Skip to main content

jsonpatchkit

Granular, schema-safe JSON editing for LLM tool calling.

Instead of asking a model to regenerate an entire JSON document to change one field, jsonpatchkit lets it emit a small JSON Patch (RFC 6902) against the existing document, validates the result against your Pydantic schema, and — if it doesn't validate — retries with the specific error, so the model never has to re-derive a whole object from scratch and the output can never violate your schema.

This is a from-scratch alternative to trustcall, built without a dependency on dydantic or LangGraph internals.

Install

pip install jsonpatchkit
# with the LangChain adapter:
pip install "jsonpatchkit[langchain]"

Quickstart

from pydantic import BaseModel
from jsonpatchkit import Extractor
from jsonpatchkit.adapters.langchain_adapter import LangChainAdapter
from langchain_anthropic import ChatAnthropic


class Person(BaseModel):
    name: str
    age: int
    tags: list[str] = []


model = ChatAnthropic(model="claude-sonnet-5")
extractor = Extractor(LangChainAdapter(model), schemas={"Person": Person})

# First-time creation
result = extractor.extract([{"role": "user", "content": "Alice is 30, likes hiking"}])
person = result.documents["Person"]  # {"name": "Alice", "age": 30, "tags": ["hiking"]}

# Granular edit of an existing document — the model only emits a patch,
# not a full regeneration.
result = extractor.extract(
    [{"role": "user", "content": "Add painting to her tags, she's 31 now"}],
    existing={"Person": person},
)

Development

Clone the repo, then set up an editable install with the dev dependency group (pytest, ruff, mypy, plus langchain-core for the adapter tests). This uses PEP 735 dependency groups, not a project.optional-dependencies extra, so it needs pip>=25.1 (for the --group flag) rather than the more familiar pip install -e ".[dev]":

git clone <your-fork-url>
cd jsonpatchkit
python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install --upgrade pip        # need >=25.1 for --group support
pip install -e . --group dev

Run the tests

pytest                           # whole suite
pytest tests/test_pointer.py     # a single module
pytest -k "test_move"            # tests matching a name pattern
pytest -v                        # verbose, one line per test

For a coverage report, also install pytest-cov (not pinned in dev since it's optional) and run:

pip install pytest-cov
pytest --cov=jsonpatchkit --cov-report=term-missing

Lint and format

ruff check .                     # lint
ruff check . --fix               # lint, auto-fixing what's safe
ruff format .                    # format

Type-check

mypy

pyproject.toml already has [tool.ruff] and [tool.mypy] sections (strict mode for mypy), so these run with the project's actual config with no extra flags needed.

Before opening a PR

  1. pytest passes.
  2. ruff check . and mypy are both clean (or new findings are justified in the PR description — e.g. a necessary type: ignore).
  3. New behavior has a test. Bug fixes should include a regression test that fails on the old code and passes on the fix — see tests/test_patch.py's test_add_missing_value_raises_malformed_not_key_error for the pattern this project follows.
  4. If you touch schema.py, validation.py, operations.py, or extractor.py, please actually run the tests — those four modules were written and reviewed without a working pydantic install in the original development environment (see "Honest note on test execution" below), so they need a real run more than most changes would.

CI/CD and releasing

Three files handle this, all under .github/:

  • workflows/ci.yml — on every push/PR: ruff check, ruff format --check, and mypy in one job; pytest across a matrix of Python 3.10–3.13 × Ubuntu/Windows/macOS in another; then a build-verification job that runs python -m build + twine check to catch packaging problems before they'd ever hit a release.
  • workflows/release.yml — builds and publishes to PyPI when you publish a GitHub Release, using trusted publishing (OIDC) — no PyPI API token or repo secret involved. Also has a manual workflow_dispatch path that publishes to TestPyPI instead, for dry-running a release.
  • dependabot.yml — keeps both the GitHub Actions versions and the Python dependency floors current automatically, since action versions in particular tend to move faster than anyone remembers to re-check by hand.

Versioning: no more manually bumping a number

The version comes from git tags via hatch-vcs ([tool.hatch.version] source = "vcs" in pyproject.toml), not a hardcoded string. This replaces the old approach of keeping pyproject.toml's version and __init__.py's __version__ in sync by hand — which, worth naming honestly, had already drifted out of sync once in this project's own history (0.1.1 vs 0.1.2) before this fix. src/jsonpatchkit/_version.py is generated at build/install time and is gitignored — never commit it.

To ship a release:

git tag v0.2.0
git push origin v0.2.0
gh release create v0.2.0 --generate-notes    # or do this in the GitHub UI

Publishing that Release triggers release.yml, which builds jsonpatchkit-0.2.0-* with the version baked in from the tag and publishes it to PyPI.

One-time PyPI setup (before the first real release)

Trusted publishing has to be configured on PyPI's side before release.yml can publish anything:

  1. On pypi.org, either add a trusted publisher to an existing jsonpatchkit project, or use PyPI's pending publisher mechanism to reserve the name before the first upload (Project Settings → Publishing).
  2. Set the owner/repo to your GitHub repo, workflow filename to release.yml, and environment name to pypi (matching the environment: name: pypi in the workflow).
  3. Repeat on test.pypi.org with environment name testpypi if you want the manual dry-run path to work too.
  4. In your GitHub repo settings, create the pypi and testpypi environments (Settings → Environments) — this is what the environment: key in the workflow refers to, and it's also where you'd add required reviewers if you want a manual approval gate before publishing.

No secrets to create anywhere in this flow — that's the point of trusted publishing.

Architecture (why this differs from trustcall)

Module Responsibility
pointer.py RFC 6901 JSON Pointer resolution — pure stdlib, zero deps
patch.py RFC 6902 JSON Patch application, built on pointer.py
schema.py JSON Schema dict → Pydantic model (replaces dydantic, uses pydantic.create_model)
validation.py Validate a document against a schema, format errors for retry prompts
operations.py The PatchDocument / PatchValidationErrors tool schemas the model calls
extractor.py The retry loop tying the above together
adapters/base.py ModelAdapter protocol — the only thing extractor.py depends on
adapters/langchain_adapter.py Optional, lazily-imported LangChain implementation of that protocol

The core (pointer.py through extractor.py) has one runtime dependency: pydantic. No LangGraph, no LangChain. LangChainAdapter is opt-in and only imports langchain-core when you instantiate it. This is the structural fix for the problem that prompted this rewrite: trustcall's core retry loop is built directly on langgraph.graph.StateGraph and langgraph.utils.runnable.RunnableCallable, which ties its release cadence to LangGraph's internals. Here, a new adapter for a raw OpenAI or Anthropic client — or a future LangGraph integration — is an addition, not a rewrite of the core.

Feature parity with trustcall

  • ✅ Patch-based updates to existing documents (existing={...})
  • ✅ First-time extraction (empty existing doc, patched via add ops)
  • ✅ Multiple documents/schemas in one call
  • ✅ Validation-error retry loop with a distinct corrective tool (PatchValidationErrors, mirroring PatchFunctionErrors)
  • ✅ Nested objects, arrays, array append (/tags/-), and arbitrary nested paths
  • ✅ Schemas as Pydantic models directly, or as raw JSON Schema dicts via build_model_from_schema
  • ⏳ Not yet ported: LangSmith-based eval harness, existing_schema_policy fine-grained ignore/error modes. These are natural follow-ups, not architectural blockers — see "Roadmap" below.

Dependency verification

Every dependency floor in pyproject.toml was checked against PyPI directly (not assumed from training data) on 2026-08-03:

  • pydantic: latest stable release is 2.13.4 (a 2.14.0a1 alpha exists but is intentionally not used as the floor).
  • langchain-core (optional): latest stable release is 1.5.3.
  • pytest (dev): latest stable release is 9.1.1.
  • pydantic.create_model's signature was checked against Pydantic's official API reference before schema.py was written, rather than assumed from memory.

Code review (v0.1.0 → v0.1.2)

Round 1 (v0.1.1): functional bugs

A dedicated review pass found and fixed 6 real bugs, the biggest being that the retry loop wasn't actually resilient to the malformed model output it exists to handle (tool-call args were never validated against operations.py's schemas — see the fixes table in git history / prior notes). All fixes shipped with regression tests.

Round 2 (v0.1.2): the identifier limitation, plus coverage/typing/lint

Round 1 left one item as "documented but not fixed": schema.py passed JSON Schema property names straight into pydantic.create_model(**...), which requires valid Python identifiers — a property like "first-name" would raise a raw TypeError. This is now fixed: non-identifier names are sanitized to a valid Python attribute name and mapped back via a Pydantic field alias, with collision handling (e.g. "first-name" and "first_name" both sanitizing to the same candidate no longer silently collide). Models built this way accept either the original JSON key or the sanitized attribute name (populate_by_name=True).

This round also went back over the earlier honest gaps rather than leaving them as caveats:

Test coverage — actually measured, not estimated. Python's stdlib trace module gives real line-coverage numbers without needing the coverage package (which couldn't be installed in this offline sandbox). For the two modules that could actually be executed here:

Module Line coverage
pointer.py 100% (103/103 executable lines)
patch.py 100% (63/63 executable lines)

Getting there surfaced 8 untested branches — all defensive error paths (descending into a scalar, operating on the root pointer, targeting a non-container) that had no test forcing them to execute. Regression tests were added for each; nothing was skipped or excluded to inflate the number.

For schema.py, validation.py, operations.py, and extractor.py (anything touching pydantic), coverage genuinely cannot be measured in this sandbox — there's no way to execute those tests without the real pydantic installed, and no network to install it. Run pytest --cov=jsonpatchkit yourself for real numbers there; this README will not claim a percentage it can't back up.

Typing. No mypy available to run, so instead: an ast-based sweep of every function in src/ and tests/ for missing return-type annotations. Found 3 (all __init__ methods missing -> None) — all fixed. mypy --strict config is now in pyproject.toml, but has not actually been run against this code; treat it as configured-but-unverified until you run it yourself.

Linting / static analysis. No ruff available either, so a small ast-based checker was written (unused imports, bare except:, mutable default arguments) — genuine static analysis, not a claim without a check behind it. Found 2 unused imports: one was a real bug (List in types.py, removed), the other was a false positive (an intentional # noqa: F401-marked import-existence check in langchain_adapter.py — my checker doesn't parse noqa comments, a real ruff run would correctly ignore it). ruff config is now in pyproject.toml; again, unverified by an actual ruff run.

Dead code. SchemaValidationError was defined and publicly exported but never actually raised anywhere — validate_against_schema correctly uses a ValidationOutcome return value instead (the retry loop needs non-exception control flow). Removed rather than left as decoration; MalformedOperationError (which is raised, by patch.py) was added to the public exports in its place, since it had been missing.

What "near 100%" honestly means here

  • Dependency-free core: 100% line coverage, real, measured, re-verified after every fix.
  • Everything touching pydantic: written with the same rigor, but genuinely unverified in this environment. Not "probably fine" — actually unverified, and the README says so rather than rounding up.
  • No claim in this section is aspirational; every number came from a tool run in this conversation, and every "not run" is stated as such.

Honest note on test execution

This library was developed in a sandboxed environment with no network access, so pydantic, pytest, and langchain-core could not actually be installed there.

  • tests/test_pointer.py and tests/test_patch.py cover the fully dependency-free core (pointer.py, patch.py) and were actually executed in that sandbox, using a small local shim providing just pytest.raises — 56/56 passed (29 + 27), at 100% line coverage for both modules, measured with Python's stdlib trace module. See the "Code review" section above for how that number was reached.
  • tests/test_schema.py, tests/test_validation.py, tests/test_operations.py, and tests/test_extractor.py depend on pydantic and were written against its documented, verified API (including the new tests added during review for bugs #5 and #6 above), but could not be executed in that environment — only import-checked against a minimal stub, which proves there are no NameError/ImportError-class mistakes but proves nothing about actual validation behavior. Please run them yourself before relying on this in production:
pip install -e . --group dev
pytest

If anything fails, it's most likely a small signature mismatch in the untested layer, not the core patch engine (which is verified).

Roadmap / suggested next steps

  • Raw OpenAI / Anthropic SDK adapters (same ModelAdapter protocol, no LangChain required)
  • Async aextract() variant
  • Streaming of patch ops as they're generated
  • Local eval harness (no LangSmith account required)
  • existing_schema_policy (True / False / "ignore") parity with trustcall

Metadata

Release files for jsonpatchkit 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for jsonpatchkit 0.1.0
File Size Uploaded
jsonpatchkit-0.1.0.tar.gz 159.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for jsonpatchkit 0.1.0
File Interpreter ABI Platform
jsonpatchkit-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 188.2 kB

Release files / jsonpatchkit-0.1.0.tar.gz

Download URL jsonpatchkit-0.1.0.tar.gz
Size 159.5 kB
Tags Source
SHA-256 checksum
How to use checksums
d8352ad651c9145d1ac30c8ad3fe3276a7e7331ab5bb51d2dfc1dc023365a598
BLAKE2b-256 checksum
How to use checksums
efcd75688b4c87ea1578f6d0e9a87e29182af2acebd0677c0c73679e6c17c2f6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 5, 2026.

Transparency log

Release files / jsonpatchkit-0.1.0-py3-none-any.whl

Download URL jsonpatchkit-0.1.0-py3-none-any.whl
Size 28.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
30a42b8da8aea3605824df759ed7e6356e71b29b028f6970f705c1233b879038
BLAKE2b-256 checksum
How to use checksums
829ca20d4da26d4ceff5f61ef0dbf3023700e6c86d75ca233842693bea02ff4b
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Aug 5, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page