pycyphal2 2.0.0.dev4__py3-none-any.whl → 2.0.0.dev6__py3-none-any.whl

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.
pycyphal2/__init__.py CHANGED
@@ -155,7 +155,7 @@ from ._transport import SubjectWriter as SubjectWriter
155
155
  from ._transport import Transport as Transport
156
156
  from ._transport import TransportArrival as TransportArrival
157
157
 
158
- __version__ = "2.0.0.dev4"
158
+ __version__ = "2.0.0.dev6"
159
159
 
160
160
  # pdoc needs __all__ to display re-exported members.
161
161
  __all__ = [
pycyphal2/_api.py CHANGED
@@ -219,7 +219,7 @@ class Publisher(Closable, ABC):
219
219
  Represents the intent to send messages on a topic.
220
220
 
221
221
  Calling the publisher sends one message.
222
- By default this is best-effort publication: the message is sent once and only immediate send failures are reported.
222
+ By default this is best-effort publication: the message is sent once and delivery is not confirmed.
223
223
  With ``reliable=True``, the library retransmits until the deadline and waits for acknowledgments from remote
224
224
  subscribers.
225
225
 
@@ -262,6 +262,9 @@ class Publisher(Closable, ABC):
262
262
  Blocks at most until ``deadline``.
263
263
  Raises :class:`SendError` if the message could not be sent before the deadline.
264
264
 
265
+ Returning means the message has been handed over to the network, not that anyone received it.
266
+ The call waits for that handover, so a congested network applies backpressure to the publisher.
267
+
265
268
  If ``reliable`` is false, the message is sent once.
266
269
  If ``reliable`` is true, the library retransmits until ``deadline`` leveraging :attr:`ack_timeout`.
267
270
  """
@@ -552,21 +555,17 @@ class Node(Closable, ABC):
552
555
  """
553
556
  from ._node import NodeImpl
554
557
 
555
- # Add random suffix if requested or generate pure random home.
556
558
  # Leading/trailing separators will be normalized away.
557
559
  home = home.strip() or "/"
558
560
  if home.endswith("/"):
559
561
  uid = transport.uid if hasattr(transport, "uid") else eui64()
560
562
  home += f"{uid:016x}"
561
563
 
562
- # Initialize the namespace: if not given explicitly, read it from the standard environment.
563
564
  namespace = namespace.strip() or os.getenv("CYPHAL_NAMESPACE", "").strip()
564
565
 
565
- # Construct the node.
566
566
  node = NodeImpl(transport, home=home, namespace=namespace)
567
567
  _logger.info("Constructed %s", node)
568
568
 
569
- # Set up default name remapping.
570
569
  try:
571
570
  node.remap(os.getenv("CYPHAL_REMAP", ""))
572
571
  except Exception as ex:
pycyphal2/_hash.py CHANGED
@@ -1,11 +1,5 @@
1
- """Hash and CRC utilities"""
2
-
3
1
  from __future__ import annotations
4
2
 
5
- # =====================================================================================================================
6
- # CRC-32C (Castagnoli)
7
- # =====================================================================================================================
8
-
9
3
  CRC32C_INITIAL = 0xFFFFFFFF
10
4
  CRC32C_OUTPUT_XOR = 0xFFFFFFFF
11
5
  CRC32C_RESIDUE = 0x48674BC7
@@ -59,10 +53,6 @@ def crc32c_full(data: bytes | memoryview) -> int:
59
53
  return crc32c_add(CRC32C_INITIAL, data) ^ CRC32C_OUTPUT_XOR
60
54
 
61
55
 
62
- # =====================================================================================================================
63
- # CRC-16/CCITT-FALSE
64
- # =====================================================================================================================
65
-
66
56
  CRC16CCITT_FALSE_INITIAL = 0xFFFF
67
57
  CRC16CCITT_FALSE_RESIDUE = 0x0000
68
58
  # fmt: off
@@ -103,10 +93,7 @@ def crc16ccitt_false_full(data: bytes | memoryview) -> int:
103
93
  return crc16ccitt_false_add(CRC16CCITT_FALSE_INITIAL, data)
104
94
 
105
95
 
106
- # =====================================================================================================================
107
96
  # rapidhash V3
108
- # =====================================================================================================================
109
-
110
97
  _RAPID_MASK = 0xFFFFFFFFFFFFFFFF
111
98
  _RAPID_SECRET = (
112
99
  0x2D358DCCAA6C78A5,
pycyphal2/_header.py CHANGED
@@ -11,11 +11,6 @@ LAGE_MIN = -1
11
11
  LAGE_MAX = 35
12
12
 
13
13
 
14
- # =====================================================================================================================
15
- # MSG headers
16
- # =====================================================================================================================
17
-
18
-
19
14
  @dataclass(frozen=True)
20
15
  class MsgBeHeader:
21
16
  TYPE = 0
@@ -76,11 +71,6 @@ def _deserialize_msg(buf: bytes | memoryview) -> tuple[int, int, int, int] | Non
76
71
  return (lage, evictions, topic_hash, tag)
77
72
 
78
73
 
79
- # =====================================================================================================================
80
- # MSG ACK/NACK headers
81
- # =====================================================================================================================
82
-
83
-
84
74
  @dataclass(frozen=True)
85
75
  class MsgAckHeader:
86
76
  TYPE = 2
@@ -131,11 +121,6 @@ def _deserialize_msg_ack(buf: bytes | memoryview) -> tuple[int, int] | None:
131
121
  return (topic_hash, tag)
132
122
 
133
123
 
134
- # =====================================================================================================================
135
- # RSP headers (responses)
136
- # =====================================================================================================================
137
-
138
-
139
124
  @dataclass(frozen=True)
140
125
  class RspBeHeader:
141
126
  TYPE = 4
@@ -172,11 +157,6 @@ class RspRelHeader:
172
157
  return RspRelHeader(*r) if r is not None else None
173
158
 
174
159
 
175
- # =====================================================================================================================
176
- # RSP ACK/NACK headers
177
- # =====================================================================================================================
178
-
179
-
180
160
  @dataclass(frozen=True)
181
161
  class RspAckHeader:
182
162
  TYPE = 6
@@ -237,11 +217,6 @@ def _deserialize_rsp(buf: bytes | memoryview) -> tuple[int, int, int, int] | Non
237
217
  return (tag, seqno, topic_hash, message_tag)
238
218
 
239
219
 
240
- # =====================================================================================================================
241
- # GOSSIP header
242
- # =====================================================================================================================
243
-
244
-
245
220
  @dataclass(frozen=True)
246
221
  class GossipHeader:
247
222
  TYPE = 8
@@ -275,11 +250,6 @@ class GossipHeader:
275
250
  return GossipHeader(lage, topic_hash, evictions, name_len)
276
251
 
277
252
 
278
- # =====================================================================================================================
279
- # SCOUT header
280
- # =====================================================================================================================
281
-
282
-
283
253
  @dataclass(frozen=True)
284
254
  class ScoutHeader:
285
255
  TYPE = 9
@@ -303,10 +273,6 @@ class ScoutHeader:
303
273
  return ScoutHeader(buf[23])
304
274
 
305
275
 
306
- # =====================================================================================================================
307
- # Dispatcher
308
- # =====================================================================================================================
309
-
310
276
  HeaderType = (
311
277
  MsgBeHeader
312
278
  | MsgRelHeader
pycyphal2/_node.py CHANGED
@@ -38,10 +38,6 @@ if TYPE_CHECKING:
38
38
 
39
39
  _logger = logging.getLogger(__name__)
40
40
 
41
- # =====================================================================================================================
42
- # Constants
43
- # =====================================================================================================================
44
-
45
41
  TOPIC_NAME_MAX = 200
46
42
  EVICTIONS_PINNED_MIN = 0xFFFFE000
47
43
  GOSSIP_PERIOD = 5.0
@@ -82,11 +78,6 @@ class GossipScope(Enum):
82
78
  INLINE = auto()
83
79
 
84
80
 
85
- # =====================================================================================================================
86
- # Name Resolution
87
- # =====================================================================================================================
88
-
89
-
90
81
  def _name_normalize(name: str) -> str:
91
82
  """Collapse separators, strip leading/trailing separators."""
92
83
  parts: list[str] = []
@@ -120,7 +111,6 @@ def _name_consume_pin_suffix(name: str) -> tuple[str, int | None]:
120
111
 
121
112
 
122
113
  def _name_join(left: str, right: str) -> str:
123
- """Join two name parts with separator, normalizing the result."""
124
114
  left = _name_normalize(left)
125
115
  right = _name_normalize(right)
126
116
  if left and right:
@@ -163,7 +153,6 @@ def resolve_name(
163
153
  if not name:
164
154
  raise ValueError("Empty name")
165
155
 
166
- # Strip pin suffix first.
167
156
  name, pin = _name_consume_pin_suffix(name)
168
157
 
169
158
  # Apply remapping: lookup on normalized pin-free name; matched rule replaces both name and pin.
@@ -173,7 +162,6 @@ def resolve_name(
173
162
  name = remaps[lookup]
174
163
  name, pin = _name_consume_pin_suffix(name)
175
164
 
176
- # Classify and construct.
177
165
  if name.startswith("/"):
178
166
  resolved = _name_normalize(name)
179
167
  elif _name_is_homeful(name):
@@ -191,7 +179,6 @@ def resolve_name(
191
179
  raise ValueError("Name resolves to empty string")
192
180
  if len(resolved) > TOPIC_NAME_MAX:
193
181
  raise ValueError(f"Resolved name exceeds {TOPIC_NAME_MAX} characters")
194
- # Validate characters: ASCII 33-126 and '/' only.
195
182
  for ch in resolved:
196
183
  o = ord(ch)
197
184
  if o < 33 or o > 126:
@@ -203,11 +190,6 @@ def resolve_name(
203
190
  return resolved, pin, verbatim
204
191
 
205
192
 
206
- # =====================================================================================================================
207
- # Pattern Matching
208
- # =====================================================================================================================
209
-
210
-
211
193
  def match_pattern(pattern: str, name: str) -> list[tuple[str, int]] | None:
212
194
  """
213
195
  Match a pattern against a topic name.
@@ -237,21 +219,12 @@ def match_pattern(pattern: str, name: str) -> list[tuple[str, int]] | None:
237
219
  return subs
238
220
 
239
221
 
240
- # =====================================================================================================================
241
- # Subject-ID Computation
242
- # =====================================================================================================================
243
-
244
-
245
222
  def compute_subject_id(topic_hash: int, evictions: int, modulus: int) -> int:
246
- """Compute the subject-ID for a topic given its hash, evictions, and subject-ID modulus."""
247
223
  if evictions >= EVICTIONS_PINNED_MIN:
248
224
  return 0xFFFFFFFF - evictions
249
- return SUBJECT_ID_PINNED_MAX + 1 + ((topic_hash + (evictions * evictions)) % modulus)
250
-
251
-
252
- # =====================================================================================================================
253
- # Internal Data Structures
254
- # =====================================================================================================================
225
+ h = topic_hash % modulus
226
+ e = evictions % modulus
227
+ return SUBJECT_ID_PINNED_MAX + 1 + ((h + ((e * e) % modulus)) % modulus)
255
228
 
256
229
 
257
230
  @dataclass
@@ -394,11 +367,6 @@ class PublishTracker:
394
367
  self.ack_event.set()
395
368
 
396
369
 
397
- # =====================================================================================================================
398
- # Topic
399
- # =====================================================================================================================
400
-
401
-
402
370
  class TopicImpl(Topic):
403
371
 
404
372
  def __init__(self, node: NodeImpl, name: str, evictions: int, now: float) -> None:
@@ -424,7 +392,6 @@ class TopicImpl(Topic):
424
392
  self.gossip_task_is_periodic = False
425
393
  self.gossip_counter = 0
426
394
 
427
- # -- Topic ABC --
428
395
  @property
429
396
  def hash(self) -> int:
430
397
  return self._topic_hash
@@ -446,7 +413,6 @@ class TopicImpl(Topic):
446
413
  def match(self, pattern: str) -> list[tuple[str, int]] | None:
447
414
  return match_pattern(pattern, self._name)
448
415
 
449
- # -- Internal --
450
416
  def lage(self, now: float) -> int:
451
417
  return log_age(self.ts_origin, now)
452
418
 
@@ -527,11 +493,6 @@ def left_wins(l_lage: int, l_hash: int, r_lage: int, r_hash: int) -> bool:
527
493
  return l_lage > r_lage if l_lage != r_lage else l_hash < r_hash
528
494
 
529
495
 
530
- # =====================================================================================================================
531
- # Node
532
- # =====================================================================================================================
533
-
534
-
535
496
  class NodeImpl(Node):
536
497
  def __init__(self, transport: Transport, *, home: str, namespace: str) -> None:
537
498
  self._transport = transport
@@ -543,26 +504,21 @@ class NodeImpl(Node):
543
504
  self._monitor_callbacks: dict[int, Callable[[Topic], None]] = {}
544
505
  self._next_monitor_callback_id = 0
545
506
 
546
- # Topic indexes.
547
507
  self.topics_by_name: dict[str, TopicImpl] = {}
548
508
  self.topics_by_hash: dict[int, TopicImpl] = {}
549
509
  self.topics_by_subject_id: dict[int, TopicImpl] = {} # non-pinned only
550
510
 
551
- # Subscriber roots.
552
511
  self.sub_roots_verbatim: dict[str, SubscriberRoot] = {}
553
512
  self.sub_roots_pattern: dict[str, SubscriberRoot] = {}
554
513
 
555
- # Respond futures for reliable responses.
556
514
  self.respond_futures: dict[tuple[int, ...], RespondTracker] = {}
557
515
 
558
- # Compute broadcast and gossip shard subject IDs.
559
516
  modulus = transport.subject_id_modulus
560
517
  sid_max = SUBJECT_ID_PINNED_MAX + modulus
561
518
  self.broadcast_subject_id = (1 << (int(math.log2(sid_max)) + 1)) - 1
562
519
  self.gossip_shard_count = self.broadcast_subject_id - (sid_max + 1)
563
520
  assert self.gossip_shard_count > 0
564
521
 
565
- # Set up broadcast writer and listener.
566
522
  self.broadcast_writer = transport.subject_advertise(self.broadcast_subject_id)
567
523
 
568
524
  def broadcast_handler(arrival: TransportArrival) -> None:
@@ -570,16 +526,13 @@ class NodeImpl(Node):
570
526
 
571
527
  self.broadcast_listener = transport.subject_listen(self.broadcast_subject_id, broadcast_handler)
572
528
 
573
- # Gossip shard state: lazily created per shard.
574
529
  self.gossip_shard_writers: dict[int, SubjectWriter] = {}
575
530
  self.gossip_shard_listeners: dict[int, Closable] = {}
576
531
  self.shared_subject_writers: dict[int, SharedSubjectWriter] = {}
577
532
  self.shared_subject_listeners: dict[int, SharedSubjectListener] = {}
578
533
 
579
- # Register unicast handler.
580
534
  transport.unicast_listen(self.on_unicast_arrival)
581
535
 
582
- # Implicit topic GC task, driven by the earliest implicit-topic expiry.
583
536
  self._implicit_topics: OrderedDict[TopicImpl, None] = OrderedDict()
584
537
  self._implicit_gc_wakeup = asyncio.Event()
585
538
  self._gc_task = self.loop.create_task(self.implicit_gc_loop())
@@ -592,7 +545,6 @@ class NodeImpl(Node):
592
545
  self.gossip_shard_count,
593
546
  )
594
547
 
595
- # -- Node ABC --
596
548
  @property
597
549
  def home(self) -> str:
598
550
  return self._home
@@ -664,7 +616,6 @@ class NodeImpl(Node):
664
616
  if pin is not None and not verbatim:
665
617
  raise ValueError("Pattern names cannot be pinned")
666
618
 
667
- # Ensure subscriber root.
668
619
  if verbatim:
669
620
  root = self.sub_roots_verbatim.get(resolved)
670
621
  if root is None:
@@ -680,12 +631,10 @@ class NodeImpl(Node):
680
631
  root.subscribers.append(subscriber)
681
632
 
682
633
  if verbatim:
683
- # Ensure topic exists and couple.
684
634
  topic = self.topic_ensure(resolved, pin)
685
635
  self.couple_topic_root(topic, root)
686
636
  topic.sync_implicit()
687
637
  else:
688
- # Pattern subscriber: couple with all existing matching topics and scout once per root.
689
638
  for topic in list(self.topics_by_name.values()):
690
639
  self.couple_topic_root(topic, root)
691
640
  topic.sync_implicit()
@@ -723,10 +672,7 @@ class NodeImpl(Node):
723
672
  except Exception as ex:
724
673
  raise SendError(f"Scout send failed for '{resolved}'") from ex
725
674
 
726
- # -- Topic Management --
727
-
728
675
  def topic_ensure(self, name: str, pin: int | None) -> TopicImpl:
729
- """Get or create a topic by resolved name."""
730
676
  topic = self.topics_by_name.get(name)
731
677
  if topic is not None:
732
678
  return topic
@@ -740,7 +686,6 @@ class NodeImpl(Node):
740
686
  self.ensure_gossip_shard(self.gossip_shard_subject_id(topic.hash))
741
687
  self.touch_implicit_topic(topic)
742
688
  self.topic_allocate(topic, evictions, now)
743
- # Couple with existing pattern subscriber roots.
744
689
  for root in self.sub_roots_pattern.values():
745
690
  self.couple_topic_root(topic, root)
746
691
  topic.sync_listener()
@@ -755,12 +700,10 @@ class NodeImpl(Node):
755
700
 
756
701
  def topic_allocate(self, topic: TopicImpl, new_evictions: int, now: float) -> None:
757
702
  """Iterative subject-ID allocation with collision resolution. Mirrors topic_allocate() in cy.c."""
758
- # Work queue: list of (topic, new_evictions) pairs to process.
759
703
  modulus = self.transport.subject_id_modulus
760
704
  work: list[tuple[TopicImpl, int]] = [(topic, new_evictions)]
761
705
  while work:
762
706
  t, ev = work.pop(0)
763
- # Remove from subject-ID index first.
764
707
  old_sid = t.subject_id(modulus)
765
708
  if old_sid in self.topics_by_subject_id and self.topics_by_subject_id[old_sid] is t:
766
709
  del self.topics_by_subject_id[old_sid]
@@ -780,14 +723,12 @@ class NodeImpl(Node):
780
723
  collider = None # same topic, no real collision
781
724
 
782
725
  if collider is None:
783
- # No collision, install.
784
726
  t.release_transport_handles()
785
727
  t.set_evictions(ev)
786
728
  self.topics_by_subject_id[new_sid] = t
787
729
  t.sync_listener()
788
730
  self.schedule_gossip_urgent(t)
789
731
  elif left_wins(t.lage(now), t.hash, collider.lage(now), collider.hash):
790
- # Our topic wins: take the slot, evict the collider.
791
732
  t.release_transport_handles()
792
733
  t.set_evictions(ev)
793
734
  del self.topics_by_subject_id[new_sid]
@@ -796,11 +737,9 @@ class NodeImpl(Node):
796
737
  t.pub_writer = self.acquire_subject_writer(t, new_sid)
797
738
  t.sync_listener()
798
739
  self.schedule_gossip_urgent(t)
799
- # Schedule collider for reallocation.
800
740
  collider.release_transport_handles()
801
741
  work.append((collider, collider.evictions + 1))
802
742
  else:
803
- # Our topic loses: increment evictions and retry.
804
743
  work.append((t, ev + 1))
805
744
 
806
745
  def sync_topic_lifecycle(self, topic: TopicImpl) -> None:
@@ -875,17 +814,14 @@ class NodeImpl(Node):
875
814
 
876
815
  @staticmethod
877
816
  def couple_topic_root(topic: TopicImpl, root: SubscriberRoot) -> None:
878
- """Create a coupling between a topic and a subscriber root if not already coupled."""
879
817
  for c in topic.couplings:
880
818
  if c.root is root:
881
- return # already coupled
819
+ return
882
820
  subs = match_pattern(root.name, topic.name) if root.is_pattern else ([] if root.name == topic.name else None)
883
821
  if subs is not None:
884
822
  topic.couplings.append(Coupling(root=root, substitutions=subs))
885
823
  _logger.debug("Coupled '%s' <-> root '%s'", topic.name, root.name)
886
824
 
887
- # -- Gossip --
888
-
889
825
  def gossip_shard_subject_id(self, topic_hash: int) -> int:
890
826
  modulus = self.transport.subject_id_modulus
891
827
  sid_max = SUBJECT_ID_PINNED_MAX + modulus
@@ -948,9 +884,8 @@ class NodeImpl(Node):
948
884
  _logger.debug("Shared subject listener released sid=%d", subject_id)
949
885
 
950
886
  def schedule_gossip(self, topic: TopicImpl) -> None:
951
- """Start periodic gossip for an explicit topic."""
952
887
  if topic.gossip_task is not None:
953
- return # already scheduled
888
+ return
954
889
  self._reschedule_gossip_periodic(topic, suppressed=False)
955
890
 
956
891
  @staticmethod
@@ -1064,8 +999,6 @@ class NodeImpl(Node):
1064
999
  except (SendError, OSError) as e:
1065
1000
  _logger.warning("Gossip unicast send failed for '%s': %s", topic.name, e)
1066
1001
 
1067
- # -- Scout --
1068
-
1069
1002
  async def _transmit_scout(self, pattern: str) -> None:
1070
1003
  pattern_bytes = pattern.encode("utf-8")
1071
1004
  hdr = ScoutHeader(pattern_len=len(pattern_bytes))
@@ -1094,14 +1027,10 @@ class NodeImpl(Node):
1094
1027
 
1095
1028
  root.scout_task = self.loop.create_task(do_send())
1096
1029
 
1097
- # -- Message Dispatch --
1098
-
1099
1030
  def on_subject_arrival(self, subject_id: int, arrival: TransportArrival) -> None:
1100
- """Handle an arrival on a subject (multicast)."""
1101
1031
  self.dispatch_arrival(arrival, subject_id=subject_id, unicast=False)
1102
1032
 
1103
1033
  def on_unicast_arrival(self, arrival: TransportArrival) -> None:
1104
- """Handle an arrival via unicast."""
1105
1034
  self.dispatch_arrival(arrival, subject_id=None, unicast=True)
1106
1035
 
1107
1036
  def dispatch_arrival(self, arrival: TransportArrival, *, subject_id: int | None, unicast: bool) -> None:
@@ -1291,7 +1220,6 @@ class NodeImpl(Node):
1291
1220
  tracker.on_ack(remote_id, positive)
1292
1221
 
1293
1222
  def on_rsp(self, arrival: TransportArrival, hdr: RspBeHeader | RspRelHeader, payload: bytes) -> None:
1294
- """Handle a response message (for RPC)."""
1295
1223
  ack = False
1296
1224
  topic = self.topics_by_hash.get(hdr.topic_hash)
1297
1225
  if topic is not None:
@@ -1315,7 +1243,6 @@ class NodeImpl(Node):
1315
1243
  )
1316
1244
 
1317
1245
  def on_rsp_ack(self, arrival: TransportArrival, hdr: RspAckHeader | RspNackHeader) -> None:
1318
- """Handle a response ACK/NACK."""
1319
1246
  key = (arrival.remote_id, hdr.message_tag, hdr.topic_hash, hdr.seqno, hdr.tag)
1320
1247
  future = self.respond_futures.get(key)
1321
1248
  if future is not None:
@@ -1364,7 +1291,6 @@ class NodeImpl(Node):
1364
1291
 
1365
1292
  topic = self.topics_by_hash.get(hdr.topic_hash)
1366
1293
 
1367
- # If unknown topic with a name, check for pattern subscriber matches.
1368
1294
  if topic is None and name:
1369
1295
  if scope in {GossipScope.UNICAST, GossipScope.BROADCAST}:
1370
1296
  topic = self.topic_subscribe_if_matching(
@@ -1470,8 +1396,6 @@ class NodeImpl(Node):
1470
1396
  self.send_gossip_unicast(topic, arrival.remote_id, arrival.priority), "gossip unicast"
1471
1397
  )
1472
1398
 
1473
- # -- Implicit Topic GC --
1474
-
1475
1399
  def notify_implicit_gc(self) -> None:
1476
1400
  if not self._closed:
1477
1401
  self._implicit_gc_wakeup.set()
@@ -1532,8 +1456,6 @@ class NodeImpl(Node):
1532
1456
  self.notify_implicit_gc()
1533
1457
  _logger.info("Topic destroyed '%s'", name)
1534
1458
 
1535
- # -- Cleanup --
1536
-
1537
1459
  def close(self) -> None:
1538
1460
  if self._closed:
1539
1461
  return
pycyphal2/_publisher.py CHANGED
@@ -297,11 +297,6 @@ class PublisherImpl(Publisher):
297
297
  _logger.info("Publisher closed for '%s'", self._topic.name)
298
298
 
299
299
 
300
- # =====================================================================================================================
301
- # Response Stream
302
- # =====================================================================================================================
303
-
304
-
305
300
  class ResponseStreamImpl(ResponseStream):
306
301
  def __init__(
307
302
  self,
@@ -367,7 +362,6 @@ class ResponseStreamImpl(ResponseStream):
367
362
  hdr: RspBeHeader | RspRelHeader,
368
363
  payload: bytes,
369
364
  ) -> bool:
370
- """Called by the node when a response arrives matching our message_tag."""
371
365
  reliable = isinstance(hdr, RspRelHeader)
372
366
  if self.closed:
373
367
  if not reliable:
pycyphal2/_subscriber.py CHANGED
@@ -23,11 +23,6 @@ _logger = logging.getLogger(__name__)
23
23
  REORDERING_WINDOW_MAX = SESSION_LIFETIME / 2
24
24
 
25
25
 
26
- # =====================================================================================================================
27
- # Reordering
28
- # =====================================================================================================================
29
-
30
-
31
26
  @dataclass
32
27
  class InternedMsg:
33
28
  arrival: Arrival
@@ -114,13 +109,11 @@ class SubscriberImpl(Subscriber):
114
109
  return item
115
110
 
116
111
  def deliver(self, arrival: Arrival, tag: int, remote_id: int) -> bool:
117
- """Called by the node to deliver a message to this subscriber."""
118
112
  if self.closed:
119
113
  return False
120
114
  if self._reordering_window is None:
121
115
  self.queue.put_nowait(arrival)
122
116
  return True
123
- # Reordering enabled.
124
117
  self._drop_stale_reordering(arrival.timestamp.s)
125
118
  topic_hash = arrival.breadcrumb.topic.hash
126
119
  key = (remote_id, topic_hash)
@@ -148,7 +141,6 @@ class SubscriberImpl(Subscriber):
148
141
 
149
142
  expected = state.last_ejected_lin_tag + 1
150
143
  if lin_tag == expected:
151
- # In-order: eject immediately and scan for consecutive.
152
144
  self.queue.put_nowait(arrival)
153
145
  state.last_ejected_lin_tag = lin_tag
154
146
  self._scan_reordering(state, force_first=False)
@@ -160,7 +152,6 @@ class SubscriberImpl(Subscriber):
160
152
  lin_tag = (tag - state.tag_baseline) & ((1 << 64) - 1)
161
153
  _logger.debug("Reorder resequence tag=%d lin=%d", tag, lin_tag)
162
154
 
163
- # Out-of-order but within capacity: intern.
164
155
  if lin_tag in state.interned:
165
156
  return True
166
157
  state.interned[lin_tag] = InternedMsg(arrival=arrival, tag=tag, remote_id=remote_id, lin_tag=lin_tag)
@@ -187,7 +178,6 @@ class SubscriberImpl(Subscriber):
187
178
  break
188
179
 
189
180
  def _force_eject_all(self, state: ReorderingState, *, silenced: bool = False) -> None:
190
- """Force-eject all interned messages in tag order."""
191
181
  while state.interned:
192
182
  lin_tag = min(state.interned)
193
183
  interned = state.interned.pop(lin_tag)
@@ -199,7 +189,6 @@ class SubscriberImpl(Subscriber):
199
189
  state.timeout_handle = None
200
190
 
201
191
  def _rearm_reorder_timeout(self, state: ReorderingState) -> None:
202
- """Arm or rearm the reordering timeout against the current head-of-line slot."""
203
192
  if self._reordering_window is None:
204
193
  return
205
194
  if not state.interned:
@@ -256,11 +245,6 @@ class SubscriberImpl(Subscriber):
256
245
  _logger.info("Subscriber closed for '%s'", self._pattern)
257
246
 
258
247
 
259
- # =====================================================================================================================
260
- # Breadcrumb
261
- # =====================================================================================================================
262
-
263
-
264
248
  class BreadcrumbImpl(Breadcrumb):
265
249
  def __init__(
266
250
  self,
@@ -322,7 +306,6 @@ class BreadcrumbImpl(Breadcrumb):
322
306
  _logger.debug("Response BE sent seqno=%d to %016x", seqno, self._remote_id)
323
307
  return
324
308
 
325
- # Reliable response with retransmission.
326
309
  tracker = RespondTracker(
327
310
  remote_id=self._remote_id,
328
311
  message_tag=self._message_tag,
pycyphal2/_transport.py CHANGED
@@ -20,6 +20,11 @@ SUBJECT_ID_MODULUS_32bit = 4294954663 # Incompatible with Cyphal/CAN and Cyphal
20
20
  class SubjectWriter(Closable):
21
21
  @abstractmethod
22
22
  async def __call__(self, deadline: Instant, priority: Priority, message: bytes | memoryview) -> None:
23
+ """
24
+ Send one message on the subject. Returning means every frame of the transfer has been handed over to the
25
+ OS or the driver on at least one interface; implementations that queue internally must await that handover.
26
+ Raises :class:`SendError` if the message could not be handed over before the deadline.
27
+ """
23
28
  raise NotImplementedError
24
29
 
25
30
 
@@ -1,16 +1,25 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  from abc import ABC, abstractmethod
4
5
  from dataclasses import dataclass
5
6
  from typing import Iterable
6
7
  import itertools
7
8
 
8
- from .. import Closable, Instant
9
+ from .. import Closable, ClosedError, Instant
9
10
 
10
11
  CAN_EXT_ID_MASK = (1 << 29) - 1
11
12
  CAN_STD_ID_MASK = (1 << 11) - 1
12
13
 
13
14
 
15
+ def closed_error(interface: str, failure: BaseException | None) -> ClosedError:
16
+ if failure is None:
17
+ return ClosedError(f"{interface} closed")
18
+ ex = ClosedError(f"{interface} failed")
19
+ ex.__cause__ = failure
20
+ return ex
21
+
22
+
14
23
  @dataclass(frozen=True)
15
24
  class Frame:
16
25
  """29-bit extended data frame."""
@@ -100,22 +109,28 @@ class Interface(Closable, ABC):
100
109
  raise NotImplementedError
101
110
 
102
111
  @abstractmethod
103
- def enqueue(self, id: int, data: Iterable[memoryview], deadline: Instant) -> None:
112
+ def enqueue(self, id: int, data: Iterable[memoryview], deadline: Instant) -> asyncio.Future[None]:
104
113
  """
105
114
  Schedule one or more frames for transmission. All frames share the same extended identifier.
106
115
  The frame order within the iterable shall be preserved. Implementations may prioritize queued
107
116
  frames by CAN identifier to approximate bus arbitration, but the relative order of frames
108
117
  belonging to one transfer shall remain unchanged.
118
+
119
+ Returns a future that completes when every frame of this transfer has been handed over to the media
120
+ layer. It fails with :class:`SendError` if the deadline expires or the frames are purged, and with
121
+ :class:`ClosedError` if the interface is closed or has failed. Awaiting is observational: neither
122
+ discarding the future nor cancelling it affects transmission.
109
123
  """
110
124
  # REFERENCE PARITY: TX queue ownership intentionally belongs to the interface rather than the transport.
111
125
  # This differs from libcanard's internal queue placement but it is not a parity drift because it does not
112
- # affect the wire-visible behavior by itself.
126
+ # affect the wire-visible behavior by itself. Here the queue is drained by a background task rather than
127
+ # by the application spin loop (cy_spin_until -> canard_poll), so the future is what observes the drain.
113
128
  raise NotImplementedError
114
129
 
115
130
  @abstractmethod
116
131
  def purge(self) -> None:
117
132
  """
118
- Drop all queued but not yet transmitted frames.
133
+ Drop all queued but not yet transmitted frames, failing their futures with :class:`SendError`.
119
134
  Used when the local node-ID changes and queued continuations become invalid.
120
135
  """
121
136
  raise NotImplementedError