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 ADDED
@@ -0,0 +1,32 @@
1
+ """Codec between LabVIEW's Flatten/Unflatten XML schema and native Python values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._checks import validate
6
+ from ._decode import from_lv_xml, from_lv_xml_all, to_value
7
+ from ._dump import dump
8
+ from ._encode import to_lv_xml, to_node
9
+ from ._errors import LVXMLError
10
+ from ._node import LVDATA_NAMESPACE, Document, Node
11
+ from ._parse import parse
12
+ from ._schema import Array, Cluster, LVEnum
13
+
14
+ __version__ = "0.2.1"
15
+
16
+ __all__ = [
17
+ "LVDATA_NAMESPACE",
18
+ "Array",
19
+ "Cluster",
20
+ "Document",
21
+ "LVEnum",
22
+ "LVXMLError",
23
+ "Node",
24
+ "dump",
25
+ "from_lv_xml",
26
+ "from_lv_xml_all",
27
+ "parse",
28
+ "to_lv_xml",
29
+ "to_node",
30
+ "to_value",
31
+ "validate",
32
+ ]
lvxml/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Allow ``python -m lvxml`` as an alternative to the ``lvxml`` command."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+
7
+ from ._cli import main
8
+
9
+ sys.exit(main())
lvxml/_checks.py ADDED
@@ -0,0 +1,110 @@
1
+ """Consistency rules beyond the XSD's shape: counts that agree and values that parse."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import math
6
+ from collections.abc import Iterator
7
+
8
+ from ._errors import LVXMLError
9
+ from ._node import Document, Node
10
+ from ._numbers import parse_bool, parse_complex, parse_float, parse_int
11
+ from ._parse import parse
12
+ from ._schema import COMPLEX_TAGS, ENUM_RANGES, FLOAT_TAGS, INT_RANGES
13
+
14
+ # An empty array with leading non-zero sizes (such as 1000x0) decodes to nested empty lists;
15
+ # more than this many is refused so a tiny input cannot demand unbounded memory.
16
+ MAX_EMPTY_ROWS = 100_000
17
+
18
+
19
+ def problems(node: Node) -> list[str]:
20
+ """Return what is wrong with `node` itself, not looking inside its items."""
21
+ found: list[str] = []
22
+ try:
23
+ _check_val(node)
24
+ except ValueError as exc:
25
+ found.append(str(exc))
26
+ if node.tag == "Array":
27
+ total = math.prod(node.dims)
28
+ count = len(node.items)
29
+ if total == 0 and count > 1:
30
+ found.append(f"array Dimsize {_dims(node)} is empty but holds {count} elements")
31
+ if total == 0 and _empty_rows(node.dims) > MAX_EMPTY_ROWS:
32
+ found.append(f"empty array Dimsize {_dims(node)} needs over {MAX_EMPTY_ROWS} lists")
33
+ elif total and count != total:
34
+ found.append(f"array Dimsize {_dims(node)} needs {total} elements, found {count}")
35
+ tags = {item.tag for item in node.items}
36
+ if len(tags) > 1:
37
+ found.append(f"array mixes element types {sorted(tags)}")
38
+ if node.tag == "Cluster" and node.num_elts != len(node.items):
39
+ found.append(f"cluster NumElts {node.num_elts} does not match {len(node.items)} elements")
40
+ return found
41
+
42
+
43
+ def _empty_rows(dims: tuple[int, ...]) -> int:
44
+ rows, prefix = 0, 1
45
+ for dim in dims[:-1]:
46
+ if dim == 0:
47
+ break
48
+ prefix *= dim
49
+ rows += prefix
50
+ if rows > MAX_EMPTY_ROWS:
51
+ break
52
+ return rows
53
+
54
+
55
+ def _dims(node: Node) -> str:
56
+ return "x".join(str(dim) for dim in node.dims)
57
+
58
+
59
+ def _check_val(node: Node) -> None:
60
+ text = node.val if node.val is not None else ""
61
+ if node.tag in FLOAT_TAGS:
62
+ parse_float(text)
63
+ elif node.tag in COMPLEX_TAGS:
64
+ parse_complex(text)
65
+ elif node.tag in INT_RANGES:
66
+ parse_int(text, node.tag)
67
+ elif node.tag == "Boolean":
68
+ parse_bool(text)
69
+ elif node.tag in ENUM_RANGES:
70
+ index = parse_int(text, node.tag)
71
+ if index >= len(node.choices):
72
+ raise ValueError(f"enum index {index} is past its {len(node.choices)} choices")
73
+
74
+
75
+ def child_path(parent: str, node: Node) -> str:
76
+ here = f"{parent}/{node.tag}" if parent else node.tag
77
+ return f"{here}[{node.name}]" if node.name else here
78
+
79
+
80
+ def walk(node: Node, parent: str) -> Iterator[tuple[str, Node]]:
81
+ """Yield every node under and including `node` with its path, depth first."""
82
+ path = child_path(parent, node)
83
+ yield path, node
84
+ for item in node.items:
85
+ yield from walk(item, path)
86
+
87
+
88
+ def validate(text: str | bytes) -> list[str]:
89
+ """Return every problem found in LabVIEW XML `text`; an empty list means it is valid.
90
+
91
+ A document that does not have the shape LVXMLSchema.xsd defines yields one problem,
92
+ since its structure cannot be trusted further. A well-shaped one is then checked
93
+ value by value: numbers that parse and fit their type, enum indexes inside the
94
+ choice list, and ``NumElts`` and ``Dimsize`` that match the elements present.
95
+ """
96
+ try:
97
+ document = parse(text)
98
+ except LVXMLError as exc:
99
+ return [str(exc)]
100
+ return document_problems(document)
101
+
102
+
103
+ def document_problems(document: Document) -> list[str]:
104
+ """Return every value-level problem in an already parsed `document`."""
105
+ root = "LVData" if document.wrapped else ""
106
+ found: list[str] = []
107
+ for top in document.values:
108
+ for path, node in walk(top, root):
109
+ found.extend(f"{path}: {problem}" for problem in problems(node))
110
+ return found
lvxml/_cli.py ADDED
@@ -0,0 +1,167 @@
1
+ """The `lvxml` command: convert LabVIEW XML to and from JSON, and validate it."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import codecs
7
+ import json
8
+ import math
9
+ import sys
10
+ from collections.abc import Sequence
11
+ from typing import NoReturn
12
+
13
+ from . import __version__
14
+ from ._checks import document_problems, validate
15
+ from ._decode import to_value
16
+ from ._dump import dump
17
+ from ._errors import LVXMLError
18
+ from ._node import Document
19
+ from ._numbers import format_float
20
+ from ._parse import parse
21
+
22
+ EXAMPLES = """\
23
+ examples:
24
+ echo "<I32><Name>n</Name><Val>7</Val></I32>" | lvxml to-json --values -
25
+ lvxml to-json data.xml -o data.json lossless tree, every name and raw value kept
26
+ lvxml to-json --values data.xml plain values: numbers, strings, lists, dicts
27
+ lvxml to-xml data.json -o data.xml tree JSON back to LabVIEW XML
28
+ lvxml validate data.xml other.xml check shape and values, exit 1 on any problem
29
+
30
+ A FILE of "-" reads standard input. Errors go to standard error with exit status 1.
31
+ """
32
+
33
+
34
+ class _Parser(argparse.ArgumentParser):
35
+ def error(self, message: str) -> NoReturn:
36
+ self.print_usage(sys.stderr)
37
+ self.exit(1, f"{self.prog}: error: {message}\n")
38
+
39
+
40
+ def _build_parser() -> argparse.ArgumentParser:
41
+ parser = _Parser(
42
+ prog="lvxml",
43
+ description="Read, write and check LabVIEW Flatten To XML data.",
44
+ epilog=EXAMPLES,
45
+ formatter_class=argparse.RawDescriptionHelpFormatter,
46
+ )
47
+ parser.add_argument("--version", action="version", version=f"lvxml {__version__}")
48
+ commands = parser.add_subparsers(dest="command", required=True, metavar="COMMAND")
49
+ to_json = commands.add_parser("to-json", help="convert LabVIEW XML to JSON")
50
+ to_json.add_argument("file", metavar="FILE", help="LabVIEW XML file, or - for stdin")
51
+ to_json.add_argument("--values", action="store_true", help="emit plain values, not the tree")
52
+ to_xml = commands.add_parser("to-xml", help="convert tree JSON (from to-json) to LabVIEW XML")
53
+ to_xml.add_argument("file", metavar="FILE", help="tree JSON file, or - for stdin")
54
+ to_xml.add_argument(
55
+ "--newline", choices=["crlf", "lf"], default="crlf", help="line break (default: crlf)"
56
+ )
57
+ check = commands.add_parser("validate", help="check LabVIEW XML files")
58
+ check.add_argument("files", metavar="FILE", nargs="+", help="LabVIEW XML files")
59
+ for sub in (to_json, to_xml, check):
60
+ sub.add_argument(
61
+ "--encoding", default="utf-8", help="text encoding of files (default: utf-8)"
62
+ )
63
+ for sub in (to_json, to_xml):
64
+ sub.add_argument("-o", "--output", metavar="OUT", help="write here instead of stdout")
65
+ return parser
66
+
67
+
68
+ def _read(path: str, encoding: str) -> str:
69
+ if path == "-":
70
+ data = sys.stdin.buffer.read()
71
+ else:
72
+ with open(path, "rb") as handle:
73
+ data = handle.read()
74
+ try:
75
+ return data.decode(encoding)
76
+ except UnicodeDecodeError as exc:
77
+ where = "stdin" if path == "-" else path
78
+ raise LVXMLError(f"{where} is not valid {encoding} ({exc.reason}); try --encoding") from exc
79
+
80
+
81
+ def _write(text: str, output: str | None, encoding: str) -> None:
82
+ try:
83
+ data = text.encode(encoding)
84
+ except UnicodeEncodeError as exc:
85
+ bad = exc.object[exc.start : exc.end]
86
+ raise LVXMLError(f"{bad!r} cannot be written as {encoding}; try --encoding utf-8") from exc
87
+ if output is None:
88
+ sys.stdout.buffer.write(data)
89
+ sys.stdout.buffer.flush()
90
+ else:
91
+ with open(output, "wb") as handle:
92
+ handle.write(data)
93
+
94
+
95
+ def _plain(value: object) -> object:
96
+ """Make a value strict JSON: complex as {"re", "im"}, NaN and Inf as strings."""
97
+ if isinstance(value, float) and not math.isfinite(value):
98
+ return format_float(value, "DBL")
99
+ if isinstance(value, complex):
100
+ return {"re": _plain(value.real), "im": _plain(value.imag)}
101
+ if isinstance(value, list):
102
+ return [_plain(item) for item in value]
103
+ if isinstance(value, dict):
104
+ return {key: _plain(item) for key, item in value.items()}
105
+ return value
106
+
107
+
108
+ def _to_json(args: argparse.Namespace) -> int:
109
+ document = parse(_read(args.file, args.encoding))
110
+ if args.values:
111
+ found = document_problems(document)
112
+ if found:
113
+ raise LVXMLError(found[0])
114
+ data: object = [
115
+ {"name": node.name, "type": node.tag, "value": _plain(to_value(node))}
116
+ for node in document.values
117
+ ]
118
+ else:
119
+ data = document.to_dict()
120
+ text = json.dumps(data, indent=2, ensure_ascii=False, allow_nan=False) + "\n"
121
+ _write(text, args.output, "utf-8")
122
+ return 0
123
+
124
+
125
+ def _to_xml(args: argparse.Namespace) -> int:
126
+ try:
127
+ data = json.loads(_read(args.file, args.encoding))
128
+ except json.JSONDecodeError as exc:
129
+ raise LVXMLError(f"invalid JSON: {exc}") from exc
130
+ newline = "\r\n" if args.newline == "crlf" else "\n"
131
+ _write(dump(Document.from_dict(data), newline=newline), args.output, args.encoding)
132
+ return 0
133
+
134
+
135
+ def _validate(args: argparse.Namespace) -> int:
136
+ status = 0
137
+ for path in args.files:
138
+ try:
139
+ found = validate(_read(path, args.encoding))
140
+ except (LVXMLError, OSError) as exc:
141
+ found = [str(exc)]
142
+ for problem in found:
143
+ print(f"{path}: {problem}", file=sys.stderr)
144
+ if found:
145
+ status = 1
146
+ else:
147
+ print(f"{path}: OK")
148
+ return status
149
+
150
+
151
+ def main(argv: Sequence[str] | None = None) -> int:
152
+ """Run the command line with `argv` (default: ``sys.argv[1:]``); return the exit status."""
153
+ parser = _build_parser()
154
+ args = parser.parse_args(argv)
155
+ try:
156
+ codecs.lookup(args.encoding)
157
+ except LookupError:
158
+ parser.error(f"unknown encoding {args.encoding!r}")
159
+ handlers = {"to-json": _to_json, "to-xml": _to_xml, "validate": _validate}
160
+ try:
161
+ return handlers[args.command](args)
162
+ except (LVXMLError, OSError) as exc:
163
+ print(f"lvxml: error: {exc}", file=sys.stderr)
164
+ return 1
165
+ except RecursionError:
166
+ print("lvxml: error: the input is nested too deeply", file=sys.stderr)
167
+ return 1
lvxml/_decode.py ADDED
@@ -0,0 +1,109 @@
1
+ """Turn LabVIEW Flatten-To-XML text, or a parsed tree, into native Python values."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from ._checks import child_path, problems
6
+ from ._errors import LVXMLError
7
+ from ._node import Node
8
+ from ._numbers import parse_bool, parse_complex, parse_float, parse_int
9
+ from ._parse import parse
10
+ from ._schema import COMPLEX_TAGS, ENUM_RANGES, FLOAT_TAGS, INT_RANGES, Value
11
+ from ._treejson import check_shape
12
+
13
+
14
+ def from_lv_xml(text: str | bytes) -> Value:
15
+ """Parse LabVIEW flattened XML into the Python value of its first value element.
16
+
17
+ Inside an ``<LVData>`` file wrapper the ``<Version>`` header is skipped; use
18
+ `from_lv_xml_all` when the file holds several values.
19
+ """
20
+ document = parse(text)
21
+ if not document.values:
22
+ raise LVXMLError("<LVData> holds no value")
23
+ root = "LVData" if document.wrapped else ""
24
+ return _value(document.values[0], root)
25
+
26
+
27
+ def from_lv_xml_all(text: str | bytes) -> list[Value]:
28
+ """Parse LabVIEW flattened XML into the Python values of every value element, in order."""
29
+ document = parse(text)
30
+ root = "LVData" if document.wrapped else ""
31
+ return [_value(node, root) for node in document.values]
32
+
33
+
34
+ def to_value(node: Node) -> Value:
35
+ """Convert one typed `Node` into its Python value.
36
+
37
+ Integers become ``int``, ``SGL``/``DBL``/``EXT`` ``float``, ``CSG``/``CDB``/``CXT``
38
+ ``complex``, ``Boolean`` ``bool``; ``String``, ``Path``, ``DAQChannel`` and a
39
+ ``Refnum``'s ``<Val>`` stay ``str``; an enum becomes its selected choice label. An
40
+ ``Array`` becomes a list, nested once per dimension, and a ``Cluster`` a dict keyed by
41
+ field name. ``LvVariant``, ``Timestamp``, ``WDT``, ``DynamicData``, ``DigitalData``
42
+ and the waveform elements become the list of their items' values.
43
+ """
44
+ check_shape(node, "")
45
+ try:
46
+ return _value(node, "")
47
+ except RecursionError:
48
+ raise LVXMLError("the tree is nested too deeply to convert") from None
49
+
50
+
51
+ def _value(node: Node, parent: str) -> Value:
52
+ path = child_path(parent, node)
53
+ found = problems(node)
54
+ if found:
55
+ raise LVXMLError(found[0], path)
56
+ tag, text = node.tag, node.val if node.val is not None else ""
57
+ if tag in FLOAT_TAGS:
58
+ return parse_float(text)
59
+ if tag in COMPLEX_TAGS:
60
+ return parse_complex(text)
61
+ if tag in INT_RANGES:
62
+ return parse_int(text, tag)
63
+ if tag == "Boolean":
64
+ return parse_bool(text)
65
+ if tag in ENUM_RANGES:
66
+ return node.choices[parse_int(text, tag)]
67
+ if tag == "Array":
68
+ return _array(node, path)
69
+ if tag == "Cluster":
70
+ return _cluster(node, path)
71
+ if node.items or node.val is None:
72
+ return [_value(item, path) for item in node.items]
73
+ return text
74
+
75
+
76
+ def _cluster(node: Node, path: str) -> dict[str, Value]:
77
+ fields: dict[str, Value] = {}
78
+ for item in node.items:
79
+ if item.name in fields:
80
+ raise LVXMLError(
81
+ f"duplicate field name {item.name!r} cannot map to a dict; "
82
+ "use lvxml.parse for positional access",
83
+ path,
84
+ )
85
+ fields[item.name] = _value(item, path)
86
+ return fields
87
+
88
+
89
+ def _array(node: Node, path: str) -> list[Value]:
90
+ dims = node.dims
91
+ if 0 in dims:
92
+ for item in node.items:
93
+ _value(item, path) # the default element must still be a valid value
94
+ return _empty(dims)
95
+ flat = [_value(item, path) for item in node.items]
96
+ return _reshape(flat, dims)
97
+
98
+
99
+ def _empty(dims: tuple[int, ...]) -> list[Value]:
100
+ if dims[0] == 0 or len(dims) == 1:
101
+ return []
102
+ return [_empty(dims[1:]) for _ in range(dims[0])]
103
+
104
+
105
+ def _reshape(flat: list[Value], dims: tuple[int, ...]) -> list[Value]:
106
+ if len(dims) == 1:
107
+ return flat
108
+ step = len(flat) // dims[0]
109
+ return [_reshape(flat[i * step : (i + 1) * step], dims[1:]) for i in range(dims[0])]
lvxml/_dump.py ADDED
@@ -0,0 +1,106 @@
1
+ """Write the typed tree back as text, in the layout LabVIEW's own XML writer uses."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ from ._errors import LVXMLError
8
+ from ._node import Document, Node
9
+ from ._treejson import check_shape
10
+
11
+ # The declaration LabVIEW's XML file writer emits before <LVData>.
12
+ DECLARATION = "<?xml version='1.0' standalone='yes' ?>"
13
+
14
+ # Characters XML 1.0 cannot carry at all, even escaped.
15
+ _ILLEGAL = re.compile("[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]")
16
+
17
+
18
+ def dump(
19
+ tree: Document | Node,
20
+ *,
21
+ newline: str = "\r\n",
22
+ indent: str = "",
23
+ declaration: bool | None = None,
24
+ ) -> str:
25
+ """Serialize a `Document` or a single `Node` as LabVIEW XML.
26
+
27
+ The defaults reproduce the layout of a file LabVIEW 2026 wrote: one element per line,
28
+ CRLF line breaks, no indentation, empty elements written as ``<Name></Name>`` and no
29
+ line break after the last element. Only ``&``, ``<`` and ``>`` are escaped.
30
+ `declaration` defaults to true for an ``<LVData>`` document and false otherwise. A
31
+ `Node` is written bare, as Flatten To XML outputs it.
32
+ """
33
+ document = tree if isinstance(tree, Document) else Document((tree,), False, None)
34
+ if not document.wrapped and len(document.items) != 1:
35
+ raise LVXMLError("a bare document holds exactly one value; set wrapped=True")
36
+ root = "LVData" if document.wrapped else ""
37
+ for node in document.items:
38
+ if node.tag == "Version" and document.wrapped:
39
+ if node != Node("Version", val=node.val) or node.val is None:
40
+ raise LVXMLError("a Version node only takes a val", root)
41
+ else:
42
+ try:
43
+ check_shape(node, root)
44
+ except RecursionError:
45
+ raise LVXMLError("the tree is nested too deeply to write", root) from None
46
+ lines: list[str] = []
47
+ if declaration if declaration is not None else document.wrapped:
48
+ lines.append(DECLARATION)
49
+ xmlns = f' xmlns="{_escape(document.namespace, attr=True)}"' if document.namespace else ""
50
+ if document.wrapped:
51
+ lines.append(f"<LVData{xmlns}>")
52
+ for node in document.items:
53
+ _write(node, lines, indent, 1 if indent else 0, "")
54
+ lines.append("</LVData>")
55
+ else:
56
+ _write(document.items[0], lines, indent, 0, xmlns)
57
+ return newline.join(lines)
58
+
59
+
60
+ def _escape(text: str, attr: bool = False) -> str:
61
+ bad = _ILLEGAL.search(text)
62
+ if bad:
63
+ raise LVXMLError(f"character U+{ord(bad.group()):04X} cannot be written in XML")
64
+ text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
65
+ return text.replace('"', "&quot;") if attr else text
66
+
67
+
68
+ def _leaf(tag: str, text: str, pad: str) -> str:
69
+ return f"{pad}<{tag}>{_escape(text)}</{tag}>"
70
+
71
+
72
+ def _write(node: Node, lines: list[str], indent: str, depth: int, xmlns: str) -> None:
73
+ pad = indent * depth
74
+ inner = indent * (depth + 1)
75
+ if node.tag == "Version":
76
+ lines.append(_leaf("Version", node.val or "", pad))
77
+ return
78
+ lines.append(f"{pad}<{node.tag}{xmlns}>")
79
+ lines.append(_leaf("Name", node.name, inner))
80
+ lines.extend(_leaf("Choice", choice, inner) for choice in node.choices)
81
+ if node.val is not None:
82
+ lines.append(_leaf("Val", node.val, inner))
83
+ _write_extra(node.extra, lines, inner, indent)
84
+ lines.extend(_leaf("Dimsize", str(dim), inner) for dim in node.dims)
85
+ if node.num_elts is not None:
86
+ lines.append(_leaf("NumElts", str(node.num_elts), inner))
87
+ for item in node.items:
88
+ _write(item, lines, indent, depth + 1, "")
89
+ lines.append(f"{pad}</{node.tag}>")
90
+
91
+
92
+ def _write_extra(
93
+ extra: tuple[tuple[str, str], ...], lines: list[str], pad: str, indent: str
94
+ ) -> None:
95
+ open_group = ""
96
+ for key, text in extra:
97
+ group, _, field = key.rpartition(".")
98
+ if group != open_group:
99
+ if open_group:
100
+ lines.append(f"{pad}</{open_group}>")
101
+ if group:
102
+ lines.append(f"{pad}<{group}>")
103
+ open_group = group
104
+ lines.append(_leaf(field, text, pad + indent if group else pad))
105
+ if open_group:
106
+ lines.append(f"{pad}</{open_group}>")