bitspark-bitwire 0.1.0__tar.gz → 0.3.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,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bitspark-bitwire
3
- Version: 0.1.0
4
- Summary: The shared relative-path Wire contract for Python
3
+ Version: 0.3.0
4
+ Summary: Addressless Wire primitives, full WireTree structure and addressed carrier contracts for Python
5
5
  Author: Bitspark
6
6
  License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/Bitspark/bitwire
@@ -20,16 +20,41 @@ Dynamic: license-file
20
20
 
21
21
  # Bitwire for Python
22
22
 
23
- `bitspark-bitwire` provides the shared relative-path Wire contract as the Python
23
+ **Source contract: 0.3.0; publication pending.** `Wire` is the addressless
24
+ primitive `send(message)`. `WireTree = DeixisNode<Wire>` provides the complete
25
+ finite, acyclic structure: own value, complete byte-keyed children, partial
26
+ selection and decomposition. Keys are exact arbitrary bytes, including empty
27
+ and non-UTF-8 keys. Empty path selects self; a missing edge is distinct from a
28
+ present refusing primitive. Recomposition preserves own and child identities.
29
+
30
+ This is symmetric with Bitstore's `Data.read()` primitive and
31
+ `DataTree = DeixisNode<Data>`. Derived sending selects the node and invokes its
32
+ own Wire. Construction and derived operators belong in bitruntime; these
33
+ packages publish declarations and criteria, not a production tree runtime.
34
+ See [decision 0012](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0012-explicit-data-and-wire-trees.md)
35
+ and the [migration guide](https://github.com/Bitspark/bitwire/blob/main/docs/migration-0.3.md).
36
+
37
+ `AddressedWire` explicitly names the former `Wire.send(path, message)` surface.
38
+ It is not a full WireTree. `Endpoint` extends AddressedWire, and return
39
+ capabilities retain AddressedWire so the existing response/lifecycle path
40
+ space, local identity, received context and closure rules remain intact.
41
+ Carrier paths remain exact Unicode-scalar strings under unchanged `bitwire/1`;
42
+ they do not imply support for arbitrary tree byte keys on that carrier.
43
+
44
+ The following addressed-carrier examples use the **0.3 source names**. Older
45
+ 0.2.0 artifacts used `Wire` for the addressed interface; their release evidence
46
+ does not validate the renamed declarations or full structural trees.
47
+
48
+ `bitspark-bitwire` provides the shared relative-path AddressedWire contract as the Python
24
49
  module `bitwire`. It requires Python 3.11 or newer and has no runtime dependencies.
25
50
  The package contains typed declarations and supporting values; endpoint runtimes,
26
51
  carriers, codecs and routing helpers belong to implementations.
27
52
 
28
53
  ```python
29
- from bitwire import Message, Receiver, ReturnAddress, Wire
54
+ from bitwire import Endpoint, Message, Receiver, ReturnAddress, AddressedWire
30
55
 
31
56
 
32
- def call(endpoint: Wire, replies: Wire) -> None:
57
+ def call(endpoint: AddressedWire, replies: AddressedWire) -> None:
33
58
  endpoint.send(
34
59
  ["cells", "get"],
35
60
  Message(
@@ -39,20 +64,24 @@ def call(endpoint: Wire, replies: Wire) -> None:
39
64
  )
40
65
 
41
66
 
42
- def listen(endpoint: Wire):
67
+ def listen(endpoint: Endpoint):
43
68
  def receive(path, message):
44
69
  print(path, message.frame)
45
70
 
46
- return endpoint.receive([], Receiver(namespace=True, message=receive))
71
+ return endpoint.receive(Receiver(message=receive))
47
72
  ```
48
73
 
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.
74
+ `AddressedWire` is a send-only structural `Protocol`; implementations do not need to inherit
75
+ from it. `send` accepts or refuses synchronously; the implementation schedules
76
+ delivery. `Endpoint` extends it with `receive(receiver)` and `close`, keeping
77
+ attachment and closure authority separate from send access. One receive attachment
78
+ is allowed at a time; duplicates are refused. Receivers may return an awaitable.
79
+ `receive` returns an idempotent detach function that cannot remove a replacement.
80
+ Path matching belongs to routing compositions, not to these interfaces.
52
81
  Paths are sequences of opaque Unicode-scalar strings and retain empty segments.
53
- Receiver paths are relative to the Wire on which they registered.
82
+ Receiver paths are relative to the attached endpoint.
54
83
 
55
- `ReturnAddress` uses object identity, including when its Wire cannot be compared
84
+ `ReturnAddress` uses object identity, including when its AddressedWire cannot be compared
56
85
  or hashed. Preserve this object during routing. The return address is local
57
86
  capability data and must never be serialized into the profile envelope. Profile
58
87
  payloads must follow the JSON profile despite their Python `object` annotation.
@@ -60,7 +89,7 @@ Runtime-owned received context associated with the return identity must survive
60
89
  routing. Application-supplied context or metadata does not establish verified
61
90
  invocation context; that verification remains the runtime's responsibility.
62
91
 
63
- The [Wire contract](https://github.com/Bitspark/bitwire/blob/main/docs/wire/contract.md)
92
+ The [AddressedWire contract](https://github.com/Bitspark/bitwire/blob/main/docs/wire/contract.md)
64
93
  and [profile boundary](https://github.com/Bitspark/bitwire/blob/main/docs/wire/profile.md)
65
94
  define the behavior. Runtime protocol checks verify only the presence of methods;
66
95
  they cannot establish conformance.
@@ -107,6 +136,6 @@ the workflow from `main`. Tokens are passed only to publication steps. Revoke th
107
136
  bootstrap token after trusted publishing is configured. The workflow verifies the
108
137
  packaged artifact before upload and installs the published PyPI package afterward.
109
138
 
110
- The binding is adapted from Nightseam's Python Wire declarations at commit
139
+ The binding is adapted from Nightseam's Python AddressedWire declarations at commit
111
140
  [`1c63f1c4`](https://github.com/Bitspark/nightseam/blob/1c63f1c4d7e4b5987d4bd32e294177645c92ed8f/duplex/py/nightseam/duplex/wire.py)
112
141
  under Apache-2.0. See the included `LICENSE` and `NOTICE` files.
@@ -1,15 +1,40 @@
1
1
  # Bitwire for Python
2
2
 
3
- `bitspark-bitwire` provides the shared relative-path Wire contract as the Python
3
+ **Source contract: 0.3.0; publication pending.** `Wire` is the addressless
4
+ primitive `send(message)`. `WireTree = DeixisNode<Wire>` provides the complete
5
+ finite, acyclic structure: own value, complete byte-keyed children, partial
6
+ selection and decomposition. Keys are exact arbitrary bytes, including empty
7
+ and non-UTF-8 keys. Empty path selects self; a missing edge is distinct from a
8
+ present refusing primitive. Recomposition preserves own and child identities.
9
+
10
+ This is symmetric with Bitstore's `Data.read()` primitive and
11
+ `DataTree = DeixisNode<Data>`. Derived sending selects the node and invokes its
12
+ own Wire. Construction and derived operators belong in bitruntime; these
13
+ packages publish declarations and criteria, not a production tree runtime.
14
+ See [decision 0012](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0012-explicit-data-and-wire-trees.md)
15
+ and the [migration guide](https://github.com/Bitspark/bitwire/blob/main/docs/migration-0.3.md).
16
+
17
+ `AddressedWire` explicitly names the former `Wire.send(path, message)` surface.
18
+ It is not a full WireTree. `Endpoint` extends AddressedWire, and return
19
+ capabilities retain AddressedWire so the existing response/lifecycle path
20
+ space, local identity, received context and closure rules remain intact.
21
+ Carrier paths remain exact Unicode-scalar strings under unchanged `bitwire/1`;
22
+ they do not imply support for arbitrary tree byte keys on that carrier.
23
+
24
+ The following addressed-carrier examples use the **0.3 source names**. Older
25
+ 0.2.0 artifacts used `Wire` for the addressed interface; their release evidence
26
+ does not validate the renamed declarations or full structural trees.
27
+
28
+ `bitspark-bitwire` provides the shared relative-path AddressedWire contract as the Python
4
29
  module `bitwire`. It requires Python 3.11 or newer and has no runtime dependencies.
5
30
  The package contains typed declarations and supporting values; endpoint runtimes,
6
31
  carriers, codecs and routing helpers belong to implementations.
7
32
 
8
33
  ```python
9
- from bitwire import Message, Receiver, ReturnAddress, Wire
34
+ from bitwire import Endpoint, Message, Receiver, ReturnAddress, AddressedWire
10
35
 
11
36
 
12
- def call(endpoint: Wire, replies: Wire) -> None:
37
+ def call(endpoint: AddressedWire, replies: AddressedWire) -> None:
13
38
  endpoint.send(
14
39
  ["cells", "get"],
15
40
  Message(
@@ -19,20 +44,24 @@ def call(endpoint: Wire, replies: Wire) -> None:
19
44
  )
20
45
 
21
46
 
22
- def listen(endpoint: Wire):
47
+ def listen(endpoint: Endpoint):
23
48
  def receive(path, message):
24
49
  print(path, message.frame)
25
50
 
26
- return endpoint.receive([], Receiver(namespace=True, message=receive))
51
+ return endpoint.receive(Receiver(message=receive))
27
52
  ```
28
53
 
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.
54
+ `AddressedWire` is a send-only structural `Protocol`; implementations do not need to inherit
55
+ from it. `send` accepts or refuses synchronously; the implementation schedules
56
+ delivery. `Endpoint` extends it with `receive(receiver)` and `close`, keeping
57
+ attachment and closure authority separate from send access. One receive attachment
58
+ is allowed at a time; duplicates are refused. Receivers may return an awaitable.
59
+ `receive` returns an idempotent detach function that cannot remove a replacement.
60
+ Path matching belongs to routing compositions, not to these interfaces.
32
61
  Paths are sequences of opaque Unicode-scalar strings and retain empty segments.
33
- Receiver paths are relative to the Wire on which they registered.
62
+ Receiver paths are relative to the attached endpoint.
34
63
 
35
- `ReturnAddress` uses object identity, including when its Wire cannot be compared
64
+ `ReturnAddress` uses object identity, including when its AddressedWire cannot be compared
36
65
  or hashed. Preserve this object during routing. The return address is local
37
66
  capability data and must never be serialized into the profile envelope. Profile
38
67
  payloads must follow the JSON profile despite their Python `object` annotation.
@@ -40,7 +69,7 @@ Runtime-owned received context associated with the return identity must survive
40
69
  routing. Application-supplied context or metadata does not establish verified
41
70
  invocation context; that verification remains the runtime's responsibility.
42
71
 
43
- The [Wire contract](https://github.com/Bitspark/bitwire/blob/main/docs/wire/contract.md)
72
+ The [AddressedWire contract](https://github.com/Bitspark/bitwire/blob/main/docs/wire/contract.md)
44
73
  and [profile boundary](https://github.com/Bitspark/bitwire/blob/main/docs/wire/profile.md)
45
74
  define the behavior. Runtime protocol checks verify only the presence of methods;
46
75
  they cannot establish conformance.
@@ -87,6 +116,6 @@ the workflow from `main`. Tokens are passed only to publication steps. Revoke th
87
116
  bootstrap token after trusted publishing is configured. The workflow verifies the
88
117
  packaged artifact before upload and installs the published PyPI package afterward.
89
118
 
90
- The binding is adapted from Nightseam's Python Wire declarations at commit
119
+ The binding is adapted from Nightseam's Python AddressedWire declarations at commit
91
120
  [`1c63f1c4`](https://github.com/Bitspark/nightseam/blob/1c63f1c4d7e4b5987d4bd32e294177645c92ed8f/duplex/py/nightseam/duplex/wire.py)
92
121
  under Apache-2.0. See the included `LICENSE` and `NOTICE` files.
@@ -4,8 +4,8 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "bitspark-bitwire"
7
- version = "0.1.0"
8
- description = "The shared relative-path Wire contract for Python"
7
+ version = "0.3.0"
8
+ description = "Addressless Wire primitives, full WireTree structure and addressed carrier contracts for Python"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
11
11
  license = "Apache-2.0"
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: bitspark-bitwire
3
- Version: 0.1.0
4
- Summary: The shared relative-path Wire contract for Python
3
+ Version: 0.3.0
4
+ Summary: Addressless Wire primitives, full WireTree structure and addressed carrier contracts for Python
5
5
  Author: Bitspark
6
6
  License-Expression: Apache-2.0
7
7
  Project-URL: Homepage, https://github.com/Bitspark/bitwire
@@ -20,16 +20,41 @@ Dynamic: license-file
20
20
 
21
21
  # Bitwire for Python
22
22
 
23
- `bitspark-bitwire` provides the shared relative-path Wire contract as the Python
23
+ **Source contract: 0.3.0; publication pending.** `Wire` is the addressless
24
+ primitive `send(message)`. `WireTree = DeixisNode<Wire>` provides the complete
25
+ finite, acyclic structure: own value, complete byte-keyed children, partial
26
+ selection and decomposition. Keys are exact arbitrary bytes, including empty
27
+ and non-UTF-8 keys. Empty path selects self; a missing edge is distinct from a
28
+ present refusing primitive. Recomposition preserves own and child identities.
29
+
30
+ This is symmetric with Bitstore's `Data.read()` primitive and
31
+ `DataTree = DeixisNode<Data>`. Derived sending selects the node and invokes its
32
+ own Wire. Construction and derived operators belong in bitruntime; these
33
+ packages publish declarations and criteria, not a production tree runtime.
34
+ See [decision 0012](https://github.com/Bitspark/bitwire/blob/main/docs/decisions/0012-explicit-data-and-wire-trees.md)
35
+ and the [migration guide](https://github.com/Bitspark/bitwire/blob/main/docs/migration-0.3.md).
36
+
37
+ `AddressedWire` explicitly names the former `Wire.send(path, message)` surface.
38
+ It is not a full WireTree. `Endpoint` extends AddressedWire, and return
39
+ capabilities retain AddressedWire so the existing response/lifecycle path
40
+ space, local identity, received context and closure rules remain intact.
41
+ Carrier paths remain exact Unicode-scalar strings under unchanged `bitwire/1`;
42
+ they do not imply support for arbitrary tree byte keys on that carrier.
43
+
44
+ The following addressed-carrier examples use the **0.3 source names**. Older
45
+ 0.2.0 artifacts used `Wire` for the addressed interface; their release evidence
46
+ does not validate the renamed declarations or full structural trees.
47
+
48
+ `bitspark-bitwire` provides the shared relative-path AddressedWire contract as the Python
24
49
  module `bitwire`. It requires Python 3.11 or newer and has no runtime dependencies.
25
50
  The package contains typed declarations and supporting values; endpoint runtimes,
26
51
  carriers, codecs and routing helpers belong to implementations.
27
52
 
28
53
  ```python
29
- from bitwire import Message, Receiver, ReturnAddress, Wire
54
+ from bitwire import Endpoint, Message, Receiver, ReturnAddress, AddressedWire
30
55
 
31
56
 
32
- def call(endpoint: Wire, replies: Wire) -> None:
57
+ def call(endpoint: AddressedWire, replies: AddressedWire) -> None:
33
58
  endpoint.send(
34
59
  ["cells", "get"],
35
60
  Message(
@@ -39,20 +64,24 @@ def call(endpoint: Wire, replies: Wire) -> None:
39
64
  )
40
65
 
41
66
 
42
- def listen(endpoint: Wire):
67
+ def listen(endpoint: Endpoint):
43
68
  def receive(path, message):
44
69
  print(path, message.frame)
45
70
 
46
- return endpoint.receive([], Receiver(namespace=True, message=receive))
71
+ return endpoint.receive(Receiver(message=receive))
47
72
  ```
48
73
 
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.
74
+ `AddressedWire` is a send-only structural `Protocol`; implementations do not need to inherit
75
+ from it. `send` accepts or refuses synchronously; the implementation schedules
76
+ delivery. `Endpoint` extends it with `receive(receiver)` and `close`, keeping
77
+ attachment and closure authority separate from send access. One receive attachment
78
+ is allowed at a time; duplicates are refused. Receivers may return an awaitable.
79
+ `receive` returns an idempotent detach function that cannot remove a replacement.
80
+ Path matching belongs to routing compositions, not to these interfaces.
52
81
  Paths are sequences of opaque Unicode-scalar strings and retain empty segments.
53
- Receiver paths are relative to the Wire on which they registered.
82
+ Receiver paths are relative to the attached endpoint.
54
83
 
55
- `ReturnAddress` uses object identity, including when its Wire cannot be compared
84
+ `ReturnAddress` uses object identity, including when its AddressedWire cannot be compared
56
85
  or hashed. Preserve this object during routing. The return address is local
57
86
  capability data and must never be serialized into the profile envelope. Profile
58
87
  payloads must follow the JSON profile despite their Python `object` annotation.
@@ -60,7 +89,7 @@ Runtime-owned received context associated with the return identity must survive
60
89
  routing. Application-supplied context or metadata does not establish verified
61
90
  invocation context; that verification remains the runtime's responsibility.
62
91
 
63
- The [Wire contract](https://github.com/Bitspark/bitwire/blob/main/docs/wire/contract.md)
92
+ The [AddressedWire contract](https://github.com/Bitspark/bitwire/blob/main/docs/wire/contract.md)
64
93
  and [profile boundary](https://github.com/Bitspark/bitwire/blob/main/docs/wire/profile.md)
65
94
  define the behavior. Runtime protocol checks verify only the presence of methods;
66
95
  they cannot establish conformance.
@@ -107,6 +136,6 @@ the workflow from `main`. Tokens are passed only to publication steps. Revoke th
107
136
  bootstrap token after trusted publishing is configured. The workflow verifies the
108
137
  packaged artifact before upload and installs the published PyPI package afterward.
109
138
 
110
- The binding is adapted from Nightseam's Python Wire declarations at commit
139
+ The binding is adapted from Nightseam's Python AddressedWire declarations at commit
111
140
  [`1c63f1c4`](https://github.com/Bitspark/nightseam/blob/1c63f1c4d7e4b5987d4bd32e294177645c92ed8f/duplex/py/nightseam/duplex/wire.py)
112
141
  under Apache-2.0. See the included `LICENSE` and `NOTICE` files.
@@ -12,4 +12,5 @@ src/bitspark_bitwire.egg-info/top_level.txt
12
12
  src/bitwire/__init__.py
13
13
  src/bitwire/py.typed
14
14
  tests/test_consumer.py
15
+ tests/test_tree.py
15
16
  tests/typecheck.py
@@ -1,4 +1,4 @@
1
- """The shared relative-path Wire contract, without a runtime dependency.
1
+ """Addressless Wire, complete WireTree structure, and addressed compatibility.
2
2
 
3
3
  Adapted from Nightseam's duplex/py/nightseam/duplex/wire.py at
4
4
  1c63f1c4d7e4b5987d4bd32e294177645c92ed8f under Apache-2.0.
@@ -9,7 +9,7 @@ from __future__ import annotations
9
9
 
10
10
  from collections.abc import Awaitable, Callable, Mapping, Sequence
11
11
  from dataclasses import dataclass
12
- from typing import Literal, NotRequired, Protocol, TypedDict, runtime_checkable
12
+ from typing import Literal, NotRequired, Protocol, TypeAlias, TypeVar, TypedDict, runtime_checkable
13
13
 
14
14
  __all__ = [
15
15
  "Path",
@@ -24,11 +24,21 @@ __all__ = [
24
24
  "ReturnAddress",
25
25
  "Message",
26
26
  "Receiver",
27
+ "AddressedWire",
27
28
  "Wire",
29
+ "Key",
30
+ "TreePath",
31
+ "DeixisNode",
32
+ "WireTree",
33
+ "Endpoint",
28
34
  ]
29
35
 
30
36
  # Opaque Unicode-scalar segments: [] != [""] != ["a/b"] != ["a", "b"].
31
37
  Path = Sequence[str]
38
+ # Structural keys admit every byte string, without Unicode conversion.
39
+ Key = bytes
40
+ TreePath = Sequence[Key]
41
+ T_co = TypeVar("T_co", covariant=True)
32
42
  ProfileKind = Literal["request", "response", "event", "cancel"]
33
43
 
34
44
 
@@ -87,14 +97,14 @@ ProfileFrame = RequestFrame | ResultFrame | ErrorFrame | EventFrame | CancelFram
87
97
  class ReturnAddress:
88
98
  """Local capability with identity equality, never a network-envelope member.
89
99
 
90
- Different addresses stay distinct even when they hold the same Wire. The
100
+ Different addresses stay distinct even when they hold the same AddressedWire. The
91
101
  wrapped implementation need not support equality or hashing. Routing must
92
102
  preserve this address object, rather than construct an equivalent wrapper.
93
103
  Runtime-owned received context associated with this identity must survive
94
104
  routing; application payloads are not evidence of verified context.
95
105
  """
96
106
 
97
- wire: Wire
107
+ wire: AddressedWire
98
108
 
99
109
 
100
110
  @dataclass(frozen=True)
@@ -111,30 +121,72 @@ class Message:
111
121
 
112
122
  @dataclass(frozen=True)
113
123
  class Receiver:
114
- """Callbacks receive paths relative to the Wire on which they registered.
124
+ """Callbacks receive every path relative to their attached endpoint.
115
125
 
116
- Exact routes win, then the longest namespace segment prefix wins. A receiver
117
- may be synchronous or awaitable; endpoint implementations own dispatch.
126
+ A receiver may be synchronous or awaitable; endpoints own dispatch.
118
127
  """
119
128
 
120
- namespace: bool = False
121
129
  message: Callable[[Path, Message], None | Awaitable[None]] | None = None
122
130
  closed: Callable[[int, str], None] | None = None
123
131
 
124
132
 
125
133
  @runtime_checkable
126
134
  class Wire(Protocol):
127
- """Access to an origin with synchronous admission and asynchronous dispatch.
135
+ """Addressless send access: admission or refusal, never handler completion.
136
+
137
+ No path, receive attachment or closure authority belongs to this primitive.
138
+ Tree selection followed by own().send(message) supplies structured interaction.
139
+ """
140
+
141
+ def send(self, message: Message) -> None: ...
142
+
143
+
144
+ class DeixisNode(Protocol[T_co]):
145
+ """A complete finite acyclic structure with an own value at every node.
146
+
147
+ Children contain every unique exact byte key, including empty keys. An empty
148
+ path selects this node; a missing child returns None, never the parent's own
149
+ value. Decomposition retains the same own value and complete children.
150
+ Implementations preserve structure and payload identity. Constructors and
151
+ derived operations belong to runtimes, not this declaration package.
152
+ """
153
+
154
+ def own(self) -> T_co: ...
155
+
156
+ def children(self) -> Sequence[tuple[Key, DeixisNode[T_co]]]: ...
157
+
158
+ def at(self, path: TreePath) -> DeixisNode[T_co] | None: ...
159
+
160
+ def decompose(self) -> tuple[T_co, Sequence[tuple[Key, DeixisNode[T_co]]]]: ...
161
+
162
+
163
+ WireTree: TypeAlias = DeixisNode[Wire]
164
+
165
+
166
+ @runtime_checkable
167
+ class AddressedWire(Protocol):
168
+ """Compatibility access for the unchanged addressed bitwire/1 profile.
128
169
 
129
170
  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.
171
+ application code on the sender's stack. It grants no receiving or closure
172
+ authority. Structural protocol matching alone does not prove these laws.
134
173
  """
135
174
 
136
175
  def send(self, path: Path, message: Message) -> None: ...
137
176
 
138
- def receive(self, path: Path, receiver: Receiver) -> Callable[[], None]: ...
177
+
178
+ @runtime_checkable
179
+ class Endpoint(AddressedWire, Protocol):
180
+ """Owning endpoint access with one receive attachment and endpoint closure.
181
+
182
+ receive refuses a second active attachment or a closed endpoint and returns
183
+ an idempotent detach. A stale detach cannot remove a later attachment. Callbacks receive the complete
184
+ message and its relative path; routing policy belongs above this boundary.
185
+ Detach preserves captured return access and does not close the endpoint.
186
+ Closing notifies the active receiver once; detached receivers are not notified.
187
+ Closing twice has no additional effect and does not release live bindings.
188
+ """
189
+
190
+ def receive(self, receiver: Receiver) -> Callable[[], None]: ...
139
191
 
140
192
  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, AddressedWire
11
11
 
12
12
 
13
13
  class RecordingEndpoint:
@@ -15,38 +15,53 @@ 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:
22
- raise AssertionError("ReturnAddress must not compare the wrapped Wire")
23
+ raise AssertionError("ReturnAddress must not compare the wrapped AddressedWire")
23
24
 
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):
41
56
  def test_structural_consumer_keeps_path_frame_and_return_object(self) -> None:
42
57
  endpoint = RecordingEndpoint()
43
- wire: Wire = endpoint
58
+ wire: AddressedWire = endpoint
44
59
  replies = ReturnAddress(RecordingEndpoint())
45
60
  request = Message(
46
61
  {"version": 1, "kind": "request", "id": "r1", "params": {"count": 2**80}},
47
62
  replies,
48
63
  )
49
- self.assertIsInstance(endpoint, Wire)
64
+ self.assertIsInstance(endpoint, AddressedWire)
50
65
  for path in ([], [""], ["a/b"], ["a", "b"], ["\u03bb", "\U0001f30d"]):
51
66
  wire.send(path, request)
52
67
 
@@ -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: AddressedWire = Access()
142
+ self.assertIsInstance(access, AddressedWire)
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__":
@@ -0,0 +1,85 @@
1
+ """Independent finite structural fixture; no production tree implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+ from dataclasses import dataclass
7
+ from typing import Generic, TypeVar
8
+ import unittest
9
+
10
+ from bitwire import DeixisNode, Key, Message, TreePath, Wire, WireTree
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ @dataclass(frozen=True)
16
+ class Node(Generic[T]):
17
+ value: T
18
+ entries: tuple[tuple[Key, DeixisNode[T]], ...] = ()
19
+
20
+ def own(self) -> T:
21
+ return self.value
22
+
23
+ def children(self) -> Sequence[tuple[Key, DeixisNode[T]]]:
24
+ return self.entries
25
+
26
+ def at(self, path: TreePath) -> DeixisNode[T] | None:
27
+ node: DeixisNode[T] = self
28
+ for key in path:
29
+ child = next((child for name, child in node.children() if name == key), None)
30
+ if child is None:
31
+ return None
32
+ node = child
33
+ return node
34
+
35
+ def decompose(self) -> tuple[T, Sequence[tuple[Key, DeixisNode[T]]]]:
36
+ return self.value, self.entries
37
+
38
+
39
+ class RefusingWire:
40
+ def send(self, message: Message) -> None:
41
+ raise RuntimeError("refused")
42
+
43
+
44
+ class RecordingWire:
45
+ def __init__(self) -> None:
46
+ self.messages: list[Message] = []
47
+
48
+ def send(self, message: Message) -> None:
49
+ self.messages.append(message)
50
+
51
+
52
+ class TreeTests(unittest.TestCase):
53
+ def test_complete_structure_preserves_binary_empty_and_missing_keys(self) -> None:
54
+ child: WireTree = Node[Wire](RefusingWire())
55
+ binary: WireTree = Node[Wire](RefusingWire())
56
+ tree: WireTree = Node[Wire](RefusingWire(), ((b"", child), (b"\xff\x00", binary)))
57
+ self.assertIs(tree.at([]), tree)
58
+ self.assertIs(tree.at([b""]), child)
59
+ self.assertIs(tree.at([b"\xff\x00"]), binary)
60
+ self.assertIsNone(tree.at([b"missing"]))
61
+ self.assertIsNone(tree.at([b"", b""]))
62
+ self.assertEqual([key for key, _ in tree.children()], [b"", b"\xff\x00"])
63
+ value, children = tree.decompose()
64
+ self.assertIs(value, tree.own())
65
+ rebuilt: WireTree = Node[Wire](value, tuple(children))
66
+ self.assertIs(rebuilt.at([b""]), child)
67
+ with self.assertRaisesRegex(RuntimeError, "refused"):
68
+ child.own().send(Message({"version": 1, "kind": "event", "data": None}))
69
+ # A present refusing leaf is still observable, unlike a missing child.
70
+ self.assertEqual(child.children(), ())
71
+
72
+ def test_selected_send_is_own_send_and_retains_message_identity(self) -> None:
73
+ root = RecordingWire()
74
+ destination = RecordingWire()
75
+ tree: WireTree = Node[Wire](root, ((b"route", Node[Wire](destination)),))
76
+ message = Message({"version": 1, "kind": "event", "data": {"value": 1}})
77
+ selected = tree.at([b"route"])
78
+ assert selected is not None
79
+ selected.own().send(message)
80
+ self.assertEqual(root.messages, [])
81
+ self.assertIs(destination.messages[0], message)
82
+
83
+
84
+ if __name__ == "__main__":
85
+ unittest.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,
@@ -14,14 +15,16 @@ from bitwire import (
14
15
  RequestFrame,
15
16
  ResultFrame,
16
17
  ReturnAddress,
18
+ AddressedWire,
17
19
  Wire,
20
+ WireTree,
18
21
  )
19
22
 
20
23
 
21
- def consume(wire: Wire, frame: ProfileFrame) -> None:
24
+ def consume(wire: AddressedWire, endpoint: Endpoint, frame: ProfileFrame) -> None:
22
25
  address = ReturnAddress(wire)
23
26
  wire.send(["scope", ""], Message(frame, address))
24
- assert_type(address.wire, Wire)
27
+ assert_type(address.wire, AddressedWire)
25
28
  kind: ProfileKind = frame["kind"]
26
29
  assert_type(kind, ProfileKind)
27
30
  if frame["kind"] == "request":
@@ -30,10 +33,28 @@ def consume(wire: Wire, frame: ProfileFrame) -> None:
30
33
  async def delivered(path: Path, message: Message) -> None:
31
34
  assert_type(message.return_address, ReturnAddress | None)
32
35
 
33
- wire.receive([], Receiver(namespace=True, message=delivered))()
36
+ endpoint.receive(Receiver(message=delivered))()
37
+ wire.receive(Receiver()) # type: ignore[attr-defined]
38
+ wire.close() # type: ignore[attr-defined]
34
39
  wire.send([42], Message(frame)) # type: ignore[list-item]
35
40
 
36
41
 
42
+ def consume_tree(tree: WireTree, primitive: Wire, message: Message) -> None:
43
+ primitive.send(message)
44
+ assert_type(tree.own(), Wire)
45
+ selected = tree.at([b"", b"\xff\x00"])
46
+ if selected is not None:
47
+ selected.own().send(message)
48
+ own, children = tree.decompose()
49
+ assert_type(own, Wire)
50
+ for key, child in children:
51
+ assert_type(key, bytes)
52
+ assert_type(child, WireTree)
53
+ primitive.send([], message) # type: ignore[call-arg,arg-type]
54
+ tree.at(["text"]) # type: ignore[list-item]
55
+ ReturnAddress(primitive) # type: ignore[arg-type]
56
+
57
+
37
58
  request: RequestFrame = {"version": 1, "kind": "request", "id": "1", "params": {}}
38
59
  result: ResultFrame = {"version": 1, "kind": "response", "id": "1", "result": None}
39
60
  error: ErrorFrame = {"version": 1, "kind": "response", "id": "1", "error": {"code": "no", "message": "No"}}