migraeda 0.1.0__py3-none-any.whl
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.
- migraeda/__init__.py +3 -0
- migraeda/__main__.py +4 -0
- migraeda/adascii.py +274 -0
- migraeda/audit.py +127 -0
- migraeda/cli.py +495 -0
- migraeda/convert.py +539 -0
- migraeda/diff.py +138 -0
- migraeda/epcb.py +352 -0
- migraeda/eprj_lib.py +107 -0
- migraeda/eprj_pcb_write.py +290 -0
- migraeda/eprj_sqlite.py +559 -0
- migraeda/eprj_write.py +622 -0
- migraeda/epro_parse.py +167 -0
- migraeda/fuzz.py +187 -0
- migraeda/ir.py +102 -0
- migraeda/libdiff.py +166 -0
- migraeda/libinspect.py +109 -0
- migraeda/libmodel.py +58 -0
- migraeda/lvs.py +161 -0
- migraeda/mcp_server.py +403 -0
- migraeda/mcp_tools.py +404 -0
- migraeda/migrate.py +158 -0
- migraeda/models.py +114 -0
- migraeda/netlist_model.py +37 -0
- migraeda/normalize.py +33 -0
- migraeda/pcb_panel.py +73 -0
- migraeda/pcbascii.py +293 -0
- migraeda/pcbdiff.py +320 -0
- migraeda/pcbdoc.py +440 -0
- migraeda/pcblib.py +91 -0
- migraeda/report.py +122 -0
- migraeda/report_common.py +93 -0
- migraeda/samples.py +99 -0
- migraeda/schdoc.py +395 -0
- migraeda/schgeom.py +265 -0
- migraeda/schlib.py +81 -0
- migraeda/selfcheck.py +109 -0
- migraeda/selfcheck_pcb.py +197 -0
- migraeda/verify_report.py +151 -0
- migraeda-0.1.0.dist-info/METADATA +397 -0
- migraeda-0.1.0.dist-info/RECORD +45 -0
- migraeda-0.1.0.dist-info/WHEEL +5 -0
- migraeda-0.1.0.dist-info/entry_points.txt +3 -0
- migraeda-0.1.0.dist-info/licenses/LICENSE +21 -0
- migraeda-0.1.0.dist-info/top_level.txt +1 -0
migraeda/__init__.py
ADDED
migraeda/__main__.py
ADDED
migraeda/adascii.py
ADDED
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Altium ASCII .SchDoc writer (and reader for closed-loop verification).
|
|
2
|
+
|
|
3
|
+
Format: first line `|HEADER=Protel for Windows - Schematic Capture Ascii
|
|
4
|
+
File Version 5.0|WEIGHT=N|...`, then one `|KEY=VALUE|...` record per line.
|
|
5
|
+
OWNERINDEX semantics follow KiCad's sch_io_altium: sequential record number,
|
|
6
|
+
0-based, counted AFTER the header line.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from migraeda.ir import SchematicIR, pin_abs
|
|
15
|
+
from migraeda.netlist_model import NetlistModel
|
|
16
|
+
from migraeda.schdoc import model_from_records
|
|
17
|
+
|
|
18
|
+
_FONT = "FONTID=1"
|
|
19
|
+
_COL_DES = "COLOR=8388608"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _q(s: str) -> str:
|
|
23
|
+
"""Sanitize a value for the pipe-delimited format."""
|
|
24
|
+
return s.replace("|", "/").replace("\n", " ").replace("\r", " ")
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _uid(n: int) -> str:
|
|
28
|
+
alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
|
29
|
+
out = []
|
|
30
|
+
x = n * 2654435761 % (36 ** 8)
|
|
31
|
+
for _ in range(8):
|
|
32
|
+
out.append(alpha[x % 36])
|
|
33
|
+
x //= 36
|
|
34
|
+
return "".join(out)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _pt(v: float) -> str:
|
|
38
|
+
i = round(v)
|
|
39
|
+
return str(int(i))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _records_out(ir: SchematicIR) -> list[str]:
|
|
43
|
+
recs: list[str] = []
|
|
44
|
+
|
|
45
|
+
def add(body: str) -> int:
|
|
46
|
+
recs.append("|" + body)
|
|
47
|
+
return len(recs) - 1 # sequential index
|
|
48
|
+
|
|
49
|
+
# sheet record at index 0
|
|
50
|
+
x0, y0, x1, y1 = ir.sheet_extent()
|
|
51
|
+
sheet_w = max(1150, int(math.ceil((x1 - x0 + 200) / 50.0)) * 50)
|
|
52
|
+
sheet_h = max(760, int(math.ceil((y1 - y0 + 200) / 50.0)) * 50)
|
|
53
|
+
add(
|
|
54
|
+
"RECORD=31|FONTIDCOUNT=1|SIZE1=10|FONTNAME1=Times New Roman"
|
|
55
|
+
"|USEMBCS=T|ISBOC=T|HOTSPOTGRIDON=T|HOTSPOTGRIDSIZE=4"
|
|
56
|
+
"|SYSTEMFONT=1|BORDERON=T|SHEETNUMBERSPACESIZE=4"
|
|
57
|
+
"|AREACOLOR=16317695|SNAPGRIDON=T|SNAPGRIDSIZE=5"
|
|
58
|
+
"|VISIBLEGRIDON=T|VISIBLEGRIDSIZE=10"
|
|
59
|
+
f"|CUSTOMX={sheet_w}|CUSTOMY={sheet_h}|DISPLAY_UNIT=4"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
# components
|
|
63
|
+
for comp in ir.components:
|
|
64
|
+
libref = _q(comp.device or comp.value or comp.ref)
|
|
65
|
+
cidx = add(
|
|
66
|
+
f"RECORD=1|LIBREFERENCE={libref}"
|
|
67
|
+
f"|COMPONENTDESCRIPTION={_q(comp.description)}"
|
|
68
|
+
"|PARTCOUNT=2|DISPLAYMODECOUNT=1"
|
|
69
|
+
f"|INDEXINSHEET={len(recs) - 1}|OWNERPARTID=-1"
|
|
70
|
+
f"|LOCATION.X={_pt(comp.x)}|LOCATION.Y={_pt(comp.y)}"
|
|
71
|
+
"|CURRENTPARTID=1|SOURCELIBRARYNAME=*|TARGETFILENAME=*"
|
|
72
|
+
f"|UNIQUEID={_uid(len(recs))}|AREACOLOR=11599871|COLOR=128"
|
|
73
|
+
f"|PARTIDLOCKED=F|DESIGNITEMID={libref}|ALLPINCOUNT={len(comp.pins)}"
|
|
74
|
+
)
|
|
75
|
+
# body rectangle around pin body ends
|
|
76
|
+
if comp.pins:
|
|
77
|
+
xs: list[float] = []
|
|
78
|
+
ys: list[float] = []
|
|
79
|
+
for p in comp.pins:
|
|
80
|
+
hx, hy = pin_abs(comp, p)
|
|
81
|
+
rad = math.radians(p.orient_into_body)
|
|
82
|
+
xs.append(hx + math.cos(rad) * p.length)
|
|
83
|
+
ys.append(hy + math.sin(rad) * p.length)
|
|
84
|
+
bx0, bx1 = min(xs) - 5, max(xs) + 5
|
|
85
|
+
by0, by1 = min(ys) - 5, max(ys) + 5
|
|
86
|
+
add(
|
|
87
|
+
f"RECORD=14|OWNERINDEX={cidx}|ISNOTACCESIBLE=T|INDEXINSHEET=-1"
|
|
88
|
+
"|OWNERPARTID=1|LINEWIDTH=1|COLOR=128|AREACOLOR=11599871"
|
|
89
|
+
f"|ISSOLID=T|LOCATION.X={_pt(bx0)}|LOCATION.Y={_pt(by0)}"
|
|
90
|
+
f"|CORNER.X={_pt(bx1)}|CORNER.Y={_pt(by1)}"
|
|
91
|
+
)
|
|
92
|
+
# pins
|
|
93
|
+
for p in comp.pins:
|
|
94
|
+
hx, hy = pin_abs(comp, p)
|
|
95
|
+
rad = math.radians(p.orient_into_body)
|
|
96
|
+
bx = hx + math.cos(rad) * p.length
|
|
97
|
+
by = hy + math.sin(rad) * p.length
|
|
98
|
+
orient_ad = int(round(((p.orient_into_body + 180.0) % 360.0) / 90.0)) % 4
|
|
99
|
+
conglomerate = 32 | orient_ad
|
|
100
|
+
pname = _q(p.name) if p.name else ""
|
|
101
|
+
name_field = f"|NAME={pname}" if pname else ""
|
|
102
|
+
add(
|
|
103
|
+
f"RECORD=2|OWNERINDEX={cidx}|OWNERPARTID=1|FORMALTYPE=1"
|
|
104
|
+
f"|ELECTRICAL=4|PINCONGLOMERATE={conglomerate}"
|
|
105
|
+
f"|PINLENGTH={_pt(p.length)}"
|
|
106
|
+
f"|LOCATION.X={_pt(bx)}|LOCATION.Y={_pt(by)}"
|
|
107
|
+
f"{name_field}|DESIGNATOR={_q(p.number)}"
|
|
108
|
+
)
|
|
109
|
+
# designator + comment + footprint link
|
|
110
|
+
add(
|
|
111
|
+
f"RECORD=34|OWNERINDEX={cidx}|INDEXINSHEET=-1|OWNERPARTID=-1"
|
|
112
|
+
f"|LOCATION.X={_pt(comp.x + 3)}|LOCATION.Y={_pt(comp.y + 11)}"
|
|
113
|
+
f"|{_COL_DES}|{_FONT}|TEXT={_q(comp.ref)}|NAME=Designator"
|
|
114
|
+
f"|READONLYSTATE=1|UNIQUEID={_uid(len(recs))}"
|
|
115
|
+
)
|
|
116
|
+
add(
|
|
117
|
+
f"RECORD=41|OWNERINDEX={cidx}|INDEXINSHEET=-1|OWNERPARTID=-1"
|
|
118
|
+
f"|LOCATION.X={_pt(comp.x + 3)}|LOCATION.Y={_pt(comp.y + 1)}"
|
|
119
|
+
f"|{_COL_DES}|{_FONT}|TEXT={_q(comp.value)}|NAME=Comment"
|
|
120
|
+
f"|UNIQUEID={_uid(len(recs))}"
|
|
121
|
+
)
|
|
122
|
+
if comp.footprint:
|
|
123
|
+
lidx = add(f"RECORD=44|OWNERINDEX={cidx}")
|
|
124
|
+
add(
|
|
125
|
+
f"RECORD=45|OWNERINDEX={lidx}|INDEXINSHEET=-1"
|
|
126
|
+
f"|MODELNAME={_q(comp.footprint)}|MODELTYPE=PCBLIB"
|
|
127
|
+
f"|DATAFILECOUNT=1|MODELDATAFILEENTITY0={_q(comp.footprint)}"
|
|
128
|
+
"|MODELDATAFILEKIND0=PCBLib|ISCURRENT=T"
|
|
129
|
+
f"|UNIQUEID={_uid(len(recs))}"
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
# wires: degenerate "net point" wires (all points identical, an LCEDA
|
|
133
|
+
# concept) are expanded to a minimal stub so labels can attach to them
|
|
134
|
+
# in Altium's model (labels only attach to real segments)
|
|
135
|
+
for w in ir.wires:
|
|
136
|
+
pts = w.points
|
|
137
|
+
if pts and all(p == pts[0] for p in pts):
|
|
138
|
+
x0, y0 = pts[0]
|
|
139
|
+
pts = [pts[0], (x0 + 2.0, y0)]
|
|
140
|
+
parts = "".join(
|
|
141
|
+
f"|X{i + 1}={_pt(px)}|Y{i + 1}={_pt(py)}" for i, (px, py) in enumerate(pts)
|
|
142
|
+
)
|
|
143
|
+
add(
|
|
144
|
+
f"RECORD=27|INDEXINSHEET={len(recs) - 1}|OWNERPARTID=-1"
|
|
145
|
+
f"|LINEWIDTH=1|COLOR=8388608|LOCATIONCOUNT={len(pts)}{parts}"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# net labels: when the label's parent wire is known, snap its position
|
|
149
|
+
# onto that wire so the attachment survives any re-parse
|
|
150
|
+
def _snap_to_wire(lb) -> tuple[float, float]:
|
|
151
|
+
if lb.wire_idx is None or lb.wire_idx >= len(ir.wires):
|
|
152
|
+
return lb.x, lb.y
|
|
153
|
+
pts = ir.wires[lb.wire_idx].points
|
|
154
|
+
if not pts:
|
|
155
|
+
return lb.x, lb.y
|
|
156
|
+
best = min(
|
|
157
|
+
pts, key=lambda p: (p[0] - lb.x) ** 2 + (p[1] - lb.y) ** 2
|
|
158
|
+
)
|
|
159
|
+
return best
|
|
160
|
+
|
|
161
|
+
for lb in ir.labels:
|
|
162
|
+
lx, ly = _snap_to_wire(lb)
|
|
163
|
+
add(
|
|
164
|
+
f"RECORD=25|INDEXINSHEET={len(recs) - 1}|OWNERPARTID=-1"
|
|
165
|
+
f"|LOCATION.X={_pt(lx)}|LOCATION.Y={_pt(ly)}"
|
|
166
|
+
f"|COLOR=128|{_FONT}|TEXT={_q(lb.text)}"
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
# power ports
|
|
170
|
+
for pp in ir.power_ports:
|
|
171
|
+
orient = int(round(pp.orient / 90.0)) % 4
|
|
172
|
+
add(
|
|
173
|
+
f"RECORD=17|INDEXINSHEET={len(recs) - 1}|OWNERPARTID=-1"
|
|
174
|
+
"|STYLE=4|SHOWNETNAME=T"
|
|
175
|
+
f"|LOCATION.X={_pt(pp.x)}|LOCATION.Y={_pt(pp.y)}"
|
|
176
|
+
f"|ORIENTATION={orient}|COLOR=128|TEXT={_q(pp.net)}"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# junctions: T-touches + hubs of >=3 wire endpoints
|
|
180
|
+
segs = [
|
|
181
|
+
(a, b)
|
|
182
|
+
for w in ir.wires
|
|
183
|
+
for a, b in zip(w.points, w.points[1:])
|
|
184
|
+
if a != b
|
|
185
|
+
]
|
|
186
|
+
endpoints: dict[tuple[int, int], list[int]] = {}
|
|
187
|
+
for i, (a, b) in enumerate(segs):
|
|
188
|
+
endpoints.setdefault((round(a[0]), round(a[1])), []).append(i)
|
|
189
|
+
endpoints.setdefault((round(b[0]), round(b[1])), []).append(i)
|
|
190
|
+
junctions: set[tuple[int, int]] = set()
|
|
191
|
+
|
|
192
|
+
def _on_seg_i(px: float, py: float, s) -> bool:
|
|
193
|
+
(x1, y1), (x2, y2) = s
|
|
194
|
+
if abs((px - x1) * (y2 - y1) - (py - y1) * (x2 - x1)) > 1e-6:
|
|
195
|
+
return False
|
|
196
|
+
return (
|
|
197
|
+
min(x1, x2) - 1e-6 <= px <= max(x1, x2) + 1e-6
|
|
198
|
+
and min(y1, y2) - 1e-6 <= py <= max(y1, y2) + 1e-6
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
for pt, idxs in endpoints.items():
|
|
202
|
+
if len(idxs) >= 3:
|
|
203
|
+
junctions.add(pt)
|
|
204
|
+
continue
|
|
205
|
+
# T-touch: endpoint strictly inside another segment
|
|
206
|
+
for k, s in enumerate(segs):
|
|
207
|
+
if k in idxs:
|
|
208
|
+
continue
|
|
209
|
+
(x1, y1), (x2, y2) = s
|
|
210
|
+
if _on_seg_i(pt[0], pt[1], s) and (pt != (round(x1), round(y1))) and (
|
|
211
|
+
pt != (round(x2), round(y2))
|
|
212
|
+
):
|
|
213
|
+
junctions.add(pt)
|
|
214
|
+
break
|
|
215
|
+
for jx, jy in sorted(junctions):
|
|
216
|
+
add(
|
|
217
|
+
f"RECORD=29|INDEXINSHEET=-1|OWNERPARTID=-1"
|
|
218
|
+
f"|LOCATION.X={jx}|LOCATION.Y={jy}|COLOR=128"
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
return recs
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def write_schdoc_ascii(ir: SchematicIR, path: str | Path) -> Path:
|
|
225
|
+
path = Path(path)
|
|
226
|
+
recs = _records_out(ir)
|
|
227
|
+
header = (
|
|
228
|
+
"|HEADER=Protel for Windows - Schematic Capture Ascii File "
|
|
229
|
+
f"Version 5.0|WEIGHT={len(recs)}|MINORVERSION=2|UNIQUEID={_uid(97)}"
|
|
230
|
+
)
|
|
231
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
path.write_text(header + "\n" + "\n".join(recs) + "\n", encoding="utf-8")
|
|
233
|
+
return path
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
# ---------------------------------------------------------------------------
|
|
237
|
+
# reader (for closed-loop verification)
|
|
238
|
+
# ---------------------------------------------------------------------------
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _read_ascii_records(path: Path) -> list[tuple[int, dict[str, str]]]:
|
|
242
|
+
recs: list[tuple[int, dict[str, str]]] = []
|
|
243
|
+
idx = -1
|
|
244
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
245
|
+
line = line.strip()
|
|
246
|
+
if not line.startswith("|"):
|
|
247
|
+
continue
|
|
248
|
+
props: dict[str, str] = {}
|
|
249
|
+
for part in line.split("|"):
|
|
250
|
+
if part and "=" in part:
|
|
251
|
+
k, v = part.split("=", 1)
|
|
252
|
+
props[k.strip().upper()] = v.strip()
|
|
253
|
+
if "HEADER" in props:
|
|
254
|
+
idx = -1 # reset at headers, like KiCad
|
|
255
|
+
continue
|
|
256
|
+
idx += 1
|
|
257
|
+
recs.append((idx, props))
|
|
258
|
+
return recs
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def parse_schdoc_ascii(path: str | Path) -> NetlistModel:
|
|
262
|
+
"""Read an ASCII .SchDoc into a NetlistModel."""
|
|
263
|
+
path = Path(path)
|
|
264
|
+
return model_from_records(_read_ascii_records(path),
|
|
265
|
+
source=f"schdoc-ascii:{path.name}")
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def parse_schdoc_ascii_ir(path: str | Path) -> "SchematicIR":
|
|
269
|
+
"""Read an ASCII .SchDoc into a SchematicIR (round-trip capable)."""
|
|
270
|
+
from migraeda.schdoc import ir_from_records
|
|
271
|
+
|
|
272
|
+
path = Path(path)
|
|
273
|
+
return ir_from_records(_read_ascii_records(path),
|
|
274
|
+
f"schdoc-ascii:{path.name}")
|
migraeda/audit.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Audit trail and rollback for migrations."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import shutil
|
|
9
|
+
import time
|
|
10
|
+
import uuid
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def default_audit_root() -> Path:
|
|
16
|
+
env = os.environ.get("MIGRAEDA_AUDIT_DIR")
|
|
17
|
+
if env:
|
|
18
|
+
return Path(env)
|
|
19
|
+
return Path.home() / ".migraeda" / "audit"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def file_sha256(path: Path) -> str:
|
|
23
|
+
h = hashlib.sha256()
|
|
24
|
+
with path.open("rb") as fh:
|
|
25
|
+
for chunk in iter(lambda: fh.read(65536), b""):
|
|
26
|
+
h.update(chunk)
|
|
27
|
+
return h.hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def new_migration_id() -> str:
|
|
31
|
+
ts = time.strftime("%Y%m%dT%H%M%S")
|
|
32
|
+
return f"{ts}-{uuid.uuid4().hex[:8]}"
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def record_migration(
|
|
36
|
+
*,
|
|
37
|
+
migration_id: str,
|
|
38
|
+
source_epro: Path,
|
|
39
|
+
outdir: Path,
|
|
40
|
+
artifacts: list[str],
|
|
41
|
+
dry_run: bool,
|
|
42
|
+
diff_grade: str,
|
|
43
|
+
extra: dict[str, Any] | None = None,
|
|
44
|
+
audit_root: Path | None = None,
|
|
45
|
+
) -> Path:
|
|
46
|
+
"""Write an audit record under audit_root/<migration_id>/."""
|
|
47
|
+
root = Path(audit_root) if audit_root else default_audit_root()
|
|
48
|
+
dest = root / migration_id
|
|
49
|
+
dest.mkdir(parents=True, exist_ok=True)
|
|
50
|
+
|
|
51
|
+
source_hash = file_sha256(source_epro) if source_epro.is_file() else ""
|
|
52
|
+
artifact_hashes = {}
|
|
53
|
+
for rel in artifacts:
|
|
54
|
+
p = outdir / rel if not Path(rel).is_absolute() else Path(rel)
|
|
55
|
+
if p.is_file():
|
|
56
|
+
artifact_hashes[rel] = file_sha256(p)
|
|
57
|
+
|
|
58
|
+
# snapshot list for rollback (paths relative to outdir)
|
|
59
|
+
record = {
|
|
60
|
+
"migration_id": migration_id,
|
|
61
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
|
|
62
|
+
"source_epro": str(source_epro.resolve()),
|
|
63
|
+
"source_sha256": source_hash,
|
|
64
|
+
"outdir": str(outdir.resolve()),
|
|
65
|
+
"artifacts": artifacts,
|
|
66
|
+
"artifact_sha256": artifact_hashes,
|
|
67
|
+
"dry_run": dry_run,
|
|
68
|
+
"diff_grade": diff_grade,
|
|
69
|
+
"extra": extra or {},
|
|
70
|
+
"status": "active",
|
|
71
|
+
}
|
|
72
|
+
(dest / "record.json").write_text(
|
|
73
|
+
json.dumps(record, indent=2, ensure_ascii=False) + "\n",
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
)
|
|
76
|
+
# also mirror into outdir
|
|
77
|
+
mirror = outdir / ".migraeda" / migration_id
|
|
78
|
+
mirror.mkdir(parents=True, exist_ok=True)
|
|
79
|
+
(mirror / "record.json").write_text(
|
|
80
|
+
json.dumps(record, indent=2, ensure_ascii=False) + "\n",
|
|
81
|
+
encoding="utf-8",
|
|
82
|
+
)
|
|
83
|
+
return dest
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def load_record(migration_id: str, audit_root: Path | None = None) -> dict[str, Any]:
|
|
87
|
+
root = Path(audit_root) if audit_root else default_audit_root()
|
|
88
|
+
path = root / migration_id / "record.json"
|
|
89
|
+
if not path.is_file():
|
|
90
|
+
raise FileNotFoundError(f"audit record not found: {migration_id}")
|
|
91
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def rollback(migration_id: str, audit_root: Path | None = None) -> dict[str, Any]:
|
|
95
|
+
"""Remove artifacts listed in the audit record and mark rolled_back."""
|
|
96
|
+
record = load_record(migration_id, audit_root=audit_root)
|
|
97
|
+
if record.get("status") == "rolled_back":
|
|
98
|
+
return {"migration_id": migration_id, "status": "already_rolled_back"}
|
|
99
|
+
|
|
100
|
+
outdir = Path(record["outdir"])
|
|
101
|
+
removed: list[str] = []
|
|
102
|
+
for rel in record.get("artifacts") or []:
|
|
103
|
+
p = outdir / rel
|
|
104
|
+
if p.is_file():
|
|
105
|
+
p.unlink()
|
|
106
|
+
removed.append(rel)
|
|
107
|
+
elif p.is_dir():
|
|
108
|
+
shutil.rmtree(p)
|
|
109
|
+
removed.append(rel)
|
|
110
|
+
|
|
111
|
+
record["status"] = "rolled_back"
|
|
112
|
+
record["rolled_back_at"] = time.strftime("%Y-%m-%dT%H:%M:%S%z")
|
|
113
|
+
record["removed"] = removed
|
|
114
|
+
|
|
115
|
+
root = Path(audit_root) if audit_root else default_audit_root()
|
|
116
|
+
dest = root / migration_id
|
|
117
|
+
(dest / "record.json").write_text(
|
|
118
|
+
json.dumps(record, indent=2, ensure_ascii=False) + "\n",
|
|
119
|
+
encoding="utf-8",
|
|
120
|
+
)
|
|
121
|
+
mirror = outdir / ".migraeda" / migration_id / "record.json"
|
|
122
|
+
if mirror.parent.is_dir():
|
|
123
|
+
mirror.write_text(
|
|
124
|
+
json.dumps(record, indent=2, ensure_ascii=False) + "\n",
|
|
125
|
+
encoding="utf-8",
|
|
126
|
+
)
|
|
127
|
+
return {"migration_id": migration_id, "status": "rolled_back", "removed": removed}
|