bitspark-bitwire 0.1.0__tar.gz → 0.2.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bitspark-bitwire
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: The shared relative-path Wire contract for Python
5
5
  Author: Bitspark
6
6
  License-Expression: Apache-2.0
@@ -26,7 +26,7 @@ The package contains typed declarations and supporting values; endpoint runtimes
26
26
  carriers, codecs and routing helpers belong to implementations.
27
27
 
28
28
  ```python
29
- from bitwire import Message, Receiver, ReturnAddress, Wire
29
+ from bitwire import Endpoint, Message, Receiver, ReturnAddress, Wire
30
30
 
31
31
 
32
32
  def call(endpoint: Wire, replies: Wire) -> None:
@@ -39,18 +39,22 @@ def call(endpoint: Wire, replies: Wire) -> None:
39
39
  )
40
40
 
41
41
 
42
- def listen(endpoint: Wire):
42
+ def listen(endpoint: Endpoint):
43
43
  def receive(path, message):
44
44
  print(path, message.frame)
45
45
 
46
- return endpoint.receive([], Receiver(namespace=True, message=receive))
46
+ return endpoint.receive(Receiver(message=receive))
47
47
  ```
48
48
 
49
- `Wire` is a structural `Protocol`; implementations do not need to inherit from
50
- it. `send` accepts or refuses synchronously; the implementation schedules delivery.
51
- Receivers may return an awaitable. `receive` returns an idempotent detach function.
49
+ `Wire` is a send-only structural `Protocol`; implementations do not need to inherit
50
+ from it. `send` accepts or refuses synchronously; the implementation schedules
51
+ delivery. `Endpoint` extends it with `receive(receiver)` and `close`, keeping
52
+ attachment and closure authority separate from send access. One receive attachment
53
+ is allowed at a time; duplicates are refused. Receivers may return an awaitable.
54
+ `receive` returns an idempotent detach function that cannot remove a replacement.
55
+ Path matching belongs to routing compositions, not to these interfaces.
52
56
  Paths are sequences of opaque Unicode-scalar strings and retain empty segments.
53
- Receiver paths are relative to the Wire on which they registered.
57
+ Receiver paths are relative to the attached endpoint.
54
58
 
55
59
  `ReturnAddress` uses object identity, including when its Wire cannot be compared
56
60
  or hashed. Preserve this object during routing. The return address is local
@@ -6,7 +6,7 @@ The package contains typed declarations and supporting values; endpoint runtimes
6
6
  carriers, codecs and routing helpers belong to implementations.
7
7
 
8
8
  ```python
9
- from bitwire import Message, Receiver, ReturnAddress, Wire
9
+ from bitwire import Endpoint, Message, Receiver, ReturnAddress, Wire
10
10
 
11
11
 
12
12
  def call(endpoint: Wire, replies: Wire) -> None:
@@ -19,18 +19,22 @@ def call(endpoint: Wire, replies: Wire) -> None:
19
19
  )
20
20
 
21
21
 
22
- def listen(endpoint: Wire):
22
+ def listen(endpoint: Endpoint):
23
23
  def receive(path, message):
24
24
  print(path, message.frame)
25
25
 
26
- return endpoint.receive([], Receiver(namespace=True, message=receive))
26
+ return endpoint.receive(Receiver(message=receive))
27
27
  ```
28
28
 
29
- `Wire` is a structural `Protocol`; implementations do not need to inherit from
30
- it. `send` accepts or refuses synchronously; the implementation schedules delivery.
31
- Receivers may return an awaitable. `receive` returns an idempotent detach function.
29
+ `Wire` is a send-only structural `Protocol`; implementations do not need to inherit
30
+ from it. `send` accepts or refuses synchronously; the implementation schedules
31
+ delivery. `Endpoint` extends it with `receive(receiver)` and `close`, keeping
32
+ attachment and closure authority separate from send access. One receive attachment
33
+ is allowed at a time; duplicates are refused. Receivers may return an awaitable.
34
+ `receive` returns an idempotent detach function that cannot remove a replacement.
35
+ Path matching belongs to routing compositions, not to these interfaces.
32
36
  Paths are sequences of opaque Unicode-scalar strings and retain empty segments.
33
- Receiver paths are relative to the Wire on which they registered.
37
+ Receiver paths are relative to the attached endpoint.
34
38
 
35
39
  `ReturnAddress` uses object identity, including when its Wire cannot be compared
36
40
  or hashed. Preserve this object during routing. The return address is local
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bitspark-bitwire"
7
- version = "0.1.0"
7
+ version = "0.2.0"
8
8
  description = "The shared relative-path Wire contract for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bitspark-bitwire
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: The shared relative-path Wire contract for Python
5
5
  Author: Bitspark
6
6
  License-Expression: Apache-2.0
@@ -26,7 +26,7 @@ The package contains typed declarations and supporting values; endpoint runtimes
26
26
  carriers, codecs and routing helpers belong to implementations.
27
27
 
28
28
  ```python
29
- from bitwire import Message, Receiver, ReturnAddress, Wire
29
+ from bitwire import Endpoint, Message, Receiver, ReturnAddress, Wire
30
30
 
31
31
 
32
32
  def call(endpoint: Wire, replies: Wire) -> None:
@@ -39,18 +39,22 @@ def call(endpoint: Wire, replies: Wire) -> None:
39
39
  )
40
40
 
41
41
 
42
- def listen(endpoint: Wire):
42
+ def listen(endpoint: Endpoint):
43
43
  def receive(path, message):
44
44
  print(path, message.frame)
45
45
 
46
- return endpoint.receive([], Receiver(namespace=True, message=receive))
46
+ return endpoint.receive(Receiver(message=receive))
47
47
  ```
48
48
 
49
- `Wire` is a structural `Protocol`; implementations do not need to inherit from
50
- it. `send` accepts or refuses synchronously; the implementation schedules delivery.
51
- Receivers may return an awaitable. `receive` returns an idempotent detach function.
49
+ `Wire` is a send-only structural `Protocol`; implementations do not need to inherit
50
+ from it. `send` accepts or refuses synchronously; the implementation schedules
51
+ delivery. `Endpoint` extends it with `receive(receiver)` and `close`, keeping
52
+ attachment and closure authority separate from send access. One receive attachment
53
+ is allowed at a time; duplicates are refused. Receivers may return an awaitable.
54
+ `receive` returns an idempotent detach function that cannot remove a replacement.
55
+ Path matching belongs to routing compositions, not to these interfaces.
52
56
  Paths are sequences of opaque Unicode-scalar strings and retain empty segments.
53
- Receiver paths are relative to the Wire on which they registered.
57
+ Receiver paths are relative to the attached endpoint.
54
58
 
55
59
  `ReturnAddress` uses object identity, including when its Wire cannot be compared
56
60
  or hashed. Preserve this object during routing. The return address is local
@@ -25,6 +25,7 @@ __all__ = [
25
25
  "Message",
26
26
  "Receiver",
27
27
  "Wire",
28
+ "Endpoint",
28
29
  ]
29
30
 
30
31
  # Opaque Unicode-scalar segments: [] != [""] != ["a/b"] != ["a", "b"].
@@ -111,30 +112,39 @@ class Message:
111
112
 
112
113
  @dataclass(frozen=True)
113
114
  class Receiver:
114
- """Callbacks receive paths relative to the Wire on which they registered.
115
+ """Callbacks receive every path relative to their attached endpoint.
115
116
 
116
- Exact routes win, then the longest namespace segment prefix wins. A receiver
117
- may be synchronous or awaitable; endpoint implementations own dispatch.
117
+ A receiver may be synchronous or awaitable; endpoints own dispatch.
118
118
  """
119
119
 
120
- namespace: bool = False
121
120
  message: Callable[[Path, Message], None | Awaitable[None]] | None = None
122
121
  closed: Callable[[int, str], None] | None = None
123
122
 
124
123
 
125
124
  @runtime_checkable
126
125
  class Wire(Protocol):
127
- """Access to an origin with synchronous admission and asynchronous dispatch.
126
+ """Send-only access with synchronous admission and asynchronous dispatch.
128
127
 
129
128
  send returns on acceptance or raises on refusal, without running destination
130
- application code on the sender's stack. receive refuses duplicate paths and
131
- returns an idempotent detach. A root owns dispatch, bounds and closure; a
132
- selected view shares that closure, while a mount owns only its registrations
133
- and routing. Structural protocol matching alone does not prove these laws.
129
+ application code on the sender's stack. It grants no receiving or closure
130
+ authority. Structural protocol matching alone does not prove these laws.
134
131
  """
135
132
 
136
133
  def send(self, path: Path, message: Message) -> None: ...
137
134
 
138
- def receive(self, path: Path, receiver: Receiver) -> Callable[[], None]: ...
135
+
136
+ @runtime_checkable
137
+ class Endpoint(Wire, Protocol):
138
+ """Owning endpoint access with one receive attachment and endpoint closure.
139
+
140
+ receive refuses a second active attachment or a closed endpoint and returns
141
+ an idempotent detach. A stale detach cannot remove a later attachment. Callbacks receive the complete
142
+ message and its relative path; routing policy belongs above this boundary.
143
+ Detach preserves captured return access and does not close the endpoint.
144
+ Closing notifies the active receiver once; detached receivers are not notified.
145
+ Closing twice has no additional effect and does not release live bindings.
146
+ """
147
+
148
+ def receive(self, receiver: Receiver) -> Callable[[], None]: ...
139
149
 
140
150
  def close(self, code: int = 1000, reason: str = "") -> None: ...
@@ -7,7 +7,7 @@ import inspect
7
7
  import unittest
8
8
  from collections.abc import Callable
9
9
 
10
- from bitwire import Message, Path, Receiver, ReturnAddress, Wire
10
+ from bitwire import Endpoint, Message, Path, Receiver, ReturnAddress, Wire
11
11
 
12
12
 
13
13
  class RecordingEndpoint:
@@ -15,7 +15,8 @@ class RecordingEndpoint:
15
15
 
16
16
  def __init__(self) -> None:
17
17
  self.sent: list[tuple[tuple[str, ...], Message]] = []
18
- self.receivers: dict[tuple[str, ...], Receiver] = {}
18
+ self.receiver: Receiver | None = None
19
+ self.attachment: object | None = None
19
20
  self.ending: tuple[int, str] | None = None
20
21
 
21
22
  def __eq__(self, other: object) -> bool:
@@ -24,17 +25,31 @@ class RecordingEndpoint:
24
25
  def send(self, path: Path, message: Message) -> None:
25
26
  self.sent.append((tuple(path), message))
26
27
 
27
- def receive(self, path: Path, receiver: Receiver) -> Callable[[], None]:
28
- key = tuple(path)
29
- self.receivers[key] = receiver
28
+ def receive(self, receiver: Receiver) -> Callable[[], None]:
29
+ if self.ending is not None:
30
+ raise RuntimeError("endpoint closed")
31
+ if self.receiver is not None:
32
+ raise RuntimeError("receiver already attached")
33
+ token = object()
34
+ self.receiver = receiver
35
+ self.attachment = token
30
36
 
31
37
  def detach() -> None:
32
- self.receivers.pop(key, None)
38
+ if self.attachment is token:
39
+ self.receiver = None
40
+ self.attachment = None
33
41
 
34
42
  return detach
35
43
 
36
44
  def close(self, code: int = 1000, reason: str = "") -> None:
45
+ if self.ending is not None:
46
+ return
37
47
  self.ending = (code, reason)
48
+ receiver = self.receiver
49
+ self.receiver = None
50
+ self.attachment = None
51
+ if receiver is not None and receiver.closed is not None:
52
+ receiver.closed(code, reason)
38
53
 
39
54
 
40
55
  class ConsumerTests(unittest.TestCase):
@@ -72,14 +87,13 @@ class ConsumerTests(unittest.TestCase):
72
87
  await asyncio.sleep(0)
73
88
  observed.append((tuple(path), message))
74
89
 
75
- receiver = Receiver(namespace=True, message=delivered)
76
- detach = endpoint.receive(["reply"], receiver)
90
+ receiver = Receiver(message=delivered)
91
+ detach = endpoint.receive(receiver)
77
92
  request = Message({"version": 1, "kind": "event", "data": None})
78
93
 
79
94
  async def dispatch() -> None:
80
- registered = endpoint.receivers[("reply",)]
81
- self.assertTrue(registered.namespace)
82
- assert registered.message is not None
95
+ registered = endpoint.receiver
96
+ assert registered is not None and registered.message is not None
83
97
  result = registered.message(["reply", ""], request)
84
98
  if inspect.isawaitable(result):
85
99
  await result
@@ -88,9 +102,47 @@ class ConsumerTests(unittest.TestCase):
88
102
  self.assertEqual(observed, [(("reply", ""), request)])
89
103
  detach()
90
104
  detach()
91
- self.assertEqual(endpoint.receivers, {})
105
+ self.assertIsNone(endpoint.receiver)
92
106
  self.assertIsNone(endpoint.ending)
93
107
 
108
+ def test_attachment_ownership_and_stale_detach(self) -> None:
109
+ endpoint: Endpoint = RecordingEndpoint()
110
+ first = Receiver()
111
+ detach = endpoint.receive(first)
112
+ with self.assertRaises(RuntimeError):
113
+ endpoint.receive(first)
114
+ detach()
115
+ second = Receiver()
116
+ second_detach = endpoint.receive(second)
117
+ detach()
118
+ assert isinstance(endpoint, RecordingEndpoint)
119
+ self.assertIs(endpoint.receiver, second)
120
+ second_detach()
121
+ self.assertIsNone(endpoint.receiver)
122
+
123
+ def test_close_notifies_only_active_attachment_once(self) -> None:
124
+ endpoint = RecordingEndpoint()
125
+ endings: list[tuple[int, str]] = []
126
+ detached: list[tuple[int, str]] = []
127
+ endpoint.receive(Receiver(closed=lambda c, r: detached.append((c, r))))()
128
+ endpoint.receive(Receiver(closed=lambda c, r: endings.append((c, r))))
129
+ endpoint.close(1000, "done")
130
+ endpoint.close(1001, "again")
131
+ self.assertEqual(endings, [(1000, "done")])
132
+ self.assertEqual(detached, [])
133
+ with self.assertRaises(RuntimeError):
134
+ endpoint.receive(Receiver())
135
+
136
+ def test_send_only_wire_requires_no_endpoint_control(self) -> None:
137
+ class Access:
138
+ def send(self, path: Path, message: Message) -> None:
139
+ pass
140
+
141
+ access: Wire = Access()
142
+ self.assertIsInstance(access, Wire)
143
+ self.assertNotIsInstance(access, Endpoint)
144
+ self.assertIs(ReturnAddress(access).wire, access)
145
+
94
146
  def test_closed_callback_is_independent_from_message_callback(self) -> None:
95
147
  endings: list[tuple[int, str]] = []
96
148
  receiver = Receiver(closed=lambda code, reason: endings.append((code, reason)))
@@ -98,7 +150,7 @@ class ConsumerTests(unittest.TestCase):
98
150
  receiver.closed(1000, "complete")
99
151
  self.assertEqual(endings, [(1000, "complete")])
100
152
  self.assertIsNone(receiver.message)
101
- self.assertFalse(receiver.namespace)
153
+
102
154
 
103
155
 
104
156
  if __name__ == "__main__":
@@ -5,6 +5,7 @@ from typing import assert_type
5
5
  from bitwire import (
6
6
  CancelFrame,
7
7
  ErrorFrame,
8
+ Endpoint,
8
9
  EventFrame,
9
10
  Message,
10
11
  Path,
@@ -18,7 +19,7 @@ from bitwire import (
18
19
  )
19
20
 
20
21
 
21
- def consume(wire: Wire, frame: ProfileFrame) -> None:
22
+ def consume(wire: Wire, endpoint: Endpoint, frame: ProfileFrame) -> None:
22
23
  address = ReturnAddress(wire)
23
24
  wire.send(["scope", ""], Message(frame, address))
24
25
  assert_type(address.wire, Wire)
@@ -30,7 +31,9 @@ def consume(wire: Wire, frame: ProfileFrame) -> None:
30
31
  async def delivered(path: Path, message: Message) -> None:
31
32
  assert_type(message.return_address, ReturnAddress | None)
32
33
 
33
- wire.receive([], Receiver(namespace=True, message=delivered))()
34
+ endpoint.receive(Receiver(message=delivered))()
35
+ wire.receive(Receiver()) # type: ignore[attr-defined]
36
+ wire.close() # type: ignore[attr-defined]
34
37
  wire.send([42], Message(frame)) # type: ignore[list-item]
35
38
 
36
39