From 69a160278d01bff355a6626f611c40975582fde7 Mon Sep 17 00:00:00 2001 From: jaykv Date: Mon, 12 Oct 2026 23:40:49 -0400 Subject: [PATCH 01/31] build!: restart as a uv workspace for the v2 rewrite Replaces the 0.1.x layout with a uv workspace of two distributions (mongospecs 0.2.0, mongospecs-web 0.1.0) plus an unpublished bench package. 0.1.x continues on the 0.1.x branch. - remove the synchronous v1 sources, tests, poetry files and stale reference docs - add Docker compose files for a single-node replica set and a small sharded cluster; tests run against real MongoDB, not mongomock - add CI (Python 3.11-3.15, MongoDB 7.0/8.0, free-threaded as experimental), a tag-driven publish workflow and a uv Makefile - add the design and work graph (claudedocs/v2-plan.md) and the v1 test inventory mapping all 302 v1 test runs to their v2 fate Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yaml | 55 + .github/workflows/python-app.yaml | 64 - .github/workflows/python-publish.yaml | 50 +- .gitignore | 14 +- Makefile | 52 +- README.md | 120 +- bench/pyproject.toml | 17 + bench/src/mongospecs_bench/__init__.py | 1 + claudedocs/v2-plan.md | 873 +++++++ docker/compose.rs.yaml | 23 + docker/compose.sharded.yaml | 70 + docs/index.md | 6 + docs/reference/attrs.md | 1 - docs/reference/base.md | 1 - docs/reference/bson.md | 0 docs/reference/empty.md | 1 - docs/reference/msgspec.md | 1 - docs/reference/ops.md | 1 - docs/reference/pydantic.md | 1 - docs/reference/query.md | 1 - docs/reference/se.md | 1 - docs/reference/utils.md | 1 - docs/v1-test-inventory.md | 82 + mkdocs.yaml | 13 +- mongospecs/__init__.py | 43 - mongospecs/attrs.py | 90 - mongospecs/base.py | 86 - mongospecs/helpers/__init__.py | 0 mongospecs/helpers/bson.py | 40 - mongospecs/helpers/empty.py | 12 - mongospecs/helpers/ops.py | 145 -- mongospecs/helpers/pagination.py | 157 -- mongospecs/helpers/query.py | 121 - mongospecs/helpers/se.py | 36 - mongospecs/mixins/base.py | 323 --- mongospecs/mixins/crud.py | 332 --- mongospecs/mixins/index.py | 30 - mongospecs/mixins/integrity.py | 26 - mongospecs/mixins/query.py | 92 - mongospecs/mixins/session.py | 30 - mongospecs/mixins/signal.py | 15 - mongospecs/msgspec.py | 115 - mongospecs/pydantic.py | 142 -- mongospecs/types.py | 37 - mongospecs/utils.py | 46 - packages/mongospecs-web/README.md | 6 + packages/mongospecs-web/pyproject.toml | 33 + .../src/mongospecs_web/__init__.py | 1 + .../src/mongospecs_web}/py.typed | 0 packages/mongospecs/README.md | 99 + packages/mongospecs/pyproject.toml | 39 + poetry.lock | 1495 ------------ poetry.toml | 2 - pyproject.toml | 115 +- tests/__init__.py | 0 tests/attrs/__init__.py | 0 tests/attrs/adapter_models.py | 97 - tests/attrs/fixtures.py | 69 - tests/attrs/models.py | 99 - tests/attrs/test_adapter.py | 856 ------- tests/attrs/test_frames.py | 792 ------ tests/attrs/test_pagination.py | 186 -- tests/attrs/test_spec.py | 9 - tests/msgspec/__init__.py | 0 tests/msgspec/adapter_models.py | 94 - tests/msgspec/fixtures.py | 68 - tests/msgspec/models.py | 94 - tests/msgspec/test_adapter.py | 861 ------- tests/msgspec/test_frames.py | 790 ------ tests/msgspec/test_pagination.py | 184 -- tests/msgspec/test_queries.py | 141 -- tests/msgspec/test_spec.py | 8 - tests/pydantic/__init__.py | 0 tests/pydantic/adapter_models.py | 99 - tests/pydantic/fixtures.py | 69 - tests/pydantic/models.py | 94 - tests/pydantic/test_adapter.py | 865 ------- tests/pydantic/test_frames.py | 789 ------ tests/pydantic/test_pagination.py | 184 -- tests/pydantic/test_queries.py | 141 -- tests/pydantic/test_spec.py | 8 - tests/test_base.py | 37 - tests/test_bson.py | 65 - tests/test_queries.py | 120 - tests/test_spec_transactions.py | 136 -- uv.lock | 2118 +++++++++++++++++ 86 files changed, 3630 insertions(+), 10600 deletions(-) create mode 100644 .github/workflows/ci.yaml delete mode 100644 .github/workflows/python-app.yaml create mode 100644 bench/pyproject.toml create mode 100644 bench/src/mongospecs_bench/__init__.py create mode 100644 claudedocs/v2-plan.md create mode 100644 docker/compose.rs.yaml create mode 100644 docker/compose.sharded.yaml delete mode 100644 docs/reference/attrs.md delete mode 100644 docs/reference/base.md delete mode 100644 docs/reference/bson.md delete mode 100644 docs/reference/empty.md delete mode 100644 docs/reference/msgspec.md delete mode 100644 docs/reference/ops.md delete mode 100644 docs/reference/pydantic.md delete mode 100644 docs/reference/query.md delete mode 100644 docs/reference/se.md delete mode 100644 docs/reference/utils.md create mode 100644 docs/v1-test-inventory.md delete mode 100644 mongospecs/__init__.py delete mode 100644 mongospecs/attrs.py delete mode 100644 mongospecs/base.py delete mode 100644 mongospecs/helpers/__init__.py delete mode 100644 mongospecs/helpers/bson.py delete mode 100644 mongospecs/helpers/empty.py delete mode 100644 mongospecs/helpers/ops.py delete mode 100644 mongospecs/helpers/pagination.py delete mode 100644 mongospecs/helpers/query.py delete mode 100644 mongospecs/helpers/se.py delete mode 100644 mongospecs/mixins/base.py delete mode 100644 mongospecs/mixins/crud.py delete mode 100644 mongospecs/mixins/index.py delete mode 100644 mongospecs/mixins/integrity.py delete mode 100644 mongospecs/mixins/query.py delete mode 100644 mongospecs/mixins/session.py delete mode 100644 mongospecs/mixins/signal.py delete mode 100644 mongospecs/msgspec.py delete mode 100644 mongospecs/pydantic.py delete mode 100644 mongospecs/types.py delete mode 100644 mongospecs/utils.py create mode 100644 packages/mongospecs-web/README.md create mode 100644 packages/mongospecs-web/pyproject.toml create mode 100644 packages/mongospecs-web/src/mongospecs_web/__init__.py rename {mongospecs => packages/mongospecs-web/src/mongospecs_web}/py.typed (100%) create mode 100644 packages/mongospecs/README.md create mode 100644 packages/mongospecs/pyproject.toml delete mode 100644 poetry.lock delete mode 100644 poetry.toml delete mode 100644 tests/__init__.py delete mode 100644 tests/attrs/__init__.py delete mode 100644 tests/attrs/adapter_models.py delete mode 100644 tests/attrs/fixtures.py delete mode 100644 tests/attrs/models.py delete mode 100644 tests/attrs/test_adapter.py delete mode 100644 tests/attrs/test_frames.py delete mode 100644 tests/attrs/test_pagination.py delete mode 100644 tests/attrs/test_spec.py delete mode 100644 tests/msgspec/__init__.py delete mode 100644 tests/msgspec/adapter_models.py delete mode 100644 tests/msgspec/fixtures.py delete mode 100644 tests/msgspec/models.py delete mode 100644 tests/msgspec/test_adapter.py delete mode 100644 tests/msgspec/test_frames.py delete mode 100644 tests/msgspec/test_pagination.py delete mode 100644 tests/msgspec/test_queries.py delete mode 100644 tests/msgspec/test_spec.py delete mode 100644 tests/pydantic/__init__.py delete mode 100644 tests/pydantic/adapter_models.py delete mode 100644 tests/pydantic/fixtures.py delete mode 100644 tests/pydantic/models.py delete mode 100644 tests/pydantic/test_adapter.py delete mode 100644 tests/pydantic/test_frames.py delete mode 100644 tests/pydantic/test_pagination.py delete mode 100644 tests/pydantic/test_queries.py delete mode 100644 tests/pydantic/test_spec.py delete mode 100644 tests/test_base.py delete mode 100644 tests/test_bson.py delete mode 100644 tests/test_queries.py delete mode 100644 tests/test_spec_transactions.py create mode 100644 uv.lock diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml new file mode 100644 index 0000000..b360269 --- /dev/null +++ b/.github/workflows/ci.yaml @@ -0,0 +1,55 @@ +name: CI + +on: + push: + branches: [main, "v2/**"] + pull_request: + +permissions: + contents: read + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv sync --all-packages --python 3.13 + - run: make lint + - run: make types + - run: make build + + test: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.12", "3.13", "3.14", "3.15"] + mongo: ["8.0"] + experimental: [false] + include: + - python: "3.13" + mongo: "7.0" + experimental: false + - python: "3.14t" + mongo: "8.0" + experimental: true + continue-on-error: ${{ matrix.experimental }} + env: + MONGO_VERSION: ${{ matrix.mongo }} + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Start MongoDB (replica set and sharded cluster) + run: make up + - run: uv sync --all-packages --python ${{ matrix.python }} + - run: uv run pytest + - name: Container logs + if: failure() + run: | + docker compose -f docker/compose.rs.yaml logs --tail 100 + docker compose -f docker/compose.sharded.yaml logs --tail 100 diff --git a/.github/workflows/python-app.yaml b/.github/workflows/python-app.yaml deleted file mode 100644 index dcae029..0000000 --- a/.github/workflows/python-app.yaml +++ /dev/null @@ -1,64 +0,0 @@ -# This workflow will install Python dependencies, run tests and lint with a single version of Python -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python - -name: Python library with poetry - -on: - push: - paths: - - 'mongospecs/**' - - 'tests/**' - - 'examples/**' - - 'pyproject.toml' - -permissions: - contents: read - -jobs: - build: - strategy: - matrix: - extras: [true] # false - python-version: [ "3.12", "3.11", "3.10", "3.9" ] - os: [ubuntu-latest] # windows-latest, macos-latest - defaults: - run: - shell: bash - runs-on: ${{ matrix.os }} - steps: - - uses: actions/checkout@v3 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v3 - with: - python-version: ${{ matrix.python-version }} - - name: Install Poetry - uses: snok/install-poetry@v1.3.3 - with: - virtualenvs-create: true - virtualenvs-in-project: true - - name: Load cached venv - id: cache - uses: actions/cache@v3 - with: - path: .venv - key: venv-${{ runner.os }}-${{ steps.setup-python.outputs.python-version }}-${{ hashFiles('**/poetry.lock') }}-${{ matrix.extras }} - - name: Install dependencies - run: poetry install --no-interaction --no-root - if: ${{ steps.cache.outputs.cache-hit != 'true' && matrix.extras == false }} - - name: Install dependencies - run: poetry install --no-interaction --no-root --all-extras - if: ${{ steps.cache.outputs.cache-hit != 'true' && matrix.extras }} - - name: Install library - run: poetry install --no-interaction - if: ${{ matrix.extras == false }} - - name: Install library - run: poetry install --no-interaction --all-extras - if: ${{ matrix.extras }} - - name: Code quality - run: | - source $VENV - poetry run make lint - - name: Test with pytest - run: | - source $VENV - poetry run make test \ No newline at end of file diff --git a/.github/workflows/python-publish.yaml b/.github/workflows/python-publish.yaml index dff8d10..041b955 100644 --- a/.github/workflows/python-publish.yaml +++ b/.github/workflows/python-publish.yaml @@ -1,12 +1,7 @@ -# This workflow will upload a Python Package using Twine when a release is created -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python#publishing-to-package-registries - -# This workflow uses actions that are not certified by GitHub. -# They are provided by a third-party and are governed by -# separate terms of service, privacy policy, and support -# documentation. - -name: Upload Python Package +# Publishes whichever distribution the release tag names: +# mongospecs-v0.2.0 -> packages/mongospecs +# mongospecs-web-v0.1.0 -> packages/mongospecs-web +name: Publish on: release: @@ -16,28 +11,23 @@ permissions: contents: read jobs: - deploy: + publish: runs-on: ubuntu-latest environment: name: pypi - url: https://pypi.org/p/mongospecs - # permissions: - # id-token: write + permissions: + id-token: write steps: - - uses: actions/checkout@v3 - - name: Set up Python - uses: actions/setup-python@v3 - with: - python-version: '3.x' - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install build - - name: Build package - run: python -m build - - name: Publish package - uses: pypa/gh-action-pypi-publish@27b31702a0e7fc50959f5ad993c78deac1bdfc29 - # uses: pypa/gh-action-pypi-publish@release/v1 - with: - user: __token__ - password: ${{ secrets.PYPI_API_TOKEN }} \ No newline at end of file + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - name: Pick the package from the tag + id: pick + run: | + tag="${GITHUB_REF_NAME}" + case "$tag" in + mongospecs-web-v*) echo "package=mongospecs-web" >> "$GITHUB_OUTPUT" ;; + mongospecs-v*) echo "package=mongospecs" >> "$GITHUB_OUTPUT" ;; + *) echo "unrecognised release tag: $tag" >&2; exit 1 ;; + esac + - run: uv build --package ${{ steps.pick.outputs.package }} + - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore index f347387..f10504c 100644 --- a/.gitignore +++ b/.gitignore @@ -128,4 +128,16 @@ dmypy.json # Pyre type checker .pyre/ -.vscode/ \ No newline at end of file +.vscode/ + +# uv +.venv-*/ + +# ruff +.ruff_cache/ + +# macOS +.DS_Store + +# docs build +site/ diff --git a/Makefile b/Makefile index a0d338a..e2b3e99 100644 --- a/Makefile +++ b/Makefile @@ -1,35 +1,41 @@ -check: format lint test clean - -SOURCE_FILES=mongospecs tests +.PHONY: install up down test test-fast lint format types check build docs clean install: - pip install -e . + uv sync --all-packages -test: - pytest -vv --capture=tee-sys +up: + docker compose -f docker/compose.rs.yaml up -d --wait + docker compose -f docker/compose.sharded.yaml up -d --wait -clean: - rm -rf build/ dist/ *.egg-info .*_cache - find . -name '*.pyc' -type f -exec rm -rf {} + - find . -name '__pycache__' -exec rm -rf {} + +down: + docker compose -f docker/compose.rs.yaml down -v + docker compose -f docker/compose.sharded.yaml down -v -package: clean - python -m build +test: + uv run pytest -publish: package - twine upload dist/* +test-fast: + uv run pytest -m "not sharded" format: - ruff format ${SOURCE_FILES} - ruff check ${SOURCE_FILES} --fix + uv run ruff format packages bench + uv run ruff check packages bench --fix lint: - ruff format --check --diff ${SOURCE_FILES} - ruff check ${SOURCE_FILES} - mypy mongospecs --strict - mypy tests + uv run ruff format --check packages bench + uv run ruff check packages bench -shell: - source .venv/bin/activate +types: + uv run mypy packages/mongospecs/src/mongospecs packages/mongospecs-web/src/mongospecs_web bench/src/mongospecs_bench -.PHONY: test clean \ No newline at end of file +check: lint types test + +build: + uv build --all-packages + +docs: + uv run --group docs mkdocs build --strict + +clean: + rm -rf dist build site .*_cache .venv-* + find . -name '__pycache__' -type d -prune -exec rm -rf {} + diff --git a/README.md b/README.md index 83c1254..85c4f07 100644 --- a/README.md +++ b/README.md @@ -1,49 +1,99 @@ # mongospecs -Built on top of the https://github.com/GetmeUK/MongoFrames ODM, with: -- Pydantic (`BaseModel`), attrs (`@define`), and msgspec (`Struct`) support for defining schema models (specs) -- Type-hints -- *Spec adapters* - - Build a spec out of existing BaseModels, attrs classes, or Structs -- *Raw BSON encode/decode for specs/mongo documents* +An async MongoDB ODM for the model library you already use: **msgspec**, **pydantic** or **attrs**. + +The aim is Mongoose's production feature set (change tracking, middleware, populate, +discriminators, transactions that restore document state) with typed Python models, plus +[`mongospecs-web`](packages/mongospecs-web), a framework-neutral REST layer. + +> **Status: 0.2.0, alpha.** This is a rewrite of 0.1.x: async-only, Python 3.11+, MongoDB 7.0+, +> and not backwards compatible. The synchronous 0.1.x line lives on the +> [`0.1.x` branch](https://github.com/jaykv/mongospecs/tree/0.1.x). The design and the work graph +> are in [`claudedocs/v2-plan.md`](claudedocs/v2-plan.md). + ## Install -Choose from one of the following: -* `pip install mongospecs` for msgspec only -* `pip install mongospecs[pydantic]` for msgspec and pydantic only -* `pip install mongospecs[attrs]` for msgspec and attrs only -* `pip install mongospecs[pydantic,attrs]` for msgspec, pydantic, and attrs -### Example +``` +pip install "mongospecs[msgspec]" # or [pydantic], [attrs], [all] +``` + +## A taste + ```python -# Import Spec, either... -## 1. with pydantic: -from mongospecs.pydantic import Spec +from datetime import datetime +from typing import Annotated + +from mongospecs import CreatedAt, Inc, Index, Q, Ref, Registry, Revision, SpecConfig, UpdatedAt +from mongospecs.msgspec import Spec # or mongospecs.pydantic / mongospecs.attrs + + +class User(Spec): + name: str = "" + -## 2. with msgspec: -from mongospecs.msgspec import Spec +class Article(Spec): + __spec__ = SpecConfig(collection="articles", indexes=[Index("title", unique=True)]) -## 3. with attrs: -from mongospecs.attrs import Spec + title: str + views: int = 0 + tags: list[str] = [] + author: Ref[User] | None = None + created_at: Annotated[datetime | None, CreatedAt] = None + updated_at: Annotated[datetime | None, UpdatedAt] = None + rev: Annotated[int, Revision] = 0 # optimistic locking -# Define schema model -class Dragon(Spec): - _collection = "dragons" # Optional. If not defined, uses the class name by default. - name: str - breed: Optional[str] = None +async def main(): + async with Registry("mongodb://localhost/app", models=[User, Article]) as registry: + await registry.apply_indexes() -# create -burt = Dragon(name="Burt", breed="Cold-drake") -print(burt.name) # Burt -print(burt.breed) # Cold-drake + ann = await User(name="ann").insert() + article = await Article(title="Hello", author=ann).insert() -# insert -burt.insert() -print(burt.id) # inserted document ObjectId + # change tracking: save() sends only what changed + article.title = "Hello, world" + assert article.changes() == {"$set": {"title": "Hello, world"}} + await article.save() -# fetch -doc = Dragon.find_one({"name": "Burt"}) # returns raw mongo document + # typed queries: names are checked, values are cast + popular = await Article.find(Q.views >= 100, Q.title.prefix("Hello")).sort("-views").limit(10) + + # relations: one extra query, or a $lookup + with_author = await Article.find(Q.author == ann).include("author").one() + print(with_author.author.doc.name) + + # atomic updates, always validated; the updated document comes back + article = await Article.update(article.id, Inc(views=1), tags=["intro"]) + + # transactions: the callback is retried on transient errors, and in-memory + # documents are restored if it aborts + async def publish(tx): + article.tags.append("published") + await article.save(session=tx) + + await registry.transaction(publish) +``` + +## What is in the box + +| | | +|---|---| +| Models | msgspec `Struct`, pydantic `BaseModel` or attrs classes, through one `Codec` seam; `SpecAdapter` for classes you already have | +| Reads | immutable chainable queries, `Q` expressions, server-side projection, `tracked` / `lean()` / `raw()` modes, cursor and offset pagination | +| Writes | delta `save()`, atomic operators (`Inc`, `Push`, `Pull`, ...), real upsert, typed `bulk_write`, `bulk_save`, partial-failure `insert_many` | +| Relations | `Ref[T]`, lists and dicts of refs, polymorphic refs, reverse relations (`Backref`), nested `include()`, `$lookup` or batched loading, cascade rules, `$graphLookup`, `$unionWith` | +| Plugins | timestamps, soft delete, revisions - activated by field markers; registry-wide plugins; document hooks | +| Modelling | discriminators (single-collection inheritance), deferred and immutable fields, aliases, lazy upcasting of old documents | +| Operations | explicit `Registry` lifecycle, transactions, index planner with gated destructive actions, `$jsonSchema` validators, change streams, multi-database use, shard-key aware writes | +| Observability | per-command events with model and operation names; logging, OpenTelemetry and Sentry listeners | + +## Development -# delete -burt.delete() ``` +make install # uv sync for the whole workspace +make up # MongoDB replica set + sharded cluster in Docker +make check # ruff, mypy --strict, tests against real MongoDB +``` + +The repository is a uv workspace: `packages/mongospecs`, `packages/mongospecs-web` and `bench/` +(benchmarks and stress scenarios against real MongoDB, never published). diff --git a/bench/pyproject.toml b/bench/pyproject.toml new file mode 100644 index 0000000..2a7bb90 --- /dev/null +++ b/bench/pyproject.toml @@ -0,0 +1,17 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mongospecs-bench" +version = "0.0.0" +description = "Benchmarks and stress scenarios for mongospecs against real MongoDB. Not published." +requires-python = ">=3.11" +classifiers = ["Private :: Do Not Upload"] +dependencies = ["mongospecs[all]", "mongospecs-web[all]", "httpx>=0.27"] + +[project.scripts] +mongospecs-bench = "mongospecs_bench.cli:main" + +[tool.hatch.build.targets.wheel] +packages = ["src/mongospecs_bench"] diff --git a/bench/src/mongospecs_bench/__init__.py b/bench/src/mongospecs_bench/__init__.py new file mode 100644 index 0000000..47abfe0 --- /dev/null +++ b/bench/src/mongospecs_bench/__init__.py @@ -0,0 +1 @@ +"""Benchmarks and stress scenarios for mongospecs.""" diff --git a/claudedocs/v2-plan.md b/claudedocs/v2-plan.md new file mode 100644 index 0000000..ba1c40e --- /dev/null +++ b/claudedocs/v2-plan.md @@ -0,0 +1,873 @@ +# mongospecs v2 — Overhaul Plan + +Status: **revision 3, for review** · Date: 2026-10-12 +Authors: Claude (Sonnet 5.5, draft 1; Opus 5.5, revisions 2–3) with Jay +Scope: full rewrite, breaking changes allowed everywhere. Async-only. Python 3.11–3.15. + +> Revision 3 changes: the target is now stated as **Mongoose + mongospecs + RESTo** (§1). Mongoose +> is added as the reference ODM (§3.3) with a parity table, a do-not-inherit list and a tiebreak +> rule (§7.1 rule 9). Consequences: snapshot-based change tracking replaces "no dirty tracking"; +> hooks gain the aggregate and bulk kinds; discriminators, reverse relations, query casting, +> deferred fields, named scopes, bulk save, registry-level plugins, multi-database use, shard-key +> aware writes and command observability are added; Mongoose joins the benchmark comparators. + +> Revision 2 changes: `Store` layer removed; plugins and field policy moved to `Annotated` markers; +> transactions redesigned around the callback API; derived view classes replaced by policy-as-data; +> web layer split into its own package in a uv workspace; entry-point discovery dropped; Rust +> removed from the build plan and turned into a decision fed by a real-MongoDB benchmark track; +> phases replaced by a dependency-ordered work graph (§7) written for agent execution. +> +> Claims are tagged **[probed]** when a script was run for this revision, **[read]** when verified by +> reading the repo, and **[unverified]** otherwise. §10 lists everything unverified. + +--- + +## 1. Goals + +**Vision: Mongoose + mongospecs + RESTo.** + +| From | What it contributes | +|---|---| +| Mongoose | The production feature set and ergonomics of a mature ODM: change tracking, four kinds of middleware, populate, discriminators, query casting, lean reads, bulk operations, index sync, transactions that restore document state | +| mongospecs | Typed models on the backend you already use (msgspec, pydantic, attrs), `Q` expressions, small readable core | +| RESTo | Declarative resources: field policy drives the HTTP surface, mounted on any framework | + +Mongoose is also the **fallback**: where this document does not decide a behaviour, v2 does what +Mongoose does, unless the behaviour is on the do-not-inherit list in §3.3. Mongoose is likewise the +reference point for "efficient and production ready" in the benchmark track (§6). + +Concretely, a performant, readable, pluggable async ODM for MongoDB plus a framework-neutral web layer: + +1. Multi-backend models (msgspec, pydantic, attrs) behind one `Codec` seam. +2. Plugins that carry behaviour (timestamps, soft delete, revisions) across all three backends. +3. Mongo-native relationship loading (`$lookup`, `$graphLookup`, `$unionWith`) with a measured + fallback to batched `$in`. +4. A **Resource** layer (RESTo's controller idea, no globals) with plugs for a pure ASGI app, + Starlette, FastAPI, Litestar and Quart. Shipped as `mongospecs-web`, a separate distribution + in this repo. This is a first-class deliverable, not an add-on. +5. A stress and benchmark suite that runs against real MongoDB and produces the evidence for + every performance decision, including whether Rust is ever warranted. + +Non-goals: sync API, SQL or other databases, Motor, Python < 3.11, entry-point plugin discovery, +any Rust code inside this plan (see §6). + +## 2. Where we are + +### 2.1 What `mixins-test` is [read] + +A structural refactor of `main`: `base.py` split into +`mongospecs/mixins/{base,crud,query,index,integrity,session,signal}.py`, composed as +`SpecBase(CrudMixin, IndexManagementMixin, IntegrityMixin, SessionTransactionMixin, SignalMixin)`; +helpers moved to `mongospecs/helpers/`. Six commits ahead of `main`. Uncommitted on top: +`mongospecs/exceptions.py` (new), `retry`/error decorators in `utils.py`, pagination/docs edits. + +It is a method-grouping refactor over one sync base class with class-level state, not a plugin +system: mixins hold no state, add no fields, cannot be opted into per model. Source is 2,035 +lines; the test suite is about 290 tests in 6,955 lines, all on `mongomock`. + +### 2.2 Defects in current code [read] + +| Area | Issue | +|---|---| +| Global state | `_client`, `_db`, `_collection_context` are class-level mutables; `with_options()` swaps a class attribute, unsafe under asyncio and free-threading | +| Transactions | `transaction()` yields a session but no CRUD call uses it unless `session=` is hand-passed; only `ConnectionFailure`/`OperationFailure` trigger an explicit abort | +| Transaction tests | `tests/test_spec_transactions.py` runs against `MagicMock`; there is no real transaction coverage | +| `exceptions.py` | `class DuplicateKeyError(MongoSpecError, DuplicateKeyError)` shadows the pymongo import, so pymongo's error falls through to the `OperationFailure` branch, which constructs `OperationError` with kwargs its `__init__` does not accept; wrapper lacks `functools.wraps` | +| `utils.py` | Redefines `ConnectionError`/`OperationError`/`DatabaseError` (shadows a builtin, duplicates `exceptions.py`); `retry()` retries `OperationFailure` including duplicate-key on non-idempotent inserts and sleeps with blocking `time.sleep` | +| Dead code | None of `retry`, `handle_database_errors`, `handle_pymongo_errors` is applied anywhere, so the bugs above are latent | +| API naming | `find`/`find_one` return raw dicts (`{}` on miss); `one`/`many` return specs | +| Soft delete | Hardcodes an undeclared `deleted` field; `find_active` mutates the caller's filter dict | +| Consistency | pydantic `from_document` uses `model_construct` (no validation) but `many` uses `cls(**d)`; pydantic `unset()` takes no kwargs so cannot receive a session | +| Duplication | `Spec`/`AdapterBuilder`/`MsgspecAdapter` repeat bodies per backend; `_id` vs `id` hacks | +| Layering | `helpers/pagination.py` and `helpers/bson.py` import `base.py` | +| Updates | `upsert` is read-then-write; `update` `$set`s the whole doc; no optimistic locking | +| Counting | `count()` with no filter uses `estimated_document_count`, which ignores any scope | +| Collection lookup | `getattr(db, name)` breaks for names starting with `_` or colliding with Database attrs | +| Tooling | Python `^3.9`, poetry, pytest 7, ruff 0.3, `mongomock`, CI 3.9–3.12 | + +The existing suite has not been run (no `.venv`). Node `F3` establishes that baseline. + +## 3. Reference projects (cloned and read for draft 1) + +### 3.1 Mongoz (dymmond/mongoz, ~7.9k LOC, pydantic-only, py3.10–3.14, pymongo>=4.13) + +Take as ideas, do not mirror structure or API. + +Worth adopting: +- Index reconciliation planner (`indexes.py`): typed actions `CORRECT/CREATE/RECREATE/RETAIN/DELETE/CONFLICT`; + normalises materialised text indexes (`_fts/_ftsx`) to avoid false diffs; destructive ops gated. +- Explicit lifecycle: no I/O at import, `async with Registry`, closed registry is final, documented + event-loop binding. +- Immutable clone-on-write queries; `update` (atomic `$set` patch) vs `save` split; + `DocumentNotFound` on acknowledged writes that matched nothing. +- No ambient session: explicit `session=`. A ContextVar session would be copied into + `gather`/`TaskGroup` children, letting concurrent tasks share one `ClientSession`. +- Trusted-vs-untrusted boundary for raw queries; string helpers are literal-matching by default. +- Release gates: wheel validation, replica-set transaction proof, consumer typing fixtures. + +Verified weaknesses to beat: + +| Finding | Evidence | +|---|---| +| `only()`/`defer()` projections are client-side only; full docs are fetched | no `$project`/`projection=` sent anywhere; `from_row` filters the dict | +| Every read is an `aggregate`, even plain finds | `manager._cursor()` always calls `aggregate(self._pipeline())` | +| `$lookup` stages are emitted before `$match`/`$sort`/`$limit` | `_pipeline()` order | +| Joined results keyed by collection name, not field name | `item[related_meta.collection.name] = values` | +| Bulk `update` = find ids then `update_many` on `_id $in` (2 round trips, racy) | `persistence.patch_many` | +| Full pydantic validation on every read, no opt-out | `from_row` ends `cls(**item)` | +| No `$graphLookup`/`$unionWith`/change streams | grep found none | +| No web/CRUD-controller layer | only lilya/ravyn integration tests | +| Global `settings` singleton | `mongoz.settings` | + +Caveat: ~60% of the source read; `metaclasses.py` and `fields/core.py` not studied; its tests not run. + +### 3.2 RESTo (jaykv/RESTo, 1.3k LOC, last commit 2022-08-30) + +Concepts to keep: +1. `ActionsConnector`: model-side contract (fetcher/inserter/updater/deleter). +2. `App` ABC per framework: `build_app / load_controller / load_controllers`. +3. `Route` subclasses bound to generators; `execute=(fn, params)` escape hatch; `pipes=[...]`. +4. "Farms": per-field flags (`Insertable/Updatable/Filterable/private/alias/ref/sub`) drive the + Create/Update/Filter surface. The most valuable idea. +5. `ActionRouter(model, Fetcher=, Inserter=, …)` auto-CRUD; `{data, messages}` envelope. + +Defects not to inherit: global singletons; `build_filter_query` is a `# TODO` pass-through; +`deleter` uses the request JSON body as the filter; `inner_patch` ignores the `<_id>` path param; +`Route.validate` iterates `self.pipes` when `None`; stray `print`. + +### 3.3 Mongoose (Automattic/mongoose, Node.js) — the reference ODM + +Source for this section: Mongoose documentation. Items tagged **[docs]** were re-read for this +revision; the rest is from memory of the 8.x/9.x docs and must be confirmed by spike `S7` before +the owning node starts. Mongoose's source and test suite have not been read. + +**Parity table.** "v2 form" is how the feature is expressed here; "Node" is where it is built (§7.2). + +| Mongoose feature | What it does | v2 form | Node | +|---|---|---|---| +| Change tracking, delta `save()` | `save()` sends only modified paths; `isModified`, `modifiedPaths`, `isNew` | Snapshot taken at load; `save()` diffs and sends minimal `$set`/`$unset`; `doc.changes()`, `doc.is_new` (§4.5) | K12 | +| `lean()` | Skip hydration and tracking, return plain objects | `.lean()` → backend objects with no snapshot; `.raw()` → dicts | K12 | +| `Model.hydrate()` | Build a tracked document from a raw object | `Model.hydrate(doc)` | K12 | +| Middleware, four kinds **[docs]** | document, query, aggregate, model | Document hooks, command/filter transformers, pipeline transformers, bulk hooks (§4.3) | C4, K7 | +| `schema.plugin()` and global `mongoose.plugin()` | Per-schema and all-schema plugins | Marker-activated plugins plus `Registry(plugins=[...])` applied to every bound model | K7 | +| `timestamps` | `createdAt`/`updatedAt`, also on updates and bulk writes | `CreatedAt`/`UpdatedAt` markers, applied by transformers on every write path incl. bulk | K8 | +| `optimisticConcurrency`, `versionKey` | Version check on save | `Revision` marker | K8 | +| `populate()` | Batched follow-up queries (not `$lookup`); `match`, `select`, `limit`, `perDocumentLimit`, nested paths | `include()` with the same options, both strategies (§4.6) | R1 | +| Virtual populate | Reverse relations via `localField`/`foreignField`, `justOne`, `count` | `Backref[...]` marker fields, never stored | R7 | +| `refPath` | Reference whose target collection is named by another field | `Ref[A \| B]` with a discriminating field | R7 | +| Discriminators **[docs: `discriminatorKey` exists]** | Several models in one collection, keyed by a discriminator field | Tagged unions per backend: msgspec `tag=`, pydantic discriminated unions, attrs via the Codec | K13 | +| Query casting | Filter and update values are cast to schema types | `Q` values coerced through `Codec.validate_field` for the addressed field | K14 | +| `strictQuery: 'throw'` **[docs]** | Unknown filter paths raise | The only mode: a typed query naming an unknown field raises at build time | K14 | +| `sanitizeFilter` / `trusted()` | Block operator injection in filters built from user input | Raw mappings are untrusted by default; `trusted(...)` wraps deliberate raw filters | C2 | +| `select: false` **[docs]** | Field not fetched unless asked for | `Deferred` marker; excluded from the default projection; `.select("+field")` | K5 | +| `immutable` | Field cannot change after insert | `Immutable` marker, enforced in the ODM (not only in `Policy`) | K5 | +| `alias` | Short stored name, long attribute name | Codec rename; queries translate attribute names to stored names | C1 | +| Getters/setters, custom validators | Per-path transforms and checks | The backend's own converters and validators; nothing new | — | +| Virtuals | Computed, non-stored properties | Python properties; inclusion in the Read view is a `Computed` marker | W2 | +| Statics, methods | Model-level and instance-level functions | Plain classmethods and methods | — | +| Query helpers | Chainable named filters (`.byName()`) | Named scopes registered on the model's query type | K16 | +| `orFail()` | Reject when nothing matched | `.or_fail()` raising `DocumentNotFound` | K5 | +| `cursor()` / `eachAsync({parallel})` | Streaming with bounded concurrency | `async for` plus `.each(fn, concurrency=n)` | K5 | +| `bulkWrite`, `bulkSave`, `insertMany({ordered:false})` | Typed bulk ops with casting and timestamps; batch of delta saves; partial-failure reporting | `Model.bulk_write([...])`, `Model.bulk_save(docs)`, `insert_many(ordered=False)` returning per-item errors | K15 | +| `Connection.transaction(fn)` **[docs]** | Wraps `withTransaction`, retries, and **resets document state** when the transaction aborts | `registry.transaction(fn)`; tracked documents touched inside are restored on abort and before each retry | K6, K12 | +| `transactionAsyncLocalStorage` **[docs]** | Opt-in ambient session inside `transaction()` | Not adopted; sessions stay explicit (§3.1 reasoning). Recorded as a deliberate divergence | — | +| `syncIndexes`, `diffIndexes`, `autoIndex: false` | Reconcile declared and actual indexes; no auto-build in production | Index planner; nothing is built implicitly | K9 | +| `shardKey` schema option | Shard key added to the filter of document writes | `SpecConfig(shard_key=...)`; document writes target a shard | K18 | +| `createConnection`, `useDb` | Several connections; same pool, different database | Several registries; `Model.using(db="tenant_a")` | K18 | +| `collation`, `read`, `writeConcern`, `maxTimeMS`, `hint`, `comment`, `explain` | Per-schema and per-query driver options | `SpecConfig` defaults plus pass-through on every query; registry-wide default `max_time_ms` | K5 | +| `Model.aggregate()`, `Model.collection` | Pipeline builder; raw driver collection | `Model.aggregate(pipeline, as_=T)` (scopes applied by pipeline transformers); `Model.collection` | K5 | +| `mongoose.set('debug')` | Log every command | `Registry(on_command=...)` built on pymongo command monitoring; logging and OpenTelemetry adapters | K17 | +| `Model.watch()` | Change streams | `Model.watch()` | R5 | +| `Model.exists`, `countDocuments`, `estimatedDocumentCount` | Explicit cheap checks and counts | `exists`, `count`, `estimated_count`; no silent switch between the last two | K5 | +| Timeseries, capped, TTL (`expires`) collection options | Collection-level options | `SpecConfig` options consumed by the planner | K9 | +| Maps, subdocuments, arrays of subdocuments | Nested structures with their own tracking | Nested models and `dict[str, T]` of the same backend; tracked by the parent's snapshot | K12 | + +**Do not inherit.** + +| Mongoose behaviour | Why not | v2 instead | +|---|---|---| +| Global default connection and global model registry by name | Hidden state, test isolation problems | Explicit `Registry`; string forward refs (`Ref["User"]`) resolved per registry | +| `bufferCommands` (queue operations until connected) | Hides connection failures until a timeout | Operations fail immediately when the registry is not open | +| `strictQuery` stripping unknown filter paths **[docs]** | `find({typo: 1})` becomes `find({})` | Unknown fields raise | +| `strict` silently stripping unknown update paths **[docs]** | Typos vanish | Unknown fields raise | +| Update validators off unless `runValidators`, with a different `this` **[docs]** | Unvalidated writes by default | Atomic updates always validate each supplied field | +| `findOneAndUpdate` returning the pre-update document by default | Common surprise | Returns the updated document; `.returning("before")` to opt out | +| `Model.aggregate()` results uncast and unscoped | Bypasses soft delete and typing unless a plugin hooks it | Scopes apply through pipeline transformers; `as_=` decodes | +| Auto-building indexes at startup | Unsafe on large collections | Planner, explicit apply | +| Implicit pluralised collection names | Surprising | Collection name is explicit or the class name, as in v1 | +| Overloaded signatures and option bags | Hard to type | Keyword-only options, one meaning per method | + +## 4. Architecture + +``` +Spec what a document is pydantic | msgspec | attrs class + Annotated markers +Codec how it crosses the wire one implementation per backend +Model ops what you can do to it ODM directly on pymongo async (no Store indirection) +Resource what you expose of it operations + policy + scope + guards (pure data) ┐ mongospecs-web +Plug where it is mounted asgi | starlette | fastapi | litestar | quart ┘ +``` + +`Store` from draft 1 is gone. A five-method protocol could not carry sessions, atomic operators, +filter-based bulk writes or upserts, and a faithful `MemoryStore` would have been a second +mongomock. The ODM talks to pymongo; the web layer defines the one narrow protocol it needs (§4.9). + +### 4.1 Repository layout (uv workspace) + +``` +pyproject.toml # virtual workspace root: members, shared ruff/mypy/pytest config +uv.lock +packages/ + mongospecs/ # distribution: mongospecs import: mongospecs + pyproject.toml # deps: pymongo; extras: [msgspec] [pydantic] [attrs] + src/mongospecs/ + registry.py errors.py codec.py markers.py query.py model.py + relations.py indexes.py tx.py hooks.py pagination.py + plugins/ timestamps.py soft_delete.py revision.py + backends/ msgspec.py pydantic.py attrs.py # implement Codec only + tests/ + mongospecs-web/ # distribution: mongospecs-web import: mongospecs_web + pyproject.toml # deps: mongospecs; extras: [starlette] [fastapi] [litestar] [quart] + src/mongospecs_web/ + resource.py operations.py policy.py filters.py errors.py openapi.py backend.py + plugs/ asgi.py starlette.py fastapi.py litestar.py quart.py + testing.py # FakeBackend + the plug contract suite, importable by third parties + tests/ +bench/ # workspace member, never published (see §6) +docker/ # compose files: replica set, sharded cluster +docs/ +``` + +- Two distributions, one lockfile, one CI. `mongospecs-web` depends on `mongospecs` via + `[tool.uv.sources] mongospecs = { workspace = true }`. +- Import name is `mongospecs_web`, not a `mongospecs.web` namespace package: namespace packages + across distributions would forbid `mongospecs/__init__.py`. +- Versions move independently; `mongospecs-web` pins a compatible `mongospecs` range. +- Third-party plugs and plugins are ordinary imports. No entry points. + +### 4.2 Spec, markers and field policy + +Field policy and plugin bindings are `Annotated` metadata. One mechanism works unchanged on all +three backends and on Python 3.13 and 3.15 **[probed]**; a `field(insert=False)` helper would have +needed to return a different object per backend. + +```python +class Article(Spec): # Spec = the backend's base (msgspec shown; same for others) + __spec__ = SpecConfig(collection="articles", indexes=[Index("title", unique=True)]) + + id: ObjectId | None = None + title: Annotated[str, Policy(filter=("eq", "prefix"), sort=True)] + author: Annotated[Ref[User], Policy(filter=("eq", "in"))] + secret: Annotated[str, Policy(private=True)] + created_at: Annotated[datetime | None, CreatedAt, Policy(insert=False, update=False)] = None + updated_at: Annotated[datetime | None, UpdatedAt, Policy(insert=False, update=False)] = None + deleted_at: Annotated[datetime | None, DeletedAt, Policy(insert=False, update=False)] = None + rev: Annotated[int, Revision, Policy(insert=False, update=False)] = 0 +``` + +- `Policy` is plain data: `insert`, `update`, `private`, `filter` (allowed operators), `sort`. + The default for an unmarked field is readable, insertable, updatable, not filterable, not sortable. +- The Python attribute is `id`; storage is `_id`; the Codec owns the mapping. The id type comes + from the annotation (`ObjectId`, `str`, `UUID`, `int`). +- Model config is a `ClassVar` (`__spec__`), not class keyword arguments, so no custom metaclass is + needed on any backend **[probed]**. +- Core markers besides the plugin ones: `Immutable` (rejected in any update), `Deferred` (left out + of the default projection, Mongoose's `select: false`), `Alias("t")` (stored name), + `Computed` (a property included in the Read view), `Backref[...]` (reverse relation, never stored). +- `Ref["User"]` string forward references are resolved against the registry at bind time, which + removes circular-import pressure without a global model registry. +- Discriminators: a base model declares `SpecConfig(discriminator="kind")`; subclasses share the + collection and are selected on decode by tagged-union support in the backend (msgspec tagged + unions decode correctly **[probed]**; pydantic and attrs go through the Codec). Queries on a + subclass add the discriminator to the filter. + +### 4.3 Plugins + +Draft 1's `class Article(Spec, Timestamps, SoftDelete, Revision)` cannot work: two field-bearing +bases raise `TypeError: multiple bases have instance lay-out conflict` on msgspec and on slotted +attrs, and a plain mixin's fields are silently dropped by msgspec **[probed]**. + +So plugins never add fields. The user declares the field and marks it (`CreatedAt`, `DeletedAt`, +`Revision`); the marker activates the plugin for that model. Benefits: fields are visible to type +checkers, field names are the user's choice, and there is one implementation per plugin. + +A plugin contributes any of the following. The first four rows are Mongoose's four middleware +kinds (document, query, aggregate, model) with typed signatures. + +| Contribution | Mongoose kind | Shape | Example | +|---|---|---|---| +| Document hook | document | `async (OpCtx, doc) -> None` | slugify before insert | +| Command transformer | query | `(OpCtx, update_doc) -> update_doc` | `UpdatedAt` adds `$currentDate`; `Revision` adds `$inc` and a `rev` filter | +| Filter transformer (scope) | query | `(OpCtx, filter) -> filter` | `DeletedAt` adds `deleted_at: None` | +| Pipeline transformer | aggregate | `(OpCtx, pipeline) -> pipeline` | soft-delete `$match` prepended to `Model.aggregate()` | +| Bulk hook | model | `async (OpCtx, ops) -> None` | audit log for `insert_many` / `bulk_write` | +| Index contribution | — | `list[Index]` | partial unique index excluding soft-deleted | + +Plugins attach in two ways: by marker on a field (per model), or as `Registry(plugins=[...])`, +which applies to every model bound to that registry (tenant scoping, auditing). + +Rules: +- `OpCtx` carries `session`, `registry`, `model`, `op`. Every hook and transformer receives it, so + cascades and hooks participate in the caller's transaction. +- Document hooks fire only for document-level operations (`insert`, `save`, `delete` on an + instance). Filter-based `update_many`/`delete_many` run command and filter transformers only; + this is documented, not papered over by loading documents. +- Scopes apply to finds, counts, updates, deletes and inside `$lookup` sub-pipelines for the + joined model. `.unscoped()` bypasses them explicitly. +- Hooks are async, sequential, fail-fast, in registration order, per class. Blinker is removed. +- A missing or mistyped marked field fails at class registration, not at first write. + +### 4.4 Codec (backend seam) + +```python +class Codec(Protocol): + def fields(self, cls) -> Mapping[str, FieldInfo] # name, type, markers, default, required + def decode(self, cls, doc: Mapping | bytes, *, trusted: bool = False) -> Any + def encode(self, obj) -> dict # BSON-ready + def validate_field(self, cls, name: str, value) -> Any # one field, with the model's own validators + def json_schema(self, cls) -> dict +``` + +- Each backend implements this once; `SpecAdapter` over existing classes becomes one generic builder. +- `decode` accepts `bytes` so a raw-BSON path can be added later without changing the seam. +- **No derived view classes.** A `create_model`-style Patch class drops the source model's + validators **[probed]** and is invisible to type checkers. Instead: + - Create: filter input keys by `Policy.insert`, then `decode` with the real model. + - Patch: filter by `Policy.update`, then `validate_field` per supplied key. "Absent" is a missing + key, "null" is an explicit `None`; no backend needs an UNSET sentinel. + - Read: `encode`, then drop `private` fields. + - JSON Schema for each view is produced by transforming `json_schema(cls)` (drop or + de-require properties). One algorithm, three backends. +- **Read validation:** reads validate by default. `trusted=True` (per query or `SpecConfig`) + skips validation where the backend allows it. The cost is measured in §6, not assumed. + +### 4.5 Model operations + +- Query builder: immutable, clone-on-write, compiled from a small AST (`filter`, `sort`, `page`, + `select`, `include`). v1's `Q` expression syntax (`Q.title == "x"`) is kept as the way to build + filters; `And/Or/Nor/In/...` helpers are kept. +- **Query casting and strictness** (Mongoose's casting, without its stripping): values in a typed + filter or update are coerced through `Codec.validate_field` for the addressed field (a string + becomes an `ObjectId`, a date string a `datetime`). A field name the model does not have raises + when the query is built. Raw mappings are untrusted unless wrapped in `trusted(...)`. +- **Named scopes** (Mongoose query helpers): `@Article.scope` registers a chainable filter, + `Article.find().published().by(author)`. +- Reads: `find` when there are no joins, otherwise an aggregation pipeline. Projections are sent + server-side. `Deferred` fields are excluded unless selected. `.or_fail()`, `.first()`, `.all()`, + `async for`, `.each(fn, concurrency=n)`, `.explain()`. +- Read modes: + + | Mode | Returns | Validated | Tracked | + |---|---|---|---| + | default | model instances | yes | yes | + | `.lean()` | model instances | per `trusted` setting | no | + | `.raw()` | dicts | no | no | + +- **Change tracking by snapshot.** When a document is loaded in the default mode, its encoded form + is kept in a side table keyed by object identity and released by a weakref finaliser. This works + on pydantic, slotted attrs and msgspec, provided the msgspec `Spec` base is declared with + `weakref=True` (inherited by subclasses) **[probed]**. No setattr interception is needed, so the + three backends share one implementation. + - `doc.changes()` → the diff; `doc.is_new`; `doc.reload()`. + - `await doc.save()` sends only the diff as `$set`/`$unset`. An unchanged document sends nothing. + - A changed list or dict is sent whole (`$set` of that path). Per-element array operators are + available through explicit atomic updates, not inferred from the diff. + - Cost is one extra encoded copy per tracked document; `.lean()` exists to avoid it. The + overhead is measured (B8), not assumed. +- Writes: + - `await doc.insert()`, `await doc.save()` (delta, as above), `await doc.delete()`. + - `await Model.update(id | filter, **fields)` and typed operators (`inc`, `push`, `pull`, + `set_on_insert`, `unset`) → atomic, via `find_one_and_update`/`update_many`. Returns the + updated document. Every supplied field is validated; `Immutable` fields are rejected. + - Real `upsert` (single round trip). + - `Model.bulk_write([...])` with typed operations, casting and timestamps; + `Model.bulk_save(docs)` sends each document's delta in one round trip; + `insert_many(ordered=False)` reports per-item failures instead of raising on the first. + - `DocumentNotFound` on acknowledged writes that matched nothing. + - With `SpecConfig(shard_key=...)`, document-level writes include the shard key in the filter. +- Escape hatches: `Model.aggregate(pipeline, as_=T)` (pipeline transformers still apply scopes) + and `Model.collection` for the raw driver collection. +- Driver options (`max_time_ms`, `hint`, `comment`, `collation`, read/write concern, read + preference) pass through on every query; defaults come from `SpecConfig`, then the registry. + `Model.using(db=..., read_preference=..., write_concern=...)` returns a rebound clone. +- Unknown fields in the database are ignored on read and preserved on write. +- **Schema evolution:** optional `SpecConfig(upcast=fn)` where `fn(doc: dict) -> dict` runs before + `decode`. Enough for lazy migration of renamed or missing fields; no migration framework. +- v1 verbs are replaced: `get(id)`, `find(...)` (async iterator / `.all()` / `.first()`), `count`. + Nothing returns raw dicts unless `.raw()` is requested. + +### 4.6 Relationships + +`include()` compiles to one pipeline; results are keyed by field name. + +1. Page-narrowing stages (`$match`, `$sort`, `$skip`, `$limit`) before joins. +2. Equality joins use `localField`/`foreignField`; `plan_indexes()` flags a missing foreign-key index. +3. Filtered or limited children use `let` + `pipeline` with `$expr`. +4. To-one relations: `$unwind` with `preserveNullAndEmptyArrays`. +5. Hierarchies: `$graphLookup` (`maxDepth`, `restrictSearchWithMatch`, `depthField`). +6. Polymorphic or multi-collection reads: `$unionWith`. +7. `include(..., strategy="auto"|"lookup"|"batch")`. Both strategies are implemented. **The default + and the `auto` heuristic are chosen from benchmark data (§6), not presumed.** `$lookup` duplicates + child payload per parent and each result document must fit 16MB. Mongoose's `populate()` is the + batch strategy, which is a reason not to assume `$lookup` wins. +8. Cascade rules (`on_delete = cascade|nullify|pull`) are hooks and require a transaction. +9. Populate-level options on either strategy: `match`, `select`, `sort`, `limit`, + `per_parent_limit`, and nested paths (`include("author.company")`). +10. Reverse relations (`comments: Backref[list[Comment], "article"]`) and counts + (`Backref[int, ...]` with `count=True`) are loaded only when included and are never stored. +11. Polymorphic references (`Ref[Post | Video]`) resolve the target per document from a + discriminating field (Mongoose `refPath`). + +Pagination: cursor (keyset) pagination is the default and returns no total. Offset pagination is +available. A total is a concurrent `count_documents`, requested explicitly. `$facet` for +page-plus-total is **not** used unless spike `S2` shows it is safe: its output is believed to be a +single document subject to the 16MB limit **[unverified]**. + +Also in scope: `Revision` optimistic locking (doubles as ETag), derived `$jsonSchema` validators +via `collMod` (opt-in), TTL/partial/unique/text/Atlas Search/vector index types, change streams +(`async for ev in Model.watch()`). + +### 4.7 Lifecycle, sessions, transactions, errors + +- `Registry(uri)` owns one `AsyncMongoClient`; `async with`; closing is final; no I/O at import. + The client binds to the first event loop that uses it. +- Models bind to a registry explicitly (`registry.bind(Article)` or `Registry(models=[...])`). + No class-level client. +- Transactions. `AsyncClientSession.with_transaction(callback, ...)` is callback-based + **[probed, pymongo 4.18.3]**, because retrying means re-running the body. Two APIs, both explicit: + - `await registry.transaction(fn)` where `fn(session)` is re-runnable. Gets pymongo's + `TransientTransactionError` and unknown-commit-result retries. **This is the recommended form.** + - `async with registry.session() as s, s.start_transaction(): ...` for manual control. No body + retry; documented as such. +- Like Mongoose's `Connection.transaction()`, `registry.transaction(fn)` restores the tracked state + (snapshot, `is_new`, assigned id) of every document written inside it when the transaction aborts + and before each retry, so in-memory documents never claim writes that were rolled back. +- Every model operation takes `session=`. Nothing is ambient. +- Observability: `Registry(on_command=...)` receives started/succeeded/failed events from pymongo + command monitoring, with model and operation names attached. Logging and OpenTelemetry adapters + ship in core; this is the equivalent of `mongoose.set('debug')`. +- Operations on a registry that is not open fail immediately. There is no command buffering. +- One error hierarchy that wraps and never shadows pymongo's; duplicate-key field extraction; + no library-level retry beyond what the driver does (retryable reads/writes, `with_transaction`). + +### 4.8 Resource (in `mongospecs-web`) + +```python +articles = Resource( + Article, + ops=[List, Get, Create, Patch, Delete], + scope=lambda ctx: Q.author == ctx.principal.id, # row-level: ANDed into every op's filter + guards={Delete: is_admin}, # op-level: boolean + page=Cursor(default=20, max=100), +) + +@articles.op("publish", method="POST", path="/{id}/publish") +async def publish(ctx: Ctx[Article]) -> Article: ... + +@articles.after(Create) +async def notify(ctx, result): ... +``` + +- `Operation` = name, method, path, input/output JSON Schema, async handler, guards. Pure data, + no framework imports, testable without a server. +- Authorization has two parts. `guards` answer "may this principal call this op". `scope` returns + a filter that is ANDed into List, Get, Patch and Delete, so row ownership is enforced in the + query rather than by load-then-check. +- `Ctx.principal` is produced by an `authenticate(request) -> principal` callable supplied when the + plug is mounted. The Resource never inspects framework request objects. +- Resource owns: query-param → filter parsing (allowlist from `Policy`, bounded operator set + `eq ne in nin gt gte lt lte exists prefix`), body validation via Codec, pagination, PATCH + semantics, ETag from `Revision`, RFC 9457 errors, OpenAPI. Raw mappings never reach Mongo. + +### 4.9 ResourceBackend and plugs + +```python +class ResourceBackend(Protocol[T]): + async def get(self, id, *, scope) -> T | None + async def list(self, query, *, scope) -> Page[T] + async def create(self, data: dict) -> T + async def patch(self, id, data: dict, *, scope, if_rev=None) -> T + async def delete(self, id, *, scope) -> None +``` + +- `mongospecs` models satisfy it through a thin adapter; that is the only coupling between the + two packages. +- `mongospecs_web.testing.FakeBackend` is an in-memory double for the plug contract suite. It only + has to evaluate the bounded operator set above, so it cannot drift into a database emulator. + +```python +class Plug(Protocol): + def mount(self, app, resource: Resource, *, prefix: str, authenticate) -> None: ... + def lifespan(self, registry: Registry): ... +``` + +| Plug | Notes | +|---|---| +| ASGI | `resource.asgi_app()`; the reference implementation the others are checked against | +| Starlette | Routes + lifespan | +| FastAPI | `APIRouter`; `response_model` is pydantic-only, so schema is injected via `responses=`/`openapi_extra` and bytes are returned | +| Litestar | Plugin + generated `Controller` | +| Quart | Blueprint + `before_serving`; no native validation, core validation carries it | + +Matrix control: one parametrised contract suite; every plug × backend must produce the same +status codes, bodies, error documents and OpenAPI for the same cases. + +### 4.10 AI-SDK / MCP (parked) + +An `Operation` with in/out JSON Schema is already a tool definition. A later `mongospecs_web.plugs.mcp` +can expose a Resource with identical guards, scope and field policy. Only constraint now: keep +descriptors pure data. + +## 5. Platform decisions + +| Decision | Choice | Basis | +|---|---|---| +| Sync API | Dropped. v1 stays available on a `0.1.x` maintenance branch | | +| Driver | `pymongo.AsyncMongoClient`, `pymongo>=4.13,<5` | Motor deprecated 2025-05 [unverified date] | +| Python | min 3.11; CI 3.11–3.15 + free-threaded | 3.15.0 is installed locally; msgspec 0.22, attrs 26.1, pydantic 2.14 install and run on it [probed] | +| MongoDB server | Proposed floor 7.0; test 7.0 and 8.0; replica set and sharded | EOL dates [unverified] | +| Packaging | uv workspace, hatchling, two distributions | §4.1 | +| Lint/types | ruff (current), mypy strict; `ty` as a non-blocking second checker | `ty` maturity [unverified] | +| Tests | pytest + pytest-asyncio against real `mongod`; no mongomock | mongomock lacks async and transactions | +| Optional backends | Imported lazily via `importlib`; none is a hard dependency of `mongospecs` | | +| Versioning | `mongospecs` 0.2.0, `mongospecs-web` 0.1.0 | decided by Jay 2026-10-12 | + +## 6. Evidence track: stress and benchmarks on real MongoDB + +`bench/` is a workspace member that is never published. It answers performance questions with +data from real `mongod` (replica set, plus a sharded cluster for the relation matrix). It runs +independently of the feature graph: any benchmark runs as soon as the node it measures exists. + +**Harness** +- `docker/` compose files; `bench run [--topology rs|sharded] [--backend ...]`. +- Results are written as JSON under `bench/results//` with server version, topology, + dataset shape and machine info, so runs are comparable and diffable. +- CPU-only microbenchmarks (codec, pipeline compile) also run under codspeed in CI. Anything that + touches the network is run on demand, not as a CI gate: it is too noisy to block merges. + +**Comparators.** Raw `pymongo` async returning dicts is the floor; ODM overhead is reported as a +ratio against it. **Mongoose** (Node.js, hydrated and `lean()`, same `mongod`, same datasets) is the +production reference: for each workload the report shows v2's overhead over raw pymongo next to +Mongoose's overhead over the raw Node driver, since cross-runtime absolute times are not +comparable. v1 (sync) is a reference point only. Mongoz and Beanie are optional comparators. + +**Workloads** + +| ID | Workload | Question it answers | +|---|---|---| +| B1 | Materialise N docs × {small, wide, nested} × backend × {validated, trusted} | What share of read time is decode? Does `trusted` matter? | +| B2 | `find` end to end, 1 / 100 / 10k docs | ODM overhead over raw pymongo | +| B3 | `include` via `$lookup` vs batched `$in`, fan-out 1 / 10 / 100 / 1000, rs and sharded | Default strategy and `auto` heuristic | +| B4 | Bulk insert, atomic patch, upsert | Write-path overhead | +| B5 | Offset vs cursor pagination at depth; `$facet` vs concurrent count | Pagination defaults | +| B6 | Hook and transformer overhead per op with 0 / 3 / 10 plugins | Cost of the plugin model | +| B7 | HTTP request → response through each plug × backend | Web-layer overhead per plug | +| B8 | Tracked vs `.lean()` vs `.raw()` reads; delta `save()` vs full `$set`; memory per tracked doc | Cost of change tracking; whether tracked should stay the default | + +**Stress** + +| ID | Scenario | Pass condition | +|---|---|---| +| X1 | Thousands of concurrent tasks against a small pool | No deadlock; pool-wait errors surface as typed errors | +| X2 | Transaction contention on hot documents with `registry.transaction(fn)` | No lost updates; retries bounded | +| X3 | Optimistic-lock storm on one document via `Revision` | Exactly one winner per revision | +| X4 | Task cancellation mid-operation and mid-transaction | No leaked sessions or connections | +| X5 | Result documents approaching 16MB through `$lookup` | Typed error or automatic batch fallback, never a raw driver error | +| X6 | Change stream consumer across primary step-down | Resume without loss or duplication | +| X7 | Free-threaded build, many threads each with its own loop and registry | No cross-registry state | + +**The Rust decision** is an output of this track and nothing in §7 depends on it. +- Input: B1 and B2 profiles. +- Proposed rule (to confirm): open the question only if decode-plus-materialise is the dominant + cost of end-to-end reads on real MongoDB after the cheap options are exhausted (`trusted` reads, + server-side projection, `RawBSONDocument` paths). Otherwise the answer is recorded as "no". +- If it is ever "yes", it becomes a separate optional distribution behind the existing `Codec` + seam. No design in this document changes. + +## 7. Work graph + +There are no phases and no dates. Work is a dependency graph of nodes. This section is the +operating contract for whichever agent picks up a node. + +### 7.1 Rules + +1. **Ready set.** A node is ready when every node in its `needs` column is done. All ready nodes + may be worked concurrently, each on its own branch or worktree. +2. **Done means the check passes.** Every node has an executable check. A node is done when that + check passes on a clean checkout and the workspace-wide gate (`ruff`, `mypy`, full test suite + against real `mongod`) is still green. No node is done by assertion. +3. **Contracts before implementations.** `C*` nodes produce a protocol plus a contract test suite. + Implementations are done when they pass the suite unmodified. +4. **Amending a contract.** If an implementation cannot satisfy a contract, the agent does not + bend the tests. It writes the amendment into this document, lists the done nodes it invalidates, + and re-runs their checks. +5. **Spikes are disposable.** `S*` nodes exist to confirm or falsify a design assumption. Their + output is a recorded result in §10 and, if needed, an amendment. Spike code is not merged. +6. **v1 tests are the behaviour inventory.** Each v1 test is either ported to v2, or listed in + `docs/migration.md` as intentionally dropped with the replacement. None is silently lost. +7. **No stubs.** A node delivers working code or is not started. +8. **Escalate to Jay** when: a spike falsifies a design in §4; an amendment would change a public + API already used by `mongospecs-web`; anything is published, tagged or force-pushed; an open + decision in §10 blocks a ready node. +9. **Mongoose is the tiebreak.** When a behaviour is not decided in this document, the agent reads + the Mongoose documentation for the equivalent feature and adopts its semantics, unless the + behaviour is on the do-not-inherit list in §3.3. The choice is recorded in `docs/parity.md` + (feature, Mongoose behaviour, v2 behaviour, same/diverges and why). This is not an escalation. +10. **Parity is tested, not claimed.** Each row of the §3.3 parity table is done when + `docs/parity.md` links it to the tests that cover it. + +### 7.2 Nodes + +**Foundation** + +| ID | Node | Needs | Check | +|---|---|---|---| +| F1 | Cut `0.1.x` maintenance branch from `main`; decide the fate of uncommitted `mixins-test` work | — | Branch exists; working tree clean | +| F2 | uv workspace skeleton per §4.1: both packages, `bench/`, shared tool config | F1 | `uv sync --all-packages` and `uv build --all-packages` succeed | +| F3 | Run the v1 suite once under uv; record pass/fail as the behaviour inventory | F1 | Inventory file lists every v1 test with an initial disposition | +| F4 | `docker/` compose for replica set and sharded cluster; pytest fixtures for both | F2 | A test that commits a transaction passes on rs; a `$lookup` test passes on sharded | +| F5 | CI: matrix Python 3.11–3.15 + free-threaded × MongoDB 7.0/8.0; ruff, mypy, wheels | F2, F4 | Pipeline green on the empty workspace | + +**Spikes** + +| ID | Question | Needs | Result feeds | +|---|---|---|---| +| S1 | Vertical slice: one model on msgspec and pydantic, real rs: bind, insert, find, atomic patch, `registry.transaction(fn)`, one `$lookup` include, one ASGI endpoint | F4 | C1–C4, W1 | +| S2 | `$facet`, `$graphLookup`, `$unionWith`, `$lookup` restrictions on sharded collections and inside transactions; 16MB behaviour | F4 | §4.6, R1–R3 | +| S3 | `Annotated` marker introspection under deferred annotations and forward refs on 3.14/3.15, all three backends, module-level and nested models | F2 | C1 | +| S4 | Per-field validation fidelity: does `validate_field` run model validators/converters on each backend (pydantic `field_validator`, msgspec `Meta` constraints, attrs validators)? | F2 | C1, W2 | +| S5 | FastAPI and Litestar: can externally supplied JSON Schema fully replace native response/DTO schema generation? Read official docs | F2 | P3, P4 | +| S6 | Change tracking: snapshot side table on all three backends under GC pressure, free-threading, copies and pickling; diff semantics for nested models, lists, dicts, `None` vs missing | F2 | C6, K12 | +| S7 | Mongoose parity inventory: read the Mongoose docs (and its test names) for every row of §3.3 not tagged [docs]; correct the table; seed `docs/parity.md` | — | Every node citing §3.3 | +| S8 | Discriminators on pydantic and attrs through the Codec; `Ref["Name"]` forward-ref resolution at bind time | F2 | C1, K13 | + +**Contracts** + +| ID | Node | Needs | Check | +|---|---|---|---| +| C1 | `Codec` protocol, `FieldInfo`, markers, `Policy`, discriminator and alias support; backend-parametrised codec contract suite | S1, S3, S4, S8 | Suite collects and fails only for "backend not implemented" | +| C2 | Query AST + `Q` builder + compiler to `find` args and pipelines | S1 | Golden tests: AST → exact Mongo commands; property tests for immutability | +| C3 | Error hierarchy and mapping from pymongo | S1 | Every pymongo error class used by the driver maps to exactly one mongospecs error | +| C4 | Hook and transformer signatures, `OpCtx`, ordering semantics | S1 | Contract suite for ordering, fail-fast, session propagation | +| C5 | `ResourceBackend` protocol, `Operation`, `Ctx`, `FakeBackend`; plug contract suite | C1, C2, S1 | Suite runs green against `FakeBackend` through a trivial in-process caller | +| C6 | Change-tracking contract: snapshot, diff, `changes()`, `is_new`, restore-on-abort | C1, S6 | Backend-parametrised suite; fails only for "backend not implemented" | + +**Core (`packages/mongospecs`)** + +| ID | Node | Needs | Check | +|---|---|---|---| +| K1 | msgspec codec | C1 | Codec contract suite | +| K2 | pydantic codec | C1 | Codec contract suite | +| K3 | attrs codec | C1 | Codec contract suite | +| K4 | `Registry`, binding, lifecycle | C3 | Lifecycle tests: no I/O at import, closed is final, two registries isolated | +| K5 | Model operations: reads, insert, atomic update operators, `save`, upsert, delete, `DocumentNotFound` | C2, C3, K4, one of K1–K3 | CRUD suite on real rs, parametrised over every finished codec | +| K6 | Sessions and both transaction APIs | K5 | Commit, abort, retry-on-transient and cancellation tests on rs | +| K7 | Hook engine and plugin registration | C4, K5 | Hook contract suite | +| K8 | Plugins: timestamps, soft delete, revision | K7 | Per-plugin suites incl. scope inside counts and filter-based writes | +| K9 | Index planner: `plan_indexes`, `check_indexes`, gated destructive apply | K5 | Planner golden tests; apply test on rs | +| K10 | `upcast` and unknown-field preservation | K5 | Old-shape documents load; unknown fields survive `save` | +| K11 | Generic `SpecAdapter` for existing classes | K1–K3 | v1 adapter tests ported | +| K12 | Change tracking: tracked/lean/raw read modes, delta `save`, `hydrate`, state restore in `registry.transaction` | C6, K5, K6 | C6 suite on every codec; rs test: aborted and retried transactions leave documents consistent | +| K13 | Discriminators | K5, S8 | Mixed-type collection decodes to the right classes on every codec; subclass queries filter by key | +| K14 | Query casting, unknown-field errors, `trusted()` boundary | C2, K5 | Casting table tests per type; injection suite at the ODM level | +| K15 | `bulk_write`, `bulk_save`, `insert_many(ordered=False)` with transformers applied | K7, K12 | Timestamps and revisions correct on every bulk path; partial-failure report shape | +| K16 | Named scopes on the query type | K5 | Chaining and typing fixtures | +| K17 | Command observability: `on_command`, logging and OpenTelemetry adapters | K4 | Every operation kind emits start and end events with model and op names | +| K18 | Multi-database `using(db=)`, shard-key aware document writes | K5 | Two-tenant isolation test; sharded test asserts targeted writes via `explain` | + +**Relations and streams** + +| ID | Node | Needs | Check | +|---|---|---|---| +| R1 | `include` via `$lookup` and via batched `$in`, same results | K5, S2 | One suite, both strategies, identical output; scope applied to joined model | +| R2 | `$graphLookup` and `$unionWith` | R1 | Suites on rs; sharded restrictions asserted as typed errors | +| R3 | Cursor and offset pagination, optional total | K5, S2 | Stable ordering under concurrent inserts | +| R4 | Cascade rules | K6, K7, R1 | Atomicity test: failure mid-cascade leaves no partial state | +| R5 | Change streams with resume | K5 | X6 scenario as a test | +| R6 | Pick `include` default and `auto` heuristic | R1, B3 results | Decision recorded with the benchmark run it cites | +| R7 | Reverse relations (`Backref`), counts, polymorphic `Ref[A \| B]`, nested include paths and per-include options | R1, K13 | Same suite on both strategies; nothing from a `Backref` is ever written | + +**Web (`packages/mongospecs-web`)** + +| ID | Node | Needs | Check | +|---|---|---|---| +| W1 | `Resource`, built-in operations, guards, scope, `after`/custom ops | C5 | Resource suite against `FakeBackend` | +| W2 | Input pipeline: policy filtering, per-field validation, filter parsing | C5, one of K1–K3 | Security suite: operator injection, allowlist bypass, private-field leakage | +| W3 | RFC 9457 error mapping, ETag/`If-Match` from `Revision` | W1, K8 | Error and precondition suites | +| W4 | OpenAPI generation from operations and transformed schemas | W1, W2 | Generated document validates against the OpenAPI schema; snapshot per backend | +| W5 | `mongospecs` → `ResourceBackend` adapter | C5, K5, R3 | Plug contract suite against real rs through the adapter | +| P1 | ASGI plug | W1–W4 | Plug contract suite | +| P2 | Starlette plug | P1 | Plug contract suite | +| P3 | FastAPI plug | P1, S5 | Plug contract suite; `/openapi.json` matches W4 output | +| P4 | Litestar plug | P1, S5 | Plug contract suite | +| P5 | Quart plug | P1 | Plug contract suite | + +**Evidence (`bench/`)** — see §6 for definitions + +| ID | Node | Needs | Check | +|---|---|---|---| +| E1 | Harness, dataset generators, result format, raw-pymongo and v1 comparators | F4 | `bench run` produces a results file for the raw-pymongo comparator | +| E2 | Mongoose comparator: Node scripts running B1–B5 and B8 equivalents (hydrated and `lean()`) against the same `mongod` and datasets, plus the raw Node driver floor | E1 | Results file in the same format, overhead ratios computed | +| B1–B8 | Each workload | E1 + the node it measures | Results committed for both topologies where applicable | +| X1–X7 | Each stress scenario | E1 + the node it stresses | Scenario passes its condition; failures become issues against the owning node | +| D1 | Rust decision record | B1, B2 | §6 rule applied; answer and cited runs written into §10 | + +**Release** + +| ID | Node | Needs | Check | +|---|---|---|---| +| L1 | `docs/migration.md` complete from the F3 inventory | All K, R nodes | Every v1 test is ported or listed | +| L6 | `docs/parity.md` complete, plus a "coming from Mongoose" guide | All K, R nodes, S7 | Every §3.3 row links to tests or is marked a deliberate divergence | +| L2 | Docs site for both packages | All K, R, W, P nodes | Docs build; every public symbol documented | +| L3 | Consumer typing fixtures (positive and negative) | K5, W1 | mypy on fixtures gives exactly the expected errors | +| L4 | `mongospecs` 0.2.0 | K*, R*, L1, L3, L6, X1–X6, B8 | Wheel/sdist validation; clean install on each Python | +| L5 | `mongospecs-web` 0.1.0 | W*, P*, L4, B7 | Same, plus plug extras install cleanly one at a time | + +### 7.3 Shape of the graph + +``` +F1 ─ F2 ─┬─ F4 ─┬─ S1 ─┬─ C1 ─┬─ K1 K2 K3 ─┐ + │ │ ├─ C2 ─┤ ├─ K5 ─┬─ K6 ─┐ + ├─ F3 ├─ S2 ├─ C3 ─┴─ K4 ───────┘ ├─ K7 ─ K8 + ├─ F5 │ ├─ C4 ─────────────────────┘ ├─ R4 + ├─ S3 │ └─ C5 ─ W1 W2 ─ W3 W4 ─ P1 ─ P2 P3 P4 P5 + ├─ S4 └─ E1 ─ B*/X* attach to whatever they measure + └─ S5 K5 ─ R1 ─ R2, R3 ─ W5 R1 + B3 ─ R6 +``` + +Not drawn: `S6 → C6 → K12 → K15`, `S8 → K13 → R7`, and `K14`, `K16`, `K17`, `K18`, which hang off +`K5` (or `K4`) independently. `S7` has no prerequisites and can start immediately. + +Widest parallel fronts: the eight spikes; the three codecs; the five plugs; all of `bench/`. +The single narrow point is `S1 → C1..C5`: nothing downstream should start before the vertical +slice has exercised every seam once. + +## 8. v1 → v2 feature map + +| v1 | v2 | Disposition | +|---|---|---| +| `Spec` per backend, `SpecAdapter` | Same idea, one generic adapter over `Codec` | Kept, reimplemented | +| `Q`, `And/Or/Nor`, `In/NotIn/All/ElemMatch/Exists/Size/Type/Not`, `SortBy` | Same surface, compiles to the AST | Kept | +| `one` / `many` / `find` / `find_one` / `by_id` / `ids` | `get`, `find(...).first()/.all()`, `.raw()`, `.ids()` | Replaced | +| Projection DSL with `$ref` / `$sub` / `$sub.` | `select()` + `include()`; embedded docs are typed fields decoded by the Codec | Replaced | +| Dict-of-refs and list-of-refs dereference | `include()` on `Ref[T]`, `list[Ref[T]]`, `dict[str, Ref[T]]` | Kept (dict form needs a test in R1) | +| `SubSpec` | Plain nested model of the same backend | Replaced | +| `Empty` / `unset()` | `Model.update(id, unset=[...])`; no sentinel type | Replaced | +| `update(*fields)` / `upsert` (read-then-write) | Atomic `update`, real `upsert`, delta `save()` | Replaced | +| `reload()` | `reload()`, also resets the snapshot | Kept | +| `insert_many` / `update_many` / `delete_many` taking instances | Kept for instances; filter-based variants added | Kept, extended | +| `soft_delete` / `find_active` | `DeletedAt` marker + scope | Replaced | +| `cascade` / `nullify` / `pull` | `on_delete` rules | Replaced | +| Blinker signals (`listen`, `stop_listening`) | Typed hooks | Replaced | +| `transaction()` context manager | `registry.transaction(fn)` / manual session | Replaced | +| `with_options()` | `Model.using(read_preference=..., write_concern=...)` returning a clone | Replaced | +| `Paginator` / `Page` with orphans | Cursor and offset pagination; orphans dropped | Replaced | +| `create_index` / `drop_index` / `list_indexes` | Declarative indexes + planner | Replaced | +| `helpers/bson.py`, `MongoEncoder/MongoDecoder` | Codec `encode`/`decode` | Folded in | +| `to_json_type`, `encode`, `decode`, `to_tuple` | Backend-native serialisation; `to_tuple` dropped | Reduced | +| `retry`, `handle_*_errors` decorators | Not carried over | Dropped | + +## 9. Risks + +1. **Backend × plug matrix (3 × 5).** Control: core-owned validation, one contract suite, schema + produced by one algorithm. +2. **Per-field validation fidelity (S4).** If a backend cannot run its own validators for a single + field, Patch needs a different mechanism for that backend. This is the most likely amendment. +3. **Scope leakage.** A soft-delete or row scope missed on one code path is a security bug. + Control: a single choke point that compiles filters, and a test that enumerates every op. +4. **`$lookup` cost and size.** Control: both strategies always available; default chosen by B3. +5. **Filter-based writes skip document hooks.** A documented limitation users will still trip on. +6. **Contract churn.** Protocols are fixed right after one vertical slice. Rule 4 in §7.1 makes + churn visible instead of silent. +7. **FastAPI and Litestar schema injection (S5).** If native schema generation cannot be fully + replaced, those plugs degrade to "pydantic models only" or "msgspec models only" for native + docs, with the ASGI-generated OpenAPI as the fallback. +8. **Free-threaded and 3.15 support in dependencies.** Installs and basic use are confirmed on + 3.15; free-threaded wheels are not. +9. **Two packages, one API boundary.** `ResourceBackend` is the only coupling; anything else that + `mongospecs-web` imports from `mongospecs` is public API by definition. +10. **Change tracking cost and correctness.** One extra encoded copy per tracked document, and a + diff on every save. Whole-container `$set` for changed lists can overwrite a concurrent + element-level write; `Revision` is the guard, and explicit atomic operators the alternative. + Control: B8 decides whether tracked stays the default; S6 covers the edge cases. +11. **Parity as scope pressure.** Mongoose has a decade of surface. The parity table in §3.3 is the + boundary: features not in it are not in v2 unless added by amendment. The tiebreak rule decides + semantics of features already in scope; it does not add features. +12. **Mongoose knowledge is second-hand.** Most of §3.3 is from memory of its docs. `S7` exists so + no node is built on a misremembered behaviour. + +## 10. Verification log and open decisions + +### Probed for revisions 2 and 3 + +| Claim | Result | +|---|---| +| Field-bearing mixins via multiple inheritance | Fail on msgspec 0.22 and attrs 26.1 (`lay-out conflict`); work on pydantic 2.14; plain mixin fields dropped by msgspec | +| `Annotated` markers + `ClassVar` config readable on all three backends | Works on Python 3.13.5 and 3.15.0 | +| `create_model`-derived Patch keeps source validators | It does not | +| `AsyncClientSession.with_transaction` | Exists, coroutine, callback-based (pymongo 4.18.3) | +| Python 3.15.0 final exists | Installed locally via uv | +| New error decorators are used somewhere | They are not | +| Snapshot side table keyed by identity with a weakref finaliser | Works on pydantic and slotted attrs; msgspec needs `weakref=True` on the base, which subclasses inherit; a plain Struct cannot be weak-referenced | +| msgspec tagged unions decode a mixed list to the right classes | Works | +| pydantic can validate one field with the model's own `field_validator` | Works via `validate_assignment` (partial answer to S4) | +| Mongoose `Connection.transaction()` retries and resets document state on abort | Confirmed in its docs | +| Mongoose has four middleware kinds; `transactionAsyncLocalStorage` is opt-in; `strictQuery` strips or throws; `strict` strips unknown update paths; `select: false` exists | Confirmed in its docs | + +### Still unverified + +- MongoDB server EOL dates behind the 7.0 floor. +- `$facet` single-document/16MB behaviour; `$graphLookup`/`$unionWith`/`$lookup` restrictions on + sharded collections and in transactions (→ S2). +- Per-field validation on each backend (→ S4). +- Marker introspection with deferred annotations and forward references (→ S3; only eager, + non-forward cases were probed). +- Litestar DTO and FastAPI schema-override details (→ S5). +- Free-threaded wheels for msgspec, pydantic-core, attrs. +- Motor deprecation dates; Beanie's current driver; `ty` maturity. +- Mongoz internals not read (metaclasses, fields). +- Every §3.3 row not tagged [docs] (→ S7). Mongoose source and tests not read. +- Snapshot tracking under free-threading, `copy`, pickling, and for nested containers (→ S6). +- Discriminators on pydantic and attrs; forward-ref resolution (→ S8). + +### Open decisions (each has a default so no node is blocked) + +1. **Uncommitted `mixins-test` work** (F1): default is commit it to the branch as-is for the + record, do not carry any of it into v2. +2. **MongoDB floor:** default 7.0. +3. **Version numbers:** decided: `mongospecs` 0.2.0; `mongospecs-web` starts at 0.1.0. +4. **Read validation default:** default validate; revisit with B1 data. +5. **Rust rule in §6:** default as written; needs your confirmation of what "dominant" means once + B1/B2 numbers exist. +6. **Default read mode:** default tracked (Mongoose's default). Revisit with B8 data; the + alternative is lean by default with `.tracked()` opt-in. +7. **Optional comparators in `bench/`** (Mongoz, Beanie): default off. Mongoose is on. +8. **Ambient transaction session** (Mongoose's `transactionAsyncLocalStorage`): default not + offered. Say so if you want it as an opt-in despite the task-sharing hazard. +9. **Scope of §3.3:** the parity table is my selection of Mongoose features. Add or strike rows + before agents start on `S7`. + +## 11. Amendments made during implementation + +Recorded per §7.1 rule 4. Each one names what changed and why; none invalidates a finished node. + +| # | Section | Amendment | Why | +|---|---|---|---| +| A1 | §4.1 | Backend modules stay at the v1 import paths `mongospecs.msgspec`, `mongospecs.pydantic`, `mongospecs.attrs` (not `mongospecs/backends/`). Core modules are flat: `expr.py`, `query.py`, `manager.py`, `model.py`, `meta.py`, `codec.py`, `registry.py`, `tracking.py`, `updates.py`, `bulk.py`, `relations.py`, `indexes.py`, `streams.py`, `hooks.py`, `refs.py`, `markers.py`, `plugins/` | Import continuity with 0.1.x; smaller surface | +| A2 | §4.2 | `Ref[T]` is a wrapper object (`.id`, `.doc`, `.loaded`, `await .fetch()`), stored as the id. `OID` is the `ObjectId` annotation that works on every backend | msgspec cannot express a union of a custom type and a struct, so "id or document" needs one runtime type | +| A3 | §4.2 | `__spec__` is assigned without an annotation | With `from __future__ import annotations`, msgspec does not recognise `t.ClassVar[...]` and turns the attribute into a field [found in testing] | +| A4 | §4.2 | A string forward reference (`Ref["User"]`) works when the name is importable in the defining module, as usual in Python. For anything else use `Annotated[Ref, To("User")]`, resolved by class name against the registry the referring model is bound to | Backends resolve annotations themselves and fail on unresolvable names | +| A5 | §4.4 | `trusted` reads only change behaviour for flat pydantic models (`model_construct`). msgspec and attrs always convert | `model_construct` does not build nested models or references; msgspec has no unvalidated path | +| A6 | §4.4 | msgspec and attrs models are encoded by a Python walker; enum, date, decimal, set and UUID leaves are handled by driver codec options on the registry | `msgspec.to_builtins` cannot emit `ObjectId` (the hook result is re-encoded and recurses) [probed] | +| A7 | §4.3 | Timestamps are written with client time at millisecond precision, not `$currentDate` | The in-memory document must hold the value that was stored, without a second read | +| A8 | §4.3 | The revision check applies to `save()`, soft delete and `update(..., if_rev=)`. A hard delete of a loaded document is not revision-checked | There is no update document to carry the check; revisit if needed | +| A9 | §4.5 | Query values are cast by type only; storage validators and constraints are not applied to filters. Updates are fully validated | A search for a value that would be invalid to store is legitimate (Mongoose casts filters the same way) | +| A10 | §4.5 | An untracked document that is saved is inserted. A `lean()` document therefore cannot be saved (the insert fails on its existing id) | Keeps lean reads free of any per-document state | +| A11 | §4.7 | `Registry(on_command=...)` listeners: `logging_listener`, `otel_listener`, and `sentry_listener` (new, requested 2026-10-13). End-to-end trace verification against a Sentry-compatible server (rustrak) is node `E3` | Jay asked for Sentry SDK integration with rustrak for end-to-end tracing | +| A12 | §5 | Versions: `mongospecs` 0.2.0, `mongospecs-web` 0.1.0 | Decided by Jay | +| A13 | §7.2 | Spikes S3, S4, S6 and S8 were answered by the contract tests rather than by throwaway code: markers under deferred annotations (suite passes on 3.11, 3.13, 3.15), per-field validation on all three backends, snapshot tracking, discriminators and forward references | The tests are the durable form of the spike | + +New node: + +| ID | Node | Needs | Check | +|---|---|---|---| +| E3 | End-to-end tracing: Sentry SDK in an app using `mongospecs-web`, sending to rustrak in Docker; one HTTP request yields one trace containing the request span and `Model.op` database spans | P1, K17 | A script asserts the trace arrived in rustrak with the expected spans | diff --git a/docker/compose.rs.yaml b/docker/compose.rs.yaml new file mode 100644 index 0000000..0a12292 --- /dev/null +++ b/docker/compose.rs.yaml @@ -0,0 +1,23 @@ +# Single-node replica set for tests: transactions and change streams need one. +# docker compose -f docker/compose.rs.yaml up -d --wait +# MONGOSPECS_TEST_URI=mongodb://localhost:27117/?replicaSet=rs0 +name: mongospecs-rs +services: + rs: + image: mongo:${MONGO_VERSION:-8.0} + command: ["mongod", "--replSet", "rs0", "--bind_ip_all", "--port", "27117"] + ports: + - "27117:27117" + tmpfs: + - /data/db + healthcheck: + test: + - CMD-SHELL + - > + mongosh --port 27117 --quiet --eval + "try { if (!db.hello().isWritablePrimary) throw 1 } catch (e) { + try { rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'localhost:27117'}]}) } catch (e2) {} + quit(1) }" + interval: 2s + timeout: 10s + retries: 60 diff --git a/docker/compose.sharded.yaml b/docker/compose.sharded.yaml new file mode 100644 index 0000000..7db00b2 --- /dev/null +++ b/docker/compose.sharded.yaml @@ -0,0 +1,70 @@ +# Minimal sharded cluster for relation and shard-key tests: one config server, +# two single-node shards, one mongos. +# docker compose -f docker/compose.sharded.yaml up -d --wait +# MONGOSPECS_TEST_SHARDED_URI=mongodb://localhost:27118 +name: mongospecs-sharded +services: + cfg: + image: mongo:${MONGO_VERSION:-8.0} + command: ["mongod", "--configsvr", "--replSet", "cfg", "--bind_ip_all", "--port", "27019"] + tmpfs: [/data/db, /data/configdb] + healthcheck: + test: + - CMD-SHELL + - > + mongosh --port 27019 --quiet --eval + "try { if (!db.hello().isWritablePrimary) throw 1 } catch (e) { + try { rs.initiate({_id: 'cfg', configsvr: true, members: [{_id: 0, host: 'cfg:27019'}]}) } catch (e2) {} + quit(1) }" + interval: 2s + timeout: 10s + retries: 60 + sh1: + image: mongo:${MONGO_VERSION:-8.0} + command: ["mongod", "--shardsvr", "--replSet", "sh1", "--bind_ip_all", "--port", "27018"] + tmpfs: [/data/db] + healthcheck: + test: + - CMD-SHELL + - > + mongosh --port 27018 --quiet --eval + "try { if (!db.hello().isWritablePrimary) throw 1 } catch (e) { + try { rs.initiate({_id: 'sh1', members: [{_id: 0, host: 'sh1:27018'}]}) } catch (e2) {} + quit(1) }" + interval: 2s + timeout: 10s + retries: 60 + sh2: + image: mongo:${MONGO_VERSION:-8.0} + command: ["mongod", "--shardsvr", "--replSet", "sh2", "--bind_ip_all", "--port", "27018"] + tmpfs: [/data/db] + healthcheck: + test: + - CMD-SHELL + - > + mongosh --port 27018 --quiet --eval + "try { if (!db.hello().isWritablePrimary) throw 1 } catch (e) { + try { rs.initiate({_id: 'sh2', members: [{_id: 0, host: 'sh2:27018'}]}) } catch (e2) {} + quit(1) }" + interval: 2s + timeout: 10s + retries: 60 + mongos: + image: mongo:${MONGO_VERSION:-8.0} + command: ["mongos", "--configdb", "cfg/cfg:27019", "--bind_ip_all", "--port", "27118"] + ports: + - "27118:27118" + depends_on: + cfg: { condition: service_healthy } + sh1: { condition: service_healthy } + sh2: { condition: service_healthy } + healthcheck: + test: + - CMD-SHELL + - > + mongosh --port 27118 --quiet --eval + "const n = db.getSiblingDB('config').shards.countDocuments({}); + if (n < 2) { try { sh.addShard('sh1/sh1:27018'); sh.addShard('sh2/sh2:27018') } catch (e) {} quit(1) }" + interval: 2s + timeout: 20s + retries: 60 diff --git a/docs/index.md b/docs/index.md index e69de29..54206c3 100644 --- a/docs/index.md +++ b/docs/index.md @@ -0,0 +1,6 @@ +# mongospecs + +An async MongoDB ODM for msgspec, pydantic and attrs models, and a framework-neutral REST layer. + +- [Design and work graph](https://github.com/jaykv/mongospecs/blob/main/claudedocs/v2-plan.md) +- [v1 test inventory](v1-test-inventory.md): what became of every 0.1.x behaviour diff --git a/docs/reference/attrs.md b/docs/reference/attrs.md deleted file mode 100644 index d8e693f..0000000 --- a/docs/reference/attrs.md +++ /dev/null @@ -1 +0,0 @@ -::: mongospecs.attrs \ No newline at end of file diff --git a/docs/reference/base.md b/docs/reference/base.md deleted file mode 100644 index 46eb881..0000000 --- a/docs/reference/base.md +++ /dev/null @@ -1 +0,0 @@ -::: mongospecs.base \ No newline at end of file diff --git a/docs/reference/bson.md b/docs/reference/bson.md deleted file mode 100644 index e69de29..0000000 diff --git a/docs/reference/empty.md b/docs/reference/empty.md deleted file mode 100644 index 3792471..0000000 --- a/docs/reference/empty.md +++ /dev/null @@ -1 +0,0 @@ -::: mongospecs.empty \ No newline at end of file diff --git a/docs/reference/msgspec.md b/docs/reference/msgspec.md deleted file mode 100644 index d4373ed..0000000 --- a/docs/reference/msgspec.md +++ /dev/null @@ -1 +0,0 @@ -::: mongospecs.msgspec \ No newline at end of file diff --git a/docs/reference/ops.md b/docs/reference/ops.md deleted file mode 100644 index 265fced..0000000 --- a/docs/reference/ops.md +++ /dev/null @@ -1 +0,0 @@ -::: mongospecs.ops \ No newline at end of file diff --git a/docs/reference/pydantic.md b/docs/reference/pydantic.md deleted file mode 100644 index 664c30e..0000000 --- a/docs/reference/pydantic.md +++ /dev/null @@ -1 +0,0 @@ -::: mongospecs.pydantic \ No newline at end of file diff --git a/docs/reference/query.md b/docs/reference/query.md deleted file mode 100644 index 935f54d..0000000 --- a/docs/reference/query.md +++ /dev/null @@ -1 +0,0 @@ -::: mongospecs.query \ No newline at end of file diff --git a/docs/reference/se.md b/docs/reference/se.md deleted file mode 100644 index 579d71e..0000000 --- a/docs/reference/se.md +++ /dev/null @@ -1 +0,0 @@ -::: mongospecs.se \ No newline at end of file diff --git a/docs/reference/utils.md b/docs/reference/utils.md deleted file mode 100644 index 978ced2..0000000 --- a/docs/reference/utils.md +++ /dev/null @@ -1 +0,0 @@ -::: mongospecs.utils \ No newline at end of file diff --git a/docs/v1-test-inventory.md b/docs/v1-test-inventory.md new file mode 100644 index 0000000..9935437 --- /dev/null +++ b/docs/v1-test-inventory.md @@ -0,0 +1,82 @@ +# v1 test inventory + +The v1 suite (302 tests, all passing on `0.1.x` under mongomock) is the behaviour inventory for the +rewrite. Every v1 test name is listed with what became of it. "ported" means the behaviour is +covered by the named v2 test against real MongoDB; "replaced" means a different API covers the +need; "dropped" means the behaviour intentionally does not exist in v2. + +| v1 test | runs | disposition | v2 coverage / reason | +|---|---|---|---| +| `test_all` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_and` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_attrs_adapter` | 1 | ported | packages/mongospecs/tests/test_codec.py::test_spec_adapter_turns_existing_class_into_document | +| `test_bson` | 3 | ported | `Codec.decode` accepts raw BSON. packages/mongospecs/tests/test_codec.py::test_decode_accepts_raw_bson_and_string_ids | +| `test_bson_with_spec` | 2 | ported | packages/mongospecs/tests/test_codec.py::test_round_trip_through_bson_ready_dict | +| `test_by_id` | 6 | ported | `Model.get(id)`. packages/mongospecs/tests/test_crud.py::test_get_returns_typed_document_or_none | +| `test_cascade` | 6 | ported | `OnDelete("cascade")`. packages/mongospecs/tests/test_relations.py::test_cascade_rules | +| `test_count` | 6 | ported | packages/mongospecs/tests/test_crud.py::test_count_exists_ids_estimated | +| `test_delete` | 6 | ported | packages/mongospecs/tests/test_crud.py::test_delete_and_reload | +| `test_delete_many` | 6 | ported | `Model.find(...).delete()`. packages/mongospecs/tests/test_updates.py::test_filter_based_update_and_delete | +| `test_dot_notation` | 6 | ported | nested paths in `Q` and dotted deltas. packages/mongospecs/tests/test_expr.py::test_comparisons, test_crud.py::test_nested_document_diff_uses_dotted_paths | +| `test_elem_match` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_equal` | 9 | ported / dropped | query form: packages/mongospecs/tests/test_expr.py::test_comparisons. Instance equality by id (attrs, msgspec, pydantic frames tests): dropped, v2 keeps each backend's own equality | +| `test_exists` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_flattern_projection` | 6 | dropped | internal helper of the projection DSL, which no longer exists | +| `test_get_collection` | 6 | ported | `Model.collection()`. packages/mongospecs/tests/test_crud.py::test_hydrate_and_collection_escape_hatch | +| `test_get_db` | 6 | replaced | `Registry.database()`. packages/mongospecs/tests/test_registry_errors.py | +| `test_get_fields` | 6 | ported | `meta_of(Model).fields`. packages/mongospecs/tests/test_codec.py::test_fields_expose_names_markers_and_policy | +| `test_greater_than` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_greater_than_or_equal` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_ids` | 6 | ported | packages/mongospecs/tests/test_crud.py::test_count_exists_ids_estimated | +| `test_in` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_insert` | 6 | ported | packages/mongospecs/tests/test_crud.py::test_insert_assigns_id_and_tracks | +| `test_insert_many` | 6 | ported | packages/mongospecs/tests/test_bulk_pagination.py::test_insert_many_ordered_and_unordered | +| `test_less_than` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_less_than_or_equal` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_listen` | 6 | replaced | typed hooks `@Model.before/after`. packages/mongospecs/tests/test_plugins_hooks.py::test_document_hooks_order_session_and_fail_fast | +| `test_many` | 6 | ported | `.all()`. packages/mongospecs/tests/test_crud.py::test_find_all_first_one_and_or_fail | +| `test_missing_client` | 1 | ported | `NotBoundError` / `RegistryClosedError`. packages/mongospecs/tests/test_crud.py::test_unbound_model_raises | +| `test_msgspec_adapter` | 1 | ported | packages/mongospecs/tests/test_codec.py::test_spec_adapter_turns_existing_class_into_document | +| `test_nor` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_not` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_not_equal` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_not_in` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_nullify` | 6 | ported | `OnDelete("nullify")`. packages/mongospecs/tests/test_relations.py::test_cascade_rules | +| `test_one` | 6 | ported | `.first()` / `.one()`. packages/mongospecs/tests/test_crud.py::test_find_all_first_one_and_or_fail | +| `test_operations_use_session` | 1 | ported | real sessions instead of mocks. packages/mongospecs/tests/test_tracking_tx.py::test_transaction_commits | +| `test_or` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_page` | 3 | replaced | `Page` from `query.page()`. packages/mongospecs/tests/test_bulk_pagination.py | +| `test_paginator` | 3 | replaced | `query.page(size, number=n)`. packages/mongospecs/tests/test_bulk_pagination.py::test_offset_pagination | +| `test_paginator_with_filter` | 3 | replaced | packages/mongospecs/tests/test_bulk_pagination.py::test_cursor_pagination | +| `test_paginator_with_orphans` | 3 | dropped | orphans are not carried over | +| `test_paginator_with_per_page` | 3 | replaced | packages/mongospecs/tests/test_bulk_pagination.py::test_offset_pagination | +| `test_paginator_with_projection` | 3 | replaced | `select()` composes with `page()` | +| `test_paginator_with_sort` | 3 | replaced | packages/mongospecs/tests/test_bulk_pagination.py::test_cursor_pagination | +| `test_projection` | 6 | replaced | `select()` + `include()` replace the `$ref`/`$sub` projection DSL. packages/mongospecs/tests/test_crud.py::test_select_projection_and_partial_save, test_relations.py | +| `test_pull` | 6 | ported | `OnDelete("pull")`. packages/mongospecs/tests/test_relations.py::test_cascade_rules | +| `test_pydantic_adapter` | 1 | ported | `SpecAdapter`. packages/mongospecs/tests/test_codec.py::test_spec_adapter_turns_existing_class_into_document | +| `test_python_sort` | 6 | dropped | relied on id-based ordering of instances; sort in the query instead | +| `test_q` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_reload` | 6 | ported | packages/mongospecs/tests/test_crud.py::test_delete_and_reload | +| `test_remove_keys` | 9 | dropped | internal dict helper with no v2 equivalent | +| `test_size` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_sort_by` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_spec` | 6 | ported | packages/mongospecs/tests/test_codec.py | +| `test_stop_listening` | 6 | replaced | hooks are registered declaratively; `mongospecs.meta.remove_hook` exists for tests | +| `test_timestamp_insert` | 6 | ported | `CreatedAt`/`UpdatedAt` markers. packages/mongospecs/tests/test_plugins_hooks.py::test_timestamps_on_every_write_path | +| `test_timestamp_update` | 6 | ported | packages/mongospecs/tests/test_plugins_hooks.py::test_timestamps_on_every_write_path | +| `test_to_json_type` | 6 | replaced | backend-native serialisation; the web layer produces JSON views | +| `test_to_refs` | 2 | ported | references encode as ids. packages/mongospecs/tests/test_codec.py::test_documents_and_ids_assigned_to_refs_encode_as_ids | +| `test_transaction_abort` | 2 | ported | packages/mongospecs/tests/test_tracking_tx.py::test_transaction_abort_rolls_back_database_and_memory | +| `test_transaction_signals_aborted` | 1 | dropped | the exception raised by `registry.transaction(fn)` is the abort signal | +| `test_transaction_signals_committed` | 1 | dropped | transaction signals are gone; the awaited `registry.transaction(fn)` returning is the commit | +| `test_transaction_success` | 1 | ported | against a real replica set now. packages/mongospecs/tests/test_tracking_tx.py::test_transaction_commits | +| `test_type` | 3 | ported | packages/mongospecs/tests/test_expr.py | +| `test_unset` | 6 | ported | `Unset(...)` operator. packages/mongospecs/tests/test_updates.py::test_compile_operators | +| `test_unset_many` | 6 | ported | `Model.find(...).update(Unset(...))`. packages/mongospecs/tests/test_updates.py | +| `test_update` | 6 | ported | `save()` sends a delta; atomic `Model.update`. packages/mongospecs/tests/test_crud.py::test_save_new_then_delta, test_updates.py | +| `test_update_many` | 6 | ported | `Model.find(...).update(...)` and `bulk_save`. packages/mongospecs/tests/test_updates.py::test_filter_based_update_and_delete | +| `test_upsert` | 6 | ported | single round trip now. packages/mongospecs/tests/test_updates.py::test_upsert_single_round_trip | +| `test_with_options` | 6 | replaced | `Model.using(read_preference=..., write_concern=..., db=...)`. packages/mongospecs/tests/test_models_misc.py::test_using_another_database | + +Total: 302 v1 test runs across 71 distinct tests. diff --git a/mkdocs.yaml b/mkdocs.yaml index d321477..fb9472d 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -82,15 +82,4 @@ plugins: nav: - Home: - Overview: index.md -- API Reference: - - mongospecs: - - base: reference/base.md - - attrs: reference/attrs.md - - msgspec: reference/msgspec.md - - pydantic: reference/pydantic.md - - query: reference/query.md - - se: reference/se.md - - ops: reference/ops.md - - empty: reference/empty.md - - utils: reference/utils.md - - bson: reference/bson.md + - v1 test inventory: v1-test-inventory.md diff --git a/mongospecs/__init__.py b/mongospecs/__init__.py deleted file mode 100644 index ebe7f0c..0000000 --- a/mongospecs/__init__.py +++ /dev/null @@ -1,43 +0,0 @@ -from pymongo import ASCENDING as ASC -from pymongo import DESCENDING as DESC -from pymongo.collation import Collation -from pymongo.operations import IndexModel - -from mongospecs.helpers.empty import Empty -from mongospecs.helpers.ops import All, And, ElemMatch, Exists, In, Nor, Not, NotIn, Or, Size, SortBy, Type -from mongospecs.helpers.pagination import Page, Paginator -from mongospecs.helpers.query import Q -from mongospecs.helpers.se import MongoDecoder, MongoEncoder - -__all__ = [ - # Queries - "Q", - # Operators - "All", - "ElemMatch", - "Exists", - "In", - "Not", - "NotIn", - "Size", - "Type", - # Groups - "And", - "Or", - "Nor", - # Sorting - "SortBy", - # Se - "MongoEncoder", - "MongoDecoder", - # pymongo - "Collation", - "IndexModel", - "ASC", - "DESC", - # Empty - "Empty", - # Pagination - "Paginator", - "Page", -] diff --git a/mongospecs/attrs.py b/mongospecs/attrs.py deleted file mode 100644 index 85c2987..0000000 --- a/mongospecs/attrs.py +++ /dev/null @@ -1,90 +0,0 @@ -import typing as t -from datetime import datetime - -import attrs -import msgspec -from attr import AttrsInstance -from bson import ObjectId -from pymongo import MongoClient - -from mongospecs.base import SpecBase, SubSpecBase -from mongospecs.helpers.empty import Empty -from mongospecs.helpers.se import MongoEncoder, mongo_dec_hook - -__all__ = ["Spec", "SubSpec"] - -T = t.TypeVar("T") - - -def attrs_serializer(inst: type, field: attrs.Attribute, value: t.Any) -> t.Any: # type: ignore[type-arg] - if isinstance(value, ObjectId): - return str(value) - elif isinstance(value, datetime): - return datetime.isoformat(value) - return value - - -@attrs.define(kw_only=True) -class Spec(SpecBase): - _id: t.Optional[ObjectId] = attrs.field(default=None, alias="_id", repr=True) # type: ignore[assignment] - - @property - def id(self) -> t.Optional[ObjectId]: - return self._id - - @id.setter - def id(self, value: ObjectId) -> None: - self._id = value - - @classmethod - def get_fields(cls) -> set[str]: - return {f.name for f in attrs.fields(cls)} - - def encode(self, **encode_kwargs: t.Any) -> bytes: - return msgspec.json.encode(self, **encode_kwargs) if encode_kwargs else MongoEncoder.encode(self) - - def decode(self, data: t.Any, **decode_kwargs: t.Any) -> t.Any: - return msgspec.json.decode(data, type=self.__class__, dec_hook=mongo_dec_hook, **decode_kwargs) - - def to_json_type(self) -> t.Any: - return attrs.asdict( - self, - filter=lambda attr, value: value is not Empty or (attr.name == "_id" and value is not None), - value_serializer=attrs_serializer, - ) - - def to_dict(self) -> dict[str, t.Any]: - return attrs.asdict(self, recurse=False).copy() - - def to_tuple(self) -> tuple[t.Any, ...]: - return attrs.astuple(self) - - -@attrs.define(kw_only=True) -class SubSpec(SubSpecBase): - _parent: t.ClassVar[t.Any] = Spec - - def get(self, name: str, default: t.Any = None) -> t.Any: - return self.to_dict().get(name, default) - - def to_dict(self) -> dict[str, t.Any]: - return attrs.asdict(self) - - -class AdapterBuilder: - def __call__( - self, obj: type[AttrsInstance], *, collection: str, client: t.Optional[MongoClient[t.Any]] = None, **kwds: t.Any - ) -> t.Any: - @attrs.define(kw_only=True) - class BuiltSpecAdapter(Spec, obj): # type: ignore - ... - - BuiltSpecAdapter.__name__ = f"{obj.__name__}SpecAdapter" - BuiltSpecAdapter._collection = collection - BuiltSpecAdapter.__doc__ = obj.__doc__ - if client: - BuiltSpecAdapter._client = client - return BuiltSpecAdapter - - -SpecAdapter = AdapterBuilder() diff --git a/mongospecs/base.py b/mongospecs/base.py deleted file mode 100644 index 4b8cc09..0000000 --- a/mongospecs/base.py +++ /dev/null @@ -1,86 +0,0 @@ -from __future__ import annotations - -import typing as t -from copy import deepcopy - -from mongospecs.mixins.crud import CrudMixin -from mongospecs.mixins.index import IndexManagementMixin -from mongospecs.mixins.integrity import IntegrityMixin -from mongospecs.mixins.session import SessionTransactionMixin -from mongospecs.mixins.signal import SignalMixin -from mongospecs.types import SubSpecBaseType - -T = t.TypeVar("T") - - -class SpecBase(CrudMixin, IndexManagementMixin, IntegrityMixin, SessionTransactionMixin, SignalMixin): - pass - - -class SubSpecBase(SubSpecBaseType): - _parent: t.ClassVar[t.Any] = SpecBase - - @classmethod - def _apply_projection(cls, documents: list[t.Any], projection: t.Mapping[str, t.Any]) -> None: - # Find reference and sub-spec mappings - references = {} - subs = {} - for key, value in deepcopy(projection).items(): - if not isinstance(value, dict): - continue - - # Store a reference/sub-spec projection - if "$ref" in value: - references[key] = value - elif "$sub" in value or "$sub." in value: - subs[key] = value - - # Dereference the documents (if required) - if references: - cls._parent._dereference(documents, references) - - # Add sub-specs to the documents (if required) - if subs: - cls._parent._apply_sub_specs(documents, subs) - - @classmethod - def _projection_to_paths(cls, root_key: str, projection: t.Mapping[str, t.Any]) -> t.Any: - """ - Expand a $sub/$sub. projection to a single projection of True (if - inclusive) or a map of full paths (e.g `employee.company.tel`). - """ - - # Referenced projections are handled separately so just flag the - # reference field to true. - if "$ref" in projection: - return True - - inclusive = True - sub_projection: dict[str, t.Any] = {} - for key, value in projection.items(): - if key in ["$sub", "$sub."]: - continue - - if key.startswith("$"): - sub_projection[root_key] = {key: value} - inclusive = False - continue - - sub_key = f"{root_key}.{key}" - - if isinstance(value, dict): - sub_value = cls._projection_to_paths(sub_key, value) - if isinstance(sub_value, dict): - sub_projection |= sub_value - else: - sub_projection[sub_key] = True - - else: - sub_projection[sub_key] = True - inclusive = False - - if inclusive: - # No specific keys so this is inclusive - return True - - return sub_projection diff --git a/mongospecs/helpers/__init__.py b/mongospecs/helpers/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/mongospecs/helpers/bson.py b/mongospecs/helpers/bson.py deleted file mode 100644 index a3072bf..0000000 --- a/mongospecs/helpers/bson.py +++ /dev/null @@ -1,40 +0,0 @@ -import typing as t -from datetime import date - -import bson -import msgspec - -from mongospecs.base import SpecBase -from mongospecs.helpers.empty import Empty, EmptyObject - - -def bson_enc_hook(obj: t.Any) -> t.Any: - if obj is msgspec.UNSET or obj is Empty: - return None - if type(obj) == date: - return str(obj) - if isinstance(obj, bson.ObjectId): - return str(obj) - - raise NotImplementedError(f"Objects of type {type(obj)} are not supported") - - -def bson_dec_hook(typ: t.Any, val: t.Any) -> t.Any: - if typ == bson.ObjectId: - return bson.ObjectId(val) - if typ == EmptyObject: - return Empty - - -def encode(obj: t.Any, enc_hook: t.Optional[t.Callable[[t.Any], t.Any]] = bson_enc_hook) -> bytes: - return bson.encode(msgspec.to_builtins(obj, enc_hook=enc_hook, builtin_types=(bson.ObjectId,))) - - -def encode_spec(obj: SpecBase) -> bytes: - return bson.encode(obj.to_json_type()) - - -def decode( - msg: bytes, typ: t.Any = t.Any, dec_hook: t.Optional[t.Callable[[type, t.Any], t.Any]] = bson_dec_hook -) -> t.Any: - return msgspec.convert(bson.decode(msg), type=typ, dec_hook=dec_hook) diff --git a/mongospecs/helpers/empty.py b/mongospecs/helpers/empty.py deleted file mode 100644 index 61442e7..0000000 --- a/mongospecs/helpers/empty.py +++ /dev/null @@ -1,12 +0,0 @@ -class EmptyObject(str): - def __repr__(self) -> str: - return "Empty" - - def __bool__(self) -> bool: - return False - - def __str__(self) -> str: - return "Empty" - - -Empty = EmptyObject() diff --git a/mongospecs/helpers/ops.py b/mongospecs/helpers/ops.py deleted file mode 100644 index 80a2214..0000000 --- a/mongospecs/helpers/ops.py +++ /dev/null @@ -1,145 +0,0 @@ -import typing as t - -from pymongo import ASCENDING, DESCENDING - -from mongospecs.helpers.query import Condition, Group, Q -from mongospecs.utils import deep_merge, to_refs - -__all__ = [ - # Operators - "All", - "ElemMatch", - "Exists", - "In", - "Not", - "NotIn", - "Size", - "Type", - # Groups - "And", - "Or", - "Nor", - # Sorting - "SortBy", -] - -# Operators - - -def All(q: Q, value: list[t.Any]) -> Condition: - """ - The All operator selects documents where the value of the field is an list - that contains all the specified elements. - """ - return Condition(q._path, to_refs(value), "$all") - - -def ElemMatch(q: Q, *conditions: t.Union[Condition, Group, dict[str, t.Any]]) -> Condition: - """ - The ElemMatch operator matches documents that contain an array field with at - least one element that matches all the specified query criteria. - """ - new_condition: dict[str, t.Any] = {} - for condition in conditions: - if isinstance(condition, (Condition, Group)): - condition = condition.to_dict() - - deep_merge(condition, new_condition) - - return Condition(q._path, new_condition, "$elemMatch") - - -def Exists(q: Q, value: bool) -> Condition: - """ - When exists is True, Exists matches the documents that contain the field, - including documents where the field value is null. If exists is False, the - query returns only the documents that do not contain the field. - """ - return Condition(q._path, value, "$exists") - - -def In(q: Q, value: list[t.Any]) -> Condition: - """ - The In operator selects the documents where the value of a field equals any - value in the specified list. - """ - return Condition(q._path, to_refs(value), "$in") - - -def Not(condition: Condition) -> Condition: - """ - Not performs a logical NOT operation on the specified condition and selects - the documents that do not match. This includes documents that do not contain - the field. - """ - return Condition(condition.q, {condition.operator: condition.value}, "$not") - - -def NotIn(q: Q, value: list[t.Any]) -> Condition: - """ - The NotIn operator selects documents where the field value is not in the - specified list or the field does not exists. - """ - return Condition(q._path, to_refs(value), "$nin") - - -def Size(q: Q, value: int) -> Condition: - """ - The Size operator matches any list with the number of elements specified by - size. - """ - return Condition(q._path, value, "$size") - - -def Type(q: Q, value: int) -> Condition: - """ - Type selects documents where the value of the field is an instance of the - specified BSON type. - """ - return Condition(q._path, value, "$type") - - -# Groups - - -class And(Group): - """ - And performs a logical AND operation on a list of two or more conditions and - selects the documents that satisfy all the conditions. - """ - - operator = "$and" - - -class Or(Group): - """ - The Or operator performs a logical OR operation on a list of two or more - conditions and selects the documents that satisfy at least one of the - conditions. - """ - - operator = "$or" - - -class Nor(Group): - """ - Nor performs a logical NOR operation on a list of one or more conditions and - selects the documents that fail all the conditions. - """ - - operator = "$nor" - - -# Sorting - - -def SortBy(*qs: Q) -> list[tuple[str, int]]: - """Convert a list of Q objects into list of sort instructions""" - - sort = [] - for q in qs: - if q._path.endswith(".desc"): - sort.append((q._path[:-5], DESCENDING)) - else: - sort.append((q._path, ASCENDING)) - return sort diff --git a/mongospecs/helpers/pagination.py b/mongospecs/helpers/pagination.py deleted file mode 100644 index 714b2ee..0000000 --- a/mongospecs/helpers/pagination.py +++ /dev/null @@ -1,157 +0,0 @@ -""" -Support for paginating specs. -""" - -import math -import typing as t -from copy import deepcopy -from dataclasses import dataclass - -from mongospecs.base import SpecBase -from mongospecs.helpers.query import Condition, Group -from mongospecs.utils import to_refs - -__all__ = ( - # Exceptions - "InvalidPage", - # Classes - "Page", - "Paginator", -) - - -T = t.TypeVar("T") -TSpec = t.TypeVar("TSpec", bound=SpecBase) - - -class InvalidPage(Exception): - """ - An error raised when an invalid page is requested. - """ - - -@dataclass -class Page(t.Generic[T]): - """ - A class to represent one page of results. - """ - - offset: int - """The offset of the first result in the page from the first result of - the entire selection. - """ - - number: int - """The page number""" - - items: list[T] - """The results/specs for this page""" - - next: t.Optional[int] - """Next page number""" - - prev: t.Optional[int] - """Previous page number""" - - def __getitem__(self, i: int) -> T: - return self.items[i] - - def __iter__(self) -> t.Iterator[T]: - yield from self.items - - def __len__(self) -> int: - return len(self.items) - - def get_offset(self, item: t.Any) -> int: - """Return the offset for an item in the page""" - return self.offset + self.items.index(item) - - -# TODO: REFACTOR - - -@dataclass -class Paginator(t.Generic[TSpec]): - """ - A pagination class for slicing query results into pages. This class is - designed to work with Spec classes. - """ - - spec_cls: type[TSpec] - """The spec class results are being paginated for""" - - filter: t.Optional[t.Union[dict[str, t.Any], Condition, Group]] = None - """The filter applied when selecting results from the database""" - - per_page: int = 20 - """The number of results that will be displayed per page""" - - orphans: int = 0 - """ If a value is specified for orphans then the last page will be able to - hold the additional results (up to the value of orphans). This can - help prevent users being presented with pages contain only a few - results. - """ - - filter_kwargs: t.Any = None - """Any additional filter arguments applied when selecting results such as sort and projection""" - - def __post_init__(self) -> None: - # flattern the filter at this point which effectively deep copies. - if isinstance(self.filter, (Condition, Group)): - self.filter = self.filter.to_dict() - else: - self.filter = to_refs(self.filter) - - # Count the total results being paginated - self._items_count = self.spec_cls.count(self.filter) - - # Calculated the number of pages - total = self._items_count - self.orphans - self._page_count = max(1, int(math.ceil(total / float(self.per_page)))) - - # Create a list of page number that can be used to navigate the results - self._page_numbers = range(1, self._page_count + 1) - - def __getitem__(self, page_number: int) -> Page[TSpec]: - if page_number not in self._page_numbers: - raise InvalidPage(page_number, self.page_count) - - # Calculate the next and previous page numbers - next = page_number + 1 if page_number + 1 in self._page_numbers else None - prev = page_number - 1 if page_number - 1 in self._page_numbers else None - - # Select the items for the page - filter_args = deepcopy(self.filter_kwargs) or {} - filter_args["skip"] = (page_number - 1) * self.per_page - filter_args["limit"] = self.per_page - - # Check to see if we need to account for orphans - if self.item_count - (page_number * self.per_page) <= self.orphans: - filter_args["limit"] += self.orphans - - # Select the results for the page - items = self.spec_cls.many(self.filter, **filter_args) - - # Build the page - return Page(offset=filter_args["skip"], number=page_number, items=items, next=next, prev=prev) - - def __iter__(self) -> t.Generator[Page[TSpec], t.Any, t.Any]: - for page_number in self._page_numbers: - yield self[page_number] - - # Read-only properties - @property - def item_count(self) -> int: - """Return the total number of items being paginated""" - return self._items_count - - @property - def page_count(self) -> int: - """Return the total number of pages""" - return self._page_count - - @property - def page_numbers(self) -> range: - """Return a list of page numbers""" - return self._page_numbers diff --git a/mongospecs/helpers/query.py b/mongospecs/helpers/query.py deleted file mode 100644 index 1becbe0..0000000 --- a/mongospecs/helpers/query.py +++ /dev/null @@ -1,121 +0,0 @@ -""" -A set of helpers to simplify the creation of MongoDB queries. -""" - -import typing as t - -__all__ = [ - # Queries - "Q", -] - - -# Queries -class Condition: - """ - A query condition of the form `{path: {operator: value}}`. - """ - - def __init__(self, q: t.Optional[str], value: t.Any, operator: str): - self.q = q - self.value = value - self.operator = operator - - def to_dict(self) -> dict[str, dict[str, t.Any]]: - """Return a dictionary suitable for use with pymongo as a filter""" - if self.q is None: - return {self.operator: self.value} - if self.operator == "$eq": - return {self.q: self.value} - return {self.q: {self.operator: self.value}} - - -class QMeta(type): - """ - Meta-class for query builder. - """ - - def __getattr__(self, name: str) -> "Q": - return Q(name) - - def __getitem__(self, name: str) -> "Q": - return Q(name) - - def __eq__(self, other: t.Any) -> Condition: # type: ignore[override] - return Condition(None, other, "$eq") - - def __ge__(self, other: t.Any) -> Condition: - return Condition(None, other, "$gte") - - def __gt__(self, other: t.Any) -> Condition: - return Condition(None, other, "$gt") - - def __le__(self, other: t.Any) -> Condition: - return Condition(None, other, "$lte") - - def __lt__(self, other: t.Any) -> Condition: - return Condition(None, other, "$lt") - - def __ne__(self, other: t.Any) -> Condition: # type: ignore[override] - return Condition(None, other, "$ne") - - -class Q(metaclass=QMeta): - """ - Start point for the query creation, the Q class is a special type of class - that's typically initialized by appending an attribute, for example: - - Q.hit_points > 100 - - """ - - def __init__(self, path: str): - self._path = path - - def __eq__(self, other: t.Any) -> Condition: # type: ignore[override] - return Condition(self._path, other, "$eq") - - def __ge__(self, other: t.Any) -> Condition: - return Condition(self._path, other, "$gte") - - def __gt__(self, other: t.Any) -> Condition: - return Condition(self._path, other, "$gt") - - def __le__(self, other: t.Any) -> Condition: - return Condition(self._path, other, "$lte") - - def __lt__(self, other: t.Any) -> Condition: - return Condition(self._path, other, "$lt") - - def __ne__(self, other: t.Any) -> Condition: # type: ignore[override] - return Condition(self._path, other, "$ne") - - def __getattr__(self, name: str) -> "Q": - self._path = "{0}.{1}".format(self._path, name) - return self - - def __getitem__(self, name: str) -> "Q": - self._path = "{0}.{1}".format(self._path, name) - return self - - -class Group: - """ - The Group class is used as a base class for operators that group together - two or more conditions. - """ - - operator = "" - - def __init__(self, *conditions: t.Any): - self.conditions = conditions - - def to_dict(self) -> dict[str, t.Any]: - """Return a dictionary suitable for use with pymongo as a filter""" - raw_conditions = [] - for condition in self.conditions: - if isinstance(condition, (Condition, Group)): - raw_conditions.append(condition.to_dict()) - else: - raw_conditions.append(condition) - return {self.operator: raw_conditions} diff --git a/mongospecs/helpers/se.py b/mongospecs/helpers/se.py deleted file mode 100644 index fba5674..0000000 --- a/mongospecs/helpers/se.py +++ /dev/null @@ -1,36 +0,0 @@ -import typing as t -from datetime import date - -try: - import attrs - - ATTRS_NOTHING_TYPE = attrs.NOTHING -except ImportError: - ATTRS_NOTHING_TYPE = type("BaseNothing") # type: ignore[assignment] - -import msgspec -from bson import ObjectId - -__all__ = ["MongoEncoder", "MongoDecoder"] - - -def mongo_enc_hook(obj: t.Any) -> t.Any: - if obj is msgspec.UNSET or obj is ATTRS_NOTHING_TYPE: - return None - elif type(obj) == date: - return str(obj) - # Object Id - elif isinstance(obj, ObjectId): - return str(obj) - - raise NotImplementedError(f"Objects of type {type(obj)} are not supported") - - -def mongo_dec_hook(typ: t.Any, obj: t.Any) -> t.Any: - if typ is ObjectId: - return ObjectId(obj) - raise NotImplementedError(f"Objects of type {type(obj)} are not supported") - - -MongoEncoder = msgspec.json.Encoder(enc_hook=mongo_enc_hook) -MongoDecoder = msgspec.json.Decoder(dec_hook=mongo_dec_hook) diff --git a/mongospecs/mixins/base.py b/mongospecs/mixins/base.py deleted file mode 100644 index e1a6f7d..0000000 --- a/mongospecs/mixins/base.py +++ /dev/null @@ -1,323 +0,0 @@ -from __future__ import annotations - -import typing as t -from abc import abstractmethod -from contextlib import contextmanager -from copy import deepcopy - -from bson import BSON, ObjectId -from pymongo import MongoClient -from pymongo.collection import Collection -from pymongo.database import Database -from typing_extensions import Self - -from mongospecs.helpers.empty import Empty, EmptyObject -from mongospecs.types import RawDocuments, SpecBaseType, SpecDocumentType, SpecsOrRawDocuments - - -class MongoBaseMixin(SpecBaseType): - _client: t.ClassVar[t.Optional[MongoClient[t.Any]]] = None - _db: t.ClassVar[t.Optional[Database[t.Any]]] = None - _collection: t.ClassVar[t.Optional[str]] = None - _collection_context: t.ClassVar[t.Optional[Collection[t.Any]]] = None - _default_projection: t.ClassVar[dict[str, t.Any]] = {} - _empty_type: t.ClassVar[t.Any] = Empty - _id: t.Union[EmptyObject, ObjectId] - - @classmethod - def from_document(cls, document: dict[str, t.Any]) -> Self: - return cls(**document) - - @classmethod - def from_raw_bson(cls, raw_bson: t.Any) -> t.Any: - decoded_data = BSON.decode(raw_bson) - return cls(**decoded_data) - - def __eq__(self, other: t.Any) -> bool: - if not isinstance(other, self.__class__): - return False - - return self._id == other._id - - def __lt__(self, other: t.Any) -> bool: - return self._id < other._id # type: ignore[no-any-return] - - @classmethod - def get_collection(cls) -> Collection[t.Any]: - """Return a reference to the database collection for the class""" - if cls._collection_context is not None: - return cls._collection_context - - return t.cast(Collection[t.Any], getattr(cls.get_db(), cls._collection or cls.__name__)) - - @classmethod - def get_db(cls) -> Database[SpecDocumentType]: - """Return the database for the collection""" - if not cls._client: - raise NotImplementedError("_client is not setup yet") - if cls._db is not None: - return t.cast(Database[SpecDocumentType], getattr(cls._client, cls._db.name)) - return t.cast(Database[SpecDocumentType], cls._client.get_default_database()) - - @classmethod - @contextmanager - def with_options(cls, **options: t.Any) -> t.Generator[t.Any, t.Any, None]: - existing_context = getattr(cls, "_collection_context", None) - - try: - collection = cls.get_collection() - cls._collection_context = collection.with_options(**options) - yield cls._collection_context - finally: - cls._collection_context = existing_context - - @classmethod - def _path_to_value(cls, path: str, parent_dict: SpecDocumentType) -> t.Any: - """Return a value from a dictionary at the given path""" - keys: list[str] = cls._path_to_keys(path) - - # Traverse to the tip of the path - child_dict = parent_dict - for key in keys[:-1]: - child_dict = child_dict.get(key) # type: ignore[assignment] - - # unpaved path- return None - if child_dict is None: - return None - - return child_dict.get(keys[-1]) - - @classmethod - def _path_to_keys(cls, path: str) -> list[str]: - """Return a list of keys for a given path""" - return path.split(".") - - @classmethod - def _ensure_specs(cls, documents: SpecsOrRawDocuments) -> t.Sequence[Self]: - """ - Ensure all items in a list are specs by converting those that aren't. - """ - specs = [] - for document in documents: - if isinstance(document, cls): - specs.append(document) - elif isinstance(document, dict): - specs.append(cls(**document)) - return specs - - @classmethod - def _apply_sub_specs(cls, documents: RawDocuments, subs: dict[str, t.Any]) -> None: - """Convert embedded documents to sub-specs for one or more documents""" - - # Dereference each reference - for path, projection in subs.items(): - # Get the SubSpec class we'll use to wrap the embedded document - sub = None - expect_map = False - if "$sub" in projection: - sub = projection.pop("$sub") - elif "$sub." in projection: - sub = projection.pop("$sub.") - expect_map = True - else: - continue - - # Add sub-specs to the documents - raw_subs: list[t.Any] = [] - for document in documents: - value = cls._path_to_value(path, document) - if value is None: - continue - - if isinstance(value, dict): - if expect_map: - # Dictionary of embedded documents - raw_subs += value.values() - for k, v in value.items(): - if isinstance(v, list): - value[k] = [sub(u) for u in v if isinstance(u, dict)] - else: - value[k] = sub(**v) - - # Single embedded document - else: - raw_subs.append(value) - value = sub(**value) - - elif isinstance(value, list): - # List of embedded documents - raw_subs += value - value = [sub(**v) for v in value if isinstance(v, dict)] - - else: - raise TypeError("Not a supported sub-spec type") - - child_document = document - keys = cls._path_to_keys(path) - for key in keys[:-1]: - child_document = child_document[key] - child_document[keys[-1]] = value - - # Apply the projection to the list of sub specs - if projection: - sub._apply_projection(raw_subs, projection) - - @classmethod - def _flatten_projection( - cls, projection: dict[str, t.Any] - ) -> tuple[dict[str, t.Any], dict[str, t.Any], dict[str, t.Any]]: - """ - Flatten a structured projection (structure projections support for - projections of (to be) dereferenced fields. - """ - - # If `projection` is empty return a full projection based on `_fields` - if not projection: - return {f: True for f in cls.get_fields()}, {}, {} - - # Flatten the projection - flat_projection: dict[str, t.Any] = {} - references = {} - subs = {} - inclusive = True - for key, value in deepcopy(projection).items(): - if isinstance(value, dict): - # Build the projection value for the field (allowing for - # special mongo directives). - values_to_project = { - k: v for k, v in value.items() if k.startswith("$") and k not in ["$ref", "$sub", "$sub."] - } - project_value = True if len(values_to_project) == 0 else {key: values_to_project} - - if project_value is not True: - inclusive = False - - # Store a reference/sub-spec projection - if "$ref" in value: - references[key] = value - - elif "$sub" in value or "$sub." in value: - subs[key] = value - sub_spec = None - if "$sub" in value: - sub_spec = value["$sub"] - - if "$sub." in value: - sub_spec = value["$sub."] - - if sub_spec: - project_value = sub_spec._projection_to_paths(key, value) - - if isinstance(project_value, dict): - flat_projection |= project_value - else: - flat_projection[key] = project_value - - elif key == "$ref": - # Strip any $ref key - continue - - elif key == "$sub" or key == "$sub.": - # Strip any $sub key - continue - - elif key.startswith("$"): - # Strip mongo operators - continue - - else: - # Store the root projection value - flat_projection[key] = value - inclusive = False - - # If only references and sub-specs where specified in the projection - # then return a full projection based on `_fields`. - if inclusive: - flat_projection = {f: True for f in cls.get_fields()} - - return flat_projection, references, subs - - @classmethod - def _dereference(cls, documents: RawDocuments, references: dict[str, t.Any]) -> None: - """Dereference one or more documents""" - - # Dereference each reference - for path, projection in references.items(): - # Check there is a $ref in the projection, else skip it - if "$ref" not in projection: - continue - - # Collect Ids of documents to dereference - ids = set() - for document in documents: - value = cls._path_to_value(path, document) - if not value: - continue - - if isinstance(value, list): - ids.update(value) - - elif isinstance(value, dict): - ids.update(value.values()) - - else: - ids.add(value) - - # Find the referenced documents - ref = projection.pop("$ref") - - specs = ref.many({"_id": {"$in": list(ids)}}, projection=projection) - specs = {f._id: f for f in specs} - - # Add dereferenced specs to the document - for document in documents: - value = cls._path_to_value(path, document) - if not value: - continue - - if isinstance(value, list): - # List of references - value = [specs[id] for id in value if id in specs] - - elif isinstance(value, dict): - # Dictionary of references - value = {key: specs.get(id) for key, id in value.items()} - - else: - value = specs.get(value) - - child_document = document - keys = cls._path_to_keys(path) - for key in keys[:-1]: - child_document = child_document[key] - child_document[keys[-1]] = value - - @classmethod - def _remove_keys(cls, parent_dict: dict[str, t.Any], paths: list[str]) -> None: - """ - Remove a list of keys from a dictionary. - - Keys are specified as a series of `.` separated paths for keys in child - dictionaries, e.g 'parent_key.child_key.grandchild_key'. - """ - - for path in paths: - keys = cls._path_to_keys(path) - - # Traverse to the tip of the path - child_dict = parent_dict - for key in keys[:-1]: - child_dict = child_dict.get(key, {}) - - if not isinstance(child_dict, dict): - continue - - if not isinstance(child_dict, dict): - continue - - child_dict.pop(keys[-1], None) - - @classmethod - @abstractmethod - def get_fields(cls) -> set[str]: - raise NotImplementedError diff --git a/mongospecs/mixins/crud.py b/mongospecs/mixins/crud.py deleted file mode 100644 index e891a52..0000000 --- a/mongospecs/mixins/crud.py +++ /dev/null @@ -1,332 +0,0 @@ -import typing as t - -from blinker import signal -from pymongo import UpdateOne -from pymongo.collection import Collection -from typing_extensions import Self - -from mongospecs.helpers.query import Condition, Group -from mongospecs.mixins.query import QueryMixin -from mongospecs.types import FilterType, SpecDocumentType, SpecsOrRawDocuments -from mongospecs.utils import to_refs - - -class CrudMixin(QueryMixin): - # Operations - def insert(self, **insert_one_kwargs: t.Any) -> None: - """Insert this document""" - # Send insert signal - signal("insert").send(self.__class__, specs=[self]) - - document_dict = self.to_dict() - if not self._id: - document_dict.pop("_id", None) - # Prepare the document to be inserted - document = to_refs(document_dict) - - # Insert the document and update the Id - self._id = self.get_collection().insert_one(document, **insert_one_kwargs).inserted_id - - # Send inserted signal - signal("inserted").send(self.__class__, specs=[self]) - - def unset(self, *fields: t.Any, **update_one_kwargs: t.Any) -> None: - """Unset the given list of fields for this document.""" - - # Send update signal - signal("update").send(self.__class__, specs=[self]) - - # Clear the fields from the document and build the unset object - unset = {} - for field in fields: - setattr(self, field, self._empty_type) - unset[field] = True - - # Update the document - self.get_collection().update_one({"_id": self._id}, {"$unset": unset}, **update_one_kwargs) - - # Send updated signal - signal("updated").send(self.__class__, specs=[self]) - - def update(self, *fields: t.Any, **update_one_kwargs: t.Any) -> None: - """ - Update this document. Optionally a specific list of fields to update can - be specified. - """ - self_document = self.to_dict() - if "_id" not in self_document: - raise ValueError("Can't update documents without `_id`") - - # Send update signal - signal("update").send(self.__class__, specs=[self]) - - # Check for selective updates - if fields: - document = {field: self._path_to_value(field, self_document) for field in fields} - else: - document = self_document - - # Prepare the document to be updated - document = to_refs(document) - document.pop("_id", None) - - # Update the document - self.get_collection().update_one({"_id": self._id}, {"$set": document}, **update_one_kwargs) - - # Send updated signal - signal("updated").send(self.__class__, specs=[self]) - - def upsert(self, *fields: t.Any, **operation_kwargs: t.Any) -> None: - """ - Update or Insert this document depending on whether it exists or not. - The presense of an `_id` value in the document is used to determine if - the document exists. - - NOTE: This method is not the same as specifying the `upsert` flag when - calling MongoDB. When called for a document with an `_id` value, this - method will call the database to see if a record with that Id exists, - if not it will call `insert`, if so it will call `update`. This - operation is therefore not atomic and much slower than the equivalent - MongoDB operation (due to the extra call). - """ - - # If no `_id` is provided then we insert the document - if not self._id: - return self.insert() - - # If an `_id` is provided then we need to check if it exists before - # performing the `upsert`. - # - if self.count({"_id": self._id}) == 0: - self.insert(**operation_kwargs) - else: - self.update(*fields, **operation_kwargs) - - def delete(self, **delete_one_kwargs: t.Any) -> None: - """Delete this document""" - - if "_id" not in self.to_dict(): - raise ValueError("Can't delete documents without `_id`") - - # Send delete signal - signal("delete").send(self.__class__, specs=[self]) - - # Delete the document - self.get_collection().delete_one({"_id": self._id}, **delete_one_kwargs) - - # Send deleted signal - signal("deleted").send(self.__class__, specs=[self]) - - @classmethod - def find(cls, filter: FilterType = None, **kwargs: t.Any) -> list[SpecDocumentType]: - """Return a list of documents matching the filter""" - # Flatten the projection - kwargs["projection"], references, subs = cls._flatten_projection( - kwargs.get("projection", cls._default_projection) - ) - - # Find the document - if isinstance(filter, (Condition, Group)): - filter = filter.to_dict() - - documents = list(cls.get_collection().find(to_refs(filter), **kwargs)) - - # Make sure we found documents - if not documents: - return [] - - # Dereference the documents (if required) - if references: - cls._dereference(documents, references) - - # Add sub-specs to the documents (if required) - if subs: - cls._apply_sub_specs(documents, subs) - - return documents - - @classmethod - def find_one(cls, filter: FilterType = None, **kwargs: t.Any) -> SpecDocumentType: - """Return the first document matching the filter""" - # Flatten the projection - kwargs["projection"], references, subs = cls._flatten_projection( - kwargs.get("projection", cls._default_projection) - ) - - # Find the document - if isinstance(filter, (Condition, Group)): - filter = filter.to_dict() - - coll: Collection[SpecDocumentType] = cls.get_collection() - document = coll.find_one(to_refs(filter), **kwargs) - - # Make sure we found a document - if not document: - return t.cast(SpecDocumentType, {}) - - # Dereference the document (if required) - if references: - cls._dereference([document], references) - - # Add sub-specs to the document (if required) - if subs: - cls._apply_sub_specs([document], subs) - - return document - - def reload(self, **kwargs: t.Any) -> None: - """Reload the document""" - spec = self.find_one({"_id": self._id}, **kwargs) - for field in spec: - setattr(self, field, spec[field]) - - @classmethod - def insert_many(cls, documents: SpecsOrRawDocuments, **kwargs: t.Any) -> t.Sequence[Self]: - """Insert a list of documents""" - # Ensure all documents have been converted to specs - specs = cls._ensure_specs(documents) - - # Send insert signal - signal("insert").send(cls, specs=specs) - - # Prepare the documents to be inserted - _documents = [to_refs(f.to_dict()) for f in specs] - - for _document in _documents: - if not _document["_id"]: - _document.pop("_id") - - # Bulk insert - ids = cls.get_collection().insert_many(_documents, **kwargs).inserted_ids - - # Apply the Ids to the specs - for i, id in enumerate(ids): - specs[i]._id = id - - # Send inserted signal - signal("inserted").send(cls, specs=specs) - - return specs - - @classmethod - def update_many( - cls, - documents: SpecsOrRawDocuments, - *fields: t.Any, - update_one_kwargs: t.Any = None, - bulk_write_kwargs: t.Any = None, - ) -> None: - """ - Update multiple documents. Optionally a specific list of fields to - update can be specified. - """ - # Ensure all documents have been converted to specs - specs = cls._ensure_specs(documents) - - if not all(f._id for f in specs): - raise ValueError("Can't update documents without `_id`s") - - # Send update signal - signal("update").send(cls, specs=specs) - - # Prepare the documents to be updated - - # Check for selective updates - if fields: - _documents = [] - for spec in specs: - document = {"_id": spec._id} - for field in fields: - document[field] = cls._path_to_value(field, spec.to_dict()) - _documents.append(to_refs(document)) - else: - _documents = [to_refs(f.to_dict()) for f in specs] - - if not update_one_kwargs: - update_one_kwargs = {} - if not bulk_write_kwargs: - bulk_write_kwargs = {} - - # Update the documents - requests = [] - for _document in _documents: - _id = _document.pop("_id") - requests.append(UpdateOne({"_id": _id}, {"$set": _document}, **update_one_kwargs)) - - cls.get_collection().bulk_write(requests, **bulk_write_kwargs) - - # Send updated signal - signal("updated").send(cls, specs=specs) - - @classmethod - def unset_many(cls, documents: SpecsOrRawDocuments, *fields: t.Any, **update_many_kwargs: t.Any) -> None: - """Unset the given list of fields for given documents.""" - - # Ensure all documents have been converted to specs - specs = cls._ensure_specs(documents) - - if not all(f._id for f in specs): - raise ValueError("Can't update documents without `_id`s") - - # Send update signal - signal("update").send(cls, specs=specs) - - ids = [spec._id for spec in specs if spec._id] - # Build the unset object - unset = {} - for field in fields: - unset[field] = True - for spec in specs: - spec.to_dict().pop(field, None) - - # Update the document - cls.get_collection().update_many({"_id": {"$in": ids}}, {"$unset": unset}, **update_many_kwargs) - - # Send updated signal - signal("updated").send(cls, specs=specs) - - @classmethod - def delete_many(cls, documents: SpecsOrRawDocuments, **delete_many_kwargs: t.Any) -> None: - """Delete multiple documents""" - - # Ensure all documents have been converted to specs - specs = cls._ensure_specs(documents) - - if not all(f._id for f in specs): - raise ValueError("Can't delete documents without `_id`s") - - # Send delete signal - signal("delete").send(cls, specs=specs) - - # Prepare the documents to be deleted - ids = [f._id for f in specs] - - # Delete the documents - cls.get_collection().delete_many({"_id": {"$in": ids}}, **delete_many_kwargs) - - # Send deleted signal - signal("deleted").send(cls, specs=specs) - - def soft_delete(self, **update_one_kwargs: t.Any) -> None: - """Soft delete this document by setting a deleted flag.""" - if "_id" not in self.to_dict(): - raise ValueError("Can't delete documents without `_id`") - - # Send delete signal - signal("soft_delete").send(self.__class__, specs=[self]) - - # Update the document to set the deleted flag - self.get_collection().update_one({"_id": self._id}, {"$set": {"deleted": True}}, **update_one_kwargs) - - # Send deleted signal - signal("soft_deleted").send(self.__class__, specs=[self]) - - @classmethod - def find_active(cls, filter: FilterType = None, **find_kwargs: t.Any) -> list[SpecDocumentType]: - """Return a list of active documents (not soft deleted).""" - if filter is None: - filter = {} - if isinstance(filter, (Condition, Group)): - filter = filter.to_dict() - filter["deleted"] = {"$ne": True} # Exclude soft deleted documents - return cls.find(filter, **find_kwargs) diff --git a/mongospecs/mixins/index.py b/mongospecs/mixins/index.py deleted file mode 100644 index 74137a2..0000000 --- a/mongospecs/mixins/index.py +++ /dev/null @@ -1,30 +0,0 @@ -import typing as t - -from pymongo import ASCENDING - -from mongospecs.mixins.base import MongoBaseMixin -from mongospecs.types import SpecDocumentType - - -class IndexManagementMixin(MongoBaseMixin): - @classmethod - def create_index(cls, keys: t.Union[str, list[tuple[str, int]]], **kwargs: t.Any) -> str: - """ - Create an index on the specified keys (a single key or a list of keys). - """ - index_keys = [(keys, ASCENDING)] if isinstance(keys, str) else keys - return cls.get_collection().create_index(index_keys, **kwargs) - - @classmethod - def drop_index(cls, index_name: str) -> None: - """ - Drop an index by its name. - """ - cls.get_collection().drop_index(index_name) - - @classmethod - def list_indexes(cls) -> list[SpecDocumentType]: - """ - List all indexes on the collection. - """ - return list(cls.get_collection().list_indexes()) diff --git a/mongospecs/mixins/integrity.py b/mongospecs/mixins/integrity.py deleted file mode 100644 index d48221e..0000000 --- a/mongospecs/mixins/integrity.py +++ /dev/null @@ -1,26 +0,0 @@ -import typing as t - -from typing_extensions import Self - -from mongospecs.mixins.base import MongoBaseMixin -from mongospecs.utils import to_refs - - -class IntegrityMixin(MongoBaseMixin): - @classmethod - def cascade(cls, ref_cls: "type[MongoBaseMixin]", field: str, specs: t.Sequence[Self]) -> None: - """Apply a cascading delete (does not emit signals)""" - ids = [to_refs(getattr(f, field)) for f in specs if hasattr(f, field)] - ref_cls.get_collection().delete_many({"_id": {"$in": ids}}) - - @classmethod - def nullify(cls, ref_cls: "type[MongoBaseMixin]", field: str, specs: t.Sequence[Self]) -> None: - """Nullify a reference field (does not emit signals)""" - ids = [to_refs(f) for f in specs] - ref_cls.get_collection().update_many({field: {"$in": ids}}, {"$set": {field: None}}) - - @classmethod - def pull(cls, ref_cls: "type[MongoBaseMixin]", field: str, specs: t.Sequence[Self]) -> None: - """Pull references from a list field (does not emit signals)""" - ids = [to_refs(f) for f in specs] - ref_cls.get_collection().update_many({field: {"$in": ids}}, {"$pull": {field: {"$in": ids}}}) diff --git a/mongospecs/mixins/query.py b/mongospecs/mixins/query.py deleted file mode 100644 index f0a1867..0000000 --- a/mongospecs/mixins/query.py +++ /dev/null @@ -1,92 +0,0 @@ -import typing as t - -from bson import ObjectId -from typing_extensions import Self - -from mongospecs.helpers.query import Condition, Group -from mongospecs.mixins.base import MongoBaseMixin -from mongospecs.types import FilterType -from mongospecs.utils import to_refs - - -class QueryMixin(MongoBaseMixin): - @classmethod - def by_id(cls, id: ObjectId, **kwargs: t.Any) -> t.Optional[Self]: - """Get a document by ID""" - return cls.one({"_id": id}, **kwargs) - - @classmethod - def count(cls, filter: FilterType = None, **kwargs: t.Any) -> int: - """Return a count of documents matching the filter""" - if isinstance(filter, (Condition, Group)): - filter = filter.to_dict() - - filter = to_refs(filter) - - if filter: - return cls.get_collection().count_documents(to_refs(filter), **kwargs) - else: - return cls.get_collection().estimated_document_count(**kwargs) - - @classmethod - def ids(cls, filter: FilterType = None, **kwargs: t.Any) -> list[ObjectId]: - """Return a list of Ids for documents matching the filter""" - # Find the documents - if isinstance(filter, (Condition, Group)): - filter = filter.to_dict() - - documents = cls.get_collection().find(to_refs(filter), projection={"_id": True}, **kwargs) - - return [d["_id"] for d in list(documents)] - - @classmethod - def one(cls, filter: FilterType = None, **kwargs: t.Any) -> t.Optional[Self]: - """Return the first spec object matching the filter""" - # Flatten the projection - kwargs["projection"], references, subs = cls._flatten_projection( - kwargs.get("projection", cls._default_projection) - ) - - # Find the document - if isinstance(filter, (Condition, Group)): - filter = filter.to_dict() - - document = cls.get_collection().find_one(to_refs(filter), **kwargs) - - # Make sure we found a document - if not document: - return None - - # Dereference the document (if required) - if references: - cls._dereference([document], references) - - # Add sub-specs to the document (if required) - if subs: - cls._apply_sub_specs([document], subs) - - return cls.from_document(document) - - @classmethod - def many(cls, filter: FilterType = None, **kwargs: t.Any) -> list[Self]: - """Return a list of spec objects matching the filter""" - # Flatten the projection - kwargs["projection"], references, subs = cls._flatten_projection( - kwargs.get("projection", cls._default_projection) - ) - - # Find the documents - if isinstance(filter, (Condition, Group)): - filter = filter.to_dict() - - documents = list(cls.get_collection().find(to_refs(filter), **kwargs)) - - # Dereference the documents (if required) - if references: - cls._dereference(documents, references) - - # Add sub-specs to the documents (if required) - if subs: - cls._apply_sub_specs(documents, subs) - - return [cls(**d) for d in documents] diff --git a/mongospecs/mixins/session.py b/mongospecs/mixins/session.py deleted file mode 100644 index 287ddd7..0000000 --- a/mongospecs/mixins/session.py +++ /dev/null @@ -1,30 +0,0 @@ -import typing as t -from contextlib import contextmanager - -from blinker import signal -from pymongo.client_session import ClientSession -from pymongo.errors import ConnectionFailure, OperationFailure - -from mongospecs.mixins.base import MongoBaseMixin - - -class SessionTransactionMixin(MongoBaseMixin): - @classmethod - @contextmanager - def transaction(cls, **start_transaction_kwargs: t.Any) -> t.Generator[ClientSession, t.Any, None]: - """Context manager for handling MongoDB transactions.""" - if not cls._client: - raise RuntimeError("MongoDB client (_client) is not set. Cannot start a transaction.") - session = cls._client.start_session() - session.start_transaction(**start_transaction_kwargs) - - try: - yield session # Allow operations to be performed within this session - session.commit_transaction() # Commit if no exceptions - signal("transaction_committed").send(cls) # Emit signal after commit - except (ConnectionFailure, OperationFailure) as e: - session.abort_transaction() # Abort on error - signal("transaction_aborted").send(cls) # Emit signal after abort - raise e # Re-raise the exception for handling - finally: - session.end_session() diff --git a/mongospecs/mixins/signal.py b/mongospecs/mixins/signal.py deleted file mode 100644 index 6b5d871..0000000 --- a/mongospecs/mixins/signal.py +++ /dev/null @@ -1,15 +0,0 @@ -import typing as t - -from blinker import signal - - -class SignalMixin: - @classmethod - def listen(cls, event: str, func: t.Callable[..., t.Any]) -> None: - """Add a callback for a signal against the class""" - signal(event).connect(func, sender=cls) - - @classmethod - def stop_listening(cls, event: str, func: t.Callable[..., t.Any]) -> None: - """Remove a callback for a signal against the class""" - signal(event).disconnect(func, sender=cls) diff --git a/mongospecs/msgspec.py b/mongospecs/msgspec.py deleted file mode 100644 index 58d47af..0000000 --- a/mongospecs/msgspec.py +++ /dev/null @@ -1,115 +0,0 @@ -import typing as t - -import msgspec -from bson import ObjectId -from pymongo import MongoClient - -from mongospecs.base import SpecBase, SubSpecBase -from mongospecs.helpers.empty import Empty, EmptyObject -from mongospecs.helpers.se import MongoEncoder, mongo_dec_hook, mongo_enc_hook - -__all__ = ["Spec", "SubSpec"] - - -class Spec(msgspec.Struct, SpecBase, kw_only=True): - _id: t.Union[ObjectId, msgspec.UnsetType] = msgspec.field(name="_id", default=msgspec.UNSET) # type: ignore[assignment] - _empty_type: t.ClassVar[t.Any] = msgspec.UNSET - - def encode(self, **encode_kwargs: t.Any) -> bytes: - return msgspec.json.encode(self, **encode_kwargs) if encode_kwargs else MongoEncoder.encode(self) - - def decode(self, data: t.Any, **decode_kwargs: t.Any) -> t.Any: - return msgspec.json.decode(data, type=self.__class__, dec_hook=mongo_dec_hook, **decode_kwargs) - - def to_json_type(self) -> t.Any: - return msgspec.to_builtins(self, enc_hook=mongo_enc_hook) - - def to_dict(self) -> dict[str, t.Any]: - return msgspec.structs.asdict(self) - - def to_tuple(self) -> tuple[t.Any, ...]: - return msgspec.structs.astuple(self) - - @classmethod - def get_fields(cls) -> set[str]: - return set(cls.__struct_fields__) - - # msgspec Struct includes these by default- so we need to override them - def __eq__(self, other: t.Any) -> bool: - if not isinstance(other, self.__class__): - return False - - return self._id == other._id - - def __lt__(self, other: t.Any) -> t.Any: - return self._id < other._id - - -class SubSpec(msgspec.Struct, SubSpecBase, kw_only=True, dict=True): - _parent: t.ClassVar[t.Any] = Spec - - def get(self, name: str, default: t.Any = None) -> t.Any: - return self.to_dict().get(name, default) - - def to_dict(self) -> dict[str, t.Any]: - return msgspec.structs.asdict(self) - - -class MsgspecAdapter(SpecBase): - _id: t.Union[EmptyObject, ObjectId] = msgspec.field(default=Empty) - _empty_type: t.ClassVar[t.Any] = msgspec.UNSET - - def __init__(self, **data: t.Any) -> None: ... - - -class AdapterBuilder: - def __call__( - self, - obj: type[msgspec.Struct], - *, - collection: str, - client: t.Optional[MongoClient[t.Any]] = None, - **kwds: t.Any, - ) -> t.Any: - class BuiltSpecAdapter(SpecBase, obj): # type: ignore - _id: t.Union[EmptyObject, ObjectId] = msgspec.field(default=Empty) - _empty_type: t.ClassVar[t.Any] = msgspec.UNSET - - def encode(self, **encode_kwargs: t.Any) -> bytes: - return msgspec.json.encode(self, **encode_kwargs) if encode_kwargs else MongoEncoder.encode(self) - - def decode(self, data: t.Any, **decode_kwargs: t.Any) -> t.Any: - return msgspec.json.decode(data, type=self.__class__, dec_hook=mongo_dec_hook, **decode_kwargs) - - def to_json_type(self) -> t.Any: - return msgspec.to_builtins(self, enc_hook=mongo_enc_hook) - - def to_dict(self) -> dict[str, t.Any]: - return msgspec.structs.asdict(self) - - def to_tuple(self) -> tuple[t.Any, ...]: - return msgspec.structs.astuple(self) - - @classmethod - def get_fields(cls) -> set[str]: - return set(cls.__struct_fields__) - - # msgspec Struct includes these by default- so we need to override them - def __eq__(self, other: t.Any) -> bool: - if not isinstance(other, self.__class__): - return False - - return self._id == other._id - - def __lt__(self, other: t.Any) -> t.Any: - return self._id < other._id - - BuiltSpecAdapter.__name__ = f"{obj.__name__}SpecAdapter" - BuiltSpecAdapter._collection = collection - BuiltSpecAdapter.__doc__ = obj.__doc__ - if client: - BuiltSpecAdapter._client = client - return BuiltSpecAdapter - - -SpecAdapter = AdapterBuilder() diff --git a/mongospecs/pydantic.py b/mongospecs/pydantic.py deleted file mode 100644 index 37a9da2..0000000 --- a/mongospecs/pydantic.py +++ /dev/null @@ -1,142 +0,0 @@ -import typing as t - -from blinker import signal -from bson import ObjectId -from pydantic import BaseModel, ConfigDict, Field -from pydantic_core import core_schema -from pymongo import MongoClient - -from mongospecs.base import SpecBase, SubSpecBase -from mongospecs.helpers.empty import EmptyObject - -__all__ = ["Spec", "SubSpec"] - - -class _ObjectIdPydanticAnnotation: - # Based on https://docs.pydantic.dev/latest/usage/types/custom/#handling-third-party-types. - - @classmethod - def __get_pydantic_core_schema__( - cls, - _source_type: t.Any, - _handler: t.Callable[[t.Any], core_schema.CoreSchema], - ) -> core_schema.CoreSchema: - def validate_from_str(input_value: str) -> ObjectId: - return ObjectId(input_value) - - return core_schema.union_schema( - [ - # check if it's an instance first before doing any further work - core_schema.is_instance_schema(ObjectId), - core_schema.no_info_plain_validator_function(validate_from_str), - ], - serialization=core_schema.to_string_ser_schema(), - ) - - -PyObjectId = t.Annotated[ObjectId, _ObjectIdPydanticAnnotation] - - -class Spec(BaseModel, SpecBase): - model_config = ConfigDict(arbitrary_types_allowed=True, populate_by_name=True) - - id: t.Optional[PyObjectId] = Field(default=None, alias="_id") - - @classmethod - def from_document(cls, document: dict[str, t.Any]) -> "Spec": - return cls.model_construct(**document) - - @property - def _id(self) -> t.Union[EmptyObject, ObjectId]: - return t.cast(t.Union[EmptyObject, ObjectId], self.id) - - @_id.setter - def _id(self, value: ObjectId) -> None: - self.id = value - - def unset(self, *fields: t.Any) -> None: - """Unset the given list of fields for this document.""" - - # Send update signal - signal("update").send(self.__class__, specs=[self]) - - # Clear the fields from the document and build the unset object - unset = {} - for field in fields: - setattr(self, field, self._empty_type) - unset[field] = True - - ## pydantic specific change: - ## remove from model fields set so it excludes when `to_json_type` is called - self.model_fields_set.remove(field) - - # Update the document - self.get_collection().update_one({"_id": self._id}, {"$unset": unset}) - - # Send updated signal - signal("updated").send(self.__class__, specs=[self]) - - def encode(self, **encode_kwargs: t.Any) -> bytes: - return str.encode(self.model_dump_json(**encode_kwargs)) - - def decode(self, data: t.Any, **decode_kwargs: t.Any) -> t.Any: - return self.__class__.model_validate_json(data, **decode_kwargs) - - def to_json_type(self) -> t.Any: - return self.model_dump(mode="json", by_alias=True, exclude_unset=True) - - def to_dict(self) -> dict[str, t.Any]: - copy_dict = self.__dict__.copy() - copy_dict["_id"] = copy_dict.pop("id") - return copy_dict - - def to_tuple(self) -> tuple[t.Any, ...]: - return tuple(self.to_dict()) - - @classmethod - def get_fields(cls) -> set[str]: - return set(cls.model_fields.keys()) # type: ignore[attr-defined,unused-ignore] - - -class SubSpec(BaseModel, SubSpecBase): - _parent: t.ClassVar[t.Any] = Spec - - def get(self, name: str, default: t.Any = None) -> t.Any: - return self.to_dict().get(name, default) - - def to_dict(self) -> dict[str, t.Any]: - return self.model_dump() - - -T = t.TypeVar("T", bound=BaseModel) - - -class PydanticAdapter(Spec, BaseModel): - def __init__(self, **data: t.Any) -> None: - """Create a new model by parsing and validating input data from keyword arguments. - - Raises [ValidationError][pydantic_core.ValidationError] if the input data cannot - be validated to form a valid model. - - __init__ uses __pydantic_self__ instead of the more common self for the first arg - to allow self as a field name. - """ - ... - - -class AdapterBuilder(t.Generic[T]): - def __call__( - self, obj: type[T], *, collection: str, client: t.Optional[MongoClient[t.Any]] = None, **kwds: t.Any - ) -> t.Any: - class BuiltSpecAdapter(obj, Spec): # type: ignore - pass - - BuiltSpecAdapter.__name__ = f"{obj.__name__}SpecAdapter" - BuiltSpecAdapter._collection = collection - BuiltSpecAdapter.__doc__ = obj.__doc__ - if client: - BuiltSpecAdapter._client = client - return BuiltSpecAdapter - - -SpecAdapter: AdapterBuilder[BaseModel] = AdapterBuilder() diff --git a/mongospecs/types.py b/mongospecs/types.py deleted file mode 100644 index 2d3d2e8..0000000 --- a/mongospecs/types.py +++ /dev/null @@ -1,37 +0,0 @@ -import typing as t -from abc import abstractmethod - -from mongospecs.helpers.query import Condition, Group - -FilterType = t.Union[None, t.MutableMapping[str, t.Any], Condition, Group] -SpecDocumentType = t.MutableMapping[str, t.Any] -RawDocuments = t.Sequence[SpecDocumentType] -SpecsOrRawDocuments = t.Sequence[t.Union["SpecBaseType", SpecDocumentType]] - - -class SpecBaseType: - @abstractmethod - def encode(self, **encode_kwargs: t.Any) -> bytes: - raise NotImplementedError - - @abstractmethod - def decode(self, data: t.Any, **decode_kwargs: t.Any) -> t.Any: - raise NotImplementedError - - @abstractmethod - def to_json_type(self) -> dict[str, t.Any]: - raise NotImplementedError - - @abstractmethod - def to_dict(self) -> dict[str, t.Any]: - raise NotImplementedError - - @abstractmethod - def to_tuple(self) -> tuple[t.Any, ...]: - raise NotImplementedError - - -class SubSpecBaseType: - @abstractmethod - def to_dict(self) -> t.Any: - raise NotImplementedError diff --git a/mongospecs/utils.py b/mongospecs/utils.py deleted file mode 100644 index 46cce51..0000000 --- a/mongospecs/utils.py +++ /dev/null @@ -1,46 +0,0 @@ -import typing as t - -from mongospecs.types import SpecBaseType, SubSpecBaseType - -__all__ = ["deep_merge"] - - -def deep_merge(source: dict[str, t.Any], dest: dict[str, t.Any]) -> None: - """ - Deep merges source dict into dest dict. - - This code was taken directly from the mongothon project: - https://github.com/gamechanger/mongothon/tree/master/mongothon - """ - for key, value in source.items(): - if key in dest: - if isinstance(value, dict) and isinstance(dest[key], dict): - deep_merge(value, dest[key]) - continue - elif isinstance(value, list) and isinstance(dest[key], list): - for item in value: - if item not in dest[key]: - dest[key].append(item) - continue - dest[key] = value - - -def to_refs(value: t.Any) -> t.Any: - """Convert all Spec instances within the given value to Ids""" - # Spec - if isinstance(value, SpecBaseType): - return getattr(value, "_id") - - # SubSpec - elif isinstance(value, SubSpecBaseType): - return to_refs(value.to_dict()) - - # Lists - elif isinstance(value, (list, tuple)): - return [to_refs(v) for v in value] - - # Dictionaries - elif isinstance(value, dict): - return {k: to_refs(v) for k, v in value.items()} - - return value diff --git a/packages/mongospecs-web/README.md b/packages/mongospecs-web/README.md new file mode 100644 index 0000000..7819dfa --- /dev/null +++ b/packages/mongospecs-web/README.md @@ -0,0 +1,6 @@ +# mongospecs-web + +Framework-neutral REST resources for [mongospecs](https://github.com/jaykv/mongospecs) models, +with plugs for a plain ASGI app, Starlette, FastAPI, Litestar and Quart. + +> **Status: 0.1.0, alpha.** diff --git a/packages/mongospecs-web/pyproject.toml b/packages/mongospecs-web/pyproject.toml new file mode 100644 index 0000000..2aa70d9 --- /dev/null +++ b/packages/mongospecs-web/pyproject.toml @@ -0,0 +1,33 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mongospecs-web" +version = "0.1.0" +description = "Framework-neutral REST resources for mongospecs models" +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "Jay", email = "jay.github0@gmail.com" }] +requires-python = ">=3.11" +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: AsyncIO", + "Programming Language :: Python :: 3 :: Only", + "Topic :: Internet :: WWW/HTTP", + "Typing :: Typed", +] +dependencies = ["mongospecs>=0.2.0,<0.3"] + +[project.optional-dependencies] +starlette = ["starlette>=0.40"] +fastapi = ["fastapi>=0.115"] +litestar = ["litestar>=2.12"] +quart = ["quart>=0.19"] +all = ["starlette>=0.40", "fastapi>=0.115", "litestar>=2.12", "quart>=0.19"] + +[project.urls] +Repository = "https://github.com/jaykv/mongospecs" + +[tool.hatch.build.targets.wheel] +packages = ["src/mongospecs_web"] diff --git a/packages/mongospecs-web/src/mongospecs_web/__init__.py b/packages/mongospecs-web/src/mongospecs_web/__init__.py new file mode 100644 index 0000000..f9d4bc7 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/__init__.py @@ -0,0 +1 @@ +"""mongospecs-web: framework-neutral REST resources for mongospecs models.""" diff --git a/mongospecs/py.typed b/packages/mongospecs-web/src/mongospecs_web/py.typed similarity index 100% rename from mongospecs/py.typed rename to packages/mongospecs-web/src/mongospecs_web/py.typed diff --git a/packages/mongospecs/README.md b/packages/mongospecs/README.md new file mode 100644 index 0000000..4a5e7da --- /dev/null +++ b/packages/mongospecs/README.md @@ -0,0 +1,99 @@ +# mongospecs + +An async MongoDB ODM for the model library you already use: **msgspec**, **pydantic** or **attrs**. + +The aim is Mongoose's production feature set (change tracking, middleware, populate, +discriminators, transactions that restore document state) with typed Python models, plus +[`mongospecs-web`](https://github.com/jaykv/mongospecs/tree/main/packages/mongospecs-web), a framework-neutral REST layer. + +> **Status: 0.2.0, alpha.** This is a rewrite of 0.1.x: async-only, Python 3.11+, MongoDB 7.0+, +> and not backwards compatible. The synchronous 0.1.x line lives on the +> [`0.1.x` branch](https://github.com/jaykv/mongospecs/tree/0.1.x). The design and the work graph +> are in [`claudedocs/v2-plan.md`](https://github.com/jaykv/mongospecs/blob/main/claudedocs/v2-plan.md). + +## Install + +``` +pip install "mongospecs[msgspec]" # or [pydantic], [attrs], [all] +``` + +## A taste + +```python +from datetime import datetime +from typing import Annotated + +from mongospecs import CreatedAt, Inc, Index, Q, Ref, Registry, Revision, SpecConfig, UpdatedAt +from mongospecs.msgspec import Spec # or mongospecs.pydantic / mongospecs.attrs + + +class User(Spec): + name: str = "" + + +class Article(Spec): + __spec__ = SpecConfig(collection="articles", indexes=[Index("title", unique=True)]) + + title: str + views: int = 0 + tags: list[str] = [] + author: Ref[User] | None = None + created_at: Annotated[datetime | None, CreatedAt] = None + updated_at: Annotated[datetime | None, UpdatedAt] = None + rev: Annotated[int, Revision] = 0 # optimistic locking + + +async def main(): + async with Registry("mongodb://localhost/app", models=[User, Article]) as registry: + await registry.apply_indexes() + + ann = await User(name="ann").insert() + article = await Article(title="Hello", author=ann).insert() + + # change tracking: save() sends only what changed + article.title = "Hello, world" + assert article.changes() == {"$set": {"title": "Hello, world"}} + await article.save() + + # typed queries: names are checked, values are cast + popular = await Article.find(Q.views >= 100, Q.title.prefix("Hello")).sort("-views").limit(10) + + # relations: one extra query, or a $lookup + with_author = await Article.find(Q.author == ann).include("author").one() + print(with_author.author.doc.name) + + # atomic updates, always validated; the updated document comes back + article = await Article.update(article.id, Inc(views=1), tags=["intro"]) + + # transactions: the callback is retried on transient errors, and in-memory + # documents are restored if it aborts + async def publish(tx): + article.tags.append("published") + await article.save(session=tx) + + await registry.transaction(publish) +``` + +## What is in the box + +| | | +|---|---| +| Models | msgspec `Struct`, pydantic `BaseModel` or attrs classes, through one `Codec` seam; `SpecAdapter` for classes you already have | +| Reads | immutable chainable queries, `Q` expressions, server-side projection, `tracked` / `lean()` / `raw()` modes, cursor and offset pagination | +| Writes | delta `save()`, atomic operators (`Inc`, `Push`, `Pull`, ...), real upsert, typed `bulk_write`, `bulk_save`, partial-failure `insert_many` | +| Relations | `Ref[T]`, lists and dicts of refs, polymorphic refs, reverse relations (`Backref`), nested `include()`, `$lookup` or batched loading, cascade rules, `$graphLookup`, `$unionWith` | +| Plugins | timestamps, soft delete, revisions - activated by field markers; registry-wide plugins; document hooks | +| Modelling | discriminators (single-collection inheritance), deferred and immutable fields, aliases, lazy upcasting of old documents | +| Operations | explicit `Registry` lifecycle, transactions, index planner with gated destructive actions, `$jsonSchema` validators, change streams, multi-database use, shard-key aware writes | +| Observability | per-command events with model and operation names; logging, OpenTelemetry and Sentry listeners | + +## Development + +``` +make install # uv sync for the whole workspace +make up # MongoDB replica set + sharded cluster in Docker +make check # ruff, mypy --strict, tests against real MongoDB +``` + +The repository is a uv workspace: `packages/mongospecs`, `packages/mongospecs-web` and `bench/` +(benchmarks and stress scenarios against real MongoDB, never published). diff --git a/packages/mongospecs/pyproject.toml b/packages/mongospecs/pyproject.toml new file mode 100644 index 0000000..8cad394 --- /dev/null +++ b/packages/mongospecs/pyproject.toml @@ -0,0 +1,39 @@ +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" + +[project] +name = "mongospecs" +version = "0.2.0" +description = "Async MongoDB ODM for msgspec, pydantic and attrs models" +readme = "README.md" +license = { text = "MIT" } +authors = [{ name = "Jay", email = "jay.github0@gmail.com" }] +requires-python = ">=3.11" +classifiers = [ + "Development Status :: 3 - Alpha", + "Framework :: AsyncIO", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.11", + "Programming Language :: Python :: 3.12", + "Programming Language :: Python :: 3.13", + "Programming Language :: Python :: 3.14", + "Programming Language :: Python :: 3.15", + "Topic :: Database", + "Typing :: Typed", +] +dependencies = ["pymongo>=4.13,<5"] + +[project.optional-dependencies] +msgspec = ["msgspec>=0.19"] +pydantic = ["pydantic>=2.9"] +attrs = ["attrs>=24.2", "msgspec>=0.19"] +otel = ["opentelemetry-api>=1.20"] +sentry = ["sentry-sdk>=2.0"] +all = ["msgspec>=0.19", "pydantic>=2.9", "attrs>=24.2"] + +[project.urls] +Repository = "https://github.com/jaykv/mongospecs" + +[tool.hatch.build.targets.wheel] +packages = ["src/mongospecs"] diff --git a/poetry.lock b/poetry.lock deleted file mode 100644 index 15db9fe..0000000 --- a/poetry.lock +++ /dev/null @@ -1,1495 +0,0 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. - -[[package]] -name = "annotated-types" -version = "0.7.0" -description = "Reusable constraint types to use with typing.Annotated" -optional = false -python-versions = ">=3.8" -files = [ - {file = "annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53"}, - {file = "annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89"}, -] - -[[package]] -name = "attrs" -version = "23.2.0" -description = "Classes Without Boilerplate" -optional = false -python-versions = ">=3.7" -files = [ - {file = "attrs-23.2.0-py3-none-any.whl", hash = "sha256:99b87a485a5820b23b879f04c2305b44b951b502fd64be915879d77a7e8fc6f1"}, - {file = "attrs-23.2.0.tar.gz", hash = "sha256:935dc3b529c262f6cf76e50877d35a4bd3c1de194fd41f47a2b7ae8f19971f30"}, -] - -[package.extras] -cov = ["attrs[tests]", "coverage[toml] (>=5.3)"] -dev = ["attrs[tests]", "pre-commit"] -docs = ["furo", "myst-parser", "sphinx", "sphinx-notfound-page", "sphinxcontrib-towncrier", "towncrier", "zope-interface"] -tests = ["attrs[tests-no-zope]", "zope-interface"] -tests-mypy = ["mypy (>=1.6)", "pytest-mypy-plugins"] -tests-no-zope = ["attrs[tests-mypy]", "cloudpickle", "hypothesis", "pympler", "pytest (>=4.3.0)", "pytest-xdist[psutil]"] - -[[package]] -name = "babel" -version = "2.16.0" -description = "Internationalization utilities" -optional = false -python-versions = ">=3.8" -files = [ - {file = "babel-2.16.0-py3-none-any.whl", hash = "sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b"}, - {file = "babel-2.16.0.tar.gz", hash = "sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316"}, -] - -[package.extras] -dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"] - -[[package]] -name = "blinker" -version = "1.9.0" -description = "Fast, simple object-to-object and broadcast signaling" -optional = false -python-versions = ">=3.9" -files = [ - {file = "blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc"}, - {file = "blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf"}, -] - -[[package]] -name = "certifi" -version = "2024.12.14" -description = "Python package for providing Mozilla's CA Bundle." -optional = false -python-versions = ">=3.6" -files = [ - {file = "certifi-2024.12.14-py3-none-any.whl", hash = "sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56"}, - {file = "certifi-2024.12.14.tar.gz", hash = "sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db"}, -] - -[[package]] -name = "charset-normalizer" -version = "3.4.0" -description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet." -optional = false -python-versions = ">=3.7.0" -files = [ - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:4f9fc98dad6c2eaa32fc3af1417d95b5e3d08aff968df0cd320066def971f9a6"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0de7b687289d3c1b3e8660d0741874abe7888100efe14bd0f9fd7141bcbda92b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5ed2e36c3e9b4f21dd9422f6893dec0abf2cca553af509b10cd630f878d3eb99"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40d3ff7fc90b98c637bda91c89d51264a3dcf210cade3a2c6f838c7268d7a4ca"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1110e22af8ca26b90bd6364fe4c763329b0ebf1ee213ba32b68c73de5752323d"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:86f4e8cca779080f66ff4f191a685ced73d2f72d50216f7112185dc02b90b9b7"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7f683ddc7eedd742e2889d2bfb96d69573fde1d92fcb811979cdb7165bb9c7d3"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:27623ba66c183eca01bf9ff833875b459cad267aeeb044477fedac35e19ba907"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f606a1881d2663630ea5b8ce2efe2111740df4b687bd78b34a8131baa007f79b"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0b309d1747110feb25d7ed6b01afdec269c647d382c857ef4663bbe6ad95a912"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:136815f06a3ae311fae551c3df1f998a1ebd01ddd424aa5603a4336997629e95"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:14215b71a762336254351b00ec720a8e85cada43b987da5a042e4ce3e82bd68e"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79983512b108e4a164b9c8d34de3992f76d48cadc9554c9e60b43f308988aabe"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win32.whl", hash = "sha256:c94057af19bc953643a33581844649a7fdab902624d2eb739738a30e2b3e60fc"}, - {file = "charset_normalizer-3.4.0-cp310-cp310-win_amd64.whl", hash = "sha256:55f56e2ebd4e3bc50442fbc0888c9d8c94e4e06a933804e2af3e89e2f9c1c749"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0d99dd8ff461990f12d6e42c7347fd9ab2532fb70e9621ba520f9e8637161d7c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c57516e58fd17d03ebe67e181a4e4e2ccab1168f8c2976c6a334d4f819fe5944"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6dba5d19c4dfab08e58d5b36304b3f92f3bd5d42c1a3fa37b5ba5cdf6dfcbcee"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bf4475b82be41b07cc5e5ff94810e6a01f276e37c2d55571e3fe175e467a1a1c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ce031db0408e487fd2775d745ce30a7cd2923667cf3b69d48d219f1d8f5ddeb6"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8ff4e7cdfdb1ab5698e675ca622e72d58a6fa2a8aa58195de0c0061288e6e3ea"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3710a9751938947e6327ea9f3ea6332a09bf0ba0c09cae9cb1f250bd1f1549bc"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:82357d85de703176b5587dbe6ade8ff67f9f69a41c0733cf2425378b49954de5"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47334db71978b23ebcf3c0f9f5ee98b8d65992b65c9c4f2d34c2eaf5bcaf0594"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8ce7fd6767a1cc5a92a639b391891bf1c268b03ec7e021c7d6d902285259685c"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f1a2f519ae173b5b6a2c9d5fa3116ce16e48b3462c8b96dfdded11055e3d6365"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:63bc5c4ae26e4bc6be6469943b8253c0fd4e4186c43ad46e713ea61a0ba49129"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:bcb4f8ea87d03bc51ad04add8ceaf9b0f085ac045ab4d74e73bbc2dc033f0236"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win32.whl", hash = "sha256:9ae4ef0b3f6b41bad6366fb0ea4fc1d7ed051528e113a60fa2a65a9abb5b1d99"}, - {file = "charset_normalizer-3.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:cee4373f4d3ad28f1ab6290684d8e2ebdb9e7a1b74fdc39e4c211995f77bec27"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:0713f3adb9d03d49d365b70b84775d0a0d18e4ab08d12bc46baa6132ba78aaf6"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:de7376c29d95d6719048c194a9cf1a1b0393fbe8488a22008610b0361d834ecf"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:4a51b48f42d9358460b78725283f04bddaf44a9358197b889657deba38f329db"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b295729485b06c1a0683af02a9e42d2caa9db04a373dc38a6a58cdd1e8abddf1"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ee803480535c44e7f5ad00788526da7d85525cfefaf8acf8ab9a310000be4b03"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3d59d125ffbd6d552765510e3f31ed75ebac2c7470c7274195b9161a32350284"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8cda06946eac330cbe6598f77bb54e690b4ca93f593dee1568ad22b04f347c15"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:07afec21bbbbf8a5cc3651aa96b980afe2526e7f048fdfb7f1014d84acc8b6d8"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:6b40e8d38afe634559e398cc32b1472f376a4099c75fe6299ae607e404c033b2"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b8dcd239c743aa2f9c22ce674a145e0a25cb1566c495928440a181ca1ccf6719"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:84450ba661fb96e9fd67629b93d2941c871ca86fc38d835d19d4225ff946a631"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:44aeb140295a2f0659e113b31cfe92c9061622cadbc9e2a2f7b8ef6b1e29ef4b"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1db4e7fefefd0f548d73e2e2e041f9df5c59e178b4c72fbac4cc6f535cfb1565"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win32.whl", hash = "sha256:5726cf76c982532c1863fb64d8c6dd0e4c90b6ece9feb06c9f202417a31f7dd7"}, - {file = "charset_normalizer-3.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:b197e7094f232959f8f20541ead1d9862ac5ebea1d58e9849c1bf979255dfac9"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:dd4eda173a9fcccb5f2e2bd2a9f423d180194b1bf17cf59e3269899235b2a114"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e9e3c4c9e1ed40ea53acf11e2a386383c3304212c965773704e4603d589343ed"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92a7e36b000bf022ef3dbb9c46bfe2d52c047d5e3f3343f43204263c5addc250"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54b6a92d009cbe2fb11054ba694bc9e284dad30a26757b1e372a1fdddaf21920"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ffd9493de4c922f2a38c2bf62b831dcec90ac673ed1ca182fe11b4d8e9f2a64"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:35c404d74c2926d0287fbd63ed5d27eb911eb9e4a3bb2c6d294f3cfd4a9e0c23"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4796efc4faf6b53a18e3d46343535caed491776a22af773f366534056c4e1fbc"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e7fdd52961feb4c96507aa649550ec2a0d527c086d284749b2f582f2d40a2e0d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:92db3c28b5b2a273346bebb24857fda45601aef6ae1c011c0a997106581e8a88"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ab973df98fc99ab39080bfb0eb3a925181454d7c3ac8a1e695fddfae696d9e90"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4b67fdab07fdd3c10bb21edab3cbfe8cf5696f453afce75d815d9d7223fbe88b"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:aa41e526a5d4a9dfcfbab0716c7e8a1b215abd3f3df5a45cf18a12721d31cb5d"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffc519621dce0c767e96b9c53f09c5d215578e10b02c285809f76509a3931482"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win32.whl", hash = "sha256:f19c1585933c82098c2a520f8ec1227f20e339e33aca8fa6f956f6691b784e67"}, - {file = "charset_normalizer-3.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:707b82d19e65c9bd28b81dde95249b07bf9f5b90ebe1ef17d9b57473f8a64b7b"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:dbe03226baf438ac4fda9e2d0715022fd579cb641c4cf639fa40d53b2fe6f3e2"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:dd9a8bd8900e65504a305bf8ae6fa9fbc66de94178c420791d0293702fce2df7"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b8831399554b92b72af5932cdbbd4ddc55c55f631bb13ff8fe4e6536a06c5c51"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a14969b8691f7998e74663b77b4c36c0337cb1df552da83d5c9004a93afdb574"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcaf7c1524c0542ee2fc82cc8ec337f7a9f7edee2532421ab200d2b920fc97cf"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:425c5f215d0eecee9a56cdb703203dda90423247421bf0d67125add85d0c4455"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:d5b054862739d276e09928de37c79ddeec42a6e1bfc55863be96a36ba22926f6"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_i686.whl", hash = "sha256:f3e73a4255342d4eb26ef6df01e3962e73aa29baa3124a8e824c5d3364a65748"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_ppc64le.whl", hash = "sha256:2f6c34da58ea9c1a9515621f4d9ac379871a8f21168ba1b5e09d74250de5ad62"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_s390x.whl", hash = "sha256:f09cb5a7bbe1ecae6e87901a2eb23e0256bb524a79ccc53eb0b7629fbe7677c4"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:0099d79bdfcf5c1f0c2c72f91516702ebf8b0b8ddd8905f97a8aecf49712c621"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win32.whl", hash = "sha256:9c98230f5042f4945f957d006edccc2af1e03ed5e37ce7c373f00a5a4daa6149"}, - {file = "charset_normalizer-3.4.0-cp37-cp37m-win_amd64.whl", hash = "sha256:62f60aebecfc7f4b82e3f639a7d1433a20ec32824db2199a11ad4f5e146ef5ee"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:af73657b7a68211996527dbfeffbb0864e043d270580c5aef06dc4b659a4b578"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:cab5d0b79d987c67f3b9e9c53f54a61360422a5a0bc075f43cab5621d530c3b6"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9289fd5dddcf57bab41d044f1756550f9e7cf0c8e373b8cdf0ce8773dc4bd417"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b493a043635eb376e50eedf7818f2f322eabbaa974e948bd8bdd29eb7ef2a51"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9fa2566ca27d67c86569e8c85297aaf413ffab85a8960500f12ea34ff98e4c41"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a8e538f46104c815be19c975572d74afb53f29650ea2025bbfaef359d2de2f7f"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6fd30dc99682dc2c603c2b315bded2799019cea829f8bf57dc6b61efde6611c8"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2006769bd1640bdf4d5641c69a3d63b71b81445473cac5ded39740a226fa88ab"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:dc15e99b2d8a656f8e666854404f1ba54765871104e50c8e9813af8a7db07f12"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:ab2e5bef076f5a235c3774b4f4028a680432cded7cad37bba0fd90d64b187d19"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:4ec9dd88a5b71abfc74e9df5ebe7921c35cbb3b641181a531ca65cdb5e8e4dea"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:43193c5cda5d612f247172016c4bb71251c784d7a4d9314677186a838ad34858"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:aa693779a8b50cd97570e5a0f343538a8dbd3e496fa5dcb87e29406ad0299654"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win32.whl", hash = "sha256:7706f5850360ac01d80c89bcef1640683cc12ed87f42579dab6c5d3ed6888613"}, - {file = "charset_normalizer-3.4.0-cp38-cp38-win_amd64.whl", hash = "sha256:c3e446d253bd88f6377260d07c895816ebf33ffffd56c1c792b13bff9c3e1ade"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:980b4f289d1d90ca5efcf07958d3eb38ed9c0b7676bf2831a54d4f66f9c27dfa"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:f28f891ccd15c514a0981f3b9db9aa23d62fe1a99997512b0491d2ed323d229a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8aacce6e2e1edcb6ac625fb0f8c3a9570ccc7bfba1f63419b3769ccf6a00ed0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bd7af3717683bea4c87acd8c0d3d5b44d56120b26fd3f8a692bdd2d5260c620a"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ff2ed8194587faf56555927b3aa10e6fb69d931e33953943bc4f837dfee2242"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e91f541a85298cf35433bf66f3fab2a4a2cff05c127eeca4af174f6d497f0d4b"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:309a7de0a0ff3040acaebb35ec45d18db4b28232f21998851cfa709eeff49d62"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:285e96d9d53422efc0d7a17c60e59f37fbf3dfa942073f666db4ac71e8d726d0"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:5d447056e2ca60382d460a604b6302d8db69476fd2015c81e7c35417cfabe4cd"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:20587d20f557fe189b7947d8e7ec5afa110ccf72a3128d61a2a387c3313f46be"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:130272c698667a982a5d0e626851ceff662565379baf0ff2cc58067b81d4f11d"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:ab22fbd9765e6954bc0bcff24c25ff71dcbfdb185fcdaca49e81bac68fe724d3"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:7782afc9b6b42200f7362858f9e73b1f8316afb276d316336c0ec3bd73312742"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win32.whl", hash = "sha256:2de62e8801ddfff069cd5c504ce3bc9672b23266597d4e4f50eda28846c322f2"}, - {file = "charset_normalizer-3.4.0-cp39-cp39-win_amd64.whl", hash = "sha256:95c3c157765b031331dd4db3c775e58deaee050a3042fcad72cbc4189d7c8dca"}, - {file = "charset_normalizer-3.4.0-py3-none-any.whl", hash = "sha256:fe9f97feb71aa9896b81973a7bbada8c49501dc73e58a10fcef6663af95e5079"}, - {file = "charset_normalizer-3.4.0.tar.gz", hash = "sha256:223217c3d4f82c3ac5e29032b3f1c2eb0fb591b72161f86d93f5719079dae93e"}, -] - -[[package]] -name = "click" -version = "8.1.7" -description = "Composable command line interface toolkit" -optional = false -python-versions = ">=3.7" -files = [ - {file = "click-8.1.7-py3-none-any.whl", hash = "sha256:ae74fb96c20a0277a1d615f1e4d73c8414f5a98db8b799a7931d1582f3390c28"}, - {file = "click-8.1.7.tar.gz", hash = "sha256:ca9853ad459e787e2192211578cc907e7594e294c7ccc834310722b41b9ca6de"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "platform_system == \"Windows\""} - -[[package]] -name = "colorama" -version = "0.4.6" -description = "Cross-platform colored terminal text." -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7" -files = [ - {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"}, - {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, -] - -[[package]] -name = "dnspython" -version = "2.7.0" -description = "DNS toolkit" -optional = false -python-versions = ">=3.9" -files = [ - {file = "dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86"}, - {file = "dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1"}, -] - -[package.extras] -dev = ["black (>=23.1.0)", "coverage (>=7.0)", "flake8 (>=7)", "hypercorn (>=0.16.0)", "mypy (>=1.8)", "pylint (>=3)", "pytest (>=7.4)", "pytest-cov (>=4.1.0)", "quart-trio (>=0.11.0)", "sphinx (>=7.2.0)", "sphinx-rtd-theme (>=2.0.0)", "twine (>=4.0.0)", "wheel (>=0.42.0)"] -dnssec = ["cryptography (>=43)"] -doh = ["h2 (>=4.1.0)", "httpcore (>=1.0.0)", "httpx (>=0.26.0)"] -doq = ["aioquic (>=1.0.0)"] -idna = ["idna (>=3.7)"] -trio = ["trio (>=0.23)"] -wmi = ["wmi (>=1.5.1)"] - -[[package]] -name = "exceptiongroup" -version = "1.2.2" -description = "Backport of PEP 654 (exception groups)" -optional = false -python-versions = ">=3.7" -files = [ - {file = "exceptiongroup-1.2.2-py3-none-any.whl", hash = "sha256:3111b9d131c238bec2f8f516e123e14ba243563fb135d3fe885990585aa7795b"}, - {file = "exceptiongroup-1.2.2.tar.gz", hash = "sha256:47c2edf7c6738fafb49fd34290706d1a1a2f4d1c6df275526b62cbb4aa5393cc"}, -] - -[package.extras] -test = ["pytest (>=6)"] - -[[package]] -name = "ghp-import" -version = "2.1.0" -description = "Copy your docs directly to the gh-pages branch." -optional = false -python-versions = "*" -files = [ - {file = "ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343"}, - {file = "ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619"}, -] - -[package.dependencies] -python-dateutil = ">=2.8.1" - -[package.extras] -dev = ["flake8", "markdown", "twine", "wheel"] - -[[package]] -name = "griffe" -version = "1.5.1" -description = "Signatures for entire Python programs. Extract the structure, the frame, the skeleton of your project, to generate API documentation or find breaking changes in your API." -optional = false -python-versions = ">=3.9" -files = [ - {file = "griffe-1.5.1-py3-none-any.whl", hash = "sha256:ad6a7980f8c424c9102160aafa3bcdf799df0e75f7829d75af9ee5aef656f860"}, - {file = "griffe-1.5.1.tar.gz", hash = "sha256:72964f93e08c553257706d6cd2c42d1c172213feb48b2be386f243380b405d4b"}, -] - -[package.dependencies] -colorama = ">=0.4" - -[[package]] -name = "idna" -version = "3.10" -description = "Internationalized Domain Names in Applications (IDNA)" -optional = false -python-versions = ">=3.6" -files = [ - {file = "idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3"}, - {file = "idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9"}, -] - -[package.extras] -all = ["flake8 (>=7.1.1)", "mypy (>=1.11.2)", "pytest (>=8.3.2)", "ruff (>=0.6.2)"] - -[[package]] -name = "importlib-metadata" -version = "8.5.0" -description = "Read metadata from Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "importlib_metadata-8.5.0-py3-none-any.whl", hash = "sha256:45e54197d28b7a7f1559e60b95e7c567032b602131fbd588f1497f47880aa68b"}, - {file = "importlib_metadata-8.5.0.tar.gz", hash = "sha256:71522656f0abace1d072b9e5481a48f07c138e00f079c38c8f883823f9c26bd7"}, -] - -[package.dependencies] -zipp = ">=3.20" - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -perf = ["ipython"] -test = ["flufl.flake8", "importlib-resources (>=1.3)", "jaraco.test (>=5.4)", "packaging", "pyfakefs", "pytest (>=6,!=8.1.*)", "pytest-perf (>=0.9.2)"] -type = ["pytest-mypy"] - -[[package]] -name = "iniconfig" -version = "2.0.0" -description = "brain-dead simple config-ini parsing" -optional = false -python-versions = ">=3.7" -files = [ - {file = "iniconfig-2.0.0-py3-none-any.whl", hash = "sha256:b6a85871a79d2e3b22d2d1b94ac2824226a63c6b741c88f7ae975f18b6778374"}, - {file = "iniconfig-2.0.0.tar.gz", hash = "sha256:2d91e135bf72d31a410b17c16da610a82cb55f6b0477d1a902134b24a455b8b3"}, -] - -[[package]] -name = "jinja2" -version = "3.1.4" -description = "A very fast and expressive template engine." -optional = false -python-versions = ">=3.7" -files = [ - {file = "jinja2-3.1.4-py3-none-any.whl", hash = "sha256:bc5dd2abb727a5319567b7a813e6a2e7318c39f4f487cfe6c89c6f9c7d25197d"}, - {file = "jinja2-3.1.4.tar.gz", hash = "sha256:4a3aee7acbbe7303aede8e9648d13b8bf88a429282aa6122a993f0ac800cb369"}, -] - -[package.dependencies] -MarkupSafe = ">=2.0" - -[package.extras] -i18n = ["Babel (>=2.7)"] - -[[package]] -name = "markdown" -version = "3.7" -description = "Python implementation of John Gruber's Markdown." -optional = false -python-versions = ">=3.8" -files = [ - {file = "Markdown-3.7-py3-none-any.whl", hash = "sha256:7eb6df5690b81a1d7942992c97fad2938e956e79df20cbc6186e9c3a77b1c803"}, - {file = "markdown-3.7.tar.gz", hash = "sha256:2ae2471477cfd02dbbf038d5d9bc226d40def84b4fe2986e49b59b6b472bbed2"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} - -[package.extras] -docs = ["mdx-gh-links (>=0.2)", "mkdocs (>=1.5)", "mkdocs-gen-files", "mkdocs-literate-nav", "mkdocs-nature (>=0.6)", "mkdocs-section-index", "mkdocstrings[python]"] -testing = ["coverage", "pyyaml"] - -[[package]] -name = "markupsafe" -version = "3.0.2" -description = "Safely add untrusted strings to HTML/XML markup." -optional = false -python-versions = ">=3.9" -files = [ - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:7e94c425039cde14257288fd61dcfb01963e658efbc0ff54f5306b06054700f8"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9e2d922824181480953426608b81967de705c3cef4d1af983af849d7bd619158"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:38a9ef736c01fccdd6600705b09dc574584b89bea478200c5fbf112a6b0d5579"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bbcb445fa71794da8f178f0f6d66789a28d7319071af7a496d4d507ed566270d"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:57cb5a3cf367aeb1d316576250f65edec5bb3be939e9247ae594b4bcbc317dfb"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3809ede931876f5b2ec92eef964286840ed3540dadf803dd570c3b7e13141a3b"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e07c3764494e3776c602c1e78e298937c3315ccc9043ead7e685b7f2b8d47b3c"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b424c77b206d63d500bcb69fa55ed8d0e6a3774056bdc4839fc9298a7edca171"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win32.whl", hash = "sha256:fcabf5ff6eea076f859677f5f0b6b5c1a51e70a376b0579e0eadef8db48c6b50"}, - {file = "MarkupSafe-3.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:6af100e168aa82a50e186c82875a5893c5597a0c1ccdb0d8b40240b1f28b969a"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:9025b4018f3a1314059769c7bf15441064b2207cb3f065e6ea1e7359cb46db9d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:93335ca3812df2f366e80509ae119189886b0f3c2b81325d39efdb84a1e2ae93"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2cb8438c3cbb25e220c2ab33bb226559e7afb3baec11c4f218ffa7308603c832"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a123e330ef0853c6e822384873bef7507557d8e4a082961e1defa947aa59ba84"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1e084f686b92e5b83186b07e8a17fc09e38fff551f3602b249881fec658d3eca"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d8213e09c917a951de9d09ecee036d5c7d36cb6cb7dbaece4c71a60d79fb9798"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:5b02fb34468b6aaa40dfc198d813a641e3a63b98c2b05a16b9f80b7ec314185e"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0bff5e0ae4ef2e1ae4fdf2dfd5b76c75e5c2fa4132d05fc1b0dabcd20c7e28c4"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win32.whl", hash = "sha256:6c89876f41da747c8d3677a2b540fb32ef5715f97b66eeb0c6b66f5e3ef6f59d"}, - {file = "MarkupSafe-3.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:70a87b411535ccad5ef2f1df5136506a10775d267e197e4cf531ced10537bd6b"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:9778bd8ab0a994ebf6f84c2b949e65736d5575320a17ae8984a77fab08db94cf"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:846ade7b71e3536c4e56b386c2a47adf5741d2d8b94ec9dc3e92e5e1ee1e2225"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1c99d261bd2d5f6b59325c92c73df481e05e57f19837bdca8413b9eac4bd8028"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e17c96c14e19278594aa4841ec148115f9c7615a47382ecb6b82bd8fea3ab0c8"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:88416bd1e65dcea10bc7569faacb2c20ce071dd1f87539ca2ab364bf6231393c"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2181e67807fc2fa785d0592dc2d6206c019b9502410671cc905d132a92866557"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:52305740fe773d09cffb16f8ed0427942901f00adedac82ec8b67752f58a1b22"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad10d3ded218f1039f11a75f8091880239651b52e9bb592ca27de44eed242a48"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win32.whl", hash = "sha256:0f4ca02bea9a23221c0182836703cbf8930c5e9454bacce27e767509fa286a30"}, - {file = "MarkupSafe-3.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:8e06879fc22a25ca47312fbe7c8264eb0b662f6db27cb2d3bbbc74b1df4b9b87"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ba9527cdd4c926ed0760bc301f6728ef34d841f405abf9d4f959c478421e4efd"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f8b3d067f2e40fe93e1ccdd6b2e1d16c43140e76f02fb1319a05cf2b79d99430"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:569511d3b58c8791ab4c2e1285575265991e6d8f8700c7be0e88f86cb0672094"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:15ab75ef81add55874e7ab7055e9c397312385bd9ced94920f2802310c930396"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f3818cb119498c0678015754eba762e0d61e5b52d34c8b13d770f0719f7b1d79"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:cdb82a876c47801bb54a690c5ae105a46b392ac6099881cdfb9f6e95e4014c6a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:cabc348d87e913db6ab4aa100f01b08f481097838bdddf7c7a84b7575b7309ca"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:444dcda765c8a838eaae23112db52f1efaf750daddb2d9ca300bcae1039adc5c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win32.whl", hash = "sha256:bcf3e58998965654fdaff38e58584d8937aa3096ab5354d493c77d1fdd66d7a1"}, - {file = "MarkupSafe-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:e6a2a455bd412959b57a172ce6328d2dd1f01cb2135efda2e4576e8a23fa3b0f"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:b5a6b3ada725cea8a5e634536b1b01c30bcdcd7f9c6fff4151548d5bf6b3a36c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:a904af0a6162c73e3edcb969eeeb53a63ceeb5d8cf642fade7d39e7963a22ddb"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4aa4e5faecf353ed117801a068ebab7b7e09ffb6e1d5e412dc852e0da018126c"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c0ef13eaeee5b615fb07c9a7dadb38eac06a0608b41570d8ade51c56539e509d"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d16a81a06776313e817c951135cf7340a3e91e8c1ff2fac444cfd75fffa04afe"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:6381026f158fdb7c72a168278597a5e3a5222e83ea18f543112b2662a9b699c5"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:3d79d162e7be8f996986c064d1c7c817f6df3a77fe3d6859f6f9e7be4b8c213a"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:131a3c7689c85f5ad20f9f6fb1b866f402c445b220c19fe4308c0b147ccd2ad9"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win32.whl", hash = "sha256:ba8062ed2cf21c07a9e295d5b8a2a5ce678b913b45fdf68c32d95d6c1291e0b6"}, - {file = "MarkupSafe-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:e444a31f8db13eb18ada366ab3cf45fd4b31e4db1236a4448f68778c1d1a5a2f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:eaa0a10b7f72326f1372a713e73c3f739b524b3af41feb43e4921cb529f5929a"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:48032821bbdf20f5799ff537c7ac3d1fba0ba032cfc06194faffa8cda8b560ff"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a9d3f5f0901fdec14d8d2f66ef7d035f2157240a433441719ac9a3fba440b13"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:88b49a3b9ff31e19998750c38e030fc7bb937398b1f78cfa599aaef92d693144"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:cfad01eed2c2e0c01fd0ecd2ef42c492f7f93902e39a42fc9ee1692961443a29"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:1225beacc926f536dc82e45f8a4d68502949dc67eea90eab715dea3a21c1b5f0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:3169b1eefae027567d1ce6ee7cae382c57fe26e82775f460f0b2778beaad66c0"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:eb7972a85c54febfb25b5c4b4f3af4dcc731994c7da0d8a0b4a6eb0640e1d178"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win32.whl", hash = "sha256:8c4e8c3ce11e1f92f6536ff07154f9d49677ebaaafc32db9db4620bc11ed480f"}, - {file = "MarkupSafe-3.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:6e296a513ca3d94054c2c881cc913116e90fd030ad1c656b3869762b754f5f8a"}, - {file = "markupsafe-3.0.2.tar.gz", hash = "sha256:ee55d3edf80167e48ea11a923c7386f4669df67d7994554387f84e7d8b0a2bf0"}, -] - -[[package]] -name = "mergedeep" -version = "1.3.4" -description = "A deep merge function for 🐍." -optional = false -python-versions = ">=3.6" -files = [ - {file = "mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307"}, - {file = "mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8"}, -] - -[[package]] -name = "methodtools" -version = "0.4.7" -description = "Expand standard functools to methods" -optional = false -python-versions = "*" -files = [ - {file = "methodtools-0.4.7.tar.gz", hash = "sha256:e213439dd64cfe60213f7015da6efe5dd4003fd89376db3baa09fe13ec2bb0ba"}, -] - -[package.dependencies] -wirerope = ">=0.4.7" - -[package.extras] -doc = ["sphinx"] -test = ["functools32 (>=3.2.3-2)", "pytest (>=4.6.7)", "pytest-cov (>=2.6.1)"] - -[[package]] -name = "mkdocs" -version = "1.6.1" -description = "Project documentation with Markdown." -optional = false -python-versions = ">=3.8" -files = [ - {file = "mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e"}, - {file = "mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2"}, -] - -[package.dependencies] -click = ">=7.0" -colorama = {version = ">=0.4", markers = "platform_system == \"Windows\""} -ghp-import = ">=1.0" -importlib-metadata = {version = ">=4.4", markers = "python_version < \"3.10\""} -jinja2 = ">=2.11.1" -markdown = ">=3.3.6" -markupsafe = ">=2.0.1" -mergedeep = ">=1.3.4" -mkdocs-get-deps = ">=0.2.0" -packaging = ">=20.5" -pathspec = ">=0.11.1" -pyyaml = ">=5.1" -pyyaml-env-tag = ">=0.1" -watchdog = ">=2.0" - -[package.extras] -i18n = ["babel (>=2.9.0)"] -min-versions = ["babel (==2.9.0)", "click (==7.0)", "colorama (==0.4)", "ghp-import (==1.0)", "importlib-metadata (==4.4)", "jinja2 (==2.11.1)", "markdown (==3.3.6)", "markupsafe (==2.0.1)", "mergedeep (==1.3.4)", "mkdocs-get-deps (==0.2.0)", "packaging (==20.5)", "pathspec (==0.11.1)", "pyyaml (==5.1)", "pyyaml-env-tag (==0.1)", "watchdog (==2.0)"] - -[[package]] -name = "mkdocs-autorefs" -version = "1.2.0" -description = "Automatically link across pages in MkDocs." -optional = false -python-versions = ">=3.8" -files = [ - {file = "mkdocs_autorefs-1.2.0-py3-none-any.whl", hash = "sha256:d588754ae89bd0ced0c70c06f58566a4ee43471eeeee5202427da7de9ef85a2f"}, - {file = "mkdocs_autorefs-1.2.0.tar.gz", hash = "sha256:a86b93abff653521bda71cf3fc5596342b7a23982093915cb74273f67522190f"}, -] - -[package.dependencies] -Markdown = ">=3.3" -markupsafe = ">=2.0.1" -mkdocs = ">=1.1" - -[[package]] -name = "mkdocs-get-deps" -version = "0.2.0" -description = "MkDocs extension that lists all dependencies according to a mkdocs.yml file" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mkdocs_get_deps-0.2.0-py3-none-any.whl", hash = "sha256:2bf11d0b133e77a0dd036abeeb06dec8775e46efa526dc70667d8863eefc6134"}, - {file = "mkdocs_get_deps-0.2.0.tar.gz", hash = "sha256:162b3d129c7fad9b19abfdcb9c1458a651628e4b1dea628ac68790fb3061c60c"}, -] - -[package.dependencies] -importlib-metadata = {version = ">=4.3", markers = "python_version < \"3.10\""} -mergedeep = ">=1.3.4" -platformdirs = ">=2.2.0" -pyyaml = ">=5.1" - -[[package]] -name = "mkdocs-material" -version = "9.5.48" -description = "Documentation that simply works" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mkdocs_material-9.5.48-py3-none-any.whl", hash = "sha256:b695c998f4b939ce748adbc0d3bff73fa886a670ece948cf27818fa115dc16f8"}, - {file = "mkdocs_material-9.5.48.tar.gz", hash = "sha256:a582531e8b34f4c7ed38c29d5c44763053832cf2a32f7409567e0c74749a47db"}, -] - -[package.dependencies] -babel = ">=2.10,<3.0" -colorama = ">=0.4,<1.0" -jinja2 = ">=3.0,<4.0" -markdown = ">=3.2,<4.0" -mkdocs = ">=1.6,<2.0" -mkdocs-material-extensions = ">=1.3,<2.0" -paginate = ">=0.5,<1.0" -pygments = ">=2.16,<3.0" -pymdown-extensions = ">=10.2,<11.0" -regex = ">=2022.4" -requests = ">=2.26,<3.0" - -[package.extras] -git = ["mkdocs-git-committers-plugin-2 (>=1.1,<2.0)", "mkdocs-git-revision-date-localized-plugin (>=1.2.4,<2.0)"] -imaging = ["cairosvg (>=2.6,<3.0)", "pillow (>=10.2,<11.0)"] -recommended = ["mkdocs-minify-plugin (>=0.7,<1.0)", "mkdocs-redirects (>=1.2,<2.0)", "mkdocs-rss-plugin (>=1.6,<2.0)"] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -description = "Extension pack for Python Markdown and MkDocs Material." -optional = false -python-versions = ">=3.8" -files = [ - {file = "mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31"}, - {file = "mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443"}, -] - -[[package]] -name = "mkdocstrings" -version = "0.24.3" -description = "Automatic documentation from sources, for MkDocs." -optional = false -python-versions = ">=3.8" -files = [ - {file = "mkdocstrings-0.24.3-py3-none-any.whl", hash = "sha256:5c9cf2a32958cd161d5428699b79c8b0988856b0d4a8c5baf8395fc1bf4087c3"}, - {file = "mkdocstrings-0.24.3.tar.gz", hash = "sha256:f327b234eb8d2551a306735436e157d0a22d45f79963c60a8b585d5f7a94c1d2"}, -] - -[package.dependencies] -click = ">=7.0" -importlib-metadata = {version = ">=4.6", markers = "python_version < \"3.10\""} -Jinja2 = ">=2.11.1" -Markdown = ">=3.3" -MarkupSafe = ">=1.1" -mkdocs = ">=1.4" -mkdocs-autorefs = ">=0.3.1" -platformdirs = ">=2.2.0" -pymdown-extensions = ">=6.3" -typing-extensions = {version = ">=4.1", markers = "python_version < \"3.10\""} - -[package.extras] -crystal = ["mkdocstrings-crystal (>=0.3.4)"] -python = ["mkdocstrings-python (>=0.5.2)"] -python-legacy = ["mkdocstrings-python-legacy (>=0.2.1)"] - -[[package]] -name = "mkdocstrings-python" -version = "1.10.0" -description = "A Python handler for mkdocstrings." -optional = false -python-versions = ">=3.8" -files = [ - {file = "mkdocstrings_python-1.10.0-py3-none-any.whl", hash = "sha256:ba833fbd9d178a4b9d5cb2553a4df06e51dc1f51e41559a4d2398c16a6f69ecc"}, - {file = "mkdocstrings_python-1.10.0.tar.gz", hash = "sha256:71678fac657d4d2bb301eed4e4d2d91499c095fd1f8a90fa76422a87a5693828"}, -] - -[package.dependencies] -griffe = ">=0.44" -mkdocstrings = ">=0.24.2" - -[[package]] -name = "mongomock" -version = "4.3.0" -description = "Fake pymongo stub for testing simple MongoDB-dependent code" -optional = false -python-versions = "*" -files = [ - {file = "mongomock-4.3.0-py2.py3-none-any.whl", hash = "sha256:5ef86bd12fc8806c6e7af32f21266c61b6c4ba96096f85129852d1c4fec1327e"}, - {file = "mongomock-4.3.0.tar.gz", hash = "sha256:32667b79066fabc12d4f17f16a8fd7361b5f4435208b3ba32c226e52212a8c30"}, -] - -[package.dependencies] -packaging = "*" -pytz = "*" -sentinels = "*" - -[package.extras] -pyexecjs = ["pyexecjs"] -pymongo = ["pymongo"] - -[[package]] -name = "msgspec" -version = "0.18.6" -description = "A fast serialization and validation library, with builtin support for JSON, MessagePack, YAML, and TOML." -optional = false -python-versions = ">=3.8" -files = [ - {file = "msgspec-0.18.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:77f30b0234eceeff0f651119b9821ce80949b4d667ad38f3bfed0d0ebf9d6d8f"}, - {file = "msgspec-0.18.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a76b60e501b3932782a9da039bd1cd552b7d8dec54ce38332b87136c64852dd"}, - {file = "msgspec-0.18.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06acbd6edf175bee0e36295d6b0302c6de3aaf61246b46f9549ca0041a9d7177"}, - {file = "msgspec-0.18.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:40a4df891676d9c28a67c2cc39947c33de516335680d1316a89e8f7218660410"}, - {file = "msgspec-0.18.6-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:a6896f4cd5b4b7d688018805520769a8446df911eb93b421c6c68155cdf9dd5a"}, - {file = "msgspec-0.18.6-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:3ac4dd63fd5309dd42a8c8c36c1563531069152be7819518be0a9d03be9788e4"}, - {file = "msgspec-0.18.6-cp310-cp310-win_amd64.whl", hash = "sha256:fda4c357145cf0b760000c4ad597e19b53adf01382b711f281720a10a0fe72b7"}, - {file = "msgspec-0.18.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e77e56ffe2701e83a96e35770c6adb655ffc074d530018d1b584a8e635b4f36f"}, - {file = "msgspec-0.18.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d5351afb216b743df4b6b147691523697ff3a2fc5f3d54f771e91219f5c23aaa"}, - {file = "msgspec-0.18.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c3232fabacef86fe8323cecbe99abbc5c02f7698e3f5f2e248e3480b66a3596b"}, - {file = "msgspec-0.18.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e3b524df6ea9998bbc99ea6ee4d0276a101bcc1aa8d14887bb823914d9f60d07"}, - {file = "msgspec-0.18.6-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:37f67c1d81272131895bb20d388dd8d341390acd0e192a55ab02d4d6468b434c"}, - {file = "msgspec-0.18.6-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d0feb7a03d971c1c0353de1a8fe30bb6579c2dc5ccf29b5f7c7ab01172010492"}, - {file = "msgspec-0.18.6-cp311-cp311-win_amd64.whl", hash = "sha256:41cf758d3f40428c235c0f27bc6f322d43063bc32da7b9643e3f805c21ed57b4"}, - {file = "msgspec-0.18.6-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:d86f5071fe33e19500920333c11e2267a31942d18fed4d9de5bc2fbab267d28c"}, - {file = "msgspec-0.18.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce13981bfa06f5eb126a3a5a38b1976bddb49a36e4f46d8e6edecf33ccf11df1"}, - {file = "msgspec-0.18.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e97dec6932ad5e3ee1e3c14718638ba333befc45e0661caa57033cd4cc489466"}, - {file = "msgspec-0.18.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ad237100393f637b297926cae1868b0d500f764ccd2f0623a380e2bcfb2809ca"}, - {file = "msgspec-0.18.6-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:db1d8626748fa5d29bbd15da58b2d73af25b10aa98abf85aab8028119188ed57"}, - {file = "msgspec-0.18.6-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:d70cb3d00d9f4de14d0b31d38dfe60c88ae16f3182988246a9861259c6722af6"}, - {file = "msgspec-0.18.6-cp312-cp312-win_amd64.whl", hash = "sha256:1003c20bfe9c6114cc16ea5db9c5466e49fae3d7f5e2e59cb70693190ad34da0"}, - {file = "msgspec-0.18.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:f7d9faed6dfff654a9ca7d9b0068456517f63dbc3aa704a527f493b9200b210a"}, - {file = "msgspec-0.18.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:9da21f804c1a1471f26d32b5d9bc0480450ea77fbb8d9db431463ab64aaac2cf"}, - {file = "msgspec-0.18.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:46eb2f6b22b0e61c137e65795b97dc515860bf6ec761d8fb65fdb62aa094ba61"}, - {file = "msgspec-0.18.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c8355b55c80ac3e04885d72db515817d9fbb0def3bab936bba104e99ad22cf46"}, - {file = "msgspec-0.18.6-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:9080eb12b8f59e177bd1eb5c21e24dd2ba2fa88a1dbc9a98e05ad7779b54c681"}, - {file = "msgspec-0.18.6-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:cc001cf39becf8d2dcd3f413a4797c55009b3a3cdbf78a8bf5a7ca8fdb76032c"}, - {file = "msgspec-0.18.6-cp38-cp38-win_amd64.whl", hash = "sha256:fac5834e14ac4da1fca373753e0c4ec9c8069d1fe5f534fa5208453b6065d5be"}, - {file = "msgspec-0.18.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:974d3520fcc6b824a6dedbdf2b411df31a73e6e7414301abac62e6b8d03791b4"}, - {file = "msgspec-0.18.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:fd62e5818731a66aaa8e9b0a1e5543dc979a46278da01e85c3c9a1a4f047ef7e"}, - {file = "msgspec-0.18.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7481355a1adcf1f08dedd9311193c674ffb8bf7b79314b4314752b89a2cf7f1c"}, - {file = "msgspec-0.18.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6aa85198f8f154cf35d6f979998f6dadd3dc46a8a8c714632f53f5d65b315c07"}, - {file = "msgspec-0.18.6-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0e24539b25c85c8f0597274f11061c102ad6b0c56af053373ba4629772b407be"}, - {file = "msgspec-0.18.6-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:c61ee4d3be03ea9cd089f7c8e36158786cd06e51fbb62529276452bbf2d52ece"}, - {file = "msgspec-0.18.6-cp39-cp39-win_amd64.whl", hash = "sha256:b5c390b0b0b7da879520d4ae26044d74aeee5144f83087eb7842ba59c02bc090"}, - {file = "msgspec-0.18.6.tar.gz", hash = "sha256:a59fc3b4fcdb972d09138cb516dbde600c99d07c38fd9372a6ef500d2d031b4e"}, -] - -[package.extras] -dev = ["attrs", "coverage", "furo", "gcovr", "ipython", "msgpack", "mypy", "pre-commit", "pyright", "pytest", "pyyaml", "sphinx", "sphinx-copybutton", "sphinx-design", "tomli", "tomli-w"] -doc = ["furo", "ipython", "sphinx", "sphinx-copybutton", "sphinx-design"] -test = ["attrs", "msgpack", "mypy", "pyright", "pytest", "pyyaml", "tomli", "tomli-w"] -toml = ["tomli", "tomli-w"] -yaml = ["pyyaml"] - -[[package]] -name = "mypy" -version = "1.13.0" -description = "Optional static typing for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "mypy-1.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6607e0f1dd1fb7f0aca14d936d13fd19eba5e17e1cd2a14f808fa5f8f6d8f60a"}, - {file = "mypy-1.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:8a21be69bd26fa81b1f80a61ee7ab05b076c674d9b18fb56239d72e21d9f4c80"}, - {file = "mypy-1.13.0-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7b2353a44d2179846a096e25691d54d59904559f4232519d420d64da6828a3a7"}, - {file = "mypy-1.13.0-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:0730d1c6a2739d4511dc4253f8274cdd140c55c32dfb0a4cf8b7a43f40abfa6f"}, - {file = "mypy-1.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:c5fc54dbb712ff5e5a0fca797e6e0aa25726c7e72c6a5850cfd2adbc1eb0a372"}, - {file = "mypy-1.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:581665e6f3a8a9078f28d5502f4c334c0c8d802ef55ea0e7276a6e409bc0d82d"}, - {file = "mypy-1.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3ddb5b9bf82e05cc9a627e84707b528e5c7caaa1c55c69e175abb15a761cec2d"}, - {file = "mypy-1.13.0-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20c7ee0bc0d5a9595c46f38beb04201f2620065a93755704e141fcac9f59db2b"}, - {file = "mypy-1.13.0-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:3790ded76f0b34bc9c8ba4def8f919dd6a46db0f5a6610fb994fe8efdd447f73"}, - {file = "mypy-1.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:51f869f4b6b538229c1d1bcc1dd7d119817206e2bc54e8e374b3dfa202defcca"}, - {file = "mypy-1.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5c7051a3461ae84dfb5dd15eff5094640c61c5f22257c8b766794e6dd85e72d5"}, - {file = "mypy-1.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:39bb21c69a5d6342f4ce526e4584bc5c197fd20a60d14a8624d8743fffb9472e"}, - {file = "mypy-1.13.0-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:164f28cb9d6367439031f4c81e84d3ccaa1e19232d9d05d37cb0bd880d3f93c2"}, - {file = "mypy-1.13.0-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:a4c1bfcdbce96ff5d96fc9b08e3831acb30dc44ab02671eca5953eadad07d6d0"}, - {file = "mypy-1.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:a0affb3a79a256b4183ba09811e3577c5163ed06685e4d4b46429a271ba174d2"}, - {file = "mypy-1.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:a7b44178c9760ce1a43f544e595d35ed61ac2c3de306599fa59b38a6048e1aa7"}, - {file = "mypy-1.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5d5092efb8516d08440e36626f0153b5006d4088c1d663d88bf79625af3d1d62"}, - {file = "mypy-1.13.0-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:de2904956dac40ced10931ac967ae63c5089bd498542194b436eb097a9f77bc8"}, - {file = "mypy-1.13.0-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:7bfd8836970d33c2105562650656b6846149374dc8ed77d98424b40b09340ba7"}, - {file = "mypy-1.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:9f73dba9ec77acb86457a8fc04b5239822df0c14a082564737833d2963677dbc"}, - {file = "mypy-1.13.0-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:100fac22ce82925f676a734af0db922ecfea991e1d7ec0ceb1e115ebe501301a"}, - {file = "mypy-1.13.0-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:7bcb0bb7f42a978bb323a7c88f1081d1b5dee77ca86f4100735a6f541299d8fb"}, - {file = "mypy-1.13.0-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bde31fc887c213e223bbfc34328070996061b0833b0a4cfec53745ed61f3519b"}, - {file = "mypy-1.13.0-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:07de989f89786f62b937851295ed62e51774722e5444a27cecca993fc3f9cd74"}, - {file = "mypy-1.13.0-cp38-cp38-win_amd64.whl", hash = "sha256:4bde84334fbe19bad704b3f5b78c4abd35ff1026f8ba72b29de70dda0916beb6"}, - {file = "mypy-1.13.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:0246bcb1b5de7f08f2826451abd947bf656945209b140d16ed317f65a17dc7dc"}, - {file = "mypy-1.13.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:7f5b7deae912cf8b77e990b9280f170381fdfbddf61b4ef80927edd813163732"}, - {file = "mypy-1.13.0-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7029881ec6ffb8bc233a4fa364736789582c738217b133f1b55967115288a2bc"}, - {file = "mypy-1.13.0-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:3e38b980e5681f28f033f3be86b099a247b13c491f14bb8b1e1e134d23bb599d"}, - {file = "mypy-1.13.0-cp39-cp39-win_amd64.whl", hash = "sha256:a6789be98a2017c912ae6ccb77ea553bbaf13d27605d2ca20a76dfbced631b24"}, - {file = "mypy-1.13.0-py3-none-any.whl", hash = "sha256:9c250883f9fd81d212e0952c92dbfcc96fc237f4b7c92f56ac81fd48460b3e5a"}, - {file = "mypy-1.13.0.tar.gz", hash = "sha256:0291a61b6fbf3e6673e3405cfcc0e7650bebc7939659fdca2702958038bd835e"}, -] - -[package.dependencies] -mypy-extensions = ">=1.0.0" -tomli = {version = ">=1.1.0", markers = "python_version < \"3.11\""} -typing-extensions = ">=4.6.0" - -[package.extras] -dmypy = ["psutil (>=4.0)"] -faster-cache = ["orjson"] -install-types = ["pip"] -mypyc = ["setuptools (>=50)"] -reports = ["lxml"] - -[[package]] -name = "mypy-extensions" -version = "1.0.0" -description = "Type system extensions for programs checked with the mypy type checker." -optional = false -python-versions = ">=3.5" -files = [ - {file = "mypy_extensions-1.0.0-py3-none-any.whl", hash = "sha256:4392f6c0eb8a5668a69e23d168ffa70f0be9ccfd32b5cc2d26a34ae5b844552d"}, - {file = "mypy_extensions-1.0.0.tar.gz", hash = "sha256:75dbf8955dc00442a438fc4d0666508a9a97b6bd41aa2f0ffe9d2f2725af0782"}, -] - -[[package]] -name = "packaging" -version = "24.2" -description = "Core utilities for Python packages" -optional = false -python-versions = ">=3.8" -files = [ - {file = "packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759"}, - {file = "packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f"}, -] - -[[package]] -name = "paginate" -version = "0.5.7" -description = "Divides large result sets into pages for easier browsing" -optional = false -python-versions = "*" -files = [ - {file = "paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591"}, - {file = "paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945"}, -] - -[package.extras] -dev = ["pytest", "tox"] -lint = ["black"] - -[[package]] -name = "pathspec" -version = "0.12.1" -description = "Utility library for gitignore style pattern matching of file paths." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pathspec-0.12.1-py3-none-any.whl", hash = "sha256:a0d503e138a4c123b27490a4f7beda6a01c6f288df0e4a8b79c7eb0dc7b4cc08"}, - {file = "pathspec-0.12.1.tar.gz", hash = "sha256:a482d51503a1ab33b1c67a6c3813a26953dbdc71c31dacaef9a838c4e29f5712"}, -] - -[[package]] -name = "platformdirs" -version = "4.3.6" -description = "A small Python package for determining appropriate platform-specific dirs, e.g. a `user data dir`." -optional = false -python-versions = ">=3.8" -files = [ - {file = "platformdirs-4.3.6-py3-none-any.whl", hash = "sha256:73e575e1408ab8103900836b97580d5307456908a03e92031bab39e4554cc3fb"}, - {file = "platformdirs-4.3.6.tar.gz", hash = "sha256:357fb2acbc885b0419afd3ce3ed34564c13c9b95c89360cd9563f73aa5e2b907"}, -] - -[package.extras] -docs = ["furo (>=2024.8.6)", "proselint (>=0.14)", "sphinx (>=8.0.2)", "sphinx-autodoc-typehints (>=2.4)"] -test = ["appdirs (==1.4.4)", "covdefaults (>=2.3)", "pytest (>=8.3.2)", "pytest-cov (>=5)", "pytest-mock (>=3.14)"] -type = ["mypy (>=1.11.2)"] - -[[package]] -name = "pluggy" -version = "1.5.0" -description = "plugin and hook calling mechanisms for python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pluggy-1.5.0-py3-none-any.whl", hash = "sha256:44e1ad92c8ca002de6377e165f3e0f1be63266ab4d554740532335b9d75ea669"}, - {file = "pluggy-1.5.0.tar.gz", hash = "sha256:2cffa88e94fdc978c4c574f15f9e59b7f4201d439195c3715ca9e2486f1d0cf1"}, -] - -[package.extras] -dev = ["pre-commit", "tox"] -testing = ["pytest", "pytest-benchmark"] - -[[package]] -name = "pydantic" -version = "2.10.3" -description = "Data validation using Python type hints" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic-2.10.3-py3-none-any.whl", hash = "sha256:be04d85bbc7b65651c5f8e6b9976ed9c6f41782a55524cef079a34a0bb82144d"}, - {file = "pydantic-2.10.3.tar.gz", hash = "sha256:cb5ac360ce894ceacd69c403187900a02c4b20b693a9dd1d643e1effab9eadf9"}, -] - -[package.dependencies] -annotated-types = ">=0.6.0" -pydantic-core = "2.27.1" -typing-extensions = ">=4.12.2" - -[package.extras] -email = ["email-validator (>=2.0.0)"] -timezone = ["tzdata"] - -[[package]] -name = "pydantic-core" -version = "2.27.1" -description = "Core functionality for Pydantic validation and serialization" -optional = false -python-versions = ">=3.8" -files = [ - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:71a5e35c75c021aaf400ac048dacc855f000bdfed91614b4a726f7432f1f3d6a"}, - {file = "pydantic_core-2.27.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f82d068a2d6ecfc6e054726080af69a6764a10015467d7d7b9f66d6ed5afa23b"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:121ceb0e822f79163dd4699e4c54f5ad38b157084d97b34de8b232bcaad70278"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4603137322c18eaf2e06a4495f426aa8d8388940f3c457e7548145011bb68e05"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a33cd6ad9017bbeaa9ed78a2e0752c5e250eafb9534f308e7a5f7849b0b1bfb4"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15cc53a3179ba0fcefe1e3ae50beb2784dede4003ad2dfd24f81bba4b23a454f"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45d9c5eb9273aa50999ad6adc6be5e0ecea7e09dbd0d31bd0c65a55a2592ca08"}, - {file = "pydantic_core-2.27.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8bf7b66ce12a2ac52d16f776b31d16d91033150266eb796967a7e4621707e4f6"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:655d7dd86f26cb15ce8a431036f66ce0318648f8853d709b4167786ec2fa4807"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:5556470f1a2157031e676f776c2bc20acd34c1990ca5f7e56f1ebf938b9ab57c"}, - {file = "pydantic_core-2.27.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:f69ed81ab24d5a3bd93861c8c4436f54afdf8e8cc421562b0c7504cf3be58206"}, - {file = "pydantic_core-2.27.1-cp310-none-win32.whl", hash = "sha256:f5a823165e6d04ccea61a9f0576f345f8ce40ed533013580e087bd4d7442b52c"}, - {file = "pydantic_core-2.27.1-cp310-none-win_amd64.whl", hash = "sha256:57866a76e0b3823e0b56692d1a0bf722bffb324839bb5b7226a7dbd6c9a40b17"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:ac3b20653bdbe160febbea8aa6c079d3df19310d50ac314911ed8cc4eb7f8cb8"}, - {file = "pydantic_core-2.27.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a5a8e19d7c707c4cadb8c18f5f60c843052ae83c20fa7d44f41594c644a1d330"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7f7059ca8d64fea7f238994c97d91f75965216bcbe5f695bb44f354893f11d52"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bed0f8a0eeea9fb72937ba118f9db0cb7e90773462af7962d382445f3005e5a4"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a3cb37038123447cf0f3ea4c74751f6a9d7afef0eb71aa07bf5f652b5e6a132c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:84286494f6c5d05243456e04223d5a9417d7f443c3b76065e75001beb26f88de"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acc07b2cfc5b835444b44a9956846b578d27beeacd4b52e45489e93276241025"}, - {file = "pydantic_core-2.27.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4fefee876e07a6e9aad7a8c8c9f85b0cdbe7df52b8a9552307b09050f7512c7e"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:258c57abf1188926c774a4c94dd29237e77eda19462e5bb901d88adcab6af919"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:35c14ac45fcfdf7167ca76cc80b2001205a8d5d16d80524e13508371fb8cdd9c"}, - {file = "pydantic_core-2.27.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d1b26e1dff225c31897696cab7d4f0a315d4c0d9e8666dbffdb28216f3b17fdc"}, - {file = "pydantic_core-2.27.1-cp311-none-win32.whl", hash = "sha256:2cdf7d86886bc6982354862204ae3b2f7f96f21a3eb0ba5ca0ac42c7b38598b9"}, - {file = "pydantic_core-2.27.1-cp311-none-win_amd64.whl", hash = "sha256:3af385b0cee8df3746c3f406f38bcbfdc9041b5c2d5ce3e5fc6637256e60bbc5"}, - {file = "pydantic_core-2.27.1-cp311-none-win_arm64.whl", hash = "sha256:81f2ec23ddc1b476ff96563f2e8d723830b06dceae348ce02914a37cb4e74b89"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:9cbd94fc661d2bab2bc702cddd2d3370bbdcc4cd0f8f57488a81bcce90c7a54f"}, - {file = "pydantic_core-2.27.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5f8c4718cd44ec1580e180cb739713ecda2bdee1341084c1467802a417fe0f02"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:15aae984e46de8d376df515f00450d1522077254ef6b7ce189b38ecee7c9677c"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ba5e3963344ff25fc8c40da90f44b0afca8cfd89d12964feb79ac1411a260ac"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:992cea5f4f3b29d6b4f7f1726ed8ee46c8331c6b4eed6db5b40134c6fe1768bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0325336f348dbee6550d129b1627cb8f5351a9dc91aad141ffb96d4937bd9529"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7597c07fbd11515f654d6ece3d0e4e5093edc30a436c63142d9a4b8e22f19c35"}, - {file = "pydantic_core-2.27.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3bbd5d8cc692616d5ef6fbbbd50dbec142c7e6ad9beb66b78a96e9c16729b089"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:dc61505e73298a84a2f317255fcc72b710b72980f3a1f670447a21efc88f8381"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:e1f735dc43da318cad19b4173dd1ffce1d84aafd6c9b782b3abc04a0d5a6f5bb"}, - {file = "pydantic_core-2.27.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:f4e5658dbffe8843a0f12366a4c2d1c316dbe09bb4dfbdc9d2d9cd6031de8aae"}, - {file = "pydantic_core-2.27.1-cp312-none-win32.whl", hash = "sha256:672ebbe820bb37988c4d136eca2652ee114992d5d41c7e4858cdd90ea94ffe5c"}, - {file = "pydantic_core-2.27.1-cp312-none-win_amd64.whl", hash = "sha256:66ff044fd0bb1768688aecbe28b6190f6e799349221fb0de0e6f4048eca14c16"}, - {file = "pydantic_core-2.27.1-cp312-none-win_arm64.whl", hash = "sha256:9a3b0793b1bbfd4146304e23d90045f2a9b5fd5823aa682665fbdaf2a6c28f3e"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:f216dbce0e60e4d03e0c4353c7023b202d95cbaeff12e5fd2e82ea0a66905073"}, - {file = "pydantic_core-2.27.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a2e02889071850bbfd36b56fd6bc98945e23670773bc7a76657e90e6b6603c08"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42b0e23f119b2b456d07ca91b307ae167cc3f6c846a7b169fca5326e32fdc6cf"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:764be71193f87d460a03f1f7385a82e226639732214b402f9aa61f0d025f0737"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c00666a3bd2f84920a4e94434f5974d7bbc57e461318d6bb34ce9cdbbc1f6b2"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3ccaa88b24eebc0f849ce0a4d09e8a408ec5a94afff395eb69baf868f5183107"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c65af9088ac534313e1963443d0ec360bb2b9cba6c2909478d22c2e363d98a51"}, - {file = "pydantic_core-2.27.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:206b5cf6f0c513baffaeae7bd817717140770c74528f3e4c3e1cec7871ddd61a"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:062f60e512fc7fff8b8a9d680ff0ddaaef0193dba9fa83e679c0c5f5fbd018bc"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:a0697803ed7d4af5e4c1adf1670af078f8fcab7a86350e969f454daf598c4960"}, - {file = "pydantic_core-2.27.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:58ca98a950171f3151c603aeea9303ef6c235f692fe555e883591103da709b23"}, - {file = "pydantic_core-2.27.1-cp313-none-win32.whl", hash = "sha256:8065914ff79f7eab1599bd80406681f0ad08f8e47c880f17b416c9f8f7a26d05"}, - {file = "pydantic_core-2.27.1-cp313-none-win_amd64.whl", hash = "sha256:ba630d5e3db74c79300d9a5bdaaf6200172b107f263c98a0539eeecb857b2337"}, - {file = "pydantic_core-2.27.1-cp313-none-win_arm64.whl", hash = "sha256:45cf8588c066860b623cd11c4ba687f8d7175d5f7ef65f7129df8a394c502de5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_10_12_x86_64.whl", hash = "sha256:5897bec80a09b4084aee23f9b73a9477a46c3304ad1d2d07acca19723fb1de62"}, - {file = "pydantic_core-2.27.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:d0165ab2914379bd56908c02294ed8405c252250668ebcb438a55494c69f44ab"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6b9af86e1d8e4cfc82c2022bfaa6f459381a50b94a29e95dcdda8442d6d83864"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f6c8a66741c5f5447e047ab0ba7a1c61d1e95580d64bce852e3df1f895c4067"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9a42d6a8156ff78981f8aa56eb6394114e0dedb217cf8b729f438f643608cbcd"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:64c65f40b4cd8b0e049a8edde07e38b476da7e3aaebe63287c899d2cff253fa5"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9fdcf339322a3fae5cbd504edcefddd5a50d9ee00d968696846f089b4432cf78"}, - {file = "pydantic_core-2.27.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:bf99c8404f008750c846cb4ac4667b798a9f7de673ff719d705d9b2d6de49c5f"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:8f1edcea27918d748c7e5e4d917297b2a0ab80cad10f86631e488b7cddf76a36"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_armv7l.whl", hash = "sha256:159cac0a3d096f79ab6a44d77a961917219707e2a130739c64d4dd46281f5c2a"}, - {file = "pydantic_core-2.27.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:029d9757eb621cc6e1848fa0b0310310de7301057f623985698ed7ebb014391b"}, - {file = "pydantic_core-2.27.1-cp38-none-win32.whl", hash = "sha256:a28af0695a45f7060e6f9b7092558a928a28553366519f64083c63a44f70e618"}, - {file = "pydantic_core-2.27.1-cp38-none-win_amd64.whl", hash = "sha256:2d4567c850905d5eaaed2f7a404e61012a51caf288292e016360aa2b96ff38d4"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:e9386266798d64eeb19dd3677051f5705bf873e98e15897ddb7d76f477131967"}, - {file = "pydantic_core-2.27.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:4228b5b646caa73f119b1ae756216b59cc6e2267201c27d3912b592c5e323b60"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0b3dfe500de26c52abe0477dde16192ac39c98f05bf2d80e76102d394bd13854"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:aee66be87825cdf72ac64cb03ad4c15ffef4143dbf5c113f64a5ff4f81477bf9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3b748c44bb9f53031c8cbc99a8a061bc181c1000c60a30f55393b6e9c45cc5bd"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ca038c7f6a0afd0b2448941b6ef9d5e1949e999f9e5517692eb6da58e9d44be"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6e0bd57539da59a3e4671b90a502da9a28c72322a4f17866ba3ac63a82c4498e"}, - {file = "pydantic_core-2.27.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ac6c2c45c847bbf8f91930d88716a0fb924b51e0c6dad329b793d670ec5db792"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:b94d4ba43739bbe8b0ce4262bcc3b7b9f31459ad120fb595627eaeb7f9b9ca01"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:00e6424f4b26fe82d44577b4c842d7df97c20be6439e8e685d0d715feceb9fb9"}, - {file = "pydantic_core-2.27.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:38de0a70160dd97540335b7ad3a74571b24f1dc3ed33f815f0880682e6880131"}, - {file = "pydantic_core-2.27.1-cp39-none-win32.whl", hash = "sha256:7ccebf51efc61634f6c2344da73e366c75e735960b5654b63d7e6f69a5885fa3"}, - {file = "pydantic_core-2.27.1-cp39-none-win_amd64.whl", hash = "sha256:a57847b090d7892f123726202b7daa20df6694cbd583b67a592e856bff603d6c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_10_12_x86_64.whl", hash = "sha256:3fa80ac2bd5856580e242dbc202db873c60a01b20309c8319b5c5986fbe53ce6"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:d950caa237bb1954f1b8c9227b5065ba6875ac9771bb8ec790d956a699b78676"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0e4216e64d203e39c62df627aa882f02a2438d18a5f21d7f721621f7a5d3611d"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:02a3d637bd387c41d46b002f0e49c52642281edacd2740e5a42f7017feea3f2c"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:161c27ccce13b6b0c8689418da3885d3220ed2eae2ea5e9b2f7f3d48f1d52c27"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:19910754e4cc9c63bc1c7f6d73aa1cfee82f42007e407c0f413695c2f7ed777f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:e173486019cc283dc9778315fa29a363579372fe67045e971e89b6365cc035ed"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:af52d26579b308921b73b956153066481f064875140ccd1dfd4e77db89dbb12f"}, - {file = "pydantic_core-2.27.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:981fb88516bd1ae8b0cbbd2034678a39dedc98752f264ac9bc5839d3923fa04c"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_10_12_x86_64.whl", hash = "sha256:5fde892e6c697ce3e30c61b239330fc5d569a71fefd4eb6512fc6caec9dd9e2f"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:816f5aa087094099fff7edabb5e01cc370eb21aa1a1d44fe2d2aefdfb5599b31"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9c10c309e18e443ddb108f0ef64e8729363adbfd92d6d57beec680f6261556f3"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:98476c98b02c8e9b2eec76ac4156fd006628b1b2d0ef27e548ffa978393fd154"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3027001c28434e7ca5a6e1e527487051136aa81803ac812be51802150d880dd"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:7699b1df36a48169cdebda7ab5a2bac265204003f153b4bd17276153d997670a"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:1c39b07d90be6b48968ddc8c19e7585052088fd7ec8d568bb31ff64c70ae3c97"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:46ccfe3032b3915586e469d4972973f893c0a2bb65669194a5bdea9bacc088c2"}, - {file = "pydantic_core-2.27.1-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:62ba45e21cf6571d7f716d903b5b7b6d2617e2d5d67c0923dc47b9d41369f840"}, - {file = "pydantic_core-2.27.1.tar.gz", hash = "sha256:62a763352879b84aa31058fc931884055fd75089cccbd9d58bb6afd01141b235"}, -] - -[package.dependencies] -typing-extensions = ">=4.6.0,<4.7.0 || >4.7.0" - -[[package]] -name = "pygments" -version = "2.18.0" -description = "Pygments is a syntax highlighting package written in Python." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pygments-2.18.0-py3-none-any.whl", hash = "sha256:b8e6aca0523f3ab76fee51799c488e38782ac06eafcf95e7ba832985c8e7b13a"}, - {file = "pygments-2.18.0.tar.gz", hash = "sha256:786ff802f32e91311bff3889f6e9a86e81505fe99f2735bb6d60ae0c5004f199"}, -] - -[package.extras] -windows-terminal = ["colorama (>=0.4.6)"] - -[[package]] -name = "pymdown-extensions" -version = "10.12" -description = "Extension pack for Python Markdown." -optional = false -python-versions = ">=3.8" -files = [ - {file = "pymdown_extensions-10.12-py3-none-any.whl", hash = "sha256:49f81412242d3527b8b4967b990df395c89563043bc51a3d2d7d500e52123b77"}, - {file = "pymdown_extensions-10.12.tar.gz", hash = "sha256:b0ee1e0b2bef1071a47891ab17003bfe5bf824a398e13f49f8ed653b699369a7"}, -] - -[package.dependencies] -markdown = ">=3.6" -pyyaml = "*" - -[package.extras] -extra = ["pygments (>=2.12)"] - -[[package]] -name = "pymongo" -version = "4.10.1" -description = "Python driver for MongoDB " -optional = false -python-versions = ">=3.8" -files = [ - {file = "pymongo-4.10.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e699aa68c4a7dea2ab5a27067f7d3e08555f8d2c0dc6a0c8c60cfd9ff2e6a4b1"}, - {file = "pymongo-4.10.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:70645abc714f06b4ad6b72d5bf73792eaad14e3a2cfe29c62a9c81ada69d9e4b"}, - {file = "pymongo-4.10.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ae2fd94c9fe048c94838badcc6e992d033cb9473eb31e5710b3707cba5e8aee2"}, - {file = "pymongo-4.10.1-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5ded27a4a5374dae03a92e084a60cdbcecd595306555bda553b833baf3fc4868"}, - {file = "pymongo-4.10.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1ecc2455e3974a6c429687b395a0bc59636f2d6aedf5785098cf4e1f180f1c71"}, - {file = "pymongo-4.10.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a920fee41f7d0259f5f72c1f1eb331bc26ffbdc952846f9bd8c3b119013bb52c"}, - {file = "pymongo-4.10.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e0a15665b2d6cf364f4cd114d62452ce01d71abfbd9c564ba8c74dcd7bbd6822"}, - {file = "pymongo-4.10.1-cp310-cp310-win32.whl", hash = "sha256:29e1c323c28a4584b7095378ff046815e39ff82cdb8dc4cc6dfe3acf6f9ad1f8"}, - {file = "pymongo-4.10.1-cp310-cp310-win_amd64.whl", hash = "sha256:88dc4aa45f8744ccfb45164aedb9a4179c93567bbd98a33109d7dc400b00eb08"}, - {file = "pymongo-4.10.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:57ee6becae534e6d47848c97f6a6dff69e3cce7c70648d6049bd586764febe59"}, - {file = "pymongo-4.10.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:6f437a612f4d4f7aca1812311b1e84477145e950fdafe3285b687ab8c52541f3"}, - {file = "pymongo-4.10.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1a970fd3117ab40a4001c3dad333bbf3c43687d90f35287a6237149b5ccae61d"}, - {file = "pymongo-4.10.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7c4d0e7cd08ef9f8fbf2d15ba281ed55604368a32752e476250724c3ce36c72e"}, - {file = "pymongo-4.10.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ca6f700cff6833de4872a4e738f43123db34400173558b558ae079b5535857a4"}, - {file = "pymongo-4.10.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cec237c305fcbeef75c0bcbe9d223d1e22a6e3ba1b53b2f0b79d3d29c742b45b"}, - {file = "pymongo-4.10.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b3337804ea0394a06e916add4e5fac1c89902f1b6f33936074a12505cab4ff05"}, - {file = "pymongo-4.10.1-cp311-cp311-win32.whl", hash = "sha256:778ac646ce6ac1e469664062dfe9ae1f5c9961f7790682809f5ec3b8fda29d65"}, - {file = "pymongo-4.10.1-cp311-cp311-win_amd64.whl", hash = "sha256:9df4ab5594fdd208dcba81be815fa8a8a5d8dedaf3b346cbf8b61c7296246a7a"}, - {file = "pymongo-4.10.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fbedc4617faa0edf423621bb0b3b8707836687161210d470e69a4184be9ca011"}, - {file = "pymongo-4.10.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7bd26b2aec8ceeb95a5d948d5cc0f62b0eb6d66f3f4230705c1e3d3d2c04ec76"}, - {file = "pymongo-4.10.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb104c3c2a78d9d85571c8ac90ec4f95bca9b297c6eee5ada71fabf1129e1674"}, - {file = "pymongo-4.10.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:4924355245a9c79f77b5cda2db36e0f75ece5faf9f84d16014c0a297f6d66786"}, - {file = "pymongo-4.10.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:11280809e5dacaef4971113f0b4ff4696ee94cfdb720019ff4fa4f9635138252"}, - {file = "pymongo-4.10.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e5d55f2a82e5eb23795f724991cac2bffbb1c0f219c0ba3bf73a835f97f1bb2e"}, - {file = "pymongo-4.10.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e974ab16a60be71a8dfad4e5afccf8dd05d41c758060f5d5bda9a758605d9a5d"}, - {file = "pymongo-4.10.1-cp312-cp312-win32.whl", hash = "sha256:544890085d9641f271d4f7a47684450ed4a7344d6b72d5968bfae32203b1bb7c"}, - {file = "pymongo-4.10.1-cp312-cp312-win_amd64.whl", hash = "sha256:dcc07b1277e8b4bf4d7382ca133850e323b7ab048b8353af496d050671c7ac52"}, - {file = "pymongo-4.10.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:90bc6912948dfc8c363f4ead54d54a02a15a7fee6cfafb36dc450fc8962d2cb7"}, - {file = "pymongo-4.10.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:594dd721b81f301f33e843453638e02d92f63c198358e5a0fa8b8d0b1218dabc"}, - {file = "pymongo-4.10.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0783e0c8e95397c84e9cf8ab092ab1e5dd7c769aec0ef3a5838ae7173b98dea0"}, - {file = "pymongo-4.10.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6fb6a72e88df46d1c1040fd32cd2d2c5e58722e5d3e31060a0393f04ad3283de"}, - {file = "pymongo-4.10.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2e3a593333e20c87415420a4fb76c00b7aae49b6361d2e2205b6fece0563bf40"}, - {file = "pymongo-4.10.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:72e2ace7456167c71cfeca7dcb47bd5dceda7db2231265b80fc625c5e8073186"}, - {file = "pymongo-4.10.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:8ad05eb9c97e4f589ed9e74a00fcaac0d443ccd14f38d1258eb4c39a35dd722b"}, - {file = "pymongo-4.10.1-cp313-cp313-win32.whl", hash = "sha256:ee4c86d8e6872a61f7888fc96577b0ea165eb3bdb0d841962b444fa36001e2bb"}, - {file = "pymongo-4.10.1-cp313-cp313-win_amd64.whl", hash = "sha256:45ee87a4e12337353242bc758accc7fb47a2f2d9ecc0382a61e64c8f01e86708"}, - {file = "pymongo-4.10.1-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:442ca247f53ad24870a01e80a71cd81b3f2318655fd9d66748ee2bd1b1569d9e"}, - {file = "pymongo-4.10.1-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:23e1d62df5592518204943b507be7b457fb8a4ad95a349440406fd42db5d0923"}, - {file = "pymongo-4.10.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6131bc6568b26e7495a9f3ef2b1700566b76bbecd919f4472bfe90038a61f425"}, - {file = "pymongo-4.10.1-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fdeba88c540c9ed0338c0b2062d9f81af42b18d6646b3e6dda05cf6edd46ada9"}, - {file = "pymongo-4.10.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:15a624d752dd3c89d10deb0ef6431559b6d074703cab90a70bb849ece02adc6b"}, - {file = "pymongo-4.10.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba164e73fdade9b4614a2497321c5b7512ddf749ed508950bdecc28d8d76a2d9"}, - {file = "pymongo-4.10.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:9235fa319993405ae5505bf1333366388add2e06848db7b3deee8f990b69808e"}, - {file = "pymongo-4.10.1-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4a65567bd17d19f03157c7ec992c6530eafd8191a4e5ede25566792c4fe3fa2"}, - {file = "pymongo-4.10.1-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:f1945d48fb9b8a87d515da07f37e5b2c35b364a435f534c122e92747881f4a7c"}, - {file = "pymongo-4.10.1-cp38-cp38-win32.whl", hash = "sha256:345f8d340802ebce509f49d5833cc913da40c82f2e0daf9f60149cacc9ca680f"}, - {file = "pymongo-4.10.1-cp38-cp38-win_amd64.whl", hash = "sha256:3a70d5efdc0387ac8cd50f9a5f379648ecfc322d14ec9e1ba8ec957e5d08c372"}, - {file = "pymongo-4.10.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:15b1492cc5c7cd260229590be7218261e81684b8da6d6de2660cf743445500ce"}, - {file = "pymongo-4.10.1-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:95207503c41b97e7ecc7e596d84a61f441b4935f11aa8332828a754e7ada8c82"}, - {file = "pymongo-4.10.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb99f003c720c6d83be02c8f1a7787c22384a8ca9a4181e406174db47a048619"}, - {file = "pymongo-4.10.1-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f2bc1ee4b1ca2c4e7e6b7a5e892126335ec8d9215bcd3ac2fe075870fefc3358"}, - {file = "pymongo-4.10.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:93a0833c10a967effcd823b4e7445ec491f0bf6da5de0ca33629c0528f42b748"}, - {file = "pymongo-4.10.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f56707497323150bd2ed5d63067f4ffce940d0549d4ea2dfae180deec7f9363"}, - {file = "pymongo-4.10.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:409ab7d6c4223e5c85881697f365239dd3ed1b58f28e4124b846d9d488c86880"}, - {file = "pymongo-4.10.1-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:dac78a650dc0637d610905fd06b5fa6419ae9028cf4d04d6a2657bc18a66bbce"}, - {file = "pymongo-4.10.1-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.whl", hash = "sha256:1ec3fa88b541e0481aff3c35194c9fac96e4d57ec5d1c122376000eb28c01431"}, - {file = "pymongo-4.10.1-cp39-cp39-win32.whl", hash = "sha256:e0e961923a7b8a1c801c43552dcb8153e45afa41749d9efbd3a6d33f45489f7a"}, - {file = "pymongo-4.10.1-cp39-cp39-win_amd64.whl", hash = "sha256:dabe8bf1ad644e6b93f3acf90ff18536d94538ca4d27e583c6db49889e98e48f"}, - {file = "pymongo-4.10.1.tar.gz", hash = "sha256:a9de02be53b6bb98efe0b9eda84ffa1ec027fcb23a2de62c4f941d9a2f2f3330"}, -] - -[package.dependencies] -dnspython = ">=1.16.0,<3.0.0" - -[package.extras] -aws = ["pymongo-auth-aws (>=1.1.0,<2.0.0)"] -docs = ["furo (==2023.9.10)", "readthedocs-sphinx-search (>=0.3,<1.0)", "sphinx (>=5.3,<8)", "sphinx-autobuild (>=2020.9.1)", "sphinx-rtd-theme (>=2,<3)", "sphinxcontrib-shellcheck (>=1,<2)"] -encryption = ["certifi", "pymongo-auth-aws (>=1.1.0,<2.0.0)", "pymongocrypt (>=1.10.0,<2.0.0)"] -gssapi = ["pykerberos", "winkerberos (>=0.5.0)"] -ocsp = ["certifi", "cryptography (>=2.5)", "pyopenssl (>=17.2.0)", "requests (<3.0.0)", "service-identity (>=18.1.0)"] -snappy = ["python-snappy"] -test = ["pytest (>=8.2)", "pytest-asyncio (>=0.24.0)"] -zstd = ["zstandard"] - -[[package]] -name = "pytest" -version = "7.4.4" -description = "pytest: simple powerful testing with Python" -optional = false -python-versions = ">=3.7" -files = [ - {file = "pytest-7.4.4-py3-none-any.whl", hash = "sha256:b090cdf5ed60bf4c45261be03239c2c1c22df034fbffe691abe93cd80cea01d8"}, - {file = "pytest-7.4.4.tar.gz", hash = "sha256:2cf0005922c6ace4a3e2ec8b4080eb0d9753fdc93107415332f50ce9e7994280"}, -] - -[package.dependencies] -colorama = {version = "*", markers = "sys_platform == \"win32\""} -exceptiongroup = {version = ">=1.0.0rc8", markers = "python_version < \"3.11\""} -iniconfig = "*" -packaging = "*" -pluggy = ">=0.12,<2.0" -tomli = {version = ">=1.0.0", markers = "python_version < \"3.11\""} - -[package.extras] -testing = ["argcomplete", "attrs (>=19.2.0)", "hypothesis (>=3.56)", "mock", "nose", "pygments (>=2.7.2)", "requests", "setuptools", "xmlschema"] - -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -description = "Extensions to the standard Python datetime module" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3"}, - {file = "python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"}, -] - -[package.dependencies] -six = ">=1.5" - -[[package]] -name = "pytz" -version = "2024.2" -description = "World timezone definitions, modern and historical" -optional = false -python-versions = "*" -files = [ - {file = "pytz-2024.2-py2.py3-none-any.whl", hash = "sha256:31c7c1817eb7fae7ca4b8c7ee50c72f93aa2dd863de768e1ef4245d426aa0725"}, - {file = "pytz-2024.2.tar.gz", hash = "sha256:2aa355083c50a0f93fa581709deac0c9ad65cca8a9e9beac660adcbd493c798a"}, -] - -[[package]] -name = "pyyaml" -version = "6.0.2" -description = "YAML parser and emitter for Python" -optional = false -python-versions = ">=3.8" -files = [ - {file = "PyYAML-6.0.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0a9a2848a5b7feac301353437eb7d5957887edbf81d56e903999a75a3d743086"}, - {file = "PyYAML-6.0.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:29717114e51c84ddfba879543fb232a6ed60086602313ca38cce623c1d62cfbf"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8824b5a04a04a047e72eea5cec3bc266db09e35de6bdfe34c9436ac5ee27d237"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7c36280e6fb8385e520936c3cb3b8042851904eba0e58d277dca80a5cfed590b"}, - {file = "PyYAML-6.0.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ec031d5d2feb36d1d1a24380e4db6d43695f3748343d99434e6f5f9156aaa2ed"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:936d68689298c36b53b29f23c6dbb74de12b4ac12ca6cfe0e047bedceea56180"}, - {file = "PyYAML-6.0.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:23502f431948090f597378482b4812b0caae32c22213aecf3b55325e049a6c68"}, - {file = "PyYAML-6.0.2-cp310-cp310-win32.whl", hash = "sha256:2e99c6826ffa974fe6e27cdb5ed0021786b03fc98e5ee3c5bfe1fd5015f42b99"}, - {file = "PyYAML-6.0.2-cp310-cp310-win_amd64.whl", hash = "sha256:a4d3091415f010369ae4ed1fc6b79def9416358877534caf6a0fdd2146c87a3e"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:cc1c1159b3d456576af7a3e4d1ba7e6924cb39de8f67111c735f6fc832082774"}, - {file = "PyYAML-6.0.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1e2120ef853f59c7419231f3bf4e7021f1b936f6ebd222406c3b60212205d2ee"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5d225db5a45f21e78dd9358e58a98702a0302f2659a3c6cd320564b75b86f47c"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5ac9328ec4831237bec75defaf839f7d4564be1e6b25ac710bd1a96321cc8317"}, - {file = "PyYAML-6.0.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3ad2a3decf9aaba3d29c8f537ac4b243e36bef957511b4766cb0057d32b0be85"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:ff3824dc5261f50c9b0dfb3be22b4567a6f938ccce4587b38952d85fd9e9afe4"}, - {file = "PyYAML-6.0.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:797b4f722ffa07cc8d62053e4cff1486fa6dc094105d13fea7b1de7d8bf71c9e"}, - {file = "PyYAML-6.0.2-cp311-cp311-win32.whl", hash = "sha256:11d8f3dd2b9c1207dcaf2ee0bbbfd5991f571186ec9cc78427ba5bd32afae4b5"}, - {file = "PyYAML-6.0.2-cp311-cp311-win_amd64.whl", hash = "sha256:e10ce637b18caea04431ce14fabcf5c64a1c61ec9c56b071a4b7ca131ca52d44"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:c70c95198c015b85feafc136515252a261a84561b7b1d51e3384e0655ddf25ab"}, - {file = "PyYAML-6.0.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ce826d6ef20b1bc864f0a68340c8b3287705cae2f8b4b1d932177dcc76721725"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1f71ea527786de97d1a0cc0eacd1defc0985dcf6b3f17bb77dcfc8c34bec4dc5"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9b22676e8097e9e22e36d6b7bda33190d0d400f345f23d4065d48f4ca7ae0425"}, - {file = "PyYAML-6.0.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:80bab7bfc629882493af4aa31a4cfa43a4c57c83813253626916b8c7ada83476"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:0833f8694549e586547b576dcfaba4a6b55b9e96098b36cdc7ebefe667dfed48"}, - {file = "PyYAML-6.0.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8b9c7197f7cb2738065c481a0461e50ad02f18c78cd75775628afb4d7137fb3b"}, - {file = "PyYAML-6.0.2-cp312-cp312-win32.whl", hash = "sha256:ef6107725bd54b262d6dedcc2af448a266975032bc85ef0172c5f059da6325b4"}, - {file = "PyYAML-6.0.2-cp312-cp312-win_amd64.whl", hash = "sha256:7e7401d0de89a9a855c839bc697c079a4af81cf878373abd7dc625847d25cbd8"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:efdca5630322a10774e8e98e1af481aad470dd62c3170801852d752aa7a783ba"}, - {file = "PyYAML-6.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:50187695423ffe49e2deacb8cd10510bc361faac997de9efef88badc3bb9e2d1"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0ffe8360bab4910ef1b9e87fb812d8bc0a308b0d0eef8c8f44e0254ab3b07133"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:17e311b6c678207928d649faa7cb0d7b4c26a0ba73d41e99c4fff6b6c3276484"}, - {file = "PyYAML-6.0.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b189594dbe54f75ab3a1acec5f1e3faa7e8cf2f1e08d9b561cb41b845f69d5"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:41e4e3953a79407c794916fa277a82531dd93aad34e29c2a514c2c0c5fe971cc"}, - {file = "PyYAML-6.0.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:68ccc6023a3400877818152ad9a1033e3db8625d899c72eacb5a668902e4d652"}, - {file = "PyYAML-6.0.2-cp313-cp313-win32.whl", hash = "sha256:bc2fa7c6b47d6bc618dd7fb02ef6fdedb1090ec036abab80d4681424b84c1183"}, - {file = "PyYAML-6.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:8388ee1976c416731879ac16da0aff3f63b286ffdd57cdeb95f3f2e085687563"}, - {file = "PyYAML-6.0.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:24471b829b3bf607e04e88d79542a9d48bb037c2267d7927a874e6c205ca7e9a"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fded462629cfa4b685c5416b949ebad6cec74af5e2d42905d41e257e0869f5"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d84a1718ee396f54f3a086ea0a66d8e552b2ab2017ef8b420e92edbc841c352d"}, - {file = "PyYAML-6.0.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9056c1ecd25795207ad294bcf39f2db3d845767be0ea6e6a34d856f006006083"}, - {file = "PyYAML-6.0.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:82d09873e40955485746739bcb8b4586983670466c23382c19cffecbf1fd8706"}, - {file = "PyYAML-6.0.2-cp38-cp38-win32.whl", hash = "sha256:43fa96a3ca0d6b1812e01ced1044a003533c47f6ee8aca31724f78e93ccc089a"}, - {file = "PyYAML-6.0.2-cp38-cp38-win_amd64.whl", hash = "sha256:01179a4a8559ab5de078078f37e5c1a30d76bb88519906844fd7bdea1b7729ff"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:688ba32a1cffef67fd2e9398a2efebaea461578b0923624778664cc1c914db5d"}, - {file = "PyYAML-6.0.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:a8786accb172bd8afb8be14490a16625cbc387036876ab6ba70912730faf8e1f"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d8e03406cac8513435335dbab54c0d385e4a49e4945d2909a581c83647ca0290"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f753120cb8181e736c57ef7636e83f31b9c0d1722c516f7e86cf15b7aa57ff12"}, - {file = "PyYAML-6.0.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3b1fdb9dc17f5a7677423d508ab4f243a726dea51fa5e70992e59a7411c89d19"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:0b69e4ce7a131fe56b7e4d770c67429700908fc0752af059838b1cfb41960e4e"}, - {file = "PyYAML-6.0.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:a9f8c2e67970f13b16084e04f134610fd1d374bf477b17ec1599185cf611d725"}, - {file = "PyYAML-6.0.2-cp39-cp39-win32.whl", hash = "sha256:6395c297d42274772abc367baaa79683958044e5d3835486c16da75d2a694631"}, - {file = "PyYAML-6.0.2-cp39-cp39-win_amd64.whl", hash = "sha256:39693e1f8320ae4f43943590b49779ffb98acb81f788220ea932a6b6c51004d8"}, - {file = "pyyaml-6.0.2.tar.gz", hash = "sha256:d584d9ec91ad65861cc08d42e834324ef890a082e591037abe114850ff7bbc3e"}, -] - -[[package]] -name = "pyyaml-env-tag" -version = "0.1" -description = "A custom YAML tag for referencing environment variables in YAML files. " -optional = false -python-versions = ">=3.6" -files = [ - {file = "pyyaml_env_tag-0.1-py3-none-any.whl", hash = "sha256:af31106dec8a4d68c60207c1886031cbf839b68aa7abccdb19868200532c2069"}, - {file = "pyyaml_env_tag-0.1.tar.gz", hash = "sha256:70092675bda14fdec33b31ba77e7543de9ddc88f2e5b99160396572d11525bdb"}, -] - -[package.dependencies] -pyyaml = "*" - -[[package]] -name = "regex" -version = "2024.11.6" -description = "Alternative regular expression module, to replace re." -optional = false -python-versions = ">=3.8" -files = [ - {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:ff590880083d60acc0433f9c3f713c51f7ac6ebb9adf889c79a261ecf541aa91"}, - {file = "regex-2024.11.6-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:658f90550f38270639e83ce492f27d2c8d2cd63805c65a13a14d36ca126753f0"}, - {file = "regex-2024.11.6-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164d8b7b3b4bcb2068b97428060b2a53be050085ef94eca7f240e7947f1b080e"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d3660c82f209655a06b587d55e723f0b813d3a7db2e32e5e7dc64ac2a9e86fde"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d22326fcdef5e08c154280b71163ced384b428343ae16a5ab2b3354aed12436e"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f1ac758ef6aebfc8943560194e9fd0fa18bcb34d89fd8bd2af18183afd8da3a2"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:997d6a487ff00807ba810e0f8332c18b4eb8d29463cfb7c820dc4b6e7562d0cf"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:02a02d2bb04fec86ad61f3ea7f49c015a0681bf76abb9857f945d26159d2968c"}, - {file = "regex-2024.11.6-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:f02f93b92358ee3f78660e43b4b0091229260c5d5c408d17d60bf26b6c900e86"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:06eb1be98df10e81ebaded73fcd51989dcf534e3c753466e4b60c4697a003b67"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:040df6fe1a5504eb0f04f048e6d09cd7c7110fef851d7c567a6b6e09942feb7d"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:fdabbfc59f2c6edba2a6622c647b716e34e8e3867e0ab975412c5c2f79b82da2"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:8447d2d39b5abe381419319f942de20b7ecd60ce86f16a23b0698f22e1b70008"}, - {file = "regex-2024.11.6-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:da8f5fc57d1933de22a9e23eec290a0d8a5927a5370d24bda9a6abe50683fe62"}, - {file = "regex-2024.11.6-cp310-cp310-win32.whl", hash = "sha256:b489578720afb782f6ccf2840920f3a32e31ba28a4b162e13900c3e6bd3f930e"}, - {file = "regex-2024.11.6-cp310-cp310-win_amd64.whl", hash = "sha256:5071b2093e793357c9d8b2929dfc13ac5f0a6c650559503bb81189d0a3814519"}, - {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:5478c6962ad548b54a591778e93cd7c456a7a29f8eca9c49e4f9a806dcc5d638"}, - {file = "regex-2024.11.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2c89a8cc122b25ce6945f0423dc1352cb9593c68abd19223eebbd4e56612c5b7"}, - {file = "regex-2024.11.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:94d87b689cdd831934fa3ce16cc15cd65748e6d689f5d2b8f4f4df2065c9fa20"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1062b39a0a2b75a9c694f7a08e7183a80c63c0d62b301418ffd9c35f55aaa114"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:167ed4852351d8a750da48712c3930b031f6efdaa0f22fa1933716bfcd6bf4a3"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d548dafee61f06ebdb584080621f3e0c23fff312f0de1afc776e2a2ba99a74f"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2a19f302cd1ce5dd01a9099aaa19cae6173306d1302a43b627f62e21cf18ac0"}, - {file = "regex-2024.11.6-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bec9931dfb61ddd8ef2ebc05646293812cb6b16b60cf7c9511a832b6f1854b55"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9714398225f299aa85267fd222f7142fcb5c769e73d7733344efc46f2ef5cf89"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:202eb32e89f60fc147a41e55cb086db2a3f8cb82f9a9a88440dcfc5d37faae8d"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:4181b814e56078e9b00427ca358ec44333765f5ca1b45597ec7446d3a1ef6e34"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:068376da5a7e4da51968ce4c122a7cd31afaaec4fccc7856c92f63876e57b51d"}, - {file = "regex-2024.11.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ac10f2c4184420d881a3475fb2c6f4d95d53a8d50209a2500723d831036f7c45"}, - {file = "regex-2024.11.6-cp311-cp311-win32.whl", hash = "sha256:c36f9b6f5f8649bb251a5f3f66564438977b7ef8386a52460ae77e6070d309d9"}, - {file = "regex-2024.11.6-cp311-cp311-win_amd64.whl", hash = "sha256:02e28184be537f0e75c1f9b2f8847dc51e08e6e171c6bde130b2687e0c33cf60"}, - {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:52fb28f528778f184f870b7cf8f225f5eef0a8f6e3778529bdd40c7b3920796a"}, - {file = "regex-2024.11.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fdd6028445d2460f33136c55eeb1f601ab06d74cb3347132e1c24250187500d9"}, - {file = "regex-2024.11.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:805e6b60c54bf766b251e94526ebad60b7de0c70f70a4e6210ee2891acb70bf2"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b85c2530be953a890eaffde05485238f07029600e8f098cdf1848d414a8b45e4"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bb26437975da7dc36b7efad18aa9dd4ea569d2357ae6b783bf1118dabd9ea577"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:abfa5080c374a76a251ba60683242bc17eeb2c9818d0d30117b4486be10c59d3"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70b7fa6606c2881c1db9479b0eaa11ed5dfa11c8d60a474ff0e095099f39d98e"}, - {file = "regex-2024.11.6-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0c32f75920cf99fe6b6c539c399a4a128452eaf1af27f39bce8909c9a3fd8cbe"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:982e6d21414e78e1f51cf595d7f321dcd14de1f2881c5dc6a6e23bbbbd68435e"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:a7c2155f790e2fb448faed6dd241386719802296ec588a8b9051c1f5c481bc29"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:149f5008d286636e48cd0b1dd65018548944e495b0265b45e1bffecce1ef7f39"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:e5364a4502efca094731680e80009632ad6624084aff9a23ce8c8c6820de3e51"}, - {file = "regex-2024.11.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0a86e7eeca091c09e021db8eb72d54751e527fa47b8d5787caf96d9831bd02ad"}, - {file = "regex-2024.11.6-cp312-cp312-win32.whl", hash = "sha256:32f9a4c643baad4efa81d549c2aadefaeba12249b2adc5af541759237eee1c54"}, - {file = "regex-2024.11.6-cp312-cp312-win_amd64.whl", hash = "sha256:a93c194e2df18f7d264092dc8539b8ffb86b45b899ab976aa15d48214138e81b"}, - {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:a6ba92c0bcdf96cbf43a12c717eae4bc98325ca3730f6b130ffa2e3c3c723d84"}, - {file = "regex-2024.11.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:525eab0b789891ac3be914d36893bdf972d483fe66551f79d3e27146191a37d4"}, - {file = "regex-2024.11.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:086a27a0b4ca227941700e0b31425e7a28ef1ae8e5e05a33826e17e47fbfdba0"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bde01f35767c4a7899b7eb6e823b125a64de314a8ee9791367c9a34d56af18d0"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b583904576650166b3d920d2bcce13971f6f9e9a396c673187f49811b2769dc7"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c4de13f06a0d54fa0d5ab1b7138bfa0d883220965a29616e3ea61b35d5f5fc7"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3cde6e9f2580eb1665965ce9bf17ff4952f34f5b126beb509fee8f4e994f143c"}, - {file = "regex-2024.11.6-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0d7f453dca13f40a02b79636a339c5b62b670141e63efd511d3f8f73fba162b3"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:59dfe1ed21aea057a65c6b586afd2a945de04fc7db3de0a6e3ed5397ad491b07"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:b97c1e0bd37c5cd7902e65f410779d39eeda155800b65fc4d04cc432efa9bc6e"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f9d1e379028e0fc2ae3654bac3cbbef81bf3fd571272a42d56c24007979bafb6"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:13291b39131e2d002a7940fb176e120bec5145f3aeb7621be6534e46251912c4"}, - {file = "regex-2024.11.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4f51f88c126370dcec4908576c5a627220da6c09d0bff31cfa89f2523843316d"}, - {file = "regex-2024.11.6-cp313-cp313-win32.whl", hash = "sha256:63b13cfd72e9601125027202cad74995ab26921d8cd935c25f09c630436348ff"}, - {file = "regex-2024.11.6-cp313-cp313-win_amd64.whl", hash = "sha256:2b3361af3198667e99927da8b84c1b010752fa4b1115ee30beaa332cabc3ef1a"}, - {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:3a51ccc315653ba012774efca4f23d1d2a8a8f278a6072e29c7147eee7da446b"}, - {file = "regex-2024.11.6-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:ad182d02e40de7459b73155deb8996bbd8e96852267879396fb274e8700190e3"}, - {file = "regex-2024.11.6-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:ba9b72e5643641b7d41fa1f6d5abda2c9a263ae835b917348fc3c928182ad467"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:40291b1b89ca6ad8d3f2b82782cc33807f1406cf68c8d440861da6304d8ffbbd"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cdf58d0e516ee426a48f7b2c03a332a4114420716d55769ff7108c37a09951bf"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a36fdf2af13c2b14738f6e973aba563623cb77d753bbbd8d414d18bfaa3105dd"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1cee317bfc014c2419a76bcc87f071405e3966da434e03e13beb45f8aced1a6"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:50153825ee016b91549962f970d6a4442fa106832e14c918acd1c8e479916c4f"}, - {file = "regex-2024.11.6-cp38-cp38-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:ea1bfda2f7162605f6e8178223576856b3d791109f15ea99a9f95c16a7636fb5"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_aarch64.whl", hash = "sha256:df951c5f4a1b1910f1a99ff42c473ff60f8225baa1cdd3539fe2819d9543e9df"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_i686.whl", hash = "sha256:072623554418a9911446278f16ecb398fb3b540147a7828c06e2011fa531e773"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_ppc64le.whl", hash = "sha256:f654882311409afb1d780b940234208a252322c24a93b442ca714d119e68086c"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_s390x.whl", hash = "sha256:89d75e7293d2b3e674db7d4d9b1bee7f8f3d1609428e293771d1a962617150cc"}, - {file = "regex-2024.11.6-cp38-cp38-musllinux_1_2_x86_64.whl", hash = "sha256:f65557897fc977a44ab205ea871b690adaef6b9da6afda4790a2484b04293a5f"}, - {file = "regex-2024.11.6-cp38-cp38-win32.whl", hash = "sha256:6f44ec28b1f858c98d3036ad5d7d0bfc568bdd7a74f9c24e25f41ef1ebfd81a4"}, - {file = "regex-2024.11.6-cp38-cp38-win_amd64.whl", hash = "sha256:bb8f74f2f10dbf13a0be8de623ba4f9491faf58c24064f32b65679b021ed0001"}, - {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:5704e174f8ccab2026bd2f1ab6c510345ae8eac818b613d7d73e785f1310f839"}, - {file = "regex-2024.11.6-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:220902c3c5cc6af55d4fe19ead504de80eb91f786dc102fbd74894b1551f095e"}, - {file = "regex-2024.11.6-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:5e7e351589da0850c125f1600a4c4ba3c722efefe16b297de54300f08d734fbf"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5056b185ca113c88e18223183aa1a50e66507769c9640a6ff75859619d73957b"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e34b51b650b23ed3354b5a07aab37034d9f923db2a40519139af34f485f77d0"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5670bce7b200273eee1840ef307bfa07cda90b38ae56e9a6ebcc9f50da9c469b"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:08986dce1339bc932923e7d1232ce9881499a0e02925f7402fb7c982515419ef"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:93c0b12d3d3bc25af4ebbf38f9ee780a487e8bf6954c115b9f015822d3bb8e48"}, - {file = "regex-2024.11.6-cp39-cp39-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_12_x86_64.manylinux2010_x86_64.whl", hash = "sha256:764e71f22ab3b305e7f4c21f1a97e1526a25ebdd22513e251cf376760213da13"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_aarch64.whl", hash = "sha256:f056bf21105c2515c32372bbc057f43eb02aae2fda61052e2f7622c801f0b4e2"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_i686.whl", hash = "sha256:69ab78f848845569401469da20df3e081e6b5a11cb086de3eed1d48f5ed57c95"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_ppc64le.whl", hash = "sha256:86fddba590aad9208e2fa8b43b4c098bb0ec74f15718bb6a704e3c63e2cef3e9"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_s390x.whl", hash = "sha256:684d7a212682996d21ca12ef3c17353c021fe9de6049e19ac8481ec35574a70f"}, - {file = "regex-2024.11.6-cp39-cp39-musllinux_1_2_x86_64.whl", hash = "sha256:a03e02f48cd1abbd9f3b7e3586d97c8f7a9721c436f51a5245b3b9483044480b"}, - {file = "regex-2024.11.6-cp39-cp39-win32.whl", hash = "sha256:41758407fc32d5c3c5de163888068cfee69cb4c2be844e7ac517a52770f9af57"}, - {file = "regex-2024.11.6-cp39-cp39-win_amd64.whl", hash = "sha256:b2837718570f95dd41675328e111345f9b7095d821bac435aac173ac80b19983"}, - {file = "regex-2024.11.6.tar.gz", hash = "sha256:7ab159b063c52a0333c884e4679f8d7a85112ee3078fe3d9004b2dd875585519"}, -] - -[[package]] -name = "requests" -version = "2.32.3" -description = "Python HTTP for Humans." -optional = false -python-versions = ">=3.8" -files = [ - {file = "requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6"}, - {file = "requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760"}, -] - -[package.dependencies] -certifi = ">=2017.4.17" -charset-normalizer = ">=2,<4" -idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" - -[package.extras] -socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] - -[[package]] -name = "ruff" -version = "0.3.7" -description = "An extremely fast Python linter and code formatter, written in Rust." -optional = false -python-versions = ">=3.7" -files = [ - {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:0e8377cccb2f07abd25e84fc5b2cbe48eeb0fea9f1719cad7caedb061d70e5ce"}, - {file = "ruff-0.3.7-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:15a4d1cc1e64e556fa0d67bfd388fed416b7f3b26d5d1c3e7d192c897e39ba4b"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d28bdf3d7dc71dd46929fafeec98ba89b7c3550c3f0978e36389b5631b793663"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:379b67d4f49774ba679593b232dcd90d9e10f04d96e3c8ce4a28037ae473f7bb"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c060aea8ad5ef21cdfbbe05475ab5104ce7827b639a78dd55383a6e9895b7c51"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:ebf8f615dde968272d70502c083ebf963b6781aacd3079081e03b32adfe4d58a"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d48098bd8f5c38897b03604f5428901b65e3c97d40b3952e38637b5404b739a2"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:da8a4fda219bf9024692b1bc68c9cff4b80507879ada8769dc7e985755d662ea"}, - {file = "ruff-0.3.7-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c44e0149f1d8b48c4d5c33d88c677a4aa22fd09b1683d6a7ff55b816b5d074f"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3050ec0af72b709a62ecc2aca941b9cd479a7bf2b36cc4562f0033d688e44fa1"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a29cc38e4c1ab00da18a3f6777f8b50099d73326981bb7d182e54a9a21bb4ff7"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5b15cc59c19edca917f51b1956637db47e200b0fc5e6e1878233d3a938384b0b"}, - {file = "ruff-0.3.7-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:e491045781b1e38b72c91247cf4634f040f8d0cb3e6d3d64d38dcf43616650b4"}, - {file = "ruff-0.3.7-py3-none-win32.whl", hash = "sha256:bc931de87593d64fad3a22e201e55ad76271f1d5bfc44e1a1887edd0903c7d9f"}, - {file = "ruff-0.3.7-py3-none-win_amd64.whl", hash = "sha256:5ef0e501e1e39f35e03c2acb1d1238c595b8bb36cf7a170e7c1df1b73da00e74"}, - {file = "ruff-0.3.7-py3-none-win_arm64.whl", hash = "sha256:789e144f6dc7019d1f92a812891c645274ed08af6037d11fc65fcbc183b7d59f"}, - {file = "ruff-0.3.7.tar.gz", hash = "sha256:d5c1aebee5162c2226784800ae031f660c350e7a3402c4d1f8ea4e97e232e3ba"}, -] - -[[package]] -name = "sentinels" -version = "1.0.0" -description = "Various objects to denote special meanings in python" -optional = false -python-versions = "*" -files = [ - {file = "sentinels-1.0.0.tar.gz", hash = "sha256:7be0704d7fe1925e397e92d18669ace2f619c92b5d4eb21a89f31e026f9ff4b1"}, -] - -[[package]] -name = "six" -version = "1.17.0" -description = "Python 2 and 3 compatibility utilities" -optional = false -python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,>=2.7" -files = [ - {file = "six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274"}, - {file = "six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"}, -] - -[[package]] -name = "tomli" -version = "2.2.1" -description = "A lil' TOML parser" -optional = false -python-versions = ">=3.8" -files = [ - {file = "tomli-2.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678e4fa69e4575eb77d103de3df8a895e1591b48e740211bd1067378c69e8249"}, - {file = "tomli-2.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:023aa114dd824ade0100497eb2318602af309e5a55595f76b626d6d9f3b7b0a6"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ece47d672db52ac607a3d9599a9d48dcb2f2f735c6c2d1f34130085bb12b112a"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6972ca9c9cc9f0acaa56a8ca1ff51e7af152a9f87fb64623e31d5c83700080ee"}, - {file = "tomli-2.2.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c954d2250168d28797dd4e3ac5cf812a406cd5a92674ee4c8f123c889786aa8e"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8dd28b3e155b80f4d54beb40a441d366adcfe740969820caf156c019fb5c7ec4"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:e59e304978767a54663af13c07b3d1af22ddee3bb2fb0618ca1593e4f593a106"}, - {file = "tomli-2.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:33580bccab0338d00994d7f16f4c4ec25b776af3ffaac1ed74e0b3fc95e885a8"}, - {file = "tomli-2.2.1-cp311-cp311-win32.whl", hash = "sha256:465af0e0875402f1d226519c9904f37254b3045fc5084697cefb9bdde1ff99ff"}, - {file = "tomli-2.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2d0f2fdd22b02c6d81637a3c95f8cd77f995846af7414c5c4b8d0545afa1bc4b"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:4a8f6e44de52d5e6c657c9fe83b562f5f4256d8ebbfe4ff922c495620a7f6cea"}, - {file = "tomli-2.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8d57ca8095a641b8237d5b079147646153d22552f1c637fd3ba7f4b0b29167a8"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4e340144ad7ae1533cb897d406382b4b6fede8890a03738ff1683af800d54192"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:db2b95f9de79181805df90bedc5a5ab4c165e6ec3fe99f970d0e302f384ad222"}, - {file = "tomli-2.2.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40741994320b232529c802f8bc86da4e1aa9f413db394617b9a256ae0f9a7f77"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:400e720fe168c0f8521520190686ef8ef033fb19fc493da09779e592861b78c6"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:02abe224de6ae62c19f090f68da4e27b10af2b93213d36cf44e6e1c5abd19fdd"}, - {file = "tomli-2.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b82ebccc8c8a36f2094e969560a1b836758481f3dc360ce9a3277c65f374285e"}, - {file = "tomli-2.2.1-cp312-cp312-win32.whl", hash = "sha256:889f80ef92701b9dbb224e49ec87c645ce5df3fa2cc548664eb8a25e03127a98"}, - {file = "tomli-2.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:7fc04e92e1d624a4a63c76474610238576942d6b8950a2d7f908a340494e67e4"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f4039b9cbc3048b2416cc57ab3bda989a6fcf9b36cf8937f01a6e731b64f80d7"}, - {file = "tomli-2.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:286f0ca2ffeeb5b9bd4fcc8d6c330534323ec51b2f52da063b11c502da16f30c"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a92ef1a44547e894e2a17d24e7557a5e85a9e1d0048b0b5e7541f76c5032cb13"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9316dc65bed1684c9a98ee68759ceaed29d229e985297003e494aa825ebb0281"}, - {file = "tomli-2.2.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:e85e99945e688e32d5a35c1ff38ed0b3f41f43fad8df0bdf79f72b2ba7bc5272"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ac065718db92ca818f8d6141b5f66369833d4a80a9d74435a268c52bdfa73140"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:d920f33822747519673ee656a4b6ac33e382eca9d331c87770faa3eef562aeb2"}, - {file = "tomli-2.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a198f10c4d1b1375d7687bc25294306e551bf1abfa4eace6650070a5c1ae2744"}, - {file = "tomli-2.2.1-cp313-cp313-win32.whl", hash = "sha256:d3f5614314d758649ab2ab3a62d4f2004c825922f9e370b29416484086b264ec"}, - {file = "tomli-2.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:a38aa0308e754b0e3c67e344754dff64999ff9b513e691d0e786265c93583c69"}, - {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, - {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, -] - -[[package]] -name = "typing-extensions" -version = "4.12.2" -description = "Backported and Experimental Type Hints for Python 3.8+" -optional = false -python-versions = ">=3.8" -files = [ - {file = "typing_extensions-4.12.2-py3-none-any.whl", hash = "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d"}, - {file = "typing_extensions-4.12.2.tar.gz", hash = "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"}, -] - -[[package]] -name = "urllib3" -version = "2.2.3" -description = "HTTP library with thread-safe connection pooling, file post, and more." -optional = false -python-versions = ">=3.8" -files = [ - {file = "urllib3-2.2.3-py3-none-any.whl", hash = "sha256:ca899ca043dcb1bafa3e262d73aa25c465bfb49e0bd9dd5d59f1d0acba2f8fac"}, - {file = "urllib3-2.2.3.tar.gz", hash = "sha256:e7d814a81dad81e6caf2ec9fdedb284ecc9c73076b62654547cc64ccdcae26e9"}, -] - -[package.extras] -brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"] -h2 = ["h2 (>=4,<5)"] -socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"] -zstd = ["zstandard (>=0.18.0)"] - -[[package]] -name = "watchdog" -version = "6.0.0" -description = "Filesystem events monitoring" -optional = false -python-versions = ">=3.9" -files = [ - {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26"}, - {file = "watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112"}, - {file = "watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"}, - {file = "watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860"}, - {file = "watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134"}, - {file = "watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:e6f0e77c9417e7cd62af82529b10563db3423625c5fce018430b249bf977f9e8"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:90c8e78f3b94014f7aaae121e6b909674df5b46ec24d6bebc45c44c56729af2a"}, - {file = "watchdog-6.0.0-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c"}, - {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881"}, - {file = "watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11"}, - {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_10_15_x86_64.whl", hash = "sha256:7a0e56874cfbc4b9b05c60c8a1926fedf56324bb08cfbc188969777940aef3aa"}, - {file = "watchdog-6.0.0-pp39-pypy39_pp73-macosx_11_0_arm64.whl", hash = "sha256:e6439e374fc012255b4ec786ae3c4bc838cd7309a540e5fe0952d03687d8804e"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c"}, - {file = "watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2"}, - {file = "watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a"}, - {file = "watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680"}, - {file = "watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f"}, - {file = "watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282"}, -] - -[package.extras] -watchmedo = ["PyYAML (>=3.10)"] - -[[package]] -name = "wirerope" -version = "0.4.8" -description = "'Turn functions and methods into fully controllable objects'" -optional = false -python-versions = "*" -files = [ - {file = "wirerope-0.4.8-py2.py3-none-any.whl", hash = "sha256:99e54b9bd9d3a8e18199b87e2c5edf58d7f64e3f7e2f5bb041034025f03a4e68"}, - {file = "wirerope-0.4.8.tar.gz", hash = "sha256:fa172d09eff7534563f315e38712b5fa3b88a1aa3467b539a7510f2e427a7af4"}, -] - -[package.dependencies] -six = ">=1.11.0" - -[package.extras] -doc = ["sphinx"] -test = ["pytest (>=4.6.7)", "pytest-checkdocs (>=1.2.5)", "pytest-checkdocs (>=2.9.0)", "pytest-cov (>=2.6.1)"] - -[[package]] -name = "zipp" -version = "3.21.0" -description = "Backport of pathlib-compatible object wrapper for zip files" -optional = false -python-versions = ">=3.9" -files = [ - {file = "zipp-3.21.0-py3-none-any.whl", hash = "sha256:ac1bbe05fd2991f160ebce24ffbac5f6d11d83dc90891255885223d42b3cd931"}, - {file = "zipp-3.21.0.tar.gz", hash = "sha256:2c9958f6430a2040341a52eb608ed6dd93ef4392e02ffe219417c1b28b5dd1f4"}, -] - -[package.extras] -check = ["pytest-checkdocs (>=2.4)", "pytest-ruff (>=0.2.1)"] -cover = ["pytest-cov"] -doc = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-lint"] -enabler = ["pytest-enabler (>=2.2)"] -test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", "jaraco.test", "more-itertools", "pytest (>=6,!=8.1.*)", "pytest-ignore-flaky"] -type = ["pytest-mypy"] - -[metadata] -lock-version = "2.0" -python-versions = "^3.9" -content-hash = "d82e9eb9d05a2019ed494bbe1cc7a22a7910964df6a56a344871fd54868e5d1d" diff --git a/poetry.toml b/poetry.toml deleted file mode 100644 index ab1033b..0000000 --- a/poetry.toml +++ /dev/null @@ -1,2 +0,0 @@ -[virtualenvs] -in-project = true diff --git a/pyproject.toml b/pyproject.toml index 9a15965..56353c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,63 +1,66 @@ -[tool.poetry] -name = "mongospecs" -version = "0.1.4" -description = "simple mongo ODM with support for msgspec, pydantic, and attrs" -authors = ["Jay "] -repository = "https://github.com/jaykv/mongospecs" -readme = "README.md" -packages = [{include = "mongospecs"}] - -[tool.poetry.dependencies] -python = "^3.9" -msgspec = "^0.18.4" -pymongo = "^4.5.0" -methodtools = "^0.4.7" -blinker = "^1.6.3" -typing-extensions = "^4.8.0" - -[tool.poetry.group.pydantic.dependencies] -pydantic = "^2.5.2" - -[tool.poetry.group.attrs.dependencies] -attrs = "^23.1.0" - -[tool.poetry.group.dev.dependencies] -pytest = "^7.2.1" -ruff = "^0.3.0" -mypy = "^1.3.0" -mongomock = "^4.1.2" - -[tool.poetry.group.docs.dependencies] -mkdocs-material = "^9.4.12" -mkdocstrings = "^0.24.0" -mkdocstrings-python = "^1.7.5" - -[build-system] -requires = ["poetry-core"] -build-backend = "poetry.core.masonry.api" +[project] +name = "mongospecs-workspace" +version = "0.0.0" +description = "Workspace root for mongospecs and mongospecs-web. Not published." +requires-python = ">=3.11" +dependencies = [] + +[tool.uv] +package = false + +[tool.uv.workspace] +members = ["packages/*", "bench"] + +[tool.uv.sources] +mongospecs = { workspace = true } +mongospecs-web = { workspace = true } +mongospecs-bench = { workspace = true } + +[dependency-groups] +dev = [ + "mongospecs[all]", + "mongospecs-web[all]", + "mongospecs-bench", + "pytest>=8.3", + "pytest-asyncio>=0.25", + "ruff>=0.8", + "mypy>=1.13", + "httpx>=0.27", + "opentelemetry-sdk>=1.20", + "sentry-sdk>=2.0", +] +docs = [ + "mkdocs-material>=9.5", + "mkdocstrings[python]>=0.26", +] + +[tool.pytest.ini_options] +addopts = "--import-mode=importlib -ra" +testpaths = ["packages/mongospecs/tests", "packages/mongospecs-web/tests", "bench/tests"] +asyncio_mode = "auto" +asyncio_default_fixture_loop_scope = "session" +asyncio_default_test_loop_scope = "session" +markers = [ + "sharded: needs the sharded cluster from docker/compose.sharded.yaml", +] [tool.ruff] -line-length = 120 +line-length = 110 +target-version = "py311" +src = ["packages/mongospecs/src", "packages/mongospecs-web/src", "bench/src"] [tool.ruff.lint] -extend-select = ["I"] -ignore-init-module-imports = true -ignore = ["F811"] +select = ["E", "F", "I", "B", "UP", "ASYNC", "RUF"] +ignore = ["E501", "RUF012"] + +[tool.ruff.lint.per-file-ignores] +"**/tests/**" = ["B", "RUF"] [tool.mypy] -#plugins = ["pydantic.mypy"] -follow_imports = "silent" -ignore_missing_imports = true +python_version = "3.11" +strict = true +warn_unreachable = true show_error_codes = true -enable_error_code = [ - "truthy-bool", - "truthy-iterable", - "redundant-expr", - "unused-awaitable", - "possibly-undefined", - "redundant-self" -] - -[[tool.mypy.overrides]] -module = "yaml" -ignore_missing_imports = true +mypy_path = ["packages/mongospecs/src", "packages/mongospecs-web/src", "bench/src"] +explicit_package_bases = true +plugins = ["pydantic.mypy"] diff --git a/tests/__init__.py b/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/attrs/__init__.py b/tests/attrs/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/attrs/adapter_models.py b/tests/attrs/adapter_models.py deleted file mode 100644 index ab767dd..0000000 --- a/tests/attrs/adapter_models.py +++ /dev/null @@ -1,97 +0,0 @@ -from datetime import datetime, timezone -from typing import Any, Optional - -from attrs import define, field - -from mongospecs.attrs import SubSpec - -__all__ = [ - "Dragon", - "Inventory", - "Lair", - "ComplexDragon", - "MonitoredDragon", -] - - -@define(slots=False) -class Dragon: - """ - A dragon. - """ - - name: str - breed: Optional[str] = None - - def _get_dummy_prop(self): - return self._dummy_prop - - def _set_dummy_prop(self, value): - self._dummy_prop = True - - dummy_prop = property(_get_dummy_prop, _set_dummy_prop) - - -@define(slots=False) -class Inventory(SubSpec): - """ - An inventory of items kept within a lair. - """ - - gold: int = 0 - skulls: int = 0 - - -@define(slots=False) -class Lair: - """ - A lair in which a dragon resides. - """ - - name: str = "" - inventory: Inventory = field(factory=Inventory) - - -@define(slots=False) -class ComplexDragon(Dragon): - _collection = "ComplexDragon" - - dob: Optional[datetime] = None - lair: Lair = field(factory=Lair) - visited_lairs: list[Lair] = [] - traits: list[str] = [] - misc: Optional[Any] = "" - - -@define(slots=False) -class MonitoredDragon(Dragon): - created: Optional[datetime] = None - modified: Optional[datetime] = None - - @staticmethod - def timestamp_insert(sender, specs): - """ - Timestamp the created and modified fields for all documents. This method - should be bound to a spec class like so: - - ``` - MySpecClass.listen('insert', MySpecClass.timestamp_insert) - ``` - """ - for spec in specs: - timestamp = datetime.now(timezone.utc) - spec.created = timestamp - spec.modified = timestamp - - @staticmethod - def timestamp_update(sender, specs): - """ - Timestamp the modified field for all documents. This method should be - bound to a spec class like so: - - ``` - MySpecClass.listen('update', MySpecClass.timestamp_update) - ``` - """ - for spec in specs: - spec.modified = datetime.now(timezone.utc) diff --git a/tests/attrs/fixtures.py b/tests/attrs/fixtures.py deleted file mode 100644 index fedfde9..0000000 --- a/tests/attrs/fixtures.py +++ /dev/null @@ -1,69 +0,0 @@ -from datetime import datetime - -import pytest -from mongomock import MongoClient - -from mongospecs.attrs import Spec - -from .models import ComplexDragon, Inventory, Lair - -# Fixtures - - -@pytest.fixture -def mongo_client(): - """Connect to the test database""" - - # Connect to mongodb and create a test database - Spec._client = MongoClient("mongodb://localhost:27017/attrs_test") - - yield Spec._client - - Spec._client.drop_database("attrs_test") - del Spec._client - - -@pytest.fixture -def example_dataset_one(mongo_client): - """Create an example set of data that can be used in testing""" - inventory = Inventory(gold=1000, skulls=100) - - cave = Lair(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragon( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - -@pytest.fixture -def example_dataset_many(mongo_client): - """Create an example set of data that can be used in testing""" - - # Burt - cave = Lair(name="Cave", inventory=Inventory(gold=1000, skulls=100)) - cave.insert() - - burt = ComplexDragon( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Fred - castle = Lair(name="Castle", inventory=Inventory(gold=2000, skulls=200)) - castle.insert() - - fred = ComplexDragon( - name="Fred", dob=datetime(1980, 7, 12), breed="Fire-drake", lair=castle, traits=["impulsive", "loyal"] - ) - fred.insert() - - # Fred - mountain = Lair(name="Mountain", inventory=Inventory(gold=3000, skulls=300)) - mountain.insert() - - albert = ComplexDragon( - name="Albert", dob=datetime(1981, 8, 13), breed="Stone dragon", lair=mountain, traits=["reclusive", "cunning"] - ) - albert.insert() diff --git a/tests/attrs/models.py b/tests/attrs/models.py deleted file mode 100644 index 6f445ad..0000000 --- a/tests/attrs/models.py +++ /dev/null @@ -1,99 +0,0 @@ -from datetime import datetime, timezone -from typing import Any, Optional - -from attrs import define, field - -from mongospecs.attrs import Spec, SubSpec - -__all__ = [ - "Dragon", - "Inventory", - "Lair", - "ComplexDragon", - "MonitoredDragon", -] - - -@define -class Dragon(Spec): - """ - A dragon. - """ - - name: str - breed: Optional[str] = None - - def _get_dummy_prop(self): - return self._dummy_prop - - def _set_dummy_prop(self, value): - self._dummy_prop = True - - dummy_prop = property(_get_dummy_prop, _set_dummy_prop) - - -@define -class Inventory(SubSpec): - """ - An inventory of items kept within a lair. - """ - - gold: int = 0 - skulls: int = 0 - - -@define -class Lair(Spec): - """ - A lair in which a dragon resides. - """ - - name: str = "" - inventory: Inventory = field(factory=Inventory) - - -@define -class ComplexDragon(Dragon): - _collection = "ComplexDragon" - - dob: Optional[datetime] = None - lair: Lair = field(factory=Lair) - visited_lairs: list[Lair] = [] - traits: list[str] = [] - misc: Optional[Any] = "" - - _default_projection = {"lair": {"$ref": Lair, "inventory": {"$sub": Inventory}}} - - -@define -class MonitoredDragon(Dragon): - created: Optional[datetime] = None - modified: Optional[datetime] = None - - @staticmethod - def timestamp_insert(sender, specs): - """ - Timestamp the created and modified fields for all documents. This method - should be bound to a spec class like so: - - ``` - MySpecClass.listen('insert', MySpecClass.timestamp_insert) - ``` - """ - for spec in specs: - timestamp = datetime.now(timezone.utc) - spec.created = timestamp - spec.modified = timestamp - - @staticmethod - def timestamp_update(sender, specs): - """ - Timestamp the modified field for all documents. This method should be - bound to a spec class like so: - - ``` - MySpecClass.listen('update', MySpecClass.timestamp_update) - ``` - """ - for spec in specs: - spec.modified = datetime.now(timezone.utc) diff --git a/tests/attrs/test_adapter.py b/tests/attrs/test_adapter.py deleted file mode 100644 index 20ccfdc..0000000 --- a/tests/attrs/test_adapter.py +++ /dev/null @@ -1,856 +0,0 @@ -from datetime import datetime, timedelta, timezone -from time import sleep -from unittest.mock import Mock - -import attrs -import pytest -from bson.objectid import ObjectId -from pymongo import ReadPreference - -from mongospecs import ASC, DESC, In, Q -from mongospecs.attrs import SpecAdapter -from mongospecs.helpers.empty import Empty - -from .adapter_models import ComplexDragon, Dragon, Inventory, Lair, MonitoredDragon -from .fixtures import mongo_client # noqa - -DragonSpec = SpecAdapter(Dragon, collection="dragon") -LairSpec = SpecAdapter(Lair, collection="lair") -ComplexDragonSpec = SpecAdapter(ComplexDragon, collection="complexdragon") -ComplexDragonSpec._default_projection = {"lair": {"$ref": LairSpec, "inventory": {"$sub": Inventory}}} -MonitoredDragonSpec = SpecAdapter(MonitoredDragon, collection="monitoreddragon") - - -@pytest.fixture -def example_dataset_one(mongo_client): - """Create an example set of data that can be used in testing""" - inventory = Inventory(gold=1000, skulls=100) - - cave = LairSpec(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragonSpec( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - -@pytest.fixture -def example_dataset_many(mongo_client): - """Create an example set of data that can be used in testing""" - - # Burt - cave = LairSpec(name="Cave", inventory=Inventory(gold=1000, skulls=100)) - cave.insert() - - burt = ComplexDragonSpec( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Fred - castle = LairSpec(name="Castle", inventory=Inventory(gold=2000, skulls=200)) - castle.insert() - - fred = ComplexDragonSpec( - name="Fred", dob=datetime(1980, 7, 12), breed="Fire-drake", lair=castle, traits=["impulsive", "loyal"] - ) - fred.insert() - - # Fred - mountain = LairSpec(name="Mountain", inventory=Inventory(gold=3000, skulls=300)) - mountain.insert() - - albert = ComplexDragonSpec( - name="Albert", dob=datetime(1981, 8, 13), breed="Stone dragon", lair=mountain, traits=["reclusive", "cunning"] - ) - albert.insert() - - -def test_attrs_adapter(mongo_client) -> None: - @attrs.define(kw_only=True, slots=False) - class Car: - make: str - model: str - year: int - - CarSpec = SpecAdapter(Car, collection="cars", client=mongo_client) - assert CarSpec.get_fields() == {"_id", "make", "model", "year"} - car = CarSpec(make="test", model="test123", year=2000) # type: ignore[call-arg] - assert car.to_dict() == {"_id": None, "make": "test", "model": "test123", "year": 2000} - assert car.get_fields() == {"_id", "make", "model", "year"} - - car.insert() - assert car.find({"make": "test"}) == [{"_id": car._id, "make": "test", "model": "test123", "year": 2000}] - assert car.to_dict() == {"_id": car._id, "make": "test", "model": "test123", "year": 2000} - - -# Tests - - -def test_spec(): - """Should create a new Dragon instance""" - # Passing initial values - burt = DragonSpec(name="Burt", breed="Cold-drake") - assert burt.name == "Burt" - assert burt.breed == "Cold-drake" - assert isinstance(burt, Dragon) - - -def test_dot_notation(): - """ - Should allow access to read and set document values using do notation. - """ - # Simple set/get - burt = DragonSpec(name="Burt", breed="Cold-drake") - - assert burt.name == "Burt" - burt.name = "Fred" - assert burt.name == "Fred" - - # SubSpec (embedded document get/set) - inventory = Inventory(gold=1000, skulls=100) - - cave = LairSpec(name="Cave", inventory=inventory) - - assert cave.inventory.gold == 1000 - cave.inventory.gold += 100 - assert cave.inventory.gold == 1100 - - -def test_equal(mongo_client): - """Should compare the equality of two Spec instances by Id""" - # Create some dragons - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - - fred = DragonSpec(name="Fred", breed="Fire-drake") - fred.insert() - - # Test equality - assert burt != fred - assert burt == burt - - -def test_python_sort(mongo_client): - """Should sort a list of Spec instances by their Ids""" - # Create some dragons - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - - fred = DragonSpec(name="Fred", breed="Fire-drake") - fred.insert() - - albert = DragonSpec(name="Albert", breed="Stone dragon") - albert.insert() - - # Test sorting by Id - assert sorted([albert, burt, fred]) == [burt, fred, albert] - - -def test_to_json_type(mongo_client, example_dataset_one): - """ - Should return a dictionary for the document with all values converted to - JSON safe types. All private fields should be excluded. - """ - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - cave = burt.lair - - burt_json = burt.to_json_type() - - assert burt_json == { - "_id": str(burt._id), - "name": "Burt", - "breed": "Cold-drake", - "dob": "1979-06-11T00:00:00", - "traits": ["irritable", "narcissistic"], - "lair": {"_id": str(cave._id), "name": "Cave", "inventory": {"gold": 1000, "skulls": 100}}, - "visited_lairs": [], - "misc": "", - } - - -def test_insert(mongo_client): - """Should insert a document into the database""" - # Create some convoluted data to insert - inventory = Inventory(gold=1000, skulls=100) - - cave = LairSpec(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragonSpec( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Test the document now has an Id - assert burt._id is not None - - # Get the document from the database and check it's values - burt.reload() - - assert burt.name == "Burt" - assert burt.dob == datetime(1979, 6, 11) - assert burt.breed == "Cold-drake" - assert burt.traits == ["irritable", "narcissistic"] - assert burt.lair.name == "Cave" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Test insert where we specify the `_id` value - _id = ObjectId() - albert = DragonSpec(_id=_id, name="Albert", breed="Stone dragon") - albert.insert() - - assert albert._id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_unset(mongo_client, example_dataset_one): - """Should unset fields against a document on the database""" - - # Unset name and breed - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.unset("breed") - - assert burt.name == "Burt" - assert burt.breed == Empty - assert "breed" not in burt.to_json_type() - - burt.reload() - - assert burt.name == "Burt" - assert burt.breed == Empty - assert "breed" not in burt.to_json_type() - - -def test_update(mongo_client, example_dataset_one): - """Should update a document on the database""" - - # Update all values - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.name = "Jess" - burt.breed = "Fire-drake" - burt.traits = ["gentle", "kind"] - burt.update() - - burt.reload() - - assert burt.name == "Jess" - assert burt.breed == "Fire-drake" - assert burt.traits == ["gentle", "kind"] - - # Selective update - burt.lair.name = "Castle" - burt.lair.inventory.gold += 100 - burt.lair.inventory.skulls = 0 - burt.lair.update("name", "inventory.skulls") - - burt.reload() - - assert burt.lair.name == "Castle" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 0 - - -def test_upsert(mongo_client): - """ - Should update or insert a document on the database depending on whether or - not it already exists. - """ - - # Insert - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.upsert() - id = burt._id - burt.reload() - - # Update - burt.upsert() - burt.reload() - - assert burt._id == id - - # Test upsert where we specify the `_id` value - _id = ObjectId() - albert = DragonSpec(_id=_id, name="Albert", breed="Stone dragon") - albert.upsert() - - assert albert._id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_delete(mongo_client, example_dataset_one): - """Should delete a document from the database""" - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.delete() - burt = burt.by_id(burt._id) - - assert burt is None - - -def test_insert_many(mongo_client): - """Should insert multiple documents records into the database""" - - # Create some convoluted data to insert - burt = DragonSpec(name="Burt", breed="Cold-drake") - - fred = DragonSpec(name="Fred", breed="Fire-drake") - - albert = DragonSpec(name="Albert", breed="Stone dragon") - - burt.insert_many([burt, fred, albert]) - - # Check 3 dragons have been created - assert DragonSpec.count() == 3 - - # Check the details for each dragon - dragons = DragonSpec.many(sort=[("_id", ASC)]) - assert dragons[0].name == "Burt" - assert dragons[0].breed == "Cold-drake" - assert dragons[1].name == "Fred" - assert dragons[1].breed == "Fire-drake" - assert dragons[2].name == "Albert" - assert dragons[2].breed == "Stone dragon" - - -def test_unset_many(mongo_client, example_dataset_many): - """Should unset fields against multiple documents on the database""" - - # Unset name and breed - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - for dragon in dragons: - dragon.unset("name", "breed") - - for dragon in dragons: - assert dragon.name == Empty - assert dragon.breed == Empty - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - dragon.reload() - - assert dragon.name == Empty - assert dragon.breed == Empty - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - -def test_update_many(mongo_client, example_dataset_many): - """Should update mulitple documents on the database""" - - # Select all the dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - # Give each dragon a second name - for dragon in dragons: - dragon.name += " " + dragon.name + "son" - - # Update all values for all the dragons in one go - ComplexDragonSpec.update_many(dragons) - - # Reload all the dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Make various changes to the dragons only some of which we want to stick - for dragon in dragons: - dragon.name = dragon.name.split(" ")[0] - assert dragon.breed - dragon.breed = dragon.breed.replace("-", "_") - dragon.breed = dragon.breed.replace(" ", "_") - dragon.lair.inventory.gold += 100 - dragon.lair.inventory.skulls += 10 - - # Update selected values for all the dragons in one go - LairSpec.update_many([d.lair for d in dragons], "inventory.gold") - ComplexDragonSpec.update_many(dragons, "breed") - - # Reload all the dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - # Names should be the same - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Breeds should have changed - assert dragons[0].breed == "Cold_drake" - assert dragons[1].breed == "Fire_drake" - assert dragons[2].breed == "Stone_dragon" - - # Gold should have changed - assert dragons[0].lair.inventory.gold == 1100 - assert dragons[1].lair.inventory.gold == 2100 - assert dragons[2].lair.inventory.gold == 3100 - - # Skulls should be the same - assert dragons[0].lair.inventory.skulls == 100 - assert dragons[1].lair.inventory.skulls == 200 - assert dragons[2].lair.inventory.skulls == 300 - - -def test_delete_many(mongo_client, example_dataset_many): - """Should delete mulitple documents from the database""" - - # Select all the dragons - dragons = ComplexDragonSpec.many() - - # Delete all of them :( - ComplexDragonSpec.delete_many(dragons) - - # Check there are no remaining dragons - assert ComplexDragonSpec.count() == 0 - - -def test_reload(mongo_client, example_dataset_one): - """Should reload the current document's values from the database""" - - # Select Burt from the database - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - # Change some values and reload - burt.name = "Fred" - burt.lair.inventory = Inventory(gold=500, skulls=50) - burt.reload() - - # Check Burt is himself again - assert burt.name == "Burt" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Reload with a different projection - burt.reload(projection={"name": True}) - - # Check Burt has values for the projection specified - assert burt.name == "Burt" - - ## Should reload reset attributes based on projection? - # assert burt.breed == None - # assert burt.lair == None - - -def test_by_id(mongo_client, example_dataset_many): - """Should return a document by Id from the database""" - - # Get an Id for a dragon - fred = ComplexDragonSpec.one(Q.name == "Fred") - assert fred - - # Load a dragon using the Id and make sure it's the same - fred_by_id = ComplexDragonSpec.by_id(fred._id) - assert fred_by_id - - assert fred_by_id.name == "Fred" - - -def test_count(mongo_client, example_dataset_many): - """Should return a count for documents matching the given query""" - - # Count all dragons - count = ComplexDragonSpec.count() - assert count == 3 - - # Count dragons that are cold or fire drakes - count = ComplexDragonSpec.count(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert count == 2 - - # Count dragons born after 1980 - count = ComplexDragonSpec.count(Q.dob >= datetime(1981, 1, 1)) - assert count == 1 - - -def test_ids(mongo_client, example_dataset_many): - """Should return a list of ids for documents matching the given query""" - - # Ids for all dragons - ids = ComplexDragonSpec.ids() - assert len(ids) == 3 - - # Ids for dragons that are cold or fire drakes - ids = ComplexDragonSpec.ids(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert len(ids) == 2 - - # Ids for dragons born after 1980 - ids = ComplexDragonSpec.ids(Q.dob >= datetime(1981, 1, 1)) - assert len(ids) == 1 - - -def test_one(mongo_client, example_dataset_many): - """Should return a the first document that matches the given query""" - - # Select the first matching dragon - burt = ComplexDragonSpec.one() - assert burt - assert burt.name == "Burt" - - # Sort the results so we select the last matching dragon - albert = ComplexDragonSpec.one(sort=[("_id", DESC)]) - assert albert - assert albert.name == "Albert" - - # Select the first dragon who's a fire-drake - fred = ComplexDragonSpec.one(Q.breed == "Fire-drake") - assert fred - assert fred.name == "Fred" - - # Select a dragon with a different projection - burt = ComplexDragonSpec.one(projection={"name": True}) - assert burt - assert burt.name == "Burt" - assert burt.breed is None - - -def test_many(mongo_client, example_dataset_many): - """Should return all documents that match the given query""" - - # Select all dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - assert len(dragons) == 3 - assert dragons[0].name == "Burt" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Albert" - - # Select all dragons ordered by date of birth (youngest to oldest) - dragons = ComplexDragonSpec.many(sort=[("dob", DESC)]) - - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Burt" - - # Select only dragons born after 1980 ordered by date of birth (youngest to - # oldest). - dragons = ComplexDragonSpec.many(Q.dob > datetime(1980, 1, 1), sort=[("dob", DESC)]) - - assert len(dragons) == 2 - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - - # Select all dragons with a different projection - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)], projection={"name": True}) - - assert dragons[0].name == "Burt" - assert dragons[0].breed is None - assert dragons[1].name == "Fred" - assert dragons[1].breed is None - assert dragons[2].name == "Albert" - assert dragons[2].breed is None - - -def test_projection(mongo_client, example_dataset_one): - """Should allow references and subspecs to be projected""" - - # Select our complex dragon called burt - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - inventory = Inventory(gold=1000, skulls=100) - - # Test list of references - burt.misc = LairSpec.many() - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$ref": LairSpec}}) - - assert burt - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].name == "Cave" - - # Test dictionary of references - burt.misc = {"cave": LairSpec.one()} - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$ref": LairSpec}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["cave"].name == "Cave" - - # Test list of sub-specs - burt.misc = [inventory] - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$sub": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].skulls == 100 - - # Test dict of sub-specs - burt.misc = {"spare": inventory} - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$sub.": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["spare"].skulls == 100 - - -def test_timestamp_insert(mongo_client): - """ - Should assign a timestamp to the `created` and `modified` field for a - document. - """ - - # Assign a the timestamp helper to the insert event - MonitoredDragonSpec.listen("insert", MonitoredDragon.timestamp_insert) - - # Insert a monitored dragon in the database - dragon = MonitoredDragonSpec(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a created/modified timestamp set - assert (dragon.created - now_tz) < timedelta(seconds=1) - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.created - now) < timedelta(seconds=1) - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_timestamp_update(mongo_client): - """@@ Should assign a timestamp to the `modified` field for a document""" - - # Assign a the timestamp helper to the insert event - MonitoredDragonSpec.listen("insert", MonitoredDragon.timestamp_insert) - MonitoredDragonSpec.listen("update", MonitoredDragon.timestamp_update) - - # Insert a monitored dragon in the database - dragon = MonitoredDragonSpec(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a modified timestamp set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.modified - now) < timedelta(seconds=1) - - # Wait a couple of seconds and then update the dragon - sleep(2) - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.breed = "Fire-drake" - dragon.update("breed", "modified") - - # Check a new modified date has been set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - dragon.reload() - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_cascade(mongo_client, example_dataset_many): - """Should apply a cascading delete""" - - # Listen for delete events against dragons and delete any associated lair at - # the same time. - def on_deleted(sender, specs): - ComplexDragonSpec.cascade(LairSpec, "lair", specs) - - ComplexDragonSpec.listen("deleted", on_deleted) - - # Delete a dragon and check the associated lair is also deleted - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.delete() - lair = LairSpec.by_id(burt.lair._id) - assert lair is None - - -def test_nullify(mongo_client, example_dataset_many): - """Should nullify a reference field""" - - # Listen for delete events against lairs and nullify the lair field against - # associated dragons - def on_deleted(sender, specs): - LairSpec.nullify(ComplexDragonSpec, "lair", specs) - - LairSpec.listen("deleted", on_deleted) - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = LairSpec.one(Q.name == "Cave") - assert lair - lair.delete() - - burt = ComplexDragonSpec.one(Q.name == "Burt", projection=None) - assert burt - assert burt.lair is None - - -def test_pull(mongo_client, example_dataset_many): - """Should pull references from a list field""" - - # Listen for delete events against lairs and pull any deleted lair from the - # associated dragons. For the sake of the tests here we're storing multiple - # lairs against the lair attribute instead of the intended one. - def on_deleted(sender, specs): - LairSpec.pull(ComplexDragonSpec, "visited_lairs", specs) - - LairSpec.listen("deleted", on_deleted) - - # List Burt stay in a few lairs - castle = LairSpec.one(Q.name == "Castle") - burt = ComplexDragonSpec.one(Q.name == "Burt") - - assert castle - assert burt - - burt.lair = castle - burt.visited_lairs.append(castle) - burt.update() - burt.reload() - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = LairSpec.one(Q.name == "Castle") - assert lair - lair.delete() - burt.reload(projection=None) - assert burt.visited_lairs == [] - - -def test_listen(mongo_client): - """Should add a callback for a signal against the class""" - - # Create a mocked functions for every event that can be triggered for a - # spec. - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - def on_inserted(sender, specs): - mock.inserted(sender, specs) - - def on_update(sender, specs): - mock.update(sender, specs) - - def on_updated(sender, specs): - mock.updated(sender, specs) - - def on_delete(sender, specs): - mock.delete(sender, specs) - - def on_deleted(sender, specs): - mock.deleted(sender, specs) - - # Listen for all events triggered by specs - DragonSpec.listen("insert", on_insert) - DragonSpec.listen("inserted", on_inserted) - DragonSpec.listen("update", on_update) - DragonSpec.listen("updated", on_updated) - DragonSpec.listen("delete", on_delete) - DragonSpec.listen("deleted", on_deleted) - - # Trigger all the events - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - burt.breed = "Fire-drake" - burt.update() - burt.delete() - - # Check each function was called - assert mock.insert.called - assert mock.inserted.called - assert mock.update.called - assert mock.updated.called - assert mock.delete.called - assert mock.deleted.called - - -def test_stop_listening(mongo_client): - """Should remove a callback for a signal against the class""" - - # Add an listener for the insert event - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - DragonSpec.listen("on_insert", on_insert) - - # Remove the listener for the insert event - DragonSpec.stop_listening("on_insert", on_insert) - - # Insert a dragon into the database and check that the insert event handler - # isn't called. - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - - assert mock.insert.called is False - - -def test_get_collection(mongo_client): - """Return a reference to the database collection for the class""" - assert DragonSpec.get_collection() == mongo_client["attrs_test"]["dragon"] - - -def test_get_db(mongo_client): - """Return the database for the collection""" - assert DragonSpec.get_db() == mongo_client["attrs_test"] - - -def test_get_fields(mongo_client): - """Return the fields for the class""" - assert DragonSpec.get_fields() == {"_id", "name", "breed"} - - -# def test_get_private_fields(mongo_client): -# """Return the private fields for the class""" -# assert Dragon.get_private_fields() == {'breed'} - - -def test_flattern_projection(): - """Flattern projection""" - projection, refs, subs = LairSpec._flatten_projection( - {"name": True, "inventory": {"$sub": Inventory, "gold": True, "secret_draw": {"$sub": Inventory, "gold": True}}} - ) - - assert projection == {"name": True, "inventory.gold": True, "inventory.secret_draw.gold": True} - - -def test_with_options(mongo_client): - """Flattern projection""" - collection = DragonSpec.get_collection() - - with DragonSpec.with_options(read_preference=ReadPreference.SECONDARY): - assert DragonSpec.get_collection().read_preference == ReadPreference.SECONDARY - - with DragonSpec.with_options(read_preference=ReadPreference.PRIMARY_PREFERRED): - assert DragonSpec.get_collection().read_preference == ReadPreference.PRIMARY_PREFERRED - - assert DragonSpec.get_collection().read_preference == ReadPreference.SECONDARY - - assert collection.read_preference == ReadPreference.PRIMARY diff --git a/tests/attrs/test_frames.py b/tests/attrs/test_frames.py deleted file mode 100644 index 3668734..0000000 --- a/tests/attrs/test_frames.py +++ /dev/null @@ -1,792 +0,0 @@ -from datetime import datetime, timedelta, timezone -from time import sleep -from unittest.mock import Mock - -from bson.objectid import ObjectId -from pymongo import ReadPreference - -from mongospecs import ASC, DESC, Empty, In, Q - -from .fixtures import example_dataset_many, example_dataset_one, mongo_client # noqa -from .models import ComplexDragon, Dragon, Inventory, Lair, MonitoredDragon - -# Tests - - -def test_spec(): - """Should create a new Dragon instance""" - - # Passing initial values - burt = Dragon(name="Burt", breed="Cold-drake") - assert burt.name == "Burt" - assert burt.breed == "Cold-drake" - assert isinstance(burt, Dragon) - - -def test_dot_notation(): - """ - Should allow access to read and set document values using do notation. - """ - - # Simple set/get - burt = Dragon(name="Burt", breed="Cold-drake") - - assert burt.name == "Burt" - burt.name = "Fred" - assert burt.name == "Fred" - - # SubSpec (embedded document get/set) - inventory = Inventory(gold=1000, skulls=100) - - cave = Lair(name="Cave", inventory=inventory) - - assert cave.inventory.gold == 1000 - cave.inventory.gold += 100 - assert cave.inventory.gold == 1100 - - -def test_equal(mongo_client): - """Should compare the equality of two Spec instances by Id""" - - # Create some dragons - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - - fred = Dragon(name="Fred", breed="Fire-drake") - fred.insert() - - # Test equality - assert burt != fred - assert burt == burt - - -def test_python_sort(mongo_client): - """Should sort a list of Spec instances by their Ids""" - - # Create some dragons - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - - fred = Dragon(name="Fred", breed="Fire-drake") - fred.insert() - - albert = Dragon(name="Albert", breed="Stone dragon") - albert.insert() - - # Test sorting by Id - assert sorted([albert, burt, fred]) == [burt, fred, albert] - - -def test_to_json_type(mongo_client, example_dataset_one): - """ - Should return a dictionary for the document with all values converted to - JSON safe types. All private fields should be excluded. - """ - - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - cave = burt.lair - - burt_json = burt.to_json_type() - - assert burt_json == { - "_id": str(burt.id), - "name": "Burt", - "breed": "Cold-drake", - "dob": "1979-06-11T00:00:00", - "traits": ["irritable", "narcissistic"], - "lair": {"_id": str(cave.id), "name": "Cave", "inventory": {"gold": 1000, "skulls": 100}}, - "visited_lairs": [], - "misc": "", - } - - -def test_insert(mongo_client): - """Should insert a document into the database""" - - # Create some convoluted data to insert - inventory = Inventory(gold=1000, skulls=100) - - cave = Lair(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragon( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Test the document now has an Id - assert burt.id is not None - - # Get the document from the database and check it's values - burt.reload() - - assert burt.name == "Burt" - assert burt.dob == datetime(1979, 6, 11) - assert burt.breed == "Cold-drake" - assert burt.traits == ["irritable", "narcissistic"] - assert burt.lair.name == "Cave" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Test insert where we specify the `_id` value - _id = ObjectId() - albert = Dragon(_id=_id, name="Albert", breed="Stone dragon") - albert.insert() - - assert albert._id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_unset(mongo_client, example_dataset_one): - """Should unset fields against a document on the database""" - - # Unset name and breed - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - burt.unset("breed") - - assert burt.name == "Burt" - assert burt.breed == Empty - assert "breed" not in burt.to_json_type() - - burt.reload() - - assert burt.name == "Burt" - assert burt.breed == Empty - assert "breed" not in burt.to_json_type() - - -def test_update(mongo_client, example_dataset_one): - """Should update a document on the database""" - - # Update all values - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - burt.name = "Jess" - burt.breed = "Fire-drake" - burt.traits = ["gentle", "kind"] - burt.update() - - burt.reload() - - assert burt.name == "Jess" - assert burt.breed == "Fire-drake" - assert burt.traits == ["gentle", "kind"] - - # Selective update - burt.lair.name = "Castle" - burt.lair.inventory.gold += 100 - burt.lair.inventory.skulls = 0 - burt.lair.update("name", "inventory.skulls") - - burt.reload() - - assert burt.lair.name == "Castle" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 0 - - -def test_upsert(mongo_client): - """ - Should update or insert a document on the database depending on whether or - not it already exists. - """ - - # Insert - burt = Dragon(name="Burt", breed="Cold-drake") - burt.upsert() - id = burt._id - burt.reload() - - # Update - burt.upsert() - burt.reload() - - assert burt._id == id - - # Test upsert where we specify the `_id` value - _id = ObjectId() - albert = Dragon(_id=_id, name="Albert", breed="Stone dragon") - albert.upsert() - - assert albert._id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_delete(mongo_client, example_dataset_one): - """Should delete a document from the database""" - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - assert burt._id - - burt.delete() - burt = burt.by_id(burt._id) - - assert burt is None - - -def test_insert_many(mongo_client): - """Should insert multiple documents records into the database""" - - # Create some convoluted data to insert - burt = Dragon(name="Burt", breed="Cold-drake") - - fred = Dragon(name="Fred", breed="Fire-drake") - - albert = Dragon(name="Albert", breed="Stone dragon") - - burt.insert_many([burt, fred, albert]) - - # Check 3 dragons have been created - assert Dragon.count() == 3 - - # Check the details for each dragon - dragons = Dragon.many(sort=[("_id", ASC)]) - assert dragons[0].name == "Burt" - assert dragons[0].breed == "Cold-drake" - assert dragons[1].name == "Fred" - assert dragons[1].breed == "Fire-drake" - assert dragons[2].name == "Albert" - assert dragons[2].breed == "Stone dragon" - - -def test_unset_many(mongo_client, example_dataset_many): - """Should unset fields against multiple documents on the database""" - - # Unset name and breed - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - for dragon in dragons: - dragon.unset("name", "breed") - - for dragon in dragons: - assert dragon.name == Empty - assert dragon.breed == Empty - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - dragon.reload() - - assert dragon.name == Empty - assert dragon.breed == Empty - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - -def test_update_many(mongo_client, example_dataset_many): - """Should update mulitple documents on the database""" - - # Select all the dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - # Give each dragon a second name - for dragon in dragons: - dragon.name += " " + dragon.name + "son" - - # Update all values for all the dragons in one go - ComplexDragon.update_many(dragons) - - # Reload all the dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Make various changes to the dragons only some of which we want to stick - for dragon in dragons: - dragon.name = dragon.name.split(" ")[0] - assert dragon.breed - dragon.breed = dragon.breed.replace("-", "_") - dragon.breed = dragon.breed.replace(" ", "_") - dragon.lair.inventory.gold += 100 - dragon.lair.inventory.skulls += 10 - - # Update selected values for all the dragons in one go - Lair.update_many([d.lair for d in dragons], "inventory.gold") - ComplexDragon.update_many(dragons, "breed") - - # Reload all the dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - # Names should be the same - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Breeds should have changed - assert dragons[0].breed == "Cold_drake" - assert dragons[1].breed == "Fire_drake" - assert dragons[2].breed == "Stone_dragon" - - # Gold should have changed - assert dragons[0].lair.inventory.gold == 1100 - assert dragons[1].lair.inventory.gold == 2100 - assert dragons[2].lair.inventory.gold == 3100 - - # Skulls should be the same - assert dragons[0].lair.inventory.skulls == 100 - assert dragons[1].lair.inventory.skulls == 200 - assert dragons[2].lair.inventory.skulls == 300 - - -def test_delete_many(mongo_client, example_dataset_many): - """Should delete mulitple documents from the database""" - - # Select all the dragons - dragons = ComplexDragon.many() - - # Delete all of them :( - ComplexDragon.delete_many(dragons) - - # Check there are no remaining dragons - assert ComplexDragon.count() == 0 - - -def test_reload(mongo_client, example_dataset_one): - """Should reload the current document's values from the database""" - - # Select Burt from the database - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - # Change some values and reload - burt.name = "Fred" - burt.lair.inventory = Inventory(gold=500, skulls=50) - burt.reload() - - # Check Burt is himself again - assert burt.name == "Burt" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Reload with a different projection - burt.reload(projection={"name": True}) - - # Check Burt has values for the projection specified - assert burt.name == "Burt" - - ## Should reload reset attributes based on projection? - # assert burt.breed == None - # assert burt.lair == None - - -def test_by_id(mongo_client, example_dataset_many): - """Should return a document by Id from the database""" - - # Get an Id for a dragon - fred = ComplexDragon.one(Q.name == "Fred") - assert fred - assert fred._id - - # Load a dragon using the Id and make sure it's the same - fred_by_id = ComplexDragon.by_id(fred._id) - assert fred_by_id - - assert fred_by_id.name == "Fred" - - -def test_count(mongo_client, example_dataset_many): - """Should return a count for documents matching the given query""" - - # Count all dragons - count = ComplexDragon.count() - assert count == 3 - - # Count dragons that are cold or fire drakes - count = ComplexDragon.count(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert count == 2 - - # Count dragons born after 1980 - count = ComplexDragon.count(Q.dob >= datetime(1981, 1, 1)) - assert count == 1 - - -def test_ids(mongo_client, example_dataset_many): - """Should return a list of ids for documents matching the given query""" - - # Ids for all dragons - ids = ComplexDragon.ids() - assert len(ids) == 3 - - # Ids for dragons that are cold or fire drakes - ids = ComplexDragon.ids(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert len(ids) == 2 - - # Ids for dragons born after 1980 - ids = ComplexDragon.ids(Q.dob >= datetime(1981, 1, 1)) - assert len(ids) == 1 - - -def test_one(mongo_client, example_dataset_many): - """Should return a the first document that matches the given query""" - - # Select the first matching dragon - burt = ComplexDragon.one() - assert burt - assert burt.name == "Burt" - - # Sort the results so we select the last matching dragon - albert = ComplexDragon.one(sort=[("_id", DESC)]) - assert albert - assert albert.name == "Albert" - - # Select the first dragon who's a fire-drake - fred = ComplexDragon.one(Q.breed == "Fire-drake") - assert fred - assert fred.name == "Fred" - - # Select a dragon with a different projection - burt = ComplexDragon.one(projection={"name": True}) - assert burt - assert burt.name == "Burt" - assert burt.breed is None - - -def test_many(mongo_client, example_dataset_many): - """Should return all documents that match the given query""" - - # Select all dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - assert len(dragons) == 3 - assert dragons[0].name == "Burt" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Albert" - - # Select all dragons ordered by date of birth (youngest to oldest) - dragons = ComplexDragon.many(sort=[("dob", DESC)]) - - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Burt" - - # Select only dragons born after 1980 ordered by date of birth (youngest to - # oldest). - dragons = ComplexDragon.many(Q.dob > datetime(1980, 1, 1), sort=[("dob", DESC)]) - - assert len(dragons) == 2 - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - - # Select all dragons with a different projection - dragons = ComplexDragon.many(sort=[("_id", ASC)], projection={"name": True}) - - assert dragons[0].name == "Burt" - assert dragons[0].breed is None - assert dragons[1].name == "Fred" - assert dragons[1].breed is None - assert dragons[2].name == "Albert" - assert dragons[2].breed is None - - -def test_projection(mongo_client, example_dataset_one): - """Should allow references and subspecs to be projected""" - - # Select our complex dragon called burt - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - inventory = Inventory(gold=1000, skulls=100) - - # Test list of references - burt.misc = Lair.many() - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$ref": Lair}}) - - assert burt - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].name == "Cave" - - # Test dictionary of references - burt.misc = {"cave": Lair.one()} - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$ref": Lair}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["cave"].name == "Cave" - - # Test list of sub-specs - burt.misc = [inventory] - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$sub": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].skulls == 100 - - # Test dict of sub-specs - burt.misc = {"spare": inventory} - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$sub.": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["spare"].skulls == 100 - - -def test_timestamp_insert(mongo_client): - """ - Should assign a timestamp to the `created` and `modified` field for a - document. - """ - - # Assign a the timestamp helper to the insert event - MonitoredDragon.listen("insert", MonitoredDragon.timestamp_insert) - - # Insert a monitored dragon in the database - dragon = MonitoredDragon(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a created/modified timestamp set - assert (dragon.created - now_tz) < timedelta(seconds=1) - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.created - now) < timedelta(seconds=1) - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_timestamp_update(mongo_client): - """@@ Should assign a timestamp to the `modified` field for a document""" - - # Assign a the timestamp helper to the insert event - MonitoredDragon.listen("insert", MonitoredDragon.timestamp_insert) - MonitoredDragon.listen("update", MonitoredDragon.timestamp_update) - - # Insert a monitored dragon in the database - dragon = MonitoredDragon(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a modified timestamp set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.modified - now) < timedelta(seconds=1) - - # Wait a couple of seconds and then update the dragon - sleep(2) - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.breed = "Fire-drake" - dragon.update("breed", "modified") - - # Check a new modified date has been set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - dragon.reload() - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_cascade(mongo_client, example_dataset_many): - """Should apply a cascading delete""" - - # Listen for delete events against dragons and delete any associated lair at - # the same time. - def on_deleted(sender, specs): - ComplexDragon.cascade(Lair, "lair", specs) - - ComplexDragon.listen("deleted", on_deleted) - - # Delete a dragon and check the associated lair is also deleted - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - burt.delete() - assert burt.lair._id - lair = Lair.by_id(burt.lair._id) - assert lair is None - - -def test_nullify(mongo_client, example_dataset_many): - """Should nullify a reference field""" - - # Listen for delete events against lairs and nullify the lair field against - # associated dragons - def on_deleted(sender, specs): - Lair.nullify(ComplexDragon, "lair", specs) - - Lair.listen("deleted", on_deleted) - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = Lair.one(Q.name == "Cave") - assert lair - lair.delete() - - burt = ComplexDragon.one(Q.name == "Burt", projection=None) - assert burt - assert burt.lair is None - - -def test_pull(mongo_client, example_dataset_many): - """Should pull references from a list field""" - - # Listen for delete events against lairs and pull any deleted lair from the - # associated dragons. For the sake of the tests here we're storing multiple - # lairs against the lair attribute instead of the intended one. - def on_deleted(sender, specs): - Lair.pull(ComplexDragon, "visited_lairs", specs) - - Lair.listen("deleted", on_deleted) - - # List Burt stay in a few lairs - castle = Lair.one(Q.name == "Castle") - burt = ComplexDragon.one(Q.name == "Burt") - - assert castle - assert burt - - burt.lair = castle - burt.visited_lairs.append(castle) - burt.update() - burt.reload() - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = Lair.one(Q.name == "Castle") - assert lair - lair.delete() - burt.reload(projection=None) - assert burt.visited_lairs == [] - - -def test_listen(mongo_client): - """Should add a callback for a signal against the class""" - - # Create a mocked functions for every event that can be triggered for a - # spec. - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - def on_inserted(sender, specs): - mock.inserted(sender, specs) - - def on_update(sender, specs): - mock.update(sender, specs) - - def on_updated(sender, specs): - mock.updated(sender, specs) - - def on_delete(sender, specs): - mock.delete(sender, specs) - - def on_deleted(sender, specs): - mock.deleted(sender, specs) - - # Listen for all events triggered by specs - Dragon.listen("insert", on_insert) - Dragon.listen("inserted", on_inserted) - Dragon.listen("update", on_update) - Dragon.listen("updated", on_updated) - Dragon.listen("delete", on_delete) - Dragon.listen("deleted", on_deleted) - - # Trigger all the events - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - burt.breed = "Fire-drake" - burt.update() - burt.delete() - - # Check each function was called - assert mock.insert.called - assert mock.inserted.called - assert mock.update.called - assert mock.updated.called - assert mock.delete.called - assert mock.deleted.called - - -def test_stop_listening(mongo_client): - """Should remove a callback for a signal against the class""" - - # Add an listener for the insert event - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - Dragon.listen("on_insert", on_insert) - - # Remove the listener for the insert event - Dragon.stop_listening("on_insert", on_insert) - - # Insert a dragon into the database and check that the insert event handler - # isn't called. - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - - assert mock.insert.called is False - - -def test_get_collection(mongo_client): - """Return a reference to the database collection for the class""" - assert Dragon.get_collection() == mongo_client["attrs_test"]["Dragon"] - - -def test_get_db(mongo_client): - """Return the database for the collection""" - assert Dragon.get_db() == mongo_client["attrs_test"] - - -def test_get_fields(mongo_client): - """Return the fields for the class""" - assert Dragon.get_fields() == {"_id", "name", "breed"} - - -# def test_get_private_fields(mongo_client): -# """Return the private fields for the class""" -# assert Dragon.get_private_fields() == {'breed'} - - -def test_flattern_projection(): - """Flattern projection""" - - projection, refs, subs = Lair._flatten_projection( - {"name": True, "inventory": {"$sub": Inventory, "gold": True, "secret_draw": {"$sub": Inventory, "gold": True}}} - ) - - assert projection == {"name": True, "inventory.gold": True, "inventory.secret_draw.gold": True} - - -def test_with_options(mongo_client): - """Flattern projection""" - - collection = Dragon.get_collection() - - with Dragon.with_options(read_preference=ReadPreference.SECONDARY): - assert Dragon.get_collection().read_preference == ReadPreference.SECONDARY - - with Dragon.with_options(read_preference=ReadPreference.PRIMARY_PREFERRED): - assert Dragon.get_collection().read_preference == ReadPreference.PRIMARY_PREFERRED - - assert Dragon.get_collection().read_preference == ReadPreference.SECONDARY - - assert collection.read_preference == ReadPreference.PRIMARY diff --git a/tests/attrs/test_pagination.py b/tests/attrs/test_pagination.py deleted file mode 100644 index 560db0b..0000000 --- a/tests/attrs/test_pagination.py +++ /dev/null @@ -1,186 +0,0 @@ -import pytest -from attrs import define - -from mongospecs import DESC, Empty, Paginator, Q -from mongospecs.attrs import Spec - -from .fixtures import mongo_client # noqa - -# Classes - - -@define -class Orc(Spec): - """ - An orc. - """ - - _collection = "Orc" - name: str = Empty - - -@pytest.fixture(scope="function") -def example_dataset(mongo_client): - """Connect to the database and create a set of example data (orcs)""" - - # Create 1,000 orcs - for i in range(0, 1000): - Orc(name="Orc {0:04d}".format(i)).insert() - - return Spec._client - - -# Paginator tests - - -def test_paginator(example_dataset): - """Paginate all orcs""" - - # Paginate all orcs - paginator = Paginator(Orc) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 50 - assert paginator.page_numbers == range(1, 51) - - i = 0 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - -def test_paginator_with_filter(example_dataset): - """Paginate the last 100 orcs using a filter""" - - # Paginate all orcs - paginator = Paginator(Orc, Q.name >= "Orc 0900") - - # Check the results are as expected - assert paginator.item_count == 100 - assert paginator.page_count == 5 - assert paginator.page_numbers == range(1, 6) - - i = 900 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - -def test_paginator_with_sort(example_dataset): - """Paginate all orcs but sort them in reverse""" - - # Paginate all orcs - paginator = Paginator(Orc, filter_kwargs={"sort": [("name", DESC)]}) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 50 - assert paginator.page_numbers == range(1, 51) - - i = 999 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i -= 1 - - -def test_paginator_with_projection(example_dataset): - """Paginate all orcs but use a custom projection""" - - # Paginate all orcs - paginator = Paginator(Orc, filter_kwargs={"projection": {"name": False}}) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 50 - assert paginator.page_numbers == range(1, 51) - - for page in paginator: - for orc in page: - assert orc._id is not None - assert orc.name is Empty - - -def test_paginator_with_per_page(example_dataset): - """Paginate all orcs but use a custom per page value""" - - # Paginate all orcs - paginator = Paginator(Orc, per_page=100) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 10 - assert paginator.page_numbers == range(1, 11) - - i = 0 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - -def test_paginator_with_orphans(example_dataset): - """Paginate all orcs but allow up to 2 orphan results""" - - # Paginate all orcs - paginator = Paginator(Orc, Q.name >= "Orc 0918", orphans=2) - - # Check the results are as expected - assert paginator.item_count == 82 - assert paginator.page_count == 4 - assert paginator.page_numbers == range(1, 5) - - i = 918 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - # Check the last page has 22 items - assert len(paginator[4]) == 22 - - -# Page tests - - -def test_page(example_dataset): - """Test the results or extracting a paginated page""" - - # Paginate all orcs - paginator = Paginator(Orc) - - # First page - page = paginator[1] - assert page.prev is None - assert page.next == 2 - assert page.number == 1 - assert len(page) == paginator.per_page - - i = 0 - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - # Second page - page = paginator[2] - assert page.prev == 1 - assert page.next == 3 - assert page.number == 2 - - i = 20 - for orc in page.items: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - # Last page - page = paginator[paginator.page_count] - assert page.prev == paginator.page_count - 1 - assert page.next is None - assert page.number == paginator.page_count - - # Find the offset of Orc 992 - orc = Orc.one(Q.name == "Orc 0992") - assert page.get_offset(orc) == 992 diff --git a/tests/attrs/test_spec.py b/tests/attrs/test_spec.py deleted file mode 100644 index 34dfe5f..0000000 --- a/tests/attrs/test_spec.py +++ /dev/null @@ -1,9 +0,0 @@ -from attrs import define - - -@define -class Country: - _collection = "country" - - name: str - population: int diff --git a/tests/msgspec/__init__.py b/tests/msgspec/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/msgspec/adapter_models.py b/tests/msgspec/adapter_models.py deleted file mode 100644 index 8330c31..0000000 --- a/tests/msgspec/adapter_models.py +++ /dev/null @@ -1,94 +0,0 @@ -from datetime import datetime, timezone -from typing import Any, Optional - -from msgspec import Struct, field - -from mongospecs.msgspec import SubSpec - -__all__ = [ - "Dragon", - "Inventory", - "Lair", - "ComplexDragon", - "MonitoredDragon", -] - - -class Dragon(Struct): - """ - A dragon. - """ - - name: str - breed: Optional[str] = None - - def _get_dummy_prop(self): - return self._dummy_prop - - def _set_dummy_prop(self, value): - self._dummy_prop = True - - dummy_prop = property(_get_dummy_prop, _set_dummy_prop) - - -class Inventory(SubSpec): - """ - An inventory of items kept within a lair. - """ - - gold: int = 0 - skulls: int = 0 - - -class Lair(Struct): - """ - A lair in which a dragon resides. - """ - - name: str = "" - inventory: Inventory = field(default_factory=Inventory) - - -class ComplexDragon(Dragon, kw_only=True): - _collection = "ComplexDragon" - - dob: Optional[datetime] = None - lair: Lair = field(default_factory=Lair) - visited_lairs: list[Lair] = [] - traits: list[str] = [] - misc: Optional[Any] = "" - - _default_projection = {"lair": {"$ref": Lair, "inventory": {"$sub": Inventory}}} - - -class MonitoredDragon(Dragon): - created: Optional[datetime] = None - modified: Optional[datetime] = None - - @staticmethod - def timestamp_insert(sender, specs): - """ - Timestamp the created and modified fields for all documents. This method - should be bound to a spec class like so: - - ``` - MySpecClass.listen('insert', MySpecClass.timestamp_insert) - ``` - """ - for spec in specs: - timestamp = datetime.now(timezone.utc) - spec.created = timestamp - spec.modified = timestamp - - @staticmethod - def timestamp_update(sender, specs): - """ - Timestamp the modified field for all documents. This method should be - bound to a spec class like so: - - ``` - MySpecClass.listen('update', MySpecClass.timestamp_update) - ``` - """ - for spec in specs: - spec.modified = datetime.now(timezone.utc) diff --git a/tests/msgspec/fixtures.py b/tests/msgspec/fixtures.py deleted file mode 100644 index 1ee9a68..0000000 --- a/tests/msgspec/fixtures.py +++ /dev/null @@ -1,68 +0,0 @@ -from datetime import datetime - -import pytest -from mongomock import MongoClient - -from mongospecs.base import SpecBase - -from .models import ComplexDragon, Inventory, Lair - -# Fixtures - - -@pytest.fixture -def mongo_client(request): - """Connect to the test database""" - - # Connect to mongodb and create a test database - SpecBase._client = MongoClient("mongodb://localhost:27017/mongospecs_test") - yield SpecBase._client - - SpecBase._client.drop_database("mongospecs_test") - del SpecBase._client - - -@pytest.fixture -def example_dataset_one(request): - """Create an example set of data that can be used in testing""" - inventory = Inventory(gold=1000, skulls=100) - - cave = Lair(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragon( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - -@pytest.fixture -def example_dataset_many(request): - """Create an example set of data that can be used in testing""" - - # Burt - cave = Lair(name="Cave", inventory=Inventory(gold=1000, skulls=100)) - cave.insert() - - burt = ComplexDragon( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Fred - castle = Lair(name="Castle", inventory=Inventory(gold=2000, skulls=200)) - castle.insert() - - fred = ComplexDragon( - name="Fred", dob=datetime(1980, 7, 12), breed="Fire-drake", lair=castle, traits=["impulsive", "loyal"] - ) - fred.insert() - - # Fred - mountain = Lair(name="Mountain", inventory=Inventory(gold=3000, skulls=300)) - mountain.insert() - - albert = ComplexDragon( - name="Albert", dob=datetime(1981, 8, 13), breed="Stone dragon", lair=mountain, traits=["reclusive", "cunning"] - ) - albert.insert() diff --git a/tests/msgspec/models.py b/tests/msgspec/models.py deleted file mode 100644 index 6b9169f..0000000 --- a/tests/msgspec/models.py +++ /dev/null @@ -1,94 +0,0 @@ -from datetime import datetime, timezone -from typing import Any, Optional - -from msgspec import field - -from mongospecs.msgspec import Spec, SubSpec - -__all__ = [ - "Dragon", - "Inventory", - "Lair", - "ComplexDragon", - "MonitoredDragon", -] - - -class Dragon(Spec): - """ - A dragon. - """ - - name: str - breed: Optional[str] = None - - def _get_dummy_prop(self): - return self._dummy_prop - - def _set_dummy_prop(self, value): - self._dummy_prop = True - - dummy_prop = property(_get_dummy_prop, _set_dummy_prop) - - -class Inventory(SubSpec): - """ - An inventory of items kept within a lair. - """ - - gold: int = 0 - skulls: int = 0 - - -class Lair(Spec): - """ - A lair in which a dragon resides. - """ - - name: str = "" - inventory: Inventory = field(default_factory=Inventory) - - -class ComplexDragon(Dragon, kw_only=True): - _collection = "ComplexDragon" - - dob: Optional[datetime] = None - lair: Lair = field(default_factory=Lair) - visited_lairs: list[Lair] = [] - traits: list[str] = [] - misc: Optional[Any] = "" - - _default_projection = {"lair": {"$ref": Lair, "inventory": {"$sub": Inventory}}} - - -class MonitoredDragon(Dragon): - created: Optional[datetime] = None - modified: Optional[datetime] = None - - @staticmethod - def timestamp_insert(sender, specs): - """ - Timestamp the created and modified fields for all documents. This method - should be bound to a spec class like so: - - ``` - MySpecClass.listen('insert', MySpecClass.timestamp_insert) - ``` - """ - for spec in specs: - timestamp = datetime.now(timezone.utc) - spec.created = timestamp - spec.modified = timestamp - - @staticmethod - def timestamp_update(sender, specs): - """ - Timestamp the modified field for all documents. This method should be - bound to a spec class like so: - - ``` - MySpecClass.listen('update', MySpecClass.timestamp_update) - ``` - """ - for spec in specs: - spec.modified = datetime.now(timezone.utc) diff --git a/tests/msgspec/test_adapter.py b/tests/msgspec/test_adapter.py deleted file mode 100644 index a0abc47..0000000 --- a/tests/msgspec/test_adapter.py +++ /dev/null @@ -1,861 +0,0 @@ -from datetime import datetime, timedelta, timezone -from time import sleep -from unittest.mock import Mock - -import pytest -from bson.objectid import ObjectId -from msgspec import UNSET, Struct -from pymongo import ReadPreference - -from mongospecs import ASC, DESC, In, Q -from mongospecs.helpers.empty import Empty -from mongospecs.msgspec import SpecAdapter - -from .adapter_models import ComplexDragon, Dragon, Inventory, Lair, MonitoredDragon -from .fixtures import mongo_client # noqa - -DragonSpec = SpecAdapter(Dragon, collection="dragon") -LairSpec = SpecAdapter(Lair, collection="lair") -ComplexDragonSpec = SpecAdapter(ComplexDragon, collection="complexdragon") -ComplexDragonSpec._default_projection = {"lair": {"$ref": LairSpec, "inventory": {"$sub": Inventory}}} -MonitoredDragonSpec = SpecAdapter(MonitoredDragon, collection="monitoreddragon") - - -@pytest.fixture -def example_dataset_one(mongo_client): - """Create an example set of data that can be used in testing""" - inventory = Inventory(gold=1000, skulls=100) - - cave = LairSpec(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragonSpec( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - -@pytest.fixture -def example_dataset_many(mongo_client): - """Create an example set of data that can be used in testing""" - - # Burt - cave = LairSpec(name="Cave", inventory=Inventory(gold=1000, skulls=100)) - cave.insert() - - burt = ComplexDragonSpec( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Fred - castle = LairSpec(name="Castle", inventory=Inventory(gold=2000, skulls=200)) - castle.insert() - - fred = ComplexDragonSpec( - name="Fred", dob=datetime(1980, 7, 12), breed="Fire-drake", lair=castle, traits=["impulsive", "loyal"] - ) - fred.insert() - - # Fred - mountain = LairSpec(name="Mountain", inventory=Inventory(gold=3000, skulls=300)) - mountain.insert() - - albert = ComplexDragonSpec( - name="Albert", dob=datetime(1981, 8, 13), breed="Stone dragon", lair=mountain, traits=["reclusive", "cunning"] - ) - albert.insert() - - -def test_msgspec_adapter(mongo_client) -> None: - class Car(Struct): - make: str - model: str - year: int - - CarSpec = SpecAdapter(Car, collection="cars", client=mongo_client) - - assert CarSpec.get_fields() == {"_id", "make", "model", "year"} - car = CarSpec(make="test", model="test123", year=2000) - assert car.to_dict() == {"_id": Empty, "make": "test", "model": "test123", "year": 2000} - assert car.get_fields() == {"_id", "make", "model", "year"} - - car.insert() - assert car.find({"make": "test"}) == [{"_id": car._id, "make": "test", "model": "test123", "year": 2000}] - assert car.to_dict() == {"_id": car._id, "make": "test", "model": "test123", "year": 2000} - - -def test_spec(): - """Should create a new Dragon instance""" - - # Passing initial values - burt = DragonSpec(name="Burt", breed="Cold-drake") - assert burt.name == "Burt" - assert burt.breed == "Cold-drake" - assert isinstance(burt, Dragon) - - -def test_dot_notation(): - """ - Should allow access to read and set document values using do notation. - """ - - # Simple set/get - burt = DragonSpec(name="Burt", breed="Cold-drake") - - assert burt.name == "Burt" - burt.name = "Fred" - assert burt.name == "Fred" - - # SubSpec (embedded document get/set) - inventory = Inventory(gold=1000, skulls=100) - - cave = LairSpec(name="Cave", inventory=inventory) - - assert cave.inventory.gold == 1000 - cave.inventory.gold += 100 - assert cave.inventory.gold == 1100 - - -def test_equal(mongo_client): - """Should compare the equality of two Spec instances by Id""" - - # Create some dragons - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - - fred = DragonSpec(name="Fred", breed="Fire-drake") - fred.insert() - - # Test equality - assert burt != fred - assert burt == burt - - -def test_python_sort(mongo_client): - """Should sort a list of Spec instances by their Ids""" - - # Create some dragons - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - - fred = DragonSpec(name="Fred", breed="Fire-drake") - fred.insert() - - albert = DragonSpec(name="Albert", breed="Stone dragon") - albert.insert() - - # Test sorting by Id - assert sorted([albert, burt, fred]) == [burt, fred, albert] - - -def test_to_json_type(mongo_client, example_dataset_one): - """ - Should return a dictionary for the document with all values converted to - JSON safe types. All private fields should be excluded. - """ - - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - cave = burt.lair - - burt_json = burt.to_json_type() - - assert burt_json == { - "_id": str(burt._id), - "name": "Burt", - "breed": "Cold-drake", - "dob": "1979-06-11T00:00:00", - "traits": ["irritable", "narcissistic"], - "lair": {"_id": str(cave._id), "name": "Cave", "inventory": {"gold": 1000, "skulls": 100}}, - "visited_lairs": [], - "misc": "", - } - - -def test_insert(mongo_client): - """Should insert a document into the database""" - - # Create some convoluted data to insert - inventory = Inventory(gold=1000, skulls=100) - - cave = LairSpec(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragonSpec( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Test the document now has an Id - assert burt._id is not None - - # Get the document from the database and check it's values - burt.reload() - - assert burt.name == "Burt" - assert burt.dob == datetime(1979, 6, 11) - assert burt.breed == "Cold-drake" - assert burt.traits == ["irritable", "narcissistic"] - assert burt.lair.name == "Cave" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Test insert where we specify the `_id` value - _id = ObjectId() - albert = DragonSpec(_id=_id, name="Albert", breed="Stone dragon") - albert.insert() - - assert albert._id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_unset(mongo_client, example_dataset_one): - """Should unset fields against a document on the database""" - - # Unset name and breed - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.unset("breed") - - assert burt.name == "Burt" - assert burt.breed == UNSET - assert "breed" not in burt.to_json_type() - - burt.reload() - - assert burt.name == "Burt" - assert burt.breed == UNSET - assert "breed" not in burt.to_json_type() - - -def test_update(mongo_client, example_dataset_one): - """Should update a document on the database""" - - # Update all values - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.name = "Jess" - burt.breed = "Fire-drake" - burt.traits = ["gentle", "kind"] - burt.update() - - burt.reload() - - assert burt.name == "Jess" - assert burt.breed == "Fire-drake" - assert burt.traits == ["gentle", "kind"] - - # Selective update - burt.lair.name = "Castle" - burt.lair.inventory.gold += 100 - burt.lair.inventory.skulls = 0 - burt.lair.update("name", "inventory.skulls") - - burt.reload() - - assert burt.lair.name == "Castle" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 0 - - -def test_upsert(mongo_client): - """ - Should update or insert a document on the database depending on whether or - not it already exists. - """ - - # Insert - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.upsert() - id = burt._id - burt.reload() - - # Update - burt.upsert() - burt.reload() - - assert burt._id == id - - # Test upsert where we specify the `_id` value - _id = ObjectId() - albert = DragonSpec(_id=_id, name="Albert", breed="Stone dragon") - albert.upsert() - - assert albert._id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_delete(mongo_client, example_dataset_one): - """Should delete a document from the database""" - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.delete() - burt = burt.by_id(burt._id) - - assert burt is None - - -def test_insert_many(mongo_client): - """Should insert multiple documents records into the database""" - - # Create some convoluted data to insert - burt = DragonSpec(name="Burt", breed="Cold-drake") - - fred = DragonSpec(name="Fred", breed="Fire-drake") - - albert = DragonSpec(name="Albert", breed="Stone dragon") - - burt.insert_many([burt, fred, albert]) - - # Check 3 dragons have been created - assert DragonSpec.count() == 3 - - # Check the details for each dragon - dragons = DragonSpec.many(sort=[("_id", ASC)]) - assert dragons[0].name == "Burt" - assert dragons[0].breed == "Cold-drake" - assert dragons[1].name == "Fred" - assert dragons[1].breed == "Fire-drake" - assert dragons[2].name == "Albert" - assert dragons[2].breed == "Stone dragon" - - -def test_unset_many(mongo_client, example_dataset_many): - """Should unset fields against multiple documents on the database""" - - # Unset name and breed - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - for dragon in dragons: - dragon.unset("name", "breed") - - for dragon in dragons: - assert dragon.name == UNSET - assert dragon.breed == UNSET - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - dragon.reload() - - assert dragon.name == UNSET - assert dragon.breed == UNSET - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - -def test_update_many(mongo_client, example_dataset_many): - """Should update mulitple documents on the database""" - - # Select all the dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - # Give each dragon a second name - for dragon in dragons: - dragon.name += " " + dragon.name + "son" - - # Update all values for all the dragons in one go - ComplexDragonSpec.update_many(dragons) - - # Reload all the dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Make various changes to the dragons only some of which we want to stick - for dragon in dragons: - dragon.name = dragon.name.split(" ")[0] - assert dragon.breed - dragon.breed = dragon.breed.replace("-", "_") - dragon.breed = dragon.breed.replace(" ", "_") - dragon.lair.inventory.gold += 100 - dragon.lair.inventory.skulls += 10 - - # Update selected values for all the dragons in one go - LairSpec.update_many([d.lair for d in dragons], "inventory.gold") - ComplexDragonSpec.update_many(dragons, "breed") - - # Reload all the dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - # Names should be the same - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Breeds should have changed - assert dragons[0].breed == "Cold_drake" - assert dragons[1].breed == "Fire_drake" - assert dragons[2].breed == "Stone_dragon" - - # Gold should have changed - assert dragons[0].lair.inventory.gold == 1100 - assert dragons[1].lair.inventory.gold == 2100 - assert dragons[2].lair.inventory.gold == 3100 - - # Skulls should be the same - assert dragons[0].lair.inventory.skulls == 100 - assert dragons[1].lair.inventory.skulls == 200 - assert dragons[2].lair.inventory.skulls == 300 - - -def test_delete_many(mongo_client, example_dataset_many): - """Should delete mulitple documents from the database""" - - # Select all the dragons - dragons = ComplexDragonSpec.many() - - # Delete all of them :( - ComplexDragonSpec.delete_many(dragons) - - # Check there are no remaining dragons - assert ComplexDragonSpec.count() == 0 - - -def test_reload(mongo_client, example_dataset_one): - """Should reload the current document's values from the database""" - - # Select Burt from the database - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - # Change some values and reload - burt.name = "Fred" - burt.lair.inventory = Inventory(gold=500, skulls=50) - burt.reload() - - # Check Burt is himself again - assert burt.name == "Burt" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Reload with a different projection - burt.reload(projection={"name": True}) - - # Check Burt has values for the projection specified - assert burt.name == "Burt" - - ## Should reload reset attributes based on projection? - # assert burt.breed == None - # assert burt.lair == None - - -def test_by_id(mongo_client, example_dataset_many): - """Should return a document by Id from the database""" - - # Get an Id for a dragon - fred = ComplexDragonSpec.one(Q.name == "Fred") - assert fred - - # Load a dragon using the Id and make sure it's the same - fred_by_id = ComplexDragonSpec.by_id(fred._id) - assert fred_by_id - - assert fred_by_id.name == "Fred" - - -def test_count(mongo_client, example_dataset_many): - """Should return a count for documents matching the given query""" - - # Count all dragons - count = ComplexDragonSpec.count() - assert count == 3 - - # Count dragons that are cold or fire drakes - count = ComplexDragonSpec.count(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert count == 2 - - # Count dragons born after 1980 - count = ComplexDragonSpec.count(Q.dob >= datetime(1981, 1, 1)) - assert count == 1 - - -def test_ids(mongo_client, example_dataset_many): - """Should return a list of ids for documents matching the given query""" - - # Ids for all dragons - ids = ComplexDragonSpec.ids() - assert len(ids) == 3 - - # Ids for dragons that are cold or fire drakes - ids = ComplexDragonSpec.ids(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert len(ids) == 2 - - # Ids for dragons born after 1980 - ids = ComplexDragonSpec.ids(Q.dob >= datetime(1981, 1, 1)) - assert len(ids) == 1 - - -def test_one(mongo_client, example_dataset_many): - """Should return a the first document that matches the given query""" - - # Select the first matching dragon - burt = ComplexDragonSpec.one() - assert burt - assert burt.name == "Burt" - - # Sort the results so we select the last matching dragon - albert = ComplexDragonSpec.one(sort=[("_id", DESC)]) - assert albert - assert albert.name == "Albert" - - # Select the first dragon who's a fire-drake - fred = ComplexDragonSpec.one(Q.breed == "Fire-drake") - assert fred - assert fred.name == "Fred" - - # Select a dragon with a different projection - burt = ComplexDragonSpec.one(projection={"name": True}) - assert burt - assert burt.name == "Burt" - assert burt.breed is None - - -def test_many(mongo_client, example_dataset_many): - """Should return all documents that match the given query""" - - # Select all dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - assert len(dragons) == 3 - assert dragons[0].name == "Burt" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Albert" - - # Select all dragons ordered by date of birth (youngest to oldest) - dragons = ComplexDragonSpec.many(sort=[("dob", DESC)]) - - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Burt" - - # Select only dragons born after 1980 ordered by date of birth (youngest to - # oldest). - dragons = ComplexDragonSpec.many(Q.dob > datetime(1980, 1, 1), sort=[("dob", DESC)]) - - assert len(dragons) == 2 - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - - # Select all dragons with a different projection - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)], projection={"name": True}) - - assert dragons[0].name == "Burt" - assert dragons[0].breed is None - assert dragons[1].name == "Fred" - assert dragons[1].breed is None - assert dragons[2].name == "Albert" - assert dragons[2].breed is None - - -def test_projection(mongo_client, example_dataset_one): - """Should allow references and subspecs to be projected""" - - # Select our complex dragon called burt - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - inventory = Inventory(gold=1000, skulls=100) - - # Test list of references - burt.misc = LairSpec.many() - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$ref": LairSpec}}) - - assert burt - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].name == "Cave" - - # Test dictionary of references - burt.misc = {"cave": LairSpec.one()} - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$ref": LairSpec}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["cave"].name == "Cave" - - # Test list of sub-specs - burt.misc = [inventory] - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$sub": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].skulls == 100 - - # Test dict of sub-specs - burt.misc = {"spare": inventory} - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$sub.": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["spare"].skulls == 100 - - -def test_timestamp_insert(mongo_client): - """ - Should assign a timestamp to the `created` and `modified` field for a - document. - """ - - # Assign a the timestamp helper to the insert event - MonitoredDragonSpec.listen("insert", MonitoredDragonSpec.timestamp_insert) - - # Insert a monitored dragon in the database - dragon = MonitoredDragonSpec(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a created/modified timestamp set - assert (dragon.created - now_tz) < timedelta(seconds=1) - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.created - now) < timedelta(seconds=1) - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_timestamp_update(mongo_client): - """@@ Should assign a timestamp to the `modified` field for a document""" - - # Assign a the timestamp helper to the insert event - MonitoredDragonSpec.listen("insert", MonitoredDragonSpec.timestamp_insert) - MonitoredDragonSpec.listen("update", MonitoredDragonSpec.timestamp_update) - - # Insert a monitored dragon in the database - dragon = MonitoredDragonSpec(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a modified timestamp set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.modified - now) < timedelta(seconds=1) - - # Wait a couple of seconds and then update the dragon - sleep(2) - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.breed = "Fire-drake" - dragon.update("breed", "modified") - - # Check a new modified date has been set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - dragon.reload() - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_cascade(mongo_client, example_dataset_many): - """Should apply a cascading delete""" - - # Listen for delete events against dragons and delete any associated lair at - # the same time. - def on_deleted(sender, specs): - ComplexDragonSpec.cascade(LairSpec, "lair", specs) - - ComplexDragonSpec.listen("deleted", on_deleted) - - # Delete a dragon and check the associated lair is also deleted - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.delete() - lair = LairSpec.by_id(burt.lair._id) - assert lair is None - - -def test_nullify(mongo_client, example_dataset_many): - """Should nullify a reference field""" - - # Listen for delete events against lairs and nullify the lair field against - # associated dragons - def on_deleted(sender, specs): - LairSpec.nullify(ComplexDragonSpec, "lair", specs) - - LairSpec.listen("deleted", on_deleted) - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = LairSpec.one(Q.name == "Cave") - assert lair - lair.delete() - - burt = ComplexDragonSpec.one(Q.name == "Burt", projection=None) - assert burt - assert burt.lair is None - - -def test_pull(mongo_client, example_dataset_many): - """Should pull references from a list field""" - - # Listen for delete events against lairs and pull any deleted lair from the - # associated dragons. For the sake of the tests here we're storing multiple - # lairs against the lair attribute instead of the intended one. - def on_deleted(sender, specs): - LairSpec.pull(ComplexDragonSpec, "visited_lairs", specs) - - LairSpec.listen("deleted", on_deleted) - - # List Burt stay in a few lairs - castle = LairSpec.one(Q.name == "Castle") - burt = ComplexDragonSpec.one(Q.name == "Burt") - - assert castle - assert burt - - burt.lair = castle - burt.visited_lairs.append(castle) - burt.update() - burt.reload() - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = LairSpec.one(Q.name == "Castle") - assert lair - lair.delete() - burt.reload(projection=None) - assert burt.visited_lairs == [] - - -def test_listen(mongo_client): - """Should add a callback for a signal against the class""" - - # Create a mocked functions for every event that can be triggered for a - # spec. - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - def on_inserted(sender, specs): - mock.inserted(sender, specs) - - def on_update(sender, specs): - mock.update(sender, specs) - - def on_updated(sender, specs): - mock.updated(sender, specs) - - def on_delete(sender, specs): - mock.delete(sender, specs) - - def on_deleted(sender, specs): - mock.deleted(sender, specs) - - # Listen for all events triggered by specs - DragonSpec.listen("insert", on_insert) - DragonSpec.listen("inserted", on_inserted) - DragonSpec.listen("update", on_update) - DragonSpec.listen("updated", on_updated) - DragonSpec.listen("delete", on_delete) - DragonSpec.listen("deleted", on_deleted) - - # Trigger all the events - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - burt.breed = "Fire-drake" - burt.update() - burt.delete() - - # Check each function was called - assert mock.insert.called - assert mock.inserted.called - assert mock.update.called - assert mock.updated.called - assert mock.delete.called - assert mock.deleted.called - - -def test_stop_listening(mongo_client): - """Should remove a callback for a signal against the class""" - - # Add an listener for the insert event - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - DragonSpec.listen("on_insert", on_insert) - - # Remove the listener for the insert event - DragonSpec.stop_listening("on_insert", on_insert) - - # Insert a dragon into the database and check that the insert event handler - # isn't called. - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - - assert mock.insert.called is False - - -def test_get_collection(mongo_client): - """Return a reference to the database collection for the class""" - assert DragonSpec.get_collection() == mongo_client["mongospecs_test"]["dragon"] - - -def test_get_db(mongo_client): - """Return the database for the collection""" - assert DragonSpec.get_db() == mongo_client["mongospecs_test"] - - -def test_get_fields(mongo_client): - """Return the fields for the class""" - assert DragonSpec.get_fields() == {"_id", "name", "breed"} - - -# def test_get_private_fields(mongo_client): -# """Return the private fields for the class""" -# assert Dragon.get_private_fields() == {"breed"} - - -def test_flattern_projection(): - """Flattern projection""" - - projection, refs, subs = LairSpec._flatten_projection( - {"name": True, "inventory": {"$sub": Inventory, "gold": True, "secret_draw": {"$sub": Inventory, "gold": True}}} - ) - - assert projection == {"name": True, "inventory.gold": True, "inventory.secret_draw.gold": True} - - -def test_with_options(mongo_client): - """Flattern projection""" - - collection = DragonSpec.get_collection() - - with DragonSpec.with_options(read_preference=ReadPreference.SECONDARY): - assert DragonSpec.get_collection().read_preference == ReadPreference.SECONDARY - - with DragonSpec.with_options(read_preference=ReadPreference.PRIMARY_PREFERRED): - assert DragonSpec.get_collection().read_preference == ReadPreference.PRIMARY_PREFERRED - - assert DragonSpec.get_collection().read_preference == ReadPreference.SECONDARY - - assert collection.read_preference == ReadPreference.PRIMARY diff --git a/tests/msgspec/test_frames.py b/tests/msgspec/test_frames.py deleted file mode 100644 index 76d05c8..0000000 --- a/tests/msgspec/test_frames.py +++ /dev/null @@ -1,790 +0,0 @@ -from datetime import datetime, timedelta, timezone -from time import sleep -from unittest.mock import Mock - -from bson.objectid import ObjectId -from msgspec import UNSET -from pymongo import ReadPreference - -from mongospecs import ASC, DESC, In, Q - -from .fixtures import example_dataset_many, example_dataset_one, mongo_client # noqa -from .models import ComplexDragon, Dragon, Inventory, Lair, MonitoredDragon - -# Tests - - -def test_spec(): - """Should create a new Dragon instance""" - - # Passing initial values - burt = Dragon(name="Burt", breed="Cold-drake") - assert burt.name == "Burt" - assert burt.breed == "Cold-drake" - assert isinstance(burt, Dragon) - - -def test_dot_notation(): - """ - Should allow access to read and set document values using do notation. - """ - - # Simple set/get - burt = Dragon(name="Burt", breed="Cold-drake") - - assert burt.name == "Burt" - burt.name = "Fred" - assert burt.name == "Fred" - - # SubSpec (embedded document get/set) - inventory = Inventory(gold=1000, skulls=100) - - cave = Lair(name="Cave", inventory=inventory) - - assert cave.inventory.gold == 1000 - cave.inventory.gold += 100 - assert cave.inventory.gold == 1100 - - -def test_equal(mongo_client): - """Should compare the equality of two Spec instances by Id""" - - # Create some dragons - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - - fred = Dragon(name="Fred", breed="Fire-drake") - fred.insert() - - # Test equality - assert burt != fred - assert burt == burt - - -def test_python_sort(mongo_client): - """Should sort a list of Spec instances by their Ids""" - - # Create some dragons - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - - fred = Dragon(name="Fred", breed="Fire-drake") - fred.insert() - - albert = Dragon(name="Albert", breed="Stone dragon") - albert.insert() - - # Test sorting by Id - assert sorted([albert, burt, fred]) == [burt, fred, albert] - - -def test_to_json_type(mongo_client, example_dataset_one): - """ - Should return a dictionary for the document with all values converted to - JSON safe types. All private fields should be excluded. - """ - - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - cave = burt.lair - - burt_json = burt.to_json_type() - - assert burt_json == { - "_id": str(burt._id), - "name": "Burt", - "breed": "Cold-drake", - "dob": "1979-06-11T00:00:00", - "traits": ["irritable", "narcissistic"], - "lair": {"_id": str(cave._id), "name": "Cave", "inventory": {"gold": 1000, "skulls": 100}}, - "visited_lairs": [], - "misc": "", - } - - -def test_insert(mongo_client): - """Should insert a document into the database""" - - # Create some convoluted data to insert - inventory = Inventory(gold=1000, skulls=100) - - cave = Lair(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragon( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Test the document now has an Id - assert burt._id is not None - - # Get the document from the database and check it's values - burt.reload() - - assert burt.name == "Burt" - assert burt.dob == datetime(1979, 6, 11) - assert burt.breed == "Cold-drake" - assert burt.traits == ["irritable", "narcissistic"] - assert burt.lair.name == "Cave" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Test insert where we specify the `_id` value - _id = ObjectId() - albert = Dragon(_id=_id, name="Albert", breed="Stone dragon") - albert.insert() - - assert albert._id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_unset(mongo_client, example_dataset_one): - """Should unset fields against a document on the database""" - - # Unset name and breed - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - burt.unset("breed") - - assert burt.name == "Burt" - assert burt.breed == UNSET - assert "breed" not in burt.to_json_type() - - burt.reload() - - assert burt.name == "Burt" - assert burt.breed == UNSET - assert "breed" not in burt.to_json_type() - - -def test_update(mongo_client, example_dataset_one): - """Should update a document on the database""" - - # Update all values - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - burt.name = "Jess" - burt.breed = "Fire-drake" - burt.traits = ["gentle", "kind"] - burt.update() - - burt.reload() - - assert burt.name == "Jess" - assert burt.breed == "Fire-drake" - assert burt.traits == ["gentle", "kind"] - - # Selective update - burt.lair.name = "Castle" - burt.lair.inventory.gold += 100 - burt.lair.inventory.skulls = 0 - burt.lair.update("name", "inventory.skulls") - - burt.reload() - - assert burt.lair.name == "Castle" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 0 - - -def test_upsert(mongo_client): - """ - Should update or insert a document on the database depending on whether or - not it already exists. - """ - - # Insert - burt = Dragon(name="Burt", breed="Cold-drake") - burt.upsert() - id = burt._id - burt.reload() - - # Update - burt.upsert() - burt.reload() - - assert burt._id == id - - # Test upsert where we specify the `_id` value - _id = ObjectId() - albert = Dragon(_id=_id, name="Albert", breed="Stone dragon") - albert.upsert() - - assert albert._id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_delete(mongo_client, example_dataset_one): - """Should delete a document from the database""" - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - burt.delete() - burt = burt.by_id(burt._id) - - assert burt is None - - -def test_insert_many(mongo_client): - """Should insert multiple documents records into the database""" - - # Create some convoluted data to insert - burt = Dragon(name="Burt", breed="Cold-drake") - - fred = Dragon(name="Fred", breed="Fire-drake") - - albert = Dragon(name="Albert", breed="Stone dragon") - - burt.insert_many([burt, fred, albert]) - - # Check 3 dragons have been created - assert Dragon.count() == 3 - - # Check the details for each dragon - dragons = Dragon.many(sort=[("_id", ASC)]) - assert dragons[0].name == "Burt" - assert dragons[0].breed == "Cold-drake" - assert dragons[1].name == "Fred" - assert dragons[1].breed == "Fire-drake" - assert dragons[2].name == "Albert" - assert dragons[2].breed == "Stone dragon" - - -def test_unset_many(mongo_client, example_dataset_many): - """Should unset fields against multiple documents on the database""" - - # Unset name and breed - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - for dragon in dragons: - dragon.unset("name", "breed") - - for dragon in dragons: - assert dragon.name == UNSET - assert dragon.breed == UNSET - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - dragon.reload() - - assert dragon.name == UNSET - assert dragon.breed == UNSET - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - -def test_update_many(mongo_client, example_dataset_many): - """Should update mulitple documents on the database""" - - # Select all the dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - # Give each dragon a second name - for dragon in dragons: - dragon.name += " " + dragon.name + "son" - - # Update all values for all the dragons in one go - ComplexDragon.update_many(dragons) - - # Reload all the dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Make various changes to the dragons only some of which we want to stick - for dragon in dragons: - dragon.name = dragon.name.split(" ")[0] - assert dragon.breed - dragon.breed = dragon.breed.replace("-", "_") - dragon.breed = dragon.breed.replace(" ", "_") - dragon.lair.inventory.gold += 100 - dragon.lair.inventory.skulls += 10 - - # Update selected values for all the dragons in one go - Lair.update_many([d.lair for d in dragons], "inventory.gold") - ComplexDragon.update_many(dragons, "breed") - - # Reload all the dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - # Names should be the same - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Breeds should have changed - assert dragons[0].breed == "Cold_drake" - assert dragons[1].breed == "Fire_drake" - assert dragons[2].breed == "Stone_dragon" - - # Gold should have changed - assert dragons[0].lair.inventory.gold == 1100 - assert dragons[1].lair.inventory.gold == 2100 - assert dragons[2].lair.inventory.gold == 3100 - - # Skulls should be the same - assert dragons[0].lair.inventory.skulls == 100 - assert dragons[1].lair.inventory.skulls == 200 - assert dragons[2].lair.inventory.skulls == 300 - - -def test_delete_many(mongo_client, example_dataset_many): - """Should delete mulitple documents from the database""" - - # Select all the dragons - dragons = ComplexDragon.many() - - # Delete all of them :( - ComplexDragon.delete_many(dragons) - - # Check there are no remaining dragons - assert ComplexDragon.count() == 0 - - -def test_reload(mongo_client, example_dataset_one): - """Should reload the current document's values from the database""" - - # Select Burt from the database - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - # Change some values and reload - burt.name = "Fred" - burt.lair.inventory = Inventory(gold=500, skulls=50) - burt.reload() - - # Check Burt is himself again - assert burt.name == "Burt" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Reload with a different projection - burt.reload(projection={"name": True}) - - # Check Burt has values for the projection specified - assert burt.name == "Burt" - - ## Should reload reset attributes based on projection? - # assert burt.breed == None - # assert burt.lair == None - - -def test_by_id(mongo_client, example_dataset_many): - """Should return a document by Id from the database""" - - # Get an Id for a dragon - fred = ComplexDragon.one(Q.name == "Fred") - assert fred - - # Load a dragon using the Id and make sure it's the same - fred_by_id = ComplexDragon.by_id(fred._id) - assert fred_by_id - - assert fred_by_id.name == "Fred" - - -def test_count(mongo_client, example_dataset_many): - """Should return a count for documents matching the given query""" - - # Count all dragons - count = ComplexDragon.count() - assert count == 3 - - # Count dragons that are cold or fire drakes - count = ComplexDragon.count(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert count == 2 - - # Count dragons born after 1980 - count = ComplexDragon.count(Q.dob >= datetime(1981, 1, 1)) - assert count == 1 - - -def test_ids(mongo_client, example_dataset_many): - """Should return a list of ids for documents matching the given query""" - - # Ids for all dragons - ids = ComplexDragon.ids() - assert len(ids) == 3 - - # Ids for dragons that are cold or fire drakes - ids = ComplexDragon.ids(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert len(ids) == 2 - - # Ids for dragons born after 1980 - ids = ComplexDragon.ids(Q.dob >= datetime(1981, 1, 1)) - assert len(ids) == 1 - - -def test_one(mongo_client, example_dataset_many): - """Should return a the first document that matches the given query""" - - # Select the first matching dragon - burt = ComplexDragon.one() - assert burt - assert burt.name == "Burt" - - # Sort the results so we select the last matching dragon - albert = ComplexDragon.one(sort=[("_id", DESC)]) - assert albert - assert albert.name == "Albert" - - # Select the first dragon who's a fire-drake - fred = ComplexDragon.one(Q.breed == "Fire-drake") - assert fred - assert fred.name == "Fred" - - # Select a dragon with a different projection - burt = ComplexDragon.one(projection={"name": True}) - assert burt - assert burt.name == "Burt" - assert burt.breed is None - - -def test_many(mongo_client, example_dataset_many): - """Should return all documents that match the given query""" - - # Select all dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - assert len(dragons) == 3 - assert dragons[0].name == "Burt" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Albert" - - # Select all dragons ordered by date of birth (youngest to oldest) - dragons = ComplexDragon.many(sort=[("dob", DESC)]) - - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Burt" - - # Select only dragons born after 1980 ordered by date of birth (youngest to - # oldest). - dragons = ComplexDragon.many(Q.dob > datetime(1980, 1, 1), sort=[("dob", DESC)]) - - assert len(dragons) == 2 - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - - # Select all dragons with a different projection - dragons = ComplexDragon.many(sort=[("_id", ASC)], projection={"name": True}) - - assert dragons[0].name == "Burt" - assert dragons[0].breed is None - assert dragons[1].name == "Fred" - assert dragons[1].breed is None - assert dragons[2].name == "Albert" - assert dragons[2].breed is None - - -def test_projection(mongo_client, example_dataset_one): - """Should allow references and subspecs to be projected""" - - # Select our complex dragon called burt - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - inventory = Inventory(gold=1000, skulls=100) - - # Test list of references - burt.misc = Lair.many() - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$ref": Lair}}) - - assert burt - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].name == "Cave" - - # Test dictionary of references - burt.misc = {"cave": Lair.one()} - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$ref": Lair}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["cave"].name == "Cave" - - # Test list of sub-specs - burt.misc = [inventory] - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$sub": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].skulls == 100 - - # Test dict of sub-specs - burt.misc = {"spare": inventory} - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$sub.": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["spare"].skulls == 100 - - -def test_timestamp_insert(mongo_client): - """ - Should assign a timestamp to the `created` and `modified` field for a - document. - """ - - # Assign a the timestamp helper to the insert event - MonitoredDragon.listen("insert", MonitoredDragon.timestamp_insert) - - # Insert a monitored dragon in the database - dragon = MonitoredDragon(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a created/modified timestamp set - assert (dragon.created - now_tz) < timedelta(seconds=1) - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.created - now) < timedelta(seconds=1) - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_timestamp_update(mongo_client): - """@@ Should assign a timestamp to the `modified` field for a document""" - - # Assign a the timestamp helper to the insert event - MonitoredDragon.listen("insert", MonitoredDragon.timestamp_insert) - MonitoredDragon.listen("update", MonitoredDragon.timestamp_update) - - # Insert a monitored dragon in the database - dragon = MonitoredDragon(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a modified timestamp set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.modified - now) < timedelta(seconds=1) - - # Wait a couple of seconds and then update the dragon - sleep(2) - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.breed = "Fire-drake" - dragon.update("breed", "modified") - - # Check a new modified date has been set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - dragon.reload() - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_cascade(mongo_client, example_dataset_many): - """Should apply a cascading delete""" - - # Listen for delete events against dragons and delete any associated lair at - # the same time. - def on_deleted(sender, specs): - ComplexDragon.cascade(Lair, "lair", specs) - - ComplexDragon.listen("deleted", on_deleted) - - # Delete a dragon and check the associated lair is also deleted - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - burt.delete() - lair = Lair.by_id(burt.lair._id) - assert lair is None - - -def test_nullify(mongo_client, example_dataset_many): - """Should nullify a reference field""" - - # Listen for delete events against lairs and nullify the lair field against - # associated dragons - def on_deleted(sender, specs): - Lair.nullify(ComplexDragon, "lair", specs) - - Lair.listen("deleted", on_deleted) - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = Lair.one(Q.name == "Cave") - assert lair - lair.delete() - - burt = ComplexDragon.one(Q.name == "Burt", projection=None) - assert burt - assert burt.lair is None - - -def test_pull(mongo_client, example_dataset_many): - """Should pull references from a list field""" - - # Listen for delete events against lairs and pull any deleted lair from the - # associated dragons. For the sake of the tests here we're storing multiple - # lairs against the lair attribute instead of the intended one. - def on_deleted(sender, specs): - Lair.pull(ComplexDragon, "visited_lairs", specs) - - Lair.listen("deleted", on_deleted) - - # List Burt stay in a few lairs - castle = Lair.one(Q.name == "Castle") - burt = ComplexDragon.one(Q.name == "Burt") - - assert castle - assert burt - - burt.lair = castle - burt.visited_lairs.append(castle) - burt.update() - burt.reload() - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = Lair.one(Q.name == "Castle") - assert lair - lair.delete() - burt.reload(projection=None) - assert burt.visited_lairs == [] - - -def test_listen(mongo_client): - """Should add a callback for a signal against the class""" - - # Create a mocked functions for every event that can be triggered for a - # spec. - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - def on_inserted(sender, specs): - mock.inserted(sender, specs) - - def on_update(sender, specs): - mock.update(sender, specs) - - def on_updated(sender, specs): - mock.updated(sender, specs) - - def on_delete(sender, specs): - mock.delete(sender, specs) - - def on_deleted(sender, specs): - mock.deleted(sender, specs) - - # Listen for all events triggered by specs - Dragon.listen("insert", on_insert) - Dragon.listen("inserted", on_inserted) - Dragon.listen("update", on_update) - Dragon.listen("updated", on_updated) - Dragon.listen("delete", on_delete) - Dragon.listen("deleted", on_deleted) - - # Trigger all the events - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - burt.breed = "Fire-drake" - burt.update() - burt.delete() - - # Check each function was called - assert mock.insert.called - assert mock.inserted.called - assert mock.update.called - assert mock.updated.called - assert mock.delete.called - assert mock.deleted.called - - -def test_stop_listening(mongo_client): - """Should remove a callback for a signal against the class""" - - # Add an listener for the insert event - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - Dragon.listen("on_insert", on_insert) - - # Remove the listener for the insert event - Dragon.stop_listening("on_insert", on_insert) - - # Insert a dragon into the database and check that the insert event handler - # isn't called. - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - - assert mock.insert.called is False - - -def test_get_collection(mongo_client): - """Return a reference to the database collection for the class""" - assert Dragon.get_collection() == mongo_client["mongospecs_test"]["Dragon"] - - -def test_get_db(mongo_client): - """Return the database for the collection""" - assert Dragon.get_db() == mongo_client["mongospecs_test"] - - -def test_get_fields(mongo_client): - """Return the fields for the class""" - assert Dragon.get_fields() == {"_id", "name", "breed"} - - -# def test_get_private_fields(mongo_client): -# """Return the private fields for the class""" -# assert Dragon.get_private_fields() == {"breed"} - - -def test_flattern_projection(): - """Flattern projection""" - - projection, refs, subs = Lair._flatten_projection( - {"name": True, "inventory": {"$sub": Inventory, "gold": True, "secret_draw": {"$sub": Inventory, "gold": True}}} - ) - - assert projection == {"name": True, "inventory.gold": True, "inventory.secret_draw.gold": True} - - -def test_with_options(mongo_client): - """Flattern projection""" - - collection = Dragon.get_collection() - - with Dragon.with_options(read_preference=ReadPreference.SECONDARY): - assert Dragon.get_collection().read_preference == ReadPreference.SECONDARY - - with Dragon.with_options(read_preference=ReadPreference.PRIMARY_PREFERRED): - assert Dragon.get_collection().read_preference == ReadPreference.PRIMARY_PREFERRED - - assert Dragon.get_collection().read_preference == ReadPreference.SECONDARY - - assert collection.read_preference == ReadPreference.PRIMARY diff --git a/tests/msgspec/test_pagination.py b/tests/msgspec/test_pagination.py deleted file mode 100644 index 1ebc367..0000000 --- a/tests/msgspec/test_pagination.py +++ /dev/null @@ -1,184 +0,0 @@ -import pytest - -from mongospecs import DESC, Empty, Paginator, Q -from mongospecs.msgspec import Spec - -from .fixtures import mongo_client # noqa - -# Classes - - -class Orc(Spec): - """ - An orc. - """ - - _collection = "Orc" - name: str = Empty - - -@pytest.fixture(scope="function") -def example_dataset(mongo_client): - """Connect to the database and create a set of example data (orcs)""" - - # Create 1,000 orcs - for i in range(0, 1000): - Orc(name="Orc {0:04d}".format(i)).insert() - - return Spec._client - - -# Paginator tests - - -def test_paginator(example_dataset): - """Paginate all orcs""" - - # Paginate all orcs - paginator = Paginator(Orc) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 50 - assert paginator.page_numbers == range(1, 51) - - i = 0 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - -def test_paginator_with_filter(example_dataset): - """Paginate the last 100 orcs using a filter""" - - # Paginate all orcs - paginator = Paginator(Orc, Q.name >= "Orc 0900") - - # Check the results are as expected - assert paginator.item_count == 100 - assert paginator.page_count == 5 - assert paginator.page_numbers == range(1, 6) - - i = 900 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - -def test_paginator_with_sort(example_dataset): - """Paginate all orcs but sort them in reverse""" - - # Paginate all orcs - paginator = Paginator(Orc, filter_kwargs={"sort": [("name", DESC)]}) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 50 - assert paginator.page_numbers == range(1, 51) - - i = 999 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i -= 1 - - -def test_paginator_with_projection(example_dataset): - """Paginate all orcs but use a custom projection""" - - # Paginate all orcs - paginator = Paginator(Orc, filter_kwargs={"projection": {"name": False}}) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 50 - assert paginator.page_numbers == range(1, 51) - - for page in paginator: - for orc in page: - assert orc._id is not None - assert orc.name is Empty - - -def test_paginator_with_per_page(example_dataset): - """Paginate all orcs but use a custom per page value""" - - # Paginate all orcs - paginator = Paginator(Orc, per_page=100) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 10 - assert paginator.page_numbers == range(1, 11) - - i = 0 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - -def test_paginator_with_orphans(example_dataset): - """Paginate all orcs but allow up to 2 orphan results""" - - # Paginate all orcs - paginator = Paginator(Orc, Q.name >= "Orc 0918", orphans=2) - - # Check the results are as expected - assert paginator.item_count == 82 - assert paginator.page_count == 4 - assert paginator.page_numbers == range(1, 5) - - i = 918 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - # Check the last page has 22 items - assert len(paginator[4]) == 22 - - -# Page tests - - -def test_page(example_dataset): - """Test the results or extracting a paginated page""" - - # Paginate all orcs - paginator = Paginator(Orc) - - # First page - page = paginator[1] - assert page.prev is None - assert page.next == 2 - assert page.number == 1 - assert len(page) == paginator.per_page - - i = 0 - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - # Second page - page = paginator[2] - assert page.prev == 1 - assert page.next == 3 - assert page.number == 2 - - i = 20 - for orc in page.items: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - # Last page - page = paginator[paginator.page_count] - assert page.prev == paginator.page_count - 1 - assert page.next is None - assert page.number == paginator.page_count - - # Find the offset of Orc 992 - orc = Orc.one(Q.name == "Orc 0992") - assert page.get_offset(orc) == 992 diff --git a/tests/msgspec/test_queries.py b/tests/msgspec/test_queries.py deleted file mode 100644 index bb39352..0000000 --- a/tests/msgspec/test_queries.py +++ /dev/null @@ -1,141 +0,0 @@ -from bson import ObjectId - -from mongospecs import All, And, ElemMatch, Exists, In, Nor, Not, NotIn, Or, Q, Size, SortBy, Type -from mongospecs.msgspec import Spec -from mongospecs.utils import to_refs - - -def test_q(): - """Should generate a field path""" - - # Single - assert Q.foo._path == "foo" - - # Path - assert Q.foo.bar._path == "foo.bar" - - # Path with index - assert Q.foo.bar[2]._path == "foo.bar.2" - - # Path with `_path` using item getter - assert Q.foo.bar[2]["_path"]._path == "foo.bar.2._path" - - -# Conditions as operator overrides - - -def test_equal(): - """Should generate an equals condition""" - assert (Q.foo == 123).to_dict() == {"foo": 123} - - -def test_greater_than_or_equal(): - """Should generate a greater than or equal condition""" - assert (Q.foo >= 123).to_dict() == {"foo": {"$gte": 123}} - - -def test_greater_than(): - """Should generate a greater than condition""" - assert (Q.foo > 123).to_dict() == {"foo": {"$gt": 123}} - - -def test_less_than_or_equal(): - """Should generate a less than or equal condition""" - assert (Q.foo <= 123).to_dict() == {"foo": {"$lte": 123}} - - -def test_less_than(): - """Should generate a less than condition""" - assert (Q.foo < 123).to_dict() == {"foo": {"$lt": 123}} - - -def test_not_equal(): - """Should generate a not equals condition""" - assert (Q.foo != 123).to_dict() == {"foo": {"$ne": 123}} - - -# Conditions through functions - - -def test_all(): - """Should generate a contains all values condition""" - assert All(Q.foo, [1, 2, 3]).to_dict() == {"foo": {"$all": [1, 2, 3]}} - - -def test_elem_match(): - """Should generate an element match condition""" - condition = ElemMatch(Q.foo, Q > 1, Q < 5) - assert condition.to_dict() == {"foo": {"$elemMatch": {"$gt": 1, "$lt": 5}}} - - -def test_exists(): - """Should generate an exists test condition""" - assert Exists(Q.foo, True).to_dict() == {"foo": {"$exists": True}} - - -def test_in(): - """Should generate a contains condition""" - assert In(Q.foo, [1, 2, 3]).to_dict() == {"foo": {"$in": [1, 2, 3]}} - - -def test_not(): - """Should generate a logical not condition""" - assert Not(Q.foo == 123).to_dict() == {"foo": {"$not": {"$eq": 123}}} - - -def test_not_in(): - """Should generate a does not contain condition""" - assert NotIn(Q.foo, [1, 2, 3]).to_dict() == {"foo": {"$nin": [1, 2, 3]}} - - -def test_size(): - """Should generate a array value size matches condition""" - assert Size(Q.foo, 2).to_dict() == {"foo": {"$size": 2}} - - -def test_type(): - """Should generate a equals instance of condition""" - assert Type(Q.foo, 1).to_dict() == {"foo": {"$type": 1}} - - -# Groups - - -def test_and(): - """Should group one or more conditions by a logical and""" - assert And(Q.foo == 123, Q.bar != 456).to_dict() == {"$and": [{"foo": 123}, {"bar": {"$ne": 456}}]} - - -def test_or(): - """Should group one or more conditions by a logical or""" - assert Or(Q.foo == 123, Q.bar != 456).to_dict() == {"$or": [{"foo": 123}, {"bar": {"$ne": 456}}]} - - -def test_nor(): - """Should group one or more conditions by a logical nor""" - assert Nor(Q.foo == 123, Q.bar != 456).to_dict() == {"$nor": [{"foo": 123}, {"bar": {"$ne": 456}}]} - - -# Sorting - - -def test_sort_by(): - """Should return sort instructions for a list of `Q` instances""" - assert SortBy(Q.dob.desc, Q.name) == [("dob", -1), ("name", 1)] - - -# Utils - - -def test_to_refs(): - """Should convert all Spec instances within a value to Ids""" - - ids = [ObjectId()] * 2 - # Convert a single Spec instance - assert to_refs(Spec(_id=ids[0])) == ids[0] - - # Convert a list of Spec instances - assert to_refs([Spec(_id=ids[0]), Spec(_id=ids[1])]) == ids - - # Convert a dictionary of Spec instances - assert to_refs({1: Spec(_id=ids[0]), 2: Spec(_id=ids[1])}) == {1: ids[0], 2: ids[1]} diff --git a/tests/msgspec/test_spec.py b/tests/msgspec/test_spec.py deleted file mode 100644 index adc71dd..0000000 --- a/tests/msgspec/test_spec.py +++ /dev/null @@ -1,8 +0,0 @@ -from mongospecs.msgspec import Spec - - -class Country(Spec): - _collection = "country" - - name: str - population: int diff --git a/tests/pydantic/__init__.py b/tests/pydantic/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/tests/pydantic/adapter_models.py b/tests/pydantic/adapter_models.py deleted file mode 100644 index 5e01886..0000000 --- a/tests/pydantic/adapter_models.py +++ /dev/null @@ -1,99 +0,0 @@ -from datetime import datetime, timezone -from typing import Any, Optional - -from pydantic import BaseModel, Field, SerializeAsAny - -from mongospecs.pydantic import SubSpec - -__all__ = [ - "Dragon", - "Inventory", - "Lair", - "ComplexDragon", - "MonitoredDragon", -] - - -class Dragon(BaseModel): - """ - A dragon. - """ - - name: str - breed: Optional[str] = None - - def _get_dummy_prop(self): - return self._dummy_prop - - def _set_dummy_prop(self, value): - self._dummy_prop = True - - _dummy_prop = property(_get_dummy_prop, _set_dummy_prop) - - -class Inventory(SubSpec): - """ - An inventory of items kept within a lair. - """ - - gold: int = 0 - skulls: int = 0 - - -class Lair(BaseModel): - """ - A lair in which a dragon resides. - """ - - name: str = "" - inventory: Inventory = Field(default_factory=Inventory) - - -class ComplexDragon(Dragon): - _collection = "ComplexDragon" - - dob: Optional[datetime] = None - - # SerializeAsAny is needed: - # * https://github.com/pydantic/pydantic/issues/7326 - # * https://github.com/pydantic/pydantic/issues/6423 - # * https://github.com/pydantic/pydantic/issues/6632 - lair: SerializeAsAny[Lair] = Field(default_factory=Lair) - visited_lairs: list[Lair] = [] - traits: list[str] = [] - misc: Optional[Any] = "" - - _default_projection = {"lair": {"$ref": Lair, "inventory": {"$sub": Inventory}}} - - -class MonitoredDragon(Dragon): - created: Optional[datetime] = None - modified: Optional[datetime] = None - - @staticmethod - def timestamp_insert(sender, specs): - """ - Timestamp the created and modified fields for all documents. This method - should be bound to a spec class like so: - - ``` - MySpecClass.listen('insert', MySpecClass.timestamp_insert) - ``` - """ - for spec in specs: - timestamp = datetime.now(timezone.utc) - spec.created = timestamp - spec.modified = timestamp - - @staticmethod - def timestamp_update(sender, specs): - """ - Timestamp the modified field for all documents. This method should be - bound to a spec class like so: - - ``` - MySpecClass.listen('update', MySpecClass.timestamp_update) - ``` - """ - for spec in specs: - spec.modified = datetime.now(timezone.utc) diff --git a/tests/pydantic/fixtures.py b/tests/pydantic/fixtures.py deleted file mode 100644 index c65df28..0000000 --- a/tests/pydantic/fixtures.py +++ /dev/null @@ -1,69 +0,0 @@ -from datetime import datetime - -import pytest -from mongomock import MongoClient - -from mongospecs.pydantic import Spec - -from .models import ComplexDragon, Inventory, Lair - -# Fixtures - - -@pytest.fixture -def mongo_client(request): - """Connect to the test database""" - - # Connect to mongodb and create a test database - Spec._client = MongoClient("mongodb://localhost:27017/mongospecs_test") - - yield Spec._client - - Spec._client.drop_database("mongospecs_test") - del Spec._client - - -@pytest.fixture -def example_dataset_one(request): - """Create an example set of data that can be used in testing""" - inventory = Inventory(gold=1000, skulls=100) - - cave = Lair(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragon( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - -@pytest.fixture -def example_dataset_many(request): - """Create an example set of data that can be used in testing""" - - # Burt - cave = Lair(name="Cave", inventory=Inventory(gold=1000, skulls=100)) - cave.insert() - - burt = ComplexDragon( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Fred - castle = Lair(name="Castle", inventory=Inventory(gold=2000, skulls=200)) - castle.insert() - - fred = ComplexDragon( - name="Fred", dob=datetime(1980, 7, 12), breed="Fire-drake", lair=castle, traits=["impulsive", "loyal"] - ) - fred.insert() - - # Fred - mountain = Lair(name="Mountain", inventory=Inventory(gold=3000, skulls=300)) - mountain.insert() - - albert = ComplexDragon( - name="Albert", dob=datetime(1981, 8, 13), breed="Stone dragon", lair=mountain, traits=["reclusive", "cunning"] - ) - albert.insert() diff --git a/tests/pydantic/models.py b/tests/pydantic/models.py deleted file mode 100644 index bed8c22..0000000 --- a/tests/pydantic/models.py +++ /dev/null @@ -1,94 +0,0 @@ -from datetime import datetime, timezone -from typing import Any, Optional - -from pydantic import Field - -from mongospecs.pydantic import Spec, SubSpec - -__all__ = [ - "Dragon", - "Inventory", - "Lair", - "ComplexDragon", - "MonitoredDragon", -] - - -class Dragon(Spec): - """ - A dragon. - """ - - name: str - breed: Optional[str] = None - - def _get_dummy_prop(self): - return self._dummy_prop - - def _set_dummy_prop(self, value): - self._dummy_prop = True - - _dummy_prop = property(_get_dummy_prop, _set_dummy_prop) - - -class Inventory(SubSpec): - """ - An inventory of items kept within a lair. - """ - - gold: int = 0 - skulls: int = 0 - - -class Lair(Spec): - """ - A lair in which a dragon resides. - """ - - name: str = "" - inventory: Inventory = Field(default_factory=Inventory) - - -class ComplexDragon(Dragon): - _collection = "ComplexDragon" - - dob: Optional[datetime] = None - lair: Lair = Field(default_factory=Lair) - visited_lairs: list[Lair] = [] - traits: list[str] = [] - misc: Optional[Any] = "" - - _default_projection = {"lair": {"$ref": Lair, "inventory": {"$sub": Inventory}}} - - -class MonitoredDragon(Dragon): - created: Optional[datetime] = None - modified: Optional[datetime] = None - - @staticmethod - def timestamp_insert(sender, specs): - """ - Timestamp the created and modified fields for all documents. This method - should be bound to a spec class like so: - - ``` - MySpecClass.listen('insert', MySpecClass.timestamp_insert) - ``` - """ - for spec in specs: - timestamp = datetime.now(timezone.utc) - spec.created = timestamp - spec.modified = timestamp - - @staticmethod - def timestamp_update(sender, specs): - """ - Timestamp the modified field for all documents. This method should be - bound to a spec class like so: - - ``` - MySpecClass.listen('update', MySpecClass.timestamp_update) - ``` - """ - for spec in specs: - spec.modified = datetime.now(timezone.utc) diff --git a/tests/pydantic/test_adapter.py b/tests/pydantic/test_adapter.py deleted file mode 100644 index 467c826..0000000 --- a/tests/pydantic/test_adapter.py +++ /dev/null @@ -1,865 +0,0 @@ -from datetime import datetime, timedelta, timezone -from time import sleep -from unittest.mock import Mock - -import pytest -from bson.objectid import ObjectId -from pydantic import BaseModel -from pymongo import ReadPreference - -from mongospecs import ASC, DESC, Empty, In, Q -from mongospecs.pydantic import Spec, SpecAdapter - -from .adapter_models import ComplexDragon, Dragon, Inventory, Lair, MonitoredDragon -from .fixtures import mongo_client # noqa - -LairSpec = SpecAdapter(Lair, collection="lair") -ComplexDragonSpec = SpecAdapter(ComplexDragon, collection="complexdragon") -ComplexDragonSpec._default_projection = {"lair": {"$ref": LairSpec, "inventory": {"$sub": Inventory}}} -MonitoredDragonSpec = SpecAdapter(MonitoredDragon, collection="monitoreddragon") - - -class DragonSpec(Dragon, Spec): - _collection = "dragon" - - -@pytest.fixture -def example_dataset_one(mongo_client): - """Create an example set of data that can be used in testing""" - inventory = Inventory(gold=1000, skulls=100) - - cave = LairSpec(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragonSpec( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - -@pytest.fixture -def example_dataset_many(mongo_client): - """Create an example set of data that can be used in testing""" - - # Burt - cave = LairSpec(name="Cave", inventory=Inventory(gold=1000, skulls=100)) - cave.insert() - - burt = ComplexDragonSpec( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Fred - castle = LairSpec(name="Castle", inventory=Inventory(gold=2000, skulls=200)) - castle.insert() - - fred = ComplexDragonSpec( - name="Fred", dob=datetime(1980, 7, 12), breed="Fire-drake", lair=castle, traits=["impulsive", "loyal"] - ) - fred.insert() - - # Fred - mountain = LairSpec(name="Mountain", inventory=Inventory(gold=3000, skulls=300)) - mountain.insert() - - albert = ComplexDragonSpec( - name="Albert", dob=datetime(1981, 8, 13), breed="Stone dragon", lair=mountain, traits=["reclusive", "cunning"] - ) - albert.insert() - - -def test_pydantic_adapter(mongo_client) -> None: - class Car(BaseModel): - """test""" - - make: str - model: str - year: int - - CarSpec = SpecAdapter(Car, collection="cars", client=mongo_client) - assert CarSpec.get_fields() == {"id", "make", "model", "year"} - car = CarSpec(make="test", model="test123", year=2000) - assert car.to_dict() == {"_id": None, "make": "test", "model": "test123", "year": 2000} - assert car.get_fields() == {"id", "make", "model", "year"} - - car.insert() - assert car.find({"make": "test"}) == [{"_id": car._id, "make": "test", "model": "test123", "year": 2000}] - assert car.to_dict() == {"_id": car._id, "make": "test", "model": "test123", "year": 2000} - - -def test_spec(): - """Should create a new Dragon instance""" - - # Passing initial values - burt = DragonSpec(name="Burt", breed="Cold-drake") - - assert burt.name == "Burt" - assert burt.breed == "Cold-drake" - assert isinstance(burt, Dragon) - - -def test_dot_notation(): - """ - Should allow access to read and set document values using do notation. - """ - - # Simple set/get - burt = DragonSpec(name="Burt", breed="Cold-drake") - - assert burt.name == "Burt" - burt.name = "Fred" - assert burt.name == "Fred" - - # SubSpec (embedded document get/set) - inventory = Inventory(gold=1000, skulls=100) - - cave = LairSpec(name="Cave", inventory=inventory) - - assert cave.inventory.gold == 1000 - cave.inventory.gold += 100 - assert cave.inventory.gold == 1100 - - -def test_equal(mongo_client): - """Should compare the equality of two Spec instances by Id""" - - # Create some dragons - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - - fred = DragonSpec(name="Fred", breed="Fire-drake") - fred.insert() - - # Test equality - assert burt != fred - assert burt == burt - - -def test_python_sort(mongo_client): - """Should sort a list of Spec instances by their Ids""" - - # Create some dragons - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - - fred = DragonSpec(name="Fred", breed="Fire-drake") - fred.insert() - - albert = DragonSpec(name="Albert", breed="Stone dragon") - albert.insert() - - # Test sorting by Id - assert sorted([albert, burt, fred]) == [burt, fred, albert] - - -def test_to_json_type(mongo_client, example_dataset_one): - """ - Should return a dictionary for the document with all values converted to - JSON safe types. All private fields should be excluded. - """ - - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - cave = burt.lair - - burt_json = burt.to_json_type() - - assert burt_json == { - "_id": str(burt._id), - "name": "Burt", - "breed": "Cold-drake", - "dob": "1979-06-11T00:00:00", - "traits": ["irritable", "narcissistic"], - "lair": {"_id": str(cave._id), "name": "Cave", "inventory": {"gold": 1000, "skulls": 100}}, - "visited_lairs": [], - "misc": "", - } - - -def test_insert(mongo_client): - """Should insert a document into the database""" - - # Create some convoluted data to insert - inventory = Inventory(gold=1000, skulls=100) - - cave = LairSpec(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragonSpec( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Test the document now has an Id - assert burt._id is not None - - # Get the document from the database and check it's values - burt.reload() - - assert burt.name == "Burt" - assert burt.dob == datetime(1979, 6, 11) - assert burt.breed == "Cold-drake" - assert burt.traits == ["irritable", "narcissistic"] - assert burt.lair.name == "Cave" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Test insert where we specify the `_id` value - _id = ObjectId() - albert = DragonSpec(_id=_id, name="Albert", breed="Stone dragon") - albert.insert() - - assert albert._id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_unset(mongo_client, example_dataset_one): - """Should unset fields against a document on the database""" - - # Unset name and breed - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.unset("breed") - - assert burt.name == "Burt" - assert burt.breed == Empty - assert "breed" not in burt.to_json_type() - - burt.reload() - - assert burt.name == "Burt" - assert burt.breed == Empty - assert "breed" not in burt.to_json_type() - - -def test_update(mongo_client, example_dataset_one): - """Should update a document on the database""" - - # Update all values - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.name = "Jess" - burt.breed = "Fire-drake" - burt.traits = ["gentle", "kind"] - burt.update() - - burt.reload() - - assert burt.name == "Jess" - assert burt.breed == "Fire-drake" - assert burt.traits == ["gentle", "kind"] - - # Selective update - burt.lair.name = "Castle" - burt.lair.inventory.gold += 100 - burt.lair.inventory.skulls = 0 - burt.lair.update("name", "inventory.skulls") - - burt.reload() - - assert burt.lair.name == "Castle" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 0 - - -def test_upsert(mongo_client): - """ - Should update or insert a document on the database depending on whether or - not it already exists. - """ - - # Insert - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.upsert() - id = burt._id - burt.reload() - - # Update - burt.upsert() - burt.reload() - - assert burt._id == id - - # Test upsert where we specify the `_id` value - _id = ObjectId() - albert = DragonSpec(_id=_id, name="Albert", breed="Stone dragon") - albert.upsert() - - assert albert._id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_delete(mongo_client, example_dataset_one): - """Should delete a document from the database""" - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.delete() - burt = burt.by_id(burt._id) - - assert burt is None - - -def test_insert_many(mongo_client): - """Should insert multiple documents records into the database""" - - # Create some convoluted data to insert - burt = DragonSpec(name="Burt", breed="Cold-drake") - - fred = DragonSpec(name="Fred", breed="Fire-drake") - - albert = DragonSpec(name="Albert", breed="Stone dragon") - - burt.insert_many([burt, fred, albert]) - - # Check 3 dragons have been created - assert DragonSpec.count() == 3 - - # Check the details for each dragon - dragons = DragonSpec.many(sort=[("_id", ASC)]) - assert dragons[0].name == "Burt" - assert dragons[0].breed == "Cold-drake" - assert dragons[1].name == "Fred" - assert dragons[1].breed == "Fire-drake" - assert dragons[2].name == "Albert" - assert dragons[2].breed == "Stone dragon" - - -def test_unset_many(mongo_client, example_dataset_many): - """Should unset fields against multiple documents on the database""" - - # Unset name and breed - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - for dragon in dragons: - dragon.unset("name", "breed") - - for dragon in dragons: - assert dragon.name == Empty - assert dragon.breed == Empty - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - dragon.reload() - - assert dragon.name == Empty - assert dragon.breed == Empty - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - -def test_update_many(mongo_client, example_dataset_many): - """Should update mulitple documents on the database""" - - # Select all the dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - # Give each dragon a second name - for dragon in dragons: - dragon.name += " " + dragon.name + "son" - - # Update all values for all the dragons in one go - ComplexDragonSpec.update_many(dragons) - - # Reload all the dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Make various changes to the dragons only some of which we want to stick - for dragon in dragons: - dragon.name = dragon.name.split(" ")[0] - assert dragon.breed - dragon.breed = dragon.breed.replace("-", "_") - dragon.breed = dragon.breed.replace(" ", "_") - dragon.lair.inventory.gold += 100 - dragon.lair.inventory.skulls += 10 - - # Update selected values for all the dragons in one go - LairSpec.update_many([d.lair for d in dragons], "inventory.gold") - ComplexDragonSpec.update_many(dragons, "breed") - - # Reload all the dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - # Names should be the same - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Breeds should have changed - assert dragons[0].breed == "Cold_drake" - assert dragons[1].breed == "Fire_drake" - assert dragons[2].breed == "Stone_dragon" - - # Gold should have changed - assert dragons[0].lair.inventory.gold == 1100 - assert dragons[1].lair.inventory.gold == 2100 - assert dragons[2].lair.inventory.gold == 3100 - - # Skulls should be the same - assert dragons[0].lair.inventory.skulls == 100 - assert dragons[1].lair.inventory.skulls == 200 - assert dragons[2].lair.inventory.skulls == 300 - - -def test_delete_many(mongo_client, example_dataset_many): - """Should delete mulitple documents from the database""" - - # Select all the dragons - dragons = ComplexDragonSpec.many() - - # Delete all of them :( - ComplexDragonSpec.delete_many(dragons) - - # Check there are no remaining dragons - assert ComplexDragonSpec.count() == 0 - - -def test_reload(mongo_client, example_dataset_one): - """Should reload the current document's values from the database""" - - # Select Burt from the database - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - # Change some values and reload - burt.name = "Fred" - burt.lair.inventory = Inventory(gold=500, skulls=50) - burt.reload() - - # Check Burt is himself again - assert burt.name == "Burt" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Reload with a different projection - burt.reload(projection={"name": True}) - - # Check Burt has values for the projection specified - assert burt.name == "Burt" - - ## Should reload reset attributes based on projection? - # assert burt.breed == None - # assert burt.lair == None - - -def test_by_id(mongo_client, example_dataset_many): - """Should return a document by Id from the database""" - - # Get an Id for a dragon - fred = ComplexDragonSpec.one(Q.name == "Fred") - assert fred - - # Load a dragon using the Id and make sure it's the same - fred_by_id = ComplexDragonSpec.by_id(fred._id) - assert fred_by_id - - assert fred_by_id.name == "Fred" - - -def test_count(mongo_client, example_dataset_many): - """Should return a count for documents matching the given query""" - - # Count all dragons - count = ComplexDragonSpec.count() - assert count == 3 - - # Count dragons that are cold or fire drakes - count = ComplexDragonSpec.count(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert count == 2 - - # Count dragons born after 1980 - count = ComplexDragonSpec.count(Q.dob >= datetime(1981, 1, 1)) - assert count == 1 - - -def test_ids(mongo_client, example_dataset_many): - """Should return a list of ids for documents matching the given query""" - - # Ids for all dragons - ids = ComplexDragonSpec.ids() - assert len(ids) == 3 - - # Ids for dragons that are cold or fire drakes - ids = ComplexDragonSpec.ids(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert len(ids) == 2 - - # Ids for dragons born after 1980 - ids = ComplexDragonSpec.ids(Q.dob >= datetime(1981, 1, 1)) - assert len(ids) == 1 - - -def test_one(mongo_client, example_dataset_many): - """Should return a the first document that matches the given query""" - - # Select the first matching dragon - burt = ComplexDragonSpec.one() - assert burt - assert burt.name == "Burt" - - # Sort the results so we select the last matching dragon - albert = ComplexDragonSpec.one(sort=[("_id", DESC)]) - assert albert - assert albert.name == "Albert" - - # Select the first dragon who's a fire-drake - fred = ComplexDragonSpec.one(Q.breed == "Fire-drake") - assert fred - assert fred.name == "Fred" - - # Select a dragon with a different projection - burt = ComplexDragonSpec.one(projection={"name": True}) - assert burt - assert burt.name == "Burt" - assert burt.breed is None - - -def test_many(mongo_client, example_dataset_many): - """Should return all documents that match the given query""" - - # Select all dragons - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)]) - - assert len(dragons) == 3 - assert dragons[0].name == "Burt" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Albert" - - # Select all dragons ordered by date of birth (youngest to oldest) - dragons = ComplexDragonSpec.many(sort=[("dob", DESC)]) - - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Burt" - - # Select only dragons born after 1980 ordered by date of birth (youngest to - # oldest). - dragons = ComplexDragonSpec.many(Q.dob > datetime(1980, 1, 1), sort=[("dob", DESC)]) - - assert len(dragons) == 2 - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - - # Select all dragons with a different projection - dragons = ComplexDragonSpec.many(sort=[("_id", ASC)], projection={"name": True}) - - assert dragons[0].name == "Burt" - assert dragons[0].breed is None - assert dragons[1].name == "Fred" - assert dragons[1].breed is None - assert dragons[2].name == "Albert" - assert dragons[2].breed is None - - -def test_projection(mongo_client, example_dataset_one): - """Should allow references and subspecs to be projected""" - - # Select our complex dragon called burt - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - inventory = Inventory(gold=1000, skulls=100) - - # Test list of references - burt.misc = LairSpec.many() - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$ref": LairSpec}}) - - assert burt - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].name == "Cave" - - # Test dictionary of references - burt.misc = {"cave": LairSpec.one()} - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$ref": LairSpec}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["cave"].name == "Cave" - - # Test list of sub-specs - burt.misc = [inventory] - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$sub": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].skulls == 100 - - # Test dict of sub-specs - burt.misc = {"spare": inventory} - burt.update() - burt = ComplexDragonSpec.one(Q.name == "Burt", projection={"misc": {"$sub.": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["spare"].skulls == 100 - - -def test_timestamp_insert(mongo_client): - """ - Should assign a timestamp to the `created` and `modified` field for a - document. - """ - - # Assign a the timestamp helper to the insert event - MonitoredDragonSpec.listen("insert", MonitoredDragonSpec.timestamp_insert) - - # Insert a monitored dragon in the database - dragon = MonitoredDragonSpec(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a created/modified timestamp set - assert (dragon.created - now_tz) < timedelta(seconds=1) - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.created - now) < timedelta(seconds=1) - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_timestamp_update(mongo_client): - """@@ Should assign a timestamp to the `modified` field for a document""" - - # Assign a the timestamp helper to the insert event - MonitoredDragonSpec.listen("insert", MonitoredDragonSpec.timestamp_insert) - MonitoredDragonSpec.listen("update", MonitoredDragonSpec.timestamp_update) - - # Insert a monitored dragon in the database - dragon = MonitoredDragonSpec(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a modified timestamp set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.modified - now) < timedelta(seconds=1) - - # Wait a couple of seconds and then update the dragon - sleep(2) - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.breed = "Fire-drake" - dragon.update("breed", "modified") - - # Check a new modified date has been set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - dragon.reload() - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_cascade(mongo_client, example_dataset_many): - """Should apply a cascading delete""" - - # Listen for delete events against dragons and delete any associated lair at - # the same time. - def on_deleted(sender, specs): - ComplexDragonSpec.cascade(LairSpec, "lair", specs) - - ComplexDragonSpec.listen("deleted", on_deleted) - - # Delete a dragon and check the associated lair is also deleted - burt = ComplexDragonSpec.one(Q.name == "Burt") - assert burt - - burt.delete() - lair = LairSpec.by_id(burt.lair._id) - assert lair is None - - -def test_nullify(mongo_client, example_dataset_many): - """Should nullify a reference field""" - - # Listen for delete events against lairs and nullify the lair field against - # associated dragons - def on_deleted(sender, specs): - LairSpec.nullify(ComplexDragonSpec, "lair", specs) - - LairSpec.listen("deleted", on_deleted) - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = LairSpec.one(Q.name == "Cave") - assert lair - lair.delete() - - burt = ComplexDragonSpec.one(Q.name == "Burt", projection=None) - assert burt - assert burt.lair is None - - -def test_pull(mongo_client, example_dataset_many): - """Should pull references from a list field""" - - # Listen for delete events against lairs and pull any deleted lair from the - # associated dragons. For the sake of the tests here we're storing multiple - # lairs against the lair attribute instead of the intended one. - def on_deleted(sender, specs): - LairSpec.pull(ComplexDragonSpec, "visited_lairs", specs) - - LairSpec.listen("deleted", on_deleted) - - # List Burt stay in a few lairs - castle = LairSpec.one(Q.name == "Castle") - burt = ComplexDragonSpec.one(Q.name == "Burt") - - assert castle - assert burt - - burt.lair = castle - burt.visited_lairs.append(castle) - burt.update() - burt.reload() - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = LairSpec.one(Q.name == "Castle") - assert lair - lair.delete() - burt.reload(projection=None) - assert burt.visited_lairs == [] - - -def test_listen(mongo_client): - """Should add a callback for a signal against the class""" - - # Create a mocked functions for every event that can be triggered for a - # spec. - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - def on_inserted(sender, specs): - mock.inserted(sender, specs) - - def on_update(sender, specs): - mock.update(sender, specs) - - def on_updated(sender, specs): - mock.updated(sender, specs) - - def on_delete(sender, specs): - mock.delete(sender, specs) - - def on_deleted(sender, specs): - mock.deleted(sender, specs) - - # Listen for all events triggered by specs - DragonSpec.listen("insert", on_insert) - DragonSpec.listen("inserted", on_inserted) - DragonSpec.listen("update", on_update) - DragonSpec.listen("updated", on_updated) - DragonSpec.listen("delete", on_delete) - DragonSpec.listen("deleted", on_deleted) - - # Trigger all the events - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - burt.breed = "Fire-drake" - burt.update() - burt.delete() - - # Check each function was called - assert mock.insert.called - assert mock.inserted.called - assert mock.update.called - assert mock.updated.called - assert mock.delete.called - assert mock.deleted.called - - -def test_stop_listening(mongo_client): - """Should remove a callback for a signal against the class""" - - # Add an listener for the insert event - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - DragonSpec.listen("on_insert", on_insert) - - # Remove the listener for the insert event - DragonSpec.stop_listening("on_insert", on_insert) - - # Insert a dragon into the database and check that the insert event handler - # isn't called. - burt = DragonSpec(name="Burt", breed="Cold-drake") - burt.insert() - - assert mock.insert.called is False - - -def test_get_collection(mongo_client): - """Return a reference to the database collection for the class""" - assert DragonSpec.get_collection() == mongo_client["mongospecs_test"]["dragon"] - - -def test_get_db(mongo_client): - """Return the database for the collection""" - assert DragonSpec.get_db() == mongo_client["mongospecs_test"] - - -def test_get_fields(mongo_client): - """Return the fields for the class""" - assert DragonSpec.get_fields() == {"id", "name", "breed"} - - -# def test_get_private_fields(mongo_client): -# """Return the private fields for the class""" -# assert Dragon.get_private_fields() == {"breed"} - - -def test_flattern_projection(): - """Flattern projection""" - - projection, refs, subs = LairSpec._flatten_projection( - {"name": True, "inventory": {"$sub": Inventory, "gold": True, "secret_draw": {"$sub": Inventory, "gold": True}}} - ) - - assert projection == {"name": True, "inventory.gold": True, "inventory.secret_draw.gold": True} - - -def test_with_options(mongo_client): - """Flattern projection""" - - collection = DragonSpec.get_collection() - - with DragonSpec.with_options(read_preference=ReadPreference.SECONDARY): - assert DragonSpec.get_collection().read_preference == ReadPreference.SECONDARY - - with DragonSpec.with_options(read_preference=ReadPreference.PRIMARY_PREFERRED): - assert DragonSpec.get_collection().read_preference == ReadPreference.PRIMARY_PREFERRED - - assert DragonSpec.get_collection().read_preference == ReadPreference.SECONDARY - - assert collection.read_preference == ReadPreference.PRIMARY diff --git a/tests/pydantic/test_frames.py b/tests/pydantic/test_frames.py deleted file mode 100644 index 0fd0355..0000000 --- a/tests/pydantic/test_frames.py +++ /dev/null @@ -1,789 +0,0 @@ -from datetime import datetime, timedelta, timezone -from time import sleep -from unittest.mock import Mock - -from bson.objectid import ObjectId -from pymongo import ReadPreference - -from mongospecs import ASC, DESC, Empty, In, Q - -from .fixtures import example_dataset_many, example_dataset_one, mongo_client # noqa -from .models import ComplexDragon, Dragon, Inventory, Lair, MonitoredDragon - -# Tests - - -def test_spec(): - """Should create a new Dragon instance""" - - # Passing initial values - burt = Dragon(name="Burt", breed="Cold-drake") - assert burt.name == "Burt" - assert burt.breed == "Cold-drake" - assert isinstance(burt, Dragon) - - -def test_dot_notation(): - """ - Should allow access to read and set document values using do notation. - """ - - # Simple set/get - burt = Dragon(name="Burt", breed="Cold-drake") - - assert burt.name == "Burt" - burt.name = "Fred" - assert burt.name == "Fred" - - # SubSpec (embedded document get/set) - inventory = Inventory(gold=1000, skulls=100) - - cave = Lair(name="Cave", inventory=inventory) - - assert cave.inventory.gold == 1000 - cave.inventory.gold += 100 - assert cave.inventory.gold == 1100 - - -def test_equal(mongo_client): - """Should compare the equality of two Spec instances by Id""" - - # Create some dragons - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - - fred = Dragon(name="Fred", breed="Fire-drake") - fred.insert() - - # Test equality - assert burt != fred - assert burt == burt - - -def test_python_sort(mongo_client): - """Should sort a list of Spec instances by their Ids""" - - # Create some dragons - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - - fred = Dragon(name="Fred", breed="Fire-drake") - fred.insert() - - albert = Dragon(name="Albert", breed="Stone dragon") - albert.insert() - - # Test sorting by Id - assert sorted([albert, burt, fred]) == [burt, fred, albert] - - -def test_to_json_type(mongo_client, example_dataset_one): - """ - Should return a dictionary for the document with all values converted to - JSON safe types. All private fields should be excluded. - """ - - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - cave = burt.lair - - burt_json = burt.to_json_type() - - assert burt_json == { - "_id": str(burt.id), - "name": "Burt", - "breed": "Cold-drake", - "dob": "1979-06-11T00:00:00", - "traits": ["irritable", "narcissistic"], - "lair": {"_id": str(cave.id), "name": "Cave", "inventory": {"gold": 1000, "skulls": 100}}, - "visited_lairs": [], - "misc": "", - } - - -def test_insert(mongo_client): - """Should insert a document into the database""" - - # Create some convoluted data to insert - inventory = Inventory(gold=1000, skulls=100) - - cave = Lair(name="Cave", inventory=inventory) - cave.insert() - - burt = ComplexDragon( - name="Burt", dob=datetime(1979, 6, 11), breed="Cold-drake", lair=cave, traits=["irritable", "narcissistic"] - ) - burt.insert() - - # Test the document now has an Id - assert burt.id is not None - - # Get the document from the database and check it's values - burt.reload() - - assert burt.name == "Burt" - assert burt.dob == datetime(1979, 6, 11) - assert burt.breed == "Cold-drake" - assert burt.traits == ["irritable", "narcissistic"] - assert burt.lair.name == "Cave" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Test insert where we specify the `_id` value - _id = ObjectId() - albert = Dragon(_id=_id, name="Albert", breed="Stone dragon") - albert.insert() - - assert albert.id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_unset(mongo_client, example_dataset_one): - """Should unset fields against a document on the database""" - - # Unset name and breed - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - burt.unset("breed") - - assert burt.name == "Burt" - assert burt.breed == Empty - assert "breed" not in burt.to_json_type() - - burt.reload() - - assert burt.name == "Burt" - assert burt.breed == Empty - assert "breed" not in burt.to_json_type() - - -def test_update(mongo_client, example_dataset_one): - """Should update a document on the database""" - - # Update all values - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - burt.name = "Jess" - burt.breed = "Fire-drake" - burt.traits = ["gentle", "kind"] - burt.update() - - burt.reload() - - assert burt.name == "Jess" - assert burt.breed == "Fire-drake" - assert burt.traits == ["gentle", "kind"] - - # Selective update - burt.lair.name = "Castle" - burt.lair.inventory.gold += 100 - burt.lair.inventory.skulls = 0 - burt.lair.update("name", "inventory.skulls") - - burt.reload() - - assert burt.lair.name == "Castle" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 0 - - -def test_upsert(mongo_client): - """ - Should update or insert a document on the database depending on whether or - not it already exists. - """ - - # Insert - burt = Dragon(name="Burt", breed="Cold-drake") - burt.upsert() - id = burt.id - burt.reload() - - # Update - burt.upsert() - burt.reload() - - assert burt.id == id - - # Test upsert where we specify the `_id` value - _id = ObjectId() - albert = Dragon(_id=_id, name="Albert", breed="Stone dragon") - albert.upsert() - - assert albert.id == _id - assert albert.name == "Albert" - assert albert.breed == "Stone dragon" - - -def test_delete(mongo_client, example_dataset_one): - """Should delete a document from the database""" - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - burt.delete() - burt = burt.by_id(burt.id) - - assert burt is None - - -def test_insert_many(mongo_client): - """Should insert multiple documents records into the database""" - - # Create some convoluted data to insert - burt = Dragon(name="Burt", breed="Cold-drake") - - fred = Dragon(name="Fred", breed="Fire-drake") - - albert = Dragon(name="Albert", breed="Stone dragon") - - burt.insert_many([burt, fred, albert]) - - # Check 3 dragons have been created - assert Dragon.count() == 3 - - # Check the details for each dragon - dragons = Dragon.many(sort=[("_id", ASC)]) - assert dragons[0].name == "Burt" - assert dragons[0].breed == "Cold-drake" - assert dragons[1].name == "Fred" - assert dragons[1].breed == "Fire-drake" - assert dragons[2].name == "Albert" - assert dragons[2].breed == "Stone dragon" - - -def test_unset_many(mongo_client, example_dataset_many): - """Should unset fields against multiple documents on the database""" - - # Unset name and breed - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - for dragon in dragons: - dragon.unset("name", "breed") - - for dragon in dragons: - assert dragon.name == Empty - assert dragon.breed == Empty - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - dragon.reload() - - assert dragon.name == Empty - assert dragon.breed == Empty - assert "name" not in dragon.to_json_type() - assert "breed" not in dragon.to_json_type() - - -def test_update_many(mongo_client, example_dataset_many): - """Should update mulitple documents on the database""" - - # Select all the dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - # Give each dragon a second name - for dragon in dragons: - dragon.name += " " + dragon.name + "son" - - # Update all values for all the dragons in one go - ComplexDragon.update_many(dragons) - - # Reload all the dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Make various changes to the dragons only some of which we want to stick - for dragon in dragons: - dragon.name = dragon.name.split(" ")[0] - assert dragon.breed - dragon.breed = dragon.breed.replace("-", "_") - dragon.breed = dragon.breed.replace(" ", "_") - dragon.lair.inventory.gold += 100 - dragon.lair.inventory.skulls += 10 - - # Update selected values for all the dragons in one go - Lair.update_many([d.lair for d in dragons], "inventory.gold") - ComplexDragon.update_many(dragons, "breed") - - # Reload all the dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - # Names should be the same - assert dragons[0].name == "Burt Burtson" - assert dragons[1].name == "Fred Fredson" - assert dragons[2].name == "Albert Albertson" - - # Breeds should have changed - assert dragons[0].breed == "Cold_drake" - assert dragons[1].breed == "Fire_drake" - assert dragons[2].breed == "Stone_dragon" - - # Gold should have changed - assert dragons[0].lair.inventory.gold == 1100 - assert dragons[1].lair.inventory.gold == 2100 - assert dragons[2].lair.inventory.gold == 3100 - - # Skulls should be the same - assert dragons[0].lair.inventory.skulls == 100 - assert dragons[1].lair.inventory.skulls == 200 - assert dragons[2].lair.inventory.skulls == 300 - - -def test_delete_many(mongo_client, example_dataset_many): - """Should delete mulitple documents from the database""" - - # Select all the dragons - dragons = ComplexDragon.many() - - # Delete all of them :( - ComplexDragon.delete_many(dragons) - - # Check there are no remaining dragons - assert ComplexDragon.count() == 0 - - -def test_reload(mongo_client, example_dataset_one): - """Should reload the current document's values from the database""" - - # Select Burt from the database - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - # Change some values and reload - burt.name = "Fred" - burt.lair.inventory = Inventory(gold=500, skulls=50) - burt.reload() - - # Check Burt is himself again - assert burt.name == "Burt" - assert burt.lair.inventory.gold == 1000 - assert burt.lair.inventory.skulls == 100 - - # Reload with a different projection - burt.reload(projection={"name": True}) - - # Check Burt has values for the projection specified - assert burt.name == "Burt" - - ## Should reload reset attributes based on projection? - # assert burt.breed == None - # assert burt.lair == None - - -def test_by_id(mongo_client, example_dataset_many): - """Should return a document by Id from the database""" - - # Get an Id for a dragon - fred = ComplexDragon.one(Q.name == "Fred") - assert fred - - # Load a dragon using the Id and make sure it's the same - fred_by_id = ComplexDragon.by_id(fred.id) - assert fred_by_id - - assert fred_by_id.name == "Fred" - - -def test_count(mongo_client, example_dataset_many): - """Should return a count for documents matching the given query""" - - # Count all dragons - count = ComplexDragon.count() - assert count == 3 - - # Count dragons that are cold or fire drakes - count = ComplexDragon.count(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert count == 2 - - # Count dragons born after 1980 - count = ComplexDragon.count(Q.dob >= datetime(1981, 1, 1)) - assert count == 1 - - -def test_ids(mongo_client, example_dataset_many): - """Should return a list of ids for documents matching the given query""" - - # Ids for all dragons - ids = ComplexDragon.ids() - assert len(ids) == 3 - - # Ids for dragons that are cold or fire drakes - ids = ComplexDragon.ids(In(Q.breed, ["Cold-drake", "Fire-drake"])) - assert len(ids) == 2 - - # Ids for dragons born after 1980 - ids = ComplexDragon.ids(Q.dob >= datetime(1981, 1, 1)) - assert len(ids) == 1 - - -def test_one(mongo_client, example_dataset_many): - """Should return a the first document that matches the given query""" - - # Select the first matching dragon - burt = ComplexDragon.one() - assert burt - assert burt.name == "Burt" - - # Sort the results so we select the last matching dragon - albert = ComplexDragon.one(sort=[("_id", DESC)]) - assert albert - assert albert.name == "Albert" - - # Select the first dragon who's a fire-drake - fred = ComplexDragon.one(Q.breed == "Fire-drake") - assert fred - assert fred.name == "Fred" - - # Select a dragon with a different projection - burt = ComplexDragon.one(projection={"name": True}) - assert burt - assert burt.name == "Burt" - assert burt.breed is None - - -def test_many(mongo_client, example_dataset_many): - """Should return all documents that match the given query""" - - # Select all dragons - dragons = ComplexDragon.many(sort=[("_id", ASC)]) - - assert len(dragons) == 3 - assert dragons[0].name == "Burt" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Albert" - - # Select all dragons ordered by date of birth (youngest to oldest) - dragons = ComplexDragon.many(sort=[("dob", DESC)]) - - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - assert dragons[2].name == "Burt" - - # Select only dragons born after 1980 ordered by date of birth (youngest to - # oldest). - dragons = ComplexDragon.many(Q.dob > datetime(1980, 1, 1), sort=[("dob", DESC)]) - - assert len(dragons) == 2 - assert dragons[0].name == "Albert" - assert dragons[1].name == "Fred" - - # Select all dragons with a different projection - dragons = ComplexDragon.many(sort=[("_id", ASC)], projection={"name": True}) - - assert dragons[0].name == "Burt" - assert dragons[0].breed is None - assert dragons[1].name == "Fred" - assert dragons[1].breed is None - assert dragons[2].name == "Albert" - assert dragons[2].breed is None - - -def test_projection(mongo_client, example_dataset_one): - """Should allow references and subspecs to be projected""" - - # Select our complex dragon called burt - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - inventory = Inventory(gold=1000, skulls=100) - - # Test list of references - burt.misc = Lair.many() - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$ref": Lair}}) - - assert burt - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].name == "Cave" - - # Test dictionary of references - burt.misc = {"cave": Lair.one()} - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$ref": Lair}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["cave"].name == "Cave" - - # Test list of sub-specs - burt.misc = [inventory] - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$sub": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc) == 1 - assert burt.misc[0].skulls == 100 - - # Test dict of sub-specs - burt.misc = {"spare": inventory} - burt.update() - burt = ComplexDragon.one(Q.name == "Burt", projection={"misc": {"$sub.": Inventory}}) - assert burt - - assert burt.misc - assert len(burt.misc.keys()) == 1 - assert burt.misc["spare"].skulls == 100 - - -def test_timestamp_insert(mongo_client): - """ - Should assign a timestamp to the `created` and `modified` field for a - document. - """ - - # Assign a the timestamp helper to the insert event - MonitoredDragon.listen("insert", MonitoredDragon.timestamp_insert) - - # Insert a monitored dragon in the database - dragon = MonitoredDragon(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a created/modified timestamp set - assert (dragon.created - now_tz) < timedelta(seconds=1) - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.created - now) < timedelta(seconds=1) - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_timestamp_update(mongo_client): - """@@ Should assign a timestamp to the `modified` field for a document""" - - # Assign a the timestamp helper to the insert event - MonitoredDragon.listen("insert", MonitoredDragon.timestamp_insert) - MonitoredDragon.listen("update", MonitoredDragon.timestamp_update) - - # Insert a monitored dragon in the database - dragon = MonitoredDragon(name="Burt", breed="Cold-drake") - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.insert() - - assert dragon.created - assert dragon.modified - # Check the dragon has a modified timestamp set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - - # When the timestamps are reloaded whether they have associated timezones - # will depend on the mongodb client settings, in the tests the client is not - # timezone aware and so tests after the reload are against a naive datetime. - dragon.reload() - - assert (dragon.modified - now) < timedelta(seconds=1) - - # Wait a couple of seconds and then update the dragon - sleep(2) - now = datetime.now(timezone.utc).replace(tzinfo=None) - now_tz = datetime.now(timezone.utc) - dragon.breed = "Fire-drake" - dragon.update("breed", "modified") - - # Check a new modified date has been set - assert (dragon.modified - now_tz) < timedelta(seconds=1) - dragon.reload() - assert (dragon.modified - now) < timedelta(seconds=1) - - -def test_cascade(mongo_client, example_dataset_many): - """Should apply a cascading delete""" - - # Listen for delete events against dragons and delete any associated lair at - # the same time. - def on_deleted(sender, specs): - ComplexDragon.cascade(Lair, "lair", specs) - - ComplexDragon.listen("deleted", on_deleted) - - # Delete a dragon and check the associated lair is also deleted - burt = ComplexDragon.one(Q.name == "Burt") - assert burt - - burt.delete() - lair = Lair.by_id(burt.lair.id) - assert lair is None - - -def test_nullify(mongo_client, example_dataset_many): - """Should nullify a reference field""" - - # Listen for delete events against lairs and nullify the lair field against - # associated dragons - def on_deleted(sender, specs): - Lair.nullify(ComplexDragon, "lair", specs) - - Lair.listen("deleted", on_deleted) - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = Lair.one(Q.name == "Cave") - assert lair - lair.delete() - - burt = ComplexDragon.one(Q.name == "Burt", projection=None) - assert burt - assert burt.lair is None - - -def test_pull(mongo_client, example_dataset_many): - """Should pull references from a list field""" - - # Listen for delete events against lairs and pull any deleted lair from the - # associated dragons. For the sake of the tests here we're storing multiple - # lairs against the lair attribute instead of the intended one. - def on_deleted(sender, specs): - Lair.pull(ComplexDragon, "visited_lairs", specs) - - Lair.listen("deleted", on_deleted) - - # List Burt stay in a few lairs - castle = Lair.one(Q.name == "Castle") - burt = ComplexDragon.one(Q.name == "Burt") - - assert castle - assert burt - - burt.lair = castle - burt.visited_lairs.append(castle) - burt.update() - burt.reload() - - # Delete a lair and check the associated field against the dragon has been - # nullified. - lair = Lair.one(Q.name == "Castle") - assert lair - lair.delete() - burt.reload(projection=None) - assert burt.visited_lairs == [] - - -def test_listen(mongo_client): - """Should add a callback for a signal against the class""" - - # Create a mocked functions for every event that can be triggered for a - # spec. - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - def on_inserted(sender, specs): - mock.inserted(sender, specs) - - def on_update(sender, specs): - mock.update(sender, specs) - - def on_updated(sender, specs): - mock.updated(sender, specs) - - def on_delete(sender, specs): - mock.delete(sender, specs) - - def on_deleted(sender, specs): - mock.deleted(sender, specs) - - # Listen for all events triggered by specs - Dragon.listen("insert", on_insert) - Dragon.listen("inserted", on_inserted) - Dragon.listen("update", on_update) - Dragon.listen("updated", on_updated) - Dragon.listen("delete", on_delete) - Dragon.listen("deleted", on_deleted) - - # Trigger all the events - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - burt.breed = "Fire-drake" - burt.update() - burt.delete() - - # Check each function was called - assert mock.insert.called - assert mock.inserted.called - assert mock.update.called - assert mock.updated.called - assert mock.delete.called - assert mock.deleted.called - - -def test_stop_listening(mongo_client): - """Should remove a callback for a signal against the class""" - - # Add an listener for the insert event - mock = Mock() - - def on_insert(sender, specs): - mock.insert(sender, specs) - - Dragon.listen("on_insert", on_insert) - - # Remove the listener for the insert event - Dragon.stop_listening("on_insert", on_insert) - - # Insert a dragon into the database and check that the insert event handler - # isn't called. - burt = Dragon(name="Burt", breed="Cold-drake") - burt.insert() - - assert mock.insert.called is False - - -def test_get_collection(mongo_client): - """Return a reference to the database collection for the class""" - assert Dragon.get_collection() == mongo_client["mongospecs_test"]["Dragon"] - - -def test_get_db(mongo_client): - """Return the database for the collection""" - assert Dragon.get_db() == mongo_client["mongospecs_test"] - - -def test_get_fields(mongo_client): - """Return the fields for the class""" - assert Dragon.get_fields() == {"id", "name", "breed"} - - -# def test_get_private_fields(mongo_client): -# """Return the private fields for the class""" -# assert Dragon.get_private_fields() == {"breed"} - - -def test_flattern_projection(): - """Flattern projection""" - - projection, refs, subs = Lair._flatten_projection( - {"name": True, "inventory": {"$sub": Inventory, "gold": True, "secret_draw": {"$sub": Inventory, "gold": True}}} - ) - - assert projection == {"name": True, "inventory.gold": True, "inventory.secret_draw.gold": True} - - -def test_with_options(mongo_client): - """Flattern projection""" - - collection = Dragon.get_collection() - - with Dragon.with_options(read_preference=ReadPreference.SECONDARY): - assert Dragon.get_collection().read_preference == ReadPreference.SECONDARY - - with Dragon.with_options(read_preference=ReadPreference.PRIMARY_PREFERRED): - assert Dragon.get_collection().read_preference == ReadPreference.PRIMARY_PREFERRED - - assert Dragon.get_collection().read_preference == ReadPreference.SECONDARY - - assert collection.read_preference == ReadPreference.PRIMARY diff --git a/tests/pydantic/test_pagination.py b/tests/pydantic/test_pagination.py deleted file mode 100644 index e03e69d..0000000 --- a/tests/pydantic/test_pagination.py +++ /dev/null @@ -1,184 +0,0 @@ -import pytest - -from mongospecs import DESC, Empty, Paginator, Q -from mongospecs.pydantic import Spec - -from .fixtures import mongo_client # noqa - -# Classes - - -class Orc(Spec): - """ - An orc. - """ - - _collection = "Orc" - name: str = Empty - - -@pytest.fixture(scope="function") -def example_dataset(mongo_client): - """Connect to the database and create a set of example data (orcs)""" - - # Create 1,000 orcs - for i in range(0, 1000): - Orc(name="Orc {0:04d}".format(i)).insert() - - return Spec._client - - -# Paginator tests - - -def test_paginator(example_dataset): - """Paginate all orcs""" - - # Paginate all orcs - paginator = Paginator(Orc) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 50 - assert paginator.page_numbers == range(1, 51) - - i = 0 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - -def test_paginator_with_filter(example_dataset): - """Paginate the last 100 orcs using a filter""" - - # Paginate all orcs - paginator = Paginator(Orc, Q.name >= "Orc 0900") - - # Check the results are as expected - assert paginator.item_count == 100 - assert paginator.page_count == 5 - assert paginator.page_numbers == range(1, 6) - - i = 900 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - -def test_paginator_with_sort(example_dataset): - """Paginate all orcs but sort them in reverse""" - - # Paginate all orcs - paginator = Paginator(Orc, filter_kwargs={"sort": [("name", DESC)]}) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 50 - assert paginator.page_numbers == range(1, 51) - - i = 999 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i -= 1 - - -def test_paginator_with_projection(example_dataset): - """Paginate all orcs but use a custom projection""" - - # Paginate all orcs - paginator = Paginator(Orc, filter_kwargs={"projection": {"name": False}}) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 50 - assert paginator.page_numbers == range(1, 51) - - for page in paginator: - for orc in page: - assert orc._id is not None - assert orc.name is Empty - - -def test_paginator_with_per_page(example_dataset): - """Paginate all orcs but use a custom per page value""" - - # Paginate all orcs - paginator = Paginator(Orc, per_page=100) - - # Check the results are as expected - assert paginator.item_count == 1000 - assert paginator.page_count == 10 - assert paginator.page_numbers == range(1, 11) - - i = 0 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - -def test_paginator_with_orphans(example_dataset): - """Paginate all orcs but allow up to 2 orphan results""" - - # Paginate all orcs - paginator = Paginator(Orc, Q.name >= "Orc 0918", orphans=2) - - # Check the results are as expected - assert paginator.item_count == 82 - assert paginator.page_count == 4 - assert paginator.page_numbers == range(1, 5) - - i = 918 - for page in paginator: - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - # Check the last page has 22 items - assert len(paginator[4]) == 22 - - -# Page tests - - -def test_page(example_dataset): - """Test the results or extracting a paginated page""" - - # Paginate all orcs - paginator = Paginator(Orc) - - # First page - page = paginator[1] - assert page.prev is None - assert page.next == 2 - assert page.number == 1 - assert len(page) == paginator.per_page - - i = 0 - for orc in page: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - # Second page - page = paginator[2] - assert page.prev == 1 - assert page.next == 3 - assert page.number == 2 - - i = 20 - for orc in page.items: - assert orc.name == "Orc {0:04d}".format(i) - i += 1 - - # Last page - page = paginator[paginator.page_count] - assert page.prev == paginator.page_count - 1 - assert page.next is None - assert page.number == paginator.page_count - - # Find the offset of Orc 992 - orc = Orc.one(Q.name == "Orc 0992") - assert page.get_offset(orc) == 992 diff --git a/tests/pydantic/test_queries.py b/tests/pydantic/test_queries.py deleted file mode 100644 index 176ade6..0000000 --- a/tests/pydantic/test_queries.py +++ /dev/null @@ -1,141 +0,0 @@ -from bson import ObjectId - -from mongospecs import All, And, ElemMatch, Exists, In, Nor, Not, NotIn, Or, Q, Size, SortBy, Type -from mongospecs.pydantic import Spec -from mongospecs.utils import to_refs - - -def test_q(): - """Should generate a field path""" - - # Single - assert Q.foo._path == "foo" - - # Path - assert Q.foo.bar._path == "foo.bar" - - # Path with index - assert Q.foo.bar[2]._path == "foo.bar.2" - - # Path with `_path` using item getter - assert Q.foo.bar[2]["_path"]._path == "foo.bar.2._path" - - -# Conditions as operator overrides - - -def test_equal(): - """Should generate an equals condition""" - assert (Q.foo == 123).to_dict() == {"foo": 123} - - -def test_greater_than_or_equal(): - """Should generate a greater than or equal condition""" - assert (Q.foo >= 123).to_dict() == {"foo": {"$gte": 123}} - - -def test_greater_than(): - """Should generate a greater than condition""" - assert (Q.foo > 123).to_dict() == {"foo": {"$gt": 123}} - - -def test_less_than_or_equal(): - """Should generate a less than or equal condition""" - assert (Q.foo <= 123).to_dict() == {"foo": {"$lte": 123}} - - -def test_less_than(): - """Should generate a less than condition""" - assert (Q.foo < 123).to_dict() == {"foo": {"$lt": 123}} - - -def test_not_equal(): - """Should generate a not equals condition""" - assert (Q.foo != 123).to_dict() == {"foo": {"$ne": 123}} - - -# Conditions through functions - - -def test_all(): - """Should generate a contains all values condition""" - assert All(Q.foo, [1, 2, 3]).to_dict() == {"foo": {"$all": [1, 2, 3]}} - - -def test_elem_match(): - """Should generate an element match condition""" - condition = ElemMatch(Q.foo, Q > 1, Q < 5) - assert condition.to_dict() == {"foo": {"$elemMatch": {"$gt": 1, "$lt": 5}}} - - -def test_exists(): - """Should generate an exists test condition""" - assert Exists(Q.foo, True).to_dict() == {"foo": {"$exists": True}} - - -def test_in(): - """Should generate a contains condition""" - assert In(Q.foo, [1, 2, 3]).to_dict() == {"foo": {"$in": [1, 2, 3]}} - - -def test_not(): - """Should generate a logical not condition""" - assert Not(Q.foo == 123).to_dict() == {"foo": {"$not": {"$eq": 123}}} - - -def test_not_in(): - """Should generate a does not contain condition""" - assert NotIn(Q.foo, [1, 2, 3]).to_dict() == {"foo": {"$nin": [1, 2, 3]}} - - -def test_size(): - """Should generate a array value size matches condition""" - assert Size(Q.foo, 2).to_dict() == {"foo": {"$size": 2}} - - -def test_type(): - """Should generate a equals instance of condition""" - assert Type(Q.foo, 1).to_dict() == {"foo": {"$type": 1}} - - -# Groups - - -def test_and(): - """Should group one or more conditions by a logical and""" - assert And(Q.foo == 123, Q.bar != 456).to_dict() == {"$and": [{"foo": 123}, {"bar": {"$ne": 456}}]} - - -def test_or(): - """Should group one or more conditions by a logical or""" - assert Or(Q.foo == 123, Q.bar != 456).to_dict() == {"$or": [{"foo": 123}, {"bar": {"$ne": 456}}]} - - -def test_nor(): - """Should group one or more conditions by a logical nor""" - assert Nor(Q.foo == 123, Q.bar != 456).to_dict() == {"$nor": [{"foo": 123}, {"bar": {"$ne": 456}}]} - - -# Sorting - - -def test_sort_by(): - """Should return sort instructions for a list of `Q` instances""" - assert SortBy(Q.dob.desc, Q.name) == [("dob", -1), ("name", 1)] - - -# Utils - - -def test_to_refs(): - """Should convert all Spec instances within a value to Ids""" - - ids = [ObjectId()] * 2 - # Convert a single Spec instance - assert to_refs(Spec(_id=ids[0])) == ids[0] - - # Convert a list of Spec instances - assert to_refs([Spec(_id=ids[0]), Spec(_id=ids[1])]) == ids - - # Convert a dictionary of Spec instances - assert to_refs({1: Spec(_id=ids[0]), 2: Spec(_id=ids[1])}) == {1: ids[0], 2: ids[1]} diff --git a/tests/pydantic/test_spec.py b/tests/pydantic/test_spec.py deleted file mode 100644 index 6f9b191..0000000 --- a/tests/pydantic/test_spec.py +++ /dev/null @@ -1,8 +0,0 @@ -from mongospecs.pydantic import Spec - - -class Country(Spec): - _collection = "country" - - name: str - population: int diff --git a/tests/test_base.py b/tests/test_base.py deleted file mode 100644 index c6c21f2..0000000 --- a/tests/test_base.py +++ /dev/null @@ -1,37 +0,0 @@ -import pytest - -from mongospecs.base import SpecBase - - -@pytest.mark.parametrize( - "parent_dict, paths, expected_result", - [ - # Happy path tests - ({"a": "something"}, ["a"], {}), # Remove only key - ({"a": 1, "b": {"c": 2}}, ["b.c"], {"a": 1, "b": {}}), # Remove nested key - ({"a": 1, "b": {"c": 2}}, ["a"], {"b": {"c": 2}}), # Remove top-level key - ({"a": {"b": {"c": 3}}}, ["a.b.c"], {"a": {"b": {}}}), # Deeply nested key - # Edge cases - ({}, ["a"], {}), # Empty dictionary - ({"a": 1}, [], {"a": 1}), # No paths to remove - ({"a": {"b": {"c": 3}}}, ["a.b.d"], {"a": {"b": {"c": 3}}}), # Non-existent key - # Error cases - ({"a": 1}, ["b.c"], {"a": 1}), # Non-existent nested path - ({"a": {"b": 2}}, ["a.b.c"], {"a": {"b": 2}}), # Non-existent deeper path - ], - ids=[ - "remove_only_key", - "remove_nested_key", - "remove_top_level_key", - "deeply_nested_key", - "empty_dict", - "no_paths", - "non_existent_key", - "non_existent_nested_path", - "non_existent_deeper_path", - ], -) -def test_remove_keys(parent_dict, paths, expected_result): - SpecBase._remove_keys(parent_dict, paths) - - assert parent_dict == expected_result diff --git a/tests/test_bson.py b/tests/test_bson.py deleted file mode 100644 index 492ce02..0000000 --- a/tests/test_bson.py +++ /dev/null @@ -1,65 +0,0 @@ -from dataclasses import dataclass - -import msgspec -import pytest -from attr import define -from bson import ObjectId - -from mongospecs.attrs import Spec as AttrsSpec -from mongospecs.helpers.bson import decode, encode, encode_spec -from mongospecs.msgspec import Spec as MsgspecSpec - - -class PointMsgspec(msgspec.Struct): - x: int - y: int - - -class PointMsgspecSpec(MsgspecSpec): - x: int - y: int - - -@define -class PointAttrs: - x: int - y: int - - -@define(kw_only=True) -class PointAttrsSpec(AttrsSpec): - x: int - y: int - - -@dataclass -class PointDataclass: - x: int - y: int - - -@pytest.mark.parametrize("klass", [PointMsgspec, PointAttrs, PointDataclass]) -def test_bson(klass): - x = klass(1, 2) - - msg = encode(x) # Encoding a high-level type works - - assert x == decode(msg, typ=klass) - - with pytest.raises(msgspec.ValidationError): - decode(encode(klass("oops", 2)), typ=klass) - - -@pytest.mark.parametrize( - "spec_klass", - [PointMsgspecSpec, PointAttrsSpec], -) -def test_bson_with_spec(spec_klass): - x = spec_klass(x=1, y=2, _id=ObjectId()) - - msg = encode_spec(x) # Encoding a high-level type works - - assert x == decode(msg, typ=spec_klass) - - with pytest.raises(msgspec.ValidationError): - decode(encode_spec(spec_klass(x="oops", y=2)), typ=spec_klass) diff --git a/tests/test_queries.py b/tests/test_queries.py deleted file mode 100644 index 855b82a..0000000 --- a/tests/test_queries.py +++ /dev/null @@ -1,120 +0,0 @@ -from mongospecs import All, And, ElemMatch, Exists, In, Nor, Not, NotIn, Or, Q, Size, SortBy, Type - - -def test_q(): - """Should generate a field path""" - - # Single - assert Q.foo._path == "foo" - - # Path - assert Q.foo.bar._path == "foo.bar" - - # Path with index - assert Q.foo.bar[2]._path == "foo.bar.2" - - # Path with `_path` using item getter - assert Q.foo.bar[2]["_path"]._path == "foo.bar.2._path" - - -# Conditions as operator overrides - - -def test_equal(): - """Should generate an equals condition""" - assert (Q.foo == 123).to_dict() == {"foo": 123} - - -def test_greater_than_or_equal(): - """Should generate a greater than or equal condition""" - assert (Q.foo >= 123).to_dict() == {"foo": {"$gte": 123}} - - -def test_greater_than(): - """Should generate a greater than condition""" - assert (Q.foo > 123).to_dict() == {"foo": {"$gt": 123}} - - -def test_less_than_or_equal(): - """Should generate a less than or equal condition""" - assert (Q.foo <= 123).to_dict() == {"foo": {"$lte": 123}} - - -def test_less_than(): - """Should generate a less than condition""" - assert (Q.foo < 123).to_dict() == {"foo": {"$lt": 123}} - - -def test_not_equal(): - """Should generate a not equals condition""" - assert (Q.foo != 123).to_dict() == {"foo": {"$ne": 123}} - - -# Conditions through functions - - -def test_all(): - """Should generate a contains all values condition""" - assert All(Q.foo, [1, 2, 3]).to_dict() == {"foo": {"$all": [1, 2, 3]}} - - -def test_elem_match(): - """Should generate an element match condition""" - condition = ElemMatch(Q.foo, Q > 1, Q < 5) - assert condition.to_dict() == {"foo": {"$elemMatch": {"$gt": 1, "$lt": 5}}} - - -def test_exists(): - """Should generate an exists test condition""" - assert Exists(Q.foo, True).to_dict() == {"foo": {"$exists": True}} - - -def test_in(): - """Should generate a contains condition""" - assert In(Q.foo, [1, 2, 3]).to_dict() == {"foo": {"$in": [1, 2, 3]}} - - -def test_not(): - """Should generate a logical not condition""" - assert Not(Q.foo == 123).to_dict() == {"foo": {"$not": {"$eq": 123}}} - - -def test_not_in(): - """Should generate a does not contain condition""" - assert NotIn(Q.foo, [1, 2, 3]).to_dict() == {"foo": {"$nin": [1, 2, 3]}} - - -def test_size(): - """Should generate a array value size matches condition""" - assert Size(Q.foo, 2).to_dict() == {"foo": {"$size": 2}} - - -def test_type(): - """Should generate a equals instance of condition""" - assert Type(Q.foo, 1).to_dict() == {"foo": {"$type": 1}} - - -# Groups - - -def test_and(): - """Should group one or more conditions by a logical and""" - assert And(Q.foo == 123, Q.bar != 456).to_dict() == {"$and": [{"foo": 123}, {"bar": {"$ne": 456}}]} - - -def test_or(): - """Should group one or more conditions by a logical or""" - assert Or(Q.foo == 123, Q.bar != 456).to_dict() == {"$or": [{"foo": 123}, {"bar": {"$ne": 456}}]} - - -def test_nor(): - """Should group one or more conditions by a logical nor""" - assert Nor(Q.foo == 123, Q.bar != 456).to_dict() == {"$nor": [{"foo": 123}, {"bar": {"$ne": 456}}]} - - -# Sorting - - -def test_sort_by(): - """Should return sort instructions for a list of `Q` instances""" - assert SortBy(Q.dob.desc, Q.name) == [("dob", -1), ("name", 1)] diff --git a/tests/test_spec_transactions.py b/tests/test_spec_transactions.py deleted file mode 100644 index c09ecf9..0000000 --- a/tests/test_spec_transactions.py +++ /dev/null @@ -1,136 +0,0 @@ -from unittest.mock import MagicMock, patch - -import pytest -from pymongo.errors import ConnectionFailure, OperationFailure - -from mongospecs.pydantic import Spec - - -@pytest.fixture -def mock_client(): - """Fixture to create a mock MongoDB client with operation verification.""" - with patch("pymongo.MongoClient") as mock_client: - # Create mock session and database - mock_session = MagicMock() - mock_db = MagicMock() - mock_db.Spec = MagicMock() - - # Set up returns - mock_client.start_session.return_value = mock_session - mock_client.get_default_database.return_value = mock_db - - # Store session for verification - mock_client.mock_session = mock_session - mock_client.mock_db = mock_db - - def verify_operation_used_session(operation_name): - """Verify that a database operation was called with the session.""" - operation = getattr(mock_db.Spec, operation_name) - assert any( - call.kwargs.get("session") == mock_session for call in operation.call_args_list - ), f"{operation_name} was not called with the correct session" - - # Add verification helper - mock_client.verify_operation_used_session = verify_operation_used_session - - yield mock_client - - -@pytest.fixture(autouse=True) -def setup_and_teardown(mock_client): - """Setup and teardown for each test.""" - Spec._client = mock_client - yield - Spec._client = None # Reset after each test - - -def test_operations_use_session(mock_client): - with Spec.transaction() as session: - spec_instance = Spec() - spec_instance.insert(session=session) - spec_instance.update(session=session) - Spec.find(session=session) - Spec.find_one(session=session) - mock_client.verify_operation_used_session("insert_one") - mock_client.verify_operation_used_session("update_one") - mock_client.verify_operation_used_session("find") - mock_client.verify_operation_used_session("find_one") - - session.commit_transaction.assert_called_once() - session.end_session.assert_called_once() - - -def test_transaction_success(mock_client): - """Test that a transaction can be successfully committed.""" - with Spec.transaction() as session: - # Simulate successful operations - session.commit_transaction.assert_not_called() # Ensure commit is not called yet - - # Simulate an insert operation - spec_instance = Spec() - spec_instance.insert(session=session) - - # Commit should be called after the operations - session.commit_transaction.assert_called_once() - session.end_session.assert_called_once() - - -@pytest.mark.parametrize("exception", [OperationFailure, ConnectionFailure]) -def test_transaction_abort(mock_client, exception): - """Test that a transaction is aborted on error.""" - with pytest.raises(exception): - with Spec.transaction() as session: - # Simulate an operation that raises an exception - raise exception("Simulated failure") - - # Ensure that abort_transaction is called - session.abort_transaction.assert_called_once() - - -def test_missing_client(): - """Test that a RuntimeError is raised if _client is not set.""" - Spec._client = None # Ensure the client is not set - with pytest.raises(RuntimeError, match=r"MongoDB client \(_client\) is not set\. Cannot start a transaction\."): - with Spec.transaction(): - pass - - -def test_transaction_signals_committed(mock_client): - """Test that transaction signals are emitted correctly.""" - committed_signal_received = [] - - def record_commit(sender): - committed_signal_received.append(sender) - - Spec.listen("transaction_committed", record_commit) - - with Spec.transaction() as session: - # Simulate successful operations - spec_instance = Spec() - spec_instance.insert(session=session) - - assert len(committed_signal_received) == 1 - assert committed_signal_received[0] == Spec - - Spec.stop_listening("transaction_committed", record_commit) - - -def test_transaction_signals_aborted(mock_client): - """Test that transaction signals are emitted correctly for aborted.""" - aborted_signal_received = [] - - def abort_commit(sender): - aborted_signal_received.append(sender) - - Spec.listen("transaction_aborted", abort_commit) - - with pytest.raises(OperationFailure): - with Spec.transaction() as session: - spec_instance = Spec() - spec_instance.insert(session=session) - raise OperationFailure("Simulated operation failure") - - assert len(aborted_signal_received) == 1 - assert aborted_signal_received[0] == Spec - - Spec.stop_listening("transaction_aborted", abort_commit) diff --git a/uv.lock b/uv.lock new file mode 100644 index 0000000..a97f539 --- /dev/null +++ b/uv.lock @@ -0,0 +1,2118 @@ +version = 1 +revision = 1 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.13' and python_full_version < '3.15'", + "python_full_version < '3.13'", +] + +[manifest] +members = [ + "mongospecs", + "mongospecs-bench", + "mongospecs-web", + "mongospecs-workspace", +] + +[[package]] +name = "aiofiles" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/c3/534eac40372d8ee36ef40df62ec129bee4fdb5ad9706e58a29be53b2c970/aiofiles-25.1.0.tar.gz", hash = "sha256:a8d728f0a29de45dc521f18f07297428d56992a742f0cd2701ba86e44d23d5b2", size = 46354 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/8a/340a1555ae33d7354dbca4faa54948d76d89a27ceef032c8c3bc661d003e/aiofiles-25.1.0-py3-none-any.whl", hash = "sha256:abe311e527c862958650f9438e859c1fa7568a141b22abcd015e120e86a85695", size = 14668 }, +] + +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302 }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427 }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079 }, +] + +[[package]] +name = "ast-serialize" +version = "0.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d2/7c/dcde7ac261e0bfbb0360c207a1e3f9fd467f9bfaeaff7b62d01625bc86d2/ast_serialize-0.13.0.tar.gz", hash = "sha256:a0bdcef01e643e0810d2dedfb64d924bcfe079a15dc20d1c067870cc01d5c5e6", size = 954999 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/86/6d1e047a41fc360ca4c7f8d1d3d0727302d7e0b0af4933a462c18390a00a/ast_serialize-0.13.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:4d1e15da4b6afc6fe80b87704be452aa0639df93d019a516e9ac9540357fc9b2", size = 897213 }, + { url = "https://files.pythonhosted.org/packages/e2/1e/4a55b8d219616dcb9523c062dd4110ca7dd9d3c99a0ea169ce1de049e8dc/ast_serialize-0.13.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:619050b18705310e19e254cdb7554289fe14da374cbfbb1362cd84635896fb7f", size = 1236396 }, + { url = "https://files.pythonhosted.org/packages/be/ad/93ec0502d21691152a26103f76064c4a5bef6945866cadd8288485907306/ast_serialize-0.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7ce1b50c5a68233e890926405afc308a5f10f6f49ec3a094d3dfa8b6733e4496", size = 1217965 }, + { url = "https://files.pythonhosted.org/packages/86/37/12412da699bdfde52233836c98a30abc6086dbe17bdf0ad3497788e719c3/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6bab08a6f287cd620578084f9974cfaf3bef71959105f62af85fa70298b24851", size = 1282094 }, + { url = "https://files.pythonhosted.org/packages/74/a4/42ba2ca24865442c6aea14bc648a0800181f76dde7e6e3f4036887cf7ce6/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4e0bc018a457052d4638b469f90674e6ec0e32d86ac4a7bfa1f7d1c71a961426", size = 1287429 }, + { url = "https://files.pythonhosted.org/packages/1f/ec/3a5923554ea5f7148ea5742ffbef2f65deed68a8a5cbad6ca5f744adfcf2/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:452fdaf5ff0b791870bb332254e083107d7abe29ef43251411c265c5b138f9a2", size = 1531892 }, + { url = "https://files.pythonhosted.org/packages/ae/b4/e61038bf0dfb42c208e96e3ef953855e3bdb435f015f9dbf14e721c98066/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:12441bc7e41e495db5634c98adc8f8886b619ce2f1e68effe3f792a2adca9f47", size = 1309544 }, + { url = "https://files.pythonhosted.org/packages/46/a2/5fc3e341f00711931bae32af6937122e205173d8521d1f7282b6cc2cc0f1/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:84cd9efdd3cd780b1f5361049becd91e0bcb0f16c2c216f41ee82e728a98390b", size = 1301823 }, + { url = "https://files.pythonhosted.org/packages/42/51/592a4640cb7a88cd27c457a0448c5a635cf5c01c78b693dd6ef4dfe4d3bf/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4dc7a24c734aded0557ef90bfabd2278b37502aacde84f49b53b4cb6a0711ea9", size = 1298520 }, + { url = "https://files.pythonhosted.org/packages/66/f6/7d938404c3b94e3a18b2b53a4250abf3f28f38caf46b6e381020e83977ab/ast_serialize-0.13.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:6c95c04f1781cbefe89d512ce30e051d10823827ae542d9f18d5c2e7ba0fad08", size = 1355363 }, + { url = "https://files.pythonhosted.org/packages/ce/2e/88d437318054d5ce5c6c2be477258ae8d756620463221276fb0ae267fe93/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fadba24386498ed745c848b0d45e4939a506694bd2b474c57576e9640f3defe2", size = 1459864 }, + { url = "https://files.pythonhosted.org/packages/e3/6c/6e502e9d9e8296d621bcb9173ec4eda6498b80757c91974be3d6e89058d2/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:771cf5ee8329ee8472dd7a3d8bea7dbc480032ed8ddb4d37d40b57b95ee19ef2", size = 1563855 }, + { url = "https://files.pythonhosted.org/packages/70/0b/f162a027c5f0c7f3f782803ee58f10ba355e49ba3e7931649635bacf2c07/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:a1714ee591a8e19833c0530a89e5a9faa7f62a11fc88f62fc5b722c7425dcc1f", size = 1557563 }, + { url = "https://files.pythonhosted.org/packages/13/0e/71b81259135a68122486ce1522ddb1d8acdd3c11df260a3e7a337d428d90/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:220a993dfc8b173e7062f690f9e00f4ebefe56718171bf35dccd73a9f8cea100", size = 1664708 }, + { url = "https://files.pythonhosted.org/packages/a7/dc/9851e6b6c4771fa0d4b10fce536d427f184f85a0fb83ae19633f4c9337b9/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:446de6067d853f61b4bde8741762c83d06b57ea81f96dd47977714d9f31837fa", size = 1472153 }, + { url = "https://files.pythonhosted.org/packages/b5/5c/73bcf627607855408c500106baf849cd1cb24d2a95e480eda3c5245c908e/ast_serialize-0.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f0cc1f94fcd3b67005a32ee3e4c6b27cdc41659f697840d00fbb1e815ec27044", size = 1500997 }, + { url = "https://files.pythonhosted.org/packages/7e/b4/ee6942656e725a1e5e15d8680425356a3c96c2fbfb519f476cd82261e93d/ast_serialize-0.13.0-cp314-cp314t-win32.whl", hash = "sha256:77efef815ecae1195ac9889f616bd518d53b2173e87157043253b864af1c81c5", size = 1122428 }, + { url = "https://files.pythonhosted.org/packages/23/9b/dacfec064d40c3be7051a95140bb4f343491aba0de06b612aec64a84a8bf/ast_serialize-0.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c77e5b62dfbfdfc1b025105f5038114ae988a0e616d48a845e0e57173f3f37c8", size = 1158940 }, + { url = "https://files.pythonhosted.org/packages/70/75/f65e883e7cd0e804fdda0ee690aeebbf3a28b96eebc1588f99e9a24a9b3e/ast_serialize-0.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:8e7c6fec7fe03cb8f40c4af81d742aa0cf690cf0b7bded47508a8a392093f414", size = 1133534 }, + { url = "https://files.pythonhosted.org/packages/bf/b4/a791bafbc7b9ecc3e727ab54651667372f3b33a6d1449c28783aeb1e3835/ast_serialize-0.13.0-cp315-abi3.abi3t-macosx_10_12_x86_64.whl", hash = "sha256:9eaa20714acf43ef0a0c82850a2ec8097f834648f527c38f1483e2fd9a52cd3e", size = 1236335 }, + { url = "https://files.pythonhosted.org/packages/30/c9/4a8d26f08d8053d4623fde6c83989b240120718cbea0b59727284fedd311/ast_serialize-0.13.0-cp315-abi3.abi3t-macosx_11_0_arm64.whl", hash = "sha256:3f5d4a7fcc916026010bae2a154e5be2c05040e67ef5c494c66a5cf315c41e30", size = 1217138 }, + { url = "https://files.pythonhosted.org/packages/82/53/68ef7ef55880e2087dc585f319847bc6670192987aad2e3a79ba4fb700f9/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:807875ed8c5de739c8c55a45944336fcb9b8601d77fcee384a743cd0497211d6", size = 1282493 }, + { url = "https://files.pythonhosted.org/packages/e4/e7/ef9e47cd8b11cc3cfed2b37d64d3166197db2aa03e9707d04af440b58c82/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e611937e6e77448496489627ae2558b6f6143449b1fb33f8a495665212eee58a", size = 1287893 }, + { url = "https://files.pythonhosted.org/packages/d6/58/3426be929c1888ef63e4defcb053e900ecee819c646db55c5a7f60de32ca/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3bb8dd779c0a25478fe1db1a8b06dd4dd5e66077d6d0afe354acadb6d9aee7d0", size = 1533846 }, + { url = "https://files.pythonhosted.org/packages/0a/3d/1eab7d9974578c2d1c514934ed88d174bdd680780857958a72aad5b8c9d3/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6a49f2a01a6df3150e022087cec0bf0ad580fec8f38a17f124d07dbb115106d1", size = 1308580 }, + { url = "https://files.pythonhosted.org/packages/50/a2/6164467e272527c107dd2209c6c4963c48a1b40c914a560c0ce77027566d/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f30f0e59be30c0c9540e8908b14874bc8d3c1d52a4562a4cfb426b003bd6c28b", size = 1302242 }, + { url = "https://files.pythonhosted.org/packages/47/bd/9024bdeead34b560f8278fb5ac96098be9a3d65ba74c6d7accd9cab21e0e/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_31_riscv64.whl", hash = "sha256:be6b1a4ee49866c77eb8a50e9cbc845370e6c15230a126d0a71aeab35f31c78c", size = 1299515 }, + { url = "https://files.pythonhosted.org/packages/ef/d5/d1f5b9c0990fd0455d054c52bf461baa2b3ec4a93f6d8c633dbf895dd7fe/ast_serialize-0.13.0-cp315-abi3.abi3t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8da1a31e941adbea886a85fb25efc6f09353d58c665fdbc523a914e3d2e49fe", size = 1355301 }, + { url = "https://files.pythonhosted.org/packages/c7/0d/7c05f233ca26f1837a44880c2167de440191bc5d44c0f3c880162952d702/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_aarch64.whl", hash = "sha256:3a9469e4b93d87e4ee8f5c7e9462f973032793a24b9ae37ffee860220f17586a", size = 1459499 }, + { url = "https://files.pythonhosted.org/packages/06/72/04bf634442d7dc3246d77ac1738be7ee9524869e78097332c497178640a4/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_armv7l.whl", hash = "sha256:192aed400b2b92ebe41da17e856b9b6e17dbcebe0011ce4c6370d4a8a0486233", size = 1564419 }, + { url = "https://files.pythonhosted.org/packages/01/4d/0c693c6b60c2abd9e03450e37c88ec86e2ac57d7734f06c296f336a2f0cb/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_i686.whl", hash = "sha256:ee58f0db40f121ff0820242b286702700bc0ec58a53b6ac43ce4f43714d42e0d", size = 1557221 }, + { url = "https://files.pythonhosted.org/packages/44/35/f35b73694cf0daf539bfd74ab51502428b403d1ccc42358730b7e93ccd3f/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_ppc64le.whl", hash = "sha256:e241f68cf5060bff9b161b202b60d6d52161ff3777fe56eb6a9a6764fdb7fdd9", size = 1665471 }, + { url = "https://files.pythonhosted.org/packages/f3/fa/fd6ec20c4cc6063ca67671df8ce59ea9e1512deb149289e50f36b7d413a2/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_riscv64.whl", hash = "sha256:bd89da715b857a27c33fad713ea0561912c56f773ebd0fed2760cedd99724dc6", size = 1473045 }, + { url = "https://files.pythonhosted.org/packages/da/53/8fc26dac873859e5b9fc3117cae2ed6b4afb6dc6f9e2d6fb62ee9fcade72/ast_serialize-0.13.0-cp315-abi3.abi3t-musllinux_1_2_x86_64.whl", hash = "sha256:6cbfa6dae34d5686056ef7c40ce1d3e7e1de48555e3a2fed985aef2d1f869d9a", size = 1501600 }, + { url = "https://files.pythonhosted.org/packages/4f/35/363f4c3428b382d981d64fa871e468ddd9d48c9f0b969babbd462150ec98/ast_serialize-0.13.0-cp315-abi3.abi3t-win32.whl", hash = "sha256:6a406251363eeb5c7b85a405eddd123e627a531bd150dc673a7f5dd087743b5c", size = 1120541 }, + { url = "https://files.pythonhosted.org/packages/8b/07/df7ecee097d62214d042216fd2945207a10b681bb933daa64f238765cdb5/ast_serialize-0.13.0-cp315-abi3.abi3t-win_amd64.whl", hash = "sha256:cdd8fd066858b57ea2761b3d3989c90ea23913825bbb5453cc684c28bba3fb19", size = 1157906 }, + { url = "https://files.pythonhosted.org/packages/be/4f/7781e45103d530f9fc963416e0a0beef6a2608a04d18290902ae6b074073/ast_serialize-0.13.0-cp315-abi3.abi3t-win_arm64.whl", hash = "sha256:841262622499585f0610a927434db526578553d8dae270b90d4c419a385e46b7", size = 1133990 }, + { url = "https://files.pythonhosted.org/packages/6d/15/7284905fbd1600016f014ac3edc82199cc0f108f31d42221faa6067fe847/ast_serialize-0.13.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:efaab6400de8ee2d0e38695feccb0758acf11c1d0f8bc58a7090c566dd3ae88e", size = 897415 }, + { url = "https://files.pythonhosted.org/packages/a9/d1/2bbd7fe3ea2381410d0efb529e8af64027ea78bb58afad3c7ff641b33934/ast_serialize-0.13.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:c51c855d8b7d5403925599acd3c6fc91b321eba3bf46dcc9fe88fd2d6619dac7", size = 1243056 }, + { url = "https://files.pythonhosted.org/packages/09/b8/a7c8d5ccc0a31589750e476f629bd8681f9bd08421112c71a9c388bf8d7e/ast_serialize-0.13.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d47c8f0eedf0a41681c10a7c497fef3691c4a6b4af4de6c93a4916bb29712554", size = 1227662 }, + { url = "https://files.pythonhosted.org/packages/64/54/4d64557c43abbd425b49c8e0c5b27093ee915c27e22bd46ea84c07d94908/ast_serialize-0.13.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7d7376c611055f5ee44e5e13a2620dbc6846f47c583952c1704c8805154c2d2f", size = 1292779 }, + { url = "https://files.pythonhosted.org/packages/7c/6f/93ab5d55402ede8444d01848cf35546c5e7ae24e9bedced6b17f530e2ef5/ast_serialize-0.13.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fe2a3c8480e8e5eb41eaa958279b8c530b77b45065423f4ebd5223e118293055", size = 1295902 }, + { url = "https://files.pythonhosted.org/packages/f6/5f/7292ef873948a99d26c6648fe92f4911b10c0396216388adfdbfc79a70dc/ast_serialize-0.13.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:98edcd24240fa217d903c8f221bc05575baf38a87a207264ee7c800d21eef5a4", size = 1542236 }, + { url = "https://files.pythonhosted.org/packages/ad/bf/4028224723d2038392ca176cfa1f982b15f12f4606d62a7f681550600c80/ast_serialize-0.13.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19b1e8f4c088ce91053df310b444fceeddb39f728ca7d04272c983646e36314b", size = 1319397 }, + { url = "https://files.pythonhosted.org/packages/00/ef/8d710fb1a5cc0e1985d023bfdee62c77cccaec6be1dee2ed841074a1ee0b/ast_serialize-0.13.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:4f55668338bcb871e83ee21ba865fb08b7b4b13af9312742f9878c39f9d84ee3", size = 1312759 }, + { url = "https://files.pythonhosted.org/packages/b0/a3/d0cff408a37f253c1ce5fd061389217d4e2b19645fb3a4bd8f9582f5ed56/ast_serialize-0.13.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:a918572608ceb20fba8c2b83560f3d20be90effa4614589477b46d81672f0c3d", size = 1308696 }, + { url = "https://files.pythonhosted.org/packages/41/c5/0a84554655da9c235e4f80a4799c6a3d7ee5d8c1ea16009077a5d0e6f289/ast_serialize-0.13.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:b004c3bdab0beb45194cd66c0b8feea40d676e461d26296f9c791c8e3e1b7061", size = 1362306 }, + { url = "https://files.pythonhosted.org/packages/b1/12/d85d300fb0a628b8183bd9301df624a819160b9fb7a3db3745c097ea2672/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5e88733df5ffff9062b5ff2779cf402e0aa1a61ca3f7f84b577a1af2b7e09676", size = 1470459 }, + { url = "https://files.pythonhosted.org/packages/c6/96/5201ebd0eaa758de11e9b4802e094d995c8d7306ce07bf62cc54ba396122/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:017ddd4f22e727ef93e66df2d53340a6ff809b7e34cc2218f67918ae6239aad0", size = 1572881 }, + { url = "https://files.pythonhosted.org/packages/08/62/05e0447e112ebaabcfc5cbf86b6f2ea455e3e08b2aebbf1f957546fd6991/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:4b2ee61692de03009e6a8f372f24fbc2d4b768a2f51ecd426bb84acdfd6da3d1", size = 1568287 }, + { url = "https://files.pythonhosted.org/packages/69/04/eaa3aa0543a747375d2a6e0b58efbf20cf149d9c3014898c63b13aef70da/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:dffcffa543c8fcfb1ca941038eeae23e7f97ad8994e4d6f81fbd658cfa8cb440", size = 1674375 }, + { url = "https://files.pythonhosted.org/packages/6a/25/cf850c03635876e6c6e53cf853d18aaed1fecc8b4e9b8f8a9300c269dc90/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:3e20e9ca3952196b91798f77ef267c36c7b3470021f950aa361d9be003fb655f", size = 1482386 }, + { url = "https://files.pythonhosted.org/packages/28/b3/305c5144eade6c39a871f6ab33d6cbb6fbf7367c89bd4860cdbfd3453c71/ast_serialize-0.13.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:359fcebc49f855bd189cf568235dc84eabf521e00d03fce43135cad6484906bc", size = 1512130 }, + { url = "https://files.pythonhosted.org/packages/2d/a8/0b26e4e4b16e3ddc9b77b6b42a29b0d6cf11a9342523ed7f0eaf4f1e1c45/ast_serialize-0.13.0-cp39-abi3-win32.whl", hash = "sha256:684e191dd41b08b0b92692a181380a70cd76e3b6469606a40aae1ca6dab39e7d", size = 1130669 }, + { url = "https://files.pythonhosted.org/packages/5d/09/862169d471439eb11961692f867b09f34f0560977cafa042305705d2d0a4/ast_serialize-0.13.0-cp39-abi3-win_amd64.whl", hash = "sha256:8f672c8e6d3b9ef6e365a5543aee2012247d1d58d948ceddb75b33a6679609ca", size = 1167803 }, + { url = "https://files.pythonhosted.org/packages/d6/85/c1c583ec96be412c4119a0ba4354c1e3519464036dd8d2909536661bed6a/ast_serialize-0.13.0-cp39-abi3-win_arm64.whl", hash = "sha256:8aff1682f9fa3e119a1cf8ef47504d38f7019b22b79e0f2b5c2135b9532d14db", size = 1138203 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, +] + +[[package]] +name = "babel" +version = "2.18.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845 }, +] + +[[package]] +name = "backrefs" +version = "8.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/68/bc/c4b75b0279b2cf9e208c7dea5755a452d158a32abe1b7330824ff1127e86/backrefs-8.1.tar.gz", hash = "sha256:f86d9312df3ebae1241758af80809a319cafa1d8f32189edfb242c9aa55573b6", size = 6999089 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/da/17/7be730f397b9239be5044a147f0cdfbd9605111b07af053c737b21d79d16/backrefs-8.1-py310-none-any.whl", hash = "sha256:438bd38dc7fa0cded52786a1351a2dad9efc2cda95d047284c41d916a22312a8", size = 380382 }, + { url = "https://files.pythonhosted.org/packages/fd/d1/d25387b14119b7062fead6c9b2e87c2ab286beb346df79c6a649c095e979/backrefs-8.1-py311-none-any.whl", hash = "sha256:11f61846e446fa8637af2f5c073f1083aa58dbb7460b019fd51689f564e44f7e", size = 392180 }, + { url = "https://files.pythonhosted.org/packages/7d/63/4c3078fcbb3956c55b24472d8375a1b7ad67203d4a1917512eee34d4fbee/backrefs-8.1-py312-none-any.whl", hash = "sha256:b472c76082769f86a97ffccfd1f2599b2b706610c6154955664fffe67e909035", size = 398092 }, + { url = "https://files.pythonhosted.org/packages/df/22/60f30c9b8e497ca4bbac23991a726ebc267e23771a1b9d9c04b3a3c16dd6/backrefs-8.1-py313-none-any.whl", hash = "sha256:db577dbae1c61187534d35fc9bfbd0c01158fff6239a66fecf7590e5b17879a9", size = 400050 }, + { url = "https://files.pythonhosted.org/packages/45/c7/80fa46ecb47717845ab9c07bce8651ef8f80aac5ec698e212d02ed2a9963/backrefs-8.1-py314-none-any.whl", hash = "sha256:1e857c32e91b8fe46ddb359ac21021a37b090fa4239566a61e9856123e334041", size = 411908 }, + { url = "https://files.pythonhosted.org/packages/05/32/a57c5a5b7461b8c88266244014aa38052d76f64b064451b1d5342eb09bf4/backrefs-8.1-py315-none-any.whl", hash = "sha256:9c8fe3e2eaae4301a9ce787341d986e5ba25a887d89da6de0de5d58eca26a949", size = 417589 }, +] + +[[package]] +name = "blinker" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/21/28/9b3f50ce0e048515135495f198351908d99540d69bfdc8c1d15b73dc55ce/blinker-1.9.0.tar.gz", hash = "sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf", size = 22460 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458 }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 }, +] + +[[package]] +name = "charset-normalizer" +version = "3.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642 }, + { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280 }, + { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335 }, + { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707 }, + { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747 }, + { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972 }, + { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339 }, + { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683 }, + { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143 }, + { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358 }, + { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766 }, + { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432 }, + { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470 }, + { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688 }, + { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932 }, + { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828 }, + { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780 }, + { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730 }, + { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707 }, + { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050 }, + { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345 }, + { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601 }, + { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222 }, + { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482 }, + { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206 }, + { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190 }, + { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527 }, + { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285 }, + { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010 }, + { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125 }, + { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486 }, + { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734 }, + { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946 }, + { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619 }, + { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701 }, + { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506 }, + { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626 }, + { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624 }, + { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731 }, + { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352 }, + { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393 }, + { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660 }, + { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996 }, + { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028 }, + { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562 }, + { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699 }, + { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030 }, + { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565 }, + { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627 }, + { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407 }, + { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207 }, + { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489 }, + { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501 }, + { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010 }, + { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266 }, + { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783 }, + { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449 }, + { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815 }, + { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095 }, + { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942 }, + { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762 }, + { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514 }, + { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056 }, + { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311 }, + { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190 }, + { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098 }, + { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228 }, + { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401 }, + { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846 }, + { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151 }, + { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570 }, + { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412 }, + { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965 }, + { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406 }, + { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871 }, + { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711 }, + { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146 }, + { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552 }, + { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810 }, + { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845 }, + { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825 }, + { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659 }, + { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819 }, + { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973 }, + { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692 }, + { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757 }, + { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220 }, + { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797 }, + { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765 }, + { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985 }, + { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676 }, + { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414 }, + { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775 }, + { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877 }, + { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455 }, + { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336 }, + { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584 }, + { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854 }, + { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767 }, + { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479 }, + { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310 }, + { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100 }, + { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936 }, + { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301 }, + { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858 }, + { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309 }, + { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343 }, + { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114 }, + { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997 }, + { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313 }, + { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986 }, + { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202 }, + { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083 }, + { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081 }, + { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712 }, + { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176 }, + { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413 }, + { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434 }, + { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716 }, + { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159 }, + { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322 }, + { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192 }, + { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815 }, + { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276 }, + { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239 }, + { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121 }, + { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350 }, + { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430 }, + { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612 }, + { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083 }, + { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738 }, + { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703 }, + { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622 }, + { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500 }, + { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100 }, + { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499 }, + { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092 }, + { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363 }, + { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872 }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "dnspython" +version = "2.9.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ef/4a/50822184bd67cc6493f0fb6a880749158fcd31ab3fa07409acfd91f9fc85/dnspython-2.9.0.tar.gz", hash = "sha256:b44dc6b18f07a8b1c56676a19fbfdb5209415b046a9cece286baafa87ff3f7f1", size = 423560 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/10/02/cdcc9b7c051786a103c3b09e1003a82fa0c66bcb91ffbdabcfbf7b4163b9/dnspython-2.9.0-py3-none-any.whl", hash = "sha256:9a4aedb833c3c1b49214d04d44d3032ab7a9135f7c1d29a549b4ff78fd82fda9", size = 354822 }, +] + +[[package]] +name = "faker" +version = "40.43.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/33/6c/b8793efc2f00a912ef17cf0b61b717cddf34607499efcb8a32238c119368/faker-40.43.0.tar.gz", hash = "sha256:02fae4327c03a4a6315e1b428a3878f435bfc276c93435ea349b95c0c9372361", size = 2032713 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/ed/0d6d0d6467ae3d009fb82fb411867c7fcfeadbdd25602cab0d7a7963f400/faker-40.43.0-py3-none-any.whl", hash = "sha256:9dd7c0ddfaf30c842b05502d3cf641c135e0120a3a19047008ba8525b72953ed", size = 2069082 }, +] + +[[package]] +name = "fastapi" +version = "0.143.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "opentelemetry-api" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0b/d7/6a8753ab6c1d432dc53703c3e1b92974a94531b7d047c32bbaae461ea844/fastapi-0.143.0.tar.gz", hash = "sha256:1acffe48206a80917cf7dac21992b5c44b25384e8902bf745c1fd9dabcf6c51f", size = 468391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bd/f4/27e386913417ad32aae42bba48b0c0cce40e9ff2fba1a871ca2702c37324/fastapi-0.143.0-py3-none-any.whl", hash = "sha256:3e9395fd35276425b61b516a31fdd7c77fe2af83e41b4da22e30696fb1304c5d", size = 144665 }, +] + +[[package]] +name = "flask" +version = "3.1.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "blinker" }, + { name = "click" }, + { name = "itsdangerous" }, + { name = "jinja2" }, + { name = "markupsafe" }, + { name = "werkzeug" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/00/35d85dcce6c57fdc871f3867d465d780f302a175ea360f62533f12b27e2b/flask-3.1.3.tar.gz", hash = "sha256:0ef0e52b8a9cd932855379197dd8f94047b359ca0a78695144304cb45f87c9eb", size = 759004 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424 }, +] + +[[package]] +name = "ghp-import" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dateutil" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, +] + +[[package]] +name = "griffelib" +version = "2.3.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2b/27/b55f1a5278918be765fb2fd8b20966bc72bbdd3f789f031937cceea7834a/griffelib-2.3.2.tar.gz", hash = "sha256:df00c7a0dee3d86268d76788997a1859272cb1fb7b865658e043d2c0c3d52e60", size = 234729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/e5/0ae74c83c1cab2c14daadf28bd0143eb14bb81503834af987b66badc7b61/griffelib-2.3.2-py3-none-any.whl", hash = "sha256:8e710afededd5607f95bf3c8ccc175ee306e7084459faf27b9f9f44ef2a7a274", size = 169420 }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515 }, +] + +[[package]] +name = "h2" +version = "4.4.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "hpack" }, + { name = "hyperframe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e7/85/7c366e69d84c17bb778fe41419e1fbcce3033d5b7ce29bbffff0a98b859f/h2-4.4.1.tar.gz", hash = "sha256:4e866ffb1a869ae14dd9b5e6beb5c24a13da0495ad72b65925ded182521c1516", size = 2157281 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/22/e85faf23bd72a92d1921e37d674ca56eb298a3c8be31fdecef0ff2b3aaac/h2-4.4.1-py3-none-any.whl", hash = "sha256:0e25f1462b23c9cb82d9eb02e28bc706dac2a68cb457c6a0d74d63c8a2a5d0e6", size = 62636 }, +] + +[[package]] +name = "hpack" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/26/5b/fcabf6028144a8723726318b07a32c2f3314acdff6265743cf08a344b18e/hpack-4.2.0.tar.gz", hash = "sha256:0895cfa3b5531fc65fe439c05eb65144f123bf7a394fcaa56aa423548d8e45c0", size = 51300 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/b4/4a9fcfb2aef6ba44d9073ecd301443aa00b3dac95de5619f2a7de7ec8a91/hpack-4.2.0-py3-none-any.whl", hash = "sha256:858ac0b02280fa582b5080d68db0899c62a80375e0e5413a74970c5e518b6986", size = 34246 }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784 }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517 }, +] + +[[package]] +name = "hypercorn" +version = "0.18.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, + { name = "h2" }, + { name = "priority" }, + { name = "wsproto" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/44/01/39f41a014b83dd5c795217362f2ca9071cf243e6a75bdcd6cd5b944658cc/hypercorn-0.18.0.tar.gz", hash = "sha256:d63267548939c46b0247dc8e5b45a9947590e35e64ee73a23c074aa3cf88e9da", size = 68420 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/35/850277d1b17b206bd10874c8a9a3f52e059452fb49bb0d22cbb908f6038b/hypercorn-0.18.0-py3-none-any.whl", hash = "sha256:225e268f2c1c2f28f6d8f6db8f40cb8c992963610c5725e13ccfcddccb24b1cd", size = 61640 }, +] + +[[package]] +name = "hyperframe" +version = "6.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/02/e7/94f8232d4a74cc99514c13a9f995811485a6903d48e5d952771ef6322e30/hyperframe-6.1.0.tar.gz", hash = "sha256:f630908a00854a7adeabd6382b43923a4c4cd4b821fcb527e6ab9e15382a3b08", size = 26566 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/30/47d0bf6072f7252e6521f3447ccfa40b421b6824517f82854703d0f5a98b/hyperframe-6.1.0-py3-none-any.whl", hash = "sha256:b03380493a519fce58ea5af42e4a42317bf9bd425596f7a0835ffce80f1a42e5", size = 13007 }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/e1/2069291243c926a2ff1cd706c7f3eeb9b62144bf60f77c9fb9ff2fb26bd3/iniconfig-2.3.1.tar.gz", hash = "sha256:67f4b9c50da0dedf52af349e7749a80a9057a5031199791b906c3bb3ae878960", size = 21209 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/56/43/4ca9e49d27a1fcf6bece6f6aec0ea46bb9112489b93d4b688fb415457bdb/iniconfig-2.3.1-py3-none-any.whl", hash = "sha256:9121e2c1fdb355232495be3194c8dfe87ccc2d5dee45947b78e68f499790d7a7", size = 7552 }, +] + +[[package]] +name = "itsdangerous" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/cb/8ac0172223afbccb63986cc25049b154ecfb5e85932587206f42317be31d/itsdangerous-2.2.0.tar.gz", hash = "sha256:e0050c0b7da1eea53ffaf149c0cfbb5c6e2e2b69c4bef22c81fa6eb73e5f6173", size = 54410 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/96/92447566d16df59b2a776c0fb82dbc4d9e07cd95062562af01e408583fc4/itsdangerous-2.2.0-py3-none-any.whl", hash = "sha256:c6242fc49e35958c8b15141343aa660db5fc54d4f13a1db01a3f5891b98700ef", size = 16234 }, +] + +[[package]] +name = "jinja2" +version = "3.1.6" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, +] + +[[package]] +name = "librt" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/f5/9dc696772d241814bacac7880bac32f2930b5a6ebc1f85317b83161a011c/librt-0.16.0.tar.gz", hash = "sha256:ac38d6d8d66bf3d744148dbbc0b8e193e195a51e364ed55e224631f5721891fc", size = 219839 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/0e/b04fd2d76b6a78a02286938251b2c119fb36ddece92c76cb4886ef7d44b8/librt-0.16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fe4372c52d4849096c6cc1cda2817d293ec51440c890474ed59ef38d46556f18", size = 148058 }, + { url = "https://files.pythonhosted.org/packages/a3/fc/e1ed24aadb32d38603c0c07f579d739eb4d90e64e90d525b024e4a999b4d/librt-0.16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c72c5295a84bd249526da9bdca38f2e176d15c31c13bb0063c5053f4ca023421", size = 153073 }, + { url = "https://files.pythonhosted.org/packages/be/38/133ab655409420e0c34a9a9836a7f4e5ae75fba758d7fe177099130d58ba/librt-0.16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be56ba9c884143495b517f23fe794ae367d58cd89ea0fdd6d437e3c024a87f9f", size = 493052 }, + { url = "https://files.pythonhosted.org/packages/94/42/aaa663bc9421f5bf7bbbe9d3316d7219749698ec7688ac3b519770a89c04/librt-0.16.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ef46c1a29ffb8c72e882e22618ec618778eacd0578fb22c6e7cf9c11d15f357b", size = 485506 }, + { url = "https://files.pythonhosted.org/packages/7b/59/83a6eb5087ba8b45b083859e2085256963e06d4c2531944cdd962670bfb5/librt-0.16.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d3c94211ee0c4f8d649ec06b7c115c0ec4eadb873a0e3154ca15cef3f814b071", size = 498708 }, + { url = "https://files.pythonhosted.org/packages/a0/e4/15a8d62b2bc12935080ec7277e2e0b9b233047781eb784a51d3e25af669a/librt-0.16.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:94aed6a8308818b91677957d1bd03188869cd7aeb23c5dba7912a6c0402f7602", size = 515981 }, + { url = "https://files.pythonhosted.org/packages/46/4f/287cc282ac5dd815204c0779e79401bb0a568c3a1335fb319bf0838fb566/librt-0.16.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:349c0bcb87ebd07481b6ff781e25cdc699723dbe2212e57dabb27f7a13b7b87d", size = 508670 }, + { url = "https://files.pythonhosted.org/packages/bf/5b/ab7292add898ade0d2b5b75d32a923aae315861671aa5c39f5491309f7ae/librt-0.16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:001bfd59a7d45b17e3e75f2a8c6405280b35e7b84471792778e718c4f368950e", size = 530393 }, + { url = "https://files.pythonhosted.org/packages/7a/9e/8e6bcf027d4bcbc16a4e864b3d158a1d3e5f55ffa53af0238020cad89271/librt-0.16.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:28e038895b998d7a0c7798922ce8a1dc157675df5cf1c9ef0aca809ed804b7a1", size = 534302 }, + { url = "https://files.pythonhosted.org/packages/96/a4/8c5ab4f3b3a8e905ff8305d76976a2e666f52fe049edd398aed2402beb36/librt-0.16.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:0dbe4096a7ecc00fa835d24510ad8545a4efef738dac96e0e63516783ccde905", size = 536150 }, + { url = "https://files.pythonhosted.org/packages/ea/5e/7c4bc267e3cd4b8bd288de9f62d61fff203829c9cc649ea2aa12e9218cf0/librt-0.16.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:5cd5b092441053364af968ea12084692cb9d4a22f3ce9524e377880bf028761e", size = 514316 }, + { url = "https://files.pythonhosted.org/packages/e0/75/c8bc6b50f43c73819334233f5a3259c81216742cef56d2140c3dd377c3f0/librt-0.16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:3ddeb3c9dedb461bb457c6c7d9aa7fbf35329da313d1a7543d00c8d0f3473c96", size = 557731 }, + { url = "https://files.pythonhosted.org/packages/93/fb/e241f91bc8de6c7ac1c6a20ea6dd381bc52312b44d711ffcd97140db1e57/librt-0.16.0-cp311-cp311-win32.whl", hash = "sha256:e05108e0849966f53a8d2d3112a7af881d0efaa479bc735bba91108f9f2350a7", size = 104922 }, + { url = "https://files.pythonhosted.org/packages/d1/38/c892383d2bb1a6a80b2ec8ede84970a265a792c56614a06282bf3008517c/librt-0.16.0-cp311-cp311-win_amd64.whl", hash = "sha256:5f49cff01bd608ef7d97104cb035c75455e79c2d70bf4a506cf773338ac1860d", size = 125093 }, + { url = "https://files.pythonhosted.org/packages/e4/56/8bbde1ed0b21480163617648f638fdaec9374cf5e9c846ebcee34ec02a42/librt-0.16.0-cp311-cp311-win_arm64.whl", hash = "sha256:1d28ae980ae2218f9c5b95d191e947296f918c9bf0b400d467a9430275bbe678", size = 115910 }, + { url = "https://files.pythonhosted.org/packages/ad/76/bbdaeb87b7c47b5c7343e90222b9bfa4e4a8a83f647e02933ad0129225b1/librt-0.16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fe52bf4641069e7978a14253b036cb9002def1926317e710f2e249f8a8c47742", size = 151028 }, + { url = "https://files.pythonhosted.org/packages/fd/0c/ab8ed3dab0085931aec4a792c7eaac8dc6c5ff4691fda3a5360d9d8a9cd2/librt-0.16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:5bcc2c4726ced915b00de0c9856a4eeabfb3fddb93e10e0b8f735b7709358b6d", size = 155283 }, + { url = "https://files.pythonhosted.org/packages/eb/36/494e79d460c80c1f030661e8287c9eca5e1ad652dc2b2180b6cd42abce0a/librt-0.16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff7baa55f8e7c69851419e50a666015d02a74198716fd45c0125a2112e0a389f", size = 503185 }, + { url = "https://files.pythonhosted.org/packages/9b/34/a8464038dd9db6e4381fa2b6eb73dc9a50888d77102c4c139304ac35cddc/librt-0.16.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b95d5d92ab83d39e760a52091bb1baba664f3a2351e39b1e16801e5747c2f0e9", size = 496738 }, + { url = "https://files.pythonhosted.org/packages/6a/53/e0e5e334ef0c6ed27039d323819368b9ef6712be87d55ee2bf9799398afd/librt-0.16.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b6d085d70bce51d43c5c7c36d63490770180d8779e71c49305c87b4213918de7", size = 513818 }, + { url = "https://files.pythonhosted.org/packages/ad/f7/7ce72cbf19d0addd05090b339152fd0548a02562c2866a603e6e3b3da2df/librt-0.16.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:36e53948e99bbe3ffea257124cfcae1cfb01831555c9a9c903c9f9a72db7fd07", size = 531952 }, + { url = "https://files.pythonhosted.org/packages/83/22/0b1bcb6a8e723c8b4fd60dfc8ae8ec6461c54073fbc8685efeb8d900d407/librt-0.16.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:54d11f726aae9df5a6ffbbf0a03a52449bbac84a53ef03669cb41cdfd4ae41bf", size = 524465 }, + { url = "https://files.pythonhosted.org/packages/64/2e/e9c23b8b9df1813da1be205deca9606beb7ddd033972246cd426d05374a0/librt-0.16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4323193ac0cd025f85af531df8ba91bf24d1973b401697347a6282e8fd3fcf5e", size = 543191 }, + { url = "https://files.pythonhosted.org/packages/bc/e5/6a8b21b342c03ed7e230fa3afbfd2edc58e6e87ef1f0d11fa2b9a748c252/librt-0.16.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e42f8e098b9c5396fefa05fb1cc7e33b0e08fc51da106b5de4a45fd22aac6743", size = 546979 }, + { url = "https://files.pythonhosted.org/packages/84/9e/b5129023eced1be01e01c22757f53be551d463b1bb7264f787927404c1d6/librt-0.16.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:39ec1d5a14e37baf1450a6cabf03fe552340808bf1ad9d71824ab90117716459", size = 555317 }, + { url = "https://files.pythonhosted.org/packages/71/89/28bba5938c725fe91f06bf93f7fa6c6b150229df53a87b454b0d5c2a796e/librt-0.16.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d1aabe3925cbb4a08d15b7b20ba4011b53019da0c4173a25155139b7b1baed65", size = 535846 }, + { url = "https://files.pythonhosted.org/packages/22/92/63773026614f888c5d4e370e395ce42ca604b89f70b3acdfedbf94851b80/librt-0.16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:300c3ffdc459f4a779a8411ecb188e3ac0b1ff3a3a7b099642555dedae06c69b", size = 573700 }, + { url = "https://files.pythonhosted.org/packages/66/8f/347d4677eefb57cd9f8e01d95a1dcee9a91b4e44e664173c32ff6f3752e5/librt-0.16.0-cp312-cp312-win32.whl", hash = "sha256:c17194318e4c0c0348b36f36c2ec7534436fe0a4c15582403162a4f08c80797a", size = 106158 }, + { url = "https://files.pythonhosted.org/packages/f0/2c/5193dc81127cd5ddfad031391b046bf32dda219b38463ab872407ca30646/librt-0.16.0-cp312-cp312-win_amd64.whl", hash = "sha256:25a58a19ea8d83b68209f04912df765e9260635ef77646542ed4b4abe6bc7940", size = 126176 }, + { url = "https://files.pythonhosted.org/packages/ff/3d/9668a400c8dd81d162eba38b33fa49fa6205f1a64493570a13fbb815c3ee/librt-0.16.0-cp312-cp312-win_arm64.whl", hash = "sha256:f7be7cf555bc30ec12622e9447299cc4a9b8ff307548b634794353db0c2065dc", size = 116572 }, + { url = "https://files.pythonhosted.org/packages/46/cd/ae5e0e9dba45d1399aa04a5395bcc0bead40d9fa06dc903634a7b4d7473d/librt-0.16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5e6144e68b577f157519f2ba88ca20e3ed61c29b00e5cdfa76cd2d45acf059a", size = 151036 }, + { url = "https://files.pythonhosted.org/packages/41/5a/48a16e323c5f9447a94cce7b59babf60fa04e62c3365ecf060c77ed8b320/librt-0.16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:33f41443a1f4e1f099331b3d8120e409fbff84b9760bc1cc9ea496f37ddaa5cc", size = 155211 }, + { url = "https://files.pythonhosted.org/packages/3f/29/0f59299eb4251a409b2e690ad4b7d9f8a676db829d7817ec961f32b44f7e/librt-0.16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e510b7770bee609617a3374a96548eb114cae048023e3f049ee449e7ff2db32", size = 502587 }, + { url = "https://files.pythonhosted.org/packages/de/ba/d6fb4ef8d1537c396079d72289f16be7cd35a366e5065c51253fea2760b6/librt-0.16.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:efc49c462d4516b8a58b00b490078fa64689fd1fe66970cc190131d7afb8027e", size = 496146 }, + { url = "https://files.pythonhosted.org/packages/52/fc/8c50dd4d7cc97c0ee8f252c8a3104980f234391cf1519b554e8b9de08b60/librt-0.16.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:92caf82ebef5e12d21c72242b70d1e92536f1711cf2a727a4c276de4b4469087", size = 513336 }, + { url = "https://files.pythonhosted.org/packages/a9/59/16c409c56f708eda2db9a0553662845d45e3871c77d70a240dae3f3bdc56/librt-0.16.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:17bac7f7a16b328fff77e440287693eb017abde913595b5827ebccbc21ecd8a6", size = 531689 }, + { url = "https://files.pythonhosted.org/packages/88/82/d34772a6c29d1446dcca6e64d74062efd508523ab351aa16625a4689d5cc/librt-0.16.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5b976054553670829985ed767feb78fb6bcede0175327c4844dd5c281c1be659", size = 524537 }, + { url = "https://files.pythonhosted.org/packages/77/8f/24c5631313746131ccee53bc91fdc8374f9cf25e0082a1fee9c93bb98acc/librt-0.16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0058f9d68721094105917254c72ac0569117bb7b13b9769cf45d26d89f9d21cd", size = 543231 }, + { url = "https://files.pythonhosted.org/packages/f2/cb/5f8e0d41dbd8b499c2265e939c31acc9ba59845565bf99539ad1c06aebcf/librt-0.16.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:30b7beaf3f4487b7d8adef1f158b49067cb4d5a19fa7a3bf31a4e7a820e435c5", size = 546499 }, + { url = "https://files.pythonhosted.org/packages/be/61/063052de441d1385f59cea4223f184bf9e5d125de1ae3239b490aa1e486e/librt-0.16.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:468df902df016a06eb0e40b0747dc8d14e47d7a38b18b63b1fb167d85cb94d63", size = 555261 }, + { url = "https://files.pythonhosted.org/packages/14/11/a2ada0529372268d6401afa9d457a095b68cd7753532b6f7f33049a19b43/librt-0.16.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:aea7b1f2b125dad5de85f049136651bff256c883c65e6b9209b2da0a1ac3cdef", size = 536029 }, + { url = "https://files.pythonhosted.org/packages/23/9d/5bb6d38853382986dca702fc7e06c8256d30f5fa0676d882773b744610dc/librt-0.16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a8afb6557920860b7a3a596eb804cf37e09e7cf8a803db2478c202acc72d8c2e", size = 573748 }, + { url = "https://files.pythonhosted.org/packages/99/f6/0025cde35ff7f607684dc775a2b2d732cce2561cec050a6ee1fb2e1fc6fe/librt-0.16.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:77c7a2b4fe2c1369e0d5aa1cade26740a7b14be32fbc9a5535d617d20065c39d", size = 79861 }, + { url = "https://files.pythonhosted.org/packages/93/93/303b8592909bd583f83f02818ffbea3f7647ca1e22b1cd5465d04b8145fe/librt-0.16.0-cp313-cp313-win32.whl", hash = "sha256:02d89c813d5ff74b17df72d3a34819d132cd168e56b81bf755b809bd9e46b8c4", size = 106236 }, + { url = "https://files.pythonhosted.org/packages/cf/24/80bbb463c60ed18e29cb26aba386ddb76609580e4e7160710e550587018c/librt-0.16.0-cp313-cp313-win_amd64.whl", hash = "sha256:14ed6ebe3e4f85f326d7920011ad30ff49ed9334e62cf88caef9ba973d9e3a92", size = 126180 }, + { url = "https://files.pythonhosted.org/packages/43/80/b1a6fbdd7da825cdd55c71aa81eb6cfa82c513c360774152eacb50b4a771/librt-0.16.0-cp313-cp313-win_arm64.whl", hash = "sha256:83d4041a3d9b2fd053a8a4e1f22878b3e5833e2712956382d5c048d791454e91", size = 116557 }, + { url = "https://files.pythonhosted.org/packages/1e/93/9e0cf7da129a93c3dc7f45bc3cd4a660f2aaa995aa8a6f95c2583ef41239/librt-0.16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:931a0bb0fcac88f263e269e46eb30ba8e21402cd3c62ca40cb97034c0693fab1", size = 149844 }, + { url = "https://files.pythonhosted.org/packages/8f/26/8a90d2a8f2b2e471bb486b7aec117b8ae622715ed6c39853aec48ea20073/librt-0.16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1bc17e54e5305f8d40b7ca203671ff5a9e59c1d0f8ea0f625dcca53a3984de11", size = 154111 }, + { url = "https://files.pythonhosted.org/packages/35/ce/67abb46258da4d3e42ff5b141db6f38c59357bef84c7979f183b22f924f1/librt-0.16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:877698bf6bca5721d8be345f2fe09778e40ecadea8b58c73075f2b1a53666bf2", size = 494234 }, + { url = "https://files.pythonhosted.org/packages/12/f9/ea7162414a16f8f1bbd3b493ad6d22b926c5471916e078350bdbca8c4e5f/librt-0.16.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5981c011b306781ce561e18e14230a14524a3d8109b97553666c942c18f31a96", size = 491173 }, + { url = "https://files.pythonhosted.org/packages/b1/09/9b3e869060dd33f9989b80ba4fea306f6db8cecefcdfe6d7346ef0603f65/librt-0.16.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:afced3dfc17cd805ecf7a3d77996a71cf5f2c75aa66eb0c21a9930f4fc992f86", size = 505547 }, + { url = "https://files.pythonhosted.org/packages/50/07/79007d2165f649ea93e08c0962d1d62c70af9cde77965255095bf9d96f9a/librt-0.16.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ca8052401c55d7511dda6760719fda7618067e83535d7d0010096d216c34b667", size = 523057 }, + { url = "https://files.pythonhosted.org/packages/61/0c/8fbaff66d0ba376d8864653f5acce1569bae27e89648671f26f7eca67ab8/librt-0.16.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1e511762a074005bb0aa569166779834e75e438370226930d0ce1866d4b6a33b", size = 515158 }, + { url = "https://files.pythonhosted.org/packages/df/2e/23ff0dece76f07a4124413a57682efa0bbeb5765ac0122bc0955513f82ca/librt-0.16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f1e8591bd8a5a628cd7f07954c6a1592359a878bf032957a8e9057a41d644311", size = 534183 }, + { url = "https://files.pythonhosted.org/packages/26/c4/e11dea21d9a29486eba78887380374189d472734fa32cc50cb37ca44d3d0/librt-0.16.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:a4aaefb4ba6c07e1aeebb2795c8958148f1d6f9af3b555b53d23d766edb6d67a", size = 540663 }, + { url = "https://files.pythonhosted.org/packages/44/75/e873ae158a8b7f5359be33e7fd6c1fbe02d9a78a3e89a77de6b0e837f476/librt-0.16.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d92db7a0f6aee44f1baee94750457e8d2d1c6ccea41842de6268d34e8dc7eddd", size = 545908 }, + { url = "https://files.pythonhosted.org/packages/ba/36/8939d3f6a93e11bd9592e6fe28d2b44f1c2dc4bed6e22e72359a91e18ffb/librt-0.16.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:378dfaffb38e59c24a87cde5713cd865d51ff7383fa12947f3907f306ea1ca55", size = 523302 }, + { url = "https://files.pythonhosted.org/packages/71/14/35309f44a077f0f42ade0e2e7cd88c0cea760c661af205c01ea90d3c0e1f/librt-0.16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3e0c39bdc85370422e8b637be76eb1fd07d30967551b03e62267dd156f553152", size = 565911 }, + { url = "https://files.pythonhosted.org/packages/78/0c/df6255b94967f3159ebc46f08d8e783c12da6ee269ddb74b2efed63c640c/librt-0.16.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:1b384b90ab79a7bc30b566895809a636e0666f21f3cf12b54823d025b7e83839", size = 79119 }, + { url = "https://files.pythonhosted.org/packages/6b/44/d30d5a5461378c9d33f36736c6791c3b4c4ba4b1ffe0da7350aedcb2c9a0/librt-0.16.0-cp314-cp314-win32.whl", hash = "sha256:52327da75a94012e7f932f913d20d3876bed3c102be00e6c3e8600ff7bdd58a7", size = 100140 }, + { url = "https://files.pythonhosted.org/packages/c2/98/769712f356a1e897df3581bb0c3100375d054a000de26099360ea65b5111/librt-0.16.0-cp314-cp314-win_amd64.whl", hash = "sha256:3f0b8114c44b2ac06ff5dacd08e07e8e807ff4f46083f2a1602685122559be41", size = 120464 }, + { url = "https://files.pythonhosted.org/packages/bf/d3/ae2abccc8bdc8b063c1613a77686e17b74e9d3d60cfe6f12c63fa2821b9f/librt-0.16.0-cp314-cp314-win_arm64.whl", hash = "sha256:8caf96a4ef8fb27d0ac0d1ad8337d26a240acd4a02fe4345d0a8f264753e8f99", size = 110889 }, + { url = "https://files.pythonhosted.org/packages/e4/26/0737d4be058dd6376eade7dd8b380d4b869b6394cee929393a5a431c45bb/librt-0.16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:953107e2f68d0f3512c48f898b0dbf0ce5cc52bba0f318d847c985dc555ee4cc", size = 159838 }, + { url = "https://files.pythonhosted.org/packages/01/96/9bc96531d7c620e9949af904470f02e3fa8f35129ab8e8f281c51eaa3788/librt-0.16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ad37d5b9abd49c9a655dcda7ea52a8a752884062ef1ee71ae17c2f2a0f81fe6a", size = 162018 }, + { url = "https://files.pythonhosted.org/packages/0e/fa/b0289dcb186eb3f97221ba00da5f4bd3ba7fa5e99752d48aa8d336615334/librt-0.16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c4aa329c17bd1aaea4f6e89335d8ccd494b3a5830b6654462273e50e11023f0", size = 703983 }, + { url = "https://files.pythonhosted.org/packages/d9/ab/05ebbbde7530fc5eeb58fbd1581522a9f64f132fa703c4c595eed6e14760/librt-0.16.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0ead24d2562a49473dddd9efef8581f020007eb0054389c3ee3ffad38b1ca4c9", size = 683847 }, + { url = "https://files.pythonhosted.org/packages/a3/75/f52aeecd4dbadbddf80725ba7de126d8bd5d0eb66247eae17a81ed90dd4d/librt-0.16.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:02118f56a9c36ddd07dfd9b919d9ecc117ba20a90987d56aa4c429fa34509188", size = 698045 }, + { url = "https://files.pythonhosted.org/packages/e6/55/fa277a835cd6eb42380591ceb85f48c5b4d2b2d7e2cb9869e1e17d24d237/librt-0.16.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4e29522c62e28595ff7e324c6834ade51127707f0e255b18d1c1cf03d39c1048", size = 724640 }, + { url = "https://files.pythonhosted.org/packages/25/e4/2cf64354f3fde8ebd591b3b48f96510bee24ac5f7d1e567adbbe210abdbd/librt-0.16.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ff4b2367926b69c6215635902cccb04048e73094e9862900d27cb2c6bbff143", size = 731748 }, + { url = "https://files.pythonhosted.org/packages/f8/c9/c180af3e94e01aa529fa93d7733fec2abc47f222345400cf21d5481d5f8a/librt-0.16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c6f1b27bf1632a7e016af9f145f82be95e1edd7721a646505c21059257cb5a04", size = 754960 }, + { url = "https://files.pythonhosted.org/packages/95/d6/01073aa78c58f356b10d9c57b3fe9abb143df338142bcd316df417b89db0/librt-0.16.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:5696d7f52e7b37217cb3a8f92c744fe835942602fdd4c1a8bc4741d3bfdce15e", size = 747000 }, + { url = "https://files.pythonhosted.org/packages/f7/d8/1de3783908658d697a8cfc00582f61299ffba7796d7260c112e4700b1109/librt-0.16.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:6072e92dd876ff6ceeb6cf371e35e51f479349837391341f479b08df4564242b", size = 749569 }, + { url = "https://files.pythonhosted.org/packages/b7/32/e817f66c96d6caa8bb8435ff93c4c220624d59efc506be59ab98fcd01d0c/librt-0.16.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:39ca4f2f2fe05de8e63493da592d84311adabe5bef52b193851981da9816b302", size = 729483 }, + { url = "https://files.pythonhosted.org/packages/7c/c9/23992ccd2b9d22798fdd0f61353183a47414e651da782ad83eb680f50833/librt-0.16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9807485a908f00355820f18e91e045ffdcdc5adb68aaec40a1e2b88c5f7bba1", size = 776584 }, + { url = "https://files.pythonhosted.org/packages/67/3b/e8af957f08e6e2e8e566b099e43d2748418aa565df6ee1d9d3331209fdfe/librt-0.16.0-cp314-cp314t-win32.whl", hash = "sha256:94be5cb7bca4df6201f4183e9e4fa2086c655283d20b38cd84500a69057575a7", size = 104402 }, + { url = "https://files.pythonhosted.org/packages/ca/1c/946e6443d7cd32347a086043395e421e52fd603c9163d2ec970ceab8eed6/librt-0.16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d46ca272b251d033dd4527b0dec5f261a28a52bd5fa0f99c117b0a1f8588cc2d", size = 126227 }, + { url = "https://files.pythonhosted.org/packages/c2/a3/bc4f9959d3c62bcbf9e5fd3470a8bbd8bf33224ed4c25d7fba173201b8ac/librt-0.16.0-cp314-cp314t-win_arm64.whl", hash = "sha256:b9d6d4b14e92d876f8026b54c20c445f36425214c1081dc76f74e40db386b82b", size = 116493 }, + { url = "https://files.pythonhosted.org/packages/b6/4b/10fdb42dfab4c1533e1570e686b18e86ff4328b406361b39fb3016667638/librt-0.16.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:6fe436af2eaf630474f491af5d032cbe45f93fcff5c3b9fe4ab194a7255b20ff", size = 149829 }, + { url = "https://files.pythonhosted.org/packages/8e/30/a90ca13f1d3d91af1680000a4038536907018fbd51764767287a05d28b8b/librt-0.16.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:8ff5d26c529336be9bd7ae04483235d77778ee7d6444a95353102b542601ce81", size = 154522 }, + { url = "https://files.pythonhosted.org/packages/5c/dd/bcf364eacfa070bb1fc88d503111ae7177914197ba1fa717c748926e7930/librt-0.16.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:909d8e3c1faee44cb762b1c519ff8613dcc5ceae5c99987a00917b5a31fd1d6a", size = 497593 }, + { url = "https://files.pythonhosted.org/packages/18/c1/2c4e81e347bdabfe8346bfc6dc37ae5e154d61c88e30848ec0606025a5e0/librt-0.16.0-cp315-cp315-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:6d4a64283ee61824b5790de882bc68e2d9d7a5143537cb7a966f7354f71646d4", size = 480459 }, + { url = "https://files.pythonhosted.org/packages/ab/d7/fef2a3cb8400701be496f6e459876f650b3451f407e30cb243de571ae615/librt-0.16.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5810ba811297fdf37a1531a57667cb8ace0842013ca8606bf9eb7c24cf4be154", size = 507814 }, + { url = "https://files.pythonhosted.org/packages/e1/6f/53762927a32e9dc9eb1d1c1f3528da281290c86d96929a8af652f671266e/librt-0.16.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e56aaf8c167548dc8e5d6f3bd0f48dcdd299a23c73be3f744aab79d99e9c7f5d", size = 525114 }, + { url = "https://files.pythonhosted.org/packages/6c/67/0b9d031f303c4e8c691a9a8ef9d272f13b530c11cc563b819df621b6a348/librt-0.16.0-cp315-cp315-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f36c58e33b304b525c6c9c5076399c6ebf1109e17b9051a05a407b091b9215b", size = 520465 }, + { url = "https://files.pythonhosted.org/packages/ae/d5/2056a3a85864e882eb17a203a10ddb26fa748bc9718ec67e79059ab46cae/librt-0.16.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:242e00b3d4fa37c3d3c1ca5f5c9adb7d909ddb1eac9c41f2787320d00caa0af2", size = 537257 }, + { url = "https://files.pythonhosted.org/packages/54/57/e0d79790c163cbc0909e209a6f62e30bb713b647f905a176cdc64848fd4d/librt-0.16.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:0253721561787b8df8443eb347b7a6461015354e5bdd37ee38a41fef220d2bb0", size = 527442 }, + { url = "https://files.pythonhosted.org/packages/aa/50/1c0c95aba7af51f4752ea34fbf2eb79b36e7cae3a72536248e8c735de735/librt-0.16.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:3e483a8d69ede8067db70c0e83007423b6925de6fd53afed01d66160f2e9398c", size = 548139 }, + { url = "https://files.pythonhosted.org/packages/98/91/a8a43dd5138d4f55f88846b8f0c85454a4fad69952cebfcff9948f831290/librt-0.16.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:69ba927445cfaaffb4081003ef5224c55a5c2ab67ef956f416ef744916e44121", size = 529708 }, + { url = "https://files.pythonhosted.org/packages/2d/41/d5226881ab2b7c20d9d587b37bdd4a0ec8775a96d00ca87ac9f385587db4/librt-0.16.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:d6a365f2ab45a984d0e00eee0dd17f599ceab8cadab6ea07b6111c8132fc0e42", size = 567781 }, + { url = "https://files.pythonhosted.org/packages/bb/bf/2345ba57a626e8c78c4ddcc724636a8df5a593fe46c1ee76bbf477e32b0d/librt-0.16.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f01f3805f2dae4781c0c34b440e31740d082950bdaf89a6f601ad589a28af57a", size = 79177 }, + { url = "https://files.pythonhosted.org/packages/1e/40/99e77936cc9207f629b077bf7cbc5e2f0827cddd7c29792f04ef881bd2c3/librt-0.16.0-cp315-cp315-win32.whl", hash = "sha256:b0e3e721c75d2e79a76d4422c79d7ba705fe1bbafec907037fe7a657a480a0e3", size = 100127 }, + { url = "https://files.pythonhosted.org/packages/56/1e/801fe26bc622061b9dfd010e166d94142cb774b733217e6d98d1c0cf2638/librt-0.16.0-cp315-cp315-win_amd64.whl", hash = "sha256:bc02954b1295de798bbdb0b4e2d8a28c2117de8b5c73dcbeb27dc32572dfb971", size = 120480 }, + { url = "https://files.pythonhosted.org/packages/bb/a9/d533983055bd36e112627384c2c038845d1df882540b8bcefb566475640b/librt-0.16.0-cp315-cp315-win_arm64.whl", hash = "sha256:c5db585d43449a5f54303d4b2774e45e1babd975cfe1630a3d708c0b80c3e560", size = 110915 }, + { url = "https://files.pythonhosted.org/packages/55/fe/d62238fa9c653b0e0613290349467cb65711b5800e8e474f45e942a0ad95/librt-0.16.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f06c689cb14afd9b612727553a5ec5a40febf113ca41c4413a2b0b334285884b", size = 159714 }, + { url = "https://files.pythonhosted.org/packages/f3/ac/f31efe7818700be72ba4f9af8a80fa67c39808c8d26dacc55dc1f6f35172/librt-0.16.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:13b4e8aba90b0b1c82474e9844aa9ffe7ad3faa484350e1da64cb8188d903134", size = 162086 }, + { url = "https://files.pythonhosted.org/packages/d9/16/4d7487bf86a9d7e8e18f37ba5538789233ea693637379b75701bd35ec9de/librt-0.16.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5a269c46ae327d8e6f8c1f85f7516cb52c0fa48127565a1105a4f4a05ff2a0b4", size = 712727 }, + { url = "https://files.pythonhosted.org/packages/77/74/50cd550ccc1a517b9ed62347625c01ddf8c4afad66490312677c011458f6/librt-0.16.0-cp315-cp315t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a33e0dae1f8592146a4764d54ce842b278732d21a84e17c3bbe6b1bc158a2248", size = 681106 }, + { url = "https://files.pythonhosted.org/packages/df/5d/7293f712975ee6fdd2251411fd9ef1c62bc99c7b83ecbe62dbc999b1fab7/librt-0.16.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:47ada6ea32636492c61aa8ad27ae3b9404bfe7a97e3ba946d1984236cc741da0", size = 705921 }, + { url = "https://files.pythonhosted.org/packages/67/f7/8aab946f11d59d1bece9ffc65d994b200c789a8ca5a95c17e17e609f912a/librt-0.16.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c43bd6e642d8a248c114327f98dd25ac5a7cb5aa168ef02f0559b91874df16b8", size = 731786 }, + { url = "https://files.pythonhosted.org/packages/43/76/1c42ab31e7cb8384ebf6d3af607213c495222474f4940443ae7639ab7685/librt-0.16.0-cp315-cp315t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c3d1bb7841a816ace6449bb26d3f9560dbfa20e71c568d23f0f62bf1e68f50b1", size = 744964 }, + { url = "https://files.pythonhosted.org/packages/6f/2e/4b19982d933d2dfced671e840b219e4c1cd3f507df6e6dabb51bbd4e3850/librt-0.16.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:3931f7a3db322e7f44e02a280e3949326ce9579ad388ee8d691dc7c76da9fb70", size = 765305 }, + { url = "https://files.pythonhosted.org/packages/b8/1b/e872583de2dcb3ac7746e7a2321aeeb168274f3952a87dc66e05ddb29faf/librt-0.16.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:f4462528b6000afe8f16907b5c7c2553abf1df005ba5140e6eb394541c3624c3", size = 744959 }, + { url = "https://files.pythonhosted.org/packages/10/de/a18c6bcfb297af2674233e90b3c3661c3a0af0f1434a0c966d2ef708a835/librt-0.16.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:80039ba9b6a7d5f1a0175a4cca6bbefead87bd854c80abad1cb30afe47a830db", size = 758138 }, + { url = "https://files.pythonhosted.org/packages/22/1c/0df1d732539c297bb1a093e1fe3204d2faf76a9022cf4e1d1c2fce059790/librt-0.16.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:7a1d272724b581bb6bc769dfdafed6da2ecc9886ba2450311de55a4ac2e1e9cd", size = 744268 }, + { url = "https://files.pythonhosted.org/packages/a2/f7/ccaf31331f20c91a5bd9bd48ffc3f743f9c81bb5720cc7b2a720ca204f0a/librt-0.16.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:fbe4fb8c5445f7496d7f7f6bb0807875d09d47e6771ffa175fb2df2895fb86ba", size = 785783 }, + { url = "https://files.pythonhosted.org/packages/c1/ea/7421d9e6db894cd6cd788b604b3394a163ded6b942052b6f96c23ccf08e1/librt-0.16.0-cp315-cp315t-win32.whl", hash = "sha256:375bfe6b572a8f6cfc398709356046173bf27e64c4c5edaf5f7062f051fb4bf9", size = 104355 }, + { url = "https://files.pythonhosted.org/packages/85/6d/7c31a506eb847bc58aeb2402d58e17605e821f68ab5df5e66fe274a6df41/librt-0.16.0-cp315-cp315t-win_amd64.whl", hash = "sha256:bd3150023d3dc2bc70f3784e59ffa1140d56ddba3d8125b3d6f9f85221279bfc", size = 126134 }, + { url = "https://files.pythonhosted.org/packages/36/69/7a5d10ac409c4da0355e054a14371871da9b5557fcc42772cd00181c6cce/librt-0.16.0-cp315-cp315t-win_arm64.whl", hash = "sha256:8ceafb70f2a4f0826f11031942e59c0728fd98da112dc346d4352bde1e486866", size = 116391 }, +] + +[[package]] +name = "litestar" +version = "2.24.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "click" }, + { name = "httpx" }, + { name = "litestar-htmx" }, + { name = "msgspec" }, + { name = "multidict" }, + { name = "multipart" }, + { name = "polyfactory" }, + { name = "pyyaml" }, + { name = "rich" }, + { name = "rich-click" }, + { name = "sniffio" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/f6/33619a1562c6732dab211a967b1e7af3285de29d655c0b37b3f379aa2700/litestar-2.24.0.tar.gz", hash = "sha256:8f4b137cb115554b9fbc12bd01d398d5bb40ba85f2d333dd73f4b747308bbb46", size = 383070 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/ce/4697b547790f23d2fcc37a928d48c38140cf86360e4041ca8177e3367c14/litestar-2.24.0-py3-none-any.whl", hash = "sha256:0ef13630173ea147847363f03f0459877ab14a572e68b2af7a25796055513a31", size = 581807 }, +] + +[[package]] +name = "litestar-htmx" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3f/b9/7e296aa1adada25cce8e5f89a996b0e38d852d93b1b656a2058226c542a2/litestar_htmx-0.5.0.tar.gz", hash = "sha256:e02d1a3a92172c874835fa3e6749d65ae9fc626d0df46719490a16293e2146fb", size = 119755 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/24/8d99982f0aa9c1cd82073c6232b54a0dbe6797c7d63c0583a6c68ee3ddf2/litestar_htmx-0.5.0-py3-none-any.whl", hash = "sha256:92833aa47e0d0e868d2a7dbfab75261f124f4b83d4f9ad12b57b9a68f86c50e6", size = 9970 }, +] + +[[package]] +name = "markdown" +version = "3.11" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f8/4f/700155c8c20d9e655dd0732b5fc3c7614f291b9148da271d7388e50bf774/markdown-3.11.tar.gz", hash = "sha256:180224db6aed87ba9ce1f2781ebcd5826253de8ff637112090e24b84502bbf9f", size = 485623 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/1e/32971905a7ab47f8b66866ed949fa48b104ba1c4a6fa57794c4f2c4b2cb8/markdown-3.11-py3-none-any.whl", hash = "sha256:cd6c89e7eb308c8b332ed673215a52d208a43f8bacc030b1419376129408719e", size = 111296 }, +] + +[[package]] +name = "markdown-it-py" +version = "4.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mdurl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687 }, +] + +[[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 } +wheels = [ + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/1e/e8/44cfcb5ea40e5e43cec7793ef90704ed0c475280839076c4345757eb8e59/markupsafe-3.0.4-cp311-cp311-win32.whl", hash = "sha256:2e5a7cd7fdd14fcb1ae5d7d8bf23d24fbd1daefd1fbca2580132e1ea75f098b5", size = 14094 }, + { url = "https://files.pythonhosted.org/packages/91/89/f2b509f7bf79352e40117824c1070dbeafd4df67031d3fa98165a3134228/markupsafe-3.0.4-cp311-cp311-win_amd64.whl", hash = "sha256:fdb4ca07ab75ffadab4a8b135ad59cdbb3156b99310f3d565370da74a15d6bd3", size = 14328 }, + { url = "https://files.pythonhosted.org/packages/2b/5a/ccf22672a0f64dc682306e288f0dabcb06c7a201f3bb6e6cbf86d9e8ad03/markupsafe-3.0.4-cp311-cp311-win_arm64.whl", hash = "sha256:569d65055d367e3dcdf30c3f41119467b73d9ee9faf332bdf40402644f5ac08e", size = 14188 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/c8/52/7632a53360671a9b750cdbabaf9cdd89f18b42248b8e4cb42c0b0296e459/markupsafe-3.0.4-cp312-cp312-win32.whl", hash = "sha256:f0ec3b750b59375eab5b0fb2b9254810c00a3375be6d789899f1055a1d556237", size = 14108 }, + { url = "https://files.pythonhosted.org/packages/3f/bf/62495e180b7000aaf30000fff849e933f74264638057176cf46852500adc/markupsafe-3.0.4-cp312-cp312-win_amd64.whl", hash = "sha256:11935df9bf455ed0c04eb87bcd720f02b1fe5e02128a9430f23aed6f93336fc7", size = 14303 }, + { url = "https://files.pythonhosted.org/packages/c5/8e/4c24208776a65878d656996945aacfbfe010d3720d1a98fc0eb8491fc03b/markupsafe-3.0.4-cp312-cp312-win_arm64.whl", hash = "sha256:a4bbd2d87dd233b9fc5812160c3d0ffbe42edc22a26ce0469f58479ede633fe9", size = 14174 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/4a/85/c43776625428f3bb4a61e8633940400e3efe6409e3c6f5bff26de5e45618/markupsafe-3.0.4-cp313-cp313-win32.whl", hash = "sha256:672d207103e6b16ca098611b0f9efad6bc00afd47c03d6ef62186495ca677dc0", size = 14118 }, + { url = "https://files.pythonhosted.org/packages/6f/36/163da64de88a13db79214ef75fa041be7fa13bdb42261cf5b7484de14bfb/markupsafe-3.0.4-cp313-cp313-win_amd64.whl", hash = "sha256:1f1f9477e174582b0a1b583d60b66e1f2cf5d3fe12cee985e4aedf44766600e5", size = 14313 }, + { url = "https://files.pythonhosted.org/packages/9f/a8/9b662783ffaa1149221432a923cee562f78b9cbbb8baa3df9b3753e63e1e/markupsafe-3.0.4-cp313-cp313-win_arm64.whl", hash = "sha256:06de8ef6331f6e822c28d577dc8bf43fe398800477c49498f38fc38b67ff33fc", size = 14189 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/1c/ea/9cc3cea873f980c75cbdb6f4277ce30ee955de38be0b3d02f14c108e0698/markupsafe-3.0.4-cp314-cp314-win32.whl", hash = "sha256:38fc55594dab834470b6733dead2ee9e3f657fb0608c769dcafa0ba5ab52f45c", size = 14272 }, + { url = "https://files.pythonhosted.org/packages/80/f0/5792ff768a410f93ee3f84fc19345295ffc352d2c936b424cb37e514714c/markupsafe-3.0.4-cp314-cp314-win_amd64.whl", hash = "sha256:c1bc67752d5f21013cfe430df4062441714eab79f65a6a05e01505957e9c35fe", size = 14501 }, + { url = "https://files.pythonhosted.org/packages/5f/cf/3d074a8edffcc6899355232ff2543ae8d929733239596423b7db79698bc9/markupsafe-3.0.4-cp314-cp314-win_arm64.whl", hash = "sha256:7e1636da3d8dfc220b6dd10264db5f2b165e4888c4518594898fbe381049af8a", size = 14364 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/09/15/98075cceac3b5ba0dbb8e4762a847be967d2befc349a2cf2d0ac77f62c9d/markupsafe-3.0.4-cp314-cp314t-win32.whl", hash = "sha256:12a606a492de952afcb43b59a14aaaaad120e708d3663dd0fdf2d738d427a691", size = 14301 }, + { url = "https://files.pythonhosted.org/packages/0b/a3/768b560fcc4156685cb563d922b217810cfa7bc135773367f62f1f9d2078/markupsafe-3.0.4-cp314-cp314t-win_amd64.whl", hash = "sha256:a18f38cafc329bac5e3c2b96c765b4c96d3d103421ed22ab7988c1e3fce27464", size = 14526 }, + { url = "https://files.pythonhosted.org/packages/93/63/da554b4c97a6b0ea3229ca7fe8cbfb620be81613d517f482e85958550537/markupsafe-3.0.4-cp314-cp314t-win_arm64.whl", hash = "sha256:eba154571c16e032112afac0dc2dfe9e63c2ceb7aedd07bb7eecf2ce26d4dd4c", size = 14421 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/19/2d/2dfdce99318abbfa26925195fbc17db188c46a1ec6457be121b6f9cfeb42/markupsafe-3.0.4-cp315-cp315-win32.whl", hash = "sha256:03470d1a8268e692ecf79ecd565593e59d44219377a7ead61f1f1b94c1f7ff6b", size = 14269 }, + { url = "https://files.pythonhosted.org/packages/5b/ec/6000fd82e8791e58fcd0456ec20f098957e2b03d5ed02eb73241a577c0ba/markupsafe-3.0.4-cp315-cp315-win_amd64.whl", hash = "sha256:d882a373d8093c2941e01291b7ced96e9cbe4781da9a7751ca7e6c70385e5214", size = 14496 }, + { url = "https://files.pythonhosted.org/packages/bc/66/e73bd5016421d5d6e2fb6de7dd609f9de020942ac8c626526bd8c6eeaf82/markupsafe-3.0.4-cp315-cp315-win_arm64.whl", hash = "sha256:353bd63081912ab8cfa6a0c7d185934cdf8426f04c618bba6bc4b394f2069b67", size = 14361 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/81/99/157e10966b033b363aeda5263e82596ee232a0b1d082fdbf90aa417ff083/markupsafe-3.0.4-cp315-cp315t-win32.whl", hash = "sha256:e49fb0d1ce92cfa0cb198cc5b1b11cdf9d0638658e2a2db2687e39db7c87fc78", size = 14300 }, + { url = "https://files.pythonhosted.org/packages/33/05/55884815414c9706a23deca150b72c25a62109e65b0b6ce232077802c719/markupsafe-3.0.4-cp315-cp315t-win_amd64.whl", hash = "sha256:4f6e0852a0283b1b1fd776eeb7b766a5f440b3e2bd31ab51af3b400585f3965c", size = 14522 }, + { url = "https://files.pythonhosted.org/packages/92/f9/ecbde7149e95b8a0f18e16d5d747f7dc06049d5da2e4f77f6f5e4a1f46a8/markupsafe-3.0.4-cp315-cp315t-win_arm64.whl", hash = "sha256:39dbacefc411633db5b4378b066a9aca70a3d7e2922c9e578d825f844026eeba", size = 14417 }, +] + +[[package]] +name = "mdurl" +version = "0.1.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, +] + +[[package]] +name = "mergedeep" +version = "1.3.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, +] + +[[package]] +name = "mkdocs" +version = "1.6.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "ghp-import" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mergedeep" }, + { name = "mkdocs-get-deps" }, + { name = "packaging" }, + { name = "pathspec" }, + { name = "pyyaml" }, + { name = "pyyaml-env-tag" }, + { name = "watchdog" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, +] + +[[package]] +name = "mkdocs-autorefs" +version = "1.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530 }, +] + +[[package]] +name = "mkdocs-get-deps" +version = "0.2.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "mergedeep" }, + { name = "platformdirs" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555 }, +] + +[[package]] +name = "mkdocs-material" +version = "9.7.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "babel" }, + { name = "backrefs" }, + { name = "colorama" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "mkdocs" }, + { name = "mkdocs-material-extensions" }, + { name = "paginate" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "requests" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438 }, +] + +[[package]] +name = "mkdocs-material-extensions" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, +] + +[[package]] +name = "mkdocstrings" +version = "1.0.7" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jinja2" }, + { name = "markdown" }, + { name = "markupsafe" }, + { name = "mkdocs" }, + { name = "mkdocs-autorefs" }, + { name = "pymdown-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f5/e8/445b19834bf9559ebbbaa76897e965665b83d2861461eed8d8de4d122057/mkdocstrings-1.0.7.tar.gz", hash = "sha256:0951b147f352118a45a3db85f0f3b660833d34cb5120a2751315b8d08dafd159", size = 103739 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/22/c3/9062531735ff1fd27f3a79c353c4cf4d822eeffd062fd90b2c553a96f4bb/mkdocstrings-1.0.7-py3-none-any.whl", hash = "sha256:9563828667eeb35256f87a6b61fe857676ac628ec1abbce9aea9839a502c9cb1", size = 41597 }, +] + +[package.optional-dependencies] +python = [ + { name = "mkdocstrings-python" }, +] + +[[package]] +name = "mkdocstrings-python" +version = "2.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "griffelib" }, + { name = "mkdocs-autorefs" }, + { name = "mkdocstrings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/11/640067d1ad713ea99c233e53e57a4c300d9885f904a61371d7ef90dea549/mkdocstrings_python-2.0.9.tar.gz", hash = "sha256:ae945637dc0618c6beedbee169f10c0234b77f322c53db3fc9fb4c29b3013038", size = 203006 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/62/0f/f7f926cfa06317bbdbd3cf0ff82f9e3a91767352eb01519c81499fddf575/mkdocstrings_python-2.0.9-py3-none-any.whl", hash = "sha256:c0233eff3f84d78110df50541918e7ec2bcff8e1614faddea34290a90321a416", size = 105698 }, +] + +[[package]] +name = "mongospecs" +version = "0.2.0" +source = { editable = "packages/mongospecs" } +dependencies = [ + { name = "pymongo" }, +] + +[package.optional-dependencies] +all = [ + { name = "attrs" }, + { name = "msgspec" }, + { name = "pydantic" }, +] +attrs = [ + { name = "attrs" }, + { name = "msgspec" }, +] +msgspec = [ + { name = "msgspec" }, +] +otel = [ + { name = "opentelemetry-api" }, +] +pydantic = [ + { name = "pydantic" }, +] +sentry = [ + { name = "sentry-sdk" }, +] + +[package.metadata] +requires-dist = [ + { name = "attrs", marker = "extra == 'all'", specifier = ">=24.2" }, + { name = "attrs", marker = "extra == 'attrs'", specifier = ">=24.2" }, + { name = "msgspec", marker = "extra == 'all'", specifier = ">=0.19" }, + { name = "msgspec", marker = "extra == 'attrs'", specifier = ">=0.19" }, + { name = "msgspec", marker = "extra == 'msgspec'", specifier = ">=0.19" }, + { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.20" }, + { name = "pydantic", marker = "extra == 'all'", specifier = ">=2.9" }, + { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.9" }, + { name = "pymongo", specifier = ">=4.13,<5" }, + { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.0" }, +] +provides-extras = ["msgspec", "pydantic", "attrs", "otel", "sentry", "all"] + +[[package]] +name = "mongospecs-bench" +version = "0.0.0" +source = { editable = "bench" } +dependencies = [ + { name = "httpx" }, + { name = "mongospecs", extra = ["all"] }, + { name = "mongospecs-web", extra = ["all"] }, +] + +[package.metadata] +requires-dist = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "mongospecs", extras = ["all"], editable = "packages/mongospecs" }, + { name = "mongospecs-web", extras = ["all"], editable = "packages/mongospecs-web" }, +] + +[[package]] +name = "mongospecs-web" +version = "0.1.0" +source = { editable = "packages/mongospecs-web" } +dependencies = [ + { name = "mongospecs" }, +] + +[package.optional-dependencies] +all = [ + { name = "fastapi" }, + { name = "litestar" }, + { name = "quart", version = "0.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "quart", version = "0.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, + { name = "starlette" }, +] +fastapi = [ + { name = "fastapi" }, +] +litestar = [ + { name = "litestar" }, +] +quart = [ + { name = "quart", version = "0.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, + { name = "quart", version = "0.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, +] +starlette = [ + { name = "starlette" }, +] + +[package.metadata] +requires-dist = [ + { name = "fastapi", marker = "extra == 'all'", specifier = ">=0.115" }, + { name = "fastapi", marker = "extra == 'fastapi'", specifier = ">=0.115" }, + { name = "litestar", marker = "extra == 'all'", specifier = ">=2.12" }, + { name = "litestar", marker = "extra == 'litestar'", specifier = ">=2.12" }, + { name = "mongospecs", editable = "packages/mongospecs" }, + { name = "quart", marker = "extra == 'all'", specifier = ">=0.19" }, + { name = "quart", marker = "extra == 'quart'", specifier = ">=0.19" }, + { name = "starlette", marker = "extra == 'all'", specifier = ">=0.40" }, + { name = "starlette", marker = "extra == 'starlette'", specifier = ">=0.40" }, +] +provides-extras = ["starlette", "fastapi", "litestar", "quart", "all"] + +[[package]] +name = "mongospecs-workspace" +version = "0.0.0" +source = { virtual = "." } + +[package.dev-dependencies] +dev = [ + { name = "httpx" }, + { name = "mongospecs", extra = ["all"] }, + { name = "mongospecs-bench" }, + { name = "mongospecs-web", extra = ["all"] }, + { name = "mypy" }, + { name = "opentelemetry-sdk" }, + { name = "pytest" }, + { name = "pytest-asyncio" }, + { name = "ruff" }, + { name = "sentry-sdk" }, +] +docs = [ + { name = "mkdocs-material" }, + { name = "mkdocstrings", extra = ["python"] }, +] + +[package.metadata] + +[package.metadata.requires-dev] +dev = [ + { name = "httpx", specifier = ">=0.27" }, + { name = "mongospecs", extras = ["all"], editable = "packages/mongospecs" }, + { name = "mongospecs-bench", editable = "bench" }, + { name = "mongospecs-web", extras = ["all"], editable = "packages/mongospecs-web" }, + { name = "mypy", specifier = ">=1.13" }, + { name = "opentelemetry-sdk", specifier = ">=1.20" }, + { name = "pytest", specifier = ">=8.3" }, + { name = "pytest-asyncio", specifier = ">=0.25" }, + { name = "ruff", specifier = ">=0.8" }, + { name = "sentry-sdk", specifier = ">=2.0" }, +] +docs = [ + { name = "mkdocs-material", specifier = ">=9.5" }, + { name = "mkdocstrings", extras = ["python"], specifier = ">=0.26" }, +] + +[[package]] +name = "msgspec" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/e6/6dcf9306ff3c5e486578f3bf29ed11dfbdbbc2a8bf0caf7e07d392887fda/msgspec-0.22.0.tar.gz", hash = "sha256:0a13624a4969159fe35d8c2a3d377b2b61bbd8585e327440d5e52725affcce38", size = 343188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9d/22/45c17acb1a85360b10afb95f66777f76bc2634993c66db8b7833832bd343/msgspec-0.22.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:fb1e129b81ac8fcf9ec649b081c6c8da1c7ea6f87cab336d46386abc2cd855c1", size = 198231 }, + { url = "https://files.pythonhosted.org/packages/34/79/1cf725694125051e866066d74e6199206838d1465cbfc35081dc29b6e366/msgspec-0.22.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dce29a04966e31abf9b83b697c6d672486526dc5d03fcd6970cb56d5dc1fbeea", size = 190911 }, + { url = "https://files.pythonhosted.org/packages/bc/b2/e0ace038031a2988aa2e85c431c4d7aef734fbba4749ace6bc5bf310b769/msgspec-0.22.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b962000e11dd34fb210a5a2c57a8a62b2d92b381c8cb3b05c075a83e38f8d645", size = 220343 }, + { url = "https://files.pythonhosted.org/packages/7b/e6/16ddb09185d79dc00177994cf0bdb1cd8e5cc44a1d1bfba61bdda5f382cb/msgspec-0.22.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a6db3806b3b76ca78064255eac6fa101a8a64fe6f698d80fbaf81fdfa21217d4", size = 225251 }, + { url = "https://files.pythonhosted.org/packages/16/c2/a6af0d38fb0e72f02851ed084c4b8175140cfaf3eaf48b38da0c3941db26/msgspec-0.22.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a88d939d3fe4b8c7314645ebcd6e86c8c8a512ea7820d6550355973e803bc0f1", size = 233488 }, + { url = "https://files.pythonhosted.org/packages/0b/9b/b1c4208cdf487e2ba7af145f721b279444ff76af05a9f8fce992ed0588ee/msgspec-0.22.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0b31746da07cba0e330c6433a94a4699ad77d3aeb9638d1a320a7686b69f6249", size = 225688 }, + { url = "https://files.pythonhosted.org/packages/83/54/b9240d908674ef7c41d02cb909731ad6d9931c23bd6a27d8d10776c6f964/msgspec-0.22.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:6ae370f92f3517f0e6f209ba7cc649c957b444868439197e046be07154667551", size = 234250 }, + { url = "https://files.pythonhosted.org/packages/df/c0/d498798aaab3bd191a33955de47b40f07fae7667d86a33b705443a7e9491/msgspec-0.22.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:9a696f23f7c1ffb31fae308502e01a3965c3891d5c400f01d0d1096dbe77519e", size = 228337 }, + { url = "https://files.pythonhosted.org/packages/fa/51/5e9ae5a5ddc254e15435749328161e95598750e5df644bb00fa9e2297122/msgspec-0.22.0-cp311-cp311-win_amd64.whl", hash = "sha256:024138c51afd335d0b4dce401be33902caafac2b64f8c9f2509a378986175d98", size = 190962 }, + { url = "https://files.pythonhosted.org/packages/12/38/fb64a18543bcbebc53a375cb00b1c93bf264a0b6c7bbe9e38b37cc5f0768/msgspec-0.22.0-cp311-cp311-win_arm64.whl", hash = "sha256:4600dbec738ed74e4c9bd35503e84701200ea7db344cfdeda80677b3ee53eb64", size = 189458 }, + { url = "https://files.pythonhosted.org/packages/a4/87/3e017dca361d09ed1cd09dc981a6df21b32e830fbec3470f7486d38b6be5/msgspec-0.22.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ab1e9e7531e353653b906cdd12a0220cc288a1e8e3436aabc65f4508d91b14d9", size = 201301 }, + { url = "https://files.pythonhosted.org/packages/fb/02/109165edaafb895668d87177972a32ade9126a54f3736123d8e44be9096d/msgspec-0.22.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b60b43425a47eb9cfe987f6874e354ca7c760e58e295b4e2273ff03574df28a1", size = 193044 }, + { url = "https://files.pythonhosted.org/packages/54/a5/65de05f8804492f76ea121b21a125cdf1d97ec461c677bfa0ba354d6fbdd/msgspec-0.22.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b5a169b5b03f0f2c7a296c002647db1dab75d2cd501bca34e32b71cab0261b56", size = 224035 }, + { url = "https://files.pythonhosted.org/packages/4a/cc/aa1a47f8c92280d37498a5ea56a2a36606d034383e3e6472d64cbb56cf85/msgspec-0.22.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:99c401861c5bb3a57f7d6423ea7ed4352cd57aa3f04f4fbe9f3e3e4564a10f08", size = 230377 }, + { url = "https://files.pythonhosted.org/packages/61/50/f8bcdb3d613a4a4b92704297a12eba5c985cf572a64ee1a004d265759c69/msgspec-0.22.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08826f5e5b0fa2f7a88592c396a243cfcc63d37e19f9d4fbe3b3f1be2fbdc404", size = 237390 }, + { url = "https://files.pythonhosted.org/packages/cf/8a/473fa423f8fdd1b810b8652594323d7301df6920b62844d860daa0feff34/msgspec-0.22.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:21460f54cee9208239b1a8421fdf25bffc77293e1daba88f585711ad839b9758", size = 227733 }, + { url = "https://files.pythonhosted.org/packages/03/1d/272ce23adae6c71b3f763aed3ee6e115cccc56124ed8ee0e3e3d2681e2c8/msgspec-0.22.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cfc3d9557de9c806318725b702f3e664db33167bb42892079b693c69893fd33b", size = 236783 }, + { url = "https://files.pythonhosted.org/packages/f6/26/29e0b9a8605c8819a3c718158e345a616ac42c092dd7d7ab248c2f2b0a72/msgspec-0.22.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0b25dcbc108783cb72503ed705b9fbb8c3cb02ee5801923f44b5f038c91cc365", size = 232728 }, + { url = "https://files.pythonhosted.org/packages/e1/a6/99597c281d716da6c662b48dcc3f734669f716b41d5df2af367dac9e7c21/msgspec-0.22.0-cp312-cp312-win_amd64.whl", hash = "sha256:6ad64f5c260866b0d543f89f50cee43628989c1433c5de7ce820281fa28a2611", size = 192885 }, + { url = "https://files.pythonhosted.org/packages/46/80/85fff923d448b886ec3a85900c578d9367f08dad54fe48879495b4c6d055/msgspec-0.22.0-cp312-cp312-win_arm64.whl", hash = "sha256:0922714feff5300aacd8ecd65fa828317ce4bf5212b3139258c0bfc0253cd80e", size = 191223 }, + { url = "https://files.pythonhosted.org/packages/7f/62/5374fba2ede0408f4bd8b9b3a6c8464f8d0ea7ae9a2a064bd81ca492bd1e/msgspec-0.22.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:f13c127a945479bc9db057eb253b8851075c8e1ae07ffc967bfa1c5676203a86", size = 201355 }, + { url = "https://files.pythonhosted.org/packages/cc/e3/357baa8d2a9164a98dfd7ef9d3a58125df0ed981be909945bdd337be7194/msgspec-0.22.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5aa24eb475d070ecbbe5b21080fc3ce4b0b76c60de25cfe0c9678d8fb44bb42f", size = 193097 }, + { url = "https://files.pythonhosted.org/packages/fa/1b/9cc07718d1dee8ed5e89a265801d565bc0f15ead435ccb198f9c7bf92574/msgspec-0.22.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:627bfdfe5a4b3d916b3360b30f4cddeee3a084f56593e33527c6872fa8322ff9", size = 224112 }, + { url = "https://files.pythonhosted.org/packages/46/64/f33fdfe95aca76601194a7064d14816c7c22c4eccc1b03a5335785895fa3/msgspec-0.22.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c6c310ef83e7e291b01a63298828f848348bb99e84a1098c4b3923c05674d032", size = 230472 }, + { url = "https://files.pythonhosted.org/packages/8e/b3/8ceaa9981c230adf43c45a6e8da25da23a381eddc7ed05aeaca1d5e7928b/msgspec-0.22.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7c1e76c6bd523141b9c05c2f8a70979cd0efedbd68855a66f292f8892c0b8fc7", size = 237382 }, + { url = "https://files.pythonhosted.org/packages/88/a6/7b5c4fb39e0bf2dabc8be923c33c39b07ba769a0ce6f0afbbdfaadb1f2f2/msgspec-0.22.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc374dedd5f85a5f4de2386dc5f737894ccb8c1ac18e9566ce66fd9839e6285d", size = 227717 }, + { url = "https://files.pythonhosted.org/packages/b8/5b/2334ee638880e756c8bc54a1177bd65877c786433693a43594ef5ecbe2d8/msgspec-0.22.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:feafe612034d49e9144340c0b5168ee4e22c2af4aaa2c1db11ae84e1aac9543b", size = 236781 }, + { url = "https://files.pythonhosted.org/packages/6c/e5/b4c5323b17ecfce45350695d40fc93e16856db957a53cbcf2f53007d6e12/msgspec-0.22.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f48317f05312bfdf78248f53933f830f07ab75cc1c813ac3ca4220cb3b5b019", size = 232777 }, + { url = "https://files.pythonhosted.org/packages/01/33/e591f9d3d8d6c9cfc02ae95f3e3c44920f2d18050f3f252c244e0f293a0e/msgspec-0.22.0-cp313-cp313-win_amd64.whl", hash = "sha256:0739b068f31f2004a364f97679ba91f2f5ecd6ec2a5b4b890188ab5c57d20672", size = 192829 }, + { url = "https://files.pythonhosted.org/packages/d1/cd/a011a5b8732cd781e2ea6da5b38d71ae4a9a329338411d1f008a58f5edbf/msgspec-0.22.0-cp313-cp313-win_arm64.whl", hash = "sha256:508278300dd4efbd21cd3a4b2b016160a5feac98bc880d3673f6c06697baaf62", size = 191258 }, + { url = "https://files.pythonhosted.org/packages/53/f9/ac027b35477e6b83bcee32b3d9675b37abfa130f098dd6500fa67d768852/msgspec-0.22.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:221cbcbfa4478152b91d37dcfd4830e2be92773e8139e883f43773450ebacef8", size = 201276 }, + { url = "https://files.pythonhosted.org/packages/13/6b/2bffffa31662b1353a62e672442865d51c291ad778352fd490de16361dc6/msgspec-0.22.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:dd9568695911055440d2bb7099ed9098fc181d335daa772d0eb3fe8f31ba4efb", size = 193233 }, + { url = "https://files.pythonhosted.org/packages/14/bc/4066416ff6aa918d1ef9295edee0041e4629e4079ad3839bdd8a68fd87f0/msgspec-0.22.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f039ef5207b847f075a0a43020ee6140cd47505f890e47e157f2deb485c2dc96", size = 225101 }, + { url = "https://files.pythonhosted.org/packages/63/ba/a8d390d5bd4c7d9ccde87c95cf071ada934cc9ca2c6af4d3d50b38f2d718/msgspec-0.22.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e4f7e09cceac7dbf4c0761b8ae7df51c55b5df5e9af7aff2c895aac1ebea015", size = 230505 }, + { url = "https://files.pythonhosted.org/packages/9c/89/979664fdc913c624ef88a139b40e3a95ddf2a47c89e8b5c4147f69ee9c48/msgspec-0.22.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:614e2c827e0a3f934f3cf0cf4ba65210df8132b75a69a8a1f51bb3b2caf0ac5a", size = 237382 }, + { url = "https://files.pythonhosted.org/packages/07/3f/7d44c614376ae008ac6099be5f589b322c4ad44e32c6dbb0edd256215028/msgspec-0.22.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa3689b9dfcc663358ef23ba4299d7460f01108515b041a7d30d05908ac9c32f", size = 228962 }, + { url = "https://files.pythonhosted.org/packages/0b/59/bf8504e6f63f6769d01fb66f8bd856cf0ed39a07fde354f440d711640054/msgspec-0.22.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:d2f950239ff1fc7322c6f9634807310265149cb168270d3ddcdda5b6ada13a28", size = 236691 }, + { url = "https://files.pythonhosted.org/packages/2b/40/5a9d2bde12af16a22ddbf371990a81d3e3c0dcd4bb4ef3b3f9616b033c14/msgspec-0.22.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:3c789b5ccd07c0a3c09767108ee06e089b2875f2309a4569c2648f30a8d31dfa", size = 232750 }, + { url = "https://files.pythonhosted.org/packages/75/5d/c0e6bdb81a87f6bd56a663a330c271af7670490c80d8d635d9fa21ad1adf/msgspec-0.22.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a66b1766311e42371e509c996c3933b161c7ae0eabdf361af5316dec197e1022", size = 136814 }, + { url = "https://files.pythonhosted.org/packages/b9/c0/b0cfc6d33608e5ea8871f3be31f9146c56699e737a7d8862bf018484f278/msgspec-0.22.0-cp314-cp314-win_amd64.whl", hash = "sha256:749899563d26b211379f142b8ffd7e2d7da149a51717798f0ce994dce50324f0", size = 197097 }, + { url = "https://files.pythonhosted.org/packages/42/1f/571f7fe7c725380605d680fc4c0084212b23d2dfcf6be0f2277f14462c56/msgspec-0.22.0-cp314-cp314-win_arm64.whl", hash = "sha256:10d0d1d464960d99a949f7ca01ef8928e51c472433a5f5ab74b2d695fb830652", size = 196779 }, + { url = "https://files.pythonhosted.org/packages/ab/f3/3c87372bac651b37911e0dc6926c3958949d3fcb8cec1016adbc44d948b2/msgspec-0.22.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:e79725246291516a7359caad5fb743ddc0ec66ed40d2381fb846325b5031504e", size = 205214 }, + { url = "https://files.pythonhosted.org/packages/43/4c/fbccd6e0fbbdf10c4d9b6bac8a26148dd5483b3ffff6d6c5a376ff1f5cb1/msgspec-0.22.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:38f7022fbe91954b31afe3888a0af1b652e0f370fafdeb1d425f4a814d789c9f", size = 196941 }, + { url = "https://files.pythonhosted.org/packages/55/04/8db7186d3ae8818356bc623cc132db8b77da37ce4b1345f35719c8ad5726/msgspec-0.22.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b6d3ca19a8ff28d0a67a1824e2bff7ec649ec795c80a265f20ade4caa63080de", size = 229934 }, + { url = "https://files.pythonhosted.org/packages/17/24/a249f3491cabbe77cc65a1a6f87c128582aa39357227149be61cac8e554f/msgspec-0.22.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a8b98ae215a102cbf6635f7df45f5c4af12f77fad1f7b71b9808fcf868a5735d", size = 234378 }, + { url = "https://files.pythonhosted.org/packages/87/ee/6dbcb1b5de8e9d47e8f0fde9a288628dc178c1749a570b98251218fa10c4/msgspec-0.22.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e0aa0cc3f18c35bab79bd7b87fde95d6274a9deddeebd1ea541f8066a5073165", size = 243118 }, + { url = "https://files.pythonhosted.org/packages/79/03/7dd2d0ca988600e01fc00ad0cf20d1d44bc59369a913c988654c65f6582b/msgspec-0.22.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c8e84789918fbc15a503b92a829115ddd7567ecd3e4778bd418c56abbb86c11", size = 234557 }, + { url = "https://files.pythonhosted.org/packages/74/e2/43f3c63bff1650efcaaea31466246e28b46927323fc9ff416c68cc6e4047/msgspec-0.22.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:3ca7d4cd69fbb66bd2da6211d3e79d40542d196c16c6d99bf838f76767ad35be", size = 241288 }, + { url = "https://files.pythonhosted.org/packages/8b/70/11b93815a59674f33182dc3e873d343ca0b37e25be52ecb28f52092f1fed/msgspec-0.22.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:28f53f3604dd3e70225f7563c831628dbb03299b428f8e62aadb4b628e386874", size = 236432 }, + { url = "https://files.pythonhosted.org/packages/b7/82/7aad0f033f8dcb3f23868773c2ede803ae162a784828ccde75aa3f9b2f9d/msgspec-0.22.0-cp314-cp314t-win_amd64.whl", hash = "sha256:7293dee54de040cfa225c22151cc3d72f17cd674b5ebcb52f38fb9f5701592e6", size = 202062 }, + { url = "https://files.pythonhosted.org/packages/e3/45/cf52577926d73e2369e25927e389cb4ea1461169c489f46d3248159b5be7/msgspec-0.22.0-cp314-cp314t-win_arm64.whl", hash = "sha256:c3c510aba9015c085e514b75a9b3f1ed7c4591ae5e379655821b8bba51f30cc7", size = 201686 }, + { url = "https://files.pythonhosted.org/packages/c8/63/d93937e2aae34ff1ea33b62799d1963cacc1bf432d196d6130039657a122/msgspec-0.22.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:263e110955ed76fe0af2d79f819903b50a70dc0e7a752eb7aabe79d2e0a084fb", size = 202241 }, + { url = "https://files.pythonhosted.org/packages/3b/e2/46ece11a244cd56432eb2362ffbb8014f3f02963136d84d941f71fdc2a3f/msgspec-0.22.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c6f06576eced70462179a4b4638e84cf69fdbba37f44d13a64a21739c131a830", size = 194232 }, + { url = "https://files.pythonhosted.org/packages/cf/b1/1c385f2f93006cdc2af1511cc512c347cb22e2d4f11952c205230aedf586/msgspec-0.22.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d67582478b0eaabb899f2fb255c878ee7de57dff80eb73ab24f1865524ec441", size = 226524 }, + { url = "https://files.pythonhosted.org/packages/dc/fb/c80c8842d40347cacf89a60a4986b849dae1a6dfd25830441efdd6faa65b/msgspec-0.22.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:71cbbdb39631064e2f2f9e9ac2b1b69931d72276eb5f9da4ed025726296bdbb6", size = 231816 }, + { url = "https://files.pythonhosted.org/packages/73/ac/90bbcfd890b4bda90c93f7e1b7fc24e84b270420486d9d43ae31443d15ab/msgspec-0.22.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8f0a5c25516e2034b2db7767081759ff8996e214def9c43b3055f61e1be1caad", size = 244241 }, + { url = "https://files.pythonhosted.org/packages/72/9a/eabdb5f1b5e6013b0e2f9f2a95790587f6864aa9ca37f9d7dece65b53878/msgspec-0.22.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:a1dab6a99c759d1391ab2993388c1892746a697254f4b5dc6c059ca6e3bfbc8b", size = 230198 }, + { url = "https://files.pythonhosted.org/packages/e9/89/9f080532d4ac52f416dd7318e55c2053cc071853d17d58e24897a5b553bf/msgspec-0.22.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:a52eba5c9528fd181fcec39d22b67aaa1dccc6cfe8e24d3f5d41130e6d04289d", size = 242949 }, + { url = "https://files.pythonhosted.org/packages/11/df/6baf9b2f3523ebe2b820820c7929fd72ec5f483a93147130338ecc353fac/msgspec-0.22.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:1e547966017265c0d23342bcf2e027305dde40ea042d16694a9b96b4f696a052", size = 233914 }, + { url = "https://files.pythonhosted.org/packages/bb/37/9cf650779c8c1e53291ef184c838703930a4cabb1fb37e222c85a7d49fa9/msgspec-0.22.0-cp315-cp315-win_amd64.whl", hash = "sha256:0067057df265795f742658b15dbe53f3b6f21d19dcfa53676db11088cfa41e0a", size = 197910 }, + { url = "https://files.pythonhosted.org/packages/f5/ce/2f78c93d4f69e0167a19c2d40d4fbf7bbd6f074e1047536735832a4368ee/msgspec-0.22.0-cp315-cp315-win_arm64.whl", hash = "sha256:05dbc8268e50c9232ec72b9af1c7b13049aade4d1197764e38c427048706e046", size = 197590 }, + { url = "https://files.pythonhosted.org/packages/3f/bf/282e9a443058b85b8f706c9a651e2d8cdd11cc09d16e8fa347b6c57b75bb/msgspec-0.22.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:b3113ebcceeb7693a915183c73d92c10bf5c62851dd187cab43bd025fb587419", size = 206298 }, + { url = "https://files.pythonhosted.org/packages/ef/2d/2e694fa46f55319007f72013b17341ea3868be1c77e7a597176b202dda92/msgspec-0.22.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:0dfadea8bdcfafc614bd031de55a8ede22b43445cfff6d8b77cc0c07d3edc8a8", size = 198145 }, + { url = "https://files.pythonhosted.org/packages/5b/2e/2fa279cb57cb47175ae604d572787f903d4ad3f0afa867201bbd99e6647e/msgspec-0.22.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7a738826936c72348c613061d260446f13c82b6fd7d5d7705b6911ab8dca2f3", size = 232362 }, + { url = "https://files.pythonhosted.org/packages/a0/58/a7e759b11b28441c27f803b29d9b5f4b5ad85150c89354b5ede1baca9258/msgspec-0.22.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f2ddea9d78d09460f06c26a7a508adcd049761c3208776162b8eb79b8a032cff", size = 235885 }, + { url = "https://files.pythonhosted.org/packages/86/56/8d7ee098e94cbd9f35fa643dc497e06a4a6307b9f562cfbe48103fc3b209/msgspec-0.22.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:884c28c80b0a511595b29a9b04a3a230c3797369e4a033e6d5c6d9b5427f8e09", size = 248155 }, + { url = "https://files.pythonhosted.org/packages/b9/6d/1cabb4b8a5dbf696e2b24df9e482b2e0333bb3b1b13ebb5433813e6616ec/msgspec-0.22.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f7a923bcde480065c8e25967464cfb2a687ee67000bb43157e2d57e40eca7305", size = 236416 }, + { url = "https://files.pythonhosted.org/packages/ba/43/8bf0f558eb369f1f2d494b3d5ab9d0ae0907d07ecc0cdbe11b6768b02867/msgspec-0.22.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:65eea14bc65ccfeb8f3af62cb204841871e2961f002d7fa87dbe0f79dacf1c1c", size = 247292 }, + { url = "https://files.pythonhosted.org/packages/81/33/2fbaadf98b5510cac4bb56d2b03937e0b1fb4bfcd1ae6aba20361f299583/msgspec-0.22.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:0666a1520cab86796612e794e71107e0fbf5e8ff3ddcdfcfff8f1d94b860d2f1", size = 238220 }, + { url = "https://files.pythonhosted.org/packages/f1/cc/b6be6041098ab859a8472983ccc2c08339fc2ef53f28d4f5fe7f4f34276b/msgspec-0.22.0-cp315-cp315t-win_amd64.whl", hash = "sha256:885c6e0c89d6103648525fe62aa78d600054dedf7b3713d23b15d7ddb6d66a13", size = 202939 }, + { url = "https://files.pythonhosted.org/packages/5a/c1/664578dd98be70cd4ab1a9dcf3a181b1376b83c65ec41ee162130b58c8c0/msgspec-0.22.0-cp315-cp315t-win_arm64.whl", hash = "sha256:268594d0bae5510572599a6ab0364dd9de43c867d24a30856cd9f5edb63d8dc6", size = 202117 }, +] + +[[package]] +name = "multidict" +version = "7.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/79/84ddb5ba16c4eb2c69c71db76ae3c579fe546e511f7170c7e27eedbab7c1/multidict-7.1.0.tar.gz", hash = "sha256:61a4e5d81b8d4e4ad61964b230129e7a2b914793d96289029078fc9009f074ec", size = 362341 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/29/72/c68c86c078a3b3cfa254bce218fbeaf1f6617d4ace811558154ae63d2c20/multidict-7.1.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:41ff3202cc23c800507777df5a4805b402f262b31008c60fdc652aeb6db2f278", size = 165442 }, + { url = "https://files.pythonhosted.org/packages/7c/af/988c71ec3a6fe143b2971ac8d57cd5aca7cc6da970bccd1e4c2254997a09/multidict-7.1.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e50f7775b66c7802f4cb697e986c5acf30ec07301efee95b396c08114e890d67", size = 95611 }, + { url = "https://files.pythonhosted.org/packages/30/5e/7d18eb5a75cf4bacb6b3ee689929dcb27d7ed8ca2d245e80e9b8056fcbf5/multidict-7.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:85cb3ced4fa84949cee12bfe78208b6ece7baf3cbd242b26dcaf773efff8d206", size = 98743 }, + { url = "https://files.pythonhosted.org/packages/72/38/2aeff3de6ddf235094aad6f833e3df14d1b3175e328748508443cb2b5458/multidict-7.1.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2128f3358335e0c83688ecb40c19d9d6606cd60784dfbf2e24e980ac2ba87b0d", size = 452832 }, + { url = "https://files.pythonhosted.org/packages/eb/1f/94d8b7fc7d47bb43be1fc041bccc2784383d352258d4d0dd90ef55d1a2e5/multidict-7.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e2e718fa9d1d900decbc240a533d5d0baf0947ef464c78a8cd4fa32b4e8f590c", size = 475378 }, + { url = "https://files.pythonhosted.org/packages/93/86/78f431ef2735fae7a773261ea26cff11e37cbbb4520bf3caed56ece006f1/multidict-7.1.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ecc68f5e47bc6f6f889bbed5bc657b22bb2237ad9ccab8229cb5a0d64f4cb536", size = 442847 }, + { url = "https://files.pythonhosted.org/packages/45/d3/1c5af82fe42f677f7c4dcaf487449511d754b84fc29f11a6d190cfb01836/multidict-7.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5f21fda91bd6c34455bd5c312e42aa1334da46cdafb4c533ecd01e0f7f19250b", size = 493652 }, + { url = "https://files.pythonhosted.org/packages/c4/bc/414b4a83a12811329c046a7c1954d6f08a5eda63f8466939a6cb612da22b/multidict-7.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7a90453a79423cd7145cc08fc92322dcd7aca4862258f533e03f473226d4b835", size = 506991 }, + { url = "https://files.pythonhosted.org/packages/ec/ac/a91709d7153489a3b53661b62af371112b96fb083d10443e22d54665a1e1/multidict-7.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c54ae1b89e582aa25f213cd8b5eac0bda1724e79299f486baeb3f562bbf82ca5", size = 480392 }, + { url = "https://files.pythonhosted.org/packages/72/63/a4fc53b6da419876f363fc6860f9fbaa48eb8066f458e907eb2989e9510b/multidict-7.1.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ff8dd079e7b5f3438332499233a2a5acfca0741fd0eb3d4ddba0c2d9bc04d19", size = 431248 }, + { url = "https://files.pythonhosted.org/packages/3d/78/08af4a624b3b9d2f48af730c173394135d14afbf4b3de5ae6c80e0900fd1/multidict-7.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e4ef15d0a29fc2da67fe8ba2301ecabd6f8733696cc2bf0a0cf96a144a20328c", size = 463657 }, + { url = "https://files.pythonhosted.org/packages/58/ef/06e5c75b88506972c880d228fe96702cf0fa058ea31f157f5fcd5efcf006/multidict-7.1.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:5fa296f14068538fced53c6eec86520a2ef3d3d27a0fb134640d03e067986d5f", size = 436120 }, + { url = "https://files.pythonhosted.org/packages/23/8d/a917004a1e8ceb325c4cf0583e418a40b7ff9519cbfe759bc6982cd3b4c1/multidict-7.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6ab323f0c5490abaf35a78563e1043c7a772eb86d93f359ecc0fd286d1cd3807", size = 453859 }, + { url = "https://files.pythonhosted.org/packages/1f/0b/d1cc417355625152b611790aa1f0bc780983ed8c0752f44087fc7dbe66c9/multidict-7.1.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:88ec4d16e9f58071c9896ea01c4da97cce9d01418fe844ff06eebb00e0a1386a", size = 481768 }, + { url = "https://files.pythonhosted.org/packages/94/0b/8e79adf65cf5430497d1020504b7d86ebe90e3289a95bb6b252a630d761f/multidict-7.1.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9161eb81b8062da824426d3700d4b0d287f0cb0b05923713adfe3bd25e7937ac", size = 426973 }, + { url = "https://files.pythonhosted.org/packages/ae/4c/e84d6e05943600b16cd2745587e65a407f89b3a4a1f77081d456c5a07011/multidict-7.1.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:c564d0758748f38aec56a6b98c6801a427b3a63f39b7cac538b2b2d18ca32740", size = 489681 }, + { url = "https://files.pythonhosted.org/packages/eb/98/01951236a01b3e15c037a4a796f329b5099374e46365d0bc65bb4a7b325d/multidict-7.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c5e4a362a95b85301d262ef6bed06cc8e4a144ac7e2be874cb4c3c46ae89d754", size = 475655 }, + { url = "https://files.pythonhosted.org/packages/d3/72/52be318cdb46732da8eb118c2e4212fe570724eaef817901f8b702af2016/multidict-7.1.0-cp311-cp311-win32.whl", hash = "sha256:5d19bb1ec12e385c09215d5d53a243c060c7e8a0aacdba16d933e22902ee380d", size = 81719 }, + { url = "https://files.pythonhosted.org/packages/37/fd/1ed7b7d206ef7a6918ac3de515543ca4b49bc50131325ca1814c005daf3f/multidict-7.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:396ba9917fe489ec3a5942ae3e29e91324c8b9956f371f7e124c971c71379e7a", size = 93586 }, + { url = "https://files.pythonhosted.org/packages/d8/1e/837877d218bfd2b656ca899ec9dd83e6eb82d8e1ef16e896d5abf3cb60cd/multidict-7.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:b5ed78742502b8d90ff2816688d407a097c8b5cc6af4343fc5ad7a98df53a7cd", size = 92446 }, + { url = "https://files.pythonhosted.org/packages/03/e1/215e7df354e2907f3af9d910c8136653cfec94796012a776359bc802a326/multidict-7.1.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ccfb950359a80de0fcd2030ad60ac1b1a861462de3e2ef746697c9256659af21", size = 165426 }, + { url = "https://files.pythonhosted.org/packages/a4/ec/461ba588b308ada2cd16907d6ea425ca4d417596b88c12814ad9a0bb7325/multidict-7.1.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:939d8cd2d8c35e3956f6bc858390b6ccb611e6152b4920d64ab5e98f3fcf39e4", size = 95943 }, + { url = "https://files.pythonhosted.org/packages/c9/84/31444ef07ec13a33c42c2772d986129e137e69c4beb89ca64f2138988145/multidict-7.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f8e95c95039eab6a2dad8c83c38ab87fc5431d28849e0c8a7e2a4e70ba38710d", size = 98294 }, + { url = "https://files.pythonhosted.org/packages/dc/10/aca13806d73d88b5b01e35e828e23a346cb1abad710a49dff0507702efc9/multidict-7.1.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05d12b4bac53abe0c65f3163af2b45894e2e1c0cc55493ac784d52a350047d88", size = 453301 }, + { url = "https://files.pythonhosted.org/packages/96/8c/382d771bfb3a9282d98332d0e1f27fd1f8b4ae0e7175bd7e008ebf934900/multidict-7.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0e79ed92b1dece6bb57e9b46effd74d7a5d3d00187c85466d880ed184239a698", size = 478570 }, + { url = "https://files.pythonhosted.org/packages/52/9c/e81b0c92449da3a1950a575c520d957d7be618777d170717a71e00558d7f/multidict-7.1.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:fab380fcff8b3555eb2bd04304fa4330909a771a9a9b0dc07666cfc23148a711", size = 443699 }, + { url = "https://files.pythonhosted.org/packages/63/72/f8f5fee6960d1b580a7b046c7c5abccf67aa26fa5647927a91c9c835c2f8/multidict-7.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4b5c41e44da74383c924cc5d75ef0a268f301d69305b3c42bd17af685d55e412", size = 497332 }, + { url = "https://files.pythonhosted.org/packages/e2/92/a25d7db3ca451b588e743e067ee80f2b475edf6467a56908454faf6a714c/multidict-7.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dbaa7f7c2f0ca8578895fc61fb8c8e50ebb405dad8982f92f4343285c7a3fda", size = 512457 }, + { url = "https://files.pythonhosted.org/packages/7a/f3/374c0ab122bb98b1a62a8742b1e3f59563e4d609942005a4041861a0df67/multidict-7.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed6b7705d49dd07e5e0dd5f5c873fc44047e92d714299b13245b5fecac49d01", size = 483000 }, + { url = "https://files.pythonhosted.org/packages/46/1f/01c8522859771dc3cee840d5852233fe84c91aa982a1cd8ad594306d25cc/multidict-7.1.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:53daa47dd176db64bb35170e3d5d0ae2388c060121201883696278f055a0e70c", size = 430746 }, + { url = "https://files.pythonhosted.org/packages/ab/f3/af90affc8cca6b4ee59b2ec354a20839005e829d14d155451009810ef1ce/multidict-7.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:542429c796430de924d03b68a6173bb6d79d5c4967d4e9a18de3e501cad55593", size = 466859 }, + { url = "https://files.pythonhosted.org/packages/f8/97/1b6762f37f6331449e164af9d5500f0abb0f23670e81ba543441e0a6be1d/multidict-7.1.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:c44ca6d3cdf4cfcbcd4f928fdcbe87af5fd7319f6ad4169617b7fd6b4527c33c", size = 441952 }, + { url = "https://files.pythonhosted.org/packages/bf/d2/4908177fbf22438799c04ba11a2853a99c69d028fccefe61f19e68caba0c/multidict-7.1.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:eb0228c809b2e7eb47921876050af0bc4214b351bad8d8112f70b6ed4288763c", size = 452810 }, + { url = "https://files.pythonhosted.org/packages/8d/05/5031f44f680ec54fc182c4d71c9ab7c07216983946aa64ce6fdd56a52692/multidict-7.1.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:6ad60de1f4c702448fc8f1449f05e810f6b7957c08a5b3950c8a792dfb13b50a", size = 483934 }, + { url = "https://files.pythonhosted.org/packages/da/3b/9b21d107dbe96fa7e6966ff9e5e10b9ac0d2d1c2cf1633098e4c700f865e/multidict-7.1.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:f79def86aee67b5ba01b2565f1610f262bf88ae53c379f93e5fa29c50fe793be", size = 427042 }, + { url = "https://files.pythonhosted.org/packages/53/ce/5b01b1041580072866b30e39c6380bff269e72fb5ca41a7f2fb828ede943/multidict-7.1.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:248dabb89b5aa90b2f7e43e045f048f7e5392ec77b6446d80853ba7117d7bbdf", size = 492543 }, + { url = "https://files.pythonhosted.org/packages/1e/6f/6508a23fcc7b1122e4f18409d7900ffeb3cd020cd080fe98aba3eabf9486/multidict-7.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0747a83e7ae617793181a4763ee8b84863cec5c0bbbde70c4394e4c0276c36de", size = 475796 }, + { url = "https://files.pythonhosted.org/packages/8f/b6/ebb6433f4aa55ac1fea4bf11e860e99611640d07cc92d21cb3f35608de22/multidict-7.1.0-cp312-cp312-win32.whl", hash = "sha256:1df055e51fe7491120cc84f3362bd43db186be78d0e4c476acad45e435af9ffb", size = 81250 }, + { url = "https://files.pythonhosted.org/packages/61/0a/11240e5e7d2e986e288f4a6090f569ad00225b7c2c513d4096b36643f9ab/multidict-7.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:10202ba98cfb3f7eb60da7ca87a2c458a69b7d0d6e4d4388cd6773ebbce89085", size = 93817 }, + { url = "https://files.pythonhosted.org/packages/e9/9b/a04ffd76db7cca2a60d347e839315e1ada63017c6c343fa90a23a5a55433/multidict-7.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:0aa1ba3ff7cdda05a1242490612976b2ae1c90fc6200903ef8f53815dcb35c5d", size = 92312 }, + { url = "https://files.pythonhosted.org/packages/60/34/22ee5f60d704e8edfe6e0a37ca1e2d1efe276560dc8f6f8caee35ea7a4f5/multidict-7.1.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:51d7f33be9a4a1a2801430846d72841deea0894eae8381a07e7d90e0f71b3c4b", size = 101454 }, + { url = "https://files.pythonhosted.org/packages/3b/6d/7652943397171fd624de163d25fc6d2807852931c40c24776cfee3f857cc/multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2a964dfeb2aba3663f0536c809aa1ff385f065e89fae57e883fb7edfb4067c2f", size = 95823 }, + { url = "https://files.pythonhosted.org/packages/a2/57/717d1aa4e901f58a7e93b945ed741c860d950f456b75be47123fef507ac2/multidict-7.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d02cd23b5af182a49d635ee72be38053767711987a9fd82625b16b93828a0d8c", size = 97841 }, + { url = "https://files.pythonhosted.org/packages/a8/74/d2d22d306225f3c53ea5a682abb8a43bc30057d6c78b5c94a74035450bfd/multidict-7.1.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:0179698c3c913eb64f32397083747fad20ed0f0a2b7469a08cd1a8a95d14d90e", size = 164661 }, + { url = "https://files.pythonhosted.org/packages/55/f4/1e63fea41ba86768e18dbc1743e6780f75ad32d7a45e418740c6fb64589e/multidict-7.1.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ccf98ee859fe29f874ddd8e637f14ba59108a333492b521acb885a9095244a9c", size = 95489 }, + { url = "https://files.pythonhosted.org/packages/8c/55/477c351b21b34fe948ffffa8b64cd0c246efd998fed3de922b217e632e59/multidict-7.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5fa1484f74d011addf2e5f5a0378ec41521989839a05d6051d8067d8ce732423", size = 97931 }, + { url = "https://files.pythonhosted.org/packages/cc/dd/288508d7deb9489dd7c8e0b172d62dc1da8f3cb24877293ead42e6cc2047/multidict-7.1.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1fed3d721f75c25a9fcdd0e362af53f4b20acbcdc63081112f85419ba0ce3444", size = 453816 }, + { url = "https://files.pythonhosted.org/packages/60/ed/172447dd09f06111f69d2cf22a018020b34a93a39ddf72cddfdb48323449/multidict-7.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df03e392cae1e05462918abbae06d6100f1e53f67db971ff0ac6c07d9edf7321", size = 477779 }, + { url = "https://files.pythonhosted.org/packages/e4/7d/e5b7755e84611ee846f0dc830cb1363ce10764b29247a01b857722fda653/multidict-7.1.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:379f477b98a1e9a77ddc3ccaa8c709d3fb4a288ff54b96e171e637b55b4adbae", size = 441299 }, + { url = "https://files.pythonhosted.org/packages/1b/d6/e9c93da1644491610f09258349283421a279fdb3b383929b4d963698691c/multidict-7.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:40f586bc8a084a3671ddcae9e5fbd3228a596bfb63d9f0380f153f9a65b69f08", size = 496193 }, + { url = "https://files.pythonhosted.org/packages/22/49/7fe19efed1b1c73e2f6ae65eba4e6528eeb26e10c970dc0bb0fc009a2aae/multidict-7.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:6e7f70d912a589e30290ed926f90ddbc3160998359cbad7c9ede1bcee481748c", size = 511469 }, + { url = "https://files.pythonhosted.org/packages/4d/2e/7b708a72001323dc6b2930834d63e355870ea5b1ee8d1450e0fe1a24752b/multidict-7.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5129cc1f5fec6888e2db0be936dab67242e32c738811c8769aeea93aab4257a8", size = 481377 }, + { url = "https://files.pythonhosted.org/packages/8b/60/3d23e7d2ebc7e7e6b1b205de0d5c2ce0652a9b2edef65eea3528758b5699/multidict-7.1.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1401caec21fd7f002e79ab6806bbfd1f54bb3de6d5e12bd91c6685dce16ad2be", size = 429992 }, + { url = "https://files.pythonhosted.org/packages/f6/e6/8e3bef78ea1929a3f2c395aa292799d4effd6ce1b5cfce42c1986a33e2b5/multidict-7.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c81062e947f4b5a624135a843f6ac4b3c7fe6508300c9fb27347f022ba0c513d", size = 465635 }, + { url = "https://files.pythonhosted.org/packages/44/6a/55fe5dd90c0e9ce60f5c4fa8ace27eeb1190d9cbb151929b04412f0c673a/multidict-7.1.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:c53be0dd676484a660acc56e4f1cd0dd74bc1255d12fa285e86a3fa9d5f22bf9", size = 441693 }, + { url = "https://files.pythonhosted.org/packages/c2/a9/628913f71537dce7dbd6c4ee1ae5019976264427c55354ecc593b4b921b9/multidict-7.1.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:41e0c3350d08994ee8640c39884e16514e282f70ba40f5b2299582509a327774", size = 453808 }, + { url = "https://files.pythonhosted.org/packages/25/d0/3ae3af653b5776d045f460582006d0b4d7dd41b078b3ab2e874bf4d1e22f/multidict-7.1.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:abeec7a89d698aa1c9b4c36bd5e3c746faef0867076e6a2ca27fa5077c4ece26", size = 483160 }, + { url = "https://files.pythonhosted.org/packages/56/3e/2c13e111b9f4c6216aee0ac57ac39c53db7f97e42bb8490e60a06eaca352/multidict-7.1.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:23f6d325241b0db006ca2841309ed17622137e134930a740a8f1331ec4404791", size = 425896 }, + { url = "https://files.pythonhosted.org/packages/45/13/15b4d614cf0d6838a7d89eb14f4820fdecfd1b7f529061d172a674912d41/multidict-7.1.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:034b0dc1b7fb8279599c5d8563f86abb4d2454735b06544ecab23c54572ad2bd", size = 491674 }, + { url = "https://files.pythonhosted.org/packages/4f/97/3a6f8c75f12a607ff047e3db9a45dff6a558b1e31b89b2f0f27da0fedb6f/multidict-7.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:5c8074ad4d67067c87bd0663dfda654f786336078c8fd7d2f6c1aa41de8494cc", size = 473989 }, + { url = "https://files.pythonhosted.org/packages/3f/20/5e7726631e50b6afd26324a234851a06104b8ee30f01bc209140788b76c6/multidict-7.1.0-cp313-cp313-win32.whl", hash = "sha256:7b25c335fc53acf29d4d21dbc19fe39d2824201cdda0448623152cc5917bd259", size = 80952 }, + { url = "https://files.pythonhosted.org/packages/4f/19/b26bdbef5bf5071ea5a3ba37c963af187fddb8b0a925bd39c0c5dd0ad04a/multidict-7.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:7de54b49e6da811b0321e412d14efdaa1ee0c0b6609296ea5b9022bc5b2bd843", size = 93554 }, + { url = "https://files.pythonhosted.org/packages/35/d5/e9a8600d3868e3fdc4a4be1d15e594048508511c0398423df0ed18c11b5a/multidict-7.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:aeba2c750102051aa51e087c2ccbc79f2724a41c94168f8731e36f54c453551a", size = 92039 }, + { url = "https://files.pythonhosted.org/packages/2c/a7/c9f5c08348a6f903143b631546e22becf1b704a1304b17f7e1b9850308d2/multidict-7.1.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:128ea4142f81a79d430f3d0eb55206093e5eda03a12abbc7b03c34748ff6116b", size = 100435 }, + { url = "https://files.pythonhosted.org/packages/82/60/92fe617008c74cc019483b1c59202c48738c6f637ad5b12b36f01e21db43/multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:439a19f7fbbff232ce96682c57e27030b8ac3a4b8121484c94f04bf99d08bfff", size = 95267 }, + { url = "https://files.pythonhosted.org/packages/17/78/82182f311d673f17de15bc7f7465c7ea5cbd2987ca3a6bf6546fa721e9d8/multidict-7.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8090c35199d6b7bc6426bb8bdaf341e64f295cc2624a1fda7860c0837f1acc03", size = 97233 }, + { url = "https://files.pythonhosted.org/packages/63/25/af4e482d053fd73b4b1d60de3ff5578c6cb1d319d39d3f2a454d65409835/multidict-7.1.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:b7cc5333fcbfb27327d12612ed72322f221b61c2b69deb1155078c964f86e1a1", size = 163070 }, + { url = "https://files.pythonhosted.org/packages/df/d5/eaed52e199451dac445305fb2631d3f4e7ac9536aeef01f23622a1d93f90/multidict-7.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b0e0040b0d8dd89bd0af9ab18901981e344ffba68bb30b8eabb4eab6c303279b", size = 94582 }, + { url = "https://files.pythonhosted.org/packages/6b/e3/ff58ecad5161baa98dec05716e2745449446f2424a9c727464bf452f7fd1/multidict-7.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:33376418ab2846b931a72b36cfa16810befc4f49485d0b3f4dc054a4d6d00038", size = 97406 }, + { url = "https://files.pythonhosted.org/packages/ac/a2/ae4eadf02d4bc035baa7cadf5548ca4fa441517193f76ce1aeb5ed276ae3/multidict-7.1.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:1101aea5c3eb1d26e090b931c693488af0db9f3d52e68be8d4cdd807dad9841d", size = 449908 }, + { url = "https://files.pythonhosted.org/packages/91/55/bd5101ef760d1af4c3f246330b29b48ee22bfdd5a83150b713dd93b431e2/multidict-7.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f996b19ac89e0dae65821ce65f788619e4286f78c62d005ecd3b75b5d9c0892b", size = 471605 }, + { url = "https://files.pythonhosted.org/packages/4d/8f/3dea8a28b0416ab71d47c8ce274bb3cacedde2d9838647ac67cdaa3d48e6/multidict-7.1.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:e0d91a4bcb59ac0d7af0d8e0da737332e1b7fe6831e53e47819b1b5349d431b2", size = 433523 }, + { url = "https://files.pythonhosted.org/packages/24/97/805d2aa4f746cc43cf4b206f1b1bfe2566add95bbb145abd3d9cf61858bf/multidict-7.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e96d67914ddbf5466e4476a1cd7ff30a332cbab85ed895207acc3e58c979b6a7", size = 488277 }, + { url = "https://files.pythonhosted.org/packages/dd/7b/eac247b7e76f6010071c7c2401da5255eeffd1b2ac981925e533413d21ec/multidict-7.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:170ba61761f59ab92afcc86ce5534a3f3d0b07c38b339b950a83213f22dd86ec", size = 501888 }, + { url = "https://files.pythonhosted.org/packages/87/58/de62e27b09dd15756265765945f68f1c0d1e23eaba09e2e109fbb4112425/multidict-7.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:33389fe084e5426d9fd85d7d9ca91a29cd0d88a83c7c96e411aca49a3f9967bc", size = 474883 }, + { url = "https://files.pythonhosted.org/packages/21/fd/afb4e50ce3b44707b5ee39c6b5fa1573bdb50c4be660bb35b040362b5170/multidict-7.1.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:77024596b9046572c4e90b34c1ff212346756dc48933f90c53cf6e233660788d", size = 424327 }, + { url = "https://files.pythonhosted.org/packages/23/70/96c9abf933c4edae53b8a1c8d3f038120a3a60d363f16c6d94eee9b04851/multidict-7.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ff15531a376dc6f35984443fd1429e4b150c36ce27633e7cc52a9e5318546e20", size = 459255 }, + { url = "https://files.pythonhosted.org/packages/24/37/dbc1dba26dc1c9e42aa07ee01908131a67fee0e79ebe5c1d0e7fc00aad55/multidict-7.1.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:544f2642a456fa264614e975d921540ee8c3b368b04d5aa1ddbec33241b13e08", size = 432476 }, + { url = "https://files.pythonhosted.org/packages/9c/0a/eac70cc1461668bad8253a2da727670a56293eaba112aeb4079af8cd37d9/multidict-7.1.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:08834fb8b20e1a985c70e8380a10940234b4162de62694458727330376e58b33", size = 450030 }, + { url = "https://files.pythonhosted.org/packages/23/2d/eb40652ea74f96df859c7c5d38f9997f420b9dc404300dda0c5745327a6f/multidict-7.1.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:b4908e17867930b7ac77f89a18dc67308c67c511f037d8580489be86fb585912", size = 476169 }, + { url = "https://files.pythonhosted.org/packages/44/d0/7454ab8335bc4ec9f8abcc4c83a314bab060f22db6da8436b396e07ed21a/multidict-7.1.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:9b24e1f93b9b586ec03bc7bea1bf021ec90bf2528c729195028a3ca1c266b3f9", size = 420459 }, + { url = "https://files.pythonhosted.org/packages/60/7a/cedb46b287b720a2c4eda2448faf162612d0b718beb32581e35ccd9219b3/multidict-7.1.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:5c93473d0d7cd9bbb370973a9679a62f381c7050d7dff4ad6aaa92e8650f5a79", size = 483071 }, + { url = "https://files.pythonhosted.org/packages/9a/11/e7ded22b008546dd30ae8c979aa5ac3282cad98876f7d5bbe93c1f2409a0/multidict-7.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:bf14cfcc30b097583d698a6e2b8b68c9bcffab277c485d481881958360c2938d", size = 467470 }, + { url = "https://files.pythonhosted.org/packages/1d/f3/0136144cb0b1731fd93475cea75d974a55009358d3bf61dd55e2709a3dca/multidict-7.1.0-cp314-cp314-win32.whl", hash = "sha256:86bc779a0896e59e4be30a5be5cd6eeffd0b40b6f0e75e730218736b7bfc6f5c", size = 80205 }, + { url = "https://files.pythonhosted.org/packages/74/d9/a62a690d780febc171026d3e3c5700fc8387aa3e5f77cd04ada33d23d3f4/multidict-7.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:6c9fd50f636a8fa9cb6324cd3eac962fec2bc5bb432452a3b583583a1059acfc", size = 92923 }, + { url = "https://files.pythonhosted.org/packages/84/08/eb7a1c34dc37c5f2c6aef52e6861e6f1cdfc6c685dcb72581eb218c00953/multidict-7.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:e6906aa4bc62cde2c8aeb8a99a7b4401b241e274ae7b11df67d863d61ab3d5de", size = 92851 }, + { url = "https://files.pythonhosted.org/packages/ff/3a/019abd746e8fbed9edd74b81259f20b278c011cef918868506d9b5bd6566/multidict-7.1.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:fabfdd4cf97db033196b51af46b8a681d4785c2a66347f2a5af1b4bbb1182629", size = 177429 }, + { url = "https://files.pythonhosted.org/packages/f7/41/c8dda935231305d9b83c4a4cc5b5b9e323615328d4704f00ba2cf6d89916/multidict-7.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:a177a0ee5cf19931dcaeb3f662bc562754cfa4f4ace2351d9da24a954ef7db94", size = 102003 }, + { url = "https://files.pythonhosted.org/packages/af/23/3c3e79a222eaaa59a32648cec34709d848657be5339f96876075731e7f8e/multidict-7.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0ae91de396d5c4ac97cb24dbada3d5c91a51454781e0a70476b008f4e879e4f0", size = 104861 }, + { url = "https://files.pythonhosted.org/packages/cd/5b/04637e7cea5729466fb89048520fde167c5404df1beedcc86f18aeabed7c/multidict-7.1.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:160bdb3520fdadcaa21e1b98aab2e011265070814ecab3804eb61674becbd400", size = 463221 }, + { url = "https://files.pythonhosted.org/packages/2d/42/b121767de213a9774399c200cb877b19c2e1e5a0b0a6bc8407c7325fa37e/multidict-7.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c2144785e42527404bbd5cfd11981fee4abe59a22aded0e498eb711a831d3f3", size = 502241 }, + { url = "https://files.pythonhosted.org/packages/47/0a/1cccd17ebf39776df7d8a6c99d066fc3ac1823d044cfe86a22ebbf42b841/multidict-7.1.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7d0b4fec6a8d02d7e95de5cfa913261820f1ce04bd4c0381924de0da523179b8", size = 457128 }, + { url = "https://files.pythonhosted.org/packages/19/a9/0616d20dee16255f8737d9017288d9304dabf3695ac0071baca25d00c713/multidict-7.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:943a9bce22180ad0f4d32d1b402a0949a4ecfe5a1257b47f54a1b51981d81b86", size = 511842 }, + { url = "https://files.pythonhosted.org/packages/91/68/5a406b82ecfeee50c097c05fd519c836a61815d81c4a9a8c523d90fb4b35/multidict-7.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f76ceb623f7ff50df46ac57e1587c479d87a5766319c4f43d0c0a5158896afab", size = 519852 }, + { url = "https://files.pythonhosted.org/packages/d5/da/d42234f9d4f0e33885c70149112bf62aa4436045090c15cec0810ada0b2f/multidict-7.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:98beff85392ce435b28a0971ec21cade61ce8be8b632c9d855475a28ef92d31a", size = 500322 }, + { url = "https://files.pythonhosted.org/packages/fb/c6/323e921a9812a6ea82978d4e1d9713e0b709023be037e6e4c14efe5472eb/multidict-7.1.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:71196ebb8d523148e5975396a444de02367f204b53b14e26794c96b2be0ed742", size = 444070 }, + { url = "https://files.pythonhosted.org/packages/93/87/109b7170de2168294f3e562f9d10caaaf946628d614b5a57246279acc1a6/multidict-7.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:35534b366410a36bb3d6f788691e37a76e4d1da48326b0ada3e5032580dd76af", size = 484862 }, + { url = "https://files.pythonhosted.org/packages/51/cf/7f1f63c9cf13f47036c0c64c607e6eee87c94076e3c6876abb6990ee62ad/multidict-7.1.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:b4674b12701c3fcbdf7f88b9e4479701c93bec5da9eb576140d5fcc0092990af", size = 457314 }, + { url = "https://files.pythonhosted.org/packages/93/ce/7de8b4ee6a847cc951915c279fc388127ec32a7c14aa7a01c4ad692be575/multidict-7.1.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0ead852a5e906a43fcb6784eeac480f6a67919a51d480c1f80d32ddf9d615475", size = 462672 }, + { url = "https://files.pythonhosted.org/packages/f6/6c/02dd089475983b1f5b8729319caa0b02fa3a60b527f27c86b2d53c24ca11/multidict-7.1.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:afe36ca503c2ffe30fb6df82b20389fa3c4035b5d65888a61310921cf3ae91c5", size = 498109 }, + { url = "https://files.pythonhosted.org/packages/47/d9/b9bdc59aa8e3eaf1f9e5d2460bbe0c428a01250d44417f8ccf965a8f6fa6/multidict-7.1.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:a5f0bebb10aae010d3c9ee3abaf83ab2069c718457aea09c15532355dd7e061f", size = 442260 }, + { url = "https://files.pythonhosted.org/packages/fa/83/21b044885ab81bf5c03ec5c4c16aa449977e428c5c122e38969481cec1df/multidict-7.1.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:b9d9b7d72975521434368fe8aed3f6b522060bf271adabaa5ca6c87c0c08e168", size = 498916 }, + { url = "https://files.pythonhosted.org/packages/95/8b/31686730092e349ee2255fa630945ca344fc62e76f0b18d65b0f3dbb0ecf/multidict-7.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:274023bf952f849e0d05eba28a4c1f65f9796430d2b09ec16539386c0f76554c", size = 493525 }, + { url = "https://files.pythonhosted.org/packages/c7/d0/c9fddcd7ac42b70a46ac9cf15e21eb527874164e14d418fcb50ce7190a4b/multidict-7.1.0-cp314-cp314t-win32.whl", hash = "sha256:7e0bfa161df365ba3c88899ee3b7c94755200967284bdedef8c1b8b43e2c0f2b", size = 86481 }, + { url = "https://files.pythonhosted.org/packages/24/ab/ce727c06680e72b5d381caa8587f561cb6fea1bfab6ba20c877019768a6f/multidict-7.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:34a35be8fb82d37087e8176aba907b9459f03d0e293c80f574c6337a436f4eaa", size = 99676 }, + { url = "https://files.pythonhosted.org/packages/fa/e7/d116d7ce514d04d9bf63774ed55e8033e2ce15ab5655a597bb947c53dfb8/multidict-7.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:d7dd46a8fcd7653c09ebe67eae9d4cb6636c7a905d9cbaf587dabcbd4eca6013", size = 98726 }, + { url = "https://files.pythonhosted.org/packages/28/42/963e2e38ffd79487b24775841cfe2abb85aa1fa08ee152afc4a9b64e1cdd/multidict-7.1.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:852c921217f330b3e81a822647ebadeae7e42cf503ec1992d0bfbc90121c09fb", size = 100450 }, + { url = "https://files.pythonhosted.org/packages/b3/6e/fbc4721aa0eaea2dff3274ff44e94f60a89841bbb4cdaecd9faa4eb64aa7/multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cbec738d2ad551c6f70955d7eec95e339380ee1564e2afe86bfee05fed52ceec", size = 95440 }, + { url = "https://files.pythonhosted.org/packages/76/66/045aee749b599560d5348bb1bee5c9d61d8b12f31d16e0594778ef3472de/multidict-7.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a5f721a2437390ab69c10c6df5c142478d399af8dfb02e6d823cf2358e8a4748", size = 97496 }, + { url = "https://files.pythonhosted.org/packages/97/79/f2916b81324d629eba363f760fba26cafef5cb437489c24969ada979508d/multidict-7.1.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:99cf27791129d37e191ff013bfc29bf6631c29edb21680c00978567b91fc5d6b", size = 163241 }, + { url = "https://files.pythonhosted.org/packages/ce/f6/2aab2b2d7bf2d69204bde0cdabfefe4df3d1954f3f156bfc53714917e757/multidict-7.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:2ba6611fc93c4b169d0e0ea376ebf4b8a529933d1f5f2c2ec7d8f8b93ef58ec2", size = 94586 }, + { url = "https://files.pythonhosted.org/packages/b6/83/cf690ab80a0db0f50b300a7141f45dbf1bbbb6b32457f74bb55f47d38f5c/multidict-7.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:c45629c0049fbdef932dbe408ac2b271fdc8c7d9962ca31160f4a0fc3455fe4f", size = 97573 }, + { url = "https://files.pythonhosted.org/packages/ce/2e/951c1412c9803e8f87c8673305be1e448761c940cd867ccfdd3e6a551dfd/multidict-7.1.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:cf606cfe3f67984b4064ac605d71e1eba12515fbabf5bd5a34a8952b8800dc66", size = 453476 }, + { url = "https://files.pythonhosted.org/packages/a1/55/12f9f95fc65470bf481ad3d1c0e0fc20094720d0af1985119656f2b4be25/multidict-7.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:50fdfcb03be719d9573597b095b1175d2e9d0b30d065791dfd9fca727c499442", size = 473078 }, + { url = "https://files.pythonhosted.org/packages/87/31/ebe216194a68934de7053d582cde62c839bef4dbc4ae67e9bbccf80a61d2/multidict-7.1.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a8bba9d1f6db4ef2a6ebfc937a65d36e80e3aada00b382eaf56fea8f639322d5", size = 435633 }, + { url = "https://files.pythonhosted.org/packages/46/e5/4aea764cd6a1d326d91f2bb92b1c8c39c3f5a8da624e7b20b88179dffb72/multidict-7.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:46d4af0afc6eb9867b3ae50605787c80b868e2f52eac3801246034925fe578b8", size = 488656 }, + { url = "https://files.pythonhosted.org/packages/6e/f8/b1e5935233fa4b504b1a58b584fcdf03526635e4b5f268f716be9108a1a4/multidict-7.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5cc58ebb731200ddb64d55f1b345630fb5f7a8138cdbd242af9dce964a7cb03d", size = 502311 }, + { url = "https://files.pythonhosted.org/packages/9a/8e/cde07147b6fc56b9ae0fe01d9c0bebd730b2be2c3052d3a5a117e13f561e/multidict-7.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4b87ad54e8d4adeb0a1f04889504d6ec7f04fb02609220810f51f1b6c66bc1cc", size = 477470 }, + { url = "https://files.pythonhosted.org/packages/68/a1/11ac1880eacddcf698aa227ba9387cda60128e38034eda182cf4585109ee/multidict-7.1.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f7e5dd83a615636b80182bdf446ece57ed61d5d51854acc5d9840631136d4e", size = 430088 }, + { url = "https://files.pythonhosted.org/packages/6a/5a/09926de0ec910704507a21c4f1ad76026aab9a5657ced868c57bbbe9324b/multidict-7.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9bc5e7f843d14a167cdc26fe2d22f6f3aa2feb57919cf3ff034262a57d8d95d0", size = 460632 }, + { url = "https://files.pythonhosted.org/packages/ed/36/cc51eb3ffb09f475326bbf868c6af2805fbbe9175122ce6d7a65aa06d6c1/multidict-7.1.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:0631eb5f49f67de10bbdc3f64141326dbc62e8d319900966648381ce0845d8ca", size = 434056 }, + { url = "https://files.pythonhosted.org/packages/6d/f4/9e0e4dce595573eba1555495ac9ca6b5f7f6e14cf6c3a5cd87ce5bd33d6f/multidict-7.1.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:d1b1b32f3c32f734dde8f36ac1df8e275e768a7b333241cd637cb2538628a4b4", size = 453014 }, + { url = "https://files.pythonhosted.org/packages/96/14/993a7ac9f3592f5628a12f6bc2495f5cce5814b883170e06f487dede6969/multidict-7.1.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:159976f9c40f96e3fe0952b708846a43a76bacb114e9cc828816f5080bddd5ec", size = 476343 }, + { url = "https://files.pythonhosted.org/packages/b0/da/c2147c1e225a676ee0c97f97bb935fb8a70821bc12df240491bf9f140801/multidict-7.1.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:81a0e08c64dfdad27dab687b96f572b23bafa1999a39d1b6f70b3ddbb73e8bd0", size = 426813 }, + { url = "https://files.pythonhosted.org/packages/5f/fa/8858b260a6662035670759e7b3a7da9decc227296491749073e76202de93/multidict-7.1.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:4e11e7299079718c78f8147e7206c22fe35bab4466d38992420795288a0b8096", size = 483882 }, + { url = "https://files.pythonhosted.org/packages/c2/43/d42dc515d56d506f437e8a19c4f604719a306f8d2b60f70a14be34ac9231/multidict-7.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:34d2ee98e15d5cfe782a431bc913fce3b58cf3fdb34fcb437aeb275cdf9007ab", size = 469953 }, + { url = "https://files.pythonhosted.org/packages/5e/bd/1b6fb52f7e082b4d49b069d362169a2e6dbc43800f152f33c8f0e8933117/multidict-7.1.0-cp315-cp315-win32.whl", hash = "sha256:f376224572d1f5da1c871f969ab04765727f180e70d012d93e07bfc08442c64b", size = 80202 }, + { url = "https://files.pythonhosted.org/packages/f2/26/5dec513dec950825529037703da89a597dc0b157e7b1b8d0304dd9e672cc/multidict-7.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:67fcf28db77b385820881521db7435e9f1c607cfaf07db6eb78aa9d1146bde86", size = 92921 }, + { url = "https://files.pythonhosted.org/packages/dd/eb/fd62025f8cd3dad6f936df8bd7ff2642e2fe3fe0166ef2fc75699ee57cac/multidict-7.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:c7aafa4dd2f702ee2198005d6cba4309c1e25ed1c201d77beddefa47411bead8", size = 92848 }, + { url = "https://files.pythonhosted.org/packages/42/f4/2dcd45731d2f57b87211a1dab1c29a72c6a4a527f370851c3e4cef5c4445/multidict-7.1.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:1348ddc076251cd542f4a99ccda4b7c1f8444e8ab489d3541a978ca5901c7c1f", size = 177634 }, + { url = "https://files.pythonhosted.org/packages/6d/d6/d57b12a5bb19396a99ae66ec48ff69f764cc2c83e83bd9a003bd5ed5802a/multidict-7.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:9267bf8261a779abb2a6eab5f107f5db85b2d1745f2494081c731aaf28738ce3", size = 101954 }, + { url = "https://files.pythonhosted.org/packages/46/8b/374e3f6e4581b8f8425712214cad854ff9370019483aaa49e6207c160adf/multidict-7.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:8a844b8b1685f38a2e8b2f3213b286e2a7abfe67508381780a0d4599ac337c1c", size = 105056 }, + { url = "https://files.pythonhosted.org/packages/9e/0a/6c89e2179a84eaa78eec658b9a05f10d3e22654708d7b2e3ebdf0faa0614/multidict-7.1.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:18a447d46a3a2f1e61b365cbf5627db7030fdb707dad70c4f2760e5144166ecc", size = 467374 }, + { url = "https://files.pythonhosted.org/packages/c5/62/431ed831b5e9b94c75ed0a410786d07176e879603601570eb25594039200/multidict-7.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:88811f890db240a1c82bf0bcd52973763707a552c8113ac3fcebca183afb2fa8", size = 504024 }, + { url = "https://files.pythonhosted.org/packages/d4/dc/52e4204538a7824e407e531b742ff6f7a2ff614dd40382d29a62755699cb/multidict-7.1.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a60b720c329c0007feae692b7bf91cf17b3f9bd3727be96cc6f9a3336651041b", size = 460139 }, + { url = "https://files.pythonhosted.org/packages/22/4c/0228d61a8fee69a7ac4d8d0426aa1810b9c36b679a8c51844de701230073/multidict-7.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b117ed1cd1a23df0902461c38093408b95971833dcee629112acda25b603c8d0", size = 513162 }, + { url = "https://files.pythonhosted.org/packages/56/50/a0b28bf4f036897bbd44c8761fbb384c014c34f621687799d07345b62820/multidict-7.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bbcae7a54050b7ad7bc7bf425ba63dea7d2cd31a92246ba787a2ce69a9b98dbc", size = 520312 }, + { url = "https://files.pythonhosted.org/packages/4f/1d/ba96f77c24174eff9f6521cc7c83958f27598b924a9f7b6aca5d11c73f35/multidict-7.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4cba2b0b9235fe10e12301d6b4cfba0f353fa668d635f6e988b03623c2cd42ba", size = 502953 }, + { url = "https://files.pythonhosted.org/packages/59/f2/14c3ffb649cf45a629ec38ca757a493f0caf729e3f3580816dc7779e4caa/multidict-7.1.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:535173fbcc3933d84f9929d49d7a59a0faec259ee07d07c34c7d2a980b4e3683", size = 451497 }, + { url = "https://files.pythonhosted.org/packages/55/df/fa4f8f6ee2314bdd3e4bc830c25f1d8a737188198abeaf105c73454f0b95/multidict-7.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:ea027bdeca1d7e498237634ee4e3a852e2723eef39996dec0ff0f77dff8a2336", size = 486212 }, + { url = "https://files.pythonhosted.org/packages/32/5e/3227762b04a8ff1a90359ee2c04af9ed095fe3892e101705fa4233b51514/multidict-7.1.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:507151e1e3dee95e9e8159e329aed4f75aa5205ecd6505a4f6be546890eafbe1", size = 459864 }, + { url = "https://files.pythonhosted.org/packages/5e/04/58524c7e82176438a48704a687fb383cc943dd7075c67b5bea288043b504/multidict-7.1.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9c10791e9f5ef132effc8fdce2009482c1cfb26618c5fc1b7952a47dd5eb632e", size = 465861 }, + { url = "https://files.pythonhosted.org/packages/f8/d6/28e549b6ec0c829647de4c75cf32e4c7ea0d0e87b91dfb399292f630c365/multidict-7.1.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:f16ac8af2804855d3cae5fc3c5ab609c9fd0fc8ecacd92579c05ed3c173396fd", size = 498865 }, + { url = "https://files.pythonhosted.org/packages/eb/ac/3006640586b4d33442c53989b78fc290c8eae5fab5b62a31ac7a4c220e69/multidict-7.1.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:b78de22bae456a976f33df34d598dfd16edc9a03df8f4cc8b7c17bdba4c97b4a", size = 449885 }, + { url = "https://files.pythonhosted.org/packages/80/7e/1ece407cee9f9aff9ace9e44379ed937bccb790936ae448feb79a82c362e/multidict-7.1.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:db77888081431aaa69f3fd3480891746ddce6c2a571f6201869a24e2f06cf423", size = 500237 }, + { url = "https://files.pythonhosted.org/packages/9a/4c/c052b700ffcf689454848ee81ba87bedfdc10caff592c0d2a327c43939a5/multidict-7.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c39dfcaa0bf23443474c0cb58d8d8aea9529c1841d99654cb38e4dada7b1948a", size = 495143 }, + { url = "https://files.pythonhosted.org/packages/0a/ef/9ddad94c32fb0bdfc5ad0942d06ae6699bd30d261b0ff59b94810a133238/multidict-7.1.0-cp315-cp315t-win32.whl", hash = "sha256:16b21164797bde6f417066d02775975cc2e15ab8abf80efa55fe85e0b4894020", size = 86526 }, + { url = "https://files.pythonhosted.org/packages/b3/ce/2efa1f32a07adfe041d48c6d04c08b6f9db66b8512c765983172399b2855/multidict-7.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:55392202cb374dd1a1f89a8ce1586644870d9e936752059d053e576acc50bc89", size = 99671 }, + { url = "https://files.pythonhosted.org/packages/a9/b3/a44c301fe13716c5f2c08afd89a481018988cde4b337b36e28a2b0fcf3a3/multidict-7.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:f979a077d1c0a9a36dd4fab0d3a36b8de7b593bf935e13df85a380395b2c11ad", size = 98893 }, + { url = "https://files.pythonhosted.org/packages/d0/86/a3de309c5e28ee85b314d0e3ba0e0dea6fd361c313322a05e67be4656e1e/multidict-7.1.0-py3-none-any.whl", hash = "sha256:d9ef29cfd98e17085b4f91bba8fa1570bec6787d5c52ce653ed33a58785585d0", size = 28087 }, +] + +[[package]] +name = "multipart" +version = "2.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a5/d5/440ca1e07162d93b6645942e36e8721d2e54ff28d265abec73a6339a6c59/multipart-2.0.1.tar.gz", hash = "sha256:5dfea7ce4260e10a9e620369449e7d41c269a94e192db9ea4af1a0059cddd370", size = 42844 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/f1/11873ce60ad824ac44f796de9879879dcd62672a172cbafe6e26eb2ea848/multipart-2.0.1-py3-none-any.whl", hash = "sha256:ac54d6bd0353ee30d2703af75adacd8b2052d4adb634e9e74dc7c5b06e0bcd86", size = 16448 }, +] + +[[package]] +name = "mypy" +version = "2.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "ast-serialize" }, + { name = "librt", marker = "platform_python_implementation != 'PyPy'" }, + { name = "mypy-extensions" }, + { name = "pathspec" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/34/4e/64300736cf0a0373a27b94a91b664ee7382e36f77b0621bae6381da3e180/mypy-2.4.0.tar.gz", hash = "sha256:77bdaebd452f43fcfc4cc3ba94352a3ea537cd01e3f2d0879f48673d2ec00d6e", size = 4064425 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/26/67/e53965e67d23d48e78dd603aa7525a8d77e7818c23954cc217ccde5cd5c0/mypy-2.4.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5d20e6c7c35fcbf2a0ebdd0eaeacfbc243009dfd33ab7822d54e213912e6dbbd", size = 14315745 }, + { url = "https://files.pythonhosted.org/packages/b1/5d/a588ccd57625822d8484d7aff4e9c70f88d042a91c2ec84324a2bc14b3f3/mypy-2.4.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3011537be6cf1de4511c0255a324362a812b58184bbe61e15f59c8b31033bd74", size = 14465652 }, + { url = "https://files.pythonhosted.org/packages/05/af/b4978a5566cb829ca563dc08e3ad8d944f116078786ad54ba3dff2bcb64d/mypy-2.4.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a96b07a49b7b1d025ce59c1b3acbcf24bead9a83da4523c4a6bde1bb94e7a0e1", size = 15419180 }, + { url = "https://files.pythonhosted.org/packages/b2/8c/52c71f31c54d0a749d832cc73b933c6e38c52d9b270d5503e031c73723dc/mypy-2.4.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:7c4f8f8d1d1c0e2832d8ee7113dd08f6df6c7aad9e863fcbed9f25832be0b8c4", size = 15669212 }, + { url = "https://files.pythonhosted.org/packages/e8/55/ba63121494404fd7d3787ec176ccd02820d6ec125ac4e7c4c0b79f2cc6c7/mypy-2.4.0-cp311-cp311-win_amd64.whl", hash = "sha256:ba05652540bf12828e52abae807b024b09ca144ff4f75e2450a81d69c376425b", size = 11299572 }, + { url = "https://files.pythonhosted.org/packages/9c/9c/385c81b5c0d3a2917f077c4da660d0493f9521ccfd4b058e33b860f72d02/mypy-2.4.0-cp311-cp311-win_arm64.whl", hash = "sha256:6306086b87cf7f8a29aa618d9fd9bffb56c59247166b9660fdb54d86d7714ecd", size = 10869751 }, + { url = "https://files.pythonhosted.org/packages/68/ed/e5d7cf4017e74a1c1e1c4058ce8f614fc1e3e7606564f47166e22bfc9f95/mypy-2.4.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e05ff2925d8b37ad26c80c1b9dc43ae5d455da2df1e23c24c095a6425917c57e", size = 14378593 }, + { url = "https://files.pythonhosted.org/packages/30/7d/12d994886a922f0f1997becc9c6625198d61eeb5962558a886af7dd38d54/mypy-2.4.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:29243242cf72582b65f9582ad9e56e8cb281566ed3519f4cd70bb8b9f2977e90", size = 14464697 }, + { url = "https://files.pythonhosted.org/packages/f3/9e/bcc9af755425ad17790bf11d73c2ea7592914cf309ee1340997670f9d57a/mypy-2.4.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:29eb0b9427a6b11b992e452f6cceb8af724f4dceb47e779d0b35e405e996ea5e", size = 15552257 }, + { url = "https://files.pythonhosted.org/packages/af/0c/3343fc4525d6f00d75ad17a93a9f052d5163641cb8911840d4a12cb59ff2/mypy-2.4.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e3ebe2f72a2a1156065a9851570ffbf50c0a93cdccadef9c6e05c508a4fd10b1", size = 15800443 }, + { url = "https://files.pythonhosted.org/packages/8f/0e/69aac6b8159da7c53e6115a2be1bf48e85503402cadbf506dd708e8b2ad7/mypy-2.4.0-cp312-cp312-win_amd64.whl", hash = "sha256:236e0d68f6941992b0811128e652590f590db444ab29ad8f1324765b9298b946", size = 11381574 }, + { url = "https://files.pythonhosted.org/packages/6e/d3/d32ce4feb5993eec09d2024bef16cedc9b93701b1446b86092f08b24491b/mypy-2.4.0-cp312-cp312-win_arm64.whl", hash = "sha256:82d0f94c8587ccb472622ee7795280aaa38a06640d5f45b3f16909d6dd86a989", size = 10944729 }, + { url = "https://files.pythonhosted.org/packages/44/f2/eb15183c97c69d7cbfac990a6efd33a19ecfd97dab9e714c742fa78a784f/mypy-2.4.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7da85fbcff6dac1abcc636707bed38b45598131fb7a605d9719c70b5cc733af8", size = 14213395 }, + { url = "https://files.pythonhosted.org/packages/8c/b5/ba91b6ff65e4d6b6ff53b2b3b3ac5f1babf0c7c27d0b43a0196b1c967926/mypy-2.4.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4209da39d85cf240f762af622d8180fcdfcb4727d021f44ade62d613a1a43324", size = 14373009 }, + { url = "https://files.pythonhosted.org/packages/d5/c4/484275efc935c0003e55e4e8a33e4b8e99528ee956c12256ece4708f903f/mypy-2.4.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be721bd4bd57576193653b75b4af3461c9d0bf7dd8b528f782e9be210dc75bb", size = 15465557 }, + { url = "https://files.pythonhosted.org/packages/50/30/66eb6fdd0875e3c9025a02f0bb0ea2e524b74274b658c37fde0068c4939d/mypy-2.4.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e1fde197ae65be856a034a91b70ed747a16562ca69577785f06c661548424bf1", size = 15728673 }, + { url = "https://files.pythonhosted.org/packages/58/bc/2aa98fd7f49c42dba8e9c065886fadffdd00f3c654cff3f2a7103a797ac8/mypy-2.4.0-cp313-cp313-win_amd64.whl", hash = "sha256:295ecf2e57542cd836ca537486951289678c8c7d1ee6ad74ebe29b2168a003cf", size = 11389803 }, + { url = "https://files.pythonhosted.org/packages/49/41/17b60df2d946792ef6af43b89351f5c9ddabc69053206b2304945572a744/mypy-2.4.0-cp313-cp313-win_arm64.whl", hash = "sha256:bc378bdad4e9f12b5bd96466083d1e71acf00594ec9c7b2bdb5e02816f77f303", size = 10951060 }, + { url = "https://files.pythonhosted.org/packages/e0/66/924be0b653372ed31ad5c48e26044cc00840e591943a56e61621cf05b60e/mypy-2.4.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:058165f564ccf559c68c70fec2091fca5891110480210c22594635e3f6683437", size = 14234230 }, + { url = "https://files.pythonhosted.org/packages/56/39/c4f176880a4177123576de6cec6309feea8f42fca2bf2f6584e88054f656/mypy-2.4.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fa247e02b505a45a2775f69df38d360d197e3790bc60f717595db9eda358b6e", size = 14392944 }, + { url = "https://files.pythonhosted.org/packages/bc/1c/26e16977e25ef2494a74f8ffc872a76ec2c3aa43c3156057cbdf88f352c5/mypy-2.4.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:20e9a5cd875837520c43db98dea0b6d0c2197833d95c30127d8f570fb9b1f00b", size = 15441463 }, + { url = "https://files.pythonhosted.org/packages/31/9c/9e4b049f0ecefbfd6817ca2a16eea55dd74a07950268edc6cffd29ccfe98/mypy-2.4.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9f03a7828cca2b0adcd6662aee8f2711ff8830e1027641fdea3ab0b787483566", size = 15705406 }, + { url = "https://files.pythonhosted.org/packages/4a/5e/e861b5f6c5ef9ee6cd24683aed1edecf82a26dbd536049f9b7850b586267/mypy-2.4.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:9279488933040b638c0ab739084c0ca100efeea6db581bf5d7628d8e89de53fe", size = 7897523 }, + { url = "https://files.pythonhosted.org/packages/3a/87/61ffee58b25a956532a6006fae84918a2de849ed25f397492b2e815fe7b3/mypy-2.4.0-cp314-cp314-win_amd64.whl", hash = "sha256:2106b55105ba5ea9be4f53a24517fc5fa927ff1585edc9bc1a975abb72caef89", size = 11576345 }, + { url = "https://files.pythonhosted.org/packages/a1/88/a331c20698971c2ce8d1c30f317fd61b5be13a85b1f053e12dfa22ac6568/mypy-2.4.0-cp314-cp314-win_arm64.whl", hash = "sha256:528c8744b8b5e3ecb8774f86af38d2376216816e9908317ad055f3c9c2d74799", size = 11172062 }, + { url = "https://files.pythonhosted.org/packages/47/c0/4f7daa73270dced8e86c6f4a911c68082d03a84e6c1ec9bd916028d66131/mypy-2.4.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0bb95cf34899e4619c61ab0a8667804e139e580b30d5df12af2102dfe44d0c97", size = 15711208 }, + { url = "https://files.pythonhosted.org/packages/2f/05/f1afa303c678be24cf7a266d38fb24b3de4599a024c2f9ba0d5905a3efa3/mypy-2.4.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:86d616fe84c6eab8026f8c50ab5bcb90db780d2ccd233d971e34e92bede9b359", size = 15935442 }, + { url = "https://files.pythonhosted.org/packages/9d/d6/6a1a45459b63716e0d035f4892a926d3054f0a8cbfa02551d228a9946083/mypy-2.4.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a3f86fd1313dd69d013e265f1fdcd12ea7a9d606f9875b2a3db946cd334555f3", size = 17240044 }, + { url = "https://files.pythonhosted.org/packages/87/85/ae33bee66c13f98d421964d87bf0888be941063bc75c1204a4cf142cf1cc/mypy-2.4.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:720434d48542ecfe84d32d287b727569d3fc8f5769acd39051130e490a5c295c", size = 17545856 }, + { url = "https://files.pythonhosted.org/packages/df/de/eeff209b65c3e267818d79333bb9f058da6e4e73761461bd94748a2eb628/mypy-2.4.0-cp314-cp314t-win_amd64.whl", hash = "sha256:a6e851b82c0661f69f1630fc16172c68787a6a9cf0991e7c6437d60976cdcd76", size = 12369342 }, + { url = "https://files.pythonhosted.org/packages/2b/43/e62d8d5c1dd737aa248968302ff7d6eabf997c3301773ed8bcb64932ca77/mypy-2.4.0-cp314-cp314t-win_arm64.whl", hash = "sha256:3bd0e340f0ebe65c548210f53be3fd8192e83964760caf0c28bef368e68b0d37", size = 11849099 }, + { url = "https://files.pythonhosted.org/packages/2e/5f/335b8980055118dc131355155883fb676bb2161a0d97e08658e479c776fb/mypy-2.4.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:afa89837d9be67e0cadfa33bca3bb7efdda98c3b07e74dc3b635ebfb1c8a926a", size = 14241302 }, + { url = "https://files.pythonhosted.org/packages/18/37/1482fdc49332b145828912b15f16eee0c4ca70a8bce0f6514ece79b14680/mypy-2.4.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb443e81057896132d3642d6be219e6efd158691ac7883e3ba8fcb469865f05d", size = 14408200 }, + { url = "https://files.pythonhosted.org/packages/04/09/dce2e8f6c1b31053c430ef6963f6f7a38ccb49b90c5e01e37ed0129b7d5a/mypy-2.4.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7f38f57d344f8b6accb40e01c3d83cfc590498231724d16c07ffb7940f157818", size = 15444409 }, + { url = "https://files.pythonhosted.org/packages/43/c5/91b68306da4cd280cb15be35dbb5ffc343cabd67c4b121b6625bf2d13177/mypy-2.4.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e76172710bd4e5eeae061abfd68347e5264632e02778be61784671ae3a2132f5", size = 15731186 }, + { url = "https://files.pythonhosted.org/packages/64/71/2d0340182a8f27352fb1e25531355951108b506097c433937e9eec452ffd/mypy-2.4.0-cp315-cp315-pyemscripten_2026_5_wasm32.whl", hash = "sha256:f83353e47ab520bf6fd4df8f5897d9fe081211f2fbc4b7d37736a3e3c166cbcf", size = 7897838 }, + { url = "https://files.pythonhosted.org/packages/3e/08/32703c117e134c02efa2a82705bb40cee91eaedea10b6530e20eba651b1f/mypy-2.4.0-cp315-cp315-win_amd64.whl", hash = "sha256:970b221ed5842213d98e3c480c08f795ace4b1f81fb21e1b126bd0476bce1c34", size = 11575700 }, + { url = "https://files.pythonhosted.org/packages/ab/08/08bb269feafdaad031046ee2d771528a64467e6a180f962afc28c4a3ccd8/mypy-2.4.0-cp315-cp315-win_arm64.whl", hash = "sha256:502b94b0b331f7dafe32fd6b151797ddbb4f32385b362e722c783a025e5954a3", size = 11172908 }, + { url = "https://files.pythonhosted.org/packages/fb/3f/c5c92626006ca92c7686adfbece47fc6a0daa0e54753952a9ad13ce0561c/mypy-2.4.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:c9de622fd397495695d0598ddc789222bfcfec9d7c9ec3a1e385c855e3bc5e01", size = 15705970 }, + { url = "https://files.pythonhosted.org/packages/10/f3/863365f7997a76a5a1dd42d7902ab05afa4124e8419089cbca1ce2554db1/mypy-2.4.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9f459f0b4f0596d9d51fe7716b404b35287b99e77da98a7af90a65dd5fd61141", size = 15942234 }, + { url = "https://files.pythonhosted.org/packages/f6/30/2f45b1f425a2c95dbe1a3f4d076bfd42b76e9615dba5906230703b14e4eb/mypy-2.4.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f9b028548b3af480e2b1ed8df14ccaac86f99c9f600d1580770ab7ba3dcd40f0", size = 17218364 }, + { url = "https://files.pythonhosted.org/packages/43/8b/5b2bbfc69e84800b78fa2dba16d995b003f93b573558e412c5490d6e6c37/mypy-2.4.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:cb734b2668c1f40d07ce093bbeb4407e9527c67901627b0e1679825be3f09975", size = 17557934 }, + { url = "https://files.pythonhosted.org/packages/df/c0/1a5dc601c22041a7bbfe1ff71cf097fbbd4fb49930867c6789baf5102106/mypy-2.4.0-cp315-cp315t-win_amd64.whl", hash = "sha256:172e30b8fea631fe310f0c665477f52d9ea40bb4e99e0c81dc30118563b13710", size = 12350955 }, + { url = "https://files.pythonhosted.org/packages/1c/bc/697e9e26fc2a86c094ad67ee1e419971b7f01ded66ee66231b09e9f3e12e/mypy-2.4.0-cp315-cp315t-win_arm64.whl", hash = "sha256:5786ef987b3767e51aaa53f20aec104c0252b42ecda7aef8e8b4cbae279b05c5", size = 11842738 }, + { url = "https://files.pythonhosted.org/packages/81/12/46ae8670c98a3cd0286ca5645c2f918f8f6be65edfed81b916010619f668/mypy-2.4.0-py3-none-any.whl", hash = "sha256:d01c5d26a352acc6d5cf3128225477e1e8465e8d3029d4c345807fbf7f3cf093", size = 2800734 }, +] + +[[package]] +name = "mypy-extensions" +version = "1.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, +] + +[[package]] +name = "opentelemetry-api" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/2e/02/6e0ae9cc61bd3169d401077b507b3ebc344745171e1051ab430be012dcd9/opentelemetry_api-1.45.1.tar.gz", hash = "sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75", size = 72804 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/1e/41/f7dcf80b81ee8e71c1a2b59f14208bc723edbd89ed027a73b175abf6348e/opentelemetry_api-1.45.1-py3-none-any.whl", hash = "sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb", size = 60256 }, +] + +[[package]] +name = "opentelemetry-sdk" +version = "1.45.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "opentelemetry-semantic-conventions" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a1/79/7392e21a1c8f0c61d90b223e31c7e48cb9d452e91a6b820ad24cca5f23c4/opentelemetry_sdk-1.45.1.tar.gz", hash = "sha256:63d24a6ca645019a631e6a51999c73e93adcac1196ca640b8ae78a7cc4762bf3", size = 218324 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/3c/87c42b4bd6dd297536f04cd9383d212ac557ecd49f2cbdcd46da1c9ef5c8/opentelemetry_sdk-1.45.1-py3-none-any.whl", hash = "sha256:c604c11dc429810812348989115fa44bd558772a3d7442afc43d024f2c250ca4", size = 140063 }, +] + +[[package]] +name = "opentelemetry-semantic-conventions" +version = "0.66b1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "opentelemetry-api" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/46/e4/dbbfb2a010c4db2224a5114638acede6fe563d33cc20fb1752cebcbe6298/opentelemetry_semantic_conventions-0.66b1.tar.gz", hash = "sha256:497ca63bf383723411e8eaf60c8779e9877633c936bb641080adab59d0eb6ec8", size = 150250 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/14/67f8aa798857f8cf686f515bf93d9bb877ce952ddc8efae0fa25b45ce0d6/opentelemetry_semantic_conventions-0.66b1-py3-none-any.whl", hash = "sha256:d4cddeb4315490b35213f55e2bdc9ac54bb1e4d318927475bed62b35545e581b", size = 206279 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 }, +] + +[[package]] +name = "paginate" +version = "0.5.7" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, +] + +[[package]] +name = "pathspec" +version = "1.1.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, +] + +[[package]] +name = "platformdirs" +version = "4.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/80/a8/66d45abadff219e36e2a824181b8f6a67e7ed4572934d6252c71c29d5731/platformdirs-4.13.0.tar.gz", hash = "sha256:1aa0b0d3f224c1f07c295121e312a5a24a180d6ae5a8425ea1784b3e3863e9c0", size = 61094 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl", hash = "sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1", size = 32724 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "polyfactory" +version = "3.3.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "faker" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/85/68/7717bd9e63ed254617a7d3dc9260904fb736d6ea203e58ffddcb186c64e4/polyfactory-3.3.0.tar.gz", hash = "sha256:237258b6ff43edf362ffd1f68086bb796466f786adfa002b0ac256dbf2246e9a", size = 348668 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dd/34/b6f19941adcdaf415b5e8a8d577499f5b6a76b59cbae37f9b125a9ffe9f2/polyfactory-3.3.0-py3-none-any.whl", hash = "sha256:686abcaa761930d3df87b91e95b26b8d8cb9fdbbbe0b03d5f918acff5c72606e", size = 62707 }, +] + +[[package]] +name = "priority" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/3c/eb7c35f4dcede96fca1842dac5f4f5d15511aa4b52f3a961219e68ae9204/priority-2.0.0.tar.gz", hash = "sha256:c965d54f1b8d0d0b19479db3924c7c36cf672dbf2aec92d43fbdaf4492ba18c0", size = 24792 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/5f/82c8074f7e84978129347c2c6ec8b6c59f3584ff1a20bc3c940a3e061790/priority-2.0.0-py3-none-any.whl", hash = "sha256:6f8eefce5f3ad59baf2c080a664037bb4725cd0a790d53d59ab4059288faf6aa", size = 8946 }, +] + +[[package]] +name = "pydantic" +version = "2.14.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7c/0b/8e10b2e693af8ec54346a14caf36221334775975a747c9ceaa3f8371d96d/pydantic-2.14.1.tar.gz", hash = "sha256:94f478203dd03404682a1ada216965651dd74b1d2d5ffd62e00e0837caab5c26", size = 900362 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/35/ea/a56b9fe5066f3537b7882f77e9c5dfb26d8c2eefdaed9b5fc73d57b4dc22/pydantic-2.14.1-py3-none-any.whl", hash = "sha256:9195d967ec791692a04438115466764fb8b9a27b31f14a760437694f40d6b454", size = 488106 }, +] + +[[package]] +name = "pydantic-core" +version = "2.50.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a6/24/af4ec4be49fbc810f35b0bdcacc3d433b56bbfa469fd3bfd4ae116cd9bf1/pydantic_core-2.50.1.tar.gz", hash = "sha256:e50d7b94baac6c7d09927fa5ca5800a0c7ee5015c7fcff65beb3a1931b5a6e09", size = 513515 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/45/1b/ca81811da3d2aea724fb3c4ba6b83f50ab8f210576306ccd601973142d35/pydantic_core-2.50.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:c531166c42ea7bdfecc8c50049581f05dd1993b09cc7c52bb36a14e96deaec7d", size = 2152145 }, + { url = "https://files.pythonhosted.org/packages/59/20/938ef7e0541af0a314e2f9d63848dbd1656ba2ebfe5ca0469f7ebee00d5e/pydantic_core-2.50.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:b6d0c2183008c188e19f4906d426b293bdc4f67ab17df8e180fe16cda208fa71", size = 2045761 }, + { url = "https://files.pythonhosted.org/packages/2f/4b/456497fe2affa18f49b226e89bde31e8a204751ee343dd9beaac5438123e/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:94be440c03fede26969a5ce75468e0e6a9927a1b46d9b679ee8adc1b057b0350", size = 2049557 }, + { url = "https://files.pythonhosted.org/packages/a5/9d/cd5dfe13928f55e236dff3a1bee06721d318ca6c955d9ca993b0a5d09f39/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:36c426eac0af8d1529ff8467e612b933346caec1fdc0d774f78f67a1a11e16c1", size = 2188153 }, + { url = "https://files.pythonhosted.org/packages/fb/8d/d0b25bc484f8d9136ea76ece69e0b2d24ab370cc2e7bbd1b79e61723b763/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028e2f212273d4a39b1ec1e0de8166b1165a65fc0f1111452a9d94fc7c625c63", size = 2388308 }, + { url = "https://files.pythonhosted.org/packages/c5/64/6513cac7c9f78492abafa01d57a69de1d246bf3085fd7b0330bf14b025b3/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e6f0cc1bb9900dc558960894adeb30b0c083366fc1d69b856209fb2ca5c36fe5", size = 2431187 }, + { url = "https://files.pythonhosted.org/packages/ed/60/7f2ae89c37e69a3dc0da9ff57b0388c285542c688d05f36767770429de85/pydantic_core-2.50.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8812592c85d0edf423f10eadcef42716d71e8219085ad9e85b775057b7306133", size = 2157623 }, + { url = "https://files.pythonhosted.org/packages/87/02/e3a71373b85a7973099b9a229a4302c7792262717e0b4997e41c01f7dd16/pydantic_core-2.50.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:bbce99252ba3167b2b6277f1829d5bf4b43b754524bddf7f944707c3db7d2253", size = 2254539 }, + { url = "https://files.pythonhosted.org/packages/3f/d5/5b620aaf40dc631d911565b64d49d3fa8422027c046a7a96dc4e4921ae98/pydantic_core-2.50.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:476f6ed8e43cd1e0b460920e23571700872b284e77331cb30c4faf459cf48a4b", size = 2267344 }, + { url = "https://files.pythonhosted.org/packages/e0/48/f46baf4580d8a56ca4782aa65842e16d3cf39736b29fcce8e9c0521b3422/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:2cbd1b75b09e976ed0d6b6ca297675632ca35df86130088457cdc60ef36970ae", size = 2300877 }, + { url = "https://files.pythonhosted.org/packages/2b/9d/e7d28505e6092c1fe075838a4f793ccbe6dd676e44ab16175815ee06cfe0/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:5958c72adb417c39b12ac87525ac60b0d73315fcdc59e21f44ee4a5e2512c9ef", size = 2464270 }, + { url = "https://files.pythonhosted.org/packages/22/b1/3bf63b82efd93ebc9968f9c12fc46037a28bcb452c88e4688f33e5ff7dca/pydantic_core-2.50.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d8f9e8a6c4ab04b78d61f78627370d834eb004b2869dcb28cfffa647b4ea1980", size = 2478653 }, + { url = "https://files.pythonhosted.org/packages/28/17/26d1e4b9c2ba6a92b7dc75bbcb99ef44456b7e3af1bced868403667ea7d3/pydantic_core-2.50.1-cp311-cp311-win32.whl", hash = "sha256:4be846f55c9477f5f3ddde8f2ce941137e16862a56d018ed885d422bb6ae02f2", size = 2120246 }, + { url = "https://files.pythonhosted.org/packages/24/65/3e875e7991eaecd4d70686293ff0d8a99aa8f93dc295e6a21c3d702912fb/pydantic_core-2.50.1-cp311-cp311-win_amd64.whl", hash = "sha256:0048b6dddc8ef4b64fccaad878bd143b0c3882ea9936279dc11d613f6b7dd1bc", size = 2176332 }, + { url = "https://files.pythonhosted.org/packages/ab/5c/58dbd17c61f209d91c70f549eb7a3bbec7023e89ea12dead091462d6d658/pydantic_core-2.50.1-cp311-cp311-win_arm64.whl", hash = "sha256:6a733778df2f7087ec1100ed0b41533e4f3001976e99570fa34f57c66e7f8e3e", size = 2182471 }, + { url = "https://files.pythonhosted.org/packages/ee/94/101b49a6c63f99586755690696eae14532772587d9e39e93f7b8d28a49da/pydantic_core-2.50.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:704075d10b74f2f3c6e15407c696d88701df35fc8953f434a431add0d0074db0", size = 2222428 }, + { url = "https://files.pythonhosted.org/packages/db/a3/f8b1b39349c33037c73d7a1b794c9c29d8f07d5bd2b74399c1375a529cc6/pydantic_core-2.50.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e8e1d6ce820aa23317e8209a86bd65a540973c12dc7552b48a4f6c8e9926815e", size = 2025779 }, + { url = "https://files.pythonhosted.org/packages/d2/2d/862a7d115305cc621358e51ba13f296e0ffdf4fbd984be9f63bc7862730b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c18db21573bd2c6489f9a544b7499f0df2853958c568e5e783536ee1f690af41", size = 2048429 }, + { url = "https://files.pythonhosted.org/packages/c0/7d/fed95d18bf2b5abe2d5ab2bbec4184bfc22b6805994a33ccdae8edffd25a/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb57f304525a5e3c13333b772bf9a473f36326e9c821b2e8e1b2fd36f80ae2c3", size = 2173274 }, + { url = "https://files.pythonhosted.org/packages/93/1e/c6f22ceac65dc3d291ff79bea0eb74cbd1de8b128f01fee48af03bcea1e1/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a27c09d86600f1bf2fe3f37e1ae697faf3143931c09322cd799da94deee923b5", size = 2360403 }, + { url = "https://files.pythonhosted.org/packages/2c/97/190b5b6bfe4c72f265feb657913eb30749193927a5ad7b83cfa7866a25a2/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:46b3301d3b5c886f77de7546e47274a5842c622ea2020b8c6524c6b66913b4a6", size = 2439799 }, + { url = "https://files.pythonhosted.org/packages/90/24/48cd98388e4ac2c08d0800af36d15413db15e20387f1038123a2c86cb2f6/pydantic_core-2.50.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:93ba4e9d8210d941c200431a56b2c0400b131865947903937ed3ec5404307d2e", size = 2160157 }, + { url = "https://files.pythonhosted.org/packages/00/86/d909133d0b9979ed14c20f110f21124a0e346caf071bd66ac5af3009062b/pydantic_core-2.50.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:e5faeaee74a57d32b3ab3aebad2e348f06d3ba946fc5d28c1728455f00a3d13a", size = 2229523 }, + { url = "https://files.pythonhosted.org/packages/90/31/4b43f08131b2098e73a852fd932063385f3103b33839fd9d1abdb89e673d/pydantic_core-2.50.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a3cda0e538208e5d722bbf3698b24f19c0a7d05bc8d5f8a7f9b121ea7fa243d9", size = 2242700 }, + { url = "https://files.pythonhosted.org/packages/88/77/de39658569dc7a4c293d110a89629f61145e7538f00d1e4280c28debefd3/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:57f51b31ff826e2859120cf4737c5a758a48d96f3e97da40ccee1796d58078ff", size = 2278452 }, + { url = "https://files.pythonhosted.org/packages/46/bd/1eeb9e4e791e81210ef9096f58080b32ba15b5b13c108d50aaa3f8ce9f73/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8daa7ee75245d43ad7d747e5c9ecc1b1d06552f72b14887e9276f787d57375f4", size = 2450098 }, + { url = "https://files.pythonhosted.org/packages/1c/e6/b2accfdff17e5a88d6358536702146de00f9339adaa4d5bbe1ee93cb4832/pydantic_core-2.50.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:acbf31f37c53a5ac0c34706c80b4f5107ba20b05fdd3816124bf236ef0c57dd2", size = 2476839 }, + { url = "https://files.pythonhosted.org/packages/3b/7e/c8a83eb5d2cd42c2e13507150fb777b1edea5a018adc9cbf2858a1d08285/pydantic_core-2.50.1-cp312-cp312-win32.whl", hash = "sha256:45b11cac094aa25725581d9304eee93c9028516b9ea80dd9e175e13a5a2c840e", size = 2101425 }, + { url = "https://files.pythonhosted.org/packages/c5/17/8bbd530b8659e9d963f5b16f6cb8e159f4cd174b4d6304e48969ab12dba8/pydantic_core-2.50.1-cp312-cp312-win_amd64.whl", hash = "sha256:132529c83901437ff642f585216831bf5fd7a91df66829907e155192ead62498", size = 2172522 }, + { url = "https://files.pythonhosted.org/packages/af/9f/a79d690b127e4e3cff143d031538ab9aa715f6b2fbeecbd07cd6f2bf9fb7/pydantic_core-2.50.1-cp312-cp312-win_arm64.whl", hash = "sha256:4e834f6a8e4ff772dcc34f58ef5504147a3ea5b0f4eeb13b0f8eb2ca75ac57f1", size = 2161366 }, + { url = "https://files.pythonhosted.org/packages/ae/57/0e237d7091d2cd44a35d243b7344227f90440166860469cd036afc23a04d/pydantic_core-2.50.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d5e062c01286d861fd6a1c4ff6e063547b3e713067f2df033c0ff97ac2ca006b", size = 2222774 }, + { url = "https://files.pythonhosted.org/packages/f7/b9/c720e56858d4e1539503297ed37063e0c08e0f3541c41b777f6a800f75fa/pydantic_core-2.50.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0c003c3b7f49debb893d2d85ae099ac5959c9839e2f330fadb1fcdf7a6594482", size = 2024738 }, + { url = "https://files.pythonhosted.org/packages/4d/b8/fbfc25875219cc060e613170ff10e850c6d8924beb4910da57c2ee3ba1d2/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:409e0ea40ec30d9158f33574fd758e689f6045a0f2596701828c27816ca9687d", size = 2047049 }, + { url = "https://files.pythonhosted.org/packages/d7/43/34210124d504c553688f2f04b48500b131237528ac545b445e0d6d30e0d5/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:131059670f1d2444269b8585cb888963994871932447c08b39ac6a51fcfef658", size = 2173415 }, + { url = "https://files.pythonhosted.org/packages/65/cf/6e178e8fdc11da5965bef980983bf46326a42f436871dd51ba0a57f39df1/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6dbcbee53bf17196a7f745aa9bf5a9603953a1e365b1f020be3207c676a3e7c4", size = 2361719 }, + { url = "https://files.pythonhosted.org/packages/11/14/bd5169d356aa91bf777e28ba0b281c192d286d03c2812c9c9db023a2f00e/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:325c23f3e35cfbf0fe3486fa5f7260d1e45885173002d30a28ca019994124255", size = 2439803 }, + { url = "https://files.pythonhosted.org/packages/1c/bc/d79d000e5203ebef39af839f2ce77a777fcad6e08ad26af9a2fcd114ffc8/pydantic_core-2.50.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:17e722e156d0444ecaefbe640bdb60928752bf2013e2b7a11cdb099aaae19bec", size = 2158402 }, + { url = "https://files.pythonhosted.org/packages/1b/a5/4902cb5fd599422c130bd3124ab31ee8b771199bd56662702eed07d3fec7/pydantic_core-2.50.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:aa8224f10880d9bf1b5993988ba153d42a8b4f3f4f511f93b1f09c93ff613c72", size = 2229755 }, + { url = "https://files.pythonhosted.org/packages/1a/f5/c1481f8669f6060d89110c9b1374173fc8767ca276b84f4870bd8acd5e3f/pydantic_core-2.50.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:41bc8237121bd8dc8d888dfd6279fc166ffc88c1f1bf3a8bf00869680533ca4c", size = 2242303 }, + { url = "https://files.pythonhosted.org/packages/04/f9/77fc3c7653ba9b6e42049e17e96b25388187d4a7c274ab1cb07f58ac8419/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:45c6266d071c241f2a168d45bf8c54344f0effce35e7e6b73afdec11f3687568", size = 2278345 }, + { url = "https://files.pythonhosted.org/packages/95/9b/0579c5d12e7f2b16b27e6782427987341fcce07b0725e02ddb0b74add0c4/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:1deeacb112d14d3f4fcb16b165f7dbaf76c70ba6e82f37ba042bdab51970a0b8", size = 2449947 }, + { url = "https://files.pythonhosted.org/packages/a8/ac/1b677db91eba54cc5922f4de6edc46ba45c2bd712dfdc0130382d158e214/pydantic_core-2.50.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:1c96fd793b73d1b92e65570132505498fe7b21eaef73cdf74e67e5dfba7ac9e4", size = 2476889 }, + { url = "https://files.pythonhosted.org/packages/4d/2a/3a9f6624ee3ea9ccba5249dde11418bb4c35780a7a92608f8768bd3fea39/pydantic_core-2.50.1-cp313-cp313-win32.whl", hash = "sha256:06ead20d39ffd6f2f6f2a8f8a6de67ff8bb1b4f14a8a30e058502514ee2ac685", size = 2101118 }, + { url = "https://files.pythonhosted.org/packages/2d/1f/323f78ddd9d9938aac420c1abb4e8ba799fc8bdab0acc67c0593b837c979/pydantic_core-2.50.1-cp313-cp313-win_amd64.whl", hash = "sha256:7816e98acc08119dc0f340ab167048ecc54126316330c1f0caf7c6756c88e28f", size = 2170840 }, + { url = "https://files.pythonhosted.org/packages/cb/09/8497c52a739ae425c3ac2f7f56414cbc711c67d374346174f40fe2062644/pydantic_core-2.50.1-cp313-cp313-win_arm64.whl", hash = "sha256:c17799a62c142d61b8a3c51752a7cbc87fe2ad4ccfab10e628a77b405075c662", size = 2161633 }, + { url = "https://files.pythonhosted.org/packages/49/33/28b96e81677153715e3eafb9f26663a80841e859fde282a380359b0d3fa1/pydantic_core-2.50.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1cf41f1ae3fa155cf167a72689ad044bcc1e3c97e064123677149bdfb5dafc4a", size = 2224428 }, + { url = "https://files.pythonhosted.org/packages/94/40/15c06410c9b7b8da5805d27b64e09bd3f900e986728106db2689dbc51513/pydantic_core-2.50.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4df197990c15b5a37c5a277d131d9f2c67de6133f2e5dafd80d9bba4b99f46f9", size = 2023740 }, + { url = "https://files.pythonhosted.org/packages/a1/4e/5eb629f6efc2a27e421d789dd4bfacbb5f6d09c80c28b13d1e87d73163d5/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0036473f5583e6a60e50b8b21651511564277a3f05cc5dab8cf579f552cd5f6c", size = 2049435 }, + { url = "https://files.pythonhosted.org/packages/ba/8e/f195aebec49ad12318876ac2368c197a7939f5d52f8e156d2238ef8a4588/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:992c3514ec891fa7858099183e4d64e6bd5a5d4ff452fae29df22faa77a006bb", size = 2172975 }, + { url = "https://files.pythonhosted.org/packages/e0/f5/7ad9fb83010cd5ea0948409db105676ed779c4e709e2d3d89b9fe2558794/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:739dc730e6be3bd5ec2f4ab5cfc7eb047cc45fc1497b3bafec74ff2ed07df597", size = 2364197 }, + { url = "https://files.pythonhosted.org/packages/ca/fb/bf0aab3e78301d202b82a0322ec968cd703b11fc0623fde9a28708936f62/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:32fad3a91e51b6d2039c572db04a5a873260b399f6bd62c3552671fa7a4a2899", size = 2444259 }, + { url = "https://files.pythonhosted.org/packages/45/35/38f6d6564fae57d9b12e5676dfa947e0e7d5c46dbeaa7eb3352261d6d299/pydantic_core-2.50.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42b54c2c90ad348b5e3a85e03e715d572c1fde357ef104cdfe3b03b697a404ea", size = 2159857 }, + { url = "https://files.pythonhosted.org/packages/65/a0/fb0a3ca10f139dcf765b2d312d10cf0f65c57c59993229d00ad12b14ceff/pydantic_core-2.50.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:2df1ff41884de2bc4b307bafd7c40a691094fad2ff8e767e5b45a319257bcf4e", size = 2233954 }, + { url = "https://files.pythonhosted.org/packages/8c/6a/e63842252702aa4ec6e6b7178ca85e541a77459076592c23ebe2d9840b33/pydantic_core-2.50.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe90228920fd8ff2be62622b6bb8a2b11acd65046d50c6b130614b5879605a20", size = 2243876 }, + { url = "https://files.pythonhosted.org/packages/39/25/5991cf8318b37e0dfab47b87541619a1df8501a793cba0d978846cba37a7/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:844b869f118e22a41a091bdcedda8a71bc1b0f62c38d1a0c3211cece47e1d8fc", size = 2277908 }, + { url = "https://files.pythonhosted.org/packages/a7/3e/3ee8baaa6cc25a6961c69168cf9ff0f002d56f4e0d541ad6724c18fb61f3/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:2eb75304506894a281d346220a4f7481a1b8729577c5ed2a05395991966a8396", size = 2449285 }, + { url = "https://files.pythonhosted.org/packages/c0/c7/acbec6deac13fe697a80c275a9b6661db62c4d323bac8a47347b1f39c7cd/pydantic_core-2.50.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:6b20a4bffabdad0db2927ac034ae3b8a681b1f7a0182f3e60b479ad2fde21ebb", size = 2479013 }, + { url = "https://files.pythonhosted.org/packages/10/08/21a3f237b264389f6219d053ee78fc1b5c2fd402c1b1cb2b6cb8b85f9834/pydantic_core-2.50.1-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:99ba9bc2b8062ea0c326a990f7f00e6530c23579de66dd246e72c4cafef950a5", size = 1484400 }, + { url = "https://files.pythonhosted.org/packages/09/ce/077a6d262d12ef09108377ac0717f029f420d773ace63cafd6143af75568/pydantic_core-2.50.1-cp314-cp314-win32.whl", hash = "sha256:cf356f70551d40374eaffb1aa63f1eb6d2006681cbd7a9faea173ce0f4dd7cd2", size = 2103222 }, + { url = "https://files.pythonhosted.org/packages/14/4c/350a2415209c43d670eb71d3c040f332c04a30583d39e311b05c7ac15762/pydantic_core-2.50.1-cp314-cp314-win_amd64.whl", hash = "sha256:d32f3acc081cc3923386d88f422cde8892335e95f034e0104bb4cf9310d9915f", size = 2171586 }, + { url = "https://files.pythonhosted.org/packages/bf/92/9bea6ca96580a0902fed366f064f0829e404f41889b34543279a1162b888/pydantic_core-2.50.1-cp314-cp314-win_arm64.whl", hash = "sha256:bed5163e03b98bc1fa2eb05d74c63d9c5c95d8ed6254985481640fbf5e237dea", size = 2155773 }, + { url = "https://files.pythonhosted.org/packages/86/8c/f121f073cf32bdd5cba7e6230b1ce0ac845b0cf43e44dd597d95af272db2/pydantic_core-2.50.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:9572c1369e9c9da2d64a7b7992c786d90ff295abc93964cfe3125e4290768070", size = 2234017 }, + { url = "https://files.pythonhosted.org/packages/a4/69/2bb2bcd6146cffe98d760a46c42ae71efd5151d9b2f9c9bf6619a3b32083/pydantic_core-2.50.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2005207aafe1231315718bf6ed5d064a7300fb4772754af35ee72fc68159492e", size = 2001016 }, + { url = "https://files.pythonhosted.org/packages/20/b3/fbf854c7d07ec114260c26e9e2071a4381740f9ae09641dbfcbdf2a18c45/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:64f6047f62a6c5ae08d0a6afb035667aa2d97c3d20d69762e034c5ea144d92a5", size = 2023015 }, + { url = "https://files.pythonhosted.org/packages/65/20/6de55b2f92cdb614b745c6e9ced639fc4fd7e1e77604825a88bdece6fcbd/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1ef800dd7d85bcdadf4c3076e4c94e43939493558a3b69a1ea830c706d4617bb", size = 2192543 }, + { url = "https://files.pythonhosted.org/packages/0c/d9/19e91c94bd5c405945ce2f15526808aea37e162c253160de8ed7bf70b406/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b0135bcdcaa0f23573f286e4cb5e0fd2962700964ed13df085b85f2b97aeab9e", size = 2388704 }, + { url = "https://files.pythonhosted.org/packages/b6/bc/2e24c8415eae1a25ee5a5484946a917123bad2e9d01689a759236928175a/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:0b3a6f334c6a2345ca15318ff894502a90012536404b37c844a976c76c846e0b", size = 2464118 }, + { url = "https://files.pythonhosted.org/packages/bd/1f/945b8053cb061c64e102bcaf7bfb9ed740c0bd4349f9bb978a7d4ddff4ab/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:06e01fbbfdb9be777b316a71b6c49efaf4a08b615d0a98d678cda3023f79d019", size = 2150142 }, + { url = "https://files.pythonhosted.org/packages/2b/78/96a3e50bf0d64aaae781107eb9335b7529d05a85793ed6e7241c4cd1d931/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:a29a061fec0b4e2d714f277e70a3a18125ecff803f2fea6eade2f2e53711d112", size = 2245577 }, + { url = "https://files.pythonhosted.org/packages/7a/5d/a6038a0322232758a6ebfa709f4ca14a60bf5b93940cd0b345056a557da4/pydantic_core-2.50.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f5187624823423e1d1b82b1072ac41dc837389e18d3d0572cc19bbee46cd550a", size = 2263728 }, + { url = "https://files.pythonhosted.org/packages/0d/4b/76ded3333a457a9344c4f2d63f2d82d0101654b05178fa4ddfe7d3627674/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:3e46a9eb0a0901dd6275e6b06ac3a464885ef350ec4121fe486869de8053e4bb", size = 2293630 }, + { url = "https://files.pythonhosted.org/packages/a9/00/9eca378335c9c1b72bc779bf6e4c2a82ce784f14ec48a0e87102c9870e05/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:756d669f04e62ec4148ecfe22be6a4484d9b1181a6ef32e205ebfd200540858b", size = 2468048 }, + { url = "https://files.pythonhosted.org/packages/35/ca/e3832e9cf93651251de43c8c5c029ed680a3c1a0a1b17ee26c81bed08cbd/pydantic_core-2.50.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:c516cc5367ca3448995d42cb994bf3f4c9002d2a7c22eac9622551269ad1b807", size = 2512159 }, + { url = "https://files.pythonhosted.org/packages/4b/f2/773469b5a10a39116a2cb17edaf6d722f017dd8da07161b371465311c542/pydantic_core-2.50.1-cp314-cp314t-win32.whl", hash = "sha256:9d1bed94af6a63835461f3cf7502058eb166c58c4778e11d0f433cfb1bd69e19", size = 2098406 }, + { url = "https://files.pythonhosted.org/packages/ee/42/0bb74f8f25204b259b11ab7c12dc7f180893b6bd706118b385147fb6efd5/pydantic_core-2.50.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c8dce1f1e0e5358b682a6ad3fa5e31b31d4560997b8e61417e9217c8d60f8a0c", size = 2148481 }, + { url = "https://files.pythonhosted.org/packages/47/0d/d801646c9679a4e630e15cf521d4108b93854b42a6e6e02391bd4c6b1095/pydantic_core-2.50.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ceff0acc940be2715bd6ad17b24c0e5304abf44f6efd0f81ee8499e640f9dc86", size = 2174972 }, + { url = "https://files.pythonhosted.org/packages/b2/84/23984b763d8862a02a13d27a44b6e8169428fd85ecfed88f54c29108604d/pydantic_core-2.50.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:8a6791afa2245e6c6b180122d105941644f5bd410bb18623b408808cc41a3102", size = 2224599 }, + { url = "https://files.pythonhosted.org/packages/4d/90/a63cf8586abc1d1a3f6d9b18f0224789ebf003851f092ebda3c7c863fd32/pydantic_core-2.50.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:84f34323a61a365b4e9295de6028474754829aaddd59c7bf1a040e7487ef8f3c", size = 2024191 }, + { url = "https://files.pythonhosted.org/packages/a6/6a/f34bff9808ffb4907fbf5f5040457d253cacf2da7fce9efbc99ca1b1a44d/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:23edad659e8dbd8ca7e4e877fe6c81573abbdf215bd25a68b53e1272f58b80c7", size = 2048252 }, + { url = "https://files.pythonhosted.org/packages/b2/54/13f419bf1eb59852003818e25935aaf175687d978f4c4bca70e08fe40a3b/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a5fce22f1e87d181e924e12da7d81cfe031fb3881a5ddf26ad28f141756ca43", size = 2173376 }, + { url = "https://files.pythonhosted.org/packages/6d/6c/b5a34d24cd0c81669d8f8339d74e6815abcf2f8fb48ab4b49b84c09be1d5/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:c73622ef819328873b53109ee4f77ceb598bffedd02daf916102be3228866b78", size = 2364559 }, + { url = "https://files.pythonhosted.org/packages/eb/8d/d64d6216a8df365082665927ff923f183056f9049fee08e9777c9ac05296/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ce8c25ca38cc0e3d7753ba180808de2c0c8cb24eae0df64491e40921454e9831", size = 2444697 }, + { url = "https://files.pythonhosted.org/packages/b8/0d/1b1149f60a00ea21ba5f70e28acbd40feb4598af414f80f53c921fac07c9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7689580e72a642ab5ec64d5f55b2e33636fa43b4ebe63c0c2c965ef307c7d1aa", size = 2159961 }, + { url = "https://files.pythonhosted.org/packages/1e/35/f236549299dcc78e71e945495d7ec67e78d20844d0999c60601685003031/pydantic_core-2.50.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:d5c0e32fdbce7f1e8ef4d11f655694bf5f4175c757a9f1dc2be09b8864e5bcf5", size = 2234581 }, + { url = "https://files.pythonhosted.org/packages/6a/85/26901a490522b7f75ef9bb9a7afb73e5bb550f0e1cb5b99a0069183e2eb9/pydantic_core-2.50.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:40f523349960fa30f3ea51404308ff50f9997a90df639590f47a057c1f32b415", size = 2244090 }, + { url = "https://files.pythonhosted.org/packages/ca/2c/481bcfc70ceeb77a778ca6e5b705fe592cb64735c108157f81b7dd9c280e/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_aarch64.whl", hash = "sha256:d4193206b6587047437f6f11d7e776df23e1c1e23af2a54d9347275614791e10", size = 2277948 }, + { url = "https://files.pythonhosted.org/packages/24/eb/f1e09333faa7ba447cde967310f758430cc7c5e987bdab5228816c70030d/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_armv7l.whl", hash = "sha256:84bc765b282a9d5b7fe0348b8648904f25a6a04b2139da52b1dd30c8ac3a2c8f", size = 2449688 }, + { url = "https://files.pythonhosted.org/packages/d1/b3/036bde636db8f76d92996e81aefc75678ab5cec4a07eea1ad0c72a893fc3/pydantic_core-2.50.1-cp315-cp315-musllinux_1_1_x86_64.whl", hash = "sha256:ed1e728b39a383c81035b2459cfcb35d99dfb01f7d6ebe3a913bc1cc5b81e459", size = 2479286 }, + { url = "https://files.pythonhosted.org/packages/d0/a3/07f018294ee18d144afeb6df1a47d9e92960f014be45c5ed13519fa5af95/pydantic_core-2.50.1-cp315-cp315-win32.whl", hash = "sha256:bc94f474417604bd383d2cd445d071b07dd55fedceed3ce33407bf1fcc107290", size = 2103683 }, + { url = "https://files.pythonhosted.org/packages/5f/98/f9bd7e1f9b6709f155acb9ef826d9c3884fe925f811fd8e55b9b52280bad/pydantic_core-2.50.1-cp315-cp315-win_amd64.whl", hash = "sha256:983a662de2571cb2502fc8ff47b6770b03d025d2eb314c92f77b3f07c74720ed", size = 2171681 }, + { url = "https://files.pythonhosted.org/packages/10/87/4bb3e1e7f385c076ab5af4d6dd0571b22042cd8207eb810d5b9fef15ae31/pydantic_core-2.50.1-cp315-cp315-win_arm64.whl", hash = "sha256:94845ff54dc5193f228cab81b2662a04bfbb892e95bdc15edf7399000ce57d54", size = 2156251 }, + { url = "https://files.pythonhosted.org/packages/47/47/83643225b08f2aef6c8cc4bbe6e3f79c5e139c4364a6e450d0f399d87774/pydantic_core-2.50.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:4a53d13cdfbedbfa87f08b83c1a0a5efcc767d785a4b41934fa9cb672670493a", size = 2234363 }, + { url = "https://files.pythonhosted.org/packages/5e/66/127ca649ba2f2462039dc1e694c6c01e00a3689f023a617364d3507e6d97/pydantic_core-2.50.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:efbecf43d321f7b9281441f1f213f7c21c66988b0e06c2730ba13ed47a46bb08", size = 2000972 }, + { url = "https://files.pythonhosted.org/packages/5e/4f/e421e0a5d653b1203b090b2e48745976988d7338a647940704b5b9c2b399/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bc1f08f68dac9f9e83845a8039880aba2ab553eb9b2259c3243a313182c253fe", size = 2022925 }, + { url = "https://files.pythonhosted.org/packages/d5/4e/ea5568e2491e1a71100f15ae8c2d01ef52db42a184a2d4e716bc79e5eb8f/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5dfe41f232befddb9c4377f6cfc702b51595e2d78ed082672adf8758d2c4619f", size = 2192867 }, + { url = "https://files.pythonhosted.org/packages/ae/5e/3b8c3a35acbe219909ada5defad5d7d9fed845fb2b37bd5ec518f453c119/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:adc06d218a1cadfd2ec4628424d7d79ce4eba69c2965e7e7b55106f0da5208c8", size = 2388687 }, + { url = "https://files.pythonhosted.org/packages/b6/aa/7889b4e515f91a2e8c0ae6b5081fec0feb30c4398d1434e14793c60f173a/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2cf91809d0721ab81592ba67bea7694821679c10b1a2e3c3460082b286c1918a", size = 2464415 }, + { url = "https://files.pythonhosted.org/packages/08/78/93449e628eb8a6fdcce3eff9043081179d1bc7ce6f1bff32dc5006f41e00/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:23923ab9292c40da026330b1ecf4dc2618c8e86e0422e5d1fbf50d94d64ca4f8", size = 2150823 }, + { url = "https://files.pythonhosted.org/packages/f4/f1/72c5bc129fceb0d00f05dc1e67f518c1728de1928c55f81fc13d7690de39/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:f3377c8c2b3ce898423c5e5dd94c7982e30aa7717a7e6ab2470b9de364963709", size = 2245806 }, + { url = "https://files.pythonhosted.org/packages/28/2a/922a0e78f3aa6ab837b59f88190233fb8dad546c17995fde9bb3ed3b9b49/pydantic_core-2.50.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:455a773617b5913bf5c20d0692e5787b119e52c4d40ea644ca31f5758fd31be2", size = 2264160 }, + { url = "https://files.pythonhosted.org/packages/52/a8/0f1449e3e1b20941c9372faa18e7b6a092e30cdfa841902473f7989532ac/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_aarch64.whl", hash = "sha256:1a9006395dece0e32e704c315eff8a00bede494f6108546cfc5539c89fef4f9a", size = 2294031 }, + { url = "https://files.pythonhosted.org/packages/d1/d0/1031f492857de70355fb16524bbb03efce5fd34c93ed4a1ec60be07e0d4b/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_armv7l.whl", hash = "sha256:d2d82aa62521c55ddfb000ae70f88cdd8de974078f6024e821dfe5addd0c818f", size = 2468423 }, + { url = "https://files.pythonhosted.org/packages/46/52/269ffffa645b8e47906395a39cf9db1151ae9fa4bcd7f47b960bc31baf37/pydantic_core-2.50.1-cp315-cp315t-musllinux_1_1_x86_64.whl", hash = "sha256:009634b83993777ddcd69cad0ffcace43dabde692109528e35f0fde91e386a8b", size = 2512598 }, + { url = "https://files.pythonhosted.org/packages/31/5c/e47e28281f20326ff6f3c31d626f0a83e615d2d94ba41bb0ad6ec237184a/pydantic_core-2.50.1-cp315-cp315t-win32.whl", hash = "sha256:3fde4fdc6487a58d944ca87cf5adc95d5f266e872c19599f5f4c0a8a1b1f9f9f", size = 2098535 }, + { url = "https://files.pythonhosted.org/packages/03/ad/759e181e69c1b472c60b2049e5f61d5da1deefdd5ce1df4bb2ebcf771f25/pydantic_core-2.50.1-cp315-cp315t-win_amd64.whl", hash = "sha256:1c8632d4ac04e6f91128fca584b3a8a507d81604c24eeaaad00d4be42765c32b", size = 2148645 }, + { url = "https://files.pythonhosted.org/packages/6d/56/8a702c27e5be9f47e5f19d8669227424290e4c024e7c370279cbaf244b4e/pydantic_core-2.50.1-cp315-cp315t-win_arm64.whl", hash = "sha256:c3ede305158e75510be50869b319550ab072008c13d64d4ab1e094fb286b6f44", size = 2175297 }, + { url = "https://files.pythonhosted.org/packages/bd/35/002a378302ff91d2a7f149bc8d22363faf581ba40ac39d0123444315a32d/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:7456d699b13954e9c0164dcb267250a10ae0dfb03e6e26d6796ab0d46e189c84", size = 2154076 }, + { url = "https://files.pythonhosted.org/packages/4b/5c/7a034330082c00f3c9795052bd9b161aaaf6e1951e897ac00a094823ef7e/pydantic_core-2.50.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b0d955195bbbe489ad343fcc956eacea9357b79cb22192c66cacdefcbc14b32f", size = 2085850 }, + { url = "https://files.pythonhosted.org/packages/57/fa/d5b44a7d3d4eceed6ef8713a0003af56fefde6ec75915513bcdd5d730cd9/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2c634642694e6a0dad2ab1d375589fa671fd442edd5caf7d9737b8f6ca22906", size = 2259754 }, + { url = "https://files.pythonhosted.org/packages/b6/88/e01dd37301710bd8b3b2daf34ec9e2a6e83a333130831c0d468b0292a48c/pydantic_core-2.50.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ee6db2fbed51a7991302e8fac498cd67e336246026d0dfa84cf5166ce1412760", size = 2268712 }, + { url = "https://files.pythonhosted.org/packages/3e/43/5bb80d4a4d2b2d84612a30205662cc521a20d209c94abc079ea48b4e0df2/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:79490e33c4c0fcb933bbbcfc3a62184d8803b99f535863dfbb925e1bcb6945ad", size = 2305049 }, + { url = "https://files.pythonhosted.org/packages/e8/7b/e3ef76b269cfe4a5bd3bdcf5d7124cf05e5a8909d443b91639307116bdae/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:48569b0ade9edfbe065cad1d700175546592aebbb42f02adcebcc26e75b896fe", size = 2466001 }, + { url = "https://files.pythonhosted.org/packages/8f/7a/ea0947aadc1d9f72d4be00d2e19939611ec17363bcb5e6eb9ec7d8221c34/pydantic_core-2.50.1-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:5f3cae32fc46121f787cb2486de9cf95a8bf72aec5cc78f64c606fa1735a6ef5", size = 2481777 }, + { url = "https://files.pythonhosted.org/packages/24/f3/d15bc0b1fb0c4f1e07b3326ffb7489f7ae70da2b56a0884d22641b0eb47c/pydantic_core-2.50.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f476456ac2bb0d937f75191494a09c83a30765fea4f70f3b404942fe25f6cdf", size = 2336787 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147 }, +] + +[[package]] +name = "pymdown-extensions" +version = "12.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown" }, + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9a/0a/d8c8535346f63ff85c8743383438a74b5c0e3394d90e898b2cd1a2fac705/pymdown_extensions-12.2.tar.gz", hash = "sha256:02da896c362b548d85123e7a377a40024ea6ea938b4e45ca064e408afd994b64", size = 870226 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/79/a5/30b1a3d82219cbe5e6661cd7053791dc3a3743547ebf31f3366429d84516/pymdown_extensions-12.2-py3-none-any.whl", hash = "sha256:caea66e916b4cfe987b5dabb0a78558e3506988f6353812b0b0288be8f976c16", size = 277518 }, +] + +[[package]] +name = "pymongo" +version = "4.18.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/42/d8/2421a5ae0d6dcdaad2a0fb75d4071eaede9f764e73b829c62b6185c3ee6b/pymongo-4.18.3.tar.gz", hash = "sha256:5dd6e659b6014288a1c53458929402a58f44a032e6f29bcef44e7477c5268e48", size = 2747872 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a6/63bdeb527d98998b8ea2c667eca00d48f22dd42af281e9a2d7090632d54d/pymongo-4.18.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4f00cb357d7cc7f2798116e2377732a409c43a6dc882f0241eafed7ffed50655", size = 818805 }, + { url = "https://files.pythonhosted.org/packages/d0/e9/35602972d9fa98b894d1e5feef4db2f5d275298430e99b54b39f01125efa/pymongo-4.18.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3fe2ef9c6eb6b75689e10b20a3d8119da87302481b0a7029f9399b35142adfd8", size = 819303 }, + { url = "https://files.pythonhosted.org/packages/d8/e1/468f2c69b32565c93a56623535f248b091cf52e83c3509fa4b791427889f/pymongo-4.18.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:ba6090d4bed582c97e38fa818c0a2b7443f203cb28882900b433ff713465f158", size = 1023754 }, + { url = "https://files.pythonhosted.org/packages/e1/24/8af75e8af2427a47cfcc996444df934990ac4890d855f2ec064409d8b94b/pymongo-4.18.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f9903d0a089317422f52bbc25f5827e6656f0c42c43ed7d799bd02748e79a1", size = 1031920 }, + { url = "https://files.pythonhosted.org/packages/78/d0/96fa79fb7cb47e6f09e58ccbe1a726330b5d052f395b65d57e286849ed43/pymongo-4.18.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ac9bf2304c2b092ccf04261ab0cddb7fd65df1cc1ae0fa57312b03396c00d28c", size = 1053859 }, + { url = "https://files.pythonhosted.org/packages/4d/99/1b3f48bd3580c53e4a0e89bdc8cd8c15af94ca944f9de96582cb3ebfa5d0/pymongo-4.18.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5f37095428af3042f6bb1ebe269fedcbb645d9e0642b274e1cff026d3979500b", size = 1047418 }, + { url = "https://files.pythonhosted.org/packages/08/1e/ab9148b15dcefd3d02852a65e4ac3a1df86248c227dd532dc53b41f33697/pymongo-4.18.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:16ade5053ab6c712fd25d3f878e38441b169d607d1326d708844a131911d029f", size = 1033358 }, + { url = "https://files.pythonhosted.org/packages/39/93/bbbd0edfa33b10e7a86c864478dbb12163c946c142fd1e7b1bd0b6490353/pymongo-4.18.3-cp311-cp311-win32.whl", hash = "sha256:463c09e2cc208a65d35a1af3c613360cff6d58c8aef652273da07250bb214dba", size = 814425 }, + { url = "https://files.pythonhosted.org/packages/1c/13/7515f91ed9e80968cc5dc9321ac97fafde07afb75123adb9e294f50c8a10/pymongo-4.18.3-cp311-cp311-win_amd64.whl", hash = "sha256:1d7d0474012def6113c224b167aae661b926ac3b788219426830013ea25acd33", size = 821387 }, + { url = "https://files.pythonhosted.org/packages/89/59/f54d5ee7d95ec4ed1f31bff014a0f61caa3e888d7a89a0585f3eb4be164b/pymongo-4.18.3-cp311-cp311-win_arm64.whl", hash = "sha256:83dff65baa6f2423857598ffc371d7412fa4d2a07c618bdc8d5053ade65de664", size = 817697 }, + { url = "https://files.pythonhosted.org/packages/05/d5/4775a2891396ad125545e23b3024adae4bfac9553b70c924f1f372269dbf/pymongo-4.18.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ea78719dd05de3a919a52b94bec790c0d0cb7d07d2f7271711832664502a0782", size = 819374 }, + { url = "https://files.pythonhosted.org/packages/e0/0b/89ad56f43c3da6cbde100699f6b99528e78eba3c6740d8dad4ea2516aa45/pymongo-4.18.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6029d14761ba7243e6c5e464592013b519ad4dd3e4cfb75ddec39f4b5910711b", size = 819694 }, + { url = "https://files.pythonhosted.org/packages/84/b4/b68ffc205441b0a6d36d6299e35e063a5d0d3264fd685428920e1f82b634/pymongo-4.18.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9536fb3820f721290f03ad07472ec2266d8f364f91de628679a7146c9c1dbe35", size = 1040136 }, + { url = "https://files.pythonhosted.org/packages/c1/40/e779ff3d9165316c35a2f9742a42b9c3e3a678e9e2a9f6fe4128b7c551eb/pymongo-4.18.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e461bfca4861057929efa4215730b28b93b2adb4d07828d0b65475755bbf63f5", size = 1051430 }, + { url = "https://files.pythonhosted.org/packages/07/9b/443ee038a739cc65a75f2078c9ef725c1cb4881545d2e9d7941c46f64a6c/pymongo-4.18.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f1fef248623ed5e7406902a68d49dc0b1db434f19489f8d2fc9fe512c3c08bb1", size = 1075464 }, + { url = "https://files.pythonhosted.org/packages/36/4b/d80518f675cd4c1215b770444bb83002454574dae0e69760af10703ed1e8/pymongo-4.18.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:213eaed8fc4f2b0f9c84323a229dea699e01e18b8fb39723f430123b6ee77813", size = 1069046 }, + { url = "https://files.pythonhosted.org/packages/e5/77/f2e9648c62e423c3b9dab1e16491a6c33250487c819e5c75784b35d16047/pymongo-4.18.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:aa6f363ff648bf061335d2190dd580cbf465b1308a7e6acb992d128d6a16a3bd", size = 1050321 }, + { url = "https://files.pythonhosted.org/packages/a6/e4/3236e3a87b29fc4c502ad7dd1521c20d4a29faf1db6fde6ae94d05c5ec27/pymongo-4.18.3-cp312-cp312-win32.whl", hash = "sha256:28ba8cae86ea02d7ffdf0eea81be69be80d35d6a4a3eba4dc436d3194341805a", size = 815300 }, + { url = "https://files.pythonhosted.org/packages/1e/18/3fa9d86ba32386c02ea991f10875a6a066dd5e5d80790243be3c141b0e73/pymongo-4.18.3-cp312-cp312-win_amd64.whl", hash = "sha256:dc8ccf72b76c99a6b9fd05f8b89fe4a693128c5cfdba70f70e5792a6a563f6b0", size = 822079 }, + { url = "https://files.pythonhosted.org/packages/03/50/65a7cefd3891b77994841992b2c5b59394667df64ef21377b8ac7ecdef47/pymongo-4.18.3-cp312-cp312-win_arm64.whl", hash = "sha256:4a1f7c7dc1d554449a1695d897eb42b6080a2f1e9ccd81385dfa00204979c54d", size = 817935 }, + { url = "https://files.pythonhosted.org/packages/62/a4/225afd1d8d6e1df853b9aafe8f785304bb2e965b2f56c9ac4b61270aaf83/pymongo-4.18.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c5785fdb948a280140166ea24aac636e1f1de7142ff14ca23ddf9e2fd6b06916", size = 819273 }, + { url = "https://files.pythonhosted.org/packages/c2/6c/67d469f23654fa75ab6047b34fab232512e5688c75ce54e2c8e6248e9432/pymongo-4.18.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7cd8983db922f0c284b8ccb4182c5ecbc71831557f788bd6c46cbfafed853a6f", size = 819587 }, + { url = "https://files.pythonhosted.org/packages/c2/d6/be809af37976d329145d2496c847e430a76f66d51f6f10d2f54fbbba0d07/pymongo-4.18.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:185b3287bbe99fccf9571f2e5df5cd560ddc3cdc2c06852010346d040a8afb0f", size = 1040277 }, + { url = "https://files.pythonhosted.org/packages/d6/f4/79b1a8cc0163337f1b9728e31884db454ea615c47224b99ab0474007a861/pymongo-4.18.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f188904336022b84afa517cf2ee3cf9d3c42ab8ab107359e9bd4afd698d0cb0", size = 1051522 }, + { url = "https://files.pythonhosted.org/packages/ba/ca/600a7fdf1447a687a429df0f1ef6e112cef26b5e05f5bae502011c33d223/pymongo-4.18.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3c72fea937927b347efce39b63f604f2b7c6d975bc4fd1c7a916c82c96920ff1", size = 1075351 }, + { url = "https://files.pythonhosted.org/packages/91/8e/6fa6e7e4d0fe9204fd4319d7ab3994356f497b475ecc8403a30a72f9240f/pymongo-4.18.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:710c0422c86e22b702f12f9b5e48d38309f264ca34eaed6c9ac163b0c697d01f", size = 1068853 }, + { url = "https://files.pythonhosted.org/packages/31/3c/698ab3ae4d90d4547e6724f08c39db14432ca17f7fec5e7eafab3d54e818/pymongo-4.18.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f973cd934f9f943602418d4d0ff9a1371990741eaaeb7c6dbb421fec1345a828", size = 1050460 }, + { url = "https://files.pythonhosted.org/packages/56/5b/4c2bec3a343cffffd6480bf6aefd0e413c3a9af3f6beedad4e79b8e7a855/pymongo-4.18.3-cp313-cp313-win32.whl", hash = "sha256:163cb12da5b5227d186bc420fbdb613f45f1525a8e48a5b8624894182a79fa29", size = 815327 }, + { url = "https://files.pythonhosted.org/packages/5f/5c/914d3eda4e321c67c87c32bfce1c1fb06ff62e61f33fa8b442273512742b/pymongo-4.18.3-cp313-cp313-win_amd64.whl", hash = "sha256:6fed3281c93aafb79748c9448f32a1658a870499f09c0d70129f153c1a5833ef", size = 822114 }, + { url = "https://files.pythonhosted.org/packages/9f/cd/b315b2f2feb4394f24ed31399d96685936b9eb248b4016425e1ccb55f782/pymongo-4.18.3-cp313-cp313-win_arm64.whl", hash = "sha256:ff7585de6e5befc06eec004ac6352507685f901eac92ea0c79ae5defae374a96", size = 817974 }, + { url = "https://files.pythonhosted.org/packages/c8/f9/7037282744f7fe86d4a86c8745ea0ec8f8e644ecc63f3b600f1af56fb225/pymongo-4.18.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:a7c8471eca11f8ec2ae3a4315f44a2f6edcd0e144573d7bf003907eb8096883f", size = 819169 }, + { url = "https://files.pythonhosted.org/packages/5c/73/4d5fa6e9d5b068cad6a608d0dffffcc61b357e7d3e6950c4c70b93d9f72c/pymongo-4.18.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d2b1b531d212dd375a2ddc59d421d09f8a6bc5782fb688e4a65ff0d89e7bf0ad", size = 819677 }, + { url = "https://files.pythonhosted.org/packages/f4/bc/eccb6237d4c1c7cfd5f91ed4e4131f033b02170fcfcaa2d85a918c54ca86/pymongo-4.18.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2edaaff5cc7b2cb0cc216a01d85a413476abdf3cd7be5fc4025506be6434d2cc", size = 1042064 }, + { url = "https://files.pythonhosted.org/packages/8d/71/e822fc1c0dd80b3ab25a90af070776568fa5441ea41559a001255b4d78ca/pymongo-4.18.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b19fc2f492263561bab174bc97dc59a70a164a1cac02620b47a13b575310c128", size = 1051775 }, + { url = "https://files.pythonhosted.org/packages/c4/a3/7aafbbaac6b8815a84b24a7ea68ae569c041dae55c9b49407c02be446090/pymongo-4.18.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:99de1deaa55b17d0f8a2ceafd7908baaafa08151e2d0d668fdc03d0f607f5d33", size = 1075303 }, + { url = "https://files.pythonhosted.org/packages/e4/02/f4326578ad9c7c2bebea6ef849afc31878dd946fbb5724dbfa8c479fc607/pymongo-4.18.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c90575489ebe2ee8c0b4009efd7d4143037113092f6b28fb66e8f8ea0ca60c71", size = 1065783 }, + { url = "https://files.pythonhosted.org/packages/26/ec/eecd7abf22839c42abbcd09293be922d46227c07857c726d738797c30950/pymongo-4.18.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:75c038d39e23b38b968fd7c61060c8611859c51e411d52f7b97be49bf8bf0d10", size = 1050123 }, + { url = "https://files.pythonhosted.org/packages/c2/98/765449cd031e2763541fc144fcc6af8df0a5021355214c44ab4d9d78787b/pymongo-4.18.3-cp314-cp314-win32.whl", hash = "sha256:01da84a43a37b5ab327dbe7cf9f2612f9963c4ca093390d2211671eb996b26cc", size = 816497 }, + { url = "https://files.pythonhosted.org/packages/fb/53/a432246287fa2ead90546c855b9ad62c0fd2fa783f9042f1d762d7d18ef0/pymongo-4.18.3-cp314-cp314-win_amd64.whl", hash = "sha256:82f620a555a646f2218cfbf6c39b722e4cbfc71bd9fee019af5e72cbbe7488f7", size = 823661 }, + { url = "https://files.pythonhosted.org/packages/d9/63/8b725508ac9f438730c35ca701e1db18e7332e5cf0ef905729419c11dbc8/pymongo-4.18.3-cp314-cp314-win_arm64.whl", hash = "sha256:a8677a3f7127144f4a100a62ef264f9143a986aa1acd3aa35a0d027fd2aafec1", size = 819257 }, + { url = "https://files.pythonhosted.org/packages/30/30/bc0b397d0b87399fa2ce20cc14b54198073cc5bee5821a84fe8b5478945a/pymongo-4.18.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8f502830b94acd44f252f305be2e71c6f067acb690970f6910be50e1c7d6d217", size = 822168 }, + { url = "https://files.pythonhosted.org/packages/87/62/4212628f536db4c630c082f27747346642acf58d27a3206c7c9d2edf6bed/pymongo-4.18.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a5bcfaa3ea009c73afabfaaf8bfd6f3b61f32eaaf68e85660f3337724acc0f62", size = 822524 }, + { url = "https://files.pythonhosted.org/packages/f6/f1/abe1519ce3b5fe125cd6b246dd998ea1989feb456427821558d59f449c63/pymongo-4.18.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:4159ab20e5784b2e2b783bc80a4bbda52cfd19ddede5a4a80327ffb7d260db8c", size = 1105944 }, + { url = "https://files.pythonhosted.org/packages/e2/36/5ee745e7e61a5f63437a16a4f8b8f6fe7cd5d1fd9ae2ce6ef48e607c8219/pymongo-4.18.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3ca11bf9d64d7b7827350cd8bd4ae96ddd38669a3ce04860118994061c5fbdd6", size = 1125679 }, + { url = "https://files.pythonhosted.org/packages/c9/ad/89d37b9a79c73a5c8f3e6ab82ee440dbc3e82e12c53aa8b424ec1c4cc5ae/pymongo-4.18.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2e443366af09655938a7614c6ca1566ccd94f7042ce470c4a67dfe2179cec2f9", size = 1144903 }, + { url = "https://files.pythonhosted.org/packages/8e/2c/17bb29e9c4b46d479523a15efef9b736a561c52b855ec8afbf20191c4027/pymongo-4.18.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:05838fcc42c277d6293ca3e85d5c959beaa355f515b877ef56a048bb1c6660ae", size = 1136719 }, + { url = "https://files.pythonhosted.org/packages/b5/be/d6e6bb72a7e4b800ceacac092c399bcb1336362ac54a721637e2bde46cdc/pymongo-4.18.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7efcf4ef53c8a49e438a646ee838f927d4e05acd872a09b54aa97c07fb2059c1", size = 1118144 }, + { url = "https://files.pythonhosted.org/packages/64/61/bbb877abbb6ee8222648ef284b9936d4c164d64530a702e009d15c9dfe11/pymongo-4.18.3-cp314-cp314t-win32.whl", hash = "sha256:89df07473db610b6aa1c7a3ac9bcc80dd50b088f85c00657435895216230c071", size = 819185 }, + { url = "https://files.pythonhosted.org/packages/cc/e9/dead464714489d234f03ec007ba57b83c2ae4fa8b82e71bb83c689409ddc/pymongo-4.18.3-cp314-cp314t-win_amd64.whl", hash = "sha256:25d43632506dc98598ac1e45018ae18cb88137035df954bac04b5a700417521f", size = 827906 }, + { url = "https://files.pythonhosted.org/packages/f8/4a/1f2a5230bda2a1a3fb94457bceb9ea3919be40666da32fddb4d64e9a7fd6/pymongo-4.18.3-cp314-cp314t-win_arm64.whl", hash = "sha256:4214355fae9e12f99c288662720123002944ba7fa186ea62f431e37842380c4f", size = 820082 }, +] + +[[package]] +name = "pytest" +version = "9.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536 }, +] + +[[package]] +name = "pytest-asyncio" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pytest" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "pyyaml" +version = "6.0.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826 }, + { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577 }, + { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556 }, + { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114 }, + { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638 }, + { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463 }, + { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986 }, + { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543 }, + { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763 }, + { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063 }, + { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973 }, + { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116 }, + { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011 }, + { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870 }, + { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089 }, + { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181 }, + { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658 }, + { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003 }, + { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344 }, + { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669 }, + { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252 }, + { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081 }, + { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159 }, + { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626 }, + { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613 }, + { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115 }, + { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427 }, + { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090 }, + { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246 }, + { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814 }, + { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809 }, + { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454 }, + { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355 }, + { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175 }, + { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228 }, + { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194 }, + { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429 }, + { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912 }, + { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108 }, + { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641 }, + { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901 }, + { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132 }, + { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261 }, + { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272 }, + { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923 }, + { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062 }, + { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, +] + +[[package]] +name = "pyyaml-env-tag" +version = "1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pyyaml" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722 }, +] + +[[package]] +name = "quart" +version = "0.22.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.13'", +] +dependencies = [ + { name = "aiofiles", marker = "python_full_version < '3.13'" }, + { name = "blinker", marker = "python_full_version < '3.13'" }, + { name = "click", marker = "python_full_version < '3.13'" }, + { name = "flask", marker = "python_full_version < '3.13'" }, + { name = "hypercorn", marker = "python_full_version < '3.13'" }, + { name = "itsdangerous", marker = "python_full_version < '3.13'" }, + { name = "jinja2", marker = "python_full_version < '3.13'" }, + { name = "markupsafe", marker = "python_full_version < '3.13'" }, + { name = "werkzeug", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/82/8a/13962df31309fa024b1811102981577b1702916779d3f17067bbf1f7691d/quart-0.22.0.tar.gz", hash = "sha256:6ba567bb29e0ea66f7c0a0297c2b6225bb531e37dbf9b75dbf4a6e1713c4c934", size = 65475 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/81/80/0159d6fe2fc76915f2354e5b9187082987f7d648f0298d49770320c086ef/quart-0.22.0-py3-none-any.whl", hash = "sha256:bb659545f1a8a287a14df9434b9225a3d4738362a3ed170744d0e03bb9447b50", size = 78912 }, +] + +[[package]] +name = "quart" +version = "0.23.1" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.15'", + "python_full_version >= '3.13' and python_full_version < '3.15'", +] +dependencies = [ + { name = "aiofiles", marker = "python_full_version >= '3.13'" }, + { name = "blinker", marker = "python_full_version >= '3.13'" }, + { name = "click", marker = "python_full_version >= '3.13'" }, + { name = "flask", marker = "python_full_version >= '3.13'" }, + { name = "hypercorn", marker = "python_full_version >= '3.13'" }, + { name = "itsdangerous", marker = "python_full_version >= '3.13'" }, + { name = "jinja2", marker = "python_full_version >= '3.13'" }, + { name = "markupsafe", marker = "python_full_version >= '3.13'" }, + { name = "werkzeug", marker = "python_full_version >= '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/6b/81/34396f67e09e7a0609261f1ef0f43b26f5d67e8f2dc4d34b4953061560f2/quart-0.23.1.tar.gz", hash = "sha256:1ca848415910bd2eb75e9d9b452388f892a37be222602a373622e6c633d1efbf", size = 65636 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5c/c1/26dca56249da1a889ebb946000ab272712476209234f714ad3e8013ee005/quart-0.23.1-py3-none-any.whl", hash = "sha256:78cf3a7249ab09f9e03d78b0b5e2472c4c09ce4615a99c2b1aa9a35261243b66", size = 79388 }, +] + +[[package]] +name = "requests" +version = "2.34.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "charset-normalizer" }, + { name = "idna" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, +] + +[[package]] +name = "rich" +version = "15.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markdown-it-py" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654 }, +] + +[[package]] +name = "rich-click" +version = "1.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "rich" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/96/3e/5688fdd83aea416de336582a274f2bc8236b5c261b04c11e17bc262786ad/rich_click-1.9.9.tar.gz", hash = "sha256:324cba7513cd4187ee92b2eef21f071714e45be062458c8b157bd7e0c81103e3", size = 75866 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b5/51/b2b91aec091e86bc961e5164f00207d89f133cc82b5ddad541d2132ea819/rich_click-1.9.9-py3-none-any.whl", hash = "sha256:365e7a9d0adb42e41ea832a0a12e02c44c079a26536dee688125eb9814f97274", size = 71910 }, +] + +[[package]] +name = "ruff" +version = "0.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e9/a7/70debb024dfacda67b8e560cc7511f52b34b9348a8cc8c1ec23036dcd51d/ruff-0.17.0.tar.gz", hash = "sha256:5cd03240d8208a557c2a9655a5cb07ebe36aa6bb35065f97d48c1f6adef5a322", size = 5320554 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/f8/ee5ab9da6089eae2a33e6008b01deb1eda19992c1c8e10661e98cee1640f/ruff-0.17.0-py3-none-linux_armv6l.whl", hash = "sha256:0e271826af9a20d18c6cfae8c51e82959167c24859686ddd3eb9a7f0842ce81e", size = 10148907 }, + { url = "https://files.pythonhosted.org/packages/9f/d9/2f81fb5a9d580afbb11b1c8ff915233a11f2a1b27405d7991f183c5e1976/ruff-0.17.0-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:5f0ca4a40f81403689c04f12966e22f44e329ae362072d8f1587b7bda87f603b", size = 10224208 }, + { url = "https://files.pythonhosted.org/packages/a7/20/643f3c8f75594f937b2bf74801241c56a2e2b8e139d24dff8b66b28cdd7f/ruff-0.17.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:cbf7149e0927dc3295d5d64679a4765576eef71b00782b2ae969ef82274d6bb9", size = 9273324 }, + { url = "https://files.pythonhosted.org/packages/ec/91/627700b233d367736cb274f1bd0b47d1f2b12f68878192812bd875adadc3/ruff-0.17.0-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:13ee90156522998c3037059d8f66885c8adeeaf7643bdce2caceee196ecd23e0", size = 9953190 }, + { url = "https://files.pythonhosted.org/packages/cd/92/91f7b5ed39490f89d6cbf56e1f543c383667a725efa8e2c2dee0f01f5591/ruff-0.17.0-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3d8e4a002a94cd9d0dc48b51dc69d807a172b5b9bf2b668e656424dc5b55ead1", size = 9961746 }, + { url = "https://files.pythonhosted.org/packages/87/c5/7310f9fc63ce11ff6394edbd5e85433dfb0e14c9fbf6ccc97f1538491bc7/ruff-0.17.0-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c0b8a60c06a218c337e1161638d34757f83449243e2db161483ddf948e53ad14", size = 10625483 }, + { url = "https://files.pythonhosted.org/packages/a5/8d/97443f0dca4a03a0bc7629fd396fd494a1cb6666121e38c5075acb217d8f/ruff-0.17.0-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a330178bdffc4205dbf3bda11d93e059e388fd6546f8cdd304501a9160363c0d", size = 11350129 }, + { url = "https://files.pythonhosted.org/packages/9c/0a/c525efd9777be4b6b012e6969a3012648468e7e6c4b3e5b46af69f46e8eb/ruff-0.17.0-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:7bb08489e234876fa2da67ae3ea938e9a2156da80293e0e4365abd6973d98329", size = 10906223 }, + { url = "https://files.pythonhosted.org/packages/2c/3c/4a01195d93420cad1175bedad13a515dc8a56f95a6e39789b92e582682f5/ruff-0.17.0-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc73e7c133e82d55b5f15897b2a442d72c0cb4a0c886c46801ce3c247150b60c", size = 10386314 }, + { url = "https://files.pythonhosted.org/packages/c7/72/1a3951665485a921f6375f91e754a1854d5a645d41acc3642668064ff64d/ruff-0.17.0-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:db4f74c533403ab70fe4007873f6ae0c9f94a8b03158cf48d78788e47cdbe399", size = 10565464 }, + { url = "https://files.pythonhosted.org/packages/90/8c/539b4d8c082f57e18db8ae2be85a460d77861c79dcd5798e32b536a6a06f/ruff-0.17.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:3d8cc360e666d1914e47b0777c6906d70cf18891a55532bd0a16844195d70859", size = 10079468 }, + { url = "https://files.pythonhosted.org/packages/e0/b8/84286966db79434e8c26b585b0a0f6897cb3ab1c51a4aa4df10c28488b62/ruff-0.17.0-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:d66de796b726c4801e05fa99a2a8d7a780e107be222486c304ab61765561e866", size = 9958556 }, + { url = "https://files.pythonhosted.org/packages/69/50/27b6eed27b83fcdd5bfa0d52b83231e29094754374698da404d094487ae3/ruff-0.17.0-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c3f268baf004aea944f040623327119527ea231af15f7fb7890e82cea0679589", size = 10352173 }, + { url = "https://files.pythonhosted.org/packages/2a/fa/955399fd13044cd827862044117d784a59e3196f6cce7424908ac9a7f914/ruff-0.17.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:864b6c1acb6b0bccf94b5a3938a1531fd09aaca5e5659a2e7bf0f3cf2a685540", size = 10791761 }, + { url = "https://files.pythonhosted.org/packages/ae/bf/024e01e1f6aec87768696725e648ed5b438941341eea8f8100beb681961f/ruff-0.17.0-py3-none-win32.whl", hash = "sha256:5e50aa5b84decd9fe5b0bb0e6f71c3b592f1767ed09faa4b7207d933961e35cd", size = 9904112 }, + { url = "https://files.pythonhosted.org/packages/cc/77/1ee73df41dcc8d1cdb686ee4bc46ea29704ea175feb6b95c78420f631ab8/ruff-0.17.0-py3-none-win_amd64.whl", hash = "sha256:8ab76bcda86dfd28e13776cb5de3c7bcdcf1ae3d37ed761113d1a5a415dc134c", size = 10115585 }, + { url = "https://files.pythonhosted.org/packages/fd/71/eb4f0ccc844aece56963e8578df9c95d4c00f580547d52329d4035d3af18/ruff-0.17.0-py3-none-win_arm64.whl", hash = "sha256:c154c73ff43f9854395e24cac507af13078962e53d2b511605058d22af1fdb88", size = 9872035 }, +] + +[[package]] +name = "sentry-sdk" +version = "2.72.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "urllib3" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/26/c1/d2bbb2b03bdc2ef14b35e02b647b8489156c50b6e0b8bacc5fa303902e23/sentry_sdk-2.72.0.tar.gz", hash = "sha256:0d2ffbc28ee2e63cbaf3d015e93b448cffcc3ef3be03a44704ad36ade72faa95", size = 1069699 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d0/0b/0dd9686842d9dc49c3ee84cced31c54bceb0fb52a826be7d3d4fe30d882a/sentry_sdk-2.72.0-py3-none-any.whl", hash = "sha256:3e13ace4ffd0b3cc78288236edfc4e4bd90eb12a396cc5845fa10a58ff289f50", size = 544754 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "sniffio" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a2/87/a6771e1546d97e7e041b6ae58d80074f81b7d5121207425c964ddf5cfdbd/sniffio-1.3.1.tar.gz", hash = "sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc", size = 20372 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e9/44/75a9c9421471a6c4805dbf2356f7c181a29c1879239abab1ea2cc8f38b40/sniffio-1.3.1-py3-none-any.whl", hash = "sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2", size = 10235 }, +] + +[[package]] +name = "starlette" +version = "1.7.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7b/2b/3850dc6bf7ef71b088962eba31dafc6cffd2f96e577ebb0bb316df96da3e/starlette-1.7.0.tar.gz", hash = "sha256:c79f74ea63cff761804fbbfb182f1e0b440c2d07b164d24700c5a1bab5d6ff5d", size = 2736246 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980 }, +] + +[[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 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 }, +] + +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750 }, +] + +[[package]] +name = "tzdata" +version = "2026.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d9/68/f1b440335057bfce71b6e50a9d09445aa2ecbd08359a337976627b8409e7/tzdata-2026.5.tar.gz", hash = "sha256:8cc73c0a0bfca7dbfa59235d60b2eff82231dee33f53d206db1acd9173cfc0a7", size = 200404 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/21/1e5995a1c920cce14e4bffae20c665ec10e7ed03ab25e006cd741092b718/tzdata-2026.5-py2.py3-none-any.whl", hash = "sha256:b683bd1b6659ddcd810ff02ad09ba821d4bf1065072805063eb35c49617905ac", size = 347996 }, +] + +[[package]] +name = "urllib3" +version = "2.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/e3/05/b17359e1cefb4f909b5e40b1b90a496d987258916dbbf88e842c729f510e/urllib3-2.8.0.tar.gz", hash = "sha256:63bf2ead4c879426ebf22ef2a781eeb4aa3b4ae798a0435506f8687fd5bb9b63", size = 458972 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717 }, +] + +[[package]] +name = "watchdog" +version = "6.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, + { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, + { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, + { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, + { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, + { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, + { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, + { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, + { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, + { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, + { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, + { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, + { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, + { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, + { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, + { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, +] + +[[package]] +name = "werkzeug" +version = "3.1.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "markupsafe" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a4/34/4dd12fc8bb7d61c91467ec3efe415ffa7d5456f799954b40c5bbaeae470e/werkzeug-3.1.9.tar.gz", hash = "sha256:55ca7c70a75689be937aa27f8ff4b018f06ff4838fc73045560bf0f5a1291060", size = 940188 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a1/38/df03f564f43cec2684823f3cccae1a652ee7face1cbaa76fb223096e64d7/werkzeug-3.1.9-py3-none-any.whl", hash = "sha256:6392e50c78460ba618e5b21f08a71f59c99ce99cdc6cf6e3dd7e6ccca8754fab", size = 228700 }, +] + +[[package]] +name = "wsproto" +version = "1.3.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b8701c2c19a14c913c120b882d50b014ca0d38083c2c/wsproto-1.3.2.tar.gz", hash = "sha256:b86885dcf294e15204919950f666e06ffc6c7c114ca900b060d6e16293528294", size = 50116 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405 }, +] From dc35f380fae0a8b13ca974a949c5cdb2948e45f9 Mon Sep 17 00:00:00 2001 From: jaykv Date: Mon, 12 Oct 2026 23:40:49 -0400 Subject: [PATCH 02/31] feat!: async ODM core for msgspec, pydantic and attrs (0.2.0) A rewrite of the ODM on pymongo's AsyncMongoClient. Async-only, Python 3.11+, not backwards compatible with 0.1.x. Models and codecs - one Codec seam with msgspec, pydantic and attrs implementations; SpecAdapter for existing classes - field markers in typing.Annotated (Policy, Deferred, Immutable, Alias, CreatedAt, UpdatedAt, DeletedAt, Revision, Backref, OnDelete) - Ref[T] references, OID, discriminators, lazy upcasting Reads and writes - immutable chainable queries, Q expressions with name checks and type casting, untrusted raw filters unless wrapped in trusted() - tracked / lean / raw read modes; snapshot-based change tracking so save() sends only the delta - atomic update operators, upsert, typed bulk_write, bulk_save, partial-failure insert_many, cursor and offset pagination Relations - include() by batched queries or $lookup with identical results, nested paths, reverse relations, polymorphic refs, cascade rules, $graphLookup and $unionWith Operations - explicit Registry lifecycle, callback transactions that restore in-memory document state on abort and retry - plugins (timestamps, soft delete, revisions), registry-wide plugins, document hooks, named scopes - index planner with gated destructive actions, $jsonSchema validators, change streams, multi-database managers, shard-key aware writes - command events with model and operation names; logging, OpenTelemetry and Sentry listeners - one error hierarchy that wraps pymongo errors 415 tests against a replica set and a sharded cluster, passing on Python 3.11, 3.13 and 3.15; ruff and mypy --strict clean. Co-Authored-By: Claude Opus 5.5 --- .../mongospecs/src/mongospecs/__init__.py | 184 +++++ packages/mongospecs/src/mongospecs/attrs.py | 108 +++ packages/mongospecs/src/mongospecs/bulk.py | 93 +++ packages/mongospecs/src/mongospecs/codec.py | 393 +++++++++ packages/mongospecs/src/mongospecs/errors.py | 202 +++++ packages/mongospecs/src/mongospecs/expr.py | 445 ++++++++++ packages/mongospecs/src/mongospecs/hooks.py | 114 +++ packages/mongospecs/src/mongospecs/indexes.py | 380 +++++++++ packages/mongospecs/src/mongospecs/manager.py | 761 ++++++++++++++++++ packages/mongospecs/src/mongospecs/markers.py | 238 ++++++ packages/mongospecs/src/mongospecs/meta.py | 373 +++++++++ packages/mongospecs/src/mongospecs/model.py | 281 +++++++ packages/mongospecs/src/mongospecs/msgspec.py | 124 +++ .../src/mongospecs/plugins/__init__.py | 7 + .../src/mongospecs/plugins/revision.py | 57 ++ .../src/mongospecs/plugins/soft_delete.py | 50 ++ .../src/mongospecs/plugins/timestamps.py | 51 ++ packages/mongospecs/src/mongospecs/py.typed | 0 .../mongospecs/src/mongospecs/pydantic.py | 156 ++++ packages/mongospecs/src/mongospecs/query.py | 626 ++++++++++++++ packages/mongospecs/src/mongospecs/refs.py | 177 ++++ .../mongospecs/src/mongospecs/registry.py | 574 +++++++++++++ .../mongospecs/src/mongospecs/relations.py | 615 ++++++++++++++ packages/mongospecs/src/mongospecs/streams.py | 98 +++ .../mongospecs/src/mongospecs/tracking.py | 128 +++ packages/mongospecs/src/mongospecs/updates.py | 264 ++++++ packages/mongospecs/tests/__init__.py | 0 packages/mongospecs/tests/conftest.py | 58 ++ packages/mongospecs/tests/models.py | 204 +++++ .../mongospecs/tests/test_bulk_pagination.py | 152 ++++ packages/mongospecs/tests/test_codec.py | 226 ++++++ packages/mongospecs/tests/test_crud.py | 247 ++++++ packages/mongospecs/tests/test_expr.py | 169 ++++ packages/mongospecs/tests/test_indexes.py | 177 ++++ packages/mongospecs/tests/test_models_misc.py | 170 ++++ .../mongospecs/tests/test_plugins_hooks.py | 247 ++++++ .../mongospecs/tests/test_query_compile.py | 140 ++++ .../mongospecs/tests/test_registry_errors.py | 240 ++++++ packages/mongospecs/tests/test_relations.py | 242 ++++++ .../mongospecs/tests/test_streams_sharded.py | 145 ++++ packages/mongospecs/tests/test_tracking_tx.py | 190 +++++ packages/mongospecs/tests/test_updates.py | 162 ++++ 42 files changed, 9268 insertions(+) create mode 100644 packages/mongospecs/src/mongospecs/__init__.py create mode 100644 packages/mongospecs/src/mongospecs/attrs.py create mode 100644 packages/mongospecs/src/mongospecs/bulk.py create mode 100644 packages/mongospecs/src/mongospecs/codec.py create mode 100644 packages/mongospecs/src/mongospecs/errors.py create mode 100644 packages/mongospecs/src/mongospecs/expr.py create mode 100644 packages/mongospecs/src/mongospecs/hooks.py create mode 100644 packages/mongospecs/src/mongospecs/indexes.py create mode 100644 packages/mongospecs/src/mongospecs/manager.py create mode 100644 packages/mongospecs/src/mongospecs/markers.py create mode 100644 packages/mongospecs/src/mongospecs/meta.py create mode 100644 packages/mongospecs/src/mongospecs/model.py create mode 100644 packages/mongospecs/src/mongospecs/msgspec.py create mode 100644 packages/mongospecs/src/mongospecs/plugins/__init__.py create mode 100644 packages/mongospecs/src/mongospecs/plugins/revision.py create mode 100644 packages/mongospecs/src/mongospecs/plugins/soft_delete.py create mode 100644 packages/mongospecs/src/mongospecs/plugins/timestamps.py create mode 100644 packages/mongospecs/src/mongospecs/py.typed create mode 100644 packages/mongospecs/src/mongospecs/pydantic.py create mode 100644 packages/mongospecs/src/mongospecs/query.py create mode 100644 packages/mongospecs/src/mongospecs/refs.py create mode 100644 packages/mongospecs/src/mongospecs/registry.py create mode 100644 packages/mongospecs/src/mongospecs/relations.py create mode 100644 packages/mongospecs/src/mongospecs/streams.py create mode 100644 packages/mongospecs/src/mongospecs/tracking.py create mode 100644 packages/mongospecs/src/mongospecs/updates.py create mode 100644 packages/mongospecs/tests/__init__.py create mode 100644 packages/mongospecs/tests/conftest.py create mode 100644 packages/mongospecs/tests/models.py create mode 100644 packages/mongospecs/tests/test_bulk_pagination.py create mode 100644 packages/mongospecs/tests/test_codec.py create mode 100644 packages/mongospecs/tests/test_crud.py create mode 100644 packages/mongospecs/tests/test_expr.py create mode 100644 packages/mongospecs/tests/test_indexes.py create mode 100644 packages/mongospecs/tests/test_models_misc.py create mode 100644 packages/mongospecs/tests/test_plugins_hooks.py create mode 100644 packages/mongospecs/tests/test_query_compile.py create mode 100644 packages/mongospecs/tests/test_registry_errors.py create mode 100644 packages/mongospecs/tests/test_relations.py create mode 100644 packages/mongospecs/tests/test_streams_sharded.py create mode 100644 packages/mongospecs/tests/test_tracking_tx.py create mode 100644 packages/mongospecs/tests/test_updates.py diff --git a/packages/mongospecs/src/mongospecs/__init__.py b/packages/mongospecs/src/mongospecs/__init__.py new file mode 100644 index 0000000..51d8030 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/__init__.py @@ -0,0 +1,184 @@ +"""Async MongoDB ODM for msgspec, pydantic and attrs models. + +Pick a backend for your models:: + + from mongospecs.msgspec import Spec # or mongospecs.pydantic / mongospecs.attrs + +Everything else is imported from here. +""" + +from mongospecs.bulk import DeleteMany, DeleteOne, InsertOne, UpdateMany, UpdateOne +from mongospecs.errors import ( + BulkWriteFailed, + ConfigurationError, + ConnectionFailed, + DocumentNotFound, + DocumentTooLarge, + DriverError, + DuplicateKeyError, + EncodingError, + MongoSpecsError, + NotBoundError, + NotLoadedError, + OperationFailed, + OperationTimeout, + PoolTimeout, + QueryError, + RegistryClosedError, + StaleDocumentError, + TransactionError, + ValidationError, +) +from mongospecs.expr import ( + All, + And, + ElemMatch, + Exists, + In, + Nor, + Not, + NotIn, + Or, + Q, + Size, + SortBy, + Type, + trusted, +) +from mongospecs.hooks import OpCtx, Plugin +from mongospecs.indexes import Action, IndexAction, IndexPlan, apply_indexes, plan_indexes +from mongospecs.manager import BulkResult, Manager +from mongospecs.markers import ( + Alias, + Backref, + CreatedAt, + Deferred, + DeletedAt, + Immutable, + Index, + OnDelete, + Policy, + Revision, + SpecConfig, + To, + UpdatedAt, + computed, +) +from mongospecs.meta import meta_of +from mongospecs.model import Document +from mongospecs.query import Page, Query +from mongospecs.refs import OID, Ref +from mongospecs.registry import CommandEvent, Registry, Tx, logging_listener, otel_listener, sentry_listener +from mongospecs.streams import ChangeEvent +from mongospecs.updates import ( + AddToSet, + CurrentDate, + Each, + Inc, + Max, + Min, + Mul, + Pop, + Pull, + Push, + Set, + SetOnInsert, + Unset, +) + +ASC = 1 +DESC = -1 + +__version__ = "0.2.0" + +__all__ = [ + "ASC", + "DESC", + "OID", + "Action", + "AddToSet", + "Alias", + "All", + "And", + "Backref", + "BulkResult", + "BulkWriteFailed", + "ChangeEvent", + "CommandEvent", + "ConfigurationError", + "ConnectionFailed", + "CreatedAt", + "CurrentDate", + "Deferred", + "DeleteMany", + "DeleteOne", + "DeletedAt", + "Document", + "DocumentNotFound", + "DocumentTooLarge", + "DriverError", + "DuplicateKeyError", + "Each", + "ElemMatch", + "EncodingError", + "Exists", + "Immutable", + "In", + "Inc", + "Index", + "IndexAction", + "IndexPlan", + "InsertOne", + "Manager", + "Max", + "Min", + "MongoSpecsError", + "Mul", + "Nor", + "Not", + "NotBoundError", + "NotIn", + "NotLoadedError", + "OnDelete", + "OpCtx", + "OperationFailed", + "OperationTimeout", + "Or", + "Page", + "Plugin", + "Policy", + "PoolTimeout", + "Pop", + "Pull", + "Push", + "Q", + "Query", + "QueryError", + "Ref", + "Registry", + "RegistryClosedError", + "Revision", + "Set", + "SetOnInsert", + "Size", + "SortBy", + "SpecConfig", + "StaleDocumentError", + "To", + "TransactionError", + "Tx", + "Type", + "Unset", + "UpdateMany", + "UpdateOne", + "UpdatedAt", + "ValidationError", + "apply_indexes", + "computed", + "logging_listener", + "meta_of", + "otel_listener", + "plan_indexes", + "sentry_listener", + "trusted", +] diff --git a/packages/mongospecs/src/mongospecs/attrs.py b/packages/mongospecs/src/mongospecs/attrs.py new file mode 100644 index 0000000..1e7f9d1 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/attrs.py @@ -0,0 +1,108 @@ +"""attrs backend: ``from mongospecs.attrs import Spec``. + +attrs has no decoding or validation of its own, so documents are converted with +msgspec, which supports attrs classes and runs their validators. +""" + +from __future__ import annotations + +import typing as t +from typing import ClassVar + +import attrs +import msgspec + +from mongospecs.codec import Codec, RawField, field_error, to_bson +from mongospecs.errors import ValidationError +from mongospecs.markers import SpecConfig +from mongospecs.model import Document +from mongospecs.msgspec import _error, dec_hook, schema_hook +from mongospecs.refs import OID + +__all__ = ["CODEC", "AttrsCodec", "Spec", "SpecAdapter"] + + +def encode_attrs(obj: t.Any) -> dict[str, t.Any]: + return {a.name: to_bson(getattr(obj, a.name)) for a in attrs.fields(type(obj))} + + +class AttrsCodec(Codec): + name = "attrs" + + def raw_fields(self, cls: type) -> list[RawField]: + hints = t.get_type_hints(cls, include_extras=True) + return [ + (a.name, a.name, hints.get(a.name, a.type or t.Any), a.default is attrs.NOTHING) + for a in attrs.fields(cls) + ] + + def _decode(self, cls: type, doc: dict[str, t.Any], trusted: bool) -> t.Any: + try: + return msgspec.convert(doc, cls, dec_hook=dec_hook) + except (msgspec.ValidationError, ValueError) as exc: + raise _error(cls, exc) from exc + + def _encode(self, obj: t.Any) -> dict[str, t.Any]: + return encode_attrs(obj) + + def validate_field(self, cls: type, name: str, value: t.Any) -> t.Any: + attribute = getattr(attrs.fields(cls), name) + try: + if attribute.converter is not None: + value = attribute.converter(value) + value = msgspec.convert(value, self.fields(cls)[name].type, dec_hook=dec_hook) + if attribute.validator is not None: + attribute.validator(object.__new__(cls), attribute, value) + except ValidationError: + raise + except msgspec.ValidationError as exc: + raise _error(cls, exc, name) from exc + except (ValueError, TypeError) as exc: + raise field_error(cls, name, str(exc)) from exc + return value + + def _json_schema(self, cls: type) -> dict[str, t.Any]: + return msgspec.json.schema(cls, schema_hook=schema_hook) + + +CODEC = AttrsCodec() + + +@attrs.define(kw_only=True) +class Spec(Document): + """Base class for attrs documents. Subclasses must also be decorated with ``@attrs.define``.""" + + __codec__: ClassVar[Codec] = CODEC + + id: OID | None = None + + def __attrs_post_init__(self) -> None: + self._normalize_refs() + + +def SpecAdapter(cls: type, **config: t.Any) -> t.Any: + """Turn an existing attrs class into a document class. + + Keyword arguments are :class:`~mongospecs.SpecConfig` fields, for example + ``SpecAdapter(Dragon, collection="dragons")``. + """ + + if issubclass(cls, Document): + return cls + + @attrs.define(kw_only=True) + class Adapted(cls, Document): # type: ignore[misc] + __codec__: ClassVar[Codec] = CODEC + __spec__ = SpecConfig(**{"collection": cls.__name__, **config}) + + id: OID | None = None + + def __attrs_post_init__(self) -> None: + parent = getattr(super(), "__attrs_post_init__", None) + if parent is not None: + parent() + self._normalize_refs() + + Adapted.__name__ = Adapted.__qualname__ = cls.__name__ + Adapted.__doc__ = cls.__doc__ + return Adapted diff --git a/packages/mongospecs/src/mongospecs/bulk.py b/packages/mongospecs/src/mongospecs/bulk.py new file mode 100644 index 0000000..a0a04f7 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/bulk.py @@ -0,0 +1,93 @@ +"""Typed operations for ``Model.bulk_write``. + +Unlike raw driver operations these are checked against the model: filters and +values are cast, unknown fields raise, scopes apply, and plugins add timestamps +and revisions exactly as they do for single writes. +""" + +from __future__ import annotations + +import typing as t + +import pymongo + +from mongospecs.errors import QueryError +from mongospecs.hooks import OpCtx +from mongospecs.updates import UpdateOp + +if t.TYPE_CHECKING: + from mongospecs.manager import Manager + +__all__ = ["DeleteMany", "DeleteOne", "InsertOne", "UpdateMany", "UpdateOne", "compile_bulk"] + + +class InsertOne: + __slots__ = ("doc",) + + def __init__(self, doc: t.Any) -> None: + self.doc = doc + + +class UpdateOne: + many = False + __slots__ = ("fields", "ops", "target", "upsert") + + def __init__(self, target: t.Any, *ops: UpdateOp, upsert: bool = False, **fields: t.Any) -> None: + self.target = target + self.ops = ops + self.fields = fields + self.upsert = upsert + + +class UpdateMany(UpdateOne): + many = True + __slots__ = () + + +class DeleteOne: + many = False + __slots__ = ("hard", "target") + + def __init__(self, target: t.Any, *, hard: bool = False) -> None: + self.target = target + self.hard = hard + + +class DeleteMany(DeleteOne): + many = True + __slots__ = () + + +def compile_bulk(manager: Manager[t.Any], ctx: OpCtx, operations: t.Iterable[t.Any]) -> list[t.Any]: + """Compile typed bulk operations to driver requests.""" + requests: list[t.Any] = [] + for op in operations: + if isinstance(op, InsertOne): + doc = manager.meta.decode(dict(op.doc)) if isinstance(op.doc, t.Mapping) else op.doc + encoded = manager.meta.encode(doc) + if encoded.get("_id") is None: + encoded.pop("_id", None) + insert_ctx = manager.ctx("insert", ctx.session, many=True) + for plugin in manager.plugins: + plugin.on_insert(insert_ctx, encoded) + requests.append(pymongo.InsertOne(encoded)) + elif isinstance(op, UpdateOne): + update_ctx = manager.ctx("update", ctx.session, many=op.many) + flt = manager.scoped(update_ctx, manager.compile(op.target)) + update = manager._update_doc(update_ctx, flt, op.ops, op.fields, upsert=op.upsert) + factory = pymongo.UpdateMany if op.many else pymongo.UpdateOne + requests.append(factory(flt, update, upsert=op.upsert)) + elif isinstance(op, DeleteOne): + delete_ctx = manager.ctx("delete", ctx.session, many=op.many) + flt = manager.scoped(delete_ctx, manager.compile(op.target)) + soft = None if op.hard else manager._soft_delete(delete_ctx, flt) + if soft is not None: + requests.append((pymongo.UpdateMany if op.many else pymongo.UpdateOne)(flt, soft)) + else: + requests.append((pymongo.DeleteMany if op.many else pymongo.DeleteOne)(flt)) + else: + raise QueryError( + f"bulk_write takes mongospecs.bulk operations, got {type(op).__name__}; " + "use Model.collection().bulk_write for raw driver operations" + ) + return requests diff --git a/packages/mongospecs/src/mongospecs/codec.py b/packages/mongospecs/src/mongospecs/codec.py new file mode 100644 index 0000000..d513c12 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/codec.py @@ -0,0 +1,393 @@ +"""The backend seam. + +A :class:`Codec` knows how one model library (msgspec, pydantic, attrs) lists +fields, decodes a MongoDB document into a model, encodes a model back, validates +a single field and produces JSON Schema. Everything else in mongospecs is +backend-agnostic and goes through this interface. +""" + +from __future__ import annotations + +import abc +import sys +import types +import typing as t +from dataclasses import dataclass + +import bson +from bson.raw_bson import RawBSONDocument + +from mongospecs.errors import ConfigurationError, ValidationError +from mongospecs.markers import DEFAULT_POLICY, Alias, Backref, Marker, Policy, To +from mongospecs.refs import Ref, id_of + +__all__ = ["Codec", "FieldInfo", "RefInfo", "codec_for", "to_bson"] + + +@dataclass(frozen=True, slots=True) +class RefInfo: + """Shape and targets of a reference field.""" + + shape: t.Literal["one", "list", "dict"] + targets: tuple[t.Any, ...] + """Target model classes, or class names (``str``) still to be resolved.""" + + +@dataclass(frozen=True, slots=True) +class FieldInfo: + name: str + """Attribute name on the model.""" + stored: str + """Key in the MongoDB document.""" + type: t.Any + """The annotation, including ``Annotated`` metadata.""" + markers: tuple[t.Any, ...] + policy: Policy + required: bool + ref: RefInfo | None = None + backref: Backref | None = None + + def has(self, marker: type[Marker]) -> bool: + return any(m is marker or isinstance(m, marker) for m in self.markers) + + def get(self, marker: type[Marker]) -> t.Any: + for m in self.markers: + if isinstance(m, marker): + return m + if m is marker: + return marker() + return None + + @property + def transient(self) -> bool: + """Never written to the database.""" + return self.backref is not None + + +# -- annotation analysis ---------------------------------------------------- + +_UNION_ORIGINS: tuple[t.Any, ...] = (t.Union, types.UnionType) + + +def _is_marker(obj: t.Any) -> bool: + return isinstance(obj, Marker) or (isinstance(obj, type) and issubclass(obj, Marker)) + + +def split_annotation(tp: t.Any) -> tuple[t.Any, tuple[t.Any, ...]]: + """Return ``(bare type, markers)``, looking through ``Annotated`` and ``Optional``.""" + markers: list[t.Any] = [] + while t.get_origin(tp) is t.Annotated: + args = t.get_args(tp) + markers.extend(m for m in args[1:] if _is_marker(m)) + tp = args[0] + if t.get_origin(tp) in _UNION_ORIGINS: + for arg in t.get_args(tp): + if t.get_origin(arg) is t.Annotated: + markers.extend(m for m in t.get_args(arg)[1:] if _is_marker(m)) + return tp, tuple(markers) + + +def strip_optional(tp: t.Any) -> t.Any: + """``X | None`` -> ``X`` (also unwraps ``Annotated``).""" + while t.get_origin(tp) is t.Annotated: + tp = t.get_args(tp)[0] + if t.get_origin(tp) in _UNION_ORIGINS: + args = [a for a in t.get_args(tp) if a is not type(None)] + if len(args) == 1: + return strip_optional(args[0]) + return tp + + +def loosen(tp: t.Any) -> t.Any: + """The type without validation constraints: ``Annotated`` metadata is dropped, + except the marker that lets pydantic handle ``ObjectId``. + + Used to cast query values, which should match the field's type but need not + satisfy rules meant for stored values (a search for a short title is legitimate + even if titles must be long). + """ + from mongospecs.refs import _OIDAnnotation + + origin = t.get_origin(tp) + if origin is t.Annotated: + inner, *meta = t.get_args(tp) + inner = loosen(inner) + return t.Annotated[inner, _OIDAnnotation] if _OIDAnnotation in meta else inner + if origin in _UNION_ORIGINS: + return t.Union[tuple(loosen(a) for a in t.get_args(tp))] # noqa: UP007 + args = t.get_args(tp) + if origin is not None and args and origin is not t.Literal: + try: + return origin[tuple(loosen(a) for a in args)] + except TypeError: + return tp + return tp + + +def element_type(tp: t.Any) -> t.Any: + """Element type of a list/set/tuple annotation, or ``None``.""" + tp = strip_optional(tp) + origin = t.get_origin(tp) + if origin in (list, set, frozenset, tuple, t.Sequence, t.MutableSequence) or ( + isinstance(origin, type) and issubclass(origin, list | set | frozenset | tuple) + ): + args = t.get_args(tp) + if args: + return args[0] + return None + + +def _ref_targets(tp: t.Any) -> tuple[t.Any, ...] | None: + tp = strip_optional(tp) + if tp is Ref: + return () + if t.get_origin(tp) is not Ref: + return None + (arg,) = t.get_args(tp) or (t.Any,) + members = t.get_args(arg) if t.get_origin(arg) in _UNION_ORIGINS else (arg,) + out: list[t.Any] = [] + for member in members: + if isinstance(member, t.ForwardRef): + out.append(member.__forward_arg__) + elif isinstance(member, str | type): + out.append(member) + return tuple(out) + + +def analyze_ref(tp: t.Any, markers: tuple[t.Any, ...]) -> RefInfo | None: + named = tuple(m.model for m in markers if isinstance(m, To)) + inner = strip_optional(tp) + targets = _ref_targets(inner) + shape: t.Literal["one", "list", "dict"] = "one" + if targets is None: + elem = element_type(inner) + if elem is not None: + targets, shape = _ref_targets(elem), "list" + elif t.get_origin(inner) in (dict, t.Mapping, t.MutableMapping): + args = t.get_args(inner) + if len(args) == 2: + targets, shape = _ref_targets(args[1]), "dict" + if targets is None: + return None + targets = named or targets + if not targets: + raise ConfigurationError("a bare Ref needs a target: use Ref[Model] or the To('Model') marker") + return RefInfo(shape, targets) + + +# -- generic BSON-ready walker ---------------------------------------------- + + +def to_bson(value: t.Any) -> t.Any: + """Make ``value`` BSON-ready: models become dicts, references become ids. + + Leaf values the driver cannot encode natively (enums, dates, decimals, sets) + are left alone; the registry's codec options handle them at encode time. + """ + tp = type(value) + if tp in _LEAVES: + return value + if tp is list or tp is tuple: + return [to_bson(v) for v in value] + if tp is dict: + return {k: to_bson(v) for k, v in value.items()} + if tp is Ref: + return value.id + if _DOCUMENT is not None and isinstance(value, _DOCUMENT): + return t.cast(t.Any, value).id # a document inside another document is a reference, never embedded + codec = _codec_or_none(tp) + if codec is not None: + return codec.encode(value) + if isinstance(value, list | tuple | set | frozenset): + return [to_bson(v) for v in value] + if isinstance(value, dict): + return {k: to_bson(v) for k, v in value.items()} + return value + + +_DOCUMENT: type | None = None +"""Set to ``Document`` by ``mongospecs.model`` when it is imported.""" + +_LEAVES: frozenset[type] = frozenset({str, int, float, bool, type(None), bson.ObjectId, bytes}) + + +def ref_ids(value: t.Any, shape: str) -> t.Any: + """Reduce the value of a reference field to ids.""" + if value is None: + return None + if shape == "list": + return [id_of(v) for v in value] + if shape == "dict": + return {k: id_of(v) for k, v in value.items()} + return id_of(value) + + +# -- the codec interface ---------------------------------------------------- + +RawField = tuple[str, str, t.Any, bool] +"""``(attribute name, natively stored name, annotation, required)``""" + + +class Codec(abc.ABC): + """Adapter for one model library.""" + + name: t.ClassVar[str] + + def __init__(self) -> None: + self._fields: dict[type, dict[str, FieldInfo]] = {} + + # -- to implement ------------------------------------------------------ + + @abc.abstractmethod + def raw_fields(self, cls: type) -> list[RawField]: ... + + @abc.abstractmethod + def _decode(self, cls: type, doc: dict[str, t.Any], trusted: bool) -> t.Any: ... + + @abc.abstractmethod + def _encode(self, obj: t.Any) -> dict[str, t.Any]: + """Encode to a dict keyed by natively stored names. Must not share containers with ``obj``.""" + + @abc.abstractmethod + def validate_field(self, cls: type, name: str, value: t.Any) -> t.Any: + """Validate and coerce one field value with the model's own rules.""" + + @abc.abstractmethod + def _json_schema(self, cls: type) -> dict[str, t.Any]: ... + + # -- shared ------------------------------------------------------------ + + def fields(self, cls: type) -> dict[str, FieldInfo]: + cached = self._fields.get(cls) + if cached is not None: + return cached + from mongospecs.model import Document + + is_document = issubclass(cls, Document) + out: dict[str, FieldInfo] = {} + for name, native, annotation, required in self.raw_fields(cls): + _, markers = split_annotation(annotation) + policy = next((m for m in markers if isinstance(m, Policy)), DEFAULT_POLICY) + alias = next((m for m in markers if isinstance(m, Alias)), None) + backref = next((m for m in markers if isinstance(m, Backref)), None) + stored = alias.name if alias is not None else native + if is_document and name == "id" and stored == "id": + stored = "_id" + ref = None if backref is not None else analyze_ref(annotation, markers) + if backref is not None and required: + raise ConfigurationError(f"{cls.__name__}.{name}: a Backref field needs a default") + out[name] = FieldInfo(name, stored, annotation, markers, policy, required, ref, backref) + self._fields[cls] = out + return out + + def _renames(self, cls: type) -> tuple[dict[str, str], dict[str, str]]: + """Key translations the backend does not do itself: ``(stored->native, native->stored)``.""" + cache: dict[type, tuple[dict[str, str], dict[str, str]]] = self.__dict__.setdefault("_rn", {}) + hit = cache.get(cls) + if hit is None: + natives = {name: native for name, native, _, _ in self.raw_fields(cls)} + to_stored = { + natives[f.name]: f.stored for f in self.fields(cls).values() if natives[f.name] != f.stored + } + hit = ({v: k for k, v in to_stored.items()}, to_stored) + cache[cls] = hit + return hit + + def decode(self, cls: type, doc: t.Any, *, trusted: bool = False) -> t.Any: + """Build a model from a MongoDB document (a mapping or raw BSON bytes).""" + if isinstance(doc, bytes | bytearray | memoryview): + doc = bson.decode(bytes(doc)) + elif isinstance(doc, RawBSONDocument): + doc = bson.decode(doc.raw) + to_native, _ = self._renames(cls) + if to_native: + doc = {to_native.get(k, k): v for k, v in doc.items()} + return self._decode(cls, doc, trusted) + + def encode(self, obj: t.Any) -> dict[str, t.Any]: + """Encode a model to a BSON-ready dict keyed by stored names.""" + cls = type(obj) + doc = self._encode(obj) + _, to_stored = self._renames(cls) + if to_stored: + doc = {to_stored.get(k, k): v for k, v in doc.items()} + for f in self._special(cls): + if f.transient: + doc.pop(f.stored, None) + elif f.ref is not None and f.stored in doc: + doc[f.stored] = ref_ids(doc[f.stored], f.ref.shape) + return doc + + def _special(self, cls: type) -> tuple[FieldInfo, ...]: + cache: dict[type, tuple[FieldInfo, ...]] = self.__dict__.setdefault("_sp", {}) + hit = cache.get(cls) + if hit is None: + hit = tuple(f for f in self.fields(cls).values() if f.transient or f.ref is not None) + cache[cls] = hit + return hit + + def encode_value(self, value: t.Any) -> t.Any: + return to_bson(value) + + def json_schema(self, cls: type) -> dict[str, t.Any]: + """JSON Schema for the model, keyed by attribute names, root inlined, ``$defs`` kept.""" + schema = dict(self._json_schema(cls)) + defs = dict(schema.pop("$defs", {})) + ref = schema.get("$ref") + if isinstance(ref, str) and ref.startswith("#/$defs/"): + schema = dict(defs[ref.rsplit("/", 1)[1]]) + props = dict(schema.get("properties", {})) + by_stored = {f.stored: f.name for f in self.fields(cls).values()} + natives = {native: name for name, native, _, _ in self.raw_fields(cls)} + rename = {**by_stored, **natives} + schema["properties"] = {rename.get(k, k): v for k, v in props.items()} + schema["required"] = [rename.get(k, k) for k in schema.get("required", [])] + schema.setdefault("type", "object") + if defs: + schema["$defs"] = defs + return schema + + def clear_cache(self) -> None: + self._fields.clear() + self.__dict__.pop("_rn", None) + self.__dict__.pop("_sp", None) + + +def field_error(cls: type, name: str | None, message: str) -> ValidationError: + where = f"{cls.__name__}.{name}" if name else cls.__name__ + return ValidationError(f"{where}: {message}", [{"field": name, "message": message}]) + + +# -- codec lookup ----------------------------------------------------------- + +_CODECS: dict[type, Codec | None] = {} + + +def _codec_or_none(cls: type) -> Codec | None: + try: + return _CODECS[cls] + except KeyError: + pass + codec: Codec | None = getattr(cls, "__codec__", None) + if codec is None: + msgspec = sys.modules.get("msgspec") + pydantic = sys.modules.get("pydantic") + attrs = sys.modules.get("attrs") + if msgspec is not None and issubclass(cls, msgspec.Struct): + from mongospecs.msgspec import CODEC as codec + elif pydantic is not None and issubclass(cls, pydantic.BaseModel): + from mongospecs.pydantic import CODEC as codec + elif attrs is not None and attrs.has(cls): + from mongospecs.attrs import CODEC as codec + _CODECS[cls] = codec + return codec + + +def codec_for(cls: type) -> Codec: + """Return the codec for a model class.""" + codec = _codec_or_none(cls) + if codec is None: + raise ConfigurationError( + f"{cls.__name__} is not a msgspec Struct, a pydantic BaseModel or an attrs class" + ) + return codec diff --git a/packages/mongospecs/src/mongospecs/errors.py b/packages/mongospecs/src/mongospecs/errors.py new file mode 100644 index 0000000..9716f97 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/errors.py @@ -0,0 +1,202 @@ +"""One error hierarchy for mongospecs. + +Driver errors are wrapped, never subclassed or shadowed: the original pymongo +exception is always available as ``__cause__``. +""" + +from __future__ import annotations + +import typing as t +from contextlib import contextmanager + +from bson.errors import BSONError +from pymongo import errors as pe + +__all__ = [ + "BulkWriteFailed", + "ConfigurationError", + "ConnectionFailed", + "DocumentNotFound", + "DocumentTooLarge", + "DriverError", + "DuplicateKeyError", + "EncodingError", + "MongoSpecsError", + "NotBoundError", + "NotLoadedError", + "OperationFailed", + "OperationTimeout", + "PoolTimeout", + "QueryError", + "RegistryClosedError", + "StaleDocumentError", + "TransactionError", + "ValidationError", +] + + +class MongoSpecsError(Exception): + """Base class for every error raised by mongospecs.""" + + +class ConfigurationError(MongoSpecsError): + """A model, plugin or registry is declared incorrectly.""" + + +class NotBoundError(MongoSpecsError): + """A model was used before being bound to a registry.""" + + +class RegistryClosedError(MongoSpecsError): + """An operation was attempted on a registry that is not open.""" + + +class QueryError(MongoSpecsError, ValueError): + """A query names an unknown field, an uncastable value or an untrusted operator.""" + + +class ValidationError(MongoSpecsError, ValueError): + """Input failed model validation. + + ``errors`` is a list of ``{"field": ..., "message": ...}`` items; ``field`` + is ``None`` when the backend reports a document-level problem. + """ + + def __init__(self, message: str, errors: list[dict[str, t.Any]] | None = None) -> None: + super().__init__(message) + self.errors: list[dict[str, t.Any]] = errors or [{"field": None, "message": message}] + + +class EncodingError(MongoSpecsError, ValueError): + """A value could not be encoded to BSON.""" + + +class NotLoadedError(MongoSpecsError): + """A reference was dereferenced before being loaded with ``include()`` or ``fetch()``.""" + + +class DocumentNotFound(MongoSpecsError, LookupError): + """An acknowledged read or write matched no document.""" + + +class StaleDocumentError(MongoSpecsError): + """An optimistic-lock check failed: the document changed since it was loaded.""" + + +class DriverError(MongoSpecsError): + """A driver error with no more specific mapping.""" + + +class ConnectionFailed(DriverError): + """The server could not be reached.""" + + +class PoolTimeout(ConnectionFailed): + """No pooled connection became available in time.""" + + +class OperationTimeout(DriverError): + """The operation exceeded its time limit.""" + + +class OperationFailed(DriverError): + """The server rejected the operation.""" + + def __init__(self, message: str, *, code: int | None = None, details: t.Any = None) -> None: + super().__init__(message) + self.code = code + self.details = details + + +class DuplicateKeyError(OperationFailed): + """A unique index was violated. ``fields`` maps index keys to the offending values.""" + + def __init__( + self, + message: str, + *, + code: int | None = None, + details: t.Any = None, + fields: dict[str, t.Any] | None = None, + ) -> None: + super().__init__(message, code=code, details=details) + self.fields: dict[str, t.Any] = fields or {} + + +class DocumentTooLarge(OperationFailed): + """A document, or an aggregation result document, exceeded the BSON size limit.""" + + +class BulkWriteFailed(OperationFailed): + """A bulk write failed for some items. ``write_errors`` lists them by input index.""" + + def __init__(self, message: str, *, details: t.Any = None) -> None: + super().__init__(message, code=None, details=details) + self.write_errors: list[dict[str, t.Any]] = list((details or {}).get("writeErrors", [])) + + +class TransactionError(DriverError): + """A transaction could not be committed.""" + + +# Server codes for "object too large" style failures. +_TOO_LARGE_CODES = frozenset({10334, 17419, 17420, 16389}) + + +def _duplicate_fields(details: t.Any) -> dict[str, t.Any]: + if isinstance(details, dict): + value = details.get("keyValue") + if isinstance(value, dict): + return dict(value) + pattern = details.get("keyPattern") + if isinstance(pattern, dict): + return {k: None for k in pattern} + return {} + + +def translate(exc: BaseException) -> MongoSpecsError: + """Map a pymongo/bson exception to exactly one mongospecs error.""" + msg = str(exc) + if isinstance(exc, MongoSpecsError): + return exc + if isinstance(exc, pe.DuplicateKeyError): + return DuplicateKeyError( + msg, code=exc.code, details=exc.details, fields=_duplicate_fields(exc.details) + ) + if isinstance(exc, pe.BulkWriteError): + details = exc.details or {} + errors = details.get("writeErrors", []) + if errors and all(e.get("code") == 11000 for e in errors) and len(errors) == 1: + first = errors[0] + return DuplicateKeyError( + first.get("errmsg", msg), code=11000, details=details, fields=_duplicate_fields(first) + ) + return BulkWriteFailed(msg, details=details) + if isinstance(exc, pe.DocumentTooLarge): + return DocumentTooLarge(msg) + if isinstance(exc, pe.ExecutionTimeout | pe.NetworkTimeout | pe.WTimeoutError): + return OperationTimeout(msg) + if isinstance(exc, pe.WaitQueueTimeoutError): + return PoolTimeout(msg) + if isinstance(exc, pe.ConnectionFailure): + return ConnectionFailed(msg) + if isinstance(exc, pe.OperationFailure): + if exc.code in _TOO_LARGE_CODES: + return DocumentTooLarge(msg, code=exc.code, details=exc.details) + return OperationFailed(msg, code=exc.code, details=exc.details) + if isinstance(exc, pe.InvalidOperation): + return DriverError(msg) + if isinstance(exc, BSONError): + return EncodingError(msg) + return DriverError(msg) + + +@contextmanager +def wrap_errors() -> t.Iterator[None]: + """Translate driver errors raised inside the block.""" + try: + yield + except MongoSpecsError: + raise + except (pe.PyMongoError, BSONError) as exc: + raise translate(exc) from exc diff --git a/packages/mongospecs/src/mongospecs/expr.py b/packages/mongospecs/src/mongospecs/expr.py new file mode 100644 index 0000000..b22becf --- /dev/null +++ b/packages/mongospecs/src/mongospecs/expr.py @@ -0,0 +1,445 @@ +"""Filter expressions. + +``Q`` builds typed conditions: ``Q.title == "x"``, ``Q.views > 10``, +``Q.tags.in_(["a", "b"])``, combined with ``&``, ``|`` and ``~`` or with +``And``/``Or``/``Nor``. Conditions are immutable and are compiled to a MongoDB +filter against a model, which is where field names are checked and values are +cast to the field's type. + +Plain mappings are accepted as filters too, but they are treated as untrusted: +operator keys (``$gt``, ``$where`` ...) are rejected unless the mapping is +wrapped in :func:`trusted`. +""" + +from __future__ import annotations + +import re +import typing as t +from dataclasses import dataclass + +from mongospecs.errors import QueryError + +if t.TYPE_CHECKING: + from mongospecs.meta import ModelMeta + +__all__ = [ + "All", + "And", + "Condition", + "ElemMatch", + "Exists", + "Expr", + "Field", + "Group", + "In", + "Nor", + "Not", + "NotIn", + "Or", + "Q", + "Size", + "SortBy", + "SortKey", + "Trusted", + "Type", + "compile_filter", + "compile_sort", + "trusted", +] + +FilterInput = t.Union["Expr", t.Mapping[str, t.Any], None] + + +class Expr: + """Base class of filter expressions.""" + + __slots__ = () + + def __and__(self, other: Expr | t.Mapping[str, t.Any]) -> Group: + return Group("$and", (self, other)) + + def __or__(self, other: Expr | t.Mapping[str, t.Any]) -> Group: + return Group("$or", (self, other)) + + def __invert__(self) -> Expr: + return Group("$nor", (self,)) + + +@dataclass(frozen=True, slots=True, eq=False) +class Condition(Expr): + """``{path: {op: value}}``. ``path`` uses attribute names; ``None`` targets the element itself.""" + + path: str | None + op: str + value: t.Any + + def __invert__(self) -> Expr: + if self.op == "$eq": + return Condition(self.path, "$ne", self.value) + if self.op == "$ne": + return Condition(self.path, "$eq", self.value) + if self.op == "$in": + return Condition(self.path, "$nin", self.value) + if self.op == "$nin": + return Condition(self.path, "$in", self.value) + return Condition(self.path, "$not", self) + + +@dataclass(frozen=True, slots=True, eq=False) +class Group(Expr): + op: str + items: tuple[t.Any, ...] + + +@dataclass(frozen=True, slots=True, eq=False) +class Trusted(Expr): + """A raw MongoDB filter the application vouches for. Passed through verbatim.""" + + filter: t.Mapping[str, t.Any] + + +def trusted(filter: t.Mapping[str, t.Any]) -> Trusted: + """Mark a raw filter as written by the application, not built from user input.""" + return Trusted(filter) + + +@dataclass(frozen=True, slots=True) +class SortKey: + path: str + direction: int + + +def _values(values: t.Any, name: str) -> list[t.Any]: + if isinstance(values, str | bytes | t.Mapping) or not isinstance(values, t.Iterable): + raise QueryError(f"{name}() needs a list of values, got {type(values).__name__}") + return list(values) + + +class Field: + """A path into a document, produced by ``Q.name`` or ``Q["name"]``.""" + + __slots__ = ("_path",) + _path: str + + def __init__(self, path: str) -> None: + object.__setattr__(self, "_path", path) + + def __setattr__(self, name: str, value: t.Any) -> None: + raise AttributeError("Q fields are immutable") + + def __getattr__(self, name: str) -> Field: + if name.startswith("__"): + raise AttributeError(name) + return Field(f"{self._path}.{name}") + + def __getitem__(self, name: str | int) -> Field: + return Field(f"{self._path}.{name}") + + __hash__ = None # type: ignore[assignment] + + def __eq__(self, other: t.Any) -> Condition: # type: ignore[override] + return Condition(self._path, "$eq", other) + + def __ne__(self, other: t.Any) -> Condition: # type: ignore[override] + return Condition(self._path, "$ne", other) + + def __gt__(self, other: t.Any) -> Condition: + return Condition(self._path, "$gt", other) + + def __ge__(self, other: t.Any) -> Condition: + return Condition(self._path, "$gte", other) + + def __lt__(self, other: t.Any) -> Condition: + return Condition(self._path, "$lt", other) + + def __le__(self, other: t.Any) -> Condition: + return Condition(self._path, "$lte", other) + + def in_(self, values: t.Iterable[t.Any]) -> Condition: + return Condition(self._path, "$in", _values(values, "in_")) + + def nin(self, values: t.Iterable[t.Any]) -> Condition: + return Condition(self._path, "$nin", _values(values, "nin")) + + def all(self, values: t.Iterable[t.Any]) -> Condition: + return Condition(self._path, "$all", _values(values, "all")) + + def exists(self, value: bool = True) -> Condition: + return Condition(self._path, "$exists", bool(value)) + + def size(self, value: int) -> Condition: + return Condition(self._path, "$size", int(value)) + + def type_(self, value: int | str) -> Condition: + return Condition(self._path, "$type", value) + + def prefix(self, value: str, *, ignore_case: bool = False) -> Condition: + """Literal starts-with match (the value is escaped).""" + return Condition(self._path, "$regex", ("^" + re.escape(value), "i" if ignore_case else "")) + + def contains(self, value: str, *, ignore_case: bool = False) -> Condition: + """Literal substring match (the value is escaped).""" + return Condition(self._path, "$regex", (re.escape(value), "i" if ignore_case else "")) + + def regex(self, pattern: str, options: str = "") -> Condition: + """Regular-expression match. The pattern is used as written; do not pass user input.""" + return Condition(self._path, "$regex", (pattern, options)) + + def elem_match(self, *conditions: Expr | t.Mapping[str, t.Any]) -> Condition: + return Condition(self._path, "$elemMatch", tuple(conditions)) + + def asc(self) -> SortKey: + return SortKey(self._path, 1) + + def desc(self) -> SortKey: + return SortKey(self._path, -1) + + def __repr__(self) -> str: + return f"Q.{self._path}" + + +class _QMeta(type): + def __getattr__(cls, name: str) -> Field: + if name.startswith("__"): + raise AttributeError(name) + return Field(name) + + def __getitem__(cls, name: str) -> Field: + return Field(name) + + # Comparisons on bare ``Q`` target the array element itself, for use inside + # ``elem_match`` on arrays of scalars: ``Q.scores.elem_match(Q > 80)``. + def __eq__(cls, other: t.Any) -> Condition: # type: ignore[override] + return Condition(None, "$eq", other) + + def __ne__(cls, other: t.Any) -> Condition: # type: ignore[override] + return Condition(None, "$ne", other) + + def __gt__(cls, other: t.Any) -> Condition: + return Condition(None, "$gt", other) + + def __ge__(cls, other: t.Any) -> Condition: + return Condition(None, "$gte", other) + + def __lt__(cls, other: t.Any) -> Condition: + return Condition(None, "$lt", other) + + def __le__(cls, other: t.Any) -> Condition: + return Condition(None, "$lte", other) + + __hash__ = type.__hash__ + + +class Q(metaclass=_QMeta): + """Entry point for building conditions: ``Q.hit_points > 100``.""" + + +# -- functional helpers (kept from v1) --------------------------------------- + + +def In(q: Field, values: t.Iterable[t.Any]) -> Condition: + return q.in_(values) + + +def NotIn(q: Field, values: t.Iterable[t.Any]) -> Condition: + return q.nin(values) + + +def All(q: Field, values: t.Iterable[t.Any]) -> Condition: + return q.all(values) + + +def Exists(q: Field, value: bool = True) -> Condition: + return q.exists(value) + + +def Size(q: Field, value: int) -> Condition: + return q.size(value) + + +def Type(q: Field, value: int | str) -> Condition: + return q.type_(value) + + +def ElemMatch(q: Field, *conditions: Expr | t.Mapping[str, t.Any]) -> Condition: + return q.elem_match(*conditions) + + +def Not(condition: Expr) -> Expr: + return ~condition + + +def And(*conditions: Expr | t.Mapping[str, t.Any]) -> Group: + return Group("$and", conditions) + + +def Or(*conditions: Expr | t.Mapping[str, t.Any]) -> Group: + return Group("$or", conditions) + + +def Nor(*conditions: Expr | t.Mapping[str, t.Any]) -> Group: + return Group("$nor", conditions) + + +SortInput = SortKey | Field | str | tuple[str, int] + + +def SortBy(*keys: SortInput) -> list[SortKey]: + """Normalise sort keys: ``SortBy(Q.dob.desc(), Q.name)`` or ``SortBy("-dob", "name")``.""" + out: list[SortKey] = [] + for key in keys: + if isinstance(key, SortKey): + out.append(key) + elif isinstance(key, Field): + out.append(SortKey(key._path, 1)) + elif isinstance(key, str): + out.append(SortKey(key[1:], -1) if key.startswith("-") else SortKey(key.lstrip("+"), 1)) + elif isinstance(key, tuple) and len(key) == 2: + out.append(SortKey(key[0], -1 if key[1] in (-1, "desc") else 1)) + else: + raise QueryError(f"cannot sort by {key!r}") + return out + + +# -- compilation -------------------------------------------------------------- + +_VALUE_OPS = frozenset({"$eq", "$ne", "$gt", "$gte", "$lt", "$lte"}) +_LIST_OPS = frozenset({"$in", "$nin", "$all"}) + + +def _has_operator(value: t.Any) -> bool: + if isinstance(value, t.Mapping): + return any(isinstance(k, str) and k.startswith("$") for k in value) or any( + _has_operator(v) for v in value.values() + ) + if isinstance(value, list | tuple): + return any(_has_operator(v) for v in value) + return False + + +def _literal(value: t.Any) -> t.Any: + """A compiled value can never be read as an operator document.""" + if _has_operator(value): + raise QueryError( + "filter values may not contain MongoDB operators; build the condition with Q " + "or wrap a raw filter in trusted()" + ) + return value + + +def _compile_condition(cond: Condition, meta: ModelMeta | None) -> dict[str, t.Any]: + op, value = cond.op, cond.value + path = cond.path + stored = meta.stored_path(path) if (meta is not None and path is not None) else path + + if op == "$not": + inner = _compile_condition(t.cast(Condition, value), meta) + if stored is None: + return {"$not": inner} + body = inner[stored] + if not isinstance(body, dict) or not any(k.startswith("$") for k in body): + body = {"$eq": body} + return {stored: {"$not": body}} + + if op in _VALUE_OPS: + compiled = _literal( + meta.cast(path, value) if (meta is not None and path is not None) else _plain(value) + ) + elif op in _LIST_OPS: + if isinstance(value, str | bytes) or not isinstance(value, t.Iterable): + raise QueryError(f"{op} needs a list of values") + compiled = [ + _literal(meta.cast(path, v) if (meta is not None and path is not None) else _plain(v)) + for v in value + ] + elif op == "$regex": + pattern, options = value + if not isinstance(pattern, str): + raise QueryError("a regex pattern must be a string") + body = {"$regex": pattern, **({"$options": options} if options else {})} + return body if stored is None else {stored: body} + elif op == "$elemMatch": + inner_meta = None # element shape is not modelled; conditions inside are not cast + merged: dict[str, t.Any] = {} + for item in value: + for k, v in compile_filter(item, inner_meta).items(): + if k in merged and isinstance(merged[k], dict) and isinstance(v, dict): + merged[k] = {**merged[k], **v} + else: + merged[k] = v + compiled = merged + elif op in ("$exists", "$size", "$type"): + compiled = value + else: + raise QueryError(f"unsupported operator {op}") + + if stored is None: + return {op: compiled} + if op == "$eq": + if isinstance(compiled, dict): + return {stored: {"$eq": compiled}} + return {stored: compiled} + return {stored: {op: compiled}} + + +def _plain(value: t.Any) -> t.Any: + from mongospecs.codec import to_bson + from mongospecs.refs import id_of + + return to_bson(id_of(value)) + + +def compile_filter(expr: t.Any, meta: ModelMeta | None = None) -> dict[str, t.Any]: + """Compile an expression (or an untrusted mapping) to a MongoDB filter.""" + if expr is None: + return {} + if isinstance(expr, Trusted): + return dict(expr.filter) + if isinstance(expr, Condition): + return _compile_condition(expr, meta) + if isinstance(expr, Group): + parts = [compile_filter(item, meta) for item in expr.items] + if expr.op == "$and": + flat: list[dict[str, t.Any]] = [] + for part in parts: + if set(part) == {"$and"}: + flat.extend(part["$and"]) + elif part: + flat.append(part) + return and_filters(*flat) + return {expr.op: parts} + if isinstance(expr, t.Mapping): + out: dict[str, t.Any] = {} + for key, value in expr.items(): + if not isinstance(key, str) or key.startswith("$"): + raise QueryError( + f"raw filter key {key!r} is a MongoDB operator; build the condition with Q " + "or wrap the filter in trusted()" + ) + out.update(_compile_condition(Condition(key, "$eq", value), meta)) + return out + if isinstance(expr, Field): + raise QueryError(f"{expr!r} is a field, not a condition; compare it to a value") + raise QueryError(f"cannot use {type(expr).__name__} as a filter") + + +def and_filters(*filters: t.Mapping[str, t.Any] | None) -> dict[str, t.Any]: + """AND filters together, merging when keys do not collide.""" + parts = [dict(f) for f in filters if f] + if not parts: + return {} + if len(parts) == 1: + return parts[0] + merged: dict[str, t.Any] = {} + for part in parts: + if any(k in merged for k in part): + return {"$and": parts} + merged.update(part) + return merged + + +def compile_sort(keys: t.Iterable[SortInput], meta: ModelMeta | None = None) -> list[tuple[str, int]]: + out: list[tuple[str, int]] = [] + for key in SortBy(*keys): + out.append((meta.stored_path(key.path) if meta is not None else key.path, key.direction)) + return out diff --git a/packages/mongospecs/src/mongospecs/hooks.py b/packages/mongospecs/src/mongospecs/hooks.py new file mode 100644 index 0000000..a6300d9 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/hooks.py @@ -0,0 +1,114 @@ +"""Hooks and plugins. + +Four kinds of contribution, matching Mongoose's four kinds of middleware: + +======================== ============== =========================================== +kind Mongoose name here +======================== ============== =========================================== +document hooks document ``before``/``after`` for insert, save, delete +command/filter rewriting query ``on_insert``, ``on_update``, ``on_filter``, + ``on_delete`` +pipeline rewriting aggregate ``on_pipeline`` +bulk hooks model ``before_bulk``/``after_bulk`` +======================== ============== =========================================== + +Document hooks run only for document-level operations. Filter-based writes +(``Model.find(...).update(...)``, ``.delete()``) never load documents, so only +the command, filter and pipeline rewriters apply to them. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +if t.TYPE_CHECKING: + from mongospecs.markers import Index + from mongospecs.meta import ModelMeta + +__all__ = ["DocumentEvent", "OpCtx", "Plugin"] + +DocumentEvent = t.Literal["insert", "save", "delete"] +DOCUMENT_EVENTS: tuple[str, ...] = ("insert", "save", "delete") + +Hook = t.Callable[..., t.Awaitable[None]] + + +@dataclass(slots=True) +class OpCtx: + """What an operation is doing; passed to every hook and rewriter.""" + + meta: ModelMeta + op: str + """``find``, ``count``, ``insert``, ``update``, ``delete``, ``aggregate``, ``bulk`` ...""" + session: t.Any = None + """The ``Tx`` or driver session the operation runs in, if any.""" + manager: t.Any = None + many: bool = False + extra: dict[str, t.Any] = field(default_factory=dict) + + @property + def model(self) -> type: + return self.meta.model + + @property + def registry(self) -> t.Any: + return self.manager.registry if self.manager is not None else None + + +class Plugin: + """Base class for plugins. Override only what you need; every method has a no-op default. + + A plugin attaches to a model through a field marker (for the built-in + ones), through ``SpecConfig(plugins=[...])``, or to every model of a + registry through ``Registry(plugins=[...])``. + """ + + name: str = "" + """Used by ``Query.unscoped("name")`` to switch this plugin's scope off.""" + + def setup(self, meta: ModelMeta) -> None: + """Validate configuration. Called once per model, when its metadata is built.""" + + # -- command and filter rewriting (query middleware) --------------------- + + def on_insert(self, ctx: OpCtx, doc: dict[str, t.Any]) -> None: + """Mutate an encoded document just before it is inserted.""" + + def on_update(self, ctx: OpCtx, flt: dict[str, t.Any], update: dict[str, t.Any]) -> None: + """Mutate the filter and update document of any update.""" + + def on_filter(self, ctx: OpCtx, flt: dict[str, t.Any]) -> dict[str, t.Any] | None: + """Return a scope to AND into every read, update and delete filter.""" + return None + + def on_delete(self, ctx: OpCtx, flt: dict[str, t.Any]) -> dict[str, t.Any] | None: + """Return an update document to turn a delete into an update (soft delete).""" + return None + + # -- pipeline rewriting (aggregate middleware) --------------------------- + + def on_pipeline(self, ctx: OpCtx, pipeline: list[dict[str, t.Any]]) -> list[dict[str, t.Any]]: + return pipeline + + # -- document hooks ------------------------------------------------------- + + async def before(self, ctx: OpCtx, event: str, doc: t.Any) -> None: + """Called before a document-level insert, save or delete.""" + + async def after(self, ctx: OpCtx, event: str, doc: t.Any) -> None: + """Called after a document-level insert, save or delete succeeded.""" + + def applied(self, ctx: OpCtx, doc: t.Any, written: dict[str, t.Any]) -> None: + """Mirror values this plugin wrote (timestamps, revision) onto the in-memory document.""" + + # -- bulk hooks (model middleware) ---------------------------------------- + + async def before_bulk(self, ctx: OpCtx, operations: list[t.Any]) -> None: ... + + async def after_bulk(self, ctx: OpCtx, operations: list[t.Any]) -> None: ... + + # -- indexes ------------------------------------------------------------- + + def indexes(self, meta: ModelMeta) -> list[Index]: + return [] diff --git a/packages/mongospecs/src/mongospecs/indexes.py b/packages/mongospecs/src/mongospecs/indexes.py new file mode 100644 index 0000000..ca0bfdc --- /dev/null +++ b/packages/mongospecs/src/mongospecs/indexes.py @@ -0,0 +1,380 @@ +"""Index reconciliation. + +Nothing is built implicitly. ``plan_indexes`` compares declared indexes with +what exists and returns typed actions; ``apply_indexes`` carries them out, and +only performs destructive ones (drop, recreate) when ``drop=True``. +""" + +from __future__ import annotations + +import enum +import typing as t +from dataclasses import dataclass, field + +from mongospecs.errors import wrap_errors +from mongospecs.markers import Index +from mongospecs.meta import meta_of +from mongospecs.refs import OID_PATTERN + +if t.TYPE_CHECKING: + from mongospecs.manager import Manager + from mongospecs.registry import Registry + +__all__ = ["Action", "IndexAction", "IndexPlan", "apply_indexes", "mongo_json_schema", "plan_indexes"] + +_IGNORED_OPTIONS = frozenset( + { + "v", + "key", + "ns", + "background", + "weights", + "default_language", + "language_override", + "textIndexVersion", + "2dsphereIndexVersion", + } +) + + +class Action(enum.StrEnum): + CREATE = "create" + """Declared, missing in the database.""" + RETAIN = "retain" + """Declared and present with the same definition.""" + RECREATE = "recreate" + """Same name, different definition. Destructive.""" + CONFLICT = "conflict" + """An index on the same keys exists under another name; it must be dropped first. Destructive.""" + DROP = "drop" + """Present in the database, not declared. Destructive.""" + + +@dataclass(frozen=True, slots=True) +class IndexAction: + action: Action + model: type + collection: str + name: str + keys: tuple[tuple[str, t.Any], ...] = () + options: t.Mapping[str, t.Any] = field(default_factory=dict) + existing: str | None = None + """Name of the existing index involved, for CONFLICT.""" + + @property + def destructive(self) -> bool: + return self.action in (Action.RECREATE, Action.CONFLICT, Action.DROP) + + def __str__(self) -> str: + keys = ", ".join(f"{k}:{d}" for k, d in self.keys) + return f"{self.action.value.upper():8} {self.collection}.{self.name} ({keys})" + + +@dataclass(slots=True) +class IndexPlan: + actions: list[IndexAction] = field(default_factory=list) + advisories: list[str] = field(default_factory=list) + """Non-blocking findings, such as a reverse relation whose foreign key has no index.""" + + def __iter__(self) -> t.Iterator[IndexAction]: + return iter(self.actions) + + def of(self, *kinds: Action) -> list[IndexAction]: + return [a for a in self.actions if a.action in kinds] + + @property + def in_sync(self) -> bool: + return all(a.action is Action.RETAIN for a in self.actions) + + +def _default_name(keys: t.Sequence[tuple[str, t.Any]]) -> str: + return "_".join(f"{key}_{direction}" for key, direction in keys) + + +def _declared(manager: Manager[t.Any]) -> list[tuple[str, tuple[tuple[str, t.Any], ...], dict[str, t.Any]]]: + meta = manager.meta + indexes: list[Index] = [*meta.config.indexes] + for plugin in manager.plugins: + indexes.extend(plugin.indexes(meta)) + out = [] + for index in indexes: + keys = tuple((meta.stored_path(name), direction) for name, direction in index.keys) + options: dict[str, t.Any] = dict(index.options) + if index.unique: + options["unique"] = True + if index.sparse: + options["sparse"] = True + if index.hidden: + options["hidden"] = True + if index.ttl is not None: + options["expireAfterSeconds"] = index.ttl + if index.partial is not None: + options["partialFilterExpression"] = dict(index.partial) + if index.collation is not None: + options["collation"] = dict(index.collation) + out.append((index.name or _default_name(keys), keys, options)) + return out + + +def _key_signature( + keys: t.Iterable[tuple[str, t.Any]], weights: t.Mapping[str, t.Any] | None = None +) -> t.Any: + """Comparable form of an index key. A materialised text index (``_fts``/``_ftsx``) is + compared by its weighted fields, so it equals the declaration that created it.""" + plain: list[tuple[str, t.Any]] = [] + text: set[str] = set(weights or ()) + for key, direction in keys: + if key in ("_fts", "_ftsx"): + continue + if direction == "text": + text.add(key) + else: + plain.append((key, int(direction) if isinstance(direction, int | float) else direction)) + return (tuple(plain), frozenset(text)) + + +def _same_options(declared: t.Mapping[str, t.Any], existing: t.Mapping[str, t.Any]) -> bool: + have = {k: v for k, v in existing.items() if k not in _IGNORED_OPTIONS} + want = dict(declared) + if set(have) != set(want): + return False + for key, value in want.items(): + other = have[key] + if key == "collation": + # The server fills in collation defaults; compare what was declared. + if any(other.get(k) != v for k, v in value.items()): + return False + elif isinstance(value, int | float) and isinstance(other, int | float): + if float(value) != float(other): + return False + elif value != other: + return False + return True + + +async def plan_indexes(registry: Registry, *models: type) -> IndexPlan: + """Compare declared and existing indexes for the given models (default: every bound model).""" + plan = IndexPlan() + targets = models or registry.models + seen: set[tuple[str | None, str]] = set() + for model in targets: + manager: Manager[t.Any] = registry.manager(model) + key = (manager.db_name, manager.meta.collection) + declared = _declared(manager) + with wrap_errors(): + existing = await manager.collection.index_information() + existing.pop("_id_", None) + signatures = { + name: _key_signature(info["key"], info.get("weights")) for name, info in existing.items() + } + matched: set[str] = set() + for name, keys, options in declared: + sig = _key_signature(keys) + if name in existing: + matched.add(name) + same = signatures[name] == sig and _same_options(options, existing[name]) + plan.actions.append( + IndexAction( + Action.RETAIN if same else Action.RECREATE, model, key[1], name, keys, options + ) + ) + continue + twin = next( + ( + n + for n, s in signatures.items() + if s == sig and n not in matched and _same_options(options, existing[n]) + ), + None, + ) + if twin is not None and not sig[1]: + matched.add(twin) + plan.actions.append( + IndexAction(Action.CONFLICT, model, key[1], name, keys, options, existing=twin) + ) + elif twin is not None: + # A text index named by the server: same definition, keep it. + matched.add(twin) + plan.actions.append(IndexAction(Action.RETAIN, model, key[1], twin, keys, options)) + else: + plan.actions.append(IndexAction(Action.CREATE, model, key[1], name, keys, options)) + if key not in seen: + # Indexes of other models sharing the collection (discriminators) are theirs to declare. + shared = { + n + for other in targets + if other is not model and registry.manager(other).meta.collection == key[1] + for n, _, _ in _declared(registry.manager(other)) + } + for name, info in existing.items(): + if name not in matched and name not in shared: + plan.actions.append( + IndexAction(Action.DROP, model, key[1], name, tuple(tuple(k) for k in info["key"])) + ) + seen.add(key) + + # Reverse relations are served by a query on the foreign key: it should be indexed. + for name, info in manager.meta.backrefs.items(): + assert info.backref is not None + (target_meta,) = manager.meta.ref_targets(name) + fk = target_meta.stored_path(info.backref.field) + target_declared = _declared(registry.manager(target_meta.model)) + if not any(keys and keys[0][0] == fk for _, keys, _ in target_declared): + plan.advisories.append( + f"{model.__name__}.{name} is loaded by querying {target_meta.model.__name__}.{info.backref.field}, " + f"which has no index; declare Index({info.backref.field!r}) on {target_meta.model.__name__}" + ) + return plan + + +async def apply_indexes( + registry: Registry, + plan: IndexPlan | None = None, + *, + drop: bool = False, +) -> list[IndexAction]: + """Carry out a plan. Destructive actions run only with ``drop=True``. Returns what was done. + + Also creates collections that declare ``collection_options`` and installs + ``$jsonSchema`` validators for models that opted in. + """ + await _ensure_collections(registry) + if plan is None: + plan = await plan_indexes(registry) + done: list[IndexAction] = [] + for action in plan.actions: + if action.action is Action.RETAIN or (action.destructive and not drop): + continue + collection = registry.manager(action.model).collection + with wrap_errors(): + if action.action is Action.DROP: + await collection.drop_index(action.name) + else: + if action.action is Action.RECREATE: + await collection.drop_index(action.name) + elif action.action is Action.CONFLICT and action.existing: + await collection.drop_index(action.existing) + await collection.create_index(list(action.keys), name=action.name, **action.options) + done.append(action) + return done + + +async def _ensure_collections(registry: Registry) -> None: + for model in registry.models: + meta = meta_of(model) + options = dict(meta.config.collection_options) + level = meta.config.json_schema_validation + if not options and level == "off": + continue + manager: Manager[t.Any] = registry.manager(model) + db = registry.database(manager.db_name) + with wrap_errors(): + exists = meta.collection in await db.list_collection_names() + if not exists: + await db.create_collection(meta.collection, **options) + if level != "off": + await db.command( + "collMod", + meta.collection, + validator={"$jsonSchema": mongo_json_schema(model)}, + validationLevel=level, + ) + + +# -- JSON Schema -> MongoDB $jsonSchema --------------------------------------------- + +_BSON_TYPES: dict[str, t.Any] = { + "string": "string", + "boolean": "bool", + "object": "object", + "array": "array", + "null": "null", + "integer": ["int", "long"], + "number": ["double", "int", "long", "decimal"], +} +_KEPT = frozenset( + { + "enum", + "minimum", + "maximum", + "exclusiveMinimum", + "exclusiveMaximum", + "multipleOf", + "minLength", + "maxLength", + "pattern", + "minItems", + "maxItems", + "uniqueItems", + "required", + "title", + "description", + "additionalProperties", + } +) + + +def _convert(node: t.Any, defs: t.Mapping[str, t.Any], depth: int = 0) -> t.Any: + if not isinstance(node, dict): + return node + ref = node.get("$ref") + if isinstance(ref, str): + if depth > 8: + return {} + return _convert(defs.get(ref.rsplit("/", 1)[-1], {}), defs, depth + 1) + if node.get("type") == "string": + if node.get("pattern") == OID_PATTERN: + return {"bsonType": "objectId"} + fmt = node.get("format") + if fmt == "date-time": + return {"bsonType": "date"} + if fmt == "uuid" or node.get("contentEncoding") == "base64" or fmt == "binary": + return {"bsonType": "binData"} + out: dict[str, t.Any] = {} + for key, value in node.items(): + if key == "type": + types = value if isinstance(value, list) else [value] + flat: list[t.Any] = [] + for item in types: + mapped = _BSON_TYPES.get(item, item) + flat.extend(mapped if isinstance(mapped, list) else [mapped]) + out["bsonType"] = flat[0] if len(flat) == 1 else flat + elif key == "const": + out["enum"] = [value] + elif key in ("anyOf", "oneOf", "allOf"): + out[key] = [_convert(v, defs, depth + 1) for v in value] + elif key == "not" or key == "items": + out[key] = _convert(value, defs, depth + 1) + elif key == "properties": + out[key] = {name: _convert(v, defs, depth + 1) for name, v in value.items()} + elif key in _KEPT: + if key == "additionalProperties" and isinstance(value, dict): + out[key] = _convert(value, defs, depth + 1) + elif key in ("exclusiveMinimum", "exclusiveMaximum") and not isinstance(value, bool): + # Draft 4 (which MongoDB implements) spells these as a bound plus a flag. + out["minimum" if key == "exclusiveMinimum" else "maximum"] = value + out[key] = True + else: + out[key] = value + return out + + +def mongo_json_schema(model: type) -> dict[str, t.Any]: + """A MongoDB ``$jsonSchema`` validator derived from the model, keyed by stored names.""" + meta = meta_of(model) + schema = meta.codec.json_schema(model) + defs = schema.get("$defs", {}) + converted = _convert({k: v for k, v in schema.items() if k != "$defs"}, defs) + stored = {f.name: f.stored for f in meta.fields.values()} + transient = {f.name for f in meta.fields.values() if f.transient} + props = {stored.get(k, k): v for k, v in converted.get("properties", {}).items() if k not in transient} + props.pop("_id", None) + converted["properties"] = props + required = [stored.get(k, k) for k in converted.get("required", []) if k not in transient and k != "id"] + if required: + converted["required"] = required + else: + converted.pop("required", None) + converted["bsonType"] = "object" + converted.pop("additionalProperties", None) + return t.cast("dict[str, t.Any]", converted) diff --git a/packages/mongospecs/src/mongospecs/manager.py b/packages/mongospecs/src/mongospecs/manager.py new file mode 100644 index 0000000..9087dc9 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/manager.py @@ -0,0 +1,761 @@ +"""The manager performs every database operation for one model on one registry. + +``Document`` class methods (``Article.find``, ``Article.update`` ...) delegate to +the model's default manager. ``Article.using(db="tenant_a")`` returns another +manager for the same model. + +This is the single place where filters are scoped and commands are rewritten by +plugins, so a scope (soft delete, tenant, discriminator) cannot be missed on +one code path. +""" + +from __future__ import annotations + +import typing as t + +from pymongo import ReturnDocument + +from mongospecs import tracking +from mongospecs.errors import ( + DocumentNotFound, + DuplicateKeyError, + OperationFailed, + QueryError, + StaleDocumentError, +) +from mongospecs.expr import Expr, Trusted, and_filters, compile_filter +from mongospecs.hooks import OpCtx, Plugin +from mongospecs.markers import CreatedAt, DeletedAt, Revision, UpdatedAt +from mongospecs.meta import ModelMeta, meta_of +from mongospecs.registry import Registry, Tx, driver_session, op_scope +from mongospecs.updates import UpdateOp, compile_update + +if t.TYPE_CHECKING: + from pymongo.asynchronous.collection import AsyncCollection + + from mongospecs.query import Query + +__all__ = ["BulkResult", "Manager"] + +T = t.TypeVar("T") + +_MANAGED_MARKERS = (CreatedAt, UpdatedAt, DeletedAt, Revision) + + +class BulkResult(t.Generic[T]): + """Outcome of ``insert_many``: the documents that were written and per-item failures.""" + + __slots__ = ("errors", "inserted") + + def __init__(self, inserted: list[T], errors: list[tuple[int, Exception]]) -> None: + self.inserted = inserted + self.errors = errors + """``(input index, error)`` for every document that was not written.""" + + def __len__(self) -> int: + return len(self.inserted) + + def __iter__(self) -> t.Iterator[T]: + return iter(self.inserted) + + def __repr__(self) -> str: + return f"BulkResult(inserted={len(self.inserted)}, errors={len(self.errors)})" + + +class Manager(t.Generic[T]): + def __init__( + self, + model: type[T], + registry: Registry, + *, + db: str | None = None, + collection_options: t.Mapping[str, t.Any] | None = None, + ) -> None: + self.model = model + self.meta: ModelMeta = meta_of(model) + self.registry = registry + self.db_name = db + self._collection_options = dict(collection_options or {}) + self.plugins: tuple[Plugin, ...] = (*registry.plugins, *self.meta.plugins) + for plugin in registry.plugins: + plugin.setup(self.meta) + self.managed_attrs: tuple[str, ...] = ( + "id", + *(f.name for f in self.meta.fields.values() if any(f.has(m) for m in _MANAGED_MARKERS)), + ) + self._collection: AsyncCollection[dict[str, t.Any]] | None = None + self._cascades: list[t.Any] | None = None + + # -- plumbing -------------------------------------------------------------- + + @property + def collection(self) -> AsyncCollection[dict[str, t.Any]]: + """The raw driver collection (with the registry's codec options).""" + if self._collection is None or not self.registry.is_open: + cfg = self.meta.config + options = { + "read_preference": cfg.read_preference, + "read_concern": cfg.read_concern, + "write_concern": cfg.write_concern, + **self._collection_options, + } + options = {k: v for k, v in options.items() if v is not None} + self._collection = self.registry.database(self.db_name).get_collection( + self.meta.collection, **options + ) + return self._collection + + def using( + self, + *, + db: str | None = None, + read_preference: t.Any = None, + read_concern: t.Any = None, + write_concern: t.Any = None, + ) -> Manager[T]: + """A manager for the same model on another database or with other driver options.""" + options = { + **self._collection_options, + **{ + k: v + for k, v in ( + ("read_preference", read_preference), + ("read_concern", read_concern), + ("write_concern", write_concern), + ) + if v is not None + }, + } + return Manager(self.model, self.registry, db=db or self.db_name, collection_options=options) + + def _op(self, op: str) -> op_scope: + return op_scope(self.registry, self.model.__name__, op) + + def ctx(self, op: str, session: t.Any = None, *, many: bool = False, **extra: t.Any) -> OpCtx: + return OpCtx(self.meta, op, session, self, many, extra) + + def _kw(self, session: t.Any, **extra: t.Any) -> dict[str, t.Any]: + kw = {k: v for k, v in extra.items() if v is not None} + s = driver_session(session) + if s is not None: + kw["session"] = s + return kw + + def _read_kw(self, session: t.Any, options: t.Mapping[str, t.Any] | None = None) -> dict[str, t.Any]: + kw = self._kw(session, **(options or {})) + if "max_time_ms" not in kw: + default = self.meta.config.max_time_ms or self.registry.max_time_ms + if default is not None: + kw["max_time_ms"] = default + if "collation" not in kw and self.meta.config.collation is not None: + kw["collation"] = self.meta.config.collation + return kw + + def scoped( + self, + ctx: OpCtx, + flt: t.Mapping[str, t.Any] | None, + unscoped: t.Collection[str] | bool = False, + ) -> dict[str, t.Any]: + """AND the discriminator filter and every plugin scope into ``flt``.""" + parts: list[t.Mapping[str, t.Any] | None] = [flt, self.meta.discriminator_filter()] + if unscoped is not True: + skip = unscoped or () + for plugin in self.plugins: + if plugin.name and plugin.name in skip: + continue + parts.append(plugin.on_filter(ctx, dict(flt or {}))) + return and_filters(*parts) + + def compile(self, target: t.Any) -> dict[str, t.Any]: + """Compile an id, an expression or an untrusted mapping to a filter.""" + if target is None: + return {} + if isinstance(target, Expr | t.Mapping): + return compile_filter(target, self.meta) + return {"_id": self.meta.cast("id", target)} + + def default_projection(self) -> dict[str, int] | None: + return {key: 0 for key in self.meta.deferred} or None + + def _shard_filter(self, snapshot: t.Mapping[str, t.Any]) -> dict[str, t.Any]: + out: dict[str, t.Any] = {} + for name in self.meta.config.shard_key: + stored = self.meta.stored_path(name) + if stored != "_id" and stored in snapshot: + out[stored] = snapshot[stored] + return out + + # -- materialising --------------------------------------------------------- + + def hydrate(self, raw: t.Mapping[str, t.Any], *, partial: frozenset[str] | None = None) -> T: + """Build a tracked document from a raw MongoDB document.""" + doc = self.meta.decode(raw) + tracking.track(doc, meta_of(type(doc)).encode(doc), self, partial) + return t.cast(T, doc) + + def _is_tracked_here(self, doc: t.Any) -> tracking.DocState | None: + return tracking.state_of(doc) + + # -- reads ------------------------------------------------------------------- + + def find(self, *conditions: t.Any, session: t.Any = None) -> Query[T]: + from mongospecs.query import Query + + return Query(self, conditions=tuple(c for c in conditions if c is not None), session=session) + + async def get(self, id: t.Any, *, session: t.Any = None) -> T | None: + """The document with this id, or ``None``.""" + return await self.find(Trusted({"_id": self.meta.cast("id", id)}), session=session).first() + + async def count(self, *conditions: t.Any, session: t.Any = None) -> int: + return await self.find(*conditions, session=session).count() + + async def exists(self, *conditions: t.Any, session: t.Any = None) -> bool: + return await self.find(*conditions, session=session).exists() + + async def estimated_count(self) -> int: + """Fast collection-level count from metadata. Ignores every scope and filter.""" + with self._op("estimated_count"): + return int(await self.collection.estimated_document_count()) + + # -- document hooks ---------------------------------------------------------- + + async def _hooks(self, when: str, events: tuple[str, ...], ctx: OpCtx, doc: t.Any) -> None: + meta = meta_of(type(doc)) + for event in events: + for plugin in self.plugins: + await (plugin.before if when == "before" else plugin.after)(ctx, event, doc) + for hook in meta.hooks(when, event): + await hook(ctx, doc) + + # -- document-level writes ----------------------------------------------------- + + async def insert(self, doc: T, *, session: t.Any = None) -> T: + ctx = self.ctx("insert", session, doc=doc) + await self._hooks("before", ("insert", "save"), ctx, doc) + if isinstance(session, Tx): + session.record(doc, self.managed_attrs) + encoded = self.meta.encode(doc) + if encoded.get("_id") is None: + encoded.pop("_id", None) + for plugin in self.plugins: + plugin.on_insert(ctx, encoded) + with self._op("insert"): + result = await self.collection.insert_one(encoded, **self._kw(session)) + encoded["_id"] = result.inserted_id + t.cast(t.Any, doc).id = result.inserted_id + for plugin in self.plugins: + plugin.applied(ctx, doc, encoded) + tracking.track(doc, encoded, self) + await self._hooks("after", ("insert", "save"), ctx, doc) + return doc + + def _delta(self, doc: t.Any, state: tracking.DocState) -> tuple[dict[str, t.Any], dict[str, t.Any]]: + """``(current encoding, update document)`` for a tracked document.""" + current = self.meta.encode(doc) + to_set, to_unset = tracking.diff(state.snapshot, current, state.partial) + for name in self.meta.immutable: + stored = self.meta.fields[name].stored + if stored in to_set or stored in to_unset or any(k.startswith(stored + ".") for k in to_set): + raise QueryError(f"{self.meta.model.__name__}.{name} is immutable") + update: dict[str, t.Any] = {} + if to_set: + update["$set"] = to_set + if to_unset: + update["$unset"] = {key: "" for key in to_unset} + return current, update + + async def save(self, doc: T, *, session: t.Any = None) -> T: + """Insert a new document, or send only what changed on a tracked one.""" + state = tracking.state_of(doc) + if state is None: + return await self.insert(doc, session=session) + ctx = self.ctx("update", session, doc=doc, snapshot=state.snapshot) + await self._hooks("before", ("save",), ctx, doc) + current, update = self._delta(doc, state) + if update: + flt: dict[str, t.Any] = {"_id": state.snapshot["_id"], **self._shard_filter(state.snapshot)} + user_keys = _touched(update) + for plugin in self.plugins: + plugin.on_update(ctx, flt, update) + with self._op("save"): + result = await self.collection.update_one(flt, update, **self._kw(session)) + if result.matched_count == 0: + await self._raise_unmatched(flt, session) + if isinstance(session, Tx): + session.record(doc, self.managed_attrs) + _patch(current, update, skip=user_keys) + for plugin in self.plugins: + plugin.applied(ctx, doc, current) + tracking.track(doc, current, state.manager, state.partial) + await self._hooks("after", ("save",), ctx, doc) + return doc + + async def _raise_unmatched(self, flt: dict[str, t.Any], session: t.Any) -> t.NoReturn: + with self._op("save"): + exists = await self.collection.count_documents({"_id": flt["_id"]}, limit=1, **self._kw(session)) + if exists and len(flt) > 1: + raise StaleDocumentError( + f"{self.model.__name__} {flt['_id']!r} was changed by someone else since it was loaded" + ) + raise DocumentNotFound(f"{self.model.__name__} {flt['_id']!r} does not exist") + + async def delete_document(self, doc: T, *, session: t.Any = None, hard: bool = False) -> None: + any_doc = t.cast(t.Any, doc) + if any_doc.id is None: + raise QueryError("cannot delete a document that has no id") + state = tracking.state_of(doc) + snapshot = state.snapshot if state is not None else {"_id": self.meta.cast("id", any_doc.id)} + ctx = self.ctx("delete", session, doc=doc, snapshot=snapshot) + await self._hooks("before", ("delete",), ctx, doc) + flt: dict[str, t.Any] = {"_id": snapshot["_id"], **self._shard_filter(snapshot)} + soft = None if hard else self._soft_delete(ctx, flt) + + async def run(s: t.Any) -> None: + if isinstance(s, Tx): + s.record(doc, self.managed_attrs) + if soft is not None: + with self._op("delete"): + result = await self.collection.update_one(flt, soft, **self._kw(s)) + matched = result.matched_count + else: + await self._cascade([snapshot["_id"]], s) + with self._op("delete"): + matched = (await self.collection.delete_one(flt, **self._kw(s))).deleted_count + if matched == 0: + await self._raise_unmatched(flt, s) + + if soft is None and session is None and self._cascade_rules(): + await self.registry.transaction(run) + else: + await run(session) + + if soft is not None: + if state is not None: + current = dict(state.snapshot) + _patch(current, soft, skip=frozenset()) + for plugin in self.plugins: + plugin.applied(ctx, doc, current) + tracking.track(doc, current, state.manager, state.partial) + else: + tracking.forget(doc) + await self._hooks("after", ("delete",), ctx, doc) + + def _soft_delete(self, ctx: OpCtx, flt: dict[str, t.Any]) -> dict[str, t.Any] | None: + for plugin in self.plugins: + update = plugin.on_delete(ctx, flt) + if update is not None: + for other in self.plugins: + other.on_update(ctx, flt, update) + return update + return None + + async def reload(self, doc: T, *, session: t.Any = None) -> T: + """Refresh a document in place from the database and reset its snapshot.""" + any_doc = t.cast(t.Any, doc) + with self._op("reload"): + raw = await self.collection.find_one( + {"_id": self.meta.cast("id", any_doc.id)}, **self._kw(session) + ) + if raw is None: + raise DocumentNotFound(f"{self.model.__name__} {any_doc.id!r} does not exist") + fresh = self.meta.decode(raw) + for name in meta_of(type(doc)).fields: + setattr(doc, name, getattr(fresh, name)) + tracking.track(doc, meta_of(type(doc)).encode(doc), self) + return doc + + # -- filter-based writes --------------------------------------------------------- + + def _update_doc( + self, + ctx: OpCtx, + flt: dict[str, t.Any], + ops: t.Iterable[UpdateOp], + fields: t.Mapping[str, t.Any], + *, + upsert: bool = False, + ) -> dict[str, t.Any]: + update = compile_update(self.meta, ops, fields) + if not update: + raise QueryError("an update needs at least one operator or field") + if upsert: + ctx.extra["upsert"] = True + if self.meta.disc_key: + update.setdefault("$setOnInsert", {})[self.meta.disc_key] = self.meta.disc_value + for plugin in self.plugins: + plugin.on_update(ctx, flt, update) + return update + + async def update( + self, + target: t.Any, + *ops: UpdateOp, + session: t.Any = None, + upsert: bool = False, + returning: t.Literal["after", "before"] = "after", + if_rev: int | None = None, + unscoped: t.Collection[str] | bool = False, + **fields: t.Any, + ) -> T: + """Atomically update one document (by id or filter) and return it. + + Raises :class:`DocumentNotFound` when nothing matched, or + :class:`StaleDocumentError` when ``if_rev`` was given and did not match. + """ + ctx = self.ctx("update", session) + flt = self.scoped(ctx, self.compile(target), unscoped) + if if_rev is not None: + ctx.extra["if_rev"] = if_rev + update = self._update_doc(ctx, flt, ops, fields, upsert=upsert) + with self._op("update"): + raw = await self.collection.find_one_and_update( + flt, + update, + return_document=ReturnDocument.AFTER if returning == "after" else ReturnDocument.BEFORE, + upsert=upsert, + projection=self.default_projection(), + **self._kw(session), + ) + if raw is None: + if upsert and returning == "before": + raise DocumentNotFound( + f"{self.model.__name__}: upsert inserted a new document (no prior version)" + ) + if if_rev is not None: + with self._op("update"): + base = self.scoped(ctx, self.compile(target), unscoped) + exists = await self.collection.count_documents(base, limit=1, **self._kw(session)) + if exists: + raise StaleDocumentError(f"{self.model.__name__}: revision {if_rev} is no longer current") + raise DocumentNotFound(f"{self.model.__name__}: no document matched the update") + partial = self._default_partial() + return self.hydrate(raw, partial=partial) + + def _default_partial(self) -> frozenset[str] | None: + return (self.meta.stored_keys - set(self.meta.deferred)) if self.meta.deferred else None + + async def upsert(self, target: t.Any, *ops: UpdateOp, session: t.Any = None, **fields: t.Any) -> T: + """Update the matching document or insert one, in a single round trip.""" + return await self.update(target, *ops, session=session, upsert=True, **fields) + + async def update_many( + self, + flt: dict[str, t.Any], + ops: t.Iterable[UpdateOp], + fields: t.Mapping[str, t.Any], + *, + session: t.Any = None, + unscoped: t.Collection[str] | bool = False, + ) -> int: + ctx = self.ctx("update", session, many=True) + flt = self.scoped(ctx, flt, unscoped) + update = self._update_doc(ctx, flt, ops, fields) + with self._op("update_many"): + result = await self.collection.update_many(flt, update, **self._kw(session)) + return int(result.modified_count) + + async def delete_many( + self, + flt: dict[str, t.Any], + *, + session: t.Any = None, + hard: bool = False, + unscoped: t.Collection[str] | bool = False, + ) -> int: + ctx = self.ctx("delete", session, many=True) + flt = self.scoped(ctx, flt, unscoped) + soft = None if hard else self._soft_delete(ctx, flt) + if soft is not None: + with self._op("delete_many"): + return int((await self.collection.update_many(flt, soft, **self._kw(session))).modified_count) + if not self._cascade_rules(): + with self._op("delete_many"): + return int((await self.collection.delete_many(flt, **self._kw(session))).deleted_count) + + async def run(s: t.Any) -> int: + with self._op("delete_many"): + ids = await self.collection.distinct("_id", flt, **self._kw(s)) + if not ids: + return 0 + await self._cascade(ids, s) + with self._op("delete_many"): + return int( + (await self.collection.delete_many({"_id": {"$in": ids}}, **self._kw(s))).deleted_count + ) + + return await (self.registry.transaction(run) if session is None else run(session)) + + # -- cascades ---------------------------------------------------------------------- + + def _cascade_rules(self) -> list[t.Any]: + if self._cascades is None: + from mongospecs.relations import cascade_rules + + self._cascades = cascade_rules(self) + return self._cascades + + async def _cascade(self, ids: list[t.Any], session: t.Any) -> None: + rules = self._cascade_rules() + if rules: + from mongospecs.relations import apply_cascades + + await apply_cascades(self, rules, ids, session) + + # -- bulk ----------------------------------------------------------------------------- + + async def insert_many( + self, + docs: t.Iterable[T | t.Mapping[str, t.Any]], + *, + ordered: bool = True, + session: t.Any = None, + ) -> BulkResult[T]: + """Insert documents in one round trip. + + With ``ordered=False`` the server attempts every document; failures are + reported in ``BulkResult.errors`` instead of raising. + """ + items: list[t.Any] = [self.meta.decode(dict(d)) if isinstance(d, t.Mapping) else d for d in docs] + if not items: + return BulkResult([], []) + ctx = self.ctx("insert", session, many=True) + for plugin in self.plugins: + await plugin.before_bulk(ctx, items) + encoded: list[dict[str, t.Any]] = [] + for doc in items: + if isinstance(session, Tx): + session.record(doc, self.managed_attrs) + enc = self.meta.encode(doc) + if enc.get("_id") is None: + enc.pop("_id", None) + for plugin in self.plugins: + plugin.on_insert(ctx, enc) + encoded.append(enc) + failed: dict[int, Exception] = {} + try: + with self._op("insert_many"): + await self.collection.insert_many(encoded, ordered=ordered, **self._kw(session)) + except OperationFailed as exc: + write_errors = (exc.details or {}).get("writeErrors") if isinstance(exc.details, dict) else None + if ordered or not write_errors: + raise + failed = {e["index"]: _write_error(e) for e in write_errors} + inserted: list[T] = [] + for index, (doc, enc) in enumerate(zip(items, encoded, strict=True)): + if index in failed: + continue + doc.id = enc["_id"] + for plugin in self.plugins: + plugin.applied(ctx, doc, enc) + tracking.track(doc, enc, self) + inserted.append(doc) + for plugin in self.plugins: + await plugin.after_bulk(ctx, inserted) + return BulkResult(inserted, sorted(failed.items())) + + async def bulk_write( + self, operations: t.Iterable[t.Any], *, ordered: bool = True, session: t.Any = None + ) -> t.Any: + """Run typed bulk operations (see :mod:`mongospecs.bulk`) in one round trip.""" + from mongospecs.bulk import compile_bulk + + ops = list(operations) + ctx = self.ctx("bulk", session, many=True) + for plugin in self.plugins: + await plugin.before_bulk(ctx, ops) + requests = compile_bulk(self, ctx, ops) + if not requests: + return None + with self._op("bulk_write"): + result = await self.collection.bulk_write(requests, ordered=ordered, **self._kw(session)) + for plugin in self.plugins: + await plugin.after_bulk(ctx, ops) + return result + + async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: + """Save many documents in one round trip: inserts for new ones, deltas for tracked ones. + + Returns the number of documents written. Raises :class:`StaleDocumentError` + if any revision-checked update matched nothing (the batch does not say which). + """ + from pymongo import InsertOne + from pymongo import UpdateOne as DriverUpdateOne + + requests: list[t.Any] = [] + pending: list[ + tuple[t.Any, str, OpCtx, dict[str, t.Any], tracking.DocState | None, frozenset[str]] + ] = [] + for doc in docs: + state = tracking.state_of(doc) + if state is None: + ctx = self.ctx("insert", session, many=True, doc=doc) + enc = self.meta.encode(doc) + if enc.get("_id") is None: + enc.pop("_id", None) + for plugin in self.plugins: + plugin.on_insert(ctx, enc) + requests.append(InsertOne(enc)) + pending.append((doc, "insert", ctx, enc, None, frozenset())) + continue + ctx = self.ctx("update", session, many=True, doc=doc, snapshot=state.snapshot) + current, update = self._delta(doc, state) + if not update: + continue + flt: dict[str, t.Any] = {"_id": state.snapshot["_id"], **self._shard_filter(state.snapshot)} + user_keys = _touched(update) + for plugin in self.plugins: + plugin.on_update(ctx, flt, update) + requests.append(DriverUpdateOne(flt, update)) + _patch(current, update, skip=user_keys) + pending.append((doc, "update", ctx, current, state, user_keys)) + if not requests: + return 0 + bulk_ctx = self.ctx("bulk", session, many=True) + all_docs = [p[0] for p in pending] + for plugin in self.plugins: + await plugin.before_bulk(bulk_ctx, all_docs) + with self._op("bulk_save"): + result = await self.collection.bulk_write(requests, ordered=False, **self._kw(session)) + updates = sum(1 for p in pending if p[1] == "update") + if result.matched_count != updates: + raise StaleDocumentError( + f"{self.model.__name__}: {updates - result.matched_count} of {updates} documents in the batch " + "no longer exist or were changed since they were loaded" + ) + for doc, kind, ctx, enc, state, _ in pending: + if isinstance(session, Tx): + session.record(doc, self.managed_attrs) + if kind == "insert": + doc.id = enc["_id"] + for plugin in self.plugins: + plugin.applied(ctx, doc, enc) + tracking.track(doc, enc, state.manager if state else self, state.partial if state else None) + for plugin in self.plugins: + await plugin.after_bulk(bulk_ctx, all_docs) + return len(pending) + + # -- escape hatches --------------------------------------------------------------------- + + def aggregate( + self, + pipeline: t.Sequence[t.Mapping[str, t.Any]], + *, + as_: t.Any = None, + session: t.Any = None, + unscoped: t.Collection[str] | bool = False, + **options: t.Any, + ) -> Aggregate[t.Any]: + """Run a raw aggregation pipeline. + + Scopes still apply: the scope filter is prepended as a ``$match`` and + plugins may rewrite the pipeline. ``as_`` decodes each result into a + model class; by default results are plain dicts. + """ + return Aggregate(self, list(pipeline), as_, session, unscoped, options) + + def watch(self, pipeline: t.Sequence[t.Mapping[str, t.Any]] | None = None, **options: t.Any) -> t.Any: + from mongospecs.streams import ChangeStream + + return ChangeStream(self, list(pipeline or []), options) + + def __repr__(self) -> str: + return f"" + + +class Aggregate(t.Generic[T]): + """Result of ``aggregate()``: await it for a list, or iterate it with ``async for``.""" + + def __init__( + self, + manager: Manager[t.Any], + pipeline: list[t.Mapping[str, t.Any]], + as_: t.Any, + session: t.Any, + unscoped: t.Collection[str] | bool, + options: dict[str, t.Any], + ) -> None: + self._manager = manager + self._pipeline = pipeline + self._as = as_ + self._session = session + self._unscoped = unscoped + self._options = options + + def compiled(self) -> list[dict[str, t.Any]]: + """The pipeline that will be sent, after scopes and plugin rewriting.""" + manager = self._manager + ctx = manager.ctx("aggregate", self._session, many=True) + pipeline = [dict(stage) for stage in self._pipeline] + scope = manager.scoped(ctx, None, self._unscoped) + if scope: + # Stages that must come first stay first. + first = next(iter(pipeline[0]), "") if pipeline else "" + at = 1 if first in ("$geoNear", "$search", "$searchMeta", "$vectorSearch", "$collStats") else 0 + pipeline.insert(at, {"$match": scope}) + if self._unscoped is not True: + for plugin in manager.plugins: + pipeline = plugin.on_pipeline(ctx, pipeline) + return pipeline + + def _decoder(self) -> t.Callable[[dict[str, t.Any]], t.Any]: + as_ = self._as + if as_ is None: + return lambda doc: doc + from mongospecs.codec import codec_for + from mongospecs.model import Document + + if isinstance(as_, type) and issubclass(as_, Document): + return meta_of(as_).decode + codec = codec_for(as_) + return lambda doc: codec.decode(as_, doc) + + async def __aiter__(self) -> t.AsyncIterator[T]: + manager = self._manager + decode = self._decoder() + with manager._op("aggregate"): + cursor = await manager.collection.aggregate( + self.compiled(), **manager._read_kw(self._session, self._options) + ) + async with cursor: + while True: + with manager._op("aggregate"): + try: + raw = await cursor.__anext__() + except StopAsyncIteration: + return + yield decode(raw) + + async def all(self) -> list[T]: + return [item async for item in self] + + def __await__(self) -> t.Generator[t.Any, None, list[T]]: + return self.all().__await__() + + +def _touched(update: t.Mapping[str, t.Any]) -> frozenset[str]: + return frozenset(k for body in update.values() for k in body) + + +def _patch(doc: dict[str, t.Any], update: t.Mapping[str, t.Any], *, skip: frozenset[str]) -> None: + """Apply plugin-written top-level ``$set``/``$inc`` values to an encoded document.""" + for key, value in update.get("$set", {}).items(): + if key not in skip and "." not in key: + doc[key] = value + for key, value in update.get("$inc", {}).items(): + if key not in skip and "." not in key: + doc[key] = (doc.get(key) or 0) + value + + +def _write_error(error: t.Mapping[str, t.Any]) -> Exception: + message = str(error.get("errmsg", "write failed")) + if error.get("code") == 11000: + fields = error.get("keyValue") + return DuplicateKeyError( + message, + code=11000, + details=dict(error), + fields=dict(fields) if isinstance(fields, dict) else None, + ) + return OperationFailed(message, code=error.get("code"), details=dict(error)) diff --git a/packages/mongospecs/src/mongospecs/markers.py b/packages/mongospecs/src/mongospecs/markers.py new file mode 100644 index 0000000..a59dde0 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/markers.py @@ -0,0 +1,238 @@ +"""Declarative metadata: field markers, field policy, model config and indexes. + +Everything here is plain data. Markers are placed in ``typing.Annotated`` so the +same declaration works on msgspec, pydantic and attrs models:: + + title: Annotated[str, Policy(filter=("eq", "prefix"), sort=True)] + created_at: Annotated[datetime | None, CreatedAt] = None +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +__all__ = [ + "FILTER_OPERATORS", + "Alias", + "Backref", + "CreatedAt", + "Deferred", + "DeletedAt", + "Immutable", + "Index", + "Marker", + "OnDelete", + "Policy", + "Revision", + "SpecConfig", + "To", + "UpdatedAt", + "computed", +] + +FILTER_OPERATORS: tuple[str, ...] = ("eq", "ne", "in", "nin", "gt", "gte", "lt", "lte", "exists", "prefix") +"""The bounded operator set a web filter may use.""" + + +class Marker: + """Base class for field markers. Markers may be used bare (the class) or instantiated.""" + + +class CreatedAt(Marker): + """Set once, when the document is inserted.""" + + +class UpdatedAt(Marker): + """Set on insert and on every update, including bulk and filter-based ones.""" + + +class DeletedAt(Marker): + """Soft delete: ``delete`` sets this field and reads exclude documents where it is set.""" + + +class Revision(Marker): + """Integer revision for optimistic locking; incremented by every write.""" + + +class Immutable(Marker): + """The field cannot change after insert.""" + + +class Deferred(Marker): + """The field is left out of the default projection; ask for it with ``select("+name")``.""" + + +@dataclass(frozen=True, slots=True) +class Alias(Marker): + """Store a top-level field under a different (usually shorter) name.""" + + name: str + + +@dataclass(frozen=True, slots=True) +class To(Marker): + """Name the target model of a ``Ref`` when it cannot be imported (resolved by class name).""" + + model: str + + +@dataclass(frozen=True, slots=True) +class Backref(Marker): + """A reverse relation: documents of another model whose ``field`` references this one. + + Never stored. Loaded only through ``include()``. With ``count=True`` the + field holds the number of referencing documents instead of the documents. + """ + + field: str + model: t.Any = None + count: bool = False + + +@dataclass(frozen=True, slots=True) +class OnDelete(Marker): + """What happens to documents holding this ``Ref`` when the referenced document is deleted. + + ``cascade`` deletes them, ``nullify`` sets the field to ``None``, ``pull`` + removes the id from a list field. + """ + + action: t.Literal["cascade", "nullify", "pull"] + + +@dataclass(frozen=True, slots=True, kw_only=True) +class Policy(Marker): + """How a field may be used from outside the application (the web layer reads this). + + ``filter`` lists the operators a client may apply (``True`` allows the whole + bounded set, see :data:`FILTER_OPERATORS`). + """ + + insert: bool = True + update: bool = True + private: bool = False + filter: tuple[str, ...] | bool = () + sort: bool = False + + def __post_init__(self) -> None: + ops = self.filter_ops + unknown = [op for op in ops if op not in FILTER_OPERATORS] + if unknown: + raise ValueError(f"unknown filter operators {unknown}; allowed: {list(FILTER_OPERATORS)}") + + @property + def filter_ops(self) -> tuple[str, ...]: + if self.filter is True: + return FILTER_OPERATORS + if self.filter is False: + return () + return tuple(self.filter) + + +DEFAULT_POLICY = Policy() + + +class computed(property): + """A read-only property that the web layer includes in the Read view.""" + + +def is_computed(prop: t.Any) -> bool: + return isinstance(prop, computed) + + +IndexKey = tuple[str, int | str] + + +@dataclass(frozen=True, init=False) +class Index: + """A declared index. + + Keys are field names (``"-name"`` for descending) or ``(name, direction)`` + pairs where direction is ``1``, ``-1`` or a special type such as ``"text"`` + or ``"2dsphere"``. Field names are attribute names; they are translated to + stored names when the index is planned. + """ + + keys: tuple[IndexKey, ...] + name: str | None = None + unique: bool = False + sparse: bool = False + ttl: int | None = None + partial: t.Mapping[str, t.Any] | None = None + collation: t.Mapping[str, t.Any] | None = None + hidden: bool = False + options: t.Mapping[str, t.Any] = field(default_factory=dict) + + def __init__( + self, + *keys: str | IndexKey, + name: str | None = None, + unique: bool = False, + sparse: bool = False, + ttl: int | None = None, + partial: t.Mapping[str, t.Any] | None = None, + collation: t.Mapping[str, t.Any] | None = None, + hidden: bool = False, + **options: t.Any, + ) -> None: + if not keys: + raise ValueError("an index needs at least one key") + parsed: list[IndexKey] = [] + for key in keys: + if isinstance(key, str): + parsed.append((key[1:], -1) if key.startswith("-") else (key, 1)) + else: + parsed.append((key[0], key[1])) + object.__setattr__(self, "keys", tuple(parsed)) + object.__setattr__(self, "name", name) + object.__setattr__(self, "unique", unique) + object.__setattr__(self, "sparse", sparse) + object.__setattr__(self, "ttl", ttl) + object.__setattr__(self, "partial", partial) + object.__setattr__(self, "collation", collation) + object.__setattr__(self, "hidden", hidden) + object.__setattr__(self, "options", dict(options)) + + def __hash__(self) -> int: + return hash((self.keys, self.name, self.unique)) + + +@dataclass(frozen=True, kw_only=True) +class SpecConfig: + """Per-model configuration, assigned to the ``__spec__`` class attribute.""" + + collection: str | None = None + """Collection name. Defaults to the class name.""" + + indexes: t.Sequence[Index] = () + + plugins: t.Sequence[t.Any] = () + """Extra :class:`~mongospecs.hooks.Plugin` instances for this model.""" + + discriminator: str | None = None + """Stored key that selects the subclass; set on the base of a single-collection hierarchy.""" + + discriminator_value: str | None = None + """Value stored for this class. Defaults to the class name.""" + + shard_key: t.Sequence[str] = () + """Field names forming the shard key; added to the filter of document-level writes.""" + + upcast: t.Callable[[dict[str, t.Any]], dict[str, t.Any]] | None = None + """Called with each raw document before decoding, to migrate old shapes lazily.""" + + trusted: bool = False + """Skip read validation where the backend allows it.""" + + max_time_ms: int | None = None + read_preference: t.Any = None + read_concern: t.Any = None + write_concern: t.Any = None + collation: t.Mapping[str, t.Any] | None = None + + collection_options: t.Mapping[str, t.Any] = field(default_factory=dict) + """Options for ``create_collection`` (``timeseries``, ``capped``, ``size``, ...).""" + + json_schema_validation: t.Literal["off", "moderate", "strict"] = "off" + """Install a derived ``$jsonSchema`` validator when indexes and options are applied.""" diff --git a/packages/mongospecs/src/mongospecs/meta.py b/packages/mongospecs/src/mongospecs/meta.py new file mode 100644 index 0000000..ab3fd20 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/meta.py @@ -0,0 +1,373 @@ +"""Per-model metadata, computed once per class and cached.""" + +from __future__ import annotations + +import dataclasses +import importlib +import typing as t +import weakref + +from bson import ObjectId + +from mongospecs.codec import Codec, FieldInfo, codec_for, element_type, loosen, strip_optional, to_bson +from mongospecs.errors import ConfigurationError, QueryError, ValidationError +from mongospecs.hooks import DOCUMENT_EVENTS, Hook, Plugin +from mongospecs.markers import ( + CreatedAt, + Deferred, + DeletedAt, + Immutable, + Marker, + Revision, + SpecConfig, + UpdatedAt, +) +from mongospecs.refs import id_of + +__all__ = ["ModelMeta", "meta_of"] + +# Built-in marker -> plugin factory ("module:attribute", imported lazily). +_MARKER_PLUGINS: dict[type[Marker], str] = { + CreatedAt: "mongospecs.plugins.timestamps:Timestamps", + UpdatedAt: "mongospecs.plugins.timestamps:Timestamps", + DeletedAt: "mongospecs.plugins.soft_delete:SoftDelete", + Revision: "mongospecs.plugins.revision:Revisions", +} + +_METAS: weakref.WeakKeyDictionary[type, ModelMeta] = weakref.WeakKeyDictionary() +_HOOKS: weakref.WeakKeyDictionary[type, dict[tuple[str, str], list[Hook]]] = weakref.WeakKeyDictionary() +_SCOPES: weakref.WeakKeyDictionary[type, dict[str, t.Callable[..., t.Any]]] = weakref.WeakKeyDictionary() + + +def _load(path: str) -> t.Any: + module, _, attr = path.partition(":") + return getattr(importlib.import_module(module), attr) + + +def _own_config(cls: type) -> SpecConfig | None: + cfg = cls.__dict__.get("__spec__") + return cfg if isinstance(cfg, SpecConfig) else None + + +def resolve_config(cls: type) -> SpecConfig: + """Own ``__spec__`` wins. An inherited one keeps its collection only inside a discriminator hierarchy.""" + from mongospecs.model import Document + + own = _own_config(cls) + parent = next((b for b in cls.__mro__[1:] if issubclass(b, Document) and b is not Document), None) + inherited = resolve_config(parent) if parent is not None else None + if own is None: + if inherited is None: + return SpecConfig() + if inherited.discriminator: + return dataclasses.replace(inherited, discriminator_value=None) + return dataclasses.replace(inherited, collection=None, discriminator_value=None) + if inherited is not None and inherited.discriminator and not own.discriminator: + return dataclasses.replace( + own, discriminator=inherited.discriminator, collection=own.collection or inherited.collection + ) + return own + + +def _all_subclasses(cls: type) -> t.Iterator[type]: + sub: type + for sub in cls.__subclasses__(): + yield sub + yield from _all_subclasses(sub) + + +class ModelMeta: + """Everything mongospecs derives from a model class.""" + + def __init__(self, model: type) -> None: + from mongospecs.model import Document + + if not (isinstance(model, type) and issubclass(model, Document)): + raise ConfigurationError(f"{model!r} is not a mongospecs document class") + self.model = model + self.codec: Codec = codec_for(model) + self.config = resolve_config(model) + self.fields: dict[str, FieldInfo] = self.codec.fields(model) + if "id" not in self.fields: + raise ConfigurationError(f"{model.__name__} has no `id` field") + self.id = self.fields["id"] + self.by_stored: dict[str, FieldInfo] = {f.stored: f for f in self.fields.values()} + self.deferred: tuple[str, ...] = tuple(f.stored for f in self.fields.values() if f.has(Deferred)) + self.immutable: frozenset[str] = frozenset(f.name for f in self.fields.values() if f.has(Immutable)) + self.refs: dict[str, FieldInfo] = {f.name: f for f in self.fields.values() if f.ref is not None} + self.backrefs: dict[str, FieldInfo] = { + f.name: f for f in self.fields.values() if f.backref is not None + } + self.stored_keys: frozenset[str] = frozenset( + f.stored for f in self.fields.values() if not f.transient + ) + + for f in self.fields.values(): + if f.has(Deferred) and f.required: + raise ConfigurationError(f"{model.__name__}.{f.name}: a Deferred field needs a default") + + # Discriminator hierarchy. + self.disc_key: str | None = self.config.discriminator + self.root: type = model + if self.disc_key: + for base in model.__mro__: + cfg = _own_config(base) + if cfg is not None and cfg.discriminator == self.disc_key: + self.root = base + if self.disc_key in self.by_stored: + raise ConfigurationError( + f"{model.__name__}: discriminator key {self.disc_key!r} collides with a field" + ) + self.disc_value: str = self.config.discriminator_value or model.__name__ + self.collection: str = self.config.collection or self.root.__name__ + self._subclass_by_value: dict[str, type] = {} + self._targets: dict[str, tuple[ModelMeta, ...]] = {} + self._loose: dict[str, t.Any] = {} + self._adapters: dict[t.Any, t.Any] = {} + + # Plugins: marker-activated first (in a stable order), then configured ones. + plugins: list[Plugin] = [] + seen: set[t.Any] = set() + for f in self.fields.values(): + for marker in f.markers: + marker_cls = marker if isinstance(marker, type) else type(marker) + factory = _MARKER_PLUGINS.get(marker_cls) or getattr(marker_cls, "plugin_factory", None) + if factory is None or factory in seen: + continue + seen.add(factory) + plugin = (_load(factory) if isinstance(factory, str) else factory)(self) + if plugin is not None: + plugins.append(plugin) + plugins.extend(self.config.plugins) + self.plugins: tuple[Plugin, ...] = tuple(plugins) + for plugin in self.plugins: + plugin.setup(self) + + # -- naming -------------------------------------------------------------- + + def stored_path(self, path: str) -> str: + """Translate an attribute path to a stored path; the first segment must be a field.""" + head, dot, rest = path.partition(".") + info = self.fields.get(head) + if info is None: + if head in self.by_stored: + return path + if self.disc_key and head == self.disc_key: + return path + raise QueryError(f"{self.model.__name__} has no field {head!r}") + if info.transient: + raise QueryError(f"{self.model.__name__}.{head} is a reverse relation and is not stored") + return info.stored + dot + rest + + def field_for(self, path: str) -> FieldInfo | None: + head = path.partition(".")[0] + return self.fields.get(head) or self.by_stored.get(head) + + # -- casting --------------------------------------------------------------- + + def cast(self, path: str, value: t.Any) -> t.Any: + """Coerce a filter value to the type of the field at ``path`` and make it BSON-ready.""" + if value is None: + return None + info = self.fields.get(path) or self.by_stored.get(path) + if info is None: + # Nested path (or unknown): no type to cast against. + self.stored_path(path) + return to_bson(id_of(value)) + if info.ref is not None: + return self._cast_ref(info, value) + loose = self._loose.get(info.name) + if loose is None: + loose = self._loose[info.name] = loosen(info.type) + try: + return to_bson(self.validate_type(loose, value)) + except ValidationError as exc: + elem = element_type(loose) + if elem is not None and not isinstance(value, list | tuple | set | frozenset): + try: + return to_bson(self.validate_type(elem, value)) + except ValidationError: + pass + raise QueryError(f"cannot cast {value!r} to {self.model.__name__}.{info.name}: {exc}") from exc + + def _cast_ref(self, info: FieldInfo, value: t.Any) -> t.Any: + if isinstance(value, list | tuple | set | frozenset): + return [self._cast_ref(info, v) for v in value] + value = id_of(value) + if isinstance(value, str) and ObjectId.is_valid(value): + targets = self.ref_targets(info.name) + if all(id_python_type(m.model) is ObjectId for m in targets): + return ObjectId(value) + return to_bson(value) + + def validate_type(self, tp: t.Any, value: t.Any) -> t.Any: + """Validate a value against a bare type with this model's backend.""" + if self.codec.name == "pydantic": + import pydantic + + try: + adapter = self._adapters.get(tp) + except TypeError: # unhashable annotation + adapter = None + if adapter is None: + adapter = pydantic.TypeAdapter(tp, config=pydantic.ConfigDict(arbitrary_types_allowed=True)) + try: + self._adapters[tp] = adapter + except TypeError: + pass + try: + return adapter.validate_python(value) + except pydantic.ValidationError as exc: + raise ValidationError(str(exc)) from exc + import msgspec + + from mongospecs.msgspec import dec_hook + + try: + return msgspec.convert(value, tp, dec_hook=dec_hook) + except (msgspec.ValidationError, ValueError) as exc: + raise ValidationError(str(exc)) from exc + + # -- encode / decode ------------------------------------------------------- + + def decode(self, doc: t.Any, *, trusted: bool | None = None) -> t.Any: + if self.config.upcast is not None: + doc = self.config.upcast(dict(doc)) + cls = self.model + if self.disc_key: + cls = self.subclass_for(doc.get(self.disc_key)) + return self.codec.decode(cls, doc, trusted=self.config.trusted if trusted is None else trusted) + + def encode(self, obj: t.Any) -> dict[str, t.Any]: + doc = self.codec.encode(obj) + if self.disc_key: + doc[self.disc_key] = meta_of(type(obj)).disc_value + return doc + + def subclass_for(self, value: t.Any) -> type: + if value is None: + return self.model + hit = self._subclass_by_value.get(value) + if hit is None: + table = {meta_of(c).disc_value: c for c in (self.root, *_all_subclasses(self.root))} + self._subclass_by_value = table + hit = table.get(value) + if hit is None or not issubclass(hit, self.model): + return self.model + return hit + + def discriminator_filter(self) -> dict[str, t.Any]: + """Restrict a query on a subclass to documents of that subclass (and its own subclasses).""" + if not self.disc_key or self.model is self.root: + return {} + values = [self.disc_value, *(meta_of(c).disc_value for c in _all_subclasses(self.model))] + return {self.disc_key: values[0] if len(values) == 1 else {"$in": values}} + + # -- relations ------------------------------------------------------------- + + def ref_targets(self, name: str) -> tuple[ModelMeta, ...]: + hit = self._targets.get(name) + if hit is None: + info = self.fields[name] + if info.ref is not None: + raw: tuple[t.Any, ...] = info.ref.targets + elif info.backref is not None: + raw = (info.backref.model or _backref_model(info),) + else: + raise ConfigurationError(f"{self.model.__name__}.{name} is not a relation") + hit = tuple(meta_of(resolve_model(target, self.model)) for target in raw) + self._targets[name] = hit + return hit + + # -- hooks and scopes ------------------------------------------------------ + + def hooks(self, when: str, event: str) -> list[Hook]: + out: list[Hook] = [] + for klass in reversed(self.model.__mro__): + table = _HOOKS.get(klass) + if table: + out.extend(table.get((when, event), ())) + return out + + def scope(self, name: str) -> t.Callable[..., t.Any] | None: + for klass in self.model.__mro__: + table = _SCOPES.get(klass) + if table and name in table: + return table[name] + return None + + def __repr__(self) -> str: + return f" {self.collection!r} ({self.codec.name})>" + + +def _backref_model(info: FieldInfo) -> t.Any: + tp = strip_optional(info.type) + elem = element_type(tp) + target = strip_optional(elem) if elem is not None else tp + if isinstance(target, t.ForwardRef): + return target.__forward_arg__ + if isinstance(target, type) and target not in (int, list): + return target + raise ConfigurationError(f"Backref field {info.name!r}: name the model with Backref(..., model=Model)") + + +def resolve_model(target: t.Any, near: type) -> type: + """Resolve a model class or class name to a document class.""" + from mongospecs.model import Document + + if isinstance(target, type): + return target + matches = [c for c in _all_subclasses(Document) if c.__name__ == target] + if not matches: + raise ConfigurationError(f"{near.__name__}: no document class named {target!r} has been defined") + if len(matches) > 1 and near.__name__ == target: + return near + if len(matches) > 1: + # Names are resolved against the registry the referring model is bound to. + from mongospecs.registry import _BINDINGS + + registry = next((_BINDINGS[k] for k in near.__mro__ if k in _BINDINGS), None) + bound = [c for c in matches if registry is not None and _BINDINGS.get(c) is registry] + if len(bound) == 1: + return bound[0] + same_module = [c for c in (bound or matches) if c.__module__ == near.__module__] + if len(same_module) == 1: + return same_module[0] + raise ConfigurationError(f"{near.__name__}: more than one document class is named {target!r}") + return matches[0] + + +def meta_of(model: type) -> ModelMeta: + """Return (building if needed) the metadata of a document class.""" + try: + return _METAS[model] + except KeyError: + meta = ModelMeta(model) + _METAS[model] = meta + return meta + except TypeError: + raise ConfigurationError(f"{model!r} is not a mongospecs document class") from None + + +def id_python_type(model: type) -> t.Any: + return strip_optional(meta_of(model).id.type) + + +def check_event(event: str) -> None: + if event not in DOCUMENT_EVENTS: + raise ConfigurationError(f"unknown document event {event!r}; expected one of {DOCUMENT_EVENTS}") + + +def add_hook(model: type, when: str, event: str, fn: Hook) -> None: + check_event(event) + _HOOKS.setdefault(model, {}).setdefault((when, event), []).append(fn) + + +def remove_hook(model: type, when: str, event: str, fn: Hook) -> None: + hooks = _HOOKS.get(model, {}).get((when, event), []) + if fn in hooks: + hooks.remove(fn) + + +def add_scope(model: type, name: str, fn: t.Callable[..., t.Any]) -> None: + _SCOPES.setdefault(model, {})[name] = fn diff --git a/packages/mongospecs/src/mongospecs/model.py b/packages/mongospecs/src/mongospecs/model.py new file mode 100644 index 0000000..b3aa49e --- /dev/null +++ b/packages/mongospecs/src/mongospecs/model.py @@ -0,0 +1,281 @@ +"""``Document``: the active-record API shared by every backend's ``Spec`` base class. + +The mixin has no fields and no state. Class methods delegate to the model's +default :class:`~mongospecs.manager.Manager`; instance methods delegate to the +manager the document was loaded through. +""" + +from __future__ import annotations + +import typing as t + +from mongospecs import codec as _codec_module +from mongospecs import tracking +from mongospecs.codec import Codec + +if t.TYPE_CHECKING: + from pymongo.asynchronous.collection import AsyncCollection + + from mongospecs.manager import Aggregate, BulkResult, Manager + from mongospecs.markers import SpecConfig + from mongospecs.query import Query + from mongospecs.updates import UpdateOp + +__all__ = ["Document"] + +T = t.TypeVar("T", bound="Document") +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + + +class Document: + """Mixin giving a model class its database API.""" + + __slots__ = () + + if t.TYPE_CHECKING: + __codec__: t.ClassVar[Codec] + __spec__: t.ClassVar[SpecConfig] + id: t.Any + + # -- managers -------------------------------------------------------------- + + @classmethod + def _manager(cls: type[T]) -> Manager[T]: + from mongospecs.registry import registry_of + + return registry_of(cls).manager(cls) + + @classmethod + def using( + cls: type[T], + *, + db: str | None = None, + registry: t.Any = None, + read_preference: t.Any = None, + read_concern: t.Any = None, + write_concern: t.Any = None, + ) -> Manager[T]: + """A manager bound to another database, registry or set of driver options. + + ``Article.using(db="tenant_a").find(...)``. Documents loaded through it + are saved back through it. + """ + base: Manager[T] = registry.manager(cls) if registry is not None else cls._manager() + return base.using( + db=db, read_preference=read_preference, read_concern=read_concern, write_concern=write_concern + ) + + @classmethod + def collection(cls) -> AsyncCollection[dict[str, t.Any]]: + """The raw driver collection, for anything mongospecs does not model.""" + return cls._manager().collection + + # -- reads ----------------------------------------------------------------- + + @classmethod + def find(cls: type[T], *conditions: t.Any, session: t.Any = None) -> Query[T]: + """Start a query. Conditions are ANDed: ``Article.find(Q.views > 10, Q.title.prefix("a"))``.""" + return cls._manager().find(*conditions, session=session) + + @classmethod + async def get(cls: type[T], id: t.Any, *, session: t.Any = None) -> T | None: + """The document with this id, or ``None``.""" + return await cls._manager().get(id, session=session) + + @classmethod + async def count(cls, *conditions: t.Any, session: t.Any = None) -> int: + """Exact count of matching documents (scopes apply).""" + return await cls._manager().count(*conditions, session=session) + + @classmethod + async def estimated_count(cls) -> int: + """Fast count of the whole collection from metadata; ignores scopes and filters.""" + return await cls._manager().estimated_count() + + @classmethod + async def exists(cls, *conditions: t.Any, session: t.Any = None) -> bool: + return await cls._manager().exists(*conditions, session=session) + + @classmethod + def hydrate(cls: type[T], raw: t.Mapping[str, t.Any]) -> T: + """Build a tracked document from a raw MongoDB document you already have.""" + return cls._manager().hydrate(raw) + + # -- writes ---------------------------------------------------------------- + + @classmethod + async def update( + cls: type[T], + target: t.Any, + *ops: UpdateOp, + session: t.Any = None, + if_rev: int | None = None, + returning: t.Literal["after", "before"] = "after", + **fields: t.Any, + ) -> T: + """Atomically update one document, found by id or filter, and return it. + + ``await Article.update(article_id, Inc(views=1), title="New")`` + """ + return await cls._manager().update( + target, *ops, session=session, if_rev=if_rev, returning=returning, **fields + ) + + @classmethod + async def upsert( + cls: type[T], target: t.Any, *ops: UpdateOp, session: t.Any = None, **fields: t.Any + ) -> T: + """Update the matching document or insert one, in a single round trip.""" + return await cls._manager().upsert(target, *ops, session=session, **fields) + + @classmethod + async def insert_many( + cls: type[T], + docs: t.Iterable[T | t.Mapping[str, t.Any]], + *, + ordered: bool = True, + session: t.Any = None, + ) -> BulkResult[T]: + return await cls._manager().insert_many(docs, ordered=ordered, session=session) + + @classmethod + async def bulk_write( + cls, operations: t.Iterable[t.Any], *, ordered: bool = True, session: t.Any = None + ) -> t.Any: + return await cls._manager().bulk_write(operations, ordered=ordered, session=session) + + @classmethod + async def bulk_save(cls: type[T], docs: t.Iterable[T], *, session: t.Any = None) -> int: + return await cls._manager().bulk_save(docs, session=session) + + @classmethod + def aggregate( + cls, + pipeline: t.Sequence[t.Mapping[str, t.Any]], + *, + as_: t.Any = None, + session: t.Any = None, + **options: t.Any, + ) -> Aggregate[t.Any]: + return cls._manager().aggregate(pipeline, as_=as_, session=session, **options) + + @classmethod + def watch(cls, pipeline: t.Sequence[t.Mapping[str, t.Any]] | None = None, **options: t.Any) -> t.Any: + """Open a change stream: ``async for event in Article.watch(): ...``""" + return cls._manager().watch(pipeline, **options) + + # -- hooks and scopes ------------------------------------------------------ + + @classmethod + def before(cls, event: t.Literal["insert", "save", "delete"]) -> t.Callable[[F], F]: + """Register ``async def hook(ctx, doc)`` to run before a document-level operation.""" + from mongospecs.meta import add_hook, check_event + + check_event(event) + + def register(fn: F) -> F: + add_hook(cls, "before", event, fn) + return fn + + return register + + @classmethod + def after(cls, event: t.Literal["insert", "save", "delete"]) -> t.Callable[[F], F]: + """Register ``async def hook(ctx, doc)`` to run after a document-level operation succeeded.""" + from mongospecs.meta import add_hook, check_event + + check_event(event) + + def register(fn: F) -> F: + add_hook(cls, "after", event, fn) + return fn + + return register + + @classmethod + def scope(cls, fn: F) -> F: + """Register a named, chainable scope: ``Article.find().published()``. + + The function receives the query (plus any call arguments) and returns a + query or a condition. + """ + from mongospecs.meta import add_scope + + add_scope(cls, fn.__name__, fn) + return fn + + # -- instance API ------------------------------------------------------------- + + def _own_manager(self) -> Manager[t.Any]: + state = tracking.state_of(self) + if state is not None and state.manager is not None and state.manager.registry.is_open: + return t.cast("Manager[t.Any]", state.manager) + return type(self)._manager() + + @property + def is_new(self) -> bool: + """True until the document has been inserted or was loaded from the database.""" + return tracking.state_of(self) is None + + def changes(self) -> dict[str, t.Any]: + """What ``save()`` would send: ``{"$set": {...}, "$unset": {...}}`` (empty when unchanged).""" + state = tracking.state_of(self) + if state is None: + return {"$set": self._own_manager().meta.encode(self)} + return self._own_manager()._delta(self, state)[1] + + async def insert(self: T, *, session: t.Any = None) -> T: + return t.cast(T, await self._own_manager().insert(self, session=session)) + + async def save(self: T, *, session: t.Any = None) -> T: + """Insert a new document, or send only the fields that changed since it was loaded.""" + return t.cast(T, await self._own_manager().save(self, session=session)) + + async def delete(self, *, session: t.Any = None, hard: bool = False) -> None: + """Delete this document (a soft delete when the model has a ``DeletedAt`` field, unless ``hard``).""" + await self._own_manager().delete_document(self, session=session, hard=hard) + + async def reload(self: T, *, session: t.Any = None) -> T: + """Refresh this document in place from the database.""" + return t.cast(T, await self._own_manager().reload(self, session=session)) + + def _normalize_refs(self) -> None: + """Wrap raw ids and documents assigned to reference fields in ``Ref`` (backends that do not validate on init).""" + cls = type(self) + refs = _REF_FIELDS.get(cls) + if refs is None: + refs = _REF_FIELDS[cls] = _ref_fields(cls) + if not refs: + return + from mongospecs.refs import Ref + + for name, shape, model in refs: + value = getattr(self, name) + if value is None: + continue + if shape == "one": + if type(value) is not Ref: + setattr(self, name, Ref(value, model)) + elif value._model is None: + value._model = model + elif shape == "list": + if any(type(v) is not Ref for v in value): + setattr(self, name, [v if type(v) is Ref else Ref(v, model) for v in value]) + elif any(type(v) is not Ref for v in value.values()): + setattr(self, name, {k: v if type(v) is Ref else Ref(v, model) for k, v in value.items()}) + + +_codec_module._DOCUMENT = Document + +_REF_FIELDS: dict[type, tuple[tuple[str, str, t.Any], ...]] = {} + + +def _ref_fields(cls: type) -> tuple[tuple[str, str, t.Any], ...]: + from mongospecs.codec import codec_for + + out = [] + for info in codec_for(cls).fields(cls).values(): + if info.ref is not None: + target = info.ref.targets[0] if len(info.ref.targets) == 1 else None + out.append((info.name, info.ref.shape, target if isinstance(target, type) else None)) + return tuple(out) diff --git a/packages/mongospecs/src/mongospecs/msgspec.py b/packages/mongospecs/src/mongospecs/msgspec.py new file mode 100644 index 0000000..2e8a4dc --- /dev/null +++ b/packages/mongospecs/src/mongospecs/msgspec.py @@ -0,0 +1,124 @@ +"""msgspec backend: ``from mongospecs.msgspec import Spec``.""" + +from __future__ import annotations + +import re +import typing as t +from typing import ClassVar + +import msgspec +from bson import ObjectId + +from mongospecs.codec import Codec, RawField, field_error, to_bson +from mongospecs.errors import ValidationError +from mongospecs.markers import SpecConfig +from mongospecs.model import Document +from mongospecs.refs import OID, OID_PATTERN, Ref, _to_object_id, coerce_ref + +__all__ = ["CODEC", "MsgspecCodec", "Spec", "SpecAdapter"] + +_PATH = re.compile(r" - at `\$\.([^`.\[]+)") + + +def dec_hook(tp: t.Any, obj: t.Any) -> t.Any: + if tp is ObjectId: + return _to_object_id(obj) + origin = t.get_origin(tp) + if tp is Ref or origin is Ref: + args = t.get_args(tp) + model = args[0] if args and isinstance(args[0], type) else None + return coerce_ref(obj, model) + if isinstance(tp, type) and isinstance(obj, tp): + return obj + raise NotImplementedError(f"objects of type {tp} are not supported") + + +def schema_hook(tp: t.Any) -> dict[str, t.Any]: + if tp is ObjectId or tp is Ref or t.get_origin(tp) is Ref: + return {"type": "string", "pattern": OID_PATTERN} + raise NotImplementedError(f"no JSON schema for {tp}") + + +def _error(cls: type, exc: Exception, name: str | None = None) -> ValidationError: + message = str(exc) + match = _PATH.search(message) + field = name or (match.group(1) if match else None) + return field_error(cls, field, _PATH.sub("", message) if name else message) + + +def encode_struct(obj: msgspec.Struct) -> dict[str, t.Any]: + out: dict[str, t.Any] = {} + for name, key in zip(obj.__struct_fields__, obj.__struct_encode_fields__, strict=True): + value = getattr(obj, name) + if value is not msgspec.UNSET: + out[key] = to_bson(value) + return out + + +class MsgspecCodec(Codec): + name = "msgspec" + + def raw_fields(self, cls: type) -> list[RawField]: + return [(f.name, f.encode_name, f.type, f.required) for f in msgspec.structs.fields(cls)] + + def _decode(self, cls: type, doc: dict[str, t.Any], trusted: bool) -> t.Any: + # msgspec has no unvalidated construction path; conversion is cheap, so + # ``trusted`` changes nothing here. + try: + return msgspec.convert(doc, cls, dec_hook=dec_hook) + except (msgspec.ValidationError, ValueError) as exc: + raise _error(cls, exc) from exc + + def _encode(self, obj: t.Any) -> dict[str, t.Any]: + return encode_struct(obj) + + def validate_field(self, cls: type, name: str, value: t.Any) -> t.Any: + tp = self.fields(cls)[name].type + try: + return msgspec.convert(value, tp, dec_hook=dec_hook) + except (msgspec.ValidationError, ValueError) as exc: + raise _error(cls, exc, name) from exc + + def _json_schema(self, cls: type) -> dict[str, t.Any]: + return msgspec.json.schema(cls, schema_hook=schema_hook) + + +CODEC = MsgspecCodec() + + +class Spec(msgspec.Struct, Document, kw_only=True, weakref=True): + """Base class for msgspec documents.""" + + __codec__: ClassVar[Codec] = CODEC + + id: OID | None = msgspec.field(default=None, name="_id") + + def __post_init__(self) -> None: + self._normalize_refs() + + +def SpecAdapter(cls: type[msgspec.Struct], **config: t.Any) -> t.Any: + """Turn an existing Struct into a document class. + + Keyword arguments are :class:`~mongospecs.SpecConfig` fields, for example + ``SpecAdapter(Dragon, collection="dragons")``. + """ + + if issubclass(cls, Document): + return cls + + class Adapted(cls, Document, kw_only=True, weakref=True): # type: ignore[misc, valid-type, call-arg] + __codec__: ClassVar[Codec] = CODEC + __spec__ = SpecConfig(**{"collection": cls.__name__, **config}) + + id: OID | None = msgspec.field(default=None, name="_id") + + def __post_init__(self) -> None: + parent = getattr(super(), "__post_init__", None) + if parent is not None: + parent() + self._normalize_refs() + + Adapted.__name__ = Adapted.__qualname__ = cls.__name__ + Adapted.__doc__ = cls.__doc__ + return Adapted diff --git a/packages/mongospecs/src/mongospecs/plugins/__init__.py b/packages/mongospecs/src/mongospecs/plugins/__init__.py new file mode 100644 index 0000000..d888a78 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/plugins/__init__.py @@ -0,0 +1,7 @@ +"""Built-in plugins. Each is activated by a field marker; none adds fields.""" + +from mongospecs.plugins.revision import Revisions +from mongospecs.plugins.soft_delete import SoftDelete +from mongospecs.plugins.timestamps import Timestamps + +__all__ = ["Revisions", "SoftDelete", "Timestamps"] diff --git a/packages/mongospecs/src/mongospecs/plugins/revision.py b/packages/mongospecs/src/mongospecs/plugins/revision.py new file mode 100644 index 0000000..07eebbc --- /dev/null +++ b/packages/mongospecs/src/mongospecs/plugins/revision.py @@ -0,0 +1,57 @@ +"""``Revision``: optimistic locking. + +Every write increments the marked integer field. Saving or deleting a loaded +document also requires the stored revision to equal the one that was loaded; +otherwise :class:`~mongospecs.errors.StaleDocumentError` is raised. +``Model.update(id, ..., if_rev=n)`` applies the same check to an atomic update. +""" + +from __future__ import annotations + +import typing as t + +from mongospecs.errors import ConfigurationError +from mongospecs.hooks import OpCtx, Plugin +from mongospecs.markers import Revision + +if t.TYPE_CHECKING: + from mongospecs.meta import ModelMeta + +__all__ = ["Revisions"] + + +class Revisions(Plugin): + name = "revision" + + def __init__(self, meta: ModelMeta) -> None: + fields = [f for f in meta.fields.values() if f.has(Revision)] + if len(fields) != 1: + raise ConfigurationError(f"{meta.model.__name__}: exactly one field may be marked Revision") + self.field = fields[0] + if self.field.required: + raise ConfigurationError( + f"{meta.model.__name__}.{self.field.name}: a Revision field needs a default of 0" + ) + + def on_insert(self, ctx: OpCtx, doc: dict[str, t.Any]) -> None: + if doc.get(self.field.stored) is None: + doc[self.field.stored] = 0 + + def on_update(self, ctx: OpCtx, flt: dict[str, t.Any], update: dict[str, t.Any]) -> None: + stored = self.field.stored + for op in ("$set", "$unset", "$setOnInsert"): + body = update.get(op) + if body and stored in body: + del body[stored] + if not body: + del update[op] + update.setdefault("$inc", {})[stored] = 1 + snapshot = ctx.extra.get("snapshot") + if snapshot is not None: + flt[stored] = snapshot.get(stored) + elif ctx.extra.get("if_rev") is not None: + flt[stored] = ctx.extra["if_rev"] + + def applied(self, ctx: OpCtx, doc: t.Any, written: dict[str, t.Any]) -> None: + if self.field.stored in written: + setattr(doc, self.field.name, written[self.field.stored]) diff --git a/packages/mongospecs/src/mongospecs/plugins/soft_delete.py b/packages/mongospecs/src/mongospecs/plugins/soft_delete.py new file mode 100644 index 0000000..ae46153 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/plugins/soft_delete.py @@ -0,0 +1,50 @@ +"""``DeletedAt``: soft delete. + +``delete`` sets the marked field instead of removing the document, and every +read, count, update and delete excludes documents where it is set. Use +``.unscoped("soft_delete")`` to see them, ``hard=True`` to really delete, and +``Model.find(...).unscoped("soft_delete").update(deleted_at=None)`` to restore. + +A unique index on a soft-deleted model usually wants +``partial={"": None}`` so deleted documents do not block reuse. +""" + +from __future__ import annotations + +import typing as t + +from mongospecs.errors import ConfigurationError +from mongospecs.hooks import OpCtx, Plugin +from mongospecs.markers import DeletedAt +from mongospecs.plugins.timestamps import utcnow + +if t.TYPE_CHECKING: + from mongospecs.meta import ModelMeta + +__all__ = ["SoftDelete"] + + +class SoftDelete(Plugin): + name = "soft_delete" + + def __init__(self, meta: ModelMeta) -> None: + fields = [f for f in meta.fields.values() if f.has(DeletedAt)] + if len(fields) != 1: + raise ConfigurationError(f"{meta.model.__name__}: exactly one field may be marked DeletedAt") + self.field = fields[0] + if self.field.required: + raise ConfigurationError( + f"{meta.model.__name__}.{self.field.name}: a DeletedAt field needs a None default" + ) + + def on_filter(self, ctx: OpCtx, flt: dict[str, t.Any]) -> dict[str, t.Any] | None: + if self.field.stored in flt: + return None # the caller is filtering on the field explicitly + return {self.field.stored: None} + + def on_delete(self, ctx: OpCtx, flt: dict[str, t.Any]) -> dict[str, t.Any] | None: + return {"$set": {self.field.stored: utcnow()}} + + def applied(self, ctx: OpCtx, doc: t.Any, written: dict[str, t.Any]) -> None: + if ctx.op == "delete" and self.field.stored in written: + setattr(doc, self.field.name, written[self.field.stored]) diff --git a/packages/mongospecs/src/mongospecs/plugins/timestamps.py b/packages/mongospecs/src/mongospecs/plugins/timestamps.py new file mode 100644 index 0000000..517c319 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/plugins/timestamps.py @@ -0,0 +1,51 @@ +"""``CreatedAt`` / ``UpdatedAt``: timestamps on every write path, including bulk and filter-based ones.""" + +from __future__ import annotations + +import datetime as dt +import typing as t + +from mongospecs.hooks import OpCtx, Plugin +from mongospecs.markers import CreatedAt, UpdatedAt + +if t.TYPE_CHECKING: + from mongospecs.meta import ModelMeta + +__all__ = ["Timestamps", "utcnow"] + + +def utcnow() -> dt.datetime: + """Now, in UTC, at the millisecond precision BSON stores.""" + now = dt.datetime.now(dt.UTC) + return now.replace(microsecond=now.microsecond // 1000 * 1000) + + +class Timestamps(Plugin): + name = "timestamps" + + def __init__(self, meta: ModelMeta) -> None: + self.created = [f for f in meta.fields.values() if f.has(CreatedAt)] + self.updated = [f for f in meta.fields.values() if f.has(UpdatedAt)] + self._attrs = {f.stored: f.name for f in (*self.created, *self.updated)} + + def on_insert(self, ctx: OpCtx, doc: dict[str, t.Any]) -> None: + now = utcnow() + for f in (*self.created, *self.updated): + if doc.get(f.stored) is None: + doc[f.stored] = now + + def on_update(self, ctx: OpCtx, flt: dict[str, t.Any], update: dict[str, t.Any]) -> None: + now = utcnow() + touched = {key for body in update.values() for key in body} + for f in self.updated: + if f.stored not in touched: + update.setdefault("$set", {})[f.stored] = now + if ctx.extra.get("upsert"): + for f in self.created: + if f.stored not in touched: + update.setdefault("$setOnInsert", {})[f.stored] = now + + def applied(self, ctx: OpCtx, doc: t.Any, written: dict[str, t.Any]) -> None: + for stored, name in self._attrs.items(): + if stored in written: + setattr(doc, name, written[stored]) diff --git a/packages/mongospecs/src/mongospecs/py.typed b/packages/mongospecs/src/mongospecs/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/packages/mongospecs/src/mongospecs/pydantic.py b/packages/mongospecs/src/mongospecs/pydantic.py new file mode 100644 index 0000000..7aaeafc --- /dev/null +++ b/packages/mongospecs/src/mongospecs/pydantic.py @@ -0,0 +1,156 @@ +"""pydantic backend: ``from mongospecs.pydantic import Spec``.""" + +from __future__ import annotations + +import inspect +import typing as t +from typing import ClassVar + +import pydantic +from pydantic import BaseModel, ConfigDict, Field, TypeAdapter + +from mongospecs.codec import Codec, RawField, field_error +from mongospecs.errors import ValidationError +from mongospecs.markers import SpecConfig +from mongospecs.model import Document +from mongospecs.refs import OID, Ref + +__all__ = ["CODEC", "PydanticCodec", "Spec", "SpecAdapter"] + +_HAS_EXCLUDE_COMPUTED = "exclude_computed_fields" in inspect.signature(BaseModel.model_dump).parameters +_SCALARS: tuple[type, ...] = (str, int, float, bool, bytes) + + +def _error(cls: type, exc: pydantic.ValidationError, name: str | None = None) -> ValidationError: + errors = [] + for err in exc.errors(include_url=False, include_context=False, include_input=False): + loc = err.get("loc") or () + errors.append({"field": name or (str(loc[0]) if loc else None), "message": err.get("msg", "")}) + first = errors[0] if errors else {"field": name, "message": str(exc)} + out = field_error(cls, first["field"], str(first["message"])) + out.errors = errors or out.errors + return out + + +def _annotation(info: t.Any) -> t.Any: + """Field annotation with the non-pydantic ``Annotated`` metadata put back.""" + if info.metadata: + return t.Annotated[(info.annotation, *info.metadata)] + return info.annotation + + +class PydanticCodec(Codec): + name = "pydantic" + + def __init__(self) -> None: + super().__init__() + self._flat: dict[type, bool] = {} + self._adapters: dict[tuple[type, str], TypeAdapter[t.Any]] = {} + + def raw_fields(self, cls: type) -> list[RawField]: + model = t.cast("type[BaseModel]", cls) + return [ + (name, info.alias or name, _annotation(info), info.is_required()) + for name, info in model.model_fields.items() + ] + + def _is_flat(self, cls: type) -> bool: + """True when every field is a plain scalar, so unvalidated construction is safe.""" + flat = self._flat.get(cls) + if flat is None: + from bson import ObjectId + + from mongospecs.codec import strip_optional + + flat = all(strip_optional(f.type) in (*_SCALARS, ObjectId) for f in self.fields(cls).values()) + self._flat[cls] = flat + return flat + + def _decode(self, cls: type, doc: dict[str, t.Any], trusted: bool) -> t.Any: + model = t.cast("type[BaseModel]", cls) + if trusted and self._is_flat(cls): + by_alias = {f.stored: f.name for f in self.fields(cls).values()} + return model.model_construct(**{by_alias.get(k, k): v for k, v in doc.items() if k in by_alias}) + try: + return model.model_validate(doc) + except pydantic.ValidationError as exc: + raise _error(cls, exc) from exc + + def _encode(self, obj: t.Any) -> dict[str, t.Any]: + if _HAS_EXCLUDE_COMPUTED: + return t.cast("dict[str, t.Any]", obj.model_dump(by_alias=True, exclude_computed_fields=True)) + doc: dict[str, t.Any] = obj.model_dump(by_alias=True) + for name in type(obj).model_computed_fields: + doc.pop(name, None) + return doc + + def validate_field(self, cls: type, name: str, value: t.Any) -> t.Any: + model = t.cast("type[BaseModel]", cls) + try: + # Runs the model's own field validators for this one field. + blank = model.model_construct() + return getattr(model.__pydantic_validator__.validate_assignment(blank, name, value), name) + except pydantic.ValidationError as exc: + raise _error(cls, exc, name) from exc + except Exception: + # A model-level validator could not run on a partial instance; fall + # back to the field's type and constraints alone. + adapter = self._adapters.get((cls, name)) + if adapter is None: + adapter = TypeAdapter(self.fields(cls)[name].type, config=_adapter_config(model)) + self._adapters[(cls, name)] = adapter + try: + return adapter.validate_python(value) + except pydantic.ValidationError as exc: + raise _error(cls, exc, name) from exc + + def _json_schema(self, cls: type) -> dict[str, t.Any]: + return t.cast("type[BaseModel]", cls).model_json_schema(by_alias=False) + + def clear_cache(self) -> None: + super().clear_cache() + self._flat.clear() + self._adapters.clear() + + +def _adapter_config(model: type[BaseModel]) -> ConfigDict | None: + return ( + ConfigDict(arbitrary_types_allowed=True) + if model.model_config.get("arbitrary_types_allowed") + else None + ) + + +CODEC = PydanticCodec() + + +class Spec(BaseModel, Document): + """Base class for pydantic documents.""" + + model_config = ConfigDict(populate_by_name=True, validate_assignment=False) + + __codec__: ClassVar[Codec] = CODEC + + id: OID | None = Field(default=None, alias="_id") + + +def SpecAdapter(cls: type[BaseModel], **config: t.Any) -> t.Any: + """Turn an existing BaseModel into a document class. + + Keyword arguments are :class:`~mongospecs.SpecConfig` fields, for example + ``SpecAdapter(Dragon, collection="dragons")``. + """ + + if issubclass(cls, Document): + return cls + + class Adapted(cls, Spec): # type: ignore[misc, valid-type] + __spec__ = SpecConfig(**{"collection": cls.__name__, **config}) + + Adapted.__name__ = Adapted.__qualname__ = cls.__name__ + Adapted.__doc__ = cls.__doc__ + return Adapted + + +# Ref is generic over the target model; pydantic needs to see it resolved here. +_ = Ref diff --git a/packages/mongospecs/src/mongospecs/query.py b/packages/mongospecs/src/mongospecs/query.py new file mode 100644 index 0000000..72a4744 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/query.py @@ -0,0 +1,626 @@ +"""Immutable, chainable queries. + +Every builder method returns a new ``Query``; nothing touches the database until +a terminal method is awaited (``all``, ``first``, ``count`` ...) or the query is +iterated with ``async for``. Awaiting a query directly is the same as ``all()``. +""" + +from __future__ import annotations + +import asyncio +import base64 +import typing as t +from dataclasses import dataclass + +import bson + +from mongospecs import tracking +from mongospecs.errors import DocumentNotFound, QueryError +from mongospecs.expr import SortInput, and_filters, compile_filter, compile_sort +from mongospecs.meta import meta_of +from mongospecs.updates import UpdateOp + +if t.TYPE_CHECKING: + from mongospecs.manager import Manager + from mongospecs.relations import Include + +__all__ = ["Page", "Query"] + +T = t.TypeVar("T") + +Mode = t.Literal["tracked", "lean", "raw"] + +# find() option name -> aggregate() option name +_AGG_OPTIONS = {"max_time_ms": "maxTimeMS", "batch_size": "batchSize", "allow_disk_use": "allowDiskUse"} +_FIND_OPTIONS = frozenset( + {"max_time_ms", "batch_size", "allow_disk_use", "hint", "comment", "collation", "no_cursor_timeout"} +) +_CHUNK = 500 + + +@dataclass(slots=True) +class Page(t.Generic[T]): + """One page of results.""" + + items: list[T] + size: int + has_next: bool + next_cursor: str | None = None + """Opaque token for the following page (cursor pagination only).""" + number: int | None = None + """1-based page number (offset pagination only).""" + total: int | None = None + """Total matching documents, when it was asked for.""" + + def __iter__(self) -> t.Iterator[T]: + return iter(self.items) + + def __len__(self) -> int: + return len(self.items) + + def __getitem__(self, index: int) -> T: + return self.items[index] + + +class Query(t.Generic[T]): + __slots__ = ( + "_conditions", + "_fail", + "_includes", + "_limit", + "_manager", + "_mode", + "_options", + "_select", + "_session", + "_skip", + "_sort", + "_unscoped", + ) + + def __init__( + self, + manager: Manager[T], + *, + conditions: tuple[t.Any, ...] = (), + sort: tuple[SortInput, ...] = (), + skip: int = 0, + limit: int = 0, + select: tuple[str, ...] = (), + includes: tuple[Include, ...] = (), + mode: Mode = "tracked", + unscoped: frozenset[str] | bool = False, + session: t.Any = None, + options: t.Mapping[str, t.Any] | None = None, + fail: bool = False, + ) -> None: + self._manager = manager + self._conditions = conditions + self._sort = sort + self._skip = skip + self._limit = limit + self._select = select + self._includes = includes + self._mode: Mode = mode + self._unscoped = unscoped + self._session = session + self._options: dict[str, t.Any] = dict(options or {}) + self._fail = fail + + def _clone(self, **changes: t.Any) -> Query[T]: + state = {name.lstrip("_"): getattr(self, name) for name in self.__slots__ if name != "_manager"} + state.update(changes) + return type(self)(self._manager, **state) + + # -- builders -------------------------------------------------------------- + + def filter(self, *conditions: t.Any) -> Query[T]: + """AND more conditions into the query.""" + return self._clone(conditions=(*self._conditions, *(c for c in conditions if c is not None))) + + where = filter + + def sort(self, *keys: SortInput) -> Query[T]: + """Sort by ``Q.field.desc()``, ``"-field"``, ``"field"`` or ``(field, direction)``.""" + return self._clone(sort=(*self._sort, *keys)) + + def skip(self, n: int) -> Query[T]: + if n < 0: + raise QueryError("skip must be >= 0") + return self._clone(skip=n) + + def limit(self, n: int) -> Query[T]: + if n < 0: + raise QueryError("limit must be >= 0") + return self._clone(limit=n) + + def select(self, *names: str) -> Query[T]: + """Project fields server-side. + + ``"name"`` includes, ``"-name"`` excludes, ``"+name"`` adds a ``Deferred`` + field to the default projection. A document loaded with a projection is + only saved for the fields that were loaded. + """ + return self._clone(select=(*self._select, *names)) + + def include( + self, + path: str, + *, + match: t.Any = None, + select: t.Sequence[str] | None = None, + sort: t.Sequence[SortInput] | SortInput | None = None, + limit: int | None = None, + per_parent_limit: int | None = None, + strategy: t.Literal["auto", "lookup", "batch"] = "auto", + ) -> Query[T]: + """Load a relation (``Ref``, list or dict of ``Ref``, or ``Backref``) with the results. + + ``path`` may be nested (``"author.company"``). ``limit`` caps the related + documents overall; ``per_parent_limit`` caps them per parent document. + """ + from mongospecs.relations import add_include + + return self._clone( + includes=add_include( + self._includes, + path, + match=match, + select=tuple(select) if select is not None else None, + sort=() + if sort is None + else tuple(sort) + if isinstance(sort, list | tuple) and not _is_pair(sort) + else (sort,), + limit=limit, + per_parent_limit=per_parent_limit, + strategy=strategy, + ) + ) + + def tracked(self) -> Query[T]: + """Return model instances with change tracking (the default).""" + return self._clone(mode="tracked") + + def lean(self) -> Query[T]: + """Return model instances without a snapshot. They are read-only as far as ``save()`` goes.""" + return self._clone(mode="lean") + + def raw(self) -> Query[dict[str, t.Any]]: + """Return plain dicts exactly as stored, with no decoding or validation.""" + return t.cast("Query[dict[str, t.Any]]", self._clone(mode="raw")) + + def unscoped(self, *names: str) -> Query[T]: + """Switch plugin scopes off: all of them, or only the named ones (``"soft_delete"``).""" + if not names: + return self._clone(unscoped=True) + current = frozenset() if isinstance(self._unscoped, bool) else self._unscoped + return self._clone(unscoped=True if self._unscoped is True else current | frozenset(names)) + + def session(self, session: t.Any) -> Query[T]: + return self._clone(session=session) + + def options(self, **options: t.Any) -> Query[T]: + """Driver options: ``max_time_ms``, ``hint``, ``comment``, ``collation``, ``batch_size``, ``allow_disk_use``.""" + unknown = set(options) - _FIND_OPTIONS + if unknown: + raise QueryError(f"unknown query options {sorted(unknown)}; allowed: {sorted(_FIND_OPTIONS)}") + return self._clone(options={**self._options, **options}) + + def or_fail(self) -> Query[T]: + """Make ``first()`` and ``all()`` raise :class:`DocumentNotFound` when nothing matches.""" + return self._clone(fail=True) + + def __getattr__(self, name: str) -> t.Callable[..., Query[T]]: + if name.startswith("_"): + raise AttributeError(name) + scope = self._manager.meta.scope(name) + if scope is None: + raise AttributeError(f"{type(self).__name__!r} has no attribute or named scope {name!r}") + + def apply(*args: t.Any, **kwargs: t.Any) -> Query[T]: + result = scope(self, *args, **kwargs) + return result if isinstance(result, Query) else self.filter(result) + + return apply + + # -- compilation ----------------------------------------------------------- + + def _user_filter(self) -> dict[str, t.Any]: + meta = self._manager.meta + return and_filters(*(compile_filter(c, meta) for c in self._conditions)) + + def _filter(self, op: str = "find") -> dict[str, t.Any]: + ctx = self._manager.ctx(op, self._session, many=True) + return self._manager.scoped(ctx, self._user_filter(), self._unscoped) + + def _projection( + self, needed: t.Iterable[str] = () + ) -> tuple[dict[str, int] | None, frozenset[str] | None]: + meta = self._manager.meta + deferred = set(meta.deferred) + include: list[str] = [] + exclude: list[str] = [] + for name in self._select: + if name.startswith("+"): + deferred.discard(meta.stored_path(name[1:])) + elif name.startswith("-"): + exclude.append(meta.stored_path(name[1:])) + else: + include.append(meta.stored_path(name)) + if include and exclude: + raise QueryError("select() cannot mix included and excluded fields") + if include: + keys = dict.fromkeys([*include, *needed]) + if meta.disc_key: + keys[meta.disc_key] = None + projection = {k: 1 for k in keys} + return projection, frozenset(k.partition(".")[0] for k in keys) | {"_id"} + excluded = [*deferred, *exclude] + if not excluded: + return None, None + return {k: 0 for k in excluded}, meta.stored_keys - {k for k in excluded if "." not in k} + + def _sort_spec(self) -> list[tuple[str, int]]: + return compile_sort(self._sort, self._manager.meta) + + def _plan(self) -> dict[str, t.Any]: + from mongospecs.relations import plan_includes + + manager = self._manager + lookups, batched = plan_includes(manager, self._includes, self._mode) + needed = [manager.meta.fields[i.name].stored for i in self._includes if i.name in manager.meta.refs] + projection, partial = self._projection(needed) + plan: dict[str, t.Any] = { + "filter": self._filter(), + "projection": projection, + "partial": partial, + "sort": self._sort_spec(), + "skip": self._skip, + "limit": self._limit, + "lookups": lookups, + "batched": batched, + } + if lookups: + from mongospecs.relations import lookup_stages + + pipeline: list[dict[str, t.Any]] = [] + if plan["filter"]: + pipeline.append({"$match": plan["filter"]}) + if plan["sort"]: + pipeline.append({"$sort": dict(plan["sort"])}) + if self._skip: + pipeline.append({"$skip": self._skip}) + if self._limit: + pipeline.append({"$limit": self._limit}) + if projection: + pipeline.append({"$project": projection}) + for include in lookups: + pipeline.extend(lookup_stages(manager, include, self._session)) + plan["pipeline"] = pipeline + return plan + + def compile(self) -> dict[str, t.Any]: + """What will be sent to MongoDB: ``find`` arguments, or a ``pipeline`` when joins are used.""" + plan = self._plan() + if "pipeline" in plan: + return {"pipeline": plan["pipeline"]} + return {k: plan[k] for k in ("filter", "projection", "sort", "skip", "limit")} + + # -- execution ------------------------------------------------------------- + + async def _cursor(self, plan: dict[str, t.Any]) -> t.Any: + manager = self._manager + kw = manager._read_kw(self._session, self._options) + if "pipeline" in plan: + agg_kw = {_AGG_OPTIONS.get(k, k): v for k, v in kw.items() if k != "no_cursor_timeout"} + return await manager.collection.aggregate(plan["pipeline"], **agg_kw) + return manager.collection.find( + plan["filter"], + plan["projection"], + sort=plan["sort"] or None, + skip=plan["skip"], + limit=plan["limit"], + **kw, + ) + + async def _chunks(self, chunk: int | None = None) -> t.AsyncIterator[list[t.Any]]: + """Yield materialised results in chunks (relations are resolved per chunk).""" + from mongospecs.relations import attach_lookups, load_batched, pop_lookups + + manager = self._manager + meta = manager.meta + plan = self._plan() + lookups, batched, partial = plan["lookups"], plan["batched"], plan["partial"] + size = chunk or self._options.get("batch_size") or _CHUNK + mode = self._mode + + async def flush(raws: list[dict[str, t.Any]]) -> list[t.Any]: + joined = [pop_lookups(raw, lookups) for raw in raws] if lookups else None + if mode == "raw": + items: list[t.Any] = raws + else: + items = [] + for raw in raws: + doc = meta.decode(raw) + if mode == "tracked": + tracking.track(doc, meta_of(type(doc)).encode(doc), manager, partial) + items.append(doc) + if joined is not None: + attach_lookups(manager, items, joined, lookups, mode) + if batched: + await load_batched(manager, items, batched, mode, self._session) + return items + + with manager._op("find"): + cursor = await self._cursor(plan) + async with cursor: + buffer: list[dict[str, t.Any]] = [] + while True: + with manager._op("find"): + try: + raw = await cursor.__anext__() + except StopAsyncIteration: + break + buffer.append(raw) + if len(buffer) >= size: + yield await flush(buffer) + buffer = [] + if buffer: + yield await flush(buffer) + + async def __aiter__(self) -> t.AsyncIterator[T]: + async for chunk in self._chunks(): + for item in chunk: + yield item + + async def all(self) -> list[T]: + out: list[T] = [] + async for chunk in self._chunks(): + out.extend(chunk) + if self._fail and not out: + raise DocumentNotFound(f"{self._manager.model.__name__}: no document matched") + return out + + def __await__(self) -> t.Generator[t.Any, None, list[T]]: + return self.all().__await__() + + async def first(self) -> T | None: + """The first match, or ``None`` (or :class:`DocumentNotFound` after ``or_fail()``).""" + items = await self._clone(limit=1, fail=False).all() + if items: + return items[0] + if self._fail: + raise DocumentNotFound(f"{self._manager.model.__name__}: no document matched") + return None + + async def one(self) -> T: + """The first match; raises :class:`DocumentNotFound` when there is none.""" + doc = await self.or_fail().first() + assert doc is not None + return doc + + async def count(self) -> int: + manager = self._manager + kw = manager._read_kw(self._session, self._options) + agg_kw = { + _AGG_OPTIONS.get(k, k): v + for k, v in kw.items() + if k in ("session", "max_time_ms", "hint", "collation", "comment") + } + if self._skip: + agg_kw["skip"] = self._skip + if self._limit: + agg_kw["limit"] = self._limit + with manager._op("count"): + return int(await manager.collection.count_documents(self._filter("count"), **agg_kw)) + + async def exists(self) -> bool: + manager = self._manager + with manager._op("exists"): + found = await manager.collection.find_one( + self._filter(), {"_id": 1}, **manager._read_kw(self._session, self._options) + ) + return found is not None + + async def ids(self) -> list[t.Any]: + manager = self._manager + with manager._op("ids"): + cursor = manager.collection.find( + self._filter(), + {"_id": 1}, + sort=self._sort_spec() or None, + skip=self._skip, + limit=self._limit, + **manager._read_kw(self._session, self._options), + ) + return [raw["_id"] async for raw in cursor] + + async def explain(self) -> dict[str, t.Any]: + """The server's query plan.""" + manager = self._manager + plan = self._plan() + with manager._op("explain"): + if "pipeline" in plan: + return dict( + await manager.registry.database(manager.db_name).command( + "explain", + {"aggregate": manager.meta.collection, "pipeline": plan["pipeline"], "cursor": {}}, + ) + ) + cursor = await self._cursor(plan) + return dict(await cursor.explain()) + + async def each(self, fn: t.Callable[[T], t.Awaitable[t.Any]], *, concurrency: int = 1) -> int: + """Call ``fn`` for every result with bounded concurrency. Returns the number processed.""" + if concurrency < 1: + raise QueryError("concurrency must be >= 1") + count = 0 + if concurrency == 1: + async for item in self: + await fn(item) + count += 1 + return count + semaphore = asyncio.Semaphore(concurrency) + + async def run(item: T) -> None: + try: + await fn(item) + finally: + semaphore.release() + + async with asyncio.TaskGroup() as group: + async for item in self: + await semaphore.acquire() + group.create_task(run(item)) + count += 1 + return count + + # -- filter-based writes ----------------------------------------------------- + + def _check_plain(self, what: str) -> None: + if self._skip or self._limit or self._sort: + raise QueryError(f"{what} applies to every match; sort, skip and limit are not supported") + + async def update(self, *ops: UpdateOp, **fields: t.Any) -> int: + """Atomically update every match. Returns the number of documents modified. + + Documents are not loaded, so document hooks do not run; plugins still + rewrite the command (timestamps, revisions, scopes). + """ + self._check_plain("update()") + return await self._manager.update_many( + self._user_filter(), ops, fields, session=self._session, unscoped=self._unscoped + ) + + async def delete(self, *, hard: bool = False) -> int: + """Delete every match (a soft delete when the model has a ``DeletedAt`` field, unless ``hard``).""" + self._check_plain("delete()") + return await self._manager.delete_many( + self._user_filter(), session=self._session, hard=hard, unscoped=self._unscoped + ) + + # -- pagination ---------------------------------------------------------------- + + async def page( + self, + size: int = 20, + *, + after: str | None = None, + number: int | None = None, + total: bool = False, + ) -> Page[T]: + """Fetch one page. + + Without ``number`` this is cursor (keyset) pagination: pass the previous + page's ``next_cursor`` as ``after``. The sort is made total by appending + ``_id``; sort fields should not be null. With ``number`` (1-based) it is + offset pagination. ``total=True`` adds a count of all matches. + """ + if size < 1: + raise QueryError("page size must be >= 1") + if number is not None and after is not None: + raise QueryError("use either number (offset pagination) or after (cursor pagination)") + count_task: t.Awaitable[int] | None = self._clone(skip=0, limit=0, sort=()).count() if total else None + + if number is not None: + if number < 1: + raise QueryError("page numbers start at 1") + query = self._clone(skip=(number - 1) * size, limit=size + 1, fail=False) + if not self._sort: + query = query.sort(("_id", 1)) + else: + sort = self._sort_spec() + if not any(key == "_id" for key, _ in sort): + sort.append(("_id", 1)) + query = self._clone(sort=tuple(sort), skip=0, limit=size + 1, fail=False) + if after is not None: + from mongospecs.expr import Trusted + + query = query.filter(Trusted(_keyset_filter(sort, _decode_cursor(after, len(sort))))) + + total_count: int | None + if count_task is not None and self._session is None: + items, total_count = await asyncio.gather(query.all(), count_task) + else: + items = await query.all() + total_count = await count_task if count_task is not None else None + has_next = len(items) > size + items = items[:size] + next_cursor = None + if number is None and has_next and items: + next_cursor = _encode_cursor(self._sort_values(items[-1], query._sort_spec())) + return Page(items, size, has_next, next_cursor, number, total_count) + + def _sort_values(self, item: t.Any, sort: list[tuple[str, int]]) -> list[t.Any]: + doc = item if isinstance(item, dict) else meta_of(type(item)).encode(item) + return [_dig(doc, key) for key, _ in sort] + + # -- other read shapes ----------------------------------------------------------- + + def graph( + self, + field: str, + *, + connect_to: str = "id", + max_depth: int | None = None, + match: t.Any = None, + ) -> t.Any: + """Follow ``field`` recursively with ``$graphLookup`` (see :class:`mongospecs.relations.GraphQuery`).""" + from mongospecs.relations import GraphQuery + + return GraphQuery(self, field, connect_to, max_depth, match) + + def union(self, *others: Query[t.Any]) -> t.Any: + """Read several collections as one result set with ``$unionWith``.""" + from mongospecs.relations import UnionQuery + + return UnionQuery((self, *others)) + + def __repr__(self) -> str: + return f"" + + +def _is_pair(value: t.Any) -> bool: + return ( + isinstance(value, tuple) + and len(value) == 2 + and isinstance(value[0], str) + and value[1] in (1, -1, "asc", "desc") + ) + + +def _dig(doc: t.Mapping[str, t.Any], path: str) -> t.Any: + value: t.Any = doc + for part in path.split("."): + if not isinstance(value, t.Mapping): + return None + value = value.get(part) + return value + + +def _encode_cursor(values: list[t.Any]) -> str: + return base64.urlsafe_b64encode(bson.encode({"v": values})).decode("ascii").rstrip("=") + + +def _decode_cursor(token: str, expected: int) -> list[t.Any]: + from mongospecs.expr import _has_operator + + try: + padded = token + "=" * (-len(token) % 4) + values = bson.decode(base64.urlsafe_b64decode(padded.encode("ascii")))["v"] + except Exception as exc: + raise QueryError("invalid pagination cursor") from exc + if not isinstance(values, list) or len(values) != expected or _has_operator(values): + raise QueryError("invalid pagination cursor") + return values + + +def _keyset_filter(sort: list[tuple[str, int]], values: list[t.Any]) -> dict[str, t.Any]: + """Documents strictly after ``values`` in ``sort`` order.""" + branches: list[dict[str, t.Any]] = [] + for i, (key, direction) in enumerate(sort): + branch: dict[str, t.Any] = {sort[j][0]: values[j] for j in range(i)} + branch[key] = {"$gt" if direction == 1 else "$lt": values[i]} + branches.append(branch) + return branches[0] if len(branches) == 1 else {"$or": branches} diff --git a/packages/mongospecs/src/mongospecs/refs.py b/packages/mongospecs/src/mongospecs/refs.py new file mode 100644 index 0000000..ac4d9f7 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/refs.py @@ -0,0 +1,177 @@ +"""Backend-neutral value types: ``OID`` and ``Ref``.""" + +from __future__ import annotations + +import typing as t + +from bson import ObjectId + +from mongospecs.errors import NotLoadedError + +__all__ = ["OID", "OID_PATTERN", "Ref", "id_of"] + +T = t.TypeVar("T") + +OID_PATTERN = "^[0-9a-fA-F]{24}$" + + +def _to_object_id(value: t.Any) -> ObjectId: + if isinstance(value, ObjectId): + return value + if isinstance(value, str | bytes): + try: + return ObjectId(value) + except Exception as exc: + raise ValueError(f"{value!r} is not a valid ObjectId") from exc + raise ValueError(f"{type(value).__name__} is not a valid ObjectId") + + +class _OIDAnnotation: + """Teaches pydantic about ``bson.ObjectId``. msgspec and attrs ignore it.""" + + @classmethod + def __get_pydantic_core_schema__(cls, source: t.Any, handler: t.Any) -> t.Any: + from pydantic_core import core_schema as cs + + return cs.no_info_plain_validator_function( + _to_object_id, + serialization=cs.plain_serializer_function_ser_schema(str, when_used="json"), + ) + + @classmethod + def __get_pydantic_json_schema__(cls, schema: t.Any, handler: t.Any) -> dict[str, t.Any]: + return {"type": "string", "pattern": OID_PATTERN} + + +OID = t.Annotated[ObjectId, _OIDAnnotation] +"""``bson.ObjectId`` usable as a field type on every backend.""" + + +def id_of(value: t.Any) -> t.Any: + """Return the id behind a document, a ``Ref`` or a bare id.""" + if isinstance(value, Ref): + return value.id + from mongospecs.model import Document + + if isinstance(value, Document): + return value.id + return value + + +class Ref(t.Generic[T]): + """A reference to a document in another collection. + + Stored as the target's id. ``doc`` is available after ``include()`` or + ``await ref.fetch()``; before that it raises :class:`NotLoadedError`. + """ + + __slots__ = ("__weakref__", "_doc", "_model", "id") + + id: t.Any + _doc: T | None + _model: type[T] | None + + def __init__(self, target: t.Any, model: type[T] | None = None) -> None: + from mongospecs.model import Document + + if isinstance(target, Ref): + self.id = target.id + self._doc = target._doc + self._model = model or target._model + elif isinstance(target, Document): + if target.id is None: + raise ValueError("cannot reference a document that has no id yet") + self.id = target.id + self._doc = t.cast(T, target) + self._model = model or t.cast("type[T]", type(target)) + else: + self.id = target + self._doc = None + self._model = model + + @property + def loaded(self) -> bool: + return self._doc is not None + + @property + def doc(self) -> T: + if self._doc is None: + raise NotLoadedError( + f"reference to {self.id!r} is not loaded; use include() on the query or await ref.fetch()" + ) + return self._doc + + async def fetch(self, *, session: t.Any = None) -> T: + """Load the referenced document (once) and return it.""" + if self._doc is None: + if self._model is None: + raise NotLoadedError("reference has no target model; it cannot be fetched") + self._doc = await t.cast(t.Any, self._model).get(self.id, session=session) + return t.cast(T, self._doc) + + def __eq__(self, other: object) -> bool: + if isinstance(other, Ref): + return bool(self.id == other.id) + return bool(self.id == other) + + def __hash__(self) -> int: + return hash(self.id) + + def __repr__(self) -> str: + state = "loaded" if self._doc is not None else "unloaded" + return f"Ref({self.id!r}, {state})" + + # -- pydantic ----------------------------------------------------------- + + @classmethod + def __get_pydantic_core_schema__(cls, source: t.Any, handler: t.Any) -> t.Any: + from pydantic_core import core_schema as cs + + args = t.get_args(source) + model = args[0] if args and isinstance(args[0], type) else None + + def validate(value: t.Any) -> Ref[t.Any]: + return coerce_ref(value, model) + + def serialize(ref: t.Any, info: t.Any) -> t.Any: + # Plain assignment is not validated, so this may be a bare id or a document. + value = id_of(ref) + return _json_id(value) if info.mode == "json" else value + + return cs.no_info_plain_validator_function( + validate, + serialization=cs.plain_serializer_function_ser_schema(serialize, info_arg=True), + ) + + @classmethod + def __get_pydantic_json_schema__(cls, schema: t.Any, handler: t.Any) -> dict[str, t.Any]: + return {"type": "string", "pattern": OID_PATTERN} + + +def _json_id(value: t.Any) -> t.Any: + return str(value) if isinstance(value, ObjectId) else value + + +def coerce_ref(value: t.Any, model: type[t.Any] | None) -> Ref[t.Any]: + """Build a ``Ref`` from an id, an id string, a document or another ``Ref``.""" + from mongospecs.model import Document + + if isinstance(value, Ref): + if value._model is None and model is not None: + value._model = model + return value + if isinstance(value, Document): + return Ref(value, model) + if isinstance(value, str) and ObjectId.is_valid(value) and _target_id_is_object_id(model): + return Ref(ObjectId(value), model) + if value is None or isinstance(value, dict | list): + raise ValueError(f"{type(value).__name__} is not a valid reference") + return Ref(value, model) + + +def _target_id_is_object_id(model: type[t.Any] | None) -> bool: + if model is None: + return True + from mongospecs.meta import id_python_type + + return id_python_type(model) is ObjectId diff --git a/packages/mongospecs/src/mongospecs/registry.py b/packages/mongospecs/src/mongospecs/registry.py new file mode 100644 index 0000000..f34f93b --- /dev/null +++ b/packages/mongospecs/src/mongospecs/registry.py @@ -0,0 +1,574 @@ +"""Registry: owns the client, binds models, runs transactions. + +Lifecycle is explicit. Constructing a registry does no I/O; ``async with`` +opens it; closing is final. The client binds to the event loop that first uses +it, so use one registry per loop (in tests: one per test, or one session loop). +""" + +from __future__ import annotations + +import contextvars +import datetime as dt +import decimal +import enum +import logging +import typing as t +import weakref +from contextlib import asynccontextmanager +from dataclasses import dataclass + +from bson.binary import UuidRepresentation +from bson.codec_options import CodecOptions, TypeCodec, TypeRegistry +from bson.decimal128 import Decimal128 +from bson.errors import InvalidDocument +from pymongo import AsyncMongoClient, monitoring +from pymongo import errors as pe + +from mongospecs import tracking +from mongospecs.errors import ( + ConfigurationError, + MongoSpecsError, + NotBoundError, + RegistryClosedError, + translate, + wrap_errors, +) +from mongospecs.hooks import Plugin +from mongospecs.refs import Ref + +if t.TYPE_CHECKING: + from pymongo.asynchronous.client_session import AsyncClientSession + from pymongo.asynchronous.database import AsyncDatabase + + from mongospecs.manager import Manager + +__all__ = [ + "CommandEvent", + "Registry", + "Tx", + "logging_listener", + "otel_listener", + "registry_of", + "sentry_listener", +] + +R = t.TypeVar("R") + +_BINDINGS: weakref.WeakKeyDictionary[type, Registry] = weakref.WeakKeyDictionary() +_CURRENT_OP: contextvars.ContextVar[tuple[str, str] | None] = contextvars.ContextVar( + "mongospecs_current_op", default=None +) + + +# -- BSON codec options ------------------------------------------------------- + + +class _DecimalCodec(TypeCodec): + python_type = decimal.Decimal + bson_type = Decimal128 + + def transform_python(self, value: decimal.Decimal) -> Decimal128: + return Decimal128(value) + + def transform_bson(self, value: Decimal128) -> decimal.Decimal: + return value.to_decimal() + + +def _fallback_encoder(value: t.Any) -> t.Any: + if isinstance(value, enum.Enum): + return value.value + if isinstance(value, Ref): + return value.id + if isinstance(value, dt.date): + return value.isoformat() + if isinstance(value, set | frozenset): + return list(value) + raise InvalidDocument(f"cannot encode object of type {type(value).__name__} to BSON") + + +def codec_options(tz_aware: bool = True) -> CodecOptions[dict[str, t.Any]]: + return CodecOptions( + tz_aware=tz_aware, + uuid_representation=UuidRepresentation.STANDARD, + type_registry=TypeRegistry([_DecimalCodec()], fallback_encoder=_fallback_encoder), + ) + + +# -- command observability ---------------------------------------------------- + + +@dataclass(frozen=True, slots=True) +class CommandEvent: + """One driver command, as seen by ``Registry(on_command=...)`` listeners.""" + + phase: t.Literal["started", "succeeded", "failed"] + command_name: str + database: str + request_id: int + model: str | None + """Name of the model the command was issued for, when it came through mongospecs.""" + op: str | None + """mongospecs operation: ``find``, ``insert``, ``update`` ...""" + duration_ms: float | None = None + command: t.Mapping[str, t.Any] | None = None + failure: t.Any = None + + +CommandListener = t.Callable[[CommandEvent], None] + + +class _Monitor(monitoring.CommandListener): + def __init__(self, listeners: t.Sequence[CommandListener]) -> None: + self._listeners = tuple(listeners) + + def _emit(self, event: CommandEvent) -> None: + for listener in self._listeners: + listener(event) + + def started(self, event: monitoring.CommandStartedEvent) -> None: + model, op = _CURRENT_OP.get() or (None, None) + self._emit( + CommandEvent( + "started", + event.command_name, + event.database_name, + event.request_id, + model, + op, + None, + event.command, + ) + ) + + def succeeded(self, event: monitoring.CommandSucceededEvent) -> None: + model, op = _CURRENT_OP.get() or (None, None) + self._emit( + CommandEvent( + "succeeded", + event.command_name, + event.database_name, + event.request_id, + model, + op, + event.duration_micros / 1000, + ) + ) + + def failed(self, event: monitoring.CommandFailedEvent) -> None: + model, op = _CURRENT_OP.get() or (None, None) + self._emit( + CommandEvent( + "failed", + event.command_name, + event.database_name, + event.request_id, + model, + op, + event.duration_micros / 1000, + None, + event.failure, + ) + ) + + +def logging_listener(logger: logging.Logger | None = None, level: int = logging.DEBUG) -> CommandListener: + """Log every command (the equivalent of ``mongoose.set('debug', true)``).""" + log = logger or logging.getLogger("mongospecs.commands") + + def listen(event: CommandEvent) -> None: + target = f"{event.model}.{event.op}" if event.model else "-" + if event.phase == "started": + log.log(level, "%s %s.%s started %s", target, event.database, event.command_name, event.command) + elif event.phase == "succeeded": + log.log( + level, "%s %s.%s ok in %.2fms", target, event.database, event.command_name, event.duration_ms + ) + else: + log.log( + max(level, logging.WARNING), + "%s %s.%s failed in %.2fms: %s", + target, + event.database, + event.command_name, + event.duration_ms, + event.failure, + ) + + return listen + + +def otel_listener(tracer: t.Any = None) -> CommandListener: + """Create one OpenTelemetry span per command. Needs ``mongospecs[otel]``.""" + from opentelemetry import trace + + tr = tracer or trace.get_tracer("mongospecs") + spans: dict[int, t.Any] = {} + + def listen(event: CommandEvent) -> None: + if event.phase == "started": + name = f"{event.model}.{event.op}" if event.model else event.command_name + span = tr.start_span(name, kind=trace.SpanKind.CLIENT) + span.set_attribute("db.system", "mongodb") + span.set_attribute("db.name", event.database) + span.set_attribute("db.operation", event.command_name) + if event.model: + span.set_attribute("mongospecs.model", event.model) + spans[event.request_id] = span + return + span = spans.pop(event.request_id, None) + if span is None: + return + if event.phase == "failed": + span.set_status(trace.Status(trace.StatusCode.ERROR, str(event.failure))) + span.end() + + return listen + + +def sentry_listener() -> CommandListener: + """Record one Sentry span per command, named after the model and operation. Needs ``mongospecs[sentry]``. + + Spans attach to whatever span or transaction is active, so a request traced + by a Sentry web integration shows its database work as ``Article.find``, + ``Article.save`` and so on. Works with any Sentry-compatible backend. + + The Sentry SDK's own ``PyMongoIntegration`` records the same commands under + driver names; disable it to avoid seeing each command twice:: + + sentry_sdk.init(dsn=..., disabled_integrations=[PyMongoIntegration()]) + """ + import sentry_sdk + + spans: dict[int, t.Any] = {} + + def listen(event: CommandEvent) -> None: + if event.phase == "started": + name = f"{event.model}.{event.op}" if event.model else event.command_name + span = sentry_sdk.start_span(op="db", name=name, origin="auto.db.mongospecs") + span.set_data("db.system", "mongodb") + span.set_data("db.name", event.database) + span.set_data("db.operation", event.command_name) + if event.model: + span.set_data("mongospecs.model", event.model) + span.set_data("mongospecs.op", event.op) + spans[event.request_id] = span + return + span = spans.pop(event.request_id, None) + if span is None: + return + span.set_status("internal_error" if event.phase == "failed" else "ok") + span.finish() + + return listen + + +# -- transactions ------------------------------------------------------------- + + +class Tx: + """A session passed as ``session=`` to operations. + + Besides carrying the driver session, it journals the in-memory state of + every document written through it, so that state can be put back if the + transaction aborts or is retried. + """ + + def __init__(self, session: AsyncClientSession, registry: Registry) -> None: + self.session = session + self.registry = registry + self._journal: list[tuple[t.Any, tracking.DocState | None, dict[str, t.Any]]] = [] + + def record(self, doc: t.Any, attrs: t.Iterable[str]) -> None: + """Remember ``doc`` as it is now. Called before a write changes it.""" + self._journal.append((doc, tracking.state_of(doc), {name: getattr(doc, name) for name in attrs})) + + def rollback_memory(self) -> None: + """Put journaled documents back to how they were before this transaction touched them.""" + for doc, state, attrs in reversed(self._journal): + tracking.restore(doc, state) + for name, value in attrs.items(): + setattr(doc, name, value) + self._journal.clear() + + @asynccontextmanager + async def start_transaction(self, **options: t.Any) -> t.AsyncIterator[Tx]: + """Manual transaction. The block is not re-run on transient errors.""" + try: + with wrap_errors(): + async with await self.session.start_transaction(**options): + yield self + except BaseException: + self.rollback_memory() + raise + self._journal.clear() + + @property + def in_transaction(self) -> bool: + return bool(self.session.in_transaction) + + +def driver_session(session: t.Any) -> t.Any: + """Unwrap a ``Tx`` to the driver session; pass driver sessions and ``None`` through.""" + return session.session if isinstance(session, Tx) else session + + +# -- the registry --------------------------------------------------------------- + + +class Registry: + """Owns one ``AsyncMongoClient`` and the models bound to it. + + :: + + async with Registry("mongodb://localhost/app", models=[Article, User]) as registry: + await Article.find(Q.title == "x").all() + """ + + def __init__( + self, + uri: str = "mongodb://localhost:27017", + *, + database: str | None = None, + models: t.Iterable[type] = (), + plugins: t.Iterable[Plugin] = (), + max_time_ms: int | None = None, + on_command: CommandListener | t.Iterable[CommandListener] | None = None, + tz_aware: bool = True, + **client_options: t.Any, + ) -> None: + self.uri = uri + self._database = database + self.plugins: tuple[Plugin, ...] = tuple(plugins) + self.max_time_ms = max_time_ms + self._client_options = client_options + self._codec_options = codec_options(tz_aware) + if on_command is None: + self._listeners: tuple[CommandListener, ...] = () + elif callable(on_command): + self._listeners = (on_command,) + else: + self._listeners = tuple(on_command) + self._client: AsyncMongoClient[dict[str, t.Any]] | None = None + self._state: t.Literal["new", "open", "closed"] = "new" + self._models: list[type] = [] + self._managers: dict[tuple[type, str | None], Manager[t.Any]] = {} + self.bind(*models) + + # -- lifecycle ----------------------------------------------------------- + + @property + def is_open(self) -> bool: + return self._state == "open" + + async def open(self) -> Registry: + if self._state == "closed": + raise RegistryClosedError("a closed registry cannot be reopened; create a new one") + if self._state == "new": + options = dict(self._client_options) + if self._listeners: + options["event_listeners"] = [*options.get("event_listeners", []), _Monitor(self._listeners)] + options.setdefault("uuidRepresentation", "standard") + options.setdefault("tz_aware", self._codec_options.tz_aware) + self._client = AsyncMongoClient(self.uri, **options) + self._state = "open" + # Models may have been bound elsewhere since construction; claim them now. + self.bind(*self._models) + return self + + async def close(self) -> None: + if self._state == "closed": + return + self._state = "closed" + for model in self._models: + if _BINDINGS.get(model) is self: + del _BINDINGS[model] + self._managers.clear() + client, self._client = self._client, None + if client is not None: + await client.close() + + async def __aenter__(self) -> Registry: + return await self.open() + + async def __aexit__(self, *exc: object) -> None: + await self.close() + + @property + def client(self) -> AsyncMongoClient[dict[str, t.Any]]: + if self._client is None or self._state != "open": + raise RegistryClosedError( + "the registry is not open; use `async with registry:` or `await registry.open()`" + ) + return self._client + + async def ping(self) -> None: + with wrap_errors(): + await self.client.admin.command("ping") + + # -- models ---------------------------------------------------------------- + + def bind(self, *models: type) -> None: + """Bind document classes to this registry. Validates each model's declaration.""" + from mongospecs.meta import meta_of + + if self._state == "closed": + raise RegistryClosedError("cannot bind models to a closed registry") + for model in models: + meta_of(model) # fails here, not at first write, if the model is misdeclared + current = _BINDINGS.get(model) + if current is not None and current is not self and current._state == "open": + raise ConfigurationError( + f"{model.__name__} is already bound to another open registry; " + "use registry.manager(Model) for a second binding" + ) + _BINDINGS[model] = self + if model not in self._models: + self._models.append(model) + + @property + def models(self) -> tuple[type, ...]: + return tuple(self._models) + + def database(self, name: str | None = None) -> AsyncDatabase[dict[str, t.Any]]: + client = self.client + if name is None: + name = self._database + if name is None: + try: + name = client.get_default_database().name + except pe.ConfigurationError: + raise ConfigurationError( + "no database: put one in the URI or pass database= to Registry" + ) from None + return client.get_database(name, codec_options=self._codec_options) + + def manager(self, model: type[R], *, db: str | None = None) -> Manager[R]: + """The object that performs operations for ``model`` on this registry.""" + from mongospecs.manager import Manager + + key = (model, db) + manager = self._managers.get(key) + if manager is None: + manager = Manager(model, self, db=db) + self._managers[key] = manager + return t.cast("Manager[R]", manager) + + # -- sessions and transactions --------------------------------------------- + + @asynccontextmanager + async def session(self, **options: t.Any) -> t.AsyncIterator[Tx]: + """A session for manual control: ``async with registry.session() as s, s.start_transaction():``""" + with wrap_errors(): + session = self.client.start_session(**options) + async with session: + yield Tx(session, self) + + async def transaction( + self, + fn: t.Callable[[Tx], t.Awaitable[R]], + *, + read_concern: t.Any = None, + write_concern: t.Any = None, + read_preference: t.Any = None, + max_commit_time_ms: int | None = None, + ) -> R: + """Run ``fn(tx)`` in a transaction and commit it. + + ``fn`` may be called more than once: the driver re-runs it on transient + transaction errors. Before every re-run, and if the transaction finally + aborts, documents written inside it are restored to their prior + in-memory state. Pass ``session=tx`` to every operation inside ``fn``. + """ + async with self.client.start_session() as session: + tx = Tx(session, self) + + async def attempt(_: t.Any) -> R: + tx.rollback_memory() + try: + return await fn(tx) + except MongoSpecsError as exc: + # Let the driver see its own error so its retry logic applies. + cause = exc.__cause__ + if isinstance(cause, pe.PyMongoError) and cause.has_error_label( + "TransientTransactionError" + ): + raise cause from None + raise + + try: + result = await session.with_transaction( + attempt, + read_concern=read_concern, + write_concern=write_concern, + read_preference=read_preference, + max_commit_time_ms=max_commit_time_ms, + ) + except MongoSpecsError: + tx.rollback_memory() + raise + except pe.PyMongoError as exc: + tx.rollback_memory() + raise translate(exc) from exc + except BaseException: + tx.rollback_memory() + raise + tx._journal.clear() + return result + + # -- indexes --------------------------------------------------------------- + + async def plan_indexes(self, *models: type) -> t.Any: + """Compare declared and existing indexes. See :func:`mongospecs.indexes.plan_indexes`.""" + from mongospecs.indexes import plan_indexes + + return await plan_indexes(self, *models) + + async def apply_indexes(self, plan: t.Any = None, *, drop: bool = False) -> t.Any: + """Create missing indexes; drop or recreate only with ``drop=True``.""" + from mongospecs.indexes import apply_indexes + + return await apply_indexes(self, plan, drop=drop) + + def __repr__(self) -> str: + return f"" + + +def registry_of(model: type) -> Registry: + """The registry a model is bound to.""" + for klass in model.__mro__: + registry = _BINDINGS.get(klass) + if registry is not None and registry._state != "closed": + if registry._state != "open": + raise RegistryClosedError( + f"the registry {model.__name__} is bound to is not open; " + "use `async with registry:` or `await registry.open()`" + ) + return registry + raise NotBoundError( + f"{model.__name__} is not bound to an open registry; " + "pass it in Registry(models=[...]) or call registry.bind()" + ) + + +class op_scope: + """Marks the mongospecs operation in progress, for command listeners, and translates errors.""" + + __slots__ = ("_token", "_value") + + def __init__(self, registry: Registry, model: str, op: str) -> None: + self._value = (model, op) if registry._listeners else None + self._token: t.Any = None + + def __enter__(self) -> None: + if self._value is not None: + self._token = _CURRENT_OP.set(self._value) + + def __exit__(self, exc_type: t.Any, exc: BaseException | None, tb: t.Any) -> None: + if self._token is not None: + _CURRENT_OP.reset(self._token) + if exc is not None and not isinstance(exc, MongoSpecsError) and isinstance(exc, pe.PyMongoError): + raise translate(exc) from exc + from bson.errors import BSONError + + if isinstance(exc, BSONError): + raise translate(exc) from exc diff --git a/packages/mongospecs/src/mongospecs/relations.py b/packages/mongospecs/src/mongospecs/relations.py new file mode 100644 index 0000000..afaef23 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/relations.py @@ -0,0 +1,615 @@ +"""Relationship loading. + +``include()`` can be served two ways and both give the same result: + +* **batch** - one extra ``find`` per relation with ``_id: {$in: [...]}`` (what + Mongoose's ``populate`` does); +* **lookup** - a ``$lookup`` stage in a single aggregation pipeline. + +``DEFAULT_STRATEGY`` is what ``strategy="auto"`` uses. It is chosen from +benchmark data, not assumed; see ``bench/`` workload B3. + +Also here: cascade rules, ``$graphLookup`` traversal and ``$unionWith`` reads. +""" + +from __future__ import annotations + +import dataclasses +import typing as t +from dataclasses import dataclass + +from mongospecs import tracking +from mongospecs.codec import FieldInfo +from mongospecs.errors import ConfigurationError, QueryError +from mongospecs.expr import SortInput, Trusted, compile_filter, compile_sort +from mongospecs.markers import OnDelete +from mongospecs.meta import meta_of +from mongospecs.refs import Ref, id_of + +if t.TYPE_CHECKING: + from mongospecs.manager import Manager + from mongospecs.query import Query + +__all__ = ["DEFAULT_STRATEGY", "GraphQuery", "GraphResult", "Include", "UnionQuery"] + +T = t.TypeVar("T") + +DEFAULT_STRATEGY: t.Literal["lookup", "batch"] = "batch" + +_PREFIX = "__inc_" + + +@dataclass(frozen=True, slots=True) +class Include: + name: str + match: t.Any = None + select: tuple[str, ...] | None = None + sort: tuple[SortInput, ...] = () + limit: int | None = None + per_parent_limit: int | None = None + strategy: str = "auto" + nested: tuple[Include, ...] = () + + +def add_include(includes: tuple[Include, ...], path: str, **options: t.Any) -> tuple[Include, ...]: + """Add ``path`` (possibly dotted) to a tuple of includes, merging shared prefixes.""" + head, _, rest = path.partition(".") + existing = next((i for i in includes if i.name == head), None) + if rest: + base = existing or Include(head, strategy=options.get("strategy", "auto")) + updated = dataclasses.replace(base, nested=add_include(base.nested, rest, **options)) + else: + updated = Include(head, nested=existing.nested if existing else (), **options) + return (*(i for i in includes if i.name != head), updated) + + +def _relation(manager: Manager[t.Any], include: Include) -> FieldInfo: + info = manager.meta.fields.get(include.name) + if info is None or (info.ref is None and info.backref is None): + raise QueryError(f"{manager.model.__name__}.{include.name} is not a relation and cannot be included") + return info + + +def _target(manager: Manager[t.Any], model: type) -> Manager[t.Any]: + return manager.registry.manager(model, db=manager.db_name) + + +def _lookup_capable(manager: Manager[t.Any], include: Include) -> bool: + info = _relation(manager, include) + targets = manager.meta.ref_targets(include.name) + if len(targets) != 1 or include.limit is not None: + return False + if info.ref is not None and info.ref.shape == "dict": + return False + child = _target(manager, targets[0].model) + return all(_lookup_capable(child, nested) for nested in include.nested) + + +def plan_includes( + manager: Manager[t.Any], includes: tuple[Include, ...], mode: str +) -> tuple[list[Include], list[Include]]: + """Split includes into those served by ``$lookup`` and those served by batched queries.""" + lookups: list[Include] = [] + batched: list[Include] = [] + for include in includes: + _relation(manager, include) + strategy = include.strategy if include.strategy != "auto" else DEFAULT_STRATEGY + if strategy == "lookup" and mode != "raw": + if _lookup_capable(manager, include): + lookups.append(include) + continue + if include.strategy == "lookup": + raise QueryError( + f"include({include.name!r}) cannot use $lookup: polymorphic targets, dict-of-references " + "and an overall limit need the batch strategy" + ) + batched.append(include) + return lookups, batched + + +# -- materialising related documents ------------------------------------------------ + + +def _child_projection( + target: Manager[t.Any], include: Include, extra: t.Iterable[str] = () +) -> dict[str, int] | None: + query = target.find() + if include.select is not None: + query = query.select(*include.select) + return query._projection(extra)[0] + + +def _materialize( + target: Manager[t.Any], raw: dict[str, t.Any], mode: str, partial: frozenset[str] | None +) -> t.Any: + if mode == "raw": + return raw + doc = target.meta.decode(raw) + if mode == "tracked": + tracking.track(doc, meta_of(type(doc)).encode(doc), target, partial) + return doc + + +def _key(value: t.Any) -> t.Any: + value = id_of(value) + try: + hash(value) + except TypeError: + return repr(value) + return value + + +def _attach_forward( + item: t.Any, info: FieldInfo, by_id: t.Mapping[t.Any, t.Any], model: type | None, limit: int | None +) -> None: + assert info.ref is not None + shape = info.ref.shape + if isinstance(item, dict): # raw mode: replace ids with the related raw documents + value = item.get(info.stored) + if value is None: + return + if shape == "one": + item[info.stored] = by_id.get(_key(value)) + elif shape == "list": + found = [by_id[_key(v)] for v in value if _key(v) in by_id] + item[info.stored] = found[:limit] if limit else found + else: + item[info.stored] = {k: by_id.get(_key(v)) for k, v in value.items()} + return + value = getattr(item, info.name) + if value is None: + return + + def ref(v: t.Any) -> Ref[t.Any]: + r = v if type(v) is Ref else Ref(v, model) + doc = by_id.get(_key(r.id)) + if doc is not None: + r._doc = doc + r._model = type(doc) + return r + + if shape == "one": + new: t.Any = ref(value) + elif shape == "list": + new = [ref(v) for v in value] + if limit: + loaded = 0 + for r in new: + if r._doc is not None: + loaded += 1 + if loaded > limit: + r._doc = None + else: + new = {k: ref(v) for k, v in value.items()} + setattr(item, info.name, new) + + +def _attach_backref(item: t.Any, info: FieldInfo, value: t.Any) -> None: + if isinstance(item, dict): + item[info.name] = value + else: + setattr(item, info.name, value) + + +def _item_id(item: t.Any) -> t.Any: + return item["_id"] if isinstance(item, dict) else item.id + + +# -- lookup strategy --------------------------------------------------------------------- + + +def lookup_stages(manager: Manager[t.Any], include: Include, session: t.Any) -> list[dict[str, t.Any]]: + """The ``$lookup`` stage (with nested lookups) for one include.""" + info = _relation(manager, include) + (target_meta,) = manager.meta.ref_targets(include.name) + target = _target(manager, target_meta.model) + ctx = target.ctx("find", session, many=True) + match = compile_filter(include.match, target_meta) if include.match is not None else None + scope = target.scoped(ctx, match) + counting = info.backref is not None and info.backref.count + + sub: list[dict[str, t.Any]] = [] + if scope: + sub.append({"$match": scope}) + if counting: + sub.append({"$count": "n"}) + else: + if include.sort: + sub.append({"$sort": dict(compile_sort(include.sort, target_meta))}) + if include.per_parent_limit: + sub.append({"$limit": include.per_parent_limit}) + needed = [target_meta.fields[n.name].stored for n in include.nested if n.name in target_meta.refs] + projection = _child_projection(target, include, needed) + if projection: + sub.append({"$project": projection}) + for nested in include.nested: + sub.extend(lookup_stages(target, nested, session)) + + if info.backref is not None: + local, foreign = "_id", target_meta.stored_path(info.backref.field) + else: + local, foreign = info.stored, "_id" + stage: dict[str, t.Any] = { + "from": target_meta.collection, + "localField": local, + "foreignField": foreign, + "as": _PREFIX + include.name, + } + if sub: + stage["pipeline"] = sub + return [{"$lookup": stage}] + + +def pop_lookups(raw: dict[str, t.Any], lookups: t.Sequence[Include]) -> dict[str, list[dict[str, t.Any]]]: + return {i.name: raw.pop(_PREFIX + i.name, []) for i in lookups} + + +def attach_lookups( + manager: Manager[t.Any], + items: list[t.Any], + joined: list[dict[str, list[dict[str, t.Any]]]], + lookups: t.Sequence[Include], + mode: str, +) -> None: + for include in lookups: + info = _relation(manager, include) + (target_meta,) = manager.meta.ref_targets(include.name) + target = _target(manager, target_meta.model) + counting = info.backref is not None and info.backref.count + partial = None if counting else target.find().select(*(include.select or ()))._projection()[1] + for item, joins in zip(items, joined, strict=True): + raws = joins[include.name] + if counting: + _attach_backref(item, info, raws[0]["n"] if raws else 0) + continue + nested_joined = [pop_lookups(raw, include.nested) for raw in raws] if include.nested else None + children = [_materialize(target, raw, mode, partial) for raw in raws] + if nested_joined is not None: + attach_lookups(target, children, nested_joined, include.nested, mode) + if info.backref is not None: + _attach_backref(item, info, children) + else: + by_id = {_key(_item_id(c)): c for c in children} + # A per-parent limit was applied server-side already. + _attach_forward(item, info, by_id, target_meta.model, None) + + +# -- batch strategy ---------------------------------------------------------------------- + + +def _child_query( + target: Manager[t.Any], + include: Include, + flt: dict[str, t.Any], + mode: str, + session: t.Any, + extra_select: tuple[str, ...] = (), +) -> Query[t.Any]: + query = target.find(Trusted(flt), session=session) + if include.match is not None: + query = query.filter(include.match) + if include.select is not None: + query = query.select(*include.select, *extra_select) + if include.sort: + query = query.sort(*include.sort) + if include.limit is not None: + query = query.limit(include.limit) + query = query._clone(mode=mode, includes=include.nested) + return query + + +async def load_batched( + manager: Manager[t.Any], items: list[t.Any], includes: t.Sequence[Include], mode: str, session: t.Any +) -> None: + """Resolve includes with one query per relation (per target model) for the whole batch.""" + if not items: + return + for include in includes: + info = _relation(manager, include) + targets = manager.meta.ref_targets(include.name) + if info.backref is not None: + await _load_backref(manager, items, include, info, targets[0].model, mode, session) + continue + assert info.ref is not None + ids: dict[t.Any, t.Any] = {} + for item in items: + value = item.get(info.stored) if isinstance(item, dict) else getattr(item, info.name) + if value is None: + continue + values = ( + value + if info.ref.shape == "list" + else value.values() + if info.ref.shape == "dict" + else (value,) + ) + for v in values: + if v is not None: + ids[_key(v)] = id_of(v) + by_id: dict[t.Any, t.Any] = {} + if ids: + for target_meta in targets: + target = _target(manager, target_meta.model) + children = await _child_query( + target, include, {"_id": {"$in": list(ids.values())}}, mode, session + ).all() + for child in children: + by_id[_key(_item_id(child))] = child + model = targets[0].model if len(targets) == 1 else None + for item in items: + _attach_forward(item, info, by_id, model, include.per_parent_limit) + + +async def _load_backref( + manager: Manager[t.Any], + items: list[t.Any], + include: Include, + info: FieldInfo, + model: type, + mode: str, + session: t.Any, +) -> None: + assert info.backref is not None + target = _target(manager, model) + fk_attr = info.backref.field + fk = target.meta.stored_path(fk_attr) + parent_ids = [_item_id(item) for item in items] + if info.backref.count: + pipeline: list[dict[str, t.Any]] = [ + {"$match": {fk: {"$in": parent_ids}}}, + {"$unwind": f"${fk}"}, + {"$match": {fk: {"$in": parent_ids}}}, + {"$group": {"_id": f"${fk}", "n": {"$sum": 1}}}, + ] + if include.match is not None: + pipeline.insert(1, {"$match": compile_filter(include.match, target.meta)}) + counts = {_key(row["_id"]): row["n"] async for row in target.aggregate(pipeline, session=session)} + for item in items: + _attach_backref(item, info, counts.get(_key(_item_id(item)), 0)) + return + children = await _child_query( + target, include, {fk: {"$in": parent_ids}}, mode, session, extra_select=(fk_attr,) + ).all() + grouped: dict[t.Any, list[t.Any]] = {} + for child in children: + value = child.get(fk) if isinstance(child, dict) else getattr(child, fk_attr) + for v in value if isinstance(value, list | tuple) else (value,): + grouped.setdefault(_key(v), []).append(child) + for item in items: + found = grouped.get(_key(_item_id(item)), []) + _attach_backref( + item, info, found[: include.per_parent_limit] if include.per_parent_limit else list(found) + ) + + +# -- cascades ------------------------------------------------------------------------------ + + +@dataclass(frozen=True, slots=True) +class CascadeRule: + manager: t.Any + field: FieldInfo + action: str + + +def cascade_rules(manager: Manager[t.Any]) -> list[CascadeRule]: + """Rules declared (with ``OnDelete``) on references that point at ``manager``'s model.""" + rules: list[CascadeRule] = [] + for model in manager.registry.models: + meta = meta_of(model) + for name, info in meta.refs.items(): + marker = info.get(OnDelete) + if marker is None: + continue + targets = [m.model for m in meta.ref_targets(name)] + if not any( + issubclass(manager.model, target) or issubclass(target, manager.model) for target in targets + ): + continue + assert info.ref is not None + if marker.action == "pull" and info.ref.shape != "list": + raise ConfigurationError( + f"{model.__name__}.{name}: OnDelete('pull') needs a list of references" + ) + if marker.action == "nullify" and info.ref.shape != "one": + raise ConfigurationError( + f"{model.__name__}.{name}: OnDelete('nullify') needs a single reference" + ) + rules.append(CascadeRule(_target(manager, model), info, marker.action)) + return rules + + +async def apply_cascades( + manager: Manager[t.Any], rules: list[CascadeRule], ids: list[t.Any], session: t.Any +) -> None: + for rule in rules: + other: Manager[t.Any] = rule.manager + stored = rule.field.stored + flt = {stored: {"$in": ids}} + if rule.action == "cascade": + await other.delete_many(flt, session=session) + continue + ctx = other.ctx("update", session, many=True) + scoped = other.scoped(ctx, flt, True) + update: dict[str, t.Any] = ( + {"$set": {stored: None}} if rule.action == "nullify" else {"$pull": {stored: {"$in": ids}}} + ) + for plugin in other.plugins: + plugin.on_update(ctx, scoped, update) + with other._op("cascade"): + await other.collection.update_many(scoped, update, **other._kw(session)) + + +# -- $graphLookup ---------------------------------------------------------------------------- + + +@dataclass(slots=True) +class GraphResult(t.Generic[T]): + """A starting document and everything reachable from it.""" + + doc: t.Any + nodes: list[t.Any] + """Connected documents, nearest first.""" + depths: list[int] + """Depth of each node (0 = directly connected).""" + + +class GraphQuery: + """``Model.find(...).graph("parent")``: follow a self-reference recursively in one round trip.""" + + def __init__( + self, query: Query[t.Any], field: str, connect_to: str, max_depth: int | None, match: t.Any + ) -> None: + self._query = query + self._field = field + self._connect_to = connect_to + self._max_depth = max_depth + self._match = match + + def compile(self) -> list[dict[str, t.Any]]: + query = self._query + manager = query._manager + meta = manager.meta + ctx = manager.ctx("find", query._session, many=True) + connect_from = meta.stored_path(self._field) + stage: dict[str, t.Any] = { + "from": meta.collection, + "startWith": f"${connect_from}", + "connectFromField": connect_from, + "connectToField": meta.stored_path(self._connect_to), + "as": "__graph", + "depthField": "__depth", + } + if self._max_depth is not None: + stage["maxDepth"] = self._max_depth + restrict = manager.scoped( + ctx, compile_filter(self._match, meta) if self._match is not None else None, query._unscoped + ) + if restrict: + stage["restrictSearchWithMatch"] = restrict + pipeline: list[dict[str, t.Any]] = [] + flt = query._filter() + if flt: + pipeline.append({"$match": flt}) + sort = query._sort_spec() + if sort: + pipeline.append({"$sort": dict(sort)}) + if query._skip: + pipeline.append({"$skip": query._skip}) + if query._limit: + pipeline.append({"$limit": query._limit}) + pipeline.append({"$graphLookup": stage}) + return pipeline + + async def all(self) -> list[GraphResult[t.Any]]: + query = self._query + manager = query._manager + mode = query._mode + results: list[GraphResult[t.Any]] = [] + with manager._op("graph"): + cursor = await manager.collection.aggregate(self.compile(), **manager._kw(query._session)) + raws = [raw async for raw in cursor] + for raw in raws: + nodes_raw = sorted(raw.pop("__graph", []), key=lambda n: n.get("__depth", 0)) + depths = [int(n.pop("__depth", 0)) for n in nodes_raw] + results.append( + GraphResult( + _materialize(manager, raw, mode, None), + [_materialize(manager, n, mode, None) for n in nodes_raw], + depths, + ) + ) + return results + + def __await__(self) -> t.Generator[t.Any, None, list[GraphResult[t.Any]]]: + return self.all().__await__() + + +# -- $unionWith -------------------------------------------------------------------------------- + + +class UnionQuery: + """Several queries read as one result set. Each document is decoded by its own model.""" + + def __init__( + self, + queries: tuple[Query[t.Any], ...], + sort: tuple[tuple[str, int], ...] = (), + skip: int = 0, + limit: int = 0, + ) -> None: + if ( + len({q._manager.db_name for q in queries}) > 1 + or len({id(q._manager.registry) for q in queries}) > 1 + ): + raise QueryError("union() needs every query to be on the same database") + self._queries = queries + self._sort = sort + self._skip = skip + self._limit = limit + + def sort(self, *keys: str | tuple[str, int]) -> UnionQuery: + """Sort the combined results by stored field names (``"-created_at"``).""" + parsed = tuple( + (k[1:], -1) if isinstance(k, str) and k.startswith("-") else (k, 1) if isinstance(k, str) else k + for k in keys + ) + return UnionQuery(self._queries, (*self._sort, *parsed), self._skip, self._limit) + + def skip(self, n: int) -> UnionQuery: + return UnionQuery(self._queries, self._sort, n, self._limit) + + def limit(self, n: int) -> UnionQuery: + return UnionQuery(self._queries, self._sort, self._skip, n) + + @staticmethod + def _branch(query: Query[t.Any], index: int) -> list[dict[str, t.Any]]: + if query._includes or query._skip or query._limit: + raise QueryError( + "queries inside union() cannot use include, skip or limit; apply them to the union" + ) + stages: list[dict[str, t.Any]] = [] + flt = query._filter() + if flt: + stages.append({"$match": flt}) + projection = query._projection()[0] + if projection: + stages.append({"$project": projection}) + stages.append({"$addFields": {"__src": index}}) + return stages + + def compile(self) -> list[dict[str, t.Any]]: + first, *rest = self._queries + pipeline = self._branch(first, 0) + for index, query in enumerate(rest, start=1): + pipeline.append( + { + "$unionWith": { + "coll": query._manager.meta.collection, + "pipeline": self._branch(query, index), + } + } + ) + if self._sort: + pipeline.append({"$sort": dict(self._sort)}) + if self._skip: + pipeline.append({"$skip": self._skip}) + if self._limit: + pipeline.append({"$limit": self._limit}) + return pipeline + + async def __aiter__(self) -> t.AsyncIterator[t.Any]: + first = self._queries[0] + manager = first._manager + with manager._op("union"): + cursor = await manager.collection.aggregate(self.compile(), **manager._kw(first._session)) + async with cursor: + async for raw in cursor: + query = self._queries[raw.pop("__src")] + yield _materialize(query._manager, raw, query._mode, query._projection()[1]) + + async def all(self) -> list[t.Any]: + return [item async for item in self] + + def __await__(self) -> t.Generator[t.Any, None, list[t.Any]]: + return self.all().__await__() diff --git a/packages/mongospecs/src/mongospecs/streams.py b/packages/mongospecs/src/mongospecs/streams.py new file mode 100644 index 0000000..c04ee73 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/streams.py @@ -0,0 +1,98 @@ +"""Change streams: ``async for event in Article.watch(): ...``""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass + +from mongospecs.registry import driver_session + +if t.TYPE_CHECKING: + from mongospecs.manager import Manager + +__all__ = ["ChangeEvent", "ChangeStream"] + + +@dataclass(frozen=True, slots=True) +class ChangeEvent: + operation: str + """``insert``, ``update``, ``replace``, ``delete``, ``invalidate`` ...""" + id: t.Any + document: t.Any + """The decoded document after the change, when the server sent one.""" + updated: dict[str, t.Any] | None + removed: list[str] | None + resume_token: t.Any + """Pass as ``resume_after=`` to continue after this event.""" + raw: dict[str, t.Any] + + +class ChangeStream: + """An async iterator of :class:`ChangeEvent`. Also usable as ``async with`` for prompt cleanup. + + Plugin scopes do not apply to change streams; pass a ``$match`` pipeline to filter. + The driver resumes automatically after transient errors such as a primary step-down. + """ + + def __init__( + self, manager: Manager[t.Any], pipeline: list[t.Mapping[str, t.Any]], options: dict[str, t.Any] + ) -> None: + self._manager = manager + self._pipeline = pipeline + self._options = dict(options) + self._options.setdefault("full_document", "updateLookup") + session = self._options.pop("session", None) + if session is not None: + self._options["session"] = driver_session(session) + self._stream: t.Any = None + + async def _open(self) -> t.Any: + if self._stream is None: + with self._manager._op("watch"): + self._stream = await self._manager.collection.watch(self._pipeline, **self._options) + return self._stream + + async def __aenter__(self) -> ChangeStream: + await self._open() + return self + + async def __aexit__(self, *exc: object) -> None: + await self.close() + + async def close(self) -> None: + stream, self._stream = self._stream, None + if stream is not None: + await stream.close() + + @property + def resume_token(self) -> t.Any: + return self._stream.resume_token if self._stream is not None else None + + def __aiter__(self) -> ChangeStream: + return self + + def _event(self, change: dict[str, t.Any]) -> ChangeEvent: + full = change.get("fullDocument") + description = change.get("updateDescription") or {} + return ChangeEvent( + operation=change.get("operationType", ""), + id=(change.get("documentKey") or {}).get("_id"), + document=self._manager.meta.decode(full) if full is not None else None, + updated=description.get("updatedFields"), + removed=description.get("removedFields"), + resume_token=change.get("_id"), + raw=change, + ) + + async def __anext__(self) -> ChangeEvent: + stream = await self._open() + with self._manager._op("watch"): + change = await stream.next() + return self._event(change) + + async def try_next(self) -> ChangeEvent | None: + """The next event if one is ready within the server's await time, else ``None``.""" + stream = await self._open() + with self._manager._op("watch"): + change = await stream.try_next() + return self._event(change) if change is not None else None diff --git a/packages/mongospecs/src/mongospecs/tracking.py b/packages/mongospecs/src/mongospecs/tracking.py new file mode 100644 index 0000000..ea5ae27 --- /dev/null +++ b/packages/mongospecs/src/mongospecs/tracking.py @@ -0,0 +1,128 @@ +"""Change tracking by snapshot. + +When a document is loaded (or written), its encoded form is kept in a side table +keyed by object identity. ``save()`` diffs the current encoding against that +snapshot and sends only the difference. No attribute interception is involved, +so the mechanism is the same for msgspec, pydantic and attrs. + +The table entry is removed by a weakref finaliser when the document is +collected, which is why document classes must be weak-referenceable. +""" + +from __future__ import annotations + +import copy +import typing as t +import weakref + +__all__ = ["DocState", "diff", "forget", "state_of", "track"] + + +class DocState: + """What mongospecs remembers about a tracked document.""" + + __slots__ = ("manager", "partial", "snapshot") + + def __init__(self, snapshot: dict[str, t.Any], manager: t.Any, partial: frozenset[str] | None) -> None: + self.snapshot = snapshot + self.manager = manager + self.partial = partial + """Stored keys that were loaded, when the document was read with a projection.""" + + def copy(self) -> DocState: + return DocState(copy.deepcopy(self.snapshot), self.manager, self.partial) + + +_states: dict[int, DocState] = {} + + +def state_of(obj: t.Any) -> DocState | None: + return _states.get(id(obj)) + + +def track( + obj: t.Any, + snapshot: dict[str, t.Any], + manager: t.Any, + partial: frozenset[str] | None = None, +) -> DocState: + key = id(obj) + state = DocState(snapshot, manager, partial) + if key not in _states: + weakref.finalize(obj, _states.pop, key, None) + _states[key] = state + return state + + +def restore(obj: t.Any, state: DocState | None) -> None: + """Put back a previous state (used when a transaction aborts).""" + key = id(obj) + if state is None: + _states.pop(key, None) + else: + if key not in _states: + weakref.finalize(obj, _states.pop, key, None) + _states[key] = state + + +def forget(obj: t.Any) -> None: + _states.pop(id(obj), None) + + +def tracked_count() -> int: + return len(_states) + + +def _safe_keys(d: dict[str, t.Any]) -> bool: + return all(isinstance(k, str) and k and "." not in k and not k.startswith("$") for k in d) + + +def diff( + old: dict[str, t.Any], + new: dict[str, t.Any], + partial: frozenset[str] | None = None, +) -> tuple[dict[str, t.Any], list[str]]: + """Return ``($set, $unset)`` turning ``old`` into ``new``. + + Nested documents are diffed into dotted paths. Lists, and dicts whose keys + cannot be used in a dotted path, are replaced whole. + """ + to_set: dict[str, t.Any] = {} + to_unset: list[str] = [] + _diff(old, new, "", to_set, to_unset, partial) + return to_set, to_unset + + +def _diff( + old: dict[str, t.Any], + new: dict[str, t.Any], + prefix: str, + to_set: dict[str, t.Any], + to_unset: list[str], + partial: frozenset[str] | None, +) -> None: + for key, value in new.items(): + if partial is not None and key not in partial: + continue + if key == "_id" and not prefix: + continue + path = prefix + key + if key not in old: + to_set[path] = value + continue + before = old[key] + if before == value and type(before) is type(value): + continue + if ( + isinstance(before, dict) + and isinstance(value, dict) + and value + and _safe_keys(before) + and _safe_keys(value) + ): + _diff(before, value, path + ".", to_set, to_unset, None) + else: + to_set[path] = value + for key in old: + if key not in new and (partial is None or key in partial) and not (key == "_id" and not prefix): + to_unset.append(prefix + key) diff --git a/packages/mongospecs/src/mongospecs/updates.py b/packages/mongospecs/src/mongospecs/updates.py new file mode 100644 index 0000000..def91ef --- /dev/null +++ b/packages/mongospecs/src/mongospecs/updates.py @@ -0,0 +1,264 @@ +"""Typed atomic update operators. + +:: + + await Article.update(article_id, Inc(views=1), Push(tags="new"), title="Renamed") + +Keyword arguments are shorthand for ``Set``. Every supplied field is checked +against the model: unknown fields raise, ``Immutable`` fields are rejected and +values are validated with the model's own rules. There is no opt-in step +(Mongoose's ``runValidators``): updates are always validated. +""" + +from __future__ import annotations + +import decimal +import typing as t + +from mongospecs.codec import element_type, to_bson +from mongospecs.errors import QueryError, ValidationError +from mongospecs.expr import Expr, compile_filter +from mongospecs.refs import id_of + +if t.TYPE_CHECKING: + from mongospecs.meta import ModelMeta + +__all__ = [ + "AddToSet", + "CurrentDate", + "Each", + "Inc", + "Max", + "Min", + "Mul", + "Pop", + "Pull", + "Push", + "Set", + "SetOnInsert", + "Unset", + "UpdateOp", + "compile_update", +] + + +class UpdateOp: + """Base class of update operators.""" + + op: t.ClassVar[str] + __slots__ = ("fields",) + + def __init__(self, _fields: t.Mapping[str, t.Any] | None = None, /, **fields: t.Any) -> None: + self.fields: dict[str, t.Any] = {**(_fields or {}), **fields} + + def compile(self, meta: ModelMeta) -> dict[str, t.Any]: + return {self._path(meta, k): self._value(meta, k, v) for k, v in self.fields.items()} + + def _path(self, meta: ModelMeta, name: str) -> str: + _check_writable(meta, name, self.op) + return meta.stored_path(name) + + def _value(self, meta: ModelMeta, name: str, value: t.Any) -> t.Any: + return to_bson(id_of(value)) + + def __repr__(self) -> str: + return f"{type(self).__name__}({self.fields!r})" + + +def _check_writable(meta: ModelMeta, name: str, op: str) -> None: + head = name.partition(".")[0] + info = meta.fields.get(head) + if info is None: + raise QueryError(f"{meta.model.__name__} has no field {head!r}") + if head == "id": + raise QueryError("the id of a document cannot be updated") + if op != "$setOnInsert" and head in meta.immutable: + raise QueryError(f"{meta.model.__name__}.{head} is immutable") + + +def _validated(meta: ModelMeta, name: str, value: t.Any) -> t.Any: + info = meta.fields.get(name) + if info is None: # dotted path into a nested document + return to_bson(id_of(value)) + if info.ref is not None: + if value is None: + return None + if info.ref.shape == "list": + return [meta._cast_ref(info, v) for v in value] + if info.ref.shape == "dict": + return {k: meta._cast_ref(info, v) for k, v in value.items()} + return meta._cast_ref(info, value) + return to_bson(meta.codec.validate_field(meta.model, name, value)) + + +class Set(UpdateOp): + op = "$set" + __slots__ = () + + def _value(self, meta: ModelMeta, name: str, value: t.Any) -> t.Any: + return _validated(meta, name, value) + + +class SetOnInsert(Set): + op = "$setOnInsert" + __slots__ = () + + +class Min(Set): + op = "$min" + __slots__ = () + + +class Max(Set): + op = "$max" + __slots__ = () + + +class Unset(UpdateOp): + op = "$unset" + __slots__ = () + + def __init__(self, *names: str) -> None: + super().__init__({name: "" for name in names}) + + def _path(self, meta: ModelMeta, name: str) -> str: + path = super()._path(meta, name) + info = meta.fields.get(name) + if info is not None and info.required: + raise QueryError(f"{meta.model.__name__}.{name} is required and cannot be unset") + return path + + def _value(self, meta: ModelMeta, name: str, value: t.Any) -> t.Any: + return "" + + +class _Numeric(UpdateOp): + __slots__ = () + + def _value(self, meta: ModelMeta, name: str, value: t.Any) -> t.Any: + if isinstance(value, bool) or not isinstance(value, int | float | decimal.Decimal): + raise QueryError(f"{self.op} on {name!r} needs a number, got {type(value).__name__}") + return value + + +class Inc(_Numeric): + op = "$inc" + __slots__ = () + + +class Mul(_Numeric): + op = "$mul" + __slots__ = () + + +class Each: + """Several values for ``Push``/``AddToSet``, with optional ``$push`` modifiers.""" + + __slots__ = ("modifiers", "values") + + def __init__( + self, + values: t.Iterable[t.Any], + *, + position: int | None = None, + slice: int | None = None, + sort: t.Any = None, + ) -> None: + self.values = list(values) + self.modifiers: dict[str, t.Any] = {} + if position is not None: + self.modifiers["$position"] = position + if slice is not None: + self.modifiers["$slice"] = slice + if sort is not None: + self.modifiers["$sort"] = sort + + +def _element(meta: ModelMeta, name: str, value: t.Any) -> t.Any: + info = meta.fields.get(name) + if info is None: + return to_bson(id_of(value)) + if info.ref is not None: + return meta._cast_ref(info, value) + elem = element_type(info.type) + if elem is None: + raise QueryError(f"{meta.model.__name__}.{name} is not a list field") + try: + return to_bson(meta.validate_type(elem, value)) + except ValidationError as exc: + raise ValidationError( + f"{meta.model.__name__}.{name}: {exc}", [{"field": name, "message": str(exc)}] + ) from exc + + +class Push(UpdateOp): + op = "$push" + __slots__ = () + + def _value(self, meta: ModelMeta, name: str, value: t.Any) -> t.Any: + if isinstance(value, Each): + return {"$each": [_element(meta, name, v) for v in value.values], **value.modifiers} + return _element(meta, name, value) + + +class AddToSet(Push): + op = "$addToSet" + __slots__ = () + + def _value(self, meta: ModelMeta, name: str, value: t.Any) -> t.Any: + if isinstance(value, Each): + return {"$each": [_element(meta, name, v) for v in value.values]} + return _element(meta, name, value) + + +class Pull(UpdateOp): + """Remove matching elements. The value is an element, or a ``Q`` condition on elements.""" + + op = "$pull" + __slots__ = () + + def _value(self, meta: ModelMeta, name: str, value: t.Any) -> t.Any: + if isinstance(value, Expr): + return compile_filter(value, None) + return _element(meta, name, value) + + +class Pop(UpdateOp): + """Remove the last (``1``) or first (``-1``) element.""" + + op = "$pop" + __slots__ = () + + def _value(self, meta: ModelMeta, name: str, value: t.Any) -> t.Any: + if value not in (1, -1): + raise QueryError("Pop takes 1 (last element) or -1 (first element)") + return value + + +class CurrentDate(UpdateOp): + op = "$currentDate" + __slots__ = () + + def __init__(self, *names: str) -> None: + super().__init__({name: True for name in names}) + + def _value(self, meta: ModelMeta, name: str, value: t.Any) -> t.Any: + return True + + +def compile_update( + meta: ModelMeta, ops: t.Iterable[UpdateOp], fields: t.Mapping[str, t.Any] +) -> dict[str, t.Any]: + """Compile operators (plus ``Set`` shorthand keywords) to a MongoDB update document.""" + update: dict[str, dict[str, t.Any]] = {} + seen: dict[str, str] = {} + all_ops = [*ops, *([Set(fields)] if fields else [])] + for op in all_ops: + if not isinstance(op, UpdateOp): + raise QueryError(f"expected an update operator, got {type(op).__name__}") + for path, value in op.compile(meta).items(): + if path in seen: + raise QueryError(f"field {path!r} is updated by both {seen[path]} and {op.op}") + seen[path] = op.op + update.setdefault(op.op, {})[path] = value + return update diff --git a/packages/mongospecs/tests/__init__.py b/packages/mongospecs/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/packages/mongospecs/tests/conftest.py b/packages/mongospecs/tests/conftest.py new file mode 100644 index 0000000..9ff4045 --- /dev/null +++ b/packages/mongospecs/tests/conftest.py @@ -0,0 +1,58 @@ +"""Shared fixtures. Tests run against real MongoDB (see docker/).""" + +import os +import uuid + +import pytest +import pytest_asyncio + +from mongospecs import Registry + +from .models import BACKENDS, build + +RS_URI = os.environ.get("MONGOSPECS_TEST_URI", "mongodb://localhost:27117/?replicaSet=rs0") +SHARDED_URI = os.environ.get("MONGOSPECS_TEST_SHARDED_URI", "mongodb://localhost:27118") + + +@pytest.fixture(params=BACKENDS) +def backend(request): + return request.param + + +@pytest.fixture +def m(backend): + """The model namespace for the current backend.""" + return build(backend) + + +@pytest.fixture +def db_name(): + return f"ms_test_{uuid.uuid4().hex[:12]}" + + +@pytest_asyncio.fixture +async def registry(m, db_name): + async with Registry(RS_URI, database=db_name, models=m.ALL) as reg: + yield reg + await reg.client.drop_database(db_name) + + +@pytest_asyncio.fixture +async def make_registry(db_name): + """Factory for registries with custom models/options; all are closed and their database dropped.""" + opened = [] + + async def factory(*models, uri=RS_URI, database=None, **kwargs): + reg = Registry(uri, database=database or db_name, models=models, **kwargs) + await reg.open() + opened.append((reg, database or db_name, uri)) + return reg + + yield factory + for reg, name, uri in opened: + if reg.is_open: + await reg.client.drop_database(name) + await reg.close() + else: + async with Registry(uri) as cleaner: + await cleaner.client.drop_database(name) diff --git a/packages/mongospecs/tests/models.py b/packages/mongospecs/tests/models.py new file mode 100644 index 0000000..cacdab0 --- /dev/null +++ b/packages/mongospecs/tests/models.py @@ -0,0 +1,204 @@ +"""The same models declared on each backend. + +No ``from __future__ import annotations`` here: the classes are created inside a +function, so their annotations must be real objects. +""" + +import datetime as dt +import enum +import functools +import types +import typing as t + +from mongospecs import ( + OID, + Alias, + Backref, + CreatedAt, + Deferred, + DeletedAt, + Immutable, + Index, + OnDelete, + Policy, + Ref, + Revision, + SpecConfig, + To, + UpdatedAt, +) + +BACKENDS = ("msgspec", "pydantic", "attrs") + + +class Color(enum.Enum): + RED = "red" + BLUE = "blue" + + +@functools.cache +def build(backend: str) -> types.SimpleNamespace: + if backend == "msgspec": + import msgspec + + from mongospecs.msgspec import Spec, SpecAdapter + + define = lambda cls: cls # noqa: E731 + + class Address(msgspec.Struct, kw_only=True): + city: str = "" + zip: str = "" + + class Plain(msgspec.Struct, kw_only=True): + name: str + legs: int = 4 + + elif backend == "pydantic": + import pydantic + + from mongospecs.pydantic import Spec, SpecAdapter + + define = lambda cls: cls # noqa: E731 + + class Address(pydantic.BaseModel): + city: str = "" + zip: str = "" + + class Plain(pydantic.BaseModel): + name: str + legs: int = 4 + + else: + import attrs + + from mongospecs.attrs import Spec, SpecAdapter + + define = attrs.define(kw_only=True) + + @attrs.define(kw_only=True) + class Address: + city: str = "" + zip: str = "" + + @attrs.define(kw_only=True) + class Plain: + name: str + legs: int = 4 + + @define + class User(Spec): + __spec__ = SpecConfig(collection="users", indexes=[Index("email", unique=True, sparse=True)]) + name: t.Annotated[str, Policy(filter=("eq", "prefix"), sort=True)] = "" + email: str | None = None + age: int = 0 + secret: t.Annotated[str | None, Deferred, Policy(private=True)] = None + address: Address | None = None + color: Color | None = None + + @define + class Article(Spec): + __spec__ = SpecConfig(collection="articles", indexes=[Index("title"), Index("-views", "title")]) + title: t.Annotated[str, Policy(filter=True, sort=True)] = "" + slug: t.Annotated[str | None, Immutable] = None + views: int = 0 + tags: list[str] = [] + author: t.Annotated[Ref[User] | None, OnDelete("nullify")] = None + editors: t.Annotated[list[Ref[User]], OnDelete("pull")] = [] + reviewers: dict[str, Ref[User]] = {} + created_at: t.Annotated[dt.datetime | None, CreatedAt] = None + updated_at: t.Annotated[dt.datetime | None, UpdatedAt] = None + deleted_at: t.Annotated[dt.datetime | None, DeletedAt] = None + rev: t.Annotated[int, Revision] = 0 + + @define + class Comment(Spec): + __spec__ = SpecConfig(collection="comments", indexes=[Index("article")]) + body: str = "" + article: t.Annotated[Ref[Article] | None, OnDelete("cascade")] = None + author: Ref[User] | None = None + likes: int = 0 + + @define + class Post(Spec): + """Like Article, with reverse relations and an aliased field.""" + + __spec__ = SpecConfig(collection="posts") + title: str = "" + summary: t.Annotated[str, Alias("s")] = "" + comments: t.Annotated[list[t.Any], Backref("post", model="Note")] = [] + comment_count: t.Annotated[int, Backref("post", model="Note", count=True)] = 0 + + @define + class Note(Spec): + __spec__ = SpecConfig(collection="notes", indexes=[Index("post")]) + text: str = "" + post: Ref[Post] | None = None + rank: int = 0 + + @define + class Category(Spec): + __spec__ = SpecConfig(collection="categories") + name: str = "" + parent: t.Annotated[Ref | None, To("Category")] = None + + @define + class Animal(Spec): + __spec__ = SpecConfig(collection="animals", discriminator="kind") + name: str = "" + + @define + class Cat(Animal): + lives: int = 9 + + @define + class Dog(Animal): + __spec__ = SpecConfig(discriminator_value="doggo") + good: bool = True + + @define + class Puppy(Dog): + age_weeks: int = 8 + + @define + class Video(Spec): + __spec__ = SpecConfig(collection="videos") + title: str = "" + seconds: int = 0 + + @define + class Bookmark(Spec): + __spec__ = SpecConfig(collection="bookmarks") + label: str = "" + target: Ref[Article | Video] | None = None + + @define + class Tenant(Spec): + __spec__ = SpecConfig(collection="tenant_docs", shard_key=["tenant"]) + tenant: str = "" + value: int = 0 + + ns = types.SimpleNamespace( + backend=backend, + Spec=Spec, + SpecAdapter=SpecAdapter, + define=define, + Address=Address, + Plain=Plain, + Color=Color, + User=User, + Article=Article, + Comment=Comment, + Post=Post, + Note=Note, + Category=Category, + Animal=Animal, + Cat=Cat, + Dog=Dog, + Puppy=Puppy, + Video=Video, + Bookmark=Bookmark, + Tenant=Tenant, + OID=OID, + ) + ns.ALL = [User, Article, Comment, Post, Note, Category, Animal, Cat, Dog, Puppy, Video, Bookmark, Tenant] + return ns diff --git a/packages/mongospecs/tests/test_bulk_pagination.py b/packages/mongospecs/tests/test_bulk_pagination.py new file mode 100644 index 0000000..6c00d6d --- /dev/null +++ b/packages/mongospecs/tests/test_bulk_pagination.py @@ -0,0 +1,152 @@ +import pytest + +from mongospecs import ( + BulkWriteFailed, + DeleteMany, + DeleteOne, + DuplicateKeyError, + Inc, + InsertOne, + Q, + QueryError, + StaleDocumentError, + UpdateMany, + UpdateOne, +) + + +async def test_insert_many_ordered_and_unordered(m, registry): + await registry.apply_indexes() + result = await m.User.insert_many([m.User(name="a", email="1"), {"name": "b", "email": "2"}]) + assert len(result) == 2 and not result.errors + assert all(not d.is_new and d.id for d in result) and type(result.inserted[1]) is m.User + with pytest.raises((DuplicateKeyError, BulkWriteFailed)): + await m.User.insert_many( + [m.User(name="c", email="3"), m.User(name="dup", email="1"), m.User(name="d", email="4")] + ) + assert await m.User.count() == 3 # ordered: stopped at the failure + docs = [ + m.User(name="e", email="5"), + m.User(name="dup", email="1"), + m.User(name="dup2", email="2"), + m.User(name="f", email="6"), + ] + partial = await m.User.insert_many(docs, ordered=False) + assert [d.name for d in partial.inserted] == ["e", "f"] + assert [i for i, _ in partial.errors] == [1, 2] + assert all(isinstance(e, DuplicateKeyError) for _, e in partial.errors) + assert partial.errors[0][1].fields == {"email": "1"} + assert docs[1].is_new and not docs[0].is_new + assert (await m.User.insert_many([])).inserted == [] + + +async def test_bulk_write_typed_operations(m, registry): + a = await m.Article(title="a").insert() + b = await m.Article(title="b").insert() + c = await m.Article(title="c").insert() + result = await m.Article.bulk_write( + [ + InsertOne(m.Article(title="new")), + InsertOne({"title": "from dict"}), + UpdateOne(a.id, Inc(views=5), title="a2"), + UpdateMany(Q.title.in_(["b", "c"]), Inc(views=1)), + UpdateOne(Q.title == "upserted", Inc(views=1), upsert=True), + DeleteOne(c.id), + DeleteMany(Q.title == "b", hard=True), + ] + ) + assert result.inserted_count == 2 and result.upserted_count == 1 + raw = {d["title"]: d async for d in m.Article.collection().find({})} + assert ( + raw["a2"]["views"] == 5 + and raw["a2"]["rev"] == 1 + and raw["a2"]["updated_at"] > raw["a2"]["created_at"] + ) + assert raw["new"]["created_at"] is not None and raw["from dict"]["rev"] == 0 + assert raw["upserted"]["created_at"] is not None + assert raw["c"]["deleted_at"] is not None and raw["c"]["views"] == 1 # soft-deleted by DeleteOne + assert "b" not in raw # hard-deleted + assert await m.Article.count() == 4 + assert b.id not in [d["_id"] for d in raw.values()] + with pytest.raises(QueryError): + await m.Article.bulk_write([{"insertOne": {}}]) + with pytest.raises(QueryError): + await m.Article.bulk_write([UpdateOne(a.id, nope=1)]) + assert await m.Article.bulk_write([]) is None + + +async def test_bulk_save_mixes_inserts_and_deltas(m, make_registry): + events = [] + await make_registry(m.Article, m.User, on_command=events.append) + a = await m.Article(title="a").insert() + b = await m.Article(title="b").insert() + untouched = await m.Article(title="same").insert() + fresh = m.Article(title="fresh") + a.title = "a2" + b.views = 7 + events.clear() + assert await m.Article.bulk_save([a, b, untouched, fresh]) == 3 + assert [e.command_name for e in events if e.phase == "started"] == ["bulkWrite"] or len( + [e for e in events if e.phase == "started"] + ) <= 2 + assert a.rev == 1 and b.rev == 1 and untouched.rev == 0 and not fresh.is_new + assert a.changes() == b.changes() == fresh.changes() == {} + assert sorted(d.title for d in await m.Article.find()) == ["a2", "b", "fresh", "same"] + stale = await m.Article.get(a.id) + a.title = "a3" + await a.save() + stale.title = "lost" + b.views = 8 + with pytest.raises(StaleDocumentError): + await m.Article.bulk_save([stale, b]) + assert await m.Article.bulk_save([]) == 0 + + +async def test_cursor_pagination(m, registry): + await m.User.insert_many([m.User(name=f"u{i:02d}", age=i % 3) for i in range(11)]) + query = m.User.find().sort("age", "-name") + seen, cursor, pages = [], None, 0 + while True: + page = await query.page(4, after=cursor) + seen.extend((u.age, u.name) for u in page) + pages += 1 + assert page.number is None and page.total is None and len(page) <= 4 + if not page.has_next: + assert page.next_cursor is None + break + cursor = page.next_cursor + expected = [(u.age, u.name) for u in await m.User.find().sort("age", "-name")] + assert seen == expected and pages == 3 + default = await m.User.find().page(5) + assert [u.id for u in default] == (await m.User.find().sort("id").ids())[:5] + with_total = await m.User.find(Q.age == 0).page(2, total=True) + assert with_total.total == 4 and with_total.has_next and with_total[0].age == 0 + for bad in ("garbage", "", "e30"): + with pytest.raises(QueryError): + await query.page(4, after=bad) + + +async def test_cursor_is_stable_under_concurrent_inserts(m, registry): + await m.User.insert_many([m.User(name=f"u{i:02d}") for i in range(6)]) + first = await m.User.find().sort("name").page(3) + await m.User(name="u00a").insert() # lands before the cursor: must not shift the next page + second = await m.User.find().sort("name").page(3, after=first.next_cursor) + assert [u.name for u in first] == ["u00", "u01", "u02"] + assert [u.name for u in second] == ["u03", "u04", "u05"] + + +async def test_offset_pagination(m, registry): + await m.User.insert_many([m.User(name=f"u{i}", age=i) for i in range(7)]) + page = await m.User.find().sort("age").page(3, number=2, total=True) + assert [u.age for u in page] == [3, 4, 5] and page.number == 2 and page.total == 7 and page.has_next + last = await m.User.find().sort("age").page(3, number=3) + assert [u.age for u in last] == [6] and not last.has_next and last.next_cursor is None + assert (await m.User.find().page(3, number=9)).items == [] + with pytest.raises(QueryError): + await m.User.find().page(3, number=0) + with pytest.raises(QueryError): + await m.User.find().page(0) + with pytest.raises(QueryError): + await m.User.find().page(3, number=1, after="x") + raw = await m.User.find().raw().sort("age").page(2) + assert isinstance(raw[0], dict) and raw.next_cursor diff --git a/packages/mongospecs/tests/test_codec.py b/packages/mongospecs/tests/test_codec.py new file mode 100644 index 0000000..5483c9d --- /dev/null +++ b/packages/mongospecs/tests/test_codec.py @@ -0,0 +1,226 @@ +"""Codec contract: every backend must behave the same through the Codec interface.""" + +import datetime as dt + +import bson +import pytest +from bson import ObjectId + +from mongospecs import ConfigurationError, Policy, Ref, ValidationError, meta_of +from mongospecs.codec import codec_for + + +def test_fields_expose_names_markers_and_policy(m): + fields = meta_of(m.User).fields + assert list(fields)[:2] == ["id", "name"] or "id" in fields + assert fields["id"].stored == "_id" + assert fields["name"].policy == Policy(filter=("eq", "prefix"), sort=True) + assert fields["secret"].policy.private and not fields["secret"].required + assert fields["age"].policy == Policy() + art = meta_of(m.Article).fields + assert art["author"].ref.shape == "one" and art["author"].ref.targets == (m.User,) + assert art["editors"].ref.shape == "list" and art["reviewers"].ref.shape == "dict" + assert meta_of(m.Bookmark).fields["target"].ref.targets == (m.Article, m.Video) + post = meta_of(m.Post).fields + assert post["summary"].stored == "s" + assert post["comments"].transient and post["comment_count"].backref.count + + +def test_round_trip_through_bson_ready_dict(m): + oid, uid = ObjectId(), ObjectId() + when = dt.datetime(2024, 5, 6, 7, 8, 9, tzinfo=dt.UTC) + meta = meta_of(m.Article) + doc = meta.decode( + { + "_id": oid, + "title": "t", + "tags": ["a"], + "author": uid, + "editors": [uid], + "reviewers": {"lead": uid}, + "created_at": when, + "unknown_field": 1, + } + ) + assert type(doc) is m.Article and doc.id == oid and doc.created_at == when + assert isinstance(doc.author, Ref) and doc.author.id == uid and not doc.author.loaded + assert doc.editors[0].id == uid and doc.reviewers["lead"].id == uid + encoded = meta.encode(doc) + assert encoded["_id"] == oid and "id" not in encoded + assert encoded["author"] == uid and encoded["editors"] == [uid] and encoded["reviewers"] == {"lead": uid} + assert "unknown_field" not in encoded + bson.encode(encoded) # BSON-ready + assert encoded["tags"] is not doc.tags # no shared containers + + +def test_decode_accepts_raw_bson_and_string_ids(m): + oid = ObjectId() + meta = meta_of(m.User) + assert meta.codec.decode(m.User, bson.encode({"_id": oid, "name": "x"})).id == oid + assert meta.decode({"_id": str(oid), "name": "x"}).id == oid + + +def test_documents_and_ids_assigned_to_refs_encode_as_ids(m): + user = m.User(name="u") + user.id = ObjectId() + art = m.Article(title="t", author=user, editors=[user, user.id], reviewers={"a": user}) + encoded = meta_of(m.Article).encode(art) + assert encoded["author"] == user.id + assert encoded["editors"] == [user.id, user.id] + assert encoded["reviewers"] == {"a": user.id} + assert art.author.doc is user # normalised to a loaded Ref + art.author = user.id # plain assignment later is still encoded as an id + assert meta_of(m.Article).encode(art)["author"] == user.id + + +def test_alias_and_transient_fields(m): + meta = meta_of(m.Post) + post = m.Post(title="t", summary="short") + encoded = meta.encode(post) + assert encoded["s"] == "short" and "summary" not in encoded + assert "comments" not in encoded and "comment_count" not in encoded + assert meta.decode({"_id": ObjectId(), "title": "t", "s": "stored"}).summary == "stored" + + +def test_nested_models_and_enums(m): + meta = meta_of(m.User) + user = m.User(name="a", address=m.Address(city="Oslo"), color=m.Color.RED) + encoded = meta.encode(user) + assert encoded["address"] == {"city": "Oslo", "zip": ""} + back = meta.decode({**encoded, "_id": ObjectId(), "color": "red"}) + assert type(back.address) is m.Address and back.color is m.Color.RED + + +def test_validation_errors_name_the_field(m): + meta = meta_of(m.User) + with pytest.raises(ValidationError) as info: + meta.decode({"_id": ObjectId(), "name": "x", "age": "old"}) + assert info.value.errors[0]["field"] == "age" + with pytest.raises(ValidationError) as info: + meta.codec.validate_field(m.User, "age", "old") + assert info.value.errors[0]["field"] == "age" + + +def test_validate_field_coerces_one_value(m): + codec = meta_of(m.User).codec + assert codec.validate_field(m.User, "age", 5) == 5 + assert codec.validate_field(m.User, "color", "blue") is m.Color.BLUE + oid = ObjectId() + assert codec.validate_field(m.User, "id", str(oid)) == oid + assert type(codec.validate_field(m.User, "address", {"city": "X"})) is m.Address + + +def test_json_schema_is_normalised(m): + schema = meta_of(m.User).codec.json_schema(m.User) + assert schema["type"] == "object" + assert {"id", "name", "age", "email", "secret", "address", "color"} <= set(schema["properties"]) + assert "_id" not in schema["properties"] + assert schema["required"] == [] + art = meta_of(m.Article).codec.json_schema(m.Article) + assert "title" in art["properties"] and "author" in art["properties"] + + +def test_codec_for_plain_models_and_rejects_others(m): + assert codec_for(m.Plain).name == m.backend + assert codec_for(m.User) is meta_of(m.User).codec + with pytest.raises(ConfigurationError): + codec_for(dict) + with pytest.raises(ConfigurationError): + meta_of(m.Plain) + + +def test_spec_adapter_turns_existing_class_into_document(m): + Dragon = m.SpecAdapter(m.Plain, collection="dragons") + meta = meta_of(Dragon) + assert meta.collection == "dragons" and "id" in meta.fields and "legs" in meta.fields + d = Dragon(name="Burt") + assert d.legs == 4 and d.id is None and d.is_new + assert meta.encode(d)["name"] == "Burt" + assert Dragon.__name__ == "Plain" + + +def test_misdeclared_models_fail_at_metadata_time(m): + import typing as t + + from mongospecs import Deferred, DeletedAt, Revision + + @m.define + class RequiredDeferred(m.Spec): + secret: t.Annotated[str, Deferred] + + with pytest.raises(ConfigurationError, match="Deferred"): + meta_of(RequiredDeferred) + + @m.define + class TwoRevisions(m.Spec): + a: t.Annotated[int, Revision] = 0 + b: t.Annotated[int, Revision] = 0 + + with pytest.raises(ConfigurationError, match="Revision"): + meta_of(TwoRevisions) + + @m.define + class RequiredDeleted(m.Spec): + gone: t.Annotated[dt.datetime, DeletedAt] + + with pytest.raises(ConfigurationError, match="DeletedAt"): + meta_of(RequiredDeleted) + + @m.define + class BareRef(m.Spec): + other: Ref | None = None + + with pytest.raises(ConfigurationError, match="target"): + meta_of(BareRef) + + +def test_validate_field_runs_the_models_own_validators(m): + """Spike S4: a single field can be validated with each backend's native rules.""" + import typing as t + + if m.backend == "msgspec": + import msgspec + + class Strict(m.Spec): + title: t.Annotated[str, msgspec.Meta(min_length=3)] = "abc" + + elif m.backend == "pydantic": + import pydantic + + class Strict(m.Spec): + title: str = "abc" + + @pydantic.field_validator("title") + @classmethod + def long_enough(cls, v): + if len(v.strip()) < 3: + raise ValueError("too short") + return v.strip() + + else: + import attrs + + def long_enough(instance, attribute, value): + if len(value) < 3: + raise ValueError("too short") + + @attrs.define(kw_only=True) + class Strict(m.Spec): + title: str = attrs.field(default="abc", validator=long_enough) + + codec = meta_of(Strict).codec + assert codec.validate_field(Strict, "title", "long enough") == "long enough" + with pytest.raises(ValidationError) as info: + codec.validate_field(Strict, "title", "no") + assert info.value.errors[0]["field"] == "title" + # the same rule guards atomic updates and query casting + from mongospecs import Q, QueryError + from mongospecs.expr import compile_filter + from mongospecs.updates import compile_update + + with pytest.raises(ValidationError): + compile_update(meta_of(Strict), [], {"title": "no"}) + # ...but a query may look for a value that would not be valid to store: casting is by type only + assert compile_filter(Q.title == "no", meta_of(Strict)) == {"title": "no"} + with pytest.raises(QueryError): + compile_filter(Q.title == 5, meta_of(Strict)) diff --git a/packages/mongospecs/tests/test_crud.py b/packages/mongospecs/tests/test_crud.py new file mode 100644 index 0000000..451f637 --- /dev/null +++ b/packages/mongospecs/tests/test_crud.py @@ -0,0 +1,247 @@ +import datetime as dt + +import pytest +from bson import ObjectId + +from mongospecs import ( + DocumentNotFound, + DuplicateKeyError, + NotBoundError, + Q, + QueryError, + ValidationError, + trusted, +) + + +async def test_insert_assigns_id_and_tracks(m, registry): + user = m.User(name="ann", age=30) + assert user.is_new and user.id is None + await user.insert() + assert isinstance(user.id, ObjectId) + assert not user.is_new + assert user.changes() == {} + raw = await m.User.collection().find_one({"_id": user.id}) + assert raw["name"] == "ann" and raw["age"] == 30 + + +async def test_get_returns_typed_document_or_none(m, registry): + user = await m.User(name="ann").insert() + found = await m.User.get(user.id) + assert type(found) is m.User and found.name == "ann" and found.id == user.id + assert await m.User.get(str(user.id)) is not None # ids are cast + assert await m.User.get(ObjectId()) is None + + +async def test_find_all_first_one_and_or_fail(m, registry): + await m.User.insert_many([m.User(name="a", age=1), m.User(name="b", age=2), m.User(name="c", age=3)]) + assert [u.name for u in await m.User.find().sort("name")] == ["a", "b", "c"] + assert [u.name for u in await m.User.find(Q.age >= 2).sort("-age").all()] == ["c", "b"] + assert (await m.User.find(Q.name == "b").first()).age == 2 + assert await m.User.find(Q.name == "zzz").first() is None + with pytest.raises(DocumentNotFound): + await m.User.find(Q.name == "zzz").one() + with pytest.raises(DocumentNotFound): + await m.User.find(Q.name == "zzz").or_fail().all() + assert [u.name async for u in m.User.find().sort("name").skip(1).limit(1)] == ["b"] + + +async def test_count_exists_ids_estimated(m, registry): + docs = await m.User.insert_many([m.User(name=n, age=i) for i, n in enumerate("abcd")]) + assert await m.User.count() == 4 + assert await m.User.count(Q.age > 1) == 2 + assert await m.User.estimated_count() == 4 + assert await m.User.exists(Q.name == "a") and not await m.User.exists(Q.name == "x") + assert await m.User.find().sort("age").ids() == [d.id for d in docs] + + +async def test_read_modes(m, registry): + user = await m.User(name="ann").insert() + tracked = await m.User.find().one() + lean = await m.User.find().lean().one() + raw = await m.User.find().raw().one() + assert not tracked.is_new and lean.is_new + assert isinstance(raw, dict) and raw["_id"] == user.id and raw["name"] == "ann" + assert type(lean) is m.User and lean.name == "ann" + + +async def test_save_new_then_delta(m, registry): + user = m.User(name="ann", age=1) + await user.save() + assert not user.is_new + user.age = 2 + assert user.changes() == {"$set": {"age": 2}} + await user.save() + assert user.changes() == {} + assert (await m.User.get(user.id)).age == 2 + + +async def test_save_unchanged_sends_nothing(m, make_registry): + events = [] + await make_registry(m.User, on_command=events.append) + user = await m.User(name="ann").insert() + events.clear() + await user.save() + assert [e.command_name for e in events if e.phase == "started"] == [] + + +async def test_save_preserves_unknown_fields(m, registry): + user = await m.User(name="ann").insert() + await m.User.collection().update_one({"_id": user.id}, {"$set": {"from_newer_app": 7}}) + loaded = await m.User.get(user.id) + loaded.age = 5 + await loaded.save() + raw = await m.User.collection().find_one({"_id": user.id}) + assert raw["from_newer_app"] == 7 and raw["age"] == 5 + + +async def test_nested_document_diff_uses_dotted_paths(m, registry): + user = await m.User(name="ann", address=m.Address(city="Oslo", zip="1")).insert() + loaded = await m.User.get(user.id) + assert type(loaded.address) is m.Address and loaded.address.city == "Oslo" + loaded.address.city = "Bergen" + assert loaded.changes() == {"$set": {"address.city": "Bergen"}} + await loaded.save() + assert (await m.User.find().raw().one())["address"] == {"city": "Bergen", "zip": "1"} + + +async def test_delete_and_reload(m, registry): + user = await m.User(name="ann").insert() + await m.User.collection().update_one({"_id": user.id}, {"$set": {"age": 9}}) + await user.reload() + assert user.age == 9 and user.changes() == {} + await user.delete() + assert user.is_new and await m.User.count() == 0 + with pytest.raises(DocumentNotFound): + await user.reload() + + +async def test_delete_missing_raises(m, registry): + user = await m.User(name="ann").insert() + await m.User.collection().delete_one({"_id": user.id}) + with pytest.raises(DocumentNotFound): + await user.delete() + user2 = await m.User(name="bob").insert() + await m.User.collection().delete_one({"_id": user2.id}) + user2.age = 3 + with pytest.raises(DocumentNotFound): + await user2.save() + + +async def test_lean_documents_are_not_saved_as_updates(m, registry): + await m.User(name="ann").insert() + lean = await m.User.find().lean().one() + lean.age = 5 + with pytest.raises(DuplicateKeyError): + await lean.save() # untracked: treated as a new document + + +async def test_duplicate_key_reports_fields(m, registry): + await registry.apply_indexes() + await m.User(name="a", email="x@y.z").insert() + with pytest.raises(DuplicateKeyError) as info: + await m.User(name="b", email="x@y.z").insert() + assert info.value.fields == {"email": "x@y.z"} + assert info.value.__cause__ is not None + + +async def test_deferred_field_is_not_loaded_or_clobbered(m, registry): + user = await m.User(name="ann", secret="s3cret").insert() + loaded = await m.User.get(user.id) + assert loaded.secret is None + loaded.age = 4 + await loaded.save() + assert "secret" not in await m.User.find().raw().one() # deferred applies to raw reads too + assert (await m.User.collection().find_one({}))["secret"] == "s3cret" + with_secret = await m.User.find().select("+secret").one() + assert with_secret.secret == "s3cret" + updated = await m.User.update(user.id, age=5) + assert updated.secret is None and updated.changes() == {} + updated.name = "anna" + await updated.save() + assert (await m.User.collection().find_one({}))["secret"] == "s3cret" + + +async def test_select_projection_and_partial_save(m, registry): + await m.User(name="ann", age=3, email="a@b.c").insert() + part = await m.User.find().select("name").one() + assert part.name == "ann" and part.age == 0 and part.email is None # defaults, not loaded + part.name = "anna" + part.age = 99 # not loaded -> not saved + assert part.changes() == {"$set": {"name": "anna"}} + await part.save() + raw = await m.User.find().raw().one() + assert raw["name"] == "anna" and raw["age"] == 3 and raw["email"] == "a@b.c" + excl = await m.User.find().select("-email").raw().one() + assert "email" not in excl and excl["age"] == 3 + with pytest.raises(QueryError): + m.User.find().select("name", "-age").compile() + + +async def test_enum_date_and_datetime_round_trip(m, registry): + user = await m.User(name="ann", color=m.Color.BLUE).insert() + raw = await m.User.find().raw().one() + assert raw["color"] == "blue" + assert (await m.User.get(user.id)).color is m.Color.BLUE + assert (await m.User.find(Q.color == m.Color.BLUE).one()).id == user.id + article = await m.Article(title="t").insert() + assert article.created_at.tzinfo is not None + loaded = await m.Article.get(article.id) + assert loaded.created_at == article.created_at + assert dt.datetime.now(dt.UTC) - loaded.created_at < dt.timedelta(minutes=1) + + +async def test_validation_on_read(m, registry): + await m.User.collection().insert_one({"name": "bad", "age": "not a number"}) + with pytest.raises(ValidationError) as info: + await m.User.find().all() + assert info.value.errors[0]["field"] == "age" + assert (await m.User.find().raw().one())["age"] == "not a number" + + +async def test_unbound_model_raises(m): + class_ = m.User + with pytest.raises(NotBoundError): + class_.find() + + +async def test_raw_filters_are_untrusted(m, registry): + await m.User(name="ann", age=5).insert() + assert await m.User.count({"name": "ann"}) == 1 + with pytest.raises(QueryError): + await m.User.count({"age": {"$gt": 1}}) + with pytest.raises(QueryError): + await m.User.count({"$where": "true"}) + assert await m.User.count(trusted({"age": {"$gt": 1}})) == 1 + + +async def test_each_with_concurrency(m, registry): + await m.User.insert_many([m.User(name=str(i), age=i) for i in range(25)]) + seen = [] + + async def visit(user): + seen.append(user.age) + + assert await m.User.find().each(visit, concurrency=5) == 25 + assert sorted(seen) == list(range(25)) + assert await m.User.find(Q.age < 3).each(visit) == 3 + + +async def test_hydrate_and_collection_escape_hatch(m, registry): + await m.User.collection().insert_one({"name": "raw"}) + raw = await m.User.collection().find_one({"name": "raw"}) + doc = m.User.hydrate(raw) + assert not doc.is_new + doc.age = 1 + await doc.save() + assert (await m.User.get(doc.id)).age == 1 + + +async def test_aggregate_decodes_and_explain(m, registry): + await m.User.insert_many([m.User(name="a", age=1), m.User(name="b", age=5)]) + rows = await m.User.aggregate([{"$group": {"_id": None, "total": {"$sum": "$age"}}}]) + assert rows[0]["total"] == 6 + typed = await m.User.aggregate([{"$match": {"age": {"$gt": 2}}}], as_=m.User) + assert [type(u) for u in typed] == [m.User] and typed[0].name == "b" + plan = await m.User.find(Q.age > 1).explain() + assert "queryPlanner" in plan or "stages" in plan diff --git a/packages/mongospecs/tests/test_expr.py b/packages/mongospecs/tests/test_expr.py new file mode 100644 index 0000000..3f551e1 --- /dev/null +++ b/packages/mongospecs/tests/test_expr.py @@ -0,0 +1,169 @@ +"""Golden tests: expressions compile to exact MongoDB filters. No database needed.""" + +import re + +import pytest +from bson import ObjectId + +from mongospecs import ( + All, + And, + ElemMatch, + Exists, + In, + Nor, + Not, + NotIn, + Or, + Q, + QueryError, + Size, + SortBy, + Type, + meta_of, + trusted, +) +from mongospecs.expr import SortKey, and_filters, compile_filter, compile_sort + + +def c(expr, meta=None): + return compile_filter(expr, meta) + + +def test_comparisons(): + assert c(Q.a == 1) == {"a": 1} + assert c(Q.a != 1) == {"a": {"$ne": 1}} + assert c(Q.a > 1) == {"a": {"$gt": 1}} + assert c(Q.a >= 1) == {"a": {"$gte": 1}} + assert c(Q.a < 1) == {"a": {"$lt": 1}} + assert c(Q.a <= 1) == {"a": {"$lte": 1}} + assert c(Q.a.b.c == 1) == {"a.b.c": 1} + assert c(Q["a"]["b"] == 1) == {"a.b": 1} + assert c(Q.items[0].name == "x") == {"items.0.name": "x"} + + +def test_operators_and_helpers(): + assert c(Q.a.in_([1, 2])) == c(In(Q.a, [1, 2])) == {"a": {"$in": [1, 2]}} + assert c(Q.a.nin([1])) == c(NotIn(Q.a, [1])) == {"a": {"$nin": [1]}} + assert c(Q.a.all([1, 2])) == c(All(Q.a, [1, 2])) == {"a": {"$all": [1, 2]}} + assert c(Q.a.exists()) == c(Exists(Q.a, True)) == {"a": {"$exists": True}} + assert c(Q.a.exists(False)) == {"a": {"$exists": False}} + assert c(Q.a.size(2)) == c(Size(Q.a, 2)) == {"a": {"$size": 2}} + assert c(Q.a.type_("string")) == c(Type(Q.a, "string")) == {"a": {"$type": "string"}} + assert c(Q.a.prefix("x.y")) == {"a": {"$regex": "^" + re.escape("x.y")}} + assert c(Q.a.contains("a+b", ignore_case=True)) == {"a": {"$regex": re.escape("a+b"), "$options": "i"}} + assert c(Q.a.regex("^x", "m")) == {"a": {"$regex": "^x", "$options": "m"}} + + +def test_groups_and_operators(): + assert c(And(Q.a == 1, Q.b == 2)) == {"a": 1, "b": 2} + assert c((Q.a == 1) & (Q.b == 2)) == {"a": 1, "b": 2} + assert c((Q.a > 1) & (Q.a < 5)) == {"$and": [{"a": {"$gt": 1}}, {"a": {"$lt": 5}}]} + assert c(Or(Q.a == 1, Q.b == 2)) == c((Q.a == 1) | (Q.b == 2)) == {"$or": [{"a": 1}, {"b": 2}]} + assert c(Nor(Q.a == 1, Q.b == 2)) == {"$nor": [{"a": 1}, {"b": 2}]} + assert c(And(Q.a == 1, Or(Q.b == 2, Q.c == 3))) == {"a": 1, "$or": [{"b": 2}, {"c": 3}]} + assert c(And(Q.a == 1, And(Q.b == 2, Q.c == 3))) == {"a": 1, "b": 2, "c": 3} + + +def test_negation(): + assert c(~(Q.a == 1)) == c(Not(Q.a == 1)) == {"a": {"$ne": 1}} + assert c(~(Q.a != 1)) == {"a": 1} + assert c(~(Q.a > 1)) == {"a": {"$not": {"$gt": 1}}} + assert c(~Q.a.in_([1])) == {"a": {"$nin": [1]}} + assert c(~Q.a.prefix("x")) == {"a": {"$not": {"$regex": "^x"}}} + assert c(~((Q.a == 1) | (Q.b == 2))) == {"$nor": [{"$or": [{"a": 1}, {"b": 2}]}]} + + +def test_elem_match(): + assert c(Q.items.elem_match(Q.qty > 1, Q.sku == "x")) == { + "items": {"$elemMatch": {"qty": {"$gt": 1}, "sku": "x"}} + } + assert c(ElemMatch(Q.scores, Q > 80, Q < 90)) == {"scores": {"$elemMatch": {"$gt": 80, "$lt": 90}}} + + +def test_sorting(): + assert SortBy(Q.a.desc(), Q.b, "-c", "d", ("e", -1)) == [ + SortKey("a", -1), + SortKey("b", 1), + SortKey("c", -1), + SortKey("d", 1), + SortKey("e", -1), + ] + assert compile_sort(["-a", Q.b]) == [("a", -1), ("b", 1)] + + +def test_untrusted_mappings_and_operator_injection(): + assert c({"a": 1, "b": "x"}) == {"a": 1, "b": "x"} + assert c({"a": {"nested": 1}}) == {"a": {"$eq": {"nested": 1}}} + for bad in ({"$where": "1"}, {"a": {"$gt": 1}}, {"a": {"b": {"$ne": 1}}}, {"a": [{"$gt": 1}]}): + with pytest.raises(QueryError): + c(bad) + with pytest.raises(QueryError): + c(Q.a == {"$gt": ""}) + with pytest.raises(QueryError): + c(Q.a.in_([{"$ne": 1}])) + assert c(trusted({"a": {"$gt": 1}})) == {"a": {"$gt": 1}} + assert c(Q.a == 1) is not None and c(None) == {} + with pytest.raises(QueryError): + c(Q.a) + with pytest.raises(QueryError): + c(Q.a.in_("abc")) + + +def test_and_filters_merges_or_wraps(): + assert and_filters({"a": 1}, None, {}, {"b": 2}) == {"a": 1, "b": 2} + assert and_filters({"a": 1}, {"a": 2}) == {"$and": [{"a": 1}, {"a": 2}]} + assert and_filters() == {} + + +def test_fields_are_immutable(): + f = Q.a + g = f.b + assert repr(f) == "Q.a" and repr(g) == "Q.a.b" + with pytest.raises(AttributeError): + f.x = 1 + + +# -- compiled against a model: names are checked and values are cast ---------------- + + +def test_field_names_are_translated_and_checked(m): + meta = meta_of(m.User) + oid = ObjectId() + assert c(Q.id == oid, meta) == {"_id": oid} + assert c(Q.address.city == "Oslo", meta) == {"address.city": "Oslo"} + assert c(Q.summary == "x", meta_of(m.Post)) == {"s": "x"} + with pytest.raises(QueryError, match="no field 'nope'"): + c(Q.nope == 1, meta) + with pytest.raises(QueryError, match="no field"): + c({"nope": 1}, meta) + with pytest.raises(QueryError, match="reverse relation"): + c(Q.comments == 1, meta_of(m.Post)) + assert compile_sort(["-id", Q.name], meta) == [("_id", -1), ("name", 1)] + with pytest.raises(QueryError): + compile_sort(["nope"], meta) + + +def test_values_are_cast_to_field_types(m): + meta = meta_of(m.User) + oid = ObjectId() + assert c(Q.id == str(oid), meta) == {"_id": oid} + assert c(Q.id.in_([str(oid)]), meta) == {"_id": {"$in": [oid]}} + assert c(Q.color == "red", meta)["color"] in (m.Color.RED, "red") + with pytest.raises(QueryError, match="cannot cast"): + c(Q.age == "abc", meta) + art = meta_of(m.Article) + assert c(Q.tags == "a", art) == {"tags": "a"} # scalar against a list field: "contains" + assert c(Q.tags == ["a", "b"], art) == {"tags": ["a", "b"]} + assert c(Q.tags.in_(["a"]), art) == {"tags": {"$in": ["a"]}} + assert c(Q.email == None, meta) == {"email": None} # noqa: E711 + + +def test_references_are_cast_to_ids(m): + art = meta_of(m.Article) + user = m.User(name="u") + user.id = ObjectId() + assert c(Q.author == user, art) == {"author": user.id} + assert c(Q.author == str(user.id), art) == {"author": user.id} + assert c(Q.editors == user, art) == {"editors": user.id} + assert c(Q.author.in_([user, user.id]), art) == {"author": {"$in": [user.id, user.id]}} diff --git a/packages/mongospecs/tests/test_indexes.py b/packages/mongospecs/tests/test_indexes.py new file mode 100644 index 0000000..94382fa --- /dev/null +++ b/packages/mongospecs/tests/test_indexes.py @@ -0,0 +1,177 @@ +import typing as t + +import pytest + +from mongospecs import Action, Backref, Inc, Index, OperationFailed, Ref, Set, SpecConfig +from mongospecs.indexes import mongo_json_schema + + +def by_name(plan): + return {a.name: a.action for a in plan.actions} + + +async def test_plan_create_then_retain(m, make_registry): + reg = await make_registry(m.Article, m.User) + plan = await reg.plan_indexes() + assert by_name(plan) == { + "title_1": Action.CREATE, + "views_-1_title_1": Action.CREATE, + "email_1": Action.CREATE, + } + assert not plan.in_sync and not any(a.destructive for a in plan) + done = await reg.apply_indexes(plan) + assert len(done) == 3 + again = await reg.plan_indexes() + assert again.in_sync and set(by_name(again).values()) == {Action.RETAIN} + info = await m.User.collection().index_information() + assert info["email_1"]["unique"] is True and info["email_1"]["sparse"] is True + assert await reg.apply_indexes() == [] + assert "CREATE" in str(plan.actions[0]) + + +async def test_destructive_actions_are_gated(m, make_registry): + reg = await make_registry(m.User) + coll = m.User.collection() + await coll.create_index([("email", 1)], name="email_1") # same name, not unique -> RECREATE + await coll.create_index([("age", 1)], name="stray") # undeclared -> DROP + plan = await reg.plan_indexes() + assert by_name(plan) == {"email_1": Action.RECREATE, "stray": Action.DROP} + assert await reg.apply_indexes(plan) == [] # nothing destructive without drop=True + assert "stray" in await coll.index_information() + done = await reg.apply_indexes(plan, drop=True) + assert {a.action for a in done} == {Action.RECREATE, Action.DROP} + info = await coll.index_information() + assert "stray" not in info and info["email_1"]["unique"] is True + assert (await reg.plan_indexes()).in_sync + + +async def test_conflict_same_keys_other_name(m, make_registry): + reg = await make_registry(m.User) + await m.User.collection().create_index([("email", 1)], name="legacy_email", unique=True, sparse=True) + plan = await reg.plan_indexes() + (action,) = plan.actions + assert action.action is Action.CONFLICT and action.existing == "legacy_email" and action.destructive + await reg.apply_indexes(plan, drop=True) + assert set(await m.User.collection().index_information()) == {"_id_", "email_1"} + + +async def test_text_ttl_partial_and_alias_indexes(m, make_registry): + @m.define + class Doc(m.Spec): + __spec__ = SpecConfig( + collection="idx_docs", + indexes=[ + Index(("body", "text"), ("title", "text"), name="search"), + Index("expires", ttl=3600), + Index("code", unique=True, partial={"active": True}), + Index("short", collation={"locale": "en", "strength": 2}), + ], + ) + body: str = "" + title: str = "" + expires: t.Any = None + code: str = "" + active: bool = True + short: str = "" + + reg = await make_registry(Doc) + await reg.apply_indexes() + info = await Doc.collection().index_information() + assert info["search"]["weights"] == {"body": 1, "title": 1} + assert info["expires_1"]["expireAfterSeconds"] == 3600 + assert info["code_1"]["partialFilterExpression"] == {"active": True} + # materialised text indexes and server-filled collation defaults do not show as drift + assert (await reg.plan_indexes()).in_sync + + +async def test_index_on_aliased_field_uses_stored_name(m, make_registry): + from mongospecs import Alias + + @m.define + class Short(m.Spec): + __spec__ = SpecConfig(collection="idx_short", indexes=[Index("-summary")]) + summary: t.Annotated[str, Alias("s")] = "" + + reg = await make_registry(Short) + await reg.apply_indexes() + assert (await Short.collection().index_information())["s_-1"]["key"] == [("s", -1)] + + +async def test_discriminated_models_share_index_ownership(m, make_registry): + @m.define + class Shape(m.Spec): + __spec__ = SpecConfig(collection="idx_shapes", discriminator="kind", indexes=[Index("name")]) + name: str = "" + + @m.define + class Circle(Shape): + __spec__ = SpecConfig(indexes=[Index("radius")]) + radius: int = 0 + + reg = await make_registry(Shape, Circle) + await reg.apply_indexes() + assert {"name_1", "radius_1"} <= set(await Shape.collection().index_information()) + plan = await reg.plan_indexes() + assert not plan.of(Action.DROP) and plan.in_sync + + +async def test_missing_foreign_key_index_is_flagged(m, make_registry): + @m.define + class Parent(m.Spec): + __spec__ = SpecConfig(collection="idx_parents") + kids: t.Annotated[list[t.Any], Backref("parent", model="Kid")] = [] + + @m.define + class Kid(m.Spec): + __spec__ = SpecConfig(collection="idx_kids") + parent: Ref[Parent] | None = None + + reg = await make_registry(Parent, Kid) + plan = await reg.plan_indexes() + assert len(plan.advisories) == 1 and "Kid.parent" in plan.advisories[0] + reg2_plan = await (await _rebind(reg, m.Post, m.Note)).plan_indexes(m.Post, m.Note) + assert reg2_plan.advisories == [] # Note declares Index("post") + + +async def _rebind(reg, *models): + reg.bind(*models) + return reg + + +async def test_json_schema_validator_is_opt_in(m, make_registry): + @m.define + class Checked(m.Spec): + __spec__ = SpecConfig(collection="checked", json_schema_validation="strict") + name: str + age: int = 0 + owner: Ref[m.User] | None = None + + schema = mongo_json_schema(Checked) + assert schema["bsonType"] == "object" and schema["required"] == ["name"] + assert schema["properties"]["name"]["bsonType"] == "string" + assert "_id" not in schema["properties"] + reg = await make_registry(Checked, m.User) + await reg.apply_indexes() + await Checked(name="ok", age=3).insert() + user = await m.User(name="u").insert() + await Checked(name="ok2", owner=user).insert() + with pytest.raises(OperationFailed) as info: + await Checked.find().update(Set({"name": "x"}), Inc(age=0.5)) # server-side: age must stay an integer + assert info.value.code == 121 + from pymongo.errors import WriteError + + with pytest.raises(WriteError): # the raw collection is the raw driver: errors are not translated + await Checked.collection().insert_one({"name": 5}) + + +async def test_collection_options_are_applied(m, make_registry): + @m.define + class Capped(m.Spec): + __spec__ = SpecConfig(collection="capped_log", collection_options={"capped": True, "size": 4096}) + line: str = "" + + reg = await make_registry(Capped) + await reg.apply_indexes() + options = await Capped.collection().options() + assert options["capped"] is True and options["size"] >= 4096 + await reg.apply_indexes() # idempotent diff --git a/packages/mongospecs/tests/test_models_misc.py b/packages/mongospecs/tests/test_models_misc.py new file mode 100644 index 0000000..ae771e4 --- /dev/null +++ b/packages/mongospecs/tests/test_models_misc.py @@ -0,0 +1,170 @@ +"""Discriminators, schema evolution, multi-database use, adapters, forward references.""" + +import typing as t + +import pytest +from bson import ObjectId + +from mongospecs import ConfigurationError, Q, Ref, SpecConfig, To, meta_of +from mongospecs.msgspec import Spec as MsgspecSpec + + +async def test_discriminators_share_a_collection(m, registry): + await m.Animal(name="generic").insert() + tom = await m.Cat(name="tom", lives=7).insert() + await m.Dog(name="rex").insert() + await m.Puppy(name="bit", age_weeks=6).insert() + kinds = {d["name"]: d["kind"] async for d in m.Animal.collection().find({})} + assert kinds == {"generic": "Animal", "tom": "Cat", "rex": "doggo", "bit": "Puppy"} + assert meta_of(m.Cat).collection == meta_of(m.Puppy).collection == "animals" + everything = await m.Animal.find().sort("name") + assert [type(a).__name__ for a in everything] == ["Puppy", "Animal", "Dog", "Cat"] + assert everything[3].lives == 7 + assert [c.name for c in await m.Cat.find()] == ["tom"] + assert sorted(d.name for d in await m.Dog.find()) == ["bit", "rex"] # includes subclasses + assert await m.Puppy.count() == 1 and await m.Animal.count() == 4 + assert await m.Dog.get(tom.id) is None and type(await m.Animal.get(tom.id)) is m.Cat + loaded = await m.Animal.get(tom.id) + loaded.lives = 6 + await loaded.save() + raw = await m.Cat.collection().find_one({"_id": tom.id}) + assert raw["lives"] == 6 and raw["kind"] == "Cat" + assert await m.Dog.find().update(name="renamed") == 2 + assert (await m.Animal.get(tom.id)).name == "tom" + assert await m.Cat.find().delete() == 1 and await m.Animal.count() == 3 + + +async def test_unknown_discriminator_value_falls_back_to_the_queried_class(m, registry): + await m.Animal.collection().insert_one({"name": "alien", "kind": "Martian"}) + (doc,) = await m.Animal.find() + assert type(doc) is m.Animal and doc.name == "alien" + + +def test_discriminator_key_cannot_collide_with_a_field(m): + @m.define + class Clash(m.Spec): + __spec__ = SpecConfig(discriminator="kind") + kind: str = "" + + with pytest.raises(ConfigurationError, match="collides"): + meta_of(Clash) + + +def test_config_inheritance_rules(m): + @m.define + class Base(m.Spec): + __spec__ = SpecConfig(collection="bases", max_time_ms=500) + + @m.define + class Child(Base): + pass + + @m.define + class Renamed(Base): + __spec__ = SpecConfig(collection="renamed") + + assert meta_of(Base).collection == "bases" + assert meta_of(Child).collection == "Child" and meta_of(Child).config.max_time_ms == 500 + assert meta_of(Renamed).collection == "renamed" + assert meta_of(m.Video).collection == "videos" and meta_of(m.Puppy).disc_value == "Puppy" + + +async def test_upcast_migrates_old_documents_lazily(m, make_registry): + def upcast(doc): + if "full_name" in doc: # renamed field + doc["name"] = doc.pop("full_name") + doc.setdefault("age", 18) # field added later + return doc + + @m.define + class Person(m.Spec): + __spec__ = SpecConfig(collection="people", upcast=upcast) + name: str + age: int + + await make_registry(Person) + await Person.collection().insert_one({"full_name": "Old Shape"}) + person = await Person.find().one() + assert person.name == "Old Shape" and person.age == 18 + person.age = 19 + await person.save() + raw = await Person.collection().find_one({}) + assert raw["age"] == 19 and raw["full_name"] == "Old Shape" # untouched until written explicitly + + +async def test_using_another_database(m, registry, db_name): + other = db_name + "_b" + try: + tenant_b = m.User.using(db=other) + await m.User(name="main").insert() + b_user = await tenant_b.insert(m.User(name="b")) + assert [u.name for u in await m.User.find()] == ["main"] + assert [u.name for u in await tenant_b.find()] == ["b"] + assert await tenant_b.count() == 1 and await m.User.count() == 1 + b_user.age = 4 + await b_user.save() # saved back through the manager it was written with + assert (await tenant_b.get(b_user.id)).age == 4 + assert await m.User.get(b_user.id) is None + loaded = await tenant_b.find().one() + await loaded.delete() + assert await tenant_b.count() == 0 and await m.User.count() == 1 + finally: + await registry.client.drop_database(other) + + +async def test_two_registries_are_isolated(m, make_registry, db_name): + first = await make_registry(m.User) + with pytest.raises(ConfigurationError, match="already bound"): + await make_registry(m.User, database=db_name + "_2") + await m.User(name="first").insert() + await first.close() + second = await make_registry(m.User, database=db_name + "_2") + assert await m.User.count() == 0 # rebinding after close goes to the new registry + assert second.models == (m.User,) + + +async def test_spec_adapter_documents_work_end_to_end(m, make_registry): + Dragon = m.SpecAdapter(m.Plain, collection="dragons") + await make_registry(Dragon) + burt = await Dragon(name="Burt").insert() + burt.legs = 2 + await burt.save() + loaded = await Dragon.get(burt.id) + assert loaded.legs == 2 and isinstance(loaded, m.Plain) + assert await Dragon.count(Q.name == "Burt") == 1 + + +# -- module-level forward references (real annotations resolved lazily) --------------- + + +class Chapter(MsgspecSpec): + __spec__ = SpecConfig(collection="fwd_chapters") + title: str = "" + book: t.Optional[Ref["Book"]] = None # noqa: UP045 + previous: t.Annotated[t.Optional[Ref], To("Chapter")] = None # noqa: UP045 + + +class Book(MsgspecSpec): + __spec__ = SpecConfig(collection="fwd_books") + name: str = "" + + +async def test_forward_references_resolve(make_registry): + await make_registry(Chapter, Book) + assert meta_of(Chapter).ref_targets("book")[0].model is Book + assert meta_of(Chapter).ref_targets("previous")[0].model is Chapter + book = await Book(name="b").insert() + one = await Chapter(title="1", book=book).insert() + await Chapter(title="2", book=book, previous=one).insert() + second = await Chapter.find(Q.title == "2").include("book").include("previous").one() + assert second.book.doc.name == "b" and second.previous.doc.title == "1" + assert (await second.book.fetch()).id == book.id + + +def test_unresolvable_names_are_reported(): + class Orphan(MsgspecSpec): + other: t.Annotated[t.Optional[Ref], To("DoesNotExist")] = None # noqa: UP045 + + with pytest.raises(ConfigurationError, match="DoesNotExist"): + meta_of(Orphan).ref_targets("other") + assert ObjectId.is_valid(str(ObjectId())) diff --git a/packages/mongospecs/tests/test_plugins_hooks.py b/packages/mongospecs/tests/test_plugins_hooks.py new file mode 100644 index 0000000..0e5af7f --- /dev/null +++ b/packages/mongospecs/tests/test_plugins_hooks.py @@ -0,0 +1,247 @@ +import datetime as dt + +import pytest + +from mongospecs import ConfigurationError, DocumentNotFound, Inc, OpCtx, Plugin, Q, StaleDocumentError + + +async def test_timestamps_on_every_write_path(m, registry): + art = await m.Article(title="a").insert() + assert art.created_at == art.updated_at and art.created_at.microsecond % 1000 == 0 + created = art.created_at + await _tick() + art.title = "b" + await art.save() + assert art.created_at == created and art.updated_at > created + saved_at = art.updated_at + await _tick() + updated = await m.Article.update(art.id, Inc(views=1)) + assert updated.updated_at > saved_at and updated.created_at == created + await _tick() + await m.Article.find(Q.id == art.id).update(Inc(views=1)) + assert (await m.Article.get(art.id)).updated_at > updated.updated_at + many = await m.Article.insert_many([m.Article(title="x"), m.Article(title="y")]) + assert all(d.created_at and d.updated_at for d in many) + explicit = dt.datetime(2020, 1, 1, tzinfo=dt.UTC) + kept = await m.Article(title="old", created_at=explicit).insert() + assert kept.created_at == explicit and kept.updated_at > explicit + + +async def _tick(): + import asyncio + + await asyncio.sleep(0.003) + + +async def test_soft_delete_scope(m, registry): + a = await m.Article(title="a").insert() + b = await m.Article(title="b").insert() + await a.delete() + assert a.deleted_at is not None and not a.is_new + assert await m.Article.count() == 1 + assert await m.Article.get(a.id) is None + assert [x.title for x in await m.Article.find()] == ["b"] + assert await m.Article.find().unscoped("soft_delete").count() == 2 + assert await m.Article.exists(Q.title == "a") is False + # updates and deletes do not reach soft-deleted documents + with pytest.raises(DocumentNotFound): + await m.Article.update(a.id, title="zombie") + assert await m.Article.find().update(Inc(views=1)) == 1 + assert await m.Article.find(Q.title == "a").delete() == 0 + # aggregate is scoped too + assert [r["title"] for r in await m.Article.aggregate([{"$project": {"title": 1}}])] == ["b"] + assert len(await m.Article.aggregate([], unscoped=True)) == 2 + # estimated_count is documented as unscoped + assert await m.Article.estimated_count() == 2 + # filter-based soft delete, restore, hard delete + assert await m.Article.find(Q.title == "b").delete() == 1 + assert await m.Article.count() == 0 + assert await m.Article.find(Q.id == b.id).unscoped("soft_delete").update(deleted_at=None) == 1 + assert await m.Article.count() == 1 + await (await m.Article.get(b.id)).delete(hard=True) + assert await m.Article.find().unscoped().count() == 1 + assert await m.Article.find().unscoped().delete(hard=True) == 1 + assert await m.Article.collection().count_documents({}) == 0 + + +async def test_soft_deleted_document_can_still_be_saved_by_its_holder(m, registry): + a = await m.Article(title="a").insert() + await a.delete() + a.title = "edited after delete" + await a.save() + raw = await m.Article.collection().find_one({"_id": a.id}) + assert raw["title"] == "edited after delete" and raw["deleted_at"] is not None + + +async def test_revision_increments_everywhere(m, registry): + art = await m.Article(title="a").insert() + assert art.rev == 0 + art.title = "b" + await art.save() + assert art.rev == 1 + assert (await m.Article.update(art.id, Inc(views=1))).rev == 2 + await m.Article.find().update(Inc(views=1)) + assert (await m.Article.get(art.id)).rev == 3 + art.title = "stale" + with pytest.raises(StaleDocumentError): + await art.save() + with pytest.raises(StaleDocumentError): + await art.delete() + art.rev = 99 # tampering with the revision field does not bypass the check + with pytest.raises(StaleDocumentError): + await art.save() + + +async def test_document_hooks_order_session_and_fail_fast(m, registry): + calls = [] + + @m.User.before("insert") + async def first(ctx, doc): + assert isinstance(ctx, OpCtx) and ctx.model is m.User and ctx.registry is registry + calls.append(("before insert", doc.name, ctx.session is not None)) + doc.name = doc.name.strip() + + @m.User.before("save") + async def second(ctx, doc): + calls.append(("before save", doc.name)) + + @m.User.after("save") + async def third(ctx, doc): + calls.append(("after save", doc.id is not None)) + + @m.User.after("delete") + async def fourth(ctx, doc): + calls.append(("after delete", doc.name)) + + try: + user = await m.User(name=" ann ").insert() + assert user.name == "ann" and (await m.User.get(user.id)).name == "ann" + assert calls == [("before insert", " ann ", False), ("before save", "ann"), ("after save", True)] + calls.clear() + user.age = 2 + await user.save() + assert calls == [("before save", "ann"), ("after save", True)] + calls.clear() + + async def work(tx): + await m.User(name="tx").insert(session=tx) + + await registry.transaction(work) + assert calls[0] == ("before insert", "tx", True) + calls.clear() + # filter-based writes do not run document hooks + await m.User.find().update(age=9) + await m.User.find(Q.name == "tx").delete() + assert calls == [] + await user.delete() + assert calls == [("after delete", "ann")] + + @m.User.before("insert") + async def veto(ctx, doc): + raise PermissionError("no") + + try: + with pytest.raises(PermissionError): + await m.User(name="blocked").insert() + assert await m.User.count() == 0 + finally: + _unhook(m.User, "before", "insert", veto) + finally: + for when, event, fn in ( + ("before", "insert", first), + ("before", "save", second), + ("after", "save", third), + ("after", "delete", fourth), + ): + _unhook(m.User, when, event, fn) + + +def _unhook(model, when, event, fn): + from mongospecs.meta import remove_hook + + remove_hook(model, when, event, fn) + + +async def test_hooks_are_inherited_by_subclasses(m, registry): + seen = [] + + @m.Animal.before("insert") + async def note(ctx, doc): + seen.append(type(doc).__name__) + + try: + await m.Cat(name="tom").insert() + await m.Animal(name="generic").insert() + assert seen == ["Cat", "Animal"] + finally: + _unhook(m.Animal, "before", "insert", note) + with pytest.raises(ConfigurationError): + m.Animal.before("explode") + + +class TenantScope(Plugin): + """A registry-level plugin: every model is confined to one tenant.""" + + name = "tenant" + + def __init__(self, tenant): + self.tenant = tenant + self.bulk = [] + + def on_filter(self, ctx, flt): + return {"tenant_id": self.tenant} + + def on_insert(self, ctx, doc): + doc["tenant_id"] = self.tenant + + def on_update(self, ctx, flt, update): + if ctx.extra.get("upsert"): + update.setdefault("$setOnInsert", {})["tenant_id"] = self.tenant + + def on_pipeline(self, ctx, pipeline): + return [*pipeline, {"$addFields": {"seen_by": self.tenant}}] + + async def before_bulk(self, ctx, operations): + self.bulk.append(("before", len(operations))) + + async def after_bulk(self, ctx, operations): + self.bulk.append(("after", len(operations))) + + +async def test_registry_level_plugin_applies_to_every_model(m, make_registry): + scope = TenantScope("t1") + await make_registry(m.User, m.Article, plugins=[scope]) + await m.User(name="mine").insert() + await m.User.collection().insert_one({"name": "theirs", "tenant_id": "t2"}) + assert [u.name for u in await m.User.find()] == ["mine"] + assert await m.User.count() == 1 + assert await m.User.find().update(age=5) == 1 + assert (await m.User.collection().find_one({"name": "theirs"})).get("age") is None + assert await m.User.find().unscoped("tenant").count() == 2 + rows = await m.User.aggregate([{"$project": {"name": 1}}]) + assert [(r["name"], r["seen_by"]) for r in rows] == [("mine", "t1")] + await m.User.insert_many([m.User(name="x"), m.User(name="y")]) + assert scope.bulk == [("before", 2), ("after", 2)] + assert (await m.User.upsert(Q.name == "up", age=1)).name == "up" + assert (await m.User.collection().find_one({"name": "up"}))["tenant_id"] == "t1" + # both scopes combine on a model that also soft-deletes + assert m.Article.find().compile()["filter"] == {"tenant_id": "t1", "deleted_at": None} + + +async def test_named_scopes(m, registry): + @m.User.scope + def adults(query, minimum=18): + return query.filter(Q.age >= minimum) + + @m.User.scope + def named(query, name): + return Q.name == name # a condition is accepted too + + await m.User.insert_many( + [m.User(name="kid", age=5), m.User(name="ann", age=30), m.User(name="bob", age=70)] + ) + assert sorted(u.name for u in await m.User.find().adults()) == ["ann", "bob"] + assert [u.name for u in await m.User.find().adults(65)] == ["bob"] + assert [u.name for u in await m.User.find().adults().named("ann").lean()] == ["ann"] + with pytest.raises(AttributeError, match="named scope"): + m.User.find().nonexistent() diff --git a/packages/mongospecs/tests/test_query_compile.py b/packages/mongospecs/tests/test_query_compile.py new file mode 100644 index 0000000..3dd7638 --- /dev/null +++ b/packages/mongospecs/tests/test_query_compile.py @@ -0,0 +1,140 @@ +"""Golden tests: queries compile to exact find arguments and pipelines.""" + +import pytest +from bson import ObjectId + +from mongospecs import Q, QueryError + + +async def test_plain_find_arguments(m, registry): + q = m.User.find(Q.age > 1, Q.name.prefix("a")).sort("-age", Q.name).skip(5).limit(10) + assert q.compile() == { + "filter": {"age": {"$gt": 1}, "name": {"$regex": "^a"}}, + "projection": {"secret": 0}, + "sort": [("age", -1), ("name", 1)], + "skip": 5, + "limit": 10, + } + + +async def test_queries_are_immutable(m, registry): + base = m.User.find(Q.age > 1) + narrowed = base.filter(Q.name == "a").sort("name").limit(3).lean() + assert base.compile()["filter"] == {"age": {"$gt": 1}} + assert base.compile()["limit"] == 0 and base.compile()["sort"] == [] + assert narrowed.compile()["filter"] == {"age": {"$gt": 1}, "name": "a"} + assert base.where is not None and base._mode == "tracked" and narrowed._mode == "lean" + + +async def test_projection_rules(m, registry): + assert m.User.find().select("+secret").compile()["projection"] is None + assert m.User.find().select("name", "age").compile()["projection"] == {"name": 1, "age": 1} + assert m.User.find().select("-age").compile()["projection"] == {"secret": 0, "age": 0} + assert m.Animal.find().select("name").compile()["projection"] == {"name": 1, "kind": 1} + assert m.Post.find().select("summary").compile()["projection"] == {"s": 1} + + +async def test_scopes_are_in_every_filter(m, registry): + assert m.Article.find().compile()["filter"] == {"deleted_at": None} + assert m.Article.find(Q.title == "x").compile()["filter"] == {"title": "x", "deleted_at": None} + assert m.Article.find().unscoped().compile()["filter"] == {} + assert m.Article.find().unscoped("soft_delete").compile()["filter"] == {} + assert m.Article.find().unscoped("other").compile()["filter"] == {"deleted_at": None} + explicit = m.Article.find(Q.deleted_at != None).compile()["filter"] # noqa: E711 + assert explicit == {"deleted_at": {"$ne": None}} + assert m.Cat.find().compile()["filter"] == {"kind": "Cat"} + assert m.Dog.find().compile()["filter"] == {"kind": {"$in": ["doggo", "Puppy"]}} + assert m.Animal.find().compile()["filter"] == {} + + +async def test_lookup_pipeline_orders_page_stages_before_joins(m, registry): + q = ( + m.Article.find(Q.views > 1) + .sort("-views") + .skip(2) + .limit(3) + .include("author", strategy="lookup", select=["name"]) + ) + assert q.compile() == { + "pipeline": [ + {"$match": {"views": {"$gt": 1}, "deleted_at": None}}, + {"$sort": {"views": -1}}, + {"$skip": 2}, + {"$limit": 3}, + { + "$lookup": { + "from": "users", + "localField": "author", + "foreignField": "_id", + "as": "__inc_author", + "pipeline": [{"$project": {"name": 1}}], + } + }, + ] + } + + +async def test_lookup_applies_target_scope_and_options(m, registry): + q = m.Comment.find().include( + "article", strategy="lookup", match=Q.views > 5, sort=["-views"], per_parent_limit=1 + ) + (stage,) = [s for s in q.compile()["pipeline"] if "$lookup" in s] + assert stage["$lookup"]["pipeline"] == [ + {"$match": {"views": {"$gt": 5}, "deleted_at": None}}, + {"$sort": {"views": -1}}, + {"$limit": 1}, + ] + + +async def test_backref_and_nested_lookup(m, registry): + q = m.Post.find().include("comments", strategy="lookup").include("comment_count", strategy="lookup") + lookups = [s["$lookup"] for s in q.compile()["pipeline"]] + assert lookups[0] == { + "from": "notes", + "localField": "_id", + "foreignField": "post", + "as": "__inc_comments", + } + assert lookups[1]["pipeline"] == [{"$count": "n"}] and lookups[1]["foreignField"] == "post" + nested = m.Comment.find().include("article.author", strategy="lookup").compile()["pipeline"] + inner = nested[-1]["$lookup"]["pipeline"] + assert inner[0] == {"$match": {"deleted_at": None}} + assert inner[-1]["$lookup"]["from"] == "users" and inner[-1]["$lookup"]["localField"] == "author" + + +async def test_batch_strategy_keeps_a_plain_find(m, registry): + q = m.Article.find().include("author") + assert "pipeline" not in q.compile() + assert "pipeline" not in m.Article.find().include("author", strategy="batch").compile() + + +async def test_lookup_refused_where_it_cannot_work(m, registry): + with pytest.raises(QueryError, match="lookup"): + m.Article.find().include("reviewers", strategy="lookup").compile() + with pytest.raises(QueryError, match="lookup"): + m.Bookmark.find().include("target", strategy="lookup").compile() + with pytest.raises(QueryError, match="lookup"): + m.Article.find().include("author", strategy="lookup", limit=3).compile() + with pytest.raises(QueryError, match="not a relation"): + m.Article.find().include("title").compile() + + +async def test_option_validation_and_write_guards(m, registry): + with pytest.raises(QueryError): + m.User.find().options(nonsense=1) + with pytest.raises(QueryError): + m.User.find().skip(-1) + with pytest.raises(QueryError): + await m.User.find().limit(1).update(age=1) + with pytest.raises(QueryError): + await m.User.find().sort("age").delete() + assert m.User.find().options(max_time_ms=500, comment="x")._options == { + "max_time_ms": 500, + "comment": "x", + } + + +async def test_get_casts_id(m, registry): + oid = ObjectId() + assert m.User._manager().compile(str(oid)) == {"_id": oid} + assert m.User._manager().compile(Q.age > 1) == {"age": {"$gt": 1}} diff --git a/packages/mongospecs/tests/test_registry_errors.py b/packages/mongospecs/tests/test_registry_errors.py new file mode 100644 index 0000000..44a6221 --- /dev/null +++ b/packages/mongospecs/tests/test_registry_errors.py @@ -0,0 +1,240 @@ +import logging + +import pytest +from pymongo import errors as pe + +from mongospecs import ( + ConfigurationError, + ConnectionFailed, + DocumentTooLarge, + DriverError, + DuplicateKeyError, + EncodingError, + MongoSpecsError, + OperationFailed, + OperationTimeout, + PoolTimeout, + Q, + Registry, + RegistryClosedError, + logging_listener, +) +from mongospecs.errors import BulkWriteFailed, translate + +from .conftest import RS_URI + + +def test_constructing_a_registry_does_no_io(m): + reg = Registry("mongodb://203.0.113.1:1/none", models=[m.User], serverSelectionTimeoutMS=50) + assert not reg.is_open and reg.models == (m.User,) + with pytest.raises(RegistryClosedError): + _ = reg.client + with pytest.raises(RegistryClosedError): + m.User.find().compile() and m.User.collection() + + +async def test_operations_fail_immediately_when_not_open(m, db_name): + reg = Registry(RS_URI, database=db_name, models=[m.User]) + with pytest.raises(RegistryClosedError): + await m.User(name="x").insert() # no command buffering + await reg.open() + await reg.ping() + await m.User(name="x").insert() + await reg.client.drop_database(db_name) + await reg.close() + await reg.close() # idempotent + with pytest.raises(RegistryClosedError): + await reg.open() # closing is final + with pytest.raises(RegistryClosedError): + reg.bind(m.Article) + from mongospecs import NotBoundError + + with pytest.raises(NotBoundError): + await m.User.count() + + +async def test_unreachable_server_is_a_connection_error(m, db_name): + async with Registry( + "mongodb://127.0.0.1:1", database=db_name, models=[m.User], serverSelectionTimeoutMS=200 + ): + with pytest.raises(ConnectionFailed) as info: + await m.User.count() + assert isinstance(info.value.__cause__, pe.PyMongoError) + + +async def test_missing_database_name_is_reported(m): + async with Registry(RS_URI, models=[m.User]): + with pytest.raises(ConfigurationError, match="database"): + m.User.collection() + + +def test_every_driver_error_maps_to_one_error(): + cases = [ + (pe.DuplicateKeyError("dup", 11000, {"keyValue": {"email": "x"}}), DuplicateKeyError), + (pe.ExecutionTimeout("slow", 50), OperationTimeout), + (pe.NetworkTimeout("net"), OperationTimeout), + (pe.WTimeoutError("w", 64), OperationTimeout), + (pe.WaitQueueTimeoutError("pool"), PoolTimeout), + (pe.ServerSelectionTimeoutError("sel"), ConnectionFailed), + (pe.AutoReconnect("re"), ConnectionFailed), + (pe.NotPrimaryError("np"), ConnectionFailed), + (pe.ConnectionFailure("cf"), ConnectionFailed), + (pe.WriteError("we", 2), OperationFailed), + (pe.WriteConcernError("wc", 64), OperationFailed), + (pe.CursorNotFound("cnf", 43), OperationFailed), + (pe.OperationFailure("too big", 10334), DocumentTooLarge), + (pe.OperationFailure("op", 1), OperationFailed), + (pe.DocumentTooLarge("big"), DocumentTooLarge), + (pe.InvalidOperation("inv"), DriverError), + (pe.ConfigurationError("cfg"), DriverError), + (pe.PyMongoError("generic"), DriverError), + (pe.BulkWriteError({"writeErrors": [{"index": 0, "code": 2, "errmsg": "x"}]}), BulkWriteFailed), + ( + pe.BulkWriteError( + {"writeErrors": [{"index": 0, "code": 11000, "errmsg": "x", "keyValue": {"a": 1}}]} + ), + DuplicateKeyError, + ), + ] + for exc, expected in cases: + out = translate(exc) + assert type(out) is expected, (exc, out) + assert isinstance(out, MongoSpecsError) + assert not isinstance(out, pe.PyMongoError) # wrapped, never subclassed + assert translate(cases[0][0]).fields == {"email": "x"} + assert translate(cases[-1][0]).fields == {"a": 1} + from bson.errors import InvalidDocument + + assert type(translate(InvalidDocument("bad"))) is EncodingError + same = MongoSpecsError("x") + assert translate(same) is same + + +async def test_timeout_and_encoding_errors_from_real_operations(m, registry): + await m.User.insert_many([m.User(name=str(i)) for i in range(50)]) + from mongospecs import trusted + + with pytest.raises(OperationTimeout): + await m.User.find(trusted({"$where": "sleep(200) || true"})).options(max_time_ms=20).all() + + class Weird: + pass + + with pytest.raises(EncodingError): + await m.User.collection().insert_one({"x": Weird()}) if False else await _insert_weird(m, Weird()) + + +async def _insert_weird(m, value): + from mongospecs import Set + + await m.User.find().update(Set({"address.city": value})) + + +async def test_default_max_time_ms_from_registry_and_model(m, make_registry): + events = [] + await make_registry(m.User, max_time_ms=1234, on_command=events.append) + await m.User.find().all() + await m.User.find().options(max_time_ms=99).all() + await m.User.count() + finds = [e.command for e in events if e.phase == "started" and e.command_name == "find"] + assert [c["maxTimeMS"] for c in finds] == [1234, 99] + counts = [e.command for e in events if e.phase == "started" and e.command_name == "aggregate"] + assert counts[0]["maxTimeMS"] == 1234 + + +async def test_command_events_carry_model_and_operation(m, make_registry, caplog): + events = [] + await make_registry(m.User, on_command=[events.append, logging_listener(level=logging.INFO)]) + with caplog.at_level(logging.INFO, logger="mongospecs.commands"): + user = await m.User(name="ann").insert() + await m.User.find(Q.name == "ann").all() + await m.User.update(user.id, age=3) + user.age = 4 + await user.save() + await m.User.count() + await user.delete() + with pytest.raises(OperationFailed): + await m.User.aggregate([{"$nope": 1}]) + seen = [(e.model, e.op, e.command_name) for e in events if e.phase == "started"] + assert seen == [ + ("User", "insert", "insert"), + ("User", "find", "find"), + ("User", "update", "findAndModify"), + ("User", "save", "update"), + ("User", "count", "aggregate"), + ("User", "delete", "delete"), + ("User", "aggregate", "aggregate"), + ] + ok = [e for e in events if e.phase == "succeeded"] + assert len(ok) == 6 and all(e.duration_ms is not None and e.duration_ms >= 0 for e in ok) + (failed,) = [e for e in events if e.phase == "failed"] + assert failed.op == "aggregate" and failed.failure + text = caplog.text + assert "User.insert" in text and "ok in" in text and "failed in" in text + + +async def test_raw_driver_use_is_reported_without_model(m, make_registry): + events = [] + reg = await make_registry(m.User, on_command=events.append) + await reg.database().command("ping") + assert [(e.model, e.op) for e in events if e.phase == "started"] == [(None, None)] + + +async def test_otel_listener_creates_spans(m, make_registry): + from opentelemetry.sdk.trace import TracerProvider + from opentelemetry.sdk.trace.export import SimpleSpanProcessor + from opentelemetry.sdk.trace.export.in_memory_span_exporter import InMemorySpanExporter + + from mongospecs import otel_listener + + exporter = InMemorySpanExporter() + provider = TracerProvider() + provider.add_span_processor(SimpleSpanProcessor(exporter)) + await make_registry(m.User, on_command=otel_listener(provider.get_tracer("test"))) + await m.User(name="ann").insert() + await m.User.find().all() + with pytest.raises(OperationFailed): + await m.User.aggregate([{"$nope": 1}]) + spans = exporter.get_finished_spans() + assert [s.name for s in spans] == ["User.insert", "User.find", "User.aggregate"] + assert spans[0].attributes["db.system"] == "mongodb" and spans[0].attributes["mongospecs.model"] == "User" + assert spans[2].status.status_code.name == "ERROR" and spans[0].status.status_code.name != "ERROR" + + +async def test_sentry_listener_records_spans_in_the_active_transaction(m, make_registry): + import sentry_sdk + from sentry_sdk.transport import Transport + + from mongospecs import sentry_listener + + captured = [] + + class Capture(Transport): + def capture_envelope(self, envelope): + captured.extend(item.payload.json for item in envelope.items if item.type == "transaction") + + sentry_sdk.init( + dsn="http://key@localhost:1/1", + traces_sample_rate=1.0, + transport=Capture, + default_integrations=False, + auto_enabling_integrations=False, + ) + try: + await make_registry(m.User, on_command=sentry_listener()) + with sentry_sdk.start_transaction(name="request", op="http.server"): + await m.User(name="ann").insert() + await m.User.find().all() + with pytest.raises(OperationFailed): + await m.User.aggregate([{"$nope": 1}]) + await m.User.count() # outside any transaction: nothing to attach to, nothing sent + sentry_sdk.flush() + finally: + sentry_sdk.init() # switch the SDK off again + (event,) = captured + spans = event["spans"] + assert [s["description"] for s in spans] == ["User.insert", "User.find", "User.aggregate"] + assert all(s["op"] == "db" and s["origin"] == "auto.db.mongospecs" for s in spans) + assert [s["status"] for s in spans] == ["ok", "ok", "internal_error"] + assert spans[0]["data"]["db.system"] == "mongodb" and spans[0]["data"]["mongospecs.model"] == "User" + assert {s["trace_id"] for s in spans} == {event["contexts"]["trace"]["trace_id"]} diff --git a/packages/mongospecs/tests/test_relations.py b/packages/mongospecs/tests/test_relations.py new file mode 100644 index 0000000..a848917 --- /dev/null +++ b/packages/mongospecs/tests/test_relations.py @@ -0,0 +1,242 @@ +import pytest + +from mongospecs import DocumentNotFound, NotLoadedError, Q, Ref, relations + +STRATEGIES = ("batch", "lookup") + + +@pytest.fixture +async def data(m, registry): + ann = await m.User(name="ann", age=30).insert() + bob = await m.User(name="bob", age=40).insert() + cy = await m.User(name="cy", age=50).insert() + a1 = await m.Article( + title="a1", views=1, author=ann, editors=[bob, cy], reviewers={"lead": cy, "second": ann} + ).insert() + a2 = await m.Article(title="a2", views=2, author=bob, editors=[]).insert() + a3 = await m.Article(title="a3", views=3).insert() + return {"ann": ann, "bob": bob, "cy": cy, "a1": a1, "a2": a2, "a3": a3} + + +async def test_refs_are_unloaded_until_included_or_fetched(m, registry, data): + art = await m.Article.get(data["a1"].id) + assert isinstance(art.author, Ref) and art.author.id == data["ann"].id and not art.author.loaded + assert art.author == data["ann"].id and art.author == Ref(data["ann"].id) + with pytest.raises(NotLoadedError): + _ = art.author.doc + assert (await art.author.fetch()).name == "ann" + assert art.author.loaded and art.author.doc.name == "ann" + assert art.changes() == {} # loading a reference is not a change + + +@pytest.mark.parametrize("strategy", STRATEGIES) +async def test_include_one_and_list(m, registry, data, strategy): + arts = ( + await m.Article.find() + .sort("title") + .include("author", strategy=strategy) + .include("editors", strategy=strategy) + ) + assert [a.author.doc.name if a.author else None for a in arts] == ["ann", "bob", None] + assert [e.doc.name for e in arts[0].editors] == ["bob", "cy"] # order follows the stored ids + assert arts[1].editors == [] + assert all(a.changes() == {} for a in arts) + assert not arts[0].author.doc.is_new # related documents are tracked like their parents + lean = await m.Article.find(Q.title == "a1").lean().include("author", strategy=strategy).one() + assert lean.is_new and lean.author.doc.is_new + + +@pytest.mark.parametrize("strategy", STRATEGIES) +async def test_include_options(m, registry, data, strategy): + arts = await m.Article.find(Q.title == "a1").include( + "editors", strategy=strategy, match=Q.age > 40, select=["name"] + ) + (art,) = arts + assert [(e.id, e.loaded) for e in art.editors] == [(data["bob"].id, False), (data["cy"].id, True)] + assert art.editors[1].doc.name == "cy" and art.editors[1].doc.age == 0 # not selected + limited = ( + await m.Article.find(Q.title == "a1") + .include("editors", strategy=strategy, per_parent_limit=1, sort=["name"]) + .one() + ) + assert sum(e.loaded for e in limited.editors) == 1 + + +async def test_dict_of_refs_and_overall_limit_use_batch(m, registry, data): + art = await m.Article.find(Q.title == "a1").include("reviewers").one() + assert {k: r.doc.name for k, r in art.reviewers.items()} == {"lead": "cy", "second": "ann"} + capped = await m.Article.find().sort("title").include("author", limit=1, sort=["name"]) + assert [a.author.loaded if a.author else None for a in capped] == [True, False, None] + + +@pytest.mark.parametrize("strategy", STRATEGIES) +async def test_nested_include(m, registry, data, strategy): + await m.Comment(body="c1", article=data["a1"], author=data["bob"]).insert() + await m.Comment(body="c2", article=data["a2"]).insert() + comments = await m.Comment.find().sort("body").include("article.author", strategy=strategy) + assert comments[0].article.doc.author.doc.name == "ann" + assert comments[1].article.doc.author.doc.name == "bob" + both = ( + await m.Comment.find(Q.body == "c1") + .include("article.author", strategy=strategy) + .include("author", strategy=strategy) + ) + assert both[0].author.doc.name == "bob" and both[0].article.doc.author.doc.name == "ann" + + +@pytest.mark.parametrize("strategy", STRATEGIES) +async def test_included_targets_respect_their_scope(m, registry, data, strategy): + await m.Comment(body="c", article=data["a1"]).insert() + await data["a1"].delete() # soft delete + comment = await m.Comment.find().include("article", strategy=strategy).one() + assert comment.article.id == data["a1"].id and not comment.article.loaded + + +@pytest.mark.parametrize("strategy", STRATEGIES) +async def test_backrefs_and_counts(m, registry, strategy): + p1, p2 = await m.Post(title="p1").insert(), await m.Post(title="p2").insert() + await m.Note.insert_many( + [ + m.Note(text="n1", post=p1, rank=2), + m.Note(text="n2", post=p1, rank=1), + m.Note(text="n3", post=p2, rank=5), + ] + ) + posts = ( + await m.Post.find() + .sort("title") + .include("comments", strategy=strategy, sort=["rank"]) + .include("comment_count", strategy=strategy) + ) + assert [[n.text for n in p.comments] for p in posts] == [["n2", "n1"], ["n3"]] + assert [type(n) for n in posts[0].comments] == [m.Note, m.Note] + assert [p.comment_count for p in posts] == [2, 1] + assert all(p.changes() == {} for p in posts) + plain = await m.Post.find().sort("title").all() + assert plain[0].comments == [] and plain[0].comment_count == 0 + one = ( + await m.Post.find(Q.title == "p1") + .include("comments", strategy=strategy, per_parent_limit=1, sort=["-rank"]) + .one() + ) + assert [n.text for n in one.comments] == ["n1"] + matched = ( + await m.Post.find(Q.title == "p1").include("comments", strategy=strategy, match=Q.rank > 1).one() + ) + assert [n.text for n in matched.comments] == ["n1"] + # a reverse relation is never written + posts[0].title = "renamed" + assert posts[0].changes() == {"$set": {"title": "renamed"}} + await posts[0].save() + raw = await m.Post.collection().find_one({"_id": p1.id}) + assert "comments" not in raw and "comment_count" not in raw + + +async def test_polymorphic_reference(m, registry, data): + video = await m.Video(title="v", seconds=60).insert() + await m.Bookmark(label="art", target=data["a2"]).insert() + await m.Bookmark(label="vid", target=video).insert() + marks = await m.Bookmark.find().sort("label").include("target") + assert type(marks[0].target.doc) is m.Article and marks[0].target.doc.title == "a2" + assert type(marks[1].target.doc) is m.Video and marks[1].target.doc.seconds == 60 + + +async def test_raw_mode_include_replaces_ids_with_documents(m, registry, data): + raw = await m.Article.find(Q.title == "a1").raw().include("author").include("editors").one() + assert raw["author"]["name"] == "ann" + assert [e["name"] for e in raw["editors"]] == ["bob", "cy"] + + +async def test_both_strategies_give_identical_results(m, registry, data): + def shape(arts): + return [ + ( + a.title, + a.author.doc.name if a.author and a.author.loaded else None, + [e.doc.name for e in a.editors if e.loaded], + ) + for a in arts + ] + + batch = ( + await m.Article.find() + .sort("title") + .include("author", strategy="batch") + .include("editors", strategy="batch") + ) + lookup = ( + await m.Article.find() + .sort("title") + .include("author", strategy="lookup") + .include("editors", strategy="lookup") + ) + assert shape(batch) == shape(lookup) + assert relations.DEFAULT_STRATEGY in ("batch", "lookup") + + +async def test_cascade_rules(m, registry, data): + await m.Comment.insert_many( + [m.Comment(body="c1", article=data["a1"]), m.Comment(body="c2", article=data["a2"])] + ) + # nullify + pull: deleting a user clears author and removes them from editors + await data["bob"].delete() + a1, a2 = await m.Article.get(data["a1"].id), await m.Article.get(data["a2"].id) + assert [e.id for e in a1.editors] == [data["cy"].id] + assert a2.author is None and a1.author.id == data["ann"].id + # cascade: hard-deleting an article deletes its comments + await a1.delete(hard=True) + assert [c.body for c in await m.Comment.find()] == ["c2"] + # soft delete does not cascade + await a2.delete() + assert await m.Comment.count() == 1 + # filter-based hard delete cascades as well + assert await m.Article.find().unscoped().delete(hard=True) == 2 # a2 (soft-deleted) and a3 + assert await m.Comment.count() == 0 + + +async def test_cascade_is_atomic(m, registry, data): + await m.Comment(body="c1", article=data["a3"]).insert() + + @m.Article.before("delete") + async def noop(ctx, doc): + pass + + from mongospecs.meta import remove_hook + + remove_hook(m.Article, "before", "delete", noop) + # make the final delete fail after the cascade ran: the article disappears mid-flight + art = await m.Article.get(data["a3"].id) + await m.Article.collection().delete_one({"_id": art.id}) + with pytest.raises(DocumentNotFound): + await art.delete(hard=True) + assert await m.Comment.count() == 1 # the cascade was rolled back with the failed delete + + +async def test_graph_lookup(m, registry): + root = await m.Category(name="root").insert() + mid = await m.Category(name="mid", parent=root).insert() + leaf = await m.Category(name="leaf", parent=mid).insert() + await m.Category(name="other").insert() + (result,) = await m.Category.find(Q.id == leaf.id).graph("parent") + assert result.doc.name == "leaf" + assert [n.name for n in result.nodes] == ["mid", "root"] and result.depths == [0, 1] + (shallow,) = await m.Category.find(Q.id == leaf.id).graph("parent", max_depth=0) + assert [n.name for n in shallow.nodes] == ["mid"] + (restricted,) = await m.Category.find(Q.id == leaf.id).graph("parent", match=Q.name != "root") + assert [n.name for n in restricted.nodes] == ["mid"] + assert type(result.nodes[0]) is m.Category + + +async def test_union_with(m, registry, data): + await m.Video.insert_many([m.Video(title="a0", seconds=1), m.Video(title="z9", seconds=2)]) + mixed = await m.Article.find(Q.views >= 2).union(m.Video.find()).sort("title") + assert [(type(d).__name__, d.title) for d in mixed] == [ + ("Video", "a0"), + ("Article", "a2"), + ("Article", "a3"), + ("Video", "z9"), + ] + page = await m.Article.find().union(m.Video.find(Q.seconds > 1)).sort("-title").skip(1).limit(2) + assert [d.title for d in page] == ["a3", "a2"] + await data["a3"].delete() # scopes apply per branch + assert "a3" not in [d.title for d in await m.Article.find().union(m.Video.find())] diff --git a/packages/mongospecs/tests/test_streams_sharded.py b/packages/mongospecs/tests/test_streams_sharded.py new file mode 100644 index 0000000..9560736 --- /dev/null +++ b/packages/mongospecs/tests/test_streams_sharded.py @@ -0,0 +1,145 @@ +import asyncio +import uuid + +import pytest +import pytest_asyncio + +from mongospecs import Q, Registry, SpecConfig + +from .conftest import SHARDED_URI + + +async def test_change_stream_events(m, registry): + await m.User(name="before").insert() + async with m.User.watch(max_await_time_ms=200) as stream: + events = [] + user = await m.User(name="ann").insert() + events.append(await asyncio.wait_for(stream.__anext__(), 10)) + user.age = 5 + await user.save() + events.append(await asyncio.wait_for(stream.__anext__(), 10)) + await user.delete() + events.append(await asyncio.wait_for(stream.__anext__(), 10)) + assert [e.operation for e in events] == ["insert", "update", "delete"] + assert type(events[0].document) is m.User and events[0].document.name == "ann" + assert events[0].id == user.id == events[2].id + assert events[1].updated == {"age": 5} and events[1].document.age == 5 + assert events[2].document is None + assert stream.resume_token is not None and events[2].resume_token is not None + assert await stream.try_next() is None + token = events[0].resume_token + async with m.User.watch(resume_after=token, max_await_time_ms=200) as resumed: + again = [await asyncio.wait_for(resumed.__anext__(), 10) for _ in range(2)] + assert [e.operation for e in again] == ["update", "delete"] + + +async def test_change_stream_pipeline_and_async_for(m, registry): + stream = m.User.watch([{"$match": {"operationType": "insert"}}], max_await_time_ms=200) + await stream._open() + await m.User(name="a").insert() + await m.User.find().update(age=1) + await m.User(name="b").insert() + names = [] + async for event in stream: + names.append(event.document.name) + if len(names) == 2: + break + await stream.close() + assert names == ["a", "b"] + + +# -- sharded cluster --------------------------------------------------------------- + + +@pytest_asyncio.fixture +async def sharded(m): + name = f"ms_sh_{uuid.uuid4().hex[:10]}" + async with Registry(SHARDED_URI, database=name, models=m.ALL) as reg: + await reg.client.admin.command("enableSharding", name) + yield reg, name + await reg.client.drop_database(name) + + +@pytest.mark.sharded +async def test_shard_key_is_added_to_document_writes(m, sharded): + reg, name = sharded + events = [] + await reg.client.admin.command("shardCollection", f"{name}.tenant_docs", key={"tenant": 1}) + doc = await m.Tenant(tenant="t1", value=1).insert() + await m.Tenant(tenant="t2", value=1).insert() + async with Registry(SHARDED_URI, database=name, on_command=events.append) as observer: + manager = observer.manager(m.Tenant) + loaded = await manager.find(Q.id == doc.id).one() + loaded.value = 2 + await manager.save(loaded) + await manager.delete_document(loaded) + writes = { + e.command_name: e.command + for e in events + if e.phase == "started" and e.command_name in ("update", "delete") + } + assert writes["update"]["updates"][0]["q"] == {"_id": doc.id, "tenant": "t1"} + assert writes["delete"]["deletes"][0]["q"] == {"_id": doc.id, "tenant": "t1"} + assert await m.Tenant.count() == 1 + + +@pytest.mark.sharded +async def test_relations_on_a_sharded_cluster(m, sharded): + reg, name = sharded + await reg.client.admin.command("shardCollection", f"{name}.users", key={"_id": "hashed"}) + await reg.client.admin.command("shardCollection", f"{name}.categories", key={"_id": "hashed"}) + users = (await m.User.insert_many([m.User(name=f"u{i}") for i in range(20)])).inserted + await m.Article.insert_many( + [m.Article(title=f"a{i}", author=users[i], editors=users[:3]) for i in range(20)] + ) + for strategy in ("batch", "lookup"): + arts = ( + await m.Article.find() + .sort("title") + .include("author", strategy=strategy) + .include("editors", strategy=strategy) + ) + assert [a.author.doc.name for a in arts] == sorted(u.name for u in users) + assert all(len([e for e in a.editors if e.loaded]) == 3 for a in arts) + # $unionWith and transactions work through mongos + await m.Video(title="v").insert() + assert len(await m.Article.find().union(m.Video.find())) == 21 + + async def work(tx): + await m.User(name="in tx").insert(session=tx) + return await m.User.count(session=tx) + + assert await reg.transaction(work) == 21 + # $graphLookup cannot read from a sharded collection inside a transaction... outside it can + root = await m.Category(name="root").insert() + leaf = await m.Category(name="leaf", parent=root).insert() + (res,) = await m.Category.find(Q.id == leaf.id).graph("parent") + assert [n.name for n in res.nodes] == ["root"] + + +@pytest.mark.sharded +async def test_lookup_from_sharded_collection_in_transaction_is_a_typed_error(m, sharded): + from mongospecs import MongoSpecsError + + reg, name = sharded + await reg.client.admin.command("shardCollection", f"{name}.users", key={"_id": "hashed"}) + user = await m.User(name="u").insert() + await m.Article(title="a", author=user).insert() + + async def work(tx): + return await m.Article.find(session=tx).include("author", strategy="lookup").all() + + try: + result = await reg.transaction(work) + except MongoSpecsError as exc: + assert "pymongo" not in type(exc).__module__ + else: # newer servers allow it + assert result[0].author.doc.name == "u" + + async def batch(tx): + return await m.Article.find(session=tx).include("author", strategy="batch").all() + + assert (await reg.transaction(batch))[0].author.doc.name == "u" + + +_ = SpecConfig diff --git a/packages/mongospecs/tests/test_tracking_tx.py b/packages/mongospecs/tests/test_tracking_tx.py new file mode 100644 index 0000000..4f5d288 --- /dev/null +++ b/packages/mongospecs/tests/test_tracking_tx.py @@ -0,0 +1,190 @@ +import asyncio +import gc + +import pytest + +from mongospecs import DuplicateKeyError, Q, StaleDocumentError, tracking +from mongospecs.tracking import diff + + +def test_diff_semantics(): + assert diff({"a": 1}, {"a": 1}) == ({}, []) + assert diff({"a": 1}, {"a": 2, "b": 3}) == ({"a": 2, "b": 3}, []) + assert diff({"a": 1, "b": 2}, {"a": 1}) == ({}, ["b"]) + assert diff({"n": {"x": 1, "y": 2}}, {"n": {"x": 1, "y": 3}}) == ({"n.y": 3}, []) + assert diff({"n": {"x": 1}}, {"n": {}}) == ({"n": {}}, []) + assert diff({"n": {"x": 1, "y": 1}}, {"n": {"x": 1}}) == ({}, ["n.y"]) + assert diff({"l": [1, 2]}, {"l": [1, 2, 3]}) == ({"l": [1, 2, 3]}, []) + assert diff({"n": {"a.b": 1}}, {"n": {"a.b": 2}}) == ({"n": {"a.b": 2}}, []) # undottable key + assert diff({"n": {"$x": 1}}, {"n": {"$x": 2}}) == ({"n": {"$x": 2}}, []) + assert diff({"a": None}, {"a": 0}) == ({"a": 0}, []) + assert diff({"a": 1}, {"a": True}) == ({"a": True}, []) + assert diff({"a": 1}, {"a": None}) == ({"a": None}, []) + assert diff({"_id": 1, "a": 1}, {"_id": 2, "a": 1}) == ({}, []) + assert diff({"a": 1, "b": 1}, {"a": 2, "b": 2}, frozenset({"a"})) == ({"a": 2}, []) + assert diff({"a": 1}, {"b": 5}, frozenset({"b"})) == ({"b": 5}, []) + + +async def test_state_is_released_with_the_document(m, registry): + before = tracking.tracked_count() + docs = await m.User.insert_many([m.User(name=str(i)) for i in range(50)]) + loaded = await m.User.find().all() + assert tracking.tracked_count() >= before + 100 + del docs, loaded + gc.collect() + assert tracking.tracked_count() <= before + 1 + + +async def test_lean_and_raw_are_not_tracked(m, registry): + await m.User.insert_many([m.User(name=str(i)) for i in range(10)]) + gc.collect() + before = tracking.tracked_count() + lean = await m.User.find().lean().all() + raw = await m.User.find().raw().all() + assert tracking.tracked_count() == before + assert len(lean) == len(raw) == 10 + + +async def test_list_changes_are_sent_whole(m, registry): + art = await m.Article(title="a", tags=["x"]).insert() + art.tags.append("y") + assert art.changes() == {"$set": {"tags": ["x", "y"]}} + await art.save() + assert (await m.Article.get(art.id)).tags == ["x", "y"] + + +async def test_reference_changes(m, registry): + a, b = await m.User(name="a").insert(), await m.User(name="b").insert() + art = await m.Article(title="t", author=a).insert() + loaded = await m.Article.get(art.id) + assert loaded.changes() == {} + loaded.author = b + assert loaded.changes() == {"$set": {"author": b.id}} + await loaded.save() + assert (await m.Article.get(art.id)).author.id == b.id + + +async def test_revision_conflict_between_two_loaded_copies(m, registry): + art = await m.Article(title="a").insert() + one, two = await m.Article.get(art.id), await m.Article.get(art.id) + one.title = "one" + await one.save() + assert one.rev == 1 + two.title = "two" + with pytest.raises(StaleDocumentError): + await two.save() + await two.reload() + two.title = "two" + await two.save() + assert two.rev == 2 and (await m.Article.get(art.id)).title == "two" + + +async def test_transaction_commits(m, registry): + async def work(tx): + user = await m.User(name="ann").insert(session=tx) + await m.Article(title="t", author=user).insert(session=tx) + assert await m.User.count(session=tx) == 1 + assert await m.User.count() == 0 # not visible outside yet + return user + + user = await registry.transaction(work) + assert await m.User.count() == 1 and await m.Article.count() == 1 + assert not user.is_new + + +async def test_transaction_abort_rolls_back_database_and_memory(m, registry): + existing = await m.Article(title="orig").insert() + fresh = m.User(name="new") + + async def work(tx): + await fresh.insert(session=tx) + existing.title = "changed" + await existing.save(session=tx) + assert existing.rev == 1 + raise RuntimeError("boom") + + with pytest.raises(RuntimeError): + await registry.transaction(work) + assert await m.User.count() == 0 + assert (await m.Article.get(existing.id)).title == "orig" + # in-memory state is back to before the transaction + assert fresh.is_new and fresh.id is None + assert existing.rev == 0 + assert existing.changes() == {"$set": {"title": "changed"}} + await existing.save() + assert (await m.Article.get(existing.id)).title == "changed" + await fresh.insert() + assert await m.User.count() == 1 + + +async def test_transaction_retries_transient_errors(m, registry): + art = await m.Article(title="a").insert() + attempts = [] + blocker_started = asyncio.Event() + release = asyncio.Event() + + async def blocker(tx): + await m.Article.update(art.id, session=tx, title="blocker") + blocker_started.set() + await release.wait() + + async def contender(tx): + attempts.append(1) + if len(attempts) == 1: + await blocker_started.wait() + else: + release.set() + await blocker_task + return await m.Article.update(art.id, session=tx, views=len(attempts)) + + blocker_task = asyncio.create_task(registry.transaction(blocker)) + result = await registry.transaction(contender) + assert len(attempts) >= 2 # the write conflict was retried by re-running the callback + assert result.views == len(attempts) + final = await m.Article.get(art.id) + assert final.title == "blocker" and final.views == len(attempts) + + +async def test_manual_session_transaction(m, registry): + user = m.User(name="ann") + async with registry.session() as s: + async with s.start_transaction(): + await user.insert(session=s) + assert await m.User.count() == 1 + with pytest.raises(RuntimeError): + async with s.start_transaction(): + user.age = 5 + await user.save(session=s) + raise RuntimeError + assert (await m.User.get(user.id)).age == 0 + assert user.changes() == {"$set": {"age": 5}} + + +async def test_errors_inside_transaction_are_translated(m, registry): + await registry.apply_indexes() + await m.User(name="a", email="x").insert() + + async def work(tx): + await m.User(name="b", email="x").insert(session=tx) + + with pytest.raises(DuplicateKeyError): + await registry.transaction(work) + assert await m.User.count() == 1 + + +async def test_cancellation_leaves_no_session_behind(m, registry): + started = asyncio.Event() + + async def slow(tx): + await m.User(name="x").insert(session=tx) + started.set() + await asyncio.sleep(30) + + task = asyncio.create_task(registry.transaction(slow)) + await started.wait() + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert await m.User.count() == 0 + assert await m.User.find(Q.name == "x").exists() is False + await m.User(name="after").insert() # the client is still usable diff --git a/packages/mongospecs/tests/test_updates.py b/packages/mongospecs/tests/test_updates.py new file mode 100644 index 0000000..d75fdaf --- /dev/null +++ b/packages/mongospecs/tests/test_updates.py @@ -0,0 +1,162 @@ +import pytest +from bson import ObjectId + +from mongospecs import ( + AddToSet, + CurrentDate, + DocumentNotFound, + Each, + Inc, + Max, + Min, + Mul, + Pop, + Pull, + Push, + Q, + QueryError, + Set, + SetOnInsert, + StaleDocumentError, + Unset, + ValidationError, + meta_of, +) +from mongospecs.updates import compile_update + + +def test_compile_operators(m): + meta = meta_of(m.Article) + uid = ObjectId() + update = compile_update( + meta, + [Inc(views=2), Push(tags="a"), AddToSet(editors=uid), Unset("author"), Min({"created_at": None})], + {"title": "t"}, + ) + assert update == { + "$inc": {"views": 2}, + "$push": {"tags": "a"}, + "$addToSet": {"editors": uid}, + "$unset": {"author": ""}, + "$min": {"created_at": None}, + "$set": {"title": "t"}, + } + assert compile_update(meta, [Push(tags=Each(["a", "b"], position=0, slice=5))], {}) == { + "$push": {"tags": {"$each": ["a", "b"], "$position": 0, "$slice": 5}} + } + assert compile_update(meta, [Pull(tags="a"), Pop(editors=-1), Mul(views=3), Max(rev=9)], {}) == { + "$pull": {"tags": "a"}, + "$pop": {"editors": -1}, + "$mul": {"views": 3}, + "$max": {"rev": 9}, + } + assert compile_update(meta, [CurrentDate("updated_at"), SetOnInsert(title="x")], {}) == { + "$currentDate": {"updated_at": True}, + "$setOnInsert": {"title": "x"}, + } + assert compile_update(meta_of(m.Post), [], {"summary": "x"}) == {"$set": {"s": "x"}} + assert compile_update(meta_of(m.User), [Set({"address.city": "Oslo"})], {}) == { + "$set": {"address.city": "Oslo"} + } + assert compile_update(meta_of(m.User), [Pull(name=Q.x > 1)], {}) == {"$pull": {"name": {"x": {"$gt": 1}}}} + + +def test_updates_are_validated(m): + meta = meta_of(m.Article) + with pytest.raises(QueryError, match="no field"): + compile_update(meta, [], {"nope": 1}) + with pytest.raises(ValidationError): + compile_update(meta, [], {"views": "many"}) + with pytest.raises(QueryError, match="number"): + compile_update(meta, [Inc(views="1")], {}) + with pytest.raises(QueryError, match="id"): + compile_update(meta, [], {"id": ObjectId()}) + with pytest.raises(QueryError, match="both"): + compile_update(meta, [Inc(views=1)], {"views": 2}) + with pytest.raises(QueryError, match="not a list"): + compile_update(meta, [Push(title="x")], {}) + with pytest.raises(ValidationError): + compile_update(meta, [Push(tags=5)], {}) + with pytest.raises(QueryError, match="Pop"): + compile_update(meta, [Pop(tags=2)], {}) + with pytest.raises(QueryError, match="operator"): + compile_update(meta, ["nope"], {}) + + +def test_immutable_and_required_fields_are_protected(m): + import typing as t + + meta = meta_of(m.Article) + with pytest.raises(QueryError, match="immutable"): + compile_update(meta, [], {"slug": "x"}) + with pytest.raises(QueryError, match="immutable"): + compile_update(meta, [Unset("slug")], {}) + assert compile_update(meta, [SetOnInsert(slug="x")], {}) == {"$setOnInsert": {"slug": "x"}} + + @m.define + class Strict(m.Spec): + name: str + + with pytest.raises(QueryError, match="required"): + compile_update(meta_of(Strict), [Unset("name")], {}) + _ = t + + +async def test_update_by_id_returns_tracked_document(m, registry): + art = await m.Article(title="a", tags=["x"]).insert() + updated = await m.Article.update(art.id, Inc(views=3), Push(tags="y"), title="b") + assert (updated.title, updated.views, updated.tags, updated.rev) == ("b", 3, ["x", "y"], 1) + assert updated.updated_at >= art.updated_at + assert not updated.is_new and updated.changes() == {} + before = await m.Article.update(art.id, returning="before", views=10) + assert before.views == 3 + + +async def test_update_by_filter_and_not_found(m, registry): + await m.User(name="ann", age=1).insert() + assert (await m.User.update(Q.name == "ann", age=2)).age == 2 + with pytest.raises(DocumentNotFound): + await m.User.update(Q.name == "nobody", age=2) + with pytest.raises(DocumentNotFound): + await m.User.update(ObjectId(), age=2) + with pytest.raises(QueryError): + await m.User.update(Q.name == "ann") + + +async def test_immutable_field_cannot_be_saved(m, registry): + art = await m.Article(title="a", slug="first").insert() + art.slug = "second" + with pytest.raises(QueryError, match="immutable"): + await art.save() + assert (await m.Article.get(art.id)).slug == "first" + + +async def test_upsert_single_round_trip(m, registry): + created = await m.User.upsert(Q.email == "a@b.c", SetOnInsert(name="new"), Inc(age=1)) + assert created.name == "new" and created.age == 1 and created.email == "a@b.c" + again = await m.User.upsert(Q.email == "a@b.c", SetOnInsert(name="ignored"), Inc(age=1)) + assert again.id == created.id and again.name == "new" and again.age == 2 + assert await m.User.count() == 1 + art = await m.Article.upsert(Q.title == "up", Inc(views=1)) + assert art.created_at is not None and art.updated_at is not None and art.rev == 1 + cat = await m.Cat.upsert(Q.name == "tom", lives=3) + assert (await m.Cat.collection().find_one({"_id": cat.id}))["kind"] == "Cat" + + +async def test_filter_based_update_and_delete(m, registry): + await m.User.insert_many([m.User(name=n, age=i) for i, n in enumerate("abcd")]) + assert await m.User.find(Q.age >= 2).update(Inc(age=10)) == 2 + assert sorted(u.age for u in await m.User.find()) == [0, 1, 12, 13] + assert await m.User.find(Q.age > 5).delete() == 2 + assert await m.User.count() == 2 + assert await m.User.find().update(name="same") == 2 + + +async def test_if_rev_on_atomic_update(m, registry): + art = await m.Article(title="a").insert() + ok = await m.Article.update(art.id, if_rev=0, title="b") + assert ok.rev == 1 + with pytest.raises(StaleDocumentError): + await m.Article.update(art.id, if_rev=0, title="c") + with pytest.raises(DocumentNotFound): + await m.Article.update(ObjectId(), if_rev=0, title="c") From 81a6fbeb3e92fcf75bb484ddbe96dce6fde36a14 Mon Sep 17 00:00:00 2001 From: jaykv Date: Mon, 12 Oct 2026 23:53:30 -0400 Subject: [PATCH 03/31] style: format the code block in the package README Co-Authored-By: Claude Opus 5.5 --- packages/mongospecs/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/mongospecs/README.md b/packages/mongospecs/README.md index 4a5e7da..c0e696c 100644 --- a/packages/mongospecs/README.md +++ b/packages/mongospecs/README.md @@ -24,7 +24,7 @@ from datetime import datetime from typing import Annotated from mongospecs import CreatedAt, Inc, Index, Q, Ref, Registry, Revision, SpecConfig, UpdatedAt -from mongospecs.msgspec import Spec # or mongospecs.pydantic / mongospecs.attrs +from mongospecs.msgspec import Spec # or mongospecs.pydantic / mongospecs.attrs class User(Spec): @@ -40,7 +40,7 @@ class Article(Spec): author: Ref[User] | None = None created_at: Annotated[datetime | None, CreatedAt] = None updated_at: Annotated[datetime | None, UpdatedAt] = None - rev: Annotated[int, Revision] = 0 # optimistic locking + rev: Annotated[int, Revision] = 0 # optimistic locking async def main(): From ea29457413edb8606307249814575264dd4c2e55 Mon Sep 17 00:00:00 2001 From: jaykv Date: Mon, 12 Oct 2026 23:53:12 -0400 Subject: [PATCH 04/31] feat(web): mongospecs-web 0.1.0, framework-neutral REST resources A Resource exposes a model as REST operations described as pure data; plugs translate requests and responses for a framework and share everything else. - Resource, Operation, Ctx, Request, Response with no framework imports; custom operations, before/after hooks - field Policy drives Create, Patch, Read and Filter views; input is filtered by policy and validated against the real model, and JSON Schema per view is a transform of the model's own schema - allowlisted query-string filters with a bounded operator set, sort, cursor and offset pagination, includes - operation guards plus a row scope that is ANDed into every query - ETag / If-Match from the revision field, RFC 9457 problem responses, OpenAPI 3.1 generation - plugs: pure ASGI, Starlette, FastAPI (schemas merged into the host app's OpenAPI for any model backend), Litestar, Quart - mongospecs_web.testing: an in-memory backend and a contract suite whose transcript every plug must reproduce Core changes this needed: Policy(private=True) is no longer writable unless insert/update are set explicitly; lax casting for values that arrive as text; msgspec missing-field errors name the field. 49 web tests; every plug x model backend matches the reference transcript. 464 tests in the workspace. Co-Authored-By: Claude Opus 5.5 --- .../src/mongospecs_web/__init__.py | 78 ++- .../mongospecs-web/src/mongospecs_web/api.py | 149 +++++ .../src/mongospecs_web/backend.py | 141 +++++ .../src/mongospecs_web/errors.py | 122 ++++ .../src/mongospecs_web/filters.py | 171 ++++++ .../src/mongospecs_web/openapi.py | 149 +++++ .../src/mongospecs_web/plugs/__init__.py | 5 + .../src/mongospecs_web/plugs/asgi.py | 88 +++ .../src/mongospecs_web/plugs/fastapi.py | 76 +++ .../src/mongospecs_web/plugs/litestar.py | 89 +++ .../src/mongospecs_web/plugs/quart.py | 63 ++ .../src/mongospecs_web/plugs/starlette.py | 74 +++ .../src/mongospecs_web/policy.py | 216 +++++++ .../src/mongospecs_web/resource.py | 544 +++++++++++++++++ .../src/mongospecs_web/testing.py | 551 ++++++++++++++++++ packages/mongospecs-web/tests/conftest.py | 31 + .../mongospecs-web/tests/test_contract.py | 228 ++++++++ .../tests/test_mongo_resource.py | 307 ++++++++++ .../tests/test_openapi_views.py | 157 +++++ packages/mongospecs/src/mongospecs/markers.py | 16 +- packages/mongospecs/src/mongospecs/meta.py | 16 +- packages/mongospecs/src/mongospecs/msgspec.py | 3 +- .../mongospecs/tests/test_registry_errors.py | 3 +- pyproject.toml | 1 + uv.lock | 310 ++++++++++ 25 files changed, 3577 insertions(+), 11 deletions(-) create mode 100644 packages/mongospecs-web/src/mongospecs_web/api.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/backend.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/errors.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/filters.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/openapi.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/plugs/__init__.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/plugs/litestar.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/plugs/quart.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/plugs/starlette.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/policy.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/resource.py create mode 100644 packages/mongospecs-web/src/mongospecs_web/testing.py create mode 100644 packages/mongospecs-web/tests/conftest.py create mode 100644 packages/mongospecs-web/tests/test_contract.py create mode 100644 packages/mongospecs-web/tests/test_mongo_resource.py create mode 100644 packages/mongospecs-web/tests/test_openapi_views.py diff --git a/packages/mongospecs-web/src/mongospecs_web/__init__.py b/packages/mongospecs-web/src/mongospecs_web/__init__.py index f9d4bc7..ac54db2 100644 --- a/packages/mongospecs-web/src/mongospecs_web/__init__.py +++ b/packages/mongospecs-web/src/mongospecs_web/__init__.py @@ -1 +1,77 @@ -"""mongospecs-web: framework-neutral REST resources for mongospecs models.""" +"""Framework-neutral REST resources for mongospecs models. + +:: + + from mongospecs_web import Api, Resource, List, Get, Create, Patch, Delete + + articles = Resource(Article, ops=[List, Get, Create, Patch, Delete]) + app = Api(articles, prefix="/api").asgi(registry=registry) + +Plugs for Starlette, FastAPI, Litestar and Quart live in ``mongospecs_web.plugs``. +""" + +from mongospecs_web.api import Api, Route +from mongospecs_web.backend import Filter, ListQuery, MongoBackend, ResourceBackend +from mongospecs_web.errors import ( + BadRequest, + Conflict, + Forbidden, + HTTPError, + MethodNotAllowed, + NotFound, + PayloadTooLarge, + PreconditionFailed, + Unauthorized, + Unprocessable, + UnsupportedMediaType, +) +from mongospecs_web.filters import Cursor, Offset +from mongospecs_web.policy import View, view_of +from mongospecs_web.resource import ( + Create, + Ctx, + Delete, + Get, + List, + Operation, + Patch, + Request, + Resource, + Response, +) + +__version__ = "0.1.0" + +__all__ = [ + "Api", + "BadRequest", + "Conflict", + "Create", + "Ctx", + "Cursor", + "Delete", + "Filter", + "Forbidden", + "Get", + "HTTPError", + "List", + "ListQuery", + "MethodNotAllowed", + "MongoBackend", + "NotFound", + "Offset", + "Operation", + "Patch", + "PayloadTooLarge", + "PreconditionFailed", + "Request", + "Resource", + "ResourceBackend", + "Response", + "Route", + "Unauthorized", + "Unprocessable", + "UnsupportedMediaType", + "View", + "view_of", +] diff --git a/packages/mongospecs-web/src/mongospecs_web/api.py b/packages/mongospecs-web/src/mongospecs_web/api.py new file mode 100644 index 0000000..342789e --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/api.py @@ -0,0 +1,149 @@ +"""An ``Api`` groups resources under a prefix and is what every plug mounts.""" + +from __future__ import annotations + +import inspect +import re +import typing as t +from dataclasses import dataclass + +from mongospecs_web.errors import HTTPError, MethodNotAllowed, NotFound, PayloadTooLarge +from mongospecs_web.resource import Operation, Request, Resource, Response + +__all__ = ["Api", "Authenticate", "Route"] + +Authenticate = t.Callable[[Request], t.Any] +"""``authenticate(request) -> principal``; may be async. Return ``None`` for anonymous, +or raise :class:`~mongospecs_web.errors.Unauthorized`.""" + +_PARAM = re.compile(r"\{([A-Za-z_][A-Za-z0-9_]*)\}") + + +@dataclass(frozen=True, slots=True) +class Route: + method: str + path: str + """Full path template with ``{name}`` parameters, including the prefix.""" + resource: Resource[t.Any] + operation: Operation + pattern: re.Pattern[str] + + @property + def name(self) -> str: + return f"{self.resource.name}.{self.operation.name}" + + +class Api: + """A set of resources mounted under one prefix. + + :: + + api = Api(articles, users, prefix="/api", title="Blog", authenticate=current_user) + app = api.asgi() # pure ASGI + # or: mongospecs_web.plugs.fastapi.mount(fastapi_app, api) + """ + + def __init__( + self, + *resources: Resource[t.Any], + prefix: str = "", + title: str = "API", + version: str = "1.0.0", + authenticate: Authenticate | None = None, + max_body: int = 1_048_576, + ) -> None: + self.resources: tuple[Resource[t.Any], ...] = resources + self.prefix = "/" + prefix.strip("/") if prefix.strip("/") else "" + self.title = title + self.version = version + self.authenticate = authenticate + self.max_body = max_body + names = [r.name for r in resources] + if len(set(names)) != len(names): + raise ValueError(f"resource names must be unique, got {names}") + + @property + def routes(self) -> list[Route]: + out: list[Route] = [] + for resource in self.resources: + for op in resource.operations: + path = f"{self.prefix}/{resource.name}{op.path}" + pattern = re.compile( + "^" + + _PARAM.sub(r"(?P<\1>[^/]+)", re.escape(path).replace(r"\{", "{").replace(r"\}", "}")) + + "/?$" + ) + out.append(Route(op.method, path, resource, op, pattern)) + return out + + @property + def openapi_path(self) -> str: + return f"{self.prefix}/openapi.json" + + def match(self, method: str, path: str) -> tuple[Route, dict[str, str]]: + """Find the route for a request, or raise 404 / 405.""" + allowed: list[str] = [] + for route in self._routes(): + found = route.pattern.match(path) + if found is None: + continue + if route.method == method.upper(): + return route, found.groupdict() + allowed.append(route.method) + if allowed: + raise MethodNotAllowed(headers={"allow": ", ".join(sorted(set(allowed)))}) + raise NotFound("No such route.") + + def _routes(self) -> list[Route]: + cached: list[Route] | None = self.__dict__.get("_route_cache") + if cached is None or self.__dict__.get("_route_count") != sum( + len(r.operations) for r in self.resources + ): + cached = self.routes + # Literal segments win over parameters: /{id}/publish before /{id}, /search before /{id}. + cached.sort(key=lambda r: (-r.path.count("/"), r.path.count("{"))) + self.__dict__["_route_cache"] = cached + self.__dict__["_route_count"] = sum(len(r.operations) for r in self.resources) + return cached + + async def run(self, route: Route, request: Request) -> Response: + """Authenticate and dispatch. Never raises.""" + try: + if len(request.body) > self.max_body: + raise PayloadTooLarge(f"The request body may be at most {self.max_body} bytes.") + if self.authenticate is not None: + principal = self.authenticate(request) + request.principal = await principal if inspect.isawaitable(principal) else principal + except Exception as exc: + return Response.error(exc) + return await route.resource.dispatch(route.operation, request) + + async def handle( + self, + method: str, + path: str, + *, + query: list[tuple[str, str]] | None = None, + headers: dict[str, str] | None = None, + body: bytes = b"", + raw: t.Any = None, + ) -> Response: + """Route and run a request given as plain values. Used by the ASGI plug and handy in tests.""" + try: + route, params = self.match(method, path) + except HTTPError as exc: + return Response.error(exc) + return await self.run( + route, Request(method.upper(), params, query or [], headers or {}, body, None, raw) + ) + + def openapi(self) -> dict[str, t.Any]: + from mongospecs_web.openapi import build_openapi + + return build_openapi(self) + + def asgi(self, *, registry: t.Any = None) -> t.Any: + """A pure ASGI application serving this API (and ``openapi.json``).""" + from mongospecs_web.plugs.asgi import AsgiApp + + return AsgiApp(self, registry=registry) diff --git a/packages/mongospecs-web/src/mongospecs_web/backend.py b/packages/mongospecs-web/src/mongospecs_web/backend.py new file mode 100644 index 0000000..360ac6c --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/backend.py @@ -0,0 +1,141 @@ +"""The narrow contract between a Resource and whatever stores its documents. + +``MongoBackend`` implements it with mongospecs. ``mongospecs_web.testing.FakeBackend`` +is an in-memory double used by the plug contract suite. +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +from mongospecs import DocumentNotFound, Page, Q, QueryError, meta_of +from mongospecs.expr import Condition, Expr, Field + +__all__ = ["OPERATORS", "Filter", "ListQuery", "MongoBackend", "ResourceBackend"] + +T = t.TypeVar("T") + +OPERATORS: dict[str, str] = { + "eq": "$eq", + "ne": "$ne", + "in": "$in", + "nin": "$nin", + "gt": "$gt", + "gte": "$gte", + "lt": "$lt", + "lte": "$lte", + "exists": "$exists", + "prefix": "$regex", +} + + +@dataclass(frozen=True, slots=True) +class Filter: + """One allowlisted condition from the client: field, bounded operator, already-cast value.""" + + field: str + op: str + value: t.Any + + def to_expr(self) -> Expr: + f = Field(self.field) + if self.op == "prefix": + return f.prefix(self.value) + return Condition(self.field, OPERATORS[self.op], self.value) + + +@dataclass(frozen=True, slots=True) +class ListQuery: + filters: tuple[Filter, ...] = () + sort: tuple[tuple[str, int], ...] = () + size: int = 20 + after: str | None = None + number: int | None = None + total: bool = False + include: tuple[str, ...] = field(default=()) + + +class ResourceBackend(t.Protocol[T]): + """What a Resource needs from storage. ``scope`` is a filter ANDed into the operation, or ``None``.""" + + model: type[T] + + async def get(self, id: t.Any, *, scope: t.Any, include: t.Sequence[str] = ()) -> T | None: ... + + async def list(self, query: ListQuery, *, scope: t.Any) -> Page[T]: ... + + async def create(self, data: dict[str, t.Any]) -> T: ... + + async def patch( + self, id: t.Any, data: dict[str, t.Any], *, scope: t.Any, if_rev: int | None = None + ) -> T: ... + + async def delete(self, id: t.Any, *, scope: t.Any) -> None: ... + + +class MongoBackend(t.Generic[T]): + """``ResourceBackend`` over a mongospecs model (or a manager from ``Model.using(...)``).""" + + def __init__(self, model: type[T], manager: t.Any = None) -> None: + self.model = model + self._manager = manager + self.meta = meta_of(model) + + @property + def manager(self) -> t.Any: + return self._manager if self._manager is not None else t.cast(t.Any, self.model)._manager() + + def _by_id(self, id: t.Any, scope: t.Any) -> t.Any: + try: + query = self.manager.find(Q.id == id) + query._user_filter() # cast the id now: a malformed id is "not found", not a server error + except QueryError: + return None + return query.filter(scope) if scope is not None else query + + async def get(self, id: t.Any, *, scope: t.Any, include: t.Sequence[str] = ()) -> T | None: + query = self._by_id(id, scope) + if query is None: + return None + for path in include: + query = query.include(path) + return t.cast("T | None", await query.first()) + + async def list(self, query: ListQuery, *, scope: t.Any) -> Page[T]: + q = self.manager.find(*(f.to_expr() for f in query.filters)) + if scope is not None: + q = q.filter(scope) + if query.sort: + q = q.sort(*query.sort) + for path in query.include: + q = q.include(path) + return t.cast( + "Page[T]", await q.page(query.size, after=query.after, number=query.number, total=query.total) + ) + + async def create(self, data: dict[str, t.Any]) -> T: + stored = {self.meta.fields[k].stored: v for k, v in data.items()} + doc = self.meta.decode(stored, trusted=False) + return t.cast(T, await self.manager.insert(doc)) + + async def patch(self, id: t.Any, data: dict[str, t.Any], *, scope: t.Any, if_rev: int | None = None) -> T: + query = self._by_id(id, scope) + if query is None: + raise DocumentNotFound(f"{self.model.__name__} {id!r} does not exist") + if not data: + doc = await query.first() + if doc is None: + raise DocumentNotFound(f"{self.model.__name__} {id!r} does not exist") + return t.cast(T, doc) + from mongospecs import trusted + + target = trusted(query._user_filter()) + return t.cast(T, await self.manager.update(target, if_rev=if_rev, **data)) + + async def delete(self, id: t.Any, *, scope: t.Any) -> None: + query = self._by_id(id, scope) + doc = await query.first() if query is not None else None + if doc is None: + raise DocumentNotFound(f"{self.model.__name__} {id!r} does not exist") + await self.manager.delete_document(doc) diff --git a/packages/mongospecs-web/src/mongospecs_web/errors.py b/packages/mongospecs-web/src/mongospecs_web/errors.py new file mode 100644 index 0000000..0724f78 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/errors.py @@ -0,0 +1,122 @@ +"""HTTP errors and their RFC 9457 (``application/problem+json``) representation.""" + +from __future__ import annotations + +import typing as t + +from mongospecs import errors as me + +__all__ = [ + "BadRequest", + "Conflict", + "Forbidden", + "HTTPError", + "MethodNotAllowed", + "NotFound", + "PayloadTooLarge", + "PreconditionFailed", + "Unauthorized", + "Unprocessable", + "UnsupportedMediaType", + "problem", +] + +PROBLEM_JSON = "application/problem+json" + + +class HTTPError(Exception): + """An error with an HTTP status. Raise it from guards, hooks and custom operations.""" + + status: t.ClassVar[int] = 500 + title: t.ClassVar[str] = "Internal Server Error" + + def __init__( + self, + detail: str | None = None, + *, + errors: list[dict[str, t.Any]] | None = None, + headers: dict[str, str] | None = None, + status: int | None = None, + title: str | None = None, + ) -> None: + super().__init__(detail or self.title) + self.detail = detail + self.errors = errors + self.headers = headers or {} + if status is not None: + self.status = status # type: ignore[misc] + if title is not None: + self.title = title # type: ignore[misc] + + def body(self) -> dict[str, t.Any]: + out: dict[str, t.Any] = {"type": "about:blank", "title": self.title, "status": self.status} + if self.detail: + out["detail"] = self.detail + if self.errors: + out["errors"] = self.errors + return out + + +class BadRequest(HTTPError): + status, title = 400, "Bad Request" + + +class Unauthorized(HTTPError): + status, title = 401, "Unauthorized" + + +class Forbidden(HTTPError): + status, title = 403, "Forbidden" + + +class NotFound(HTTPError): + status, title = 404, "Not Found" + + +class MethodNotAllowed(HTTPError): + status, title = 405, "Method Not Allowed" + + +class Conflict(HTTPError): + status, title = 409, "Conflict" + + +class PreconditionFailed(HTTPError): + status, title = 412, "Precondition Failed" + + +class PayloadTooLarge(HTTPError): + status, title = 413, "Content Too Large" + + +class UnsupportedMediaType(HTTPError): + status, title = 415, "Unsupported Media Type" + + +class Unprocessable(HTTPError): + status, title = 422, "Unprocessable Content" + + +def problem(exc: BaseException) -> HTTPError: + """Map any exception to an :class:`HTTPError`. Unknown errors become a 500 with no detail.""" + if isinstance(exc, HTTPError): + return exc + if isinstance(exc, me.ValidationError): + return Unprocessable("The request body failed validation.", errors=exc.errors) + if isinstance(exc, me.StaleDocumentError): + return PreconditionFailed("The resource was changed since the revision you supplied.") + if isinstance(exc, me.DocumentNotFound): + return NotFound("The resource does not exist.") + if isinstance(exc, me.DuplicateKeyError): + fields = sorted(exc.fields) + return Conflict( + "A resource with the same unique value already exists.", + errors=[{"field": f, "message": "must be unique"} for f in fields] or None, + ) + if isinstance(exc, me.QueryError): + return BadRequest(str(exc)) + if isinstance(exc, me.OperationTimeout): + return HTTPError("The database did not answer in time.", status=504, title="Gateway Timeout") + if isinstance(exc, me.ConnectionFailed): + return HTTPError("The database is unavailable.", status=503, title="Service Unavailable") + return HTTPError() diff --git a/packages/mongospecs-web/src/mongospecs_web/filters.py b/packages/mongospecs-web/src/mongospecs_web/filters.py new file mode 100644 index 0000000..2a61322 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/filters.py @@ -0,0 +1,171 @@ +"""Query-string parsing for list operations. + +Only what the model's ``Policy`` allows gets through: a filter must name a +filterable field and one of the operators that field declares; a sort key must +be sortable. Everything else is a 400, never a pass-through to MongoDB. + +Syntax:: + + ?title=hello equality + ?views[gte]=10&views[lt]=50 operators in brackets + ?tag[in]=a,b lists are comma-separated (or repeat the parameter) + ?email[exists]=false + ?sort=-views,title + ?limit=20&after= cursor pagination + ?limit=20&page=3 offset pagination + ?total=true also count all matches + ?include=author,comments load allowlisted relations +""" + +from __future__ import annotations + +import re +import typing as t +from dataclasses import dataclass + +from mongospecs import QueryError +from mongospecs_web.backend import Filter, ListQuery +from mongospecs_web.errors import BadRequest +from mongospecs_web.policy import View + +__all__ = ["Cursor", "Offset", "Pagination", "parse_list_query"] + +RESERVED = frozenset({"sort", "limit", "after", "page", "total", "include"}) +_PARAM = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)(?:\[([a-z]+)\])?$") +_TRUE = frozenset({"true", "1", "yes"}) +_FALSE = frozenset({"false", "0", "no"}) + + +@dataclass(frozen=True, slots=True) +class Pagination: + default: int = 20 + max: int = 100 + total: bool = True + """Whether clients may ask for a total count with ``?total=true``.""" + kind: t.ClassVar[str] = "cursor" + + +class Cursor(Pagination): + """Keyset pagination: ``?limit=&after=``. Stable under concurrent writes.""" + + kind = "cursor" + + +class Offset(Pagination): + """Page-number pagination: ``?limit=&page=``.""" + + kind = "offset" + + +def _bool(value: str, name: str) -> bool: + lowered = value.lower() + if lowered in _TRUE: + return True + if lowered in _FALSE: + return False + raise BadRequest(f"{name} must be true or false") + + +def _int(value: str, name: str, minimum: int = 1) -> int: + try: + number = int(value) + except ValueError: + raise BadRequest(f"{name} must be an integer") from None + if number < minimum: + raise BadRequest(f"{name} must be at least {minimum}") + return number + + +def parse_list_query( + params: t.Iterable[tuple[str, str]], + view: View, + pagination: Pagination, + includes: t.Collection[str] = (), +) -> ListQuery: + """Turn raw query parameters into a validated :class:`ListQuery`.""" + grouped: dict[str, list[str]] = {} + for key, value in params: + grouped.setdefault(key, []).append(value) + + filters: list[Filter] = [] + for key, values in grouped.items(): + if key in RESERVED: + continue + match = _PARAM.match(key) + if match is None: + raise BadRequest(f"unrecognised query parameter {key!r}") + name, op = match.group(1), match.group(2) or "eq" + allowed = view.filterable.get(name) + if allowed is None: + raise BadRequest(f"{name!r} cannot be used as a filter") + if op not in allowed: + raise BadRequest(f"operator {op!r} is not allowed on {name!r}; allowed: {', '.join(allowed)}") + filters.append(Filter(name, op, _value(view, name, op, values))) + + sort: list[tuple[str, int]] = [] + for item in ",".join(grouped.get("sort", [])).split(","): + item = item.strip() + if not item: + continue + direction = -1 if item.startswith("-") else 1 + name = item.lstrip("+-") + if name not in view.sortable: + raise BadRequest(f"{name!r} cannot be used for sorting") + sort.append((name, direction)) + + size = pagination.default + if "limit" in grouped: + size = min(_int(grouped["limit"][-1], "limit"), pagination.max) + after = grouped.get("after", [None])[-1] + number: int | None = None + if pagination.kind == "offset": + if after is not None: + raise BadRequest("this resource uses page numbers; use page= instead of after=") + number = _int(grouped["page"][-1], "page") if "page" in grouped else 1 + elif "page" in grouped: + raise BadRequest("this resource uses cursors; use after= instead of page=") + total = _bool(grouped["total"][-1], "total") if "total" in grouped else False + if total and not pagination.total: + raise BadRequest("this resource does not report totals") + + include: list[str] = [] + for item in ",".join(grouped.get("include", [])).split(","): + item = item.strip() + if not item: + continue + if item not in includes: + raise BadRequest(f"{item!r} cannot be included") + include.append(item) + + return ListQuery(tuple(filters), tuple(sort), size, after, number, total, tuple(include)) + + +def _value(view: View, name: str, op: str, values: list[str]) -> t.Any: + meta = view.meta + if op == "exists": + return _bool(values[-1], f"{name}[exists]") + if op == "prefix": + return values[-1] + try: + if op in ("in", "nin"): + items = [v for raw in values for v in raw.split(",") if v != ""] + return [meta.cast(name, v, lax=True) for v in items] + return meta.cast(name, values[-1], lax=True) + except QueryError as exc: + raise BadRequest(f"invalid value for {name!r}: {exc}") from None + + +def parse_includes(params: t.Iterable[tuple[str, str]], includes: t.Collection[str]) -> tuple[str, ...]: + """The ``include`` parameter of a single-resource read.""" + out: list[str] = [] + for key, value in params: + if key != "include": + continue + for item in value.split(","): + item = item.strip() + if not item: + continue + if item not in includes: + raise BadRequest(f"{item!r} cannot be included") + out.append(item) + return tuple(out) diff --git a/packages/mongospecs-web/src/mongospecs_web/openapi.py b/packages/mongospecs-web/src/mongospecs_web/openapi.py new file mode 100644 index 0000000..6f11d78 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/openapi.py @@ -0,0 +1,149 @@ +"""OpenAPI 3.1 generation from operation descriptors and transformed model schemas.""" + +from __future__ import annotations + +import copy +import typing as t + +from mongospecs_web.resource import Operation, Resource + +if t.TYPE_CHECKING: + from mongospecs_web.api import Api + +__all__ = ["build_openapi", "operation_object"] + +PROBLEM = { + "type": "object", + "properties": { + "type": {"type": "string"}, + "title": {"type": "string"}, + "status": {"type": "integer"}, + "detail": {"type": "string"}, + "errors": { + "type": "array", + "items": { + "type": "object", + "properties": {"field": {"type": ["string", "null"]}, "message": {"type": "string"}}, + }, + }, + }, + "required": ["type", "title", "status"], +} + +_ERRORS: dict[str, tuple[str, ...]] = { + "list": ("400",), + "get": ("404",), + "create": ("409", "422"), + "patch": ("404", "409", "412", "422"), + "delete": ("404",), + "custom": ("404", "422"), +} +_ERROR_TEXT = { + "400": "Bad request", + "401": "Unauthorized", + "403": "Forbidden", + "404": "Not found", + "409": "Conflict", + "412": "Precondition failed", + "422": "Validation failed", +} + + +def _rewrite_refs(node: t.Any, prefix: str) -> t.Any: + """Point ``#/$defs/X`` references at ``#/components/schemas/X``.""" + if isinstance(node, dict): + out = {} + for key, value in node.items(): + if key == "$ref" and isinstance(value, str) and value.startswith("#/$defs/"): + out[key] = f"#/components/schemas/{prefix}{value.rsplit('/', 1)[1]}" + else: + out[key] = _rewrite_refs(value, prefix) + return out + if isinstance(node, list): + return [_rewrite_refs(v, prefix) for v in node] + return node + + +def component_schemas(resource: Resource[t.Any]) -> dict[str, t.Any]: + """Read/Create/Patch schemas of a resource, plus the nested definitions they refer to.""" + schemas: dict[str, t.Any] = {} + prefix = resource.model.__name__ + "_" + kinds: tuple[t.Literal["read", "create", "patch"], ...] = ("read", "create", "patch") + for kind in kinds: + schema = copy.deepcopy(resource.view.schema(kind)) + for name, definition in schema.pop("$defs", {}).items(): + schemas[prefix + name] = _rewrite_refs(definition, prefix) + schemas[resource.schema_name(kind)] = _rewrite_refs(schema, prefix) + return schemas + + +def operation_object(resource: Resource[t.Any], op: Operation, *, secured: bool) -> dict[str, t.Any]: + """The OpenAPI Operation Object for one operation.""" + out: dict[str, t.Any] = { + "operationId": f"{resource.name}_{op.name}", + "summary": op.summary or op.name, + "tags": list(resource.tags), + } + parameters = [ + {"name": name, "in": "path", "required": True, "schema": {"type": "string"}} + for name in op.path_parameters + ] + parameters.extend(op.query_parameters) + if op.kind == "patch" and resource.view.revision: + parameters.append( + { + "name": "If-Match", + "in": "header", + "required": False, + "schema": {"type": "string"}, + "description": "ETag of the version being changed; the update fails with 412 if it is stale.", + } + ) + if parameters: + out["parameters"] = parameters + if op.request_schema is not None: + out["requestBody"] = { + "required": True, + "content": {"application/json": {"schema": op.request_schema}}, + } + responses: dict[str, t.Any] = {} + if op.status == 204 or (op.response_schema is None and op.kind == "delete"): + responses["204"] = {"description": "No content"} + else: + content = {"application/json": {"schema": op.response_schema or {}}} + responses[str(op.status)] = {"description": "Success", "content": content} + codes = [*_ERRORS.get(op.kind, ()), *(("401", "403") if secured else ())] + for code in sorted(set(codes)): + responses[code] = {"$ref": f"#/components/responses/Problem{code}"} + out["responses"] = responses + return out + + +def build_openapi(api: Api) -> dict[str, t.Any]: + schemas: dict[str, t.Any] = {"Problem": PROBLEM} + paths: dict[str, dict[str, t.Any]] = {} + for resource in api.resources: + schemas.update(component_schemas(resource)) + for route in api.routes: + resource, op = route.resource, route.operation + secured = api.authenticate is not None + paths.setdefault(route.path, {})[route.method.lower()] = operation_object( + resource, op, secured=secured + ) + return { + "openapi": "3.1.0", + "info": {"title": api.title, "version": api.version}, + "paths": paths, + "components": { + "schemas": schemas, + "responses": { + f"Problem{code}": { + "description": text, + "content": { + "application/problem+json": {"schema": {"$ref": "#/components/schemas/Problem"}} + }, + } + for code, text in _ERROR_TEXT.items() + }, + }, + } diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/__init__.py b/packages/mongospecs-web/src/mongospecs_web/plugs/__init__.py new file mode 100644 index 0000000..fb6777b --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/__init__.py @@ -0,0 +1,5 @@ +"""Plugs mount an :class:`~mongospecs_web.api.Api` on a web framework. + +Each plug only translates requests and responses; routing targets, validation, +authorization and serialisation are the same code everywhere. +""" diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py b/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py new file mode 100644 index 0000000..a929f75 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py @@ -0,0 +1,88 @@ +"""Pure ASGI plug: no framework required. The reference the other plugs are checked against.""" + +from __future__ import annotations + +import json +import typing as t +from urllib.parse import parse_qsl + +from mongospecs_web.api import Api +from mongospecs_web.resource import Response + +__all__ = ["AsgiApp"] + +Scope = dict[str, t.Any] +Receive = t.Callable[[], t.Awaitable[dict[str, t.Any]]] +Send = t.Callable[[dict[str, t.Any]], t.Awaitable[None]] + + +class AsgiApp: + """An ASGI application for one :class:`Api`. + + With ``registry=`` the application opens the registry on ASGI lifespan + startup and closes it on shutdown. + """ + + def __init__(self, api: Api, *, registry: t.Any = None) -> None: + self.api = api + self.registry = registry + self._openapi: bytes | None = None + + async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: + if scope["type"] == "lifespan": + await self._lifespan(receive, send) + elif scope["type"] == "http": + await self._http(scope, receive, send) + else: + raise RuntimeError(f"unsupported ASGI scope type {scope['type']!r}") + + async def _lifespan(self, receive: Receive, send: Send) -> None: + while True: + message = await receive() + if message["type"] == "lifespan.startup": + try: + if self.registry is not None: + await self.registry.open() + except Exception as exc: + await send({"type": "lifespan.startup.failed", "message": str(exc)}) + return + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + if self.registry is not None: + await self.registry.close() + await send({"type": "lifespan.shutdown.complete"}) + return + + async def _http(self, scope: Scope, receive: Receive, send: Send) -> None: + method: str = scope["method"] + root = scope.get("root_path", "") + path: str = scope["path"] + if root and path.startswith(root): + path = path[len(root) :] or "/" + if method == "GET" and path == self.api.openapi_path: + if self._openapi is None: + self._openapi = json.dumps(self.api.openapi(), separators=(",", ":")).encode() + await _send(send, Response(200, self._openapi, {})) + return + body = b"" + limit = self.api.max_body + while True: + message = await receive() + if message["type"] == "http.disconnect": + return + body += message.get("body", b"") + if len(body) > limit or not message.get("more_body"): + break + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + query = parse_qsl(scope.get("query_string", b"").decode("latin-1"), keep_blank_values=True) + response = await self.api.handle(method, path, query=query, headers=headers, body=body, raw=scope) + await _send(send, response) + + +async def _send(send: Send, response: Response) -> None: + headers = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in response.headers.items()] + if response.body or response.status not in (204, 304): + headers.append((b"content-type", response.media_type.encode("latin-1"))) + headers.append((b"content-length", str(len(response.body)).encode("latin-1"))) + await send({"type": "http.response.start", "status": response.status, "headers": headers}) + await send({"type": "http.response.body", "body": response.body}) diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py b/packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py new file mode 100644 index 0000000..917b1ef --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py @@ -0,0 +1,76 @@ +"""FastAPI plug. + +FastAPI derives request and response schemas from pydantic models only. Here the +operations are documented through ``openapi_extra`` and the component schemas +are merged into the application's OpenAPI document, so models on any backend +(msgspec, attrs, pydantic) appear in ``/docs`` the same way. Responses are sent +as already-serialised bytes. +""" + +from __future__ import annotations + +import typing as t + +from fastapi import APIRouter, FastAPI +from starlette.requests import Request as StarletteRequest +from starlette.responses import Response as StarletteResponse + +from mongospecs_web.api import Api, Route +from mongospecs_web.openapi import build_openapi, operation_object +from mongospecs_web.plugs.starlette import lifespan, to_request, to_response + +__all__ = ["lifespan", "mount", "router"] + + +def _endpoint(api: Api, route: Route) -> t.Callable[..., t.Awaitable[StarletteResponse]]: + async def endpoint(request: StarletteRequest) -> StarletteResponse: + return to_response(await api.run(route, await to_request(request))) + + return endpoint + + +def router(api: Api) -> APIRouter: + """An ``APIRouter`` with one route per operation.""" + out = APIRouter() + secured = api.authenticate is not None + for route in api._routes(): + extra = operation_object(route.resource, route.operation, secured=secured) + out.add_api_route( + route.path, + _endpoint(api, route), + methods=[route.method], + name=route.name, + operation_id=extra.pop("operationId"), + summary=extra.pop("summary"), + tags=list(extra.pop("tags")), + status_code=route.operation.status, + response_class=StarletteResponse, + openapi_extra=extra, + ) + return out + + +def mount(app: FastAPI, api: Api) -> None: + """Add the API to a FastAPI application and merge its schemas into ``app.openapi()``.""" + app.include_router(router(api)) + original = app.openapi + + def openapi() -> dict[str, t.Any]: + if app.openapi_schema is not None: + return app.openapi_schema + schema = original() + ours = build_openapi(api)["components"] + components = schema.setdefault("components", {}) + components.setdefault("schemas", {}).update(ours["schemas"]) + components.setdefault("responses", {}).update(ours["responses"]) + for route in api._routes(): + operation = schema["paths"][route.path][route.method.lower()] + documented = operation_object( + route.resource, route.operation, secured=api.authenticate is not None + ) + # FastAPI adds its own default responses; ours are the truth. + operation["responses"] = documented["responses"] + app.openapi_schema = schema + return schema + + app.openapi = openapi # type: ignore[method-assign] diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/litestar.py b/packages/mongospecs-web/src/mongospecs_web/plugs/litestar.py new file mode 100644 index 0000000..6bb3403 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/litestar.py @@ -0,0 +1,89 @@ +"""Litestar plug. + +Handlers are excluded from Litestar's own schema generation (it derives schemas +from handler signatures, which here are generic); the API's OpenAPI document is +served at ``/openapi.json`` instead. +""" + +from __future__ import annotations + +import contextlib +import typing as t + +from litestar import Request as LitestarRequest +from litestar import Response as LitestarResponse +from litestar import get, route +from litestar.config.app import AppConfig +from litestar.plugins import InitPluginProtocol + +from mongospecs_web.api import Api, Route +from mongospecs_web.resource import Request, Response + +__all__ = ["MongospecsPlugin", "handlers"] + + +def _to_response(response: Response) -> LitestarResponse[bytes]: + return LitestarResponse( + content=response.body, + status_code=response.status, + headers=dict(response.headers), + media_type=response.media_type, + ) + + +def _handler(api: Api, target: Route) -> t.Any: + path = target.path + for name in target.operation.path_parameters: + path = path.replace("{" + name + "}", "{" + name + ":str}") + + method = t.cast("t.Any", target.method) + + @route(path, http_method=[method], name=target.name, include_in_schema=False, status_code=200) + async def handler(request: LitestarRequest[t.Any, t.Any, t.Any]) -> LitestarResponse[bytes]: + neutral = Request( + request.method, + {k: str(v) for k, v in request.path_params.items()}, + list(request.query_params.multi_items()), + {k.lower(): v for k, v in request.headers.items()}, + await request.body(), + None, + request, + ) + return _to_response(await api.run(target, neutral)) + + return handler + + +def handlers(api: Api, *, openapi: bool = True) -> list[t.Any]: + """Litestar route handlers for every operation of the API.""" + out = [_handler(api, target) for target in api._routes()] + if openapi: + + @get(api.openapi_path, name="mongospecs_openapi", include_in_schema=False, sync_to_thread=False) + def spec() -> dict[str, t.Any]: + return api.openapi() + + out.append(spec) + return out + + +class MongospecsPlugin(InitPluginProtocol): + """``Litestar(plugins=[MongospecsPlugin(api, registry=registry)])``""" + + def __init__(self, api: Api, *, registry: t.Any = None, openapi: bool = True) -> None: + self.api = api + self.registry = registry + self.openapi = openapi + + def on_app_init(self, app_config: AppConfig) -> AppConfig: + app_config.route_handlers.extend(handlers(self.api, openapi=self.openapi)) + if self.registry is not None: + registry = self.registry + + @contextlib.asynccontextmanager + async def lifespan(app: t.Any) -> t.AsyncIterator[None]: + async with registry: + yield + + app_config.lifespan.append(lifespan) + return app_config diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/quart.py b/packages/mongospecs-web/src/mongospecs_web/plugs/quart.py new file mode 100644 index 0000000..2cd7548 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/quart.py @@ -0,0 +1,63 @@ +"""Quart plug. Quart validates nothing itself, so everything rests on the neutral core.""" + +from __future__ import annotations + +import typing as t + +from quart import Blueprint, Quart +from quart import Response as QuartResponse +from quart import request as quart_request + +from mongospecs_web.api import Api, Route +from mongospecs_web.resource import Request + +__all__ = ["blueprint", "init_app"] + + +def _view(api: Api, route: Route) -> t.Callable[..., t.Awaitable[QuartResponse]]: + async def view(**path_params: t.Any) -> QuartResponse: + neutral = Request( + quart_request.method, + {k: str(v) for k, v in path_params.items()}, + list(quart_request.args.items(multi=True)), + {k.lower(): v for k, v in quart_request.headers.items()}, + t.cast(bytes, await quart_request.get_data(as_text=False)), + None, + quart_request, + ) + response = await api.run(route, neutral) + out = QuartResponse(response.body, response.status, dict(response.headers)) + if response.body or response.status not in (204, 304): + out.headers["content-type"] = response.media_type + else: + out.headers.pop("content-type", None) + return out + + return view + + +def blueprint(api: Api, *, name: str = "mongospecs", openapi: bool = True) -> Blueprint: + """A Blueprint with one rule per operation (plus ``openapi.json``).""" + bp = Blueprint(name, __name__) + for route in api._routes(): + rule = route.path + for param in route.operation.path_parameters: + rule = rule.replace("{" + param + "}", f"<{param}>") + bp.add_url_rule( + rule, endpoint=route.name.replace(".", "_"), view_func=_view(api, route), methods=[route.method] + ) + if openapi: + + async def spec() -> dict[str, t.Any]: + return api.openapi() + + bp.add_url_rule(api.openapi_path, endpoint="openapi", view_func=spec, methods=["GET"]) + return bp + + +def init_app(app: Quart, api: Api, *, registry: t.Any = None, name: str = "mongospecs") -> None: + """Register the API on a Quart app; with ``registry=`` also open and close it with the server.""" + app.register_blueprint(blueprint(api, name=name)) + if registry is not None: + app.before_serving(registry.open) + app.after_serving(registry.close) diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/starlette.py b/packages/mongospecs-web/src/mongospecs_web/plugs/starlette.py new file mode 100644 index 0000000..600a090 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/starlette.py @@ -0,0 +1,74 @@ +"""Starlette plug.""" + +from __future__ import annotations + +import contextlib +import typing as t + +from starlette.requests import Request as StarletteRequest +from starlette.responses import JSONResponse +from starlette.responses import Response as StarletteResponse +from starlette.routing import BaseRoute +from starlette.routing import Route as StarletteRoute + +from mongospecs_web.api import Api, Route +from mongospecs_web.resource import Request, Response + +__all__ = ["lifespan", "mount", "routes", "to_request", "to_response"] + + +async def to_request(request: StarletteRequest) -> Request: + return Request( + request.method, + {k: str(v) for k, v in request.path_params.items()}, + list(request.query_params.multi_items()), + {k.lower(): v for k, v in request.headers.items()}, + await request.body(), + None, + request, + ) + + +def to_response(response: Response) -> StarletteResponse: + has_body = bool(response.body) or response.status not in (204, 304) + return StarletteResponse( + response.body, response.status, dict(response.headers), response.media_type if has_body else None + ) + + +def _endpoint(api: Api, route: Route) -> t.Callable[[StarletteRequest], t.Awaitable[StarletteResponse]]: + async def endpoint(request: StarletteRequest) -> StarletteResponse: + return to_response(await api.run(route, await to_request(request))) + + return endpoint + + +def routes(api: Api, *, openapi: bool = True) -> list[BaseRoute]: + """Starlette routes for every operation of the API (plus ``openapi.json``).""" + out: list[BaseRoute] = [ + StarletteRoute(route.path, _endpoint(api, route), methods=[route.method], name=route.name) + for route in api._routes() + ] + if openapi: + + async def spec(request: StarletteRequest) -> StarletteResponse: + return JSONResponse(api.openapi()) + + out.append(StarletteRoute(api.openapi_path, spec, methods=["GET"], name="openapi")) + return out + + +def mount(app: t.Any, api: Api, *, openapi: bool = True) -> None: + """Add the API's routes to an existing Starlette application.""" + app.router.routes.extend(routes(api, openapi=openapi)) + + +def lifespan(registry: t.Any) -> t.Callable[[t.Any], t.AsyncContextManager[None]]: + """A Starlette ``lifespan`` that opens and closes a registry: ``Starlette(lifespan=lifespan(registry))``.""" + + @contextlib.asynccontextmanager + async def manage(app: t.Any) -> t.AsyncIterator[None]: + async with registry: + yield + + return manage diff --git a/packages/mongospecs-web/src/mongospecs_web/policy.py b/packages/mongospecs-web/src/mongospecs_web/policy.py new file mode 100644 index 0000000..6c687e2 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/policy.py @@ -0,0 +1,216 @@ +"""Views of a model derived from its field policy. + +One model declaration drives four views. No view class is generated: the policy +is data, input is filtered by it and then validated against the real model, and +JSON Schema for each view is produced by transforming the model's own schema. +""" + +from __future__ import annotations + +import base64 +import datetime as dt +import decimal +import enum +import typing as t +import uuid +import weakref + +from bson import ObjectId +from bson.decimal128 import Decimal128 + +from mongospecs import CreatedAt, Deferred, DeletedAt, Immutable, Ref, Revision, UpdatedAt, meta_of +from mongospecs.codec import FieldInfo +from mongospecs.markers import is_computed +from mongospecs.meta import ModelMeta + +__all__ = ["View", "jsonable", "view_of"] + +_MANAGED = (CreatedAt, UpdatedAt, DeletedAt, Revision) + + +def jsonable(value: t.Any) -> t.Any: + """Convert a BSON-ready value to plain JSON types.""" + if value is None or isinstance(value, str | bool | int | float): + return value + if isinstance(value, ObjectId | uuid.UUID | decimal.Decimal | Decimal128): + return str(value) + if isinstance(value, dt.datetime): + return value.isoformat().replace("+00:00", "Z") + if isinstance(value, dt.date | dt.time): + return value.isoformat() + if isinstance(value, enum.Enum): + return jsonable(value.value) + if isinstance(value, dict): + return {str(k): jsonable(v) for k, v in value.items()} + if isinstance(value, list | tuple | set | frozenset): + return [jsonable(v) for v in value] + if isinstance(value, bytes | bytearray): + return base64.b64encode(bytes(value)).decode("ascii") + if isinstance(value, Ref): + return jsonable(value.id) + return str(value) + + +class View: + """What of a model is readable, writable, filterable and sortable from outside.""" + + def __init__(self, model: type) -> None: + self.model = model + self.meta: ModelMeta = meta_of(model) + fields = self.meta.fields + managed = {f.name for f in fields.values() if any(f.has(m) for m in _MANAGED)} + self.private: frozenset[str] = frozenset(f.name for f in fields.values() if f.policy.private) + self.readable: tuple[FieldInfo, ...] = tuple( + f for f in fields.values() if not f.policy.private and not f.has(Deferred) + ) + writable = frozenset( + f.name for f in fields.values() if f.name != "id" and f.name not in managed and not f.transient + ) + self.insertable: frozenset[str] = frozenset( + f.name + for f in fields.values() + if f.policy.can_insert and f.name != "id" and f.name not in managed and not f.transient + ) + self.updatable: frozenset[str] = frozenset( + name for name in writable if fields[name].policy.can_update and not fields[name].has(Immutable) + ) + self.filterable: dict[str, tuple[str, ...]] = { + f.name: f.policy.filter_ops + for f in fields.values() + if f.policy.filter_ops and not f.policy.private and not f.transient + } + self.sortable: frozenset[str] = frozenset( + f.name for f in fields.values() if f.policy.sort and not f.policy.private and not f.transient + ) + self.relations: frozenset[str] = frozenset( + f.name for f in fields.values() if (f.ref is not None or f.transient) and not f.policy.private + ) + self.computed: tuple[str, ...] = tuple( + name + for klass in reversed(model.__mro__) + for name, attr in vars(klass).items() + if is_computed(attr) + ) + revision = next((f for f in fields.values() if f.has(Revision)), None) + self.revision: str | None = revision.name if revision else None + + # -- output ---------------------------------------------------------------- + + def serialize(self, doc: t.Any, include: t.Collection[str] = ()) -> dict[str, t.Any]: + """The Read view of a document as JSON types. Private fields never appear.""" + if type(doc) is not self.model: + return view_of(type(doc)).serialize(doc, include) + meta = self.meta + encoded = meta.encode(doc) + out: dict[str, t.Any] = {} + for f in self.readable: + nested = {p.partition(".")[2] for p in include if p.partition(".")[0] == f.name and "." in p} + if f.transient: + if f.name in include: + value = getattr(doc, f.name) + out[f.name] = ( + [_child(v, nested) for v in value] if isinstance(value, list) else jsonable(value) + ) + continue + if f.ref is not None and f.name in include: + out[f.name] = _embed(getattr(doc, f.name), encoded.get(f.stored), nested) + continue + out[f.name] = jsonable(encoded.get(f.stored)) + if meta.disc_key: + out[meta.disc_key] = encoded.get(meta.disc_key) + for name in self.computed: + out[name] = jsonable(getattr(doc, name)) + return out + + def etag(self, doc: t.Any) -> str | None: + if self.revision is None: + return None + return f'"{getattr(doc, self.revision)}"' + + # -- input --------------------------------------------------------------- + + def check_input(self, data: t.Any, allowed: frozenset[str], action: str) -> dict[str, t.Any]: + """Reject bodies that are not objects, name unknown fields or name read-only ones.""" + from mongospecs_web.errors import Unprocessable + + if not isinstance(data, dict): + raise Unprocessable("The request body must be a JSON object.") + errors: list[dict[str, t.Any]] = [] + for key in data: + if key in allowed: + continue + known = key in self.meta.fields and key not in self.private + errors.append( + {"field": key, "message": f"cannot be set on {action}" if known else "unknown field"} + ) + if errors: + raise Unprocessable("The request body names fields that cannot be written.", errors=errors) + return data + + # -- schemas ---------------------------------------------------------------- + + def _base(self) -> dict[str, t.Any]: + return self.meta.codec.json_schema(self.model) + + def schema(self, kind: t.Literal["read", "create", "patch"]) -> dict[str, t.Any]: + """JSON Schema of one view. ``$defs`` of nested models are kept alongside.""" + base = self._base() + props: dict[str, t.Any] = base.get("properties", {}) + required: list[str] = list(base.get("required", [])) + if kind == "read": + keep = [f.name for f in self.readable] + out_props = {name: props.get(name, {}) for name in keep if name in props} + for name in self.computed: + out_props[name] = {} + schema: dict[str, t.Any] = { + "type": "object", + "properties": out_props, + "required": [n for n in keep if n == "id" or n in required], + } + else: + allowed = self.insertable if kind == "create" else self.updatable + schema = { + "type": "object", + "properties": {name: props[name] for name in props if name in allowed}, + "additionalProperties": False, + } + if kind == "create": + schema["required"] = [n for n in required if n in allowed] + if not schema.get("required"): + schema.pop("required", None) + if "$defs" in base: + schema["$defs"] = base["$defs"] + return schema + + +def _child(value: t.Any, include: t.Collection[str]) -> t.Any: + if value is None: + return None + return view_of(type(value)).serialize(value, include) + + +def _embed(value: t.Any, encoded: t.Any, include: t.Collection[str]) -> t.Any: + """A reference field in the output: the related document when it is loaded, else its id.""" + + def one(ref: t.Any, raw: t.Any) -> t.Any: + if isinstance(ref, Ref) and ref.loaded: + return _child(ref.doc, include) + return jsonable(raw) + + if value is None: + return None + if isinstance(value, list): + return [one(r, e) for r, e in zip(value, encoded or [], strict=False)] + if isinstance(value, dict): + return {k: one(r, (encoded or {}).get(k)) for k, r in value.items()} + return one(value, encoded) + + +_VIEWS: weakref.WeakKeyDictionary[type, View] = weakref.WeakKeyDictionary() + + +def view_of(model: type) -> View: + view = _VIEWS.get(model) + if view is None: + view = _VIEWS[model] = View(model) + return view diff --git a/packages/mongospecs-web/src/mongospecs_web/resource.py b/packages/mongospecs-web/src/mongospecs_web/resource.py new file mode 100644 index 0000000..e2b0fe8 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/resource.py @@ -0,0 +1,544 @@ +"""Resources: a model exposed over HTTP, described as pure data. + +A :class:`Resource` owns a list of :class:`Operation` descriptors. Nothing here +imports a web framework: a plug translates its framework's request into a +:class:`Request`, calls :meth:`Resource.dispatch`, and translates the +:class:`Response` back. That makes every operation testable without a server. +""" + +from __future__ import annotations + +import inspect +import json +import typing as t +from dataclasses import dataclass, field + +from mongospecs.expr import Expr +from mongospecs_web.backend import MongoBackend, ResourceBackend +from mongospecs_web.errors import ( + PROBLEM_JSON, + BadRequest, + Forbidden, + NotFound, + Unprocessable, + UnsupportedMediaType, + problem, +) +from mongospecs_web.filters import Cursor, Pagination, parse_includes, parse_list_query +from mongospecs_web.policy import View, jsonable, view_of + +__all__ = ["Create", "Ctx", "Delete", "Get", "List", "Operation", "Patch", "Request", "Resource", "Response"] + +T = t.TypeVar("T") +F = t.TypeVar("F", bound=t.Callable[..., t.Any]) + +# Built-in operation kinds. Used as keys for ``ops=``, ``guards=`` and hooks. +List = "list" +Get = "get" +Create = "create" +Patch = "patch" +Delete = "delete" + +JSON = "application/json" + + +@dataclass(slots=True) +class Request: + """A framework-neutral HTTP request.""" + + method: str + path_params: dict[str, str] = field(default_factory=dict) + query: list[tuple[str, str]] = field(default_factory=list) + headers: dict[str, str] = field(default_factory=dict) + """Header names in lower case.""" + body: bytes = b"" + principal: t.Any = None + """Whoever is calling, as returned by the plug's ``authenticate`` callable.""" + raw: t.Any = None + """The framework's own request object, for custom operations that need it.""" + + def json(self) -> t.Any: + if not self.body: + return None + content_type = self.headers.get("content-type", JSON).split(";")[0].strip().lower() + if content_type != JSON and not content_type.endswith("+json"): + raise UnsupportedMediaType("The request body must be application/json.") + try: + return json.loads(self.body) + except ValueError: + raise BadRequest("The request body is not valid JSON.") from None + + +@dataclass(slots=True) +class Response: + """A framework-neutral HTTP response.""" + + status: int = 200 + body: bytes = b"" + headers: dict[str, str] = field(default_factory=dict) + media_type: str = JSON + + @classmethod + def json(cls, data: t.Any, status: int = 200, headers: dict[str, str] | None = None) -> Response: + body = json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode() + return cls(status, body, dict(headers or {}), JSON) + + @classmethod + def error(cls, exc: BaseException) -> Response: + err = problem(exc) + body = json.dumps(err.body(), separators=(",", ":"), ensure_ascii=False).encode() + return cls(err.status, body, dict(err.headers), PROBLEM_JSON) + + +@dataclass(slots=True) +class Ctx(t.Generic[T]): + """What an operation handler, guard, scope or hook receives.""" + + resource: Resource[T] + op: Operation + request: Request + state: dict[str, t.Any] = field(default_factory=dict) + + @property + def principal(self) -> t.Any: + return self.request.principal + + @property + def path_params(self) -> dict[str, str]: + return self.request.path_params + + @property + def id(self) -> str: + return self.request.path_params["id"] + + @property + def backend(self) -> ResourceBackend[T]: + return self.resource.backend + + def body(self) -> t.Any: + return self.request.json() + + async def scope(self) -> t.Any: + """The row-level filter for this request (``None`` when the resource has no scope).""" + if "scope" not in self.state: + self.state["scope"] = await self.resource._scope(self) + return self.state["scope"] + + async def load(self) -> T: + """The document named by ``{id}``, within the request's scope; 404 if it is not visible.""" + doc = await self.backend.get(self.id, scope=await self.scope()) + if doc is None: + raise NotFound("The resource does not exist.") + return doc + + +Handler = t.Callable[[Ctx[t.Any]], t.Awaitable[t.Any]] +Guard = t.Callable[[Ctx[t.Any]], t.Any] + + +@dataclass(frozen=True, slots=True) +class Operation: + """One HTTP operation of a resource: pure data plus an async handler.""" + + name: str + method: str + path: str + """Path under the resource, with ``{name}`` parameters: ``""``, ``"/{id}"``, ``"/{id}/publish"``.""" + handler: Handler + kind: str = "custom" + summary: str = "" + status: int = 200 + request_schema: dict[str, t.Any] | None = None + response_schema: dict[str, t.Any] | None = None + query_parameters: tuple[dict[str, t.Any], ...] = () + guards: tuple[Guard, ...] = () + + @property + def path_parameters(self) -> tuple[str, ...]: + return tuple( + part[1:-1] for part in self.path.split("/") if part.startswith("{") and part.endswith("}") + ) + + +async def _maybe(value: t.Any) -> t.Any: + return await value if inspect.isawaitable(value) else value + + +class Resource(t.Generic[T]): + """A model exposed as REST operations. + + :: + + articles = Resource( + Article, + ops=[List, Get, Create, Patch, Delete], + scope=lambda ctx: Q.author == ctx.principal.id, # row-level, ANDed into every filter + guards={Delete: is_admin}, # operation-level + include=["author"], + page=Cursor(default=20, max=100), + ) + """ + + def __init__( + self, + model: type[T], + *, + name: str | None = None, + ops: t.Sequence[str] = (List, Get, Create, Patch, Delete), + backend: ResourceBackend[T] | None = None, + scope: t.Callable[[Ctx[T]], t.Any] | None = None, + guards: t.Mapping[str, Guard | t.Sequence[Guard]] | t.Sequence[Guard] | None = None, + include: t.Sequence[str] = (), + page: Pagination | None = None, + tags: t.Sequence[str] | None = None, + ) -> None: + self.model = model + self.view: View = view_of(model) + self.name = name or self.view.meta.collection + self.backend: ResourceBackend[T] = backend if backend is not None else MongoBackend(model) + self.scope_fn = scope + self.includes: tuple[str, ...] = tuple(include) + for path in self.includes: + head = path.partition(".")[0] + if head not in self.view.relations: + raise ValueError(f"{model.__name__}.{head} is not a readable relation and cannot be included") + self.page: Pagination = page or Cursor() + self.tags: tuple[str, ...] = tuple(tags) if tags is not None else (self.name,) + self._all_guards: tuple[Guard, ...] = () + self._guards: dict[str, tuple[Guard, ...]] = {} + if isinstance(guards, t.Mapping): + for key, value in guards.items(): + self._guards[key] = ( + tuple(value) if isinstance(value, list | tuple) else (t.cast(Guard, value),) + ) + elif guards: + self._all_guards = tuple(guards) + self._before: dict[str, list[t.Callable[..., t.Any]]] = {} + self._after: dict[str, list[t.Callable[..., t.Any]]] = {} + self.operations: list[Operation] = [] + builders = { + List: self._list_op, + Get: self._get_op, + Create: self._create_op, + Patch: self._patch_op, + Delete: self._delete_op, + } + for kind in ops: + if kind not in builders: + raise ValueError( + f"unknown operation {kind!r}; use List, Get, Create, Patch, Delete or @resource.op" + ) + self.operations.append(builders[kind]()) + + # -- declaration ------------------------------------------------------------- + + def op( + self, + name: str, + *, + method: str = "POST", + path: str | None = None, + summary: str = "", + status: int = 200, + request_schema: dict[str, t.Any] | None = None, + response_schema: dict[str, t.Any] | None = None, + guards: t.Sequence[Guard] = (), + ) -> t.Callable[[Handler], Handler]: + """Add a custom operation. The handler receives a :class:`Ctx` and returns a document, + JSON-able data, a :class:`Response`, or ``None`` (204).""" + + def register(handler: Handler) -> Handler: + self.operations.append( + Operation( + name, + method.upper(), + path if path is not None else f"/{{id}}/{name}", + handler, + "custom", + summary or (handler.__doc__ or "").strip().split("\n")[0], + status, + request_schema, + response_schema, + (), + tuple(guards), + ) + ) + return handler + + return register + + def before(self, op: str) -> t.Callable[[F], F]: + """Register ``async def hook(ctx)`` to run before an operation (after guards).""" + + def register(fn: F) -> F: + self._before.setdefault(op, []).append(fn) + return fn + + return register + + def after(self, op: str) -> t.Callable[[F], F]: + """Register ``async def hook(ctx, result)`` to run after an operation succeeded.""" + + def register(fn: F) -> F: + self._after.setdefault(op, []).append(fn) + return fn + + return register + + def operation(self, name: str) -> Operation: + for op in self.operations: + if op.name == name: + return op + raise KeyError(name) + + # -- dispatch ---------------------------------------------------------------- + + async def _scope(self, ctx: Ctx[T]) -> t.Any: + if self.scope_fn is None: + return None + scope = await _maybe(self.scope_fn(ctx)) + if scope is not None and not isinstance(scope, Expr | t.Mapping): + raise TypeError("scope must return a Q condition, a mapping, or None") + return scope + + async def dispatch(self, op: Operation, request: Request) -> Response: + """Run one operation. Never raises: every error becomes a problem+json response.""" + ctx: Ctx[T] = Ctx(self, op, request) + try: + for guard in ( + *self._all_guards, + *self._guards.get(op.kind, ()), + *self._guards.get(op.name, ()), + *op.guards, + ): + if not await _maybe(guard(ctx)): + raise Forbidden("You are not allowed to do this.") + for hook in self._before.get(op.name, ()): + await _maybe(hook(ctx)) + result = await op.handler(ctx) + for hook in self._after.get(op.name, ()): + await _maybe(hook(ctx, ctx.state.get("result", result))) + return self._respond(op, result, ctx) + except Exception as exc: + return Response.error(exc) + + def _respond(self, op: Operation, result: t.Any, ctx: Ctx[T]) -> Response: + if isinstance(result, Response): + return result + if result is None: + return Response(204, b"", {}) + if isinstance(result, self.model): + headers: dict[str, str] = {} + etag = self.view.etag(result) + if etag: + headers["etag"] = etag + return Response.json({"data": self.view.serialize(result)}, op.status, headers) + return Response.json({"data": jsonable(result)}, op.status) + + # -- built-in operations ------------------------------------------------------- + + def _ref(self, kind: str) -> dict[str, t.Any]: + return {"$ref": f"#/components/schemas/{self.schema_name(kind)}"} + + def schema_name(self, kind: str) -> str: + return f"{self.model.__name__}{kind.capitalize()}" + + def _list_op(self) -> Operation: + async def handler(ctx: Ctx[T]) -> Response: + query = parse_list_query(ctx.request.query, self.view, self.page, self.includes) + page = await self.backend.list(query, scope=await ctx.scope()) + ctx.state["result"] = page + meta: dict[str, t.Any] = {"size": page.size, "has_next": page.has_next} + if page.number is not None: + meta["number"] = page.number + else: + meta["next_cursor"] = page.next_cursor + if page.total is not None: + meta["total"] = page.total + include = set(query.include) + return Response.json( + {"data": [self.view.serialize(d, include) for d in page.items], "page": meta} + ) + + return Operation( + List, + "GET", + "", + handler, + List, + f"List {self.name}", + 200, + None, + { + "type": "object", + "properties": { + "data": {"type": "array", "items": self._ref("read")}, + "page": {"type": "object"}, + }, + "required": ["data", "page"], + }, + tuple(self._list_parameters()), + ) + + def _list_parameters(self) -> list[dict[str, t.Any]]: + params: list[dict[str, t.Any]] = [] + for name, ops in self.view.filterable.items(): + for op in ops: + params.append( + { + "name": name if op == "eq" else f"{name}[{op}]", + "in": "query", + "required": False, + "schema": {"type": "string"}, + "description": f"Filter: {name} {op}", + } + ) + if self.view.sortable: + params.append( + { + "name": "sort", + "in": "query", + "required": False, + "schema": {"type": "string"}, + "description": "Comma-separated sort keys; prefix with - for descending. Allowed: " + + ", ".join(sorted(self.view.sortable)), + } + ) + params.append( + { + "name": "limit", + "in": "query", + "required": False, + "schema": { + "type": "integer", + "minimum": 1, + "maximum": self.page.max, + "default": self.page.default, + }, + } + ) + if self.page.kind == "cursor": + params.append({"name": "after", "in": "query", "required": False, "schema": {"type": "string"}}) + else: + params.append( + { + "name": "page", + "in": "query", + "required": False, + "schema": {"type": "integer", "minimum": 1}, + } + ) + if self.page.total: + params.append({"name": "total", "in": "query", "required": False, "schema": {"type": "boolean"}}) + if self.includes: + params.append(self._include_parameter()) + return params + + def _include_parameter(self) -> dict[str, t.Any]: + return { + "name": "include", + "in": "query", + "required": False, + "schema": {"type": "string"}, + "description": "Comma-separated relations to embed. Allowed: " + ", ".join(self.includes), + } + + def _one(self) -> dict[str, t.Any]: + return {"type": "object", "properties": {"data": self._ref("read")}, "required": ["data"]} + + def _get_op(self) -> Operation: + async def handler(ctx: Ctx[T]) -> Response: + include = parse_includes(ctx.request.query, self.includes) + doc = await self.backend.get(ctx.id, scope=await ctx.scope(), include=include) + if doc is None: + raise NotFound("The resource does not exist.") + ctx.state["result"] = doc + headers: dict[str, str] = {} + etag = self.view.etag(doc) + if etag: + headers["etag"] = etag + if ctx.request.headers.get("if-none-match") == etag: + return Response(304, b"", headers) + return Response.json({"data": self.view.serialize(doc, set(include))}, 200, headers) + + params = (self._include_parameter(),) if self.includes else () + return Operation( + Get, "GET", "/{id}", handler, Get, f"Get one of {self.name}", 200, None, self._one(), params + ) + + def _create_op(self) -> Operation: + async def handler(ctx: Ctx[T]) -> Response: + body = ctx.body() + if body is None: + raise Unprocessable("A JSON request body is required.") + data = self.view.check_input(body, self.view.insertable, "create") + doc = await self.backend.create(dict(data)) + ctx.state["result"] = doc + headers = {"location": str(jsonable(getattr(doc, "id", "")))} + etag = self.view.etag(doc) + if etag: + headers["etag"] = etag + return Response.json({"data": self.view.serialize(doc)}, 201, headers) + + return Operation( + Create, + "POST", + "", + handler, + Create, + f"Create one of {self.name}", + 201, + self._ref("create"), + self._one(), + ) + + def _patch_op(self) -> Operation: + async def handler(ctx: Ctx[T]) -> Response: + body = ctx.body() + if body is None: + raise Unprocessable("A JSON request body is required.") + data = self.view.check_input(body, self.view.updatable, "update") + if_rev = _if_match(ctx.request.headers.get("if-match"), self.view.revision is not None) + doc = await self.backend.patch(ctx.id, dict(data), scope=await ctx.scope(), if_rev=if_rev) + ctx.state["result"] = doc + headers: dict[str, str] = {} + etag = self.view.etag(doc) + if etag: + headers["etag"] = etag + return Response.json({"data": self.view.serialize(doc)}, 200, headers) + + return Operation( + Patch, + "PATCH", + "/{id}", + handler, + Patch, + f"Update one of {self.name}", + 200, + self._ref("patch"), + self._one(), + ) + + def _delete_op(self) -> Operation: + async def handler(ctx: Ctx[T]) -> None: + await self.backend.delete(ctx.id, scope=await ctx.scope()) + + return Operation(Delete, "DELETE", "/{id}", handler, Delete, f"Delete one of {self.name}", 204) + + def __repr__(self) -> str: + return f"" + + +def _if_match(header: str | None, has_revision: bool) -> int | None: + """Parse ``If-Match`` into a revision number. ``*`` and absence mean "no check".""" + if header is None or not has_revision: + return None + value = header.strip() + if value == "*": + return None + if value.startswith("W/"): + value = value[2:] + try: + return int(value.strip('"')) + except ValueError: + raise BadRequest("If-Match must carry the ETag of the resource.") from None diff --git a/packages/mongospecs-web/src/mongospecs_web/testing.py b/packages/mongospecs-web/src/mongospecs_web/testing.py new file mode 100644 index 0000000..30986e7 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/testing.py @@ -0,0 +1,551 @@ +"""Test support: an in-memory backend and the plug contract suite. + +``FakeBackend`` implements :class:`~mongospecs_web.backend.ResourceBackend` +without a database. It evaluates only the bounded filter operators a Resource +can produce, so it stays a test double rather than a database emulator. + +``run_contract`` drives a mounted API through an ``httpx.AsyncClient`` and +returns a transcript. Every plug must produce the same transcript for the same +API, which is how a third-party plug proves it behaves like the built-in ones:: + + api = contract_api("msgspec") + app = my_framework_app(api) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app), base_url="http://t") as client: + transcript = await run_contract(client, api) + assert transcript == await reference_transcript("msgspec") +""" + +from __future__ import annotations + +import re +import typing as t + +from bson import ObjectId + +from mongospecs import ( + DocumentNotFound, + DuplicateKeyError, + Page, + Policy, + Q, + QueryError, + Revision, + SpecConfig, + StaleDocumentError, + meta_of, +) +from mongospecs.expr import compile_filter +from mongospecs_web.api import Api +from mongospecs_web.backend import ListQuery +from mongospecs_web.errors import Unauthorized +from mongospecs_web.filters import Offset +from mongospecs_web.policy import view_of +from mongospecs_web.resource import Create, Ctx, Delete, Get, List, Patch, Request, Resource + +__all__ = ["FakeBackend", "contract_api", "reference_transcript", "run_contract"] + +T = t.TypeVar("T") + + +def _matches(doc: t.Mapping[str, t.Any], flt: t.Mapping[str, t.Any]) -> bool: + for key, cond in flt.items(): + if key == "$and": + if not all(_matches(doc, c) for c in cond): + return False + elif key == "$or": + if not any(_matches(doc, c) for c in cond): + return False + elif key == "$nor": + if any(_matches(doc, c) for c in cond): + return False + elif not _field(doc.get(key), key in doc, cond): + return False + return True + + +def _eq(value: t.Any, expected: t.Any) -> bool: + return value == expected or (isinstance(value, list) and expected in value) + + +def _field(value: t.Any, present: bool, cond: t.Any) -> bool: + if not (isinstance(cond, dict) and any(str(k).startswith("$") for k in cond)): + return _eq(value, cond) + for op, arg in cond.items(): + if op == "$eq": + ok = _eq(value, arg) + elif op == "$ne": + ok = not _eq(value, arg) + elif op == "$in": + ok = any(_eq(value, a) for a in arg) + elif op == "$nin": + ok = not any(_eq(value, a) for a in arg) + elif op in ("$gt", "$gte", "$lt", "$lte"): + if value is None: + ok = False + else: + ok = {"$gt": value > arg, "$gte": value >= arg, "$lt": value < arg, "$lte": value <= arg}[op] + elif op == "$exists": + ok = present == bool(arg) + elif op == "$regex": + ok = isinstance(value, str) and re.search(arg, value) is not None + elif op == "$options": + ok = True + else: + raise QueryError(f"FakeBackend does not evaluate {op}") + if not ok: + return False + return True + + +class FakeBackend(t.Generic[T]): + """In-memory ``ResourceBackend``. Ids are sequential so transcripts are reproducible.""" + + def __init__(self, model: type[T], *, unique: t.Sequence[str] = ()) -> None: + self.model = model + self.meta = meta_of(model) + self.view = view_of(model) + self.unique = tuple(unique) + self.docs: dict[t.Any, T] = {} + self._next = 1 + + def _visible(self, doc: T, scope: t.Any) -> bool: + return scope is None or _matches(self.meta.encode(doc), compile_filter(scope, self.meta)) + + def _find(self, id: t.Any, scope: t.Any) -> T | None: + try: + key = self.meta.cast("id", id) + except QueryError: + return None + doc = self.docs.get(key) + return doc if doc is not None and self._visible(doc, scope) else None + + def _check_unique(self, doc: T, skip: t.Any = None) -> None: + for name in self.unique: + value = getattr(doc, name) + if value is None: + continue # like a sparse unique index + for other_id, other in self.docs.items(): + if other_id != skip and getattr(other, name) == value: + raise DuplicateKeyError(f"duplicate {name}", code=11000, fields={name: value}) + + async def get(self, id: t.Any, *, scope: t.Any, include: t.Sequence[str] = ()) -> T | None: + return self._find(id, scope) + + async def list(self, query: ListQuery, *, scope: t.Any) -> Page[T]: + conditions = [compile_filter(f.to_expr(), self.meta) for f in query.filters] + rows = [(self.meta.encode(d), d) for d in self.docs.values() if self._visible(d, scope)] + rows = [(enc, d) for enc, d in rows if all(_matches(enc, c) for c in conditions)] + keys = [(self.meta.stored_path(name), direction) for name, direction in query.sort] + if not any(k == "_id" for k, _ in keys): + keys.append(("_id", 1)) + for key, direction in reversed(keys): + rows.sort(key=lambda row: (row[0].get(key) is not None, row[0].get(key)), reverse=direction == -1) + total = len(rows) if query.total else None + if query.number is not None: + start = (query.number - 1) * query.size + elif query.after is not None: + ids = [str(enc["_id"]) for enc, _ in rows] + if query.after not in ids: + raise QueryError("invalid pagination cursor") + start = ids.index(query.after) + 1 + else: + start = 0 + chunk = rows[start : start + query.size + 1] + has_next = len(chunk) > query.size + items = [d for _, d in chunk[: query.size]] + cursor = str(chunk[query.size - 1][0]["_id"]) if has_next and query.number is None else None + return Page(items, query.size, has_next, cursor, query.number, total) + + async def create(self, data: dict[str, t.Any]) -> T: + stored = {self.meta.fields[k].stored: v for k, v in data.items()} + doc = self.meta.decode(stored, trusted=False) + self._check_unique(doc) + doc.id = ObjectId(f"{self._next:024x}") + self._next += 1 + self.docs[doc.id] = doc + return t.cast(T, doc) + + async def patch(self, id: t.Any, data: dict[str, t.Any], *, scope: t.Any, if_rev: int | None = None) -> T: + doc = self._find(id, scope) + if doc is None: + raise DocumentNotFound("not found") + revision = self.view.revision + if revision is not None and if_rev is not None and getattr(doc, revision) != if_rev: + raise StaleDocumentError("stale") + values = {k: self.meta.codec.validate_field(self.model, k, v) for k, v in data.items()} + if values: + before = {k: getattr(doc, k) for k in values} + for key, value in values.items(): + setattr(doc, key, value) + try: + self._check_unique(doc, skip=t.cast(t.Any, doc).id) + except DuplicateKeyError: + for key, value in before.items(): + setattr(doc, key, value) + raise + if revision is not None: + setattr(doc, revision, getattr(doc, revision) + 1) + return doc + + async def delete(self, id: t.Any, *, scope: t.Any) -> None: + doc = self._find(id, scope) + if doc is None: + raise DocumentNotFound("not found") + del self.docs[t.cast(t.Any, doc).id] + + +# -- the contract API ---------------------------------------------------------------- + + +def _widget_model(backend: str) -> type: + """The model every plug is checked against, declared on the requested backend.""" + fields: dict[str, t.Any] = { + "name": (t.Annotated[str, Policy(filter=("eq", "prefix", "in"), sort=True)], ...), + "price": (t.Annotated[float, Policy(filter=True, sort=True)], 0.0), + "stock": (t.Annotated[int, Policy(filter=("eq", "gte", "lte"))], 0), + "tags": (list[str], None), + "owner": (t.Annotated[str, Policy(update=False, filter=("eq",))], "anonymous"), + "sku": (t.Annotated[str | None, Policy(filter=("eq", "exists"))], None), + "internal_note": (t.Annotated[str | None, Policy(private=True)], None), + "audit": (t.Annotated[str | None, Policy(insert=False, update=False)], None), + "rev": (t.Annotated[int, Revision], 0), + } + config = SpecConfig(collection="widgets") + if backend == "msgspec": + import msgspec + + from mongospecs.msgspec import Spec as MsgspecSpec + + spec_fields: list[t.Any] = [] + for name, (tp, default) in fields.items(): + if default is ...: + spec_fields.append((name, tp)) + elif name == "tags": + spec_fields.append((name, tp, msgspec.field(default_factory=list))) + else: + spec_fields.append((name, tp, default)) + return msgspec.defstruct( + "Widget", spec_fields, bases=(MsgspecSpec,), kw_only=True, namespace={"__spec__": config} + ) + if backend == "pydantic": + import pydantic + + from mongospecs.pydantic import Spec as PydanticSpec + + definitions: dict[str, t.Any] = {} + for name, (tp, default) in fields.items(): + if name == "tags": + definitions[name] = (tp, pydantic.Field(default_factory=list)) + else: + definitions[name] = (tp, default) + model = pydantic.create_model("Widget", __base__=PydanticSpec, **definitions) + model.__spec__ = config + return model + if backend == "attrs": + import attrs + + from mongospecs.attrs import Spec as AttrsSpec + + attributes: dict[str, t.Any] = {} + for name, (tp, default) in fields.items(): + if default is ...: + attributes[name] = attrs.field(type=tp) + elif name == "tags": + attributes[name] = attrs.field(type=tp, factory=list) + else: + attributes[name] = attrs.field(type=tp, default=default) + klass = attrs.make_class("Widget", attributes, bases=(AttrsSpec,), kw_only=True) + t.cast(t.Any, klass).__spec__ = config + return klass + raise ValueError(f"unknown backend {backend!r}") + + +class Principal(t.NamedTuple): + name: str + admin: bool + + +def _authenticate(request: Request) -> Principal | None: + token = request.headers.get("authorization", "") + if not token: + return None + if not token.startswith("Bearer "): + raise Unauthorized("Malformed credentials.", headers={"www-authenticate": "Bearer"}) + name = token[len("Bearer ") :] + return Principal(name, name == "root") + + +def contract_api(backend: str = "msgspec") -> Api: + """The API the contract suite runs against: one model, in memory, with auth, scope and guards.""" + model = _widget_model(backend) + store: FakeBackend[t.Any] = FakeBackend(model, unique=["sku"]) + + def require_login(ctx: Ctx[t.Any]) -> bool: + if ctx.principal is None: + raise Unauthorized("Log in first.", headers={"www-authenticate": "Bearer"}) + return True + + def admin_only(ctx: Ctx[t.Any]) -> bool: + return bool(ctx.principal and ctx.principal.admin) + + def own_rows(ctx: Ctx[t.Any]) -> t.Any: + # admins see everything; everyone else only what they own or what is public + if ctx.principal is not None and ctx.principal.admin: + return None + owners = ["anonymous"] + ([ctx.principal.name] if ctx.principal is not None else []) + return Q.owner.in_(owners) + + widgets: Resource[t.Any] = Resource( + model, + backend=store, + ops=[List, Get, Create, Patch, Delete], + scope=own_rows, + guards={Create: require_login, Patch: require_login, Delete: [require_login, admin_only]}, + ) + + @widgets.before(Create) + def stamp_owner(ctx: Ctx[t.Any]) -> None: + ctx.state["creator"] = ctx.principal.name + + @widgets.after(Create) + def record(ctx: Ctx[t.Any], doc: t.Any) -> None: + doc.audit = f"created by {ctx.state['creator']}" + + @widgets.op("restock", method="POST", summary="Add stock to a widget") + async def restock(ctx: Ctx[t.Any]) -> t.Any: + amount = (ctx.body() or {}).get("amount", 1) + doc = await ctx.load() + doc.stock += int(amount) + return doc + + @widgets.op("stats", method="GET", path="/stats") + async def stats(ctx: Ctx[t.Any]) -> dict[str, t.Any]: + page = await ctx.backend.list(ListQuery(size=1000), scope=await ctx.scope()) + return {"count": len(page.items), "stock": sum(d.stock for d in page.items)} + + pages: Resource[t.Any] = Resource( + model, name="widget-pages", backend=store, ops=[List], page=Offset(default=2, max=3, total=True) + ) + return Api(widgets, pages, prefix="/api", title="Contract", version="1.0.0", authenticate=_authenticate) + + +Transcript = list[dict[str, t.Any]] + +_HEADERS = ("etag", "location", "www-authenticate", "content-type") +_FRAMEWORK_ROUTED = frozenset({"method not allowed", "unknown route", "unsupported method"}) + + +async def run_contract(client: t.Any, api: Api | None = None) -> Transcript: + """Drive the contract API through ``client`` (an ``httpx.AsyncClient``) and record what happened. + + The fixed sequence covers CRUD, validation, field policy, filters, sorting, + both pagination styles, authentication, guards, row scope, ETags and + conditional updates, custom operations, error documents and routing errors. + """ + transcript: Transcript = [] + ann = {"authorization": "Bearer ann"} + bob = {"authorization": "Bearer bob"} + root = {"authorization": "Bearer root"} + + async def call(method: str, path: str, *, label: str, **kwargs: t.Any) -> t.Any: + response = await client.request(method, "/api" + path, **kwargs) + try: + body = response.json() if response.content else None + except ValueError: + body = response.text + headers = {k: response.headers[k] for k in _HEADERS if k in response.headers} + if "content-type" in headers: + headers["content-type"] = headers["content-type"].split(";")[0] + entry = {"label": label, "status": response.status_code, "headers": headers, "body": body} + if label in _FRAMEWORK_ROUTED: + # The framework answers these itself (no route matched), so only the status is contractual. + entry = {"label": label, "status": response.status_code, "framework": True} + transcript.append(entry) + return body + + # -- create ------------------------------------------------------------------ + await call("POST", "/widgets", label="create needs login", json={"name": "a"}) + await call( + "POST", "/widgets", label="malformed credentials", json={"name": "a"}, headers={"authorization": "x"} + ) + await call( + "POST", + "/widgets", + label="create", + json={"name": "anvil", "price": 10.5, "stock": 3, "tags": ["heavy"], "owner": "ann", "sku": "A-1"}, + headers=ann, + ) + await call( + "POST", + "/widgets", + label="create second", + json={"name": "bolt", "price": 0.5, "owner": "bob"}, + headers=bob, + ) + await call( + "POST", "/widgets", label="create public", json={"name": "cog", "price": 2, "stock": 9}, headers=ann + ) + await call( + "POST", "/widgets", label="create duplicate sku", json={"name": "dup", "sku": "A-1"}, headers=ann + ) + await call("POST", "/widgets", label="create missing required", json={"price": 1}, headers=ann) + await call( + "POST", "/widgets", label="create wrong type", json={"name": "x", "price": "cheap"}, headers=ann + ) + await call( + "POST", "/widgets", label="create unknown field", json={"name": "x", "colour": "red"}, headers=ann + ) + await call( + "POST", "/widgets", label="create read-only field", json={"name": "x", "audit": "forged"}, headers=ann + ) + await call( + "POST", + "/widgets", + label="create private field", + json={"name": "x", "internal_note": "n"}, + headers=ann, + ) + await call("POST", "/widgets", label="create managed field", json={"name": "x", "rev": 9}, headers=ann) + await call("POST", "/widgets", label="create with id", json={"name": "x", "id": "0" * 24}, headers=ann) + await call("POST", "/widgets", label="create not an object", json=["a"], headers=ann) + await call( + "POST", + "/widgets", + label="create bad json", + content=b"{nope", + headers={**ann, "content-type": "application/json"}, + ) + await call( + "POST", + "/widgets", + label="create wrong media type", + content=b"name=a", + headers={**ann, "content-type": "text/plain"}, + ) + await call("POST", "/widgets", label="create empty body", headers=ann) + + first, second, third = (f"{n:024x}" for n in (1, 2, 3)) + + # -- read and row scope ----------------------------------------------------- + await call("GET", f"/widgets/{first}", label="get own", headers=ann) + await call("GET", f"/widgets/{first}", label="get someone else's is 404", headers=bob) + await call("GET", f"/widgets/{first}", label="get anonymous is 404") + await call("GET", f"/widgets/{third}", label="get public anonymously") + await call("GET", f"/widgets/{first}", label="get as admin", headers=root) + await call("GET", "/widgets/not-an-id", label="get malformed id") + await call("GET", f"/widgets/{'f' * 24}", label="get missing") + await call("GET", f"/widgets/{first}", label="get not modified", headers={**ann, "if-none-match": '"0"'}) + + # -- list, filters, sort ------------------------------------------------------ + await call("GET", "/widgets", label="list anonymous sees public only") + await call("GET", "/widgets", label="list as ann", headers=ann) + await call("GET", "/widgets?sort=-price", label="list sorted desc", headers=root) + await call("GET", "/widgets?sort=name&limit=2", label="list first page", headers=root) + await call("GET", "/widgets?sort=name&limit=2&after=garbage", label="list bad cursor", headers=root) + await call("GET", "/widgets?name=bolt", label="filter eq", headers=root) + await call("GET", "/widgets?name[prefix]=an", label="filter prefix", headers=root) + await call("GET", "/widgets?name[in]=anvil,cog&sort=name", label="filter in", headers=root) + await call("GET", "/widgets?price[gte]=2&price[lt]=11&sort=price", label="filter range", headers=root) + await call("GET", "/widgets?stock[gte]=3&sort=name", label="filter int cast", headers=root) + await call("GET", "/widgets?sku[exists]=true&sort=name", label="filter exists", headers=root) + await call( + "GET", "/widgets?price[ne]=0.5&sort=name&total=true", label="filter ne with total", headers=root + ) + await call("GET", "/widgets?tags=heavy", label="filter on unfilterable field", headers=root) + await call("GET", "/widgets?internal_note=n", label="filter on private field", headers=root) + await call("GET", "/widgets?name[gt]=a", label="filter operator not allowed", headers=root) + await call("GET", "/widgets?name[$ne]=a", label="operator injection in brackets", headers=root) + await call("GET", "/widgets?$where=1", label="operator injection as key", headers=root) + await call("GET", "/widgets?price[gte]=cheap", label="filter uncastable value", headers=root) + await call("GET", "/widgets?sort=stock", label="sort on unsortable field", headers=root) + await call("GET", "/widgets?limit=0", label="limit too small", headers=root) + await call("GET", "/widgets?limit=abc", label="limit not a number", headers=root) + await call("GET", "/widgets?limit=100000&sort=name", label="limit is capped", headers=root) + await call("GET", "/widgets?page=2", label="page number on cursor resource", headers=root) + await call("GET", "/widgets?include=owner", label="include not allowed", headers=root) + + # -- offset pagination -------------------------------------------------------- + await call("GET", "/widget-pages?sort=name", label="offset page 1") + await call("GET", "/widget-pages?sort=name&page=2&total=true", label="offset page 2 with total") + await call("GET", "/widget-pages?after=x", label="cursor on offset resource") + await call("POST", "/widget-pages", label="method not allowed", json={}) + + # -- patch -------------------------------------------------------------------- + await call("PATCH", f"/widgets/{first}", label="patch needs login", json={"price": 1}) + await call( + "PATCH", + f"/widgets/{first}", + label="patch", + json={"price": 12, "tags": ["heavy", "iron"]}, + headers=ann, + ) + await call( + "PATCH", + f"/widgets/{first}", + label="patch stale revision", + json={"price": 13}, + headers={**ann, "if-match": '"0"'}, + ) + await call( + "PATCH", + f"/widgets/{first}", + label="patch current revision", + json={"price": 13}, + headers={**ann, "if-match": '"1"'}, + ) + await call( + "PATCH", + f"/widgets/{first}", + label="patch bad if-match", + json={"price": 13}, + headers={**ann, "if-match": "abc"}, + ) + await call( + "PATCH", f"/widgets/{first}", label="patch null clears optional", json={"sku": None}, headers=ann + ) + await call("PATCH", f"/widgets/{first}", label="patch null on required", json={"name": None}, headers=ann) + await call("PATCH", f"/widgets/{first}", label="patch wrong type", json={"stock": "many"}, headers=ann) + await call( + "PATCH", f"/widgets/{first}", label="patch insert-only field", json={"owner": "bob"}, headers=ann + ) + await call("PATCH", f"/widgets/{first}", label="patch unknown field", json={"colour": "red"}, headers=ann) + await call("PATCH", f"/widgets/{first}", label="patch empty object", json={}, headers=ann) + await call( + "PATCH", f"/widgets/{first}", label="patch someone else's is 404", json={"price": 1}, headers=bob + ) + await call("PATCH", f"/widgets/{'f' * 24}", label="patch missing", json={"price": 1}, headers=ann) + + # -- custom operations ---------------------------------------------------------- + await call("POST", f"/widgets/{first}/restock", label="custom op", json={"amount": 4}, headers=ann) + await call( + "POST", f"/widgets/{first}/restock", label="custom op out of scope", json={"amount": 4}, headers=bob + ) + await call("GET", "/widgets/stats", label="custom collection op", headers=root) + + # -- delete ----------------------------------------------------------------------- + await call("DELETE", f"/widgets/{second}", label="delete needs login") + await call("DELETE", f"/widgets/{second}", label="delete forbidden for non-admin", headers=bob) + await call("DELETE", f"/widgets/{second}", label="delete", headers=root) + await call("DELETE", f"/widgets/{second}", label="delete again is 404", headers=root) + await call("GET", "/widgets?sort=name", label="list after delete", headers=root) + + # -- routing -------------------------------------------------------------------------- + await call("GET", "/nothing-here", label="unknown route") + await call("PUT", f"/widgets/{first}", label="unsupported method", json={}, headers=ann) + spec = await call("GET", "/openapi.json", label="openapi") + transcript[-1]["body"] = { + "paths": sorted(spec["paths"]), + "schemas": sorted(spec["components"]["schemas"]), + } + return transcript + + +async def reference_transcript(backend: str = "msgspec") -> Transcript: + """The transcript produced by the built-in pure-ASGI plug: the reference for every other plug.""" + import httpx + + api = contract_api(backend) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=api.asgi()), base_url="http://contract" + ) as client: + return await run_contract(client, api) diff --git a/packages/mongospecs-web/tests/conftest.py b/packages/mongospecs-web/tests/conftest.py new file mode 100644 index 0000000..08af818 --- /dev/null +++ b/packages/mongospecs-web/tests/conftest.py @@ -0,0 +1,31 @@ +import os +import uuid + +import pytest +import pytest_asyncio + +RS_URI = os.environ.get("MONGOSPECS_TEST_URI", "mongodb://localhost:27117/?replicaSet=rs0") + + +@pytest.fixture +def db_name(): + return f"ms_web_{uuid.uuid4().hex[:12]}" + + +@pytest_asyncio.fixture +async def make_registry(db_name): + from mongospecs import Registry + + opened = [] + + async def factory(*models, **kwargs): + reg = Registry(RS_URI, database=db_name, models=models, **kwargs) + await reg.open() + opened.append(reg) + return reg + + yield factory + for reg in opened: + if reg.is_open: + await reg.client.drop_database(db_name) + await reg.close() diff --git a/packages/mongospecs-web/tests/test_contract.py b/packages/mongospecs-web/tests/test_contract.py new file mode 100644 index 0000000..d223d1b --- /dev/null +++ b/packages/mongospecs-web/tests/test_contract.py @@ -0,0 +1,228 @@ +"""Every plug x model backend must produce the reference transcript.""" + +import httpx +import pytest + +from mongospecs_web.testing import contract_api, reference_transcript, run_contract + +BACKENDS = ("msgspec", "pydantic", "attrs") +PLUGS = ("asgi", "starlette", "fastapi", "litestar", "quart") + + +def build_app(plug, api): + if plug == "asgi": + return api.asgi() + if plug == "starlette": + from starlette.applications import Starlette + + from mongospecs_web.plugs.starlette import routes + + return Starlette(routes=routes(api)) + if plug == "fastapi": + from fastapi import FastAPI + + from mongospecs_web.plugs.fastapi import mount + + app = FastAPI(openapi_url="/api/openapi.json") + mount(app, api) + return app + if plug == "litestar": + from litestar import Litestar + + from mongospecs_web.plugs.litestar import MongospecsPlugin + + return Litestar(plugins=[MongospecsPlugin(api)], openapi_config=None) + if plug == "quart": + from quart import Quart + + from mongospecs_web.plugs.quart import init_app + + app = Quart("contract") + init_app(app, api) + return app + raise AssertionError(plug) + + +async def transcript_for(plug, backend): + api = contract_api(backend) + app = build_app(plug, api) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://contract" + ) as client: + return await run_contract(client, api) + + +@pytest.fixture(scope="module") +def references(): + return {} + + +@pytest.mark.parametrize("backend", BACKENDS) +async def test_reference_transcript_is_what_the_api_should_do(backend, references): + transcript = await reference_transcript(backend) + references[backend] = transcript + got = {step["label"]: step for step in transcript} + status = {label: step["status"] for label, step in got.items()} + expected = { + "create needs login": 401, + "malformed credentials": 401, + "create": 201, + "create second": 201, + "create public": 201, + "create duplicate sku": 409, + "create missing required": 422, + "create wrong type": 422, + "create unknown field": 422, + "create read-only field": 422, + "create private field": 422, + "create managed field": 422, + "create with id": 422, + "create not an object": 422, + "create bad json": 400, + "create wrong media type": 415, + "create empty body": 422, + "get own": 200, + "get someone else's is 404": 404, + "get anonymous is 404": 404, + "get public anonymously": 200, + "get as admin": 200, + "get malformed id": 404, + "get missing": 404, + "get not modified": 304, + "list anonymous sees public only": 200, + "list as ann": 200, + "list sorted desc": 200, + "list first page": 200, + "list bad cursor": 400, + "filter eq": 200, + "filter prefix": 200, + "filter in": 200, + "filter range": 200, + "filter int cast": 200, + "filter exists": 200, + "filter ne with total": 200, + "filter on unfilterable field": 400, + "filter on private field": 400, + "filter operator not allowed": 400, + "operator injection in brackets": 400, + "operator injection as key": 400, + "filter uncastable value": 400, + "sort on unsortable field": 400, + "limit too small": 400, + "limit not a number": 400, + "limit is capped": 200, + "page number on cursor resource": 400, + "include not allowed": 400, + "offset page 1": 200, + "offset page 2 with total": 200, + "cursor on offset resource": 400, + "method not allowed": 405, + "patch needs login": 401, + "patch": 200, + "patch stale revision": 412, + "patch current revision": 200, + "patch bad if-match": 400, + "patch null clears optional": 200, + "patch null on required": 422, + "patch wrong type": 422, + "patch insert-only field": 422, + "patch unknown field": 422, + "patch empty object": 200, + "patch someone else's is 404": 404, + "patch missing": 404, + "custom op": 200, + "custom op out of scope": 404, + "custom collection op": 200, + "delete needs login": 401, + "delete forbidden for non-admin": 403, + "delete": 204, + "delete again is 404": 404, + "list after delete": 200, + "unknown route": 404, + "unsupported method": 405, + "openapi": 200, + } + assert status == expected + + def names(label): + return [w["name"] for w in got[label]["body"]["data"]] + + created = got["create"] + assert created["headers"]["location"] == f"{1:024x}" and created["headers"]["etag"] == '"0"' + assert created["body"]["data"] == { + "id": f"{1:024x}", + "name": "anvil", + "price": 10.5, + "stock": 3, + "tags": ["heavy"], + "owner": "ann", + "sku": "A-1", + "audit": None, + "rev": 0, + } + assert "internal_note" not in created["body"]["data"] # private fields never leave + assert got["get own"]["body"]["data"]["audit"] == "created by ann" # after-hook ran + assert names("list anonymous sees public only") == ["cog"] + assert names("list as ann") == ["anvil", "cog"] + assert names("list sorted desc") == ["anvil", "cog", "bolt"] + assert names("list first page") == ["anvil", "bolt"] + assert got["list first page"]["body"]["page"] == {"size": 2, "has_next": True, "next_cursor": f"{2:024x}"} + assert names("filter eq") == ["bolt"] and names("filter prefix") == ["anvil"] + assert names("filter in") == ["anvil", "cog"] and names("filter range") == ["cog", "anvil"] + assert names("filter int cast") == ["anvil", "cog"] and names("filter exists") == [ + "anvil", + "bolt", + "cog", + ] # null counts as existing, as in MongoDB + assert got["filter ne with total"]["body"]["page"]["total"] == 2 + assert got["limit is capped"]["body"]["page"]["size"] == 100 + assert names("offset page 1") == ["anvil", "bolt"] + assert got["offset page 2 with total"]["body"]["page"] == { + "size": 2, + "has_next": False, + "number": 2, + "total": 3, + } + assert got["patch"]["body"]["data"]["price"] == 12 and got["patch"]["headers"]["etag"] == '"1"' + assert got["patch current revision"]["headers"]["etag"] == '"2"' + assert got["patch null clears optional"]["body"]["data"]["sku"] is None + assert got["custom op"]["body"]["data"]["stock"] == 7 + assert got["custom collection op"]["body"]["data"] == {"count": 3, "stock": 16} + assert names("list after delete") == ["anvil", "cog"] + assert got["delete"]["body"] is None and "content-type" not in got["delete"]["headers"] + # errors are RFC 9457 problem documents and never leak internals + for label, step in got.items(): + if step["status"] >= 400 and not step.get("framework"): + assert step["headers"]["content-type"] == "application/problem+json", label + assert step["body"]["status"] == step["status"] and step["body"]["type"] == "about:blank", label + assert got["create needs login"]["headers"]["www-authenticate"] == "Bearer" + assert got["create duplicate sku"]["body"]["errors"] == [{"field": "sku", "message": "must be unique"}] + assert got["create unknown field"]["body"]["errors"] == [{"field": "colour", "message": "unknown field"}] + assert got["create read-only field"]["body"]["errors"] == [ + {"field": "audit", "message": "cannot be set on create"} + ] + assert got["create private field"]["body"]["errors"] == [ + {"field": "internal_note", "message": "unknown field"} + ] + assert got["patch insert-only field"]["body"]["errors"] == [ + {"field": "owner", "message": "cannot be set on update"} + ] + assert got["create missing required"]["body"]["errors"][0]["field"] == "name" + assert got["patch wrong type"]["body"]["errors"][0]["field"] == "stock" + assert got["openapi"]["body"]["paths"] == [ + "/api/widget-pages", + "/api/widgets", + "/api/widgets/stats", + "/api/widgets/{id}", + "/api/widgets/{id}/restock", + ] + assert {"WidgetRead", "WidgetCreate", "WidgetPatch", "Problem"} <= set(got["openapi"]["body"]["schemas"]) + + +@pytest.mark.parametrize("backend", BACKENDS) +@pytest.mark.parametrize("plug", PLUGS) +async def test_every_plug_matches_the_reference(plug, backend, references): + reference = references.get(backend) or await reference_transcript(backend) + transcript = await transcript_for(plug, backend) + for ours, theirs in zip(transcript, reference, strict=True): + assert ours == theirs, ours["label"] diff --git a/packages/mongospecs-web/tests/test_mongo_resource.py b/packages/mongospecs-web/tests/test_mongo_resource.py new file mode 100644 index 0000000..07c4bab --- /dev/null +++ b/packages/mongospecs-web/tests/test_mongo_resource.py @@ -0,0 +1,307 @@ +"""Resources over a real database: the parts the in-memory contract cannot show.""" + +import datetime as dt +import typing as t + +import httpx +import pytest + +from mongospecs import ( + Backref, + CreatedAt, + DeletedAt, + Index, + Policy, + Q, + Ref, + Revision, + SpecConfig, + UpdatedAt, + computed, +) +from mongospecs_web import Api, Create, Delete, Get, List, Offset, Patch, Resource, Unauthorized +from mongospecs_web.plugs.asgi import AsgiApp + +BACKENDS = ("msgspec", "pydantic", "attrs") + + +def models(backend): + if backend == "msgspec": + from mongospecs.msgspec import Spec + + define = lambda c: c # noqa: E731 + elif backend == "pydantic": + from mongospecs.pydantic import Spec + + define = lambda c: c # noqa: E731 + else: + import attrs + + from mongospecs.attrs import Spec + + define = attrs.define(kw_only=True) + + @define + class Author(Spec): + __spec__ = SpecConfig(collection="authors", indexes=[Index("email", unique=True)]) + name: t.Annotated[str, Policy(filter=("eq",), sort=True)] = "" + email: t.Annotated[str, Policy(filter=("eq",))] = "" + password: t.Annotated[str | None, Policy(private=True, insert=True)] = None # write-only + + @define + class Story(Spec): + __spec__ = SpecConfig(collection="stories") + title: t.Annotated[str, Policy(filter=("eq", "prefix"), sort=True)] = "" + words: t.Annotated[int, Policy(filter=True, sort=True)] = 0 + published: t.Annotated[dt.datetime | None, Policy(filter=("gte", "lt", "exists"), sort=True)] = None + author: t.Annotated[Ref[Author] | None, Policy(filter=("eq",))] = None + reactions: t.Annotated[list[t.Any], Backref("story", model="Reaction")] = [] + reaction_count: t.Annotated[int, Backref("story", model="Reaction", count=True)] = 0 + created_at: t.Annotated[dt.datetime | None, CreatedAt] = None + updated_at: t.Annotated[dt.datetime | None, UpdatedAt] = None + deleted_at: t.Annotated[dt.datetime | None, DeletedAt, Policy(private=True)] = None + rev: t.Annotated[int, Revision] = 0 + + @computed + def reading_minutes(self) -> int: + return max(1, self.words // 200) + + @define + class Reaction(Spec): + __spec__ = SpecConfig(collection="reactions", indexes=[Index("story")]) + emoji: str = "" + story: Ref[Story] | None = None + + return Author, Story, Reaction + + +class User(t.NamedTuple): + id: str + + +def authenticate(request): + token = request.headers.get("x-user") + return User(token) if token else None + + +@pytest.fixture(params=BACKENDS) +async def app(request, make_registry): + Author, Story, Reaction = models(request.param) + registry = await make_registry(Author, Story, Reaction) + await registry.apply_indexes() + + def mine(ctx): + if ctx.principal is None: + raise Unauthorized("who are you?") + return Q.author == ctx.principal.id + + api = Api( + Resource(Author, ops=[List, Get, Create, Patch]), + Resource( + Story, ops=[List, Get, Create, Patch, Delete], include=["author", "reactions", "reaction_count"] + ), + Resource( + Story, name="my-stories", ops=[List, Get, Patch, Delete], scope=mine, page=Offset(default=10) + ), + prefix="/v1", + authenticate=authenticate, + ) + client = httpx.AsyncClient(transport=httpx.ASGITransport(app=api.asgi()), base_url="http://t") + try: + yield client, (Author, Story, Reaction), api, registry + finally: + await client.aclose() + + +async def test_crud_round_trip_with_plugins(app): + client, (Author, Story, Reaction), *_ = app + author = ( + await client.post("/v1/authors", json={"name": "Ann", "email": "a@x.io", "password": "pw"}) + ).json()["data"] + assert "password" not in author # write-only: stored, never returned + assert (await Author.collection().find_one({}))["password"] == "pw" + res = await client.post("/v1/stories", json={"title": "First", "words": 450, "author": author["id"]}) + assert res.status_code == 201 and res.headers["etag"] == '"0"' + story = res.json()["data"] + assert story["reading_minutes"] == 2 # computed property + assert story["created_at"].endswith("Z") and story["author"] == author["id"] + assert "deleted_at" not in story and "reactions" not in story + # conditional update through the real revision plugin + ok = await client.patch(f"/v1/stories/{story['id']}", json={"words": 900}, headers={"if-match": '"0"'}) + assert ok.status_code == 200 and ok.headers["etag"] == '"1"' and ok.json()["data"]["reading_minutes"] == 4 + assert ok.json()["data"]["updated_at"] >= story["updated_at"] + stale = await client.patch(f"/v1/stories/{story['id']}", json={"words": 1}, headers={"if-match": '"0"'}) + assert stale.status_code == 412 + # unique index -> 409 with the field named + dup = await client.post("/v1/authors", json={"name": "Other", "email": "a@x.io"}) + assert dup.status_code == 409 and dup.json()["errors"] == [ + {"field": "email", "message": "must be unique"} + ] + # soft delete: gone from the API, still in the collection + assert (await client.delete(f"/v1/stories/{story['id']}")).status_code == 204 + assert (await client.get(f"/v1/stories/{story['id']}")).status_code == 404 + assert (await client.get("/v1/stories")).json()["data"] == [] + assert await Story.collection().count_documents({}) == 1 + + +async def test_filters_sorting_and_cursor_pagination(app): + client, (Author, Story, Reaction), *_ = app + ann = await Author(name="Ann", email="a").insert() + bob = await Author(name="Bob", email="b").insert() + when = dt.datetime(2024, 1, 1, tzinfo=dt.UTC) + for i in range(7): + await Story( + title=f"story {i}", + words=i * 100, + author=ann if i % 2 else bob, + published=when + dt.timedelta(days=i) if i < 5 else None, + ).insert() + + def titles(response): + assert response.status_code == 200, response.text + return [s["title"] for s in response.json()["data"]] + + assert titles(await client.get("/v1/stories?words[gte]=400&sort=-words")) == [ + "story 6", + "story 5", + "story 4", + ] + assert titles(await client.get(f"/v1/stories?author={ann.id}&sort=title")) == [ + "story 1", + "story 3", + "story 5", + ] + assert titles(await client.get("/v1/stories?published[gte]=2024-01-04T00:00:00Z&sort=published")) == [ + "story 3", + "story 4", + ] + assert titles(await client.get("/v1/stories?title[prefix]=story 6")) == ["story 6"] + assert ( + titles(await client.get("/v1/stories?title[prefix]=.*")) == [] + ) # the prefix is a literal, not a regex + assert (await client.get("/v1/stories?author=not-an-id")).status_code in (200, 400) + seen, cursor = [], None + while True: + url = "/v1/stories?sort=-words&limit=3" + (f"&after={cursor}" if cursor else "") + body = (await client.get(url)).json() + seen += [s["words"] for s in body["data"]] + cursor = body["page"]["next_cursor"] + if not body["page"]["has_next"]: + break + assert seen == [600, 500, 400, 300, 200, 100, 0] + assert (await client.get("/v1/stories?limit=3&after=tampered")).status_code == 400 + total = (await client.get("/v1/stories?words[lt]=300&total=true")).json()["page"]["total"] + assert total == 3 + + +async def test_includes_embed_related_documents(app): + client, (Author, Story, Reaction), *_ = app + ann = await Author(name="Ann", email="a", password="hunter2").insert() + story = await Story(title="s", author=ann).insert() + await Reaction.insert_many([Reaction(emoji="+1", story=story), Reaction(emoji="!", story=story)]) + plain = (await client.get(f"/v1/stories/{story.id}")).json()["data"] + assert plain["author"] == str(ann.id) and "reactions" not in plain + rich = (await client.get(f"/v1/stories/{story.id}?include=author,reactions,reaction_count")).json()[ + "data" + ] + assert rich["author"] == {"id": str(ann.id), "name": "Ann", "email": "a"} # private field stays private + assert sorted(r["emoji"] for r in rich["reactions"]) == ["!", "+1"] and rich["reaction_count"] == 2 + listed = (await client.get("/v1/stories?include=author")).json()["data"] + assert listed[0]["author"]["name"] == "Ann" + assert (await client.get(f"/v1/stories/{story.id}?include=password")).status_code == 400 + + +async def test_row_scope_is_enforced_in_the_query(app): + client, (Author, Story, Reaction), *_ = app + ann = await Author(name="Ann", email="a").insert() + bob = await Author(name="Bob", email="b").insert() + hers = await Story(title="hers", author=ann).insert() + his = await Story(title="his", author=bob).insert() + as_ann = {"x-user": str(ann.id)} + assert (await client.get("/v1/my-stories")).status_code == 401 + body = (await client.get("/v1/my-stories?total=true", headers=as_ann)).json() + assert [s["title"] for s in body["data"]] == ["hers"] and body["page"] == { + "size": 10, + "has_next": False, + "number": 1, + "total": 1, + } + assert (await client.get(f"/v1/my-stories/{hers.id}", headers=as_ann)).status_code == 200 + assert (await client.get(f"/v1/my-stories/{his.id}", headers=as_ann)).status_code == 404 + assert ( + await client.patch(f"/v1/my-stories/{his.id}", json={"title": "x"}, headers=as_ann) + ).status_code == 404 + assert (await client.delete(f"/v1/my-stories/{his.id}", headers=as_ann)).status_code == 404 + assert (await Story.get(his.id)).title == "his" + assert ( + await client.patch(f"/v1/my-stories/{hers.id}", json={"title": "mine"}, headers=as_ann) + ).status_code == 200 + assert (await client.delete(f"/v1/my-stories/{hers.id}", headers=as_ann)).status_code == 204 + + +async def test_validation_never_reaches_the_database(app): + client, (Author, Story, Reaction), _, registry = app + for body in ({"title": 5}, {"title": "t", "words": "many"}, {"title": "t", "author": {"$ne": None}}): + res = await client.post("/v1/stories", json=body) + assert res.status_code == 422, (body, res.text) + story = await Story(title="t").insert() + for body in ( + {"words": {"$inc": 1}}, + {"title": None}, + {"rev": 5}, + {"deleted_at": None}, + {"reactions": []}, + ): + res = await client.patch(f"/v1/stories/{story.id}", json=body) + assert res.status_code == 422, (body, res.text) + for query in ("words[$gt]=1", "$where=sleep(1)", "words[regex]=.*", "deleted_at[exists]=true", "rev=0"): + assert (await client.get(f"/v1/stories?{query}")).status_code == 400, query + raw = await Story.collection().find_one({"_id": story.id}) + assert raw["title"] == "t" and raw["words"] == 0 and raw["rev"] == 0 + assert await Story.collection().count_documents({}) == 1 + + +async def test_unexpected_errors_do_not_leak(app): + client, (Author, Story, Reaction), api, _ = app + stories = api.resources[1] + + @stories.op("explode", method="POST", path="/explode") + async def explode(ctx): + raise RuntimeError("secret connection string mongodb://user:pw@host") + + res = await client.post("/v1/stories/explode") + assert res.status_code == 500 + assert res.json() == {"type": "about:blank", "title": "Internal Server Error", "status": 500} + + +async def test_body_size_limit_and_lifespan(make_registry): + from mongospecs import Registry + + Author, Story, Reaction = models("msgspec") + registry = Registry( + "mongodb://localhost:27117/?replicaSet=rs0", database="ms_web_lifespan", models=[Author] + ) + api = Api(Resource(Author), max_body=64) + asgi = AsgiApp(api, registry=registry) + sent = [] + messages = iter([{"type": "lifespan.startup"}, {"type": "lifespan.shutdown"}]) + + async def receive(): + message = next(messages) + if message["type"] == "lifespan.shutdown": + assert registry.is_open + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=asgi), base_url="http://t" + ) as client: + big = await client.post("/authors", json={"name": "x" * 200}) + assert big.status_code == 413 and big.json()["status"] == 413 + assert (await client.post("/authors", json={"name": "ok"})).status_code == 201 + await registry.client.drop_database("ms_web_lifespan") + return message + + async def send(message): + sent.append(message["type"]) + + await asgi({"type": "lifespan"}, receive, send) + assert sent == ["lifespan.startup.complete", "lifespan.shutdown.complete"] + assert not registry.is_open diff --git a/packages/mongospecs-web/tests/test_openapi_views.py b/packages/mongospecs-web/tests/test_openapi_views.py new file mode 100644 index 0000000..0fc2fb2 --- /dev/null +++ b/packages/mongospecs-web/tests/test_openapi_views.py @@ -0,0 +1,157 @@ +import httpx +import pytest +from openapi_spec_validator import validate + +from mongospecs_web import Api, Resource, view_of +from mongospecs_web.policy import jsonable +from mongospecs_web.testing import contract_api + +BACKENDS = ("msgspec", "pydantic", "attrs") + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_openapi_document_is_valid(backend): + spec = contract_api(backend).openapi() + validate(spec) + assert spec["openapi"] == "3.1.0" and spec["info"] == {"title": "Contract", "version": "1.0.0"} + listing = spec["paths"]["/api/widgets"]["get"] + names = [p["name"] for p in listing["parameters"]] + assert { + "name", + "name[prefix]", + "name[in]", + "price[gte]", + "stock[lte]", + "sort", + "limit", + "after", + "total", + } <= set(names) + assert "internal_note" not in str(names) and "tags" not in names + assert "page" not in names + assert "page" in [p["name"] for p in spec["paths"]["/api/widget-pages"]["get"]["parameters"]] + patch = spec["paths"]["/api/widgets/{id}"]["patch"] + assert patch["requestBody"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/WidgetPatch" + } + assert {"404", "409", "412", "422", "401", "403"} <= set(patch["responses"]) + assert "If-Match" in [p["name"] for p in patch["parameters"]] + assert "204" in spec["paths"]["/api/widgets/{id}"]["delete"]["responses"] + assert spec["paths"]["/api/widgets/{id}/restock"]["post"]["summary"] == "Add stock to a widget" + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_view_schemas_follow_the_policy(backend): + api = contract_api(backend) + view = api.resources[0].view + read, create, patch = view.schema("read"), view.schema("create"), view.schema("patch") + assert "internal_note" not in read["properties"] and "id" in read["properties"] + assert set(create["properties"]) == {"name", "price", "stock", "tags", "owner", "sku"} + assert create["required"] == ["name"] and create["additionalProperties"] is False + assert set(patch["properties"]) == {"name", "price", "stock", "tags", "sku"} # owner is insert-only + assert "required" not in patch + assert view.filterable["name"] == ("eq", "prefix", "in") and view.sortable == {"name", "price"} + assert view.revision == "rev" and view_of(view.model) is view + assert read["properties"]["id"].get("anyOf") or read["properties"]["id"].get("type") + + +def test_jsonable_covers_bson_types(): + import datetime as dt + import decimal + import enum + import uuid + + from bson import ObjectId + from bson.decimal128 import Decimal128 + + class Shade(enum.Enum): + DARK = "dark" + + oid = ObjectId() + uid = uuid.uuid4() + out = jsonable( + { + "o": oid, + "d": dt.datetime(2024, 1, 2, 3, 4, 5, tzinfo=dt.UTC), + "day": dt.date(2024, 1, 2), + "n": decimal.Decimal("1.50"), + "n128": Decimal128("2.5"), + "u": uid, + "e": Shade.DARK, + "b": b"\x00\x01", + "s": {3, 3}, + "nested": [{"o": oid}], + } + ) + assert out == { + "o": str(oid), + "d": "2024-01-02T03:04:05Z", + "day": "2024-01-02", + "n": "1.50", + "n128": "2.5", + "u": str(uid), + "e": "dark", + "b": "AAE=", + "s": [3], + "nested": [{"o": str(oid)}], + } + + +@pytest.mark.parametrize("backend", BACKENDS) +async def test_fastapi_documents_any_backend(backend): + """Spike S5: externally supplied schema fully replaces FastAPI's pydantic-only generation.""" + from fastapi import FastAPI + + from mongospecs_web.plugs.fastapi import mount + + api = contract_api(backend) + app = FastAPI(title="Host app") + + @app.get("/health") + def health() -> dict[str, str]: + return {"status": "ok"} + + mount(app, api) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client: + spec = (await client.get("/openapi.json")).json() + assert (await client.get("/docs")).status_code == 200 + assert (await client.get("/health")).json() == {"status": "ok"} + validate(spec) + ours = api.openapi() + assert "/health" in spec["paths"] # the host application's own routes are untouched + for path, methods in ours["paths"].items(): + for method, operation in methods.items(): + theirs = spec["paths"][path][method] + assert theirs["responses"] == operation["responses"], (path, method) + assert theirs.get("parameters") == operation.get("parameters"), (path, method) + assert theirs.get("requestBody") == operation.get("requestBody"), (path, method) + assert theirs["operationId"] == operation["operationId"] + assert spec["components"]["schemas"]["WidgetRead"] == ours["components"]["schemas"]["WidgetRead"] + assert "HTTPValidationError" not in str(spec["paths"]["/api/widgets"]) + + +def test_resource_declaration_errors(): + api = contract_api("msgspec") + model = api.resources[0].model + with pytest.raises(ValueError, match="unknown operation"): + Resource(model, ops=["explode"]) + with pytest.raises(ValueError, match="not a readable relation"): + Resource(model, include=["name"]) + with pytest.raises(ValueError, match="unique"): + Api(Resource(model), Resource(model)) + assert Api(Resource(model), prefix="api/").prefix == "/api" and Api(Resource(model)).prefix == "" + assert api.resources[0].operation("restock").path == "/{id}/restock" + with pytest.raises(KeyError): + api.resources[0].operation("nope") + + +async def test_api_handle_without_a_server(): + api = contract_api("msgspec") + created = await api.handle( + "POST", "/api/widgets", body=b'{"name":"direct"}', headers={"authorization": "Bearer ann"} + ) + assert created.status == 201 and b'"direct"' in created.body + assert (await api.handle("GET", "/api/widgets/")).status == 200 # trailing slash tolerated + assert (await api.handle("GET", "/api/widgets/stats")).status == 200 # literal segment beats {id} + assert (await api.handle("GET", "/api/nope")).status == 404 + assert (await api.handle("PUT", "/api/widgets")).headers["allow"] == "GET, POST" diff --git a/packages/mongospecs/src/mongospecs/markers.py b/packages/mongospecs/src/mongospecs/markers.py index a59dde0..e92a468 100644 --- a/packages/mongospecs/src/mongospecs/markers.py +++ b/packages/mongospecs/src/mongospecs/markers.py @@ -109,9 +109,13 @@ class Policy(Marker): bounded set, see :data:`FILTER_OPERATORS`). """ - insert: bool = True - update: bool = True + insert: bool | None = None + """May be set when creating. Defaults to ``True``, or ``False`` for a private field.""" + update: bool | None = None + """May be changed afterwards. Defaults to ``True``, or ``False`` for a private field.""" private: bool = False + """Never returned, filterable or sortable. Also not writable unless ``insert``/``update`` say so + explicitly (``Policy(private=True, insert=True)`` is a write-only field such as a password).""" filter: tuple[str, ...] | bool = () sort: bool = False @@ -121,6 +125,14 @@ def __post_init__(self) -> None: if unknown: raise ValueError(f"unknown filter operators {unknown}; allowed: {list(FILTER_OPERATORS)}") + @property + def can_insert(self) -> bool: + return (not self.private) if self.insert is None else self.insert + + @property + def can_update(self) -> bool: + return (not self.private) if self.update is None else self.update + @property def filter_ops(self) -> tuple[str, ...]: if self.filter is True: diff --git a/packages/mongospecs/src/mongospecs/meta.py b/packages/mongospecs/src/mongospecs/meta.py index ab3fd20..a7d501d 100644 --- a/packages/mongospecs/src/mongospecs/meta.py +++ b/packages/mongospecs/src/mongospecs/meta.py @@ -165,8 +165,12 @@ def field_for(self, path: str) -> FieldInfo | None: # -- casting --------------------------------------------------------------- - def cast(self, path: str, value: t.Any) -> t.Any: - """Coerce a filter value to the type of the field at ``path`` and make it BSON-ready.""" + def cast(self, path: str, value: t.Any, *, lax: bool = False) -> t.Any: + """Coerce a filter value to the type of the field at ``path`` and make it BSON-ready. + + ``lax`` also accepts strings for numbers and booleans, for values that + arrive as text (URL query strings). + """ if value is None: return None info = self.fields.get(path) or self.by_stored.get(path) @@ -180,12 +184,12 @@ def cast(self, path: str, value: t.Any) -> t.Any: if loose is None: loose = self._loose[info.name] = loosen(info.type) try: - return to_bson(self.validate_type(loose, value)) + return to_bson(self.validate_type(loose, value, lax=lax)) except ValidationError as exc: elem = element_type(loose) if elem is not None and not isinstance(value, list | tuple | set | frozenset): try: - return to_bson(self.validate_type(elem, value)) + return to_bson(self.validate_type(elem, value, lax=lax)) except ValidationError: pass raise QueryError(f"cannot cast {value!r} to {self.model.__name__}.{info.name}: {exc}") from exc @@ -200,7 +204,7 @@ def _cast_ref(self, info: FieldInfo, value: t.Any) -> t.Any: return ObjectId(value) return to_bson(value) - def validate_type(self, tp: t.Any, value: t.Any) -> t.Any: + def validate_type(self, tp: t.Any, value: t.Any, *, lax: bool = False) -> t.Any: """Validate a value against a bare type with this model's backend.""" if self.codec.name == "pydantic": import pydantic @@ -224,7 +228,7 @@ def validate_type(self, tp: t.Any, value: t.Any) -> t.Any: from mongospecs.msgspec import dec_hook try: - return msgspec.convert(value, tp, dec_hook=dec_hook) + return msgspec.convert(value, tp, dec_hook=dec_hook, strict=not lax) except (msgspec.ValidationError, ValueError) as exc: raise ValidationError(str(exc)) from exc diff --git a/packages/mongospecs/src/mongospecs/msgspec.py b/packages/mongospecs/src/mongospecs/msgspec.py index 2e8a4dc..7550b36 100644 --- a/packages/mongospecs/src/mongospecs/msgspec.py +++ b/packages/mongospecs/src/mongospecs/msgspec.py @@ -18,6 +18,7 @@ __all__ = ["CODEC", "MsgspecCodec", "Spec", "SpecAdapter"] _PATH = re.compile(r" - at `\$\.([^`.\[]+)") +_MISSING = re.compile(r"missing required field `([^`]+)`") def dec_hook(tp: t.Any, obj: t.Any) -> t.Any: @@ -41,7 +42,7 @@ def schema_hook(tp: t.Any) -> dict[str, t.Any]: def _error(cls: type, exc: Exception, name: str | None = None) -> ValidationError: message = str(exc) - match = _PATH.search(message) + match = _PATH.search(message) or _MISSING.search(message) field = name or (match.group(1) if match else None) return field_error(cls, field, _PATH.sub("", message) if name else message) diff --git a/packages/mongospecs/tests/test_registry_errors.py b/packages/mongospecs/tests/test_registry_errors.py index 44a6221..50d0daa 100644 --- a/packages/mongospecs/tests/test_registry_errors.py +++ b/packages/mongospecs/tests/test_registry_errors.py @@ -230,7 +230,8 @@ def capture_envelope(self, envelope): await m.User.count() # outside any transaction: nothing to attach to, nothing sent sentry_sdk.flush() finally: - sentry_sdk.init() # switch the SDK off again + # Switch the SDK off again, without letting it auto-enable framework integrations. + sentry_sdk.init(dsn=None, default_integrations=False, auto_enabling_integrations=False) (event,) = captured spans = event["spans"] assert [s["description"] for s in spans] == ["User.insert", "User.find", "User.aggregate"] diff --git a/pyproject.toml b/pyproject.toml index 56353c4..55cdf73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -28,6 +28,7 @@ dev = [ "httpx>=0.27", "opentelemetry-sdk>=1.20", "sentry-sdk>=2.0", + "openapi-spec-validator>=0.7", ] docs = [ "mkdocs-material>=9.5", diff --git a/uv.lock b/uv.lock index a97f539..249bda8 100644 --- a/uv.lock +++ b/uv.lock @@ -532,6 +532,87 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899 }, ] +[[package]] +name = "jsonschema" +version = "4.26.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "jsonschema-specifications" }, + { name = "referencing" }, + { name = "rpds-py" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/69/90/f63fb5873511e014207a475e2bb4e8b2e570d655b00ac19a9a0ca0a385ee/jsonschema-4.26.0-py3-none-any.whl", hash = "sha256:d489f15263b8d200f8387e64b4c3a75f06629559fb73deb8fdfb525f2dab50ce", size = 90630 }, +] + +[[package]] +name = "jsonschema-path" +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "pathable" }, + { name = "pyyaml" }, + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/39/79/cd02a4df6d9270efdc7d3feefe6edd730b0820c39eeaa107a2faee8322d5/jsonschema_path-0.5.0.tar.gz", hash = "sha256:493b156ba895c97602655b620a8456caa2ce08c1aa389f5a7addec065e6e855c", size = 19597 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/2c/9e69d73c4297508be9e3b64a970ea3971b3eb8db64ffc5802d40bd25981f/jsonschema_path-0.5.0-py3-none-any.whl", hash = "sha256:2790a070bc7abb08ea3dbe4d340ece4efadf639223001f020c7503229ba068e2", size = 24077 }, +] + +[[package]] +name = "jsonschema-specifications" +version = "2025.9.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "referencing" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/19/74/a633ee74eb36c44aa6d1095e7cc5569bebf04342ee146178e2d36600708b/jsonschema_specifications-2025.9.1.tar.gz", hash = "sha256:b540987f239e745613c7a9176f3edb72b832a4ac465cf02712288397832b5e8d", size = 32855 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/41/45/1a4ed80516f02155c51f51e8cedb3c1902296743db0bbc66608a0db2814f/jsonschema_specifications-2025.9.1-py3-none-any.whl", hash = "sha256:98802fee3a11ee76ecaca44429fda8a41bff98b00a0f2838151b113f210cc6fe", size = 18437 }, +] + +[[package]] +name = "lazy-object-proxy" +version = "1.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf69cd1a6c7fe2dbcc3edaa017cf010f4192e53796538cc7d5e1fedbfa4bcff", size = 26656 }, + { url = "https://files.pythonhosted.org/packages/3a/03/1bdc21d9a6df9ff72d70b2ff17d8609321bea4b0d3cffd2cea92fb2ef738/lazy_object_proxy-1.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efff4375a8c52f55a145dc8487a2108c2140f0bec4151ab4e1843e52eb9987ad", size = 68832 }, + { url = "https://files.pythonhosted.org/packages/3d/4b/5788e5e8bd01d19af71e50077ab020bc5cce67e935066cd65e1215a09ff9/lazy_object_proxy-1.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1192e8c2f1031a6ff453ee40213afa01ba765b3dc861302cd91dbdb2e2660b00", size = 69148 }, + { url = "https://files.pythonhosted.org/packages/79/0e/090bf070f7a0de44c61659cb7f74c2fe02309a77ca8c4b43adfe0b695f66/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3605b632e82a1cbc32a1e5034278a64db555b3496e0795723ee697006b980508", size = 67800 }, + { url = "https://files.pythonhosted.org/packages/cf/d2/b320325adbb2d119156f7c506a5fbfa37fcab15c26d13cf789a90a6de04e/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a61095f5d9d1a743e1e20ec6d6db6c2ca511961777257ebd9b288951b23b44fa", size = 68085 }, + { url = "https://files.pythonhosted.org/packages/6a/48/4b718c937004bf71cd82af3713874656bcb8d0cc78600bf33bb9619adc6c/lazy_object_proxy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:997b1d6e10ecc6fb6fe0f2c959791ae59599f41da61d652f6c903d1ee58b7370", size = 26535 }, + { url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746 }, + { url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457 }, + { url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036 }, + { url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329 }, + { url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690 }, + { url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563 }, + { url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745 }, + { url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537 }, + { url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141 }, + { url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449 }, + { url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744 }, + { url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568 }, + { url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391 }, + { url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552 }, + { url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857 }, + { url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833 }, + { url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516 }, + { url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656 }, + { url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582 }, + { url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059 }, + { url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034 }, + { url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529 }, + { url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391 }, + { url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988 }, + { url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072 }, +] + [[package]] name = "librt" version = "0.16.0" @@ -1086,6 +1167,7 @@ dev = [ { name = "mongospecs-bench" }, { name = "mongospecs-web", extra = ["all"] }, { name = "mypy" }, + { name = "openapi-spec-validator" }, { name = "opentelemetry-sdk" }, { name = "pytest" }, { name = "pytest-asyncio" }, @@ -1106,6 +1188,7 @@ dev = [ { name = "mongospecs-bench", editable = "bench" }, { name = "mongospecs-web", extras = ["all"], editable = "packages/mongospecs-web" }, { name = "mypy", specifier = ">=1.13" }, + { name = "openapi-spec-validator", specifier = ">=0.7" }, { name = "opentelemetry-sdk", specifier = ">=1.20" }, { name = "pytest", specifier = ">=8.3" }, { name = "pytest-asyncio", specifier = ">=0.25" }, @@ -1432,6 +1515,40 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963 }, ] +[[package]] +name = "openapi-schema-validator" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-specifications" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, + { name = "referencing" }, + { name = "rfc3339-validator" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/98/e8/ab3f27dbca54ec645f7fab714b640907d5d36c2ebb07e87eebd30bd5c81b/openapi_schema_validator-0.9.0.tar.gz", hash = "sha256:b72db64315b89d21834cd3ffef37e3e6893bc876327be2d366e8424b1029afd3", size = 24686 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/90/c0/5467967d95378b2cfce312e09cbd0c9ab64354a0922379b734f793edd04f/openapi_schema_validator-0.9.0-py3-none-any.whl", hash = "sha256:faa3bbe7c3aa8ca2087ad83f709dc3b7d920283153a570c03e24ea182558aa25", size = 19980 }, +] + +[[package]] +name = "openapi-spec-validator" +version = "0.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "jsonschema" }, + { name = "jsonschema-path" }, + { name = "lazy-object-proxy" }, + { name = "openapi-schema-validator" }, + { name = "pydantic" }, + { name = "pydantic-settings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8f/d2/640b5149cd5688bc0ad1fdbb4df6a2f7b84a093c8d787c27d566132f8b8b/openapi_spec_validator-0.9.0.tar.gz", hash = "sha256:6d648cff6490ebb799dcfe273792f2941c050158854c721f086599d845da78b8", size = 1756839 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/95/d8/321ff889330acca2e3097f3d4f80a40bcc41b6d34d302978ab32c449520b/openapi_spec_validator-0.9.0-py3-none-any.whl", hash = "sha256:222fecffc7714f6d0a6ad62c0e4b66cc2b7dbfafb7b93acfc6c308abbdb51af8", size = 50328 }, +] + [[package]] name = "opentelemetry-api" version = "1.45.1" @@ -1489,6 +1606,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, ] +[[package]] +name = "pathable" +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/66/f3/5a20387de9bcd0607871bfc2198ee0e15836da7baa4592ccd7f24c27c986/pathable-0.6.0.tar.gz", hash = "sha256:6404b8b82aef5ff0fd478934137128b99b12212ba35afdde5525ca4f8388ea58", size = 18970 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/e8/6d75ffd9784bce2e93d1ae4415649427e39a53bb172d4672b2b59c6f0a7b/pathable-0.6.0-py3-none-any.whl", hash = "sha256:82c4ca6c98c502ad12e0d4e9779b6210afee93c38990988c8c5d1b49bdcdf566", size = 18983 }, +] + [[package]] name = "pathspec" version = "1.1.1" @@ -1678,6 +1804,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/24/f3/d15bc0b1fb0c4f1e07b3326ffb7489f7ae70da2b56a0884d22641b0eb47c/pydantic_core-2.50.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:7f476456ac2bb0d937f75191494a09c83a30765fea4f70f3b404942fe25f6cdf", size = 2336787 }, ] +[[package]] +name = "pydantic-settings" +version = "2.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pydantic" }, + { name = "python-dotenv" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/ca/31c57507b13119d7d3cfa1576dad2911a4861e3be07b579395f4e9d393f9/pydantic_settings-2.15.0.tar.gz", hash = "sha256:694b793e84f766ba76a90ebdefc01d0a9a045dab0382bee70393da93712ad117", size = 261253 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/30/a4/2bffa9f8e804325a09867f0e9d30795c80ea9f8d62560bd1b6ad6220eb2f/pydantic_settings-2.15.0-py3-none-any.whl", hash = "sha256:0ba092c291c94baceb5eff768aa0d56400a457585bc0175925a5a5510303da42", size = 69413 }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -1802,6 +1942,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, ] +[[package]] +name = "python-dotenv" +version = "1.2.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/74/26/2fbeedb218a787a5eea551c7532cac4e009f83d689dd2faa0d0353473f86/python_dotenv-1.2.4.tar.gz", hash = "sha256:f0d53e69935a851c0dcc78f3ab7aaccd8cabef0b92382b576b824212902873c0", size = 60824 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/d1/38f3a3405989a89ac18390803e70c6ad7c7760da4f9b83cbeca0c44a0c72/python_dotenv-1.2.4-py3-none-any.whl", hash = "sha256:42269a8a5b3fd54ffa6f3d84b18abed50064717576b4ecf03dc4a55d8aa04fdc", size = 23266 }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -1916,6 +2065,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5c/c1/26dca56249da1a889ebb946000ab272712476209234f714ad3e8013ee005/quart-0.23.1-py3-none-any.whl", hash = "sha256:78cf3a7249ab09f9e03d78b0b5e2472c4c09ce4615a99c2b1aa9a35261243b66", size = 79388 }, ] +[[package]] +name = "referencing" +version = "0.37.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "attrs" }, + { name = "rpds-py" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, +] + [[package]] name = "requests" version = "2.34.2" @@ -1931,6 +2094,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, ] +[[package]] +name = "rfc3339-validator" +version = "0.1.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490 }, +] + [[package]] name = "rich" version = "15.0.0" @@ -1958,6 +2133,141 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b5/51/b2b91aec091e86bc961e5164f00207d89f133cc82b5ddad541d2132ea819/rich_click-1.9.9-py3-none-any.whl", hash = "sha256:365e7a9d0adb42e41ea832a0a12e02c44c079a26536dee688125eb9814f97274", size = 71910 }, ] +[[package]] +name = "rpds-py" +version = "2026.9.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/42/68/3bd46b8a5e01d3c2ebdf9c5e9497912e3fe0cde02bac21a7130ca866e403/rpds_py-2026.9.1.tar.gz", hash = "sha256:4793ef7f78268b124b73fa933440f01d258bbae01de9fa53e9080c9ab0425a12", size = 63948 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/6d/ddb8dfcf41892ba9c672d27988592c038d30db61708230313831b6780a1b/rpds_py-2026.9.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:2711d29b653b3bce48a63d18b9c6b53274669e6d6c4094dddeb4d9a0e45128b2", size = 344947 }, + { url = "https://files.pythonhosted.org/packages/9e/b2/632c3d034961149eff606e3e357547620a9ab37b849ae5815f67838753fd/rpds_py-2026.9.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:3231c4c0e521dafa5be0c9f114ee2c2ad46650836f2d72caa86801950c3e7044", size = 346770 }, + { url = "https://files.pythonhosted.org/packages/6d/45/4df346410bb873d03ec60c3b891d1fceb8cabde1971d29844bbb625cc296/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e01b3c878c8641913e688edd1b3f08658c6783d29cf6b826bd3c0d1ae7a1ffaa", size = 377795 }, + { url = "https://files.pythonhosted.org/packages/ba/b8/a70f302207e4f29f873c01a986fcac5def49b9c8224328cef1ad7da6b7a9/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3e524c7874ac72884d28e16dd5b8d839fd09e0fe76b020d3fbca23212a7b8c52", size = 383137 }, + { url = "https://files.pythonhosted.org/packages/13/e3/3aa28a48a01169d0e20728bf0e5ebbea3ae2774285b40853ae49b3ef332a/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:761fdae6728ceb99ab182fad2f0cc1e262f610834dc891aea1d1a2a2e634776f", size = 496789 }, + { url = "https://files.pythonhosted.org/packages/3e/a7/414e8d088602d66ad6f47c65bf07fe5090992a8574cdd4571c1a83b647ed/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b723eb406dec5bc9ec516c73ab9c3239a3284e017f7eb89ee2b3258bd504fb7", size = 395929 }, + { url = "https://files.pythonhosted.org/packages/2d/5c/aac3b960fd55d37b49f77c0eebbe043eaf36762ebd48e7b46951e59c1c05/rpds_py-2026.9.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:136a1c3fe4402b7008bc81cb62ee538481795b61a7e83df88dff3b3f02b726ff", size = 375426 }, + { url = "https://files.pythonhosted.org/packages/d3/80/77987a41c40be7202a78d9aed4a650bfb05792237cfd3b9e22638d990fe5/rpds_py-2026.9.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:839dde845559254f34885267c6878f60d61d5205180226d976fe488d45fa128e", size = 381497 }, + { url = "https://files.pythonhosted.org/packages/b8/d0/640b02eb1ecd71a913c1178b6235287b31006266bca9a26862e80e497b54/rpds_py-2026.9.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:56cd8b3f77d7b6812f533b662186a1f28316931166ddc00fb893b1b0db7e9888", size = 405740 }, + { url = "https://files.pythonhosted.org/packages/3e/8c/d034195d45d215625a23f2ae85dbbdb86a49a93a51272061c97c80b9d316/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:074a4d198bc34d9a8ea425114fc3ded6d11ec01f6a314a8db67454a5152d8834", size = 555850 }, + { url = "https://files.pythonhosted.org/packages/de/f8/532f1addfc86207bc3cf23749638bb0d457bce891595012ed8a08d0543b8/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:6beb738155fe8ab8091afdfa5a3226b21c2b1593f1e50ebb90eb25b44dbc0391", size = 618661 }, + { url = "https://files.pythonhosted.org/packages/50/5b/b0f2c9833a36a5b1f6ac5cb0e635fabacf713266f46ceefba2102da0bf51/rpds_py-2026.9.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:57492a550a1d88d29d003247e5f78dd8cf04a701fac0e4c8db8745a6d2504e0a", size = 583530 }, + { url = "https://files.pythonhosted.org/packages/bf/20/464ed4b98d3c8c6a334f3b5e24d3886de04b05658a95fc40a37e0e6e4775/rpds_py-2026.9.1-cp311-cp311-win32.whl", hash = "sha256:d95a354e02393eada6d7351184671aced9d4cce109dabf927cb7aa99624352a1", size = 208125 }, + { url = "https://files.pythonhosted.org/packages/df/c9/384a9b62f8cd89c8e3e609b8c9052c140e8c01d807261e0517c904b8bf29/rpds_py-2026.9.1-cp311-cp311-win_amd64.whl", hash = "sha256:ce4d4f52e2a4324396caddbd45a97d8d7be5f42edd25d2355282a9c34f9b2f7f", size = 226045 }, + { url = "https://files.pythonhosted.org/packages/4f/3b/0a691f0dcb2301fa9c736346da2d0d4991778373283301c5c12b8f952f3d/rpds_py-2026.9.1-cp311-cp311-win_arm64.whl", hash = "sha256:fdcd198979b4ecffcc1beba366a7fbcf4eb41243691a82fe52ceb0b902f09c12", size = 222636 }, + { url = "https://files.pythonhosted.org/packages/5d/34/a828586ea3329fbb50895b50e9cf98ca3d924a9f41a0b26d443bff1b3794/rpds_py-2026.9.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:50906f5aea24b5a865cbd0a589698288631d9f3a54c3a937c83aefa95a0d14af", size = 346822 }, + { url = "https://files.pythonhosted.org/packages/90/81/ac6a0d064982251856ce009c9e1dd51a34110b3c055aa7d1aad18b4899a3/rpds_py-2026.9.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e21c1429e205828ea886a2293a4a2c8e01f4c25d9893ca330e97a6cf73f52e7b", size = 341380 }, + { url = "https://files.pythonhosted.org/packages/42/ff/bf7d54f362748fd6a49277b9d6531c394074110fedf50ad791ec59133fbb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2693b2728bbcc48d09a981a356954b0c47c53ff25b545856f28a889ea619f69a", size = 373053 }, + { url = "https://files.pythonhosted.org/packages/60/de/74b0ccdbbd28687b9b5fdb34c1cabed88352182facaced9d6f5486b8b9ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8601470267d938bcb7f3ab1a336100af51a4fd5b6ed030ef52461bb3ef5e7e07", size = 377742 }, + { url = "https://files.pythonhosted.org/packages/c2/6d/b979775a3057b2a5c26d75ecbff60a20a24ef081db6dd76836fca3f20247/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3890a6aa36e6baa53d5258a2a25d3ef8b37ad165a6ab27a892d7c3e3a432cd69", size = 487289 }, + { url = "https://files.pythonhosted.org/packages/1d/5d/7c34734ce3ece943d9d6ee0122a74a21bbc75b47acb5b7053c83f6efb1ed/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6b5b393eda5ea42cca1c1a6665f2a4882b4fd5d1777e41ce0545a107fb008c9d", size = 390710 }, + { url = "https://files.pythonhosted.org/packages/1d/6d/b26eb1e75395925b3a142ed351cbed2ec8212f5c9d937aee3df32c701baa/rpds_py-2026.9.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:addeda51556dac7c1a2f14cda62db8b621cd12afba3091d03a96c72932387eab", size = 371261 }, + { url = "https://files.pythonhosted.org/packages/7c/98/b2fdfe10301a9e27af21e634337fbba7baac0bc17fe44619a17c26bba2cb/rpds_py-2026.9.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:d9edf30457d74eebfd76b045535e36f1cd89062566a128a0db2145ca042d787e", size = 376075 }, + { url = "https://files.pythonhosted.org/packages/c5/b1/c4b8d954e49c3c69cf8063c0c9e0919c99f3d2cecc46311606a1cbe835dc/rpds_py-2026.9.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:815d26356930846a40c7bc1366e7b1b0320ab8a063e66c11298a208bed0fd237", size = 399358 }, + { url = "https://files.pythonhosted.org/packages/b3/59/9559a7293c97dff0cbb293efffbd36644103883ecc27741af8ef90c84ca8/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3b5a6f40f0a1486b4b36c888123afc67acdbd9f33235927acf5ff295429a0ba3", size = 550267 }, + { url = "https://files.pythonhosted.org/packages/6c/9d/6dc60e49511de4c8b00e74f9c1d43aed4d27f712cdb1ade92f2c199cbf64/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b5b8b0753718d258fd454283fbd57e14545d3b40583fa672e27cb4f987626bcc", size = 614018 }, + { url = "https://files.pythonhosted.org/packages/fb/bc/92a4ecf27301b886232a413360f6b348a81d55ffef654f2ebda5970ebc3d/rpds_py-2026.9.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:46d80bc76b51a6c24f9944368c28d38b8bcbcea1da4f2f8d3ebc31a67e8c6ec6", size = 578509 }, + { url = "https://files.pythonhosted.org/packages/08/53/efb97f2e6589b7ab8394385a73870e0b2ab2a195281c117b21fd6d7d4855/rpds_py-2026.9.1-cp312-cp312-win32.whl", hash = "sha256:befc2d6a953e563f8a7bfd87a42c22ebf8a3e980dcb7b6a4d17b70b0e914e8a3", size = 205401 }, + { url = "https://files.pythonhosted.org/packages/1c/84/1700cc748d0eaa747486e4e82882d2575224e6e9a3148df583058850c629/rpds_py-2026.9.1-cp312-cp312-win_amd64.whl", hash = "sha256:5ce8943f79c2210f7abcc28e86367b03b28d95027fd01c46d2472373ae70c86f", size = 222923 }, + { url = "https://files.pythonhosted.org/packages/c3/89/0302215373c2f8b4408b0fdfee955368cc378a98ff59508d47a7e0dc2dbf/rpds_py-2026.9.1-cp312-cp312-win_arm64.whl", hash = "sha256:501909f2e4a1e2dee528ef766fe3c469060ebc17e54a8383d404ba07a81a6f02", size = 218415 }, + { url = "https://files.pythonhosted.org/packages/83/ea/ee88fd9e756ff93fb6b1182a47ec09504a242620e33ce1d20679efefe841/rpds_py-2026.9.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:a36b70596407634ca82d4b989a3729074a008537a0522e4c8046a67c729103e9", size = 346229 }, + { url = "https://files.pythonhosted.org/packages/57/71/a097d6552f837500fc36e6b23d09cfb9890c3cc47531f9ca64e149799615/rpds_py-2026.9.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eba5d173f7d5708b22a93815017a4611873ed54db9f268077c0dd1ed99cfc858", size = 340514 }, + { url = "https://files.pythonhosted.org/packages/bd/b7/497e85768bf4e0d8ddbaa096a4cac31d1509251dee2728a8490aa367e0b5/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:457866b85daf5034296666168b84a69e0b2e89dc4f1af102b46f6448a60b9063", size = 372611 }, + { url = "https://files.pythonhosted.org/packages/52/4b/74ab4108916250b6e198e0d3af05bc6835eb046315f22f7a0ceb49667c5a/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a3a52a3ba86436ab3aef510fbe21512abc2ddd1993005dfe50514bd2284ef025", size = 377712 }, + { url = "https://files.pythonhosted.org/packages/0c/8e/067e77d9d7b3cc793c9d909b7e97e7aadbbb1fb094093b6876902cc96d38/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d7841166b7fa64c9c56404617ae4341448847482d45933b13135d26c130519e5", size = 488106 }, + { url = "https://files.pythonhosted.org/packages/3c/b4/c5aae6c2dde269bf955f6b7d35065c655a57e47750d9668052ad67e74dda/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:926bdd3e3b5998ddf70cc64bc8cf57209571f9044542913afb673799fec77dd0", size = 390795 }, + { url = "https://files.pythonhosted.org/packages/a0/36/76fab39973ee11e7f9f357c55138197bb01c86f6502cb76487e3b4f42db0/rpds_py-2026.9.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7868b85224291c6cb6759f9b5adb9745f486d226f62b16a614dd5a2a5ab2b35b", size = 370794 }, + { url = "https://files.pythonhosted.org/packages/3d/fe/cd2a80e6d7b871937a60e935c5d507aa390d143f4ff3636f640b9733d5df/rpds_py-2026.9.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:3cd182d7291d29b92c521a0069d9c01ba6193628a9a105531d11b40a6d731a33", size = 375673 }, + { url = "https://files.pythonhosted.org/packages/6c/18/7464a9953724e55a3b3206062fa0ffdeaa519584c6aabf65d3956d94f131/rpds_py-2026.9.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e6ea1cda8d8c688278430e4268a42f5e5da3bdd74578dfadc0820c3f1766ce83", size = 398758 }, + { url = "https://files.pythonhosted.org/packages/c0/86/1534b436700fd49ff411063b7c4d7e938adfabf90895b6cf1622d5a7d1f6/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5943980471829f6de242a20b109de3111ba6b77e3af0ffc587028ac854b05e6c", size = 550370 }, + { url = "https://files.pythonhosted.org/packages/57/1c/e1fa82a8a01e3c5820f3ba98a8b2673f642128eb368fa88871b01dd2c909/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:76d3af9732d2dab69f28179b40ba2d87e2f1d5824b4a694780aa787d685e8f36", size = 613106 }, + { url = "https://files.pythonhosted.org/packages/29/55/b20b8c4c3dde8755bfcd5b08492a02d0cd2e26929aedf6199ca2a377d42a/rpds_py-2026.9.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:78326f4cb4427a56ba4996c0762b63be45f06b85f086526420d2b3a66e40f84d", size = 578235 }, + { url = "https://files.pythonhosted.org/packages/55/42/df3f7bbc3f7ab37a8a9db8d6c2ff2c985422899f1f7926afbf7ec3c0b8b4/rpds_py-2026.9.1-cp313-cp313-win32.whl", hash = "sha256:172e47169583f46ce118cbec68e6795d0da0f4606b488b6434f8276bca0a058c", size = 205293 }, + { url = "https://files.pythonhosted.org/packages/31/9c/ba5a9569d719bfdd6ce863df4133ac6a1658cf1b07cc3534c31db729fbbc/rpds_py-2026.9.1-cp313-cp313-win_amd64.whl", hash = "sha256:3e93b2cd69a9830be33e03945cd7cda940a0a8bfcfbff41d6144f0cb0d3d8bd9", size = 222409 }, + { url = "https://files.pythonhosted.org/packages/35/72/f28ca566f6c23c35bbf7445f65eb0364577b25a026305995e24f78b83d94/rpds_py-2026.9.1-cp313-cp313-win_arm64.whl", hash = "sha256:d151e148117294133bf8af7eeace085e7e87432db15ab6adf640330298a47f6f", size = 217681 }, + { url = "https://files.pythonhosted.org/packages/8a/f2/67b94be1532767803415c1c5a1fd88ea487643d74a673cec1ba140af77bb/rpds_py-2026.9.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c9d1aca01f49170fdcf5c92761b1fafe97f554b721ca4570c5949fff778f0d4b", size = 347178 }, + { url = "https://files.pythonhosted.org/packages/04/37/b751de2b59b0197a1d92a5dd491de88e8a5e928c2e6562581974f1e85263/rpds_py-2026.9.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3f0e9ac28fc067d4d34b88ae43c48e9489455c97fee9633d851f7eeed5a05d35", size = 341878 }, + { url = "https://files.pythonhosted.org/packages/72/e2/5873bc4643c250db9e05d48dc0c93763d4aa68bc3b81164cb1af3b45b284/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:07deecbfce94c78473018bc7d10b337cc651d12df87a1eb2cb3e4024bc9c33d0", size = 373963 }, + { url = "https://files.pythonhosted.org/packages/51/03/5acf7632158247f3f6386ff0af3a1ee48167d575037e8d0920594b76d92b/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:821b2755db9194409254012f429c56643416fb96ef9be090be82ec8826b7f477", size = 377975 }, + { url = "https://files.pythonhosted.org/packages/e6/00/63fda451b8bffa5808fc8bb311ee7c073b340974b09f273c7b2a145d3d62/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3c91c210ae7645626c608400e3519b4a642f837cce09ca830db3beb2e9f274d4", size = 490347 }, + { url = "https://files.pythonhosted.org/packages/a1/ae/c093ffd070ba0fb02f76c565d06fecc65ad6e4afdbae78f7031076d3cdac/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:54ac2158a6f96cfbabff0b2eedaf94b90c5ec7ca8317fcadc61e1c2b2e0ff6ef", size = 392962 }, + { url = "https://files.pythonhosted.org/packages/22/9d/d08a1128ab199b2f0cf25bfeb0639bd05119fff4b7c47bec24ef9a8ec23f/rpds_py-2026.9.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eac2f5dbafd585dfe31f86a23ebf0d3ba480a9d49ebc87947267b5608d4ea0cd", size = 371623 }, + { url = "https://files.pythonhosted.org/packages/53/c7/4758ddcbb75609414bbccfcb11d612436f9b3ee821bd2f33f0f1604ee648/rpds_py-2026.9.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:8aa5dda18d39b6143eb24809d158f9252c88f402749b6f1b62a506cc7d96cc35", size = 376997 }, + { url = "https://files.pythonhosted.org/packages/87/e4/947bd7f608ff60faf46dc9d389c3dffd0e3d767d78a0be19978448ef0ce7/rpds_py-2026.9.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5c90e7fa02e8f5de0d10c17595c568ada48c5302e749462c0ea1a4c362111a86", size = 400169 }, + { url = "https://files.pythonhosted.org/packages/4b/35/fe93e020a0543b5670472c18d7e6af3197c08da571240ce1965c84f85c0f/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e6d198bad4e49dd6732fbd636e2fc5c082f45c8cad0b4acb756b00c82c76072e", size = 551615 }, + { url = "https://files.pythonhosted.org/packages/0d/4f/5d2a0136bb03b2a56a39dc6ff92d58a6e3e53a2e17079238b86228882f16/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:96beca19ec79de272e8668585380ff9092c47077c1d7a1e098e00bbd921f4785", size = 614655 }, + { url = "https://files.pythonhosted.org/packages/09/1c/3f1025aaf70d9bf7272cc41f8b64ee76b48bf01726248138430e16f23b38/rpds_py-2026.9.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5cf77eb04f20b720be95265a3e00eb2a14814074255cc27069c551b2db53118", size = 579602 }, + { url = "https://files.pythonhosted.org/packages/53/0d/5c72e6204f76610608706da32b6b7e11ef7e10317558a7bb15a122e008dc/rpds_py-2026.9.1-cp314-cp314-win32.whl", hash = "sha256:a03d57b86d2a51d0a66c92177e2be154ad015f357791d306e714569999cdb4cc", size = 206315 }, + { url = "https://files.pythonhosted.org/packages/a4/0b/489d48abbcc7d70cf3fbf662d9d22abf1f4650761c0a9ae05260800800d3/rpds_py-2026.9.1-cp314-cp314-win_amd64.whl", hash = "sha256:837c6b305e26fe0f75b15c92cf3b2ba29e0ae19dc40b1c557b026cb426347d0c", size = 222852 }, + { url = "https://files.pythonhosted.org/packages/91/16/bbb05a7e6a10cf79ba639be7f799d770ee15f64175cc61d081b218dd402a/rpds_py-2026.9.1-cp314-cp314-win_arm64.whl", hash = "sha256:fce4b85234a0cbad67bf8e6e1201ee815d172c9aebad75f25645bc4d834f8e31", size = 218709 }, + { url = "https://files.pythonhosted.org/packages/22/ac/ac507a0a4ec478ca470440a09583db4be5259ba7670aeba0620822f1e57a/rpds_py-2026.9.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a72c11530d71abfb66c8d7696a2f86c43e63fca8b948f1a784ac490f4ec688e", size = 349596 }, + { url = "https://files.pythonhosted.org/packages/e9/f2/817a46b658d5070f477f722c298ee9a24525b0e4017347964146ef5fdd0e/rpds_py-2026.9.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:068c37bba854ec2fe42f7365c640af11dd9895890ccbf2df5070d0c059bd7f96", size = 343114 }, + { url = "https://files.pythonhosted.org/packages/6c/42/6ade976b13ac1b4cb3bf2eb603f1be2fe74df19a29988d4c2b386be59d6f/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d7fca4eb6df565e2a928f1c7dad92d27db8f9df0f449e76423ed5d7e713ed445", size = 374197 }, + { url = "https://files.pythonhosted.org/packages/d9/70/77cdf1d3f1a07faabe936016ae623aec7981f73108a8fe7a203ed2e21998/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c933c6678c6f116ff8af47a4c6db0868b8ace74af0343016c0ef00f00272ea69", size = 378025 }, + { url = "https://files.pythonhosted.org/packages/3f/6b/18a44a3beaa9b7931acb04af7bd9539836477c630a794452d4826d6185d4/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:028ad274ea951dac64491b5d1e65712a4aeabfdbdb9fccf797b57bd899b0c495", size = 491969 }, + { url = "https://files.pythonhosted.org/packages/73/27/fb39cfd6bddaf741b024f813890374578ff8ac1f1adc473659c048b03b05/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:740d0a99cf9de0b17a3943388e9294a59becf75e7c43421f387bd3c7a9901f7c", size = 393905 }, + { url = "https://files.pythonhosted.org/packages/ed/71/0fa7bb77b57af0d710273964180d11b503f62b8a5c358eb2d8c3f62feff6/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0da298fb372dc192610a4b9ecbc68a0cd8b675bbbd1fc519d01b41cfd658333e", size = 374569 }, + { url = "https://files.pythonhosted.org/packages/54/22/f41cfac269af3b449513ef1bc3d7f32fde52abbbd2d01c7e76b47743acd9/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:eb61be926bb81567c1f48bdc8aa22b9855048dc2efd53871f9f7e6e9a5632346", size = 377482 }, + { url = "https://files.pythonhosted.org/packages/b4/fc/312b49006e7f8f9ca5f96647577b8aa6f3df30519c46bd448f5c425af0b2/rpds_py-2026.9.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:42e75466f83cd43f6026c81eab74246efb2bdadafb307b85700632d06c68f299", size = 400040 }, + { url = "https://files.pythonhosted.org/packages/cf/a6/18cca7a878dc7fa95165a83343fd4d7b65643fd22e54e47340a451121d5c/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:617f59cde379b4f648a09797b7f683d04b90a46344cddab85639da5aff0f5531", size = 551849 }, + { url = "https://files.pythonhosted.org/packages/e4/6d/1f5685e20f39604691bdc3c05aaa6b8bd2f954e9e996477adf2376768e33/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3edae8c5ddfdb6985d49ae9d150516e5076888879022f91a26c2de9276ce0bdb", size = 614842 }, + { url = "https://files.pythonhosted.org/packages/c6/25/98652109fd9f7e10268dd4571aa52b81987001b806f37ef1a18260de714a/rpds_py-2026.9.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0f045bb053c9057720d72c56dffe30dffdc05997b2897a827b9325f0ab6623fa", size = 582606 }, + { url = "https://files.pythonhosted.org/packages/19/03/11ca09099bab5f53373a80a334c424ec917c13d050760a59499d2af5171e/rpds_py-2026.9.1-cp314-cp314t-win32.whl", hash = "sha256:bf35d0568abda97233239ce32896d3ad53fccc537832c104e30c94aa5fb93569", size = 203341 }, + { url = "https://files.pythonhosted.org/packages/6f/8a/88909e3ffd9f47f5b58211473875d8c3c09079f0058c46fb72c55a702a26/rpds_py-2026.9.1-cp314-cp314t-win_amd64.whl", hash = "sha256:1e8d4d79d828299bf44a55db22a9388ab967b49d17132c88eab0f4360b48da8e", size = 222843 }, + { url = "https://files.pythonhosted.org/packages/f6/b7/a662f367d4896dd0a10cef2fc91f10b7f08af1c10858e287e019563f338d/rpds_py-2026.9.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:1d77b649e6f7cdf12ca5c2a98dad0ad37f9ea9b6f960408a92f0cb12bb3d04d9", size = 347463 }, + { url = "https://files.pythonhosted.org/packages/ae/3f/ad45d03df4f84ebae5439577ee81f3999d182711e82235c8037b6528890e/rpds_py-2026.9.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:00ba2d8c7dd4ee537978ddf4b3fbd712bef2d8751603f7f3146b3f4287768e25", size = 342051 }, + { url = "https://files.pythonhosted.org/packages/7e/31/3dcd68c13d4bcc59c1f7eb33ac8e80698f06f3eb0d8a1e06419836071c20/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ec450527cbf485e13c8d3602a54f428ab0432fdade0ede75efd74b735421c871", size = 374143 }, + { url = "https://files.pythonhosted.org/packages/cf/0d/68c1f058a250fbd1380ebda9fc227cbf50117adf8ffe8161ef383ea79f68/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:306ee1850d8105b5baf977e78d45fcadd12c1a54678d614c9baf217708446e91", size = 378604 }, + { url = "https://files.pythonhosted.org/packages/d7/d6/2d4c59b85397cb4800594fadf692688ccf5ce556adc930e7a5bf21061a5e/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ef6b65b03247c54692ad4fd9ee97cb772781927db72e3cb05e70b3db6d1ff14f", size = 490790 }, + { url = "https://files.pythonhosted.org/packages/da/04/7e05dc3aebaf52f4e026766bd668fdd09a9d0e23f64a14686b36b3501892/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a575404ebc9cf2e91edd32eaf570ec1430eb900d4f56724ba7dd4bc1fc9c176d", size = 393146 }, + { url = "https://files.pythonhosted.org/packages/57/ca/e2e9a0a46a74ed51a0498ba1fe10f4ea6b2d9a155f372a2f91e51f18cf10/rpds_py-2026.9.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2c16ab111bc27c646ba8aa005d0527754edc538ebb636f0b1bf8e244b48d1945", size = 372022 }, + { url = "https://files.pythonhosted.org/packages/ba/cb/8f8774df5134e23424372838bcc5c7ed4127d723e1ff52f7bebd4dcb2563/rpds_py-2026.9.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:7664419f27db41d4f1c43a78dccda7dd6e8ef2428df3ee01d0c2a07a6b071297", size = 377092 }, + { url = "https://files.pythonhosted.org/packages/90/02/8d7095d73bf9114219be40230baa5df00611e0a82ed9517779ff9c19f82b/rpds_py-2026.9.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4b26b03d9d2658ee2fa234f8f4f19f38a09773fe5261028025032e26d4d35af0", size = 400830 }, + { url = "https://files.pythonhosted.org/packages/ec/02/8206856f8f363cd042a8315dc86b3912f5dcb6d3c66bbb24b69c2bfb0775/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:be3e47e2d91aa3942ff9bf4077a505226005abfc39b6f7554a91c1b9393986b9", size = 551478 }, + { url = "https://files.pythonhosted.org/packages/41/6b/36211f1bb1f0b0313f496d92f5905b74ea107f27cb16fa3355a82f04575e/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:6307a0da524939decb8ca4a3933b8ab62525794411d6984fca6726e732804af6", size = 615173 }, + { url = "https://files.pythonhosted.org/packages/35/77/cda0c4a6f055446b692f0ed5692f73707cafd3dff82672ede31b1a9b59de/rpds_py-2026.9.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:159a7aab5c5e8b112c8830f54717ce56da1252ebdbb526f5be2df2309280b9e7", size = 579653 }, + { url = "https://files.pythonhosted.org/packages/74/ec/d8385f446240aed643b9e92a5055cff3015cc04a13c61f73b2883c478ed5/rpds_py-2026.9.1-cp315-cp315-win32.whl", hash = "sha256:dbc2673f9223d420c91145599b3ba45a8a50c207d1976908e5fb5ddb0c9b9429", size = 206498 }, + { url = "https://files.pythonhosted.org/packages/6d/a5/71b5cd00e0521e3b6b81828baea368c62b6b700ebdd9554cd7d41ddf12fa/rpds_py-2026.9.1-cp315-cp315-win_amd64.whl", hash = "sha256:75c38c50ab9aca840225d9a9a3810bf11d04bd5c1f186cabbb8aee56db3e9b15", size = 223121 }, + { url = "https://files.pythonhosted.org/packages/33/58/dba857c3bc8221b31b62eb170a3080f4f191de79f047200389b7ed1b06a7/rpds_py-2026.9.1-cp315-cp315-win_arm64.whl", hash = "sha256:a431156bb41865fc14cd5d79bb9d7bbed83110b0159e34e62ae30951f96c0009", size = 218741 }, + { url = "https://files.pythonhosted.org/packages/5b/d0/320ab28ccc1415eeb509d68682b0014fb74690cd49f1c2d29a232475af50/rpds_py-2026.9.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ef0d8c843e2827d6c120ab4687e9423fb1d893db1df27b7c1506615bcb9734a0", size = 349719 }, + { url = "https://files.pythonhosted.org/packages/56/88/f5b12f1358f443c08b7ce3cc8391d82d335f4872580e2e097847fd36087a/rpds_py-2026.9.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:45bc6bccf78b20fd834237d18db64965d7ee68ba7f60440a26c7ab71e7b8d51a", size = 343267 }, + { url = "https://files.pythonhosted.org/packages/f7/0c/c765b0059d532acb3b9c45d781ccc22f15a96dbe443d00903f643ba9df10/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1d55198263bb51f557550c6ed2e6d1cb6a6fed6eb5c9120b741c5926bef8a45d", size = 374555 }, + { url = "https://files.pythonhosted.org/packages/6b/8a/cafddfda77564a10cd21184640c3bffda6a2b8d20972fb5dedd5e0166328/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a8763f20692da7df39b0afdd1ba3042b004c50a45994f76c2d9a25641f7673db", size = 378346 }, + { url = "https://files.pythonhosted.org/packages/b9/01/5e626016eff72c183bf6c96539240cace15d402468647a453ec08415b2fc/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e43d4a1f673e8a1cbd8533e809e02b4bf9d4f2280269bb640436556312121250", size = 492373 }, + { url = "https://files.pythonhosted.org/packages/3b/9c/15a2469e9389242f46896b3f0a01d68caea8a5a35c011fcb05ef333aae73/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea394a937f17a54c51239348bdbe2e3518124c8d4a8951ba04a311d3095bd18f", size = 394371 }, + { url = "https://files.pythonhosted.org/packages/63/f5/c100ff77e1e6366e947c75969c258fdfc4b7bc5bbfe7351e62ffbf2a1228/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdeaa99ce822dca76cfb1b993e9120c5ea212f2eb66d48950ad63c349668a018", size = 375132 }, + { url = "https://files.pythonhosted.org/packages/dd/f4/fe0269c9de253e99c81cabc12b8971a5feaa083debdaff1221e06264d9e3/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:b4f062343e7ad3fa94f2c66e5ae667dee47ee74dd41a9057c4fbe163236a123d", size = 377642 }, + { url = "https://files.pythonhosted.org/packages/05/65/b34a7b257baccff8f4a24a722933166d4941d5ebdc9c3f4bc4ffcd5ce4f4/rpds_py-2026.9.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:22ffd29a63d71fb1b81552c21f2c2b734949b7ac751a9be70675a939a900839b", size = 400855 }, + { url = "https://files.pythonhosted.org/packages/98/32/844e54176b6071b90b38a564e6940bc6eb8f97b2890dc709c19db9dec0f4/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:08dae4a4095150a7c4545a1fb40b98e1ab1744fbc2770d92c977b9dadaa49ab6", size = 552573 }, + { url = "https://files.pythonhosted.org/packages/17/73/6041d20729dffbfdf155c02d65be58bc225a1c1fb548fd87c23ef306138f/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:9a0460d43603d1fd9ef59c30278531e15d78581721ddb538fa560aa7817ea4ad", size = 615840 }, + { url = "https://files.pythonhosted.org/packages/af/9e/418094adaee6b056ce199051b255448ed872829051e341b2294c80da0977/rpds_py-2026.9.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:1c2d1f6da5128eabf34e963d7163a818846075a52568250d006c4c953b40f903", size = 582764 }, + { url = "https://files.pythonhosted.org/packages/3d/9b/1698ebf6b840ddfe8b472198abbed6ace35c6e398faeecd6c47dae742a4e/rpds_py-2026.9.1-cp315-cp315t-win32.whl", hash = "sha256:5c6ee90dee3e85e055ddfd502d611643d9b0fd94c818220bda84ec3dacd9b27b", size = 203795 }, + { url = "https://files.pythonhosted.org/packages/8a/e2/91f70d804c61f8eac39a417e82aa1024f655ab9e8bdd7393b196242bc41b/rpds_py-2026.9.1-cp315-cp315t-win_amd64.whl", hash = "sha256:fe5ad0664ec772b02c45859041aa17655709cced7a31005817fbbbd988c25567", size = 223037 }, + { url = "https://files.pythonhosted.org/packages/f3/ad/f0eba548e297987964ef8088403db875b333963bf8189f3f563b6936b20e/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4c0d2cb595a420b34d5086db0add011e26e2c09d6a024afbac4228bf8f863a30", size = 384112 }, + { url = "https://files.pythonhosted.org/packages/33/89/891fa4d81f1eae7fe262ecd0aba2c51de94a116c7f3ccfb034fa75f4c537/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f3d6ed6a98cfd19155996605474982cc470d7601746a6439078f1a5a3fa8b050", size = 389662 }, + { url = "https://files.pythonhosted.org/packages/4f/46/912cf623b2263f9c9c2d04e8508fc38dbe89983faeba5fb36ca4558ae550/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8171b44a054e5c67fd748ada04187f1250bf35b95f85e52ab64bcf3331a923bb", size = 504637 }, + { url = "https://files.pythonhosted.org/packages/9b/5c/7f3cfa7c5f01765c9f47c8398210ed0c1eb686c0e99c223cb1c6776d3fb9/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:fda1d96e542c37b6c804547dbf489c129fe7c97183a76a5ec275909ba1a063df", size = 402607 }, + { url = "https://files.pythonhosted.org/packages/f0/76/2e915d4115ee453c33e6af17bc1df7e22ddc52974e178eeeb92a78c09983/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:270bdcdaac5d5b6f73c5e22e7e135c7f2a50e789f71d9e241d5be8d90026e19a", size = 382762 }, + { url = "https://files.pythonhosted.org/packages/0d/e5/844382925d7b84a4074ad79fc2b9d05eb6231ea243b34f247e68e519b606/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:84a6ecc0c940169190d2c23bd969debd48c94dbc855acd60188a68d71d421608", size = 389563 }, + { url = "https://files.pythonhosted.org/packages/81/82/d6e3951602dc2e24790ef9d299aa24bf5e2493df5043f2c6da00480cc447/rpds_py-2026.9.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ab4b2fda7c2b542f7f9d886cc6a838c5079d2b76f72e6081411faba11adde2c9", size = 416842 }, + { url = "https://files.pythonhosted.org/packages/ea/1c/8d248e4ea671d525ab7daa0703ad81cc2175076fa0ccd7ef088facbd2b01/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:44b32a7c4f0da3d28af31c259e38ddcff096f855e205ed0671d02fcf44f1ea1c", size = 562434 }, + { url = "https://files.pythonhosted.org/packages/ad/38/e7ac0db898120d15dea25e3661b1242a40cb3b23d1f4db86c15a01fe0ba1/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:a3dbc5ed9514908d5046107d7b1346bde71eea61de6e0e4919c19354f97e769f", size = 627909 }, + { url = "https://files.pythonhosted.org/packages/03/c8/37e555514932cb1ee36c75349187aa882a98dee5d5654c761699ed8a0d27/rpds_py-2026.9.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6eae33003518fd4cb4f83a218d5371469dd3001aa3b87128c005b07762f7fe5e", size = 593658 }, + { url = "https://files.pythonhosted.org/packages/12/5c/5f69a91add94a8a33acea68a2b034a464946a654ff7296af9d957ef0d6a5/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_10_12_x86_64.whl", hash = "sha256:0483515261947e4e8b8e1375bf7463e7eb6ccfb3d86e7b554d90cd5285f20f32", size = 351769 }, + { url = "https://files.pythonhosted.org/packages/41/22/35ea8bf3b2682b90ab687c74e4828418ff12298a5b0b0a0c033f61c86a30/rpds_py-2026.9.1-pp311-pypy311_pp80-macosx_11_0_arm64.whl", hash = "sha256:4cfaf02209061880210819934de2f4f6aa83dc04dafe6770276acc240a56da31", size = 351719 }, + { url = "https://files.pythonhosted.org/packages/a3/90/a9ba81408369d34c1aca73319c15adc91ed45c1d21e9e8ee4832ffb7fe0b/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6cdc537c8633d7fd92a82e2e0d2ab74320a3f63d5e59fb9cf08711e08fe151c4", size = 381109 }, + { url = "https://files.pythonhosted.org/packages/b3/44/5192a0bed94cec86bd2a5e1cc5a1bb8f7eec3aec907eacd2deb13e87e326/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:10e208f2425d973938afcd56e28a7c4be32e27b6a60b5d381f49fb9d8acf9759", size = 388222 }, + { url = "https://files.pythonhosted.org/packages/92/cd/5356549711448f18b52f7a11ffbe90f1228774996fbf3c6cd1b18d22abaf/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c0dbbcc19735fe5f8b0a54c07659d154a9e69f47e15d0a6ab7299215daf62cb", size = 500384 }, + { url = "https://files.pythonhosted.org/packages/7a/88/ddda9d28adfe33119c75b2e733d4ba7e326f41d7e1b1093951771768ca48/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:684fd492fff4fead00587544e059be2bbcb6f93454f21fa2a91b66fc7508be82", size = 401661 }, + { url = "https://files.pythonhosted.org/packages/32/c3/bb59ba16a6b4a57995d15b8c04c00f28df3b938c4dcdde48fbe0f73edb0c/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d1028417bb44037eb3069c1009bd7b7277212876cda22fbe565b0bca9fab6d2c", size = 380748 }, + { url = "https://files.pythonhosted.org/packages/f5/b8/0580faa1c5a32ddc160130dd7ae54d2d007272a89a40a6b5db3e50f9f585/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:492e5e428cbe126221611f47e068f01660352feec4ad18bc0f5ea9b2ae88fb14", size = 385731 }, + { url = "https://files.pythonhosted.org/packages/bf/70/f73564642bbe3322c7eeef2c2f258cf040b71c41a430b532e59d04a1b838/rpds_py-2026.9.1-pp312-pypy312_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:88b5268892fde430d5531f95bc560b6efbbd67c929662c586afd729a96e7461c", size = 412131 }, + { url = "https://files.pythonhosted.org/packages/8c/5d/17fff2e1f8f68721a2afb5cb48f7e442c751bb6b4883157c5abaf618cd08/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:01445c8d194aa032a08e944f16567672da1c62dbdbefd8b6d0693032e290cf68", size = 558193 }, + { url = "https://files.pythonhosted.org/packages/2a/d0/869ab6fb08531f97aae4a780b4d61b4190d8aef48fe054ee28bcf7114466/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_i686.whl", hash = "sha256:eef6a03b0b6d08d0835ccfa8ec8d1bc70525e3801387567137b50c557695e6da", size = 622448 }, + { url = "https://files.pythonhosted.org/packages/ba/a5/9672e532fe02cd3b92c78cfd8743abca939d96853006c63835009a1ecb6f/rpds_py-2026.9.1-pp312-pypy312_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:6b9bf3135b4ad5981df9a73d71a35272d650a2985ae9c2746357b24d59de2448", size = 591126 }, + { url = "https://files.pythonhosted.org/packages/80/ef/3a9f8e4279c7920af561deed4cb7ebebed2794a8f608cf404d5eef14a105/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_10_12_x86_64.whl", hash = "sha256:56c6952a9b15047466d0c2347c446a761d4527f89976156341e68f0ce5cc08b0", size = 357108 }, + { url = "https://files.pythonhosted.org/packages/b7/55/4b2fa381a583760aea5c92841e4e928a358e1c6511b129219e9c2826a226/rpds_py-2026.9.1-pp312-pypy312_pp80-macosx_11_0_arm64.whl", hash = "sha256:b242c27c8f836305a4a72df9cdd564386ac57b807bd252a063223331c9316b37", size = 346877 }, +] + [[package]] name = "ruff" version = "0.17.0" From 4f3b2c1c8ce03282515dc4428959b2f15be98eba Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 00:01:50 -0400 Subject: [PATCH 05/31] feat(bench): benchmark and stress harness against real MongoDB An unpublished workspace package answering performance questions with data from real MongoDB (replica set and sharded cluster). - mongospecs-bench run: workloads B1-B8 (materialise, find, include strategies, writes, pagination, plugin overhead, HTTP per plug, cost of change tracking) - mongospecs-bench stress: scenarios X1-X7 (pool pressure, transaction contention, optimistic-lock storm, cancellation, 16MB results, change stream across a step-down, threads with own loops) - mongospecs-bench trace: Sentry SDK through mongospecs-web into a local rustrak server, asserting one trace per request with Model.op database spans - results are JSON under bench/results// with server, topology and machine metadata; --quick only proves the harness runs - smoke tests keep every workload and scenario runnable Co-Authored-By: Claude Opus 5.5 --- bench/pyproject.toml | 2 +- bench/src/mongospecs_bench/cli.py | 110 ++++ bench/src/mongospecs_bench/harness.py | 217 ++++++++ bench/src/mongospecs_bench/models.py | 177 +++++++ bench/src/mongospecs_bench/stress.py | 360 +++++++++++++ bench/src/mongospecs_bench/tracing.py | 204 ++++++++ bench/src/mongospecs_bench/workloads.py | 643 ++++++++++++++++++++++++ bench/tests/test_bench_smoke.py | 44 ++ docker/compose.rustrak.yaml | 19 + pyproject.toml | 2 + uv.lock | 4 +- 11 files changed, 1779 insertions(+), 3 deletions(-) create mode 100644 bench/src/mongospecs_bench/cli.py create mode 100644 bench/src/mongospecs_bench/harness.py create mode 100644 bench/src/mongospecs_bench/models.py create mode 100644 bench/src/mongospecs_bench/stress.py create mode 100644 bench/src/mongospecs_bench/tracing.py create mode 100644 bench/src/mongospecs_bench/workloads.py create mode 100644 bench/tests/test_bench_smoke.py create mode 100644 docker/compose.rustrak.yaml diff --git a/bench/pyproject.toml b/bench/pyproject.toml index 2a7bb90..28e1295 100644 --- a/bench/pyproject.toml +++ b/bench/pyproject.toml @@ -8,7 +8,7 @@ version = "0.0.0" description = "Benchmarks and stress scenarios for mongospecs against real MongoDB. Not published." requires-python = ">=3.11" classifiers = ["Private :: Do Not Upload"] -dependencies = ["mongospecs[all]", "mongospecs-web[all]", "httpx>=0.27"] +dependencies = ["mongospecs[all,sentry]", "mongospecs-web[all]", "httpx>=0.27"] [project.scripts] mongospecs-bench = "mongospecs_bench.cli:main" diff --git a/bench/src/mongospecs_bench/cli.py b/bench/src/mongospecs_bench/cli.py new file mode 100644 index 0000000..1b47e40 --- /dev/null +++ b/bench/src/mongospecs_bench/cli.py @@ -0,0 +1,110 @@ +"""``mongospecs-bench``: run benchmarks, stress scenarios and the tracing check. + +:: + + mongospecs-bench run B1 B2 # workloads (default: all) + mongospecs-bench run B3 --topology sharded + mongospecs-bench stress # X1-X7 + mongospecs-bench trace # Sentry SDK -> rustrak, end to end + mongospecs-bench report # markdown summary of the newest results + +Add ``--quick`` to check that everything runs; quick numbers are not for conclusions. +""" + +from __future__ import annotations + +import argparse +import asyncio +import json +import pathlib +import sys +import typing as t + +from mongospecs_bench.harness import RESULTS, Result, Settings, metadata, table, write_result + + +async def _run(names: list[str], registry: dict[str, t.Any], settings: Settings) -> int: + meta = await metadata(settings) + failed = 0 + for name in names: + fn = registry[name] + print(f"\n== {name} ({settings.topology}{', quick' if settings.quick else ''})", flush=True) + result: Result = await fn(settings) + path = write_result(result, meta) + print(f"{result.title}\n{result.question}\n") + print(table(result.rows)) + for note in result.notes: + print(f"note: {note}") + if result.conclusion: + print(result.conclusion) + failed += result.conclusion.startswith("FAIL") + print(f"-> {path.relative_to(RESULTS.parent)}") + return 1 if failed else 0 + + +def report(folder: pathlib.Path | None = None) -> str: + """Markdown summary of one results folder (default: the most recently written).""" + if folder is None: + folders = sorted((p for p in RESULTS.iterdir() if p.is_dir()), key=lambda p: p.stat().st_mtime) + if not folders: + return "No results yet. Run `mongospecs-bench run` first." + folder = folders[-1] + lines = [f"# Benchmark results ({folder.name})", ""] + for path in sorted(folder.glob("*.json")): + data = json.loads(path.read_text()) + meta = data["meta"] + lines += [ + f"## {data['workload']}: {data['title']} ({meta['topology']}{', quick' if meta['quick'] else ''})", + "", + data["question"], + "", + f"MongoDB {meta['mongodb']}, Python {meta['python']}, {meta['machine']}, {meta['timestamp']}", + "", + ] + rows = data["rows"] + if rows: + columns = list(dict.fromkeys(key for row in rows for key in row)) + lines.append("| " + " | ".join(columns) + " |") + lines.append("|" + "---|" * len(columns)) + lines.extend("| " + " | ".join(str(row.get(c, "")) for c in columns) + " |" for row in rows) + lines.append("") + lines.extend(f"- {note}" for note in data["notes"]) + if data["conclusion"]: + lines += ["", f"**{data['conclusion']}**"] + lines.append("") + return "\n".join(lines) + + +def main(argv: list[str] | None = None) -> int: + from mongospecs_bench.stress import SCENARIOS + from mongospecs_bench.workloads import WORKLOADS + + parser = argparse.ArgumentParser( + prog="mongospecs-bench", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + sub = parser.add_subparsers(dest="command", required=True) + for name, choices in (("run", WORKLOADS), ("stress", SCENARIOS)): + cmd = sub.add_parser(name) + cmd.add_argument("names", nargs="*", choices=[*choices, []], default=[]) + cmd.add_argument("--topology", choices=("rs", "sharded"), default="rs") + cmd.add_argument("--quick", action="store_true") + cmd.add_argument("--backend", action="append", choices=("msgspec", "pydantic", "attrs")) + sub.add_parser("trace") + rep = sub.add_parser("report") + rep.add_argument("folder", nargs="?") + args = parser.parse_args(argv) + + if args.command == "trace": + from mongospecs_bench.tracing import main as trace_main + + return trace_main() + if args.command == "report": + print(report(RESULTS / args.folder if args.folder else None)) + return 0 + registry = WORKLOADS if args.command == "run" else SCENARIOS + settings = Settings(args.topology, args.quick, tuple(args.backend or ("msgspec", "pydantic", "attrs"))) + return asyncio.run(_run(list(args.names) or list(registry), registry, settings)) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/bench/src/mongospecs_bench/harness.py b/bench/src/mongospecs_bench/harness.py new file mode 100644 index 0000000..99d9643 --- /dev/null +++ b/bench/src/mongospecs_bench/harness.py @@ -0,0 +1,217 @@ +"""Timing, environment and result-file plumbing shared by workloads and stress scenarios.""" + +from __future__ import annotations + +import asyncio +import datetime as dt +import gc +import json +import os +import pathlib +import platform +import statistics +import subprocess +import sys +import time +import typing as t +import uuid +from contextlib import asynccontextmanager +from dataclasses import dataclass, field + +from mongospecs import Registry + +RS_URI = os.environ.get("MONGOSPECS_TEST_URI", "mongodb://localhost:27117/?replicaSet=rs0") +SHARDED_URI = os.environ.get("MONGOSPECS_TEST_SHARDED_URI", "mongodb://localhost:27118") +RESULTS = pathlib.Path(__file__).resolve().parents[2] / "results" + + +def uri_for(topology: str) -> str: + return SHARDED_URI if topology == "sharded" else RS_URI + + +@dataclass +class Timing: + samples: list[float] + """Seconds per call.""" + + @property + def median_ms(self) -> float: + return statistics.median(self.samples) * 1000 + + @property + def min_ms(self) -> float: + return min(self.samples) * 1000 + + @property + def p95_ms(self) -> float: + ordered = sorted(self.samples) + return ordered[min(len(ordered) - 1, round(0.95 * (len(ordered) - 1)))] * 1000 + + def as_dict(self) -> dict[str, float]: + return { + "median_ms": round(self.median_ms, 4), + "min_ms": round(self.min_ms, 4), + "p95_ms": round(self.p95_ms, 4), + "runs": len(self.samples), + } + + +async def measure( + fn: t.Callable[[], t.Awaitable[t.Any]], + *, + repeat: int = 15, + warmup: int = 2, + setup: t.Callable[[], t.Awaitable[t.Any]] | None = None, +) -> Timing: + """Time an async callable. ``setup`` runs before each call and is not timed.""" + for _ in range(warmup): + if setup is not None: + await setup() + await fn() + samples: list[float] = [] + gc.collect() + for _ in range(repeat): + if setup is not None: + await setup() + start = time.perf_counter() + await fn() + samples.append(time.perf_counter() - start) + return Timing(samples) + + +def measure_sync(fn: t.Callable[[], t.Any], *, repeat: int = 15, warmup: int = 2) -> Timing: + for _ in range(warmup): + fn() + samples: list[float] = [] + gc.collect() + for _ in range(repeat): + start = time.perf_counter() + fn() + samples.append(time.perf_counter() - start) + return Timing(samples) + + +@dataclass +class Settings: + topology: str = "rs" + quick: bool = False + """Small sizes and few repetitions: for checking the harness, not for publishing numbers.""" + backends: tuple[str, ...] = ("msgspec", "pydantic", "attrs") + + @property + def repeat(self) -> int: + return 3 if self.quick else 15 + + def scale(self, full: int, quick: int) -> int: + return quick if self.quick else full + + +@dataclass +class Result: + workload: str + title: str + question: str + rows: list[dict[str, t.Any]] = field(default_factory=list) + notes: list[str] = field(default_factory=list) + conclusion: str = "" + + def add(self, **row: t.Any) -> None: + self.rows.append(row) + + +@asynccontextmanager +async def environment( + settings: Settings, models: t.Iterable[type] = (), **registry_options: t.Any +) -> t.AsyncIterator[Registry]: + """An open registry on a fresh database of the chosen topology, dropped afterwards.""" + name = f"ms_bench_{uuid.uuid4().hex[:10]}" + async with Registry( + uri_for(settings.topology), database=name, models=models, **registry_options + ) as registry: + if settings.topology == "sharded": + await registry.client.admin.command("enableSharding", name) + try: + yield registry + finally: + await registry.client.drop_database(name) + + +def _git(*args: str) -> str: + try: + return subprocess.run( + ["git", *args], capture_output=True, text=True, check=True, cwd=RESULTS.parent + ).stdout.strip() + except Exception: + return "unknown" + + +async def metadata(settings: Settings) -> dict[str, t.Any]: + import msgspec + import pydantic + import pymongo + + import mongospecs + + async with Registry(uri_for(settings.topology), database="admin") as registry: + info = await registry.client.server_info() + return { + "git_sha": _git("rev-parse", "--short", "HEAD"), + "git_dirty": bool(_git("status", "--porcelain")), + "timestamp": dt.datetime.now(dt.UTC).isoformat(timespec="seconds"), + "topology": settings.topology, + "quick": settings.quick, + "mongodb": info.get("version"), + "python": sys.version.split()[0], + "free_threaded": not getattr(sys, "_is_gil_enabled", lambda: True)(), + "platform": platform.platform(), + "machine": platform.machine(), + "cpus": os.cpu_count(), + "versions": { + "mongospecs": mongospecs.__version__, + "pymongo": pymongo.version, + "msgspec": msgspec.__version__, + "pydantic": pydantic.VERSION, + }, + } + + +def write_result(result: Result, meta: dict[str, t.Any]) -> pathlib.Path: + """Write one result file under ``bench/results//``.""" + folder = RESULTS / str(meta["git_sha"]) + folder.mkdir(parents=True, exist_ok=True) + suffix = "" if meta["topology"] == "rs" else f"-{meta['topology']}" + path = folder / f"{result.workload}{suffix}{'-quick' if meta['quick'] else ''}.json" + payload = { + "meta": meta, + "workload": result.workload, + "title": result.title, + "question": result.question, + "rows": result.rows, + "notes": result.notes, + "conclusion": result.conclusion, + } + path.write_text(json.dumps(payload, indent=2, default=str) + "\n") + return path + + +def table(rows: list[dict[str, t.Any]]) -> str: + """Render rows as a fixed-width text table.""" + if not rows: + return "(no rows)" + columns = list(dict.fromkeys(key for row in rows for key in row)) + cells = [[_cell(row.get(c, "")) for c in columns] for row in rows] + widths = [max(len(c), *(len(r[i]) for r in cells)) for i, c in enumerate(columns)] + lines = [" ".join(c.ljust(w) for c, w in zip(columns, widths, strict=True))] + lines.append(" ".join("-" * w for w in widths)) + lines.extend(" ".join(v.ljust(w) for v, w in zip(row, widths, strict=True)) for row in cells) + return "\n".join(lines) + + +def _cell(value: t.Any) -> str: + if isinstance(value, float): + return f"{value:.3f}" if abs(value) < 100 else f"{value:.1f}" + return str(value) + + +def run(coro: t.Coroutine[t.Any, t.Any, t.Any]) -> t.Any: + return asyncio.run(coro) diff --git a/bench/src/mongospecs_bench/models.py b/bench/src/mongospecs_bench/models.py new file mode 100644 index 0000000..6620050 --- /dev/null +++ b/bench/src/mongospecs_bench/models.py @@ -0,0 +1,177 @@ +"""Benchmark models, declared once per backend at module level. + +No ``from __future__ import annotations``: the classes are built by a factory. +""" + +import datetime as dt +import types +import typing as t + +import attrs +import msgspec +import pydantic + +from mongospecs import CreatedAt, Index, Ref, Revision, SpecConfig, UpdatedAt +from mongospecs import attrs as at +from mongospecs import msgspec as ms +from mongospecs import pydantic as pd + +BACKENDS = ("msgspec", "pydantic", "attrs") +SHAPES = ("small", "wide", "nested") +WIDE_FIELDS = 40 + + +def _build(backend: str) -> types.SimpleNamespace: + if backend == "msgspec": + Spec: t.Any = ms.Spec + define: t.Any = lambda cls: cls # noqa: E731 + + class Address(msgspec.Struct, kw_only=True): + street: str = "" + city: str = "" + zip: str = "" + + class Line(msgspec.Struct, kw_only=True): + sku: str = "" + qty: int = 0 + price: float = 0.0 + + elif backend == "pydantic": + Spec = pd.Spec + define = lambda cls: cls # noqa: E731 + + class Address(pydantic.BaseModel): # type: ignore[no-redef] + street: str = "" + city: str = "" + zip: str = "" + + class Line(pydantic.BaseModel): # type: ignore[no-redef] + sku: str = "" + qty: int = 0 + price: float = 0.0 + + else: + Spec = at.Spec + define = attrs.define(kw_only=True) + + @attrs.define(kw_only=True) + class Address: # type: ignore[no-redef] + street: str = "" + city: str = "" + zip: str = "" + + @attrs.define(kw_only=True) + class Line: # type: ignore[no-redef] + sku: str = "" + qty: int = 0 + price: float = 0.0 + + @define + class Small(Spec): # type: ignore[misc] + __spec__ = SpecConfig(collection="bench_small", indexes=[Index("n")]) + n: int = 0 + name: str = "" + score: float = 0.0 + active: bool = True + created: dt.datetime | None = None + + # 40 scalar fields, built so the three backends get identical definitions. + wide_annotations: dict[str, t.Any] = {} + wide_defaults: dict[str, t.Any] = {} + for i in range(WIDE_FIELDS): + kind = i % 4 + wide_annotations[f"f{i:02d}"] = (int, str, float, bool)[kind] + wide_defaults[f"f{i:02d}"] = (0, "", 0.0, False)[kind] + wide_ns = { + "__annotations__": {"n": int, **wide_annotations}, + "__spec__": SpecConfig(collection="bench_wide", indexes=[Index("n")]), + "n": 0, + **wide_defaults, + } + Wide = define(types.new_class("Wide", (Spec,), {}, lambda ns: ns.update(wide_ns))) + + @define + class Nested(Spec): # type: ignore[misc] + __spec__ = SpecConfig(collection="bench_nested", indexes=[Index("n")]) + n: int = 0 + customer: str = "" + address: Address | None = None + lines: list[Line] = [] + tags: list[str] = [] + meta: dict[str, str] = {} + placed: dt.datetime | None = None + + @define + class Child(Spec): # type: ignore[misc] + __spec__ = SpecConfig(collection="bench_children") + n: int = 0 + label: str = "" + payload: str = "" + + @define + class Parent(Spec): # type: ignore[misc] + __spec__ = SpecConfig(collection="bench_parents", indexes=[Index("n")]) + n: int = 0 + name: str = "" + owner: Ref[Child] | None = None + children: list[Ref[Child]] = [] + + @define + class Managed(Spec): # type: ignore[misc] + """A model with the three built-in plugins active.""" + + __spec__ = SpecConfig(collection="bench_managed") + n: int = 0 + name: str = "" + created_at: t.Annotated[dt.datetime | None, CreatedAt] = None + updated_at: t.Annotated[dt.datetime | None, UpdatedAt] = None + rev: t.Annotated[int, Revision] = 0 + + @define + class Plain(Spec): # type: ignore[misc] + __spec__ = SpecConfig(collection="bench_plain") + n: int = 0 + name: str = "" + + return types.SimpleNamespace( + backend=backend, + Small=Small, + Wide=Wide, + Nested=Nested, + Child=Child, + Parent=Parent, + Managed=Managed, + Plain=Plain, + ALL=[Small, Wide, Nested, Child, Parent, Managed, Plain], + ) + + +MODELS: dict[str, types.SimpleNamespace] = {backend: _build(backend) for backend in BACKENDS} + + +def raw_document(shape: str, n: int) -> dict[str, t.Any]: + """A raw MongoDB document of the given shape (without ``_id``).""" + when = dt.datetime(2024, 1, 1, tzinfo=dt.UTC) + dt.timedelta(minutes=n) + if shape == "small": + return {"n": n, "name": f"name-{n}", "score": n * 1.5, "active": n % 2 == 0, "created": when} + if shape == "wide": + doc: dict[str, t.Any] = {"n": n} + for i in range(WIDE_FIELDS): + kind = i % 4 + doc[f"f{i:02d}"] = (n + i, f"value-{n}-{i}", (n + i) * 0.5, (n + i) % 2 == 0)[kind] + return doc + if shape == "nested": + return { + "n": n, + "customer": f"customer-{n}", + "address": {"street": f"{n} Main St", "city": "Springfield", "zip": f"{n % 100000:05d}"}, + "lines": [{"sku": f"sku-{n}-{i}", "qty": i + 1, "price": 9.99 + i} for i in range(8)], + "tags": [f"tag-{(n + i) % 7}" for i in range(5)], + "meta": {"source": "bench", "batch": str(n // 100)}, + "placed": when, + } + raise ValueError(shape) + + +def model_for(backend: str, shape: str) -> type: + return t.cast(type, getattr(MODELS[backend], shape.capitalize())) diff --git a/bench/src/mongospecs_bench/stress.py b/bench/src/mongospecs_bench/stress.py new file mode 100644 index 0000000..f442374 --- /dev/null +++ b/bench/src/mongospecs_bench/stress.py @@ -0,0 +1,360 @@ +"""Stress scenarios X1-X7 (plan section 6). Each returns a :class:`Result` with a pass/fail verdict.""" + +from __future__ import annotations + +import asyncio +import gc +import random +import threading +import typing as t + +from mongospecs import ( + DocumentTooLarge, + MongoSpecsError, + PoolTimeout, + Q, + Registry, + StaleDocumentError, + tracking, +) +from mongospecs_bench.harness import Result, Settings, environment, uri_for +from mongospecs_bench.models import MODELS + +Scenario = t.Callable[[Settings], t.Awaitable[Result]] + + +def _verdict(result: Result, passed: bool, summary: str) -> Result: + result.conclusion = ("PASS: " if passed else "FAIL: ") + summary + result.add(check="verdict", value="pass" if passed else "fail") + return result + + +async def x1(settings: Settings) -> Result: + result = Result( + "X1", + "Many tasks, small pool", + "Do thousands of concurrent tasks finish on a pool of 5, and do pool waits surface as typed errors?", + ) + tasks = settings.scale(3000, 200) + ns = MODELS["msgspec"] + async with environment(settings, maxPoolSize=5) as registry: + manager = registry.manager(ns.Plain) + + async def work(i: int) -> int: + doc = await manager.insert(ns.Plain(n=i, name=f"t{i}")) + found = await manager.get(doc.id) + return found.n if found else -1 + + done = await asyncio.wait_for(asyncio.gather(*(work(i) for i in range(tasks))), timeout=300) + count = await manager.count() + result.add(check="tasks completed", value=len(done)) + result.add(check="documents written", value=count) + ok = sorted(done) == list(range(tasks)) and count == tasks + async with environment(settings, maxPoolSize=1, waitQueueTimeoutMS=20) as starved: + manager = starved.manager(ns.Plain) + await manager.insert(ns.Plain(n=0)) + from mongospecs import trusted + + slow = [manager.find(trusted({"$where": "sleep(300) || true"})).all() for _ in range(6)] + outcomes = await asyncio.gather(*slow, return_exceptions=True) + typed = sum(isinstance(o, PoolTimeout) for o in outcomes) + untyped = [o for o in outcomes if isinstance(o, BaseException) and not isinstance(o, MongoSpecsError)] + result.add(check="pool waits raised as PoolTimeout", value=typed) + result.add(check="untyped errors", value=len(untyped)) + ok = ok and typed >= 1 and not untyped + return _verdict( + result, ok, f"{tasks} tasks completed on 5 connections; starved pool raised PoolTimeout {typed} times" + ) + + +async def x2(settings: Settings) -> Result: + result = Result( + "X2", + "Transaction contention", + "Do contended read-modify-write transactions lose updates, and are retries bounded?", + ) + workers = settings.scale(40, 8) + ns = MODELS["msgspec"] + async with environment(settings) as registry: + manager = registry.manager(ns.Plain) + counter = await manager.insert(ns.Plain(n=0, name="counter")) + attempts = 0 + + async def bump(tx: t.Any) -> None: + nonlocal attempts + attempts += 1 + doc = await manager.find(Q.id == counter.id, session=tx).one() + doc.n += 1 + await manager.save(doc, session=tx) + + await asyncio.gather(*(registry.transaction(bump) for _ in range(workers))) + final = await manager.get(counter.id) + assert final is not None + result.add(check="workers", value=workers) + result.add(check="final counter", value=final.n) + result.add(check="callback runs (incl. retries)", value=attempts) + ok = final.n == workers and attempts <= workers * 50 + return _verdict(result, ok, f"counter reached {final.n}/{workers} with {attempts - workers} retries") + + +async def x3(settings: Settings) -> Result: + result = Result( + "X3", + "Optimistic-lock storm", + "With many writers on one revisioned document, does exactly one win per revision?", + ) + writers = settings.scale(40, 8) + ns = MODELS["msgspec"] + async with environment(settings) as registry: + manager = registry.manager(ns.Managed) + doc = await manager.insert(ns.Managed(n=0, name="hot")) + copies = [await manager.find(Q.id == doc.id).one() for _ in range(writers)] + + async def attempt(copy: t.Any) -> bool: + copy.n += 1 + try: + await manager.save(copy) + except StaleDocumentError: + return False + return True + + first_round = await asyncio.gather(*(attempt(c) for c in copies)) + result.add(check="winners of the first round", value=sum(first_round)) + conflicts = 0 + + async def persistent(_: int) -> None: + nonlocal conflicts + while True: + fresh = await manager.find(Q.id == doc.id).one() + fresh.n += 1 + try: + await manager.save(fresh) + return + except StaleDocumentError: + conflicts += 1 + + await asyncio.gather(*(persistent(i) for i in range(writers))) + final = await manager.get(doc.id) + assert final is not None + result.add(check="final n", value=final.n) + result.add(check="final rev", value=final.rev) + result.add(check="conflicts retried", value=conflicts) + ok = sum(first_round) == 1 and final.n == writers + 1 and final.rev == writers + 1 + return _verdict( + result, + ok, + f"1 winner in the first round; after retries n={final.n}, rev={final.rev} for {writers} writers", + ) + + +async def x4(settings: Settings) -> Result: + result = Result( + "X4", + "Cancellation", + "Does cancelling tasks mid-operation and mid-transaction leak sessions, transactions or tracked state?", + ) + count = settings.scale(200, 30) + ns = MODELS["msgspec"] + async with environment(settings) as registry: + manager = registry.manager(ns.Plain) + hot = await manager.insert(ns.Plain(n=0, name="hot")) + gc.collect() + tracked_before = tracking.tracked_count() + + async def in_transaction(i: int) -> None: + async def body(tx: t.Any) -> None: + await manager.insert(ns.Plain(n=i, name="tx"), session=tx) + await asyncio.sleep(random.random() * 0.2) + await manager.update(hot.id, session=tx, name=f"tx{i}") + + await registry.transaction(body) + + async def plain(i: int) -> None: + docs = await manager.find().limit(50).all() + await asyncio.sleep(random.random() * 0.2) + await manager.insert(ns.Plain(n=i + len(docs), name="plain")) + + tasks = [asyncio.create_task(in_transaction(i) if i % 2 else plain(i)) for i in range(count)] + await asyncio.sleep(0.05) + cancelled = 0 + for task in tasks: + if random.random() < 0.7 and not task.done(): + task.cancel() + cancelled += 1 + outcomes = await asyncio.gather(*tasks, return_exceptions=True) + unexpected = [ + o + for o in outcomes + if isinstance(o, BaseException) and not isinstance(o, asyncio.CancelledError | MongoSpecsError) + ] + await asyncio.sleep(1) + admin = registry.client.admin + cursor = await admin.aggregate( + [ + {"$currentOp": {"allUsers": True, "idleSessions": True}}, + {"$match": {"transaction": {"$exists": True}}}, + ] + ) + open_transactions = len(await cursor.to_list()) + # locks are released: the hot document can be written immediately + await asyncio.wait_for(manager.update(hot.id, name="after"), timeout=5) + del tasks, outcomes + gc.collect() + result.add(check="tasks cancelled", value=cancelled) + result.add(check="unexpected error types", value=len(unexpected)) + result.add(check="transactions still open on the server", value=open_transactions) + result.add( + check="tracked documents leaked", value=max(0, tracking.tracked_count() - tracked_before - 1) + ) + ok = not unexpected and open_transactions == 0 and tracking.tracked_count() - tracked_before <= 1 + return _verdict( + result, + ok, + f"{cancelled} of {count} tasks cancelled; {open_transactions} open transactions afterwards", + ) + + +async def x5(settings: Settings) -> Result: + result = Result( + "X5", + "Result documents near 16MB", + "What happens when a $lookup would build a document over the BSON limit?", + ) + ns = MODELS["msgspec"] + async with environment(settings) as registry: + parents, children = registry.manager(ns.Parent), registry.manager(ns.Child) + payload = "x" * (1024 * 1024) + ids = [] + for i in range(18): # 18 x 1MB children referenced by a single parent + child = await children.insert(ns.Child(n=i, label=f"c{i}", payload=payload)) + ids.append(child.id) + await parents.insert(ns.Parent(n=0, name="big", children=ids)) + try: + await parents.find().include("children", strategy="lookup").all() + lookup = "returned" + except DocumentTooLarge: + lookup = "DocumentTooLarge" + except MongoSpecsError as exc: + lookup = f"{type(exc).__name__}: {exc}"[:120] + except Exception as exc: + lookup = f"UNTYPED {type(exc).__name__}" + loaded = await parents.find().include("children", strategy="batch").one() + batch_loaded = sum(ref.loaded for ref in loaded.children) + result.add(check="$lookup outcome", value=lookup) + result.add(check="batch strategy loaded children", value=batch_loaded) + ok = not lookup.startswith("UNTYPED") and lookup != "returned" and batch_loaded == 18 + return _verdict( + result, ok, f"$lookup over the limit -> {lookup}; batch strategy loaded all {batch_loaded} children" + ) + + +async def x6(settings: Settings) -> Result: + result = Result( + "X6", + "Change stream across a primary step-down", + "Does a consumer see every event exactly once across a step-down?", + ) + ns = MODELS["msgspec"] + async with environment(settings) as registry: + manager = registry.manager(ns.Plain) + await manager.insert(ns.Plain(n=-1, name="create collection")) + seen: list[int] = [] + stream = manager.watch([{"$match": {"operationType": "insert"}}], max_await_time_ms=500) + await stream._open() + + async def consume(expected: int) -> None: + async for event in stream: + seen.append(event.document.n) + if len(seen) == expected: + return + + consumer = asyncio.create_task(consume(20)) + for i in range(10): + await manager.insert(ns.Plain(n=i, name="before")) + stepped = "not attempted" + if settings.topology == "rs": + try: + await registry.client.admin.command("replSetStepDown", 2, force=True) + stepped = "stepped down" + except Exception as exc: # the command drops connections; that is the point + stepped = f"stepped down ({type(exc).__name__})" + inserted_after = 0 + deadline = asyncio.get_running_loop().time() + 90 + while inserted_after < 10 and asyncio.get_running_loop().time() < deadline: + try: + await manager.insert(ns.Plain(n=10 + inserted_after, name="after")) + inserted_after += 1 + except MongoSpecsError: + await asyncio.sleep(0.5) # no primary yet + try: + await asyncio.wait_for(consumer, timeout=60) + except TimeoutError: + consumer.cancel() + await stream.close() + result.add(check="step-down", value=stepped) + result.add(check="events seen", value=len(seen)) + result.add(check="duplicates", value=len(seen) - len(set(seen))) + ok = seen == list(range(20)) + return _verdict(result, ok, f"{len(seen)} of 20 events seen in order across the step-down ({stepped})") + + +async def x7(settings: Settings) -> Result: + import sys + + result = Result( + "X7", + "Threads with their own loops and registries", + "Is any state shared across registries when each thread runs its own event loop?", + ) + threads = settings.scale(8, 3) + per_thread = settings.scale(200, 20) + ns = MODELS["msgspec"] + uri = uri_for(settings.topology) + errors: list[str] = [] + counts: dict[int, int] = {} + + def worker(index: int) -> None: + async def main() -> None: + name = f"ms_x7_{index}_{random.randrange(10**9)}" + async with Registry(uri, database=name) as registry: + manager = registry.manager(ns.Managed) # explicit manager: no global binding involved + try: + docs = ( + await manager.insert_many( + [ns.Managed(n=i, name=f"t{index}") for i in range(per_thread)] + ) + ).inserted + for doc in docs[:20]: + doc.n += 1000 + await manager.save(doc) + loaded = await manager.find().all() + if any(d.name != f"t{index}" for d in loaded): + errors.append(f"thread {index} saw another thread's documents") + counts[index] = len(loaded) + finally: + await registry.client.drop_database(name) + + try: + asyncio.run(main()) + except Exception as exc: + errors.append(f"thread {index}: {type(exc).__name__}: {exc}") + + pool = [threading.Thread(target=worker, args=(i,)) for i in range(threads)] + for thread in pool: + thread.start() + for thread in pool: + thread.join() + free_threaded = not getattr(sys, "_is_gil_enabled", lambda: True)() + result.add(check="free-threaded build", value=free_threaded) + result.add(check="threads", value=threads) + result.add(check="errors", value=len(errors)) + result.notes.extend(errors[:5]) + ok = not errors and all(counts.get(i) == per_thread for i in range(threads)) + return _verdict( + result, + ok, + f"{threads} threads x {per_thread} documents, each with its own loop and registry; free-threaded={free_threaded}", + ) + + +SCENARIOS: dict[str, Scenario] = {"X1": x1, "X2": x2, "X3": x3, "X4": x4, "X5": x5, "X6": x6, "X7": x7} diff --git a/bench/src/mongospecs_bench/tracing.py b/bench/src/mongospecs_bench/tracing.py new file mode 100644 index 0000000..4afaa95 --- /dev/null +++ b/bench/src/mongospecs_bench/tracing.py @@ -0,0 +1,204 @@ +"""End-to-end tracing check (plan node E3). + +Runs a small mongospecs-web application with the Sentry SDK pointed at a local +rustrak server (``docker/compose.rustrak.yaml``), makes a few HTTP requests and +then asks rustrak what it received. The check passes when every request shows +up as one trace holding the HTTP transaction and the ``Model.op`` database spans +recorded by ``mongospecs.sentry_listener``. +""" + +from __future__ import annotations + +import asyncio +import datetime as dt +import os +import typing as t +import uuid +from dataclasses import dataclass, field + +import httpx + +from mongospecs import CreatedAt, Policy, Ref, Registry, Revision, SpecConfig, sentry_listener +from mongospecs.msgspec import Spec +from mongospecs_web import Api, Resource + +RUSTRAK_URL = os.environ.get("RUSTRAK_URL", "http://localhost:8787") +RUSTRAK_USER = os.environ.get("RUSTRAK_SUPERUSER", "admin@example.com:mongospecs-dev") +MONGO_URI = os.environ.get("MONGOSPECS_TEST_URI", "mongodb://localhost:27117/?replicaSet=rs0") + + +@dataclass +class TraceReport: + project: str + dsn: str + requests: list[str] = field(default_factory=list) + transactions: list[dict[str, t.Any]] = field(default_factory=list) + problems: list[str] = field(default_factory=list) + + @property + def ok(self) -> bool: + return not self.problems + + def render(self) -> str: + lines = [f"rustrak project {self.project!r}", f"dsn {self.dsn}", ""] + for tx in self.transactions: + lines.append(f" {tx['name']} trace={tx['trace_id'][:8]} {tx['duration_ms']:.1f}ms") + for span in tx["spans"]: + lines.append(f" {span['op']:<6} {span['description']:<22} {span['status']}") + lines.append("") + lines.extend(f"PROBLEM: {p}" for p in self.problems) + lines.append("end-to-end tracing: " + ("OK" if self.ok else "FAILED")) + return "\n".join(lines) + + +async def _rustrak_project(client: httpx.AsyncClient) -> tuple[int, str]: + email, _, password = RUSTRAK_USER.partition(":") + login = await client.post("/auth/login", json={"email": email, "password": password}) + login.raise_for_status() + created = await client.post("/api/projects", json={"name": f"mongospecs-e2e-{uuid.uuid4().hex[:8]}"}) + created.raise_for_status() + project = created.json() + key = str(project["sentry_key"]).replace("-", "") + host = RUSTRAK_URL.split("://", 1)[1] + scheme = RUSTRAK_URL.split("://", 1)[0] + return int(project["id"]), f"{scheme}://{key}@{host}/{project['id']}" + + +class Writer(Spec): + __spec__ = SpecConfig(collection="writers") + name: str = "" + + +class Story(Spec): + __spec__ = SpecConfig(collection="stories") + title: t.Annotated[str, Policy(filter=("eq",), sort=True)] = "" + words: int = 0 + writer: Ref[Writer] | None = None + created_at: t.Annotated[dt.datetime | None, CreatedAt] = None + rev: t.Annotated[int, Revision] = 0 + + +def _build_app(registry: t.Any) -> t.Any: + registry.bind(Writer, Story) + return Api(Resource(Writer), Resource(Story, include=["writer"]), prefix="/api").asgi() + + +async def run_trace_check(*, wait_seconds: float = 30.0) -> TraceReport: + import sentry_sdk + from sentry_sdk.integrations.asgi import SentryAsgiMiddleware + + async with httpx.AsyncClient(base_url=RUSTRAK_URL, timeout=10) as rustrak: + project_id, dsn = await _rustrak_project(rustrak) + report = TraceReport(f"project {project_id}", dsn) + sentry_sdk.init( + dsn=dsn, + traces_sample_rate=1.0, + default_integrations=False, + auto_enabling_integrations=False, + release="mongospecs-bench", + ) + database = f"ms_trace_{uuid.uuid4().hex[:8]}" + try: + async with Registry(MONGO_URI, database=database, on_command=sentry_listener()) as registry: + inner = _build_app(registry) + app = SentryAsgiMiddleware(inner, transaction_style="url") + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://app" + ) as client: + writer = (await client.post("/api/writers", json={"name": "Ann"})).json()["data"] + story = ( + await client.post("/api/stories", json={"title": "Traced", "writer": writer["id"]}) + ).json()["data"] + await client.get("/api/stories?include=writer&sort=title") + await client.patch(f"/api/stories/{story['id']}", json={"words": 120}) + report.requests = [ + "POST writers", + "POST stories", + "GET stories?include", + "PATCH stories/{id}", + ] + await registry.client.drop_database(database) + sentry_sdk.flush(timeout=10) + finally: + sentry_sdk.init(dsn=None, default_integrations=False, auto_enabling_integrations=False) + + # rustrak ingests in two phases; poll until the transactions are queryable. + deadline = asyncio.get_running_loop().time() + wait_seconds + items: list[dict[str, t.Any]] = [] + while asyncio.get_running_loop().time() < deadline: + listed = await rustrak.get(f"/api/projects/{project_id}/transactions") + listed.raise_for_status() + body = listed.json() + items = body.get("items", body if isinstance(body, list) else []) + if len(items) >= len(report.requests): + break + await asyncio.sleep(0.5) + for item in items: + detail = await rustrak.get(f"/api/projects/{project_id}/transactions/{item['id']}") + detail.raise_for_status() + report.transactions.append(_summarise(detail.json())) + + report.transactions.sort(key=lambda tx: tx["start"]) + _verify(report) + return report + + +def _summarise(detail: dict[str, t.Any]) -> dict[str, t.Any]: + data = detail.get("data") or detail.get("event") or detail + trace = (data.get("contexts") or {}).get("trace") or {} + spans = data.get("spans") or detail.get("spans") or [] + return { + "name": detail.get("transaction") or detail.get("name") or data.get("transaction") or "?", + "trace_id": detail.get("trace_id") or trace.get("trace_id") or "", + "duration_ms": float(detail.get("duration_ms") or detail.get("duration") or 0.0), + "start": str( + detail.get("start_timestamp") or data.get("start_timestamp") or detail.get("timestamp") or "" + ), + "spans": [ + { + "op": span.get("op") or "", + "description": span.get("description") or span.get("name") or "", + "status": span.get("status") or "", + "trace_id": span.get("trace_id") or "", + } + for span in spans + ], + } + + +EXPECTED: list[tuple[str, list[str]]] = [ + ("writers", ["Writer.insert"]), + ("stories", ["Story.insert"]), + ("stories", ["Story.find", "Writer.find"]), + ("stories", ["Story.update"]), +] + + +def _verify(report: TraceReport) -> None: + if len(report.transactions) != len(EXPECTED): + report.problems.append( + f"expected {len(EXPECTED)} transactions in rustrak, found {len(report.transactions)}" + ) + return + for tx, (path_part, wanted) in zip(report.transactions, EXPECTED, strict=True): + if path_part not in tx["name"]: + report.problems.append(f"transaction {tx['name']!r} does not look like a request to {path_part}") + db = [s["description"] for s in tx["spans"] if s["op"] == "db"] + if db != wanted: + report.problems.append(f"{tx['name']}: database spans {db} != expected {wanted}") + if any(s["trace_id"] and s["trace_id"] != tx["trace_id"] for s in tx["spans"]): + report.problems.append(f"{tx['name']}: a span belongs to a different trace") + if any(s["status"] not in ("ok", "") for s in tx["spans"] if s["op"] == "db"): + report.problems.append(f"{tx['name']}: a database span did not finish ok") + if len({tx["trace_id"] for tx in report.transactions}) != len(report.transactions): + report.problems.append("requests did not get one trace each") + + +def main() -> int: + report = asyncio.run(run_trace_check()) + print(report.render()) + return 0 if report.ok else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/bench/src/mongospecs_bench/workloads.py b/bench/src/mongospecs_bench/workloads.py new file mode 100644 index 0000000..3b16d05 --- /dev/null +++ b/bench/src/mongospecs_bench/workloads.py @@ -0,0 +1,643 @@ +"""Benchmark workloads B1-B8 (plan section 6). Each returns a :class:`Result`. + +Everything that touches the network runs against real MongoDB. Numbers from a +``--quick`` run only prove the harness works; they are not for conclusions. +""" + +from __future__ import annotations + +import gc +import tracemalloc +import typing as t + +import bson +import httpx + +from mongospecs import Inc, Plugin, Q, meta_of, relations, tracking +from mongospecs.query import _encode_cursor +from mongospecs_bench.harness import Result, Settings, environment, measure, measure_sync +from mongospecs_bench.models import MODELS, SHAPES, model_for, raw_document +from mongospecs_web import Api, Resource + +Workload = t.Callable[[Settings], t.Awaitable[Result]] + + +def _ratio(value: float, base: float) -> float: + return round(value / base, 2) if base else 0.0 + + +# -- B1 ------------------------------------------------------------------------------- + + +async def b1(settings: Settings) -> Result: + result = Result( + "B1", + "Materialise documents", + "What does turning a raw document into a model cost, per backend, and how does it compare " + "with the driver's own BSON decoding?", + ) + n = settings.scale(10_000, 300) + for shape in SHAPES: + docs = [{"_id": bson.ObjectId(), **raw_document(shape, i)} for i in range(n)] + encoded = [bson.encode(d) for d in docs] + options: t.Any = bson.codec_options.CodecOptions(tz_aware=True) + base = measure_sync(lambda: [bson.decode(b, options) for b in encoded], repeat=settings.repeat) + result.add( + shape=shape, + backend="driver", + mode="bson->dict", + n=n, + us_per_doc=round(base.median_ms * 1000 / n, 2), + vs_bson=1.0, + ) + for backend in settings.backends: + meta = meta_of(model_for(backend, shape)) + modes: dict[str, t.Callable[[], t.Any]] = { + "validated": lambda: [meta.decode(d, trusted=False) for d in docs], + "trusted": lambda: [meta.decode(d, trusted=True) for d in docs], + "tracked": lambda: [meta.encode(meta.decode(d, trusted=False)) for d in docs], + } + for mode, fn in modes.items(): + timing = measure_sync(fn, repeat=settings.repeat) + result.add( + shape=shape, + backend=backend, + mode=mode, + n=n, + us_per_doc=round(timing.median_ms * 1000 / n, 2), + vs_bson=_ratio(timing.median_ms, base.median_ms), + ) + result.notes.append("'tracked' is decode plus the encode that builds the change-tracking snapshot.") + result.notes.append("'trusted' only differs for flat pydantic models; msgspec and attrs always convert.") + return result + + +# -- B2 ------------------------------------------------------------------------------- + + +async def b2(settings: Settings) -> Result: + result = Result( + "B2", + "find() end to end", + "How much does the ODM add on top of the raw driver for reads of 1, 100 and 10,000 documents?", + ) + total = settings.scale(10_000, 300) + sizes = [n for n in (1, 100, 10_000) if n <= total] or [total] + async with environment(settings) as registry: + for shape in ("small", "nested"): + for backend in settings.backends: + model = model_for(backend, shape) + manager: t.Any = registry.manager(model) + if backend == settings.backends[0]: + await manager.collection.insert_many([raw_document(shape, i) for i in range(total)]) + for n in sizes: + collection = manager.collection + raw = await measure( + lambda: collection.find({}, limit=n).to_list(), repeat=settings.repeat + ) + cases = { + "odm raw()": lambda: manager.find().limit(n).raw().all(), + "odm lean()": lambda: manager.find().limit(n).lean().all(), + "odm tracked": lambda: manager.find().limit(n).all(), + } + if backend == settings.backends[0]: + result.add( + shape=shape, + backend="pymongo", + case="driver dicts", + n=n, + median_ms=round(raw.median_ms, 3), + vs_driver=1.0, + ) + for case, fn in cases.items(): + timing = await measure(fn, repeat=settings.repeat) + result.add( + shape=shape, + backend=backend, + case=case, + n=n, + median_ms=round(timing.median_ms, 3), + vs_driver=_ratio(timing.median_ms, raw.median_ms), + ) + await registry.manager(model_for(settings.backends[0], shape)).collection.drop() + return result + + +# -- B3 ------------------------------------------------------------------------------- + + +async def b3(settings: Settings) -> Result: + result = Result( + "B3", + "include(): $lookup vs batched $in", + "Which strategy should include(strategy='auto') use, across fan-outs and topologies?", + ) + plans = [(1, 400), (10, 200), (100, 50), (1000, 10)] + if settings.quick: + plans = [(1, 20), (10, 10)] + ns = MODELS["msgspec"] + async with environment(settings) as registry: + parents, children = registry.manager(ns.Parent), registry.manager(ns.Child) + if settings.topology == "sharded": + db = parents.collection.database.name + await registry.client.admin.command( + "shardCollection", f"{db}.bench_children", key={"_id": "hashed"} + ) + for fan_out, parent_count in plans: + await parents.collection.delete_many({}) + await children.collection.delete_many({}) + child_docs = [ + {"_id": bson.ObjectId(), "n": i, "label": f"c{i}", "payload": "x" * 200} + for i in range(fan_out * parent_count) + ] + await children.collection.insert_many(child_docs) + await parents.collection.insert_many( + [ + { + "n": p, + "name": f"p{p}", + "children": [c["_id"] for c in child_docs[p * fan_out : (p + 1) * fan_out]], + } + for p in range(parent_count) + ] + ) + timings = {} + strategy: t.Any + for strategy in ("batch", "lookup"): + timings[strategy] = await measure( + lambda: parents.find().lean().include("children", strategy=strategy).all(), + repeat=settings.repeat, + ) + result.add( + relation="list, distinct children", + fan_out=fan_out, + parents=parent_count, + batch_ms=round(timings["batch"].median_ms, 3), + lookup_ms=round(timings["lookup"].median_ms, 3), + lookup_vs_batch=_ratio(timings["lookup"].median_ms, timings["batch"].median_ms), + ) + # To-one with heavy sharing: many parents, few targets. $lookup repeats each target per parent. + await parents.collection.delete_many({}) + await children.collection.delete_many({}) + owners = [ + {"_id": bson.ObjectId(), "n": i, "label": f"o{i}", "payload": "x" * 2000} for i in range(20) + ] + await children.collection.insert_many(owners) + count = settings.scale(2000, 100) + await parents.collection.insert_many( + [{"n": p, "name": f"p{p}", "owner": owners[p % 20]["_id"]} for p in range(count)] + ) + timings = {} + for strategy in ("batch", "lookup"): + timings[strategy] = await measure( + lambda: parents.find().lean().include("owner", strategy=strategy).all(), + repeat=settings.repeat, + ) + result.add( + relation="to-one, 20 shared targets", + fan_out=1, + parents=count, + batch_ms=round(timings["batch"].median_ms, 3), + lookup_ms=round(timings["lookup"].median_ms, 3), + lookup_vs_batch=_ratio(timings["lookup"].median_ms, timings["batch"].median_ms), + ) + ratios = [row["lookup_vs_batch"] for row in result.rows] + wins = sum(1 for r in ratios if r < 0.95) + result.conclusion = ( + f"$lookup was faster than batched $in in {wins} of {len(ratios)} cases on {settings.topology} " + f"(lookup/batch ratios: {ratios}). Current default: {relations.DEFAULT_STRATEGY}." + ) + result.notes.append( + "Strategy choice does not depend on the model backend; measured with msgspec, lean reads." + ) + return result + + +# -- B4 ------------------------------------------------------------------------------- + + +async def b4(settings: Settings) -> Result: + result = Result("B4", "Writes", "What does the ODM add to inserts, atomic patches, saves and upserts?") + n = settings.scale(1000, 50) + ops = settings.scale(200, 20) + async with environment(settings) as registry: + for backend in settings.backends: + ns = MODELS[backend] + plain, managed = registry.manager(ns.Plain), registry.manager(ns.Managed) + collection = plain.collection + + async def reset() -> None: + await collection.delete_many({}) + await managed.collection.delete_many({}) + + raw_insert = await measure( + lambda: collection.insert_many([{"n": i, "name": f"n{i}"} for i in range(n)]), + repeat=settings.repeat, + setup=reset, + ) + cases: dict[str, t.Callable[[], t.Awaitable[t.Any]]] = { + "insert_many (no plugins)": lambda: plain.insert_many( + [ns.Plain(n=i, name=f"n{i}") for i in range(n)] + ), + "insert_many (timestamps+revision)": lambda: managed.insert_many( + [ns.Managed(n=i, name=f"n{i}") for i in range(n)] + ), + } + result.add( + backend=backend, + op=f"insert {n}", + case="driver insert_many", + median_ms=round(raw_insert.median_ms, 3), + vs_driver=1.0, + ) + for case, fn in cases.items(): + timing = await measure(fn, repeat=settings.repeat, setup=reset) + result.add( + backend=backend, + op=f"insert {n}", + case=case, + median_ms=round(timing.median_ms, 3), + vs_driver=_ratio(timing.median_ms, raw_insert.median_ms), + ) + + await reset() + docs = (await plain.insert_many([ns.Plain(n=i, name=f"n{i}") for i in range(ops)])).inserted + ids = [d.id for d in docs] + + async def raw_patch() -> None: + for _id in ids: + await collection.update_one({"_id": _id}, {"$inc": {"n": 1}}) + + async def odm_patch() -> None: + for _id in ids: + await plain.update(_id, Inc(n=1)) + + async def odm_save() -> None: + for doc in docs: + doc.n += 1 + await plain.save(doc) + + async def raw_upsert() -> None: + for i in range(ops): + await collection.update_one({"name": f"u{i}"}, {"$inc": {"n": 1}}, upsert=True) + + async def odm_upsert() -> None: + for i in range(ops): + await plain.upsert(Q.name == f"u{i}", Inc(n=1)) + + base = await measure(raw_patch, repeat=settings.repeat) + result.add( + backend=backend, + op=f"patch x{ops}", + case="driver update_one", + median_ms=round(base.median_ms, 3), + vs_driver=1.0, + ) + for case, fn in (("Model.update (returns doc)", odm_patch), ("doc.save() delta", odm_save)): + timing = await measure(fn, repeat=settings.repeat) + result.add( + backend=backend, + op=f"patch x{ops}", + case=case, + median_ms=round(timing.median_ms, 3), + vs_driver=_ratio(timing.median_ms, base.median_ms), + ) + base = await measure(raw_upsert, repeat=settings.repeat) + timing = await measure(odm_upsert, repeat=settings.repeat) + result.add( + backend=backend, + op=f"upsert x{ops}", + case="driver update_one(upsert)", + median_ms=round(base.median_ms, 3), + vs_driver=1.0, + ) + result.add( + backend=backend, + op=f"upsert x{ops}", + case="Model.upsert (returns doc)", + median_ms=round(timing.median_ms, 3), + vs_driver=_ratio(timing.median_ms, base.median_ms), + ) + await reset() + result.notes.append( + "Model.update and Model.upsert use findAndModify to return the document; the driver baseline does not." + ) + return result + + +# -- B5 ------------------------------------------------------------------------------- + + +async def b5(settings: Settings) -> Result: + result = Result( + "B5", + "Pagination", + "How do offset and cursor pagination compare at depth, and should a total come from $facet or a count?", + ) + total = settings.scale(50_000, 2_000) + size = 20 + ns = MODELS["msgspec"] + async with environment(settings) as registry: + manager = registry.manager(ns.Small) + await manager.collection.insert_many([raw_document("small", i) for i in range(total)]) + await manager.collection.create_index([("n", 1)]) + for page in (1, 100, total // size // 2, total // size - 1): + if page < 1 or page * size > total: + continue + skip = (page - 1) * size + offset = await measure( + lambda: manager.find().sort("n").lean().page(size, number=page), repeat=settings.repeat + ) + if skip: + last = await manager.collection.find_one({}, sort=[("n", 1)], skip=skip - 1) + assert last is not None + token: str | None = _encode_cursor([last["n"], last["_id"]]) + else: + token = None + cursor = await measure( + lambda: manager.find().sort("n").lean().page(size, after=token), repeat=settings.repeat + ) + result.add( + page=page, + skip=skip, + offset_ms=round(offset.median_ms, 3), + cursor_ms=round(cursor.median_ms, 3), + offset_vs_cursor=_ratio(offset.median_ms, cursor.median_ms), + ) + flt = {"n": {"$gte": total // 4}} + facet_pipeline: list[dict[str, t.Any]] = [ + {"$match": flt}, + {"$facet": {"items": [{"$sort": {"n": 1}}, {"$limit": size}], "total": [{"$count": "n"}]}}, + ] + + async def facet() -> t.Any: + cursor_ = await manager.collection.aggregate(facet_pipeline) + return await cursor_.to_list() + + concurrent = await measure( + lambda: manager.find(Q.n >= total // 4).sort("n").lean().page(size, number=1, total=True), + repeat=settings.repeat, + ) + faceted = await measure(facet, repeat=settings.repeat) + result.add( + page="total: concurrent count", + skip=0, + offset_ms=round(concurrent.median_ms, 3), + cursor_ms="", + offset_vs_cursor="", + ) + result.add( + page="total: $facet (raw driver)", + skip=0, + offset_ms=round(faceted.median_ms, 3), + cursor_ms="", + offset_vs_cursor="", + ) + result.notes.append(f"Collection of {total} documents, page size {size}, sorted on an indexed field.") + result.notes.append( + "$facet returns the page and the total as one document, so the page is bound by the 16MB limit." + ) + return result + + +# -- B6 ------------------------------------------------------------------------------- + + +class _Noop(Plugin): + def __init__(self, index: int) -> None: + self.name = f"noop{index}" + + +async def b6(settings: Settings) -> Result: + result = Result("B6", "Plugin overhead", "What does each active plugin cost per operation?") + ops = settings.scale(300, 20) + ns = MODELS["msgspec"] + baseline: dict[str, float] = {} + for count in (0, 3, 10): + async with environment(settings, plugins=[_Noop(i) for i in range(count)]) as registry: + manager = registry.manager(ns.Plain) + + async def inserts() -> None: + for i in range(ops): + await manager.insert(ns.Plain(n=i)) + + docs = (await manager.insert_many([ns.Plain(n=i) for i in range(ops)])).inserted + + async def saves() -> None: + for doc in docs: + doc.n += 1 + await manager.save(doc) + + async def finds() -> None: + for doc in docs: + await manager.get(doc.id) + + for op, fn in (("insert", inserts), ("save", saves), ("get", finds)): + timing = await measure(fn, repeat=settings.repeat) + per_op = timing.median_ms * 1000 / ops + baseline.setdefault(op, per_op) + result.add( + plugins=f"{count} no-op", + op=op, + us_per_op=round(per_op, 1), + vs_no_plugins=_ratio(per_op, baseline[op]), + ) + async with environment(settings) as registry: + manager = registry.manager(ns.Managed) + + async def managed_inserts() -> None: + for i in range(ops): + await manager.insert(ns.Managed(n=i)) + + timing = await measure(managed_inserts, repeat=settings.repeat) + per_op = timing.median_ms * 1000 / ops + result.add( + plugins="timestamps+revision", + op="insert", + us_per_op=round(per_op, 1), + vs_no_plugins=_ratio(per_op, baseline["insert"]), + ) + result.notes.append( + "Each operation is one round trip to MongoDB, which dominates; plugin calls are in-process." + ) + return result + + +# -- B7 ------------------------------------------------------------------------------- + + +def _apps(api: Api) -> dict[str, t.Any]: + from fastapi import FastAPI + from litestar import Litestar + from quart import Quart + from starlette.applications import Starlette + + from mongospecs_web.plugs import fastapi as fastapi_plug + from mongospecs_web.plugs import quart as quart_plug + from mongospecs_web.plugs import starlette as starlette_plug + from mongospecs_web.plugs.litestar import MongospecsPlugin + + fast = FastAPI() + fastapi_plug.mount(fast, api) + quart = Quart(__name__) + quart_plug.init_app(quart, api) + return { + "asgi": api.asgi(), + "starlette": Starlette(routes=starlette_plug.routes(api)), + "fastapi": fast, + "litestar": Litestar(plugins=[MongospecsPlugin(api)], openapi_config=None), + "quart": quart, + } + + +async def b7(settings: Settings) -> Result: + result = Result( + "B7", + "HTTP request to response", + "What does each plug add over calling the API core directly, per model backend?", + ) + calls = settings.scale(200, 10) + async with environment(settings) as registry: + for backend in settings.backends: + ns = MODELS[backend] + manager = registry.manager(ns.Small) + await manager.collection.delete_many({}) + await manager.collection.insert_many([raw_document("small", i) for i in range(200)]) + first = await manager.collection.find_one({}) + assert first is not None + from mongospecs_web.backend import MongoBackend + + api = Api( + Resource(ns.Small, name="small", backend=MongoBackend(ns.Small, manager)), prefix="/api" + ) + one, many = f"/api/small/{first['_id']}", "/api/small?limit=20" + + async def direct(path: str) -> None: + for _ in range(calls): + await api.handle( + "GET", path.split("?")[0], query=[("limit", "20")] if "?" in path else [] + ) + + base = { + "get one": await measure(lambda: direct(one), repeat=settings.repeat), + "list 20": await measure(lambda: direct(many), repeat=settings.repeat), + } + for case, timing in base.items(): + result.add( + backend=backend, + plug="(core, no HTTP)", + case=case, + us_per_request=round(timing.median_ms * 1000 / calls, 1), + vs_core=1.0, + ) + for plug, app in _apps(api).items(): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://bench" + ) as client: + + async def hit(path: str) -> None: + for _ in range(calls): + response = await client.get(path) + assert response.status_code == 200, response.text + + for case, path in (("get one", one), ("list 20", many)): + timing = await measure(lambda: hit(path), repeat=settings.repeat) + per = timing.median_ms * 1000 / calls + result.add( + backend=backend, + plug=plug, + case=case, + us_per_request=round(per, 1), + vs_core=_ratio(per, base[case].median_ms * 1000 / calls), + ) + result.notes.append( + "Requests go through httpx's in-process ASGI transport, so client overhead is included equally for every plug." + ) + return result + + +# -- B8 ------------------------------------------------------------------------------- + + +async def b8(settings: Settings) -> Result: + result = Result( + "B8", + "Cost of change tracking", + "What do tracked reads cost in time and memory over lean and raw reads, and what does a delta save send?", + ) + n = settings.scale(5000, 200) + async with environment(settings) as registry: + for backend in settings.backends: + manager: t.Any = registry.manager(model_for(backend, "nested")) + await manager.collection.delete_many({}) + await manager.collection.insert_many([raw_document("nested", i) for i in range(n)]) + timings = { + "raw()": await measure(lambda: manager.find().raw().all(), repeat=settings.repeat), + "lean()": await measure(lambda: manager.find().lean().all(), repeat=settings.repeat), + "tracked": await measure(lambda: manager.find().all(), repeat=settings.repeat), + } + memory: dict[str, float] = {} + for mode, query in ( + ("raw()", manager.find().raw()), + ("lean()", manager.find().lean()), + ("tracked", manager.find()), + ): + gc.collect() + tracemalloc.start() + before = tracemalloc.get_traced_memory()[0] + held = await query.all() + memory[mode] = (tracemalloc.get_traced_memory()[0] - before) / len(held) + tracemalloc.stop() + del held + for mode, timing in timings.items(): + result.add( + backend=backend, + mode=mode, + n=n, + median_ms=round(timing.median_ms, 2), + vs_lean=_ratio(timing.median_ms, timings["lean()"].median_ms), + bytes_per_doc=round(memory[mode]), + ) + doc = await manager.find().first() + assert doc is not None + full = bson.encode( + {"$set": {k: v for k, v in meta_of(type(doc)).encode(doc).items() if k != "_id"}} + ) + t.cast(t.Any, doc).customer = "changed" + delta = bson.encode(t.cast(t.Any, doc).changes()) + result.add( + backend=backend, + mode="save payload: full $set", + n=1, + median_ms="", + vs_lean="", + bytes_per_doc=len(full), + ) + result.add( + backend=backend, + mode="save payload: delta", + n=1, + median_ms="", + vs_lean="", + bytes_per_doc=len(delta), + ) + gc.collect() + result.notes.append( + f"{tracking.tracked_count()} tracked documents remained after the run (should be ~0)." + ) + result.notes.append( + "bytes_per_doc for reads is Python heap per document held; for save payloads it is the BSON update size." + ) + return result + + +WORKLOADS: dict[str, Workload] = { + "B1": b1, + "B2": b2, + "B3": b3, + "B4": b4, + "B5": b5, + "B6": b6, + "B7": b7, + "B8": b8, +} diff --git a/bench/tests/test_bench_smoke.py b/bench/tests/test_bench_smoke.py new file mode 100644 index 0000000..c63eca6 --- /dev/null +++ b/bench/tests/test_bench_smoke.py @@ -0,0 +1,44 @@ +"""The harness must keep working: every workload and scenario runs in quick mode.""" + +import json + +import pytest + +from mongospecs_bench import harness +from mongospecs_bench.cli import report +from mongospecs_bench.harness import Settings, metadata, write_result +from mongospecs_bench.stress import SCENARIOS +from mongospecs_bench.workloads import WORKLOADS + +QUICK = Settings(quick=True) + + +@pytest.mark.parametrize("name", sorted(WORKLOADS)) +async def test_workload_runs(name): + result = await WORKLOADS[name](QUICK) + assert result.workload == name and result.rows + assert all(isinstance(row, dict) for row in result.rows) + + +@pytest.mark.parametrize("name", sorted(set(SCENARIOS) - {"X6"})) +async def test_stress_scenario_passes(name): + result = await SCENARIOS[name](QUICK) + assert result.conclusion.startswith("PASS"), result.conclusion + + +@pytest.mark.sharded +async def test_include_workload_on_sharded_cluster(): + result = await WORKLOADS["B3"](Settings(topology="sharded", quick=True)) + assert all(row["batch_ms"] > 0 and row["lookup_ms"] > 0 for row in result.rows) + + +async def test_results_are_written_and_reported(tmp_path, monkeypatch): + monkeypatch.setattr(harness, "RESULTS", tmp_path) + monkeypatch.setattr("mongospecs_bench.cli.RESULTS", tmp_path) + meta = await metadata(QUICK) + result = await WORKLOADS["B6"](QUICK) + path = write_result(result, meta) + data = json.loads(path.read_text()) + assert data["workload"] == "B6" and data["meta"]["mongodb"] and data["meta"]["quick"] is True + text = report() + assert "## B6: Plugin overhead" in text and "| plugins |" in text diff --git a/docker/compose.rustrak.yaml b/docker/compose.rustrak.yaml new file mode 100644 index 0000000..72fadc0 --- /dev/null +++ b/docker/compose.rustrak.yaml @@ -0,0 +1,19 @@ +# rustrak: a self-hosted, Sentry-compatible server, used to verify tracing end to end. +# docker compose -f docker/compose.rustrak.yaml up -d --wait +# dashboard and API: http://localhost:8787 (admin@example.com / mongospecs-dev) +# Development only: the credentials below are throwaway defaults. +name: mongospecs-rustrak +services: + rustrak: + image: rustrak/rustrak-server:${RUSTRAK_VERSION:-latest} + ports: + - "8787:8080" + environment: + - SESSION_SECRET_KEY=${RUSTRAK_SESSION_SECRET:-0000000000000000000000000000000000000000000000000000000000000000} + - CREATE_SUPERUSER=${RUSTRAK_SUPERUSER:-admin@example.com:mongospecs-dev} + - RUSTRAK_TELEMETRY=off + volumes: + - rustrak_data:/data + +volumes: + rustrak_data: diff --git a/pyproject.toml b/pyproject.toml index 55cdf73..52809c9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -56,6 +56,8 @@ ignore = ["E501", "RUF012"] [tool.ruff.lint.per-file-ignores] "**/tests/**" = ["B", "RUF"] +# Benchmarks define closures inside loops and call them before the next iteration. +"bench/**" = ["B023"] [tool.mypy] python_version = "3.11" diff --git a/uv.lock b/uv.lock index 249bda8..57e90f6 100644 --- a/uv.lock +++ b/uv.lock @@ -1100,14 +1100,14 @@ version = "0.0.0" source = { editable = "bench" } dependencies = [ { name = "httpx" }, - { name = "mongospecs", extra = ["all"] }, + { name = "mongospecs", extra = ["all", "sentry"] }, { name = "mongospecs-web", extra = ["all"] }, ] [package.metadata] requires-dist = [ { name = "httpx", specifier = ">=0.27" }, - { name = "mongospecs", extras = ["all"], editable = "packages/mongospecs" }, + { name = "mongospecs", extras = ["all", "sentry"], editable = "packages/mongospecs" }, { name = "mongospecs-web", extras = ["all"], editable = "packages/mongospecs-web" }, ] From 3c415daa65026da79fe7ff279dc0f081d21056ce Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 00:25:23 -0400 Subject: [PATCH 06/31] perf(core): raw-BSON snapshots and batched cursor reads; e2e storefront Changes driven by the first full benchmark run and by booting a real application. Performance - tracked reads keep the raw BSON the driver received as the snapshot and build the comparable form only when a document is saved, instead of re-encoding every loaded document - cursors are drained with to_list() batches, not one await per document - remove the 'trusted' read flag: the pydantic fast path it enabled was slower than validating, and it did nothing on msgspec and attrs Fixes found by the end-to-end tests - aggregate, union, graph and backref-count paths sent max_time_ms in find() spelling and failed whenever a default time limit was set - web: unexpected errors are now logged server-side (the client still gets a bare 500) - ids given as strings to reference fields are coerced to the target's id type on backends that do not validate on construction - stored documents are read leniently on strict backends (an integer stored as a double), input stays strict - a document stored before a Revision field existed can be saved Reference application and tests - bench: 'storefront', a production-shaped app (auth, admin guards, row scope, transactional orders that cannot oversell, soft delete, ETags, reviews as a reverse relation, a resumable exactly-once change-stream projector, health check, graceful shutdown) - end-to-end tests boot it under uvicorn and drive it over HTTP on every plug and model backend: customer journey, concurrent buyers, all-or-nothing multi-item orders, projector resume across restarts, in-flight requests on shutdown, consistency across a primary step-down - HTTP load benchmark against the running server with a hand-written pymongo + Starlette baseline of the same endpoints - Resource(server_fields=...) for values the server sets on create - guide pages whose examples are executed by the test suite, migration guide, Mongoose parity page, Mongoose benchmark comparator Co-Authored-By: Claude Opus 5.5 --- bench/README.md | 36 ++ bench/mongoose/.gitignore | 2 + bench/mongoose/bench.mjs | 228 ++++++++ bench/mongoose/package.json | 12 + bench/pyproject.toml | 2 +- bench/results/4f3b2c1/B1.json | 271 ++++++++++ bench/results/4f3b2c1/B2.json | 508 ++++++++++++++++++ bench/results/4f3b2c1/B3.json | 70 +++ bench/results/4f3b2c1/B4.json | 198 +++++++ bench/results/4f3b2c1/B5.json | 73 +++ bench/results/4f3b2c1/B6.json | 90 ++++ bench/src/mongospecs_bench/cli.py | 9 +- bench/src/mongospecs_bench/load.py | 214 ++++++++ .../mongospecs_bench/storefront/__init__.py | 16 + bench/src/mongospecs_bench/storefront/app.py | 405 ++++++++++++++ bench/src/mongospecs_bench/storefront/auth.py | 68 +++ .../src/mongospecs_bench/storefront/models.py | 160 ++++++ .../mongospecs_bench/storefront/projector.py | 87 +++ .../mongospecs_bench/storefront/raw_app.py | 226 ++++++++ bench/src/mongospecs_bench/storefront/seed.py | 132 +++++ .../src/mongospecs_bench/storefront/server.py | 140 +++++ bench/src/mongospecs_bench/workloads.py | 25 +- bench/tests/conftest.py | 80 +++ bench/tests/test_e2e_storefront.py | 388 +++++++++++++ docs/benchmarks.md | 1 + docs/guide/models.md | 134 +++++ docs/guide/operations.md | 92 ++++ docs/guide/plugins.md | 68 +++ docs/guide/queries.md | 129 +++++ docs/guide/relations.md | 93 ++++ docs/guide/writes.md | 159 ++++++ docs/index.md | 44 +- docs/migration.md | 66 +++ docs/parity.md | 68 +++ docs/reference/bulk.md | 3 + docs/reference/errors.md | 3 + docs/reference/expr.md | 3 + docs/reference/hooks.md | 3 + docs/reference/indexes.md | 3 + docs/reference/manager.md | 3 + docs/reference/markers.md | 3 + docs/reference/model.md | 3 + docs/reference/query.md | 3 + docs/reference/refs.md | 3 + docs/reference/registry.md | 3 + docs/reference/relations.md | 3 + docs/reference/streams.md | 3 + docs/reference/updates.md | 3 + docs/reference/web_api.md | 3 + docs/reference/web_backend.md | 3 + docs/reference/web_errors.md | 3 + docs/reference/web_filters.md | 3 + docs/reference/web_openapi.md | 3 + docs/reference/web_policy.md | 3 + docs/reference/web_resource.md | 3 + docs/reference/web_testing.md | 3 + docs/web/plugs.md | 76 +++ docs/web/resources.md | 131 +++++ mkdocs.yaml | 54 +- .../mongospecs-web/src/mongospecs_web/api.py | 6 +- .../src/mongospecs_web/backend.py | 2 +- .../src/mongospecs_web/resource.py | 20 +- .../src/mongospecs_web/testing.py | 2 +- .../tests/test_docs_examples.py | 51 ++ packages/mongospecs/src/mongospecs/attrs.py | 4 +- packages/mongospecs/src/mongospecs/bulk.py | 2 +- packages/mongospecs/src/mongospecs/codec.py | 13 +- packages/mongospecs/src/mongospecs/manager.py | 34 +- packages/mongospecs/src/mongospecs/markers.py | 3 - packages/mongospecs/src/mongospecs/meta.py | 6 +- packages/mongospecs/src/mongospecs/model.py | 11 +- packages/mongospecs/src/mongospecs/msgspec.py | 6 +- .../src/mongospecs/plugins/revision.py | 4 +- .../mongospecs/src/mongospecs/pydantic.py | 23 +- packages/mongospecs/src/mongospecs/query.py | 86 +-- .../mongospecs/src/mongospecs/relations.py | 4 +- .../mongospecs/src/mongospecs/tracking.py | 58 +- .../mongospecs/tests/test_registry_errors.py | 6 + packages/mongospecs/tests/test_tracking_tx.py | 37 ++ pyproject.toml | 1 + uv.lock | 15 + 81 files changed, 4886 insertions(+), 129 deletions(-) create mode 100644 bench/README.md create mode 100644 bench/mongoose/.gitignore create mode 100644 bench/mongoose/bench.mjs create mode 100644 bench/mongoose/package.json create mode 100644 bench/results/4f3b2c1/B1.json create mode 100644 bench/results/4f3b2c1/B2.json create mode 100644 bench/results/4f3b2c1/B3.json create mode 100644 bench/results/4f3b2c1/B4.json create mode 100644 bench/results/4f3b2c1/B5.json create mode 100644 bench/results/4f3b2c1/B6.json create mode 100644 bench/src/mongospecs_bench/load.py create mode 100644 bench/src/mongospecs_bench/storefront/__init__.py create mode 100644 bench/src/mongospecs_bench/storefront/app.py create mode 100644 bench/src/mongospecs_bench/storefront/auth.py create mode 100644 bench/src/mongospecs_bench/storefront/models.py create mode 100644 bench/src/mongospecs_bench/storefront/projector.py create mode 100644 bench/src/mongospecs_bench/storefront/raw_app.py create mode 100644 bench/src/mongospecs_bench/storefront/seed.py create mode 100644 bench/src/mongospecs_bench/storefront/server.py create mode 100644 bench/tests/conftest.py create mode 100644 bench/tests/test_e2e_storefront.py create mode 100644 docs/benchmarks.md create mode 100644 docs/guide/models.md create mode 100644 docs/guide/operations.md create mode 100644 docs/guide/plugins.md create mode 100644 docs/guide/queries.md create mode 100644 docs/guide/relations.md create mode 100644 docs/guide/writes.md create mode 100644 docs/migration.md create mode 100644 docs/parity.md create mode 100644 docs/reference/bulk.md create mode 100644 docs/reference/errors.md create mode 100644 docs/reference/expr.md create mode 100644 docs/reference/hooks.md create mode 100644 docs/reference/indexes.md create mode 100644 docs/reference/manager.md create mode 100644 docs/reference/markers.md create mode 100644 docs/reference/model.md create mode 100644 docs/reference/query.md create mode 100644 docs/reference/refs.md create mode 100644 docs/reference/registry.md create mode 100644 docs/reference/relations.md create mode 100644 docs/reference/streams.md create mode 100644 docs/reference/updates.md create mode 100644 docs/reference/web_api.md create mode 100644 docs/reference/web_backend.md create mode 100644 docs/reference/web_errors.md create mode 100644 docs/reference/web_filters.md create mode 100644 docs/reference/web_openapi.md create mode 100644 docs/reference/web_policy.md create mode 100644 docs/reference/web_resource.md create mode 100644 docs/reference/web_testing.md create mode 100644 docs/web/plugs.md create mode 100644 docs/web/resources.md create mode 100644 packages/mongospecs-web/tests/test_docs_examples.py diff --git a/bench/README.md b/bench/README.md new file mode 100644 index 0000000..aa1eb6a --- /dev/null +++ b/bench/README.md @@ -0,0 +1,36 @@ +# mongospecs-bench + +Benchmarks, stress scenarios and an end-to-end tracing check for mongospecs, all against real +MongoDB. This package is a workspace member and is never published. + +``` +make up # replica set + sharded cluster +uv run mongospecs-bench run # workloads B1-B8 +uv run mongospecs-bench run B3 --topology sharded +uv run mongospecs-bench stress # scenarios X1-X7 +uv run mongospecs-bench report # markdown summary of the newest results + +docker compose -f docker/compose.rustrak.yaml up -d +uv run mongospecs-bench trace # Sentry SDK -> rustrak, end to end + +cd bench/mongoose && npm install && npm run bench # Mongoose comparator +``` + +Results are JSON files under `results//`, each with the MongoDB version, topology, +Python version and machine. `--quick` uses tiny sizes to prove the harness runs; do not draw +conclusions from quick numbers. + +Absolute times depend on the machine. The comparisons that travel are ratios: mongospecs over raw +`pymongo`, and Mongoose over the raw Node driver. + +| ID | Measures | +|---|---| +| B1 | Turning raw documents into models, per backend, next to the driver's BSON decode | +| B2 | `find()` end to end for 1, 100 and 10,000 documents in raw, lean and tracked modes | +| B3 | `include()` by `$lookup` vs batched `$in` across fan-outs, on both topologies | +| B4 | Inserts, atomic patches, delta saves and upserts | +| B5 | Offset vs cursor pagination at depth; total by count vs `$facet` | +| B6 | Per-operation cost of plugins | +| B7 | HTTP request to response through each plug and model backend | +| B8 | Time and memory of change tracking; size of a delta save | +| X1-X7 | Pool pressure, transaction contention, optimistic-lock storm, cancellation, 16MB results, change stream across a step-down, threads with their own loops | diff --git a/bench/mongoose/.gitignore b/bench/mongoose/.gitignore new file mode 100644 index 0000000..504afef --- /dev/null +++ b/bench/mongoose/.gitignore @@ -0,0 +1,2 @@ +node_modules/ +package-lock.json diff --git a/bench/mongoose/bench.mjs b/bench/mongoose/bench.mjs new file mode 100644 index 0000000..e1748cc --- /dev/null +++ b/bench/mongoose/bench.mjs @@ -0,0 +1,228 @@ +// Mongoose comparator (plan node E2). +// +// Runs the equivalents of workloads B1, B2, B4 and B8 with Mongoose against the +// same MongoDB and the same document shapes, and reports Mongoose's overhead +// over the raw Node driver. Absolute times are not comparable across runtimes; +// the overhead ratios are what is set next to mongospecs' ratios over pymongo. +// +// cd bench/mongoose && npm install && npm run bench [-- --quick] + +import { execSync } from 'node:child_process'; +import { mkdirSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import mongoose from 'mongoose'; + +const QUICK = process.argv.includes('--quick'); +const URI = process.env.MONGOSPECS_TEST_URI || 'mongodb://localhost:27117/?replicaSet=rs0'; +const REPEAT = QUICK ? 3 : 15; +const here = dirname(fileURLToPath(import.meta.url)); + +function rawDocument(shape, n) { + const when = new Date(Date.UTC(2024, 0, 1) + n * 60000); + if (shape === 'small') { + return { n, name: `name-${n}`, score: n * 1.5, active: n % 2 === 0, created: when }; + } + return { + n, + customer: `customer-${n}`, + address: { street: `${n} Main St`, city: 'Springfield', zip: String(n % 100000).padStart(5, '0') }, + lines: Array.from({ length: 8 }, (_, i) => ({ sku: `sku-${n}-${i}`, qty: i + 1, price: 9.99 + i })), + tags: Array.from({ length: 5 }, (_, i) => `tag-${(n + i) % 7}`), + meta: { source: 'bench', batch: String(Math.floor(n / 100)) }, + placed: when, + }; +} + +const Small = mongoose.model( + 'Small', + new mongoose.Schema( + { n: Number, name: String, score: Number, active: Boolean, created: Date }, + { collection: 'bench_small', versionKey: false }, + ), +); +const Nested = mongoose.model( + 'Nested', + new mongoose.Schema( + { + n: Number, + customer: String, + address: new mongoose.Schema({ street: String, city: String, zip: String }, { _id: false }), + lines: [new mongoose.Schema({ sku: String, qty: Number, price: Number }, { _id: false })], + tags: [String], + meta: { type: Map, of: String }, + placed: Date, + }, + { collection: 'bench_nested', versionKey: false }, + ), +); +const Plain = mongoose.model( + 'Plain', + new mongoose.Schema({ n: Number, name: String }, { collection: 'bench_plain', versionKey: false }), +); +const Managed = mongoose.model( + 'Managed', + new mongoose.Schema( + { n: Number, name: String }, + { collection: 'bench_managed', timestamps: true, optimisticConcurrency: true }, + ), +); +const MODELS = { small: Small, nested: Nested }; + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +async function measure(fn, { setup } = {}) { + for (let i = 0; i < 2; i++) { + if (setup) await setup(); + await fn(); + } + const samples = []; + for (let i = 0; i < REPEAT; i++) { + if (setup) await setup(); + const start = process.hrtime.bigint(); + await fn(); + samples.push(Number(process.hrtime.bigint() - start) / 1e6); + } + return median(samples); +} + +const round = (value, digits = 3) => Number(value.toFixed(digits)); +const ratio = (value, base) => (base ? round(value / base, 2) : 0); +const rows = []; + +async function materialise() { + const n = QUICK ? 300 : 10000; + for (const [shape, Model] of Object.entries(MODELS)) { + const docs = Array.from({ length: n }, (_, i) => ({ _id: new mongoose.Types.ObjectId(), ...rawDocument(shape, i) })); + const encoded = docs.map((d) => mongoose.mongo.BSON.serialize(d)); + const base = await measure(() => encoded.map((b) => mongoose.mongo.BSON.deserialize(b))); + rows.push({ workload: 'B1', shape, case: 'driver bson->object', n, us_per_doc: round((base * 1000) / n, 2), vs_driver: 1 }); + const hydrated = await measure(() => docs.map((d) => Model.hydrate(d))); + rows.push({ workload: 'B1', shape, case: 'Model.hydrate', n, us_per_doc: round((hydrated * 1000) / n, 2), vs_driver: ratio(hydrated, base) }); + } +} + +async function finds() { + const total = QUICK ? 300 : 10000; + for (const [shape, Model] of Object.entries(MODELS)) { + await Model.collection.deleteMany({}); + await Model.collection.insertMany(Array.from({ length: total }, (_, i) => rawDocument(shape, i))); + for (const n of [1, 100, 10000].filter((size) => size <= total)) { + const raw = await measure(() => Model.collection.find({}, { limit: n }).toArray()); + const lean = await measure(() => Model.find().limit(n).lean()); + const full = await measure(() => Model.find().limit(n)); + rows.push({ workload: 'B2', shape, case: 'driver objects', n, median_ms: round(raw), vs_driver: 1 }); + rows.push({ workload: 'B2', shape, case: 'mongoose lean()', n, median_ms: round(lean), vs_driver: ratio(lean, raw) }); + rows.push({ workload: 'B2', shape, case: 'mongoose documents', n, median_ms: round(full), vs_driver: ratio(full, raw) }); + } + } +} + +async function writes() { + const n = QUICK ? 50 : 1000; + const ops = QUICK ? 20 : 200; + const reset = async () => { + await Plain.collection.deleteMany({}); + await Managed.collection.deleteMany({}); + }; + const make = () => Array.from({ length: n }, (_, i) => ({ n: i, name: `n${i}` })); + const rawInsert = await measure(() => Plain.collection.insertMany(make()), { setup: reset }); + const plainInsert = await measure(() => Plain.insertMany(make()), { setup: reset }); + const managedInsert = await measure(() => Managed.insertMany(make()), { setup: reset }); + rows.push({ workload: 'B4', op: `insert ${n}`, case: 'driver insertMany', median_ms: round(rawInsert), vs_driver: 1 }); + rows.push({ workload: 'B4', op: `insert ${n}`, case: 'Model.insertMany (no plugins)', median_ms: round(plainInsert), vs_driver: ratio(plainInsert, rawInsert) }); + rows.push({ workload: 'B4', op: `insert ${n}`, case: 'Model.insertMany (timestamps+versioning)', median_ms: round(managedInsert), vs_driver: ratio(managedInsert, rawInsert) }); + + await reset(); + const docs = await Plain.insertMany(Array.from({ length: ops }, (_, i) => ({ n: i, name: `n${i}` }))); + const ids = docs.map((d) => d._id); + const rawPatch = await measure(async () => { + for (const id of ids) await Plain.collection.updateOne({ _id: id }, { $inc: { n: 1 } }); + }); + const odmPatch = await measure(async () => { + for (const id of ids) await Plain.findByIdAndUpdate(id, { $inc: { n: 1 } }, { new: true }); + }); + const odmSave = await measure(async () => { + for (const doc of docs) { + doc.n += 1; + await doc.save(); + } + }); + rows.push({ workload: 'B4', op: `patch x${ops}`, case: 'driver updateOne', median_ms: round(rawPatch), vs_driver: 1 }); + rows.push({ workload: 'B4', op: `patch x${ops}`, case: 'findByIdAndUpdate (returns doc)', median_ms: round(odmPatch), vs_driver: ratio(odmPatch, rawPatch) }); + rows.push({ workload: 'B4', op: `patch x${ops}`, case: 'doc.save() delta', median_ms: round(odmSave), vs_driver: ratio(odmSave, rawPatch) }); + await reset(); +} + +async function tracking() { + const n = QUICK ? 200 : 5000; + await Nested.collection.deleteMany({}); + await Nested.collection.insertMany(Array.from({ length: n }, (_, i) => rawDocument('nested', i))); + const lean = await measure(() => Nested.find().lean()); + const full = await measure(() => Nested.find()); + const heap = async (query) => { + globalThis.gc?.(); + const before = process.memoryUsage().heapUsed; + const held = await query(); + globalThis.gc?.(); + const used = (process.memoryUsage().heapUsed - before) / held.length; + return Math.round(used); + }; + const leanBytes = await heap(() => Nested.find().lean()); + const fullBytes = await heap(() => Nested.find()); + rows.push({ workload: 'B8', mode: 'lean()', n, median_ms: round(lean, 2), vs_lean: 1, bytes_per_doc: leanBytes }); + rows.push({ workload: 'B8', mode: 'documents (tracked)', n, median_ms: round(full, 2), vs_lean: ratio(full, lean), bytes_per_doc: fullBytes }); + await Nested.collection.deleteMany({}); +} + +function git(args) { + try { + return execSync(`git ${args}`, { cwd: here, encoding: 'utf8' }).trim(); + } catch { + return 'unknown'; + } +} + +const database = `ms_bench_mongoose_${Date.now().toString(36)}`; +await mongoose.connect(URI, { dbName: database }); +try { + await materialise(); + await finds(); + await writes(); + await tracking(); + const info = await mongoose.connection.db.admin().serverInfo(); + const meta = { + git_sha: git('rev-parse --short HEAD'), + timestamp: new Date().toISOString(), + topology: 'rs', + quick: QUICK, + mongodb: info.version, + node: process.version, + versions: { mongoose: mongoose.version, driver: mongoose.mongo.BSON ? 'bundled with mongoose' : 'unknown' }, + platform: `${process.platform}-${process.arch}`, + }; + const folder = join(here, '..', 'results', meta.git_sha); + mkdirSync(folder, { recursive: true }); + const path = join(folder, `mongoose${QUICK ? '-quick' : ''}.json`); + const payload = { + meta, + workload: 'E2', + title: 'Mongoose comparator', + question: "What is Mongoose's overhead over the raw Node driver for the same workloads?", + rows, + notes: [ + 'Ratios are against the raw Node driver in the same process. Compare them with mongospecs ratios against pymongo, not absolute times.', + ], + conclusion: '', + }; + writeFileSync(path, JSON.stringify(payload, null, 2) + '\n'); + console.table(rows); + console.log(`-> ${path}`); +} finally { + await mongoose.connection.dropDatabase(); + await mongoose.disconnect(); +} diff --git a/bench/mongoose/package.json b/bench/mongoose/package.json new file mode 100644 index 0000000..e4aa52c --- /dev/null +++ b/bench/mongoose/package.json @@ -0,0 +1,12 @@ +{ + "name": "mongospecs-bench-mongoose", + "private": true, + "type": "module", + "description": "Mongoose comparator for the mongospecs benchmark track. Not published.", + "scripts": { + "bench": "node --expose-gc bench.mjs" + }, + "dependencies": { + "mongoose": "^8.9.0" + } +} diff --git a/bench/pyproject.toml b/bench/pyproject.toml index 28e1295..ec1478c 100644 --- a/bench/pyproject.toml +++ b/bench/pyproject.toml @@ -8,7 +8,7 @@ version = "0.0.0" description = "Benchmarks and stress scenarios for mongospecs against real MongoDB. Not published." requires-python = ">=3.11" classifiers = ["Private :: Do Not Upload"] -dependencies = ["mongospecs[all,sentry]", "mongospecs-web[all]", "httpx>=0.27"] +dependencies = ["mongospecs[all,sentry]", "mongospecs-web[all]", "httpx>=0.27", "uvicorn>=0.30"] [project.scripts] mongospecs-bench = "mongospecs_bench.cli:main" diff --git a/bench/results/4f3b2c1/B1.json b/bench/results/4f3b2c1/B1.json new file mode 100644 index 0000000..978b2f0 --- /dev/null +++ b/bench/results/4f3b2c1/B1.json @@ -0,0 +1,271 @@ +{ + "meta": { + "git_sha": "4f3b2c1", + "git_dirty": false, + "timestamp": "2026-10-13T04:01:56+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B1", + "title": "Materialise documents", + "question": "What does turning a raw document into a model cost, per backend, and how does it compare with the driver's own BSON decoding?", + "rows": [ + { + "shape": "small", + "backend": "driver", + "mode": "bson->dict", + "n": 10000, + "us_per_doc": 1.07, + "vs_bson": 1.0 + }, + { + "shape": "small", + "backend": "msgspec", + "mode": "validated", + "n": 10000, + "us_per_doc": 1.21, + "vs_bson": 1.13 + }, + { + "shape": "small", + "backend": "msgspec", + "mode": "trusted", + "n": 10000, + "us_per_doc": 1.17, + "vs_bson": 1.09 + }, + { + "shape": "small", + "backend": "msgspec", + "mode": "tracked", + "n": 10000, + "us_per_doc": 3.12, + "vs_bson": 2.93 + }, + { + "shape": "small", + "backend": "pydantic", + "mode": "validated", + "n": 10000, + "us_per_doc": 3.19, + "vs_bson": 2.99 + }, + { + "shape": "small", + "backend": "pydantic", + "mode": "trusted", + "n": 10000, + "us_per_doc": 2.13, + "vs_bson": 2.0 + }, + { + "shape": "small", + "backend": "pydantic", + "mode": "tracked", + "n": 10000, + "us_per_doc": 4.51, + "vs_bson": 4.22 + }, + { + "shape": "small", + "backend": "attrs", + "mode": "validated", + "n": 10000, + "us_per_doc": 1.93, + "vs_bson": 1.81 + }, + { + "shape": "small", + "backend": "attrs", + "mode": "trusted", + "n": 10000, + "us_per_doc": 1.92, + "vs_bson": 1.8 + }, + { + "shape": "small", + "backend": "attrs", + "mode": "tracked", + "n": 10000, + "us_per_doc": 5.76, + "vs_bson": 5.39 + }, + { + "shape": "wide", + "backend": "driver", + "mode": "bson->dict", + "n": 10000, + "us_per_doc": 4.19, + "vs_bson": 1.0 + }, + { + "shape": "wide", + "backend": "msgspec", + "mode": "validated", + "n": 10000, + "us_per_doc": 1.96, + "vs_bson": 0.47 + }, + { + "shape": "wide", + "backend": "msgspec", + "mode": "trusted", + "n": 10000, + "us_per_doc": 1.98, + "vs_bson": 0.47 + }, + { + "shape": "wide", + "backend": "msgspec", + "mode": "tracked", + "n": 10000, + "us_per_doc": 9.56, + "vs_bson": 2.28 + }, + { + "shape": "wide", + "backend": "pydantic", + "mode": "validated", + "n": 10000, + "us_per_doc": 5.11, + "vs_bson": 1.22 + }, + { + "shape": "wide", + "backend": "pydantic", + "mode": "trusted", + "n": 10000, + "us_per_doc": 17.36, + "vs_bson": 4.14 + }, + { + "shape": "wide", + "backend": "pydantic", + "mode": "tracked", + "n": 10000, + "us_per_doc": 11.22, + "vs_bson": 2.67 + }, + { + "shape": "wide", + "backend": "attrs", + "mode": "validated", + "n": 10000, + "us_per_doc": 6.0, + "vs_bson": 1.43 + }, + { + "shape": "wide", + "backend": "attrs", + "mode": "trusted", + "n": 10000, + "us_per_doc": 5.67, + "vs_bson": 1.35 + }, + { + "shape": "wide", + "backend": "attrs", + "mode": "tracked", + "n": 10000, + "us_per_doc": 14.2, + "vs_bson": 3.38 + }, + { + "shape": "nested", + "backend": "driver", + "mode": "bson->dict", + "n": 10000, + "us_per_doc": 7.94, + "vs_bson": 1.0 + }, + { + "shape": "nested", + "backend": "msgspec", + "mode": "validated", + "n": 10000, + "us_per_doc": 4.3, + "vs_bson": 0.54 + }, + { + "shape": "nested", + "backend": "msgspec", + "mode": "trusted", + "n": 10000, + "us_per_doc": 5.98, + "vs_bson": 0.75 + }, + { + "shape": "nested", + "backend": "msgspec", + "mode": "tracked", + "n": 10000, + "us_per_doc": 19.52, + "vs_bson": 2.46 + }, + { + "shape": "nested", + "backend": "pydantic", + "mode": "validated", + "n": 10000, + "us_per_doc": 16.06, + "vs_bson": 2.02 + }, + { + "shape": "nested", + "backend": "pydantic", + "mode": "trusted", + "n": 10000, + "us_per_doc": 14.18, + "vs_bson": 1.78 + }, + { + "shape": "nested", + "backend": "pydantic", + "mode": "tracked", + "n": 10000, + "us_per_doc": 15.61, + "vs_bson": 1.96 + }, + { + "shape": "nested", + "backend": "attrs", + "mode": "validated", + "n": 10000, + "us_per_doc": 6.71, + "vs_bson": 0.84 + }, + { + "shape": "nested", + "backend": "attrs", + "mode": "trusted", + "n": 10000, + "us_per_doc": 6.5, + "vs_bson": 0.82 + }, + { + "shape": "nested", + "backend": "attrs", + "mode": "tracked", + "n": 10000, + "us_per_doc": 21.46, + "vs_bson": 2.7 + } + ], + "notes": [ + "'tracked' is decode plus the encode that builds the change-tracking snapshot.", + "'trusted' only differs for flat pydantic models; msgspec and attrs always convert." + ], + "conclusion": "" +} diff --git a/bench/results/4f3b2c1/B2.json b/bench/results/4f3b2c1/B2.json new file mode 100644 index 0000000..6c68a53 --- /dev/null +++ b/bench/results/4f3b2c1/B2.json @@ -0,0 +1,508 @@ +{ + "meta": { + "git_sha": "4f3b2c1", + "git_dirty": false, + "timestamp": "2026-10-13T04:01:56+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B2", + "title": "find() end to end", + "question": "How much does the ODM add on top of the raw driver for reads of 1, 100 and 10,000 documents?", + "rows": [ + { + "shape": "small", + "backend": "pymongo", + "case": "driver dicts", + "n": 1, + "median_ms": 0.41, + "vs_driver": 1.0 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm raw()", + "n": 1, + "median_ms": 0.551, + "vs_driver": 1.35 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm lean()", + "n": 1, + "median_ms": 0.536, + "vs_driver": 1.31 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm tracked", + "n": 1, + "median_ms": 0.444, + "vs_driver": 1.08 + }, + { + "shape": "small", + "backend": "pymongo", + "case": "driver dicts", + "n": 100, + "median_ms": 0.734, + "vs_driver": 1.0 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm raw()", + "n": 100, + "median_ms": 0.714, + "vs_driver": 0.97 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm lean()", + "n": 100, + "median_ms": 0.782, + "vs_driver": 1.07 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm tracked", + "n": 100, + "median_ms": 1.113, + "vs_driver": 1.52 + }, + { + "shape": "small", + "backend": "pymongo", + "case": "driver dicts", + "n": 10000, + "median_ms": 24.777, + "vs_driver": 1.0 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm raw()", + "n": 10000, + "median_ms": 36.537, + "vs_driver": 1.47 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm lean()", + "n": 10000, + "median_ms": 49.699, + "vs_driver": 2.01 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm tracked", + "n": 10000, + "median_ms": 88.151, + "vs_driver": 3.56 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm raw()", + "n": 1, + "median_ms": 0.684, + "vs_driver": 1.03 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm lean()", + "n": 1, + "median_ms": 0.69, + "vs_driver": 1.04 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm tracked", + "n": 1, + "median_ms": 0.85, + "vs_driver": 1.28 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm raw()", + "n": 100, + "median_ms": 0.861, + "vs_driver": 1.41 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm lean()", + "n": 100, + "median_ms": 1.126, + "vs_driver": 1.85 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm tracked", + "n": 100, + "median_ms": 1.723, + "vs_driver": 2.83 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm raw()", + "n": 10000, + "median_ms": 33.949, + "vs_driver": 1.26 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm lean()", + "n": 10000, + "median_ms": 57.318, + "vs_driver": 2.13 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm tracked", + "n": 10000, + "median_ms": 106.646, + "vs_driver": 3.96 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm raw()", + "n": 1, + "median_ms": 0.849, + "vs_driver": 1.0 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm lean()", + "n": 1, + "median_ms": 0.756, + "vs_driver": 0.89 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm tracked", + "n": 1, + "median_ms": 0.867, + "vs_driver": 1.02 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm raw()", + "n": 100, + "median_ms": 1.287, + "vs_driver": 1.41 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm lean()", + "n": 100, + "median_ms": 1.537, + "vs_driver": 1.68 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm tracked", + "n": 100, + "median_ms": 2.097, + "vs_driver": 2.3 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm raw()", + "n": 10000, + "median_ms": 37.138, + "vs_driver": 1.38 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm lean()", + "n": 10000, + "median_ms": 59.104, + "vs_driver": 2.19 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm tracked", + "n": 10000, + "median_ms": 100.8, + "vs_driver": 3.74 + }, + { + "shape": "nested", + "backend": "pymongo", + "case": "driver dicts", + "n": 1, + "median_ms": 0.408, + "vs_driver": 1.0 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm raw()", + "n": 1, + "median_ms": 0.452, + "vs_driver": 1.11 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm lean()", + "n": 1, + "median_ms": 0.459, + "vs_driver": 1.13 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm tracked", + "n": 1, + "median_ms": 0.48, + "vs_driver": 1.18 + }, + { + "shape": "nested", + "backend": "pymongo", + "case": "driver dicts", + "n": 100, + "median_ms": 1.16, + "vs_driver": 1.0 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm raw()", + "n": 100, + "median_ms": 1.161, + "vs_driver": 1.0 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm lean()", + "n": 100, + "median_ms": 1.441, + "vs_driver": 1.24 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm tracked", + "n": 100, + "median_ms": 3.464, + "vs_driver": 2.99 + }, + { + "shape": "nested", + "backend": "pymongo", + "case": "driver dicts", + "n": 10000, + "median_ms": 100.05, + "vs_driver": 1.0 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm raw()", + "n": 10000, + "median_ms": 104.699, + "vs_driver": 1.05 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm lean()", + "n": 10000, + "median_ms": 155.254, + "vs_driver": 1.55 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm tracked", + "n": 10000, + "median_ms": 427.674, + "vs_driver": 4.27 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm raw()", + "n": 1, + "median_ms": 0.836, + "vs_driver": 1.11 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm lean()", + "n": 1, + "median_ms": 0.905, + "vs_driver": 1.21 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm tracked", + "n": 1, + "median_ms": 0.962, + "vs_driver": 1.28 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm raw()", + "n": 100, + "median_ms": 1.624, + "vs_driver": 0.84 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm lean()", + "n": 100, + "median_ms": 2.735, + "vs_driver": 1.41 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm tracked", + "n": 100, + "median_ms": 4.617, + "vs_driver": 2.38 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm raw()", + "n": 10000, + "median_ms": 106.912, + "vs_driver": 0.98 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm lean()", + "n": 10000, + "median_ms": 261.247, + "vs_driver": 2.4 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm tracked", + "n": 10000, + "median_ms": 388.983, + "vs_driver": 3.58 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm raw()", + "n": 1, + "median_ms": 0.856, + "vs_driver": 1.01 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm lean()", + "n": 1, + "median_ms": 0.843, + "vs_driver": 0.99 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm tracked", + "n": 1, + "median_ms": 0.881, + "vs_driver": 1.04 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm raw()", + "n": 100, + "median_ms": 1.795, + "vs_driver": 1.02 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm lean()", + "n": 100, + "median_ms": 2.326, + "vs_driver": 1.33 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm tracked", + "n": 100, + "median_ms": 4.143, + "vs_driver": 2.36 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm raw()", + "n": 10000, + "median_ms": 92.473, + "vs_driver": 1.02 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm lean()", + "n": 10000, + "median_ms": 158.192, + "vs_driver": 1.75 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm tracked", + "n": 10000, + "median_ms": 359.47, + "vs_driver": 3.97 + } + ], + "notes": [], + "conclusion": "" +} diff --git a/bench/results/4f3b2c1/B3.json b/bench/results/4f3b2c1/B3.json new file mode 100644 index 0000000..49ad0d6 --- /dev/null +++ b/bench/results/4f3b2c1/B3.json @@ -0,0 +1,70 @@ +{ + "meta": { + "git_sha": "4f3b2c1", + "git_dirty": false, + "timestamp": "2026-10-13T04:01:56+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B3", + "title": "include(): $lookup vs batched $in", + "question": "Which strategy should include(strategy='auto') use, across fan-outs and topologies?", + "rows": [ + { + "relation": "list, distinct children", + "fan_out": 1, + "parents": 400, + "batch_ms": 6.716, + "lookup_ms": 10.275, + "lookup_vs_batch": 1.53 + }, + { + "relation": "list, distinct children", + "fan_out": 10, + "parents": 200, + "batch_ms": 26.252, + "lookup_ms": 20.198, + "lookup_vs_batch": 0.77 + }, + { + "relation": "list, distinct children", + "fan_out": 100, + "parents": 50, + "batch_ms": 97.258, + "lookup_ms": 40.653, + "lookup_vs_batch": 0.42 + }, + { + "relation": "list, distinct children", + "fan_out": 1000, + "parents": 10, + "batch_ms": 101.378, + "lookup_ms": 82.163, + "lookup_vs_batch": 0.81 + }, + { + "relation": "to-one, 20 shared targets", + "fan_out": 1, + "parents": 2000, + "batch_ms": 20.164, + "lookup_ms": 49.826, + "lookup_vs_batch": 2.47 + } + ], + "notes": [ + "Strategy choice does not depend on the model backend; measured with msgspec, lean reads." + ], + "conclusion": "$lookup was faster than batched $in in 3 of 5 cases on rs (lookup/batch ratios: [1.53, 0.77, 0.42, 0.81, 2.47]). Current default: batch." +} diff --git a/bench/results/4f3b2c1/B4.json b/bench/results/4f3b2c1/B4.json new file mode 100644 index 0000000..b765df9 --- /dev/null +++ b/bench/results/4f3b2c1/B4.json @@ -0,0 +1,198 @@ +{ + "meta": { + "git_sha": "4f3b2c1", + "git_dirty": false, + "timestamp": "2026-10-13T04:01:56+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B4", + "title": "Writes", + "question": "What does the ODM add to inserts, atomic patches, saves and upserts?", + "rows": [ + { + "backend": "msgspec", + "op": "insert 1000", + "case": "driver insert_many", + "median_ms": 8.739, + "vs_driver": 1.0 + }, + { + "backend": "msgspec", + "op": "insert 1000", + "case": "insert_many (no plugins)", + "median_ms": 11.722, + "vs_driver": 1.34 + }, + { + "backend": "msgspec", + "op": "insert 1000", + "case": "insert_many (timestamps+revision)", + "median_ms": 14.774, + "vs_driver": 1.69 + }, + { + "backend": "msgspec", + "op": "patch x200", + "case": "driver update_one", + "median_ms": 648.9, + "vs_driver": 1.0 + }, + { + "backend": "msgspec", + "op": "patch x200", + "case": "Model.update (returns doc)", + "median_ms": 679.692, + "vs_driver": 1.05 + }, + { + "backend": "msgspec", + "op": "patch x200", + "case": "doc.save() delta", + "median_ms": 605.333, + "vs_driver": 0.93 + }, + { + "backend": "msgspec", + "op": "upsert x200", + "case": "driver update_one(upsert)", + "median_ms": 1063.307, + "vs_driver": 1.0 + }, + { + "backend": "msgspec", + "op": "upsert x200", + "case": "Model.upsert (returns doc)", + "median_ms": 992.707, + "vs_driver": 0.93 + }, + { + "backend": "pydantic", + "op": "insert 1000", + "case": "driver insert_many", + "median_ms": 10.299, + "vs_driver": 1.0 + }, + { + "backend": "pydantic", + "op": "insert 1000", + "case": "insert_many (no plugins)", + "median_ms": 13.502, + "vs_driver": 1.31 + }, + { + "backend": "pydantic", + "op": "insert 1000", + "case": "insert_many (timestamps+revision)", + "median_ms": 18.901, + "vs_driver": 1.84 + }, + { + "backend": "pydantic", + "op": "patch x200", + "case": "driver update_one", + "median_ms": 586.275, + "vs_driver": 1.0 + }, + { + "backend": "pydantic", + "op": "patch x200", + "case": "Model.update (returns doc)", + "median_ms": 690.681, + "vs_driver": 1.18 + }, + { + "backend": "pydantic", + "op": "patch x200", + "case": "doc.save() delta", + "median_ms": 598.93, + "vs_driver": 1.02 + }, + { + "backend": "pydantic", + "op": "upsert x200", + "case": "driver update_one(upsert)", + "median_ms": 689.839, + "vs_driver": 1.0 + }, + { + "backend": "pydantic", + "op": "upsert x200", + "case": "Model.upsert (returns doc)", + "median_ms": 935.368, + "vs_driver": 1.36 + }, + { + "backend": "attrs", + "op": "insert 1000", + "case": "driver insert_many", + "median_ms": 10.134, + "vs_driver": 1.0 + }, + { + "backend": "attrs", + "op": "insert 1000", + "case": "insert_many (no plugins)", + "median_ms": 13.516, + "vs_driver": 1.33 + }, + { + "backend": "attrs", + "op": "insert 1000", + "case": "insert_many (timestamps+revision)", + "median_ms": 14.908, + "vs_driver": 1.47 + }, + { + "backend": "attrs", + "op": "patch x200", + "case": "driver update_one", + "median_ms": 602.354, + "vs_driver": 1.0 + }, + { + "backend": "attrs", + "op": "patch x200", + "case": "Model.update (returns doc)", + "median_ms": 584.293, + "vs_driver": 0.97 + }, + { + "backend": "attrs", + "op": "patch x200", + "case": "doc.save() delta", + "median_ms": 644.737, + "vs_driver": 1.07 + }, + { + "backend": "attrs", + "op": "upsert x200", + "case": "driver update_one(upsert)", + "median_ms": 833.59, + "vs_driver": 1.0 + }, + { + "backend": "attrs", + "op": "upsert x200", + "case": "Model.upsert (returns doc)", + "median_ms": 818.948, + "vs_driver": 0.98 + } + ], + "notes": [ + "Model.update and Model.upsert use findAndModify to return the document; the driver baseline does not." + ], + "conclusion": "" +} diff --git a/bench/results/4f3b2c1/B5.json b/bench/results/4f3b2c1/B5.json new file mode 100644 index 0000000..c3c66dc --- /dev/null +++ b/bench/results/4f3b2c1/B5.json @@ -0,0 +1,73 @@ +{ + "meta": { + "git_sha": "4f3b2c1", + "git_dirty": false, + "timestamp": "2026-10-13T04:01:56+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B5", + "title": "Pagination", + "question": "How do offset and cursor pagination compare at depth, and should a total come from $facet or a count?", + "rows": [ + { + "page": 1, + "skip": 0, + "offset_ms": 0.57, + "cursor_ms": 19.848, + "offset_vs_cursor": 0.03 + }, + { + "page": 100, + "skip": 1980, + "offset_ms": 4.242, + "cursor_ms": 36.729, + "offset_vs_cursor": 0.12 + }, + { + "page": 1250, + "skip": 24980, + "offset_ms": 6.524, + "cursor_ms": 18.783, + "offset_vs_cursor": 0.35 + }, + { + "page": 2499, + "skip": 49960, + "offset_ms": 11.733, + "cursor_ms": 0.735, + "offset_vs_cursor": 15.96 + }, + { + "page": "total: concurrent count", + "skip": 0, + "offset_ms": 5.97, + "cursor_ms": "", + "offset_vs_cursor": "" + }, + { + "page": "total: $facet (raw driver)", + "skip": 0, + "offset_ms": 28.805, + "cursor_ms": "", + "offset_vs_cursor": "" + } + ], + "notes": [ + "Collection of 50000 documents, page size 20, sorted on an indexed field.", + "$facet returns the page and the total as one document, so the page is bound by the 16MB limit." + ], + "conclusion": "" +} diff --git a/bench/results/4f3b2c1/B6.json b/bench/results/4f3b2c1/B6.json new file mode 100644 index 0000000..c52665a --- /dev/null +++ b/bench/results/4f3b2c1/B6.json @@ -0,0 +1,90 @@ +{ + "meta": { + "git_sha": "4f3b2c1", + "git_dirty": false, + "timestamp": "2026-10-13T04:01:56+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B6", + "title": "Plugin overhead", + "question": "What does each active plugin cost per operation?", + "rows": [ + { + "plugins": "0 no-op", + "op": "insert", + "us_per_op": 2574.7, + "vs_no_plugins": 1.0 + }, + { + "plugins": "0 no-op", + "op": "save", + "us_per_op": 3124.7, + "vs_no_plugins": 1.0 + }, + { + "plugins": "0 no-op", + "op": "get", + "us_per_op": 421.8, + "vs_no_plugins": 1.0 + }, + { + "plugins": "3 no-op", + "op": "insert", + "us_per_op": 2703.9, + "vs_no_plugins": 1.05 + }, + { + "plugins": "3 no-op", + "op": "save", + "us_per_op": 3619.3, + "vs_no_plugins": 1.16 + }, + { + "plugins": "3 no-op", + "op": "get", + "us_per_op": 455.2, + "vs_no_plugins": 1.08 + }, + { + "plugins": "10 no-op", + "op": "insert", + "us_per_op": 2724.2, + "vs_no_plugins": 1.06 + }, + { + "plugins": "10 no-op", + "op": "save", + "us_per_op": 3170.5, + "vs_no_plugins": 1.01 + }, + { + "plugins": "10 no-op", + "op": "get", + "us_per_op": 423.6, + "vs_no_plugins": 1.0 + }, + { + "plugins": "timestamps+revision", + "op": "insert", + "us_per_op": 2646.1, + "vs_no_plugins": 1.03 + } + ], + "notes": [ + "Each operation is one round trip to MongoDB, which dominates; plugin calls are in-process." + ], + "conclusion": "" +} diff --git a/bench/src/mongospecs_bench/cli.py b/bench/src/mongospecs_bench/cli.py index 1b47e40..9f9016c 100644 --- a/bench/src/mongospecs_bench/cli.py +++ b/bench/src/mongospecs_bench/cli.py @@ -4,6 +4,7 @@ mongospecs-bench run B1 B2 # workloads (default: all) mongospecs-bench run B3 --topology sharded + mongospecs-bench run LOAD # the storefront as a real server under HTTP load mongospecs-bench stress # X1-X7 mongospecs-bench trace # Sentry SDK -> rustrak, end to end mongospecs-bench report # markdown summary of the newest results @@ -83,7 +84,7 @@ def main(argv: list[str] | None = None) -> int: prog="mongospecs-bench", description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter ) sub = parser.add_subparsers(dest="command", required=True) - for name, choices in (("run", WORKLOADS), ("stress", SCENARIOS)): + for name, choices in (("run", [*WORKLOADS, "LOAD"]), ("stress", SCENARIOS)): cmd = sub.add_parser(name) cmd.add_argument("names", nargs="*", choices=[*choices, []], default=[]) cmd.add_argument("--topology", choices=("rs", "sharded"), default="rs") @@ -101,9 +102,13 @@ def main(argv: list[str] | None = None) -> int: if args.command == "report": print(report(RESULTS / args.folder if args.folder else None)) return 0 + from mongospecs_bench.load import load + + WORKLOADS = {**WORKLOADS, "LOAD": load} registry = WORKLOADS if args.command == "run" else SCENARIOS settings = Settings(args.topology, args.quick, tuple(args.backend or ("msgspec", "pydantic", "attrs"))) - return asyncio.run(_run(list(args.names) or list(registry), registry, settings)) + default = [name for name in registry if name != "LOAD"] + return asyncio.run(_run(list(args.names) or default, registry, settings)) if __name__ == "__main__": diff --git a/bench/src/mongospecs_bench/load.py b/bench/src/mongospecs_bench/load.py new file mode 100644 index 0000000..6e4a9ef --- /dev/null +++ b/bench/src/mongospecs_bench/load.py @@ -0,0 +1,214 @@ +"""HTTP load benchmark against the storefront running as a real server process. + +Virtual users mix browsing, product detail with reviews, and placing orders +over real TCP connections. The same mix is run against a hand-written +pymongo + Starlette implementation of the same endpoints, so the result shows +what mongospecs and mongospecs-web cost in a whole request, not in isolation. + +After every run the stock invariant is checked against the database: a +benchmark that loses or oversells units is a failed run, whatever its speed. +""" + +from __future__ import annotations + +import asyncio +import random +import statistics +import time +import typing as t +import uuid + +import httpx + +from mongospecs import Registry +from mongospecs_bench.harness import Result, Settings, uri_for +from mongospecs_bench.storefront import models as shop_models +from mongospecs_bench.storefront.auth import Tokens +from mongospecs_bench.storefront.seed import CATEGORIES, Seeded, seed, stock_invariant +from mongospecs_bench.storefront.server import running_server + +SECRET = "load-secret" +MIX = (("list", 60), ("detail", 30), ("order", 10)) + + +def _percentile(values: list[float], q: float) -> float: + ordered = sorted(values) + return ordered[min(len(ordered) - 1, round(q * (len(ordered) - 1)))] + + +async def _virtual_user( + index: int, + client: httpx.AsyncClient, + seeded: Seeded, + stop: asyncio.Event, + samples: dict[str, list[float]], + failures: dict[str, int], + rng: random.Random, +) -> None: + token = {"authorization": f"Bearer {seeded.user_tokens[index % len(seeded.user_tokens)]}"} + actions = [name for name, weight in MIX for _ in range(weight)] + cursor: str | None = None + category = rng.choice(CATEGORIES) + while not stop.is_set(): + action = rng.choice(actions) + started = time.perf_counter() + try: + if action == "list": + url = f"/api/products?category={category}&sort=price&limit=20" + if cursor and rng.random() < 0.5: + url += f"&after={cursor}" + else: + category = rng.choice(CATEGORIES) + url = f"/api/products?category={category}&sort=price&limit=20" + response = await client.get(url) + cursor = response.json()["page"]["next_cursor"] if response.status_code == 200 else None + elif action == "detail": + response = await client.get( + f"/api/products/{rng.choice(seeded.product_ids)}?include=reviews,review_count" + ) + else: + lines = [ + {"sku": sku, "qty": rng.randint(1, 2)} + for sku in rng.sample(seeded.skus, k=rng.randint(1, 3)) + ] + response = await client.post("/api/orders", json={"lines": lines}, headers=token) + ok = response.status_code in (200, 201) + except httpx.HTTPError: + ok = False + elapsed = time.perf_counter() - started + if ok: + samples[action].append(elapsed) + else: + failures[action] += 1 + + +async def run_load( + settings: Settings, + *, + app: str = "storefront", + plug: str = "asgi", + backend: str = "msgspec", + users: int = 32, + seconds: float = 15.0, + products: int = 5000, +) -> list[dict[str, t.Any]]: + """One load run. Returns rows per action plus an ``all`` row; raises if the stock invariant fails.""" + database = f"ms_load_{uuid.uuid4().hex[:10]}" + uri = uri_for(settings.topology) + m = shop_models.build(backend) + label = "raw pymongo + starlette" if app == "raw" else f"mongospecs ({backend}, {plug})" + async with Registry(uri, database=database) as registry: + try: + seeded = await seed(registry, m, Tokens(SECRET), products=products, users=max(users, 8)) + for model in m.ALL: # both apps get the same indexes + registry.bind(model) + await registry.apply_indexes() + async with running_server( + app=app, + database=database, + mongo_uri=uri, + secret=SECRET, + plug=plug, + backend=backend, + projector=False, + ) as server: + limits = httpx.Limits(max_connections=users, max_keepalive_connections=users) + async with httpx.AsyncClient(base_url=server.base_url, timeout=30, limits=limits) as client: + for warm in (2.0, seconds): # a short warm-up, then the measured run + samples: dict[str, list[float]] = {name: [] for name, _ in MIX} + failures: dict[str, int] = {name: 0 for name, _ in MIX} + stop = asyncio.Event() + tasks = [ + asyncio.create_task( + _virtual_user( + i, client, seeded, stop, samples, failures, random.Random(1000 + i) + ) + ) + for i in range(users) + ] + started = time.perf_counter() + await asyncio.sleep(warm) + stop.set() + await asyncio.gather(*tasks) + elapsed = time.perf_counter() - started + invariant = await stock_invariant(registry, m, seeded) + finally: + await registry.client.drop_database(database) + if not invariant["ok"]: + raise AssertionError(f"{label}: stock invariant violated under load: {invariant}") + rows: list[dict[str, t.Any]] = [] + everything: list[float] = [] + for action, _ in MIX: + values = samples[action] + everything.extend(values) + if not values: + continue + rows.append( + { + "app": label, + "action": action, + "requests": len(values), + "rps": round(len(values) / elapsed, 1), + "p50_ms": round(statistics.median(values) * 1000, 2), + "p95_ms": round(_percentile(values, 0.95) * 1000, 2), + "p99_ms": round(_percentile(values, 0.99) * 1000, 2), + "failed": failures[action], + } + ) + rows.append( + { + "app": label, + "action": "all", + "requests": len(everything), + "rps": round(len(everything) / elapsed, 1), + "p50_ms": round(statistics.median(everything) * 1000, 2), + "p95_ms": round(_percentile(everything, 0.95) * 1000, 2), + "p99_ms": round(_percentile(everything, 0.99) * 1000, 2), + "failed": sum(failures.values()), + } + ) + rows[-1]["orders_placed"] = invariant["orders_placed"] + return rows + + +async def load(settings: Settings) -> Result: + """Workload ``LOAD``: the raw baseline, then mongospecs on every plug and model backend.""" + result = Result( + "LOAD", + "Storefront under HTTP load", + "What do mongospecs and mongospecs-web cost per request in a real server, against the same " + "endpoints written by hand with pymongo?", + ) + users = settings.scale(32, 6) + seconds = float(settings.scale(15, 3)) + products = settings.scale(5000, 200) + runs: list[tuple[str, str, str]] = [("raw", "asgi", "msgspec")] + runs += [ + ("storefront", plug, "msgspec") for plug in ("asgi", "starlette", "fastapi", "litestar", "quart") + ] + runs += [("storefront", "asgi", backend) for backend in settings.backends if backend != "msgspec"] + if settings.quick: + runs = runs[:2] + baseline: dict[str, float] = {} + for app, plug, backend in runs: + rows = await run_load( + settings, app=app, plug=plug, backend=backend, users=users, seconds=seconds, products=products + ) + for row in rows: + if app == "raw": + baseline[row["action"]] = row["rps"] + row["rps_vs_raw"] = ( + round(row["rps"] / baseline[row["action"]], 2) if baseline.get(row["action"]) else "" + ) + result.add(**row) + result.notes.append( + f"{users} virtual users for {seconds:.0f}s after a 2s warm-up; mix {dict(MIX)}; {products} products, " + "3 reviews each; one uvicorn worker; load generator and server on the same machine." + ) + result.notes.append( + "The stock invariant (stock + units sold = initial stock, never negative) held after every run." + ) + result.notes.append( + "The raw baseline implements the same three endpoints by hand, including the order transaction." + ) + return result diff --git a/bench/src/mongospecs_bench/storefront/__init__.py b/bench/src/mongospecs_bench/storefront/__init__.py new file mode 100644 index 0000000..5d03ae0 --- /dev/null +++ b/bench/src/mongospecs_bench/storefront/__init__.py @@ -0,0 +1,16 @@ +"""Storefront: a production-shaped reference application built on mongospecs and mongospecs-web. + +It is what the end-to-end tests boot as a real server process and what the load +benchmark drives over HTTP. It is deliberately not a toy: + +* authentication with signed bearer tokens, admin-only operations, row scope on orders +* placing an order is a multi-document transaction that must never oversell stock +* products are soft-deleted, revisioned (ETag / If-Match) and carry reviews as a reverse relation +* a change-stream projector keeps sales statistics up to date, exactly once, resumable +* indexes and validators are applied at startup; shutdown is graceful; ``/healthz`` checks the database +* optional tracing through the Sentry SDK (``SENTRY_DSN``) + +Run it:: + + SHOP_DB=shop uv run uvicorn mongospecs_bench.storefront.app:create_app --factory +""" diff --git a/bench/src/mongospecs_bench/storefront/app.py b/bench/src/mongospecs_bench/storefront/app.py new file mode 100644 index 0000000..be5f44e --- /dev/null +++ b/bench/src/mongospecs_bench/storefront/app.py @@ -0,0 +1,405 @@ +"""The storefront application: resources, custom operations, lifecycle.""" + +from __future__ import annotations + +import json +import os +import typing as t +from dataclasses import dataclass, field + +from mongospecs import DocumentNotFound, DuplicateKeyError, Inc, Q, Registry, sentry_listener +from mongospecs_bench.storefront import models as shop_models +from mongospecs_bench.storefront.auth import Principal, Tokens, hash_password, verify_password +from mongospecs_bench.storefront.projector import SalesProjector +from mongospecs_web import ( + Api, + Conflict, + Create, + Ctx, + Cursor, + Delete, + Forbidden, + Get, + List, + NotFound, + Patch, + Resource, + Unauthorized, + Unprocessable, +) + +PLUGS = ("asgi", "starlette", "fastapi", "litestar", "quart") + + +@dataclass +class Config: + mongo_uri: str = "mongodb://localhost:27117/?replicaSet=rs0" + database: str = "storefront" + backend: str = "msgspec" + plug: str = "asgi" + secret: str = "change-me" + sentry_dsn: str | None = None + projector: bool = True + max_pool_size: int = 100 + max_time_ms: int = 10_000 + options: dict[str, t.Any] = field(default_factory=dict) + + @classmethod + def from_env(cls) -> Config: + env = os.environ + return cls( + mongo_uri=env.get("SHOP_MONGO_URI", cls.mongo_uri), + database=env.get("SHOP_DB", cls.database), + backend=env.get("SHOP_BACKEND", cls.backend), + plug=env.get("SHOP_PLUG", cls.plug), + secret=env.get("SHOP_SECRET", cls.secret), + sentry_dsn=env.get("SENTRY_DSN") or None, + projector=env.get("SHOP_PROJECTOR", "1") != "0", + max_pool_size=int(env.get("SHOP_POOL", cls.max_pool_size)), + ) + + +def require_login(ctx: Ctx[t.Any]) -> bool: + if ctx.principal is None: + raise Unauthorized("Log in first.", headers={"www-authenticate": "Bearer"}) + return True + + +def require_admin(ctx: Ctx[t.Any]) -> bool: + require_login(ctx) + return bool(ctx.principal.is_admin) + + +def build_api(m: t.Any, registry: Registry, tokens: Tokens) -> Api: + """Declare the storefront's resources. ``m`` is the model namespace from ``models.build``.""" + + # -- products: public reads, admin writes, soft delete, ETags ------------------------- + products: Resource[t.Any] = Resource( + m.Product, + ops=[List, Get, Create, Patch, Delete], + guards={Create: require_admin, Patch: require_admin, Delete: require_admin}, + include=["reviews", "review_count"], + page=Cursor(default=20, max=100), + ) + + @products.op("restock", method="POST", guards=[require_admin], summary="Add stock to a product") + async def restock(ctx: Ctx[t.Any]) -> t.Any: + amount = (ctx.body() or {}).get("amount") + if not isinstance(amount, int) or isinstance(amount, bool) or amount <= 0: + raise Unprocessable( + "amount must be a positive integer.", + errors=[{"field": "amount", "message": "must be a positive integer"}], + ) + try: + return await m.Product.update(ctx.id, Inc(stock=amount)) + except DocumentNotFound: + raise NotFound("The product does not exist.") from None + + # -- reviews: anyone reads, logged-in users write, the author is set by the server ----- + reviews: Resource[t.Any] = Resource( + m.Review, + ops=[List, Get, Create, Delete], + guards={Create: require_login, Delete: require_login}, + server_fields=lambda ctx: {"author": ctx.principal.id}, + page=Cursor(default=20, max=100), + ) + + @reviews.before(Create) + async def check_review(ctx: Ctx[t.Any]) -> None: + body = ctx.body() or {} + rating = body.get("rating") + if not isinstance(rating, int) or isinstance(rating, bool) or not 1 <= rating <= 5: + raise Unprocessable( + "rating must be between 1 and 5.", + errors=[{"field": "rating", "message": "must be between 1 and 5"}], + ) + if not await m.Product.exists(Q.id == body.get("product")): + raise Unprocessable( + "The product does not exist.", errors=[{"field": "product", "message": "does not exist"}] + ) + + @reviews.before(Delete) + async def only_own_review(ctx: Ctx[t.Any]) -> None: + review = await ctx.load() + if not ctx.principal.is_admin and ( + review.author is None or str(review.author.id) != ctx.principal.id + ): + raise Forbidden("You can only delete your own reviews.") + + # -- orders: each user sees their own; placing and cancelling are transactions ---------- + def own_orders(ctx: Ctx[t.Any]) -> t.Any: + require_login(ctx) + return None if ctx.principal.is_admin else Q.user == ctx.principal.id + + orders: Resource[t.Any] = Resource( + m.Order, ops=[List, Get], scope=own_orders, page=Cursor(default=20, max=100) + ) + + @orders.op("place", method="POST", path="", status=201, guards=[require_login], summary="Place an order") + async def place(ctx: Ctx[t.Any]) -> t.Any: + wanted = _order_lines(ctx.body()) + + async def work(tx: t.Any) -> t.Any: + lines: list[t.Any] = [] + total = 0.0 + # A fixed order of products keeps concurrent orders from deadlocking each other. + for sku in sorted(wanted): + qty = wanted[sku] + try: + # One atomic step: only decrement if enough stock is left. + product = await m.Product.update( + (Q.sku == sku) & (Q.stock >= qty), Inc(stock=-qty), session=tx + ) + except DocumentNotFound: + raise Conflict( + f"{sku} is out of stock or does not exist.", + errors=[{"field": "lines", "message": f"{sku}: insufficient stock"}], + ) from None + lines.append( + m.OrderLine( + product=product.id, sku=sku, name=product.name, qty=qty, unit_price=product.price + ) + ) + total += qty * product.price + return await m.Order(user=ctx.principal.id, lines=lines, total=round(total, 2)).insert(session=tx) + + return await registry.transaction(work) + + @orders.op( + "cancel", method="POST", guards=[require_login], summary="Cancel an order and return its stock" + ) + async def cancel(ctx: Ctx[t.Any]) -> t.Any: + scope = await ctx.scope() + + async def work(tx: t.Any) -> t.Any: + condition = (Q.id == ctx.id) & (Q.status == "placed") + try: + order = await m.Order.update( + condition if scope is None else condition & scope, status="cancelled", session=tx + ) + except DocumentNotFound: + raise Conflict("The order does not exist or was already cancelled.") from None + for line in order.lines: + await ( + m.Product.find(Q.id == line.product, session=tx) + .unscoped("soft_delete") + .update(Inc(stock=line.qty)) + ) + return order + + return await registry.transaction(work) + + # -- accounts ------------------------------------------------------------------------- + users: Resource[t.Any] = Resource(m.User, ops=[Get], scope=lambda ctx: _self_only(ctx)) + + @users.op("signup", method="POST", path="/signup", status=201) + async def signup(ctx: Ctx[t.Any]) -> t.Any: + body = ctx.body() or {} + email, password = body.get("email"), body.get("password") + if not isinstance(email, str) or "@" not in email: + raise Unprocessable( + "A valid email is required.", errors=[{"field": "email", "message": "is not a valid email"}] + ) + if not isinstance(password, str) or len(password) < 8: + raise Unprocessable( + "The password is too short.", + errors=[{"field": "password", "message": "must be at least 8 characters"}], + ) + try: + user = await m.User( + email=email.lower(), name=str(body.get("name", "")), password_hash=hash_password(password) + ).insert() + except DuplicateKeyError: + raise Conflict( + "That email is already registered.", errors=[{"field": "email", "message": "must be unique"}] + ) from None + return {"user": users.view.serialize(user), "token": tokens.issue(user.id)} + + @users.op("login", method="POST", path="/login") + async def login(ctx: Ctx[t.Any]) -> t.Any: + body = ctx.body() or {} + user = ( + await m.User.find(Q.email == str(body.get("email", "")).lower()).select("+password_hash").first() + ) + if user is None or not verify_password(str(body.get("password", "")), user.password_hash): + raise Unauthorized("Wrong email or password.") + return {"token": tokens.issue(user.id, is_admin=user.is_admin)} + + stats: Resource[t.Any] = Resource(m.SalesStat, name="sales", ops=[List], guards=[require_admin]) + + return Api( + products, + reviews, + orders, + users, + stats, + prefix="/api", + title="Storefront", + authenticate=tokens.authenticate, + ) + + +def _self_only(ctx: Ctx[t.Any]) -> t.Any: + require_login(ctx) + return None if ctx.principal.is_admin else Q.id == ctx.principal.id + + +def _order_lines(body: t.Any) -> dict[str, int]: + lines = body.get("lines") if isinstance(body, dict) else None + if not isinstance(lines, list) or not lines or len(lines) > 50: + raise Unprocessable( + "lines must be a list of 1 to 50 items.", + errors=[{"field": "lines", "message": "must have 1 to 50 items"}], + ) + wanted: dict[str, int] = {} + for item in lines: + sku = item.get("sku") if isinstance(item, dict) else None + qty = item.get("qty") if isinstance(item, dict) else None + if ( + not isinstance(sku, str) + or not isinstance(qty, int) + or isinstance(qty, bool) + or not 1 <= qty <= 1000 + ): + raise Unprocessable( + "Each line needs a sku and a qty from 1 to 1000.", + errors=[{"field": "lines", "message": "each line needs sku and qty (1-1000)"}], + ) + wanted[sku] = wanted.get(sku, 0) + qty + return wanted + + +class Storefront: + """The ASGI application: lifecycle and ``/healthz`` around the framework-specific app.""" + + def __init__(self, config: Config) -> None: + self.config = config + self.m = shop_models.build(config.backend) + listeners = [sentry_listener()] if config.sentry_dsn else None + self.registry = Registry( + config.mongo_uri, + database=config.database, + models=self.m.ALL, + max_time_ms=config.max_time_ms, + on_command=listeners, + maxPoolSize=config.max_pool_size, + **config.options, + ) + self.tokens = Tokens(config.secret) + self.api = build_api(self.m, self.registry, self.tokens) + self.projector = SalesProjector(self.registry, self.m) if config.projector else None + self.inner = self._mount(config.plug) + if config.sentry_dsn: + import sentry_sdk + from sentry_sdk.integrations.asgi import SentryAsgiMiddleware + + sentry_sdk.init( + dsn=config.sentry_dsn, + traces_sample_rate=1.0, + default_integrations=False, + auto_enabling_integrations=False, + ) + self.inner = SentryAsgiMiddleware(self.inner, transaction_style="url") + + def _mount(self, plug: str) -> t.Any: + if plug == "asgi": + return self.api.asgi() + if plug == "starlette": + from starlette.applications import Starlette + + from mongospecs_web.plugs import starlette as starlette_plug + + return Starlette(routes=starlette_plug.routes(self.api)) + if plug == "fastapi": + from fastapi import FastAPI + + from mongospecs_web.plugs import fastapi as fastapi_plug + + app = FastAPI(title="Storefront", openapi_url="/api/openapi.json") + fastapi_plug.mount(app, self.api) + return app + if plug == "litestar": + from litestar import Litestar + + from mongospecs_web.plugs.litestar import MongospecsPlugin + + return Litestar(plugins=[MongospecsPlugin(self.api)], openapi_config=None) + if plug == "quart": + from quart import Quart + + from mongospecs_web.plugs import quart as quart_plug + + quart_app = Quart(__name__) + quart_plug.init_app(quart_app, self.api) + return quart_app + raise ValueError(f"unknown plug {plug!r}; expected one of {PLUGS}") + + async def startup(self) -> None: + await self.registry.open() + await self.registry.ping() + await self.registry.apply_indexes() + if self.projector is not None: + await self.projector.start() + + async def shutdown(self) -> None: + if self.projector is not None: + await self.projector.stop() + await self.registry.close() + print("storefront: shutdown complete", flush=True) + if self.config.sentry_dsn: + import sentry_sdk + + sentry_sdk.flush(timeout=5) + + async def __call__(self, scope: dict[str, t.Any], receive: t.Any, send: t.Any) -> None: + if scope["type"] == "lifespan": + while True: + message = await receive() + if message["type"] == "lifespan.startup": + try: + await self.startup() + except Exception as exc: + await send( + {"type": "lifespan.startup.failed", "message": f"{type(exc).__name__}: {exc}"} + ) + return + await send({"type": "lifespan.startup.complete"}) + elif message["type"] == "lifespan.shutdown": + await self.shutdown() + await send({"type": "lifespan.shutdown.complete"}) + return + elif scope["type"] == "http" and scope["path"] == "/healthz": + await self._health(send) + else: + await self.inner(scope, receive, send) + + async def _health(self, send: t.Any) -> None: + try: + await self.registry.ping() + status, body = 200, {"status": "ok", "backend": self.config.backend, "plug": self.config.plug} + except Exception as exc: + status, body = 503, {"status": "unavailable", "error": type(exc).__name__} + payload = json.dumps(body).encode() + await send( + { + "type": "http.response.start", + "status": status, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(payload)).encode()), + ], + } + ) + await send({"type": "http.response.body", "body": payload}) + + +def create_app() -> Storefront: + """ASGI factory: ``uvicorn mongospecs_bench.storefront.app:create_app --factory``.""" + return Storefront(Config.from_env()) + + +def issue_admin_token(secret: str, user_id: t.Any) -> str: + return Tokens(secret).issue(user_id, is_admin=True) + + +__all__ = ["PLUGS", "Config", "Principal", "Storefront", "build_api", "create_app", "issue_admin_token"] diff --git a/bench/src/mongospecs_bench/storefront/auth.py b/bench/src/mongospecs_bench/storefront/auth.py new file mode 100644 index 0000000..66534a7 --- /dev/null +++ b/bench/src/mongospecs_bench/storefront/auth.py @@ -0,0 +1,68 @@ +"""Stateless bearer tokens and password hashing, standard library only.""" + +from __future__ import annotations + +import base64 +import hashlib +import hmac +import os +import typing as t +from dataclasses import dataclass + +from mongospecs_web import Request, Unauthorized + + +@dataclass(frozen=True, slots=True) +class Principal: + id: str + is_admin: bool + + +def hash_password(password: str, *, salt: bytes | None = None) -> str: + salt = salt or os.urandom(16) + digest = hashlib.scrypt(password.encode(), salt=salt, n=2**12, r=8, p=1, dklen=32) + return base64.b64encode(salt + digest).decode() + + +def verify_password(password: str, stored: str) -> bool: + try: + raw = base64.b64decode(stored.encode()) + except ValueError: + return False + salt, digest = raw[:16], raw[16:] + candidate = hashlib.scrypt(password.encode(), salt=salt, n=2**12, r=8, p=1, dklen=32) + return hmac.compare_digest(candidate, digest) + + +class Tokens: + """``..``: nothing to look up, nothing to store.""" + + def __init__(self, secret: str) -> None: + self._secret = secret.encode() + + def _sign(self, payload: str) -> str: + return hmac.new(self._secret, payload.encode(), hashlib.sha256).hexdigest() + + def issue(self, user_id: t.Any, *, is_admin: bool = False) -> str: + payload = f"{user_id}.{'a' if is_admin else 'u'}" + return f"{payload}.{self._sign(payload)}" + + def verify(self, token: str) -> Principal: + user_id, _, rest = token.partition(".") + role, _, signature = rest.partition(".") + if ( + not user_id + or role not in ("a", "u") + or not hmac.compare_digest(signature, self._sign(f"{user_id}.{role}")) + ): + raise Unauthorized("Invalid token.", headers={"www-authenticate": "Bearer"}) + return Principal(user_id, role == "a") + + def authenticate(self, request: Request) -> Principal | None: + header = request.headers.get("authorization") + if not header: + return None + scheme, _, token = header.partition(" ") + if scheme.lower() != "bearer" or not token: + raise Unauthorized("Use a Bearer token.", headers={"www-authenticate": "Bearer"}) + return self.verify(token) diff --git a/bench/src/mongospecs_bench/storefront/models.py b/bench/src/mongospecs_bench/storefront/models.py new file mode 100644 index 0000000..c168f3c --- /dev/null +++ b/bench/src/mongospecs_bench/storefront/models.py @@ -0,0 +1,160 @@ +"""Storefront models, available on each backend (no ``from __future__ import annotations``: +the classes are built by a factory, so annotations must be real objects).""" + +import datetime as dt +import functools +import types +import typing as t + +from mongospecs import ( + OID, + Backref, + CreatedAt, + DeletedAt, + Immutable, + Index, + OnDelete, + Policy, + Ref, + Revision, + SpecConfig, + UpdatedAt, +) + +READ_ONLY = Policy(insert=False, update=False) + + +@functools.cache +def build(backend: str = "msgspec") -> types.SimpleNamespace: + if backend == "msgspec": + import msgspec + + from mongospecs.msgspec import Spec + + define: t.Any = lambda cls: cls # noqa: E731 + + class OrderLine(msgspec.Struct, kw_only=True): + product: OID + sku: str + name: str + qty: int + unit_price: float + + elif backend == "pydantic": + import pydantic + + from mongospecs.pydantic import Spec # type: ignore[assignment] + + define = lambda cls: cls # noqa: E731 + + class OrderLine(pydantic.BaseModel): # type: ignore[no-redef] + product: OID + sku: str + name: str + qty: int + unit_price: float + + elif backend == "attrs": + import attrs + + from mongospecs.attrs import Spec # type: ignore[assignment] + + define = attrs.define(kw_only=True) + + @attrs.define(kw_only=True) + class OrderLine: # type: ignore[no-redef] + product: OID + sku: str + name: str + qty: int + unit_price: float + + else: + raise ValueError(f"unknown backend {backend!r}") + + @define + class User(Spec): + __spec__ = SpecConfig(collection="users", indexes=[Index("email", unique=True)]) + email: t.Annotated[str, Policy(update=False)] + name: str = "" + password_hash: t.Annotated[str, Policy(private=True)] = "" + is_admin: t.Annotated[bool, READ_ONLY] = False + created_at: t.Annotated[dt.datetime | None, CreatedAt] = None + + @define + class Product(Spec): + __spec__ = SpecConfig( + collection="products", + indexes=[ + Index("sku", unique=True), + # Cursor pagination sorts by the requested key and then by id, so the id + # belongs at the end of every index that serves a listing. + Index("category", "price", "id"), + Index("price", "id"), + Index("name", "id"), + ], + ) + sku: t.Annotated[str, Immutable, Policy(filter=("eq", "in"))] + name: t.Annotated[str, Policy(filter=("eq", "prefix"), sort=True)] + category: t.Annotated[str, Policy(filter=("eq", "in"))] = "general" + price: t.Annotated[float, Policy(filter=("gte", "lte", "gt", "lt"), sort=True)] = 0.0 + stock: t.Annotated[int, Policy(update=False, filter=("gte", "eq"))] = 0 + """Changed only by orders and restocks, never by a plain PATCH.""" + tags: list[str] = [] + description: str = "" + reviews: t.Annotated[list[t.Any], Backref("product", model="Review")] = [] + review_count: t.Annotated[int, Backref("product", model="Review", count=True)] = 0 + created_at: t.Annotated[dt.datetime | None, CreatedAt, Policy(sort=True)] = None + updated_at: t.Annotated[dt.datetime | None, UpdatedAt] = None + deleted_at: t.Annotated[dt.datetime | None, DeletedAt, Policy(private=True)] = None + rev: t.Annotated[int, Revision] = 0 + + @define + class Review(Spec): + __spec__ = SpecConfig( + collection="reviews", indexes=[Index("product", "-created_at"), Index("author")] + ) + product: t.Annotated[Ref[Product], OnDelete("cascade"), Policy(update=False, filter=("eq",))] + rating: t.Annotated[int, Policy(filter=("eq", "gte", "lte"), sort=True)] + body: str = "" + author: t.Annotated[Ref[User] | None, Policy(insert=False, update=False, filter=("eq",))] = None + created_at: t.Annotated[dt.datetime | None, CreatedAt, Policy(sort=True)] = None + + @define + class Order(Spec): + __spec__ = SpecConfig(collection="orders", indexes=[Index("user", "-created_at"), Index("status")]) + user: t.Annotated[Ref[User], Policy(filter=("eq",))] + lines: list[OrderLine] + total: float + status: t.Annotated[str, Policy(filter=("eq",))] = "placed" + created_at: t.Annotated[dt.datetime | None, CreatedAt, Policy(sort=True)] = None + updated_at: t.Annotated[dt.datetime | None, UpdatedAt] = None + rev: t.Annotated[int, Revision] = 0 + + @define + class SalesStat(Spec): + """Units and revenue per SKU, maintained by the change-stream projector.""" + + __spec__ = SpecConfig(collection="sales_stats", indexes=[Index("sku", unique=True)]) + sku: t.Annotated[str, Policy(filter=("eq",))] + units: t.Annotated[int, Policy(sort=True)] = 0 + revenue: float = 0.0 + + @define + class ProjectorState(Spec): + __spec__ = SpecConfig(collection="projector_state", indexes=[Index("name", unique=True)]) + name: str + resume_token: dict[str, t.Any] | None = None + events: int = 0 + + return types.SimpleNamespace( + backend=backend, + OrderLine=OrderLine, + User=User, + Product=Product, + Review=Review, + Order=Order, + SalesStat=SalesStat, + ProjectorState=ProjectorState, + ALL=[User, Product, Review, Order, SalesStat, ProjectorState], + ) diff --git a/bench/src/mongospecs_bench/storefront/projector.py b/bench/src/mongospecs_bench/storefront/projector.py new file mode 100644 index 0000000..80c96ca --- /dev/null +++ b/bench/src/mongospecs_bench/storefront/projector.py @@ -0,0 +1,87 @@ +"""Sales statistics kept current from the orders change stream. + +Each event is applied in a transaction together with the stream's resume token, +so an event is counted exactly once even if the process dies between events, +and a restarted projector continues where the last one stopped. +""" + +from __future__ import annotations + +import asyncio +import contextlib +import logging +import typing as t + +from mongospecs import Inc, MongoSpecsError, Q, Registry, Set + +log = logging.getLogger("storefront.projector") + + +class SalesProjector: + name = "sales" + + def __init__(self, registry: Registry, models: t.Any) -> None: + self.registry = registry + self.m = models + self._task: asyncio.Task[None] | None = None + self.ready = asyncio.Event() + + async def start(self) -> None: + self._task = asyncio.create_task(self._run(), name="sales-projector") + await self.ready.wait() + + async def stop(self) -> None: + if self._task is not None: + self._task.cancel() + with contextlib.suppress(asyncio.CancelledError): + await self._task + self._task = None + + async def _apply(self, event: t.Any) -> None: + order = event.document + sign = 1 if event.operation == "insert" else -1 # an update here is a cancellation + + async def work(tx: t.Any) -> None: + for line in order.lines: + await self.m.SalesStat.upsert( + Q.sku == line.sku, + Inc(units=sign * line.qty, revenue=sign * line.qty * line.unit_price), + session=tx, + ) + await self.m.ProjectorState.upsert( + Q.name == self.name, + Set(resume_token=dict(event.resume_token)), + Inc(events=1), + session=tx, + ) + + await self.registry.transaction(work) + + async def _run(self) -> None: + pipeline = [ + { + "$match": { + "$or": [ + {"operationType": "insert"}, + {"operationType": "update", "updateDescription.updatedFields.status": "cancelled"}, + ] + } + } + ] + while True: + state = await self.m.ProjectorState.find(Q.name == self.name).first() + options: dict[str, t.Any] = {"max_await_time_ms": 250} + if state is not None and state.resume_token: + options["resume_after"] = state.resume_token + try: + async with self.m.Order.watch(pipeline, **options) as stream: + self.ready.set() + async for event in stream: + await self._apply(event) + except asyncio.CancelledError: + raise + except ( + MongoSpecsError + ) as exc: # lost the stream (step-down, network): resume from the stored token + log.warning("projector restarting after %s: %s", type(exc).__name__, exc) + await asyncio.sleep(0.5) diff --git a/bench/src/mongospecs_bench/storefront/raw_app.py b/bench/src/mongospecs_bench/storefront/raw_app.py new file mode 100644 index 0000000..8e1e7b5 --- /dev/null +++ b/bench/src/mongospecs_bench/storefront/raw_app.py @@ -0,0 +1,226 @@ +"""The same hot paths as the storefront, written by hand with pymongo and Starlette. + +This is the baseline for the load benchmark: what the three busiest endpoints +cost when there is no ODM and no resource layer, only the driver and a +framework. It implements the same behaviour (soft-delete filter, cursor +pagination, reviews embedded on demand, a transaction that cannot oversell) so +the comparison is like for like. +""" + +from __future__ import annotations + +import base64 +import contextlib +import datetime as dt +import json +import os +import typing as t + +import bson +from bson import ObjectId +from pymongo import AsyncMongoClient, ReturnDocument +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +from mongospecs_bench.storefront.auth import Tokens +from mongospecs_web import HTTPError + +PRODUCT_FIELDS = ( + "sku", + "name", + "category", + "price", + "stock", + "tags", + "description", + "created_at", + "updated_at", + "rev", +) + + +def _json(value: t.Any) -> t.Any: + if isinstance(value, ObjectId): + return str(value) + if isinstance(value, dt.datetime): + return value.isoformat().replace("+00:00", "Z") + if isinstance(value, dict): + return {k: _json(v) for k, v in value.items()} + if isinstance(value, list): + return [_json(v) for v in value] + return value + + +def _product(doc: dict[str, t.Any]) -> dict[str, t.Any]: + return {"id": str(doc["_id"]), **{k: _json(doc.get(k)) for k in PRODUCT_FIELDS}} + + +def _problem(status: int, title: str, detail: str) -> Response: + body = json.dumps({"type": "about:blank", "title": title, "status": status, "detail": detail}) + return Response(body, status, media_type="application/problem+json") + + +def create_app() -> Starlette: + uri = os.environ.get("SHOP_MONGO_URI", "mongodb://localhost:27117/?replicaSet=rs0") + database = os.environ.get("SHOP_DB", "storefront") + tokens = Tokens(os.environ.get("SHOP_SECRET", "change-me")) + state: dict[str, t.Any] = {} + + @contextlib.asynccontextmanager + async def lifespan(app: Starlette) -> t.AsyncIterator[None]: + client: AsyncMongoClient[dict[str, t.Any]] = AsyncMongoClient( + uri, tz_aware=True, maxPoolSize=int(os.environ.get("SHOP_POOL", "100")) + ) + state["client"] = client + state["db"] = client[database] + await client.admin.command("ping") + yield + await client.close() + + async def health(request: Request) -> Response: + await state["client"].admin.command("ping") + return JSONResponse({"status": "ok", "backend": "none", "plug": "raw"}) + + async def list_products(request: Request) -> Response: + params = request.query_params + flt: dict[str, t.Any] = {"deleted_at": None} + if "category" in params: + flt["category"] = params["category"] + sort_param = params.get("sort", "") + direction = -1 if sort_param.startswith("-") else 1 + key = sort_param.lstrip("-") or "_id" + if key not in ("price", "name", "created_at", "_id"): + return _problem(400, "Bad Request", f"{key!r} cannot be used for sorting") + try: + limit = min(max(int(params.get("limit", 20)), 1), 100) + except ValueError: + return _problem(400, "Bad Request", "limit must be an integer") + sort = [(key, direction)] + ([("_id", 1)] if key != "_id" else []) + if "after" in params: + try: + padded = params["after"] + "=" * (-len(params["after"]) % 4) + values = bson.decode(base64.urlsafe_b64decode(padded))["v"] + except Exception: + return _problem(400, "Bad Request", "invalid pagination cursor") + op = "$gt" if direction == 1 else "$lt" + if key == "_id": + flt["_id"] = {"$gt": values[0]} + else: + flt["$or"] = [{key: {op: values[0]}}, {key: values[0], "_id": {"$gt": values[1]}}] + docs = await state["db"].products.find(flt, sort=sort, limit=limit + 1).to_list() + has_next = len(docs) > limit + docs = docs[:limit] + cursor = None + if has_next and docs: + last = docs[-1] + values = [last["_id"]] if key == "_id" else [last[key], last["_id"]] + cursor = base64.urlsafe_b64encode(bson.encode({"v": values})).decode().rstrip("=") + return JSONResponse( + { + "data": [_product(d) for d in docs], + "page": {"size": limit, "has_next": has_next, "next_cursor": cursor}, + } + ) + + async def get_product(request: Request) -> Response: + try: + oid = ObjectId(request.path_params["id"]) + except Exception: + return _problem(404, "Not Found", "The resource does not exist.") + doc = await state["db"].products.find_one({"_id": oid, "deleted_at": None}) + if doc is None: + return _problem(404, "Not Found", "The resource does not exist.") + out = _product(doc) + include = request.query_params.get("include", "").split(",") + if "reviews" in include: + reviews = await state["db"].reviews.find({"product": oid}).to_list() + out["reviews"] = [ + { + "id": str(r["_id"]), + "product": str(r["product"]), + "rating": r["rating"], + "body": r["body"], + "author": _json(r.get("author")), + "created_at": _json(r.get("created_at")), + } + for r in reviews + ] + if "review_count" in include: + out["review_count"] = await state["db"].reviews.count_documents({"product": oid}) + return JSONResponse({"data": out}, headers={"etag": f'"{doc.get("rev", 0)}"'}) + + async def place_order(request: Request) -> Response: + header = request.headers.get("authorization", "") + try: + principal = tokens.verify(header.partition(" ")[2]) if header else None + except HTTPError: + principal = None + if principal is None: + return _problem(401, "Unauthorized", "Log in first.") + try: + body = json.loads(await request.body()) + wanted: dict[str, int] = {} + for item in body["lines"]: + wanted[item["sku"]] = wanted.get(item["sku"], 0) + int(item["qty"]) + except Exception: + return _problem(422, "Unprocessable Content", "lines must be a list of {sku, qty}.") + db = state["db"] + + class OutOfStock(Exception): + pass + + async def work(session: t.Any) -> dict[str, t.Any]: + now = dt.datetime.now(dt.UTC) + now = now.replace(microsecond=now.microsecond // 1000 * 1000) + lines, total = [], 0.0 + for sku in sorted(wanted): + qty = wanted[sku] + product = await db.products.find_one_and_update( + {"sku": sku, "stock": {"$gte": qty}, "deleted_at": None}, + {"$inc": {"stock": -qty, "rev": 1}, "$set": {"updated_at": now}}, + return_document=ReturnDocument.AFTER, + session=session, + ) + if product is None: + raise OutOfStock(sku) + lines.append( + { + "product": product["_id"], + "sku": sku, + "name": product["name"], + "qty": qty, + "unit_price": product["price"], + } + ) + total += qty * product["price"] + order = { + "user": ObjectId(principal.id), + "lines": lines, + "total": round(total, 2), + "status": "placed", + "created_at": now, + "updated_at": now, + "rev": 0, + } + await db.orders.insert_one(order, session=session) + return order + + try: + async with state["client"].start_session() as session: + order = await session.with_transaction(work) + except OutOfStock as exc: + return _problem(409, "Conflict", f"{exc} is out of stock or does not exist.") + out = {"id": str(order["_id"]), **{k: _json(v) for k, v in order.items() if k != "_id"}} + return JSONResponse({"data": out}, status_code=201, headers={"etag": '"0"'}) + + return Starlette( + routes=[ + Route("/healthz", health), + Route("/api/products", list_products), + Route("/api/products/{id}", get_product), + Route("/api/orders", place_order, methods=["POST"]), + ], + lifespan=lifespan, + ) diff --git a/bench/src/mongospecs_bench/storefront/seed.py b/bench/src/mongospecs_bench/storefront/seed.py new file mode 100644 index 0000000..0778178 --- /dev/null +++ b/bench/src/mongospecs_bench/storefront/seed.py @@ -0,0 +1,132 @@ +"""Seed data for the storefront, written with the raw driver for speed.""" + +from __future__ import annotations + +import datetime as dt +import random +import typing as t +from dataclasses import dataclass + +from bson import ObjectId + +from mongospecs import Registry +from mongospecs_bench.storefront.auth import Tokens + +CATEGORIES = ("books", "games", "garden", "kitchen", "music", "office", "outdoor", "toys") + + +@dataclass +class Seeded: + product_ids: list[str] + skus: list[str] + user_tokens: list[str] + admin_token: str + initial_stock: int + products: int + + +async def seed( + registry: Registry, + m: t.Any, + tokens: Tokens, + *, + products: int = 2000, + users: int = 50, + reviews_per_product: int = 3, + stock: int = 1_000_000, + seed_value: int = 7, +) -> Seeded: + rng = random.Random(seed_value) + now = dt.datetime.now(dt.UTC).replace(microsecond=0) + user_docs = [ + { + "_id": ObjectId(), + "email": f"user{i}@example.com", + "name": f"User {i}", + "password_hash": "", + "is_admin": False, + "created_at": now, + } + for i in range(users) + ] + admin = { + "_id": ObjectId(), + "email": "admin@example.com", + "name": "Admin", + "password_hash": "", + "is_admin": True, + "created_at": now, + } + await registry.manager(m.User).collection.insert_many([*user_docs, admin]) + + product_docs = [] + for i in range(products): + product_docs.append( + { + "_id": ObjectId(), + "sku": f"SKU-{i:06d}", + "name": f"{rng.choice(('Classic', 'Deluxe', 'Compact', 'Pro', 'Eco'))} item {i}", + "category": CATEGORIES[i % len(CATEGORIES)], + "price": round(rng.uniform(1, 500), 2), + "stock": stock, + "tags": rng.sample(("new", "sale", "gift", "limited", "popular", "eco"), k=2), + "description": "Lorem ipsum dolor sit amet. " * 6, + "created_at": now - dt.timedelta(minutes=i), + "updated_at": now - dt.timedelta(minutes=i), + "deleted_at": None, + "rev": 0, + } + ) + collection = registry.manager(m.Product).collection + for start in range(0, len(product_docs), 5000): + await collection.insert_many(product_docs[start : start + 5000]) + + review_docs = [ + { + "product": product["_id"], + "rating": rng.randint(1, 5), + "body": "Works as described. " * rng.randint(1, 4), + "author": rng.choice(user_docs)["_id"], + "created_at": now - dt.timedelta(minutes=rng.randint(0, 100_000)), + } + for product in product_docs + for _ in range(reviews_per_product) + ] + reviews = registry.manager(m.Review).collection + for start in range(0, len(review_docs), 5000): + await reviews.insert_many(review_docs[start : start + 5000]) + + return Seeded( + product_ids=[str(p["_id"]) for p in product_docs], + skus=[str(p["sku"]) for p in product_docs], + user_tokens=[tokens.issue(u["_id"]) for u in user_docs], + admin_token=tokens.issue(admin["_id"], is_admin=True), + initial_stock=stock, + products=products, + ) + + +async def stock_invariant(registry: Registry, m: t.Any, seeded: Seeded) -> dict[str, t.Any]: + """Every unit is either still in stock or on a placed order: nothing lost, nothing oversold.""" + products = registry.manager(m.Product).collection + orders = registry.manager(m.Order).collection + stock_now = {d["sku"]: d["stock"] async for d in products.find({}, {"sku": 1, "stock": 1})} + sold: dict[str, int] = {} + cursor = await orders.aggregate( + [ + {"$match": {"status": "placed"}}, + {"$unwind": "$lines"}, + {"$group": {"_id": "$lines.sku", "units": {"$sum": "$lines.qty"}}}, + ] + ) + async for row in cursor: + sold[row["_id"]] = row["units"] + negative = [sku for sku, value in stock_now.items() if value < 0] + mismatched = [sku for sku, value in stock_now.items() if value + sold.get(sku, 0) != seeded.initial_stock] + return { + "orders_placed": await orders.count_documents({"status": "placed"}), + "units_sold": sum(sold.values()), + "negative_stock": len(negative), + "stock_mismatches": len(mismatched), + "ok": not negative and not mismatched, + } diff --git a/bench/src/mongospecs_bench/storefront/server.py b/bench/src/mongospecs_bench/storefront/server.py new file mode 100644 index 0000000..f70a29f --- /dev/null +++ b/bench/src/mongospecs_bench/storefront/server.py @@ -0,0 +1,140 @@ +"""Run the storefront (or the raw baseline) as a real server process.""" + +from __future__ import annotations + +import asyncio +import contextlib +import os +import signal +import socket +import subprocess +import sys +import typing as t +from dataclasses import dataclass + +import httpx + +APPS = { + "storefront": "mongospecs_bench.storefront.app:create_app", + "raw": "mongospecs_bench.storefront.raw_app:create_app", +} + + +def free_port() -> int: + with socket.socket() as sock: + sock.bind(("127.0.0.1", 0)) + return int(sock.getsockname()[1]) + + +@dataclass +class Server: + base_url: str + process: subprocess.Popen[bytes] + env: dict[str, str] + exit_code: int | None = None + _log: str | None = None + + async def stop(self, *, grace_seconds: float = 20.0) -> int: + """Ask the server to shut down gracefully (SIGTERM) and return its exit code.""" + if self.process.poll() is None: + self.process.send_signal(signal.SIGTERM) + try: + await asyncio.to_thread(self.process.wait, grace_seconds) + except subprocess.TimeoutExpired: + self.process.kill() + await asyncio.to_thread(self.process.wait) + self.exit_code = self.process.returncode + return self.exit_code + + def stopped_gracefully(self) -> bool: + """True when the process ended on our SIGTERM after running its shutdown handlers. + + uvicorn finishes in-flight requests, runs the ASGI lifespan shutdown and + then re-raises the signal, so a clean stop ends with exit code 0 or -SIGTERM. + """ + if self._log is None: + self._log = self.output() + return self.exit_code in (0, -signal.SIGTERM) and "shutdown complete" in self._log + + def output(self) -> str: + """Everything the server printed. Only complete once the process has exited.""" + if self._log is not None: + return self._log + if self.process.stdout is None: + return "" + with contextlib.suppress(Exception): + text = self.process.stdout.read().decode(errors="replace") + if self.process.poll() is not None: + self._log = text + return text + return "" + + +async def start_server( + *, + app: str = "storefront", + database: str, + mongo_uri: str, + secret: str = "e2e-secret", + plug: str = "asgi", + backend: str = "msgspec", + projector: bool = True, + extra_env: t.Mapping[str, str] | None = None, + startup_seconds: float = 30.0, +) -> Server: + port = free_port() + env = { + **os.environ, + "SHOP_MONGO_URI": mongo_uri, + "SHOP_DB": database, + "SHOP_SECRET": secret, + "SHOP_PLUG": plug, + "SHOP_BACKEND": backend, + "SHOP_PROJECTOR": "1" if projector else "0", + **(extra_env or {}), + } + process = subprocess.Popen( # noqa: ASYNC220 - starting a server process, not doing I/O with it + [ + sys.executable, + "-m", + "uvicorn", + APPS[app], + "--factory", + "--host", + "127.0.0.1", + "--port", + str(port), + "--log-level", + "warning", + "--no-access-log", + ], + env=env, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + server = Server(f"http://127.0.0.1:{port}", process, env) + deadline = asyncio.get_running_loop().time() + startup_seconds + async with httpx.AsyncClient(base_url=server.base_url, timeout=2) as client: + while True: + if process.poll() is not None: + raise RuntimeError( + f"server exited during startup with code {process.returncode}:\n{server.output()}" + ) + try: + if (await client.get("/healthz")).status_code == 200: + return server + except httpx.HTTPError: + pass + if asyncio.get_running_loop().time() > deadline: + await server.stop() + raise RuntimeError(f"server did not become healthy in {startup_seconds}s:\n{server.output()}") + await asyncio.sleep(0.1) + + +@contextlib.asynccontextmanager +async def running_server(**options: t.Any) -> t.AsyncIterator[Server]: + server = await start_server(**options) + try: + yield server + finally: + await server.stop() diff --git a/bench/src/mongospecs_bench/workloads.py b/bench/src/mongospecs_bench/workloads.py index 3b16d05..e6d4c55 100644 --- a/bench/src/mongospecs_bench/workloads.py +++ b/bench/src/mongospecs_bench/workloads.py @@ -53,9 +53,8 @@ async def b1(settings: Settings) -> Result: for backend in settings.backends: meta = meta_of(model_for(backend, shape)) modes: dict[str, t.Callable[[], t.Any]] = { - "validated": lambda: [meta.decode(d, trusted=False) for d in docs], - "trusted": lambda: [meta.decode(d, trusted=True) for d in docs], - "tracked": lambda: [meta.encode(meta.decode(d, trusted=False)) for d in docs], + "decode": lambda: [meta.decode(d) for d in docs], + "decode + encode": lambda: [meta.encode(meta.decode(d)) for d in docs], } for mode, fn in modes.items(): timing = measure_sync(fn, repeat=settings.repeat) @@ -67,8 +66,9 @@ async def b1(settings: Settings) -> Result: us_per_doc=round(timing.median_ms * 1000 / n, 2), vs_bson=_ratio(timing.median_ms, base.median_ms), ) - result.notes.append("'tracked' is decode plus the encode that builds the change-tracking snapshot.") - result.notes.append("'trusted' only differs for flat pydantic models; msgspec and attrs always convert.") + result.notes.append( + "'decode' always validates. 'decode + encode' adds turning the model back into a BSON-ready dict (what a save does)." + ) return result @@ -341,6 +341,17 @@ async def b5(settings: Settings) -> Result: manager = registry.manager(ns.Small) await manager.collection.insert_many([raw_document("small", i) for i in range(total)]) await manager.collection.create_index([("n", 1)]) + # Cursor pagination sorts by (key, _id). An index on the key alone cannot provide that + # order, so the server sorts in memory; measure that first, then add the right index. + unindexed = await measure(lambda: manager.find().sort("n").lean().page(size), repeat=settings.repeat) + result.add( + page="1, index on (n) only", + skip=0, + offset_ms="", + cursor_ms=round(unindexed.median_ms, 3), + offset_vs_cursor="", + ) + await manager.collection.create_index([("n", 1), ("_id", 1)]) for page in (1, 100, total // size // 2, total // size - 1): if page < 1 or page * size > total: continue @@ -393,7 +404,9 @@ async def facet() -> t.Any: cursor_ms="", offset_vs_cursor="", ) - result.notes.append(f"Collection of {total} documents, page size {size}, sorted on an indexed field.") + result.notes.append( + f"Collection of {total} documents, page size {size}; index on (n, _id) unless stated." + ) result.notes.append( "$facet returns the page and the total as one document, so the page is bound by the 16MB limit." ) diff --git a/bench/tests/conftest.py b/bench/tests/conftest.py new file mode 100644 index 0000000..05e2b92 --- /dev/null +++ b/bench/tests/conftest.py @@ -0,0 +1,80 @@ +import os +import uuid + +import pytest_asyncio + +from mongospecs import Registry +from mongospecs_bench.storefront import models as shop_models +from mongospecs_bench.storefront.auth import Tokens +from mongospecs_bench.storefront.seed import seed +from mongospecs_bench.storefront.server import start_server + +RS_URI = os.environ.get("MONGOSPECS_TEST_URI", "mongodb://localhost:27117/?replicaSet=rs0") +SECRET = "e2e-secret" + + +class Shop: + """A running storefront server plus direct database access for assertions.""" + + def __init__(self, server, registry, m, seeded, database, options): + self.server = server + self.registry = registry + self.m = m + self.seeded = seeded + self.database = database + self.options = options + self.tokens = Tokens(SECRET) + + @property + def url(self): + return self.server.base_url + + def user(self, index=0): + return {"authorization": f"Bearer {self.seeded.user_tokens[index]}"} + + @property + def admin(self): + return {"authorization": f"Bearer {self.seeded.admin_token}"} + + def manager(self, name): + return self.registry.manager(getattr(self.m, name)) + + async def restart(self, **overrides): + """Stop the server gracefully and start a new one on the same database.""" + await self.server.stop() + assert self.server.stopped_gracefully(), self.server.output() + self.server = await start_server( + database=self.database, mongo_uri=RS_URI, secret=SECRET, **{**self.options, **overrides} + ) + + +@pytest_asyncio.fixture +async def make_shop(): + """Factory: seed a fresh database and boot a real server process against it.""" + shops = [] + + async def factory( + *, plug="asgi", backend="msgspec", app="storefront", products=40, users=6, stock=100, projector=True + ): + database = f"ms_e2e_{uuid.uuid4().hex[:10]}" + m = shop_models.build(backend) + registry = Registry(RS_URI, database=database) + await registry.open() + seeded = await seed( + registry, m, Tokens(SECRET), products=products, users=users, stock=stock, reviews_per_product=2 + ) + options = {"plug": plug, "backend": backend, "app": app, "projector": projector} + server = await start_server(database=database, mongo_uri=RS_URI, secret=SECRET, **options) + shop = Shop(server, registry, m, seeded, database, options) + shops.append(shop) + return shop + + yield factory + for shop in shops: + code = await shop.server.stop() + await shop.registry.client.drop_database(shop.database) + await shop.registry.close() + assert shop.server.stopped_gracefully(), ( + f"server did not shut down cleanly (exit {code}):\n{shop.server.output()}" + ) + assert "Traceback" not in shop.server.output(), shop.server.output() diff --git a/bench/tests/test_e2e_storefront.py b/bench/tests/test_e2e_storefront.py new file mode 100644 index 0000000..68221b1 --- /dev/null +++ b/bench/tests/test_e2e_storefront.py @@ -0,0 +1,388 @@ +"""End-to-end tests: a real server process, real HTTP, real MongoDB. + +Every test boots ``uvicorn`` with the storefront application against a freshly +seeded database, talks to it over TCP, and checks the database directly for the +invariants that matter in production (no oversold stock, no partial orders, +nothing private in a response, a clean shutdown). +""" + +import asyncio + +import httpx +import pytest + +from mongospecs_bench.storefront.app import PLUGS +from mongospecs_bench.storefront.seed import stock_invariant + + +def client(shop, **kwargs): + return httpx.AsyncClient(base_url=shop.url, timeout=30, **kwargs) + + +async def eventually(check, *, seconds=20.0, every=0.1): + """Poll ``check`` (async, returns a truthy value when satisfied).""" + deadline = asyncio.get_running_loop().time() + seconds + while True: + value = await check() + if value: + return value + if asyncio.get_running_loop().time() > deadline: + raise AssertionError("condition was not met in time") + await asyncio.sleep(every) + + +@pytest.mark.parametrize("plug", PLUGS) +async def test_customer_journey_on_every_plug(make_shop, plug): + shop = await make_shop(plug=plug) + async with client(shop) as http: + health = (await http.get("/healthz")).json() + assert health == {"status": "ok", "backend": "msgspec", "plug": plug} + + # -- sign up and log in --------------------------------------------------------- + signup = await http.post( + "/api/users/signup", json={"email": "New@Example.com", "password": "correct horse", "name": "New"} + ) + assert signup.status_code == 201, signup.text + me = signup.json()["data"] + assert me["user"]["email"] == "new@example.com" and "password_hash" not in me["user"] + assert ( + await http.post( + "/api/users/signup", json={"email": "new@example.com", "password": "correct horse"} + ) + ).status_code == 409 + assert ( + await http.post("/api/users/signup", json={"email": "bad", "password": "x"}) + ).status_code == 422 + assert ( + await http.post("/api/users/login", json={"email": "new@example.com", "password": "wrong"}) + ).status_code == 401 + login = await http.post( + "/api/users/login", json={"email": "new@example.com", "password": "correct horse"} + ) + token = {"authorization": f"Bearer {login.json()['data']['token']}"} + assert (await http.get(f"/api/users/{me['user']['id']}", headers=token)).json()["data"][ + "name" + ] == "New" + assert (await http.get(f"/api/users/{me['user']['id']}", headers=shop.user(0))).status_code == 404 + assert (await http.get(f"/api/users/{me['user']['id']}")).status_code == 401 + + # -- browse ------------------------------------------------------------------------ + page = (await http.get("/api/products?category=books&sort=price&limit=3")).json() + prices = [p["price"] for p in page["data"]] + assert len(prices) == 3 and prices == sorted(prices) and page["page"]["has_next"] + assert all(p["category"] == "books" and "deleted_at" not in p for p in page["data"]) + following = ( + await http.get( + f"/api/products?category=books&sort=price&limit=3&after={page['page']['next_cursor']}" + ) + ).json() + assert following["data"][0]["price"] >= prices[-1] + assert {p["id"] for p in following["data"]}.isdisjoint(p["id"] for p in page["data"]) + assert (await http.get("/api/products?stock[$gt]=1")).status_code == 400 + assert (await http.get("/api/products?description=x")).status_code == 400 + product = page["data"][0] + detail = (await http.get(f"/api/products/{product['id']}?include=reviews,review_count")).json()[ + "data" + ] + assert ( + detail["review_count"] == 2 + and len(detail["reviews"]) == 2 + and detail["reviews"][0]["rating"] in range(1, 6) + ) + + # -- order: stock moves, the order is mine alone ------------------------------------- + other = page["data"][1] + placed = await http.post( + "/api/orders", + json={"lines": [{"sku": product["sku"], "qty": 2}, {"sku": other["sku"], "qty": 1}]}, + headers=token, + ) + assert placed.status_code == 201, placed.text + order = placed.json()["data"] + assert order["status"] == "placed" and order["user"] == me["user"]["id"] + assert order["total"] == round(2 * product["price"] + other["price"], 2) + assert sorted(line["sku"] for line in order["lines"]) == sorted([product["sku"], other["sku"]]) + assert (await http.get(f"/api/products/{product['id']}")).json()["data"]["stock"] == 98 + assert (await http.post("/api/orders", json={"lines": []}, headers=token)).status_code == 422 + assert ( + await http.post("/api/orders", json={"lines": [{"sku": product["sku"], "qty": 1}]}) + ).status_code == 401 + mine = (await http.get("/api/orders", headers=token)).json()["data"] + assert [o["id"] for o in mine] == [order["id"]] + assert (await http.get("/api/orders", headers=shop.user(0))).json()["data"] == [] + assert (await http.get(f"/api/orders/{order['id']}", headers=shop.user(0))).status_code == 404 + assert (await http.get("/api/orders")).status_code == 401 + assert len((await http.get("/api/orders", headers=shop.admin)).json()["data"]) == 1 + + # -- cancel: stock comes back, once ---------------------------------------------------- + assert (await http.post(f"/api/orders/{order['id']}/cancel", headers=shop.user(0))).status_code == 409 + cancelled = await http.post(f"/api/orders/{order['id']}/cancel", headers=token) + assert cancelled.status_code == 200 and cancelled.json()["data"]["status"] == "cancelled" + assert (await http.post(f"/api/orders/{order['id']}/cancel", headers=token)).status_code == 409 + assert (await http.get(f"/api/products/{product['id']}")).json()["data"]["stock"] == 100 + + # -- reviews: the server decides who the author is ----------------------------------------- + forged = await http.post( + "/api/reviews", + json={"product": product["id"], "rating": 5, "author": shop.seeded.product_ids[0]}, + headers=token, + ) + assert forged.status_code == 422 + assert ( + await http.post("/api/reviews", json={"product": product["id"], "rating": 9}, headers=token) + ).status_code == 422 + assert ( + await http.post("/api/reviews", json={"product": product["id"], "rating": 5}) + ).status_code == 401 + review = await http.post( + "/api/reviews", json={"product": product["id"], "rating": 5, "body": "great"}, headers=token + ) + assert review.status_code == 201 and review.json()["data"]["author"] == me["user"]["id"] + assert ( + await http.delete(f"/api/reviews/{review.json()['data']['id']}", headers=shop.user(1)) + ).status_code == 403 + listed = (await http.get(f"/api/reviews?product={product['id']}&sort=-created_at")).json()["data"] + assert len(listed) == 3 and listed[0]["body"] == "great" + + # -- admin: conditional update, protected fields, soft delete ------------------------------- + assert ( + await http.patch(f"/api/products/{product['id']}", json={"price": 1}, headers=token) + ).status_code == 403 + current = await http.get(f"/api/products/{product['id']}") + etag = current.headers["etag"] + changed = await http.patch( + f"/api/products/{product['id']}", json={"price": 9.5}, headers={**shop.admin, "if-match": etag} + ) + assert changed.status_code == 200 and changed.json()["data"]["price"] == 9.5 + stale = await http.patch( + f"/api/products/{product['id']}", json={"price": 1}, headers={**shop.admin, "if-match": etag} + ) + assert stale.status_code == 412 + assert ( + await http.patch(f"/api/products/{product['id']}", json={"stock": 10**6}, headers=shop.admin) + ).status_code == 422 + assert ( + await http.patch(f"/api/products/{product['id']}", json={"sku": "X"}, headers=shop.admin) + ).status_code == 422 + restocked = await http.post( + f"/api/products/{product['id']}/restock", json={"amount": 5}, headers=shop.admin + ) + assert restocked.json()["data"]["stock"] == 105 + assert (await http.delete(f"/api/products/{product['id']}", headers=shop.admin)).status_code == 204 + assert (await http.get(f"/api/products/{product['id']}")).status_code == 404 + assert ( + await http.post("/api/orders", json={"lines": [{"sku": product["sku"], "qty": 1}]}, headers=token) + ).status_code == 409 + raw = await shop.manager("Product").collection.find_one({"sku": product["sku"]}) + assert raw["deleted_at"] is not None and raw["price"] == 9.5 # soft-deleted, still there + assert await shop.manager("Review").collection.count_documents({"product": raw["_id"]}) == 3 + for problem in (stale, forged): + assert problem.headers["content-type"].startswith("application/problem+json") + + +@pytest.mark.parametrize("backend", ["pydantic", "attrs"]) +async def test_ordering_works_on_every_model_backend(make_shop, backend): + shop = await make_shop(backend=backend, plug="starlette", products=8) + async with client(shop) as http: + sku = shop.seeded.skus[0] + placed = await http.post( + "/api/orders", json={"lines": [{"sku": sku, "qty": 3}]}, headers=shop.user(0) + ) + assert placed.status_code == 201, placed.text + order = placed.json()["data"] + assert order["lines"][0]["qty"] == 3 and order["lines"][0]["product"] == shop.seeded.product_ids[0] + detail = ( + await http.get(f"/api/products/{shop.seeded.product_ids[0]}?include=reviews,review_count") + ).json()["data"] + assert detail["stock"] == 97 and detail["review_count"] == 2 + assert (await http.post(f"/api/orders/{order['id']}/cancel", headers=shop.user(0))).status_code == 200 + assert (await stock_invariant(shop.registry, shop.m, shop.seeded))["ok"] + + +async def test_concurrent_buyers_never_oversell(make_shop): + shop = await make_shop(products=3, users=40, stock=10) + sku = shop.seeded.skus[0] + async with client(shop, limits=httpx.Limits(max_connections=40)) as http: + responses = await asyncio.gather( + *( + http.post("/api/orders", json={"lines": [{"sku": sku, "qty": 1}]}, headers=shop.user(i)) + for i in range(40) + ) + ) + statuses = sorted(r.status_code for r in responses) + assert statuses == [201] * 10 + [409] * 30, statuses + assert (await http.get(f"/api/products/{shop.seeded.product_ids[0]}")).json()["data"]["stock"] == 0 + + # the change-stream projector catches up: exactly the ten sold units, counted once + async def stats_ready(): + rows = (await http.get(f"/api/sales?sku={sku}", headers=shop.admin)).json()["data"] + return rows if rows and rows[0]["units"] == 10 else None + + (stat,) = await eventually(stats_ready) + assert stat["units"] == 10 + invariant = await stock_invariant(shop.registry, shop.m, shop.seeded) + assert invariant == { + "orders_placed": 10, + "units_sold": 10, + "negative_stock": 0, + "stock_mismatches": 0, + "ok": True, + } + + +async def test_multi_item_orders_are_all_or_nothing(make_shop): + shop = await make_shop(products=4, stock=5) + plenty, scarce = shop.seeded.skus[0], shop.seeded.skus[1] + async with client(shop) as http: + refused = await http.post( + "/api/orders", + json={"lines": [{"sku": plenty, "qty": 2}, {"sku": scarce, "qty": 6}]}, + headers=shop.user(0), + ) + assert refused.status_code == 409 and scarce in refused.json()["detail"] + missing = await http.post( + "/api/orders", + json={"lines": [{"sku": plenty, "qty": 1}, {"sku": "NOPE", "qty": 1}]}, + headers=shop.user(0), + ) + assert missing.status_code == 409 + # contention across two products in opposite line order must neither deadlock nor oversell + mixed = await asyncio.gather( + *( + http.post( + "/api/orders", + json={"lines": [{"sku": a, "qty": 1}, {"sku": b, "qty": 1}]}, + headers=shop.user(i % 6), + ) + for i in range(12) + for a, b in ([plenty, scarce] if i % 2 else [scarce, plenty],) + ) + ) + assert sorted(r.status_code for r in mixed) == [201] * 5 + [409] * 7 + stocks = {d["sku"]: d["stock"] async for d in shop.manager("Product").collection.find({})} + assert stocks[plenty] == 0 and stocks[scarce] == 0 # nothing leaked from the refused orders + assert await shop.manager("Order").collection.count_documents({}) == 5 + assert (await stock_invariant(shop.registry, shop.m, shop.seeded))["ok"] + + +async def test_projector_resumes_after_a_restart_without_double_counting(make_shop): + shop = await make_shop(products=3, stock=1000) + sku = shop.seeded.skus[0] + + async def units(http): + rows = (await http.get(f"/api/sales?sku={sku}", headers=shop.admin)).json()["data"] + return rows[0]["units"] if rows else 0 + + async with client(shop) as http: + for _ in range(5): + assert ( + await http.post("/api/orders", json={"lines": [{"sku": sku, "qty": 2}]}, headers=shop.user(0)) + ).status_code == 201 + + async def ten(): + return await units(http) == 10 + + await eventually(ten) + # Orders written while no projector is running must be picked up from the stored resume token. + await shop.restart(projector=False) + async with client(shop) as http: + for _ in range(3): + assert ( + await http.post("/api/orders", json={"lines": [{"sku": sku, "qty": 1}]}, headers=shop.user(1)) + ).status_code == 201 + cancel_me = (await http.get("/api/orders?sort=-created_at&limit=1", headers=shop.user(1))).json()[ + "data" + ][0] + assert ( + await http.post(f"/api/orders/{cancel_me['id']}/cancel", headers=shop.user(1)) + ).status_code == 200 + assert await units(http) == 10 # nothing is projecting right now + await shop.restart() # back with the projector + async with client(shop) as http: + + async def caught_up(): + return await units(http) == 12 # 10 + 3 placed - 1 cancelled + + await eventually(caught_up) + await asyncio.sleep(1) + assert await units(http) == 12 # and it stays there: no event was applied twice + state = await shop.manager("ProjectorState").collection.find_one({"name": "sales"}) + assert state["events"] == 9 # 5 + 3 inserts and 1 cancellation + + +async def test_requests_in_flight_finish_on_graceful_shutdown(make_shop): + shop = await make_shop(products=200, stock=1000) + async with client(shop) as http: + slow = [ + asyncio.create_task(http.get("/api/products?limit=100&include=reviews,review_count&sort=price")) + for _ in range(20) + ] + await asyncio.sleep(0.02) + stopping = asyncio.create_task(shop.server.stop()) + responses = await asyncio.gather(*slow, return_exceptions=True) + await stopping + assert shop.server.stopped_gracefully(), shop.server.output() + finished = [r for r in responses if isinstance(r, httpx.Response)] + # requests the server had accepted were answered in full before it exited + assert finished and all(r.status_code == 200 and len(r.json()["data"]) == 100 for r in finished) + # anything else was refused at the connection level, never answered with a broken body + assert all(isinstance(r, httpx.Response | httpx.HTTPError) for r in responses) + + +@pytest.mark.chaos +async def test_orders_stay_consistent_across_a_primary_step_down(make_shop): + shop = await make_shop(products=20, users=10, stock=100_000) + statuses = [] + stop = asyncio.Event() + + async def shopper(index, http): + while not stop.is_set(): + sku = shop.seeded.skus[index % 20] + try: + response = await http.post( + "/api/orders", json={"lines": [{"sku": sku, "qty": 1}]}, headers=shop.user(index) + ) + statuses.append(response.status_code) + if response.status_code >= 400: + assert response.headers["content-type"].startswith("application/problem+json") + listing = await http.get("/api/products?limit=5") + statuses.append(listing.status_code) + except httpx.HTTPError: + statuses.append(0) + await asyncio.sleep(0.01) + + async with client(shop, limits=httpx.Limits(max_connections=20)) as http: + shoppers = [asyncio.create_task(shopper(i, http)) for i in range(10)] + await asyncio.sleep(1.5) + before = len(statuses) + try: + await shop.registry.client.admin.command("replSetStepDown", 3, force=True) + except Exception: + pass # the command closes connections; that is expected + await asyncio.sleep(1) + + async def healthy_again(): + try: + return ( + await http.post( + "/api/orders", + json={"lines": [{"sku": shop.seeded.skus[0], "qty": 1}]}, + headers=shop.user(0), + ) + ).status_code == 201 + except httpx.HTTPError: + return False + + await eventually(healthy_again, seconds=90, every=0.5) + await asyncio.sleep(1.5) + stop.set() + await asyncio.gather(*shoppers) + assert (await http.get("/healthz")).status_code == 200 + assert before > 20 and statuses.count(201) > before // 2 + # during the election requests may fail, but only as declared errors: never a 500, never a hang + assert set(statuses) <= {200, 201, 503, 504, 409}, sorted(set(statuses)) + invariant = await stock_invariant(shop.registry, shop.m, shop.seeded) + assert invariant["ok"], ( + invariant + ) # every accepted order moved stock exactly once; no rejected order moved any + assert invariant["orders_placed"] == statuses.count(201) + 1 diff --git a/docs/benchmarks.md b/docs/benchmarks.md new file mode 100644 index 0000000..649d4df --- /dev/null +++ b/docs/benchmarks.md @@ -0,0 +1 @@ +placeholder until results exist diff --git a/docs/guide/models.md b/docs/guide/models.md new file mode 100644 index 0000000..c6b9f87 --- /dev/null +++ b/docs/guide/models.md @@ -0,0 +1,134 @@ +# Models + +A model is a class from the library you already use, with the `Spec` base of that backend. +Everything else in mongospecs is imported from `mongospecs` and is the same for all three. + +```python +from datetime import datetime +from typing import Annotated + +from mongospecs import OID, CreatedAt, Deferred, Immutable, Index, Policy, Registry, SpecConfig, UpdatedAt +from mongospecs.msgspec import Spec # or: from mongospecs.pydantic import Spec / mongospecs.attrs + + +class User(Spec): + __spec__ = SpecConfig(collection="users", indexes=[Index("email", unique=True)]) + + email: Annotated[str, Immutable] + name: str = "" + password_hash: Annotated[str | None, Deferred, Policy(private=True)] = None + created_at: Annotated[datetime | None, CreatedAt] = None + updated_at: Annotated[datetime | None, UpdatedAt] = None +``` + +- Every document has an `id` attribute, stored as `_id`. Its type is `OID | None` unless you + redeclare it (`id: str | None = None` for string ids). +- `__spec__` is assigned **without an annotation**. It holds the collection name (default: the + class name), indexes, and per-model options. +- With attrs, decorate each model with `@attrs.define(kw_only=True)`. + +## Binding and lifecycle + +Models do nothing until they are bound to an open registry. Constructing a registry does no I/O; +`async with` opens it; closing is final. + +```python +registry = Registry("mongodb://localhost/app", models=[User]) +await registry.open() +await registry.apply_indexes() + +ann = await User(email="ann@example.com", name="Ann").insert() +assert isinstance(ann.id, OID.__origin__) and ann.created_at is not None +``` + +Operations on a registry that is not open fail immediately with `RegistryClosedError`; nothing is +queued. + +## Field markers + +Markers go in `typing.Annotated`, so one declaration works on every backend. + +| Marker | Effect | +|---|---| +| `CreatedAt`, `UpdatedAt` | Timestamps on every write path, including bulk and filter-based updates | +| `DeletedAt` | Soft delete: `delete()` sets it, reads exclude documents where it is set | +| `Revision` | Optimistic locking; the value doubles as an HTTP ETag | +| `Immutable` | Cannot change after insert | +| `Deferred` | Not loaded unless asked for with `select("+name")` | +| `Alias("x")` | Stored under a shorter name (top-level fields) | +| `Policy(...)` | How the field may be used from outside (read by `mongospecs-web`) | +| `Backref(...)`, `OnDelete(...)`, `To(...)` | Relations, see [Relations](relations.md) | + +```python +loaded = await User.get(ann.id) +assert loaded.password_hash is None # Deferred: not fetched + +with_hash = await User.find().select("+password_hash").one() +assert with_hash.email == "ann@example.com" +``` + +## Existing classes + +`SpecAdapter` turns a class you already have into a document class without touching it. + +```python +import msgspec + +from mongospecs.msgspec import SpecAdapter + + +class Dragon(msgspec.Struct, kw_only=True): + name: str + breed: str = "unknown" + + +DragonDoc = SpecAdapter(Dragon, collection="dragons") +registry.bind(DragonDoc) +burt = await DragonDoc(name="Burt").insert() +assert isinstance(burt, Dragon) and burt.id is not None +``` + +## Single-collection inheritance + +Set `discriminator` on the base; subclasses share the collection and are picked on read. + +```python +class Animal(Spec): + __spec__ = SpecConfig(collection="animals", discriminator="kind") + name: str = "" + + +class Cat(Animal): + lives: int = 9 + + +registry.bind(Animal, Cat) +await Cat(name="Tom").insert() +(animal,) = await Animal.find() +assert type(animal) is Cat +``` + +## Documents written by older code + +`upcast` runs on each raw document before it is decoded, so renamed or added fields can be +migrated lazily. Fields the model does not know are ignored on read and left alone on write. + +```python +def upcast(doc): + if "full_name" in doc: + doc["name"] = doc.pop("full_name") + return doc + + +class Person(Spec): + __spec__ = SpecConfig(collection="people", upcast=upcast) + name: str + + +registry.bind(Person) +await Person.collection().insert_one({"full_name": "Old Shape", "legacy_flag": True}) +person = await Person.find().one() +assert person.name == "Old Shape" + +await registry.close() +``` diff --git a/docs/guide/operations.md b/docs/guide/operations.md new file mode 100644 index 0000000..0feccdf --- /dev/null +++ b/docs/guide/operations.md @@ -0,0 +1,92 @@ +# Running in production + +```python +import logging +from typing import Annotated + +from mongospecs import Action, Index, Registry, SpecConfig, logging_listener +from mongospecs.msgspec import Spec + + +class Account(Spec): + __spec__ = SpecConfig(collection="accounts", indexes=[Index("email", unique=True), Index("-balance")]) + email: str = "" + balance: Annotated[int, "cents"] = 0 + + +events = [] +registry = await Registry( + "mongodb://localhost/app", + models=[Account], + max_time_ms=5000, # default time limit for reads + on_command=[events.append, logging_listener(level=logging.DEBUG)], +).open() +``` + +## Indexes + +Nothing is built implicitly. `plan_indexes()` compares what is declared with what exists; +`apply_indexes()` creates what is missing and only drops or recreates with `drop=True`. + +```python +plan = await registry.plan_indexes() +assert {a.name: a.action for a in plan} == {"email_1": Action.CREATE, "balance_-1": Action.CREATE} +await registry.apply_indexes(plan) +assert (await registry.plan_indexes()).in_sync +``` + +| Action | Meaning | Destructive | +|---|---|---| +| `CREATE` | declared, missing | no | +| `RETAIN` | declared and identical | no | +| `RECREATE` | same name, different definition | yes | +| `CONFLICT` | same keys under another name | yes | +| `DROP` | exists, not declared | yes | + +`plan.advisories` lists non-blocking findings, such as a reverse relation whose foreign key has no +index. + +## Observability + +Every driver command produces `started` and `succeeded`/`failed` events carrying the model and the +mongospecs operation. + +```python +await Account(email="a@example.com").insert() +started = [e for e in events if e.phase == "started" and e.model] +assert (started[-1].model, started[-1].op, started[-1].command_name) == ("Account", "insert", "insert") +``` + +`logging_listener()`, `otel_listener()` (extra `otel`) and `sentry_listener()` (extra `sentry`) are +ready-made listeners. With the Sentry SDK, database work shows up as `Account.insert`, +`Account.find` and so on inside the active trace, on Sentry or any compatible server. + +## Errors + +mongospecs raises its own errors and keeps the driver error as `__cause__`. + +| Error | When | +|---|---| +| `ValidationError` | input does not fit the model | +| `QueryError` | unknown field, uncastable value, untrusted operator | +| `DocumentNotFound` | an acknowledged read or write matched nothing | +| `StaleDocumentError` | a revision check failed | +| `DuplicateKeyError` | unique index violated; `.fields` names the values | +| `OperationTimeout`, `ConnectionFailed`, `PoolTimeout` | the database is slow or unreachable | +| `DocumentTooLarge` | a document or pipeline result exceeded 16MB | + +## More than one database + +```python +archive = Account.using(db="app_archive") +await archive.insert(Account(email="old@example.com")) +assert await archive.count() == 1 and await Account.count() == 1 + +await registry.client.drop_database("app_archive") +await registry.client.drop_database("app") +await registry.close() +``` + +Also: `SpecConfig(shard_key=[...])` adds the shard key to document-level writes so they are +targeted; `Model.watch()` opens a change stream; `SpecConfig(json_schema_validation="strict")` +installs a `$jsonSchema` validator derived from the model when indexes are applied. diff --git a/docs/guide/plugins.md b/docs/guide/plugins.md new file mode 100644 index 0000000..81428d0 --- /dev/null +++ b/docs/guide/plugins.md @@ -0,0 +1,68 @@ +# Hooks and plugins + +mongospecs has the same four kinds of middleware as Mongoose, with typed signatures. + +| Kind | Where | Runs for | +|---|---|---| +| Document hooks | `@Model.before("insert" \| "save" \| "delete")`, `@Model.after(...)` | document-level operations | +| Command and filter rewriting | `Plugin.on_insert`, `on_update`, `on_filter`, `on_delete` | every write and read, including filter-based ones | +| Pipeline rewriting | `Plugin.on_pipeline` | `Model.aggregate` | +| Bulk hooks | `Plugin.before_bulk`, `after_bulk` | `insert_many`, `bulk_write`, `bulk_save` | + +```python +from mongospecs import Plugin, Q, Registry, SpecConfig +from mongospecs.msgspec import Spec + + +class Note(Spec): + __spec__ = SpecConfig(collection="notes") + text: str = "" + slug: str = "" + + +@Note.before("insert") +async def slugify(ctx, note): + note.slug = note.text.lower().replace(" ", "-") +``` + +Hooks receive an `OpCtx` (the session, registry and model) and the document. They run in +registration order and the first exception stops the operation. + +Document hooks do not run for filter-based writes such as `Note.find(...).update(...)`: no +documents are loaded. Plugins still rewrite those commands, which is how timestamps, revisions and +scopes keep working there. + +## Writing a plugin + +A plugin overrides only what it needs. This one confines every model of a registry to one tenant. + +```python +class Tenant(Plugin): + name = "tenant" + + def __init__(self, tenant_id): + self.tenant_id = tenant_id + + def on_insert(self, ctx, doc): + doc["tenant_id"] = self.tenant_id + + def on_filter(self, ctx, flt): + return {"tenant_id": self.tenant_id} + + +registry = await Registry("mongodb://localhost/app", models=[Note], plugins=[Tenant("acme")]).open() +note = await Note(text="Hello World").insert() +assert note.slug == "hello-world" + +await Note.collection().insert_one({"text": "someone else's", "tenant_id": "other"}) +assert [n.text for n in await Note.find()] == ["Hello World"] +assert await Note.find().update(text="changed") == 1 # the other tenant's note is untouched +assert await Note.find().unscoped("tenant").count() == 2 + +await registry.client.drop_database("app") +await registry.close() +``` + +Plugins attach in three ways: by a field marker (the built-in timestamps, soft delete and +revisions), per model with `SpecConfig(plugins=[...])`, or to every model with +`Registry(plugins=[...])`. diff --git a/docs/guide/queries.md b/docs/guide/queries.md new file mode 100644 index 0000000..5519d44 --- /dev/null +++ b/docs/guide/queries.md @@ -0,0 +1,129 @@ +# Queries + +Queries are immutable and chainable. Nothing is sent until you await a terminal method or iterate. + +```python +from typing import Annotated + +from mongospecs import Deferred, Q, QueryError, Registry, SpecConfig, trusted +from mongospecs.msgspec import Spec + + +class Article(Spec): + __spec__ = SpecConfig(collection="articles") + title: str = "" + views: int = 0 + tags: list[str] = [] + body: Annotated[str, Deferred] = "" + + +registry = await Registry("mongodb://localhost/app", models=[Article]).open() +await Article.insert_many([Article(title=f"post {i}", views=i * 10, tags=["a"] if i % 2 else ["b"]) for i in range(10)]) +``` + +## Conditions + +`Q.field` builds conditions. Field names are checked against the model and values are cast to the +field's type, so a typo is an error instead of a query that silently matches nothing. + +```python +popular = await Article.find(Q.views >= 50, Q.title.prefix("post")).sort("-views").limit(3) +assert [a.views for a in popular] == [90, 80, 70] + +either = await Article.find((Q.views < 10) | (Q.views > 80)).sort("views").all() +assert [a.views for a in either] == [0, 90] + +assert await Article.count(Q.tags == "a") == 5 # a scalar against a list field means "contains" +assert await Article.exists(~Q.title.prefix("post")) is False + +try: + Article.find(Q.titel == "x").compile() +except QueryError as exc: + assert "no field 'titel'" in str(exc) +``` + +Operators: `== != > >= < <=`, `.in_()`, `.nin()`, `.all()`, `.exists()`, `.size()`, +`.prefix()`, `.contains()`, `.regex()`, `.elem_match()`, combined with `&`, `|`, `~` or +`And`/`Or`/`Nor`. + +Plain dicts work for equality. Anything containing a MongoDB operator must be wrapped in +`trusted()`, which marks it as written by you rather than built from user input. + +```python +assert await Article.count({"title": "post 3"}) == 1 +assert await Article.count(trusted({"views": {"$mod": [20, 0]}})) == 5 +``` + +## Results + +```python +first = await Article.find(Q.views > 0).sort("views").first() # a document, or None +one = await Article.find(Q.title == "post 4").one() # raises DocumentNotFound if missing +by_id = await Article.get(one.id) +assert first.views == 10 and by_id.title == "post 4" + +async for article in Article.find().sort("-views").limit(2): + assert article.views >= 80 + +titles = [a["title"] for a in await Article.find().raw().limit(2)] # plain dicts +``` + +| Mode | Returns | Tracked for `save()` | +|---|---|---| +| default | model instances | yes | +| `.lean()` | model instances | no (read-only) | +| `.raw()` | dicts as stored | no | + +## Projection + +`select("a", "b")` includes, `select("-a")` excludes, `select("+a")` adds a `Deferred` field. +A document loaded with a projection only saves the fields that were loaded. + +```python +partial = await Article.find().select("title").first() +assert partial.title and partial.views == 0 # not loaded: the default, not the stored value +``` + +## Pagination + +Cursor pagination is the default and stays stable while documents are inserted. Offset pagination +and totals are available when you need them. + +Cursor pages are sorted by your keys and then by `_id`, so that the order is total. Give the query +an index that ends in the id (`Index("views", "id")`); with an index on the key alone MongoDB has +to sort in memory. + +```python +page = await Article.find().sort("-views").page(4) +assert [a.views for a in page] == [90, 80, 70, 60] and page.has_next + +following = await Article.find().sort("-views").page(4, after=page.next_cursor) +assert [a.views for a in following] == [50, 40, 30, 20] + +third = await Article.find().sort("views").page(4, number=3, total=True) +assert [a.views for a in third] == [80, 90] and third.total == 10 +``` + +## Named scopes + +```python +@Article.scope +def trending(query, minimum=50): + return query.filter(Q.views >= minimum) + + +assert await Article.find().trending(80).count() == 2 +``` + +## Escape hatches + +`Model.aggregate(pipeline, as_=...)` runs a raw pipeline (scopes still apply) and +`Model.collection()` returns the driver collection. + +```python +rows = await Article.aggregate([{"$group": {"_id": None, "views": {"$sum": "$views"}}}]) +assert rows[0]["views"] == 450 + +await registry.client.drop_database("app") +await registry.close() +``` diff --git a/docs/guide/relations.md b/docs/guide/relations.md new file mode 100644 index 0000000..ee2677a --- /dev/null +++ b/docs/guide/relations.md @@ -0,0 +1,93 @@ +# Relations + +```python +from typing import Annotated, Any + +from mongospecs import Backref, Index, NotLoadedError, OnDelete, Q, Ref, Registry, SpecConfig +from mongospecs.msgspec import Spec + + +class Author(Spec): + __spec__ = SpecConfig(collection="authors") + name: str = "" + books: Annotated[list[Any], Backref("author", model="Book")] = [] + book_count: Annotated[int, Backref("author", model="Book", count=True)] = 0 + + +class Book(Spec): + __spec__ = SpecConfig(collection="books", indexes=[Index("author")]) + title: str = "" + author: Annotated[Ref[Author] | None, OnDelete("cascade")] = None + editors: list[Ref[Author]] = [] + + +registry = await Registry("mongodb://localhost/app", models=[Author, Book]).open() +ann = await Author(name="Ann").insert() +bob = await Author(name="Bob").insert() +await Book.insert_many([Book(title="One", author=ann, editors=[bob]), Book(title="Two", author=ann)]) +``` + +## References + +`Ref[T]` is stored as the target's id. It always has `.id`; the document is available after +`include()` or `fetch()`. + +```python +book = await Book.find(Q.title == "One").one() +assert book.author.id == ann.id and not book.author.loaded +try: + book.author.doc +except NotLoadedError: + pass +assert (await book.author.fetch()).name == "Ann" + +assert await Book.count(Q.author == ann) == 2 # documents and ids both work in conditions +``` + +## include() + +```python +books = await Book.find().sort("title").include("author").include("editors") +assert books[0].author.doc.name == "Ann" +assert [e.doc.name for e in books[0].editors] == ["Bob"] +``` + +Options per relation: `match`, `select`, `sort`, `limit` (overall), `per_parent_limit`, and nested +paths such as `include("author.books")`. + +`include()` can be served by one extra query per relation (`strategy="batch"`, the default) or by a +`$lookup` stage in a single pipeline (`strategy="lookup"`). Both return the same result; which is +faster depends on fan-out and topology, see [Benchmarks](../benchmarks.md). + +```python +same = await Book.find().sort("title").include("author", strategy="lookup") +assert same[0].author.doc.name == "Ann" +``` + +## Reverse relations + +A `Backref` field is never stored. It is filled only when included; with `count=True` it holds the +number of referencing documents. + +```python +author = await Author.find(Q.name == "Ann").include("books", sort=["title"]).include("book_count").one() +assert [b.title for b in author.books] == ["One", "Two"] and author.book_count == 2 +``` + +## Cascades + +`OnDelete` on a reference says what happens to the documents holding it when the target is +deleted: `"cascade"` deletes them, `"nullify"` clears the field, `"pull"` removes the id from a +list. The delete and its cascades run in one transaction. + +```python +await ann.delete() +assert await Book.count() == 0 + +await registry.client.drop_database("app") +await registry.close() +``` + +Also available: polymorphic references (`Ref[Post | Video]`), `dict[str, Ref[T]]`, +`Model.find(...).graph("parent")` for `$graphLookup` over a self-reference, and +`a.find(...).union(b.find(...))` for `$unionWith` across collections. diff --git a/docs/guide/writes.md b/docs/guide/writes.md new file mode 100644 index 0000000..054de90 --- /dev/null +++ b/docs/guide/writes.md @@ -0,0 +1,159 @@ +# Writes + +```python +from datetime import datetime +from typing import Annotated + +from mongospecs import ( + CreatedAt, + DeletedAt, + DocumentNotFound, + Inc, + Push, + Q, + Registry, + Revision, + SetOnInsert, + SpecConfig, + StaleDocumentError, + UpdatedAt, + UpdateOne, +) +from mongospecs.msgspec import Spec + + +class Article(Spec): + __spec__ = SpecConfig(collection="articles") + title: str = "" + views: int = 0 + tags: list[str] = [] + created_at: Annotated[datetime | None, CreatedAt] = None + updated_at: Annotated[datetime | None, UpdatedAt] = None + deleted_at: Annotated[datetime | None, DeletedAt] = None + rev: Annotated[int, Revision] = 0 + + +registry = await Registry("mongodb://localhost/app", models=[Article]).open() +``` + +## Change tracking + +A document remembers what it looked like when it was loaded. `save()` sends only the difference; +an unchanged document sends nothing. + +```python +article = await Article(title="Draft").insert() +article.title = "Final" +article.tags.append("news") +assert article.changes() == {"$set": {"title": "Final", "tags": ["news"]}} +await article.save() +assert article.changes() == {} and article.rev == 1 +``` + +Nested documents are diffed into dotted paths. A changed list is sent whole; for element-level +changes that must not overwrite concurrent writers, use the atomic operators below. + +## Atomic updates + +`Model.update` changes one document in a single round trip and returns it. Every supplied field +is validated; unknown and immutable fields raise. + +```python +updated = await Article.update(article.id, Inc(views=1), Push(tags="featured"), title="Final v2") +assert updated.views == 1 and updated.tags == ["news", "featured"] and updated.rev == 2 + +assert await Article.find(Q.views >= 1).update(Inc(views=10)) == 1 # every match, no documents loaded + +created = await Article.upsert(Q.title == "Landing", Inc(views=1), SetOnInsert(tags=["page"])) +assert created.views == 1 and created.created_at is not None +``` + +Operators: `Set`, `Unset`, `Inc`, `Mul`, `Min`, `Max`, `Push`, `AddToSet`, `Pull`, `Pop`, +`SetOnInsert`, `CurrentDate`; `Each([...])` for several values. + +## Optimistic locking + +With a `Revision` field, saving a stale copy fails instead of overwriting. + +```python +stale = article # loaded at rev 1, the database is now at rev 3 +stale.title = "Overwrite?" +try: + await stale.save() +except StaleDocumentError: + await stale.reload() +assert stale.title == "Final v2" +``` + +## Soft delete + +With a `DeletedAt` field, `delete()` hides the document instead of removing it. + +```python +await stale.delete() +assert await Article.get(stale.id) is None +assert await Article.find().unscoped("soft_delete").count() == 2 +await Article.find(Q.id == stale.id).unscoped("soft_delete").update(deleted_at=None) # restore +await (await Article.get(stale.id)).delete(hard=True) # really remove +``` + +## Bulk + +```python +result = await Article.insert_many([Article(title=f"bulk {i}") for i in range(3)]) +assert len(result.inserted) == 3 and not result.errors + +docs = await Article.find(Q.title.prefix("bulk")).all() +for doc in docs: + doc.views += 5 +assert await Article.bulk_save(docs) == 3 # one round trip, deltas only + +await Article.bulk_write([UpdateOne(Q.title == "bulk 0", Inc(views=1))]) +assert (await Article.find(Q.title == "bulk 0").one()).views == 6 +``` + +`insert_many(..., ordered=False)` attempts every document and reports failures per item instead of +raising on the first. + +## Transactions + +`registry.transaction(fn)` commits when `fn` returns. The driver re-runs `fn` on transient errors, +so it must be safe to call twice. If the transaction aborts, documents written inside it are put +back to their previous in-memory state. + +```python +landing = await Article.find(Q.title == "Landing").one() + + +async def promote(tx): + landing.tags.append("promoted") + await landing.save(session=tx) + raise RuntimeError("changed my mind") + + +try: + await registry.transaction(promote) +except RuntimeError: + pass + +assert (await Article.get(landing.id)).tags == ["page"] # rolled back in the database +assert landing.rev == 1 and landing.changes() == {"$set": {"tags": ["page", "promoted"]}} # and in memory +``` + +Sessions are always explicit: pass `session=tx` to every operation inside the function. For manual +control use `async with registry.session() as s, s.start_transaction(): ...` (no automatic retry). + +```python +try: + await Article.update(landing.id, title=123) +except Exception as exc: + assert type(exc).__name__ == "ValidationError" + +try: + await Article.update("0" * 24, title="nobody") +except DocumentNotFound: + pass + +await registry.client.drop_database("app") +await registry.close() +``` diff --git a/docs/index.md b/docs/index.md index 54206c3..90f345f 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,6 +1,44 @@ # mongospecs -An async MongoDB ODM for msgspec, pydantic and attrs models, and a framework-neutral REST layer. +An async MongoDB ODM for the model library you already use (msgspec, pydantic or attrs) and a +framework-neutral REST layer on top of it. -- [Design and work graph](https://github.com/jaykv/mongospecs/blob/main/claudedocs/v2-plan.md) -- [v1 test inventory](v1-test-inventory.md): what became of every 0.1.x behaviour +``` +pip install "mongospecs[msgspec]" # or [pydantic], [attrs] +pip install "mongospecs-web[fastapi]" # or [starlette], [litestar], [quart]; none needed for plain ASGI +``` + +Requires Python 3.11+ and MongoDB 7.0+. Status: alpha. + +## Where to start + +- [Models](guide/models.md): declaring documents, binding them, field markers +- [Queries](guide/queries.md) and [Writes and transactions](guide/writes.md) +- [Relations](guide/relations.md), [Hooks and plugins](guide/plugins.md) +- [Running in production](guide/operations.md): indexes, observability, errors +- [Resources](web/resources.md) and [Plugs](web/plugs.md): the REST layer +- [Coming from Mongoose](parity.md), [Migrating from 0.1.x](migration.md) +- [Benchmarks](benchmarks.md): measured against real MongoDB + +Every example in the guide is executed by the test suite. + +## What it looks like + +```py +class Article(Spec): + __spec__ = SpecConfig(collection="articles", indexes=[Index("title", unique=True)]) + + title: Annotated[str, Policy(filter=("eq", "prefix"), sort=True)] + views: int = 0 + author: Ref[User] | None = None + updated_at: Annotated[datetime | None, UpdatedAt] = None + rev: Annotated[int, Revision] = 0 + + +async with Registry("mongodb://localhost/app", models=[User, Article]) as registry: + article = await Article.find(Q.title.prefix("Hello")).include("author").one() + article.views += 1 + await article.save() # sends {"$set": {"views": ...}} and checks the revision + +app = Api(Resource(Article), prefix="/api").asgi(registry=registry) # a REST API, on any framework +``` diff --git a/docs/migration.md b/docs/migration.md new file mode 100644 index 0000000..1937b7a --- /dev/null +++ b/docs/migration.md @@ -0,0 +1,66 @@ +# Migrating from 0.1.x + +0.2 is a rewrite. It is async-only, needs Python 3.11+ and MongoDB 7.0+, and its API is not +compatible with 0.1.x. The synchronous line stays available on the +[`0.1.x` branch](https://github.com/jaykv/mongospecs/tree/0.1.x) and as `mongospecs<0.2`. + +What stayed: models on msgspec, pydantic or attrs; `from mongospecs. import Spec`; `Q` +expressions and `And`/`Or`/`Nor`/`In`/...; `SpecAdapter`. + +## The shape of the change + +```py +# 0.1.x +Spec._client = MongoClient("mongodb://localhost/app") +burt = Dragon(name="Burt") +burt.insert() +doc = Dragon.one(Q.name == "Burt") + +# 0.2 +async with Registry("mongodb://localhost/app", models=[Dragon]): + burt = await Dragon(name="Burt").insert() + doc = await Dragon.find(Q.name == "Burt").first() +``` + +## API map + +| 0.1.x | 0.2 | Notes | +|---|---|---| +| `Spec._client = client` | `Registry(uri, models=[...])` | explicit lifecycle, no class-level client | +| `_collection = "dragons"` | `__spec__ = SpecConfig(collection="dragons")` | assigned without an annotation | +| `_id` / `id` depending on backend | `id` everywhere, stored as `_id` | | +| `Model.by_id(id)` | `await Model.get(id)` | | +| `Model.one(filter)` | `await Model.find(filter).first()` or `.one()` | `.one()` raises when nothing matches | +| `Model.many(filter)` | `await Model.find(filter)` | also `async for` | +| `Model.find(...)`, `find_one(...)` (raw dicts) | `Model.find(...).raw()` | | +| `Model.ids(filter)` | `await Model.find(filter).ids()` | | +| `Model.count(filter)` | `await Model.count(filter)` | always exact; `estimated_count()` is separate | +| `doc.update()` / `update("field")` | `await doc.save()` | sends only what changed | +| `doc.upsert()` | `await Model.upsert(filter, ...)` | one round trip | +| `doc.unset("field")` | `await Model.update(id, Unset("field"))` | | +| `Model.update_many(docs)` | `await Model.bulk_save(docs)` or `Model.find(...).update(...)` | | +| `Model.delete_many(docs)` | `await Model.find(Q.id.in_(ids)).delete()` | | +| projection with `$ref` | `.include("field")` | `Ref[T]` fields | +| projection with `$sub` / `SubSpec` | a nested model as the field type | decoded by the backend | +| `Model.cascade` / `nullify` / `pull` | `OnDelete("cascade" \| "nullify" \| "pull")` on the reference | | +| `Model.listen("insert", fn)` | `@Model.before("insert")` / `@Model.after(...)` | async, typed, no global signals | +| `soft_delete()` / `find_active()` | a `DeletedAt` field | scope applied everywhere | +| `with Model.transaction() as session` | `await registry.transaction(fn)` | retried, restores document state | +| `Model.with_options(...)` | `Model.using(read_preference=..., db=...)` | returns a manager, changes nothing globally | +| `Paginator(Model, ...)` | `await Model.find(...).page(size, after=...)` | cursor by default; `number=` for offsets | +| `create_index` / `drop_index` | `SpecConfig(indexes=[Index(...)])` + `registry.apply_indexes()` | | +| `to_json_type()` | your backend's own serialisation, or `mongospecs_web.view_of(Model).serialize(doc)` | | +| `Empty` | `None`, plus `Unset(...)` for removing a field | | + +## Behaviour differences worth knowing + +- Reads are validated. A stored document that does not fit the model raises `ValidationError` + instead of producing a half-typed object. `SpecConfig(upcast=...)` migrates old shapes on read. +- Unknown field names in conditions and updates raise `QueryError`. +- Raw dict filters may not contain operators unless wrapped in `trusted(...)`. +- Reference fields hold a `Ref` object (`.id`, `.doc`), not a bare id or a document. +- msgspec is no longer installed implicitly: pick an extra (`mongospecs[msgspec]`, `[pydantic]`, + `[attrs]`). +- Documents compare with their backend's own equality, not by id. + +Every 0.1.x test is accounted for in the [v1 test inventory](v1-test-inventory.md). diff --git a/docs/parity.md b/docs/parity.md new file mode 100644 index 0000000..0ff8e17 --- /dev/null +++ b/docs/parity.md @@ -0,0 +1,68 @@ +# Coming from Mongoose + +mongospecs takes Mongoose as its reference for what a production ODM should do. This page maps +Mongoose features to their mongospecs form and to the tests that cover them, and lists where +mongospecs deliberately behaves differently. + +Test paths are relative to `packages/mongospecs/tests/`. "Source" says how the Mongoose behaviour +was established: **docs** means it was re-read in the Mongoose documentation for this project, +**memory** means it is stated from general knowledge of Mongoose 8.x and has not been re-checked. + +## Feature map + +| Mongoose | mongospecs | Covered by | Source | +|---|---|---|---| +| Change tracking, delta `save()`, `isNew`, `modifiedPaths()` | snapshot at load; `save()` sends the diff; `doc.is_new`, `doc.changes()` | `test_crud.py::test_save_new_then_delta`, `test_tracking_tx.py` | memory | +| `lean()` | `.lean()` (models, untracked), `.raw()` (dicts) | `test_crud.py::test_read_modes` | docs | +| `Model.hydrate()` | `Model.hydrate(raw)` | `test_crud.py::test_hydrate_and_collection_escape_hatch` | memory | +| Middleware: document, query, aggregate, model | document hooks; `on_insert`/`on_update`/`on_filter`/`on_delete`; `on_pipeline`; `before_bulk`/`after_bulk` | `test_plugins_hooks.py` | docs | +| `schema.plugin()`, `mongoose.plugin()` | marker plugins, `SpecConfig(plugins=)`, `Registry(plugins=)` | `test_plugins_hooks.py::test_registry_level_plugin_applies_to_every_model` | memory | +| `timestamps` | `CreatedAt`, `UpdatedAt` | `test_plugins_hooks.py::test_timestamps_on_every_write_path` | memory | +| `optimisticConcurrency`, `versionKey` | `Revision` | `test_plugins_hooks.py::test_revision_increments_everywhere` | memory | +| `populate()` with `match`, `select`, `options`, `perDocumentLimit`, nested paths | `include()` with `match`, `select`, `sort`, `limit`, `per_parent_limit`, dotted paths | `test_relations.py` | memory | +| Virtual populate (`localField`/`foreignField`, `count`) | `Backref(...)`, `Backref(..., count=True)` | `test_relations.py::test_backrefs_and_counts` | memory | +| `refPath` | `Ref[A \| B]` | `test_relations.py::test_polymorphic_reference` | memory | +| Discriminators | `SpecConfig(discriminator=...)` | `test_models_misc.py::test_discriminators_share_a_collection` | docs | +| Query casting | values cast to the field type | `test_expr.py::test_values_are_cast_to_field_types` | memory | +| `strictQuery: 'throw'` | always: unknown fields raise | `test_expr.py::test_field_names_are_translated_and_checked` | docs | +| `sanitizeFilter`, `trusted()` | raw filters untrusted unless `trusted()` | `test_expr.py::test_untrusted_mappings_and_operator_injection` | memory | +| `select: false` | `Deferred` | `test_crud.py::test_deferred_field_is_not_loaded_or_clobbered` | docs | +| `immutable` | `Immutable` | `test_updates.py::test_immutable_and_required_fields_are_protected` | memory | +| `alias` | `Alias("x")` | `test_codec.py::test_alias_and_transient_fields` | memory | +| Query helpers | `@Model.scope` | `test_plugins_hooks.py::test_named_scopes` | memory | +| `orFail()` | `.or_fail()`, `.one()` | `test_crud.py::test_find_all_first_one_and_or_fail` | memory | +| `cursor()`, `eachAsync({parallel})` | `async for`, `.each(fn, concurrency=n)` | `test_crud.py::test_each_with_concurrency` | memory | +| `bulkWrite`, `bulkSave`, `insertMany({ordered: false})` | `bulk_write`, `bulk_save`, `insert_many(ordered=False)` | `test_bulk_pagination.py` | memory | +| `Connection.transaction(fn)` resets document state on abort | `registry.transaction(fn)` does the same, also before each retry | `test_tracking_tx.py::test_transaction_abort_rolls_back_database_and_memory` | docs | +| `syncIndexes`, `diffIndexes`, `autoIndex: false` | `plan_indexes`, `apply_indexes(drop=...)`; never automatic | `test_indexes.py` | memory | +| `shardKey` | `SpecConfig(shard_key=...)` | `test_streams_sharded.py::test_shard_key_is_added_to_document_writes` | memory | +| `createConnection`, `useDb` | several registries; `Model.using(db=...)` | `test_models_misc.py::test_using_another_database` | memory | +| `maxTimeMS`, `hint`, `comment`, `collation`, read/write concern | `.options(...)`, `SpecConfig`, `Registry(max_time_ms=)` | `test_registry_errors.py::test_default_max_time_ms_from_registry_and_model` | memory | +| `Model.aggregate()`, `Model.collection` | `Model.aggregate(pipeline, as_=)`, `Model.collection()` | `test_crud.py::test_aggregate_decodes_and_explain` | memory | +| `mongoose.set('debug')` | `Registry(on_command=...)` with logging, OpenTelemetry, Sentry listeners | `test_registry_errors.py::test_command_events_carry_model_and_operation` | memory | +| `Model.watch()` | `Model.watch()` | `test_streams_sharded.py::test_change_stream_events` | memory | +| `exists`, `countDocuments`, `estimatedDocumentCount` | `exists`, `count`, `estimated_count` | `test_crud.py::test_count_exists_ids_estimated` | memory | +| Capped, time-series, TTL options | `SpecConfig(collection_options=)`, `Index(ttl=)` | `test_indexes.py::test_collection_options_are_applied` | memory | +| Subdocuments, arrays of subdocuments, Maps | nested models, `list[T]`, `dict[str, T]`; tracked through the parent | `test_crud.py::test_nested_document_diff_uses_dotted_paths` | memory | +| Getters, setters, custom validators | your backend's converters and validators | `test_codec.py::test_validate_field_runs_the_models_own_validators` | memory | +| Virtuals | Python properties; `@computed` to include one in API output | `packages/mongospecs-web/tests/test_mongo_resource.py` | memory | +| Statics and methods | classmethods and methods | (plain Python) | memory | + +## Deliberate differences + +| Mongoose | mongospecs | Why | +|---|---|---| +| Global default connection and model registry | Explicit `Registry`; names resolved per registry | No hidden state, isolated tests | +| `bufferCommands` queues operations until connected | Operations fail immediately when the registry is not open | A dead connection should not look like a slow one | +| `strictQuery` can strip unknown filter paths, so a typo matches everything | Unknown fields raise `QueryError` | Fail closed | +| `strict` silently strips unknown update paths | Unknown fields raise | Same | +| Update validators are off unless `runValidators` | Atomic updates are always validated | Unvalidated writes should be the explicit case | +| Filter casting follows schema types | Same; storage validators are not applied to filters | A search for a value that would be invalid to store is legitimate | +| `findOneAndUpdate` returns the document as it was unless `new: true` | `Model.update` returns the updated document; `returning="before"` for the old one | Least surprise | +| `Model.aggregate()` results are uncast and plugins must hook it | Scopes apply to `aggregate()`; `as_=` decodes | Soft delete and tenancy should not have a hole | +| Indexes are built on startup by default | Never automatic | Unsafe on large collections | +| Collection names are pluralised | The class name, or what you declare | No guessing | +| `transactionAsyncLocalStorage` offers an ambient session | Sessions are always passed explicitly | Tasks spawned inside a transaction would silently share one session | +| Populated paths replace the id with the document | A `Ref` keeps `.id` and gains `.doc` | One type whether or not it is loaded | +| Arrays are tracked per element | A changed list is sent whole | Per-element intent is expressed with atomic operators | +| A document's delete is not version-checked | Soft delete and `save()` are revision-checked; hard delete is not | Recorded as a known gap | diff --git a/docs/reference/bulk.md b/docs/reference/bulk.md new file mode 100644 index 0000000..e54108c --- /dev/null +++ b/docs/reference/bulk.md @@ -0,0 +1,3 @@ +# mongospecs.bulk + +::: mongospecs.bulk diff --git a/docs/reference/errors.md b/docs/reference/errors.md new file mode 100644 index 0000000..4c9c04d --- /dev/null +++ b/docs/reference/errors.md @@ -0,0 +1,3 @@ +# mongospecs.errors + +::: mongospecs.errors diff --git a/docs/reference/expr.md b/docs/reference/expr.md new file mode 100644 index 0000000..62e8f7e --- /dev/null +++ b/docs/reference/expr.md @@ -0,0 +1,3 @@ +# mongospecs.expr + +::: mongospecs.expr diff --git a/docs/reference/hooks.md b/docs/reference/hooks.md new file mode 100644 index 0000000..19139a3 --- /dev/null +++ b/docs/reference/hooks.md @@ -0,0 +1,3 @@ +# mongospecs.hooks + +::: mongospecs.hooks diff --git a/docs/reference/indexes.md b/docs/reference/indexes.md new file mode 100644 index 0000000..c6f622a --- /dev/null +++ b/docs/reference/indexes.md @@ -0,0 +1,3 @@ +# mongospecs.indexes + +::: mongospecs.indexes diff --git a/docs/reference/manager.md b/docs/reference/manager.md new file mode 100644 index 0000000..a810f89 --- /dev/null +++ b/docs/reference/manager.md @@ -0,0 +1,3 @@ +# mongospecs.manager + +::: mongospecs.manager diff --git a/docs/reference/markers.md b/docs/reference/markers.md new file mode 100644 index 0000000..052b33c --- /dev/null +++ b/docs/reference/markers.md @@ -0,0 +1,3 @@ +# mongospecs.markers + +::: mongospecs.markers diff --git a/docs/reference/model.md b/docs/reference/model.md new file mode 100644 index 0000000..b27b3db --- /dev/null +++ b/docs/reference/model.md @@ -0,0 +1,3 @@ +# mongospecs.model + +::: mongospecs.model diff --git a/docs/reference/query.md b/docs/reference/query.md new file mode 100644 index 0000000..705ce75 --- /dev/null +++ b/docs/reference/query.md @@ -0,0 +1,3 @@ +# mongospecs.query + +::: mongospecs.query diff --git a/docs/reference/refs.md b/docs/reference/refs.md new file mode 100644 index 0000000..4cddce7 --- /dev/null +++ b/docs/reference/refs.md @@ -0,0 +1,3 @@ +# mongospecs.refs + +::: mongospecs.refs diff --git a/docs/reference/registry.md b/docs/reference/registry.md new file mode 100644 index 0000000..0a4ef3b --- /dev/null +++ b/docs/reference/registry.md @@ -0,0 +1,3 @@ +# mongospecs.registry + +::: mongospecs.registry diff --git a/docs/reference/relations.md b/docs/reference/relations.md new file mode 100644 index 0000000..0197c5f --- /dev/null +++ b/docs/reference/relations.md @@ -0,0 +1,3 @@ +# mongospecs.relations + +::: mongospecs.relations diff --git a/docs/reference/streams.md b/docs/reference/streams.md new file mode 100644 index 0000000..5efd0d1 --- /dev/null +++ b/docs/reference/streams.md @@ -0,0 +1,3 @@ +# mongospecs.streams + +::: mongospecs.streams diff --git a/docs/reference/updates.md b/docs/reference/updates.md new file mode 100644 index 0000000..67328f7 --- /dev/null +++ b/docs/reference/updates.md @@ -0,0 +1,3 @@ +# mongospecs.updates + +::: mongospecs.updates diff --git a/docs/reference/web_api.md b/docs/reference/web_api.md new file mode 100644 index 0000000..fe84991 --- /dev/null +++ b/docs/reference/web_api.md @@ -0,0 +1,3 @@ +# mongospecs_web.api + +::: mongospecs_web.api diff --git a/docs/reference/web_backend.md b/docs/reference/web_backend.md new file mode 100644 index 0000000..073ed9e --- /dev/null +++ b/docs/reference/web_backend.md @@ -0,0 +1,3 @@ +# mongospecs_web.backend + +::: mongospecs_web.backend diff --git a/docs/reference/web_errors.md b/docs/reference/web_errors.md new file mode 100644 index 0000000..d43683b --- /dev/null +++ b/docs/reference/web_errors.md @@ -0,0 +1,3 @@ +# mongospecs_web.errors + +::: mongospecs_web.errors diff --git a/docs/reference/web_filters.md b/docs/reference/web_filters.md new file mode 100644 index 0000000..a381d9e --- /dev/null +++ b/docs/reference/web_filters.md @@ -0,0 +1,3 @@ +# mongospecs_web.filters + +::: mongospecs_web.filters diff --git a/docs/reference/web_openapi.md b/docs/reference/web_openapi.md new file mode 100644 index 0000000..4ef594c --- /dev/null +++ b/docs/reference/web_openapi.md @@ -0,0 +1,3 @@ +# mongospecs_web.openapi + +::: mongospecs_web.openapi diff --git a/docs/reference/web_policy.md b/docs/reference/web_policy.md new file mode 100644 index 0000000..56aef52 --- /dev/null +++ b/docs/reference/web_policy.md @@ -0,0 +1,3 @@ +# mongospecs_web.policy + +::: mongospecs_web.policy diff --git a/docs/reference/web_resource.md b/docs/reference/web_resource.md new file mode 100644 index 0000000..8a70204 --- /dev/null +++ b/docs/reference/web_resource.md @@ -0,0 +1,3 @@ +# mongospecs_web.resource + +::: mongospecs_web.resource diff --git a/docs/reference/web_testing.md b/docs/reference/web_testing.md new file mode 100644 index 0000000..03cc34a --- /dev/null +++ b/docs/reference/web_testing.md @@ -0,0 +1,3 @@ +# mongospecs_web.testing + +::: mongospecs_web.testing diff --git a/docs/web/plugs.md b/docs/web/plugs.md new file mode 100644 index 0000000..6001f92 --- /dev/null +++ b/docs/web/plugs.md @@ -0,0 +1,76 @@ +# Plugs + +A plug mounts an `Api` on a framework. It only translates requests and responses; routing targets, +validation, authorization and serialisation are the same code everywhere, and a contract suite +checks that every plug behaves identically. + +```py +api = Api(articles, users, prefix="/api", authenticate=current_user) +``` + +## Pure ASGI + +```py +app = api.asgi(registry=registry) # opens and closes the registry on lifespan events +``` + +## Starlette + +```py +from starlette.applications import Starlette +from mongospecs_web.plugs import starlette as plug + +app = Starlette(routes=plug.routes(api), lifespan=plug.lifespan(registry)) +``` + +## FastAPI + +```py +from fastapi import FastAPI +from mongospecs_web.plugs import fastapi as plug + +app = FastAPI(lifespan=plug.lifespan(registry)) +plug.mount(app, api) +``` + +The operations and their schemas are merged into the application's own `/openapi.json` and +`/docs`, for msgspec and attrs models as well as pydantic ones. + +## Litestar + +```py +from litestar import Litestar +from mongospecs_web.plugs.litestar import MongospecsPlugin + +app = Litestar(plugins=[MongospecsPlugin(api, registry=registry)]) +``` + +Handlers are left out of Litestar's own schema generation; the API's OpenAPI document is served at +`/openapi.json`. + +## Quart + +```py +from quart import Quart +from mongospecs_web.plugs import quart as plug + +app = Quart(__name__) +plug.init_app(app, api, registry=registry) +``` + +## Writing another plug + +Convert the framework's request to `mongospecs_web.Request`, call `await api.run(route, request)` +for the matching entry of `api.routes`, and convert the `Response` back. Then prove it: + +```py +from mongospecs_web.testing import contract_api, reference_transcript, run_contract + +api = contract_api("msgspec") +app = my_framework_app(api) +async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client: + assert await run_contract(client, api) == await reference_transcript("msgspec") +``` + +Routes the framework rejects by itself (unknown path, unsupported method) keep that framework's +native 404/405 body; the contract compares only their status. diff --git a/docs/web/resources.md b/docs/web/resources.md new file mode 100644 index 0000000..3ab162d --- /dev/null +++ b/docs/web/resources.md @@ -0,0 +1,131 @@ +# Resources + +`mongospecs-web` exposes a model as REST operations. The description is pure data; a plug mounts it +on a framework. + +```python +from typing import Annotated + +from mongospecs import Policy, Q, Registry, Revision, SpecConfig +from mongospecs.msgspec import Spec +from mongospecs_web import Api, Create, Delete, Get, List, Patch, Resource, Unauthorized + + +class Task(Spec): + __spec__ = SpecConfig(collection="tasks") + title: Annotated[str, Policy(filter=("eq", "prefix"), sort=True)] + done: Annotated[bool, Policy(filter=("eq",))] = False + owner: Annotated[str, Policy(insert=False, update=False, filter=("eq",))] = "" + secret_note: Annotated[str | None, Policy(private=True)] = None + rev: Annotated[int, Revision] = 0 + + +def authenticate(request): + return request.headers.get("x-user") # your real authentication goes here + + +def logged_in(ctx): + if ctx.principal is None: + raise Unauthorized("Log in first.") + return True + + +tasks = Resource( + Task, + ops=[List, Get, Create, Patch, Delete], + guards=[logged_in], # who may call the operations + scope=lambda ctx: Q.owner == ctx.principal, # which rows they may touch + server_fields=lambda ctx: {"owner": ctx.principal}, # set by the server on create +) +api = Api(tasks, prefix="/api", authenticate=authenticate) + +registry = await Registry("mongodb://localhost/app", models=[Task]).open() +``` + +`Policy` on each field decides what a client may do with it: + +| Policy | Meaning | +|---|---| +| `insert=False` / `update=False` | not settable on create / on patch | +| `private=True` | never returned, filtered or sorted; not writable unless `insert`/`update` say so | +| `filter=("eq", "gte", ...)` | operators allowed in the query string (`True` for all) | +| `sort=True` | usable in `?sort=` | + +## Calling it + +`api.handle(...)` runs a request without any server, which is also how you test resources. + +```python +ann = {"x-user": "ann"} +created = await api.handle("POST", "/api/tasks", body=b'{"title": "Write docs"}', headers=ann) +assert created.status == 201 and created.headers["etag"] == '"0"' + +import json + +task = json.loads(created.body)["data"] +assert task["owner"] == "ann" and "secret_note" not in task + +listed = await api.handle("GET", "/api/tasks", query=[("title[prefix]", "Write"), ("sort", "-title")], headers=ann) +assert [t["title"] for t in json.loads(listed.body)["data"]] == ["Write docs"] + +# someone else cannot see or change it: the scope is part of the query +assert (await api.handle("GET", f"/api/tasks/{task['id']}", headers={"x-user": "bob"})).status == 404 + +# conditional update with the ETag +patched = await api.handle( + "PATCH", f"/api/tasks/{task['id']}", body=b'{"done": true}', headers={**ann, "if-match": '"0"'} +) +assert patched.status == 200 +stale = await api.handle( + "PATCH", f"/api/tasks/{task['id']}", body=b'{"done": false}', headers={**ann, "if-match": '"0"'} +) +assert stale.status == 412 + +# input the policy does not allow never reaches the database +for body in (b'{"owner": "mallory"}', b'{"title": {"$ne": null}}', b'{"nope": 1}'): + assert (await api.handle("PATCH", f"/api/tasks/{task['id']}", body=body, headers=ann)).status == 422 +assert (await api.handle("GET", "/api/tasks", query=[("done[$ne]", "1")], headers=ann)).status == 400 +``` + +Errors are RFC 9457 `application/problem+json` documents. Unexpected exceptions become a bare 500 +with no detail. + +## Custom operations and hooks + +```python +@tasks.op("complete", method="POST", summary="Mark a task as done") +async def complete(ctx): + task = await ctx.load() # 404 unless it is within the caller's scope + task.done = True + return await task.save() + + +@tasks.after(Create) +async def audit(ctx, task): + ctx.state["audited"] = task.id + + +done = await api.handle("POST", f"/api/tasks/{task['id']}/complete", headers=ann) +assert json.loads(done.body)["data"]["done"] is True + +spec = api.openapi() +assert "/api/tasks/{id}/complete" in spec["paths"] and "TaskPatch" in spec["components"]["schemas"] + +await registry.client.drop_database("app") +await registry.close() +``` + +## Query string + +``` +?title=hello equality +?views[gte]=10&views[lt]=50 operators in brackets +?tag[in]=a,b comma-separated lists +?sort=-views,title +?limit=20&after= cursor pagination (default) +?limit=20&page=3 offset pagination, with page=Offset(...) +?total=true also count all matches +?include=author embed allowlisted relations +``` + +Only fields and operators the policy allows are accepted; everything else is a 400. diff --git a/mkdocs.yaml b/mkdocs.yaml index fb9472d..2f137a3 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -65,21 +65,57 @@ plugins: mkdocstrings: handlers: python: + paths: [packages/mongospecs/src, packages/mongospecs-web/src] options: docstring_section_style: list filters: ["!^_"] - heading_level: 1 - inherited_members: true - merge_init_into_class: false - preload_modules: ["msgspec"] + heading_level: 2 + merge_init_into_class: true separate_signature: true - show_if_no_docstring: true - show_root_heading: true - show_root_full_path: false + show_root_heading: false show_signature_annotations: true + show_source: false signature_crossrefs: true nav: -- Home: - - Overview: index.md +- Home: index.md +- Guide: + - Models: guide/models.md + - Queries: guide/queries.md + - Writes and transactions: guide/writes.md + - Relations: guide/relations.md + - Hooks and plugins: guide/plugins.md + - Running in production: guide/operations.md +- Web: + - Resources: web/resources.md + - Plugs: web/plugs.md +- Benchmarks: benchmarks.md +- Coming from Mongoose: parity.md +- Migrating from 0.1.x: + - Guide: migration.md - v1 test inventory: v1-test-inventory.md +- Reference: + - mongospecs: + - registry: reference/registry.md + - model: reference/model.md + - manager: reference/manager.md + - query: reference/query.md + - expr: reference/expr.md + - updates: reference/updates.md + - bulk: reference/bulk.md + - markers: reference/markers.md + - refs: reference/refs.md + - relations: reference/relations.md + - indexes: reference/indexes.md + - hooks: reference/hooks.md + - streams: reference/streams.md + - errors: reference/errors.md + - mongospecs-web: + - resource: reference/web_resource.md + - api: reference/web_api.md + - backend: reference/web_backend.md + - filters: reference/web_filters.md + - policy: reference/web_policy.md + - errors: reference/web_errors.md + - openapi: reference/web_openapi.md + - testing: reference/web_testing.md diff --git a/packages/mongospecs-web/src/mongospecs_web/api.py b/packages/mongospecs-web/src/mongospecs_web/api.py index 342789e..443ebe6 100644 --- a/packages/mongospecs-web/src/mongospecs_web/api.py +++ b/packages/mongospecs-web/src/mongospecs_web/api.py @@ -3,6 +3,7 @@ from __future__ import annotations import inspect +import logging import re import typing as t from dataclasses import dataclass @@ -115,7 +116,10 @@ async def run(self, route: Route, request: Request) -> Response: principal = self.authenticate(request) request.principal = await principal if inspect.isawaitable(principal) else principal except Exception as exc: - return Response.error(exc) + response = Response.error(exc) + if response.status >= 500: + logging.getLogger("mongospecs_web").error("authentication failed unexpectedly", exc_info=exc) + return response return await route.resource.dispatch(route.operation, request) async def handle( diff --git a/packages/mongospecs-web/src/mongospecs_web/backend.py b/packages/mongospecs-web/src/mongospecs_web/backend.py index 360ac6c..001b6dc 100644 --- a/packages/mongospecs-web/src/mongospecs_web/backend.py +++ b/packages/mongospecs-web/src/mongospecs_web/backend.py @@ -116,7 +116,7 @@ async def list(self, query: ListQuery, *, scope: t.Any) -> Page[T]: async def create(self, data: dict[str, t.Any]) -> T: stored = {self.meta.fields[k].stored: v for k, v in data.items()} - doc = self.meta.decode(stored, trusted=False) + doc = self.meta.decode(stored, stored=False) return t.cast(T, await self.manager.insert(doc)) async def patch(self, id: t.Any, data: dict[str, t.Any], *, scope: t.Any, if_rev: int | None = None) -> T: diff --git a/packages/mongospecs-web/src/mongospecs_web/resource.py b/packages/mongospecs-web/src/mongospecs_web/resource.py index e2b0fe8..050e4f2 100644 --- a/packages/mongospecs-web/src/mongospecs_web/resource.py +++ b/packages/mongospecs-web/src/mongospecs_web/resource.py @@ -10,6 +10,7 @@ import inspect import json +import logging import typing as t from dataclasses import dataclass, field @@ -32,6 +33,8 @@ T = t.TypeVar("T") F = t.TypeVar("F", bound=t.Callable[..., t.Any]) +log = logging.getLogger("mongospecs_web") + # Built-in operation kinds. Used as keys for ``ops=``, ``guards=`` and hooks. List = "list" Get = "get" @@ -191,12 +194,17 @@ def __init__( include: t.Sequence[str] = (), page: Pagination | None = None, tags: t.Sequence[str] | None = None, + server_fields: t.Callable[[Ctx[T]], t.Any] | None = None, ) -> None: self.model = model self.view: View = view_of(model) self.name = name or self.view.meta.collection self.backend: ResourceBackend[T] = backend if backend is not None else MongoBackend(model) self.scope_fn = scope + self.server_fields = server_fields + """``server_fields(ctx) -> dict`` of values the server sets on create (the author of a + review, a tenant id). They are not subject to the field policy, so the fields can stay + closed to clients with ``Policy(insert=False)``.""" self.includes: tuple[str, ...] = tuple(include) for path in self.includes: head = path.partition(".")[0] @@ -320,7 +328,11 @@ async def dispatch(self, op: Operation, request: Request) -> Response: await _maybe(hook(ctx, ctx.state.get("result", result))) return self._respond(op, result, ctx) except Exception as exc: - return Response.error(exc) + response = Response.error(exc) + if response.status >= 500: + # The client gets a bare 500; the cause belongs in the server log. + log.error("%s %s failed", self.name, op.name, exc_info=exc) + return response def _respond(self, op: Operation, result: t.Any, ctx: Ctx[T]) -> Response: if isinstance(result, Response): @@ -471,8 +483,10 @@ async def handler(ctx: Ctx[T]) -> Response: body = ctx.body() if body is None: raise Unprocessable("A JSON request body is required.") - data = self.view.check_input(body, self.view.insertable, "create") - doc = await self.backend.create(dict(data)) + data = dict(self.view.check_input(body, self.view.insertable, "create")) + if self.server_fields is not None: + data.update(await _maybe(self.server_fields(ctx))) + doc = await self.backend.create(data) ctx.state["result"] = doc headers = {"location": str(jsonable(getattr(doc, "id", "")))} etag = self.view.etag(doc) diff --git a/packages/mongospecs-web/src/mongospecs_web/testing.py b/packages/mongospecs-web/src/mongospecs_web/testing.py index 30986e7..b600611 100644 --- a/packages/mongospecs-web/src/mongospecs_web/testing.py +++ b/packages/mongospecs-web/src/mongospecs_web/testing.py @@ -158,7 +158,7 @@ async def list(self, query: ListQuery, *, scope: t.Any) -> Page[T]: async def create(self, data: dict[str, t.Any]) -> T: stored = {self.meta.fields[k].stored: v for k, v in data.items()} - doc = self.meta.decode(stored, trusted=False) + doc = self.meta.decode(stored, stored=False) self._check_unique(doc) doc.id = ObjectId(f"{self._next:024x}") self._next += 1 diff --git a/packages/mongospecs-web/tests/test_docs_examples.py b/packages/mongospecs-web/tests/test_docs_examples.py new file mode 100644 index 0000000..4ab7b29 --- /dev/null +++ b/packages/mongospecs-web/tests/test_docs_examples.py @@ -0,0 +1,51 @@ +"""The guide pages are executable: every ``python`` block on a page runs, in order, against MongoDB. + +Blocks fenced as ``py`` are illustrative and are not run. +""" + +import ast +import pathlib +import re +import uuid + +import pytest + +DOCS = pathlib.Path(__file__).resolve().parents[3] / "docs" +PAGES = sorted(p for p in (*DOCS.glob("guide/*.md"), *DOCS.glob("web/*.md")) if "```python" in p.read_text()) +BLOCK = re.compile(r"```python\n(.*?)```", re.S) +RS = "mongodb://localhost:27117/{db}?replicaSet=rs0" + + +@pytest.mark.parametrize("page", PAGES, ids=lambda p: f"{p.parent.name}/{p.stem}") +async def test_page_examples_run(page): + import os + + db = f"ms_docs_{uuid.uuid4().hex[:10]}" + uri = os.environ.get("MONGOSPECS_TEST_URI", RS.format(db=db)) + if "{db}" not in RS or db not in uri: # a custom URI without a database: add ours + base, _, query = uri.partition("?") + uri = f"{base.rstrip('/')}/{db}" + (f"?{query}" if query else "") + source = "\n".join(BLOCK.findall(page.read_text())) + assert source.strip(), f"{page} has no runnable examples" + source = ( + source.replace("mongodb://localhost/app", uri) + .replace('"app_archive"', f'"{db}_archive"') + .replace('drop_database("app")', f'drop_database("{db}")') + ) + code = compile(source, str(page), "exec", flags=ast.PyCF_ALLOW_TOP_LEVEL_AWAIT) + namespace = {"__name__": f"docs_{page.stem}"} + result = eval(code, namespace) + if result is not None: + await result + + +def test_every_guide_page_is_covered(): + assert {p.stem for p in PAGES} >= { + "models", + "queries", + "writes", + "relations", + "plugins", + "operations", + "resources", + } diff --git a/packages/mongospecs/src/mongospecs/attrs.py b/packages/mongospecs/src/mongospecs/attrs.py index 1e7f9d1..fea5ab3 100644 --- a/packages/mongospecs/src/mongospecs/attrs.py +++ b/packages/mongospecs/src/mongospecs/attrs.py @@ -36,9 +36,9 @@ def raw_fields(self, cls: type) -> list[RawField]: for a in attrs.fields(cls) ] - def _decode(self, cls: type, doc: dict[str, t.Any], trusted: bool) -> t.Any: + def _decode(self, cls: type, doc: dict[str, t.Any], lax: bool) -> t.Any: try: - return msgspec.convert(doc, cls, dec_hook=dec_hook) + return msgspec.convert(doc, cls, dec_hook=dec_hook, strict=not lax) except (msgspec.ValidationError, ValueError) as exc: raise _error(cls, exc) from exc diff --git a/packages/mongospecs/src/mongospecs/bulk.py b/packages/mongospecs/src/mongospecs/bulk.py index a0a04f7..d27635e 100644 --- a/packages/mongospecs/src/mongospecs/bulk.py +++ b/packages/mongospecs/src/mongospecs/bulk.py @@ -63,7 +63,7 @@ def compile_bulk(manager: Manager[t.Any], ctx: OpCtx, operations: t.Iterable[t.A requests: list[t.Any] = [] for op in operations: if isinstance(op, InsertOne): - doc = manager.meta.decode(dict(op.doc)) if isinstance(op.doc, t.Mapping) else op.doc + doc = manager.meta.decode(dict(op.doc), stored=False) if isinstance(op.doc, t.Mapping) else op.doc encoded = manager.meta.encode(doc) if encoded.get("_id") is None: encoded.pop("_id", None) diff --git a/packages/mongospecs/src/mongospecs/codec.py b/packages/mongospecs/src/mongospecs/codec.py index d513c12..9705992 100644 --- a/packages/mongospecs/src/mongospecs/codec.py +++ b/packages/mongospecs/src/mongospecs/codec.py @@ -242,7 +242,7 @@ def __init__(self) -> None: def raw_fields(self, cls: type) -> list[RawField]: ... @abc.abstractmethod - def _decode(self, cls: type, doc: dict[str, t.Any], trusted: bool) -> t.Any: ... + def _decode(self, cls: type, doc: dict[str, t.Any], lax: bool) -> t.Any: ... @abc.abstractmethod def _encode(self, obj: t.Any) -> dict[str, t.Any]: @@ -293,8 +293,13 @@ def _renames(self, cls: type) -> tuple[dict[str, str], dict[str, str]]: cache[cls] = hit return hit - def decode(self, cls: type, doc: t.Any, *, trusted: bool = False) -> t.Any: - """Build a model from a MongoDB document (a mapping or raw BSON bytes).""" + def decode(self, cls: type, doc: t.Any, *, lax: bool = False) -> t.Any: + """Build a model from a MongoDB document (a mapping or raw BSON bytes). + + ``lax`` is for documents read back from the database: it tolerates the + representations other writers produce (an integer stored as a double, + for instance) where the backend would otherwise be strict. + """ if isinstance(doc, bytes | bytearray | memoryview): doc = bson.decode(bytes(doc)) elif isinstance(doc, RawBSONDocument): @@ -302,7 +307,7 @@ def decode(self, cls: type, doc: t.Any, *, trusted: bool = False) -> t.Any: to_native, _ = self._renames(cls) if to_native: doc = {to_native.get(k, k): v for k, v in doc.items()} - return self._decode(cls, doc, trusted) + return self._decode(cls, doc, lax) def encode(self, obj: t.Any) -> dict[str, t.Any]: """Encode a model to a BSON-ready dict keyed by stored names.""" diff --git a/packages/mongospecs/src/mongospecs/manager.py b/packages/mongospecs/src/mongospecs/manager.py index 9087dc9..a7db1fc 100644 --- a/packages/mongospecs/src/mongospecs/manager.py +++ b/packages/mongospecs/src/mongospecs/manager.py @@ -40,6 +40,8 @@ T = t.TypeVar("T") _MANAGED_MARKERS = (CreatedAt, UpdatedAt, DeletedAt, Revision) +# find() option name -> aggregate() option name +_AGG_NAMES = {"max_time_ms": "maxTimeMS", "batch_size": "batchSize", "allow_disk_use": "allowDiskUse"} class BulkResult(t.Generic[T]): @@ -84,6 +86,7 @@ def __init__( *(f.name for f in self.meta.fields.values() if any(f.has(m) for m in _MANAGED_MARKERS)), ) self._collection: AsyncCollection[dict[str, t.Any]] | None = None + self._raw_collection: t.Any = None self._cascades: list[t.Any] | None = None # -- plumbing -------------------------------------------------------------- @@ -105,6 +108,16 @@ def collection(self) -> AsyncCollection[dict[str, t.Any]]: ) return self._collection + @property + def raw_collection(self) -> t.Any: + """The collection, configured to hand back undecoded BSON (used for tracked reads).""" + if self._raw_collection is None or not self.registry.is_open: + from bson.raw_bson import RawBSONDocument + + options = self.registry._codec_options.with_options(document_class=RawBSONDocument) + self._raw_collection = self.collection.with_options(codec_options=options) + return self._raw_collection + def using( self, *, @@ -151,6 +164,11 @@ def _read_kw(self, session: t.Any, options: t.Mapping[str, t.Any] | None = None) kw["collation"] = self.meta.config.collation return kw + def _agg_kw(self, session: t.Any, options: t.Mapping[str, t.Any] | None = None) -> dict[str, t.Any]: + """Read options spelled the way ``aggregate`` and ``count_documents`` expect them.""" + kw = self._read_kw(session, options) + return {_AGG_NAMES.get(k, k): v for k, v in kw.items() if k != "no_cursor_timeout"} + def scoped( self, ctx: OpCtx, @@ -517,7 +535,9 @@ async def insert_many( With ``ordered=False`` the server attempts every document; failures are reported in ``BulkResult.errors`` instead of raising. """ - items: list[t.Any] = [self.meta.decode(dict(d)) if isinstance(d, t.Mapping) else d for d in docs] + items: list[t.Any] = [ + self.meta.decode(dict(d), stored=False) if isinstance(d, t.Mapping) else d for d in docs + ] if not items: return BulkResult([], []) ctx = self.ctx("insert", session, many=True) @@ -716,16 +736,16 @@ async def __aiter__(self) -> t.AsyncIterator[T]: decode = self._decoder() with manager._op("aggregate"): cursor = await manager.collection.aggregate( - self.compiled(), **manager._read_kw(self._session, self._options) + self.compiled(), **manager._agg_kw(self._session, self._options) ) async with cursor: while True: with manager._op("aggregate"): - try: - raw = await cursor.__anext__() - except StopAsyncIteration: - return - yield decode(raw) + batch = await cursor.to_list(1000) + for raw in batch: + yield decode(raw) + if len(batch) < 1000: + return async def all(self) -> list[T]: return [item async for item in self] diff --git a/packages/mongospecs/src/mongospecs/markers.py b/packages/mongospecs/src/mongospecs/markers.py index e92a468..e6c7d86 100644 --- a/packages/mongospecs/src/mongospecs/markers.py +++ b/packages/mongospecs/src/mongospecs/markers.py @@ -234,9 +234,6 @@ class SpecConfig: upcast: t.Callable[[dict[str, t.Any]], dict[str, t.Any]] | None = None """Called with each raw document before decoding, to migrate old shapes lazily.""" - trusted: bool = False - """Skip read validation where the backend allows it.""" - max_time_ms: int | None = None read_preference: t.Any = None read_concern: t.Any = None diff --git a/packages/mongospecs/src/mongospecs/meta.py b/packages/mongospecs/src/mongospecs/meta.py index a7d501d..5ade142 100644 --- a/packages/mongospecs/src/mongospecs/meta.py +++ b/packages/mongospecs/src/mongospecs/meta.py @@ -234,13 +234,15 @@ def validate_type(self, tp: t.Any, value: t.Any, *, lax: bool = False) -> t.Any: # -- encode / decode ------------------------------------------------------- - def decode(self, doc: t.Any, *, trusted: bool | None = None) -> t.Any: + def decode(self, doc: t.Any, *, stored: bool = True) -> t.Any: + """Build a model. ``stored=True`` (the default) is for documents read from the database; + pass ``stored=False`` for input, which is held to the backend's strict rules.""" if self.config.upcast is not None: doc = self.config.upcast(dict(doc)) cls = self.model if self.disc_key: cls = self.subclass_for(doc.get(self.disc_key)) - return self.codec.decode(cls, doc, trusted=self.config.trusted if trusted is None else trusted) + return self.codec.decode(cls, doc, lax=stored) def encode(self, obj: t.Any) -> dict[str, t.Any]: doc = self.codec.encode(obj) diff --git a/packages/mongospecs/src/mongospecs/model.py b/packages/mongospecs/src/mongospecs/model.py index b3aa49e..65ee4f7 100644 --- a/packages/mongospecs/src/mongospecs/model.py +++ b/packages/mongospecs/src/mongospecs/model.py @@ -247,22 +247,25 @@ def _normalize_refs(self) -> None: refs = _REF_FIELDS[cls] = _ref_fields(cls) if not refs: return - from mongospecs.refs import Ref + from mongospecs.refs import Ref, coerce_ref + # coerce_ref also turns an id given as a string into the target's id type. for name, shape, model in refs: value = getattr(self, name) if value is None: continue if shape == "one": if type(value) is not Ref: - setattr(self, name, Ref(value, model)) + setattr(self, name, coerce_ref(value, model)) elif value._model is None: value._model = model elif shape == "list": if any(type(v) is not Ref for v in value): - setattr(self, name, [v if type(v) is Ref else Ref(v, model) for v in value]) + setattr(self, name, [v if type(v) is Ref else coerce_ref(v, model) for v in value]) elif any(type(v) is not Ref for v in value.values()): - setattr(self, name, {k: v if type(v) is Ref else Ref(v, model) for k, v in value.items()}) + setattr( + self, name, {k: v if type(v) is Ref else coerce_ref(v, model) for k, v in value.items()} + ) _codec_module._DOCUMENT = Document diff --git a/packages/mongospecs/src/mongospecs/msgspec.py b/packages/mongospecs/src/mongospecs/msgspec.py index 7550b36..e37b6b8 100644 --- a/packages/mongospecs/src/mongospecs/msgspec.py +++ b/packages/mongospecs/src/mongospecs/msgspec.py @@ -62,11 +62,9 @@ class MsgspecCodec(Codec): def raw_fields(self, cls: type) -> list[RawField]: return [(f.name, f.encode_name, f.type, f.required) for f in msgspec.structs.fields(cls)] - def _decode(self, cls: type, doc: dict[str, t.Any], trusted: bool) -> t.Any: - # msgspec has no unvalidated construction path; conversion is cheap, so - # ``trusted`` changes nothing here. + def _decode(self, cls: type, doc: dict[str, t.Any], lax: bool) -> t.Any: try: - return msgspec.convert(doc, cls, dec_hook=dec_hook) + return msgspec.convert(doc, cls, dec_hook=dec_hook, strict=not lax) except (msgspec.ValidationError, ValueError) as exc: raise _error(cls, exc) from exc diff --git a/packages/mongospecs/src/mongospecs/plugins/revision.py b/packages/mongospecs/src/mongospecs/plugins/revision.py index 07eebbc..ad35c61 100644 --- a/packages/mongospecs/src/mongospecs/plugins/revision.py +++ b/packages/mongospecs/src/mongospecs/plugins/revision.py @@ -48,7 +48,9 @@ def on_update(self, ctx: OpCtx, flt: dict[str, t.Any], update: dict[str, t.Any]) update.setdefault("$inc", {})[stored] = 1 snapshot = ctx.extra.get("snapshot") if snapshot is not None: - flt[stored] = snapshot.get(stored) + loaded = snapshot.get(stored) + # A document written before the model had a revision field has none stored. + flt[stored] = {"$in": [0, None]} if not loaded else loaded elif ctx.extra.get("if_rev") is not None: flt[stored] = ctx.extra["if_rev"] diff --git a/packages/mongospecs/src/mongospecs/pydantic.py b/packages/mongospecs/src/mongospecs/pydantic.py index 7aaeafc..b2a940d 100644 --- a/packages/mongospecs/src/mongospecs/pydantic.py +++ b/packages/mongospecs/src/mongospecs/pydantic.py @@ -18,7 +18,6 @@ __all__ = ["CODEC", "PydanticCodec", "Spec", "SpecAdapter"] _HAS_EXCLUDE_COMPUTED = "exclude_computed_fields" in inspect.signature(BaseModel.model_dump).parameters -_SCALARS: tuple[type, ...] = (str, int, float, bool, bytes) def _error(cls: type, exc: pydantic.ValidationError, name: str | None = None) -> ValidationError: @@ -44,7 +43,6 @@ class PydanticCodec(Codec): def __init__(self) -> None: super().__init__() - self._flat: dict[type, bool] = {} self._adapters: dict[tuple[type, str], TypeAdapter[t.Any]] = {} def raw_fields(self, cls: type) -> list[RawField]: @@ -54,25 +52,9 @@ def raw_fields(self, cls: type) -> list[RawField]: for name, info in model.model_fields.items() ] - def _is_flat(self, cls: type) -> bool: - """True when every field is a plain scalar, so unvalidated construction is safe.""" - flat = self._flat.get(cls) - if flat is None: - from bson import ObjectId - - from mongospecs.codec import strip_optional - - flat = all(strip_optional(f.type) in (*_SCALARS, ObjectId) for f in self.fields(cls).values()) - self._flat[cls] = flat - return flat - - def _decode(self, cls: type, doc: dict[str, t.Any], trusted: bool) -> t.Any: - model = t.cast("type[BaseModel]", cls) - if trusted and self._is_flat(cls): - by_alias = {f.stored: f.name for f in self.fields(cls).values()} - return model.model_construct(**{by_alias.get(k, k): v for k, v in doc.items() if k in by_alias}) + def _decode(self, cls: type, doc: dict[str, t.Any], lax: bool) -> t.Any: try: - return model.model_validate(doc) + return t.cast("type[BaseModel]", cls).model_validate(doc) except pydantic.ValidationError as exc: raise _error(cls, exc) from exc @@ -109,7 +91,6 @@ def _json_schema(self, cls: type) -> dict[str, t.Any]: def clear_cache(self) -> None: super().clear_cache() - self._flat.clear() self._adapters.clear() diff --git a/packages/mongospecs/src/mongospecs/query.py b/packages/mongospecs/src/mongospecs/query.py index 72a4744..01ae684 100644 --- a/packages/mongospecs/src/mongospecs/query.py +++ b/packages/mongospecs/src/mongospecs/query.py @@ -30,8 +30,6 @@ Mode = t.Literal["tracked", "lean", "raw"] -# find() option name -> aggregate() option name -_AGG_OPTIONS = {"max_time_ms": "maxTimeMS", "batch_size": "batchSize", "allow_disk_use": "allowDiskUse"} _FIND_OPTIONS = frozenset( {"max_time_ms", "batch_size", "allow_disk_use", "hint", "comment", "collation", "no_cursor_timeout"} ) @@ -309,13 +307,15 @@ def compile(self) -> dict[str, t.Any]: # -- execution ------------------------------------------------------------- - async def _cursor(self, plan: dict[str, t.Any]) -> t.Any: + async def _cursor(self, plan: dict[str, t.Any], *, raw_bson: bool = False) -> t.Any: manager = self._manager kw = manager._read_kw(self._session, self._options) + collection = manager.raw_collection if raw_bson else manager.collection if "pipeline" in plan: - agg_kw = {_AGG_OPTIONS.get(k, k): v for k, v in kw.items() if k != "no_cursor_timeout"} - return await manager.collection.aggregate(plan["pipeline"], **agg_kw) - return manager.collection.find( + return await collection.aggregate( + plan["pipeline"], **manager._agg_kw(self._session, self._options) + ) + return collection.find( plan["filter"], plan["projection"], sort=plan["sort"] or None, @@ -334,40 +334,49 @@ async def _chunks(self, chunk: int | None = None) -> t.AsyncIterator[list[t.Any] lookups, batched, partial = plan["lookups"], plan["batched"], plan["partial"] size = chunk or self._options.get("batch_size") or _CHUNK mode = self._mode - - async def flush(raws: list[dict[str, t.Any]]) -> list[t.Any]: - joined = [pop_lookups(raw, lookups) for raw in raws] if lookups else None - if mode == "raw": - items: list[t.Any] = raws - else: - items = [] - for raw in raws: - doc = meta.decode(raw) - if mode == "tracked": - tracking.track(doc, meta_of(type(doc)).encode(doc), manager, partial) + # Tracked reads ask the driver for undecoded BSON: the bytes are the snapshot, so nothing + # is re-encoded at load time. With $lookup the stored form is not what came back, so the + # snapshot is rebuilt from the decoded document instead. + raw_bson = mode == "tracked" and not lookups + options = manager.registry._codec_options + + async def flush(raws: list[t.Any]) -> list[t.Any]: + if raw_bson: + items: list[t.Any] = [] + decode = meta.decode + for item in raws: + data = item.raw + doc = decode(bson.decode(data, options)) + tracking.track(doc, data, manager, partial) items.append(doc) - if joined is not None: - attach_lookups(manager, items, joined, lookups, mode) + else: + joined = [pop_lookups(raw, lookups) for raw in raws] if lookups else None + if mode == "raw": + items = raws + else: + items = [] + for raw in raws: + doc = meta.decode(raw) + if mode == "tracked": + tracking.track(doc, meta_of(type(doc)).encode(doc), manager, partial) + items.append(doc) + if joined is not None: + attach_lookups(manager, items, joined, lookups, mode) if batched: await load_batched(manager, items, batched, mode, self._session) return items with manager._op("find"): - cursor = await self._cursor(plan) + cursor = await self._cursor(plan, raw_bson=raw_bson) async with cursor: - buffer: list[dict[str, t.Any]] = [] while True: with manager._op("find"): - try: - raw = await cursor.__anext__() - except StopAsyncIteration: - break - buffer.append(raw) - if len(buffer) >= size: - yield await flush(buffer) - buffer = [] - if buffer: - yield await flush(buffer) + batch = await cursor.to_list(size) + if not batch: + break + yield await flush(batch) + if len(batch) < size: + break async def __aiter__(self) -> t.AsyncIterator[T]: async for chunk in self._chunks(): @@ -376,7 +385,9 @@ async def __aiter__(self) -> t.AsyncIterator[T]: async def all(self) -> list[T]: out: list[T] = [] - async for chunk in self._chunks(): + # Without batched relations there is nothing to do per chunk, so fetch in large steps. + step = None if self._includes else 10_000 + async for chunk in self._chunks(step): out.extend(chunk) if self._fail and not out: raise DocumentNotFound(f"{self._manager.model.__name__}: no document matched") @@ -402,11 +413,10 @@ async def one(self) -> T: async def count(self) -> int: manager = self._manager - kw = manager._read_kw(self._session, self._options) agg_kw = { - _AGG_OPTIONS.get(k, k): v - for k, v in kw.items() - if k in ("session", "max_time_ms", "hint", "collation", "comment") + k: v + for k, v in manager._agg_kw(self._session, self._options).items() + if k in ("session", "maxTimeMS", "hint", "collation", "comment") } if self._skip: agg_kw["skip"] = self._skip @@ -514,7 +524,9 @@ async def page( Without ``number`` this is cursor (keyset) pagination: pass the previous page's ``next_cursor`` as ``after``. The sort is made total by appending - ``_id``; sort fields should not be null. With ``number`` (1-based) it is + ``_id``, so the index serving it should end in ``_id`` (``Index("price", "id")``); + otherwise the server sorts in memory. Sort fields should not be null. + With ``number`` (1-based) it is offset pagination. ``total=True`` adds a count of all matches. """ if size < 1: diff --git a/packages/mongospecs/src/mongospecs/relations.py b/packages/mongospecs/src/mongospecs/relations.py index afaef23..78840dd 100644 --- a/packages/mongospecs/src/mongospecs/relations.py +++ b/packages/mongospecs/src/mongospecs/relations.py @@ -507,7 +507,7 @@ async def all(self) -> list[GraphResult[t.Any]]: mode = query._mode results: list[GraphResult[t.Any]] = [] with manager._op("graph"): - cursor = await manager.collection.aggregate(self.compile(), **manager._kw(query._session)) + cursor = await manager.collection.aggregate(self.compile(), **manager._agg_kw(query._session)) raws = [raw async for raw in cursor] for raw in raws: nodes_raw = sorted(raw.pop("__graph", []), key=lambda n: n.get("__depth", 0)) @@ -602,7 +602,7 @@ async def __aiter__(self) -> t.AsyncIterator[t.Any]: first = self._queries[0] manager = first._manager with manager._op("union"): - cursor = await manager.collection.aggregate(self.compile(), **manager._kw(first._session)) + cursor = await manager.collection.aggregate(self.compile(), **manager._agg_kw(first._session)) async with cursor: async for raw in cursor: query = self._queries[raw.pop("__src")] diff --git a/packages/mongospecs/src/mongospecs/tracking.py b/packages/mongospecs/src/mongospecs/tracking.py index ea5ae27..938f6fb 100644 --- a/packages/mongospecs/src/mongospecs/tracking.py +++ b/packages/mongospecs/src/mongospecs/tracking.py @@ -1,36 +1,66 @@ """Change tracking by snapshot. -When a document is loaded (or written), its encoded form is kept in a side table -keyed by object identity. ``save()`` diffs the current encoding against that -snapshot and sends only the difference. No attribute interception is involved, -so the mechanism is the same for msgspec, pydantic and attrs. - -The table entry is removed by a weakref finaliser when the document is -collected, which is why document classes must be weak-referenceable. +A tracked document remembers how it looked when it was loaded. ``save()`` +compares the current encoding with that snapshot and sends only the difference. +No attribute interception is involved, so the mechanism is the same for +msgspec, pydantic and attrs. + +For documents read by a query, what is kept is the raw BSON the driver received, +as bytes: nothing is re-encoded at load time and the bytes are smaller than the +equivalent Python objects. The snapshot proper (the document decoded into the +model and encoded again, so that defaults and type coercions are already +applied) is only built if the document is actually saved. + +State lives in a side table keyed by object identity and is removed by a weakref +finaliser when the document is collected, which is why document classes must be +weak-referenceable. """ from __future__ import annotations -import copy import typing as t import weakref +import bson + __all__ = ["DocState", "diff", "forget", "state_of", "track"] class DocState: """What mongospecs remembers about a tracked document.""" - __slots__ = ("manager", "partial", "snapshot") + __slots__ = ("_doc", "_raw", "manager", "partial") - def __init__(self, snapshot: dict[str, t.Any], manager: t.Any, partial: frozenset[str] | None) -> None: - self.snapshot = snapshot + def __init__( + self, + snapshot: dict[str, t.Any] | bytes, + manager: t.Any, + partial: frozenset[str] | None, + ) -> None: + self._raw: bytes | None = snapshot if isinstance(snapshot, bytes) else None + self._doc: dict[str, t.Any] | None = None if isinstance(snapshot, bytes) else snapshot self.manager = manager self.partial = partial """Stored keys that were loaded, when the document was read with a projection.""" - def copy(self) -> DocState: - return DocState(copy.deepcopy(self.snapshot), self.manager, self.partial) + @property + def snapshot(self) -> dict[str, t.Any]: + """The document as the model encoded it when it was loaded. + + Built on first use from the raw bytes by decoding into the model and + encoding again. Going through the model means a field the stored + document lacks already has its default here, so loading a document never + looks like editing it. + """ + if self._doc is None: + assert self._raw is not None + from mongospecs.meta import meta_of + + meta = self.manager.meta + loaded = meta.decode(bson.decode(self._raw, self.manager.registry._codec_options)) + self._doc = meta_of(type(loaded)).encode(loaded) + self._raw = None + return self._doc _states: dict[int, DocState] = {} @@ -42,7 +72,7 @@ def state_of(obj: t.Any) -> DocState | None: def track( obj: t.Any, - snapshot: dict[str, t.Any], + snapshot: dict[str, t.Any] | bytes, manager: t.Any, partial: frozenset[str] | None = None, ) -> DocState: diff --git a/packages/mongospecs/tests/test_registry_errors.py b/packages/mongospecs/tests/test_registry_errors.py index 50d0daa..4a5d786 100644 --- a/packages/mongospecs/tests/test_registry_errors.py +++ b/packages/mongospecs/tests/test_registry_errors.py @@ -140,6 +140,12 @@ async def test_default_max_time_ms_from_registry_and_model(m, make_registry): assert [c["maxTimeMS"] for c in finds] == [1234, 99] counts = [e.command for e in events if e.phase == "started" and e.command_name == "aggregate"] assert counts[0]["maxTimeMS"] == 1234 + # every path that runs a pipeline carries the default too (found by the storefront e2e tests) + events.clear() + await m.User.aggregate([{"$match": {}}]) + await m.User.find().union(m.User.find()) + pipelines = [e.command for e in events if e.phase == "started" and e.command_name == "aggregate"] + assert len(pipelines) >= 2 and all(c["maxTimeMS"] == 1234 for c in pipelines) async def test_command_events_carry_model_and_operation(m, make_registry, caplog): diff --git a/packages/mongospecs/tests/test_tracking_tx.py b/packages/mongospecs/tests/test_tracking_tx.py index 4f5d288..1837e85 100644 --- a/packages/mongospecs/tests/test_tracking_tx.py +++ b/packages/mongospecs/tests/test_tracking_tx.py @@ -188,3 +188,40 @@ async def slow(tx): assert await m.User.count() == 0 assert await m.User.find(Q.name == "x").exists() is False await m.User(name="after").insert() # the client is still usable + + +async def test_freshly_loaded_documents_report_no_changes(m, registry): + """The snapshot is the raw stored form; loading must not look like editing, whatever the types.""" + user = await m.User(name="ann", age=3, color=m.Color.BLUE, address=m.Address(city="Oslo")).insert() + article = await m.Article( + title="t", author=user, editors=[user], reviewers={"a": user}, tags=["x"] + ).insert() + # documents written by other code: an int where the model has a float-like default, missing optionals + await m.User.collection().insert_one({"name": "sparse"}) + await m.Video.collection().insert_one({"title": "v", "seconds": 60.0}) + for model in (m.User, m.Article, m.Video): + for doc in await model.find(): + assert doc.changes() == {}, (model.__name__, doc.changes()) + assert not doc.is_new + loaded = await m.Article.get(article.id) + loaded.tags.append("y") + assert loaded.changes() == {"$set": {"tags": ["x", "y"]}} + sparse = await m.User.find(Q.name == "sparse").one() + sparse.age = 7 + await sparse.save() + assert await m.User.collection().find_one({"name": "sparse"}, {"_id": 0}) == {"name": "sparse", "age": 7} + + +async def test_documents_written_before_a_field_existed(m, registry): + await m.User.collection().insert_one({"name": "old shape"}) + await m.Article.collection().insert_one({"title": "no revision yet"}) + doc = await m.User.find(Q.name == "old shape").one() + assert doc.changes() == {} and doc.age == 0 # the default is applied in memory, not written + doc.name = "renamed" + await doc.save() + assert await m.User.collection().find_one({"name": "renamed"}, {"_id": 0}) == {"name": "renamed"} + # a stored document without the revision field can still be saved, and gets one + article = await m.Article.find().one() + article.title = "now revisioned" + await article.save() + assert article.rev == 1 and (await m.Article.collection().find_one({}))["rev"] == 1 diff --git a/pyproject.toml b/pyproject.toml index 52809c9..09120d4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,6 +43,7 @@ asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" markers = [ "sharded: needs the sharded cluster from docker/compose.sharded.yaml", + "chaos: steps the replica set primary down while the system is under load", ] [tool.ruff] diff --git a/uv.lock b/uv.lock index 57e90f6..132b066 100644 --- a/uv.lock +++ b/uv.lock @@ -1102,6 +1102,7 @@ dependencies = [ { name = "httpx" }, { name = "mongospecs", extra = ["all", "sentry"] }, { name = "mongospecs-web", extra = ["all"] }, + { name = "uvicorn" }, ] [package.metadata] @@ -1109,6 +1110,7 @@ requires-dist = [ { name = "httpx", specifier = ">=0.27" }, { name = "mongospecs", extras = ["all", "sentry"], editable = "packages/mongospecs" }, { name = "mongospecs-web", extras = ["all"], editable = "packages/mongospecs-web" }, + { name = "uvicorn", specifier = ">=0.30" }, ] [[package]] @@ -2376,6 +2378,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/92/9d/c4e665119135114480843e7ab388fa94d8480650450e6f8e26b70d323a4c/urllib3-2.8.0-py3-none-any.whl", hash = "sha256:0cf3cae568d36aa9576b28dfb35f11328f1cb974ca7647d9475ebb86c75ac6e3", size = 135717 }, ] +[[package]] +name = "uvicorn" +version = "0.54.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/34/30e9280707135d2cfc589dfff3cb796bd07a3aeb1a3e415ba09dd89d7bb4/uvicorn-0.54.0.tar.gz", hash = "sha256:a2e33cbfaa0306f8e6b0c13e0cb89d7d7a2da3e62b90c66e18c33d9807b28620", size = 112283 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427 }, +] + [[package]] name = "watchdog" version = "6.0.0" From 6dc837fd83362204797ac468559185a601d9ed93 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 00:45:48 -0400 Subject: [PATCH 07/31] perf(web): native JSON serialisation and untracked reads; fix load generator - Codec.to_json: msgspec.to_builtins / pydantic model_dump(mode='json') produce the Read view directly instead of encoding to BSON types and converting those; responses are dumped with msgspec's JSON encoder when available - list and get requests read lean documents: nothing is saved, so nothing is tracked - upserts give the inserted document the model's defaults, so it has the same shape as one created by insert() - load benchmark: the httpx-based generator capped out below the server's capacity, so its throughput numbers measured the client. Replaced with a minimal keep-alive client; the primary metric is now server CPU time per request, with latency taken on an unloaded server and throughput at saturation - CI job for the Sentry -> rustrak tracing check; plan amendments A14-A21 and nodes E4-E6 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yaml | 13 + README.md | 16 +- bench/src/mongospecs_bench/load.py | 276 ++++++++++++------ claudedocs/v2-plan.md | 16 + docs/parity.md | 12 +- packages/mongospecs-web/README.md | 52 +++- .../src/mongospecs_web/backend.py | 14 +- .../src/mongospecs_web/policy.py | 65 ++--- .../src/mongospecs_web/resource.py | 24 +- .../src/mongospecs_web/testing.py | 4 +- packages/mongospecs/README.md | 16 +- packages/mongospecs/src/mongospecs/attrs.py | 17 ++ packages/mongospecs/src/mongospecs/codec.py | 16 + .../mongospecs/src/mongospecs/jsonable.py | 53 ++++ packages/mongospecs/src/mongospecs/manager.py | 19 ++ packages/mongospecs/src/mongospecs/meta.py | 14 + packages/mongospecs/src/mongospecs/msgspec.py | 28 ++ .../mongospecs/src/mongospecs/pydantic.py | 16 + packages/mongospecs/tests/test_updates.py | 25 ++ 19 files changed, 539 insertions(+), 157 deletions(-) create mode 100644 packages/mongospecs/src/mongospecs/jsonable.py diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index b360269..4e32943 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -53,3 +53,16 @@ jobs: run: | docker compose -f docker/compose.rs.yaml logs --tail 100 docker compose -f docker/compose.sharded.yaml logs --tail 100 + + tracing: + # End to end: Sentry SDK -> mongospecs-web -> MongoDB, received by a Sentry-compatible server. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: docker compose -f docker/compose.rs.yaml up -d --wait + - run: docker compose -f docker/compose.rustrak.yaml up -d + - run: uv sync --all-packages --python 3.13 + - name: Wait for rustrak + run: timeout 60 sh -c 'until curl -sf http://localhost:8787/health >/dev/null; do sleep 1; done' + - run: uv run mongospecs-bench trace diff --git a/README.md b/README.md index 85c4f07..aa58713 100644 --- a/README.md +++ b/README.md @@ -87,12 +87,26 @@ async def main(): | Operations | explicit `Registry` lifecycle, transactions, index planner with gated destructive actions, `$jsonSchema` validators, change streams, multi-database use, shard-key aware writes | | Observability | per-command events with model and operation names; logging, OpenTelemetry and Sentry listeners | +## How it is tested + +- Unit and contract suites run against real MongoDB (a replica set and a sharded cluster), on + every backend, on Python 3.11 to 3.15. +- A reference application, the storefront in `bench/`, is booted as a real server process and + driven over HTTP: a full customer journey on every plug, concurrent buyers that must never + oversell, multi-item orders that are all-or-nothing, a change-stream projector that must count + each sale exactly once across restarts, and orders that stay consistent across a primary + step-down. +- Every example in the guide pages is executed. +- Benchmarks and stress scenarios in `bench/` run against real MongoDB; results and the + conclusions drawn from them are in `docs/benchmarks.md`. + ## Development ``` make install # uv sync for the whole workspace make up # MongoDB replica set + sharded cluster in Docker -make check # ruff, mypy --strict, tests against real MongoDB +make check # ruff, mypy --strict, tests against real MongoDB (unit, contract, end-to-end) +uv run mongospecs-bench run # benchmarks; `run LOAD` for the HTTP load test; `stress`; `trace` ``` The repository is a uv workspace: `packages/mongospecs`, `packages/mongospecs-web` and `bench/` diff --git a/bench/src/mongospecs_bench/load.py b/bench/src/mongospecs_bench/load.py index 6e4a9ef..ceabdb8 100644 --- a/bench/src/mongospecs_bench/load.py +++ b/bench/src/mongospecs_bench/load.py @@ -12,14 +12,17 @@ from __future__ import annotations import asyncio +import contextlib +import json +import os +import pathlib import random import statistics +import subprocess import time import typing as t import uuid -import httpx - from mongospecs import Registry from mongospecs_bench.harness import Result, Settings, uri_for from mongospecs_bench.storefront import models as shop_models @@ -36,50 +39,156 @@ def _percentile(values: list[float], q: float) -> float: return ordered[min(len(ordered) - 1, round(q * (len(ordered) - 1)))] +class Connection: + """A minimal HTTP/1.1 keep-alive client connection. + + The load generator must not be the bottleneck. A general-purpose client + spends more CPU per request than the server under test does; this one writes + a request and reads a length-delimited response, nothing else. + """ + + def __init__(self, host: str, port: int) -> None: + self.host, self.port = host, port + self.reader: asyncio.StreamReader | None = None + self.writer: asyncio.StreamWriter | None = None + + async def open(self) -> None: + self.reader, self.writer = await asyncio.open_connection(self.host, self.port) + + async def close(self) -> None: + if self.writer is not None: + self.writer.close() + with contextlib.suppress(Exception): + await self.writer.wait_closed() + + async def request( + self, method: str, path: str, *, token: str | None = None, body: bytes = b"" + ) -> tuple[int, bytes]: + assert self.reader is not None and self.writer is not None + head = [f"{method} {path} HTTP/1.1", f"host: {self.host}:{self.port}"] + if token: + head.append(f"authorization: Bearer {token}") + if body: + head.append("content-type: application/json") + head.append(f"content-length: {len(body)}") + self.writer.write(("\r\n".join(head) + "\r\n\r\n").encode() + body) + await self.writer.drain() + raw = await self.reader.readuntil(b"\r\n\r\n") + lines = raw.split(b"\r\n") + status = int(lines[0].split(b" ", 2)[1]) + length, chunked = 0, False + for line in lines[1:]: + name, _, value = line.partition(b":") + lowered = name.lower() + if lowered == b"content-length": + length = int(value) + elif lowered == b"transfer-encoding" and b"chunked" in value.lower(): + chunked = True + if chunked: + parts = [] + while True: + size = int((await self.reader.readline()).strip() or b"0", 16) + if size == 0: + await self.reader.readline() + break + parts.append(await self.reader.readexactly(size)) + await self.reader.readline() + return status, b"".join(parts) + return status, await self.reader.readexactly(length) if length else b"" + + +def process_cpu_seconds(pid: int) -> float: + """CPU time (user + system) a process has used so far.""" + stat = pathlib.Path(f"/proc/{pid}/stat") + if stat.exists(): + fields = stat.read_text().rsplit(")", 1)[1].split() + return (int(fields[11]) + int(fields[12])) / os.sysconf("SC_CLK_TCK") + out = subprocess.run( + ["ps", "-o", "cputime=", "-p", str(pid)], capture_output=True, text=True + ).stdout.strip() + total = 0.0 + for part in out.replace("-", ":").split(":"): + total = total * 60 + float(part) + return total + + async def _virtual_user( index: int, - client: httpx.AsyncClient, + host: str, + port: int, seeded: Seeded, stop: asyncio.Event, samples: dict[str, list[float]], failures: dict[str, int], rng: random.Random, ) -> None: - token = {"authorization": f"Bearer {seeded.user_tokens[index % len(seeded.user_tokens)]}"} + token = seeded.user_tokens[index % len(seeded.user_tokens)] actions = [name for name, weight in MIX for _ in range(weight)] cursor: str | None = None category = rng.choice(CATEGORIES) - while not stop.is_set(): - action = rng.choice(actions) - started = time.perf_counter() - try: - if action == "list": - url = f"/api/products?category={category}&sort=price&limit=20" - if cursor and rng.random() < 0.5: - url += f"&after={cursor}" + connection = Connection(host, port) + await connection.open() + try: + while not stop.is_set(): + action = rng.choice(actions) + started = time.perf_counter() + try: + if action == "list": + if cursor and rng.random() < 0.5: + path = f"/api/products?category={category}&sort=price&limit=20&after={cursor}" + else: + category = rng.choice(CATEGORIES) + path = f"/api/products?category={category}&sort=price&limit=20" + status, payload = await connection.request("GET", path) + cursor = json.loads(payload)["page"]["next_cursor"] if status == 200 else None + elif action == "detail": + product = rng.choice(seeded.product_ids) + status, payload = await connection.request( + "GET", f"/api/products/{product}?include=reviews,review_count" + ) else: - category = rng.choice(CATEGORIES) - url = f"/api/products?category={category}&sort=price&limit=20" - response = await client.get(url) - cursor = response.json()["page"]["next_cursor"] if response.status_code == 200 else None - elif action == "detail": - response = await client.get( - f"/api/products/{rng.choice(seeded.product_ids)}?include=reviews,review_count" - ) + lines = [ + {"sku": sku, "qty": rng.randint(1, 2)} + for sku in rng.sample(seeded.skus, k=rng.randint(1, 3)) + ] + status, payload = await connection.request( + "POST", "/api/orders", token=token, body=json.dumps({"lines": lines}).encode() + ) + ok = status in (200, 201) + except (OSError, asyncio.IncompleteReadError, ValueError): + ok = False + await connection.close() + await connection.open() + elapsed = time.perf_counter() - started + if ok: + samples[action].append(elapsed) else: - lines = [ - {"sku": sku, "qty": rng.randint(1, 2)} - for sku in rng.sample(seeded.skus, k=rng.randint(1, 3)) - ] - response = await client.post("/api/orders", json={"lines": lines}, headers=token) - ok = response.status_code in (200, 201) - except httpx.HTTPError: - ok = False - elapsed = time.perf_counter() - started - if ok: - samples[action].append(elapsed) - else: - failures[action] += 1 + failures[action] += 1 + finally: + await connection.close() + + +async def _phase( + server: t.Any, seeded: Seeded, users: int, seconds: float +) -> tuple[dict[str, list[float]], dict[str, int], float, float]: + """Run ``users`` virtual users for ``seconds``. Returns samples, failures, elapsed and server CPU seconds.""" + host, port = server.base_url.split("://", 1)[1].split(":") + samples: dict[str, list[float]] = {name: [] for name, _ in MIX} + failures: dict[str, int] = {name: 0 for name, _ in MIX} + stop = asyncio.Event() + cpu_before = process_cpu_seconds(server.process.pid) + started = time.perf_counter() + tasks = [ + asyncio.create_task( + _virtual_user(i, host, int(port), seeded, stop, samples, failures, random.Random(1000 + i)) + ) + for i in range(users) + ] + await asyncio.sleep(seconds) + stop.set() + await asyncio.gather(*tasks) + elapsed = time.perf_counter() - started + return samples, failures, elapsed, process_cpu_seconds(server.process.pid) - cpu_before async def run_load( @@ -88,11 +197,14 @@ async def run_load( app: str = "storefront", plug: str = "asgi", backend: str = "msgspec", - users: int = 32, + users: int = 64, seconds: float = 15.0, products: int = 5000, ) -> list[dict[str, t.Any]]: - """One load run. Returns rows per action plus an ``all`` row; raises if the stock invariant fails.""" + """One load run: a light phase for latency, then a saturating phase for capacity. + + Raises if the stock invariant does not hold afterwards. + """ database = f"ms_load_{uuid.uuid4().hex[:10]}" uri = uri_for(settings.topology) m = shop_models.build(backend) @@ -112,63 +224,33 @@ async def run_load( backend=backend, projector=False, ) as server: - limits = httpx.Limits(max_connections=users, max_keepalive_connections=users) - async with httpx.AsyncClient(base_url=server.base_url, timeout=30, limits=limits) as client: - for warm in (2.0, seconds): # a short warm-up, then the measured run - samples: dict[str, list[float]] = {name: [] for name, _ in MIX} - failures: dict[str, int] = {name: 0 for name, _ in MIX} - stop = asyncio.Event() - tasks = [ - asyncio.create_task( - _virtual_user( - i, client, seeded, stop, samples, failures, random.Random(1000 + i) - ) - ) - for i in range(users) - ] - started = time.perf_counter() - await asyncio.sleep(warm) - stop.set() - await asyncio.gather(*tasks) - elapsed = time.perf_counter() - started + await _phase(server, seeded, 4, 2.0) # warm-up + light = await _phase(server, seeded, 2, seconds / 3) + heavy = await _phase(server, seeded, users, seconds) invariant = await stock_invariant(registry, m, seeded) finally: await registry.client.drop_database(database) if not invariant["ok"]: raise AssertionError(f"{label}: stock invariant violated under load: {invariant}") - rows: list[dict[str, t.Any]] = [] - everything: list[float] = [] + + samples, failures, elapsed, cpu = heavy + total = sum(len(v) for v in samples.values()) + row: dict[str, t.Any] = { + "app": label, + "server_cpu_ms_per_request": round(cpu / total * 1000, 3) if total else 0.0, + "rps_at_saturation": round(total / elapsed, 1), + "server_cpu_utilisation": round(cpu / elapsed, 2), + } + quiet = light[0] for action, _ in MIX: - values = samples[action] - everything.extend(values) - if not values: - continue - rows.append( - { - "app": label, - "action": action, - "requests": len(values), - "rps": round(len(values) / elapsed, 1), - "p50_ms": round(statistics.median(values) * 1000, 2), - "p95_ms": round(_percentile(values, 0.95) * 1000, 2), - "p99_ms": round(_percentile(values, 0.99) * 1000, 2), - "failed": failures[action], - } - ) - rows.append( - { - "app": label, - "action": "all", - "requests": len(everything), - "rps": round(len(everything) / elapsed, 1), - "p50_ms": round(statistics.median(everything) * 1000, 2), - "p95_ms": round(_percentile(everything, 0.95) * 1000, 2), - "p99_ms": round(_percentile(everything, 0.99) * 1000, 2), - "failed": sum(failures.values()), - } - ) - rows[-1]["orders_placed"] = invariant["orders_placed"] - return rows + values = quiet[action] + row[f"{action}_p50_ms"] = round(statistics.median(values) * 1000, 2) if values else "" + row[f"{action}_p95_ms"] = round(_percentile(values, 0.95) * 1000, 2) if values else "" + busy = [v for values in samples.values() for v in values] + row["saturated_p99_ms"] = round(_percentile(busy, 0.99) * 1000, 1) if busy else "" + row["failed"] = sum(failures.values()) + sum(light[1].values()) + row["orders_placed"] = invariant["orders_placed"] + return [row] async def load(settings: Settings) -> Result: @@ -179,7 +261,7 @@ async def load(settings: Settings) -> Result: "What do mongospecs and mongospecs-web cost per request in a real server, against the same " "endpoints written by hand with pymongo?", ) - users = settings.scale(32, 6) + users = settings.scale(64, 8) seconds = float(settings.scale(15, 3)) products = settings.scale(5000, 200) runs: list[tuple[str, str, str]] = [("raw", "asgi", "msgspec")] @@ -191,19 +273,25 @@ async def load(settings: Settings) -> Result: runs = runs[:2] baseline: dict[str, float] = {} for app, plug, backend in runs: - rows = await run_load( + for row in await run_load( settings, app=app, plug=plug, backend=backend, users=users, seconds=seconds, products=products - ) - for row in rows: + ): if app == "raw": - baseline[row["action"]] = row["rps"] + baseline = {"cpu": row["server_cpu_ms_per_request"], "rps": row["rps_at_saturation"]} + row["cpu_vs_raw"] = ( + round(row["server_cpu_ms_per_request"] / baseline["cpu"], 2) if baseline.get("cpu") else "" + ) row["rps_vs_raw"] = ( - round(row["rps"] / baseline[row["action"]], 2) if baseline.get(row["action"]) else "" + round(row["rps_at_saturation"] / baseline["rps"], 2) if baseline.get("rps") else "" ) result.add(**row) result.notes.append( - f"{users} virtual users for {seconds:.0f}s after a 2s warm-up; mix {dict(MIX)}; {products} products, " - "3 reviews each; one uvicorn worker; load generator and server on the same machine." + f"Mix {dict(MIX)}; {products} products with 3 reviews each; one uvicorn worker. Latency columns: 2 virtual " + f"users for {seconds / 3:.0f}s (an unloaded server). Capacity columns: {users} virtual users for {seconds:.0f}s." + ) + result.notes.append( + "server_cpu_ms_per_request is CPU time of the server process divided by requests served: it does not " + "depend on the load generator, which runs on the same machine." ) result.notes.append( "The stock invariant (stock + units sold = initial stock, never negative) held after every run." diff --git a/claudedocs/v2-plan.md b/claudedocs/v2-plan.md index ba1c40e..4809fd0 100644 --- a/claudedocs/v2-plan.md +++ b/claudedocs/v2-plan.md @@ -865,9 +865,25 @@ Recorded per §7.1 rule 4. Each one names what changed and why; none invalidates | A11 | §4.7 | `Registry(on_command=...)` listeners: `logging_listener`, `otel_listener`, and `sentry_listener` (new, requested 2026-10-13). End-to-end trace verification against a Sentry-compatible server (rustrak) is node `E3` | Jay asked for Sentry SDK integration with rustrak for end-to-end tracing | | A12 | §5 | Versions: `mongospecs` 0.2.0, `mongospecs-web` 0.1.0 | Decided by Jay | | A13 | §7.2 | Spikes S3, S4, S6 and S8 were answered by the contract tests rather than by throwaway code: markers under deferred annotations (suite passes on 3.11, 3.13, 3.15), per-field validation on all three backends, snapshot tracking, discriminators and forward references | The tests are the durable form of the spike | +| A14 | §4.4 | The `trusted` read option is removed | Measured (B1): the pydantic fast path was slower than validating (17µs vs 5µs per wide document) and the flag did nothing on msgspec and attrs | +| A15 | §4.5 | A tracked document keeps the raw BSON bytes it was read from. The comparable snapshot (decode into the model, encode again) is built only when the document is saved | Measured (B2, B8): building the snapshot at load time made tracked reads about twice as slow as lean reads | +| A16 | §4.5 | A field the stored document lacks is not written back just because the model has a default for it; loading a document never produces changes | Follows from A15. What Mongoose does with defaults applied at load time was not verified | +| A17 | §4.4 | Documents read from the database are decoded leniently on strict backends (msgspec, attrs): an integer stored as a double is accepted. Input (creates, inserts from dicts, updates) stays strict | Data written by other clients is commonly numerically "loose"; pydantic is already lax | +| A18 | §4.2 | `Policy(private=True)` also means not writable, unless `insert`/`update` are set explicitly | Secure default found while building the web layer | +| A19 | §4.6 | Cursor pagination needs an index ending in `_id`; with an index on the sort key alone the server sorts in memory | Measured (B5): 20ms vs under 1ms for the first page of 50k documents | +| A20 | §4.8 | `Resource(server_fields=...)` sets values on create outside the field policy | Needed by the reference application (the author of a review) | +| A21 | §6 | The evidence track gained a production-shaped reference application ("storefront"), end-to-end tests that boot it as a real server process, and an HTTP load benchmark with a hand-written pymongo baseline (nodes E4-E6) | Requested by Jay 2026-10-13: real end-to-end, production-ready scenarios | New node: | ID | Node | Needs | Check | |---|---|---|---| | E3 | End-to-end tracing: Sentry SDK in an app using `mongospecs-web`, sending to rustrak in Docker; one HTTP request yields one trace containing the request span and `Model.op` database spans | P1, K17 | A script asserts the trace arrived in rustrak with the expected spans | +| E4 | Storefront reference application: auth, guards, row scope, transactional orders, soft delete, ETags, reverse relations, exactly-once change-stream projector, health check, graceful shutdown; runs on every plug and model backend | P1-P5, K6, R5 | The e2e suite below | +| E5 | End-to-end tests over real HTTP against the storefront under uvicorn: customer journey on every plug, ordering on every backend, concurrent buyers never oversell, multi-item orders are atomic, projector resumes across restarts without double counting, in-flight requests finish on shutdown, orders stay consistent across a primary step-down | E4 | `uv run pytest bench/tests/test_e2e_storefront.py` passes; the stock invariant holds after each scenario | +| E6 | HTTP load benchmark: mixed browse/detail/order traffic against the running storefront on every plug and backend, next to a hand-written pymongo + Starlette implementation of the same endpoints | E4, E1 | Results committed; the stock invariant holds after every run | + +Bugs the end-to-end tests found that the unit and contract suites had not: aggregation paths +(`aggregate`, `union`, `graph`, backref counts) broke whenever a default time limit was configured; +string ids assigned to reference fields were stored as strings on msgspec and attrs; unexpected +server errors were not logged anywhere. diff --git a/docs/parity.md b/docs/parity.md index 0ff8e17..4a38ccb 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -18,9 +18,9 @@ was established: **docs** means it was re-read in the Mongoose documentation for | Middleware: document, query, aggregate, model | document hooks; `on_insert`/`on_update`/`on_filter`/`on_delete`; `on_pipeline`; `before_bulk`/`after_bulk` | `test_plugins_hooks.py` | docs | | `schema.plugin()`, `mongoose.plugin()` | marker plugins, `SpecConfig(plugins=)`, `Registry(plugins=)` | `test_plugins_hooks.py::test_registry_level_plugin_applies_to_every_model` | memory | | `timestamps` | `CreatedAt`, `UpdatedAt` | `test_plugins_hooks.py::test_timestamps_on_every_write_path` | memory | -| `optimisticConcurrency`, `versionKey` | `Revision` | `test_plugins_hooks.py::test_revision_increments_everywhere` | memory | -| `populate()` with `match`, `select`, `options`, `perDocumentLimit`, nested paths | `include()` with `match`, `select`, `sort`, `limit`, `per_parent_limit`, dotted paths | `test_relations.py` | memory | -| Virtual populate (`localField`/`foreignField`, `count`) | `Backref(...)`, `Backref(..., count=True)` | `test_relations.py::test_backrefs_and_counts` | memory | +| `optimisticConcurrency`, `versionKey` | `Revision` | `test_plugins_hooks.py::test_revision_increments_everywhere` | docs | +| `populate()` with `match`, `select`, `options`, `perDocumentLimit`, nested paths | `include()` with `match`, `select`, `sort`, `limit`, `per_parent_limit`, dotted paths | `test_relations.py` | docs | +| Virtual populate (`localField`/`foreignField`, `count`) | `Backref(...)`, `Backref(..., count=True)` | `test_relations.py::test_backrefs_and_counts` | docs | | `refPath` | `Ref[A \| B]` | `test_relations.py::test_polymorphic_reference` | memory | | Discriminators | `SpecConfig(discriminator=...)` | `test_models_misc.py::test_discriminators_share_a_collection` | docs | | Query casting | values cast to the field type | `test_expr.py::test_values_are_cast_to_field_types` | memory | @@ -32,10 +32,10 @@ was established: **docs** means it was re-read in the Mongoose documentation for | Query helpers | `@Model.scope` | `test_plugins_hooks.py::test_named_scopes` | memory | | `orFail()` | `.or_fail()`, `.one()` | `test_crud.py::test_find_all_first_one_and_or_fail` | memory | | `cursor()`, `eachAsync({parallel})` | `async for`, `.each(fn, concurrency=n)` | `test_crud.py::test_each_with_concurrency` | memory | -| `bulkWrite`, `bulkSave`, `insertMany({ordered: false})` | `bulk_write`, `bulk_save`, `insert_many(ordered=False)` | `test_bulk_pagination.py` | memory | +| `bulkWrite`, `bulkSave`, `insertMany({ordered: false})` | `bulk_write`, `bulk_save`, `insert_many(ordered=False)` | `test_bulk_pagination.py` | docs | | `Connection.transaction(fn)` resets document state on abort | `registry.transaction(fn)` does the same, also before each retry | `test_tracking_tx.py::test_transaction_abort_rolls_back_database_and_memory` | docs | | `syncIndexes`, `diffIndexes`, `autoIndex: false` | `plan_indexes`, `apply_indexes(drop=...)`; never automatic | `test_indexes.py` | memory | -| `shardKey` | `SpecConfig(shard_key=...)` | `test_streams_sharded.py::test_shard_key_is_added_to_document_writes` | memory | +| `shardKey` | `SpecConfig(shard_key=...)` | `test_streams_sharded.py::test_shard_key_is_added_to_document_writes` | docs | | `createConnection`, `useDb` | several registries; `Model.using(db=...)` | `test_models_misc.py::test_using_another_database` | memory | | `maxTimeMS`, `hint`, `comment`, `collation`, read/write concern | `.options(...)`, `SpecConfig`, `Registry(max_time_ms=)` | `test_registry_errors.py::test_default_max_time_ms_from_registry_and_model` | memory | | `Model.aggregate()`, `Model.collection` | `Model.aggregate(pipeline, as_=)`, `Model.collection()` | `test_crud.py::test_aggregate_decodes_and_explain` | memory | @@ -66,3 +66,5 @@ was established: **docs** means it was re-read in the Mongoose documentation for | Populated paths replace the id with the document | A `Ref` keeps `.id` and gains `.doc` | One type whether or not it is loaded | | Arrays are tracked per element | A changed list is sent whole | Per-element intent is expressed with atomic operators | | A document's delete is not version-checked | Soft delete and `save()` are revision-checked; hard delete is not | Recorded as a known gap | +| `populate()` always runs a separate query per path | `include()` does the same by default and can use `$lookup` instead (`strategy="lookup"`) | Both give the same result; the faster one depends on fan-out, see the benchmarks | +| `limit` in `populate()` is applied across all parents unless `perDocumentLimit` is used | `limit` is overall, `per_parent_limit` is per parent, without one query per parent | Same two meanings, named for what they do | diff --git a/packages/mongospecs-web/README.md b/packages/mongospecs-web/README.md index 7819dfa..707c92c 100644 --- a/packages/mongospecs-web/README.md +++ b/packages/mongospecs-web/README.md @@ -1,6 +1,54 @@ # mongospecs-web -Framework-neutral REST resources for [mongospecs](https://github.com/jaykv/mongospecs) models, -with plugs for a plain ASGI app, Starlette, FastAPI, Litestar and Quart. +REST resources for [mongospecs](https://github.com/jaykv/mongospecs) models, described once and +mounted on the framework you use: plain ASGI, Starlette, FastAPI, Litestar or Quart. > **Status: 0.1.0, alpha.** + +``` +pip install "mongospecs-web[fastapi]" "mongospecs[msgspec]" +``` + +```python +from typing import Annotated + +from mongospecs import Policy, Q, Registry, Revision, SpecConfig +from mongospecs.msgspec import Spec +from mongospecs_web import Api, Create, Delete, Get, List, Patch, Resource + + +class Task(Spec): + __spec__ = SpecConfig(collection="tasks") + title: Annotated[str, Policy(filter=("eq", "prefix"), sort=True)] + done: Annotated[bool, Policy(filter=("eq",))] = False + owner: Annotated[str, Policy(insert=False, update=False)] = "" + rev: Annotated[int, Revision] = 0 + + +tasks = Resource( + Task, + ops=[List, Get, Create, Patch, Delete], + scope=lambda ctx: Q.owner == ctx.principal, # row-level: part of every query + server_fields=lambda ctx: {"owner": ctx.principal}, # set by the server on create +) +registry = Registry("mongodb://localhost/app", models=[Task]) +app = Api(tasks, prefix="/api", authenticate=lambda request: request.headers.get("x-user")).asgi( + registry=registry +) +``` + +That is a complete API: `GET /api/tasks?title[prefix]=Wri&sort=-title&limit=20`, `POST`, `GET /{id}`, +`PATCH /{id}` with `If-Match`, `DELETE /{id}`, and `GET /api/openapi.json`. + +- **The field policy is the contract.** What can be created, patched, read, filtered and sorted + comes from `Policy` on each field. Input is validated against the real model; nothing a client + sends reaches MongoDB as a raw filter or update. +- **Authorization in two parts.** Guards decide who may call an operation; `scope` returns a + condition that is ANDed into list, get, patch and delete. +- **HTTP done properly.** ETag and `If-Match` from the revision field, RFC 9457 problem documents, + cursor or offset pagination, OpenAPI 3.1. +- **Framework-neutral.** The same resource mounts on five frameworks through thin plugs, and a + contract suite (`mongospecs_web.testing`) checks that they all behave identically. + +Guides: [resources](https://github.com/jaykv/mongospecs/blob/main/docs/web/resources.md), +[plugs](https://github.com/jaykv/mongospecs/blob/main/docs/web/plugs.md). diff --git a/packages/mongospecs-web/src/mongospecs_web/backend.py b/packages/mongospecs-web/src/mongospecs_web/backend.py index 001b6dc..61fefb2 100644 --- a/packages/mongospecs-web/src/mongospecs_web/backend.py +++ b/packages/mongospecs-web/src/mongospecs_web/backend.py @@ -61,7 +61,11 @@ class ResourceBackend(t.Protocol[T]): model: type[T] - async def get(self, id: t.Any, *, scope: t.Any, include: t.Sequence[str] = ()) -> T | None: ... + async def get( + self, id: t.Any, *, scope: t.Any, include: t.Sequence[str] = (), read_only: bool = False + ) -> T | None: + """``read_only`` says the caller will only serialise the document, never save it.""" + ... async def list(self, query: ListQuery, *, scope: t.Any) -> Page[T]: ... @@ -94,16 +98,20 @@ def _by_id(self, id: t.Any, scope: t.Any) -> t.Any: return None return query.filter(scope) if scope is not None else query - async def get(self, id: t.Any, *, scope: t.Any, include: t.Sequence[str] = ()) -> T | None: + async def get( + self, id: t.Any, *, scope: t.Any, include: t.Sequence[str] = (), read_only: bool = False + ) -> T | None: query = self._by_id(id, scope) if query is None: return None + if read_only: + query = query.lean() # nothing will be saved: skip change tracking for path in include: query = query.include(path) return t.cast("T | None", await query.first()) async def list(self, query: ListQuery, *, scope: t.Any) -> Page[T]: - q = self.manager.find(*(f.to_expr() for f in query.filters)) + q = self.manager.find(*(f.to_expr() for f in query.filters)).lean() # listings are read-only if scope is not None: q = q.filter(scope) if query.sort: diff --git a/packages/mongospecs-web/src/mongospecs_web/policy.py b/packages/mongospecs-web/src/mongospecs_web/policy.py index 6c687e2..447ba5e 100644 --- a/packages/mongospecs-web/src/mongospecs_web/policy.py +++ b/packages/mongospecs-web/src/mongospecs_web/policy.py @@ -7,19 +7,12 @@ from __future__ import annotations -import base64 -import datetime as dt -import decimal -import enum import typing as t -import uuid import weakref -from bson import ObjectId -from bson.decimal128 import Decimal128 - from mongospecs import CreatedAt, Deferred, DeletedAt, Immutable, Ref, Revision, UpdatedAt, meta_of from mongospecs.codec import FieldInfo +from mongospecs.jsonable import jsonable from mongospecs.markers import is_computed from mongospecs.meta import ModelMeta @@ -28,29 +21,6 @@ _MANAGED = (CreatedAt, UpdatedAt, DeletedAt, Revision) -def jsonable(value: t.Any) -> t.Any: - """Convert a BSON-ready value to plain JSON types.""" - if value is None or isinstance(value, str | bool | int | float): - return value - if isinstance(value, ObjectId | uuid.UUID | decimal.Decimal | Decimal128): - return str(value) - if isinstance(value, dt.datetime): - return value.isoformat().replace("+00:00", "Z") - if isinstance(value, dt.date | dt.time): - return value.isoformat() - if isinstance(value, enum.Enum): - return jsonable(value.value) - if isinstance(value, dict): - return {str(k): jsonable(v) for k, v in value.items()} - if isinstance(value, list | tuple | set | frozenset): - return [jsonable(v) for v in value] - if isinstance(value, bytes | bytearray): - return base64.b64encode(bytes(value)).decode("ascii") - if isinstance(value, Ref): - return jsonable(value.id) - return str(value) - - class View: """What of a model is readable, writable, filterable and sortable from outside.""" @@ -91,6 +61,11 @@ def __init__(self, model: type) -> None: for name, attr in vars(klass).items() if is_computed(attr) ) + # Serialisation plan: plain readable fields, and the relations that may be embedded. + self._plain: tuple[str, ...] = tuple(f.name for f in self.readable if not f.transient) + self._relations: tuple[FieldInfo, ...] = tuple( + f for f in self.readable if f.transient or f.ref is not None + ) revision = next((f for f in fields.values() if f.has(Revision)), None) self.revision: str | None = revision.name if revision else None @@ -100,24 +75,22 @@ def serialize(self, doc: t.Any, include: t.Collection[str] = ()) -> dict[str, t. """The Read view of a document as JSON types. Private fields never appear.""" if type(doc) is not self.model: return view_of(type(doc)).serialize(doc, include) - meta = self.meta - encoded = meta.encode(doc) - out: dict[str, t.Any] = {} - for f in self.readable: - nested = {p.partition(".")[2] for p in include if p.partition(".")[0] == f.name and "." in p} - if f.transient: - if f.name in include: - value = getattr(doc, f.name) + data = self.meta.codec.to_json(doc) + out = {name: data.get(name) for name in self._plain} + if include: + for f in self._relations: + if f.name not in include: + continue + nested = {p.partition(".")[2] for p in include if p.partition(".")[0] == f.name and "." in p} + value = getattr(doc, f.name) + if f.transient: out[f.name] = ( [_child(v, nested) for v in value] if isinstance(value, list) else jsonable(value) ) - continue - if f.ref is not None and f.name in include: - out[f.name] = _embed(getattr(doc, f.name), encoded.get(f.stored), nested) - continue - out[f.name] = jsonable(encoded.get(f.stored)) - if meta.disc_key: - out[meta.disc_key] = encoded.get(meta.disc_key) + else: + out[f.name] = _embed(value, out.get(f.name), nested) + if self.meta.disc_key: + out[self.meta.disc_key] = self.meta.disc_value for name in self.computed: out[name] = jsonable(getattr(doc, name)) return out diff --git a/packages/mongospecs-web/src/mongospecs_web/resource.py b/packages/mongospecs-web/src/mongospecs_web/resource.py index 050e4f2..7e0809c 100644 --- a/packages/mongospecs-web/src/mongospecs_web/resource.py +++ b/packages/mongospecs-web/src/mongospecs_web/resource.py @@ -15,6 +15,7 @@ from dataclasses import dataclass, field from mongospecs.expr import Expr +from mongospecs.jsonable import jsonable from mongospecs_web.backend import MongoBackend, ResourceBackend from mongospecs_web.errors import ( PROBLEM_JSON, @@ -26,7 +27,7 @@ problem, ) from mongospecs_web.filters import Cursor, Pagination, parse_includes, parse_list_query -from mongospecs_web.policy import View, jsonable, view_of +from mongospecs_web.policy import View, view_of __all__ = ["Create", "Ctx", "Delete", "Get", "List", "Operation", "Patch", "Request", "Resource", "Response"] @@ -45,6 +46,22 @@ JSON = "application/json" +def _stdlib_dumps(data: t.Any) -> bytes: + return json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode() + + +def _pick_dumps() -> t.Callable[[t.Any], bytes]: + """Use msgspec's JSON encoder when it is installed (it usually is); the standard library otherwise.""" + try: + import msgspec + except ImportError: + return _stdlib_dumps + return msgspec.json.Encoder().encode + + +dumps: t.Callable[[t.Any], bytes] = _pick_dumps() + + @dataclass(slots=True) class Request: """A framework-neutral HTTP request.""" @@ -83,8 +100,7 @@ class Response: @classmethod def json(cls, data: t.Any, status: int = 200, headers: dict[str, str] | None = None) -> Response: - body = json.dumps(data, separators=(",", ":"), ensure_ascii=False).encode() - return cls(status, body, dict(headers or {}), JSON) + return cls(status, dumps(data), dict(headers or {}), JSON) @classmethod def error(cls, exc: BaseException) -> Response: @@ -461,7 +477,7 @@ def _one(self) -> dict[str, t.Any]: def _get_op(self) -> Operation: async def handler(ctx: Ctx[T]) -> Response: include = parse_includes(ctx.request.query, self.includes) - doc = await self.backend.get(ctx.id, scope=await ctx.scope(), include=include) + doc = await self.backend.get(ctx.id, scope=await ctx.scope(), include=include, read_only=True) if doc is None: raise NotFound("The resource does not exist.") ctx.state["result"] = doc diff --git a/packages/mongospecs-web/src/mongospecs_web/testing.py b/packages/mongospecs-web/src/mongospecs_web/testing.py index b600611..7996403 100644 --- a/packages/mongospecs-web/src/mongospecs_web/testing.py +++ b/packages/mongospecs-web/src/mongospecs_web/testing.py @@ -128,7 +128,9 @@ def _check_unique(self, doc: T, skip: t.Any = None) -> None: if other_id != skip and getattr(other, name) == value: raise DuplicateKeyError(f"duplicate {name}", code=11000, fields={name: value}) - async def get(self, id: t.Any, *, scope: t.Any, include: t.Sequence[str] = ()) -> T | None: + async def get( + self, id: t.Any, *, scope: t.Any, include: t.Sequence[str] = (), read_only: bool = False + ) -> T | None: return self._find(id, scope) async def list(self, query: ListQuery, *, scope: t.Any) -> Page[T]: diff --git a/packages/mongospecs/README.md b/packages/mongospecs/README.md index c0e696c..1dc7a52 100644 --- a/packages/mongospecs/README.md +++ b/packages/mongospecs/README.md @@ -87,12 +87,26 @@ async def main(): | Operations | explicit `Registry` lifecycle, transactions, index planner with gated destructive actions, `$jsonSchema` validators, change streams, multi-database use, shard-key aware writes | | Observability | per-command events with model and operation names; logging, OpenTelemetry and Sentry listeners | +## How it is tested + +- Unit and contract suites run against real MongoDB (a replica set and a sharded cluster), on + every backend, on Python 3.11 to 3.15. +- A reference application, the storefront in `bench/`, is booted as a real server process and + driven over HTTP: a full customer journey on every plug, concurrent buyers that must never + oversell, multi-item orders that are all-or-nothing, a change-stream projector that must count + each sale exactly once across restarts, and orders that stay consistent across a primary + step-down. +- Every example in the guide pages is executed. +- Benchmarks and stress scenarios in `bench/` run against real MongoDB; results and the + conclusions drawn from them are in `docs/benchmarks.md`. + ## Development ``` make install # uv sync for the whole workspace make up # MongoDB replica set + sharded cluster in Docker -make check # ruff, mypy --strict, tests against real MongoDB +make check # ruff, mypy --strict, tests against real MongoDB (unit, contract, end-to-end) +uv run mongospecs-bench run # benchmarks; `run LOAD` for the HTTP load test; `stress`; `trace` ``` The repository is a uv workspace: `packages/mongospecs`, `packages/mongospecs-web` and `bench/` diff --git a/packages/mongospecs/src/mongospecs/attrs.py b/packages/mongospecs/src/mongospecs/attrs.py index fea5ab3..27a3e63 100644 --- a/packages/mongospecs/src/mongospecs/attrs.py +++ b/packages/mongospecs/src/mongospecs/attrs.py @@ -14,6 +14,7 @@ from mongospecs.codec import Codec, RawField, field_error, to_bson from mongospecs.errors import ValidationError +from mongospecs.jsonable import json_default from mongospecs.markers import SpecConfig from mongospecs.model import Document from mongospecs.msgspec import _error, dec_hook, schema_hook @@ -64,6 +65,22 @@ def validate_field(self, cls: type, name: str, value: t.Any) -> t.Any: def _json_schema(self, cls: type) -> dict[str, t.Any]: return msgspec.json.schema(cls, schema_hook=schema_hook) + def to_json(self, obj: t.Any) -> dict[str, t.Any]: + return t.cast("dict[str, t.Any]", msgspec.to_builtins(obj, enc_hook=json_default)) + + def defaults(self, cls: type) -> dict[str, t.Any]: + out: dict[str, t.Any] = {} + for a in attrs.fields(cls): + if a.default is attrs.NOTHING: + continue + default = a.default + if isinstance(default, attrs.Factory): # type: ignore[arg-type] + if default.takes_self: + continue # depends on the instance; nothing to write without one + default = default.factory() + out[a.name] = default + return out + CODEC = AttrsCodec() diff --git a/packages/mongospecs/src/mongospecs/codec.py b/packages/mongospecs/src/mongospecs/codec.py index 9705992..05b6d25 100644 --- a/packages/mongospecs/src/mongospecs/codec.py +++ b/packages/mongospecs/src/mongospecs/codec.py @@ -255,6 +255,10 @@ def validate_field(self, cls: type, name: str, value: t.Any) -> t.Any: @abc.abstractmethod def _json_schema(self, cls: type) -> dict[str, t.Any]: ... + @abc.abstractmethod + def defaults(self, cls: type) -> dict[str, t.Any]: + """Default values by attribute name, for fields that have one (factories are called).""" + # -- shared ------------------------------------------------------------ def fields(self, cls: type) -> dict[str, FieldInfo]: @@ -334,6 +338,18 @@ def _special(self, cls: type) -> tuple[FieldInfo, ...]: def encode_value(self, value: t.Any) -> t.Any: return to_bson(value) + def to_json(self, obj: t.Any) -> dict[str, t.Any]: + """The model as JSON types, keyed by attribute names; references appear as ids. + + Backends override this with their native serialiser, which is far faster + than encoding to BSON types and converting those. + """ + from mongospecs.jsonable import jsonable + + cls = type(obj) + names = {f.stored: f.name for f in self.fields(cls).values()} + return {names.get(k, k): jsonable(v) for k, v in self.encode(obj).items()} + def json_schema(self, cls: type) -> dict[str, t.Any]: """JSON Schema for the model, keyed by attribute names, root inlined, ``$defs`` kept.""" schema = dict(self._json_schema(cls)) diff --git a/packages/mongospecs/src/mongospecs/jsonable.py b/packages/mongospecs/src/mongospecs/jsonable.py new file mode 100644 index 0000000..6eaa31f --- /dev/null +++ b/packages/mongospecs/src/mongospecs/jsonable.py @@ -0,0 +1,53 @@ +"""Conversion of BSON-ready values to plain JSON types.""" + +from __future__ import annotations + +import base64 +import datetime as dt +import decimal +import enum +import typing as t +import uuid + +from bson import ObjectId +from bson.decimal128 import Decimal128 + +from mongospecs.refs import Ref + +__all__ = ["json_default", "jsonable"] + + +def jsonable(value: t.Any) -> t.Any: + """Convert a BSON-ready value to plain JSON types.""" + if value is None or isinstance(value, str | bool | int | float): + return value + if isinstance(value, ObjectId | uuid.UUID | decimal.Decimal | Decimal128): + return str(value) + if isinstance(value, dt.datetime): + return value.isoformat().replace("+00:00", "Z") + if isinstance(value, dt.date | dt.time): + return value.isoformat() + if isinstance(value, enum.Enum): + return jsonable(value.value) + if isinstance(value, dict): + return {str(k): jsonable(v) for k, v in value.items()} + if isinstance(value, list | tuple | set | frozenset): + return [jsonable(v) for v in value] + if isinstance(value, bytes | bytearray): + return base64.b64encode(bytes(value)).decode("ascii") + if isinstance(value, Ref): + return jsonable(value.id) + return str(value) + + +def json_default(value: t.Any) -> t.Any: + """``enc_hook`` for serialisers that handle the standard types themselves: + only the types they do not know (ObjectId, Ref, Decimal128, nested documents) arrive here.""" + if isinstance(value, ObjectId | Decimal128): + return str(value) + if isinstance(value, Ref): + return jsonable(value.id) + identifier = getattr(value, "id", None) + if identifier is not None: # a document assigned where a reference is expected + return jsonable(identifier) + raise NotImplementedError(f"cannot serialise {type(value).__name__}") diff --git a/packages/mongospecs/src/mongospecs/manager.py b/packages/mongospecs/src/mongospecs/manager.py index a7db1fc..cbb7591 100644 --- a/packages/mongospecs/src/mongospecs/manager.py +++ b/packages/mongospecs/src/mongospecs/manager.py @@ -402,6 +402,12 @@ def _update_doc( ctx.extra["upsert"] = True if self.meta.disc_key: update.setdefault("$setOnInsert", {})[self.meta.disc_key] = self.meta.disc_value + # Give the inserted document the model's defaults (Mongoose: setDefaultsOnInsert), + # except where the update or the filter already decides the value. + taken = {key.partition(".")[0] for body in update.values() for key in body} | _filter_keys(flt) + for stored, value in self.meta.insert_defaults().items(): + if stored not in taken: + update.setdefault("$setOnInsert", {})[stored] = value for plugin in self.plugins: plugin.on_update(ctx, flt, update) return update @@ -779,3 +785,16 @@ def _write_error(error: t.Mapping[str, t.Any]) -> Exception: fields=dict(fields) if isinstance(fields, dict) else None, ) return OperationFailed(message, code=error.get("code"), details=dict(error)) + + +def _filter_keys(flt: t.Any) -> set[str]: + """Top-level field names a filter mentions, looking through $and/$or/$nor.""" + keys: set[str] = set() + if isinstance(flt, dict): + for key, value in flt.items(): + if key in ("$and", "$or", "$nor") and isinstance(value, list): + for part in value: + keys |= _filter_keys(part) + elif not key.startswith("$"): + keys.add(key.partition(".")[0]) + return keys diff --git a/packages/mongospecs/src/mongospecs/meta.py b/packages/mongospecs/src/mongospecs/meta.py index 5ade142..346d747 100644 --- a/packages/mongospecs/src/mongospecs/meta.py +++ b/packages/mongospecs/src/mongospecs/meta.py @@ -244,6 +244,20 @@ def decode(self, doc: t.Any, *, stored: bool = True) -> t.Any: cls = self.subclass_for(doc.get(self.disc_key)) return self.codec.decode(cls, doc, lax=stored) + def insert_defaults(self) -> dict[str, t.Any]: + """Stored key -> BSON-ready default, for fields with a non-null default. + + Used so a document created by an upsert has the same shape as one created + by ``insert()``; otherwise a query on a defaulted value would not find it. + """ + out: dict[str, t.Any] = {} + for name, value in self.codec.defaults(self.model).items(): + info = self.fields.get(name) + if info is None or info.transient or name == "id" or value is None: + continue + out[info.stored] = to_bson(value) + return out + def encode(self, obj: t.Any) -> dict[str, t.Any]: doc = self.codec.encode(obj) if self.disc_key: diff --git a/packages/mongospecs/src/mongospecs/msgspec.py b/packages/mongospecs/src/mongospecs/msgspec.py index e37b6b8..12de11a 100644 --- a/packages/mongospecs/src/mongospecs/msgspec.py +++ b/packages/mongospecs/src/mongospecs/msgspec.py @@ -11,6 +11,7 @@ from mongospecs.codec import Codec, RawField, field_error, to_bson from mongospecs.errors import ValidationError +from mongospecs.jsonable import json_default from mongospecs.markers import SpecConfig from mongospecs.model import Document from mongospecs.refs import OID, OID_PATTERN, Ref, _to_object_id, coerce_ref @@ -81,6 +82,33 @@ def validate_field(self, cls: type, name: str, value: t.Any) -> t.Any: def _json_schema(self, cls: type) -> dict[str, t.Any]: return msgspec.json.schema(cls, schema_hook=schema_hook) + def to_json(self, obj: t.Any) -> dict[str, t.Any]: + data: dict[str, t.Any] = msgspec.to_builtins(obj, enc_hook=json_default) + renames = self._json_renames(type(obj)) + for stored, name in renames: + if stored in data: + data[name] = data.pop(stored) + return data + + def _json_renames(self, cls: type) -> tuple[tuple[str, str], ...]: + cache: dict[type, tuple[tuple[str, str], ...]] = self.__dict__.setdefault("_jr", {}) + hit = cache.get(cls) + if hit is None: + hit = tuple( + (f.encode_name, f.name) for f in msgspec.structs.fields(cls) if f.encode_name != f.name + ) + cache[cls] = hit + return hit + + def defaults(self, cls: type) -> dict[str, t.Any]: + out: dict[str, t.Any] = {} + for f in msgspec.structs.fields(cls): + if f.default is not msgspec.NODEFAULT: + out[f.name] = f.default + elif f.default_factory is not msgspec.NODEFAULT: + out[f.name] = f.default_factory() + return out + CODEC = MsgspecCodec() diff --git a/packages/mongospecs/src/mongospecs/pydantic.py b/packages/mongospecs/src/mongospecs/pydantic.py index b2a940d..4515a80 100644 --- a/packages/mongospecs/src/mongospecs/pydantic.py +++ b/packages/mongospecs/src/mongospecs/pydantic.py @@ -89,6 +89,22 @@ def validate_field(self, cls: type, name: str, value: t.Any) -> t.Any: def _json_schema(self, cls: type) -> dict[str, t.Any]: return t.cast("type[BaseModel]", cls).model_json_schema(by_alias=False) + def to_json(self, obj: t.Any) -> dict[str, t.Any]: + if _HAS_EXCLUDE_COMPUTED: + return t.cast("dict[str, t.Any]", obj.model_dump(mode="json", exclude_computed_fields=True)) + data: dict[str, t.Any] = obj.model_dump(mode="json") + for name in type(obj).model_computed_fields: + data.pop(name, None) + return data + + def defaults(self, cls: type) -> dict[str, t.Any]: + fields = t.cast("type[BaseModel]", cls).model_fields + return { + name: info.get_default(call_default_factory=True) + for name, info in fields.items() + if not info.is_required() + } + def clear_cache(self) -> None: super().clear_cache() self._adapters.clear() diff --git a/packages/mongospecs/tests/test_updates.py b/packages/mongospecs/tests/test_updates.py index d75fdaf..ea45b83 100644 --- a/packages/mongospecs/tests/test_updates.py +++ b/packages/mongospecs/tests/test_updates.py @@ -160,3 +160,28 @@ async def test_if_rev_on_atomic_update(m, registry): await m.Article.update(art.id, if_rev=0, title="c") with pytest.raises(DocumentNotFound): await m.Article.update(ObjectId(), if_rev=0, title="c") + + +async def test_upsert_gives_the_new_document_the_model_defaults(m, registry): + """A document created by an upsert must look like one created by insert().""" + created = await m.Article.upsert(Q.title == "via upsert", Inc(views=5)) + inserted = await m.Article(title="via insert", views=5).insert() + raw_upsert = await m.Article.collection().find_one({"_id": created.id}, {"_id": 0, "title": 0, "rev": 0}) + raw_insert = await m.Article.collection().find_one({"_id": inserted.id}, {"_id": 0, "title": 0, "rev": 0}) + for doc in (raw_upsert, raw_insert): + doc.pop("created_at"), doc.pop("updated_at") + + def present(doc): + return {k: v for k, v in doc.items() if v is not None} + + assert present(raw_insert) == present(raw_upsert) + assert raw_upsert["tags"] == [] and raw_upsert["editors"] == [] and raw_upsert["reviewers"] == {} + # so a query on a defaulted value finds it + assert await m.Article.count(Q.tags == []) == 2 + # the filter and the update keep precedence over defaults, and an existing document is not reset + again = await m.Article.upsert(Q.title == "via upsert", Push(tags="x")) + assert again.tags == ["x"] and again.views == 5 + with_default = await m.User.upsert((Q.name == "u") & (Q.age == 7), email="u@x.io") + assert with_default.age == 7 + assert await m.Cat.upsert(Q.name == "tom", Inc(lives=-1)) is not None + assert (await m.Cat.collection().find_one({"name": "tom"}))["lives"] == -1 # $inc owns the field From 724d20e7b7d66c22d4168c83b147d39f4592e9eb Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 01:05:30 -0400 Subject: [PATCH 08/31] docs(bench): benchmark results, conclusions and decisions - results for all workloads, the HTTP load test, the stress scenarios and the Mongoose comparator at 6dc837f (earlier B1-B6 results kept as the before picture for the tracking optimisation) - docs/benchmarks.md: what was measured, how to read it, and what follows from it - decisions recorded in the plan: include() defaults to batched queries; no Rust in 0.2, with a BSON-to-model decoder for bulk reads as the one candidate; tracked stays the default read mode - drop workload B7 in favour of LOAD: measuring plugs through an in-process client measured the client - ignore stale class copies in subclass discovery (found by the free-threaded CI job) with a regression test - CI builds the docs in strict mode Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yaml | 1 + bench/README.md | 18 +- bench/results/6dc837f/B1.json | 198 +++++++ bench/results/6dc837f/B2.json | 508 ++++++++++++++++++ bench/results/6dc837f/B3-sharded.json | 70 +++ bench/results/6dc837f/B3.json | 70 +++ bench/results/6dc837f/B4.json | 198 +++++++ bench/results/6dc837f/B5.json | 80 +++ bench/results/6dc837f/B6.json | 90 ++++ bench/results/6dc837f/B8.json | 151 ++++++ bench/results/6dc837f/LOAD.json | 169 ++++++ bench/results/6dc837f/X1.json | 48 ++ bench/results/6dc837f/X2.json | 44 ++ bench/results/6dc837f/X3.json | 48 ++ bench/results/6dc837f/X4.json | 48 ++ bench/results/6dc837f/X5.json | 40 ++ bench/results/6dc837f/X6.json | 44 ++ bench/results/6dc837f/X7.json | 44 ++ bench/results/6dc837f/mongoose.json | 258 +++++++++ bench/src/mongospecs_bench/workloads.py | 101 +--- claudedocs/v2-plan.md | 12 + docs/benchmarks.md | 254 ++++++++- packages/mongospecs/src/mongospecs/meta.py | 32 +- packages/mongospecs/tests/test_models_misc.py | 39 ++ 24 files changed, 2459 insertions(+), 106 deletions(-) create mode 100644 bench/results/6dc837f/B1.json create mode 100644 bench/results/6dc837f/B2.json create mode 100644 bench/results/6dc837f/B3-sharded.json create mode 100644 bench/results/6dc837f/B3.json create mode 100644 bench/results/6dc837f/B4.json create mode 100644 bench/results/6dc837f/B5.json create mode 100644 bench/results/6dc837f/B6.json create mode 100644 bench/results/6dc837f/B8.json create mode 100644 bench/results/6dc837f/LOAD.json create mode 100644 bench/results/6dc837f/X1.json create mode 100644 bench/results/6dc837f/X2.json create mode 100644 bench/results/6dc837f/X3.json create mode 100644 bench/results/6dc837f/X4.json create mode 100644 bench/results/6dc837f/X5.json create mode 100644 bench/results/6dc837f/X6.json create mode 100644 bench/results/6dc837f/X7.json create mode 100644 bench/results/6dc837f/mongoose.json diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 4e32943..3eef0a6 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -22,6 +22,7 @@ jobs: - run: make lint - run: make types - run: make build + - run: make docs test: runs-on: ubuntu-latest diff --git a/bench/README.md b/bench/README.md index aa1eb6a..213730a 100644 --- a/bench/README.md +++ b/bench/README.md @@ -5,7 +5,8 @@ MongoDB. This package is a workspace member and is never published. ``` make up # replica set + sharded cluster -uv run mongospecs-bench run # workloads B1-B8 +uv run mongospecs-bench run # workloads B1-B6, B8 +uv run mongospecs-bench run LOAD # the storefront as a real server under HTTP load uv run mongospecs-bench run B3 --topology sharded uv run mongospecs-bench stress # scenarios X1-X7 uv run mongospecs-bench report # markdown summary of the newest results @@ -31,6 +32,19 @@ Absolute times depend on the machine. The comparisons that travel are ratios: mo | B4 | Inserts, atomic patches, delta saves and upserts | | B5 | Offset vs cursor pagination at depth; total by count vs `$facet` | | B6 | Per-operation cost of plugins | -| B7 | HTTP request to response through each plug and model backend | +| LOAD | The storefront as a real server under HTTP load, on every plug and model backend, next to a hand-written pymongo baseline (this is B7 of the plan) | | B8 | Time and memory of change tracking; size of a delta save | | X1-X7 | Pool pressure, transaction contention, optimistic-lock storm, cancellation, 16MB results, change stream across a step-down, threads with their own loops | + +## The storefront + +`mongospecs_bench.storefront` is a production-shaped application used by the end-to-end tests +(`bench/tests/test_e2e_storefront.py`) and the load benchmark: bearer-token auth, admin guards, row +scope on orders, an order transaction that cannot oversell, soft-deleted and revisioned products, +reviews as a reverse relation, an exactly-once change-stream projector, `/healthz`, graceful +shutdown. `raw_app.py` implements the three busiest endpoints by hand with pymongo and Starlette as +the baseline. + +``` +SHOP_DB=shop uv run uvicorn mongospecs_bench.storefront.app:create_app --factory +``` diff --git a/bench/results/6dc837f/B1.json b/bench/results/6dc837f/B1.json new file mode 100644 index 0000000..12ebb3e --- /dev/null +++ b/bench/results/6dc837f/B1.json @@ -0,0 +1,198 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": false, + "timestamp": "2026-10-13T04:45:53+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B1", + "title": "Materialise documents", + "question": "What does turning a raw document into a model cost, per backend, and how does it compare with the driver's own BSON decoding?", + "rows": [ + { + "shape": "small", + "backend": "driver", + "mode": "bson->dict", + "n": 10000, + "us_per_doc": 1.12, + "vs_bson": 1.0 + }, + { + "shape": "small", + "backend": "msgspec", + "mode": "decode", + "n": 10000, + "us_per_doc": 1.25, + "vs_bson": 1.11 + }, + { + "shape": "small", + "backend": "msgspec", + "mode": "decode + encode", + "n": 10000, + "us_per_doc": 3.14, + "vs_bson": 2.81 + }, + { + "shape": "small", + "backend": "pydantic", + "mode": "decode", + "n": 10000, + "us_per_doc": 2.09, + "vs_bson": 1.87 + }, + { + "shape": "small", + "backend": "pydantic", + "mode": "decode + encode", + "n": 10000, + "us_per_doc": 4.52, + "vs_bson": 4.04 + }, + { + "shape": "small", + "backend": "attrs", + "mode": "decode", + "n": 10000, + "us_per_doc": 1.94, + "vs_bson": 1.74 + }, + { + "shape": "small", + "backend": "attrs", + "mode": "decode + encode", + "n": 10000, + "us_per_doc": 4.38, + "vs_bson": 3.92 + }, + { + "shape": "wide", + "backend": "driver", + "mode": "bson->dict", + "n": 10000, + "us_per_doc": 6.92, + "vs_bson": 1.0 + }, + { + "shape": "wide", + "backend": "msgspec", + "mode": "decode", + "n": 10000, + "us_per_doc": 2.07, + "vs_bson": 0.3 + }, + { + "shape": "wide", + "backend": "msgspec", + "mode": "decode + encode", + "n": 10000, + "us_per_doc": 8.89, + "vs_bson": 1.28 + }, + { + "shape": "wide", + "backend": "pydantic", + "mode": "decode", + "n": 10000, + "us_per_doc": 6.18, + "vs_bson": 0.89 + }, + { + "shape": "wide", + "backend": "pydantic", + "mode": "decode + encode", + "n": 10000, + "us_per_doc": 10.05, + "vs_bson": 1.45 + }, + { + "shape": "wide", + "backend": "attrs", + "mode": "decode", + "n": 10000, + "us_per_doc": 6.58, + "vs_bson": 0.95 + }, + { + "shape": "wide", + "backend": "attrs", + "mode": "decode + encode", + "n": 10000, + "us_per_doc": 21.38, + "vs_bson": 3.09 + }, + { + "shape": "nested", + "backend": "driver", + "mode": "bson->dict", + "n": 10000, + "us_per_doc": 10.09, + "vs_bson": 1.0 + }, + { + "shape": "nested", + "backend": "msgspec", + "mode": "decode", + "n": 10000, + "us_per_doc": 3.32, + "vs_bson": 0.33 + }, + { + "shape": "nested", + "backend": "msgspec", + "mode": "decode + encode", + "n": 10000, + "us_per_doc": 25.96, + "vs_bson": 2.57 + }, + { + "shape": "nested", + "backend": "pydantic", + "mode": "decode", + "n": 10000, + "us_per_doc": 15.88, + "vs_bson": 1.57 + }, + { + "shape": "nested", + "backend": "pydantic", + "mode": "decode + encode", + "n": 10000, + "us_per_doc": 17.75, + "vs_bson": 1.76 + }, + { + "shape": "nested", + "backend": "attrs", + "mode": "decode", + "n": 10000, + "us_per_doc": 8.32, + "vs_bson": 0.83 + }, + { + "shape": "nested", + "backend": "attrs", + "mode": "decode + encode", + "n": 10000, + "us_per_doc": 20.68, + "vs_bson": 2.05 + } + ], + "notes": [ + "'decode' always validates. 'decode + encode' adds turning the model back into a BSON-ready dict (what a save does)." + ], + "conclusion": "" +} diff --git a/bench/results/6dc837f/B2.json b/bench/results/6dc837f/B2.json new file mode 100644 index 0000000..5f46352 --- /dev/null +++ b/bench/results/6dc837f/B2.json @@ -0,0 +1,508 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": false, + "timestamp": "2026-10-13T04:45:53+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B2", + "title": "find() end to end", + "question": "How much does the ODM add on top of the raw driver for reads of 1, 100 and 10,000 documents?", + "rows": [ + { + "shape": "small", + "backend": "pymongo", + "case": "driver dicts", + "n": 1, + "median_ms": 0.421, + "vs_driver": 1.0 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm raw()", + "n": 1, + "median_ms": 0.503, + "vs_driver": 1.19 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm lean()", + "n": 1, + "median_ms": 0.392, + "vs_driver": 0.93 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm tracked", + "n": 1, + "median_ms": 0.434, + "vs_driver": 1.03 + }, + { + "shape": "small", + "backend": "pymongo", + "case": "driver dicts", + "n": 100, + "median_ms": 0.562, + "vs_driver": 1.0 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm raw()", + "n": 100, + "median_ms": 0.578, + "vs_driver": 1.03 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm lean()", + "n": 100, + "median_ms": 0.696, + "vs_driver": 1.24 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm tracked", + "n": 100, + "median_ms": 0.899, + "vs_driver": 1.6 + }, + { + "shape": "small", + "backend": "pymongo", + "case": "driver dicts", + "n": 10000, + "median_ms": 19.226, + "vs_driver": 1.0 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm raw()", + "n": 10000, + "median_ms": 43.938, + "vs_driver": 2.29 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm lean()", + "n": 10000, + "median_ms": 41.261, + "vs_driver": 2.15 + }, + { + "shape": "small", + "backend": "msgspec", + "case": "odm tracked", + "n": 10000, + "median_ms": 63.621, + "vs_driver": 3.31 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm raw()", + "n": 1, + "median_ms": 0.589, + "vs_driver": 1.05 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm lean()", + "n": 1, + "median_ms": 0.73, + "vs_driver": 1.3 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm tracked", + "n": 1, + "median_ms": 1.62, + "vs_driver": 2.88 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm raw()", + "n": 100, + "median_ms": 1.817, + "vs_driver": 0.61 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm lean()", + "n": 100, + "median_ms": 3.942, + "vs_driver": 1.33 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm tracked", + "n": 100, + "median_ms": 4.373, + "vs_driver": 1.47 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm raw()", + "n": 10000, + "median_ms": 33.515, + "vs_driver": 0.65 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm lean()", + "n": 10000, + "median_ms": 54.828, + "vs_driver": 1.07 + }, + { + "shape": "small", + "backend": "pydantic", + "case": "odm tracked", + "n": 10000, + "median_ms": 93.484, + "vs_driver": 1.82 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm raw()", + "n": 1, + "median_ms": 0.82, + "vs_driver": 1.06 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm lean()", + "n": 1, + "median_ms": 0.854, + "vs_driver": 1.1 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm tracked", + "n": 1, + "median_ms": 1.046, + "vs_driver": 1.35 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm raw()", + "n": 100, + "median_ms": 0.806, + "vs_driver": 0.78 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm lean()", + "n": 100, + "median_ms": 1.273, + "vs_driver": 1.23 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm tracked", + "n": 100, + "median_ms": 1.695, + "vs_driver": 1.63 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm raw()", + "n": 10000, + "median_ms": 25.294, + "vs_driver": 1.04 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm lean()", + "n": 10000, + "median_ms": 72.603, + "vs_driver": 3.0 + }, + { + "shape": "small", + "backend": "attrs", + "case": "odm tracked", + "n": 10000, + "median_ms": 70.821, + "vs_driver": 2.93 + }, + { + "shape": "nested", + "backend": "pymongo", + "case": "driver dicts", + "n": 1, + "median_ms": 0.432, + "vs_driver": 1.0 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm raw()", + "n": 1, + "median_ms": 0.455, + "vs_driver": 1.05 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm lean()", + "n": 1, + "median_ms": 0.481, + "vs_driver": 1.11 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm tracked", + "n": 1, + "median_ms": 0.507, + "vs_driver": 1.17 + }, + { + "shape": "nested", + "backend": "pymongo", + "case": "driver dicts", + "n": 100, + "median_ms": 1.193, + "vs_driver": 1.0 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm raw()", + "n": 100, + "median_ms": 1.146, + "vs_driver": 0.96 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm lean()", + "n": 100, + "median_ms": 5.1, + "vs_driver": 4.28 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm tracked", + "n": 100, + "median_ms": 4.955, + "vs_driver": 4.15 + }, + { + "shape": "nested", + "backend": "pymongo", + "case": "driver dicts", + "n": 10000, + "median_ms": 93.285, + "vs_driver": 1.0 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm raw()", + "n": 10000, + "median_ms": 97.613, + "vs_driver": 1.05 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm lean()", + "n": 10000, + "median_ms": 135.797, + "vs_driver": 1.46 + }, + { + "shape": "nested", + "backend": "msgspec", + "case": "odm tracked", + "n": 10000, + "median_ms": 150.438, + "vs_driver": 1.61 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm raw()", + "n": 1, + "median_ms": 0.49, + "vs_driver": 0.99 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm lean()", + "n": 1, + "median_ms": 0.512, + "vs_driver": 1.03 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm tracked", + "n": 1, + "median_ms": 0.521, + "vs_driver": 1.05 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm raw()", + "n": 100, + "median_ms": 1.503, + "vs_driver": 1.13 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm lean()", + "n": 100, + "median_ms": 2.651, + "vs_driver": 1.99 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm tracked", + "n": 100, + "median_ms": 2.782, + "vs_driver": 2.09 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm raw()", + "n": 10000, + "median_ms": 90.612, + "vs_driver": 0.93 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm lean()", + "n": 10000, + "median_ms": 262.019, + "vs_driver": 2.68 + }, + { + "shape": "nested", + "backend": "pydantic", + "case": "odm tracked", + "n": 10000, + "median_ms": 264.325, + "vs_driver": 2.7 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm raw()", + "n": 1, + "median_ms": 0.932, + "vs_driver": 0.9 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm lean()", + "n": 1, + "median_ms": 0.551, + "vs_driver": 0.53 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm tracked", + "n": 1, + "median_ms": 0.536, + "vs_driver": 0.52 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm raw()", + "n": 100, + "median_ms": 1.858, + "vs_driver": 1.17 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm lean()", + "n": 100, + "median_ms": 2.571, + "vs_driver": 1.62 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm tracked", + "n": 100, + "median_ms": 3.024, + "vs_driver": 1.91 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm raw()", + "n": 10000, + "median_ms": 87.638, + "vs_driver": 0.58 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm lean()", + "n": 10000, + "median_ms": 159.615, + "vs_driver": 1.06 + }, + { + "shape": "nested", + "backend": "attrs", + "case": "odm tracked", + "n": 10000, + "median_ms": 181.859, + "vs_driver": 1.21 + } + ], + "notes": [], + "conclusion": "" +} diff --git a/bench/results/6dc837f/B3-sharded.json b/bench/results/6dc837f/B3-sharded.json new file mode 100644 index 0000000..4cf5fea --- /dev/null +++ b/bench/results/6dc837f/B3-sharded.json @@ -0,0 +1,70 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": true, + "timestamp": "2026-10-13T04:55:34+00:00", + "topology": "sharded", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B3", + "title": "include(): $lookup vs batched $in", + "question": "Which strategy should include(strategy='auto') use, across fan-outs and topologies?", + "rows": [ + { + "relation": "list, distinct children", + "fan_out": 1, + "parents": 400, + "batch_ms": 9.173, + "lookup_ms": 43.112, + "lookup_vs_batch": 4.7 + }, + { + "relation": "list, distinct children", + "fan_out": 10, + "parents": 200, + "batch_ms": 26.31, + "lookup_ms": 130.27, + "lookup_vs_batch": 4.95 + }, + { + "relation": "list, distinct children", + "fan_out": 100, + "parents": 50, + "batch_ms": 52.175, + "lookup_ms": 70.531, + "lookup_vs_batch": 1.35 + }, + { + "relation": "list, distinct children", + "fan_out": 1000, + "parents": 10, + "batch_ms": 107.094, + "lookup_ms": 96.36, + "lookup_vs_batch": 0.9 + }, + { + "relation": "to-one, 20 shared targets", + "fan_out": 1, + "parents": 2000, + "batch_ms": 21.974, + "lookup_ms": 198.309, + "lookup_vs_batch": 9.02 + } + ], + "notes": [ + "Strategy choice does not depend on the model backend; measured with msgspec, lean reads." + ], + "conclusion": "$lookup was faster than batched $in in 1 of 5 cases on sharded (lookup/batch ratios: [4.7, 4.95, 1.35, 0.9, 9.02]). Current default: batch." +} diff --git a/bench/results/6dc837f/B3.json b/bench/results/6dc837f/B3.json new file mode 100644 index 0000000..d4c4a47 --- /dev/null +++ b/bench/results/6dc837f/B3.json @@ -0,0 +1,70 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": false, + "timestamp": "2026-10-13T04:45:53+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B3", + "title": "include(): $lookup vs batched $in", + "question": "Which strategy should include(strategy='auto') use, across fan-outs and topologies?", + "rows": [ + { + "relation": "list, distinct children", + "fan_out": 1, + "parents": 400, + "batch_ms": 7.674, + "lookup_ms": 8.341, + "lookup_vs_batch": 1.09 + }, + { + "relation": "list, distinct children", + "fan_out": 10, + "parents": 200, + "batch_ms": 17.435, + "lookup_ms": 19.091, + "lookup_vs_batch": 1.1 + }, + { + "relation": "list, distinct children", + "fan_out": 100, + "parents": 50, + "batch_ms": 43.373, + "lookup_ms": 54.28, + "lookup_vs_batch": 1.25 + }, + { + "relation": "list, distinct children", + "fan_out": 1000, + "parents": 10, + "batch_ms": 85.993, + "lookup_ms": 82.976, + "lookup_vs_batch": 0.96 + }, + { + "relation": "to-one, 20 shared targets", + "fan_out": 1, + "parents": 2000, + "batch_ms": 17.118, + "lookup_ms": 42.392, + "lookup_vs_batch": 2.48 + } + ], + "notes": [ + "Strategy choice does not depend on the model backend; measured with msgspec, lean reads." + ], + "conclusion": "$lookup was faster than batched $in in 0 of 5 cases on rs (lookup/batch ratios: [1.09, 1.1, 1.25, 0.96, 2.48]). Current default: batch." +} diff --git a/bench/results/6dc837f/B4.json b/bench/results/6dc837f/B4.json new file mode 100644 index 0000000..83220a7 --- /dev/null +++ b/bench/results/6dc837f/B4.json @@ -0,0 +1,198 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": false, + "timestamp": "2026-10-13T04:45:53+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B4", + "title": "Writes", + "question": "What does the ODM add to inserts, atomic patches, saves and upserts?", + "rows": [ + { + "backend": "msgspec", + "op": "insert 1000", + "case": "driver insert_many", + "median_ms": 11.347, + "vs_driver": 1.0 + }, + { + "backend": "msgspec", + "op": "insert 1000", + "case": "insert_many (no plugins)", + "median_ms": 12.212, + "vs_driver": 1.08 + }, + { + "backend": "msgspec", + "op": "insert 1000", + "case": "insert_many (timestamps+revision)", + "median_ms": 14.248, + "vs_driver": 1.26 + }, + { + "backend": "msgspec", + "op": "patch x200", + "case": "driver update_one", + "median_ms": 381.189, + "vs_driver": 1.0 + }, + { + "backend": "msgspec", + "op": "patch x200", + "case": "Model.update (returns doc)", + "median_ms": 424.609, + "vs_driver": 1.11 + }, + { + "backend": "msgspec", + "op": "patch x200", + "case": "doc.save() delta", + "median_ms": 588.127, + "vs_driver": 1.54 + }, + { + "backend": "msgspec", + "op": "upsert x200", + "case": "driver update_one(upsert)", + "median_ms": 692.723, + "vs_driver": 1.0 + }, + { + "backend": "msgspec", + "op": "upsert x200", + "case": "Model.upsert (returns doc)", + "median_ms": 913.096, + "vs_driver": 1.32 + }, + { + "backend": "pydantic", + "op": "insert 1000", + "case": "driver insert_many", + "median_ms": 11.164, + "vs_driver": 1.0 + }, + { + "backend": "pydantic", + "op": "insert 1000", + "case": "insert_many (no plugins)", + "median_ms": 12.583, + "vs_driver": 1.13 + }, + { + "backend": "pydantic", + "op": "insert 1000", + "case": "insert_many (timestamps+revision)", + "median_ms": 18.683, + "vs_driver": 1.67 + }, + { + "backend": "pydantic", + "op": "patch x200", + "case": "driver update_one", + "median_ms": 599.82, + "vs_driver": 1.0 + }, + { + "backend": "pydantic", + "op": "patch x200", + "case": "Model.update (returns doc)", + "median_ms": 775.698, + "vs_driver": 1.29 + }, + { + "backend": "pydantic", + "op": "patch x200", + "case": "doc.save() delta", + "median_ms": 638.09, + "vs_driver": 1.06 + }, + { + "backend": "pydantic", + "op": "upsert x200", + "case": "driver update_one(upsert)", + "median_ms": 834.695, + "vs_driver": 1.0 + }, + { + "backend": "pydantic", + "op": "upsert x200", + "case": "Model.upsert (returns doc)", + "median_ms": 932.299, + "vs_driver": 1.12 + }, + { + "backend": "attrs", + "op": "insert 1000", + "case": "driver insert_many", + "median_ms": 19.75, + "vs_driver": 1.0 + }, + { + "backend": "attrs", + "op": "insert 1000", + "case": "insert_many (no plugins)", + "median_ms": 12.879, + "vs_driver": 0.65 + }, + { + "backend": "attrs", + "op": "insert 1000", + "case": "insert_many (timestamps+revision)", + "median_ms": 15.12, + "vs_driver": 0.77 + }, + { + "backend": "attrs", + "op": "patch x200", + "case": "driver update_one", + "median_ms": 564.26, + "vs_driver": 1.0 + }, + { + "backend": "attrs", + "op": "patch x200", + "case": "Model.update (returns doc)", + "median_ms": 678.06, + "vs_driver": 1.2 + }, + { + "backend": "attrs", + "op": "patch x200", + "case": "doc.save() delta", + "median_ms": 511.812, + "vs_driver": 0.91 + }, + { + "backend": "attrs", + "op": "upsert x200", + "case": "driver update_one(upsert)", + "median_ms": 826.581, + "vs_driver": 1.0 + }, + { + "backend": "attrs", + "op": "upsert x200", + "case": "Model.upsert (returns doc)", + "median_ms": 918.963, + "vs_driver": 1.11 + } + ], + "notes": [ + "Model.update and Model.upsert use findAndModify to return the document; the driver baseline does not." + ], + "conclusion": "" +} diff --git a/bench/results/6dc837f/B5.json b/bench/results/6dc837f/B5.json new file mode 100644 index 0000000..6b78994 --- /dev/null +++ b/bench/results/6dc837f/B5.json @@ -0,0 +1,80 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": false, + "timestamp": "2026-10-13T04:45:53+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B5", + "title": "Pagination", + "question": "How do offset and cursor pagination compare at depth, and should a total come from $facet or a count?", + "rows": [ + { + "page": "1, index on (n) only", + "skip": 0, + "offset_ms": "", + "cursor_ms": 28.781, + "offset_vs_cursor": "" + }, + { + "page": 1, + "skip": 0, + "offset_ms": 0.575, + "cursor_ms": 0.581, + "offset_vs_cursor": 0.99 + }, + { + "page": 100, + "skip": 1980, + "offset_ms": 1.199, + "cursor_ms": 0.649, + "offset_vs_cursor": 1.85 + }, + { + "page": 1250, + "skip": 24980, + "offset_ms": 7.055, + "cursor_ms": 0.663, + "offset_vs_cursor": 10.64 + }, + { + "page": 2499, + "skip": 49960, + "offset_ms": 12.623, + "cursor_ms": 0.669, + "offset_vs_cursor": 18.86 + }, + { + "page": "total: concurrent count", + "skip": 0, + "offset_ms": 6.025, + "cursor_ms": "", + "offset_vs_cursor": "" + }, + { + "page": "total: $facet (raw driver)", + "skip": 0, + "offset_ms": 28.463, + "cursor_ms": "", + "offset_vs_cursor": "" + } + ], + "notes": [ + "Collection of 50000 documents, page size 20; index on (n, _id) unless stated.", + "$facet returns the page and the total as one document, so the page is bound by the 16MB limit." + ], + "conclusion": "" +} diff --git a/bench/results/6dc837f/B6.json b/bench/results/6dc837f/B6.json new file mode 100644 index 0000000..56d7113 --- /dev/null +++ b/bench/results/6dc837f/B6.json @@ -0,0 +1,90 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": false, + "timestamp": "2026-10-13T04:45:53+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B6", + "title": "Plugin overhead", + "question": "What does each active plugin cost per operation?", + "rows": [ + { + "plugins": "0 no-op", + "op": "insert", + "us_per_op": 2494.2, + "vs_no_plugins": 1.0 + }, + { + "plugins": "0 no-op", + "op": "save", + "us_per_op": 3044.1, + "vs_no_plugins": 1.0 + }, + { + "plugins": "0 no-op", + "op": "get", + "us_per_op": 422.3, + "vs_no_plugins": 1.0 + }, + { + "plugins": "3 no-op", + "op": "insert", + "us_per_op": 2935.7, + "vs_no_plugins": 1.18 + }, + { + "plugins": "3 no-op", + "op": "save", + "us_per_op": 3666.9, + "vs_no_plugins": 1.2 + }, + { + "plugins": "3 no-op", + "op": "get", + "us_per_op": 427.9, + "vs_no_plugins": 1.01 + }, + { + "plugins": "10 no-op", + "op": "insert", + "us_per_op": 2348.5, + "vs_no_plugins": 0.94 + }, + { + "plugins": "10 no-op", + "op": "save", + "us_per_op": 3092.5, + "vs_no_plugins": 1.02 + }, + { + "plugins": "10 no-op", + "op": "get", + "us_per_op": 425.7, + "vs_no_plugins": 1.01 + }, + { + "plugins": "timestamps+revision", + "op": "insert", + "us_per_op": 3077.7, + "vs_no_plugins": 1.23 + } + ], + "notes": [ + "Each operation is one round trip to MongoDB, which dominates; plugin calls are in-process." + ], + "conclusion": "" +} diff --git a/bench/results/6dc837f/B8.json b/bench/results/6dc837f/B8.json new file mode 100644 index 0000000..7ab4fce --- /dev/null +++ b/bench/results/6dc837f/B8.json @@ -0,0 +1,151 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": false, + "timestamp": "2026-10-13T04:45:53+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B8", + "title": "Cost of change tracking", + "question": "What do tracked reads cost in time and memory over lean and raw reads, and what does a delta save send?", + "rows": [ + { + "backend": "msgspec", + "mode": "raw()", + "n": 5000, + "median_ms": 41.42, + "vs_lean": 0.54, + "bytes_per_doc": 5263 + }, + { + "backend": "msgspec", + "mode": "lean()", + "n": 5000, + "median_ms": 76.11, + "vs_lean": 1.0, + "bytes_per_doc": 2398 + }, + { + "backend": "msgspec", + "mode": "tracked", + "n": 5000, + "median_ms": 63.1, + "vs_lean": 0.83, + "bytes_per_doc": 3625 + }, + { + "backend": "msgspec", + "mode": "save payload: full $set", + "n": 1, + "median_ms": "", + "vs_lean": "", + "bytes_per_doc": 659 + }, + { + "backend": "msgspec", + "mode": "save payload: delta", + "n": 1, + "median_ms": "", + "vs_lean": "", + "bytes_per_doc": 38 + }, + { + "backend": "pydantic", + "mode": "raw()", + "n": 5000, + "median_ms": 41.34, + "vs_lean": 0.34, + "bytes_per_doc": 5263 + }, + { + "backend": "pydantic", + "mode": "lean()", + "n": 5000, + "median_ms": 121.05, + "vs_lean": 1.0, + "bytes_per_doc": 7103 + }, + { + "backend": "pydantic", + "mode": "tracked", + "n": 5000, + "median_ms": 126.49, + "vs_lean": 1.04, + "bytes_per_doc": 8329 + }, + { + "backend": "pydantic", + "mode": "save payload: full $set", + "n": 1, + "median_ms": "", + "vs_lean": "", + "bytes_per_doc": 659 + }, + { + "backend": "pydantic", + "mode": "save payload: delta", + "n": 1, + "median_ms": "", + "vs_lean": "", + "bytes_per_doc": 38 + }, + { + "backend": "attrs", + "mode": "raw()", + "n": 5000, + "median_ms": 48.21, + "vs_lean": 0.57, + "bytes_per_doc": 5263 + }, + { + "backend": "attrs", + "mode": "lean()", + "n": 5000, + "median_ms": 84.78, + "vs_lean": 1.0, + "bytes_per_doc": 2543 + }, + { + "backend": "attrs", + "mode": "tracked", + "n": 5000, + "median_ms": 89.97, + "vs_lean": 1.06, + "bytes_per_doc": 3768 + }, + { + "backend": "attrs", + "mode": "save payload: full $set", + "n": 1, + "median_ms": "", + "vs_lean": "", + "bytes_per_doc": 659 + }, + { + "backend": "attrs", + "mode": "save payload: delta", + "n": 1, + "median_ms": "", + "vs_lean": "", + "bytes_per_doc": 38 + } + ], + "notes": [ + "1 tracked documents remained after the run (should be ~0).", + "bytes_per_doc for reads is Python heap per document held; for save payloads it is the BSON update size." + ], + "conclusion": "" +} diff --git a/bench/results/6dc837f/LOAD.json b/bench/results/6dc837f/LOAD.json new file mode 100644 index 0000000..2465342 --- /dev/null +++ b/bench/results/6dc837f/LOAD.json @@ -0,0 +1,169 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": true, + "timestamp": "2026-10-13T04:56:11+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "LOAD", + "title": "Storefront under HTTP load", + "question": "What do mongospecs and mongospecs-web cost per request in a real server, against the same endpoints written by hand with pymongo?", + "rows": [ + { + "app": "raw pymongo + starlette", + "server_cpu_ms_per_request": 0.631, + "rps_at_saturation": 1511.5, + "server_cpu_utilisation": 0.95, + "list_p50_ms": 1.2, + "list_p95_ms": 3.63, + "detail_p50_ms": 2.11, + "detail_p95_ms": 6.65, + "order_p50_ms": 3.74, + "order_p95_ms": 10.31, + "saturated_p99_ms": 150.5, + "failed": 0, + "orders_placed": 3079, + "cpu_vs_raw": 1.0, + "rps_vs_raw": 1.0 + }, + { + "app": "mongospecs (msgspec, asgi)", + "server_cpu_ms_per_request": 0.764, + "rps_at_saturation": 1257.2, + "server_cpu_utilisation": 0.96, + "list_p50_ms": 1.34, + "list_p95_ms": 4.36, + "detail_p50_ms": 2.46, + "detail_p95_ms": 9.21, + "order_p50_ms": 4.15, + "order_p95_ms": 13.87, + "saturated_p99_ms": 182.9, + "failed": 0, + "orders_placed": 2520, + "cpu_vs_raw": 1.21, + "rps_vs_raw": 0.83 + }, + { + "app": "mongospecs (msgspec, starlette)", + "server_cpu_ms_per_request": 0.796, + "rps_at_saturation": 1211.1, + "server_cpu_utilisation": 0.96, + "list_p50_ms": 1.45, + "list_p95_ms": 5.24, + "detail_p50_ms": 2.66, + "detail_p95_ms": 10.82, + "order_p50_ms": 4.79, + "order_p95_ms": 13.75, + "saturated_p99_ms": 194.6, + "failed": 0, + "orders_placed": 2421, + "cpu_vs_raw": 1.26, + "rps_vs_raw": 0.8 + }, + { + "app": "mongospecs (msgspec, fastapi)", + "server_cpu_ms_per_request": 0.877, + "rps_at_saturation": 1109.8, + "server_cpu_utilisation": 0.97, + "list_p50_ms": 1.46, + "list_p95_ms": 3.57, + "detail_p50_ms": 2.63, + "detail_p95_ms": 6.73, + "order_p50_ms": 4.66, + "order_p95_ms": 10.29, + "saturated_p99_ms": 195.4, + "failed": 0, + "orders_placed": 2341, + "cpu_vs_raw": 1.39, + "rps_vs_raw": 0.73 + }, + { + "app": "mongospecs (msgspec, litestar)", + "server_cpu_ms_per_request": 0.785, + "rps_at_saturation": 1239.0, + "server_cpu_utilisation": 0.97, + "list_p50_ms": 1.37, + "list_p95_ms": 4.87, + "detail_p50_ms": 2.48, + "detail_p95_ms": 10.36, + "order_p50_ms": 3.95, + "order_p95_ms": 15.25, + "saturated_p99_ms": 172.6, + "failed": 0, + "orders_placed": 2493, + "cpu_vs_raw": 1.24, + "rps_vs_raw": 0.82 + }, + { + "app": "mongospecs (msgspec, quart)", + "server_cpu_ms_per_request": 0.842, + "rps_at_saturation": 1171.0, + "server_cpu_utilisation": 0.99, + "list_p50_ms": 1.64, + "list_p95_ms": 4.09, + "detail_p50_ms": 2.96, + "detail_p95_ms": 9.69, + "order_p50_ms": 5.56, + "order_p95_ms": 12.82, + "saturated_p99_ms": 168.2, + "failed": 0, + "orders_placed": 2394, + "cpu_vs_raw": 1.33, + "rps_vs_raw": 0.77 + }, + { + "app": "mongospecs (pydantic, asgi)", + "server_cpu_ms_per_request": 0.763, + "rps_at_saturation": 1288.0, + "server_cpu_utilisation": 0.98, + "list_p50_ms": 1.49, + "list_p95_ms": 3.97, + "detail_p50_ms": 2.62, + "detail_p95_ms": 7.34, + "order_p50_ms": 4.4, + "order_p95_ms": 10.56, + "saturated_p99_ms": 146.9, + "failed": 0, + "orders_placed": 2611, + "cpu_vs_raw": 1.21, + "rps_vs_raw": 0.85 + }, + { + "app": "mongospecs (attrs, asgi)", + "server_cpu_ms_per_request": 0.783, + "rps_at_saturation": 1245.2, + "server_cpu_utilisation": 0.97, + "list_p50_ms": 1.43, + "list_p95_ms": 4.15, + "detail_p50_ms": 2.53, + "detail_p95_ms": 7.71, + "order_p50_ms": 3.99, + "order_p95_ms": 11.31, + "saturated_p99_ms": 166.6, + "failed": 0, + "orders_placed": 2560, + "cpu_vs_raw": 1.24, + "rps_vs_raw": 0.82 + } + ], + "notes": [ + "Mix {'list': 60, 'detail': 30, 'order': 10}; 5000 products with 3 reviews each; one uvicorn worker. Latency columns: 2 virtual users for 5s (an unloaded server). Capacity columns: 64 virtual users for 15s.", + "server_cpu_ms_per_request is CPU time of the server process divided by requests served: it does not depend on the load generator, which runs on the same machine.", + "The stock invariant (stock + units sold = initial stock, never negative) held after every run.", + "The raw baseline implements the same three endpoints by hand, including the order transaction." + ], + "conclusion": "" +} diff --git a/bench/results/6dc837f/X1.json b/bench/results/6dc837f/X1.json new file mode 100644 index 0000000..bbb6b4d --- /dev/null +++ b/bench/results/6dc837f/X1.json @@ -0,0 +1,48 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": true, + "timestamp": "2026-10-13T04:59:26+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "X1", + "title": "Many tasks, small pool", + "question": "Do thousands of concurrent tasks finish on a pool of 5, and do pool waits surface as typed errors?", + "rows": [ + { + "check": "tasks completed", + "value": 3000 + }, + { + "check": "documents written", + "value": 3000 + }, + { + "check": "pool waits raised as PoolTimeout", + "value": 5 + }, + { + "check": "untyped errors", + "value": 0 + }, + { + "check": "verdict", + "value": "pass" + } + ], + "notes": [], + "conclusion": "PASS: 3000 tasks completed on 5 connections; starved pool raised PoolTimeout 5 times" +} diff --git a/bench/results/6dc837f/X2.json b/bench/results/6dc837f/X2.json new file mode 100644 index 0000000..a9fb108 --- /dev/null +++ b/bench/results/6dc837f/X2.json @@ -0,0 +1,44 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": true, + "timestamp": "2026-10-13T04:59:26+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "X2", + "title": "Transaction contention", + "question": "Do contended read-modify-write transactions lose updates, and are retries bounded?", + "rows": [ + { + "check": "workers", + "value": 40 + }, + { + "check": "final counter", + "value": 40 + }, + { + "check": "callback runs (incl. retries)", + "value": 214 + }, + { + "check": "verdict", + "value": "pass" + } + ], + "notes": [], + "conclusion": "PASS: counter reached 40/40 with 174 retries" +} diff --git a/bench/results/6dc837f/X3.json b/bench/results/6dc837f/X3.json new file mode 100644 index 0000000..71fb522 --- /dev/null +++ b/bench/results/6dc837f/X3.json @@ -0,0 +1,48 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": true, + "timestamp": "2026-10-13T04:59:26+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "X3", + "title": "Optimistic-lock storm", + "question": "With many writers on one revisioned document, does exactly one win per revision?", + "rows": [ + { + "check": "winners of the first round", + "value": 1 + }, + { + "check": "final n", + "value": 41 + }, + { + "check": "final rev", + "value": 41 + }, + { + "check": "conflicts retried", + "value": 332 + }, + { + "check": "verdict", + "value": "pass" + } + ], + "notes": [], + "conclusion": "PASS: 1 winner in the first round; after retries n=41, rev=41 for 40 writers" +} diff --git a/bench/results/6dc837f/X4.json b/bench/results/6dc837f/X4.json new file mode 100644 index 0000000..763dc68 --- /dev/null +++ b/bench/results/6dc837f/X4.json @@ -0,0 +1,48 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": true, + "timestamp": "2026-10-13T04:59:26+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "X4", + "title": "Cancellation", + "question": "Does cancelling tasks mid-operation and mid-transaction leak sessions, transactions or tracked state?", + "rows": [ + { + "check": "tasks cancelled", + "value": 141 + }, + { + "check": "unexpected error types", + "value": 0 + }, + { + "check": "transactions still open on the server", + "value": 0 + }, + { + "check": "tracked documents leaked", + "value": 0 + }, + { + "check": "verdict", + "value": "pass" + } + ], + "notes": [], + "conclusion": "PASS: 141 of 200 tasks cancelled; 0 open transactions afterwards" +} diff --git a/bench/results/6dc837f/X5.json b/bench/results/6dc837f/X5.json new file mode 100644 index 0000000..a0176ff --- /dev/null +++ b/bench/results/6dc837f/X5.json @@ -0,0 +1,40 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": true, + "timestamp": "2026-10-13T04:59:26+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "X5", + "title": "Result documents near 16MB", + "question": "What happens when a $lookup would build a document over the BSON limit?", + "rows": [ + { + "check": "$lookup outcome", + "value": "DocumentTooLarge" + }, + { + "check": "batch strategy loaded children", + "value": 18 + }, + { + "check": "verdict", + "value": "pass" + } + ], + "notes": [], + "conclusion": "PASS: $lookup over the limit -> DocumentTooLarge; batch strategy loaded all 18 children" +} diff --git a/bench/results/6dc837f/X6.json b/bench/results/6dc837f/X6.json new file mode 100644 index 0000000..b16a592 --- /dev/null +++ b/bench/results/6dc837f/X6.json @@ -0,0 +1,44 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": true, + "timestamp": "2026-10-13T04:59:26+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "X6", + "title": "Change stream across a primary step-down", + "question": "Does a consumer see every event exactly once across a step-down?", + "rows": [ + { + "check": "step-down", + "value": "stepped down" + }, + { + "check": "events seen", + "value": 20 + }, + { + "check": "duplicates", + "value": 0 + }, + { + "check": "verdict", + "value": "pass" + } + ], + "notes": [], + "conclusion": "PASS: 20 of 20 events seen in order across the step-down (stepped down)" +} diff --git a/bench/results/6dc837f/X7.json b/bench/results/6dc837f/X7.json new file mode 100644 index 0000000..bc2b5a8 --- /dev/null +++ b/bench/results/6dc837f/X7.json @@ -0,0 +1,44 @@ +{ + "meta": { + "git_sha": "6dc837f", + "git_dirty": true, + "timestamp": "2026-10-13T04:59:26+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "X7", + "title": "Threads with their own loops and registries", + "question": "Is any state shared across registries when each thread runs its own event loop?", + "rows": [ + { + "check": "free-threaded build", + "value": false + }, + { + "check": "threads", + "value": 8 + }, + { + "check": "errors", + "value": 0 + }, + { + "check": "verdict", + "value": "pass" + } + ], + "notes": [], + "conclusion": "PASS: 8 threads x 200 documents, each with its own loop and registry; free-threaded=False" +} diff --git a/bench/results/6dc837f/mongoose.json b/bench/results/6dc837f/mongoose.json new file mode 100644 index 0000000..9c2c555 --- /dev/null +++ b/bench/results/6dc837f/mongoose.json @@ -0,0 +1,258 @@ +{ + "meta": { + "git_sha": "6dc837f", + "timestamp": "2026-10-13T05:00:39.969Z", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "node": "v22.14.0", + "versions": { + "mongoose": "8.24.5", + "driver": "bundled with mongoose" + }, + "platform": "darwin-arm64" + }, + "workload": "E2", + "title": "Mongoose comparator", + "question": "What is Mongoose's overhead over the raw Node driver for the same workloads?", + "rows": [ + { + "workload": "B1", + "shape": "small", + "case": "driver bson->object", + "n": 10000, + "us_per_doc": 0.78, + "vs_driver": 1 + }, + { + "workload": "B1", + "shape": "small", + "case": "Model.hydrate", + "n": 10000, + "us_per_doc": 2.08, + "vs_driver": 2.67 + }, + { + "workload": "B1", + "shape": "nested", + "case": "driver bson->object", + "n": 10000, + "us_per_doc": 5.07, + "vs_driver": 1 + }, + { + "workload": "B1", + "shape": "nested", + "case": "Model.hydrate", + "n": 10000, + "us_per_doc": 23.86, + "vs_driver": 4.71 + }, + { + "workload": "B2", + "shape": "small", + "case": "driver objects", + "n": 1, + "median_ms": 0.42, + "vs_driver": 1 + }, + { + "workload": "B2", + "shape": "small", + "case": "mongoose lean()", + "n": 1, + "median_ms": 0.442, + "vs_driver": 1.05 + }, + { + "workload": "B2", + "shape": "small", + "case": "mongoose documents", + "n": 1, + "median_ms": 0.439, + "vs_driver": 1.05 + }, + { + "workload": "B2", + "shape": "small", + "case": "driver objects", + "n": 100, + "median_ms": 0.897, + "vs_driver": 1 + }, + { + "workload": "B2", + "shape": "small", + "case": "mongoose lean()", + "n": 100, + "median_ms": 0.72, + "vs_driver": 0.8 + }, + { + "workload": "B2", + "shape": "small", + "case": "mongoose documents", + "n": 100, + "median_ms": 0.756, + "vs_driver": 0.84 + }, + { + "workload": "B2", + "shape": "small", + "case": "driver objects", + "n": 10000, + "median_ms": 63.424, + "vs_driver": 1 + }, + { + "workload": "B2", + "shape": "small", + "case": "mongoose lean()", + "n": 10000, + "median_ms": 22.648, + "vs_driver": 0.36 + }, + { + "workload": "B2", + "shape": "small", + "case": "mongoose documents", + "n": 10000, + "median_ms": 53.451, + "vs_driver": 0.84 + }, + { + "workload": "B2", + "shape": "nested", + "case": "driver objects", + "n": 1, + "median_ms": 0.331, + "vs_driver": 1 + }, + { + "workload": "B2", + "shape": "nested", + "case": "mongoose lean()", + "n": 1, + "median_ms": 0.401, + "vs_driver": 1.21 + }, + { + "workload": "B2", + "shape": "nested", + "case": "mongoose documents", + "n": 1, + "median_ms": 0.588, + "vs_driver": 1.78 + }, + { + "workload": "B2", + "shape": "nested", + "case": "driver objects", + "n": 100, + "median_ms": 0.948, + "vs_driver": 1 + }, + { + "workload": "B2", + "shape": "nested", + "case": "mongoose lean()", + "n": 100, + "median_ms": 0.91, + "vs_driver": 0.96 + }, + { + "workload": "B2", + "shape": "nested", + "case": "mongoose documents", + "n": 100, + "median_ms": 3.91, + "vs_driver": 4.13 + }, + { + "workload": "B2", + "shape": "nested", + "case": "driver objects", + "n": 10000, + "median_ms": 92.983, + "vs_driver": 1 + }, + { + "workload": "B2", + "shape": "nested", + "case": "mongoose lean()", + "n": 10000, + "median_ms": 81.711, + "vs_driver": 0.88 + }, + { + "workload": "B2", + "shape": "nested", + "case": "mongoose documents", + "n": 10000, + "median_ms": 352.387, + "vs_driver": 3.79 + }, + { + "workload": "B4", + "op": "insert 1000", + "case": "driver insertMany", + "median_ms": 10.228, + "vs_driver": 1 + }, + { + "workload": "B4", + "op": "insert 1000", + "case": "Model.insertMany (no plugins)", + "median_ms": 14.665, + "vs_driver": 1.43 + }, + { + "workload": "B4", + "op": "insert 1000", + "case": "Model.insertMany (timestamps+versioning)", + "median_ms": 20.153, + "vs_driver": 1.97 + }, + { + "workload": "B4", + "op": "patch x200", + "case": "driver updateOne", + "median_ms": 703.776, + "vs_driver": 1 + }, + { + "workload": "B4", + "op": "patch x200", + "case": "findByIdAndUpdate (returns doc)", + "median_ms": 568.354, + "vs_driver": 0.81 + }, + { + "workload": "B4", + "op": "patch x200", + "case": "doc.save() delta", + "median_ms": 802.97, + "vs_driver": 1.14 + }, + { + "workload": "B8", + "mode": "lean()", + "n": 5000, + "median_ms": 36.59, + "vs_lean": 1, + "bytes_per_doc": 1910 + }, + { + "workload": "B8", + "mode": "documents (tracked)", + "n": 5000, + "median_ms": 171.12, + "vs_lean": 4.68, + "bytes_per_doc": 7168 + } + ], + "notes": [ + "Ratios are against the raw Node driver in the same process. Compare them with mongospecs ratios against pymongo, not absolute times." + ], + "conclusion": "" +} diff --git a/bench/src/mongospecs_bench/workloads.py b/bench/src/mongospecs_bench/workloads.py index e6d4c55..6c6870f 100644 --- a/bench/src/mongospecs_bench/workloads.py +++ b/bench/src/mongospecs_bench/workloads.py @@ -1,4 +1,7 @@ -"""Benchmark workloads B1-B8 (plan section 6). Each returns a :class:`Result`. +"""Benchmark workloads B1-B6 and B8 (plan section 6). Each returns a :class:`Result`. + +B7 (HTTP per plug) is the ``LOAD`` workload in :mod:`mongospecs_bench.load`: measuring plugs through +an in-process test client mostly measured the client. Everything that touches the network runs against real MongoDB. Numbers from a ``--quick`` run only prove the harness works; they are not for conclusions. @@ -11,13 +14,11 @@ import typing as t import bson -import httpx from mongospecs import Inc, Plugin, Q, meta_of, relations, tracking from mongospecs.query import _encode_cursor from mongospecs_bench.harness import Result, Settings, environment, measure, measure_sync from mongospecs_bench.models import MODELS, SHAPES, model_for, raw_document -from mongospecs_web import Api, Resource Workload = t.Callable[[Settings], t.Awaitable[Result]] @@ -476,99 +477,6 @@ async def managed_inserts() -> None: return result -# -- B7 ------------------------------------------------------------------------------- - - -def _apps(api: Api) -> dict[str, t.Any]: - from fastapi import FastAPI - from litestar import Litestar - from quart import Quart - from starlette.applications import Starlette - - from mongospecs_web.plugs import fastapi as fastapi_plug - from mongospecs_web.plugs import quart as quart_plug - from mongospecs_web.plugs import starlette as starlette_plug - from mongospecs_web.plugs.litestar import MongospecsPlugin - - fast = FastAPI() - fastapi_plug.mount(fast, api) - quart = Quart(__name__) - quart_plug.init_app(quart, api) - return { - "asgi": api.asgi(), - "starlette": Starlette(routes=starlette_plug.routes(api)), - "fastapi": fast, - "litestar": Litestar(plugins=[MongospecsPlugin(api)], openapi_config=None), - "quart": quart, - } - - -async def b7(settings: Settings) -> Result: - result = Result( - "B7", - "HTTP request to response", - "What does each plug add over calling the API core directly, per model backend?", - ) - calls = settings.scale(200, 10) - async with environment(settings) as registry: - for backend in settings.backends: - ns = MODELS[backend] - manager = registry.manager(ns.Small) - await manager.collection.delete_many({}) - await manager.collection.insert_many([raw_document("small", i) for i in range(200)]) - first = await manager.collection.find_one({}) - assert first is not None - from mongospecs_web.backend import MongoBackend - - api = Api( - Resource(ns.Small, name="small", backend=MongoBackend(ns.Small, manager)), prefix="/api" - ) - one, many = f"/api/small/{first['_id']}", "/api/small?limit=20" - - async def direct(path: str) -> None: - for _ in range(calls): - await api.handle( - "GET", path.split("?")[0], query=[("limit", "20")] if "?" in path else [] - ) - - base = { - "get one": await measure(lambda: direct(one), repeat=settings.repeat), - "list 20": await measure(lambda: direct(many), repeat=settings.repeat), - } - for case, timing in base.items(): - result.add( - backend=backend, - plug="(core, no HTTP)", - case=case, - us_per_request=round(timing.median_ms * 1000 / calls, 1), - vs_core=1.0, - ) - for plug, app in _apps(api).items(): - async with httpx.AsyncClient( - transport=httpx.ASGITransport(app=app), base_url="http://bench" - ) as client: - - async def hit(path: str) -> None: - for _ in range(calls): - response = await client.get(path) - assert response.status_code == 200, response.text - - for case, path in (("get one", one), ("list 20", many)): - timing = await measure(lambda: hit(path), repeat=settings.repeat) - per = timing.median_ms * 1000 / calls - result.add( - backend=backend, - plug=plug, - case=case, - us_per_request=round(per, 1), - vs_core=_ratio(per, base[case].median_ms * 1000 / calls), - ) - result.notes.append( - "Requests go through httpx's in-process ASGI transport, so client overhead is included equally for every plug." - ) - return result - - # -- B8 ------------------------------------------------------------------------------- @@ -651,6 +559,5 @@ async def b8(settings: Settings) -> Result: "B4": b4, "B5": b5, "B6": b6, - "B7": b7, "B8": b8, } diff --git a/claudedocs/v2-plan.md b/claudedocs/v2-plan.md index 4809fd0..8c69b01 100644 --- a/claudedocs/v2-plan.md +++ b/claudedocs/v2-plan.md @@ -873,6 +873,9 @@ Recorded per §7.1 rule 4. Each one names what changed and why; none invalidates | A19 | §4.6 | Cursor pagination needs an index ending in `_id`; with an index on the sort key alone the server sorts in memory | Measured (B5): 20ms vs under 1ms for the first page of 50k documents | | A20 | §4.8 | `Resource(server_fields=...)` sets values on create outside the field policy | Needed by the reference application (the author of a review) | | A21 | §6 | The evidence track gained a production-shaped reference application ("storefront"), end-to-end tests that boot it as a real server process, and an HTTP load benchmark with a hand-written pymongo baseline (nodes E4-E6) | Requested by Jay 2026-10-13: real end-to-end, production-ready scenarios | +| A22 | §6 | Workload B7 (HTTP per plug) is the `LOAD` workload: a real server process under load, with server CPU per request as the primary metric | Measuring plugs through an in-process test client, and later through httpx over TCP, measured the client | +| A23 | §4.5 | An upsert gives the inserted document the model's non-null defaults through `$setOnInsert` | Otherwise a document created by upsert differs in shape from one created by `insert()` and queries on defaulted values miss it (Mongoose: `setDefaultsOnInsert`) | +| A24 | §4.2 | Subclass discovery ignores stale class copies | `attrs.define` replaces a class with a slotted copy and the original lingers until collected; found by the free-threaded CI job | New node: @@ -887,3 +890,12 @@ Bugs the end-to-end tests found that the unit and contract suites had not: aggre (`aggregate`, `union`, `graph`, backref counts) broke whenever a default time limit was configured; string ids assigned to reference fields were stored as strings on msgspec and attrs; unexpected server errors were not logged anywhere. + +### Decisions taken from the evidence (see `docs/benchmarks.md`, results `6dc837f`) + +| Node | Decision | Basis | +|---|---|---| +| R6 | `include(strategy="auto")` uses batched queries. `$lookup` stays available explicitly | `$lookup` never won clearly on a replica set (two runs disagreed at fan-out 100), was 2.5-3x slower when many parents share few targets, and 5-9x slower through mongos for small fan-outs | +| D1 | No Rust in 0.2. The only candidate worth a native component is a BSON-to-model decoder for bulk reads, as a separate optional package behind `Codec.decode(bytes)` | BSON-to-dict is essentially all of the driver's time on bulk reads (10.1 us per nested document against 3.3 us for msgspec's dict-to-model step) but an estimated 10-20% of server CPU on request-sized reads. Needs Jay's call on whether bulk reads matter enough | +| Open decision 4 | Reads validate; the `trusted` option is removed | Measured slower than validating on pydantic, no effect elsewhere | +| Open decision 6 | Tracked stays the default read mode | 0-15% over lean and about 1.2 KB per document after the raw-BSON snapshot change | diff --git a/docs/benchmarks.md b/docs/benchmarks.md index 649d4df..b01f216 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -1 +1,253 @@ -placeholder until results exist +# Benchmarks + +Everything here was measured against real MongoDB by the unpublished `bench/` package. Raw result +files are in `bench/results//`; the numbers below are from `6dc837f`. + +**Environment.** MongoDB 8.0.32 in Docker (single-node replica set; a two-shard cluster where +stated), Python 3.13.5, pymongo 4.18.3, msgspec 0.22, pydantic 2.14, Apple Silicon (8 cores), +client and server on the same machine. + +**How to read the numbers.** + +- Absolute times depend on the machine. The comparisons that travel are ratios against the raw + driver in the same process. +- Run-to-run variation on this setup is large: the same driver baseline moved by ±30% between runs. + Treat a ratio within about 0.8–1.25 as "no difference". Conclusions below rest only on effects + that were larger than that and repeated across runs. +- Cross-runtime absolute times (Python vs Node) are not comparable; overhead ratios are. + +Reproduce with `make up` and `uv run mongospecs-bench run`, `run LOAD`, `stress`. + +## Summary + +| Question | Answer | +|---|---| +| What does a request cost in a real server compared with hand-written pymongo? | About 1.2× the server CPU per request (0.76 ms vs 0.63 ms), 0.83× the throughput at saturation | +| What do typed reads cost over the driver's dicts? | `raw()`: nothing measurable. `lean()`: 1.1–1.7× with msgspec and attrs, about 2.6× with pydantic, on 10,000 nested documents | +| What does change tracking cost? | 0–15% over a lean read and about 1.2 KB per document; a save sends 38 bytes instead of 659 | +| How does that compare with Mongoose? | Mongoose's tracked documents cost 3.8× its driver on the same workload and 3.75× the memory of lean; mongospecs' are 1.6× (msgspec) to 2.7× (pydantic) and 1.2–1.5× the memory | +| `$lookup` or batched queries for `include()`? | Batched. `$lookup` never won clearly; on a sharded cluster it was 5–9× slower for small fan-outs | +| Offset or cursor pagination? | Cursor, with an index ending in `_id`: flat 0.6 ms at any depth against 12.6 ms for offset at the last page | +| Do plugins cost anything? | Not measurably: within noise of zero for 10 plugins | +| Is Rust warranted? | Only for one thing, and only for bulk reads; see [the Rust question](#the-rust-question) | + +## Requests in a real server + +The storefront application runs under uvicorn (one worker) and is driven over TCP with a mix of +60% product listings (filter, sort, cursor page of 20), 30% product detail with reviews and a +review count, and 10% orders (a multi-document transaction). The baseline implements the same three +endpoints by hand with pymongo and Starlette, including the transaction. + +Latency is measured with 2 virtual users (an unloaded server), capacity with 64. The primary metric +is CPU time of the server process per request, which does not depend on the load generator. + +| Server | CPU ms / request | vs raw | Requests/s at saturation | list p50 | detail p50 | order p50 | +|---|---|---|---|---|---|---| +| raw pymongo + Starlette | 0.631 | 1.00 | 1512 | 1.20 ms | 2.11 ms | 3.74 ms | +| mongospecs, msgspec, pure ASGI | 0.764 | 1.21 | 1257 | 1.34 ms | 2.46 ms | 4.15 ms | +| mongospecs, msgspec, Litestar | 0.785 | 1.24 | 1239 | 1.37 ms | 2.48 ms | 3.95 ms | +| mongospecs, msgspec, Starlette | 0.796 | 1.26 | 1211 | 1.45 ms | 2.66 ms | 4.79 ms | +| mongospecs, msgspec, Quart | 0.842 | 1.33 | 1171 | 1.64 ms | 2.96 ms | 5.56 ms | +| mongospecs, msgspec, FastAPI | 0.877 | 1.39 | 1110 | 1.46 ms | 2.63 ms | 4.66 ms | +| mongospecs, pydantic, pure ASGI | 0.763 | 1.21 | 1288 | 1.49 ms | 2.62 ms | 4.40 ms | +| mongospecs, attrs, pure ASGI | 0.783 | 1.24 | 1245 | 1.43 ms | 2.53 ms | 3.99 ms | + +No request failed in any run, and after every run the stock invariant held: for every product, +stock plus units on placed orders equals the initial stock, and no stock is negative. + +What the extra 0.13 ms buys: validated typed models, the field policy, allowlisted filters, scopes, +ETags, problem documents and OpenAPI. In a profile of a listing the driver itself is the largest +part of the request; decoding and serialising the 20 documents comes next. + +A first version of this benchmark used httpx as the load generator and reported both servers at +about 330 requests/s. That was the client's ceiling, not the servers': the server sat at 10–30% +CPU. The generator was replaced with a minimal keep-alive client and CPU per request became the +primary metric. + +## Reads + +10,000 documents fetched with `find()`, median of 15 runs, ratio to the driver returning dicts in +the same loop. "Nested" documents have a sub-document, a list of 8 sub-documents, a list and a map. + +| Mode | msgspec | pydantic | attrs | +|---|---|---|---| +| `raw()` | 1.05 | 0.93 | 0.58–1.04 | +| `lean()` | 1.46 | 2.68 | 1.06–1.71 | +| tracked (default) | 1.61 | 2.70 | 1.21–1.93 | + +`raw()` is the driver. The cost of `lean()` is the backend's own validation: msgspec converts a +nested document in 3.3 µs, attrs (through msgspec) in 8.3 µs, pydantic in 15.9 µs; the driver's +BSON-to-dict step before that takes 10.1 µs. + +## Change tracking + +5,000 nested documents: + +| Backend | lean | tracked | Heap per document, lean → tracked | +|---|---|---|---| +| msgspec | 76 ms | 63 ms | 2.4 KB → 3.6 KB | +| pydantic | 121 ms | 126 ms | 7.1 KB → 8.3 KB | +| attrs | 85 ms | 90 ms | 2.5 KB → 3.8 KB | + +A tracked document keeps the raw BSON bytes it was read from (about 1.2 KB here) and nothing else; +the comparable snapshot is built only if the document is saved. Changing one field and saving sends +a 38-byte update where a full `$set` would send 659 bytes. + +This is the result of an optimisation made during this work. The first implementation re-encoded +every loaded document to build the snapshot up front: + +| 10,000 nested documents, msgspec | Before (`4f3b2c1`) | After (`6dc837f`) | +|---|---|---| +| tracked read | 428 ms (4.3× driver) | 150 ms (1.6× driver) | +| lean read | 155 ms | 136 ms | + +The same pass replaced one-await-per-document cursor iteration with batch fetches and removed a +"trusted read" option whose pydantic fast path measured slower than validating (17 µs against 5 µs +per wide document). + +## Compared with Mongoose + +Mongoose 8 on Node 22, same MongoDB, same document shapes, ratios against the raw Node driver. + +| 10,000 nested documents | Mongoose | mongospecs (msgspec) | mongospecs (pydantic) | +|---|---|---|---| +| Lean read vs driver | 0.88 | 1.46 | 2.68 | +| Tracked documents vs driver | 3.79 | 1.61 | 2.70 | +| Tracked vs lean, time | 4.68 | 0.83–1.15 | 1.04 | +| Tracked vs lean, memory | 3.75 (1.9 KB → 7.2 KB) | 1.51 | 1.17 | +| Hydrating one nested document vs BSON decode | 4.71 | 0.33 | 1.57 | +| `insertMany` of 1,000 vs driver | 1.43 | 1.08 | 1.13 | +| …with timestamps and versioning | 1.97 | 1.26 | 1.67 | + +The two are not doing identical work: Mongoose's lean objects are untyped, while a mongospecs lean +read validates into typed models, which is why Mongoose is ahead on lean reads. On tracked +documents, the default in both, mongospecs' overhead over its driver is lower. + +## `include()`: `$lookup` or batched queries + +Parents each referencing `fan_out` distinct children; ratio of `$lookup` time to batched time +(below 1 means `$lookup` is faster). Two full runs on the replica set are shown because they +disagree. + +| Relation | Replica set, run 1 | Replica set, run 2 | Sharded cluster | +|---|---|---|---| +| list, fan-out 1 | 1.27 | 1.09 | 4.70 | +| list, fan-out 10 | 1.29 | 1.10 | 4.95 | +| list, fan-out 100 | 0.61 | 1.25 | 1.35 | +| list, fan-out 1000 | 0.78 | 0.96 | 0.90 | +| to-one, 2,000 parents sharing 20 targets | 3.16 | 2.48 | 9.02 | + +**Decision: `strategy="auto"` means batched queries.** `$lookup` is never clearly ahead, is 2.5–3× +slower when many parents share few targets (the server repeats each target per parent), and is +5–9× slower through mongos for small fan-outs. It stays available as `strategy="lookup"` for the +case where a single round trip matters more than time. This is also what Mongoose does. + +A `$lookup` whose result document would exceed 16 MB fails with a typed `DocumentTooLarge`; the +batched strategy loads the same data without trouble (stress scenario X5). + +## Pagination + +50,000 documents, pages of 20, sorted on an indexed field. + +| Page | Offset | Cursor | +|---|---|---| +| 1 | 0.58 ms | 0.58 ms | +| 100 | 1.20 ms | 0.65 ms | +| 1,250 | 7.06 ms | 0.66 ms | +| 2,499 (last) | 12.62 ms | 0.67 ms | + +- Cursor pagination sorts by the requested key and then by `_id`. With an index on the key alone + the first page took 28.8 ms, because the server sorted in memory; with an index on + `(key, _id)` it took 0.58 ms. Index listings accordingly. +- A total from a concurrent `count_documents` took 6.0 ms; the same page and total through `$facet` + took 28.5 ms and is additionally bound by the 16 MB document limit. `$facet` is not used. + +## Writes and plugins + +Ratios to the driver; these are single round trips where the network dominates and noise is +highest. + +| Operation | msgspec | pydantic | attrs | +|---|---|---|---| +| `insert_many` of 1,000 | 1.08 | 1.13 | (noisy) | +| …with timestamps and revision | 1.26 | 1.67 | (noisy) | +| `Model.update` ×200 (returns the document) | 1.11 | 1.29 | 1.20 | +| `doc.save()` delta ×200 | 1.54 | 1.06 | 0.91 | +| `Model.upsert` ×200 (returns the document) | 1.32 | 1.12 | 1.11 | + +Per-operation cost with 0, 3 and 10 no-op plugins on a registry differed by less than the noise +(0.94–1.20× with no trend), as did the three built-in plugins. + +## Stress + +All scenarios pass at full size. + +| Scenario | Result | +|---|---| +| X1: 3,000 concurrent tasks on a pool of 5 | all completed; a starved pool raised `PoolTimeout` | +| X2: 40 contending read-modify-write transactions | counter reached 40; 174 automatic retries, no lost update | +| X3: 40 writers on one revisioned document | exactly one winner per revision; final revision 41 | +| X4: 141 of 200 tasks cancelled mid-operation or mid-transaction | no open transactions left on the server, no leaked tracking state | +| X5: `$lookup` result over 16 MB | typed `DocumentTooLarge`; batched strategy loaded everything | +| X6: change stream across a primary step-down | 20 of 20 events, in order, no duplicates | +| X7: 8 threads, each with its own event loop and registry | no cross-registry state | + +X7 ran on a standard (GIL) build. The unit suite runs on the free-threaded build in CI as an +experimental job. + +## End-to-end + +`bench/tests/test_e2e_storefront.py` boots the storefront under uvicorn and talks to it over HTTP: + +- a full customer journey on each of the five plugs, and ordering on each model backend +- 40 concurrent buyers for 10 units: exactly 10 orders succeed, 30 are refused, stock ends at 0 +- multi-item orders are all-or-nothing, including opposing lock orders that must not deadlock +- the change-stream projector counts every sale exactly once across a restart and a period with no + projector running +- requests in flight finish when the server is asked to stop +- under traffic, a primary step-down produces only declared errors (503/504), and afterwards every + accepted order has moved stock exactly once + +A separate check (`mongospecs-bench trace`) sends requests through the Sentry SDK to a local +[rustrak](https://github.com/rustrak/rustrak) server and asserts that each request arrives as one +trace containing the HTTP transaction and its `Model.op` database spans. + +These tests found defects the unit and contract suites had not: aggregation paths failed whenever a +default time limit was configured, ids given as strings to reference fields were stored as strings +on msgspec and attrs, and unexpected server errors were not logged. + +## The Rust question + +The plan asked whether a Rust component is justified, to be decided from these measurements. + +Where the time goes when reading 10,000 nested documents with msgspec models: + +| Step | Per document | +|---|---| +| Driver: BSON bytes to Python dict | 10.1 µs | +| msgspec: dict to model | 3.3 µs | +| Everything else in mongospecs | under 1 µs | + +- For **bulk reads**, decoding BSON into dicts is essentially all of the driver's time: measured + alone it takes 101 ms for 10,000 documents, and the driver's whole read took 93 ms in the same + run (equal within noise). It is about two thirds of a lean read. Decoding BSON + straight into models, skipping the dict, is the one change that could make mongospecs faster than + the raw driver rather than slower. That is a native-code job. +- For **request-sized reads** (20 documents) that step is an estimated 10–20% of the server CPU + per request (20 documents at 3–7 µs against 0.63 ms); the driver's asynchronous machinery + dominates. A native decoder would gain little there. +- Nothing else measured (query building, plugins, tracking, serialisation) is large enough to move + to Rust. + +**Recommendation:** no Rust in 0.2. If bulk reads matter to you, the worthwhile project is a +BSON-to-model decoder as a separate optional package behind `Codec.decode(bytes)`. The seam is +already there: tracked reads receive undecoded BSON from the driver today. A Rust driver is not +indicated by anything here. + +## Limits of these measurements + +- One machine, client and server together, MongoDB in Docker on macOS. Network cost is unrealistically + low and variance is high. +- One uvicorn worker; no TLS, no proxy. +- The free-threaded build has not been benchmarked. +- The Mongoose comparison uses default settings on both sides and no tuning of either. diff --git a/packages/mongospecs/src/mongospecs/meta.py b/packages/mongospecs/src/mongospecs/meta.py index 346d747..c52ab9a 100644 --- a/packages/mongospecs/src/mongospecs/meta.py +++ b/packages/mongospecs/src/mongospecs/meta.py @@ -69,11 +69,29 @@ def resolve_config(cls: type) -> SpecConfig: return own -def _all_subclasses(cls: type) -> t.Iterator[type]: - sub: type - for sub in cls.__subclasses__(): - yield sub - yield from _all_subclasses(sub) +def _all_subclasses(cls: type) -> list[type]: + """Every live subclass of ``cls``, one per definition. + + ``attrs.define`` replaces a class with a slotted copy; the original lingers in + ``__subclasses__()`` until it is garbage collected (noticeably longer on + free-threaded builds). Both have the same parent, module and qualified name, so + the later one, which is the real class, wins. + """ + out: list[type] = [] + + def walk(klass: type) -> None: + # Deduplicate among the direct subclasses of one parent only: unrelated classes may + # legitimately share a qualified name (built by the same factory function, say). + level: dict[tuple[str, str], type] = {} + sub: type + for sub in klass.__subclasses__(): + level[(sub.__module__, sub.__qualname__)] = sub + for sub in level.values(): + out.append(sub) + walk(sub) + + walk(cls) + return out class ModelMeta: @@ -280,7 +298,9 @@ def discriminator_filter(self) -> dict[str, t.Any]: """Restrict a query on a subclass to documents of that subclass (and its own subclasses).""" if not self.disc_key or self.model is self.root: return {} - values = [self.disc_value, *(meta_of(c).disc_value for c in _all_subclasses(self.model))] + values = list( + dict.fromkeys([self.disc_value, *(meta_of(c).disc_value for c in _all_subclasses(self.model))]) + ) return {self.disc_key: values[0] if len(values) == 1 else {"$in": values}} # -- relations ------------------------------------------------------------- diff --git a/packages/mongospecs/tests/test_models_misc.py b/packages/mongospecs/tests/test_models_misc.py index ae771e4..dff0ff6 100644 --- a/packages/mongospecs/tests/test_models_misc.py +++ b/packages/mongospecs/tests/test_models_misc.py @@ -168,3 +168,42 @@ class Orphan(MsgspecSpec): with pytest.raises(ConfigurationError, match="DoesNotExist"): meta_of(Orphan).ref_targets("other") assert ObjectId.is_valid(str(ObjectId())) + + +def test_stale_class_copies_are_not_counted_as_subclasses(): + """attrs replaces a class with a slotted copy; the original must not show up as a second subclass.""" + import gc + + import attrs + + from mongospecs.attrs import Spec as AttrsSpec + from mongospecs.meta import _all_subclasses + + @attrs.define(kw_only=True) + class Vehicle(AttrsSpec): + __spec__ = SpecConfig(collection="vehicles", discriminator="kind") + wheels: int = 4 + + gc.disable() # keep the pre-slots originals alive, as a free-threaded build may + try: + + @attrs.define(kw_only=True) + class Truck(Vehicle): + __spec__ = SpecConfig(discriminator_value="lorry") + load: int = 0 + + @attrs.define(kw_only=True) + class Pickup(Truck): + bed: int = 0 + + assert [c.__name__ for c in _all_subclasses(Vehicle)] == ["Truck", "Pickup"] + assert all( + attrs.has(c) and "load" in [a.name for a in attrs.fields(c)] for c in _all_subclasses(Vehicle) + ) + assert meta_of(Truck).discriminator_filter() == {"kind": {"$in": ["lorry", "Pickup"]}} + assert ( + meta_of(Vehicle).subclass_for("lorry") is Truck + and meta_of(Vehicle).subclass_for("Pickup") is Pickup + ) + finally: + gc.enable() From d1544af68a3bb4c93f1ec506fea9f346dc14cc91 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 01:06:16 -0400 Subject: [PATCH 09/31] test(core): consumer typing fixtures checked with mypy A fixture file written from a user's point of view; mypy --strict must reject exactly the lines marked expect-error (wrong model type, a possibly-None result used as a document, a count used as a document, a reference's target type) and accept the rest. Co-Authored-By: Claude Opus 5.5 --- packages/mongospecs/tests/test_typing.py | 29 +++++++ .../tests/typing_fixtures/consumer.py | 75 +++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 packages/mongospecs/tests/test_typing.py create mode 100644 packages/mongospecs/tests/typing_fixtures/consumer.py diff --git a/packages/mongospecs/tests/test_typing.py b/packages/mongospecs/tests/test_typing.py new file mode 100644 index 0000000..13a7adc --- /dev/null +++ b/packages/mongospecs/tests/test_typing.py @@ -0,0 +1,29 @@ +"""Consumer typing fixtures: mypy must reject exactly the lines marked ``# expect-error``.""" + +import pathlib +import re +import subprocess +import sys + +FIXTURE = pathlib.Path(__file__).parent / "typing_fixtures" / "consumer.py" +ROOT = pathlib.Path(__file__).resolve().parents[3] + + +def test_public_api_types_hold_for_a_consumer(): + expected = { + number + for number, line in enumerate(FIXTURE.read_text().splitlines(), start=1) + if line.rstrip().endswith("# expect-error") + } + assert len(expected) >= 8 + run = subprocess.run( + [sys.executable, "-m", "mypy", "--strict", "--no-error-summary", "--no-incremental", str(FIXTURE)], + capture_output=True, + text=True, + cwd=ROOT, + ) + reported = {int(m.group(1)) for m in re.finditer(r"consumer\.py:(\d+): error", run.stdout)} + assert reported == expected, ( + f"unexpected errors on lines {sorted(reported - expected)}, " + f"missing errors on lines {sorted(expected - reported)}\n{run.stdout}{run.stderr}" + ) diff --git a/packages/mongospecs/tests/typing_fixtures/consumer.py b/packages/mongospecs/tests/typing_fixtures/consumer.py new file mode 100644 index 0000000..3d59d1d --- /dev/null +++ b/packages/mongospecs/tests/typing_fixtures/consumer.py @@ -0,0 +1,75 @@ +"""What a user of the library should and should not be able to write, as seen by a type checker. + +Lines marked ``# expect-error`` must be rejected by mypy; every other line must be accepted. +This file is checked, never executed. +""" + +from mongospecs import Inc, Page, Q, Query, Ref, Registry, SpecConfig +from mongospecs.msgspec import Spec + + +class User(Spec): + name: str = "" + + +class Article(Spec): + __spec__ = SpecConfig(collection="articles") + title: str = "" + views: int = 0 + author: Ref[User] | None = None + + +async def reads(registry: Registry) -> None: + query: Query[Article] = Article.find(Q.views > 1).sort("-views").limit(5) + articles: list[Article] = await query.all() + also: list[Article] = await query + maybe: Article | None = await query.first() + one: Article = await query.one() + by_id: Article | None = await Article.get("abc") + raw: list[dict[str, object]] = [dict(d) for d in await query.raw().all()] + page: Page[Article] = await query.page(10) + count: int = await Article.count(Q.title == "x") + async for article in query: + title: str = article.title + print(articles, also, maybe, one, by_id, raw, page.items[0].views, count, title) + + wrong_model: list[User] = await query.all() # expect-error + not_optional: Article = await query.first() # expect-error + not_a_list: Article = await query.all() # expect-error + print(wrong_model, not_optional, not_a_list) + if maybe is not None: + print(maybe.title.upper()) + print(maybe.title) # expect-error + + +async def writes(registry: Registry) -> None: + article: Article = await Article(title="t").insert() + saved: Article = await article.save() + updated: Article = await Article.update(article.id, Inc(views=1), title="x") + upserted: Article = await Article.upsert(Q.title == "x", views=2) + modified: int = await Article.find(Q.views > 1).update(Inc(views=1)) + deleted: int = await Article.find(Q.views > 1).delete() + await article.delete() + print(saved, updated, upserted, modified, deleted) + + as_user: User = await Article.update(article.id, title="x") # expect-error + count_as_doc: Article = await Article.find().update(views=1) # expect-error + print(as_user, count_as_doc) + + +async def relations(article: Article) -> None: + if article.author is not None: + user: User = await article.author.fetch() + loaded: User = article.author.doc + print(user.name, loaded.name) + wrong: Article = article.author.doc # expect-error + print(wrong) + + +async def transactions(registry: Registry) -> None: + async def work(tx: object) -> int: + return 1 + + result: int = await registry.transaction(work) + text: str = await registry.transaction(work) # expect-error + print(result, text) From 279bd6c508e5b6b63d2da973b167a52f122a71c9 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 01:13:58 -0400 Subject: [PATCH 10/31] test(web): compare timestamps as instants, not strings A timestamp with a zero fractional second is printed without one by some serialisers, which made a lexical comparison fail intermittently. Co-Authored-By: Claude Opus 5.5 --- packages/mongospecs-web/tests/test_mongo_resource.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/mongospecs-web/tests/test_mongo_resource.py b/packages/mongospecs-web/tests/test_mongo_resource.py index 07c4bab..80a9a37 100644 --- a/packages/mongospecs-web/tests/test_mongo_resource.py +++ b/packages/mongospecs-web/tests/test_mongo_resource.py @@ -129,7 +129,12 @@ async def test_crud_round_trip_with_plugins(app): # conditional update through the real revision plugin ok = await client.patch(f"/v1/stories/{story['id']}", json={"words": 900}, headers={"if-match": '"0"'}) assert ok.status_code == 200 and ok.headers["etag"] == '"1"' and ok.json()["data"]["reading_minutes"] == 4 - assert ok.json()["data"]["updated_at"] >= story["updated_at"] + + # compare as instants: serialisers may or may not print a zero fractional second + def instant(text): + return dt.datetime.fromisoformat(text.replace("Z", "+00:00")) + + assert instant(ok.json()["data"]["updated_at"]) >= instant(story["updated_at"]) stale = await client.patch(f"/v1/stories/{story['id']}", json={"words": 1}, headers={"if-match": '"0"'}) assert stale.status_code == 412 # unique index -> 409 with the field named From fb9d31c2b0daeb6b39ccd48c224b916659202831 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 01:13:58 -0400 Subject: [PATCH 11/31] test(web): compare timestamps as instants, not strings A timestamp with a zero fractional second is printed without one by some serialisers, which made a lexical comparison fail intermittently. Co-Authored-By: Claude Opus 5.5 (cherry picked from commit 279bd6c508e5b6b63d2da973b167a52f122a71c9) --- packages/mongospecs-web/tests/test_mongo_resource.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/mongospecs-web/tests/test_mongo_resource.py b/packages/mongospecs-web/tests/test_mongo_resource.py index 07c4bab..80a9a37 100644 --- a/packages/mongospecs-web/tests/test_mongo_resource.py +++ b/packages/mongospecs-web/tests/test_mongo_resource.py @@ -129,7 +129,12 @@ async def test_crud_round_trip_with_plugins(app): # conditional update through the real revision plugin ok = await client.patch(f"/v1/stories/{story['id']}", json={"words": 900}, headers={"if-match": '"0"'}) assert ok.status_code == 200 and ok.headers["etag"] == '"1"' and ok.json()["data"]["reading_minutes"] == 4 - assert ok.json()["data"]["updated_at"] >= story["updated_at"] + + # compare as instants: serialisers may or may not print a zero fractional second + def instant(text): + return dt.datetime.fromisoformat(text.replace("Z", "+00:00")) + + assert instant(ok.json()["data"]["updated_at"]) >= instant(story["updated_at"]) stale = await client.patch(f"/v1/stories/{story['id']}", json={"words": 1}, headers={"if-match": '"0"'}) assert stale.status_code == 412 # unique index -> 409 with the field named From 426c0b1afd85191e327bd40fc7294b1647e4f120 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 02:23:10 -0400 Subject: [PATCH 12/31] fix(core,web): defects found in review of the v2 pull requests Each has a regression test that failed before the fix. - cursor pagination with select() repeated documents and did not terminate: sort keys were left out of the projection, so the next cursor was built from defaults. Sort keys are now always fetched - bulk_save left memory out of sync after a partial failure. Writes in the batch are independent; the ones that succeeded are now brought in sync and the error lists the rest in .failed. Which updates applied is determined by reading the documents back, since the server only reports a total - bulk_write hard deletes skipped OnDelete cascade rules - a cycle of cascading references recursed forever; cascades now carry the set of documents already being deleted - cascades resolved ids with distinct() (16MB limit) and one unbounded $in; ids are now read with find and applied in chunks - saving an untracked instance that has an id (a copy, a deepcopy, a lean read) raised a bare duplicate-key error; it now says the instance is not tracked and what to do - reload() loaded Deferred fields - Query.update() returns an int that also carries .matched - the Location header of a create was a bare id; it is now a URL under the API prefix Co-Authored-By: Claude Opus 5.5 --- .../mongospecs-web/src/mongospecs_web/api.py | 8 +- .../mongospecs-web/tests/test_contract.py | 2 +- .../tests/test_openapi_views.py | 5 + .../mongospecs/src/mongospecs/__init__.py | 3 +- packages/mongospecs/src/mongospecs/bulk.py | 14 +- packages/mongospecs/src/mongospecs/errors.py | 4 + packages/mongospecs/src/mongospecs/manager.py | 234 +++++++++++++++--- packages/mongospecs/src/mongospecs/query.py | 12 +- .../mongospecs/src/mongospecs/relations.py | 36 +-- .../mongospecs/tests/test_review_fixes.py | 149 +++++++++++ 10 files changed, 404 insertions(+), 63 deletions(-) create mode 100644 packages/mongospecs/tests/test_review_fixes.py diff --git a/packages/mongospecs-web/src/mongospecs_web/api.py b/packages/mongospecs-web/src/mongospecs_web/api.py index 443ebe6..0e4aa5a 100644 --- a/packages/mongospecs-web/src/mongospecs_web/api.py +++ b/packages/mongospecs-web/src/mongospecs_web/api.py @@ -120,7 +120,13 @@ async def run(self, route: Route, request: Request) -> Response: if response.status >= 500: logging.getLogger("mongospecs_web").error("authentication failed unexpectedly", exc_info=exc) return response - return await route.resource.dispatch(route.operation, request) + response = await route.resource.dispatch(route.operation, request) + location = response.headers.get("location") + if location and not location.startswith(("/", "http://", "https://")): + # A resource names what it created by id; the URL of that thing depends on where + # the API is mounted, which only the Api knows. + response.headers["location"] = f"{self.prefix}/{route.resource.name}/{location}" + return response async def handle( self, diff --git a/packages/mongospecs-web/tests/test_contract.py b/packages/mongospecs-web/tests/test_contract.py index d223d1b..7c85693 100644 --- a/packages/mongospecs-web/tests/test_contract.py +++ b/packages/mongospecs-web/tests/test_contract.py @@ -148,7 +148,7 @@ def names(label): return [w["name"] for w in got[label]["body"]["data"]] created = got["create"] - assert created["headers"]["location"] == f"{1:024x}" and created["headers"]["etag"] == '"0"' + assert created["headers"]["location"] == f"/api/widgets/{1:024x}" and created["headers"]["etag"] == '"0"' assert created["body"]["data"] == { "id": f"{1:024x}", "name": "anvil", diff --git a/packages/mongospecs-web/tests/test_openapi_views.py b/packages/mongospecs-web/tests/test_openapi_views.py index 0fc2fb2..9fc997d 100644 --- a/packages/mongospecs-web/tests/test_openapi_views.py +++ b/packages/mongospecs-web/tests/test_openapi_views.py @@ -151,6 +151,11 @@ async def test_api_handle_without_a_server(): "POST", "/api/widgets", body=b'{"name":"direct"}', headers={"authorization": "Bearer ann"} ) assert created.status == 201 and b'"direct"' in created.body + # Location is a URL that resolves to the new resource, wherever the API is mounted + assert created.headers["location"].startswith("/api/widgets/") + assert ( + await api.handle("GET", created.headers["location"], headers={"authorization": "Bearer ann"}) + ).status == 200 assert (await api.handle("GET", "/api/widgets/")).status == 200 # trailing slash tolerated assert (await api.handle("GET", "/api/widgets/stats")).status == 200 # literal segment beats {id} assert (await api.handle("GET", "/api/nope")).status == 404 diff --git a/packages/mongospecs/src/mongospecs/__init__.py b/packages/mongospecs/src/mongospecs/__init__.py index 51d8030..1bb440c 100644 --- a/packages/mongospecs/src/mongospecs/__init__.py +++ b/packages/mongospecs/src/mongospecs/__init__.py @@ -47,7 +47,7 @@ ) from mongospecs.hooks import OpCtx, Plugin from mongospecs.indexes import Action, IndexAction, IndexPlan, apply_indexes, plan_indexes -from mongospecs.manager import BulkResult, Manager +from mongospecs.manager import BulkResult, Manager, UpdateCount from mongospecs.markers import ( Alias, Backref, @@ -169,6 +169,7 @@ "Tx", "Type", "Unset", + "UpdateCount", "UpdateMany", "UpdateOne", "UpdatedAt", diff --git a/packages/mongospecs/src/mongospecs/bulk.py b/packages/mongospecs/src/mongospecs/bulk.py index d27635e..6845d97 100644 --- a/packages/mongospecs/src/mongospecs/bulk.py +++ b/packages/mongospecs/src/mongospecs/bulk.py @@ -58,9 +58,16 @@ class DeleteMany(DeleteOne): __slots__ = () -def compile_bulk(manager: Manager[t.Any], ctx: OpCtx, operations: t.Iterable[t.Any]) -> list[t.Any]: - """Compile typed bulk operations to driver requests.""" +def compile_bulk( + manager: Manager[t.Any], ctx: OpCtx, operations: t.Iterable[t.Any] +) -> tuple[list[t.Any], dict[int, tuple[dict[str, t.Any], bool]]]: + """Compile typed bulk operations to driver requests. + + Also returns the hard deletes as ``{request index: (filter, many)}`` so the caller + can run cascade rules for them. + """ requests: list[t.Any] = [] + deletes: dict[int, tuple[dict[str, t.Any], bool]] = {} for op in operations: if isinstance(op, InsertOne): doc = manager.meta.decode(dict(op.doc), stored=False) if isinstance(op.doc, t.Mapping) else op.doc @@ -84,10 +91,11 @@ def compile_bulk(manager: Manager[t.Any], ctx: OpCtx, operations: t.Iterable[t.A if soft is not None: requests.append((pymongo.UpdateMany if op.many else pymongo.UpdateOne)(flt, soft)) else: + deletes[len(requests)] = (flt, op.many) requests.append((pymongo.DeleteMany if op.many else pymongo.DeleteOne)(flt)) else: raise QueryError( f"bulk_write takes mongospecs.bulk operations, got {type(op).__name__}; " "use Model.collection().bulk_write for raw driver operations" ) - return requests + return requests, deletes diff --git a/packages/mongospecs/src/mongospecs/errors.py b/packages/mongospecs/src/mongospecs/errors.py index 9716f97..76191b2 100644 --- a/packages/mongospecs/src/mongospecs/errors.py +++ b/packages/mongospecs/src/mongospecs/errors.py @@ -38,6 +38,10 @@ class MongoSpecsError(Exception): """Base class for every error raised by mongospecs.""" + failed: t.Sequence[t.Any] = () + """For batch operations that partly succeeded: the documents that were *not* written. + Every other document in the batch was written and is in sync in memory.""" + class ConfigurationError(MongoSpecsError): """A model, plugin or registry is declared incorrectly.""" diff --git a/packages/mongospecs/src/mongospecs/manager.py b/packages/mongospecs/src/mongospecs/manager.py index cbb7591..dc8c0c2 100644 --- a/packages/mongospecs/src/mongospecs/manager.py +++ b/packages/mongospecs/src/mongospecs/manager.py @@ -19,6 +19,7 @@ from mongospecs.errors import ( DocumentNotFound, DuplicateKeyError, + MongoSpecsError, OperationFailed, QueryError, StaleDocumentError, @@ -35,7 +36,7 @@ from mongospecs.query import Query -__all__ = ["BulkResult", "Manager"] +__all__ = ["BulkResult", "Manager", "UpdateCount"] T = t.TypeVar("T") @@ -64,6 +65,26 @@ def __repr__(self) -> str: return f"BulkResult(inserted={len(self.inserted)}, errors={len(self.errors)})" +class UpdateCount(int): + """The number of documents a filter-based update modified, with the match count alongside. + + It is an ``int`` (the modified count), so existing arithmetic and comparisons work; + ``.matched`` also counts documents that matched but already had the new values. + """ + + matched: int + modified: int + + def __new__(cls, matched: int, modified: int) -> UpdateCount: + self = super().__new__(cls, modified) + self.matched = matched + self.modified = modified + return self + + def __repr__(self) -> str: + return f"UpdateCount(matched={self.matched}, modified={self.modified})" + + class Manager(t.Generic[T]): def __init__( self, @@ -288,7 +309,21 @@ async def save(self, doc: T, *, session: t.Any = None) -> T: """Insert a new document, or send only what changed on a tracked one.""" state = tracking.state_of(doc) if state is None: - return await self.insert(doc, session=session) + try: + return await self.insert(doc, session=session) + except DuplicateKeyError as exc: + if "_id" not in exc.fields: + raise + # An id that already exists on a document we hold no snapshot for: it came from + # lean(), copy/deepcopy, or pickling, not from a tracked read. + raise DuplicateKeyError( + f"{self.model.__name__} {t.cast(t.Any, doc).id!r} already exists and this instance is " + "not tracked (it is a lean read, a copy, or was unpickled), so save() tried to insert " + "it. Load it with a tracked query, or adopt a raw document with Model.hydrate().", + code=exc.code, + details=exc.details, + fields=exc.fields, + ) from exc ctx = self.ctx("update", session, doc=doc, snapshot=state.snapshot) await self._hooks("before", ("save",), ctx, doc) current, update = self._delta(doc, state) @@ -338,7 +373,7 @@ async def run(s: t.Any) -> None: result = await self.collection.update_one(flt, soft, **self._kw(s)) matched = result.matched_count else: - await self._cascade([snapshot["_id"]], s) + await self._cascade([snapshot["_id"]], s, set()) with self._op("delete"): matched = (await self.collection.delete_one(flt, **self._kw(s))).deleted_count if matched == 0: @@ -370,18 +405,24 @@ def _soft_delete(self, ctx: OpCtx, flt: dict[str, t.Any]) -> dict[str, t.Any] | return None async def reload(self, doc: T, *, session: t.Any = None) -> T: - """Refresh a document in place from the database and reset its snapshot.""" + """Refresh a document in place from the database and reset its snapshot. + + ``Deferred`` fields stay unloaded, as they would be on a fresh read. + """ any_doc = t.cast(t.Any, doc) with self._op("reload"): raw = await self.collection.find_one( - {"_id": self.meta.cast("id", any_doc.id)}, **self._kw(session) + {"_id": self.meta.cast("id", any_doc.id)}, self.default_projection(), **self._kw(session) ) if raw is None: raise DocumentNotFound(f"{self.model.__name__} {any_doc.id!r} does not exist") fresh = self.meta.decode(raw) - for name in meta_of(type(doc)).fields: - setattr(doc, name, getattr(fresh, name)) - tracking.track(doc, meta_of(type(doc)).encode(doc), self) + deferred = set(self.meta.deferred) + meta = meta_of(type(doc)) + for name, info in meta.fields.items(): + if info.stored not in deferred: + setattr(doc, name, getattr(fresh, name)) + tracking.track(doc, meta.encode(doc), self, self._default_partial()) return doc # -- filter-based writes --------------------------------------------------------- @@ -472,13 +513,13 @@ async def update_many( *, session: t.Any = None, unscoped: t.Collection[str] | bool = False, - ) -> int: + ) -> UpdateCount: ctx = self.ctx("update", session, many=True) flt = self.scoped(ctx, flt, unscoped) update = self._update_doc(ctx, flt, ops, fields) with self._op("update_many"): result = await self.collection.update_many(flt, update, **self._kw(session)) - return int(result.modified_count) + return UpdateCount(int(result.matched_count), int(result.modified_count)) async def delete_many( self, @@ -487,6 +528,7 @@ async def delete_many( session: t.Any = None, hard: bool = False, unscoped: t.Collection[str] | bool = False, + _seen: set[t.Any] | None = None, ) -> int: ctx = self.ctx("delete", session, many=True) flt = self.scoped(ctx, flt, unscoped) @@ -499,18 +541,28 @@ async def delete_many( return int((await self.collection.delete_many(flt, **self._kw(session))).deleted_count) async def run(s: t.Any) -> int: - with self._op("delete_many"): - ids = await self.collection.distinct("_id", flt, **self._kw(s)) + seen = _seen if _seen is not None else set() + # Documents already being deleted further up a cascade chain are left to that + # caller; this is what makes cycles of cascading references terminate. + ids = [i for i in await self._ids(flt, s) if (self.meta.collection, _hashable(i)) not in seen] if not ids: return 0 - await self._cascade(ids, s) - with self._op("delete_many"): - return int( - (await self.collection.delete_many({"_id": {"$in": ids}}, **self._kw(s))).deleted_count - ) + await self._cascade(ids, s, seen) + deleted = 0 + for chunk in _chunks(ids): + with self._op("delete_many"): + result = await self.collection.delete_many({"_id": {"$in": chunk}}, **self._kw(s)) + deleted += int(result.deleted_count) + return deleted return await (self.registry.transaction(run) if session is None else run(session)) + async def _ids(self, flt: dict[str, t.Any], session: t.Any, limit: int = 0) -> list[t.Any]: + """Ids of the documents matching an already-scoped filter.""" + with self._op("ids"): + cursor = self.collection.find(flt, {"_id": 1}, limit=limit, **self._kw(session)) + return [raw["_id"] for raw in await cursor.to_list()] + # -- cascades ---------------------------------------------------------------------- def _cascade_rules(self) -> list[t.Any]: @@ -520,12 +572,14 @@ def _cascade_rules(self) -> list[t.Any]: self._cascades = cascade_rules(self) return self._cascades - async def _cascade(self, ids: list[t.Any], session: t.Any) -> None: + async def _cascade(self, ids: list[t.Any], session: t.Any, seen: set[t.Any] | None = None) -> None: rules = self._cascade_rules() if rules: from mongospecs.relations import apply_cascades - await apply_cascades(self, rules, ids, session) + seen = seen if seen is not None else set() + seen.update((self.meta.collection, _hashable(i)) for i in ids) + await apply_cascades(self, rules, ids, session, seen) # -- bulk ----------------------------------------------------------------------------- @@ -584,18 +638,38 @@ async def insert_many( async def bulk_write( self, operations: t.Iterable[t.Any], *, ordered: bool = True, session: t.Any = None ) -> t.Any: - """Run typed bulk operations (see :mod:`mongospecs.bulk`) in one round trip.""" + """Run typed bulk operations (see :mod:`mongospecs.bulk`) in one round trip. + + Hard deletes on a model with ``OnDelete`` rules run their cascades first, in the + same transaction, exactly as single deletes do. + """ + import pymongo + from mongospecs.bulk import compile_bulk ops = list(operations) ctx = self.ctx("bulk", session, many=True) for plugin in self.plugins: await plugin.before_bulk(ctx, ops) - requests = compile_bulk(self, ctx, ops) + requests, deletes = compile_bulk(self, ctx, ops) if not requests: return None - with self._op("bulk_write"): - result = await self.collection.bulk_write(requests, ordered=ordered, **self._kw(session)) + + async def run(s: t.Any) -> t.Any: + seen: set[t.Any] = set() + for index, (flt, many) in deletes.items(): + # Resolve the documents first so the cascade and the delete act on the same ones. + ids = await self._ids(flt, s, limit=0 if many else 1) + await self._cascade(ids, s, seen) + requests[index] = pymongo.DeleteMany({"_id": {"$in": ids}}) + with self._op("bulk_write"): + return await self.collection.bulk_write(requests, ordered=ordered, **self._kw(s)) + + if deletes and self._cascade_rules(): + result = await (self.registry.transaction(run) if session is None else run(session)) + else: + deletes = {} + result = await run(session) for plugin in self.plugins: await plugin.after_bulk(ctx, ops) return result @@ -603,15 +677,18 @@ async def bulk_write( async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: """Save many documents in one round trip: inserts for new ones, deltas for tracked ones. - Returns the number of documents written. Raises :class:`StaleDocumentError` - if any revision-checked update matched nothing (the batch does not say which). + Returns the number of documents written. Writes are independent: if some + fail (a duplicate key, a document that no longer exists, a revision that + moved on), the rest are still written and brought in sync in memory, and + the error raised afterwards lists the ones that were not in ``.failed``. """ from pymongo import InsertOne from pymongo import UpdateOne as DriverUpdateOne requests: list[t.Any] = [] + # (document, kind, ctx, encoding after the write, prior state, the update that was sent) pending: list[ - tuple[t.Any, str, OpCtx, dict[str, t.Any], tracking.DocState | None, frozenset[str]] + tuple[t.Any, str, OpCtx, dict[str, t.Any], tracking.DocState | None, dict[str, t.Any]] ] = [] for doc in docs: state = tracking.state_of(doc) @@ -623,7 +700,7 @@ async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: for plugin in self.plugins: plugin.on_insert(ctx, enc) requests.append(InsertOne(enc)) - pending.append((doc, "insert", ctx, enc, None, frozenset())) + pending.append((doc, "insert", ctx, enc, None, {})) continue ctx = self.ctx("update", session, many=True, doc=doc, snapshot=state.snapshot) current, update = self._delta(doc, state) @@ -635,22 +712,42 @@ async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: plugin.on_update(ctx, flt, update) requests.append(DriverUpdateOne(flt, update)) _patch(current, update, skip=user_keys) - pending.append((doc, "update", ctx, current, state, user_keys)) + pending.append((doc, "update", ctx, current, state, update)) if not requests: return 0 bulk_ctx = self.ctx("bulk", session, many=True) all_docs = [p[0] for p in pending] for plugin in self.plugins: await plugin.before_bulk(bulk_ctx, all_docs) - with self._op("bulk_save"): - result = await self.collection.bulk_write(requests, ordered=False, **self._kw(session)) - updates = sum(1 for p in pending if p[1] == "update") - if result.matched_count != updates: - raise StaleDocumentError( - f"{self.model.__name__}: {updates - result.matched_count} of {updates} documents in the batch " - "no longer exist or were changed since they were loaded" - ) - for doc, kind, ctx, enc, state, _ in pending: + + failed: set[int] = set() + error: MongoSpecsError | None = None + try: + with self._op("bulk_save"): + result = await self.collection.bulk_write(requests, ordered=False, **self._kw(session)) + matched = int(result.matched_count) + except OperationFailed as exc: + details = exc.details if isinstance(exc.details, dict) else {} + write_errors = details.get("writeErrors") + if not write_errors: + raise + failed = {int(e["index"]) for e in write_errors} + matched = int(details.get("nMatched", 0)) + error = exc + updates = [i for i, p in enumerate(pending) if p[1] == "update" and i not in failed] + if matched != len(updates): + # The server only reports a total. Read back what each update should have left behind. + unmatched = await self._unmatched(pending, updates, session) + failed |= unmatched + if error is None: + error = StaleDocumentError( + f"{self.model.__name__}: {len(unmatched)} of {len(updates)} documents in the batch " + "no longer exist or were changed since they were loaded" + ) + written = 0 + for index, (doc, kind, ctx, enc, state, _) in enumerate(pending): + if index in failed: + continue if isinstance(session, Tx): session.record(doc, self.managed_attrs) if kind == "insert": @@ -658,9 +755,40 @@ async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: for plugin in self.plugins: plugin.applied(ctx, doc, enc) tracking.track(doc, enc, state.manager if state else self, state.partial if state else None) + written += 1 for plugin in self.plugins: - await plugin.after_bulk(bulk_ctx, all_docs) - return len(pending) + await plugin.after_bulk(bulk_ctx, [p[0] for i, p in enumerate(pending) if i not in failed]) + if error is not None: + error.failed = [pending[i][0] for i in sorted(failed)] + raise error + return written + + async def _unmatched(self, pending: list[t.Any], updates: list[int], session: t.Any) -> set[int]: + """Which of the batch's updates did not apply. + + The server reports only a total, so the documents are read back and compared with + what each update would have left: the values it sets, the fields it removes and the + counters it increments (the revision among them). + """ + found: dict[t.Any, dict[str, t.Any]] = {} + for chunk in _chunks([pending[i][3]["_id"] for i in updates]): + with self._op("bulk_save"): + cursor = self.collection.find({"_id": {"$in": chunk}}, **self._kw(session)) + for raw in await cursor.to_list(): + found[_hashable(raw["_id"])] = raw + out: set[int] = set() + for i in updates: + enc, update = pending[i][3], pending[i][5] + stored = found.get(_hashable(enc["_id"])) + applied = ( + stored is not None + and all(_dig(stored, path) == value for path, value in update.get("$set", {}).items()) + and all(_dig(stored, path, _MISSING) is _MISSING for path in update.get("$unset", {})) + and all(stored.get(key) == enc.get(key) for key in update.get("$inc", {})) + ) + if not applied: + out.add(i) + return out # -- escape hatches --------------------------------------------------------------------- @@ -798,3 +926,31 @@ def _filter_keys(flt: t.Any) -> set[str]: elif not key.startswith("$"): keys.add(key.partition(".")[0]) return keys + + +_CHUNK_IDS = 5000 + + +def _chunks(ids: list[t.Any]) -> t.Iterator[list[t.Any]]: + """Ids in pieces small enough for one ``$in`` each.""" + for start in range(0, len(ids), _CHUNK_IDS): + yield ids[start : start + _CHUNK_IDS] + + +def _hashable(value: t.Any) -> t.Any: + try: + hash(value) + except TypeError: + return repr(value) + return value + + +_MISSING: t.Any = object() + + +def _dig(doc: t.Any, path: str, default: t.Any = None) -> t.Any: + for part in path.split("."): + if not isinstance(doc, dict) or part not in doc: + return default + doc = doc[part] + return doc diff --git a/packages/mongospecs/src/mongospecs/query.py b/packages/mongospecs/src/mongospecs/query.py index 01ae684..b3abdf4 100644 --- a/packages/mongospecs/src/mongospecs/query.py +++ b/packages/mongospecs/src/mongospecs/query.py @@ -21,7 +21,7 @@ from mongospecs.updates import UpdateOp if t.TYPE_CHECKING: - from mongospecs.manager import Manager + from mongospecs.manager import Manager, UpdateCount from mongospecs.relations import Include __all__ = ["Page", "Query"] @@ -254,7 +254,8 @@ def _projection( keys[meta.disc_key] = None projection = {k: 1 for k in keys} return projection, frozenset(k.partition(".")[0] for k in keys) | {"_id"} - excluded = [*deferred, *exclude] + required = set(needed) + excluded = [key for key in (*deferred, *exclude) if key.partition(".")[0] not in required] if not excluded: return None, None return {k: 0 for k in excluded}, meta.stored_keys - {k for k in excluded if "." not in k} @@ -268,6 +269,8 @@ def _plan(self) -> dict[str, t.Any]: manager = self._manager lookups, batched = plan_includes(manager, self._includes, self._mode) needed = [manager.meta.fields[i.name].stored for i in self._includes if i.name in manager.meta.refs] + # Sort keys are always fetched: cursor pagination builds the next cursor from them. + needed += [key.partition(".")[0] for key, _ in self._sort_spec()] projection, partial = self._projection(needed) plan: dict[str, t.Any] = { "filter": self._filter(), @@ -492,8 +495,9 @@ def _check_plain(self, what: str) -> None: if self._skip or self._limit or self._sort: raise QueryError(f"{what} applies to every match; sort, skip and limit are not supported") - async def update(self, *ops: UpdateOp, **fields: t.Any) -> int: - """Atomically update every match. Returns the number of documents modified. + async def update(self, *ops: UpdateOp, **fields: t.Any) -> UpdateCount: + """Atomically update every match. Returns the number of documents modified + (an ``int`` that also carries ``.matched`` and ``.modified``). Documents are not loaded, so document hooks do not run; plugins still rewrite the command (timestamps, revisions, scopes). diff --git a/packages/mongospecs/src/mongospecs/relations.py b/packages/mongospecs/src/mongospecs/relations.py index 78840dd..8fca230 100644 --- a/packages/mongospecs/src/mongospecs/relations.py +++ b/packages/mongospecs/src/mongospecs/relations.py @@ -420,24 +420,32 @@ def cascade_rules(manager: Manager[t.Any]) -> list[CascadeRule]: async def apply_cascades( - manager: Manager[t.Any], rules: list[CascadeRule], ids: list[t.Any], session: t.Any + manager: Manager[t.Any], rules: list[CascadeRule], ids: list[t.Any], session: t.Any, seen: set[t.Any] ) -> None: + """Apply ``OnDelete`` rules for documents of ``manager``'s model that are about to be deleted. + + ``seen`` holds every (collection, id) already being deleted in this cascade, so a cycle of + cascading references ends instead of recursing forever. + """ + from mongospecs.manager import _chunks + for rule in rules: other: Manager[t.Any] = rule.manager stored = rule.field.stored - flt = {stored: {"$in": ids}} - if rule.action == "cascade": - await other.delete_many(flt, session=session) - continue - ctx = other.ctx("update", session, many=True) - scoped = other.scoped(ctx, flt, True) - update: dict[str, t.Any] = ( - {"$set": {stored: None}} if rule.action == "nullify" else {"$pull": {stored: {"$in": ids}}} - ) - for plugin in other.plugins: - plugin.on_update(ctx, scoped, update) - with other._op("cascade"): - await other.collection.update_many(scoped, update, **other._kw(session)) + for chunk in _chunks(ids): + flt = {stored: {"$in": chunk}} + if rule.action == "cascade": + await other.delete_many(flt, session=session, _seen=seen) + continue + ctx = other.ctx("update", session, many=True) + scoped = other.scoped(ctx, flt, True) + update: dict[str, t.Any] = ( + {"$set": {stored: None}} if rule.action == "nullify" else {"$pull": {stored: {"$in": chunk}}} + ) + for plugin in other.plugins: + plugin.on_update(ctx, scoped, update) + with other._op("cascade"): + await other.collection.update_many(scoped, update, **other._kw(session)) # -- $graphLookup ---------------------------------------------------------------------------- diff --git a/packages/mongospecs/tests/test_review_fixes.py b/packages/mongospecs/tests/test_review_fixes.py new file mode 100644 index 0000000..dca56a4 --- /dev/null +++ b/packages/mongospecs/tests/test_review_fixes.py @@ -0,0 +1,149 @@ +"""Regression tests for defects reported in review of the v2 pull requests.""" + +import copy + +import pytest + +from mongospecs import ( + BulkWriteFailed, + DeleteMany, + DeleteOne, + DuplicateKeyError, + Inc, + Q, + StaleDocumentError, +) + + +async def test_cursor_pages_with_a_projection_do_not_repeat(m, registry): + """select() used to drop the sort key from the projection, so the cursor was built from defaults.""" + await m.User.insert_many([m.User(name=f"u{i:02d}", age=i % 5, email=f"{i}@x.io") for i in range(30)]) + for select in (("name",), ("-age",), ("email",)): + query = m.User.find().select(*select).sort("age", "-name") + seen, cursor, pages = [], None, 0 + while True: + page = await query.page(7, after=cursor) + seen.extend(u.name for u in page) + pages += 1 + assert pages <= 6, f"select{select}: pagination does not terminate" + if not page.has_next: + break + cursor = page.next_cursor + assert len(seen) == len(set(seen)) == 30 and pages == 5, (select, pages, len(seen)) + raw = await m.User.find().raw().select("name").sort("-age").page(4) + assert "age" in raw[0] # sort keys are always fetched: the cursor is built from them + + +async def test_bulk_save_keeps_memory_in_sync_after_a_partial_failure(m, registry): + docs = (await m.Article.insert_many([m.Article(title=f"a{i}") for i in range(3)])).inserted + fresh = m.Article(title="new") + await m.Article.collection().delete_one({"_id": docs[1].id}) # this one cannot be updated + for doc in docs: + doc.views = 5 + with pytest.raises(StaleDocumentError) as info: + await m.Article.bulk_save([*docs, fresh]) + assert info.value.failed == [docs[1]] + # the others were written, and say so + for doc in (docs[0], docs[2]): + assert doc.rev == 1 and doc.changes() == {} + assert (await m.Article.collection().find_one({"_id": doc.id}))["views"] == 5 + assert not fresh.is_new and fresh.changes() == {} + assert docs[1].rev == 0 and docs[1].changes() == {"$set": {"views": 5}} + docs[0].views = 6 + assert await m.Article.bulk_save([docs[0], docs[2]]) == 1 # saving again works + + +async def test_bulk_save_reports_revision_conflicts_per_document(m, registry): + docs = (await m.Article.insert_many([m.Article(title=f"a{i}") for i in range(3)])).inserted + await m.Article.update(docs[2].id, Inc(views=1)) # someone else got there first + for doc in docs: + doc.title += "!" + with pytest.raises(StaleDocumentError) as info: + await m.Article.bulk_save(docs) + assert info.value.failed == [docs[2]] + assert [d.rev for d in docs] == [1, 1, 0] + + +async def test_bulk_save_duplicate_key_does_not_hide_the_other_writes(m, registry): + await registry.apply_indexes() + await m.User(name="taken", email="t@x.io").insert() + existing = await m.User(name="e", email="e@x.io").insert() + existing.age = 9 + clash, fine = m.User(name="clash", email="t@x.io"), m.User(name="fine", email="f@x.io") + with pytest.raises((DuplicateKeyError, BulkWriteFailed)) as info: + await m.User.bulk_save([existing, clash, fine]) + assert info.value.failed == [clash] + assert existing.changes() == {} and not fine.is_new and clash.is_new + assert await m.User.count() == 3 + + +async def test_bulk_write_deletes_run_cascades(m, registry): + a1, a2, a3 = (await m.Article.insert_many([m.Article(title=t) for t in "abc"])).inserted + await m.Comment.insert_many([m.Comment(body=f"c{i}", article=a) for i, a in enumerate((a1, a1, a2, a3))]) + await m.Article.bulk_write([DeleteOne(a1.id, hard=True), DeleteMany(Q.title == "b", hard=True)]) + assert [c.body for c in await m.Comment.find()] == ["c3"] # only a3's comment is left + # a soft delete in a bulk write does not cascade, like a single soft delete + await m.Article.bulk_write([DeleteOne(a3.id)]) + assert await m.Comment.count() == 1 and await m.Article.count() == 0 + + +async def test_cascade_cycles_terminate(m, make_registry): + import typing as t + + from mongospecs import OnDelete, Ref, SpecConfig, To + + @m.define + class Left(m.Spec): + __spec__ = SpecConfig(collection="cyc_left") + other: t.Annotated[Ref | None, To("Right"), OnDelete("cascade")] = None + + @m.define + class Right(m.Spec): + __spec__ = SpecConfig(collection="cyc_right") + other: t.Annotated[Ref[Left] | None, OnDelete("cascade")] = None + + await make_registry(Left, Right) + left = await Left().insert() + right = await Right(other=left).insert() + left.other = right + await left.save() + await Left().insert() # unrelated, must survive + await left.delete() + assert await Left.count() == 1 and await Right.count() == 0 + + +async def test_large_cascades_are_chunked(m, registry): + articles = (await m.Article.insert_many([m.Article(title=f"a{i}") for i in range(2500)])).inserted + await m.Comment.insert_many([m.Comment(body="c", article=a) for a in articles]) + assert await m.Article.find().delete(hard=True) == 2500 + assert await m.Comment.count() == 0 + + +async def test_saving_an_untracked_copy_says_what_is_wrong(m, registry): + user = await m.User(name="ann").insert() + for clone in (copy.copy(user), copy.deepcopy(user), await m.User.find().lean().one()): + clone.age = 3 + with pytest.raises(DuplicateKeyError, match="not tracked"): + await clone.save() + assert (await m.User.get(user.id)).age == 0 + adopted = m.User.hydrate(await m.User.collection().find_one({"_id": user.id})) + adopted.age = 4 + await adopted.save() # the documented way to get a saveable copy + assert (await m.User.get(user.id)).age == 4 + + +async def test_reload_respects_deferred_fields(m, registry): + user = await m.User(name="ann", secret="s3cret").insert() + loaded = await m.User.get(user.id) + await m.User.collection().update_one({"_id": user.id}, {"$set": {"age": 7}}) + await loaded.reload() + assert loaded.age == 7 and loaded.secret is None # still deferred + loaded.name = "anna" + await loaded.save() + assert (await m.User.collection().find_one({"_id": user.id}))["secret"] == "s3cret" + + +async def test_filter_update_reports_matched_and_modified(m, registry): + await m.User.insert_many([m.User(name="a", age=1), m.User(name="b", age=1), m.User(name="c", age=2)]) + result = await m.User.find(Q.age >= 1).update(age=1) + assert result == 1 and result.modified == 1 and result.matched == 3 # still an int: the number modified From ea43cece77411231f055d2cd6f563475e31c7c72 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 02:29:09 -0400 Subject: [PATCH 13/31] feat(web)!: harden the request path From review of the web layer. - request bodies are read under the size limit on every plug (declared length checked first, then chunk by chunk); previously only the pure ASGI plug stopped at the limit and the others buffered the whole body - DELETE applies the row scope in the delete's own filter, not only in the lookup before it, and honours If-Match - deleting a loaded document checks its revision on hard deletes too - offset pagination refuses pages deeper than Pagination.max_offset (10,000 skipped documents by default) - totals are opt-in per resource: Pagination.total now defaults to False - reads made for a request carry a 10s time limit when neither the registry nor the model sets one - Api(openapi=False) keeps the API description private on every plug - HEAD and OPTIONS are answered by Api.handle and the pure ASGI plug - Resource(explicit_writes=True): a field is writable only when its Policy says so - mongospecs_web.testing.mount_on(plug, api) builds an app on any built-in plug BREAKING: ?total=true needs page=Cursor(total=True) / Offset(total=True). Co-Authored-By: Claude Opus 5.5 --- .../mongospecs-web/src/mongospecs_web/api.py | 42 ++++++- .../src/mongospecs_web/backend.py | 32 +++++- .../src/mongospecs_web/filters.py | 12 +- .../src/mongospecs_web/plugs/asgi.py | 27 +++-- .../src/mongospecs_web/plugs/fastapi.py | 8 +- .../src/mongospecs_web/plugs/litestar.py | 16 ++- .../src/mongospecs_web/plugs/quart.py | 33 ++++-- .../src/mongospecs_web/plugs/starlette.py | 21 ++-- .../src/mongospecs_web/policy.py | 26 +++-- .../src/mongospecs_web/resource.py | 9 +- .../src/mongospecs_web/testing.py | 61 +++++++++- .../mongospecs-web/tests/test_contract.py | 42 +------ .../tests/test_mongo_resource.py | 64 ++++++++++- .../tests/test_openapi_views.py | 108 +++++++++++++++++- packages/mongospecs/src/mongospecs/manager.py | 24 +++- packages/mongospecs/src/mongospecs/model.py | 10 +- 16 files changed, 428 insertions(+), 107 deletions(-) diff --git a/packages/mongospecs-web/src/mongospecs_web/api.py b/packages/mongospecs-web/src/mongospecs_web/api.py index 0e4aa5a..83dfbb0 100644 --- a/packages/mongospecs-web/src/mongospecs_web/api.py +++ b/packages/mongospecs-web/src/mongospecs_web/api.py @@ -52,6 +52,7 @@ def __init__( version: str = "1.0.0", authenticate: Authenticate | None = None, max_body: int = 1_048_576, + openapi: bool = True, ) -> None: self.resources: tuple[Resource[t.Any], ...] = resources self.prefix = "/" + prefix.strip("/") if prefix.strip("/") else "" @@ -59,6 +60,9 @@ def __init__( self.version = version self.authenticate = authenticate self.max_body = max_body + self.expose_openapi = openapi + """Serve ``/openapi.json``. Turn it off to keep the API description private; + ``api.openapi()`` still builds it for you to serve behind your own authentication.""" names = [r.name for r in resources] if len(set(names)) != len(names): raise ValueError(f"resource names must be unique, got {names}") @@ -81,6 +85,29 @@ def routes(self) -> list[Route]: def openapi_path(self) -> str: return f"{self.prefix}/openapi.json" + async def read_body(self, headers: t.Mapping[str, str], chunks: t.AsyncIterable[bytes]) -> bytes: + """Read a request body without ever holding more than ``max_body`` bytes. + + Raises :class:`PayloadTooLarge` as soon as the declared or the actual size passes the + limit, instead of buffering an oversized body first. + """ + declared = headers.get("content-length") + if declared is not None and declared.isdigit() and int(declared) > self.max_body: + raise PayloadTooLarge(f"The request body may be at most {self.max_body} bytes.") + body = bytearray() + async for chunk in chunks: + body += chunk + if len(body) > self.max_body: + raise PayloadTooLarge(f"The request body may be at most {self.max_body} bytes.") + return bytes(body) + + def allowed_methods(self, path: str) -> list[str]: + """Methods the routes matching ``path`` accept (empty when no route matches).""" + methods = {route.method for route in self._routes() if route.pattern.match(path)} + if "GET" in methods: + methods.add("HEAD") + return sorted(methods | {"OPTIONS"}) if methods else [] + def match(self, method: str, path: str) -> tuple[Route, dict[str, str]]: """Find the route for a request, or raise 404 / 405.""" allowed: list[str] = [] @@ -139,13 +166,22 @@ async def handle( raw: t.Any = None, ) -> Response: """Route and run a request given as plain values. Used by the ASGI plug and handy in tests.""" + method = method.upper() + if method == "OPTIONS": + allowed = self.allowed_methods(path) + if not allowed: + return Response.error(NotFound("No such route.")) + return Response(204, b"", {"allow": ", ".join(allowed)}) try: - route, params = self.match(method, path) + route, params = self.match("GET" if method == "HEAD" else method, path) except HTTPError as exc: return Response.error(exc) - return await self.run( - route, Request(method.upper(), params, query or [], headers or {}, body, None, raw) + response = await self.run( + route, Request(route.method, params, query or [], headers or {}, body, None, raw) ) + if method == "HEAD": + response.body = b"" # same status and headers as GET, no body + return response def openapi(self) -> dict[str, t.Any]: from mongospecs_web.openapi import build_openapi diff --git a/packages/mongospecs-web/src/mongospecs_web/backend.py b/packages/mongospecs-web/src/mongospecs_web/backend.py index 61fefb2..ce85e5e 100644 --- a/packages/mongospecs-web/src/mongospecs_web/backend.py +++ b/packages/mongospecs-web/src/mongospecs_web/backend.py @@ -9,7 +9,7 @@ import typing as t from dataclasses import dataclass, field -from mongospecs import DocumentNotFound, Page, Q, QueryError, meta_of +from mongospecs import DocumentNotFound, Page, Q, QueryError, StaleDocumentError, meta_of from mongospecs.expr import Condition, Expr, Field __all__ = ["OPERATORS", "Filter", "ListQuery", "MongoBackend", "ResourceBackend"] @@ -75,16 +75,25 @@ async def patch( self, id: t.Any, data: dict[str, t.Any], *, scope: t.Any, if_rev: int | None = None ) -> T: ... - async def delete(self, id: t.Any, *, scope: t.Any) -> None: ... + async def delete(self, id: t.Any, *, scope: t.Any, if_rev: int | None = None) -> None: ... class MongoBackend(t.Generic[T]): """``ResourceBackend`` over a mongospecs model (or a manager from ``Model.using(...)``).""" - def __init__(self, model: type[T], manager: t.Any = None) -> None: + def __init__(self, model: type[T], manager: t.Any = None, *, max_time_ms: int | None = 10_000) -> None: self.model = model self._manager = manager self.meta = meta_of(model) + self.max_time_ms = max_time_ms + """Time limit for reads made on behalf of a request, used when neither the registry + nor the model sets one. A request should not be able to run a query without bound.""" + + def _bounded(self, query: t.Any) -> t.Any: + manager = self.manager + if self.max_time_ms is None or manager.registry.max_time_ms or manager.meta.config.max_time_ms: + return query + return query.options(max_time_ms=self.max_time_ms) @property def manager(self) -> t.Any: @@ -104,6 +113,7 @@ async def get( query = self._by_id(id, scope) if query is None: return None + query = self._bounded(query) if read_only: query = query.lean() # nothing will be saved: skip change tracking for path in include: @@ -111,7 +121,7 @@ async def get( return t.cast("T | None", await query.first()) async def list(self, query: ListQuery, *, scope: t.Any) -> Page[T]: - q = self.manager.find(*(f.to_expr() for f in query.filters)).lean() # listings are read-only + q = self._bounded(self.manager.find(*(f.to_expr() for f in query.filters))).lean() # read-only if scope is not None: q = q.filter(scope) if query.sort: @@ -141,9 +151,19 @@ async def patch(self, id: t.Any, data: dict[str, t.Any], *, scope: t.Any, if_rev target = trusted(query._user_filter()) return t.cast(T, await self.manager.update(target, if_rev=if_rev, **data)) - async def delete(self, id: t.Any, *, scope: t.Any) -> None: + async def delete(self, id: t.Any, *, scope: t.Any, if_rev: int | None = None) -> None: query = self._by_id(id, scope) doc = await query.first() if query is not None else None if doc is None: raise DocumentNotFound(f"{self.model.__name__} {id!r} does not exist") - await self.manager.delete_document(doc) + # The document is loaded so that hooks, soft delete and cascades run. The scope is + # then also part of the delete's own filter, so it holds at the moment of the write. + condition = self.manager.compile(scope) if scope is not None else None + try: + await self.manager.delete_document(doc, condition=condition, if_rev=if_rev) + except StaleDocumentError: + if if_rev is not None: + raise + if scope is not None and not await self._by_id(id, scope).exists(): + raise DocumentNotFound(f"{self.model.__name__} {id!r} does not exist") from None + raise diff --git a/packages/mongospecs-web/src/mongospecs_web/filters.py b/packages/mongospecs-web/src/mongospecs_web/filters.py index 2a61322..86ede91 100644 --- a/packages/mongospecs-web/src/mongospecs_web/filters.py +++ b/packages/mongospecs-web/src/mongospecs_web/filters.py @@ -40,8 +40,12 @@ class Pagination: default: int = 20 max: int = 100 - total: bool = True - """Whether clients may ask for a total count with ``?total=true``.""" + total: bool = False + """Whether clients may ask for a total count with ``?total=true``. Off by default: a + count is a second query over every match, which a client should not get for free.""" + max_offset: int = 10_000 + """Offset pagination only: how many documents a page may skip. Deep offsets make the + server walk and discard everything before them; use cursors beyond this.""" kind: t.ClassVar[str] = "cursor" @@ -122,6 +126,10 @@ def parse_list_query( if after is not None: raise BadRequest("this resource uses page numbers; use page= instead of after=") number = _int(grouped["page"][-1], "page") if "page" in grouped else 1 + if (number - 1) * size > pagination.max_offset: + raise BadRequest( + f"page {number} is too deep: at most {pagination.max_offset} results can be skipped" + ) elif "page" in grouped: raise BadRequest("this resource uses cursors; use after= instead of page=") total = _bool(grouped["total"][-1], "total") if "total" in grouped else False diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py b/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py index a929f75..00b8ebd 100644 --- a/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py @@ -7,6 +7,7 @@ from urllib.parse import parse_qsl from mongospecs_web.api import Api +from mongospecs_web.errors import HTTPError from mongospecs_web.resource import Response __all__ = ["AsgiApp"] @@ -59,21 +60,27 @@ async def _http(self, scope: Scope, receive: Receive, send: Send) -> None: path: str = scope["path"] if root and path.startswith(root): path = path[len(root) :] or "/" - if method == "GET" and path == self.api.openapi_path: + if method == "GET" and path == self.api.openapi_path and self.api.expose_openapi: if self._openapi is None: self._openapi = json.dumps(self.api.openapi(), separators=(",", ":")).encode() await _send(send, Response(200, self._openapi, {})) return - body = b"" - limit = self.api.max_body - while True: - message = await receive() - if message["type"] == "http.disconnect": - return - body += message.get("body", b"") - if len(body) > limit or not message.get("more_body"): - break headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + + async def chunks() -> t.AsyncIterator[bytes]: + while True: + message = await receive() + if message["type"] == "http.disconnect": + return + yield message.get("body", b"") + if not message.get("more_body"): + return + + try: + body = await self.api.read_body(headers, chunks()) + except HTTPError as exc: + await _send(send, Response.error(exc)) + return query = parse_qsl(scope.get("query_string", b"").decode("latin-1"), keep_blank_values=True) response = await self.api.handle(method, path, query=query, headers=headers, body=body, raw=scope) await _send(send, response) diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py b/packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py index 917b1ef..a58057c 100644 --- a/packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py @@ -16,15 +16,21 @@ from starlette.responses import Response as StarletteResponse from mongospecs_web.api import Api, Route +from mongospecs_web.errors import HTTPError from mongospecs_web.openapi import build_openapi, operation_object from mongospecs_web.plugs.starlette import lifespan, to_request, to_response +from mongospecs_web.resource import Response __all__ = ["lifespan", "mount", "router"] def _endpoint(api: Api, route: Route) -> t.Callable[..., t.Awaitable[StarletteResponse]]: async def endpoint(request: StarletteRequest) -> StarletteResponse: - return to_response(await api.run(route, await to_request(request))) + try: + neutral = await to_request(api, request) + except HTTPError as exc: + return to_response(Response.error(exc)) + return to_response(await api.run(route, neutral)) return endpoint diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/litestar.py b/packages/mongospecs-web/src/mongospecs_web/plugs/litestar.py index 6bb3403..30376cf 100644 --- a/packages/mongospecs-web/src/mongospecs_web/plugs/litestar.py +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/litestar.py @@ -17,6 +17,7 @@ from litestar.plugins import InitPluginProtocol from mongospecs_web.api import Api, Route +from mongospecs_web.errors import HTTPError from mongospecs_web.resource import Request, Response __all__ = ["MongospecsPlugin", "handlers"] @@ -40,12 +41,17 @@ def _handler(api: Api, target: Route) -> t.Any: @route(path, http_method=[method], name=target.name, include_in_schema=False, status_code=200) async def handler(request: LitestarRequest[t.Any, t.Any, t.Any]) -> LitestarResponse[bytes]: + headers = {k.lower(): v for k, v in request.headers.items()} + try: + body = await api.read_body(headers, request.stream()) + except HTTPError as exc: + return _to_response(Response.error(exc)) neutral = Request( request.method, {k: str(v) for k, v in request.path_params.items()}, list(request.query_params.multi_items()), - {k.lower(): v for k, v in request.headers.items()}, - await request.body(), + headers, + body, None, request, ) @@ -54,10 +60,10 @@ async def handler(request: LitestarRequest[t.Any, t.Any, t.Any]) -> LitestarResp return handler -def handlers(api: Api, *, openapi: bool = True) -> list[t.Any]: +def handlers(api: Api, *, openapi: bool | None = None) -> list[t.Any]: """Litestar route handlers for every operation of the API.""" out = [_handler(api, target) for target in api._routes()] - if openapi: + if api.expose_openapi if openapi is None else openapi: @get(api.openapi_path, name="mongospecs_openapi", include_in_schema=False, sync_to_thread=False) def spec() -> dict[str, t.Any]: @@ -70,7 +76,7 @@ def spec() -> dict[str, t.Any]: class MongospecsPlugin(InitPluginProtocol): """``Litestar(plugins=[MongospecsPlugin(api, registry=registry)])``""" - def __init__(self, api: Api, *, registry: t.Any = None, openapi: bool = True) -> None: + def __init__(self, api: Api, *, registry: t.Any = None, openapi: bool | None = None) -> None: self.api = api self.registry = registry self.openapi = openapi diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/quart.py b/packages/mongospecs-web/src/mongospecs_web/plugs/quart.py index 2cd7548..4d87c96 100644 --- a/packages/mongospecs-web/src/mongospecs_web/plugs/quart.py +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/quart.py @@ -9,34 +9,43 @@ from quart import request as quart_request from mongospecs_web.api import Api, Route -from mongospecs_web.resource import Request +from mongospecs_web.errors import HTTPError +from mongospecs_web.resource import Request, Response __all__ = ["blueprint", "init_app"] +def _to_quart(response: Response) -> QuartResponse: + out = QuartResponse(response.body, response.status, dict(response.headers)) + if response.body or response.status not in (204, 304): + out.headers["content-type"] = response.media_type + else: + out.headers.pop("content-type", None) + return out + + def _view(api: Api, route: Route) -> t.Callable[..., t.Awaitable[QuartResponse]]: async def view(**path_params: t.Any) -> QuartResponse: + headers = {k.lower(): v for k, v in quart_request.headers.items()} + try: + body = await api.read_body(headers, quart_request.body) + except HTTPError as exc: + return _to_quart(Response.error(exc)) neutral = Request( quart_request.method, {k: str(v) for k, v in path_params.items()}, list(quart_request.args.items(multi=True)), - {k.lower(): v for k, v in quart_request.headers.items()}, - t.cast(bytes, await quart_request.get_data(as_text=False)), + headers, + body, None, quart_request, ) - response = await api.run(route, neutral) - out = QuartResponse(response.body, response.status, dict(response.headers)) - if response.body or response.status not in (204, 304): - out.headers["content-type"] = response.media_type - else: - out.headers.pop("content-type", None) - return out + return _to_quart(await api.run(route, neutral)) return view -def blueprint(api: Api, *, name: str = "mongospecs", openapi: bool = True) -> Blueprint: +def blueprint(api: Api, *, name: str = "mongospecs", openapi: bool | None = None) -> Blueprint: """A Blueprint with one rule per operation (plus ``openapi.json``).""" bp = Blueprint(name, __name__) for route in api._routes(): @@ -46,7 +55,7 @@ def blueprint(api: Api, *, name: str = "mongospecs", openapi: bool = True) -> Bl bp.add_url_rule( rule, endpoint=route.name.replace(".", "_"), view_func=_view(api, route), methods=[route.method] ) - if openapi: + if api.expose_openapi if openapi is None else openapi: async def spec() -> dict[str, t.Any]: return api.openapi() diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/starlette.py b/packages/mongospecs-web/src/mongospecs_web/plugs/starlette.py index 600a090..66bb5c1 100644 --- a/packages/mongospecs-web/src/mongospecs_web/plugs/starlette.py +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/starlette.py @@ -12,18 +12,21 @@ from starlette.routing import Route as StarletteRoute from mongospecs_web.api import Api, Route +from mongospecs_web.errors import HTTPError from mongospecs_web.resource import Request, Response __all__ = ["lifespan", "mount", "routes", "to_request", "to_response"] -async def to_request(request: StarletteRequest) -> Request: +async def to_request(api: Api, request: StarletteRequest) -> Request: + """Translate a Starlette request. The body is read under the API's size limit.""" + headers = {k.lower(): v for k, v in request.headers.items()} return Request( request.method, {k: str(v) for k, v in request.path_params.items()}, list(request.query_params.multi_items()), - {k.lower(): v for k, v in request.headers.items()}, - await request.body(), + headers, + await api.read_body(headers, request.stream()), None, request, ) @@ -38,18 +41,22 @@ def to_response(response: Response) -> StarletteResponse: def _endpoint(api: Api, route: Route) -> t.Callable[[StarletteRequest], t.Awaitable[StarletteResponse]]: async def endpoint(request: StarletteRequest) -> StarletteResponse: - return to_response(await api.run(route, await to_request(request))) + try: + neutral = await to_request(api, request) + except HTTPError as exc: + return to_response(Response.error(exc)) + return to_response(await api.run(route, neutral)) return endpoint -def routes(api: Api, *, openapi: bool = True) -> list[BaseRoute]: +def routes(api: Api, *, openapi: bool | None = None) -> list[BaseRoute]: """Starlette routes for every operation of the API (plus ``openapi.json``).""" out: list[BaseRoute] = [ StarletteRoute(route.path, _endpoint(api, route), methods=[route.method], name=route.name) for route in api._routes() ] - if openapi: + if api.expose_openapi if openapi is None else openapi: async def spec(request: StarletteRequest) -> StarletteResponse: return JSONResponse(api.openapi()) @@ -58,7 +65,7 @@ async def spec(request: StarletteRequest) -> StarletteResponse: return out -def mount(app: t.Any, api: Api, *, openapi: bool = True) -> None: +def mount(app: t.Any, api: Api, *, openapi: bool | None = None) -> None: """Add the API's routes to an existing Starlette application.""" app.router.routes.extend(routes(api, openapi=openapi)) diff --git a/packages/mongospecs-web/src/mongospecs_web/policy.py b/packages/mongospecs-web/src/mongospecs_web/policy.py index 447ba5e..e2b31cb 100644 --- a/packages/mongospecs-web/src/mongospecs_web/policy.py +++ b/packages/mongospecs-web/src/mongospecs_web/policy.py @@ -24,8 +24,9 @@ class View: """What of a model is readable, writable, filterable and sortable from outside.""" - def __init__(self, model: type) -> None: + def __init__(self, model: type, *, explicit_writes: bool = False) -> None: self.model = model + self.explicit_writes = explicit_writes self.meta: ModelMeta = meta_of(model) fields = self.meta.fields managed = {f.name for f in fields.values() if any(f.has(m) for m in _MANAGED)} @@ -36,13 +37,19 @@ def __init__(self, model: type) -> None: writable = frozenset( f.name for f in fields.values() if f.name != "id" and f.name not in managed and not f.transient ) + + def may(flag: bool | None, default: bool) -> bool: + # explicit mode: only what the policy turns on; otherwise the policy's own default + return flag is True if explicit_writes else default + self.insertable: frozenset[str] = frozenset( - f.name - for f in fields.values() - if f.policy.can_insert and f.name != "id" and f.name not in managed and not f.transient + name for name in writable if may(fields[name].policy.insert, fields[name].policy.can_insert) ) self.updatable: frozenset[str] = frozenset( - name for name in writable if fields[name].policy.can_update and not fields[name].has(Immutable) + name + for name in writable + if may(fields[name].policy.update, fields[name].policy.can_update) + and not fields[name].has(Immutable) ) self.filterable: dict[str, tuple[str, ...]] = { f.name: f.policy.filter_ops @@ -179,11 +186,12 @@ def one(ref: t.Any, raw: t.Any) -> t.Any: return one(value, encoded) -_VIEWS: weakref.WeakKeyDictionary[type, View] = weakref.WeakKeyDictionary() +_VIEWS: weakref.WeakKeyDictionary[type, dict[bool, View]] = weakref.WeakKeyDictionary() -def view_of(model: type) -> View: - view = _VIEWS.get(model) +def view_of(model: type, *, explicit_writes: bool = False) -> View: + views = _VIEWS.setdefault(model, {}) + view = views.get(explicit_writes) if view is None: - view = _VIEWS[model] = View(model) + view = views[explicit_writes] = View(model, explicit_writes=explicit_writes) return view diff --git a/packages/mongospecs-web/src/mongospecs_web/resource.py b/packages/mongospecs-web/src/mongospecs_web/resource.py index 7e0809c..ca98619 100644 --- a/packages/mongospecs-web/src/mongospecs_web/resource.py +++ b/packages/mongospecs-web/src/mongospecs_web/resource.py @@ -211,9 +211,13 @@ def __init__( page: Pagination | None = None, tags: t.Sequence[str] | None = None, server_fields: t.Callable[[Ctx[T]], t.Any] | None = None, + explicit_writes: bool = False, ) -> None: self.model = model - self.view: View = view_of(model) + self.view: View = view_of(model, explicit_writes=explicit_writes) + """With ``explicit_writes=True`` a field is writable only if its ``Policy`` says so + (``Policy(insert=True)``, ``Policy(update=True)``); by default ordinary fields are + writable unless marked otherwise.""" self.name = name or self.view.meta.collection self.backend: ResourceBackend[T] = backend if backend is not None else MongoBackend(model) self.scope_fn = scope @@ -551,7 +555,8 @@ async def handler(ctx: Ctx[T]) -> Response: def _delete_op(self) -> Operation: async def handler(ctx: Ctx[T]) -> None: - await self.backend.delete(ctx.id, scope=await ctx.scope()) + if_rev = _if_match(ctx.request.headers.get("if-match"), self.view.revision is not None) + await self.backend.delete(ctx.id, scope=await ctx.scope(), if_rev=if_rev) return Operation(Delete, "DELETE", "/{id}", handler, Delete, f"Delete one of {self.name}", 204) diff --git a/packages/mongospecs-web/src/mongospecs_web/testing.py b/packages/mongospecs-web/src/mongospecs_web/testing.py index 7996403..18c3a5f 100644 --- a/packages/mongospecs-web/src/mongospecs_web/testing.py +++ b/packages/mongospecs-web/src/mongospecs_web/testing.py @@ -38,11 +38,11 @@ from mongospecs_web.api import Api from mongospecs_web.backend import ListQuery from mongospecs_web.errors import Unauthorized -from mongospecs_web.filters import Offset +from mongospecs_web.filters import Cursor, Offset from mongospecs_web.policy import view_of from mongospecs_web.resource import Create, Ctx, Delete, Get, List, Patch, Request, Resource -__all__ = ["FakeBackend", "contract_api", "reference_transcript", "run_contract"] +__all__ = ["PLUGS", "FakeBackend", "contract_api", "mount_on", "reference_transcript", "run_contract"] T = t.TypeVar("T") @@ -189,10 +189,13 @@ async def patch(self, id: t.Any, data: dict[str, t.Any], *, scope: t.Any, if_rev setattr(doc, revision, getattr(doc, revision) + 1) return doc - async def delete(self, id: t.Any, *, scope: t.Any) -> None: + async def delete(self, id: t.Any, *, scope: t.Any, if_rev: int | None = None) -> None: doc = self._find(id, scope) if doc is None: raise DocumentNotFound("not found") + revision = self.view.revision + if revision is not None and if_rev is not None and getattr(doc, revision) != if_rev: + raise StaleDocumentError("stale") del self.docs[t.cast(t.Any, doc).id] @@ -301,6 +304,7 @@ def own_rows(ctx: Ctx[t.Any]) -> t.Any: model, backend=store, ops=[List, Get, Create, Patch, Delete], + page=Cursor(total=True), scope=own_rows, guards={Create: require_login, Patch: require_login, Delete: [require_login, admin_only]}, ) @@ -326,7 +330,11 @@ async def stats(ctx: Ctx[t.Any]) -> dict[str, t.Any]: return {"count": len(page.items), "stock": sum(d.stock for d in page.items)} pages: Resource[t.Any] = Resource( - model, name="widget-pages", backend=store, ops=[List], page=Offset(default=2, max=3, total=True) + model, + name="widget-pages", + backend=store, + ops=[List], + page=Offset(default=2, max=3, total=True, max_offset=8), ) return Api(widgets, pages, prefix="/api", title="Contract", version="1.0.0", authenticate=_authenticate) @@ -470,6 +478,8 @@ async def call(method: str, path: str, *, label: str, **kwargs: t.Any) -> t.Any: await call("GET", "/widget-pages?sort=name", label="offset page 1") await call("GET", "/widget-pages?sort=name&page=2&total=true", label="offset page 2 with total") await call("GET", "/widget-pages?after=x", label="cursor on offset resource") + await call("GET", "/widget-pages?page=6", label="offset page too deep") + await call("GET", "/widget-pages?page=1&total=true", label="offset total") await call("POST", "/widget-pages", label="method not allowed", json={}) # -- patch -------------------------------------------------------------------- @@ -527,7 +537,10 @@ async def call(method: str, path: str, *, label: str, **kwargs: t.Any) -> t.Any: # -- delete ----------------------------------------------------------------------- await call("DELETE", f"/widgets/{second}", label="delete needs login") await call("DELETE", f"/widgets/{second}", label="delete forbidden for non-admin", headers=bob) - await call("DELETE", f"/widgets/{second}", label="delete", headers=root) + await call( + "DELETE", f"/widgets/{second}", label="delete stale revision", headers={**root, "if-match": '"7"'} + ) + await call("DELETE", f"/widgets/{second}", label="delete", headers={**root, "if-match": '"0"'}) await call("DELETE", f"/widgets/{second}", label="delete again is 404", headers=root) await call("GET", "/widgets?sort=name", label="list after delete", headers=root) @@ -551,3 +564,41 @@ async def reference_transcript(backend: str = "msgspec") -> Transcript: transport=httpx.ASGITransport(app=api.asgi()), base_url="http://contract" ) as client: return await run_contract(client, api) + + +PLUGS = ("asgi", "starlette", "fastapi", "litestar", "quart") + + +def mount_on(plug: str, api: Api) -> t.Any: + """An ASGI application serving ``api`` through one of the built-in plugs.""" + if plug == "asgi": + return api.asgi() + if plug == "starlette": + from starlette.applications import Starlette + + from mongospecs_web.plugs.starlette import routes + + return Starlette(routes=routes(api)) + if plug == "fastapi": + from fastapi import FastAPI + + from mongospecs_web.plugs.fastapi import mount + + fastapi_app = FastAPI(openapi_url=api.openapi_path) + mount(fastapi_app, api) + return fastapi_app + if plug == "litestar": + from litestar import Litestar + + from mongospecs_web.plugs.litestar import MongospecsPlugin + + return Litestar(plugins=[MongospecsPlugin(api)], openapi_config=None) + if plug == "quart": + from quart import Quart + + from mongospecs_web.plugs.quart import init_app + + quart_app = Quart(__name__) + init_app(quart_app, api) + return quart_app + raise ValueError(f"unknown plug {plug!r}; expected one of {PLUGS}") diff --git a/packages/mongospecs-web/tests/test_contract.py b/packages/mongospecs-web/tests/test_contract.py index 7c85693..6646154 100644 --- a/packages/mongospecs-web/tests/test_contract.py +++ b/packages/mongospecs-web/tests/test_contract.py @@ -3,49 +3,14 @@ import httpx import pytest -from mongospecs_web.testing import contract_api, reference_transcript, run_contract +from mongospecs_web.testing import PLUGS, contract_api, mount_on, reference_transcript, run_contract BACKENDS = ("msgspec", "pydantic", "attrs") -PLUGS = ("asgi", "starlette", "fastapi", "litestar", "quart") - - -def build_app(plug, api): - if plug == "asgi": - return api.asgi() - if plug == "starlette": - from starlette.applications import Starlette - - from mongospecs_web.plugs.starlette import routes - - return Starlette(routes=routes(api)) - if plug == "fastapi": - from fastapi import FastAPI - - from mongospecs_web.plugs.fastapi import mount - - app = FastAPI(openapi_url="/api/openapi.json") - mount(app, api) - return app - if plug == "litestar": - from litestar import Litestar - - from mongospecs_web.plugs.litestar import MongospecsPlugin - - return Litestar(plugins=[MongospecsPlugin(api)], openapi_config=None) - if plug == "quart": - from quart import Quart - - from mongospecs_web.plugs.quart import init_app - - app = Quart("contract") - init_app(app, api) - return app - raise AssertionError(plug) async def transcript_for(plug, backend): api = contract_api(backend) - app = build_app(plug, api) + app = mount_on(plug, api) async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://contract" ) as client: @@ -116,6 +81,8 @@ async def test_reference_transcript_is_what_the_api_should_do(backend, reference "offset page 1": 200, "offset page 2 with total": 200, "cursor on offset resource": 400, + "offset page too deep": 400, + "offset total": 200, "method not allowed": 405, "patch needs login": 401, "patch": 200, @@ -135,6 +102,7 @@ async def test_reference_transcript_is_what_the_api_should_do(backend, reference "custom collection op": 200, "delete needs login": 401, "delete forbidden for non-admin": 403, + "delete stale revision": 412, "delete": 204, "delete again is 404": 404, "list after delete": 200, diff --git a/packages/mongospecs-web/tests/test_mongo_resource.py b/packages/mongospecs-web/tests/test_mongo_resource.py index 80a9a37..0c797bf 100644 --- a/packages/mongospecs-web/tests/test_mongo_resource.py +++ b/packages/mongospecs-web/tests/test_mongo_resource.py @@ -19,7 +19,7 @@ UpdatedAt, computed, ) -from mongospecs_web import Api, Create, Delete, Get, List, Offset, Patch, Resource, Unauthorized +from mongospecs_web import Api, Create, Cursor, Delete, Get, List, Offset, Patch, Resource, Unauthorized from mongospecs_web.plugs.asgi import AsgiApp BACKENDS = ("msgspec", "pydantic", "attrs") @@ -98,10 +98,17 @@ def mine(ctx): api = Api( Resource(Author, ops=[List, Get, Create, Patch]), Resource( - Story, ops=[List, Get, Create, Patch, Delete], include=["author", "reactions", "reaction_count"] + Story, + ops=[List, Get, Create, Patch, Delete], + include=["author", "reactions", "reaction_count"], + page=Cursor(total=True), ), Resource( - Story, name="my-stories", ops=[List, Get, Patch, Delete], scope=mine, page=Offset(default=10) + Story, + name="my-stories", + ops=[List, Get, Patch, Delete], + scope=mine, + page=Offset(default=10, total=True, max_offset=50), ), prefix="/v1", authenticate=authenticate, @@ -310,3 +317,54 @@ async def send(message): await asgi({"type": "lifespan"}, receive, send) assert sent == ["lifespan.startup.complete", "lifespan.shutdown.complete"] assert not registry.is_open + + +async def test_delete_is_conditional_and_scoped_at_the_write(app): + client, (Author, Story, Reaction), api, registry = app + ann = await Author(name="Ann", email="a").insert() + story = await Story(title="s", author=ann).insert() + as_ann = {"x-user": str(ann.id)} + # If-Match on DELETE: a stale revision is refused, the current one is accepted + await client.patch(f"/v1/stories/{story.id}", json={"words": 5}) + stale = await client.delete(f"/v1/stories/{story.id}", headers={"if-match": '"0"'}) + assert stale.status_code == 412 + assert (await client.get(f"/v1/stories/{story.id}")).status_code == 200 + # the row scope is part of the delete's own filter, not only of the lookup before it + events = [] + observer = type(registry)("mongodb://localhost:27117/?replicaSet=rs0", on_command=events.append) + await observer.open() + try: + from mongospecs_web.backend import MongoBackend + + backend = MongoBackend(Story, observer.manager(Story, db=registry.database().name)) + await backend.delete(str(story.id), scope={"author": ann.id}, if_rev=1) + finally: + await observer.close() + (write,) = [e.command for e in events if e.phase == "started" and e.command_name == "update"] + assert write["updates"][0]["q"] == {"_id": story.id, "rev": 1, "author": ann.id} + assert (await client.get(f"/v1/my-stories/{story.id}", headers=as_ann)).status_code == 404 # soft-deleted + + +async def test_listing_limits(app): + client, (Author, Story, Reaction), api, registry = app + ann = await Author(name="Ann", email="a").insert() + await Story.insert_many([Story(title=f"s{i}", author=ann) for i in range(3)]) + as_ann = {"x-user": str(ann.id)} + assert (await client.get("/v1/my-stories?page=6&limit=10", headers=as_ann)).status_code == 200 + deep = await client.get("/v1/my-stories?page=7&limit=10", headers=as_ann) + assert deep.status_code == 400 and "too deep" in deep.json()["detail"] + assert (await client.get("/v1/authors?total=true")).status_code == 400 # totals are opt-in per resource + # reads made for a request carry a time limit even when nothing else sets one + events = [] + observer = type(registry)("mongodb://localhost:27117/?replicaSet=rs0", on_command=events.append) + await observer.open() + try: + from mongospecs_web.backend import ListQuery, MongoBackend + + manager = observer.manager(Story, db=registry.database().name) + await MongoBackend(Story, manager).list(ListQuery(), scope=None) + await MongoBackend(Story, manager, max_time_ms=None).list(ListQuery(), scope=None) + finally: + await observer.close() + finds = [e.command for e in events if e.phase == "started" and e.command_name == "find"] + assert finds[0]["maxTimeMS"] == 10_000 and "maxTimeMS" not in finds[1] diff --git a/packages/mongospecs-web/tests/test_openapi_views.py b/packages/mongospecs-web/tests/test_openapi_views.py index 9fc997d..eb465bf 100644 --- a/packages/mongospecs-web/tests/test_openapi_views.py +++ b/packages/mongospecs-web/tests/test_openapi_views.py @@ -4,7 +4,7 @@ from mongospecs_web import Api, Resource, view_of from mongospecs_web.policy import jsonable -from mongospecs_web.testing import contract_api +from mongospecs_web.testing import contract_api, mount_on BACKENDS = ("msgspec", "pydantic", "attrs") @@ -160,3 +160,109 @@ async def test_api_handle_without_a_server(): assert (await api.handle("GET", "/api/widgets/stats")).status == 200 # literal segment beats {id} assert (await api.handle("GET", "/api/nope")).status == 404 assert (await api.handle("PUT", "/api/widgets")).headers["allow"] == "GET, POST" + + +BODY_PLUGS = ("asgi", "starlette", "fastapi", "litestar", "quart") + + +@pytest.mark.parametrize("plug", BODY_PLUGS) +async def test_oversized_bodies_are_refused_without_being_buffered(plug): + """Every plug stops reading at the limit, whether or not the client declares the size.""" + api = contract_api("msgspec") + api.max_body = 256 + app = mount_on(plug, api) + headers = {"authorization": "Bearer ann", "content-type": "application/json"} + received = 0 + + async def endless(): + nonlocal received + yield b'{"name": "' + for _ in range(10_000): + received += 1 + yield b"x" * 64 + + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client: + declared = await client.post("/api/widgets", content=b"x" * 1000, headers=headers) + assert declared.status_code == 413 and declared.json()["status"] == 413 + streamed = await client.post("/api/widgets", content=endless(), headers=headers) + assert streamed.status_code == 413 + ok = await client.post("/api/widgets", content=b'{"name": "fits"}', headers=headers) + assert ok.status_code == 201 + + +async def test_body_is_not_read_past_the_limit(): + """Driving the ASGI app directly shows that reading stops once the limit is passed.""" + api = contract_api("msgspec") + api.max_body = 256 + pulls = 0 + + async def receive(): + nonlocal pulls + pulls += 1 + return {"type": "http.request", "body": b"x" * 100, "more_body": True} + + sent = [] + + async def send(message): + sent.append(message) + + scope = { + "type": "http", + "method": "POST", + "path": "/api/widgets", + "query_string": b"", + "headers": [(b"authorization", b"Bearer ann"), (b"content-type", b"application/json")], + } + await api.asgi()(scope, receive, send) + assert sent[0]["status"] == 413 and pulls == 3 # 300 bytes read, then stopped + + +async def test_head_options_and_private_openapi(): + api = contract_api("msgspec") + created = await api.handle( + "POST", "/api/widgets", body=b'{"name":"w"}', headers={"authorization": "Bearer ann"} + ) + path = created.headers["location"] + get = await api.handle("GET", path, headers={"authorization": "Bearer ann"}) + head = await api.handle("HEAD", path, headers={"authorization": "Bearer ann"}) + assert head.status == 200 and head.body == b"" and head.headers["etag"] == get.headers["etag"] + options = await api.handle("OPTIONS", path) + assert options.status == 204 and options.headers["allow"] == "DELETE, GET, HEAD, OPTIONS, PATCH" + assert (await api.handle("OPTIONS", "/api/nope")).status == 404 + assert (await api.handle("HEAD", "/api/widgets/stats")).status == 200 + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=api.asgi()), base_url="http://t" + ) as client: + assert (await client.get("/api/openapi.json")).status_code == 200 + assert (await client.head(path, headers={"authorization": "Bearer ann"})).status_code == 200 + model = api.resources[0].model + private = Api(Resource(model), prefix="/api", openapi=False) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=private.asgi()), base_url="http://t" + ) as client: + assert (await client.get("/api/openapi.json")).status_code == 404 + assert "paths" in private.openapi() # still available to serve behind your own authentication + + +def test_explicit_writes_mode_is_default_deny(): + import typing as t + + from mongospecs import Policy + from mongospecs.msgspec import Spec + + model = contract_api("msgspec").resources[0].model + open_view = Resource(model).view + strict = Resource(model, explicit_writes=True).view + assert {"name", "price", "tags", "sku"} <= open_view.insertable + # nothing on the contract model opts in explicitly, so nothing is writable + assert strict.insertable == frozenset() and strict.updatable == frozenset() + assert strict.schema("create")["properties"] == {} + assert view_of(model, explicit_writes=True) is strict and view_of(model) is open_view + + class Opt(Spec): + title: t.Annotated[str, Policy(insert=True, update=True)] = "" + internal: str = "" + created_only: t.Annotated[str, Policy(insert=True)] = "" + + view = view_of(Opt, explicit_writes=True) + assert view.insertable == {"title", "created_only"} and view.updatable == {"title"} diff --git a/packages/mongospecs/src/mongospecs/manager.py b/packages/mongospecs/src/mongospecs/manager.py index dc8c0c2..23b98e4 100644 --- a/packages/mongospecs/src/mongospecs/manager.py +++ b/packages/mongospecs/src/mongospecs/manager.py @@ -354,7 +354,21 @@ async def _raise_unmatched(self, flt: dict[str, t.Any], session: t.Any) -> t.NoR ) raise DocumentNotFound(f"{self.model.__name__} {flt['_id']!r} does not exist") - async def delete_document(self, doc: T, *, session: t.Any = None, hard: bool = False) -> None: + async def delete_document( + self, + doc: T, + *, + session: t.Any = None, + hard: bool = False, + condition: t.Mapping[str, t.Any] | None = None, + if_rev: int | None = None, + ) -> None: + """Delete a document (softly when the model has a ``DeletedAt`` field, unless ``hard``). + + ``condition`` is an extra filter the document must still satisfy at the moment of the + delete (a row scope, for instance). With a ``Revision`` field the delete also requires + the revision that was loaded, or ``if_rev`` when given. + """ any_doc = t.cast(t.Any, doc) if any_doc.id is None: raise QueryError("cannot delete a document that has no id") @@ -363,7 +377,15 @@ async def delete_document(self, doc: T, *, session: t.Any = None, hard: bool = F ctx = self.ctx("delete", session, doc=doc, snapshot=snapshot) await self._hooks("before", ("delete",), ctx, doc) flt: dict[str, t.Any] = {"_id": snapshot["_id"], **self._shard_filter(snapshot)} + if if_rev is not None: + ctx.extra["if_rev"] = if_rev soft = None if hard else self._soft_delete(ctx, flt) + revision = next((f for f in self.meta.fields.values() if f.has(Revision)), None) + if revision is not None and (state is not None or if_rev is not None): + expected = if_rev if if_rev is not None else snapshot.get(revision.stored) + flt[revision.stored] = {"$in": [0, None]} if not expected else expected + if condition: + flt = and_filters(flt, condition) async def run(s: t.Any) -> None: if isinstance(s, Tx): diff --git a/packages/mongospecs/src/mongospecs/model.py b/packages/mongospecs/src/mongospecs/model.py index 65ee4f7..fcaf342 100644 --- a/packages/mongospecs/src/mongospecs/model.py +++ b/packages/mongospecs/src/mongospecs/model.py @@ -231,9 +231,13 @@ async def save(self: T, *, session: t.Any = None) -> T: """Insert a new document, or send only the fields that changed since it was loaded.""" return t.cast(T, await self._own_manager().save(self, session=session)) - async def delete(self, *, session: t.Any = None, hard: bool = False) -> None: - """Delete this document (a soft delete when the model has a ``DeletedAt`` field, unless ``hard``).""" - await self._own_manager().delete_document(self, session=session, hard=hard) + async def delete(self, *, session: t.Any = None, hard: bool = False, if_rev: int | None = None) -> None: + """Delete this document (a soft delete when the model has a ``DeletedAt`` field, unless ``hard``). + + With a ``Revision`` field the delete fails with ``StaleDocumentError`` if the document + changed since it was loaded (or since revision ``if_rev``). + """ + await self._own_manager().delete_document(self, session=session, hard=hard, if_rev=if_rev) async def reload(self: T, *, session: t.Any = None) -> T: """Refresh this document in place from the database.""" From e6957632aace35ec27ce0a8ffa6e2565dc25b885 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 02:32:13 -0400 Subject: [PATCH 14/31] docs,ci: review follow-ups - parity page: 28 of 35 Mongoose behaviours now confirmed against its documentation; three more deliberate differences recorded - plan amendments A25-A29: the computed decorator, Atlas Search and vector indexes declared out of scope for 0.2 (not testable here), revision-checked hard deletes, the changed web defaults - guides: limits and defaults of the web layer, untracked documents, partial bulk_save failures, registries and explicit managers - CI: MongoDB 7.0 on Python 3.11, 3.13 and 3.15 - publish workflow runs lint, types and the test suite first Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yaml | 6 +++++ .github/workflows/python-publish.yaml | 13 +++++++++ claudedocs/v2-plan.md | 22 ++++++++++++++++ docs/guide/operations.md | 7 +++++ docs/guide/writes.md | 12 +++++++++ docs/parity.md | 38 ++++++++++++++------------- docs/web/resources.md | 20 +++++++++++++- 7 files changed, 99 insertions(+), 19 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3eef0a6..15fadcf 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -33,9 +33,15 @@ jobs: mongo: ["8.0"] experimental: [false] include: + - python: "3.11" + mongo: "7.0" + experimental: false - python: "3.13" mongo: "7.0" experimental: false + - python: "3.15" + mongo: "7.0" + experimental: false - python: "3.14t" mongo: "8.0" experimental: true diff --git a/.github/workflows/python-publish.yaml b/.github/workflows/python-publish.yaml index 041b955..66a28d0 100644 --- a/.github/workflows/python-publish.yaml +++ b/.github/workflows/python-publish.yaml @@ -11,7 +11,20 @@ permissions: contents: read jobs: + verify: + # Nothing is published from a commit that does not pass the checks. + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: make up + - run: uv sync --all-packages --python 3.13 + - run: make lint + - run: make types + - run: uv run pytest + publish: + needs: verify runs-on: ubuntu-latest environment: name: pypi diff --git a/claudedocs/v2-plan.md b/claudedocs/v2-plan.md index 8c69b01..b49734a 100644 --- a/claudedocs/v2-plan.md +++ b/claudedocs/v2-plan.md @@ -876,6 +876,11 @@ Recorded per §7.1 rule 4. Each one names what changed and why; none invalidates | A22 | §6 | Workload B7 (HTTP per plug) is the `LOAD` workload: a real server process under load, with server CPU per request as the primary metric | Measuring plugs through an in-process test client, and later through httpx over TCP, measured the client | | A23 | §4.5 | An upsert gives the inserted document the model's non-null defaults through `$setOnInsert` | Otherwise a document created by upsert differs in shape from one created by `insert()` and queries on defaulted values miss it (Mongoose: `setDefaultsOnInsert`) | | A24 | §4.2 | Subclass discovery ignores stale class copies | `attrs.define` replaces a class with a slotted copy and the original lingers until collected; found by the free-threaded CI job | +| A25 | §4.2 | The `Computed` marker became the `@computed` property decorator | A marker goes on an annotation; a computed value is a property and has none | +| A26 | §4.6 | Atlas Search and vector search indexes are not in 0.2. `Index` covers ordinary, compound, text, geospatial, TTL, partial and collated indexes | They need Atlas (or a `mongot` deployment) to create and to test; nothing here could verify them. Declared out of scope rather than shipped untested | +| A27 | §4.3 | A8 is superseded: deleting a loaded document checks its revision on hard deletes as well, and `delete(if_rev=)` / HTTP `If-Match` on DELETE are supported | Raised in review | +| A28 | §4.8 | Web defaults changed after review: totals are opt-in per resource, offset pages are capped by `max_offset`, request reads get a 10s time limit when nothing else sets one, bodies are size-limited while being read on every plug, the row scope is part of the DELETE filter, `Api(openapi=False)` and `Resource(explicit_writes=True)` exist | Raised in review | +| A29 | §4.5 | `bulk_save` writes are independent: successes are brought in sync in memory and the error lists the failures. `Query.update()` returns an int carrying `.matched` and `.modified`. Cursor pagination always fetches its sort keys. Cascades carry a visited set and work in chunks | Defects reproduced in review, each with a regression test | New node: @@ -899,3 +904,20 @@ server errors were not logged anywhere. | D1 | No Rust in 0.2. The only candidate worth a native component is a BSON-to-model decoder for bulk reads, as a separate optional package behind `Codec.decode(bytes)` | BSON-to-dict is essentially all of the driver's time on bulk reads (10.1 us per nested document against 3.3 us for msgspec's dict-to-model step) but an estimated 10-20% of server CPU on request-sized reads. Needs Jay's call on whether bulk reads matter enough | | Open decision 4 | Reads validate; the `trusted` option is removed | Measured slower than validating on pydantic, no effect elsewhere | | Open decision 6 | Tracked stays the default read mode | 0-15% over lean and about 1.2 KB per document after the raw-BSON snapshot change | + +### Review of the pull requests (2026-10-13) + +A second session reviewed #6-#8 and reproduced five defects (duplicate pages with `select()`, a bare-id +`Location` header, `bulk_save` leaving memory out of sync, `bulk_write` skipping cascades, an unhelpful +error when saving a copy). All five are fixed with regression tests, along with an infinite recursion on +cyclic cascades found while reproducing them. Its read-only findings on the web layer are addressed in A28. + +Known and not changed: + +- `registry._BINDINGS` and `tracking._states` are process-global. `Model.find()` resolves the registry a + model is bound to, so one model class cannot be bound to two open registries at once; code that needs + several registries or one registry per thread uses `registry.manager(Model)`. A task-scoped "current + registry" was suggested and is not built. +- S7 (confirming every Mongoose parity row against its documentation) is done for 28 of 35 rows; + `docs/parity.md` marks the 7 that are still from memory (alias, `useDb`, per-query driver options, + `exists`/counts, subdocuments and maps, getters/setters, virtuals). diff --git a/docs/guide/operations.md b/docs/guide/operations.md index 0feccdf..536ac7f 100644 --- a/docs/guide/operations.md +++ b/docs/guide/operations.md @@ -75,6 +75,13 @@ mongospecs raises its own errors and keeps the driver error as `__cause__`. | `OperationTimeout`, `ConnectionFailed`, `PoolTimeout` | the database is slow or unreachable | | `DocumentTooLarge` | a document or pipeline result exceeded 16MB | +## One registry per model, or explicit managers + +`Model.find()` and the other class methods use the registry the model is bound to, and a model +class can be bound to only one open registry at a time. When you need the same model on several +registries, or one registry per thread, ask the registry for a manager instead: +`registry.manager(Article).find(...)`. Documents loaded through a manager are saved through it. + ## More than one database ```python diff --git a/docs/guide/writes.md b/docs/guide/writes.md index 054de90..2eee8c4 100644 --- a/docs/guide/writes.md +++ b/docs/guide/writes.md @@ -50,6 +50,11 @@ await article.save() assert article.changes() == {} and article.rev == 1 ``` +A document you did not get from a tracked read has no snapshot: a `lean()` result, a +`copy.copy()` or a deep copy of a tracked document, or one that was unpickled. Saving one tries to +insert it and fails with a `DuplicateKeyError` that says so. Use a tracked read, or +`Model.hydrate(raw)` to adopt a raw document. + Nested documents are diffed into dotted paths. A changed list is sent whole; for element-level changes that must not overwrite concurrent writers, use the atomic operators below. @@ -115,6 +120,13 @@ assert (await Article.find(Q.title == "bulk 0").one()).views == 6 `insert_many(..., ordered=False)` attempts every document and reports failures per item instead of raising on the first. +The writes in a `bulk_save` are independent. If some fail (a duplicate key, a revision that moved +on), the rest are still written and are in sync in memory; the error raised afterwards lists the +documents that were not written in `.failed`. + +`Model.find(...).update(...)` returns the number of documents modified, as an `int` that also +carries `.matched` and `.modified`. + ## Transactions `registry.transaction(fn)` commits when `fn` returns. The driver re-runs `fn` on transient errors, diff --git a/docs/parity.md b/docs/parity.md index 4a38ccb..a16300d 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -12,41 +12,41 @@ was established: **docs** means it was re-read in the Mongoose documentation for | Mongoose | mongospecs | Covered by | Source | |---|---|---|---| -| Change tracking, delta `save()`, `isNew`, `modifiedPaths()` | snapshot at load; `save()` sends the diff; `doc.is_new`, `doc.changes()` | `test_crud.py::test_save_new_then_delta`, `test_tracking_tx.py` | memory | +| Change tracking, delta `save()`, `isNew`, `modifiedPaths()` | snapshot at load; `save()` sends the diff; `doc.is_new`, `doc.changes()` | `test_crud.py::test_save_new_then_delta`, `test_tracking_tx.py` | docs | | `lean()` | `.lean()` (models, untracked), `.raw()` (dicts) | `test_crud.py::test_read_modes` | docs | -| `Model.hydrate()` | `Model.hydrate(raw)` | `test_crud.py::test_hydrate_and_collection_escape_hatch` | memory | +| `Model.hydrate()` | `Model.hydrate(raw)` | `test_crud.py::test_hydrate_and_collection_escape_hatch` | docs | | Middleware: document, query, aggregate, model | document hooks; `on_insert`/`on_update`/`on_filter`/`on_delete`; `on_pipeline`; `before_bulk`/`after_bulk` | `test_plugins_hooks.py` | docs | -| `schema.plugin()`, `mongoose.plugin()` | marker plugins, `SpecConfig(plugins=)`, `Registry(plugins=)` | `test_plugins_hooks.py::test_registry_level_plugin_applies_to_every_model` | memory | -| `timestamps` | `CreatedAt`, `UpdatedAt` | `test_plugins_hooks.py::test_timestamps_on_every_write_path` | memory | +| `schema.plugin()`, `mongoose.plugin()` | marker plugins, `SpecConfig(plugins=)`, `Registry(plugins=)` | `test_plugins_hooks.py::test_registry_level_plugin_applies_to_every_model` | docs | +| `timestamps` | `CreatedAt`, `UpdatedAt` | `test_plugins_hooks.py::test_timestamps_on_every_write_path` | docs | | `optimisticConcurrency`, `versionKey` | `Revision` | `test_plugins_hooks.py::test_revision_increments_everywhere` | docs | | `populate()` with `match`, `select`, `options`, `perDocumentLimit`, nested paths | `include()` with `match`, `select`, `sort`, `limit`, `per_parent_limit`, dotted paths | `test_relations.py` | docs | | Virtual populate (`localField`/`foreignField`, `count`) | `Backref(...)`, `Backref(..., count=True)` | `test_relations.py::test_backrefs_and_counts` | docs | -| `refPath` | `Ref[A \| B]` | `test_relations.py::test_polymorphic_reference` | memory | +| `refPath` | `Ref[A \| B]` | `test_relations.py::test_polymorphic_reference` | docs | | Discriminators | `SpecConfig(discriminator=...)` | `test_models_misc.py::test_discriminators_share_a_collection` | docs | -| Query casting | values cast to the field type | `test_expr.py::test_values_are_cast_to_field_types` | memory | +| Query casting | values cast to the field type | `test_expr.py::test_values_are_cast_to_field_types` | docs | | `strictQuery: 'throw'` | always: unknown fields raise | `test_expr.py::test_field_names_are_translated_and_checked` | docs | -| `sanitizeFilter`, `trusted()` | raw filters untrusted unless `trusted()` | `test_expr.py::test_untrusted_mappings_and_operator_injection` | memory | +| `sanitizeFilter`, `trusted()` | raw filters untrusted unless `trusted()` | `test_expr.py::test_untrusted_mappings_and_operator_injection` | docs | | `select: false` | `Deferred` | `test_crud.py::test_deferred_field_is_not_loaded_or_clobbered` | docs | -| `immutable` | `Immutable` | `test_updates.py::test_immutable_and_required_fields_are_protected` | memory | +| `immutable` | `Immutable` | `test_updates.py::test_immutable_and_required_fields_are_protected` | docs | | `alias` | `Alias("x")` | `test_codec.py::test_alias_and_transient_fields` | memory | -| Query helpers | `@Model.scope` | `test_plugins_hooks.py::test_named_scopes` | memory | -| `orFail()` | `.or_fail()`, `.one()` | `test_crud.py::test_find_all_first_one_and_or_fail` | memory | -| `cursor()`, `eachAsync({parallel})` | `async for`, `.each(fn, concurrency=n)` | `test_crud.py::test_each_with_concurrency` | memory | +| Query helpers | `@Model.scope` | `test_plugins_hooks.py::test_named_scopes` | docs | +| `orFail()` | `.or_fail()`, `.one()` | `test_crud.py::test_find_all_first_one_and_or_fail` | docs | +| `cursor()`, `eachAsync({parallel})` | `async for`, `.each(fn, concurrency=n)` | `test_crud.py::test_each_with_concurrency` | docs | | `bulkWrite`, `bulkSave`, `insertMany({ordered: false})` | `bulk_write`, `bulk_save`, `insert_many(ordered=False)` | `test_bulk_pagination.py` | docs | | `Connection.transaction(fn)` resets document state on abort | `registry.transaction(fn)` does the same, also before each retry | `test_tracking_tx.py::test_transaction_abort_rolls_back_database_and_memory` | docs | -| `syncIndexes`, `diffIndexes`, `autoIndex: false` | `plan_indexes`, `apply_indexes(drop=...)`; never automatic | `test_indexes.py` | memory | +| `syncIndexes`, `diffIndexes`, `autoIndex: false` | `plan_indexes`, `apply_indexes(drop=...)`; never automatic | `test_indexes.py` | docs | | `shardKey` | `SpecConfig(shard_key=...)` | `test_streams_sharded.py::test_shard_key_is_added_to_document_writes` | docs | | `createConnection`, `useDb` | several registries; `Model.using(db=...)` | `test_models_misc.py::test_using_another_database` | memory | | `maxTimeMS`, `hint`, `comment`, `collation`, read/write concern | `.options(...)`, `SpecConfig`, `Registry(max_time_ms=)` | `test_registry_errors.py::test_default_max_time_ms_from_registry_and_model` | memory | -| `Model.aggregate()`, `Model.collection` | `Model.aggregate(pipeline, as_=)`, `Model.collection()` | `test_crud.py::test_aggregate_decodes_and_explain` | memory | -| `mongoose.set('debug')` | `Registry(on_command=...)` with logging, OpenTelemetry, Sentry listeners | `test_registry_errors.py::test_command_events_carry_model_and_operation` | memory | -| `Model.watch()` | `Model.watch()` | `test_streams_sharded.py::test_change_stream_events` | memory | +| `Model.aggregate()`, `Model.collection` | `Model.aggregate(pipeline, as_=)`, `Model.collection()` | `test_crud.py::test_aggregate_decodes_and_explain` | docs | +| `mongoose.set('debug')` | `Registry(on_command=...)` with logging, OpenTelemetry, Sentry listeners | `test_registry_errors.py::test_command_events_carry_model_and_operation` | docs | +| `Model.watch()` | `Model.watch()` | `test_streams_sharded.py::test_change_stream_events` | docs | | `exists`, `countDocuments`, `estimatedDocumentCount` | `exists`, `count`, `estimated_count` | `test_crud.py::test_count_exists_ids_estimated` | memory | -| Capped, time-series, TTL options | `SpecConfig(collection_options=)`, `Index(ttl=)` | `test_indexes.py::test_collection_options_are_applied` | memory | +| Capped, time-series, TTL options | `SpecConfig(collection_options=)`, `Index(ttl=)` | `test_indexes.py::test_collection_options_are_applied` | docs | | Subdocuments, arrays of subdocuments, Maps | nested models, `list[T]`, `dict[str, T]`; tracked through the parent | `test_crud.py::test_nested_document_diff_uses_dotted_paths` | memory | | Getters, setters, custom validators | your backend's converters and validators | `test_codec.py::test_validate_field_runs_the_models_own_validators` | memory | | Virtuals | Python properties; `@computed` to include one in API output | `packages/mongospecs-web/tests/test_mongo_resource.py` | memory | -| Statics and methods | classmethods and methods | (plain Python) | memory | +| Statics and methods | classmethods and methods | (plain Python) | docs | ## Deliberate differences @@ -65,6 +65,8 @@ was established: **docs** means it was re-read in the Mongoose documentation for | `transactionAsyncLocalStorage` offers an ambient session | Sessions are always passed explicitly | Tasks spawned inside a transaction would silently share one session | | Populated paths replace the id with the document | A `Ref` keeps `.id` and gains `.doc` | One type whether or not it is loaded | | Arrays are tracked per element | A changed list is sent whole | Per-element intent is expressed with atomic operators | -| A document's delete is not version-checked | Soft delete and `save()` are revision-checked; hard delete is not | Recorded as a known gap | +| `Model.watch()` errors out when it loses the primary | The change stream resumes across a step-down (the driver's behaviour, verified by a stress scenario) | Consumers should not need their own reconnect loop | +| `syncIndexes()` drops every undeclared index | `apply_indexes()` only drops or recreates with `drop=True` | Dropping an index is not something a deploy should do by accident | +| `orFail()` on an update throws when nothing was modified | `Model.update` raises when nothing matched; `find(...).update(...)` returns the matched and modified counts | A no-op update that matched is not an error | | `populate()` always runs a separate query per path | `include()` does the same by default and can use `$lookup` instead (`strategy="lookup"`) | Both give the same result; the faster one depends on fan-out, see the benchmarks | | `limit` in `populate()` is applied across all parents unless `perDocumentLimit` is used | `limit` is overall, `per_parent_limit` is per parent, without one query per parent | Same two meanings, named for what they do | diff --git a/docs/web/resources.md b/docs/web/resources.md index 3ab162d..ec3890c 100644 --- a/docs/web/resources.md +++ b/docs/web/resources.md @@ -124,8 +124,26 @@ await registry.close() ?sort=-views,title ?limit=20&after= cursor pagination (default) ?limit=20&page=3 offset pagination, with page=Offset(...) -?total=true also count all matches +?total=true also count all matches, with page=Cursor(total=True) ?include=author embed allowlisted relations ``` Only fields and operators the policy allows are accepted; everything else is a 400. + +## Limits and defaults + +These are set so that a client cannot make the server do unbounded work. + +| Setting | Default | Meaning | +|---|---|---| +| `Api(max_body=...)` | 1 MiB | Larger request bodies get a 413. The body is never buffered past the limit, on any plug | +| `Cursor(max=...)`, `Offset(max=...)` | 100 | Largest page a client can ask for | +| `Offset(max_offset=...)` | 10,000 | How many documents a page may skip; deeper pages get a 400 | +| `Cursor(total=...)`, `Offset(total=...)` | `False` | Whether `?total=true` is allowed (a count is a second query over every match) | +| `MongoBackend(max_time_ms=...)` | 10 s | Time limit for reads made for a request, when the registry and the model set none | +| `Api(openapi=...)` | `True` | Serve `/openapi.json`; turn off to keep the description private | +| `Resource(explicit_writes=...)` | `False` | When `True`, a field is writable only if its `Policy` says `insert=True` / `update=True` | + +`DELETE` honours `If-Match` like `PATCH`, and the row scope is part of the delete's own filter. +`HEAD` and `OPTIONS` are answered by `api.handle` and the pure ASGI plug; the framework plugs leave +them to the framework. From a78acb2e3df7ff3544791ee5b2d8e8093039af01 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 02:44:32 -0400 Subject: [PATCH 15/31] fix(core,web): defects from the second review of v2 Each has a regression test that failed before the change (tests/test_review_round2.py in both packages). core - a field whose default is None is not stored while it is None, and setting it to None unsets it; a unique sparse index no longer collides on stored nulls - field names that shadow the Document API raise ConfigurationError - registry.transaction() inside a running transaction raises unless independent=True (it would commit on its own) - UpdateCount formats as its int value in str() and f-strings - filter values are cast by one rule on every backend - empty Or()/Nor(), non-text prefix()/contains(), sort keys that are not field paths and server type mismatches raise QueryError - include() checks its path and strategy when the query is built - find().on_invalid('raise' | 'skip' | 'raw') web - integers beyond 64 bits are a 422 on create and patch, and an EncodingError is no longer a 500 - Quart: init_app installs BodyLimit, which reads API bodies under the limit before Quart queues them - a guard runs once per request however many places list it - OpenAPI publishes only referenced definitions and no private field - HEAD on the ASGI plug reports the length of the GET body BREAKING: optional fields that are None are omitted from stored documents, so an exists filter now means 'has a value'. Co-Authored-By: Claude Opus 5.5 --- claudedocs/v2-plan.md | 4 + docs/guide/models.md | 11 + docs/guide/queries.md | 12 ++ docs/guide/writes.md | 9 + docs/web/plugs.md | 19 ++ docs/web/resources.md | 5 + .../mongospecs-web/src/mongospecs_web/api.py | 4 +- .../src/mongospecs_web/errors.py | 4 + .../src/mongospecs_web/plugs/asgi.py | 5 +- .../src/mongospecs_web/plugs/quart.py | 68 +++++- .../src/mongospecs_web/policy.py | 71 ++++++- .../src/mongospecs_web/resource.py | 6 +- .../mongospecs-web/tests/test_contract.py | 6 +- .../tests/test_review_round2.py | 159 ++++++++++++++ packages/mongospecs/src/mongospecs/codec.py | 25 +++ packages/mongospecs/src/mongospecs/errors.py | 7 + packages/mongospecs/src/mongospecs/expr.py | 22 +- packages/mongospecs/src/mongospecs/manager.py | 9 + packages/mongospecs/src/mongospecs/meta.py | 53 ++++- packages/mongospecs/src/mongospecs/query.py | 74 +++++-- .../mongospecs/src/mongospecs/registry.py | 22 ++ .../mongospecs/src/mongospecs/relations.py | 22 ++ packages/mongospecs/src/mongospecs/updates.py | 6 +- .../mongospecs/tests/test_review_round2.py | 196 ++++++++++++++++++ 24 files changed, 780 insertions(+), 39 deletions(-) create mode 100644 packages/mongospecs-web/tests/test_review_round2.py create mode 100644 packages/mongospecs/tests/test_review_round2.py diff --git a/claudedocs/v2-plan.md b/claudedocs/v2-plan.md index b49734a..9d209c9 100644 --- a/claudedocs/v2-plan.md +++ b/claudedocs/v2-plan.md @@ -881,6 +881,10 @@ Recorded per §7.1 rule 4. Each one names what changed and why; none invalidates | A27 | §4.3 | A8 is superseded: deleting a loaded document checks its revision on hard deletes as well, and `delete(if_rev=)` / HTTP `If-Match` on DELETE are supported | Raised in review | | A28 | §4.8 | Web defaults changed after review: totals are opt-in per resource, offset pages are capped by `max_offset`, request reads get a 10s time limit when nothing else sets one, bodies are size-limited while being read on every plug, the row scope is part of the DELETE filter, `Api(openapi=False)` and `Resource(explicit_writes=True)` exist | Raised in review | | A29 | §4.5 | `bulk_save` writes are independent: successes are brought in sync in memory and the error lists the failures. `Query.update()` returns an int carrying `.matched` and `.modified`. Cursor pagination always fetches its sort keys. Cascades carry a visited set and work in chunks | Defects reproduced in review, each with a regression test | +| A30 | §4.2 | A field whose default is `None` is not stored while it is `None` (the key is omitted; setting it to `None` unsets it) | Second review: a unique sparse index collided on stored nulls. Consequence: `exists` filters mean "has a value" | +| A31 | §4.2 | Field names that shadow the `Document` API raise `ConfigurationError` (`meta.RESERVED_FIELD_NAMES`) | Second review: a field named `count` silently replaced `Model.count()` | +| A32 | §4.4 | `registry.transaction()` inside a running transaction raises unless `independent=True`. A context variable is used to detect this only; sessions are still always passed explicitly | Second review: an inner transaction committed while the outer aborted | +| A33 | §4.3 | Filter values are cast by one rule on all backends (exact type, else text that reads as the type). `find().on_invalid("raise" \| "skip" \| "raw")` | Second review | New node: diff --git a/docs/guide/models.md b/docs/guide/models.md index c6b9f87..26d2af6 100644 --- a/docs/guide/models.md +++ b/docs/guide/models.md @@ -26,6 +26,17 @@ class User(Spec): - `__spec__` is assigned **without an annotation**. It holds the collection name (default: the class name), indexes, and per-model options. - With attrs, decorate each model with `@attrs.define(kw_only=True)`. +- A field whose default is `None` is **not stored while it is `None`**: the key is left out of the + document, and a document without the key reads back as `None`. Setting such a field to `None` + removes the key (`$unset`). This is what makes `Index("email", unique=True, sparse=True)` work + for documents without an email: MongoDB indexes an explicit `null`, so two stored nulls would + collide. `Q.email == None` still finds them (it matches a missing key), and `Exists(Q.email)` + means "has a value". A field with any other default stores `None` as `null`. +- A field may not be named after part of the document API (`count`, `find`, `get`, `update`, + `delete`, `save`, `insert`, `scope`, `watch`, `changes`, `is_new`, ... the full list is + `mongospecs.meta.RESERVED_FIELD_NAMES`); it would replace that method on the class. Doing so + raises `ConfigurationError` when the model is first used. Rename the attribute and keep the + stored key: `total: Annotated[int, Alias("count")] = 0`. ## Binding and lifecycle diff --git a/docs/guide/queries.md b/docs/guide/queries.md index 5519d44..fab9699 100644 --- a/docs/guide/queries.md +++ b/docs/guide/queries.md @@ -54,8 +54,20 @@ assert await Article.count({"title": "post 3"}) == 1 assert await Article.count(trusted({"views": {"$mod": [20, 0]}})) == 5 ``` +Filter values are cast to the field's type the same way on every backend: a value of the right +type is used as it is, and text that reads as the type is accepted (`Q.age == "12"` matches the +number 12). Anything else raises `QueryError`, as do an empty `Or()`/`Nor()`, a non-text argument +to `prefix()`/`contains()`, and a sort key that is not a field path. Values given to `update()` +and to a model's constructor follow the model library's own rules instead (pydantic coerces +`"7"` to `7`; msgspec and attrs do not). + ## Results +By default one stored document that does not fit the model fails the whole read with +`ValidationError`. `find().on_invalid("skip")` leaves such documents out, and +`find().on_invalid("raw")` returns them in place as plain dicts so they can be found and +repaired. + ```python first = await Article.find(Q.views > 0).sort("views").first() # a document, or None one = await Article.find(Q.title == "post 4").one() # raises DocumentNotFound if missing diff --git a/docs/guide/writes.md b/docs/guide/writes.md index 2eee8c4..23b7e9f 100644 --- a/docs/guide/writes.md +++ b/docs/guide/writes.md @@ -90,6 +90,9 @@ except StaleDocumentError: assert stale.title == "Final v2" ``` +A document created by `upsert()` starts at revision 1, one created by `insert()` at 0: an upsert +is an update, and every update increments the revision. + ## Soft delete With a `DeletedAt` field, `delete()` hides the document instead of removing it. @@ -133,6 +136,12 @@ carries `.matched` and `.modified`. so it must be safe to call twice. If the transaction aborts, documents written inside it are put back to their previous in-memory state. +**Transactions do not nest.** MongoDB has no savepoints: a transaction started inside another is +a separate one that commits on its own and stays committed if the outer one aborts. Calling +`registry.transaction()` inside a running one raises `TransactionError`; pass the outer `tx` to +the code that needs it. When a separate commit is what you want (an audit record that must +survive a rollback), say so: `registry.transaction(fn, independent=True)`. + ```python landing = await Article.find(Q.title == "Landing").one() diff --git a/docs/web/plugs.md b/docs/web/plugs.md index 6001f92..b7a2a6d 100644 --- a/docs/web/plugs.md +++ b/docs/web/plugs.md @@ -58,6 +58,25 @@ app = Quart(__name__) plug.init_app(app, api, registry=registry) ``` +Quart queues every body chunk as it arrives, before a view asks for it, so `init_app` also wraps +the app in `plug.BodyLimit`, which reads API request bodies under `api.max_body` before Quart +sees them. If you register `plug.blueprint(api)` yourself, add +`app.asgi_app = plug.BodyLimit(app.asgi_app, api)`. + +## What the frameworks decide themselves + +The plugs register one route per operation and leave the rest of routing to the framework, so +three things differ between them. The contract suite does not cover them. + +| | Pure ASGI | Starlette | FastAPI | Litestar | Quart | +|---|---|---|---|---|---| +| Trailing slash (`/widgets/`) | 200 | 307 redirect | 307 redirect | 200 | 404 | +| `HEAD` on a `GET` route | 200, with GET's headers and length | 200 | 405 | 405 | 200 | +| `OPTIONS` | 204 with `Allow` | 405 | 405 | 204 | 200 | + +Use the framework's own settings (or its CORS middleware for `OPTIONS`) where you need one +behaviour everywhere. + ## Writing another plug Convert the framework's request to `mongospecs_web.Request`, call `await api.run(route, request)` diff --git a/docs/web/resources.md b/docs/web/resources.md index ec3890c..dd83ba6 100644 --- a/docs/web/resources.md +++ b/docs/web/resources.md @@ -144,6 +144,11 @@ These are set so that a client cannot make the server do unbounded work. | `Api(openapi=...)` | `True` | Serve `/openapi.json`; turn off to keep the description private | | `Resource(explicit_writes=...)` | `False` | When `True`, a field is writable only if its `Policy` says `insert=True` / `update=True` | +An integer outside the 64-bit range MongoDB can store is a 422 with a field error, on create and +on patch (a float or Decimal field takes it as that type). A guard listed in more than one place +runs once per request. Private fields appear nowhere in `openapi.json`, and it only publishes +the nested definitions that the Read, Create and Patch schemas refer to. + `DELETE` honours `If-Match` like `PATCH`, and the row scope is part of the delete's own filter. `HEAD` and `OPTIONS` are answered by `api.handle` and the pure ASGI plug; the framework plugs leave them to the framework. diff --git a/packages/mongospecs-web/src/mongospecs_web/api.py b/packages/mongospecs-web/src/mongospecs_web/api.py index 83dfbb0..d75e905 100644 --- a/packages/mongospecs-web/src/mongospecs_web/api.py +++ b/packages/mongospecs-web/src/mongospecs_web/api.py @@ -180,7 +180,9 @@ async def handle( route, Request(route.method, params, query or [], headers or {}, body, None, raw) ) if method == "HEAD": - response.body = b"" # same status and headers as GET, no body + # Same status and headers as GET, including the length of the body GET would send. + response.headers.setdefault("content-length", str(len(response.body))) + response.body = b"" return response def openapi(self) -> dict[str, t.Any]: diff --git a/packages/mongospecs-web/src/mongospecs_web/errors.py b/packages/mongospecs-web/src/mongospecs_web/errors.py index 0724f78..9abb1cf 100644 --- a/packages/mongospecs-web/src/mongospecs_web/errors.py +++ b/packages/mongospecs-web/src/mongospecs_web/errors.py @@ -113,6 +113,10 @@ def problem(exc: BaseException) -> HTTPError: "A resource with the same unique value already exists.", errors=[{"field": f, "message": "must be unique"} for f in fields] or None, ) + if isinstance(exc, me.EncodingError): + # A value that passed validation but has no BSON form (an integer beyond 64 bits inside + # an untyped field, say). The request is at fault, so this is not a 500. + return Unprocessable("The request contains a value that cannot be stored.") if isinstance(exc, me.QueryError): return BadRequest(str(exc)) if isinstance(exc, me.OperationTimeout): diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py b/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py index 00b8ebd..0051232 100644 --- a/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py @@ -88,8 +88,9 @@ async def chunks() -> t.AsyncIterator[bytes]: async def _send(send: Send, response: Response) -> None: headers = [(k.lower().encode("latin-1"), v.encode("latin-1")) for k, v in response.headers.items()] - if response.body or response.status not in (204, 304): + if response.body or response.status not in (204, 304): # includes the answer to a HEAD headers.append((b"content-type", response.media_type.encode("latin-1"))) - headers.append((b"content-length", str(len(response.body)).encode("latin-1"))) + if "content-length" not in response.headers: # HEAD carries the length of the GET body + headers.append((b"content-length", str(len(response.body)).encode("latin-1"))) await send({"type": "http.response.start", "status": response.status, "headers": headers}) await send({"type": "http.response.body", "body": response.body}) diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/quart.py b/packages/mongospecs-web/src/mongospecs_web/plugs/quart.py index 4d87c96..5895304 100644 --- a/packages/mongospecs-web/src/mongospecs_web/plugs/quart.py +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/quart.py @@ -10,9 +10,10 @@ from mongospecs_web.api import Api, Route from mongospecs_web.errors import HTTPError +from mongospecs_web.plugs.asgi import _send from mongospecs_web.resource import Request, Response -__all__ = ["blueprint", "init_app"] +__all__ = ["BodyLimit", "blueprint", "init_app"] def _to_quart(response: Response) -> QuartResponse: @@ -64,9 +65,72 @@ async def spec() -> dict[str, t.Any]: return bp +class BodyLimit: + """ASGI middleware: read request bodies for the API under its size limit, before Quart does. + + Quart moves every chunk a client sends into an unbounded queue as it arrives, whether or not + the view has asked for it, so a limit applied inside a view comes too late. This reads the + body first (never more than ``api.max_body`` bytes), answers 413 itself when it is too + large, and otherwise hands Quart the body it read. + """ + + def __init__(self, app: t.Any, api: Api) -> None: + self.app = app + self.api = api + + async def __call__(self, scope: t.Any, receive: t.Any, send: t.Any) -> None: + if scope["type"] != "http" or not self._ours(scope): + await self.app(scope, receive, send) + return + headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + gone = False + + async def chunks() -> t.AsyncIterator[bytes]: + nonlocal gone + while True: + message = await receive() + if message["type"] == "http.disconnect": + gone = True + return + yield message.get("body", b"") + if not message.get("more_body"): + return + + try: + body = await self.api.read_body(headers, chunks()) + except HTTPError as exc: + await _send(send, Response.error(exc)) + return + if gone: + return + replayed = False + + async def replay() -> t.Any: + nonlocal replayed + if replayed: + return await receive() + replayed = True + return {"type": "http.request", "body": body, "more_body": False} + + await self.app(scope, replay, send) + + def _ours(self, scope: t.Any) -> bool: + path: str = scope.get("path", "") + root: str = scope.get("root_path", "") + if root and path.startswith(root): + path = path[len(root) :] or "/" + return path == self.api.prefix or path.startswith(self.api.prefix + "/") + + def init_app(app: Quart, api: Api, *, registry: t.Any = None, name: str = "mongospecs") -> None: - """Register the API on a Quart app; with ``registry=`` also open and close it with the server.""" + """Register the API on a Quart app; with ``registry=`` also open and close it with the server. + + Also installs :class:`BodyLimit`, so request bodies for the API are never read past + ``api.max_body``. If you register :func:`blueprint` yourself, wrap the app the same way: + ``app.asgi_app = BodyLimit(app.asgi_app, api)``. + """ app.register_blueprint(blueprint(api, name=name)) + app.asgi_app = BodyLimit(app.asgi_app, api) # type: ignore[method-assign] if registry is not None: app.before_serving(registry.open) app.after_serving(registry.close) diff --git a/packages/mongospecs-web/src/mongospecs_web/policy.py b/packages/mongospecs-web/src/mongospecs_web/policy.py index e2b31cb..415886f 100644 --- a/packages/mongospecs-web/src/mongospecs_web/policy.py +++ b/packages/mongospecs-web/src/mongospecs_web/policy.py @@ -20,6 +20,53 @@ _MANAGED = (CreatedAt, UpdatedAt, DeletedAt, Revision) +INT64_MIN, INT64_MAX = -(2**63), 2**63 - 1 +"""MongoDB's largest integer type is 64 bits; a larger integer cannot be stored as one.""" + + +def _refs(node: t.Any, out: set[str]) -> None: + if isinstance(node, dict): + for key, value in node.items(): + if key == "$ref" and isinstance(value, str) and value.startswith("#/$defs/"): + out.add(value.rsplit("/", 1)[1]) + else: + _refs(value, out) + elif isinstance(node, list): + for value in node: + _refs(value, out) + + +def _reachable(schema: dict[str, t.Any], defs: dict[str, t.Any]) -> dict[str, t.Any]: + """The definitions ``schema`` actually refers to, directly or through one another.""" + found: set[str] = set() + _refs(schema, found) + pending = list(found) + while pending: + inner: set[str] = set() + _refs(defs.get(pending.pop(), {}), inner) + pending.extend(inner - found) + found |= inner + return {name: definition for name, definition in defs.items() if name in found} + + +def _has_oversized_int(value: t.Any) -> bool: + if isinstance(value, int): + return not INT64_MIN <= value <= INT64_MAX + if isinstance(value, list): + return any(_has_oversized_int(v) for v in value) + if isinstance(value, dict): + return any(_has_oversized_int(v) for v in value.values()) + return False + + +def _takes_any_number(tp: t.Any) -> bool: + """A float or Decimal field: a large JSON integer becomes one of those and fits.""" + import decimal + + from mongospecs.codec import strip_optional + + return strip_optional(tp) in (float, decimal.Decimal) + class View: """What of a model is readable, writable, filterable and sortable from outside.""" @@ -125,12 +172,29 @@ def check_input(self, data: t.Any, allowed: frozenset[str], action: str) -> dict ) if errors: raise Unprocessable("The request body names fields that cannot be written.", errors=errors) + for key, value in data.items(): + if _has_oversized_int(value) and not _takes_any_number(self.meta.fields[key].type): + errors.append( + {"field": key, "message": f"integers must be between {INT64_MIN} and {INT64_MAX}"} + ) + if errors: + raise Unprocessable("The request body failed validation.", errors=errors) return data # -- schemas ---------------------------------------------------------------- def _base(self) -> dict[str, t.Any]: - return self.meta.codec.json_schema(self.model) + base = self.meta.codec.json_schema(self.model) + own = base.get("$defs", {}).get(self.model.__name__) + if isinstance(own, dict) and self.private: + # The model's own definition survives only where the model refers to itself; + # it must not describe more than the Read view does. + hidden = self.private | {self.meta.fields[name].stored for name in self.private} + own = dict(own) + own["properties"] = {k: v for k, v in own.get("properties", {}).items() if k not in hidden} + own["required"] = [k for k in own.get("required", []) if k not in hidden] + base = {**base, "$defs": {**base["$defs"], self.model.__name__: own}} + return base def schema(self, kind: t.Literal["read", "create", "patch"]) -> dict[str, t.Any]: """JSON Schema of one view. ``$defs`` of nested models are kept alongside.""" @@ -158,8 +222,9 @@ def schema(self, kind: t.Literal["read", "create", "patch"]) -> dict[str, t.Any] schema["required"] = [n for n in required if n in allowed] if not schema.get("required"): schema.pop("required", None) - if "$defs" in base: - schema["$defs"] = base["$defs"] + defs = _reachable(schema, base.get("$defs", {})) + if defs: + schema["$defs"] = defs return schema diff --git a/packages/mongospecs-web/src/mongospecs_web/resource.py b/packages/mongospecs-web/src/mongospecs_web/resource.py index ca98619..e870a71 100644 --- a/packages/mongospecs-web/src/mongospecs_web/resource.py +++ b/packages/mongospecs-web/src/mongospecs_web/resource.py @@ -333,12 +333,16 @@ async def dispatch(self, op: Operation, request: Request) -> Response: """Run one operation. Never raises: every error becomes a problem+json response.""" ctx: Ctx[T] = Ctx(self, op, request) try: + ran: set[int] = set() for guard in ( *self._all_guards, *self._guards.get(op.kind, ()), - *self._guards.get(op.name, ()), + *self._guards.get(op.name, ()), # the same key as the kind for built-in operations *op.guards, ): + if id(guard) in ran: + continue # one guard is one check, however many places list it + ran.add(id(guard)) if not await _maybe(guard(ctx)): raise Forbidden("You are not allowed to do this.") for hook in self._before.get(op.name, ()): diff --git a/packages/mongospecs-web/tests/test_contract.py b/packages/mongospecs-web/tests/test_contract.py index 6646154..37e4029 100644 --- a/packages/mongospecs-web/tests/test_contract.py +++ b/packages/mongospecs-web/tests/test_contract.py @@ -138,10 +138,8 @@ def names(label): assert names("filter eq") == ["bolt"] and names("filter prefix") == ["anvil"] assert names("filter in") == ["anvil", "cog"] and names("filter range") == ["cog", "anvil"] assert names("filter int cast") == ["anvil", "cog"] and names("filter exists") == [ - "anvil", - "bolt", - "cog", - ] # null counts as existing, as in MongoDB + "anvil" + ] # an optional field without a value is not stored, so only the widget with a sku has one assert got["filter ne with total"]["body"]["page"]["total"] == 2 assert got["limit is capped"]["body"]["page"]["size"] == 100 assert names("offset page 1") == ["anvil", "bolt"] diff --git a/packages/mongospecs-web/tests/test_review_round2.py b/packages/mongospecs-web/tests/test_review_round2.py new file mode 100644 index 0000000..e38af9b --- /dev/null +++ b/packages/mongospecs-web/tests/test_review_round2.py @@ -0,0 +1,159 @@ +"""Regression tests for the second QA review of v2 (issue numbers refer to that review).""" + +import json + +import httpx +import pytest + +from mongospecs_web import Api, Delete, Get, List, Resource +from mongospecs_web.testing import PLUGS, contract_api, mount_on + +BACKENDS = ("msgspec", "pydantic", "attrs") +ANN = {"authorization": "Bearer ann", "content-type": "application/json"} + + +# -- 2: integers the database cannot hold ---------------------------------------------------------- + + +@pytest.mark.parametrize("backend", BACKENDS) +@pytest.mark.parametrize("number", [2**63, -(2**63) - 1, 10**400]) +async def test_2_out_of_range_integers_are_a_validation_error(backend, number): + api = contract_api(backend) + created = await api.handle("POST", "/api/widgets", body=b'{"name":"w","price":1}', headers=ANN) + assert created.status == 201 + for method, path, field in ( + ("POST", "/api/widgets", "stock"), + ("PATCH", created.headers["location"], "stock"), + ): + body = json.dumps({"name": "big", field: number}).encode() + response = await api.handle(method, path, body=body, headers=ANN) + assert response.status == 422, (method, response.status, response.body) + problem = json.loads(response.body) + assert any(e["field"] == field for e in problem["errors"]), problem + # inside a container too, and the largest values that do fit are accepted + nested = await api.handle( + "POST", "/api/widgets", body=json.dumps({"name": "n", "tags": [number]}).encode(), headers=ANN + ) + assert nested.status == 422 + for fits in (2**63 - 1, -(2**63)): + ok = await api.handle( + "POST", "/api/widgets", body=json.dumps({"name": "ok", "stock": fits}).encode(), headers=ANN + ) + assert ok.status == 201, ok.body + # a float field takes a large integer as a float + as_float = await api.handle( + "POST", "/api/widgets", body=json.dumps({"name": "f", "price": 2**63}).encode(), headers=ANN + ) + assert as_float.status == 201 + + +def test_2_an_encoding_error_is_not_a_server_error(): + from mongospecs import EncodingError + from mongospecs_web.errors import problem + + assert problem(EncodingError("cannot encode object")).status == 422 + + +# -- 3: the body limit holds on every plug, at the ASGI level ----------------------------------------- + + +@pytest.mark.parametrize("plug", PLUGS) +@pytest.mark.parametrize("declared", [None, "10"]) # no Content-Length, and one that lies +async def test_3_no_plug_reads_a_body_past_the_limit(plug, declared): + api = contract_api("msgspec") + api.max_body = 1000 + app = mount_on(plug, api) + offered = 0 + + async def receive(): + nonlocal offered + if offered >= 300: + return {"type": "http.disconnect"} + offered += 1 + return {"type": "http.request", "body": b"x" * 65536, "more_body": True} + + sent = [] + + async def send(message): + sent.append(message) + + headers = [(b"host", b"t"), (b"authorization", b"Bearer ann"), (b"content-type", b"application/json")] + if declared: + headers.append((b"content-length", declared.encode())) + scope = { + "type": "http", + "asgi": {"version": "3.0", "spec_version": "2.3"}, + "http_version": "1.1", + "method": "POST", + "scheme": "http", + "path": "/api/widgets", + "raw_path": b"/api/widgets", + "root_path": "", + "query_string": b"", + "headers": headers, + "client": ("127.0.0.1", 1), + "server": ("t", 80), + "state": {}, + "extensions": {}, + } + await app(scope, receive, send) + status = next(m["status"] for m in sent if m["type"] == "http.response.start") + assert status == 413 + assert offered <= 3, f"{plug} read {offered} chunks of 64 KiB with a 1000 byte limit" + + +# -- 5: guards run once ------------------------------------------------------------------------ + + +@pytest.mark.parametrize("key", ["delete", "get", "list"]) +async def test_5_a_guard_runs_once_per_request(key): + model = contract_api("msgspec").resources[0].model + calls = [] + + def guard(ctx): + calls.append(ctx.op.name) + return True + + backend = contract_api("msgspec").resources[0].backend + api = Api(Resource(model, ops=[List, Get, Delete], guards={key: guard}, backend=backend), prefix="/api") + path = "/api/widgets" if key == "list" else "/api/widgets/000000000000000000000000" + await api.handle("DELETE" if key == "delete" else "GET", path) + assert calls == [key] + + # the same guard listed for the resource and for the operation is still one check + calls.clear() + both = Api(Resource(model, ops=[List], guards=[guard, guard], backend=backend), prefix="/api") + await both.handle("GET", "/api/widgets") + assert calls == ["list"] + + +# -- 8: private fields stay out of the API description --------------------------------------------- + + +@pytest.mark.parametrize("backend", BACKENDS) +def test_8_openapi_names_no_private_field(backend): + api = contract_api(backend) + text = json.dumps(api.openapi()) + assert "internal_note" not in text + schemas = api.openapi()["components"]["schemas"] + # every schema that is published is referenced by something: no stray full model definition + for name in schemas: + if name in ("Problem",) or name.endswith(("Read", "Create", "Patch")): + continue + assert f"#/components/schemas/{name}" in text, f"{name} is published but never referenced" + + +# -- HEAD mirrors GET on the ASGI plug ----------------------------------------------------------------- + + +async def test_head_reports_the_length_of_the_get_body(): + api = contract_api("msgspec") + created = await api.handle("POST", "/api/widgets", body=b'{"name":"w"}', headers=ANN) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=api.asgi()), base_url="http://t" + ) as client: + get = await client.get(created.headers["location"], headers=ANN) + head = await client.head(created.headers["location"], headers=ANN) + assert head.status_code == 200 and head.content == b"" + assert head.headers["content-length"] == str(len(get.content)) != "0" + assert head.headers["content-type"] == get.headers["content-type"] diff --git a/packages/mongospecs/src/mongospecs/codec.py b/packages/mongospecs/src/mongospecs/codec.py index 05b6d25..2dd1b11 100644 --- a/packages/mongospecs/src/mongospecs/codec.py +++ b/packages/mongospecs/src/mongospecs/codec.py @@ -320,6 +320,9 @@ def encode(self, obj: t.Any) -> dict[str, t.Any]: _, to_stored = self._renames(cls) if to_stored: doc = {to_stored.get(k, k): v for k, v in doc.items()} + for key in self.absent_when_none(cls): + if key in doc and doc[key] is None: + del doc[key] for f in self._special(cls): if f.transient: doc.pop(f.stored, None) @@ -327,6 +330,27 @@ def encode(self, obj: t.Any) -> dict[str, t.Any]: doc[f.stored] = ref_ids(doc[f.stored], f.ref.shape) return doc + def absent_when_none(self, cls: type) -> frozenset[str]: + """Stored keys of the fields whose default is ``None``. + + For these, ``None`` means "no value" and the key is left out of the stored + document instead of being written as ``null``. A document without the key + decodes back to ``None``, so nothing is lost, and sparse and unique indexes + behave as expected: MongoDB indexes an explicit ``null``, so two documents + "without" a value would otherwise collide on a unique sparse index. + """ + cache: dict[type, frozenset[str]] = self.__dict__.setdefault("_abs", {}) + hit = cache.get(cls) + if hit is None: + defaults = self.defaults(cls) + hit = frozenset( + f.stored + for f in self.fields(cls).values() + if f.stored != "_id" and not f.transient and f.name in defaults and defaults[f.name] is None + ) + cache[cls] = hit + return hit + def _special(self, cls: type) -> tuple[FieldInfo, ...]: cache: dict[type, tuple[FieldInfo, ...]] = self.__dict__.setdefault("_sp", {}) hit = cache.get(cls) @@ -372,6 +396,7 @@ def clear_cache(self) -> None: self._fields.clear() self.__dict__.pop("_rn", None) self.__dict__.pop("_sp", None) + self.__dict__.pop("_abs", None) def field_error(cls: type, name: str | None, message: str) -> ValidationError: diff --git a/packages/mongospecs/src/mongospecs/errors.py b/packages/mongospecs/src/mongospecs/errors.py index 76191b2..845ec73 100644 --- a/packages/mongospecs/src/mongospecs/errors.py +++ b/packages/mongospecs/src/mongospecs/errors.py @@ -158,6 +158,9 @@ def _duplicate_fields(details: t.Any) -> dict[str, t.Any]: return {} +_TYPE_MISMATCH = 14 + + def translate(exc: BaseException) -> MongoSpecsError: """Map a pymongo/bson exception to exactly one mongospecs error.""" msg = str(exc) @@ -187,6 +190,10 @@ def translate(exc: BaseException) -> MongoSpecsError: if isinstance(exc, pe.OperationFailure): if exc.code in _TOO_LARGE_CODES: return DocumentTooLarge(msg, code=exc.code, details=exc.details) + if exc.code == _TYPE_MISMATCH: + # The update or query does not fit the stored value (incrementing a string, say): + # the caller's mistake, not a server failure. + return QueryError(msg) return OperationFailed(msg, code=exc.code, details=exc.details) if isinstance(exc, pe.InvalidOperation): return DriverError(msg) diff --git a/packages/mongospecs/src/mongospecs/expr.py b/packages/mongospecs/src/mongospecs/expr.py index b22becf..60bda1f 100644 --- a/packages/mongospecs/src/mongospecs/expr.py +++ b/packages/mongospecs/src/mongospecs/expr.py @@ -109,6 +109,16 @@ class SortKey: direction: int +def _text(value: t.Any, name: str) -> str: + if not isinstance(value, str): + raise QueryError(f"{name}() matches literal text and needs a str, got {type(value).__name__}") + return value + + +def _flags(ignore_case: bool) -> str: + return "i" if ignore_case else "" + + def _values(values: t.Any, name: str) -> list[t.Any]: if isinstance(values, str | bytes | t.Mapping) or not isinstance(values, t.Iterable): raise QueryError(f"{name}() needs a list of values, got {type(values).__name__}") @@ -175,11 +185,11 @@ def type_(self, value: int | str) -> Condition: def prefix(self, value: str, *, ignore_case: bool = False) -> Condition: """Literal starts-with match (the value is escaped).""" - return Condition(self._path, "$regex", ("^" + re.escape(value), "i" if ignore_case else "")) + return Condition(self._path, "$regex", ("^" + re.escape(_text(value, "prefix")), _flags(ignore_case))) def contains(self, value: str, *, ignore_case: bool = False) -> Condition: """Literal substring match (the value is escaped).""" - return Condition(self._path, "$regex", (re.escape(value), "i" if ignore_case else "")) + return Condition(self._path, "$regex", (re.escape(_text(value, "contains")), _flags(ignore_case))) def regex(self, pattern: str, options: str = "") -> Condition: """Regular-expression match. The pattern is used as written; do not pass user input.""" @@ -274,10 +284,14 @@ def And(*conditions: Expr | t.Mapping[str, t.Any]) -> Group: def Or(*conditions: Expr | t.Mapping[str, t.Any]) -> Group: + if not conditions: + raise QueryError("Or() needs at least one condition") return Group("$or", conditions) def Nor(*conditions: Expr | t.Mapping[str, t.Any]) -> Group: + if not conditions: + raise QueryError("Nor() needs at least one condition") return Group("$nor", conditions) @@ -441,5 +455,9 @@ def and_filters(*filters: t.Mapping[str, t.Any] | None) -> dict[str, t.Any]: def compile_sort(keys: t.Iterable[SortInput], meta: ModelMeta | None = None) -> list[tuple[str, int]]: out: list[tuple[str, int]] = [] for key in SortBy(*keys): + if not isinstance(key.path, str) or any( + not part or part.startswith("$") for part in key.path.split(".") + ): + raise QueryError(f"cannot sort by {key.path!r}: not a field path") out.append((meta.stored_path(key.path) if meta is not None else key.path, key.direction)) return out diff --git a/packages/mongospecs/src/mongospecs/manager.py b/packages/mongospecs/src/mongospecs/manager.py index 23b98e4..5ee8b5b 100644 --- a/packages/mongospecs/src/mongospecs/manager.py +++ b/packages/mongospecs/src/mongospecs/manager.py @@ -84,6 +84,15 @@ def __new__(cls, matched: int, modified: int) -> UpdateCount: def __repr__(self) -> str: return f"UpdateCount(matched={self.matched}, modified={self.modified})" + def __str__(self) -> str: + return int.__repr__(self) + + def __format__(self, spec: str) -> str: + return int(self).__format__(spec) + + def __reduce__(self) -> tuple[t.Any, ...]: + return (UpdateCount, (self.matched, self.modified)) + class Manager(t.Generic[T]): def __init__( diff --git a/packages/mongospecs/src/mongospecs/meta.py b/packages/mongospecs/src/mongospecs/meta.py index c52ab9a..19b2a19 100644 --- a/packages/mongospecs/src/mongospecs/meta.py +++ b/packages/mongospecs/src/mongospecs/meta.py @@ -94,6 +94,38 @@ def walk(klass: type) -> None: return out +RESERVED_FIELD_NAMES: frozenset[str] = frozenset( + { + "after", + "aggregate", + "before", + "bulk_save", + "bulk_write", + "changes", + "collection", + "count", + "delete", + "estimated_count", + "exists", + "find", + "get", + "hydrate", + "insert", + "insert_many", + "is_new", + "reload", + "save", + "scope", + "update", + "upsert", + "using", + "watch", + } +) +"""Attribute names a model field may not use: each is part of the ``Document`` API, and a field +of the same name would replace it on the class (``Model.count()`` would stop working).""" + + class ModelMeta: """Everything mongospecs derives from a model class.""" @@ -108,6 +140,13 @@ def __init__(self, model: type) -> None: self.fields: dict[str, FieldInfo] = self.codec.fields(model) if "id" not in self.fields: raise ConfigurationError(f"{model.__name__} has no `id` field") + shadowed = sorted(set(self.fields) & RESERVED_FIELD_NAMES) + if shadowed: + raise ConfigurationError( + f"{model.__name__}: field name {shadowed[0]!r} shadows the document API " + f"({model.__name__}.{shadowed[0]} is part of mongospecs). Rename the attribute and keep the " + f"stored key with Annotated[..., Alias({shadowed[0]!r})]." + ) self.id = self.fields["id"] self.by_stored: dict[str, FieldInfo] = {f.stored: f for f in self.fields.values()} self.deferred: tuple[str, ...] = tuple(f.stored for f in self.fields.values() if f.has(Deferred)) @@ -202,16 +241,26 @@ def cast(self, path: str, value: t.Any, *, lax: bool = False) -> t.Any: if loose is None: loose = self._loose[info.name] = loosen(info.type) try: - return to_bson(self.validate_type(loose, value, lax=lax)) + return to_bson(self._coerce(loose, value, lax)) except ValidationError as exc: elem = element_type(loose) if elem is not None and not isinstance(value, list | tuple | set | frozenset): try: - return to_bson(self.validate_type(elem, value, lax=lax)) + return to_bson(self._coerce(elem, value, lax)) except ValidationError: pass raise QueryError(f"cannot cast {value!r} to {self.model.__name__}.{info.name}: {exc}") from exc + def _coerce(self, tp: t.Any, value: t.Any, lax: bool) -> t.Any: + """Cast a filter value. A value of the right type is taken as it is; otherwise text that + reads as the field's type is accepted (``"12"`` for an ``int``), on every backend.""" + if lax: + return self.validate_type(tp, value, lax=True) + try: + return self.validate_type(tp, value) + except ValidationError: + return self.validate_type(tp, value, lax=True) + def _cast_ref(self, info: FieldInfo, value: t.Any) -> t.Any: if isinstance(value, list | tuple | set | frozenset): return [self._cast_ref(info, v) for v in value] diff --git a/packages/mongospecs/src/mongospecs/query.py b/packages/mongospecs/src/mongospecs/query.py index b3abdf4..8c99fa2 100644 --- a/packages/mongospecs/src/mongospecs/query.py +++ b/packages/mongospecs/src/mongospecs/query.py @@ -15,7 +15,7 @@ import bson from mongospecs import tracking -from mongospecs.errors import DocumentNotFound, QueryError +from mongospecs.errors import DocumentNotFound, QueryError, ValidationError from mongospecs.expr import SortInput, and_filters, compile_filter, compile_sort from mongospecs.meta import meta_of from mongospecs.updates import UpdateOp @@ -65,6 +65,7 @@ class Query(t.Generic[T]): "_conditions", "_fail", "_includes", + "_invalid", "_limit", "_manager", "_mode", @@ -91,6 +92,7 @@ def __init__( session: t.Any = None, options: t.Mapping[str, t.Any] | None = None, fail: bool = False, + invalid: str = "raise", ) -> None: self._manager = manager self._conditions = conditions @@ -104,6 +106,7 @@ def __init__( self._session = session self._options: dict[str, t.Any] = dict(options or {}) self._fail = fail + self._invalid = invalid def _clone(self, **changes: t.Any) -> Query[T]: state = {name.lstrip("_"): getattr(self, name) for name in self.__slots__ if name != "_manager"} @@ -157,8 +160,11 @@ def include( ``path`` may be nested (``"author.company"``). ``limit`` caps the related documents overall; ``per_parent_limit`` caps them per parent document. """ - from mongospecs.relations import add_include + from mongospecs.relations import add_include, check_include + if strategy not in ("auto", "lookup", "batch"): + raise QueryError(f"include strategy must be 'auto', 'lookup' or 'batch', got {strategy!r}") + check_include(self._manager.meta, path) return self._clone( includes=add_include( self._includes, @@ -176,6 +182,18 @@ def include( ) ) + def on_invalid(self, action: t.Literal["raise", "skip", "raw"]) -> Query[T]: + """What to do with a stored document that does not fit the model. + + ``"raise"`` (the default) fails the whole read with :class:`ValidationError`. + ``"skip"`` leaves such documents out. ``"raw"`` returns them as plain dicts in + place, so they can be inspected or repaired; relations are not loaded for them. + Skipped documents still count towards ``limit()`` and page sizes. + """ + if action not in ("raise", "skip", "raw"): + raise QueryError(f"on_invalid takes 'raise', 'skip' or 'raw', got {action!r}") + return self._clone(invalid=action) + def tracked(self) -> Query[T]: """Return model instances with change tracking (the default).""" return self._clone(mode="tracked") @@ -343,30 +361,44 @@ async def _chunks(self, chunk: int | None = None) -> t.AsyncIterator[list[t.Any] raw_bson = mode == "tracked" and not lookups options = manager.registry._codec_options + invalid = self._invalid + async def flush(raws: list[t.Any]) -> list[t.Any]: - if raw_bson: - items: list[t.Any] = [] - decode = meta.decode - for item in raws: - data = item.raw - doc = decode(bson.decode(data, options)) - tracking.track(doc, data, manager, partial) - items.append(doc) + misfits: list[tuple[int, t.Any]] = [] # (position, raw document) kept for on_invalid("raw") + joined = [pop_lookups(raw, lookups) for raw in raws] if lookups else None + if mode == "raw": + items: list[t.Any] = raws else: - joined = [pop_lookups(raw, lookups) for raw in raws] if lookups else None - if mode == "raw": - items = raws - else: - items = [] - for raw in raws: - doc = meta.decode(raw) - if mode == "tracked": - tracking.track(doc, meta_of(type(doc)).encode(doc), manager, partial) - items.append(doc) + items = [] + kept: list[t.Any] = [] + for position, item in enumerate(raws): + data = item.raw if raw_bson else None + plain = bson.decode(data, options) if data is not None else item + try: + doc = meta.decode(plain) + except ValidationError: + if invalid == "raise": + raise + if invalid == "raw": + misfits.append((position, plain)) + continue + if data is not None: + tracking.track(doc, data, manager, partial) + elif mode == "tracked": + tracking.track(doc, meta_of(type(doc)).encode(doc), manager, partial) + items.append(doc) + if joined is not None: + kept.append(joined[position]) if joined is not None: - attach_lookups(manager, items, joined, lookups, mode) + joined = kept + if joined is not None: + attach_lookups(manager, items, joined, lookups, mode) if batched: await load_batched(manager, items, batched, mode, self._session) + if misfits: # put the documents that did not fit back where the server returned them + raw_at = dict(misfits) + valid = iter(items) + return [raw_at[i] if i in raw_at else next(valid) for i in range(len(raws))] return items with manager._op("find"): diff --git a/packages/mongospecs/src/mongospecs/registry.py b/packages/mongospecs/src/mongospecs/registry.py index f34f93b..6b91ef7 100644 --- a/packages/mongospecs/src/mongospecs/registry.py +++ b/packages/mongospecs/src/mongospecs/registry.py @@ -30,6 +30,7 @@ MongoSpecsError, NotBoundError, RegistryClosedError, + TransactionError, translate, wrap_errors, ) @@ -55,6 +56,9 @@ R = t.TypeVar("R") _BINDINGS: weakref.WeakKeyDictionary[type, Registry] = weakref.WeakKeyDictionary() +_IN_TRANSACTION: contextvars.ContextVar[bool] = contextvars.ContextVar( + "mongospecs_in_transaction", default=False +) _CURRENT_OP: contextvars.ContextVar[tuple[str, str] | None] = contextvars.ContextVar( "mongospecs_current_op", default=None ) @@ -471,19 +475,35 @@ async def transaction( write_concern: t.Any = None, read_preference: t.Any = None, max_commit_time_ms: int | None = None, + independent: bool = False, ) -> R: """Run ``fn(tx)`` in a transaction and commit it. + Transactions do not nest. MongoDB has no savepoints, and a transaction + started while another one is running is a separate transaction: it commits + on its own and stays committed if the outer one aborts. Calling this from + inside another ``transaction()`` therefore raises + :class:`TransactionError`; pass the outer ``tx`` down and use it instead. + If a separate, independently committed transaction is what you want (an + audit record that must survive a rollback), say so with ``independent=True``. + ``fn`` may be called more than once: the driver re-runs it on transient transaction errors. Before every re-run, and if the transaction finally aborts, documents written inside it are restored to their prior in-memory state. Pass ``session=tx`` to every operation inside ``fn``. """ + if _IN_TRANSACTION.get() and not independent: + raise TransactionError( + "transaction() was called inside another transaction. Transactions do not nest: this one " + "would commit on its own even if the outer one aborts. Pass the outer tx to the code that " + "needs it, or use transaction(fn, independent=True) if a separate commit is intended." + ) async with self.client.start_session() as session: tx = Tx(session, self) async def attempt(_: t.Any) -> R: tx.rollback_memory() + token = _IN_TRANSACTION.set(True) # for detection only; sessions are always passed explicitly try: return await fn(tx) except MongoSpecsError as exc: @@ -494,6 +514,8 @@ async def attempt(_: t.Any) -> R: ): raise cause from None raise + finally: + _IN_TRANSACTION.reset(token) try: result = await session.with_transaction( diff --git a/packages/mongospecs/src/mongospecs/relations.py b/packages/mongospecs/src/mongospecs/relations.py index 8fca230..8ccd482 100644 --- a/packages/mongospecs/src/mongospecs/relations.py +++ b/packages/mongospecs/src/mongospecs/relations.py @@ -63,6 +63,28 @@ def add_include(includes: tuple[Include, ...], path: str, **options: t.Any) -> t return (*(i for i in includes if i.name != head), updated) +def check_include(meta: t.Any, path: str) -> None: + """Raise :class:`QueryError` unless every segment of ``path`` is a relation. + + Called when the query is built, so a mistyped path fails at once and not only + when a matching document happens to exist. + """ + metas: tuple[t.Any, ...] = (meta,) + for name in path.split("."): + holders = [ + m + for m in metas + if (f := m.fields.get(name)) is not None and (f.ref is not None or f.backref is not None) + ] + if not holders: + owner = " | ".join(m.model.__name__ for m in metas) + raise QueryError(f"{owner}.{name} is not a relation and cannot be included") + try: + metas = tuple(target for m in holders for target in m.ref_targets(name)) + except ConfigurationError: + return # a target that is not defined yet; it is reported when the query runs + + def _relation(manager: Manager[t.Any], include: Include) -> FieldInfo: info = manager.meta.fields.get(include.name) if info is None or (info.ref is None and info.backref is None): diff --git a/packages/mongospecs/src/mongospecs/updates.py b/packages/mongospecs/src/mongospecs/updates.py index def91ef..3e86804 100644 --- a/packages/mongospecs/src/mongospecs/updates.py +++ b/packages/mongospecs/src/mongospecs/updates.py @@ -252,6 +252,7 @@ def compile_update( """Compile operators (plus ``Set`` shorthand keywords) to a MongoDB update document.""" update: dict[str, dict[str, t.Any]] = {} seen: dict[str, str] = {} + absent = meta.codec.absent_when_none(meta.model) all_ops = [*ops, *([Set(fields)] if fields else [])] for op in all_ops: if not isinstance(op, UpdateOp): @@ -260,5 +261,8 @@ def compile_update( if path in seen: raise QueryError(f"field {path!r} is updated by both {seen[path]} and {op.op}") seen[path] = op.op - update.setdefault(op.op, {})[path] = value + if value is None and op.op == "$set" and path in absent: + update.setdefault("$unset", {})[path] = "" # None means "no value": the key is not stored + else: + update.setdefault(op.op, {})[path] = value return update diff --git a/packages/mongospecs/tests/test_review_round2.py b/packages/mongospecs/tests/test_review_round2.py new file mode 100644 index 0000000..e156461 --- /dev/null +++ b/packages/mongospecs/tests/test_review_round2.py @@ -0,0 +1,196 @@ +"""Regression tests for the second QA review of v2 (issue numbers refer to that review).""" + +import typing as t + +import pytest + +from mongospecs import ( + ConfigurationError, + DocumentNotFound, + DuplicateKeyError, + Inc, + Index, + MongoSpecsError, + Nor, + Or, + Q, + QueryError, + SpecConfig, + TransactionError, + ValidationError, + meta_of, +) +from mongospecs.manager import UpdateCount + +# -- 1: sparse unique index and optional fields ------------------------------------------------ + + +async def test_1_absent_optional_fields_do_not_collide_on_a_sparse_unique_index(m, registry): + await registry.apply_indexes() # User has Index("email", unique=True, sparse=True) + result = await m.User.insert_many([m.User(name="n1"), m.User(name="n2")]) + assert len(result.inserted) == 2 + await m.User(name="n3").insert() + raw = await m.User.collection().find_one({"name": "n1"}) + assert "email" not in raw # None means absent: the field is not stored + # uniqueness still holds where a value is present + await m.User(name="a", email="a@x.io").insert() + with pytest.raises(DuplicateKeyError): + await m.User(name="b", email="a@x.io").insert() + # clearing a value un-stores it, through save() and through an atomic update alike + a = await m.User.find(Q.name == "a").one() + a.email = None + assert a.changes() == {"$unset": {"email": ""}} + await a.save() + await m.User(name="c", email="c@x.io").insert() + await m.User.update(Q.name == "c", email=None) + assert await m.User.collection().count_documents({"email": {"$exists": True}}) == 0 + assert await m.User.count(Q.email == None) == 5 # noqa: E711 - None still matches "no value" + loaded = await m.User.find(Q.name == "c").one() + assert loaded.email is None and loaded.changes() == {} + + +# -- 4: field names that shadow the document API ------------------------------------------------- + + +@pytest.mark.parametrize( + "name", + ["count", "update", "delete", "find", "save", "get", "insert", "changes", "is_new", "scope", "watch"], +) +def test_4_fields_may_not_shadow_the_document_api(m, name): + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("ignore") # the backends themselves only warn, if at all + try: + if m.backend == "attrs": + import attrs + + Shadow = attrs.make_class( + "Shadow", {name: attrs.field(default=0, type=int)}, bases=(m.Spec,), kw_only=True + ) + elif m.backend == "pydantic": + import pydantic + + Shadow = pydantic.create_model("Shadow", __base__=m.Spec, **{name: (int, 0)}) + else: + import msgspec + + Shadow = msgspec.defstruct("Shadow", [(name, int, 0)], bases=(m.Spec,), kw_only=True) + except Exception as exc: # a backend may refuse the class outright, which is fine too + assert name in str(exc) + return + with pytest.raises(ConfigurationError, match=f"'{name}'.*document API"): + meta_of(Shadow) + + +def test_4_ordinary_and_id_fields_are_fine(m): + assert "id" in meta_of(m.User).fields and "name" in meta_of(m.User).fields + + +# -- 6: nested transactions ---------------------------------------------------------------------- + + +async def test_6_a_nested_transaction_is_refused_unless_declared_independent(m, registry): + async def inner(tx): + await m.User(name="inner").insert(session=tx) + + async def outer(tx): + await m.User(name="outer").insert(session=tx) + await registry.transaction(inner) # would commit on its own, whatever happens to the outer one + raise RuntimeError("outer aborts") + + with pytest.raises(TransactionError, match="inside another transaction"): + await registry.transaction(outer) + assert await m.User.count() == 0 # nothing slipped through + + async def outer_explicit(tx): + await m.User(name="outer").insert(session=tx) + await registry.transaction(inner, independent=True) # stated on purpose + raise RuntimeError("outer aborts") + + with pytest.raises(RuntimeError): + await registry.transaction(outer_explicit) + assert [u.name for u in await m.User.find()] == ["inner"] + # sequential transactions and a transaction after a failed one are unaffected + await registry.transaction(inner) + assert await m.User.count() == 2 + + +# -- 7: UpdateCount formats like an int ----------------------------------------------------------- + + +def test_7_update_count_prints_like_an_int(): + import json + import pickle + + n = UpdateCount(5, 3) + assert str(n) == "3" and f"{n}" == "3" and "%s" % n == "3" and "%d" % n == "3" # noqa: UP031 and f"{n:>4}" == " 3" + assert repr(n) == "UpdateCount(matched=5, modified=3)" + assert n == 3 and n + 1 == 4 and json.dumps({"n": n}) == '{"n": 3}' + restored = pickle.loads(pickle.dumps(n)) + assert restored == 3 and restored.matched == 5 + + +# -- smaller findings ------------------------------------------------------------------------------ + + +async def test_casting_follows_one_rule_on_every_backend(m, registry): + """Coercion is lax everywhere: text that reads as the field's type is accepted.""" + user = await m.User(name="ann", age=12).insert() + assert await m.User.count(Q.age == "12") == 1 + assert await m.User.count(Q.age.in_(["12", 13])) == 1 + with pytest.raises(QueryError): + await m.User.count(Q.age == "twelve") + assert await m.User.count(Q.age == 12) == 1 and await m.User.count(Q.name == "ann") == 1 + assert user.age == 12 + + +async def test_malformed_queries_are_query_errors_not_server_errors(m, registry): + await m.User(name="ann").insert() + for bad in (lambda: Or(), lambda: Nor(), lambda: Q.name.prefix({"$gt": ""}), lambda: Q.name.contains(5)): + with pytest.raises(QueryError): + await m.User.find(bad()).all() + with pytest.raises(QueryError): + await m.User.find().sort("address.$").all() + with pytest.raises(QueryError): + await m.User.find().sort("$natural").all() + # the server refusing an update that does not fit the stored value is the caller's error + await m.User.collection().update_one({"name": "ann"}, {"$set": {"address": {"zip": "abc"}}}) + with pytest.raises(QueryError): + await m.User.update(Q.name == "ann", Inc({"address.zip": 1})) + + +async def test_include_is_validated_when_the_query_is_built(m, registry): + with pytest.raises(QueryError, match="not a relation"): + m.Article.find().include("nope") # an empty collection used to hide this + with pytest.raises(QueryError, match="not a relation"): + m.Article.find().include("author.nope") + with pytest.raises(QueryError, match="strategy"): + m.Article.find().include("author", strategy="weird") + assert await m.Article.find().include("author").all() == [] + + +async def test_invalid_stored_documents_can_be_skipped_or_returned_raw(m, registry): + await m.User.insert_many([m.User(name="ok1", age=1), m.User(name="ok2", age=2)]) + await m.User.collection().insert_one({"name": "corrupt", "age": "not a number"}) + with pytest.raises(ValidationError): + await m.User.find().all() # the default: a corrupt document is an error + skipped = await m.User.find().sort("name").on_invalid("skip").all() + assert [u.name for u in skipped] == ["ok1", "ok2"] + mixed = await m.User.find().sort("name").on_invalid("raw").all() + assert [u["name"] if isinstance(u, dict) else u.name for u in mixed] == ["corrupt", "ok1", "ok2"] + assert isinstance(mixed[0], dict) and mixed[0]["age"] == "not a number" + lean = await m.User.find().lean().on_invalid("skip").all() + assert len(lean) == 2 + with pytest.raises(QueryError): + m.User.find().on_invalid("ignore") + + +async def test_upsert_revision_is_documented_behaviour(m, registry): + """An upsert is a write, so the document it creates starts at revision 1; insert() starts at 0.""" + assert (await m.Article(title="i").insert()).rev == 0 + assert (await m.Article.upsert(Q.title == "u", Inc(views=1))).rev == 1 + + +def test_unused_names_keep_imports_honest(): + assert Index and SpecConfig and DocumentNotFound and MongoSpecsError and t From 976d41e635376deae1eb679061b6b84f7426a792 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 02:56:05 -0400 Subject: [PATCH 16/31] fix(web): enforce Policy(private=True) on fields of embedded models Private fields of embedded (non-document) models were serialised, writable and described in OpenAPI. They are now handled like private fields of the document itself, at any depth and inside lists, dicts, optionals and unions: never in a response (including included documents), a 422 'unknown field' when a client supplies one on create or patch, and absent from every published schema. Co-Authored-By: Claude Opus 5.5 --- .../src/mongospecs_web/policy.py | 190 +++++++++++++++++- .../tests/test_embedded_private.py | 171 ++++++++++++++++ 2 files changed, 354 insertions(+), 7 deletions(-) create mode 100644 packages/mongospecs-web/tests/test_embedded_private.py diff --git a/packages/mongospecs-web/src/mongospecs_web/policy.py b/packages/mongospecs-web/src/mongospecs_web/policy.py index 415886f..7b2ae8e 100644 --- a/packages/mongospecs-web/src/mongospecs_web/policy.py +++ b/packages/mongospecs-web/src/mongospecs_web/policy.py @@ -24,6 +24,148 @@ """MongoDB's largest integer type is 64 bits; a larger integer cannot be stored as one.""" +class Nested: + """Where the private fields are inside the value of one field. + + ``private`` and ``children`` describe an embedded model (by every key it may appear + under: attribute name, stored name, the backend's own name); ``elem`` describes the + items of a list or the values of a dict. + """ + + __slots__ = ("children", "elem", "model", "private") + + def __init__(self) -> None: + self.model = False + self.private: frozenset[str] = frozenset() + self.children: dict[str, Nested] = {} + self.elem: Nested | None = None + + def hides_something(self, seen: set[int] | None = None) -> bool: + seen = set() if seen is None else seen + if id(self) in seen: + return False + seen.add(id(self)) + return ( + bool(self.private) + or any(child.hides_something(seen) for child in self.children.values()) + or (self.elem is not None and self.elem.hides_something(seen)) + ) + + +def embedded_models(tp: t.Any, found: dict[type, None] | None = None) -> dict[type, None]: + """Every embedded (non-document) model class reachable from an annotation, at any depth.""" + from mongospecs import Document + from mongospecs.codec import _codec_or_none + + found = {} if found is None else found + while t.get_origin(tp) is t.Annotated: + tp = t.get_args(tp)[0] + if isinstance(tp, type) and not issubclass(tp, Document) and t.get_origin(tp) is None: + codec = _codec_or_none(tp) + if codec is not None and tp not in found: + found[tp] = None + for info in codec.fields(tp).values(): + embedded_models(info.type, found) + return found + if t.get_origin(tp) is Ref: + return found # another document: its own view applies + for arg in t.get_args(tp): + embedded_models(arg, found) + return found + + +def _model_keys(cls: type) -> dict[str, tuple[FieldInfo, frozenset[str]]]: + from mongospecs.codec import codec_for + + codec = codec_for(cls) + natives = {name: native for name, native, _, _ in codec.raw_fields(cls)} + return { + name: (info, frozenset({name, info.stored, natives.get(name, name)})) + for name, info in codec.fields(cls).items() + } + + +def nested_plan(tp: t.Any, cache: dict[type, Nested]) -> Nested | None: + """The :class:`Nested` plan for an annotation, or ``None`` when it holds no embedded model.""" + from mongospecs import Document + from mongospecs.codec import _UNION_ORIGINS, _codec_or_none + + while t.get_origin(tp) is t.Annotated: + tp = t.get_args(tp)[0] + origin = t.get_origin(tp) + if origin is Ref: + return None + if origin is None: + if not isinstance(tp, type) or issubclass(tp, Document) or _codec_or_none(tp) is None: + return None + node = cache.get(tp) + if node is None: + node = cache[tp] = Nested() + node.model = True + private: set[str] = set() + for info, keys in _model_keys(tp).values(): + if info.policy.private: + private |= keys + continue + child = nested_plan(info.type, cache) + if child is not None: + for key in keys: + node.children[key] = child + node.private = frozenset(private) + return node + parts = [plan for arg in t.get_args(tp) if (plan := nested_plan(arg, cache)) is not None] + if not parts: + return None + if origin in _UNION_ORIGINS: + if len(parts) == 1: + return parts[0] + merged = Nested() # a union: hide whatever any member hides, so nothing can leak + for part in parts: + merged.model = merged.model or part.model + merged.private |= part.private + for key, child in part.children.items(): + merged.children.setdefault(key, child) + merged.elem = merged.elem or part.elem + return merged + container = Nested() + container.elem = parts[-1] # list[X] -> X, dict[str, X] -> X + return container + + +def strip_nested(value: t.Any, node: Nested) -> t.Any: + """Remove private keys from a JSON-shaped value, in place.""" + if isinstance(value, dict): + if node.model: + for key in node.private: + value.pop(key, None) + for key, child in node.children.items(): + if key in value: + strip_nested(value[key], child) + elif node.elem is not None: + for item in value.values(): + strip_nested(item, node.elem) + elif isinstance(value, list) and node.elem is not None: + for item in value: + strip_nested(item, node.elem) + return value + + +def nested_violations(value: t.Any, node: Nested, path: str, out: list[str]) -> None: + """Collect the dotted paths of private keys a client supplied.""" + if isinstance(value, dict): + if node.model: + out.extend(f"{path}.{key}" for key in value if key in node.private) + for key, child in node.children.items(): + if key in value: + nested_violations(value[key], child, f"{path}.{key}", out) + elif node.elem is not None: + for key, item in value.items(): + nested_violations(item, node.elem, f"{path}.{key}", out) + elif isinstance(value, list) and node.elem is not None: + for index, item in enumerate(value): + nested_violations(item, node.elem, f"{path}.{index}", out) + + def _refs(node: t.Any, out: set[str]) -> None: if isinstance(node, dict): for key, value in node.items(): @@ -120,6 +262,16 @@ def may(flag: bool | None, default: bool) -> bool: self._relations: tuple[FieldInfo, ...] = tuple( f for f in self.readable if f.transient or f.ref is not None ) + # Private fields of embedded models, at any depth below a field of this model. + cache: dict[type, Nested] = {} + self._nested: dict[str, Nested] = { + f.name: plan + for f in fields.values() + if not f.transient and (plan := nested_plan(f.type, cache)) is not None and plan.hides_something() + } + self._nested_read: tuple[tuple[str, Nested], ...] = tuple( + (name, plan) for name, plan in self._nested.items() if name in self._plain + ) revision = next((f for f in fields.values() if f.has(Revision)), None) self.revision: str | None = revision.name if revision else None @@ -131,6 +283,8 @@ def serialize(self, doc: t.Any, include: t.Collection[str] = ()) -> dict[str, t. return view_of(type(doc)).serialize(doc, include) data = self.meta.codec.to_json(doc) out = {name: data.get(name) for name in self._plain} + for name, plan in self._nested_read: + strip_nested(out[name], plan) if include: for f in self._relations: if f.name not in include: @@ -173,6 +327,11 @@ def check_input(self, data: t.Any, allowed: frozenset[str], action: str) -> dict if errors: raise Unprocessable("The request body names fields that cannot be written.", errors=errors) for key, value in data.items(): + plan = self._nested.get(key) + if plan is not None: + hidden: list[str] = [] + nested_violations(value, plan, key, hidden) + errors.extend({"field": path, "message": "unknown field"} for path in hidden) if _has_oversized_int(value) and not _takes_any_number(self.meta.fields[key].type): errors.append( {"field": key, "message": f"integers must be between {INT64_MIN} and {INT64_MAX}"} @@ -185,15 +344,32 @@ def check_input(self, data: t.Any, allowed: frozenset[str], action: str) -> dict def _base(self) -> dict[str, t.Any]: base = self.meta.codec.json_schema(self.model) - own = base.get("$defs", {}).get(self.model.__name__) - if isinstance(own, dict) and self.private: + defs = dict(base.get("$defs", {})) + hidden_by_name: dict[str, frozenset[str]] = {} + if self.private: # The model's own definition survives only where the model refers to itself; # it must not describe more than the Read view does. - hidden = self.private | {self.meta.fields[name].stored for name in self.private} - own = dict(own) - own["properties"] = {k: v for k, v in own.get("properties", {}).items() if k not in hidden} - own["required"] = [k for k in own.get("required", []) if k not in hidden] - base = {**base, "$defs": {**base["$defs"], self.model.__name__: own}} + hidden_by_name[self.model.__name__] = self.private | { + self.meta.fields[name].stored for name in self.private + } + for info in self.meta.fields.values(): + for cls in embedded_models(info.type): + keys = frozenset().union(*(k for f, k in _model_keys(cls).values() if f.policy.private)) + if keys: + hidden_by_name[cls.__name__] = hidden_by_name.get(cls.__name__, frozenset()) | keys + changed = False + for name, hidden in hidden_by_name.items(): + own = defs.get(name) + if isinstance(own, dict): + own = dict(own) + own["properties"] = {k: v for k, v in own.get("properties", {}).items() if k not in hidden} + own["required"] = [k for k in own.get("required", []) if k not in hidden] + if not own["required"]: + del own["required"] + defs[name] = own + changed = True + if changed: + base = {**base, "$defs": defs} return base def schema(self, kind: t.Literal["read", "create", "patch"]) -> dict[str, t.Any]: diff --git a/packages/mongospecs-web/tests/test_embedded_private.py b/packages/mongospecs-web/tests/test_embedded_private.py new file mode 100644 index 0000000..2c237df --- /dev/null +++ b/packages/mongospecs-web/tests/test_embedded_private.py @@ -0,0 +1,171 @@ +"""Policy(private=True) on the fields of embedded (non-document) models, at any depth.""" + +import json +import typing as t + +import httpx +import pytest + +from mongospecs import Policy, Ref, SpecConfig +from mongospecs_web import Api, Create, Get, List, Patch, Resource +from mongospecs_web.policy import view_of + +BACKENDS = ("msgspec", "pydantic", "attrs") +SECRETS = ("vault_code", "surveyor") + + +def models(backend): + if backend == "msgspec": + import msgspec + + from mongospecs.msgspec import Spec + + class Base(msgspec.Struct, kw_only=True): + pass + + define = lambda c: c # noqa: E731 + elif backend == "pydantic": + import pydantic + + from mongospecs.pydantic import Spec + + Base = pydantic.BaseModel + define = lambda c: c # noqa: E731 + else: + import attrs + + from mongospecs.attrs import Spec + + Base = object + define = attrs.define(kw_only=True) + + @define + class Geo(Base): + lat: float = 0.0 + surveyor: t.Annotated[str | None, Policy(private=True)] = None + + @define + class Addr(Base): + city: str = "" + vault_code: t.Annotated[str, Policy(private=True)] = "" + geo: Geo | None = None + + @define + class Company(Spec): + __spec__ = SpecConfig(collection="companies") + name: str = "" + hq: Addr | None = None + + @define + class Person(Spec): + __spec__ = SpecConfig(collection="persons") + name: t.Annotated[str, Policy(filter=("eq",), sort=True)] = "" + address: Addr | None = None + history: list[Addr] = [] # noqa: RUF012 + by_label: dict[str, Addr] = {} # noqa: RUF012 + employer: Ref[Company] | None = None + + return Geo, Addr, Company, Person + + +@pytest.fixture(params=BACKENDS) +async def world(request, make_registry): + Geo, Addr, Company, Person = models(request.param) + await make_registry(Company, Person) + api = Api( + Resource(Person, name="persons", ops=[List, Get, Create, Patch], include=["employer"]), + Resource(Company, name="companies", ops=[Get]), + prefix="/v1", + ) + client = httpx.AsyncClient(transport=httpx.ASGITransport(app=api.asgi()), base_url="http://t") + try: + yield client, api, (Geo, Addr, Company, Person) + finally: + await client.aclose() + + +async def seeded(Geo, Addr, Company, Person): + def addr(city): + return Addr(city=city, vault_code=f"vault-{city}", geo=Geo(lat=1.5, surveyor=f"surveyor-{city}")) + + acme = await Company(name="acme", hq=addr("hq")).insert() + return await Person( + name="ann", + address=addr("home"), + history=[addr("old1"), addr("old2")], + by_label={"work": addr("work")}, + employer=acme, + ).insert() + + +async def test_nested_private_fields_are_never_read(world): + client, api, classes = world + ann = await seeded(*classes) + stored = await classes[3].collection().find_one({"_id": ann.id}) + assert stored["address"]["vault_code"] == "vault-home" # it is stored; it is only never shown + for url in ( + f"/v1/persons/{ann.id}", + "/v1/persons", + f"/v1/persons/{ann.id}?include=employer", + "/v1/persons?include=employer", + f"/v1/companies/{ann.employer.id}", + ): + response = await client.get(url) + assert response.status_code == 200, response.text + for secret in (*SECRETS, "vault-", "surveyor-"): + assert secret not in response.text, (url, secret) + body = (await client.get(f"/v1/persons/{ann.id}?include=employer")).json()["data"] + assert body["address"] == {"city": "home", "geo": {"lat": 1.5}} + assert [a["city"] for a in body["history"]] == ["old1", "old2"] and body["by_label"]["work"][ + "city" + ] == "work" + assert body["employer"]["hq"] == {"city": "hq", "geo": {"lat": 1.5}} + + +@pytest.mark.parametrize( + ("payload", "field"), + [ + ({"address": {"city": "x", "vault_code": "client-set"}}, "address.vault_code"), + ({"address": {"city": "x", "geo": {"lat": 1, "surveyor": "me"}}}, "address.geo.surveyor"), + ({"history": [{"city": "a"}, {"city": "b", "vault_code": "v"}]}, "history.1.vault_code"), + ({"by_label": {"work": {"geo": {"surveyor": "me"}}}}, "by_label.work.geo.surveyor"), + ], +) +async def test_nested_private_fields_are_never_written(world, payload, field): + client, api, (Geo, Addr, Company, Person) = world + existing = await Person(name="bo", address=Addr(city="here", vault_code="server-set")).insert() + for method, url in (("POST", "/v1/persons"), ("PATCH", f"/v1/persons/{existing.id}")): + response = await client.request(method, url, json={"name": "eve", **payload}) + assert response.status_code == 422, (method, response.text) + assert {"field": field, "message": "unknown field"} in response.json()["errors"] + assert await Person.count() == 1 + assert (await Person.get(existing.id)).address.vault_code == "server-set" + # without the private keys the same shapes are accepted + ok = await client.post("/v1/persons", json={"name": "ok", "address": {"city": "x", "geo": {"lat": 2}}}) + assert ok.status_code == 201 and ok.json()["data"]["address"] == {"city": "x", "geo": {"lat": 2.0}} + patched = await client.patch(f"/v1/persons/{existing.id}", json={"address": {"city": "moved"}}) + assert patched.status_code == 200 and patched.json()["data"]["address"]["city"] == "moved" + + +async def test_nested_private_fields_are_not_described_or_addressable(world): + client, api, classes = world + text = json.dumps(api.openapi()) + for secret in SECRETS: + assert secret not in text + assert "city" in text and "lat" in text # the public nested fields are described + await seeded(*classes) + for query in ( + "address.vault_code=x", + "address.vault_code[eq]=x", + "sort=address.vault_code", + "include=address.vault_code", + ): + response = await client.get(f"/v1/persons?{query}") + assert response.status_code == 400, (query, response.text) + assert "vault-" not in response.text + + +def test_views_without_embedded_private_fields_do_no_extra_work(): + from mongospecs_web.testing import contract_api + + assert view_of(contract_api("msgspec").resources[0].model)._nested == {} From 0dde820d64226ad22366be1b4d0c8b7e67530a4e Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 02:59:34 -0400 Subject: [PATCH 17/31] feat(core): StoreNone, OmitNone and SpecConfig(none=...) Leaving None-valued optional fields out of stored documents stays the default. StoreNone on a field, or none='store' on a model, stores an explicit null instead (OmitNone opts a field back out). Encoding, change tracking, update(), upsert defaults and the bulk paths follow the field's mode; loading never produces a spurious change in either mode. A unique sparse index over a field that stores null is refused with a pointer to a partial index. Also applies the omit rule to embedded pydantic models, which were encoded with their None values. Co-Authored-By: Claude Opus 5.5 --- claudedocs/v2-plan.md | 2 + docs/guide/models.md | 35 +++ .../tests/test_embedded_private.py | 43 ++++ .../mongospecs/src/mongospecs/__init__.py | 4 + packages/mongospecs/src/mongospecs/codec.py | 36 +++- packages/mongospecs/src/mongospecs/markers.py | 19 ++ packages/mongospecs/src/mongospecs/meta.py | 28 ++- .../mongospecs/src/mongospecs/pydantic.py | 93 +++++++- packages/mongospecs/tests/test_store_none.py | 203 ++++++++++++++++++ 9 files changed, 455 insertions(+), 8 deletions(-) create mode 100644 packages/mongospecs/tests/test_store_none.py diff --git a/claudedocs/v2-plan.md b/claudedocs/v2-plan.md index 9d209c9..0a3207f 100644 --- a/claudedocs/v2-plan.md +++ b/claudedocs/v2-plan.md @@ -885,6 +885,8 @@ Recorded per §7.1 rule 4. Each one names what changed and why; none invalidates | A31 | §4.2 | Field names that shadow the `Document` API raise `ConfigurationError` (`meta.RESERVED_FIELD_NAMES`) | Second review: a field named `count` silently replaced `Model.count()` | | A32 | §4.4 | `registry.transaction()` inside a running transaction raises unless `independent=True`. A context variable is used to detect this only; sessions are still always passed explicitly | Second review: an inner transaction committed while the outer aborted | | A33 | §4.3 | Filter values are cast by one rule on all backends (exact type, else text that reads as the type). `find().on_invalid("raise" \| "skip" \| "raw")` | Second review | +| A34 | §4.8 | `Policy(private=True)` is enforced on fields of embedded models at any depth: not serialised, a 422 when supplied, absent from schemas | Third review item A. PATCH replaces an embedded object whole, so a client that patches the parent object clears server-set private values inside it; documented, not changed | +| A35 | §4.2 | `StoreNone` / `OmitNone` markers and `SpecConfig(none="omit" \| "store")`. Embedded models resolve the setting from their own markers, or from a `__spec__` if they declare one; they do not inherit the parent document's setting. A unique sparse index over a field that stores null is a `ConfigurationError`. No warning is raised for a partial filter on `$type: null` over an omitted field | Requested as the opt-out from A30. The embedded rule keeps one model's stored shape the same wherever it is embedded | New node: diff --git a/docs/guide/models.md b/docs/guide/models.md index 26d2af6..bf5b2f5 100644 --- a/docs/guide/models.md +++ b/docs/guide/models.md @@ -38,6 +38,41 @@ class User(Spec): raises `ConfigurationError` when the model is first used. Rename the attribute and keep the stored key: `total: Annotated[int, Alias("count")] = 0`. +## None: left out, or stored as null + +A field whose default is `None` is left out of the document while it is `None`. Where other +readers of the collection need the key to be present (BI tools, a `$jsonSchema` validator, +`{field: {$type: "null"}}` queries), store an explicit `null` instead: + +```py +class Contact(Spec): + __spec__ = SpecConfig(none="omit") # the default; "store" flips it for the model + nick: str | None = None # left out while None + email: Annotated[str | None, StoreNone] = None # stored as null + # on a model with none="store": Annotated[str | None, OmitNone] opts one field out +``` + +| | Left out (default, `OmitNone`) | Stored (`StoreNone`, `none="store"`) | +|---|---|---| +| A new document with `None` | no key | `key: null` | +| `doc.field = None; save()` and `update(field=None)` | `$unset` | `$set: null` | +| A document created by `upsert()` | no key | `key: null` | +| `Q.field == None` | matches | matches | +| `Exists(Q.field)`, `?field[exists]=true` | "has a value" | "the key is present", null included | +| `Unset("field")` | removes the key | removes the key | + +- The setting changes what is written, never what can be read. A stored `null` under the default + and a missing key under `StoreNone` both load as `None` with `changes() == {}`, and saving such a + document sends nothing for that field. To convert a collection, run one `update_many`. +- Embedded models follow their own field markers. They omit by default; an embedded model may + carry `__spec__ = SpecConfig(none="store")` to change that for its fields. +- A field that stores `null` cannot be a key of a unique **sparse** index: a sparse index still + indexes `null`, so only one document could be without a value. This raises + `ConfigurationError` when the model is first used. Use a partial index: + `Index("email", unique=True, partial={"email": {"$type": "string"}})`. +- HTTP responses are the same in both modes (`None` is `null` in JSON). A `PATCH` with `null` + follows the field's mode. + ## Binding and lifecycle Models do nothing until they are bound to an open registry. Constructing a registry does no I/O; diff --git a/packages/mongospecs-web/tests/test_embedded_private.py b/packages/mongospecs-web/tests/test_embedded_private.py index 2c237df..e16c2e1 100644 --- a/packages/mongospecs-web/tests/test_embedded_private.py +++ b/packages/mongospecs-web/tests/test_embedded_private.py @@ -169,3 +169,46 @@ def test_views_without_embedded_private_fields_do_no_extra_work(): from mongospecs_web.testing import contract_api assert view_of(contract_api("msgspec").resources[0].model)._nested == {} + + +async def test_null_in_a_request_follows_the_fields_mode(request, make_registry): + """PATCH/POST with an explicit null: unset where None is omitted, stored where StoreNone says so.""" + from mongospecs import StoreNone + + results = {} + for backend in BACKENDS: + if backend == "attrs": + import attrs + + from mongospecs.attrs import Spec + + define = attrs.define(kw_only=True) + else: + Spec = __import__(f"mongospecs.{backend}", fromlist=["Spec"]).Spec + define = lambda c: c # noqa: E731 + + @define + class Contact(Spec): + __spec__ = SpecConfig(collection=f"contacts_{backend}") + name: str = "" + nick: str | None = None + email: t.Annotated[str | None, StoreNone] = None + + await make_registry(Contact) + api = Api(Resource(Contact, name="contacts", ops=[Get, Create, Patch]), prefix="/v1") + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=api.asgi()), base_url="http://t" + ) as client: + created = await client.post("/v1/contacts", json={"name": "a", "nick": None, "email": None}) + assert created.status_code == 201 and created.json()["data"]["nick"] is None + oid = Contact.collection() + stored = await oid.find_one({"name": "a"}) + assert "nick" not in stored and "email" in stored and stored["email"] is None + await oid.update_one({"_id": stored["_id"]}, {"$set": {"nick": "n", "email": "e"}}) + patched = await client.patch(f"/v1/contacts/{stored['_id']}", json={"nick": None, "email": None}) + assert patched.status_code == 200 + assert patched.json()["data"]["nick"] is None and patched.json()["data"]["email"] is None + stored = await oid.find_one({"_id": stored["_id"]}) + assert "nick" not in stored and "email" in stored and stored["email"] is None + results[backend] = patched.json()["data"].keys() + assert len(results) == 3 diff --git a/packages/mongospecs/src/mongospecs/__init__.py b/packages/mongospecs/src/mongospecs/__init__.py index 1bb440c..f3f631a 100644 --- a/packages/mongospecs/src/mongospecs/__init__.py +++ b/packages/mongospecs/src/mongospecs/__init__.py @@ -56,10 +56,12 @@ DeletedAt, Immutable, Index, + OmitNone, OnDelete, Policy, Revision, SpecConfig, + StoreNone, To, UpdatedAt, computed, @@ -139,6 +141,7 @@ "NotBoundError", "NotIn", "NotLoadedError", + "OmitNone", "OnDelete", "OpCtx", "OperationFailed", @@ -164,6 +167,7 @@ "SortBy", "SpecConfig", "StaleDocumentError", + "StoreNone", "To", "TransactionError", "Tx", diff --git a/packages/mongospecs/src/mongospecs/codec.py b/packages/mongospecs/src/mongospecs/codec.py index 2dd1b11..a82ccd8 100644 --- a/packages/mongospecs/src/mongospecs/codec.py +++ b/packages/mongospecs/src/mongospecs/codec.py @@ -18,7 +18,17 @@ from bson.raw_bson import RawBSONDocument from mongospecs.errors import ConfigurationError, ValidationError -from mongospecs.markers import DEFAULT_POLICY, Alias, Backref, Marker, Policy, To +from mongospecs.markers import ( + DEFAULT_POLICY, + Alias, + Backref, + Marker, + OmitNone, + Policy, + SpecConfig, + StoreNone, + To, +) from mongospecs.refs import Ref, id_of __all__ = ["Codec", "FieldInfo", "RefInfo", "codec_for", "to_bson"] @@ -333,6 +343,9 @@ def encode(self, obj: t.Any) -> dict[str, t.Any]: def absent_when_none(self, cls: type) -> frozenset[str]: """Stored keys of the fields whose default is ``None``. + ``StoreNone`` on a field, or ``SpecConfig(none="store")`` on its model, takes a + field out of this set; ``OmitNone`` puts it back. + For these, ``None`` means "no value" and the key is left out of the stored document instead of being written as ``null``. A document without the key decodes back to ``None``, so nothing is lost, and sparse and unique indexes @@ -343,10 +356,16 @@ def absent_when_none(self, cls: type) -> frozenset[str]: hit = cache.get(cls) if hit is None: defaults = self.defaults(cls) + store = _none_mode(cls) == "store" hit = frozenset( f.stored for f in self.fields(cls).values() - if f.stored != "_id" and not f.transient and f.name in defaults and defaults[f.name] is None + if f.stored != "_id" + and not f.transient + and f.name in defaults + and defaults[f.name] is None + and not f.has(StoreNone) + and (not store or f.has(OmitNone)) ) cache[cls] = hit return hit @@ -397,6 +416,19 @@ def clear_cache(self) -> None: self.__dict__.pop("_rn", None) self.__dict__.pop("_sp", None) self.__dict__.pop("_abs", None) + self.__dict__.pop("_eo", None) + + +def _none_mode(cls: type) -> str: + """``"omit"`` or ``"store"`` for a model class: from its SpecConfig, own or inherited.""" + from mongospecs.model import Document + + if isinstance(cls, type) and issubclass(cls, Document): + from mongospecs.meta import resolve_config + + return resolve_config(cls).none + config = getattr(cls, "__spec__", None) # an embedded model may declare one + return config.none if isinstance(config, SpecConfig) else "omit" def field_error(cls: type, name: str | None, message: str) -> ValidationError: diff --git a/packages/mongospecs/src/mongospecs/markers.py b/packages/mongospecs/src/mongospecs/markers.py index e6c7d86..6f832d1 100644 --- a/packages/mongospecs/src/mongospecs/markers.py +++ b/packages/mongospecs/src/mongospecs/markers.py @@ -22,10 +22,12 @@ "Immutable", "Index", "Marker", + "OmitNone", "OnDelete", "Policy", "Revision", "SpecConfig", + "StoreNone", "To", "UpdatedAt", "computed", @@ -59,6 +61,18 @@ class Immutable(Marker): """The field cannot change after insert.""" +class StoreNone(Marker): + """Store ``None`` as an explicit ``null`` instead of leaving the key out. + + By default a field whose default is ``None`` is not stored while it is ``None``. + Use this where other readers of the collection expect the key to be present. + """ + + +class OmitNone(Marker): + """Leave the key out while the value is ``None``, on a model declared with ``none="store"``.""" + + class Deferred(Marker): """The field is left out of the default projection; ask for it with ``select("+name")``.""" @@ -244,4 +258,9 @@ class SpecConfig: """Options for ``create_collection`` (``timeseries``, ``capped``, ``size``, ...).""" json_schema_validation: t.Literal["off", "moderate", "strict"] = "off" + none: t.Literal["omit", "store"] = "omit" + """What to write for a field whose default is ``None`` while its value is ``None``: + leave the key out (``"omit"``) or store ``null`` (``"store"``). ``StoreNone`` and + ``OmitNone`` on a field override it. Embedded models may carry a ``__spec__`` with + this setting too; without one they omit.""" """Install a derived ``$jsonSchema`` validator when indexes and options are applied.""" diff --git a/packages/mongospecs/src/mongospecs/meta.py b/packages/mongospecs/src/mongospecs/meta.py index 19b2a19..dd0bfb8 100644 --- a/packages/mongospecs/src/mongospecs/meta.py +++ b/packages/mongospecs/src/mongospecs/meta.py @@ -159,6 +159,29 @@ def __init__(self, model: type) -> None: f.stored for f in self.fields.values() if not f.transient ) + # A sparse index still indexes an explicit null, so a unique sparse index over a field + # that stores None as null lets only one document be without a value. + absent = self.codec.absent_when_none(model) + defaults = self.codec.defaults(model) + for index in self.config.indexes: + if not (index.unique and index.sparse): + continue + for key, _ in index.keys: + f = self.fields.get(key.partition(".")[0]) + if ( + f is not None + and f.name in defaults + and defaults[f.name] is None + and f.stored not in absent + ): + raise ConfigurationError( + f"{model.__name__}.{f.name} stores None as null (StoreNone or none='store') and is a key " + "of a unique sparse index. A sparse index still indexes null, so a second document " + "without a value would be a duplicate. Use a partial index instead, for example " + f"Index({f.name!r}, unique=True, partial={{{f.name!r}: {{'$type': 'string'}}}}), " + "or mark the field OmitNone." + ) + for f in self.fields.values(): if f.has(Deferred) and f.required: raise ConfigurationError(f"{model.__name__}.{f.name}: a Deferred field needs a default") @@ -318,10 +341,13 @@ def insert_defaults(self) -> dict[str, t.Any]: by ``insert()``; otherwise a query on a defaulted value would not find it. """ out: dict[str, t.Any] = {} + absent = self.codec.absent_when_none(self.model) for name, value in self.codec.defaults(self.model).items(): info = self.fields.get(name) - if info is None or info.transient or name == "id" or value is None: + if info is None or info.transient or name == "id": continue + if value is None and info.stored in absent: + continue # not stored while None; a StoreNone field is written as null out[info.stored] = to_bson(value) return out diff --git a/packages/mongospecs/src/mongospecs/pydantic.py b/packages/mongospecs/src/mongospecs/pydantic.py index 4515a80..8cbcf32 100644 --- a/packages/mongospecs/src/mongospecs/pydantic.py +++ b/packages/mongospecs/src/mongospecs/pydantic.py @@ -9,7 +9,7 @@ import pydantic from pydantic import BaseModel, ConfigDict, Field, TypeAdapter -from mongospecs.codec import Codec, RawField, field_error +from mongospecs.codec import _UNION_ORIGINS, Codec, RawField, field_error from mongospecs.errors import ValidationError from mongospecs.markers import SpecConfig from mongospecs.model import Document @@ -38,6 +38,45 @@ def _annotation(info: t.Any) -> t.Any: return info.annotation +class _Omit: + """Which keys of an embedded model are left out while None, and where its own embedded models are.""" + + __slots__ = ("absent", "children", "elem") + + def __init__(self) -> None: + self.absent: frozenset[str] = frozenset() + self.children: dict[str, _Omit] = {} + self.elem: _Omit | None = None + + def active(self, seen: set[int] | None = None) -> bool: + seen = set() if seen is None else seen + if id(self) in seen: + return False + seen.add(id(self)) + return ( + bool(self.absent) + or any(c.active(seen) for c in self.children.values()) + or (self.elem is not None and self.elem.active(seen)) + ) + + +def _drop_nones(value: t.Any, plan: _Omit) -> None: + if isinstance(value, dict): + if plan.elem is not None: + for item in value.values(): + _drop_nones(item, plan.elem) + return + for key in plan.absent: + if key in value and value[key] is None: + del value[key] + for key, child in plan.children.items(): + if key in value: + _drop_nones(value[key], child) + elif isinstance(value, list) and plan.elem is not None: + for item in value: + _drop_nones(item, plan.elem) + + class PydanticCodec(Codec): name = "pydantic" @@ -60,12 +99,56 @@ def _decode(self, cls: type, doc: dict[str, t.Any], lax: bool) -> t.Any: def _encode(self, obj: t.Any) -> dict[str, t.Any]: if _HAS_EXCLUDE_COMPUTED: - return t.cast("dict[str, t.Any]", obj.model_dump(by_alias=True, exclude_computed_fields=True)) - doc: dict[str, t.Any] = obj.model_dump(by_alias=True) - for name in type(obj).model_computed_fields: - doc.pop(name, None) + doc: dict[str, t.Any] = obj.model_dump(by_alias=True, exclude_computed_fields=True) + else: + doc = obj.model_dump(by_alias=True) + for name in type(obj).model_computed_fields: + doc.pop(name, None) + # model_dump also dumps embedded models, so the "None is not stored" rule of each + # embedded model has to be applied here; the other backends encode them one by one. + for key, plan in self._embedded_omits(type(obj)): + if key in doc: + _drop_nones(doc[key], plan) return doc + def _embedded_omits(self, cls: type) -> tuple[tuple[str, _Omit], ...]: + cache: dict[type, tuple[tuple[str, _Omit], ...]] = self.__dict__.setdefault("_eo", {}) + hit = cache.get(cls) + if hit is None: + plans: dict[type, _Omit] = {} + natives = {name: native for name, native, _, _ in self.raw_fields(cls)} + hit = tuple( + (natives[name], plan) + for name, info in self.fields(cls).items() + if (plan := self._omit_plan(info.type, plans)) is not None and plan.active() + ) + cache[cls] = hit + return hit + + def _omit_plan(self, tp: t.Any, plans: dict[type, _Omit]) -> _Omit | None: + while t.get_origin(tp) is t.Annotated: + tp = t.get_args(tp)[0] + if t.get_origin(tp) is None: + if not (isinstance(tp, type) and issubclass(tp, BaseModel)) or issubclass(tp, Document): + return None + plan = plans.get(tp) + if plan is None: + plan = plans[tp] = _Omit() + plan.absent = self.absent_when_none(tp) + for name, _native, annotation, _ in self.raw_fields(tp): + child = self._omit_plan(annotation, plans) + if child is not None: + plan.children[self.fields(tp)[name].stored] = child + return plan + parts = [p for arg in t.get_args(tp) if (p := self._omit_plan(arg, plans)) is not None] + if not parts: + return None + if t.get_origin(tp) in _UNION_ORIGINS: + return parts[0] + container = _Omit() + container.elem = parts[-1] + return container + def validate_field(self, cls: type, name: str, value: t.Any) -> t.Any: model = t.cast("type[BaseModel]", cls) try: diff --git a/packages/mongospecs/tests/test_store_none.py b/packages/mongospecs/tests/test_store_none.py new file mode 100644 index 0000000..939e1ab --- /dev/null +++ b/packages/mongospecs/tests/test_store_none.py @@ -0,0 +1,203 @@ +"""StoreNone / OmitNone / SpecConfig(none=...): whether a None value is stored as null or left out.""" + +import typing as t + +import pytest + +from mongospecs import ( + ConfigurationError, + Exists, + Index, + InsertOne, + OmitNone, + Q, + SpecConfig, + StoreNone, + Unset, + UpdateOne, + meta_of, +) + + +def embedded_base(m): + if m.backend == "msgspec": + import msgspec + + class Base(msgspec.Struct, kw_only=True): + pass + + return Base + if m.backend == "pydantic": + import pydantic + + return pydantic.BaseModel + return object + + +@pytest.fixture +async def models(m, make_registry): + Base = embedded_base(m) + + @m.define + class Part(Base): + label: str = "" + note: str | None = None # omitted while None + code: t.Annotated[str | None, StoreNone] = None # stored as null + + @m.define + class Profile(m.Spec): + __spec__ = SpecConfig(collection="profiles") + name: str = "" + nick: str | None = None # the default: omitted + email: t.Annotated[str | None, StoreNone] = None + part: Part | None = None + parts: list[Part] = [] # noqa: RUF012 + + @m.define + class Legacy(m.Spec): + __spec__ = SpecConfig(collection="legacy", none="store") + name: str = "" + phone: str | None = None # stored as null: the model says so + fax: t.Annotated[str | None, OmitNone] = None # the field opts out + + await make_registry(Profile, Legacy) + return Profile, Legacy, Part + + +async def raw(model, doc): + return await model.collection().find_one({"_id": doc.id}) + + +async def test_round_trip_per_mode(models): + Profile, Legacy, Part = models + doc = await Profile(name="a").insert() + stored = await raw(Profile, doc) + assert stored["email"] is None and "email" in stored # StoreNone: an explicit null + assert "nick" not in stored # omit mode: no key + loaded = await Profile.get(doc.id) + assert loaded.email is None and loaded.nick is None and loaded.changes() == {} + + +async def test_delta_save_per_mode(models): + Profile, Legacy, Part = models + doc = await Profile(name="a").insert() + doc.email, doc.nick = "a@x.io", "ann" + assert doc.changes() == {"$set": {"email": "a@x.io", "nick": "ann"}} + await doc.save() + doc.email, doc.nick = None, None + assert doc.changes() == {"$set": {"email": None}, "$unset": {"nick": ""}} + await doc.save() + stored = await raw(Profile, doc) + assert stored["email"] is None and "email" in stored and "nick" not in stored + assert (await Profile.get(doc.id)).changes() == {} + + +async def test_update_and_upsert_per_mode(models): + Profile, Legacy, Part = models + doc = await Profile(name="a", email="a@x.io", nick="ann").insert() + await Profile.update(doc.id, email=None, nick=None) + stored = await raw(Profile, doc) + assert "email" in stored and stored["email"] is None and "nick" not in stored + created = await Profile.upsert(Q.name == "fresh", name="fresh") + stored = await raw(Profile, created) + assert "email" in stored and stored["email"] is None and "nick" not in stored + assert created.changes() == {} + # Unset always removes the key, whatever the mode + await Profile.update(doc.id, Unset("email")) + assert "email" not in await raw(Profile, doc) + + +async def test_bulk_paths_per_mode(models): + Profile, Legacy, Part = models + many = (await Profile.insert_many([Profile(name="m1"), Profile(name="m2")])).inserted + fresh, existing = Profile(name="b1"), many[0] + existing.name = "m1!" + await Profile.bulk_save([fresh, existing]) + await Profile.bulk_write( + [InsertOne(Profile(name="w1")), UpdateOne(Q.name == "m2", email=None, nick=None)] + ) + docs = [d async for d in Profile.collection().find({})] + assert len(docs) == 4 + for stored in docs: + assert "email" in stored and stored["email"] is None, stored + assert "nick" not in stored, stored + + +async def test_model_wide_setting_and_field_opt_out(models): + Profile, Legacy, Part = models + doc = await Legacy(name="l").insert() + stored = await raw(Legacy, doc) + assert "phone" in stored and stored["phone"] is None # none="store" + assert "fax" not in stored # OmitNone wins over the model + doc.phone, doc.fax = "1", "2" + await doc.save() + doc.phone, doc.fax = None, None + assert doc.changes() == {"$set": {"phone": None}, "$unset": {"fax": ""}} + await Legacy.update(doc.id, phone=None, fax=None) + stored = await raw(Legacy, doc) + assert "phone" in stored and "fax" not in stored + + +async def test_embedded_models_follow_their_own_field_markers(models): + Profile, Legacy, Part = models + doc = await Profile( + name="e", part=Part(label="one"), parts=[Part(label="a"), Part(label="b", code="c")] + ).insert() + stored = await raw(Profile, doc) + assert stored["part"] == {"label": "one", "code": None} # note omitted, code stored as null + assert stored["parts"] == [{"label": "a", "code": None}, {"label": "b", "code": "c"}] + loaded = await Profile.get(doc.id) + assert loaded.changes() == {} and loaded.part.note is None and loaded.parts[0].code is None + + +async def test_exists_means_different_things_per_mode(models): + Profile, Legacy, Part = models + await Profile.insert_many([Profile(name="none"), Profile(name="both", email="e", nick="n")]) + assert await Profile.count(Exists(Q.nick)) == 1 # omit: "has a value" + assert await Profile.count(Exists(Q.email)) == 2 # StoreNone: "the key is present", null included + # comparing with None matches "no value" in both modes + assert await Profile.count(Q.nick == None) == 1 and await Profile.count(Q.email == None) == 1 # noqa: E711 + + +async def test_existing_data_loads_clean_in_both_modes(models): + Profile, Legacy, Part = models + # a null where the model omits, and a missing key where the model stores null + result = await Profile.collection().insert_one({"name": "old", "nick": None}) + loaded = await Profile.get(result.inserted_id) + assert loaded.nick is None and loaded.email is None and loaded.changes() == {} + await loaded.save() # nothing to send + stored = await Profile.collection().find_one({"_id": result.inserted_id}) + assert stored == {"_id": result.inserted_id, "name": "old", "nick": None} # untouched + loaded.name = "renamed" + assert loaded.changes() == {"$set": {"name": "renamed"}} # and only real changes are sent + + +def test_sparse_unique_index_on_a_stored_null_is_refused(m): + def build(config, annotation): + if m.backend == "attrs": + import attrs + + cls = attrs.make_class( + "Clash", {"email": attrs.field(default=None, type=annotation)}, bases=(m.Spec,), kw_only=True + ) + elif m.backend == "pydantic": + import pydantic + + cls = pydantic.create_model("Clash", __base__=m.Spec, email=(annotation, None)) + else: + import msgspec + + cls = msgspec.defstruct("Clash", [("email", annotation, None)], bases=(m.Spec,), kw_only=True) + cls.__spec__ = config + return cls + + sparse = [Index("email", unique=True, sparse=True)] + with pytest.raises(ConfigurationError, match="sparse.*partial"): + meta_of(build(SpecConfig(indexes=sparse), t.Annotated[str | None, StoreNone])) + with pytest.raises(ConfigurationError, match="sparse.*partial"): + meta_of(build(SpecConfig(indexes=sparse, none="store"), str | None)) + # fine: omitted while None, or a partial index instead of a sparse one + meta_of(build(SpecConfig(indexes=sparse), str | None)) + meta_of(build(SpecConfig(indexes=sparse, none="store"), t.Annotated[str | None, OmitNone])) + partial = [Index("email", unique=True, partial={"email": {"$type": "string"}})] + meta_of(build(SpecConfig(indexes=partial), t.Annotated[str | None, StoreNone])) From 49a32a93d0546165e8358a6b2072c14f8c4720a1 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 03:00:31 -0400 Subject: [PATCH 18/31] bench: B9, CPU per call and commands per operation on the hot paths Co-Authored-By: Claude Opus 5.5 --- bench/src/mongospecs_bench/hotpaths.py | 175 ++++++++++++++++++++++++ bench/src/mongospecs_bench/workloads.py | 7 + 2 files changed, 182 insertions(+) create mode 100644 bench/src/mongospecs_bench/hotpaths.py diff --git a/bench/src/mongospecs_bench/hotpaths.py b/bench/src/mongospecs_bench/hotpaths.py new file mode 100644 index 0000000..095e3d3 --- /dev/null +++ b/bench/src/mongospecs_bench/hotpaths.py @@ -0,0 +1,175 @@ +"""Workload B9: CPU cost of the hot paths, measured through public entry points only. + +Wall-clock time on a laptop is noisy; CPU time of this process and the number of +commands sent are not. Every row here is one of those two, so a change to the +library can be judged by running this workload before and after it. +""" + +from __future__ import annotations + +import datetime as dt +import time +import typing as t + +from mongospecs import Q, Registry +from mongospecs_bench.harness import Result, Settings, environment +from mongospecs_bench.storefront import models as shop_models +from mongospecs_web import Api, Cursor, Delete, Get, List, Resource + + +async def cpu_us(fn: t.Callable[[], t.Awaitable[t.Any]], calls: int, repeats: int) -> float: + """CPU microseconds per call: the best of ``repeats`` batches of ``calls``.""" + for _ in range(min(calls, 20)): + await fn() + best = float("inf") + for _ in range(repeats): + start = time.process_time() + for _ in range(calls): + await fn() + best = min(best, (time.process_time() - start) / calls) + return round(best * 1e6, 1) + + +class Commands: + """Counts the commands a block sends.""" + + def __init__(self, registry: Registry) -> None: + self.names: list[str] = [] + self._registry = registry + + def __call__(self, event: t.Any) -> None: + if event.phase == "started": + self.names.append(event.command_name) + + +def _api(m: t.Any, routes: int = 0) -> Api: + resources: list[Resource[t.Any]] = [ + Resource(m.Product, name="products", ops=[List, Get, Delete], page=Cursor(default=20, max=100)), + Resource(m.Review, name="reviews", ops=[List, Get], include=["product", "author"]), + ] + # extra mount points of the same model, to measure routing as an API grows + resources += [Resource(m.Review, name=f"r{i}", ops=[List, Get]) for i in range(routes)] + return Api(*resources, prefix="/api") + + +async def b9(settings: Settings) -> Result: + result = Result( + "B9", + "Hot paths: CPU per call and commands per operation", + "Where does the library spend CPU on the paths every request takes, and how many commands " + "does each operation send?", + ) + calls = settings.scale(300, 20) + repeats = settings.scale(5, 2) + products = settings.scale(400, 120) + for backend in settings.backends: + m = shop_models.build(backend) + events = Commands.__new__(Commands) + events.names = [] + async with environment(settings, m.ALL, on_command=events) as registry: + await registry.apply_indexes() + now = dt.datetime.now(dt.UTC) + await m.Product.insert_many( + [ + m.Product( + sku=f"S{i:05d}", + name=f"Product {i}", + category="tools", + price=float(i), + stock=i, + tags=["a", "b", "c"], + description="x" * 200, + created_at=now, + ) + for i in range(products) + ] + ) + users = ( + await m.User.insert_many([m.User(email=f"u{i}@x.io", name=f"u{i}") for i in range(20)]) + ).inserted + items = await m.Product.find().sort("sku").all() + await m.Review.insert_many( + [ + m.Review(product=items[i % 20], rating=1 + i % 5, body="fine", author=users[i % 20]) + for i in range(2000) + ] + ) + first = items[0].id + api = _api(m) + + async def listing() -> None: + response = await api.handle("GET", "/api/products", query=[("limit", "100")]) + assert response.status == 200, response.body + + async def detail() -> None: + assert (await api.handle("GET", f"/api/products/{first}")).status == 200 + + async def model_get() -> None: + assert await m.Product.get(first) is not None + + async def tracked_page() -> None: + assert len(await m.Product.find().limit(100).all()) == 100 + + async def lean_page() -> None: + assert len(await m.Product.find().lean().limit(100).all()) == 100 + + async def two_includes() -> None: + assert len(await m.Review.find().limit(100).include("product").include("author").all()) == 100 + + async def per_parent() -> None: + query = m.Product.find(Q.sku < "S00020").include("reviews", per_parent_limit=2) + assert len(await query.all()) == 20 + + def add(path: str, unit: str, value: float, per: int = 1) -> None: + result.add(backend=backend, path=path, unit=unit, value=round(value / per, 2)) + + add("web: GET list of 100", "cpu us/doc", await cpu_us(listing, calls, repeats), 100) + add("web: GET one", "cpu us/call", await cpu_us(detail, calls, repeats)) + add("Model.get(id)", "cpu us/call", await cpu_us(model_get, calls, repeats)) + add("find().all(), 100 tracked", "cpu us/doc", await cpu_us(tracked_page, calls, repeats), 100) + add("find().lean().all(), 100", "cpu us/doc", await cpu_us(lean_page, calls, repeats), 100) + add("include x2, 100 parents", "cpu us/call", await cpu_us(two_includes, calls // 4, repeats)) + add( + "include reviews, per_parent_limit=2 of 100", + "cpu us/call", + await cpu_us(per_parent, calls // 4, repeats), + ) + + def count(name: str, *paths: str) -> None: + add(name, "commands", float(len(events.names))) + result.rows[-1]["detail"] = " ".join(events.names) + + for name, run in ( + ("web: GET one", detail), + ("web: GET list of 100", listing), + ("include x2, 100 parents", two_includes), + ("include reviews, per_parent_limit=2 of 100", per_parent), + ): + events.names.clear() + await run() + count(name) + victim = items[-1].id + events.names.clear() + assert (await api.handle("DELETE", f"/api/products/{victim}")).status == 204 + count("web: DELETE (soft)") + + if backend == settings.backends[0]: + for routes in (25, 250, 1000): + big = _api(m, routes // 2) + path = f"/api/r{routes // 2 - 1}/000000000000000000000000" + for _ in range(200): + big.match("GET", path) + start = time.process_time() + for _ in range(2000): + big.match("GET", path) + result.add( + backend="-", + path=f"route match, last of {len(big.routes)} routes", + unit="cpu us/call", + value=round((time.process_time() - start) / 2000 * 1e6, 2), + ) + result.notes.append( + "CPU time of this process (best of several batches), so the database's own work and network waits " + "are excluded. Rows with unit 'commands' are driver commands sent, listed in 'detail'." + ) + return result diff --git a/bench/src/mongospecs_bench/workloads.py b/bench/src/mongospecs_bench/workloads.py index 6c6870f..1af6047 100644 --- a/bench/src/mongospecs_bench/workloads.py +++ b/bench/src/mongospecs_bench/workloads.py @@ -552,6 +552,12 @@ async def b8(settings: Settings) -> Result: return result +async def _b9(settings: Settings) -> Result: + from mongospecs_bench.hotpaths import b9 + + return await b9(settings) + + WORKLOADS: dict[str, Workload] = { "B1": b1, "B2": b2, @@ -560,4 +566,5 @@ async def b8(settings: Settings) -> Result: "B5": b5, "B6": b6, "B8": b8, + "B9": _b9, } From ce9173182e4c209cb3c8742fe30d3a377eb62432 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 06:59:06 -0400 Subject: [PATCH 19/31] perf(core,web): cut work on the read, include, delete and routing paths - web GET list/get read stored documents with a projection of the readable fields and serialise them directly when that is certain to equal the model path; anything else falls back to the model - DELETE is one conditional write unless the model needs the document (delete hooks, cascades) - independent includes are loaded concurrently (not inside a session) - per_parent_limit on a reverse relation is applied on the server - tracked documents use one weak reference instead of weakref.finalize - Query._clone copies slots instead of re-running __init__ - routes are looked up by resource before patterns are tried fix(core): cursor pagination no longer skips or stops at documents without a value for a sort key Co-Authored-By: Claude Opus 5.5 --- bench/results/49a32a9/B9.json | 279 +++++++++++++ .../mongospecs-web/src/mongospecs_web/api.py | 50 ++- .../src/mongospecs_web/backend.py | 39 +- .../src/mongospecs_web/fastread.py | 284 +++++++++++++ .../src/mongospecs_web/policy.py | 29 +- .../src/mongospecs_web/resource.py | 29 +- .../mongospecs-web/tests/test_fast_reads.py | 382 ++++++++++++++++++ .../tests/test_mongo_resource.py | 5 +- packages/mongospecs/src/mongospecs/manager.py | 53 +++ packages/mongospecs/src/mongospecs/query.py | 59 ++- .../mongospecs/src/mongospecs/relations.py | 102 +++-- .../mongospecs/src/mongospecs/tracking.py | 54 ++- .../mongospecs/tests/test_review_round2.py | 27 ++ 13 files changed, 1318 insertions(+), 74 deletions(-) create mode 100644 bench/results/49a32a9/B9.json create mode 100644 packages/mongospecs-web/src/mongospecs_web/fastread.py create mode 100644 packages/mongospecs-web/tests/test_fast_reads.py diff --git a/bench/results/49a32a9/B9.json b/bench/results/49a32a9/B9.json new file mode 100644 index 0000000..84946fd --- /dev/null +++ b/bench/results/49a32a9/B9.json @@ -0,0 +1,279 @@ +{ + "meta": { + "git_sha": "49a32a9", + "git_dirty": false, + "timestamp": "2026-10-13T07:00:32+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B9", + "title": "Hot paths: CPU per call and commands per operation", + "question": "Where does the library spend CPU on the paths every request takes, and how many commands does each operation send?", + "rows": [ + { + "backend": "msgspec", + "path": "web: GET list of 100", + "unit": "cpu us/doc", + "value": 10.92 + }, + { + "backend": "msgspec", + "path": "web: GET one", + "unit": "cpu us/call", + "value": 272.2 + }, + { + "backend": "msgspec", + "path": "Model.get(id)", + "unit": "cpu us/call", + "value": 248.4 + }, + { + "backend": "msgspec", + "path": "find().all(), 100 tracked", + "unit": "cpu us/doc", + "value": 8.7 + }, + { + "backend": "msgspec", + "path": "find().lean().all(), 100", + "unit": "cpu us/doc", + "value": 6.66 + }, + { + "backend": "msgspec", + "path": "include x2, 100 parents", + "unit": "cpu us/call", + "value": 2239.9 + }, + { + "backend": "msgspec", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "cpu us/call", + "value": 23049.0 + }, + { + "backend": "msgspec", + "path": "web: GET one", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "msgspec", + "path": "web: GET list of 100", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "msgspec", + "path": "include x2, 100 parents", + "unit": "commands", + "value": 3.0, + "detail": "find find find" + }, + { + "backend": "msgspec", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "commands", + "value": 3.0, + "detail": "find find getMore" + }, + { + "backend": "msgspec", + "path": "web: DELETE (soft)", + "unit": "commands", + "value": 2.0, + "detail": "find update" + }, + { + "backend": "-", + "path": "route match, last of 29 routes", + "unit": "cpu us/call", + "value": 4.41 + }, + { + "backend": "-", + "path": "route match, last of 255 routes", + "unit": "cpu us/call", + "value": 19.12 + }, + { + "backend": "-", + "path": "route match, last of 1005 routes", + "unit": "cpu us/call", + "value": 73.25 + }, + { + "backend": "pydantic", + "path": "web: GET list of 100", + "unit": "cpu us/doc", + "value": 14.36 + }, + { + "backend": "pydantic", + "path": "web: GET one", + "unit": "cpu us/call", + "value": 251.2 + }, + { + "backend": "pydantic", + "path": "Model.get(id)", + "unit": "cpu us/call", + "value": 219.6 + }, + { + "backend": "pydantic", + "path": "find().all(), 100 tracked", + "unit": "cpu us/doc", + "value": 10.93 + }, + { + "backend": "pydantic", + "path": "find().lean().all(), 100", + "unit": "cpu us/doc", + "value": 8.18 + }, + { + "backend": "pydantic", + "path": "include x2, 100 parents", + "unit": "cpu us/call", + "value": 2281.2 + }, + { + "backend": "pydantic", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "cpu us/call", + "value": 23069.5 + }, + { + "backend": "pydantic", + "path": "web: GET one", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "pydantic", + "path": "web: GET list of 100", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "pydantic", + "path": "include x2, 100 parents", + "unit": "commands", + "value": 3.0, + "detail": "find find find" + }, + { + "backend": "pydantic", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "commands", + "value": 3.0, + "detail": "find find getMore" + }, + { + "backend": "pydantic", + "path": "web: DELETE (soft)", + "unit": "commands", + "value": 2.0, + "detail": "find update" + }, + { + "backend": "attrs", + "path": "web: GET list of 100", + "unit": "cpu us/doc", + "value": 17.25 + }, + { + "backend": "attrs", + "path": "web: GET one", + "unit": "cpu us/call", + "value": 252.9 + }, + { + "backend": "attrs", + "path": "Model.get(id)", + "unit": "cpu us/call", + "value": 393.9 + }, + { + "backend": "attrs", + "path": "find().all(), 100 tracked", + "unit": "cpu us/doc", + "value": 14.34 + }, + { + "backend": "attrs", + "path": "find().lean().all(), 100", + "unit": "cpu us/doc", + "value": 11.69 + }, + { + "backend": "attrs", + "path": "include x2, 100 parents", + "unit": "cpu us/call", + "value": 2787.5 + }, + { + "backend": "attrs", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "cpu us/call", + "value": 25883.3 + }, + { + "backend": "attrs", + "path": "web: GET one", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "attrs", + "path": "web: GET list of 100", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "attrs", + "path": "include x2, 100 parents", + "unit": "commands", + "value": 3.0, + "detail": "find find find" + }, + { + "backend": "attrs", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "commands", + "value": 3.0, + "detail": "find find getMore" + }, + { + "backend": "attrs", + "path": "web: DELETE (soft)", + "unit": "commands", + "value": 2.0, + "detail": "find update" + } + ], + "notes": [ + "CPU time of this process (best of several batches), so the database's own work and network waits are excluded. Rows with unit 'commands' are driver commands sent, listed in 'detail'." + ], + "conclusion": "" +} diff --git a/packages/mongospecs-web/src/mongospecs_web/api.py b/packages/mongospecs-web/src/mongospecs_web/api.py index d75e905..69aece3 100644 --- a/packages/mongospecs-web/src/mongospecs_web/api.py +++ b/packages/mongospecs-web/src/mongospecs_web/api.py @@ -103,7 +103,7 @@ async def read_body(self, headers: t.Mapping[str, str], chunks: t.AsyncIterable[ def allowed_methods(self, path: str) -> list[str]: """Methods the routes matching ``path`` accept (empty when no route matches).""" - methods = {route.method for route in self._routes() if route.pattern.match(path)} + methods = {route.method for route in self._candidates(path) if route.pattern.match(path)} if "GET" in methods: methods.add("HEAD") return sorted(methods | {"OPTIONS"}) if methods else [] @@ -111,27 +111,63 @@ def allowed_methods(self, path: str) -> list[str]: def match(self, method: str, path: str) -> tuple[Route, dict[str, str]]: """Find the route for a request, or raise 404 / 405.""" allowed: list[str] = [] - for route in self._routes(): + method = method.upper() + for route in self._candidates(path): found = route.pattern.match(path) if found is None: continue - if route.method == method.upper(): + if route.method == method: return route, found.groupdict() allowed.append(route.method) if allowed: raise MethodNotAllowed(headers={"allow": ", ".join(sorted(set(allowed)))}) raise NotFound("No such route.") + def _candidates(self, path: str) -> t.Sequence[Route]: + """The routes of the resource ``path`` points into: a dict lookup, then a few patterns. + + Matching used to try every route of the API in turn, which grows with the API. + """ + index: dict[str, tuple[Resource[t.Any], int, list[Route]]] | None = self.__dict__.get("_route_index") + if index is None: + index = self._build_index() + prefix = self.prefix + if prefix and not path.startswith(prefix + "/"): + return () + rest = path[len(prefix) + 1 :] + # resource names may contain slashes, so try the longest known name first + segments = rest.rstrip("/").split("/") + for depth in range(min(len(segments), self.__dict__["_route_depth"]), 0, -1): + entry = index.get("/".join(segments[:depth])) + if entry is None: + continue + resource, count, routes = entry + if len(resource.operations) != count: # an operation was added after the index was built + self.__dict__.pop("_route_index", None) + return self._candidates(path) + return routes + return () + + def _build_index(self) -> dict[str, tuple[Resource[t.Any], int, list[Route]]]: + index: dict[str, tuple[Resource[t.Any], int, list[Route]]] = {} + for resource in self.resources: + index[resource.name.strip("/")] = (resource, len(resource.operations), []) + for route in self._routes(): + index[route.resource.name.strip("/")][2].append(route) # keeps the literal-first order + self.__dict__["_route_index"] = index + self.__dict__["_route_depth"] = max((name.count("/") + 1 for name in index), default=1) + return index + def _routes(self) -> list[Route]: cached: list[Route] | None = self.__dict__.get("_route_cache") - if cached is None or self.__dict__.get("_route_count") != sum( - len(r.operations) for r in self.resources - ): + count = sum(len(r.operations) for r in self.resources) + if cached is None or self.__dict__.get("_route_count") != count: cached = self.routes # Literal segments win over parameters: /{id}/publish before /{id}, /search before /{id}. cached.sort(key=lambda r: (-r.path.count("/"), r.path.count("{"))) self.__dict__["_route_cache"] = cached - self.__dict__["_route_count"] = sum(len(r.operations) for r in self.resources) + self.__dict__["_route_count"] = count + self.__dict__.pop("_route_index", None) return cached async def run(self, route: Route, request: Request) -> Response: diff --git a/packages/mongospecs-web/src/mongospecs_web/backend.py b/packages/mongospecs-web/src/mongospecs_web/backend.py index ce85e5e..2b2e4de 100644 --- a/packages/mongospecs-web/src/mongospecs_web/backend.py +++ b/packages/mongospecs-web/src/mongospecs_web/backend.py @@ -107,21 +107,35 @@ def _by_id(self, id: t.Any, scope: t.Any) -> t.Any: return None return query.filter(scope) if scope is not None else query + supports_raw = True + """``get`` and ``list`` accept ``fields=``: return stored documents (dicts) holding only + those fields instead of models. A Resource asks for this when it will only serialise the + result and its view can do that without a model.""" + async def get( - self, id: t.Any, *, scope: t.Any, include: t.Sequence[str] = (), read_only: bool = False + self, + id: t.Any, + *, + scope: t.Any, + include: t.Sequence[str] = (), + read_only: bool = False, + fields: t.Sequence[str] | None = None, ) -> T | None: query = self._by_id(id, scope) if query is None: return None query = self._bounded(query) - if read_only: + if fields is not None: + query = query.raw().select(*fields) # only what will be shown leaves the database + elif read_only: query = query.lean() # nothing will be saved: skip change tracking for path in include: query = query.include(path) return t.cast("T | None", await query.first()) - async def list(self, query: ListQuery, *, scope: t.Any) -> Page[T]: - q = self._bounded(self.manager.find(*(f.to_expr() for f in query.filters))).lean() # read-only + async def list(self, query: ListQuery, *, scope: t.Any, fields: t.Sequence[str] | None = None) -> Page[T]: + q = self._bounded(self.manager.find(*(f.to_expr() for f in query.filters))) + q = q.raw().select(*fields) if fields is not None else q.lean() # read-only either way if scope is not None: q = q.filter(scope) if query.sort: @@ -152,15 +166,24 @@ async def patch(self, id: t.Any, data: dict[str, t.Any], *, scope: t.Any, if_rev return t.cast(T, await self.manager.update(target, if_rev=if_rev, **data)) async def delete(self, id: t.Any, *, scope: t.Any, if_rev: int | None = None) -> None: + manager = self.manager + condition = manager.compile(scope) if scope is not None else None + try: + key = manager.meta.cast("id", id) + except QueryError: + raise DocumentNotFound(f"{self.model.__name__} {id!r} does not exist") from None + # One conditional write when nothing needs the document itself (no delete hooks, no + # cascades): the scope and the revision are part of the write's own filter. + if await manager.delete_by_id(key, condition=condition, if_rev=if_rev): + return query = self._by_id(id, scope) doc = await query.first() if query is not None else None if doc is None: raise DocumentNotFound(f"{self.model.__name__} {id!r} does not exist") - # The document is loaded so that hooks, soft delete and cascades run. The scope is - # then also part of the delete's own filter, so it holds at the moment of the write. - condition = self.manager.compile(scope) if scope is not None else None + # The document is loaded so that hooks and cascades run. The scope is then also + # part of the delete's own filter, so it holds at the moment of the write. try: - await self.manager.delete_document(doc, condition=condition, if_rev=if_rev) + await manager.delete_document(doc, condition=condition, if_rev=if_rev) except StaleDocumentError: if if_rev is not None: raise diff --git a/packages/mongospecs-web/src/mongospecs_web/fastread.py b/packages/mongospecs-web/src/mongospecs_web/fastread.py new file mode 100644 index 0000000..81e6adc --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/fastread.py @@ -0,0 +1,284 @@ +"""Serialise the Read view straight from a stored document, without building a model. + +The ordinary read path decodes BSON into a model, dumps the whole model to JSON +types and then picks the readable fields. For most models that work can be +skipped: the readable fields are simple values whose JSON form is known from the +declared type. :func:`compile_plan` builds such a plan when, and only when, the +result is certain to equal what the model path produces: + +* every readable field has a type this module knows how to convert, +* the model has nothing that could change values on the way in or out + (validators, converters, serialisers, post-init hooks, ``upcast``, computed + fields, a discriminator), +* at run time each stored value has exactly the declared type. Anything else + (an integer stored as a double, a missing required field) raises + :class:`Slow`, and that one document goes through the model path instead. + +Private fields are never read: not at the top level, where they are left out of +the projection, and not inside embedded models. +""" + +from __future__ import annotations + +import datetime as dt +import enum +import typing as t + +from bson import ObjectId + +from mongospecs import Document, Ref, meta_of +from mongospecs.codec import _UNION_ORIGINS, Codec, _codec_or_none, to_bson +from mongospecs.markers import Deferred, Marker +from mongospecs.refs import _OIDAnnotation + +__all__ = ["Plan", "Slow", "compile_plan"] + +Conv = t.Callable[[t.Any], t.Any] + + +class Slow(Exception): + """This document needs the model path.""" + + +_SLOW = Slow() +_MISSING: t.Any = object() +_HARMLESS_METADATA = ("msgspec", "annotated_types", "pydantic.fields", "pydantic.types") +_VALUE_CHANGING_CONFIG = ("str_strip_whitespace", "str_to_lower", "str_to_upper", "coerce_numbers_to_str") + + +def _exact(kind: type) -> Conv: + def conv(value: t.Any) -> t.Any: + if type(value) is not kind: + raise _SLOW + return value + + return conv + + +def _object_id(value: t.Any) -> t.Any: + if type(value) is not ObjectId: + raise _SLOW + return str(value) + + +def _anything(value: t.Any) -> t.Any: + """An untyped value: only plain JSON types are certain to come out unchanged.""" + kind = type(value) + if kind is str or kind is int or kind is float or kind is bool or value is None: + return value + if kind is list: + return [_anything(v) for v in value] + if kind is dict: + for key in value: + if type(key) is not str: + raise _SLOW + return {k: _anything(v) for k, v in value.items()} + raise _SLOW + + +def _plain_class(cls: type, codec: Codec) -> bool: + """True when the class has nothing that could change a value between storage and output.""" + for hook in ("__post_init__", "__attrs_post_init__", "model_post_init"): + fn = getattr(cls, hook, None) + module = getattr(fn, "__module__", "") or "" + if fn is not None and not module.startswith(("mongospecs.", "pydantic.")): + return False + if codec.name == "pydantic": + decorators = getattr(cls, "__pydantic_decorators__", None) + for group in ( + "validators", + "field_validators", + "root_validators", + "model_validators", + "field_serializers", + "model_serializers", + "computed_fields", + ): + if getattr(decorators, group, None): + return False + config = getattr(cls, "model_config", {}) + if any(config.get(key) for key in _VALUE_CHANGING_CONFIG): + return False + elif codec.name == "attrs": + import attrs + + if any(a.converter is not None for a in attrs.fields(cls)): + return False + return True + + +class _Builder: + def __init__(self, codec_name: str) -> None: + self.models: dict[type, Conv | None] = {} + if codec_name == "pydantic": + from pydantic_core import to_jsonable_python + + self.temporal: Conv = to_jsonable_python + else: + import msgspec + + self.temporal = msgspec.to_builtins + + def conv(self, tp: t.Any) -> Conv | None: + """A converter for values of type ``tp``, or ``None`` when the type is not supported.""" + while t.get_origin(tp) is t.Annotated: + tp, *metadata = t.get_args(tp) + for item in metadata: + if item is _OIDAnnotation or isinstance(item, Marker): + continue + if isinstance(item, type) and issubclass(item, Marker): + continue + if not type(item).__module__.startswith(_HARMLESS_METADATA): + return None # a validator or serialiser we cannot reason about + origin = t.get_origin(tp) + if origin in _UNION_ORIGINS: + members = [a for a in t.get_args(tp) if a is not type(None)] + if len(members) != 1 or len(members) == len(t.get_args(tp)): + return None + inner = self.conv(members[0]) + if inner is None: + return None + return lambda value: None if value is None else inner(value) + if tp is t.Any: + return _anything + if tp is Ref or origin is Ref: + return _object_id + if origin is list: + (arg,) = t.get_args(tp) or (t.Any,) + item = self.conv(arg) + if item is None: + return None + + def conv_list(value: t.Any) -> t.Any: + if type(value) is not list: + raise _SLOW + return [item(v) for v in value] + + return conv_list + if origin is dict: + key, arg = t.get_args(tp) or (str, t.Any) + each = self.conv(arg) + if key is not str or each is None: + return None + + def conv_dict(value: t.Any) -> t.Any: + if type(value) is not dict: + raise _SLOW + return {k: each(v) for k, v in value.items()} + + return conv_dict + if origin is not None or not isinstance(tp, type): + return None + if tp in (str, int, float, bool): + return _exact(tp) + if tp is ObjectId: + return _object_id + if tp is dt.datetime: + temporal = self.temporal + + def conv_datetime(value: t.Any) -> t.Any: + if type(value) is not dt.datetime: + raise _SLOW + return temporal(value) + + return conv_datetime + if issubclass(tp, enum.Enum): + values = {member.value for member in tp} + if not all(type(v) in (str, int) for v in values): + return None + + def conv_enum(value: t.Any) -> t.Any: + if type(value) not in (str, int) or value not in values: + raise _SLOW + return value + + return conv_enum + if issubclass(tp, Document): + return None + codec = _codec_or_none(tp) + if codec is None: + return None + if tp not in self.models: + self.models[tp] = None # a model that contains itself is not supported + self.models[tp] = self.embedded(tp, codec) + return self.models[tp] + + def fields(self, cls: type, codec: Codec, *, top: bool) -> list[tuple[str, str, Conv, t.Any]] | None: + """``(output key, stored key, converter, default)`` per readable field, or ``None``.""" + if not _plain_class(cls, codec): + return None + natives = {name: native for name, native, _, _ in codec.raw_fields(cls)} + defaults = codec.defaults(cls) + out: list[tuple[str, str, Conv, t.Any]] = [] + for name, info in codec.fields(cls).items(): + if info.transient: + continue + if not top and info.stored != natives[name]: + return None # an alias inside an embedded model: leave it to the model path + if info.policy.private or (top and info.has(Deferred)): + if name not in defaults: + return None # the model path needs the field, and we will not fetch it + continue + conv = self.conv(info.type) + if conv is None: + return None + default = _MISSING + if name in defaults: + default = defaults[name] + if default is not None: + try: + conv(to_bson(default)) + except Slow: + return None + out.append((name if top else natives[name], info.stored, conv, default)) + return out + + def embedded(self, cls: type, codec: Codec) -> Conv | None: + fields = self.fields(cls, codec, top=False) + if fields is None: + return None + + def conv_model(value: t.Any) -> t.Any: + if type(value) is not dict: + raise _SLOW + return _apply(fields, value) + + return conv_model + + +def _apply(fields: list[tuple[str, str, Conv, t.Any]], doc: dict[str, t.Any]) -> dict[str, t.Any]: + out: dict[str, t.Any] = {} + get = doc.get + for key, stored, conv, default in fields: + value = get(stored, _MISSING) + if value is _MISSING: + if default is _MISSING: + raise _SLOW # a required field is missing: let the model report it + out[key] = None if default is None else conv(to_bson(default)) + else: + out[key] = conv(value) + return out + + +class Plan: + """How to produce the Read view of one model from a stored document.""" + + __slots__ = ("_fields", "select") + + def __init__(self, fields: list[tuple[str, str, Conv, t.Any]]) -> None: + self._fields = fields + self.select: tuple[str, ...] = tuple(key for key, _, _, _ in fields) + """Attribute names to project: the readable fields and nothing else.""" + + def serialize(self, doc: dict[str, t.Any]) -> dict[str, t.Any]: + """The Read view as JSON types. Raises :class:`Slow` when this document needs the model path.""" + return _apply(self._fields, doc) + + +def compile_plan(model: type, computed: t.Sequence[str]) -> Plan | None: + """A :class:`Plan` for ``model``, or ``None`` when only the model path is certain to be right.""" + meta = meta_of(model) + if computed or meta.disc_key or meta.config.upcast is not None: + return None + fields = _Builder(meta.codec.name).fields(model, meta.codec, top=True) + return Plan(fields) if fields is not None else None diff --git a/packages/mongospecs-web/src/mongospecs_web/policy.py b/packages/mongospecs-web/src/mongospecs_web/policy.py index 7b2ae8e..1d1da57 100644 --- a/packages/mongospecs-web/src/mongospecs_web/policy.py +++ b/packages/mongospecs-web/src/mongospecs_web/policy.py @@ -274,11 +274,22 @@ def may(flag: bool | None, default: bool) -> bool: ) revision = next((f for f in fields.values() if f.has(Revision)), None) self.revision: str | None = revision.name if revision else None + self._revision_stored: str | None = revision.stored if revision else None + from mongospecs_web.fastread import compile_plan + + self.fast = compile_plan(model, self.computed) + """Plan for serialising straight from a stored document, or ``None`` when this model + needs the model path (see :mod:`mongospecs_web.fastread`).""" # -- output ---------------------------------------------------------------- def serialize(self, doc: t.Any, include: t.Collection[str] = ()) -> dict[str, t.Any]: - """The Read view of a document as JSON types. Private fields never appear.""" + """The Read view of a document as JSON types. Private fields never appear. + + ``doc`` is a model instance, or a stored document (a ``dict``) read for this purpose. + """ + if type(doc) is dict: + return self.serialize_raw(doc, include) if type(doc) is not self.model: return view_of(type(doc)).serialize(doc, include) data = self.meta.codec.to_json(doc) @@ -303,9 +314,25 @@ def serialize(self, doc: t.Any, include: t.Collection[str] = ()) -> dict[str, t. out[name] = jsonable(getattr(doc, name)) return out + def serialize_raw(self, raw: dict[str, t.Any], include: t.Collection[str] = ()) -> dict[str, t.Any]: + """The Read view of a stored document; identical to decoding it and calling ``serialize``.""" + from mongospecs_web.fastread import Slow + + if self.fast is not None and not include: + try: + return self.fast.serialize(raw) + except Slow: + pass # a value that does not have its declared type: take the careful path + return self.serialize(self.meta.decode(raw), include) + def etag(self, doc: t.Any) -> str | None: if self.revision is None: return None + if type(doc) is dict: + revision = doc.get(self._revision_stored, 0) + if type(revision) is not int: # stored as a double by another writer: let the model read it + revision = getattr(self.meta.decode(doc), self.revision) + return f'"{revision}"' return f'"{getattr(doc, self.revision)}"' # -- input --------------------------------------------------------------- diff --git a/packages/mongospecs-web/src/mongospecs_web/resource.py b/packages/mongospecs-web/src/mongospecs_web/resource.py index e870a71..92ff515 100644 --- a/packages/mongospecs-web/src/mongospecs_web/resource.py +++ b/packages/mongospecs-web/src/mongospecs_web/resource.py @@ -232,6 +232,9 @@ def __init__( raise ValueError(f"{model.__name__}.{head} is not a readable relation and cannot be included") self.page: Pagination = page or Cursor() self.tags: tuple[str, ...] = tuple(tags) if tags is not None else (self.name,) + self.raw_reads = True + """Serve ``GET`` straight from stored documents where that is certain to give the same + response as going through the model. Set to ``False`` to always build models.""" self._all_guards: tuple[Guard, ...] = () self._guards: dict[str, tuple[Guard, ...]] = {} if isinstance(guards, t.Mapping): @@ -379,10 +382,28 @@ def _ref(self, kind: str) -> dict[str, t.Any]: def schema_name(self, kind: str) -> str: return f"{self.model.__name__}{kind.capitalize()}" + def _raw_fields(self, kind: str, include: t.Collection[str]) -> tuple[str, ...] | None: + """The fields to read as a plain stored document, or ``None`` to read a model. + + Reading without a model is possible when the response is all the document is used + for: the view can serialise it directly, no relation is embedded, and no ``after`` + hook is waiting for a model. + """ + plan = self.view.fast + if plan is None or include or self._after.get(kind) or not self.raw_reads: + return None + if not getattr(self.backend, "supports_raw", False): + return None + return plan.select + def _list_op(self) -> Operation: async def handler(ctx: Ctx[T]) -> Response: query = parse_list_query(ctx.request.query, self.view, self.page, self.includes) - page = await self.backend.list(query, scope=await ctx.scope()) + fields = self._raw_fields(List, query.include) + if fields is not None: + page = await t.cast(t.Any, self.backend).list(query, scope=await ctx.scope(), fields=fields) + else: + page = await self.backend.list(query, scope=await ctx.scope()) ctx.state["result"] = page meta: dict[str, t.Any] = {"size": page.size, "has_next": page.has_next} if page.number is not None: @@ -485,7 +506,11 @@ def _one(self) -> dict[str, t.Any]: def _get_op(self) -> Operation: async def handler(ctx: Ctx[T]) -> Response: include = parse_includes(ctx.request.query, self.includes) - doc = await self.backend.get(ctx.id, scope=await ctx.scope(), include=include, read_only=True) + fields = self._raw_fields(Get, include) + if fields is not None: + doc = await t.cast(t.Any, self.backend).get(ctx.id, scope=await ctx.scope(), fields=fields) + else: + doc = await self.backend.get(ctx.id, scope=await ctx.scope(), include=include, read_only=True) if doc is None: raise NotFound("The resource does not exist.") ctx.state["result"] = doc diff --git a/packages/mongospecs-web/tests/test_fast_reads.py b/packages/mongospecs-web/tests/test_fast_reads.py new file mode 100644 index 0000000..91739ac --- /dev/null +++ b/packages/mongospecs-web/tests/test_fast_reads.py @@ -0,0 +1,382 @@ +"""Reads served straight from stored documents must equal reads served through the model.""" + +import datetime as dt +import enum +import json +import typing as t + +import pytest + +from mongospecs import ( + Alias, + CreatedAt, + Deferred, + DeletedAt, + Policy, + Q, + Ref, + Revision, + SpecConfig, + computed, +) +from mongospecs_web import Api, Delete, Get, List, Resource +from mongospecs_web.policy import view_of + +BACKENDS = ("msgspec", "pydantic", "attrs") + + +class Colour(enum.Enum): + RED = "red" + BLUE = "blue" + + +def models(backend): + if backend == "msgspec": + import msgspec + + from mongospecs.msgspec import Spec + + class Base(msgspec.Struct, kw_only=True): + pass + + define = lambda c: c # noqa: E731 + elif backend == "pydantic": + import pydantic + + from mongospecs.pydantic import Spec + + Base = pydantic.BaseModel + define = lambda c: c # noqa: E731 + else: + import attrs + + from mongospecs.attrs import Spec + + Base = object + define = attrs.define(kw_only=True) + + @define + class Geo(Base): + lat: float = 0.0 + surveyor: t.Annotated[str | None, Policy(private=True)] = None + + @define + class Addr(Base): + city: str = "" + vault_code: t.Annotated[str, Policy(private=True)] = "" + geo: Geo | None = None + + @define + class Maker(Spec): + __spec__ = SpecConfig(collection="makers") + name: str = "" + + @define + class Item(Spec): + __spec__ = SpecConfig(collection="items") + name: t.Annotated[str, Policy(filter=("eq", "prefix"), sort=True)] = "" + label: t.Annotated[str, Alias("l")] = "" + price: t.Annotated[float, Policy(sort=True)] = 0.0 + stock: int = 0 + active: bool = True + colour: Colour | None = None + tags: list[str] = [] # noqa: RUF012 + scores: dict[str, int] = {} # noqa: RUF012 + extra: dict[str, t.Any] = {} # noqa: RUF012 + address: Addr | None = None + history: list[Addr] = [] # noqa: RUF012 + maker: Ref[Maker] | None = None + makers: list[Ref[Maker]] = [] # noqa: RUF012 + secret: t.Annotated[str | None, Policy(private=True)] = None + blob: t.Annotated[str | None, Deferred] = None + created_at: t.Annotated[dt.datetime | None, CreatedAt] = None + seen_at: dt.datetime | None = None + deleted_at: t.Annotated[dt.datetime | None, DeletedAt, Policy(private=True)] = None + rev: t.Annotated[int, Revision] = 0 + + @define + class Shape(Spec): + __spec__ = SpecConfig(collection="shapes", discriminator="kind") + name: str = "" + + @define + class Circle(Shape): + radius: float = 1.0 + + @define + class Scored(Spec): + __spec__ = SpecConfig(collection="scored") + points: int = 0 + + @computed + def double(self) -> int: + return self.points * 2 + + return Geo, Addr, Maker, Item, Shape, Circle, Scored + + +@pytest.fixture(params=BACKENDS) +async def world(request, make_registry): + classes = models(request.param) + Geo, Addr, Maker, Item, Shape, Circle, Scored = classes + events = [] + await make_registry(Maker, Item, Shape, Circle, Scored, on_command=events.append) + + def api(raw): + resources = [ + Resource(Item, name="items", ops=[List, Get, Delete], include=["maker"]), + Resource(Shape, name="shapes", ops=[List, Get]), + Resource(Scored, name="scored", ops=[List, Get]), + ] + for resource in resources: + resource.raw_reads = raw + return Api(*resources, prefix="/v1") + + return classes, api(True), api(False), events + + +async def seed(Geo, Addr, Maker, Item, *_): + acme = await Maker(name="acme").insert() + other = await Maker(name="other").insert() + await Item( + name="full", + label="L", + price=9.5, + stock=3, + active=False, + colour=Colour.BLUE, + tags=["a", "b"], + scores={"x": 1}, + extra={"k": [1, "two", {"three": 3.5, "none": None}], "flag": True}, + address=Addr(city="here", vault_code="v", geo=Geo(lat=1.25, surveyor="s")), + history=[Addr(city="old"), Addr(city="older", geo=Geo(lat=2.0))], + maker=acme, + makers=[acme, other], + secret="hidden", + blob="big", + seen_at=dt.datetime(2024, 5, 6, 7, 8, 9, 123000, tzinfo=dt.UTC), + ).insert() + await Item(name="defaults").insert() + coll = Item.collection() + # shapes other writers produce: missing keys, an int stored as a double, a double as an int, + # extra keys, a non-JSON value inside an untyped field, a soft-deleted document + await coll.insert_one({"name": "sparse"}) + await coll.insert_one({"name": "odd numbers", "stock": 4.0, "price": 7, "rev": 2.0}) + await coll.insert_one( + { + "name": "extras", + "unknown": 1, + "address": {"city": "c", "zz": 1}, + "extra": {"when": dt.datetime(2021, 1, 1)}, + } + ) + await coll.insert_one({"name": "gone", "deleted_at": dt.datetime(2020, 1, 1)}) + return acme + + +async def call(api, method, url, headers=None): + from urllib.parse import parse_qsl, urlsplit + + parts = urlsplit(url) + return await api.handle(method, parts.path, query=parse_qsl(parts.query), headers=headers or {}) + + +async def both(fast, slow, method, path): + a, b = await call(fast, method, path), await call(slow, method, path) + assert a.status == b.status, (path, a.body, b.body) + if a.body != b.body: + # The only permitted difference is the opaque cursor: it is built from the last sort key as + # stored (an int 7) on one path and as the model holds it (a float 7.0) on the other. + # Both resume at the same place, which the pagination walk below checks. + one, two = json.loads(a.body), json.loads(b.body) + assert one["page"].pop("next_cursor") and two["page"].pop("next_cursor") + assert one == two, f"{path}\nraw: {a.body!r}\nmodel: {b.body!r}" + assert a.headers == b.headers + return a + + +async def test_raw_reads_equal_model_reads(world): + classes, fast, slow, events = world + Item = classes[3] + assert view_of(Item).fast is not None # this model is served without building models + await seed(*classes) + listing = await both(fast, slow, "GET", "/v1/items?sort=name&limit=50") + rows = json.loads(listing.body)["data"] + assert [r["name"] for r in rows] == ["defaults", "extras", "full", "odd numbers", "sparse"] # not "gone" + for row in rows: + await both(fast, slow, "GET", f"/v1/items/{row['id']}") + assert "secret" not in row and "blob" not in row and "deleted_at" not in row + assert "vault_code" not in json.dumps(row) and "surveyor" not in json.dumps(row) + full = next(r for r in rows if r["name"] == "full") + assert ( + full["label"] == "L" + and full["colour"] == "blue" + and full["address"] == {"city": "here", "geo": {"lat": 1.25}} + ) + for path in ( + "/v1/items?sort=-price&limit=2", + "/v1/items?name[prefix]=o", + "/v1/items?include=maker&sort=name", + f"/v1/items/{full['id']}?include=maker", + "/v1/items/000000000000000000000000", + "/v1/items/not-an-id", + ): + await both(fast, slow, "GET", path) + # cursor pagination walks the same pages on both paths, also across a sort key stored as another number type + for sort in ("name", "-price", "price"): + walks = [] + for api in (fast, slow): + cursor, seen = None, [] + while True: + url = f"/v1/items?sort={sort}&limit=2" + (f"&after={cursor}" if cursor else "") + page = json.loads((await call(api, "GET", url)).body) + seen += [r["name"] for r in page["data"]] + cursor = page["page"]["next_cursor"] + if not page["page"]["has_next"]: + break + walks.append(seen) + # two documents have no stored price: they are paged as well, first ascending, last descending + assert walks[0] == walks[1] and len(walks[0]) == len(set(walks[0])) == 5, (sort, walks) + + +async def test_raw_reads_fetch_only_what_is_shown(world): + classes, fast, slow, events = world + Item = classes[3] + await seed(*classes) + events.clear() + assert (await call(fast, "GET", "/v1/items?sort=name")).status == 200 + (find,) = [e.command for e in events if e.phase == "started" and e.command_name == "find"] + projection = find["projection"] + assert set(projection.values()) == {1} + for hidden in ("secret", "blob", "deleted_at"): + assert hidden not in projection # private and deferred fields never leave the database + assert {"name", "l", "price", "address", "rev"} <= set(projection) + assert find["filter"].get("deleted_at", "absent") is None # the soft-delete scope still applies + # an after-hook wants a model, so the resource reads models again + items = fast.resources[0] + seen = [] + items.after("list")(lambda ctx, result: seen.append(type(result.items[0]))) + events.clear() + assert (await call(fast, "GET", "/v1/items")).status == 200 + assert seen == [Item] + (find,) = [e.command for e in events if e.phase == "started" and e.command_name == "find"] + assert "name" not in (find.get("projection") or {}) + + +async def test_models_that_need_the_model_path_take_it(world): + classes, fast, slow, events = world + Geo, Addr, Maker, Item, Shape, Circle, Scored = classes + assert view_of(Shape).fast is None and view_of(Scored).fast is None # discriminator, computed field + await Circle(name="c", radius=2.0).insert() + await Shape(name="s").insert() + await Scored(points=4).insert() + shapes = json.loads((await both(fast, slow, "GET", "/v1/shapes")).body)["data"] + assert {s["kind"] for s in shapes} == {"Circle", "Shape"} and any("radius" in s for s in shapes) + scored = json.loads((await both(fast, slow, "GET", "/v1/scored")).body)["data"] + assert scored[0]["double"] == 8 + + +def test_value_changing_models_are_not_served_raw(): + import attrs + import msgspec + import pydantic + + from mongospecs.attrs import Spec as AttrsSpec + from mongospecs.msgspec import Spec as MsgspecSpec + from mongospecs.pydantic import Spec as PydanticSpec + + class Plain(PydanticSpec): + name: str = "" + + class Validated(PydanticSpec): + name: str = "" + + @pydantic.field_validator("name") + @classmethod + def tidy(cls, value): + return value.strip() + + class Stripped(PydanticSpec): + model_config = pydantic.ConfigDict(str_strip_whitespace=True) + name: str = "" + + class Serialised(PydanticSpec): + name: str = "" + + @pydantic.field_serializer("name") + def shout(self, value): + return value.upper() + + class Annotated(PydanticSpec): + name: t.Annotated[str, pydantic.BeforeValidator(str.strip)] = "" + + @attrs.define(kw_only=True) + class Converted(AttrsSpec): + name: str = attrs.field(default="", converter=str.strip) + + class Hooked(MsgspecSpec): + name: str = "" + + def __post_init__(self): + super().__post_init__() + self.name = self.name.strip() + + class Constrained(MsgspecSpec): + name: t.Annotated[str, msgspec.Meta(max_length=50)] = "" + when: dt.date | None = None # a type the plan does not know + + class Known(MsgspecSpec): + name: t.Annotated[str, msgspec.Meta(max_length=50)] = "" + + assert view_of(Plain).fast is not None and view_of(Known).fast is not None + for model in (Validated, Stripped, Serialised, Annotated, Converted, Hooked, Constrained): + assert view_of(model).fast is None, model.__name__ + + +async def test_etag_and_conditional_get_on_raw_reads(world): + classes, fast, slow, events = world + Item = classes[3] + item = await Item(name="e").insert() + await Item.update(item.id, stock=1) + got = await both(fast, slow, "GET", f"/v1/items/{item.id}") + assert got.headers["etag"] == '"1"' + for api in (fast, slow): + cached = await call(api, "GET", f"/v1/items/{item.id}", headers={"if-none-match": '"1"'}) + assert cached.status == 304 + assert await Item.count(Q.name == "e") == 1 + + +async def test_delete_is_one_write_unless_the_document_is_needed(world): + classes, fast, slow, events = world + Geo, Addr, Maker, Item, Shape, Circle, Scored = classes + item = await Item(name="d").insert() + events.clear() + assert (await call(fast, "DELETE", f"/v1/items/{item.id}")).status == 204 + assert [e.command_name for e in events if e.phase == "started"] == [ + "update" + ] # soft delete, no read first + assert await Item.count() == 0 and await Item.collection().count_documents({}) == 1 + assert (await call(fast, "DELETE", f"/v1/items/{item.id}")).status == 404 # already deleted + assert (await call(fast, "DELETE", "/v1/items/nope")).status == 404 + # with If-Match: a stale revision is 412 and the current one deletes + other = await Item(name="d2").insert() + await Item.update(other.id, stock=2) + assert (await call(fast, "DELETE", f"/v1/items/{other.id}", headers={"if-match": '"0"'})).status == 412 + assert (await call(fast, "DELETE", f"/v1/items/{other.id}", headers={"if-match": '"1"'})).status == 204 + # a model with a delete hook still gets its document + third = await Item(name="d3").insert() + seen = [] + + @Item.before("delete") + async def note(ctx, doc): + seen.append(doc.name) + + try: + events.clear() + assert (await call(fast, "DELETE", f"/v1/items/{third.id}")).status == 204 + assert seen == ["d3"] + assert [e.command_name for e in events if e.phase == "started"] == ["find", "update"] + finally: + from mongospecs.meta import _HOOKS + + _HOOKS.pop(Item, None) diff --git a/packages/mongospecs-web/tests/test_mongo_resource.py b/packages/mongospecs-web/tests/test_mongo_resource.py index 0c797bf..c61bb92 100644 --- a/packages/mongospecs-web/tests/test_mongo_resource.py +++ b/packages/mongospecs-web/tests/test_mongo_resource.py @@ -341,7 +341,10 @@ async def test_delete_is_conditional_and_scoped_at_the_write(app): finally: await observer.close() (write,) = [e.command for e in events if e.phase == "started" and e.command_name == "update"] - assert write["updates"][0]["q"] == {"_id": story.id, "rev": 1, "author": ann.id} + # the id, the revision, the row scope and the soft-delete scope, all in the one write + assert write["updates"][0]["q"] == {"_id": story.id, "rev": 1, "author": ann.id, "deleted_at": None} + sent = [e.command_name for e in events if e.phase == "started" and e.command_name != "endSessions"] + assert sent == ["update"] # one conditional write, no read before it assert (await client.get(f"/v1/my-stories/{story.id}", headers=as_ann)).status_code == 404 # soft-deleted diff --git a/packages/mongospecs/src/mongospecs/manager.py b/packages/mongospecs/src/mongospecs/manager.py index 5ee8b5b..7ddd366 100644 --- a/packages/mongospecs/src/mongospecs/manager.py +++ b/packages/mongospecs/src/mongospecs/manager.py @@ -426,6 +426,59 @@ async def run(s: t.Any) -> None: tracking.forget(doc) await self._hooks("after", ("delete",), ctx, doc) + def _needs_document_to_delete(self, hard: bool) -> bool: + if self.meta.hooks("before", "delete") or self.meta.hooks("after", "delete"): + return True + for plugin in self.plugins: + if type(plugin).before is not Plugin.before or type(plugin).after is not Plugin.after: + return True + return False + + async def delete_by_id( + self, + id: t.Any, + *, + condition: t.Mapping[str, t.Any] | None = None, + if_rev: int | None = None, + hard: bool = False, + session: t.Any = None, + ) -> bool: + """Delete one document by id in a single conditional write, without loading it. + + Returns ``False`` without doing anything when the delete needs the document: the + model has delete hooks, or it is a hard delete with cascades to run. Otherwise + deletes (softly when the model has a ``DeletedAt`` field, unless ``hard``) and + returns ``True``. Raises :class:`DocumentNotFound` when no document with this id + satisfies ``condition``, and :class:`StaleDocumentError` when ``if_rev`` is stale. + """ + if self._needs_document_to_delete(hard): + return False + ctx = self.ctx("delete", session) + base = and_filters(self.scoped(ctx, {"_id": id}, False), condition) + flt = dict(base) + if if_rev is not None: + ctx.extra["if_rev"] = if_rev + soft = None if hard else self._soft_delete(ctx, flt) + if soft is None and self._cascade_rules(): + return False + revision = next((f for f in self.meta.fields.values() if f.has(Revision)), None) + if revision is not None and if_rev is not None and revision.stored not in flt: + # a soft delete is an update, and the revision plugin has already added the condition + flt[revision.stored] = {"$in": [0, None]} if not if_rev else if_rev + with self._op("delete"): + if soft is not None: + matched = (await self.collection.update_one(flt, soft, **self._kw(session))).matched_count + else: + matched = (await self.collection.delete_one(flt, **self._kw(session))).deleted_count + if matched: + return True + if if_rev is not None: + with self._op("delete"): + exists = await self.collection.count_documents(base, limit=1, **self._kw(session)) + if exists: + raise StaleDocumentError(f"{self.model.__name__}: revision {if_rev} is no longer current") + raise DocumentNotFound(f"{self.model.__name__} {id!r} does not exist") + def _soft_delete(self, ctx: OpCtx, flt: dict[str, t.Any]) -> dict[str, t.Any] | None: for plugin in self.plugins: update = plugin.on_delete(ctx, flt) diff --git a/packages/mongospecs/src/mongospecs/query.py b/packages/mongospecs/src/mongospecs/query.py index 8c99fa2..90abc37 100644 --- a/packages/mongospecs/src/mongospecs/query.py +++ b/packages/mongospecs/src/mongospecs/query.py @@ -66,6 +66,7 @@ class Query(t.Generic[T]): "_fail", "_includes", "_invalid", + "_keys", "_limit", "_manager", "_mode", @@ -107,11 +108,29 @@ def __init__( self._options: dict[str, t.Any] = dict(options or {}) self._fail = fail self._invalid = invalid + self._keys: dict[int, t.Any] | None = None def _clone(self, **changes: t.Any) -> Query[T]: - state = {name.lstrip("_"): getattr(self, name) for name in self.__slots__ if name != "_manager"} - state.update(changes) - return type(self)(self._manager, **state) + # A query is cloned several times on every call path; copying the slots is much + # cheaper than going through __init__ with a dozen keyword arguments. + new: Query[T] = object.__new__(type(self)) + new._manager = self._manager + new._conditions = self._conditions + new._sort = self._sort + new._skip = self._skip + new._limit = self._limit + new._select = self._select + new._includes = self._includes + new._mode = self._mode + new._unscoped = self._unscoped + new._session = self._session + new._options = self._options + new._fail = self._fail + new._invalid = self._invalid + new._keys = None + for name, value in changes.items(): + setattr(new, "_" + name, dict(value) if name == "options" else value) + return new # -- builders -------------------------------------------------------------- @@ -362,6 +381,7 @@ async def _chunks(self, chunk: int | None = None) -> t.AsyncIterator[list[t.Any] options = manager.registry._codec_options invalid = self._invalid + stored_keys = self._keys async def flush(raws: list[t.Any]) -> list[t.Any]: misfits: list[tuple[int, t.Any]] = [] # (position, raw document) kept for on_invalid("raw") @@ -382,6 +402,8 @@ async def flush(raws: list[t.Any]) -> list[t.Any]: if invalid == "raw": misfits.append((position, plain)) continue + if stored_keys is not None: + stored_keys[id(doc)] = plain if data is not None: tracking.track(doc, data, manager, partial) elif mode == "tracked": @@ -561,7 +583,8 @@ async def page( Without ``number`` this is cursor (keyset) pagination: pass the previous page's ``next_cursor`` as ``after``. The sort is made total by appending ``_id``, so the index serving it should end in ``_id`` (``Index("price", "id")``); - otherwise the server sorts in memory. Sort fields should not be null. + otherwise the server sorts in memory. Documents without a value for a sort + field are paged too: they come first ascending and last descending. With ``number`` (1-based) it is offset pagination. ``total=True`` adds a count of all matches. """ @@ -587,6 +610,10 @@ async def page( query = query.filter(Trusted(_keyset_filter(sort, _decode_cursor(after, len(sort))))) + if number is None and query._mode != "raw": + # The cursor must hold the sort keys as they are stored. A model has its + # default where the stored document has no key, which sorts elsewhere. + query._keys = {} total_count: int | None if count_task is not None and self._session is None: items, total_count = await asyncio.gather(query.all(), count_task) @@ -597,7 +624,11 @@ async def page( items = items[:size] next_cursor = None if number is None and has_next and items: - next_cursor = _encode_cursor(self._sort_values(items[-1], query._sort_spec())) + last = items[-1] + stored = query._keys.get(id(last)) if query._keys is not None else None + next_cursor = _encode_cursor( + self._sort_values(last if stored is None else stored, query._sort_spec()) + ) return Page(items, size, has_next, next_cursor, number, total_count) def _sort_values(self, item: t.Any, sort: list[tuple[str, int]]) -> list[t.Any]: @@ -665,10 +696,20 @@ def _decode_cursor(token: str, expected: int) -> list[t.Any]: def _keyset_filter(sort: list[tuple[str, int]], values: list[t.Any]) -> dict[str, t.Any]: - """Documents strictly after ``values`` in ``sort`` order.""" + """Documents strictly after ``values`` in ``sort`` order. + + A missing or null sort key sorts before every value, so it comes first ascending and + last descending; ``{key: None}`` matches both the missing key and an explicit null. + """ branches: list[dict[str, t.Any]] = [] for i, (key, direction) in enumerate(sort): - branch: dict[str, t.Any] = {sort[j][0]: values[j] for j in range(i)} - branch[key] = {"$gt" if direction == 1 else "$lt": values[i]} - branches.append(branch) + prefix: dict[str, t.Any] = {sort[j][0]: values[j] for j in range(i)} + value = values[i] + if direction == 1: + # after "no value" comes every value; after a value come the larger ones + branches.append({**prefix, key: {"$ne": None} if value is None else {"$gt": value}}) + elif value is not None: + branches.append({**prefix, key: {"$lt": value}}) + branches.append({**prefix, key: None}) # documents without a value come last + # descending and already among the documents without a value: only ties remain return branches[0] if len(branches) == 1 else {"$or": branches} diff --git a/packages/mongospecs/src/mongospecs/relations.py b/packages/mongospecs/src/mongospecs/relations.py index 8ccd482..88a4ef9 100644 --- a/packages/mongospecs/src/mongospecs/relations.py +++ b/packages/mongospecs/src/mongospecs/relations.py @@ -14,6 +14,7 @@ from __future__ import annotations +import asyncio import dataclasses import typing as t from dataclasses import dataclass @@ -326,40 +327,47 @@ async def load_batched( """Resolve includes with one query per relation (per target model) for the whole batch.""" if not items: return + if session is None and len(includes) > 1: + # Independent relations: one round trip each, all in flight together. With a session + # they stay sequential, because a session cannot run two operations at once. + await asyncio.gather(*(_load_one(manager, items, include, mode, session) for include in includes)) + return for include in includes: - info = _relation(manager, include) - targets = manager.meta.ref_targets(include.name) - if info.backref is not None: - await _load_backref(manager, items, include, info, targets[0].model, mode, session) + await _load_one(manager, items, include, mode, session) + + +async def _load_one( + manager: Manager[t.Any], items: list[t.Any], include: Include, mode: str, session: t.Any +) -> None: + info = _relation(manager, include) + targets = manager.meta.ref_targets(include.name) + if info.backref is not None: + await _load_backref(manager, items, include, info, targets[0].model, mode, session) + return + assert info.ref is not None + ids: dict[t.Any, t.Any] = {} + for item in items: + value = item.get(info.stored) if isinstance(item, dict) else getattr(item, info.name) + if value is None: continue - assert info.ref is not None - ids: dict[t.Any, t.Any] = {} - for item in items: - value = item.get(info.stored) if isinstance(item, dict) else getattr(item, info.name) - if value is None: - continue - values = ( - value - if info.ref.shape == "list" - else value.values() - if info.ref.shape == "dict" - else (value,) - ) - for v in values: - if v is not None: - ids[_key(v)] = id_of(v) - by_id: dict[t.Any, t.Any] = {} - if ids: - for target_meta in targets: - target = _target(manager, target_meta.model) - children = await _child_query( - target, include, {"_id": {"$in": list(ids.values())}}, mode, session - ).all() - for child in children: - by_id[_key(_item_id(child))] = child - model = targets[0].model if len(targets) == 1 else None - for item in items: - _attach_forward(item, info, by_id, model, include.per_parent_limit) + values = ( + value if info.ref.shape == "list" else value.values() if info.ref.shape == "dict" else (value,) + ) + for v in values: + if v is not None: + ids[_key(v)] = id_of(v) + by_id: dict[t.Any, t.Any] = {} + if ids: + for target_meta in targets: + target = _target(manager, target_meta.model) + children = await _child_query( + target, include, {"_id": {"$in": list(ids.values())}}, mode, session + ).all() + for child in children: + by_id[_key(_item_id(child))] = child + model = targets[0].model if len(targets) == 1 else None + for item in items: + _attach_forward(item, info, by_id, model, include.per_parent_limit) async def _load_backref( @@ -389,9 +397,33 @@ async def _load_backref( for item in items: _attach_backref(item, info, counts.get(_key(_item_id(item)), 0)) return - children = await _child_query( - target, include, {fk: {"$in": parent_ids}}, mode, session, extra_select=(fk_attr,) - ).all() + wanted: dict[str, t.Any] = {fk: {"$in": parent_ids}} + fk_info = target.meta.fields.get(fk_attr) + single = fk_info is not None and fk_info.ref is not None and fk_info.ref.shape == "one" + if include.per_parent_limit and include.limit is None and single: + # Choose the children on the server: at most N ids per parent come back, and only + # those documents are then fetched. Fetching every child to keep N per parent would + # transfer the whole relation for a parent with many children. + pipeline = [{"$match": wanted}] + if include.match is not None: + pipeline.append({"$match": compile_filter(include.match, target.meta)}) + order = compile_sort(include.sort, target.meta) if include.sort else [("_id", 1)] + pipeline.append({"$sort": dict(order)}) + pipeline.append( + { + "$group": { + "_id": f"${fk}", + "ids": {"$firstN": {"input": "$_id", "n": include.per_parent_limit}}, + } + } + ) + chosen = [i async for row in target.aggregate(pipeline, session=session) for i in row["ids"]] + wanted = {"_id": {"$in": chosen}} + if not chosen: + for item in items: + _attach_backref(item, info, []) + return + children = await _child_query(target, include, wanted, mode, session, extra_select=(fk_attr,)).all() grouped: dict[t.Any, list[t.Any]] = {} for child in children: value = child.get(fk) if isinstance(child, dict) else getattr(child, fk_attr) diff --git a/packages/mongospecs/src/mongospecs/tracking.py b/packages/mongospecs/src/mongospecs/tracking.py index 938f6fb..05746f8 100644 --- a/packages/mongospecs/src/mongospecs/tracking.py +++ b/packages/mongospecs/src/mongospecs/tracking.py @@ -26,10 +26,14 @@ __all__ = ["DocState", "diff", "forget", "state_of", "track"] +def _nothing() -> None: + return None + + class DocState: """What mongospecs remembers about a tracked document.""" - __slots__ = ("_doc", "_raw", "manager", "partial") + __slots__ = ("_doc", "_raw", "_ref", "manager", "partial") def __init__( self, @@ -41,6 +45,7 @@ def __init__( self._doc: dict[str, t.Any] | None = None if isinstance(snapshot, bytes) else snapshot self.manager = manager self.partial = partial + self._ref: t.Any = _nothing # the weak reference to the tracked object, set by track() """Stored keys that were loaded, when the document was read with a projection.""" @property @@ -67,7 +72,40 @@ def snapshot(self) -> dict[str, t.Any]: def state_of(obj: t.Any) -> DocState | None: - return _states.get(id(obj)) + state = _states.get(id(obj)) + if state is not None and state._ref() is not obj: + return None # the entry belongs to an object that is being collected; its id was reused + return state + + +class _Watch(weakref.ref): # type: ignore[type-arg] + """A weak reference that remembers which entry of the side table it guards. + + One plain weak reference per tracked document is several times cheaper than a + ``weakref.finalize`` (which also registers itself in a global table). + """ + + __slots__ = ("key",) + key: int + + +def _gone(watch: _Watch) -> None: + # ids are reused: only drop the entry if it still belongs to the object that died + state = _states.get(watch.key) + if state is not None and state._ref is watch: + del _states[watch.key] + + +def _install(obj: t.Any, state: DocState) -> None: + key = id(obj) + current = _states.get(key) + if current is not None and current._ref() is obj: + state._ref = current._ref + else: + watch = _Watch(obj, _gone) + watch.key = key + state._ref = watch + _states[key] = state def track( @@ -76,23 +114,17 @@ def track( manager: t.Any, partial: frozenset[str] | None = None, ) -> DocState: - key = id(obj) state = DocState(snapshot, manager, partial) - if key not in _states: - weakref.finalize(obj, _states.pop, key, None) - _states[key] = state + _install(obj, state) return state def restore(obj: t.Any, state: DocState | None) -> None: """Put back a previous state (used when a transaction aborts).""" - key = id(obj) if state is None: - _states.pop(key, None) + _states.pop(id(obj), None) else: - if key not in _states: - weakref.finalize(obj, _states.pop, key, None) - _states[key] = state + _install(obj, state) def forget(obj: t.Any) -> None: diff --git a/packages/mongospecs/tests/test_review_round2.py b/packages/mongospecs/tests/test_review_round2.py index e156461..4f08558 100644 --- a/packages/mongospecs/tests/test_review_round2.py +++ b/packages/mongospecs/tests/test_review_round2.py @@ -194,3 +194,30 @@ async def test_upsert_revision_is_documented_behaviour(m, registry): def test_unused_names_keep_imports_honest(): assert Index and SpecConfig and DocumentNotFound and MongoSpecsError and t + + +@pytest.mark.parametrize("mode", ["tracked", "lean", "raw"]) +async def test_cursor_pages_include_documents_without_the_sort_key(m, registry, mode): + """A missing or null sort key used to end the walk early or skip documents.""" + await m.User.insert_many( + [m.User(name=f"u{i}", email=f"{i}@x.io" if i % 2 else None, age=i) for i in range(6)] + ) + # no stored age at all: the model reads its default (0), the server sorts it as "no value" + await m.User.collection().insert_many([{"name": "no-age-1"}, {"name": "no-age-2"}]) + for sort, first, last in ( + ("email", "no-age-1", "u5"), + ("-email", "u5", "u4"), + ("age", "no-age-1", "u5"), + ("-age", "u5", "no-age-2"), + ): + query = getattr(m.User.find().sort(sort, "name"), mode)() + seen, cursor = [], None + while True: + page = await query.page(3, after=cursor) + seen += [u["name"] if isinstance(u, dict) else u.name for u in page] + assert len(seen) <= 12, f"{sort}: pagination does not terminate: {seen}" + if not page.has_next: + break + cursor = page.next_cursor + assert len(seen) == len(set(seen)) == 8, (sort, seen) + assert (seen[0], seen[-1]) == (first, last), (sort, seen) From 3ee709b1cc3a9dc6f77294d6053f88ac74d66db8 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 07:00:32 -0400 Subject: [PATCH 20/31] feat(web): Api(max_concurrency=...) and a 503 for pool timeouts Requests beyond the limit are answered at once with 503 and Retry-After instead of queueing for database connections. test: correctness of the optimised paths (tracking, clone, includes, per-parent limits, routing) Co-Authored-By: Claude Opus 5.5 --- .../mongospecs-web/src/mongospecs_web/api.py | 21 +- .../src/mongospecs_web/errors.py | 7 + .../tests/test_review_round2.py | 92 ++++++++ packages/mongospecs/tests/test_hot_paths.py | 208 ++++++++++++++++++ 4 files changed, 327 insertions(+), 1 deletion(-) create mode 100644 packages/mongospecs/tests/test_hot_paths.py diff --git a/packages/mongospecs-web/src/mongospecs_web/api.py b/packages/mongospecs-web/src/mongospecs_web/api.py index 69aece3..3aaf23a 100644 --- a/packages/mongospecs-web/src/mongospecs_web/api.py +++ b/packages/mongospecs-web/src/mongospecs_web/api.py @@ -53,6 +53,7 @@ def __init__( authenticate: Authenticate | None = None, max_body: int = 1_048_576, openapi: bool = True, + max_concurrency: int | None = None, ) -> None: self.resources: tuple[Resource[t.Any], ...] = resources self.prefix = "/" + prefix.strip("/") if prefix.strip("/") else "" @@ -60,6 +61,11 @@ def __init__( self.version = version self.authenticate = authenticate self.max_body = max_body + self.max_concurrency = max_concurrency + """At most this many requests are worked on at once; one more is answered at once with + 503 and ``Retry-After`` instead of waiting. Without a limit, overload turns into a + growing queue for database connections, and every request gets slower together.""" + self._in_flight = 0 self.expose_openapi = openapi """Serve ``/openapi.json``. Turn it off to keep the API description private; ``api.openapi()`` still builds it for you to serve behind your own authentication.""" @@ -183,7 +189,20 @@ async def run(self, route: Route, request: Request) -> Response: if response.status >= 500: logging.getLogger("mongospecs_web").error("authentication failed unexpectedly", exc_info=exc) return response - response = await route.resource.dispatch(route.operation, request) + if self.max_concurrency is not None and self._in_flight >= self.max_concurrency: + return Response.error( + HTTPError( + "The server is handling as many requests as it accepts. Try again shortly.", + status=503, + title="Service Unavailable", + headers={"retry-after": "1"}, + ) + ) + self._in_flight += 1 + try: + response = await route.resource.dispatch(route.operation, request) + finally: + self._in_flight -= 1 location = response.headers.get("location") if location and not location.startswith(("/", "http://", "https://")): # A resource names what it created by id; the URL of that thing depends on where diff --git a/packages/mongospecs-web/src/mongospecs_web/errors.py b/packages/mongospecs-web/src/mongospecs_web/errors.py index 9abb1cf..25c99da 100644 --- a/packages/mongospecs-web/src/mongospecs_web/errors.py +++ b/packages/mongospecs-web/src/mongospecs_web/errors.py @@ -121,6 +121,13 @@ def problem(exc: BaseException) -> HTTPError: return BadRequest(str(exc)) if isinstance(exc, me.OperationTimeout): return HTTPError("The database did not answer in time.", status=504, title="Gateway Timeout") + if isinstance(exc, me.PoolTimeout): + return HTTPError( + "The server is busy: no database connection became free in time.", + status=503, + title="Service Unavailable", + headers={"retry-after": "1"}, + ) if isinstance(exc, me.ConnectionFailed): return HTTPError("The database is unavailable.", status=503, title="Service Unavailable") return HTTPError() diff --git a/packages/mongospecs-web/tests/test_review_round2.py b/packages/mongospecs-web/tests/test_review_round2.py index e38af9b..1d8d54f 100644 --- a/packages/mongospecs-web/tests/test_review_round2.py +++ b/packages/mongospecs-web/tests/test_review_round2.py @@ -157,3 +157,95 @@ async def test_head_reports_the_length_of_the_get_body(): assert head.status_code == 200 and head.content == b"" assert head.headers["content-length"] == str(len(get.content)) != "0" assert head.headers["content-type"] == get.headers["content-type"] + + +# -- routing by resource, and refusing work beyond a limit ---------------------------------------- + + +async def test_routes_are_found_by_resource_and_keep_their_precedence(): + from mongospecs_web import Create, Patch + + base = contract_api("msgspec") + model, backend = base.resources[0].model, base.resources[0].backend + plain = Resource(model, name="things", ops=[List, Get, Create, Patch, Delete], backend=backend) + nested = Resource(model, name="shop/things", ops=[List, Get], backend=backend) + api = Api( + plain, + nested, + *[Resource(model, name=f"r{i}", ops=[List, Get], backend=backend) for i in range(40)], + prefix="/api", + ) + + @plain.op("search", method="GET", path="/search") + async def search(ctx): + return {"found": "search"} + + def target(method, path): + route, params = api.match(method, path) + return route.resource.name, route.operation.name, params + + assert target("GET", "/api/things") == ("things", "list", {}) + assert target("GET", "/api/things/") == ("things", "list", {}) + assert target("GET", "/api/things/search") == ("things", "search", {}) # a literal wins over /{id} + assert target("GET", "/api/things/abc") == ("things", "get", {"id": "abc"}) + assert target("GET", "/api/shop/things/abc") == ("shop/things", "get", {"id": "abc"}) + assert target("GET", "/api/r39/x") == ("r39", "get", {"id": "x"}) + for method, path, status in ( + ("PUT", "/api/things/abc", 405), + ("GET", "/api/nothing", 404), + ("GET", "/other/things", 404), + ("GET", "/api", 404), + ("GET", "/api/things/a/b/c", 404), + ): + assert (await api.handle(method, path)).status == status, (method, path) + assert (await api.handle("PUT", "/api/things/abc")).headers["allow"] == "DELETE, GET, PATCH" + + # an operation added after requests have been served is routable + @plain.op("archive", method="POST", path="/{id}/archive") + async def archive(ctx): + return {"archived": ctx.id} + + assert target("POST", "/api/things/abc/archive") == ("things", "archive", {"id": "abc"}) + assert (await api.handle("OPTIONS", "/api/things/abc/archive")).headers["allow"] == "OPTIONS, POST" + + +async def test_requests_beyond_max_concurrency_are_refused_at_once(): + import asyncio + + base = contract_api("msgspec") + model, backend = base.resources[0].model, base.resources[0].backend + resource = Resource(model, name="things", ops=[List], backend=backend) + release, entered = asyncio.Event(), asyncio.Event() + + @resource.op("slow", method="GET", path="/slow") + async def slow(ctx): + entered.set() + await release.wait() + return {"ok": True} + + api = Api(resource, prefix="/api", max_concurrency=2) + held = [asyncio.create_task(api.handle("GET", "/api/things/slow")) for _ in range(2)] + await entered.wait() + await asyncio.sleep(0) + refused = await api.handle("GET", "/api/things") + assert refused.status == 503 and refused.headers["retry-after"] == "1" + release.set() + assert [r.status for r in await asyncio.gather(*held)] == [200, 200] + assert (await api.handle("GET", "/api/things")).status == 200 # capacity is back + # a failing handler gives its slot back too + crash = Resource(model, name="boom", ops=[], backend=backend) + + @crash.op("fail", method="GET", path="") + async def fail(ctx): + raise RuntimeError("boom") + + small = Api(crash, prefix="/api", max_concurrency=1) + assert [(await small.handle("GET", "/api/boom")).status for _ in range(3)] == [500, 500, 500] + + +def test_a_pool_timeout_is_a_503_with_retry_after(): + from mongospecs import PoolTimeout + from mongospecs_web.errors import problem + + error = problem(PoolTimeout("no connection")) + assert error.status == 503 and error.headers["retry-after"] == "1" diff --git a/packages/mongospecs/tests/test_hot_paths.py b/packages/mongospecs/tests/test_hot_paths.py new file mode 100644 index 0000000..987975a --- /dev/null +++ b/packages/mongospecs/tests/test_hot_paths.py @@ -0,0 +1,208 @@ +"""Correctness of the paths that were made cheaper: the result must not have changed.""" + +import asyncio +import gc +import typing as t + +import pytest + +from mongospecs import Backref, Q, Ref, SpecConfig, tracking + + +def started(events, name=None): + return [e for e in events if e.phase == "started" and (name is None or e.command_name == name)] + + +# -- tracking: one weak reference per document ------------------------------------------------- + + +async def test_tracking_state_goes_away_with_the_document(m, registry): + await m.User.insert_many([m.User(name=f"u{i}") for i in range(50)]) + gc.collect() + before = tracking.tracked_count() + docs = await m.User.find().all() + assert tracking.tracked_count() == before + 50 + first = docs[0] + del docs + gc.collect() + assert tracking.tracked_count() == before + 1 and tracking.state_of(first) is not None + # re-tracking the same object (save, reload) keeps exactly one entry and stays collectable + first.age = 3 + await first.save() + await first.reload() + assert tracking.tracked_count() == before + 1 and first.changes() == {} + del first + gc.collect() + assert tracking.tracked_count() == before + + +async def test_a_reused_id_never_inherits_another_documents_state(m, registry): + await m.User(name="a").insert() + for _ in range(200): # ids of freed objects are reused quickly; a fresh object must be untracked + loaded = await m.User.find().one() + del loaded + fresh = m.User(name="fresh") + assert tracking.state_of(fresh) is None and fresh.is_new + + +async def test_transaction_rollback_restores_tracking(m, registry): + user = await m.User(name="a").insert() + + async def change(tx): + user.name = "b" + await user.save(session=tx) + raise RuntimeError("abort") + + with pytest.raises(RuntimeError): + await registry.transaction(change) + assert user.changes() in ({}, {"$set": {"name": "b"}}) + assert (await m.User.get(user.id)).name == "a" + gc.collect() + assert tracking.state_of(user) is not None + + +# -- Query._clone ------------------------------------------------------------------------------ + + +def test_clone_keeps_every_setting_and_shares_nothing_mutable(m, registry): + base = ( + m.User.find(Q.age > 1) + .sort("-age") + .skip(2) + .limit(5) + .select("name") + .lean() + .options(max_time_ms=50) + .or_fail() + .on_invalid("skip") + ) + clone = base.limit(9) + for slot in type(base).__slots__: + if slot in ("_limit", "_keys"): + continue + assert getattr(clone, slot) == getattr(base, slot), slot + assert (base._limit, clone._limit) == (5, 9) + changed = base.options(max_time_ms=99) + assert base._options["max_time_ms"] == 50 and changed._options["max_time_ms"] == 99 + assert changed._options is not base._options + assert type(clone) is type(base) + + +# -- includes ---------------------------------------------------------------------------------- + + +@pytest.fixture +async def library(m, make_registry): + @m.define + class Writer(m.Spec): + __spec__ = SpecConfig(collection="hp_writers") + name: str = "" + books: t.Annotated[list[t.Any], Backref("writer", model="Book")] = [] # noqa: RUF012 + + @m.define + class Shelf(m.Spec): + __spec__ = SpecConfig(collection="hp_shelves") + name: str = "" + + @m.define + class Book(m.Spec): + __spec__ = SpecConfig(collection="hp_books") + title: str = "" + pages: int = 0 + writer: Ref[Writer] | None = None + shelf: Ref[Shelf] | None = None + co_writers: list[Ref[Writer]] = [] # noqa: RUF012 + + events = [] + registry = await make_registry(Writer, Shelf, Book, on_command=events.append) + writers = (await Writer.insert_many([Writer(name=f"w{i}") for i in range(4)])).inserted + shelves = (await Shelf.insert_many([Shelf(name=f"s{i}") for i in range(2)])).inserted + await Book.insert_many( + [ + Book(title=f"b{w}-{i:02d}", pages=(i * 7) % 11, writer=writers[w], shelf=shelves[i % 2]) + for w in range(3) # w3 has no books + for i in range(12) + ] + ) + return registry, events, Writer, Shelf, Book + + +async def test_independent_includes_are_in_flight_together(library): + registry, events, Writer, Shelf, Book = library + # open a few pooled connections first: a second connection being set up would hide the overlap + await asyncio.gather(*(Book.count() for _ in range(6))) + events.clear() + books = await Book.find().sort("title").include("writer").include("shelf").all() + assert len(books) == 36 and all(b.writer.loaded and b.shelf.loaded for b in books) + assert books[0].writer.doc.name == "w0" and {b.shelf.doc.name for b in books} == {"s0", "s1"} + finds = [e for e in events if e.command_name == "find"] + order = [(e.phase, e.command["find"] if e.phase == "started" else None) for e in finds] + relation_starts = [i for i, (phase, coll) in enumerate(order) if coll in ("hp_writers", "hp_shelves")] + first_done_after = next( + i for i, (phase, _) in enumerate(order) if phase != "started" and i > relation_starts[0] + ) + assert ( + len(relation_starts) == 2 and relation_starts[1] < first_done_after + ) # both sent before either returned + assert len(started(events)) == 3 # still one command per relation + + # inside a transaction they run one after another: a session cannot do two things at once + async def in_tx(tx): + return await Book.find(session=tx).include("writer").include("shelf").all() + + assert len(await registry.transaction(in_tx)) == 36 + + +@pytest.mark.parametrize("mode", ["tracked", "lean", "raw"]) +async def test_per_parent_limit_is_applied_by_the_server(library, mode): + registry, events, Writer, Shelf, Book = library + expected = {} + for writer in await Writer.find().all(): + mine = await Book.find(Q.writer == writer.id, Q.pages >= 2).sort("-pages", "title").all() + expected[writer.name] = [b.title for b in mine[:3]] + events.clear() + query = getattr(Writer.find().sort("name"), mode)() + writers = await query.include( + "books", per_parent_limit=3, match=Q.pages >= 2, sort=["-pages", "title"] + ).all() + + def books_of(w): + return w["books"] if isinstance(w, dict) else w.books + + def name_of(w): + return w["name"] if isinstance(w, dict) else w.name + + def title_of(b): + return b["title"] if isinstance(b, dict) else b.title + + got = {name_of(w): [title_of(b) for b in books_of(w)] for w in writers} + assert got == expected and got["w3"] == [] and all(len(v) == 3 for k, v in got.items() if k != "w3") + # the children are chosen on the server: only the kept ones are fetched + (pick,) = started(events, "aggregate") + assert any("$firstN" in str(stage) for stage in pick.command["pipeline"]) + fetch = [e for e in started(events, "find") if e.command["find"] == "hp_books"] + assert len(fetch) == 1 and len(fetch[0].command["filter"]["_id"]["$in"]) == 9 + + # without a sort the per-parent cap still holds + capped = await Writer.find().include("books", per_parent_limit=2).all() + assert sorted(len(w.books) for w in capped) == [0, 2, 2, 2] + # an overall limit keeps the previous behaviour (limit first, then the cap per parent) + overall = await Writer.find().include("books", per_parent_limit=2, limit=5, sort=["title"]).all() + assert sum(len(w.books) for w in overall) <= 5 + + +async def test_per_parent_limit_on_a_list_reference_still_works(library): + registry, events, Writer, Shelf, Book = library + + @Writer.scope + def named(query): # an unrelated named scope must not disturb relation loading + return query.filter(Q.name != "") + + del named + books = await Book.find().limit(2).all() + writers = await Writer.find().sort("name").all() + books[0].co_writers = [writers[0], writers[1]] + await books[0].save() + loaded = await Book.find(Q.id == books[0].id).include("co_writers", per_parent_limit=1).one() + assert len(loaded.co_writers) == 2 and sum(r.loaded for r in loaded.co_writers) >= 1 + assert asyncio.get_running_loop() is not None From fb543de1b1a0b8af560ce5c77578ea435d5d5cd4 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 07:01:58 -0400 Subject: [PATCH 21/31] bench: B9 also measures serialisation alone, through the model and from the stored document Co-Authored-By: Claude Opus 5.5 --- bench/results/3ee709b/B9.json | 279 +++++++++++++++++++++++++ bench/src/mongospecs_bench/hotpaths.py | 24 +++ 2 files changed, 303 insertions(+) create mode 100644 bench/results/3ee709b/B9.json diff --git a/bench/results/3ee709b/B9.json b/bench/results/3ee709b/B9.json new file mode 100644 index 0000000..8c606b5 --- /dev/null +++ b/bench/results/3ee709b/B9.json @@ -0,0 +1,279 @@ +{ + "meta": { + "git_sha": "3ee709b", + "git_dirty": false, + "timestamp": "2026-10-13T11:00:33+00:00", + "topology": "rs", + "quick": false, + "mongodb": "8.0.32", + "python": "3.13.5", + "free_threaded": false, + "platform": "macOS-15.6.1-arm64-arm-64bit-Mach-O", + "machine": "arm64", + "cpus": 8, + "versions": { + "mongospecs": "0.2.0", + "pymongo": "4.18.3", + "msgspec": "0.22.0", + "pydantic": "2.14.1" + } + }, + "workload": "B9", + "title": "Hot paths: CPU per call and commands per operation", + "question": "Where does the library spend CPU on the paths every request takes, and how many commands does each operation send?", + "rows": [ + { + "backend": "msgspec", + "path": "web: GET list of 100", + "unit": "cpu us/doc", + "value": 9.68 + }, + { + "backend": "msgspec", + "path": "web: GET one", + "unit": "cpu us/call", + "value": 289.4 + }, + { + "backend": "msgspec", + "path": "Model.get(id)", + "unit": "cpu us/call", + "value": 211.0 + }, + { + "backend": "msgspec", + "path": "find().all(), 100 tracked", + "unit": "cpu us/doc", + "value": 7.91 + }, + { + "backend": "msgspec", + "path": "find().lean().all(), 100", + "unit": "cpu us/doc", + "value": 6.87 + }, + { + "backend": "msgspec", + "path": "include x2, 100 parents", + "unit": "cpu us/call", + "value": 1946.7 + }, + { + "backend": "msgspec", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "cpu us/call", + "value": 1872.0 + }, + { + "backend": "msgspec", + "path": "web: GET one", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "msgspec", + "path": "web: GET list of 100", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "msgspec", + "path": "include x2, 100 parents", + "unit": "commands", + "value": 3.0, + "detail": "find find find" + }, + { + "backend": "msgspec", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "commands", + "value": 3.0, + "detail": "find aggregate find" + }, + { + "backend": "msgspec", + "path": "web: DELETE (soft)", + "unit": "commands", + "value": 1.0, + "detail": "update" + }, + { + "backend": "-", + "path": "route match, last of 29 routes", + "unit": "cpu us/call", + "value": 2.76 + }, + { + "backend": "-", + "path": "route match, last of 255 routes", + "unit": "cpu us/call", + "value": 2.34 + }, + { + "backend": "-", + "path": "route match, last of 1005 routes", + "unit": "cpu us/call", + "value": 13.13 + }, + { + "backend": "pydantic", + "path": "web: GET list of 100", + "unit": "cpu us/doc", + "value": 12.24 + }, + { + "backend": "pydantic", + "path": "web: GET one", + "unit": "cpu us/call", + "value": 276.2 + }, + { + "backend": "pydantic", + "path": "Model.get(id)", + "unit": "cpu us/call", + "value": 211.6 + }, + { + "backend": "pydantic", + "path": "find().all(), 100 tracked", + "unit": "cpu us/doc", + "value": 9.68 + }, + { + "backend": "pydantic", + "path": "find().lean().all(), 100", + "unit": "cpu us/doc", + "value": 8.17 + }, + { + "backend": "pydantic", + "path": "include x2, 100 parents", + "unit": "cpu us/call", + "value": 1968.4 + }, + { + "backend": "pydantic", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "cpu us/call", + "value": 1600.4 + }, + { + "backend": "pydantic", + "path": "web: GET one", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "pydantic", + "path": "web: GET list of 100", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "pydantic", + "path": "include x2, 100 parents", + "unit": "commands", + "value": 3.0, + "detail": "find find find" + }, + { + "backend": "pydantic", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "commands", + "value": 3.0, + "detail": "find aggregate find" + }, + { + "backend": "pydantic", + "path": "web: DELETE (soft)", + "unit": "commands", + "value": 1.0, + "detail": "update" + }, + { + "backend": "attrs", + "path": "web: GET list of 100", + "unit": "cpu us/doc", + "value": 9.1 + }, + { + "backend": "attrs", + "path": "web: GET one", + "unit": "cpu us/call", + "value": 258.3 + }, + { + "backend": "attrs", + "path": "Model.get(id)", + "unit": "cpu us/call", + "value": 225.8 + }, + { + "backend": "attrs", + "path": "find().all(), 100 tracked", + "unit": "cpu us/doc", + "value": 10.05 + }, + { + "backend": "attrs", + "path": "find().lean().all(), 100", + "unit": "cpu us/doc", + "value": 10.36 + }, + { + "backend": "attrs", + "path": "include x2, 100 parents", + "unit": "cpu us/call", + "value": 2281.6 + }, + { + "backend": "attrs", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "cpu us/call", + "value": 2019.9 + }, + { + "backend": "attrs", + "path": "web: GET one", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "attrs", + "path": "web: GET list of 100", + "unit": "commands", + "value": 1.0, + "detail": "find" + }, + { + "backend": "attrs", + "path": "include x2, 100 parents", + "unit": "commands", + "value": 3.0, + "detail": "find find find" + }, + { + "backend": "attrs", + "path": "include reviews, per_parent_limit=2 of 100", + "unit": "commands", + "value": 3.0, + "detail": "find aggregate find" + }, + { + "backend": "attrs", + "path": "web: DELETE (soft)", + "unit": "commands", + "value": 1.0, + "detail": "update" + } + ], + "notes": [ + "CPU time of this process (best of several batches), so the database's own work and network waits are excluded. Rows with unit 'commands' are driver commands sent, listed in 'detail'." + ], + "conclusion": "" +} diff --git a/bench/src/mongospecs_bench/hotpaths.py b/bench/src/mongospecs_bench/hotpaths.py index 095e3d3..9f98f7f 100644 --- a/bench/src/mongospecs_bench/hotpaths.py +++ b/bench/src/mongospecs_bench/hotpaths.py @@ -135,6 +135,30 @@ def add(path: str, unit: str, value: float, per: int = 1) -> None: await cpu_us(per_parent, calls // 4, repeats), ) + # The same 100 stored documents turned into the response rows, with the database out + # of the picture: through the model, and straight from the stored document. + view = api.resources[0].view + meta = view.meta + stored = [d async for d in m.Product.collection().find({}, limit=100)] + stored = [dict(d) for d in stored] + plan = getattr(view, "fast", None) + for label, fn in ( + ("serialise 100: decode + model", lambda: [view.serialize(meta.decode(d)) for d in stored]), + ( + "serialise 100: stored document", + (lambda: [plan.serialize(d) for d in stored]) if plan else None, + ), + ): + if fn is None: + continue + best = float("inf") + for _ in range(repeats * 4): + start = time.process_time() + for _ in range(20): + fn() + best = min(best, (time.process_time() - start) / 20) + add(label, "cpu us/doc", best * 1e6, 100) + def count(name: str, *paths: str) -> None: add(name, "commands", float(len(events.names))) result.rows[-1]["detail"] = " ".join(events.names) From 9775738ad0231cc414648e85e5430b4a4f80319e Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 07:14:24 -0400 Subject: [PATCH 22/31] docs,bench: production tuning guide, B9 before/after, review decisions - docs/guide/production.md: pool and timeout settings, backpressure, read and pagination choices, relations, writes, what needs a replica set; each item marked measured, tested or guidance - docs/benchmarks.md: hot paths before and after the review - bench/results: B9 before (49a32a9) and after (fb543de) - plan: decision per review finding, amendment A36 Co-Authored-By: Claude Opus 5.5 --- bench/results/{3ee709b => fb543de}/B9.json | 90 +++++++++---- claudedocs/v2-plan.md | 26 ++++ docs/benchmarks.md | 34 +++++ docs/guide/production.md | 136 ++++++++++++++++++++ docs/web/resources.md | 8 ++ mkdocs.yaml | 1 + packages/mongospecs/tests/test_hot_paths.py | 17 +++ 7 files changed, 285 insertions(+), 27 deletions(-) rename bench/results/{3ee709b => fb543de}/B9.json (80%) create mode 100644 docs/guide/production.md diff --git a/bench/results/3ee709b/B9.json b/bench/results/fb543de/B9.json similarity index 80% rename from bench/results/3ee709b/B9.json rename to bench/results/fb543de/B9.json index 8c606b5..e35678b 100644 --- a/bench/results/3ee709b/B9.json +++ b/bench/results/fb543de/B9.json @@ -1,8 +1,8 @@ { "meta": { - "git_sha": "3ee709b", - "git_dirty": false, - "timestamp": "2026-10-13T11:00:33+00:00", + "git_sha": "fb543de", + "git_dirty": true, + "timestamp": "2026-10-13T11:01:58+00:00", "topology": "rs", "quick": false, "mongodb": "8.0.32", @@ -26,43 +26,55 @@ "backend": "msgspec", "path": "web: GET list of 100", "unit": "cpu us/doc", - "value": 9.68 + "value": 10.14 }, { "backend": "msgspec", "path": "web: GET one", "unit": "cpu us/call", - "value": 289.4 + "value": 258.8 }, { "backend": "msgspec", "path": "Model.get(id)", "unit": "cpu us/call", - "value": 211.0 + "value": 217.6 }, { "backend": "msgspec", "path": "find().all(), 100 tracked", "unit": "cpu us/doc", - "value": 7.91 + "value": 9.77 }, { "backend": "msgspec", "path": "find().lean().all(), 100", "unit": "cpu us/doc", - "value": 6.87 + "value": 7.78 }, { "backend": "msgspec", "path": "include x2, 100 parents", "unit": "cpu us/call", - "value": 1946.7 + "value": 2780.9 }, { "backend": "msgspec", "path": "include reviews, per_parent_limit=2 of 100", "unit": "cpu us/call", - "value": 1872.0 + "value": 1468.7 + }, + { + "backend": "msgspec", + "path": "serialise 100: decode + model", + "unit": "cpu us/doc", + "value": 4.34 + }, + { + "backend": "msgspec", + "path": "serialise 100: stored document", + "unit": "cpu us/doc", + "value": 2.5 }, { "backend": "msgspec", @@ -103,61 +115,73 @@ "backend": "-", "path": "route match, last of 29 routes", "unit": "cpu us/call", - "value": 2.76 + "value": 1.01 }, { "backend": "-", "path": "route match, last of 255 routes", "unit": "cpu us/call", - "value": 2.34 + "value": 1.3 }, { "backend": "-", "path": "route match, last of 1005 routes", "unit": "cpu us/call", - "value": 13.13 + "value": 15.36 }, { "backend": "pydantic", "path": "web: GET list of 100", "unit": "cpu us/doc", - "value": 12.24 + "value": 10.57 }, { "backend": "pydantic", "path": "web: GET one", "unit": "cpu us/call", - "value": 276.2 + "value": 265.7 }, { "backend": "pydantic", "path": "Model.get(id)", "unit": "cpu us/call", - "value": 211.6 + "value": 226.5 }, { "backend": "pydantic", "path": "find().all(), 100 tracked", "unit": "cpu us/doc", - "value": 9.68 + "value": 12.17 }, { "backend": "pydantic", "path": "find().lean().all(), 100", "unit": "cpu us/doc", - "value": 8.17 + "value": 8.54 }, { "backend": "pydantic", "path": "include x2, 100 parents", "unit": "cpu us/call", - "value": 1968.4 + "value": 3018.1 }, { "backend": "pydantic", "path": "include reviews, per_parent_limit=2 of 100", "unit": "cpu us/call", - "value": 1600.4 + "value": 1341.4 + }, + { + "backend": "pydantic", + "path": "serialise 100: decode + model", + "unit": "cpu us/doc", + "value": 6.74 + }, + { + "backend": "pydantic", + "path": "serialise 100: stored document", + "unit": "cpu us/doc", + "value": 2.89 }, { "backend": "pydantic", @@ -198,43 +222,55 @@ "backend": "attrs", "path": "web: GET list of 100", "unit": "cpu us/doc", - "value": 9.1 + "value": 11.16 }, { "backend": "attrs", "path": "web: GET one", "unit": "cpu us/call", - "value": 258.3 + "value": 256.4 }, { "backend": "attrs", "path": "Model.get(id)", "unit": "cpu us/call", - "value": 225.8 + "value": 301.0 }, { "backend": "attrs", "path": "find().all(), 100 tracked", "unit": "cpu us/doc", - "value": 10.05 + "value": 12.48 }, { "backend": "attrs", "path": "find().lean().all(), 100", "unit": "cpu us/doc", - "value": 10.36 + "value": 9.74 }, { "backend": "attrs", "path": "include x2, 100 parents", "unit": "cpu us/call", - "value": 2281.6 + "value": 2331.4 }, { "backend": "attrs", "path": "include reviews, per_parent_limit=2 of 100", "unit": "cpu us/call", - "value": 2019.9 + "value": 1667.0 + }, + { + "backend": "attrs", + "path": "serialise 100: decode + model", + "unit": "cpu us/doc", + "value": 6.43 + }, + { + "backend": "attrs", + "path": "serialise 100: stored document", + "unit": "cpu us/doc", + "value": 2.46 }, { "backend": "attrs", diff --git a/claudedocs/v2-plan.md b/claudedocs/v2-plan.md index 0a3207f..f966e02 100644 --- a/claudedocs/v2-plan.md +++ b/claudedocs/v2-plan.md @@ -887,6 +887,7 @@ Recorded per §7.1 rule 4. Each one names what changed and why; none invalidates | A33 | §4.3 | Filter values are cast by one rule on all backends (exact type, else text that reads as the type). `find().on_invalid("raise" \| "skip" \| "raw")` | Second review | | A34 | §4.8 | `Policy(private=True)` is enforced on fields of embedded models at any depth: not serialised, a 422 when supplied, absent from schemas | Third review item A. PATCH replaces an embedded object whole, so a client that patches the parent object clears server-set private values inside it; documented, not changed | | A35 | §4.2 | `StoreNone` / `OmitNone` markers and `SpecConfig(none="omit" \| "store")`. Embedded models resolve the setting from their own markers, or from a `__spec__` if they declare one; they do not inherit the parent document's setting. A unique sparse index over a field that stores null is a `ConfigurationError`. No warning is raised for a partial filter on `$type: null` over an omitted field | Requested as the opt-out from A30. The embedded rule keeps one model's stored shape the same wherever it is embedded | +| A36 | §4.3 | Cursor pagination handles documents without a value for a sort key (first ascending, last descending) and builds the cursor from stored values, not model defaults | Found while testing raw reads: such documents were skipped or ended the walk. Became common once None was no longer stored (A30) | New node: @@ -927,3 +928,28 @@ Known and not changed: - S7 (confirming every Mongoose parity row against its documentation) is done for 28 of 35 rows; `docs/parity.md` marks the 7 that are still from memory (alias, `useDb`, per-query driver options, `exists`/counts, subdocuments and maps, getters/setters, virtuals). + +### Performance review (2026-10-13): decisions + +Thirteen findings from a profiling review. Numbers are from workload B9, `bench/results/49a32a9` +(before) and `bench/results/fb543de` (after). + +| # | Finding | Decision | Reason | +|---|---|---|---| +| 1 | Web reads build a model only to serialise it; private fields travel over the wire | Implemented: projection of the readable fields, serialise from the stored document when certain to be identical, model path otherwise (`mongospecs_web.fastread`) | Serialise alone 4.3 → 2.5 µs/doc; whole request under 10% because the driver dominates. Kept for the projection and because it is verified equal | +| 2 | Refs handled twice on decode (dec_hook, then normalisation) | Not done | Changes the decode core of three backends for a gain of about 1 µs/doc; revisit if per-document CPU becomes the limit | +| 3 | Encode is a Python walker; save rebuilds the snapshot through the model | Not done | Same reason; the model round trip is also what keeps a load from looking like an edit (A15/A16) | +| 4 | `weakref.finalize` per tracked document | Implemented: one `weakref.ref` held on the state, guarded against id reuse | Cheap and contained; effect not visible in B9 | +| 5 | Batched includes run one after another | Implemented: gathered outside a session | A session cannot run two operations at once, so inside a transaction they stay serial | +| 6 | `per_parent_limit` applied after fetching every child | Implemented: `$sort` + `$group` with `$firstN` picks ids on the server | 23.0 → 1.5 ms CPU for 20 parents × 100 children | +| 7 | Web DELETE is two round trips | Implemented: one conditional write unless the model has delete hooks or cascades | 2 → 1 commands | +| 8 | `Query._clone` cost; `get` through `find().first()` | Clone implemented (slot copy). `find_one` fast path not done | `get` 248 → 218 µs is within noise; a second read path is not worth it | +| 9 | Hard delete of a cascade target needs a transaction, so a replica set | Documented | A cascade that half-completes is worse than a clear requirement | +| 10 | `total=True` is a full count per request | Documented; totals are already opt-in per resource | An estimate is wrong for any filtered listing; a cache belongs to the application | +| 11 | Routing scans every route | Implemented: lookup by resource, then that resource's patterns | 19.1 → 1.3 µs at 255 routes | +| 12 | No backpressure | Implemented `Api(max_concurrency=)` and 503 for `PoolTimeout`; pool defaults left to the driver and documented | Changing the driver's defaults silently would surprise; the guide says what to set | +| 13 | Memory shape of pydantic documents; `all()` materialises | Documented | Inherent to the model library | + +Design-level options not taken: per-model compiled plans shared by decode and encode, lazy `Ref` +materialisation, an explain-sampling listener, state on the instance for free-threading, a native +decoder. None is needed by a measured workload yet. diff --git a/docs/benchmarks.md b/docs/benchmarks.md index b01f216..25e38c8 100644 --- a/docs/benchmarks.md +++ b/docs/benchmarks.md @@ -244,6 +244,40 @@ BSON-to-model decoder as a separate optional package behind `Codec.decode(bytes) already there: tracked reads receive undecoded BSON from the driver today. A Rust driver is not indicated by anything here. +## Hot paths after the performance review + +A review profiled the library and listed thirteen places where work could be saved. Workload B9 +measures them through public entry points: CPU time of the client process, and commands sent. +Before is `bench/results/49a32a9/B9.json`, after is `bench/results/fb543de/B9.json` (msgspec rows). + +| Path | Before | After | +|---|---|---| +| Reverse relation with `per_parent_limit=2`, 20 parents × 100 children | 23.0 ms CPU | 1.5 ms CPU | +| Web `DELETE` (soft) | 2 commands | 1 command | +| Route match, last of 255 routes | 19.1 µs | 1.3 µs | +| Route match, last of 1005 routes | 73.3 µs | 15.4 µs | +| Serialise a document for a response | 4.3 µs through the model | 2.5 µs from the stored document | +| Web `GET` list of 100, per document | 10.9 µs | 10.1 µs | +| `Model.get(id)` | 248 µs | 218 µs | +| `find().all()`, 100 tracked, per document | 8.7 µs | 9.8 µs | + +What this shows: + +- The large wins are where work was avoided altogether: children that were fetched to be thrown + away, a read before a delete, a scan over every route. +- Serialising from the stored document is 1.7 to 2.6 times cheaper than going through the model + (pydantic 6.7 → 2.9 µs, attrs 6.4 → 2.5 µs), but a whole list request improves by under 10% here: + the driver and the event loop are most of a request, as the Rust section above already found. +- The last three rows are within run-to-run variation on this machine. The cheaper weak reference + per tracked document, the cheaper query clone and concurrent includes are correct and tested, + but this benchmark cannot show their effect; do not read the tracked row as a regression or the + `get` row as a gain. + +Not done, and why: compiling encode plans and skipping reference normalisation on decode (review +items 2 and 3) change the decode and encode core for a gain B1 puts at a few microseconds per +document; they are left until a workload shows per-document CPU, rather than the driver, is the +limit. + ## Limits of these measurements - One machine, client and server together, MongoDB in Docker on macOS. Network cost is unrealistically diff --git a/docs/guide/production.md b/docs/guide/production.md new file mode 100644 index 0000000..28d6d12 --- /dev/null +++ b/docs/guide/production.md @@ -0,0 +1,136 @@ +# Tuning for production and high concurrency + +Each item says what backs it: **measured** (a committed result under `bench/results/`), **tested** +(a test in this repository asserts it), or **guidance** (sound practice that nothing here verifies). + +## Connections and timeouts + +Any keyword `Registry` does not know is passed to the driver, so the pool is configured there: + +```py +registry = Registry( + uri, + models=[...], + max_time_ms=5_000, # default time limit for reads + maxPoolSize=50, # connections per process (driver default: 100) + minPoolSize=5, # kept open, so a burst does not pay for handshakes + waitQueueTimeoutMS=500, # fail instead of queueing for a connection without bound + serverSelectionTimeoutMS=5_000, + connectTimeoutMS=2_000, +) +``` + +- **Set `waitQueueTimeoutMS`.** Without it a request that finds the pool empty waits indefinitely, + so overload shows up as every request getting slower. With it the operation raises `PoolTimeout`, + which the web layer answers with 503 and `Retry-After`. *Tested* + (`test_a_full_pool_fails_fast_when_a_wait_limit_is_set`, `test_a_pool_timeout_is_a_503_with_retry_after`). +- **Size the pool for the database, not for the process.** `maxPoolSize` × processes is the number of + connections the server must hold. One event loop rarely makes use of more than a few dozen. *Guidance.* +- **Give reads a time limit.** `Registry(max_time_ms=...)` or `SpecConfig(max_time_ms=...)`; an + operation that exceeds it raises `OperationTimeout` (HTTP 504). The web layer applies 10 s to reads + made for a request when neither is set. *Tested* (`test_listing_limits`). +- **One registry per process and event loop.** Open it when the server starts and close it when it + stops (`plug.lifespan(registry)`, `init_app(..., registry=registry)`). A model class is bound to + one open registry; use `registry.manager(Model)` where you need several. *Tested* (lifecycle and + binding tests); do not share a registry across event loops. *Guidance.* + +## Refusing work you cannot do + +```py +api = Api(*resources, max_concurrency=200, max_body=1_048_576) +``` + +- `max_concurrency` answers the request beyond the limit at once with 503 and `Retry-After: 1` + instead of letting it queue. Set it a little above the concurrency your pool and database + sustain. *Tested* (`test_requests_beyond_max_concurrency_are_refused_at_once`); the right number + for your deployment is *guidance*: find it with the load workload below. +- `max_body` is enforced while the body is read, on every plug. *Tested* + (`test_3_no_plug_reads_a_body_past_the_limit`). +- `Offset(max_offset=...)` caps how far a client can page with skip/limit, and `Cursor(max=...)` the + page size. *Tested.* + +## Reads + +- **Use `lean()` for anything you will not save.** A tracked document also keeps its stored bytes: + 3.6 KB against 2.4 KB of heap per document for the benchmark's shape (msgspec). The time + difference between tracked and lean reads is small. `raw()` skips the model altogether and + roughly halves the time, but a dict takes more memory than a model (5.3 KB here). + (`bench/results/6dc837f/B8.json`). *Measured.* +- **Project.** `select("name", "price")` fetches only those fields; mark large, rarely needed + fields `Deferred` so they are left out by default. *Tested.* +- **Stream large results** with `async for doc in Model.find(...)` instead of `.all()`, which holds + the whole result. Pydantic instances take roughly 2.6 times the memory of msgspec or attrs ones + (1829 against 709 bytes per lean document of the review's sample shape). *Measured by the + reviewer; there is no committed result for it.* +- **The web layer reads without building models where it can.** `GET` on a resource fetches only + the readable fields and serialises the stored document directly when the model has nothing that + could change a value on the way (validators, converters, computed fields, a discriminator); any + other model, and any single document whose stored values do not have their declared types, goes + through the model. Serialising alone costs 2.5 µs per document this way against 4.3 (msgspec), + 6.4 (attrs) and 6.7 (pydantic) through the model; for a whole request the gain is smaller because + the driver dominates (`bench/results/fb543de/B9.json`). *Measured*, and *tested* to give the same + response (`test_raw_reads_equal_model_reads`). `Resource.raw_reads = False` turns it off. + +## Pagination + +- **Prefer cursors.** `Cursor()` (keyset) costs the same on page 1 and page 10,000; `Offset()` + makes the server walk and discard everything before the page: at page 1,250 of a 50,000-document + collection, 7.1 ms against 0.66 ms. *Measured* (`bench/results/6dc837f/B5.json`). +- **The index must end in `_id`.** Cursor pagination sorts by your key and then by `_id`: + declare `Index("price", "id")`. With an index on the key alone the first page took 28.8 ms + instead of 0.58 ms. *Measured* (B5). +- Documents without a value for the sort key are paged too (first ascending, last descending). + *Tested* (`test_cursor_pages_include_documents_without_the_sort_key`). +- **`?total=true` is a second query over every match.** It is off unless the resource opts in + with `Cursor(total=True)`. There is no cached or estimated total; if you need one, keep a counter. + *Tested* (two commands; `test_listing_limits`). + +## Relations + +- `include()` uses one query per relation for the whole page by default. Independent relations are + sent together, except inside a transaction. *Tested* + (`test_independent_includes_are_in_flight_together`). +- **Give reverse relations a `per_parent_limit`.** `include("reviews", per_parent_limit=5)` chooses + the children on the server, so a parent with 100,000 children costs the same as one with five. + With 20 parents of 100 children each this took 1.5 ms of client CPU against 23 ms when every + child was fetched (`bench/results/49a32a9/B9.json` before, `fb543de` after). *Measured and tested.* +- Index the foreign key of every reverse relation (`plan.advisories` lists the ones that lack one). + *Tested.* + +## Writes + +- `insert_many`, `bulk_save` and `bulk_write` send one command per batch; inserting 1,000 documents + costs 1.08 times the driver's own `insert_many` (`bench/results/6dc837f/B4.json`). *Measured.* +- `save()` sends only the changed fields; `Model.update(id, Inc(...))` is one atomic round trip and + needs no read. *Tested.* +- A web `DELETE` is one conditional write. It needs a read first only when the model has delete + hooks, or cascades on a hard delete. *Tested* (`test_delete_is_one_write_unless_the_document_is_needed`). + +## What needs a replica set + +Transactions and change streams need a replica set (a single-node one is enough). That includes +**hard-deleting a document that other documents refer to with `OnDelete`**: the cascade and the +delete run in one transaction, which a standalone `mongod` cannot do. Soft deletes do not cascade +and need no transaction. *Tested on a replica set; not supported on a standalone server.* + +Change streams resume across a primary step-down (`bench/results/6dc837f/X6.json`). To resume +across a restart of your own process, store `event.resume_token` and pass it back as +`resume_after=`. *Measured for the step-down; the restart pattern is guidance.* + +## Observability + +`on_command` listeners run for every command, inline. The logging, OpenTelemetry and Sentry +listeners do little per command, but anything slow in a listener is added to every operation. +*Guidance:* their cost has not been measured. + +## Measuring your own workload + +```sh +make up # MongoDB replica set and sharded cluster +uv run python -m mongospecs_bench.cli run B9 # CPU per call and commands per operation +uv run python -m mongospecs_bench.cli run LOAD # the storefront under HTTP load, against raw pymongo +``` + +Results are written to `bench/results//`. CPU time and command counts are stable from +run to run; wall-clock numbers on a shared machine vary by 20 to 30 percent, so compare those only +when the difference is large. diff --git a/docs/web/resources.md b/docs/web/resources.md index dd83ba6..2689701 100644 --- a/docs/web/resources.md +++ b/docs/web/resources.md @@ -149,6 +149,14 @@ on patch (a float or Decimal field takes it as that type). A guard listed in mor runs once per request. Private fields appear nowhere in `openapi.json`, and it only publishes the nested definitions that the Read, Create and Patch schemas refer to. +`Policy(private=True)` also applies to the fields of embedded models, at any depth: they are never +in a response, a request that supplies one gets a 422, and they are in no schema. A `PATCH` that +sends an embedded object replaces it whole, so private values inside it are cleared; patch the +other fields of the parent, or set such values in a server-side hook. + +`Api(max_concurrency=N)` answers the request beyond N in flight with 503 and `Retry-After`. See +[Tuning for production](../guide/production.md). + `DELETE` honours `If-Match` like `PATCH`, and the row scope is part of the delete's own filter. `HEAD` and `OPTIONS` are answered by `api.handle` and the pure ASGI plug; the framework plugs leave them to the framework. diff --git a/mkdocs.yaml b/mkdocs.yaml index 2f137a3..89a9134 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -86,6 +86,7 @@ nav: - Relations: guide/relations.md - Hooks and plugins: guide/plugins.md - Running in production: guide/operations.md + - Tuning for production: guide/production.md - Web: - Resources: web/resources.md - Plugs: web/plugs.md diff --git a/packages/mongospecs/tests/test_hot_paths.py b/packages/mongospecs/tests/test_hot_paths.py index 987975a..56c82a9 100644 --- a/packages/mongospecs/tests/test_hot_paths.py +++ b/packages/mongospecs/tests/test_hot_paths.py @@ -206,3 +206,20 @@ def named(query): # an unrelated named scope must not disturb relation loading loaded = await Book.find(Q.id == books[0].id).include("co_writers", per_parent_limit=1).one() assert len(loaded.co_writers) == 2 and sum(r.loaded for r in loaded.co_writers) >= 1 assert asyncio.get_running_loop() is not None + + +async def test_a_full_pool_fails_fast_when_a_wait_limit_is_set(m, make_registry, db_name): + """Backs the production guide: with waitQueueTimeoutMS an exhausted pool raises instead of queueing.""" + from mongospecs import PoolTimeout + + registry = await make_registry(m.User, maxPoolSize=1, waitQueueTimeoutMS=100) + users = registry.manager(m.User) + await users.insert(m.User(name="a")) + slow = asyncio.create_task( + users.collection.find_one({"$where": "function() { sleep(600); return true; }"}) + ) + await asyncio.sleep(0.15) # the only connection is now busy + with pytest.raises(PoolTimeout): + await users.count() + await slow + assert await users.count() == 1 # and it recovers once the connection is free From 0748110b113953e76147e6fa08fcaea3b3271807 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 07:34:35 -0400 Subject: [PATCH 23/31] fix(core): serialise BSON-only values held in untyped fields An ObjectId, Binary, Timestamp, Regex or Int64 inside a field typed Any (or dict[str, Any], list[Any]) made the JSON conversion fail, and the web layer answered 500: on pydantic for any ObjectId, on msgspec and attrs for the BSON types their serialiser does not know. All three backends now give the same JSON for such values, at any depth. Co-Authored-By: Claude Opus 5.5 --- .../tests/test_untyped_values.py | 155 ++++++++++++++++++ packages/mongospecs/src/mongospecs/codec.py | 1 + .../mongospecs/src/mongospecs/jsonable.py | 25 ++- .../mongospecs/src/mongospecs/pydantic.py | 51 +++++- 4 files changed, 227 insertions(+), 5 deletions(-) create mode 100644 packages/mongospecs-web/tests/test_untyped_values.py diff --git a/packages/mongospecs-web/tests/test_untyped_values.py b/packages/mongospecs-web/tests/test_untyped_values.py new file mode 100644 index 0000000..00a7235 --- /dev/null +++ b/packages/mongospecs-web/tests/test_untyped_values.py @@ -0,0 +1,155 @@ +"""Values only BSON has (ObjectId, dates, decimals, binary, UUID ...) inside untyped fields.""" + +import datetime as dt +import json +import typing as t +import uuid + +import bson +import pytest + +from mongospecs import SpecConfig +from mongospecs_web import Api, Create, Get, List, Patch, Resource +from mongospecs_web.policy import view_of + +BACKENDS = ("msgspec", "pydantic", "attrs") +OID = bson.ObjectId("65a1b2c3d4e5f60718293a4b") +VALUES = { + "oid": OID, + "when": dt.datetime(2021, 1, 2, 3, 4, 5, 123000, tzinfo=dt.UTC), + "amount": bson.Decimal128("12.50"), + "blob": b"\xff\x00hi", + "text_bytes": b"plain", + "uid": uuid.UUID("12345678-1234-5678-1234-567812345678"), + "big": bson.Int64(2**40), + "binary": bson.Binary(b"\x01\x02", 128), + "stamp": bson.Timestamp(1700000000, 1), + "pattern": bson.Regex("^a", "i"), + "deep": {"list": [OID, {"again": [dt.datetime(2020, 5, 6, tzinfo=dt.UTC), None, 1.5, "s", True]}]}, +} +EXPECTED = { + "oid": "65a1b2c3d4e5f60718293a4b", + "when": "2021-01-02T03:04:05.123000Z", + "amount": "12.50", + "blob": "/wBoaQ==", + "text_bytes": "cGxhaW4=", + "uid": "12345678-1234-5678-1234-567812345678", + "big": 2**40, + "binary": "AQI=", + "stamp": "2023-11-14T22:13:20Z", + "pattern": "^a", + "deep": {"list": ["65a1b2c3d4e5f60718293a4b", {"again": ["2020-05-06T00:00:00Z", None, 1.5, "s", True]}]}, +} + + +def models(backend): + if backend == "msgspec": + import msgspec + + from mongospecs.msgspec import Spec + + class Base(msgspec.Struct, kw_only=True): + pass + + define = lambda c: c # noqa: E731 + elif backend == "pydantic": + import pydantic + + from mongospecs.pydantic import Spec + + Base = pydantic.BaseModel + define = lambda c: c # noqa: E731 + else: + import attrs + + from mongospecs.attrs import Spec + + Base = object + define = attrs.define(kw_only=True) + + @define + class Part(Base): + label: str = "" + meta: dict[str, t.Any] = {} # noqa: RUF012 + + @define + class Loose(Spec): + __spec__ = SpecConfig(collection="loose") + name: str = "" + extra: dict[str, t.Any] = {} # noqa: RUF012 + things: list[t.Any] = [] # noqa: RUF012 + anything: t.Any = None + part: Part | None = None + parts: list[Part] = [] # noqa: RUF012 + + return Part, Loose + + +@pytest.fixture(params=BACKENDS) +async def world(request, make_registry): + Part, Loose = models(request.param) + await make_registry(Loose) + + def api(raw): + resource = Resource(Loose, name="loose", ops=[List, Get, Create, Patch]) + resource.raw_reads = raw + return Api(resource, prefix="/v1") + + return Loose, api(True), api(False) + + +async def test_bson_only_values_in_untyped_fields_are_served(world): + Loose, fast, slow = world + stored = await Loose.collection().insert_one( + { + "name": "x", + "extra": dict(VALUES), + "things": list(VALUES.values()), + "anything": VALUES["oid"], + "part": {"label": "p", "meta": dict(VALUES)}, + "parts": [{"label": "q", "meta": {"k": VALUES["when"], "o": [VALUES["oid"]]}}], + } + ) + for path in (f"/v1/loose/{stored.inserted_id}", "/v1/loose"): + bodies = [] + for api in (fast, slow): + response = await api.handle("GET", path) + assert response.status == 200, (path, response.body) + bodies.append(response.body) + assert bodies[0] == bodies[1] # served from the stored document or through the model: the same + data = json.loads(bodies[0])["data"] + row = data[0] if isinstance(data, list) else data + assert row["extra"] == EXPECTED + assert row["things"] == list(EXPECTED.values()) + assert row["anything"] == EXPECTED["oid"] + assert row["part"] == {"label": "p", "meta": EXPECTED} + assert row["parts"] == [{"label": "q", "meta": {"k": EXPECTED["when"], "o": [EXPECTED["oid"]]}}] + # and the same through a tracked model outside the web layer + doc = await Loose.get(stored.inserted_id) + assert view_of(Loose).serialize(doc)["extra"] == EXPECTED + + +async def test_json_written_into_untyped_fields_round_trips(world): + """A client can only send JSON types, and they come back as sent.""" + Loose, fast, slow = world + payload = { + "name": "j", + "extra": {"a": [1, 2.5, "s", None, True, {"b": {}}]}, + "things": [[], {}, "x"], + "anything": {"k": 1}, + } + headers = {"content-type": "application/json"} + created = await fast.handle("POST", "/v1/loose", body=json.dumps(payload).encode(), headers=headers) + assert created.status == 201, created.body + row = json.loads(created.body)["data"] + assert {k: row[k] for k in payload} == payload + for api in (fast, slow): + got = json.loads((await api.handle("GET", created.headers["location"])).body)["data"] + assert {k: got[k] for k in payload} == payload + patched = await fast.handle( + "PATCH", + created.headers["location"], + body=json.dumps({"extra": {"z": [None]}}).encode(), + headers=headers, + ) + assert patched.status == 200 and json.loads(patched.body)["data"]["extra"] == {"z": [None]} diff --git a/packages/mongospecs/src/mongospecs/codec.py b/packages/mongospecs/src/mongospecs/codec.py index a82ccd8..75a3d9c 100644 --- a/packages/mongospecs/src/mongospecs/codec.py +++ b/packages/mongospecs/src/mongospecs/codec.py @@ -417,6 +417,7 @@ def clear_cache(self) -> None: self.__dict__.pop("_sp", None) self.__dict__.pop("_abs", None) self.__dict__.pop("_eo", None) + self.__dict__.pop("_ut", None) def _none_mode(cls: type) -> str: diff --git a/packages/mongospecs/src/mongospecs/jsonable.py b/packages/mongospecs/src/mongospecs/jsonable.py index 6eaa31f..24d08fb 100644 --- a/packages/mongospecs/src/mongospecs/jsonable.py +++ b/packages/mongospecs/src/mongospecs/jsonable.py @@ -10,17 +10,37 @@ import uuid from bson import ObjectId +from bson.code import Code +from bson.dbref import DBRef from bson.decimal128 import Decimal128 +from bson.max_key import MaxKey +from bson.min_key import MinKey +from bson.regex import Regex +from bson.timestamp import Timestamp from mongospecs.refs import Ref +_BSON_ONLY = (bytes, bytearray, int, Timestamp, Regex, Code, DBRef, MaxKey, MinKey) +"""Types a serialiser may hand to :func:`json_default` that only BSON has, or that are +subclasses of standard types the serialiser does not recognise (``Binary``, ``Int64``).""" + + __all__ = ["json_default", "jsonable"] def jsonable(value: t.Any) -> t.Any: """Convert a BSON-ready value to plain JSON types.""" - if value is None or isinstance(value, str | bool | int | float): + kind = type(value) + if value is None or kind is str or kind is bool or kind is int or kind is float: + return value + if isinstance(value, bool | str | float): return value + if isinstance(value, int): + return int(value) # bson.Int64 and other int subclasses + if isinstance(value, Timestamp): + return jsonable(value.as_datetime()) + if isinstance(value, Regex): + return value.pattern if isinstance(value, ObjectId | uuid.UUID | decimal.Decimal | Decimal128): return str(value) if isinstance(value, dt.datetime): @@ -47,6 +67,9 @@ def json_default(value: t.Any) -> t.Any: return str(value) if isinstance(value, Ref): return jsonable(value.id) + if isinstance(value, _BSON_ONLY): + # Found in untyped fields: whatever another writer stored there has to be readable. + return jsonable(value) identifier = getattr(value, "id", None) if identifier is not None: # a document assigned where a reference is expected return jsonable(identifier) diff --git a/packages/mongospecs/src/mongospecs/pydantic.py b/packages/mongospecs/src/mongospecs/pydantic.py index 8cbcf32..7674325 100644 --- a/packages/mongospecs/src/mongospecs/pydantic.py +++ b/packages/mongospecs/src/mongospecs/pydantic.py @@ -11,6 +11,7 @@ from mongospecs.codec import _UNION_ORIGINS, Codec, RawField, field_error from mongospecs.errors import ValidationError +from mongospecs.jsonable import jsonable from mongospecs.markers import SpecConfig from mongospecs.model import Document from mongospecs.refs import OID, Ref @@ -38,6 +39,21 @@ def _annotation(info: t.Any) -> t.Any: return info.annotation +def _has_untyped(tp: t.Any, seen: set[type]) -> bool: + while t.get_origin(tp) is t.Annotated: + tp = t.get_args(tp)[0] + if tp is t.Any or tp is object or tp in (dict, list, set, tuple, frozenset): + return True + if isinstance(tp, type) and issubclass(tp, BaseModel) and not issubclass(tp, Document): + if tp in seen: + return False + seen.add(tp) + return any(_has_untyped(f.annotation, seen) for f in tp.model_fields.values()) + if t.get_origin(tp) is Ref: + return False + return any(_has_untyped(arg, seen) for arg in t.get_args(tp)) + + class _Omit: """Which keys of an embedded model are left out while None, and where its own embedded models are.""" @@ -173,13 +189,40 @@ def _json_schema(self, cls: type) -> dict[str, t.Any]: return t.cast("type[BaseModel]", cls).model_json_schema(by_alias=False) def to_json(self, obj: t.Any) -> dict[str, t.Any]: + cls = type(obj) + loose = self._untyped(cls) + options: dict[str, t.Any] = {"exclude": loose} if loose else {} if _HAS_EXCLUDE_COMPUTED: - return t.cast("dict[str, t.Any]", obj.model_dump(mode="json", exclude_computed_fields=True)) - data: dict[str, t.Any] = obj.model_dump(mode="json") - for name in type(obj).model_computed_fields: - data.pop(name, None) + options["exclude_computed_fields"] = True + data: dict[str, t.Any] = obj.model_dump(mode="json", **options) + if not _HAS_EXCLUDE_COMPUTED: + for name in cls.model_computed_fields: + data.pop(name, None) + if loose: + # pydantic cannot serialise the BSON-only values an untyped field may hold (an + # ObjectId raises, bytes are read as UTF-8). Those fields are converted by the same + # rules the other backends use, so all three give the same JSON. + plain = obj.model_dump(mode="python", include=loose) + data = { + name: jsonable(plain[name]) if name in loose else data[name] + for name in cls.model_fields + if name in loose or name in data + } return data + def _untyped(self, cls: type) -> set[str]: + """Fields whose type leaves some value untyped (``Any``, a bare ``dict`` or ``list``), + directly or inside an embedded model.""" + cache: dict[type, set[str]] = self.__dict__.setdefault("_ut", {}) + hit = cache.get(cls) + if hit is None: + hit = cache[cls] = { + name + for name, _native, annotation, _ in self.raw_fields(cls) + if _has_untyped(annotation, set()) + } + return hit + def defaults(self, cls: type) -> dict[str, t.Any]: fields = t.cast("type[BaseModel]", cls).model_fields return { From 933df43eb8698d8f09dbefff31106eb6f89c3e32 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 08:50:15 -0400 Subject: [PATCH 24/31] feat(web)!: PATCH merges into embedded objects A PATCH that gave an object for an embedded model replaced the stored object, writing defaults over everything the client did not send, including private, read-only and immutable values it is not allowed to set. Objects are now merged key by key (RFC 7396) in one atomic update; lists and dicts are still replaced whole. - nested keys are held to the embedded model's own Policy - an embedded object holding protected values cannot be set to null, and a list or dict of such objects cannot be replaced through PATCH - Policy(insert=False) is enforced on nested keys at create - the OpenAPI Patch schema describes embedded objects as partial BREAKING: clients that relied on PATCH replacing an embedded object must now send null for the keys they want cleared. Co-Authored-By: Claude Opus 5.5 --- claudedocs/v2-plan.md | 2 + docs/parity.md | 1 + docs/web/resources.md | 35 ++- .../src/mongospecs_web/backend.py | 82 +++++- .../src/mongospecs_web/merge.py | 225 +++++++++++++++ .../src/mongospecs_web/policy.py | 74 ++++- .../src/mongospecs_web/resource.py | 11 +- .../src/mongospecs_web/testing.py | 53 +++- .../tests/test_patch_embedded.py | 261 ++++++++++++++++++ 9 files changed, 731 insertions(+), 13 deletions(-) create mode 100644 packages/mongospecs-web/src/mongospecs_web/merge.py create mode 100644 packages/mongospecs-web/tests/test_patch_embedded.py diff --git a/claudedocs/v2-plan.md b/claudedocs/v2-plan.md index f966e02..5286399 100644 --- a/claudedocs/v2-plan.md +++ b/claudedocs/v2-plan.md @@ -888,6 +888,8 @@ Recorded per §7.1 rule 4. Each one names what changed and why; none invalidates | A34 | §4.8 | `Policy(private=True)` is enforced on fields of embedded models at any depth: not serialised, a 422 when supplied, absent from schemas | Third review item A. PATCH replaces an embedded object whole, so a client that patches the parent object clears server-set private values inside it; documented, not changed | | A35 | §4.2 | `StoreNone` / `OmitNone` markers and `SpecConfig(none="omit" \| "store")`. Embedded models resolve the setting from their own markers, or from a `__spec__` if they declare one; they do not inherit the parent document's setting. A unique sparse index over a field that stores null is a `ConfigurationError`. No warning is raised for a partial filter on `$type: null` over an omitted field | Requested as the opt-out from A30. The embedded rule keeps one model's stored shape the same wherever it is embedded | | A36 | §4.3 | Cursor pagination handles documents without a value for a sort key (first ascending, last descending) and builds the cursor from stored values, not model defaults | Found while testing raw reads: such documents were skipped or ended the walk. Became common once None was no longer stored (A30) | +| A37 | §4.8 | HTTP `PATCH` uses JSON Merge Patch semantics for embedded objects (per-key dotted `$set`/`$unset` in one atomic update); lists and dicts still replace whole. Nested keys are held to the embedded model's own `Policy`. An embedded object holding protected values cannot be nulled, and a list or dict of such objects cannot be replaced through `PATCH` (422). `Policy(insert=False)` is enforced on nested keys at create. Supersedes the note in A34 | Decision delegated by Jay. Options weighed: (a) document only, rejected because a client could clear private and read-only values by omitting them, which no documentation fixes; (c) per-field opt-in, rejected because the safe behaviour would be off by default; (d) reject any patch of an object with protected values, rejected because such objects would be unpatchable. (b) merge is what RFC 7396 specifies for objects, is atomic, and removes the hazard instead of fencing it; (d) is kept only where merge has no meaning (list items, null on the whole object). Cost: PATCH on an embedded object is now partial, a behaviour change for clients that relied on replacement; a merge into a missing object needs its required fields; model-level cross-field validators of the embedded model do not run on a merge, as they already do not for top-level PATCH | +| A38 | §4.2 | BSON-only values in untyped fields (`Any`) serialise to JSON on all backends: ObjectId and UUID as strings, dates as ISO 8601, Decimal128 as a string, binary as base64, Int64 as a number, Timestamp as a date, Regex as its pattern | Reported: a 500 on pydantic for any ObjectId, and on msgspec/attrs for Binary, Timestamp, Regex and Int64 | New node: diff --git a/docs/parity.md b/docs/parity.md index a16300d..bc750d2 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -66,6 +66,7 @@ was established: **docs** means it was re-read in the Mongoose documentation for | Populated paths replace the id with the document | A `Ref` keeps `.id` and gains `.doc` | One type whether or not it is loaded | | Arrays are tracked per element | A changed list is sent whole | Per-element intent is expressed with atomic operators | | `Model.watch()` errors out when it loses the primary | The change stream resumes across a step-down (the driver's behaviour, verified by a stress scenario) | Consumers should not need their own reconnect loop | +| Setting a nested object overwrites it (`doc.set('child', {...})`; `{merge: true}` merges) | Model code is the same: assigning an embedded object replaces it. An HTTP `PATCH` merges into embedded objects | A client must not clear values it is not allowed to set (private or read-only fields inside the object) | | `syncIndexes()` drops every undeclared index | `apply_indexes()` only drops or recreates with `drop=True` | Dropping an index is not something a deploy should do by accident | | `orFail()` on an update throws when nothing was modified | `Model.update` raises when nothing matched; `find(...).update(...)` returns the matched and modified counts | A no-op update that matched is not an error | | `populate()` always runs a separate query per path | `include()` does the same by default and can use `$lookup` instead (`strategy="lookup"`) | Both give the same result; the faster one depends on fan-out, see the benchmarks | diff --git a/docs/web/resources.md b/docs/web/resources.md index 2689701..4e57bf8 100644 --- a/docs/web/resources.md +++ b/docs/web/resources.md @@ -149,10 +149,37 @@ on patch (a float or Decimal field takes it as that type). A guard listed in mor runs once per request. Private fields appear nowhere in `openapi.json`, and it only publishes the nested definitions that the Read, Create and Patch schemas refer to. -`Policy(private=True)` also applies to the fields of embedded models, at any depth: they are never -in a response, a request that supplies one gets a 422, and they are in no schema. A `PATCH` that -sends an embedded object replaces it whole, so private values inside it are cleared; patch the -other fields of the parent, or set such values in a server-side hook. +`Policy` also applies to the fields of embedded models, at any depth. Private fields are never in +a response and are in no schema; a request that supplies a field it may not write (private, +`insert=False` on create, `update=False` or `Immutable` on patch) gets a 422 naming the dotted path. + +## PATCH and embedded objects + +`PATCH` merges into embedded objects instead of replacing them (JSON Merge Patch, RFC 7396): + +``` +PATCH /people/1 {"address": {"zip": "0150", "note": null, "geo": {"lat": 59.9}}} +``` + +- Each key is written on its own (`address.zip`, `address.geo.lat`), all in one atomic update, with + one revision bump and the usual `If-Match` check. Keys the request does not name are untouched. +- `null` clears a key: it is removed, or stored as `null` where the field is `StoreNone`. A key that + cannot be `None` rejects `null`. +- Lists and dicts are replaced whole, as before. +- If the object does not exist yet, the request must carry all of its required fields; otherwise it + is a 422 that lists them. + +What a client cannot set, it cannot clear either: + +- a nested key it may not write is rejected (422); +- an embedded object that holds such values cannot be set to `null`; +- a list or dict of embedded objects that hold such values cannot be replaced through `PATCH` + (there is no way to merge into a list item). Give such lists their own operation with + `@resource.op`, where your code decides what happens to the server's values. + +The Patch schema in OpenAPI describes embedded objects as partial (`AddrPatch`: no required keys, +only the writable ones). Values stored in untyped fields (`Any`, `dict[str, Any]`) are served as +JSON whatever they are: an ObjectId as its string, dates as ISO 8601, binary as base64. `Api(max_concurrency=N)` answers the request beyond N in flight with 503 and `Retry-After`. See [Tuning for production](../guide/production.md). diff --git a/packages/mongospecs-web/src/mongospecs_web/backend.py b/packages/mongospecs-web/src/mongospecs_web/backend.py index 2b2e4de..dc323de 100644 --- a/packages/mongospecs-web/src/mongospecs_web/backend.py +++ b/packages/mongospecs-web/src/mongospecs_web/backend.py @@ -9,13 +9,25 @@ import typing as t from dataclasses import dataclass, field -from mongospecs import DocumentNotFound, Page, Q, QueryError, StaleDocumentError, meta_of +from mongospecs import DocumentNotFound, Page, Q, QueryError, Revision, StaleDocumentError, meta_of from mongospecs.expr import Condition, Expr, Field __all__ = ["OPERATORS", "Filter", "ListQuery", "MongoBackend", "ResourceBackend"] T = t.TypeVar("T") +_PATH_NOT_VIABLE = 28 +"""Server error for a dotted path that runs through a null or a non-object.""" + + +def _dig(doc: t.Any, path: str) -> t.Any: + for part in path.split("."): + if not isinstance(doc, dict): + return None + doc = doc.get(part) + return doc + + OPERATORS: dict[str, str] = { "eq": "$eq", "ne": "$ne", @@ -151,19 +163,77 @@ async def create(self, data: dict[str, t.Any]) -> T: doc = self.meta.decode(stored, stored=False) return t.cast(T, await self.manager.insert(doc)) - async def patch(self, id: t.Any, data: dict[str, t.Any], *, scope: t.Any, if_rev: int | None = None) -> T: + supports_merge = True + """``patch`` accepts ``merge=``: a :class:`~mongospecs_web.merge.PatchPlan` of writes inside + embedded objects, applied together with ``data`` in one atomic update.""" + + async def patch( + self, + id: t.Any, + data: dict[str, t.Any], + *, + scope: t.Any, + if_rev: int | None = None, + merge: t.Any = None, + ) -> T: query = self._by_id(id, scope) if query is None: raise DocumentNotFound(f"{self.model.__name__} {id!r} does not exist") - if not data: + nested = merge is not None and merge.nested + if not data and not nested: doc = await query.first() if doc is None: raise DocumentNotFound(f"{self.model.__name__} {id!r} does not exist") return t.cast(T, doc) - from mongospecs import trusted + from mongospecs import OperationFailed, Set, Unset, trusted + from mongospecs.expr import and_filters + + base = query._user_filter() + if not nested: + return t.cast(T, await self.manager.update(trusted(base), if_rev=if_rev, **data)) + meta = self.manager.meta + ops: list[t.Any] = [] + if merge.set: + ops.append(Set(merge.set)) + if merge.unset: + ops.append(Unset(*merge.unset)) + # A partial object can only be merged into an object that is already there. + present = {meta.stored_path(path): {"$type": "object"} for path in merge.parents} + try: + return t.cast( + T, await self.manager.update(trusted(and_filters(base, present)), *ops, if_rev=if_rev, **data) + ) + except DocumentNotFound: + if not merge.parents: + raise + raw = await self.manager.find(trusted(base)).raw().first() + if raw is None: + raise + revision = next((f for f in meta.fields.values() if f.has(Revision)), None) + if if_rev is not None and revision is not None and (raw.get(revision.stored) or 0) != if_rev: + raise StaleDocumentError( + f"{self.model.__name__}: revision {if_rev} is no longer current" + ) from None + from mongospecs_web.errors import Unprocessable + + errors = [ + { + "field": client_path, + "message": "does not exist yet; send all of its required fields: " + ", ".join(required), + } + for path, (client_path, required) in merge.parents.items() + if not isinstance(_dig(raw, meta.stored_path(path)), dict) + ] + raise Unprocessable("The request body failed validation.", errors=errors or None) from None + except OperationFailed as exc: + if exc.code != _PATH_NOT_VIABLE: + raise + from mongospecs_web.errors import Unprocessable - target = trusted(query._user_filter()) - return t.cast(T, await self.manager.update(target, if_rev=if_rev, **data)) + raise Unprocessable( + "Part of the request addresses an embedded object that is stored as null. " + "Send that object whole, with all of its required fields." + ) from None async def delete(self, id: t.Any, *, scope: t.Any, if_rev: int | None = None) -> None: manager = self.manager diff --git a/packages/mongospecs-web/src/mongospecs_web/merge.py b/packages/mongospecs-web/src/mongospecs_web/merge.py new file mode 100644 index 0000000..03f6281 --- /dev/null +++ b/packages/mongospecs-web/src/mongospecs_web/merge.py @@ -0,0 +1,225 @@ +"""PATCH on embedded objects: JSON Merge Patch semantics, bounded by field policy. + +A ``PATCH`` body that gives an object for an embedded model does not replace the +stored object. Each key it names is written on its own (``$set`` on a dotted +path, all in one atomic update), ``null`` clears a key, and keys it does not name +are left alone. That is RFC 7396 for objects; lists and dicts are still replaced +whole, as the RFC says. + +The reason is not convenience. Replacing the object would write the model's +defaults over every value the client did not send, including the ones it is not +allowed to send: private fields, fields with ``Policy(update=False)``, immutable +ones. A client must not be able to clear what it cannot set, so: + +* a nested key the client may not write is rejected, with its dotted path; +* an embedded object that holds such values cannot be set to ``null``; +* a list or dict of embedded objects that hold such values cannot be replaced + through ``PATCH`` at all (there is no way to merge into a list item). +""" + +from __future__ import annotations + +import typing as t +from dataclasses import dataclass, field + +from mongospecs import Document, Immutable, Ref, ValidationError +from mongospecs.codec import _UNION_ORIGINS, FieldInfo, _codec_or_none, codec_for, to_bson + +__all__ = ["PatchPlan", "holds_protected", "nested_create_errors", "plan_patch", "single_model"] + + +@dataclass +class PatchPlan: + """A validated PATCH, split into whole-field values and writes inside embedded objects.""" + + fields: dict[str, t.Any] = field(default_factory=dict) + """Top-level fields given a whole new value, by attribute name.""" + set: dict[str, t.Any] = field(default_factory=dict) + """Dotted path (attribute name, then stored keys) -> BSON-ready value.""" + unset: list[str] = field(default_factory=list) + parents: dict[str, tuple[str, tuple[str, ...]]] = field(default_factory=dict) + """Dotted paths that must already hold an object, because the patch does not supply every + required field of it: path -> (path as the client wrote it, required field names).""" + + @property + def nested(self) -> bool: + return bool(self.set or self.unset or self.parents) + + +def single_model(tp: t.Any) -> type | None: + """The embedded model class when ``tp`` is exactly one (optionally ``| None``), else ``None``.""" + while t.get_origin(tp) is t.Annotated: + tp = t.get_args(tp)[0] + if t.get_origin(tp) in _UNION_ORIGINS: + members = [a for a in t.get_args(tp) if a is not type(None)] + return single_model(members[0]) if len(members) == 1 else None + if isinstance(tp, type) and t.get_origin(tp) is None and not issubclass(tp, Document): + return tp if _codec_or_none(tp) is not None else None + return None + + +def _may(info: FieldInfo, action: str, explicit: bool) -> bool: + if action == "create": + return info.policy.insert is True if explicit else info.policy.can_insert + if info.has(Immutable): + return False + return info.policy.update is True if explicit else info.policy.can_update + + +def _fields(cls: type) -> dict[str, tuple[str, FieldInfo]]: + """Every key a client may use for a field of ``cls`` -> (attribute name, field).""" + codec = codec_for(cls) + out: dict[str, tuple[str, FieldInfo]] = {} + for name, native, _, _ in codec.raw_fields(cls): + info = codec.fields(cls)[name] + for key in (name, native, info.stored): + out.setdefault(key, (name, info)) + return out + + +def _protected(cls: type, explicit: bool, seen: set[type]) -> bool: + """True when ``cls`` (or a model embedded in it) has a field a client cannot update.""" + if cls in seen: + return False + seen.add(cls) + for info in codec_for(cls).fields(cls).values(): + if info.policy.private or not _may(info, "update", explicit): + return True + if holds_protected(info.type, explicit, seen): + return True + return False + + +def holds_protected(tp: t.Any, explicit: bool, seen: set[type] | None = None) -> bool: + """True when a value of type ``tp`` can contain something a client cannot update.""" + seen = set() if seen is None else seen + while t.get_origin(tp) is t.Annotated: + tp = t.get_args(tp)[0] + if t.get_origin(tp) is Ref: + return False + if isinstance(tp, type) and t.get_origin(tp) is None: + if issubclass(tp, Document) or _codec_or_none(tp) is None: + return False + return _protected(tp, explicit, seen) + return any(holds_protected(arg, explicit, seen) for arg in t.get_args(tp)) + + +def nested_create_errors( + tp: t.Any, value: t.Any, path: str, explicit: bool, out: list[dict[str, t.Any]] +) -> None: + """On create: keys inside embedded objects that the client may not set.""" + while t.get_origin(tp) is t.Annotated: + tp = t.get_args(tp)[0] + origin = t.get_origin(tp) + if origin is Ref: + return + if origin is None: + cls = single_model(tp) + if cls is None or not isinstance(value, dict): + return + fields = _fields(cls) + for key, item in value.items(): + hit = fields.get(key) + if hit is None or hit[1].policy.private: + continue # unknown keys are the model's to reject; private ones are reported already + if not _may(hit[1], "create", explicit): + out.append({"field": f"{path}.{key}", "message": "cannot be set on create"}) + else: + nested_create_errors(hit[1].type, item, f"{path}.{key}", explicit, out) + return + args = [a for a in t.get_args(tp) if a is not type(None)] + if origin in _UNION_ORIGINS: + for arg in args: + nested_create_errors(arg, value, path, explicit, out) + elif isinstance(value, list) and args: + for index, item in enumerate(value): + nested_create_errors(args[0], item, f"{path}.{index}", explicit, out) + elif isinstance(value, dict) and len(args) == 2: + for key, item in value.items(): + nested_create_errors(args[1], item, f"{path}.{key}", explicit, out) + + +def plan_patch( + fields: t.Mapping[str, FieldInfo], data: t.Mapping[str, t.Any], explicit: bool +) -> tuple[PatchPlan, list[dict[str, t.Any]]]: + """Split a PATCH body (already checked at the top level) into a :class:`PatchPlan`. + + Returns the plan and the list of field errors; the plan is only meaningful when + there are none. + """ + plan = PatchPlan() + errors: list[dict[str, t.Any]] = [] + for key, value in data.items(): + info = fields[key] + cls = single_model(info.type) + if cls is not None and isinstance(value, dict): + _merge(cls, value, key, key, plan, errors, explicit) + elif cls is not None and value is None and _protected(cls, explicit, set()): + errors.append({"field": key, "message": _CANNOT_REMOVE}) + elif cls is None and holds_protected(info.type, explicit): + errors.append({"field": key, "message": _CANNOT_REPLACE}) + else: + plan.fields[key] = value + return plan, errors + + +_CANNOT_REMOVE = "cannot be removed: it holds values only the server sets" +_CANNOT_REPLACE = ( + "cannot be replaced through PATCH: its items hold values only the server sets, " + "and replacing them would clear those" +) + + +def _merge( + cls: type, + value: dict[str, t.Any], + client_path: str, + path: str, + plan: PatchPlan, + errors: list[dict[str, t.Any]], + explicit: bool, +) -> None: + codec = codec_for(cls) + fields = _fields(cls) + defaults = codec.defaults(cls) + absent = codec.absent_when_none(cls) + supplied: set[str] = set() + for key, item in value.items(): + where = f"{client_path}.{key}" + hit = fields.get(key) + if hit is None or hit[1].policy.private: + errors.append({"field": where, "message": "unknown field"}) + continue + name, info = hit + if not _may(info, "update", explicit): + errors.append({"field": where, "message": "cannot be set on update"}) + continue + supplied.add(name) + target = f"{path}.{info.stored}" + inner = single_model(info.type) + if inner is not None and isinstance(item, dict): + _merge(inner, item, where, target, plan, errors, explicit) + elif item is None: + if inner is not None and _protected(inner, explicit, set()): + errors.append({"field": where, "message": _CANNOT_REMOVE}) + elif name in defaults and defaults[name] is None: + if info.stored in absent: + plan.unset.append(target) # not stored while None + else: + plan.set[target] = None # StoreNone: an explicit null + else: + errors.append({"field": where, "message": "may not be null"}) + elif inner is None and holds_protected(info.type, explicit): + errors.append({"field": where, "message": _CANNOT_REPLACE}) + else: + try: + plan.set[target] = to_bson(codec.validate_field(cls, name, item)) + except ValidationError as exc: + message = exc.errors[0]["message"] if exc.errors else str(exc) + errors.append({"field": where, "message": message}) + required = tuple( + name for name, info in codec.fields(cls).items() if info.required and name not in supplied + ) + if required: + # Without these the object cannot be created by this patch, so it has to exist already. + plan.parents[path] = (client_path, required) diff --git a/packages/mongospecs-web/src/mongospecs_web/policy.py b/packages/mongospecs-web/src/mongospecs_web/policy.py index 1d1da57..e17ac99 100644 --- a/packages/mongospecs-web/src/mongospecs_web/policy.py +++ b/packages/mongospecs-web/src/mongospecs_web/policy.py @@ -359,6 +359,10 @@ def check_input(self, data: t.Any, allowed: frozenset[str], action: str) -> dict hidden: list[str] = [] nested_violations(value, plan, key, hidden) errors.extend({"field": path, "message": "unknown field"} for path in hidden) + if action == "create": + from mongospecs_web.merge import nested_create_errors + + nested_create_errors(self.meta.fields[key].type, value, key, self.explicit_writes, errors) if _has_oversized_int(value) and not _takes_any_number(self.meta.fields[key].type): errors.append( {"field": key, "message": f"integers must be between {INT64_MIN} and {INT64_MAX}"} @@ -367,6 +371,66 @@ def check_input(self, data: t.Any, allowed: frozenset[str], action: str) -> dict raise Unprocessable("The request body failed validation.", errors=errors) return data + def plan_patch(self, data: t.Mapping[str, t.Any]) -> t.Any: + """Turn a checked PATCH body into a :class:`~mongospecs_web.merge.PatchPlan`. + + An object given for an embedded model is merged into the stored one, key by key; + see :mod:`mongospecs_web.merge` for the rules and the reason. + """ + from mongospecs_web.errors import Unprocessable + from mongospecs_web.merge import plan_patch + + plan, errors = plan_patch(self.meta.fields, data, self.explicit_writes) + if errors: + raise Unprocessable("The request body failed validation.", errors=errors) + return plan + + def _merge_schema(self, node: t.Any, defs: dict[str, t.Any]) -> t.Any: + """The PATCH form of a property schema: an embedded object becomes a partial object. + + References reached directly or through ``anyOf`` (an optional object) point at + ``Patch``; those inside ``items`` or ``additionalProperties`` are left alone, + because lists and dicts are replaced whole. + """ + if not isinstance(node, dict): + return node + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith("#/$defs/"): + name = ref.rsplit("/", 1)[1] + return {**node, "$ref": f"#/$defs/{self._patch_def(name, defs)}"} + out = dict(node) + for key in _MERGE_KEYS: + if isinstance(node.get(key), list): + out[key] = [self._merge_schema(item, defs) for item in node[key]] + return out + + def _patch_def(self, name: str, defs: dict[str, t.Any]) -> str: + from mongospecs_web.merge import _fields, _may + + patch_name = f"{name}Patch" + if patch_name in defs or name not in defs: + return patch_name if patch_name in defs else name + classes = { + cls.__name__: cls for info in self.meta.fields.values() for cls in embedded_models(info.type) + } + cls = classes.get(name) + if cls is None: + return name + writable = { + key + for key, (_, info) in _fields(cls).items() + if not info.policy.private and _may(info, "update", self.explicit_writes) + } + full = defs[name] + defs[patch_name] = partial = {k: v for k, v in full.items() if k not in ("required", "title")} + partial["properties"] = { + key: self._merge_schema(value, defs) + for key, value in full.get("properties", {}).items() + if key in writable + } + partial["additionalProperties"] = False + return patch_name + # -- schemas ---------------------------------------------------------------- def _base(self) -> dict[str, t.Any]: @@ -425,12 +489,20 @@ def schema(self, kind: t.Literal["read", "create", "patch"]) -> dict[str, t.Any] schema["required"] = [n for n in required if n in allowed] if not schema.get("required"): schema.pop("required", None) - defs = _reachable(schema, base.get("$defs", {})) + all_defs: dict[str, t.Any] = dict(base.get("$defs", {})) + if kind == "patch": + schema["properties"] = { + name: self._merge_schema(node, all_defs) for name, node in schema["properties"].items() + } + defs = _reachable(schema, all_defs) if defs: schema["$defs"] = defs return schema +_MERGE_KEYS = ("anyOf", "oneOf", "allOf") + + def _child(value: t.Any, include: t.Collection[str]) -> t.Any: if value is None: return None diff --git a/packages/mongospecs-web/src/mongospecs_web/resource.py b/packages/mongospecs-web/src/mongospecs_web/resource.py index 92ff515..6d3dc90 100644 --- a/packages/mongospecs-web/src/mongospecs_web/resource.py +++ b/packages/mongospecs-web/src/mongospecs_web/resource.py @@ -561,8 +561,17 @@ async def handler(ctx: Ctx[T]) -> Response: if body is None: raise Unprocessable("A JSON request body is required.") data = self.view.check_input(body, self.view.updatable, "update") + plan = self.view.plan_patch(data) if_rev = _if_match(ctx.request.headers.get("if-match"), self.view.revision is not None) - doc = await self.backend.patch(ctx.id, dict(data), scope=await ctx.scope(), if_rev=if_rev) + if plan.nested: + # Objects given for embedded models are merged, not replaced. + if not getattr(self.backend, "supports_merge", False): + raise Unprocessable("This resource cannot patch part of an embedded object.") + doc = await t.cast(t.Any, self.backend).patch( + ctx.id, plan.fields, scope=await ctx.scope(), if_rev=if_rev, merge=plan + ) + else: + doc = await self.backend.patch(ctx.id, plan.fields, scope=await ctx.scope(), if_rev=if_rev) ctx.state["result"] = doc headers: dict[str, str] = {} etag = self.view.etag(doc) diff --git a/packages/mongospecs-web/src/mongospecs_web/testing.py b/packages/mongospecs-web/src/mongospecs_web/testing.py index 18c3a5f..17b027a 100644 --- a/packages/mongospecs-web/src/mongospecs_web/testing.py +++ b/packages/mongospecs-web/src/mongospecs_web/testing.py @@ -167,7 +167,17 @@ async def create(self, data: dict[str, t.Any]) -> T: self.docs[doc.id] = doc return t.cast(T, doc) - async def patch(self, id: t.Any, data: dict[str, t.Any], *, scope: t.Any, if_rev: int | None = None) -> T: + supports_merge = True + + async def patch( + self, + id: t.Any, + data: dict[str, t.Any], + *, + scope: t.Any, + if_rev: int | None = None, + merge: t.Any = None, + ) -> T: doc = self._find(id, scope) if doc is None: raise DocumentNotFound("not found") @@ -175,6 +185,26 @@ async def patch(self, id: t.Any, data: dict[str, t.Any], *, scope: t.Any, if_rev if revision is not None and if_rev is not None and getattr(doc, revision) != if_rev: raise StaleDocumentError("stale") values = {k: self.meta.codec.validate_field(self.model, k, v) for k, v in data.items()} + if merge is not None and merge.nested: + # Apply the nested writes to the encoded document and read the touched fields back. + encoded = self.meta.encode(doc) + for path, (client_path, required) in merge.parents.items(): + if not isinstance(_walk(encoded, self.meta.stored_path(path)), dict): + from mongospecs_web.errors import Unprocessable + + message = "does not exist yet; send all of its required fields: " + ", ".join(required) + raise Unprocessable( + "The request body failed validation.", + errors=[{"field": client_path, "message": message}], + ) + for path, value in merge.set.items(): + _put(encoded, self.meta.stored_path(path), value) + for path in merge.unset: + _put(encoded, self.meta.stored_path(path), _REMOVE) + merged = self.meta.decode(encoded) + for path in (*merge.set, *merge.unset): + head = path.partition(".")[0] + values[head] = getattr(merged, head) if values: before = {k: getattr(doc, k) for k in values} for key, value in values.items(): @@ -199,6 +229,27 @@ async def delete(self, id: t.Any, *, scope: t.Any, if_rev: int | None = None) -> del self.docs[t.cast(t.Any, doc).id] +_REMOVE: t.Any = object() + + +def _walk(doc: t.Any, path: str) -> t.Any: + for part in path.split("."): + if not isinstance(doc, dict): + return None + doc = doc.get(part) + return doc + + +def _put(doc: dict[str, t.Any], path: str, value: t.Any) -> None: + *parents, last = path.split(".") + for part in parents: + doc = doc.setdefault(part, {}) + if value is _REMOVE: + doc.pop(last, None) + else: + doc[last] = value + + # -- the contract API ---------------------------------------------------------------- diff --git a/packages/mongospecs-web/tests/test_patch_embedded.py b/packages/mongospecs-web/tests/test_patch_embedded.py new file mode 100644 index 0000000..2678bb7 --- /dev/null +++ b/packages/mongospecs-web/tests/test_patch_embedded.py @@ -0,0 +1,261 @@ +"""PATCH on embedded objects merges; values only the server sets can never be cleared or changed.""" + +import json +import typing as t + +import pytest + +from mongospecs import Immutable, Policy, Revision, SpecConfig, StoreNone +from mongospecs_web import Api, Create, Get, Patch, Resource + +BACKENDS = ("msgspec", "pydantic", "attrs") +JSON = {"content-type": "application/json"} + + +def models(backend): + if backend == "msgspec": + import msgspec + + from mongospecs.msgspec import Spec + + class Base(msgspec.Struct, kw_only=True): + pass + + define = lambda c: c # noqa: E731 + elif backend == "pydantic": + import pydantic + + from mongospecs.pydantic import Spec + + Base = pydantic.BaseModel + define = lambda c: c # noqa: E731 + else: + import attrs + + from mongospecs.attrs import Spec + + Base = object + define = attrs.define(kw_only=True) + + @define + class Geo(Base): + lat: float = 0.0 + surveyor: t.Annotated[str | None, Policy(private=True)] = None + + @define + class Addr(Base): + city: str # required + zip: str | None = None + note: t.Annotated[str | None, StoreNone] = None + vault_code: t.Annotated[str, Policy(private=True)] = "" + verified: t.Annotated[bool, Policy(insert=False, update=False)] = False + code: t.Annotated[str, Immutable] = "" + geo: Geo | None = None + + @define + class Tag(Base): + label: str = "" + weight: int = 0 + + @define + class Person(Spec): + __spec__ = SpecConfig(collection="patch_persons") + name: str = "" + address: Addr | None = None + history: list[Addr] = [] # noqa: RUF012 + tag: Tag | None = None + tags: list[Tag] = [] # noqa: RUF012 + rev: t.Annotated[int, Revision] = 0 + + return Geo, Addr, Tag, Person + + +@pytest.fixture(params=BACKENDS) +async def world(request, make_registry): + Geo, Addr, Tag, Person = models(request.param) + events = [] + await make_registry(Person, on_command=events.append) + api = Api(Resource(Person, name="persons", ops=[Get, Create, Patch]), prefix="/v1") + + async def call(method, path, body=None, headers=None): + response = await api.handle( + method, + path, + body=json.dumps(body).encode() if body is not None else b"", + headers={**JSON, **(headers or {})}, + ) + return response.status, json.loads(response.body) if response.body else None + + async def person(**extra): + doc = await Person( + name="ann", + address=Addr( + city="Oslo", + zip="0150", + note="n", + vault_code="vault", + verified=True, + code="C1", + geo=Geo(lat=1.0, surveyor="sv"), + ), + history=[Addr(city="Old", vault_code="old-vault")], + tag=Tag(label="t", weight=1), + tags=[Tag(label="a"), Tag(label="b")], + **extra, + ).insert() + return doc, f"/v1/persons/{doc.id}" + + async def stored(doc): + return await Person.collection().find_one({"_id": doc.id}) + + return Person, api, call, person, stored, events + + +async def test_patching_an_embedded_object_merges_and_keeps_server_values(world): + Person, api, call, person, stored, events = world + doc, url = await person() + events.clear() + status, body = await call("PATCH", url, {"address": {"zip": "9999"}}) + assert status == 200, body + writes = [e for e in events if e.phase == "started" and e.command_name != "endSessions"] + raw = (await stored(doc))["address"] + assert raw["zip"] == "9999" and raw["city"] == "Oslo" # the rest of the object is untouched + assert raw["vault_code"] == "vault" and raw["verified"] is True and raw["code"] == "C1" + assert raw["geo"] == {"lat": 1.0, "surveyor": "sv"} + assert body["data"]["address"]["zip"] == "9999" and body["data"]["address"]["city"] == "Oslo" + assert "vault_code" not in body["data"]["address"] + # one atomic write with dotted paths, and one revision + assert [e.command_name for e in writes] == ["findAndModify"] + assert writes[0].command["update"]["$set"]["address.zip"] == "9999" + assert not any(key == "address" for key in writes[0].command["update"]["$set"]) # never the whole object + assert body["data"]["rev"] == 1 + # deeper: a nested object inside the embedded one merges too + status, body = await call("PATCH", url, {"name": "anna", "address": {"geo": {"lat": 5}}}) + assert status == 200 and body["data"]["name"] == "anna" + assert (await stored(doc))["address"]["geo"] == {"lat": 5.0, "surveyor": "sv"} + + +async def test_null_inside_a_merge_follows_the_fields_mode(world): + Person, api, call, person, stored, events = world + doc, url = await person() + status, body = await call("PATCH", url, {"address": {"zip": None, "note": None}}) + assert status == 200, body + raw = (await stored(doc))["address"] + assert "zip" not in raw # left out while None + assert "note" in raw and raw["note"] is None # StoreNone: an explicit null + assert body["data"]["address"]["zip"] is None and body["data"]["address"]["note"] is None + status, body = await call("PATCH", url, {"address": {"city": None}}) + assert status == 422 and body["errors"][0]["field"] == "address.city" + assert (await stored(doc))["address"]["city"] == "Oslo" + + +@pytest.mark.parametrize( + ("patch", "field", "message"), + [ + ({"address": {"nope": 1}}, "address.nope", "unknown field"), + ({"address": {"vault_code": "x"}}, "address.vault_code", "unknown field"), + ({"address": {"geo": {"surveyor": "me"}}}, "address.geo.surveyor", "unknown field"), + ({"address": {"verified": False}}, "address.verified", "cannot be set on update"), + ({"address": {"code": "C2"}}, "address.code", "cannot be set on update"), + ({"address": {"geo": {"lat": "north"}}}, "address.geo.lat", None), + ({"address": None}, "address", "cannot be removed"), + ({"address": {"geo": None}}, "address.geo", "cannot be removed"), + ({"history": [{"city": "New"}]}, "history", "cannot be replaced"), + ({"history": []}, "history", "cannot be replaced"), + ], +) +async def test_a_client_can_never_clear_or_change_a_server_value(world, patch, field, message): + Person, api, call, person, stored, events = world + doc, url = await person() + before = await stored(doc) + status, body = await call("PATCH", url, {"name": "changed", **patch}) + assert status == 422, body + errors = {e["field"]: e["message"] for e in body["errors"]} + assert field in errors, errors + if message: + assert message in errors[field] + assert await stored(doc) == before # nothing was written, not even the valid part + + +async def test_objects_without_server_values_can_be_replaced_or_removed(world): + Person, api, call, person, stored, events = world + doc, url = await person() + status, body = await call("PATCH", url, {"tags": [{"label": "only", "weight": 3}], "tag": {"weight": 9}}) + assert status == 200, body + raw = await stored(doc) + assert raw["tags"] == [{"label": "only", "weight": 3}] # a list is replaced whole + assert raw["tag"] == {"label": "t", "weight": 9} # an object is merged + status, body = await call("PATCH", url, {"tag": None, "tags": []}) + assert status == 200 and body["data"]["tag"] is None and body["data"]["tags"] == [] + assert "tag" not in await stored(doc) + + +async def test_merging_into_an_object_that_does_not_exist_yet(world): + Person, api, call, person, stored, events = world + bare = await Person(name="bare").insert() + url = f"/v1/persons/{bare.id}" + status, body = await call("PATCH", url, {"address": {"zip": "1"}}) + assert status == 422, body # city is required and there is nothing to merge into + assert body["errors"][0]["field"] == "address" and "city" in body["errors"][0]["message"] + assert "address" not in await stored(bare) + status, body = await call("PATCH", url, {"address": {"city": "Bergen", "zip": "1"}}) + assert status == 200 and body["data"]["address"]["city"] == "Bergen" + assert (await Person.get(bare.id)).address.zip == "1" + assert (await call("PATCH", "/v1/persons/000000000000000000000000", {"address": {"zip": "1"}}))[0] == 404 + + +async def test_merge_respects_if_match(world): + Person, api, call, person, stored, events = world + doc, url = await person() + assert (await call("PATCH", url, {"address": {"zip": "1"}}, {"if-match": '"7"'}))[0] == 412 + status, body = await call("PATCH", url, {"address": {"zip": "1"}}, {"if-match": '"0"'}) + assert status == 200 and body["data"]["rev"] == 1 + bare = await Person(name="bare").insert() + assert (await call("PATCH", f"/v1/persons/{bare.id}", {"address": {"zip": "1"}}, {"if-match": '"3"'}))[ + 0 + ] == 412 + + +async def test_create_rejects_nested_fields_the_client_may_not_set(world): + Person, api, call, person, stored, events = world + status, body = await call( + "POST", "/v1/persons", {"name": "n", "address": {"city": "x", "verified": True}} + ) + assert ( + status == 422 + and {"field": "address.verified", "message": "cannot be set on create"} in body["errors"] + ) + status, body = await call( + "POST", "/v1/persons", {"name": "n", "history": [{"city": "x", "verified": True}]} + ) + assert status == 422 and body["errors"][0]["field"] == "history.0.verified" + status, body = await call("POST", "/v1/persons", {"name": "n", "address": {"city": "x", "code": "C9"}}) + assert status == 201 and body["data"]["address"]["code"] == "C9" # immutable fields are set on create + assert await Person.count() == 1 + + +async def test_the_patch_schema_describes_a_partial_object(world): + Person, api, call, person, stored, events = world + schemas = api.openapi()["components"]["schemas"] + patch = ( + schemas["PersonPatch"] + if "PersonPatch" in schemas + else next( + v for k, v in schemas.items() if k.endswith("Patch") and "address" in v.get("properties", {}) + ) + ) + text = json.dumps(patch["properties"]["address"]) + assert "AddrPatch" in text + partial = next(v for k, v in schemas.items() if k.endswith("AddrPatch")) + assert "required" not in partial # every key is optional in a merge + assert set(partial["properties"]) == { + "city", + "zip", + "note", + "geo", + } # no private, read-only or immutable keys + assert "GeoPatch" in json.dumps(partial["properties"]["geo"]) + # a list of embedded objects is replaced whole, so its items keep the full schema + assert "AddrPatch" not in json.dumps(patch["properties"].get("history", {})) + full = next(v for k, v in schemas.items() if k.endswith("_Addr")) + assert "city" in full.get("required", []) From ecb42e652774132ee903174c77c887220b23b727 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 10:04:36 -0400 Subject: [PATCH 25/31] feat(web): Put operation and Resource(merge_patch=False) PATCH merging into embedded objects (A37) had no way out for resources that need the embedded model validated whole, need to write over a stored null, or use a backend without supports_merge. - Resource(merge_patch=False): PATCH replaces an embedded object with the one sent. Values the client cannot send go back to their defaults; nested keys the policy closes are still rejected. - Put: PUT /{id} replaces every field the client may update (the sent value, or the default when left out) and leaves the rest as stored, in one atomic update with the If-Match check. An embedded object holding protected values is replaced key by key; one without is replaced whole. It never creates, and is not in the default ops. - OpenAPI gains Put schemas where a resource has the operation. Recorded as A39 in the plan. Co-Authored-By: Claude Opus 5.5 --- claudedocs/v2-plan.md | 1 + docs/parity.md | 2 +- docs/web/resources.md | 46 ++- .../src/mongospecs_web/__init__.py | 2 + .../src/mongospecs_web/merge.py | 115 ++++++- .../src/mongospecs_web/openapi.py | 12 +- .../src/mongospecs_web/policy.py | 90 +++-- .../src/mongospecs_web/resource.py | 80 +++-- .../tests/test_put_and_replace.py | 308 ++++++++++++++++++ 9 files changed, 595 insertions(+), 61 deletions(-) create mode 100644 packages/mongospecs-web/tests/test_put_and_replace.py diff --git a/claudedocs/v2-plan.md b/claudedocs/v2-plan.md index 5286399..c2f9e73 100644 --- a/claudedocs/v2-plan.md +++ b/claudedocs/v2-plan.md @@ -890,6 +890,7 @@ Recorded per §7.1 rule 4. Each one names what changed and why; none invalidates | A36 | §4.3 | Cursor pagination handles documents without a value for a sort key (first ascending, last descending) and builds the cursor from stored values, not model defaults | Found while testing raw reads: such documents were skipped or ended the walk. Became common once None was no longer stored (A30) | | A37 | §4.8 | HTTP `PATCH` uses JSON Merge Patch semantics for embedded objects (per-key dotted `$set`/`$unset` in one atomic update); lists and dicts still replace whole. Nested keys are held to the embedded model's own `Policy`. An embedded object holding protected values cannot be nulled, and a list or dict of such objects cannot be replaced through `PATCH` (422). `Policy(insert=False)` is enforced on nested keys at create. Supersedes the note in A34 | Decision delegated by Jay. Options weighed: (a) document only, rejected because a client could clear private and read-only values by omitting them, which no documentation fixes; (c) per-field opt-in, rejected because the safe behaviour would be off by default; (d) reject any patch of an object with protected values, rejected because such objects would be unpatchable. (b) merge is what RFC 7396 specifies for objects, is atomic, and removes the hazard instead of fencing it; (d) is kept only where merge has no meaning (list items, null on the whole object). Cost: PATCH on an embedded object is now partial, a behaviour change for clients that relied on replacement; a merge into a missing object needs its required fields; model-level cross-field validators of the embedded model do not run on a merge, as they already do not for top-level PATCH | | A38 | §4.2 | BSON-only values in untyped fields (`Any`) serialise to JSON on all backends: ObjectId and UUID as strings, dates as ISO 8601, Decimal128 as a string, binary as base64, Int64 as a number, Timestamp as a date, Regex as its pattern | Reported: a 500 on pydantic for any ObjectId, and on msgspec/attrs for Binary, Timestamp, Regex and Int64 | +| A39 | §4.8 | `Put` operation and `Resource(merge_patch=False)`. `PUT /{id}` replaces every field the client may update (sent value, or the default when left out; a required field must be sent) and leaves the rest as stored, in one atomic update; an embedded object holding protected values is replaced key by key the same way, one without them is replaced whole. It never creates. `Put` is not in the default `ops`. `merge_patch=False` makes `PATCH` replace embedded objects as before A37; nested keys the policy closes are still rejected | Requested by Jay as the opt-out from A37 and its limits (validators of the embedded model see one key at a time on a merge, a stored null needs the whole object, a backend without `supports_merge` gets a 422). The flag is on the resource, not the model: it describes an HTTP verb, model code is unaffected, and embedded models do not inherit settings (A35). Cost of opting out, accepted by whoever sets it: values the client cannot send are reset to defaults on replacement. `Put` is opt-in because guards are keyed by operation, so adding it to the defaults would expose an unguarded write on resources that guard `Patch` | New node: diff --git a/docs/parity.md b/docs/parity.md index bc750d2..c246da6 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -66,7 +66,7 @@ was established: **docs** means it was re-read in the Mongoose documentation for | Populated paths replace the id with the document | A `Ref` keeps `.id` and gains `.doc` | One type whether or not it is loaded | | Arrays are tracked per element | A changed list is sent whole | Per-element intent is expressed with atomic operators | | `Model.watch()` errors out when it loses the primary | The change stream resumes across a step-down (the driver's behaviour, verified by a stress scenario) | Consumers should not need their own reconnect loop | -| Setting a nested object overwrites it (`doc.set('child', {...})`; `{merge: true}` merges) | Model code is the same: assigning an embedded object replaces it. An HTTP `PATCH` merges into embedded objects | A client must not clear values it is not allowed to set (private or read-only fields inside the object) | +| Setting a nested object overwrites it (`doc.set('child', {...})`; `{merge: true}` merges) | Model code is the same: assigning an embedded object replaces it. An HTTP `PATCH` merges into embedded objects; `Resource(merge_patch=False)` makes it overwrite, and `PUT` replaces the document | A client must not clear values it is not allowed to set (private or read-only fields inside the object) | | `syncIndexes()` drops every undeclared index | `apply_indexes()` only drops or recreates with `drop=True` | Dropping an index is not something a deploy should do by accident | | `orFail()` on an update throws when nothing was modified | `Model.update` raises when nothing matched; `find(...).update(...)` returns the matched and modified counts | A no-op update that matched is not an error | | `populate()` always runs a separate query per path | `include()` does the same by default and can use `$lookup` instead (`strategy="lookup"`) | Both give the same result; the faster one depends on fan-out, see the benchmarks | diff --git a/docs/web/resources.md b/docs/web/resources.md index 4e57bf8..d15b72e 100644 --- a/docs/web/resources.md +++ b/docs/web/resources.md @@ -46,7 +46,7 @@ registry = await Registry("mongodb://localhost/app", models=[Task]).open() | Policy | Meaning | |---|---| -| `insert=False` / `update=False` | not settable on create / on patch | +| `insert=False` / `update=False` | not settable on create / on patch and put | | `private=True` | never returned, filtered or sorted; not writable unless `insert`/`update` say so | | `filter=("eq", "gte", ...)` | operators allowed in the query string (`True` for all) | | `sort=True` | usable in `?sort=` | @@ -143,6 +143,7 @@ These are set so that a client cannot make the server do unbounded work. | `MongoBackend(max_time_ms=...)` | 10 s | Time limit for reads made for a request, when the registry and the model set none | | `Api(openapi=...)` | `True` | Serve `/openapi.json`; turn off to keep the description private | | `Resource(explicit_writes=...)` | `False` | When `True`, a field is writable only if its `Policy` says `insert=True` / `update=True` | +| `Resource(merge_patch=...)` | `True` | When `False`, `PATCH` replaces embedded objects instead of merging into them (see below) | An integer outside the 64-bit range MongoDB can store is a 422 with a field error, on create and on patch (a float or Decimal field takes it as that type). A guard listed in more than one place @@ -177,6 +178,49 @@ What a client cannot set, it cannot clear either: (there is no way to merge into a list item). Give such lists their own operation with `@resource.op`, where your code decides what happens to the server's values. +### Turning the merge off + +`Resource(Person, merge_patch=False)` makes `PATCH` replace an embedded object with the one sent, +as it did before the merge was introduced. Top-level fields are still partial. Choose it when: + +- the embedded model has validators that look at more than one field. A merge validates each key + on its own; a replacement validates the object as sent; +- clients should be able to write over an object stored as `null` without knowing that it is; +- the resource has its own backend that does not implement `supports_merge`. + +The cost is the reason the merge exists: every value in the object that the client did not send +goes back to its default, and that includes the values it is not allowed to send (private fields, +`update=False`, `Immutable`). Keep the merge for objects that carry such values. A request still +cannot *set* them: a nested key the policy closes is a 422 either way. The setting belongs to the +resource and not to the model, because it describes what an HTTP verb does; model code is not +affected by it, and two resources over one model can differ. + +## PUT + +`PUT` is not among the default operations; list it to have it: `ops=[List, Get, Create, Patch, Put, +Delete]`. Guards and hooks use the `Put` key, so it is not covered by what you registered for +`Patch`. + +``` +PUT /people/1 {"name": "Ann", "address": {"city": "Oslo"}} +``` + +`PUT /{id}` replaces everything the client may write, in one atomic update, with the same +`If-Match` check and revision bump as `PATCH`: + +- a field in the body takes that value; a writable field left out goes back to its default, and a + required one must be sent (422); +- fields the client may not update keep their stored values, and naming one is a 422, as with + `PATCH`. A body read with `GET` is therefore not accepted back unchanged: leave out `id`, the + revision and the other read-only fields; +- an embedded object with no such values inside is replaced whole and validated whole; +- an embedded object that does hold them is replaced key by key: its writable keys follow the two + rules above and the rest stay. Left out of the body, it is not touched. It cannot be set to + `null`, and a list or dict of such objects cannot be sent; +- the id comes from the URL, and a `PUT` to an id that does not exist is a 404. It never creates. + +OpenAPI gets a `PersonPut` schema (and `AddrPut` for an embedded object with protected values). + The Patch schema in OpenAPI describes embedded objects as partial (`AddrPatch`: no required keys, only the writable ones). Values stored in untyped fields (`Any`, `dict[str, Any]`) are served as JSON whatever they are: an ObjectId as its string, dates as ISO 8601, binary as base64. diff --git a/packages/mongospecs-web/src/mongospecs_web/__init__.py b/packages/mongospecs-web/src/mongospecs_web/__init__.py index ac54db2..d318f92 100644 --- a/packages/mongospecs-web/src/mongospecs_web/__init__.py +++ b/packages/mongospecs-web/src/mongospecs_web/__init__.py @@ -35,6 +35,7 @@ List, Operation, Patch, + Put, Request, Resource, Response, @@ -64,6 +65,7 @@ "Patch", "PayloadTooLarge", "PreconditionFailed", + "Put", "Request", "Resource", "ResourceBackend", diff --git a/packages/mongospecs-web/src/mongospecs_web/merge.py b/packages/mongospecs-web/src/mongospecs_web/merge.py index 03f6281..09f6be0 100644 --- a/packages/mongospecs-web/src/mongospecs_web/merge.py +++ b/packages/mongospecs-web/src/mongospecs_web/merge.py @@ -15,6 +15,12 @@ * an embedded object that holds such values cannot be set to ``null``; * a list or dict of embedded objects that hold such values cannot be replaced through ``PATCH`` at all (there is no way to merge into a list item). + +``PUT`` (:func:`plan_put`) is the same machinery asked a different question: every +field the client may write takes the value sent or goes back to its default, and +what the client cannot write stays as it is stored. ``Resource(merge_patch=False)`` +turns the merge off: ``PATCH`` then replaces embedded objects whole, and the values +a client cannot send are reset to their defaults. """ from __future__ import annotations @@ -25,7 +31,14 @@ from mongospecs import Document, Immutable, Ref, ValidationError from mongospecs.codec import _UNION_ORIGINS, FieldInfo, _codec_or_none, codec_for, to_bson -__all__ = ["PatchPlan", "holds_protected", "nested_create_errors", "plan_patch", "single_model"] +__all__ = [ + "PatchPlan", + "holds_protected", + "nested_write_errors", + "plan_patch", + "plan_put", + "single_model", +] @dataclass @@ -104,10 +117,10 @@ def holds_protected(tp: t.Any, explicit: bool, seen: set[type] | None = None) -> return any(holds_protected(arg, explicit, seen) for arg in t.get_args(tp)) -def nested_create_errors( - tp: t.Any, value: t.Any, path: str, explicit: bool, out: list[dict[str, t.Any]] +def nested_write_errors( + tp: t.Any, value: t.Any, path: str, explicit: bool, out: list[dict[str, t.Any]], action: str = "create" ) -> None: - """On create: keys inside embedded objects that the client may not set.""" + """Keys inside embedded objects, given whole, that the client may not set on ``action``.""" while t.get_origin(tp) is t.Annotated: tp = t.get_args(tp)[0] origin = t.get_origin(tp) @@ -122,33 +135,39 @@ def nested_create_errors( hit = fields.get(key) if hit is None or hit[1].policy.private: continue # unknown keys are the model's to reject; private ones are reported already - if not _may(hit[1], "create", explicit): - out.append({"field": f"{path}.{key}", "message": "cannot be set on create"}) + if not _may(hit[1], action, explicit): + out.append({"field": f"{path}.{key}", "message": f"cannot be set on {action}"}) else: - nested_create_errors(hit[1].type, item, f"{path}.{key}", explicit, out) + nested_write_errors(hit[1].type, item, f"{path}.{key}", explicit, out, action) return args = [a for a in t.get_args(tp) if a is not type(None)] if origin in _UNION_ORIGINS: for arg in args: - nested_create_errors(arg, value, path, explicit, out) + nested_write_errors(arg, value, path, explicit, out, action) elif isinstance(value, list) and args: for index, item in enumerate(value): - nested_create_errors(args[0], item, f"{path}.{index}", explicit, out) + nested_write_errors(args[0], item, f"{path}.{index}", explicit, out, action) elif isinstance(value, dict) and len(args) == 2: for key, item in value.items(): - nested_create_errors(args[1], item, f"{path}.{key}", explicit, out) + nested_write_errors(args[1], item, f"{path}.{key}", explicit, out, action) def plan_patch( - fields: t.Mapping[str, FieldInfo], data: t.Mapping[str, t.Any], explicit: bool + fields: t.Mapping[str, FieldInfo], data: t.Mapping[str, t.Any], explicit: bool, *, merge: bool = True ) -> tuple[PatchPlan, list[dict[str, t.Any]]]: """Split a PATCH body (already checked at the top level) into a :class:`PatchPlan`. Returns the plan and the list of field errors; the plan is only meaningful when - there are none. + there are none. With ``merge=False`` every value replaces the stored one whole; the + only check left is that the body does not name a nested key the client may not write. """ plan = PatchPlan() errors: list[dict[str, t.Any]] = [] + if not merge: + for key, value in data.items(): + nested_write_errors(fields[key].type, value, key, explicit, errors, "update") + plan.fields[key] = value + return plan, errors for key, value in data.items(): info = fields[key] cls = single_model(info.type) @@ -163,10 +182,51 @@ def plan_patch( return plan, errors +def plan_put( + model: type, + fields: t.Mapping[str, FieldInfo], + updatable: t.Collection[str], + data: t.Mapping[str, t.Any], + explicit: bool, +) -> tuple[PatchPlan, list[dict[str, t.Any]]]: + """Turn a PUT body (already checked at the top level) into a :class:`PatchPlan`. + + Every field the client may update is written: with the value sent, or with its default + when the body leaves it out (a required field has to be sent). Fields the client may not + update are not part of the plan, so they keep their stored values. The same holds inside + an embedded object that has such fields; one that has none is replaced whole. + """ + plan = PatchPlan() + errors: list[dict[str, t.Any]] = [] + defaults = codec_for(model).defaults(model) + for key, info in fields.items(): + if key not in updatable: + continue + cls = single_model(info.type) + guarded = holds_protected(info.type, explicit) + if key not in data: + if guarded: + continue # resetting it would clear what only the server sets + if info.required: + errors.append({"field": key, "message": "is required"}) + else: + plan.fields[key] = defaults.get(key) + elif not guarded: + plan.fields[key] = data[key] + elif cls is not None and isinstance(data[key], dict): + _merge(cls, data[key], key, key, plan, errors, explicit, replace=True) + elif cls is not None and data[key] is None: + errors.append({"field": key, "message": _CANNOT_REMOVE}) + elif cls is None: + errors.append({"field": key, "message": _CANNOT_REPLACE}) + else: + plan.fields[key] = data[key] # not an object: the model's own validation rejects it + return plan, errors + + _CANNOT_REMOVE = "cannot be removed: it holds values only the server sets" _CANNOT_REPLACE = ( - "cannot be replaced through PATCH: its items hold values only the server sets, " - "and replacing them would clear those" + "cannot be replaced: its items hold values only the server sets, and replacing them would clear those" ) @@ -178,7 +238,13 @@ def _merge( plan: PatchPlan, errors: list[dict[str, t.Any]], explicit: bool, + replace: bool = False, ) -> None: + """Plan the writes for one embedded object. + + With ``replace`` (PUT) the keys the client may write and did not send go back to their + defaults, and an object nested in this one is only walked into when it has values to keep. + """ codec = codec_for(cls) fields = _fields(cls) defaults = codec.defaults(cls) @@ -197,8 +263,10 @@ def _merge( supplied.add(name) target = f"{path}.{info.stored}" inner = single_model(info.type) + if replace and not holds_protected(info.type, explicit): + inner = None # nothing in it to keep: validated and written whole below if inner is not None and isinstance(item, dict): - _merge(inner, item, where, target, plan, errors, explicit) + _merge(inner, item, where, target, plan, errors, explicit, replace) elif item is None: if inner is not None and _protected(inner, explicit, set()): errors.append({"field": where, "message": _CANNOT_REMOVE}) @@ -217,6 +285,23 @@ def _merge( except ValidationError as exc: message = exc.errors[0]["message"] if exc.errors else str(exc) errors.append({"field": where, "message": message}) + if replace: + for name, info in codec.fields(cls).items(): + if name in supplied or info.policy.private or not _may(info, "update", explicit): + continue + if holds_protected(info.type, explicit): + continue + target = f"{path}.{info.stored}" + if info.required: + errors.append({"field": f"{client_path}.{name}", "message": "is required"}) + supplied.add(name) # reported here, not again as a missing parent + elif defaults.get(name) is None: + if info.stored in absent: + plan.unset.append(target) + else: + plan.set[target] = None + else: + plan.set[target] = to_bson(defaults[name]) required = tuple( name for name, info in codec.fields(cls).items() if info.required and name not in supplied ) diff --git a/packages/mongospecs-web/src/mongospecs_web/openapi.py b/packages/mongospecs-web/src/mongospecs_web/openapi.py index 6f11d78..5756d78 100644 --- a/packages/mongospecs-web/src/mongospecs_web/openapi.py +++ b/packages/mongospecs-web/src/mongospecs_web/openapi.py @@ -35,6 +35,7 @@ "get": ("404",), "create": ("409", "422"), "patch": ("404", "409", "412", "422"), + "put": ("404", "409", "412", "422"), "delete": ("404",), "custom": ("404", "422"), } @@ -65,12 +66,15 @@ def _rewrite_refs(node: t.Any, prefix: str) -> t.Any: def component_schemas(resource: Resource[t.Any]) -> dict[str, t.Any]: - """Read/Create/Patch schemas of a resource, plus the nested definitions they refer to.""" + """Read/Create/Patch schemas of a resource (and Put, when it has that operation), plus + the nested definitions they refer to.""" schemas: dict[str, t.Any] = {} prefix = resource.model.__name__ + "_" - kinds: tuple[t.Literal["read", "create", "patch"], ...] = ("read", "create", "patch") + kinds: list[t.Literal["read", "create", "patch", "put"]] = ["read", "create", "patch"] + if any(op.kind == "put" for op in resource.operations): + kinds.append("put") for kind in kinds: - schema = copy.deepcopy(resource.view.schema(kind)) + schema = copy.deepcopy(resource.view.schema(kind, merge=resource.merge_patch)) for name, definition in schema.pop("$defs", {}).items(): schemas[prefix + name] = _rewrite_refs(definition, prefix) schemas[resource.schema_name(kind)] = _rewrite_refs(schema, prefix) @@ -89,7 +93,7 @@ def operation_object(resource: Resource[t.Any], op: Operation, *, secured: bool) for name in op.path_parameters ] parameters.extend(op.query_parameters) - if op.kind == "patch" and resource.view.revision: + if op.kind in ("patch", "put") and resource.view.revision: parameters.append( { "name": "If-Match", diff --git a/packages/mongospecs-web/src/mongospecs_web/policy.py b/packages/mongospecs-web/src/mongospecs_web/policy.py index e17ac99..8338833 100644 --- a/packages/mongospecs-web/src/mongospecs_web/policy.py +++ b/packages/mongospecs-web/src/mongospecs_web/policy.py @@ -360,9 +360,9 @@ def check_input(self, data: t.Any, allowed: frozenset[str], action: str) -> dict nested_violations(value, plan, key, hidden) errors.extend({"field": path, "message": "unknown field"} for path in hidden) if action == "create": - from mongospecs_web.merge import nested_create_errors + from mongospecs_web.merge import nested_write_errors - nested_create_errors(self.meta.fields[key].type, value, key, self.explicit_writes, errors) + nested_write_errors(self.meta.fields[key].type, value, key, self.explicit_writes, errors) if _has_oversized_int(value) and not _takes_any_number(self.meta.fields[key].type): errors.append( {"field": key, "message": f"integers must be between {INT64_MIN} and {INT64_MAX}"} @@ -371,43 +371,58 @@ def check_input(self, data: t.Any, allowed: frozenset[str], action: str) -> dict raise Unprocessable("The request body failed validation.", errors=errors) return data - def plan_patch(self, data: t.Mapping[str, t.Any]) -> t.Any: + def plan_patch(self, data: t.Mapping[str, t.Any], *, merge: bool = True) -> t.Any: """Turn a checked PATCH body into a :class:`~mongospecs_web.merge.PatchPlan`. An object given for an embedded model is merged into the stored one, key by key; - see :mod:`mongospecs_web.merge` for the rules and the reason. + see :mod:`mongospecs_web.merge` for the rules and the reason. With ``merge=False`` + it replaces the stored object instead. """ from mongospecs_web.errors import Unprocessable from mongospecs_web.merge import plan_patch - plan, errors = plan_patch(self.meta.fields, data, self.explicit_writes) + plan, errors = plan_patch(self.meta.fields, data, self.explicit_writes, merge=merge) if errors: raise Unprocessable("The request body failed validation.", errors=errors) return plan - def _merge_schema(self, node: t.Any, defs: dict[str, t.Any]) -> t.Any: + def plan_put(self, data: t.Mapping[str, t.Any]) -> t.Any: + """Turn a checked PUT body into a :class:`~mongospecs_web.merge.PatchPlan` that + replaces everything the client may write and nothing else.""" + from mongospecs_web.errors import Unprocessable + from mongospecs_web.merge import plan_put + + plan, errors = plan_put(self.model, self.meta.fields, self.updatable, data, self.explicit_writes) + if errors: + raise Unprocessable("The request body failed validation.", errors=errors) + return plan + + def _merge_schema(self, node: t.Any, defs: dict[str, t.Any], suffix: str = "Patch") -> t.Any: """The PATCH form of a property schema: an embedded object becomes a partial object. References reached directly or through ``anyOf`` (an optional object) point at ``Patch``; those inside ``items`` or ``additionalProperties`` are left alone, - because lists and dicts are replaced whole. + because lists and dicts are replaced whole. With ``suffix="Put"`` it is the PUT form. """ if not isinstance(node, dict): return node ref = node.get("$ref") if isinstance(ref, str) and ref.startswith("#/$defs/"): name = ref.rsplit("/", 1)[1] - return {**node, "$ref": f"#/$defs/{self._patch_def(name, defs)}"} + return {**node, "$ref": f"#/$defs/{self._patch_def(name, defs, suffix)}"} out = dict(node) for key in _MERGE_KEYS: if isinstance(node.get(key), list): - out[key] = [self._merge_schema(item, defs) for item in node[key]] + out[key] = [self._merge_schema(item, defs, suffix) for item in node[key]] return out - def _patch_def(self, name: str, defs: dict[str, t.Any]) -> str: - from mongospecs_web.merge import _fields, _may + def _patch_def(self, name: str, defs: dict[str, t.Any], suffix: str = "Patch") -> str: + """Define ``Patch`` (every writable key, none required) or ``Put`` (the + writable keys of an object that also holds values only the server sets; the required + ones stay required). An object without such values has no Put form: it is sent whole.""" + from mongospecs_web.merge import _fields, _may, _protected, holds_protected, single_model - patch_name = f"{name}Patch" + patch_name = f"{name}{suffix}" if patch_name in defs or name not in defs: return patch_name if patch_name in defs else name classes = { @@ -416,18 +431,30 @@ def _patch_def(self, name: str, defs: dict[str, t.Any]) -> str: cls = classes.get(name) if cls is None: return name - writable = { - key - for key, (_, info) in _fields(cls).items() - if not info.policy.private and _may(info, "update", self.explicit_writes) - } + put = suffix == "Put" + if put and not _protected(cls, self.explicit_writes, set()): + return name + writable: set[str] = set() + kept: set[str] = set() # PUT: left as stored when the request does not send them + for key, (_, info) in _fields(cls).items(): + if info.policy.private or not _may(info, "update", self.explicit_writes): + continue + guarded = put and holds_protected(info.type, self.explicit_writes) + if guarded and single_model(info.type) is None: + continue # a list or dict of such objects cannot be replaced at all + writable.add(key) + if guarded: + kept.add(key) full = defs[name] defs[patch_name] = partial = {k: v for k, v in full.items() if k not in ("required", "title")} partial["properties"] = { - key: self._merge_schema(value, defs) + key: self._merge_schema(value, defs, suffix) for key, value in full.get("properties", {}).items() if key in writable } + required = [k for k in full.get("required", []) if k in writable and k not in kept] + if put and required: + partial["required"] = required partial["additionalProperties"] = False return patch_name @@ -463,8 +490,14 @@ def _base(self) -> dict[str, t.Any]: base = {**base, "$defs": defs} return base - def schema(self, kind: t.Literal["read", "create", "patch"]) -> dict[str, t.Any]: - """JSON Schema of one view. ``$defs`` of nested models are kept alongside.""" + def schema( + self, kind: t.Literal["read", "create", "patch", "put"], *, merge: bool = True + ) -> dict[str, t.Any]: + """JSON Schema of one view. ``$defs`` of nested models are kept alongside. + + ``merge=False`` gives the Patch view of a resource whose ``PATCH`` replaces embedded + objects instead of merging into them. + """ base = self._base() props: dict[str, t.Any] = base.get("properties", {}) required: list[str] = list(base.get("required", [])) @@ -490,10 +523,25 @@ def schema(self, kind: t.Literal["read", "create", "patch"]) -> dict[str, t.Any] if not schema.get("required"): schema.pop("required", None) all_defs: dict[str, t.Any] = dict(base.get("$defs", {})) - if kind == "patch": + if kind == "patch" and merge: schema["properties"] = { name: self._merge_schema(node, all_defs) for name, node in schema["properties"].items() } + elif kind == "put": + from mongospecs_web.merge import holds_protected, single_model + + # What holds values only the server sets is optional (left as stored when not + # sent); a list or dict of such objects cannot be sent at all. + types = {name: self.meta.fields[name].type for name in schema["properties"]} + kept = {name for name, tp in types.items() if holds_protected(tp, self.explicit_writes)} + schema["properties"] = { + name: self._merge_schema(node, all_defs, "Put") if name in kept else node + for name, node in schema["properties"].items() + if name not in kept or single_model(types[name]) is not None + } + schema["required"] = [n for n in required if n in allowed and n not in kept] + if not schema["required"]: + del schema["required"] defs = _reachable(schema, all_defs) if defs: schema["$defs"] = defs diff --git a/packages/mongospecs-web/src/mongospecs_web/resource.py b/packages/mongospecs-web/src/mongospecs_web/resource.py index 6d3dc90..da8438c 100644 --- a/packages/mongospecs-web/src/mongospecs_web/resource.py +++ b/packages/mongospecs-web/src/mongospecs_web/resource.py @@ -29,7 +29,19 @@ from mongospecs_web.filters import Cursor, Pagination, parse_includes, parse_list_query from mongospecs_web.policy import View, view_of -__all__ = ["Create", "Ctx", "Delete", "Get", "List", "Operation", "Patch", "Request", "Resource", "Response"] +__all__ = [ + "Create", + "Ctx", + "Delete", + "Get", + "List", + "Operation", + "Patch", + "Put", + "Request", + "Resource", + "Response", +] T = t.TypeVar("T") F = t.TypeVar("F", bound=t.Callable[..., t.Any]) @@ -41,6 +53,7 @@ Get = "get" Create = "create" Patch = "patch" +Put = "put" Delete = "delete" JSON = "application/json" @@ -212,12 +225,17 @@ def __init__( tags: t.Sequence[str] | None = None, server_fields: t.Callable[[Ctx[T]], t.Any] | None = None, explicit_writes: bool = False, + merge_patch: bool = True, ) -> None: self.model = model self.view: View = view_of(model, explicit_writes=explicit_writes) """With ``explicit_writes=True`` a field is writable only if its ``Policy`` says so (``Policy(insert=True)``, ``Policy(update=True)``); by default ordinary fields are writable unless marked otherwise.""" + self.merge_patch = merge_patch + """``PATCH`` merges an object given for an embedded model into the stored one. With + ``merge_patch=False`` it replaces the stored object: the embedded model is validated + whole, and every value in it that the client cannot send goes back to its default.""" self.name = name or self.view.meta.collection self.backend: ResourceBackend[T] = backend if backend is not None else MongoBackend(model) self.scope_fn = scope @@ -252,12 +270,13 @@ def __init__( Get: self._get_op, Create: self._create_op, Patch: self._patch_op, + Put: self._put_op, Delete: self._delete_op, } for kind in ops: if kind not in builders: raise ValueError( - f"unknown operation {kind!r}; use List, Get, Create, Patch, Delete or @resource.op" + f"unknown operation {kind!r}; use List, Get, Create, Patch, Put, Delete or @resource.op" ) self.operations.append(builders[kind]()) @@ -561,23 +580,7 @@ async def handler(ctx: Ctx[T]) -> Response: if body is None: raise Unprocessable("A JSON request body is required.") data = self.view.check_input(body, self.view.updatable, "update") - plan = self.view.plan_patch(data) - if_rev = _if_match(ctx.request.headers.get("if-match"), self.view.revision is not None) - if plan.nested: - # Objects given for embedded models are merged, not replaced. - if not getattr(self.backend, "supports_merge", False): - raise Unprocessable("This resource cannot patch part of an embedded object.") - doc = await t.cast(t.Any, self.backend).patch( - ctx.id, plan.fields, scope=await ctx.scope(), if_rev=if_rev, merge=plan - ) - else: - doc = await self.backend.patch(ctx.id, plan.fields, scope=await ctx.scope(), if_rev=if_rev) - ctx.state["result"] = doc - headers: dict[str, str] = {} - etag = self.view.etag(doc) - if etag: - headers["etag"] = etag - return Response.json({"data": self.view.serialize(doc)}, 200, headers) + return await self._write(ctx, self.view.plan_patch(data, merge=self.merge_patch)) return Operation( Patch, @@ -591,6 +594,45 @@ async def handler(ctx: Ctx[T]) -> Response: self._one(), ) + def _put_op(self) -> Operation: + async def handler(ctx: Ctx[T]) -> Response: + body = ctx.body() + if body is None: + raise Unprocessable("A JSON request body is required.") + data = self.view.check_input(body, self.view.updatable, "update") + return await self._write(ctx, self.view.plan_put(data)) + + return Operation( + Put, + "PUT", + "/{id}", + handler, + Put, + f"Replace one of {self.name}", + 200, + self._ref("put"), + self._one(), + ) + + async def _write(self, ctx: Ctx[T], plan: t.Any) -> Response: + """Apply a planned PATCH or PUT in one atomic update and return the document.""" + if_rev = _if_match(ctx.request.headers.get("if-match"), self.view.revision is not None) + if plan.nested: + # Writes inside an embedded object, leaving the rest of it as stored. + if not getattr(self.backend, "supports_merge", False): + raise Unprocessable("This resource cannot write part of an embedded object.") + doc = await t.cast(t.Any, self.backend).patch( + ctx.id, plan.fields, scope=await ctx.scope(), if_rev=if_rev, merge=plan + ) + else: + doc = await self.backend.patch(ctx.id, plan.fields, scope=await ctx.scope(), if_rev=if_rev) + ctx.state["result"] = doc + headers: dict[str, str] = {} + etag = self.view.etag(doc) + if etag: + headers["etag"] = etag + return Response.json({"data": self.view.serialize(doc)}, 200, headers) + def _delete_op(self) -> Operation: async def handler(ctx: Ctx[T]) -> None: if_rev = _if_match(ctx.request.headers.get("if-match"), self.view.revision is not None) diff --git a/packages/mongospecs-web/tests/test_put_and_replace.py b/packages/mongospecs-web/tests/test_put_and_replace.py new file mode 100644 index 0000000..0905b31 --- /dev/null +++ b/packages/mongospecs-web/tests/test_put_and_replace.py @@ -0,0 +1,308 @@ +"""PUT replaces what the client may write; ``merge_patch=False`` makes PATCH replace embedded objects.""" + +import json +import typing as t + +import pytest + +from mongospecs import Immutable, Policy, Revision, SpecConfig, StoreNone +from mongospecs_web import Api, Create, Get, Patch, Put, Resource + +BACKENDS = ("msgspec", "pydantic", "attrs") +JSON = {"content-type": "application/json"} + + +def models(backend): + if backend == "msgspec": + import msgspec + + from mongospecs.msgspec import Spec + + class Base(msgspec.Struct, kw_only=True): + pass + + define = lambda c: c # noqa: E731 + elif backend == "pydantic": + import pydantic + + from mongospecs.pydantic import Spec + + Base = pydantic.BaseModel + define = lambda c: c # noqa: E731 + else: + import attrs + + from mongospecs.attrs import Spec + + Base = object + define = attrs.define(kw_only=True) + + @define + class Geo(Base): + lat: float = 0.0 + surveyor: t.Annotated[str | None, Policy(private=True)] = None + + @define + class Addr(Base): + city: str # required + zip: str | None = None + note: t.Annotated[str | None, StoreNone] = None + vault_code: t.Annotated[str, Policy(private=True)] = "" + verified: t.Annotated[bool, Policy(insert=False, update=False)] = False + code: t.Annotated[str, Immutable] = "" + geo: Geo | None = None + + @define + class Tag(Base): + label: str = "" + weight: int = 0 + + @define + class Person(Spec): + __spec__ = SpecConfig(collection="put_persons") + name: str # required + nick: str | None = None + score: int = 0 + owner: t.Annotated[str, Policy(update=False)] = "" + secret: t.Annotated[str | None, Policy(private=True)] = None + address: Addr | None = None + history: list[Addr] = [] # noqa: RUF012 + tag: Tag | None = None + tags: list[Tag] = [] # noqa: RUF012 + rev: t.Annotated[int, Revision] = 0 + + return Geo, Addr, Tag, Person + + +@pytest.fixture(params=BACKENDS) +async def world(request, make_registry): + Geo, Addr, Tag, Person = models(request.param) + events = [] + await make_registry(Person, on_command=events.append) + api = Api( + Resource(Person, name="persons", ops=[Get, Create, Patch, Put]), + Resource(Person, name="replaced", ops=[Get, Patch], merge_patch=False), + prefix="/v1", + ) + + async def call(method, path, body=None, headers=None): + response = await api.handle( + method, + path, + body=json.dumps(body).encode() if body is not None else b"", + headers={**JSON, **(headers or {})}, + ) + return response.status, json.loads(response.body) if response.body else None + + async def person(): + doc = await Person( + name="ann", + nick="annie", + score=7, + owner="ann-id", + secret="s3", + address=Addr( + city="Oslo", + zip="0150", + note="n", + vault_code="vault", + verified=True, + code="C1", + geo=Geo(lat=1.0, surveyor="sv"), + ), + history=[Addr(city="Old", vault_code="old-vault")], + tag=Tag(label="t", weight=1), + tags=[Tag(label="a"), Tag(label="b")], + ).insert() + return doc, str(doc.id) + + async def stored(doc): + return await Person.collection().find_one({"_id": doc.id}) + + return Person, api, call, person, stored, events + + +async def test_put_replaces_what_the_client_may_write_and_keeps_the_rest(world): + Person, api, call, person, stored, events = world + doc, id = await person() + before = await stored(doc) + events.clear() + status, body = await call( + "PUT", f"/v1/persons/{id}", {"name": "bob", "address": {"city": "Bergen"}, "tag": {"label": "x"}} + ) + assert status == 200, body + writes = [e for e in events if e.phase == "started" and e.command_name != "endSessions"] + assert [e.command_name for e in writes] == ["findAndModify"] # one atomic write + raw = await stored(doc) + # sent: replaced. Left out: back to the default. + assert raw["name"] == "bob" and raw["score"] == 0 and raw.get("nick") is None + assert raw["tag"] == {"label": "x", "weight": 0} # nothing to keep in it: replaced whole + assert raw.get("tags", []) == [] + # what the client cannot write is untouched + assert raw["owner"] == "ann-id" and raw["secret"] == "s3" + assert raw["history"] == before["history"] + address = raw["address"] + assert address["city"] == "Bergen" and "zip" not in address and address["note"] is None + assert address["vault_code"] == "vault" and address["verified"] is True and address["code"] == "C1" + assert address["geo"] == {"lat": 1.0, "surveyor": "sv"} # not sent, holds a server value: kept + assert body["data"]["rev"] == 1 and body["data"]["name"] == "bob" + assert "secret" not in body["data"] and "vault_code" not in body["data"]["address"] + # deeper: an object sent inside the embedded one is replaced the same way + status, body = await call( + "PUT", f"/v1/persons/{id}", {"name": "bob", "address": {"city": "Bergen", "zip": "5", "geo": {}}} + ) + assert status == 200, body + address = (await stored(doc))["address"] + assert address["zip"] == "5" and address["geo"] == {"lat": 0.0, "surveyor": "sv"} + assert (await stored(doc)).get("tag") is None # left out this time + assert (await Person.get(doc.id)).address.verified is True + + +@pytest.mark.parametrize( + ("body", "field", "message"), + [ + ({}, "name", "is required"), + ({"name": "b", "address": {"zip": "1"}}, "address.city", "is required"), + ({"name": "b", "owner": "mallory"}, "owner", "cannot be set on update"), + ({"name": "b", "secret": "x"}, "secret", "unknown field"), + ({"name": "b", "address": {"city": "c", "verified": False}}, "address.verified", "cannot be set"), + ({"name": "b", "address": {"city": "c", "vault_code": "x"}}, "address.vault_code", "unknown field"), + ({"name": "b", "address": None}, "address", "cannot be removed"), + ({"name": "b", "address": {"city": "c", "geo": None}}, "address.geo", "cannot be removed"), + ({"name": "b", "history": []}, "history", "cannot be replaced"), + ({"name": "b", "score": "many"}, "score", None), + ], +) +async def test_put_rejects_what_it_cannot_do_and_writes_nothing(world, body, field, message): + Person, api, call, person, stored, events = world + doc, id = await person() + before = await stored(doc) + status, problem = await call("PUT", f"/v1/persons/{id}", body) + assert status == 422, problem + errors = {e["field"]: e["message"] for e in problem["errors"]} + assert field in errors, errors + if message: + assert message in errors[field] + assert await stored(doc) == before + + +async def test_put_conditions_and_routing(world): + Person, api, call, person, stored, events = world + doc, id = await person() + assert (await call("PUT", f"/v1/persons/{id}", {"name": "b"}, {"if-match": '"7"'}))[0] == 412 + status, body = await call("PUT", f"/v1/persons/{id}", {"name": "b"}, {"if-match": '"0"'}) + assert status == 200 and body["data"]["rev"] == 1 + assert (await stored(doc))["address"]["city"] == "Oslo" # not sent, holds server values: kept + assert (await call("PUT", "/v1/persons/000000000000000000000000", {"name": "b"}))[0] == 404 + assert await Person.count() == 1 # PUT never creates + assert (await call("PUT", f"/v1/replaced/{id}", {"name": "b"}))[0] == 405 # only where ops= lists it + + +async def test_put_into_an_embedded_object_that_does_not_exist_yet(world): + Person, api, call, person, stored, events = world + bare = await Person(name="bare").insert() + status, body = await call("PUT", f"/v1/persons/{bare.id}", {"name": "bare", "address": {"city": "Rome"}}) + assert status == 200, body + assert body["data"]["address"]["city"] == "Rome" and body["data"]["address"]["verified"] is False + + +async def test_patch_replaces_embedded_objects_when_merging_is_turned_off(world): + Person, api, call, person, stored, events = world + doc, id = await person() + url = f"/v1/replaced/{id}" + # a partial object is not enough: the embedded model is validated whole + status, body = await call("PATCH", url, {"address": {"zip": "1"}}) + assert status == 422, body + assert (await stored(doc))["address"]["zip"] == "0150" + status, body = await call("PATCH", url, {"address": {"city": "Bergen"}}) + assert status == 200, body + raw = await stored(doc) + # replaced whole: what the client did not (and could not) send is back at its default + assert raw["address"]["city"] == "Bergen" and "zip" not in raw["address"] + assert raw["address"].get("vault_code", "") == "" and raw["address"].get("verified", False) is False + assert raw["name"] == "ann" and raw["score"] == 7 # other fields are still left alone + # it still may not set what the policy closes + for patch, field in ( + ({"address": {"city": "c", "verified": True}}, "address.verified"), + ({"address": {"city": "c", "code": "C2"}}, "address.code"), + ({"address": {"city": "c", "vault_code": "x"}}, "address.vault_code"), + ({"history": [{"city": "c", "verified": True}]}, "history.0.verified"), + ): + status, body = await call("PATCH", url, patch) + assert status == 422 and field in [e["field"] for e in body["errors"]], body + # lists of such objects and null are accepted again + status, body = await call("PATCH", url, {"history": [{"city": "New"}], "address": None}) + assert status == 200, body + raw = await stored(doc) + assert raw.get("address") is None and [h["city"] for h in raw["history"]] == ["New"] + # a stored null is no obstacle to a whole object + await Person.collection().update_one({"_id": doc.id}, {"$set": {"address": None}}) + status, body = await call("PATCH", url, {"address": {"city": "Rome"}}) + assert status == 200 and body["data"]["address"]["city"] == "Rome" + # the same request on the merging resource merges + status, body = await call("PATCH", f"/v1/persons/{id}", {"address": {"zip": "9"}}) + assert status == 200 and body["data"]["address"] == { + **body["data"]["address"], + "city": "Rome", + "zip": "9", + } + + +async def test_replacing_runs_the_embedded_models_own_validators(make_registry): + import pydantic + + from mongospecs.pydantic import Spec + + class Span(pydantic.BaseModel): + low: int = 0 + high: int = 10 + + @pydantic.model_validator(mode="after") + def ordered(self): + if self.low > self.high: + raise ValueError("low must not exceed high") + return self + + class Gauge(Spec): + __spec__ = SpecConfig(collection="put_gauges") + span: Span = Span() + + await make_registry(Gauge) + api = Api(Resource(Gauge, name="replaced", ops=[Patch, Put], merge_patch=False)) + doc = await Gauge(span=Span(low=0, high=100)).insert() + + async def send(method, name, body): + return (await api.handle(method, f"/{name}/{doc.id}", body=json.dumps(body).encode())).status + + # the object as sent is what gets validated: high is left out, so it is the default 10 + assert await send("PATCH", "replaced", {"span": {"low": 50}}) == 422 + assert await send("PUT", "replaced", {"span": {"low": 50}}) == 422 + assert (await Gauge.get(doc.id)).span == Span(low=0, high=100) + assert await send("PATCH", "replaced", {"span": {"low": 50, "high": 60}}) == 200 + assert (await Gauge.get(doc.id)).span == Span(low=50, high=60) + + +async def test_openapi_describes_put_and_the_replacing_patch(world): + Person, api, call, person, stored, events = world + spec = api.openapi() + schemas = spec["components"]["schemas"] + put = spec["paths"]["/v1/persons/{id}"]["put"] + assert put["requestBody"]["content"]["application/json"]["schema"] == { + "$ref": "#/components/schemas/PersonPut" + } + assert "If-Match" in [p["name"] for p in put["parameters"]] + assert {"404", "412", "422"} <= set(put["responses"]) + body = schemas["PersonPut"] + assert body["required"] == ["name"] and body["additionalProperties"] is False + assert set(body["properties"]) == {"name", "nick", "score", "address", "tag", "tags"} + assert "AddrPut" in json.dumps(body["properties"]["address"]) + addr = next(v for k, v in schemas.items() if k.endswith("AddrPut")) + assert addr["required"] == ["city"] and set(addr["properties"]) == {"city", "zip", "note", "geo"} + assert "GeoPut" in json.dumps(addr["properties"]["geo"]) + assert "TagPut" not in json.dumps(schemas) # nothing to keep in a Tag: it is sent whole + assert "put" not in spec["paths"]["/v1/replaced/{id}"] + # one schema name per model, so two resources over one model share it; ask the view directly + replacing = api.resources[1].view.schema("patch", merge=False) + assert "AddrPatch" not in json.dumps(replacing) and "Addr" in json.dumps( + replacing["properties"]["address"] + ) From 7a4012b4228ce96365f8458794528b0baecddf4f Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 10:25:40 -0400 Subject: [PATCH 26/31] fix: nine defects found in the third review of v2 Each was reproduced against the replica set before it was fixed; the tests are in test_review_round3.py of both packages. Core - A single-field update on a pydantic model failed a model-level validator that only saw the other fields' defaults. The field's own validators still run; the cross-field verdict is no longer taken. - insert_many and bulk_save run document hooks, as insert and save do. - page() starts its count after its arguments are validated, so a bad cursor no longer leaves an un-awaited coroutine. Web - Request bodies are strict JSON: NaN, Infinity and over-deep nesting are a 400 (NaN was stored; deep nesting was a logged 500). - If-Match on a model without a Revision is a 412, not an unconditional write. If-None-Match accepts lists, weak tags and *. - The ASGI plug drops a request whose client disconnected mid-body. - Api(openapi=False) is honoured by the FastAPI plug. - Guards and hooks must name an operation the resource has. - The concurrency limit is applied before authentication. - A filter or sort key given twice is a 400. Recorded as A40 in the plan. Co-Authored-By: Claude Opus 5.5 --- claudedocs/v2-plan.md | 1 + docs/guide/plugins.md | 8 +- docs/web/plugs.md | 3 +- docs/web/resources.md | 10 +- .../mongospecs-web/src/mongospecs_web/api.py | 28 ++- .../src/mongospecs_web/filters.py | 5 + .../src/mongospecs_web/plugs/asgi.py | 5 + .../src/mongospecs_web/plugs/fastapi.py | 31 ++- .../src/mongospecs_web/resource.py | 44 +++- .../tests/test_review_round3.py | 195 ++++++++++++++++++ packages/mongospecs/src/mongospecs/manager.py | 32 ++- .../mongospecs/src/mongospecs/pydantic.py | 19 +- packages/mongospecs/src/mongospecs/query.py | 3 +- .../mongospecs/tests/test_review_round3.py | 137 ++++++++++++ 14 files changed, 484 insertions(+), 37 deletions(-) create mode 100644 packages/mongospecs-web/tests/test_review_round3.py create mode 100644 packages/mongospecs/tests/test_review_round3.py diff --git a/claudedocs/v2-plan.md b/claudedocs/v2-plan.md index c2f9e73..ca2b593 100644 --- a/claudedocs/v2-plan.md +++ b/claudedocs/v2-plan.md @@ -891,6 +891,7 @@ Recorded per §7.1 rule 4. Each one names what changed and why; none invalidates | A37 | §4.8 | HTTP `PATCH` uses JSON Merge Patch semantics for embedded objects (per-key dotted `$set`/`$unset` in one atomic update); lists and dicts still replace whole. Nested keys are held to the embedded model's own `Policy`. An embedded object holding protected values cannot be nulled, and a list or dict of such objects cannot be replaced through `PATCH` (422). `Policy(insert=False)` is enforced on nested keys at create. Supersedes the note in A34 | Decision delegated by Jay. Options weighed: (a) document only, rejected because a client could clear private and read-only values by omitting them, which no documentation fixes; (c) per-field opt-in, rejected because the safe behaviour would be off by default; (d) reject any patch of an object with protected values, rejected because such objects would be unpatchable. (b) merge is what RFC 7396 specifies for objects, is atomic, and removes the hazard instead of fencing it; (d) is kept only where merge has no meaning (list items, null on the whole object). Cost: PATCH on an embedded object is now partial, a behaviour change for clients that relied on replacement; a merge into a missing object needs its required fields; model-level cross-field validators of the embedded model do not run on a merge, as they already do not for top-level PATCH | | A38 | §4.2 | BSON-only values in untyped fields (`Any`) serialise to JSON on all backends: ObjectId and UUID as strings, dates as ISO 8601, Decimal128 as a string, binary as base64, Int64 as a number, Timestamp as a date, Regex as its pattern | Reported: a 500 on pydantic for any ObjectId, and on msgspec/attrs for Binary, Timestamp, Regex and Int64 | | A39 | §4.8 | `Put` operation and `Resource(merge_patch=False)`. `PUT /{id}` replaces every field the client may update (sent value, or the default when left out; a required field must be sent) and leaves the rest as stored, in one atomic update; an embedded object holding protected values is replaced key by key the same way, one without them is replaced whole. It never creates. `Put` is not in the default `ops`. `merge_patch=False` makes `PATCH` replace embedded objects as before A37; nested keys the policy closes are still rejected | Requested by Jay as the opt-out from A37 and its limits (validators of the embedded model see one key at a time on a merge, a stored null needs the whole object, a backend without `supports_merge` gets a 422). The flag is on the resource, not the model: it describes an HTTP verb, model code is unaffected, and embedded models do not inherit settings (A35). Cost of opting out, accepted by whoever sets it: values the client cannot send are reset to defaults on replacement. `Put` is opt-in because guards are keyed by operation, so adding it to the defaults would expose an unguarded write on resources that guard `Patch` | +| A40 | §4.4, §4.8 | Third review. Core: a single-field update on a pydantic model no longer fails a model-level validator that saw the other fields' defaults (the field's own validators still run); `insert_many` and `bulk_save` run document hooks; `page()` starts its count only after its arguments are validated. Web: request bodies are strict JSON (`NaN`, `Infinity` and over-deep nesting are a 400); `If-Match` on a model without a `Revision` is a 412; `If-None-Match` accepts lists, weak tags and `*`; the ASGI plug drops a request whose client disconnected mid-body; `Api(openapi=False)` is honoured by the FastAPI plug; guard and hook keys must name an operation; the concurrency limit is applied before authentication; a filter or sort key given twice is a 400 | Each was reproduced against the replica set first (`test_review_round3.py` in both packages). `insert_many` hooks deviate from Mongoose, where `insertMany` does not run `save` middleware: here the documents are in the caller's hands and a `before("insert")` hook that silently does not run loses data (a slug, a derived field). The hook calls are skipped when no hook is registered, so the bulk paths cost the same as before for models without hooks. Left as documented differences: trailing slash, `HEAD` and `OPTIONS` on framework plugs | New node: diff --git a/docs/guide/plugins.md b/docs/guide/plugins.md index 81428d0..6f70c82 100644 --- a/docs/guide/plugins.md +++ b/docs/guide/plugins.md @@ -4,7 +4,7 @@ mongospecs has the same four kinds of middleware as Mongoose, with typed signatu | Kind | Where | Runs for | |---|---|---| -| Document hooks | `@Model.before("insert" \| "save" \| "delete")`, `@Model.after(...)` | document-level operations | +| Document hooks | `@Model.before("insert" \| "save" \| "delete")`, `@Model.after(...)` | operations on documents you hold: `insert`, `save`, `delete`, `insert_many`, `bulk_save` | | Command and filter rewriting | `Plugin.on_insert`, `on_update`, `on_filter`, `on_delete` | every write and read, including filter-based ones | | Pipeline rewriting | `Plugin.on_pipeline` | `Model.aggregate` | | Bulk hooks | `Plugin.before_bulk`, `after_bulk` | `insert_many`, `bulk_write`, `bulk_save` | @@ -28,8 +28,10 @@ async def slugify(ctx, note): Hooks receive an `OpCtx` (the session, registry and model) and the document. They run in registration order and the first exception stops the operation. -Document hooks do not run for filter-based writes such as `Note.find(...).update(...)`: no -documents are loaded. Plugins still rewrite those commands, which is how timestamps, revisions and +Document hooks run wherever the documents are in your hands, which includes `insert_many` and +`bulk_save` (the `before` hooks for every document ahead of the one round trip, the `after` hooks +for the documents that were written). They do not run for filter-based writes such as +`Note.find(...).update(...)`, `Model.update(id, ...)` or `bulk_write`: no documents are loaded. Plugins still rewrite those commands, which is how timestamps, revisions and scopes keep working there. ## Writing a plugin diff --git a/docs/web/plugs.md b/docs/web/plugs.md index b7a2a6d..fb33fe6 100644 --- a/docs/web/plugs.md +++ b/docs/web/plugs.md @@ -34,7 +34,8 @@ plug.mount(app, api) ``` The operations and their schemas are merged into the application's own `/openapi.json` and -`/docs`, for msgspec and attrs models as well as pydantic ones. +`/docs`, for msgspec and attrs models as well as pydantic ones. With `Api(openapi=False)` (or +`plug.mount(app, api, openapi=False)`) the routes are served but stay out of both. ## Litestar diff --git a/docs/web/resources.md b/docs/web/resources.md index d15b72e..4990bd8 100644 --- a/docs/web/resources.md +++ b/docs/web/resources.md @@ -128,7 +128,8 @@ await registry.close() ?include=author embed allowlisted relations ``` -Only fields and operators the policy allows are accepted; everything else is a 400. +Only fields and operators the policy allows are accepted; everything else is a 400. So is a +filter or sort key given twice (`?name=a&name=b`); `[in]` and `[nin]` may be repeated. ## Limits and defaults @@ -229,5 +230,12 @@ JSON whatever they are: an ObjectId as its string, dates as ISO 8601, binary as [Tuning for production](../guide/production.md). `DELETE` honours `If-Match` like `PATCH`, and the row scope is part of the delete's own filter. +On a model without a `Revision` field there is nothing to compare with, so a request carrying +`If-Match` gets a 412 instead of an unconditional write (`If-Match: *` passes). `If-None-Match` on +`GET` accepts a list, weak tags and `*`. + +Request bodies are strict JSON: `NaN` and `Infinity` are a 400, and so is a body nested too +deeply to parse. Guards and hooks must name an operation the resource has; a misspelt key or +`Put` on a resource without it raises `ValueError` when the `Api` builds its routes. `HEAD` and `OPTIONS` are answered by `api.handle` and the pure ASGI plug; the framework plugs leave them to the framework. diff --git a/packages/mongospecs-web/src/mongospecs_web/api.py b/packages/mongospecs-web/src/mongospecs_web/api.py index 3aaf23a..8aaaf7e 100644 --- a/packages/mongospecs-web/src/mongospecs_web/api.py +++ b/packages/mongospecs-web/src/mongospecs_web/api.py @@ -168,6 +168,8 @@ def _routes(self) -> list[Route]: cached: list[Route] | None = self.__dict__.get("_route_cache") count = sum(len(r.operations) for r in self.resources) if cached is None or self.__dict__.get("_route_count") != count: + for resource in self.resources: + resource.check() cached = self.routes # Literal segments win over parameters: /{id}/publish before /{id}, /search before /{id}. cached.sort(key=lambda r: (-r.path.count("/"), r.path.count("{"))) @@ -178,17 +180,8 @@ def _routes(self) -> list[Route]: async def run(self, route: Route, request: Request) -> Response: """Authenticate and dispatch. Never raises.""" - try: - if len(request.body) > self.max_body: - raise PayloadTooLarge(f"The request body may be at most {self.max_body} bytes.") - if self.authenticate is not None: - principal = self.authenticate(request) - request.principal = await principal if inspect.isawaitable(principal) else principal - except Exception as exc: - response = Response.error(exc) - if response.status >= 500: - logging.getLogger("mongospecs_web").error("authentication failed unexpectedly", exc_info=exc) - return response + # The limit comes first: authentication is work too (often a database read), and an + # overloaded server should not do it for requests it is about to turn away. if self.max_concurrency is not None and self._in_flight >= self.max_concurrency: return Response.error( HTTPError( @@ -200,6 +193,19 @@ async def run(self, route: Route, request: Request) -> Response: ) self._in_flight += 1 try: + try: + if len(request.body) > self.max_body: + raise PayloadTooLarge(f"The request body may be at most {self.max_body} bytes.") + if self.authenticate is not None: + principal = self.authenticate(request) + request.principal = await principal if inspect.isawaitable(principal) else principal + except Exception as exc: + response = Response.error(exc) + if response.status >= 500: + logging.getLogger("mongospecs_web").error( + "authentication failed unexpectedly", exc_info=exc + ) + return response response = await route.resource.dispatch(route.operation, request) finally: self._in_flight -= 1 diff --git a/packages/mongospecs-web/src/mongospecs_web/filters.py b/packages/mongospecs-web/src/mongospecs_web/filters.py index 86ede91..9ce49f7 100644 --- a/packages/mongospecs-web/src/mongospecs_web/filters.py +++ b/packages/mongospecs-web/src/mongospecs_web/filters.py @@ -104,6 +104,9 @@ def parse_list_query( raise BadRequest(f"{name!r} cannot be used as a filter") if op not in allowed: raise BadRequest(f"operator {op!r} is not allowed on {name!r}; allowed: {', '.join(allowed)}") + if len(values) > 1 and op not in ("in", "nin"): + # Only one of them could apply; picking it silently hides the client's mistake. + raise BadRequest(f"{key!r} is given more than once") filters.append(Filter(name, op, _value(view, name, op, values))) sort: list[tuple[str, int]] = [] @@ -115,6 +118,8 @@ def parse_list_query( name = item.lstrip("+-") if name not in view.sortable: raise BadRequest(f"{name!r} cannot be used for sorting") + if any(name == taken for taken, _ in sort): + raise BadRequest(f"{name!r} is given more than once in sort") sort.append((name, direction)) size = pagination.default diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py b/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py index 0051232..13cab09 100644 --- a/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/asgi.py @@ -66,11 +66,14 @@ async def _http(self, scope: Scope, receive: Receive, send: Send) -> None: await _send(send, Response(200, self._openapi, {})) return headers = {k.decode("latin-1").lower(): v.decode("latin-1") for k, v in scope.get("headers", [])} + gone = False async def chunks() -> t.AsyncIterator[bytes]: + nonlocal gone while True: message = await receive() if message["type"] == "http.disconnect": + gone = True return yield message.get("body", b"") if not message.get("more_body"): @@ -81,6 +84,8 @@ async def chunks() -> t.AsyncIterator[bytes]: except HTTPError as exc: await _send(send, Response.error(exc)) return + if gone: + return # the client left before the request was complete: there is nothing to run query = parse_qsl(scope.get("query_string", b"").decode("latin-1"), keep_blank_values=True) response = await self.api.handle(method, path, query=query, headers=headers, body=body, raw=scope) await _send(send, response) diff --git a/packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py b/packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py index a58057c..90e122f 100644 --- a/packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py +++ b/packages/mongospecs-web/src/mongospecs_web/plugs/fastapi.py @@ -35,9 +35,14 @@ async def endpoint(request: StarletteRequest) -> StarletteResponse: return endpoint -def router(api: Api) -> APIRouter: - """An ``APIRouter`` with one route per operation.""" +def router(api: Api, *, openapi: bool | None = None) -> APIRouter: + """An ``APIRouter`` with one route per operation. + + ``openapi`` says whether the operations appear in the application's OpenAPI document; + by default that follows ``Api(openapi=...)``. + """ out = APIRouter() + documented = api.expose_openapi if openapi is None else openapi secured = api.authenticate is not None for route in api._routes(): extra = operation_object(route.resource, route.operation, secured=secured) @@ -52,16 +57,24 @@ def router(api: Api) -> APIRouter: status_code=route.operation.status, response_class=StarletteResponse, openapi_extra=extra, + include_in_schema=documented, ) return out -def mount(app: FastAPI, api: Api) -> None: - """Add the API to a FastAPI application and merge its schemas into ``app.openapi()``.""" - app.include_router(router(api)) +def mount(app: FastAPI, api: Api, *, openapi: bool | None = None) -> None: + """Add the API to a FastAPI application and merge its schemas into ``app.openapi()``. + + With ``Api(openapi=False)`` (or ``openapi=False`` here) the routes are served but left out + of the application's ``/openapi.json`` and ``/docs``. + """ + documented = api.expose_openapi if openapi is None else openapi + app.include_router(router(api, openapi=documented)) + if not documented: + return original = app.openapi - def openapi() -> dict[str, t.Any]: + def merged() -> dict[str, t.Any]: if app.openapi_schema is not None: return app.openapi_schema schema = original() @@ -71,12 +84,12 @@ def openapi() -> dict[str, t.Any]: components.setdefault("responses", {}).update(ours["responses"]) for route in api._routes(): operation = schema["paths"][route.path][route.method.lower()] - documented = operation_object( + described = operation_object( route.resource, route.operation, secured=api.authenticate is not None ) # FastAPI adds its own default responses; ours are the truth. - operation["responses"] = documented["responses"] + operation["responses"] = described["responses"] app.openapi_schema = schema return schema - app.openapi = openapi # type: ignore[method-assign] + app.openapi = merged # type: ignore[method-assign] diff --git a/packages/mongospecs-web/src/mongospecs_web/resource.py b/packages/mongospecs-web/src/mongospecs_web/resource.py index da8438c..9e602f9 100644 --- a/packages/mongospecs-web/src/mongospecs_web/resource.py +++ b/packages/mongospecs-web/src/mongospecs_web/resource.py @@ -22,6 +22,7 @@ BadRequest, Forbidden, NotFound, + PreconditionFailed, Unprocessable, UnsupportedMediaType, problem, @@ -75,6 +76,12 @@ def _pick_dumps() -> t.Callable[[t.Any], bytes]: dumps: t.Callable[[t.Any], bytes] = _pick_dumps() +def _not_json(literal: str) -> t.NoReturn: + # Python's parser reads NaN and Infinity; JSON has neither, and a NaN that got stored + # could not be sent back as JSON. + raise ValueError(f"{literal} is not a JSON value") + + @dataclass(slots=True) class Request: """A framework-neutral HTTP request.""" @@ -97,9 +104,11 @@ def json(self) -> t.Any: if content_type != JSON and not content_type.endswith("+json"): raise UnsupportedMediaType("The request body must be application/json.") try: - return json.loads(self.body) + return json.loads(self.body, parse_constant=_not_json) except ValueError: raise BadRequest("The request body is not valid JSON.") from None + except RecursionError: + raise BadRequest("The request body is nested too deeply.") from None @dataclass(slots=True) @@ -335,6 +344,20 @@ def register(fn: F) -> F: return register + def check(self) -> None: + """Raise ``ValueError`` if a guard or hook is registered for an operation this resource + does not have: a misspelt key, or ``Put`` on a resource whose ``ops`` leave it out, would + otherwise guard nothing without a word. Called when an ``Api`` builds its routes, so + custom operations may be declared after the guards that name them.""" + known = {op.name for op in self.operations} | {op.kind for op in self.operations} + for what, keys in (("guards", self._guards), ("before", self._before), ("after", self._after)): + unknown = sorted(set(keys) - known) + if unknown: + raise ValueError( + f"{self.name}: {what} registered for {unknown}, which this resource does not have; " + f"its operations are {sorted(known - {'custom'})}" + ) + def operation(self, name: str) -> Operation: for op in self.operations: if op.name == name: @@ -537,7 +560,7 @@ async def handler(ctx: Ctx[T]) -> Response: etag = self.view.etag(doc) if etag: headers["etag"] = etag - if ctx.request.headers.get("if-none-match") == etag: + if _etag_listed(ctx.request.headers.get("if-none-match"), etag): return Response(304, b"", headers) return Response.json({"data": self.view.serialize(doc, set(include))}, 200, headers) @@ -644,13 +667,28 @@ def __repr__(self) -> str: return f"" +def _etag_listed(header: str | None, etag: str) -> bool: + """Whether ``If-None-Match`` names ``etag``: one of a list, weak or strong, or ``*``.""" + if header is None: + return False + for item in header.split(","): + item = item.strip() + if item == "*" or item.removeprefix("W/") == etag: + return True + return False + + def _if_match(header: str | None, has_revision: bool) -> int | None: """Parse ``If-Match`` into a revision number. ``*`` and absence mean "no check".""" - if header is None or not has_revision: + if header is None: return None value = header.strip() if value == "*": return None + if not has_revision: + # There is no ETag to compare with, so the condition cannot hold. Going ahead anyway + # would be an unconditional write the client explicitly did not ask for. + raise PreconditionFailed("This resource has no revisions, so If-Match cannot be satisfied.") if value.startswith("W/"): value = value[2:] try: diff --git a/packages/mongospecs-web/tests/test_review_round3.py b/packages/mongospecs-web/tests/test_review_round3.py new file mode 100644 index 0000000..c7aef7f --- /dev/null +++ b/packages/mongospecs-web/tests/test_review_round3.py @@ -0,0 +1,195 @@ +"""Regression tests for the third review of v2: one test per reproduced defect.""" + +import json +import typing as t + +import httpx +import pytest + +from mongospecs import Policy, Revision, SpecConfig +from mongospecs_web import Api, Create, Cursor, Delete, Get, List, Put, Resource +from mongospecs_web.testing import PLUGS, FakeBackend, mount_on + +JSON = {"content-type": "application/json"} + + +def api_for(*, revision=False, **options): + from mongospecs.msgspec import Spec + + if revision: + + class Item(Spec, kw_only=True): + __spec__ = SpecConfig(collection="r3_items") + name: t.Annotated[str, Policy(filter=("eq", "in"), sort=True)] = "" + price: float = 0.0 + rev: t.Annotated[int, Revision] = 0 + + else: + + class Item(Spec, kw_only=True): + __spec__ = SpecConfig(collection="r3_items") + name: t.Annotated[str, Policy(filter=("eq", "in"), sort=True)] = "" + price: float = 0.0 + + store = FakeBackend(Item) + resource = Resource(Item, name="items", backend=store, page=Cursor(total=True)) + return Api(resource, prefix="/api", **options), store + + +async def create(api, **fields): + response = await api.handle("POST", "/api/items", body=json.dumps(fields).encode(), headers=JSON) + assert response.status == 201, response.body + return response.headers["location"] + + +@pytest.mark.parametrize("literal", [b"NaN", b"Infinity", b"-Infinity"]) +async def test_2_numbers_json_does_not_have_are_rejected(literal): + api, store = api_for() + url = await create(api, name="a", price=1.0) + for method, path in (("POST", "/api/items"), ("PATCH", url)): + response = await api.handle(method, path, body=b'{"price":' + literal + b"}", headers=JSON) + assert response.status == 400, (method, response.status, response.body) + assert [d.price for d in store.docs.values()] == [1.0] + + +async def test_3_json_nested_too_deeply_is_a_bad_request(caplog): + api, _ = api_for() + response = await api.handle("POST", "/api/items", body=b"[" * 200_000, headers=JSON) + assert response.status == 400, response.body + assert not caplog.records # the client's mistake is not a server error + + +async def test_4_if_match_cannot_be_satisfied_without_revisions(): + api, store = api_for() + url = await create(api, name="a") + for method, body in (("PATCH", b'{"name":"b"}'), ("DELETE", b"")): + response = await api.handle(method, url, body=body, headers={**JSON, "if-match": '"7"'}) + assert response.status == 412, (method, response.status) + assert [d.name for d in store.docs.values()] == ["a"] + # "*" only asks that the resource exists, which needs no revision + assert ( + await api.handle("PATCH", url, body=b'{"name":"b"}', headers={**JSON, "if-match": "*"}) + ).status == 200 + + +async def test_if_none_match_reads_etags_the_way_if_match_does(): + api, _ = api_for(revision=True) + url = await create(api, name="a") + for header, status in (('"0"', 304), ('W/"0"', 304), ('"9", "0"', 304), ("*", 304), ('"9"', 200)): + response = await api.handle("GET", url, headers={"if-none-match": header}) + assert response.status == status, header + + +async def test_5_a_request_the_client_abandoned_is_not_run(): + api, store = api_for() + url = await create(api, name="victim") + messages = iter([{"type": "http.request", "body": b"", "more_body": True}, {"type": "http.disconnect"}]) + sent = [] + + async def receive(): + return next(messages) + + async def send(message): + sent.append(message) + + scope = {"type": "http", "method": "DELETE", "path": url, "headers": [], "query_string": b""} + await api.asgi()(scope, receive, send) + assert len(store.docs) == 1 and not sent + + +async def test_6_a_private_openapi_stays_private_on_fastapi(): + from fastapi import FastAPI + + from mongospecs_web.plugs.fastapi import mount + + api, _ = api_for(openapi=False) + app = FastAPI() + mount(app, api) + spec = app.openapi() + assert not [path for path in spec["paths"] if path.startswith("/api")] + assert "ItemRead" not in spec.get("components", {}).get("schemas", {}) + async with httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://t") as client: + assert (await client.get("/api/items")).status_code == 200 # still served + # and it can be asked for explicitly, as on the other plugs + shown = FastAPI() + mount(shown, api, openapi=True) + assert "/api/items" in shown.openapi()["paths"] + + +def test_9_guards_and_hooks_must_name_an_operation(): + from mongospecs.msgspec import Spec + + class Thing(Spec, kw_only=True): + __spec__ = SpecConfig(collection="r3_things") + name: str = "" + + def deny(ctx): + return False + + for guards in ({"patchh": deny}, {Put: deny}): # a typo; an operation this resource does not have + resource = Resource(Thing, backend=FakeBackend(Thing), guards=guards) + with pytest.raises(ValueError, match="guards"): + Api(resource).match("GET", "/r3_things") + hooked = Resource(Thing, backend=FakeBackend(Thing), ops=[List, Get]) + hooked.before(Create)(lambda ctx: None) + with pytest.raises(ValueError, match="before"): + Api(hooked).match("GET", "/r3_things") + # a custom operation may be guarded before it is declared + late = Resource(Thing, backend=FakeBackend(Thing), guards={"publish": deny, Delete: deny}) + + @late.op("publish") + async def publish(ctx): + return None + + assert Api(late).match("GET", "/r3_things")[0].operation.name == "list" + + +async def test_overload_is_refused_before_authentication_runs(): + import asyncio + + authenticated = [] + release = asyncio.Event() + + async def authenticate(request): + authenticated.append(1) + await release.wait() + return "ann" + + api, _ = api_for(authenticate=authenticate, max_concurrency=1) + first = asyncio.ensure_future(api.handle("GET", "/api/items")) + await asyncio.sleep(0) + await asyncio.sleep(0) + try: + second = await asyncio.wait_for(api.handle("GET", "/api/items"), 2) + finally: + release.set() + assert second.status == 503 and len(authenticated) == 1 + assert (await first).status == 200 + assert (await api.handle("GET", "/api/items")).status == 200 # the slot was given back + + +async def test_a_filter_or_sort_key_given_twice_is_an_error(): + api, _ = api_for() + await create(api, name="a") + for query in ( + [("name", "a"), ("name", "b")], + [("sort", "name,-name")], + [("sort", "name"), ("sort", "name")], + ): + assert (await api.handle("GET", "/api/items", query=query)).status == 400, query + listed = await api.handle("GET", "/api/items", query=[("name[in]", "a"), ("name[in]", "b")]) + assert listed.status == 200 and len(json.loads(listed.body)["data"]) == 1 + + +@pytest.mark.parametrize("plug", PLUGS) +async def test_every_plug_refuses_what_the_core_refuses(plug): + api, store = api_for() + url = await create(api, name="a", price=1.0) + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=mount_on(plug, api)), base_url="http://t" + ) as client: + assert (await client.patch(url, content=b'{"price":NaN}', headers=JSON)).status_code == 400 + assert ( + await client.patch(url, content=b'{"name":"b"}', headers={**JSON, "if-match": '"3"'}) + ).status_code == 412 + assert [(d.name, d.price) for d in store.docs.values()] == [("a", 1.0)] diff --git a/packages/mongospecs/src/mongospecs/manager.py b/packages/mongospecs/src/mongospecs/manager.py index 7ddd366..7ad61f3 100644 --- a/packages/mongospecs/src/mongospecs/manager.py +++ b/packages/mongospecs/src/mongospecs/manager.py @@ -277,6 +277,13 @@ async def _hooks(self, when: str, events: tuple[str, ...], ctx: OpCtx, doc: t.An for hook in meta.hooks(when, event): await hook(ctx, doc) + def _has_hooks(self, *events: str) -> bool: + """Whether a document hook could run for any of ``events`` (bulk paths skip the calls otherwise).""" + for plugin in self.plugins: + if type(plugin).before is not Plugin.before or type(plugin).after is not Plugin.after: + return True + return any(self.meta.hooks(when, event) for when in ("before", "after") for event in events) + # -- document-level writes ----------------------------------------------------- async def insert(self, doc: T, *, session: t.Any = None) -> T: @@ -677,7 +684,9 @@ async def insert_many( """Insert documents in one round trip. With ``ordered=False`` the server attempts every document; failures are - reported in ``BulkResult.errors`` instead of raising. + reported in ``BulkResult.errors`` instead of raising. Document hooks for + ``insert`` and ``save`` run for every document, as they do for ``insert()``: + the ``before`` hooks ahead of the write, the ``after`` hooks for what was written. """ items: list[t.Any] = [ self.meta.decode(dict(d), stored=False) if isinstance(d, t.Mapping) else d for d in docs @@ -685,6 +694,10 @@ async def insert_many( if not items: return BulkResult([], []) ctx = self.ctx("insert", session, many=True) + hooks = self._has_hooks("insert", "save") + if hooks: + for doc in items: + await self._hooks("before", ("insert", "save"), ctx, doc) for plugin in self.plugins: await plugin.before_bulk(ctx, items) encoded: list[dict[str, t.Any]] = [] @@ -717,6 +730,9 @@ async def insert_many( inserted.append(doc) for plugin in self.plugins: await plugin.after_bulk(ctx, inserted) + if hooks: + for doc in inserted: + await self._hooks("after", ("insert", "save"), ctx, doc) return BulkResult(inserted, sorted(failed.items())) async def bulk_write( @@ -765,6 +781,9 @@ async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: fail (a duplicate key, a document that no longer exists, a revision that moved on), the rest are still written and brought in sync in memory, and the error raised afterwards lists the ones that were not in ``.failed``. + + Document hooks run as they do for ``save()``: ``insert`` and ``save`` for new + documents, ``save`` for tracked ones; the ``after`` hooks only for what was written. """ from pymongo import InsertOne from pymongo import UpdateOne as DriverUpdateOne @@ -774,10 +793,13 @@ async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: pending: list[ tuple[t.Any, str, OpCtx, dict[str, t.Any], tracking.DocState | None, dict[str, t.Any]] ] = [] + hooks = self._has_hooks("insert", "save") for doc in docs: state = tracking.state_of(doc) if state is None: ctx = self.ctx("insert", session, many=True, doc=doc) + if hooks: + await self._hooks("before", ("insert", "save"), ctx, doc) enc = self.meta.encode(doc) if enc.get("_id") is None: enc.pop("_id", None) @@ -787,6 +809,8 @@ async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: pending.append((doc, "insert", ctx, enc, None, {})) continue ctx = self.ctx("update", session, many=True, doc=doc, snapshot=state.snapshot) + if hooks: + await self._hooks("before", ("save",), ctx, doc) current, update = self._delta(doc, state) if not update: continue @@ -842,6 +866,12 @@ async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: written += 1 for plugin in self.plugins: await plugin.after_bulk(bulk_ctx, [p[0] for i, p in enumerate(pending) if i not in failed]) + if hooks: + for index, (doc, kind, ctx, *_rest) in enumerate(pending): + if index not in failed: + await self._hooks( + "after", ("insert", "save") if kind == "insert" else ("save",), ctx, doc + ) if error is not None: error.failed = [pending[i][0] for i in sorted(failed)] raise error diff --git a/packages/mongospecs/src/mongospecs/pydantic.py b/packages/mongospecs/src/mongospecs/pydantic.py index 7674325..4ee9129 100644 --- a/packages/mongospecs/src/mongospecs/pydantic.py +++ b/packages/mongospecs/src/mongospecs/pydantic.py @@ -167,15 +167,20 @@ def _omit_plan(self, tp: t.Any, plans: dict[type, _Omit]) -> _Omit | None: def validate_field(self, cls: type, name: str, value: t.Any) -> t.Any: model = t.cast("type[BaseModel]", cls) + # Runs the model's own field validators for this one field. The instance it is assigned + # to is blank: every other field holds its default, not the stored value. + blank = model.model_construct() + blank.__dict__.pop(name, None) try: - # Runs the model's own field validators for this one field. - blank = model.model_construct() return getattr(model.__pydantic_validator__.validate_assignment(blank, name, value), name) - except pydantic.ValidationError as exc: - raise _error(cls, exc, name) from exc - except Exception: - # A model-level validator could not run on a partial instance; fall - # back to the field's type and constraints alone. + except Exception as exc: + if isinstance(exc, pydantic.ValidationError) and any(e.get("loc") for e in exc.errors()): + raise _error(cls, exc, name) from exc # the field itself is wrong + # A model-level validator rejected, or could not run on, the blank instance. It + # compares fields, and the other fields here are defaults, so its verdict says + # nothing about the document being updated. The field was validated before it ran. + if name in blank.__dict__: + return blank.__dict__[name] adapter = self._adapters.get((cls, name)) if adapter is None: adapter = TypeAdapter(self.fields(cls)[name].type, config=_adapter_config(model)) diff --git a/packages/mongospecs/src/mongospecs/query.py b/packages/mongospecs/src/mongospecs/query.py index 90abc37..fef990c 100644 --- a/packages/mongospecs/src/mongospecs/query.py +++ b/packages/mongospecs/src/mongospecs/query.py @@ -592,7 +592,6 @@ async def page( raise QueryError("page size must be >= 1") if number is not None and after is not None: raise QueryError("use either number (offset pagination) or after (cursor pagination)") - count_task: t.Awaitable[int] | None = self._clone(skip=0, limit=0, sort=()).count() if total else None if number is not None: if number < 1: @@ -615,6 +614,8 @@ async def page( # default where the stored document has no key, which sorts elsewhere. query._keys = {} total_count: int | None + # Created only now: everything above can raise, and a coroutine nobody awaits is a warning. + count_task: t.Awaitable[int] | None = self._clone(skip=0, limit=0, sort=()).count() if total else None if count_task is not None and self._session is None: items, total_count = await asyncio.gather(query.all(), count_task) else: diff --git a/packages/mongospecs/tests/test_review_round3.py b/packages/mongospecs/tests/test_review_round3.py new file mode 100644 index 0000000..260d4af --- /dev/null +++ b/packages/mongospecs/tests/test_review_round3.py @@ -0,0 +1,137 @@ +"""Regression tests for the third review of v2: one test per reproduced defect.""" + +import gc +import warnings + +import pytest + +from mongospecs import SpecConfig, ValidationError + + +def span_model(): + import pydantic + + from mongospecs.pydantic import Spec + + class Span(Spec): + __spec__ = SpecConfig(collection="r3_spans") + low: int = 0 + high: int = 10 + label: str = "" + + @pydantic.field_validator("label") + @classmethod + def tidy(cls, value): + return value.strip() + + @pydantic.model_validator(mode="after") + def ordered(self): + if self.low > self.high: + raise ValueError("low must not exceed high") + return self + + return Span + + +async def test_1_updating_one_field_is_not_judged_against_the_other_fields_defaults(make_registry): + Span = span_model() + await make_registry(Span) + doc = await Span(low=50, high=60).insert() + # low=55 is fine next to the stored high=60; the model validator only knows the default high=10 + updated = await Span.update(doc.id, low=55) + assert (updated.low, updated.high) == (55, 60) + # the field's own rules still apply: its type, and its field validator + with pytest.raises(ValidationError): + await Span.update(doc.id, low="many") + assert (await Span.update(doc.id, label=" x ")).label == "x" + # a whole document is still validated whole + import pydantic + + with pytest.raises(pydantic.ValidationError): + Span(low=5, high=1) + + +def hooked(backend): + if backend == "msgspec": + from mongospecs.msgspec import Spec + + class Note(Spec, kw_only=True): + __spec__ = SpecConfig(collection="r3_notes") + text: str = "" + slug: str = "" + + elif backend == "pydantic": + from mongospecs.pydantic import Spec + + class Note(Spec): + __spec__ = SpecConfig(collection="r3_notes") + text: str = "" + slug: str = "" + + else: + import attrs + + from mongospecs.attrs import Spec + + @attrs.define(kw_only=True) + class Note(Spec): + __spec__ = SpecConfig(collection="r3_notes") + text: str = "" + slug: str = "" + + seen = [] + + @Note.before("insert") + async def slugify(ctx, note): + note.slug = note.text.lower().replace(" ", "-") + + @Note.before("save") + async def before_save(ctx, note): + seen.append(("before", note.text)) + + @Note.after("save") + async def after_save(ctx, note): + seen.append(("after", note.text, note.id is not None)) + + return Note, seen + + +async def test_7_document_hooks_run_for_insert_many_and_bulk_save(backend, make_registry): + Note, seen = hooked(backend) + await make_registry(Note) + result = await Note.insert_many([Note(text="Hello World"), Note(text="Two")]) + assert [n.slug for n in result] == ["hello-world", "two"] + assert [n["slug"] async for n in Note.find().raw().sort("slug")] == ["hello-world", "two"] + assert seen == [ + ("before", "Hello World"), + ("before", "Two"), + ("after", "Hello World", True), + ("after", "Two", True), + ] + seen.clear() + first = result.inserted[0] + first.text = "Changed" + assert await Note.bulk_save([first, Note(text="New One")]) == 2 + assert await Note.count({"slug": "new-one"}) == 1 + assert seen == [ + ("before", "Changed"), + ("before", "New One"), + ("after", "Changed", True), + ("after", "New One", True), + ] + assert (await Note.get(first.id)).slug == "hello-world" # "insert" hooks are for new documents only + + +async def test_8_a_bad_cursor_leaves_no_coroutine_behind(make_registry): + from mongospecs import QueryError + + Note, _ = hooked("msgspec") + await make_registry(Note) + with warnings.catch_warnings(record=True) as caught: + warnings.simplefilter("always") + with pytest.raises(QueryError): + await Note.find().page(5, after="not-a-cursor", total=True) + with pytest.raises(QueryError): + await Note.find().page(5, number=0, total=True) + gc.collect() + assert not [w for w in caught if "never awaited" in str(w.message)] From 0c5388ac5e03f92d926aabb76e30e9ffc76bed22 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 10:25:40 -0400 Subject: [PATCH 27/31] ci,build: run each CI job once per push; complete the extras Branch pushes and their pull requests both triggered CI, so every job ran twice. mongospecs[all] now includes the otel and sentry extras, and mongospecs-web declares msgspec as the optional encoder it already uses when present. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/ci.yaml | 3 ++- packages/mongospecs-web/pyproject.toml | 2 ++ packages/mongospecs/pyproject.toml | 2 +- uv.lock | 10 +++++++++- 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 15fadcf..4b1ba53 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -1,8 +1,9 @@ name: CI on: + # Branches are tested through their pull request; a push trigger for them too ran every job twice. push: - branches: [main, "v2/**"] + branches: [main] pull_request: permissions: diff --git a/packages/mongospecs-web/pyproject.toml b/packages/mongospecs-web/pyproject.toml index 2aa70d9..867aeb3 100644 --- a/packages/mongospecs-web/pyproject.toml +++ b/packages/mongospecs-web/pyproject.toml @@ -20,6 +20,8 @@ classifiers = [ dependencies = ["mongospecs>=0.2.0,<0.3"] [project.optional-dependencies] +# Optional: responses are encoded with msgspec when it is installed, with the standard library otherwise. +msgspec = ["msgspec>=0.19"] starlette = ["starlette>=0.40"] fastapi = ["fastapi>=0.115"] litestar = ["litestar>=2.12"] diff --git a/packages/mongospecs/pyproject.toml b/packages/mongospecs/pyproject.toml index 8cad394..87b5771 100644 --- a/packages/mongospecs/pyproject.toml +++ b/packages/mongospecs/pyproject.toml @@ -30,7 +30,7 @@ pydantic = ["pydantic>=2.9"] attrs = ["attrs>=24.2", "msgspec>=0.19"] otel = ["opentelemetry-api>=1.20"] sentry = ["sentry-sdk>=2.0"] -all = ["msgspec>=0.19", "pydantic>=2.9", "attrs>=24.2"] +all = ["msgspec>=0.19", "pydantic>=2.9", "attrs>=24.2", "opentelemetry-api>=1.20", "sentry-sdk>=2.0"] [project.urls] Repository = "https://github.com/jaykv/mongospecs" diff --git a/uv.lock b/uv.lock index 132b066..f9291a1 100644 --- a/uv.lock +++ b/uv.lock @@ -1060,7 +1060,9 @@ dependencies = [ all = [ { name = "attrs" }, { name = "msgspec" }, + { name = "opentelemetry-api" }, { name = "pydantic" }, + { name = "sentry-sdk" }, ] attrs = [ { name = "attrs" }, @@ -1086,10 +1088,12 @@ requires-dist = [ { name = "msgspec", marker = "extra == 'all'", specifier = ">=0.19" }, { name = "msgspec", marker = "extra == 'attrs'", specifier = ">=0.19" }, { name = "msgspec", marker = "extra == 'msgspec'", specifier = ">=0.19" }, + { name = "opentelemetry-api", marker = "extra == 'all'", specifier = ">=1.20" }, { name = "opentelemetry-api", marker = "extra == 'otel'", specifier = ">=1.20" }, { name = "pydantic", marker = "extra == 'all'", specifier = ">=2.9" }, { name = "pydantic", marker = "extra == 'pydantic'", specifier = ">=2.9" }, { name = "pymongo", specifier = ">=4.13,<5" }, + { name = "sentry-sdk", marker = "extra == 'all'", specifier = ">=2.0" }, { name = "sentry-sdk", marker = "extra == 'sentry'", specifier = ">=2.0" }, ] provides-extras = ["msgspec", "pydantic", "attrs", "otel", "sentry", "all"] @@ -1135,6 +1139,9 @@ fastapi = [ litestar = [ { name = "litestar" }, ] +msgspec = [ + { name = "msgspec" }, +] quart = [ { name = "quart", version = "0.22.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" }, { name = "quart", version = "0.23.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" }, @@ -1150,12 +1157,13 @@ requires-dist = [ { name = "litestar", marker = "extra == 'all'", specifier = ">=2.12" }, { name = "litestar", marker = "extra == 'litestar'", specifier = ">=2.12" }, { name = "mongospecs", editable = "packages/mongospecs" }, + { name = "msgspec", marker = "extra == 'msgspec'", specifier = ">=0.19" }, { name = "quart", marker = "extra == 'all'", specifier = ">=0.19" }, { name = "quart", marker = "extra == 'quart'", specifier = ">=0.19" }, { name = "starlette", marker = "extra == 'all'", specifier = ">=0.40" }, { name = "starlette", marker = "extra == 'starlette'", specifier = ">=0.40" }, ] -provides-extras = ["starlette", "fastapi", "litestar", "quart", "all"] +provides-extras = ["msgspec", "starlette", "fastapi", "litestar", "quart", "all"] [[package]] name = "mongospecs-workspace" From c5eba4d407bfff7deecb2e8b7ff12704771edc1b Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 10:33:35 -0400 Subject: [PATCH 28/31] docs: getting-started tutorial, runnable examples and four topic guides A new user had reference-quality guides and no way into them. - examples/: a quickstart script, a to-do REST API with its tests, and a compose file for a single-node replica set. They are run by the suite, and the tutorial page and the README show them as they are. - docs/tutorial.md: from MongoDB to a model, an API and its tests. - New guides: choosing a backend, errors (what each means and the HTTP status it becomes), authentication and authorization, testing an API. The last two are executed like the other guide pages. - The replica-set requirement is stated in the README, the landing page and the tutorial instead of only in the production notes. - The package README is checked to be the root README. - The reference section is removed: it was generated from docstrings, and the source is the better reference. mkdocstrings goes with it. Co-Authored-By: Claude Opus 5.5 --- Makefile | 8 +- README.md | 55 +++++++--- docs/guide/backends.md | 67 ++++++++++++ docs/guide/errors.md | 69 ++++++++++++ docs/guide/operations.md | 13 +-- docs/index.md | 16 ++- docs/reference/bulk.md | 3 - docs/reference/errors.md | 3 - docs/reference/expr.md | 3 - docs/reference/hooks.md | 3 - docs/reference/indexes.md | 3 - docs/reference/manager.md | 3 - docs/reference/markers.md | 3 - docs/reference/model.md | 3 - docs/reference/query.md | 3 - docs/reference/refs.md | 3 - docs/reference/registry.md | 3 - docs/reference/relations.md | 3 - docs/reference/streams.md | 3 - docs/reference/updates.md | 3 - docs/reference/web_api.md | 3 - docs/reference/web_backend.md | 3 - docs/reference/web_errors.md | 3 - docs/reference/web_filters.md | 3 - docs/reference/web_openapi.md | 3 - docs/reference/web_policy.md | 3 - docs/reference/web_resource.md | 3 - docs/reference/web_testing.md | 3 - docs/tutorial.md | 122 ++++++++++++++++++++++ docs/web/auth.md | 168 ++++++++++++++++++++++++++++++ docs/web/testing.md | 135 ++++++++++++++++++++++++ examples/compose.yaml | 25 +++++ examples/quickstart.py | 65 ++++++++++++ examples/test_quickstart.py | 55 ++++++++++ examples/todo_api/app.py | 72 +++++++++++++ examples/todo_api/conftest.py | 9 ++ examples/todo_api/test_app.py | 59 +++++++++++ mkdocs.yaml | 49 ++------- packages/mongospecs-web/README.md | 10 +- packages/mongospecs/README.md | 51 +++++++-- pyproject.toml | 6 +- uv.lock | 65 +----------- 42 files changed, 969 insertions(+), 216 deletions(-) create mode 100644 docs/guide/backends.md create mode 100644 docs/guide/errors.md delete mode 100644 docs/reference/bulk.md delete mode 100644 docs/reference/errors.md delete mode 100644 docs/reference/expr.md delete mode 100644 docs/reference/hooks.md delete mode 100644 docs/reference/indexes.md delete mode 100644 docs/reference/manager.md delete mode 100644 docs/reference/markers.md delete mode 100644 docs/reference/model.md delete mode 100644 docs/reference/query.md delete mode 100644 docs/reference/refs.md delete mode 100644 docs/reference/registry.md delete mode 100644 docs/reference/relations.md delete mode 100644 docs/reference/streams.md delete mode 100644 docs/reference/updates.md delete mode 100644 docs/reference/web_api.md delete mode 100644 docs/reference/web_backend.md delete mode 100644 docs/reference/web_errors.md delete mode 100644 docs/reference/web_filters.md delete mode 100644 docs/reference/web_openapi.md delete mode 100644 docs/reference/web_policy.md delete mode 100644 docs/reference/web_resource.md delete mode 100644 docs/reference/web_testing.md create mode 100644 docs/tutorial.md create mode 100644 docs/web/auth.md create mode 100644 docs/web/testing.md create mode 100644 examples/compose.yaml create mode 100644 examples/quickstart.py create mode 100644 examples/test_quickstart.py create mode 100644 examples/todo_api/app.py create mode 100644 examples/todo_api/conftest.py create mode 100644 examples/todo_api/test_app.py diff --git a/Makefile b/Makefile index e2b3e99..292e3c9 100644 --- a/Makefile +++ b/Makefile @@ -18,12 +18,12 @@ test-fast: uv run pytest -m "not sharded" format: - uv run ruff format packages bench - uv run ruff check packages bench --fix + uv run ruff format packages bench examples + uv run ruff check packages bench examples --fix lint: - uv run ruff format --check packages bench - uv run ruff check packages bench + uv run ruff format --check packages bench examples + uv run ruff check packages bench examples types: uv run mypy packages/mongospecs/src/mongospecs packages/mongospecs-web/src/mongospecs_web bench/src/mongospecs_bench diff --git a/README.md b/README.md index aa58713..16212ea 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,13 @@ The aim is Mongoose's production feature set (change tracking, middleware, popul discriminators, transactions that restore document state) with typed Python models, plus [`mongospecs-web`](packages/mongospecs-web), a framework-neutral REST layer. +**Documentation: .** Start with +[Getting started](https://jaykv.github.io/mongospecs/tutorial/). + > **Status: 0.2.0, alpha.** This is a rewrite of 0.1.x: async-only, Python 3.11+, MongoDB 7.0+, > and not backwards compatible. The synchronous 0.1.x line lives on the -> [`0.1.x` branch](https://github.com/jaykv/mongospecs/tree/0.1.x). The design and the work graph -> are in [`claudedocs/v2-plan.md`](claudedocs/v2-plan.md). +> [`0.1.x` branch](https://github.com/jaykv/mongospecs/tree/0.1.x). Changes are listed in the +> [changelog](CHANGELOG.md). ## Install @@ -17,14 +20,30 @@ discriminators, transactions that restore document state) with typed Python mode pip install "mongospecs[msgspec]" # or [pydantic], [attrs], [all] ``` +You also need MongoDB 7.0 or newer. Transactions, cascading deletes and change streams need it to +run as a replica set; a single node is enough. [`examples/compose.yaml`](examples/compose.yaml) +starts one on `localhost:27017`: + +``` +docker compose -f examples/compose.yaml up -d --wait +``` + ## A taste +This is [`examples/quickstart.py`](examples/quickstart.py), as it is run by the test suite. + ```python +"""mongospecs in one file. Start MongoDB (see compose.yaml), then: python examples/quickstart.py""" + +import asyncio +import os from datetime import datetime from typing import Annotated from mongospecs import CreatedAt, Inc, Index, Q, Ref, Registry, Revision, SpecConfig, UpdatedAt -from mongospecs.msgspec import Spec # or mongospecs.pydantic / mongospecs.attrs +from mongospecs.msgspec import Spec # or mongospecs.pydantic / mongospecs.attrs + +URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017/quickstart?replicaSet=rs0") class User(Spec): @@ -40,11 +59,11 @@ class Article(Spec): author: Ref[User] | None = None created_at: Annotated[datetime | None, CreatedAt] = None updated_at: Annotated[datetime | None, UpdatedAt] = None - rev: Annotated[int, Revision] = 0 # optimistic locking + rev: Annotated[int, Revision] = 0 # optimistic locking -async def main(): - async with Registry("mongodb://localhost/app", models=[User, Article]) as registry: +async def main(uri: str = URI) -> None: + async with Registry(uri, models=[User, Article]) as registry: await registry.apply_indexes() ann = await User(name="ann").insert() @@ -56,24 +75,34 @@ async def main(): await article.save() # typed queries: names are checked, values are cast - popular = await Article.find(Q.views >= 100, Q.title.prefix("Hello")).sort("-views").limit(10) + popular = await Article.find(Q.views >= 0, Q.title.prefix("Hello")).sort("-views").limit(10) + assert [a.title for a in popular] == ["Hello, world"] # relations: one extra query, or a $lookup with_author = await Article.find(Q.author == ann).include("author").one() - print(with_author.author.doc.name) + print(with_author.title, "by", with_author.author.doc.name) # atomic updates, always validated; the updated document comes back article = await Article.update(article.id, Inc(views=1), tags=["intro"]) + assert article.views == 1 - # transactions: the callback is retried on transient errors, and in-memory - # documents are restored if it aborts + # transactions (these need a replica set): the callback is retried on transient + # errors, and in-memory documents are restored if it aborts async def publish(tx): article.tags.append("published") await article.save(session=tx) await registry.transaction(publish) + print("tags:", (await Article.get(article.id)).tags) + + +if __name__ == "__main__": + asyncio.run(main()) ``` +A REST API over a model is a few more lines; see [`examples/todo_api`](examples/todo_api) and the +[web guide](https://jaykv.github.io/mongospecs/web/resources/). + ## What is in the box | | | @@ -96,7 +125,7 @@ async def main(): oversell, multi-item orders that are all-or-nothing, a change-stream projector that must count each sale exactly once across restarts, and orders that stay consistent across a primary step-down. -- Every example in the guide pages is executed. +- Every example in the guide pages and in `examples/` is executed. - Benchmarks and stress scenarios in `bench/` run against real MongoDB; results and the conclusions drawn from them are in `docs/benchmarks.md`. @@ -110,4 +139,6 @@ uv run mongospecs-bench run # benchmarks; `run LOAD` for the HTTP load ``` The repository is a uv workspace: `packages/mongospecs`, `packages/mongospecs-web` and `bench/` -(benchmarks and stress scenarios against real MongoDB, never published). +(benchmarks and stress scenarios against real MongoDB, never published). See +[CONTRIBUTING.md](CONTRIBUTING.md); the design decisions behind 0.2 are recorded in +[`claudedocs/v2-plan.md`](claudedocs/v2-plan.md). diff --git a/docs/guide/backends.md b/docs/guide/backends.md new file mode 100644 index 0000000..71d8162 --- /dev/null +++ b/docs/guide/backends.md @@ -0,0 +1,67 @@ +# Choosing a backend + +A model is a msgspec `Struct`, a pydantic `BaseModel` or an attrs class. Queries, writes, +relations, plugins and the web layer are the same on all three; what differs is what the model +library itself does. Pick the one your application already uses. With no preference, msgspec is +the lightest and the one the examples use. + +``` +pip install "mongospecs[msgspec]" # or [pydantic], or [attrs] (which also installs msgspec) +``` + +| | msgspec | pydantic | attrs | +|---|---|---|---| +| Import | `from mongospecs.msgspec import Spec` | `from mongospecs.pydantic import Spec` | `from mongospecs.attrs import Spec` | +| Declaring a model | `class A(Spec):` | `class A(Spec):` | `@attrs.define(kw_only=True)` on **every** model, then `class A(Spec):` | +| Wrong type passed to the constructor | accepted | `ValidationError` | accepted | +| Stored documents on read | validated | validated | validated | +| `Model.update(...)`, `PATCH`, filters | validated and cast | validated and cast | validated and cast | +| Your own validators | none in msgspec; use `Annotated[..., msgspec.Meta(...)]` constraints | `field_validator`, `model_validator` | `attrs.field(validator=..., converter=...)` | +| An empty list as a default | `tags: list[str] = []` | `tags: list[str] = []` | `tags: list[str] = attrs.field(factory=list)` | +| Adopting an existing class | `SpecAdapter` from each backend module | same | same | + +Speed and memory are measured in [Benchmarks](../benchmarks.md). + +## What to know about each + +### msgspec and attrs: the constructor does not check types + +That is how both libraries work, and mongospecs does not add a check on `insert()` or `save()`: + +```py +doc = Counter(n="many") # no error: msgspec and attrs do not validate here +await doc.insert() # stored as a string +await Counter.get(doc.id) # ValidationError: reads are validated +``` + +Values that come from outside (a request body, a file, another service) should go through +something that validates: `Model.update(id, n=value)`, `Model.insert_many([{"n": value}])` (a +mapping is validated; an instance is taken as it is), a `mongospecs-web` resource, or +`msgspec.convert(data, Counter)`. Values your own typed code builds are covered by your type +checker. + +### pydantic: validators and single-field updates + +- Models are validated when constructed. `Spec` sets `validate_assignment=False`, as pydantic does + by default, so a value assigned to an attribute afterwards is not validated, and is saved as it is. +- `Model.update(id, low=5)` and an HTTP `PATCH` validate one field at a time. The field's type, + constraints and `field_validator`s run. A `model_validator` that compares fields does not: it + would be comparing the new value with the other fields' defaults, not with what is stored. If a + rule spans fields, enforce it where you have the whole document: load it, change it, `save()`. +- `id` is declared with the alias `_id`; both names are accepted when constructing. +- The `pydantic.mypy` plugin works as usual. + +### attrs: decorate every model + +A subclass of `Spec` that is not decorated with `@attrs.define(kw_only=True)` gets no fields of +its own. `kw_only=True` is needed because `id` has a default and your required fields come after +it. + +## What is the same everywhere + +- The `id` attribute, stored as `_id`; `__spec__ = SpecConfig(...)` assigned without an annotation. +- Field markers in `typing.Annotated`: `CreatedAt`, `Revision`, `Immutable`, `Policy(...)` and the + rest (see [Models](models.md)). +- Embedded models are plain classes of the same library, without the `Spec` base. +- Two documents are equal when their backend says so (field by field), not when their ids match. +- Models from different backends can be bound to the same registry. diff --git a/docs/guide/errors.md b/docs/guide/errors.md new file mode 100644 index 0000000..a36ce18 --- /dev/null +++ b/docs/guide/errors.md @@ -0,0 +1,69 @@ +# Errors + +mongospecs raises its own exceptions, all subclasses of `MongoSpecsError` and all importable from +`mongospecs`. When one wraps a driver error, that error is its `__cause__`. + +```py +from mongospecs import DuplicateKeyError + +try: + await User(email="ann@example.com").insert() +except DuplicateKeyError as exc: + print(exc.fields) # {"email": "ann@example.com"} +``` + +## What each one means + +The last column is what a `mongospecs-web` API answers when the error reaches it. Anything not +listed becomes a 500 with no detail, and the cause is logged. + +| Error | Raised when | What to do | HTTP | +|---|---|---|---| +| `ValidationError` | a value does not fit the model: on read, in `update()`, in a request body. `.errors` lists `{"field", "message"}` | fix the input; for stored documents see `SpecConfig(upcast=...)` and `find().on_invalid(...)` | 422 | +| `QueryError` | a filter, sort or update names an unknown field, has a value that cannot be cast, or a raw dict contains an operator | fix the query; wrap filters you wrote yourself in `trusted()` | 400 | +| `DocumentNotFound` | `one()`, `or_fail()`, `update()`, `save()` or `delete()` matched nothing | treat as "gone" | 404 | +| `StaleDocumentError` | a `Revision` check failed: the document changed since you loaded it | reload and retry, or tell the user | 412 | +| `DuplicateKeyError` | a unique index was violated. `.fields` maps index keys to the offending values | report the conflict | 409 | +| `EncodingError` | a value has no BSON form (an integer beyond 64 bits, an unsupported type) | store a type MongoDB has | 422 | +| `NotLoadedError` | `ref.doc` was read before the reference was loaded | add `.include("field")` or `await ref.fetch()` | 500 | +| `OperationTimeout` | the operation passed its time limit (`max_time_ms`) | add an index, raise the limit, or retry | 504 | +| `PoolTimeout` | no pooled connection became free in time | the server is overloaded; see [Tuning for production](production.md) | 503 | +| `ConnectionFailed` | the server could not be reached | retry later | 503 | +| `TransactionError` | a transaction could not be committed, or `transaction()` was called inside another | pass the outer `tx` down | 500 | +| `DocumentTooLarge` | a document or an aggregation result passed 16 MB | split the document | 500 | +| `BulkWriteFailed` | part of a bulk write failed. `.write_errors` lists the items by input index | handle per item | 500 | +| `OperationFailed` | the server rejected the operation for another reason. `.code` is the server's error code | depends on the code | 500 | +| `ConfigurationError` | a model, plugin or registry is declared incorrectly; raised when the model is first used | fix the declaration | at startup | +| `NotBoundError` | a model was used before it was given to a registry | add it to `Registry(models=[...])` | at startup | +| `RegistryClosedError` | an operation ran on a registry that is not open | open it first; a closed registry cannot be reopened | 500 | + +`QueryError`, `ValidationError` and `EncodingError` are also `ValueError`s, and `DocumentNotFound` +is a `LookupError`, so code that already catches those keeps working. + +Batch operations that partly succeed (`bulk_save`) raise after writing what they could; the +documents that were **not** written are in the error's `.failed`. + +## Errors of your own, over HTTP + +In a guard, hook or custom operation, raise one of the errors from `mongospecs_web` to choose the +status yourself: `BadRequest`, `Unauthorized`, `Forbidden`, `NotFound`, `Conflict`, +`PreconditionFailed`, `Unprocessable`, or `HTTPError(detail, status=..., title=...)`. + +```py +from mongospecs_web import Conflict + +@orders.op("ship", method="POST") +async def ship(ctx): + order = await ctx.load() + if order.shipped: + raise Conflict("This order has already shipped.") + ... +``` + +Every error response is an RFC 9457 `application/problem+json` document: + +```json +{"type": "about:blank", "title": "Unprocessable Content", "status": 422, + "detail": "The request body failed validation.", + "errors": [{"field": "title", "message": "Expected `str`, got `int`"}]} +``` diff --git a/docs/guide/operations.md b/docs/guide/operations.md index 536ac7f..fa01624 100644 --- a/docs/guide/operations.md +++ b/docs/guide/operations.md @@ -63,17 +63,8 @@ ready-made listeners. With the Sentry SDK, database work shows up as `Account.in ## Errors -mongospecs raises its own errors and keeps the driver error as `__cause__`. - -| Error | When | -|---|---| -| `ValidationError` | input does not fit the model | -| `QueryError` | unknown field, uncastable value, untrusted operator | -| `DocumentNotFound` | an acknowledged read or write matched nothing | -| `StaleDocumentError` | a revision check failed | -| `DuplicateKeyError` | unique index violated; `.fields` names the values | -| `OperationTimeout`, `ConnectionFailed`, `PoolTimeout` | the database is slow or unreachable | -| `DocumentTooLarge` | a document or pipeline result exceeded 16MB | +mongospecs raises its own errors and keeps the driver error as `__cause__`. See [Errors](errors.md) +for each one, what to do about it and what an API client sees. ## One registry per model, or explicit managers diff --git a/docs/index.md b/docs/index.md index 90f345f..35c6a47 100644 --- a/docs/index.md +++ b/docs/index.md @@ -8,19 +8,25 @@ pip install "mongospecs[msgspec]" # or [pydantic], [attrs] pip install "mongospecs-web[fastapi]" # or [starlette], [litestar], [quart]; none needed for plain ASGI ``` -Requires Python 3.11+ and MongoDB 7.0+. Status: alpha. +Requires Python 3.11+ and MongoDB 7.0+ running as a replica set (one node is enough; transactions, +cascading deletes and change streams need it). Status: alpha. ## Where to start -- [Models](guide/models.md): declaring documents, binding them, field markers +- **[Getting started](tutorial.md)**: MongoDB, a first model, a REST API and its tests, in one page +- [Models](guide/models.md): declaring documents, binding them, field markers; + [Choosing a backend](guide/backends.md) - [Queries](guide/queries.md) and [Writes and transactions](guide/writes.md) - [Relations](guide/relations.md), [Hooks and plugins](guide/plugins.md) -- [Running in production](guide/operations.md): indexes, observability, errors -- [Resources](web/resources.md) and [Plugs](web/plugs.md): the REST layer +- [Errors](guide/errors.md): what each one means and what a client sees +- [Running in production](guide/operations.md): indexes, observability +- [Resources](web/resources.md), [Authentication and authorization](web/auth.md), + [Testing your API](web/testing.md) and [Plugs](web/plugs.md): the REST layer - [Coming from Mongoose](parity.md), [Migrating from 0.1.x](migration.md) - [Benchmarks](benchmarks.md): measured against real MongoDB -Every example in the guide is executed by the test suite. +Every example in the guide is executed by the test suite, and so is everything in +[`examples/`](https://github.com/jaykv/mongospecs/tree/main/examples). ## What it looks like diff --git a/docs/reference/bulk.md b/docs/reference/bulk.md deleted file mode 100644 index e54108c..0000000 --- a/docs/reference/bulk.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.bulk - -::: mongospecs.bulk diff --git a/docs/reference/errors.md b/docs/reference/errors.md deleted file mode 100644 index 4c9c04d..0000000 --- a/docs/reference/errors.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.errors - -::: mongospecs.errors diff --git a/docs/reference/expr.md b/docs/reference/expr.md deleted file mode 100644 index 62e8f7e..0000000 --- a/docs/reference/expr.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.expr - -::: mongospecs.expr diff --git a/docs/reference/hooks.md b/docs/reference/hooks.md deleted file mode 100644 index 19139a3..0000000 --- a/docs/reference/hooks.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.hooks - -::: mongospecs.hooks diff --git a/docs/reference/indexes.md b/docs/reference/indexes.md deleted file mode 100644 index c6f622a..0000000 --- a/docs/reference/indexes.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.indexes - -::: mongospecs.indexes diff --git a/docs/reference/manager.md b/docs/reference/manager.md deleted file mode 100644 index a810f89..0000000 --- a/docs/reference/manager.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.manager - -::: mongospecs.manager diff --git a/docs/reference/markers.md b/docs/reference/markers.md deleted file mode 100644 index 052b33c..0000000 --- a/docs/reference/markers.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.markers - -::: mongospecs.markers diff --git a/docs/reference/model.md b/docs/reference/model.md deleted file mode 100644 index b27b3db..0000000 --- a/docs/reference/model.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.model - -::: mongospecs.model diff --git a/docs/reference/query.md b/docs/reference/query.md deleted file mode 100644 index 705ce75..0000000 --- a/docs/reference/query.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.query - -::: mongospecs.query diff --git a/docs/reference/refs.md b/docs/reference/refs.md deleted file mode 100644 index 4cddce7..0000000 --- a/docs/reference/refs.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.refs - -::: mongospecs.refs diff --git a/docs/reference/registry.md b/docs/reference/registry.md deleted file mode 100644 index 0a4ef3b..0000000 --- a/docs/reference/registry.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.registry - -::: mongospecs.registry diff --git a/docs/reference/relations.md b/docs/reference/relations.md deleted file mode 100644 index 0197c5f..0000000 --- a/docs/reference/relations.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.relations - -::: mongospecs.relations diff --git a/docs/reference/streams.md b/docs/reference/streams.md deleted file mode 100644 index 5efd0d1..0000000 --- a/docs/reference/streams.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.streams - -::: mongospecs.streams diff --git a/docs/reference/updates.md b/docs/reference/updates.md deleted file mode 100644 index 67328f7..0000000 --- a/docs/reference/updates.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs.updates - -::: mongospecs.updates diff --git a/docs/reference/web_api.md b/docs/reference/web_api.md deleted file mode 100644 index fe84991..0000000 --- a/docs/reference/web_api.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs_web.api - -::: mongospecs_web.api diff --git a/docs/reference/web_backend.md b/docs/reference/web_backend.md deleted file mode 100644 index 073ed9e..0000000 --- a/docs/reference/web_backend.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs_web.backend - -::: mongospecs_web.backend diff --git a/docs/reference/web_errors.md b/docs/reference/web_errors.md deleted file mode 100644 index d43683b..0000000 --- a/docs/reference/web_errors.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs_web.errors - -::: mongospecs_web.errors diff --git a/docs/reference/web_filters.md b/docs/reference/web_filters.md deleted file mode 100644 index a381d9e..0000000 --- a/docs/reference/web_filters.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs_web.filters - -::: mongospecs_web.filters diff --git a/docs/reference/web_openapi.md b/docs/reference/web_openapi.md deleted file mode 100644 index 4ef594c..0000000 --- a/docs/reference/web_openapi.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs_web.openapi - -::: mongospecs_web.openapi diff --git a/docs/reference/web_policy.md b/docs/reference/web_policy.md deleted file mode 100644 index 56aef52..0000000 --- a/docs/reference/web_policy.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs_web.policy - -::: mongospecs_web.policy diff --git a/docs/reference/web_resource.md b/docs/reference/web_resource.md deleted file mode 100644 index 8a70204..0000000 --- a/docs/reference/web_resource.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs_web.resource - -::: mongospecs_web.resource diff --git a/docs/reference/web_testing.md b/docs/reference/web_testing.md deleted file mode 100644 index 03cc34a..0000000 --- a/docs/reference/web_testing.md +++ /dev/null @@ -1,3 +0,0 @@ -# mongospecs_web.testing - -::: mongospecs_web.testing diff --git a/docs/tutorial.md b/docs/tutorial.md new file mode 100644 index 0000000..55525fe --- /dev/null +++ b/docs/tutorial.md @@ -0,0 +1,122 @@ +# Getting started + +This page takes you from nothing to a working model, and then to a REST API over it. Every file +shown here is in [`examples/`](https://github.com/jaykv/mongospecs/tree/main/examples) and is run +by the test suite. + +## 1. What you need + +- Python 3.11 or newer. +- MongoDB 7.0 or newer, running as a **replica set**. A single node is enough. Transactions, + cascading deletes and change streams do not work on a standalone `mongod`; everything else does. + +If you have Docker, this starts a one-node replica set on `localhost:27017`: + +```yaml title="compose.yaml" +--8<-- "examples/compose.yaml" +``` + +``` +docker compose -f compose.yaml up -d --wait +``` + +MongoDB Atlas works too (every Atlas cluster is a replica set): use its connection string +wherever you see `mongodb://localhost:27017/...` below. + +## 2. Install + +Pick the model library you want to write your models in. If you have no preference, start with +msgspec; [Choosing a backend](guide/backends.md) compares the three. + +``` +pip install "mongospecs[msgspec]" # or [pydantic], or [attrs] +``` + +## 3. A model, and what you can do with it + +```python title="quickstart.py" +--8<-- "examples/quickstart.py" +``` + +``` +$ python quickstart.py +Hello, world by ann +tags: ['intro', 'published'] +``` + +What happened there: + +- **A model is a class.** `Spec` is the base for the backend you chose; everything else comes from + `mongospecs` and is the same on all three. Every document has an `id`, stored as `_id`. +- **A `Registry` owns the connection.** Models do nothing until they are in an open registry. + `async with` opens it and closes it; in a web application you open it once at startup. +- **Nothing is built implicitly.** `apply_indexes()` creates the indexes you declared. +- **`save()` sends only what changed**, and `changes()` shows you what that is. +- **Queries are typed.** `Q.views >= 0` checks that `views` is a field and casts the value; a + misspelt name raises `QueryError` instead of matching nothing. +- **A `Ref` is not loaded until you ask**: `.include("author")` loads it with the query. +- **`Model.update(id, ...)` is one atomic command** and returns the updated document. +- **A transaction is a function.** It may run more than once, and your in-memory documents are + put back if it aborts. + +Each of these has a page: [Models](guide/models.md), [Queries](guide/queries.md), +[Writes and transactions](guide/writes.md), [Relations](guide/relations.md). + +!!! note "Top-level await" + The guide pages use `await` at the top level to stay short. In your own code that goes inside + an `async def`, as in the file above, or straight into `python -m asyncio`. + +## 4. A REST API over the model + +`mongospecs-web` describes an API as data and mounts it on plain ASGI, Starlette, FastAPI, +Litestar or Quart. This example uses plain ASGI, so the only other thing to install is a server. + +``` +pip install mongospecs-web uvicorn +``` + +```python title="app.py" +--8<-- "examples/todo_api/app.py" +``` + +``` +$ uvicorn app:app +$ curl -s -X POST localhost:8000/api/tasks -H 'authorization: Bearer ann-token' \ + -H 'content-type: application/json' -d '{"title": "Write docs"}' +{"data":{"id":"...","title":"Write docs","done":false,"owner":"ann","created_at":"...","rev":0}} +$ curl -s 'localhost:8000/api/tasks?done=false&sort=-title' -H 'authorization: Bearer ann-token' +$ curl -s localhost:8000/api/openapi.json +``` + +The parts: + +- **`Policy` on each field is the contract.** It says whether a client may set the field, change + it, see it, filter by it and sort by it. `owner` cannot be written by clients; `note_to_self` + never leaves the server. +- **`authenticate`** turns a request into a principal (anything you like), or `None`. +- **Guards** decide who may call an operation; **`scope`** decides which rows they may touch, and + is part of every query, so Bob gets a 404 for Ann's task rather than a 403. +- **`@tasks.op`** adds an operation of your own next to the built-in ones. +- **`rev`** is a `Revision` field, so responses carry an `ETag` and writes honour `If-Match`. + +More in [Resources](web/resources.md) and +[Authentication and authorization](web/auth.md). + +## 5. Test it + +`api.handle()` runs a request through routing, authentication, guards, validation and the +database, without a server: + +```python title="test_app.py" +--8<-- "examples/todo_api/test_app.py" +``` + +[Testing your API](web/testing.md) covers the other ways, including an in-memory backend. + +## Where next + +- Coming from Mongoose? [The parity table](parity.md) maps what you know. +- Coming from mongospecs 0.1? [Migrating from 0.1.x](migration.md). +- Going to production? [Running in production](guide/operations.md) and + [Tuning for production](guide/production.md). +- Something raised an error you do not recognise? [Errors](guide/errors.md). diff --git a/docs/web/auth.md b/docs/web/auth.md new file mode 100644 index 0000000..3a67f19 --- /dev/null +++ b/docs/web/auth.md @@ -0,0 +1,168 @@ +# Authentication and authorization + +Four things decide what a request may do. They are separate on purpose: each answers one question. + +| | Question | Where | +|---|---|---| +| `authenticate` | Who is calling? | `Api(authenticate=...)` | +| Guards | May they call this operation at all? | `Resource(guards=...)`, `@resource.op(guards=...)` | +| `scope` | Which documents may they see and change? | `Resource(scope=...)` | +| `Policy` | Which fields may they set, change and see? | on each field of the model | + +```python +from typing import Annotated, NamedTuple + +from mongospecs import Policy, Q, Registry, SpecConfig +from mongospecs.msgspec import Spec +from mongospecs_web import Api, Create, Delete, Forbidden, Get, List, Patch, Resource, Unauthorized + + +class Report(Spec): + __spec__ = SpecConfig(collection="reports") + title: str = "" + team: Annotated[str, Policy(insert=False, update=False)] = "" # set by the server + approved: Annotated[bool, Policy(insert=False, update=False)] = False # changed only by an operation + review_notes: Annotated[str | None, Policy(private=True)] = None # never sent to clients +``` + +## Who is calling: `authenticate` + +`authenticate(request)` returns the principal, which can be anything: a user id, a dataclass, a +model. It may be `async`. Return `None` for an anonymous request and let guards decide whether that +is allowed; raise `Unauthorized` when credentials are present but wrong. + +```python +class Principal(NamedTuple): + name: str + team: str + admin: bool = False + + +SESSIONS = {"t-ann": Principal("ann", "red"), "t-bob": Principal("bob", "blue"), "t-root": Principal("root", "red", True)} + + +async def authenticate(request): + token = request.headers.get("authorization", "").removeprefix("Bearer ") + if not token: + return None + if token not in SESSIONS: # look the token up in your session store, or verify a JWT here + raise Unauthorized("Unknown token.", headers={"www-authenticate": "Bearer"}) + return SESSIONS[token] +``` + +`request` is the framework-neutral `mongospecs_web.Request`; `request.raw` is your framework's own +request object when you need it (a session cookie, `request.state`). + +## May they call this: guards + +A guard is `guard(ctx) -> bool`, sync or async. Returning false is a 403; raise `Unauthorized` for +a 401. `ctx.principal` is what `authenticate` returned. + +```python +def logged_in(ctx): + if ctx.principal is None: + raise Unauthorized("Log in first.", headers={"www-authenticate": "Bearer"}) + return True + + +def is_admin(ctx): + return ctx.principal is not None and ctx.principal.admin +``` + +`guards=[...]` applies to every operation; `guards={Delete: is_admin}` to the ones named. A key +must name an operation the resource has, so a misspelt one is an error and not an open door. + +## Which documents: `scope` + +`scope(ctx)` returns a condition that is ANDed into list, get, patch, put and delete, in the +database query itself. A document outside the scope does not exist for that caller: they get a +404, not a 403, and it never appears in a list or a count. Return `None` for "no restriction". + +```python +def own_team(ctx): + return None if ctx.principal.admin else Q.team == ctx.principal.team + + +reports = Resource( + Report, + guards={List: logged_in, Get: logged_in, Create: logged_in, Patch: logged_in, Delete: [logged_in, is_admin]}, + scope=own_team, + server_fields=lambda ctx: {"team": ctx.principal.team}, # the server, not the client, says whose it is +) +``` + +`server_fields` is the other half: on create it sets the fields the scope filters on, so a client +cannot create a document in someone else's scope. Those fields stay closed to clients with +`Policy(insert=False, update=False)`. + +## Which fields: `Policy` + +| On a field | A client can | +|---|---| +| nothing | set it on create, change it, read it | +| `Policy(update=False)` | set it once, on create | +| `Policy(insert=False, update=False)` | only read it | +| `Policy(private=True)` | not see it, set it, filter or sort by it | +| `Policy(private=True, insert=True)` | send it on create and never read it back (a password) | +| `Policy(filter=("eq", "gte"), sort=True)` | use it in the query string | + +The same applies to the fields of embedded models, at any depth. A request that names a field it +may not write is rejected as a whole with a 422. If you would rather list what is writable than +what is not, `Resource(Model, explicit_writes=True)` makes every field read-only unless its policy +says `insert=True` or `update=True`. + +## A change only some people may make: a custom operation + +A field clients cannot write is changed by an operation with its own guard. `ctx.load()` applies +the scope, so the guard and the scope still both hold. + +```python +@reports.op("approve", method="POST", guards=[logged_in, is_admin]) +async def approve(ctx): + report = await ctx.load() + report.approved = True + return await report.save() + + +api = Api(reports, prefix="/api", authenticate=authenticate) +registry = await Registry("mongodb://localhost/app", models=[Report]).open() +``` + +## All of it at work + +```python +import json + + +async def call(method, path, token=None, body=None): + headers = {"content-type": "application/json", **({"authorization": f"Bearer {token}"} if token else {})} + response = await api.handle(method, path, headers=headers, body=json.dumps(body).encode() if body else b"") + return response.status, json.loads(response.body) if response.body else None + + +assert (await call("GET", "/api/reports"))[0] == 401 # anonymous: the guard says no +assert (await call("GET", "/api/reports", "t-nobody"))[0] == 401 # bad credentials: authenticate says no + +status, created = await call("POST", "/api/reports", "t-ann", {"title": "Q3"}) +url = f"/api/reports/{created['data']['id']}" +assert status == 201 and created["data"]["team"] == "red" # server_fields +assert "review_notes" not in created["data"] # private + +assert (await call("GET", url, "t-bob"))[0] == 404 # another team: outside the scope +assert (await call("PATCH", url, "t-ann", {"approved": True}))[0] == 422 # the policy closes the field +assert (await call("POST", f"{url}/approve", "t-ann"))[0] == 403 # logged in, but not an admin +assert (await call("POST", f"{url}/approve", "t-root"))[1]["data"]["approved"] is True +assert (await call("DELETE", url, "t-ann"))[0] == 403 and (await call("DELETE", url, "t-root"))[0] == 204 + +await registry.client.drop_database("app") +await registry.close() +``` + +## Checklist + +- Every write operation has a guard, including `Put` if you list it and every custom operation. +- Every field the scope filters on is set by `server_fields` and closed with + `Policy(insert=False, update=False)`. +- Secrets are `Policy(private=True)`, and `Deferred` too if they should not even be loaded. +- `Api(openapi=False)` if the API description itself should not be public. +- `Forbidden` and `Unauthorized` can be raised from anywhere inside an operation. diff --git a/docs/web/testing.md b/docs/web/testing.md new file mode 100644 index 0000000..ad1bd04 --- /dev/null +++ b/docs/web/testing.md @@ -0,0 +1,135 @@ +# Testing your API + +There are three ways to test a resource, from fastest to most complete. All of them run your real +guards, scope, field policy and validation; they differ in what stands behind them. + +| | Runs | Needs | +|---|---|---| +| `api.handle(...)` with `FakeBackend` | routing, auth, policy, validation, your operations | nothing | +| `api.handle(...)` with MongoDB | all of that, plus real queries, indexes and transactions | MongoDB | +| An HTTP client on the mounted app | all of that, plus the framework plug | MongoDB | + +```python +import json +from typing import Annotated + +from mongospecs import Index, Policy, Q, Registry, Revision, SpecConfig +from mongospecs.msgspec import Spec +from mongospecs_web import Api, Resource +from mongospecs_web.testing import FakeBackend + + +class Task(Spec): + __spec__ = SpecConfig(collection="tasks", indexes=[Index("owner", "title", unique=True)]) + title: Annotated[str, Policy(filter=("eq",), sort=True)] + done: bool = False + owner: Annotated[str, Policy(insert=False, update=False)] = "" + rev: Annotated[int, Revision] = 0 + + +def build_api(backend=None): + tasks = Resource( + Task, + backend=backend, # None: MongoDB + scope=lambda ctx: Q.owner == ctx.principal, + server_fields=lambda ctx: {"owner": ctx.principal}, + ) + return Api(tasks, prefix="/api", authenticate=lambda request: request.headers.get("x-user")) + + +async def call(api, method, path, body=None, user="ann", **headers): + response = await api.handle( + method, + path, + headers={"x-user": user, "content-type": "application/json", **headers}, + body=json.dumps(body).encode() if body is not None else b"", + ) + return response.status, json.loads(response.body) if response.body else None, response.headers +``` + +Build the `Api` in a function, as above, so tests can make one of their own. + +## Without a database: `FakeBackend` + +`FakeBackend(Model)` keeps documents in memory. No registry is opened and nothing is connected, so +these tests run anywhere and in microseconds. Ids are sequential, which makes them predictable. + +```python +store = FakeBackend(Task, unique=["title"]) +api = build_api(store) + +status, created, headers = await call(api, "POST", "/api/tasks", {"title": "Write docs"}) +assert status == 201 and created["data"]["owner"] == "ann" +assert (await call(api, "GET", headers["location"], user="bob"))[0] == 404 # the scope applies +assert (await call(api, "PATCH", headers["location"], {"owner": "bob"}))[0] == 422 # so does the policy +assert (await call(api, "POST", "/api/tasks", {"title": "Write docs"}))[0] == 409 # unique=["title"] +assert [task.title for task in store.docs.values()] == ["Write docs"] # look inside directly +``` + +What it does not do: it evaluates the filters a resource can produce, not arbitrary queries; it +has no indexes beyond `unique=`, no transactions and no relations to `include`. A custom operation +that calls `Task.find(...)` itself talks to MongoDB and needs the next kind of test. + +## With MongoDB: `api.handle` + +The same calls against a real database. Open a registry for the test and drop its database +afterwards; a registry cannot be reopened once closed, so each test (or session) makes its own. + +```python +registry = await Registry("mongodb://localhost/app", models=[Task]).open() +await registry.apply_indexes() +api = build_api() + +status, created, headers = await call(api, "POST", "/api/tasks", {"title": "Write docs"}) +assert status == 201 and headers["etag"] == '"0"' + +# things only the database decides: the unique index, the revision check +assert (await call(api, "POST", "/api/tasks", {"title": "Write docs"}))[0] == 409 +url = headers["location"] +assert (await call(api, "PATCH", url, {"done": True}, **{"if-match": '"0"'}))[0] == 200 +assert (await call(api, "PATCH", url, {"done": False}, **{"if-match": '"0"'}))[0] == 412 + +# and the documents are there to be checked with the model +assert (await Task.find(Q.owner == "ann").one()).done is True +``` + +With pytest and `pytest-asyncio` that becomes a fixture: + +```py +@pytest_asyncio.fixture +async def api(): + async with Registry(TEST_URI, database=f"test_{uuid.uuid4().hex[:8]}", models=[Task]) as registry: + await registry.apply_indexes() + yield build_api() + await registry.client.drop_database(registry.database().name) +``` + +## Through the framework: an HTTP client + +To test what a real client sees, including your framework's own routing and middleware, mount the +API and drive it with `httpx`. No server process is needed. + +```python +import httpx + +async with httpx.AsyncClient(transport=httpx.ASGITransport(app=api.asgi()), base_url="http://test") as client: + listed = await client.get("/api/tasks", params={"sort": "-title"}, headers={"x-user": "ann"}) + assert listed.status_code == 200 and [t["title"] for t in listed.json()["data"]] == ["Write docs"] + assert (await client.get("/api/openapi.json")).json()["info"]["title"] == "API" + +await registry.client.drop_database("app") +await registry.close() +``` + +Replace `api.asgi()` with your Starlette, FastAPI, Litestar or Quart application. + +## What to assert + +- **Authorization, from the outside in**: anonymous, the wrong user, the right user, for each + operation. A document outside the caller's scope is a 404. +- **The field policy**: a write that names a closed field is a 422 and changes nothing. +- **Errors**: responses are `application/problem+json`; field errors are in `errors`. +- **Concurrency**, where it matters: two writers with the same `If-Match`, one of which gets 412. + +If you write a plug for another framework, `mongospecs_web.testing.run_contract` checks it against +the reference behaviour; see [Plugs](plugs.md). diff --git a/examples/compose.yaml b/examples/compose.yaml new file mode 100644 index 0000000..cd88962 --- /dev/null +++ b/examples/compose.yaml @@ -0,0 +1,25 @@ +# MongoDB for the examples: a single-node replica set on localhost:27017. +# Transactions and change streams need a replica set; one node is enough. +# docker compose -f examples/compose.yaml up -d --wait +name: mongospecs-examples +services: + mongo: + image: mongo:8.0 + command: ["mongod", "--replSet", "rs0", "--bind_ip_all"] + ports: + - "27017:27017" + volumes: + - data:/data/db + healthcheck: + test: + - CMD-SHELL + - > + mongosh --quiet --eval + "try { if (!db.hello().isWritablePrimary) throw 1 } catch (e) { + try { rs.initiate({_id: 'rs0', members: [{_id: 0, host: 'localhost:27017'}]}) } catch (e2) {} + quit(1) }" + interval: 2s + timeout: 10s + retries: 60 +volumes: + data: diff --git a/examples/quickstart.py b/examples/quickstart.py new file mode 100644 index 0000000..9fcbdf8 --- /dev/null +++ b/examples/quickstart.py @@ -0,0 +1,65 @@ +"""mongospecs in one file. Start MongoDB (see compose.yaml), then: python examples/quickstart.py""" + +import asyncio +import os +from datetime import datetime +from typing import Annotated + +from mongospecs import CreatedAt, Inc, Index, Q, Ref, Registry, Revision, SpecConfig, UpdatedAt +from mongospecs.msgspec import Spec # or mongospecs.pydantic / mongospecs.attrs + +URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017/quickstart?replicaSet=rs0") + + +class User(Spec): + name: str = "" + + +class Article(Spec): + __spec__ = SpecConfig(collection="articles", indexes=[Index("title", unique=True)]) + + title: str + views: int = 0 + tags: list[str] = [] + author: Ref[User] | None = None + created_at: Annotated[datetime | None, CreatedAt] = None + updated_at: Annotated[datetime | None, UpdatedAt] = None + rev: Annotated[int, Revision] = 0 # optimistic locking + + +async def main(uri: str = URI) -> None: + async with Registry(uri, models=[User, Article]) as registry: + await registry.apply_indexes() + + ann = await User(name="ann").insert() + article = await Article(title="Hello", author=ann).insert() + + # change tracking: save() sends only what changed + article.title = "Hello, world" + assert article.changes() == {"$set": {"title": "Hello, world"}} + await article.save() + + # typed queries: names are checked, values are cast + popular = await Article.find(Q.views >= 0, Q.title.prefix("Hello")).sort("-views").limit(10) + assert [a.title for a in popular] == ["Hello, world"] + + # relations: one extra query, or a $lookup + with_author = await Article.find(Q.author == ann).include("author").one() + print(with_author.title, "by", with_author.author.doc.name) + + # atomic updates, always validated; the updated document comes back + article = await Article.update(article.id, Inc(views=1), tags=["intro"]) + assert article.views == 1 + + # transactions (these need a replica set): the callback is retried on transient + # errors, and in-memory documents are restored if it aborts + async def publish(tx): + article.tags.append("published") + await article.save(session=tx) + + await registry.transaction(publish) + print("tags:", (await Article.get(article.id)).tags) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/test_quickstart.py b/examples/test_quickstart.py new file mode 100644 index 0000000..27cfe14 --- /dev/null +++ b/examples/test_quickstart.py @@ -0,0 +1,55 @@ +"""The quickstart is run as written, and the README shows exactly that file.""" + +import importlib.util +import os +import pathlib +import re +import uuid + +HERE = pathlib.Path(__file__).resolve().parent +ROOT = HERE.parent + + +def load(): + spec = importlib.util.spec_from_file_location("quickstart_example", HERE / "quickstart.py") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +async def test_the_quickstart_runs(capsys): + from mongospecs import Registry + + base = os.environ.get("MONGOSPECS_TEST_URI", "mongodb://localhost:27117/?replicaSet=rs0") + host, _, query = base.partition("?") + db = f"quickstart_{uuid.uuid4().hex[:8]}" + uri = f"{host.rstrip('/')}/{db}?{query}" + try: + await load().main(uri) + finally: + async with Registry(uri) as registry: + await registry.client.drop_database(db) + assert capsys.readouterr().out == "Hello, world by ann\ntags: ['intro', 'published']\n" + + +def test_the_readmes_show_the_quickstart(): + source = (HERE / "quickstart.py").read_text() + for readme in (ROOT / "README.md", ROOT / "packages" / "mongospecs" / "README.md"): + blocks = re.findall(r"```python\n(.*?)```", readme.read_text(), re.S) + assert source in blocks, f"{readme} does not show examples/quickstart.py as it is" + + +def test_the_package_readme_is_the_root_readme(): + # PyPI shows the package README; it differs from the root one only in where links point. + root = (ROOT / "README.md").read_text() + package = (ROOT / "packages" / "mongospecs" / "README.md").read_text() + blob, tree = ( + "https://github.com/jaykv/mongospecs/blob/main/", + "https://github.com/jaykv/mongospecs/tree/main/", + ) + absolute = re.sub( + r"\]\((?!https?://|#)([^)]+)\)", + lambda m: f"]({tree if '.' not in m[1].rsplit('/', 1)[-1] else blob}{m[1]})", + root, + ) + assert package == absolute diff --git a/examples/todo_api/app.py b/examples/todo_api/app.py new file mode 100644 index 0000000..a3be7a7 --- /dev/null +++ b/examples/todo_api/app.py @@ -0,0 +1,72 @@ +"""A small REST API with mongospecs-web: per-user to-do lists. + + pip install "mongospecs[msgspec]" mongospecs-web uvicorn + docker compose -f examples/compose.yaml up -d --wait + uvicorn app:app --app-dir examples/todo_api + + curl -s -X POST localhost:8000/api/tasks -H 'authorization: Bearer ann-token' \ + -H 'content-type: application/json' -d '{"title": "Write docs"}' + curl -s 'localhost:8000/api/tasks?done=false&sort=-title' -H 'authorization: Bearer ann-token' +""" + +import os +from datetime import datetime +from typing import Annotated + +from mongospecs import CreatedAt, Index, Policy, Q, Registry, Revision, SpecConfig +from mongospecs.msgspec import Spec +from mongospecs_web import Api, Create, Delete, Get, List, Patch, Resource, Unauthorized + +URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017/todo?replicaSet=rs0") + +# Stands in for your real user store or token verification. +TOKENS = {"ann-token": "ann", "bob-token": "bob"} + + +class Task(Spec, kw_only=True): + __spec__ = SpecConfig(collection="tasks", indexes=[Index("owner", "title")]) + + # Policy says what a client may do with each field. + title: Annotated[str, Policy(filter=("eq", "prefix"), sort=True)] + done: Annotated[bool, Policy(filter=("eq",))] = False + owner: Annotated[str, Policy(insert=False, update=False)] = "" # set by the server + note_to_self: Annotated[str | None, Policy(private=True)] = None # never leaves the server + created_at: Annotated[datetime | None, CreatedAt] = None + rev: Annotated[int, Revision] = 0 # the ETag, checked by If-Match + + +def authenticate(request): + """Who is calling: the principal, or None for an anonymous request.""" + scheme, _, token = request.headers.get("authorization", "").partition(" ") + if not token: + return None + if scheme != "Bearer" or token not in TOKENS: + raise Unauthorized("Unknown token.", headers={"www-authenticate": "Bearer"}) + return TOKENS[token] + + +def logged_in(ctx): + if ctx.principal is None: + raise Unauthorized("Log in first.", headers={"www-authenticate": "Bearer"}) + return True + + +tasks = Resource( + Task, + ops=[List, Get, Create, Patch, Delete], + guards=[logged_in], # who may call the operations + scope=lambda ctx: Q.owner == ctx.principal, # which rows they may see and change + server_fields=lambda ctx: {"owner": ctx.principal}, # values the server sets on create +) + + +@tasks.op("complete", method="POST", summary="Mark a task as done") +async def complete(ctx): + task = await ctx.load() # 404 unless the task is within the caller's scope + task.done = True + return await task.save() + + +registry = Registry(URI, models=[Task]) +api = Api(tasks, prefix="/api", title="To-do", authenticate=authenticate) +app = api.asgi(registry=registry) # opens the registry on startup and closes it on shutdown diff --git a/examples/todo_api/conftest.py b/examples/todo_api/conftest.py new file mode 100644 index 0000000..0a912e8 --- /dev/null +++ b/examples/todo_api/conftest.py @@ -0,0 +1,9 @@ +import os +import sys +import uuid + +# The tests import the application as a module, and point it at a database of their own. +sys.path.insert(0, os.path.dirname(__file__)) +_base = os.environ.get("MONGOSPECS_TEST_URI", "mongodb://localhost:27117/?replicaSet=rs0") +_host, _, _query = _base.partition("?") +os.environ.setdefault("MONGO_URI", f"{_host.rstrip('/')}/todo_test_{uuid.uuid4().hex[:8]}?{_query}") diff --git a/examples/todo_api/test_app.py b/examples/todo_api/test_app.py new file mode 100644 index 0000000..4a3b891 --- /dev/null +++ b/examples/todo_api/test_app.py @@ -0,0 +1,59 @@ +"""Testing the API without a server: api.handle() runs a request through the whole stack.""" + +import json + +import pytest_asyncio + +from app import URI, Task, api +from mongospecs import Registry + +ANN = {"authorization": "Bearer ann-token", "content-type": "application/json"} +BOB = {"authorization": "Bearer bob-token", "content-type": "application/json"} + + +@pytest_asyncio.fixture(autouse=True) +async def database(): + # A registry cannot be reopened once closed, so each test opens its own. + async with Registry(URI, models=[Task]) as registry: + yield + await registry.client.drop_database(registry.database().name) + + +async def call(method, path, body=None, headers=ANN): + response = await api.handle( + method, path, body=json.dumps(body).encode() if body is not None else b"", headers=headers + ) + return response.status, json.loads(response.body) if response.body else None, response.headers + + +async def test_create_list_and_complete(): + status, created, headers = await call("POST", "/api/tasks", {"title": "Write docs"}) + assert status == 201 and created["data"]["owner"] == "ann" and headers["etag"] == '"0"' + assert "note_to_self" not in created["data"] # private fields are never returned + + status, listed, _ = await call("GET", "/api/tasks") + assert [task["title"] for task in listed["data"]] == ["Write docs"] + + status, done, _ = await call("POST", f"{headers['location']}/complete") + assert status == 200 and done["data"]["done"] is True + + +async def test_each_user_sees_only_their_own_tasks(): + _, _, headers = await call("POST", "/api/tasks", {"title": "Ann's"}) + assert (await call("GET", headers["location"], headers=BOB))[0] == 404 + assert (await call("GET", "/api/tasks", headers=BOB))[1]["data"] == [] + assert (await call("GET", "/api/tasks", headers={}))[0] == 401 + + +async def test_the_field_policy_is_enforced(): + _, _, headers = await call("POST", "/api/tasks", {"title": "Mine"}) + status, problem, _ = await call("PATCH", headers["location"], {"owner": "bob"}) + assert status == 422 and problem["errors"] == [{"field": "owner", "message": "cannot be set on update"}] + assert (await call("PATCH", headers["location"], {"note_to_self": "x"}))[0] == 422 + + +async def test_a_stale_etag_is_refused(): + _, _, headers = await call("POST", "/api/tasks", {"title": "Mine"}) + fresh = {**ANN, "if-match": headers["etag"]} + assert (await call("PATCH", headers["location"], {"done": True}, fresh))[0] == 200 + assert (await call("PATCH", headers["location"], {"done": False}, fresh))[0] == 412 diff --git a/mkdocs.yaml b/mkdocs.yaml index 89a9134..a01efc2 100644 --- a/mkdocs.yaml +++ b/mkdocs.yaml @@ -1,4 +1,7 @@ site_name: "mongospecs" +site_url: "https://jaykv.github.io/mongospecs/" +repo_url: "https://github.com/jaykv/mongospecs" +edit_uri: "edit/main/docs/" theme: name: "material" @@ -51,6 +54,8 @@ markdown_extensions: emoji_generator: !!python/name:material.extensions.emoji.to_svg - pymdownx.magiclink - pymdownx.snippets: + base_path: ["."] + check_paths: true - pymdownx.superfences - pymdownx.tabbed: alternate_style: true @@ -62,61 +67,27 @@ markdown_extensions: plugins: search: - mkdocstrings: - handlers: - python: - paths: [packages/mongospecs/src, packages/mongospecs-web/src] - options: - docstring_section_style: list - filters: ["!^_"] - heading_level: 2 - merge_init_into_class: true - separate_signature: true - show_root_heading: false - show_signature_annotations: true - show_source: false - signature_crossrefs: true nav: - Home: index.md +- Getting started: tutorial.md - Guide: - Models: guide/models.md + - Choosing a backend: guide/backends.md - Queries: guide/queries.md - Writes and transactions: guide/writes.md - Relations: guide/relations.md - Hooks and plugins: guide/plugins.md + - Errors: guide/errors.md - Running in production: guide/operations.md - Tuning for production: guide/production.md - Web: - Resources: web/resources.md + - Authentication and authorization: web/auth.md + - Testing your API: web/testing.md - Plugs: web/plugs.md - Benchmarks: benchmarks.md - Coming from Mongoose: parity.md - Migrating from 0.1.x: - Guide: migration.md - v1 test inventory: v1-test-inventory.md -- Reference: - - mongospecs: - - registry: reference/registry.md - - model: reference/model.md - - manager: reference/manager.md - - query: reference/query.md - - expr: reference/expr.md - - updates: reference/updates.md - - bulk: reference/bulk.md - - markers: reference/markers.md - - refs: reference/refs.md - - relations: reference/relations.md - - indexes: reference/indexes.md - - hooks: reference/hooks.md - - streams: reference/streams.md - - errors: reference/errors.md - - mongospecs-web: - - resource: reference/web_resource.md - - api: reference/web_api.md - - backend: reference/web_backend.md - - filters: reference/web_filters.md - - policy: reference/web_policy.md - - errors: reference/web_errors.md - - openapi: reference/web_openapi.md - - testing: reference/web_testing.md diff --git a/packages/mongospecs-web/README.md b/packages/mongospecs-web/README.md index 707c92c..f3b80e9 100644 --- a/packages/mongospecs-web/README.md +++ b/packages/mongospecs-web/README.md @@ -50,5 +50,11 @@ That is a complete API: `GET /api/tasks?title[prefix]=Wri&sort=-title&limit=20`, - **Framework-neutral.** The same resource mounts on five frameworks through thin plugs, and a contract suite (`mongospecs_web.testing`) checks that they all behave identically. -Guides: [resources](https://github.com/jaykv/mongospecs/blob/main/docs/web/resources.md), -[plugs](https://github.com/jaykv/mongospecs/blob/main/docs/web/plugs.md). +Start with [Getting started](https://jaykv.github.io/mongospecs/tutorial/), which builds an API +like this one step by step. Guides: [resources](https://jaykv.github.io/mongospecs/web/resources/), +[authentication and authorization](https://jaykv.github.io/mongospecs/web/auth/), +[testing](https://jaykv.github.io/mongospecs/web/testing/), +[plugs](https://jaykv.github.io/mongospecs/web/plugs/). + +Responses are encoded with msgspec when it is installed (`mongospecs-web[msgspec]`), and with the +standard library otherwise. diff --git a/packages/mongospecs/README.md b/packages/mongospecs/README.md index 1dc7a52..53f67fa 100644 --- a/packages/mongospecs/README.md +++ b/packages/mongospecs/README.md @@ -6,10 +6,13 @@ The aim is Mongoose's production feature set (change tracking, middleware, popul discriminators, transactions that restore document state) with typed Python models, plus [`mongospecs-web`](https://github.com/jaykv/mongospecs/tree/main/packages/mongospecs-web), a framework-neutral REST layer. +**Documentation: .** Start with +[Getting started](https://jaykv.github.io/mongospecs/tutorial/). + > **Status: 0.2.0, alpha.** This is a rewrite of 0.1.x: async-only, Python 3.11+, MongoDB 7.0+, > and not backwards compatible. The synchronous 0.1.x line lives on the -> [`0.1.x` branch](https://github.com/jaykv/mongospecs/tree/0.1.x). The design and the work graph -> are in [`claudedocs/v2-plan.md`](https://github.com/jaykv/mongospecs/blob/main/claudedocs/v2-plan.md). +> [`0.1.x` branch](https://github.com/jaykv/mongospecs/tree/0.1.x). Changes are listed in the +> [changelog](https://github.com/jaykv/mongospecs/blob/main/CHANGELOG.md). ## Install @@ -17,15 +20,31 @@ discriminators, transactions that restore document state) with typed Python mode pip install "mongospecs[msgspec]" # or [pydantic], [attrs], [all] ``` +You also need MongoDB 7.0 or newer. Transactions, cascading deletes and change streams need it to +run as a replica set; a single node is enough. [`examples/compose.yaml`](https://github.com/jaykv/mongospecs/blob/main/examples/compose.yaml) +starts one on `localhost:27017`: + +``` +docker compose -f examples/compose.yaml up -d --wait +``` + ## A taste +This is [`examples/quickstart.py`](https://github.com/jaykv/mongospecs/blob/main/examples/quickstart.py), as it is run by the test suite. + ```python +"""mongospecs in one file. Start MongoDB (see compose.yaml), then: python examples/quickstart.py""" + +import asyncio +import os from datetime import datetime from typing import Annotated from mongospecs import CreatedAt, Inc, Index, Q, Ref, Registry, Revision, SpecConfig, UpdatedAt from mongospecs.msgspec import Spec # or mongospecs.pydantic / mongospecs.attrs +URI = os.environ.get("MONGO_URI", "mongodb://localhost:27017/quickstart?replicaSet=rs0") + class User(Spec): name: str = "" @@ -43,8 +62,8 @@ class Article(Spec): rev: Annotated[int, Revision] = 0 # optimistic locking -async def main(): - async with Registry("mongodb://localhost/app", models=[User, Article]) as registry: +async def main(uri: str = URI) -> None: + async with Registry(uri, models=[User, Article]) as registry: await registry.apply_indexes() ann = await User(name="ann").insert() @@ -56,24 +75,34 @@ async def main(): await article.save() # typed queries: names are checked, values are cast - popular = await Article.find(Q.views >= 100, Q.title.prefix("Hello")).sort("-views").limit(10) + popular = await Article.find(Q.views >= 0, Q.title.prefix("Hello")).sort("-views").limit(10) + assert [a.title for a in popular] == ["Hello, world"] # relations: one extra query, or a $lookup with_author = await Article.find(Q.author == ann).include("author").one() - print(with_author.author.doc.name) + print(with_author.title, "by", with_author.author.doc.name) # atomic updates, always validated; the updated document comes back article = await Article.update(article.id, Inc(views=1), tags=["intro"]) + assert article.views == 1 - # transactions: the callback is retried on transient errors, and in-memory - # documents are restored if it aborts + # transactions (these need a replica set): the callback is retried on transient + # errors, and in-memory documents are restored if it aborts async def publish(tx): article.tags.append("published") await article.save(session=tx) await registry.transaction(publish) + print("tags:", (await Article.get(article.id)).tags) + + +if __name__ == "__main__": + asyncio.run(main()) ``` +A REST API over a model is a few more lines; see [`examples/todo_api`](https://github.com/jaykv/mongospecs/tree/main/examples/todo_api) and the +[web guide](https://jaykv.github.io/mongospecs/web/resources/). + ## What is in the box | | | @@ -96,7 +125,7 @@ async def main(): oversell, multi-item orders that are all-or-nothing, a change-stream projector that must count each sale exactly once across restarts, and orders that stay consistent across a primary step-down. -- Every example in the guide pages is executed. +- Every example in the guide pages and in `examples/` is executed. - Benchmarks and stress scenarios in `bench/` run against real MongoDB; results and the conclusions drawn from them are in `docs/benchmarks.md`. @@ -110,4 +139,6 @@ uv run mongospecs-bench run # benchmarks; `run LOAD` for the HTTP load ``` The repository is a uv workspace: `packages/mongospecs`, `packages/mongospecs-web` and `bench/` -(benchmarks and stress scenarios against real MongoDB, never published). +(benchmarks and stress scenarios against real MongoDB, never published). See +[CONTRIBUTING.md](https://github.com/jaykv/mongospecs/blob/main/CONTRIBUTING.md); the design decisions behind 0.2 are recorded in +[`claudedocs/v2-plan.md`](https://github.com/jaykv/mongospecs/blob/main/claudedocs/v2-plan.md). diff --git a/pyproject.toml b/pyproject.toml index 09120d4..840907e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,12 +32,11 @@ dev = [ ] docs = [ "mkdocs-material>=9.5", - "mkdocstrings[python]>=0.26", ] [tool.pytest.ini_options] addopts = "--import-mode=importlib -ra" -testpaths = ["packages/mongospecs/tests", "packages/mongospecs-web/tests", "bench/tests"] +testpaths = ["packages/mongospecs/tests", "packages/mongospecs-web/tests", "bench/tests", "examples"] asyncio_mode = "auto" asyncio_default_fixture_loop_scope = "session" asyncio_default_test_loop_scope = "session" @@ -49,7 +48,7 @@ markers = [ [tool.ruff] line-length = 110 target-version = "py311" -src = ["packages/mongospecs/src", "packages/mongospecs-web/src", "bench/src"] +src = ["packages/mongospecs/src", "packages/mongospecs-web/src", "bench/src", "examples/todo_api"] [tool.ruff.lint] select = ["E", "F", "I", "B", "UP", "ASYNC", "RUF"] @@ -57,6 +56,7 @@ ignore = ["E501", "RUF012"] [tool.ruff.lint.per-file-ignores] "**/tests/**" = ["B", "RUF"] +"examples/**" = ["B", "RUF"] # Benchmarks define closures inside loops and call them before the next iteration. "bench/**" = ["B023"] diff --git a/uv.lock b/uv.lock index f9291a1..3850a74 100644 --- a/uv.lock +++ b/uv.lock @@ -401,15 +401,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, ] -[[package]] -name = "griffelib" -version = "2.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/2b/27/b55f1a5278918be765fb2fd8b20966bc72bbdd3f789f031937cceea7834a/griffelib-2.3.2.tar.gz", hash = "sha256:df00c7a0dee3d86268d76788997a1859272cb1fb7b865658e043d2c0c3d52e60", size = 234729 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/e5/0ae74c83c1cab2c14daadf28bd0143eb14bb81503834af987b66badc7b61/griffelib-2.3.2-py3-none-any.whl", hash = "sha256:8e710afededd5607f95bf3c8ccc175ee306e7084459faf27b9f9f44ef2a7a274", size = 169420 }, -] - [[package]] name = "h11" version = "0.16.0" @@ -953,20 +944,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, ] -[[package]] -name = "mkdocs-autorefs" -version = "1.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/52/c0/f641843de3f612a6b48253f39244165acff36657a91cc903633d456ae1ac/mkdocs_autorefs-1.4.4.tar.gz", hash = "sha256:d54a284f27a7346b9c38f1f852177940c222da508e66edc816a0fa55fc6da197", size = 56588 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/28/de/a3e710469772c6a89595fc52816da05c1e164b4c866a89e3cb82fb1b67c5/mkdocs_autorefs-1.4.4-py3-none-any.whl", hash = "sha256:834ef5408d827071ad1bc69e0f39704fa34c7fc05bc8e1c72b227dfdc5c76089", size = 25530 }, -] - [[package]] name = "mkdocs-get-deps" version = "0.2.2" @@ -1012,42 +989,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, ] -[[package]] -name = "mkdocstrings" -version = "1.0.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mkdocs" }, - { name = "mkdocs-autorefs" }, - { name = "pymdown-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f5/e8/445b19834bf9559ebbbaa76897e965665b83d2861461eed8d8de4d122057/mkdocstrings-1.0.7.tar.gz", hash = "sha256:0951b147f352118a45a3db85f0f3b660833d34cb5120a2751315b8d08dafd159", size = 103739 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/c3/9062531735ff1fd27f3a79c353c4cf4d822eeffd062fd90b2c553a96f4bb/mkdocstrings-1.0.7-py3-none-any.whl", hash = "sha256:9563828667eeb35256f87a6b61fe857676ac628ec1abbce9aea9839a502c9cb1", size = 41597 }, -] - -[package.optional-dependencies] -python = [ - { name = "mkdocstrings-python" }, -] - -[[package]] -name = "mkdocstrings-python" -version = "2.0.9" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "griffelib" }, - { name = "mkdocs-autorefs" }, - { name = "mkdocstrings" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/96/11/640067d1ad713ea99c233e53e57a4c300d9885f904a61371d7ef90dea549/mkdocstrings_python-2.0.9.tar.gz", hash = "sha256:ae945637dc0618c6beedbee169f10c0234b77f322c53db3fc9fb4c29b3013038", size = 203006 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/62/0f/f7f926cfa06317bbdbd3cf0ff82f9e3a91767352eb01519c81499fddf575/mkdocstrings_python-2.0.9-py3-none-any.whl", hash = "sha256:c0233eff3f84d78110df50541918e7ec2bcff8e1614faddea34290a90321a416", size = 105698 }, -] - [[package]] name = "mongospecs" version = "0.2.0" @@ -1186,7 +1127,6 @@ dev = [ ] docs = [ { name = "mkdocs-material" }, - { name = "mkdocstrings", extra = ["python"] }, ] [package.metadata] @@ -1205,10 +1145,7 @@ dev = [ { name = "ruff", specifier = ">=0.8" }, { name = "sentry-sdk", specifier = ">=2.0" }, ] -docs = [ - { name = "mkdocs-material", specifier = ">=9.5" }, - { name = "mkdocstrings", extras = ["python"], specifier = ">=0.26" }, -] +docs = [{ name = "mkdocs-material", specifier = ">=9.5" }] [[package]] name = "msgspec" From c23ffbc92d610a5a13944e6a2910e25199caef81 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 10:33:35 -0400 Subject: [PATCH 29/31] chore: changelog, contributing and security files; publish the docs - CHANGELOG.md, CONTRIBUTING.md and SECURITY.md. - A workflow deploys the documentation to GitHub Pages from main. - The publish workflow refuses a release tag that does not match the version in the package it names. - Package metadata links to the documentation and the changelog. Co-Authored-By: Claude Opus 5.5 --- .github/workflows/docs.yaml | 24 ++++++++++++ .github/workflows/python-publish.yaml | 9 +++++ CHANGELOG.md | 49 +++++++++++++++++++++++++ CONTRIBUTING.md | 51 ++++++++++++++++++++++++++ SECURITY.md | 24 ++++++++++++ packages/mongospecs-web/pyproject.toml | 2 + packages/mongospecs/pyproject.toml | 2 + 7 files changed, 161 insertions(+) create mode 100644 .github/workflows/docs.yaml create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 SECURITY.md diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml new file mode 100644 index 0000000..64748f4 --- /dev/null +++ b/.github/workflows/docs.yaml @@ -0,0 +1,24 @@ +# Publishes the documentation site to GitHub Pages (the gh-pages branch) from main. +name: Docs + +on: + push: + branches: [main] + paths: ["docs/**", "examples/**", "mkdocs.yaml", ".github/workflows/docs.yaml"] + workflow_dispatch: + +permissions: + contents: write + +concurrency: + group: docs + cancel-in-progress: true + +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: astral-sh/setup-uv@v5 + - run: uv sync --group docs --python 3.13 + - run: uv run --group docs mkdocs gh-deploy --strict --force diff --git a/.github/workflows/python-publish.yaml b/.github/workflows/python-publish.yaml index 66a28d0..63221b0 100644 --- a/.github/workflows/python-publish.yaml +++ b/.github/workflows/python-publish.yaml @@ -42,5 +42,14 @@ jobs: mongospecs-v*) echo "package=mongospecs" >> "$GITHUB_OUTPUT" ;; *) echo "unrecognised release tag: $tag" >&2; exit 1 ;; esac + - name: The tag must be the version being published + run: | + package="${{ steps.pick.outputs.package }}" + declared="$(uv run --no-project python -c "import tomllib, sys; print(tomllib.load(open(sys.argv[1], 'rb'))['project']['version'])" "packages/$package/pyproject.toml")" + tagged="${GITHUB_REF_NAME#"$package"-v}" + if [ "$declared" != "$tagged" ]; then + echo "tag $GITHUB_REF_NAME says $tagged, but packages/$package/pyproject.toml says $declared" >&2 + exit 1 + fi - run: uv build --package ${{ steps.pick.outputs.package }} - uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..f7971e2 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,49 @@ +# Changelog + +Two packages are released from this repository; each entry says which one it is about. Versions +follow semantic versioning, and while a package is below 1.0 a minor release may break +compatibility. Breaking changes are marked **Breaking**. + +## Unreleased + +### mongospecs 0.2.0 + +A rewrite. See [Migrating from 0.1.x](docs/migration.md) for the API map. + +- **Breaking:** async only, Python 3.11+, MongoDB 7.0+. The synchronous 0.1.x line stays on the + `0.1.x` branch and as `mongospecs<0.2`. +- **Breaking:** models are bound to an explicit `Registry`; there is no class-level client. +- **Breaking:** msgspec is no longer installed implicitly; choose an extra (`[msgspec]`, + `[pydantic]`, `[attrs]`). +- Change tracking by snapshot: `save()` sends only what changed. +- Typed, immutable queries with `Q`; `tracked`, `lean()` and `raw()` read modes; cursor and offset + pagination. +- Atomic update operators, real upsert, `bulk_write`, `bulk_save`, partial-failure `insert_many`. +- Relations: `Ref`, `Backref`, `include()`, cascade rules, `$graphLookup`, `$unionWith`. +- Document hooks and plugins; timestamps, soft delete and revisions by field marker. +- Transactions as a retried callback that restores in-memory documents when it aborts. +- Index planning with gated destructive actions; change streams; per-command observability events + with logging, OpenTelemetry and Sentry listeners. +- Document hooks run for `insert_many` and `bulk_save`. +- A single-field update on a pydantic model runs the field's validators and not the model-level + ones, which could only see the other fields' defaults. + +### mongospecs-web 0.1.0 + +First release. + +- Resources described as data and mounted on plain ASGI, Starlette, FastAPI, Litestar or Quart. +- Field `Policy` drives the create, patch, put, read, filter and sort surface; guards and row + scope for authorization; RFC 9457 errors; ETag and `If-Match`; OpenAPI 3.1. +- `PATCH` merges into embedded objects (RFC 7396); `Resource(merge_patch=False)` makes it replace + them. `PUT` is available as the `Put` operation. + +Changes made after the first review builds, for anyone who tried those: + +- **Breaking:** `?total=true` is opt-in per resource (`page=Cursor(total=True)`). +- **Breaking:** `PATCH` on an embedded object is partial. Send `null` for keys to clear, or set + `merge_patch=False`. +- **Breaking:** `If-Match` on a model without a `Revision` field is answered with 412. +- **Breaking:** a filter or sort key given twice is a 400, as are `NaN` and `Infinity` in a body. +- **Breaking:** a guard or hook registered for an operation the resource does not have raises + `ValueError`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1ea1ddc --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,51 @@ +# Contributing + +## Setting up + +You need [uv](https://docs.astral.sh/uv/) and Docker. + +``` +make install # uv sync for the whole workspace +make up # MongoDB in Docker: a replica set on :27117 and a sharded cluster on :27118 +make check # ruff, mypy --strict, and the whole test suite +make down # stop MongoDB and delete its data +``` + +Tests run against real MongoDB; nothing is mocked. `make test-fast` leaves out the tests that +need the sharded cluster. To use another server, set `MONGOSPECS_TEST_URI` (a replica set) and +`MONGOSPECS_TEST_SHARDED_URI`. + +## Where things are + +| Path | What | +|---|---| +| `packages/mongospecs` | the ODM | +| `packages/mongospecs-web` | the REST layer and its framework plugs | +| `bench` | benchmarks, stress scenarios and the storefront reference application; never published | +| `examples` | the files shown in the tutorial; they are tests too | +| `docs` | the documentation site (`make docs` builds it) | +| `claudedocs/v2-plan.md` | the design of 0.2 and the record of every decision that departed from it | + +## What a change needs + +- **A test that fails without it.** For a bug, reproduce it first. Tests for behaviour that + differs by model library are parametrised over `msgspec`, `pydantic` and `attrs`. +- **Docs for anything a user can see.** `python` blocks in `docs/guide` and `docs/web` are + executed in order, per page, against MongoDB; `py` blocks are shown but not run. Prefer `python`. +- **A changelog entry** in `CHANGELOG.md`, marked **Breaking** when it is. +- **`make check` passing.** + +Keep changes to one concern. If a change departs from the design in `claudedocs/v2-plan.md`, add +an amendment row there saying what and why. + +## Plugs for other frameworks + +A plug only translates requests and responses. Prove it with the contract suite: +`mongospecs_web.testing.run_contract` must produce the same transcript as the reference plug. See +[Plugs](docs/web/plugs.md). + +## Releasing + +Each package is released by publishing a GitHub release whose tag names it: +`mongospecs-v0.2.0` or `mongospecs-web-v0.1.0`. The tag must match the version in that package's +`pyproject.toml`; the publish workflow checks this and runs the full suite before uploading. diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..143532a --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,24 @@ +# Security + +## Reporting a vulnerability + +Please do not open a public issue. Use GitHub's private reporting instead: +. + +Say what you found, how to reproduce it, and which package and version it affects. + +## Supported versions + +Fixes are made on the latest release of each package. `mongospecs` 0.1.x is no longer developed. + +## What the library is meant to guarantee + +These are the properties to hold a report against: + +- A plain mapping used as a filter cannot carry MongoDB operators unless the application wraps it + in `trusted()`. +- In `mongospecs-web`, nothing a client sends reaches MongoDB as a raw filter or update: fields + and operators are allowlisted by `Policy`, and values are cast to the field's type. +- A field marked `Policy(private=True)` is never part of a response or of the OpenAPI document. +- A resource's `scope` is part of the database query of every list, get, patch, put and delete. +- Request bodies are never read past `Api(max_body=...)`. diff --git a/packages/mongospecs-web/pyproject.toml b/packages/mongospecs-web/pyproject.toml index 867aeb3..edd4f2a 100644 --- a/packages/mongospecs-web/pyproject.toml +++ b/packages/mongospecs-web/pyproject.toml @@ -29,7 +29,9 @@ quart = ["quart>=0.19"] all = ["starlette>=0.40", "fastapi>=0.115", "litestar>=2.12", "quart>=0.19"] [project.urls] +Documentation = "https://jaykv.github.io/mongospecs/" Repository = "https://github.com/jaykv/mongospecs" +Changelog = "https://github.com/jaykv/mongospecs/blob/main/CHANGELOG.md" [tool.hatch.build.targets.wheel] packages = ["src/mongospecs_web"] diff --git a/packages/mongospecs/pyproject.toml b/packages/mongospecs/pyproject.toml index 87b5771..408b392 100644 --- a/packages/mongospecs/pyproject.toml +++ b/packages/mongospecs/pyproject.toml @@ -33,7 +33,9 @@ sentry = ["sentry-sdk>=2.0"] all = ["msgspec>=0.19", "pydantic>=2.9", "attrs>=24.2", "opentelemetry-api>=1.20", "sentry-sdk>=2.0"] [project.urls] +Documentation = "https://jaykv.github.io/mongospecs/" Repository = "https://github.com/jaykv/mongospecs" +Changelog = "https://github.com/jaykv/mongospecs/blob/main/CHANGELOG.md" [tool.hatch.build.targets.wheel] packages = ["src/mongospecs"] From 1dac7f61b31022219506e958aad68e56704746f0 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 10:44:32 -0400 Subject: [PATCH 30/31] feat(core): validate documents before they are written MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit msgspec and attrs do not check types when a model is constructed or assigned to, and pydantic does not check assignments. A wrongly typed value was therefore stored as it was, and the document could not be loaded afterwards. insert, save, insert_many and bulk_save now decode the document's own encoding with the backend's strict rules before sending anything, and raise ValidationError if it does not fit. save checks only when there is a change, and only the changed fields of a document loaded with a projection. The check can be skipped with validate=False on a call or SpecConfig(validate_writes=False) on a model. It cost about 3 µs (msgspec) and 5 µs (pydantic) per seven-field document when measured. Recorded as A41 in the plan. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 3 + claudedocs/v2-plan.md | 1 + docs/guide/backends.md | 44 +++++-- docs/guide/errors.md | 2 +- docs/guide/production.md | 6 + docs/guide/writes.md | 16 +++ docs/migration.md | 2 + .../src/mongospecs_web/backend.py | 2 +- packages/mongospecs/src/mongospecs/manager.py | 60 +++++++-- packages/mongospecs/src/mongospecs/markers.py | 8 ++ packages/mongospecs/src/mongospecs/model.py | 23 ++-- .../mongospecs/tests/test_review_round3.py | 121 ++++++++++++++++++ 12 files changed, 260 insertions(+), 28 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7971e2..d010490 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,6 +25,9 @@ A rewrite. See [Migrating from 0.1.x](docs/migration.md) for the API map. - Index planning with gated destructive actions; change streams; per-command observability events with logging, OpenTelemetry and Sentry listeners. - Document hooks run for `insert_many` and `bulk_save`. +- `insert`, `save`, `insert_many` and `bulk_save` validate the document before writing it, on + every backend. `validate=False` on a call or `SpecConfig(validate_writes=False)` on a model + turns the check off. - A single-field update on a pydantic model runs the field's validators and not the model-level ones, which could only see the other fields' defaults. diff --git a/claudedocs/v2-plan.md b/claudedocs/v2-plan.md index ca2b593..14ec78e 100644 --- a/claudedocs/v2-plan.md +++ b/claudedocs/v2-plan.md @@ -892,6 +892,7 @@ Recorded per §7.1 rule 4. Each one names what changed and why; none invalidates | A38 | §4.2 | BSON-only values in untyped fields (`Any`) serialise to JSON on all backends: ObjectId and UUID as strings, dates as ISO 8601, Decimal128 as a string, binary as base64, Int64 as a number, Timestamp as a date, Regex as its pattern | Reported: a 500 on pydantic for any ObjectId, and on msgspec/attrs for Binary, Timestamp, Regex and Int64 | | A39 | §4.8 | `Put` operation and `Resource(merge_patch=False)`. `PUT /{id}` replaces every field the client may update (sent value, or the default when left out; a required field must be sent) and leaves the rest as stored, in one atomic update; an embedded object holding protected values is replaced key by key the same way, one without them is replaced whole. It never creates. `Put` is not in the default `ops`. `merge_patch=False` makes `PATCH` replace embedded objects as before A37; nested keys the policy closes are still rejected | Requested by Jay as the opt-out from A37 and its limits (validators of the embedded model see one key at a time on a merge, a stored null needs the whole object, a backend without `supports_merge` gets a 422). The flag is on the resource, not the model: it describes an HTTP verb, model code is unaffected, and embedded models do not inherit settings (A35). Cost of opting out, accepted by whoever sets it: values the client cannot send are reset to defaults on replacement. `Put` is opt-in because guards are keyed by operation, so adding it to the defaults would expose an unguarded write on resources that guard `Patch` | | A40 | §4.4, §4.8 | Third review. Core: a single-field update on a pydantic model no longer fails a model-level validator that saw the other fields' defaults (the field's own validators still run); `insert_many` and `bulk_save` run document hooks; `page()` starts its count only after its arguments are validated. Web: request bodies are strict JSON (`NaN`, `Infinity` and over-deep nesting are a 400); `If-Match` on a model without a `Revision` is a 412; `If-None-Match` accepts lists, weak tags and `*`; the ASGI plug drops a request whose client disconnected mid-body; `Api(openapi=False)` is honoured by the FastAPI plug; guard and hook keys must name an operation; the concurrency limit is applied before authentication; a filter or sort key given twice is a 400 | Each was reproduced against the replica set first (`test_review_round3.py` in both packages). `insert_many` hooks deviate from Mongoose, where `insertMany` does not run `save` middleware: here the documents are in the caller's hands and a `before("insert")` hook that silently does not run loses data (a slug, a derived field). The hook calls are skipped when no hook is registered, so the bulk paths cost the same as before for models without hooks. Left as documented differences: trailing slash, `HEAD` and `OPTIONS` on framework plugs | +| A41 | §4.2, §4.4 | Document writes are validated by default. `insert`, `save`, `insert_many` and `bulk_save` decode the document's own encoding with the backend's strict rules before sending it and raise `ValidationError` if it does not fit. `save` checks only when there is a change, and only the changed fields of a document loaded with a projection. Escape hatches: `validate=False` per call, `SpecConfig(validate_writes=False)` per model | Requested by Jay after the third review found that msgspec and attrs (no constructor or assignment checks) and pydantic (no assignment checks) let a wrongly typed value be stored, after which the document could not be loaded. Checking the encoded form means the test is exactly "can this be read back", is one code path for all three backends, and reuses the encoding the write needs anyway. Cost measured ad hoc at about 3 µs (msgspec) and 5 µs (pydantic) per seven-field document; the recorded B4 run predates it. The full suite (887 tests) passed with the check on, so no existing write was falsely refused | New node: diff --git a/docs/guide/backends.md b/docs/guide/backends.md index 71d8162..2b530e6 100644 --- a/docs/guide/backends.md +++ b/docs/guide/backends.md @@ -14,6 +14,8 @@ pip install "mongospecs[msgspec]" # or [pydantic], or [attrs] (which also ins | Import | `from mongospecs.msgspec import Spec` | `from mongospecs.pydantic import Spec` | `from mongospecs.attrs import Spec` | | Declaring a model | `class A(Spec):` | `class A(Spec):` | `@attrs.define(kw_only=True)` on **every** model, then `class A(Spec):` | | Wrong type passed to the constructor | accepted | `ValidationError` | accepted | +| Wrong type assigned to an attribute | accepted | accepted | accepted | +| `insert()`, `save()`, `insert_many()`, `bulk_save()` | validated | validated | validated | | Stored documents on read | validated | validated | validated | | `Model.update(...)`, `PATCH`, filters | validated and cast | validated and cast | validated and cast | | Your own validators | none in msgspec; use `Annotated[..., msgspec.Meta(...)]` constraints | `field_validator`, `model_validator` | `attrs.field(validator=..., converter=...)` | @@ -26,28 +28,27 @@ Speed and memory are measured in [Benchmarks](../benchmarks.md). ### msgspec and attrs: the constructor does not check types -That is how both libraries work, and mongospecs does not add a check on `insert()` or `save()`: +That is how both libraries work. mongospecs checks the document when it is written instead, so a +wrong value never reaches the database: ```py doc = Counter(n="many") # no error: msgspec and attrs do not validate here -await doc.insert() # stored as a string -await Counter.get(doc.id) # ValidationError: reads are validated +await doc.insert() # ValidationError: Counter.n: Expected `int`, got `str` ``` -Values that come from outside (a request body, a file, another service) should go through -something that validates: `Model.update(id, n=value)`, `Model.insert_many([{"n": value}])` (a -mapping is validated; an instance is taken as it is), a `mongospecs-web` resource, or -`msgspec.convert(data, Counter)`. Values your own typed code builds are covered by your type -checker. +The error comes at the write, not at the line that made the mistake. To be told earlier, build +documents from outside data with something that validates on the spot, such as +`msgspec.convert(data, Counter)` or a `mongospecs-web` resource. ### pydantic: validators and single-field updates - Models are validated when constructed. `Spec` sets `validate_assignment=False`, as pydantic does - by default, so a value assigned to an attribute afterwards is not validated, and is saved as it is. + by default, so a value assigned to an attribute afterwards is not checked by pydantic; it is + checked when the document is written, like on the other backends. - `Model.update(id, low=5)` and an HTTP `PATCH` validate one field at a time. The field's type, constraints and `field_validator`s run. A `model_validator` that compares fields does not: it would be comparing the new value with the other fields' defaults, not with what is stored. If a - rule spans fields, enforce it where you have the whole document: load it, change it, `save()`. + rule spans fields, enforce it where you have the whole document: load it, change it, `save()`, which validates the whole document, model validators included. - `id` is declared with the alias `_id`; both names are accepted when constructing. - The `pydantic.mypy` plugin works as usual. @@ -57,6 +58,29 @@ A subclass of `Spec` that is not decorated with `@attrs.define(kw_only=True)` ge its own. `kw_only=True` is needed because `id` has a default and your required fields come after it. +## Writes are validated, unless you say otherwise + +Before `insert()`, `save()`, `insert_many()` and `bulk_save()` send anything, the document is +checked against its model, in the form it is about to be stored in. What is refused is exactly +what could not be read back. `save()` only checks when something changed, and for a document +loaded with a projection it checks the fields that changed rather than the whole document. + +The check costs a few microseconds per document (about 3 on msgspec and 5 on pydantic for a +document with seven fields and two embedded objects, measured on one machine; a round trip to +MongoDB is hundreds). Where even that matters, and the values are known to be sound, turn it off: + +```py +await Event.insert_many(events, validate=False) # for one call +await event.save(validate=False) + +class Event(Spec): + __spec__ = SpecConfig(validate_writes=False) # for every write of a model +``` + +With the check off, a wrongly typed value is stored as it is and the document fails to load +afterwards. `Model.update(...)` and the other filter-based writes always validate what they are +given; the switch does not apply to them. + ## What is the same everywhere - The `id` attribute, stored as `_id`; `__spec__ = SpecConfig(...)` assigned without an annotation. diff --git a/docs/guide/errors.md b/docs/guide/errors.md index a36ce18..201bd9b 100644 --- a/docs/guide/errors.md +++ b/docs/guide/errors.md @@ -19,7 +19,7 @@ listed becomes a 500 with no detail, and the cause is logged. | Error | Raised when | What to do | HTTP | |---|---|---|---| -| `ValidationError` | a value does not fit the model: on read, in `update()`, in a request body. `.errors` lists `{"field", "message"}` | fix the input; for stored documents see `SpecConfig(upcast=...)` and `find().on_invalid(...)` | 422 | +| `ValidationError` | a value does not fit the model: on read, on any write, in a request body. `.errors` lists `{"field", "message"}` | fix the input; for stored documents see `SpecConfig(upcast=...)` and `find().on_invalid(...)` | 422 | | `QueryError` | a filter, sort or update names an unknown field, has a value that cannot be cast, or a raw dict contains an operator | fix the query; wrap filters you wrote yourself in `trusted()` | 400 | | `DocumentNotFound` | `one()`, `or_fail()`, `update()`, `save()` or `delete()` matched nothing | treat as "gone" | 404 | | `StaleDocumentError` | a `Revision` check failed: the document changed since you loaded it | reload and retry, or tell the user | 412 | diff --git a/docs/guide/production.md b/docs/guide/production.md index 28d6d12..6cb13e9 100644 --- a/docs/guide/production.md +++ b/docs/guide/production.md @@ -103,6 +103,12 @@ api = Api(*resources, max_concurrency=200, max_body=1_048_576) costs 1.08 times the driver's own `insert_many` (`bench/results/6dc837f/B4.json`). *Measured.* - `save()` sends only the changed fields; `Model.update(id, Inc(...))` is one atomic round trip and needs no read. *Tested.* +- Documents are validated before `insert`, `save`, `insert_many` and `bulk_save` write them. The + check took about 3 microseconds per document on msgspec and 5 on pydantic for a seven-field + document with two embedded objects, against about 6 and 4 for encoding it. For bulk loads of + data that is already known to be sound, `validate=False` on the call or + `SpecConfig(validate_writes=False)` on the model skips it. *Measured once, on one machine; not + part of the recorded benchmark runs, which predate the check.* - A web `DELETE` is one conditional write. It needs a read first only when the model has delete hooks, or cascades on a hard delete. *Tested* (`test_delete_is_one_write_unless_the_document_is_needed`). diff --git a/docs/guide/writes.md b/docs/guide/writes.md index 23b7e9f..76fa29f 100644 --- a/docs/guide/writes.md +++ b/docs/guide/writes.md @@ -164,12 +164,28 @@ assert landing.rev == 1 and landing.changes() == {"$set": {"tags": ["page", "pro Sessions are always explicit: pass `session=tx` to every operation inside the function. For manual control use `async with registry.session() as s, s.start_transaction(): ...` (no automatic retry). +## Validation + +Every write is validated. `Model.update(...)` checks the values it is given; `insert()`, `save()`, +`insert_many()` and `bulk_save()` check the document as it is about to be stored, so a wrongly +typed attribute is an error at the write and not when the document is next read. `validate=False` +on a call, or `SpecConfig(validate_writes=False)` on a model, skips that check where its few +microseconds matter (see [Choosing a backend](backends.md)). + ```python try: await Article.update(landing.id, title=123) except Exception as exc: assert type(exc).__name__ == "ValidationError" +draft = Article(title="Draft") +draft.views = "many" # msgspec does not check this +try: + await draft.insert() +except Exception as exc: + assert type(exc).__name__ == "ValidationError" and exc.errors[0]["field"] == "views" +assert draft.is_new and await Article.count(Q.title == "Draft") == 0 + try: await Article.update("0" * 24, title="nobody") except DocumentNotFound: diff --git a/docs/migration.md b/docs/migration.md index 1937b7a..bc1d3b3 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -56,6 +56,8 @@ async with Registry("mongodb://localhost/app", models=[Dragon]): - Reads are validated. A stored document that does not fit the model raises `ValidationError` instead of producing a half-typed object. `SpecConfig(upcast=...)` migrates old shapes on read. +- Writes are validated too: `insert()` and `save()` refuse a document that does not fit its + model. `validate=False` or `SpecConfig(validate_writes=False)` skips the check. - Unknown field names in conditions and updates raise `QueryError`. - Raw dict filters may not contain operators unless wrapped in `trusted(...)`. - Reference fields hold a `Ref` object (`.id`, `.doc`), not a bare id or a document. diff --git a/packages/mongospecs-web/src/mongospecs_web/backend.py b/packages/mongospecs-web/src/mongospecs_web/backend.py index dc323de..8f7d3f0 100644 --- a/packages/mongospecs-web/src/mongospecs_web/backend.py +++ b/packages/mongospecs-web/src/mongospecs_web/backend.py @@ -161,7 +161,7 @@ async def list(self, query: ListQuery, *, scope: t.Any, fields: t.Sequence[str] async def create(self, data: dict[str, t.Any]) -> T: stored = {self.meta.fields[k].stored: v for k, v in data.items()} doc = self.meta.decode(stored, stored=False) - return t.cast(T, await self.manager.insert(doc)) + return t.cast(T, await self.manager.insert(doc)) # checked again there: hooks may change it supports_merge = True """``patch`` accepts ``merge=``: a :class:`~mongospecs_web.merge.PatchPlan` of writes inside diff --git a/packages/mongospecs/src/mongospecs/manager.py b/packages/mongospecs/src/mongospecs/manager.py index 7ad61f3..f1fc8ee 100644 --- a/packages/mongospecs/src/mongospecs/manager.py +++ b/packages/mongospecs/src/mongospecs/manager.py @@ -284,14 +284,39 @@ def _has_hooks(self, *events: str) -> bool: return True return any(self.meta.hooks(when, event) for when in ("before", "after") for event in events) + def _validated(self, doc: t.Any, validate: bool | None) -> bool: + return meta_of(type(doc)).config.validate_writes if validate is None else validate + + def _check( + self, doc: t.Any, encoded: t.Mapping[str, t.Any], changed: t.Iterable[str] | None = None + ) -> None: + """Raise :class:`ValidationError` unless ``encoded``, the document as it is about to be + stored, fits the model. The model libraries do not check a value assigned to an + attribute (msgspec and attrs do not check the constructor either), so this is where a + wrongly typed value is caught: before it is written, not when it is next read. + + ``changed`` limits the check to those stored keys, for a document that was loaded with + a projection and so cannot be judged as a whole. + """ + meta = meta_of(type(doc)) + if changed is None: + meta.codec.decode(type(doc), encoded) + return + for stored in changed: + info = meta.by_stored.get(stored) + if info is not None and not info.transient: + meta.codec.validate_field(type(doc), info.name, encoded.get(stored)) + # -- document-level writes ----------------------------------------------------- - async def insert(self, doc: T, *, session: t.Any = None) -> T: + async def insert(self, doc: T, *, session: t.Any = None, validate: bool | None = None) -> T: ctx = self.ctx("insert", session, doc=doc) await self._hooks("before", ("insert", "save"), ctx, doc) + encoded = self.meta.encode(doc) + if self._validated(doc, validate): + self._check(doc, encoded) if isinstance(session, Tx): session.record(doc, self.managed_attrs) - encoded = self.meta.encode(doc) if encoded.get("_id") is None: encoded.pop("_id", None) for plugin in self.plugins: @@ -321,12 +346,12 @@ def _delta(self, doc: t.Any, state: tracking.DocState) -> tuple[dict[str, t.Any] update["$unset"] = {key: "" for key in to_unset} return current, update - async def save(self, doc: T, *, session: t.Any = None) -> T: + async def save(self, doc: T, *, session: t.Any = None, validate: bool | None = None) -> T: """Insert a new document, or send only what changed on a tracked one.""" state = tracking.state_of(doc) if state is None: try: - return await self.insert(doc, session=session) + return await self.insert(doc, session=session, validate=validate) except DuplicateKeyError as exc: if "_id" not in exc.fields: raise @@ -346,6 +371,8 @@ async def save(self, doc: T, *, session: t.Any = None) -> T: if update: flt: dict[str, t.Any] = {"_id": state.snapshot["_id"], **self._shard_filter(state.snapshot)} user_keys = _touched(update) + if self._validated(doc, validate): + self._check(doc, current, _heads(user_keys) if state.partial is not None else None) for plugin in self.plugins: plugin.on_update(ctx, flt, update) with self._op("save"): @@ -680,6 +707,7 @@ async def insert_many( *, ordered: bool = True, session: t.Any = None, + validate: bool | None = None, ) -> BulkResult[T]: """Insert documents in one round trip. @@ -688,11 +716,14 @@ async def insert_many( ``insert`` and ``save`` run for every document, as they do for ``insert()``: the ``before`` hooks ahead of the write, the ``after`` hooks for what was written. """ + given = list(docs) items: list[t.Any] = [ - self.meta.decode(dict(d), stored=False) if isinstance(d, t.Mapping) else d for d in docs + self.meta.decode(dict(d), stored=False) if isinstance(d, t.Mapping) else d for d in given ] if not items: return BulkResult([], []) + # a mapping has just been validated by being decoded; an instance has not been + unchecked = [not isinstance(d, t.Mapping) for d in given] ctx = self.ctx("insert", session, many=True) hooks = self._has_hooks("insert", "save") if hooks: @@ -701,10 +732,12 @@ async def insert_many( for plugin in self.plugins: await plugin.before_bulk(ctx, items) encoded: list[dict[str, t.Any]] = [] - for doc in items: + for doc, instance in zip(items, unchecked, strict=True): + enc = self.meta.encode(doc) + if (instance or hooks) and self._validated(doc, validate): + self._check(doc, enc) if isinstance(session, Tx): session.record(doc, self.managed_attrs) - enc = self.meta.encode(doc) if enc.get("_id") is None: enc.pop("_id", None) for plugin in self.plugins: @@ -774,7 +807,9 @@ async def run(s: t.Any) -> t.Any: await plugin.after_bulk(ctx, ops) return result - async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: + async def bulk_save( + self, docs: t.Iterable[T], *, session: t.Any = None, validate: bool | None = None + ) -> int: """Save many documents in one round trip: inserts for new ones, deltas for tracked ones. Returns the number of documents written. Writes are independent: if some @@ -801,6 +836,8 @@ async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: if hooks: await self._hooks("before", ("insert", "save"), ctx, doc) enc = self.meta.encode(doc) + if self._validated(doc, validate): + self._check(doc, enc) if enc.get("_id") is None: enc.pop("_id", None) for plugin in self.plugins: @@ -816,6 +853,8 @@ async def bulk_save(self, docs: t.Iterable[T], *, session: t.Any = None) -> int: continue flt: dict[str, t.Any] = {"_id": state.snapshot["_id"], **self._shard_filter(state.snapshot)} user_keys = _touched(update) + if self._validated(doc, validate): + self._check(doc, current, _heads(user_keys) if state.partial is not None else None) for plugin in self.plugins: plugin.on_update(ctx, flt, update) requests.append(DriverUpdateOne(flt, update)) @@ -1006,6 +1045,11 @@ def _touched(update: t.Mapping[str, t.Any]) -> frozenset[str]: return frozenset(k for body in update.values() for k in body) +def _heads(paths: t.Iterable[str]) -> set[str]: + """The top-level stored keys that dotted update paths write under.""" + return {path.partition(".")[0] for path in paths} + + def _patch(doc: dict[str, t.Any], update: t.Mapping[str, t.Any], *, skip: frozenset[str]) -> None: """Apply plugin-written top-level ``$set``/``$inc`` values to an encoded document.""" for key, value in update.get("$set", {}).items(): diff --git a/packages/mongospecs/src/mongospecs/markers.py b/packages/mongospecs/src/mongospecs/markers.py index 6f832d1..56c6aca 100644 --- a/packages/mongospecs/src/mongospecs/markers.py +++ b/packages/mongospecs/src/mongospecs/markers.py @@ -257,6 +257,14 @@ class SpecConfig: collection_options: t.Mapping[str, t.Any] = field(default_factory=dict) """Options for ``create_collection`` (``timeseries``, ``capped``, ``size``, ...).""" + validate_writes: bool = True + """Check a document against its model before ``insert``, ``save``, ``insert_many`` and + ``bulk_save`` write it, so that nothing is stored that could not be read back. msgspec and + attrs do not check types when a model is constructed or assigned to, and pydantic does not + check assignments, so without this a wrongly typed value is stored as it is. Turn it off for + a model whose values are known to be sound and whose writes must be as cheap as possible; + ``validate=False`` on a single call does the same for that call.""" + json_schema_validation: t.Literal["off", "moderate", "strict"] = "off" none: t.Literal["omit", "store"] = "omit" """What to write for a field whose default is ``None`` while its value is ``None``: diff --git a/packages/mongospecs/src/mongospecs/model.py b/packages/mongospecs/src/mongospecs/model.py index fcaf342..2c8f6e3 100644 --- a/packages/mongospecs/src/mongospecs/model.py +++ b/packages/mongospecs/src/mongospecs/model.py @@ -135,8 +135,9 @@ async def insert_many( *, ordered: bool = True, session: t.Any = None, + validate: bool | None = None, ) -> BulkResult[T]: - return await cls._manager().insert_many(docs, ordered=ordered, session=session) + return await cls._manager().insert_many(docs, ordered=ordered, session=session, validate=validate) @classmethod async def bulk_write( @@ -145,8 +146,10 @@ async def bulk_write( return await cls._manager().bulk_write(operations, ordered=ordered, session=session) @classmethod - async def bulk_save(cls: type[T], docs: t.Iterable[T], *, session: t.Any = None) -> int: - return await cls._manager().bulk_save(docs, session=session) + async def bulk_save( + cls: type[T], docs: t.Iterable[T], *, session: t.Any = None, validate: bool | None = None + ) -> int: + return await cls._manager().bulk_save(docs, session=session, validate=validate) @classmethod def aggregate( @@ -224,12 +227,16 @@ def changes(self) -> dict[str, t.Any]: return {"$set": self._own_manager().meta.encode(self)} return self._own_manager()._delta(self, state)[1] - async def insert(self: T, *, session: t.Any = None) -> T: - return t.cast(T, await self._own_manager().insert(self, session=session)) + async def insert(self: T, *, session: t.Any = None, validate: bool | None = None) -> T: + """Insert this document. It is checked against the model first, so a wrongly typed value + is a ``ValidationError`` here and not when the document is next read; ``validate=False`` + skips the check (as does ``SpecConfig(validate_writes=False)`` for the whole model).""" + return t.cast(T, await self._own_manager().insert(self, session=session, validate=validate)) - async def save(self: T, *, session: t.Any = None) -> T: - """Insert a new document, or send only the fields that changed since it was loaded.""" - return t.cast(T, await self._own_manager().save(self, session=session)) + async def save(self: T, *, session: t.Any = None, validate: bool | None = None) -> T: + """Insert a new document, or send only the fields that changed since it was loaded. + Validated like :meth:`insert`, and only when there is something to send.""" + return t.cast(T, await self._own_manager().save(self, session=session, validate=validate)) async def delete(self, *, session: t.Any = None, hard: bool = False, if_rev: int | None = None) -> None: """Delete this document (a soft delete when the model has a ``DeletedAt`` field, unless ``hard``). diff --git a/packages/mongospecs/tests/test_review_round3.py b/packages/mongospecs/tests/test_review_round3.py index 260d4af..53580ef 100644 --- a/packages/mongospecs/tests/test_review_round3.py +++ b/packages/mongospecs/tests/test_review_round3.py @@ -135,3 +135,124 @@ async def test_8_a_bad_cursor_leaves_no_coroutine_behind(make_registry): await Note.find().page(5, number=0, total=True) gc.collect() assert not [w for w in caught if "never awaited" in str(w.message)] + + +# -- writes are validated --------------------------------------------------------------------- + + +def counter(backend, **config): + spec = SpecConfig(collection="r3_counters", **config) + if backend == "msgspec": + import msgspec + + from mongospecs.msgspec import Spec + + class Part(msgspec.Struct, kw_only=True): + size: int = 0 + + class Counter(Spec, kw_only=True): + __spec__ = spec + name: str = "" + n: int = 0 + part: Part | None = None + parts: list[Part] = [] + + elif backend == "pydantic": + import pydantic + + from mongospecs.pydantic import Spec + + class Part(pydantic.BaseModel): + size: int = 0 + + class Counter(Spec): + __spec__ = spec + name: str = "" + n: int = 0 + part: Part | None = None + parts: list[Part] = [] + + else: + import attrs + + from mongospecs.attrs import Spec + + @attrs.define(kw_only=True) + class Part: + size: int = 0 + + @attrs.define(kw_only=True) + class Counter(Spec): + __spec__ = spec + name: str = "" + n: int = 0 + part: Part | None = None + parts: list[Part] = attrs.field(factory=list) + + return Counter, Part + + +def wrong(Counter, Part, **fields): + """A document holding a wrongly typed value, however the backend lets one come about.""" + doc = Counter(name="c") + for name, value in fields.items(): + setattr(doc, name, value) + return doc + + +async def test_10_a_wrongly_typed_value_is_refused_before_it_is_written(backend, make_registry): + Counter, Part = counter(backend) + await make_registry(Counter) + bad_part = Part() + bad_part.size = "big" + for fields in ({"n": "many"}, {"part": bad_part}, {"parts": [bad_part]}, {"name": None}): + with pytest.raises(ValidationError): + await wrong(Counter, Part, **fields).insert() + with pytest.raises(ValidationError): + await wrong(Counter, Part, **fields).save() + with pytest.raises(ValidationError): + await Counter.insert_many([wrong(Counter, Part, **fields)]) + with pytest.raises(ValidationError): + await Counter.bulk_save([wrong(Counter, Part, **fields)]) + assert await Counter.count() == 0 + + doc = await Counter(name="ok", n=1, part=Part(size=2), parts=[Part(size=3)]).insert() + doc.n = "many" + with pytest.raises(ValidationError) as caught: + await doc.save() + assert caught.value.errors[0]["field"] == "n" + with pytest.raises(ValidationError): + await Counter.bulk_save([doc]) + assert (await Counter.get(doc.id)).n == 1 # nothing was sent + doc.n = 2 + assert (await doc.save()).n == 2 # and the document is still usable once it is put right + + # a document loaded with a projection is judged by what was changed, not as a whole + partial = await Counter.find().select("n").one() + partial.n = "many" + with pytest.raises(ValidationError): + await partial.save() + partial.n = 5 + await partial.save() + assert [(c.name, c.n) for c in await Counter.find()] == [("ok", 5)] + + +async def test_10_validation_of_writes_can_be_turned_off(backend, make_registry): + Counter, Part = counter(backend) + await make_registry(Counter) + doc = await wrong(Counter, Part, n="many").insert(validate=False) + assert (await Counter.collection().find_one({"_id": doc.id}))["n"] == "many" + doc.name = 7 + await doc.save(validate=False) + await Counter.insert_many([wrong(Counter, Part, n="x")], validate=False) + await Counter.bulk_save([wrong(Counter, Part, n="y")], validate=False) + assert await Counter.find().raw().count() == 3 + + +async def test_10_or_for_a_whole_model(backend, make_registry, db_name): + Counter, Part = counter(backend, validate_writes=False) + await make_registry(Counter) + doc = await wrong(Counter, Part, n="many").insert() + assert (await Counter.collection().find_one({"_id": doc.id}))["n"] == "many" + with pytest.raises(ValidationError): + await wrong(Counter, Part, n="many").insert(validate=True) # a call can still ask for it From 92df62580308025bc3c44533f195cf0f39b5a021 Mon Sep 17 00:00:00 2001 From: jaykv Date: Tue, 13 Oct 2026 10:52:30 -0400 Subject: [PATCH 31/31] build(docs): switch from mkdocs-material to zensical Move the site config to zensical.toml on the new default theme, swap the docs dependency, and deploy through the GitHub Pages actions since zensical has no gh-deploy. Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/docs.yaml | 22 +- .gitignore | 2 +- Makefile | 2 +- mkdocs.yaml | 93 -------- pyproject.toml | 2 +- uv.lock | 457 +++++++++--------------------------- zensical.toml | 94 ++++++++ 7 files changed, 225 insertions(+), 447 deletions(-) delete mode 100644 mkdocs.yaml create mode 100644 zensical.toml diff --git a/.github/workflows/docs.yaml b/.github/workflows/docs.yaml index 64748f4..7f96787 100644 --- a/.github/workflows/docs.yaml +++ b/.github/workflows/docs.yaml @@ -1,24 +1,36 @@ -# Publishes the documentation site to GitHub Pages (the gh-pages branch) from main. +# Publishes the documentation site to GitHub Pages from main. +# Requires Settings > Pages > Source: "GitHub Actions". name: Docs on: push: branches: [main] - paths: ["docs/**", "examples/**", "mkdocs.yaml", ".github/workflows/docs.yaml"] + paths: ["docs/**", "examples/**", "zensical.toml", ".github/workflows/docs.yaml"] workflow_dispatch: permissions: - contents: write + contents: read + pages: write + id-token: write concurrency: - group: docs + group: pages cancel-in-progress: true jobs: deploy: runs-on: ubuntu-latest + environment: + name: github-pages + url: ${{ steps.deployment.outputs.page_url }} steps: - uses: actions/checkout@v4 - uses: astral-sh/setup-uv@v5 - run: uv sync --group docs --python 3.13 - - run: uv run --group docs mkdocs gh-deploy --strict --force + - run: uv run --group docs zensical build --clean + - uses: actions/configure-pages@v5 + - uses: actions/upload-pages-artifact@v3 + with: + path: site + - id: deployment + uses: actions/deploy-pages@v4 diff --git a/.gitignore b/.gitignore index f10504c..8a28c70 100644 --- a/.gitignore +++ b/.gitignore @@ -117,7 +117,7 @@ venv.bak/ # Rope project settings .ropeproject -# mkdocs documentation +# zensical documentation /site # mypy diff --git a/Makefile b/Makefile index 292e3c9..fb2572e 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ build: uv build --all-packages docs: - uv run --group docs mkdocs build --strict + uv run --group docs zensical build --clean clean: rm -rf dist build site .*_cache .venv-* diff --git a/mkdocs.yaml b/mkdocs.yaml deleted file mode 100644 index a01efc2..0000000 --- a/mkdocs.yaml +++ /dev/null @@ -1,93 +0,0 @@ -site_name: "mongospecs" -site_url: "https://jaykv.github.io/mongospecs/" -repo_url: "https://github.com/jaykv/mongospecs" -edit_uri: "edit/main/docs/" - -theme: - name: "material" - features: - - announce.dismiss - - content.action.edit - - content.action.view - - content.code.annotate - - content.code.copy - - content.tooltips - - navigation.footer - - navigation.indexes - - navigation.sections - - navigation.tabs - - navigation.tabs.sticky - - navigation.top - - search.highlight - - search.suggest - - toc.follow - palette: - - media: "(prefers-color-scheme: light)" - scheme: default - accent: purple - toggle: - icon: material/weather-sunny - name: Switch to dark mode - - media: "(prefers-color-scheme: dark)" - scheme: slate - primary: black - accent: lime - toggle: - icon: material/weather-night - name: Switch to light mode - -markdown_extensions: -- abbr -- attr_list -- admonition -- footnotes -- md_in_html -- pymdownx.blocks.admonition -- pymdownx.blocks.details -- pymdownx.blocks.tab: - alternate_style: true - slugify: !!python/object/apply:pymdownx.slugs.slugify - kwds: - case: lower -- pymdownx.emoji: - emoji_index: !!python/name:material.extensions.emoji.twemoji - emoji_generator: !!python/name:material.extensions.emoji.to_svg -- pymdownx.magiclink -- pymdownx.snippets: - base_path: ["."] - check_paths: true -- pymdownx.superfences -- pymdownx.tabbed: - alternate_style: true - slugify: !!python/object/apply:pymdownx.slugs.slugify - kwds: - case: lower -- pymdownx.tasklist: - custom_checkbox: true - -plugins: - search: - -nav: -- Home: index.md -- Getting started: tutorial.md -- Guide: - - Models: guide/models.md - - Choosing a backend: guide/backends.md - - Queries: guide/queries.md - - Writes and transactions: guide/writes.md - - Relations: guide/relations.md - - Hooks and plugins: guide/plugins.md - - Errors: guide/errors.md - - Running in production: guide/operations.md - - Tuning for production: guide/production.md -- Web: - - Resources: web/resources.md - - Authentication and authorization: web/auth.md - - Testing your API: web/testing.md - - Plugs: web/plugs.md -- Benchmarks: benchmarks.md -- Coming from Mongoose: parity.md -- Migrating from 0.1.x: - - Guide: migration.md - - v1 test inventory: v1-test-inventory.md diff --git a/pyproject.toml b/pyproject.toml index 840907e..fe9a260 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -31,7 +31,7 @@ dev = [ "openapi-spec-validator>=0.7", ] docs = [ - "mkdocs-material>=9.5", + "zensical>=0.0.70", ] [tool.pytest.ini_options] diff --git a/uv.lock b/uv.lock index 3850a74..4c1ae74 100644 --- a/uv.lock +++ b/uv.lock @@ -128,29 +128,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548 }, ] -[[package]] -name = "babel" -version = "2.18.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/7d/b2/51899539b6ceeeb420d40ed3cd4b7a40519404f9baf3d4ac99dc413a834b/babel-2.18.0.tar.gz", hash = "sha256:b80b99a14bd085fcacfa15c9165f651fbb3406e66cc603abf11c5750937c992d", size = 9959554 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/77/f5/21d2de20e8b8b0408f0681956ca2c69f1320a3848ac50e6e7f39c6159675/babel-2.18.0-py3-none-any.whl", hash = "sha256:e2b422b277c2b9a9630c1d7903c2a00d0830c409c59ac8cae9081c92f1aeba35", size = 10196845 }, -] - -[[package]] -name = "backrefs" -version = "8.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/68/bc/c4b75b0279b2cf9e208c7dea5755a452d158a32abe1b7330824ff1127e86/backrefs-8.1.tar.gz", hash = "sha256:f86d9312df3ebae1241758af80809a319cafa1d8f32189edfb242c9aa55573b6", size = 6999089 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/da/17/7be730f397b9239be5044a147f0cdfbd9605111b07af053c737b21d79d16/backrefs-8.1-py310-none-any.whl", hash = "sha256:438bd38dc7fa0cded52786a1351a2dad9efc2cda95d047284c41d916a22312a8", size = 380382 }, - { url = "https://files.pythonhosted.org/packages/fd/d1/d25387b14119b7062fead6c9b2e87c2ab286beb346df79c6a649c095e979/backrefs-8.1-py311-none-any.whl", hash = "sha256:11f61846e446fa8637af2f5c073f1083aa58dbb7460b019fd51689f564e44f7e", size = 392180 }, - { url = "https://files.pythonhosted.org/packages/7d/63/4c3078fcbb3956c55b24472d8375a1b7ad67203d4a1917512eee34d4fbee/backrefs-8.1-py312-none-any.whl", hash = "sha256:b472c76082769f86a97ffccfd1f2599b2b706610c6154955664fffe67e909035", size = 398092 }, - { url = "https://files.pythonhosted.org/packages/df/22/60f30c9b8e497ca4bbac23991a726ebc267e23771a1b9d9c04b3a3c16dd6/backrefs-8.1-py313-none-any.whl", hash = "sha256:db577dbae1c61187534d35fc9bfbd0c01158fff6239a66fecf7590e5b17879a9", size = 400050 }, - { url = "https://files.pythonhosted.org/packages/45/c7/80fa46ecb47717845ab9c07bce8651ef8f80aac5ec698e212d02ed2a9963/backrefs-8.1-py314-none-any.whl", hash = "sha256:1e857c32e91b8fe46ddb359ac21021a37b090fa4239566a61e9856123e334041", size = 411908 }, - { url = "https://files.pythonhosted.org/packages/05/32/a57c5a5b7461b8c88266244014aa38052d76f64b064451b1d5342eb09bf4/backrefs-8.1-py315-none-any.whl", hash = "sha256:9c8fe3e2eaae4301a9ce787341d986e5ba25a887d89da6de0de5d58eca26a949", size = 417589 }, -] - [[package]] name = "blinker" version = "1.9.0" @@ -169,153 +146,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983 }, ] -[[package]] -name = "charset-normalizer" -version = "3.5.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/33/1c/f41d4e74c28ab327ff3acd36053f7ea506c55872d7a90b0fa71aa3ab0c89/charset_normalizer-3.5.2.tar.gz", hash = "sha256:39de2a259fc954455c57274dc94c79d5842774e1247a016aff30bc0efed0f4ef", size = 172659 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/67/6a0b94a7960d5e1b5eacd2fb529f3fccc47db4644f7f0a7cfdcfc3be578a/charset_normalizer-3.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:3d21b8b13c7592db2ac5e544a6d83187b995257472b0c9e8351b6d507ae37ed6", size = 370642 }, - { url = "https://files.pythonhosted.org/packages/fb/94/01009e13b94041599004edf32e56e382c24e570f60f79bab8efe45cfe1eb/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d760fe2a4d7c3b226cb9026d6a842868d52a7901bd98420e1baf14e80da85cf5", size = 259280 }, - { url = "https://files.pythonhosted.org/packages/66/85/3b5358f60a13210f0b67d3755c168ef758701b021e655d88d4da28554467/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c9790464842f85f437dbbb54417eda1e0e6bfc52dd8d22d6fd1c994b73b2dc74", size = 245335 }, - { url = "https://files.pythonhosted.org/packages/74/75/77c1c479b09ecd751d1e767b251ea5c14d4d50ff757bf404afab2692f600/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4685902cf26edf013ed7a3da0f426ebba7a00ebb9541386d835afbf002c11cab", size = 291707 }, - { url = "https://files.pythonhosted.org/packages/0b/0d/363f78cacb70f58f15f4b083961bbd9d292f335d3f5c66fc4f1cfe69cb90/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4495c5002a7b28557e7e222e77e0b661183e432b7d6d2e788101e3f240e05b8c", size = 286747 }, - { url = "https://files.pythonhosted.org/packages/e4/ed/cf505d3011ffceb12c2067a7a5d3cfe92b875d4d44bb0ff0d69375e2c184/charset_normalizer-3.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:211d5a3eb6af8f513b8d4ca19a8c1b7accab1b5f0d3175f9826b03c1a920dc1f", size = 269972 }, - { url = "https://files.pythonhosted.org/packages/15/d8/f0a93a431d170e7ca681d4f6650fee3de934d18560e474e7267eb4b0f987/charset_normalizer-3.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ef4fcbf3327382cd4c9f540babd61248208af7b93eec4de397b4d5f58a09e288", size = 269339 }, - { url = "https://files.pythonhosted.org/packages/86/bd/9b2bd1c5b7af02462c9752d33994834ff972a96b4c483eefde9e594488e2/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:bd16aabe4a02a297c23417aa17ac6299dbd8c49f673bcd645b4929b11f5a4400", size = 261683 }, - { url = "https://files.pythonhosted.org/packages/76/a5/cac540ab0fd61f3fec88ad3dbb64509e71424593d73cfdfff5ab3e4db279/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:fb9e68df06293761f9fe66ade60a9bc6d0f5e42b8acf2939a9158af86ab0e5bd", size = 248143 }, - { url = "https://files.pythonhosted.org/packages/71/7a/ff467301deef2089fad87f72df9e000a26a78fec7acbb18e1999371b8369/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:59f63901b0031c3136cf64704dcb21de0bbae62ce2c9529bc39d27665463de37", size = 292358 }, - { url = "https://files.pythonhosted.org/packages/ad/77/22d7e785d1e210afc2e2f58600dd1799d17a35665faf84383f002826c5f8/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:304d5463e65a35d7bb0850550e0780395395f6fcf452f04db7d5ca7cecc425ac", size = 268766 }, - { url = "https://files.pythonhosted.org/packages/ae/91/e8e946267f1c2d9e2bd651726e2fbd2addf02c4d36cea5069e32ca9d7bb5/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:9cf9b1a857e25c4baceeb3624e92a56df3668f398c4acba74e174d81fb4d1d3a", size = 287432 }, - { url = "https://files.pythonhosted.org/packages/4e/88/7561d8a88d555e7df6623abe7c0070b4baf47549b9408783a2ae0a1a6cf7/charset_normalizer-3.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:114e4d0c92d618409ed82a99e22b5c5e768fe995f2973f78265f4524f49d4640", size = 272470 }, - { url = "https://files.pythonhosted.org/packages/35/7e/578c702301ec036f01455f30744a08d2b42f6ab35b9b2d4bf8cae0ef2a80/charset_normalizer-3.5.2-cp311-cp311-win32.whl", hash = "sha256:2625388c6c754520c37abaf3b41eb34d1cc4a373f457898f08606c8e362b891d", size = 186688 }, - { url = "https://files.pythonhosted.org/packages/e8/fc/fdf8cf52ff21cd5bf158f20978991cf985325842f74283eb6df26c8a39d8/charset_normalizer-3.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:87e50a3e7cb90af586b6c5faf23e302a970415ac73bd7bd90a515a04b427ef96", size = 214932 }, - { url = "https://files.pythonhosted.org/packages/97/66/3e45a506d8110b632541faf9a9470185aa9878f1ed44020f31346c1c5e5b/charset_normalizer-3.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:254eb48b9fa5ee9898a3c445825a1f340fe53712a098904b39b0bddba8ea3cb1", size = 202828 }, - { url = "https://files.pythonhosted.org/packages/e7/c8/693809898870237d82785a03f3b2b58fe4c9f14669f84a7d4e623c92a59e/charset_normalizer-3.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:ed2a239c0ea213acc1908150a3037257083c7c083128f1a4cec2ec4b97dca491", size = 367780 }, - { url = "https://files.pythonhosted.org/packages/c9/87/2fea8c13dc24b3ca9c6f803a5b2dfdeae73eb4f9e12c7885ed908ff0433c/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b91363207bd9dc966a691e959bb47f64b30f7ac4b072be9968b366982f7db77c", size = 246730 }, - { url = "https://files.pythonhosted.org/packages/a8/9e/09efac30b937722f46d3110ba30b875b24b2e3a266ed746cc4e376a94d80/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:38a873987f3be698494da8b2e3085e29da02da7b633dce73e79c699a113d7bf0", size = 237707 }, - { url = "https://files.pythonhosted.org/packages/9e/18/70d76670b13686237863a379928d60bd10e021f17d243ab3d7014c4a5f4e/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:355ad8011081dec5412240c087a9a0c9d4d5039f3ed11a3f13e18c2b29b56c51", size = 273050 }, - { url = "https://files.pythonhosted.org/packages/54/e2/77a8b09d5adc013ed07b95b01b8b8fa5441c4e810e83ee7e4aae2fa4d91a/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ee21e28f0430bd6dc9086c6e525d5e818a44a5ad19720c8a0ef766792f3eb5e5", size = 270345 }, - { url = "https://files.pythonhosted.org/packages/7f/c5/38806a25ab5e65fc178f39affeda20858efafede2fce1ffc2556cfc9fe73/charset_normalizer-3.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3d31298449090ab8d47b7b1b2a555ff73cac7ed438a08b7ac160980c7ebed649", size = 257601 }, - { url = "https://files.pythonhosted.org/packages/ae/8d/213565184708fdb263ae55e2c04ee1ff748129dd65d48ed0e3502da9c85a/charset_normalizer-3.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5cde776b7cc66e4f6c99612cea4aa7269aa65863f7a15841b2c264f103822f4e", size = 252222 }, - { url = "https://files.pythonhosted.org/packages/7e/24/76d2cefc25472531e4c5c7dfff68865eb1c39b78482f0fdc15b46f047830/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:ae4f5fea5b8b8ccff88238cc8569303e5ee95efae67fa62922a311397a71f346", size = 248482 }, - { url = "https://files.pythonhosted.org/packages/7d/dc/65a801b66ab4c197e22c433ab25e7ac24324ac6f45a2269aca42cce309bf/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:f7d486c83842422badd511868fd8a9a20e9407ace71564b6af47ce7e60a336c1", size = 241206 }, - { url = "https://files.pythonhosted.org/packages/a7/95/ca9b5eabde673002c6f1e7ada1b223916fe18f6d661da7aabd4d643718f1/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:11a4d68a6ecda3292cb1e50239e111543ba5d709bb62a6b4ea1afcfa729d8875", size = 273190 }, - { url = "https://files.pythonhosted.org/packages/2d/8b/803b4d2a3f6e1740f63f1e87b04d14b42f3d4fdfe6ed7d4db2d34102b14f/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:d6734d2ef8a50fbf8445c139477da401f50d62a0606bf00e20ec6d87773fefb1", size = 253527 }, - { url = "https://files.pythonhosted.org/packages/a9/55/93c0e5dbd085ae0471346026abbe7e0db9ea2d6fea74e51f0b5a46f233a7/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:a815775b6c38d4e0ff7bcffbeba67feded90202bb6a226b8dd35f1c855217413", size = 271285 }, - { url = "https://files.pythonhosted.org/packages/95/69/0dbd0e0b9b16cfa816cdfcb3e2e3854a1f680dc07fb1245ea125e7448060/charset_normalizer-3.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:23851fb4e1b85ed3f6c2a27b777cdfe2e19fb5b38429a8faf38c7542b7665869", size = 260010 }, - { url = "https://files.pythonhosted.org/packages/58/9d/e7b88e7b1bf403590c3b573277b5e1e488c68c7a6fbacca310a2c324e90c/charset_normalizer-3.5.2-cp312-cp312-win32.whl", hash = "sha256:db19d07e2e0129e974a0e65d0064fc222a446cd5122c2fd4184d2af9fc734a9e", size = 184125 }, - { url = "https://files.pythonhosted.org/packages/eb/e6/e6e083884cbcfd49c64865af05027fe7011be7b2d9179524f099a1b611f3/charset_normalizer-3.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:780fbe7cab297b81dad9fb8dc5eb003c0468ffb0d9e5f65068c53a34661a96bc", size = 207486 }, - { url = "https://files.pythonhosted.org/packages/c4/e3/017aea0911ada7405a825c7d937eb3a13009664e2f5b38e8c4bbf2abf894/charset_normalizer-3.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:e2af3aad578aa6bd1384bcf4750fc285e5a9de53f40b7d41e5a0bf748edeb2b3", size = 196734 }, - { url = "https://files.pythonhosted.org/packages/c5/34/68292d68512768591aaff07c59bb53ee31341c87759433a859c4641a50c2/charset_normalizer-3.5.2-cp313-cp313-android_24_arm64_v8a.whl", hash = "sha256:ed905975ab14056a2e5eb1c376cb2e1ebc5396baf84163939c518556fccde9f5", size = 225946 }, - { url = "https://files.pythonhosted.org/packages/e3/80/bee0b01b90ccd5322ae1d0abb33fab1bd95b7c2eadaf02aeccf22e04ee83/charset_normalizer-3.5.2-cp313-cp313-android_24_x86_64.whl", hash = "sha256:a66c3bc5ab1f0ff2164fc9965ddd611ff0802173f4b9d24554c563f6ab7e1d6e", size = 238619 }, - { url = "https://files.pythonhosted.org/packages/78/6e/60ce52a85a7fd631ae8482ae6d74521014ca2f255892679484dc04d7ef56/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2374b62878abb00cd8309b32af6c0b715cd02dec0ca74ef12e5069bdc64144a", size = 206701 }, - { url = "https://files.pythonhosted.org/packages/36/8c/71aafad23f971afc84c2b295bc0c560739ce1dac558aad9fec22e39f3639/charset_normalizer-3.5.2-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:d376bbd28b3a8999db1a103b3b388aee6f1ddeb3e51bc2172993efdcd86e064d", size = 210506 }, - { url = "https://files.pythonhosted.org/packages/91/da/3c5a7798c046df7d2d68ad653cf5b6c5a8bfee225055a843c6f2f42aac1a/charset_normalizer-3.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6045373d5a89a5ec71afde535db987ca28e76dfa276c2d4c818265b375d4b055", size = 366626 }, - { url = "https://files.pythonhosted.org/packages/e1/16/710ac3de2ee354e2bd1a9c94efe45a2d27b5c6ad39b2d6a905be2c094b6c/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:849df64e889b2e17230d58410a03dba311a65b163508fd33679b2b737d4b7858", size = 244624 }, - { url = "https://files.pythonhosted.org/packages/d6/39/45c7439f5b63d24f7d5b2a1d760f34af7628782d7144b4cc8ded45c2d4bc/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:15c44f7edfd477b06f517a5cc317fc1707edb9de2c865f43d4b6513907473234", size = 235731 }, - { url = "https://files.pythonhosted.org/packages/4d/34/38f3154785ce92e9f56eb226f4d35bdfae6b008480dd055f58837a89c810/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a89012d6d5476ee112d20d998570ed58df2260a852afb1758809cd6900411d21", size = 270352 }, - { url = "https://files.pythonhosted.org/packages/04/f3/859f74e7babc977705026b30593b3be04049632a522fb7000f83c033d747/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:0c951d5e6dd9c2ff60609476752bee49da4206adde960ebc247766937f72e718", size = 267393 }, - { url = "https://files.pythonhosted.org/packages/4b/85/41d27f234b82e47c167a5f6c0f62501dc0c640585ff4aba79e08a390336a/charset_normalizer-3.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7218e8f32b0956cfcd048fd42d9d5779809745ca1d86113ca56f66e7ae1549c4", size = 254660 }, - { url = "https://files.pythonhosted.org/packages/58/ca/5d1a997587febe5b26d8daffe363b5c1a091cece19828eec6502fd09c5ef/charset_normalizer-3.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a19a731138fc27d5682277d3b9df22855cea1239bce7fcec5f78f42ef2d1f3c3", size = 249996 }, - { url = "https://files.pythonhosted.org/packages/b3/1f/d1e78246f7ed60c8c8d606b4ac27f66ce49cc3e95f24893ccbeba9f77302/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:62603db9a7caa0802eaa28c1c46fecd7b3a263a774069c24c3c28c302448721c", size = 247028 }, - { url = "https://files.pythonhosted.org/packages/8e/37/eba316edd4f0c4d3a5d945924c4eeeae59abac4056aa815d8a4268f863a2/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:b6856554c4f44d79fc2307d5768854310a8f0096e501c75637542c82292b0429", size = 240562 }, - { url = "https://files.pythonhosted.org/packages/c8/8e/aaa037d40ca9ef045977f1a661048b1aa33f223adfce3452fe9be9f79d14/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:1bc0baf5ef96b6ede57d47f4b8fe4d9d84019c3bfcbeb20a41edc6a6ee341f1f", size = 271699 }, - { url = "https://files.pythonhosted.org/packages/26/19/1c1c9f75974adf523b87f34b8a2adc5a435cd65916812bcbd0dfa45f9a29/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:56bc200a365efb37383b7852e4cc5898d3b2da5987289b543956cf8cad71018a", size = 252030 }, - { url = "https://files.pythonhosted.org/packages/bc/90/0660ef18e18df0a4d2a1a0edff7dfbba42d4e50ef2425557a5bb7051f77b/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:2c9ad19a6cfcd5ea5c0d41161d22f9df1dcc277e9bef2751391334546a314c00", size = 268565 }, - { url = "https://files.pythonhosted.org/packages/79/ba/57adc269824e8658f1a0f97a9e514c247445a9632b3419b97e0ba37f16dc/charset_normalizer-3.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e243bd13217235fc7290c621941c3f5cc8b66e4872495be821d7436ba2fb838d", size = 257627 }, - { url = "https://files.pythonhosted.org/packages/9a/85/33abd4315c052d3d4f54c92b1ee49bfbc0dc7115a981e462a793b6d2ab87/charset_normalizer-3.5.2-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:a090bb2c68df85450502e3e20d665e3a5af9c65a84d6508ed477badd49166fd3", size = 143407 }, - { url = "https://files.pythonhosted.org/packages/4f/de/6435e18d1aaa5d910b896d551411c96af1f42a0c56c29afc2016c61ccc2e/charset_normalizer-3.5.2-cp313-cp313-win32.whl", hash = "sha256:2b7b3bbfb4fe8ef40600792d762fbaa9057559f9d3fad209525b7a22b99e91fd", size = 183207 }, - { url = "https://files.pythonhosted.org/packages/9c/76/b8ec57f4e9ee3253541abf95e4a462c0175fe8032dcd070f1f2421240942/charset_normalizer-3.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:78456a747de8dc58360ffa581f30a002baf5aa28cb262536545e91f113ed7639", size = 206489 }, - { url = "https://files.pythonhosted.org/packages/3e/60/c647c6ae47480221e875ea5d743ff94946f7416e3c69415ab772928e8d32/charset_normalizer-3.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:11912e4bb14baae7c5d8791aa55ba0a3a03ec6729073307b0f57270abaa713d3", size = 195501 }, - { url = "https://files.pythonhosted.org/packages/58/ca/7aa91362a2f77ac8e9e28a9b902a74f7d0e11a851ef0d27a74308da8cd90/charset_normalizer-3.5.2-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:1afb975bd5d68d5ce9f6b6d44fdf2f7e34b895a35e95708a7a91b20a3b51d187", size = 226010 }, - { url = "https://files.pythonhosted.org/packages/a8/cf/ac8878d0322cf88a1aad4c7b147db32ca0bd806eb0060957b2e31486dbe6/charset_normalizer-3.5.2-cp314-cp314-android_24_x86_64.whl", hash = "sha256:bbbfc8e28816f19d7c0f1816664980c0a9875d01b27cdf8eedddb639d9e108ad", size = 239266 }, - { url = "https://files.pythonhosted.org/packages/c9/6d/9a08d7e0b29b7208e2c6c01dc56c8e0520e7c7beadbbfb024b58fd69c8a5/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7967d08cf06dee78443b874f98c98036f624f3a4e73e11f9f64f5be4d25393cf", size = 206783 }, - { url = "https://files.pythonhosted.org/packages/82/44/b0aa350280e6ff5a5492d17cf10460dd39d5ee848f872f7ba2df10607f60/charset_normalizer-3.5.2-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4c2b5031f63e331e3839b40aed2dd6f191e9c07edbde303e7876846ea1946995", size = 210449 }, - { url = "https://files.pythonhosted.org/packages/7c/8a/40db9aa9f5907bb0e6f8b6d64064bf8852fb33d4b813ff9414911df7647c/charset_normalizer-3.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:fcff63213e8e6e47770541a4607175404f47cbb3ebea7b6058cc82d524a0e424", size = 367815 }, - { url = "https://files.pythonhosted.org/packages/7f/72/9c5e7707b57c8ddfa9ddf7b0b1d009d7fbab9e9e887d5b721060f37e307d/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d86d6fc60743dc916eb79e2eb1ec4818e21e427731543af40a3021851174a13", size = 247095 }, - { url = "https://files.pythonhosted.org/packages/83/09/71e453691e927de4ddf792770cfaab3f49d494e222f66ea5e404bbd5e39c/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:7a881931aa470808df94a8c380eed2bbbc76cd9dc622310f99665658c821eb6d", size = 233942 }, - { url = "https://files.pythonhosted.org/packages/9f/86/85c84e4da8b27dd409577d9437926ff581c5f9d3c66038dc68c1a526de51/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8024d00c3faf3fc0c16e07a69f4405e8eac7cc0ab15f65fe6cf43827c4cf72b4", size = 273762 }, - { url = "https://files.pythonhosted.org/packages/92/08/564955a4b5f2ccb410ab480bbe8c6a18063ff27f2d35458731c4a5335df9/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d48f2d08b9de5864e2c8744d4461b862fb149a18274abc8b698c45975573438", size = 269514 }, - { url = "https://files.pythonhosted.org/packages/18/24/bad3ac4271589df29cf5ce2f5ae490518a5739358052bd0d61209e6fea54/charset_normalizer-3.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34276fd796040bf0993ab33a369aa572e6979c7aab225a88893667ad8eac8f7a", size = 255056 }, - { url = "https://files.pythonhosted.org/packages/d6/3e/350d89ad49916b86554d6f5f2d03ec1152148f87e5ff735106c6a03b1a36/charset_normalizer-3.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0521c5665880b33d603717defa76c094048900010897909952397feb3039da56", size = 251311 }, - { url = "https://files.pythonhosted.org/packages/56/5b/4970a2d154df502e133402906dd04e3ae7cada7b3011283c88d0479a2585/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:eff0ac9dbe711a4aee69bf04a83896aa9b85f19641264053a9f6d48573abb7dd", size = 250190 }, - { url = "https://files.pythonhosted.org/packages/88/8c/f1a91bddc8fb47c2889e29ea7ea49a194eb0d9868675d786806519c00d76/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:1503bccbeb36d5527790c3930327704c39af22de3112f1b1666a9f3ce15ee204", size = 237098 }, - { url = "https://files.pythonhosted.org/packages/24/0e/bb5dace3cc7e79068425386a6589c19b5a2ab5fefc2a46abea6919683332/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:52aa6992700996af31f375de0c6bacd402b0097fe40b53c426b9f51a90ebabc7", size = 274228 }, - { url = "https://files.pythonhosted.org/packages/9d/79/b849ad523017ea9f5a45581bbebed91439e0cf42fd2860a6f64e358eb5a6/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e09a3942ecbdee5cce73ea9d42da82b81b72ac1bf031ce069b93b5adf4eac8cd", size = 252401 }, - { url = "https://files.pythonhosted.org/packages/89/8c/75469d690cf47200bce8f6cad7655724fc23148e147abfc5ce78b5f65863/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:c7c9ab723cde841fefb34efbad91e87f00a674b1fe1cd0784fde742bf2c154dc", size = 271846 }, - { url = "https://files.pythonhosted.org/packages/26/cd/6d52d3c7437cdcf2e310ce9f28f282e733d4ef60ed19105d1819c356255f/charset_normalizer-3.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ddc7dacc8ece3a182e7f15cb862d1fd616b46d076cb1ae9dd232b2c38b655874", size = 258151 }, - { url = "https://files.pythonhosted.org/packages/f7/4c/070b38bdb5f49a70199fce923ec0726a49536a63ab262abbfcaaf351110b/charset_normalizer-3.5.2-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:ee43c17b173d46a3212baa6ead3ae258eeabdae48c263a01ccf0218c366dd655", size = 143570 }, - { url = "https://files.pythonhosted.org/packages/81/84/9ebfc8ed6c8c4fcd8e726ff6bf220cc8deb3966e31dce9be8dd8aa017e64/charset_normalizer-3.5.2-cp314-cp314-win32.whl", hash = "sha256:4f87960d57feabfb618e4e0af6e7371645fa26a277860739d6e5d6e0012c92f0", size = 186412 }, - { url = "https://files.pythonhosted.org/packages/d1/78/5ed86f743d4bc350db307e7636419a0a5ee1d91806d30c7f667bd5c80dae/charset_normalizer-3.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4e81e09c1578b8df602e3db08b0b3ea0a6947ad612f52bf8dc5ea8d47691f0c", size = 210965 }, - { url = "https://files.pythonhosted.org/packages/53/94/a3a7698e9b1a395e1eb99ccd9a324be9347973bff4e72db2a06496d7cd27/charset_normalizer-3.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:80d02b6f04e92601a081dd97b23d3128033098bff5d35d392ddcc0476ea11253", size = 200406 }, - { url = "https://files.pythonhosted.org/packages/c1/48/c5dd00d5ef7791f02666de250a5bb6071e29b7e133cf4b835800b6d3bc27/charset_normalizer-3.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:dca9ab98072a5a54ebacebdc45f53e645336b320c667410b061be1ca588ae709", size = 396871 }, - { url = "https://files.pythonhosted.org/packages/12/c8/8379554b42e8368161d898476686947a0fdbd3e8865170d7909dcabfdee8/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0aa869112ef88429ae17820d99c3dd9504c9e9c671d3c246f3d7442cb051084", size = 249711 }, - { url = "https://files.pythonhosted.org/packages/4a/eb/2ddb1035d17320caa9f41682935123a9a250277b261c3efc86b2d2a21343/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c0afc6800ba57ccc350374c5bd6150419915d95ce93cdbab2d783d75eaf30ecb", size = 231146 }, - { url = "https://files.pythonhosted.org/packages/4a/24/2ecb4bde104322cd7859d6594fcfa74649f8d90b3221c9feecbef149875b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7dcd882da75ef9adf94903b1e3b9419e8aa8fb4c7396822b834b9ef7fb96954f", size = 271552 }, - { url = "https://files.pythonhosted.org/packages/3f/98/9d5f6ebc3aee9fef5d30b4aff11fb2ab7a1222b4064f8ef2c7c87cde217a/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2e06a3a98f916dd41d27f3105e02e7a40181c98c94b9158733d03a6f80506c09", size = 269810 }, - { url = "https://files.pythonhosted.org/packages/09/e1/a3b06a10461b1b7628853c934c644e03bc28e42767116afb52f19a56519b/charset_normalizer-3.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6bd128f206a7752ae1f2ab6c61bf8a24ba28913a10df8b14c2637b973ff97a80", size = 259845 }, - { url = "https://files.pythonhosted.org/packages/fd/d3/6f561f74a296cf27d61775a1dc665ad13f3bff6a798810ca05907f37a7c4/charset_normalizer-3.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c8f3d67aeaf55f017982b73683f0e7342ba2f6635a78f69ce89ebb26aa411e5c", size = 252825 }, - { url = "https://files.pythonhosted.org/packages/26/9f/69e13ca3b18f43e0eafcd34c04a45b732ae22a43b54a5fc9e119103356eb/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fe9753dfee015c570d73df76f899f18444d41388bffcde097deba51c4fadbb9f", size = 252659 }, - { url = "https://files.pythonhosted.org/packages/73/a9/ace29806a0dae18939919c76ba526472d83214afa101105fabff2cf30625/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:92888bb3187c5ba50500b00b3b310c9f2c651709d28036077680cb5255450a03", size = 238819 }, - { url = "https://files.pythonhosted.org/packages/f8/c1/6116d52a2e3311ec80f21f5fb5e17b27405f10b9608af8f6e69516841a1b/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:d008d90a7f2471519aef0c90dfbe73b3e6e4d5e66ac48e19154c17e89e98b604", size = 272973 }, - { url = "https://files.pythonhosted.org/packages/19/aa/9955c7e93bba10a9c7e8f7a5031b7ced66f3a1883a55c00712b8d5850ff3/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:31f3930700408d211f13378ccbe1c40845d8da54bd0681fac3a9b5aae81c7aa8", size = 253692 }, - { url = "https://files.pythonhosted.org/packages/bb/33/2a6ae7fdc1b10cb581cef91addd8cdfc5f40d50abb5702309369d5834579/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2a925889534b3748302dae5dead07cc13480de1dac3aea80a941b729b471ef93", size = 268757 }, - { url = "https://files.pythonhosted.org/packages/a2/22/80992720a0282cd39bba1db35868e6b9c22f41281160143a836544bc1d8a/charset_normalizer-3.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f5ec61164adcec446f8969a3358ec3f9b26bbda3b9213e5586d219afa8df2915", size = 262220 }, - { url = "https://files.pythonhosted.org/packages/92/9f/181fd07e1bffea1d95cd80c84ac537354f50699c22cfc4d3c02b6fc16208/charset_normalizer-3.5.2-cp314-cp314t-win32.whl", hash = "sha256:598a11a2c7ebaa5334bf698bf29568c9c390abac6a154d8170fedecd1cea38c5", size = 197797 }, - { url = "https://files.pythonhosted.org/packages/49/1c/25d8415ec1c4f2f41f1680435e4c87cfb378ff2f677d950946f2a45d0632/charset_normalizer-3.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:7fdde2c9fd9e3eca40631e024664cf2584272cc8f96308cbe5fdfc930f51d8bc", size = 222765 }, - { url = "https://files.pythonhosted.org/packages/3e/b4/46b48f013dadfc0d0d33b375438e31bdf5a989dc68389c6bf627054d4df9/charset_normalizer-3.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d1befeed746d247c81127bb14de9dc3d30edb6e5976d34f83f86ed262b1d9105", size = 210985 }, - { url = "https://files.pythonhosted.org/packages/ca/e9/34e597dee616d0b8ee4b34d29399e85c2204ade174157a48505d42baa4ff/charset_normalizer-3.5.2-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:87475fabc8d9996fd9c27debb395e642e8c838d78a00b6e932227a0e06b81e26", size = 367676 }, - { url = "https://files.pythonhosted.org/packages/60/9f/a5d1c91c0263745e2cd344c5a4415d787c575501ab1d449f1148ac6b495d/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9409a8bf35cf78353942504b24a57de3d75b708997a1e4bd8db71ac8633ce364", size = 248414 }, - { url = "https://files.pythonhosted.org/packages/26/79/e697f77464748a3ee3cf490c83d592459400d4898380d66c38366b03080c/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:498dc3188ca05a68231ac3fdbfc7f57eb67e1343c30e0fea17f8218c1599b253", size = 244775 }, - { url = "https://files.pythonhosted.org/packages/ca/87/3d42a42e18ea066e2513936fd678a00696e77878b5ae04528976abdbcb83/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e242bb1c5e76e97dfa9e7f209a71e93a01d7f19ffdd5cfbb2e2d55b4f08f8ab0", size = 273877 }, - { url = "https://files.pythonhosted.org/packages/c3/76/8a28136f3938ba9836f84280ce0c4d61ed1cf15a036b2034900c62634162/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:def79fa35ef0cef8d2accec024f4fdc7ead3012ff02f5215c783f39f03ef8cfc", size = 269455 }, - { url = "https://files.pythonhosted.org/packages/a0/a1/4fbf5d0f0f1b2a080474c1cf9a2f12c4c6531bb0e8ba591055e846d2b4e9/charset_normalizer-3.5.2-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3df041de8887954562c9b261cba85ca0e9ded74048daf125f45edcfaa4832229", size = 257336 }, - { url = "https://files.pythonhosted.org/packages/ba/a2/8b50aa320adb880ad579518e6f718f24944804b42a88b83d267d5d444125/charset_normalizer-3.5.2-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:04851f73ae72b8413dddadb16a49dfee95263553741fd42d546f7d66907e6be5", size = 257584 }, - { url = "https://files.pythonhosted.org/packages/a5/57/50e3fed84e175f40349bd0da7a4fce94c87f0378f52d74f511d89e0bdc20/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:183b88127acdb4fabe59d951ab424faf1af7b63cdbb5f776186c1ea2ffcaed98", size = 251854 }, - { url = "https://files.pythonhosted.org/packages/d6/54/f7fbb3493c9f49091213b9c2d6dd65800696f1ce1a3f196a4205f50417b1/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:16fa0eccf81304b79c5cd87f9271c3b85dd9dd99245e4422ae9c0dd45e0f99d3", size = 248767 }, - { url = "https://files.pythonhosted.org/packages/d9/37/b3a6385acc5a1e45b39ae9c90bfb9cf838a09b9dd37ef2740ab4c6b4a2eb/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:7441d755b7ab94f8d4eb3e43ec05482d760842fd263d003a99102d742cd835e2", size = 274479 }, - { url = "https://files.pythonhosted.org/packages/89/44/809913e2cfd279e635a9294fdbbfb1b1dc62a8189d473d561f649fce98d8/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:ca403d7e4798f525fdfc78e258820419cbbd0f0ecbab9de7840e3c017cf6b8cf", size = 258310 }, - { url = "https://files.pythonhosted.org/packages/af/a2/f28400ab13359d91bd39179df8e149376b9bf36588e739a3a4f9de2b84b2/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:df29a0a7107f7011e77f4eebdddec4c7331e24d787a0b21a46d63bdf7445da95", size = 271100 }, - { url = "https://files.pythonhosted.org/packages/e9/89/9bab37955edf0adb3b66f8a3a6617d9f2f487e0d56f295a6a286cb640aa6/charset_normalizer-3.5.2-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:f3c96f633825733f735c5a9cf21d21a257d8e1edf0b1cee0a064b9c424ca0f7d", size = 259936 }, - { url = "https://files.pythonhosted.org/packages/23/b5/4459e08d45a679f903d50fea08bc52cfa728cca4d7bd02c757b5e5abda2e/charset_normalizer-3.5.2-cp315-cp315-win32.whl", hash = "sha256:281cb91036248400f4cc957495cccd44c275c2e0c5854f7e45ac5cf7dc193847", size = 186301 }, - { url = "https://files.pythonhosted.org/packages/98/e8/55d5fd3935b4bce6da4fe0df61898e8c82653e317e677bd58aceb9c60f13/charset_normalizer-3.5.2-cp315-cp315-win_amd64.whl", hash = "sha256:89b53f3cda69831909888e0494f4fa0bcd3537e3e138dabeb620bd6ad946bae8", size = 210858 }, - { url = "https://files.pythonhosted.org/packages/a9/5b/974423c2fd8e524c7a7f64318c1e02240ef954912fa2b4d70344107b9c68/charset_normalizer-3.5.2-cp315-cp315-win_arm64.whl", hash = "sha256:6be488a102b8cf28d0391d8c4ba7748938ae28b78ad901f8585520fca33ead1a", size = 200309 }, - { url = "https://files.pythonhosted.org/packages/ee/f9/00ee0195db1013d8f7c416fd770fbeb560bb46eb2e36b054d05cb56f6cfa/charset_normalizer-3.5.2-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:915563965d418f986e7e145accc592eae9e1a1be3566ff98a05d7a9ec42a76e1", size = 395343 }, - { url = "https://files.pythonhosted.org/packages/04/3a/c00b50e94c964cf934c7899cd47c97952fc11dad71cc5884b3c61795b09b/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:65cd72beeeca9d3aaea1201e5923859f308f952f9c71de93f06063c79f0f7a3b", size = 250114 }, - { url = "https://files.pythonhosted.org/packages/50/27/d102dc880bbcffd0479ab64dfc1fb96777a854355a55e2bda72a71efadcb/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:b7fd005a73d9e657273b7a10dc71a9e03c8fb9ee6999798d6918ce095b81ac7f", size = 239997 }, - { url = "https://files.pythonhosted.org/packages/a5/4a/bf7ef45794dd293fab5f98a9309817977fbb845b9998f171b8cc5d8437a3/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e54da4baf05720032d527874d40b65fa4d7e5c6c6a43d0c3adbeffcaf275a2b3", size = 272313 }, - { url = "https://files.pythonhosted.org/packages/e8/ee/008a2837737991474c5754bb3191010007663860979701990982a502cbaf/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:124fbf1a8ff966d87ae05bb8bd45a71f966055ed8bba320d0c7cf450bc5f4d0e", size = 269986 }, - { url = "https://files.pythonhosted.org/packages/93/ad/bd74a283940dc910c5b14f8e4f80a248082bc9c0fcbe1f54530cb6d9cc5e/charset_normalizer-3.5.2-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:28b4f0d66fb834ff90f28209ac7bce77868c45d8c93e26f906709d9b7c2e1af9", size = 260202 }, - { url = "https://files.pythonhosted.org/packages/8a/7b/ed341c66f69f688723501fac752be3d63c7159ca0d0d4174fc611e5710bb/charset_normalizer-3.5.2-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:58ca3755ee7ff7f59b57789ec9833c9de9ea275405cdd240eda1f193112e398a", size = 259083 }, - { url = "https://files.pythonhosted.org/packages/cc/9d/e41588b777965e5031a43128a1e96173ebb35ac75fc53ec3b517e7c21cd4/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:443eae2bf318abeaf6f15d785138f71fd6de770e99a92158b8b814265e079115", size = 253081 }, - { url = "https://files.pythonhosted.org/packages/81/35/b761eb6d8c1eb218b9b42b9b4d5ac902afdc399fb6dac6f9a9aac7bda589/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:58f361dcbab699cf8f42db3f47c8e7fd1036f138c23a5d08de9fde5f425a730c", size = 248712 }, - { url = "https://files.pythonhosted.org/packages/4d/2c/147169a041b747759f37405c0a97157e8e92de967968373101ff14915cba/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:1b4cbc7c3491ccb4aa17fcd8165649d01cf39f76de1696da8631b5f71b85401d", size = 274176 }, - { url = "https://files.pythonhosted.org/packages/f0/2d/0ff8db0d373ba8538db686db11cd7e8912031490b9e4f383b41912e8d594/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:ba0b1d2620edf869789c3879223f52bf2afc5d31b3cb47cc57b3a12c05e2aa9d", size = 259413 }, - { url = "https://files.pythonhosted.org/packages/8a/8e/b4a085fb47c9d3a7e43576a4784fdd8fe23f907514a972de8086edaf7a48/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:5e2b6b57e9733d39f0c9fd3185efa6b8e29652c4cd8fe94180272cf6ed9a78c4", size = 269434 }, - { url = "https://files.pythonhosted.org/packages/83/1c/d8d8d7322a7c3eecdf3237a4a419cf41d2eaad8e006ce7dfdd9d4c8fa2eb/charset_normalizer-3.5.2-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:51cf45226a9b588d0d2b4880c62d686934b63ab0bd79ca23ab0e9762eb27441b", size = 262716 }, - { url = "https://files.pythonhosted.org/packages/a0/16/0e4c6ba9b44e97a2da150e52d331e8f9c968b21b358fbffa6c856cebcd89/charset_normalizer-3.5.2-cp315-cp315t-win32.whl", hash = "sha256:5fb29fb8cd1a46c27a1bf9613ad5ec2599310d46b4025d9556404a6b6a292800", size = 197159 }, - { url = "https://files.pythonhosted.org/packages/be/33/e90bc2b1374f7f36ef106f56620de5a783907e19ca857efe2277e31cac3e/charset_normalizer-3.5.2-cp315-cp315t-win_amd64.whl", hash = "sha256:a192e2c40070d92c3ccf777e3a5c4ff515573cd2bb7ed0c537fdadbbec5bbf21", size = 221322 }, - { url = "https://files.pythonhosted.org/packages/66/89/dfa6dcb08c200b7830ab56439e8c1890f2971d51aafbb3937894a2e7fcfc/charset_normalizer-3.5.2-cp315-cp315t-win_arm64.whl", hash = "sha256:749e97e1b32313717a565abbe321bc2190bc8b35f1a67e4cdbc7c56c8d8ffe58", size = 209192 }, - { url = "https://files.pythonhosted.org/packages/8c/ab/176fbfd5b64939c55d652366aa5b9ef1d767af207a3aa6ebeb0d226c484d/charset_normalizer-3.5.2-cp37-abi3-macosx_10_9_universal2.whl", hash = "sha256:4275811936e2f06feff5e598fb42a1b7ae852da8e39605211892b56b81a34efd", size = 331815 }, - { url = "https://files.pythonhosted.org/packages/7e/84/371eac6b30bdbcbf2d632a1a01809103459216fcaae61b8b8d922c1bfb8a/charset_normalizer-3.5.2-cp37-abi3-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1c50fe28bbc2ced33386f298650d91218076c05420e6cbd790b913adc41659e7", size = 253276 }, - { url = "https://files.pythonhosted.org/packages/43/6f/c4fbae58febff71709c51bc7e18fdfa55341dc382704740f9f0cbf03817b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19fbd981a488e22cd04883659ca6b08f50b5974f9fd7c95655ef6a043e5893f", size = 241239 }, - { url = "https://files.pythonhosted.org/packages/61/71/458c3f42164a07d0c5210798e9e704b39e540a6793b05aba67f3a35243a9/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0fed1d06615f022ee3b13caf5e8b180cfea32bb2c5aded8a9d44277afc040f93", size = 231121 }, - { url = "https://files.pythonhosted.org/packages/09/54/ab9e89367076f6331bb6c65c4bf14a5361fa5191cb6561bf534f18504e1b/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:838dcc90063569a0448120554591a1d6c4a4ffe11babf048908793154ab86ade", size = 260350 }, - { url = "https://files.pythonhosted.org/packages/7c/c1/061431ecc688d9d76602502cb57cc01e691e682c18f1beb45f9673b5bbd2/charset_normalizer-3.5.2-cp37-abi3-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2ce45c6627b22c47e390bc91a41c3d13032192e699fa0bea96e9671b373d69b0", size = 255430 }, - { url = "https://files.pythonhosted.org/packages/8d/1f/20c8949f0676f7ab811abdeb7f4d7f1cbc6e61ff20bef08b44edeb092bc8/charset_normalizer-3.5.2-cp37-abi3-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0774bf9bf620249fee3e0b8b9fd3065de213be30f3aa94ce2494b3b638949e26", size = 250612 }, - { url = "https://files.pythonhosted.org/packages/2b/9e/46f2fa4c431fc98c4ae76a8cb5bdca54e0341e3cfc3fcfd8e82740250818/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1db38f4c5496827c1a501846d64d14c3b80c7e6714e406cd7dc36a9899fa1011", size = 242083 }, - { url = "https://files.pythonhosted.org/packages/bd/39/559be29a0c0f086e0bba6922babd38916cc5e0b58ced4de13ee01ea05508/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:304d8e4d493af723536393eee0c689eb7813f4a474c8b479dee63f1fdd98f621", size = 232738 }, - { url = "https://files.pythonhosted.org/packages/ff/6c/387b0e4f756a282831c1d9fc6aeb6c51ca4507ca202767c8de15ce9b12e2/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_ppc64le.whl", hash = "sha256:9b7f416ff0978e2f2249330527f0ad6fa02f4932e6199692d3b52da2048c19e4", size = 260703 }, - { url = "https://files.pythonhosted.org/packages/96/92/1fdf015f09ef449f50d3ac4b67c90887c9c318b727daa95cc4f866e6521d/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_riscv64.whl", hash = "sha256:01077390b03f7988f11d700a2194e69b119741a86b1a638b1db88891e3eced8e", size = 247622 }, - { url = "https://files.pythonhosted.org/packages/dc/3c/8e7b8a5671ad5d433669fb2a76f1a0164df2d9b1718b0206bc2a16d840cc/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_s390x.whl", hash = "sha256:7e841fb9010836c992c9f12fcbd43a831de93a5f726fc1ccd8ca1d0268c5014c", size = 257500 }, - { url = "https://files.pythonhosted.org/packages/b4/f0/45b579df5cabc1d5d53ea1cc35e8437d3ca768c0acccc7041517cb6fbb32/charset_normalizer-3.5.2-cp37-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9cae88599c7219005d879f98e5ed53341e9a122af585e1091200358a3003d2a0", size = 255100 }, - { url = "https://files.pythonhosted.org/packages/31/68/fdec18a343f5fb3f310588dd478b09ac4799e0b187dbade3a8cd776f03ef/charset_normalizer-3.5.2-cp37-abi3-win32.whl", hash = "sha256:01b0c0d2262a9e28e8484a278c7e1b5d650e3ac8cf2683d2967e25899f208bdf", size = 174499 }, - { url = "https://files.pythonhosted.org/packages/9d/8a/b618149cc5207943a0242068d7a27897f56a62947b5a039085f2a22029f8/charset_normalizer-3.5.2-cp37-abi3-win_amd64.whl", hash = "sha256:9f56f72050826f63dcee7a7f55b0a77168cb3bfc553fd405e7f8f9ece75a4036", size = 200092 }, - { url = "https://files.pythonhosted.org/packages/03/cf/4c66866fa9e2b1c78e3c911516d1de497a677b7ac60f1eceda74ce777ca3/charset_normalizer-3.5.2-cp37-abi3-win_arm64.whl", hash = "sha256:40ab6bffa02ae10a0581e6c198be7d2d8ca5c2a0c64e4ed3465d766df457573e", size = 294363 }, - { url = "https://files.pythonhosted.org/packages/fc/ad/d07d7862a62ffa6d79d68074d14823243dd235a77c45262acbf6adeb28bf/charset_normalizer-3.5.2-py3-none-any.whl", hash = "sha256:b6b751274acb69d77b3323d6b7dbaa3c7fdfc1eb829b7eb61d262f32e1af9685", size = 68872 }, -] - [[package]] name = "click" version = "8.5.0" @@ -334,6 +164,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, ] +[[package]] +name = "deepmerge" +version = "3.0.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/38/6e/5cb3548b4d3112fea529375e55e6f3cdc52b8054e3a66f203b1f888ba885/deepmerge-3.0.1.tar.gz", hash = "sha256:35b39a4cb92cf328d6eca61cbbf65f68a37c2ceb3085f0f853cbb2e52a59fc23", size = 22328 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/20/91/600003aaad107e27553fbc9cbfc57e96fa37e0223d2ef9d7d3a0e8d8d070/deepmerge-3.0.1-py3-none-any.whl", hash = "sha256:35c96f6a68fcf90719a5b31d9f8042ecef6c00fb56836660d33455d0f5cfda65", size = 14909 }, +] + [[package]] name = "dnspython" version = "2.9.0" @@ -389,18 +228,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7f/9c/34f6962f9b9e9c71f6e5ed806e0d0ff03c9d1b0b2340088a0cf4bce09b18/flask-3.1.3-py3-none-any.whl", hash = "sha256:f4bcbefc124291925f1a26446da31a5178f9483862233b23c0c96a20701f670c", size = 103424 }, ] -[[package]] -name = "ghp-import" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "python-dateutil" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/d9/29/d40217cbe2f6b1359e00c6c307bb3fc876ba74068cbab3dde77f03ca0dc4/ghp-import-2.1.0.tar.gz", hash = "sha256:9c535c4c61193c2df8871222567d7fd7e5014d835f97dc7b7439069e2413d343", size = 10943 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034 }, -] - [[package]] name = "h11" version = "0.16.0" @@ -911,84 +738,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979 }, ] -[[package]] -name = "mergedeep" -version = "1.3.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/3a/41/580bb4006e3ed0361b8151a01d324fb03f420815446c7def45d02f74c270/mergedeep-1.3.4.tar.gz", hash = "sha256:0096d52e9dad9939c3d975a774666af186eda617e6ca84df4c94dec30004f2a8", size = 4661 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/19/04f9b178c2d8a15b076c8b5140708fa6ffc5601fb6f1e975537072df5b2a/mergedeep-1.3.4-py3-none-any.whl", hash = "sha256:70775750742b25c0d8f36c55aed03d24c3384d17c951b3175d898bd778ef0307", size = 6354 }, -] - -[[package]] -name = "mkdocs" -version = "1.6.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "click" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "ghp-import" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "markupsafe" }, - { name = "mergedeep" }, - { name = "mkdocs-get-deps" }, - { name = "packaging" }, - { name = "pathspec" }, - { name = "pyyaml" }, - { name = "pyyaml-env-tag" }, - { name = "watchdog" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/bc/c6/bbd4f061bd16b378247f12953ffcb04786a618ce5e904b8c5a01a0309061/mkdocs-1.6.1.tar.gz", hash = "sha256:7b432f01d928c084353ab39c57282f29f92136665bdd6abf7c1ec8d822ef86f2", size = 3889159 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/5b/dbc6a8cddc9cfa9c4971d59fb12bb8d42e161b7e7f8cc89e49137c5b279c/mkdocs-1.6.1-py3-none-any.whl", hash = "sha256:db91759624d1647f3f34aa0c3f327dd2601beae39a366d6e064c03468d35c20e", size = 3864451 }, -] - -[[package]] -name = "mkdocs-get-deps" -version = "0.2.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mergedeep" }, - { name = "platformdirs" }, - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ce/25/b3cccb187655b9393572bde9b09261d267c3bf2f2cdabe347673be5976a6/mkdocs_get_deps-0.2.2.tar.gz", hash = "sha256:8ee8d5f316cdbbb2834bc1df6e69c08fe769a83e040060de26d3c19fad3599a1", size = 11047 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/88/29/744136411e785c4b0b744d5413e56555265939ab3a104c6a4b719dad33fd/mkdocs_get_deps-0.2.2-py3-none-any.whl", hash = "sha256:e7878cbeac04860b8b5e0ca31d3abad3df9411a75a32cde82f8e44b6c16ff650", size = 9555 }, -] - -[[package]] -name = "mkdocs-material" -version = "9.7.7" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "babel" }, - { name = "backrefs" }, - { name = "colorama" }, - { name = "jinja2" }, - { name = "markdown" }, - { name = "mkdocs" }, - { name = "mkdocs-material-extensions" }, - { name = "paginate" }, - { name = "pygments" }, - { name = "pymdown-extensions" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/f1/cd/c05d3a530ba7934f144fb45f7203cd236adc25c7bdcc34673d202f4b0278/mkdocs_material-9.7.7.tar.gz", hash = "sha256:c0649c065b1b0512d60aad8c10f947f8e455284475239b364b610f2deb4d0855", size = 4097923 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ad/21/17c1bc9e6f47c972ad66fb2ac2568f99f90f1207eeb6fc3b34d094dba7b5/mkdocs_material-9.7.7-py3-none-any.whl", hash = "sha256:8ea9bb1737a5b524a5f9dcf2e1b4ebda8274ae3008aa7845720a97083bef708f", size = 9305438 }, -] - -[[package]] -name = "mkdocs-material-extensions" -version = "1.3.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/79/9b/9b4c96d6593b2a541e1cb8b34899a6d021d208bb357042823d4d2cabdbe7/mkdocs_material_extensions-1.3.1.tar.gz", hash = "sha256:10c9511cea88f568257f960358a467d12b970e1f7b2c0e5fb2bb48cab1928443", size = 11847 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5b/54/662a4743aa81d9582ee9339d4ffa3c8fd40a4965e033d77b9da9774d3960/mkdocs_material_extensions-1.3.1-py3-none-any.whl", hash = "sha256:adff8b62700b25cb77b53358dad940f3ef973dd6db797907c49e3c2ef3ab4e31", size = 8728 }, -] - [[package]] name = "mongospecs" version = "0.2.0" @@ -1126,7 +875,7 @@ dev = [ { name = "sentry-sdk" }, ] docs = [ - { name = "mkdocs-material" }, + { name = "zensical" }, ] [package.metadata] @@ -1145,7 +894,7 @@ dev = [ { name = "ruff", specifier = ">=0.8" }, { name = "sentry-sdk", specifier = ">=2.0" }, ] -docs = [{ name = "mkdocs-material", specifier = ">=9.5" }] +docs = [{ name = "zensical", specifier = ">=0.0.70" }] [[package]] name = "msgspec" @@ -1544,15 +1293,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 }, ] -[[package]] -name = "paginate" -version = "0.5.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ec/46/68dde5b6bc00c1296ec6466ab27dddede6aec9af1b99090e1107091b3b84/paginate-0.5.7.tar.gz", hash = "sha256:22bd083ab41e1a8b4f3690544afb2c60c25e5c9a63a30fa2f483f6c60c8e5945", size = 19252 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/90/96/04b8e52da071d28f5e21a805b19cb9390aa17a47462ac87f5e2696b9566d/paginate-0.5.7-py2.py3-none-any.whl", hash = "sha256:b885e2af73abcf01d9559fd5216b57ef722f8c42affbb63942377668e35c7591", size = 13746 }, -] - [[package]] name = "pathable" version = "0.6.0" @@ -1571,15 +1311,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328 }, ] -[[package]] -name = "platformdirs" -version = "4.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/80/a8/66d45abadff219e36e2a824181b8f6a67e7ed4572934d6252c71c29d5731/platformdirs-4.13.0.tar.gz", hash = "sha256:1aa0b0d3f224c1f07c295121e312a5a24a180d6ae5a8425ea1784b3e3863e9c0", size = 61094 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8d/15/1633010b26e88e872c93b67c0b6c5e174fb74cb6fb5c1472b4d51d4a8f22/platformdirs-4.13.0-py3-none-any.whl", hash = "sha256:3dbcf4cd708f21cf876c4eaa90e58412bc4f033d87143f41b1493ff77c25b7e1", size = 32724 }, -] - [[package]] name = "pluggy" version = "1.6.0" @@ -1877,18 +1608,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930 }, ] -[[package]] -name = "python-dateutil" -version = "2.9.0.post0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "six" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, -] - [[package]] name = "python-dotenv" version = "1.2.4" @@ -1953,18 +1672,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341 }, ] -[[package]] -name = "pyyaml-env-tag" -version = "1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pyyaml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/eb/2e/79c822141bfd05a853236b504869ebc6b70159afc570e1d5a20641782eaa/pyyaml_env_tag-1.1.tar.gz", hash = "sha256:2eb38b75a2d21ee0475d6d97ec19c63287a7e140231e4214969d0eac923cd7ff", size = 5737 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/04/11/432f32f8097b03e3cd5fe57e88efb685d964e2e5178a48ed61e841f7fdce/pyyaml_env_tag-1.1-py3-none-any.whl", hash = "sha256:17109e1a528561e32f026364712fee1264bc2ea6715120891174ed1b980d2e04", size = 4722 }, -] - [[package]] name = "quart" version = "0.22.0" @@ -2026,21 +1733,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2c/58/ca301544e1fa93ed4f80d724bf5b194f6e4b945841c5bfd555878eea9fcb/referencing-0.37.0-py3-none-any.whl", hash = "sha256:381329a9f99628c9069361716891d34ad94af76e461dcb0335825aecc7692231", size = 26766 }, ] -[[package]] -name = "requests" -version = "2.34.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075 }, -] - [[package]] name = "rfc3339-validator" version = "0.1.4" @@ -2284,6 +1976,78 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/4e/d6/1ec1b290f9e0fb067899b61e1d37a30c923068bad260b216dbe37a7d2967/starlette-1.7.0-py3-none-any.whl", hash = "sha256:67f8e99895493dd2911a03f11314af6ceebeae4e704bb9f43dfc6a9db151c93e", size = 78980 }, ] +[[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 } +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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/bf/72/7619b87dea4261fc27dd7b54c4461c129c1f7d9bb7ba3aec89c797a431b8/tomli-2.5.0-cp311-cp311-win32.whl", hash = "sha256:610b27d99f28ec5f191c7064a48f3ddb179a1fe6ca73d571483ae859f57b605e", size = 101830 }, + { url = "https://files.pythonhosted.org/packages/1e/74/220106da34502304b6751a2a9b8a9fbca6c3fd47e737a2e2e3da7c61c9db/tomli-2.5.0-cp311-cp311-win_amd64.whl", hash = "sha256:c804ae44fe7b4bab5da295e4f980a1ff04670bca9d23fe0a4e887e08ebd741a8", size = 112742 }, + { url = "https://files.pythonhosted.org/packages/27/99/7d9c8b41837a7773613e169504147375c157a290167aa59ad74a085f521f/tomli-2.5.0-cp311-cp311-win_arm64.whl", hash = "sha256:cfac177ebd6236003846ea339981f71457cb6eb748f23381eb257e45092e3980", size = 109332 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/77/8b/0144c65f0e37e51c18d04ae15c21b19431c165002d0131fe9aa8b0b8b1e8/tomli-2.5.0-cp312-cp312-win32.whl", hash = "sha256:e7ad033e27a516a233bea839cdb77b80146facb3b4f40bf02cd0cac165cdd5c2", size = 102760 }, + { url = "https://files.pythonhosted.org/packages/de/32/5d6d8f42fc9a05fce69354e00ff256484192f5f2fc9a2165718fa0de61ec/tomli-2.5.0-cp312-cp312-win_amd64.whl", hash = "sha256:bd05de8c1698f8413dd7d869492693a0bf2211543b787ac78cd5e7536af1a6d7", size = 112722 }, + { url = "https://files.pythonhosted.org/packages/30/65/df18032218db0fb9b769fb23c8039a051f15c811993995ea04c350273a32/tomli-2.5.0-cp312-cp312-win_arm64.whl", hash = "sha256:069435bd5480429b98c5e5afb02ab21c219b6f0064680671c6dc0d46817346ea", size = 109534 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/ac/03/70f2bcb2923a6db37818d917e124270a7f4cfd38ea576f5aa753a91c0ef5/tomli-2.5.0-cp313-cp313-win32.whl", hash = "sha256:abdbf6313b8d9efe157edeb7ab6eae4de064b1300ad31abf73755154b30abe68", size = 102628 }, + { url = "https://files.pythonhosted.org/packages/dc/98/d52024bb5b0ff68b4f0d276d867f634c84a67319a7e9f6b7708a37742333/tomli-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:fd4dc129784e0c5335bd4e61dfcc4487499a013419e655cf2da1d091b7e0efdc", size = 113301 }, + { url = "https://files.pythonhosted.org/packages/6f/f2/540db3a70572a8c23a28aba3e9c358ce0ffffbafc990905c1343aa265b31/tomli-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:69491c143d2fe063046e0301e62a810bed338fa4d1ce0fd870c27dc1e09b0d84", size = 109744 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/84/50/ff94454e75461d75623e47401ed323d65c10aab8fe9033242c20cd2fdf32/tomli-2.5.0-cp314-cp314-win32.whl", hash = "sha256:6f041843c4d3a37245c0c056fd955b186bf8b1fb85690cbe40b81230891dc34b", size = 103535 }, + { url = "https://files.pythonhosted.org/packages/54/0b/bdacf05f963bd6026ebf6eeb0beda847d1d60e03e440725c64a4e08a0afd/tomli-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:f4b653094e18f9031102d3a1da5c729c8f222d85225b18037dac621695e46e1a", size = 114621 }, + { url = "https://files.pythonhosted.org/packages/61/99/53f438fa6ae4f9d4ed0ddde3e7242b3bdc34b48c8f9948b72b9e9b127676/tomli-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3f89d10c1ff6a38d992c27fc8a4816af71a909e08a40ec66934240b1e74347c3", size = 111572 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/ab/c7/18f8baae0b5607a60e8e19b4a7fedee43a8ff6458e3896dcbbadeeac9c22/tomli-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:91294a9fb94a75542f6e46e4a2ae709bd8d9b51134098cae5cf3bea5478b6d03", size = 108546 }, + { url = "https://files.pythonhosted.org/packages/72/34/4cca9739254130627bde87500b3f2b512154fe2f278efa7e2a5e10ad4bcb/tomli-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f15e3e0b835a6d68b10c86bf80a3149780498d6911c93c3ffd1861d19f9200f1", size = 117814 }, + { url = "https://files.pythonhosted.org/packages/7d/fb/afa530d47dd80a78fce43beac6bc6e00f84558eafcffbc6f37b21e80d056/tomli-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6664b7ae7af7294256c53960a6103077f4914cec8ff98479c352f622c6f6b2f0", size = 115188 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/1a/b9/e1dab9a30bcb677b5cc5cee810609cfd64f24306a3055767dd3fda00b1e0/tomli-2.5.0-cp315-cp315-win32.whl", hash = "sha256:667e521b37a6c5ccaa044202c235b530f90177ffe2cd4a64ecc213c7dd535feb", size = 103475 }, + { url = "https://files.pythonhosted.org/packages/4c/bd/31a3790c11d6ea95fcf5e6022ac0f8d0543c9b61120b730fc481bd43d3b4/tomli-2.5.0-cp315-cp315-win_amd64.whl", hash = "sha256:d747252933c8a65ef6bd8da0fbb7ce28a90eb6119d8cd00772cd528aa07b68d5", size = 114589 }, + { url = "https://files.pythonhosted.org/packages/47/a2/4f6310fa699364f0e3af7ee3af88dddd9af066d33e716a0265bbe2b3ea84/tomli-2.5.0-cp315-cp315-win_arm64.whl", hash = "sha256:75dbcde8751b0a960aa3de173aa5e894d590755c6d7758b7e774c06f1dc3cbdd", size = 111493 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { 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 }, + { url = "https://files.pythonhosted.org/packages/05/74/590e7d19d6a118fc5cc5704ff358e21d95b8573f6b9443b1519f29ca8825/tomli-2.5.0-cp315-cp315t-win32.whl", hash = "sha256:62fc1bc8eb03e3a9cadfca713d65614ed8e09d974a283295ffe3a831976b4dc5", size = 108432 }, + { url = "https://files.pythonhosted.org/packages/1c/b8/63a75cfb27a17c38550e44025d3a6e7be64516fd8608a3b75703bf37d81b/tomli-2.5.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f3fcbc57b1791fa6cbe5d8434179d51de12be1a4811469529f47f6e7487a2571", size = 117281 }, + { url = "https://files.pythonhosted.org/packages/72/01/e8c1debb2173973372934c68fc8e46170ab60ef23ed4592dff4dec6e8993/tomli-2.5.0-cp315-cp315t-win_arm64.whl", hash = "sha256:d2ba24db8a9376921b5e87b4762b9adb0f3f1deaea68f2b8b0bb2c11efb9c3e7", size = 115069 }, + { url = "https://files.pythonhosted.org/packages/60/3f/3e3f8fd0919249b0200c80fbc4f9a1e70be19f9883da71dfb7f8b9ab8aca/tomli-2.5.0-py3-none-any.whl", hash = "sha256:32a7b79ac57a2e83670ce329ccf675798bc5a2094783a63676866b70503f2e2b", size = 14765 }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -2336,33 +2100,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/38/0c/b54a4fdd7f90a3af8b02ebc9ce6712c2c208b7926a2f7bad95c33ebbe943/uvicorn-0.54.0-py3-none-any.whl", hash = "sha256:505bdb0f318731d45f1f712071fc781a8981f6847a31c902c9f5e652d4f67faf", size = 87427 }, ] -[[package]] -name = "watchdog" -version = "6.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220 } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393 }, - { url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392 }, - { url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019 }, - { url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471 }, - { url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449 }, - { url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054 }, - { url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480 }, - { url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451 }, - { url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057 }, - { url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079 }, - { url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076 }, - { url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077 }, - { url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077 }, - { url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078 }, - { url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065 }, - { url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070 }, - { url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067 }, -] - [[package]] name = "werkzeug" version = "3.1.9" @@ -2386,3 +2123,31 @@ sdist = { url = "https://files.pythonhosted.org/packages/c7/79/12135bdf8b9c9367b wheels = [ { url = "https://files.pythonhosted.org/packages/a4/f5/10b68b7b1544245097b2a1b8238f66f2fc6dcaeb24ba5d917f52bd2eed4f/wsproto-1.3.2-py3-none-any.whl", hash = "sha256:61eea322cdf56e8cc904bd3ad7573359a242ba65688716b0710a5eb12beab584", size = 24405 }, ] + +[[package]] +name = "zensical" +version = "0.0.70" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "deepmerge" }, + { name = "jinja2" }, + { name = "markdown" }, + { name = "pathspec" }, + { name = "pygments" }, + { name = "pymdown-extensions" }, + { name = "pyyaml" }, + { name = "tomli" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7f/4e/a21d2d91b49766fc64be0d9e9a67782f5bfa0710ce76ae464dbec69d0649/zensical-0.0.70.tar.gz", hash = "sha256:ed5fb8c6ee439ae8d1a27ba606e10c651918499184a50aa135f1d5d83a54c4db", size = 4319716 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/50/ee/5d4fbe0d24bba9e797b7c4e636184b48cf5eee0d5598ee15fa5231515225/zensical-0.0.70-cp311-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2b16dfbc12d2cdf1ab3ef52984831c9db5d202b0e508329de383f0aace277d81", size = 18127509 }, + { url = "https://files.pythonhosted.org/packages/fb/b0/61bb824448493c5db9a5dacf050326e946f91a3dbe704cfa6953d3639660/zensical-0.0.70-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:e9579ada6e14e659b86833d6a1723fa5f947b10ec66e65fc059b05dc8068ec8b", size = 17660685 }, + { url = "https://files.pythonhosted.org/packages/f1/bc/0d9959bf065b8187aaddd42b237e7864d57fc1f30df5b8cf07b2c5284721/zensical-0.0.70-cp311-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9b187d69ce1563bc24c248b93da468c7bc11b8cc269f231505bff0e45e2e2e32", size = 18166247 }, + { url = "https://files.pythonhosted.org/packages/ca/a9/6f95078f2c16ca014caec8ee986d34ec1f137441c13ec6eb0153cf5033e7/zensical-0.0.70-cp311-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:78397d29a2bc2b69b2d46f1f39f64b197e204c0403f343da339c3d73ea692b34", size = 18100648 }, + { url = "https://files.pythonhosted.org/packages/02/fe/ceaeaa19b8caed3978c2d3a65ab97b6ba34659200b20dded0da91b7e3138/zensical-0.0.70-cp311-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:eec6d8b92a7e00986d8c62d813cf37371ec03a3d5e11383363c979ca98ef0d14", size = 18642573 }, + { url = "https://files.pythonhosted.org/packages/7a/2f/3ba313d98d7aa8b13ce8e1730fdf571ff1bacfe7e1c883870b9a384d0d15/zensical-0.0.70-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:d11e08cf0a0e0572aa3cc6adc17f88a691f0c6211f45ea386f5483b79019cf2b", size = 18333292 }, + { url = "https://files.pythonhosted.org/packages/66/25/982a0ccf753e733ab79a889b39491b93c7be2db7479e659f9398dc387dbb/zensical-0.0.70-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:78fe80274362b800d1b4d9d5cbdb3c7459206c48cfbac0f7eea0833ae8c41c1e", size = 18899896 }, + { url = "https://files.pythonhosted.org/packages/43/e5/0c5784851cc179b7ff33810047d2654208bee99997e74205646eed06f602/zensical-0.0.70-cp311-abi3-win_amd64.whl", hash = "sha256:c892d5306eb287a0cccc2f6e1c1206bb5eb7e8d4017a6dea329643de65af81fe", size = 18831199 }, + { url = "https://files.pythonhosted.org/packages/3d/96/09d43fea6eabcf55ae13338f657c08a232d6c6c40e1f7fb3d91723a2eddf/zensical-0.0.70-cp311-abi3-win_arm64.whl", hash = "sha256:d072895c7c4b09a95cc33de259bd540a6891497c79df6f8aefc24c25ad604d5f", size = 18247622 }, +] diff --git a/zensical.toml b/zensical.toml new file mode 100644 index 0000000..cd76dfa --- /dev/null +++ b/zensical.toml @@ -0,0 +1,94 @@ +[project] +site_name = "mongospecs" +site_url = "https://jaykv.github.io/mongospecs/" +repo_url = "https://github.com/jaykv/mongospecs" +edit_uri = "edit/main/docs/" + +nav = [ + { "Home" = "index.md" }, + { "Getting started" = "tutorial.md" }, + { "Guide" = [ + { "Models" = "guide/models.md" }, + { "Choosing a backend" = "guide/backends.md" }, + { "Queries" = "guide/queries.md" }, + { "Writes and transactions" = "guide/writes.md" }, + { "Relations" = "guide/relations.md" }, + { "Hooks and plugins" = "guide/plugins.md" }, + { "Errors" = "guide/errors.md" }, + { "Running in production" = "guide/operations.md" }, + { "Tuning for production" = "guide/production.md" }, + ] }, + { "Web" = [ + { "Resources" = "web/resources.md" }, + { "Authentication and authorization" = "web/auth.md" }, + { "Testing your API" = "web/testing.md" }, + { "Plugs" = "web/plugs.md" }, + ] }, + { "Benchmarks" = "benchmarks.md" }, + { "Coming from Mongoose" = "parity.md" }, + { "Migrating from 0.1.x" = [ + { "Guide" = "migration.md" }, + { "v1 test inventory" = "v1-test-inventory.md" }, + ] }, +] + +[project.theme] +language = "en" +features = [ + "announce.dismiss", + "content.action.edit", + "content.action.view", + "content.code.annotate", + "content.code.copy", + "content.code.select", + "content.footnote.tooltips", + "content.tabs.link", + "content.tooltips", + "navigation.footer", + "navigation.indexes", + "navigation.instant", + "navigation.instant.prefetch", + "navigation.path", + "navigation.sections", + "navigation.tabs", + "navigation.tabs.sticky", + "navigation.top", + "navigation.tracking", + "search.highlight", + "toc.follow", +] + +[[project.theme.palette]] +media = "(prefers-color-scheme)" +toggle.icon = "lucide/sun-moon" +toggle.name = "Switch to light mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: light)" +scheme = "default" +toggle.icon = "lucide/sun" +toggle.name = "Switch to dark mode" + +[[project.theme.palette]] +media = "(prefers-color-scheme: dark)" +scheme = "slate" +toggle.icon = "lucide/moon" +toggle.name = "Switch to system preference" + +[project.markdown_extensions] +abbr = {} +admonition = {} +attr_list = {} +footnotes = {} +md_in_html = {} +toc.permalink = true +pymdownx.details = {} +pymdownx.emoji.emoji_generator = "zensical.extensions.emoji.to_svg" +pymdownx.emoji.emoji_index = "zensical.extensions.emoji.twemoji" +pymdownx.magiclink = {} +pymdownx.snippets.base_path = ["."] +pymdownx.snippets.check_paths = true +pymdownx.superfences = {} +pymdownx.tabbed.alternate_style = true +pymdownx.tabbed.combine_header_slug = true +pymdownx.tasklist.custom_checkbox = true