diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 29d8332..d7a922a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -170,6 +170,15 @@ jobs: echo "::error::@mockable/test indirection found in production code." exit 1 fi + - name: JSON schemas match their contracts (blocking) + # docs/schemas/*.json is generated from bambu_cli/contracts/. This is + # the anti-drift gate: edit a payload, regenerate, commit both. + # + # Pinned to 3.12 on purpose. The generator needs 3.10+ to evaluate the + # contracts' `X | None` annotations, and pydantic is only installed + # above that floor (see the marker in pyproject). The package itself + # still runs on 3.9 — that leg is covered by the test matrix. + run: uv run --python 3.12 --with pydantic python scripts/gen_schemas.py --check - name: layer boundaries (blocking) # Directories alone never held: protocols/, slicer/ and download/ already # existed as packages and still drifted (slicer imported a private FTPS diff --git a/AGENTS.md b/AGENTS.md index 315edcd..8d8218d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,6 +46,7 @@ Logic lives in focused packages; `bambu_cli/bambu.py` is a **thin entrypoint** ( | `commands/` | Printer subcommand handlers (`status`, `device`, `files`, `print_cmd`, `doctor`, `gcode`, thin `setup_wrappers`) | | `download/` | URL/filename validation, HTML scraping, ZIP extraction, `download` command | | `printables/` | Printables.com integration behind a strict adapter. `client.py` (the undocumented GraphQL wire format) is **sealed** — import only from `bambu_cli.printables`. `adapter.py` guarantees no Printables failure escapes as an exception | +| `contracts/` | Typed `--json` payload shapes (frozen dataclasses). **Generates `docs/schemas/*.json`** via `scripts/gen_schemas.py`; do not hand-edit a schema | | `job/` | One-shot `job`/`send` orchestration, dry-run predict, print payloads, injectable `JobSteps` | | `setup_cmd/` | Guided/non-interactive setup, mDNS, config show/validate, preflight | | `slicer/` | OrcaSlicer integration | @@ -79,6 +80,17 @@ Accepted debt lives in `ALLOWED` in that script, each entry with a reason. Shrin The same script also enforces `SEALED` — package internals no outside module may import. `bambu_cli.printables.client` is sealed because an adapter is only a sandbox if callers cannot reach past it. **Third-party integrations go behind an adapter that cannot raise:** `PrintablesAdapter.resolve()` returns a `PrintablesResolution` for every outcome, converting a renamed field or a redesigned error envelope into a typed `printables_contract_changed` result instead of a traceback in the middle of `plate job`. `KeyboardInterrupt`/`SystemExit` are deliberately the only things that still propagate. +**JSON schemas are generated, never hand-written.** `docs/schemas/*.json` comes from the dataclasses in `bambu_cli/contracts/`: + +```bash +python scripts/gen_schemas.py # regenerate after changing a payload +python scripts/gen_schemas.py --check # what CI runs (blocking) +``` + +Editing a schema by hand will be overwritten and will red CI. Change the model, regenerate, commit both. The gate fails in *both* directions — a stale schema, and a schema with no contract behind it. + +**Pydantic is a dev/build dependency only** (`[test]` extra, `python_version >= '3.10'`). `bambu_cli` never imports it, and a test asserts that. Serialization stays in `emit_json`, because that pass applies the credential redaction a `model_dump_json()` would bypass. The contracts annotate optionals as `X | None`, which only *evaluates* on 3.10+ — safe because nothing at runtime resolves those annotations (also asserted by a test). Only the generator does, and it refuses to run below 3.10 with an explanatory message. + **Package inventory is derived:** setuptools finds `bambu_cli*`; syntax smoke and CLI help smoke auto-discover modules/commands (`scripts/syntax_smoke.py`, `scripts/cli_help_smoke.py`). Adding a module under `bambu_cli/` or a subcommand in `cli.py` is enough — no triplicated lists. **Typing (mypy):** CI runs `uvx mypy@ -p bambu_cli` over the **whole package** with `check_untyped_defs = true` (CI pins the tool version in `.github/workflows/ci.yml`; running it unpinned locally is fine). There is **no residual exclude blocklist** — `printer.py` and `slicer/` are included. New modules are type-checked automatically. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c8195f4..499cdb9 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -117,8 +117,9 @@ SECURITY.md. `get_printer()` / `RuntimeContext` — do not grow `bambu_cli/bambu.py` beyond the thin entrypoint. - Prefer dependency injection over patching module globals (see `download/` for the pattern). - JSON success and error payloads: assert full shapes (`status`, `command`, `failed_step`, - `exit_code`, `next_command` where applicable); add or extend a schema under `docs/schemas/` - when introducing agent-facing fields. + `exit_code`, `next_command` where applicable). When introducing agent-facing fields, edit the + dataclass in `bambu_cli/contracts/` and run `python scripts/gen_schemas.py` — **never hand-edit + `docs/schemas/*.json`**, it is generated and CI diffs it. - Follow `docs/quality-roadmap.md` and `docs/test-backlog.md` when adding tests. - Do not add Claude-Session or similar trailers to commits or PRs. diff --git a/bambu_cli/cli.py b/bambu_cli/cli.py index d5d9ca0..26bf17c 100644 --- a/bambu_cli/cli.py +++ b/bambu_cli/cli.py @@ -13,11 +13,6 @@ from .argutils import exit_code_from_system_exit as _exit_code_from_system_exit from .argutils import namespace_get as _namespace_get from .argutils import setup_args_provided as _setup_args_provided - -# The argparse tree lives in bambu_cli.cliparse so domain code can build a -# namespace without importing this entrypoint (audit item A1). Re-exported here -# because build_parser() is the documented source of truth for the command set, -# and the help/workflow smokes plus several tests import it from this module. from .cliparse import ( # noqa: F401 JsonArgumentParser, _add_job_arguments, @@ -35,6 +30,12 @@ EXIT_SUCCESS, PRINTER_NETWORK_COMMANDS, ) + +# The argparse tree lives in bambu_cli.cliparse so domain code can build a +# namespace without importing this entrypoint (audit item A1). Re-exported here +# because build_parser() is the documented source of truth for the command set, +# and the help/workflow smokes plus several tests import it from this module. +from .contracts import Version from .jsonio import json_mode_requested as _json_mode_requested from .utils import emit_json, emit_json_error @@ -143,13 +144,7 @@ def main(): args = parser.parse_args() if getattr(args, "version", False): if bool(getattr(args, "json", False)): - emit_json( - { - "status": "ok", - "command": "version", - "version": VERSION, - } - ) + emit_json(Version(status="ok", command="version", version=VERSION)) else: print(f"plate {VERSION}") return diff --git a/bambu_cli/commands/device.py b/bambu_cli/commands/device.py index 5c1bc39..9b0f266 100644 --- a/bambu_cli/commands/device.py +++ b/bambu_cli/commands/device.py @@ -5,6 +5,7 @@ from bambu_cli.argutils import namespace_get as _namespace_get from bambu_cli.constants import EXIT_COMMAND_ERROR, EXIT_NETWORK_ERROR from bambu_cli.context import RuntimeContext +from bambu_cli.contracts import Light, Pause, Resume, Stop from bambu_cli.errors import abort from bambu_cli.logging_utils import logger, safe_log_error from bambu_cli.utils import emit_json, emit_json_error, get_sequence_id @@ -36,14 +37,7 @@ def cmd_light(args, ctx=None): abort("", exit_code=EXIT_NETWORK_ERROR) logger.info(f"💡 Light turned {action}") if bool(_namespace_get(args, "json", False)): - emit_json( - { - "status": "light_changed", - "command": "light", - "action": action, - "changed": True, - } - ) + emit_json(Light(status="light_changed", command="light", action=action, changed=True)) def cmd_pause(args, ctx=None): @@ -54,12 +48,12 @@ def cmd_pause(args, ctx=None): logger.warning("⚠️ This will PAUSE the current print. Add --confirm to proceed.") if bool(_namespace_get(args, "json", False)): emit_json( - { - "status": "confirmation_required", - "command": "pause", - "paused": False, - "next_command": ["pause", "--confirm", "--json"], - } + Pause( + status="confirmation_required", + command="pause", + paused=False, + next_command=["pause", "--confirm", "--json"], + ) ) abort("", exit_code=EXIT_COMMAND_ERROR) payload = json.dumps({"print": {"sequence_id": get_sequence_id(), "command": "pause"}}) @@ -71,13 +65,7 @@ def cmd_pause(args, ctx=None): abort("", exit_code=EXIT_NETWORK_ERROR) logger.info("⏸️ Print paused") if bool(_namespace_get(args, "json", False)): - emit_json( - { - "status": "paused", - "command": "pause", - "paused": True, - } - ) + emit_json(Pause(status="paused", command="pause", paused=True)) def cmd_resume(args, ctx=None): @@ -88,12 +76,12 @@ def cmd_resume(args, ctx=None): logger.warning("⚠️ This will RESUME the paused print. Add --confirm to proceed.") if bool(_namespace_get(args, "json", False)): emit_json( - { - "status": "confirmation_required", - "command": "resume", - "resumed": False, - "next_command": ["resume", "--confirm", "--json"], - } + Resume( + status="confirmation_required", + command="resume", + resumed=False, + next_command=["resume", "--confirm", "--json"], + ) ) abort("", exit_code=EXIT_COMMAND_ERROR) payload = json.dumps({"print": {"sequence_id": get_sequence_id(), "command": "resume"}}) @@ -105,13 +93,7 @@ def cmd_resume(args, ctx=None): abort("", exit_code=EXIT_NETWORK_ERROR) logger.info("▶️ Print resumed") if bool(_namespace_get(args, "json", False)): - emit_json( - { - "status": "resumed", - "command": "resume", - "resumed": True, - } - ) + emit_json(Resume(status="resumed", command="resume", resumed=True)) def cmd_stop(args, ctx=None): @@ -122,12 +104,12 @@ def cmd_stop(args, ctx=None): logger.warning("⚠️ This will STOP the current print. Add --confirm to proceed.") if bool(_namespace_get(args, "json", False)): emit_json( - { - "status": "confirmation_required", - "command": "stop", - "stopped": False, - "next_command": ["stop", "--confirm", "--json"], - } + Stop( + status="confirmation_required", + command="stop", + stopped=False, + next_command=["stop", "--confirm", "--json"], + ) ) abort("", exit_code=EXIT_COMMAND_ERROR) payload = json.dumps({"print": {"sequence_id": get_sequence_id(), "command": "stop"}}) @@ -139,10 +121,4 @@ def cmd_stop(args, ctx=None): abort("", exit_code=EXIT_NETWORK_ERROR) logger.info("⏹️ Print stopped") if bool(_namespace_get(args, "json", False)): - emit_json( - { - "status": "stopped", - "command": "stop", - "stopped": True, - } - ) + emit_json(Stop(status="stopped", command="stop", stopped=True)) diff --git a/bambu_cli/contracts/__init__.py b/bambu_cli/contracts/__init__.py new file mode 100644 index 0000000..4c21691 --- /dev/null +++ b/bambu_cli/contracts/__init__.py @@ -0,0 +1,89 @@ +"""Typed contracts for every ``--json`` payload platecli emits. + +``docs/schemas/*.json`` is generated from these models by +``scripts/gen_schemas.py``; CI regenerates and diffs, so a schema can never +drift from the code that produces it. Change a payload here, regenerate, commit +both. + +Serialization stays in ``bambu_cli.utils.emit_json`` — it applies credential +redaction and home-directory compaction to every emitted string, and nothing +here may bypass it. See ``base.py`` for why these are stdlib dataclasses rather +than pydantic models. +""" + +from bambu_cli.contracts.base import Contract, all_contracts +from bambu_cli.contracts.models import ( + AmsState, + AmsTray, + AmsUnit, + ConfigCmd, + Delete, + Doctor, + Download, + ErrorEnvelope, + FilamentSettings, + Files, + Gcode, + Go, + JobError, + JobOk, + Light, + OkEnvelope, + Pause, + Preflight, + PreflightCheck, + Print, + PrinterState, + ProcessSettings, + RemoteFile, + Resume, + Setup, + Slice, + SliceListSettings, + Snapshot, + Status, + StatusEvent, + Stop, + Tui, + Upload, + Version, +) + +__all__ = [ + "AmsState", + "AmsTray", + "AmsUnit", + "ConfigCmd", + "Contract", + "Delete", + "Doctor", + "Download", + "ErrorEnvelope", + "FilamentSettings", + "Files", + "Gcode", + "Go", + "JobError", + "JobOk", + "Light", + "OkEnvelope", + "Pause", + "Preflight", + "PreflightCheck", + "Print", + "PrinterState", + "ProcessSettings", + "RemoteFile", + "Resume", + "Setup", + "Slice", + "SliceListSettings", + "Snapshot", + "Status", + "StatusEvent", + "Stop", + "Tui", + "Upload", + "Version", + "all_contracts", +] diff --git a/bambu_cli/contracts/base.py b/bambu_cli/contracts/base.py new file mode 100644 index 0000000..aebacd3 --- /dev/null +++ b/bambu_cli/contracts/base.py @@ -0,0 +1,135 @@ +"""Base machinery for command-output contracts. + +A contract is a frozen dataclass describing the JSON one command emits. It is +the single source of truth: ``docs/schemas/*.json`` is *generated* from these +(``scripts/gen_schemas.py``), and CI fails if the committed schemas drift from +what the models say. There is no hand-maintained schema any more. + +Two deliberate choices: + +**Plain dataclasses, not pydantic models.** ``bambu_cli.utils.emit_json`` +already owns serialization, and that pass is security-critical — it redacts URL +credentials and compacts home directories on *every* emitted string. A pydantic +``.model_dump_json()`` would bypass it. Pydantic is a **dev-only** dependency +used by the generator to derive JSON Schema from these dataclasses; it is never +imported at runtime and never ships to users. + +**Annotations are never evaluated at runtime.** They use ``X | None`` (PEP 604), +which only *evaluates* on Python 3.10+. ``from __future__ import annotations`` +keeps them as strings, and everything here reads fields via +``dataclasses.fields()`` rather than ``typing.get_type_hints()``, so the package +imports and works fine on the 3.9 floor. Only the generator resolves them, and +it requires 3.10+. +""" + +from __future__ import annotations + +import dataclasses +from typing import Any, ClassVar + +_UNSET = object() + + +def spec( + *, + default=_UNSET, + default_factory=_UNSET, + required: bool = False, + min_length: int | None = None, + minimum: int | None = None, + description: str | None = None, + requires_keys: tuple[str, ...] | None = None, +): + """Declare a contract field with its published JSON Schema constraints. + + The constraints live on the field so the generator can derive the schema + from the model alone — a separate constraints table would be exactly the + hand-maintained parallel list this refactor is removing. + + ``required=True`` marks a field contractually required even though it + carries a Python default. That combination is unavoidable: dataclasses + force defaulted fields last, but the published key order is part of the + contract and several required keys follow optional ones. + + ``requires_keys`` is the nested ``required`` list for an object-typed field + (e.g. ``status.printer`` guarantees gcode_state/mc_percent/…). + """ + metadata: dict[str, Any] = {} + if required: + metadata["contract_required"] = True + if min_length is not None: + metadata["min_length"] = min_length + if minimum is not None: + metadata["minimum"] = minimum + if description: + metadata["description"] = description + if requires_keys: + metadata["requires_keys"] = tuple(requires_keys) + + kwargs: dict[str, Any] = {"metadata": metadata} + if default_factory is not _UNSET: + kwargs["default_factory"] = default_factory + elif default is not _UNSET: + kwargs["default"] = default + return dataclasses.field(**kwargs) + + +@dataclasses.dataclass(frozen=True) +class Contract: + """Base for a command's ``--json`` output. + + Subclasses declare their fields and set the class vars below. Optional + fields default to ``None`` and are dropped from the payload unless listed in + ``keep_none`` — matching what each command emits today, where an + inapplicable key is usually absent rather than null. + """ + + #: Basename (without .json) under docs/schemas/. + schema_name: ClassVar[str] = "" + #: Human title written into the generated schema. + schema_title: ClassVar[str] = "" + #: Optional prose written into the generated schema's top-level description. + schema_description: ClassVar[str] = "" + #: Whether unknown keys are allowed. Every published schema but `version` + #: says yes: they describe the *guaranteed* keys, and commands are free to + #: add detail. Tightening this would silently break existing consumers. + additional_properties: ClassVar[bool] = True + #: Fields emitted as ``null`` rather than omitted when unset. + keep_none: ClassVar[frozenset[str]] = frozenset() + + def to_payload(self, **extra: Any) -> dict[str, Any]: + """Render to the dict ``emit_json`` takes. + + ``extra`` carries command-specific keys that are not part of the + guaranteed contract — legal because the schemas allow additional + properties. Redaction still happens downstream in ``emit_json``; this + method deliberately does no escaping or scrubbing of its own. + """ + payload: dict[str, Any] = {} + for field in dataclasses.fields(self): + value = getattr(self, field.name) + if value is None and field.name not in self.keep_none: + continue + payload[field.name] = value + for key, value in extra.items(): + if value is not None or key in self.keep_none: + payload[key] = value + return payload + + +def all_contracts() -> list[type[Contract]]: + """Every concrete contract, discovered from the registry module. + + Derived rather than hand-listed, for the same reason the package inventory + and CLI help coverage are derived (see AGENTS.md): a parallel list drifts. + """ + from bambu_cli.contracts import models + + found: list[type[Contract]] = [] + for name in dir(models): + obj = getattr(models, name) + # `schema_name` is empty on the base and on any abstract helper, so it + # doubles as the "is this actually published?" test. + if isinstance(obj, type) and issubclass(obj, Contract) and obj is not Contract and obj.schema_name: + found.append(obj) + return sorted(found, key=lambda c: c.schema_name) diff --git a/bambu_cli/contracts/models.py b/bambu_cli/contracts/models.py new file mode 100644 index 0000000..7f579f3 --- /dev/null +++ b/bambu_cli/contracts/models.py @@ -0,0 +1,558 @@ +"""The published ``--json`` contracts, one dataclass per schema. + +These generate ``docs/schemas/*.json``. Edit a model, run +``python scripts/gen_schemas.py``, commit both — CI fails if they disagree. + +Field order here is the key order in the emitted payload, so keep ``status`` +and ``command`` first: agents pattern-match on those. + +``Literal`` pins a value the command always emits (it becomes ``const`` in the +schema, which is what lets a consumer dispatch on ``status``). A field typed +``X | None`` with a ``None`` default is optional and omitted when unset unless +it is named in ``keep_none``. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, ClassVar, Literal + +from bambu_cli.contracts.base import Contract, spec + +# --------------------------------------------------------------------------- +# Nested structures. These are not published on their own; they are inlined +# into the parent schema so each file stays self-contained. +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class RemoteFile: + """One entry in the `files` listing.""" + + name: str + path: str + + +@dataclass(frozen=True) +class PreflightCheck: + """One `preflight` result row.""" + + status: str + name: str + message: str + detail: dict[str, Any] | None = None + + +@dataclass(frozen=True) +class ProcessSettings: + """The `slice --list-settings` process group: key count and an example of each.""" + + count: int + settings: dict[str, Any] = spec( + required=True, + default_factory=dict, + description="Map of process setting key to a representative/example value.", + ) + + +@dataclass(frozen=True) +class FilamentSettings: + """The `slice --list-settings` filament group: key count and an example of each.""" + + count: int + settings: dict[str, Any] = spec( + required=True, + default_factory=dict, + description="Map of filament setting key to a representative/example value.", + ) + + +@dataclass(frozen=True) +class AmsTray: + slot: float | None = None + active: bool | None = None + empty: bool | None = None + + +@dataclass(frozen=True) +class AmsUnit: + id: float | None = None + humidity: float | None = None + temp: float | None = None + trays: list[AmsTray] | None = None + + +@dataclass(frozen=True) +class AmsState: + units: list[AmsUnit] | None = None + + +@dataclass(frozen=True) +class PrinterState: + """Normalised printer state under `status.printer`.""" + + gcode_state: str | None = None + mc_percent: float | None = None + bed_temper: float | None = None + bed_target_temper: float | None = None + nozzle_temper: float | None = None + nozzle_target_temper: float | None = None + cooling_fan_speed: float | None = None + wifi_signal: str | None = None + sw_ver: str | None = None + hw_ver: str | None = None + ams: AmsState | None = spec(default=None, description="Normalised AMS state (present when AMS is attached).") + + +# --------------------------------------------------------------------------- +# Shared envelopes +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class OkEnvelope(Contract): + """Minimum shape every successful command shares.""" + + schema_name: ClassVar[str] = "ok_envelope" + schema_title: ClassVar[str] = "platecli JSON ok envelope" + + status: str + command: str = spec(required=True, min_length=1) + + +@dataclass(frozen=True) +class ErrorEnvelope(Contract): + """Minimum shape every failed command shares. + + ``next_command`` is the recovery hint agents follow; ``detail`` carries the + failing sub-command's own error payload when a pipeline stage failed. + """ + + schema_name: ClassVar[str] = "error_envelope" + schema_title: ClassVar[str] = "platecli JSON error envelope" + # `job`/`send` build their summary up front and emit `next_command: null` + # when there is no recovery step, so null is a real value here. The old + # hand-written schema typed this `{}` (anything), which hid that; the + # generated one is explicit. + keep_none: ClassVar[frozenset[str]] = frozenset({"next_command"}) + + status: Literal["error"] + command: str = spec(required=True, min_length=1) + exit_code: int = spec(required=True) + error: str = spec(required=True) + failed_step: str | None = None + printer_error_code: int | None = None + printer_error_code_hex: str | None = None + next_command: list[str] | None = None + detail: dict[str, Any] | None = None + + +# --------------------------------------------------------------------------- +# Printer commands +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Status(Contract): + schema_name: ClassVar[str] = "status" + schema_title: ClassVar[str] = "platecli status success envelope" + schema_description: ClassVar[str] = ( + "JSON output of `plate status --json`. Printer fields appear both at the top level (raw MQTT data) and normalised under the `printer` key." + ) + + status: Literal["ok"] + command: Literal["status"] + printer: PrinterState | None = spec( + default=None, + required=True, + description=( + "Complete printer state. Merged from the MQTT report topic and guaranteed to be a full " + "snapshot, never a partial delta; the command fails with exit code 6 rather than emitting " + "an incomplete object." + ), + requires_keys=("gcode_state", "mc_percent", "bed_temper", "nozzle_temper"), + ) + + +@dataclass(frozen=True) +class StatusEvent(Contract): + """One NDJSON line from ``status --monitor --json``.""" + + schema_name: ClassVar[str] = "status_event" + schema_title: ClassVar[str] = "platecli status --monitor NDJSON event" + + event: Literal["update", "terminal"] + command: Literal["status"] + gcode_state: str + mc_percent: int + + +@dataclass(frozen=True) +class Light(Contract): + schema_name: ClassVar[str] = "light" + schema_title: ClassVar[str] = "platecli light success envelope" + + status: Literal["light_changed"] + command: Literal["light"] + action: Literal["on", "off"] + changed: bool + + +@dataclass(frozen=True) +class Pause(Contract): + schema_name: ClassVar[str] = "pause" + schema_title: ClassVar[str] = "platecli pause success or confirmation envelope" + + status: Literal["paused", "confirmation_required"] + command: Literal["pause"] + paused: bool + next_command: list[str] | None = None + + +@dataclass(frozen=True) +class Resume(Contract): + schema_name: ClassVar[str] = "resume" + schema_title: ClassVar[str] = "platecli resume success or confirmation envelope" + + status: Literal["resumed", "confirmation_required"] + command: Literal["resume"] + resumed: bool + next_command: list[str] | None = None + + +@dataclass(frozen=True) +class Stop(Contract): + schema_name: ClassVar[str] = "stop" + schema_title: ClassVar[str] = "platecli stop success or confirmation envelope" + + status: Literal["stopped", "confirmation_required"] + command: Literal["stop"] + stopped: bool + next_command: list[str] | None = None + + +@dataclass(frozen=True) +class Gcode(Contract): + schema_name: ClassVar[str] = "gcode" + schema_title: ClassVar[str] = "platecli gcode success or confirmation envelope" + + status: str + command: Literal["gcode"] + gcode: str + sent: bool + next_command: list[str] | None = None + + +@dataclass(frozen=True) +class Files(Contract): + schema_name: ClassVar[str] = "files" + schema_title: ClassVar[str] = "platecli files listing envelope" + + status: Literal["ok"] + command: Literal["files"] + count: int = spec(required=True, minimum=0) + files: list[RemoteFile] = spec(required=True, default_factory=list) + + +@dataclass(frozen=True) +class Delete(Contract): + schema_name: ClassVar[str] = "delete" + schema_title: ClassVar[str] = "platecli delete success or confirmation envelope" + + status: str + command: Literal["delete"] + file: str + deleted: bool + next_command: list[str] | None = None + + +@dataclass(frozen=True) +class Upload(Contract): + schema_name: ClassVar[str] = "upload" + schema_title: ClassVar[str] = "platecli upload success or dry-run envelope" + + status: Literal["uploaded", "dry_run_ok"] + command: Literal["upload"] + file: str + remote_name: str + bytes: int = spec(required=True, minimum=0) + uploaded: bool = spec(required=True) + + +@dataclass(frozen=True) +class Print(Contract): + schema_name: ClassVar[str] = "print" + schema_title: ClassVar[str] = "platecli print success or confirmation envelope" + + status: str + command: Literal["print"] + file: str + printed: bool | None = None + dry_run: bool | None = None + next_command: list[str] | None = None + + +@dataclass(frozen=True) +class Snapshot(Contract): + schema_name: ClassVar[str] = "snapshot" + schema_title: ClassVar[str] = "platecli snapshot success envelope" + + status: Literal["saved"] + command: Literal["snapshot"] + output: str = spec(required=True, min_length=1) + size_bytes: int = spec(required=True) + captured_at: str = spec( + required=True, + description="ISO-8601 UTC timestamp of capture (e.g. 2026-07-24T19:15:30Z)", + ) + sha256: str = spec( + required=True, + description="Hex SHA-256 digest of the captured JPEG bytes; use to verify a capture is new", + ) + method: str | None = None + camera_image: str | None = None + docker_container: str | None = None + + +# --------------------------------------------------------------------------- +# Local commands +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Download(Contract): + schema_name: ClassVar[str] = "download" + schema_title: ClassVar[str] = "platecli download success envelope" + keep_none: ClassVar[frozenset[str]] = frozenset({"normalized_source"}) + + status: Literal["downloaded"] + command: Literal["download"] + source: str + normalized_source: str | None = None + download_url: str = spec(required=True, default="") + path: str = spec(required=True, min_length=1, default="") + filename: str = spec(required=True, min_length=1, default="") + bytes: int = spec(required=True, default=0) + archive_entry: str | None = None + + +@dataclass(frozen=True) +class Slice(Contract): + schema_name: ClassVar[str] = "slice" + schema_title: ClassVar[str] = "platecli slice success envelope" + + status: Literal["sliced"] + command: Literal["slice"] + file: str = spec(required=True, min_length=1) + path: str = spec(required=True, min_length=1) + filename: str = spec(required=True, min_length=1) + bytes: int = spec(required=True) + step_converted: bool = spec(required=True) + + +@dataclass(frozen=True) +class SliceListSettings(Contract): + """``slice --list-settings``: the full OrcaSlicer key surface.""" + + schema_name: ClassVar[str] = "slice_list_settings" + schema_title: ClassVar[str] = "platecli slice --list-settings result envelope" + schema_description: ClassVar[str] = ( + "Discovery output listing every settable OrcaSlicer process/filament setting. Agents read this to learn the override vocabulary, then drive it via --set / --set-filament / --settings-json." + ) + + status: Literal["ok"] + command: Literal["slice"] + action: Literal["list_settings"] + profiles_dir: str | None = None + process: ProcessSettings | None = spec(required=True, default=None) + filament: FilamentSettings | None = spec(required=True, default=None) + + +@dataclass(frozen=True) +class Version(Contract): + """``--version``. The one strict schema: nothing else may appear.""" + + schema_name: ClassVar[str] = "version" + schema_title: ClassVar[str] = "platecli --version envelope" + additional_properties: ClassVar[bool] = False + + status: Literal["ok"] + command: Literal["version"] + version: str = spec(required=True, min_length=1) + + +# --------------------------------------------------------------------------- +# Setup / diagnostics +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class Setup(Contract): + """``setup`` reports *whether* things are configured, never their values.""" + + schema_name: ClassVar[str] = "setup" + schema_title: ClassVar[str] = "platecli setup summary envelope" + schema_description: ClassVar[str] = ( + "Reports whether each setting is configured rather than its value, so the summary stays safe to paste into a bug report. The access code itself is never included; access_code_storage says only where it lives." + ) + keep_none: ClassVar[frozenset[str]] = frozenset({"model", "nozzle"}) + + status: Literal["configured"] + command: Literal["setup"] + config_path: str + printer_ip_configured: bool + serial_configured: bool + access_code_storage: Literal["file", "inline"] + model: str | None = None + nozzle: str | None = None + orca_slicer_configured: bool = spec(required=True, default=False) + profiles_dir_configured: bool = spec(required=True, default=False) + cert_fingerprint_configured: bool = spec(required=True, default=False) + insecure_tls: bool = spec(required=True, default=False) + access_code_file: str | None = None + + +@dataclass(frozen=True) +class ConfigCmd(Contract): + schema_name: ClassVar[str] = "config_cmd" + schema_title: ClassVar[str] = "platecli config show/validate envelope" + + status: str + command: Literal["config"] + action: Literal["show", "validate"] + config_path: str | None = None + config: dict[str, Any] | None = None + exit_code: int | None = None + ok: bool | None = None + errors: int | None = None + warnings: int | None = None + strict: bool | None = None + checks: list[Any] | None = None + + +@dataclass(frozen=True) +class Preflight(Contract): + schema_name: ClassVar[str] = "preflight" + schema_title: ClassVar[str] = "platecli preflight result envelope" + + status: str + command: Literal["preflight"] + checks: list[PreflightCheck] = spec(required=True, default_factory=list) + + +@dataclass(frozen=True) +class Doctor(Contract): + schema_name: ClassVar[str] = "doctor" + schema_title: ClassVar[str] = "platecli doctor result envelope" + keep_none: ClassVar[frozenset[str]] = frozenset({"certificate_fingerprint"}) + + status: str + command: Literal["doctor"] + checks: list[Any] | None = None + certificate_fingerprint: str | None = None + printer_reachable: bool | None = None + + +# --------------------------------------------------------------------------- +# Orchestrated / interactive +# --------------------------------------------------------------------------- + + +@dataclass(frozen=True) +class JobOk(Contract): + schema_name: ClassVar[str] = "job_ok" + schema_title: ClassVar[str] = "platecli job/send success or dry-run envelope" + + status: str + command: str = spec(required=True, min_length=1) + steps: dict[str, Any] | None = None + source: str | None = None + local_path: str | None = None + remote_path: str | None = None + print_started: bool | None = None + dry_run: bool | None = None + copies_ignored: bool | None = None + + +@dataclass(frozen=True) +class JobError(Contract): + """``job``/``send`` failure: the error envelope plus pipeline progress. + + The extra fields say how far the pipeline got before failing, so an agent + can resume rather than restart. + """ + + schema_name: ClassVar[str] = "job_error" + schema_title: ClassVar[str] = "platecli job/send error envelope (summary + error fields)" + keep_none: ClassVar[frozenset[str]] = frozenset( + { + "source", + "normalized_source", + "downloaded_path", + "extracted_path", + "archive_entry", + "printable_path", + "remote_name", + "workdir", + "next_command", + } + ) + + status: Literal["error"] + command: str = spec(required=True, min_length=1) + exit_code: int = spec(required=True) + error: str = spec(required=True) + failed_step: str = spec(required=True, min_length=1) + printer_error_code: int | None = None + printer_error_code_hex: str | None = None + source: str | None = None + normalized_source: str | None = None + downloaded_path: str | None = None + extracted_path: str | None = None + archive_entry: str | None = None + printable_path: str | None = None + remote_name: str | None = None + printed: bool | None = None + uploaded: bool | None = None + dry_run: bool | None = None + upload_only: bool | None = None + workdir: str | None = None + next_command: list[str] | None = None + would_download: bool | None = None + would_extract: bool | None = None + would_slice: bool | None = None + would_upload: bool | None = None + would_print: bool | None = None + copies_ignored: bool | None = None + + +@dataclass(frozen=True) +class Tui(Contract): + """``tui`` is a full-screen Textual UI: ``--json`` only ever reports refusal. + + Same shape as :class:`Go` — both are human-only front-ends with no machine + contract (AGENTS.md), so the only payload either can emit is the refusal. + """ + + schema_name: ClassVar[str] = "tui" + schema_title: ClassVar[str] = "platecli tui error envelope (interactive command; --json always errors)" + + status: Literal["error"] + command: Literal["tui"] + exit_code: Literal[5] + error: str = spec(required=True, min_length=1) + failed_step: Literal["parse"] = spec(required=True, default="parse") + + +@dataclass(frozen=True) +class Go(Contract): + """``go`` is an interactive wizard: ``--json`` only ever reports refusal.""" + + schema_name: ClassVar[str] = "go" + schema_title: ClassVar[str] = "platecli go error envelope (interactive command; --json always errors)" + + status: Literal["error"] + command: Literal["go"] + exit_code: Literal[5] + error: str = spec(required=True, min_length=1) + failed_step: Literal["parse"] = spec(required=True, default="parse") diff --git a/bambu_cli/utils.py b/bambu_cli/utils.py index e813a2d..868955f 100644 --- a/bambu_cli/utils.py +++ b/bambu_cli/utils.py @@ -130,10 +130,24 @@ def _json_display_paths(value): return value +def _as_payload(data): + """Accept either a raw dict or a contract from ``bambu_cli.contracts``. + + Contracts are the typed description of each command's ``--json`` output and + generate ``docs/schemas``. They render to a plain dict here rather than + serializing themselves, so every payload still goes through the redaction + pass below — that is the whole reason these are dataclasses and not pydantic + models (see bambu_cli/contracts/base.py). + """ + from bambu_cli.contracts import Contract + + return data.to_payload() if isinstance(data, Contract) else data + + def emit_json(data): global _JSON_EMITTED _JSON_EMITTED = True - print(json.dumps(_json_display_paths(data), indent=2)) + print(json.dumps(_json_display_paths(_as_payload(data)), indent=2)) def emit_json_line(data): @@ -145,7 +159,7 @@ def emit_json_line(data): """ global _JSON_EMITTED _JSON_EMITTED = True - print(json.dumps(_json_display_paths(data), separators=(",", ":")), flush=True) + print(json.dumps(_json_display_paths(_as_payload(data)), separators=(",", ":")), flush=True) def _namespace_get(args, key, default=None): diff --git a/docs/quality-roadmap.md b/docs/quality-roadmap.md index 6c315c9..ab992a6 100644 --- a/docs/quality-roadmap.md +++ b/docs/quality-roadmap.md @@ -61,7 +61,7 @@ security is not yet **A+**. | Correctness / bugs | **A** | dead flags fixed (global `--json` before subcommand); structured errors; purity greps; version single-sourced | | Typing | **A** | `uvx mypy -p bambu_cli` full package with `check_untyped_defs = true`; no residual excludes | | Error model | **A** | `sys.exit` only in `cli.py` (errors.py hits are docstrings); domain uses `abort` / `BambuError` | -| Tests | **A−** | **1308** non-live tests collected / **1307** passing (2026-07-31; latest additions are the Textual TUI phases 1-5: dashboard, prepare, confirm/print, job monitor, help overlay, and advanced slice settings — pilot tests plus the shared `interactive/core.py` unit tests and a hermetic override read-back, on top of the deep-audit fix wave); **88.53%** coverage measured 2026-07-31 on Linux (CI on `cc6f78c`: Windows 88.09%, Linux 88.51%); CI floor **83**; per-module floors not enforced | +| Tests | **A−** | **1374** non-live tests collected / **1373** passing (2026-08-05; the Textual TUI phases 1-5 plus the structural refactor wave: layer-boundary enforcement, the Printables adapter's malformed-payload containment sweep, and round-trip tests proving each generated schema matches what its contract emits); **89.0%** coverage measured 2026-08-05 on Linux; CI floor **83**; per-module floors not enforced | | CI / release | **A−** | single pytest path; purity greps; bandit/audit/mypy blocking; **`--cov-fail-under=83`** (A+ target remains 92) | | Docs / governance | **A−** | roadmap + backlog + SECURITY + AGENTS aligned (2026-07-24); prior AGENTS mypy-blocklist / backlog ≥98% claims corrected | | Product polish | **B+** | quality gates in place; still pre-1.0 Beta (version is single-sourced from `pyproject.toml`); coverage ratchet + camera defaults remain for 1.0 A+ | diff --git a/docs/schemas/config_cmd.json b/docs/schemas/config_cmd.json index 6562db8..f13a16e 100644 --- a/docs/schemas/config_cmd.json +++ b/docs/schemas/config_cmd.json @@ -3,19 +3,52 @@ "$id": "https://platecli.local/schemas/config_cmd.json", "title": "platecli config show/validate envelope", "type": "object", - "required": ["status", "command", "action"], + "required": [ + "status", + "command", + "action" + ], "properties": { - "status": {"type": "string"}, - "command": {"const": "config"}, - "action": {"enum": ["show", "validate"]}, - "config_path": {"type": "string"}, - "config": {"type": "object"}, - "exit_code": {"type": "integer"}, - "ok": {"type": "boolean"}, - "errors": {"type": "integer"}, - "warnings": {"type": "integer"}, - "strict": {"type": "boolean"}, - "checks": {"type": "array"} + "status": { + "type": "string" + }, + "command": { + "const": "config", + "type": "string" + }, + "action": { + "enum": [ + "show", + "validate" + ], + "type": "string" + }, + "config_path": { + "type": "string" + }, + "config": { + "additionalProperties": true, + "type": "object" + }, + "exit_code": { + "type": "integer" + }, + "ok": { + "type": "boolean" + }, + "errors": { + "type": "integer" + }, + "warnings": { + "type": "integer" + }, + "strict": { + "type": "boolean" + }, + "checks": { + "items": {}, + "type": "array" + } }, "additionalProperties": true } diff --git a/docs/schemas/delete.json b/docs/schemas/delete.json index 468054f..4e72290 100644 --- a/docs/schemas/delete.json +++ b/docs/schemas/delete.json @@ -3,13 +3,32 @@ "$id": "https://platecli.local/schemas/delete.json", "title": "platecli delete success or confirmation envelope", "type": "object", - "required": ["status", "command", "file", "deleted"], + "required": [ + "status", + "command", + "file", + "deleted" + ], "properties": { - "status": {"type": "string"}, - "command": {"const": "delete"}, - "file": {"type": "string"}, - "deleted": {"type": "boolean"}, - "next_command": {"type": "array"} + "status": { + "type": "string" + }, + "command": { + "const": "delete", + "type": "string" + }, + "file": { + "type": "string" + }, + "deleted": { + "type": "boolean" + }, + "next_command": { + "items": { + "type": "string" + }, + "type": "array" + } }, "additionalProperties": true } diff --git a/docs/schemas/doctor.json b/docs/schemas/doctor.json index 22110b5..8ee4160 100644 --- a/docs/schemas/doctor.json +++ b/docs/schemas/doctor.json @@ -3,13 +3,31 @@ "$id": "https://platecli.local/schemas/doctor.json", "title": "platecli doctor result envelope", "type": "object", - "required": ["status", "command"], + "required": [ + "status", + "command" + ], "properties": { - "status": {"type": "string"}, - "command": {"const": "doctor"}, - "checks": {"type": "array"}, - "certificate_fingerprint": {}, - "printer_reachable": {"type": "boolean"} + "status": { + "type": "string" + }, + "command": { + "const": "doctor", + "type": "string" + }, + "checks": { + "items": {}, + "type": "array" + }, + "certificate_fingerprint": { + "type": [ + "string", + "null" + ] + }, + "printer_reachable": { + "type": "boolean" + } }, "additionalProperties": true } diff --git a/docs/schemas/download.json b/docs/schemas/download.json index e886218..0743f48 100644 --- a/docs/schemas/download.json +++ b/docs/schemas/download.json @@ -3,17 +3,50 @@ "$id": "https://platecli.local/schemas/download.json", "title": "platecli download success envelope", "type": "object", - "required": ["status", "command", "source", "download_url", "path", "filename", "bytes"], + "required": [ + "status", + "command", + "source", + "download_url", + "path", + "filename", + "bytes" + ], "properties": { - "status": {"const": "downloaded"}, - "command": {"const": "download"}, - "source": {"type": "string"}, - "normalized_source": {}, - "download_url": {"type": "string"}, - "path": {"type": "string", "minLength": 1}, - "filename": {"type": "string", "minLength": 1}, - "bytes": {"type": "integer"}, - "archive_entry": {"type": "string"} + "status": { + "const": "downloaded", + "type": "string" + }, + "command": { + "const": "download", + "type": "string" + }, + "source": { + "type": "string" + }, + "normalized_source": { + "type": [ + "string", + "null" + ] + }, + "download_url": { + "type": "string" + }, + "path": { + "minLength": 1, + "type": "string" + }, + "filename": { + "minLength": 1, + "type": "string" + }, + "bytes": { + "type": "integer" + }, + "archive_entry": { + "type": "string" + } }, "additionalProperties": true } diff --git a/docs/schemas/error_envelope.json b/docs/schemas/error_envelope.json index 3138027..1205bc3 100644 --- a/docs/schemas/error_envelope.json +++ b/docs/schemas/error_envelope.json @@ -3,17 +3,49 @@ "$id": "https://platecli.local/schemas/error_envelope.json", "title": "platecli JSON error envelope", "type": "object", - "required": ["status", "command", "exit_code", "error"], + "required": [ + "status", + "command", + "exit_code", + "error" + ], "properties": { - "status": {"const": "error"}, - "command": {"type": "string", "minLength": 1}, - "exit_code": {"type": "integer"}, - "error": {"type": "string"}, - "failed_step": {"type": "string"}, - "printer_error_code": {"type": "integer"}, - "printer_error_code_hex": {"type": "string"}, - "next_command": {}, - "detail": {"type": "object"} + "status": { + "const": "error", + "type": "string" + }, + "command": { + "minLength": 1, + "type": "string" + }, + "exit_code": { + "type": "integer" + }, + "error": { + "type": "string" + }, + "failed_step": { + "type": "string" + }, + "printer_error_code": { + "type": "integer" + }, + "printer_error_code_hex": { + "type": "string" + }, + "next_command": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "detail": { + "additionalProperties": true, + "type": "object" + } }, "additionalProperties": true } diff --git a/docs/schemas/files.json b/docs/schemas/files.json index 67417e1..27ee4ed 100644 --- a/docs/schemas/files.json +++ b/docs/schemas/files.json @@ -3,22 +3,43 @@ "$id": "https://platecli.local/schemas/files.json", "title": "platecli files listing envelope", "type": "object", - "required": ["status", "command", "count", "files"], + "required": [ + "status", + "command", + "count", + "files" + ], "properties": { - "status": {"const": "ok"}, - "command": {"const": "files"}, - "count": {"type": "integer", "minimum": 0}, + "status": { + "const": "ok", + "type": "string" + }, + "command": { + "const": "files", + "type": "string" + }, + "count": { + "type": "integer", + "minimum": 0 + }, "files": { - "type": "array", "items": { - "type": "object", - "required": ["name", "path"], "properties": { - "name": {"type": "string"}, - "path": {"type": "string"} + "name": { + "type": "string" + }, + "path": { + "type": "string" + } }, + "required": [ + "name", + "path" + ], + "type": "object", "additionalProperties": true - } + }, + "type": "array" } }, "additionalProperties": true diff --git a/docs/schemas/gcode.json b/docs/schemas/gcode.json index 02d4e27..0902227 100644 --- a/docs/schemas/gcode.json +++ b/docs/schemas/gcode.json @@ -3,13 +3,32 @@ "$id": "https://platecli.local/schemas/gcode.json", "title": "platecli gcode success or confirmation envelope", "type": "object", - "required": ["status", "command", "gcode", "sent"], + "required": [ + "status", + "command", + "gcode", + "sent" + ], "properties": { - "status": {"type": "string"}, - "command": {"const": "gcode"}, - "gcode": {"type": "string"}, - "sent": {"type": "boolean"}, - "next_command": {"type": "array"} + "status": { + "type": "string" + }, + "command": { + "const": "gcode", + "type": "string" + }, + "gcode": { + "type": "string" + }, + "sent": { + "type": "boolean" + }, + "next_command": { + "items": { + "type": "string" + }, + "type": "array" + } }, "additionalProperties": true } diff --git a/docs/schemas/go.json b/docs/schemas/go.json index e0eb500..c02504b 100644 --- a/docs/schemas/go.json +++ b/docs/schemas/go.json @@ -3,13 +3,34 @@ "$id": "https://platecli.local/schemas/go.json", "title": "platecli go error envelope (interactive command; --json always errors)", "type": "object", - "required": ["status", "command", "exit_code", "error", "failed_step"], + "required": [ + "status", + "command", + "exit_code", + "error", + "failed_step" + ], "properties": { - "status": {"const": "error"}, - "command": {"const": "go"}, - "exit_code": {"const": 5}, - "error": {"type": "string", "minLength": 1}, - "failed_step": {"const": "parse"} + "status": { + "const": "error", + "type": "string" + }, + "command": { + "const": "go", + "type": "string" + }, + "exit_code": { + "const": 5, + "type": "integer" + }, + "error": { + "minLength": 1, + "type": "string" + }, + "failed_step": { + "const": "parse", + "type": "string" + } }, "additionalProperties": true } diff --git a/docs/schemas/job_error.json b/docs/schemas/job_error.json index ef66f2f..e1de0d2 100644 --- a/docs/schemas/job_error.json +++ b/docs/schemas/job_error.json @@ -3,34 +3,125 @@ "$id": "https://platecli.local/schemas/job_error.json", "title": "platecli job/send error envelope (summary + error fields)", "type": "object", - "required": ["status", "command", "exit_code", "error", "failed_step"], + "required": [ + "status", + "command", + "exit_code", + "error", + "failed_step" + ], "properties": { - "status": {"const": "error"}, - "command": {"type": "string", "minLength": 1}, - "exit_code": {"type": "integer"}, - "error": {"type": "string"}, - "failed_step": {"type": "string", "minLength": 1}, - "printer_error_code": {"type": "integer"}, - "printer_error_code_hex": {"type": "string"}, - "source": {}, - "normalized_source": {}, - "downloaded_path": {}, - "extracted_path": {}, - "archive_entry": {}, - "printable_path": {}, - "remote_name": {}, - "printed": {"type": "boolean"}, - "uploaded": {"type": "boolean"}, - "dry_run": {"type": "boolean"}, - "upload_only": {"type": "boolean"}, - "workdir": {}, - "next_command": {}, - "would_download": {"type": "boolean"}, - "would_extract": {"type": "boolean"}, - "would_slice": {"type": "boolean"}, - "would_upload": {"type": "boolean"}, - "would_print": {"type": "boolean"}, - "copies_ignored": {"type": "boolean"} + "status": { + "const": "error", + "type": "string" + }, + "command": { + "minLength": 1, + "type": "string" + }, + "exit_code": { + "type": "integer" + }, + "error": { + "type": "string" + }, + "failed_step": { + "minLength": 1, + "type": "string" + }, + "printer_error_code": { + "type": "integer" + }, + "printer_error_code_hex": { + "type": "string" + }, + "source": { + "type": [ + "string", + "null" + ] + }, + "normalized_source": { + "type": [ + "string", + "null" + ] + }, + "downloaded_path": { + "type": [ + "string", + "null" + ] + }, + "extracted_path": { + "type": [ + "string", + "null" + ] + }, + "archive_entry": { + "type": [ + "string", + "null" + ] + }, + "printable_path": { + "type": [ + "string", + "null" + ] + }, + "remote_name": { + "type": [ + "string", + "null" + ] + }, + "printed": { + "type": "boolean" + }, + "uploaded": { + "type": "boolean" + }, + "dry_run": { + "type": "boolean" + }, + "upload_only": { + "type": "boolean" + }, + "workdir": { + "type": [ + "string", + "null" + ] + }, + "next_command": { + "items": { + "type": "string" + }, + "type": [ + "array", + "null" + ] + }, + "would_download": { + "type": "boolean" + }, + "would_extract": { + "type": "boolean" + }, + "would_slice": { + "type": "boolean" + }, + "would_upload": { + "type": "boolean" + }, + "would_print": { + "type": "boolean" + }, + "copies_ignored": { + "type": "boolean" + } }, "additionalProperties": true } diff --git a/docs/schemas/job_ok.json b/docs/schemas/job_ok.json index 94d43f5..f085af9 100644 --- a/docs/schemas/job_ok.json +++ b/docs/schemas/job_ok.json @@ -3,17 +3,40 @@ "$id": "https://platecli.local/schemas/job_ok.json", "title": "platecli job/send success or dry-run envelope", "type": "object", - "required": ["status", "command"], + "required": [ + "status", + "command" + ], "properties": { - "status": {"type": "string"}, - "command": {"type": "string", "minLength": 1}, - "steps": {"type": "object"}, - "source": {"type": "string"}, - "local_path": {"type": "string"}, - "remote_path": {"type": "string"}, - "print_started": {"type": "boolean"}, - "dry_run": {"type": "boolean"}, - "copies_ignored": {"type": "boolean"} + "status": { + "type": "string" + }, + "command": { + "minLength": 1, + "type": "string" + }, + "steps": { + "additionalProperties": true, + "type": "object" + }, + "source": { + "type": "string" + }, + "local_path": { + "type": "string" + }, + "remote_path": { + "type": "string" + }, + "print_started": { + "type": "boolean" + }, + "dry_run": { + "type": "boolean" + }, + "copies_ignored": { + "type": "boolean" + } }, "additionalProperties": true } diff --git a/docs/schemas/light.json b/docs/schemas/light.json index 26c5520..c6a70e6 100644 --- a/docs/schemas/light.json +++ b/docs/schemas/light.json @@ -3,12 +3,31 @@ "$id": "https://platecli.local/schemas/light.json", "title": "platecli light success envelope", "type": "object", - "required": ["status", "command", "action", "changed"], + "required": [ + "status", + "command", + "action", + "changed" + ], "properties": { - "status": {"const": "light_changed"}, - "command": {"const": "light"}, - "action": {"enum": ["on", "off"]}, - "changed": {"type": "boolean"} + "status": { + "const": "light_changed", + "type": "string" + }, + "command": { + "const": "light", + "type": "string" + }, + "action": { + "enum": [ + "on", + "off" + ], + "type": "string" + }, + "changed": { + "type": "boolean" + } }, "additionalProperties": true } diff --git a/docs/schemas/ok_envelope.json b/docs/schemas/ok_envelope.json index 46283db..85d3825 100644 --- a/docs/schemas/ok_envelope.json +++ b/docs/schemas/ok_envelope.json @@ -3,10 +3,18 @@ "$id": "https://platecli.local/schemas/ok_envelope.json", "title": "platecli JSON ok envelope", "type": "object", - "required": ["status", "command"], + "required": [ + "status", + "command" + ], "properties": { - "status": {"type": "string"}, - "command": {"type": "string", "minLength": 1} + "status": { + "type": "string" + }, + "command": { + "minLength": 1, + "type": "string" + } }, "additionalProperties": true } diff --git a/docs/schemas/pause.json b/docs/schemas/pause.json index ceca6cf..fe596ad 100644 --- a/docs/schemas/pause.json +++ b/docs/schemas/pause.json @@ -3,12 +3,32 @@ "$id": "https://platecli.local/schemas/pause.json", "title": "platecli pause success or confirmation envelope", "type": "object", - "required": ["status", "command", "paused"], + "required": [ + "status", + "command", + "paused" + ], "properties": { - "status": {"enum": ["paused", "confirmation_required"]}, - "command": {"const": "pause"}, - "paused": {"type": "boolean"}, - "next_command": {"type": "array", "items": {"type": "string"}} + "status": { + "enum": [ + "paused", + "confirmation_required" + ], + "type": "string" + }, + "command": { + "const": "pause", + "type": "string" + }, + "paused": { + "type": "boolean" + }, + "next_command": { + "items": { + "type": "string" + }, + "type": "array" + } }, "additionalProperties": true } diff --git a/docs/schemas/preflight.json b/docs/schemas/preflight.json index 0d38636..83b861c 100644 --- a/docs/schemas/preflight.json +++ b/docs/schemas/preflight.json @@ -3,23 +3,45 @@ "$id": "https://platecli.local/schemas/preflight.json", "title": "platecli preflight result envelope", "type": "object", - "required": ["status", "command", "checks"], + "required": [ + "status", + "command", + "checks" + ], "properties": { - "status": {"type": "string"}, - "command": {"const": "preflight"}, + "status": { + "type": "string" + }, + "command": { + "const": "preflight", + "type": "string" + }, "checks": { - "type": "array", "items": { - "type": "object", - "required": ["status", "name", "message"], "properties": { - "status": {"type": "string"}, - "name": {"type": "string"}, - "message": {"type": "string"}, - "detail": {"type": "object"} + "status": { + "type": "string" + }, + "name": { + "type": "string" + }, + "message": { + "type": "string" + }, + "detail": { + "additionalProperties": true, + "type": "object" + } }, + "required": [ + "status", + "name", + "message" + ], + "type": "object", "additionalProperties": true - } + }, + "type": "array" } }, "additionalProperties": true diff --git a/docs/schemas/print.json b/docs/schemas/print.json index f2cf60c..4aecd29 100644 --- a/docs/schemas/print.json +++ b/docs/schemas/print.json @@ -3,14 +3,34 @@ "$id": "https://platecli.local/schemas/print.json", "title": "platecli print success or confirmation envelope", "type": "object", - "required": ["status", "command", "file"], + "required": [ + "status", + "command", + "file" + ], "properties": { - "status": {"type": "string"}, - "command": {"const": "print"}, - "file": {"type": "string"}, - "printed": {"type": "boolean"}, - "dry_run": {"type": "boolean"}, - "next_command": {"type": "array"} + "status": { + "type": "string" + }, + "command": { + "const": "print", + "type": "string" + }, + "file": { + "type": "string" + }, + "printed": { + "type": "boolean" + }, + "dry_run": { + "type": "boolean" + }, + "next_command": { + "items": { + "type": "string" + }, + "type": "array" + } }, "additionalProperties": true } diff --git a/docs/schemas/resume.json b/docs/schemas/resume.json index 239df4d..52d98da 100644 --- a/docs/schemas/resume.json +++ b/docs/schemas/resume.json @@ -3,12 +3,32 @@ "$id": "https://platecli.local/schemas/resume.json", "title": "platecli resume success or confirmation envelope", "type": "object", - "required": ["status", "command", "resumed"], + "required": [ + "status", + "command", + "resumed" + ], "properties": { - "status": {"enum": ["resumed", "confirmation_required"]}, - "command": {"const": "resume"}, - "resumed": {"type": "boolean"}, - "next_command": {"type": "array", "items": {"type": "string"}} + "status": { + "enum": [ + "resumed", + "confirmation_required" + ], + "type": "string" + }, + "command": { + "const": "resume", + "type": "string" + }, + "resumed": { + "type": "boolean" + }, + "next_command": { + "items": { + "type": "string" + }, + "type": "array" + } }, "additionalProperties": true } diff --git a/docs/schemas/setup.json b/docs/schemas/setup.json index c727b8a..4f8afe7 100644 --- a/docs/schemas/setup.json +++ b/docs/schemas/setup.json @@ -2,8 +2,8 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://platecli.local/schemas/setup.json", "title": "platecli setup summary envelope", - "description": "Reports whether each setting is configured rather than its value, so the summary stays safe to paste into a bug report. The access code itself is never included; access_code_storage says only where it lives.", "type": "object", + "description": "Reports whether each setting is configured rather than its value, so the summary stays safe to paste into a bug report. The access code itself is never included; access_code_storage says only where it lives.", "required": [ "status", "command", @@ -17,19 +17,57 @@ "insecure_tls" ], "properties": { - "status": {"const": "configured"}, - "command": {"const": "setup"}, - "config_path": {"type": "string"}, - "printer_ip_configured": {"type": "boolean"}, - "serial_configured": {"type": "boolean"}, - "access_code_storage": {"enum": ["file", "inline"]}, - "model": {"type": ["string", "null"]}, - "nozzle": {"type": ["string", "null"]}, - "orca_slicer_configured": {"type": "boolean"}, - "profiles_dir_configured": {"type": "boolean"}, - "cert_fingerprint_configured": {"type": "boolean"}, - "insecure_tls": {"type": "boolean"}, - "access_code_file": {"type": "string"} + "status": { + "const": "configured", + "type": "string" + }, + "command": { + "const": "setup", + "type": "string" + }, + "config_path": { + "type": "string" + }, + "printer_ip_configured": { + "type": "boolean" + }, + "serial_configured": { + "type": "boolean" + }, + "access_code_storage": { + "enum": [ + "file", + "inline" + ], + "type": "string" + }, + "model": { + "type": [ + "string", + "null" + ] + }, + "nozzle": { + "type": [ + "string", + "null" + ] + }, + "orca_slicer_configured": { + "type": "boolean" + }, + "profiles_dir_configured": { + "type": "boolean" + }, + "cert_fingerprint_configured": { + "type": "boolean" + }, + "insecure_tls": { + "type": "boolean" + }, + "access_code_file": { + "type": "string" + } }, "additionalProperties": true } diff --git a/docs/schemas/slice.json b/docs/schemas/slice.json index d0e3bfe..fd258f7 100644 --- a/docs/schemas/slice.json +++ b/docs/schemas/slice.json @@ -3,15 +3,42 @@ "$id": "https://platecli.local/schemas/slice.json", "title": "platecli slice success envelope", "type": "object", - "required": ["status", "command", "file", "path", "filename", "bytes", "step_converted"], + "required": [ + "status", + "command", + "file", + "path", + "filename", + "bytes", + "step_converted" + ], "properties": { - "status": {"const": "sliced"}, - "command": {"const": "slice"}, - "file": {"type": "string", "minLength": 1}, - "path": {"type": "string", "minLength": 1}, - "filename": {"type": "string", "minLength": 1}, - "bytes": {"type": "integer"}, - "step_converted": {"type": "boolean"} + "status": { + "const": "sliced", + "type": "string" + }, + "command": { + "const": "slice", + "type": "string" + }, + "file": { + "minLength": 1, + "type": "string" + }, + "path": { + "minLength": 1, + "type": "string" + }, + "filename": { + "minLength": 1, + "type": "string" + }, + "bytes": { + "type": "integer" + }, + "step_converted": { + "type": "boolean" + } }, "additionalProperties": true } diff --git a/docs/schemas/slice_list_settings.json b/docs/schemas/slice_list_settings.json index 7106925..e63aaaf 100644 --- a/docs/schemas/slice_list_settings.json +++ b/docs/schemas/slice_list_settings.json @@ -2,35 +2,66 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://platecli.local/schemas/slice_list_settings.json", "title": "platecli slice --list-settings result envelope", - "description": "Discovery output listing every settable OrcaSlicer process/filament setting. Agents read this to learn the override vocabulary, then drive it via --set / --set-filament / --settings-json.", "type": "object", - "required": ["status", "command", "action", "process", "filament"], + "description": "Discovery output listing every settable OrcaSlicer process/filament setting. Agents read this to learn the override vocabulary, then drive it via --set / --set-filament / --settings-json.", + "required": [ + "status", + "command", + "action", + "process", + "filament" + ], "properties": { - "status": {"const": "ok"}, - "command": {"const": "slice"}, - "action": {"const": "list_settings"}, - "profiles_dir": {"type": "string"}, + "status": { + "const": "ok", + "type": "string" + }, + "command": { + "const": "slice", + "type": "string" + }, + "action": { + "const": "list_settings", + "type": "string" + }, + "profiles_dir": { + "type": "string" + }, "process": { - "type": "object", - "required": ["count", "settings"], "properties": { - "count": {"type": "integer"}, + "count": { + "type": "integer" + }, "settings": { - "type": "object", - "description": "Map of process setting key to a representative/example value." + "additionalProperties": true, + "description": "Map of process setting key to a representative/example value.", + "type": "object" } - } + }, + "required": [ + "count", + "settings" + ], + "type": "object", + "additionalProperties": true }, "filament": { - "type": "object", - "required": ["count", "settings"], "properties": { - "count": {"type": "integer"}, + "count": { + "type": "integer" + }, "settings": { - "type": "object", - "description": "Map of filament setting key to a representative/example value." + "additionalProperties": true, + "description": "Map of filament setting key to a representative/example value.", + "type": "object" } - } + }, + "required": [ + "count", + "settings" + ], + "type": "object", + "additionalProperties": true } }, "additionalProperties": true diff --git a/docs/schemas/snapshot.json b/docs/schemas/snapshot.json index 73739cb..c9e47d4 100644 --- a/docs/schemas/snapshot.json +++ b/docs/schemas/snapshot.json @@ -3,23 +3,47 @@ "$id": "https://platecli.local/schemas/snapshot.json", "title": "platecli snapshot success envelope", "type": "object", - "required": ["status", "command", "output", "size_bytes", "captured_at", "sha256"], + "required": [ + "status", + "command", + "output", + "size_bytes", + "captured_at", + "sha256" + ], "properties": { - "status": {"const": "saved"}, - "command": {"const": "snapshot"}, - "output": {"type": "string", "minLength": 1}, - "size_bytes": {"type": "integer"}, + "status": { + "const": "saved", + "type": "string" + }, + "command": { + "const": "snapshot", + "type": "string" + }, + "output": { + "minLength": 1, + "type": "string" + }, + "size_bytes": { + "type": "integer" + }, "captured_at": { - "type": "string", - "description": "ISO-8601 UTC timestamp of capture (e.g. 2026-07-24T19:15:30Z)" + "description": "ISO-8601 UTC timestamp of capture (e.g. 2026-07-24T19:15:30Z)", + "type": "string" }, "sha256": { - "type": "string", - "description": "Hex SHA-256 digest of the captured JPEG bytes; use to verify a capture is new" + "description": "Hex SHA-256 digest of the captured JPEG bytes; use to verify a capture is new", + "type": "string" + }, + "method": { + "type": "string" + }, + "camera_image": { + "type": "string" }, - "method": {"type": "string"}, - "camera_image": {"type": "string"}, - "docker_container": {"type": "string"} + "docker_container": { + "type": "string" + } }, "additionalProperties": true } diff --git a/docs/schemas/status.json b/docs/schemas/status.json index 451cef4..dd27227 100644 --- a/docs/schemas/status.json +++ b/docs/schemas/status.json @@ -2,56 +2,110 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://platecli.local/schemas/status.json", "title": "platecli status success envelope", - "description": "JSON output of `plate status --json`. Printer fields appear both at the top level (raw MQTT data) and normalised under the `printer` key.", "type": "object", - "required": ["status", "command", "printer"], + "description": "JSON output of `plate status --json`. Printer fields appear both at the top level (raw MQTT data) and normalised under the `printer` key.", + "required": [ + "status", + "command", + "printer" + ], "properties": { - "status": {"const": "ok"}, - "command": {"const": "status"}, + "status": { + "const": "ok", + "type": "string" + }, + "command": { + "const": "status", + "type": "string" + }, "printer": { - "type": "object", - "description": "Complete printer state. Merged from the MQTT report topic and guaranteed to be a full snapshot, never a partial delta; the command fails with exit code 6 rather than emitting an incomplete object.", - "required": ["gcode_state", "mc_percent", "bed_temper", "nozzle_temper"], "properties": { - "gcode_state": {"type": "string"}, - "mc_percent": {"type": "number"}, - "hw_ver": {"type": "string"}, - "sw_ver": {"type": "string"}, - "bed_temper": {"type": "number"}, - "bed_target_temper": {"type": "number"}, - "nozzle_temper": {"type": "number"}, - "nozzle_target_temper": {"type": "number"}, - "cooling_fan_speed": {"type": "number"}, - "wifi_signal": {"type": "string"}, + "gcode_state": { + "type": "string" + }, + "mc_percent": { + "type": "number" + }, + "bed_temper": { + "type": "number" + }, + "bed_target_temper": { + "type": "number" + }, + "nozzle_temper": { + "type": "number" + }, + "nozzle_target_temper": { + "type": "number" + }, + "cooling_fan_speed": { + "type": "number" + }, + "wifi_signal": { + "type": "string" + }, + "sw_ver": { + "type": "string" + }, + "hw_ver": { + "type": "string" + }, "ams": { - "type": "object", - "description": "Normalised AMS state (present when AMS is attached).", "properties": { "units": { - "type": "array", "items": { - "type": "object", "properties": { - "id": {"type": "number"}, - "humidity": {"type": "number"}, - "temp": {"type": "number"}, + "id": { + "type": "number" + }, + "humidity": { + "type": "number" + }, + "temp": { + "type": "number" + }, "trays": { - "type": "array", "items": { - "type": "object", "properties": { - "slot": {"type": "number"}, - "empty": {"type": "boolean"}, - "active": {"type": "boolean"} - } - } + "slot": { + "type": "number" + }, + "active": { + "type": "boolean" + }, + "empty": { + "type": "boolean" + } + }, + "type": "object", + "required": [], + "additionalProperties": true + }, + "type": "array" } - } - } + }, + "type": "object", + "required": [], + "additionalProperties": true + }, + "type": "array" } - } + }, + "type": "object", + "required": [], + "additionalProperties": true, + "description": "Normalised AMS state (present when AMS is attached)." } - } + }, + "type": "object", + "required": [ + "gcode_state", + "mc_percent", + "bed_temper", + "nozzle_temper" + ], + "additionalProperties": true, + "description": "Complete printer state. Merged from the MQTT report topic and guaranteed to be a full snapshot, never a partial delta; the command fails with exit code 6 rather than emitting an incomplete object." } }, "additionalProperties": true diff --git a/docs/schemas/status_event.json b/docs/schemas/status_event.json index 2d09906..5e5f20f 100644 --- a/docs/schemas/status_event.json +++ b/docs/schemas/status_event.json @@ -14,10 +14,12 @@ "enum": [ "update", "terminal" - ] + ], + "type": "string" }, "command": { - "const": "status" + "const": "status", + "type": "string" }, "gcode_state": { "type": "string" diff --git a/docs/schemas/stop.json b/docs/schemas/stop.json index 95dcf39..864f50c 100644 --- a/docs/schemas/stop.json +++ b/docs/schemas/stop.json @@ -3,12 +3,32 @@ "$id": "https://platecli.local/schemas/stop.json", "title": "platecli stop success or confirmation envelope", "type": "object", - "required": ["status", "command", "stopped"], + "required": [ + "status", + "command", + "stopped" + ], "properties": { - "status": {"enum": ["stopped", "confirmation_required"]}, - "command": {"const": "stop"}, - "stopped": {"type": "boolean"}, - "next_command": {"type": "array", "items": {"type": "string"}} + "status": { + "enum": [ + "stopped", + "confirmation_required" + ], + "type": "string" + }, + "command": { + "const": "stop", + "type": "string" + }, + "stopped": { + "type": "boolean" + }, + "next_command": { + "items": { + "type": "string" + }, + "type": "array" + } }, "additionalProperties": true } diff --git a/docs/schemas/tui.json b/docs/schemas/tui.json index 06f7663..f3ca5a5 100644 --- a/docs/schemas/tui.json +++ b/docs/schemas/tui.json @@ -3,13 +3,34 @@ "$id": "https://platecli.local/schemas/tui.json", "title": "platecli tui error envelope (interactive command; --json always errors)", "type": "object", - "required": ["status", "command", "exit_code", "error", "failed_step"], + "required": [ + "status", + "command", + "exit_code", + "error", + "failed_step" + ], "properties": { - "status": {"const": "error"}, - "command": {"const": "tui"}, - "exit_code": {"const": 5}, - "error": {"type": "string", "minLength": 1}, - "failed_step": {"const": "parse"} + "status": { + "const": "error", + "type": "string" + }, + "command": { + "const": "tui", + "type": "string" + }, + "exit_code": { + "const": 5, + "type": "integer" + }, + "error": { + "minLength": 1, + "type": "string" + }, + "failed_step": { + "const": "parse", + "type": "string" + } }, "additionalProperties": true } diff --git a/docs/schemas/upload.json b/docs/schemas/upload.json index ea0aaee..280b588 100644 --- a/docs/schemas/upload.json +++ b/docs/schemas/upload.json @@ -3,14 +3,39 @@ "$id": "https://platecli.local/schemas/upload.json", "title": "platecli upload success or dry-run envelope", "type": "object", - "required": ["status", "command", "file", "remote_name", "bytes", "uploaded"], + "required": [ + "status", + "command", + "file", + "remote_name", + "bytes", + "uploaded" + ], "properties": { - "status": {"enum": ["uploaded", "dry_run_ok"]}, - "command": {"const": "upload"}, - "file": {"type": "string"}, - "remote_name": {"type": "string"}, - "bytes": {"type": "integer", "minimum": 0}, - "uploaded": {"type": "boolean"} + "status": { + "enum": [ + "uploaded", + "dry_run_ok" + ], + "type": "string" + }, + "command": { + "const": "upload", + "type": "string" + }, + "file": { + "type": "string" + }, + "remote_name": { + "type": "string" + }, + "bytes": { + "type": "integer", + "minimum": 0 + }, + "uploaded": { + "type": "boolean" + } }, "additionalProperties": true } diff --git a/docs/schemas/version.json b/docs/schemas/version.json index 6226783..3cb579c 100644 --- a/docs/schemas/version.json +++ b/docs/schemas/version.json @@ -10,14 +10,16 @@ ], "properties": { "status": { - "const": "ok" + "const": "ok", + "type": "string" }, "command": { - "const": "version" + "const": "version", + "type": "string" }, "version": { - "type": "string", - "minLength": 1 + "minLength": 1, + "type": "string" } }, "additionalProperties": false diff --git a/docs/test-backlog.md b/docs/test-backlog.md index 315a298..2b844f8 100644 --- a/docs/test-backlog.md +++ b/docs/test-backlog.md @@ -10,8 +10,8 @@ Do not treat historical “≥98% coverage” claims as current — see the snap | Metric | Current (honest) | A+ / 1.0 target | |--------|------------------|-----------------| -| Non-live tests collected | **1308** collected / **1307** passing (measured 2026-07-31; incl. the deep-audit fix wave and the Textual TUI phases 1-5: dashboard/prepare/confirm/monitor/settings pilot tests, the shared `interactive/core.py` unit tests, and the hermetic override read-back against the OrcaSlicer stub) | ≥550 with zero known flakes ✅ size | -| Line/branch coverage (CI) | **88.51%** Linux, **88.09%** Windows, **88.33%** macOS — all read off CI run `30632442521` (2026-07-31); local Linux measures 88.35%; **floor 83** (Windows is still the binding leg, so it sets any future ratchet) | **≥92%** total; optional module floors | +| Non-live tests collected | **1374** collected / **1373** passing (measured 2026-08-05 on Linux; the Textual TUI phases 1-5 plus the structural refactor wave: layer-boundary enforcement, Printables-adapter containment, and generated-schema contract tests) | ≥550 with zero known flakes ✅ size | +| Line/branch coverage (CI) | **89.0%** Linux measured 2026-08-05; **88.09%** Windows measured 2026-07-31 (not re-measured since); **floor 83** (Windows is the binding leg) | **≥92%** total; optional module floors | | Typing | Full package mypy + `check_untyped_defs` | keep; optional full `strict` later | | Error model | `sys.exit` only in `cli.py` | keep | | `@mockable` / test-awareness | **0** (CI greps) | keep | diff --git a/pyproject.toml b/pyproject.toml index d66c210..ddbd889 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -63,6 +63,12 @@ test = [ "mutmut>=3.0", "hypothesis>=6.0", "textual>=0.86,<2.0", + # Build-time only: scripts/gen_schemas.py derives docs/schemas/*.json from + # the dataclasses in bambu_cli.contracts. Deliberately NOT a runtime + # dependency — bambu_cli never imports pydantic, because emit_json owns + # serialization (and its credential redaction). Keeping it here means users + # install 3 runtime deps, not 8, and no compiled wheel ships to them. + "pydantic>=2.0,<3.0; python_version >= '3.10'", ] [project.urls] diff --git a/scripts/check_layers.py b/scripts/check_layers.py index 1b44b98..813fbbc 100644 --- a/scripts/check_layers.py +++ b/scripts/check_layers.py @@ -42,6 +42,9 @@ "jsonio": 10, "tlspin": 10, "fsutil": 10, + # Typed --json payload shapes. Pure data: stdlib only, imports nothing from + # the package, and generates docs/schemas/. Any layer may build one. + "contracts": 10, # 20 — core services: process-wide config/runtime state and shared helpers. "utils": 20, "config": 20, diff --git a/scripts/gen_schemas.py b/scripts/gen_schemas.py new file mode 100644 index 0000000..8715182 --- /dev/null +++ b/scripts/gen_schemas.py @@ -0,0 +1,275 @@ +#!/usr/bin/env python3 +"""Generate docs/schemas/*.json from the contracts in bambu_cli.contracts. + +The schemas used to be hand-written, which meant they drifted from what the +commands actually emitted. Now they are derived, and CI regenerates and diffs +them, so drift is a build failure instead of a support ticket. + + python scripts/gen_schemas.py # write docs/schemas/ + python scripts/gen_schemas.py --check # fail if anything is stale + +**Pydantic is a dev-only dependency.** It is used here, at build time, purely +to turn dataclass annotations into JSON Schema. It is not a runtime dependency +and is never imported by the package — ``bambu_cli.utils.emit_json`` still owns +serialization, because that pass applies credential redaction that a +``model_dump_json()`` would bypass. + +Requires Python 3.10+: the contracts annotate optionals as ``X | None``, which +only *evaluates* on 3.10+. The package itself never evaluates them (it reads +``dataclasses.fields()``), so runtime support for the 3.9 floor is unaffected. +""" + +from __future__ import annotations + +import argparse +import dataclasses +import json +import sys +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +SCHEMA_DIR = ROOT / "docs" / "schemas" +SCHEMA_URI = "https://json-schema.org/draft/2020-12/schema" +SCHEMA_ID_BASE = "https://platecli.local/schemas" + +if sys.version_info < (3, 10): # pragma: no cover -- guarded in CI by job config + raise SystemExit( + "gen_schemas.py needs Python 3.10+ to evaluate `X | None` annotations.\n" + "This is a dev/build tool only — the package still supports 3.9." + ) + +sys.path.insert(0, str(ROOT)) + +try: + from pydantic import TypeAdapter +except ModuleNotFoundError: # pragma: no cover -- dev dependency + raise SystemExit( + "pydantic is required to generate schemas (dev-only dependency).\n" + "Install it with: uv pip install '.[test]'" + ) from None + +from bambu_cli.contracts import all_contracts # noqa: E402 + + +def _strip_noise(node): + """Remove pydantic bookkeeping that is not part of the published contract. + + ``title`` is derived from the Python identifier and would leak field naming + into the public schema; ``default`` restates what ``required`` already says + and would churn the diff whenever a default changes. + """ + if isinstance(node, dict): + return {k: _strip_noise(v) for k, v in node.items() if k not in ("title", "default")} + if isinstance(node, list): + return [_strip_noise(v) for v in node] + return node + + +def _inline_defs(schema): + """Inline ``$defs``/``$ref`` so each published schema stands alone. + + Consumers read one file; a ``$ref`` into ``$defs`` would make them resolve + references for no benefit at this size. + """ + defs = schema.pop("$defs", None) + if not defs: + return schema + + def walk(node): + if isinstance(node, dict): + ref = node.get("$ref") + if isinstance(ref, str) and ref.startswith("#/$defs/"): + target = defs.get(ref.split("/")[-1], {}) + # The referring site wins. A field's own description and its + # `requires_keys` are more specific than anything on the shared + # model, and letting the target overwrite them silently dropped + # `status.printer`'s description and required list. + merged = walk(dict(target)) + merged.update({k: v for k, v in node.items() if k != "$ref"}) + return merged + return {k: walk(v) for k, v in node.items()} + if isinstance(node, list): + return [walk(v) for v in node] + return node + + return walk(schema) + + +def _normalize_optional(prop, *, nullable): + """Turn pydantic's ``anyOf: [X, null]`` into what this project publishes. + + Two reasons not to ship the ``anyOf`` form: + + * It is inaccurate for most fields. ``Contract.to_payload`` *omits* an unset + optional rather than emitting ``null``, so ``null`` is not a value the + field can actually take — only ``keep_none`` fields are emitted as null. + * The contract test's validator (tests/contracts/test_schema_validation.py) + understands ``type`` but not ``anyOf``, so an ``anyOf`` property would be + silently skipped — a schema that looks stricter while checking less. + + So: collapse to the bare type, or to ``type: [X, "null"]`` when null really + is emitted (the form ``setup.model`` already used). + """ + branches = prop.get("anyOf") + if not branches: + return prop + non_null = [b for b in branches if b.get("type") != "null"] + has_null = len(non_null) != len(branches) + if len(non_null) != 1 or not has_null: + return prop + + merged = {k: v for k, v in prop.items() if k != "anyOf"} + merged.update(non_null[0]) + if nullable and isinstance(merged.get("type"), str): + merged["type"] = [merged["type"], "null"] + return merged + + +def _apply_field_metadata(contract, properties): + """Fold each field's declared constraints into its property schema. + + pydantic derives type/const/enum from the annotation; ``spec(...)`` metadata + on the dataclass field carries the rest of the published contract + (minLength, minimum, description, nested required) so it stays on the model + rather than in a side table. + """ + extra_required = set() + keep_none = set(getattr(contract, "keep_none", frozenset())) + for f in dataclasses.fields(contract): + prop = properties.get(f.name) + if prop is None: + continue + prop = _normalize_optional(prop, nullable=f.name in keep_none) + properties[f.name] = prop + if f.metadata.get("contract_required"): + extra_required.add(f.name) + if "min_length" in f.metadata: + prop["minLength"] = f.metadata["min_length"] + if "minimum" in f.metadata: + prop["minimum"] = f.metadata["minimum"] + if "description" in f.metadata: + prop["description"] = f.metadata["description"] + if "requires_keys" in f.metadata: + prop["required"] = list(f.metadata["requires_keys"]) + return extra_required + + +def _dataclass_named(name): + """Look up a nested model by the class name pydantic used as its $defs key.""" + from bambu_cli.contracts import models + + obj = getattr(models, name, None) + return obj if dataclasses.is_dataclass(obj) else None + + +def _apply_to_object(model, node): + """Apply a model's declared constraints to its object schema node, in place.""" + properties = node.setdefault("properties", {}) + extra_required = _apply_field_metadata(model, properties) + derived = set(node.get("required", [])) | extra_required + node["required"] = [f for f in properties if f in derived] + # Nested objects stay permissive, same as the top-level contracts: they name + # the guaranteed keys and tolerate extra detail from the printer/slicer. + node.setdefault("additionalProperties", True) + + +def schema_for(contract): + """Build the published schema document for one contract.""" + raw = _strip_noise(TypeAdapter(contract).json_schema()) + + # Nested models carry their own spec() metadata, so process $defs *before* + # inlining — once inlined there is no way back to the owning dataclass. + for name, node in (raw.get("$defs") or {}).items(): + model = _dataclass_named(name) + if model is not None and node.get("type") == "object": + # pydantic publishes a dataclass's docstring as `description`. + # Docstrings are for developers; only descriptions declared through + # spec(...)/schema_description belong in the published contract. + node.pop("description", None) + _apply_to_object(model, node) + + properties = raw.get("properties", {}) + extra_required = _apply_field_metadata(contract, properties) + derived = set(raw.get("required", [])) | extra_required + # Ordered by declaration, so the schema's `required` reads like the payload. + required = [f for f in properties if f in derived] + + raw["properties"] = properties + raw = _inline_defs(raw) + properties = raw.get("properties", {}) + + doc = { + "$schema": SCHEMA_URI, + "$id": f"{SCHEMA_ID_BASE}/{contract.schema_name}.json", + "title": contract.schema_title, + "type": "object", + } + if contract.schema_description: + doc["description"] = contract.schema_description + doc["required"] = required + doc["properties"] = properties + doc["additionalProperties"] = contract.additional_properties + return doc + + +def render(contract): + return json.dumps(schema_for(contract), indent=2) + "\n" + + +def main(argv=None): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--check", action="store_true", help="fail if any schema is stale (CI drift gate)") + args = parser.parse_args(argv) + + contracts = all_contracts() + if not contracts: + raise SystemExit("no contracts found in bambu_cli.contracts.models") + + expected = {f"{c.schema_name}.json": render(c) for c in contracts} + SCHEMA_DIR.mkdir(parents=True, exist_ok=True) + on_disk = {p.name for p in SCHEMA_DIR.glob("*.json")} + + stale, missing = [], [] + for name, body in expected.items(): + path = SCHEMA_DIR / name + if not path.is_file(): + missing.append(name) + elif path.read_text(encoding="utf-8") != body: + stale.append(name) + + # A schema with no contract is drift in the other direction: it would keep + # being published while nothing generates or checks it. + orphaned = sorted(on_disk - set(expected)) + + if args.check: + problems = [] + if missing: + problems.append(f"missing: {', '.join(sorted(missing))}") + if stale: + problems.append(f"stale: {', '.join(sorted(stale))}") + if orphaned: + problems.append(f"no contract generates: {', '.join(orphaned)}") + if problems: + print("docs/schemas is out of sync with bambu_cli.contracts:") + for line in problems: + print(f" - {line}") + print("\nRun: python scripts/gen_schemas.py") + return 1 + print(f"docs/schemas up to date ({len(expected)} schemas).") + return 0 + + if orphaned: + print(f"warning: {', '.join(orphaned)} has no contract — delete it or add a model.") + written = 0 + for name, body in expected.items(): + path = SCHEMA_DIR / name + if not path.is_file() or path.read_text(encoding="utf-8") != body: + path.write_text(body, encoding="utf-8") + written += 1 + print(f"wrote {path.relative_to(ROOT)}") + print(f"{len(expected)} schemas ({written} changed).") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/contracts/test_contract_models.py b/tests/contracts/test_contract_models.py new file mode 100644 index 0000000..250d662 --- /dev/null +++ b/tests/contracts/test_contract_models.py @@ -0,0 +1,271 @@ +"""Tests for bambu_cli.contracts — the typed source of every --json payload. + +The claim this file has to defend is "zero drift": the published schemas in +``docs/schemas`` cannot disagree with the code that produces the payloads. +Three things enforce it, and each is tested here: + +1. Every contract generates its schema, and the committed file matches + (``scripts/gen_schemas.py --check``, also a blocking CI step). +2. Every contract's own ``to_payload()`` validates against the schema it + generated — so the model, the schema, and the emitted dict agree. +3. Every published schema has a contract behind it, and vice versa. + +Point 2 is the one that catches a bad model: a schema derived from a model is +trivially consistent with itself, but it is *not* trivially consistent with +what ``to_payload`` actually emits (omitted-vs-null, key order, defaults). + +Runtime import must work on the 3.9 floor; only schema *generation* needs 3.10+ +(the contracts annotate optionals as ``X | None``, which 3.9 cannot evaluate). +That split is asserted below. +""" + +from __future__ import annotations + +import dataclasses +import json +import sys +from pathlib import Path +from unittest.mock import MagicMock + +import pytest + +_mock_mqtt = MagicMock() +sys.modules.setdefault("paho", _mock_mqtt) +sys.modules.setdefault("paho.mqtt", _mock_mqtt) +sys.modules.setdefault("paho.mqtt.client", _mock_mqtt) + +from bambu_cli import contracts # noqa: E402 +from bambu_cli.contracts import Contract, all_contracts # noqa: E402 + +pytestmark = pytest.mark.contract + +ROOT = Path(__file__).resolve().parents[2] +SCHEMA_DIR = ROOT / "docs" / "schemas" + +_needs_generator = pytest.mark.skipif( + sys.version_info < (3, 10), + reason="schema generation needs 3.10+ to evaluate `X | None`; runtime does not", +) + + +# --- the registry ------------------------------------------------------------ + + +def test_contracts_are_discovered(): + found = all_contracts() + assert found, "no contracts discovered — all_contracts() derivation is broken" + assert len({c.schema_name for c in found}) == len(found), "two contracts claim the same schema_name" + + +def test_every_schema_file_has_a_contract_and_vice_versa(): + """Drift in both directions is a failure. + + A schema with no contract keeps being published while nothing generates or + checks it; a contract with no schema means the generator was never run. + """ + on_disk = {p.stem for p in SCHEMA_DIR.glob("*.json")} + modelled = {c.schema_name for c in all_contracts()} + assert on_disk == modelled, ( + f"schema-only={sorted(on_disk - modelled)}, contract-only={sorted(modelled - on_disk)}" + ) + + +def test_every_contract_declares_a_title(): + for contract in all_contracts(): + assert contract.schema_title, f"{contract.__name__} has no schema_title" + + +# --- to_payload semantics ---------------------------------------------------- + + +def test_unset_optionals_are_omitted_not_nulled(): + payload = contracts.Pause(status="paused", command="pause", paused=True).to_payload() + assert payload == {"status": "paused", "command": "pause", "paused": True} + assert "next_command" not in payload + + +def test_keep_none_fields_are_emitted_as_null(): + # setup reports model/nozzle as null rather than dropping them: a consumer + # distinguishes "not configured" from "key absent because of an old version". + payload = contracts.Setup( + status="configured", + command="setup", + config_path="/tmp/config.json", + printer_ip_configured=True, + serial_configured=True, + access_code_storage="file", + ).to_payload() + assert payload["model"] is None + assert payload["nozzle"] is None + + +def test_key_order_follows_field_order(): + # Agents pattern-match on the leading status/command pair. + payload = contracts.Light(status="light_changed", command="light", action="on", changed=True).to_payload() + assert list(payload)[:2] == ["status", "command"] + + +def test_extra_keys_pass_through(): + # The schemas allow additional properties; commands add detail beyond the + # guaranteed shape and must not have it silently dropped. + payload = contracts.Light( + status="light_changed", command="light", action="on", changed=True + ).to_payload(sequence_id="42") + assert payload["sequence_id"] == "42" + + +def test_extra_none_is_dropped_like_a_declared_optional(): + payload = contracts.Light( + status="light_changed", command="light", action="on", changed=True + ).to_payload(irrelevant=None) + assert "irrelevant" not in payload + + +def test_contracts_are_frozen(): + light = contracts.Light(status="light_changed", command="light", action="on", changed=True) + with pytest.raises(dataclasses.FrozenInstanceError): + light.changed = False # type: ignore[misc] + + +# --- runtime does not need the generator's Python ----------------------------- + + +def test_contracts_import_without_evaluating_annotations(): + """The package must not call get_type_hints() on these models. + + ``X | None`` annotations only evaluate on 3.10+. If any runtime path + resolved them, importing bambu_cli would break on the supported 3.9 floor — + a failure CI would only catch on one leg. + """ + for contract in all_contracts(): + fields = dataclasses.fields(contract) + assert fields, f"{contract.__name__} declares no fields" + # dataclasses keeps annotations as strings; resolving is the generator's job. + assert all(isinstance(f.type, str) for f in fields), ( + f"{contract.__name__} has resolved annotations — something called get_type_hints()" + ) + + +def test_pydantic_is_not_a_runtime_dependency(): + """Importing the package must never pull pydantic in. + + It is declared in the `test` extra for scripts/gen_schemas.py only. If this + fails, a compiled dependency has leaked into every user's install. + """ + import subprocess # noqa: S404 -- fixed argv, no shell + + code = "import bambu_cli.contracts, bambu_cli.utils, sys; print('pydantic' in sys.modules)" + out = subprocess.run( # noqa: S603 -- fixed argv + [sys.executable, "-c", code], capture_output=True, text=True, cwd=ROOT + ) + assert out.returncode == 0, out.stderr + assert out.stdout.strip() == "False", "importing bambu_cli pulled in pydantic" + + +# --- generated schemas agree with the models AND with to_payload -------------- + + +@_needs_generator +def test_committed_schemas_match_the_contracts(): + """The anti-drift gate, run as a test as well as a CI step.""" + sys.path.insert(0, str(ROOT / "scripts")) + import gen_schemas + + assert gen_schemas.main(["--check"]) == 0, "docs/schemas is stale — run python scripts/gen_schemas.py" + + +# One representative instance per contract. Kept explicit rather than +# auto-constructed: the point is to check a *realistic* payload shape. +SAMPLES = [ + contracts.OkEnvelope(status="ok", command="status"), + contracts.ErrorEnvelope(status="error", command="print", exit_code=2, error="boom", failed_step="mqtt"), + contracts.Status( + status="ok", + command="status", + # All four of these are contractually guaranteed inside `printer`; a + # partial delta is a failure, not a payload (see the schema description). + printer=contracts.PrinterState( + gcode_state="IDLE", mc_percent=0, bed_temper=25.0, nozzle_temper=30.0 + ), + ), + contracts.StatusEvent(event="update", command="status", gcode_state="RUNNING", mc_percent=42), + contracts.Light(status="light_changed", command="light", action="on", changed=True), + contracts.Pause(status="paused", command="pause", paused=True), + contracts.Resume(status="resumed", command="resume", resumed=True), + contracts.Stop(status="stopped", command="stop", stopped=True), + contracts.Gcode(status="ok", command="gcode", gcode="G28", sent=True), + contracts.Files(status="ok", command="files", count=1, files=[contracts.RemoteFile(name="a.3mf", path="/a.3mf")]), + contracts.Delete(status="ok", command="delete", file="a.3mf", deleted=True), + contracts.Upload(status="uploaded", command="upload", file="a.3mf", remote_name="a.3mf", bytes=10, uploaded=True), + contracts.Print(status="ok", command="print", file="a.3mf", printed=True), + contracts.Snapshot( + status="saved", + command="snapshot", + output="/tmp/a.jpg", + size_bytes=100, + captured_at="2026-07-24T19:15:30Z", + sha256="ab" * 32, + ), + contracts.Download( + status="downloaded", + command="download", + source="https://example.com/a.stl", + download_url="https://example.com/a.stl", + path="/tmp/a.stl", + filename="a.stl", + bytes=7, + ), + contracts.Slice( + status="sliced", command="slice", file="a.stl", path="/tmp/a.3mf", filename="a.3mf", bytes=9, step_converted=False + ), + contracts.SliceListSettings( + status="ok", + command="slice", + action="list_settings", + process=contracts.ProcessSettings(count=1, settings={"layer_height": "0.2"}), + filament=contracts.FilamentSettings(count=1, settings={"filament_flow_ratio": "0.98"}), + ), + contracts.Version(status="ok", command="version", version="0.5.0"), + contracts.Setup( + status="configured", + command="setup", + config_path="/tmp/config.json", + printer_ip_configured=True, + serial_configured=True, + access_code_storage="file", + ), + contracts.ConfigCmd(status="ok", command="config", action="show"), + contracts.Preflight( + status="ok", + command="preflight", + checks=[contracts.PreflightCheck(status="ok", name="orca", message="found")], + ), + contracts.Doctor(status="ok", command="doctor"), + contracts.JobOk(status="uploaded", command="job"), + contracts.JobError(status="error", command="job", exit_code=2, error="boom", failed_step="upload"), + contracts.Go(status="error", command="go", exit_code=5, error="interactive only", failed_step="parse"), + contracts.Tui(status="error", command="tui", exit_code=5, error="interactive only", failed_step="parse"), +] + + +def test_samples_cover_every_contract(): + assert {type(s).schema_name for s in SAMPLES} == {c.schema_name for c in all_contracts()} + + +@pytest.mark.parametrize("sample", SAMPLES, ids=lambda s: type(s).schema_name) +def test_payload_validates_against_its_generated_schema(sample): + """A model is only useful if what it *emits* matches what it *publishes*.""" + from tests.contracts.test_schema_validation import _validate + + schema = json.loads((SCHEMA_DIR / f"{type(sample).schema_name}.json").read_text(encoding="utf-8")) + payload = json.loads(json.dumps(sample.to_payload(), default=_as_plain)) + _validate(payload, schema) + + +def _as_plain(obj): + """Nested contracts/dataclasses render as plain dicts, same as emit_json sees.""" + if isinstance(obj, Contract): + return obj.to_payload() + if dataclasses.is_dataclass(obj): + return {k: v for k, v in dataclasses.asdict(obj).items() if v is not None} + raise TypeError(type(obj)) diff --git a/uv.lock b/uv.lock index cdf74e7..8c533bc 100644 --- a/uv.lock +++ b/uv.lock @@ -8,6 +8,15 @@ resolution-markers = [ "python_full_version < '3.10'", ] +[[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, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + [[package]] name = "attrs" version = "26.1.0" @@ -759,6 +768,7 @@ test = [ { name = "hypothesis", version = "6.156.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "mutmut", version = "3.3.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "mutmut", version = "3.6.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "pydantic", marker = "python_full_version >= '3.10'" }, { name = "pytest", version = "8.4.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, { name = "pytest", version = "9.1.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, { name = "pytest-asyncio", version = "1.2.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, @@ -776,6 +786,7 @@ requires-dist = [ { name = "hypothesis", marker = "extra == 'test'", specifier = ">=6.0" }, { name = "mutmut", marker = "extra == 'test'", specifier = ">=3.0" }, { name = "paho-mqtt", specifier = ">=2.0,<3.0" }, + { name = "pydantic", marker = "python_full_version >= '3.10' and extra == 'test'", specifier = ">=2.0,<3.0" }, { name = "pytest", marker = "extra == 'test'" }, { name = "pytest-asyncio", marker = "extra == 'test'" }, { name = "pytest-cov", marker = "extra == 'test'" }, @@ -823,6 +834,151 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types", marker = "python_full_version >= '3.10'" }, + { name = "pydantic-core", marker = "python_full_version >= '3.10'" }, + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, + { name = "typing-inspection", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/5d/00/13a0c039569d1e583779ee1b8d7df6bfe275a0db83fcae14f01d6856c16e/pydantic_core-2.46.4-cp39-cp39-macosx_10_12_x86_64.whl", hash = "sha256:fd8b3d9fd264be37976686c7f65cd52a83f5e84f4bfd2adf9c1d469676bbb6ae", size = 2115337, upload-time = "2026-05-06T13:38:37.741Z" }, + { url = "https://files.pythonhosted.org/packages/41/60/e70fa1ee03e243bdfd4b1fddf1e1f2a8fba681df3034b51b9376c0fb5bf5/pydantic_core-2.46.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:9f444c499b3eefd3a92e348059471ea0c3a6e303d9c1cec09fa748fd9f895201", size = 1957976, upload-time = "2026-05-06T13:37:33.478Z" }, + { url = "https://files.pythonhosted.org/packages/11/9a/78fb5f2ea849f767ea802de8b4e8f5a0c4a48ddbe4bc66bd19ac2f55a01c/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3447661d99f75a3683a4cf5c87da72f2161964611864dbbeac7fbb118bb4bfc0", size = 1979390, upload-time = "2026-05-06T13:36:52.419Z" }, + { url = "https://files.pythonhosted.org/packages/f5/7d/3acfdcd000bad9735de0430a88355948469781f62cb841fd63e8a307e80e/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8b9bab013d1c7a79d3501ff86d0bc9c31bf587db4551677b96bec07df78c6b15", size = 2043263, upload-time = "2026-05-06T13:39:54.798Z" }, + { url = "https://files.pythonhosted.org/packages/35/60/1325e5a8d7f9697416481c7f7c1c304738d6b961a7fd1ea0f054ce0f14fb/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d995260fdf4e1db774581b4900e0f832abe3c7c84996726bbc161b19c8f29e76", size = 2225708, upload-time = "2026-05-06T13:40:24.887Z" }, + { url = "https://files.pythonhosted.org/packages/6a/b0/9ec8c38f33b26db0b612cb7fd165bb0a370773710432a2a74fa31287b430/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f13a646d65d09fbf1bc6b3a9635d30095c8e7e5cc419ff35ecc563c5fd04cd49", size = 2288494, upload-time = "2026-05-06T13:38:00.091Z" }, + { url = "https://files.pythonhosted.org/packages/65/05/497446a9586d1b2d24ee25ebe208beb15388f1875d783e1e014055d150ac/pydantic_core-2.46.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:432c179df7874eeb73307aad2df0755e1ae0efa61ff0ea89b93e194411ae3928", size = 2095629, upload-time = "2026-05-06T13:38:23.632Z" }, + { url = "https://files.pythonhosted.org/packages/93/d9/cd5fa98f9d94f9294c15459396c8a2383c164469e679ac178d6d42cfee6b/pydantic_core-2.46.4-cp39-cp39-manylinux_2_31_riscv64.whl", hash = "sha256:e68b7a074f65a2fd746c52a7ce6142ab7006074ac269ace0c25cd8ba171f8066", size = 2119309, upload-time = "2026-05-06T13:39:50.144Z" }, + { url = "https://files.pythonhosted.org/packages/20/1b/64cec655451ddbf3976df5dc9706b240df4fdaebdeebeadd4f59a8dab926/pydantic_core-2.46.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4a05d69cba51d852c5c3e92758653245a50c0b646ced0cf05bd793ed592839d6", size = 2170216, upload-time = "2026-05-06T13:39:14.561Z" }, + { url = "https://files.pythonhosted.org/packages/2a/21/fe9f039138c9ea3be10ccdb6ec490acb54dcbef5a5e96dbdf1411f82b929/pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:228ee9bae8bef5b1e97ec58302f80357c37199e0d0a99174e138d28e6957b9d9", size = 2186726, upload-time = "2026-05-06T13:37:51.597Z" }, + { url = "https://files.pythonhosted.org/packages/44/cb/19ca0da64821d1aefcef65f253aa9ecbdd0dde360f607d0f9b3d95db2b4e/pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_armv7l.whl", hash = "sha256:10e17cbb10a330363733efc4d7c4d0dd827ac0909b8f6a6542298fed1ea62f29", size = 2320400, upload-time = "2026-05-06T13:39:36.29Z" }, + { url = "https://files.pythonhosted.org/packages/cd/14/fe3fbf6e845bf2080dc2f282d75085ddf79d037b35634ecde68f33c217b4/pydantic_core-2.46.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:91a06d2e259ecfbd8c901d70c3c507900458498142b3026a296b7de4d1322cc9", size = 2363318, upload-time = "2026-05-06T13:38:53.039Z" }, + { url = "https://files.pythonhosted.org/packages/62/88/60b110889507a426eecf626f7536566cb290ada71147eff49b6e2724ca62/pydantic_core-2.46.4-cp39-cp39-win32.whl", hash = "sha256:d80ee3d731373b24cebbc10d689ca4ee1875caf0d5703a245db18efd4dd37fc1", size = 1988880, upload-time = "2026-05-06T13:39:16.572Z" }, + { url = "https://files.pythonhosted.org/packages/0b/d6/8ede2f98f17e1e4e127d37be0eced4eee931a511c62cd68af50e1b25bfa9/pydantic_core-2.46.4-cp39-cp39-win_amd64.whl", hash = "sha256:3be77f45df024d789a672ae34f8b06fb346c4f9f46ea714956660ea4862e89ac", size = 2079257, upload-time = "2026-05-06T13:39:38.498Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -1244,6 +1400,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version >= '3.10'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, +] + [[package]] name = "uc-micro-py" version = "1.0.3"