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.
- rosotacom/__init__.py +32 -0
- rosotacom/__main__.py +8 -0
- rosotacom/anonymize.py +360 -0
- rosotacom/bag_ground_truth.py +395 -0
- rosotacom/benched_set.py +492 -0
- rosotacom/benchmark.py +1842 -0
- rosotacom/bundle_check.py +517 -0
- rosotacom/cli.py +8637 -0
- rosotacom/cli_benchmark.py +8211 -0
- rosotacom/deployment.py +196 -0
- rosotacom/ffmpeg_packet.py +123 -0
- rosotacom/forensics.py +1172 -0
- rosotacom/geomap.py +848 -0
- rosotacom/network_profiles.py +563 -0
- rosotacom/network_shaper.py +127 -0
- rosotacom/plots.py +609 -0
- rosotacom/py.typed +0 -0
- rosotacom/resources/benched-set.yaml +317 -0
- rosotacom/resources/examples/.gitignore +1 -0
- rosotacom/resources/examples/README.md +163 -0
- rosotacom/resources/examples/__init__.py +1 -0
- rosotacom/resources/examples/bags/8_drop_reference/metadata.yaml +17 -0
- rosotacom/resources/examples/deployment.example.yaml +14 -0
- rosotacom/resources/examples/profiles.yaml +85 -0
- rosotacom/resources/examples/ros2docker.json +33 -0
- rosotacom/resources/examples/rosotacom.yaml +12 -0
- rosotacom/resources/examples/scenarios/2_native_chatter/scenario-definition.yaml +10 -0
- rosotacom/resources/examples/scripts/1_heartbeat/run_machine_a.sh +9 -0
- rosotacom/resources/examples/scripts/1_heartbeat/run_machine_b.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/external.ros2docker.json +21 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_a/wait_for_echo_topic.sh +10 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/external.ros2docker.json +19 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/2_native_chatter/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external.ros2docker.json +12 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/external_session.yaml +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/foxglove.json +89 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/costmap_1second/metadata.yaml +44 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/external.ros2docker.json +13 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/3_comp_occ_grid/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external.ros2docker.json +12 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/external_session.yaml +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/foxglove.json +89 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/costmap_1second_0.mcap +0 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/costmap_1second/metadata.yaml +44 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/external.ros2docker.json +13 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/4_comp_occ_grid_zen/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external.ros2docker.json +20 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external.ros2docker.json +20 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/5_sized_payload/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external.ros2docker.json +18 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_a/run_external.py +15 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external.ros2docker.json +18 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/external_session.yaml +22 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_communication.sh +9 -0
- rosotacom/resources/examples/scripts/6_sized_payload_zen/machine_b/run_external.py +15 -0
- rosotacom/resources/examples/sessions/10_restamp/session-definition.yaml +39 -0
- rosotacom/resources/examples/sessions/11_trickle/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/12_content_integrity/session-definition.yaml +33 -0
- rosotacom/resources/examples/sessions/13_link_latency/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/14_remote_assist_anonymized/session-definition.yaml +211 -0
- rosotacom/resources/examples/sessions/15_remote_assist_anonymized_costmap/session-definition.yaml +59 -0
- rosotacom/resources/examples/sessions/16_remote_assist_anonymized_camera/session-definition.yaml +61 -0
- rosotacom/resources/examples/sessions/17_synthetic_camera_quality/session-definition.yaml +57 -0
- rosotacom/resources/examples/sessions/1_heartbeat/session-definition.yaml +26 -0
- rosotacom/resources/examples/sessions/1_heartbeat_status/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/2_native_chatter/session-definition.yaml +34 -0
- rosotacom/resources/examples/sessions/3_comp_occ_grid/session-definition.yaml +43 -0
- rosotacom/resources/examples/sessions/4_comp_occ_grid_zen/session-definition.yaml +43 -0
- rosotacom/resources/examples/sessions/5_sized_payload/session-definition.yaml +39 -0
- rosotacom/resources/examples/sessions/6_sized_payload_zen/session-definition.yaml +50 -0
- rosotacom/resources/examples/sessions/7_latched_static/session-definition.yaml +49 -0
- rosotacom/resources/examples/sessions/8_drop/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/9_throttle/session-definition.yaml +37 -0
- rosotacom/resources/examples/sessions/bench_1_1_capacity/session-definition.yaml +42 -0
- rosotacom/resources/examples/sessions/bench_1_2_load_sweep/session-definition.yaml +51 -0
- rosotacom/resources/examples/sessions/bench_1_3_ramp/session-definition.yaml +41 -0
- rosotacom/resources/examples/sessions/bench_1_4_recovery/session-definition.yaml +47 -0
- rosotacom/resources/examples/sessions/bench_1_5_camera/session-definition.yaml +45 -0
- rosotacom/resources/ros2docker.json.example +33 -0
- rosotacom/resources/ws/.vscode/c_cpp_properties.json +20 -0
- rosotacom/resources/ws/.vscode/settings.json +8 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_easy_mode.xml.template +32 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_initial_peer_list.xml.template +21 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v2.xml.template +17 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v3.xml.template +48 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v4.xml.template +95 -0
- rosotacom/resources/ws/ota_configs/archive/fastdds_v5.xml.template +64 -0
- rosotacom/resources/ws/ota_configs/create_zenoh_json5.py +56 -0
- rosotacom/resources/ws/ota_configs/cyclonedds_minimal.xml.template +9 -0
- rosotacom/resources/ws/ota_configs/cyclonedds_tuned.xml.template +33 -0
- rosotacom/resources/ws/ota_configs/fastdds_unicast.xml.template +71 -0
- rosotacom/resources/ws/ota_configs/get_ota_xml.py +214 -0
- rosotacom/resources/ws/ota_configs/utils.py +74 -0
- rosotacom/resources/ws/ota_configs/zenoh.json5.template +32 -0
- rosotacom/resources/ws/ros2src/.gitkeep +0 -0
- rosotacom/resources/ws/ros2src/com_cpp/CMakeLists.txt +67 -0
- rosotacom/resources/ws/ros2src/com_cpp/COLCON_IGNORE +0 -0
- rosotacom/resources/ws/ros2src/com_cpp/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_cpp/package.xml +37 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/hz_monitor.cpp +192 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/universal_compressor.cpp +283 -0
- rosotacom/resources/ws/ros2src/com_cpp/src/universal_decompressor.cpp +306 -0
- rosotacom/resources/ws/ros2src/com_msgs/CMakeLists.txt +22 -0
- rosotacom/resources/ws/ros2src/com_msgs/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/CompressedData.msg +3 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/EchoHeartbeat.msg +10 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/OtaStamped.msg +4 -0
- rosotacom/resources/ws/ros2src/com_msgs/msg/SizedPayload.msg +4 -0
- rosotacom/resources/ws/ros2src/com_msgs/package.xml +25 -0
- rosotacom/resources/ws/ros2src/com_py/LICENSE.txt +25 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/__init__.py +0 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/base_bridge_relay.py +106 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/bridge_in.py +76 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/bridge_out.py +70 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/heartbeat_echo.py +221 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/hz_monitor.py +178 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/image_bandwidth_reducer.py +218 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/image_pixel_capper.py +370 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/lanelet_saver.py +80 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/latch_relay.py +205 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/costmap_remapper.py +73 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/marker_remapper.py +93 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/speed_test.py +107 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/tf_remapper.py +156 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/traffic_monitor.py +91 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/legacy/wifi_network_analysis_1.py +213 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/link_bytes.py +177 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/link_trace.py +186 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/local_global_frame_bridge.py +465 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/localization_map_split_reduce_saver.py +258 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/msg_transform.py +277 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/pair_management.py +87 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/pub_sub_pair.py +145 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/qos.py +254 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/relay_in.py +78 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/relay_out.py +75 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/restamp.py +191 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/sized_publisher.py +244 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency.py +64 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/stage_latency_core.py +51 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview.py +476 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/status_overview_core.py +989 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/tf_map_to_cart_saver.py +99 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/topic_monitor.py +327 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/topic_resolution.py +171 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/trickle.py +183 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_compressor.py +653 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_decompressor.py +372 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_unwrapper.py +191 -0
- rosotacom/resources/ws/ros2src/com_py/com_py/universal_ota_wrapper.py +154 -0
- rosotacom/resources/ws/ros2src/com_py/package.xml +34 -0
- rosotacom/resources/ws/ros2src/com_py/resource/com_py +0 -0
- rosotacom/resources/ws/ros2src/com_py/setup.cfg +6 -0
- rosotacom/resources/ws/ros2src/com_py/setup.py +55 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_copyright.py +25 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_flake8.py +25 -0
- rosotacom/resources/ws/ros2src/com_py/test/test_pep257.py +23 -0
- rosotacom/resources/ws/session/content/base/session_plugin_base.yaml +633 -0
- rosotacom/resources/ws/session/content/network/up_connectivity.py +364 -0
- rosotacom/resources/ws/session/content/network/wifi_network_analysis_2.py +215 -0
- rosotacom/resources/ws/session/creation/anonymize_bag.py +156 -0
- rosotacom/resources/ws/session/creation/catmux_log_setup.sh +65 -0
- rosotacom/resources/ws/session/creation/create_session_yaml.py +145 -0
- rosotacom/resources/ws/session/creation/generate_session_files.py +3217 -0
- rosotacom/resources/ws/session/creation/run_session.py +447 -0
- rosotacom/resources/ws/session/creation/run_session_in_external_terminal.py +54 -0
- rosotacom/resources/ws/session/creation/strip_ansi.py +42 -0
- rosotacom/status_eval.py +475 -0
- rosotacom/stream_stats.py +650 -0
- rosotacom/trace_profiles.py +561 -0
- rosotacom/transit.py +249 -0
- rosotacom/video_quality.py +906 -0
- rosotacom_dev-2.2.dist-info/METADATA +874 -0
- rosotacom_dev-2.2.dist-info/RECORD +195 -0
- rosotacom_dev-2.2.dist-info/WHEEL +5 -0
- rosotacom_dev-2.2.dist-info/entry_points.txt +4 -0
- rosotacom_dev-2.2.dist-info/licenses/LICENSE.txt +25 -0
- 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
|
+
)
|