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.
- semiont/__init__.py +20 -0
- semiont/bus.py +236 -0
- semiont/cache.py +345 -0
- semiont/cached.py +152 -0
- semiont/channel.py +81 -0
- semiont/channels.py +1306 -0
- semiont/client.py +161 -0
- semiont/error_codes.py +232 -0
- semiont/errors.py +147 -0
- semiont/event_bus.py +108 -0
- semiont/events.py +101 -0
- semiont/http/__init__.py +24 -0
- semiont/http/agent.py +232 -0
- semiont/http/content.py +234 -0
- semiont/http/exchange.py +200 -0
- semiont/http/oauth.py +346 -0
- semiont/http/session.py +191 -0
- semiont/http/sse.py +71 -0
- semiont/http/stream.py +683 -0
- semiont/http/transport.py +326 -0
- semiont/identifiers.py +114 -0
- semiont/identity.py +59 -0
- semiont/media_types.py +69 -0
- semiont/media_types_table.py +89 -0
- semiont/model.py +33 -0
- semiont/namespaces/__init__.py +19 -0
- semiont/namespaces/auth.py +32 -0
- semiont/namespaces/beckon.py +47 -0
- semiont/namespaces/bind.py +38 -0
- semiont/namespaces/browse.py +338 -0
- semiont/namespaces/follow.py +238 -0
- semiont/namespaces/frame.py +35 -0
- semiont/namespaces/gather.py +82 -0
- semiont/namespaces/job.py +94 -0
- semiont/namespaces/links.py +66 -0
- semiont/namespaces/live.py +301 -0
- semiont/namespaces/mark.py +174 -0
- semiont/namespaces/match.py +67 -0
- semiont/namespaces/refresher.py +283 -0
- semiont/namespaces/system.py +24 -0
- semiont/namespaces/yield_.py +101 -0
- semiont/oauth_clients.py +27 -0
- semiont/operations.py +337 -0
- semiont/py.typed +0 -0
- semiont/refresh.py +188 -0
- semiont/resume.py +95 -0
- semiont/retry.py +164 -0
- semiont/running.py +110 -0
- semiont/session.py +493 -0
- semiont/sign_in.py +54 -0
- semiont/sign_in_store.py +283 -0
- semiont/storage.py +139 -0
- semiont/telemetry.py +116 -0
- semiont/telemetry_table.py +121 -0
- semiont/testing/__init__.py +62 -0
- semiont/testing/client.py +53 -0
- semiont/testing/content.py +124 -0
- semiont/testing/gateway.py +85 -0
- semiont/testing/transport.py +341 -0
- semiont/timing.py +187 -0
- semiont/transport.py +531 -0
- semiont/types.py +5902 -0
- semiont/watched.py +111 -0
- semiont-0.6.12.dev2.dist-info/METADATA +533 -0
- semiont-0.6.12.dev2.dist-info/RECORD +66 -0
- 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()
|