pymcap-cli 0.8.0__tar.gz → 0.9.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 (94) hide show
  1. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/PKG-INFO +1 -1
  2. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/pyproject.toml +1 -1
  3. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cli.py +52 -31
  4. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/_run_processor.py +66 -1
  5. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/bag2mcap_cmd.py +5 -5
  6. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/cat_cmd.py +18 -29
  7. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/compress_cmd.py +21 -6
  8. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/convert_cmd.py +9 -11
  9. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/diag_cmd.py +10 -8
  10. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/diff_cmd.py +205 -68
  11. pymcap_cli-0.9.0/src/pymcap_cli/cmd/duplicates_cmd.py +765 -0
  12. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/export_geo_cmd.py +3 -2
  13. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/export_parquet_cmd.py +5 -4
  14. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/filter_cmd.py +20 -6
  15. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/info_cmd.py +10 -8
  16. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/merge_cmd.py +20 -6
  17. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/plot_cmd.py +7 -8
  18. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/process_cmd.py +21 -7
  19. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/rechunk_cmd.py +23 -15
  20. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/records_cmd.py +5 -5
  21. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/recover_cmd.py +28 -9
  22. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/recover_inplace_cmd.py +9 -11
  23. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/roscompress_cmd.py +34 -42
  24. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/rosdecompress_cmd.py +2 -2
  25. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/split_cmd.py +30 -18
  26. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/tftree_cmd.py +4 -2
  27. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/video_cmd.py +4 -3
  28. pymcap_cli-0.9.0/src/pymcap_cli/core/mcap_compare.py +1105 -0
  29. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/mcap_transform.py +40 -20
  30. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/_common.py +5 -4
  31. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/base.py +7 -8
  32. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/driver.py +21 -24
  33. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/gpx_exporter.py +4 -3
  34. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/kml_exporter.py +4 -3
  35. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/parquet_exporter.py +11 -10
  36. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/plot_exporter.py +20 -30
  37. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/video_exporter.py +4 -10
  38. pymcap_cli-0.9.0/src/pymcap_cli/log_setup.py +59 -0
  39. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/types/types_manual.py +8 -0
  40. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/utils.py +24 -5
  41. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/README.md +0 -0
  42. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/__init__.py +0 -0
  43. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/__init__.py +0 -0
  44. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/_run_processor_multi.py +0 -0
  45. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/du_cmd.py +0 -0
  46. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/export_csv_cmd.py +0 -0
  47. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/export_images_cmd.py +0 -0
  48. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/export_json_cmd.py +0 -0
  49. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/export_pcd_cmd.py +0 -0
  50. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/info_json_cmd.py +0 -0
  51. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/list_cmd.py +0 -0
  52. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/cmd/topic_chunks_cmd.py +0 -0
  53. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/__init__.py +0 -0
  54. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/input_handler.py +0 -0
  55. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/mcap_processor.py +0 -0
  56. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/msg_resolver.py +0 -0
  57. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/processors/__init__.py +0 -0
  58. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/processors/always_decode.py +0 -0
  59. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/processors/attachment_filter.py +0 -0
  60. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/processors/base.py +0 -0
  61. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/processors/duration_split.py +0 -0
  62. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/processors/expression_split.py +0 -0
  63. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/processors/metadata_filter.py +0 -0
  64. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/processors/time_filter.py +0 -0
  65. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/processors/timestamp_split.py +0 -0
  66. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/processors/topic_filter.py +0 -0
  67. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/core/processors/utils.py +0 -0
  68. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/debug_wrapper.py +0 -0
  69. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/display/__init__.py +0 -0
  70. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/display/display_utils.py +0 -0
  71. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/display/osc_utils.py +0 -0
  72. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/display/sparkline.py +0 -0
  73. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/encoding/__init__.py +0 -0
  74. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/encoding/arrow_schema.py +0 -0
  75. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/__init__.py +0 -0
  76. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/csv_exporter.py +0 -0
  77. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/geo_common.py +0 -0
  78. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/geojson_exporter.py +0 -0
  79. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/image_exporter.py +0 -0
  80. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/json_exporter.py +0 -0
  81. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/exporters/pcd_exporter.py +0 -0
  82. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/http_utils.py +0 -0
  83. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/py.typed +0 -0
  84. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/rihs01.py +0 -0
  85. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/rosbag_reader/__init__.py +0 -0
  86. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/rosbag_reader/_reader.py +0 -0
  87. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/rosbag_reader/_types.py +0 -0
  88. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/rosbag_reader/py.typed +0 -0
  89. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/types/__init__.py +0 -0
  90. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/types/duration.py +0 -0
  91. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/types/info_data.py +0 -0
  92. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/types/info_link.py +0 -0
  93. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/types/info_types.py +0 -0
  94. {pymcap_cli-0.8.0 → pymcap_cli-0.9.0}/src/pymcap_cli/types/to_plain.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: pymcap-cli
3
- Version: 0.8.0
3
+ Version: 0.9.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.8.0"
3
+ version = "0.9.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"
@@ -1,8 +1,8 @@
1
1
  """Main CLI entry point for pymcap-cli using Cyclopts."""
2
2
 
3
- import sys
3
+ from typing import Annotated
4
4
 
5
- from cyclopts import App, Group
5
+ from cyclopts import App, Group, Parameter
6
6
 
7
7
  from pymcap_cli.cmd import (
8
8
  bag2mcap_cmd,
@@ -12,6 +12,7 @@ from pymcap_cli.cmd import (
12
12
  diag_cmd,
13
13
  diff_cmd,
14
14
  du_cmd,
15
+ duplicates_cmd,
15
16
  export_csv_cmd,
16
17
  export_geo_cmd,
17
18
  export_json_cmd,
@@ -29,6 +30,7 @@ from pymcap_cli.cmd import (
29
30
  tftree_cmd,
30
31
  topic_chunks_cmd,
31
32
  )
33
+ from pymcap_cli.log_setup import ERR, setup_logging
32
34
 
33
35
  try:
34
36
  from pymcap_cli.cmd.video_cmd import video
@@ -41,12 +43,11 @@ except ImportError:
41
43
 
42
44
  uv add --group video pymcap-cli
43
45
  """
44
- print( # noqa: T201
45
- "Error:\n"
46
+ ERR.print(
47
+ "[red]Error:[/red]\n"
46
48
  "Video command is unavailable because the 'av' and/or 'numpy' are not installed.\n"
47
49
  "To enable video functionality, please install pymcap-cli with the 'video' extra:\n\n"
48
- " uv add --group video pymcap-cli\n",
49
- file=sys.stderr,
50
+ " uv add --group video pymcap-cli\n"
50
51
  )
51
52
  return 1
52
53
 
@@ -62,12 +63,11 @@ except ImportError:
62
63
 
63
64
  uv add --group plot pymcap-cli
64
65
  """
65
- print( # noqa: T201
66
- "Error:\n"
66
+ ERR.print(
67
+ "[red]Error:[/red]\n"
67
68
  "Plot command is unavailable because 'plotly' is not installed.\n"
68
69
  "To enable plot functionality, please install pymcap-cli with the 'plot' extra:\n\n"
69
- " uv add --group plot pymcap-cli\n",
70
- file=sys.stderr,
70
+ " uv add --group plot pymcap-cli\n"
71
71
  )
72
72
  return 1
73
73
 
@@ -83,12 +83,11 @@ except ImportError:
83
83
 
84
84
  uv add --group video pymcap-cli
85
85
  """
86
- print( # noqa: T201
87
- "Error:\n"
86
+ ERR.print(
87
+ "[red]Error:[/red]\n"
88
88
  "ROS compress command is unavailable because the 'av' package is not installed.\n"
89
89
  "To enable this functionality, please install pymcap-cli with the 'video' extra:\n\n"
90
- " uv add --group video pymcap-cli\n",
91
- file=sys.stderr,
90
+ " uv add --group video pymcap-cli\n"
92
91
  )
93
92
  return 1
94
93
 
@@ -104,12 +103,11 @@ except ImportError:
104
103
 
105
104
  uv add 'pymcap-cli[parquet]'
106
105
  """
107
- print( # noqa: T201
108
- "Error:\n"
106
+ ERR.print(
107
+ "[red]Error:[/red]\n"
109
108
  "Export to Parquet is unavailable because 'pyarrow' is not installed.\n"
110
109
  "Install with:\n\n"
111
- " uv add 'pymcap-cli[parquet]'\n",
112
- file=sys.stderr,
110
+ " uv add 'pymcap-cli[parquet]'\n"
113
111
  )
114
112
  return 1
115
113
 
@@ -125,10 +123,9 @@ except ImportError:
125
123
 
126
124
  uv add 'pymcap-cli[pointcloud]'
127
125
  """
128
- print( # noqa: T201
129
- "Error:\nPCD export requires numpy + pointcloud2.\n"
130
- "Install with:\n\n uv add 'pymcap-cli[pointcloud]'\n",
131
- file=sys.stderr,
126
+ ERR.print(
127
+ "[red]Error:[/red]\nPCD export requires numpy + pointcloud2.\n"
128
+ "Install with:\n\n uv add 'pymcap-cli[pointcloud]'\n"
132
129
  )
133
130
  return 1
134
131
 
@@ -144,10 +141,9 @@ except ImportError:
144
141
 
145
142
  uv add 'pymcap-cli[image]'
146
143
  """
147
- print( # noqa: T201
148
- "Error:\nImage export requires the 'image' extra (imagecodecs).\n"
149
- "Install with:\n\n uv add 'pymcap-cli[image]'\n",
150
- file=sys.stderr,
144
+ ERR.print(
145
+ "[red]Error:[/red]\nImage export requires the 'image' extra (imagecodecs).\n"
146
+ "Install with:\n\n uv add 'pymcap-cli[image]'\n"
151
147
  )
152
148
  return 1
153
149
 
@@ -163,12 +159,11 @@ except ImportError:
163
159
 
164
160
  uv add --group video pymcap-cli
165
161
  """
166
- print( # noqa: T201
167
- "Error:\n"
162
+ ERR.print(
163
+ "[red]Error:[/red]\n"
168
164
  "ROS decompress command is unavailable because the 'av' package is not installed.\n"
169
165
  "To enable this functionality, please install pymcap-cli with the 'video' extra:\n\n"
170
- " uv add --group video pymcap-cli\n",
171
- file=sys.stderr,
166
+ " uv add --group video pymcap-cli\n"
172
167
  )
173
168
  return 1
174
169
 
@@ -177,6 +172,7 @@ app = App(
177
172
  name="pymcap-cli",
178
173
  help="CLI tool for slicing and dicing MCAP files.",
179
174
  help_format="rich",
175
+ default_parameter=Parameter(negative_iterable=""),
180
176
  )
181
177
 
182
178
  inspect_group = Group("Inspect", sort_key=0)
@@ -187,6 +183,7 @@ app.command(name="cat", group=inspect_group)(cat_cmd.cat)
187
183
  app.command(name="diag", group=inspect_group)(diag_cmd.diag)
188
184
  app.command(name="du", group=inspect_group)(du_cmd.du)
189
185
  app.command(name="diff", group=inspect_group)(diff_cmd.diff_cmd)
186
+ app.command(name="duplicates", group=inspect_group)(duplicates_cmd.duplicates)
190
187
  app.command(name="info", group=inspect_group)(info_cmd.info)
191
188
  app.command(name="info-json", group=inspect_group)(info_json_cmd.info_json)
192
189
  list_cmd.list_app.group = (inspect_group,)
@@ -218,8 +215,32 @@ app.command(name="rosdecompress", group=transform_group)(rosdecompress)
218
215
  app.command(name="video", group=transform_group)(video)
219
216
 
220
217
 
218
+ @app.meta.default
219
+ def launcher(
220
+ *tokens: Annotated[str, Parameter(allow_leading_hyphen=True)],
221
+ verbose: Annotated[
222
+ int,
223
+ Parameter(
224
+ name=["--verbose", "-v"],
225
+ count=True,
226
+ help="Increase log verbosity. -v: DEBUG.",
227
+ ),
228
+ ] = 0,
229
+ quiet: Annotated[
230
+ int,
231
+ Parameter(
232
+ name=["--quiet"],
233
+ count=True,
234
+ help="Decrease log verbosity. Once: WARNING; twice: ERROR.",
235
+ ),
236
+ ] = 0,
237
+ ) -> int | None:
238
+ setup_logging(verbose=verbose, quiet=quiet)
239
+ return app(tokens)
240
+
241
+
221
242
  def main() -> None:
222
- app()
243
+ app.meta()
223
244
 
224
245
 
225
246
  if __name__ == "__main__":
@@ -1,9 +1,13 @@
1
1
  """Shared processor pipeline for transform commands."""
2
2
 
3
3
  import contextlib
4
+ import logging
4
5
  from dataclasses import dataclass
5
6
  from pathlib import Path
6
7
  from typing import BinaryIO
8
+ from urllib.parse import urlparse
9
+
10
+ from small_mcap import InvalidMagicError, McapError
7
11
 
8
12
  from pymcap_cli.core.input_handler import open_input
9
13
  from pymcap_cli.core.mcap_processor import (
@@ -15,7 +19,9 @@ from pymcap_cli.core.mcap_processor import (
15
19
  ProcessingOptions,
16
20
  ProcessingStats,
17
21
  )
18
- from pymcap_cli.utils import confirm_output_overwrite
22
+ from pymcap_cli.utils import confirm_output_overwrite, read_info
23
+
24
+ logger = logging.getLogger(__name__)
19
25
 
20
26
 
21
27
  @dataclass(slots=True)
@@ -79,3 +85,62 @@ def run_processor(
79
85
  stats = processor.process(output_stream)
80
86
 
81
87
  return ProcessorResult(stats=stats, processor=processor)
88
+
89
+
90
+ def validate_mcap_output(path: Path) -> bool:
91
+ """Return True iff the MCAP at ``path`` has a readable header and summary."""
92
+ try:
93
+ with path.open("rb") as f:
94
+ read_info(f)
95
+ except (McapError, InvalidMagicError, OSError, AssertionError) as e:
96
+ logger.debug(f"Output validation failed for {path}: {e}")
97
+ return False
98
+ return True
99
+
100
+
101
+ def delete_source_files(sources: list[str], outputs: list[Path]) -> None:
102
+ """Delete each local source file. Skip URLs and any source path that
103
+ resolves to one of ``outputs`` (with a warning).
104
+ """
105
+ output_resolved = {p.resolve() for p in outputs}
106
+ for src in sources:
107
+ scheme = urlparse(src).scheme
108
+ if scheme in ("http", "https"):
109
+ logger.warning(f"Skipping delete: '{src}' is a remote URL")
110
+ continue
111
+ path = Path(src)
112
+ try:
113
+ resolved = path.resolve()
114
+ except OSError as e:
115
+ logger.warning(f"Skipping delete '{src}': {e}")
116
+ continue
117
+ if resolved in output_resolved:
118
+ logger.warning(f"Skipping delete: source '{src}' is also an output")
119
+ continue
120
+ try:
121
+ path.unlink()
122
+ logger.info(f"Deleted source: {src}")
123
+ except FileNotFoundError:
124
+ logger.debug(f"Source already gone: {src}")
125
+ except OSError:
126
+ logger.exception(f"Failed to delete '{src}'")
127
+
128
+
129
+ def finalize_delete_source(
130
+ *,
131
+ sources: list[str],
132
+ outputs: list[Path],
133
+ ) -> int:
134
+ """Validate every output and, if all valid, delete the eligible sources.
135
+
136
+ Returns 0 on success (sources deleted or skipped with warning) and 1 if
137
+ any output failed validation (no sources are deleted in that case).
138
+ """
139
+ invalid = [p for p in outputs if not validate_mcap_output(p)]
140
+ if invalid:
141
+ for p in invalid:
142
+ logger.error(f"[red]Output failed validation: {p}[/red]")
143
+ logger.error("Source file(s) preserved — output not safe to replace source.")
144
+ return 1
145
+ delete_source_files(sources, outputs)
146
+ return 0
@@ -249,7 +249,7 @@ def bag2mcap(
249
249
  """
250
250
  input_path = Path(file)
251
251
  if not input_path.exists():
252
- console.print(f"[red]Error: Input file '{file}' does not exist[/red]")
252
+ logger.error(f"Input file '{file}' does not exist")
253
253
  return 1
254
254
 
255
255
  confirm_output_overwrite(output, force)
@@ -266,20 +266,20 @@ def bag2mcap(
266
266
  compression=writer_compression,
267
267
  )
268
268
 
269
- console.print(f"[blue]Converting '{file}' to '{output}'[/blue]")
269
+ logger.info(f"Converting '{file}' to '{output}'")
270
270
 
271
271
  with output.open("wb") as output_stream:
272
272
  try:
273
273
  stats = convert_bag_to_mcap(input_path, output_stream, options)
274
274
 
275
- console.print("[green]Conversion completed successfully[/green]")
275
+ logger.info("[green]Conversion completed successfully[/green]")
276
276
  console.print(
277
277
  f"Converted {stats.topic_count} topics, "
278
278
  f"{stats.message_count:,} messages, "
279
279
  f"{stats.schema_count} schemas"
280
280
  )
281
- except Exception as e: # noqa: BLE001
282
- console.print(f"[red]Error during conversion: {e}[/red]")
281
+ except Exception:
282
+ logger.exception("Error during conversion")
283
283
  return 1
284
284
 
285
285
  return 0
@@ -2,6 +2,7 @@
2
2
 
3
3
  import base64
4
4
  import json
5
+ import logging
5
6
  import re
6
7
  import sys
7
8
  from contextlib import ExitStack
@@ -26,7 +27,7 @@ from small_mcap import Channel, JSONDecoderFactory, read_message_decoded
26
27
  from pymcap_cli.core.input_handler import open_input
27
28
  from pymcap_cli.utils import MAX_INT64, ProgressTrackingIO, file_progress, parse_timestamp_args
28
29
 
29
- console_err = Console(stderr=True)
30
+ logger = logging.getLogger(__name__)
30
31
  console_out = Console()
31
32
 
32
33
  FILTERING_GROUP = Group("Filtering")
@@ -220,8 +221,8 @@ def cat(
220
221
  if query:
221
222
  try:
222
223
  parsed_query = parse_message_path(query)
223
- except Exception as e: # noqa: BLE001
224
- console_err.print(f"[red]Invalid query syntax: {e}[/red]")
224
+ except Exception:
225
+ logger.exception("Invalid query syntax")
225
226
  return 1
226
227
 
227
228
  # Determine output mode
@@ -256,18 +257,12 @@ def cat(
256
257
  root_msgdef = all_definitions.get(f"{parts[0]}/{parts[-1]}")
257
258
 
258
259
  if root_msgdef is None:
259
- console_err.print(
260
- f"[yellow]Warning: Could not find message definition "
261
- f"for schema '{msg_schema_name}'[/yellow]"
262
- )
260
+ logger.warning(f"Could not find message definition for schema '{msg_schema_name}'")
263
261
  else:
264
262
  parsed_query.validate(root_msgdef, all_definitions) # type: ignore[union-attr]
265
- except ValidationError as e:
266
- console_err.print(f"[red]Query validation error for topic '{topic}':[/red]")
267
- console_err.print(f"[red]{e}[/red]")
268
- console_err.print(
269
- f"\n[yellow]Query:[/yellow] {query}\n[yellow]Schema:[/yellow] {msg_schema_name}"
270
- )
263
+ except ValidationError:
264
+ logger.exception(f"Query validation error for topic '{topic}'")
265
+ logger.exception(f"Query: {query} Schema: {msg_schema_name}")
271
266
  return 1
272
267
  return None
273
268
 
@@ -288,7 +283,7 @@ def cat(
288
283
  with open_input(file) as (input_stream, file_size), ExitStack() as stack:
289
284
  stream: IO[bytes] = input_stream
290
285
  if writing_to_file and file_size:
291
- progress = file_progress("[bold blue]Reading MCAP...", console_err)
286
+ progress = file_progress("[bold blue]Reading MCAP...")
292
287
  progress.start()
293
288
  stack.callback(progress.stop)
294
289
  task = progress.add_task("Processing", total=file_size)
@@ -312,9 +307,9 @@ def cat(
312
307
  validated_topics.add(msg.channel.topic)
313
308
 
314
309
  if msg.schema is None:
315
- console_err.print(
316
- f"[yellow]Warning: Cannot validate query for topic "
317
- f"'{msg.channel.topic}' (no schema available)[/yellow]"
310
+ logger.warning(
311
+ f"Cannot validate query for topic '{msg.channel.topic}' "
312
+ "(no schema available)"
318
313
  )
319
314
  else:
320
315
  err = _validate_query(msg.schema.name, msg.schema.data, msg.channel.topic)
@@ -328,9 +323,7 @@ def cat(
328
323
  if data is None:
329
324
  continue
330
325
  except MessagePathError as e:
331
- console_err.print(
332
- f"[yellow]Filter error on {msg.channel.topic}: {e}[/yellow]",
333
- )
326
+ logger.warning(f"Filter error on {msg.channel.topic}: {e}")
334
327
  continue
335
328
  else:
336
329
  data = msg.decoded_message
@@ -369,22 +362,18 @@ def cat(
369
362
  print(line, file=sys.stdout) # noqa: T201
370
363
 
371
364
  if writing_to_file:
372
- console_err.print(
373
- f"Wrote [bold]{message_count:,}[/bold] messages to [cyan]{output}[/cyan]"
374
- )
365
+ logger.info(f"Wrote {message_count:,} messages to {output}")
375
366
 
376
367
  if parsed_query and not validated_topics:
377
- console_err.print(
378
- f"[red]Error: Topic '{parsed_query.topic}' not found in MCAP file[/red]"
379
- )
368
+ logger.error(f"Topic '{parsed_query.topic}' not found in MCAP file")
380
369
  return 1
381
370
 
382
371
  except KeyboardInterrupt:
383
- console_err.print("\n[yellow]Interrupted by user[/yellow]")
372
+ logger.warning("Interrupted by user")
384
373
  return 0
385
374
 
386
- except Exception as e: # noqa: BLE001
387
- console_err.print(f"[red]Error reading MCAP: {e}[/red]")
375
+ except Exception:
376
+ logger.exception("Error reading MCAP")
388
377
  return 1
389
378
 
390
379
  return 0
@@ -1,8 +1,14 @@
1
1
  """Compress command for pymcap-cli."""
2
2
 
3
+ import logging
4
+
3
5
  from rich.console import Console
4
6
 
5
- from pymcap_cli.cmd._run_processor import resolve_overwrite_policy, run_processor
7
+ from pymcap_cli.cmd._run_processor import (
8
+ finalize_delete_source,
9
+ resolve_overwrite_policy,
10
+ run_processor,
11
+ )
6
12
  from pymcap_cli.core.mcap_processor import (
7
13
  InputOptions,
8
14
  OutputOptions,
@@ -12,11 +18,13 @@ from pymcap_cli.types.types_manual import (
12
18
  DEFAULT_COMPRESSION,
13
19
  ChunkSizeOption,
14
20
  CompressionOption,
21
+ DeleteSourceOption,
15
22
  ForceOverwriteOption,
16
23
  NoClobberOption,
17
24
  OutputPathOption,
18
25
  )
19
26
 
27
+ logger = logging.getLogger(__name__)
20
28
  console = Console()
21
29
 
22
30
 
@@ -28,6 +36,7 @@ def compress(
28
36
  compression: CompressionOption = DEFAULT_COMPRESSION,
29
37
  force: ForceOverwriteOption = False,
30
38
  no_clobber: NoClobberOption = False,
39
+ delete_source: DeleteSourceOption = False,
31
40
  ) -> int:
32
41
  """Create a compressed copy of an MCAP file.
33
42
 
@@ -47,6 +56,9 @@ def compress(
47
56
  Force overwrite of output file without confirmation.
48
57
  no_clobber
49
58
  Fail instead of prompting if the output file already exists.
59
+ delete_source
60
+ Delete source file(s) after the output is validated (header + summary).
61
+ URL inputs and any source whose path equals the output are skipped.
50
62
 
51
63
  Examples
52
64
  --------
@@ -56,10 +68,10 @@ def compress(
56
68
  """
57
69
  overwrite_policy = resolve_overwrite_policy(force=force, no_clobber=no_clobber)
58
70
  if overwrite_policy is None:
59
- console.print("[red]Error: --force and --no-clobber cannot be used together.[/red]")
71
+ logger.error("--force and --no-clobber cannot be used together.")
60
72
  return 1
61
73
 
62
- console.print(f"[blue]Compressing '{file}' to '{output}'[/blue]")
74
+ logger.info(f"Compressing '{file}' to '{output}'")
63
75
 
64
76
  try:
65
77
  result = run_processor(
@@ -74,10 +86,13 @@ def compress(
74
86
  overwrite_policy=overwrite_policy,
75
87
  ),
76
88
  )
77
- console.print("[green]✓ Compression completed successfully![/green]")
89
+ logger.info("[green]✓ Compression completed successfully![/green]")
78
90
  console.print(result.stats)
79
- except Exception as e: # noqa: BLE001
80
- console.print(f"[red]Error during compression: {e}[/red]")
91
+ except Exception:
92
+ logger.exception("Error during compression")
81
93
  return 1
82
94
 
95
+ if delete_source:
96
+ return finalize_delete_source(sources=[file], outputs=[output])
97
+
83
98
  return 0
@@ -522,7 +522,7 @@ def convert(
522
522
  # Validate input file
523
523
  input_path = Path(file)
524
524
  if not input_path.exists():
525
- console.print(f"[red]Error: Input file '{file}' does not exist[/red]")
525
+ logger.error(f"Input file '{file}' does not exist")
526
526
  return 1
527
527
 
528
528
  # Confirm overwrite if needed
@@ -546,17 +546,17 @@ def convert(
546
546
  use_chunking=True,
547
547
  )
548
548
 
549
- console.print(f"[blue]Converting '{file}' to '{output}'[/blue]")
550
- console.print(f"[dim]ROS2 distro: {distro.value}[/dim]")
549
+ logger.info(f"Converting '{file}' to '{output}'")
550
+ logger.info(f"ROS2 distro: {distro.value}")
551
551
  if extra_path:
552
- console.print(f"[dim]Extra paths: {', '.join(str(p) for p in extra_path)}[/dim]")
552
+ logger.info(f"Extra paths: {', '.join(str(p) for p in extra_path)}")
553
553
 
554
554
  # Perform conversion
555
555
  with output.open("wb") as output_stream:
556
556
  try:
557
557
  stats = convert_db3_to_mcap(input_path, output_stream, options)
558
558
 
559
- console.print("[green]✓ Conversion completed successfully![/green]")
559
+ logger.info("[green]✓ Conversion completed successfully![/green]")
560
560
  console.print(
561
561
  f"Converted {stats.topic_count} topics, "
562
562
  f"{stats.message_count:,} messages, "
@@ -565,18 +565,16 @@ def convert(
565
565
 
566
566
  # Report skipped topics if any
567
567
  if stats.skipped_topics:
568
- console.print(
569
- "[yellow]"
570
- f"⚠ Skipped {len(stats.skipped_topics)} topics "
568
+ logger.warning(
569
+ f"Skipped {len(stats.skipped_topics)} topics "
571
570
  f"({stats.skipped_message_count:,} messages) due to missing message definitions"
572
- "[/]"
573
571
  )
574
572
  console.print("[dim]Skipped topics:[/dim]")
575
573
  for topic in sorted(stats.skipped_topics):
576
574
  console.print(f" [dim]- {topic}[/dim]")
577
575
 
578
- except Exception as e: # noqa: BLE001
579
- console.print(f"[red]Error during conversion: {e}[/red]")
576
+ except Exception:
577
+ logger.exception("Error during conversion")
580
578
  return 1
581
579
 
582
580
  return 0
@@ -1,6 +1,7 @@
1
1
  """Diag command - inspect ROS2 diagnostics from MCAP files."""
2
2
 
3
3
  import json
4
+ import logging
4
5
  import re
5
6
  import sys
6
7
  from dataclasses import dataclass, field
@@ -18,9 +19,10 @@ from small_mcap import include_topics, read_message_decoded
18
19
 
19
20
  from pymcap_cli.core.input_handler import open_input
20
21
  from pymcap_cli.display.sparkline import sparkline
22
+ from pymcap_cli.log_setup import ERR
21
23
 
24
+ logger = logging.getLogger(__name__)
22
25
  console = Console()
23
- console_err = Console(stderr=True)
24
26
 
25
27
  LEVEL_NAMES = {0: "OK", 1: "WARN", 2: "ERROR", 3: "STALE"}
26
28
  LEVEL_STYLES = {0: "green", 1: "yellow", 2: "red", 3: "dim"}
@@ -64,7 +66,7 @@ def _collect_diagnostics(file: str, topics: list[str]) -> dict[str, DiagEntry]:
64
66
  TextColumn("[cyan]{task.fields[msgs]} msgs"),
65
67
  TextColumn("[dim]{task.fields[components]} components"),
66
68
  TimeElapsedColumn(),
67
- console=console_err,
69
+ console=ERR,
68
70
  transient=True,
69
71
  ) as progress,
70
72
  ):
@@ -186,7 +188,7 @@ def _compile_pattern(pattern: str, flag_name: str) -> re.Pattern[str]:
186
188
  try:
187
189
  return re.compile(pattern, re.IGNORECASE)
188
190
  except re.error as e:
189
- console_err.print(f"[red]Invalid regex for {flag_name}: {e}[/red]")
191
+ logger.exception(f"Invalid regex for {flag_name}")
190
192
  raise SystemExit(1) from e
191
193
 
192
194
 
@@ -559,16 +561,16 @@ def diag(
559
561
 
560
562
  try:
561
563
  entries = _collect_diagnostics(file, resolved_topics)
562
- except (OSError, ValueError, RuntimeError) as e:
563
- console_err.print(f"[red]Error reading MCAP file: {e}[/red]")
564
+ except (OSError, ValueError, RuntimeError):
565
+ logger.exception("Error reading MCAP file")
564
566
  return 1
565
567
  except KeyboardInterrupt:
566
- console_err.print("\n[yellow]Interrupted by user[/yellow]")
568
+ logger.warning("Interrupted by user")
567
569
  return 0
568
570
 
569
571
  if not entries:
570
572
  topic_str = ", ".join(resolved_topics)
571
- console_err.print(f"[yellow]No diagnostics found on topic(s) '{topic_str}'[/yellow]")
573
+ logger.warning(f"No diagnostics found on topic(s) '{topic_str}'")
572
574
  return 0
573
575
 
574
576
  level_totals: dict[int, int] = {0: 0, 1: 0, 2: 0, 3: 0}
@@ -592,7 +594,7 @@ def diag(
592
594
  if inspect_re:
593
595
  matched = [e for e in filtered if inspect_re.search(e.name)]
594
596
  if not matched:
595
- console_err.print(f"[yellow]No components matching '{inspect}'[/yellow]")
597
+ logger.warning(f"No components matching '{inspect}'")
596
598
  return 0
597
599
 
598
600
  for renderable in _build_inspect_view(matched):