@anik-2/autosar 0.0.0-stage → 0.1.1

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.
@@ -0,0 +1,429 @@
1
+ """In-place ECUC edits.
2
+
3
+ Ops (addressed by AUTOSAR path; names are def SHORT-NAMEs):
4
+ {"op": "set", "path": "/Pkg/Com/ComConfig/Sig1", "param": "ComBitPosition", "value": 8}
5
+ {"op": "set", "path": "/Pkg/Com/ComConfig/Sig1/ComBitPosition", "value": 8} # param in path
6
+ {"op": "set", "path": ".../ComIPdu1", "param": "ComIPduSignalRef", "value": ["->/a", "->/b"]} # multi
7
+ {"op": "add", "path": "/Pkg/Com/ComConfig", "container": "ComSignal:Sig2", "values": {...ECUC JSON...}}
8
+ {"op": "add", "path": ".../ComIPdu1", "param": "ComIPduSignalRef", "value": "->/Pkg/Com/ComConfig/Sig2"}
9
+ {"op": "remove", "path": "/Pkg/Com/ComConfig/Sig2"}
10
+ {"op": "remove", "path": ".../ComIPdu1", "param": "ComIPduSignalRef" [, "value": "->/..."]}
11
+
12
+ The engine picks the value element (NUMERICAL / TEXTUAL / REFERENCE), the DEFINITION-REF and its
13
+ DEST, and the VALUE-REF DEST from the param defs. Each op patches only the byte span it changes, in
14
+ the file the element lives in; everything else (UUIDs, order, comments, formatting) is untouched.
15
+ All ops apply in memory first; files are written only if every op succeeds.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import difflib
20
+ import os
21
+ import uuid
22
+ from typing import Optional
23
+
24
+ from . import defs as D
25
+ from .model import CONTAINER_VALUE, Elem, Project
26
+ from .validate import _resolve_def, validate
27
+ from .values import check_value, expected_value_tag, from_json
28
+ from .xmltree import Node, escape, line_indent
29
+
30
+ CONTAINER_ORDER = ["DEFINITION-REF", "PARAMETER-VALUES", "REFERENCE-VALUES", "SUB-CONTAINERS", "VARIATION-POINT"]
31
+ MODULE_ORDER = ["DEFINITION-REF", "ECUC-DEF-EDITION", "IMPLEMENTATION-CONFIG-VARIANT", "MODULE-DESCRIPTION-REF",
32
+ "POST-BUILD-VARIANT-USED", "CONTAINERS"]
33
+
34
+
35
+ class EditError(Exception):
36
+ pass
37
+
38
+
39
+ class Editor:
40
+ def __init__(self, project: Project):
41
+ self.p = project
42
+ self.original: dict[str, bytes] = {}
43
+ self.notes: list = []
44
+
45
+ # ---------- low-level byte patching ----------
46
+ def data(self, f: str) -> bytes:
47
+ return self.p.files[f].data
48
+
49
+ def patch(self, f: str, start: int, end: int, repl: bytes):
50
+ data = self.data(f)
51
+ self.original.setdefault(f, data)
52
+ self.p.reload(f, data[:start] + repl + data[end:])
53
+ if f not in self.p.files:
54
+ raise EditError(f"internal: patched file {f} no longer parses")
55
+
56
+ def nl(self, f: str) -> bytes:
57
+ return self.p.files[f].newline
58
+
59
+ def indent(self, f: str, node: Node) -> bytes:
60
+ return line_indent(self.data(f), node.start) or b""
61
+
62
+ def unit(self, f: str, node: Node) -> bytes:
63
+ if node.children:
64
+ ci, ni = self.indent(f, node.children[0]), self.indent(f, node)
65
+ if len(ci) > len(ni) and ci.startswith(ni):
66
+ return ci[len(ni):]
67
+ return b" "
68
+
69
+ def emit(self, f: str, lines: list, base: bytes, unit: bytes) -> bytes:
70
+ nl = self.nl(f)
71
+ return nl.join(base + unit * d + s.encode("utf-8") for d, s in lines)
72
+
73
+ def remove_node(self, f: str, node: Node):
74
+ parent = node.parent
75
+ if parent is not None and len(parent.children) == 1 and parent.tag in (
76
+ "PARAMETER-VALUES", "REFERENCE-VALUES", "SUB-CONTAINERS", "CONTAINERS"):
77
+ return self.remove_node(f, parent)
78
+ data = self.data(f)
79
+ start = node.start
80
+ if line_indent(data, start) is not None:
81
+ nlpos = data.rfind(b"\n", 0, start)
82
+ if nlpos >= 0:
83
+ start = nlpos - 1 if nlpos > 0 and data[nlpos - 1:nlpos] == b"\r" else nlpos
84
+ self.patch(f, start, node.end, b"")
85
+
86
+ def insert_into(self, f: str, owner: Node, coll_tag: str, items: list):
87
+ """Append rendered items (each a list of (depth, text)) to owner's collection, creating it if needed."""
88
+ unit = self.unit(f, owner)
89
+ coll = owner.child(coll_tag)
90
+ nl = self.nl(f)
91
+ if coll is not None and coll.children:
92
+ last = coll.children[-1]
93
+ ind = self.indent(f, last)
94
+ text = b"".join(nl + self.emit(f, it, ind, unit) for it in items)
95
+ self.patch(f, last.end, last.end, text)
96
+ return
97
+ lines = [(0, f"<{coll_tag}>")] + [(d + 1, s) for it in items for d, s in it] + [(0, f"</{coll_tag}>")]
98
+ if coll is not None: # empty collection element
99
+ self.patch(f, coll.start, coll.end, self.emit(f, lines, self.indent(f, coll), unit).lstrip())
100
+ return
101
+ order = MODULE_ORDER if owner.tag == "ECUC-MODULE-CONFIGURATION-VALUES" else CONTAINER_ORDER
102
+ after = set(order[order.index(coll_tag) + 1:]) if coll_tag in order else set()
103
+ ind = self.indent(f, owner.children[0]) if owner.children else self.indent(f, owner) + unit
104
+ before = next((c for c in owner.children if c.tag in after), None)
105
+ if before is not None:
106
+ self.patch(f, before.start, before.start, self.emit(f, lines, ind, unit).lstrip() + nl + ind)
107
+ else:
108
+ last = owner.children[-1]
109
+ self.patch(f, last.end, last.end, nl + self.emit(f, lines, ind, unit))
110
+
111
+ # ---------- rendering ----------
112
+ def ref_dest(self, pd: Optional[D.DefNode], target: str) -> str:
113
+ hit = self.p.find(target)
114
+ if hit:
115
+ return hit[0].node.tag
116
+ ident = self.p.identifiables.get(target)
117
+ if ident:
118
+ return ident[0]
119
+ if pd is not None and pd.dest_type:
120
+ return pd.dest_type
121
+ return CONTAINER_VALUE
122
+
123
+ def render_param(self, pd: D.DefNode, def_ref: str, value) -> list:
124
+ tag = expected_value_tag(pd)
125
+ if tag is None or tag in ("ECUC-INSTANCE-REFERENCE-VALUE", "ECUC-ADD-INFO-PARAM-VALUE"):
126
+ raise EditError(f"{pd.name}: {pd.kind} values cannot be edited by this engine yet")
127
+ text = from_json(pd, value)
128
+ lines = [(0, f"<{tag}>"), (1, f'<DEFINITION-REF DEST="{pd.tag}">{escape(def_ref)}</DEFINITION-REF>')]
129
+ if tag == "ECUC-REFERENCE-VALUE":
130
+ lines.append((1, f'<VALUE-REF DEST="{self.ref_dest(pd, text)}">{escape(text)}</VALUE-REF>'))
131
+ else:
132
+ lines.append((1, f"<VALUE>{escape(text)}</VALUE>"))
133
+ lines.append((0, f"</{tag}>"))
134
+ return lines
135
+
136
+ def render_container(self, f: str, cd: D.DefNode, def_ref: str, name: str, values: dict) -> list:
137
+ with_uuid = b"<ECUC-CONTAINER-VALUE UUID=" in self.data(f)
138
+ head = f'<ECUC-CONTAINER-VALUE UUID="{uuid.uuid4()}">' if with_uuid else "<ECUC-CONTAINER-VALUE>"
139
+ lines = [(0, head), (1, f"<SHORT-NAME>{escape(name)}</SHORT-NAME>"),
140
+ (1, f'<DEFINITION-REF DEST="{cd.tag}">{escape(def_ref)}</DEFINITION-REF>')]
141
+ params, refs, subs = [], [], []
142
+ by_name = {self.p.defs.by_path[x].name: self.p.defs.by_path[x] for x in cd.params + cd.refs}
143
+ cdefs = {self.p.defs.by_path[x].name: self.p.defs.by_path[x] for x in cd.containers}
144
+ order = {n: i for i, n in enumerate(list(by_name) + list(cdefs))}
145
+ for k in sorted((k for k in values if not k.startswith("@")), key=lambda k: order.get(k.split(":")[0], 1 << 30)):
146
+ v = values[k]
147
+ if isinstance(v, dict):
148
+ dname, _, sname = k.partition(":")
149
+ sname = sname or dname
150
+ sd = cdefs.get(dname)
151
+ if sd is None:
152
+ raise EditError(f"{cd.name} has no sub-container def '{dname}' (allowed: {', '.join(cdefs) or 'none'})")
153
+ subs.append(self.render_container(f, sd, f"{def_ref}/{sd.name}", sname, v))
154
+ continue
155
+ pd = by_name.get(k)
156
+ if pd is None:
157
+ raise EditError(f"{cd.name} has no parameter/reference '{k}' (allowed: {', '.join(by_name) or 'none'})")
158
+ for one in (v if isinstance(v, list) else [v]):
159
+ self.check(pd, one)
160
+ (refs if D.is_ref_kind(pd.kind or "") else params).append(self.render_param(pd, f"{def_ref}/{pd.name}", one))
161
+ for tag, items in (("PARAMETER-VALUES", params), ("REFERENCE-VALUES", refs), ("SUB-CONTAINERS", subs)):
162
+ if items:
163
+ lines.append((1, f"<{tag}>"))
164
+ lines += [(d + 2, s) for it in items for d, s in it]
165
+ lines.append((1, f"</{tag}>"))
166
+ lines.append((0, "</ECUC-CONTAINER-VALUE>"))
167
+ return lines
168
+
169
+ def check(self, pd: D.DefNode, value):
170
+ if D.is_ref_kind(pd.kind or ""):
171
+ t = from_json(pd, value)
172
+ if not t.startswith("/"):
173
+ raise EditError(f"{pd.name}: reference value must be an AUTOSAR path ('->/Pkg/...'), got {value!r}")
174
+ return
175
+ probs = check_value(pd, from_json(pd, value))
176
+ if probs and not self.force:
177
+ raise EditError("; ".join(m for _, m in probs) + " (pass force:true to write it anyway)")
178
+
179
+ # ---------- lookups ----------
180
+ def container(self, path: str) -> list:
181
+ g = self.p.find(path)
182
+ if not g:
183
+ raise EditError(f"no ECUC module/container at {path}")
184
+ return g
185
+
186
+ def child_def(self, e: Elem, name: str, want: str) -> D.DefNode:
187
+ d = _resolve_def(self.p.defs, e.def_ref)
188
+ if d is None:
189
+ raise EditError(f"no param def loaded for {e.def_ref}; cannot {want} (load the param defs, see ecuc_read warnings)")
190
+ pool = d.containers if want == "container" else d.params + d.refs
191
+ for x in pool:
192
+ if self.p.defs.by_path[x].name == name:
193
+ return self.p.defs.by_path[x]
194
+ names = ", ".join(self.p.defs.by_path[x].name for x in pool) or "none"
195
+ raise EditError(f"{d.name} has no {want} def '{name}' (allowed: {names})")
196
+
197
+ def split_param_path(self, op: dict):
198
+ path, param = op.get("path", "").rstrip("/"), op.get("param")
199
+ if param or self.p.find(path):
200
+ return path, param
201
+ parent, _, last = path.rpartition("/")
202
+ if self.p.find(parent):
203
+ return parent, last
204
+ raise EditError(f"no ECUC element at {path} (nor at its parent {parent}); read the module first for exact paths")
205
+
206
+ # ---------- ops ----------
207
+ def apply(self, ops: list, force: bool = False):
208
+ self.force = force
209
+ check_ops(ops)
210
+ for i, op in enumerate(ops):
211
+ kind = op.get("op")
212
+ try:
213
+ if kind == "set":
214
+ self.op_set(op)
215
+ elif kind == "add":
216
+ self.op_add(op)
217
+ elif kind == "remove":
218
+ self.op_remove(op)
219
+ else:
220
+ raise EditError(f"unknown op {kind!r} (use set/add/remove)")
221
+ except EditError as e:
222
+ raise EditError(f"op {i} ({kind} {op.get('path')}): {e}") from None
223
+
224
+ def op_set(self, op: dict):
225
+ path, name = self.split_param_path(op)
226
+ if not name:
227
+ raise EditError("set needs 'param' (or a path ending in the parameter name)")
228
+ if "value" not in op:
229
+ raise EditError("set needs 'value'")
230
+ value = op["value"]
231
+ g = self.container(path)
232
+ pd = None
233
+ try:
234
+ pd = self.child_def(g[0], name, "parameter")
235
+ except EditError:
236
+ if self.p.defs:
237
+ raise
238
+ existing = [(e, pv) for e in g for pv in e.params if pv.name == name]
239
+ if isinstance(value, list):
240
+ if pd is None:
241
+ raise EditError("multi-value set needs the param defs loaded")
242
+ if pd.upper is not None and len(value) > pd.upper:
243
+ raise EditError(f"{pd.name} allows at most {pd.upper} value(s)")
244
+ for v in value:
245
+ self.check(pd, v)
246
+ for _ in existing:
247
+ e, pv = [(e, pv) for e in self.container(path) for pv in e.params if pv.name == name][0]
248
+ self.remove_node(e.file, pv.node)
249
+ for v in value:
250
+ self._add_value(path, pd, v)
251
+ return
252
+ if len(existing) > 1:
253
+ raise EditError(f"{name} has {len(existing)} values; pass a list to replace them, or use add/remove")
254
+ if pd is not None:
255
+ self.check(pd, value)
256
+ if not existing:
257
+ if pd is None:
258
+ raise EditError(f"{name} not present and no param defs loaded to create it")
259
+ self._add_value(path, pd, value)
260
+ return
261
+ e, pv = existing[0]
262
+ text = from_json(pd, value)
263
+ vn = pv.value_node
264
+ if pv.is_auto:
265
+ self.notes.append(f"{path}/{name} has IS-AUTO-VALUE=true; the generator may recompute it")
266
+ if vn is None:
267
+ raise EditError(f"{name} value element has no VALUE; remove and re-add it")
268
+ if vn.tag == "VALUE-REF":
269
+ dest = self.ref_dest(pd, text)
270
+ if vn.attrs.get("DEST") != dest or vn.cstart is None:
271
+ self.patch(e.file, vn.start, vn.end, f'<VALUE-REF DEST="{dest}">{escape(text)}</VALUE-REF>'.encode())
272
+ return
273
+ if vn.cstart is None:
274
+ self.patch(e.file, vn.start, vn.end, f"<{vn.tag}>{escape(text)}</{vn.tag}>".encode())
275
+ else:
276
+ self.patch(e.file, vn.cstart, vn.cend, escape(text).encode("utf-8"))
277
+
278
+ def _add_value(self, path: str, pd: D.DefNode, value):
279
+ e = self.container(path)[0]
280
+ coll = "REFERENCE-VALUES" if D.is_ref_kind(pd.kind or "") else "PARAMETER-VALUES"
281
+ self.insert_into(e.file, e.node, coll, [self.render_param(pd, f"{e.def_ref}/{pd.name}", value)])
282
+
283
+ def op_add(self, op: dict):
284
+ path = op.get("path", "").rstrip("/")
285
+ if op.get("param"):
286
+ g = self.container(path)
287
+ pd = self.child_def(g[0], op["param"], "parameter")
288
+ have = sum(1 for e in g for pv in e.params if pv.name == pd.name)
289
+ if pd.upper is not None and have >= pd.upper:
290
+ raise EditError(f"{pd.name} already has {have} value(s), def allows {pd.mult}; use set")
291
+ self.check(pd, op.get("value"))
292
+ self._add_value(path, pd, op.get("value"))
293
+ return
294
+ key = op.get("container")
295
+ if not key:
296
+ raise EditError("add needs 'container' (\"DefName:ShortName\") or 'param' + 'value'")
297
+ dname, _, sname = key.partition(":")
298
+ sname = sname or dname
299
+ g = self.container(path)
300
+ cd = self.child_def(g[0], dname, "container")
301
+ new_path = f"{path}/{sname}"
302
+ if self.p.find(new_path):
303
+ raise EditError(f"{new_path} already exists")
304
+ siblings = sum(1 for e in g for c in e.children if (c.def_ref or "").rsplit("/", 1)[-1] == cd.name)
305
+ if cd.upper is not None and siblings >= cd.upper:
306
+ raise EditError(f"{cd.name} already has {siblings} instance(s), def allows {cd.mult}")
307
+ e = g[0]
308
+ lines = self.render_container(e.file, cd, f"{e.def_ref}/{cd.name}", sname, op.get("values") or {})
309
+ coll = "CONTAINERS" if e.kind == "module" else "SUB-CONTAINERS"
310
+ self.insert_into(e.file, e.node, coll, [lines])
311
+
312
+ def op_remove(self, op: dict):
313
+ path, name = self.split_param_path(op)
314
+ if name and not self.p.find(f"{path}/{name}"):
315
+ g = self.container(path)
316
+ want = from_json(None, op["value"]) if "value" in op else None
317
+ hits = [(e, pv) for e in g for pv in e.params if pv.name == name and (want is None or pv.value == want)]
318
+ if not hits:
319
+ raise EditError(f"no value of {name}{'=' + want if want else ''} under {path}")
320
+ for _ in hits:
321
+ e, pv = [(e, pv) for e in self.container(path) for pv in e.params
322
+ if pv.name == name and (want is None or pv.value == want)][0]
323
+ self.remove_node(e.file, pv.node)
324
+ return
325
+ target = f"{path}/{name}" if name else path
326
+ g = self.container(target)
327
+ refs_in = [p for p, grp in self.p.by_path.items() for e in grp for pv in e.params
328
+ if pv.is_ref and pv.value == target]
329
+ if refs_in:
330
+ self.notes.append(f"{target} is still referenced from {', '.join(sorted(set(refs_in))[:10])}; "
331
+ "validate will report dangling refs")
332
+ for _ in range(len(g)):
333
+ e = self.container(target)[0]
334
+ self.remove_node(e.file, e.node)
335
+
336
+ # ---------- results ----------
337
+ def diffs(self) -> list:
338
+ out = []
339
+ for f, before in self.original.items():
340
+ after = self.data(f)
341
+ if before == after:
342
+ continue
343
+ a = before.decode("utf-8", "replace").splitlines(keepends=True)
344
+ b = after.decode("utf-8", "replace").splitlines(keepends=True)
345
+ rel = self.p.rel(f)
346
+ out.append({"file": rel, "diff": "".join(difflib.unified_diff(a, b, f"a/{rel}", f"b/{rel}", n=2))})
347
+ return out
348
+
349
+ def write(self):
350
+ for f, before in self.original.items():
351
+ after = self.data(f)
352
+ if before != after:
353
+ tmp = f + ".ecuc-tmp"
354
+ with open(tmp, "wb") as fh:
355
+ fh.write(after)
356
+ os.replace(tmp, f)
357
+
358
+
359
+ OP_KEYS = {"op", "path", "param", "value", "container", "values"}
360
+
361
+
362
+ def check_ops(ops) -> None:
363
+ """Shape-check ops before touching anything: clear errors instead of tracebacks."""
364
+ if not isinstance(ops, list) or not ops:
365
+ raise EditError(f"ops must be a non-empty list of op objects, got {type(ops).__name__}")
366
+ for i, op in enumerate(ops):
367
+ if not isinstance(op, dict):
368
+ raise EditError(f"op {i} must be an object like {{\"op\": \"set\", \"path\": ..., \"param\": ..., \"value\": ...}}, "
369
+ f"got {type(op).__name__}: {str(op)[:120]}")
370
+ unknown = set(op) - OP_KEYS
371
+ if unknown:
372
+ raise EditError(f"op {i}: unknown key(s) {sorted(unknown)}; allowed: {sorted(OP_KEYS)}")
373
+ if op.get("op") not in ("set", "add", "remove"):
374
+ raise EditError(f"op {i}: 'op' must be set, add or remove (got {op.get('op')!r})")
375
+ if not isinstance(op.get("path"), str) or not op["path"].startswith("/"):
376
+ raise EditError(f"op {i}: 'path' must be an absolute AUTOSAR path string ('/Pkg/Module/...'), got {op.get('path')!r}")
377
+ for k in ("param", "container"):
378
+ if k in op and op[k] is not None and not isinstance(op[k], str):
379
+ raise EditError(f"op {i}: '{k}' must be a string, got {type(op[k]).__name__}")
380
+ if "values" in op and op["values"] is not None and not isinstance(op["values"], dict):
381
+ raise EditError(f"op {i}: 'values' must be an object of ECUC JSON, got {type(op['values']).__name__}")
382
+ if op["op"] == "set" and "value" not in op:
383
+ raise EditError(f"op {i}: set needs 'value'")
384
+ if op["op"] == "add" and not op.get("container") and not op.get("param"):
385
+ raise EditError(f"op {i}: add needs 'container' (\"DefName:ShortName\") or 'param' + 'value'")
386
+
387
+
388
+ def schema_check(path: str, data: bytes) -> Optional[str]:
389
+ """Strict schema load with autosar-data when available. Returns an error string or None."""
390
+ try:
391
+ import autosar_data as ad # type: ignore
392
+ except Exception:
393
+ return None
394
+ try:
395
+ m = ad.AutosarModel()
396
+ m.load_buffer(data.decode("utf-8"), os.path.basename(path), True)
397
+ return None
398
+ except Exception as e: # noqa: BLE001
399
+ return str(e)
400
+
401
+
402
+ def edit(project: Project, ops: list, dry_run: bool = False, force: bool = False, run_validate: bool = True) -> dict:
403
+ ed = Editor(project)
404
+ try:
405
+ ed.apply(ops, force=force)
406
+ except EditError as e:
407
+ return {"ok": False, "error": str(e), "written": False}
408
+ changes = ed.diffs()
409
+ res: dict = {"ok": True, "applied": len(ops), "changes": changes, "written": False}
410
+ schema = []
411
+ for f, before in ed.original.items():
412
+ if schema_check(f, before) is None:
413
+ err = schema_check(f, ed.data(f))
414
+ if err:
415
+ schema.append({"file": project.rel(f), "error": err})
416
+ if schema and not force:
417
+ return {"ok": False, "error": "edit would make the ARXML schema-invalid; nothing written",
418
+ "schema": schema, "changes": changes, "written": False}
419
+ if not dry_run:
420
+ ed.write()
421
+ res["written"] = True
422
+ if ed.notes:
423
+ res["notes"] = ed.notes
424
+ if run_validate:
425
+ touched = list(ed.original)
426
+ v = validate(project, files=touched)
427
+ res["validation"] = {"validated": v["validated"], "summary": v["summary"],
428
+ "diagnostics": v["diagnostics"][:50]}
429
+ return res