pycyphal2 2.0.0.dev0__tar.gz → 2.0.0.dev2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {pycyphal2-2.0.0.dev0/src/pycyphal2.egg-info → pycyphal2-2.0.0.dev2}/PKG-INFO +6 -7
  2. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/README.md +5 -6
  3. pycyphal2-2.0.0.dev2/src/pycyphal2/__init__.py +167 -0
  4. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/_api.py +15 -1
  5. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/_node.py +47 -19
  6. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/can/__init__.py +1 -1
  7. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2/src/pycyphal2.egg-info}/PKG-INFO +6 -7
  8. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_gossip.py +3 -3
  9. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_integration.py +10 -2
  10. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_monitor.py +7 -1
  11. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_parity.py +7 -7
  12. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_reliable.py +15 -14
  13. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_topic.py +5 -5
  14. pycyphal2-2.0.0.dev0/src/pycyphal2/__init__.py +0 -89
  15. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/LICENSE +0 -0
  16. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/pyproject.toml +0 -0
  17. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/setup.cfg +0 -0
  18. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/_hash.py +0 -0
  19. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/_header.py +0 -0
  20. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/_publisher.py +0 -0
  21. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/_subscriber.py +0 -0
  22. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/_transport.py +0 -0
  23. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/can/_interface.py +0 -0
  24. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/can/_reassembly.py +0 -0
  25. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/can/_transport.py +0 -0
  26. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/can/_wire.py +0 -0
  27. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/can/pythoncan.py +0 -0
  28. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/can/socketcan.py +0 -0
  29. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/py.typed +0 -0
  30. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2/udp.py +0 -0
  31. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2.egg-info/SOURCES.txt +0 -0
  32. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2.egg-info/dependency_links.txt +0 -0
  33. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2.egg-info/requires.txt +0 -0
  34. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/src/pycyphal2.egg-info/top_level.txt +0 -0
  35. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_hash.py +0 -0
  36. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_header.py +0 -0
  37. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_names.py +0 -0
  38. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_parity_coverage.py +0 -0
  39. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_pubsub.py +0 -0
  40. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_reorder.py +0 -0
  41. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_rpc.py +0 -0
  42. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_scout.py +0 -0
  43. {pycyphal2-2.0.0.dev0 → pycyphal2-2.0.0.dev2}/tests/test_udp.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycyphal2
3
- Version: 2.0.0.dev0
3
+ Version: 2.0.0.dev2
4
4
  Summary: Pure-Python implementation of Cyphal -- a simple and robust real-time publish/subscribe stack that runs anywhere.
5
5
  Author-email: Pavel Kirienko and OpenCyphal team <pavel@opencyphal.org>
6
6
  License: MIT
@@ -38,6 +38,7 @@ _pub/sub without steroids_
38
38
 
39
39
  [![Website](https://img.shields.io/badge/website-opencyphal.org-black?color=1700b3)](https://opencyphal.org/)
40
40
  [![Forum](https://img.shields.io/discourse/https/forum.opencyphal.org/users.svg?logo=discourse&color=1700b3)](https://forum.opencyphal.org)
41
+ [![PyPI](https://img.shields.io/pypi/v/pycyphal2.svg)](https://pypi.org/project/pycyphal2/)
41
42
  [![Docs](https://img.shields.io/badge/Docs-rtfm-black?color=ff00aa&logo=readthedocs)](https://opencyphal.github.io/pycyphal)
42
43
 
43
44
  </div>
@@ -46,12 +47,10 @@ _pub/sub without steroids_
46
47
 
47
48
  Python implementation of the [Cyphal](https://opencyphal.org) stack that runs on GNU/Linux, Windows, and macOS.
48
49
 
49
- Install as follows.
50
- Optional features inside the brackets can be removed if not needed; see `pyproject.toml` for the full list:
51
-
52
- ```
53
- pip install pycyphal2[udp,pythoncan]
54
- ```
50
+ PyCyphal v2 is published on PyPI as `pycyphal2` to enable coexistence with v1 `pycyphal` in the same Python environment.
51
+ The two packages have radically different APIs but are wire-compatible on Cyphal/CAN.
52
+ The maintenance of the original `pycyphal` package will eventually cease;
53
+ existing applications leveraging `pycyphal` should upgrade to the new API of `pycyphal2`.
55
54
 
56
55
  📚 **Read the docs** at <https://opencyphal.github.io/pycyphal>.
57
56
 
@@ -8,6 +8,7 @@ _pub/sub without steroids_
8
8
 
9
9
  [![Website](https://img.shields.io/badge/website-opencyphal.org-black?color=1700b3)](https://opencyphal.org/)
10
10
  [![Forum](https://img.shields.io/discourse/https/forum.opencyphal.org/users.svg?logo=discourse&color=1700b3)](https://forum.opencyphal.org)
11
+ [![PyPI](https://img.shields.io/pypi/v/pycyphal2.svg)](https://pypi.org/project/pycyphal2/)
11
12
  [![Docs](https://img.shields.io/badge/Docs-rtfm-black?color=ff00aa&logo=readthedocs)](https://opencyphal.github.io/pycyphal)
12
13
 
13
14
  </div>
@@ -16,12 +17,10 @@ _pub/sub without steroids_
16
17
 
17
18
  Python implementation of the [Cyphal](https://opencyphal.org) stack that runs on GNU/Linux, Windows, and macOS.
18
19
 
19
- Install as follows.
20
- Optional features inside the brackets can be removed if not needed; see `pyproject.toml` for the full list:
21
-
22
- ```
23
- pip install pycyphal2[udp,pythoncan]
24
- ```
20
+ PyCyphal v2 is published on PyPI as `pycyphal2` to enable coexistence with v1 `pycyphal` in the same Python environment.
21
+ The two packages have radically different APIs but are wire-compatible on Cyphal/CAN.
22
+ The maintenance of the original `pycyphal` package will eventually cease;
23
+ existing applications leveraging `pycyphal` should upgrade to the new API of `pycyphal2`.
25
24
 
26
25
  📚 **Read the docs** at <https://opencyphal.github.io/pycyphal>.
27
26
 
@@ -0,0 +1,167 @@
1
+ """
2
+ [Cyphal](https://opencyphal.org) in Python —
3
+ decentralized real-time pub/sub with tunable reliability, service discovery, and zero configuration.
4
+ Works anywhere, [including baremetal MCUs](https://github.com/OpenCyphal-Garage/cy).
5
+
6
+ Supports various transports such as Ethernet (UDP) and CAN FD with optional redundancy.
7
+
8
+ ## Installation
9
+
10
+ Optional features inside the brackets can be removed if not needed; see `pyproject.toml` for the full list:
11
+
12
+ ```
13
+ pip install 'pycyphal2[udp,pythoncan]'
14
+ ```
15
+
16
+ ## Usage
17
+
18
+ Set up a transport, make a node, publish and subscribe:
19
+
20
+ ```python
21
+ import asyncio
22
+ from pycyphal2 import Node, Instant
23
+ from pycyphal2.udp import UDPTransport
24
+
25
+ async def main():
26
+ node = Node.new(UDPTransport.new(), "my_node")
27
+
28
+ pub = node.advertise("sensor/temperature")
29
+ await pub(Instant.now() + 1.0, b"21.5")
30
+
31
+ sub = node.subscribe("sensor/temperature")
32
+ async for arrival in sub:
33
+ print(arrival.message)
34
+
35
+ if __name__ == "__main__":
36
+ asyncio.run(main())
37
+ ```
38
+
39
+ All public symbols live at the top level — just `import pycyphal2`.
40
+ Transport modules (`pycyphal2.udp`, `pycyphal2.can`) are imported separately
41
+ so that only the needed dependencies are pulled in.
42
+
43
+ ### Name resolution
44
+
45
+ The topic naming system shares many similarities with ROS.
46
+ A valid name contains printable ASCII characters except space (ASCII codes [33, 126]).
47
+ Normalized names do not have leading or trailing segment separators `/` and do not have consecutive separators.
48
+ Every node should have a unique name, which is called its *home*; home substitution is done via `~/`.
49
+
50
+ | Input name | Namespace | Home | Remap | Resolved name | Note |
51
+ | ----------------- | --------- | ---- | ------------------ | --------------------- | -------------------------------- |
52
+ | `foo/bar` | `ns` | `me` | | `ns/foo/bar` | Relative name |
53
+ | `/foo//bar/` | `ns` | `me` | | `foo/bar` | Absolute name; namespace ignored |
54
+ | `~/foo/bar` | `ns` | `me` | | `me/foo/bar` | Homeful name |
55
+ | `sensor/*/temp` | `diag` | `me` | | `diag/sensor/*/temp` | Pattern with `*` |
56
+ | `/sensor/>` | `diag` | `me` | | `sensor/>` | Pattern with trailing `>` |
57
+ | `foo/bar` | `ns` | `me` | `foo/bar=~/zoo` | `me/zoo` | Remap first, then resolve |
58
+
59
+ Only exact `~` or `~/...` is homeful; `~ns` is literal. A matching remap overrides pinning.
60
+ Pins are allowed only on verbatim names, not on patterns.
61
+
62
+ Environment variables that control name remapping:
63
+
64
+ - `CYPHAL_NAMESPACE` — default namespace prepended to relative topic names.
65
+ - `CYPHAL_REMAP` — topic name remappings (`from=to` pairs, whitespace-separated).
66
+
67
+ See also :meth:`Node.remap`.
68
+
69
+ ### Publish
70
+
71
+ Publication is best-effort by default. Pass `reliable=True` when publishing to retry delivery until
72
+ acknowledged by every known subscriber or until the deadline; if the remote side does not acknowledge in time,
73
+ :class:`DeliveryError` is raised.
74
+
75
+ ```python
76
+ pub = node.advertise("sensor/temperature")
77
+ await pub(Instant.now() + 1.0, b"payload", reliable=True)
78
+ ```
79
+
80
+ ### Subscribe
81
+
82
+ Subscriptions normally yield messages as soon as they arrive. Set `reordering_window` [seconds] on
83
+ :meth:`Node.subscribe` to allow delaying out-of-order messages to reconstruct the original publication order.
84
+ This is useful for sensor feeds and state estimators.
85
+
86
+ ```python
87
+ sub = node.subscribe("sensor/temperature", reordering_window=0.1)
88
+ ```
89
+
90
+ Pattern matching is supported: use `*` to match one name segment (e.g., `sensor/*/temperature`)
91
+ and a trailing `>` to match zero or more trailing segments (e.g., `sensor/>`).
92
+ Pattern subscribers automatically join matching topics as they appear, and unsubscribe as they disappear.
93
+
94
+ ```python
95
+ sub = node.subscribe("sensor/*/temperature")
96
+ async for arrival in sub:
97
+ topic = arrival.breadcrumb.topic
98
+ captures = sub.substitutions(topic)
99
+ print(topic.name, captures) # [('engine', 1)], where 1 is the pattern segment index
100
+ ```
101
+
102
+ ### RPC & streaming
103
+
104
+ RPC is layered directly on top of pub/sub. Use :meth:`Publisher.request` to publish a message that expects
105
+ responses, and use :attr:`Arrival.breadcrumb` on the subscriber side to send a unicast reply back to the requester.
106
+ One request may yield responses from multiple subscribers.
107
+
108
+ ```python
109
+ stream = await pub.request(Instant.now() + 1.0, 0.5, b"read")
110
+ async for response in stream:
111
+ print(response.message)
112
+ ```
113
+
114
+ Streaming is just repeated replying on the same breadcrumb. The requester consumes such replies through
115
+ :class:`ResponseStream`; each responder numbers its own responses from zero upward.
116
+
117
+ ```python
118
+ await arrival.breadcrumb(Instant.now() + 1.0, b"chunk-1", reliable=True)
119
+ await arrival.breadcrumb(Instant.now() + 1.0, b"chunk-2", reliable=True)
120
+ ```
121
+
122
+ ### Topic pinning
123
+
124
+ Topics may be pinned to a specific subject-ID using `name#1234` to bypass automatic assignment.
125
+ This is useful for applications where a high degree of determinism is required and for Cyphal/CAN v1.0 interoperability.
126
+ Pattern names (e.g., `sensor/*/temperature/>`) cannot be pinned.
127
+
128
+ To join a Cyphal/CAN v1.0 subject, use topic name of the form `subject_id#subject_id`; e.g., `7509#7509`.
129
+
130
+ ```python
131
+ pub = node.advertise("motor/status#1234")
132
+ sub = node.subscribe("1234#1234")
133
+ ```
134
+
135
+ Old Cyphal/CAN v1.0 nodes do not participate in the topic discovery protocol,
136
+ so topics joined only by such nodes are not discoverable by pattern subscribers.
137
+
138
+ ## Remarks
139
+
140
+ Cyphal does not define a serialization format. Previous versions used to define the DSDL format but it has been
141
+ extracted into an independent project, and Cyphal was made serialization-agnostic in v1.1+.
142
+
143
+ PyCyphal v2 is published on PyPI as [`pycyphal2`](https://pypi.org/project/pycyphal2/)
144
+ to enable coexistence with the original [`pycyphal` v1](https://pypi.org/project/pycyphal/)
145
+ in the same Python environment.
146
+ The two packages have radically different APIs but are wire-compatible on Cyphal/CAN.
147
+ The maintenance of the original `pycyphal` package will eventually cease;
148
+ existing applications leveraging `pycyphal` should upgrade to the new API of `pycyphal2`.
149
+ """
150
+
151
+ from __future__ import annotations
152
+
153
+ from ._api import *
154
+ from ._transport import SubjectWriter as SubjectWriter
155
+ from ._transport import Transport as Transport
156
+ from ._transport import TransportArrival as TransportArrival
157
+
158
+ __version__ = "2.0.0.dev2"
159
+
160
+ # pdoc needs __all__ to display re-exported members.
161
+ __all__ = [
162
+ _k
163
+ for _k, _v in vars().items()
164
+ if not _k.startswith("_")
165
+ and _k not in {"annotations", "TYPE_CHECKING"}
166
+ and (getattr(_v, "__module__", None) or "").startswith(__name__)
167
+ ]
@@ -131,7 +131,6 @@ class Closable(ABC):
131
131
  class Topic(ABC):
132
132
  """
133
133
  Topics are managed automatically by the library, created and destroyed as necessary.
134
- This is just a compact view to expose some auxiliary information.
135
134
  """
136
135
 
137
136
  @property
@@ -144,6 +143,16 @@ class Topic(ABC):
144
143
  def name(self) -> str:
145
144
  raise NotImplementedError
146
145
 
146
+ @property
147
+ @abstractmethod
148
+ def evictions(self) -> int:
149
+ raise NotImplementedError
150
+
151
+ @abstractmethod
152
+ def subject_id(self, modulus: int) -> int:
153
+ """The modulus can be obtained from :attr:`Transport.subject_id_modulus`."""
154
+ raise NotImplementedError
155
+
147
156
  @abstractmethod
148
157
  def match(self, pattern: str) -> list[tuple[str, int]] | None:
149
158
  """
@@ -475,6 +484,11 @@ class Node(Closable, ABC):
475
484
  def namespace(self) -> str:
476
485
  raise NotImplementedError
477
486
 
487
+ @property
488
+ @abstractmethod
489
+ def transport(self) -> Transport:
490
+ raise NotImplementedError
491
+
478
492
  @abstractmethod
479
493
  def remap(self, spec: str | dict[str, str]) -> None:
480
494
  """
@@ -306,6 +306,7 @@ class _TopicFlyweight(Topic):
306
306
 
307
307
  _topic_hash: int
308
308
  _name: str
309
+ _evictions: int
309
310
 
310
311
  @property
311
312
  def hash(self) -> int:
@@ -315,6 +316,13 @@ class _TopicFlyweight(Topic):
315
316
  def name(self) -> str:
316
317
  return self._name
317
318
 
319
+ @property
320
+ def evictions(self) -> int:
321
+ return self._evictions
322
+
323
+ def subject_id(self, modulus: int) -> int:
324
+ return compute_subject_id(self._topic_hash, self._evictions, modulus)
325
+
318
326
  def match(self, pattern: str) -> list[tuple[str, int]] | None:
319
327
  return match_pattern(pattern, self._name)
320
328
 
@@ -364,7 +372,7 @@ class TopicImpl(Topic):
364
372
  self._node = node
365
373
  self._name = name
366
374
  self._topic_hash = rapidhash(name)
367
- self.evictions = evictions
375
+ self._evictions = evictions
368
376
  self.ts_origin = now
369
377
  self.ts_animated = now
370
378
  self._pub_tag_baseline = int.from_bytes(os.urandom(8), "little")
@@ -392,14 +400,20 @@ class TopicImpl(Topic):
392
400
  def name(self) -> str:
393
401
  return self._name
394
402
 
403
+ @property
404
+ def evictions(self) -> int:
405
+ return self._evictions
406
+
407
+ def set_evictions(self, evictions: int) -> None:
408
+ self._evictions = evictions
409
+
410
+ def subject_id(self, modulus: int) -> int:
411
+ return compute_subject_id(self._topic_hash, self._evictions, modulus)
412
+
395
413
  def match(self, pattern: str) -> list[tuple[str, int]] | None:
396
414
  return match_pattern(pattern, self._name)
397
415
 
398
416
  # -- Internal --
399
- @property
400
- def subject_id(self) -> int:
401
- return compute_subject_id(self._topic_hash, self.evictions, self._node.transport.subject_id_modulus)
402
-
403
417
  def lage(self, now: float) -> int:
404
418
  return log_age(self.ts_origin, now)
405
419
 
@@ -426,14 +440,14 @@ class TopicImpl(Topic):
426
440
 
427
441
  def ensure_writer(self) -> SubjectWriter:
428
442
  if self.pub_writer is None:
429
- sid = self.subject_id
443
+ sid = self.subject_id(self._node.transport.subject_id_modulus)
430
444
  self.pub_writer = self._node.acquire_subject_writer(self, sid)
431
445
  _logger.info("Writer acquired for '%s' sid=%d", self._name, sid)
432
446
  return self.pub_writer
433
447
 
434
448
  def ensure_listener(self) -> None:
435
449
  if self.sub_listener is None and self.couplings:
436
- sid = self.subject_id
450
+ sid = self.subject_id(self._node.transport.subject_id_modulus)
437
451
  self.sub_listener = self._node.acquire_subject_listener(self, sid)
438
452
  _logger.info("Listener acquired for '%s' sid=%d", self._name, sid)
439
453
 
@@ -441,12 +455,12 @@ class TopicImpl(Topic):
441
455
  if self.couplings:
442
456
  self.ensure_listener()
443
457
  elif self.sub_listener is not None:
444
- self._node.release_subject_listener(self, self.subject_id)
458
+ self._node.release_subject_listener(self, self.subject_id(self._node.transport.subject_id_modulus))
445
459
  self.sub_listener = None
446
460
  _logger.info("Listener released for '%s'", self._name)
447
461
 
448
462
  def release_transport_handles(self) -> None:
449
- sid = self.subject_id
463
+ sid = self.subject_id(self._node.transport.subject_id_modulus)
450
464
  if self.pub_writer is not None:
451
465
  self._node.release_subject_writer(self, sid)
452
466
  self.pub_writer = None
@@ -487,7 +501,7 @@ def left_wins(l_lage: int, l_hash: int, r_lage: int, r_hash: int) -> bool:
487
501
 
488
502
  class NodeImpl(Node):
489
503
  def __init__(self, transport: Transport, *, home: str, namespace: str) -> None:
490
- self.transport = transport
504
+ self._transport = transport
491
505
  self._home = home
492
506
  self._namespace = namespace
493
507
  self._remaps: dict[str, str] = {}
@@ -555,6 +569,10 @@ class NodeImpl(Node):
555
569
  def namespace(self) -> str:
556
570
  return self._namespace
557
571
 
572
+ @property
573
+ def transport(self) -> Transport:
574
+ return self._transport
575
+
558
576
  def remap(self, spec: str | dict[str, str]) -> None:
559
577
  if isinstance(spec, str):
560
578
  spec = dict(x.split("=", 1) for x in spec.split() if "=" in x)
@@ -573,7 +591,12 @@ class NodeImpl(Node):
573
591
  topic.pub_count += 1
574
592
  topic.sync_implicit()
575
593
  topic.ensure_writer()
576
- _logger.info("Advertise '%s' -> '%s' sid=%d", name, resolved, topic.subject_id)
594
+ _logger.info(
595
+ "Advertise '%s' -> '%s' sid=%d",
596
+ name,
597
+ resolved,
598
+ topic.subject_id(self.transport.subject_id_modulus),
599
+ )
577
600
  return PublisherImpl(self, topic)
578
601
 
579
602
  def subscribe(self, name: str, *, reordering_window: float | None = None) -> Subscriber:
@@ -662,29 +685,34 @@ class NodeImpl(Node):
662
685
  self.couple_topic_root(topic, root)
663
686
  topic.sync_listener()
664
687
  self.notify_implicit_gc()
665
- _logger.info("Topic created '%s' hash=%016x sid=%d", name, topic.hash, topic.subject_id)
688
+ _logger.info(
689
+ "Topic created '%s' hash=%016x sid=%d",
690
+ name,
691
+ topic.hash,
692
+ topic.subject_id(self.transport.subject_id_modulus),
693
+ )
666
694
  return topic
667
695
 
668
696
  def topic_allocate(self, topic: TopicImpl, new_evictions: int, now: float) -> None:
669
697
  """Iterative subject-ID allocation with collision resolution. Mirrors topic_allocate() in cy.c."""
670
698
  # Work queue: list of (topic, new_evictions) pairs to process.
699
+ modulus = self.transport.subject_id_modulus
671
700
  work: list[tuple[TopicImpl, int]] = [(topic, new_evictions)]
672
701
  while work:
673
702
  t, ev = work.pop(0)
674
703
  # Remove from subject-ID index first.
675
- old_sid = t.subject_id
704
+ old_sid = t.subject_id(modulus)
676
705
  if old_sid in self.topics_by_subject_id and self.topics_by_subject_id[old_sid] is t:
677
706
  del self.topics_by_subject_id[old_sid]
678
707
 
679
708
  if ev >= EVICTIONS_PINNED_MIN:
680
709
  # Pinned topic: no collision detection, shared subject-IDs are fine.
681
710
  t.release_transport_handles()
682
- t.evictions = ev
711
+ t.set_evictions(ev)
683
712
  t.sync_listener()
684
713
  self.schedule_gossip_urgent(t)
685
714
  continue
686
715
 
687
- modulus = self.transport.subject_id_modulus
688
716
  new_sid = compute_subject_id(t.hash, ev, modulus)
689
717
  collider = self.topics_by_subject_id.get(new_sid)
690
718
 
@@ -694,14 +722,14 @@ class NodeImpl(Node):
694
722
  if collider is None:
695
723
  # No collision, install.
696
724
  t.release_transport_handles()
697
- t.evictions = ev
725
+ t.set_evictions(ev)
698
726
  self.topics_by_subject_id[new_sid] = t
699
727
  t.sync_listener()
700
728
  self.schedule_gossip_urgent(t)
701
729
  elif left_wins(t.lage(now), t.hash, collider.lage(now), collider.hash):
702
730
  # Our topic wins: take the slot, evict the collider.
703
731
  t.release_transport_handles()
704
- t.evictions = ev
732
+ t.set_evictions(ev)
705
733
  del self.topics_by_subject_id[new_sid]
706
734
  self.topics_by_subject_id[new_sid] = t
707
735
  if collider.pub_writer is not None:
@@ -1293,7 +1321,7 @@ class NodeImpl(Node):
1293
1321
  self._notify_monitors(topic)
1294
1322
  else:
1295
1323
  self.on_gossip_unknown(hdr.topic_hash, hdr.topic_evictions, hdr.topic_log_age, ts)
1296
- self._notify_monitors(_TopicFlyweight(hdr.topic_hash, name))
1324
+ self._notify_monitors(_TopicFlyweight(hdr.topic_hash, name, hdr.topic_evictions))
1297
1325
 
1298
1326
  def on_gossip_known(
1299
1327
  self,
@@ -1430,7 +1458,7 @@ class NodeImpl(Node):
1430
1458
  self.decouple_topic_root(topic, topic.couplings[0].root, sync_lifecycle=False)
1431
1459
  self.topics_by_name.pop(name, None)
1432
1460
  self.topics_by_hash.pop(topic.hash, None)
1433
- sid = topic.subject_id
1461
+ sid = topic.subject_id(self.transport.subject_id_modulus)
1434
1462
  if self.topics_by_subject_id.get(sid) is topic:
1435
1463
  del self.topics_by_subject_id[sid]
1436
1464
  topic.associations.clear()
@@ -12,7 +12,7 @@ transport = CANTransport.new(SocketCANInterface("can0"))
12
12
  ```
13
13
 
14
14
  Python-CAN is useful when the application runs not on GNU/Linux or already uses `python-can` or needs
15
- `one of its *many* hardware backends <https://python-can.readthedocs.io/en/stable/interfaces.html>`_
15
+ [one of its *many* hardware backends](https://python-can.readthedocs.io/en/stable/interfaces.html)
16
16
  -- GS-USB, SLCAN, PCAN, etc:
17
17
 
18
18
  ```python
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pycyphal2
3
- Version: 2.0.0.dev0
3
+ Version: 2.0.0.dev2
4
4
  Summary: Pure-Python implementation of Cyphal -- a simple and robust real-time publish/subscribe stack that runs anywhere.
5
5
  Author-email: Pavel Kirienko and OpenCyphal team <pavel@opencyphal.org>
6
6
  License: MIT
@@ -38,6 +38,7 @@ _pub/sub without steroids_
38
38
 
39
39
  [![Website](https://img.shields.io/badge/website-opencyphal.org-black?color=1700b3)](https://opencyphal.org/)
40
40
  [![Forum](https://img.shields.io/discourse/https/forum.opencyphal.org/users.svg?logo=discourse&color=1700b3)](https://forum.opencyphal.org)
41
+ [![PyPI](https://img.shields.io/pypi/v/pycyphal2.svg)](https://pypi.org/project/pycyphal2/)
41
42
  [![Docs](https://img.shields.io/badge/Docs-rtfm-black?color=ff00aa&logo=readthedocs)](https://opencyphal.github.io/pycyphal)
42
43
 
43
44
  </div>
@@ -46,12 +47,10 @@ _pub/sub without steroids_
46
47
 
47
48
  Python implementation of the [Cyphal](https://opencyphal.org) stack that runs on GNU/Linux, Windows, and macOS.
48
49
 
49
- Install as follows.
50
- Optional features inside the brackets can be removed if not needed; see `pyproject.toml` for the full list:
51
-
52
- ```
53
- pip install pycyphal2[udp,pythoncan]
54
- ```
50
+ PyCyphal v2 is published on PyPI as `pycyphal2` to enable coexistence with v1 `pycyphal` in the same Python environment.
51
+ The two packages have radically different APIs but are wire-compatible on Cyphal/CAN.
52
+ The maintenance of the original `pycyphal` package will eventually cease;
53
+ existing applications leveraging `pycyphal` should upgrade to the new API of `pycyphal2`.
55
54
 
56
55
  📚 **Read the docs** at <https://opencyphal.github.io/pycyphal>.
57
56
 
@@ -266,7 +266,7 @@ async def test_topic_destroy():
266
266
  topic = node.topics_by_name.get("to_destroy")
267
267
  assert topic is not None
268
268
  topic_hash = topic.hash
269
- sid = topic.subject_id
269
+ sid = topic.subject_id(tr.subject_id_modulus)
270
270
 
271
271
  pub.close() # Allow destroy.
272
272
  node.destroy_topic("to_destroy")
@@ -380,7 +380,7 @@ async def test_topic_collision_during_allocate():
380
380
 
381
381
  pub_a = node.advertise("/topic_alpha")
382
382
  topic_a = node.topics_by_name["topic_alpha"]
383
- sid_a = topic_a.subject_id
383
+ sid_a = topic_a.subject_id(tr.subject_id_modulus)
384
384
 
385
385
  # Find a name that collides with topic_a's subject-ID.
386
386
  from pycyphal2._hash import rapidhash
@@ -394,7 +394,7 @@ async def test_topic_collision_during_allocate():
394
394
  pub_b = node.advertise(f"/{name}")
395
395
  topic_b = node.topics_by_name[name]
396
396
  # One of them should have been reallocated.
397
- assert topic_a.subject_id != topic_b.subject_id
397
+ assert topic_a.subject_id(tr.subject_id_modulus) != topic_b.subject_id(tr.subject_id_modulus)
398
398
  pub_b.close()
399
399
  break
400
400
 
@@ -47,6 +47,14 @@ async def test_node_creation_and_home():
47
47
  node.close()
48
48
 
49
49
 
50
+ async def test_node_exposes_transport_property():
51
+ net = MockNetwork()
52
+ tr = MockTransport(node_id=1, network=net)
53
+ node = new_node(tr, home="my_home")
54
+ assert node.transport is tr
55
+ node.close()
56
+
57
+
50
58
  async def test_node_namespace():
51
59
  """Namespace should affect name resolution."""
52
60
  net = MockNetwork()
@@ -121,7 +129,7 @@ async def test_pinned_topic():
121
129
 
122
130
  pub = node.advertise("/my/topic#42")
123
131
  topic = list(node.topics_by_name.values())[0]
124
- assert topic.subject_id == 42
132
+ assert topic.subject_id(tr.subject_id_modulus) == 42
125
133
  assert topic.evictions == 0xFFFFFFFF - 42
126
134
 
127
135
  pub.close()
@@ -348,7 +356,7 @@ async def test_remap_advertise_pinned():
348
356
  pub = node.advertise("my/topic")
349
357
  topic = list(node.topics_by_name.values())[0]
350
358
  assert topic.name == "ns/remapped"
351
- assert topic.subject_id == 42
359
+ assert topic.subject_id(tr.subject_id_modulus) == 42
352
360
 
353
361
  pub.close()
354
362
  node.close()
@@ -115,6 +115,7 @@ async def test_monitor_implicit_topic_creation_reports_local_topic_instead_of_fl
115
115
 
116
116
  async def test_monitor_unknown_topic_uses_flyweight_with_wire_identity() -> None:
117
117
  node = new_node(MockTransport(node_id=1), home="n1")
118
+ modulus = node.transport.subject_id_modulus
118
119
 
119
120
  received: list[pycyphal2.Topic] = []
120
121
  node.monitor(received.append)
@@ -128,6 +129,8 @@ async def test_monitor_unknown_topic_uses_flyweight_with_wire_identity() -> None
128
129
  assert not isinstance(received[0], TopicImpl)
129
130
  assert received[0].hash == topic_hash
130
131
  assert received[0].name == name
132
+ assert received[0].evictions == 0
133
+ assert received[0].subject_id(modulus) == pycyphal2.SUBJECT_ID_PINNED_MAX + 1 + (topic_hash % modulus)
131
134
  assert received[0].match("sensor/*") == [("temp", 1)]
132
135
 
133
136
  node.close()
@@ -142,6 +145,7 @@ async def test_monitor_unknown_topic_uses_flyweight_with_wire_identity() -> None
142
145
  )
143
146
  async def test_monitor_unknown_topic_preserves_decoded_wire_name(name_bytes: bytes, expected_name: str) -> None:
144
147
  node = new_node(MockTransport(node_id=1), home="n1")
148
+ modulus = node.transport.subject_id_modulus
145
149
 
146
150
  received: list[pycyphal2.Topic] = []
147
151
  node.monitor(received.append)
@@ -154,6 +158,8 @@ async def test_monitor_unknown_topic_preserves_decoded_wire_name(name_bytes: byt
154
158
  assert len(received) == 1
155
159
  assert received[0].hash == 0xDEADBEEFCAFEBABE
156
160
  assert received[0].name == expected_name
161
+ assert received[0].evictions == 3
162
+ assert received[0].subject_id(modulus) == pycyphal2.SUBJECT_ID_PINNED_MAX + 1 + ((0xDEADBEEFCAFEBABE + 9) % modulus)
157
163
 
158
164
  node.close()
159
165
 
@@ -179,7 +185,7 @@ async def test_monitor_is_not_invoked_for_inline_gossip_on_message_reception() -
179
185
  ).serialize()
180
186
  + b"data",
181
187
  )
182
- node.on_subject_arrival(topic.subject_id, arrival)
188
+ node.on_subject_arrival(topic.subject_id(node.transport.subject_id_modulus), arrival)
183
189
 
184
190
  assert received == []
185
191
 
@@ -44,7 +44,7 @@ async def test_crdt_collision_older_topic_wins():
44
44
  # Create topic_a first (it will be older).
45
45
  pub_a = node.advertise("/topic_a")
46
46
  topic_a = node.topics_by_name["topic_a"]
47
- sid_a = topic_a.subject_id
47
+ sid_a = topic_a.subject_id(tr.subject_id_modulus)
48
48
 
49
49
  # Search for a colliding name.
50
50
  modulus = tr.subject_id_modulus
@@ -66,7 +66,7 @@ async def test_crdt_collision_older_topic_wins():
66
66
  topic_b = node.topics_by_name[colliding_name]
67
67
 
68
68
  # topic_a should keep its subject-ID since it is older; topic_b should have been evicted.
69
- assert topic_a.subject_id != topic_b.subject_id
69
+ assert topic_a.subject_id(tr.subject_id_modulus) != topic_b.subject_id(tr.subject_id_modulus)
70
70
  assert topic_b.evictions > 0 # loser got bumped
71
71
  assert topic_a.evictions == 0 # winner untouched
72
72
 
@@ -211,7 +211,7 @@ async def test_msg_header_merges_lage():
211
211
  remote_id=99,
212
212
  message=hdr.serialize() + b"payload",
213
213
  )
214
- node.on_subject_arrival(topic.subject_id, arrival)
214
+ node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
215
215
 
216
216
  # After merge, our lage should have increased to at least the remote's claim.
217
217
  merged_lage = topic.lage(time.monotonic())
@@ -375,7 +375,7 @@ async def test_best_effort_full_pipeline():
375
375
  await pub(pycyphal2.Instant.now() + 1.0, b"test_payload")
376
376
 
377
377
  # Verify the transport writer was invoked.
378
- writer = tr.writers.get(topic.subject_id)
378
+ writer = tr.writers.get(topic.subject_id(tr.subject_id_modulus))
379
379
  assert writer is not None
380
380
  assert writer.send_count >= 1
381
381
 
@@ -443,7 +443,7 @@ async def test_pinned_topic_formula():
443
443
  for pin_val in [0, 1, 42, 100, SUBJECT_ID_PINNED_MAX]:
444
444
  pub = node.advertise(f"/pin_{pin_val}#{pin_val}")
445
445
  topic = node.topics_by_name[f"pin_{pin_val}"]
446
- assert topic.subject_id == pin_val
446
+ assert topic.subject_id(tr.subject_id_modulus) == pin_val
447
447
  assert topic.evictions == 0xFFFFFFFF - pin_val
448
448
  pub.close()
449
449
 
@@ -462,8 +462,8 @@ async def test_multiple_pinned_topics_share_subject_id():
462
462
  topic_b = node.topics_by_name["beta"]
463
463
 
464
464
  # Both should have subject-ID 42.
465
- assert topic_a.subject_id == 42
466
- assert topic_b.subject_id == 42
465
+ assert topic_a.subject_id(tr.subject_id_modulus) == 42
466
+ assert topic_b.subject_id(tr.subject_id_modulus) == 42
467
467
  assert topic_a.pub_writer is topic_b.pub_writer
468
468
  assert tr.subject_writer_creations.get(42) == 1
469
469
 
@@ -160,7 +160,7 @@ async def test_reliable_publish_retry_rebuilds_writer_and_header_after_reallocat
160
160
  pub.priority = pycyphal2.Priority.EXCEPTIONAL
161
161
  pub.ack_timeout = 0.1
162
162
  topic = node.topics_by_name["topic"]
163
- old_sid = topic.subject_id
163
+ old_sid = topic.subject_id(tr.subject_id_modulus)
164
164
  old_evictions = topic.evictions
165
165
  old_messages: list[TransportArrival] = []
166
166
  new_messages: list[TransportArrival] = []
@@ -189,7 +189,7 @@ async def test_reliable_publish_retry_rebuilds_writer_and_header_after_reallocat
189
189
  )
190
190
  node.on_subject_arrival(node.broadcast_subject_id, gossip_arrival)
191
191
 
192
- new_sid = topic.subject_id
192
+ new_sid = topic.subject_id(tr.subject_id_modulus)
193
193
  assert new_sid != old_sid
194
194
  observer.subject_listen(new_sid, new_messages.append)
195
195
 
@@ -231,7 +231,7 @@ async def test_gossip_reallocation_to_occupied_subject_preserves_writer():
231
231
 
232
232
  pub_b = node.advertise(colliding_name)
233
233
  topic_b = node.topics_by_name[colliding_name.removeprefix("/")]
234
- sid_b = topic_b.subject_id
234
+ sid_b = topic_b.subject_id(tr.subject_id_modulus)
235
235
  writer_b = expect_mock_writer(topic_b.pub_writer)
236
236
 
237
237
  now = pycyphal2.Instant.now().s
@@ -245,11 +245,11 @@ async def test_gossip_reallocation_to_occupied_subject_preserves_writer():
245
245
  remote_lage = topic_a.lage(now) + 1
246
246
  node.on_gossip_known(topic_a, remote_evictions, remote_lage, now, GossipScope.SHARDED)
247
247
 
248
- assert topic_a.subject_id == sid_b
248
+ assert topic_a.subject_id(tr.subject_id_modulus) == sid_b
249
249
  assert topic_a.pub_writer is writer_b
250
250
  assert tr.subject_writer_creations.get(sid_b) == writer_creations_before == 1
251
251
  assert topic_b.pub_writer is None
252
- assert topic_b.subject_id != sid_b
252
+ assert topic_b.subject_id(tr.subject_id_modulus) != sid_b
253
253
 
254
254
  send_count_before = writer_b.send_count
255
255
  await pub_a(pycyphal2.Instant.now() + 1.0, b"payload")
@@ -439,7 +439,7 @@ async def test_gossip_unknown_topic_collision():
439
439
 
440
440
  topic_a = node.topics_by_name.get("topic_a")
441
441
  assert topic_a is not None
442
- old_sid = topic_a.subject_id
442
+ old_sid = topic_a.subject_id(tr.subject_id_modulus)
443
443
 
444
444
  # Craft a gossip from a different topic that happens to claim the same subject-ID.
445
445
  # Use a fake hash that maps to the same subject-ID with evictions=0.
@@ -469,7 +469,7 @@ async def test_gossip_unknown_topic_collision():
469
469
  node.on_subject_arrival(node.broadcast_subject_id, arrival)
470
470
  await asyncio.sleep(0.02)
471
471
  # Our topic should have been reallocated.
472
- assert topic_a.subject_id != old_sid or topic_a.evictions > 0
472
+ assert topic_a.subject_id(tr.subject_id_modulus) != old_sid or topic_a.evictions > 0
473
473
 
474
474
  pub.close()
475
475
  node.close()
@@ -753,7 +753,7 @@ async def test_reliable_msg_sends_ack():
753
753
  remote_id=99,
754
754
  message=msg_data,
755
755
  )
756
- node.on_subject_arrival(topic.subject_id, arrival)
756
+ node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
757
757
 
758
758
  # Give ACK task time to run.
759
759
  await asyncio.sleep(0.02)
@@ -781,7 +781,8 @@ async def test_reliable_msg_wrong_subject_dropped():
781
781
 
782
782
  topic = list(node.topics_by_name.values())[0]
783
783
  subject_id_max = pycyphal2.SUBJECT_ID_PINNED_MAX + tr.subject_id_modulus
784
- wrong_subject_id = topic.subject_id + 1 if topic.subject_id < subject_id_max else topic.subject_id - 1
784
+ topic_subject_id = topic.subject_id(tr.subject_id_modulus)
785
+ wrong_subject_id = topic_subject_id + 1 if topic_subject_id < subject_id_max else topic_subject_id - 1
785
786
  hdr = MsgRelHeader(
786
787
  topic_log_age=0,
787
788
  topic_evictions=topic.evictions,
@@ -828,8 +829,8 @@ async def test_reliable_msg_dedup():
828
829
  )
829
830
 
830
831
  # Deliver twice.
831
- node.on_subject_arrival(topic.subject_id, arrival)
832
- node.on_subject_arrival(topic.subject_id, arrival)
832
+ node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
833
+ node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
833
834
 
834
835
  # Should only get one message.
835
836
  assert sub.queue.qsize() == 1
@@ -891,7 +892,7 @@ async def test_reliable_msg_ordered_late_drop_sends_no_ack_or_nack():
891
892
  remote_id=99,
892
893
  message=hdr.serialize() + f"m{tag}".encode(),
893
894
  )
894
- node.on_subject_arrival(topic.subject_id, arrival)
895
+ node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
895
896
  await asyncio.sleep(0.02)
896
897
  tr.unicast_log.clear()
897
898
  await sub.queue.get()
@@ -908,7 +909,7 @@ async def test_reliable_msg_ordered_late_drop_sends_no_ack_or_nack():
908
909
  remote_id=99,
909
910
  message=late_hdr.serialize() + b"late",
910
911
  )
911
- node.on_subject_arrival(topic.subject_id, late_arrival)
912
+ node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), late_arrival)
912
913
  await asyncio.sleep(0.02)
913
914
 
914
915
  assert tr.unicast_log == []
@@ -1011,7 +1012,7 @@ async def test_multicast_msg_ack_ignored():
1011
1012
  remote_id=42,
1012
1013
  message=MsgAckHeader(topic_hash=topic.hash, tag=tag).serialize(),
1013
1014
  )
1014
- node.on_subject_arrival(topic.subject_id, arrival)
1015
+ node.on_subject_arrival(topic.subject_id(tr.subject_id_modulus), arrival)
1015
1016
 
1016
1017
  assert not tracker.acknowledged
1017
1018
  assert tracker.remaining == {42}
@@ -109,7 +109,7 @@ async def test_advertise_assigns_subject_id():
109
109
  resolved, _, _ = resolve_name("my/topic", "test_node", "")
110
110
  topic = node.topics_by_name[resolved]
111
111
 
112
- sid = topic.subject_id
112
+ sid = topic.subject_id(tr.subject_id_modulus)
113
113
  assert sid == compute_subject_id(topic.hash, topic.evictions, DEFAULT_MODULUS)
114
114
  assert node.topics_by_subject_id.get(sid) is topic
115
115
 
@@ -127,7 +127,7 @@ async def test_advertise_pinned_topic():
127
127
  resolved, pin, _ = resolve_name("my/topic#42", "test_node", "")
128
128
  assert pin == 42
129
129
  topic = node.topics_by_name[resolved]
130
- assert topic.subject_id == 42
130
+ assert topic.subject_id(tr.subject_id_modulus) == 42
131
131
 
132
132
  pub.close()
133
133
  node.close()
@@ -179,7 +179,7 @@ async def test_topic_collision_evicts_loser():
179
179
  topic2 = node.topics_by_name[resolved2]
180
180
 
181
181
  # Both topics should exist with non-colliding subject-IDs (the allocator resolved them).
182
- assert topic1.subject_id != topic2.subject_id or topic1 is topic2
182
+ assert topic1.subject_id(tr.subject_id_modulus) != topic2.subject_id(tr.subject_id_modulus) or topic1 is topic2
183
183
  assert topic1.name in node.topics_by_name
184
184
  assert topic2.name in node.topics_by_name
185
185
 
@@ -217,7 +217,7 @@ async def test_collision_allocator_iterates():
217
217
  # Collect all subject-IDs (non-pinned).
218
218
  sids = set()
219
219
  for name, topic in node.topics_by_name.items():
220
- sid = topic.subject_id
220
+ sid = topic.subject_id(tr.subject_id_modulus)
221
221
  if sid not in sids:
222
222
  sids.add(sid)
223
223
  else:
@@ -318,7 +318,7 @@ async def test_gossip_unknown_collision_we_win():
318
318
  pub = node.advertise("my/topic")
319
319
  resolved, _, _ = resolve_name("my/topic", "test_node", "")
320
320
  topic = node.topics_by_name[resolved]
321
- my_sid = topic.subject_id
321
+ my_sid = topic.subject_id(tr.subject_id_modulus)
322
322
 
323
323
  # Make our topic very old so we win.
324
324
  topic.ts_origin = time.monotonic() - 100000
@@ -1,89 +0,0 @@
1
- """
2
- `Cyphal <https://opencyphal.org>`_ in Python —
3
- decentralized real-time pub/sub with tunable reliability, service discovery, and zero configuration.
4
- Works anywhere, `even baremetal MCUs <https://github.com/OpenCyphal-Garage/cy>`_.
5
-
6
- Supports various transports such as Ethernet (UDP) and CAN FD with optional redundancy.
7
- Set up a transport, make a node, publish and subscribe:
8
-
9
- ```python
10
- from pycyphal2 import Node, Instant
11
- from pycyphal2.udp import UDPTransport
12
-
13
- async def main():
14
- node = Node.new(UDPTransport.new(), "my_node")
15
-
16
- pub = node.advertise("sensor/temperature")
17
- await pub(Instant.now() + 1.0, b"21.5")
18
-
19
- sub = node.subscribe("sensor/temperature")
20
- async for arrival in sub:
21
- print(arrival.message)
22
- ```
23
-
24
- All public symbols live at the top level — just `import pycyphal2`.
25
- Transport modules (`pycyphal2.udp`, `pycyphal2.can`) are imported separately
26
- so that only the needed dependencies are pulled in.
27
-
28
- The source repository contains a collection of runnable examples.
29
-
30
- Environment variables control name remapping similar to ROS:
31
-
32
- - `CYPHAL_NAMESPACE` — default namespace prepended to relative topic names.
33
- - `CYPHAL_REMAP` — topic name remappings (`from=to` pairs, whitespace-separated).
34
-
35
- Publication is best-effort by default. Pass ``reliable=True`` when publishing to retry delivery until
36
- acknowledged by every known subscriber or until the deadline; if the remote side does not acknowledge in time,
37
- :class:`DeliveryError` is raised.
38
-
39
- ```python
40
- await pub(Instant.now() + 1.0, b"payload", reliable=True)
41
- ```
42
-
43
- Subscriptions normally yield messages as soon as they arrive. Set ``reordering_window`` [seconds] on
44
- :meth:`Node.subscribe` to allow delaying out-of-order messages to reconstruct the original publication order.
45
- This is useful for sensor feeds and state estimators.
46
-
47
- ```python
48
- sub = node.subscribe("sensor/temperature", reordering_window=0.1)
49
- ```
50
-
51
- RPC is layered directly on top of pub/sub. Use :meth:`Publisher.request` to publish a message that expects
52
- responses, and use :attr:`Arrival.breadcrumb` on the subscriber side to send a unicast reply back to the requester.
53
- One request may yield responses from multiple subscribers.
54
-
55
- ```python
56
- stream = await pub.request(Instant.now() + 1.0, 0.5, b"read")
57
- async for response in stream:
58
- print(response.message)
59
- ```
60
-
61
- Streaming is just repeated replying on the same breadcrumb. The requester consumes such replies through
62
- :class:`ResponseStream`; each responder numbers its own responses from zero upward.
63
-
64
- ```python
65
- await arrival.breadcrumb(Instant.now() + 1.0, b"chunk-1", reliable=True)
66
- await arrival.breadcrumb(Instant.now() + 1.0, b"chunk-2", reliable=True)
67
- ```
68
-
69
- Cyphal does not define a serialization format. Previous versions used to define the DSDL format but it has been
70
- extracted into an independent project, and Cyphal was made serialization-agnostic in v1.1+.
71
- """
72
-
73
- from __future__ import annotations
74
-
75
- from ._api import *
76
- from ._transport import Transport as Transport
77
- from ._transport import TransportArrival as TransportArrival
78
- from ._transport import SubjectWriter as SubjectWriter
79
-
80
- __version__ = "2.0.0.dev0"
81
-
82
- # pdoc needs __all__ to display re-exported members.
83
- __all__ = [
84
- _k
85
- for _k, _v in vars().items()
86
- if not _k.startswith("_")
87
- and _k not in {"annotations", "TYPE_CHECKING"}
88
- and (getattr(_v, "__module__", None) or "").startswith(__name__)
89
- ]
File without changes
File without changes