pytest-amgi 0.41.0__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.
pytest_amgi/__init__.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
from pytest_amgi._internal import amgi_producer
|
|
2
|
+
from pytest_amgi._internal import AMGIMessageResult
|
|
3
|
+
from pytest_amgi._internal import AMGIProducer
|
|
4
|
+
from pytest_amgi._internal import AMGIProducerFactory
|
|
5
|
+
from pytest_amgi._internal import Message
|
|
6
|
+
|
|
7
|
+
__all__ = [
|
|
8
|
+
"AMGIProducer",
|
|
9
|
+
"AMGIProducerFactory",
|
|
10
|
+
"AMGIMessageResult",
|
|
11
|
+
"Message",
|
|
12
|
+
"amgi_producer",
|
|
13
|
+
]
|
pytest_amgi/_internal.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json as json_module
|
|
4
|
+
import re
|
|
5
|
+
from collections.abc import AsyncGenerator
|
|
6
|
+
from collections.abc import Awaitable
|
|
7
|
+
from collections.abc import Mapping
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from contextlib import AsyncExitStack
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from typing import Any
|
|
12
|
+
from typing import Callable
|
|
13
|
+
from typing import TypeAlias
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
from amgi_common import Lifespan
|
|
17
|
+
from amgi_types import AMGIApplication
|
|
18
|
+
from amgi_types import AMGIReceiveEvent
|
|
19
|
+
from amgi_types import AMGISendEvent
|
|
20
|
+
from amgi_types import AMGIVersions
|
|
21
|
+
from amgi_types import MessageScope
|
|
22
|
+
from amgi_types import MessageSendEvent
|
|
23
|
+
|
|
24
|
+
AMGI_CLIENT_SCOPE: AMGIVersions = {"version": "2.0", "spec_version": "2.0"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
JsonType: TypeAlias = (
|
|
28
|
+
None | bool | int | float | str | Sequence["JsonType"] | Mapping[str, "JsonType"]
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
HeaderType: TypeAlias = (
|
|
32
|
+
Sequence[tuple[str | bytes, str | bytes]] | Mapping[str, str | bytes]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def encode_bytes(data: str | bytes) -> bytes:
|
|
37
|
+
if isinstance(data, str):
|
|
38
|
+
return data.encode()
|
|
39
|
+
return data
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def encode_headers(headers: HeaderType) -> Sequence[tuple[bytes, bytes]]:
|
|
43
|
+
header_items = headers.items() if isinstance(headers, Mapping) else headers
|
|
44
|
+
return [(encode_bytes(key), encode_bytes(value)) for key, value in header_items]
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class JsonMatcher:
|
|
48
|
+
def __init__(self, expected: JsonType) -> None:
|
|
49
|
+
self.expected = expected
|
|
50
|
+
|
|
51
|
+
def __eq__(self, other: object) -> bool:
|
|
52
|
+
return isinstance(other, bytes) and json_module.loads(other) == self.expected
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class HeadersMatcher:
|
|
56
|
+
def __init__(self, expected: HeaderType) -> None:
|
|
57
|
+
self.expected = expected
|
|
58
|
+
|
|
59
|
+
def __eq__(self, other: object) -> bool:
|
|
60
|
+
if not isinstance(other, Sequence):
|
|
61
|
+
return False
|
|
62
|
+
|
|
63
|
+
actual = list(other)
|
|
64
|
+
|
|
65
|
+
if isinstance(self.expected, Mapping):
|
|
66
|
+
expected = [
|
|
67
|
+
(encode_bytes(key), encode_bytes(value))
|
|
68
|
+
for key, value in self.expected.items()
|
|
69
|
+
]
|
|
70
|
+
return sorted(actual) == sorted(expected)
|
|
71
|
+
|
|
72
|
+
expected = [
|
|
73
|
+
(encode_bytes(key), encode_bytes(value)) for key, value in self.expected
|
|
74
|
+
]
|
|
75
|
+
return actual == expected
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Message:
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
address: str,
|
|
82
|
+
headers: HeaderType = (),
|
|
83
|
+
payload: str | bytes | None = None,
|
|
84
|
+
json: JsonType = None,
|
|
85
|
+
bindings: dict[str, dict[str, Any]] | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
self._address = address
|
|
88
|
+
self._header_matcher = HeadersMatcher(headers)
|
|
89
|
+
self._bindings = {} if bindings is None else bindings
|
|
90
|
+
|
|
91
|
+
payload_matcher: bytes | JsonMatcher | None = None
|
|
92
|
+
if payload is not None:
|
|
93
|
+
payload_matcher = encode_bytes(payload)
|
|
94
|
+
elif json is not None:
|
|
95
|
+
payload_matcher = JsonMatcher(json)
|
|
96
|
+
|
|
97
|
+
self._payload_matcher = payload_matcher
|
|
98
|
+
|
|
99
|
+
def __eq__(self, other: object) -> bool:
|
|
100
|
+
if not isinstance(other, Mapping):
|
|
101
|
+
return False
|
|
102
|
+
|
|
103
|
+
message_send = {
|
|
104
|
+
**other,
|
|
105
|
+
"payload": other.get("payload"),
|
|
106
|
+
"bindings": other.get("bindings", {}),
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
expected_message_send = {
|
|
110
|
+
"type": "message.send",
|
|
111
|
+
"address": self._address,
|
|
112
|
+
"headers": self._header_matcher,
|
|
113
|
+
"payload": self._payload_matcher,
|
|
114
|
+
"bindings": self._bindings,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return message_send == expected_message_send
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass(frozen=True)
|
|
121
|
+
class AMGIMessageResult:
|
|
122
|
+
_events: Sequence[AMGISendEvent]
|
|
123
|
+
|
|
124
|
+
@property
|
|
125
|
+
def acked(self) -> bool:
|
|
126
|
+
return any(event["type"] == "message.ack" for event in self._events)
|
|
127
|
+
|
|
128
|
+
@property
|
|
129
|
+
def nack_message(self) -> str | None:
|
|
130
|
+
for event in self._events:
|
|
131
|
+
if event["type"] == "message.nack":
|
|
132
|
+
return event["message"]
|
|
133
|
+
return None
|
|
134
|
+
|
|
135
|
+
@property
|
|
136
|
+
def message_sends(self) -> Sequence[MessageSendEvent]:
|
|
137
|
+
return [event for event in self._events if event["type"] == "message.send"]
|
|
138
|
+
|
|
139
|
+
def assert_acked(self) -> None:
|
|
140
|
+
assert self.acked
|
|
141
|
+
|
|
142
|
+
def assert_nacked(self, *, match: str | None = None) -> None:
|
|
143
|
+
assert self.nack_message is not None
|
|
144
|
+
if match is not None:
|
|
145
|
+
|
|
146
|
+
assert re.search(match, self.nack_message) is not None
|
|
147
|
+
|
|
148
|
+
def assert_has_message_sends(self, message_sends: Sequence[Message]) -> None:
|
|
149
|
+
actual_message_sends = self.message_sends
|
|
150
|
+
|
|
151
|
+
assert len(actual_message_sends) == len(message_sends)
|
|
152
|
+
assert all(
|
|
153
|
+
expected == actual
|
|
154
|
+
for actual, expected in zip(actual_message_sends, message_sends)
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
def assert_has_message_send(
|
|
158
|
+
self,
|
|
159
|
+
address: str,
|
|
160
|
+
headers: HeaderType = (),
|
|
161
|
+
payload: str | bytes | None = None,
|
|
162
|
+
json: JsonType = None,
|
|
163
|
+
bindings: dict[str, dict[str, Any]] | None = None,
|
|
164
|
+
) -> None:
|
|
165
|
+
expected_message = Message(address, headers, payload, json, bindings)
|
|
166
|
+
|
|
167
|
+
assert any(message == expected_message for message in self.message_sends)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
class AMGIProducer:
|
|
171
|
+
def __init__(
|
|
172
|
+
self,
|
|
173
|
+
app: AMGIApplication,
|
|
174
|
+
*,
|
|
175
|
+
state: dict[str, Any],
|
|
176
|
+
) -> None:
|
|
177
|
+
self._app = app
|
|
178
|
+
self._state = state
|
|
179
|
+
|
|
180
|
+
async def send(
|
|
181
|
+
self,
|
|
182
|
+
address: str,
|
|
183
|
+
*,
|
|
184
|
+
payload: str | bytes | None = None,
|
|
185
|
+
json: JsonType = None,
|
|
186
|
+
headers: HeaderType | None = None,
|
|
187
|
+
bindings: dict[str, dict[str, Any]] | None = None,
|
|
188
|
+
extensions: dict[str, dict[str, Any]] | None = None,
|
|
189
|
+
) -> AMGIMessageResult:
|
|
190
|
+
scope: MessageScope = {
|
|
191
|
+
"type": "message",
|
|
192
|
+
"amgi": AMGI_CLIENT_SCOPE,
|
|
193
|
+
"address": address,
|
|
194
|
+
"headers": [] if headers is None else encode_headers(headers),
|
|
195
|
+
"state": self._state.copy(),
|
|
196
|
+
}
|
|
197
|
+
if json is not None:
|
|
198
|
+
payload = json_module.dumps(json)
|
|
199
|
+
if payload is not None:
|
|
200
|
+
scope["payload"] = encode_bytes(payload)
|
|
201
|
+
if bindings is not None:
|
|
202
|
+
scope["bindings"] = bindings
|
|
203
|
+
if extensions is not None:
|
|
204
|
+
scope["extensions"] = extensions
|
|
205
|
+
|
|
206
|
+
events: list[AMGISendEvent] = []
|
|
207
|
+
|
|
208
|
+
async def receive() -> AMGIReceiveEvent:
|
|
209
|
+
raise RuntimeError("Receive should not be called for message scopes")
|
|
210
|
+
|
|
211
|
+
async def send(event: AMGISendEvent) -> None:
|
|
212
|
+
events.append(event)
|
|
213
|
+
|
|
214
|
+
await self._app(scope, receive, send)
|
|
215
|
+
return AMGIMessageResult(events)
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
AMGIProducerFactory = Callable[
|
|
219
|
+
[AMGIApplication],
|
|
220
|
+
Awaitable[AMGIProducer],
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@pytest.fixture
|
|
225
|
+
async def amgi_producer() -> AsyncGenerator[AMGIProducerFactory, None]:
|
|
226
|
+
async with AsyncExitStack() as exit_stack:
|
|
227
|
+
|
|
228
|
+
async def factory(app: AMGIApplication) -> AMGIProducer:
|
|
229
|
+
state = await exit_stack.enter_async_context(Lifespan(app))
|
|
230
|
+
return AMGIProducer(app, state=state)
|
|
231
|
+
|
|
232
|
+
yield factory
|
pytest_amgi/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
Metadata-Version: 2.3
|
|
2
|
+
Name: pytest-amgi
|
|
3
|
+
Version: 0.41.0
|
|
4
|
+
Summary: Pytest helpers for AMGI applications
|
|
5
|
+
Author: jack.burridge
|
|
6
|
+
Author-email: jack.burridge <jack.burridge@mail.com>
|
|
7
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
11
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
13
|
+
Requires-Dist: amgi-common==0.41.0
|
|
14
|
+
Requires-Dist: amgi-types==0.41.0
|
|
15
|
+
Requires-Dist: pytest>=9.0.3
|
|
16
|
+
Requires-Python: >=3.10
|
|
17
|
+
Description-Content-Type: text/markdown
|
|
18
|
+
|
|
19
|
+
# pytest-amgi
|
|
20
|
+
|
|
21
|
+
Pytest helpers for driving AMGI applications in-process.
|
|
22
|
+
|
|
23
|
+
## Installation
|
|
24
|
+
|
|
25
|
+
```
|
|
26
|
+
pip install pytest-amgi==0.41.0
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
## Example
|
|
30
|
+
|
|
31
|
+
This example uses [AsyncFast](https://pypi.org/project/asyncfast/):
|
|
32
|
+
|
|
33
|
+
```python
|
|
34
|
+
from asyncfast import AsyncFast
|
|
35
|
+
from pytest_amgi import AMGIProducerFactory
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def test_message(amgi_producer: AMGIProducerFactory) -> None:
|
|
39
|
+
app = AsyncFast()
|
|
40
|
+
|
|
41
|
+
@app.channel("topic")
|
|
42
|
+
async def handler(payload: int) -> None:
|
|
43
|
+
assert payload == 1
|
|
44
|
+
|
|
45
|
+
producer = await amgi_producer(app)
|
|
46
|
+
response = await producer.send("topic", json=1)
|
|
47
|
+
|
|
48
|
+
response.assert_acked()
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
The `amgi_producer` fixture starts lifespan before returning the producer and
|
|
52
|
+
shuts it down when the test finishes.
|
|
53
|
+
|
|
54
|
+
## Message Sends
|
|
55
|
+
|
|
56
|
+
Use `assert_has_message_send` to assert that the application sent a follow-up
|
|
57
|
+
message:
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from dataclasses import dataclass
|
|
61
|
+
|
|
62
|
+
from asyncfast import AsyncFast
|
|
63
|
+
from asyncfast import Message
|
|
64
|
+
from asyncfast import MessageSender
|
|
65
|
+
from pytest_amgi import AMGIProducerFactory
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass
|
|
69
|
+
class ProcessOrder(Message, address="order.process"):
|
|
70
|
+
payload: dict[str, int]
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
async def test_message_send(amgi_producer: AMGIProducerFactory) -> None:
|
|
74
|
+
app = AsyncFast()
|
|
75
|
+
|
|
76
|
+
@app.channel("order.created")
|
|
77
|
+
async def handle_order_created(
|
|
78
|
+
message_sender: MessageSender[ProcessOrder],
|
|
79
|
+
) -> None:
|
|
80
|
+
await message_sender.send(ProcessOrder(payload={"id": 1}))
|
|
81
|
+
|
|
82
|
+
producer = await amgi_producer(app)
|
|
83
|
+
response = await producer.send("order.created")
|
|
84
|
+
|
|
85
|
+
response.assert_acked()
|
|
86
|
+
response.assert_has_message_send("order.process", json={"id": 1})
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
## Contact
|
|
90
|
+
|
|
91
|
+
For questions or suggestions, please contact [jack.burridge@mail.com](mailto:jack.burridge@mail.com).
|
|
92
|
+
|
|
93
|
+
## License
|
|
94
|
+
|
|
95
|
+
Copyright 2026 AMGI
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
pytest_amgi/__init__.py,sha256=ZxY3bhhG1WZ2EpdXONAujEKeUNCJOvV2T2BOznAJRsc,366
|
|
2
|
+
pytest_amgi/_internal.py,sha256=y6CxXoi3pLbZR-iPZ_GTM1Is8ML9SiX4o0ZKYM6KVv8,6934
|
|
3
|
+
pytest_amgi/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
4
|
+
pytest_amgi-0.41.0.dist-info/WHEEL,sha256=8ZlpUMJ7mlDirmlHRhDirEx_nPnARrwDjeE92mlk68E,81
|
|
5
|
+
pytest_amgi-0.41.0.dist-info/entry_points.txt,sha256=mn2Y0aOYE0_2SH8DDjkPGIvKKJPiEkoIuvE8UI5MY74,31
|
|
6
|
+
pytest_amgi-0.41.0.dist-info/METADATA,sha256=AAUGeXQ7VdTBbRabx8yYKwnFcmdB5L_F8obMIGcRqrU,2420
|
|
7
|
+
pytest_amgi-0.41.0.dist-info/RECORD,,
|