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/__init__.py +32 -0
- lvxml/__main__.py +9 -0
- lvxml/_checks.py +110 -0
- lvxml/_cli.py +167 -0
- lvxml/_decode.py +109 -0
- lvxml/_dump.py +106 -0
- lvxml/_encode.py +158 -0
- lvxml/_errors.py +16 -0
- lvxml/_load.py +59 -0
- lvxml/_node.py +85 -0
- lvxml/_numbers.py +83 -0
- lvxml/_parse.py +168 -0
- lvxml/_schema.py +114 -0
- lvxml/_treejson.py +162 -0
- lvxml/py.typed +0 -0
- lvxml-0.2.1.dist-info/METADATA +254 -0
- lvxml-0.2.1.dist-info/RECORD +20 -0
- lvxml-0.2.1.dist-info/WHEEL +4 -0
- lvxml-0.2.1.dist-info/entry_points.txt +2 -0
- lvxml-0.2.1.dist-info/licenses/LICENSE +21 -0
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", " ") 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"]
|