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..1fad29e --- /dev/null +++ b/jsonpatch-stubs/__init__.pyi @@ -0,0 +1,146 @@ +# 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 + +# 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 +__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 | bytearray, + 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..0fc902d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -2,3 +2,6 @@ # license_expression in setup.py requires setuptools >= 77 requires = ["setuptools>=77"] build-backend = "setuptools.build_meta" + +[tool.mypy] +strict = 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/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..4b02bb9 --- /dev/null +++ b/typetests/usage.py @@ -0,0 +1,103 @@ +""" 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 + +import jsonpointer +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_string(bytearray(b'[]')), 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) + + +# a custom pointer class +class MyPointer(JsonPointer): + pass + + +jsonpatch.apply_patch(doc, ops, pointer_cls=MyPointer) +JsonPatch(ops, pointer_cls=MyPointer) + +# exceptions +try: + patch.apply(doc) +except (InvalidJsonPatch, JsonPatchConflict, JsonPatchTestFailed) as ex: + assert_type(ex, InvalidJsonPatch | JsonPatchConflict | JsonPatchTestFailed) + 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] +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]