Skip to content

Commit 0122cc2

Browse files
committed
1st draft bring up for "ros2 log watch" sub-command.
Signed-off-by: Tomoya Fujita <tomoya.fujita825@gmail.com> clean up the package and implementation. Signed-off-by: Tomoya Fujita <tomoya.fujita825@gmail.com> fix ros2log test_watch.py. Signed-off-by: Tomoya Fujita <tomoya.fujita825@gmail.com> support QoS configuration argument for ros2 log watch. Signed-off-by: Tomoya Fujita <tomoya.fujita825@gmail.com> Call content filtering API for logger name and level if available. Signed-off-by: Tomoya Fujita <tomoya.fujita825@gmail.com> add test_cli.py to test "ros2 log watch". Signed-off-by: Tomoya Fujita <tomoya.fujita825@gmail.com> remove ros2log/README.md. Signed-off-by: Tomoya Fujita <tomoya.fujita825@gmail.com> :construction: add list subcommand Signed-off-by: Decwest <fumiyaonishi1016@gmail.com> bug: fix test Signed-off-by: Decwest <fumiyaonishi1016@gmail.com> :recycle: delete non-necessary fixture node for test Signed-off-by: Decwest <fumiyaonishi1016@gmail.com> :bug: fix to print one by one Signed-off-by: Decwest <fumiyaonishi1016@gmail.com> support "ros2 log levels". Signed-off-by: Tomoya Fujita <tomoya.fujita825@gmail.com> :zap: add get and set subcommand Signed-off-by: Decwest <fumiyaonishi1016@gmail.com> :bug: align logger's initial state during test Signed-off-by: Decwest <fumiyaonishi1016@gmail.com> FIX: add sleep to reduce cpu consumption Signed-off-by: Decwest <fumiyaonishi1016@gmail.com> FIX: rewrite waiting acync process Signed-off-by: Decwest <fumiyaonishi1016@gmail.com> :FIX: delete --include-hidden-nodes option Signed-off-by: Decwest <fumiyaonishi1016@gmail.com> FIX: import from api Signed-off-by: Decwest <fumiyaonishi1016@gmail.com> FIX: add ValueError when empty string is inputted in get_absolute_node_name Signed-off-by: Decwest <fumiyaonishi1016@gmail.com> Signed-off-by: Tomoya.Fujita <tomoya.fujita825@gmail.com>
1 parent 8cf4749 commit 0122cc2

27 files changed

Lines changed: 2625 additions & 0 deletions

ros2log/package.xml

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?xml version="1.0"?>
2+
<?xml-model href="http://download.ros.org/schema/package_format3.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
3+
<package format="3">
4+
<name>ros2log</name>
5+
<version>0.40.4</version>
6+
<description>The log command for ROS 2 command line tools.</description>
7+
<maintainer email="tomoya.fujita825@gmail.com">Tomoya Fujita</maintainer>
8+
<maintainer email="fumiya-onishi@keio.jp">Fumiya Ohnishi</maintainer>
9+
<license>Apache License 2.0</license>
10+
11+
<author email="tomoya.fujita825@gmail.com">Tomoya Fujita</author>
12+
<author email="fumiya-onishi@keio.jp">Fumiya Ohnishi</author>
13+
14+
<exec_depend>python3-argcomplete</exec_depend>
15+
<exec_depend>python3-packaging</exec_depend>
16+
<exec_depend>python3-psutil</exec_depend>
17+
<exec_depend>rcl_interfaces</exec_depend>
18+
<exec_depend>rclpy</exec_depend>
19+
<exec_depend>ros2cli</exec_depend>
20+
<exec_depend>rosgraph_msgs</exec_depend>
21+
22+
<test_depend>ament_copyright</test_depend>
23+
<test_depend>ament_flake8</test_depend>
24+
<test_depend>ament_pep257</test_depend>
25+
<test_depend>ament_xmllint</test_depend>
26+
<test_depend>python3-pytest</test_depend>
27+
<test_depend>python3-pytest-timeout</test_depend>
28+
<test_depend>test_msgs</test_depend>
29+
30+
<export>
31+
<build_type>ament_python</build_type>
32+
</export>
33+
</package>

ros2log/resource/ros2log

Whitespace-only changes.

ros2log/ros2log/__init__.py

Whitespace-only changes.

ros2log/ros2log/api/__init__.py

Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
# Copyright 2026 Tomoya Fujita, Fumiya Ohnishi
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from collections.abc import Iterator
16+
from collections.abc import Sequence
17+
18+
from rcl_interfaces.msg import LoggerLevel
19+
from rcl_interfaces.msg import SetLoggerLevelsResult
20+
from rcl_interfaces.srv import GetLoggerLevels
21+
from rcl_interfaces.srv import SetLoggerLevels
22+
import rclpy
23+
24+
from ros2node.api import get_absolute_node_name
25+
from ros2node.api import get_node_names
26+
from ros2node.api import NodeName
27+
28+
29+
LOGGER_GET_SERVICE_SUFFIX = '/get_logger_levels'
30+
LOGGER_SET_SERVICE_SUFFIX = '/set_logger_levels'
31+
LOGGER_GET_SERVICE_TYPE = 'rcl_interfaces/srv/GetLoggerLevels'
32+
LOGGER_SET_SERVICE_TYPE = 'rcl_interfaces/srv/SetLoggerLevels'
33+
34+
35+
def _require_absolute_node_name(node_name: str) -> str:
36+
absolute_node_name = get_absolute_node_name(node_name)
37+
if absolute_node_name is None:
38+
raise ValueError('node_name must not be empty')
39+
return absolute_node_name
40+
41+
42+
def get_get_logger_levels_service_name(node_name: str) -> str:
43+
"""Return the get-logger-levels service name for a node."""
44+
return f'{_require_absolute_node_name(node_name)}{LOGGER_GET_SERVICE_SUFFIX}'
45+
46+
47+
def get_set_logger_levels_service_name(node_name: str) -> str:
48+
"""Return the set-logger-levels service name for a node."""
49+
return f'{_require_absolute_node_name(node_name)}{LOGGER_SET_SERVICE_SUFFIX}'
50+
51+
52+
def get_logger_name_for_node(node_name: str) -> str:
53+
"""
54+
Convert a fully qualified node name into its root logger name.
55+
56+
ROS node logger names use dot-separated namespaces, e.g. `/demo/talker`
57+
becomes `demo.talker`.
58+
"""
59+
absolute_node_name = _require_absolute_node_name(node_name)
60+
return absolute_node_name.lstrip('/').replace('/', '.')
61+
62+
63+
def format_logger_service_unavailable_error(node_name: str) -> str:
64+
"""Return the user-facing error for nodes without logger services."""
65+
absolute_node_name = _require_absolute_node_name(node_name)
66+
return (
67+
f"Logger service not available for node '{absolute_node_name}'.\n"
68+
"The 'enable_logger_service' node option must be enabled for this node."
69+
)
70+
71+
72+
def iter_logger_service_nodes(
73+
*,
74+
node,
75+
) -> Iterator[NodeName]:
76+
"""Yield nodes that expose the logger get/set services as they are discovered."""
77+
for node_name in get_node_names(node=node):
78+
if node_has_logger_services(node, node_name):
79+
yield node_name
80+
81+
82+
def get_logger_service_nodes(*, node) -> list[NodeName]:
83+
"""Return all nodes that expose the logger get/set services."""
84+
return list(iter_logger_service_nodes(node=node))
85+
86+
87+
def get_target_node_names(
88+
*,
89+
node,
90+
node_name: str | None = None,
91+
all_nodes: bool = False,
92+
) -> tuple[list[str] | None, str | None]:
93+
"""Resolve the node names targeted by a get/set command."""
94+
logger_service_nodes = get_logger_service_nodes(node=node)
95+
logger_service_node_names = {
96+
logger_node.full_name for logger_node in logger_service_nodes
97+
}
98+
99+
if all_nodes:
100+
return sorted(logger_service_node_names), None
101+
102+
absolute_node_name = get_absolute_node_name(node_name)
103+
all_node_names = {
104+
discovered_node.full_name for discovered_node in get_node_names(node=node)
105+
}
106+
107+
if absolute_node_name not in all_node_names:
108+
return None, 'Node not found'
109+
110+
if absolute_node_name not in logger_service_node_names:
111+
return None, format_logger_service_unavailable_error(absolute_node_name)
112+
113+
return [absolute_node_name], None
114+
115+
116+
def node_has_logger_services(node, node_name: NodeName) -> bool:
117+
"""Check if a node provides both get/set logger level services."""
118+
services = node.get_service_names_and_types_by_node(node_name.name, node_name.namespace)
119+
service_map = dict(services)
120+
121+
expected_get = get_get_logger_levels_service_name(node_name.full_name)
122+
expected_set = get_set_logger_levels_service_name(node_name.full_name)
123+
124+
return (
125+
_service_has_type(service_map, expected_get, LOGGER_GET_SERVICE_TYPE) and
126+
_service_has_type(service_map, expected_set, LOGGER_SET_SERVICE_TYPE)
127+
)
128+
129+
130+
def call_get_logger_levels(
131+
*,
132+
node,
133+
logger_names_by_node: dict[str, Sequence[str]],
134+
timeout_sec: float = 5.0,
135+
) -> dict[str, list[LoggerLevel] | Exception]:
136+
"""Call the get-logger-levels service for one or more nodes."""
137+
clients = {}
138+
futures = {}
139+
results = {}
140+
141+
for node_name, logger_names in logger_names_by_node.items():
142+
client = node.create_client(
143+
GetLoggerLevels,
144+
get_get_logger_levels_service_name(node_name),
145+
)
146+
clients[node_name] = client
147+
if not client.wait_for_service(timeout_sec=timeout_sec):
148+
results[node_name] = RuntimeError(
149+
'Wait for service timed out waiting for logger services '
150+
f'for node {node_name}'
151+
)
152+
continue
153+
154+
request = GetLoggerLevels.Request()
155+
request.names = list(logger_names)
156+
futures[node_name] = client.call_async(request)
157+
158+
while futures and not all(future.done() for future in futures.values()):
159+
rclpy.spin_once(node, timeout_sec=0.1)
160+
161+
for node_name, future in futures.items():
162+
if future.result() is not None:
163+
results[node_name] = list(future.result().levels)
164+
else:
165+
results[node_name] = future.exception()
166+
167+
return results
168+
169+
170+
def call_set_logger_levels(
171+
*,
172+
node,
173+
levels_by_node: dict[str, Sequence[LoggerLevel]],
174+
timeout_sec: float = 5.0,
175+
) -> dict[str, list[SetLoggerLevelsResult] | Exception]:
176+
"""Call the set-logger-levels service for one or more nodes."""
177+
clients = {}
178+
futures = {}
179+
results = {}
180+
181+
for node_name, levels in levels_by_node.items():
182+
client = node.create_client(
183+
SetLoggerLevels,
184+
get_set_logger_levels_service_name(node_name),
185+
)
186+
clients[node_name] = client
187+
if not client.wait_for_service(timeout_sec=timeout_sec):
188+
results[node_name] = RuntimeError(
189+
'Wait for service timed out waiting for logger services '
190+
f'for node {node_name}'
191+
)
192+
continue
193+
194+
request = SetLoggerLevels.Request()
195+
request.levels = list(levels)
196+
futures[node_name] = client.call_async(request)
197+
198+
while futures and not all(future.done() for future in futures.values()):
199+
rclpy.spin_once(node, timeout_sec=0.1)
200+
201+
for node_name, future in futures.items():
202+
if future.result() is not None:
203+
results[node_name] = list(future.result().results)
204+
else:
205+
results[node_name] = future.exception()
206+
207+
return results
208+
209+
210+
def _service_has_type(service_map, service_name: str, service_type: str) -> bool:
211+
"""Check if a service exists and matches the expected type."""
212+
types = service_map.get(service_name)
213+
if not types:
214+
return False
215+
return service_type in types

ros2log/ros2log/command/__init__.py

Whitespace-only changes.

ros2log/ros2log/command/log.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Copyright 2025 Tomoya Fujita, Fumiya Ohnishi
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from ros2cli.command import add_subparsers_on_demand
16+
from ros2cli.command import CommandExtension
17+
18+
19+
class LogCommand(CommandExtension):
20+
"""Various log related sub-commands."""
21+
22+
def add_arguments(self, parser, cli_name):
23+
self._subparser = parser
24+
25+
# Add global debug flag for all log subcommands
26+
parser.add_argument(
27+
'--debug',
28+
action='store_true',
29+
default=False,
30+
help='Enable debug output for verbose information')
31+
32+
# add arguments and sub-commands of verbs
33+
add_subparsers_on_demand(
34+
parser, cli_name, '_verb', 'ros2log.verb', required=False)
35+
36+
def main(self, *, parser, args):
37+
if not hasattr(args, '_verb'):
38+
# in case no verb was passed
39+
self._subparser.print_help()
40+
return 0
41+
42+
extension = getattr(args, '_verb')
43+
44+
# call the verb's main method
45+
return extension.main(args=args)

ros2log/ros2log/py.typed

Whitespace-only changes.

ros2log/ros2log/verb/__init__.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Copyright 2025 Tomoya Fujita, Fumiya Ohnishi
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
from ros2cli.plugin_system import PLUGIN_SYSTEM_VERSION
16+
from ros2cli.plugin_system import satisfies_version
17+
18+
19+
class VerbExtension:
20+
"""
21+
The extension point for 'log' verb extensions.
22+
23+
The following properties must be defined:
24+
* `NAME` (will be set to the entry point name)
25+
26+
The following methods can be defined:
27+
* `main` - handles CLI invocation
28+
29+
The following methods can be defined:
30+
* `add_arguments`
31+
"""
32+
33+
NAME = None
34+
EXTENSION_POINT_VERSION = '0.1'
35+
36+
def __init__(self):
37+
super(VerbExtension, self).__init__()
38+
satisfies_version(PLUGIN_SYSTEM_VERSION, '^0.1')
39+
40+
def add_arguments(self, parser, cli_name):
41+
pass
42+
43+
def main(self, *, args):
44+
raise NotImplementedError()

0 commit comments

Comments
 (0)