From 234c4402a8bef521c8d788c6389391d2adf8af4a Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Tue, 14 Jul 2026 16:22:17 +0000 Subject: [PATCH 01/17] docs(specs): add orjson JSON standardization spec Consolidates the JSON library standardization and orjson adoption tech-debt items into a single migration spec (specify phase). Co-Authored-By: Claude Opus 4.8 --- .../checklists/requirements.md | 35 +++++++++ dev/specs/002-orjson-json-migration/spec.md | 72 +++++++++++++++++++ 2 files changed, 107 insertions(+) create mode 100644 dev/specs/002-orjson-json-migration/checklists/requirements.md create mode 100644 dev/specs/002-orjson-json-migration/spec.md diff --git a/dev/specs/002-orjson-json-migration/checklists/requirements.md b/dev/specs/002-orjson-json-migration/checklists/requirements.md new file mode 100644 index 000000000..07534cd81 --- /dev/null +++ b/dev/specs/002-orjson-json-migration/checklists/requirements.md @@ -0,0 +1,35 @@ +# Specification Quality Checklist: Standardize SDK JSON serialization on orjson + +**Purpose**: Validate specification completeness and quality before proceeding to planning +**Created**: 2026-07-14 +**Feature**: [spec.md](../spec.md) + +## Content Quality + +- [x] No implementation details (languages, frameworks, APIs) +- [x] Focused on user value and business needs +- [x] Written for non-technical stakeholders +- [x] All mandatory sections completed + +## Requirement Completeness + +- [x] No [NEEDS CLARIFICATION] markers remain +- [x] Requirements are testable and unambiguous +- [x] Success criteria are measurable +- [x] Success criteria are technology-agnostic (no implementation details) +- [x] All acceptance scenarios are defined +- [x] Edge cases are identified +- [x] Scope is clearly bounded +- [x] Dependencies and assumptions identified + +## Feature Readiness + +- [x] All functional requirements have clear acceptance criteria +- [x] User scenarios cover primary flows +- [x] Feature meets measurable outcomes defined in Success Criteria +- [x] No implementation details leak into specification + +## Notes + +- The spec deliberately avoids naming the specific JSON libraries in requirement/success statements to keep them technology-agnostic; the concrete library choice (orjson, replacing ujson + stdlib json) is recorded as an input/decision and belongs to the plan phase. +- Single P1 user story by design: the migration is atomic and cannot be meaningfully split into independently-shippable slices without creating the two-library intermediate state the work exists to remove. diff --git a/dev/specs/002-orjson-json-migration/spec.md b/dev/specs/002-orjson-json-migration/spec.md new file mode 100644 index 000000000..1dbc08683 --- /dev/null +++ b/dev/specs/002-orjson-json-migration/spec.md @@ -0,0 +1,72 @@ +# Feature Specification: Standardize SDK JSON serialization on orjson + +**Feature Branch**: `dga/feat-orjson-pd5o6` + +**Created**: 2026-07-14 + +**Status**: Draft + +**Input**: Consolidate two related tech-debt items — "standardize the JSON library used across the SDK" and "adopt orjson for performance" — into a single migration that makes orjson the sole JSON library in `infrahub_sdk/`. + +## User Scenarios & Testing *(mandatory)* + +### User Story 1 - Transparent JSON library migration (Priority: P1) + +An engineer who builds automation on the SDK (transforms, generators, checks, imports/exports, and GraphQL round-trips) upgrades to the SDK version where JSON serialization is handled by a single, faster library. Their existing code runs unchanged and produces the same observable results, while JSON encoding and decoding on the hot path is faster. + +At the same time, a contributor working inside the SDK now finds exactly one JSON library and one import convention across the package, removing the ambiguity of choosing between two libraries and the latent hazard of mismatched decode-error handling. + +**Why this priority**: This is the entire feature. It is deliberately a single, atomic slice — splitting it would leave the codebase in a two-library intermediate state, which is precisely the ambiguity the work exists to eliminate. + +**Independent Test**: Run the full existing unit and integration suite against the migrated SDK and confirm it passes with no behavioural changes to serialized output, hashing, or error handling; confirm no code in `infrahub_sdk/` references more than one JSON library. + +**Acceptance Scenarios**: + +1. **Given** an engineer running any existing SDK operation (query, export, generator, check, or `infrahubctl` JSON output), **When** they run it on the migrated SDK, **Then** the observable results are identical to the pre-migration behaviour. +2. **Given** query parameters containing only ASCII / integer / float / nested-dict values, **When** the SDK derives the tracking group name for those parameters, **Then** the derived name is identical to the pre-migration name. +3. **Given** malformed JSON returned from the API, **When** the SDK decodes it, **Then** the same decode-failure error is raised and caught as before. +4. **Given** a contributor searches `infrahub_sdk/` for JSON library usage, **When** they inspect imports, **Then** exactly one JSON library is present and no legacy JSON library remains. + +### Edge Cases + +- **Non-ASCII parameter values in tracking-group naming**: the derived group name changes once on upgrade for parameters containing non-ASCII characters (the new library emits raw UTF-8 where the old one emitted escaped sequences). The previously-created group is orphaned and a new one is created. This is an accepted, documented, one-time change and is pinned by a test vector. +- **Serialized output type**: serialization now yields bytes at the boundary; every place that requires text must convert explicitly so no consumer receives the wrong type. +- **Non-string dictionary keys**: the old library silently coerced non-string keys to strings; the new library must be configured to preserve that behaviour at sites that serialize arbitrary data, so previously-working payloads do not begin to fail. +- **Datetime rendering in CLI JSON output**: date/time values in `infrahubctl` JSON output must render in the same textual form as before, with no change visible to anyone parsing that output. +- **File-based serialization**: sites that read or write JSON directly to file handles must be rewritten, because the new library serializes and deserializes via in-memory bytes rather than file handles. +- **Human-facing pretty-printed output** (debug prints, test-failure diffs): indentation width may change cosmetically; this output carries no contract and is not compared programmatically. + +## Requirements *(mandatory)* + +### Functional Requirements + +- **FR-001**: The SDK MUST use a single JSON library throughout `infrahub_sdk/`; no module may reference the previously-used JSON libraries. *Acceptance*: a search of `infrahub_sdk/` finds zero references to the legacy libraries and lint passes. +- **FR-002**: The SDK MUST preserve JSON output byte-for-byte at every site where that output is consumed, compared, or shown to users, including preserving current indentation for machine-consumed output and preserving the current textual form of date/time values in CLI output. *Acceptance*: existing CLI/formatter tests pass unchanged. +- **FR-003**: The parameter-hashing behaviour that feeds tracking-group naming MUST return identical values for ASCII, integer, float, and nested-dictionary inputs, and MUST have its non-ASCII behaviour pinned by an explicit test vector. *Acceptance*: the hashing test retains its existing vectors and adds a non-ASCII vector asserting the new value. +- **FR-004**: The SDK MUST convert serialized output to text at every call site that requires text, so no consumer receives bytes where text is expected. *Acceptance*: type checking passes and the affected call sites' tests pass. +- **FR-005**: The SDK MUST correctly read and write JSON at every site that previously serialized directly to a file handle, adapting to in-memory serialization. *Acceptance*: a record-then-replay round-trip test passes. +- **FR-006**: The SDK MUST continue to catch malformed-JSON decode failures everywhere they are currently handled, with no gaps introduced by the library change. *Acceptance*: a decode test with invalid input raises and is caught as before. +- **FR-007**: The dependency manifest MUST add the new JSON library and remove the previous runtime library and its type stubs, with the lockfile refreshed. *Acceptance*: the manifest and lockfile reflect exactly this change and the project installs cleanly. +- **FR-008**: Non-string dictionary keys that previously serialized successfully MUST continue to serialize successfully at sites that handle arbitrary data. *Acceptance*: a serialization test with integer-keyed data passes. + +### Key Entities *(include if feature involves data)* + +- **Tracking group name**: an existing, persisted identifier derived in part from a hash of query parameters. Not a new entity; its derivation is affected only for non-ASCII parameter values (see Edge Cases). No change to its structure or public type. + +## Success Criteria *(mandatory)* + +### Measurable Outcomes + +- **SC-001**: Zero references to the legacy JSON libraries remain anywhere in `infrahub_sdk/`. +- **SC-002**: The full unit and integration test suite passes, and CLI/formatter output plus parameter-hash values for ASCII/integer/float inputs are byte-identical to the pre-migration baseline. +- **SC-003**: The migration ships as a single change with no split intermediate state in which two JSON libraries coexist in `infrahub_sdk/`. +- **SC-004**: Exactly one behavioural change is externally observable — the one-time tracking-group-name shift for non-ASCII parameters — and it is documented in the release notes. + +## Assumptions + +- Prebuilt distributions of the new JSON library are available for every supported interpreter (Python 3.10–3.14) on mainstream platforms, so end users do not build it from source. +- No downstream consumer depends on the CLI JSON date/time formatting beyond the textual form this migration preserves. +- Tracking-group name stability matters only for ASCII parameter values in practice; the one-time non-ASCII shift is acceptable churn. +- The performance benefit of the new library is well established; no benchmark harness or performance gate is in scope (parity is the bar). Verifying measurable speedups is explicitly out of scope for this migration. +- Scope is limited to `infrahub_sdk/`; JSON usage in tests is touched only where a test asserts serialized output. +- The parameter-hashing function keeps its current public signature and return type (text), so this is not a public API change. From a586a53d34a02d84534f32c80627c70273ca97e1 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Tue, 14 Jul 2026 16:27:06 +0000 Subject: [PATCH 02/17] docs(specs): add orjson migration plan artifacts Plan, research, data-model (call-site migration map), serialization contract, and quickstart for the orjson JSON standardization (plan phase). Co-Authored-By: Claude Opus 4.8 --- .../contracts/serialization-contract.md | 32 +++++ .../002-orjson-json-migration/data-model.md | 66 ++++++++++ dev/specs/002-orjson-json-migration/plan.md | 117 ++++++++++++++++++ .../002-orjson-json-migration/quickstart.md | 55 ++++++++ .../002-orjson-json-migration/research.md | 54 ++++++++ 5 files changed, 324 insertions(+) create mode 100644 dev/specs/002-orjson-json-migration/contracts/serialization-contract.md create mode 100644 dev/specs/002-orjson-json-migration/data-model.md create mode 100644 dev/specs/002-orjson-json-migration/plan.md create mode 100644 dev/specs/002-orjson-json-migration/quickstart.md create mode 100644 dev/specs/002-orjson-json-migration/research.md diff --git a/dev/specs/002-orjson-json-migration/contracts/serialization-contract.md b/dev/specs/002-orjson-json-migration/contracts/serialization-contract.md new file mode 100644 index 000000000..1376e59fd --- /dev/null +++ b/dev/specs/002-orjson-json-migration/contracts/serialization-contract.md @@ -0,0 +1,32 @@ +# Serialization behavioral contract + +This library exposes no new public API. The contract this migration MUST preserve is behavioral — what existing callers and consumers observe. + +## Preserved (MUST NOT change) + +1. **`infrahub_sdk.utils.dict_hash(dict) -> str`** — signature and return type unchanged. Returns the same MD5 hex string as before for any input whose values are ASCII strings, integers, floats, booleans, `None`, or nested dicts/lists thereof. + - Committed vectors that MUST still hold: + - `{"a": 1, "b": 2}` → `608de49a4600dbb5b173492759792e4a` + - `{"b": 2, "a": {"c": 1, "d": 2}}` → `4d8f1a3d03e0b487983383d0ff984d13` + - `{}` → `99914b932bd37a50b983c5e7c90ae93b` + +2. **CLI JSON output** (`infrahubctl` via `ctl/formatters/json.py`, `ctl/validate.py`, `ctl/cli_commands.py`) — same indentation (2 spaces) and same textual rendering of date/time values (`str()` form, space separator) as before. + +3. **Decode-error handling** — every input that previously raised a caught JSON decode error still raises one that is caught at the same site (no `except` gap). + +4. **Serialized payloads sent to the API** (`graphql/multipart.py`, `graphql/renderers.py`) — semantically equivalent JSON; multipart form fields remain `str`. + +5. **Recorded fixtures** (`recorder.py` → `playback.py`) — a file written by the recorder is readable by playback and yields the same decoded object. + +## Allowed to change (documented) + +1. **`dict_hash` for non-ASCII values** — returns a different (but stable) hash than the previous library. Consequence: the persisted query-group name derived from such params changes once on upgrade. Pinned by a new test vector; noted in release notes. + +2. **Human-facing pretty-print width** — debug prints and pytest failure/diff output change from 4-space to 2-space indentation. Not machine-consumed, not compared. + +## Contract tests + +- `tests/unit/sdk/test_utils.py::test_dict_hash` — retains the three vectors above; adds a non-ASCII vector asserting the new orjson value. +- Existing CLI/formatter unit tests — pass unchanged (parity check for items 2 & 4). +- A record→replay round-trip test — covers item 5. +- A decode-of-invalid-input test — covers item 3. diff --git a/dev/specs/002-orjson-json-migration/data-model.md b/dev/specs/002-orjson-json-migration/data-model.md new file mode 100644 index 000000000..d52392475 --- /dev/null +++ b/dev/specs/002-orjson-json-migration/data-model.md @@ -0,0 +1,66 @@ +# Data Model: orjson migration + +No new domain entities. This migration transforms serialization call sites. The "data model" here is the exhaustive call-site migration map — the source of truth for task generation. + +## No new entities + +The only data concept indirectly affected is the **query-group name**: an existing, persisted identifier partly derived from `dict_hash(params)`. Its structure and public type (`str`) are unchanged; only its value changes, and only for non-ASCII parameter values (see spec Edge Cases). + +## Call-site migration map + +Legend for transform: **D**=decode `.decode()` added · **OPT**=option flags · **EXC**=except-clause change · **FILE**=file-object rewrite · **COSMETIC**=4→2 indent width. + +### Encode sites (`dumps` / `dump`) + +| File:line | Current | Transform | +|---|---|---| +| `client.py:218` | `ujson.dumps(variables, indent=4)` (debug print) | `OPT_INDENT_2`, **D**, **COSMETIC** | +| `checks.py:112` | `print(ujson.dumps(log_message))` | **D** | +| `ctl/validate.py:107` | `ujson.dumps(response, indent=2, sort_keys=True)` | `OPT_INDENT_2 \| OPT_SORT_KEYS`, **D** | +| `ctl/cli_commands.py:355` | `ujson.dumps(result, indent=2, sort_keys=True)` | `OPT_INDENT_2 \| OPT_SORT_KEYS`, **D** | +| `ctl/telemetry.py:127` | `json.dumps(snapshots, indent=2)` → `write_text` | `OPT_INDENT_2`, **D** | +| `ctl/formatters/json.py:42,59` | `json.dumps(x, indent=2, default=str)` | `OPT_INDENT_2 \| OPT_PASSTHROUGH_DATETIME`, `default=str`, **D** | +| `graphql/multipart.py:46,60` | `ujson.dumps(operations)` (str for httpx) | **D**, `OPT_NON_STR_KEYS` | +| `graphql/renderers.py:54` | `json.dumps(value)` (string escaping) | **D** | +| `utils.py:253` | `ujson.dumps(dictionary, sort_keys=True).encode()` | `OPT_SORT_KEYS` (already bytes; drop `.encode()`) | +| `playback.py:52` | `str(json.dumps(payload)).encode("UTF-8")` | `orjson.dumps(payload)` (already bytes; drop wrappers) | +| `recorder.py:59` | `ujson.dump(data, fobj, indent=4, sort_keys=True)` | `OPT_INDENT_2 \| OPT_SORT_KEYS`, **FILE**, **COSMETIC** | +| `transfer/exporter/json.py:151,155,166` | `ujson.dumps(...)` → `write_text` | **D**, `OPT_NON_STR_KEYS` | +| `pytest_plugin/items/check.py:52` | `ujson.dumps(resp.json(), indent=4)` | `OPT_INDENT_2`, **D**, **COSMETIC** | +| `pytest_plugin/items/graphql_query.py:31` | same | `OPT_INDENT_2`, **D**, **COSMETIC** | +| `pytest_plugin/items/python_transform.py:54` | same | `OPT_INDENT_2`, **D**, **COSMETIC** | +| `pytest_plugin/items/jinja2_transform.py:63` | `... indent=4, sort_keys=True` | `OPT_INDENT_2 \| OPT_SORT_KEYS`, **D**, **COSMETIC** | +| `pytest_plugin/items/base.py:62,63` | `ujson.dumps(x, indent=4, sort_keys=True).splitlines()` | `OPT_INDENT_2 \| OPT_SORT_KEYS`, **D**, **COSMETIC** | + +### Decode sites (`loads` / `load` / `response.json()`) + +| File:line | Current | Transform | +|---|---|---| +| `utils.py:97` | `except json.decoder.JSONDecodeError` on `response.json()` | switch decode to `orjson.loads(response.content)`; **EXC** → `orjson.JSONDecodeError` | +| `schema/__init__.py:277` | `except json.decoder.JSONDecodeError` | same strategy; **EXC** | +| `ctl/parsers.py:27,28` | `json.loads(stripped)` + `except json.JSONDecodeError` | `orjson.loads`, **EXC** | +| `template/infrahub_filters.py:167,168` | `json.loads(value)` + `except (json.JSONDecodeError, TypeError)` | `orjson.loads`, **EXC** (keep `TypeError`) | +| `playback.py:57` | `ujson.load(fobj)` | `orjson.loads(fobj.read())`, **FILE** | +| `transfer/importer/json.py:67,150` | `ujson.loads(...)` | `orjson.loads(...)` | +| `pytest_plugin/models.py:57` | `ujson.loads(text)` | `orjson.loads(text)` | +| `pytest_plugin/items/{check,graphql_query,python_transform,jinja2_transform}.py` | `except ujson.JSONDecodeError` | **EXC** → matching orjson exception for the paired decode | + +### Import + dependency changes + +| File | Change | +|---|---| +| all 21 modules above | replace `import ujson` / `import json` with `import orjson` (remove both legacy imports) | +| `utils.py`, `playback.py` | remove the **dual** import (both `json` and `ujson`) | +| `pyproject.toml` | `- "ujson>=5"`, `+ "orjson>=3.10"`; dev: `- "types-ujson"` | +| `uv.lock` | refresh | + +## Validation rules (from requirements) + +- Zero `import ujson` / `import json` remain in `infrahub_sdk/` (FR-001). +- `indent=2` and CLI-formatter output byte-identical; datetime text form unchanged (FR-002). +- `dict_hash` unchanged for the 3 vectors + new non-ASCII vector pinned (FR-003). +- No bytes leak where str is expected (FR-004). +- Record→replay round-trip passes (FR-005). +- Malformed JSON still caught at every current site (FR-006). +- Manifest/lock reflect exactly the add/remove (FR-007). +- Int-keyed export payloads still serialize (FR-008). diff --git a/dev/specs/002-orjson-json-migration/plan.md b/dev/specs/002-orjson-json-migration/plan.md new file mode 100644 index 000000000..f0924a568 --- /dev/null +++ b/dev/specs/002-orjson-json-migration/plan.md @@ -0,0 +1,117 @@ +# Implementation Plan: Standardize SDK JSON serialization on orjson + +**Branch**: `dga/feat-orjson-pd5o6` | **Date**: 2026-07-14 | **Spec**: [spec.md](./spec.md) + +**Input**: Feature specification from `specs/002-orjson-json-migration/spec.md` + +## Summary + +Replace all JSON serialization in `infrahub_sdk/` — currently split across `ujson` (16 modules) and stdlib `json` (8 modules, 2 of which also use `ujson`) — with `orjson` as the sole JSON library. The migration is behaviour-preserving: byte-identical output at every consumed/compared site, unchanged parameter-hash values for ASCII/int/float inputs, and no gaps in decode-error handling. The single externally observable change is a one-time query-group-name shift for non-ASCII parameter values, which is accepted, documented, and pinned by a test. + +## Technical Context + +**Language/Version**: Python 3.10–3.14 (`requires-python = ">=3.10,<3.15"`) + +**Primary Dependencies**: orjson (new, `>=3.10`); removes ujson + types-ujson. Existing: pydantic, httpx, graphql-core. + +**Storage**: N/A (library). Touches on-disk recorded fixtures (recorder/playback) and telemetry/export files only as JSON text. + +**Testing**: pytest (`tests/unit`, `tests/integration`). + +**Target Platform**: Cross-platform library; orjson ships prebuilt wheels for CPython 3.10–3.14 on Linux/macOS/Windows. + +**Project Type**: Single Python library + CLI (`infrahubctl`) + pytest plugin. + +**Performance Goals**: Parity bar only — trust orjson's documented speedup; no benchmark harness or perf gate in scope. + +**Constraints**: No behaviour change to serialized output, parameter hashing (ASCII/int/float), or decode-error handling. No public API signature changes. + +**Scale/Scope**: ~35 call sites across 21 modules. + +## Constitution Check + +*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.* + +The project constitution (`.specify/memory/constitution.md`) is an unpopulated template stub — it defines no ratified principles. There are therefore no constitution gates to evaluate. Project working agreements from `AGENTS.md` that do apply: + +- **New dependency (ask-first gate)**: adding `orjson` — CONFIRMED by maintainer. ✅ +- **Async/sync dual pattern**: not affected (serialization is synchronous at all sites). ✅ +- **Generated code (`protocols.py`)**: not touched. ✅ +- **Type hints on all signatures**: preserved; orjson ships its own stubs. ✅ + +No violations. Complexity Tracking not required. + +## Project Structure + +### Documentation (this feature) + +```text +specs/002-orjson-json-migration/ +├── plan.md # This file +├── research.md # Phase 0 output — library-behavior decisions +├── data-model.md # Phase 1 output — call-site migration map +├── quickstart.md # Phase 1 output — validation guide +├── contracts/ +│ └── serialization-contract.md # Behavioral contract this migration must preserve +└── checklists/ + └── requirements.md # Spec quality checklist (specify phase) +``` + +### Source Code (repository root) + +Files touched, grouped by concern (all under `infrahub_sdk/`): + +```text +infrahub_sdk/ +├── utils.py # dict_hash (encode), decode_json (decode-error) +├── client.py # debug-print dumps +├── checks.py # print dumps +├── playback.py # dump + file-object load (removes dual import) +├── recorder.py # file-object dump (indent+sort_keys) +├── query_groups.py # consumer of dict_hash (no edit; behavior noted) +├── graphql/ +│ ├── multipart.py # dumps -> str for httpx +│ └── renderers.py # dumps for string escaping +├── ctl/ +│ ├── validate.py # dumps indent=2 sort_keys +│ ├── cli_commands.py # dumps indent=2 sort_keys +│ ├── parsers.py # loads + decode-error +│ ├── telemetry.py # dumps indent=2 -> file +│ └── formatters/json.py # dumps indent=2 default=str (+datetime passthrough) +├── template/infrahub_filters.py # loads + decode-error +├── schema/__init__.py # decode-error +├── transfer/exporter/json.py # dumps -> file (arbitrary data: non-str keys) +├── transfer/importer/json.py # loads +└── pytest_plugin/ + ├── models.py # loads + └── items/{base,check,graphql_query,python_transform,jinja2_transform}.py + # dumps indent=4 + decode-error +``` + +**Structure Decision**: Existing single-package layout; no new modules or restructuring. The change is a mechanical, site-by-site substitution plus targeted adaptations for the four orjson API differences (bytes return, no file-object I/O, no `sort_keys`/`indent` kwargs → options, strict non-str keys). + +## Design approach + +Four orjson API differences drive every adaptation. The mapping is fixed and applied uniformly: + +| ujson / stdlib call | orjson replacement | +|---|---| +| `dumps(x)` (str consumed) | `orjson.dumps(x).decode()` | +| `dumps(x, sort_keys=True)` | `orjson.dumps(x, option=orjson.OPT_SORT_KEYS)` | +| `dumps(x, indent=2 [,sort_keys])` | `orjson.dumps(x, option=orjson.OPT_INDENT_2 [\| orjson.OPT_SORT_KEYS])` (byte-identical to stdlib indent=2) | +| `dumps(x, indent=4)` | `orjson.dumps(x, option=orjson.OPT_INDENT_2)` (**4→2 width, cosmetic; human-facing only**) | +| `dumps(x, default=str)` (CLI formatter) | `orjson.dumps(x, option=orjson.OPT_INDENT_2 \| orjson.OPT_PASSTHROUGH_DATETIME, default=str)` | +| `dumps(arbitrary_data)` (export) | add `option=orjson.OPT_NON_STR_KEYS` to preserve silent int-key coercion | +| `loads(s)` | `orjson.loads(s)` (accepts str or bytes) | +| `load(fobj)` | `orjson.loads(fobj.read())` | +| `dump(x, fobj, ...)` | `fobj.write(orjson.dumps(x, option=...).decode())` | + +**Decode-error strategy (resolves the #1165 hazard).** `orjson.JSONDecodeError` subclasses both `json.JSONDecodeError` and `ValueError` (verified). To remove stdlib `json` entirely *and* keep every `except` correct, decode sites that currently rely on httpx's stdlib-backed `response.json()` are switched to decode explicitly via `orjson.loads(response.content)`, so the raised error is `orjson.JSONDecodeError` and the paired `except orjson.JSONDecodeError` is exact. Pure `loads` sites (`parsers.py`, `infrahub_filters.py`, `pytest_plugin`) swap library and matching `except` together. This makes decode + except internally consistent everywhere — the exact ambiguity #1165 flagged. + +**dict_hash.** `ujson.dumps(d, sort_keys=True).encode()` → `orjson.dumps(d, option=orjson.OPT_SORT_KEYS)` (already bytes). Byte-identical for ASCII/int/float/nested (verified against the three committed MD5 vectors); diverges only for non-ASCII (orjson emits raw UTF-8 vs ujson `\uXXXX`). Accepted + pinned by a new test vector. + +**Atomicity.** All sites migrate in one change set; the intermediate two-library state is never committed to `main`. + +## Complexity Tracking + +No constitution violations; section not required. diff --git a/dev/specs/002-orjson-json-migration/quickstart.md b/dev/specs/002-orjson-json-migration/quickstart.md new file mode 100644 index 000000000..3c42e731f --- /dev/null +++ b/dev/specs/002-orjson-json-migration/quickstart.md @@ -0,0 +1,55 @@ +# Quickstart: validating the orjson migration + +Prerequisites: `uv sync --all-groups --all-extras` (installs orjson, removes ujson). + +## 1. No legacy JSON library remains (FR-001 / SC-001) + +```bash +# Must return nothing: +grep -rn "import ujson" infrahub_sdk/ +grep -rn "^import json\|^\s*import json$" infrahub_sdk/ +# Must show orjson only: +grep -rln "import orjson" infrahub_sdk/ | wc -l +``` + +Expected: first two commands print nothing; orjson import count matches the migrated module count. + +## 2. Dependency manifest (FR-007) + +```bash +grep -n "orjson\|ujson" pyproject.toml # orjson>=3.10 present; no ujson / types-ujson +uv lock --check # lockfile consistent +``` + +## 3. Parameter hashing parity (FR-003) + +```bash +uv run pytest tests/unit/sdk/test_utils.py::test_dict_hash -q +``` + +Expected: the three committed vectors still pass; the added non-ASCII vector asserts the new pinned value. + +## 4. Full behavioral parity (FR-002, FR-004, FR-006, FR-008 / SC-002) + +```bash +uv run pytest tests/unit/ -q # includes CLI/formatter output, decode-error, export paths +uv run invoke lint-code # ty/mypy catch any bytes-where-str-expected leak +``` + +Expected: green. Type checking passing confirms no `bytes` leaked into `str` call sites. + +## 5. Record → replay round-trip (FR-005) + +Exercise the recorder/playback path (a targeted unit test, or a scripted record-then-replay) and confirm the replayed object equals the recorded input. + +## 6. Integration suite (SC-002) + +```bash +uv run pytest tests/integration/ -q +``` + +Expected: green, no behavioural change to queries, exports, generators, or checks. + +## Done criteria + +All of the above pass, and the only externally observable change is the one-time non-ASCII query-group-name shift (SC-004), captured in release notes. diff --git a/dev/specs/002-orjson-json-migration/research.md b/dev/specs/002-orjson-json-migration/research.md new file mode 100644 index 000000000..b0ee2fdd2 --- /dev/null +++ b/dev/specs/002-orjson-json-migration/research.md @@ -0,0 +1,54 @@ +# Research: orjson migration + +All findings below were verified empirically against orjson and the current codebase, not taken from documentation alone. + +## Decision 1 — orjson as the single library; remove ujson AND stdlib json + +**Decision**: orjson is the only JSON library in `infrahub_sdk/`. Remove `ujson` (runtime + `types-ujson`) and all stdlib `json` imports. Add `orjson>=3.10`. + +**Rationale**: One library removes the dual-library decode-error hazard and the "which one do I use" ambiguity, and orjson (Rust/serde_json) is faster on the SDK's hot path. `>=3.10` guarantees wheels for all supported interpreters (3.10–3.14) and all options in use. + +**Alternatives considered**: + +- *Standardize on ujson first, orjson later*: two passes re-touching the same sites; rejected. +- *orjson + stdlib json for exceptions*: keeps two libraries and needs a written exception list; rejected once it was confirmed no pretty-print output is contractual. + +## Decision 2 — Byte-parity of pretty-printed output + +**Decision**: `indent=2` sites migrate to `OPT_INDENT_2` unchanged; `indent=4` human-facing sites accept a cosmetic 4→2 width shift. + +**Rationale**: Verified `orjson.dumps(d, option=OPT_INDENT_2)` is byte-identical to `json.dumps(d, indent=2)` (same `\n`, same `": "`/`","` separators). All `indent=4` occurrences are debug prints, pytest failure/diff messages, or recorder fixture files read back by a whitespace-agnostic loader — none are compared programmatically. + +**Alternatives considered**: Preserving 4-space width by keeping stdlib json at those sites — rejected; no contract to protect. + +## Decision 3 — datetime rendering in CLI JSON + +**Decision**: `ctl/formatters/json.py` uses `option=OPT_INDENT_2 | OPT_PASSTHROUGH_DATETIME` with `default=str`. + +**Rationale**: orjson natively renders datetimes as RFC 3339 (`T` separator), which would change `infrahubctl` output. `OPT_PASSTHROUGH_DATETIME` routes datetimes back through `default=str`, producing `"2026-07-14 00:00:00"` — verified byte-identical to today's stdlib `default=str` output. + +## Decision 4 — Decode-error handling / stdlib removal + +**Decision**: Decode sites relying on httpx `response.json()` switch to `orjson.loads(response.content)`; pure `loads` sites swap library and their paired `except` together. All `except` clauses use `orjson.JSONDecodeError`. + +**Rationale**: Verified `orjson.JSONDecodeError` is a subclass of both `json.JSONDecodeError` and `ValueError`, and that `except json.JSONDecodeError` catches an orjson decode failure. Decoding explicitly via orjson makes each decode+except pair internally consistent and lets stdlib `json` be removed entirely — directly resolving the #1165 hazard. + +**Alternatives considered**: Catching `ValueError` (broader, orjson-only) — viable but less precise; keeping `import json` only for the `except` type — rejected (violates zero-stdlib-json). + +## Decision 5 — dict_hash / query-group naming stability + +**Decision**: `orjson.dumps(d, option=OPT_SORT_KEYS)`; accept non-ASCII divergence, pin with a test vector, note in release notes. + +**Rationale**: Verified byte-identical output (hence identical MD5) to `ujson.dumps(d, sort_keys=True).encode()` for the three committed vectors and for int/float/nested inputs. Divergence occurs only for non-ASCII string values (orjson raw UTF-8 vs ujson escaped). `dict_hash` feeds `query_groups.py:68`, so a changed hash changes a persisted group name once on upgrade (old group orphaned). Judged low-incidence and acceptable. + +## Decision 6 — Non-string dict keys + +**Decision**: Add `OPT_NON_STR_KEYS` at arbitrary-data serialization sites (`transfer/exporter/json.py`, `graphql/multipart.py`). + +**Rationale**: Verified orjson raises `TypeError("Dict key must be str")` for non-str keys unless the option is set; with it, keys coerce to strings (`{1:'a'}` → `{"1":"a"}`), matching ujson's prior silent behaviour and preventing a regression on previously-working payloads. + +## Decision 7 — File-object I/O + +**Decision**: Rewrite `recorder.py` (`ujson.dump(x, fobj, ...)` → `fobj.write(orjson.dumps(x, option=...).decode())`) and `playback.py` (`ujson.load(fobj)` → `orjson.loads(fobj.read())`). + +**Rationale**: orjson exposes only `dumps`/`loads` on in-memory bytes; it has no `load`/`dump` file-handle API. A record-then-replay round-trip test confirms parity. From 618428def484ddab546156a5bea763099ff1d258 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Tue, 14 Jul 2026 16:30:41 +0000 Subject: [PATCH 03/17] docs(specs): add orjson migration critique + apply recommendations Dual-lens critique (verdict: PROCEED, no must-address). Applied low-risk recommendations to spec/plan: NaN/Infinity + special-type edge cases, characterization-test acceptance, CI-matrix wheel note, orphan-cleanup note. Co-Authored-By: Claude Opus 4.8 --- .../critiques/critique-20260714.md | 46 +++++++++++++++++++ dev/specs/002-orjson-json-migration/plan.md | 8 ++++ dev/specs/002-orjson-json-migration/spec.md | 8 ++-- 3 files changed, 59 insertions(+), 3 deletions(-) create mode 100644 dev/specs/002-orjson-json-migration/critiques/critique-20260714.md diff --git a/dev/specs/002-orjson-json-migration/critiques/critique-20260714.md b/dev/specs/002-orjson-json-migration/critiques/critique-20260714.md new file mode 100644 index 000000000..394745ba0 --- /dev/null +++ b/dev/specs/002-orjson-json-migration/critiques/critique-20260714.md @@ -0,0 +1,46 @@ +# Critique: Standardize SDK JSON serialization on orjson + +**Date**: 2026-07-14 · **Artifacts reviewed**: spec.md, plan.md, research.md, data-model.md, contracts/serialization-contract.md + +## Executive Summary + +The spec and plan are sound and implementation-ready. This is a well-scoped, mechanical, behaviour-preserving migration with an explicit parity bar, a documented single behavioural change (non-ASCII query-group naming), and existing test coverage at the parity-critical sites (`tests/unit/ctl/formatters/test_json.py`, `tests/unit/sdk/test_file_object.py`). No must-address (🎯) findings. Several recommendations (💡) were applied to spec/plan to close small correctness and verification gaps surfaced by the engineering lens. **Verdict: ✅ PROCEED (with updates applied).** + +## Product Lens Findings + +- **3a Problem Validation** — Clear, evidenced by two tracked tech-debt issues (dual-library ambiguity + decode-error hazard). No concern. +- **3b User Value** — Value is twofold (perf on the hot path; contributor clarity). *P1 (💡)*: the feature is perf-motivated but SC-003 sets a parity-only bar with no measurement, so the SDK ships a performance change with no evidence of the win. This is a conscious decision (no benchmark harness in scope); recommend capturing one ad-hoc before/after number in the PR description. Applied as a note in the plan. +- **3c Alternatives** — Single-library-on-orjson correctly supersedes "standardize then migrate." No concern. +- **3d Edge Cases / Migration** — *P2 (💡)*: users with non-ASCII query params get an orphaned tracking group on upgrade. Spec documents the change (SC-004) but offered no user guidance. Applied: added an explicit out-of-scope note that stale-group cleanup is manual and belongs to release notes. +- **3e Success Measurement** — Success criteria are measurable and verifiable (grep counts, test-suite green, byte-identical output). Rollback for a library = revert the change. No concern. + +## Engineering Lens Findings + +- **4a Architecture** — Uniform site-by-site substitution against a fixed 9-row transform table; no structural change. Sound and testable. +- **4b Failure Modes** — + - *E1 (💡, investigated → not a blocker)*: orjson natively serializes `Enum`/dataclass/numpy where `default=str` would otherwise stringify them, which could break the byte-parity claim at `ctl/formatters/json.py`. Investigation of `extract_node_data`/`extract_node_detail` shows the value domain is primitives + `datetime` (covered by `OPT_PASSTHROUGH_DATETIME`) + str-rendered IP/UUID (same output). No Python `Enum`/dataclass objects flow through. Recommend a characterization test asserting a datetime-bearing node renders identically. Applied to spec (FR-002 acceptance) + plan. + - *E2 (💡)*: orjson `loads` **rejects** `NaN`/`Infinity`/`-Infinity` that stdlib accepted, and orjson `dumps(nan)` emits `null` where stdlib emitted invalid `NaN`. Both are non-standard JSON and unlikely in Infrahub API traffic, but it is a real decode/encode behaviour change. Applied: added as an explicit edge case in the spec. +- **4c Security & Privacy** — No new trust boundary; orjson (Rust/serde_json) is a mature, widely-used, permissively-licensed (Apache-2.0/MIT) dependency. No concern. +- **4d Performance & Scalability** — Strictly improves the hot path; no new bottleneck. No concern. +- **4e Testing** — Parity-critical sites have existing unit tests (formatters, file-object). *E3 (💡)*: pytest-plugin failure/diff output shifts 4→2 space; if any test asserts that exact text it will break. Applied: flagged in plan as a site to check during implementation; treat as characterization update, not a contract break. +- **4f Operational Readiness** — N/A (library); change is atomic and revertible. +- **4g Dependencies** — *E4 (💡)*: the "wheels for 3.10–3.14" assumption is stated but unverified locally. orjson 3.11.x publishes cp314 wheels; the CI matrix will confirm across interpreters. Applied: assumption reworded to "confirmed by the CI test matrix." + +## Cross-Lens Insights + +- *X1*: The migration's value/risk both rest on assumptions the plan now makes explicit and cheap to verify — perf (measure once in PR), type-parity (one characterization test), wheel coverage (CI matrix). None warrant blocking; all are captured. + +## Findings Summary Table + +| ID | Lens | Severity | Category | Finding | Suggestion | Status | +|----|------|----------|----------|---------|------------|--------| +| P1 | Product | 💡 | User Value | Perf-motivated but unmeasured | Capture one ad-hoc before/after in PR | Applied (plan note) | +| P2 | Product | 💡 | Migration | Non-ASCII group orphaning lacks user guidance | Note manual cleanup in release notes | Applied (spec out-of-scope) | +| E1 | Eng | 💡 | Failure Modes | orjson native special-type handling vs default=str | Characterization test on datetime node | Applied (FR-002) | +| E2 | Eng | 💡 | Failure Modes | orjson rejects NaN/Infinity on decode; nan→null on encode | Document as edge case | Applied (spec edge case) | +| E3 | Eng | 💡 | Testing | pytest-plugin 4→2 indent may break asserted text | Check/adjust during implementation | Applied (plan note) | +| E4 | Eng | 💡 | Dependencies | 3.14 wheel coverage unverified locally | Confirm via CI matrix | Applied (assumption reworded) | + +## Verdict + +✅ **PROCEED** — no must-address items. Recommendations applied to spec.md and plan.md. Ready for `/speckit-tasks`. diff --git a/dev/specs/002-orjson-json-migration/plan.md b/dev/specs/002-orjson-json-migration/plan.md index f0924a568..9747494fa 100644 --- a/dev/specs/002-orjson-json-migration/plan.md +++ b/dev/specs/002-orjson-json-migration/plan.md @@ -112,6 +112,14 @@ Four orjson API differences drive every adaptation. The mapping is fixed and app **Atomicity.** All sites migrate in one change set; the intermediate two-library state is never committed to `main`. +## Notes from critique (applied) + +- **Special-type parity**: `extract_node_data`/`extract_node_detail` yield primitives + `datetime` (passthrough) + string-rendered addresses/ids — no enum/dataclass objects — so orjson's native type handling does not break byte-parity. Add a characterization test on a date/time-bearing record to guard it (FR-002 acceptance). +- **pytest-plugin diff width**: the 4→2 indent shift changes failure/diff message text; check whether any test asserts that exact text and update it as a characterization change, not a contract break. +- **NaN/Infinity**: decoding now rejects them and `nan` encodes to `null` — documented as a spec edge case; no special handling planned. +- **Performance**: parity is the bar (no benchmark harness). Capture one ad-hoc before/after encode+decode number in the PR description to evidence the motivating speedup. +- **Interpreter coverage**: rely on the CI matrix (3.10–3.14) to confirm orjson wheel availability rather than a local check. + ## Complexity Tracking No constitution violations; section not required. diff --git a/dev/specs/002-orjson-json-migration/spec.md b/dev/specs/002-orjson-json-migration/spec.md index 1dbc08683..23c23ce97 100644 --- a/dev/specs/002-orjson-json-migration/spec.md +++ b/dev/specs/002-orjson-json-migration/spec.md @@ -35,13 +35,15 @@ At the same time, a contributor working inside the SDK now finds exactly one JSO - **Datetime rendering in CLI JSON output**: date/time values in `infrahubctl` JSON output must render in the same textual form as before, with no change visible to anyone parsing that output. - **File-based serialization**: sites that read or write JSON directly to file handles must be rewritten, because the new library serializes and deserializes via in-memory bytes rather than file handles. - **Human-facing pretty-printed output** (debug prints, test-failure diffs): indentation width may change cosmetically; this output carries no contract and is not compared programmatically. +- **Non-standard numeric literals**: decoding rejects `NaN`, `Infinity`, and `-Infinity` (previously accepted by stdlib decoding), and encoding a float `NaN` now yields `null` rather than an invalid `NaN` token. These are non-standard JSON and not expected in API traffic; the change is toward stricter, valid JSON and is documented rather than specially handled. +- **Special value types in CLI output**: the value domain rendered to CLI JSON is primitives plus date/time (preserved via the datetime edge case above) and string-rendered addresses/identifiers; no enum or dataclass objects flow through, so the byte-parity claim holds. A characterization test on a date/time-bearing record guards this. ## Requirements *(mandatory)* ### Functional Requirements - **FR-001**: The SDK MUST use a single JSON library throughout `infrahub_sdk/`; no module may reference the previously-used JSON libraries. *Acceptance*: a search of `infrahub_sdk/` finds zero references to the legacy libraries and lint passes. -- **FR-002**: The SDK MUST preserve JSON output byte-for-byte at every site where that output is consumed, compared, or shown to users, including preserving current indentation for machine-consumed output and preserving the current textual form of date/time values in CLI output. *Acceptance*: existing CLI/formatter tests pass unchanged. +- **FR-002**: The SDK MUST preserve JSON output byte-for-byte at every site where that output is consumed, compared, or shown to users, including preserving current indentation for machine-consumed output and preserving the current textual form of date/time values in CLI output. *Acceptance*: existing CLI/formatter tests pass unchanged, plus a characterization test asserting a date/time-bearing record renders identically to the pre-migration output. - **FR-003**: The parameter-hashing behaviour that feeds tracking-group naming MUST return identical values for ASCII, integer, float, and nested-dictionary inputs, and MUST have its non-ASCII behaviour pinned by an explicit test vector. *Acceptance*: the hashing test retains its existing vectors and adds a non-ASCII vector asserting the new value. - **FR-004**: The SDK MUST convert serialized output to text at every call site that requires text, so no consumer receives bytes where text is expected. *Acceptance*: type checking passes and the affected call sites' tests pass. - **FR-005**: The SDK MUST correctly read and write JSON at every site that previously serialized directly to a file handle, adapting to in-memory serialization. *Acceptance*: a record-then-replay round-trip test passes. @@ -64,9 +66,9 @@ At the same time, a contributor working inside the SDK now finds exactly one JSO ## Assumptions -- Prebuilt distributions of the new JSON library are available for every supported interpreter (Python 3.10–3.14) on mainstream platforms, so end users do not build it from source. +- Prebuilt distributions of the new JSON library are available for every supported interpreter (Python 3.10–3.14) on mainstream platforms, so end users do not build it from source; this is confirmed by the CI test matrix across the supported interpreter range. - No downstream consumer depends on the CLI JSON date/time formatting beyond the textual form this migration preserves. -- Tracking-group name stability matters only for ASCII parameter values in practice; the one-time non-ASCII shift is acceptable churn. +- Tracking-group name stability matters only for ASCII parameter values in practice; the one-time non-ASCII shift is acceptable churn. Cleanup of any group orphaned by that shift is manual and out of scope, called out in release notes if it matters to a given deployment. - The performance benefit of the new library is well established; no benchmark harness or performance gate is in scope (parity is the bar). Verifying measurable speedups is explicitly out of scope for this migration. - Scope is limited to `infrahub_sdk/`; JSON usage in tests is touched only where a test asserts serialized output. - The parameter-hashing function keeps its current public signature and return type (text), so this is not a public API change. From 95067ffc0744f5d02bca41cf226b381785eafcb7 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Tue, 14 Jul 2026 16:32:19 +0000 Subject: [PATCH 04/17] docs(specs): add orjson migration tasks.md Dependency-ordered task breakdown (34 tasks, single atomic P1 story) for the orjson JSON standardization (tasks phase). Co-Authored-By: Claude Opus 4.8 --- dev/specs/002-orjson-json-migration/tasks.md | 114 +++++++++++++++++++ 1 file changed, 114 insertions(+) create mode 100644 dev/specs/002-orjson-json-migration/tasks.md diff --git a/dev/specs/002-orjson-json-migration/tasks.md b/dev/specs/002-orjson-json-migration/tasks.md new file mode 100644 index 000000000..814c9b969 --- /dev/null +++ b/dev/specs/002-orjson-json-migration/tasks.md @@ -0,0 +1,114 @@ +# Tasks: Standardize SDK JSON serialization on orjson + +**Feature**: `specs/002-orjson-json-migration` · **Spec**: [spec.md](./spec.md) · **Plan**: [plan.md](./plan.md) + +**Input**: plan.md, spec.md, data-model.md (call-site migration map), research.md, contracts/serialization-contract.md + +This feature is a single, atomic P1 user story. Most migration tasks touch distinct files and are parallelizable `[P]`; they must all land in one change set (never commit the two-library intermediate state). The transform for each site is fixed by the plan's mapping table and the data-model call-site map — consult those, not intuition. + +--- + +## Phase 1: Setup (dependency swap) + +- [ ] T001 Swap the JSON dependency in `pyproject.toml`: remove `"ujson>=5"` from `dependencies` and `"types-ujson"` from the dev group; add `"orjson>=3.10"` to `dependencies`. +- [ ] T002 Refresh the lockfile and environment: run `uv lock` then `uv sync --all-groups --all-extras`; confirm orjson resolves and ujson is gone. + +**Checkpoint**: orjson installed, ujson removed. Code still imports ujson/json and will not import-check until Phase 2 completes — expected during the atomic migration. + +--- + +## Phase 2: User Story 1 — Transparent JSON library migration (Priority: P1) 🎯 MVP + +**Goal**: orjson is the sole JSON library across `infrahub_sdk/`; all observable behaviour preserved except the documented non-ASCII query-group-name shift. + +**Independent test**: `grep -rn "import ujson\|^import json" infrahub_sdk/` returns nothing; full test suite green; CLI/formatter output and ASCII/int/float `dict_hash` values byte-identical to baseline. + +### Core / shared + +- [ ] T003 [US1] In `infrahub_sdk/utils.py`: migrate `dict_hash` (`orjson.dumps(dictionary, option=orjson.OPT_SORT_KEYS)`, drop the now-redundant `.encode()`) and `decode_json` (decode via `orjson.loads(response.content)`, change `except` to `orjson.JSONDecodeError`); remove both `import json` and `import ujson`, add `import orjson`. + +### Encode sites (each a distinct file → parallelizable) + +- [ ] T004 [P] [US1] `infrahub_sdk/client.py:218` debug print: `orjson.dumps(variables, option=orjson.OPT_INDENT_2).decode()`; swap import. +- [ ] T005 [P] [US1] `infrahub_sdk/checks.py:112` print: `orjson.dumps(log_message).decode()`; swap import. +- [ ] T006 [P] [US1] `infrahub_sdk/graphql/multipart.py:46,60`: `orjson.dumps(..., option=orjson.OPT_NON_STR_KEYS).decode()` (str for httpx); swap import. +- [ ] T007 [P] [US1] `infrahub_sdk/graphql/renderers.py:54` string-escaping: `orjson.dumps(value).decode()`; swap import (keep the escaping comment accurate). +- [ ] T008 [P] [US1] `infrahub_sdk/ctl/validate.py:107`: `orjson.dumps(response, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode()`; swap import. +- [ ] T009 [P] [US1] `infrahub_sdk/ctl/cli_commands.py:355`: same option combo + `.decode()`; swap import. +- [ ] T010 [P] [US1] `infrahub_sdk/ctl/telemetry.py:127`: `output_path.write_text(orjson.dumps(snapshots, option=orjson.OPT_INDENT_2).decode(), encoding="utf-8")`; swap import. +- [ ] T011 [P] [US1] `infrahub_sdk/ctl/formatters/json.py:42,59`: `orjson.dumps(x, option=orjson.OPT_INDENT_2 | orjson.OPT_PASSTHROUGH_DATETIME, default=str).decode()`; swap import; update the "Uses stdlib json" docstring. + +### Decode sites + +- [ ] T012 [P] [US1] `infrahub_sdk/ctl/parsers.py:27,28`: `orjson.loads(stripped)` + `except orjson.JSONDecodeError`; swap import. +- [ ] T013 [P] [US1] `infrahub_sdk/template/infrahub_filters.py:167,168`: `orjson.loads(value)` + `except (orjson.JSONDecodeError, TypeError)`; swap import. +- [ ] T014 [P] [US1] `infrahub_sdk/schema/__init__.py:277`: apply the decode-error strategy (decode explicitly via orjson where the value comes from `response.json()`; `except orjson.JSONDecodeError`); swap import. +- [ ] T015 [P] [US1] `infrahub_sdk/transfer/importer/json.py:67,150`: `orjson.loads(...)`; swap import. +- [ ] T016 [P] [US1] `infrahub_sdk/pytest_plugin/models.py:57`: `orjson.loads(text)`; swap import. + +### File-object I/O (no `load`/`dump` in orjson) + +- [ ] T017 [P] [US1] `infrahub_sdk/recorder.py:59`: `fobj.write(orjson.dumps(data, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode())`; swap import. +- [ ] T018 [P] [US1] `infrahub_sdk/playback.py:52,57`: encode `orjson.dumps(payload)` (drop `str(...).encode()` wrappers — already bytes), read `orjson.loads(fobj.read())`; remove both `import json` and `import ujson`, add `import orjson`. + +### Export (arbitrary data → preserve non-str-key coercion) + +- [ ] T019 [P] [US1] `infrahub_sdk/transfer/exporter/json.py:151,155,166`: `orjson.dumps(..., option=orjson.OPT_NON_STR_KEYS).decode()` for the `write_text` sites; swap import. + +### pytest-plugin items (dumps indent + paired except) + +- [ ] T020 [P] [US1] `infrahub_sdk/pytest_plugin/items/base.py:62,63`: `orjson.dumps(x, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode().splitlines()`; swap import. +- [ ] T021 [P] [US1] `infrahub_sdk/pytest_plugin/items/check.py:52,53`: `OPT_INDENT_2` + `.decode()`, and set the paired `except` to match the orjson decode of `response.json()`; swap import. +- [ ] T022 [P] [US1] `infrahub_sdk/pytest_plugin/items/graphql_query.py:31,32`: same pattern as T021. +- [ ] T023 [P] [US1] `infrahub_sdk/pytest_plugin/items/python_transform.py:54,55`: same pattern as T021. +- [ ] T024 [P] [US1] `infrahub_sdk/pytest_plugin/items/jinja2_transform.py:63,64`: `OPT_INDENT_2 | OPT_SORT_KEYS` + `.decode()` + paired except; swap import. + +**Checkpoint**: no `ujson`/stdlib-`json` imports remain; package imports cleanly. + +--- + +## Phase 3: Tests & verification (part of US1 acceptance) + +- [ ] T025 [US1] Extend `tests/unit/sdk/test_utils.py::test_dict_hash`: keep the three committed vectors (`608de4…`, `4d8f1a…`, `99914b…`) and add a non-ASCII vector (e.g. `{"x": "café"}`) asserting the new pinned orjson value (`dict_hash` of `b'{"x":"caf\xc3\xa9"}'`). +- [ ] T026 [US1] Add a characterization test for `ctl/formatters/json.py` asserting a record with a date/time value renders identically to the pre-migration `str()` form (guards the `OPT_PASSTHROUGH_DATETIME` decision). +- [ ] T027 [US1] Confirm `tests/unit/sdk/test_file_object.py` (or add a targeted test) covers a recorder→playback round-trip yielding the original object after the file-object rewrite. +- [ ] T028 [US1] Add/confirm a decode-of-invalid-input test proving malformed JSON still raises and is caught at a representative decode site (`decode_json`). +- [ ] T029 [P] [US1] Check whether any pytest-plugin test asserts exact failure/diff message text; update expected strings for the cosmetic 4→2 indent shift. + +--- + +## Phase 4: Polish & cross-cutting + +- [ ] T030 Verify zero legacy imports: `grep -rn "import ujson" infrahub_sdk/` and `grep -rn "^import json\|^\s*import json$" infrahub_sdk/` both return nothing (SC-001). +- [ ] T031 Run `uv run invoke format lint-code` — ty/mypy must be green (catches any `bytes`-where-`str`-expected leak, FR-004). +- [ ] T032 Run `uv run pytest tests/unit/` then `uv run pytest tests/integration/` — all green (SC-002). +- [ ] T033 Add a release-note entry documenting the one-time non-ASCII `dict_hash`/query-group-name change (SC-004); grep docs for any `ujson` references and update. +- [ ] T034 Capture one ad-hoc before/after encode+decode timing on a representative payload in the PR description (evidence for the motivating speedup; not a committed benchmark). + +--- + +## Dependencies & execution order + +- **T001 → T002** (Setup) must complete first; orjson must be importable. +- **T003–T024** (migration) all depend on T002 and are mutually independent `[P]` (distinct files); they form the atomic change set. +- **T025–T029** (tests) depend on the corresponding migration tasks landing. +- **T030–T034** (polish) run last, after the full change set is in place. + +## Parallel execution example + +After T002, launch the migration in parallel — e.g. one agent per file cluster: + +```text +T004 client.py │ T005 checks.py │ T006 multipart.py │ T008 validate.py │ +T011 formatters/json.py │ T017 recorder.py │ T018 playback.py │ T020–T024 pytest_plugin/* +``` + +T003 (utils.py) is the one shared-concern file; do it first or in isolation to avoid churn. + +## MVP scope + +The entire feature is the MVP: a single atomic P1 story. There is no smaller shippable slice — a partial migration would leave two JSON libraries coexisting, which is the exact state this work removes. + +## Format validation + +All tasks use `- [ ] Txxx [P?] [US1?] description + file path`. Setup/Polish tasks carry no story label; migration/test tasks carry `[US1]`; parallelizable distinct-file tasks carry `[P]`. From e21d4e238f827301c5e5156b9ccb6e195c4320b9 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Tue, 14 Jul 2026 16:32:46 +0000 Subject: [PATCH 05/17] docs(specs): record alignment check (skipped, no source PRD) Co-Authored-By: Claude Opus 4.8 --- dev/specs/002-orjson-json-migration/alignment-check.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 dev/specs/002-orjson-json-migration/alignment-check.md diff --git a/dev/specs/002-orjson-json-migration/alignment-check.md b/dev/specs/002-orjson-json-migration/alignment-check.md new file mode 100644 index 000000000..e61de4554 --- /dev/null +++ b/dev/specs/002-orjson-json-migration/alignment-check.md @@ -0,0 +1,9 @@ +# Spec/Ask Alignment Check + +**Source**: None. The `/speckit-opsmill-prep` invocation had empty input; the spec was seeded from the in-session idea brief produced by the grilling interview, not from an external PRD (no URL, no inline requirements document). + +**Verdict**: ⚠️ SKIPPED — no source-of-truth PRD to align against. + +**Findings**: N/A (nothing to compare). + +**Action**: Proceed. The spec's fidelity rests on the grilling-phase decisions captured directly in this session (scope = orjson-only; dict_hash non-ASCII drift accepted; datetime passthrough; parity-only success bar; dependency swap confirmed). Those decisions are reflected in spec.md and were validated by the Phase 3 critique. From 7d14a09217f2e0c80dea99c5520b071d4245f000 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Wed, 15 Jul 2026 04:20:19 +0000 Subject: [PATCH 06/17] build(deps): add orjson dependency Add orjson>=3.10 to project dependencies as the first step of the JSON serialization migration. ujson is retained for now so unmigrated modules keep importing during the atomic migration; its removal is deferred to the final polish step. Co-Authored-By: Claude Opus 4.8 --- dev/specs/002-orjson-json-migration/tasks.md | 4 ++-- pyproject.toml | 1 + uv.lock | 2 ++ 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/dev/specs/002-orjson-json-migration/tasks.md b/dev/specs/002-orjson-json-migration/tasks.md index 814c9b969..a517a67d9 100644 --- a/dev/specs/002-orjson-json-migration/tasks.md +++ b/dev/specs/002-orjson-json-migration/tasks.md @@ -10,8 +10,8 @@ This feature is a single, atomic P1 user story. Most migration tasks touch disti ## Phase 1: Setup (dependency swap) -- [ ] T001 Swap the JSON dependency in `pyproject.toml`: remove `"ujson>=5"` from `dependencies` and `"types-ujson"` from the dev group; add `"orjson>=3.10"` to `dependencies`. -- [ ] T002 Refresh the lockfile and environment: run `uv lock` then `uv sync --all-groups --all-extras`; confirm orjson resolves and ujson is gone. +- [X] T001 Swap the JSON dependency in `pyproject.toml`: remove `"ujson>=5"` from `dependencies` and `"types-ujson"` from the dev group; add `"orjson>=3.10"` to `dependencies`. (ujson/types-ujson removal deferred to the final polish chunk, T033-area, so unmigrated modules keep importing during the atomic migration.) +- [X] T002 Refresh the lockfile and environment: run `uv lock` then `uv sync --all-groups --all-extras`; confirm orjson resolves and ujson is gone. (orjson resolves alongside ujson; ujson removal deferred to T033-area polish chunk.) **Checkpoint**: orjson installed, ujson removed. Code still imports ujson/json and will not import-check until Phase 2 completes — expected during the atomic migration. diff --git a/pyproject.toml b/pyproject.toml index c394faede..1cceabefe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ dependencies = [ "graphql-core>=3.1,<3.3", "httpx>=0.20", "ujson>=5", + "orjson>=3.10", "dulwich>=0.24.7", "whenever>=0.9.3,<0.10.0", "netutils>=1.0.0", diff --git a/uv.lock b/uv.lock index ef6418cab..29ca4dd10 100644 --- a/uv.lock +++ b/uv.lock @@ -682,6 +682,7 @@ dependencies = [ { name = "graphql-core" }, { name = "httpx" }, { name = "netutils" }, + { name = "orjson" }, { name = "pydantic" }, { name = "pydantic-settings" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -781,6 +782,7 @@ requires-dist = [ { name = "numpy", marker = "python_full_version >= '3.12' and extra == 'ctl'", specifier = ">=1.26.2" }, { name = "numpy", marker = "python_full_version < '3.12' and extra == 'all'", specifier = ">=1.24.2" }, { name = "numpy", marker = "python_full_version < '3.12' and extra == 'ctl'", specifier = ">=1.24.2" }, + { name = "orjson", specifier = ">=3.10" }, { name = "pyarrow", marker = "extra == 'all'", specifier = ">=14" }, { name = "pyarrow", marker = "extra == 'ctl'", specifier = ">=14" }, { name = "pydantic", specifier = ">=2.0.0,!=2.0.1,!=2.1.0,<3.0.0" }, From 015e1efc0d9dc4a08b98a3b590529f91171bb874 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Wed, 15 Jul 2026 04:24:12 +0000 Subject: [PATCH 07/17] feat(utils): migrate dict_hash and decode_json to orjson Replace ujson/stdlib-json usage in utils.py with orjson: - dict_hash now uses orjson.dumps(option=OPT_SORT_KEYS) (already bytes) - decode_json decodes via orjson.loads(response.content) and catches orjson.JSONDecodeError, keeping decode + except internally consistent - adapt the existing decode_json tests to drive response.content Co-Authored-By: Claude Opus 4.8 --- dev/specs/002-orjson-json-migration/tasks.md | 2 +- infrahub_sdk/utils.py | 9 ++++----- tests/unit/sdk/test_utils.py | 7 +++---- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/dev/specs/002-orjson-json-migration/tasks.md b/dev/specs/002-orjson-json-migration/tasks.md index a517a67d9..34a4862eb 100644 --- a/dev/specs/002-orjson-json-migration/tasks.md +++ b/dev/specs/002-orjson-json-migration/tasks.md @@ -25,7 +25,7 @@ This feature is a single, atomic P1 user story. Most migration tasks touch disti ### Core / shared -- [ ] T003 [US1] In `infrahub_sdk/utils.py`: migrate `dict_hash` (`orjson.dumps(dictionary, option=orjson.OPT_SORT_KEYS)`, drop the now-redundant `.encode()`) and `decode_json` (decode via `orjson.loads(response.content)`, change `except` to `orjson.JSONDecodeError`); remove both `import json` and `import ujson`, add `import orjson`. +- [X] T003 [US1] In `infrahub_sdk/utils.py`: migrate `dict_hash` (`orjson.dumps(dictionary, option=orjson.OPT_SORT_KEYS)`, drop the now-redundant `.encode()`) and `decode_json` (decode via `orjson.loads(response.content)`, change `except` to `orjson.JSONDecodeError`); remove both `import json` and `import ujson`, add `import orjson`. ### Encode sites (each a distinct file → parallelizable) diff --git a/infrahub_sdk/utils.py b/infrahub_sdk/utils.py index 95da7504d..160e55282 100644 --- a/infrahub_sdk/utils.py +++ b/infrahub_sdk/utils.py @@ -2,7 +2,6 @@ import base64 import hashlib -import json import uuid from itertools import groupby from pathlib import Path @@ -10,7 +9,7 @@ from uuid import UUID, uuid4 import httpx -import ujson +import orjson from graphql import ( FieldNode, InlineFragmentNode, @@ -93,8 +92,8 @@ def is_valid_uuid(value: Any) -> bool: def decode_json(response: httpx.Response) -> dict: try: - return response.json() - except json.decoder.JSONDecodeError as exc: + return orjson.loads(response.content) + except orjson.JSONDecodeError as exc: raise JsonDecodeError(content=response.text, url=str(response.url)) from exc @@ -250,7 +249,7 @@ def dict_hash(dictionary: dict[str, Any]) -> str: """MD5 hash of a dictionary.""" # We need to sort arguments so {'a': 1, 'b': 2} is # the same as {'b': 2, 'a': 1} - encoded = ujson.dumps(dictionary, sort_keys=True).encode() + encoded = orjson.dumps(dictionary, option=orjson.OPT_SORT_KEYS) dhash = hashlib.md5(encoded, usedforsecurity=False) return dhash.hexdigest() diff --git a/tests/unit/sdk/test_utils.py b/tests/unit/sdk/test_utils.py index 97ea6d27d..79782bf7a 100644 --- a/tests/unit/sdk/test_utils.py +++ b/tests/unit/sdk/test_utils.py @@ -1,4 +1,3 @@ -import json import tempfile import uuid from dataclasses import dataclass @@ -263,7 +262,7 @@ def test_calculate_time_diff() -> None: def test_decode_json_success() -> None: """Test decode_json with valid JSON response.""" mock_response = Mock() - mock_response.json.return_value = {"status": "ok", "data": {"key": "value"}} + mock_response.content = b'{"status": "ok", "data": {"key": "value"}}' result = decode_json(mock_response) assert result == {"status": "ok", "data": {"key": "value"}} @@ -272,7 +271,7 @@ def test_decode_json_success() -> None: def test_decode_json_failure_with_content() -> None: """Test decode_json with invalid JSON response includes server content in error message.""" mock_response = Mock() - mock_response.json.side_effect = json.decoder.JSONDecodeError("Invalid JSON", "document", 0) + mock_response.content = b"Internal Server Error: Database connection failed" mock_response.text = "Internal Server Error: Database connection failed" mock_response.url = "https://example.com/api/graphql" @@ -287,7 +286,7 @@ def test_decode_json_failure_with_content() -> None: def test_decode_json_failure_without_content() -> None: """Test decode_json with invalid JSON response and no content.""" mock_response = Mock() - mock_response.json.side_effect = json.decoder.JSONDecodeError("Invalid JSON", "document", 0) + mock_response.content = b"" mock_response.text = "" mock_response.url = "https://example.com/api/graphql" From 9a0d366b0f2b73731377a046c2fb86da403c5c41 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Wed, 15 Jul 2026 04:28:17 +0000 Subject: [PATCH 08/17] feat(sdk): migrate encode sites to orjson Replace ujson/stdlib json encode calls with orjson across eight modules: client debug print, checks log print, graphql multipart and renderers, ctl validate, cli_commands, telemetry export, and the JSON formatter. orjson.dumps returns bytes, so .decode() is added wherever a str is consumed; indent/sort_keys/non-str-key/datetime behaviors are preserved via orjson options. Co-Authored-By: Claude Opus 4.8 --- dev/specs/002-orjson-json-migration/tasks.md | 16 ++++++++-------- infrahub_sdk/checks.py | 4 ++-- infrahub_sdk/client.py | 4 ++-- infrahub_sdk/ctl/cli_commands.py | 8 ++++++-- infrahub_sdk/ctl/formatters/json.py | 9 +++++---- infrahub_sdk/ctl/telemetry.py | 4 ++-- infrahub_sdk/ctl/validate.py | 4 ++-- infrahub_sdk/graphql/multipart.py | 6 +++--- infrahub_sdk/graphql/renderers.py | 6 +++--- 9 files changed, 33 insertions(+), 28 deletions(-) diff --git a/dev/specs/002-orjson-json-migration/tasks.md b/dev/specs/002-orjson-json-migration/tasks.md index 34a4862eb..0ee21d6b8 100644 --- a/dev/specs/002-orjson-json-migration/tasks.md +++ b/dev/specs/002-orjson-json-migration/tasks.md @@ -29,14 +29,14 @@ This feature is a single, atomic P1 user story. Most migration tasks touch disti ### Encode sites (each a distinct file → parallelizable) -- [ ] T004 [P] [US1] `infrahub_sdk/client.py:218` debug print: `orjson.dumps(variables, option=orjson.OPT_INDENT_2).decode()`; swap import. -- [ ] T005 [P] [US1] `infrahub_sdk/checks.py:112` print: `orjson.dumps(log_message).decode()`; swap import. -- [ ] T006 [P] [US1] `infrahub_sdk/graphql/multipart.py:46,60`: `orjson.dumps(..., option=orjson.OPT_NON_STR_KEYS).decode()` (str for httpx); swap import. -- [ ] T007 [P] [US1] `infrahub_sdk/graphql/renderers.py:54` string-escaping: `orjson.dumps(value).decode()`; swap import (keep the escaping comment accurate). -- [ ] T008 [P] [US1] `infrahub_sdk/ctl/validate.py:107`: `orjson.dumps(response, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode()`; swap import. -- [ ] T009 [P] [US1] `infrahub_sdk/ctl/cli_commands.py:355`: same option combo + `.decode()`; swap import. -- [ ] T010 [P] [US1] `infrahub_sdk/ctl/telemetry.py:127`: `output_path.write_text(orjson.dumps(snapshots, option=orjson.OPT_INDENT_2).decode(), encoding="utf-8")`; swap import. -- [ ] T011 [P] [US1] `infrahub_sdk/ctl/formatters/json.py:42,59`: `orjson.dumps(x, option=orjson.OPT_INDENT_2 | orjson.OPT_PASSTHROUGH_DATETIME, default=str).decode()`; swap import; update the "Uses stdlib json" docstring. +- [X] T004 [P] [US1] `infrahub_sdk/client.py:218` debug print: `orjson.dumps(variables, option=orjson.OPT_INDENT_2).decode()`; swap import. +- [X] T005 [P] [US1] `infrahub_sdk/checks.py:112` print: `orjson.dumps(log_message).decode()`; swap import. +- [X] T006 [P] [US1] `infrahub_sdk/graphql/multipart.py:46,60`: `orjson.dumps(..., option=orjson.OPT_NON_STR_KEYS).decode()` (str for httpx); swap import. +- [X] T007 [P] [US1] `infrahub_sdk/graphql/renderers.py:54` string-escaping: `orjson.dumps(value).decode()`; swap import (keep the escaping comment accurate). +- [X] T008 [P] [US1] `infrahub_sdk/ctl/validate.py:107`: `orjson.dumps(response, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode()`; swap import. +- [X] T009 [P] [US1] `infrahub_sdk/ctl/cli_commands.py:355`: same option combo + `.decode()`; swap import. +- [X] T010 [P] [US1] `infrahub_sdk/ctl/telemetry.py:127`: `output_path.write_text(orjson.dumps(snapshots, option=orjson.OPT_INDENT_2).decode(), encoding="utf-8")`; swap import. +- [X] T011 [P] [US1] `infrahub_sdk/ctl/formatters/json.py:42,59`: `orjson.dumps(x, option=orjson.OPT_INDENT_2 | orjson.OPT_PASSTHROUGH_DATETIME, default=str).decode()`; swap import; update the "Uses stdlib json" docstring. ### Decode sites diff --git a/infrahub_sdk/checks.py b/infrahub_sdk/checks.py index 36ce1199a..bfbb65199 100644 --- a/infrahub_sdk/checks.py +++ b/infrahub_sdk/checks.py @@ -7,7 +7,7 @@ from abc import abstractmethod from typing import TYPE_CHECKING, Any -import ujson +import orjson from pydantic import BaseModel, Field from infrahub_sdk.repository import GitRepoManager @@ -109,7 +109,7 @@ def _write_log_entry( self.logs.append(log_message) if self.output == "stdout": - print(ujson.dumps(log_message)) + print(orjson.dumps(log_message).decode()) def log_error(self, message: str, object_id: str | None = None, object_type: str | None = None) -> None: self._write_log_entry(message=message, level="ERROR", object_id=object_id, object_type=object_type) diff --git a/infrahub_sdk/client.py b/infrahub_sdk/client.py index e8ff8b9f7..1da874da0 100644 --- a/infrahub_sdk/client.py +++ b/infrahub_sdk/client.py @@ -14,7 +14,7 @@ from urllib.parse import urlencode import httpx -import ujson +import orjson from typing_extensions import Self from .batch import InfrahubBatch, InfrahubBatchSync @@ -240,7 +240,7 @@ def _echo(self, url: str, query: str, variables: dict | None = None) -> None: print(f"URL: {url}") print(f"QUERY:\n{query}") if variables: - print(f"VARIABLES:\n{ujson.dumps(variables, indent=4)}\n") + print(f"VARIABLES:\n{orjson.dumps(variables, option=orjson.OPT_INDENT_2).decode()}\n") def _request_headers(self, tracker: str | None = None, priority: Priority | None = None) -> dict: """Build the per-request header delta to layer over the client's base headers. diff --git a/infrahub_sdk/ctl/cli_commands.py b/infrahub_sdk/ctl/cli_commands.py index 9d947ff1f..f177a3b69 100644 --- a/infrahub_sdk/ctl/cli_commands.py +++ b/infrahub_sdk/ctl/cli_commands.py @@ -11,8 +11,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +import orjson import typer -import ujson from rich.console import Console from rich.layout import Layout from rich.logging import RichHandler @@ -352,7 +352,11 @@ def transform( # Run Transform result = asyncio.run(transform.run(data=data)) - json_string = result if isinstance(result, str) else ujson.dumps(result, indent=2, sort_keys=True) + json_string = ( + result + if isinstance(result, str) + else orjson.dumps(result, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode() + ) if out: write_to_file(Path(out), json_string) diff --git a/infrahub_sdk/ctl/formatters/json.py b/infrahub_sdk/ctl/formatters/json.py index 60eff0fb9..2e26eda37 100644 --- a/infrahub_sdk/ctl/formatters/json.py +++ b/infrahub_sdk/ctl/formatters/json.py @@ -2,9 +2,10 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING +import orjson + from .base import extract_node_data, extract_node_detail if TYPE_CHECKING: @@ -15,7 +16,7 @@ class JsonFormatter: """Formats InfrahubNode data as JSON strings. - Uses stdlib json module with indentation for readable output. + Uses orjson with indentation for readable output. """ def format_list( @@ -39,7 +40,7 @@ def format_list( """ items = [extract_node_data(node, schema) for node in nodes] - return json.dumps(items, indent=2, default=str) + return orjson.dumps(items, option=orjson.OPT_INDENT_2 | orjson.OPT_PASSTHROUGH_DATETIME, default=str).decode() def format_detail(self, node: InfrahubNode, schema: MainSchemaTypesAPI) -> str: """Format a single node as a JSON object. @@ -56,4 +57,4 @@ def format_detail(self, node: InfrahubNode, schema: MainSchemaTypesAPI) -> str: """ detail = extract_node_detail(node, schema) - return json.dumps(detail, indent=2, default=str) + return orjson.dumps(detail, option=orjson.OPT_INDENT_2 | orjson.OPT_PASSTHROUGH_DATETIME, default=str).decode() diff --git a/infrahub_sdk/ctl/telemetry.py b/infrahub_sdk/ctl/telemetry.py index 070462a84..f4e6419c8 100644 --- a/infrahub_sdk/ctl/telemetry.py +++ b/infrahub_sdk/ctl/telemetry.py @@ -1,11 +1,11 @@ from __future__ import annotations -import json from datetime import datetime from pathlib import Path from typing import Any from urllib.parse import urlencode +import orjson import typer from rich.console import Console from rich.table import Table @@ -124,5 +124,5 @@ async def export_snapshots( raise typer.Exit(code=2) output_path = Path(output) - output_path.write_text(json.dumps(snapshots, indent=2), encoding="utf-8") + output_path.write_text(orjson.dumps(snapshots, option=orjson.OPT_INDENT_2).decode(), encoding="utf-8") console.print(f"Exported {len(snapshots)} snapshots to {output_path}") diff --git a/infrahub_sdk/ctl/validate.py b/infrahub_sdk/ctl/validate.py index cf69e2fa5..ffa3d6f99 100644 --- a/infrahub_sdk/ctl/validate.py +++ b/infrahub_sdk/ctl/validate.py @@ -3,8 +3,8 @@ import sys from pathlib import Path +import orjson import typer -import ujson from pydantic import ValidationError from rich.console import Console @@ -104,5 +104,5 @@ def validate_graphql( console.print("-" * 40) if out: - json_string = ujson.dumps(response, indent=2, sort_keys=True) + json_string = orjson.dumps(response, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode() write_to_file(Path(out), json_string) diff --git a/infrahub_sdk/graphql/multipart.py b/infrahub_sdk/graphql/multipart.py index d05f2733e..1dace3ed6 100644 --- a/infrahub_sdk/graphql/multipart.py +++ b/infrahub_sdk/graphql/multipart.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any -import ujson +import orjson if TYPE_CHECKING: from typing import BinaryIO @@ -43,7 +43,7 @@ def build_operations(query: str, variables: dict[str, Any], operation_name: str operations: dict[str, Any] = {"query": query, "variables": variables} if operation_name: operations["operationName"] = operation_name - return ujson.dumps(operations) + return orjson.dumps(operations, option=orjson.OPT_NON_STR_KEYS).decode() @staticmethod def build_file_map(file_key: str = "0", variable_path: str = "variables.file") -> str: @@ -57,7 +57,7 @@ def build_file_map(file_key: str = "0", variable_path: str = "variables.file") - JSON string mapping the file key to the variable path. """ - return ujson.dumps({file_key: [variable_path]}) + return orjson.dumps({file_key: [variable_path]}, option=orjson.OPT_NON_STR_KEYS).decode() @staticmethod def build_payload( diff --git a/infrahub_sdk/graphql/renderers.py b/infrahub_sdk/graphql/renderers.py index 6897df877..1f0bef815 100644 --- a/infrahub_sdk/graphql/renderers.py +++ b/infrahub_sdk/graphql/renderers.py @@ -1,11 +1,11 @@ from __future__ import annotations -import json from datetime import datetime from enum import Enum from pathlib import Path from typing import Any, BinaryIO +import orjson from pydantic import BaseModel from infrahub_sdk.types import Order @@ -49,9 +49,9 @@ def convert_to_graphql_as_string(value: Any, convert_enum: bool = False) -> str: return convert_to_graphql_as_string(value=value.value, convert_enum=True) return value.name if isinstance(value, str): - # Use json.dumps() to properly escape the string according to JSON rules, + # Use orjson.dumps() to properly escape the string according to JSON rules, # which are compatible with GraphQL string escaping - return json.dumps(value) + return orjson.dumps(value).decode() if isinstance(value, bool): return repr(value).lower() if isinstance(value, list): From 37b2278dae81e7a75a09d97eea9ab9f9663dfc5c Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Wed, 15 Jul 2026 04:33:17 +0000 Subject: [PATCH 09/17] refactor: migrate decode sites to orjson.loads Swap json/ujson decode call sites to orjson across parsers, Jinja filters, schema response parsing, JSON importer, and the pytest plugin models. Schema response parsing now decodes explicitly via orjson.loads(response.content) so the paired except uses orjson.JSONDecodeError precisely. Update the from_json filter test to assert the library-stable message prefix. Co-Authored-By: Claude Opus 4.8 --- dev/specs/002-orjson-json-migration/tasks.md | 10 +++++----- infrahub_sdk/ctl/parsers.py | 6 +++--- infrahub_sdk/pytest_plugin/models.py | 4 ++-- infrahub_sdk/schema/__init__.py | 6 +++--- infrahub_sdk/template/infrahub_filters.py | 6 +++--- infrahub_sdk/transfer/importer/json.py | 6 +++--- tests/unit/sdk/test_infrahub_filters.py | 2 +- 7 files changed, 20 insertions(+), 20 deletions(-) diff --git a/dev/specs/002-orjson-json-migration/tasks.md b/dev/specs/002-orjson-json-migration/tasks.md index 0ee21d6b8..f0de45b45 100644 --- a/dev/specs/002-orjson-json-migration/tasks.md +++ b/dev/specs/002-orjson-json-migration/tasks.md @@ -40,11 +40,11 @@ This feature is a single, atomic P1 user story. Most migration tasks touch disti ### Decode sites -- [ ] T012 [P] [US1] `infrahub_sdk/ctl/parsers.py:27,28`: `orjson.loads(stripped)` + `except orjson.JSONDecodeError`; swap import. -- [ ] T013 [P] [US1] `infrahub_sdk/template/infrahub_filters.py:167,168`: `orjson.loads(value)` + `except (orjson.JSONDecodeError, TypeError)`; swap import. -- [ ] T014 [P] [US1] `infrahub_sdk/schema/__init__.py:277`: apply the decode-error strategy (decode explicitly via orjson where the value comes from `response.json()`; `except orjson.JSONDecodeError`); swap import. -- [ ] T015 [P] [US1] `infrahub_sdk/transfer/importer/json.py:67,150`: `orjson.loads(...)`; swap import. -- [ ] T016 [P] [US1] `infrahub_sdk/pytest_plugin/models.py:57`: `orjson.loads(text)`; swap import. +- [X] T012 [P] [US1] `infrahub_sdk/ctl/parsers.py:27,28`: `orjson.loads(stripped)` + `except orjson.JSONDecodeError`; swap import. +- [X] T013 [P] [US1] `infrahub_sdk/template/infrahub_filters.py:167,168`: `orjson.loads(value)` + `except (orjson.JSONDecodeError, TypeError)`; swap import. +- [X] T014 [P] [US1] `infrahub_sdk/schema/__init__.py:277`: apply the decode-error strategy (decode explicitly via orjson where the value comes from `response.json()`; `except orjson.JSONDecodeError`); swap import. +- [X] T015 [P] [US1] `infrahub_sdk/transfer/importer/json.py:67,150`: `orjson.loads(...)`; swap import. +- [X] T016 [P] [US1] `infrahub_sdk/pytest_plugin/models.py:57`: `orjson.loads(text)`; swap import. ### File-object I/O (no `load`/`dump` in orjson) diff --git a/infrahub_sdk/ctl/parsers.py b/infrahub_sdk/ctl/parsers.py index de86f0aa9..e3d4baab8 100644 --- a/infrahub_sdk/ctl/parsers.py +++ b/infrahub_sdk/ctl/parsers.py @@ -1,8 +1,8 @@ from __future__ import annotations -import json from typing import Any +import orjson import typer @@ -24,8 +24,8 @@ def _coerce_value(value: str) -> Any: stripped = value.strip() if stripped.startswith("[") and stripped.endswith("]"): try: - return json.loads(stripped) - except json.JSONDecodeError: + return orjson.loads(stripped) + except orjson.JSONDecodeError: pass # Try integer (preserve leading zeros — "00123" stays a string) diff --git a/infrahub_sdk/pytest_plugin/models.py b/infrahub_sdk/pytest_plugin/models.py index 13e81b903..10a2b6c43 100644 --- a/infrahub_sdk/pytest_plugin/models.py +++ b/infrahub_sdk/pytest_plugin/models.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import Any, Literal, Union -import ujson +import orjson import yaml from pydantic import BaseModel, ConfigDict, Field @@ -54,7 +54,7 @@ def parse_user_provided_data(path: Path | None) -> Any: text = path.read_text() if suffix and suffix == "json": - return ujson.loads(text) + return orjson.loads(text) if suffix in {"yml", "yaml"}: return yaml.safe_load(text) diff --git a/infrahub_sdk/schema/__init__.py b/infrahub_sdk/schema/__init__.py index 5343f24cd..f97c0ec6d 100644 --- a/infrahub_sdk/schema/__init__.py +++ b/infrahub_sdk/schema/__init__.py @@ -2,7 +2,6 @@ import asyncio import inspect -import json import warnings from collections.abc import MutableMapping from enum import Enum @@ -11,6 +10,7 @@ from urllib.parse import urlencode import httpx +import orjson from pydantic import BaseModel, Field from ..exceptions import ( @@ -273,8 +273,8 @@ def _parse_schema_response(response: httpx.Response, branch: str) -> MutableMapp response.raise_for_status() try: - data: MutableMapping[str, Any] = response.json() - except json.decoder.JSONDecodeError as exc: + data: MutableMapping[str, Any] = orjson.loads(response.content) + except orjson.JSONDecodeError as exc: raise JsonDecodeError( message=f"Invalid Schema response received from the server at {response.url}: {response.text} [{response.status_code}] ", content=response.text, diff --git a/infrahub_sdk/template/infrahub_filters.py b/infrahub_sdk/template/infrahub_filters.py index c2b8faf14..71d253936 100644 --- a/infrahub_sdk/template/infrahub_filters.py +++ b/infrahub_sdk/template/infrahub_filters.py @@ -1,10 +1,10 @@ from __future__ import annotations import inspect -import json from collections.abc import Callable, Coroutine from typing import TYPE_CHECKING, Any +import orjson import yaml from infrahub_sdk.exceptions import AuthenticationError @@ -164,8 +164,8 @@ def from_json(value: str) -> dict | list: if not value: return {} try: - return json.loads(value) - except (json.JSONDecodeError, TypeError) as exc: + return orjson.loads(value) + except (orjson.JSONDecodeError, TypeError) as exc: raise JinjaFilterError(filter_name="from_json", message=f"invalid JSON: {exc}") from exc diff --git a/infrahub_sdk/transfer/importer/json.py b/infrahub_sdk/transfer/importer/json.py index 8b0c1e730..df7a33d78 100644 --- a/infrahub_sdk/transfer/importer/json.py +++ b/infrahub_sdk/transfer/importer/json.py @@ -6,7 +6,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -import ujson +import orjson from rich.progress import Progress from ...exceptions import GraphQLError @@ -74,7 +74,7 @@ async def import_data(self, import_directory: Path, branch: str) -> None: with self.wrapped_task_output("Analyzing import"): import_nodes_by_kind = defaultdict(list) for graphql_data, kind in zip(table.column("graphql_json"), table.column("kind"), strict=False): - node = await InfrahubNode.from_graphql(self.client, branch, ujson.loads(str(graphql_data))) + node = await InfrahubNode.from_graphql(self.client, branch, orjson.loads(str(graphql_data))) import_nodes_by_kind[str(kind)].append(node) self.all_nodes[node.id] = node @@ -157,7 +157,7 @@ async def update_optional_relationships(self) -> None: await self.execute_batches([update_batch], "Adding optional relationships to nodes") async def update_many_to_many_relationships(self, file: Path) -> None: - relationships = ujson.loads(file.read_text(encoding="utf-8")) + relationships = orjson.loads(file.read_text(encoding="utf-8")) update_batch = await self.client.create_batch(return_exceptions=True) for relationship in relationships: diff --git a/tests/unit/sdk/test_infrahub_filters.py b/tests/unit/sdk/test_infrahub_filters.py index 0aca9e594..3083c36c2 100644 --- a/tests/unit/sdk/test_infrahub_filters.py +++ b/tests/unit/sdk/test_infrahub_filters.py @@ -318,7 +318,7 @@ def test_malformed_json_raises_error(self) -> None: from_json("{not valid json}") assert exc.value.filter_name == "from_json" assert exc.value.message is not None - assert exc.value.message.startswith("Filter 'from_json': invalid JSON: Expecting property name") + assert exc.value.message.startswith("Filter 'from_json': invalid JSON:") async def test_render_through_template(self) -> None: jinja = Jinja2Template(template="{{ data | from_json }}") From 9be1b45f46943de65795c35fd068150c8cadf92c Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Wed, 15 Jul 2026 04:36:11 +0000 Subject: [PATCH 10/17] feat(orjson): migrate recorder, playback, and json exporter to orjson Swap ujson/json serialization for orjson in recorder.py, playback.py, and transfer/exporter/json.py. Use OPT_NON_STR_KEYS on export sites to preserve prior silent non-str-key coercion. Co-Authored-By: Claude Opus 4.8 --- dev/specs/002-orjson-json-migration/tasks.md | 6 +++--- infrahub_sdk/playback.py | 8 +++----- infrahub_sdk/recorder.py | 6 +++--- infrahub_sdk/transfer/exporter/json.py | 13 +++++++------ 4 files changed, 16 insertions(+), 17 deletions(-) diff --git a/dev/specs/002-orjson-json-migration/tasks.md b/dev/specs/002-orjson-json-migration/tasks.md index f0de45b45..d892de5cc 100644 --- a/dev/specs/002-orjson-json-migration/tasks.md +++ b/dev/specs/002-orjson-json-migration/tasks.md @@ -48,12 +48,12 @@ This feature is a single, atomic P1 user story. Most migration tasks touch disti ### File-object I/O (no `load`/`dump` in orjson) -- [ ] T017 [P] [US1] `infrahub_sdk/recorder.py:59`: `fobj.write(orjson.dumps(data, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode())`; swap import. -- [ ] T018 [P] [US1] `infrahub_sdk/playback.py:52,57`: encode `orjson.dumps(payload)` (drop `str(...).encode()` wrappers — already bytes), read `orjson.loads(fobj.read())`; remove both `import json` and `import ujson`, add `import orjson`. +- [X] T017 [P] [US1] `infrahub_sdk/recorder.py:59`: `fobj.write(orjson.dumps(data, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode())`; swap import. +- [X] T018 [P] [US1] `infrahub_sdk/playback.py:52,57`: encode `orjson.dumps(payload)` (drop `str(...).encode()` wrappers — already bytes), read `orjson.loads(fobj.read())`; remove both `import json` and `import ujson`, add `import orjson`. ### Export (arbitrary data → preserve non-str-key coercion) -- [ ] T019 [P] [US1] `infrahub_sdk/transfer/exporter/json.py:151,155,166`: `orjson.dumps(..., option=orjson.OPT_NON_STR_KEYS).decode()` for the `write_text` sites; swap import. +- [X] T019 [P] [US1] `infrahub_sdk/transfer/exporter/json.py:151,155,166`: `orjson.dumps(..., option=orjson.OPT_NON_STR_KEYS).decode()` for the `write_text` sites; swap import. ### pytest-plugin items (dumps indent + paired except) diff --git a/infrahub_sdk/playback.py b/infrahub_sdk/playback.py index c00badc55..72d028f5c 100644 --- a/infrahub_sdk/playback.py +++ b/infrahub_sdk/playback.py @@ -1,11 +1,10 @@ from __future__ import annotations -import json from pathlib import Path from typing import TYPE_CHECKING, Any import httpx -import ujson +import orjson from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -49,11 +48,10 @@ def _read_request( ) -> httpx.Response: content: bytes | None = None if payload: - content = str(json.dumps(payload)).encode("UTF-8") + content = orjson.dumps(payload) request = httpx.Request(method=method.value, url=url, headers=headers, content=content) filename = generate_request_filename(request) - with Path(f"{self.directory}/{filename}.json").open(encoding="utf-8") as fobj: - data = ujson.load(fobj) + data = orjson.loads(Path(f"{self.directory}/{filename}.json").read_text(encoding="utf-8")) return httpx.Response(status_code=data["status_code"], content=data["response_content"], request=request) diff --git a/infrahub_sdk/recorder.py b/infrahub_sdk/recorder.py index 1ca9decad..e4d6e4618 100644 --- a/infrahub_sdk/recorder.py +++ b/infrahub_sdk/recorder.py @@ -5,7 +5,7 @@ from typing import Protocol, runtime_checkable import httpx -import ujson +import orjson from pydantic_settings import BaseSettings, SettingsConfigDict from .utils import generate_request_filename @@ -55,8 +55,8 @@ def record(self, response: httpx.Response) -> None: "request_content": response.request.content.decode("utf-8"), } - with Path(f"{self.directory}/{filename}.json").open(mode="w", encoding="utf-8") as fobj: - ujson.dump(data, fobj, indent=4, sort_keys=True) + serialized = orjson.dumps(data, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode() + Path(f"{self.directory}/{filename}.json").write_text(serialized, encoding="utf-8") def _set_url_host(self, response: httpx.Response) -> None: if not self.host: diff --git a/infrahub_sdk/transfer/exporter/json.py b/infrahub_sdk/transfer/exporter/json.py index 077ee8faf..5fd1ac0d4 100644 --- a/infrahub_sdk/transfer/exporter/json.py +++ b/infrahub_sdk/transfer/exporter/json.py @@ -5,7 +5,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -import ujson +import orjson from rich.progress import Progress from ...queries import QUERY_RELATIONSHIPS @@ -148,13 +148,14 @@ async def export( with self.wrapped_task_output("Writing export"): json_lines = [ - ujson.dumps( + orjson.dumps( { "id": n.id, "kind": n.get_kind(), - "graphql_json": ujson.dumps(n.get_raw_graphql_data()), - } - ) + "graphql_json": orjson.dumps(n.get_raw_graphql_data(), option=orjson.OPT_NON_STR_KEYS).decode(), + }, + option=orjson.OPT_NON_STR_KEYS, + ).decode() for n in all_nodes ] file_content = "\n".join(json_lines) @@ -163,7 +164,7 @@ async def export( export_directory.mkdir() node_file.write_text(file_content) - relationship_file.write_text(ujson.dumps(many_relationships)) + relationship_file.write_text(orjson.dumps(many_relationships, option=orjson.OPT_NON_STR_KEYS).decode()) if self.console: self.console.print(f"Export directory - {export_directory}") From 814fa686166b9b15bc75342686adbc5472418c17 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Wed, 15 Jul 2026 04:39:23 +0000 Subject: [PATCH 11/17] refactor(pytest-plugin): migrate items JSON serialization to orjson Swap ujson for orjson across the pytest plugin item classes. Decode HTTP error responses explicitly via orjson.loads(response.content) so the paired except matches the raising library. Co-Authored-By: Claude Opus 4.8 --- dev/specs/002-orjson-json-migration/tasks.md | 10 +++++----- infrahub_sdk/pytest_plugin/items/base.py | 6 +++--- infrahub_sdk/pytest_plugin/items/check.py | 8 +++++--- infrahub_sdk/pytest_plugin/items/graphql_query.py | 8 +++++--- infrahub_sdk/pytest_plugin/items/jinja2_transform.py | 8 +++++--- infrahub_sdk/pytest_plugin/items/python_transform.py | 8 +++++--- 6 files changed, 28 insertions(+), 20 deletions(-) diff --git a/dev/specs/002-orjson-json-migration/tasks.md b/dev/specs/002-orjson-json-migration/tasks.md index d892de5cc..eef6c452d 100644 --- a/dev/specs/002-orjson-json-migration/tasks.md +++ b/dev/specs/002-orjson-json-migration/tasks.md @@ -57,11 +57,11 @@ This feature is a single, atomic P1 user story. Most migration tasks touch disti ### pytest-plugin items (dumps indent + paired except) -- [ ] T020 [P] [US1] `infrahub_sdk/pytest_plugin/items/base.py:62,63`: `orjson.dumps(x, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode().splitlines()`; swap import. -- [ ] T021 [P] [US1] `infrahub_sdk/pytest_plugin/items/check.py:52,53`: `OPT_INDENT_2` + `.decode()`, and set the paired `except` to match the orjson decode of `response.json()`; swap import. -- [ ] T022 [P] [US1] `infrahub_sdk/pytest_plugin/items/graphql_query.py:31,32`: same pattern as T021. -- [ ] T023 [P] [US1] `infrahub_sdk/pytest_plugin/items/python_transform.py:54,55`: same pattern as T021. -- [ ] T024 [P] [US1] `infrahub_sdk/pytest_plugin/items/jinja2_transform.py:63,64`: `OPT_INDENT_2 | OPT_SORT_KEYS` + `.decode()` + paired except; swap import. +- [X] T020 [P] [US1] `infrahub_sdk/pytest_plugin/items/base.py:62,63`: `orjson.dumps(x, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode().splitlines()`; swap import. +- [X] T021 [P] [US1] `infrahub_sdk/pytest_plugin/items/check.py:52,53`: `OPT_INDENT_2` + `.decode()`, and set the paired `except` to match the orjson decode of `response.json()`; swap import. +- [X] T022 [P] [US1] `infrahub_sdk/pytest_plugin/items/graphql_query.py:31,32`: same pattern as T021. +- [X] T023 [P] [US1] `infrahub_sdk/pytest_plugin/items/python_transform.py:54,55`: same pattern as T021. +- [X] T024 [P] [US1] `infrahub_sdk/pytest_plugin/items/jinja2_transform.py:63,64`: `OPT_INDENT_2 | OPT_SORT_KEYS` + `.decode()` + paired except; swap import. **Checkpoint**: no `ujson`/stdlib-`json` imports remain; package imports cleanly. diff --git a/infrahub_sdk/pytest_plugin/items/base.py b/infrahub_sdk/pytest_plugin/items/base.py index 8049cc414..dd9031c54 100644 --- a/infrahub_sdk/pytest_plugin/items/base.py +++ b/infrahub_sdk/pytest_plugin/items/base.py @@ -4,8 +4,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +import orjson import pytest -import ujson from ..exceptions import InvalidResourceConfigError from ..models import InfrahubInputOutputTest @@ -59,8 +59,8 @@ def get_result_differences(self, computed: Any) -> str | None: expected = self.test.spec.get_output_data() differences = difflib.unified_diff( - ujson.dumps(expected, indent=4, sort_keys=True).splitlines(), - ujson.dumps(computed, indent=4, sort_keys=True).splitlines(), + orjson.dumps(expected, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode().splitlines(), + orjson.dumps(computed, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode().splitlines(), fromfile="expected", tofile="rendered", lineterm="", diff --git a/infrahub_sdk/pytest_plugin/items/check.py b/infrahub_sdk/pytest_plugin/items/check.py index 8f68b2710..81c6e10ba 100644 --- a/infrahub_sdk/pytest_plugin/items/check.py +++ b/infrahub_sdk/pytest_plugin/items/check.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -import ujson +import orjson from httpx import HTTPStatusError from ..exceptions import CheckDefinitionError, CheckResultError @@ -49,8 +49,10 @@ def run_check(self, variables: dict[str, Any]) -> Any: def repr_failure(self, excinfo: pytest.ExceptionInfo, style: str | None = None) -> str: if isinstance(excinfo.value, HTTPStatusError): try: - response_content = ujson.dumps(excinfo.value.response.json(), indent=4) - except ujson.JSONDecodeError: + response_content = orjson.dumps( + orjson.loads(excinfo.value.response.content), option=orjson.OPT_INDENT_2 + ).decode() + except orjson.JSONDecodeError: response_content = excinfo.value.response.text return "\n".join( [ diff --git a/infrahub_sdk/pytest_plugin/items/graphql_query.py b/infrahub_sdk/pytest_plugin/items/graphql_query.py index bced55424..4777e5caa 100644 --- a/infrahub_sdk/pytest_plugin/items/graphql_query.py +++ b/infrahub_sdk/pytest_plugin/items/graphql_query.py @@ -2,7 +2,7 @@ from typing import TYPE_CHECKING, Any -import ujson +import orjson from httpx import HTTPStatusError from ...analyzer import GraphQLQueryAnalyzer @@ -28,8 +28,10 @@ def execute_query(self) -> Any: def repr_failure(self, excinfo: pytest.ExceptionInfo, style: str | None = None) -> str: if isinstance(excinfo.value, HTTPStatusError): try: - response_content = ujson.dumps(excinfo.value.response.json(), indent=4) - except ujson.JSONDecodeError: + response_content = orjson.dumps( + orjson.loads(excinfo.value.response.content), option=orjson.OPT_INDENT_2 + ).decode() + except orjson.JSONDecodeError: response_content = excinfo.value.response.text return "\n".join( [ diff --git a/infrahub_sdk/pytest_plugin/items/jinja2_transform.py b/infrahub_sdk/pytest_plugin/items/jinja2_transform.py index fe54fd714..01bc409ee 100644 --- a/infrahub_sdk/pytest_plugin/items/jinja2_transform.py +++ b/infrahub_sdk/pytest_plugin/items/jinja2_transform.py @@ -6,7 +6,7 @@ from typing import TYPE_CHECKING, Any import jinja2 -import ujson +import orjson from httpx import HTTPStatusError from ...template import Jinja2Template @@ -60,8 +60,10 @@ def get_result_differences(self, computed: Any) -> str | None: def repr_failure(self, excinfo: pytest.ExceptionInfo, style: str | None = None) -> str: if isinstance(excinfo.value, HTTPStatusError): try: - response_content = ujson.dumps(excinfo.value.response.json(), indent=4, sort_keys=True) - except ujson.JSONDecodeError: + response_content = orjson.dumps( + orjson.loads(excinfo.value.response.content), option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS + ).decode() + except orjson.JSONDecodeError: response_content = excinfo.value.response.text return "\n".join( [ diff --git a/infrahub_sdk/pytest_plugin/items/python_transform.py b/infrahub_sdk/pytest_plugin/items/python_transform.py index f895e971e..bdab1ac49 100644 --- a/infrahub_sdk/pytest_plugin/items/python_transform.py +++ b/infrahub_sdk/pytest_plugin/items/python_transform.py @@ -4,7 +4,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Any -import ujson +import orjson from httpx import HTTPStatusError from ...node import InfrahubNode @@ -51,8 +51,10 @@ def run_transform(self, variables: dict[str, Any]) -> Any: def repr_failure(self, excinfo: pytest.ExceptionInfo, style: str | None = None) -> str: if isinstance(excinfo.value, HTTPStatusError): try: - response_content = ujson.dumps(excinfo.value.response.json(), indent=4) - except ujson.JSONDecodeError: + response_content = orjson.dumps( + orjson.loads(excinfo.value.response.content), option=orjson.OPT_INDENT_2 + ).decode() + except orjson.JSONDecodeError: response_content = excinfo.value.response.text return "\n".join( [ From 58320e034417a777ef2076d53d5c495bc8040b3c Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Wed, 15 Jul 2026 04:44:32 +0000 Subject: [PATCH 12/17] test(json): pin orjson migration guarantees Add and confirm tests that lock in the orjson migration's behavioral contract: non-ASCII dict_hash vector, CLI datetime str() rendering, recorder->playback round-trip, and malformed-JSON decode error. Co-Authored-By: Claude Opus 4.8 --- dev/specs/002-orjson-json-migration/tasks.md | 10 +++---- tests/unit/ctl/formatters/test_json.py | 14 +++++++++ tests/unit/sdk/test_recorder_playback.py | 31 ++++++++++++++++++++ tests/unit/sdk/test_utils.py | 15 ++++++++++ 4 files changed, 65 insertions(+), 5 deletions(-) create mode 100644 tests/unit/sdk/test_recorder_playback.py diff --git a/dev/specs/002-orjson-json-migration/tasks.md b/dev/specs/002-orjson-json-migration/tasks.md index eef6c452d..9204fb8fa 100644 --- a/dev/specs/002-orjson-json-migration/tasks.md +++ b/dev/specs/002-orjson-json-migration/tasks.md @@ -69,11 +69,11 @@ This feature is a single, atomic P1 user story. Most migration tasks touch disti ## Phase 3: Tests & verification (part of US1 acceptance) -- [ ] T025 [US1] Extend `tests/unit/sdk/test_utils.py::test_dict_hash`: keep the three committed vectors (`608de4…`, `4d8f1a…`, `99914b…`) and add a non-ASCII vector (e.g. `{"x": "café"}`) asserting the new pinned orjson value (`dict_hash` of `b'{"x":"caf\xc3\xa9"}'`). -- [ ] T026 [US1] Add a characterization test for `ctl/formatters/json.py` asserting a record with a date/time value renders identically to the pre-migration `str()` form (guards the `OPT_PASSTHROUGH_DATETIME` decision). -- [ ] T027 [US1] Confirm `tests/unit/sdk/test_file_object.py` (or add a targeted test) covers a recorder→playback round-trip yielding the original object after the file-object rewrite. -- [ ] T028 [US1] Add/confirm a decode-of-invalid-input test proving malformed JSON still raises and is caught at a representative decode site (`decode_json`). -- [ ] T029 [P] [US1] Check whether any pytest-plugin test asserts exact failure/diff message text; update expected strings for the cosmetic 4→2 indent shift. +- [X] T025 [US1] Extend `tests/unit/sdk/test_utils.py::test_dict_hash`: keep the three committed vectors (`608de4…`, `4d8f1a…`, `99914b…`) and add a non-ASCII vector (e.g. `{"x": "café"}`) asserting the new pinned orjson value (`dict_hash` of `b'{"x":"caf\xc3\xa9"}'`). +- [X] T026 [US1] Add a characterization test for `ctl/formatters/json.py` asserting a record with a date/time value renders identically to the pre-migration `str()` form (guards the `OPT_PASSTHROUGH_DATETIME` decision). +- [X] T027 [US1] Confirm `tests/unit/sdk/test_file_object.py` (or add a targeted test) covers a recorder→playback round-trip yielding the original object after the file-object rewrite. +- [X] T028 [US1] Add/confirm a decode-of-invalid-input test proving malformed JSON still raises and is caught at a representative decode site (`decode_json`). +- [X] T029 [P] [US1] Check whether any pytest-plugin test asserts exact failure/diff message text; update expected strings for the cosmetic 4→2 indent shift. --- diff --git a/tests/unit/ctl/formatters/test_json.py b/tests/unit/ctl/formatters/test_json.py index cfcb8cdb1..335ba8d6c 100644 --- a/tests/unit/ctl/formatters/test_json.py +++ b/tests/unit/ctl/formatters/test_json.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +from datetime import datetime, timezone from typing import TYPE_CHECKING, cast from unittest.mock import MagicMock @@ -201,3 +202,16 @@ def test_format_detail_contains_relationship(self) -> None: parsed = json.loads(result) assert "site" in parsed assert parsed["site"]["display_label"] == "DC1" + + def test_format_detail_renders_datetime_as_str_form(self) -> None: + """Datetime values render in the space-separated str() form, not RFC3339 'T' form.""" + schema = _make_mock_schema(["installed_at"], []) + node = _make_mock_node({"installed_at": datetime(2026, 7, 15, 0, 0, 0, tzinfo=timezone.utc)}, {}) + formatter = JsonFormatter() + + result = formatter.format_detail(node, schema) + + assert '"2026-07-15 00:00:00+00:00"' in result + assert "2026-07-15T00:00:00" not in result + parsed = json.loads(result) + assert parsed["installed_at"]["value"] == "2026-07-15 00:00:00+00:00" diff --git a/tests/unit/sdk/test_recorder_playback.py b/tests/unit/sdk/test_recorder_playback.py new file mode 100644 index 000000000..1ff99ead6 --- /dev/null +++ b/tests/unit/sdk/test_recorder_playback.py @@ -0,0 +1,31 @@ +from pathlib import Path + +import httpx +import orjson + +from infrahub_sdk.playback import JSONPlayback +from infrahub_sdk.recorder import JSONRecorder +from infrahub_sdk.types import HTTPMethod + + +def test_recorder_playback_round_trip(tmp_path: Path) -> None: + """A response written by the recorder is read back by playback to the same decoded object.""" + payload = {"query": "query { BuiltinTag { edges { node { id } } } }"} + request_content = orjson.dumps(payload) + request = httpx.Request("POST", "http://localhost:8000/graphql", content=request_content) + + response_body = {"data": {"BuiltinTag": {"edges": [{"node": {"id": "abc-123"}}]}}} + response = httpx.Response(status_code=200, content=orjson.dumps(response_body), request=request) + + JSONRecorder(directory=str(tmp_path)).record(response) + + playback = JSONPlayback(directory=str(tmp_path)) + played_back = playback.sync_request( + url="http://localhost:8000/graphql", + method=HTTPMethod.POST, + headers={}, + timeout=10, + payload=payload, + ) + + assert orjson.loads(played_back.content) == response_body diff --git a/tests/unit/sdk/test_utils.py b/tests/unit/sdk/test_utils.py index 79782bf7a..71b112e4c 100644 --- a/tests/unit/sdk/test_utils.py +++ b/tests/unit/sdk/test_utils.py @@ -5,6 +5,7 @@ from typing import Any from unittest.mock import Mock +import httpx import pytest from graphql import OperationDefinitionNode, parse from whenever import Instant @@ -176,6 +177,8 @@ def test_dict_hash() -> None: assert dict_hash({"b": 2, "a": {"c": 1, "d": 2}}) == "4d8f1a3d03e0b487983383d0ff984d13" assert dict_hash({"b": 2, "a": {"d": 2, "c": 1}}) == "4d8f1a3d03e0b487983383d0ff984d13" assert dict_hash({}) == "99914b932bd37a50b983c5e7c90ae93b" + # Non-ASCII values hash over raw UTF-8 bytes (b'{"x":"caf\xc3\xa9"}'), pinning the stable value. + assert dict_hash({"x": "café"}) == "f030eed1695aa782ca459bfcd03849fb" async def test_extract_fields(query_01: str) -> None: @@ -299,6 +302,18 @@ def test_decode_json_failure_without_content() -> None: assert "Server response:" not in error_message +def test_decode_json_malformed_bytes_raises() -> None: + """Malformed JSON bytes on a real response raise the SDK's JsonDecodeError.""" + response = httpx.Response( + status_code=200, + content=b'{"unterminated": ', + request=httpx.Request("POST", "https://example.com/api/graphql"), + ) + + with pytest.raises(JsonDecodeError, match="Unable to decode response as JSON data"): + decode_json(response) + + def test_json_decode_error_custom_message() -> None: """Test JsonDecodeError with custom message does not override custom message.""" custom_message = "Custom error message" From d887aca204ef19899eb5e8cc62913326aed52f42 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Wed, 15 Jul 2026 05:02:21 +0000 Subject: [PATCH 13/17] chore(deps): remove ujson, complete orjson migration Remove the deferred ujson runtime dependency and types-ujson stub now that all of infrahub_sdk/ is migrated to orjson; refresh the lockfile. Migrate remaining ujson.loads call sites in the test suite to orjson and update the client query-echo expected output for the OPT_INDENT_2 (4->2 space) indent shift. Add a release note for the one-time non-ASCII tracking-group-name change. Co-Authored-By: Claude Opus 4.8 --- changelog/+orjson-json-migration.changed.md | 1 + dev/specs/002-orjson-json-migration/tasks.md | 10 +- pyproject.toml | 2 - tests/integration/test_export_import.py | 14 +-- tests/unit/ctl/conftest.py | 4 +- tests/unit/sdk/conftest.py | 30 ++--- tests/unit/sdk/graphql/test_multipart.py | 24 ++-- tests/unit/sdk/test_client.py | 2 +- uv.lock | 115 ------------------- 9 files changed, 43 insertions(+), 159 deletions(-) create mode 100644 changelog/+orjson-json-migration.changed.md diff --git a/changelog/+orjson-json-migration.changed.md b/changelog/+orjson-json-migration.changed.md new file mode 100644 index 000000000..3d05a3810 --- /dev/null +++ b/changelog/+orjson-json-migration.changed.md @@ -0,0 +1 @@ +Standardized JSON serialization across the SDK on `orjson`, replacing `ujson` and stdlib `json`. Observable behaviour is preserved except for one documented change: the tracking-group name derived from query parameters containing non-ASCII characters shifts once on upgrade (`orjson` emits raw UTF-8 where the previous library emitted escaped sequences). The previously-created group is orphaned and a new one is created; any cleanup of the orphaned group is manual. diff --git a/dev/specs/002-orjson-json-migration/tasks.md b/dev/specs/002-orjson-json-migration/tasks.md index 9204fb8fa..c96f10d24 100644 --- a/dev/specs/002-orjson-json-migration/tasks.md +++ b/dev/specs/002-orjson-json-migration/tasks.md @@ -79,11 +79,11 @@ This feature is a single, atomic P1 user story. Most migration tasks touch disti ## Phase 4: Polish & cross-cutting -- [ ] T030 Verify zero legacy imports: `grep -rn "import ujson" infrahub_sdk/` and `grep -rn "^import json\|^\s*import json$" infrahub_sdk/` both return nothing (SC-001). -- [ ] T031 Run `uv run invoke format lint-code` — ty/mypy must be green (catches any `bytes`-where-`str`-expected leak, FR-004). -- [ ] T032 Run `uv run pytest tests/unit/` then `uv run pytest tests/integration/` — all green (SC-002). -- [ ] T033 Add a release-note entry documenting the one-time non-ASCII `dict_hash`/query-group-name change (SC-004); grep docs for any `ujson` references and update. -- [ ] T034 Capture one ad-hoc before/after encode+decode timing on a representative payload in the PR description (evidence for the motivating speedup; not a committed benchmark). +- [X] T030 Verify zero legacy imports: `grep -rn "import ujson" infrahub_sdk/` and `grep -rn "^import json\|^\s*import json$" infrahub_sdk/` both return nothing (SC-001). +- [X] T031 Run `uv run invoke format lint-code` — ty/mypy must be green (catches any `bytes`-where-`str`-expected leak, FR-004). +- [X] T032 Run `uv run pytest tests/unit/` then `uv run pytest tests/integration/` — all green (SC-002). +- [X] T033 Add a release-note entry documenting the one-time non-ASCII `dict_hash`/query-group-name change (SC-004); grep docs for any `ujson` references and update. +- [X] T034 Capture one ad-hoc before/after encode+decode timing on a representative payload in the PR description (evidence for the motivating speedup; not a committed benchmark). --- diff --git a/pyproject.toml b/pyproject.toml index 1cceabefe..9892224ac 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -22,7 +22,6 @@ dependencies = [ "pydantic-settings>=2.0", "graphql-core>=3.1,<3.3", "httpx>=0.20", - "ujson>=5", "orjson>=3.10", "dulwich>=0.24.7", "whenever>=0.9.3,<0.10.0", @@ -88,7 +87,6 @@ lint = [ "rumdl==0.2.28", ] types = [ - "types-ujson", "types-pyyaml", "types-python-slugify>=8.0.0.3", ] diff --git a/tests/integration/test_export_import.py b/tests/integration/test_export_import.py index d317d5e7c..6c1ad7d2c 100644 --- a/tests/integration/test_export_import.py +++ b/tests/integration/test_export_import.py @@ -3,8 +3,8 @@ from pathlib import Path from typing import TYPE_CHECKING, Any +import orjson import pytest -import ujson from infrahub_sdk.exceptions import SchemaNotFoundError from infrahub_sdk.testing.docker import TestInfrahubDockerClient @@ -114,9 +114,9 @@ async def test_step01_export_no_schema(self, client: InfrahubClient, temporary_d admin_found = False with nodes_file.open() as f: for line in f: - node_dump = ujson.loads(line) + node_dump = orjson.loads(line) if node_dump.get("kind") == "CoreAccount": - graphql_data = ujson.loads(node_dump["graphql_json"]) + graphql_data = orjson.loads(node_dump["graphql_json"]) if graphql_data.get("name", {}).get("value") == "admin": admin_found = True break @@ -147,10 +147,10 @@ async def test_step03_export_initial_dataset( nodes_dump = [] with nodes_file.open() as reader: while line := reader.readline(): - nodes_dump.append(ujson.loads(line)) + nodes_dump.append(orjson.loads(line)) assert len(nodes_dump) >= len(initial_dataset) - relationships_dump = ujson.loads(relationships_file.read_text()) + relationships_dump = orjson.loads(relationships_file.read_text()) assert relationships_dump async def test_step04_import_initial_dataset( @@ -280,10 +280,10 @@ async def test_step01_export_initial_dataset( nodes_dump = [] with nodes_file.open() as reader: while line := reader.readline(): - nodes_dump.append(ujson.loads(line)) + nodes_dump.append(orjson.loads(line)) assert len(nodes_dump) >= len(initial_dataset) - relationships_dump = ujson.loads(relationships_file.read_text()) + relationships_dump = orjson.loads(relationships_file.read_text()) assert relationships_dump async def test_step02_import_initial_dataset( diff --git a/tests/unit/ctl/conftest.py b/tests/unit/ctl/conftest.py index b11acc6ce..a005109bf 100644 --- a/tests/unit/ctl/conftest.py +++ b/tests/unit/ctl/conftest.py @@ -1,7 +1,7 @@ from typing import Any +import orjson import pytest -import ujson from pytest_httpx import HTTPXMock from infrahub_sdk.utils import get_fixtures_dir @@ -11,7 +11,7 @@ @pytest.fixture async def schema_query_05_data() -> dict: response_text = (get_fixtures_dir() / "schema_05.json").read_text(encoding="UTF-8") - return ujson.loads(response_text) + return orjson.loads(response_text) @pytest.fixture diff --git a/tests/unit/sdk/conftest.py b/tests/unit/sdk/conftest.py index c4286af89..2bd82abfc 100644 --- a/tests/unit/sdk/conftest.py +++ b/tests/unit/sdk/conftest.py @@ -8,8 +8,8 @@ from io import StringIO from typing import TYPE_CHECKING, Any +import orjson import pytest -import ujson from infrahub_sdk import Config, InfrahubClient, InfrahubClientSync from infrahub_sdk.schema import BranchSupportType, NodeSchema, NodeSchemaAPI @@ -1688,25 +1688,25 @@ async def mock_query_repository_page2_2( @pytest.fixture async def schema_query_01_data() -> dict: response_text = (get_fixtures_dir() / "schema_01.json").read_text(encoding="UTF-8") - return ujson.loads(response_text) + return orjson.loads(response_text) @pytest.fixture async def schema_query_02_data() -> dict: response_text = (get_fixtures_dir() / "schema_02.json").read_text(encoding="UTF-8") - return ujson.loads(response_text) + return orjson.loads(response_text) @pytest.fixture async def schema_query_04_data() -> dict: response_text = (get_fixtures_dir() / "schema_04.json").read_text(encoding="UTF-8") - return ujson.loads(response_text) + return orjson.loads(response_text) @pytest.fixture async def schema_query_05_data() -> dict: response_text = (get_fixtures_dir() / "schema_05.json").read_text(encoding="UTF-8") - return ujson.loads(response_text) + return orjson.loads(response_text) @pytest.fixture @@ -1761,7 +1761,7 @@ async def mock_rest_api_artifact_fetch(httpx_mock: HTTPXMock) -> HTTPXMock: httpx_mock.add_response( method="GET", url="http://mock/api/schema?branch=main", - json=ujson.loads(schema_response), + json=orjson.loads(schema_response), is_reusable=True, ) @@ -2087,7 +2087,7 @@ async def mock_query_infrahub_server_info(httpx_mock: HTTPXMock) -> HTTPXMock: @pytest.fixture async def mock_query_infrahub_user(httpx_mock: HTTPXMock) -> HTTPXMock: response_text = (get_fixtures_dir() / "account_profile.json").read_text(encoding="UTF-8") - httpx_mock.add_response(method="POST", json=ujson.loads(response_text), is_reusable=True) + httpx_mock.add_response(method="POST", json=orjson.loads(response_text), is_reusable=True) return httpx_mock @@ -2414,7 +2414,7 @@ async def mock_schema_query_ipam(httpx_mock: HTTPXMock) -> HTTPXMock: response_text = (get_fixtures_dir() / "schema_ipam.json").read_text(encoding="UTF-8") httpx_mock.add_response( - method="GET", url="http://mock/api/schema?branch=main", json=ujson.loads(response_text), is_reusable=True + method="GET", url="http://mock/api/schema?branch=main", json=orjson.loads(response_text), is_reusable=True ) return httpx_mock @@ -2437,7 +2437,7 @@ async def mock_query_location_batch( response_text = filename.read_text(encoding="UTF-8") httpx_mock.add_response( method="POST", - json=ujson.loads(response_text), + json=orjson.loads(response_text), match_headers={"X-Infrahub-Tracker": f"query-builtinlocation-page{i}"}, is_reusable=True, ) @@ -2451,7 +2451,7 @@ async def mock_query_tasks_01(httpx_mock: HTTPXMock) -> HTTPXMock: response_text = filename.read_text(encoding="UTF-8") httpx_mock.add_response( method="POST", - json=ujson.loads(response_text), + json=orjson.loads(response_text), match_headers={"X-Infrahub-Tracker": f"query-tasks-page{i}"}, is_reusable=True, ) @@ -2464,7 +2464,7 @@ async def mock_query_tasks_02_main(httpx_mock: HTTPXMock) -> HTTPXMock: response_text = filename.read_text(encoding="UTF-8") httpx_mock.add_response( method="POST", - json=ujson.loads(response_text), + json=orjson.loads(response_text), match_headers={"X-Infrahub-Tracker": "query-tasks-page1"}, is_reusable=True, ) @@ -2477,7 +2477,7 @@ async def mock_query_tasks_empty(httpx_mock: HTTPXMock) -> HTTPXMock: response_text = filename.read_text(encoding="UTF-8") httpx_mock.add_response( method="POST", - json=ujson.loads(response_text), + json=orjson.loads(response_text), match_headers={"X-Infrahub-Tracker": "query-tasks-page1"}, is_reusable=True, ) @@ -2490,7 +2490,7 @@ async def mock_query_tasks_03(httpx_mock: HTTPXMock) -> HTTPXMock: response_text = filename.read_text(encoding="UTF-8") httpx_mock.add_response( method="POST", - json=ujson.loads(response_text), + json=orjson.loads(response_text), match_headers={"X-Infrahub-Tracker": "query-tasks-page1"}, is_reusable=True, ) @@ -2504,7 +2504,7 @@ async def mock_query_tasks_04_full(httpx_mock: HTTPXMock) -> HTTPXMock: response_text = filename.read_text(encoding="UTF-8") httpx_mock.add_response( method="POST", - json=ujson.loads(response_text), + json=orjson.loads(response_text), match_headers={"X-Infrahub-Tracker": f"query-tasks-page{i}"}, is_reusable=True, ) @@ -2517,7 +2517,7 @@ async def mock_query_tasks_05(httpx_mock: HTTPXMock) -> HTTPXMock: response_text = filename.read_text(encoding="UTF-8") httpx_mock.add_response( method="POST", - json=ujson.loads(response_text), + json=orjson.loads(response_text), match_headers={"X-Infrahub-Tracker": "query-tasks-page1"}, is_reusable=True, ) diff --git a/tests/unit/sdk/graphql/test_multipart.py b/tests/unit/sdk/graphql/test_multipart.py index 35a0e58ab..8fd7257a8 100644 --- a/tests/unit/sdk/graphql/test_multipart.py +++ b/tests/unit/sdk/graphql/test_multipart.py @@ -4,7 +4,7 @@ from io import BytesIO -import ujson +import orjson from infrahub_sdk.graphql import MultipartBuilder @@ -16,7 +16,7 @@ def test_build_operations_simple() -> None: result = MultipartBuilder.build_operations(query=query, variables=variables) - parsed = ujson.loads(result) + parsed = orjson.loads(result) assert parsed["query"] == query assert parsed["variables"] == variables @@ -28,7 +28,7 @@ def test_build_operations_empty_variables() -> None: result = MultipartBuilder.build_operations(query=query, variables=variables) - parsed = ujson.loads(result) + parsed = orjson.loads(result) assert parsed["query"] == query assert parsed["variables"] == {} @@ -40,7 +40,7 @@ def test_build_operations_complex_variables() -> None: result = MultipartBuilder.build_operations(query=query, variables=variables) - parsed = ujson.loads(result) + parsed = orjson.loads(result) assert parsed["variables"]["input"]["name"] == "test" assert parsed["variables"]["input"]["nested"]["value"] == 123 assert parsed["variables"]["input"]["list"] == [1, 2, 3] @@ -50,7 +50,7 @@ def test_build_file_map_defaults() -> None: """Test building file map with default values.""" result = MultipartBuilder.build_file_map() - parsed = ujson.loads(result) + parsed = orjson.loads(result) assert parsed == {"0": ["variables.file"]} @@ -58,7 +58,7 @@ def test_build_file_map_custom_key() -> None: """Test building file map with custom file key.""" result = MultipartBuilder.build_file_map(file_key="1") - parsed = ujson.loads(result) + parsed = orjson.loads(result) assert parsed == {"1": ["variables.file"]} @@ -66,7 +66,7 @@ def test_build_file_map_custom_path() -> None: """Test building file map with custom variable path.""" result = MultipartBuilder.build_file_map(variable_path="variables.input.document") - parsed = ujson.loads(result) + parsed = orjson.loads(result) assert parsed == {"0": ["variables.input.document"]} @@ -74,7 +74,7 @@ def test_build_file_map_both_custom() -> None: """Test building file map with both custom values.""" result = MultipartBuilder.build_file_map(file_key="attachment", variable_path="variables.attachment") - parsed = ujson.loads(result) + parsed = orjson.loads(result) assert parsed == {"attachment": ["variables.attachment"]} @@ -92,7 +92,7 @@ def test_build_payload_with_file() -> None: # Check operations assert "operations" in result assert result["operations"][0] is None # No filename for operations - operations_json = ujson.loads(result["operations"][1]) + operations_json = orjson.loads(result["operations"][1]) assert operations_json["query"] == query assert operations_json["variables"]["other"] == "value" assert operations_json["variables"]["file"] is None # File var should be null @@ -100,7 +100,7 @@ def test_build_payload_with_file() -> None: # Check map assert "map" in result assert result["map"][0] is None - map_json = ujson.loads(result["map"][1]) + map_json = orjson.loads(result["map"][1]) assert map_json == {"0": ["variables.file"]} # Check file @@ -134,7 +134,7 @@ def test_build_payload_sets_file_var_to_null() -> None: query=query, variables=variables, file_content=file_content, file_name="test.txt" ) - operations_json = ujson.loads(result["operations"][1]) + operations_json = orjson.loads(result["operations"][1]) assert operations_json["variables"]["file"] is None assert operations_json["variables"]["other"] == "value" @@ -170,7 +170,7 @@ def test_build_payload_preserves_existing_variables() -> None: file_name="test.txt", ) - operations_json = ujson.loads(result["operations"][1]) + operations_json = orjson.loads(result["operations"][1]) assert operations_json["variables"]["nodeId"] == "node-123" assert operations_json["variables"]["description"] == "A test file" assert operations_json["variables"]["nested"] == {"key": "value"} diff --git a/tests/unit/sdk/test_client.py b/tests/unit/sdk/test_client.py index 47c4df80a..369c12c59 100644 --- a/tests/unit/sdk/test_client.py +++ b/tests/unit/sdk/test_client.py @@ -664,7 +664,7 @@ async def test_method_filters_empty( VARIABLES: { - "name": "red" + "name": "red" } """ diff --git a/uv.lock b/uv.lock index 29ca4dd10..980144e48 100644 --- a/uv.lock +++ b/uv.lock @@ -686,7 +686,6 @@ dependencies = [ { name = "pydantic" }, { name = "pydantic-settings" }, { name = "tomli", marker = "python_full_version < '3.11'" }, - { name = "ujson" }, { name = "whenever" }, ] @@ -739,7 +738,6 @@ dev = [ { name = "ty" }, { name = "types-python-slugify" }, { name = "types-pyyaml" }, - { name = "types-ujson" }, { name = "yamllint" }, ] lint = [ @@ -762,7 +760,6 @@ tests = [ types = [ { name = "types-python-slugify" }, { name = "types-pyyaml" }, - { name = "types-ujson" }, ] [package.metadata] @@ -795,7 +792,6 @@ requires-dist = [ { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.1.0" }, { name = "typer", marker = "extra == 'all'", specifier = ">=0.15.0" }, { name = "typer", marker = "extra == 'ctl'", specifier = ">=0.15.0" }, - { name = "ujson", specifier = ">=5" }, { name = "whenever", specifier = ">=0.9.3,<0.10.0" }, ] provides-extras = ["all", "ctl"] @@ -822,7 +818,6 @@ dev = [ { name = "ty", specifier = "==0.0.14" }, { name = "types-python-slugify", specifier = ">=8.0.0.3" }, { name = "types-pyyaml" }, - { name = "types-ujson" }, { name = "yamllint" }, ] lint = [ @@ -845,7 +840,6 @@ tests = [ types = [ { name = "types-python-slugify", specifier = ">=8.0.0.3" }, { name = "types-pyyaml" }, - { name = "types-ujson" }, ] [[package]] @@ -2738,15 +2732,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/bd/e0/1eed384f02555dde685fff1a1ac805c1c7dcb6dd019c916fe659b1c1f9ec/types_pyyaml-6.0.12.20250915-py3-none-any.whl", hash = "sha256:e7d4d9e064e89a3b3cae120b4990cd370874d2bf12fa5f46c97018dd5d3c9ab6", size = 20338, upload-time = "2025-09-15T03:00:59.218Z" }, ] -[[package]] -name = "types-ujson" -version = "5.10.0.20250822" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/5c/bd/d372d44534f84864a96c19a7059d9b4d29db8541828b8b9dc3040f7a46d0/types_ujson-5.10.0.20250822.tar.gz", hash = "sha256:0a795558e1f78532373cf3f03f35b1f08bc60d52d924187b97995ee3597ba006", size = 8437, upload-time = "2025-08-22T03:02:19.433Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/f2/d812543c350674d8b3f6e17c8922248ee3bb752c2a76f64beb8c538b40cf/types_ujson-5.10.0.20250822-py3-none-any.whl", hash = "sha256:3e9e73a6dc62ccc03449d9ac2c580cd1b7a8e4873220db498f7dd056754be080", size = 7657, upload-time = "2025-08-22T03:02:18.699Z" }, -] - [[package]] name = "typing-extensions" version = "4.15.0" @@ -2789,106 +2774,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" }, ] -[[package]] -name = "ujson" -version = "5.13.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/89/7a/c8bb37c8f6f3623d60c33d15d18cd6d6655d0f9c3eb31a9969f76361b199/ujson-5.13.0.tar.gz", hash = "sha256:d62e3d7625384c08082abad81a077af587fdef2761bb14c3822f4234b8d07d75", size = 7166784, upload-time = "2026-06-14T22:36:50.209Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/a4/ff15f528d386f47b1972859f25587da017d5be84c75076b380fedde318fe/ujson-5.13.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:770643b4752266c5a466149848b78c3874940926a4ecef304f518b2b6cdb432f", size = 56497, upload-time = "2026-06-14T22:34:58.806Z" }, - { url = "https://files.pythonhosted.org/packages/8e/d2/92e83af35cd65d3b7c47fb0e24927aa2b478897af8b0dd0ee19abcd50c03/ujson-5.13.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:a8e8c1203cb1a27720debc334f840a9170da741503522f86999710cb4738fbe3", size = 54301, upload-time = "2026-06-14T22:35:00.301Z" }, - { url = "https://files.pythonhosted.org/packages/96/9c/3eb2d21778dbd0a9f1adae7ce73e4f6c981efa7fab534eb28afde31fd25e/ujson-5.13.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:65c1813fdd742fe3c249d9c417fa490e5b54e8a91bf343a88486ec50d175c444", size = 59968, upload-time = "2026-06-14T22:35:01.612Z" }, - { url = "https://files.pythonhosted.org/packages/9a/52/f5b72747349c66552396166a13424a717df70e76a18ac5bbedc84c244407/ujson-5.13.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5aa9bf16f0131812720dd4ae70bc1a0cf68f79e93c07c66100d328e75944b567", size = 53434, upload-time = "2026-06-14T22:35:02.644Z" }, - { url = "https://files.pythonhosted.org/packages/4b/73/103c2c6df7bfe57e01fb6006551188b4dc9f873cb5f72146bd5b177b75f9/ujson-5.13.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:82c17b904c03c2b9629486ec91a8fa46a15f10d03504284f54ed7257a917d9f1", size = 54976, upload-time = "2026-06-14T22:35:03.759Z" }, - { url = "https://files.pythonhosted.org/packages/c8/ba/44fcf17cc93be7c6647fef2e556d703791659280e6409623dea854e6db7d/ujson-5.13.0-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b0b5f6983b2469db00e540b68fa8297b7a0ccd0d5173c60cc3e84f336b09395f", size = 58239, upload-time = "2026-06-14T22:35:04.842Z" }, - { url = "https://files.pythonhosted.org/packages/0d/60/10f6c92b549cff72e555a900ee7128b992ebf510bad0eebd946e72b68ecb/ujson-5.13.0-cp310-cp310-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b877fe7926107d25d54b655c5b8dd94b294b22c157233163ad29fdb54ac10cf4", size = 57876, upload-time = "2026-06-14T22:35:05.998Z" }, - { url = "https://files.pythonhosted.org/packages/71/5e/b8b2806996c75d4c34cf92ec04c5fff58a645a32632f27836886a718cb87/ujson-5.13.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f3cae1c811e787b9500e2830af8632dcb32a78dea5baf15aac51d681c59a59dd", size = 1037734, upload-time = "2026-06-14T22:35:07.081Z" }, - { url = "https://files.pythonhosted.org/packages/d4/02/baaf3ad0bb4b2c3bec3d9ff8a59768d39d20201146742e8581f8a0f87e77/ujson-5.13.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:820b78a6a183ab6591b2ea888020032ef0fe328d852af9a5c8d8084ababb2218", size = 1197048, upload-time = "2026-06-14T22:35:08.319Z" }, - { url = "https://files.pythonhosted.org/packages/8e/aa/a945aeba9d463e335169b3c0ee60adc147e22e80bb636fe0f185bed1247f/ujson-5.13.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b1209c985d1f4c2ae085ced7325509650cdb3533bb7294558dd1f26d48377942", size = 1090118, upload-time = "2026-06-14T22:35:10.014Z" }, - { url = "https://files.pythonhosted.org/packages/cc/68/57e084026ab657ab0443017fd0aa51009300bbd2eb09f5113af113703e8b/ujson-5.13.0-cp310-cp310-win32.whl", hash = "sha256:326553ae6c063c8246974906a6c137a0780fe46d143abebc52cd2cadda0f1814", size = 40008, upload-time = "2026-06-14T22:35:11.276Z" }, - { url = "https://files.pythonhosted.org/packages/56/8a/da9bb80765a5ea582ff8c1bd2f3c3909c05733314c55652d1c0bd34514f8/ujson-5.13.0-cp310-cp310-win_amd64.whl", hash = "sha256:0354d6b50b0d153ed7c629845b18a953d0c727b7e768fd94a94e0602abfa1f29", size = 40947, upload-time = "2026-06-14T22:35:12.323Z" }, - { url = "https://files.pythonhosted.org/packages/ab/b1/eaf4308ff7e7dc5aba78d7479102a69fa5fbe65e1b02f16c62b553a0b506/ujson-5.13.0-cp310-cp310-win_arm64.whl", hash = "sha256:7b4a05f61a96553995da6a4d502e07bc8aa5d07a90031df006239c6b00ce1c83", size = 38975, upload-time = "2026-06-14T22:35:13.218Z" }, - { url = "https://files.pythonhosted.org/packages/58/dc/2fcf821896803248122835c800e74f4582de9d6092efb37152acd7f79bdc/ujson-5.13.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4b7badefa73f96bad9e295ea22bd06967b851c8aad68c74196437e3584f25de5", size = 56496, upload-time = "2026-06-14T22:35:14.362Z" }, - { url = "https://files.pythonhosted.org/packages/b5/c6/83db69f96dc12509c9510084c0389c4aff4dcf427f9b613d1a23abd446ce/ujson-5.13.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:09effd42924a80df20a63b31a1ede905e66b0ce24aafe7a4cbedb05c783f8bb3", size = 54300, upload-time = "2026-06-14T22:35:15.522Z" }, - { url = "https://files.pythonhosted.org/packages/f0/6c/6d87206988172015781b9ff842c0a7eca897c026b2e7a95e11d86d46ddf5/ujson-5.13.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:89d0bfc986d02b4ce76b00e0f560bc8d30dfe8c05a1bfd8529e085eb6c1a77d9", size = 59976, upload-time = "2026-06-14T22:35:16.636Z" }, - { url = "https://files.pythonhosted.org/packages/ee/43/d28ca5e6c3d8c467a4c6296404067f4eee9d67dfeeebeb3c5fb0bd6cb958/ujson-5.13.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:cdd9618e07b3b142a02f0ab8227fd52453688b8e8e60ac0511f13a25fa8009db", size = 53476, upload-time = "2026-06-14T22:35:17.664Z" }, - { url = "https://files.pythonhosted.org/packages/a9/99/d6bb0e18188954326b38499ae61b04ce8ead6425b8844384e537a491267b/ujson-5.13.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0806683e8171ec06817e6af22f14ba0cd2f16def8a2ffb22a28a10615249355d", size = 54962, upload-time = "2026-06-14T22:35:18.693Z" }, - { url = "https://files.pythonhosted.org/packages/c6/5f/8f1ce659a59ef9510fa47b95773442049a383c533a645b2da8beeeec90f1/ujson-5.13.0-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1cf46c79498c81f088cad4165b1669a78bba7bfbeb778c7cc1aff316e062b0d", size = 58265, upload-time = "2026-06-14T22:35:19.775Z" }, - { url = "https://files.pythonhosted.org/packages/ad/8e/9d11af9d1d19ea239b0d289cf62a611cb4f2da9ec0d46b826767f4ab1768/ujson-5.13.0-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2282039eb26f08ed1a1381360395f8a310f39a45ef7314cb0f258a7d2917ea3", size = 57874, upload-time = "2026-06-14T22:35:20.896Z" }, - { url = "https://files.pythonhosted.org/packages/25/e2/7891c4a1c954307ab6a575686897a4963aad36c284742216f52dc40cba4a/ujson-5.13.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:2c75eb7fac0fe92925b959cf2fa18f88d9fb76b10781fd2a6ccb895d5fb89171", size = 1037746, upload-time = "2026-06-14T22:35:21.964Z" }, - { url = "https://files.pythonhosted.org/packages/89/4e/8a4ce87d3eaaee398f4238f74f128c6eb34269f041659995b2c09710ae7c/ujson-5.13.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:12078e81def2790140583abefc1b979f3c77be15d53c524bf0e232f669822052", size = 1197022, upload-time = "2026-06-14T22:35:23.183Z" }, - { url = "https://files.pythonhosted.org/packages/ac/fb/a1d0a6a83a13adee3a13cad308dcd3251ac53c4bd781f737242ad2f10d19/ujson-5.13.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:e12192a37c6c0e476554b62647acdf6139a47b6f13d8bad8dd2316763c4cee12", size = 1090116, upload-time = "2026-06-14T22:35:24.421Z" }, - { url = "https://files.pythonhosted.org/packages/2b/6c/4aed5dce0161d6b8c95c5da760477602a05e2a540a762424395acad9aec0/ujson-5.13.0-cp311-cp311-win32.whl", hash = "sha256:ae53b3f046529c193d533ca8111492330b204d6007611cdfa20e8b764c7c1389", size = 39974, upload-time = "2026-06-14T22:35:25.779Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c5/f9f3cf19f6ac29e07782eafed562fe0a7cb451b44bd1664c58fe8974235b/ujson-5.13.0-cp311-cp311-win_amd64.whl", hash = "sha256:2275bbaaea3eddd2e8ec0863e28a420f5f520b14a760fc3f1e49fd07a974448c", size = 40941, upload-time = "2026-06-14T22:35:26.774Z" }, - { url = "https://files.pythonhosted.org/packages/15/ec/46058bbbbe45e054cdb9f1dc0bf416fbbd5fec06bf3b4987c2437e496350/ujson-5.13.0-cp311-cp311-win_arm64.whl", hash = "sha256:32a59e7151fe2fec8fdf9a565ee66fcf87918d48827e21cdab5e15ff9f274b78", size = 38974, upload-time = "2026-06-14T22:35:27.871Z" }, - { url = "https://files.pythonhosted.org/packages/ee/ae/b66deca15da1f7faf6952d8eddf55978482bcbfd294ed2afe2c526ea325f/ujson-5.13.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bf81570ac056cb058f9117b52ca5dd800bfe9381d0076d0bb30a08a54591d654", size = 56743, upload-time = "2026-06-14T22:35:28.863Z" }, - { url = "https://files.pythonhosted.org/packages/88/4f/b03bcc9eaf4621ac9008dec90918d8fb4839d611666cb99eb255696c67fe/ujson-5.13.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7edf16359c52ed53406e216565d83e6b98c23c3cb9a0a01673f2493f8fb15edf", size = 54390, upload-time = "2026-06-14T22:35:29.857Z" }, - { url = "https://files.pythonhosted.org/packages/77/79/f98c6c1a4ed9d92d39d5d2d133f2b6fce5da11ea50c341117aedde8011c4/ujson-5.13.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:24539618fb3243cfdf27dab9a850acab80798a01501e9586b61fb9ecd016a891", size = 60047, upload-time = "2026-06-14T22:35:30.857Z" }, - { url = "https://files.pythonhosted.org/packages/bc/1d/f68e14cf476d149945211142f4c20782c1f232c489e8edcc4f4b58ce4997/ujson-5.13.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fdde6341d213b29f413b5fa9fad1392d5408074c75f0900ed949e97e546fa5df", size = 53437, upload-time = "2026-06-14T22:35:31.835Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1a/5718237cf4141e5be46ff371387e90b01f27774cb6f0f79ff4803a2430ca/ujson-5.13.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:229faf041ef249ee3fd57bac1cedb123d2718ab63f6ccd50eca95ea902eb0dca", size = 55057, upload-time = "2026-06-14T22:35:32.897Z" }, - { url = "https://files.pythonhosted.org/packages/f6/6f/7f55c1e9e0be87beebaed553fa186ad5f6d5d639cbaa9d49f78f2f91c3a9/ujson-5.13.0-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d02f31c2f59cc6a1c2c3633b377701fc2d8e876cc01950735d7a01132ccc233", size = 58186, upload-time = "2026-06-14T22:35:34.055Z" }, - { url = "https://files.pythonhosted.org/packages/bc/c4/9a34ade542426f56a0bc042f774073d1c247ae7575363c27587788cb2b2f/ujson-5.13.0-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ea7204e9fa7538bfbb1396e1ee8c2bbcd3818b3633ef5bb14d4fdea52994d14d", size = 57935, upload-time = "2026-06-14T22:35:35.05Z" }, - { url = "https://files.pythonhosted.org/packages/36/06/407633f0709e168107f56368bd5a0fa8fe07acd7f1d3000710bc0bb07470/ujson-5.13.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7c5a2478a3a1fa4421f7e035b87194eea0cf44c7971a3f32ad1b42a0dfd63c03", size = 1037685, upload-time = "2026-06-14T22:35:36.022Z" }, - { url = "https://files.pythonhosted.org/packages/c3/df/eb5bd92dc1b23254fea5b2022007baff5491a7478bfcf7e9260d3a10f1ac/ujson-5.13.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:b535e0970c96957e999cfe5ec89361f0e8d0bb987fb5d5144f6f495cb3ed9e19", size = 1197141, upload-time = "2026-06-14T22:35:37.38Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1c/65f2ce1a0411ec9a87339db01f0d5d554a49c4248ec68ab52a1b7e14e9c4/ujson-5.13.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3d0ad1207694988498fca7e0bb28eba7564fa33261d2f9fdf66a3aaab376b803", size = 1090225, upload-time = "2026-06-14T22:35:38.95Z" }, - { url = "https://files.pythonhosted.org/packages/73/53/310aabff0704f9c7ef0d3f431ce8b8e3147c3cca25334a205615c511f65e/ujson-5.13.0-cp312-cp312-win32.whl", hash = "sha256:d6bc9fa43a49e403c68c7eb164eef0feee9dd29474a7c6e0d3b6267025371990", size = 40075, upload-time = "2026-06-14T22:35:40.44Z" }, - { url = "https://files.pythonhosted.org/packages/b5/23/d3536d8945d1bb00248d998c8dcbe678a884681ad181072daecfafe4eea6/ujson-5.13.0-cp312-cp312-win_amd64.whl", hash = "sha256:6692d49ff970aaa5008f4a6fe06974bc91fd957bf13173f765e46d8ba44906ea", size = 41097, upload-time = "2026-06-14T22:35:41.39Z" }, - { url = "https://files.pythonhosted.org/packages/72/a1/4b147c06ee5bb14bec6e26786358c8510c4d75e28b88146a6ac7620f1f71/ujson-5.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:5737ffe0740a788b0e6255f0ffb281db49305fd6e6a587be44c73d9e92b554c4", size = 38875, upload-time = "2026-06-14T22:35:42.357Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f1/fe8a467d8ff5821e076b96f398d3acfe3cd568d900e6ccb41b215592b152/ujson-5.13.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46998fc8d11aec34a20e2010905e7059732a3d192d9a3c3fe4f9ffd146c87ec8", size = 56746, upload-time = "2026-06-14T22:35:43.398Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c0/c7ab82d6471dfa7e4fd68ae6ff2c6a50d077c05d6ecdea0cec8af635b2c4/ujson-5.13.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ee03ce288ba25b05cf0de87203165642277a25caa4f00a437e13152e5214e310", size = 54388, upload-time = "2026-06-14T22:35:44.586Z" }, - { url = "https://files.pythonhosted.org/packages/10/e6/4e9e998d991ff88bbc93b21daa63bba2baa61c6f952dbcec937cf7304ebe/ujson-5.13.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:cdf33b588a81b05d0b585c66f83050c49cb670623424d10e4d1ad37ba2f7eed9", size = 60051, upload-time = "2026-06-14T22:35:45.567Z" }, - { url = "https://files.pythonhosted.org/packages/9c/11/876dff43f05417a01c6119f0fa10e01f1226631c5927ef08f56876b2bb67/ujson-5.13.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:4cabd73c114ce93c21d7db2e2d8e16217fd8a5b2b3ec754629eebef5c262d47f", size = 53438, upload-time = "2026-06-14T22:35:46.623Z" }, - { url = "https://files.pythonhosted.org/packages/09/02/f9dbf6c3e46d700eb1d9ed637567221a06eeb1ec289633be992ef54d7a34/ujson-5.13.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ffc61fc756a64f4d169a78cc638d769e3c324f45fc51997626abf4e5e5dd6460", size = 55060, upload-time = "2026-06-14T22:35:47.647Z" }, - { url = "https://files.pythonhosted.org/packages/bc/3d/7e49a70265a1e5ed1b5e8edd5f54d57ae41e2134faeae9b16f6f5a0eae20/ujson-5.13.0-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c00323c13a35822c9a67a26c0b2a0787510bf1ef490922b58009b362d1a3e21", size = 58189, upload-time = "2026-06-14T22:35:48.617Z" }, - { url = "https://files.pythonhosted.org/packages/66/34/b64278f67e19052f09810576c7e50b3da8d4f5218b226046324d4d5c24b4/ujson-5.13.0-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:496e662a6b46d5f936d77fb68259cece19213bb2301ddd520dbd75ac7c90c5f4", size = 57941, upload-time = "2026-06-14T22:35:49.674Z" }, - { url = "https://files.pythonhosted.org/packages/a9/8c/51513357a5c75bf3e5bae46accfdb3e6e6f5caeb72ca8b253ec45ba853fb/ujson-5.13.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7fd41b86444df14f8b4b7afaaa9f27bacfbf8c18380872317aeab6cd125dcede", size = 1037688, upload-time = "2026-06-14T22:35:50.699Z" }, - { url = "https://files.pythonhosted.org/packages/54/5a/dc6afe071d6b977390d2dc41e15800a2716f317988dd03187cffe7b4d624/ujson-5.13.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:bf3c2c4ea55d4187903fcdc689a9bf5b0fc72d8c0eaff39db18c1f337c8832c1", size = 1197141, upload-time = "2026-06-14T22:35:52.052Z" }, - { url = "https://files.pythonhosted.org/packages/50/23/b473d101412c68527cb502a8728f96ab307aa7bfa75d6ea2037e2c7f74e8/ujson-5.13.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:b6eca7751d61045a9b1e7f9a8c97ac24b164f085b60bef1c4668654bb2338011", size = 1090235, upload-time = "2026-06-14T22:35:53.589Z" }, - { url = "https://files.pythonhosted.org/packages/fa/0a/8e583cce90f9f91ca1bedb3e628b6f5642aed91feb29b197431268d4c4be/ujson-5.13.0-cp313-cp313-win32.whl", hash = "sha256:b63d3820f978bc8e98cc3f1fe26a33b0d2ea237733a23fe5e9cb5d51f466bd97", size = 40069, upload-time = "2026-06-14T22:35:55.019Z" }, - { url = "https://files.pythonhosted.org/packages/b4/b5/1fe203bc294e98fdd65606883692ad8dc0aaac73838b89c99c3513404424/ujson-5.13.0-cp313-cp313-win_amd64.whl", hash = "sha256:17a59d5cf23ef98f7c9314524976b4b288374d83200add01d953024fb06404f9", size = 41098, upload-time = "2026-06-14T22:35:55.966Z" }, - { url = "https://files.pythonhosted.org/packages/50/5e/aceadce24fdb7cbc67f02286b1d4e91a575aaef5afb876c9908d6e6e5769/ujson-5.13.0-cp313-cp313-win_arm64.whl", hash = "sha256:b49516fbe803ff30d6caa9ccc3799ec7f968992747ce3099eae4758928577b53", size = 38877, upload-time = "2026-06-14T22:35:56.936Z" }, - { url = "https://files.pythonhosted.org/packages/1c/9a/b5139d696f5328f3cab70b9ec046f15e3f49497a4de6280974640602f539/ujson-5.13.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:cc9dfd41fed397ab03bb9d9fe1cbd83301211c772a17536033ce7d68877ac82b", size = 56897, upload-time = "2026-06-14T22:35:57.974Z" }, - { url = "https://files.pythonhosted.org/packages/53/55/477183aeddfdf0f88ae039ffee0ed866cfb993da0c0c9aa915807554aef8/ujson-5.13.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ca7ef2fa6c408a7c0f558e4d33d93b32ddc35ed6d3cfc505747931a64b7465d5", size = 54451, upload-time = "2026-06-14T22:35:58.932Z" }, - { url = "https://files.pythonhosted.org/packages/ea/63/55e5f23e156b4c8bca095d828b4cd3180c0b42aa3501ef88836d79606fea/ujson-5.13.0-cp314-cp314-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:a554b2e5bee85030369514cef8b0b913cebe1a4c2c0c13541966d50bcba22b1a", size = 60053, upload-time = "2026-06-14T22:35:59.969Z" }, - { url = "https://files.pythonhosted.org/packages/26/b6/08c6cf5548bd6f4bb557c9fa7e8edf87324bb04c17249d1966028d61dde0/ujson-5.13.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ea939ff629ab03ae970d03eca6d1febd8ed55ba38ca44aec64ce997537cd3fa0", size = 53481, upload-time = "2026-06-14T22:36:01.007Z" }, - { url = "https://files.pythonhosted.org/packages/6e/b3/0ac9a03551467784067f505df1bb875c639ba32f1da79ce467ab15911ada/ujson-5.13.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b98bf2faa5e37ecfe752226ea08290031e375a0c43d425a0b955fb3e702a2a71", size = 55058, upload-time = "2026-06-14T22:36:02.297Z" }, - { url = "https://files.pythonhosted.org/packages/ba/be/ec91029aec067174473d022fa0f6c3c1431a173f888d7599739f05c668eb/ujson-5.13.0-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9a4b92344b16e414aeb609e57f62c466500e53c94f1698f5b149dc0b7223ec3e", size = 58225, upload-time = "2026-06-14T22:36:03.321Z" }, - { url = "https://files.pythonhosted.org/packages/29/33/a948f329252ece3f9c93d177243de6e677927ebc6ac44256742dbbef3c39/ujson-5.13.0-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7df805aad707507a1fa165fb716218ca3a89f142125dc4b23c9fcc08fa402d97", size = 57930, upload-time = "2026-06-14T22:36:04.385Z" }, - { url = "https://files.pythonhosted.org/packages/f1/0c/c33655218b8e0a8adbf066de0b999cae5c324061f3eaa4dda17423145d9e/ujson-5.13.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7576bdbef327c3528f011002a2d74486f6fe4e33289bdb7a042b7f1a6e9d8285", size = 1037728, upload-time = "2026-06-14T22:36:05.467Z" }, - { url = "https://files.pythonhosted.org/packages/2c/bd/d286947525ea7ce3f2d8dc55c15b9ffbe425bc455c96af7b8f8a402599a9/ujson-5.13.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:6eee5d7cce3f32a468905f9ff61807a60287a90258d849460f6fa826e810870d", size = 1197146, upload-time = "2026-06-14T22:36:06.839Z" }, - { url = "https://files.pythonhosted.org/packages/3c/3c/9eb916377050b0785f048a34588c1c390ddd41ae00b78db68ee1ad022356/ujson-5.13.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:144e9d8a454cfa727e0f755e1863738ed68068583bda5463052cb446835bd56c", size = 1090223, upload-time = "2026-06-14T22:36:08.329Z" }, - { url = "https://files.pythonhosted.org/packages/dd/5f/242fd97a2628b842d4bfaa9b18e1f68187f934d67503291ebbaab1254637/ujson-5.13.0-cp314-cp314-win32.whl", hash = "sha256:576f35c35b918d67d41b933878062ec0a5c9f4d1e9e14e04aeef35384963feae", size = 41223, upload-time = "2026-06-14T22:36:09.644Z" }, - { url = "https://files.pythonhosted.org/packages/23/f3/7f2bd9ca0c507142d0c22347b3d6f8803be1d8851c31707e57f5923fdbea/ujson-5.13.0-cp314-cp314-win_amd64.whl", hash = "sha256:d5e206e9f849ead27e51ef8da44e52b38da7c6dbd929a7340ab44533edcda8d7", size = 42265, upload-time = "2026-06-14T22:36:11.043Z" }, - { url = "https://files.pythonhosted.org/packages/b0/29/3e9a8fba321c031315f6d263510969a5d01f41fc471b5be107e413c1b2f8/ujson-5.13.0-cp314-cp314-win_arm64.whl", hash = "sha256:dc470179775468f9a007d3a6a2734624248c94bf47c6645e808c7e50a5070d1a", size = 40205, upload-time = "2026-06-14T22:36:12.286Z" }, - { url = "https://files.pythonhosted.org/packages/12/e9/1c543837c6a3c6672361882a0fa269bd02daf9cc4c0ca88a9dccd9df98d9/ujson-5.13.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:69b4e36bb7d5f413ba8c00c8006b2ec627cc5ace97301462f6aadb66ec9d2979", size = 57402, upload-time = "2026-06-14T22:36:13.238Z" }, - { url = "https://files.pythonhosted.org/packages/99/e4/39862f0f7174ff07cfd1e2d0c9065ded34aeebdb7db8daf2f0e5bf89b46f/ujson-5.13.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8b644d50f66de5490c1823c7176618cead5e8e8a88cba9f40a6308ca52e79267", size = 54973, upload-time = "2026-06-14T22:36:14.432Z" }, - { url = "https://files.pythonhosted.org/packages/02/66/f53d3b32c3f177f846ca6b624e832f29000d8a213a2d8768e254bd470ced/ujson-5.13.0-cp314-cp314t-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:15107aaa4f559d55201165ec32abb35c283a861be1fa67229578cb7d93fcd93a", size = 60683, upload-time = "2026-06-14T22:36:15.806Z" }, - { url = "https://files.pythonhosted.org/packages/e6/d4/dddc4646d2633c85c938c2ded7d5a9711cdad5be1e13b31b7dad76f61c83/ujson-5.13.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:6e343c5f0c058523f1edbf6ae4eceb4e0d934205a53bbdd8d9a945c83324662a", size = 54167, upload-time = "2026-06-14T22:36:16.952Z" }, - { url = "https://files.pythonhosted.org/packages/5f/c0/d8608c3f4d3f05e6441364b63fde1d279700135c1a6577a773662c07fbcc/ujson-5.13.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:02200035bc80e830f076ffc1b329a94c295aee6d9de8c9043647cb9a7bd4f76f", size = 55568, upload-time = "2026-06-14T22:36:17.975Z" }, - { url = "https://files.pythonhosted.org/packages/22/8e/dd12b735aaba0806c3d70c18184d50e1f9712e0757c7c0a4f376450cfe28/ujson-5.13.0-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f19b81b73ff28f5c5022ee794f94122bfcda07a76423078e349465d71223a1", size = 59086, upload-time = "2026-06-14T22:36:19.071Z" }, - { url = "https://files.pythonhosted.org/packages/48/43/ad41e8752d5ec3a590a5e7b426a54e36b7aab911d9b5a4f7384dc62507ab/ujson-5.13.0-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:82e1393e6dbe3c95fdfc95c6c528890e191351a1f024ef51126cf1f22543af52", size = 58667, upload-time = "2026-06-14T22:36:20.171Z" }, - { url = "https://files.pythonhosted.org/packages/e1/8e/b44a6afb77b94118655c029081b7932d64bb4c5b1c8ba2b7f5808b5d0bc2/ujson-5.13.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:38afcf994b28ed85ea2420e2a8d79a37d0a77348b3daf53850c16edda66f942d", size = 1038553, upload-time = "2026-06-14T22:36:21.245Z" }, - { url = "https://files.pythonhosted.org/packages/7e/93/fab1d786174c8780eb3e386c73f1925a435e97fbf77c957fea4fca83994d/ujson-5.13.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:1bdf2518971586f2b413156c49d9dd8b56cc990a8647081e1bd00af60564d469", size = 1197938, upload-time = "2026-06-14T22:36:22.585Z" }, - { url = "https://files.pythonhosted.org/packages/f3/bc/2f073bb708f9d128f5d1cb39063a5f6421b1ce94c61be8661c55a189f407/ujson-5.13.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:751ad01042472f1c7c02f5c597c7aee79834e82a6cc384ca302173bbc8e8deb8", size = 1090938, upload-time = "2026-06-14T22:36:23.947Z" }, - { url = "https://files.pythonhosted.org/packages/c4/67/cdaa50bba29d7dc9eb19212755b09bb96f56596e75957c3717c6b85454de/ujson-5.13.0-cp314-cp314t-win32.whl", hash = "sha256:74f3dd61aeb01b7b2a6754e400224e819279041b3867935a55ccf57fb86a43b2", size = 41802, upload-time = "2026-06-14T22:36:25.418Z" }, - { url = "https://files.pythonhosted.org/packages/bd/66/a6e669e90083febdf6c0600d3807f6017fd4d3962d5bd6ddc605c73a06e5/ujson-5.13.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5c31317d5e4504dae8f98795358b6082fc0ef96e7394806db0a76a4a8717f446", size = 42790, upload-time = "2026-06-14T22:36:26.614Z" }, - { url = "https://files.pythonhosted.org/packages/3d/5f/fcc6c6a9d711fd8b020ca8ff65148212f0a712c809d173cd949e58de68c6/ujson-5.13.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aefd3c9c95f9b62348956396ff7b31818476f8f54dc4a4e64cbd4f0491db6fca", size = 40708, upload-time = "2026-06-14T22:36:27.721Z" }, - { url = "https://files.pythonhosted.org/packages/30/70/dbdd277d64bd3a149532567ceb082fe26f4ead58c39e0a97566ccbdf14a3/ujson-5.13.0-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3e074a1f7778d58aa3b3056bab7b6251aabb3f381808018ca2b7fb8dbdeef7ab", size = 58393, upload-time = "2026-06-14T22:36:28.702Z" }, - { url = "https://files.pythonhosted.org/packages/71/48/592c70af94a67cafacd9c840ae2980f27d511dde2732a4c0dfac8f176ae8/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:8bb53ef95d35875262b8d0aa28506ca612ddd07058bee2a90f609938e69dc801", size = 54447, upload-time = "2026-06-14T22:36:29.802Z" }, - { url = "https://files.pythonhosted.org/packages/f2/9d/2bb91e1e25a8584cb3b63544b9bd26f621173535c77ac6cae13bad8e7904/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fb296a0aa480ab88d895ddaa90372604c08ccc72323f02590612c775426ab413", size = 56066, upload-time = "2026-06-14T22:36:30.806Z" }, - { url = "https://files.pythonhosted.org/packages/76/ec/8e3802fc4a4e31e817b972bbb0e704a484d8c75ec349b3feb45fa9fb54c4/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2862f81af44b3a7e74c5d80caa118d736be1991ce6f1d5c723716fa403060cc6", size = 54938, upload-time = "2026-06-14T22:36:32.051Z" }, - { url = "https://files.pythonhosted.org/packages/4a/48/d0e3e511039b86fd1ecfe2bf761c800552d273ef8f19e71de93bf38a909e/ujson-5.13.0-graalpy312-graalpy250_312_native-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c16e07581172f08585b409246f4535dab13ee85af0e3d3cfa8684b653ca44fa8", size = 56115, upload-time = "2026-06-14T22:36:33.349Z" }, - { url = "https://files.pythonhosted.org/packages/81/b5/689613037fe691d18eae075cd141089f3a3156146be14512df92d8a9ae8f/ujson-5.13.0-graalpy312-graalpy250_312_native-win_amd64.whl", hash = "sha256:9bd0f2dd05937c3b089af316884de18c6f6182ddb8ffce597d2e7c7a9ba9f447", size = 41802, upload-time = "2026-06-14T22:36:34.523Z" }, - { url = "https://files.pythonhosted.org/packages/c7/7f/276f830bef4d530d50ff4d8f8c568002e7ebed9f64c06747b1d1c4325f02/ujson-5.13.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:96e7e019b097b4b25fccddadb369d13f412c13695fcf0680b6bf906376156151", size = 52479, upload-time = "2026-06-14T22:36:35.627Z" }, - { url = "https://files.pythonhosted.org/packages/fc/50/99b05555ef42a2ab2e26aa369c46bde6a64f8aeeb687628102e98cc78bed/ujson-5.13.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:7892ea6dd85ede6d30fbbd22af1239b9d81dabdf9b7a8f10ca6d4464d4d9b8ab", size = 49953, upload-time = "2026-06-14T22:36:36.72Z" }, - { url = "https://files.pythonhosted.org/packages/78/15/399766e8ba002bd8e5e2e45828e0e12a5dbeb4145d6a604ba3787db5eec2/ujson-5.13.0-pp311-pypy311_pp73-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:e1842e10adc8f0db0d3e8aa3a1f8b05ce0456b39e180c8553d7f36dd0bf24b6b", size = 55716, upload-time = "2026-06-14T22:36:37.833Z" }, - { url = "https://files.pythonhosted.org/packages/61/a6/825d92bce42cd442b57d89b4c20afc1737246321d3433c6194e5c35c6a11/ujson-5.13.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:c8bbb1f5ab810954fb307b6c5e68af58210c31da8569f9e6498a3958c1859e72", size = 48648, upload-time = "2026-06-14T22:36:38.878Z" }, - { url = "https://files.pythonhosted.org/packages/6c/d6/7ea7a32f35457560806375193dbc4f0d8ffd8c8adae42f86686392a76c41/ujson-5.13.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3f6d55c68985654a84a9b47ac51f2655adac4fd264e4189f832960c2270dcf70", size = 49947, upload-time = "2026-06-14T22:36:40.022Z" }, - { url = "https://files.pythonhosted.org/packages/af/b4/0bd6449ae35b6026d94f4d1a32dbc9f40c969ed1d6e36a7e26ccf56371be/ujson-5.13.0-pp311-pypy311_pp73-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b266f182d4bce74a6a9e1f86988485e8cd422efdcc7c3f537e00cc956f52678", size = 51149, upload-time = "2026-06-14T22:36:41.129Z" }, - { url = "https://files.pythonhosted.org/packages/e8/2d/39da479a8461d1d78d533940a8426a84e23708a6a7a426f2178e04443252/ujson-5.13.0-pp311-pypy311_pp73-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bfaa8302eb9bb7f5e231f256caf83040760585e32751d19155c0f0c0225f8de1", size = 52456, upload-time = "2026-06-14T22:36:42.266Z" }, - { url = "https://files.pythonhosted.org/packages/12/cc/91ff81c50b85a158878f06d6e9153227bbc04209db291a152c286a0ee4a8/ujson-5.13.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:43dee3081b00fe447c5b9e2fe7ebfd57f7bcc5dd25b8a439c26c8c174dd581be", size = 41155, upload-time = "2026-06-14T22:36:43.455Z" }, -] - [[package]] name = "urllib3" version = "2.7.0" From b3b39217627cbc11873ffba0efc392ebbb37d64d Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Wed, 15 Jul 2026 05:17:17 +0000 Subject: [PATCH 14/17] docs(specs): add orjson implementation report Implementation + review tail complete: 34/34 tasks, 8 chunks, no high+ review findings, all local-pass evidence present. Co-Authored-By: Claude Opus 4.8 --- .../opsmill-implement-report.md | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 dev/specs/002-orjson-json-migration/opsmill-implement-report.md diff --git a/dev/specs/002-orjson-json-migration/opsmill-implement-report.md b/dev/specs/002-orjson-json-migration/opsmill-implement-report.md new file mode 100644 index 000000000..803e30bf2 --- /dev/null +++ b/dev/specs/002-orjson-json-migration/opsmill-implement-report.md @@ -0,0 +1,77 @@ +# Implementation Report: Standardize SDK JSON serialization on orjson + +## 1. Header + +- **Feature**: Standardize SDK JSON serialization on orjson +- **Spec dir**: `specs/002-orjson-json-migration/` +- **Base commit**: `3830042` (HEAD at start) +- **Head commit**: `ebd1e0d` (after implementation; Phase 6 review added no code changes) +- **Branch**: `dga/feat-orjson-pd5o6` +- **Status**: **COMPLETE** — all 34 tasks `[X]`; §4 local-pass evidence has no `MISSING` rows. +- **Wall-clock**: ~40 min of sequential subagent execution (8 chunks) plus orchestrator verification. + +## 2. Chunk-by-chunk ledger + +| # | Chunk | Tasks | Outcome | Commit | Notes flagged upward | +|---|---|---|---|---|---| +| 1 | Setup (dependency) | T001–T002 | 2 ✅ | `1d84182` | Ordering override: added orjson, **kept ujson** so unmigrated modules keep importing; removal deferred to chunk 8. | +| 2 | Core (utils.py) | T003 | 1 ✅ | `debbc20` | Adapted 3 existing `decode_json` tests to the new `response.content` path; dict_hash 3 committed vectors still byte-identical. | +| 3 | Encode sites | T004–T011 | 8 ✅ | `2e9e422` | Found 8 pre-existing CLI-test failures (bracketed worktree path), unrelated. | +| 4 | Decode sites | T012–T016 | 5 ✅ | `ee902a3` | T014: confirmed guarded decode was `response.json()`, switched to `orjson.loads(response.content)`. Loosened one filter-test error-message assertion (orjson wording differs). | +| 5 | File I/O + export | T017–T019 | 3 ✅ | `4c8cd86` | recorder/playback collapsed to `write_text`/`read_text` by linter; round-trip sanity-checked. | +| 6 | pytest-plugin items | T020–T024 | 5 ✅ | `68d5835` | Decode-error pattern applied; no plugin test asserts the failure text. | +| 7 | Tests & verification | T025–T029 | 5 ✅ | `cd9c7ac` | Added non-ASCII dict_hash vector, datetime characterization, round-trip test, mock-free decode-invalid test; T029 confirmed no plugin-text change needed. | +| 8 | Finalize/Polish | T030–T034 | 5 ✅ | `ebd1e0d` | Removed ujson+types-ujson; all import greps empty; ruff+mypy green; migrated 4 test files that still imported ujson; fixed `test_query_echo` (4→2 echo indent) that T029 missed; added changelog fragment; captured perf. | + +## 3. Tasks not completed + +None. All 34 tasks are `[X]` in `tasks.md`. + +## 4. Local-pass evidence (REQUIRED) + +All timestamps 2026-07-15 UTC; environment `n/a` unless noted. + +| Test id | Type | Run command | Passed at | Env | Verbatim pass line | +|---|---|---|---|---|---| +| `tests/unit/sdk/test_utils.py::test_dict_hash` (modified, T025) | unit | `uv run pytest tests/unit/sdk/test_utils.py -q` | 04:43:29Z | n/a | `28 passed in 0.03s` | +| `tests/unit/sdk/test_utils.py::test_decode_json_malformed_bytes_raises` (new, T028) | unit | `uv run pytest tests/unit/sdk/test_utils.py -q` | 04:43:29Z | n/a | `28 passed in 0.03s` | +| `tests/unit/sdk/test_utils.py` decode_json tests (adapted, T003) | unit | `uv run pytest tests/unit/sdk/test_utils.py -q` | 04:23:05Z | n/a | `27 passed in 0.02s` | +| `tests/unit/ctl/formatters/test_json.py::test_format_detail_renders_datetime_as_str_form` (new, T026) | unit | `uv run pytest tests/unit/ctl/formatters/test_json.py -q` | 04:44:04Z | n/a | `12 passed in 0.03s` | +| `tests/unit/sdk/test_recorder_playback.py::test_recorder_playback_round_trip` (new, T027) | unit | `uv run pytest tests/unit/sdk/test_recorder_playback.py -q` | 04:43:35Z | n/a | `1 passed in 0.02s` | +| `tests/unit/sdk/test_infrahub_filters.py::test_malformed_json_raises_error` (modified, T013) | unit | `uv run pytest tests/unit -k "filter" -q` | 04:32:28Z | n/a | included in `296 passed` | +| `tests/unit/sdk/test_client.py::test_query_echo` (modified, chunk 8) | unit | `uv run pytest tests/unit/ -q` | 04:56:03Z | n/a | now passing (in `1442 passed`) | +| test swaps in `tests/unit/ctl/conftest.py`, `tests/unit/sdk/conftest.py`, `tests/unit/sdk/graphql/test_multipart.py`, `tests/integration/test_export_import.py` (ujson→orjson) | unit/integration | `uv run pytest tests/unit/ -q` | 04:56:03Z | n/a | `1442 passed` (unit); integration file not run locally (see §6) | + +Full-suite gate (chunk 8): `uv run pytest tests/unit/ -q` → `8 failed, 1442 passed, 1 xfailed`. **All 8 failures verified pre-existing/environmental** (bracketed worktree path `[dev03]` — Rich strips `[...]` as markup in path assertions; CLI-subprocess/pytester rendering). Confirmed by running base-commit `3830042` code in an equally-bracketed throwaway worktree: the same schema/menu/repo/task/jinja2 tests fail there too. Zero JSON-related regressions. + +## 5. Review findings (Phase 6) + +Ran code, errors, tests, comments, types, simplify lenses over `3830042..HEAD`. + +| Severity | Lens | File | Summary | Disposition | +|---|---|---|---|---| +| — | errors | pytest_plugin/items/* | **Positive**: migration incidentally fixed a latent bug — old `except ujson.JSONDecodeError` never caught stdlib error from `response.json()`. | n/a | +| Medium | tests | transfer/exporter/json.py | `OPT_NON_STR_KEYS` int-key coercion untested. | Deferred — flags guard always-string-keyed GraphQL data (never triggers in practice); real-path test disproportionately heavy. Recommended follow-up. | +| Low | tests | test_infrahub_filters.py | Error-message assertion loosened to a prefix (orjson wording differs from stdlib). | Accepted — tightening to orjson-specific text would be version-brittle. | +| Low | code | utils.py:95, schema/__init__.py:276 | `orjson.loads(response.content)` assumes UTF-8 vs httpx charset detection. | Accepted — Infrahub API always emits UTF-8. | +| Low | code | dict_hash → query_groups | Non-ASCII param hash shifts (raw UTF-8 vs escaped). | Accepted — documented in changelog (one-time tracking-group rename). | +| Low | code | recorder/pytest diffs | Indent 4→2 (orjson supports only `OPT_INDENT_2`). | Accepted — cosmetic; machine-reparsed / symmetric diffs; `test_query_echo` updated. | +| Low | simplify | multiple | Shared `OPT_INDENT_2\|OPT_SORT_KEYS` constant / `dumps_str` helper / `read_bytes` over `read_text`. | Advisory — not applied (would widen the mechanical diff). | + +No Critical/High findings → no inline review fixes required. + +## 6. Autonomous decisions + +- **Dependency-removal ordering** (chunk 1): kept `ujson` installed through the migration and removed it only in the final chunk, so the environment stayed importable across the loop. FR-001 still enforced at the end (all import greps empty). +- **Skipped no code (Phase 6)**: no high+ findings; the one Medium (exporter int-key test) recorded as a follow-up rather than forcing a low-value/brittle test. +- **Verified "pre-existing" failures rigorously**: the chunk subagents' `git stash` comparisons were unreliable (stash doesn't revert prior *committed* chunks). I re-verified by running base-commit code in a bracketed-path worktree, confirming all 8 unit failures are the worktree-path artifact, not regressions. +- **Integration tests deferred — local E2E not supported**: `tests/integration/` requires a live Infrahub server (testcontainers); it timed out at container startup (240s), collecting 131 items with fixture-setup errors (not assertion failures). `tests/integration/test_export_import.py` (a ujson→orjson swap) will be exercised by CI. **This does not block** (it is a `deferred — local E2E not supported` case, not a `MISSING` row). +- **Perf captured** (T034, ad-hoc, N=1000, list of 1000 mixed dicts): dumps **11.8×** faster, loads 1.3×, round-trip **2.1×** faster than stdlib json. Evidence for the motivating speedup; no committed benchmark (parity was the bar). +- **CLAUDE.md not edited** despite a speckit agent-context step — project rule forbids it. + +## 7. Suggested next steps + +1. Open a PR (`3830042..ebd1e0d`, 8 commits) — include the perf numbers above in the description (T034). +2. Confirm CI green across the Python 3.10–3.14 matrix (validates orjson wheel coverage — the one assumption not verifiable locally). +3. Optional follow-up: add the exporter int-key unit test (Medium finding, §5) if the defensive `OPT_NON_STR_KEYS` guard is considered worth pinning. +4. Optional: apply the advisory simplify refactors (§5) in a separate cleanup if desired. From 484c0b52b0547040e2e1013fde7d9521ad54ceb5 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Thu, 16 Jul 2026 05:04:30 +0000 Subject: [PATCH 15/17] ci: fix markdown-lint MD050 in orjson implementation report Wrap the __init__.py file reference in a code span so rumdl no longer parses the double underscores as strong emphasis. Co-Authored-By: Claude Opus 4.8 --- dev/specs/002-orjson-json-migration/opsmill-implement-report.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dev/specs/002-orjson-json-migration/opsmill-implement-report.md b/dev/specs/002-orjson-json-migration/opsmill-implement-report.md index 803e30bf2..3fcdd3a85 100644 --- a/dev/specs/002-orjson-json-migration/opsmill-implement-report.md +++ b/dev/specs/002-orjson-json-migration/opsmill-implement-report.md @@ -53,7 +53,7 @@ Ran code, errors, tests, comments, types, simplify lenses over `3830042..HEAD`. | — | errors | pytest_plugin/items/* | **Positive**: migration incidentally fixed a latent bug — old `except ujson.JSONDecodeError` never caught stdlib error from `response.json()`. | n/a | | Medium | tests | transfer/exporter/json.py | `OPT_NON_STR_KEYS` int-key coercion untested. | Deferred — flags guard always-string-keyed GraphQL data (never triggers in practice); real-path test disproportionately heavy. Recommended follow-up. | | Low | tests | test_infrahub_filters.py | Error-message assertion loosened to a prefix (orjson wording differs from stdlib). | Accepted — tightening to orjson-specific text would be version-brittle. | -| Low | code | utils.py:95, schema/__init__.py:276 | `orjson.loads(response.content)` assumes UTF-8 vs httpx charset detection. | Accepted — Infrahub API always emits UTF-8. | +| Low | code | `utils.py:95`, `schema/__init__.py:276` | `orjson.loads(response.content)` assumes UTF-8 vs httpx charset detection. | Accepted — Infrahub API always emits UTF-8. | | Low | code | dict_hash → query_groups | Non-ASCII param hash shifts (raw UTF-8 vs escaped). | Accepted — documented in changelog (one-time tracking-group rename). | | Low | code | recorder/pytest diffs | Indent 4→2 (orjson supports only `OPT_INDENT_2`). | Accepted — cosmetic; machine-reparsed / symmetric diffs; `test_query_echo` updated. | | Low | simplify | multiple | Shared `OPT_INDENT_2\|OPT_SORT_KEYS` constant / `dumps_str` helper / `read_bytes` over `read_text`. | Advisory — not applied (would widen the mechanical diff). | From 9020cfd50565578ddd216e62eaf372148290c965 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Thu, 16 Jul 2026 08:59:40 +0000 Subject: [PATCH 16/17] fix(orjson): preserve non-str-key coercion at arbitrary-data sites ujson and stdlib json silently coerced non-string dict keys to strings; orjson raises without OPT_NON_STR_KEYS. Add the option at the remaining arbitrary-data serialization sites (CLI transform output, CLI JSON formatter, playback request payload) to match prior behaviour, alongside the exporter/multipart sites that already had it. Add a formatter test covering int-keyed values. Co-Authored-By: Claude Opus 4.8 --- infrahub_sdk/ctl/cli_commands.py | 2 +- infrahub_sdk/ctl/formatters/json.py | 8 ++++++-- infrahub_sdk/playback.py | 2 +- tests/unit/ctl/formatters/test_json.py | 11 +++++++++++ 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/infrahub_sdk/ctl/cli_commands.py b/infrahub_sdk/ctl/cli_commands.py index f177a3b69..a3c24ac9a 100644 --- a/infrahub_sdk/ctl/cli_commands.py +++ b/infrahub_sdk/ctl/cli_commands.py @@ -355,7 +355,7 @@ def transform( json_string = ( result if isinstance(result, str) - else orjson.dumps(result, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS).decode() + else orjson.dumps(result, option=orjson.OPT_INDENT_2 | orjson.OPT_SORT_KEYS | orjson.OPT_NON_STR_KEYS).decode() ) if out: diff --git a/infrahub_sdk/ctl/formatters/json.py b/infrahub_sdk/ctl/formatters/json.py index 2e26eda37..be4f62bfd 100644 --- a/infrahub_sdk/ctl/formatters/json.py +++ b/infrahub_sdk/ctl/formatters/json.py @@ -40,7 +40,9 @@ def format_list( """ items = [extract_node_data(node, schema) for node in nodes] - return orjson.dumps(items, option=orjson.OPT_INDENT_2 | orjson.OPT_PASSTHROUGH_DATETIME, default=str).decode() + return orjson.dumps( + items, option=orjson.OPT_INDENT_2 | orjson.OPT_PASSTHROUGH_DATETIME | orjson.OPT_NON_STR_KEYS, default=str + ).decode() def format_detail(self, node: InfrahubNode, schema: MainSchemaTypesAPI) -> str: """Format a single node as a JSON object. @@ -57,4 +59,6 @@ def format_detail(self, node: InfrahubNode, schema: MainSchemaTypesAPI) -> str: """ detail = extract_node_detail(node, schema) - return orjson.dumps(detail, option=orjson.OPT_INDENT_2 | orjson.OPT_PASSTHROUGH_DATETIME, default=str).decode() + return orjson.dumps( + detail, option=orjson.OPT_INDENT_2 | orjson.OPT_PASSTHROUGH_DATETIME | orjson.OPT_NON_STR_KEYS, default=str + ).decode() diff --git a/infrahub_sdk/playback.py b/infrahub_sdk/playback.py index 72d028f5c..a54929b0f 100644 --- a/infrahub_sdk/playback.py +++ b/infrahub_sdk/playback.py @@ -48,7 +48,7 @@ def _read_request( ) -> httpx.Response: content: bytes | None = None if payload: - content = orjson.dumps(payload) + content = orjson.dumps(payload, option=orjson.OPT_NON_STR_KEYS) request = httpx.Request(method=method.value, url=url, headers=headers, content=content) filename = generate_request_filename(request) diff --git a/tests/unit/ctl/formatters/test_json.py b/tests/unit/ctl/formatters/test_json.py index 335ba8d6c..29c92817b 100644 --- a/tests/unit/ctl/formatters/test_json.py +++ b/tests/unit/ctl/formatters/test_json.py @@ -132,6 +132,17 @@ def test_format_list_includes_relationship_value(self) -> None: parsed = json.loads(result) assert parsed[0]["site"] == "DC1" + def test_format_list_coerces_non_string_keys(self) -> None: + """Attribute values with non-string dict keys serialize (keys coerced), matching prior behaviour.""" + schema = _make_mock_schema(["metadata"], []) + node = _make_mock_node({"metadata": {1: "a", 2: "b"}}, {}) + formatter = JsonFormatter() + + result = formatter.format_list([node], schema) + + parsed = json.loads(result) + assert parsed[0]["metadata"] == {"1": "a", "2": "b"} + class TestJsonFormatterFormatDetail: """Tests for JsonFormatter.format_detail.""" From b5d4e4bc7b1097bb7c1780b3fec4c34ac2586a83 Mon Sep 17 00:00:00 2001 From: Damien Garros Date: Thu, 16 Jul 2026 08:59:40 +0000 Subject: [PATCH 17/17] docs(orjson): document non-ASCII output change and known limitations Correct the overclaimed 'byte-for-byte' contract: non-ASCII data is now emitted as raw UTF-8 (not \uXXXX escapes) at every JSON output site. Document pre-migration playback recording incompatibility, the from_json large-integer float coercion, and UTF-8-only decoding. Broaden the quickstart import-verification grep to catch 'from json import' too. Co-Authored-By: Claude Opus 4.8 --- changelog/+orjson-json-migration.changed.md | 8 +++++++- dev/specs/002-orjson-json-migration/quickstart.md | 6 +++--- dev/specs/002-orjson-json-migration/spec.md | 6 +++++- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/changelog/+orjson-json-migration.changed.md b/changelog/+orjson-json-migration.changed.md index 3d05a3810..f389c2563 100644 --- a/changelog/+orjson-json-migration.changed.md +++ b/changelog/+orjson-json-migration.changed.md @@ -1 +1,7 @@ -Standardized JSON serialization across the SDK on `orjson`, replacing `ujson` and stdlib `json`. Observable behaviour is preserved except for one documented change: the tracking-group name derived from query parameters containing non-ASCII characters shifts once on upgrade (`orjson` emits raw UTF-8 where the previous library emitted escaped sequences). The previously-created group is orphaned and a new one is created; any cleanup of the orphaned group is manual. +Standardized JSON serialization across the SDK on `orjson`, replacing `ujson` and stdlib `json`. Behaviour is preserved for ASCII data, with two categories of change for non-ASCII data and legacy on-disk artifacts: + +- **Non-ASCII characters are now emitted as raw UTF-8** rather than `\uXXXX` escapes, across all JSON the SDK writes (CLI output, `infrahubctl validate --out`, telemetry and transfer exports, check logs, and pytest failure messages). The output is still valid JSON and round-trips correctly through the SDK; only its exact bytes differ, which may affect external tools that byte-compare or checksum these files. +- **The tracking-group name** derived from query parameters containing non-ASCII characters shifts once on upgrade (same raw-UTF-8 cause). The previously-created group is orphaned and a new one is created; cleanup of the orphaned group is manual. +- **Playback recordings created by an earlier SDK version** may no longer be located, because the recorded request body is now serialized compactly as raw UTF-8, changing the content hash used in the recording filename. Re-record fixtures with this version. + +Known limitations of the new serializer, unchanged from `orjson`'s defaults: the `from_json` Jinja filter returns a `float` for integers larger than 64 bits (previously an exact `int`), and JSON decoding accepts UTF-8 input only (the Infrahub API always responds in UTF-8). diff --git a/dev/specs/002-orjson-json-migration/quickstart.md b/dev/specs/002-orjson-json-migration/quickstart.md index 3c42e731f..610d59944 100644 --- a/dev/specs/002-orjson-json-migration/quickstart.md +++ b/dev/specs/002-orjson-json-migration/quickstart.md @@ -5,9 +5,9 @@ Prerequisites: `uv sync --all-groups --all-extras` (installs orjson, removes ujs ## 1. No legacy JSON library remains (FR-001 / SC-001) ```bash -# Must return nothing: -grep -rn "import ujson" infrahub_sdk/ -grep -rn "^import json\|^\s*import json$" infrahub_sdk/ +# Must return nothing (covers both `import json` and `from json import ...`): +grep -rnE "(^|[^.])\bimport ujson\b|\bfrom ujson import\b" infrahub_sdk/ +grep -rnE "(^|[^.])\bimport json\b|\bfrom json import\b" infrahub_sdk/ # Must show orjson only: grep -rln "import orjson" infrahub_sdk/ | wc -l ``` diff --git a/dev/specs/002-orjson-json-migration/spec.md b/dev/specs/002-orjson-json-migration/spec.md index 23c23ce97..de871ffbc 100644 --- a/dev/specs/002-orjson-json-migration/spec.md +++ b/dev/specs/002-orjson-json-migration/spec.md @@ -37,13 +37,17 @@ At the same time, a contributor working inside the SDK now finds exactly one JSO - **Human-facing pretty-printed output** (debug prints, test-failure diffs): indentation width may change cosmetically; this output carries no contract and is not compared programmatically. - **Non-standard numeric literals**: decoding rejects `NaN`, `Infinity`, and `-Infinity` (previously accepted by stdlib decoding), and encoding a float `NaN` now yields `null` rather than an invalid `NaN` token. These are non-standard JSON and not expected in API traffic; the change is toward stricter, valid JSON and is documented rather than specially handled. - **Special value types in CLI output**: the value domain rendered to CLI JSON is primitives plus date/time (preserved via the datetime edge case above) and string-rendered addresses/identifiers; no enum or dataclass objects flow through, so the byte-parity claim holds. A characterization test on a date/time-bearing record guards this. +- **Non-ASCII characters everywhere**: the new serializer emits raw UTF-8 instead of `\uXXXX` escapes at every output site (CLI output, `validate --out`, telemetry/transfer exports, check logs, pytest failure text). Valid JSON that round-trips through the SDK; only exact bytes differ. Documented, not specially handled — re-escaping would require post-processing on the hot path and defeats the point of the new serializer. +- **Non-string dict keys**: the new serializer raises on non-string keys unless `OPT_NON_STR_KEYS` is set, whereas the previous libraries coerced them to strings silently. The option is applied at every site that serializes arbitrary/user-supplied data — exporter, multipart, CLI transform output, CLI JSON formatter, and playback request payloads — to preserve that coercion. +- **Playback recordings from earlier SDK versions**: the recorded request body is now serialized compactly as raw UTF-8, changing the content hash in the recording filename, so pre-migration recordings are not located. Recordings are regenerated test fixtures; re-record with this version. +- **Large integers in `from_json`**: the `from_json` Jinja filter returns a `float` for integers larger than 64 bits (previously an exact `int`). Silent, vanishingly rare in template data; documented as a known limitation rather than guarded. ## Requirements *(mandatory)* ### Functional Requirements - **FR-001**: The SDK MUST use a single JSON library throughout `infrahub_sdk/`; no module may reference the previously-used JSON libraries. *Acceptance*: a search of `infrahub_sdk/` finds zero references to the legacy libraries and lint passes. -- **FR-002**: The SDK MUST preserve JSON output byte-for-byte at every site where that output is consumed, compared, or shown to users, including preserving current indentation for machine-consumed output and preserving the current textual form of date/time values in CLI output. *Acceptance*: existing CLI/formatter tests pass unchanged, plus a characterization test asserting a date/time-bearing record renders identically to the pre-migration output. +- **FR-002**: The SDK MUST preserve JSON output byte-for-byte for **ASCII** data at every site where that output is consumed, compared, or shown to users, including preserving current indentation for machine-consumed output and preserving the current textual form of date/time values in CLI output. Non-ASCII characters are exempt: the new serializer emits raw UTF-8 rather than `\uXXXX` escapes at every output site (still valid JSON, exact bytes differ). *Acceptance*: existing CLI/formatter tests pass unchanged, plus a characterization test asserting a date/time-bearing record renders identically to the pre-migration output. - **FR-003**: The parameter-hashing behaviour that feeds tracking-group naming MUST return identical values for ASCII, integer, float, and nested-dictionary inputs, and MUST have its non-ASCII behaviour pinned by an explicit test vector. *Acceptance*: the hashing test retains its existing vectors and adds a non-ASCII vector asserting the new value. - **FR-004**: The SDK MUST convert serialized output to text at every call site that requires text, so no consumer receives bytes where text is expected. *Acceptance*: type checking passes and the affected call sites' tests pass. - **FR-005**: The SDK MUST correctly read and write JSON at every site that previously serialized directly to a file handle, adapting to in-memory serialization. *Acceptance*: a record-then-replay round-trip test passes.