migrate-guard 0.1.0__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.
- migrate_guard-0.1.0/LICENSE +21 -0
- migrate_guard-0.1.0/PKG-INFO +219 -0
- migrate_guard-0.1.0/README.md +190 -0
- migrate_guard-0.1.0/pyproject.toml +103 -0
- migrate_guard-0.1.0/pyproject.toml.orig +84 -0
- migrate_guard-0.1.0/src/migrate_guard/__init__.py +3 -0
- migrate_guard-0.1.0/src/migrate_guard/application/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/application/use_cases/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/application/use_cases/run_corpus.py +122 -0
- migrate_guard-0.1.0/src/migrate_guard/cli/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/cli/app.py +118 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/entities/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/entities/case.py +40 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/entities/case_result.py +59 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/entities/difference.py +42 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/entities/state.py +27 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/ports/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/ports/state.py +23 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/ports/target_runner.py +21 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/services/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/services/case_compare.py +23 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/services/redaction.py +94 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/services/response_diff.py +270 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/services/state_diff.py +27 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/services/state_values.py +60 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/value_objects/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/value_objects/json_pointer.py +35 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/value_objects/json_value.py +24 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/value_objects/rules.py +74 -0
- migrate_guard-0.1.0/src/migrate_guard/domain/value_objects/state_rules.py +39 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/reporters/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/reporters/json_reporter.py +63 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/reporters/junit_reporter.py +60 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/reporters/plain_text.py +78 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/reporters/terminal_reporter.py +87 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/state/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/state/sqlalchemy_state.py +167 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/targets/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/targets/http_target.py +114 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/config/__init__.py +0 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/config/loader.py +254 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/config/state_config.py +195 -0
- migrate_guard-0.1.0/src/migrate_guard/infrastructure/wiring.py +54 -0
- migrate_guard-0.1.0/src/migrate_guard/py.typed +0 -0
- migrate_guard-0.1.0/src/migrate_guard/pytest_plugin.py +136 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Marcos Lozina
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: migrate-guard
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Catch behavior changes when you rewrite a legacy service: in the response AND in the database.
|
|
5
|
+
Keywords: migration,parity,differential-testing,legacy,ai
|
|
6
|
+
Author: Marcos Lozina
|
|
7
|
+
Author-email: Marcos Lozina <marcoslozina@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Topic :: Software Development :: Testing
|
|
15
|
+
Requires-Dist: httpx>=0.28.1
|
|
16
|
+
Requires-Dist: pydantic>=2.14.2
|
|
17
|
+
Requires-Dist: pyyaml>=6.0.3
|
|
18
|
+
Requires-Dist: rich>=15.0.0
|
|
19
|
+
Requires-Dist: sqlalchemy>=2.1.8
|
|
20
|
+
Requires-Dist: typer>=0.28.0
|
|
21
|
+
Requires-Dist: psycopg[binary]>=3.3.8 ; extra == 'postgres'
|
|
22
|
+
Requires-Python: >=3.12
|
|
23
|
+
Project-URL: Homepage, https://github.com/marcoslozina/migrate-guard
|
|
24
|
+
Project-URL: Repository, https://github.com/marcoslozina/migrate-guard
|
|
25
|
+
Project-URL: Issues, https://github.com/marcoslozina/migrate-guard/issues
|
|
26
|
+
Project-URL: Changelog, https://github.com/marcoslozina/migrate-guard/releases
|
|
27
|
+
Provides-Extra: postgres
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# migrate-guard
|
|
31
|
+
|
|
32
|
+
> **Catch behavior changes when you rewrite a legacy service: in the response AND in the database.**
|
|
33
|
+
|
|
34
|
+
Behavioral parity testing for service migrations. Not a schema migration linter.
|
|
35
|
+
|
|
36
|
+
**Status:** pre-alpha, built in public. Python 3.12+.
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install migrate-guard # or: uv add --dev migrate-guard
|
|
40
|
+
pip install "migrate-guard[postgres]" # to compare Postgres databases
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
## The problem
|
|
44
|
+
|
|
45
|
+
Teams are rewriting legacy services with AI assistants: C# to Python, Flask to FastAPI, SQL Server
|
|
46
|
+
to Postgres. The new code compiles, the unit tests pass, and the behavior still changes in the
|
|
47
|
+
edge cases nobody wrote a test for:
|
|
48
|
+
|
|
49
|
+
- rounding (`2.675` becomes `2.67` instead of `2.68`),
|
|
50
|
+
- nulls (a promotion with no end date is suddenly expired),
|
|
51
|
+
- time zones, list ordering, discounts applied in a different order.
|
|
52
|
+
|
|
53
|
+
Worse, two services can return the **same HTTP response** and still write **different rows** to
|
|
54
|
+
the database.
|
|
55
|
+
|
|
56
|
+
## The idea
|
|
57
|
+
|
|
58
|
+
Replay the same requests against the **baseline** (legacy) and the **candidate** (rewrite), then
|
|
59
|
+
compare two things:
|
|
60
|
+
|
|
61
|
+
1. **The response:** status, selected headers and JSON body, with tolerance rules for the
|
|
62
|
+
differences you expect (ignore a field, numeric precision, unordered arrays, time zones).
|
|
63
|
+
2. **The database state** each one leaves behind, row by row, even across different schemas and
|
|
64
|
+
engines.
|
|
65
|
+
|
|
66
|
+
Every difference is reported with its exact location, so you can decide: expected, or a bug.
|
|
67
|
+
|
|
68
|
+
## How it compares
|
|
69
|
+
|
|
70
|
+
- **Response diffing tools** (Twitter Diffy, `apiparity-check`) compare HTTP responses only.
|
|
71
|
+
- **Schema migration linters** (squawk and similar) check DDL, not behavior.
|
|
72
|
+
- **Record and replay tools** generate tests; they don't compare two implementations.
|
|
73
|
+
|
|
74
|
+
migrate-guard compares the response **and** the resulting database state, with tolerance rules
|
|
75
|
+
built for migrations.
|
|
76
|
+
|
|
77
|
+
## See it work
|
|
78
|
+
|
|
79
|
+
The demo replays 6 requests against a legacy Flask pricing service and its FastAPI rewrite, with
|
|
80
|
+
five planted bugs. It catches all five, including one that only shows up in the database:
|
|
81
|
+
|
|
82
|
+
```text
|
|
83
|
+
✗ price lookup that only reads
|
|
84
|
+
state /prices/sku=B2/last_priced_at null → "2026-11-09T15:29:02.885023+00:00" type_changed
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
Same response on both sides; the rewrite also writes a row on every read. Run it with
|
|
88
|
+
`uv sync --group demo && examples/retail-pricing/run-demo.sh`. Details in
|
|
89
|
+
[`examples/retail-pricing`](https://github.com/marcoslozina/migrate-guard/blob/main/examples/retail-pricing/README.md).
|
|
90
|
+
|
|
91
|
+
## What works today
|
|
92
|
+
|
|
93
|
+
- `migrate-guard run --config migrate-guard.yaml`, with exit codes `0` (all match), `1`
|
|
94
|
+
(differences or case errors) and `2` (refused: invalid config, unreachable target, safety).
|
|
95
|
+
- Response comparison: status code and JSON body, with tolerance rules.
|
|
96
|
+
- Database state comparison (SQLite and Postgres): both databases are reseeded before every case,
|
|
97
|
+
each watched table is read row by row after the request, and the rows are compared by key. Each
|
|
98
|
+
side can use its own table and column names.
|
|
99
|
+
- Reports for tools: `--report json` (a versioned schema, numbers written exactly) and
|
|
100
|
+
`--report junit` (one testcase per case). Alone they go to stdout; with `-o FILE` the terminal
|
|
101
|
+
summary stays on screen and the file is written whole or not at all.
|
|
102
|
+
- A pytest plugin: `pytest --migrate-guard=migrate-guard.yaml` turns each case into a test named
|
|
103
|
+
`migrate_guard[<case name>]`, so `-k`, `--junitxml` and your usual CI setup just work.
|
|
104
|
+
|
|
105
|
+
### In CI
|
|
106
|
+
|
|
107
|
+
```bash
|
|
108
|
+
migrate-guard run --config migrate-guard.yaml --report junit -o reports/migrate-guard.xml
|
|
109
|
+
# or, inside an existing pytest run:
|
|
110
|
+
pytest --migrate-guard=migrate-guard.yaml
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
### The diff engine
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
The core diff engine: compare two JSON bodies and get every difference with its exact location
|
|
117
|
+
(a [JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901)), after applying the tolerance rules
|
|
118
|
+
you expect.
|
|
119
|
+
|
|
120
|
+
```python
|
|
121
|
+
import json
|
|
122
|
+
from decimal import Decimal
|
|
123
|
+
|
|
124
|
+
from migrate_guard.domain.services.response_diff import diff_responses
|
|
125
|
+
from migrate_guard.domain.value_objects.rules import (
|
|
126
|
+
DatetimeRule,
|
|
127
|
+
IgnoreRule,
|
|
128
|
+
NumericRule,
|
|
129
|
+
UnorderedRule,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# Parse numbers as Decimal: the comparison must not add its own rounding errors.
|
|
133
|
+
baseline = json.loads(
|
|
134
|
+
"""{"request_id": "a1",
|
|
135
|
+
"items": [{"sku": "A1", "price": 2.675}, {"sku": "B2", "price": 10.005}],
|
|
136
|
+
"promo": {"ends_at": "2026-11-30T23:59:00-03:00"}}""",
|
|
137
|
+
parse_float=Decimal,
|
|
138
|
+
)
|
|
139
|
+
candidate = json.loads(
|
|
140
|
+
"""{"request_id": "b2",
|
|
141
|
+
"items": [{"sku": "B2", "price": 10.01}, {"sku": "A1", "price": 2.67}],
|
|
142
|
+
"promo": {"ends_at": "2026-12-01T02:59:00"}}""",
|
|
143
|
+
parse_float=Decimal,
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
rules = (
|
|
147
|
+
IgnoreRule(path="/request_id"),
|
|
148
|
+
UnorderedRule(path="/items", key="sku"),
|
|
149
|
+
NumericRule(path="/items/*/price", decimals=2),
|
|
150
|
+
DatetimeRule(path="/promo/ends_at"),
|
|
151
|
+
)
|
|
152
|
+
for d in diff_responses(baseline, candidate, rules).differences:
|
|
153
|
+
print(f"{d.kind:<20} {d.location} {d.detail}")
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
```text
|
|
157
|
+
value_changed /items/0/price rounded to 2 decimals (half_up): 2.68 vs 2.67
|
|
158
|
+
rule_not_applicable /promo/ends_at candidate datetime has no timezone: '2026-12-01T02:59:00'
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
The different `request_id`, the reordered items and `10.005` vs `10.01` are all tolerated. Two
|
|
162
|
+
things are not: `2.675` rounded the wrong way, and a timestamp that lost its time zone.
|
|
163
|
+
|
|
164
|
+
The rules:
|
|
165
|
+
|
|
166
|
+
- `ignore`: skip a path (and everything under it).
|
|
167
|
+
- `numeric`: round both sides to N decimals (`half_up`, `half_even` or `down`) before comparing.
|
|
168
|
+
- `unordered`: compare an array ignoring order, pairing items by a key. A duplicated or missing
|
|
169
|
+
key is reported, never matched silently.
|
|
170
|
+
- `datetime`: compare ISO 8601 strings as instants, so the same moment in another offset matches.
|
|
171
|
+
A datetime without a time zone is reported, never assumed to be UTC.
|
|
172
|
+
|
|
173
|
+
Paths accept `*` (`/items/*/price`). A rule that never matches anything is returned as unused, so
|
|
174
|
+
stale rules are visible.
|
|
175
|
+
|
|
176
|
+
A field that is `null` and a field that doesn't exist are different things, so they are reported
|
|
177
|
+
differently. The kinds of difference are `value_changed`, `type_changed`, `missing_in_candidate`,
|
|
178
|
+
`added_in_candidate`, `duplicate_key` and `rule_not_applicable`.
|
|
179
|
+
|
|
180
|
+
## Roadmap
|
|
181
|
+
|
|
182
|
+
- [x] **M0:** project skeleton, response diff with `ignore` rules.
|
|
183
|
+
- [x] **M1:** numeric, unordered and datetime rules; HTTP target; `migrate-guard run` CLI; the
|
|
184
|
+
retail-pricing demo.
|
|
185
|
+
- [x] **M2:** database state diff (SQLAlchemy: Postgres and SQLite), seeding and isolation.
|
|
186
|
+
SQL Server comes later.
|
|
187
|
+
- [x] **M3:** pytest plugin, JSON and JUnit reports, CI, and the first PyPI release (Trusted
|
|
188
|
+
Publishing).
|
|
189
|
+
- [ ] **M4:** opt-in AI triage of differences (expected vs likely bug).
|
|
190
|
+
|
|
191
|
+
The full specification is in [`docs/SPEC.md`](https://github.com/marcoslozina/migrate-guard/blob/main/docs/SPEC.md).
|
|
192
|
+
|
|
193
|
+
## Safety by default
|
|
194
|
+
|
|
195
|
+
- Loopback targets only, unless you pass `--allow-remote`.
|
|
196
|
+
- Mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) are opt-in.
|
|
197
|
+
- Secrets come from environment variables, never from config files. Database errors are reported
|
|
198
|
+
with the password removed.
|
|
199
|
+
- The state probe is read-only, enforced by the engine itself, and baseline and candidate can
|
|
200
|
+
never point to the same database.
|
|
201
|
+
- Sensitive headers and fields are redacted in every report: terminal, JSON, JUnit and pytest.
|
|
202
|
+
- Report files are created readable only by you, since they can hold test data.
|
|
203
|
+
- AI triage never runs unless you ask for it, and never turns a difference into a match.
|
|
204
|
+
|
|
205
|
+
## Development
|
|
206
|
+
|
|
207
|
+
```bash
|
|
208
|
+
uv sync
|
|
209
|
+
uv run pytest # the Postgres integration tests need Docker; they skip without it
|
|
210
|
+
uv run ruff check .
|
|
211
|
+
uv run mypy
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
CI runs ruff, mypy, the tests on Python 3.12 and 3.13 (Postgres included), the demo, and
|
|
215
|
+
`pip-audit` against the lockfile. The GitHub Actions are pinned by commit SHA.
|
|
216
|
+
|
|
217
|
+
## License
|
|
218
|
+
|
|
219
|
+
MIT. See [LICENSE](https://github.com/marcoslozina/migrate-guard/blob/main/LICENSE).
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
# migrate-guard
|
|
2
|
+
|
|
3
|
+
> **Catch behavior changes when you rewrite a legacy service: in the response AND in the database.**
|
|
4
|
+
|
|
5
|
+
Behavioral parity testing for service migrations. Not a schema migration linter.
|
|
6
|
+
|
|
7
|
+
**Status:** pre-alpha, built in public. Python 3.12+.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install migrate-guard # or: uv add --dev migrate-guard
|
|
11
|
+
pip install "migrate-guard[postgres]" # to compare Postgres databases
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
## The problem
|
|
15
|
+
|
|
16
|
+
Teams are rewriting legacy services with AI assistants: C# to Python, Flask to FastAPI, SQL Server
|
|
17
|
+
to Postgres. The new code compiles, the unit tests pass, and the behavior still changes in the
|
|
18
|
+
edge cases nobody wrote a test for:
|
|
19
|
+
|
|
20
|
+
- rounding (`2.675` becomes `2.67` instead of `2.68`),
|
|
21
|
+
- nulls (a promotion with no end date is suddenly expired),
|
|
22
|
+
- time zones, list ordering, discounts applied in a different order.
|
|
23
|
+
|
|
24
|
+
Worse, two services can return the **same HTTP response** and still write **different rows** to
|
|
25
|
+
the database.
|
|
26
|
+
|
|
27
|
+
## The idea
|
|
28
|
+
|
|
29
|
+
Replay the same requests against the **baseline** (legacy) and the **candidate** (rewrite), then
|
|
30
|
+
compare two things:
|
|
31
|
+
|
|
32
|
+
1. **The response:** status, selected headers and JSON body, with tolerance rules for the
|
|
33
|
+
differences you expect (ignore a field, numeric precision, unordered arrays, time zones).
|
|
34
|
+
2. **The database state** each one leaves behind, row by row, even across different schemas and
|
|
35
|
+
engines.
|
|
36
|
+
|
|
37
|
+
Every difference is reported with its exact location, so you can decide: expected, or a bug.
|
|
38
|
+
|
|
39
|
+
## How it compares
|
|
40
|
+
|
|
41
|
+
- **Response diffing tools** (Twitter Diffy, `apiparity-check`) compare HTTP responses only.
|
|
42
|
+
- **Schema migration linters** (squawk and similar) check DDL, not behavior.
|
|
43
|
+
- **Record and replay tools** generate tests; they don't compare two implementations.
|
|
44
|
+
|
|
45
|
+
migrate-guard compares the response **and** the resulting database state, with tolerance rules
|
|
46
|
+
built for migrations.
|
|
47
|
+
|
|
48
|
+
## See it work
|
|
49
|
+
|
|
50
|
+
The demo replays 6 requests against a legacy Flask pricing service and its FastAPI rewrite, with
|
|
51
|
+
five planted bugs. It catches all five, including one that only shows up in the database:
|
|
52
|
+
|
|
53
|
+
```text
|
|
54
|
+
✗ price lookup that only reads
|
|
55
|
+
state /prices/sku=B2/last_priced_at null → "2026-11-09T15:29:02.885023+00:00" type_changed
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Same response on both sides; the rewrite also writes a row on every read. Run it with
|
|
59
|
+
`uv sync --group demo && examples/retail-pricing/run-demo.sh`. Details in
|
|
60
|
+
[`examples/retail-pricing`](https://github.com/marcoslozina/migrate-guard/blob/main/examples/retail-pricing/README.md).
|
|
61
|
+
|
|
62
|
+
## What works today
|
|
63
|
+
|
|
64
|
+
- `migrate-guard run --config migrate-guard.yaml`, with exit codes `0` (all match), `1`
|
|
65
|
+
(differences or case errors) and `2` (refused: invalid config, unreachable target, safety).
|
|
66
|
+
- Response comparison: status code and JSON body, with tolerance rules.
|
|
67
|
+
- Database state comparison (SQLite and Postgres): both databases are reseeded before every case,
|
|
68
|
+
each watched table is read row by row after the request, and the rows are compared by key. Each
|
|
69
|
+
side can use its own table and column names.
|
|
70
|
+
- Reports for tools: `--report json` (a versioned schema, numbers written exactly) and
|
|
71
|
+
`--report junit` (one testcase per case). Alone they go to stdout; with `-o FILE` the terminal
|
|
72
|
+
summary stays on screen and the file is written whole or not at all.
|
|
73
|
+
- A pytest plugin: `pytest --migrate-guard=migrate-guard.yaml` turns each case into a test named
|
|
74
|
+
`migrate_guard[<case name>]`, so `-k`, `--junitxml` and your usual CI setup just work.
|
|
75
|
+
|
|
76
|
+
### In CI
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
migrate-guard run --config migrate-guard.yaml --report junit -o reports/migrate-guard.xml
|
|
80
|
+
# or, inside an existing pytest run:
|
|
81
|
+
pytest --migrate-guard=migrate-guard.yaml
|
|
82
|
+
```
|
|
83
|
+
|
|
84
|
+
### The diff engine
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
The core diff engine: compare two JSON bodies and get every difference with its exact location
|
|
88
|
+
(a [JSON Pointer](https://www.rfc-editor.org/rfc/rfc6901)), after applying the tolerance rules
|
|
89
|
+
you expect.
|
|
90
|
+
|
|
91
|
+
```python
|
|
92
|
+
import json
|
|
93
|
+
from decimal import Decimal
|
|
94
|
+
|
|
95
|
+
from migrate_guard.domain.services.response_diff import diff_responses
|
|
96
|
+
from migrate_guard.domain.value_objects.rules import (
|
|
97
|
+
DatetimeRule,
|
|
98
|
+
IgnoreRule,
|
|
99
|
+
NumericRule,
|
|
100
|
+
UnorderedRule,
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
# Parse numbers as Decimal: the comparison must not add its own rounding errors.
|
|
104
|
+
baseline = json.loads(
|
|
105
|
+
"""{"request_id": "a1",
|
|
106
|
+
"items": [{"sku": "A1", "price": 2.675}, {"sku": "B2", "price": 10.005}],
|
|
107
|
+
"promo": {"ends_at": "2026-11-30T23:59:00-03:00"}}""",
|
|
108
|
+
parse_float=Decimal,
|
|
109
|
+
)
|
|
110
|
+
candidate = json.loads(
|
|
111
|
+
"""{"request_id": "b2",
|
|
112
|
+
"items": [{"sku": "B2", "price": 10.01}, {"sku": "A1", "price": 2.67}],
|
|
113
|
+
"promo": {"ends_at": "2026-12-01T02:59:00"}}""",
|
|
114
|
+
parse_float=Decimal,
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
rules = (
|
|
118
|
+
IgnoreRule(path="/request_id"),
|
|
119
|
+
UnorderedRule(path="/items", key="sku"),
|
|
120
|
+
NumericRule(path="/items/*/price", decimals=2),
|
|
121
|
+
DatetimeRule(path="/promo/ends_at"),
|
|
122
|
+
)
|
|
123
|
+
for d in diff_responses(baseline, candidate, rules).differences:
|
|
124
|
+
print(f"{d.kind:<20} {d.location} {d.detail}")
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
```text
|
|
128
|
+
value_changed /items/0/price rounded to 2 decimals (half_up): 2.68 vs 2.67
|
|
129
|
+
rule_not_applicable /promo/ends_at candidate datetime has no timezone: '2026-12-01T02:59:00'
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
The different `request_id`, the reordered items and `10.005` vs `10.01` are all tolerated. Two
|
|
133
|
+
things are not: `2.675` rounded the wrong way, and a timestamp that lost its time zone.
|
|
134
|
+
|
|
135
|
+
The rules:
|
|
136
|
+
|
|
137
|
+
- `ignore`: skip a path (and everything under it).
|
|
138
|
+
- `numeric`: round both sides to N decimals (`half_up`, `half_even` or `down`) before comparing.
|
|
139
|
+
- `unordered`: compare an array ignoring order, pairing items by a key. A duplicated or missing
|
|
140
|
+
key is reported, never matched silently.
|
|
141
|
+
- `datetime`: compare ISO 8601 strings as instants, so the same moment in another offset matches.
|
|
142
|
+
A datetime without a time zone is reported, never assumed to be UTC.
|
|
143
|
+
|
|
144
|
+
Paths accept `*` (`/items/*/price`). A rule that never matches anything is returned as unused, so
|
|
145
|
+
stale rules are visible.
|
|
146
|
+
|
|
147
|
+
A field that is `null` and a field that doesn't exist are different things, so they are reported
|
|
148
|
+
differently. The kinds of difference are `value_changed`, `type_changed`, `missing_in_candidate`,
|
|
149
|
+
`added_in_candidate`, `duplicate_key` and `rule_not_applicable`.
|
|
150
|
+
|
|
151
|
+
## Roadmap
|
|
152
|
+
|
|
153
|
+
- [x] **M0:** project skeleton, response diff with `ignore` rules.
|
|
154
|
+
- [x] **M1:** numeric, unordered and datetime rules; HTTP target; `migrate-guard run` CLI; the
|
|
155
|
+
retail-pricing demo.
|
|
156
|
+
- [x] **M2:** database state diff (SQLAlchemy: Postgres and SQLite), seeding and isolation.
|
|
157
|
+
SQL Server comes later.
|
|
158
|
+
- [x] **M3:** pytest plugin, JSON and JUnit reports, CI, and the first PyPI release (Trusted
|
|
159
|
+
Publishing).
|
|
160
|
+
- [ ] **M4:** opt-in AI triage of differences (expected vs likely bug).
|
|
161
|
+
|
|
162
|
+
The full specification is in [`docs/SPEC.md`](https://github.com/marcoslozina/migrate-guard/blob/main/docs/SPEC.md).
|
|
163
|
+
|
|
164
|
+
## Safety by default
|
|
165
|
+
|
|
166
|
+
- Loopback targets only, unless you pass `--allow-remote`.
|
|
167
|
+
- Mutating requests (`POST`, `PUT`, `PATCH`, `DELETE`) are opt-in.
|
|
168
|
+
- Secrets come from environment variables, never from config files. Database errors are reported
|
|
169
|
+
with the password removed.
|
|
170
|
+
- The state probe is read-only, enforced by the engine itself, and baseline and candidate can
|
|
171
|
+
never point to the same database.
|
|
172
|
+
- Sensitive headers and fields are redacted in every report: terminal, JSON, JUnit and pytest.
|
|
173
|
+
- Report files are created readable only by you, since they can hold test data.
|
|
174
|
+
- AI triage never runs unless you ask for it, and never turns a difference into a match.
|
|
175
|
+
|
|
176
|
+
## Development
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
uv sync
|
|
180
|
+
uv run pytest # the Postgres integration tests need Docker; they skip without it
|
|
181
|
+
uv run ruff check .
|
|
182
|
+
uv run mypy
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
CI runs ruff, mypy, the tests on Python 3.12 and 3.13 (Postgres included), the demo, and
|
|
186
|
+
`pip-audit` against the lockfile. The GitHub Actions are pinned by commit SHA.
|
|
187
|
+
|
|
188
|
+
## License
|
|
189
|
+
|
|
190
|
+
MIT. See [LICENSE](https://github.com/marcoslozina/migrate-guard/blob/main/LICENSE).
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "migrate-guard"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Catch behavior changes when you rewrite a legacy service: in the response AND in the database."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.12"
|
|
9
|
+
keywords = [
|
|
10
|
+
"migration",
|
|
11
|
+
"parity",
|
|
12
|
+
"differential-testing",
|
|
13
|
+
"legacy",
|
|
14
|
+
"ai",
|
|
15
|
+
]
|
|
16
|
+
classifiers = [
|
|
17
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
18
|
+
"Framework :: Pytest",
|
|
19
|
+
"Intended Audience :: Developers",
|
|
20
|
+
"Programming Language :: Python :: 3.12",
|
|
21
|
+
"Topic :: Software Development :: Testing",
|
|
22
|
+
]
|
|
23
|
+
dependencies = [
|
|
24
|
+
"httpx>=0.28.1",
|
|
25
|
+
"pydantic>=2.14.2",
|
|
26
|
+
"pyyaml>=6.0.3",
|
|
27
|
+
"rich>=15.0.0",
|
|
28
|
+
"sqlalchemy>=2.1.8",
|
|
29
|
+
"typer>=0.28.0",
|
|
30
|
+
]
|
|
31
|
+
|
|
32
|
+
[[project.authors]]
|
|
33
|
+
name = "Marcos Lozina"
|
|
34
|
+
email = "marcoslozina@gmail.com"
|
|
35
|
+
|
|
36
|
+
[project.urls]
|
|
37
|
+
Homepage = "https://github.com/marcoslozina/migrate-guard"
|
|
38
|
+
Repository = "https://github.com/marcoslozina/migrate-guard"
|
|
39
|
+
Issues = "https://github.com/marcoslozina/migrate-guard/issues"
|
|
40
|
+
Changelog = "https://github.com/marcoslozina/migrate-guard/releases"
|
|
41
|
+
|
|
42
|
+
[project.scripts]
|
|
43
|
+
migrate-guard = "migrate_guard.cli.app:main"
|
|
44
|
+
|
|
45
|
+
[project.entry-points.pytest11]
|
|
46
|
+
migrate_guard = "migrate_guard.pytest_plugin"
|
|
47
|
+
|
|
48
|
+
[project.optional-dependencies]
|
|
49
|
+
postgres = ["psycopg[binary]>=3.3.8"]
|
|
50
|
+
|
|
51
|
+
[build-system]
|
|
52
|
+
requires = ["uv_build>=0.11.7,<0.12.0"]
|
|
53
|
+
build-backend = "uv_build"
|
|
54
|
+
|
|
55
|
+
[dependency-groups]
|
|
56
|
+
demo = [
|
|
57
|
+
"fastapi>=0.162.2",
|
|
58
|
+
"flask>=3.1.3",
|
|
59
|
+
"uvicorn>=0.54.0",
|
|
60
|
+
]
|
|
61
|
+
dev = [
|
|
62
|
+
"mypy>=2.4.1",
|
|
63
|
+
"psycopg[binary]>=3.3.8",
|
|
64
|
+
"pytest>=9.2.0",
|
|
65
|
+
"ruff>=0.17.3",
|
|
66
|
+
"testcontainers[postgres]>=4.15.0",
|
|
67
|
+
"types-pyyaml>=6.0.12.20261103",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
[tool.ruff]
|
|
71
|
+
line-length = 100
|
|
72
|
+
target-version = "py312"
|
|
73
|
+
|
|
74
|
+
[tool.ruff.lint]
|
|
75
|
+
select = [
|
|
76
|
+
"E",
|
|
77
|
+
"F",
|
|
78
|
+
"W",
|
|
79
|
+
"I",
|
|
80
|
+
"B",
|
|
81
|
+
"UP",
|
|
82
|
+
"SIM",
|
|
83
|
+
"RUF",
|
|
84
|
+
"S",
|
|
85
|
+
]
|
|
86
|
+
|
|
87
|
+
[tool.ruff.lint.per-file-ignores]
|
|
88
|
+
"tests/**" = [
|
|
89
|
+
"S101",
|
|
90
|
+
"S106",
|
|
91
|
+
"S314",
|
|
92
|
+
]
|
|
93
|
+
|
|
94
|
+
[tool.mypy]
|
|
95
|
+
strict = true
|
|
96
|
+
python_version = "3.12"
|
|
97
|
+
packages = ["migrate_guard"]
|
|
98
|
+
mypy_path = "src"
|
|
99
|
+
|
|
100
|
+
[tool.pytest.ini_options]
|
|
101
|
+
testpaths = ["tests"]
|
|
102
|
+
addopts = "-ra --strict-markers"
|
|
103
|
+
markers = ["integration: needs a real database (SQLite file or a Postgres container)"]
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "migrate-guard"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Catch behavior changes when you rewrite a legacy service: in the response AND in the database."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
authors = [
|
|
9
|
+
{ name = "Marcos Lozina", email = "marcoslozina@gmail.com" }
|
|
10
|
+
]
|
|
11
|
+
requires-python = ">=3.12"
|
|
12
|
+
keywords = ["migration", "parity", "differential-testing", "legacy", "ai"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 2 - Pre-Alpha",
|
|
15
|
+
"Framework :: Pytest",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Topic :: Software Development :: Testing",
|
|
19
|
+
]
|
|
20
|
+
dependencies = [
|
|
21
|
+
"httpx>=0.28.1",
|
|
22
|
+
"pydantic>=2.14.2",
|
|
23
|
+
"pyyaml>=6.0.3",
|
|
24
|
+
"rich>=15.0.0",
|
|
25
|
+
"sqlalchemy>=2.1.8",
|
|
26
|
+
"typer>=0.28.0",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[project.urls]
|
|
30
|
+
Homepage = "https://github.com/marcoslozina/migrate-guard"
|
|
31
|
+
Repository = "https://github.com/marcoslozina/migrate-guard"
|
|
32
|
+
Issues = "https://github.com/marcoslozina/migrate-guard/issues"
|
|
33
|
+
Changelog = "https://github.com/marcoslozina/migrate-guard/releases"
|
|
34
|
+
|
|
35
|
+
[project.scripts]
|
|
36
|
+
migrate-guard = "migrate_guard.cli.app:main"
|
|
37
|
+
|
|
38
|
+
[project.entry-points.pytest11]
|
|
39
|
+
migrate_guard = "migrate_guard.pytest_plugin"
|
|
40
|
+
|
|
41
|
+
[project.optional-dependencies]
|
|
42
|
+
postgres = [
|
|
43
|
+
"psycopg[binary]>=3.3.8",
|
|
44
|
+
]
|
|
45
|
+
|
|
46
|
+
[build-system]
|
|
47
|
+
requires = ["uv_build>=0.11.7,<0.12.0"]
|
|
48
|
+
build-backend = "uv_build"
|
|
49
|
+
|
|
50
|
+
[dependency-groups]
|
|
51
|
+
demo = [
|
|
52
|
+
"fastapi>=0.162.2",
|
|
53
|
+
"flask>=3.1.3",
|
|
54
|
+
"uvicorn>=0.54.0",
|
|
55
|
+
]
|
|
56
|
+
dev = [
|
|
57
|
+
"mypy>=2.4.1",
|
|
58
|
+
"psycopg[binary]>=3.3.8",
|
|
59
|
+
"pytest>=9.2.0",
|
|
60
|
+
"ruff>=0.17.3",
|
|
61
|
+
"testcontainers[postgres]>=4.15.0",
|
|
62
|
+
"types-pyyaml>=6.0.12.20261103",
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
[tool.ruff]
|
|
66
|
+
line-length = 100
|
|
67
|
+
target-version = "py312"
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["E", "F", "W", "I", "B", "UP", "SIM", "RUF", "S"]
|
|
71
|
+
|
|
72
|
+
[tool.ruff.lint.per-file-ignores]
|
|
73
|
+
"tests/**" = ["S101", "S106", "S314"] # asserts; fake passwords; parsing our own XML
|
|
74
|
+
|
|
75
|
+
[tool.mypy]
|
|
76
|
+
strict = true
|
|
77
|
+
python_version = "3.12"
|
|
78
|
+
packages = ["migrate_guard"]
|
|
79
|
+
mypy_path = "src"
|
|
80
|
+
|
|
81
|
+
[tool.pytest.ini_options]
|
|
82
|
+
testpaths = ["tests"]
|
|
83
|
+
addopts = "-ra --strict-markers"
|
|
84
|
+
markers = ["integration: needs a real database (SQLite file or a Postgres container)"]
|
|
File without changes
|
|
File without changes
|