> ## Documentation Index
> Fetch the complete documentation index at: https://docs.cerulion.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Use Cerulion from Python

> Build the Python client from source, read and publish Cerulion topics on one machine, and write a node in Python.

When the rest of your robot code is Python, you still want its data. The `cerulion` Python package reads and publishes the same shared-memory topics a graph uses, raw or typed with the ROS 2 message classes, and a node can be written in Python too.

> [!NOTE]
>
> The Python client is built from a Cerulion source checkout: the release archives carry no wheel. It needs Python 3.11 or later, and it reaches topics on the local machine only; it opens no network connection.

## Build and install the client

From a checkout of [the Cerulion repository](https://github.com/cerulion-inc/cerulion), in a virtual environment with Python 3.11 or later:

```bash theme={null}
pip install maturin
maturin build --release -m crates/cerulion_py/Cargo.toml -o wheels
pip install wheels/cerulion-*.whl
```

The wheel is tagged for CPython 3.11 and later, so `pip` refuses it on an older interpreter.

## Read a topic the CLI publishes

Message classes live in `cerulion.msgs`, one module per ROS 2 package: `cerulion.msgs.geometry_msgs.Vector3` is the type a Rust node names as `native_ros2_messages::geometry_msgs::Vector3`. Pass the class to a subscriber, and each received frame views as an instance of it.

Save this as `sub_vector3.py` and start it:

```python theme={null}
import cerulion
from cerulion.msgs.geometry_msgs import Vector3

session = cerulion.connect()
vec_sub = session.subscriber("/docs_demo/vector3", schema=Vector3, depth=4)
print("subscribed; waiting for frames", flush=True)

frame = vec_sub.receive(30000)
if frame is None:
    print("no Vector3 frame within 30 s")
else:
    msg = frame.view()
    print(type(msg).__name__, isinstance(msg, Vector3), msg.x, msg.y, msg.z)
    print(msg.copy(), frame.sequence, hex(frame.schema_hash), hex(Vector3.__cerulion_schema_hash__))
    frame.release()
```

```bash theme={null}
python -u sub_vector3.py
```

In a second terminal, publish one message:

```bash theme={null}
cerulion topic pub --local /docs_demo/vector3 geometry_msgs/Vector3 '{"x": 1.0, "y": 2.5, "z": -3.0}'
```

The script prints:

```text theme={null}
subscribed; waiting for frames
Vector3 True 1.0 2.5 -3.0
{'x': 1.0, 'y': 2.5, 'z': -3.0} 1 0xd43ee5592039b9df 0xd43ee5592039b9df
```

`frame.view()` is a read-only, zero-copy view of the shared-memory slot, and `msg.copy()` makes an owned copy. Release each frame promptly, and drop its views first: a subscriber can hold only a few frames at once (`sub.max_borrowed_samples`, 2 by default).

> [!TIP]
>
> A script binds `cerulion.msgs` to the workspace that contains the current directory, or to `$CERULION_WORKSPACE` when it is set, so your workspace's own message types are classes too. Outside a workspace it sees the built-in messages alone.

## Publish typed messages

A schema you define yourself works the same way. This script publishes a `Point` twice, once from a dict and once by writing a zero-copy loan in place, and reads each back:

```python theme={null}
import cerulion

session = cerulion.connect()

schemas = cerulion.SchemaSet()
schemas.add_yaml("""
schemas:
  Point:
    fields:
      float64 x: {}
      float64 y: {}
""")
Point = schemas.message_type("Point")
point_pub = session.publisher("/cerulion_py/docs/typed", schema=Point)
point_sub = session.subscriber("/cerulion_py/docs/typed", schema=Point)
point_pub.publish({"x": 1.5, "y": -2.0})
point_frame = point_sub.receive(1000)
point = point_frame.view()
print(point.copy(), isinstance(point, Point))
point_frame.release()

with point_pub.loan() as point:
    point.x = 2.5
    point.y = -4.0
point_frame = point_sub.receive(1000)
point = point_frame.view()
print(point.x, point.copy())
point_frame.release()
```

```text theme={null}
{'x': 1.5, 'y': -2.0} True
2.5 {'x': 2.5, 'y': -4.0}
```

`publish()` of a dict that has missing, extra or ill-typed fields raises `cerulion.EncodeError`. Every client error derives from `cerulion.CerulionError`.

> [!WARNING]
>
> One transport serves the whole process: `cerulion.connect()` returns the same session every time. A Python process that loads this client must not also load another module that embeds the Cerulion runtime.

## Read a recording

`cerulion.open_bag(path)` reads a finalized MCAP bag. `topics()` lists its channels, and `messages(topics=None)` yields `(topic, record)` pairs whose bytes are owned copies; view a record with `record.view(Vector3)`, or `record.view("pkg/Name", schemas=s)` for a schema of your own.

## Write a node in Python

A Python node is a node type like any other: `cerulion node build` compiles a small library that embeds CPython, and the node's logic lives in `nodes/<type>/node.py`.

```bash theme={null}
cerulion node create counter --lang python -i std_msgs/Int32 inp -o std_msgs/Int32 out --policy period_ms=10
cerulion node build counter
```

```python theme={null}
import cerulion
from cerulion.msgs.std_msgs import Int32

@cerulion.node(period_ms=10)
class Counter:
    inp = cerulion.input(Int32, depth=1)
    out = cerulion.output(Int32)

    def tick(self):
        msg = self.inp
        if msg is None:
            return
        self.out.data = msg.data
```

What to know before you write one:

* **It needs a source checkout.** `node create --lang python` refuses outside one, because the embedding crate is not published to a registry.
* **Exactly one scheduling policy.** Give `period_ms`, one input marked `trigger=True`, or `sync_window_ms` for several trigger inputs, so `node create --lang python` needs `-T` or `--policy`. A Python node takes `-i` and `-o` repeatedly.
* **Edit `node.py`, not the scaffold.** `node modify` refuses Python nodes: change `node.py` and run `cerulion node build` again.
* **The interpreter is found at build time**, in this order: `$CERULION_PYTHON`, `<workspace>/.venv/bin/python`, then `python3` on `PATH`. Install the client into that interpreter.
* **One interpreter per process.** Python execution is serialized through the GIL; threads your node creates and `fork()` are not supported. It suits control and integration nodes, not kHz loops: every tick crosses into the interpreter.
* **No state capture, and no `Fatal` stop.** A Python node starts from `init` on every run, and its errors carry no kind, so it cannot stop itself with `NodeError::Fatal`.

## Next steps

> [card] [Inspect topics](https://docs.cerulion.com/cerulion/guides/inspect-topics.md)
>
> Find the topics to read, and their types, from the CLI.

> [card] [Messages and schemas](https://docs.cerulion.com/cerulion/guides/messages-and-schemas.md)
>
> The built-in message types and how to define your own.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.