Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 103 additions & 7 deletions package_xml_validation/helpers/find_launch_dependencies.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@

Recursively search a ROS 2 package's launch/ folder and extract
all referenced ROS 2 package names via a small set of regexes.

Now ignores matches that occur inside comments:
- Python: # line comments, and triple-quoted blocks
- XML/.launch: <!-- ... --> comments
- YAML: # line comments
"""

import os
Expand All @@ -29,21 +34,111 @@
r"\$\(\s*find-pkg-share\s+([A-Za-z0-9_]+)\s*\)",
]

# Compile once for speed
COMPILED = [re.compile(rx) for rx in REGEX_EXPR]

_TRIPLE_QUOTE_BLOCK = re.compile(r"(?s)(['\"]{3})(?:.*?)(\1)")
_XML_COMMENT_BLOCK = re.compile(r"(?s)<!--.*?-->")


def _strip_hash_line_comments_outside_strings(text: str) -> str:
"""
Remove '#' to end-of-line comments that occur OUTSIDE of single/double quoted strings.
Preserves newlines.
Suitable for Python and YAML after any triple-quoted removal (for Python).
"""
out = []
in_single = False
in_double = False
i = 0
n = len(text)

while i < n:
ch = text[i]

# Handle escapes inside strings
if ch == "\\" and (in_single or in_double) and i + 1 < n:
out.append(ch)
out.append(text[i + 1])
i += 2
continue

if not in_single and not in_double:
if ch == "#":
# skip until end of line (keep the newline itself)
while i < n and text[i] not in ("\n", "\r"):
i += 1
# fall through to append the newline (if any)
continue
elif ch == "'":
in_single = True
out.append(ch)
i += 1
continue
elif ch == '"':
in_double = True
out.append(ch)
i += 1
continue
else:
out.append(ch)
i += 1
continue
else:
# inside quotes
if in_single and ch == "'":
in_single = False
elif in_double and ch == '"':
in_double = False
out.append(ch)
i += 1

return "".join(out)


def scan_file(path, found: set[str], verbose: bool = False):
"""Apply every regex to the file and add matches to `found`."""
def _decomment_python(text: str) -> str:
# 1) drop triple-quoted blocks entirely
text = _TRIPLE_QUOTE_BLOCK.sub("", text)
# 2) drop '#' comments outside of quoted strings
text = _strip_hash_line_comments_outside_strings(text)
return text


def _decomment_xml(text: str) -> str:
# Drop <!-- ... --> blocks
return _XML_COMMENT_BLOCK.sub("", text)


def _decomment_yaml(text: str) -> str:
# YAML has only '#' line comments; be string-aware for ' and "
return _strip_hash_line_comments_outside_strings(text)


def _decomment_for_suffix(suffix: str, text: str) -> str:
s = suffix.lower()
if s.endswith(".py"):
return _decomment_python(text)
if s.endswith(".xml") or s.endswith(".launch"):
return _decomment_xml(text)
if s.endswith(".yaml") or s.endswith(".yml"):
return _decomment_yaml(text)
# default: no decommenting
return text


def scan_file(path: str, found: set[str], verbose: bool = False):
"""Apply every regex to the file after stripping comments; add matches to `found`."""
with open(path, encoding="utf-8") as f:
text = f.read()

text = _decomment_for_suffix(path, text)

for i, rx in enumerate(COMPILED):
for m in rx.finditer(text):
found.add(m.group(1))
pkg = m.group(1)
found.add(pkg)
if verbose:
print(
f"Found package '{m.group(1)}' in {os.path.basename(path)} with regex {REGEX_EXPR[i]}"
f"Found package '{pkg}' in {os.path.basename(path)} with regex {REGEX_EXPR[i]}"
)


Expand All @@ -52,15 +147,16 @@ def scan_files(launch_dir: str, verbose: bool = False) -> list[str]:
Extracts launch dependencies from the specified directory.
Launch dependencies are listed packages names in the launch files.
It uses regex to extract package names from common launch patterns.
Comments are stripped (type-specific) before matching.
"""
if not os.path.isdir(launch_dir):
print(f"Error: '{launch_dir}' is not a directory.")
return []

pkgs = set()
pkgs: set[str] = set()

for root, _, files in os.walk(launch_dir):
for fn in files:
if fn.endswith((".py", ".xml", ".yaml", ".launch", ".yml")):
scan_file(os.path.join(root, fn), pkgs, verbose)
return list(pkgs)
return sorted(pkgs)
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
# description: Announcer for Athena Multi Robot
# provides: announcement
# launch:
# package: athena_announcer_comment
# executable: athena_announcer
description: Announcer for Athena Multi Robot
provides: announcement
launch:
package: athena_announcer
executable: athena_announcer
99 changes: 99 additions & 0 deletions tests/examples/launch_examples/python_example_comment.launch.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
# from https://docs.ros.org/en/rolling/How-To-Guides/Launch-file-different-formats.html
from launch import LaunchDescription
from launch.actions import DeclareLaunchArgument, GroupAction, IncludeLaunchDescription
from launch.substitutions import LaunchConfiguration, PathJoinSubstitution
from launch_ros.actions import Node, PushROSNamespace
from launch_ros.substitutions import FindPackageShare


def generate_launch_description():
# launch_dir = PathJoinSubstitution(
# [FindPackageShare("demo_nodes_cpp_comment"), "launch", "topics"]
# )
"""
launch_dir = PathJoinSubstitution(
[FindPackageShare("demo_nodes_cpp_commentII"), "launch", "topics"]
)
"""
launch_dir = PathJoinSubstitution(
[FindPackageShare("demo_nodes_cpp"), "launch", "topics"]
)
return LaunchDescription(
[
# args that can be set from the command line or a default will be used
DeclareLaunchArgument("background_r", default_value="0"),
DeclareLaunchArgument("background_g", default_value="255"),
DeclareLaunchArgument("background_b", default_value="0"),
DeclareLaunchArgument("chatter_py_ns", default_value="chatter/py/ns"),
DeclareLaunchArgument("chatter_xml_ns", default_value="chatter/xml/ns"),
DeclareLaunchArgument("chatter_yaml_ns", default_value="chatter/yaml/ns"),
# include another launch file
IncludeLaunchDescription(
PathJoinSubstitution([launch_dir, "talker_listener_launch.py"])
),
# include a Python launch file in the chatter_py_ns namespace
GroupAction(
actions=[
# push_ros_namespace first to set namespace of included nodes for following actions
PushROSNamespace("chatter_py_ns"),
IncludeLaunchDescription(
PathJoinSubstitution([launch_dir, "talker_listener_launch.py"])
),
]
),
# include a xml launch file in the chatter_xml_ns namespace
GroupAction(
actions=[
# push_ros_namespace first to set namespace of included nodes for following actions
PushROSNamespace("chatter_xml_ns"),
IncludeLaunchDescription(
PathJoinSubstitution([launch_dir, "talker_listener_launch.xml"])
),
]
),
# include a yaml launch file in the chatter_yaml_ns namespace
GroupAction(
actions=[
# push_ros_namespace first to set namespace of included nodes for following actions
PushROSNamespace("chatter_yaml_ns"),
IncludeLaunchDescription(
PathJoinSubstitution(
[launch_dir, "talker_listener_launch.yaml"]
)
),
]
),
# start a turtlesim_node in the turtlesim1 namespace
Node(
package="turtlesim",
namespace="turtlesim1",
executable="turtlesim_node",
name="sim",
),
# start another turtlesim_node in the turtlesim2 namespace
# and use args to set parameters
Node(
package="turtlesim",
namespace="turtlesim2",
executable="turtlesim_node",
name="sim",
parameters=[
{
"background_r": LaunchConfiguration("background_r"),
"background_g": LaunchConfiguration("background_g"),
"background_b": LaunchConfiguration("background_b"),
}
],
),
# perform remap so both turtles listen to the same command topic
Node(
package="turtlesim",
executable="mimic",
name="mimic",
remappings=[
("/input/pose", "/turtlesim1/turtle1/pose"),
("/output/cmd_vel", "/turtlesim2/turtle1/cmd_vel"),
],
),
]
)
55 changes: 55 additions & 0 deletions tests/examples/launch_examples/xml_example_comment.launch.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- from https://docs.ros.org/en/rolling/How-To-Guides/Launch-file-different-formats.html -->
<launch>
<!-- args that can be set from the command line or a default will be used -->
<arg name="background_r" default="0" />
<arg name="background_g" default="255" />
<arg name="background_b" default="0" />
<arg name="chatter_py_ns" default="chatter/py/ns" />
<arg name="chatter_xml_ns" default="chatter/xml/ns" />
<arg name="chatter_yaml_ns" default="chatter/yaml/ns" />

<!-- include another launch file -->
<include file="$(find-pkg-share demo_nodes_cpp)/launch/topics/talker_listener_launch.py" />
<!--<include file="$(find-pkg-share demo_nodes_cpp_comment)/launch/topics/talker_listener_launch.py" />-->
<!-- This is multiline comment
this pkg should not be found!!
<include file="$(find-pkg-share demo_nodes_cpp_comment)/launch/topics/talker_listener_launch.py" />
-->
<!-- include a Python launch file in the chatter_py_ns namespace-->
<group>
<!-- push_ros_namespace to set namespace of included nodes -->
<push_ros_namespace namespace="$(var chatter_py_ns)" />
<include file="$(find-pkg-share demo_nodes_cpp)/launch/topics/talker_listener_launch.py" />
</group>

<!-- include a xml launch file in the chatter_xml_ns namespace-->
<group>
<!-- push_ros_namespace to set namespace of included nodes -->
<push_ros_namespace namespace="$(var chatter_xml_ns)" />
<include file="$(find-pkg-share demo_nodes_cpp)/launch/topics/talker_listener_launch.xml" />
</group>

<!-- include a yaml launch file in the chatter_yaml_ns namespace-->
<group>
<!-- push_ros_namespace to set namespace of included nodes -->
<push_ros_namespace namespace="$(var chatter_yaml_ns)" />
<include file="$(find-pkg-share demo_nodes_cpp)/launch/topics/talker_listener_launch.yaml" />
</group>

<!-- start a turtlesim_node in the turtlesim1 namespace -->
<node pkg="turtlesim" exec="turtlesim_node" name="sim" namespace="turtlesim1" />

<!-- start another turtlesim_node in the turtlesim2 namespace and use args to set parameters -->
<node pkg="turtlesim" exec="turtlesim_node" name="sim" namespace="turtlesim2">
<param name="background_r" value="$(var background_r)" />
<param name="background_g" value="$(var background_g)" />
<param name="background_b" value="$(var background_b)" />
</node>

<!-- perform remap so both turtles listen to the same command topic -->
<node pkg="turtlesim" exec="mimic" name="mimic">
<remap from="/input/pose" to="/turtlesim1/turtle1/pose" />
<remap from="/output/cmd_vel" to="/turtlesim2/turtle1/cmd_vel" />
</node>
</launch>
82 changes: 82 additions & 0 deletions tests/examples/launch_examples/yaml_example_comment.launch.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
%YAML 1.2
# extracted from https://docs.ros.org/en/rolling/How-To-Guides/Launch-file-different-formats.html
---
launch:
# args that can be set from the command line or a default will be used
- arg:
name: "background_r"
default: "0"
- arg:
name: "background_g"
default: "255"
- arg:
name: "background_b"
default: "0"
- arg:
name: "chatter_py_ns"
default: "chatter/py/ns"
- arg:
name: "chatter_xml_ns"
default: "chatter/xml/ns"
- arg:
name: "chatter_yaml_ns"
default: "chatter/yaml/ns"

# include another launch file
- include:
file: "$(find-pkg-share demo_nodes_cpp)/launch/topics/talker_listener_launch.py"

# include a Python launch file in the chatter_py_ns namespace
- group:
- push_ros_namespace:
namespace: "$(var chatter_py_ns)"
- include:
file: "$(find-pkg-share demo_nodes_cpp)/launch/topics/talker_listener_launch.py"
#- include:
# file: "$(find-pkg-share demo_nodes_cpp_comment)/launch/topics/talker_listener_launch.py"

# include a xml launch file in the chatter_xml_ns namespace
- group:
- push_ros_namespace:
namespace: "$(var chatter_xml_ns)"
- include:
file: "$(find-pkg-share demo_nodes_cpp)/launch/topics/talker_listener_launch.xml"

# include a yaml launch file in the chatter_yaml_ns namespace
- group:
- push_ros_namespace:
namespace: "$(var chatter_yaml_ns)"
- include:
file: "$(find-pkg-share demo_nodes_cpp)/launch/topics/talker_listener_launch.yaml"

# start a turtlesim_node in the turtlesim1 namespace
- node:
pkg: "turtlesim"
exec: "turtlesim_node"
name: "sim"
namespace: "turtlesim1"

# start another turtlesim_node in the turtlesim2 namespace and use args to set parameters
- node:
pkg: "turtlesim"
exec: "turtlesim_node"
name: "sim"
namespace: "turtlesim2"
param:
- name: "background_r"
value: "$(var background_r)"
- name: "background_g"
value: "$(var background_g)"
- name: "background_b"
value: "$(var background_b)"

# perform remap so both turtles listen to the same command topic
- node:
pkg: "turtlesim"
exec: "mimic"
name: "mimic"
remap:
- from: "/input/pose"
to: "/turtlesim1/turtle1/pose"
- from: "/output/cmd_vel"
to: "/turtlesim2/turtle1/cmd_vel"
Loading
Loading