diff --git a/CHANGELOG b/CHANGELOG index 78e188a..5815f9e 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/). --- +## [0.2.0] - 2025-08-23 + +### Added +- XML launch file parsing support +- Autoware XML Launch Real Test File + +### Changed +- Reorganized the parser contents for demarcation between python and xml with common utilities + ## [0.1.10] - 2025-08-20 ### Added diff --git a/package-lock.json b/package-lock.json index e06412b..3cc5ae7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "launchmap", - "version": "0.1.10", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "launchmap", - "version": "0.1.10", + "version": "0.2.0", "license": "Apache-2.0", "dependencies": { "which": "^5.0.0" diff --git a/package.json b/package.json index 315385a..81bd08e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "launchmap", "displayName": "LaunchMap", "description": "Visualize ROS2 Launch Files", - "version": "0.1.10", + "version": "0.2.0", "publisher": "kodorobotics", "icon": "assets/launchmap-logo.png", "bugs": { diff --git a/parser/custom_handlers/rewritten_yaml_handler.py b/parser/custom_handlers/rewritten_yaml_handler.py index ac09e4d..d569ed3 100644 --- a/parser/custom_handlers/rewritten_yaml_handler.py +++ b/parser/custom_handlers/rewritten_yaml_handler.py @@ -13,7 +13,7 @@ # limitations under the License. from parser.parser.postprocessing import simplify_launch_configurations -from parser.parser.user_handler import register_user_handler +from parser.parser.python.user_handler import register_user_handler from parser.resolution.utils import resolve_call_kwargs diff --git a/parser/entrypoint/common.py b/parser/entrypoint/common.py new file mode 100644 index 0000000..d5c8182 --- /dev/null +++ b/parser/entrypoint/common.py @@ -0,0 +1,45 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from typing import List + +from parser.context import ParseContext + + +def build_result(filepath: str, context: ParseContext, parsed: List): + """ + Shape the final response uniformly. + """ + return { + "file": filepath, + "parsed": parsed, + "used_launch_config": sorted(context.introspection.used_launch_configs), + "declared_arguments": sorted(context.introspection.declared_launch_args.keys()), + "undeclared_launch_configurations": sorted( + context.introspection.get_undeclared_launch_configs() + ), + "environment_variables": context.introspection.get_environment_variables(), + "python_expressions": context.introspection.get_python_expressions(), + "composable_containers": context.get_composable_node_groups(), + "additional_components": context.introspection.get_registered_entities(), + } + +def detect_format_from_content(code: str) -> str: + """ + Return 'xml' if it parses as XML with root; otherwise 'python'. + """ + head = code.lstrip().lower() + if head.startswith(" dict: - """ - Entrypoint: parses a launch file and returns structured output - Detects LaunchDescription([...]) or ld.add_action(...) usage. - """ with open(filepath, "r", encoding="utf-8") as f: code = f.read() - tree = ast.parse(code, filename=filepath) - - # Set up shared context and resolution engine - context = ParseContext() - context.current_file = filepath - engine = ResolutionEngine(context) - context.engine = engine - - parsed = [] - - collect_function_defs(tree.body, context) - - # Simulate top-level execution - for node in tree.body: - if isinstance(node, ast.Assign): - engine.resolve(node) - - elif isinstance(node, ast.Expr): - engine.resolve(node) - - # Now extract and run generate_launch_description - main_fn = context.lookup_function("generate_launch_description") - if not main_fn: - raise ValueError("No generate_launch_description() function found.") - - parsed.extend(_parse_launch_function_body(main_fn.body, context, engine)) - - return { - "file": filepath, - "parsed": parsed, - "used_launch_config": sorted(context.introspection.used_launch_configs), - "declared_arguments": sorted(context.introspection.declared_launch_args.keys()), - "undeclared_launch_configurations": sorted( - context.introspection.get_undeclared_launch_configs() - ), - "environment_variables": context.introspection.get_environment_variables(), - "python_expressions": context.introspection.get_python_expressions(), - "composable_containers": context.get_composable_node_groups(), - "additional_components": context.introspection.get_registered_entities(), - } - - -def _parse_launch_function_body( - body: list[ast.stmt], context: ParseContext, engine: ResolutionEngine -) -> list: - parsed = [] - for stmt in body: - if isinstance(stmt, ast.Assign): - engine.resolve(stmt) - - elif isinstance(stmt, ast.If): - engine.resolve(stmt) - - elif isinstance(stmt, ast.Expr): - func_name = context.get_func_name(stmt.value.func) - if func_name.endswith("add_action"): - arg = stmt.value.args[0] - result = engine.resolve(arg) - if result: - parsed.append(result) - else: - engine.resolve(stmt) - - elif isinstance(stmt, ast.Return) and isinstance(stmt.value, ast.Call): - resolved = engine.resolve(stmt.value) - if isinstance(resolved, list): - parsed.extend(resolved) - elif resolved is not None: - parsed.append(resolved) + kind = detect_format_from_content(code) - return parsed + if kind == "xml": + return parse_xml_launch_file(filepath) + return parse_python_launch_file(filepath) diff --git a/parser/entrypoint/python_runner.py b/parser/entrypoint/python_runner.py new file mode 100644 index 0000000..3c16299 --- /dev/null +++ b/parser/entrypoint/python_runner.py @@ -0,0 +1,89 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import ast + +from parser.context import ParseContext +from parser.entrypoint.common import build_result +from parser.parser.python.utils import collect_function_defs +from parser.resolution.resolution_engine import ResolutionEngine + + +def parse_python_launch_file(filepath: str) -> dict: + """ + Entrypoint: parses a launch file and returns structured output + Detects LaunchDescription([...]) or ld.add_action(...) usage. + """ + with open(filepath, "r", encoding="utf-8") as f: + code = f.read() + + tree = ast.parse(code, filename=filepath) + + # Set up shared context and resolution engine + context = ParseContext() + context.current_file = filepath + engine = ResolutionEngine(context) + context.engine = engine + + parsed = [] + + collect_function_defs(tree.body, context) + + # Simulate top-level execution + for node in tree.body: + if isinstance(node, ast.Assign): + engine.resolve(node) + + elif isinstance(node, ast.Expr): + engine.resolve(node) + + # Now extract and run generate_launch_description + main_fn = context.lookup_function("generate_launch_description") + if not main_fn: + raise ValueError("No generate_launch_description() function found.") + + parsed.extend(_parse_launch_function_body(main_fn.body, context, engine)) + + return build_result(filepath, context, parsed) + + +def _parse_launch_function_body( + body: list[ast.stmt], context: ParseContext, engine: ResolutionEngine +) -> list: + parsed = [] + for stmt in body: + if isinstance(stmt, ast.Assign): + engine.resolve(stmt) + + elif isinstance(stmt, ast.If): + engine.resolve(stmt) + + elif isinstance(stmt, ast.Expr): + func_name = context.get_func_name(stmt.value.func) + if func_name.endswith("add_action"): + arg = stmt.value.args[0] + result = engine.resolve(arg) + if result: + parsed.append(result) + else: + engine.resolve(stmt) + + elif isinstance(stmt, ast.Return) and isinstance(stmt.value, ast.Call): + resolved = engine.resolve(stmt.value) + if isinstance(resolved, list): + parsed.extend(resolved) + elif resolved is not None: + parsed.append(resolved) + + return parsed \ No newline at end of file diff --git a/parser/entrypoint/user_interface.py b/parser/entrypoint/user_interface.py index b576620..8660fff 100644 --- a/parser/entrypoint/user_interface.py +++ b/parser/entrypoint/user_interface.py @@ -21,7 +21,7 @@ collect_python_variable_usages, ) from parser.parser.postprocessing import simplify_launch_configurations -from parser.parser.utils.common import group_entities_by_type +from parser.parser.utils import group_entities_by_type def parse_and_format_launch_file(filepath: str) -> dict: diff --git a/parser/entrypoint/xml_runner.py b/parser/entrypoint/xml_runner.py new file mode 100644 index 0000000..1fec547 --- /dev/null +++ b/parser/entrypoint/xml_runner.py @@ -0,0 +1,42 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from xml.etree import ElementTree as ET + +from parser.context import ParseContext +from parser.entrypoint.common import build_result +from parser.parser.xml.dispatcher import dispatch_element +from parser.parser.xml.utils import strip_ns + + +def parse_xml_launch_file(filepath: str) -> dict: + """ + Entrypoint: parses a launch file and returns structured output + Detects + """ + root = ET.parse(filepath).getroot() + tag = strip_ns(root.tag) + if tag != "launch": + raise ValueError(f"Expected as root tag, found <{tag}") + + # Set up shared context + context = ParseContext() + context.current_file = filepath + + parsed = [] + for child in list(root): + result = dispatch_element(child, context) + parsed.append(result) + + return build_result(filepath, context, parsed) diff --git a/parser/introspection/tracker.py b/parser/introspection/tracker.py index af61248..140a1ce 100644 --- a/parser/introspection/tracker.py +++ b/parser/introspection/tracker.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from parser.parser.utils.common import compute_entity_key +from parser.parser.utils import compute_entity_key class IntrospectionTracker: diff --git a/parser/parser/handlers/__init__.py b/parser/parser/python/__init__.py similarity index 100% rename from parser/parser/handlers/__init__.py rename to parser/parser/python/__init__.py diff --git a/parser/parser/dispatcher.py b/parser/parser/python/dispatcher.py similarity index 95% rename from parser/parser/dispatcher.py rename to parser/parser/python/dispatcher.py index 04af22e..2052da0 100644 --- a/parser/parser/dispatcher.py +++ b/parser/parser/python/dispatcher.py @@ -15,7 +15,7 @@ import ast from parser.context import ParseContext -from parser.parser.loader import register_builtin_handlers +from parser.parser.python.loader import register_builtin_handlers from parser.parser.registry import get_handler from parser.resolution.utils import get_func_name diff --git a/parser/parser/utils/__init__.py b/parser/parser/python/handlers/__init__.py similarity index 100% rename from parser/parser/utils/__init__.py rename to parser/parser/python/handlers/__init__.py diff --git a/parser/parser/handlers/command_handler.py b/parser/parser/python/handlers/command_handler.py similarity index 100% rename from parser/parser/handlers/command_handler.py rename to parser/parser/python/handlers/command_handler.py diff --git a/parser/parser/handlers/composable_node_container_handler.py b/parser/parser/python/handlers/composable_node_container_handler.py similarity index 96% rename from parser/parser/handlers/composable_node_container_handler.py rename to parser/parser/python/handlers/composable_node_container_handler.py index c7b1738..7eee8f5 100644 --- a/parser/parser/handlers/composable_node_container_handler.py +++ b/parser/parser/python/handlers/composable_node_container_handler.py @@ -16,7 +16,7 @@ from parser.context import ParseContext from parser.parser.registry import register_handler -from parser.parser.utils.common import flatten_once, group_entities_by_type +from parser.parser.utils import flatten_once, group_entities_by_type from parser.resolution.utils import resolve_call_signature diff --git a/parser/parser/handlers/composable_node_handler.py b/parser/parser/python/handlers/composable_node_handler.py similarity index 100% rename from parser/parser/handlers/composable_node_handler.py rename to parser/parser/python/handlers/composable_node_handler.py diff --git a/parser/parser/handlers/condition_handler.py b/parser/parser/python/handlers/condition_handler.py similarity index 100% rename from parser/parser/handlers/condition_handler.py rename to parser/parser/python/handlers/condition_handler.py diff --git a/parser/parser/handlers/declare_launch_argument_handler.py b/parser/parser/python/handlers/declare_launch_argument_handler.py similarity index 100% rename from parser/parser/handlers/declare_launch_argument_handler.py rename to parser/parser/python/handlers/declare_launch_argument_handler.py diff --git a/parser/parser/handlers/environment_variable_handler.py b/parser/parser/python/handlers/environment_variable_handler.py similarity index 100% rename from parser/parser/handlers/environment_variable_handler.py rename to parser/parser/python/handlers/environment_variable_handler.py diff --git a/parser/parser/handlers/equals_handler.py b/parser/parser/python/handlers/equals_handler.py similarity index 100% rename from parser/parser/handlers/equals_handler.py rename to parser/parser/python/handlers/equals_handler.py diff --git a/parser/parser/handlers/find_executable.py b/parser/parser/python/handlers/find_executable.py similarity index 100% rename from parser/parser/handlers/find_executable.py rename to parser/parser/python/handlers/find_executable.py diff --git a/parser/parser/handlers/find_package_share.py b/parser/parser/python/handlers/find_package_share.py similarity index 100% rename from parser/parser/handlers/find_package_share.py rename to parser/parser/python/handlers/find_package_share.py diff --git a/parser/parser/handlers/group_handler.py b/parser/parser/python/handlers/group_handler.py similarity index 96% rename from parser/parser/handlers/group_handler.py rename to parser/parser/python/handlers/group_handler.py index d535f7a..b811090 100644 --- a/parser/parser/handlers/group_handler.py +++ b/parser/parser/python/handlers/group_handler.py @@ -16,7 +16,7 @@ from parser.context import ParseContext from parser.parser.registry import register_handler -from parser.parser.utils.common import flatten_once, group_entities_by_type +from parser.parser.utils import flatten_once, group_entities_by_type from parser.resolution.utils import resolve_call_signature diff --git a/parser/parser/handlers/include_handler.py b/parser/parser/python/handlers/include_handler.py similarity index 100% rename from parser/parser/handlers/include_handler.py rename to parser/parser/python/handlers/include_handler.py diff --git a/parser/parser/handlers/launch_configuration_handler.py b/parser/parser/python/handlers/launch_configuration_handler.py similarity index 100% rename from parser/parser/handlers/launch_configuration_handler.py rename to parser/parser/python/handlers/launch_configuration_handler.py diff --git a/parser/parser/handlers/launch_description_handler.py b/parser/parser/python/handlers/launch_description_handler.py similarity index 95% rename from parser/parser/handlers/launch_description_handler.py rename to parser/parser/python/handlers/launch_description_handler.py index 29c51ca..411a7d7 100644 --- a/parser/parser/handlers/launch_description_handler.py +++ b/parser/parser/python/handlers/launch_description_handler.py @@ -16,7 +16,7 @@ from parser.context import ParseContext from parser.parser.registry import register_handler -from parser.parser.utils.common import flatten_once +from parser.parser.utils import flatten_once from parser.resolution.utils import resolve_call_signature diff --git a/parser/parser/handlers/launch_sources.py b/parser/parser/python/handlers/launch_sources.py similarity index 100% rename from parser/parser/handlers/launch_sources.py rename to parser/parser/python/handlers/launch_sources.py diff --git a/parser/parser/handlers/load_composable_nodes_handler.py b/parser/parser/python/handlers/load_composable_nodes_handler.py similarity index 96% rename from parser/parser/handlers/load_composable_nodes_handler.py rename to parser/parser/python/handlers/load_composable_nodes_handler.py index 8bf32b5..3a6c9bc 100644 --- a/parser/parser/handlers/load_composable_nodes_handler.py +++ b/parser/parser/python/handlers/load_composable_nodes_handler.py @@ -17,7 +17,7 @@ from parser.context import ParseContext from parser.parser.postprocessing import simplify_launch_configurations from parser.parser.registry import register_handler -from parser.parser.utils.common import flatten_once, group_entities_by_type +from parser.parser.utils import flatten_once, group_entities_by_type from parser.resolution.utils import resolve_call_signature diff --git a/parser/parser/handlers/node_handler.py b/parser/parser/python/handlers/node_handler.py similarity index 100% rename from parser/parser/handlers/node_handler.py rename to parser/parser/python/handlers/node_handler.py diff --git a/parser/parser/handlers/on_process_exit_handler.py b/parser/parser/python/handlers/on_process_exit_handler.py similarity index 100% rename from parser/parser/handlers/on_process_exit_handler.py rename to parser/parser/python/handlers/on_process_exit_handler.py diff --git a/parser/parser/handlers/on_process_start_handler.py b/parser/parser/python/handlers/on_process_start_handler.py similarity index 100% rename from parser/parser/handlers/on_process_start_handler.py rename to parser/parser/python/handlers/on_process_start_handler.py diff --git a/parser/parser/handlers/opaque_function_handler.py b/parser/parser/python/handlers/opaque_function_handler.py similarity index 95% rename from parser/parser/handlers/opaque_function_handler.py rename to parser/parser/python/handlers/opaque_function_handler.py index 4ad1487..6376eb9 100644 --- a/parser/parser/handlers/opaque_function_handler.py +++ b/parser/parser/python/handlers/opaque_function_handler.py @@ -16,9 +16,9 @@ from parser.context import ParseContext from parser.parser.postprocessing import simplify_launch_configurations +from parser.parser.python.utils import extract_opaque_function from parser.parser.registry import register_handler -from parser.parser.utils.ast_utils import extract_opaque_function -from parser.parser.utils.common import group_entities_by_type +from parser.parser.utils import group_entities_by_type from parser.resolution.resolution_engine import ResolutionEngine from parser.resolution.utils import bind_function_args, resolve_call_signature diff --git a/parser/parser/handlers/parameter_file_handler.py b/parser/parser/python/handlers/parameter_file_handler.py similarity index 100% rename from parser/parser/handlers/parameter_file_handler.py rename to parser/parser/python/handlers/parameter_file_handler.py diff --git a/parser/parser/handlers/path_join.py b/parser/parser/python/handlers/path_join.py similarity index 100% rename from parser/parser/handlers/path_join.py rename to parser/parser/python/handlers/path_join.py diff --git a/parser/parser/handlers/perform_handler.py b/parser/parser/python/handlers/perform_handler.py similarity index 100% rename from parser/parser/handlers/perform_handler.py rename to parser/parser/python/handlers/perform_handler.py diff --git a/parser/parser/handlers/push_ros_namespace_handler.py b/parser/parser/python/handlers/push_ros_namespace_handler.py similarity index 100% rename from parser/parser/handlers/push_ros_namespace_handler.py rename to parser/parser/python/handlers/push_ros_namespace_handler.py diff --git a/parser/parser/handlers/python_expression_handler.py b/parser/parser/python/handlers/python_expression_handler.py similarity index 100% rename from parser/parser/handlers/python_expression_handler.py rename to parser/parser/python/handlers/python_expression_handler.py diff --git a/parser/parser/handlers/register_event_handler.py b/parser/parser/python/handlers/register_event_handler.py similarity index 100% rename from parser/parser/handlers/register_event_handler.py rename to parser/parser/python/handlers/register_event_handler.py diff --git a/parser/parser/handlers/set_environment_variable_handler.py b/parser/parser/python/handlers/set_environment_variable_handler.py similarity index 100% rename from parser/parser/handlers/set_environment_variable_handler.py rename to parser/parser/python/handlers/set_environment_variable_handler.py diff --git a/parser/parser/handlers/set_parameter_handler.py b/parser/parser/python/handlers/set_parameter_handler.py similarity index 100% rename from parser/parser/handlers/set_parameter_handler.py rename to parser/parser/python/handlers/set_parameter_handler.py diff --git a/parser/parser/handlers/timer_action_handler.py b/parser/parser/python/handlers/timer_action_handler.py similarity index 95% rename from parser/parser/handlers/timer_action_handler.py rename to parser/parser/python/handlers/timer_action_handler.py index d10e972..467e524 100644 --- a/parser/parser/handlers/timer_action_handler.py +++ b/parser/parser/python/handlers/timer_action_handler.py @@ -16,7 +16,7 @@ from parser.context import ParseContext from parser.parser.registry import register_handler -from parser.parser.utils.common import flatten_once, group_entities_by_type +from parser.parser.utils import flatten_once, group_entities_by_type from parser.resolution.utils import resolve_call_signature diff --git a/parser/parser/python/loader.py b/parser/parser/python/loader.py new file mode 100644 index 0000000..1208f93 --- /dev/null +++ b/parser/parser/python/loader.py @@ -0,0 +1,28 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import importlib +import os +import pkgutil + + +def register_builtin_handlers(): + """ + Auto import all modules in parsers.handlers to trigger @register_handler decorators. + """ + import parser.parser.python.handlers + + package_dir = os.path.dirname(parser.parser.python.handlers.__file__) + for _, module_name, _ in pkgutil.iter_modules([package_dir]): + importlib.import_module(f"parser.parser.python.handlers.{module_name}") diff --git a/parser/parser/user_handler.py b/parser/parser/python/user_handler.py similarity index 100% rename from parser/parser/user_handler.py rename to parser/parser/python/user_handler.py diff --git a/parser/parser/utils/ast_utils.py b/parser/parser/python/utils.py similarity index 100% rename from parser/parser/utils/ast_utils.py rename to parser/parser/python/utils.py diff --git a/parser/parser/registry.py b/parser/parser/registry.py index ebbf753..87032ed 100644 --- a/parser/parser/registry.py +++ b/parser/parser/registry.py @@ -12,23 +12,32 @@ # See the License for the specific language governing permissions and # limitations under the License. -import ast import warnings -from typing import Callable, Dict, Optional +from typing import Any, Callable, Dict, Optional from parser.context import ParseContext +# Handler signature: accepts a construct (AST Call, XML Element etc.) + context +Handler = Callable[[Any, "ParseContext"], Optional[dict]] + # Registry dictionary for known launch constructs -_HANDLER_REGISTRY: Dict[str, Callable[[ast.Call, "ParseContext"], Optional[dict]]] = {} +_HANDLER_REGISTRY: Dict[str, Handler] = {} def register_handler(*names: str): """ Decorator to register a handler for a given launch construct. - Example: @register_handler("Node") registers a handler for launch_ros.actions.Node. + + For Python-based launch: + @register_handler("Node", "launch_ros.actions.Node") + + For XML-based launch: + @register_handler("node) + + You can register multiple aliases pointing to the same handler. """ - def decorator(func: Callable[[ast.Call, "ParseContext"], Optional[dict]]): + def decorator(func: Handler): for name in names: if name in _HANDLER_REGISTRY: warnings.warn(f"Overwriting existing handler for '{name}'") @@ -38,14 +47,14 @@ def decorator(func: Callable[[ast.Call, "ParseContext"], Optional[dict]]): return decorator -def get_handler(name: str) -> Optional[Callable[[ast.Call, "ParseContext"], Optional[dict]]]: +def get_handler(name: str) -> Optional[Handler]: """ Retrieve the handler for a given construct, or None if unregistered """ return _HANDLER_REGISTRY.get(name) -def all_registered() -> Dict[str, Callable]: +def all_registered() -> Dict[str, Handler]: """ Return the complete handler map (useful for debugging or listing). """ diff --git a/parser/parser/utils/common.py b/parser/parser/utils.py similarity index 100% rename from parser/parser/utils/common.py rename to parser/parser/utils.py diff --git a/parser/parser/utils/symbolic.py b/parser/parser/xml/__init__.py similarity index 75% rename from parser/parser/utils/symbolic.py rename to parser/parser/xml/__init__.py index 742fc73..396960c 100644 --- a/parser/parser/utils/symbolic.py +++ b/parser/parser/xml/__init__.py @@ -11,10 +11,3 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. - - -def is_symbolic(value): - """ - Determines if a value is symbolic (non-concrete) object from the resolution engine. - """ - return isinstance(value, dict) and "type" in value diff --git a/parser/parser/xml/dispatcher.py b/parser/parser/xml/dispatcher.py new file mode 100644 index 0000000..77e0761 --- /dev/null +++ b/parser/parser/xml/dispatcher.py @@ -0,0 +1,53 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from xml.etree import ElementTree as ET + +from parser.context import ParseContext +from parser.parser.registry import get_handler +from parser.parser.xml.loader import register_builtin_handlers +from parser.parser.xml.utils import strip_ns + +register_builtin_handlers() + +def dispatch_element(el: ET.Element, context: ParseContext) -> dict: + """ + Dispatch a launch construct (XML element) to its registered handler. + + - Uses the raw tag name ('node', 'include', 'group') + - Looks up the handler in registry + - Delegates to handler + """ + tag = strip_ns(el.tag) + handler = get_handler(tag) + + if not handler: + raise ValueError(f"Unrecognized XML launch construct: <{tag}>") + + return handler(el, context) + +def dispatch_substitution(expr: str, context: ParseContext): + expr = expr.strip() + if not expr: + return None + + parts = expr.split(maxsplit=1) + key = parts[0] + arg = parts[1] if len(parts) > 1 else "" + + handler = get_handler(f"subst:{key}") + if not handler: + return f"${{{expr}}}" + + return handler(arg, context) \ No newline at end of file diff --git a/parser/parser/xml/handlers/__init__.py b/parser/parser/xml/handlers/__init__.py new file mode 100644 index 0000000..396960c --- /dev/null +++ b/parser/parser/xml/handlers/__init__.py @@ -0,0 +1,13 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/parser/parser/xml/handlers/arg_handler.py b/parser/parser/xml/handlers/arg_handler.py new file mode 100644 index 0000000..8bb369f --- /dev/null +++ b/parser/parser/xml/handlers/arg_handler.py @@ -0,0 +1,42 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from xml.etree import ElementTree as ET + +from parser.context import ParseContext +from parser.parser.registry import register_handler +from parser.parser.xml.utils import normalize_keys, resolve_parameters + + +@register_handler("arg") +def handle_arg(element: ET.Element, context: ParseContext) -> dict: + """ + Handle an XML tag. + - Tracks the declared argument in introspection + """ + kwargs = {} + kwargs.update(resolve_parameters(element, context)) + + # Detect if arg is used inside include tag + arg_value = kwargs.get("value", None) + if arg_value: + return {"type": "LaunchArguments", "value": {kwargs["name"]: kwargs["value"]}} + + # Track for introspection + arg_name = kwargs.get("name") + if arg_name: + context.introspection.track_launch_arg_declaration(arg_name, kwargs) + + norm_kwargs = normalize_keys(kwargs, {"default": "default_value"}) + return {"type": "DeclareLaunchArgument", **norm_kwargs} diff --git a/parser/parser/xml/handlers/condition_handler.py b/parser/parser/xml/handlers/condition_handler.py new file mode 100644 index 0000000..936379d --- /dev/null +++ b/parser/parser/xml/handlers/condition_handler.py @@ -0,0 +1,34 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from parser.parser.postprocessing import simplify_launch_configurations + + +def handle_condition(kwargs: dict): + if "if" in kwargs: + _handle_if_condition(kwargs) + elif "unless" in kwargs: + _handle_unless_condition(kwargs) + + return + +def _handle_if_condition(kwargs: dict): + expression = simplify_launch_configurations(kwargs["if"]) + kwargs.pop("if", None) + kwargs["condition"] = f"${{IfCondition:{expression}}}" + +def _handle_unless_condition(kwargs: dict): + expression = simplify_launch_configurations(kwargs["unless"]) + kwargs.pop("unless", None) + kwargs["condition"] = f"${{UnlessCondition:{expression}}}" \ No newline at end of file diff --git a/parser/parser/xml/handlers/env_handler.py b/parser/parser/xml/handlers/env_handler.py new file mode 100644 index 0000000..29726d4 --- /dev/null +++ b/parser/parser/xml/handlers/env_handler.py @@ -0,0 +1,44 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from xml.etree import ElementTree as ET + +from parser.context import ParseContext +from parser.parser.registry import register_handler +from parser.parser.xml.utils import resolve_parameters + + +@register_handler("env") +def handle_environment_variable(element: ET.Element, context: ParseContext): + """ + Handle tag. + Converts XML attributes (name, value) into a key-value dictionary entry. + """ + kwargs = {} + kwargs.update(resolve_parameters(element, context)) + + name, value = kwargs["name"], kwargs["value"] + + # Track environment variable usage + context.introspection.track_environment_variable(kwargs["name"], kwargs) + + return {"type": "EnvironmentVariable", "name": name, "value": value} + + +@register_handler("subst:env") +def handle_environment_variable_substitution(name: str, context: ParseContext) -> dict: + """ + Handle $(eval) substitution + """ + return {"type": "EnvironmentVariable", "name": name} diff --git a/parser/parser/xml/handlers/eval_handler.py b/parser/parser/xml/handlers/eval_handler.py new file mode 100644 index 0000000..5828f37 --- /dev/null +++ b/parser/parser/xml/handlers/eval_handler.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from parser.context import ParseContext +from parser.parser.registry import register_handler + + +@register_handler("subst:eval") +def handle_find_package_share(name: str, context: ParseContext) -> dict: + """ + Handle $(eval) substitution + """ + return f"${{PythonExpression:{str(name)}}}" diff --git a/parser/parser/xml/handlers/find_pkg_share_handler.py b/parser/parser/xml/handlers/find_pkg_share_handler.py new file mode 100644 index 0000000..53d29cf --- /dev/null +++ b/parser/parser/xml/handlers/find_pkg_share_handler.py @@ -0,0 +1,24 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from parser.context import ParseContext +from parser.parser.registry import register_handler + + +@register_handler("subst:find-pkg-share") +def handle_find_package_share(name: str, context: ParseContext) -> dict: + """ + Handle $(find-pkg-share) substitution + """ + return {"type": "FindPackageShare", "package": str(name)} diff --git a/parser/parser/xml/handlers/group_handler.py b/parser/parser/xml/handlers/group_handler.py new file mode 100644 index 0000000..0a419a4 --- /dev/null +++ b/parser/parser/xml/handlers/group_handler.py @@ -0,0 +1,66 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from xml.etree import ElementTree as ET + +from parser.context import ParseContext +from parser.parser.registry import register_handler +from parser.parser.utils import flatten_once, group_entities_by_type +from parser.parser.xml.handlers.condition_handler import handle_condition +from parser.parser.xml.utils import resolve_children, resolve_parameters + + +@register_handler("group") +def handle_group(element: ET.Element, context: ParseContext) -> dict: + """ + Handle an XML tag. + Processes attributes and child tags (param, remap and env). + """ + kwargs = {} + kwargs.update(resolve_parameters(element, context)) + handle_condition(kwargs) + + # Resolve 'actions' under group + raw_expr = resolve_children(element, context) + resolved_flat = flatten_once(raw_expr) + + namespace = None + parameters = [] + actions = [] + for item in resolved_flat: + if isinstance(item, dict) and item.get("type") == "PushROSNamespace": + namespace = item.get("namespace") + context.push_namespace(namespace) + elif isinstance(item, dict) and item.get("type") == "SetParameter": + parameters.append({item.get("name"): item.get("value")}) + else: + actions.append(item) + + grouped = group_entities_by_type(actions) + + if namespace: + context.pop_namespace() + + result = { + "type": "GroupAction", + **kwargs, + "actions": grouped, + } + + if namespace: + result["namespace"] = namespace + if parameters: + result["parameters"] = parameters + + return result diff --git a/parser/parser/xml/handlers/include_handler.py b/parser/parser/xml/handlers/include_handler.py new file mode 100644 index 0000000..84f0462 --- /dev/null +++ b/parser/parser/xml/handlers/include_handler.py @@ -0,0 +1,53 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from xml.etree import ElementTree as ET + +from parser.context import ParseContext +from parser.parser.registry import register_handler +from parser.parser.xml.handlers.condition_handler import handle_condition +from parser.parser.xml.utils import resolve_children, resolve_parameters + + +@register_handler("include") +def handle_include(element: ET.Element, context: ParseContext) -> dict: + """ + Handle an XML tag. + Processes attributes and child tags (arg). + """ + kwargs = {} + kwargs.update(resolve_parameters(element, context)) + handle_condition(kwargs) + + launch_source = kwargs.get("file") + condition = kwargs.get("condition", None) + + # Extract launch arguments + launch_args = {} + children = resolve_children(element, context) + for child in children: + if child["type"] == "LaunchArguments": + launch_args.update(child["value"]) + + result = { + "type": "IncludeLaunchDescription", + "launch_description_source": launch_source, + "launch_arguments": launch_args, + "included": {}, + } + + if condition: + result["condition"] = condition + + return result diff --git a/parser/parser/xml/handlers/node_handler.py b/parser/parser/xml/handlers/node_handler.py new file mode 100644 index 0000000..d074a54 --- /dev/null +++ b/parser/parser/xml/handlers/node_handler.py @@ -0,0 +1,62 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from xml.etree import ElementTree as ET + +from parser.context import ParseContext +from parser.parser.registry import register_handler +from parser.parser.xml.handlers.condition_handler import handle_condition +from parser.parser.xml.utils import normalize_keys, resolve_children, resolve_parameters + + +@register_handler("node") +def handle_node(element: ET.Element, context: ParseContext) -> dict: + """ + Handle an XML tag. + Processes attributes and child tags (param, remap and env). + """ + kwargs = {} + kwargs.update(resolve_parameters(element, context)) + handle_condition(kwargs) + + # Resolve remapping and parameters + remappings = [] + parameters = {} + environment_vars = {} + children = resolve_children(element, context) + for child in children: + if child["type"] == "Remapping": + remappings.append(child["value"]) + elif child["type"] == "SetParameter": + parameters.update({child["name"]: child["value"]}) + elif child["type"] == "EnvironmentVariable": + environment_vars.update({child["name"]: child["value"]}) + + if len(remappings) > 0: + kwargs["remappings"] = remappings + if len(parameters) > 0: + kwargs["parameters"] = parameters + if len(environment_vars) > 0: + kwargs["env"] = environment_vars + + # Resolve namespace + if "namespace" not in kwargs: + ns = context.current_namespace() + if ns: + kwargs["namespace"] = ns + + norm_kwargs = normalize_keys( + kwargs, {"pkg": "package", "exec": "executable"} + ) + return {"type": "Node", **norm_kwargs} diff --git a/parser/parser/xml/handlers/param_handler.py b/parser/parser/xml/handlers/param_handler.py new file mode 100644 index 0000000..35b0774 --- /dev/null +++ b/parser/parser/xml/handlers/param_handler.py @@ -0,0 +1,32 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from xml.etree import ElementTree as ET + +from parser.context import ParseContext +from parser.parser.registry import register_handler +from parser.parser.xml.utils import resolve_parameters + + +@register_handler("param") +def handle_param(element: ET.Element, context: ParseContext) -> dict: + """ + Handle tag. + Converts XML attributes (name, value) into a key-value dictionary entry. + """ + kwargs = {} + kwargs.update(resolve_parameters(element, context)) + + name, value = kwargs["name"], kwargs["value"] + return {"type": "SetParameter", "name": name, "value": value} diff --git a/parser/parser/xml/handlers/push_ros_namespace_handler.py b/parser/parser/xml/handlers/push_ros_namespace_handler.py new file mode 100644 index 0000000..16700e9 --- /dev/null +++ b/parser/parser/xml/handlers/push_ros_namespace_handler.py @@ -0,0 +1,32 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from xml.etree import ElementTree as ET + +from parser.context import ParseContext +from parser.parser.registry import register_handler +from parser.parser.xml.utils import resolve_parameters + + +@register_handler("push-ros-namespace") +def handle_node(element: ET.Element, context: ParseContext) -> dict: + """ + Handle an XML tag. + Processes attributes and child tags (param, remap and env). + """ + kwargs = {} + kwargs.update(resolve_parameters(element, context)) + + ns = kwargs.get("namespace") + return {"type": "PushROSNamespace", "namespace": ns} diff --git a/parser/parser/xml/handlers/remap_handler.py b/parser/parser/xml/handlers/remap_handler.py new file mode 100644 index 0000000..4dfab96 --- /dev/null +++ b/parser/parser/xml/handlers/remap_handler.py @@ -0,0 +1,31 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from xml.etree import ElementTree as ET + +from parser.context import ParseContext +from parser.parser.registry import register_handler +from parser.parser.xml.utils import resolve_parameters + + +@register_handler("remap") +def handle_remap(element: ET.Element, context: ParseContext) -> dict: + """ + Handle an tag. + Converts into ["a", "b"] + """ + kwargs = {} + kwargs.update(resolve_parameters(element, context)) + + return {"type": "Remapping", "value": [kwargs["from"], kwargs["to"]]} diff --git a/parser/parser/xml/handlers/var_handler.py b/parser/parser/xml/handlers/var_handler.py new file mode 100644 index 0000000..ecb1282 --- /dev/null +++ b/parser/parser/xml/handlers/var_handler.py @@ -0,0 +1,25 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from parser.context import ParseContext +from parser.parser.registry import register_handler + + +@register_handler("subst:var") +def handle_var(name: str, context: ParseContext) -> dict: + """ + Handle $(var) substitution + """ + context.introspection.track_launch_config_usage(name) + return {"type": "LaunchConfiguration", "name": name} diff --git a/parser/parser/loader.py b/parser/parser/xml/loader.py similarity index 81% rename from parser/parser/loader.py rename to parser/parser/xml/loader.py index 4e49c1d..9d616db 100644 --- a/parser/parser/loader.py +++ b/parser/parser/xml/loader.py @@ -21,8 +21,8 @@ def register_builtin_handlers(): """ Auto import all modules in parsers.handlers to trigger @register_handler decorators. """ - import parser.parser.handlers + import parser.parser.xml.handlers - package_dir = os.path.dirname(parser.parser.handlers.__file__) + package_dir = os.path.dirname(parser.parser.xml.handlers.__file__) for _, module_name, _ in pkgutil.iter_modules([package_dir]): - importlib.import_module(f"parser.parser.handlers.{module_name}") + importlib.import_module(f"parser.parser.xml.handlers.{module_name}") diff --git a/parser/parser/xml/utils.py b/parser/parser/xml/utils.py new file mode 100644 index 0000000..25832b5 --- /dev/null +++ b/parser/parser/xml/utils.py @@ -0,0 +1,95 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import re +from xml.etree import ElementTree as ET + +from parser.context import ParseContext +from parser.parser.postprocessing import simplify_launch_configurations + + +def strip_ns(tag: str) -> str: + return tag.split('}')[-1] + +def normalize_keys(attrs: dict, key_map: dict) -> dict: + """ + Normalize XML attribute keys to unified schema keys. + """ + normalized = {} + for key, value in attrs.items(): + norm_key = key_map.get(key, key) + normalized[norm_key] = value + return normalized + +def resolve_parameters(element: ET.Element, context: ParseContext): + """ + Process the parameter values from given XML tag. + """ + kwargs = {} + for key, value in element.attrib.items(): + kwargs[key] = _process_value(value, context) + + return kwargs + +def resolve_children(element: ET.Element, context: ParseContext): + """ + Recursively resolve children of a launch XML element. + Returns a list of accumulated resolved children. + """ + from parser.parser.xml.dispatcher import dispatch_element + + results = [] + for child in element: + parsed = dispatch_element(child, context) + if not parsed: + continue + results.append(parsed) + + return results + +def _process_value(value: str, context: ParseContext): + """ + Process an attribute or text value. + If it contains $(), delegate to substitution handlers. + Otherwise, return as plain string. + """ + from parser.parser.xml.dispatcher import dispatch_substitution + SUBST_PATTERN = re.compile(r"\$\(([^)]+)\)") + + if value is None: + return None + value = value.strip() + if not value: + return value + + out = [] + last = 0 + for match in SUBST_PATTERN.finditer(value): + start, end = match.span() + if start > last: + out.append(value[last:start]) + expr = match.group(1).strip() + subst = dispatch_substitution(expr, context) + out.append(simplify_launch_configurations(subst)) + last = end + + if last < len(value): + out.append(value[last:]) + + return "".join(out) + + if value.startswith("$(") and value.endswith(")"): + expr = value[2:-1] + return dispatch_substitution(expr, context) + return value \ No newline at end of file diff --git a/parser/resolution/resolvers/call.py b/parser/resolution/resolvers/call.py index 4c6657c..fba5751 100644 --- a/parser/resolution/resolvers/call.py +++ b/parser/resolution/resolvers/call.py @@ -15,8 +15,8 @@ import ast import warnings -from parser.parser.dispatcher import dispatch_call from parser.parser.postprocessing import simplify_launch_configurations +from parser.parser.python.dispatcher import dispatch_call from parser.resolution.resolution_registry import register_resolver diff --git a/parser/tests/output_helper_script.py b/parser/tests/output_helper_script.py index f071448..8702169 100644 --- a/parser/tests/output_helper_script.py +++ b/parser/tests/output_helper_script.py @@ -25,7 +25,7 @@ load_user_handlers_from_directory(PLUGIN_DIR) for fname in os.listdir(INPUT_DIR): - if fname.endswith(".py"): + if fname.endswith(".py") or fname.endswith('.xml'): input_path = os.path.join(INPUT_DIR, fname) result = parse_and_format_launch_file(input_path) diff --git a/parser/tests/real_cases/expected_outputs/autoware.launch.xml.json b/parser/tests/real_cases/expected_outputs/autoware.launch.xml.json new file mode 100644 index 0000000..5057276 --- /dev/null +++ b/parser/tests/real_cases/expected_outputs/autoware.launch.xml.json @@ -0,0 +1,506 @@ +{ + "arguments": [ + { + "name": "vehicle_model", + "default_value": "sample_vehicle", + "description": "vehicle model name" + }, + { + "name": "sensor_model", + "default_value": "sample_sensor_kit", + "description": "sensor model name" + }, + { + "name": "pointcloud_container_name", + "default_value": "pointcloud_container" + }, + { + "name": "data_path", + "default_value": "${EnvironmentVariable:HOME}/autoware_data", + "description": "packages data and artifacts directory path" + }, + { + "name": "planning_module_preset", + "default_value": "default", + "description": "planning module preset" + }, + { + "name": "control_module_preset", + "default_value": "default", + "description": "control module preset" + }, + { + "name": "launch_vehicle", + "default_value": "true", + "description": "launch vehicle" + }, + { + "name": "launch_system", + "default_value": "true", + "description": "launch system" + }, + { + "name": "launch_map", + "default_value": "true", + "description": "launch map" + }, + { + "name": "launch_sensing", + "default_value": "true", + "description": "launch sensing" + }, + { + "name": "launch_sensing_driver", + "default_value": "true", + "description": "launch sensing driver" + }, + { + "name": "launch_localization", + "default_value": "true", + "description": "launch localization" + }, + { + "name": "launch_perception", + "default_value": "true", + "description": "launch perception" + }, + { + "name": "launch_planning", + "default_value": "true", + "description": "launch planning" + }, + { + "name": "launch_control", + "default_value": "true", + "description": "launch control" + }, + { + "name": "launch_api", + "default_value": "true", + "description": "launch api" + }, + { + "name": "use_sim_time", + "default_value": "false", + "description": "use_sim_time" + }, + { + "name": "vehicle_id", + "default_value": "${EnvironmentVariable:VEHICLE_ID default}", + "description": "vehicle specific ID" + }, + { + "name": "launch_vehicle_interface", + "default_value": "true", + "description": "launch vehicle interface" + }, + { + "name": "check_external_emergency_heartbeat", + "default_value": "false" + }, + { + "name": "lanelet2_map_file", + "default_value": "lanelet2_map.osm", + "description": "lanelet2 map file name" + }, + { + "name": "pointcloud_map_file", + "default_value": "pointcloud_map.pcd", + "description": "pointcloud map file name" + }, + { + "name": "system_run_mode", + "default_value": "online", + "description": "run mode in system" + }, + { + "name": "launch_system_monitor", + "default_value": "true", + "description": "launch system monitor" + }, + { + "name": "launch_dummy_diag_publisher", + "default_value": "false", + "description": "launch dummy diag publisher" + }, + { + "name": "diagnostic_graph_aggregator_graph_path", + "default_value": "${FindPackageShare:autoware_launch}/config/system/diagnostics/autoware-main.yaml", + "description": "diagnostic graph config" + }, + { + "name": "rviz", + "default_value": "true", + "description": "launch rviz" + }, + { + "name": "rviz_config_name", + "default_value": "autoware.rviz", + "description": "rviz config name" + }, + { + "name": "rviz_config", + "default_value": "${FindPackageShare:autoware_launch}/rviz/${LaunchConfiguration:rviz_config_name}", + "description": "rviz config path" + }, + { + "name": "rviz_respawn", + "default_value": "true" + }, + { + "name": "perception_mode", + "default_value": "lidar", + "description": "select perception mode. camera_lidar_radar_fusion, camera_lidar_fusion, lidar_radar_fusion, lidar, radar" + }, + { + "name": "traffic_light_recognition/use_high_accuracy_detection", + "default_value": "true", + "description": "enable to use high accuracy detection for traffic light recognition" + }, + { + "name": "enable_all_modules_auto_mode", + "default_value": "false", + "description": "enable all module's auto mode" + }, + { + "name": "is_simulation", + "default_value": "false", + "description": "Autoware's behavior will change depending on whether this is a simulation or not." + } + ], + "groups": [ + { + "scoped": "false", + "actions": { + "includes": [ + { + "launch_description_source": "${FindPackageShare:autoware_global_parameter_loader}/launch/global_params.launch.py", + "launch_arguments": { + "use_sim_time": "${LaunchConfiguration:use_sim_time}", + "vehicle_model": "${LaunchConfiguration:vehicle_model}" + }, + "included": {} + } + ] + } + }, + { + "condition": "${IfCondition:${LaunchConfiguration:launch_vehicle}}", + "actions": { + "includes": [ + { + "launch_description_source": "${FindPackageShare:tier4_vehicle_launch}/launch/vehicle.launch.xml", + "launch_arguments": { + "vehicle_model": "${LaunchConfiguration:vehicle_model}", + "sensor_model": "${LaunchConfiguration:sensor_model}", + "vehicle_id": "${LaunchConfiguration:vehicle_id}", + "launch_vehicle_interface": "${LaunchConfiguration:launch_vehicle_interface}", + "config_dir": "${FindPackageShare:$(var sensor_model}_description)/config", + "raw_vehicle_cmd_converter_param_path": "${FindPackageShare:autoware_launch}/config/vehicle/raw_vehicle_cmd_converter/raw_vehicle_cmd_converter.param.yaml" + }, + "included": {} + } + ] + } + }, + { + "condition": "${IfCondition:${LaunchConfiguration:launch_system}}", + "actions": { + "includes": [ + { + "launch_description_source": "${FindPackageShare:autoware_launch}/launch/components/tier4_system_component.launch.xml", + "launch_arguments": {}, + "included": {} + } + ] + } + }, + { + "condition": "${IfCondition:${LaunchConfiguration:launch_map}}", + "actions": { + "includes": [ + { + "launch_description_source": "${FindPackageShare:autoware_launch}/launch/components/tier4_map_component.launch.xml", + "launch_arguments": {}, + "included": {} + } + ] + } + }, + { + "condition": "${IfCondition:${LaunchConfiguration:launch_sensing}}", + "actions": { + "includes": [ + { + "launch_description_source": "${FindPackageShare:autoware_launch}/launch/components/tier4_sensing_component.launch.xml", + "launch_arguments": { + "pointcloud_container_name": "${LaunchConfiguration:pointcloud_container_name}" + }, + "included": {} + } + ] + } + }, + { + "condition": "${IfCondition:${LaunchConfiguration:launch_localization}}", + "actions": { + "includes": [ + { + "launch_description_source": "${FindPackageShare:autoware_launch}/launch/components/tier4_localization_component.launch.xml", + "launch_arguments": {}, + "included": {} + } + ] + } + }, + { + "condition": "${IfCondition:${LaunchConfiguration:launch_perception}}", + "actions": { + "includes": [ + { + "launch_description_source": "${FindPackageShare:autoware_launch}/launch/components/tier4_perception_component.launch.xml", + "launch_arguments": { + "data_path": "${LaunchConfiguration:data_path}", + "pointcloud_container_name": "${LaunchConfiguration:pointcloud_container_name}" + }, + "included": {} + } + ] + } + }, + { + "condition": "${IfCondition:${LaunchConfiguration:launch_planning}}", + "actions": { + "includes": [ + { + "launch_description_source": "${FindPackageShare:autoware_launch}/launch/components/tier4_planning_component.launch.xml", + "launch_arguments": { + "module_preset": "${LaunchConfiguration:planning_module_preset}", + "enable_all_modules_auto_mode": "${LaunchConfiguration:enable_all_modules_auto_mode}", + "is_simulation": "${LaunchConfiguration:is_simulation}", + "use_sim_time": "${LaunchConfiguration:use_sim_time}", + "vehicle_model": "${LaunchConfiguration:vehicle_model}" + }, + "included": {} + } + ], + "nodes": [ + { + "package": "topic_tools", + "executable": "relay", + "name": "trajectory_relay", + "parameters": { + "input_topic": "/planning/trajectory", + "output_topic": "/planning/scenario_planning/trajectory" + } + } + ] + } + }, + { + "condition": "${IfCondition:${LaunchConfiguration:launch_control}}", + "actions": { + "includes": [ + { + "launch_description_source": "${FindPackageShare:autoware_launch}/launch/components/tier4_control_component.launch.xml", + "launch_arguments": { + "module_preset": "${LaunchConfiguration:control_module_preset}", + "use_sim_time": "${LaunchConfiguration:use_sim_time}", + "vehicle_model": "${LaunchConfiguration:vehicle_model}" + }, + "included": {} + } + ] + } + }, + { + "condition": "${IfCondition:${LaunchConfiguration:launch_api}}", + "actions": { + "includes": [ + { + "launch_description_source": "${FindPackageShare:autoware_launch}/launch/components/tier4_autoware_api_component.launch.xml", + "launch_arguments": { + "use_sim_time": "${LaunchConfiguration:use_sim_time}", + "vehicle_model": "${LaunchConfiguration:vehicle_model}" + }, + "included": {} + } + ] + } + }, + { + "actions": { + "nodes": [ + { + "package": "rviz2", + "executable": "rviz2", + "name": "rviz2", + "output": "screen", + "args": "-d ${LaunchConfiguration:rviz_config} -s ${FindPackageShare:autoware_launch}/rviz/image/autoware.png", + "respawn": "${LaunchConfiguration:rviz_respawn}", + "condition": "${IfCondition:${LaunchConfiguration:rviz}}", + "env": { + "QT_QPA_PLATFORMTHEME": "qt5ct" + } + } + ] + } + } + ], + "includes": [ + { + "launch_description_source": "${FindPackageShare:autoware_launch}/launch/pointcloud_container.launch.py", + "launch_arguments": { + "use_multithread": "true", + "container_name": "${LaunchConfiguration:pointcloud_container_name}" + }, + "included": {} + } + ], + "launch_argument_usages": [ + { + "argument": "rviz_config_name", + "path": "arguments[28].default_value" + }, + { + "argument": "pointcloud_container_name", + "path": "includes[0].launch_arguments.container_name" + }, + { + "argument": "use_sim_time", + "path": "groups[0].actions.includes[0].launch_arguments.use_sim_time" + }, + { + "argument": "vehicle_model", + "path": "groups[0].actions.includes[0].launch_arguments.vehicle_model" + }, + { + "argument": "launch_vehicle", + "path": "groups[1].condition" + }, + { + "argument": "vehicle_model", + "path": "groups[1].actions.includes[0].launch_arguments.vehicle_model" + }, + { + "argument": "sensor_model", + "path": "groups[1].actions.includes[0].launch_arguments.sensor_model" + }, + { + "argument": "vehicle_id", + "path": "groups[1].actions.includes[0].launch_arguments.vehicle_id" + }, + { + "argument": "launch_vehicle_interface", + "path": "groups[1].actions.includes[0].launch_arguments.launch_vehicle_interface" + }, + { + "argument": "launch_system", + "path": "groups[2].condition" + }, + { + "argument": "launch_map", + "path": "groups[3].condition" + }, + { + "argument": "launch_sensing", + "path": "groups[4].condition" + }, + { + "argument": "pointcloud_container_name", + "path": "groups[4].actions.includes[0].launch_arguments.pointcloud_container_name" + }, + { + "argument": "launch_localization", + "path": "groups[5].condition" + }, + { + "argument": "launch_perception", + "path": "groups[6].condition" + }, + { + "argument": "data_path", + "path": "groups[6].actions.includes[0].launch_arguments.data_path" + }, + { + "argument": "pointcloud_container_name", + "path": "groups[6].actions.includes[0].launch_arguments.pointcloud_container_name" + }, + { + "argument": "launch_planning", + "path": "groups[7].condition" + }, + { + "argument": "planning_module_preset", + "path": "groups[7].actions.includes[0].launch_arguments.module_preset" + }, + { + "argument": "enable_all_modules_auto_mode", + "path": "groups[7].actions.includes[0].launch_arguments.enable_all_modules_auto_mode" + }, + { + "argument": "is_simulation", + "path": "groups[7].actions.includes[0].launch_arguments.is_simulation" + }, + { + "argument": "use_sim_time", + "path": "groups[7].actions.includes[0].launch_arguments.use_sim_time" + }, + { + "argument": "vehicle_model", + "path": "groups[7].actions.includes[0].launch_arguments.vehicle_model" + }, + { + "argument": "launch_control", + "path": "groups[8].condition" + }, + { + "argument": "control_module_preset", + "path": "groups[8].actions.includes[0].launch_arguments.module_preset" + }, + { + "argument": "use_sim_time", + "path": "groups[8].actions.includes[0].launch_arguments.use_sim_time" + }, + { + "argument": "vehicle_model", + "path": "groups[8].actions.includes[0].launch_arguments.vehicle_model" + }, + { + "argument": "launch_api", + "path": "groups[9].condition" + }, + { + "argument": "use_sim_time", + "path": "groups[9].actions.includes[0].launch_arguments.use_sim_time" + }, + { + "argument": "vehicle_model", + "path": "groups[9].actions.includes[0].launch_arguments.vehicle_model" + }, + { + "argument": "rviz_config", + "path": "groups[10].actions.nodes[0].args" + }, + { + "argument": "rviz_respawn", + "path": "groups[10].actions.nodes[0].respawn" + }, + { + "argument": "rviz", + "path": "groups[10].actions.nodes[0].condition" + } + ], + "environment_variables": [ + { + "name": "QT_QPA_PLATFORMTHEME", + "value": "qt5ct" + } + ], + "environment_variable_usages": [ + { + "argument": "HOME", + "path": "arguments[3].default_value" + } + ] +} \ No newline at end of file diff --git a/parser/tests/real_cases/launch_files/autoware.launch.xml b/parser/tests/real_cases/launch_files/autoware.launch.xml new file mode 100644 index 0000000..538c430 --- /dev/null +++ b/parser/tests/real_cases/launch_files/autoware.launch.xml @@ -0,0 +1,158 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/parser/tests/test_cases/composable_node_tests.yaml b/parser/tests/test_cases/python/composable_node_tests.yaml similarity index 100% rename from parser/tests/test_cases/composable_node_tests.yaml rename to parser/tests/test_cases/python/composable_node_tests.yaml diff --git a/parser/tests/test_cases/condition_tests.yaml b/parser/tests/test_cases/python/condition_tests.yaml similarity index 100% rename from parser/tests/test_cases/condition_tests.yaml rename to parser/tests/test_cases/python/condition_tests.yaml diff --git a/parser/tests/test_cases/custom_handlers_tests.yaml b/parser/tests/test_cases/python/custom_handlers_tests.yaml similarity index 100% rename from parser/tests/test_cases/custom_handlers_tests.yaml rename to parser/tests/test_cases/python/custom_handlers_tests.yaml diff --git a/parser/tests/test_cases/event_handler_tests.yaml b/parser/tests/test_cases/python/event_handler_tests.yaml similarity index 100% rename from parser/tests/test_cases/event_handler_tests.yaml rename to parser/tests/test_cases/python/event_handler_tests.yaml diff --git a/parser/tests/test_cases/group_action_tests.yaml b/parser/tests/test_cases/python/group_action_tests.yaml similarity index 100% rename from parser/tests/test_cases/group_action_tests.yaml rename to parser/tests/test_cases/python/group_action_tests.yaml diff --git a/parser/tests/test_cases/include_launch_tests.yaml b/parser/tests/test_cases/python/include_launch_tests.yaml similarity index 100% rename from parser/tests/test_cases/include_launch_tests.yaml rename to parser/tests/test_cases/python/include_launch_tests.yaml diff --git a/parser/tests/test_cases/launch_config_tests.yaml b/parser/tests/test_cases/python/launch_config_tests.yaml similarity index 100% rename from parser/tests/test_cases/launch_config_tests.yaml rename to parser/tests/test_cases/python/launch_config_tests.yaml diff --git a/parser/tests/test_cases/node_tests.yaml b/parser/tests/test_cases/python/node_tests.yaml similarity index 86% rename from parser/tests/test_cases/node_tests.yaml rename to parser/tests/test_cases/python/node_tests.yaml index 05237cc..fd9617a 100644 --- a/parser/tests/test_cases/node_tests.yaml +++ b/parser/tests/test_cases/python/node_tests.yaml @@ -114,3 +114,24 @@ tests: executable: robot_node parameters: - robot_description: "${Command:['xacro', 'robot.urdf.xacro']}" + + - name: node_with_environment_variable + input: | + from launch import LaunchDescription + from launch_ros.actions import Node + + def generate_launch_description(): + return LaunchDescription([ + Node( + package="demo_pkg", + executable="talker", + env={"RCUTILS_LOG_LEVEL": "debug", "CUSTOM": "42"} + ) + ]) + expected: + nodes: + - package: demo_pkg + executable: talker + env: + RCUTILS_LOG_LEVEL: debug + CUSTOM: "42" \ No newline at end of file diff --git a/parser/tests/test_cases/opaque_function_tests.yaml b/parser/tests/test_cases/python/opaque_function_tests.yaml similarity index 100% rename from parser/tests/test_cases/opaque_function_tests.yaml rename to parser/tests/test_cases/python/opaque_function_tests.yaml diff --git a/parser/tests/test_cases/recursive_include_tests.yaml b/parser/tests/test_cases/python/recursive_include_tests.yaml similarity index 100% rename from parser/tests/test_cases/recursive_include_tests.yaml rename to parser/tests/test_cases/python/recursive_include_tests.yaml diff --git a/parser/tests/test_cases/xml/condition_tests.yaml b/parser/tests/test_cases/xml/condition_tests.yaml new file mode 100644 index 0000000..0d65da5 --- /dev/null +++ b/parser/tests/test_cases/xml/condition_tests.yaml @@ -0,0 +1,54 @@ +tests: + - name: node_with_if_condition_xml + input: | + + + + + + expected: + arguments: + - name: enable_talker + nodes: + - package: demo_pkg + executable: talker + condition: ${IfCondition:${LaunchConfiguration:enable_talker}} + launch_argument_usages: + - argument: enable_talker + path: nodes[0].condition + undeclared_launch_configurations: [] + + - name: node_with_unless_condition_constant_xml + input: | + + + + + expected: + nodes: + - package: demo_pkg + executable: listener + condition: ${UnlessCondition:${PythonExpression:False}} + + - name: group_with_condition_and_node_xml + input: | + + + + + + + + expected: + arguments: + - name: launch_group + groups: + - condition: ${IfCondition:${LaunchConfiguration:launch_group}} + actions: + nodes: + - package: demo_pkg + executable: child_node + launch_argument_usages: + - argument: launch_group + path: groups[0].condition + undeclared_launch_configurations: [] \ No newline at end of file diff --git a/parser/tests/test_cases/xml/group_action_tests.yaml b/parser/tests/test_cases/xml/group_action_tests.yaml new file mode 100644 index 0000000..3d98fcd --- /dev/null +++ b/parser/tests/test_cases/xml/group_action_tests.yaml @@ -0,0 +1,77 @@ +tests: + - name: basic_group_with_node_xml + description: A group with a single node child + input: | + + + + + + + expected: + groups: + - actions: + nodes: + - package: demo + executable: basic + + - name: group_with_namespace_xml + description: Group with PushROSNamespace + input: | + + + + + + + + expected: + groups: + - namespace: robot1 + actions: + nodes: + - package: demo + executable: ns_node + + - name: group_with_multiple_nodes_xml + description: Group with mutiple child nodes + input: | + + + + + + + + expected: + groups: + - actions: + nodes: + - package: demo + executable: a + - package: demo + executable: b + + - name: nested_groups_xml + description: Nested groups with namespace and node + input: | + + + + + + + + + + + expected: + groups: + - namespace: outer_ns + actions: + groups: + - namespace: inner_ns + actions: + nodes: + - package: demo + executable: deep \ No newline at end of file diff --git a/parser/tests/test_cases/xml/include_launch_tests.yaml b/parser/tests/test_cases/xml/include_launch_tests.yaml new file mode 100644 index 0000000..bc9f211 --- /dev/null +++ b/parser/tests/test_cases/xml/include_launch_tests.yaml @@ -0,0 +1,50 @@ +tests: + - name: include_literal_file_xml + description: with direct string file path + input: | + + + + + expected: + includes: + - launch_description_source: sub_launch.py + launch_arguments: {} + included: {} + + - name: include_with_launch_arguments_xml + description: with forwarded arguments using LaunchConfiguration + input: | + + + + + + + + expected: + arguments: + - name: map_file + default_value: map.yaml + includes: + - launch_description_source: mapper_launch.py + launch_arguments: + map: "${LaunchConfiguration:map_file}" + included: {} + launch_argument_usages: + - argument: map_file + path: includes[0].launch_arguments.map + undeclared_launch_configurations: [] + + - name: include_with_find_pkg_share_xml + description: Uses find-pkg-share to locate a launch file + input: | + + + + + expected: + includes: + - launch_description_source: "${FindPackageShare:demo_pkg}/launch/sub_launch.py" + launch_arguments: {} + included: {} \ No newline at end of file diff --git a/parser/tests/test_cases/xml/launch_config_tests.yaml b/parser/tests/test_cases/xml/launch_config_tests.yaml new file mode 100644 index 0000000..4eca65c --- /dev/null +++ b/parser/tests/test_cases/xml/launch_config_tests.yaml @@ -0,0 +1,152 @@ +tests: + - name: declared_argument_basic_xml + description: Declare an argument only + input: | + + + + + expected: + arguments: + - name: robot_name + default_value: turtle + description: The robot's name + launch_argument_usages: [] + undeclared_launch_configurations: [] + + - name: node_parameter_usage_xml + description: Use LaunchConfiguration in Node parameter + input: | + + + + + + + + expected: + arguments: + - name: use_sim_time + default_value: 'true' + nodes: + - package: demo + executable: run + parameters: + use_sim_time: "${LaunchConfiguration:use_sim_time}" + launch_argument_usages: + - argument: use_sim_time + path: nodes[0].parameters.use_sim_time + undeclared_launch_configurations: [] + + - name: include_with_launch_arguments_xml + description: Use LaunchConfiguration in Include arguments + input: | + + + + + + + + expected: + arguments: + - name: map_file + default_value: maps/map.yaml + includes: + - launch_description_source: sub_launch.py + launch_arguments: + map: "${LaunchConfiguration:map_file}" + included: {} + launch_argument_usages: + - argument: map_file + path: includes[0].launch_arguments.map + undeclared_launch_configurations: [] + + - name: push_ros_namespace_from_config_xml + description: LaunchConfiguration used in PushROSNamespace inside Group + input: | + + + + + + + + + expected: + arguments: + - name: robot_namespace + default_value: robot1 + groups: + - namespace: "${LaunchConfiguration:robot_namespace}" + actions: + nodes: + - package: demo + executable: x + launch_argument_usages: + - argument: robot_namespace + path: groups[0].namespace + undeclared_launch_configurations: [] + + - name: set_parameter_with_config_xml + description: Use LaunchConfiguration in SetParameter value + input: | + + + + + + expected: + arguments: + - name: mode + default_value: auto + parameters: + - name: mode + value: "${LaunchConfiguration:mode}" + launch_argument_usages: + - argument: mode + path: parameters[0].value + undeclared_launch_configurations: [] + + - name: condition_with_config_xml + description: Use LaunchConfiguration inside IfCondition + input: | + + + + + + expected: + arguments: + - name: enable + default_value: 'true' + nodes: + - package: demo + executable: cond_node + condition: "${IfCondition:${LaunchConfiguration:enable}}" + launch_argument_usages: + - argument: enable + path: nodes[0].condition + undeclared_launch_configurations: [] + + - name: undeclared_config_error_xml + description: LaunchConfiguration used without declaration + input: | + + + + + + + expected: + arguments: [] + nodes: + - package: demo + executable: missing + parameters: + param: "${LaunchConfiguration:not_declared}" + launch_argument_usages: + - argument: not_declared + path: nodes[0].parameters.param + undeclared_launch_configurations: + - not_declared \ No newline at end of file diff --git a/parser/tests/test_cases/xml/node_tests.yaml b/parser/tests/test_cases/xml/node_tests.yaml new file mode 100644 index 0000000..9464005 --- /dev/null +++ b/parser/tests/test_cases/xml/node_tests.yaml @@ -0,0 +1,70 @@ +tests: + - name: minimal_node_xml + description: Node with just package and executable (XML) + input: | + + + + + expected: + nodes: + - package: my_pkg + executable: my_node + + - name: node_with_fields_xml + description: Node with name, output, parameters and remapping (XML) + input: | + + + + + + + + + + expected: + nodes: + - package: demo_nodes + executable: talker + name: talker_node + output: screen + parameters: + use_sim_time: "${LaunchConfiguration:use_sim_time}" + use_remapping: "true" + remappings: + - ['/input', '/robot1/input'] + - ['/output', '/robot1/output'] + + - name: multiple_nodes_xml + description: Two nodes declared directly under + input: | + + + + + + expected: + nodes: + - package: demo + executable: a + - package: demo + executable: b + + - name: node_with_environment_variable_xml + description: Node with Environment Variables + input: | + + + + + + + + expected: + nodes: + - package: demo_pkg + executable: talker + env: + RCUTILS_LOG_LEVEL: debug + CUSTOM: "42" diff --git a/parser/tests/test_from_yaml.py b/parser/tests/test_from_yaml_python.py similarity index 65% rename from parser/tests/test_from_yaml.py rename to parser/tests/test_from_yaml_python.py index b50d7da..5afd603 100644 --- a/parser/tests/test_from_yaml.py +++ b/parser/tests/test_from_yaml_python.py @@ -21,15 +21,15 @@ ) -@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/node_tests.yaml")) +@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/python/node_tests.yaml")) def test_node_parsing(code, expected): - result = parse_launch_string(code) + result = parse_launch_string(code, suffix=".py") assert result.get("nodes") == expected.get("nodes") - -@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/launch_config_tests.yaml")) +@pytest.mark.parametrize("code,expected", + load_yaml_tests("test_cases/python/launch_config_tests.yaml")) def test_launch_configuration_parsing(code, expected): - result = parse_launch_string(code) + result = parse_launch_string(code, suffix=".py") for key in [ "nodes", "arguments", @@ -42,16 +42,18 @@ def test_launch_configuration_parsing(code, expected): assert result.get(key, []) == expected.get(key, []) -@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/group_action_tests.yaml")) +@pytest.mark.parametrize("code,expected", + load_yaml_tests("test_cases/python/group_action_tests.yaml")) def test_group_action_parsing(code, expected): - result = parse_launch_string(code) + result = parse_launch_string(code, suffix=".py") for key in ["nodes", "arguments", "includes", "groups", "launch_argument_usages"]: assert result.get(key, []) == expected.get(key, []) -@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/include_launch_tests.yaml")) +@pytest.mark.parametrize("code,expected", + load_yaml_tests("test_cases/python/include_launch_tests.yaml")) def test_include_launch_parsing(code, expected): - result = parse_launch_string(code) + result = parse_launch_string(code, suffix=".py") for key in [ "arguments", "includes", @@ -61,9 +63,10 @@ def test_include_launch_parsing(code, expected): assert result.get(key, []) == expected.get(key, []) -@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/opaque_function_tests.yaml")) +@pytest.mark.parametrize("code,expected", + load_yaml_tests("test_cases/python/opaque_function_tests.yaml")) def test_opaque_functions_parsing(code, expected): - result = parse_launch_string(code) + result = parse_launch_string(code, suffix=".py") for key in [ "arguments", "opaque_functions", @@ -73,9 +76,10 @@ def test_opaque_functions_parsing(code, expected): assert result.get(key, []) == expected.get(key, []) -@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/condition_tests.yaml")) +@pytest.mark.parametrize("code,expected", + load_yaml_tests("test_cases/python/condition_tests.yaml")) def test_conditions_parsing(code, expected): - result = parse_launch_string(code) + result = parse_launch_string(code, suffix=".py") for key in [ "arguments", "nodes", @@ -86,9 +90,10 @@ def test_conditions_parsing(code, expected): assert result.get(key, []) == expected.get(key, []) -@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/composable_node_tests.yaml")) +@pytest.mark.parametrize("code,expected", + load_yaml_tests("test_cases/python/composable_node_tests.yaml")) def test_composable_nodes_parsing(code, expected): - result = parse_launch_string(code) + result = parse_launch_string(code, suffix=".py") for key in [ "arguments", "composable_nodes", @@ -99,18 +104,19 @@ def test_composable_nodes_parsing(code, expected): assert result.get(key, []) == expected.get(key, []) -@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/event_handler_tests.yaml")) +@pytest.mark.parametrize("code,expected", + load_yaml_tests("test_cases/python/event_handler_tests.yaml")) def test_event_handlers_parsing(code, expected): - result = parse_launch_string(code) + result = parse_launch_string(code, suffix=".py") for key in ["nodes", "event_handlers"]: assert result.get(key, []) == expected.get(key, []) @pytest.mark.parametrize( "code,expected", - load_custom_handler_tests("test_cases/custom_handlers_tests.yaml", "test_handlers"), + load_custom_handler_tests("test_cases/python/custom_handlers_tests.yaml", "test_handlers"), ) def test_custom_handlers_parsing(code, expected): - result = parse_launch_string(code) + result = parse_launch_string(code, suffix=".py") for key in ["arguments", "nodes", "launch_argument_usages", "custom_components"]: assert result.get(key, []) == expected.get(key, []) diff --git a/parser/tests/test_from_yaml_xml.py b/parser/tests/test_from_yaml_xml.py new file mode 100644 index 0000000..4a57b3a --- /dev/null +++ b/parser/tests/test_from_yaml_xml.py @@ -0,0 +1,72 @@ +# Copyright (c) 2025 Kodo Robotics +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +import pytest + +from parser.tests.test_helpers import load_yaml_tests, parse_launch_string + + +@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/xml/node_tests.yaml")) +def test_node_parsing(code, expected): + result = parse_launch_string(code, suffix=".xml") + assert result.get("nodes") == expected.get("nodes") + +@pytest.mark.parametrize("code,expected", + load_yaml_tests("test_cases/xml/launch_config_tests.yaml")) +def test_launch_configuration_parsing(code, expected): + result = parse_launch_string(code, suffix=".xml") + print(result) + print(expected) + for key in [ + "nodes", + "arguments", + "includes", + "groups", + "parameters", + "launch_argument_usages", + "undeclared_launch_configurations", + ]: + assert result.get(key, []) == expected.get(key, []) + +@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/xml/group_action_tests.yaml")) +def test_group_action_parsing(code, expected): + result = parse_launch_string(code, suffix=".xml") + for key in ["nodes", "arguments", "includes", "groups", "launch_argument_usages"]: + assert result.get(key, []) == expected.get(key, []) + +@pytest.mark.parametrize("code,expected", + load_yaml_tests("test_cases/xml/include_launch_tests.yaml")) +def test_include_launch_parsing(code, expected): + result = parse_launch_string(code, suffix=".xml") + print(result) + print(expected) + for key in [ + "arguments", + "includes", + "launch_argument_usages", + "undeclared_launch_configurations", + ]: + assert result.get(key, []) == expected.get(key, []) + +@pytest.mark.parametrize("code,expected", load_yaml_tests("test_cases/xml/condition_tests.yaml")) +def test_conditions_parsing(code, expected): + result = parse_launch_string(code, suffix=".xml") + for key in [ + "arguments", + "nodes", + "groups", + "launch_argument_usages", + "undeclared_launch_configurations", + ]: + assert result.get(key, []) == expected.get(key, []) \ No newline at end of file diff --git a/parser/tests/test_handlers/custom_launch_thing_handler.py b/parser/tests/test_handlers/custom_launch_thing_handler.py index eae4cca..92f008d 100644 --- a/parser/tests/test_handlers/custom_launch_thing_handler.py +++ b/parser/tests/test_handlers/custom_launch_thing_handler.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -from parser.parser.user_handler import register_user_handler +from parser.parser.python.user_handler import register_user_handler @register_user_handler("MyCustomLaunchThing") diff --git a/parser/tests/test_handlers/my_launch_handler.py b/parser/tests/test_handlers/my_launch_handler.py index c057cbe..dce223a 100644 --- a/parser/tests/test_handlers/my_launch_handler.py +++ b/parser/tests/test_handlers/my_launch_handler.py @@ -13,7 +13,7 @@ # limitations under the License. from parser.parser.postprocessing import simplify_launch_configurations -from parser.parser.user_handler import register_user_handler +from parser.parser.python.user_handler import register_user_handler from parser.resolution.utils import resolve_call_signature diff --git a/parser/tests/test_helpers.py b/parser/tests/test_helpers.py index 3ebbd8d..5b7de88 100644 --- a/parser/tests/test_helpers.py +++ b/parser/tests/test_helpers.py @@ -40,8 +40,8 @@ def load_custom_handler_tests(file_path, handler_directory): return load_yaml_tests(file_path) -def parse_launch_string(code: str) -> dict: - with tempfile.NamedTemporaryFile("w", suffix=".py", delete=False) as tmp: +def parse_launch_string(code: str, suffix) -> dict: + with tempfile.NamedTemporaryFile("w", suffix=suffix, delete=False) as tmp: tmp.write(code) tmp.flush() return parse_and_format_launch_file(tmp.name) diff --git a/parser/tests/test_real_launch_files.py b/parser/tests/test_real_launch_files.py index 04a5d1f..fcc376c 100644 --- a/parser/tests/test_real_launch_files.py +++ b/parser/tests/test_real_launch_files.py @@ -26,7 +26,8 @@ PLUGIN_DIR = os.path.join(BASE_DIR, "../custom_handlers") -@pytest.mark.parametrize("filename", [f for f in os.listdir(INPUT_DIR) if f.endswith(".py")]) +@pytest.mark.parametrize("filename", [f for f in os.listdir(INPUT_DIR) + if f.endswith(".py") or f.endswith(".xml")]) def test_real_launch_file_snapshot(filename): input_path = os.path.join(INPUT_DIR, filename) output_path = os.path.join(OUTPUT_DIR, f"{filename}.json") diff --git a/webview/components/renderGroup.js b/webview/components/renderGroup.js index 2c4bbb0..785e64e 100644 --- a/webview/components/renderGroup.js +++ b/webview/components/renderGroup.js @@ -47,7 +47,8 @@ export function renderGroup(group, options = {}) { const metaSections = [ { key: 'namespace', icon: '🧭', label: 'Namespace', value: ns }, { key: 'condition', icon: '❓', label: 'Condition', value: group.condition }, - { key: 'parameters', icon: '⚙️', label: 'Params', value: group.parameters } + { key: 'parameters', icon: '⚙️', label: 'Params', value: group.parameters }, + { key: 'scoped', icon: '🌍', label: 'Scoped', value: group.scoped } ]; metaSections.forEach(({ key, icon, label, value }) => { diff --git a/webview/components/renderNode.js b/webview/components/renderNode.js index 62f8323..cf53415 100644 --- a/webview/components/renderNode.js +++ b/webview/components/renderNode.js @@ -44,17 +44,20 @@ function renderNode(node, options) { block.appendChild(renderSection('executable', '▶️', 'Executable', node.executable, renderOptions)); block.appendChild(renderSection('output', '🖥️', 'Output', node.output || '—', renderOptions)); - if (node.condition) { - block.appendChild(renderSection('condition', '❓', 'Condition', node.condition, renderOptions)); - } - - if (node.parameters?.length > 0) { - block.appendChild(renderSection('parameters', '⚙️', 'Params', node.parameters, renderOptions)); - } - - if (node.arguments?.length > 0) { - block.appendChild(renderSection('arguments', '💬', 'Args', node.arguments, renderOptions)); - } + // Render additional sections + const metaSections = [ + { key: 'condition', icon: '❓', label: 'Condition', value: node.condition }, + { key: 'parameters', icon: '⚙️', label: 'Params', value: node.parameters }, + { key: 'arguments', icon: '💬', label: 'Args', value: node.arguments }, + { key: 'env', icon: '🌍', label: 'Environment Variables', value: node.env } + ]; + + metaSections.forEach(({ key, icon, label, value }) => { + if (value) { + const section = renderSection(key, icon, label, value, renderOptions); + block.appendChild(section); + } + }); return block; } diff --git a/webview/tests/__screenshots__/interaction.spec.js-snapshots/autoware-launch-xml-json-final-linux.png b/webview/tests/__screenshots__/interaction.spec.js-snapshots/autoware-launch-xml-json-final-linux.png new file mode 100644 index 0000000..9691d83 Binary files /dev/null and b/webview/tests/__screenshots__/interaction.spec.js-snapshots/autoware-launch-xml-json-final-linux.png differ diff --git a/webview/tests/__screenshots__/visual.spec.js-snapshots/autoware-launch-xml-json-linux.png b/webview/tests/__screenshots__/visual.spec.js-snapshots/autoware-launch-xml-json-linux.png new file mode 100644 index 0000000..9f551b7 Binary files /dev/null and b/webview/tests/__screenshots__/visual.spec.js-snapshots/autoware-launch-xml-json-linux.png differ