simaticml-decoder 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.
- simaticml_decoder/__init__.py +13 -0
- simaticml_decoder/cli.py +113 -0
- simaticml_decoder/emit.py +365 -0
- simaticml_decoder/fold.py +652 -0
- simaticml_decoder/instructions.py +105 -0
- simaticml_decoder/ir.py +182 -0
- simaticml_decoder/model.py +264 -0
- simaticml_decoder/operand.py +141 -0
- simaticml_decoder/parse.py +596 -0
- simaticml_decoder/scl_reconstruct.py +75 -0
- simaticml_decoder-0.1.0.dist-info/METADATA +118 -0
- simaticml_decoder-0.1.0.dist-info/RECORD +14 -0
- simaticml_decoder-0.1.0.dist-info/WHEEL +4 -0
- simaticml_decoder-0.1.0.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
"""Phase 1: SimaticML XML -> model.* (a faithful, behaviour-free mirror).
|
|
2
|
+
|
|
3
|
+
All XML-quirk handling is isolated here so the later phases never touch a raw
|
|
4
|
+
element. Decoder rules from SIMATICML_READING_GUIDE.md that this phase owns:
|
|
5
|
+
|
|
6
|
+
* Parse namespace-aware but match by *local name* (FlgNet is /v5, StructuredText
|
|
7
|
+
is /v4, Interface is /v5 — versions drift; local-name matching is robust).
|
|
8
|
+
* Scope every UId lookup table *per compile unit* — UIds repeat across networks.
|
|
9
|
+
(Each FlgNet owns its own accesses/parts/calls dicts, so this is structural.)
|
|
10
|
+
* Never deduplicate Access nodes — one node per use (each use has a unique UId).
|
|
11
|
+
* Unescape ``"..."`` datatypes and mark them as UDT references.
|
|
12
|
+
(ElementTree unescapes entities; a datatype left wrapped in quotes is a UDT.)
|
|
13
|
+
* Read ``Informative`` values but do not depend on them.
|
|
14
|
+
* Preserve unknown attributes/elements in ``raw`` for forward compatibility.
|
|
15
|
+
* Empty ``<NetworkSource />`` -> Network.source = None.
|
|
16
|
+
|
|
17
|
+
This is the first module implemented, validated against the six V21 samples.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from xml.etree import ElementTree as ET
|
|
23
|
+
|
|
24
|
+
from . import model
|
|
25
|
+
|
|
26
|
+
# --------------------------------------------------------------------------- #
|
|
27
|
+
# Namespace-agnostic element helpers (match by local name only) #
|
|
28
|
+
# --------------------------------------------------------------------------- #
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _ln(tag: object) -> str:
|
|
32
|
+
"""Local name of an element tag, dropping any ``{namespace}`` prefix."""
|
|
33
|
+
if not isinstance(tag, str):
|
|
34
|
+
return ""
|
|
35
|
+
return tag.rsplit("}", 1)[-1]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _children(elem: ET.Element, name: str | None = None) -> list[ET.Element]:
|
|
39
|
+
"""Direct child elements, optionally filtered by local name."""
|
|
40
|
+
return [c for c in elem if name is None or _ln(c.tag) == name]
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _child(elem: ET.Element | None, name: str) -> ET.Element | None:
|
|
44
|
+
"""First direct child with the given local name, or None."""
|
|
45
|
+
if elem is None:
|
|
46
|
+
return None
|
|
47
|
+
for c in elem:
|
|
48
|
+
if _ln(c.tag) == name:
|
|
49
|
+
return c
|
|
50
|
+
return None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _child_text(elem: ET.Element | None, name: str) -> str | None:
|
|
54
|
+
c = _child(elem, name)
|
|
55
|
+
return c.text if c is not None else None
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _first_element_child(elem: ET.Element | None) -> ET.Element | None:
|
|
59
|
+
"""First child element (ignoring text/whitespace); None if there are none."""
|
|
60
|
+
if elem is None:
|
|
61
|
+
return None
|
|
62
|
+
for c in elem:
|
|
63
|
+
return c
|
|
64
|
+
return None
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _nz(text: str | None) -> str | None:
|
|
68
|
+
"""Normalise a metadata string: strip, and collapse empty/blank to None."""
|
|
69
|
+
if text is None:
|
|
70
|
+
return None
|
|
71
|
+
stripped = text.strip()
|
|
72
|
+
return stripped or None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_true(value: str | None) -> bool:
|
|
76
|
+
return (value or "").strip().lower() == "true"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _int_or_none(value: str | None) -> int | None:
|
|
80
|
+
if value is None:
|
|
81
|
+
return None
|
|
82
|
+
try:
|
|
83
|
+
return int(value.strip())
|
|
84
|
+
except (ValueError, AttributeError):
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _extra_attrs(elem: ET.Element, known: set[str]) -> dict:
|
|
89
|
+
"""Unknown attributes preserved for forward compatibility (the raw hatch)."""
|
|
90
|
+
return {k: v for k, v in elem.attrib.items() if k not in known}
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --------------------------------------------------------------------------- #
|
|
94
|
+
# Entry points #
|
|
95
|
+
# --------------------------------------------------------------------------- #
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def parse_document(xml_text: str) -> model.Document:
|
|
99
|
+
"""Raw XML string -> model.Document (a faithful syntactic mirror)."""
|
|
100
|
+
root = ET.fromstring(xml_text)
|
|
101
|
+
|
|
102
|
+
engineering = _child(root, "Engineering")
|
|
103
|
+
version = engineering.get("version") if engineering is not None else None
|
|
104
|
+
|
|
105
|
+
block_elem = _find_block_element(root)
|
|
106
|
+
if block_elem is None:
|
|
107
|
+
raise ValueError("no SW.Blocks.* block element found in document")
|
|
108
|
+
|
|
109
|
+
return model.Document(engineering_version=version, block=_parse_block(block_elem))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def parse_file(path: str) -> model.Document:
|
|
113
|
+
"""Convenience wrapper: read a file and parse it."""
|
|
114
|
+
with open(path, encoding="utf-8-sig") as fh: # -sig strips the UTF-8 BOM TIA emits
|
|
115
|
+
return parse_document(fh.read())
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
# --------------------------------------------------------------------------- #
|
|
119
|
+
# Block #
|
|
120
|
+
# --------------------------------------------------------------------------- #
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _find_block_element(root: ET.Element) -> ET.Element | None:
|
|
124
|
+
for child in root:
|
|
125
|
+
if _ln(child.tag).startswith("SW.Blocks."):
|
|
126
|
+
return child
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _block_kind(tag_local: str) -> model.BlockKind:
|
|
131
|
+
suffix = tag_local.split(".")[-1]
|
|
132
|
+
try:
|
|
133
|
+
return model.BlockKind(suffix)
|
|
134
|
+
except ValueError:
|
|
135
|
+
return model.BlockKind.UNKNOWN
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _parse_block(elem: ET.Element) -> model.Block:
|
|
139
|
+
attrs = _child(elem, "AttributeList")
|
|
140
|
+
objlist = _child(elem, "ObjectList")
|
|
141
|
+
|
|
142
|
+
block = model.Block(
|
|
143
|
+
kind=_block_kind(_ln(elem.tag)),
|
|
144
|
+
id=elem.get("ID", ""),
|
|
145
|
+
name=_nz(_child_text(attrs, "Name")) or "",
|
|
146
|
+
number=_int_or_none(_child_text(attrs, "Number")),
|
|
147
|
+
language=_parse_language(_child_text(attrs, "ProgrammingLanguage")),
|
|
148
|
+
memory_layout=_nz(_child_text(attrs, "MemoryLayout")),
|
|
149
|
+
memory_reserve=_int_or_none(_child_text(attrs, "MemoryReserve")),
|
|
150
|
+
set_eno_automatically=_is_true(_child_text(attrs, "SetENOAutomatically")),
|
|
151
|
+
interface=_parse_interface(_child(attrs, "Interface")),
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
if objlist is not None:
|
|
155
|
+
block.title, block.comment = _titles_and_comments(objlist)
|
|
156
|
+
index = 0
|
|
157
|
+
for cu in _children(objlist, "SW.Blocks.CompileUnit"):
|
|
158
|
+
index += 1
|
|
159
|
+
block.networks.append(_parse_compile_unit(cu, index))
|
|
160
|
+
|
|
161
|
+
return block
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _parse_language(value: str | None) -> model.Language:
|
|
165
|
+
"""Per-compile-unit / block language -> model.Language (unknown -> OTHER)."""
|
|
166
|
+
raw = (value or "").strip()
|
|
167
|
+
mapping = {
|
|
168
|
+
"LAD": model.Language.LAD,
|
|
169
|
+
"LAD_IEC": model.Language.LAD,
|
|
170
|
+
"FBD": model.Language.FBD,
|
|
171
|
+
"FBD_IEC": model.Language.FBD,
|
|
172
|
+
"SCL": model.Language.SCL,
|
|
173
|
+
"STL": model.Language.STL,
|
|
174
|
+
"GRAPH": model.Language.GRAPH,
|
|
175
|
+
}
|
|
176
|
+
return mapping.get(raw, model.Language.OTHER)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
# --------------------------------------------------------------------------- #
|
|
180
|
+
# Interface (sections + members) #
|
|
181
|
+
# --------------------------------------------------------------------------- #
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _parse_interface(elem: ET.Element | None) -> model.Interface:
|
|
185
|
+
interface = model.Interface()
|
|
186
|
+
if elem is None:
|
|
187
|
+
return interface
|
|
188
|
+
sections = _child(elem, "Sections")
|
|
189
|
+
if sections is None:
|
|
190
|
+
return interface
|
|
191
|
+
for sec in _children(sections, "Section"):
|
|
192
|
+
section = model.Section(name=sec.get("Name", ""))
|
|
193
|
+
for member in _children(sec, "Member"):
|
|
194
|
+
section.members.append(_parse_member(member))
|
|
195
|
+
interface.sections.append(section)
|
|
196
|
+
return interface
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
_MEMBER_KNOWN_ATTRS = {"Name", "Datatype", "Version", "Remanence", "Accessibility"}
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _parse_member(elem: ET.Element) -> model.Member:
|
|
203
|
+
# ElementTree already unescaped " -> a datatype still wrapped in double
|
|
204
|
+
# quotes is a UDT reference (e.g. '"PLC_System"'). Keep the quotes; flag it.
|
|
205
|
+
datatype = elem.get("Datatype", "")
|
|
206
|
+
is_udt = len(datatype) >= 2 and datatype.startswith('"') and datatype.endswith('"')
|
|
207
|
+
|
|
208
|
+
raw: dict = {}
|
|
209
|
+
accessibility = elem.get("Accessibility")
|
|
210
|
+
if accessibility is not None:
|
|
211
|
+
raw["accessibility"] = accessibility
|
|
212
|
+
extra = _extra_attrs(elem, _MEMBER_KNOWN_ATTRS)
|
|
213
|
+
if extra:
|
|
214
|
+
raw["attrs"] = extra
|
|
215
|
+
attr_list = _child(elem, "AttributeList")
|
|
216
|
+
if attr_list is not None:
|
|
217
|
+
bool_attrs = {
|
|
218
|
+
ba.get("Name"): (ba.text or "").strip()
|
|
219
|
+
for ba in _children(attr_list, "BooleanAttribute")
|
|
220
|
+
}
|
|
221
|
+
if bool_attrs:
|
|
222
|
+
raw["boolean_attributes"] = bool_attrs
|
|
223
|
+
|
|
224
|
+
return model.Member(
|
|
225
|
+
name=elem.get("Name", ""),
|
|
226
|
+
datatype=datatype,
|
|
227
|
+
is_udt=is_udt,
|
|
228
|
+
version=elem.get("Version"),
|
|
229
|
+
start_value=_nz(_child_text(elem, "StartValue")),
|
|
230
|
+
remanence=elem.get("Remanence"),
|
|
231
|
+
comment=_inline_comment(_child(elem, "Comment")),
|
|
232
|
+
children=[_parse_member(c) for c in _children(elem, "Member")],
|
|
233
|
+
raw=raw,
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
# --------------------------------------------------------------------------- #
|
|
238
|
+
# Compile units / networks #
|
|
239
|
+
# --------------------------------------------------------------------------- #
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def _parse_compile_unit(elem: ET.Element, index: int) -> model.Network:
|
|
243
|
+
attrs = _child(elem, "AttributeList")
|
|
244
|
+
objlist = _child(elem, "ObjectList")
|
|
245
|
+
|
|
246
|
+
language = _parse_language(_child_text(attrs, "ProgrammingLanguage"))
|
|
247
|
+
title, comment = (_titles_and_comments(objlist) if objlist is not None else (None, None))
|
|
248
|
+
source = _parse_network_source(_child(attrs, "NetworkSource"), language)
|
|
249
|
+
|
|
250
|
+
return model.Network(
|
|
251
|
+
index=index,
|
|
252
|
+
language=language,
|
|
253
|
+
title=title,
|
|
254
|
+
comment=comment,
|
|
255
|
+
source=source,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _parse_network_source(elem: ET.Element | None, language: model.Language):
|
|
260
|
+
"""NetworkSource -> FlgNet | StructuredText | RawSource | None.
|
|
261
|
+
|
|
262
|
+
An empty ``<NetworkSource />`` (no element children) is a blank network and
|
|
263
|
+
resolves to None.
|
|
264
|
+
"""
|
|
265
|
+
if elem is None:
|
|
266
|
+
return None
|
|
267
|
+
container = _first_element_child(elem)
|
|
268
|
+
if container is None:
|
|
269
|
+
return None # empty network
|
|
270
|
+
|
|
271
|
+
local = _ln(container.tag)
|
|
272
|
+
if local == "FlgNet":
|
|
273
|
+
return _parse_flgnet(container)
|
|
274
|
+
if local == "StructuredText":
|
|
275
|
+
return _parse_structured_text(container)
|
|
276
|
+
# STL (StatementList) and GRAPH (Graph) are parsed losslessly but not
|
|
277
|
+
# rendered in v0 — retain the element tree so nothing is dropped.
|
|
278
|
+
return model.RawSource(language=language, element=container)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
# --------------------------------------------------------------------------- #
|
|
282
|
+
# FlgNet (LAD/FBD graph) #
|
|
283
|
+
# --------------------------------------------------------------------------- #
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_flgnet(elem: ET.Element) -> model.FlgNet:
|
|
287
|
+
net = model.FlgNet()
|
|
288
|
+
|
|
289
|
+
parts = _child(elem, "Parts")
|
|
290
|
+
if parts is not None:
|
|
291
|
+
for child in parts:
|
|
292
|
+
local = _ln(child.tag)
|
|
293
|
+
if local == "Access":
|
|
294
|
+
access = _parse_access(child)
|
|
295
|
+
net.accesses[access.uid] = access
|
|
296
|
+
elif local == "Part":
|
|
297
|
+
part = _parse_part(child)
|
|
298
|
+
net.parts[part.uid] = part
|
|
299
|
+
elif local == "Call":
|
|
300
|
+
call = _parse_call(child)
|
|
301
|
+
net.calls[call.uid] = call
|
|
302
|
+
|
|
303
|
+
labels = _child(elem, "Labels")
|
|
304
|
+
if labels is not None:
|
|
305
|
+
for decl in _children(labels, "LabelDeclaration"):
|
|
306
|
+
label_el = _child(decl, "Label")
|
|
307
|
+
net.labels.append(
|
|
308
|
+
model.Label(
|
|
309
|
+
uid=decl.get("UId", ""),
|
|
310
|
+
name=(label_el.get("Name", "") if label_el is not None else ""),
|
|
311
|
+
comment=_inline_comment(_child(decl, "Comment")),
|
|
312
|
+
)
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
wires = _child(elem, "Wires")
|
|
316
|
+
if wires is not None:
|
|
317
|
+
for wire in _children(wires, "Wire"):
|
|
318
|
+
net.wires.append(_parse_wire(wire))
|
|
319
|
+
|
|
320
|
+
return net
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
_ACCESS_KNOWN_ATTRS = {"Scope", "UId"}
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _parse_access(elem: ET.Element) -> model.Access:
|
|
327
|
+
operand: model.Operand = None
|
|
328
|
+
raw: dict = {}
|
|
329
|
+
|
|
330
|
+
for child in elem:
|
|
331
|
+
local = _ln(child.tag)
|
|
332
|
+
if local == "Symbol":
|
|
333
|
+
operand = _parse_symbol(child)
|
|
334
|
+
elif local == "Constant":
|
|
335
|
+
operand = _parse_constant(child)
|
|
336
|
+
elif local == "Address":
|
|
337
|
+
operand = _parse_address(child)
|
|
338
|
+
elif local == "Comment":
|
|
339
|
+
continue
|
|
340
|
+
else:
|
|
341
|
+
# Expression / CallInfo / Instruction / DataType / Reference / ...
|
|
342
|
+
# are not LAD/FBD operands; round-trip them via raw rather than raise.
|
|
343
|
+
raw.setdefault("children", {})[local] = child
|
|
344
|
+
|
|
345
|
+
extra = _extra_attrs(elem, _ACCESS_KNOWN_ATTRS)
|
|
346
|
+
if extra:
|
|
347
|
+
raw["attrs"] = extra
|
|
348
|
+
|
|
349
|
+
return model.Access(
|
|
350
|
+
uid=elem.get("UId", ""),
|
|
351
|
+
scope=elem.get("Scope", ""),
|
|
352
|
+
operand=operand,
|
|
353
|
+
raw=raw,
|
|
354
|
+
)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def _parse_symbol(elem: ET.Element) -> model.Symbol:
|
|
358
|
+
return model.Symbol(components=[_parse_component(c) for c in _children(elem, "Component")])
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
def _parse_component(elem: ET.Element) -> model.Component:
|
|
362
|
+
slice_access = elem.get("SliceAccessModifier")
|
|
363
|
+
if slice_access in (None, "undef"):
|
|
364
|
+
slice_access = None
|
|
365
|
+
return model.Component(
|
|
366
|
+
name=elem.get("Name", ""),
|
|
367
|
+
slice_access=slice_access,
|
|
368
|
+
access_modifier=elem.get("AccessModifier", "None"),
|
|
369
|
+
simple_access_modifier=elem.get("SimpleAccessModifier", "None"),
|
|
370
|
+
indices=[_parse_access(a) for a in _children(elem, "Access")],
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def _parse_constant(elem: ET.Element) -> model.Constant:
|
|
375
|
+
type_el = _child(elem, "ConstantType")
|
|
376
|
+
value_el = _child(elem, "ConstantValue")
|
|
377
|
+
raw: dict = {}
|
|
378
|
+
if type_el is not None and _is_true(type_el.get("Informative")):
|
|
379
|
+
raw["type_informative"] = True
|
|
380
|
+
if value_el is not None and _is_true(value_el.get("Informative")):
|
|
381
|
+
raw["value_informative"] = True
|
|
382
|
+
fmt = {
|
|
383
|
+
sa.get("Name"): sa.text
|
|
384
|
+
for sa in _children(elem, "StringAttribute")
|
|
385
|
+
}
|
|
386
|
+
if fmt:
|
|
387
|
+
raw["format"] = fmt
|
|
388
|
+
return model.Constant(
|
|
389
|
+
type=type_el.text if type_el is not None else None,
|
|
390
|
+
value=value_el.text if value_el is not None else None,
|
|
391
|
+
name=elem.get("Name"),
|
|
392
|
+
raw=raw,
|
|
393
|
+
)
|
|
394
|
+
|
|
395
|
+
|
|
396
|
+
def _parse_address(elem: ET.Element) -> model.Address:
|
|
397
|
+
return model.Address(
|
|
398
|
+
area=elem.get("Area", ""),
|
|
399
|
+
type=elem.get("Type"),
|
|
400
|
+
bit_offset=_int_or_none(elem.get("BitOffset")),
|
|
401
|
+
block_number=_int_or_none(elem.get("BlockNumber")),
|
|
402
|
+
)
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
_PART_KNOWN_ATTRS = {"Name", "UId", "DisabledENO", "Version"}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def _parse_part(elem: ET.Element) -> model.Part:
|
|
409
|
+
template_values: list[model.TemplateValue] = []
|
|
410
|
+
negated_pins: list[str] = []
|
|
411
|
+
invisible_pins: list[str] = []
|
|
412
|
+
instance: model.Instance | None = None
|
|
413
|
+
equation: str | None = None
|
|
414
|
+
comment: str | None = None
|
|
415
|
+
raw: dict = {}
|
|
416
|
+
|
|
417
|
+
for child in elem:
|
|
418
|
+
local = _ln(child.tag)
|
|
419
|
+
if local == "TemplateValue":
|
|
420
|
+
template_values.append(
|
|
421
|
+
model.TemplateValue(
|
|
422
|
+
name=child.get("Name", ""),
|
|
423
|
+
kind=child.get("Type", ""),
|
|
424
|
+
value=child.text,
|
|
425
|
+
)
|
|
426
|
+
)
|
|
427
|
+
elif local == "Negated":
|
|
428
|
+
negated_pins.append(child.get("Name", ""))
|
|
429
|
+
elif local == "Invisible":
|
|
430
|
+
invisible_pins.append(child.get("Name", ""))
|
|
431
|
+
elif local == "Instance":
|
|
432
|
+
instance = _parse_instance(child)
|
|
433
|
+
elif local == "Equation":
|
|
434
|
+
equation = child.text
|
|
435
|
+
elif local == "Comment":
|
|
436
|
+
comment = _inline_comment(child)
|
|
437
|
+
elif local == "AutomaticTyped":
|
|
438
|
+
raw.setdefault("automatic_typed", []).append(child.get("Name"))
|
|
439
|
+
else:
|
|
440
|
+
raw.setdefault("children", {})[local] = child
|
|
441
|
+
|
|
442
|
+
extra = _extra_attrs(elem, _PART_KNOWN_ATTRS)
|
|
443
|
+
if extra:
|
|
444
|
+
raw["attrs"] = extra
|
|
445
|
+
|
|
446
|
+
return model.Part(
|
|
447
|
+
uid=elem.get("UId", ""),
|
|
448
|
+
name=elem.get("Name", ""),
|
|
449
|
+
disabled_eno=_is_true(elem.get("DisabledENO")),
|
|
450
|
+
version=elem.get("Version"),
|
|
451
|
+
template_values=template_values,
|
|
452
|
+
negated_pins=negated_pins,
|
|
453
|
+
invisible_pins=invisible_pins,
|
|
454
|
+
instance=instance,
|
|
455
|
+
equation=equation,
|
|
456
|
+
comment=comment,
|
|
457
|
+
raw=raw,
|
|
458
|
+
)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _parse_instance(elem: ET.Element) -> model.Instance:
|
|
462
|
+
return model.Instance(
|
|
463
|
+
scope=elem.get("Scope", ""),
|
|
464
|
+
components=[_parse_component(c) for c in _children(elem, "Component")],
|
|
465
|
+
)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
def _parse_call(elem: ET.Element) -> model.Call:
|
|
469
|
+
info = _child(elem, "CallInfo")
|
|
470
|
+
name = ""
|
|
471
|
+
block_type = ""
|
|
472
|
+
instance: model.Instance | None = None
|
|
473
|
+
parameters: list[model.Parameter] = []
|
|
474
|
+
|
|
475
|
+
if info is not None:
|
|
476
|
+
name = info.get("Name", "")
|
|
477
|
+
block_type = info.get("BlockType", "")
|
|
478
|
+
for child in info:
|
|
479
|
+
local = _ln(child.tag)
|
|
480
|
+
if local == "Parameter":
|
|
481
|
+
parameters.append(
|
|
482
|
+
model.Parameter(
|
|
483
|
+
name=child.get("Name", ""),
|
|
484
|
+
section=child.get("Section", ""),
|
|
485
|
+
type=child.get("Type"),
|
|
486
|
+
informative=_is_true(child.get("Informative")),
|
|
487
|
+
)
|
|
488
|
+
)
|
|
489
|
+
elif local == "Instance":
|
|
490
|
+
instance = _parse_instance(child)
|
|
491
|
+
|
|
492
|
+
return model.Call(
|
|
493
|
+
uid=elem.get("UId", ""),
|
|
494
|
+
name=name,
|
|
495
|
+
block_type=block_type,
|
|
496
|
+
instance=instance,
|
|
497
|
+
parameters=parameters,
|
|
498
|
+
comment=_inline_comment(_child(elem, "Comment")),
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
_ENDPOINT_KINDS = {
|
|
503
|
+
"Powerrail": model.EndpointKind.POWERRAIL,
|
|
504
|
+
"IdentCon": model.EndpointKind.IDENT_CON,
|
|
505
|
+
"NameCon": model.EndpointKind.NAME_CON,
|
|
506
|
+
"OpenCon": model.EndpointKind.OPEN_CON,
|
|
507
|
+
"Openbranch": model.EndpointKind.OPEN_BRANCH,
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _parse_wire(elem: ET.Element) -> model.Wire:
|
|
512
|
+
endpoints: list[model.Endpoint] = []
|
|
513
|
+
for child in elem:
|
|
514
|
+
kind = _ENDPOINT_KINDS.get(_ln(child.tag))
|
|
515
|
+
if kind is None:
|
|
516
|
+
continue
|
|
517
|
+
endpoints.append(
|
|
518
|
+
model.Endpoint(kind=kind, uid=child.get("UId"), pin=child.get("Name"))
|
|
519
|
+
)
|
|
520
|
+
return model.Wire(uid=elem.get("UId", ""), endpoints=endpoints)
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# --------------------------------------------------------------------------- #
|
|
524
|
+
# StructuredText (SCL tokenised AST) — parsed losslessly, reconstructed later #
|
|
525
|
+
# --------------------------------------------------------------------------- #
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _parse_structured_text(elem: ET.Element) -> model.StructuredText:
|
|
529
|
+
"""Interleaved Access/Token/Text/comment stream.
|
|
530
|
+
|
|
531
|
+
Access nodes are parsed into model.Access (so operand.render and the xref
|
|
532
|
+
work); every other token is retained as its raw element so scl_reconstruct
|
|
533
|
+
(Phase 2) can rebuild the source verbatim. Nothing is dropped.
|
|
534
|
+
"""
|
|
535
|
+
items: list[object] = []
|
|
536
|
+
for child in elem:
|
|
537
|
+
if _ln(child.tag) == "Access":
|
|
538
|
+
items.append(_parse_access(child))
|
|
539
|
+
else:
|
|
540
|
+
items.append(child)
|
|
541
|
+
return model.StructuredText(items=items)
|
|
542
|
+
|
|
543
|
+
|
|
544
|
+
# --------------------------------------------------------------------------- #
|
|
545
|
+
# Multilingual text (block / network titles and comments) #
|
|
546
|
+
# --------------------------------------------------------------------------- #
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def _titles_and_comments(objlist: ET.Element) -> tuple[str | None, str | None]:
|
|
550
|
+
title: str | None = None
|
|
551
|
+
comment: str | None = None
|
|
552
|
+
for mlt in _children(objlist, "MultilingualText"):
|
|
553
|
+
composition = mlt.get("CompositionName")
|
|
554
|
+
text = _multilingual_text(mlt)
|
|
555
|
+
if composition == "Title":
|
|
556
|
+
title = text
|
|
557
|
+
elif composition == "Comment":
|
|
558
|
+
comment = text
|
|
559
|
+
return title, comment
|
|
560
|
+
|
|
561
|
+
|
|
562
|
+
def _multilingual_text(mlt: ET.Element) -> str | None:
|
|
563
|
+
"""Extract the en-US text from a MultilingualText, falling back to the first.
|
|
564
|
+
|
|
565
|
+
MultilingualText > ObjectList > MultilingualTextItem > AttributeList >
|
|
566
|
+
{Culture, Text}. Empty/whitespace text resolves to None.
|
|
567
|
+
"""
|
|
568
|
+
objlist = _child(mlt, "ObjectList")
|
|
569
|
+
if objlist is None:
|
|
570
|
+
return None
|
|
571
|
+
fallback: str | None = None
|
|
572
|
+
for item in _children(objlist, "MultilingualTextItem"):
|
|
573
|
+
attrs = _child(item, "AttributeList")
|
|
574
|
+
if attrs is None:
|
|
575
|
+
continue
|
|
576
|
+
culture = (_child_text(attrs, "Culture") or "").strip()
|
|
577
|
+
text = _nz(_child_text(attrs, "Text"))
|
|
578
|
+
if culture == "en-US":
|
|
579
|
+
return text
|
|
580
|
+
if fallback is None:
|
|
581
|
+
fallback = text
|
|
582
|
+
return fallback
|
|
583
|
+
|
|
584
|
+
|
|
585
|
+
def _inline_comment(elem: ET.Element | None) -> str | None:
|
|
586
|
+
"""Inline Part/Call/Member Comment: <Comment><MultiLanguageText .../></Comment>."""
|
|
587
|
+
if elem is None:
|
|
588
|
+
return None
|
|
589
|
+
fallback: str | None = None
|
|
590
|
+
for mlt in _children(elem, "MultiLanguageText"):
|
|
591
|
+
text = _nz(mlt.text)
|
|
592
|
+
if mlt.get("Lang") == "en-US":
|
|
593
|
+
return text
|
|
594
|
+
if fallback is None:
|
|
595
|
+
fallback = text
|
|
596
|
+
return fallback
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Reconstruct an SCL network from its tokenised AST.
|
|
2
|
+
|
|
3
|
+
SCL networks do not *fold* — they are already textual, just stored as an ordered,
|
|
4
|
+
interleaved stream of Access / Token / comment elements (SIMATICML_READING_GUIDE.md
|
|
5
|
+
"SCL Is a Tokenized AST"). Reconstruction is ordered concatenation, reusing
|
|
6
|
+
operand.render for the Access elements. This is a distinct operation from graph
|
|
7
|
+
folding, hence a separate module.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from xml.etree import ElementTree as ET
|
|
13
|
+
|
|
14
|
+
from . import model, operand
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def reconstruct(st: model.StructuredText) -> str:
|
|
18
|
+
"""model.StructuredText -> SCL source text.
|
|
19
|
+
|
|
20
|
+
parse._parse_structured_text stores Access uses as model.Access (so operand
|
|
21
|
+
rendering and the xref work) and every other token as its raw element. We
|
|
22
|
+
walk that stream in order and concatenate: Tokens contribute their literal
|
|
23
|
+
text, Access nodes their rendered operand, comments their (wrapped) content.
|
|
24
|
+
"""
|
|
25
|
+
out: list[str] = []
|
|
26
|
+
for item in st.items:
|
|
27
|
+
if isinstance(item, model.Access):
|
|
28
|
+
out.append(operand.render(item))
|
|
29
|
+
elif isinstance(item, ET.Element):
|
|
30
|
+
out.append(_render_element(item))
|
|
31
|
+
else: # defensive: anything else, stringify
|
|
32
|
+
out.append(str(item))
|
|
33
|
+
return "".join(out).strip()
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _local(tag: object) -> str:
|
|
37
|
+
return tag.rsplit("}", 1)[-1] if isinstance(tag, str) else ""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _render_element(elem: ET.Element) -> str:
|
|
41
|
+
local = _local(elem.tag)
|
|
42
|
+
|
|
43
|
+
if local == "Token":
|
|
44
|
+
return elem.get("Text", "")
|
|
45
|
+
|
|
46
|
+
if local in ("LineComment", "Comment", "Comment_G"):
|
|
47
|
+
return _render_comment(elem)
|
|
48
|
+
|
|
49
|
+
if local == "Text":
|
|
50
|
+
return elem.text or ""
|
|
51
|
+
|
|
52
|
+
if local == "Access":
|
|
53
|
+
# A raw (unparsed) Access — e.g. nested inside an Expression. Recurse so
|
|
54
|
+
# its inner Token/Access stream is still emitted rather than dropped.
|
|
55
|
+
return "".join(_render_element(c) for c in elem)
|
|
56
|
+
|
|
57
|
+
if local == "Parameter":
|
|
58
|
+
# Named call parameter inside an SCL call: "name := <wired value>".
|
|
59
|
+
name = elem.get("Name", "")
|
|
60
|
+
inner = "".join(_render_element(c) for c in elem)
|
|
61
|
+
return f"{name}{inner}" if inner else name
|
|
62
|
+
|
|
63
|
+
# Unknown token type: keep whatever text it carries so nothing is lost.
|
|
64
|
+
text = "".join(elem.itertext())
|
|
65
|
+
return text
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _render_comment(elem: ET.Element) -> str:
|
|
69
|
+
"""Render an SCL comment. Multi-line content becomes a (* ... *) block."""
|
|
70
|
+
text = "".join(elem.itertext())
|
|
71
|
+
if not text.strip():
|
|
72
|
+
return ""
|
|
73
|
+
if "\n" in text.strip():
|
|
74
|
+
return f"(*{text}*)" if text.startswith("\n") else f"(*\n{text}\n*)"
|
|
75
|
+
return f"// {text.strip()}"
|