Skip to content

Commit 48b28ae

Browse files
authored
CLI: Get storage-specific values from plugin (ros2#1209)
* Get storage-specific values from plugin-registered modules, instead of hardcoding in the CLI Signed-off-by: Emerson Knapp <[email protected]>
1 parent 88bfc1e commit 48b28ae

File tree

17 files changed

+390
-132
lines changed

17 files changed

+390
-132
lines changed

.github/workflows/lint.yml

+2
Original file line numberDiff line numberDiff line change
@@ -102,3 +102,5 @@ jobs:
102102
package-name: |
103103
ros2bag
104104
rosbag2_py
105+
rosbag2_storage_sqlite3
106+
rosbag2_storage_mcap

.github/workflows/test.yml

+1-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ jobs:
1313
build_and_test:
1414
runs-on: ubuntu-latest
1515
container:
16-
image: rostooling/setup-ros-docker:ubuntu-focal-latest
16+
image: rostooling/setup-ros-docker:ubuntu-jammy-latest
1717
steps:
1818
- name: Build and run tests
1919
id: action-ros-ci

README.md

+2
Original file line numberDiff line numberDiff line change
@@ -332,6 +332,8 @@ $ ros2 bag record --storage <storage_id>
332332

333333
Bag reading commands can detect the storage plugin automatically, but if for any reason you want to force a specific plugin to read a bag, you can use the `--storage` option on any `ros2 bag` verb.
334334

335+
To write your own Rosbag2 storage implementation, refer to [this document describing that process](docs/storage_plugin_development.md)
336+
335337

336338
## Serialization format plugin architecture
337339

docs/storage_plugin_development.md

+115-24
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,146 @@
1-
# Writing storage plugins
1+
# Writing storage plugins for Rosbag2
22

3-
There are different interfaces for storage plugins depending on your need: The general `ReadWriteStorage` and the more specific `ReadableStorage`.
3+
Storage plugins provide the actual underlying storage format for Rosbag2.
44

5-
## Writing a general plugin
5+
Plugins can implement the following APIs to provide a storage plugin, specified in `rosbag2_storage::storage_interfaces`:
6+
* `ReadOnlyInterface` which covers only reading files
7+
* `ReadWriteInterface` can both write new files and read existing ones. It is a superset of ReadOnly
68

7-
Assume you write a plugin `MyStorage` which can both save messages and read messages.
8-
Its header file could be `my_storage.hpp` and `MyStorage` will derive from `rosbag2_storage::storage_interfaces::ReadWriteInterface`.
9-
**Important:** While implementing the interface provided by `rosbag2_storage::storage_interfaces::ReadWriteInterface`, make sure that all resources such as file handles or database connections are closed or destroyed in the destructor, no additional `close` call should be necessary.
9+
## Creating a ReadWrite plugin
10+
11+
Goal: Create a plugin named `my_storage`, in package `rosbag2_storage_my_storage`, implemented by class `my_namespace::MyStorage`.
12+
13+
The following code snippets shows the necessary pieces to provide this plugin.
1014

11-
In order to find the plugin at runtime, it needs to be exported to the pluginlib.
12-
Add the following lines to `my_storage.cpp`:
1315

1416
```
17+
// my_storage.cpp
18+
#include "rosbag2_storage/storage_interfaces/read_write_interface.hpp"
19+
20+
namespace my_namespace {
21+
22+
class MyStorage : public rosbag2_storage::storage_interfaces::ReadWriteInterface
23+
{
24+
public:
25+
MyStorage();
26+
~MyStorage() override; // IMPORTANT: All cleanup must happen in the destructor, such as closing file handles or database connections
27+
28+
// ReadWriteInterface's virtual overrides here
29+
};
30+
31+
// Implementations
32+
33+
} // namespace my_namespace
34+
35+
// The following block exposes our class to pluginlib so that it can be discovered at runtime.
1536
#include "pluginlib/class_list_macros.hpp"
16-
PLUGINLIB_EXPORT_CLASS(MyStorage, rosbag2_storage::storage_interfaces::ReadWriteInterface)
37+
PLUGINLIB_EXPORT_CLASS(my_namespace::MyStorage,
38+
rosbag2_storage::storage_interfaces::ReadWriteInterface)
1739
```
1840

19-
Furthermore, we need some meta-information in the form of a `plugin_description.xml` file.
41+
Next, our package must provide a file named `plugin_description.xml`.
2042
Here, it contains
2143

2244
```
23-
<library path="my_storage_lib">
24-
<class name="my_storage" type="MyStorage" base_class_type="rosbag2_storage::storage_interfaces::ReadWriteInterface">
45+
<library path="rosbag2_storage_my_storage">
46+
<class
47+
name="my_storage"
48+
type="MyStorage"
49+
base_class_type="rosbag2_storage::storage_interfaces::ReadWriteInterface"
50+
>
51+
<description>Rosbag2 storage plugin providing the MyStorage file format.</description>
2552
</class>
2653
</library>
2754
```
28-
`my_storage_lib` is the name of the library (ament package) while `my_storage` is an identifier used by the pluginlib to load it.
2955

30-
In addition, in the `CMakeLists.txt` the `plugin_description.xml` file needs to be added to the index to be found at runtime:
56+
`rosbag2_storage_my_storage` is the name of the library from `package.xml` while `my_storage` is an identifier used by the pluginlib to refer to the plugin.
3157

32-
`pluginlib_export_plugin_description_file(rosbag2_storage plugin_description.xml)`
58+
Finally, the `CMakeLists.txt` must add our `plugin_description.xml` file to the ament index to be found at runtime:
3359

34-
The first argument `rosbag2_storage` denotes the library we add our plugin to (this will always be `rosbag2_storage`), while the second argument is the path to the plugin description file.
60+
```
61+
pluginlib_export_plugin_description_file(rosbag2_storage plugin_description.xml)
62+
```
3563

36-
## Writing a plugin for reading only
64+
The first argument `rosbag2_storage` denotes the library we add our plugin to (this will always be `rosbag2_storage` for this plugin type), while the second argument is the path to the plugin description file.
3765

38-
When writing plugins to only provide functionality for reading, derive from `rosbag2_storage::storage_interfaces::ReadOnlyInterface`.
66+
## Creating a ReadOnly plugin
3967

40-
If the read-only plugin is called `my_readonly_storage` in a library `my_storage_lib`, it will be registered using
68+
When writing plugins to only provide functionality for reading, derive your implementation class from `rosbag2_storage::storage_interfaces::ReadOnlyInterface` instead.
69+
This is the only functional difference, it will require only a subset of the interface overrides.
4170

4271
```
72+
// my_readonly_storage.cpp
73+
#include "rosbag2_storage/storage_interfaces/read_only_interface.hpp"
74+
75+
namespace my_namespace {
76+
77+
class MyReadOnlyStorage : public rosbag2_storage::storage_interfaces::ReadOnlyInterface
78+
{
79+
public:
80+
MyReadOnlyStorage();
81+
~MyReadOnlyStorage() override; // IMPORTANT: All cleanup must happen in the destructor, such as closing file handles or database connections
82+
83+
// ReadOnlyInterface's virtual overrides here
84+
};
85+
86+
// Implementations
87+
88+
} // namespace my_namespace
89+
90+
// The following block exposes our class to pluginlib so that it can be discovered at runtime.
4391
#include "pluginlib/class_list_macros.hpp"
44-
PLUGINLIB_EXPORT_CLASS(MyReadonlyStorage, rosbag2_storage::storage_interfaces::ReadOnlyInterface)
92+
PLUGINLIB_EXPORT_CLASS(my_namespace::MyReadOnlyStorage,
93+
rosbag2_storage::storage_interfaces::ReadOnlynterface)
4594
```
46-
with the plugin description
95+
4796
```
48-
<library path="my_storage_lib">
49-
<class name="my_readonly_storage" type="MyReadonlyStorage" base_class_type="rosbag2_storage::storage_interfaces::ReadOnlyInterface">
97+
<!-- plugin_description.xml -->
98+
<library path="rosbag2_storage_my_storage">
99+
<class
100+
name="my_readonly_storage"
101+
type="my_namespace::MyReadOnlyStorage"
102+
base_class_type="rosbag2_storage::storage_interfaces::ReadOnlyInterface"
103+
>
104+
<description>Rosbag2 storage plugin providing read functionality for MyStorage file format.</description>
50105
</class>
51106
</library>
52107
```
108+
53109
and the usual pluginlib export in the CMakeLists:
54110

55-
`pluginlib_export_plugin_description_file(rosbag2_storage plugin_description.xml)`
111+
```
112+
# CMakeLists.txt
113+
pluginlib_export_plugin_description_file(rosbag2_storage plugin_description.xml)
114+
```
115+
116+
## Providing plugin-specific configuration
117+
118+
Some storage plugins may have configuration parameters unique to the format that you'd like to allow users to provide from the command line.
119+
Rosbag2 provides a CLI argument `--storage-config-file` which allows users to pass the path to a file.
120+
This file can contain anything, its format is specified by the storage implementation, it is passed as a path all the way to the plugin, where it may be used however desired.
121+
Plugins are recommended to document the expected format of this file so that users can write well-formatted configurations.
122+
123+
### Extending CLI from a storage plugin
124+
125+
Commandline arguments can be a much more convenient way to expose configuration to users than writing out a file.
126+
The `ros2bag` package, which creates the `ros2 bag` command, provides an entrypoint for plugins to extend the CLI.
127+
128+
All a package needs to do is expose a Python setuptools entrypoint to the group `ros2bag.storage_plugin_cli_extension`, with an entrypoint keyed by the name of the storage plugin. For example, here is `setup.cfg` from `rosbag2_storage_mcap`:
129+
130+
```
131+
[options.entry_points]
132+
ros2bag.storage_plugin_cli_extension =
133+
mcap = ros2bag_mcap_cli
134+
```
135+
136+
This registers an entrypoint in group `ros2bag.storage_plugin_cli_extension`, for the plugin named `mcap`, that is implemented by a Python module called `rosbag2_mcap_cli`.
137+
138+
The exposed entrypoint can be installed as a Python module by any method, for example via `ament_cmake_python`'s `ament_python_install_package` macro, or by having a pure-python `ament_python` package with a `setup.py`.
139+
140+
The functions this entry point may provide:
141+
142+
* `get_preset_profiles(): List[Tuple[str, str]]` - provide a list of string pairs containing (name, description) of _preset profiles_, or predefined configurations, for writing storage files. The first item will be used as default. Consider returning 'none' as the first profile.
143+
144+
NOTE: For each of these lists, the string literal 'none' will be used to indicate the feature is disable/not used.
145+
146+
NOTE: Any entry point may exclude any of the extension functions, and a warning will be printed for each extention point omitted. When the function for a list of values is not provided, or returns `None`, by default `'none'` will be provided as the only option.

ros2bag/ros2bag/api/__init__.py

+72-1
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,12 @@
1212
# See the License for the specific language governing permissions and
1313
# limitations under the License.
1414

15-
from argparse import ArgumentParser, ArgumentTypeError
15+
from argparse import (
16+
ArgumentParser,
17+
ArgumentTypeError,
18+
FileType,
19+
HelpFormatter,
20+
)
1621
import os
1722
from typing import Any
1823
from typing import Dict
@@ -24,6 +29,7 @@
2429
from rclpy.qos import QoSLivelinessPolicy
2530
from rclpy.qos import QoSProfile
2631
from rclpy.qos import QoSReliabilityPolicy
32+
from ros2cli.entry_points import get_entry_points
2733
import rosbag2_py
2834

2935
# This map needs to be updated when new policies are introduced
@@ -37,6 +43,17 @@
3743
_VALUE_KEYS = ['depth', 'avoid_ros_namespace_conventions']
3844

3945

46+
class SplitLineFormatter(HelpFormatter):
47+
"""Extend argparse HelpFormatter to allow for explicit newlines in help string."""
48+
49+
def _split_lines(self, text, width):
50+
lines = text.splitlines()
51+
result_lines = []
52+
for line in lines:
53+
result_lines.extend(HelpFormatter._split_lines(self, line, width))
54+
return result_lines
55+
56+
4057
def print_error(string: str) -> str:
4158
return '[ERROR] [ros2bag]: {}'.format(string)
4259

@@ -129,3 +146,57 @@ def add_standard_reader_args(parser: ArgumentParser) -> None:
129146
'-s', '--storage', default='', choices=reader_choices,
130147
help='Storage implementation of bag. '
131148
'By default attempts to detect automatically - use this argument to override.')
149+
150+
151+
def _parse_cli_storage_plugin():
152+
plugin_choices = set(rosbag2_py.get_registered_writers())
153+
default_storage = rosbag2_py.get_default_storage_id()
154+
if default_storage not in plugin_choices:
155+
default_storage = next(iter(plugin_choices))
156+
157+
storage_parser = ArgumentParser(add_help=False)
158+
storage_parser.add_argument(
159+
'-s', '--storage',
160+
default=default_storage,
161+
choices=plugin_choices,
162+
help='Storage implementation of bag. '
163+
'By default attempts to detect automatically - use this argument to override.')
164+
storage_parsed_args, _ = storage_parser.parse_known_args()
165+
plugin_id = storage_parsed_args.storage
166+
167+
if plugin_id not in plugin_choices:
168+
raise ValueError(f'No storage plugin found with ID "{plugin_id}". Found {plugin_choices}.')
169+
return plugin_id
170+
171+
172+
def add_writer_storage_plugin_extensions(parser: ArgumentParser) -> None:
173+
174+
plugin_id = _parse_cli_storage_plugin()
175+
try:
176+
extension = get_entry_points('ros2bag.storage_plugin_cli_extension')[plugin_id].load()
177+
except KeyError:
178+
print(f'No CLI extension module found for plugin name {plugin_id} '
179+
'in entry_point group "ros2bag.storage_plugin_cli_extension".')
180+
# Commandline arguments should still be added when no extension present
181+
# None will throw AttributeError for all method calls
182+
extension = None
183+
184+
parser.add_argument(
185+
'--storage-config-file', type=FileType('r'),
186+
help='Path to a yaml file defining storage specific configurations. '
187+
f'See {plugin_id} plugin documentation for the format of this file.')
188+
189+
try:
190+
preset_profiles = extension.get_preset_profiles() or \
191+
[('none', 'Default writer configuration.')]
192+
except AttributeError:
193+
print(f'Storage plugin {plugin_id} does not provide function "get_preset_profiles".')
194+
preset_profiles = ['none']
195+
default_preset_profile = preset_profiles[0][0]
196+
parser.add_argument(
197+
'--storage-preset-profile', type=str, default=default_preset_profile,
198+
choices=[preset[0] for preset in preset_profiles],
199+
help=f'R|Select a preset configuration for storage plugin "{plugin_id}". '
200+
'Settings in this profile can still be overriden by other explicit options '
201+
'and --storage-config-file. Profiles:\n' +
202+
'\n'.join([f'{preset[0]}: {preset[1]}' for preset in preset_profiles]))

ros2bag/ros2bag/verb/burst.py

+1-5
Original file line numberDiff line numberDiff line change
@@ -53,11 +53,7 @@ def add_arguments(self, parser, cli_name): # noqa: D102
5353
parser.add_argument(
5454
'--storage-config-file', type=FileType('r'),
5555
help='Path to a yaml file defining storage specific configurations. '
56-
'For the default storage plugin settings are specified through syntax:'
57-
'read:'
58-
' pragmas: [\"<setting_name>\" = <setting_value>]'
59-
'Note that applicable settings are limited to read-only for ros2 bag play.'
60-
'For a list of sqlite3 settings, refer to sqlite3 documentation')
56+
'See storage plugin documentation for the format of this file.')
6157
parser.add_argument(
6258
'--start-offset', type=check_positive_float, default=0.0,
6359
help='Start the playback player this many seconds into the bag file.')

ros2bag/ros2bag/verb/play.py

+14-18
Original file line numberDiff line numberDiff line change
@@ -74,11 +74,7 @@ def add_arguments(self, parser, cli_name): # noqa: D102
7474
parser.add_argument(
7575
'--storage-config-file', type=FileType('r'),
7676
help='Path to a yaml file defining storage specific configurations. '
77-
'For the default storage plugin settings are specified through syntax:'
78-
'read:'
79-
' pragmas: [\"<setting_name>\" = <setting_value>]'
80-
'Note that applicable settings are limited to read-only for ros2 bag play.'
81-
'For a list of sqlite3 settings, refer to sqlite3 documentation')
77+
'See storage plugin documentation for the format of this file.')
8278
clock_args_group = parser.add_mutually_exclusive_group()
8379
clock_args_group.add_argument(
8480
'--clock', type=positive_float, nargs='?', const=40, default=0,
@@ -99,29 +95,29 @@ def add_arguments(self, parser, cli_name): # noqa: D102
9995
parser.add_argument(
10096
'--playback-duration', type=float, default=-1.0,
10197
help='Playback duration, in seconds. Negative durations mark an infinite playback. '
102-
'Default is -1.0. When positive, the maximum of `playback-until-*` and the one '
103-
'that this attribute yields will be used to determine which one stops playback '
104-
'execution.')
98+
'Default is %(default)d. '
99+
'When positive, the maximum effective time between `playback-until-*` '
100+
'and this argument will determine when playback stops.')
105101

106102
playback_until_arg_group = parser.add_mutually_exclusive_group()
107103
playback_until_arg_group.add_argument(
108104
'--playback-until-sec', type=float, default=-1.,
109105
help='Playback until timestamp, expressed in seconds since epoch. '
110106
'Mutually exclusive argument with `--playback-until-nsec`. '
111-
'Use this argument when floating point to integer conversion error is not a '
112-
'problem for your application. Negative stamps disable this feature. Default is '
113-
'-1.0. When positive, the maximum of the effective time that '
114-
'`--playback-duration` yields and this attribute will be used to determine which '
115-
'one stops playback execution.')
107+
'Use when floating point to integer conversion error is not a concern. '
108+
'A negative value disables this feature. '
109+
'Default is %(default)f. '
110+
'When positive, the maximum effective time between `--playback-duration` '
111+
'and this argument will determine when playback stops.')
116112
playback_until_arg_group.add_argument(
117113
'--playback-until-nsec', type=int, default=-1,
118114
help='Playback until timestamp, expressed in nanoseconds since epoch. '
119115
'Mutually exclusive argument with `--playback-until-sec`. '
120-
'Use this argument when floating point to integer conversion error matters for '
121-
'your application. Negative stamps disable this feature. Default is -1. When '
122-
'positive, the maximum of the effective time that `--playback-duration` yields '
123-
'and this attribute will be used to determine which one stops playback '
124-
'execution.')
116+
'Use when floating point to integer conversion error matters for your use case. '
117+
'A negative value disables this feature. '
118+
'Default is %(default)s. '
119+
'When positive, the maximum effective time between `--playback-duration` '
120+
'and this argument will determine when playback stops.')
125121

126122
parser.add_argument(
127123
'--disable-keyboard-controls', action='store_true',

0 commit comments

Comments
 (0)