Skip to content
Closed
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
1 change: 1 addition & 0 deletions ament_nodl/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ project(ament_nodl)
find_package(ament_cmake REQUIRED)

install(FILES
cmake/ament_nodl_register_document.cmake
cmake/ament_nodl_register_node.cmake
DESTINATION share/${PROJECT_NAME}/cmake)

Expand Down
1 change: 1 addition & 0 deletions ament_nodl/ament_nodl-extras.cmake.in
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
# Resolves ${Python3_EXECUTABLE} for the build-time validation step the macros invoke.
find_package(Python3 REQUIRED COMPONENTS Interpreter)

include("${CMAKE_CURRENT_LIST_DIR}/ament_nodl_register_document.cmake")
include("${CMAKE_CURRENT_LIST_DIR}/ament_nodl_register_node.cmake")
80 changes: 80 additions & 0 deletions ament_nodl/cmake/ament_nodl_register_document.cmake
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
# SPDX-FileCopyrightText: 2026 Open Source Robotics Foundation, Inc.
# SPDX-License-Identifier: Apache-2.0
#
# Register a reusable NoDL document in the ament resource index.
#
# Publishes the contents of a NoDL document under the ``nodl_documents`` resource type.
# The resource key is ``<package>__<name>``.
# NoDL node compositions reference the document as a mixin by ``nodl://<package>/<name>``.
#
# Consumers retrieve the content via::
#
# ament_index_python.packages.get_resource('nodl_documents', '<pkg>__<name>')
#
# The source file is also installed under ``share/<package>/nodl/documents/`` for direct filesystem access.
#
# Example::
#
# ament_nodl_register_document(tf_listener
# FILE nodl/tf_listener.nodl.yaml
# )
#
# :param document_name: name of the document.
# Combined with PACKAGE to form the resource key and the ``nodl://<package>/<name>`` URI.
# :type document_name: string
# :param FILE: path to the NoDL file containing the document.
# May be absolute or relative to ``CMAKE_CURRENT_SOURCE_DIR``.
# :type FILE: string
# :param PACKAGE: package name to use in the resource key.
# Defaults to ``${PROJECT_NAME}``.
# :type PACKAGE: string
#
# @public
#
function(ament_nodl_register_document document_name)
cmake_parse_arguments(_ARGS "" "FILE;PACKAGE" "" ${ARGN})

if(NOT _ARGS_FILE)
message(FATAL_ERROR "ament_nodl_register_document: FILE is required")
endif()
if(NOT _ARGS_PACKAGE)
set(_ARGS_PACKAGE "${PROJECT_NAME}")
endif()

get_filename_component(_abs_file "${_ARGS_FILE}" ABSOLUTE
BASE_DIR "${CMAKE_CURRENT_SOURCE_DIR}")

if(NOT EXISTS "${_abs_file}")
message(WARNING
"ament_nodl_register_document: file not found at configure time: ${_abs_file}")
endif()

# Validate the document at build time so authoring errors surface when registering, not downstream when consuming.
# The document schema forbids composition keys (base/main/mixins), so a node composition cannot be registered here.
# This only runs when ${_abs_file} changes.
set(_stamp_dir "${CMAKE_CURRENT_BINARY_DIR}/ament_nodl/nodl_documents")
set(_stamp "${_stamp_dir}/${_ARGS_PACKAGE}__${document_name}.valid.stamp")
file(MAKE_DIRECTORY "${_stamp_dir}")
add_custom_command(
OUTPUT "${_stamp}"
DEPENDS "${_abs_file}"
COMMAND "${Python3_EXECUTABLE}" -m nodl_schema "${_abs_file}"
COMMAND "${CMAKE_COMMAND}" -E touch "${_stamp}"
COMMENT "Validating NoDL document ${_ARGS_PACKAGE}/${document_name}"
VERBATIM
)
add_custom_target(_ament_nodl_validate_document_${_ARGS_PACKAGE}__${document_name} ALL
DEPENDS "${_stamp}"
)

# Install to ament index
install(
FILES "${_abs_file}"
DESTINATION "share/ament_index/resource_index/nodl_documents"
RENAME "${_ARGS_PACKAGE}__${document_name}")

# Install to package's share directory
install(
FILES "${_abs_file}"
DESTINATION "share/${_ARGS_PACKAGE}/nodl/documents")
endfunction()
9 changes: 6 additions & 3 deletions ament_nodl/cmake/ament_nodl_register_node.cmake
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
# SPDX-FileCopyrightText: 2026 Open Source Robotics Foundation, Inc.
# SPDX-License-Identifier: Apache-2.0
#
# Register a NoDL document for an executable in the ament resource index.
# Register a NoDL node composition for an executable in the ament resource index.
#
# Publishes the contents of a NoDL file under the ``nodl_nodes`` resource type.
# The file is a NoDL node (``node.schema.yaml``): a ``base`` + ``mixins`` + ``main``
# composition describing the executable's whole interface.
#
# Publishes the contents of the file under the ``nodl_nodes`` resource type.
# The resource key is ``<package>__<executable>``.
# Tools like ``nodl_test`` and ``nodl_docgen`` use this to locate the spec by package and executable name.
#
Expand Down Expand Up @@ -57,7 +60,7 @@ function(ament_nodl_register_node executable_name)
add_custom_command(
OUTPUT "${_stamp}"
DEPENDS "${_abs_file}"
COMMAND "${Python3_EXECUTABLE}" -m nodl_schema "${_abs_file}"
COMMAND "${Python3_EXECUTABLE}" -m nodl_schema --node "${_abs_file}"
COMMAND "${CMAKE_COMMAND}" -E touch "${_stamp}"
COMMENT "Validating NoDL node ${_ARGS_PACKAGE}/${executable_name}"
VERBATIM
Expand Down
34 changes: 32 additions & 2 deletions nodl/doc/concepts.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,17 @@
# NoDL Concepts

A NoDL document declares a ROS 2 node's public interface.
A NoDL **document** declares a ROS 2 node's public interface.
It can be consumed:

1. at **runtime** by conformance testing and health monitoring
1. at **build time** by code generators
1. at **documentation time** to describe a node for its users

NoDL distinguishes two things:

- a **document** — an interface, which may be only _part_ of a node's interface.
- a **node** — a _whole_ node's interface, [composed](#composition-documents-into-nodes) from documents.

## Node identity

A NoDL document does _not_ declare its own name, package, or namespace.
Expand All @@ -25,6 +30,26 @@ Those interfaces are:

See the [schema reference](schema.md) for field-by-field detail.

## Composition: documents into nodes

A single document is often not the whole story: a node inherits interfaces from its ROS base type and may share reusable interfaces with other nodes.
A NoDL **node** composes a whole interface from three layers:

- **`base`** — a built-in ROS 2 node type (`node` or `lifecycle_node`) whose interface is inherited. It resolves to a built-in document shipped with `nodl_schema` (e.g. `use_sim_time`, and for lifecycle nodes the state-management services and transition event).
- **`main`** — the interface this node's implementation _owns_, written in place as a document.
- **`mixins`** — additional documents merged in, each either a reference (`nodl://<package>/<name>` for a document registered in the ament index, or a path relative to the node file) or an in-place document.

Layers merge in order **base → mixins (as listed) → main**; later layers win on a name collision, so `main` always has the final say.

Mixins are single-level: a referenced document is merged as-is and cannot itself declare a `base` or further `mixins`. The document schema forbids those keys, so this is enforced, not just convention.

### Forward and backward

This split lines up with the two [usage models](#usage-models):

- Working **forward** (code generation), a generator implements `base` + `main` and **ignores `mixins`** — mixins describe interfaces owned by other code, not this node's implementation.
- Working **backward** (observation), `base` can be deduced and the full observed non-base interface placed in `main`. `mixins` cannot be deduced and are simply absent; conformance and documentation still work against the merged result.

## Syntax & Frontend(s)

The schema is JSON Schema.
Expand All @@ -33,9 +58,14 @@ Tools should not assume any particular extension (such as `.nodl.yaml`) or a sin

## Validation

NoDL files are validated both at build time (by the `ament_nodl` CMake macros, before install) and at runtime (by `nodl_schema.load_nodl`).
NoDL files are validated both at build time (by the `ament_nodl` CMake macros, before install) and at runtime (by `nodl_schema.load_nodl` / `load_node`).
Authoring errors surface during the build of the package that owns the file, not at runtime, so a misconfigured node never ships.

The `ament_nodl` package registers files into the ament index and validates them as it does:

- `ament_nodl_register_node(<exe> FILE ...)` registers a node composition (under the `nodl_nodes` resource type) for an executable.
- `ament_nodl_register_document(<name> FILE ...)` registers a reusable document (under `nodl_documents`) that other nodes pull in as a mixin via `nodl://<package>/<name>`.

## Usage Models

The NoDL project aims to support two directional modes of use for NoDL documents, which it calls "forward" and "backward".
Expand Down
4 changes: 4 additions & 0 deletions nodl/doc/conf.py
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@
'tasklist',
]

# Generate slug anchors for headings (h1-h3) so `[...](#slug)` and
# `[...](other.md#slug)` cross-references resolve.
myst_heading_anchors = 3

source_suffix = {
'.md': 'markdown',
'.rst': 'restructuredtext',
Expand Down
16 changes: 15 additions & 1 deletion nodl/doc/schema.md
Original file line number Diff line number Diff line change
@@ -1,13 +1,27 @@
# NoDL Schema reference

The following reference is generated from {repo}`nodl_schema/nodl_schema/schemas/nodl.schema.yaml`, which is the canonical source.
NoDL has two schemas, both generated below from their canonical sources:

- a **node** ({repo}`nodl_schema/nodl_schema/schemas/node.schema.yaml`) -- a whole-node composition of `base` + `mixins` + `main`.
- a **document** ({repo}`nodl_schema/nodl_schema/schemas/nodl.schema.yaml`) -- a (possibly partial) node interface, used for each composition layer.

See [Concepts](concepts.md#composition-documents-into-nodes) for how the two relate.

## Schema Version

The NoDL schema is [JSON Schema Draft 7](https://json-schema.org/draft-07).
This was chosen chosen to trivially support all live ROS 2 distributions - the key limitation being the system packages available on Ubuntu 22.04 Jammy with ROS 2 Humble.
After Humble EOL in May 2027, we will consider updating to a newer JSON Schema draft version.

## Node composition

A node composes a whole interface from a built-in `base`, the node's own `main` document, and zero or more `mixins`. `main` is a [node document](#node-document); each mixin is a reference (`nodl://<package>/<name>` or a relative path) or an in-place document.

```{eval-rst}
.. json:schema:: Node
:title: NoDL node
```

## Node document

```{eval-rst}
Expand Down
16 changes: 12 additions & 4 deletions nodl/doc/schema_reference.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
SCHEMA_GLOB = '_generated/schemas/*.yaml'

_DEFINITION_REF = re.compile(r'#/definitions/(?P<name>\w+)$')
_FILE_REF = re.compile(r'^(?P<stem>\w+)\.schema\.yaml$')


def _short_id(name: str) -> str:
Expand All @@ -23,13 +24,20 @@ def _short_id(name: str) -> str:


def _rewrite_refs(node, ref_map: dict) -> None:
"""Replace every ``#/definitions/<name>`` $ref with its short id, in place."""
"""Rewrite each $ref to a short display id, in place.

Handles both ``#/definitions/<name>`` (a definition in any schema) and a
whole-file ref like ``nodl.schema.yaml`` (e.g. the node schema's ``main``).
"""
if isinstance(node, dict):
for key, value in node.items():
if key == '$ref' and isinstance(value, str):
match = _DEFINITION_REF.search(value)
if match:
node[key] = ref_map[match.group('name')]
definition = _DEFINITION_REF.search(value)
whole_file = _FILE_REF.match(value)
if definition:
node[key] = ref_map[definition.group('name')]
elif whole_file:
node[key] = _short_id(whole_file.group('stem'))
else:
_rewrite_refs(value, ref_map)
elif isinstance(node, list):
Expand Down
24 changes: 22 additions & 2 deletions nodl_schema/nodl_schema/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,26 @@
# SPDX-License-Identifier: Apache-2.0
"""NoDL schema, in-memory models, and validation helpers."""

from nodl_schema.validator import dump_nodl, load_nodl, load_schema, validate
from nodl_schema.composition import Base, Node
from nodl_schema.resolve import LayeredDocument, resolve
from nodl_schema.validator import (
dump_nodl,
load_node,
load_nodl,
load_schema,
validate,
validate_node,
)

__all__ = ['dump_nodl', 'load_nodl', 'load_schema', 'validate']
__all__ = [
'Base',
'LayeredDocument',
'Node',
'dump_nodl',
'load_node',
'load_nodl',
'load_schema',
'resolve',
'validate',
'validate_node',
]
50 changes: 50 additions & 0 deletions nodl_schema/nodl_schema/composition.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# SPDX-FileCopyrightText: 2026 Open Source Robotics Foundation, Inc.
# SPDX-License-Identifier: Apache-2.0
"""Models for the Node composition schema (``node.schema.yaml``).

These are hand-written rather than emitted into the generated ``models.py``
because the Node schema's ``main``/``mixins`` reference the document schema
across files, which datamodel-codegen can only express as a multi-module
package -- not the single ``models.py`` the rest of nodl_schema imports. Keep
this in sync with ``node.schema.yaml`` by hand; it is a small, stable surface.
"""

from __future__ import annotations

from enum import Enum
from typing import Optional, Union

try:
from pydantic.v1 import BaseModel, Extra, Field
except ImportError:
from pydantic import BaseModel, Extra, Field

from nodl_schema.models import NodlDocument


class Base(Enum):
"""Built-in ROS 2 base node type whose interface a node inherits."""

node = 'node'
lifecycle_node = 'lifecycle_node'


class Node(BaseModel):
"""A NoDL Node: a whole ROS 2 node interface composed from base + mixins + main."""

class Config:
extra = Extra.forbid

nodl_version: int = Field(2, const=True, description='NoDL schema major version this document targets.')
base: Optional[Base] = Field(
None,
description='Built-in ROS 2 base node type whose interface this node inherits.',
)
main: NodlDocument = Field(
...,
description="The interface this node's implementation owns, as an in-place NoDL document.",
)
mixins: Optional[list[Union[str, NodlDocument]]] = Field(
None,
description='NoDL documents merged in for documentation and conformance; ignored by code generation.',
)
16 changes: 9 additions & 7 deletions nodl_schema/nodl_schema/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -415,13 +415,15 @@ class Config:

class NodlDocument(BaseModel):
"""
NoDL (Node Definition Language) v2 schema.

Describes the public ROS interface of a single ROS 2 node:
parameters, publishers, subscriptions, service servers/clients,
and action servers/clients. Field names align with ``rcl_interfaces``
and ``rosgraph_msgs`` (``Topic``, ``Service``, ``Action``) so a NoDL document
maps cleanly onto runtime introspection types.
NoDL (Node Definition Language) v2 document schema.

Describes a ROS 2 node interface -- but not necessarily a *whole*
node's interface: parameters, publishers, subscriptions, service
servers/clients, and action servers/clients. A document may be
partial; whole nodes are assembled from documents by the Node schema
(``node.schema.yaml``) via base + mixins + main. Field names align
with ``rcl_interfaces`` and ``rosgraph_msgs`` (``Topic``, ``Service``,
``Action``) so a document maps cleanly onto runtime introspection types.

Node identity (name, package, namespace) is established by the
enclosing package and the file's location within it, not declared
Expand Down
Loading
Loading