pymcap-cli 0.10.0__tar.gz → 0.12.0__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 (139) hide show
  1. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/PKG-INFO +1 -1
  2. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/pyproject.toml +1 -1
  3. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cli.py +34 -26
  4. pymcap_cli-0.12.0/src/pymcap_cli/cmd/_rechunk_strategy.py +42 -0
  5. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/diff_cmd.py +95 -23
  6. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/duplicates_cmd.py +202 -92
  7. pymcap_cli-0.12.0/src/pymcap_cli/cmd/index_cmd.py +1066 -0
  8. pymcap_cli-0.12.0/src/pymcap_cli/cmd/process_cmd.py +733 -0
  9. pymcap_cli-0.12.0/src/pymcap_cli/cmd/rechunk_cmd.py +248 -0
  10. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/split_cmd.py +5 -12
  11. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/tf_export_cmd.py +4 -2
  12. pymcap_cli-0.12.0/src/pymcap_cli/cmd/tf_get_cmd.py +182 -0
  13. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/tftree_cmd.py +46 -27
  14. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/mcap_compare.py +455 -3
  15. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/mcap_processor.py +116 -123
  16. pymcap_cli-0.12.0/src/pymcap_cli/core/processors/ARCHITECTURE.md +180 -0
  17. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/base.py +27 -1
  18. pymcap_cli-0.12.0/src/pymcap_cli/core/processors/chunk_groupers.py +61 -0
  19. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/dedup.py +4 -2
  20. pymcap_cli-0.12.0/src/pymcap_cli/core/tf_findings.py +254 -0
  21. pymcap_cli-0.12.0/src/pymcap_cli/core/tf_tree.py +551 -0
  22. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/video_file_writer.py +19 -12
  23. pymcap_cli-0.12.0/src/pymcap_cli/index/db.py +78 -0
  24. pymcap_cli-0.12.0/src/pymcap_cli/index/fingerprint.py +50 -0
  25. pymcap_cli-0.12.0/src/pymcap_cli/index/migrations/0001.py +155 -0
  26. pymcap_cli-0.12.0/src/pymcap_cli/index/migrations/__init__.py +67 -0
  27. pymcap_cli-0.12.0/src/pymcap_cli/index/scanner.py +687 -0
  28. pymcap_cli-0.12.0/src/pymcap_cli/index/summary_fingerprint.py +132 -0
  29. pymcap_cli-0.12.0/src/pymcap_cli/types/__init__.py +0 -0
  30. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/types/duration.py +14 -0
  31. pymcap_cli-0.10.0/src/pymcap_cli/cmd/process_cmd.py +0 -237
  32. pymcap_cli-0.10.0/src/pymcap_cli/cmd/rechunk_cmd.py +0 -185
  33. pymcap_cli-0.10.0/src/pymcap_cli/core/tf_tree.py +0 -154
  34. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/README.md +0 -0
  35. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/__init__.py +0 -0
  36. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/__init__.py +0 -0
  37. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/_run_processor.py +0 -0
  38. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/_run_processor_multi.py +0 -0
  39. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/bag2mcap_cmd.py +0 -0
  40. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/bridge/__init__.py +0 -0
  41. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/bridge/_shared.py +0 -0
  42. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/bridge/cat.py +0 -0
  43. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/bridge/inspect.py +0 -0
  44. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/bridge/record.py +0 -0
  45. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/cat_cmd.py +0 -0
  46. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/compress_cmd.py +0 -0
  47. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/convert_cmd.py +0 -0
  48. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/diag_cmd.py +0 -0
  49. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/doctor_cmd.py +0 -0
  50. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/du_cmd.py +0 -0
  51. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/export_csv_cmd.py +0 -0
  52. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/export_geo_cmd.py +0 -0
  53. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/export_images_cmd.py +0 -0
  54. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/export_json_cmd.py +0 -0
  55. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/export_parquet_cmd.py +0 -0
  56. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/export_pcd_cmd.py +0 -0
  57. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/filter_cmd.py +0 -0
  58. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/get_cmd.py +0 -0
  59. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/info_cmd.py +0 -0
  60. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/info_json_cmd.py +0 -0
  61. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/list_cmd.py +0 -0
  62. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/merge_cmd.py +0 -0
  63. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/plot_cmd.py +0 -0
  64. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/records_cmd.py +0 -0
  65. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/recover_cmd.py +0 -0
  66. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/recover_inplace_cmd.py +0 -0
  67. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/roscompress_cmd.py +0 -0
  68. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/rosdecompress_cmd.py +0 -0
  69. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/topic_chunks_cmd.py +0 -0
  70. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/cmd/video_cmd.py +0 -0
  71. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/constants.py +0 -0
  72. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/__init__.py +0 -0
  73. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/input_handler.py +0 -0
  74. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/input_options.py +0 -0
  75. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/input_processor_chain.py +0 -0
  76. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/mcap_transform.py +0 -0
  77. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/msg_resolver.py +0 -0
  78. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/__init__.py +0 -0
  79. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/always_decode.py +0 -0
  80. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/attachment_filter.py +0 -0
  81. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/boundary_split.py +0 -0
  82. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/channel_merge.py +0 -0
  83. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/duration_split.py +0 -0
  84. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/expression_split.py +0 -0
  85. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/latching.py +0 -0
  86. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/metadata_filter.py +0 -0
  87. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/nth_message.py +0 -0
  88. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/size_split.py +0 -0
  89. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/time_filter.py +0 -0
  90. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/time_offset.py +0 -0
  91. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/timestamp_split.py +0 -0
  92. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/topic_alias.py +0 -0
  93. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/topic_filter.py +0 -0
  94. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/topic_rewrite.py +0 -0
  95. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/core/processors/utils.py +0 -0
  96. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/debug_wrapper.py +0 -0
  97. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/display/__init__.py +0 -0
  98. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/display/cat_helpers.py +0 -0
  99. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/display/display_utils.py +0 -0
  100. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/display/message_render.py +0 -0
  101. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/display/osc_utils.py +0 -0
  102. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/display/sparkline.py +0 -0
  103. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/display/time_ranges.py +0 -0
  104. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/doctor.py +0 -0
  105. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/encoding/__init__.py +0 -0
  106. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/encoding/arrow_schema.py +0 -0
  107. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/__init__.py +0 -0
  108. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/_common.py +0 -0
  109. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/base.py +0 -0
  110. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/csv_exporter.py +0 -0
  111. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/driver.py +0 -0
  112. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/geo_common.py +0 -0
  113. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/geojson_exporter.py +0 -0
  114. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/gpx_exporter.py +0 -0
  115. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/image_exporter.py +0 -0
  116. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/json_exporter.py +0 -0
  117. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/kml_exporter.py +0 -0
  118. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/parquet_exporter.py +0 -0
  119. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/pcd_exporter.py +0 -0
  120. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/plot_exporter.py +0 -0
  121. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/sdf_exporter.py +0 -0
  122. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/urdf_exporter.py +0 -0
  123. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/exporters/video_exporter.py +0 -0
  124. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/http_utils.py +0 -0
  125. {pymcap_cli-0.10.0/src/pymcap_cli/types → pymcap_cli-0.12.0/src/pymcap_cli/index}/__init__.py +0 -0
  126. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/log_setup.py +0 -0
  127. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/py.typed +0 -0
  128. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/rihs01.py +0 -0
  129. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/rosbag_reader/__init__.py +0 -0
  130. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/rosbag_reader/_reader.py +0 -0
  131. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/rosbag_reader/_types.py +0 -0
  132. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/rosbag_reader/py.typed +0 -0
  133. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/types/info_data.py +0 -0
  134. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/types/info_link.py +0 -0
  135. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/types/info_types.py +0 -0
  136. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/types/size.py +0 -0
  137. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/types/to_plain.py +0 -0
  138. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/types/types_manual.py +0 -0
  139. {pymcap_cli-0.10.0 → pymcap_cli-0.12.0}/src/pymcap_cli/utils.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pymcap-cli
3
- Version: 0.10.0
3
+ Version: 0.12.0
4
4
  Summary: High-performance Python CLI for MCAP file processing with advanced recovery, filtering, and optimization capabilities
5
5
  Keywords: mcap,cli,robotics,ros,ros2,recovery,filtering,compression
6
6
  Author: Marko Bausch
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pymcap-cli"
3
- version = "0.10.0"
3
+ version = "0.12.0"
4
4
  description = "High-performance Python CLI for MCAP file processing with advanced recovery, filtering, and optimization capabilities"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -33,6 +33,7 @@ from pymcap_cli.cmd import (
33
33
  recover_inplace_cmd,
34
34
  split_cmd,
35
35
  tf_export_cmd,
36
+ tf_get_cmd,
36
37
  tftree_cmd,
37
38
  topic_chunks_cmd,
38
39
  )
@@ -120,6 +121,22 @@ def _load_optional_app(
120
121
  return cast("App", getattr(module, app_name))
121
122
 
122
123
 
124
+ # Modules that go missing together when a given extra is absent. Used by the
125
+ # optional-command loader to suppress ImportError for the *expected* shape of
126
+ # "extra not installed."
127
+ _VIDEO_MODULES: tuple[str, ...] = ("av", "mcap_codec_support", "numpy")
128
+ _POINTCLOUD_MODULES: tuple[str, ...] = (
129
+ "DracoPy",
130
+ "mcap_codec_support",
131
+ "numpy",
132
+ "pointcloud2",
133
+ "pureini",
134
+ )
135
+ _VIDEO_AND_POINTCLOUD_MODULES: tuple[str, ...] = tuple(
136
+ sorted({*_VIDEO_MODULES, *_POINTCLOUD_MODULES})
137
+ )
138
+
139
+
123
140
  bridge_app = _load_optional_app(
124
141
  "pymcap_cli.cmd.bridge",
125
142
  "bridge_app",
@@ -130,7 +147,7 @@ bridge_app = _load_optional_app(
130
147
  video = _load_optional_command(
131
148
  "pymcap_cli.cmd.video_cmd",
132
149
  "video",
133
- expected_missing_modules=("av", "mcap_codec_support", "numpy"),
150
+ expected_missing_modules=_VIDEO_MODULES,
134
151
  message="Video command requires the 'video' extra.",
135
152
  install_command="uv add 'pymcap-cli[video]'",
136
153
  )
@@ -144,35 +161,21 @@ plot = _load_optional_command(
144
161
  roscompress = _load_optional_command(
145
162
  "pymcap_cli.cmd.roscompress_cmd",
146
163
  "roscompress",
147
- expected_missing_modules=(
148
- "av",
149
- "DracoPy",
150
- "mcap_codec_support",
151
- "numpy",
152
- "pointcloud2",
153
- "pureini",
154
- ),
164
+ expected_missing_modules=_VIDEO_AND_POINTCLOUD_MODULES,
155
165
  message="ROS compression requires the 'video' and 'pointcloud' extras.",
156
166
  install_command="uv add 'pymcap-cli[video,pointcloud]'",
157
167
  )
158
168
  export_parquet = _load_optional_command(
159
169
  "pymcap_cli.cmd.export_parquet_cmd",
160
170
  "export_parquet",
161
- expected_missing_modules=(
162
- "DracoPy",
163
- "mcap_codec_support",
164
- "numpy",
165
- "pointcloud2",
166
- "pureini",
167
- "pyarrow",
168
- ),
171
+ expected_missing_modules=(*_POINTCLOUD_MODULES, "pyarrow"),
169
172
  message="Parquet export requires the 'parquet' extra.",
170
173
  install_command="uv add 'pymcap-cli[parquet]'",
171
174
  )
172
175
  export_pcd = _load_optional_command(
173
176
  "pymcap_cli.cmd.export_pcd_cmd",
174
177
  "export_pcd",
175
- expected_missing_modules=("DracoPy", "mcap_codec_support", "numpy", "pointcloud2", "pureini"),
178
+ expected_missing_modules=_POINTCLOUD_MODULES,
176
179
  message="PCD export requires the 'pointcloud' extra.",
177
180
  install_command="uv add 'pymcap-cli[pointcloud]'",
178
181
  )
@@ -186,23 +189,25 @@ export_images = _load_optional_command(
186
189
  rosdecompress = _load_optional_command(
187
190
  "pymcap_cli.cmd.rosdecompress_cmd",
188
191
  "rosdecompress",
189
- expected_missing_modules=(
190
- "av",
191
- "DracoPy",
192
- "mcap_codec_support",
193
- "numpy",
194
- "pointcloud2",
195
- "pureini",
196
- ),
192
+ expected_missing_modules=_VIDEO_AND_POINTCLOUD_MODULES,
197
193
  message="ROS decompression requires the 'video' and 'pointcloud' extras.",
198
194
  install_command="uv add 'pymcap-cli[video,pointcloud]'",
199
195
  )
196
+ index_app = _load_optional_app(
197
+ "pymcap_cli.cmd.index_cmd",
198
+ "index_app",
199
+ expected_missing_modules=("xxhash",),
200
+ message="Index command requires the 'xxhash' extra "
201
+ "(fingerprint stability requires a single committed hash function).",
202
+ install_command="uv add 'pymcap-cli[xxhash]'",
203
+ )
200
204
 
201
205
 
202
206
  app = App(
203
207
  name="pymcap-cli",
204
208
  help="CLI tool for slicing and dicing MCAP files.",
205
209
  help_format="rich",
210
+ help_on_error=True,
206
211
  default_parameter=Parameter(negative_iterable=""),
207
212
  )
208
213
 
@@ -220,11 +225,14 @@ app.command(name="diff", group=inspect_group)(diff_cmd.diff_cmd)
220
225
  app.command(name="duplicates", group=inspect_group)(duplicates_cmd.duplicates)
221
226
  app.command(name="info", group=inspect_group)(info_cmd.info)
222
227
  app.command(name="info-json", group=inspect_group)(info_json_cmd.info_json)
228
+ index_app.group = (inspect_group,)
229
+ app.command(index_app, name="index")
223
230
  get_cmd.get_app.group = (inspect_group,)
224
231
  app.command(get_cmd.get_app, name="get")
225
232
  list_cmd.list_app.group = (inspect_group,)
226
233
  app.command(list_cmd.list_app, name="list")
227
234
  app.command(name="records", group=inspect_group)(records_cmd.records)
235
+ app.command(name="tf-get", group=inspect_group)(tf_get_cmd.tf_get)
228
236
  app.command(name="tftree", group=inspect_group)(tftree_cmd.tftree)
229
237
  app.command(name="topic-chunks", group=inspect_group)(topic_chunks_cmd.topic_chunks)
230
238
 
@@ -0,0 +1,42 @@
1
+ """CLI-level rechunking strategy enum.
2
+
3
+ Lives in the cmd layer because it is purely a user-facing selector that the
4
+ ``rechunk`` and ``process`` commands translate into concrete
5
+ ``OutputProcessor`` instances. The core processor itself is strategy-agnostic.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from enum import Enum
11
+ from typing import TYPE_CHECKING
12
+
13
+ from pymcap_cli.core.processors.chunk_groupers import (
14
+ PatternGrouper,
15
+ PerChannelGrouper,
16
+ )
17
+
18
+ if TYPE_CHECKING:
19
+ from re import Pattern
20
+
21
+ from pymcap_cli.core.processors.base import OutputProcessor
22
+
23
+
24
+ class RechunkStrategy(str, Enum):
25
+ """User-facing rechunking strategy."""
26
+
27
+ NONE = "none" # No rechunking — fast-copy optimization when possible
28
+ PATTERN = "pattern" # Group by topic / schema regex
29
+ ALL = "all" # Each channel in its own chunk group
30
+
31
+
32
+ def build_output_processors(
33
+ strategy: RechunkStrategy,
34
+ topic_patterns: list[Pattern[str]] | None = None,
35
+ schema_patterns: list[Pattern[str]] | None = None,
36
+ ) -> list[OutputProcessor]:
37
+ """Translate a CLI rechunking strategy into concrete OutputProcessors."""
38
+ if strategy == RechunkStrategy.NONE:
39
+ return []
40
+ if strategy == RechunkStrategy.ALL:
41
+ return [PerChannelGrouper()]
42
+ return [PatternGrouper(topic_patterns or [], schema_patterns or [])]
@@ -20,11 +20,14 @@ from pymcap_cli.core.mcap_compare import (
20
20
  IndexedCompareKind,
21
21
  IndexedComparison,
22
22
  MessageIndexIdentityReadResult,
23
+ PayloadLookup,
23
24
  collect_message_timestamps,
24
25
  compare_indexed_identities,
25
26
  message_index_identity_from_info,
27
+ payload_lookup_from_info,
26
28
  read_compare_file,
27
29
  split_timestamps_into_segments,
30
+ verify_comparison_payloads,
28
31
  )
29
32
  from pymcap_cli.display.time_ranges import (
30
33
  format_count,
@@ -236,8 +239,13 @@ def _extract_summary(path: str, label: str, info: RebuildInfo, file_size: int) -
236
239
 
237
240
 
238
241
  def _process_file(
239
- path: str, label: str
240
- ) -> tuple[FileSummary, dict[int, set[int]], MessageIndexIdentityReadResult]:
242
+ path: str, label: str, *, compare_payloads: bool
243
+ ) -> tuple[
244
+ FileSummary,
245
+ dict[int, set[int]],
246
+ MessageIndexIdentityReadResult,
247
+ PayloadLookup | None,
248
+ ]:
241
249
  result = read_compare_file(path, rebuild_missing=True)
242
250
  indexed = MessageIndexIdentityReadResult(
243
251
  path=path,
@@ -245,10 +253,16 @@ def _process_file(
245
253
  identity=message_index_identity_from_info(result.info),
246
254
  read_mode=result.read_mode,
247
255
  )
256
+ payload_lookup = (
257
+ payload_lookup_from_info(path, result.size_bytes, result.info, result.read_mode)
258
+ if compare_payloads
259
+ else None
260
+ )
248
261
  return (
249
262
  _extract_summary(path, label, result.info, result.size_bytes),
250
263
  collect_message_timestamps(result.info),
251
264
  indexed,
265
+ payload_lookup,
252
266
  )
253
267
 
254
268
 
@@ -568,6 +582,8 @@ def _verdict_text(
568
582
  return "[yellow]edge overlap[/yellow]"
569
583
  if comparison.kind is IndexedCompareKind.PARTIAL_OVERLAP:
570
584
  return "[yellow]partial overlap[/yellow]"
585
+ if comparison.kind is IndexedCompareKind.PAYLOAD_MISMATCH:
586
+ return "[red]payload mismatch[/red] [dim](timestamps/indexes match)[/dim]"
571
587
  return "[red]no indexed message overlap[/red]"
572
588
 
573
589
 
@@ -593,6 +609,13 @@ def _build_smart_diff_tree(
593
609
  f"left-only {format_count(comparison.left_extra_messages, 'msg')}, "
594
610
  f"right-only {format_count(comparison.right_extra_messages, 'msg')})[/dim]"
595
611
  )
612
+ if comparison.payload_mismatch is not None:
613
+ mismatch = comparison.payload_mismatch
614
+ node.add(
615
+ f"[red]first mismatch[/red] [cyan]{escape(mismatch.topic)}[/cyan] "
616
+ f"[dim]@ {format_ts_short(mismatch.log_time)}: "
617
+ f"{escape(mismatch.reason)}[/dim]"
618
+ )
596
619
 
597
620
  for topic in comparison.topics[:max_topic_rows]:
598
621
  overlap_window = format_optional_time_window(
@@ -623,21 +646,34 @@ def _compare_to_first(
623
646
  labels: list[str],
624
647
  *,
625
648
  max_range_preview: int,
649
+ payload_lookups: list[PayloadLookup | None] | None = None,
626
650
  ) -> list[LabeledIndexedComparison]:
627
651
  first = indexed_results[0]
628
652
  first_label = labels[0]
629
- return [
630
- LabeledIndexedComparison(
631
- left_label=first_label,
632
- right_label=labels[index],
633
- comparison=compare_indexed_identities(
634
- first,
635
- indexed_result,
636
- max_range_preview=max_range_preview,
637
- ),
653
+ comparisons: list[LabeledIndexedComparison] = []
654
+ for index, indexed_result in enumerate(indexed_results[1:], start=1):
655
+ comparison = compare_indexed_identities(
656
+ first,
657
+ indexed_result,
658
+ max_range_preview=max_range_preview,
659
+ )
660
+ if payload_lookups is not None:
661
+ left_lookup = payload_lookups[0]
662
+ right_lookup = payload_lookups[index]
663
+ if left_lookup is not None and right_lookup is not None:
664
+ comparison = verify_comparison_payloads(
665
+ comparison,
666
+ left_lookup,
667
+ right_lookup,
668
+ )
669
+ comparisons.append(
670
+ LabeledIndexedComparison(
671
+ left_label=first_label,
672
+ right_label=labels[index],
673
+ comparison=comparison,
674
+ )
638
675
  )
639
- for index, indexed_result in enumerate(indexed_results[1:], start=1)
640
- ]
676
+ return comparisons
641
677
 
642
678
 
643
679
  def diff_cmd(
@@ -663,6 +699,16 @@ def diff_cmd(
663
699
  help="Maximum number of timestamp ranges to show per channel",
664
700
  ),
665
701
  ] = 3,
702
+ compare_payloads: Annotated[
703
+ bool,
704
+ Parameter(
705
+ name=["--compare-payloads"],
706
+ help=(
707
+ "After indexes match, compare raw message payload bytes and fail "
708
+ "fast on the first payload/header mismatch."
709
+ ),
710
+ ),
711
+ ] = False,
666
712
  ) -> int:
667
713
  """Compare MCAP files using summary and message index timestamps.
668
714
 
@@ -678,6 +724,8 @@ def diff_cmd(
678
724
  Hide channels where all message timestamps match exactly
679
725
  max_ranges
680
726
  Maximum timestamp ranges to display per channel (default: 3)
727
+ compare_payloads
728
+ Verify raw message payloads after the fast index comparison passes.
681
729
 
682
730
  Examples
683
731
  --------
@@ -700,12 +748,17 @@ def diff_cmd(
700
748
  all_timestamps: dict[str, dict[int, set[int]]] = {}
701
749
  all_summaries: dict[str, FileSummary] = {}
702
750
  indexed_results: list[MessageIndexIdentityReadResult] = []
751
+ payload_lookups: list[PayloadLookup | None] = []
703
752
  seen_labels: dict[str, int] = {}
704
753
 
705
754
  for path in files:
706
755
  label = _unique_label(path, seen_labels)
707
756
  try:
708
- fs, ts, indexed = _process_file(path, label)
757
+ fs, ts, indexed, payload_lookup = _process_file(
758
+ path,
759
+ label,
760
+ compare_payloads=compare_payloads,
761
+ )
709
762
  except Exception:
710
763
  logger.exception(f"Error reading {path}")
711
764
  return 1
@@ -713,14 +766,20 @@ def diff_cmd(
713
766
  all_timestamps[label] = ts
714
767
  all_summaries[label] = fs
715
768
  indexed_results.append(indexed)
769
+ payload_lookups.append(payload_lookup)
716
770
 
717
771
  labels = [fs.label for fs in summaries]
718
772
  first_label = labels[0]
719
- smart_comparisons = _compare_to_first(
720
- indexed_results,
721
- labels,
722
- max_range_preview=max_ranges,
723
- )
773
+ try:
774
+ smart_comparisons = _compare_to_first(
775
+ indexed_results,
776
+ labels,
777
+ max_range_preview=max_ranges,
778
+ payload_lookups=payload_lookups if compare_payloads else None,
779
+ )
780
+ except Exception:
781
+ logger.exception("Error comparing message payloads")
782
+ return 1
724
783
 
725
784
  channel_diffs = _compare_channels(all_timestamps, all_summaries)
726
785
  schema_diffs = _compare_schemas(all_summaries)
@@ -732,6 +791,9 @@ def diff_cmd(
732
791
  has_diffs = total_added > 0 or total_removed > 0
733
792
  has_schema_diffs = any(not d.is_identical for d in schema_diffs.values())
734
793
  has_mismatches = bool(channel_schema_mismatches)
794
+ has_payload_mismatches = any(
795
+ item.comparison.kind is IndexedCompareKind.PAYLOAD_MISMATCH for item in smart_comparisons
796
+ )
735
797
 
736
798
  console.print()
737
799
  console.print(_build_smart_diff_tree(smart_comparisons))
@@ -768,11 +830,19 @@ def diff_cmd(
768
830
  console.print(mismatch_table)
769
831
 
770
832
  console.print()
771
- all_good = not has_diffs and not has_schema_diffs and not has_mismatches
833
+ all_good = (
834
+ not has_diffs and not has_schema_diffs and not has_mismatches and not has_payload_mismatches
835
+ )
772
836
  if all_good:
773
- console.print(
774
- f"[green]✓ All {total_common:,} messages have identical timestamps and schemas[/]"
775
- )
837
+ if compare_payloads:
838
+ console.print(
839
+ f"[green]✓ All {total_common:,} messages have identical timestamps, "
840
+ "schemas and payloads[/]"
841
+ )
842
+ else:
843
+ console.print(
844
+ f"[green]✓ All {total_common:,} messages have identical timestamps and schemas[/]"
845
+ )
776
846
  else:
777
847
  if not has_diffs:
778
848
  console.print(f"[green]✓ All {total_common:,} messages have identical timestamps[/]")
@@ -790,5 +860,7 @@ def diff_cmd(
790
860
  f"[red]⚠ {len(channel_schema_mismatches)} "
791
861
  f"channel(s) use different schemas across files[/]"
792
862
  )
863
+ if has_payload_mismatches:
864
+ console.print("[red]⚠ message payloads differ across files[/]")
793
865
 
794
866
  return 0