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
21 changes: 21 additions & 0 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions MANIFEST.in
Original file line number Diff line number Diff line change
Expand Up @@ -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
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
146 changes: 146 additions & 0 deletions jsonpatch-stubs/__init__.pyi
Original file line number Diff line number Diff line change
@@ -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]]: ...
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,6 @@
# license_expression in setup.py requires setuptools >= 77
requires = ["setuptools>=77"]
build-backend = "setuptools.build_meta"

[tool.mypy]
strict = true
2 changes: 1 addition & 1 deletion requirements.txt
Original file line number Diff line number Diff line change
@@ -1 +1 @@
jsonpointer>=3.2
jsonpointer>=3.8
6 changes: 5 additions & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@
'Programming Language :: Python :: Implementation :: PyPy',
'Topic :: Software Development :: Libraries',
'Topic :: Utilities',
'Typing :: Typed',
]


Expand All @@ -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',
Expand Down
103 changes: 103 additions & 0 deletions typetests/usage.py
Original file line number Diff line number Diff line change
@@ -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]
Loading