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.
- patchdiff-0.3.6/.github/workflows/benchmark.yml +69 -0
- patchdiff-0.3.6/.github/workflows/ci.yml +103 -0
- patchdiff-0.3.6/.gitignore +7 -0
- {patchdiff-0.3.4 → patchdiff-0.3.6}/PKG-INFO +5 -12
- patchdiff-0.3.6/benchmarks/benchmark.py +166 -0
- patchdiff-0.3.6/patchdiff/diff.py +187 -0
- {patchdiff-0.3.4 → patchdiff-0.3.6}/patchdiff/pointer.py +6 -5
- patchdiff-0.3.6/pyproject.toml +39 -0
- patchdiff-0.3.6/tests/test_apply.py +111 -0
- patchdiff-0.3.6/tests/test_diff.py +170 -0
- patchdiff-0.3.6/tests/test_pointer.py +46 -0
- patchdiff-0.3.6/tests/test_proxy.py +86 -0
- patchdiff-0.3.6/tests/test_serialize.py +31 -0
- patchdiff-0.3.4/patchdiff/diff.py +0 -141
- patchdiff-0.3.4/pyproject.toml +0 -25
- patchdiff-0.3.4/setup.py +0 -26
- {patchdiff-0.3.4 → patchdiff-0.3.6}/README.md +0 -0
- {patchdiff-0.3.4 → patchdiff-0.3.6}/patchdiff/__init__.py +0 -0
- {patchdiff-0.3.4 → patchdiff-0.3.6}/patchdiff/apply.py +0 -0
- {patchdiff-0.3.4 → patchdiff-0.3.6}/patchdiff/serialize.py +0 -0
- {patchdiff-0.3.4 → patchdiff-0.3.6}/patchdiff/types.py +0 -0
|
@@ -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
|
|
@@ -1,16 +1,10 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
2
|
Name: patchdiff
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.6
|
|
4
4
|
Summary: MIT
|
|
5
|
-
|
|
6
|
-
Author: Korijn van Golen
|
|
7
|
-
|
|
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
|
[](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,
|
|
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:
|
|
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({
|
|
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
|
|
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
|
-
]
|
patchdiff-0.3.4/pyproject.toml
DELETED
|
@@ -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': '[](https://badge.fury.io/py/patchdiff)\n[](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
|
|
File without changes
|
|
File without changes
|