Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .clusterfuzzlite/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,10 @@ These workflows consume the `.clusterfuzzlite` build/config:
- Sanitizer: `address`
- Time budget: `fuzz-seconds: 300`
- `keep-unaffected-fuzz-targets: false`
- PRs only fuzz when fuzz-relevant inputs changed: `jsonpatchx/`, `fuzzers/`,
`.clusterfuzzlite/`, `pyproject.toml`, `uv.lock`, or one of the
ClusterFuzzLite workflow YAMLs. The required `fuzz-code-changes` job still
runs on every PR and exits green when none of those paths changed.
- Purpose: fast PR signal focused on changed code areas.

2. [`cflite_coverage.yml`](../.github/workflows/cflite_coverage.yml)
Expand Down
30 changes: 25 additions & 5 deletions .github/dependency-review-config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,17 @@
# .github/workflows/dependency-review.yml
# .github/workflows/dependency-review-full-audit.yml

# Shared action policy settings. Workflow-specific toggles such as
# comment-summary-in-pr and explicit base/head refs stay in workflow YAML.
fail-on-severity: high
retry-on-snapshot-warnings: true
retry-on-snapshot-warnings-timeout: 10
warn-on-openssf-scorecard-level: 3

# Explicit SPDX allow-list.
allow-licenses:
- Apache-2.0
- ISC
- MIT
- MIT-0
- BSD-2-Clause
Expand All @@ -20,18 +28,30 @@ allow-licenses:
# Version-pinned exceptions for dependencies where license detection is unknown.
# Keep this list narrow and remove entries when upstream metadata is detectable.
allow-dependencies-licenses:
- pkg:pypi/jsonpointer@3.1.0 # Modified BSD License
- pkg:pypi/click@8.3.2 # BSD-3-Clause
- pkg:pypi/fastapi@0.135.3 # MIT
- pkg:pypi/griffelib@2.0.2 # ISC
- pkg:pypi/jsonpointer@3.1.1 # Modified BSD License
- pkg:pypi/mkdocs-autorefs@1.4.4 # ISC
- pkg:pypi/mkdocs-get-deps@0.2.2 # MIT
- pkg:pypi/mkdocstrings@1.0.3 # ISC
- pkg:pypi/mkdocstrings-python@2.0.3 # ISC
- pkg:pypi/mypy@1.20.0 # MIT
- pkg:pypi/pymdown-extensions@10.21.2 # MIT
- pkg:pypi/pytest@9.0.3 # MIT
- pkg:pypi/pytest-cov@7.1.0 # MIT
- pkg:pypi/ruff@0.15.7 # MIT
- pkg:pypi/uvicorn@0.42.0 # BSD-3-Clause
- pkg:pypi/python-dateutil@2.9.0.post0 # Apache-2.0 AND BSD-3-Clause; also reported as LicenseRef-scancode-unknown-license-reference
- pkg:pypi/ruff@0.15.9 # MIT
- pkg:pypi/starlette@1.0.0 # BSD-3-Clause
- pkg:pypi/uvicorn@0.44.0 # BSD-3-Clause
- pkg:pypi/pyinstaller@6.19.0 # GPL-2.0-or-later
- pkg:pypi/python-jsonpath@2.0.2 # MIT
- pkg:pypi/pyinstaller-hooks-contrib@2026.3 # GPL-2.0-or-later
- pkg:pypi/pyinstaller-hooks-contrib@2026.4 # GPL-2.0-or-later
- pkg:pypi/uv@0.10.12 # Apache-2.0 OR MIT


# Allow-lists are preferred over deny-lists.
# Super-linter will be deprecated deny-licenses lists.
# Super-linter will deprecate deny-licenses lists.
# deny-licenses:
# - AGPL-1.0
# - AGPL-3.0
6 changes: 6 additions & 0 deletions .github/workflows/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,8 @@ License policy behavior:
- `allow-dependencies-licenses` is a version-pinned exception list for
dependencies where license detection is currently unknown, with inline license
notes for auditability.
- Shared action thresholds also live there, including severity threshold,
OpenSSF scorecard warning level, and snapshot-retry behavior.
- Keep exceptions narrow (package + version) and remove entries when upstream
metadata becomes detectable.

Expand All @@ -101,6 +103,10 @@ Quick trigger/permission summary:

- [`cflite_pr.yml`](cflite_pr.yml)
- Triggers: `pull_request` and `workflow_dispatch`
- PR behavior: runs fuzzing only when fuzz-relevant inputs changed
(`jsonpatchx/`, `fuzzers/`, `.clusterfuzzlite/`, `pyproject.toml`,
`uv.lock`, or the ClusterFuzzLite workflow YAMLs); otherwise the required
`fuzz-code-changes` job exits successfully without fuzzing
- Write scopes: `security-events: write` only
- [`cflite_coverage.yml`](cflite_coverage.yml)
- Triggers: weekly schedule and `workflow_dispatch`
Expand Down
42 changes: 41 additions & 1 deletion .github/workflows/cflite_pr.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ on:
- main
workflow_dispatch:

permissions: read-all
permissions:
contents: read

env:
FUZZ_STORAGE_BRANCH: fuzz-corpus
Expand All @@ -29,11 +30,47 @@ jobs:
with:
egress-policy: audit

- name: Detect fuzz-relevant changes
id: changes
# the step-security/paths-filter alternative would require a subscription
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
filters: |
fuzz_relevant:
- 'jsonpatchx/**'
- 'fuzzers/**'
- '.clusterfuzzlite/**'
- 'pyproject.toml'
- 'uv.lock'
- '.github/workflows/cflite_pr.yml'
- '.github/workflows/cflite_coverage.yml'
- '.github/workflows/cflite_weekly.yml'

# Skip the rest of the workflow if no fuzz-relevant paths were changed.
# This avoids unnecessary builds and fuzz runs for documentation updates, CI config tweaks, etc.

- name: Skip fuzzing for non-fuzz-relevant PRs
if: >
github.event_name == 'pull_request' &&
steps.changes.outputs.fuzz_relevant != 'true'
run: |
echo "No changes in fuzz-relevant paths."
echo "Skipping ClusterFuzzLite PR run."

# Continue with checkout, build, and fuzzing steps if this is a workflow_dispatch event
# or if fuzz-relevant paths were changed.

- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
if: >
github.event_name == 'workflow_dispatch' ||
steps.changes.outputs.fuzz_relevant == 'true'
with:
persist-credentials: false

- name: Build fuzzers
if: >
github.event_name == 'workflow_dispatch' ||
steps.changes.outputs.fuzz_relevant == 'true'
uses: google/clusterfuzzlite/actions/build_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
with:
language: python
Expand All @@ -47,6 +84,9 @@ jobs:
storage-repo-branch-coverage: ${{ env.FUZZ_STORAGE_BRANCH }}

- name: Run fuzzers (changed code)
if: >
github.event_name == 'workflow_dispatch' ||
steps.changes.outputs.fuzz_relevant == 'true'
uses: google/clusterfuzzlite/actions/run_fuzzers@884713a6c30a92e5e8544c39945cd7cb630abcd1 # v1
with:
github-token: ${{ secrets.GITHUB_TOKEN }}
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/dependency-review-full-audit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,9 +36,7 @@ jobs:
with:
# Centralized dependency license policy lives in this config file.
config-file: ./.github/dependency-review-config.yml
fail-on-severity: "high"
comment-summary-in-pr: false
warn-on-openssf-scorecard-level: 3
# Diff from repository root commit to current SHA to force a full-graph audit.
base-ref: ${{ steps.root-commit.outputs.base_ref }}
head-ref: ${{ github.sha }}
2 changes: 0 additions & 2 deletions .github/workflows/dependency-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,4 @@ jobs:
with:
# Centralized dependency license policy lives in this config file.
config-file: ./.github/dependency-review-config.yml
fail-on-severity: "high"
comment-summary-in-pr: true
warn-on-openssf-scorecard-level: 3
11 changes: 11 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,17 @@
"group": "test",
"problemMatcher": []
},
{
"label": "pytest-matrix",
"type": "shell",
"command": "bash",
"args": [
"-c",
"uv run --managed-python -p 3.12 pytest && uv run --managed-python -p 3.13 pytest && uv run --managed-python -p 3.14 pytest"
],
"group": "test",
"problemMatcher": []
},
{
"label": "update-openapi-snapshots",
"type": "shell",
Expand Down
45 changes: 32 additions & 13 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# JsonPatchX

A PATCH framework for Python.

<!-- markdownlint-disable MD013 -->

[![Tests](https://img.shields.io/github/actions/workflow/status/angela-tarantula/jsonpatchx/python-tests.yml?branch=main&label=Tests&style=flat)](https://github.com/angela-tarantula/jsonpatchx/actions)
Expand All @@ -11,21 +9,22 @@ A PATCH framework for Python.

<!-- markdownlint-enable MD013 -->

Documentation:
[https://angela-tarantula.github.io/jsonpatchx](https://angela-tarantula.github.io/jsonpatchx)

## About The Project

[RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) (JSON Patch) is
intentionally minimal and transport-focused. That is great for interoperability,
but modern PATCH traffic crosses trust boundaries: browser clients, internal
services, third-party integrations, and increasingly LLM-generated patch
payloads.
[RFC 6902](https://datatracker.ietf.org/doc/html/rfc6902) JSON Patch is
intentionally minimal and transport-focused. That minimalism is great for
interoperability, but modern PATCH traffic crosses trust boundaries: browser
clients, internal services, third-party integrations, and increasingly
LLM-generated patch payloads.

### JsonPatchX supports standard JSON Patch and adds a contract layer

- **Input Safety**: patch operations are Pydantic models, so malformed payloads
fail fast with clear, structured errors.

- **FastAPI Native**: set up PATCH routes quickly with minimal boilerplate.

### It also provides extensibility beyond the RFC

- **Richer Operations**: define custom patch operations such as `increment`,
Expand All @@ -42,22 +41,42 @@ payloads.

### And it treats the patch layer as a first-class contract

- **OpenAPI in Sync**: OpenAPI is generated from the same runtime patch models,
so documentation stays aligned automatically.
- **Synchronized Documentation**: OpenAPI is generated from the same runtime
patch models, so documentation stays aligned automatically.

- **Surface Control**: operations can be allow-listed per route to limit what
clients can do.

- **Lifecycle Management**: evolve operation contracts over time with additive
schema changes and deprecations.

### Integrates cleanly with FastAPI

- **Protocol Enforcement**: require `application/json-patch+json` and publish
accurate request schemas with examples in OpenAPI.

- **Predictable Failures**: patch errors map to consistent HTTP responses (422,
409, 415) with structured details.

## Getting Started

### Installation

> JsonPatchX is not on PyPI yet. Install it from a local clone instead:

<!--
```sh
pip install jsonpatchx
```
-->

```sh
git clone https://github.com/angela-tarantula/jsonpatchx.git
cd jsonpatchx
python -m venv .venv
source .venv/bin/activate
pip install -e .
```

## Usage

Expand Down Expand Up @@ -102,8 +121,8 @@ def patch_user(user_id: int, patch: JsonPatchFor[User]) -> User:
return updated
```

> **Note**: For registries, custom operations, JSONSelector/JSONPath targeting,
> and optional FastAPI route helpers, see the
> **Note**: For custom operations, JSONPath targeting, route-level controls, and
> optional FastAPI route helpers, see the
> [User Guide](https://angela-tarantula.github.io/jsonpatchx/).

## Roadmap
Expand Down
2 changes: 0 additions & 2 deletions docs/index.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
# JsonPatchX

A PATCH framework for Python.

JsonPatchX starts with RFC 6902 JSON Patch and goes farther when an API needs
more than a generic patch document.

Expand Down
18 changes: 18 additions & 0 deletions docs/user-guide/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,33 @@ with the fewest moving parts.

## Install

> JsonPatchX is not on PyPI yet. Install it from a local clone instead:

<!--
```sh
pip install jsonpatchx
```
-->

```sh
git clone https://github.com/angela-tarantula/jsonpatchx.git
cd jsonpatchx
python -m venv .venv
source .venv/bin/activate
pip install -e .
```

Optional FastAPI helpers come later:

<!--
```sh
pip install "jsonpatchx[fastapi]"
```
-->

```sh
pip install -e ".[fastapi]"
```

You do not need that extra just to use `JsonPatch` or `JsonPatchFor[...]`.

Expand Down
2 changes: 1 addition & 1 deletion fuzzers/jsonpatchx_custom_backend_fuzzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from dataclasses import dataclass
from typing import Literal, Self, cast, override

import atheris # type: ignore[import-not-found]
import atheris # type: ignore[import-untyped]

from fuzzers._fuzz_shared import (
ByteCursor,
Expand Down
2 changes: 1 addition & 1 deletion fuzzers/jsonpatchx_fuzzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
from dataclasses import dataclass
from typing import cast

import atheris # type: ignore[import-not-found]
import atheris # type: ignore[import-untyped]

from fuzzers._fuzz_shared import (
ByteCursor,
Expand Down
14 changes: 9 additions & 5 deletions jsonpatchx/pydantic.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
from typing_extensions import TypeVar

from jsonpatchx.exceptions import PatchValidationError
from jsonpatchx.registry import _RegistrySpec
from jsonpatchx.registry import StandardRegistry, _RegistrySpec
from jsonpatchx.schema import OperationSchema, _apply_ops
from jsonpatchx.types import JSONValue, _validate_JSONValue

Expand Down Expand Up @@ -120,7 +120,7 @@ def apply(


TargetT = TypeVar("TargetT", bound=BaseModel | str)
RegistryT = TypeVar("RegistryT", bound=OperationSchema)
RegistryT = TypeVar("RegistryT", bound=OperationSchema, default=StandardRegistry)


def _coerce_schema_name(target: object) -> str | None:
Expand All @@ -145,7 +145,8 @@ class JsonPatchFor(_RegistryBoundPatchRoot, Generic[TargetT, RegistryT]):
"""
Factory for creating typed JSON Patch models bound to a registry declaration.

``JsonPatchFor[Target, Registry]`` produces a patch model.
``JsonPatchFor[Target]`` produces a patch model using ``StandardRegistry``.
``JsonPatchFor[Target, Registry]`` produces a patch model using an explicit registry.
``Target`` is either a Pydantic model or ``Literal["SchemaName"]`` for JSON documents.
``Registry`` is a union of concrete OperationSchemas (``OpA | OpB | ...``).
"""
Expand Down Expand Up @@ -215,9 +216,12 @@ def apply(self, *args: Any, **kwargs: Any) -> Any:

@override
def __class_getitem__(cls, params: object) -> type[_RegistryBoundPatchRoot]:
if not isinstance(params, tuple) or len(params) != 2:
if not isinstance(params, tuple):
params = (params, StandardRegistry)

if len(params) != 2:
raise TypeError(
"JsonPatchFor expects JsonPatchFor[Target, Registry] where "
"JsonPatchFor expects JsonPatchFor[Target] or JsonPatchFor[Target, Registry] where "
'Target is a BaseModel subclass or Literal["SchemaName"] and '
"Registry is a union of concrete OperationSchemas. "
f"Got: {params!r}."
Expand Down
Loading
Loading