pymcap-cli 0.14.0__tar.gz → 0.16.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 (172) hide show
  1. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/PKG-INFO +2 -1
  2. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/pyproject.toml +5 -2
  3. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/_run_processor.py +97 -4
  4. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/_run_processor_multi.py +4 -0
  5. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/compress_cmd.py +19 -3
  6. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/info_cmd.py +80 -20
  7. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/merge_cmd.py +11 -0
  8. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/plot_cmd.py +8 -1
  9. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/roscompress_cmd.py +21 -6
  10. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/split_cmd.py +8 -1
  11. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/input_handler.py +28 -1
  12. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/mcap_processor.py +9 -0
  13. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/mcap_transform.py +32 -22
  14. pymcap_cli-0.16.0/src/pymcap_cli/core/rosbag2_layout.py +197 -0
  15. pymcap_cli-0.16.0/src/pymcap_cli/exporters/_summary_hints.py +96 -0
  16. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/driver.py +10 -3
  17. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/plot_exporter.py +191 -60
  18. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/utils.py +15 -0
  19. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/README.md +0 -0
  20. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/__init__.py +0 -0
  21. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cli.py +0 -0
  22. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/__init__.py +0 -0
  23. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/_rechunk_strategy.py +0 -0
  24. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/bag2mcap_cmd.py +0 -0
  25. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/bridge/__init__.py +0 -0
  26. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/bridge/_shared.py +0 -0
  27. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/bridge/cat.py +0 -0
  28. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/bridge/inspect.py +0 -0
  29. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/bridge/record.py +0 -0
  30. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/cat_cmd.py +0 -0
  31. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/convert_cmd.py +0 -0
  32. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/diag_cmd.py +0 -0
  33. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/diff_cmd.py +0 -0
  34. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/doctor_cmd.py +0 -0
  35. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/du_cmd.py +0 -0
  36. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/duplicates_cmd.py +0 -0
  37. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/export_csv_cmd.py +0 -0
  38. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/export_geo_cmd.py +0 -0
  39. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/export_images_cmd.py +0 -0
  40. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/export_json_cmd.py +0 -0
  41. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/export_parquet_cmd.py +0 -0
  42. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/export_pcd_cmd.py +0 -0
  43. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/filter_cmd.py +0 -0
  44. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/get_cmd.py +0 -0
  45. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/__init__.py +0 -0
  46. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/_helpers.py +0 -0
  47. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/duplicates_cmd.py +0 -0
  48. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/errors_cmd.py +0 -0
  49. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/info_cmd.py +0 -0
  50. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/migrate_cmd.py +0 -0
  51. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/query_cmd.py +0 -0
  52. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/scan_cmd.py +0 -0
  53. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/schemas_cmd.py +0 -0
  54. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/serve_cmd.py +0 -0
  55. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/sessions_cmd.py +0 -0
  56. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/status_cmd.py +0 -0
  57. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/timeline_cmd.py +0 -0
  58. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/topics_cmd.py +0 -0
  59. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/index/tree_cmd.py +0 -0
  60. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/info_json_cmd.py +0 -0
  61. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/list_cmd.py +0 -0
  62. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/msg/__init__.py +0 -0
  63. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/msg/def_cmd.py +0 -0
  64. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/msg/list_cmd.py +0 -0
  65. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/msg/serve_cmd.py +0 -0
  66. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/process_cmd.py +0 -0
  67. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/rechunk_cmd.py +0 -0
  68. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/records_cmd.py +0 -0
  69. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/recover_cmd.py +0 -0
  70. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/recover_inplace_cmd.py +0 -0
  71. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/rosdecompress_cmd.py +0 -0
  72. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/tf_export_cmd.py +0 -0
  73. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/tf_get_cmd.py +0 -0
  74. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/tftree_cmd.py +0 -0
  75. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/topic_chunks_cmd.py +0 -0
  76. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/cmd/video_cmd.py +0 -0
  77. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/constants.py +0 -0
  78. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/__init__.py +0 -0
  79. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/input_options.py +0 -0
  80. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/input_processor_chain.py +0 -0
  81. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/mcap_compare.py +0 -0
  82. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/msg_resolver.py +0 -0
  83. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/ARCHITECTURE.md +0 -0
  84. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/__init__.py +0 -0
  85. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/always_decode.py +0 -0
  86. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/attachment_filter.py +0 -0
  87. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/base.py +0 -0
  88. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/boundary_split.py +0 -0
  89. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/channel_merge.py +0 -0
  90. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/chunk_groupers.py +0 -0
  91. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/dedup.py +0 -0
  92. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/duration_split.py +0 -0
  93. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/expression_split.py +0 -0
  94. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/latching.py +0 -0
  95. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/metadata_filter.py +0 -0
  96. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/nth_message.py +0 -0
  97. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/size_split.py +0 -0
  98. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/time_filter.py +0 -0
  99. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/time_offset.py +0 -0
  100. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/timestamp_split.py +0 -0
  101. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/topic_alias.py +0 -0
  102. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/topic_filter.py +0 -0
  103. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/topic_rewrite.py +0 -0
  104. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/processors/utils.py +0 -0
  105. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/qos.py +0 -0
  106. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/tf_findings.py +0 -0
  107. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/core/tf_tree.py +0 -0
  108. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/debug_wrapper.py +0 -0
  109. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/display/__init__.py +0 -0
  110. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/display/cat_helpers.py +0 -0
  111. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/display/display_utils.py +0 -0
  112. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/display/message_render.py +0 -0
  113. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/display/osc_utils.py +0 -0
  114. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/display/schema_html.py +0 -0
  115. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/display/schema_render.py +0 -0
  116. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/display/sparkline.py +0 -0
  117. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/display/time_ranges.py +0 -0
  118. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/doctor.py +0 -0
  119. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/encoding/__init__.py +0 -0
  120. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/encoding/arrow_schema.py +0 -0
  121. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/__init__.py +0 -0
  122. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/_common.py +0 -0
  123. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/base.py +0 -0
  124. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/csv_exporter.py +0 -0
  125. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/geo_common.py +0 -0
  126. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/geojson_exporter.py +0 -0
  127. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/gpx_exporter.py +0 -0
  128. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/image_exporter.py +0 -0
  129. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/json_exporter.py +0 -0
  130. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/kml_exporter.py +0 -0
  131. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/parquet_exporter.py +0 -0
  132. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/pcd_exporter.py +0 -0
  133. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/sdf_exporter.py +0 -0
  134. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/urdf_exporter.py +0 -0
  135. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/video_exporter.py +0 -0
  136. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/exporters/video_file_writer.py +0 -0
  137. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/http_utils.py +0 -0
  138. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/__init__.py +0 -0
  139. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/datasette/metadata.yaml +0 -0
  140. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/datasette/plugins/pymcap_render.py +0 -0
  141. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/datasette/templates/index.html +0 -0
  142. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/datasette/templates/query-index-timeline.html +0 -0
  143. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/db.py +0 -0
  144. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/fingerprint.py +0 -0
  145. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/migrations/0001.py +0 -0
  146. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/migrations/0002_normalise_and_drop_chunks.py +0 -0
  147. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/migrations/0003_intern_channels.py +0 -0
  148. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/migrations/0004_intern_compress_channel_metadata.py +0 -0
  149. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/migrations/0005_content_compression.py +0 -0
  150. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/migrations/0006_channel_statistics.py +0 -0
  151. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/migrations/0007_consolidate_schema.py +0 -0
  152. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/migrations/0008_rewrite_current_file_view.py +0 -0
  153. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/migrations/0009_read_side_covering_indexes.py +0 -0
  154. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/migrations/__init__.py +0 -0
  155. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/scanner.py +0 -0
  156. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/index/summary_fingerprint.py +0 -0
  157. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/log_setup.py +0 -0
  158. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/py.typed +0 -0
  159. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/rihs01.py +0 -0
  160. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/rosbag_reader/__init__.py +0 -0
  161. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/rosbag_reader/_reader.py +0 -0
  162. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/rosbag_reader/_types.py +0 -0
  163. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/rosbag_reader/py.typed +0 -0
  164. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/types/__init__.py +0 -0
  165. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/types/duration.py +0 -0
  166. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/types/info_data.py +0 -0
  167. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/types/info_link.py +0 -0
  168. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/types/info_types.py +0 -0
  169. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/types/qos.py +0 -0
  170. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/types/size.py +0 -0
  171. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/types/to_plain.py +0 -0
  172. {pymcap_cli-0.14.0 → pymcap_cli-0.16.0}/src/pymcap_cli/types/types_manual.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pymcap-cli
3
- Version: 0.14.0
3
+ Version: 0.16.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
@@ -37,6 +37,7 @@ Requires-Dist: pymcap-cli[image,draco,bridge,xxhash] ; extra == 'lite'
37
37
  Requires-Dist: pyarrow>=15.0.0 ; extra == 'parquet'
38
38
  Requires-Dist: numpy>=1.24.0 ; extra == 'parquet'
39
39
  Requires-Dist: mcap-codec-support[pointcloud] ; extra == 'parquet'
40
+ Requires-Dist: kaleido>=1.0.0 ; extra == 'plot'
40
41
  Requires-Dist: plotly>=6.0.0 ; extra == 'plot'
41
42
  Requires-Dist: mcap-codec-support[pointcloud] ; extra == 'pointcloud'
42
43
  Requires-Dist: datasette>=0.65.2,<1 ; extra == 'serve'
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pymcap-cli"
3
- version = "0.14.0"
3
+ version = "0.16.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"
@@ -52,7 +52,10 @@ bridge = ["robo-ws-bridge"]
52
52
  draco = ["mcap-codec-support[draco]"]
53
53
  image = ["pillow>=10.0"]
54
54
  parquet = ["pyarrow>=15.0.0", "numpy>=1.24.0", "mcap-codec-support[pointcloud]"]
55
- plot = ["plotly>=6.0.0"]
55
+ plot = [
56
+ "kaleido>=1.0.0",
57
+ "plotly>=6.0.0",
58
+ ]
56
59
  pointcloud = ["mcap-codec-support[pointcloud]"]
57
60
  video = ["mcap-codec-support[video]"]
58
61
  xxhash = ["xxhash>=3.0.0"]
@@ -19,6 +19,7 @@ from pymcap_cli.core.mcap_processor import (
19
19
  ProcessingOptions,
20
20
  ProcessingStats,
21
21
  )
22
+ from pymcap_cli.core.rosbag2_layout import expand_bag_paths
22
23
  from pymcap_cli.utils import confirm_output_overwrite, read_info
23
24
 
24
25
  logger = logging.getLogger(__name__)
@@ -64,6 +65,7 @@ def run_processor(
64
65
 
65
66
  Raises any exception from McapProcessor.process() to the caller.
66
67
  """
68
+ files = expand_bag_paths(files)
67
69
  with contextlib.ExitStack() as stack:
68
70
  input_files: list[InputFile] = []
69
71
 
@@ -98,6 +100,76 @@ def validate_mcap_output(path: Path) -> bool:
98
100
  return True
99
101
 
100
102
 
103
+ def mcap_message_count(path: Path) -> int | None:
104
+ """Return the message count from an MCAP summary, or None if it can't be read."""
105
+ try:
106
+ with path.open("rb") as f:
107
+ info = read_info(f)
108
+ except (McapError, InvalidMagicError, OSError, AssertionError) as e:
109
+ logger.debug(f"Could not read message count for {path}: {e}")
110
+ return None
111
+ if info.summary.statistics is None:
112
+ return None
113
+ return info.summary.statistics.message_count
114
+
115
+
116
+ def _outputs_dropped_all_messages(sources: list[str], outputs: list[Path]) -> bool:
117
+ """True if every output has zero messages while some local source had messages.
118
+
119
+ Guards against deleting sources when a transform silently produced empty output
120
+ (e.g. the source was truncated before being read). Partial drops — dedup, time or
121
+ channel filters — are intentionally not flagged; only total loss is.
122
+ """
123
+ out_total = 0
124
+ for p in outputs:
125
+ count = mcap_message_count(p)
126
+ if count is None:
127
+ return False # unknown — validation already passed, don't second-guess it
128
+ out_total += count
129
+ if out_total > 0:
130
+ return False
131
+ for src in sources:
132
+ if urlparse(src).scheme in ("http", "https"):
133
+ continue # URLs are never deleted, so their counts don't matter
134
+ count = mcap_message_count(Path(src))
135
+ if count:
136
+ return True
137
+ return False
138
+
139
+
140
+ def _outputs_lost_messages(sources: list[str], outputs: list[Path]) -> bool:
141
+ """True if the outputs hold fewer messages in total than the local sources.
142
+
143
+ For lossless transforms (compress) every source message must appear in the
144
+ output, so any shortfall means data was dropped. Counts that can't be read
145
+ are treated as unknown and never trigger a block.
146
+ """
147
+ out_total = 0
148
+ for p in outputs:
149
+ count = mcap_message_count(p)
150
+ if count is None:
151
+ return False
152
+ out_total += count
153
+ src_total = 0
154
+ for src in sources:
155
+ if urlparse(src).scheme in ("http", "https"):
156
+ continue
157
+ count = mcap_message_count(Path(src))
158
+ if count is None:
159
+ return False
160
+ src_total += count
161
+ return out_total < src_total
162
+
163
+
164
+ def processing_had_errors(stats: ProcessingStats) -> bool:
165
+ """True if the processor swallowed read/validation errors during the run.
166
+
167
+ Such a run produces incomplete output even when it exits cleanly, so it is
168
+ not safe to delete or replace the source from it.
169
+ """
170
+ return stats.errors_encountered > 0 or stats.validation_errors > 0
171
+
172
+
101
173
  def delete_source_files(sources: list[str], outputs: list[Path]) -> None:
102
174
  """Delete each local source file. Skip URLs and any source path that
103
175
  resolves to one of ``outputs`` (with a warning).
@@ -134,14 +206,19 @@ def in_place_temp_path(source: Path) -> Path:
134
206
  def finalize_replace_source(*, source: Path, tmp_output: Path) -> int:
135
207
  """Validate ``tmp_output`` and atomically replace ``source`` with it.
136
208
 
137
- Returns 0 on success and 1 if validation failed (source is preserved and
138
- the temp file is removed).
209
+ Returns 0 on success and 1 if the temp output failed validation or is empty
210
+ while the source had messages. In those cases the source is preserved and the
211
+ temp file is removed.
139
212
  """
140
213
  if not validate_mcap_output(tmp_output):
141
214
  logger.error(f"[red]Output failed validation: {tmp_output}[/red]")
142
215
  logger.error("Source file preserved — output not safe to replace source.")
143
216
  tmp_output.unlink(missing_ok=True)
144
217
  return 1
218
+ if _outputs_lost_messages([str(source)], [tmp_output]):
219
+ logger.error("Output has fewer messages than the source — source file preserved.")
220
+ tmp_output.unlink(missing_ok=True)
221
+ return 1
145
222
  tmp_output.replace(source)
146
223
  logger.info(f"Replaced source: {source}")
147
224
  return 0
@@ -151,17 +228,33 @@ def finalize_delete_source(
151
228
  *,
152
229
  sources: list[str],
153
230
  outputs: list[Path],
231
+ require_lossless: bool = False,
154
232
  ) -> int:
155
233
  """Validate every output and, if all valid, delete the eligible sources.
156
234
 
157
- Returns 0 on success (sources deleted or skipped with warning) and 1 if
158
- any output failed validation (no sources are deleted in that case).
235
+ Returns 0 on success (sources deleted or skipped with warning) and 1 if there
236
+ are no outputs, any output failed validation, or every output is empty while a
237
+ source had messages. No sources are deleted in those cases.
238
+
239
+ When ``require_lossless`` is set (transforms that must preserve every message,
240
+ e.g. ``compress``), any shortfall in total output messages versus the sources
241
+ also preserves them — not just total loss.
159
242
  """
243
+ if not outputs:
244
+ logger.error("No output files were produced — source file(s) preserved.")
245
+ return 1
160
246
  invalid = [p for p in outputs if not validate_mcap_output(p)]
161
247
  if invalid:
162
248
  for p in invalid:
163
249
  logger.error(f"[red]Output failed validation: {p}[/red]")
164
250
  logger.error("Source file(s) preserved — output not safe to replace source.")
165
251
  return 1
252
+ if require_lossless:
253
+ if _outputs_lost_messages(sources, outputs):
254
+ logger.error("Output has fewer messages than the source(s) — source file(s) preserved.")
255
+ return 1
256
+ elif _outputs_dropped_all_messages(sources, outputs):
257
+ logger.error("Output contains no messages but the source did — source file(s) preserved.")
258
+ return 1
166
259
  delete_source_files(sources, outputs)
167
260
  return 0
@@ -11,6 +11,7 @@ from pymcap_cli.core.mcap_processor import (
11
11
  OutputOptions,
12
12
  ProcessingOptions,
13
13
  )
14
+ from pymcap_cli.core.rosbag2_layout import expand_bag_paths
14
15
 
15
16
 
16
17
  def run_processor_multi(
@@ -28,6 +29,9 @@ def run_processor_multi(
28
29
  """
29
30
  if input_options is None:
30
31
  input_options = InputOptions.from_args()
32
+ files = expand_bag_paths(files)
33
+ # Let the OutputManager refuse to open a segment that would truncate an input.
34
+ output_options.input_paths = tuple(files)
31
35
  with contextlib.ExitStack() as stack:
32
36
  input_files: list[InputFile] = []
33
37
 
@@ -10,6 +10,7 @@ from pymcap_cli.cmd._run_processor import (
10
10
  finalize_delete_source,
11
11
  finalize_replace_source,
12
12
  in_place_temp_path,
13
+ processing_had_errors,
13
14
  resolve_overwrite_policy,
14
15
  run_processor,
15
16
  )
@@ -30,6 +31,7 @@ from pymcap_cli.types.types_manual import (
30
31
  NoClobberOption,
31
32
  OutputPathOption,
32
33
  )
34
+ from pymcap_cli.utils import output_overwrites_input
33
35
 
34
36
  logger = logging.getLogger(__name__)
35
37
  console = Console()
@@ -113,6 +115,12 @@ def compress(
113
115
  if output is None:
114
116
  logger.error("Either --output or --in-place is required.")
115
117
  return 1
118
+ if output_overwrites_input(file, output):
119
+ logger.error(
120
+ "Output path is the same file as the input. "
121
+ "Use --in-place to compress in place safely."
122
+ )
123
+ return 1
116
124
  policy = resolve_overwrite_policy(force=force, no_clobber=no_clobber)
117
125
  if policy is None:
118
126
  logger.error("--force and --no-clobber cannot be used together.")
@@ -139,14 +147,22 @@ def compress(
139
147
  console.print(result.stats)
140
148
  except Exception:
141
149
  logger.exception("Error during compression")
142
- if in_place:
143
- output.unlink(missing_ok=True)
150
+ # The output was opened "wb" (truncated) before processing, so a failed run
151
+ # leaves an empty/partial file. Remove it rather than leaving a 0-byte result.
152
+ output.unlink(missing_ok=True)
144
153
  return 1
145
154
 
146
155
  if in_place:
156
+ if processing_had_errors(result.stats):
157
+ logger.error("Processing reported errors — source preserved, not replaced in place.")
158
+ output.unlink(missing_ok=True)
159
+ return 1
147
160
  return finalize_replace_source(source=Path(file), tmp_output=output)
148
161
 
149
162
  if delete_source:
150
- return finalize_delete_source(sources=[file], outputs=[output])
163
+ if processing_had_errors(result.stats):
164
+ logger.error("Processing reported errors — source file preserved.")
165
+ return 1
166
+ return finalize_delete_source(sources=[file], outputs=[output], require_lossless=True)
151
167
 
152
168
  return 0
@@ -10,6 +10,7 @@ from datetime import datetime, timedelta, timezone
10
10
  from enum import Enum
11
11
  from pathlib import Path
12
12
  from typing import Annotated
13
+ from urllib.parse import urlparse
13
14
 
14
15
  from cyclopts import Group as CycloptsGroup
15
16
  from cyclopts import Parameter
@@ -17,12 +18,13 @@ from rich.console import Console, Group, RenderableType
17
18
  from rich.live import Live
18
19
  from rich.table import Table
19
20
  from rich.text import Text
20
- from small_mcap import McapError, rebuild_summary
21
+ from small_mcap import McapError, RebuildInfo, rebuild_summary
21
22
  from small_mcap.records import Summary
22
23
 
23
24
  from pymcap_cli.constants import NS_TO_MS, NS_TO_SEC
24
25
  from pymcap_cli.core.input_handler import open_input
25
26
  from pymcap_cli.core.qos import parse_qos_profiles
27
+ from pymcap_cli.core.rosbag2_layout import find_bag_splits, read_aggregated_bag_info
26
28
  from pymcap_cli.display.display_utils import (
27
29
  ChannelTableColumn,
28
30
  DistributionBar,
@@ -241,6 +243,41 @@ def _qos_from_summary(summary: Summary) -> dict[int, list[QosProfile]]:
241
243
  return out
242
244
 
243
245
 
246
+ def _is_local_dir(path: str) -> bool:
247
+ return urlparse(path).scheme not in ("http", "https") and Path(path).is_dir()
248
+
249
+
250
+ def _load_info(
251
+ file: str,
252
+ *,
253
+ rebuild: bool,
254
+ exact_sizes: bool,
255
+ debug: bool,
256
+ ) -> tuple[RebuildInfo, int, str]:
257
+ """Read info for one ``info`` argument.
258
+
259
+ A multi-split rosbag2 directory is aggregated into one logical RebuildInfo;
260
+ a plain file (or single-split directory) is read directly. Returns
261
+ ``(info, size, label)`` where ``label`` names the file or bag for display.
262
+ """
263
+ if _is_local_dir(file):
264
+ splits = find_bag_splits(Path(file))
265
+ if not splits:
266
+ raise ValueError(f"{file!r} is not an MCAP file or a rosbag2 bag directory")
267
+ if len(splits) > 1:
268
+ info_data, total = read_aggregated_bag_info(
269
+ splits, rebuild=rebuild, exact_sizes=exact_sizes
270
+ )
271
+ return info_data, total, file
272
+ file = str(splits[0])
273
+
274
+ with open_input(file, buffering=0, debug=debug) as (f_buffered, file_size):
275
+ info_data = read_or_rebuild_info(
276
+ f_buffered, file_size, rebuild=rebuild, exact_sizes=exact_sizes
277
+ )
278
+ return info_data, file_size, file
279
+
280
+
244
281
  def _build_info_display(
245
282
  data: McapInfoOutput,
246
283
  has_chunk_info: bool,
@@ -454,6 +491,14 @@ def info(
454
491
  group=DISPLAY_GROUP,
455
492
  ),
456
493
  ] = False,
494
+ qos: Annotated[
495
+ bool,
496
+ Parameter(
497
+ name=["--qos"],
498
+ negative="--no-qos",
499
+ group=DISPLAY_GROUP,
500
+ ),
501
+ ] = False,
457
502
  watch: Annotated[
458
503
  bool,
459
504
  Parameter(
@@ -490,6 +535,8 @@ def info(
490
535
  ----------
491
536
  files
492
537
  Path(s) to MCAP file(s) to analyze (local files or HTTP/HTTPS URLs).
538
+ A rosbag2 bag directory (``<bag>/<bag>_<N>.mcap`` splits) may be passed
539
+ in place of a file; its splits are aggregated into one combined view.
493
540
  rebuild
494
541
  Rebuild file metadata by scanning all records (use for corrupt or
495
542
  summary-less files).
@@ -515,6 +562,9 @@ def info(
515
562
  --rebuild to calculate message intervals.
516
563
  tree
517
564
  Display channels in a hierarchical tree structure based on topic paths.
565
+ qos
566
+ Show the per-channel QoS column for ROS 2 MCAPs (default: off). Pass
567
+ --qos to show it.
518
568
  watch
519
569
  Watch the file for changes and display live-updating statistics.
520
570
  Requires exactly one local file. Incompatible with --json and --compress.
@@ -582,20 +632,32 @@ def info(
582
632
  if len(files) != 1:
583
633
  logger.error("--watch requires exactly one file")
584
634
  return 1
635
+ watch_target = files[0]
636
+ if _is_local_dir(watch_target):
637
+ splits = find_bag_splits(Path(watch_target))
638
+ if not splits:
639
+ logger.error("%r is not an MCAP file or a rosbag2 bag directory", watch_target)
640
+ return 1
641
+ if len(splits) > 1:
642
+ logger.error(
643
+ "--watch does not support multi-split rosbag2 directories; "
644
+ "pass a single .mcap file"
645
+ )
646
+ return 1
647
+ watch_target = str(splits[0])
585
648
  return _watch_file(
586
- files[0], sort.value, reverse, index_duration, median, tree, watch_interval
649
+ watch_target, sort.value, reverse, index_duration, median, tree, watch_interval
587
650
  )
588
651
 
589
652
  # Link output mode
590
653
  if link:
591
654
  mode: ScanMode = "exact" if exact_sizes else ("rebuild" if rebuild else "summary")
592
655
  for file in files:
593
- with open_input(file, buffering=0, debug=debug) as (f_buffered, file_size):
594
- info_data = read_or_rebuild_info(
595
- f_buffered, file_size, rebuild=rebuild, exact_sizes=exact_sizes
596
- )
597
- data = info_to_dict(info_data, str(file), file_size)
598
- url = generate_link(data, str(file), file_size, mode)
656
+ info_data, file_size, label = _load_info(
657
+ file, rebuild=rebuild, exact_sizes=exact_sizes, debug=debug
658
+ )
659
+ data = info_to_dict(info_data, label, file_size)
660
+ url = generate_link(data, label, file_size, mode)
599
661
  print(url) # noqa: T201
600
662
  return 0
601
663
 
@@ -603,11 +665,10 @@ def info(
603
665
  if json_output:
604
666
  all_outputs: list[McapInfoOutput] = []
605
667
  for file in files:
606
- with open_input(file, buffering=0, debug=debug) as (f_buffered, file_size):
607
- info_data = read_or_rebuild_info(
608
- f_buffered, file_size, rebuild=rebuild, exact_sizes=exact_sizes
609
- )
610
- data = info_to_dict(info_data, str(file), file_size)
668
+ info_data, file_size, label = _load_info(
669
+ file, rebuild=rebuild, exact_sizes=exact_sizes, debug=debug
670
+ )
671
+ data = info_to_dict(info_data, label, file_size)
611
672
  all_outputs.append(data)
612
673
  _output_json(all_outputs, compress)
613
674
  return 0
@@ -617,13 +678,12 @@ def info(
617
678
  if i > 0:
618
679
  console.print("\n" + "=" * 80 + "\n")
619
680
 
620
- with open_input(file, buffering=0, debug=debug) as (f_buffered, file_size):
621
- info_data = read_or_rebuild_info(
622
- f_buffered, file_size, rebuild=rebuild, exact_sizes=exact_sizes
623
- )
681
+ info_data, file_size, label = _load_info(
682
+ file, rebuild=rebuild, exact_sizes=exact_sizes, debug=debug
683
+ )
624
684
 
625
685
  # Get structured data
626
- data = info_to_dict(info_data, str(file), file_size)
686
+ data = info_to_dict(info_data, label, file_size)
627
687
  has_chunk_info = info_data.chunk_information is not None
628
688
 
629
689
  # Warn if sorting by size fields when channel_sizes is unavailable
@@ -658,7 +718,7 @@ def info(
658
718
  )
659
719
  console.print()
660
720
 
661
- qos_by_channel_id = _qos_from_summary(info_data.summary)
721
+ qos_by_channel_id = _qos_from_summary(info_data.summary) if qos else None
662
722
 
663
723
  # Display all sections
664
724
  console.print(
@@ -676,7 +736,7 @@ def info(
676
736
 
677
737
  # Show shareable web inspector link
678
738
  mode: ScanMode = "exact" if exact_sizes else ("rebuild" if rebuild else "summary")
679
- url = generate_link(data, str(file), file_size, mode)
739
+ url = generate_link(data, label, file_size, mode)
680
740
  console.print(f"\n[link={url}][dim]View in web inspector[/dim][/]")
681
741
 
682
742
  return 0
@@ -8,6 +8,7 @@ from rich.console import Console
8
8
 
9
9
  from pymcap_cli.cmd._run_processor import (
10
10
  finalize_delete_source,
11
+ processing_had_errors,
11
12
  resolve_overwrite_policy,
12
13
  run_processor,
13
14
  )
@@ -18,6 +19,7 @@ from pymcap_cli.core.mcap_processor import (
18
19
  )
19
20
  from pymcap_cli.core.processors.base import InputProcessor # noqa: TC001 - runtime annotation below
20
21
  from pymcap_cli.core.processors.dedup import DedupIdenticalProcessor
22
+ from pymcap_cli.core.rosbag2_layout import expand_bag_paths
21
23
  from pymcap_cli.types.types_manual import (
22
24
  ChunkSizeOption,
23
25
  CompressionOption,
@@ -29,6 +31,7 @@ from pymcap_cli.types.types_manual import (
29
31
  from pymcap_cli.utils import (
30
32
  AttachmentsMode,
31
33
  MetadataMode,
34
+ output_overwrites_input,
32
35
  )
33
36
 
34
37
  logger = logging.getLogger(__name__)
@@ -109,6 +112,7 @@ def merge(
109
112
  pymcap-cli merge *.mcap -o all_recordings.mcap --compression lz4
110
113
  ```
111
114
  """
115
+ files = expand_bag_paths(files)
112
116
  if len(files) < 2:
113
117
  logger.error("At least 2 input files are required for merging")
114
118
  return 1
@@ -118,6 +122,10 @@ def merge(
118
122
  logger.error("--force and --no-clobber cannot be used together.")
119
123
  return 1
120
124
 
125
+ if any(output_overwrites_input(f, output) for f in files):
126
+ logger.error("Output path is the same file as an input; choose a different output file.")
127
+ return 1
128
+
121
129
  dedup_processor = DedupIdenticalProcessor() if dedup_identical else None
122
130
  extra_processors: list[InputProcessor] | None = [dedup_processor] if dedup_processor else None
123
131
 
@@ -147,6 +155,9 @@ def merge(
147
155
  return 1
148
156
 
149
157
  if delete_source:
158
+ if processing_had_errors(result.stats):
159
+ logger.error("Processing reported errors — source file(s) preserved.")
160
+ return 1
150
161
  return finalize_delete_source(sources=list(files), outputs=[output])
151
162
 
152
163
  return 0
@@ -65,7 +65,12 @@ def plot(
65
65
 
66
66
  Extracts values along message paths and plots them over time using plotly.
67
67
  Supports numeric, boolean, and string values natively. Multiple paths can
68
- be overlaid.
68
+ be overlaid. Array-valued paths expand into one trace per element index.
69
+
70
+ Output format is chosen from the ``-o`` suffix: ``.html`` (interactive) or
71
+ ``.png`` / ``.svg`` / ``.pdf`` / ``.jpg`` / ``.webp`` (static image, no
72
+ browser needed — handy over SSH). With no ``-o`` the figure opens in a
73
+ browser.
69
74
 
70
75
  Paths can be given a custom label: "Label=/topic.field"
71
76
 
@@ -75,6 +80,7 @@ def plot(
75
80
  pymcap-cli plot recording.mcap --xy /odom.pose.position.x /odom.pose.position.y
76
81
  pymcap-cli plot recording.mcap /odom.pose.position.x -d 1000
77
82
  pymcap-cli plot recording.mcap /odom.pose.position.x -s 10 -e 20 -o plot.html
83
+ pymcap-cli plot recording.mcap "/joints.position[:].@degrees" -o joints.svg
78
84
  """
79
85
  if not paths:
80
86
  logger.error("At least one message path is required")
@@ -93,6 +99,7 @@ def plot(
93
99
  downsample=downsample,
94
100
  xy=xy,
95
101
  force=force,
102
+ source_name=Path(file).name,
96
103
  )
97
104
  except (ValueError, ValidationError) as exc:
98
105
  logger.error(str(exc)) # noqa: TRY400
@@ -1,5 +1,6 @@
1
1
  """Command to compress image and point cloud topics in MCAP files."""
2
2
 
3
+ import contextlib
3
4
  import logging
4
5
  from collections import deque
5
6
  from collections.abc import Iterable, Iterator
@@ -50,7 +51,7 @@ from pymcap_cli.core.mcap_transform import (
50
51
  )
51
52
  from pymcap_cli.exporters._common import normalize_schema_name
52
53
  from pymcap_cli.types.types_manual import ForceOverwriteOption, OutputPathOption
53
- from pymcap_cli.utils import confirm_output_overwrite
54
+ from pymcap_cli.utils import confirm_output_overwrite, output_overwrites_input
54
55
 
55
56
  logger = logging.getLogger(__name__)
56
57
  console = Console()
@@ -246,6 +247,10 @@ def roscompress(
246
247
  pointcloud
247
248
  Enable point cloud compression. Default: True.
248
249
  """
250
+ if output_overwrites_input(file, output):
251
+ logger.error("Output path is the same file as the input; choose a different output file.")
252
+ return 1
253
+
249
254
  confirm_output_overwrite(output, force)
250
255
 
251
256
  if not 1 <= jpeg_quality <= 100:
@@ -332,11 +337,20 @@ def roscompress(
332
337
  last_video_times: dict[str, tuple[int, int]] = {}
333
338
  pending_messages: dict[str, deque[DecodedMessage]] = {}
334
339
 
335
- with (
336
- open_input(file) as (input_stream, input_size),
337
- output.open("wb") as output_stream,
338
- create_progress(title="Compressing images") as progress,
339
- ):
340
+ with contextlib.ExitStack() as stack:
341
+ # Opening the output truncates it; remove the truncated/partial file if the
342
+ # run does not finish cleanly. Registered first so it runs after the stream
343
+ # is closed on exit.
344
+ ok = False
345
+
346
+ def _cleanup_on_failure() -> None:
347
+ if not ok:
348
+ output.unlink(missing_ok=True)
349
+
350
+ stack.callback(_cleanup_on_failure)
351
+ input_stream, input_size = stack.enter_context(open_input(file))
352
+ output_stream = stack.enter_context(output.open("wb"))
353
+ progress = stack.enter_context(create_progress(title="Compressing images"))
340
354
  task_id = progress.add_task("Processing messages", total=total_message_count)
341
355
 
342
356
  writer = McapWriter(
@@ -411,6 +425,7 @@ def roscompress(
411
425
  counters["converted"] += 1
412
426
 
413
427
  writer.finish()
428
+ ok = compress_ok
414
429
 
415
430
  if not compress_ok:
416
431
  return 1
@@ -8,7 +8,11 @@ from cyclopts import Group, Parameter, validators
8
8
  from rich.console import Console
9
9
  from ros_parser.message_path import MessagePathError
10
10
 
11
- from pymcap_cli.cmd._run_processor import finalize_delete_source, resolve_overwrite_policy
11
+ from pymcap_cli.cmd._run_processor import (
12
+ finalize_delete_source,
13
+ processing_had_errors,
14
+ resolve_overwrite_policy,
15
+ )
12
16
  from pymcap_cli.cmd._run_processor_multi import run_processor_multi
13
17
  from pymcap_cli.constants import DEFAULT_CHUNK_SIZE, DEFAULT_COMPRESSION
14
18
  from pymcap_cli.core.mcap_processor import InputOptions, OutputOptions
@@ -394,6 +398,9 @@ def split(
394
398
  )
395
399
 
396
400
  if delete_source:
401
+ if processing_had_errors(result.stats):
402
+ logger.error("Processing reported errors — source file preserved.")
403
+ return 1
397
404
  outputs = [
398
405
  Path(segment.path) for segment in result.processor.output_manager.segments.values()
399
406
  ]
@@ -11,8 +11,35 @@ from pymcap_cli.debug_wrapper import DebugStreamWrapper
11
11
  from pymcap_cli.http_utils import open_http_stream
12
12
 
13
13
 
14
+ def resolve_mcap_path(path: str) -> str:
15
+ """Resolve a rosbag2 bag directory to its single ``.mcap`` file.
16
+
17
+ rosbag2 lays a recording out as ``<bagname>/<bagname>_<N>.mcap`` splits.
18
+ For single-file commands this resolves a directory holding exactly one split
19
+ to that file. A multi-split directory (which has no single-file meaning here)
20
+ or a directory with no resolvable MCAP raises ``ValueError`` rather than
21
+ surfacing an opaque ``IsADirectoryError`` downstream. Files and URLs pass
22
+ through unchanged.
23
+ """
24
+ candidate_dir = Path(path)
25
+ if not candidate_dir.is_dir():
26
+ return path
27
+
28
+ from pymcap_cli.core.rosbag2_layout import find_bag_splits # noqa: PLC0415
29
+
30
+ splits = find_bag_splits(candidate_dir)
31
+ if len(splits) == 1:
32
+ return str(splits[0])
33
+ if not splits:
34
+ raise ValueError(f"{path!r} is not an MCAP file or a rosbag2 bag directory")
35
+ raise ValueError(
36
+ f"{path!r} is a multi-split rosbag2 directory ({len(splits)} files); "
37
+ f"this command reads a single file — use 'info' or 'merge'"
38
+ )
39
+
40
+
14
41
  def _open_path_file(url: ParseResult) -> tuple[io.RawIOBase, int]:
15
- file_path = Path(url.path)
42
+ file_path = Path(resolve_mcap_path(url.path))
16
43
  raw_stream = file_path.open("rb", buffering=0)
17
44
  size = file_path.stat().st_size
18
45
  return raw_stream, size