pytest-amgi 0.41.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.
@@ -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,77 @@
1
+ # pytest-amgi
2
+
3
+ Pytest helpers for driving AMGI applications in-process.
4
+
5
+ ## Installation
6
+
7
+ ```
8
+ pip install pytest-amgi==0.41.0
9
+ ```
10
+
11
+ ## Example
12
+
13
+ This example uses [AsyncFast](https://pypi.org/project/asyncfast/):
14
+
15
+ ```python
16
+ from asyncfast import AsyncFast
17
+ from pytest_amgi import AMGIProducerFactory
18
+
19
+
20
+ async def test_message(amgi_producer: AMGIProducerFactory) -> None:
21
+ app = AsyncFast()
22
+
23
+ @app.channel("topic")
24
+ async def handler(payload: int) -> None:
25
+ assert payload == 1
26
+
27
+ producer = await amgi_producer(app)
28
+ response = await producer.send("topic", json=1)
29
+
30
+ response.assert_acked()
31
+ ```
32
+
33
+ The `amgi_producer` fixture starts lifespan before returning the producer and
34
+ shuts it down when the test finishes.
35
+
36
+ ## Message Sends
37
+
38
+ Use `assert_has_message_send` to assert that the application sent a follow-up
39
+ message:
40
+
41
+ ```python
42
+ from dataclasses import dataclass
43
+
44
+ from asyncfast import AsyncFast
45
+ from asyncfast import Message
46
+ from asyncfast import MessageSender
47
+ from pytest_amgi import AMGIProducerFactory
48
+
49
+
50
+ @dataclass
51
+ class ProcessOrder(Message, address="order.process"):
52
+ payload: dict[str, int]
53
+
54
+
55
+ async def test_message_send(amgi_producer: AMGIProducerFactory) -> None:
56
+ app = AsyncFast()
57
+
58
+ @app.channel("order.created")
59
+ async def handle_order_created(
60
+ message_sender: MessageSender[ProcessOrder],
61
+ ) -> None:
62
+ await message_sender.send(ProcessOrder(payload={"id": 1}))
63
+
64
+ producer = await amgi_producer(app)
65
+ response = await producer.send("order.created")
66
+
67
+ response.assert_acked()
68
+ response.assert_has_message_send("order.process", json={"id": 1})
69
+ ```
70
+
71
+ ## Contact
72
+
73
+ For questions or suggestions, please contact [jack.burridge@mail.com](mailto:jack.burridge@mail.com).
74
+
75
+ ## License
76
+
77
+ Copyright 2026 AMGI
@@ -0,0 +1,40 @@
1
+ [build-system]
2
+ build-backend = "uv_build"
3
+ requires = [
4
+ "uv-build>=0.10.7,<0.11.0",
5
+ ]
6
+
7
+ [project]
8
+ name = "pytest-amgi"
9
+ version = "0.41.0"
10
+ description = "Pytest helpers for AMGI applications"
11
+ readme = "README.md"
12
+ authors = [
13
+ { name = "jack.burridge", email = "jack.burridge@mail.com" },
14
+ ]
15
+ requires-python = ">=3.10"
16
+ classifiers = [
17
+ "Programming Language :: Python :: 3 :: Only",
18
+ "Programming Language :: Python :: 3.10",
19
+ "Programming Language :: Python :: 3.11",
20
+ "Programming Language :: Python :: 3.12",
21
+ "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
23
+ ]
24
+ dependencies = [
25
+ "amgi-common==0.41.0",
26
+ "amgi-types==0.41.0",
27
+ "pytest>=9.0.3",
28
+ ]
29
+ entry-points.pytest11.amgi = "pytest_amgi"
30
+
31
+ [dependency-groups]
32
+ dev = [
33
+ "pytest-asyncio>=1.4.0",
34
+ "pytest-cov>=7.0.0",
35
+ "pytest-timeout>=2.4.0",
36
+ ]
37
+
38
+ [tool.uv]
39
+ sources.amgi-common.workspace = true
40
+ sources.amgi-types.workspace = true
@@ -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
+ ]
@@ -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
File without changes