lvxml 0.2.1__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.
lvxml/_encode.py ADDED
@@ -0,0 +1,158 @@
1
+ """Render native Python values as LabVIEW Unflatten-From-XML text, driven by a schema."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._dump import dump
6
+ from ._errors import LVXMLError
7
+ from ._node import Node
8
+ from ._numbers import check_range, format_complex, format_float
9
+ from ._schema import (
10
+ COMPLEX_TAGS,
11
+ FLOAT_TAGS,
12
+ INT_RANGES,
13
+ TEXT_TAGS,
14
+ Array,
15
+ Cluster,
16
+ LVEnum,
17
+ Schema,
18
+ Value,
19
+ )
20
+
21
+
22
+ def to_lv_xml(value: Value, schema: Schema, name: str = "") -> str:
23
+ """Render `value` as LabVIEW flattened XML matching `schema`, indented for reading."""
24
+ return dump(to_node(value, schema, name), newline="\n", indent=" ")
25
+
26
+
27
+ def to_node(value: Value, schema: Schema, name: str = "") -> Node:
28
+ """Build the typed `Node` for `value` under `schema`, checking every type and range."""
29
+ try:
30
+ return _encode(value, schema, name, name or "value")
31
+ except RecursionError:
32
+ raise LVXMLError("the value is nested too deeply to write") from None
33
+
34
+
35
+ def _encode(value: Value, schema: Schema, name: str, path: str) -> Node:
36
+ if isinstance(schema, Array):
37
+ return _encode_array(value, schema, name, path)
38
+ if isinstance(schema, Cluster):
39
+ return _encode_cluster(value, schema, name, path)
40
+ if not isinstance(schema, (str, LVEnum)):
41
+ kind = type(schema).__name__
42
+ raise TypeError(f"a schema is a tag string, Array, Cluster or LVEnum, not {kind}")
43
+ if isinstance(schema, LVEnum):
44
+ return Node(
45
+ schema.tag, name, val=str(_enum_index(value, schema, path)), choices=schema.choices
46
+ )
47
+ return Node(schema, name, val=_scalar_text(value, schema, path))
48
+
49
+
50
+ def _kind(value: Value) -> str:
51
+ return type(value).__name__
52
+
53
+
54
+ def _scalar_text(value: Value, tag: str, path: str) -> str:
55
+ try:
56
+ if tag in FLOAT_TAGS:
57
+ if isinstance(value, bool) or not isinstance(value, (int, float)):
58
+ raise LVXMLError(f"{tag} expects a number, got {_kind(value)}", path)
59
+ return format_float(float(value), tag)
60
+ if tag in COMPLEX_TAGS:
61
+ if isinstance(value, bool) or not isinstance(value, (int, float, complex)):
62
+ raise LVXMLError(f"{tag} expects a number, got {_kind(value)}", path)
63
+ return format_complex(complex(value), tag)
64
+ if tag in INT_RANGES:
65
+ if isinstance(value, bool) or not isinstance(value, int):
66
+ raise LVXMLError(f"{tag} expects an int, got {_kind(value)}", path)
67
+ check_range(value, tag)
68
+ return str(value)
69
+ except (ValueError, OverflowError) as exc:
70
+ if isinstance(exc, LVXMLError):
71
+ raise
72
+ raise LVXMLError(str(exc), path) from exc
73
+ if tag == "Boolean":
74
+ if not isinstance(value, bool):
75
+ raise LVXMLError(f"Boolean expects a bool, got {_kind(value)}", path)
76
+ return "1" if value else "0"
77
+ if tag in TEXT_TAGS:
78
+ if not isinstance(value, str):
79
+ raise LVXMLError(f"{tag} expects a str, got {_kind(value)}", path)
80
+ return value
81
+ raise LVXMLError(f"unknown or unsupported LabVIEW type tag {tag!r}", path)
82
+
83
+
84
+ def _enum_index(value: Value, schema: LVEnum, path: str) -> int:
85
+ if isinstance(value, str):
86
+ if value not in schema.choices:
87
+ raise LVXMLError(f"{value!r} is not one of the choices {list(schema.choices)}", path)
88
+ return schema.choices.index(value)
89
+ if isinstance(value, bool) or not isinstance(value, int):
90
+ raise LVXMLError(f"{schema.tag} expects a choice label or index, got {_kind(value)}", path)
91
+ if not 0 <= value < len(schema.choices):
92
+ raise LVXMLError(f"enum index {value} is outside 0..{len(schema.choices) - 1}", path)
93
+ return value
94
+
95
+
96
+ def _encode_cluster(value: Value, schema: Cluster, name: str, path: str) -> Node:
97
+ if not isinstance(value, dict):
98
+ raise LVXMLError(f"Cluster expects a dict, got {_kind(value)}", path)
99
+ extra = set(value) - set(schema.fields)
100
+ if extra:
101
+ raise LVXMLError(f"cluster value has unknown fields {sorted(extra)}", path)
102
+ items: list[Node] = []
103
+ for field_name, field_schema in schema.fields.items():
104
+ if field_name not in value:
105
+ raise LVXMLError(f"missing cluster field {field_name!r}", path)
106
+ items.append(_encode(value[field_name], field_schema, field_name, f"{path}.{field_name}"))
107
+ return Node("Cluster", name, num_elts=len(items), items=tuple(items))
108
+
109
+
110
+ def _encode_array(value: Value, schema: Array, name: str, path: str) -> Node:
111
+ dims: list[int] = []
112
+ flat = _flatten(value, schema.ndim, dims, path)
113
+ # An empty array still carries one default element, so the element type is visible.
114
+ if not flat:
115
+ flat = [_default(schema.element)]
116
+ items = tuple(_encode(item, schema.element, "", f"{path}[{i}]") for i, item in enumerate(flat))
117
+ return Node("Array", name, dims=tuple(dims), items=items)
118
+
119
+
120
+ def _flatten(value: Value, ndim: int, dims: list[int], path: str) -> list[Value]:
121
+ """Flatten a nested list row-major, recording its shape and rejecting ragged rows."""
122
+ if not isinstance(value, list):
123
+ raise LVXMLError(f"Array expects a list, got {_kind(value)}", path)
124
+ dims.append(len(value))
125
+ if ndim == 1:
126
+ return list(value)
127
+ if not value:
128
+ dims.extend([0] * (ndim - 1))
129
+ return []
130
+ rows: list[list[Value]] = []
131
+ for index, row in enumerate(value):
132
+ row_dims: list[int] = []
133
+ rows.append(_flatten(row, ndim - 1, row_dims, f"{path}[{index}]"))
134
+ if index == 0:
135
+ dims.extend(row_dims)
136
+ elif row_dims != dims[len(dims) - len(row_dims) :]:
137
+ raise LVXMLError(f"ragged array: row {index} has shape {row_dims}", path)
138
+ return [item for row in rows for item in row]
139
+
140
+
141
+ def _default(schema: Schema) -> Value:
142
+ if isinstance(schema, Array):
143
+ return []
144
+ if isinstance(schema, Cluster):
145
+ return {name: _default(sub) for name, sub in schema.fields.items()}
146
+ if isinstance(schema, LVEnum):
147
+ return schema.choices[0]
148
+ if schema in FLOAT_TAGS:
149
+ return 0.0
150
+ if schema in COMPLEX_TAGS:
151
+ return 0j
152
+ if schema in INT_RANGES:
153
+ return 0
154
+ if schema == "Boolean":
155
+ return False
156
+ if schema in TEXT_TAGS:
157
+ return ""
158
+ raise LVXMLError(f"unknown or unsupported LabVIEW type tag {schema!r}")
lvxml/_errors.py ADDED
@@ -0,0 +1,16 @@
1
+ """The single error type raised for malformed or unsupported LabVIEW XML."""
2
+
3
+ from __future__ import annotations
4
+
5
+
6
+ class LVXMLError(ValueError):
7
+ """Raised when LabVIEW XML is malformed or falls outside the covered subset.
8
+
9
+ `path` locates the offending element, for example ``LVData/Cluster[pt]/DBL[x]``, and
10
+ is empty when the problem has no single location.
11
+ """
12
+
13
+ def __init__(self, message: str, path: str = "") -> None:
14
+ super().__init__(f"{path}: {message}" if path else message)
15
+ self.message = message
16
+ self.path = path
lvxml/_load.py ADDED
@@ -0,0 +1,59 @@
1
+ """Turn text or bytes into an XML element tree, keeping what a plain parse would lose."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from xml.etree import ElementTree as ET
7
+
8
+ from ._errors import LVXMLError
9
+
10
+ _MARKUP = re.compile(r"(<[^>]*>)")
11
+ _DECLARATION = re.compile(r"\A?\s*<\?xml\b[^>]*\?>")
12
+ _ENCODING = re.compile(rb"\A\s*<\?xml\b[^>]*\bencoding\s*=\s*[\"']([A-Za-z0-9._-]+)[\"']")
13
+
14
+
15
+ def decode(data: str | bytes) -> str:
16
+ """Return `data` as text, decoding bytes by their XML declaration (default UTF-8)."""
17
+ if isinstance(data, str):
18
+ return data
19
+ if not isinstance(data, (bytes, bytearray)):
20
+ raise TypeError(f"expected str or bytes, got {type(data).__name__}")
21
+ raw = bytes(data)
22
+ if raw.startswith(b"\xef\xbb\xbf"):
23
+ raw, declared = raw[3:], "utf-8"
24
+ else:
25
+ match = _ENCODING.match(raw)
26
+ declared = match.group(1).decode("ascii") if match else "utf-8"
27
+ try:
28
+ return raw.decode(declared)
29
+ except (LookupError, UnicodeDecodeError) as exc:
30
+ raise LVXMLError(f"cannot decode the input as {declared}: {exc}") from exc
31
+
32
+
33
+ def load(text: str) -> ET.Element:
34
+ """Parse `text` into an element tree, refusing what LabVIEW XML never contains."""
35
+ if "<!DOCTYPE" in text:
36
+ raise LVXMLError("a DOCTYPE is not part of LabVIEW XML")
37
+ if "<![CDATA[" in text:
38
+ raise LVXMLError("CDATA sections are not supported; LabVIEW escapes text instead")
39
+ # The text is already decoded, so an encoding named in the declaration must not apply.
40
+ body = _DECLARATION.sub("", text, count=1)
41
+ try:
42
+ return ET.fromstring(_keep_carriage_returns(body))
43
+ except ET.ParseError as exc:
44
+ raise LVXMLError(f"invalid XML: {exc}") from exc
45
+
46
+
47
+ def _keep_carriage_returns(text: str) -> str:
48
+ """Encode CRs inside the root element so the parser does not fold them into LFs."""
49
+ if "\r" not in text:
50
+ return text
51
+ parts = _MARKUP.split(text)
52
+ tags = [i for i in range(1, len(parts), 2) if not parts[i].startswith(("<?", "<!"))]
53
+ if not tags:
54
+ return text
55
+ first, last = tags[0], tags[-1]
56
+ return "".join(
57
+ part.replace("\r", "&#13;") if first < i < last and i % 2 == 0 else part
58
+ for i, part in enumerate(parts)
59
+ )
lvxml/_node.py ADDED
@@ -0,0 +1,85 @@
1
+ """The lossless typed tree: one `Node` per LabVIEW element, wrapped in a `Document`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ # The namespace LabVIEW's XML file writer puts on <LVData>.
8
+ LVDATA_NAMESPACE = "http://www.ni.com/LVData"
9
+
10
+
11
+ @dataclass(frozen=True)
12
+ class Node:
13
+ """One LabVIEW value element, keeping every piece of the XML.
14
+
15
+ `val` holds the raw ``<Val>`` text exactly as written (``None`` for containers),
16
+ `choices` an enum's ``<Choice>`` labels, `dims` an array's ``<Dimsize>`` values,
17
+ `num_elts` a cluster's ``<NumElts>``, and `items` the child value elements of an
18
+ array, cluster, variant, timestamp or waveform. `extra` keeps a ``Refnum``'s optional
19
+ ``RefKind``, ``RefType``, ``ActiveXType.GUID``, ``ActiveXType.Name`` and
20
+ ``DotNetType.Name`` fields in document order. A ``Version`` header inside
21
+ ``<LVData>`` is a node with tag ``"Version"`` and its text in `val`.
22
+ """
23
+
24
+ tag: str
25
+ name: str = ""
26
+ val: str | None = None
27
+ choices: tuple[str, ...] = ()
28
+ dims: tuple[int, ...] = ()
29
+ num_elts: int | None = None
30
+ items: tuple[Node, ...] = ()
31
+ extra: tuple[tuple[str, str], ...] = ()
32
+
33
+ def to_dict(self) -> dict[str, object]:
34
+ """Return a JSON-ready dict that `Node.from_dict` turns back into this node."""
35
+ from ._treejson import node_to_dict
36
+
37
+ return node_to_dict(self)
38
+
39
+ @staticmethod
40
+ def from_dict(data: object) -> Node:
41
+ """Build a node from the dict shape `to_dict` produces, checking every field."""
42
+ from ._treejson import node_from_dict
43
+
44
+ return node_from_dict(data, "")
45
+
46
+
47
+ @dataclass(frozen=True)
48
+ class Document:
49
+ """A parsed LabVIEW XML document.
50
+
51
+ `wrapped` is true for the ``<LVData>`` file form, whose `items` may include
52
+ ``Version`` nodes; otherwise the document is the bare output of Flatten To XML and
53
+ `items` holds exactly one node. `namespace` is the default XML namespace of the root
54
+ element, or ``None`` when it has none.
55
+ """
56
+
57
+ items: tuple[Node, ...]
58
+ wrapped: bool = True
59
+ namespace: str | None = LVDATA_NAMESPACE
60
+
61
+ @property
62
+ def version(self) -> str | None:
63
+ """The text of the first ``<Version>`` header, if there is one."""
64
+ for node in self.items:
65
+ if node.tag == "Version":
66
+ return node.val
67
+ return None
68
+
69
+ @property
70
+ def values(self) -> tuple[Node, ...]:
71
+ """The value nodes, leaving out ``Version`` headers."""
72
+ return tuple(node for node in self.items if node.tag != "Version")
73
+
74
+ def to_dict(self) -> dict[str, object]:
75
+ """Return a JSON-ready dict that `Document.from_dict` turns back into this one."""
76
+ from ._treejson import document_to_dict
77
+
78
+ return document_to_dict(self)
79
+
80
+ @staticmethod
81
+ def from_dict(data: object) -> Document:
82
+ """Build a document from the dict shape `to_dict` produces, checking every field."""
83
+ from ._treejson import document_from_dict
84
+
85
+ return document_from_dict(data)
lvxml/_numbers.py ADDED
@@ -0,0 +1,83 @@
1
+ """Scalar text in <Val>: parse it strictly and format it the way LabVIEW reads it back."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ import re
7
+ import struct
8
+
9
+ from ._schema import ENUM_RANGES, INT_RANGES
10
+
11
+ # A period is the only decimal separator: LabVIEW does not localize this format.
12
+ _FLOAT = re.compile(r"[+-]?(\d+\.?\d*|\.\d+)([eE][+-]?\d+)?")
13
+ _NON_FINITE = {"nan": math.nan, "inf": math.inf, "+inf": math.inf, "-inf": -math.inf}
14
+ _NON_FINITE.update({"infinity": math.inf, "+infinity": math.inf, "-infinity": -math.inf})
15
+ _INT = re.compile(r"[+-]?\d+")
16
+ # LabVIEW's run-time library formats a complex value as "<re> +<im> i".
17
+ _COMPLEX = re.compile(r"\s*(\S+)\s*\+\s*(\S+)\s*i\s*")
18
+ _TRUE = frozenset({"1", "true"})
19
+ _FALSE = frozenset({"0", "false"})
20
+
21
+
22
+ def parse_float(text: str) -> float:
23
+ """Parse a float with a period decimal point; NaN, Inf and Infinity in any case."""
24
+ stripped = text.strip()
25
+ if stripped.lower() in _NON_FINITE:
26
+ return _NON_FINITE[stripped.lower()]
27
+ if not stripped.isascii() or not _FLOAT.fullmatch(stripped):
28
+ hint = " (the decimal separator is always a period)" if "," in stripped else ""
29
+ raise ValueError(f"invalid float value {text!r}{hint}")
30
+ return float(stripped)
31
+
32
+
33
+ def parse_complex(text: str) -> complex:
34
+ """Parse LabVIEW's ``re +im i`` complex text."""
35
+ match = _COMPLEX.fullmatch(text)
36
+ if match is None:
37
+ raise ValueError(f"invalid complex value {text!r}, expected '<re> +<im> i'")
38
+ return complex(parse_float(match.group(1)), parse_float(match.group(2)))
39
+
40
+
41
+ def parse_int(text: str, tag: str) -> int:
42
+ """Parse a decimal integer and check it fits `tag`'s range (an integer or enum tag)."""
43
+ stripped = text.strip()
44
+ if not stripped.isascii() or not _INT.fullmatch(stripped):
45
+ raise ValueError(f"invalid integer value {text!r}")
46
+ value = int(stripped, 10)
47
+ check_range(value, tag)
48
+ return value
49
+
50
+
51
+ def check_range(value: int, tag: str) -> None:
52
+ low, high = INT_RANGES[tag] if tag in INT_RANGES else ENUM_RANGES[tag]
53
+ if not low <= value <= high:
54
+ raise ValueError(f"{value} is out of range for {tag} ({low}..{high})")
55
+
56
+
57
+ def parse_bool(text: str) -> bool:
58
+ """Parse a Boolean ``<Val>``: 1 or 0, also accepting true and false."""
59
+ lowered = text.strip().lower()
60
+ if lowered in _TRUE:
61
+ return True
62
+ if lowered in _FALSE:
63
+ return False
64
+ raise ValueError(f"invalid boolean value {text!r}")
65
+
66
+
67
+ def format_float(value: float, tag: str) -> str:
68
+ """Format a float with a period decimal point; non-finite as NaN, Inf or -Inf."""
69
+ if math.isnan(value):
70
+ return "NaN"
71
+ if math.isinf(value):
72
+ return "Inf" if value > 0 else "-Inf"
73
+ if tag in {"SGL", "CSG"}:
74
+ try:
75
+ struct.pack("<f", value)
76
+ except OverflowError:
77
+ raise ValueError(f"{value!r} does not fit single precision {tag}") from None
78
+ return repr(float(value))
79
+
80
+
81
+ def format_complex(value: complex, tag: str) -> str:
82
+ """Format a complex value as LabVIEW's ``re +im i``."""
83
+ return f"{format_float(value.real, tag)} +{format_float(value.imag, tag)} i"
lvxml/_parse.py ADDED
@@ -0,0 +1,168 @@
1
+ """Read LabVIEW XML text into the typed tree, enforcing the shape LVXMLSchema.xsd defines."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from xml.etree import ElementTree as ET
6
+
7
+ from ._errors import LVXMLError
8
+ from ._load import decode, load
9
+ from ._node import Document, Node
10
+ from ._schema import (
11
+ ARRAY_ITEM_TAGS,
12
+ CLUSTERED_TAGS,
13
+ ENUM_RANGES,
14
+ SIMPLE_TAGS,
15
+ VALUE_TAGS,
16
+ )
17
+
18
+ # Deeper nesting than this is refused rather than exhausting the interpreter's stack.
19
+ MAX_DEPTH = 100
20
+
21
+
22
+ def parse(text: str | bytes) -> Document:
23
+ """Parse LabVIEW Flatten-To-XML, bare or inside ``<LVData>``, into a `Document`.
24
+
25
+ Bytes are decoded by the encoding their XML declaration names, or as UTF-8.
26
+ """
27
+ root = load(decode(text))
28
+ tag, namespace = _split(root.tag)
29
+ for element in root.iter():
30
+ if _split(element.tag)[1] != namespace:
31
+ where = _split(element.tag)[0]
32
+ raise LVXMLError(f"<{where}> is not in the root element's namespace {namespace!r}")
33
+ _no_attributes(root, tag)
34
+ if tag != "LVData":
35
+ return Document((_node(root, VALUE_TAGS, "", 1),), wrapped=False, namespace=namespace)
36
+ _no_text(root.text, "LVData")
37
+ items: list[Node] = []
38
+ for child in root:
39
+ _no_text(child.tail, "LVData")
40
+ if _split(child.tag)[0] == "Version":
41
+ items.append(Node("Version", val=_leaf(child, "LVData/Version")))
42
+ else:
43
+ items.append(_node(child, VALUE_TAGS, "LVData", 1))
44
+ return Document(tuple(items), wrapped=True, namespace=namespace)
45
+
46
+
47
+ def _split(tag: str) -> tuple[str, str | None]:
48
+ if tag.startswith("{"):
49
+ namespace, local = tag[1:].split("}", 1)
50
+ return local, namespace
51
+ return tag, None
52
+
53
+
54
+ def _no_attributes(element: ET.Element, path: str) -> None:
55
+ if element.attrib:
56
+ raise LVXMLError(f"unexpected attributes {sorted(element.attrib)}", path)
57
+
58
+
59
+ def _no_text(text: str | None, path: str) -> None:
60
+ if text is not None and text.strip():
61
+ raise LVXMLError(f"unexpected text {text.strip()[:40]!r}", path)
62
+
63
+
64
+ def _leaf(element: ET.Element, path: str) -> str:
65
+ _no_attributes(element, path)
66
+ if len(element):
67
+ raise LVXMLError("expected text only, found child elements", path)
68
+ return element.text or ""
69
+
70
+
71
+ class _Children:
72
+ """Walk an element's children in order, as the schema's xsd:sequence requires."""
73
+
74
+ def __init__(self, element: ET.Element, path: str, depth: int = 0) -> None:
75
+ _no_text(element.text, path)
76
+ self.depth = depth
77
+ self.kids = list(element)
78
+ for kid in self.kids:
79
+ _no_text(kid.tail, path)
80
+ self.pos = 0
81
+ self.path = path
82
+
83
+ def peek(self) -> str | None:
84
+ return _split(self.kids[self.pos].tag)[0] if self.pos < len(self.kids) else None
85
+
86
+ def take(self, tag: str) -> str:
87
+ if self.peek() != tag:
88
+ found = f"<{self.peek()}>" if self.peek() else "the end of the element"
89
+ raise LVXMLError(f"expected <{tag}>, found {found}", self.path)
90
+ self.pos += 1
91
+ return _leaf(self.kids[self.pos - 1], f"{self.path}/{tag}")
92
+
93
+ def take_all(self, tag: str) -> list[str]:
94
+ values = [self.take(tag)]
95
+ while self.peek() == tag:
96
+ values.append(self.take(tag))
97
+ return values
98
+
99
+ def rest(self, allowed: frozenset[str]) -> tuple[Node, ...]:
100
+ nodes = tuple(_node(k, allowed, self.path, self.depth + 1) for k in self.kids[self.pos :])
101
+ self.pos = len(self.kids)
102
+ return nodes
103
+
104
+ def end(self) -> None:
105
+ if self.pos < len(self.kids):
106
+ raise LVXMLError(f"unexpected <{self.peek()}>", self.path)
107
+
108
+
109
+ def _node(element: ET.Element, allowed: frozenset[str], parent: str, depth: int) -> Node:
110
+ tag = _split(element.tag)[0]
111
+ here = f"{parent}/{tag}" if parent else tag
112
+ if depth > MAX_DEPTH:
113
+ raise LVXMLError(f"nesting deeper than {MAX_DEPTH} levels is not supported", here)
114
+ if tag not in allowed:
115
+ where = f"inside <{parent.rsplit('/', 1)[-1]}>" if parent else "as a root element"
116
+ raise LVXMLError(f"<{tag}> is not allowed {where} by LVXMLSchema.xsd", here)
117
+ _no_attributes(element, here)
118
+ kids = _Children(element, here, depth)
119
+ name = kids.take("Name")
120
+ kids.path = f"{here}[{name}]" if name else here
121
+ node = _body(tag, name, kids)
122
+ kids.end()
123
+ return node
124
+
125
+
126
+ def _body(tag: str, name: str, kids: _Children) -> Node:
127
+ if tag in SIMPLE_TAGS:
128
+ return Node(tag, name, val=kids.take("Val"))
129
+ if tag in ENUM_RANGES:
130
+ choices = tuple(kids.take_all("Choice"))
131
+ return Node(tag, name, val=kids.take("Val"), choices=choices)
132
+ if tag == "Refnum":
133
+ return Node(tag, name, val=kids.take("Val"), extra=_refnum_extra(kids))
134
+ if tag == "Array":
135
+ dims = tuple(_count(text, f"{kids.path}/Dimsize") for text in kids.take_all("Dimsize"))
136
+ return Node(tag, name, dims=dims, items=kids.rest(ARRAY_ITEM_TAGS))
137
+ if tag == "Cluster":
138
+ num_elts = _count(kids.take("NumElts"), f"{kids.path}/NumElts")
139
+ return Node(tag, name, num_elts=num_elts, items=kids.rest(VALUE_TAGS))
140
+ if tag in CLUSTERED_TAGS:
141
+ return Node(tag, name, items=kids.rest(frozenset({"Cluster"})))
142
+ return Node(tag, name, items=kids.rest(VALUE_TAGS)) # LvVariant
143
+
144
+
145
+ def _refnum_extra(kids: _Children) -> tuple[tuple[str, str], ...]:
146
+ extra: list[tuple[str, str]] = []
147
+ if kids.peek() == "RefKind":
148
+ extra.append(("RefKind", kids.take("RefKind")))
149
+ if kids.peek() == "RefType":
150
+ extra.append(("RefType", kids.take("RefType")))
151
+ for group, fields in (("ActiveXType", ("GUID", "Name")), ("DotNetType", ("Name",))):
152
+ if kids.peek() != group:
153
+ continue
154
+ sub = _Children(kids.kids[kids.pos], f"{kids.path}/{group}")
155
+ _no_attributes(kids.kids[kids.pos], sub.path)
156
+ kids.pos += 1
157
+ for index, field in enumerate(fields):
158
+ if index == 0 or sub.peek() == field:
159
+ extra.append((f"{group}.{field}", sub.take(field)))
160
+ sub.end()
161
+ return tuple(extra)
162
+
163
+
164
+ def _count(text: str, path: str) -> int:
165
+ stripped = text.strip()
166
+ if not stripped.isdigit() or not stripped.isascii():
167
+ raise LVXMLError(f"expected a non-negative integer, got {text!r}", path)
168
+ return int(stripped)
lvxml/_schema.py ADDED
@@ -0,0 +1,114 @@
1
+ """LabVIEW type tags from LVXMLSchema.xsd and the schema that pins Python values to them."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+
7
+ FLOAT_TAGS: frozenset[str] = frozenset({"SGL", "DBL", "EXT"})
8
+ COMPLEX_TAGS: frozenset[str] = frozenset({"CSG", "CDB", "CXT"})
9
+
10
+ INT_RANGES: dict[str, tuple[int, int]] = {
11
+ "I8": (-(2**7), 2**7 - 1),
12
+ "I16": (-(2**15), 2**15 - 1),
13
+ "I32": (-(2**31), 2**31 - 1),
14
+ "I64": (-(2**63), 2**63 - 1),
15
+ "U8": (0, 2**8 - 1),
16
+ "U16": (0, 2**16 - 1),
17
+ "U32": (0, 2**32 - 1),
18
+ "U64": (0, 2**64 - 1),
19
+ }
20
+
21
+ # Enum tags carry their value as an index into the <Choice> list, sized like U8/U16/U32.
22
+ ENUM_RANGES: dict[str, tuple[int, int]] = {
23
+ "EB": INT_RANGES["U8"],
24
+ "EW": INT_RANGES["U16"],
25
+ "EL": INT_RANGES["U32"],
26
+ }
27
+
28
+ # Types whose payload is free text in <Val>.
29
+ TEXT_TAGS: frozenset[str] = frozenset({"String", "Path", "DAQChannel"})
30
+
31
+ # Types built on the schema's LVDataType: exactly <Name> then <Val>.
32
+ SIMPLE_TAGS: frozenset[str] = (
33
+ FLOAT_TAGS | COMPLEX_TAGS | frozenset(INT_RANGES) | TEXT_TAGS | {"Boolean"}
34
+ )
35
+
36
+ _WAVEFORM_BASES: tuple[str, ...] = ("DBL", "I16", "SGL", "EXT", "I8", "I32", "I64")
37
+ _WAVEFORM_BASES += ("U8", "U16", "U32", "U64", "CSG", "CDB", "CXT")
38
+
39
+ # Types the schema defines as <Name> followed by any number of <Cluster> elements.
40
+ CLUSTERED_TAGS: frozenset[str] = frozenset(
41
+ {"WDT", "DynamicData", "Timestamp", "DigitalData", "DigitalWaveform"}
42
+ | {f"{base}Waveform" for base in _WAVEFORM_BASES}
43
+ )
44
+
45
+ # Every value element allowed inside <LVData>, <Cluster> and <LvVariant>.
46
+ VALUE_TAGS: frozenset[str] = (
47
+ SIMPLE_TAGS
48
+ | frozenset(ENUM_RANGES)
49
+ | CLUSTERED_TAGS
50
+ | {"Refnum", "Cluster", "Array", "LvVariant"}
51
+ )
52
+
53
+ # The schema does not allow an <Array> directly inside an <Array>.
54
+ ARRAY_ITEM_TAGS: frozenset[str] = VALUE_TAGS - {"Array"}
55
+
56
+ # Tags `to_lv_xml` accepts as a plain string schema.
57
+ SCALAR_TAGS: frozenset[str] = SIMPLE_TAGS
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Array:
62
+ """A LabVIEW array of homogeneous `element` values with `ndim` dimensions.
63
+
64
+ A one-dimensional array maps to a list, a two-dimensional one to a list of equal
65
+ length lists, and so on, in LabVIEW's row-major order.
66
+ """
67
+
68
+ element: Schema
69
+ ndim: int = 1
70
+
71
+ def __post_init__(self) -> None:
72
+ if isinstance(self.ndim, bool) or not isinstance(self.ndim, int) or self.ndim < 1:
73
+ raise ValueError(f"Array ndim must be a positive int, got {self.ndim!r}")
74
+ if isinstance(self.element, Array):
75
+ raise ValueError("an Array element cannot be an Array; wrap it in a Cluster")
76
+
77
+
78
+ @dataclass(frozen=True)
79
+ class Cluster:
80
+ """A LabVIEW cluster: named fields kept in declaration order."""
81
+
82
+ fields: dict[str, Schema]
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class LVEnum:
87
+ """A LabVIEW enum: its ordered `choices` and the `EB`, `EW` or `EL` storage tag.
88
+
89
+ Values are written as the index of a choice; a Python value may be the choice label
90
+ or its integer index. `EW` (16-bit) is LabVIEW's default enum representation.
91
+ """
92
+
93
+ choices: tuple[str, ...]
94
+ tag: str = "EW"
95
+
96
+ def __post_init__(self) -> None:
97
+ if isinstance(self.choices, str) or not all(isinstance(c, str) for c in self.choices):
98
+ raise TypeError("LVEnum choices must be a sequence of str labels, not one str")
99
+ object.__setattr__(self, "choices", tuple(self.choices))
100
+ if self.tag not in ENUM_RANGES:
101
+ raise ValueError(f"LVEnum tag must be EB, EW or EL, got {self.tag!r}")
102
+ if not self.choices:
103
+ raise ValueError("LVEnum needs at least one choice")
104
+ if len(set(self.choices)) != len(self.choices):
105
+ raise ValueError("LVEnum choices must be unique")
106
+ if len(self.choices) > ENUM_RANGES[self.tag][1] + 1:
107
+ raise ValueError(f"too many choices for {self.tag}")
108
+
109
+
110
+ # A scalar tag such as "DBL", or a compound container.
111
+ Schema = str | Array | Cluster | LVEnum
112
+
113
+ # Any value this codec maps to Python. bool precedes int because bool is an int subclass.
114
+ Value = bool | int | float | complex | str | list["Value"] | dict[str, "Value"]