Skip to content
Open
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
70 changes: 59 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ Read the [full documentation on Github pages](https://dbinfrago.github.io/capell

# Examples

## Import of ROS Messages
Import local ROS .msg files to Capella model layer's root data package:

```sh
python -m capella_ros_tools \
import \
capella-ros-tools import \
-i tests/data/data_model/example_msgs \
-m tests/data/empty_project_60 \
-l la \
Expand All @@ -32,32 +32,80 @@ import \
Import remote ROS .msg files to Capella model layer's root data package:

```sh
python -m capella_ros_tools \
import \
capella-ros-tools import \
-i git+https://github.com/DSD-DBS/dsd-ros-msg-definitions-oss \
-m tests/data/empty_project_60 \
-l la
```

Export local Capella model layer's root data package as ROS .msg files:
## Export of Capella Classes as ROS2 Messages
Please mind: If classes don't follow the ROS2 naming conventions, their names as well as property, enumeration value and
package names will be converted accordingly.
### Export by layer
Export local Capella model layer's root data package as ROS .msg files. All msg files will be exported in a single
package:

```sh
python -m capella_ros_tools \
export \
capella-ros-tools export \
-m tests/data/melody_model_60 \
-l la \
-o tests/data/melody_msgs
```

Export remote Capella model layer's root data package as ROS .msg files:
Export remote Capella model layer's root data package as ROS .msg files. All msg files will be exported in a single
package:

```sh
python -m capella_ros_tools \
export \
capella-ros-tools export \
-m git+https://github.com/DSD-DBS/coffee-machine \
-l sa \
-o tests/data/coffee_msgs
```
### Custom Export
Use the custom exporter using a config file:
````yaml
packages:
asdf: <capella_pkg_uuid>
built_ins:
ros_pkg: <capella_build_in_pkg_uuid>
custom_pkg:
"abc":
- <capella_cls1_uuid>
"xyz":
- <capella_cls2_uuid>
custom_types:
Bitset16: int16
Bitset32: int32
````
Comment on lines +65 to +78
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The world needs more TOML.

Advantages over YAML:

  • it's not YAML
  • tomllib is in stdlib since Python 3.11 (previously tomli on PyPI)
  • very simple and very explicit format
  • no significant whitespace, which is especially beneficial with the config templating feature

Disadvantages:

  • only 4 different types of strings instead of 9
  • can't easily introduce an ACE by accidentally using load() instead of safe_load()
````toml
[packages]
asdf = "<capella_pkg_uuid>"

[built_ins]
ros_pkg = "<capella_built_in_pkg_uuid>"

[custom_pkg]
abc = ["<capella_cls1_uuid>"]
xyz = [
  "<capella_cls2_uuid>",
  "<capella_cls3_uuid>",
]

[custom_types]
Bitset16 = "int16"
Bitset32 = "int32"
````

This will generate three ROS packages. Package `asdf` will contain all classes listed in `capella_pkg` and sub-packages.
Package `abc` will contain `capella_cls1` and `xyz` will contain `capella_cls2`. If those classes use other classes as
types in their properties, these classes will be pulled in. In the `built_ins` section data packages can be defined,
which can be considered as built-in. So in this example, classes which are located in `capella_build_in_pkg` will not
be pulled into the packages and will be considered as available. These classes will be referenced using `ros_pkg` as ROS
package name. In `custom_types` a mapping of Capella types to ROS types can be provided for custom capella types.
```sh
capella-ros-tools export \
-m tests/data/melody_model_60 \
-c config.yaml \
-o tests/data/melody_msgs
```
The config file may also be provided as a jinja2 template, which will be rendered to the yaml described above during the
run. The jinja2 template will be rendered with the model provided in variable `model`. This way complex configurations
stay maintainable:
````yaml
packages: {}
built_ins:
{% for pkg in model.search("DataPkg").by_name("ROS-Msgs").packages %}
{{pkg.name}}: {{pkg.uuid}}
{% endfor %}
custom_pkg:
"abc":
- <capella_cls1_uuid>
"xyz":
- <capella_cls2_uuid>
custom_types:
Bitset16: int16
Bitset32: int32
````

# Installation

Expand Down
86 changes: 75 additions & 11 deletions capella_ros_tools/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,11 @@ def cli() -> None:
type=str,
help="Regular expression to extract description from the file .",
)
@click.option(
"--dependency-json",
type=click.Path(path_type=pathlib.Path, dir_okay=False),
help="A path to a JSON containing dependencies which should be imported.",
)
def import_msgs(
*,
input: str,
Expand All @@ -93,6 +98,7 @@ def import_msgs(
output: pathlib.Path,
license_header: pathlib.Path | None,
description_regex: str | None,
dependency_json: pathlib.Path | None,
) -> None:
"""Import ROS messages into a Capella data package."""
if root:
Expand All @@ -108,7 +114,7 @@ def import_msgs(
params = {"types_parent_uuid": model.sa.data_package.uuid}

parsed = importer.Importer(
input, no_deps, license_header, description_regex
input, no_deps, license_header, description_regex, dependency_json
)
logger.info("Loaded %d packages", len(parsed.messages.packages))

Expand All @@ -129,18 +135,30 @@ def import_msgs(
type=cli_helpers.ModelCLI(),
required=True,
help="Path to the Capella model.",
envvar="CAPELLA_ROS_TOOLS_MODEL",
)
@click.option(
"-l",
"--layer",
type=click.Choice(["oa", "la", "sa", "pa"], case_sensitive=False),
help="The layer to export the model objects from.",
envvar="CAPELLA_ROS_TOOLS_LAYER",
)
@click.option(
"-r",
"--root",
type=click.UUID,
help="The UUID of the root package to import the messages from.",
envvar="CAPELLA_ROS_TOOLS_ROOT_PACKAGE",
)
@click.option(
"-c",
"--config",
type=click.Path(
path_type=pathlib.Path, file_okay=True, dir_okay=False, readable=True
),
help="Path to the configuration file.",
envvar="CAPELLA_ROS_TOOLS_EXPORT_CONFIG",
)
@click.option(
"-o",
Expand All @@ -149,21 +167,67 @@ def import_msgs(
default=pathlib.Path.cwd() / "data-package",
help="Output directory for the .msg files.",
)
def export_capella(
@click.option(
"--generate-cmake",
type=bool,
default=False,
is_flag=True,
help="Decide whether experimental cmake files should be generated.",
envvar="CAPELLA_ROS_TOOLS_GENERATE_CMAKE",
)
Comment on lines +170 to +177
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm a fan of short flag names. And short help texts. :)

Suggested change
@click.option(
"--generate-cmake",
type=bool,
default=False,
is_flag=True,
help="Decide whether experimental cmake files should be generated.",
envvar="CAPELLA_ROS_TOOLS_GENERATE_CMAKE",
)
@click.option(
"--cmake",
type=bool,
default=False,
is_flag=True,
help="Generate cmake files. Experimental.",
envvar="CAPELLA_ROS_TOOLS_GENERATE_CMAKE",
)

def export(
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Now that the function is called export (just like the click command), we no longer need to pass the command name explicitly in the outermost decorator.

-@cli.command("export")
+@cli.command()

*,
model: capellambse.MelodyModel,
layer: str,
root: uuid.UUID,
config: pathlib.Path | None,
layer: str | None,
root: str | None,
output: pathlib.Path,
generate_cmake: bool,
) -> None:
"""Export Capella data package to ROS messages."""
if root:
current_pkg = model.search("DataPkg").by_uuid(str(root))
elif layer:
current_pkg = getattr(model, layer).data_package
if config:
conf = exporter.load_config(config, model)
else:
raise click.UsageError("Either --root or --layer must be provided")

exporter.export(current_pkg, output) # type: ignore
if root:
root_package = model.search("DataPkg").by_uuid(str(root))
elif layer:
root_package = getattr(model, layer).data_package
else:
raise RuntimeError(
"Neither config nor root package nor layer specified."
)
Comment on lines +196 to +198
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This should be a click.UsageError, which has a much nicer UX. Compare these error messages:

RuntimeError
INFO:capellambse.cli_helpers:Loading model from [$WORKSPACE]/models/ife-demo
Traceback (most recent call last):
  File "", line 198, in _run_module_as_main
  File "", line 88, in _run_code
  File "[$WORKSPACE]/rostools/capella_ros_tools/__main__.py", line 234, in 
    cli()
    ~~~^^
  File "[$WORKSPACE]/rostools/.venv/lib/python3.13/site-packages/click/core.py", line 1462, in __call__
    return self.main(*args, **kwargs)
           ~~~~~~~~~^^^^^^^^^^^^^^^^^
  File "[$WORKSPACE]/rostools/.venv/lib/python3.13/site-packages/click/core.py", line 1383, in main
    rv = self.invoke(ctx)
  File "[$WORKSPACE]/rostools/.venv/lib/python3.13/site-packages/click/core.py", line 1850, in invoke
    return _process_result(sub_ctx.command.invoke(sub_ctx))
                           ~~~~~~~~~~~~~~~~~~~~~~^^^^^^^^^
  File "[$WORKSPACE]/rostools/.venv/lib/python3.13/site-packages/click/core.py", line 1246, in invoke
    return ctx.invoke(self.callback, **ctx.params)
           ~~~~~~~~~~^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "[$WORKSPACE]/rostools/.venv/lib/python3.13/site-packages/click/core.py", line 814, in invoke
    return callback(*args, **kwargs)
  File "[$WORKSPACE]/rostools/capella_ros_tools/__main__.py", line 196, in export
    raise RuntimeError(
        "Neither config nor root package nor layer specified."
    )
RuntimeError: Neither config nor root package nor layer specified.
UsageError
INFO:capellambse.cli_helpers:Loading model from [$WORKSPACE]/models/ife-demo
Usage: python -m capella_ros_tools export [OPTIONS]
Try 'python -m capella_ros_tools export --help' for help.
Error: Neither config nor root package nor layer specified.

IMO we should also use the parameter names in the error message:

Suggested change
raise RuntimeError(
"Neither config nor root package nor layer specified."
)
raise RuntimeError(
"One of --config=, --root= or --layer= is required."
)

if not isinstance(root_package, capellambse.model.ModelElement):
raise RuntimeError("Failed to find root package.")
conf = exporter.ExporterConfig(
packages={
exporter.RosExportHelper.make_snake_case(
root_package.name
): root_package.uuid
},
built_ins={},
custom_packages={},
custom_types={},
)
if conf.packages and (root or layer):
logger.warning(
"Your config has packages defined, will ignore root/layer for that reason."
)

_exporter = exporter.Exporter(
conf.packages,
conf.built_ins,
conf.custom_packages,
conf.custom_types,
model,
generate_cmake,
conf.pkg_postfix,
)
_exporter.export_ros_pkgs(
output,
conf.project_name,
conf.contact_email,
conf.maintainer,
)


if __name__ == "__main__":
Expand Down
13 changes: 9 additions & 4 deletions capella_ros_tools/data_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -229,6 +229,7 @@ def __eq__(self, other: object) -> bool:
@classmethod
def from_file(
cls,
pkg_name: str,
file: abc.AbstractFilePath | pathlib.Path,
license_header: str | None = None,
msg_description_regex: re.Pattern[str] | None = None,
Expand All @@ -238,11 +239,14 @@ def from_file(
msg_string = file.read_text()
license_header = license_header or LICENSE_HEADER
msg_string = msg_string.removeprefix(license_header)
return cls.from_string(msg_name, msg_string, msg_description_regex)
return cls.from_string(
pkg_name, msg_name, msg_string, msg_description_regex
)

@classmethod
def from_string( # noqa: C901 # FIXME too complex
cls,
pkg_name: str,
msg_name: str,
msg_string: str,
msg_description_regex: re.Pattern[str] | None = None,
Expand Down Expand Up @@ -346,14 +350,15 @@ def from_string( # noqa: C901 # FIXME too complex
if field.type.name == enum.literals[0].type.name:
matched_field = matched_field or field
if field.name.lower() == enum.name.lower():
enum.name = msg_name + matched_field.name.capitalize()
field.type.name = enum.name
field.type.package = msg_name
field.type.package = f"{pkg_name}.{msg_name}"
break
else:
if matched_field:
enum.name = msg_name + matched_field.name.capitalize()
matched_field.type.name = enum.name
matched_field.type.package = msg_name
matched_field.type.package = f"{pkg_name}.{msg_name}"

return msg

Expand Down Expand Up @@ -428,7 +433,7 @@ def from_msg_folder(
)
for msg_file in sorted(files, key=os.fspath):
msg_def = MessageDef.from_file(
msg_file, license_header, msg_description_regex
pkg_name, msg_file, license_header, msg_description_regex
)
out.messages.append(msg_def)
return out
90 changes: 90 additions & 0 deletions capella_ros_tools/export_templates/cmake_pkg_level.j2
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
{#
Copyright DB InfraGO AG and contributors
SPDX-License-Identifier: Apache-2.0
#}
# ##############################################################################
# Preamble
# ##############################################################################
cmake_minimum_required(VERSION 3.22)

# set cmake policy to use OLD behavior for FindPythonLibs / FindPythonInterp
# modules are deprecated since cmake 3.12, but rosidl_generator_py***() is still
# using them
# cmake_policy(SET CMP0148 OLD)

project({{pkg_name}} VERSION 1.0.0)

# ##############################################################################
# Project Wide Setup
# ##############################################################################
list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_LIST_DIR}/cmake")

set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_CXX_EXTENSIONS OFF)

# ##############################################################################
# Dependencies
# ##############################################################################
find_package(ament_cmake REQUIRED)

{% for dependency in dependencies -%}
find_package({{dependency}} REQUIRED)
{% endfor %}

# ##############################################################################
# Main targets
# ##############################################################################
# Targets
file(
GLOB_RECURSE MSG_FILES
LIST_DIRECTORIES false
RELATIVE "${CMAKE_CURRENT_SOURCE_DIR}"
"msg/*.msg"
)

# Note: It seems required that the rosidl interface name is the same as
# the project name
rosidl_generate_interfaces(
${PROJECT_NAME} ${MSG_FILES}
DEPENDENCIES
{% for dependency in dependencies -%}
{{ dependency }}
{% endfor %}
)
rosidl_get_typesupport_target(${PROJECT_NAME}_target_name ${PROJECT_NAME}
"rosidl_typesupport_cpp")

# Create wrapper target, which has the desired name and not generated ros name
# We use the suffix wrapper_target, because the project name is already taken
# (see above). For ease of use the suffix is removed in the alias target and the
# exported target
add_library(${PROJECT_NAME}_wrapper_target INTERFACE)
set_target_properties(${PROJECT_NAME}_wrapper_target
PROPERTIES EXPORT_NAME ${PROJECT_NAME})
add_library(${PROJECT_NAME}::${PROJECT_NAME} ALIAS
${PROJECT_NAME}_wrapper_target)
target_link_libraries(${PROJECT_NAME}_wrapper_target
INTERFACE ${${PROJECT_NAME}_target_name})

# Installation
install(
TARGETS ${PROJECT_NAME}_wrapper_target
EXPORT ${PROJECT_NAME}_wrapper_targetTargets
INCLUDES
DESTINATION ${CMAKE_INSTALL_INCLUDEDIR})

ament_export_dependencies(rosidl_default_runtime)
ament_export_targets(${PROJECT_NAME}_wrapper_targetTargets
HAS_LIBRARY_TARGET)

# ##############################################################################
# Tests
# ##############################################################################
if(CMAKE_PROJECT_NAME STREQUAL PROJECT_NAME AND BUILD_TESTING)
find_package(ament_lint_auto REQUIRED)
# find_package(ament_cmake_gtest REQUIRED)
# ament_lint_auto_find_test_dependencies()
endif()

ament_package()
Loading