semiont 0.6.12.dev2__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.
Files changed (66) hide show
  1. semiont/__init__.py +20 -0
  2. semiont/bus.py +236 -0
  3. semiont/cache.py +345 -0
  4. semiont/cached.py +152 -0
  5. semiont/channel.py +81 -0
  6. semiont/channels.py +1306 -0
  7. semiont/client.py +161 -0
  8. semiont/error_codes.py +232 -0
  9. semiont/errors.py +147 -0
  10. semiont/event_bus.py +108 -0
  11. semiont/events.py +101 -0
  12. semiont/http/__init__.py +24 -0
  13. semiont/http/agent.py +232 -0
  14. semiont/http/content.py +234 -0
  15. semiont/http/exchange.py +200 -0
  16. semiont/http/oauth.py +346 -0
  17. semiont/http/session.py +191 -0
  18. semiont/http/sse.py +71 -0
  19. semiont/http/stream.py +683 -0
  20. semiont/http/transport.py +326 -0
  21. semiont/identifiers.py +114 -0
  22. semiont/identity.py +59 -0
  23. semiont/media_types.py +69 -0
  24. semiont/media_types_table.py +89 -0
  25. semiont/model.py +33 -0
  26. semiont/namespaces/__init__.py +19 -0
  27. semiont/namespaces/auth.py +32 -0
  28. semiont/namespaces/beckon.py +47 -0
  29. semiont/namespaces/bind.py +38 -0
  30. semiont/namespaces/browse.py +338 -0
  31. semiont/namespaces/follow.py +238 -0
  32. semiont/namespaces/frame.py +35 -0
  33. semiont/namespaces/gather.py +82 -0
  34. semiont/namespaces/job.py +94 -0
  35. semiont/namespaces/links.py +66 -0
  36. semiont/namespaces/live.py +301 -0
  37. semiont/namespaces/mark.py +174 -0
  38. semiont/namespaces/match.py +67 -0
  39. semiont/namespaces/refresher.py +283 -0
  40. semiont/namespaces/system.py +24 -0
  41. semiont/namespaces/yield_.py +101 -0
  42. semiont/oauth_clients.py +27 -0
  43. semiont/operations.py +337 -0
  44. semiont/py.typed +0 -0
  45. semiont/refresh.py +188 -0
  46. semiont/resume.py +95 -0
  47. semiont/retry.py +164 -0
  48. semiont/running.py +110 -0
  49. semiont/session.py +493 -0
  50. semiont/sign_in.py +54 -0
  51. semiont/sign_in_store.py +283 -0
  52. semiont/storage.py +139 -0
  53. semiont/telemetry.py +116 -0
  54. semiont/telemetry_table.py +121 -0
  55. semiont/testing/__init__.py +62 -0
  56. semiont/testing/client.py +53 -0
  57. semiont/testing/content.py +124 -0
  58. semiont/testing/gateway.py +85 -0
  59. semiont/testing/transport.py +341 -0
  60. semiont/timing.py +187 -0
  61. semiont/transport.py +531 -0
  62. semiont/types.py +5902 -0
  63. semiont/watched.py +111 -0
  64. semiont-0.6.12.dev2.dist-info/METADATA +533 -0
  65. semiont-0.6.12.dev2.dist-info/RECORD +66 -0
  66. semiont-0.6.12.dev2.dist-info/WHEEL +4 -0
@@ -0,0 +1,62 @@
1
+ """What a consumer's tests are built on: a real client over doubles.
2
+
3
+ `create_test_client` gives a real `SemiontClient` (its namespaces, its cache
4
+ and its deadlines are the client's own) over a transport a test scripts
5
+ (`FaultyTransport`), a content transport that keeps what it is given
6
+ (`InMemoryContent`), and a gateway that answers only what it was told to
7
+ (`StubGateway`). A test scripts the doubles and observes through the client.
8
+
9
+ made = create_test_client()
10
+ made.transport.queue_reply("browse:resource-requested", [{"resource": {...}, ...}])
11
+ async with made.client as client:
12
+ described = await client.browse.resource(resource_id).fresh()
13
+ assert made.transport.request_log[0].payload == {"resourceId": resource_id}
14
+
15
+ A double answers what a test told it to and refuses the rest by name. It
16
+ never answers with a value of its own making: a request nobody scripted an
17
+ answer for fails naming its operation, and a read of content nobody stored
18
+ fails naming the resource, so a test that forgot to say something fails where
19
+ it forgot.
20
+ """
21
+
22
+ from semiont.testing.client import TestClient, create_test_client
23
+ from semiont.testing.content import ContentCall, GetBinary, GetBinaryStream, GetResourceGraph, InMemoryContent, PutBinary
24
+ from semiont.testing.gateway import StubGateway
25
+ from semiont.testing.transport import (
26
+ Delay,
27
+ Deliver,
28
+ DropReply,
29
+ DuplicateReply,
30
+ FaultAction,
31
+ FaultyTransport,
32
+ MakeResponse,
33
+ Refuse,
34
+ RejectEmit,
35
+ RequestLogEntry,
36
+ refuse_unscripted_operation,
37
+ retry_key_of,
38
+ )
39
+
40
+ __all__ = [
41
+ "ContentCall",
42
+ "Delay",
43
+ "Deliver",
44
+ "DropReply",
45
+ "DuplicateReply",
46
+ "FaultAction",
47
+ "FaultyTransport",
48
+ "GetBinary",
49
+ "GetBinaryStream",
50
+ "GetResourceGraph",
51
+ "InMemoryContent",
52
+ "MakeResponse",
53
+ "PutBinary",
54
+ "Refuse",
55
+ "RejectEmit",
56
+ "RequestLogEntry",
57
+ "StubGateway",
58
+ "TestClient",
59
+ "create_test_client",
60
+ "refuse_unscripted_operation",
61
+ "retry_key_of",
62
+ ]
@@ -0,0 +1,53 @@
1
+ """A real client for tests. Every namespace, cache and deadline is the client's own; only what it speaks through is a double.
2
+
3
+ A test scripts the doubles and observes through the client. What a test does
4
+ not give is a double that refuses: a request nobody scripted an answer for
5
+ fails naming its operation, a read of content nobody stored fails naming the
6
+ resource, and an operation of the gateway nobody scripted fails naming it.
7
+ """
8
+
9
+ from dataclasses import dataclass
10
+ from typing import ClassVar, final
11
+
12
+ from semiont.client import CachePersistence, ClientTiming, SemiontClient
13
+ from semiont.testing.content import InMemoryContent
14
+ from semiont.testing.gateway import StubGateway
15
+ from semiont.testing.transport import FaultyTransport
16
+
17
+ __all__ = ["TestClient", "create_test_client"]
18
+
19
+
20
+ @final
21
+ @dataclass(frozen=True, slots=True, kw_only=True)
22
+ class TestClient:
23
+ """A client, and the doubles it speaks through."""
24
+
25
+ # A name a test runner would otherwise take for a class of tests.
26
+ __test__: ClassVar[bool] = False
27
+
28
+ client: SemiontClient[FaultyTransport]
29
+ transport: FaultyTransport
30
+ content: InMemoryContent
31
+ gateway: StubGateway
32
+
33
+
34
+ def create_test_client(
35
+ *,
36
+ transport: FaultyTransport | None = None,
37
+ content: InMemoryContent | None = None,
38
+ gateway: StubGateway | None = None,
39
+ timing: ClientTiming | None = None,
40
+ persistence: CachePersistence | None = None,
41
+ ) -> TestClient:
42
+ """A real `SemiontClient` over a `FaultyTransport`, an `InMemoryContent` and a `StubGateway`.
43
+
44
+ Each double is the one given, as the test scripted it; absent, one that
45
+ nothing is scripted to answer and that holds nothing. `timing` and
46
+ `persistence` are the client's own. The client is held with `async with`
47
+ as any other is, and whoever made the transport closes it.
48
+ """
49
+ over = FaultyTransport() if transport is None else transport
50
+ holding = InMemoryContent() if content is None else content
51
+ answering = StubGateway() if gateway is None else gateway
52
+ client = SemiontClient(over, holding, answering, timing=timing, persistence=persistence)
53
+ return TestClient(client=client, transport=over, content=holding, gateway=answering)
@@ -0,0 +1,124 @@
1
+ """A `ContentTransport` with no wire, for tests.
2
+
3
+ It keeps what `put_binary` receives and gives it back to a read, and records
4
+ every call made of it. A read of a resource nobody stored fails as
5
+ `not-found`, as a gateway's would, so a test that forgot to seed its content
6
+ fails where it forgot rather than on bytes the double made up.
7
+ """
8
+
9
+ import asyncio
10
+ from collections.abc import AsyncGenerator, Callable, Sequence
11
+ from dataclasses import dataclass
12
+ from typing import Final, final, override
13
+
14
+ from semiont.errors import TransportError
15
+ from semiont.identifiers import ResourceId
16
+ from semiont.transport import Content, ContentStream, ContentTransport, PutBinaryRequest, Upload, UploadProgress
17
+ from semiont.types import CreateResourceResponse, GetResourceResponse
18
+
19
+ __all__ = ["ContentCall", "GetBinary", "GetBinaryStream", "GetResourceGraph", "InMemoryContent", "PutBinary"]
20
+
21
+
22
+ @final
23
+ @dataclass(frozen=True, slots=True)
24
+ class PutBinary:
25
+ """A call of `put_binary`, with what it was given."""
26
+
27
+ request: PutBinaryRequest
28
+
29
+
30
+ @final
31
+ @dataclass(frozen=True, slots=True)
32
+ class GetBinary:
33
+ """A call of `get_binary`."""
34
+
35
+ resource_id: ResourceId
36
+
37
+
38
+ @final
39
+ @dataclass(frozen=True, slots=True)
40
+ class GetBinaryStream:
41
+ """A call of `get_binary_stream`."""
42
+
43
+ resource_id: ResourceId
44
+
45
+
46
+ @final
47
+ @dataclass(frozen=True, slots=True)
48
+ class GetResourceGraph:
49
+ """A call of `get_resource_graph`."""
50
+
51
+ resource_id: ResourceId
52
+
53
+
54
+ type ContentCall = PutBinary | GetBinary | GetBinaryStream | GetResourceGraph
55
+ """One call made of the content transport."""
56
+
57
+
58
+ def _not_found(what: str, resource_id: ResourceId) -> TransportError:
59
+ return TransportError.of_status(f"InMemoryContent: no {what} stored for {resource_id}", 404, None)
60
+
61
+
62
+ async def _whole(data: bytes) -> AsyncGenerator[bytes]:
63
+ yield data
64
+
65
+
66
+ @final
67
+ class InMemoryContent(ContentTransport):
68
+ """See the module's documentation."""
69
+
70
+ def __init__(self) -> None:
71
+ self._stored: Final[dict[ResourceId, Content]] = {}
72
+ self._graphs: Final[dict[ResourceId, GetResourceResponse]] = {}
73
+ self._calls: Final[list[ContentCall]] = []
74
+
75
+ def seed(self, resource_id: ResourceId, content: Content) -> None:
76
+ """Store `content` as the bytes of `resource_id`."""
77
+ self._stored[resource_id] = content
78
+
79
+ def seed_graph(self, resource_id: ResourceId, graph: GetResourceResponse) -> None:
80
+ """Store `graph` as the description of `resource_id`."""
81
+ self._graphs[resource_id] = graph
82
+
83
+ @property
84
+ def calls(self) -> Sequence[ContentCall]:
85
+ """Every call made, in order."""
86
+ return self._calls
87
+
88
+ def _read(self, resource_id: ResourceId) -> Content:
89
+ content = self._stored.get(resource_id)
90
+ if content is None:
91
+ raise _not_found("content", resource_id)
92
+ return content
93
+
94
+ @override
95
+ def put_binary(self, request: PutBinaryRequest) -> Upload:
96
+ """Stored under an id of this double's making, `test-content-<n>`."""
97
+
98
+ async def stored(report: Callable[[UploadProgress], None]) -> CreateResourceResponse:
99
+ resource_id = ResourceId(f"test-content-{len(self._stored) + 1}")
100
+ self._stored[resource_id] = Content(data=request.file, content_type=request.format)
101
+ self._calls.append(PutBinary(request))
102
+ report(UploadProgress(bytes_uploaded=len(request.file), total_bytes=len(request.file)))
103
+ return CreateResourceResponse(resource_id=resource_id)
104
+
105
+ return Upload(lambda report: asyncio.ensure_future(stored(report)))
106
+
107
+ @override
108
+ async def get_binary(self, resource_id: ResourceId) -> Content:
109
+ self._calls.append(GetBinary(resource_id))
110
+ return self._read(resource_id)
111
+
112
+ @override
113
+ async def get_binary_stream(self, resource_id: ResourceId) -> ContentStream:
114
+ self._calls.append(GetBinaryStream(resource_id))
115
+ content = self._read(resource_id)
116
+ return ContentStream(content.content_type, _whole(content.data))
117
+
118
+ @override
119
+ async def get_resource_graph(self, resource_id: ResourceId) -> GetResourceResponse:
120
+ self._calls.append(GetResourceGraph(resource_id))
121
+ graph = self._graphs.get(resource_id)
122
+ if graph is None:
123
+ raise _not_found("description", resource_id)
124
+ return graph
@@ -0,0 +1,85 @@
1
+ """A `GatewayOperations` for tests that answers only what it was told to.
2
+
3
+ An operation nobody scripted is refused, naming it: a double that answered it
4
+ with a value of its own making would hand its caller a success nobody decided
5
+ on. Every call is recorded, answered or not.
6
+ """
7
+
8
+ from collections.abc import Sequence
9
+ from typing import Final, final, override
10
+
11
+ from semiont.errors import TransportError
12
+ from semiont.identifiers import ResourceId
13
+ from semiont.transport import GatewayOperations
14
+ from semiont.types import HealthResponse, MediaTokenResponse, ProtectedResourceMetadata, StatusResponse, UserResponse
15
+
16
+ __all__ = ["StubGateway"]
17
+
18
+
19
+ @final
20
+ class StubGateway(GatewayOperations):
21
+ """See the module's documentation."""
22
+
23
+ def __init__(self) -> None:
24
+ self._current_user: UserResponse | None = None
25
+ self._media_token: MediaTokenResponse | None = None
26
+ self._protected_resource_metadata: ProtectedResourceMetadata | None = None
27
+ self._health: HealthResponse | None = None
28
+ self._status: StatusResponse | None = None
29
+ self._calls: Final[list[str]] = []
30
+
31
+ # ── What a test says ────────────────────────────────────────────────
32
+
33
+ def current_user(self, answer: UserResponse) -> None:
34
+ """What `get_current_user` answers."""
35
+ self._current_user = answer
36
+
37
+ def media_token(self, answer: MediaTokenResponse) -> None:
38
+ """What `get_media_token` answers, of any resource."""
39
+ self._media_token = answer
40
+
41
+ def protected_resource_metadata(self, answer: ProtectedResourceMetadata) -> None:
42
+ """What `get_protected_resource_metadata` answers."""
43
+ self._protected_resource_metadata = answer
44
+
45
+ def health(self, answer: HealthResponse) -> None:
46
+ """What `health_check` answers."""
47
+ self._health = answer
48
+
49
+ def status(self, answer: StatusResponse) -> None:
50
+ """What `get_status` answers."""
51
+ self._status = answer
52
+
53
+ @property
54
+ def calls(self) -> Sequence[str]:
55
+ """Every operation called, in order: its name and, when it has one, the resource it was called for (`get_media_token res-1`)."""
56
+ return self._calls
57
+
58
+ def _answer[T](self, operation: str, scripted: T | None, of: ResourceId | None = None) -> T:
59
+ """Record a call of `operation` and give what was scripted for it."""
60
+ self._calls.append(operation if of is None else f"{operation} {of}")
61
+ if scripted is None:
62
+ raise TransportError("error", f"StubGateway: not scripted: {operation}")
63
+ return scripted
64
+
65
+ # ── The gateway's own operations ────────────────────────────────────
66
+
67
+ @override
68
+ async def get_current_user(self) -> UserResponse:
69
+ return self._answer("get_current_user", self._current_user)
70
+
71
+ @override
72
+ async def get_media_token(self, resource_id: ResourceId) -> MediaTokenResponse:
73
+ return self._answer("get_media_token", self._media_token, resource_id)
74
+
75
+ @override
76
+ async def get_protected_resource_metadata(self) -> ProtectedResourceMetadata:
77
+ return self._answer("get_protected_resource_metadata", self._protected_resource_metadata)
78
+
79
+ @override
80
+ async def health_check(self) -> HealthResponse:
81
+ return self._answer("health_check", self._health)
82
+
83
+ @override
84
+ async def get_status(self) -> StatusResponse:
85
+ return self._answer("get_status", self._status)
@@ -0,0 +1,341 @@
1
+ """A scriptable `Transport` with no wire, for tests.
2
+
3
+ Two things are scripted, and they are different things. The **wire**, by a
4
+ schedule of faults applied one to each request in turn: deliver its reply,
5
+ drop it, delay it, deliver it twice, or refuse the emit. And the **gateway**,
6
+ by the responses queued for each operation. So "the first reply is lost and
7
+ the retry sees the next page" can be said: a dropped reply still spends its
8
+ queued response, because the gateway answered and the wire ate it.
9
+
10
+ A request can be scripted to be answered with a failure (`refuse_when`): the
11
+ answer a peer gives when it will not do what was asked. That is not the wire
12
+ failing, and the schedule applies to it as to any reply.
13
+
14
+ A request nobody scripted a response for is refused, naming the operation. A
15
+ double that answered it with an empty success would hand its caller a reply
16
+ whose every field is absent, which fails far from its cause.
17
+
18
+ What is emitted through it is delivered through it, to whoever listens, as a
19
+ gateway gives a frame to its subscribers. All of its variation comes in
20
+ through the schedule, so a test that fixes the schedule fixes the run.
21
+ """
22
+
23
+ import asyncio
24
+ from collections import deque
25
+ from collections.abc import Callable, Collection, Iterable, Mapping, Sequence
26
+ from dataclasses import dataclass, field
27
+ from typing import Final, Literal, NoReturn, final, override
28
+
29
+ from pydantic import JsonValue, TypeAdapter
30
+
31
+ from semiont.errors import SemiontError, TransportError
32
+ from semiont.events import Broadcast, Events
33
+ from semiont.identifiers import ResourceId
34
+ from semiont.operations import OPERATIONS
35
+ from semiont.transport import ConnectionState, Frame, FrameHub, FrameSink, PendingReply, ReplyRouter, ResourceHold, Transport, unsubscribed
36
+ from semiont.watched import Variable, Watched
37
+
38
+ __all__ = [
39
+ "Delay",
40
+ "Deliver",
41
+ "DropReply",
42
+ "DuplicateReply",
43
+ "FaultAction",
44
+ "FaultyTransport",
45
+ "MakeResponse",
46
+ "Refuse",
47
+ "RejectEmit",
48
+ "RequestLogEntry",
49
+ "refuse_unscripted_operation",
50
+ "retry_key_of",
51
+ ]
52
+
53
+
54
+ @final
55
+ @dataclass(frozen=True, slots=True)
56
+ class Deliver:
57
+ """The request's reply is delivered."""
58
+
59
+ kind: Literal["deliver"] = field(default="deliver", init=False)
60
+
61
+
62
+ @final
63
+ @dataclass(frozen=True, slots=True)
64
+ class DropReply:
65
+ """The request's reply is lost."""
66
+
67
+ kind: Literal["drop-reply"] = field(default="drop-reply", init=False)
68
+
69
+
70
+ @final
71
+ @dataclass(frozen=True, slots=True)
72
+ class Delay:
73
+ """The request's reply is delivered after `ms` milliseconds."""
74
+
75
+ ms: int
76
+ kind: Literal["delay"] = field(default="delay", init=False)
77
+
78
+
79
+ @final
80
+ @dataclass(frozen=True, slots=True)
81
+ class DuplicateReply:
82
+ """The request's reply is delivered twice."""
83
+
84
+ kind: Literal["duplicate-reply"] = field(default="duplicate-reply", init=False)
85
+
86
+
87
+ @final
88
+ @dataclass(frozen=True, slots=True)
89
+ class RejectEmit:
90
+ """The emit itself is refused: the request never reaches the gateway."""
91
+
92
+ kind: Literal["reject-emit"] = field(default="reject-emit", init=False)
93
+
94
+
95
+ type FaultAction = Deliver | DropReply | Delay | DuplicateReply | RejectEmit
96
+ """What the wire does to one request."""
97
+
98
+ type MakeResponse = Callable[[str, Mapping[str, JsonValue]], JsonValue | None]
99
+ """What the gateway answers a request with, given its operation and its payload.
100
+
101
+ Its `response`, or nothing for a reply that carries none.
102
+ """
103
+
104
+ type Refuse = Callable[[str, Mapping[str, JsonValue]], Mapping[str, JsonValue] | None]
105
+ """The failure the gateway answers a request with, when it answers it with one: the payload of the operation's failure channel."""
106
+
107
+ _PAIRS: Final = TypeAdapter[list[tuple[str, JsonValue]]](list[tuple[str, JsonValue]])
108
+
109
+ # What a gateway stamps on a payload: no part of what was asked.
110
+ _STAMPED: Final = ("_trace", "_userId")
111
+
112
+
113
+ @final
114
+ @dataclass(frozen=True, slots=True, kw_only=True)
115
+ class RequestLogEntry:
116
+ """One request the transport was sent."""
117
+
118
+ channel: str
119
+ action: FaultAction
120
+ """What the schedule did to it."""
121
+ correlation_id: str | None
122
+ retry_key: str
123
+ """The request's identity across retries: two entries with the same key are one logical request, sent again."""
124
+ payload: Mapping[str, JsonValue]
125
+ """The payload as it was sent."""
126
+
127
+
128
+ def retry_key_of(channel: str, payload: Mapping[str, JsonValue]) -> str:
129
+ """A request's identity across retries: its channel and its payload, less what the gateway stamps on it."""
130
+ asked = sorted((name, value) for name, value in payload.items() if name not in _STAMPED)
131
+ return f"{channel} {_PAIRS.dump_json(asked).decode()}"
132
+
133
+
134
+ def refuse_unscripted_operation(operation: str, payload: Mapping[str, JsonValue]) -> NoReturn:
135
+ """What answers a request nothing was scripted for: a refusal that names the operation, never a response made up."""
136
+ raise TransportError(
137
+ "error",
138
+ f'No response scripted for bus operation "{operation}". '
139
+ f'Script one with queue_reply("{operation}", ...) or build the transport with a make_response that answers it.',
140
+ )
141
+
142
+
143
+ @final
144
+ class FaultyTransport(Transport):
145
+ """See the module's documentation.
146
+
147
+ The i-th request meets `schedule[i % len(schedule)]`; with an empty
148
+ schedule every reply is delivered. `make_response` answers every request
149
+ no queued response does: with none, such a request is refused by name.
150
+ `channels` are the channels its stream delivers with no scope held: every
151
+ channel, unless a test names them.
152
+ """
153
+
154
+ def __init__(
155
+ self,
156
+ schedule: Sequence[FaultAction] = (),
157
+ *,
158
+ make_response: MakeResponse = refuse_unscripted_operation,
159
+ channels: Collection[str] | None = None,
160
+ ) -> None:
161
+ self._schedule: Final = tuple(schedule)
162
+ self._make_response: Final = make_response
163
+ self._channels: Final = channels
164
+ self._refuse: Refuse | None = None
165
+ self._replies: Final[dict[str, deque[JsonValue | None]]] = {}
166
+ self._log: Final[list[RequestLogEntry]] = []
167
+ self._emitted: Final[list[Frame]] = []
168
+ self._held: Final[dict[ResourceId, int]] = {}
169
+ """How many holds each resource's scope has."""
170
+ self._hub: Final = FrameHub()
171
+ self._router: Final = ReplyRouter()
172
+ self._state: Final = Variable[ConnectionState]("open")
173
+ self._failures: Final = Broadcast[SemiontError]()
174
+ self._closed = False
175
+
176
+ # ── What a test says ────────────────────────────────────────────────
177
+
178
+ def queue_reply(self, operation: str, responses: Iterable[JsonValue | None]) -> None:
179
+ """Queue what the gateway answers the next requests of `operation` with, one each, before `make_response` is asked.
180
+
181
+ Each is a `response`, or `None` for a reply that carries none.
182
+ """
183
+ self._replies.setdefault(operation, deque()).extend(responses)
184
+
185
+ def refuse_when(self, refuse: Refuse) -> None:
186
+ """Have the gateway answer with a failure every request `refuse` gives one for.
187
+
188
+ The failure is the payload of the operation's failure channel, such
189
+ as `{"code": "not-found", "message": "…"}`. It is asked before
190
+ anything queued or scripted to answer.
191
+ """
192
+ self._refuse = refuse
193
+
194
+ def fail(self, error: SemiontError) -> None:
195
+ """Report a failure on the failure stream, as a transport does of a request the gateway refused."""
196
+ self._failures.deliver(error)
197
+
198
+ def set_state(self, state: ConnectionState) -> None:
199
+ """Change the connection's state, as a wire would. A closed transport's is closed."""
200
+ self._state.set(state)
201
+
202
+ def deliver(self, frame: Frame) -> None:
203
+ """Deliver a frame as if the bus had carried it. A closed transport has nobody to deliver to."""
204
+ self._router.route(frame)
205
+ self._hub.deliver(frame)
206
+
207
+ # ── What a test reads ───────────────────────────────────────────────
208
+
209
+ @property
210
+ def request_log(self) -> Sequence[RequestLogEntry]:
211
+ """Every request sent, in order."""
212
+ return self._log
213
+
214
+ @property
215
+ def emitted(self) -> Sequence[Frame]:
216
+ """Every frame emitted through it, in order: the requests, and what was only sent."""
217
+ return self._emitted
218
+
219
+ def holds(self, resource_id: ResourceId) -> int:
220
+ """How many holds there are on a resource's scope."""
221
+ return self._held.get(resource_id, 0)
222
+
223
+ @property
224
+ def scopes(self) -> list[ResourceId]:
225
+ """The resources whose scope is held, in name order."""
226
+ return sorted(self._held)
227
+
228
+ @property
229
+ def pending_replies(self) -> list[str]:
230
+ """The correlation ids of the replies still awaited."""
231
+ return self._router.awaited()
232
+
233
+ # ── The transport ───────────────────────────────────────────────────
234
+
235
+ @property
236
+ @override
237
+ def base_url(self) -> str:
238
+ return "faulty://simulator"
239
+
240
+ @override
241
+ async def emit(
242
+ self, channel: str, payload: Mapping[str, JsonValue], *, scope: ResourceId | None = None, correlation_id: str | None = None
243
+ ) -> int | None:
244
+ if self._closed:
245
+ return None
246
+ # As it was sent: its caller changing its own payload afterwards rewrites nothing here.
247
+ sent = dict(payload)
248
+ request = Frame(channel=channel, payload=sent, correlation_id=correlation_id, scope=scope)
249
+ self._emitted.append(request)
250
+ operation = OPERATIONS.get(channel)
251
+ if operation is None:
252
+ self.deliver(request)
253
+ return 1
254
+
255
+ action: FaultAction = self._schedule[len(self._log) % len(self._schedule)] if self._schedule else Deliver()
256
+ self._log.append(
257
+ RequestLogEntry(
258
+ channel=channel, action=action, correlation_id=correlation_id, retry_key=retry_key_of(channel, sent), payload=sent
259
+ )
260
+ )
261
+ if isinstance(action, RejectEmit):
262
+ raise TransportError("error", f"FaultyTransport: emit rejected by schedule on {channel}")
263
+
264
+ # The gateway answers once per request that reaches it, whatever the wire then does to the answer.
265
+ refused = None if self._refuse is None else self._refuse(channel, sent)
266
+ if refused is not None:
267
+ reply = Frame(channel=operation.failure.name, payload=refused, correlation_id=correlation_id)
268
+ else:
269
+ queued = self._replies.get(channel)
270
+ response = queued.popleft() if queued else self._make_response(channel, sent)
271
+ reply = Frame(
272
+ channel=operation.result.name, payload={} if response is None else {"response": response}, correlation_id=correlation_id
273
+ )
274
+ self.deliver(request)
275
+
276
+ # The reply comes after the emit has been accepted, as a wire's does. One on its way when the transport closes is dropped.
277
+ loop = asyncio.get_running_loop()
278
+ match action:
279
+ case Deliver():
280
+ loop.call_soon(self._arrive, reply, 1)
281
+ case DuplicateReply():
282
+ loop.call_soon(self._arrive, reply, 2)
283
+ case Delay(ms=ms):
284
+ loop.call_later(ms / 1000, self._arrive, reply, 1)
285
+ case DropReply():
286
+ pass
287
+ return 1
288
+
289
+ def _arrive(self, reply: Frame, copies: int) -> None:
290
+ for _ in range(copies):
291
+ self.deliver(reply)
292
+
293
+ @override
294
+ def frames(self, channel: str) -> Events[Frame]:
295
+ if not self.is_subscribed(channel):
296
+ raise unsubscribed(channel)
297
+ return self._hub.frames(channel)
298
+
299
+ @override
300
+ def is_subscribed(self, channel: str) -> bool:
301
+ return self._channels is None or channel in self._channels
302
+
303
+ @override
304
+ def subscribe_to_resource(self, resource_id: ResourceId) -> ResourceHold:
305
+ # Nothing here is delivered by scope, so a hold changes only the count of them (`holds`).
306
+ self._held[resource_id] = self._held.get(resource_id, 0) + 1
307
+
308
+ def release() -> None:
309
+ remaining = self._held[resource_id] - 1
310
+ if remaining:
311
+ self._held[resource_id] = remaining
312
+ else:
313
+ del self._held[resource_id]
314
+
315
+ return ResourceHold(release)
316
+
317
+ @property
318
+ @override
319
+ def state(self) -> Watched[ConnectionState]:
320
+ return self._state
321
+
322
+ @override
323
+ def failures(self) -> Events[SemiontError]:
324
+ return self._failures.listen()
325
+
326
+ @override
327
+ def track_reply(self, correlation_id: str, reply_channels: Collection[str]) -> PendingReply:
328
+ return self._router.track(correlation_id, reply_channels)
329
+
330
+ @override
331
+ def bridge_into(self, bus: FrameSink) -> None:
332
+ self._hub.bridge(bus)
333
+
334
+ @override
335
+ async def close(self) -> None:
336
+ self._closed = True
337
+ self._state.set("closed")
338
+ self._state.end()
339
+ self._hub.close()
340
+ self._router.close()
341
+ self._failures.close()