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.
Files changed (48) hide show
  1. migrate_guard-0.1.0/LICENSE +21 -0
  2. migrate_guard-0.1.0/PKG-INFO +219 -0
  3. migrate_guard-0.1.0/README.md +190 -0
  4. migrate_guard-0.1.0/pyproject.toml +103 -0
  5. migrate_guard-0.1.0/pyproject.toml.orig +84 -0
  6. migrate_guard-0.1.0/src/migrate_guard/__init__.py +3 -0
  7. migrate_guard-0.1.0/src/migrate_guard/application/__init__.py +0 -0
  8. migrate_guard-0.1.0/src/migrate_guard/application/use_cases/__init__.py +0 -0
  9. migrate_guard-0.1.0/src/migrate_guard/application/use_cases/run_corpus.py +122 -0
  10. migrate_guard-0.1.0/src/migrate_guard/cli/__init__.py +0 -0
  11. migrate_guard-0.1.0/src/migrate_guard/cli/app.py +118 -0
  12. migrate_guard-0.1.0/src/migrate_guard/domain/__init__.py +0 -0
  13. migrate_guard-0.1.0/src/migrate_guard/domain/entities/__init__.py +0 -0
  14. migrate_guard-0.1.0/src/migrate_guard/domain/entities/case.py +40 -0
  15. migrate_guard-0.1.0/src/migrate_guard/domain/entities/case_result.py +59 -0
  16. migrate_guard-0.1.0/src/migrate_guard/domain/entities/difference.py +42 -0
  17. migrate_guard-0.1.0/src/migrate_guard/domain/entities/state.py +27 -0
  18. migrate_guard-0.1.0/src/migrate_guard/domain/ports/__init__.py +0 -0
  19. migrate_guard-0.1.0/src/migrate_guard/domain/ports/state.py +23 -0
  20. migrate_guard-0.1.0/src/migrate_guard/domain/ports/target_runner.py +21 -0
  21. migrate_guard-0.1.0/src/migrate_guard/domain/services/__init__.py +0 -0
  22. migrate_guard-0.1.0/src/migrate_guard/domain/services/case_compare.py +23 -0
  23. migrate_guard-0.1.0/src/migrate_guard/domain/services/redaction.py +94 -0
  24. migrate_guard-0.1.0/src/migrate_guard/domain/services/response_diff.py +270 -0
  25. migrate_guard-0.1.0/src/migrate_guard/domain/services/state_diff.py +27 -0
  26. migrate_guard-0.1.0/src/migrate_guard/domain/services/state_values.py +60 -0
  27. migrate_guard-0.1.0/src/migrate_guard/domain/value_objects/__init__.py +0 -0
  28. migrate_guard-0.1.0/src/migrate_guard/domain/value_objects/json_pointer.py +35 -0
  29. migrate_guard-0.1.0/src/migrate_guard/domain/value_objects/json_value.py +24 -0
  30. migrate_guard-0.1.0/src/migrate_guard/domain/value_objects/rules.py +74 -0
  31. migrate_guard-0.1.0/src/migrate_guard/domain/value_objects/state_rules.py +39 -0
  32. migrate_guard-0.1.0/src/migrate_guard/infrastructure/__init__.py +0 -0
  33. migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/__init__.py +0 -0
  34. migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/reporters/__init__.py +0 -0
  35. migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/reporters/json_reporter.py +63 -0
  36. migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/reporters/junit_reporter.py +60 -0
  37. migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/reporters/plain_text.py +78 -0
  38. migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/reporters/terminal_reporter.py +87 -0
  39. migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/state/__init__.py +0 -0
  40. migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/state/sqlalchemy_state.py +167 -0
  41. migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/targets/__init__.py +0 -0
  42. migrate_guard-0.1.0/src/migrate_guard/infrastructure/adapters/targets/http_target.py +114 -0
  43. migrate_guard-0.1.0/src/migrate_guard/infrastructure/config/__init__.py +0 -0
  44. migrate_guard-0.1.0/src/migrate_guard/infrastructure/config/loader.py +254 -0
  45. migrate_guard-0.1.0/src/migrate_guard/infrastructure/config/state_config.py +195 -0
  46. migrate_guard-0.1.0/src/migrate_guard/infrastructure/wiring.py +54 -0
  47. migrate_guard-0.1.0/src/migrate_guard/py.typed +0 -0
  48. 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)"]
@@ -0,0 +1,3 @@
1
+ """Catch behavior changes when you rewrite a legacy service."""
2
+
3
+ __version__ = "0.1.0"