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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 5 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ _pub/sub without steroids_

[![Website](https://img.shields.io/badge/website-opencyphal.org-black?color=1700b3)](https://opencyphal.org/)
[![Forum](https://img.shields.io/discourse/https/forum.opencyphal.org/users.svg?logo=discourse&color=1700b3)](https://forum.opencyphal.org)
[![PyPI](https://img.shields.io/pypi/v/pycyphal2.svg)](https://pypi.org/project/pycyphal2/)
[![Docs](https://img.shields.io/badge/Docs-rtfm-black?color=ff00aa&logo=readthedocs)](https://opencyphal.github.io/pycyphal)

</div>
Expand All @@ -16,12 +17,10 @@ _pub/sub without steroids_

Python implementation of the [Cyphal](https://opencyphal.org) stack that runs on GNU/Linux, Windows, and macOS.

Install as follows.
Optional features inside the brackets can be removed if not needed; see `pyproject.toml` for the full list:

```
pip install pycyphal2[udp,pythoncan]
```
PyCyphal v2 is published on PyPI as `pycyphal2` to enable coexistence with v1 `pycyphal` in the same Python environment.
The two packages have radically different APIs but are wire-compatible on Cyphal/CAN.
The maintenance of the original `pycyphal` package will eventually cease;
existing applications leveraging `pycyphal` should upgrade to the new API of `pycyphal2`.

📚 **Read the docs** at <https://opencyphal.github.io/pycyphal>.

Expand Down
102 changes: 84 additions & 18 deletions docs/build.py
Original file line number Diff line number Diff line change
@@ -1,31 +1,97 @@
#!/usr/bin/env python
"""Build API docs using pdoc. Invoked via ``nox -s docs``."""

import ast
import shutil
from pathlib import Path
import pkgutil
import importlib
import sys

import pdoc
import pycyphal2

# Discover and import all public submodules so pdoc can see them,
# then inject them into their parent's __all__ so pdoc lists them in the sidebar.
# Public modules are expected to be importable in the docs environment; failures are treated as hard errors.
for mi in pkgutil.walk_packages(pycyphal2.__path__, pycyphal2.__name__ + "."):
leaf = mi.name.rsplit(".", 1)[-1]
if leaf.startswith("_"):
continue
OUTPUT_DIRECTORY = Path("html_docs")
EXAMPLES_DIRECTORY = Path("examples")


def _discover_examples(directory: Path) -> list[Path]:
if not directory.is_dir():
raise RuntimeError(f"Examples directory {directory!s} not found while building docs")
examples = sorted(path for path in directory.rglob("*.py") if path.is_file())
if not examples:
raise RuntimeError(f"No example scripts found under {directory!s} while building docs")
return examples


def _load_summary(path: Path) -> str:
try:
importlib.import_module(mi.name)
except Exception as ex:
raise RuntimeError(f"Failed to import public module {mi.name!r} while building docs") from ex
parent = sys.modules[mi.name.rsplit(".", 1)[0]]
if hasattr(parent, "__all__") and leaf not in parent.__all__:
parent.__all__.append(leaf)
module = ast.parse(path.read_text(encoding="utf8"), filename=str(path))
except SyntaxError as ex:
raise RuntimeError(f"Failed to parse example {path!s} while building docs") from ex
doc = ast.get_docstring(module, clean=True)
if not doc:
return ""
for line in doc.splitlines():
text = line.strip()
if text and not text.startswith("Usage:"):
return text
return ""


def _make_examples_section(examples: list[Path]) -> str:
if not examples:
return ""
lines = ["## Examples", "", "Runnable examples:"]
for path in examples:
relative = path.relative_to(EXAMPLES_DIRECTORY).as_posix()
summary = _load_summary(path)
suffix = f" - {summary}" if summary else ""
lines.append(f"- [`examples/{relative}`](examples/{relative}){suffix}")
return "\n".join(lines) + "\n"


def _inject_examples_section(examples: list[Path]) -> None:
section = _make_examples_section(examples)
doc = pycyphal2.__doc__ or ""
pycyphal2.__doc__ = doc.rstrip() + f"\n\n{section}"


def _copy_examples(examples_source: Path, output_directory: Path, examples: list[Path]) -> None:
destination = output_directory / examples_source.name
shutil.rmtree(destination, ignore_errors=True)
if not examples:
return
for source in examples:
target = destination / source.relative_to(examples_source)
target.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, target)


def main() -> None:
# Discover and import all public submodules so pdoc can see them,
# then inject them into their parent's __all__ so pdoc lists them in the sidebar.
# Public modules are expected to be importable in the docs environment; failures are treated as hard errors.
for mi in pkgutil.walk_packages(pycyphal2.__path__, pycyphal2.__name__ + "."):
leaf = mi.name.rsplit(".", 1)[-1]
if leaf.startswith("_"):
continue
try:
importlib.import_module(mi.name)
except Exception as ex:
raise RuntimeError(f"Failed to import public module {mi.name!r} while building docs") from ex
parent = sys.modules[mi.name.rsplit(".", 1)[0]]
if hasattr(parent, "__all__") and leaf not in parent.__all__:
parent.__all__.append(leaf)

# Customization is necessary to expose special members like __aiter__, __call__, etc.
# We also use it to tweak the colors.
pdoc.render.configure(template_directory=Path(__file__).resolve().with_name("pdoc"))
examples = _discover_examples(EXAMPLES_DIRECTORY)
_inject_examples_section(examples)
pdoc.pdoc("pycyphal2", output_directory=OUTPUT_DIRECTORY)
_copy_examples(EXAMPLES_DIRECTORY, OUTPUT_DIRECTORY, examples)

import pdoc

# Customization is necessary to expose special members like __aiter__, __call__, etc.
# We also use it to tweak the colors.
pdoc.render.configure(template_directory=Path(__file__).resolve().with_name("pdoc"))
pdoc.pdoc("pycyphal2", output_directory=Path("html_docs"))
if __name__ == "__main__":
main()
4 changes: 4 additions & 0 deletions docs/pdoc/theme.css
Original file line number Diff line number Diff line change
Expand Up @@ -18,3 +18,7 @@
--def: #FC6D09;
--annotation: #7aa2ff;
}

main.pdoc .docstring h3 {
font-size: 1.2rem;
}
50 changes: 32 additions & 18 deletions examples/monitor.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,21 @@
import logging
import sys
import time
from pathlib import Path
from dataclasses import dataclass

from pycyphal2 import Node, Topic, Transport

NAME = f"{Path(__file__).stem}/"
SCOUT_INTERVAL = 10.0
DISPLAY_INTERVAL = 2.0
EVICTION_TIMEOUT = 600.0


@dataclass(frozen=True)
class TopicInfo:
last_seen_monotonic: float
topic: Topic


def make_node(transport_spec: str) -> Node:
if transport_spec == "udp":
from pycyphal2.udp import UDPTransport
Expand All @@ -36,20 +41,25 @@ def make_node(transport_spec: str) -> Node:
else:
raise ValueError(f"Unknown transport {transport_spec!r}")

return Node.new(transport, NAME)
return Node.new(transport, "monitor/") # The trailing slash indicates that we want a unique ID at the end.


def _clear() -> str:
return "\033[2J\033[H" if sys.stdout.isatty() else ("\n" * 3)


def _bright(text: str) -> str:
return f"\033[1m{text}\033[0m" if sys.stdout.isatty() else text


async def run(transport_spec: str) -> None:
# topic_name -> (topic_hash, last_seen_monotonic, gossip_count)
topics: dict[str, tuple[int, float, int]] = {}
topics: dict[str, TopicInfo] = {}
node = make_node(transport_spec)
subject_id_modulus = node.transport.subject_id_modulus

def on_gossip(topic: Topic) -> None:
name = topic.name
prev = topics.get(name)
count = (prev[2] + 1) if prev else 1
topics[name] = (topic.hash, time.monotonic(), count)
topics[topic.name] = TopicInfo(last_seen_monotonic=time.monotonic(), topic=topic)

node = make_node(transport_spec)
_mon = node.monitor(on_gossip)

async def scout_loop() -> None:
Expand All @@ -65,16 +75,20 @@ async def display_loop() -> None:
await asyncio.sleep(DISPLAY_INTERVAL)
now = time.monotonic()
# Evict stale topics.
for name in [n for n, (_, ts, _) in topics.items() if now - ts > EVICTION_TIMEOUT]:
for name in [n for n, info in topics.items() if now - info.last_seen_monotonic > EVICTION_TIMEOUT]:
del topics[name]
# Clear screen and home cursor.
sys.stdout.write("\033[2J\033[H")
sys.stdout.write("#\tHASH\t\t\tCOUNT\tAGO\tNAME\n")
# Render the display.
out = [
_clear(),
_bright(f"{'#':>3} {'HEARD':<5} {'HASH':<16} {'EVICTIONS':>10} {'SUBJECT-ID':>10} NAME\n"),
]
for idx, name in enumerate(sorted(topics), 1):
th, ts, count = topics[name]
age = int(now - ts)
sys.stdout.write(f"{idx}\t{th:016x}\t{count}\t{age // 60:02d}:{age % 60:02d}\t{name}\n")
sys.stdout.flush()
age = int(now - topics[name].last_seen_monotonic)
age_fmt = f"{age // 60:02d}:{age % 60:02d}"
t = topics[name].topic
subject_id = t.subject_id(subject_id_modulus)
out.append(f"{idx:>3} {age_fmt} {t.hash:016x} {t.evictions:>10} {subject_id:>10} {t.name}\n")
print("".join(out), end="", flush=True)

await asyncio.gather(scout_loop(), display_loop())

Expand Down
88 changes: 81 additions & 7 deletions src/pycyphal2/__init__.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,20 @@
"""
`Cyphal <https://opencyphal.org>`_ in Python —
[Cyphal](https://opencyphal.org) in Python —
decentralized real-time pub/sub with tunable reliability, service discovery, and zero configuration.
Works anywhere, `even baremetal MCUs <https://github.com/OpenCyphal-Garage/cy>`_.
Works anywhere, [including baremetal MCUs](https://github.com/OpenCyphal-Garage/cy).

Supports various transports such as Ethernet (UDP) and CAN FD with optional redundancy.

## Installation

Optional features inside the brackets can be removed if not needed; see `pyproject.toml` for the full list:

```
pip install pycyphal2[udp,pythoncan]
```

## Usage

Set up a transport, make a node, publish and subscribe:

```python
Expand All @@ -25,29 +36,67 @@ async def main():
Transport modules (`pycyphal2.udp`, `pycyphal2.can`) are imported separately
so that only the needed dependencies are pulled in.

The source repository contains a collection of runnable examples.
### Name resolution

Environment variables control name remapping similar to ROS:
The topic naming system shares many similarities with ROS.
A valid name contains printable ASCII characters except space (ASCII codes [33, 126]).
Normalized names do not have leading or trailing segment separators `/` and do not have consecutive separators.
Every node should have a unique name, which is called its *home*; home substitution is done via `~/`.

| Input name | Namespace | Home | Remap | Resolved name | Note |
| ----------------- | --------- | ---- | ------------------ | --------------------- | -------------------------------- |
| `foo/bar` | `ns` | `me` | | `ns/foo/bar` | Relative name |
| `/foo//bar/` | `ns` | `me` | | `foo/bar` | Absolute name; namespace ignored |
| `~/foo/bar` | `ns` | `me` | | `me/foo/bar` | Homeful name |
| `sensor/*/temp` | `diag` | `me` | | `diag/sensor/*/temp` | Pattern with `*` |
| `/sensor/>` | `diag` | `me` | | `sensor/>` | Pattern with trailing `>` |
| `foo/bar` | `ns` | `me` | `foo/bar=~/zoo` | `me/zoo` | Remap first, then resolve |

Only exact `~` or `~/...` is homeful; `~ns` is literal. A matching remap overrides pinning.
Pins are allowed only on verbatim names, not on patterns.

Environment variables that control name remapping:

- `CYPHAL_NAMESPACE` — default namespace prepended to relative topic names.
- `CYPHAL_REMAP` — topic name remappings (`from=to` pairs, whitespace-separated).

Publication is best-effort by default. Pass ``reliable=True`` when publishing to retry delivery until
See also :meth:`Node.remap`.

### Publish

Publication is best-effort by default. Pass `reliable=True` when publishing to retry delivery until
acknowledged by every known subscriber or until the deadline; if the remote side does not acknowledge in time,
:class:`DeliveryError` is raised.

```python
pub = node.advertise("sensor/temperature")
await pub(Instant.now() + 1.0, b"payload", reliable=True)
```

Subscriptions normally yield messages as soon as they arrive. Set ``reordering_window`` [seconds] on
### Subscribe

Subscriptions normally yield messages as soon as they arrive. Set `reordering_window` [seconds] on
:meth:`Node.subscribe` to allow delaying out-of-order messages to reconstruct the original publication order.
This is useful for sensor feeds and state estimators.

```python
sub = node.subscribe("sensor/temperature", reordering_window=0.1)
```

Pattern matching is supported: use `*` to match one name segment (e.g., `sensor/*/temperature`)
and a trailing `>` to match zero or more trailing segments (e.g., `sensor/>`).
Pattern subscribers automatically join matching topics as they appear, and unsubscribe as they disappear.

```python
sub = node.subscribe("sensor/*/temperature")
async for arrival in sub:
topic = arrival.breadcrumb.topic
captures = sub.substitutions(topic)
print(topic.name, captures) # [('engine', 1)], where 1 is the pattern segment index
```

### RPC & streaming

RPC is layered directly on top of pub/sub. Use :meth:`Publisher.request` to publish a message that expects
responses, and use :attr:`Arrival.breadcrumb` on the subscriber side to send a unicast reply back to the requester.
One request may yield responses from multiple subscribers.
Expand All @@ -66,8 +115,33 @@ async def main():
await arrival.breadcrumb(Instant.now() + 1.0, b"chunk-2", reliable=True)
```

### Topic pinning

Topics may be pinned to a specific subject-ID using `name#1234` to bypass automatic assignment.
This is useful for applications where a high degree of determinism is required and for Cyphal/CAN v1.0 interoperability.
Pattern names (e.g., `sensor/*/temperature/>`) cannot be pinned.

To join a Cyphal/CAN v1.0 subject, use topic name of the form `subject_id#subject_id`; e.g., `7509#7509`.

```python
pub = node.advertise("motor/status#1234")
sub = node.subscribe("1234#1234")
```

Old Cyphal/CAN v1.0 nodes do not participate in the topic discovery protocol,
so topics joined only by such nodes are not discoverable by pattern subscribers.

## Remarks

Cyphal does not define a serialization format. Previous versions used to define the DSDL format but it has been
extracted into an independent project, and Cyphal was made serialization-agnostic in v1.1+.

PyCyphal v2 is published on PyPI as [`pycyphal2`](https://pypi.org/project/pycyphal2/)
to enable coexistence with the original [`pycyphal` v1](https://pypi.org/project/pycyphal/)
in the same Python environment.
The two packages have radically different APIs but are wire-compatible on Cyphal/CAN.
The maintenance of the original `pycyphal` package will eventually cease;
existing applications leveraging `pycyphal` should upgrade to the new API of `pycyphal2`.
"""

from __future__ import annotations
Expand All @@ -77,7 +151,7 @@ async def main():
from ._transport import TransportArrival as TransportArrival
from ._transport import SubjectWriter as SubjectWriter

__version__ = "2.0.0.dev0"
__version__ = "2.0.0.dev1"
Comment thread
pavel-kirienko marked this conversation as resolved.

# pdoc needs __all__ to display re-exported members.
__all__ = [
Expand Down
Loading