grepros 1.2.2
grep for ROS bag files and live topics
Loading...
Searching...
No Matches
main.py
Go to the documentation of this file.
1# -*- coding: utf-8 -*-
2"""
3Program main interface.
4
5------------------------------------------------------------------------------
6This file is part of grepros - grep for ROS bag files and live topics.
7Released under the BSD License.
8
9@author Erki Suurjaak
10@created 23.10.2021
11@modified 24.03.2024
12------------------------------------------------------------------------------
13"""
14## @namespace grepros.main
15import argparse
16import atexit
17import os
18import random
19import signal
20import sys
21import traceback
22
23from . import __title__, __version__, __version_date__, api, inputs, outputs, search
24from . common import ArgumentUtil, ConsolePrinter, MatchMarkers
25from . import plugins
26
27
28
29## Configuration for argparse, as {description, epilog, args: [..], groups: {name: [..]}}
30ARGUMENTS = {
31 "description": "Searches through messages in ROS bag files or live topics.",
32 "epilog": """
33PATTERNs use Python regular expression syntax, message matches if all match.
34* wildcards use simple globbing as zero or more characters,
35target matches if any value matches.
36
37
38Example usage:
39
40Search for "my text" in all bags under current directory and subdirectories:
41 %(title)s -r "my text"
42
43Print 30 lines of the first message from each live ROS topic:
44 %(title)s --max-per-topic 1 --lines-per-message 30 --live
45
46Find first message containing "future" (case-sensitive) in my.bag:
47 %(title)s future -I --max-count 1 --name my.bag
48
49Find 10 messages, from geometry_msgs package, in "map" frame,
50from bags in current directory, reindexing any unindexed bags:
51 %(title)s frame_id=map --type geometry_msgs/* --max-count 10 --reindex-if-unindexed
52
53Pipe all diagnostics messages with "CPU usage" from live ROS topics to my.bag:
54 %(title)s "CPU usage" --type *DiagnosticArray --no-console-output --write my.bag
55
56Find messages with field "key" containing "0xA002",
57in topics ending with "diagnostics", in bags under "/tmp":
58 %(title)s key=0xA002 --topic *diagnostics --path /tmp
59
60Find diagnostics_msgs messages in bags in current directory,
61containing "navigation" in fields "name" or "message",
62print only header stamp and values:
63 %(title)s --type diagnostic_msgs/* --select-field name message \\
64 --emit-field header.stamp status.values -- navigation
65
66Print first message from each lidar topic on ROS1 host 1.2.3.4, without highlight:
67 ROS_MASTER_URI=http://1.2.3.4::11311 \\
68 %(title)s --live --topic *lidar* --max-per-topic 1 --no-highlight
69
70Export all bag messages to SQLite and Postgres, print only export progress:
71 %(title)s -n my.bag --write my.bag.sqlite --no-console-output --no-verbose --progress
72
73 %(title)s -n my.bag --write postgresql://user@host/dbname \\
74 --no-console-output --no-verbose --progress
75 """ % dict(title=__title__),
76
77 "arguments": [
78 dict(args=["PATTERN"], nargs="*", default=[],
79 help="pattern(s) to find in message field values,\n"
80 "all messages match if not given,\n"
81 "can specify message field as NAME=PATTERN\n"
82 "(supports nested.paths and * wildcards)"),
83
84 dict(args=["-h", "--help"],
85 dest="HELP", action="store_true",
86 help="show this help message and exit"),
87
88 dict(args=["-F", "--fixed-strings"],
89 dest="FIXED_STRING", action="store_true",
90 help="PATTERNs are ordinary strings, not regular expressions"),
91
92 dict(args=["-I", "--no-ignore-case"],
93 dest="CASE", action="store_true",
94 help="use case-sensitive matching in PATTERNs"),
95
96 dict(args=["-v", "--invert-match"],
97 dest="INVERT", action="store_true",
98 help="select messages not matching PATTERNs"),
99
100 dict(args=["-e", "--expression"],
101 dest="EXPRESSION", action="store_true",
102 help="PATTERNs are a logical expression\n"
103 "like 'this AND (this2 OR NOT \"skip this\")',\n"
104 "with elements as patterns to find in message fields"),
105
106 dict(args=["--version"],
107 dest="VERSION", action="version",
108 version="%s: grep for ROS bag files and live topics, v%s (%s)" %
109 (__title__, __version__, __version_date__),
110 help="display version information and exit"),
111
112 dict(args=["--live"],
113 dest="LIVE", action="store_true",
114 help="read messages from live ROS topics instead of bagfiles"),
115
116 dict(args=["--publish"],
117 dest="PUBLISH", action="store_true",
118 help="publish matched messages to live ROS topics"),
119
120 dict(args=["--write"],
121 dest="WRITE", nargs="+", default=[], action="append",
122 metavar="TARGET [format=bag] [KEY=VALUE ...]",
123 help="write matched messages to specified output,\n"
124 "format is autodetected from TARGET if not specified.\n"
125 "Bag or database will be appended to if it already exists.\n"
126 "Keyword arguments are given to output writer."),
127
128 dict(args=["--write-options"], # Will be populated from --write by MultiSink
129 dest="WRITE_OPTIONS", default=argparse.SUPPRESS, help=argparse.SUPPRESS),
130
131 dict(args=["--plugin"],
132 dest="PLUGIN", nargs="+", default=[], action="append",
133 help="load a Python module or class as plugin"),
134
135 dict(args=["--stop-on-error"],
136 dest="STOP_ON_ERROR", action="store_true",
137 help="stop further execution on any error like unknown message type"),
138 ],
139
140 "groups": {"Filtering": [
141
142 dict(args=["-t", "--topic"],
143 dest="TOPIC", nargs="+", default=[], action="append",
144 help="ROS topics to read if not all (supports * wildcards)"),
145
146 dict(args=["-nt", "--no-topic"],
147 dest="SKIP_TOPIC", metavar="TOPIC", nargs="+", default=[], action="append",
148 help="ROS topics to skip (supports * wildcards)"),
149
150 dict(args=["-d", "--type"],
151 dest="TYPE", nargs="+", default=[], action="append",
152 help="ROS message types to read if not all (supports * wildcards)"),
153
154 dict(args=["-nd", "--no-type"],
155 dest="SKIP_TYPE", metavar="TYPE", nargs="+", default=[], action="append",
156 help="ROS message types to skip (supports * wildcards)"),
157
158 dict(args=["--condition"],
159 dest="CONDITION", nargs="+", default=[], action="append",
160 help="extra conditions to require for matching messages,\n"
161 "as ordinary Python expressions, can refer to last messages\n"
162 "in topics as <topic /my/topic>; topic name can contain wildcards.\n"
163 'E.g. --condition "<topic /robot/enabled>.data" matches\n'
164 "messages only while last message in '/robot/enabled' has data=true."),
165
166 dict(args=["-t0", "--start-time"],
167 dest="START_TIME", metavar="TIME",
168 help="earliest timestamp of messages to read\n"
169 "as relative seconds if signed,\n"
170 "or epoch timestamp or ISO datetime\n"
171 "(for bag input, relative to bag start time\n"
172 "if positive or end time if negative,\n"
173 "for live input relative to system time,\n"
174 "datetime may be partial like 2021-10-14T12)"),
175
176 dict(args=["-t1", "--end-time"],
177 dest="END_TIME", metavar="TIME",
178 help="latest timestamp of messages to read\n"
179 "as relative seconds if signed,\n"
180 "or epoch timestamp or ISO datetime\n"
181 "(for bag input, relative to bag start time\n"
182 "if positive or end time if negative,\n"
183 "for live input relative to system time,\n"
184 "datetime may be partial like 2021-10-14T12)"),
185
186 dict(args=["-n0", "--start-index"],
187 dest="START_INDEX", metavar="INDEX", type=int,
188 help="message index within topic to start from\n"
189 "(1-based if positive, counts back from bag total if negative)"),
190
191 dict(args=["-n1", "--end-index"],
192 dest="END_INDEX", metavar="INDEX", type=int,
193 help="message index within topic to stop at\n"
194 "(1-based if positive, counts back from bag total if negative)"),
195
196 dict(args=["--every-nth-message"],
197 dest="NTH_MESSAGE", metavar="NUM", type=int, default=1,
198 help="read every Nth message within topic, starting from first"),
199
200 dict(args=["--every-nth-interval"],
201 dest="NTH_INTERVAL", metavar="SECONDS", type=float, default=0,
202 help="read messages at least N seconds apart within topic"),
203
204 dict(args=["--every-nth-match"],
205 dest="NTH_MATCH", metavar="NUM", type=int, default=1,
206 help="emit every Nth match in topic, starting from first"),
207
208 dict(args=["-sf", "--select-field"],
209 dest="SELECT_FIELD", metavar="FIELD", nargs="+", default=[], action="append",
210 help="message fields to use in matching if not all\n"
211 "(supports nested.paths and * wildcards)"),
212
213 dict(args=["-ns", "--no-select-field"],
214 dest="NOSELECT_FIELD", metavar="FIELD", nargs="+", default=[], action="append",
215 help="message fields to skip in matching\n"
216 "(supports nested.paths and * wildcards)"),
217
218 dict(args=["-m", "--max-count"],
219 dest="MAX_COUNT", metavar="NUM", default=0, type=int,
220 help="number of matched messages to emit (per each file if bag input)"),
221
222 dict(args=["--max-per-topic"],
223 dest="MAX_PER_TOPIC", metavar="NUM", default=0, type=int,
224 help="number of matched messages to emit from each topic\n"
225 "(per each file if bag input)"),
226
227 dict(args=["--max-topics"],
228 dest="MAX_TOPICS", metavar="NUM", default=0, type=int,
229 help="number of topics to emit matches from (per each file if bag input)"),
230
231 dict(args=["--unique-only"],
232 dest="UNIQUE", action="store_true",
233 help="only emit matches that are unique in topic,\n"
234 "taking --select-field and --no-select-field into account\n"
235 "(per each file if bag input)"),
236
237 ], "Output control": [
238
239 dict(args=["-B", "--before-context"],
240 dest="BEFORE", metavar="NUM", default=0, type=int,
241 help="emit NUM messages of leading context before match"),
242
243 dict(args=["-A", "--after-context"],
244 dest="AFTER", metavar="NUM", default=0, type=int,
245 help="emit NUM messages of trailing context after match"),
246
247 dict(args=["-C", "--context"],
248 dest="CONTEXT", metavar="NUM", default=0, type=int,
249 help="emit NUM messages of leading and trailing context\n"
250 "around match"),
251
252 dict(args=["-ef", "--emit-field"],
253 dest="EMIT_FIELD", metavar="FIELD", nargs="+", default=[], action="append",
254 help="message fields to emit in console output if not all\n"
255 "(supports nested.paths and * wildcards)"),
256
257 dict(args=["-nf", "--no-emit-field"],
258 dest="NOEMIT_FIELD", metavar="FIELD", nargs="+", default=[], action="append",
259 help="message fields to skip in console output\n"
260 "(supports nested.paths and * wildcards)"),
261
262 dict(args=["-mo", "--matched-fields-only"],
263 dest="MATCHED_FIELDS_ONLY", action="store_true",
264 help="emit only the fields where PATTERNs find a match in console output"),
265
266 dict(args=["-la", "--lines-around-match"],
267 dest="LINES_AROUND_MATCH", metavar="NUM", type=int,
268 help="emit only matched fields and NUM message lines\n"
269 "around match in console output"),
270
271 dict(args=["-lf", "--lines-per-field"],
272 dest="MAX_FIELD_LINES", metavar="NUM", type=int,
273 help="maximum number of lines to emit per field in console output"),
274
275 dict(args=["-l0", "--start-line"],
276 dest="START_LINE", metavar="NUM", type=int,
277 help="message line number to start emitting from in console output\n"
278 "(1-based if positive, counts back from total if negative)"),
279
280 dict(args=["-l1", "--end-line"],
281 dest="END_LINE", metavar="NUM", type=int,
282 help="message line number to stop emitting at in console output\n"
283 "(1-based if positive, counts back from total if negative)"),
284
285 dict(args=["-lm", "--lines-per-message"],
286 dest="MAX_MESSAGE_LINES", metavar="NUM", type=int,
287 help="maximum number of lines to emit per message in console output"),
288
289 dict(args=["--match-wrapper"],
290 dest="MATCH_WRAPPER", metavar="STR", nargs="*",
291 help="string to wrap around matched values in console output,\n"
292 "both sides if one value, start and end if more than one,\n"
293 "or no wrapping if zero values\n"
294 '(default "**" in colorless output)'),
295
296 dict(args=["--wrap-width"],
297 dest="WRAP_WIDTH", metavar="NUM", type=int,
298 help="character width to wrap message YAML console output at,\n"
299 "0 disables (defaults to detected terminal width)"),
300
301 dict(args=["--color"], dest="COLOR",
302 choices=["auto", "always", "never"], default="always",
303 help='use color output in console (default "always")'),
304
305 dict(args=["--no-meta"], dest="META", action="store_false",
306 help="do not print source and message metainfo to console"),
307
308 dict(args=["--no-filename"], dest="LINE_PREFIX", action="store_false",
309 help="do not print bag filename prefix on each console message line"),
310
311 dict(args=["--no-highlight"], dest="HIGHLIGHT", action="store_false",
312 help="do not highlight matched values"),
313
314 dict(args=["--no-console-output"], dest="CONSOLE", action="store_false",
315 help="do not print matches to console"),
316
317 dict(args=["--progress"], dest="PROGRESS", action="store_true",
318 help="show progress bar when not printing matches to console"),
319
320 dict(args=["--verbose"], dest="VERBOSE", action="store_true",
321 help="print status messages during console output\n"
322 "for publishing and writing, and error stacktraces"),
323
324 dict(args=["--no-verbose"], dest="SKIP_VERBOSE", action="store_true",
325 help="do not print status messages during console output\n"
326 "for publishing and writing"),
327
328 ], "Bag input control": [
329
330 dict(args=["-n", "--filename"],
331 dest="FILE", nargs="+", default=[], action="append",
332 help="names of ROS bagfiles to read if not all in directory\n"
333 "(supports * wildcards)"),
334
335 dict(args=["-p", "--path"],
336 dest="PATH", nargs="+", default=[], action="append",
337 help="paths to scan if not current directory\n"
338 "(supports * wildcards)"),
339
340 dict(args=["-r", "--recursive"],
341 dest="RECURSE", action="store_true",
342 help="recurse into subdirectories when looking for bagfiles"),
343
344 dict(args=["--order-bag-by"],
345 dest="ORDERBY", choices=["topic", "type"],
346 help="order bag messages by topic or type first and then by time"),
347
348 dict(args=["--decompress"],
349 dest="DECOMPRESS", action="store_true",
350 help="decompress archived bagfiles with recognized extensions (.zst .zstd)"),
351
352 dict(args=["--reindex-if-unindexed"],
353 dest="REINDEX", action="store_true",
354 help="reindex unindexed bagfiles (ROS1 only), makes backup copies"),
355
356 dict(args=["--time-scale"],
357 dest="TIMESCALE", metavar="FACTOR", nargs="?", type=float, const=1, default=0,
358 help="emit messages on original bag timeline from first matched message,\n"
359 "optionally with a speedup or slowdown factor"),
360
361 dict(args=["--time-scale-emission"],
362 dest="TIMESCALE_EMISSION", nargs="?", type=int, const=True, default=True,
363 help=argparse.SUPPRESS), # Timeline from first matched message vs first in bag
364
365 ], "Live topic control": [
366
367 dict(args=["--publish-prefix"],
368 dest="PUBLISH_PREFIX", metavar="PREFIX", default="",
369 help="prefix to prepend to input topic name on publishing match"),
370
371 dict(args=["--publish-suffix"],
372 dest="PUBLISH_SUFFIX", metavar="SUFFIX", default="",
373 help="suffix to append to input topic name on publishing match"),
374
375 dict(args=["--publish-fixname"],
376 dest="PUBLISH_FIXNAME", metavar="TOPIC", default="",
377 help="single output topic name to publish all matches to,\n"
378 "overrides prefix and suffix"),
379
380 dict(args=["--queue-size-in"],
381 dest="QUEUE_SIZE_IN", metavar="SIZE", type=int, default=10,
382 help="live ROS topic subscriber queue size (default 10)"),
383
384 dict(args=["--queue-size-out"],
385 dest="QUEUE_SIZE_OUT", metavar="SIZE", type=int, default=10,
386 help="output publisher queue size (default 10)"),
387
388 dict(args=["--ros-time-in"],
389 dest="ROS_TIME_IN", action="store_true",
390 help="use ROS time instead of system time for incoming message\n"
391 "timestamps from subsribed live ROS topics"),
392
393 ]},
394}
395
396
397CLI_ARGS = None
398
399
401 """Writes a linefeed to sdtout if nothing has been printed to it so far."""
402 if not ConsolePrinter.PRINTS.get(sys.stdout) and not sys.stdout.isatty():
403 try: print() # Piping cursed output to `more` remains paging if nothing is printed
404 except (Exception, KeyboardInterrupt): pass
405
406
407def preload_plugins(cli_args):
408 """Imports and initializes plugins from auto-load folder and from arguments."""
409 plugins.add_write_format("bag", outputs.BagSink, "bag", [
410 ("overwrite=true|false", "overwrite existing file\nin bag output\n"
411 "instead of appending to if bag or database\n"
412 "or appending unique counter to file name\n"
413 "(default false)")
414
415 ] + outputs.RolloverSinkMixin.get_write_options("bag"))
416 args = None
417 if "--plugin" in cli_args:
418 args, _ = ArgumentUtil.make_parser(ARGUMENTS).parse_known_args(cli_args)
419 args = ArgumentUtil.flatten(args)
420 try: plugins.init(args)
421 except ImportWarning: sys.exit(1)
422
423
424def make_thread_excepthook(args, exitcode_dict):
425 """Returns thread exception handler: function(text, exc) prints error, stops application."""
426 def thread_excepthook(text, exc):
427 """Prints error, sets exitcode flag, shuts down ROS node if any, interrupts main thread."""
428 ConsolePrinter.error(text)
429 if args.VERBOSE: traceback.print_exc()
430 exitcode_dict["value"] = 1
431 api.shutdown_node()
432 os.kill(os.getpid(), signal.SIGINT)
433 return thread_excepthook
434
435
436def run():
437 """Parses command-line arguments and runs search."""
438 global CLI_ARGS
439 CLI_ARGS = sys.argv[1:]
440 MatchMarkers.populate("%08x" % random.randint(1, 1E9))
441 preload_plugins(CLI_ARGS)
442 argparser = ArgumentUtil.make_parser(ARGUMENTS)
443 if not CLI_ARGS:
444 argparser.print_usage()
445 return
446
447 atexit.register(flush_stdout)
448 args = argparser.parse_args(CLI_ARGS)
449 if args.HELP:
450 argparser.print_help()
451 return
452
453 BREAK_EXS = (KeyboardInterrupt, )
454 try: BREAK_EXS += (BrokenPipeError, ) # Py3
455 except NameError: pass # Py2
456
457 exitcode = {"value": 0}
458 source, sink = None, None
459 try:
460 ConsolePrinter.configure({"always": True, "never": False}.get(args.COLOR))
461 api.validate()
462 args = ArgumentUtil.validate(args, cli=True)
463
464 source = plugins.load("source", args) or \
465 (inputs.LiveSource if args.LIVE else inputs.BagSource)(args)
466 if not source.validate():
467 sys.exit(1)
468 sink = outputs.MultiSink(args)
469 sink.sinks.extend(filter(bool, plugins.load("sink", args, collect=True)))
470 if not sink.validate():
471 sys.exit(1)
472
473 source.thread_excepthook = sink.thread_excepthook = make_thread_excepthook(args, exitcode)
474 grepper = plugins.load("scan", args) or search.Scanner(args)
475 grepper.work(source, sink)
476 except BREAK_EXS:
477 try: sink and sink.close()
478 except (Exception, KeyboardInterrupt): pass
479 try: source and source.close()
480 except (Exception, KeyboardInterrupt): pass
481 # Redirect remaining output to devnull to avoid another BrokenPipeError
482 try: os.dup2(os.open(os.devnull, os.O_WRONLY), sys.stdout.fileno())
483 except (Exception, KeyboardInterrupt): pass
484 sys.exit(exitcode["value"])
485 except Exception as e:
486 ConsolePrinter.error(e)
487 if args.VERBOSE: traceback.print_exc()
488 finally:
489 sink and sink.close()
490 source and source.close()
491 try: api.shutdown_node()
492 except BREAK_EXS: pass
493
494
495__all__ = [
496 "ARGUMENTS", "CLI_ARGS", "flush_stdout", "make_thread_excepthook", "preload_plugins", "run",
497]
498
499
500
501if "__main__" == __name__:
502 run()
Produces messages from ROS bagfiles.
Definition inputs.py:522
Produces messages from live ROS topics.
Definition inputs.py:1005
Writes messages to bagfile.
Definition outputs.py:636
Combines any number of sinks.
Definition outputs.py:918
ROS message grepper.
Definition search.py:36
make_thread_excepthook(args, exitcode_dict)
Returns thread exception handler: function(text, exc) prints error, stops application.
Definition main.py:424
preload_plugins(cli_args)
Imports and initializes plugins from auto-load folder and from arguments.
Definition main.py:407
flush_stdout()
Writes a linefeed to sdtout if nothing has been printed to it so far.
Definition main.py:400
run()
Parses command-line arguments and runs search.
Definition main.py:436