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
pytestpasses.ruff check .andmypyare both clean (or new findings are justified in the PR description — e.g. a necessarytype: ignore).- 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'stest_add_missing_value_raises_malformed_not_key_errorfor the pattern this project follows. - If you touch
schema.py,validation.py,operations.py, orextractor.py, please actually run the tests — those four modules were written and reviewed without a workingpydanticinstall 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, andmypyin one job;pytestacross a matrix of Python 3.10–3.13 × Ubuntu/Windows/macOS in another; then a build-verification job that runspython -m build+twine checkto 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 manualworkflow_dispatchpath 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:
- On pypi.org, either add a trusted publisher to an existing
jsonpatchkitproject, or use PyPI's pending publisher mechanism to reserve the name before the first upload (Project Settings → Publishing). - Set the owner/repo to your GitHub repo, workflow filename to
release.yml, and environment name topypi(matching theenvironment: name: pypiin the workflow). - Repeat on test.pypi.org with environment name
testpypiif you want the manual dry-run path to work too. - In your GitHub repo settings, create the
pypiandtestpypienvironments (Settings → Environments) — this is what theenvironment: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
addops) - ✅ Multiple documents/schemas in one call
- ✅ Validation-error retry loop with a distinct corrective tool
(
PatchValidationErrors, mirroringPatchFunctionErrors) - ✅ 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_policyfine-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 (a2.14.0a1alpha 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 beforeschema.pywas 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.pyandtests/test_patch.pycover the fully dependency-free core (pointer.py,patch.py) and were actually executed in that sandbox, using a small local shim providing justpytest.raises— 56/56 passed (29 + 27), at 100% line coverage for both modules, measured with Python's stdlibtracemodule. See the "Code review" section above for how that number was reached.tests/test_schema.py,tests/test_validation.py,tests/test_operations.py, andtests/test_extractor.pydepend onpydanticand 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 noNameError/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
ModelAdapterprotocol, 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)
| File | Size | Uploaded | |
|---|---|---|---|
| jsonpatchkit-0.1.0.tar.gz | 159.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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 logRelease 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