@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.
- package/.hydron-plugin/plugin.json +3 -0
- package/README.md +55 -2
- package/agents/autosar-integrator.md +44 -0
- package/commands/autosar-trace.md +11 -0
- package/commands/autosar-validate.md +12 -0
- package/package.json +44 -4
- package/plugins/index.js +21 -0
- package/python/ecuc_engine/__init__.py +2 -0
- package/python/ecuc_engine/__main__.py +5 -0
- package/python/ecuc_engine/cli.py +111 -0
- package/python/ecuc_engine/defs.py +330 -0
- package/python/ecuc_engine/diff.py +111 -0
- package/python/ecuc_engine/ecucjson.py +154 -0
- package/python/ecuc_engine/edit.py +429 -0
- package/python/ecuc_engine/model.py +330 -0
- package/python/ecuc_engine/rules/__init__.py +82 -0
- package/python/ecuc_engine/rules/com.py +107 -0
- package/python/ecuc_engine/rules/handles.py +42 -0
- package/python/ecuc_engine/validate.py +227 -0
- package/python/ecuc_engine/values.py +146 -0
- package/python/ecuc_engine/xmltree.py +141 -0
- package/python/pyproject.toml +13 -0
- package/skills/autosar/SKILL.md +70 -0
- package/skills/autosar/references/config-classes.md +23 -0
- package/skills/autosar/references/ecuc-model.md +42 -0
- package/skills/autosar/references/project-layout.md +29 -0
- package/skills/autosar/references/symbolic-names.md +25 -0
- package/skills/autosar/references/workflow.md +29 -0
|
@@ -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
|