From 8d9af5bb692df0f738fd03e5723a73b9876406cc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 10 Jul 2026 21:36:53 +0000 Subject: [PATCH] Fully implement modern type hinting, checked with ty in CI Closes the typing item of the 1.0.0 roadmap (Track C), following fork-tongue/observ#184/#185. Typing: - Operation TypedDicts in types.py (AddOperation, RemoveOperation, ReplaceOperation and the Operation union), used across diff, apply, serialize and produce signatures: diff() and produce() now advertise tuple[..., list[Operation], list[Operation]] - PEP 604 unions and builtin generics everywhere via from __future__ import annotations (the runtime floor stays Python 3.9; typing-only imports live in a TYPE_CHECKING block) - py.typed marker shipped in the wheel, Typing :: Typed classifier - Genuine annotation bugs fixed: Pointer.evaluate returned tuple[Diffable, ...] but parent is None at the root; PatchRecorder.record_add annotated reverse_path: Pointer with a None default - Duck-typed code kept honest instead of over-narrowed: the iapply interpreter and the diff() dispatch unpack into Any/cast at the boundary, with comments explaining why Toolchain: - ty in a dedicated dependency group (and in dev), [tool.ty] config scoped to patchdiff/ with python-version = "3.9" so 3.9-incompatible typing constructs are caught - Typecheck job in CI, added to publish's needs Runtime changes (annotation-driven, behavior-preserving): - _Proxy._detach makes its caller-guarded invariant explicit with an assert - pyproject metadata fix: description was literally "MIT"; now a real description plus license = "MIT" ty check: 26 diagnostics on the previous code, 0 after. Tests pass at 100% coverage on 3.10-3.14 locally; docs build stays warning-free with the new signatures. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_016Mu9vEBwU4fQLi8ZgkgdS2 --- .github/workflows/ci.yml | 17 +++++++++++++- patchdiff/apply.py | 27 ++++++++++++++-------- patchdiff/diff.py | 48 ++++++++++++++++++++++------------------ patchdiff/pointer.py | 8 +++---- patchdiff/produce.py | 28 +++++++++++++++-------- patchdiff/py.typed | 0 patchdiff/serialize.py | 10 ++++++--- patchdiff/types.py | 37 +++++++++++++++++++++++++++++-- pyproject.toml | 14 +++++++++++- 9 files changed, 139 insertions(+), 50 deletions(-) create mode 100644 patchdiff/py.typed diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5f6371d..4abb8f4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,6 +32,21 @@ jobs: - name: Format run: uv run --no-sync ruff format --check + typecheck: + name: Typecheck + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Install uv + uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2 + with: + enable-cache: true + cache-dependency-glob: "pyproject.toml" + - name: Install only ty + run: uv sync --only-group ty + - name: Typecheck + run: uv run --no-sync ty check + test: name: Test on ${{ matrix.name }} runs-on: ubuntu-latest @@ -85,7 +100,7 @@ jobs: publish: name: Publish to Github and Pypi runs-on: ubuntu-latest - needs: [lint, test, build] + needs: [lint, typecheck, test, build] if: success() && startsWith(github.ref, 'refs/tags/v') environment: name: pypi diff --git a/patchdiff/apply.py b/patchdiff/apply.py index f108788..aa67737 100644 --- a/patchdiff/apply.py +++ b/patchdiff/apply.py @@ -1,10 +1,13 @@ +from __future__ import annotations + from copy import deepcopy -from typing import Dict, List +from typing import Any, cast -from .types import Diffable +from .pointer import Pointer +from .types import Diffable, Operation -def iapply(obj: Diffable, patches: List[Dict]) -> Diffable: +def iapply(obj: Diffable, patches: list[Operation]) -> Diffable: """Apply a list of patches to an object, in place. Patch values are deep-copied as they are written, so mutating the @@ -22,12 +25,18 @@ def iapply(obj: Diffable, patches: List[Dict]) -> Diffable: if not patches: return obj for patch in patches: - ptr = patch["path"] - op = patch["op"] - parent, key, _ = ptr.evaluate(obj) - value = None + # The interpreter below is duck-typed on purpose (dict/list/set + # look-alikes such as observ proxies must work), so the operation + # is unpacked once into dynamically-typed locals. + op_dict = cast("dict[str, Any]", patch) + ptr: Pointer = op_dict["path"] + op: str = op_dict["op"] + target = ptr.evaluate(obj) + parent: Any = target[0] + key: Any = target[1] + value: Any = None if op != "remove": - value = deepcopy(patch["value"]) + value = deepcopy(op_dict["value"]) if hasattr(parent, "keys"): # dict if op == "remove": del parent[key] @@ -56,7 +65,7 @@ def iapply(obj: Diffable, patches: List[Dict]) -> Diffable: return obj -def apply(obj: Diffable, patches: List[Dict]) -> Diffable: +def apply(obj: Diffable, patches: list[Operation]) -> Diffable: """Apply a list of patches to a deep copy of an object. Args: diff --git a/patchdiff/diff.py b/patchdiff/diff.py index 5a537a4..cb5f5d3 100644 --- a/patchdiff/diff.py +++ b/patchdiff/diff.py @@ -1,12 +1,14 @@ from __future__ import annotations -from typing import Dict, List, Set, Tuple +from typing import Any, cast from .pointer import Pointer -from .types import Diffable +from .types import Diffable, Operation -def diff_lists(input: List, output: List, ptr: Pointer) -> Tuple[List, List]: +def diff_lists( + input: list, output: list, ptr: Pointer +) -> tuple[list[Operation], list[Operation]]: m_full, n_full = len(input), len(output) # Strip common prefix so the DP table only covers the changed region. @@ -55,8 +57,8 @@ def diff_lists(input: List, output: List, ptr: Pointer) -> Tuple[List, List]: # Traceback to extract operations. Indexes are emitted in sub-list # coordinates and shifted by `prefix` below so they refer to positions # in the original input/output. - ops = [] - rops = [] + ops: list[dict[str, Any]] = [] + rops: list[dict[str, Any]] = [] i, j = m, n while i > 0 or j > 0: @@ -96,7 +98,7 @@ def diff_lists(input: List, output: List, ptr: Pointer) -> Tuple[List, List]: j -= 1 # Apply padding to operations (using explicit loops instead of reduce) - padded_ops = [] + padded_ops: list[Operation] = [] padding = 0 # Iterate in reverse to get correct order (traceback extracts operations backwards) for op in reversed(ops): @@ -124,7 +126,7 @@ def diff_lists(input: List, output: List, ptr: Pointer) -> Tuple[List, List]: replace_ops, _ = diff(op["original"], op["value"], replace_ptr) padded_ops.extend(replace_ops) - padded_rops = [] + padded_rops: list[Operation] = [] padding = 0 # Iterate in reverse to get correct order (traceback extracts operations backwards) for op in reversed(rops): @@ -155,11 +157,13 @@ def diff_lists(input: List, output: List, ptr: Pointer) -> Tuple[List, List]: return padded_ops, padded_rops -def diff_dicts(input: Dict, output: Dict, ptr: Pointer) -> Tuple[List, List]: - ops: List = [] - input_only_rops: List = [] - output_only_rops: List = [] - common_rops_chunks: List[List] = [] +def diff_dicts( + input: dict, output: dict, ptr: Pointer +) -> tuple[list[Operation], list[Operation]]: + ops: list[Operation] = [] + input_only_rops: list[Operation] = [] + output_only_rops: list[Operation] = [] + common_rops_chunks: list[list[Operation]] = [] input_keys = set(input.keys()) if input else set() output_keys = set(output.keys()) if output else set() @@ -185,7 +189,7 @@ def diff_dicts(input: Dict, output: Dict, ptr: Pointer) -> Tuple[List, List]: # Match the historical insert(0,…) + key_rops.extend(rops) layering: # later common chunks went in front of earlier ones, and the input/output # singletons sat behind them in reverse iteration order. - rops: List = [] + rops: list[Operation] = [] for chunk in reversed(common_rops_chunks): rops.extend(chunk) rops.extend(output_only_rops) @@ -193,10 +197,12 @@ def diff_dicts(input: Dict, output: Dict, ptr: Pointer) -> Tuple[List, List]: return ops, rops -def diff_sets(input: Set, output: Set, ptr: Pointer) -> Tuple[List, List]: - ops: List = [] - input_only_rops: List = [] - output_only_rops: List = [] +def diff_sets( + input: set, output: set, ptr: Pointer +) -> tuple[list[Operation], list[Operation]]: + ops: list[Operation] = [] + input_only_rops: list[Operation] = [] + output_only_rops: list[Operation] = [] for value in input - output: ops.append({"op": "remove", "path": ptr.append(value)}) @@ -214,7 +220,7 @@ def diff_sets(input: Set, output: Set, ptr: Pointer) -> Tuple[List, List]: def diff( input: Diffable, output: Diffable, ptr: Pointer | None = None -) -> Tuple[List, List]: +) -> tuple[list[Operation], list[Operation]]: """Compute the difference between two objects as JSON patch operations. Recursively compares `input` and `output` and returns operations in @@ -241,11 +247,11 @@ def diff( if ptr is None: ptr = Pointer() if hasattr(input, "append") and hasattr(output, "append"): # list - return diff_lists(input, output, ptr) + return diff_lists(cast("list", input), cast("list", output), ptr) if hasattr(input, "keys") and hasattr(output, "keys"): # dict - return diff_dicts(input, output, ptr) + return diff_dicts(cast("dict", input), cast("dict", output), ptr) if hasattr(input, "add") and hasattr(output, "add"): # set - return diff_sets(input, output, ptr) + return diff_sets(cast("set", input), cast("set", output), ptr) return [{"op": "replace", "path": ptr, "value": output}], [ {"op": "replace", "path": ptr, "value": input} ] diff --git a/patchdiff/pointer.py b/patchdiff/pointer.py index 3c7ab46..f287a26 100644 --- a/patchdiff/pointer.py +++ b/patchdiff/pointer.py @@ -59,7 +59,7 @@ def __eq__(self, other: Any) -> bool: return False return self.tokens == other.tokens - def evaluate(self, obj: Diffable) -> tuple[Diffable, Hashable, Any]: + def evaluate(self, obj: Diffable) -> tuple[Diffable | None, Hashable, Any]: """Resolve the pointer against an object. Returns: @@ -69,9 +69,9 @@ def evaluate(self, obj: Diffable) -> tuple[Diffable, Hashable, Any]: exist yet (the target of an `"add"`, or a list append via the `"-"` token). """ - key = "" - parent = None - cursor = obj + key: Hashable = "" + parent: Any = None + cursor: Any = obj if tokens := self.tokens: # Walk to the parent strictly: any failure here is a path that # doesn't exist in the target, and silently landing on a partial diff --git a/patchdiff/produce.py b/patchdiff/produce.py index a871941..0298396 100644 --- a/patchdiff/produce.py +++ b/patchdiff/produce.py @@ -14,10 +14,11 @@ from __future__ import annotations from copy import deepcopy -from typing import Any, Callable, Dict, Hashable, List, Tuple, Union +from typing import Any, Callable, Hashable from weakref import ref from .pointer import Pointer +from .types import Operation # Types that are immutable and can never contain a proxy, so they can be # stored and snapshotted as-is. @@ -117,8 +118,8 @@ class PatchRecorder: """ def __init__(self): - self.patches: List[Dict] = [] - self.reverse_patches: List[Dict] = [] + self.patches: list[Operation] = [] + self.reverse_patches: list[Operation] = [] def finalize(self, root: "_Proxy") -> None: """Put the reverse patches in reverse application order and @@ -133,7 +134,7 @@ def finalize(self, root: "_Proxy") -> None: root._detached = True def record_add( - self, path: Pointer, value: Any, reverse_path: Pointer = None + self, path: Pointer, value: Any, reverse_path: Pointer | None = None ) -> None: """Record an add operation. @@ -215,6 +216,13 @@ class _Proxy: ) __hash__ = None # mutable containers are unhashable + _data: Any + _detached: bool + _key: Any + _parent: Any # weakref.ref[_Proxy] | None + _proxies: dict[Any, _Proxy] | None + _recorder: PatchRecorder + def __init__( self, data: Any, @@ -290,7 +298,9 @@ def _detach(self, key: Hashable) -> None: Only called when self._proxies is non-empty (callers guard). """ - proxy = self._proxies.pop(key, None) + proxies = self._proxies + assert proxies is not None + proxy = proxies.pop(key, None) if proxy is not None: proxy._detached = True @@ -495,7 +505,7 @@ def _shift_cache(self, start: int, delta: int) -> None: shifted[index] = proxy self._proxies = shifted - def __getitem__(self, index: Union[int, slice]) -> Any: + def __getitem__(self, index: int | slice) -> Any: value = self._data[index] if isinstance(index, slice): # Wrap each element in the slice so nested mutations are tracked @@ -507,7 +517,7 @@ def __getitem__(self, index: Union[int, slice]) -> Any: index = len(self._data) + index return self._wrap(index, value) - def __setitem__(self, index: Union[int, slice], value: Any) -> None: + def __setitem__(self, index: int | slice, value: Any) -> None: if isinstance(index, slice): # Handle slice assignment with proper patch generation start, stop, step = index.indices(len(self._data)) @@ -595,7 +605,7 @@ def __setitem__(self, index: Union[int, slice], value: Any) -> None: self._recorder.record_replace(Pointer((*tokens, index)), old_value, value) self._data[index] = value - def __delitem__(self, index: Union[int, slice]) -> None: + def __delitem__(self, index: int | slice) -> None: if isinstance(index, slice): # Handle slice deletion with proper patch generation start, stop, step = index.indices(len(self._data)) @@ -979,7 +989,7 @@ def symmetric_difference_update(self, other): def produce( base: Any, recipe: Callable[[Any], None], in_place: bool = False -) -> Tuple[Any, List[Dict], List[Dict]]: +) -> tuple[Any, list[Operation], list[Operation]]: """ Produce a new state by applying mutations, tracking patches along the way. diff --git a/patchdiff/py.typed b/patchdiff/py.typed new file mode 100644 index 0000000..e69de29 diff --git a/patchdiff/serialize.py b/patchdiff/serialize.py index c6d5843..db0a29e 100644 --- a/patchdiff/serialize.py +++ b/patchdiff/serialize.py @@ -1,8 +1,12 @@ +from __future__ import annotations + import json -from typing import List +from typing import Any + +from .types import Operation -def to_str_paths(ops: List) -> List: +def to_str_paths(ops: list[Operation]) -> list[dict[str, Any]]: """Return a copy of the operations with each path rendered as a string. The [`Pointer`][patchdiff.pointer.Pointer] objects in the `"path"` @@ -12,7 +16,7 @@ def to_str_paths(ops: List) -> List: return [{**op, "path": str(op["path"])} for op in ops] -def to_json(ops: List, **kwargs) -> str: +def to_json(ops: list[Operation], **kwargs: Any) -> str: """Serialize a list of operations to a JSON patch (RFC 6902) string. Pointer paths are rendered as JSON pointer strings; any keyword diff --git a/patchdiff/types.py b/patchdiff/types.py index adebb3a..71d3038 100644 --- a/patchdiff/types.py +++ b/patchdiff/types.py @@ -1,3 +1,36 @@ -from typing import Dict, List, Set, Union +from __future__ import annotations -Diffable = Union[Dict, List, Set] +from typing import TYPE_CHECKING, Any, Literal, TypedDict, Union + +if TYPE_CHECKING: + from .pointer import Pointer + +# The structures patchdiff diffs and patches. Consumers may also pass +# duck-typed container look-alikes (e.g. observ reactive proxies). +Diffable = Union[dict, list, set] + + +class AddOperation(TypedDict): + """An `add` JSON patch operation.""" + + op: Literal["add"] + path: Pointer + value: Any + + +class RemoveOperation(TypedDict): + """A `remove` JSON patch operation.""" + + op: Literal["remove"] + path: Pointer + + +class ReplaceOperation(TypedDict): + """A `replace` JSON patch operation.""" + + op: Literal["replace"] + path: Pointer + value: Any + + +Operation = Union[AddOperation, RemoveOperation, ReplaceOperation] diff --git a/pyproject.toml b/pyproject.toml index 0d3de4a..e38ca9a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,15 @@ [project] name = "patchdiff" version = "0.3.12" -description = "MIT" +description = "Bidirectional, JSON-patch-compliant diffs between Python data structures" authors = [ { name = "Korijn van Golen", email = "korijn@gmail.com" }, { name = "Berend Klein Haneveld", email = "berendkleinhaneveld@gmail.com" }, ] requires-python = ">=3.9" readme = "README.md" +license = "MIT" +classifiers = ["Typing :: Typed"] [project.urls] Homepage = "https://github.com/fork-tongue/patchdiff" @@ -19,8 +21,10 @@ dev = [ "pytest-cov", "pytest-watch", "pytest-benchmark", + "ty", ] ruff = ["ruff"] +ty = ["ty"] observ = [ "observ>=0.18.0", ] @@ -45,6 +49,14 @@ extend-select = [ # command line tool that reports through print "benchmarks/compare_runs.py" = ["T201"] +[tool.ty.src] +include = ["patchdiff"] + +[tool.ty.environment] +# Match the oldest supported Python version (requires-python), so +# that the type checker catches use of newer typing features +python-version = "3.9" + [build-system] requires = ["hatchling"] build-backend = "hatchling.build"