patchdiff 0.3.4__tar.gz → 0.3.6__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,69 @@
1
+ name: Benchmarks
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ pull_request:
8
+ branches:
9
+ - master
10
+
11
+ jobs:
12
+ benchmark:
13
+ name: Benchmarks
14
+ runs-on: ubuntu-latest
15
+ steps:
16
+ - uses: actions/checkout@v5
17
+
18
+ - name: Install uv
19
+ uses: astral-sh/setup-uv@v6
20
+
21
+ - name: Set up Python 3.14
22
+ uses: actions/setup-python@v5
23
+ with:
24
+ python-version: '3.14'
25
+
26
+ - name: Install dependencies
27
+ run: uv sync
28
+
29
+ # Restore benchmark baseline (read-only for PRs)
30
+ - name: Restore benchmark baseline
31
+ uses: actions/cache/restore@v4
32
+ with:
33
+ path: .benchmarks
34
+ key: benchmark-baseline-3.14-${{ runner.os }}
35
+
36
+ # On master: save baseline results
37
+ - name: Run benchmarks and save baseline
38
+ if: github.ref == 'refs/heads/master'
39
+ continue-on-error: true
40
+ run: |
41
+ uv run --no-sync pytest benchmarks/benchmark.py \
42
+ --benchmark-only \
43
+ --benchmark-autosave \
44
+ --benchmark-sort=name
45
+
46
+ # On master: cache the new baseline results
47
+ - name: Save benchmark baseline
48
+ if: github.ref == 'refs/heads/master'
49
+ uses: actions/cache/save@v4
50
+ with:
51
+ path: .benchmarks
52
+ key: benchmark-baseline-3.14-${{ runner.os }}
53
+
54
+ # On PRs: compare against baseline and fail if degraded
55
+ - name: Run benchmarks and compare
56
+ if: github.event_name == 'pull_request'
57
+ run: |
58
+ if [ -z "$(uv run --no-sync pytest-benchmark list)" ]; then
59
+ echo "No baseline found, not comparing"
60
+ uv run --no-sync pytest -v benchmarks/benchmark.py
61
+ exit
62
+ fi
63
+
64
+ uv run --no-sync pytest benchmarks/benchmark.py \
65
+ --benchmark-only \
66
+ --benchmark-compare \
67
+ --benchmark-compare-fail=mean:5% \
68
+ --benchmark-sort=name
69
+
@@ -0,0 +1,103 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - master
7
+ tags:
8
+ - 'v*'
9
+ pull_request:
10
+ branches:
11
+ - master
12
+
13
+ jobs:
14
+ test:
15
+ name: Lint and test on ${{ matrix.name }}
16
+ runs-on: ubuntu-latest
17
+ strategy:
18
+ fail-fast: false
19
+ matrix:
20
+ include:
21
+ - name: Linux py38
22
+ pyversion: '3.8'
23
+ - name: Linux py39
24
+ pyversion: '3.9'
25
+ - name: Linux py310
26
+ pyversion: '3.10'
27
+ - name: Linux py311
28
+ pyversion: '3.11'
29
+ - name: Linux py312
30
+ pyversion: '3.12'
31
+ - name: Linux py313
32
+ pyversion: '3.13'
33
+ - name: Linux py314
34
+ pyversion: '3.14'
35
+ steps:
36
+ - uses: actions/checkout@v5
37
+ - name: Install uv
38
+ uses: astral-sh/setup-uv@v6
39
+ - name: Set up Python ${{ matrix.pyversion }}
40
+ uses: actions/setup-python@v5
41
+ with:
42
+ python-version: ${{ matrix.pyversion }}
43
+ - name: Install dependencies
44
+ run: uv sync
45
+ - name: Lint
46
+ run: uv run ruff check
47
+ - name: Format
48
+ run: uv run ruff format --check
49
+ - name: Test
50
+ run: uv run pytest -v --cov=patchdiff --cov-report=term-missing
51
+
52
+ build:
53
+ name: Build and test wheel
54
+ runs-on: ubuntu-latest
55
+ steps:
56
+ - uses: actions/checkout@v5
57
+ - name: Install uv
58
+ uses: astral-sh/setup-uv@v6
59
+ - name: Set up Python 3.9
60
+ uses: actions/setup-python@v5
61
+ with:
62
+ python-version: '3.9'
63
+ - name: Install dependencies
64
+ run: uv sync
65
+ - name: Build wheel
66
+ run: uv build
67
+ - name: Twine check
68
+ run: uvx twine check dist/*
69
+ - name: Upload wheel artifact
70
+ uses: actions/upload-artifact@v4
71
+ with:
72
+ path: dist
73
+ name: dist
74
+
75
+ publish:
76
+ name: Publish to Github and Pypi
77
+ runs-on: ubuntu-latest
78
+ needs: [test, build]
79
+ if: success() && startsWith(github.ref, 'refs/tags/v')
80
+ environment:
81
+ name: pypi
82
+ url: https://pypi.org/p/patchdiff
83
+ permissions:
84
+ id-token: write
85
+ contents: write
86
+ steps:
87
+ - uses: actions/checkout@v5
88
+ - name: Download wheel artifact
89
+ uses: actions/download-artifact@v4
90
+ with:
91
+ name: dist
92
+ path: dist
93
+ - name: Release
94
+ uses: softprops/action-gh-release@v2
95
+ with:
96
+ token: ${{ secrets.GITHUB_TOKEN }}
97
+ files: |
98
+ dist/*.tar.gz
99
+ dist/*.whl
100
+ draft: true
101
+ prerelease: false
102
+ - name: Publish to PyPI
103
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,7 @@
1
+ *.egg-info/
2
+ __pycache__
3
+ .vscode
4
+ .coverage
5
+ dist
6
+ uv.lock
7
+ .benchmarks/
@@ -1,16 +1,10 @@
1
- Metadata-Version: 2.1
1
+ Metadata-Version: 2.4
2
2
  Name: patchdiff
3
- Version: 0.3.4
3
+ Version: 0.3.6
4
4
  Summary: MIT
5
- Home-page: https://github.com/fork-tongue/patchdiff
6
- Author: Korijn van Golen
7
- Author-email: korijn@gmail.com
8
- Requires-Python: >=3.7
9
- Classifier: Programming Language :: Python :: 3
10
- Classifier: Programming Language :: Python :: 3.10
11
- Classifier: Programming Language :: Python :: 3.7
12
- Classifier: Programming Language :: Python :: 3.8
13
- Classifier: Programming Language :: Python :: 3.9
5
+ Project-URL: Homepage, https://github.com/fork-tongue/patchdiff
6
+ Author-email: Korijn van Golen <korijn@gmail.com>, Berend Klein Haneveld <berendkleinhaneveld@gmail.com>
7
+ Requires-Python: >=3.8
14
8
  Description-Content-Type: text/markdown
15
9
 
16
10
  [![PyPI version](https://badge.fury.io/py/patchdiff.svg)](https://badge.fury.io/py/patchdiff)
@@ -58,4 +52,3 @@ print(to_json(ops, indent=4))
58
52
  # }
59
53
  # ]
60
54
  ```
61
-
@@ -0,0 +1,166 @@
1
+ """
2
+ Benchmark suite for patchdiff performance testing using pytest-benchmark.
3
+
4
+ Run benchmarks:
5
+ uv run pytest benchmarks/benchmark.py --benchmark-only
6
+
7
+ Save baseline:
8
+ uv run pytest benchmarks/benchmark.py --benchmark-only --benchmark-autosave
9
+
10
+ Compare against baseline:
11
+ uv run pytest benchmarks/benchmark.py --benchmark-only --benchmark-compare=0001
12
+
13
+ Fail if performance degrades >5%:
14
+ uv run pytest benchmarks/benchmark.py --benchmark-only --benchmark-compare=0001 --benchmark-compare-fail=mean:5%
15
+ """
16
+
17
+ import random
18
+
19
+ import pytest
20
+
21
+ from patchdiff import apply, diff
22
+
23
+ # Set seed for reproducibility
24
+ random.seed(42)
25
+
26
+
27
+ def generate_random_list(size: int, value_range: int = 1000) -> list[int]:
28
+ """Generate a random list of integers."""
29
+ return [random.randint(0, value_range) for _ in range(size)]
30
+
31
+
32
+ def generate_similar_lists(
33
+ size: int, change_ratio: float = 0.1
34
+ ) -> tuple[list[int], list[int]]:
35
+ """
36
+ Generate two similar lists with specified change ratio.
37
+
38
+ Args:
39
+ size: Size of the lists
40
+ change_ratio: Ratio of elements that differ (0.0 to 1.0)
41
+ """
42
+ list_a = generate_random_list(size)
43
+ list_b = list_a.copy()
44
+
45
+ num_changes = int(size * change_ratio)
46
+
47
+ # Make some replacements
48
+ for _ in range(num_changes // 3):
49
+ idx = random.randint(0, size - 1)
50
+ list_b[idx] = random.randint(0, 1000)
51
+
52
+ # Make some insertions
53
+ for _ in range(num_changes // 3):
54
+ idx = random.randint(0, len(list_b))
55
+ list_b.insert(idx, random.randint(0, 1000))
56
+
57
+ # Make some deletions
58
+ for _ in range(num_changes // 3):
59
+ if list_b:
60
+ idx = random.randint(0, len(list_b) - 1)
61
+ del list_b[idx]
62
+
63
+ return list_a, list_b
64
+
65
+
66
+ def generate_nested_dict(depth: int, breadth: int) -> dict | int:
67
+ """Generate a nested dictionary structure."""
68
+ if depth == 0:
69
+ return random.randint(0, 1000)
70
+
71
+ result = {}
72
+ for i in range(breadth):
73
+ key = f"key_{i}"
74
+ if random.random() > 0.3:
75
+ result[key] = generate_nested_dict(depth - 1, breadth)
76
+ else:
77
+ result[key] = random.randint(0, 1000)
78
+ return result
79
+
80
+
81
+ # ========================================
82
+ # List Diff Benchmarks
83
+ # ========================================
84
+
85
+
86
+ @pytest.mark.benchmark(group="list-diff")
87
+ def test_list_diff_small_10pct(benchmark):
88
+ """Benchmark: 50 element list with 10% changes."""
89
+ a, b = generate_similar_lists(50, 0.1)
90
+ benchmark(diff, a, b)
91
+
92
+
93
+ @pytest.mark.benchmark(group="list-diff")
94
+ @pytest.mark.parametrize("change_ratio", [0.05, 0.1, 0.5])
95
+ def test_list_diff_medium(benchmark, change_ratio):
96
+ """Benchmark: 1000 element list with varying change ratios."""
97
+ a, b = generate_similar_lists(1000, change_ratio)
98
+ benchmark(diff, a, b)
99
+
100
+
101
+ @pytest.mark.benchmark(group="list-diff-edge")
102
+ def test_list_diff_completely_different(benchmark):
103
+ """Benchmark: Two completely different 1000 element lists."""
104
+ a = generate_random_list(1000)
105
+ b = generate_random_list(1000)
106
+ benchmark(diff, a, b)
107
+
108
+
109
+ @pytest.mark.benchmark(group="list-diff-edge")
110
+ def test_list_diff_identical(benchmark):
111
+ """Benchmark: Two identical 10000 element lists."""
112
+ a = generate_random_list(10000)
113
+ b = a.copy()
114
+ benchmark(diff, a, b)
115
+
116
+
117
+ # ========================================
118
+ # Dict Diff Benchmarks
119
+ # ========================================
120
+
121
+
122
+ @pytest.mark.benchmark(group="dict-diff")
123
+ def test_dict_diff_flat_500_keys(benchmark):
124
+ """Benchmark: Flat dict with 500 keys, 10% changed."""
125
+ a = {f"key_{i}": i for i in range(500)}
126
+ b = a.copy()
127
+ # Change 10%
128
+ for i in range(50):
129
+ b[f"key_{i}"] = i + 500
130
+
131
+ benchmark(diff, a, b)
132
+
133
+
134
+ @pytest.mark.benchmark(group="dict-diff")
135
+ def test_dict_diff_nested(benchmark):
136
+ """Benchmark: Nested dict with depth=3, breadth=5."""
137
+ a = generate_nested_dict(3, 5)
138
+ b = generate_nested_dict(3, 5)
139
+ benchmark(diff, a, b)
140
+
141
+
142
+ # ========================================
143
+ # Mixed Structure Benchmarks
144
+ # ========================================
145
+
146
+
147
+ @pytest.mark.benchmark(group="mixed")
148
+ def test_mixed_dict_with_list_values(benchmark):
149
+ """Benchmark: Dict with 50 keys, each containing a 100-element list."""
150
+ a = {f"key_{i}": generate_random_list(100) for i in range(50)}
151
+ b = {f"key_{i}": generate_random_list(100) for i in range(50)}
152
+ benchmark(diff, a, b)
153
+
154
+
155
+ # ========================================
156
+ # Apply Benchmarks
157
+ # ========================================
158
+
159
+
160
+ @pytest.mark.benchmark(group="apply")
161
+ def test_apply_list_1000_elements(benchmark):
162
+ """Benchmark: Apply patch to 1000 element list with 10% changes."""
163
+ a, b = generate_similar_lists(1000, 0.1)
164
+ ops, _ = diff(a, b)
165
+
166
+ benchmark(apply, a, ops)
@@ -0,0 +1,187 @@
1
+ from __future__ import annotations
2
+
3
+ from typing import Dict, List, Set, Tuple
4
+
5
+ from .pointer import Pointer
6
+ from .types import Diffable
7
+
8
+
9
+ def diff_lists(input: List, output: List, ptr: Pointer) -> Tuple[List, List]:
10
+ m, n = len(input), len(output)
11
+
12
+ # Build DP table bottom-up (iterative approach)
13
+ # dp[i][j] = cost of transforming input[0:i] to output[0:j]
14
+ dp = [[0] * (n + 1) for _ in range(m + 1)]
15
+
16
+ # Initialize base cases
17
+ for i in range(1, m + 1):
18
+ dp[i][0] = i # Cost of deleting all elements
19
+ for j in range(1, n + 1):
20
+ dp[0][j] = j # Cost of adding all elements
21
+
22
+ # Fill DP table
23
+ for i in range(1, m + 1):
24
+ for j in range(1, n + 1):
25
+ if input[i - 1] == output[j - 1]:
26
+ # Elements match, no operation needed
27
+ dp[i][j] = dp[i - 1][j - 1]
28
+ else:
29
+ # Take minimum of three operations
30
+ dp[i][j] = min(
31
+ dp[i - 1][j] + 1, # Remove from input
32
+ dp[i][j - 1] + 1, # Add from output
33
+ dp[i - 1][j - 1] + 1, # Replace
34
+ )
35
+
36
+ # Traceback to extract operations
37
+ ops = []
38
+ rops = []
39
+ i, j = m, n
40
+
41
+ while i > 0 or j > 0:
42
+ if i > 0 and j > 0 and input[i - 1] == output[j - 1]:
43
+ # Elements match, no operation
44
+ i -= 1
45
+ j -= 1
46
+ elif i > 0 and (j == 0 or dp[i][j] == dp[i - 1][j] + 1):
47
+ # Remove from input
48
+ ops.append({"op": "remove", "idx": i - 1})
49
+ rops.append({"op": "add", "idx": j - 1, "value": input[i - 1]})
50
+ i -= 1
51
+ elif j > 0 and (i == 0 or dp[i][j] == dp[i][j - 1] + 1):
52
+ # Add from output
53
+ ops.append({"op": "add", "idx": i - 1, "value": output[j - 1]})
54
+ rops.append({"op": "remove", "idx": j - 1})
55
+ j -= 1
56
+ else:
57
+ # Replace
58
+ ops.append(
59
+ {
60
+ "op": "replace",
61
+ "idx": i - 1,
62
+ "original": input[i - 1],
63
+ "value": output[j - 1],
64
+ }
65
+ )
66
+ rops.append(
67
+ {
68
+ "op": "replace",
69
+ "idx": j - 1,
70
+ "original": output[j - 1],
71
+ "value": input[i - 1],
72
+ }
73
+ )
74
+ i -= 1
75
+ j -= 1
76
+
77
+ # Apply padding to operations (using explicit loops instead of reduce)
78
+ padded_ops = []
79
+ padding = 0
80
+ # Iterate in reverse to get correct order (traceback extracts operations backwards)
81
+ for op in reversed(ops):
82
+ if op["op"] == "add":
83
+ padded_idx = op["idx"] + 1 + padding
84
+ idx_token = padded_idx if padded_idx < len(input) + padding else "-"
85
+ padded_ops.append(
86
+ {
87
+ "op": "add",
88
+ "path": ptr.append(idx_token),
89
+ "value": op["value"],
90
+ }
91
+ )
92
+ padding += 1
93
+ elif op["op"] == "remove":
94
+ padded_ops.append(
95
+ {
96
+ "op": "remove",
97
+ "path": ptr.append(op["idx"] + padding),
98
+ }
99
+ )
100
+ padding -= 1
101
+ else: # replace
102
+ replace_ptr = ptr.append(op["idx"] + padding)
103
+ replace_ops, _ = diff(op["original"], op["value"], replace_ptr)
104
+ padded_ops.extend(replace_ops)
105
+
106
+ padded_rops = []
107
+ padding = 0
108
+ # Iterate in reverse to get correct order (traceback extracts operations backwards)
109
+ for op in reversed(rops):
110
+ if op["op"] == "add":
111
+ padded_idx = op["idx"] + 1 + padding
112
+ idx_token = padded_idx if padded_idx < len(output) + padding else "-"
113
+ padded_rops.append(
114
+ {
115
+ "op": "add",
116
+ "path": ptr.append(idx_token),
117
+ "value": op["value"],
118
+ }
119
+ )
120
+ padding += 1
121
+ elif op["op"] == "remove":
122
+ padded_rops.append(
123
+ {
124
+ "op": "remove",
125
+ "path": ptr.append(op["idx"] + padding),
126
+ }
127
+ )
128
+ padding -= 1
129
+ else: # replace
130
+ replace_ptr = ptr.append(op["idx"] + padding)
131
+ replace_ops, _ = diff(op["original"], op["value"], replace_ptr)
132
+ padded_rops.extend(replace_ops)
133
+
134
+ return padded_ops, padded_rops
135
+
136
+
137
+ def diff_dicts(input: Dict, output: Dict, ptr: Pointer) -> Tuple[List, List]:
138
+ ops, rops = [], []
139
+ input_keys = set(input.keys())
140
+ output_keys = set(output.keys())
141
+ for key in input_keys - output_keys:
142
+ ops.append({"op": "remove", "path": ptr.append(key)})
143
+ rops.insert(0, {"op": "add", "path": ptr.append(key), "value": input[key]})
144
+ for key in output_keys - input_keys:
145
+ ops.append(
146
+ {
147
+ "op": "add",
148
+ "path": ptr.append(key),
149
+ "value": output[key],
150
+ }
151
+ )
152
+ rops.insert(0, {"op": "remove", "path": ptr.append(key)})
153
+ for key in input_keys & output_keys:
154
+ key_ops, key_rops = diff(input[key], output[key], ptr.append(key))
155
+ ops.extend(key_ops)
156
+ key_rops.extend(rops)
157
+ rops = key_rops
158
+ return ops, rops
159
+
160
+
161
+ def diff_sets(input: Set, output: Set, ptr: Pointer) -> Tuple[List, List]:
162
+ ops, rops = [], []
163
+ for value in input - output:
164
+ ops.append({"op": "remove", "path": ptr.append(value)})
165
+ rops.insert(0, {"op": "add", "path": ptr.append("-"), "value": value})
166
+ for value in output - input:
167
+ ops.append({"op": "add", "path": ptr.append("-"), "value": value})
168
+ rops.insert(0, {"op": "remove", "path": ptr.append(value)})
169
+ return ops, rops
170
+
171
+
172
+ def diff(
173
+ input: Diffable, output: Diffable, ptr: Pointer | None = None
174
+ ) -> Tuple[List, List]:
175
+ if input == output:
176
+ return [], []
177
+ if ptr is None:
178
+ ptr = Pointer()
179
+ if hasattr(input, "append") and hasattr(output, "append"): # list
180
+ return diff_lists(input, output, ptr)
181
+ if hasattr(input, "keys") and hasattr(output, "keys"): # dict
182
+ return diff_dicts(input, output, ptr)
183
+ if hasattr(input, "add") and hasattr(output, "add"): # set
184
+ return diff_sets(input, output, ptr)
185
+ return [{"op": "replace", "path": ptr, "value": output}], [
186
+ {"op": "replace", "path": ptr, "value": input}
187
+ ]
@@ -1,9 +1,10 @@
1
+ from __future__ import annotations
2
+
1
3
  import re
2
- from typing import Any, Hashable, List, Tuple
4
+ from typing import Any, Hashable, Iterable, Tuple
3
5
 
4
6
  from .types import Diffable
5
7
 
6
-
7
8
  tilde0_re = re.compile("~0")
8
9
  tilde1_re = re.compile("~1")
9
10
  tilde_re = re.compile("~")
@@ -19,7 +20,7 @@ def escape(token: str) -> str:
19
20
 
20
21
 
21
22
  class Pointer:
22
- def __init__(self, tokens: List[Hashable] = None) -> None:
23
+ def __init__(self, tokens: Iterable[Hashable] | None = None) -> None:
23
24
  if tokens is None:
24
25
  tokens = []
25
26
  self.tokens = tuple(tokens)
@@ -33,7 +34,7 @@ class Pointer:
33
34
  return "/" + "/".join(escape(str(t)) for t in self.tokens)
34
35
 
35
36
  def __repr__(self) -> str:
36
- return f"Pointer({repr(list(self.tokens))})"
37
+ return f"Pointer({list(self.tokens)!r})"
37
38
 
38
39
  def __hash__(self) -> int:
39
40
  return hash(self.tokens)
@@ -62,4 +63,4 @@ class Pointer:
62
63
 
63
64
  def append(self, token: Hashable) -> "Pointer":
64
65
  """append, creating new Pointer"""
65
- return Pointer(self.tokens + (token,))
66
+ return Pointer((*self.tokens, token))
@@ -0,0 +1,39 @@
1
+ [project]
2
+ name = "patchdiff"
3
+ version = "0.3.6"
4
+ description = "MIT"
5
+ authors = [
6
+ { name = "Korijn van Golen", email = "korijn@gmail.com" },
7
+ { name = "Berend Klein Haneveld", email = "berendkleinhaneveld@gmail.com" },
8
+ ]
9
+ requires-python = ">=3.8"
10
+ readme = "README.md"
11
+
12
+ [project.urls]
13
+ Homepage = "https://github.com/fork-tongue/patchdiff"
14
+
15
+ [dependency-groups]
16
+ dev = [
17
+ "ruff",
18
+ "pytest",
19
+ "pytest-cov",
20
+ "pytest-watch",
21
+ "pytest-benchmark",
22
+ ]
23
+
24
+ [tool.ruff.lint]
25
+ extend-select = [
26
+ "F", # Pyflakes (default)
27
+ "I", # isort imports
28
+ "N", # pep8-naming
29
+ "T10", # flake8-debugger
30
+ "T20", # flake8-print
31
+ "RUF", # ruff
32
+ ]
33
+
34
+ [tool.ruff.lint.per-file-ignores]
35
+ "patchdiff/__init__.py" = ["F401"]
36
+
37
+ [build-system]
38
+ requires = ["hatchling"]
39
+ build-backend = "hatchling.build"
@@ -0,0 +1,111 @@
1
+ from patchdiff import apply, diff
2
+
3
+
4
+ def test_apply():
5
+ a = {
6
+ "a": [5, 7, 9, {"a", "b", "c"}],
7
+ "b": 6,
8
+ }
9
+ b = {
10
+ "a": [5, 2, 9, {"b", "c"}],
11
+ "b": 6,
12
+ "c": 7,
13
+ }
14
+
15
+ ops, rops = diff(a, b)
16
+
17
+ c = apply(a, ops)
18
+ assert c == b
19
+
20
+ d = apply(b, rops)
21
+ assert a == d
22
+
23
+
24
+ def test_apply_list():
25
+ a = [1, 5, 9, "sdfsdf", "fff"]
26
+ b = ["sdf", 5, 9, "c"]
27
+
28
+ ops, rops = diff(a, b)
29
+
30
+ c = apply(a, ops)
31
+ assert c == b
32
+
33
+ d = apply(b, rops)
34
+ assert a == d
35
+
36
+
37
+ def test_add_remove_list():
38
+ a = []
39
+ b = [1]
40
+
41
+ ops, rops = diff(a, b)
42
+
43
+ c = apply(a, ops)
44
+ assert c == b
45
+
46
+ d = apply(b, rops)
47
+ assert a == d
48
+
49
+
50
+ def test_add_remove_list_extended():
51
+ a = []
52
+ b = [1, 2, 3]
53
+
54
+ ops, rops = diff(a, b)
55
+
56
+ c = apply(a, ops)
57
+ assert c == b
58
+
59
+ d = apply(b, rops)
60
+ assert a == d
61
+
62
+
63
+ def test_insertion_in_list_front():
64
+ a = [1, 2]
65
+ b = [3, 1, 2]
66
+ ops, rops = diff(a, b)
67
+
68
+ c = apply(a, ops)
69
+ assert c == b
70
+
71
+ d = apply(b, rops)
72
+ assert a == d
73
+
74
+
75
+ def test_add_remove_list_extended_inverse():
76
+ a = [1, 2, 3]
77
+ b = []
78
+
79
+ ops, rops = diff(a, b)
80
+
81
+ c = apply(a, ops)
82
+ assert c == b
83
+
84
+ d = apply(b, rops)
85
+ assert a == d
86
+
87
+
88
+ def test_add_remove_list_extended_inverse_leaving_start():
89
+ a = [1, 2, 3, 4]
90
+ b = [1]
91
+
92
+ ops, rops = diff(a, b)
93
+
94
+ c = apply(a, ops)
95
+ assert c == b
96
+
97
+ d = apply(b, rops)
98
+ assert a == d
99
+
100
+
101
+ def test_add_remove_list_extended_inverse_leaving_end():
102
+ a = [1, 2, 3, 4]
103
+ b = [4]
104
+
105
+ ops, rops = diff(a, b)
106
+
107
+ c = apply(a, ops)
108
+ assert c == b
109
+
110
+ d = apply(b, rops)
111
+ assert a == d
@@ -0,0 +1,170 @@
1
+ from patchdiff import diff
2
+ from patchdiff.pointer import Pointer
3
+
4
+
5
+ def test_basic_list_insertion():
6
+ a = []
7
+ b = [1]
8
+ ops, rops = diff(a, b)
9
+
10
+ assert ops == [{"op": "add", "path": Pointer(["-"]), "value": 1}]
11
+ assert rops == [{"op": "remove", "path": Pointer([0])}]
12
+
13
+
14
+ def test_basic_list_deletion():
15
+ a = [1]
16
+ b = []
17
+ ops, rops = diff(a, b)
18
+
19
+ assert ops == [{"op": "remove", "path": Pointer([0])}]
20
+ assert rops == [{"op": "add", "path": Pointer(["-"]), "value": 1}]
21
+
22
+
23
+ def test_basic_list_insertion_half_way():
24
+ a = [1, 3]
25
+ b = [1, 2, 3]
26
+ ops, rops = diff(a, b)
27
+
28
+ assert ops == [{"op": "add", "path": Pointer([1]), "value": 2}]
29
+ assert rops == [{"op": "remove", "path": Pointer([1])}]
30
+
31
+
32
+ def test_basic_list_deletion_half_way():
33
+ a = [1, 2, 3]
34
+ b = [1, 3]
35
+ ops, rops = diff(a, b)
36
+
37
+ assert ops == [{"op": "remove", "path": Pointer([1])}]
38
+ assert rops == [{"op": "add", "path": Pointer([1]), "value": 2}]
39
+
40
+
41
+ def test_basic_list_multiple_insertion():
42
+ a = []
43
+ b = [1, 2, 3]
44
+ ops, rops = diff(a, b)
45
+
46
+ assert ops == [
47
+ {"op": "add", "path": Pointer(["-"]), "value": 1},
48
+ {"op": "add", "path": Pointer(["-"]), "value": 2},
49
+ {"op": "add", "path": Pointer(["-"]), "value": 3},
50
+ ]
51
+ assert rops == [
52
+ {"op": "remove", "path": Pointer([0])},
53
+ {"op": "remove", "path": Pointer([0])},
54
+ {"op": "remove", "path": Pointer([0])},
55
+ ]
56
+
57
+
58
+ def test_basic_list_multiple_deletion():
59
+ a = [1, 2, 3]
60
+ b = []
61
+ ops, rops = diff(a, b)
62
+
63
+ assert ops == [
64
+ {"op": "remove", "path": Pointer([0])},
65
+ {"op": "remove", "path": Pointer([0])},
66
+ {"op": "remove", "path": Pointer([0])},
67
+ ]
68
+ assert rops == [
69
+ {"op": "add", "path": Pointer(["-"]), "value": 1},
70
+ {"op": "add", "path": Pointer(["-"]), "value": 2},
71
+ {"op": "add", "path": Pointer(["-"]), "value": 3},
72
+ ]
73
+
74
+
75
+ def test_list():
76
+ a = [1, 5, 9, "sdfsdf", "fff"]
77
+ b = ["sdf", 5, 9, "c"]
78
+ ops, rops = diff(a, b)
79
+
80
+ assert ops == [
81
+ {"op": "replace", "path": Pointer([0]), "value": "sdf"},
82
+ {"op": "replace", "path": Pointer([3]), "value": "c"},
83
+ {"op": "remove", "path": Pointer([4])},
84
+ ]
85
+ assert rops == [
86
+ {"op": "replace", "path": Pointer([0]), "value": 1},
87
+ {"op": "replace", "path": Pointer([3]), "value": "sdfsdf"},
88
+ {"op": "add", "path": Pointer(["-"]), "value": "fff"},
89
+ ]
90
+
91
+
92
+ def test_list_begin():
93
+ a = [1, 2]
94
+ b = [3, 1, 2]
95
+ ops, rops = diff(a, b)
96
+
97
+ assert ops == [{"op": "add", "path": Pointer([0]), "value": 3}]
98
+ assert rops == [{"op": "remove", "path": Pointer([0])}]
99
+
100
+
101
+ def test_list_end():
102
+ a = [1, 2, 3]
103
+ b = [1, 2, 3, 4]
104
+ ops, rops = diff(a, b)
105
+
106
+ assert ops == [{"op": "add", "path": Pointer(["-"]), "value": 4}]
107
+ assert rops == [{"op": "remove", "path": Pointer([3])}]
108
+
109
+
110
+ def test_dicts():
111
+ a = {"a": 5, "b": 6}
112
+ b = {"a": 3, "b": 6, "c": 7}
113
+ ops, rops = diff(a, b)
114
+
115
+ assert ops == [
116
+ {"op": "add", "path": Pointer(["c"]), "value": 7},
117
+ {"op": "replace", "path": Pointer(["a"]), "value": 3},
118
+ ]
119
+ assert rops == [
120
+ {"op": "replace", "path": Pointer(["a"]), "value": 5},
121
+ {"op": "remove", "path": Pointer(["c"])},
122
+ ]
123
+
124
+
125
+ def test_dicts_remove_item():
126
+ a = {"a": 3, "b": 6}
127
+ b = {"a": 3}
128
+ ops, rops = diff(a, b)
129
+
130
+ assert ops == [{"op": "remove", "path": Pointer(["b"])}]
131
+ assert rops == [{"op": "add", "path": Pointer(["b"]), "value": 6}]
132
+
133
+
134
+ def test_sets():
135
+ a = {"a", "b"}
136
+ b = {"a", "c"}
137
+ ops, rops = diff(a, b)
138
+
139
+ assert ops == [
140
+ {"op": "remove", "path": Pointer(["b"])},
141
+ {"op": "add", "path": Pointer(["-"]), "value": "c"},
142
+ ]
143
+ assert rops == [
144
+ {"op": "remove", "path": Pointer(["c"])},
145
+ {"op": "add", "path": Pointer(["-"]), "value": "b"},
146
+ ]
147
+
148
+
149
+ def test_mixed():
150
+ a = {
151
+ "a": [5, 7, 9, {"a", "b", "c"}],
152
+ "b": 6,
153
+ }
154
+ b = {
155
+ "a": [5, 2, 9, {"b", "c"}],
156
+ "b": 6,
157
+ "c": 7,
158
+ }
159
+ ops, rops = diff(a, b)
160
+
161
+ assert ops == [
162
+ {"op": "add", "path": Pointer(["c"]), "value": 7},
163
+ {"op": "replace", "path": Pointer(["a", 1]), "value": 2},
164
+ {"op": "remove", "path": Pointer(["a", 3, "a"])},
165
+ ]
166
+ assert rops == [
167
+ {"op": "replace", "path": Pointer(["a", 1]), "value": 7},
168
+ {"op": "add", "path": Pointer(["a", 3, "-"]), "value": "a"},
169
+ {"op": "remove", "path": Pointer(["c"])},
170
+ ]
@@ -0,0 +1,46 @@
1
+ from patchdiff.pointer import Pointer
2
+
3
+
4
+ def test_pointer_get():
5
+ obj = [1, 5, {"foo": 1, "bar": [1, 2, 3]}, "sdfsdf", "fff"]
6
+ assert Pointer([1]).evaluate(obj)[2] == 5
7
+ assert Pointer([2, "bar", 1]).evaluate(obj)[2] == 2
8
+
9
+
10
+ def test_pointer_str():
11
+ assert str(Pointer([1])) == "/1"
12
+ assert str(Pointer(["foo", "bar", "-"])) == "/foo/bar/-"
13
+
14
+
15
+ def test_pointer_repr():
16
+ assert repr(Pointer([1])) == "Pointer([1])"
17
+ assert repr(Pointer(["foo", "bar", "-"])) == "Pointer(['foo', 'bar', '-'])"
18
+
19
+
20
+ def test_pointer_from_str():
21
+ assert Pointer.from_str("/1") == Pointer(["1"])
22
+ assert Pointer.from_str("/foo/bar/-") == Pointer(["foo", "bar", "-"])
23
+
24
+
25
+ def test_pointer_hash():
26
+ assert hash(Pointer([1])) == hash((1,))
27
+ assert hash(Pointer(["foo", "bar", "-"])) == hash(("foo", "bar", "-"))
28
+
29
+
30
+ def test_pointer_set():
31
+ # hash supports comparison operators for use as keys and set elements
32
+ # so we exercise that as well
33
+ unique_pointers = [Pointer([1]), Pointer(["2", "3"])]
34
+ duplicated_pointers = unique_pointers + unique_pointers
35
+ assert len(set(duplicated_pointers)) == len(unique_pointers)
36
+
37
+
38
+ def test_pointer_eq():
39
+ assert Pointer([1]) != [1]
40
+ assert Pointer([1]) != Pointer(["1"])
41
+ assert Pointer([1]) != Pointer([0])
42
+ assert Pointer([1]) == Pointer([1])
43
+
44
+
45
+ def test_pointer_append():
46
+ assert Pointer([1]).append("foo") == Pointer([1, "foo"])
@@ -0,0 +1,86 @@
1
+ """
2
+ Tests that check that patchdiff apply and diff work
3
+ on proxied objects.
4
+ """
5
+
6
+ from collections import UserDict, UserList
7
+ from collections.abc import Set
8
+
9
+ from patchdiff import apply, diff
10
+
11
+
12
+ class SetProxy(Set):
13
+ """
14
+ Custom proxy class that works like UserDict and UserList by
15
+ storing the original data under the 'data' attribute.
16
+ """
17
+
18
+ def __init__(self, *args, **kwargs):
19
+ self.data = set(*args, **kwargs)
20
+
21
+ def __contains__(self, *args, **kwargs):
22
+ return self.data.__contains__(*args, **kwargs)
23
+
24
+ def __iter__(self, *args, **kwargs):
25
+ return self.data.__iter__(*args, **kwargs)
26
+
27
+ def __len__(self, *args, **kwargs):
28
+ return self.data.__len__(*args, **kwargs)
29
+
30
+ def __getattribute__(self, attr):
31
+ # Redirect __class__ to super() to make sure
32
+ # isinstance(obj, set) will fail
33
+ if attr in {"data", "_from_iterable", "__class__"}:
34
+ return super().__getattribute__(attr)
35
+ return self.data.__getattribute__(attr)
36
+
37
+
38
+ def test_proxy_dict():
39
+ data = {"foo": "bar"}
40
+ obj = UserDict(data)
41
+ assert not isinstance(obj, dict)
42
+
43
+ old = obj.copy()
44
+ obj["foo"] = "baz"
45
+
46
+ assert old["foo"] == "bar"
47
+ assert obj["foo"] == "baz"
48
+
49
+ ops, reverse_ops = diff(old, obj)
50
+
51
+ assert apply(old, ops) == obj
52
+ assert apply(obj, reverse_ops) == old
53
+
54
+
55
+ def test_proxy_list():
56
+ data = [1, 2]
57
+ obj = UserList(data)
58
+ assert not isinstance(obj, list)
59
+
60
+ old = obj.copy()
61
+ obj[1] = 3
62
+
63
+ assert old[1] == 2
64
+ assert obj[1] == 3
65
+
66
+ ops, reverse_ops = diff(old, obj)
67
+
68
+ assert apply(old, ops) == obj
69
+ assert apply(obj, reverse_ops) == old
70
+
71
+
72
+ def test_proxy_set():
73
+ data = {"a", "b"}
74
+ obj = SetProxy(data)
75
+ assert not isinstance(obj, set)
76
+
77
+ old = obj.copy()
78
+ obj.add("c")
79
+
80
+ assert "c" not in old
81
+ assert "c" in obj
82
+
83
+ ops, reverse_ops = diff(old, obj)
84
+
85
+ assert apply(old, ops) == obj
86
+ assert apply(obj, reverse_ops) == old
@@ -0,0 +1,31 @@
1
+ from patchdiff import diff, to_json
2
+
3
+
4
+ def test_to_json():
5
+ a = {
6
+ "a": [5, 7, 9, {"a", "b", "c"}],
7
+ "b": 6,
8
+ }
9
+ b = {"a": [5, 2, 9, {"b", "c"}], "b": 6, "c": 7}
10
+
11
+ ops, _ = diff(a, b)
12
+
13
+ assert (
14
+ to_json(ops, indent=4)
15
+ == """[
16
+ {
17
+ "op": "add",
18
+ "path": "/c",
19
+ "value": 7
20
+ },
21
+ {
22
+ "op": "replace",
23
+ "path": "/a/1",
24
+ "value": 2
25
+ },
26
+ {
27
+ "op": "remove",
28
+ "path": "/a/3/a"
29
+ }
30
+ ]"""
31
+ )
@@ -1,141 +0,0 @@
1
- from functools import partial, reduce
2
- from typing import Dict, List, Set, Tuple
3
-
4
- from .pointer import Pointer
5
- from .types import Diffable
6
-
7
-
8
- def diff_lists(input: List, output: List, ptr: Pointer) -> Tuple[List, List]:
9
- memory = {(0, 0): {"ops": [], "rops": [], "cost": 0}}
10
-
11
- def dist(i, j):
12
- if (i, j) not in memory:
13
- if i > 0 and j > 0 and input[i - 1] == output[j - 1]:
14
- step = dist(i - 1, j - 1)
15
- else:
16
- paths = []
17
- if i > 0:
18
- base = dist(i - 1, j)
19
- op = {"op": "remove", "idx": i - 1}
20
- rop = {"op": "add", "idx": j - 1, "value": input[i - 1]}
21
- paths.append(
22
- {
23
- "ops": base["ops"] + [op],
24
- "rops": base["rops"] + [rop],
25
- "cost": base["cost"] + 1,
26
- }
27
- )
28
- if j > 0:
29
- base = dist(i, j - 1)
30
- op = {"op": "add", "idx": i - 1, "value": output[j - 1]}
31
- rop = {"op": "remove", "idx": j - 1}
32
- paths.append(
33
- {
34
- "ops": base["ops"] + [op],
35
- "rops": base["rops"] + [rop],
36
- "cost": base["cost"] + 1,
37
- }
38
- )
39
- if i > 0 and j > 0:
40
- base = dist(i - 1, j - 1)
41
- op = {
42
- "op": "replace",
43
- "idx": i - 1,
44
- "original": input[i - 1],
45
- "value": output[j - 1],
46
- }
47
- rop = {
48
- "op": "replace",
49
- "idx": j - 1,
50
- "original": output[j - 1],
51
- "value": input[i - 1],
52
- }
53
- paths.append(
54
- {
55
- "ops": base["ops"] + [op],
56
- "rops": base["rops"] + [rop],
57
- "cost": base["cost"] + 1,
58
- }
59
- )
60
- step = min(paths, key=lambda a: a["cost"])
61
- memory[(i, j)] = step
62
- return memory[(i, j)]
63
-
64
- def pad(state, op, target=None):
65
- ops, padding = state
66
- if op["op"] == "add":
67
- padded_idx = op["idx"] + 1 + padding
68
- idx_token = padded_idx if padded_idx < len(target) + padding else "-"
69
- full_op = {
70
- "op": "add",
71
- "path": ptr.append(idx_token),
72
- "value": op["value"],
73
- }
74
- return [ops + [full_op], padding + 1]
75
- elif op["op"] == "remove":
76
- full_op = {
77
- "op": "remove",
78
- "path": ptr.append(op["idx"] + padding),
79
- }
80
- return [ops + [full_op], padding - 1]
81
- else:
82
- replace_ptr = ptr.append(op["idx"] + padding)
83
- replace_ops, _ = diff(op["original"], op["value"], replace_ptr)
84
- return [ops + replace_ops, padding]
85
-
86
- solution = dist(len(input), len(output))
87
- padded_ops, _ = reduce(partial(pad, target=input), solution["ops"], [[], 0])
88
- padded_rops, _ = reduce(partial(pad, target=output), solution["rops"], [[], 0])
89
-
90
- return padded_ops, padded_rops
91
-
92
-
93
- def diff_dicts(input: Dict, output: Dict, ptr: Pointer) -> Tuple[List, List]:
94
- ops, rops = [], []
95
- input_keys = set(input.keys())
96
- output_keys = set(output.keys())
97
- for key in input_keys - output_keys:
98
- ops.append({"op": "remove", "path": ptr.append(key)})
99
- rops.insert(0, {"op": "add", "path": ptr.append(key), "value": input[key]})
100
- for key in output_keys - input_keys:
101
- ops.append(
102
- {
103
- "op": "add",
104
- "path": ptr.append(key),
105
- "value": output[key],
106
- }
107
- )
108
- rops.insert(0, {"op": "remove", "path": ptr.append(key)})
109
- for key in input_keys & output_keys:
110
- key_ops, key_rops = diff(input[key], output[key], ptr.append(key))
111
- ops.extend(key_ops)
112
- key_rops.extend(rops)
113
- rops = key_rops
114
- return ops, rops
115
-
116
-
117
- def diff_sets(input: Set, output: Set, ptr: Pointer) -> Tuple[List, List]:
118
- ops, rops = [], []
119
- for value in input - output:
120
- ops.append({"op": "remove", "path": ptr.append(value)})
121
- rops.insert(0, {"op": "add", "path": ptr.append("-"), "value": value})
122
- for value in output - input:
123
- ops.append({"op": "add", "path": ptr.append("-"), "value": value})
124
- rops.insert(0, {"op": "remove", "path": ptr.append(value)})
125
- return ops, rops
126
-
127
-
128
- def diff(input: Diffable, output: Diffable, ptr: Pointer = None) -> Tuple[List, List]:
129
- if input == output:
130
- return [], []
131
- if ptr is None:
132
- ptr = Pointer()
133
- if hasattr(input, "append") and hasattr(output, "append"): # list
134
- return diff_lists(input, output, ptr)
135
- if hasattr(input, "keys") and hasattr(output, "keys"): # dict
136
- return diff_dicts(input, output, ptr)
137
- if hasattr(input, "add") and hasattr(output, "add"): # set
138
- return diff_sets(input, output, ptr)
139
- return [{"op": "replace", "path": ptr, "value": output}], [
140
- {"op": "replace", "path": ptr, "value": input}
141
- ]
@@ -1,25 +0,0 @@
1
- [tool.poetry]
2
- name = "patchdiff"
3
- version = "0.3.4"
4
- description = "MIT"
5
- authors = ["Korijn van Golen <korijn@gmail.com>", "Berend Klein Haneveld <berendkleinhaneveld@gmail.com>"]
6
- homepage = "https://github.com/fork-tongue/patchdiff"
7
- readme = "README.md"
8
-
9
- [tool.poetry.dependencies]
10
- python = ">=3.7"
11
-
12
- [tool.poetry.dev-dependencies]
13
- flake8 = "*"
14
- black = "*"
15
- flake8-black = "*"
16
- flake8-import-order = "*"
17
- flake8-print = "*"
18
- pytest = "*"
19
- pytest-cov = "*"
20
- pytest-watch = "*"
21
- twine = "*"
22
-
23
- [build-system]
24
- requires = ["poetry-core>=1.0.0"]
25
- build-backend = "poetry.core.masonry.api"
patchdiff-0.3.4/setup.py DELETED
@@ -1,26 +0,0 @@
1
- # -*- coding: utf-8 -*-
2
- from setuptools import setup
3
-
4
- packages = \
5
- ['patchdiff']
6
-
7
- package_data = \
8
- {'': ['*']}
9
-
10
- setup_kwargs = {
11
- 'name': 'patchdiff',
12
- 'version': '0.3.4',
13
- 'description': 'MIT',
14
- 'long_description': '[![PyPI version](https://badge.fury.io/py/patchdiff.svg)](https://badge.fury.io/py/patchdiff)\n[![CI status](https://github.com/fork-tongue/patchdiff/workflows/CI/badge.svg)](https://github.com/fork-tongue/patchdiff/actions)\n\n# Patchdiff 🔍\n\nBased on [rfc6902](https://github.com/chbrown/rfc6902) this library provides a simple API to generate bi-directional diffs between composite python datastructures composed out of lists, sets, tuples and dicts. The diffs are jsonpatch compliant, and can optionally be serialized to json format. Patchdiff can also be used to apply lists of patches to objects, both in-place or on a deepcopy of the input.\n\n## Install\n\n`pip install patchdiff`\n\n## Quick-start\n\n```python\nfrom patchdiff import apply, diff, iapply, to_json\n\ninput = {"a": [5, 7, 9, {"a", "b", "c"}], "b": 6}\noutput = {"a": [5, 2, 9, {"b", "c"}], "b": 6, "c": 7}\n\nops, reverse_ops = diff(input, output)\n\nassert apply(input, ops) == output\nassert apply(output, reverse_ops) == input\n\niapply(input, ops) # apply in-place\nassert input == output\n\nprint(to_json(ops, indent=4))\n# [\n# {\n# "op": "add",\n# "path": "/c",\n# "value": 7\n# },\n# {\n# "op": "replace",\n# "path": "/a/1",\n# "value": 2\n# },\n# {\n# "op": "remove",\n# "path": "/a/3/a"\n# }\n# ]\n```\n',
15
- 'author': 'Korijn van Golen',
16
- 'author_email': 'korijn@gmail.com',
17
- 'maintainer': None,
18
- 'maintainer_email': None,
19
- 'url': 'https://github.com/fork-tongue/patchdiff',
20
- 'packages': packages,
21
- 'package_data': package_data,
22
- 'python_requires': '>=3.7',
23
- }
24
-
25
-
26
- setup(**setup_kwargs)
File without changes
File without changes
File without changes