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 ADDED
@@ -0,0 +1,3 @@
1
+ """MigraEDA: pin-level verified conversion between JLCEDA and Altium Designer."""
2
+
3
+ __version__ = "0.1.0"
migraeda/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from migraeda.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
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}