lcm-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,577 @@
1
+ """Runtime LCM type class builder.
2
+
3
+ Dynamically generates Python decode classes from parsed LcmStruct definitions.
4
+ Generated classes have the same interface as lcm-gen output (decode, _decode_one,
5
+ _get_packed_fingerprint, __slots__, etc.) and can be used directly with the
6
+ echo display module.
7
+
8
+ Also provides TypeRegistry for fingerprint-based auto-matching.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import struct as _struct
14
+ from io import BytesIO
15
+ from pathlib import Path
16
+ from typing import Any, Dict, List, Optional
17
+
18
+ from lcm_tools.core.lcm_type_parser import (
19
+ LcmStruct,
20
+ compute_fingerprints,
21
+ parse_lcm_file,
22
+ )
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # struct format char mapping (big-endian)
26
+ # ---------------------------------------------------------------------------
27
+
28
+ _PRIM_FORMAT = {
29
+ "byte": "B",
30
+ "boolean": "b",
31
+ "int8_t": "b",
32
+ "int16_t": ">h",
33
+ "int32_t": ">i",
34
+ "int64_t": ">q",
35
+ "float": ">f",
36
+ "double": ">d",
37
+ }
38
+
39
+ _PRIM_SIZE = {
40
+ "byte": 1,
41
+ "boolean": 1,
42
+ "int8_t": 1,
43
+ "int16_t": 2,
44
+ "int32_t": 4,
45
+ "int64_t": 8,
46
+ "float": 4,
47
+ "double": 8,
48
+ }
49
+
50
+ # For batch unpacking of arrays (big-endian, no '>')
51
+ _PRIM_FMT_CHAR = {
52
+ "byte": "B",
53
+ "boolean": "b",
54
+ "int8_t": "b",
55
+ "int16_t": "h",
56
+ "int32_t": "i",
57
+ "int64_t": "q",
58
+ "float": "f",
59
+ "double": "d",
60
+ }
61
+
62
+
63
+ def _is_primitive(type_name: str) -> bool:
64
+ return type_name in _PRIM_FORMAT
65
+
66
+
67
+ # ---------------------------------------------------------------------------
68
+ # Dynamic class builder
69
+ # ---------------------------------------------------------------------------
70
+
71
+ def _build_init(members: list) -> callable:
72
+ """Build __init__ method that initialises all fields to defaults."""
73
+
74
+ def __init__(self: Any) -> None:
75
+ for m in members:
76
+ type_name = m["type"]
77
+ dims = m["dims"]
78
+
79
+ if not dims:
80
+ # Scalar
81
+ if type_name == "byte":
82
+ setattr(self, m["name"], 0)
83
+ elif type_name == "boolean":
84
+ setattr(self, m["name"], False)
85
+ elif type_name in ("int8_t", "int16_t", "int32_t", "int64_t"):
86
+ setattr(self, m["name"], 0)
87
+ elif type_name in ("float", "double"):
88
+ setattr(self, m["name"], 0.0)
89
+ elif type_name == "string":
90
+ setattr(self, m["name"], "")
91
+ else:
92
+ # Compound type - will be resolved at decode time
93
+ setattr(self, m["name"], None)
94
+ else:
95
+ # Array
96
+ if dims[-1][0] == "var":
97
+ # Variable-length last dim -> empty list
98
+ setattr(self, m["name"], [])
99
+ else:
100
+ # Fixed-size: build nested lists
101
+ val = _build_fixed_init(type_name, dims, 0)
102
+ setattr(self, m["name"], val)
103
+
104
+ return __init__
105
+
106
+
107
+ def _build_fixed_init(type_name: str, dims: list, dim_idx: int) -> Any:
108
+ """Recursively build fixed-size array initialiser."""
109
+ if dim_idx == len(dims):
110
+ # Leaf value
111
+ if type_name == "byte":
112
+ return 0
113
+ elif type_name == "boolean":
114
+ return False
115
+ elif type_name in ("int8_t", "int16_t", "int32_t", "int64_t"):
116
+ return 0
117
+ elif type_name in ("float", "double"):
118
+ return 0.0
119
+ elif type_name == "string":
120
+ return ""
121
+ else:
122
+ return None
123
+ if dim_idx == len(dims) - 1 and type_name == "byte":
124
+ return b""
125
+ size = int(dims[dim_idx][1])
126
+ return [_build_fixed_init(type_name, dims, dim_idx + 1) for _ in range(size)]
127
+
128
+
129
+ def _build_decode_one(member_specs: list, type_registry: "TypeRegistry") -> callable:
130
+ """Build _decode_one(buf) static method (raw function, not staticmethod)."""
131
+
132
+ def _decode_one(buf: BytesIO) -> Any:
133
+ # 'cls' is the class itself, accessed via the closure
134
+ self = _decode_one._owner_cls()
135
+ for ms in member_specs:
136
+ _decode_member(self, buf, ms, type_registry)
137
+ return self
138
+
139
+ return _decode_one
140
+
141
+
142
+ def _decode_member(
143
+ self: Any, buf: BytesIO, ms: dict, registry: "TypeRegistry"
144
+ ) -> None:
145
+ """Decode a single member into self."""
146
+ name = ms["name"]
147
+ type_name = ms["type"]
148
+ dims = ms["dims"]
149
+
150
+ if not dims:
151
+ # Scalar
152
+ setattr(self, name, _decode_scalar(buf, type_name, registry))
153
+ else:
154
+ # Array
155
+ val = _decode_array(self, buf, type_name, dims, 0, registry)
156
+ setattr(self, name, val)
157
+
158
+
159
+ def _decode_scalar(buf: BytesIO, type_name: str, registry: "TypeRegistry") -> Any:
160
+ """Decode a single scalar or compound value from buf."""
161
+ if type_name == "string":
162
+ str_len = _struct.unpack(">I", buf.read(4))[0]
163
+ raw = buf.read(str_len)
164
+ return raw[:-1].decode("utf-8", "replace") if raw else ""
165
+ elif type_name == "boolean":
166
+ return bool(_struct.unpack("b", buf.read(1))[0])
167
+ elif type_name in _PRIM_FORMAT:
168
+ fmt = _PRIM_FORMAT[type_name]
169
+ return _struct.unpack(fmt, buf.read(_PRIM_SIZE[type_name]))[0]
170
+ else:
171
+ # Compound type
172
+ ref_cls = registry._classes_by_name.get(type_name)
173
+ if ref_cls is None:
174
+ raise ValueError(f"Unknown LCM type: {type_name}")
175
+ return ref_cls._decode_one(buf)
176
+
177
+
178
+ def _resolve_dim_size(self: Any, dim: tuple) -> int:
179
+ """Resolve a dimension size: const -> int, var -> self.<field>."""
180
+ mode, size_str = dim
181
+ if mode == "const":
182
+ return int(size_str)
183
+ else:
184
+ return getattr(self, size_str)
185
+
186
+
187
+ def _decode_array(
188
+ self: Any,
189
+ buf: BytesIO,
190
+ type_name: str,
191
+ dims: list,
192
+ dim_idx: int,
193
+ registry: "TypeRegistry",
194
+ ) -> Any:
195
+ """Recursively decode a (possibly multi-dimensional) array."""
196
+ if dim_idx == len(dims):
197
+ # Should not be called here; scalar path handles this
198
+ return _decode_scalar(buf, type_name, registry)
199
+
200
+ dim_mode, dim_size_str = dims[dim_idx]
201
+ size = _resolve_dim_size(self, dims[dim_idx])
202
+ is_last_dim = (dim_idx == len(dims) - 1)
203
+
204
+ if is_last_dim:
205
+ # Last dimension: decode elements directly
206
+ if _is_primitive(type_name) and type_name != "string":
207
+ # Batch decode primitives
208
+ return _decode_prim_array(buf, type_name, size)
209
+ else:
210
+ # Decode elements one by one (strings or compound)
211
+ result = []
212
+ for _ in range(size):
213
+ result.append(_decode_scalar(buf, type_name, registry))
214
+ return result
215
+ else:
216
+ # Not last dimension: recurse
217
+ result = []
218
+ for _ in range(size):
219
+ result.append(
220
+ _decode_array(self, buf, type_name, dims, dim_idx + 1, registry)
221
+ )
222
+ return result
223
+
224
+
225
+ def _decode_prim_array(buf: BytesIO, type_name: str, count: int) -> Any:
226
+ """Decode an array of primitive values using batch struct.unpack."""
227
+ if type_name == "byte":
228
+ return buf.read(count)
229
+
230
+ fmt_char = _PRIM_FMT_CHAR[type_name]
231
+ elem_size = _PRIM_SIZE[type_name]
232
+ raw = buf.read(count * elem_size)
233
+
234
+ if type_name == "boolean":
235
+ return [bool(x) for x in _struct.unpack(f">{count}{fmt_char}", raw)]
236
+ else:
237
+ return list(_struct.unpack(f">{count}{fmt_char}", raw))
238
+
239
+
240
+ def build_lcm_class(
241
+ lcm_struct: LcmStruct, registry: "TypeRegistry"
242
+ ) -> type:
243
+ """Dynamically build a Python class for an LCM struct.
244
+
245
+ The generated class has the same interface as lcm-gen output:
246
+ - __slots__ with all member names
247
+ - __init__() initialising all fields
248
+ - decode(data) static method
249
+ - _decode_one(buf) static method
250
+ - _get_packed_fingerprint() static method
251
+ - _get_hash_recursive(parents) static method
252
+
253
+ Args:
254
+ lcm_struct: Parsed struct definition (with hash_value computed).
255
+ registry: TypeRegistry for resolving compound type references.
256
+
257
+ Returns:
258
+ A dynamically created Python class.
259
+ """
260
+ short_name = lcm_struct.short_name
261
+ member_names = [m.member_name for m in lcm_struct.members]
262
+
263
+ # Prepare member specs for decode
264
+ member_specs = []
265
+ for m in lcm_struct.members:
266
+ dims = [(d.mode, d.size) for d in m.dimensions]
267
+ member_specs.append({
268
+ "name": m.member_name,
269
+ "type": m.type_name,
270
+ "dims": dims,
271
+ })
272
+
273
+ # Build __init__
274
+ init_fn = _build_init(member_specs)
275
+
276
+ # Build _decode_one
277
+ decode_one_fn = _build_decode_one(member_specs, registry)
278
+
279
+ # Build _get_hash_recursive (raw function)
280
+ hash_recursive_fn = _build_hash_recursive(lcm_struct, registry)
281
+
282
+ # Build _get_packed_fingerprint (raw function)
283
+ packed_fp_cache: list = [None]
284
+
285
+ def _get_packed_fingerprint() -> bytes:
286
+ if _get_packed_fingerprint._cache[0] is None: # type: ignore[attr-defined]
287
+ _get_packed_fingerprint._cache[0] = _struct.pack( # type: ignore[attr-defined]
288
+ ">Q", _get_packed_fingerprint._hash_fn([]) # type: ignore[attr-defined]
289
+ )
290
+ return _get_packed_fingerprint._cache[0] # type: ignore[attr-defined]
291
+
292
+ _get_packed_fingerprint._cache = packed_fp_cache # type: ignore[attr-defined]
293
+ _get_packed_fingerprint._hash_fn = hash_recursive_fn # type: ignore[attr-defined]
294
+
295
+ # Build decode (raw function)
296
+ def decode(data: bytes) -> Any:
297
+ if hasattr(data, "read"):
298
+ buf = data
299
+ else:
300
+ buf = BytesIO(data)
301
+ if buf.read(8) != decode._owner_cls._get_packed_fingerprint(): # type: ignore[attr-defined]
302
+ raise ValueError("Decode error: fingerprint mismatch")
303
+ return decode._owner_cls._decode_one(buf) # type: ignore[attr-defined]
304
+
305
+ # Build _encode_one (minimal, for encode support)
306
+ def _encode_one(self: Any, buf: BytesIO) -> None:
307
+ for ms in member_specs:
308
+ _encode_member(self, buf, ms, registry)
309
+
310
+ def encode(self: Any) -> bytes:
311
+ buf = BytesIO()
312
+ buf.write(self.__class__._get_packed_fingerprint())
313
+ self._encode_one(buf)
314
+ return buf.getvalue()
315
+
316
+ def get_hash(self: Any) -> int:
317
+ return _struct.unpack(">Q", self.__class__._get_packed_fingerprint())[0]
318
+
319
+ # Create the class
320
+ cls_dict: Dict[str, Any] = {
321
+ "__slots__": member_names,
322
+ "__init__": init_fn,
323
+ "_decode_one": staticmethod(decode_one_fn),
324
+ "_get_hash_recursive": staticmethod(hash_recursive_fn),
325
+ "_get_packed_fingerprint": staticmethod(_get_packed_fingerprint),
326
+ "decode": staticmethod(decode),
327
+ "_encode_one": _encode_one,
328
+ "encode": encode,
329
+ "get_hash": get_hash,
330
+ "__typenames__": [m.type_name for m in lcm_struct.members],
331
+ "__dimensions__": [
332
+ [
333
+ int(d.size) if d.mode == "const" else d.size
334
+ for d in m.dimensions
335
+ ] if m.dimensions else None
336
+ for m in lcm_struct.members
337
+ ],
338
+ }
339
+
340
+ # Add constants
341
+ for c in lcm_struct.constants:
342
+ if c.type_name in ("int8_t", "int16_t", "int32_t"):
343
+ cls_dict[c.name] = int(c.value_str, 0)
344
+ elif c.type_name == "int64_t":
345
+ cls_dict[c.name] = int(c.value_str, 0)
346
+ elif c.type_name in ("float", "double"):
347
+ cls_dict[c.name] = float(c.value_str)
348
+
349
+ cls = type(short_name, (object,), cls_dict)
350
+
351
+ # Store back-references so functions can find the owning class
352
+ decode_one_fn._owner_cls = cls # type: ignore[attr-defined]
353
+ decode._owner_cls = cls # type: ignore[attr-defined]
354
+
355
+ return cls
356
+
357
+
358
+ def _build_hash_recursive(lcm_struct: LcmStruct, registry: "TypeRegistry") -> callable:
359
+ """Build _get_hash_recursive(parents) matching lcm-gen output (raw function)."""
360
+ base_hash = lcm_struct.base_hash
361
+ short_name = lcm_struct.short_name
362
+
363
+ # Pre-compute which members are compound types
364
+ compound_members = []
365
+ for m in lcm_struct.members:
366
+ if not _is_primitive(m.type_name):
367
+ compound_members.append(m.type_name)
368
+
369
+ def _get_hash_recursive(parents: list) -> int:
370
+ if _get_hash_recursive._short_name in parents: # type: ignore[attr-defined]
371
+ return 0
372
+ newparents = parents + [_get_hash_recursive._short_name] # type: ignore[attr-defined]
373
+ tmphash = _get_hash_recursive._base_hash # type: ignore[attr-defined]
374
+ for type_name in _get_hash_recursive._compound_members: # type: ignore[attr-defined]
375
+ ref_cls = _get_hash_recursive._registry._classes_by_name.get(type_name) # type: ignore[attr-defined]
376
+ if ref_cls is not None:
377
+ tmphash = (tmphash + ref_cls._get_hash_recursive(newparents)) & 0xFFFFFFFFFFFFFFFF
378
+ tmphash = (((tmphash << 1) & 0xFFFFFFFFFFFFFFFF) + (tmphash >> 63)) & 0xFFFFFFFFFFFFFFFF
379
+ return tmphash
380
+
381
+ _get_hash_recursive._base_hash = base_hash # type: ignore[attr-defined]
382
+ _get_hash_recursive._short_name = short_name # type: ignore[attr-defined]
383
+ _get_hash_recursive._compound_members = compound_members # type: ignore[attr-defined]
384
+ _get_hash_recursive._registry = registry # type: ignore[attr-defined]
385
+
386
+ return _get_hash_recursive
387
+
388
+
389
+ def _encode_member(self: Any, buf: BytesIO, ms: dict, registry: "TypeRegistry") -> None:
390
+ """Encode a single member."""
391
+ name = ms["name"]
392
+ type_name = ms["type"]
393
+ dims = ms["dims"]
394
+ value = getattr(self, name)
395
+
396
+ if not dims:
397
+ _encode_scalar(buf, value, type_name, registry)
398
+ else:
399
+ _encode_array(buf, value, type_name, dims, 0, self, registry)
400
+
401
+
402
+ def _encode_scalar(buf: BytesIO, value: Any, type_name: str, registry: "TypeRegistry") -> None:
403
+ if type_name == "string":
404
+ encoded = value.encode("utf-8")
405
+ buf.write(_struct.pack(">I", len(encoded) + 1))
406
+ buf.write(encoded)
407
+ buf.write(b"\x00")
408
+ elif type_name == "boolean":
409
+ buf.write(_struct.pack("b", int(value)))
410
+ elif type_name in _PRIM_FORMAT:
411
+ buf.write(_struct.pack(_PRIM_FORMAT[type_name], value))
412
+ else:
413
+ # Compound
414
+ value._encode_one(buf)
415
+
416
+
417
+ def _encode_array(
418
+ buf: BytesIO, value: Any, type_name: str,
419
+ dims: list, dim_idx: int, self: Any, registry: "TypeRegistry"
420
+ ) -> None:
421
+ if dim_idx == len(dims):
422
+ _encode_scalar(buf, value, type_name, registry)
423
+ return
424
+
425
+ is_last = (dim_idx == len(dims) - 1)
426
+ if is_last and _is_primitive(type_name) and type_name != "string":
427
+ _encode_prim_array(buf, value, type_name)
428
+ else:
429
+ for item in value:
430
+ if is_last:
431
+ _encode_scalar(buf, item, type_name, registry)
432
+ else:
433
+ _encode_array(buf, item, type_name, dims, dim_idx + 1, self, registry)
434
+
435
+
436
+ def _encode_prim_array(buf: BytesIO, value: Any, type_name: str) -> None:
437
+ if type_name == "byte":
438
+ buf.write(bytearray(value))
439
+ return
440
+ fmt_char = _PRIM_FMT_CHAR[type_name]
441
+ count = len(value)
442
+ if type_name == "boolean":
443
+ buf.write(_struct.pack(f">{count}{fmt_char}", *[int(v) for v in value]))
444
+ else:
445
+ buf.write(_struct.pack(f">{count}{fmt_char}", *value))
446
+
447
+
448
+ # ---------------------------------------------------------------------------
449
+ # Type Registry
450
+ # ---------------------------------------------------------------------------
451
+
452
+ class TypeRegistry:
453
+ """Registry of dynamically generated LCM decode classes.
454
+
455
+ Parses .lcm files and maintains a mapping from fingerprints and
456
+ type names to generated classes. Supports auto-matching by fingerprint.
457
+ """
458
+
459
+ def __init__(self) -> None:
460
+ self._structs: List[LcmStruct] = []
461
+ self._classes: Dict[str, type] = {} # full_name -> class
462
+ self._classes_by_name: Dict[str, type] = {} # various name forms -> class
463
+ self._by_fingerprint: Dict[int, type] = {} # fingerprint int -> class
464
+ self._built = False
465
+
466
+ def register_file(self, lcm_path: str | Path) -> None:
467
+ """Parse a .lcm file and register all structs found.
468
+
469
+ Also auto-discovers sibling .lcm files in the same directory to
470
+ resolve cross-file type references (nested struct dependencies).
471
+
472
+ Args:
473
+ lcm_path: Path to a .lcm file.
474
+ """
475
+ p = Path(lcm_path)
476
+ structs = parse_lcm_file(p)
477
+ self._structs.extend(structs)
478
+ self._built = False
479
+
480
+ # Auto-discover sibling .lcm files for cross-file type references
481
+ parent = p.parent
482
+ if parent.is_dir():
483
+ registered_files = {s.source_file for s in self._structs}
484
+ for sibling in sorted(parent.glob("*.lcm")):
485
+ sibling_str = str(sibling)
486
+ if sibling_str not in registered_files and sibling != p:
487
+ try:
488
+ sib_structs = parse_lcm_file(sibling)
489
+ self._structs.extend(sib_structs)
490
+ except Exception:
491
+ pass # Skip unparseable files silently
492
+
493
+ def register_dir(self, dir_path: str | Path) -> None:
494
+ """Recursively register all .lcm files in a directory.
495
+
496
+ Args:
497
+ dir_path: Path to a directory containing .lcm files.
498
+ """
499
+ p = Path(dir_path)
500
+ if p.is_file() and p.suffix == ".lcm":
501
+ self.register_file(p)
502
+ return
503
+ for lcm_file in sorted(p.rglob("*.lcm")):
504
+ self.register_file(lcm_file)
505
+
506
+ def register_paths(self, paths: List[str | Path]) -> None:
507
+ """Register multiple files/directories."""
508
+ for p in paths:
509
+ path = Path(p)
510
+ if path.is_dir():
511
+ self.register_dir(path)
512
+ elif path.is_file():
513
+ self.register_file(path)
514
+ else:
515
+ raise FileNotFoundError(f"LCM path not found: {p}")
516
+
517
+ def _build_all(self) -> None:
518
+ """Compute fingerprints and build all classes."""
519
+ if self._built:
520
+ return
521
+
522
+ # Compute fingerprints across ALL registered structs
523
+ compute_fingerprints(self._structs)
524
+
525
+ # Build classes
526
+ for s in self._structs:
527
+ cls = build_lcm_class(s, self)
528
+ self._classes[s.full_name] = cls
529
+
530
+ # Register under multiple name forms for lookup
531
+ self._classes_by_name[s.full_name] = cls
532
+ if s.short_name not in self._classes_by_name:
533
+ self._classes_by_name[s.short_name] = cls
534
+
535
+ # Register by fingerprint
536
+ self._by_fingerprint[s.hash_value] = cls
537
+
538
+ self._built = True
539
+
540
+ def find_by_fingerprint(self, fp: int) -> Optional[type]:
541
+ """Find a decode class by LCM fingerprint.
542
+
543
+ Args:
544
+ fp: The 64-bit fingerprint integer.
545
+
546
+ Returns:
547
+ The decode class, or None if not found.
548
+ """
549
+ self._build_all()
550
+ return self._by_fingerprint.get(fp)
551
+
552
+ def find_by_name(self, name: str) -> Optional[type]:
553
+ """Find a decode class by type name.
554
+
555
+ Accepts short name ("example_t"), fully-qualified ("exlcm.example_t"),
556
+ or module.Class format ("exlcm.example_t").
557
+
558
+ Args:
559
+ name: Type name to search for.
560
+
561
+ Returns:
562
+ The decode class, or None if not found.
563
+ """
564
+ self._build_all()
565
+ return self._classes_by_name.get(name)
566
+
567
+ @property
568
+ def all_types(self) -> Dict[str, type]:
569
+ """Return all registered classes keyed by full name."""
570
+ self._build_all()
571
+ return dict(self._classes)
572
+
573
+ @property
574
+ def all_fingerprints(self) -> Dict[int, str]:
575
+ """Return mapping from fingerprint to type full name."""
576
+ self._build_all()
577
+ return {fp: cls.__name__ for fp, cls in self._by_fingerprint.items()}