rosotacom-dev 2.2__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.
Files changed (195) hide show
  1. rosotacom/__init__.py +32 -0
  2. rosotacom/__main__.py +8 -0
  3. rosotacom/anonymize.py +360 -0
  4. rosotacom/bag_ground_truth.py +395 -0
  5. rosotacom/benched_set.py +492 -0
  6. rosotacom/benchmark.py +1842 -0
  7. rosotacom/bundle_check.py +517 -0
  8. rosotacom/cli.py +8637 -0
  9. rosotacom/cli_benchmark.py +8211 -0
  10. rosotacom/deployment.py +196 -0
  11. rosotacom/ffmpeg_packet.py +123 -0
  12. rosotacom/forensics.py +1172 -0
  13. rosotacom/geomap.py +848 -0
  14. rosotacom/network_profiles.py +563 -0
  15. rosotacom/network_shaper.py +127 -0
  16. rosotacom/plots.py +609 -0
  17. rosotacom/py.typed +0 -0
  18. rosotacom/resources/benched-set.yaml +317 -0
  19. rosotacom/resources/examples/.gitignore +1 -0
  20. rosotacom/resources/examples/README.md +163 -0
  21. rosotacom/resources/examples/__init__.py +1 -0
  22. rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml +17 -0
  23. rosotacom/resources/examples/deployment.example.yaml +14 -0
  24. rosotacom/resources/examples/profiles.yaml +85 -0
  25. rosotacom/resources/examples/ros2docker.json +33 -0
  26. rosotacom/resources/examples/rosotacom.yaml +12 -0
  27. rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml +10 -0
  28. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_a.sh +9 -0
  29. rosotacom/resources/examples/scripts/1_heartbeat/run_machine_b.sh +9 -0
  30. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/external.ros2docker.json +21 -0
  31. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_communication.sh +9 -0
  32. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_external.py +15 -0
  33. rosotacom/resources/examples/scripts/2_native_chatter/machine_a/wait_for_echo_topic.sh +10 -0
  34. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/external.ros2docker.json +19 -0
  35. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_communication.sh +9 -0
  36. rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_external.py +15 -0
  37. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external.ros2docker.json +12 -0
  38. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external_session.yaml +9 -0
  39. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/foxglove.json +89 -0
  40. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_communication.sh +9 -0
  41. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_external.py +15 -0
  42. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  43. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/metadata.yaml +44 -0
  44. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/external.ros2docker.json +13 -0
  45. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_communication.sh +9 -0
  46. rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_external.py +15 -0
  47. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external.ros2docker.json +12 -0
  48. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external_session.yaml +9 -0
  49. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/foxglove.json +89 -0
  50. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_communication.sh +9 -0
  51. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_external.py +15 -0
  52. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
  53. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/metadata.yaml +44 -0
  54. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/external.ros2docker.json +13 -0
  55. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_communication.sh +9 -0
  56. rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_external.py +15 -0
  57. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external.ros2docker.json +20 -0
  58. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external_session.yaml +22 -0
  59. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_communication.sh +9 -0
  60. rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_external.py +15 -0
  61. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external.ros2docker.json +20 -0
  62. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external_session.yaml +22 -0
  63. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_communication.sh +9 -0
  64. rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_external.py +15 -0
  65. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external.ros2docker.json +18 -0
  66. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external_session.yaml +22 -0
  67. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_communication.sh +9 -0
  68. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_external.py +15 -0
  69. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external.ros2docker.json +18 -0
  70. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external_session.yaml +22 -0
  71. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_communication.sh +9 -0
  72. rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_external.py +15 -0
  73. rosotacom/resources/examples/sessions/10_restamp/session-definition.yaml +39 -0
  74. rosotacom/resources/examples/sessions/11_trickle/session-definition.yaml +42 -0
  75. rosotacom/resources/examples/sessions/12_content_integrity/session-definition.yaml +33 -0
  76. rosotacom/resources/examples/sessions/13_link_latency/session-definition.yaml +42 -0
  77. rosotacom/resources/examples/sessions/14_remote_assist_anonymized/session-definition.yaml +211 -0
  78. rosotacom/resources/examples/sessions/15_remote_assist_anonymized_costmap/session-definition.yaml +59 -0
  79. rosotacom/resources/examples/sessions/16_remote_assist_anonymized_camera/session-definition.yaml +61 -0
  80. rosotacom/resources/examples/sessions/17_synthetic_camera_quality/session-definition.yaml +57 -0
  81. rosotacom/resources/examples/sessions/1_heartbeat/session-definition.yaml +26 -0
  82. rosotacom/resources/examples/sessions/1_heartbeat_status/session-definition.yaml +37 -0
  83. rosotacom/resources/examples/sessions/2_native_chatter/session-definition.yaml +34 -0
  84. rosotacom/resources/examples/sessions/3_comp_occ_grid/session-definition.yaml +43 -0
  85. rosotacom/resources/examples/sessions/4_comp_occ_grid_zen/session-definition.yaml +43 -0
  86. rosotacom/resources/examples/sessions/5_sized_payload/session-definition.yaml +39 -0
  87. rosotacom/resources/examples/sessions/6_sized_payload_zen/session-definition.yaml +50 -0
  88. rosotacom/resources/examples/sessions/7_latched_static/session-definition.yaml +49 -0
  89. rosotacom/resources/examples/sessions/8_drop/session-definition.yaml +37 -0
  90. rosotacom/resources/examples/sessions/9_throttle/session-definition.yaml +37 -0
  91. rosotacom/resources/examples/sessions/bench_1_1_capacity/session-definition.yaml +42 -0
  92. rosotacom/resources/examples/sessions/bench_1_2_load_sweep/session-definition.yaml +51 -0
  93. rosotacom/resources/examples/sessions/bench_1_3_ramp/session-definition.yaml +41 -0
  94. rosotacom/resources/examples/sessions/bench_1_4_recovery/session-definition.yaml +47 -0
  95. rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml +45 -0
  96. rosotacom/resources/ros2docker.json.example +33 -0
  97. rosotacom/resources/ws/.vscode/c_cpp_properties.json +20 -0
  98. rosotacom/resources/ws/.vscode/settings.json +8 -0
  99. rosotacom/resources/ws/ota_configs/archive/fastdds_easy_mode.xml.template +32 -0
  100. rosotacom/resources/ws/ota_configs/archive/fastdds_initial_peer_list.xml.template +21 -0
  101. rosotacom/resources/ws/ota_configs/archive/fastdds_v2.xml.template +17 -0
  102. rosotacom/resources/ws/ota_configs/archive/fastdds_v3.xml.template +48 -0
  103. rosotacom/resources/ws/ota_configs/archive/fastdds_v4.xml.template +95 -0
  104. rosotacom/resources/ws/ota_configs/archive/fastdds_v5.xml.template +64 -0
  105. rosotacom/resources/ws/ota_configs/create_zenoh_json5.py +56 -0
  106. rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template +9 -0
  107. rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template +33 -0
  108. rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template +71 -0
  109. rosotacom/resources/ws/ota_configs/get_ota_xml.py +214 -0
  110. rosotacom/resources/ws/ota_configs/utils.py +74 -0
  111. rosotacom/resources/ws/ota_configs/zenoh.json5.template +32 -0
  112. rosotacom/resources/ws/ros2src/.gitkeep +0 -0
  113. rosotacom/resources/ws/ros2src/com_cpp/CMakeLists.txt +67 -0
  114. rosotacom/resources/ws/ros2src/com_cpp/COLCON_IGNORE +0 -0
  115. rosotacom/resources/ws/ros2src/com_cpp/LICENSE.txt +25 -0
  116. rosotacom/resources/ws/ros2src/com_cpp/package.xml +37 -0
  117. rosotacom/resources/ws/ros2src/com_cpp/src/hz_monitor.cpp +192 -0
  118. rosotacom/resources/ws/ros2src/com_cpp/src/universal_compressor.cpp +283 -0
  119. rosotacom/resources/ws/ros2src/com_cpp/src/universal_decompressor.cpp +306 -0
  120. rosotacom/resources/ws/ros2src/com_msgs/CMakeLists.txt +22 -0
  121. rosotacom/resources/ws/ros2src/com_msgs/LICENSE.txt +25 -0
  122. rosotacom/resources/ws/ros2src/com_msgs/msg/CompressedData.msg +3 -0
  123. rosotacom/resources/ws/ros2src/com_msgs/msg/EchoHeartbeat.msg +10 -0
  124. rosotacom/resources/ws/ros2src/com_msgs/msg/OtaStamped.msg +4 -0
  125. rosotacom/resources/ws/ros2src/com_msgs/msg/SizedPayload.msg +4 -0
  126. rosotacom/resources/ws/ros2src/com_msgs/package.xml +25 -0
  127. rosotacom/resources/ws/ros2src/com_py/LICENSE.txt +25 -0
  128. rosotacom/resources/ws/ros2src/com_py/com_py/__init__.py +0 -0
  129. rosotacom/resources/ws/ros2src/com_py/com_py/base_bridge_relay.py +106 -0
  130. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_in.py +76 -0
  131. rosotacom/resources/ws/ros2src/com_py/com_py/bridge_out.py +70 -0
  132. rosotacom/resources/ws/ros2src/com_py/com_py/heartbeat_echo.py +221 -0
  133. rosotacom/resources/ws/ros2src/com_py/com_py/hz_monitor.py +178 -0
  134. rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py +218 -0
  135. rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py +370 -0
  136. rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py +80 -0
  137. rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py +205 -0
  138. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/costmap_remapper.py +73 -0
  139. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/marker_remapper.py +93 -0
  140. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/speed_test.py +107 -0
  141. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/tf_remapper.py +156 -0
  142. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/traffic_monitor.py +91 -0
  143. rosotacom/resources/ws/ros2src/com_py/com_py/legacy/wifi_network_analysis_1.py +213 -0
  144. rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py +177 -0
  145. rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py +186 -0
  146. rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py +465 -0
  147. rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py +258 -0
  148. rosotacom/resources/ws/ros2src/com_py/com_py/msg_transform.py +277 -0
  149. rosotacom/resources/ws/ros2src/com_py/com_py/pair_management.py +87 -0
  150. rosotacom/resources/ws/ros2src/com_py/com_py/pub_sub_pair.py +145 -0
  151. rosotacom/resources/ws/ros2src/com_py/com_py/qos.py +254 -0
  152. rosotacom/resources/ws/ros2src/com_py/com_py/relay_in.py +78 -0
  153. rosotacom/resources/ws/ros2src/com_py/com_py/relay_out.py +75 -0
  154. rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py +191 -0
  155. rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py +244 -0
  156. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py +64 -0
  157. rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py +51 -0
  158. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +476 -0
  159. rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py +989 -0
  160. rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py +99 -0
  161. rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py +327 -0
  162. rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py +171 -0
  163. rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py +183 -0
  164. rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py +653 -0
  165. rosotacom/resources/ws/ros2src/com_py/com_py/universal_decompressor.py +372 -0
  166. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_unwrapper.py +191 -0
  167. rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_wrapper.py +154 -0
  168. rosotacom/resources/ws/ros2src/com_py/package.xml +34 -0
  169. rosotacom/resources/ws/ros2src/com_py/resource/com_py +0 -0
  170. rosotacom/resources/ws/ros2src/com_py/setup.cfg +6 -0
  171. rosotacom/resources/ws/ros2src/com_py/setup.py +55 -0
  172. rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py +25 -0
  173. rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py +25 -0
  174. rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py +23 -0
  175. rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +633 -0
  176. rosotacom/resources/ws/session/content/network/up_connectivity.py +364 -0
  177. rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py +215 -0
  178. rosotacom/resources/ws/session/creation/anonymize_bag.py +156 -0
  179. rosotacom/resources/ws/session/creation/catmux_log_setup.sh +65 -0
  180. rosotacom/resources/ws/session/creation/create_session_yaml.py +145 -0
  181. rosotacom/resources/ws/session/creation/generate_session_files.py +3217 -0
  182. rosotacom/resources/ws/session/creation/run_session.py +447 -0
  183. rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py +54 -0
  184. rosotacom/resources/ws/session/creation/strip_ansi.py +42 -0
  185. rosotacom/status_eval.py +475 -0
  186. rosotacom/stream_stats.py +650 -0
  187. rosotacom/trace_profiles.py +561 -0
  188. rosotacom/transit.py +249 -0
  189. rosotacom/video_quality.py +906 -0
  190. rosotacom_dev-2.2.dist-info/METADATA +874 -0
  191. rosotacom_dev-2.2.dist-info/RECORD +195 -0
  192. rosotacom_dev-2.2.dist-info/WHEEL +5 -0
  193. rosotacom_dev-2.2.dist-info/entry_points.txt +4 -0
  194. rosotacom_dev-2.2.dist-info/licenses/LICENSE.txt +25 -0
  195. rosotacom_dev-2.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,517 @@
1
+ """Completeness checks for rosotacom session-instance artifact bundles."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable, Mapping, Sequence
7
+ from dataclasses import dataclass
8
+ from pathlib import Path
9
+ from typing import Any, Literal
10
+
11
+ import yaml
12
+
13
+ CheckStatus = Literal["present", "missing", "empty", "invalid"]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ExpectedPath:
18
+ path: str
19
+ label: str | None = None
20
+ required: bool = True
21
+
22
+ @property
23
+ def display_label(self) -> str:
24
+ return self.label or self.path
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class BundleCheckConfig:
29
+ peers: tuple[str, ...] = ()
30
+ files: tuple[ExpectedPath, ...] = ()
31
+ bags: tuple[ExpectedPath, ...] = ()
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class CheckResult:
36
+ status: CheckStatus
37
+ kind: str
38
+ label: str
39
+ path: str
40
+ required: bool
41
+ detail: str = ""
42
+
43
+ @property
44
+ def failed(self) -> bool:
45
+ if self.status == "present":
46
+ return False
47
+ if not self.required and self.status == "missing":
48
+ return False
49
+ return True
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class BundleCheckReport:
54
+ root: Path
55
+ results: tuple[CheckResult, ...]
56
+
57
+ @property
58
+ def failures(self) -> tuple[CheckResult, ...]:
59
+ return tuple(result for result in self.results if result.failed)
60
+
61
+ @property
62
+ def complete(self) -> bool:
63
+ return not self.failures
64
+
65
+
66
+ _MANIFEST_KEYS = {
67
+ "schema_version",
68
+ "peers",
69
+ "files",
70
+ "bags",
71
+ "required_files",
72
+ "optional_files",
73
+ "required_bags",
74
+ "optional_bags",
75
+ }
76
+ _ENTRY_KEYS = {"path", "label", "required"}
77
+
78
+
79
+ def load_bundle_manifest(path: str | Path) -> BundleCheckConfig:
80
+ manifest_path = Path(path)
81
+ loaded = yaml.safe_load(manifest_path.read_text(encoding="utf-8")) or {}
82
+ if not isinstance(loaded, Mapping):
83
+ raise RuntimeError(f"Bundle manifest must contain a mapping: {manifest_path}")
84
+ unknown = sorted(set(loaded) - _MANIFEST_KEYS)
85
+ if unknown:
86
+ raise RuntimeError(f"Unsupported bundle manifest keys in {manifest_path}: {unknown}")
87
+ schema_version = loaded.get("schema_version", 1)
88
+ if schema_version != 1:
89
+ raise RuntimeError(f"Unsupported bundle manifest schema_version {schema_version!r}; expected 1.")
90
+
91
+ files = (
92
+ *_parse_expected_path_entries(loaded.get("files", ()), default_required=True, allow_required_override=True),
93
+ *_parse_expected_path_entries(
94
+ loaded.get("required_files", ()), default_required=True, allow_required_override=False
95
+ ),
96
+ *_parse_expected_path_entries(
97
+ loaded.get("optional_files", ()), default_required=False, allow_required_override=False
98
+ ),
99
+ )
100
+ bags = (
101
+ *_parse_expected_path_entries(loaded.get("bags", ()), default_required=True, allow_required_override=True),
102
+ *_parse_expected_path_entries(
103
+ loaded.get("required_bags", ()), default_required=True, allow_required_override=False
104
+ ),
105
+ *_parse_expected_path_entries(
106
+ loaded.get("optional_bags", ()), default_required=False, allow_required_override=False
107
+ ),
108
+ )
109
+ return BundleCheckConfig(peers=_parse_peer_names(loaded.get("peers", ())), files=files, bags=bags)
110
+
111
+
112
+ def merge_bundle_configs(*configs: BundleCheckConfig) -> BundleCheckConfig:
113
+ peers: list[str] = []
114
+ files: list[ExpectedPath] = []
115
+ bags: list[ExpectedPath] = []
116
+ for config in configs:
117
+ for peer in config.peers:
118
+ if peer not in peers:
119
+ peers.append(peer)
120
+ files.extend(config.files)
121
+ bags.extend(config.bags)
122
+ return BundleCheckConfig(peers=tuple(peers), files=tuple(files), bags=tuple(bags))
123
+
124
+
125
+ def check_bundle(root_path: str | Path, config: BundleCheckConfig | None = None) -> BundleCheckReport:
126
+ root = Path(root_path)
127
+ effective_config = config or BundleCheckConfig()
128
+ results: list[CheckResult] = []
129
+
130
+ if not root.exists():
131
+ return BundleCheckReport(root, (CheckResult("missing", "directory", "bundle root", ".", True),))
132
+ if not root.is_dir():
133
+ return BundleCheckReport(root, (CheckResult("invalid", "directory", "bundle root", ".", True),))
134
+
135
+ results.append(CheckResult("present", "directory", "bundle root", ".", True))
136
+ peers = effective_config.peers or _discover_peer_names(root)
137
+ if not peers:
138
+ results.append(
139
+ CheckResult(
140
+ "missing",
141
+ "peer",
142
+ "peer status directories",
143
+ "logs/*/status",
144
+ True,
145
+ "no peers discovered; pass --peer or manifest peers when discovery is insufficient",
146
+ )
147
+ )
148
+ for peer in peers:
149
+ _validate_peer_name(peer)
150
+ results.append(_check_status_json(root, peer))
151
+ results.append(_check_events_jsonl(root, peer))
152
+
153
+ for expected_file in effective_config.files:
154
+ results.append(_check_required_file(root, expected_file))
155
+ for expected_bag in effective_config.bags:
156
+ results.append(_check_bag(root, expected_bag))
157
+
158
+ return BundleCheckReport(root, tuple(results))
159
+
160
+
161
+ def format_bundle_report(report: BundleCheckReport) -> str:
162
+ lines = [f"Bundle check: {report.root}"]
163
+ for result in report.results:
164
+ required = "required" if result.required else "optional"
165
+ detail = f" ({result.detail})" if result.detail else ""
166
+ lines.append(f"{result.status:7} {required} {result.kind} {result.label}: {result.path}{detail}")
167
+ if report.complete:
168
+ lines.append("Bundle complete.")
169
+ else:
170
+ lines.append(f"Bundle incomplete: {len(report.failures)} failure(s).")
171
+ return "\n".join(lines)
172
+
173
+
174
+ def _parse_peer_names(raw: Any) -> tuple[str, ...]:
175
+ if raw is None:
176
+ return ()
177
+ if isinstance(raw, str):
178
+ peers = [raw]
179
+ elif isinstance(raw, Sequence):
180
+ peers = list(raw)
181
+ else:
182
+ raise RuntimeError("Bundle manifest peers must be a string or list of strings.")
183
+ parsed: list[str] = []
184
+ for peer in peers:
185
+ if not isinstance(peer, str):
186
+ raise RuntimeError("Bundle manifest peers must be strings.")
187
+ _validate_peer_name(peer)
188
+ if peer not in parsed:
189
+ parsed.append(peer)
190
+ return tuple(parsed)
191
+
192
+
193
+ def _parse_expected_path_entries(
194
+ raw: Any, *, default_required: bool, allow_required_override: bool
195
+ ) -> tuple[ExpectedPath, ...]:
196
+ if raw is None:
197
+ return ()
198
+ if isinstance(raw, (str, Mapping)):
199
+ entries: Iterable[Any] = (raw,)
200
+ elif isinstance(raw, Sequence):
201
+ entries = raw
202
+ else:
203
+ raise RuntimeError("Bundle manifest path entries must be strings or mappings.")
204
+
205
+ parsed: list[ExpectedPath] = []
206
+ for entry in entries:
207
+ if isinstance(entry, str):
208
+ parsed.append(ExpectedPath(path=entry, required=default_required))
209
+ continue
210
+ if not isinstance(entry, Mapping):
211
+ raise RuntimeError("Bundle manifest path entries must be strings or mappings.")
212
+ unknown = sorted(set(entry) - _ENTRY_KEYS)
213
+ if unknown:
214
+ raise RuntimeError(f"Unsupported bundle manifest path entry keys: {unknown}")
215
+ path = entry.get("path")
216
+ if not isinstance(path, str) or not path:
217
+ raise RuntimeError("Bundle manifest path entries need a non-empty string path.")
218
+ label = entry.get("label")
219
+ if label is not None and not isinstance(label, str):
220
+ raise RuntimeError("Bundle manifest path entry label must be a string when provided.")
221
+ required = default_required
222
+ if allow_required_override and "required" in entry:
223
+ required_raw = entry["required"]
224
+ if not isinstance(required_raw, bool):
225
+ raise RuntimeError("Bundle manifest path entry required must be true or false.")
226
+ required = required_raw
227
+ parsed.append(ExpectedPath(path=path, label=label, required=required))
228
+ return tuple(parsed)
229
+
230
+
231
+ def _validate_peer_name(peer: str) -> None:
232
+ if not peer or peer in {".", ".."} or Path(peer).name != peer:
233
+ raise RuntimeError(f"Peer names must be single path tokens, got {peer!r}.")
234
+
235
+
236
+ def _discover_peer_names(root: Path) -> tuple[str, ...]:
237
+ logs_dir = root / "logs"
238
+ if not logs_dir.is_dir():
239
+ return ()
240
+ return tuple(sorted(child.name for child in logs_dir.iterdir() if child.is_dir() and (child / "status").is_dir()))
241
+
242
+
243
+ def _relative_path(path: Path, root: Path) -> str:
244
+ try:
245
+ return path.relative_to(root).as_posix()
246
+ except ValueError:
247
+ return str(path)
248
+
249
+
250
+ def _resolve_path(root: Path, raw_path: str) -> Path:
251
+ path = Path(raw_path)
252
+ return path if path.is_absolute() else root / path
253
+
254
+
255
+ def _check_existing_file(
256
+ root: Path,
257
+ path: Path,
258
+ *,
259
+ kind: str,
260
+ label: str,
261
+ required: bool,
262
+ detail: str = "non-empty",
263
+ ) -> CheckResult:
264
+ display_path = _relative_path(path, root)
265
+ if not path.exists():
266
+ return CheckResult("missing", kind, label, display_path, required)
267
+ if not path.is_file():
268
+ return CheckResult("invalid", kind, label, display_path, required, "not a file")
269
+ if path.stat().st_size == 0:
270
+ return CheckResult("empty", kind, label, display_path, required)
271
+ return CheckResult("present", kind, label, display_path, required, detail)
272
+
273
+
274
+ def _check_status_json(root: Path, peer: str) -> CheckResult:
275
+ rel = Path("logs") / peer / "status" / "status.json"
276
+ path = root / rel
277
+ base = _check_existing_file(root, path, kind="status", label=f"{peer} status.json", required=True)
278
+ if base.status != "present":
279
+ return base
280
+ text = path.read_text(encoding="utf-8")
281
+ if not text.strip():
282
+ return CheckResult("empty", "status", f"{peer} status.json", rel.as_posix(), True)
283
+ try:
284
+ payload = json.loads(text)
285
+ except json.JSONDecodeError as exc:
286
+ return CheckResult("invalid", "status", f"{peer} status.json", rel.as_posix(), True, str(exc))
287
+ if not isinstance(payload, Mapping):
288
+ return CheckResult(
289
+ "invalid",
290
+ "status",
291
+ f"{peer} status.json",
292
+ rel.as_posix(),
293
+ True,
294
+ "JSON root is not an object",
295
+ )
296
+ return CheckResult("present", "status", f"{peer} status.json", rel.as_posix(), True, "valid JSON")
297
+
298
+
299
+ def _check_events_jsonl(root: Path, peer: str) -> CheckResult:
300
+ rel = Path("logs") / peer / "status" / "events.jsonl"
301
+ path = root / rel
302
+ base = _check_existing_file(root, path, kind="events", label=f"{peer} events.jsonl", required=True)
303
+ if base.status != "present":
304
+ return base
305
+ text = path.read_text(encoding="utf-8")
306
+ if not text.strip():
307
+ return CheckResult("empty", "events", f"{peer} events.jsonl", rel.as_posix(), True, "file is empty")
308
+
309
+ transit_rows = 0
310
+ parsed_rows = 0
311
+ for lineno, line in enumerate(text.splitlines(), start=1):
312
+ if not line.strip():
313
+ continue
314
+ parsed_rows += 1
315
+ try:
316
+ payload = json.loads(line)
317
+ except json.JSONDecodeError as exc:
318
+ return CheckResult(
319
+ "invalid",
320
+ "events",
321
+ f"{peer} events.jsonl",
322
+ rel.as_posix(),
323
+ True,
324
+ f"line {lineno}: {exc}",
325
+ )
326
+ if not isinstance(payload, Mapping):
327
+ return CheckResult(
328
+ "invalid",
329
+ "events",
330
+ f"{peer} events.jsonl",
331
+ rel.as_posix(),
332
+ True,
333
+ f"line {lineno}: JSON root is not an object",
334
+ )
335
+ if payload.get("kind") == "transit":
336
+ transit_rows += 1
337
+
338
+ if parsed_rows == 0:
339
+ return CheckResult("empty", "events", f"{peer} events.jsonl", rel.as_posix(), True, "file is empty")
340
+ if transit_rows == 0:
341
+ return CheckResult("empty", "events", f"{peer} events.jsonl", rel.as_posix(), True, "no transit rows")
342
+ row_label = "row" if transit_rows == 1 else "rows"
343
+ return CheckResult(
344
+ "present",
345
+ "events",
346
+ f"{peer} events.jsonl",
347
+ rel.as_posix(),
348
+ True,
349
+ f"{transit_rows} transit {row_label}",
350
+ )
351
+
352
+
353
+ def _check_required_file(root: Path, expected: ExpectedPath) -> CheckResult:
354
+ path = _resolve_path(root, expected.path)
355
+ return _check_existing_file(root, path, kind="file", label=expected.display_label, required=expected.required)
356
+
357
+
358
+ def _check_bag(root: Path, expected: ExpectedPath) -> CheckResult:
359
+ raw_path = _resolve_path(root, expected.path)
360
+ metadata_path = raw_path if raw_path.name == "metadata.yaml" else raw_path / "metadata.yaml"
361
+ bag_dir = metadata_path.parent
362
+ display_path = _relative_path(raw_path, root)
363
+ if not raw_path.exists():
364
+ return CheckResult("missing", "bag", expected.display_label, display_path, expected.required)
365
+ if metadata_path != raw_path and not raw_path.is_dir():
366
+ return CheckResult(
367
+ "invalid", "bag", expected.display_label, display_path, expected.required, "not a bag directory"
368
+ )
369
+ if not metadata_path.exists():
370
+ return CheckResult(
371
+ "missing", "bag", expected.display_label, _relative_path(metadata_path, root), expected.required
372
+ )
373
+ if not metadata_path.is_file():
374
+ return CheckResult(
375
+ "invalid",
376
+ "bag",
377
+ expected.display_label,
378
+ _relative_path(metadata_path, root),
379
+ expected.required,
380
+ "not a file",
381
+ )
382
+ if metadata_path.stat().st_size == 0:
383
+ return CheckResult(
384
+ "empty", "bag", expected.display_label, _relative_path(metadata_path, root), expected.required
385
+ )
386
+
387
+ try:
388
+ loaded = yaml.safe_load(metadata_path.read_text(encoding="utf-8")) or {}
389
+ except yaml.YAMLError as exc:
390
+ return CheckResult(
391
+ "invalid",
392
+ "bag",
393
+ expected.display_label,
394
+ _relative_path(metadata_path, root),
395
+ expected.required,
396
+ str(exc),
397
+ )
398
+ if not isinstance(loaded, Mapping):
399
+ return CheckResult(
400
+ "invalid",
401
+ "bag",
402
+ expected.display_label,
403
+ _relative_path(metadata_path, root),
404
+ expected.required,
405
+ "metadata root is not a mapping",
406
+ )
407
+ info = loaded.get("rosbag2_bagfile_information")
408
+ if not isinstance(info, Mapping):
409
+ return CheckResult(
410
+ "invalid",
411
+ "bag",
412
+ expected.display_label,
413
+ _relative_path(metadata_path, root),
414
+ expected.required,
415
+ "missing rosbag2_bagfile_information",
416
+ )
417
+
418
+ topics = info.get("topics_with_message_count") or ()
419
+ if not isinstance(topics, Sequence) or isinstance(topics, (str, bytes)):
420
+ return CheckResult(
421
+ "invalid",
422
+ "bag",
423
+ expected.display_label,
424
+ _relative_path(metadata_path, root),
425
+ expected.required,
426
+ "topics_with_message_count is not a list",
427
+ )
428
+ total_messages = 0
429
+ topics_with_messages = 0
430
+ for index, entry in enumerate(topics):
431
+ if not isinstance(entry, Mapping):
432
+ return CheckResult(
433
+ "invalid",
434
+ "bag",
435
+ expected.display_label,
436
+ _relative_path(metadata_path, root),
437
+ expected.required,
438
+ f"topic entry {index} is not a mapping",
439
+ )
440
+ try:
441
+ count = int(entry.get("message_count") or 0)
442
+ except (TypeError, ValueError):
443
+ return CheckResult(
444
+ "invalid",
445
+ "bag",
446
+ expected.display_label,
447
+ _relative_path(metadata_path, root),
448
+ expected.required,
449
+ f"topic entry {index} has non-integer message_count",
450
+ )
451
+ if count > 0:
452
+ topics_with_messages += 1
453
+ total_messages += count
454
+ if total_messages <= 0:
455
+ return CheckResult(
456
+ "empty", "bag", expected.display_label, display_path, expected.required, "metadata has no messages"
457
+ )
458
+
459
+ data_files = info.get("relative_file_paths") or ()
460
+ if not isinstance(data_files, Sequence) or isinstance(data_files, (str, bytes)):
461
+ return CheckResult(
462
+ "invalid",
463
+ "bag",
464
+ expected.display_label,
465
+ _relative_path(metadata_path, root),
466
+ expected.required,
467
+ "relative_file_paths is not a list",
468
+ )
469
+ for data_file in data_files:
470
+ if not isinstance(data_file, str) or not data_file:
471
+ return CheckResult(
472
+ "invalid",
473
+ "bag",
474
+ expected.display_label,
475
+ _relative_path(metadata_path, root),
476
+ expected.required,
477
+ "relative_file_paths entries must be non-empty strings",
478
+ )
479
+ data_path = bag_dir / data_file
480
+ if not data_path.exists():
481
+ return CheckResult(
482
+ "missing",
483
+ "bag",
484
+ expected.display_label,
485
+ _relative_path(data_path, root),
486
+ expected.required,
487
+ "metadata references missing data file",
488
+ )
489
+ if not data_path.is_file():
490
+ return CheckResult(
491
+ "invalid",
492
+ "bag",
493
+ expected.display_label,
494
+ _relative_path(data_path, root),
495
+ expected.required,
496
+ "metadata references a non-file path",
497
+ )
498
+ if data_path.stat().st_size == 0:
499
+ return CheckResult(
500
+ "empty",
501
+ "bag",
502
+ expected.display_label,
503
+ _relative_path(data_path, root),
504
+ expected.required,
505
+ "metadata references an empty data file",
506
+ )
507
+
508
+ topic_label = "topic" if topics_with_messages == 1 else "topics"
509
+ message_label = "message" if total_messages == 1 else "messages"
510
+ return CheckResult(
511
+ "present",
512
+ "bag",
513
+ expected.display_label,
514
+ display_path,
515
+ expected.required,
516
+ f"{topics_with_messages} {topic_label}, {total_messages} {message_label}",
517
+ )