From 76e45060975f711ea16f592c50ba1b175459169d Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 21:35:33 +0000 Subject: [PATCH 1/5] Ship type stubs as a jsonpatch-stubs package (#158) mypy reported "module is installed, but missing library stubs or py.typed marker" for jsonpatch. PEP 561 has no py.typed marker for single-file modules, so the types are shipped as a stub-only package, jsonpatch-stubs, in the same distribution, which type checkers pick up automatically. This keeps jsonpatch.py a single module without annotations. JSON documents are typed as Any, as they can be any JSON value and the module accepts arbitrary Mapping / Sequence types. Patches are typed as str | bytes | Iterable[Mapping[str, Any]]. A new CI job runs mypy's stubtest to check that the stubs agree with jsonpatch.py, and type-checks typical usage (typetests/usage.py) against the installed distribution with mypy --strict, including usage that must be rejected. Released jsonpointer versions are untyped, so pyproject.toml tells mypy to ignore that; the stubs also pass against the typed jsonpointer from stefankoegl/python-json-pointer#106. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pewzpj8LrcYUx1DeTxFCMg --- .github/workflows/test.yaml | 21 ++++++ MANIFEST.in | 1 + README.md | 14 ++++ jsonpatch-stubs/__init__.pyi | 142 +++++++++++++++++++++++++++++++++++ pyproject.toml | 10 +++ setup.py | 6 +- typetests/usage.py | 86 +++++++++++++++++++++ 7 files changed, 279 insertions(+), 1 deletion(-) create mode 100644 jsonpatch-stubs/__init__.pyi create mode 100644 typetests/usage.py diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index e9ad37e..848c4ce 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -47,6 +47,27 @@ jobs: - name: Check distributions run: twine check --strict dist/* + typing: + name: "Check type stubs" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Set up Python 3.13 + uses: actions/setup-python@v7 + with: + python-version: "3.13" + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install mypy . + - name: Check that the stubs match jsonpatch.py + run: python -m mypy.stubtest --mypy-config-file pyproject.toml jsonpatch + - name: Type-check typical usage against the installed stubs + # run from typetests/, so that the installed stubs are used and not + # jsonpatch.py from the repository root + working-directory: typetests + run: mypy --config-file ../pyproject.toml usage.py + docs: name: "Build documentation" runs-on: ubuntu-latest diff --git a/MANIFEST.in b/MANIFEST.in index fc2d0f9..e5a5b30 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -6,5 +6,6 @@ include doc/*.rst include property_tests.py include pytest.ini include ext_tests.py +include typetests/usage.py include AUTHORS include tests.js diff --git a/README.md b/README.md index db67db2..da8b08a 100644 --- a/README.md +++ b/README.md @@ -21,6 +21,20 @@ See source code for examples * CI: https://github.com/stefankoegl/python-json-patch/actions * Coveralls: https://coveralls.io/github/stefankoegl/python-json-patch +Type hints +---------- +jsonpatch ships type stubs (in the `jsonpatch-stubs` directory, as +jsonpatch.py is a single-file module), so type checkers such as mypy and +pyright work with it without further setup. JSON documents are typed as `Any`. + +When changing the public API of jsonpatch.py, update +`jsonpatch-stubs/__init__.pyi` as well. These check that both agree, and that +typical usage in `typetests/usage.py` type-checks: + + pip install mypy . + python -m mypy.stubtest --mypy-config-file pyproject.toml jsonpatch + cd typetests && mypy --config-file ../pyproject.toml usage.py + Running external tests ---------------------- To run external tests (such as those from https://github.com/json-patch/json-patch-tests) use ext_tests.py diff --git a/jsonpatch-stubs/__init__.pyi b/jsonpatch-stubs/__init__.pyi new file mode 100644 index 0000000..f070f03 --- /dev/null +++ b/jsonpatch-stubs/__init__.pyi @@ -0,0 +1,142 @@ +# Type stubs for jsonpatch. +# +# jsonpatch is a single-file module, and PEP 561 has no py.typed marker for +# those, so its types are shipped as this stub-only package in the same +# distribution. Keep it in sync with jsonpatch.py; CI runs mypy's stubtest to +# check that both agree. +# +# JSON documents are typed as Any: they can be any JSON value, and the module +# also accepts arbitrary Mapping / Sequence types, which a recursive JSON type +# would reject. + +from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence +from typing import Any, ClassVar + +from jsonpointer import JsonPointer +from typing_extensions import Self + +__author__: str +__version__: str +__website__: str +__license__: str + +# A single patch operation, e.g. {'op': 'add', 'path': '/a', 'value': 1} +_Operation = Mapping[str, Any] + +class JsonPatchException(Exception): + # The failing operation: a PatchOperation, or the raw operation object if + # none could be constructed from it, or None. + op: Any + def __init__(self, *args: object, op: Any = None) -> None: ... + +class InvalidJsonPatch(JsonPatchException): ... +class JsonPatchConflict(JsonPatchException): ... +class JsonPatchTestFailed(JsonPatchException, AssertionError): ... + +def multidict(ordered_pairs: Iterable[tuple[str, Any]]) -> dict[str, Any]: ... +def apply_patch( + doc: Any, + patch: str | bytes | Iterable[_Operation], + in_place: bool = False, + pointer_cls: type[JsonPointer] = ..., +) -> Any: ... +def make_patch( + src: Any, + dst: Any, + pointer_cls: type[JsonPointer] = ..., + generate_test_ops: bool = False, +) -> JsonPatch: ... + +class PatchOperation: + pointer_cls: type[JsonPointer] + location: str + pointer: JsonPointer + operation: _Operation + def __init__(self, operation: _Operation, pointer_cls: type[JsonPointer] = ...) -> None: ... + def apply(self, obj: Any) -> Any: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + @property + def path(self) -> str: ... + @property + def key(self) -> int | str: ... + @key.setter + def key(self, value: int | str) -> None: ... + def get_part(self, index: int) -> int | str: ... + def set_part(self, index: int, value: int | str) -> None: ... + +class RemoveOperation(PatchOperation): ... +class AddOperation(PatchOperation): ... +class ReplaceOperation(PatchOperation): ... + +class MoveOperation(PatchOperation): + @property + def from_path(self) -> str: ... + @property + def from_key(self) -> int | str: ... + @from_key.setter + def from_key(self, value: int | str) -> None: ... + def get_from_part(self, index: int) -> int | str: ... + def set_from_part(self, index: int, value: int | str) -> None: ... + +class TestOperation(PatchOperation): ... +class CopyOperation(PatchOperation): ... + +class JsonPatch: + json_dumper: ClassVar[Callable[..., str]] + json_loader: ClassVar[Callable[..., Any]] + operations: ClassVar[Mapping[str, type[PatchOperation]]] + pointer_cls: type[JsonPointer] + patch: Sequence[_Operation] + def __init__(self, patch: Iterable[_Operation], pointer_cls: type[JsonPointer] = ...) -> None: ... + def __bool__(self) -> bool: ... + def __iter__(self) -> Iterator[_Operation]: ... + def __hash__(self) -> int: ... + def __eq__(self, other: object) -> bool: ... + def __ne__(self, other: object) -> bool: ... + __nonzero__ = __bool__ + @classmethod + def from_string( + cls, + patch_str: str | bytes, + loads: Callable[[Any], Any] | None = None, + pointer_cls: type[JsonPointer] = ..., + ) -> Self: ... + @classmethod + def from_diff( + cls, + src: Any, + dst: Any, + optimization: bool = True, + dumps: Callable[[Any], str] | None = None, + pointer_cls: type[JsonPointer] = ..., + generate_test_ops: bool = False, + ) -> Self: ... + def to_string(self, dumps: Callable[[Any], str] | None = None) -> str: ... + def apply(self, obj: Any, in_place: bool = False) -> Any: ... + +# Computes the operations for JsonPatch.from_diff; mostly an implementation +# detail, so its internal methods are typed loosely. +class DiffBuilder: + dumps: Callable[[Any], str] + pointer_cls: type[JsonPointer] + optimization: bool + generate_test_ops: bool + src_doc: Any + dst_doc: Any + def __init__( + self, + src_doc: Any, + dst_doc: Any, + dumps: Callable[[Any], str] = ..., + pointer_cls: type[JsonPointer] = ..., + optimization: bool = True, + generate_test_ops: bool = False, + ) -> None: ... + def store_index(self, value: Any, index: Any, st: int) -> None: ... + def take_index(self, value: Any, st: int) -> Any: ... + def insert(self, op: Any) -> Any: ... + def remove(self, index: Any) -> None: ... + def __iter__(self) -> Iterator[Any]: ... + def execute(self) -> Iterator[dict[str, Any]]: ... diff --git a/pyproject.toml b/pyproject.toml index 95af07e..382a8b8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,3 +2,13 @@ # license_expression in setup.py requires setuptools >= 77 requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" + +[tool.mypy] +strict = true + +# Released versions of jsonpointer do not ship type information yet. Remove +# this once one does +# (and raise the jsonpointer requirement accordingly). +[[tool.mypy.overrides]] +module = ["jsonpointer"] +ignore_missing_imports = true diff --git a/setup.py b/setup.py index 1656b1b..e365115 100644 --- a/setup.py +++ b/setup.py @@ -43,6 +43,7 @@ 'Programming Language :: Python :: Implementation :: PyPy', 'Topic :: Software Development :: Libraries', 'Topic :: Utilities', + 'Typing :: Typed', ] @@ -56,7 +57,10 @@ license_expression=LICENSE, url=WEBSITE, py_modules=MODULES, - package_data={'': ['requirements.txt']}, + # jsonpatch is a single-file module, which can't be marked as typed + # with py.typed (PEP 561), so its type stubs are a stub-only package + packages=['jsonpatch-stubs'], + package_data={'': ['requirements.txt'], 'jsonpatch-stubs': ['*.pyi']}, entry_points={ 'console_scripts': [ 'jsondiff = jsonpatch_cli:jsondiff_main', diff --git a/typetests/usage.py b/typetests/usage.py new file mode 100644 index 0000000..bf6632b --- /dev/null +++ b/typetests/usage.py @@ -0,0 +1,86 @@ +""" Type checks of typical jsonpatch usage, as seen by a user's type checker. + +This file is not run; CI type-checks it with ``mypy --strict`` against the +installed distribution, run from this directory so that the stubs in +jsonpatch-stubs are used rather than jsonpatch.py itself. Lines marked with +``# type: ignore[...]`` must produce exactly that error (strict mode reports +unused ignores), so they test that wrong usage is rejected. +""" + +import json +from collections.abc import Mapping +from typing import Any + +from jsonpointer import JsonPointer +from typing_extensions import assert_type + +import jsonpatch +from jsonpatch import (InvalidJsonPatch, JsonPatch, JsonPatchConflict, + JsonPatchException, JsonPatchTestFailed, + PatchOperation) + +doc = {'foo': 'bar'} +ops = [{'op': 'add', 'path': '/baz', 'value': 'qux'}] + +# module-level functions +assert_type(jsonpatch.apply_patch(doc, ops), Any) +assert_type(jsonpatch.apply_patch(doc, ops, in_place=True), Any) +assert_type(jsonpatch.apply_patch(doc, json.dumps(ops)), Any) +assert_type(jsonpatch.apply_patch(doc, json.dumps(ops).encode()), Any) +assert_type(jsonpatch.make_patch(doc, {}), JsonPatch) +assert_type(jsonpatch.make_patch(doc, {}, generate_test_ops=True), JsonPatch) + +# values of dicts with narrow types are accepted (Mapping is covariant) +str_ops: list[dict[str, str]] = [{'op': 'remove', 'path': '/foo'}] +jsonpatch.apply_patch(doc, str_ops) + +# JsonPatch +patch = JsonPatch(ops) +JsonPatch(op for op in ops) +assert_type(patch.apply(doc), Any) +assert_type(patch.to_string(), str) +assert_type(patch.to_string(dumps=json.dumps), str) +assert_type(bool(patch), bool) +assert_type(list(patch), list[Mapping[str, Any]]) +assert_type(JsonPatch.from_string('[]'), JsonPatch) +assert_type(JsonPatch.from_string(b'[]', loads=json.loads), JsonPatch) +assert_type(JsonPatch.from_diff(doc, {}, optimization=False), JsonPatch) +assert_type(JsonPatch.operations['add'], type[PatchOperation]) + + +class MyPatch(JsonPatch): + pass + + +# the alternative constructors return instances of subclasses +assert_type(MyPatch.from_string('[]'), MyPatch) +assert_type(MyPatch.from_diff(doc, {}), MyPatch) + +# operations +operation = jsonpatch.AddOperation({'op': 'add', 'path': '/a/0', 'value': 1}) +assert_type(operation.apply({'a': []}), Any) +assert_type(operation.location, str) +assert_type(operation.path, str) +assert_type(operation.key, int | str) +operation.key = 1 +assert_type(operation.pointer, JsonPointer) +move = jsonpatch.MoveOperation({'op': 'move', 'from': '/a', 'path': '/b'}) +assert_type(move.from_key, int | str) + +# pointer classes +jsonpatch.apply_patch(doc, ops, pointer_cls=JsonPointer) +JsonPatch(ops, pointer_cls=JsonPointer) + +# exceptions +try: + patch.apply(doc) +except (InvalidJsonPatch, JsonPatchConflict, JsonPatchTestFailed) as ex: + assert_type(ex, InvalidJsonPatch | JsonPatchConflict | JsonPatchTestFailed) + failed = ex.op +JsonPatchException('message', op=None) + +# wrong usage is rejected +jsonpatch.apply_patch(doc, ops, in_place='yes') # type: ignore[arg-type] +jsonpatch.make_patch(doc) # type: ignore[call-arg] +JsonPatch(ops).to_string(dumps=len) # type: ignore[arg-type] +JsonPatch([1, 2]) # type: ignore[list-item] From cec2647ef4cdd02b2949773f26e509ff097cadcb Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 21:35:56 +0000 Subject: [PATCH 2/5] Reflow mypy config comment Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pewzpj8LrcYUx1DeTxFCMg --- pyproject.toml | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 382a8b8..4be5972 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,8 +7,7 @@ build-backend = "setuptools.build_meta" strict = true # Released versions of jsonpointer do not ship type information yet. Remove -# this once one does -# (and raise the jsonpointer requirement accordingly). +# this once one does (and raise the jsonpointer requirement accordingly). [[tool.mypy.overrides]] module = ["jsonpointer"] ignore_missing_imports = true From a8399445998e5bc7339f3971ce81cc040484bd24 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:03:22 +0000 Subject: [PATCH 3/5] Require jsonpointer 3.8, which ships type stubs jsonpointer 3.8 includes its own type stubs, so mypy no longer needs to ignore it, and JsonPointer / pointer_cls in the jsonpatch stubs now have real types instead of Any. The usage type test now also covers a custom JsonPointer subclass as pointer_cls, and rejects a pointer_cls that is not one. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pewzpj8LrcYUx1DeTxFCMg --- pyproject.toml | 6 ------ requirements.txt | 2 +- typetests/usage.py | 12 +++++++++--- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 4be5972..0fc902d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,3 @@ build-backend = "setuptools.build_meta" [tool.mypy] strict = true - -# Released versions of jsonpointer do not ship type information yet. Remove -# this once one does (and raise the jsonpointer requirement accordingly). -[[tool.mypy.overrides]] -module = ["jsonpointer"] -ignore_missing_imports = true diff --git a/requirements.txt b/requirements.txt index 6a27aad..2a7dcf6 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1 +1 @@ -jsonpointer>=3.2 +jsonpointer>=3.8 diff --git a/typetests/usage.py b/typetests/usage.py index bf6632b..86c17f4 100644 --- a/typetests/usage.py +++ b/typetests/usage.py @@ -67,9 +67,14 @@ class MyPatch(JsonPatch): move = jsonpatch.MoveOperation({'op': 'move', 'from': '/a', 'path': '/b'}) assert_type(move.from_key, int | str) -# pointer classes -jsonpatch.apply_patch(doc, ops, pointer_cls=JsonPointer) -JsonPatch(ops, pointer_cls=JsonPointer) + +# a custom pointer class +class MyPointer(JsonPointer): + pass + + +jsonpatch.apply_patch(doc, ops, pointer_cls=MyPointer) +JsonPatch(ops, pointer_cls=MyPointer) # exceptions try: @@ -84,3 +89,4 @@ class MyPatch(JsonPatch): jsonpatch.make_patch(doc) # type: ignore[call-arg] JsonPatch(ops).to_string(dumps=len) # type: ignore[arg-type] JsonPatch([1, 2]) # type: ignore[list-item] +JsonPatch(ops, pointer_cls=str) # type: ignore[arg-type] From fedf807479849f7e7a91ecc7d6957958e69ac23a Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:05:45 +0000 Subject: [PATCH 4/5] Re-export JsonPointer and JsonPointerException in the stubs jsonpatch.py imports both from jsonpointer, so jsonpatch.JsonPointer and jsonpatch.JsonPointerException work at runtime (ext_tests.py catches the latter). In a .pyi file, imports are private unless re-exported with a same-name alias, so type checkers rejected these uses. stubtest doesn't notice, as it skips names defined in other modules, so the usage type test now covers them. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pewzpj8LrcYUx1DeTxFCMg --- jsonpatch-stubs/__init__.pyi | 6 +++++- typetests/usage.py | 8 ++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/jsonpatch-stubs/__init__.pyi b/jsonpatch-stubs/__init__.pyi index f070f03..e9e1558 100644 --- a/jsonpatch-stubs/__init__.pyi +++ b/jsonpatch-stubs/__init__.pyi @@ -12,7 +12,11 @@ from collections.abc import Callable, Iterable, Iterator, Mapping, Sequence from typing import Any, ClassVar -from jsonpointer import JsonPointer +# Re-exported, as jsonpatch.py imports them and users rely on that, e.g. to +# catch jsonpatch.JsonPointerException. stubtest does not check these, as +# they are defined in another module. +from jsonpointer import JsonPointer as JsonPointer +from jsonpointer import JsonPointerException as JsonPointerException from typing_extensions import Self __author__: str diff --git a/typetests/usage.py b/typetests/usage.py index 86c17f4..276f819 100644 --- a/typetests/usage.py +++ b/typetests/usage.py @@ -11,6 +11,7 @@ from collections.abc import Mapping from typing import Any +import jsonpointer from jsonpointer import JsonPointer from typing_extensions import assert_type @@ -84,6 +85,13 @@ class MyPointer(JsonPointer): failed = ex.op JsonPatchException('message', op=None) +# names that jsonpatch re-exports from jsonpointer +try: + patch.apply(doc) +except jsonpatch.JsonPointerException as ex: + assert_type(ex, jsonpointer.JsonPointerException) +assert_type(jsonpatch.JsonPointer('/a'), jsonpointer.JsonPointer) + # wrong usage is rejected jsonpatch.apply_patch(doc, ops, in_place='yes') # type: ignore[arg-type] jsonpatch.make_patch(doc) # type: ignore[call-arg] From ac8266225b3be441acb6954b62c997da0ca0d991 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 22:12:24 +0000 Subject: [PATCH 5/5] Accept bytearray in JsonPatch.from_string's type The default loader (json.loads) accepts bytearray, and from_string passes its input on unchanged, so JsonPatch.from_string(bytearray(...)) works at runtime. apply_patch keeps str | bytes: it treats a bytearray as a sequence of operations and fails, so the usage test checks that type checkers reject that. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01Pewzpj8LrcYUx1DeTxFCMg --- jsonpatch-stubs/__init__.pyi | 2 +- typetests/usage.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/jsonpatch-stubs/__init__.pyi b/jsonpatch-stubs/__init__.pyi index e9e1558..1fad29e 100644 --- a/jsonpatch-stubs/__init__.pyi +++ b/jsonpatch-stubs/__init__.pyi @@ -103,7 +103,7 @@ class JsonPatch: @classmethod def from_string( cls, - patch_str: str | bytes, + patch_str: str | bytes | bytearray, loads: Callable[[Any], Any] | None = None, pointer_cls: type[JsonPointer] = ..., ) -> Self: ... diff --git a/typetests/usage.py b/typetests/usage.py index 276f819..4b02bb9 100644 --- a/typetests/usage.py +++ b/typetests/usage.py @@ -45,6 +45,7 @@ assert_type(list(patch), list[Mapping[str, Any]]) assert_type(JsonPatch.from_string('[]'), JsonPatch) assert_type(JsonPatch.from_string(b'[]', loads=json.loads), JsonPatch) +assert_type(JsonPatch.from_string(bytearray(b'[]')), JsonPatch) assert_type(JsonPatch.from_diff(doc, {}, optimization=False), JsonPatch) assert_type(JsonPatch.operations['add'], type[PatchOperation]) @@ -98,3 +99,5 @@ class MyPointer(JsonPointer): JsonPatch(ops).to_string(dumps=len) # type: ignore[arg-type] JsonPatch([1, 2]) # type: ignore[list-item] JsonPatch(ops, pointer_cls=str) # type: ignore[arg-type] +# apply_patch treats a bytearray as a sequence of operations, and fails +jsonpatch.apply_patch(doc, bytearray(b'[]')) # type: ignore[arg-type]