ltcodecs 4.4.2__tar.gz → 4.5.1__tar.gz

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.
Files changed (70) hide show
  1. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/PKG-INFO +2 -2
  2. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/codec_configs.rst +24 -0
  3. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/reference/codec_aliases.rst +3 -0
  4. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/reference/codecs.rst +4 -0
  5. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/reference/config_schema.rst +10 -3
  6. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/reference/python_api.rst +8 -0
  7. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/pyproject.toml +7 -2
  8. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/__init__.py +2 -0
  9. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/inspection.py +24 -2
  10. ltcodecs-4.5.1/src/ltcodecs/variable_len_array_optional_codec.py +103 -0
  11. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs.egg-info/PKG-INFO +2 -2
  12. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs.egg-info/SOURCES.txt +1 -0
  13. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs.egg-info/requires.txt +1 -1
  14. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs.egg-info/scm_file_list.json +1 -0
  15. ltcodecs-4.5.1/src/ltcodecs.egg-info/scm_version.json +8 -0
  16. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/tests/test_codecs.py +65 -0
  17. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/tests/test_dict_codec.py +61 -0
  18. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/tests/test_inspection.py +32 -0
  19. ltcodecs-4.4.2/src/ltcodecs.egg-info/scm_version.json +0 -8
  20. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/.gitignore +0 -0
  21. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/.gitlab/merge_request_templates/Default.md +0 -0
  22. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/.gitlab-ci.yml +0 -0
  23. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/CONTRIBUTING.md +0 -0
  24. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/LICENSE +0 -0
  25. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/README.md +0 -0
  26. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/Makefile +0 -0
  27. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/conf.py +0 -0
  28. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/index.rst +0 -0
  29. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/quickstart.rst +0 -0
  30. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/requirements.txt +0 -0
  31. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/ros_messages.rst +0 -0
  32. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/docs/tests.rst +0 -0
  33. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/requirements.txt +0 -0
  34. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/setup.cfg +0 -0
  35. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/_length_modes.py +0 -0
  36. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/bool_codec.py +0 -0
  37. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/bytes_codec.py +0 -0
  38. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/ccl_latlon_bcd_codec.py +0 -0
  39. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/ccl_latlon_codec.py +0 -0
  40. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/constant_codec.py +0 -0
  41. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/dict_codec.py +0 -0
  42. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/dict_field_codec.py +0 -0
  43. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/exceptions.py +0 -0
  44. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/exponentialgolomb_integer_codec.py +0 -0
  45. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/field_codec.py +0 -0
  46. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/fixed_len_array_codec.py +0 -0
  47. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/fixedint_codec.py +0 -0
  48. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/float_codec.py +0 -0
  49. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/ieee_float_codec.py +0 -0
  50. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/inspect_cli.py +0 -0
  51. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/linspace_float_codec.py +0 -0
  52. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/lzma_codec.py +0 -0
  53. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/metadata_field_codec.py +0 -0
  54. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/multiple_field_codec.py +0 -0
  55. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/optional_field_codec.py +0 -0
  56. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/padding_codec.py +0 -0
  57. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/ros_message_codec.py +0 -0
  58. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/ros_msg_field_codec.py +0 -0
  59. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/rostime_codec.py +0 -0
  60. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/string_codecs.py +0 -0
  61. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/string_enum_codec.py +0 -0
  62. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/variable_len_array_codec.py +0 -0
  63. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/variable_len_bit_array_codec.py +0 -0
  64. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs/varint_codec.py +0 -0
  65. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs.egg-info/dependency_links.txt +0 -0
  66. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs.egg-info/entry_points.txt +0 -0
  67. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/src/ltcodecs.egg-info/top_level.txt +0 -0
  68. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/tests/__init__.py +0 -0
  69. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/tests/dict_codec.yaml +0 -0
  70. {ltcodecs-4.4.2 → ltcodecs-4.5.1}/tests/string_msg_codec.yaml +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ltcodecs
3
- Version: 4.4.2
3
+ Version: 4.5.1
4
4
  Summary: LT Codecs: Lightweight codecs for use over low-throughput links
5
5
  Author: Woods Hole Oceanographic Institution, Acoustic Communications Group
6
6
  License: LGPL-3.0-or-later
@@ -16,7 +16,7 @@ Requires-Python: >=3.6
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: armw
19
- Requires-Dist: bitstring
19
+ Requires-Dist: bitstring<5,>=4
20
20
  Requires-Dist: msgpack
21
21
  Requires-Dist: crccheck
22
22
  Requires-Dist: rospy-yaml-include
@@ -284,6 +284,30 @@ target fields use no bits.
284
284
  The input dictionary or ROS message must contain both ``has_extra`` and
285
285
  ``extra`` when ``has_extra`` is true.
286
286
 
287
+ Array-Optional Fields
288
+ ---------------------
289
+
290
+ Use ``variable_len_array_optional`` when the presence of additional fields
291
+ depends on a variable-length array. It uses the same array parameters as
292
+ ``variable_len_array``. By default, ``target_fields`` are encoded immediately
293
+ after the array only when the encoded array is nonempty. Set
294
+ ``target_fields_length_threshold`` to require more than that many elements.
295
+
296
+ .. code-block:: yaml
297
+
298
+ samples:
299
+ codec: variable_len_array_optional
300
+ element_type: uint8
301
+ max_length: 10
302
+ target_fields:
303
+ sample_time:
304
+ codec: uint32
305
+ value: 0
306
+
307
+ When ``samples`` is empty, ``sample_time`` uses no bits and decodes to ``0``.
308
+ Without ``value``, the field is absent from the decoded result. When the array
309
+ is nonempty, the input must provide ``sample_time`` and its value is encoded.
310
+
287
311
  Padding
288
312
  -------
289
313
 
@@ -51,6 +51,9 @@ Alias Codec Class
51
51
  ``variable_len_bit_array``
52
52
  :py:class:`~ltcodecs.variable_len_bit_array_codec.VariableLenBitArrayCodec`
53
53
 
54
+ ``variable_len_array_optional``
55
+ :py:class:`~ltcodecs.variable_len_array_optional_codec.VariableLenArrayOptionalCodec`
56
+
54
57
  Metadata Aliases
55
58
  ----------------
56
59
 
@@ -85,6 +85,10 @@ Container Codecs
85
85
  and a three-bit count of unused final-byte bits. Configure the derived field
86
86
  with ``bit_length_field``; it is not a separate configured wire field.
87
87
 
88
+ ``variable_len_array_optional``
89
+ Encodes a variable-length array followed by ``target_fields`` when the
90
+ encoded array length exceeds ``target_fields_length_threshold`` (default 0).
91
+
88
92
  ``fixed_len_array``
89
93
  Encodes a fixed number of elements. The encoded bitstream does not include
90
94
  a length field, so the decoder always reads ``length`` elements.
@@ -38,8 +38,9 @@ Common Field Keys
38
38
  codecs.
39
39
 
40
40
  ``min_length``
41
- Minimum number of elements for ``variable_len_array``. Encoding fails when
42
- the provided list is shorter than this value.
41
+ Minimum number of elements for ``variable_len_array`` and
42
+ ``variable_len_array_optional``. Encoding fails when the provided list is
43
+ shorter than this value.
43
44
 
44
45
  ``length_mode``
45
46
  Length handling mode for ``string``, ``bytes``, and
@@ -69,7 +70,13 @@ Common Field Keys
69
70
  absent/present bit.
70
71
 
71
72
  ``target_fields``
72
- Field mapping controlled by an ``optional`` field.
73
+ Field mapping controlled by an ``optional`` or
74
+ ``variable_len_array_optional`` field. For the latter, an individual target
75
+ may set ``value`` to supply its decoded value when it is not encoded.
76
+
77
+ ``target_fields_length_threshold``
78
+ For ``variable_len_array_optional``, target fields are present when the
79
+ encoded array length is greater than this value. Defaults to 0.
73
80
 
74
81
  ``ros_type``
75
82
  ROS message type for ``msg`` fields. ROS 1 and ROS 2 type names are
@@ -197,6 +197,14 @@ ltcodecs.variable\_len\_array\_codec module
197
197
  :undoc-members:
198
198
  :show-inheritance:
199
199
 
200
+ ltcodecs.variable\_len\_array\_optional\_codec module
201
+ -----------------------------------------------------
202
+
203
+ .. automodule:: ltcodecs.variable_len_array_optional_codec
204
+ :members:
205
+ :undoc-members:
206
+ :show-inheritance:
207
+
200
208
  ltcodecs.variable\_len\_bit\_array\_codec module
201
209
  ---------------------------------------------------
202
210
 
@@ -11,7 +11,7 @@ classifiers = [
11
11
  "Topic :: Scientific/Engineering",
12
12
  ]
13
13
  requires-python = ">=3.6"
14
- dependencies = ["armw", "bitstring", "msgpack", "crccheck", "rospy-yaml-include"]
14
+ dependencies = ["armw", "bitstring>=4,<5", "msgpack", "crccheck", "rospy-yaml-include"]
15
15
 
16
16
  readme = {file = "README.md", content-type = "text/markdown"}
17
17
 
@@ -32,7 +32,12 @@ inspect_codec = "ltcodecs.inspect_cli:main"
32
32
  # No optional dependencies
33
33
 
34
34
  [build-system]
35
- requires = ["setuptools>=64", "wheel", "setuptools_scm>=8"]
35
+ requires = [
36
+ "setuptools>=64",
37
+ "wheel",
38
+ "setuptools_scm>=8; python_version >= '3.8'",
39
+ "setuptools_scm>=7,<8; python_version < '3.8'",
40
+ ]
36
41
  build-backend = "setuptools.build_meta"
37
42
 
38
43
  [tool.setuptools_scm]
@@ -12,6 +12,7 @@ from .string_enum_codec import StringEnumCodec
12
12
  from .bytes_codec import BytesCodec
13
13
  from .metadata_field_codec import MetadataFieldCodec
14
14
  from .variable_len_array_codec import VariableLenArrayCodec
15
+ from .variable_len_array_optional_codec import VariableLenArrayOptionalCodec
15
16
  from .variable_len_bit_array_codec import VariableLenBitArrayCodec
16
17
  from .fixed_len_array_codec import FixedLenArrayCodec
17
18
  from .dict_field_codec import DictFieldCodec
@@ -56,6 +57,7 @@ field_codec_classes = {
56
57
  "msg": RosMsgFieldCodec,
57
58
  "ros_msg": RosMsgFieldCodec,
58
59
  "variable_len_array": VariableLenArrayCodec,
60
+ "variable_len_array_optional": VariableLenArrayOptionalCodec,
59
61
  "variable_len_bit_array": VariableLenBitArrayCodec,
60
62
  "fixed_len_array": FixedLenArrayCodec,
61
63
  "ccl_latlon": CclLatLonCodec,
@@ -209,9 +209,9 @@ class CodecInspector:
209
209
  continue
210
210
  if codec_name in ("dict", "dictionary"):
211
211
  issues.extend(cls._validate_fields(params.get("fields"), path))
212
- elif codec_name == "optional":
212
+ elif codec_name in ("optional", "variable_len_array_optional"):
213
213
  issues.extend(cls._validate_fields(params.get("target_fields"), path))
214
- elif codec_name in ("variable_len_array", "fixed_len_array"):
214
+ if codec_name in ("variable_len_array", "variable_len_array_optional", "fixed_len_array"):
215
215
  element_type = params.get("element_type")
216
216
  if not isinstance(element_type, str):
217
217
  issues.append(ValidationIssue(path, "Missing required string parameter 'element_type'"))
@@ -269,6 +269,28 @@ class CodecInspector:
269
269
  cls._make_conditional(child)
270
270
  return FieldReport(path, codec_name, "controls following fields", 1, 1 + child_max, children), []
271
271
 
272
+ if codec_name == "variable_len_array_optional":
273
+ array_params = dict(params)
274
+ array_params["codec"] = "variable_len_array"
275
+ array_report, issues = cls._inspect_array(array_params, path)
276
+ if issues:
277
+ return None, issues
278
+ children, issues, _child_min, child_max = cls._inspect_fields(params["target_fields"], path)
279
+ if issues:
280
+ return None, issues
281
+ try:
282
+ field_codec = ltcodecs.VariableLenArrayOptionalCodec(**params)
283
+ except Exception as err:
284
+ return None, [ValidationIssue(path, str(err))]
285
+ if field_codec.array_codec.min_length <= field_codec.target_fields_length_threshold:
286
+ for child in children:
287
+ cls._make_conditional(child)
288
+ return FieldReport(
289
+ path, codec_name, "array controls following fields",
290
+ field_codec.min_length_bits, field_codec.max_length_bits,
291
+ array_report.children + children,
292
+ ), []
293
+
272
294
  if codec_name in ("variable_len_array", "fixed_len_array"):
273
295
  return cls._inspect_array(params, path)
274
296
 
@@ -0,0 +1,103 @@
1
+ """Variable-length arrays that control optional fields."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from copy import deepcopy
6
+
7
+ from bitstring import BitArray, ConstBitStream
8
+
9
+ import ltcodecs as ltcodecs
10
+
11
+ from .exceptions import EncodingFailed
12
+ from .field_codec import encode_configured_field
13
+ from .multiple_field_codec import MultipleFieldCodec
14
+ from .variable_len_array_codec import VariableLenArrayCodec
15
+
16
+
17
+ class VariableLenArrayOptionalCodec(MultipleFieldCodec):
18
+ """Encode an array, followed by target fields when its length exceeds a threshold.
19
+
20
+ Array parameters match :class:`VariableLenArrayCodec`. The default
21
+ ``target_fields_length_threshold`` of zero includes targets for any
22
+ nonempty encoded array. A target field's optional ``value`` parameter
23
+ supplies its decoded value when the array omits that target.
24
+ """
25
+
26
+ def __init__(
27
+ self, element_type: str, max_length: int, target_fields: dict,
28
+ target_fields_length_threshold: int = 0, **kwargs,
29
+ ) -> None:
30
+ if not isinstance(target_fields_length_threshold, int) or not 0 <= target_fields_length_threshold < max_length:
31
+ raise ValueError("target_fields_length_threshold must be an integer from 0 to max_length - 1")
32
+ if kwargs.get("length_mode") == "fill_outer":
33
+ raise ValueError("VariableLenArrayOptionalCodec requires an encoded array length")
34
+
35
+ self.array_codec = VariableLenArrayCodec(
36
+ element_type=element_type, max_length=max_length, **kwargs
37
+ )
38
+ self.target_fields = target_fields or {}
39
+ self.target_fields_length_threshold = target_fields_length_threshold
40
+ self.absent_values = {
41
+ field_name: field_params["value"]
42
+ for field_name, field_params in self.target_fields.items()
43
+ if "value" in field_params
44
+ }
45
+ self.field_codecs = {}
46
+ for field_name, field_params in self.target_fields.items():
47
+ try:
48
+ self.field_codecs[field_name] = ltcodecs.field_codec_classes[field_params["codec"]](**field_params)
49
+ except KeyError as err:
50
+ raise KeyError(
51
+ f"Error parsing codec config for {field_name}. Got params:\n{field_params}\nError: {err}"
52
+ ) from err
53
+
54
+ def encode_multiple(self, value: list, message_dict: dict) -> tuple[BitArray, list, dict]:
55
+ bits, encoded_array = self.array_codec.encode(value)
56
+ if len(encoded_array) <= self.target_fields_length_threshold:
57
+ return bits, encoded_array, deepcopy(self.absent_values)
58
+
59
+ encoded_fields = {}
60
+ for field_name in self.target_fields:
61
+ field_codec = self.field_codecs[field_name]
62
+ try:
63
+ if isinstance(field_codec, MultipleFieldCodec):
64
+ field_bits, encoded_fields[field_name], extra_fields = field_codec.encode_multiple(
65
+ message_dict[field_name], message_dict
66
+ )
67
+ encoded_fields.update(extra_fields)
68
+ else:
69
+ field_bits, encoded_fields[field_name] = encode_configured_field(
70
+ field_codec, message_dict, field_name
71
+ )
72
+ bits.append(field_bits)
73
+ except Exception as err:
74
+ raise EncodingFailed(f'Error encoding target field "{field_name}"') from err
75
+ return bits, encoded_array, encoded_fields
76
+
77
+ def decode_multiple(self, bits_to_decode: ConstBitStream) -> tuple[list, dict]:
78
+ decoded_array = self.array_codec.decode(bits_to_decode)
79
+ if len(decoded_array) <= self.target_fields_length_threshold:
80
+ return decoded_array, deepcopy(self.absent_values)
81
+
82
+ decoded_fields = {}
83
+ for field_name in self.target_fields:
84
+ field_codec = self.field_codecs[field_name]
85
+ if isinstance(field_codec, MultipleFieldCodec):
86
+ decoded_fields[field_name], extra_fields = field_codec.decode_multiple(bits_to_decode)
87
+ decoded_fields.update(extra_fields)
88
+ else:
89
+ decoded_fields[field_name] = field_codec.decode(bits_to_decode)
90
+ return decoded_array, decoded_fields
91
+
92
+ @property
93
+ def min_length_bits(self) -> int:
94
+ target_bits = 0
95
+ if self.array_codec.min_length > self.target_fields_length_threshold:
96
+ target_bits = sum(codec.min_length_bits for codec in self.field_codecs.values())
97
+ return self.array_codec.min_length_bits + target_bits
98
+
99
+ @property
100
+ def max_length_bits(self) -> int:
101
+ return self.array_codec.max_length_bits + sum(
102
+ codec.max_length_bits for codec in self.field_codecs.values()
103
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ltcodecs
3
- Version: 4.4.2
3
+ Version: 4.5.1
4
4
  Summary: LT Codecs: Lightweight codecs for use over low-throughput links
5
5
  Author: Woods Hole Oceanographic Institution, Acoustic Communications Group
6
6
  License: LGPL-3.0-or-later
@@ -16,7 +16,7 @@ Requires-Python: >=3.6
16
16
  Description-Content-Type: text/markdown
17
17
  License-File: LICENSE
18
18
  Requires-Dist: armw
19
- Requires-Dist: bitstring
19
+ Requires-Dist: bitstring<5,>=4
20
20
  Requires-Dist: msgpack
21
21
  Requires-Dist: crccheck
22
22
  Requires-Dist: rospy-yaml-include
@@ -48,6 +48,7 @@ src/ltcodecs/rostime_codec.py
48
48
  src/ltcodecs/string_codecs.py
49
49
  src/ltcodecs/string_enum_codec.py
50
50
  src/ltcodecs/variable_len_array_codec.py
51
+ src/ltcodecs/variable_len_array_optional_codec.py
51
52
  src/ltcodecs/variable_len_bit_array_codec.py
52
53
  src/ltcodecs/varint_codec.py
53
54
  src/ltcodecs.egg-info/PKG-INFO
@@ -1,5 +1,5 @@
1
1
  armw
2
- bitstring
2
+ bitstring<5,>=4
3
3
  msgpack
4
4
  crccheck
5
5
  rospy-yaml-include
@@ -50,6 +50,7 @@
50
50
  "src/ltcodecs/string_codecs.py",
51
51
  "src/ltcodecs/string_enum_codec.py",
52
52
  "src/ltcodecs/variable_len_array_codec.py",
53
+ "src/ltcodecs/variable_len_array_optional_codec.py",
53
54
  "src/ltcodecs/variable_len_bit_array_codec.py",
54
55
  "src/ltcodecs/varint_codec.py",
55
56
  "tests/__init__.py",
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "4.5.1",
3
+ "distance": 0,
4
+ "node": "g9f9b23b3e2382e44090cbdcbfb1c66e66c2b621a",
5
+ "dirty": false,
6
+ "branch": "HEAD",
7
+ "node_date": "2026-10-21"
8
+ }
@@ -600,6 +600,71 @@ class TestCodecs:
600
600
  assert isinstance(decoded["nested"], String)
601
601
  assert decoded["nested"].data == msg.data
602
602
 
603
+ def test_variable_len_array_optional_length_controls_targets(self) -> None:
604
+ # An empty array carries no target bits; a nonempty array carries both targets.
605
+ codec = ltcodecs.VariableLenArrayOptionalCodec(
606
+ element_type="uint8",
607
+ max_length=3,
608
+ target_fields={
609
+ "sample_time": {"codec": "uint8", "value": 0},
610
+ "quality": {"codec": "uint8"},
611
+ },
612
+ )
613
+
614
+ empty_bits, empty_array, empty_fields = codec.encode_multiple([], {})
615
+ decoded_array, decoded_fields = codec.decode_multiple(ConstBitStream(empty_bits))
616
+ assert empty_array == decoded_array == []
617
+ assert empty_fields == decoded_fields == {"sample_time": 0}
618
+ assert len(empty_bits) == len(codec.array_codec.encode([])[0])
619
+
620
+ source = {"sample_time": 37, "quality": 5}
621
+ present_bits, present_array, present_fields = codec.encode_multiple([9], source)
622
+ decoded_array, decoded_fields = codec.decode_multiple(ConstBitStream(present_bits))
623
+ assert present_array == decoded_array == [9]
624
+ assert present_fields == decoded_fields == source
625
+ assert len(present_bits) == len(codec.array_codec.encode([9])[0]) + 16
626
+
627
+ def test_variable_len_array_optional_copies_absent_value(self) -> None:
628
+ # Mutating one decoded fallback must not change later decoded messages.
629
+ codec = ltcodecs.VariableLenArrayOptionalCodec(
630
+ element_type="uint8",
631
+ max_length=2,
632
+ target_fields={
633
+ "details": {
634
+ "codec": "dict",
635
+ "fields": {"count": {"codec": "uint8"}},
636
+ "value": {"count": 0},
637
+ },
638
+ },
639
+ )
640
+
641
+ bits, _, _ = codec.encode_multiple([], {})
642
+ first = codec.decode_multiple(ConstBitStream(bits))[1]
643
+ first["details"]["count"] = 99
644
+ second = codec.decode_multiple(ConstBitStream(bits))[1]
645
+ assert second == {"details": {"count": 0}}
646
+
647
+ def test_variable_len_array_optional_absent_value_matches_checksum(self) -> None:
648
+ # The checksum must use the same fallback or wire value that decode returns.
649
+ codec = ltcodecs.DictCodec(
650
+ {
651
+ "samples": {
652
+ "codec": "variable_len_array_optional",
653
+ "element_type": "uint8",
654
+ "max_length": 2,
655
+ "target_fields": {"sample_time": {"codec": "uint8", "value": 0}},
656
+ },
657
+ },
658
+ checksum="crc8",
659
+ )
660
+
661
+ for source, expected in (
662
+ ({"samples": []}, {"samples": [], "sample_time": 0}),
663
+ ({"samples": [9], "sample_time": 37}, {"samples": [9], "sample_time": 37}),
664
+ ):
665
+ bits, _ = codec.encode(source)
666
+ assert codec.decode(ConstBitStream(bits)) == expected
667
+
603
668
  def test_ros_message_field_codec(self) -> None:
604
669
  """test functionality of the ros message field codec"""
605
670
 
@@ -184,3 +184,64 @@ def test_dict_codec_variable_length_bit_array_derives_bit_length() -> None:
184
184
  decoded_msg = codec.decode(ConstBitStream(encoded_msg[0]))
185
185
 
186
186
  assert decoded_msg == {"payload": [0xAB, 0xC0], "payload_size_in_bits": 13}
187
+
188
+
189
+ def test_variable_len_array_optional_omits_targets_for_empty_array() -> None:
190
+ # At the dictionary boundary, target fields appear only for nonempty arrays.
191
+ codec = ltcodecs.DictCodec({
192
+ "samples": {
193
+ "codec": "variable_len_array_optional",
194
+ "element_type": "uint8",
195
+ "max_length": 4,
196
+ "target_fields": {"sample_time": {"codec": "uint32"}},
197
+ },
198
+ })
199
+
200
+ for source in ({"samples": []}, {"samples": [3, 5], "sample_time": 123}):
201
+ bits, encoded = codec.root_field_codec.encode(source)
202
+ assert encoded == source
203
+ assert codec.decode(ConstBitStream(bits)) == source
204
+
205
+
206
+ def test_variable_len_array_optional_uses_encoded_length_and_threshold() -> None:
207
+ # With a threshold of one, one element omits targets and two include them.
208
+ codec = ltcodecs.DictCodec({
209
+ "samples": {
210
+ "codec": "variable_len_array_optional",
211
+ "element_type": "uint8",
212
+ "max_length": 2,
213
+ "target_fields_length_threshold": 1,
214
+ "target_fields": {"sample_time": {"codec": "uint32"}},
215
+ },
216
+ })
217
+
218
+ for source in ({"samples": [3]}, {"samples": [3, 5], "sample_time": 123}):
219
+ bits, encoded = codec.root_field_codec.encode(source)
220
+ assert codec.decode(ConstBitStream(bits)) == encoded
221
+
222
+ # Truncation to max_length determines the array length used on the wire.
223
+ bits, encoded = codec.root_field_codec.encode({"samples": [3, 5, 7], "sample_time": 123})
224
+ assert encoded == {"samples": [3, 5], "sample_time": 123}
225
+ assert codec.decode(ConstBitStream(bits)) == encoded
226
+
227
+
228
+ def test_variable_len_array_optional_supports_nested_target_controller() -> None:
229
+ # A present target can itself control another optional field.
230
+ codec = ltcodecs.DictCodec({
231
+ "samples": {
232
+ "codec": "variable_len_array_optional",
233
+ "element_type": "uint8",
234
+ "max_length": 2,
235
+ "target_fields": {
236
+ "has_extra": {
237
+ "codec": "optional",
238
+ "target_fields": {"extra": {"codec": "uint8"}},
239
+ },
240
+ },
241
+ },
242
+ })
243
+ source = {"samples": [1], "has_extra": True, "extra": 9}
244
+
245
+ bits, encoded = codec.root_field_codec.encode(source)
246
+ assert encoded == source
247
+ assert codec.decode(ConstBitStream(bits)) == source
@@ -31,6 +31,38 @@ def test_inspect_fields_reports_nested_and_optional_sizes() -> None:
31
31
  assert (optional_target.min_length_bits, optional_target.max_length_bits) == (0, 8)
32
32
 
33
33
 
34
+ def test_inspect_array_controlled_fields() -> None:
35
+ # With an empty array allowed, the target contributes zero minimum bits.
36
+ report = CodecInspector.inspect_fields({
37
+ "samples": {
38
+ "codec": "variable_len_array_optional",
39
+ "element_type": "uint8",
40
+ "max_length": 3,
41
+ "target_fields": {"sample_time": {"codec": "uint32"}},
42
+ },
43
+ })
44
+
45
+ assert report.is_valid
46
+ assert report.fields[0].children[0].path == "samples[]"
47
+ assert report.fields[0].children[1].path == "samples.sample_time"
48
+ assert report.fields[0].children[1].min_length_bits == 0
49
+ assert report.max_length_bits == 58
50
+
51
+ # Requiring an element makes the target mandatory in the size report.
52
+ required = CodecInspector.inspect_fields({
53
+ "samples": {
54
+ "codec": "variable_len_array_optional",
55
+ "element_type": "uint8",
56
+ "max_length": 3,
57
+ "min_length": 1,
58
+ "target_fields": {"sample_time": {"codec": "uint32"}},
59
+ },
60
+ })
61
+ assert required.is_valid
62
+ assert required.fields[0].children[1].min_length_bits == 32
63
+ assert required.min_length_bits == 42
64
+
65
+
34
66
  def test_inspect_file_resolves_include_directory(tmp_path: Path) -> None:
35
67
  include_directory = tmp_path / "includes"
36
68
  include_directory.mkdir()
@@ -1,8 +0,0 @@
1
- {
2
- "tag": "4.4.2",
3
- "distance": 0,
4
- "node": "ga9636d4658ff2656726da9b973e30356019e708d",
5
- "dirty": false,
6
- "branch": "HEAD",
7
- "node_date": "2026-10-13"
8
- }
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes