lvtypedesc 0.2.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.
- lvtypedesc/__init__.py +43 -0
- lvtypedesc/_cli.py +91 -0
- lvtypedesc/_codes.py +131 -0
- lvtypedesc/_emit.py +59 -0
- lvtypedesc/_errors.py +11 -0
- lvtypedesc/_model.py +96 -0
- lvtypedesc/_parser.py +125 -0
- lvtypedesc/_pretty.py +54 -0
- lvtypedesc/py.typed +0 -0
- lvtypedesc-0.2.0.dist-info/METADATA +142 -0
- lvtypedesc-0.2.0.dist-info/RECORD +14 -0
- lvtypedesc-0.2.0.dist-info/WHEEL +4 -0
- lvtypedesc-0.2.0.dist-info/entry_points.txt +2 -0
- lvtypedesc-0.2.0.dist-info/licenses/LICENSE +21 -0
lvtypedesc/__init__.py
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
"""Parse LabVIEW type-descriptor byte strings into a readable, typed schema."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from ._codes import Category, TypeCode, known_codes
|
|
6
|
+
from ._emit import to_bytes
|
|
7
|
+
from ._errors import TypeDescError
|
|
8
|
+
from ._model import (
|
|
9
|
+
VARIABLE,
|
|
10
|
+
Array,
|
|
11
|
+
Boolean,
|
|
12
|
+
Cluster,
|
|
13
|
+
FixedPoint,
|
|
14
|
+
LVString,
|
|
15
|
+
Refnum,
|
|
16
|
+
Scalar,
|
|
17
|
+
TypeDesc,
|
|
18
|
+
describe,
|
|
19
|
+
)
|
|
20
|
+
from ._parser import parse_type_descriptor
|
|
21
|
+
from ._pretty import pretty
|
|
22
|
+
|
|
23
|
+
__version__ = "0.2.0"
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"VARIABLE",
|
|
27
|
+
"Array",
|
|
28
|
+
"Boolean",
|
|
29
|
+
"Category",
|
|
30
|
+
"Cluster",
|
|
31
|
+
"FixedPoint",
|
|
32
|
+
"LVString",
|
|
33
|
+
"Refnum",
|
|
34
|
+
"Scalar",
|
|
35
|
+
"TypeCode",
|
|
36
|
+
"TypeDesc",
|
|
37
|
+
"TypeDescError",
|
|
38
|
+
"describe",
|
|
39
|
+
"known_codes",
|
|
40
|
+
"parse_type_descriptor",
|
|
41
|
+
"pretty",
|
|
42
|
+
"to_bytes",
|
|
43
|
+
]
|
lvtypedesc/_cli.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Command-line interface for lvtypedesc.
|
|
2
|
+
|
|
3
|
+
Decode a hex type descriptor into a readable type, or list the codes the library
|
|
4
|
+
knows. Errors are written to stderr and the process exits with status 1.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
from collections.abc import Sequence
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from ._codes import known_codes
|
|
15
|
+
from ._errors import TypeDescError
|
|
16
|
+
from ._model import describe
|
|
17
|
+
from ._parser import parse_type_descriptor
|
|
18
|
+
from ._pretty import pretty
|
|
19
|
+
|
|
20
|
+
_EXAMPLES = """\
|
|
21
|
+
examples:
|
|
22
|
+
lvtypedesc decode 00040003 -> I32
|
|
23
|
+
lvtypedesc decode "0004 000A" -> DBL (spaces are ignored)
|
|
24
|
+
lvtypedesc decode 000E00400001FFFFFFFF0004000A -> 1D Array of DBL
|
|
25
|
+
lvtypedesc decode --tree 000E00500002000400030004000A
|
|
26
|
+
lvtypedesc codes -> list every known type code
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
parser = argparse.ArgumentParser(
|
|
32
|
+
prog="lvtypedesc",
|
|
33
|
+
description="Parse LabVIEW type-descriptor byte strings into a readable type.",
|
|
34
|
+
epilog=_EXAMPLES,
|
|
35
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
36
|
+
)
|
|
37
|
+
parser.add_argument("--version", action="version", version=f"lvtypedesc {__version__}")
|
|
38
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
39
|
+
|
|
40
|
+
decode = sub.add_parser(
|
|
41
|
+
"decode",
|
|
42
|
+
help="decode a hex type descriptor into a readable type",
|
|
43
|
+
description="Decode a hex type descriptor. Whitespace in the hex is ignored.",
|
|
44
|
+
)
|
|
45
|
+
decode.add_argument("hex", help="the type descriptor as hex, for example 00040003")
|
|
46
|
+
decode.add_argument(
|
|
47
|
+
"--tree",
|
|
48
|
+
action="store_true",
|
|
49
|
+
help="print an indented tree instead of a one-line summary",
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
sub.add_parser(
|
|
53
|
+
"codes",
|
|
54
|
+
help="list every type code the library recognises",
|
|
55
|
+
description="List every recognised type code, with its name and category.",
|
|
56
|
+
)
|
|
57
|
+
return parser
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _run_decode(hex_text: str, tree: bool) -> int:
|
|
61
|
+
try:
|
|
62
|
+
data = bytes.fromhex(hex_text)
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
print(f"error: not valid hex: {exc}", file=sys.stderr)
|
|
65
|
+
return 1
|
|
66
|
+
try:
|
|
67
|
+
node = parse_type_descriptor(data)
|
|
68
|
+
except TypeDescError as exc:
|
|
69
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
70
|
+
return 1
|
|
71
|
+
print(pretty(node) if tree else describe(node))
|
|
72
|
+
return 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _run_codes() -> int:
|
|
76
|
+
for code, name, category in known_codes():
|
|
77
|
+
print(f"0x{code:02X} {name:<10} {category.value}")
|
|
78
|
+
return 0
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
82
|
+
"""Entry point for the ``lvtypedesc`` console script."""
|
|
83
|
+
parser = _build_parser()
|
|
84
|
+
args = parser.parse_args(argv)
|
|
85
|
+
if args.command == "decode":
|
|
86
|
+
return _run_decode(args.hex, args.tree)
|
|
87
|
+
return _run_codes()
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
if __name__ == "__main__": # pragma: no cover
|
|
91
|
+
raise SystemExit(main())
|
lvtypedesc/_codes.py
ADDED
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
"""The type codes this library understands.
|
|
2
|
+
|
|
3
|
+
The names and values follow the community-documented LabVIEW type-descriptor
|
|
4
|
+
table (see the project README for provenance). These codes are not an official
|
|
5
|
+
NI byte specification and can change between LabVIEW versions, so they are kept
|
|
6
|
+
in one place rather than scattered through the parser.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from enum import Enum, IntEnum
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class TypeCode(IntEnum):
|
|
15
|
+
"""Every raw type code this library recognises, by name.
|
|
16
|
+
|
|
17
|
+
Member names are Python identifiers. The human label used in output comes
|
|
18
|
+
from the name tables below (for example ``BOOLEAN16`` is labelled
|
|
19
|
+
``Boolean16``).
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
I8 = 0x01
|
|
23
|
+
I16 = 0x02
|
|
24
|
+
I32 = 0x03
|
|
25
|
+
I64 = 0x04
|
|
26
|
+
U8 = 0x05
|
|
27
|
+
U16 = 0x06
|
|
28
|
+
U32 = 0x07
|
|
29
|
+
U64 = 0x08
|
|
30
|
+
SGL = 0x09
|
|
31
|
+
DBL = 0x0A
|
|
32
|
+
EXT = 0x0B
|
|
33
|
+
CSG = 0x0C
|
|
34
|
+
CDB = 0x0D
|
|
35
|
+
CXT = 0x0E
|
|
36
|
+
BOOLEAN16 = 0x20
|
|
37
|
+
BOOLEAN = 0x21
|
|
38
|
+
STRING = 0x30
|
|
39
|
+
PATH = 0x32
|
|
40
|
+
PICTURE = 0x33
|
|
41
|
+
ARRAY = 0x40
|
|
42
|
+
CLUSTER = 0x50
|
|
43
|
+
FXP = 0x5F
|
|
44
|
+
REFNUM = 0x70
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
SCALAR_NAMES: dict[int, str] = {
|
|
48
|
+
0x01: "I8",
|
|
49
|
+
0x02: "I16",
|
|
50
|
+
0x03: "I32",
|
|
51
|
+
0x04: "I64",
|
|
52
|
+
0x05: "U8",
|
|
53
|
+
0x06: "U16",
|
|
54
|
+
0x07: "U32",
|
|
55
|
+
0x08: "U64",
|
|
56
|
+
0x09: "SGL",
|
|
57
|
+
0x0A: "DBL",
|
|
58
|
+
0x0B: "EXT",
|
|
59
|
+
0x0C: "CSG",
|
|
60
|
+
0x0D: "CDB",
|
|
61
|
+
0x0E: "CXT",
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
BOOLEAN_NAMES: dict[int, str] = {
|
|
65
|
+
0x20: "Boolean16",
|
|
66
|
+
0x21: "Boolean",
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
STRING_NAMES: dict[int, str] = {
|
|
70
|
+
0x30: "String",
|
|
71
|
+
0x32: "Path",
|
|
72
|
+
0x33: "Picture",
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
ARRAY_CODE = 0x40
|
|
76
|
+
CLUSTER_CODE = 0x50
|
|
77
|
+
FXP_CODE = 0x5F
|
|
78
|
+
REFNUM_CODE = 0x70
|
|
79
|
+
|
|
80
|
+
# Recognised by LabVIEW but outside this library's scope. Their payloads are not
|
|
81
|
+
# documented well enough to parse honestly, so the parser refuses them with a
|
|
82
|
+
# named message instead of a bare "unknown code".
|
|
83
|
+
UNSUPPORTED_NAMES: dict[int, str] = {
|
|
84
|
+
0x15: "EnumU8",
|
|
85
|
+
0x16: "EnumU16",
|
|
86
|
+
0x17: "EnumU32",
|
|
87
|
+
0x53: "Variant",
|
|
88
|
+
0x54: "Waveform",
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class Category(Enum):
|
|
93
|
+
"""The structural family a type code belongs to."""
|
|
94
|
+
|
|
95
|
+
SCALAR = "scalar"
|
|
96
|
+
BOOLEAN = "boolean"
|
|
97
|
+
STRING = "string"
|
|
98
|
+
ARRAY = "array"
|
|
99
|
+
CLUSTER = "cluster"
|
|
100
|
+
FXP = "fxp"
|
|
101
|
+
REFNUM = "refnum"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def classify(code: int) -> tuple[Category, str] | None:
|
|
105
|
+
"""Map a type code to its category and name, or None if out of scope."""
|
|
106
|
+
if code in SCALAR_NAMES:
|
|
107
|
+
return Category.SCALAR, SCALAR_NAMES[code]
|
|
108
|
+
if code in BOOLEAN_NAMES:
|
|
109
|
+
return Category.BOOLEAN, BOOLEAN_NAMES[code]
|
|
110
|
+
if code in STRING_NAMES:
|
|
111
|
+
return Category.STRING, STRING_NAMES[code]
|
|
112
|
+
if code == ARRAY_CODE:
|
|
113
|
+
return Category.ARRAY, "Array"
|
|
114
|
+
if code == CLUSTER_CODE:
|
|
115
|
+
return Category.CLUSTER, "Cluster"
|
|
116
|
+
if code == FXP_CODE:
|
|
117
|
+
return Category.FXP, "FXP"
|
|
118
|
+
if code == REFNUM_CODE:
|
|
119
|
+
return Category.REFNUM, "Refnum"
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def known_codes() -> list[tuple[int, str, Category]]:
|
|
124
|
+
"""Return every recognised ``(code, name, category)`` row, sorted by code."""
|
|
125
|
+
rows: list[tuple[int, str, Category]] = []
|
|
126
|
+
for code in range(0x100):
|
|
127
|
+
kind = classify(code)
|
|
128
|
+
if kind is not None:
|
|
129
|
+
category, name = kind
|
|
130
|
+
rows.append((code, name, category))
|
|
131
|
+
return rows
|
lvtypedesc/_emit.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Re-emit a typed tree as a canonical short-form type descriptor.
|
|
2
|
+
|
|
3
|
+
``to_bytes`` is the inverse of :func:`parse_type_descriptor` for every node type
|
|
4
|
+
this library models. It writes the short-form descriptor (16-bit length) and
|
|
5
|
+
raises :class:`TypeDescError` when a tree is too large for that form, rather than
|
|
6
|
+
guessing the long-form layout.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from ._errors import TypeDescError
|
|
12
|
+
from ._model import (
|
|
13
|
+
VARIABLE,
|
|
14
|
+
Array,
|
|
15
|
+
Boolean,
|
|
16
|
+
Cluster,
|
|
17
|
+
FixedPoint,
|
|
18
|
+
LVString,
|
|
19
|
+
Refnum,
|
|
20
|
+
Scalar,
|
|
21
|
+
TypeDesc,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
_HEADER = 4
|
|
25
|
+
_U32_VARIABLE = 0xFFFFFFFF
|
|
26
|
+
_MAX_LENGTH = 0xFFFF
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def to_bytes(node: TypeDesc) -> bytes:
|
|
30
|
+
"""Serialise ``node`` back into a canonical short-form type descriptor."""
|
|
31
|
+
body = _body(node)
|
|
32
|
+
length = _HEADER + len(body)
|
|
33
|
+
if length > _MAX_LENGTH:
|
|
34
|
+
raise TypeDescError(f"descriptor of {length} bytes is too large for the short form")
|
|
35
|
+
return _u16(length) + _u16(node.code & 0xFFFF) + body
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _body(node: TypeDesc) -> bytes:
|
|
39
|
+
if isinstance(node, (Scalar, Boolean)):
|
|
40
|
+
return b""
|
|
41
|
+
if isinstance(node, LVString):
|
|
42
|
+
size = _U32_VARIABLE if node.size == VARIABLE else node.size
|
|
43
|
+
return _u32(size)
|
|
44
|
+
if isinstance(node, Array):
|
|
45
|
+
dims = b"".join(_u32(_U32_VARIABLE if d == VARIABLE else d) for d in node.dimensions)
|
|
46
|
+
return _u16(node.rank) + dims + to_bytes(node.element)
|
|
47
|
+
if isinstance(node, Cluster):
|
|
48
|
+
return _u16(len(node.fields)) + b"".join(to_bytes(f) for f in node.fields)
|
|
49
|
+
if isinstance(node, (FixedPoint, Refnum)):
|
|
50
|
+
return node.payload
|
|
51
|
+
raise TypeDescError(f"cannot serialise node of type {type(node).__name__}")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _u16(value: int) -> bytes:
|
|
55
|
+
return int(value).to_bytes(2, "big")
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _u32(value: int) -> bytes:
|
|
59
|
+
return int(value).to_bytes(4, "big")
|
lvtypedesc/_errors.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""The single error type raised for malformed or unsupported input."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TypeDescError(ValueError):
|
|
7
|
+
"""Raised when a byte string is not a type descriptor this library can parse.
|
|
8
|
+
|
|
9
|
+
Covers truncated input, an impossible length word, and type codes that are
|
|
10
|
+
unknown or outside the supported subset.
|
|
11
|
+
"""
|
lvtypedesc/_model.py
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
"""The typed tree a parsed type descriptor becomes, and a readable rendering."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
|
|
7
|
+
# A dimension or string whose size is not fixed in the descriptor. LabVIEW
|
|
8
|
+
# stores this as 0xFFFFFFFF; the actual size lives in the flattened data.
|
|
9
|
+
VARIABLE = -1
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(frozen=True)
|
|
13
|
+
class TypeDesc:
|
|
14
|
+
"""Base node. ``code`` is the raw type code, ``name`` its documented label."""
|
|
15
|
+
|
|
16
|
+
code: int
|
|
17
|
+
name: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@dataclass(frozen=True)
|
|
21
|
+
class Scalar(TypeDesc):
|
|
22
|
+
"""A fixed-width numeric type, for example I32 or DBL."""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class Boolean(TypeDesc):
|
|
27
|
+
"""A LabVIEW boolean."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass(frozen=True)
|
|
31
|
+
class LVString(TypeDesc):
|
|
32
|
+
"""A variable-length string, path or picture.
|
|
33
|
+
|
|
34
|
+
``size`` is the descriptor's 32-bit size word: ``VARIABLE`` when the size is
|
|
35
|
+
not fixed (``0xFFFFFFFF``), otherwise the fixed byte count.
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
size: int = VARIABLE
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass(frozen=True)
|
|
42
|
+
class Array(TypeDesc):
|
|
43
|
+
"""An n-dimensional array of one element type."""
|
|
44
|
+
|
|
45
|
+
element: TypeDesc
|
|
46
|
+
dimensions: tuple[int, ...] = field(default_factory=tuple)
|
|
47
|
+
|
|
48
|
+
@property
|
|
49
|
+
def rank(self) -> int:
|
|
50
|
+
"""The number of dimensions."""
|
|
51
|
+
return len(self.dimensions)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass(frozen=True)
|
|
55
|
+
class Cluster(TypeDesc):
|
|
56
|
+
"""An ordered group of named-by-position field types."""
|
|
57
|
+
|
|
58
|
+
fields: tuple[TypeDesc, ...] = field(default_factory=tuple)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(frozen=True)
|
|
62
|
+
class FixedPoint(TypeDesc):
|
|
63
|
+
"""A LabVIEW fixed-point number (FXP).
|
|
64
|
+
|
|
65
|
+
The word, delta and offset encoding inside an FXP descriptor is
|
|
66
|
+
version-specific and not fully documented, so the type-specific bytes are
|
|
67
|
+
preserved verbatim in ``payload`` rather than parsed into fields. This keeps
|
|
68
|
+
round-trip exact without inventing an interpretation.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
payload: bytes = b""
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Refnum(TypeDesc):
|
|
76
|
+
"""A LabVIEW refnum (a handle to a queue, VISA resource, control, ...).
|
|
77
|
+
|
|
78
|
+
``subtype`` is the first payload word, which identifies the refnum kind. Its
|
|
79
|
+
meaning is only partly documented, so it is surfaced best-effort and the full
|
|
80
|
+
type-specific bytes are kept in ``payload`` for exact round-trip.
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
subtype: int = 0
|
|
84
|
+
payload: bytes = b""
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def describe(node: TypeDesc) -> str:
|
|
88
|
+
"""Render a parsed descriptor as a compact, readable one-line string."""
|
|
89
|
+
if isinstance(node, Array):
|
|
90
|
+
return f"{node.rank}D Array of {describe(node.element)}"
|
|
91
|
+
if isinstance(node, Cluster):
|
|
92
|
+
inner = ", ".join(describe(f) for f in node.fields)
|
|
93
|
+
return f"Cluster {{ {inner} }}"
|
|
94
|
+
if isinstance(node, Refnum):
|
|
95
|
+
return f"Refnum(subtype=0x{node.subtype:02x})"
|
|
96
|
+
return node.name
|
lvtypedesc/_parser.py
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
"""Recursive descent over a LabVIEW type descriptor byte string.
|
|
2
|
+
|
|
3
|
+
Layout of the common types, all big-endian:
|
|
4
|
+
|
|
5
|
+
- header: a 16-bit length (bytes in this descriptor, including the length) and a
|
|
6
|
+
16-bit type-code word whose low byte is the type code.
|
|
7
|
+
- scalar, boolean: header only.
|
|
8
|
+
- string, path, picture: header plus a 32-bit size word (0xFFFFFFFF = variable).
|
|
9
|
+
- array: header, a 16-bit dimension count, one 32-bit size per dimension, then
|
|
10
|
+
the element descriptor.
|
|
11
|
+
- cluster: header, a 16-bit field count, then that many descriptors in order.
|
|
12
|
+
- fxp, refnum: header plus a type-specific payload kept as raw bytes, because its
|
|
13
|
+
internal layout is version-specific and not fully documented.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from ._codes import UNSUPPORTED_NAMES, Category, classify
|
|
19
|
+
from ._errors import TypeDescError
|
|
20
|
+
from ._model import (
|
|
21
|
+
VARIABLE,
|
|
22
|
+
Array,
|
|
23
|
+
Boolean,
|
|
24
|
+
Cluster,
|
|
25
|
+
FixedPoint,
|
|
26
|
+
LVString,
|
|
27
|
+
Refnum,
|
|
28
|
+
Scalar,
|
|
29
|
+
TypeDesc,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_HEADER = 4
|
|
33
|
+
_U32_VARIABLE = 0xFFFFFFFF
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def parse_type_descriptor(data: bytes) -> TypeDesc:
|
|
37
|
+
"""Parse the leading type descriptor in ``data`` into a typed tree.
|
|
38
|
+
|
|
39
|
+
Trailing bytes (such as the flattened data that follows a descriptor) are
|
|
40
|
+
ignored. Raises :class:`TypeDescError` on truncated or unsupported input.
|
|
41
|
+
"""
|
|
42
|
+
if not isinstance(data, (bytes, bytearray)):
|
|
43
|
+
raise TypeDescError("expected bytes")
|
|
44
|
+
node, _ = _parse(bytes(data), 0)
|
|
45
|
+
return node
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _u16(data: bytes, offset: int) -> int:
|
|
49
|
+
if offset + 2 > len(data):
|
|
50
|
+
raise TypeDescError(f"truncated: need 2 bytes at offset {offset}")
|
|
51
|
+
return int.from_bytes(data[offset : offset + 2], "big")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def _u32(data: bytes, offset: int) -> int:
|
|
55
|
+
if offset + 4 > len(data):
|
|
56
|
+
raise TypeDescError(f"truncated: need 4 bytes at offset {offset}")
|
|
57
|
+
return int.from_bytes(data[offset : offset + 4], "big")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse(data: bytes, offset: int) -> tuple[TypeDesc, int]:
|
|
61
|
+
length = _u16(data, offset)
|
|
62
|
+
code = _u16(data, offset + 2) & 0xFF
|
|
63
|
+
end = offset + length
|
|
64
|
+
if length < _HEADER:
|
|
65
|
+
raise TypeDescError(f"length {length} too small at offset {offset}")
|
|
66
|
+
if end > len(data):
|
|
67
|
+
raise TypeDescError(f"descriptor at offset {offset} runs past the input")
|
|
68
|
+
|
|
69
|
+
kind = classify(code)
|
|
70
|
+
if kind is None:
|
|
71
|
+
name = UNSUPPORTED_NAMES.get(code)
|
|
72
|
+
detail = f"unsupported type {name}" if name else "unknown type code"
|
|
73
|
+
raise TypeDescError(f"{detail} 0x{code:02x} at offset {offset}")
|
|
74
|
+
|
|
75
|
+
category, name = kind
|
|
76
|
+
if category is Category.SCALAR:
|
|
77
|
+
return Scalar(code, name), end
|
|
78
|
+
if category is Category.BOOLEAN:
|
|
79
|
+
return Boolean(code, name), end
|
|
80
|
+
if category is Category.STRING:
|
|
81
|
+
return _parse_string(data, offset, end, code, name), end
|
|
82
|
+
if category is Category.ARRAY:
|
|
83
|
+
return _parse_array(data, offset, code, name), end
|
|
84
|
+
if category is Category.CLUSTER:
|
|
85
|
+
return _parse_cluster(data, offset, code, name), end
|
|
86
|
+
if category is Category.FXP:
|
|
87
|
+
return FixedPoint(code, name, payload=data[offset + _HEADER : end]), end
|
|
88
|
+
return _parse_refnum(data, offset, end, code, name), end
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _parse_string(data: bytes, offset: int, end: int, code: int, name: str) -> LVString:
|
|
92
|
+
if end - offset >= _HEADER + 4:
|
|
93
|
+
raw = _u32(data, offset + _HEADER)
|
|
94
|
+
size = VARIABLE if raw == _U32_VARIABLE else raw
|
|
95
|
+
else:
|
|
96
|
+
size = VARIABLE
|
|
97
|
+
return LVString(code, name, size=size)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _parse_array(data: bytes, offset: int, code: int, name: str) -> Array:
|
|
101
|
+
rank = _u16(data, offset + _HEADER)
|
|
102
|
+
pos = offset + _HEADER + 2
|
|
103
|
+
dims: list[int] = []
|
|
104
|
+
for _ in range(rank):
|
|
105
|
+
size = _u32(data, pos)
|
|
106
|
+
dims.append(VARIABLE if size == _U32_VARIABLE else size)
|
|
107
|
+
pos += 4
|
|
108
|
+
element, _ = _parse(data, pos)
|
|
109
|
+
return Array(code, name, element=element, dimensions=tuple(dims))
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _parse_cluster(data: bytes, offset: int, code: int, name: str) -> Cluster:
|
|
113
|
+
count = _u16(data, offset + _HEADER)
|
|
114
|
+
pos = offset + _HEADER + 2
|
|
115
|
+
fields: list[TypeDesc] = []
|
|
116
|
+
for _ in range(count):
|
|
117
|
+
child, pos = _parse(data, pos)
|
|
118
|
+
fields.append(child)
|
|
119
|
+
return Cluster(code, name, fields=tuple(fields))
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _parse_refnum(data: bytes, offset: int, end: int, code: int, name: str) -> Refnum:
|
|
123
|
+
payload = data[offset + _HEADER : end]
|
|
124
|
+
subtype = _u16(data, offset + _HEADER) if end - offset >= _HEADER + 2 else 0
|
|
125
|
+
return Refnum(code, name, subtype=subtype, payload=payload)
|
lvtypedesc/_pretty.py
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Render a parsed type descriptor as an indented, multi-line tree.
|
|
2
|
+
|
|
3
|
+
This complements the one-line :func:`describe`. It is meant for reading a nested
|
|
4
|
+
descriptor at a glance, so it spells out ranks, dimensions and field positions.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from ._model import (
|
|
10
|
+
VARIABLE,
|
|
11
|
+
Array,
|
|
12
|
+
Cluster,
|
|
13
|
+
FixedPoint,
|
|
14
|
+
LVString,
|
|
15
|
+
Refnum,
|
|
16
|
+
TypeDesc,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
_STEP = " "
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def pretty(node: TypeDesc) -> str:
|
|
23
|
+
"""Return a multi-line, indented rendering of ``node`` and its children."""
|
|
24
|
+
return "\n".join(_lines(node, 0))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _dim(value: int) -> str:
|
|
28
|
+
return "variable" if value == VARIABLE else str(value)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _lines(node: TypeDesc, depth: int) -> list[str]:
|
|
32
|
+
pad = _STEP * depth
|
|
33
|
+
if isinstance(node, Array):
|
|
34
|
+
dims = ", ".join(_dim(d) for d in node.dimensions)
|
|
35
|
+
head = f"{pad}Array (rank {node.rank}, dims [{dims}])"
|
|
36
|
+
return [head, *_lines(node.element, depth + 1)]
|
|
37
|
+
if isinstance(node, Cluster):
|
|
38
|
+
head = f"{pad}Cluster ({len(node.fields)} fields)"
|
|
39
|
+
out = [head]
|
|
40
|
+
for index, child in enumerate(node.fields):
|
|
41
|
+
label = f"{_STEP * (depth + 1)}[{index}]"
|
|
42
|
+
child_lines = _lines(child, depth + 2)
|
|
43
|
+
first = child_lines[0].lstrip()
|
|
44
|
+
out.append(f"{label} {first}")
|
|
45
|
+
out.extend(child_lines[1:])
|
|
46
|
+
return out
|
|
47
|
+
if isinstance(node, LVString):
|
|
48
|
+
size = "variable" if node.size == VARIABLE else f"{node.size} bytes"
|
|
49
|
+
return [f"{pad}{node.name} (size {size})"]
|
|
50
|
+
if isinstance(node, Refnum):
|
|
51
|
+
return [f"{pad}Refnum (subtype 0x{node.subtype:02x})"]
|
|
52
|
+
if isinstance(node, FixedPoint):
|
|
53
|
+
return [f"{pad}FXP ({len(node.payload)}-byte payload)"]
|
|
54
|
+
return [f"{pad}{node.name}"]
|
lvtypedesc/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: lvtypedesc
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Parse LabVIEW type-descriptor byte strings into a readable, typed schema
|
|
5
|
+
Project-URL: Homepage, https://lianes8studio.com
|
|
6
|
+
Project-URL: Repository, https://github.com/JaviLianes8/lvtypedesc
|
|
7
|
+
Project-URL: Issues, https://github.com/JaviLianes8/lvtypedesc/issues
|
|
8
|
+
Project-URL: Changelog, https://github.com/JaviLianes8/lvtypedesc/blob/main/CHANGELOG.md
|
|
9
|
+
Project-URL: LinkedIn, https://www.linkedin.com/in/jlianes/
|
|
10
|
+
Project-URL: Funding, https://buymeacoffee.com/jlianesglrs
|
|
11
|
+
Author-email: Javier Lianes <javier@lianes8studio.com>
|
|
12
|
+
License-Expression: MIT
|
|
13
|
+
License-File: LICENSE
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Intended Audience :: Developers
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Typing :: Typed
|
|
18
|
+
Requires-Python: >=3.10
|
|
19
|
+
Provides-Extra: dev
|
|
20
|
+
Requires-Dist: build; extra == 'dev'
|
|
21
|
+
Requires-Dist: mypy; extra == 'dev'
|
|
22
|
+
Requires-Dist: pytest; extra == 'dev'
|
|
23
|
+
Requires-Dist: ruff; extra == 'dev'
|
|
24
|
+
Requires-Dist: twine; extra == 'dev'
|
|
25
|
+
Description-Content-Type: text/markdown
|
|
26
|
+
|
|
27
|
+
# lvtypedesc
|
|
28
|
+
|
|
29
|
+
Parse LabVIEW type-descriptor byte strings into a readable, typed schema
|
|
30
|
+
|
|
31
|
+
## Install
|
|
32
|
+
|
|
33
|
+
```
|
|
34
|
+
pip install lvtypedesc
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
A LabVIEW type descriptor is the small, length-prefixed block of type codes that
|
|
38
|
+
precedes flattened LabVIEW data. This library turns that block into a typed tree
|
|
39
|
+
you can inspect, which is the schema a generic unflatten needs.
|
|
40
|
+
|
|
41
|
+
## Usage
|
|
42
|
+
|
|
43
|
+
```python
|
|
44
|
+
from lvtypedesc import parse_type_descriptor, describe, pretty, to_bytes
|
|
45
|
+
|
|
46
|
+
# A 1D array of clusters, each holding an I32 and a string.
|
|
47
|
+
data = bytes.fromhex(
|
|
48
|
+
"001C 0040 0001 FFFFFFFF 0012 0050 0002 0004 0003 0008 0030 FFFFFFFF".replace(" ", "")
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
node = parse_type_descriptor(data)
|
|
52
|
+
print(describe(node)) # 1D Array of Cluster { I32, String }
|
|
53
|
+
print(node.rank) # 1
|
|
54
|
+
print(node.element.fields[0].name) # I32
|
|
55
|
+
print(to_bytes(node) == data) # True, round-trips exactly
|
|
56
|
+
print(pretty(node))
|
|
57
|
+
# Array (rank 1, dims [variable])
|
|
58
|
+
# Cluster (2 fields)
|
|
59
|
+
# [0] I32
|
|
60
|
+
# [1] String (size variable)
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
The tree is built from frozen dataclasses: `Scalar`, `Boolean`, `LVString` (with
|
|
64
|
+
`size`), `Array` (with `element` and `dimensions`), `Cluster` (with `fields`),
|
|
65
|
+
`FixedPoint` and `Refnum`. A dimension or string whose size is not fixed in the
|
|
66
|
+
descriptor uses the sentinel `VARIABLE`. Trailing bytes, such as the flattened
|
|
67
|
+
data after the descriptor, are ignored. Truncated, unknown or out-of-scope input
|
|
68
|
+
raises `TypeDescError`.
|
|
69
|
+
|
|
70
|
+
`to_bytes` re-emits the canonical short-form descriptor for any node the library
|
|
71
|
+
models, so `parse_type_descriptor` and `to_bytes` are inverses for the supported
|
|
72
|
+
subset. `TypeCode` exposes the recognised codes by name and `known_codes()`
|
|
73
|
+
returns the full table.
|
|
74
|
+
|
|
75
|
+
## Command line
|
|
76
|
+
|
|
77
|
+
Installing the package also installs a `lvtypedesc` console script:
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
$ lvtypedesc decode 00040003
|
|
81
|
+
I32
|
|
82
|
+
$ lvtypedesc decode 000E00400001FFFFFFFF0004000A
|
|
83
|
+
1D Array of DBL
|
|
84
|
+
$ lvtypedesc decode --tree 000E00500002000400030004000A
|
|
85
|
+
Cluster (2 fields)
|
|
86
|
+
[0] I32
|
|
87
|
+
[1] DBL
|
|
88
|
+
$ lvtypedesc codes # list every recognised type code
|
|
89
|
+
$ lvtypedesc --version
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Whitespace in the hex is ignored. Errors go to stderr with exit code 1. Run
|
|
93
|
+
`lvtypedesc --help` for the full list of examples.
|
|
94
|
+
|
|
95
|
+
## Supported subset
|
|
96
|
+
|
|
97
|
+
Fully parsed: the numeric scalars (`I8` to `U64`, `SGL`, `DBL`, `EXT` and the
|
|
98
|
+
complex variants), booleans, strings, paths, pictures, n-dimensional arrays and
|
|
99
|
+
clusters, nested to any depth. Fixed-point (`FXP`) and refnum descriptors are
|
|
100
|
+
recognised and their type-specific bytes preserved, but their internal layout is
|
|
101
|
+
not interpreted (see provenance below).
|
|
102
|
+
|
|
103
|
+
Not covered, and reported with a clear error: enums, variants and waveforms,
|
|
104
|
+
whose payloads are not documented well enough to parse honestly.
|
|
105
|
+
|
|
106
|
+
## Provenance (please read)
|
|
107
|
+
|
|
108
|
+
The type-descriptor code table used here is **community reverse-engineering**,
|
|
109
|
+
documented at the [LabVIEW wiki](https://labviewwiki.org/wiki/Type_descriptor).
|
|
110
|
+
It is consistent with the fragments NI has published but it is **not** an
|
|
111
|
+
official, byte-exact NI specification. Two further caveats:
|
|
112
|
+
|
|
113
|
+
- Codes can change between LabVIEW versions, so treat them as the common
|
|
114
|
+
documented set rather than a guarantee for every build.
|
|
115
|
+
- The in-memory 16-bit-word form and the flattened byte form can differ. This
|
|
116
|
+
library parses the flattened byte form.
|
|
117
|
+
|
|
118
|
+
Because of this, anything that cannot be verified is treated as provisional:
|
|
119
|
+
unknown codes raise an honest error instead of a guess, and the fixed-point and
|
|
120
|
+
refnum payloads are kept as raw bytes rather than invented into fields. The
|
|
121
|
+
refnum `subtype` word is surfaced best-effort and is itself provisional.
|
|
122
|
+
|
|
123
|
+
## Why
|
|
124
|
+
|
|
125
|
+
LabVIEW ships flattened data with a leading type descriptor, and decoding it in
|
|
126
|
+
Python needs that descriptor parsed into a schema first. OpenG's Variant Data
|
|
127
|
+
Library parses descriptors inside LabVIEW, but it is LabVIEW VIs, not Python. The
|
|
128
|
+
existing PyPI packages read LabVIEW source files instead: `pylabview` and `lvkit`
|
|
129
|
+
parse the `.vi` binary, `pylavi` validates VI files. None of them exposes a parser
|
|
130
|
+
for the raw runtime type-descriptor byte string that precedes flattened data or
|
|
131
|
+
sits inside a variant. This library fills that gap with a zero-dependency, fully
|
|
132
|
+
typed reader for the common documented types.
|
|
133
|
+
|
|
134
|
+
## License
|
|
135
|
+
|
|
136
|
+
MIT. See [LICENSE](LICENSE).
|
|
137
|
+
|
|
138
|
+
## Links
|
|
139
|
+
|
|
140
|
+
- Website: https://lianes8studio.com
|
|
141
|
+
- GitHub: https://github.com/JaviLianes8
|
|
142
|
+
- LinkedIn: https://www.linkedin.com/in/jlianes/
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
lvtypedesc/__init__.py,sha256=qWMcxYKoxepoPwbrYag-v30l7xmObb-Ic0SYWWQ7PEE,782
|
|
2
|
+
lvtypedesc/_cli.py,sha256=vTPtSt8g2WKzwf4YMFKKpq5VpsTxnQ19eVDHOqMxfM8,2919
|
|
3
|
+
lvtypedesc/_codes.py,sha256=6M72TfbblERaL0XAMKfFw7UmuicOpBzbQSFWUozbR_4,3186
|
|
4
|
+
lvtypedesc/_emit.py,sha256=MIHK0Lg1HXBL4QjXcy_ltQ2mk8thwqjsyaoA6LODJL8,1815
|
|
5
|
+
lvtypedesc/_errors.py,sha256=yFS7B0CyC7wIUHQ4B_L3cORJFMqKhW0LDmqyX5x06HA,357
|
|
6
|
+
lvtypedesc/_model.py,sha256=szDUs52GuCazK6BozpHHuJcaPHc6ghvkTe8PMMTMvRw,2723
|
|
7
|
+
lvtypedesc/_parser.py,sha256=NEpe39QQwnBZGWI2uFptBLeBAJ8f3Lbs5q1no9QN3cw,4459
|
|
8
|
+
lvtypedesc/_pretty.py,sha256=40EmR7CpMeWAIiYcxoocRHGa_8M4RhxnSJ7H_plxS5k,1747
|
|
9
|
+
lvtypedesc/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
lvtypedesc-0.2.0.dist-info/METADATA,sha256=nztOqEr8I3KyUIILg5CA-IRoJKJJLhHaV1q0yp6Q0lE,5433
|
|
11
|
+
lvtypedesc-0.2.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
12
|
+
lvtypedesc-0.2.0.dist-info/entry_points.txt,sha256=cFKuuV3WevGGmQy_2KFN801j22pVqEwTkiLsWJhb0uk,52
|
|
13
|
+
lvtypedesc-0.2.0.dist-info/licenses/LICENSE,sha256=wirD5nCA2aQa5UusK_1YHLBogI3CGcw23xAnulp3L_U,1091
|
|
14
|
+
lvtypedesc-0.2.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Javier Lianes
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|