aiohttp-tiny-mcp 0.1.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.
Files changed (41) hide show
  1. aiohttp_tiny_mcp/__init__.py +76 -0
  2. aiohttp_tiny_mcp/adapter.py +321 -0
  3. aiohttp_tiny_mcp/auth.py +129 -0
  4. aiohttp_tiny_mcp/client.py +111 -0
  5. aiohttp_tiny_mcp/client_base.py +295 -0
  6. aiohttp_tiny_mcp/console/__init__.py +98 -0
  7. aiohttp_tiny_mcp/console/console.css +525 -0
  8. aiohttp_tiny_mcp/console/console.js +1273 -0
  9. aiohttp_tiny_mcp/console/index.html +100 -0
  10. aiohttp_tiny_mcp/core.py +327 -0
  11. aiohttp_tiny_mcp/dispatcher.py +309 -0
  12. aiohttp_tiny_mcp/endpoint.py +531 -0
  13. aiohttp_tiny_mcp/exchange.py +279 -0
  14. aiohttp_tiny_mcp/http_sse.py +267 -0
  15. aiohttp_tiny_mcp/hub.py +109 -0
  16. aiohttp_tiny_mcp/models.py +346 -0
  17. aiohttp_tiny_mcp/namespaces.py +36 -0
  18. aiohttp_tiny_mcp/postgres.py +454 -0
  19. aiohttp_tiny_mcp/protocol/__init__.py +0 -0
  20. aiohttp_tiny_mcp/protocol/selection.py +92 -0
  21. aiohttp_tiny_mcp/protocol/v2024_11_05.py +30 -0
  22. aiohttp_tiny_mcp/protocol/v2025_03_26.py +165 -0
  23. aiohttp_tiny_mcp/protocol/v2025_06_18.py +11 -0
  24. aiohttp_tiny_mcp/protocol/v2025_11_25.py +164 -0
  25. aiohttp_tiny_mcp/protocol/v2026_07_28.py +363 -0
  26. aiohttp_tiny_mcp/py.typed +0 -0
  27. aiohttp_tiny_mcp/redis.py +195 -0
  28. aiohttp_tiny_mcp/registry.py +162 -0
  29. aiohttp_tiny_mcp/request_state.py +107 -0
  30. aiohttp_tiny_mcp/schema.py +131 -0
  31. aiohttp_tiny_mcp/sessions.py +347 -0
  32. aiohttp_tiny_mcp/specs.py +268 -0
  33. aiohttp_tiny_mcp/sqlite.py +236 -0
  34. aiohttp_tiny_mcp/sse.py +260 -0
  35. aiohttp_tiny_mcp/stdio.py +187 -0
  36. aiohttp_tiny_mcp/stdio_client.py +91 -0
  37. aiohttp_tiny_mcp/subscriptions.py +124 -0
  38. aiohttp_tiny_mcp/testing.py +200 -0
  39. aiohttp_tiny_mcp-0.1.0.dist-info/METADATA +12 -0
  40. aiohttp_tiny_mcp-0.1.0.dist-info/RECORD +41 -0
  41. aiohttp_tiny_mcp-0.1.0.dist-info/WHEEL +4 -0
@@ -0,0 +1,363 @@
1
+ """2026-07-28: no handshake, per-request `_meta`, MRTR, mirrored headers."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ from collections.abc import Mapping
7
+ from functools import lru_cache
8
+ from types import MappingProxyType
9
+ from typing import Any, ClassVar, Literal
10
+
11
+ from pydantic import Field
12
+
13
+ from aiohttp_tiny_mcp.adapter import Adapter, RegistryProtocol
14
+ from aiohttp_tiny_mcp.core import (
15
+ Call,
16
+ ClientInfo,
17
+ ClientProfile,
18
+ Failure,
19
+ FailureKind,
20
+ InputRequest,
21
+ NeedsInput,
22
+ Operation,
23
+ Preamble,
24
+ Rejected,
25
+ unwrap_answers,
26
+ )
27
+ from aiohttp_tiny_mcp.models import (
28
+ CallToolParams,
29
+ CompleteParams,
30
+ GetPromptParams,
31
+ Incoming,
32
+ ListenParams,
33
+ ListParams,
34
+ Meta,
35
+ Model,
36
+ Params,
37
+ ReadResourceParams,
38
+ ReadResourceResult,
39
+ ToolDef,
40
+ )
41
+ from aiohttp_tiny_mcp.schema import header_params
42
+ from aiohttp_tiny_mcp.specs import ToolSpec
43
+
44
+ # Modern subscriptions are independent long-lived requests, not MCP sessions.
45
+ METHODS: Mapping[str, Operation] = MappingProxyType(
46
+ {
47
+ "server/discover": Operation.DESCRIBE,
48
+ "tools/list": Operation.LIST_TOOLS,
49
+ "tools/call": Operation.CALL_TOOL,
50
+ "resources/list": Operation.LIST_RESOURCES,
51
+ "resources/templates/list": Operation.LIST_RESOURCE_TEMPLATES,
52
+ "resources/read": Operation.READ_RESOURCE,
53
+ "prompts/list": Operation.LIST_PROMPTS,
54
+ "prompts/get": Operation.GET_PROMPT,
55
+ "completion/complete": Operation.COMPLETE,
56
+ "subscriptions/listen": Operation.LISTEN,
57
+ }
58
+ )
59
+ METHOD_NAMES: Mapping[Operation, str] = MappingProxyType({op: name for name, op in METHODS.items()})
60
+
61
+ PARAMS_MODELS: Mapping[Operation, type[Params]] = MappingProxyType(
62
+ {
63
+ Operation.LIST_TOOLS: ListParams,
64
+ Operation.CALL_TOOL: CallToolParams,
65
+ Operation.LIST_RESOURCES: ListParams,
66
+ Operation.LIST_RESOURCE_TEMPLATES: ListParams,
67
+ Operation.READ_RESOURCE: ReadResourceParams,
68
+ Operation.LIST_PROMPTS: ListParams,
69
+ Operation.GET_PROMPT: GetPromptParams,
70
+ Operation.COMPLETE: CompleteParams,
71
+ Operation.LISTEN: ListenParams,
72
+ }
73
+ )
74
+
75
+ NAME_HEADER_METHODS = frozenset({"tools/call", "resources/read", "prompts/get"})
76
+
77
+
78
+ @lru_cache(maxsize=1024)
79
+ def decode_header(value: str) -> str:
80
+ """Undo the `=?base64?...?=` sentinel used for non-ASCII header values."""
81
+ if value.startswith("=?base64?") and value.endswith("?="):
82
+ return base64.b64decode(value[9:-2], validate=True).decode("utf-8")
83
+ return value
84
+
85
+
86
+ def header_text(value: Any) -> str:
87
+ if isinstance(value, bool):
88
+ return "true" if value else "false"
89
+ if isinstance(value, int) and -(2**53) + 1 <= value <= 2**53 - 1:
90
+ return str(value)
91
+ if isinstance(value, str):
92
+ return value
93
+ raise ValueError("mirrored parameter must be a string, boolean, or safe integer")
94
+
95
+
96
+ def encode_header(value: Any) -> str:
97
+ text = header_text(value)
98
+ unsafe = text != text.strip(" \t") or any(
99
+ ord(char) < 0x20 or ord(char) >= 0x7F for char in text
100
+ )
101
+ if unsafe:
102
+ encoded = base64.b64encode(text.encode()).decode("ascii")
103
+ return f"=?base64?{encoded}?="
104
+ return text
105
+
106
+
107
+ MISSING = object()
108
+
109
+
110
+ def path_value(arguments: Mapping[str, Any], path: tuple[str, ...]) -> Any:
111
+ value: Any = arguments
112
+ for part in path:
113
+ if not isinstance(value, Mapping) or part not in value:
114
+ return MISSING
115
+ value = value[part]
116
+ return value
117
+
118
+
119
+ class DiscoverResult(Model):
120
+ result_type: Literal["complete"] = "complete"
121
+ supported_versions: list[str]
122
+ capabilities: dict[str, Any]
123
+ instructions: str | None = None
124
+ ttl_ms: int | None = None
125
+ cache_scope: Literal["private", "public"] | None = None
126
+ meta: Meta | None = Field(None, alias="_meta")
127
+
128
+
129
+ class InputRequiredResult(Model):
130
+ result_type: Literal["input_required"] = "input_required"
131
+ input_requests: dict[str, Any]
132
+ request_state: str | None = None
133
+ meta: Meta | None = Field(None, alias="_meta")
134
+
135
+
136
+ class Adapter2026_07_28(Adapter): # noqa: N801 -- revision date, greppable against the spec
137
+ version: ClassVar[str] = "2026-07-28"
138
+ can_ask: ClassVar[bool] = True
139
+
140
+ FAILURE_MAP: ClassVar[Mapping[FailureKind, tuple[int, int]]] = MappingProxyType(
141
+ {
142
+ **Adapter.BASE_FAILURE_MAP,
143
+ FailureKind.UNKNOWN_METHOD: (-32601, 404),
144
+ FailureKind.UNKNOWN_TARGET: (-32601, 404),
145
+ FailureKind.RESOURCE_NOT_FOUND: (-32602, 404),
146
+ FailureKind.HEADER_MISMATCH: (-32020, 400),
147
+ FailureKind.INPUT_UNSUPPORTED: (-32603, 200),
148
+ FailureKind.MISSING_REQUIRED_CAPABILITY: (-32021, 400),
149
+ FailureKind.UNSUPPORTED_VERSION: (-32022, 400),
150
+ }
151
+ )
152
+
153
+ def __init__(self) -> None:
154
+ self.supported_versions: tuple[str, ...] = (self.version,)
155
+
156
+ def bind(self, versions: tuple[str, ...]) -> None:
157
+ self.supported_versions = versions
158
+
159
+ def check_http(
160
+ self,
161
+ pre: Preamble,
162
+ headers: Mapping[str, str],
163
+ registry: RegistryProtocol | None = None,
164
+ ) -> None:
165
+ is_notification = isinstance(pre.body, dict) and "id" not in pre.body
166
+ if pre.meta_version is None:
167
+ raise Rejected(
168
+ Failure(FailureKind.MALFORMED, "missing protocol version in request _meta")
169
+ )
170
+ if pre.header_version is None:
171
+ if is_notification:
172
+ return
173
+ raise Rejected(
174
+ Failure(FailureKind.HEADER_MISMATCH, "missing MCP-Protocol-Version header")
175
+ )
176
+ if pre.header_version != pre.meta_version:
177
+ raise Rejected(
178
+ Failure(FailureKind.HEADER_MISMATCH, "MCP-Protocol-Version does not match _meta")
179
+ )
180
+ method = headers.get("Mcp-Method")
181
+ if method is None:
182
+ raise Rejected(Failure(FailureKind.HEADER_MISMATCH, "missing Mcp-Method header"))
183
+ if method != pre.method:
184
+ raise Rejected(
185
+ Failure(
186
+ FailureKind.HEADER_MISMATCH,
187
+ f"Mcp-Method {method!r} != body method {pre.method!r}",
188
+ )
189
+ )
190
+ if pre.method not in NAME_HEADER_METHODS:
191
+ return
192
+ name = headers.get("Mcp-Name")
193
+ if name is None:
194
+ raise Rejected(Failure(FailureKind.HEADER_MISMATCH, "missing Mcp-Name header"))
195
+ params = pre.body.get("params") if isinstance(pre.body, dict) else None
196
+ key = "uri" if pre.method == "resources/read" else "name"
197
+ target = params.get(key) if isinstance(params, dict) else None
198
+ try:
199
+ decoded_name = decode_header(name)
200
+ except (ValueError, UnicodeDecodeError):
201
+ raise Rejected(
202
+ Failure(FailureKind.HEADER_MISMATCH, "malformed Mcp-Name header")
203
+ ) from None
204
+ if decoded_name != target:
205
+ raise Rejected(Failure(FailureKind.HEADER_MISMATCH, "Mcp-Name does not match body"))
206
+ if pre.method == "tools/call" and registry is not None:
207
+ spec = registry.tools.get(target or "")
208
+ arguments = params.get("arguments", {}) if isinstance(params, dict) else {}
209
+ if spec is not None and isinstance(arguments, Mapping):
210
+ self.check_parameter_headers(spec, arguments, headers)
211
+
212
+ def check_parameter_headers(
213
+ self,
214
+ spec: ToolSpec,
215
+ arguments: Mapping[str, Any],
216
+ headers: Mapping[str, str],
217
+ ) -> None:
218
+ for path, suffix in spec.header_params:
219
+ header_name = f"Mcp-Param-{suffix}"
220
+ actual = headers.get(header_name)
221
+ expected_value = path_value(arguments, path)
222
+ if expected_value is MISSING or expected_value is None:
223
+ if actual is not None:
224
+ raise Rejected(
225
+ Failure(
226
+ FailureKind.HEADER_MISMATCH,
227
+ f"{header_name} has no matching body parameter",
228
+ )
229
+ )
230
+ continue
231
+ if actual is None:
232
+ raise Rejected(
233
+ Failure(FailureKind.HEADER_MISMATCH, f"missing {header_name} header")
234
+ )
235
+ try:
236
+ expected = header_text(expected_value)
237
+ decoded = decode_header(actual)
238
+ except (ValueError, UnicodeDecodeError):
239
+ raise Rejected(
240
+ Failure(FailureKind.HEADER_MISMATCH, f"malformed {header_name} header")
241
+ ) from None
242
+ if decoded != expected:
243
+ raise Rejected(
244
+ Failure(
245
+ FailureKind.HEADER_MISMATCH,
246
+ f"{header_name} does not match body parameter",
247
+ )
248
+ )
249
+
250
+ def params_model(self, operation: Operation) -> type[Params]:
251
+ return PARAMS_MODELS.get(operation, Params)
252
+
253
+ def check_message(self, operation: Operation, msg: Incoming) -> None:
254
+ if operation is Operation.LISTEN and msg.is_notification:
255
+ raise Rejected(Failure(FailureKind.MALFORMED, "subscriptions/listen requires an id"))
256
+
257
+ def check_params(self, params: Params) -> None:
258
+ """Context is required on every request; there are no handshake defaults."""
259
+ if params.meta.protocol_version != self.version:
260
+ raise Rejected(
261
+ Failure(FailureKind.MALFORMED, "missing or incorrect protocol version in _meta")
262
+ )
263
+ if params.meta.client_capabilities is None:
264
+ raise Rejected(
265
+ Failure(FailureKind.MALFORMED, "missing client capabilities in request _meta")
266
+ )
267
+
268
+ def client_info_for(self, params: Params) -> ClientInfo:
269
+ info = params.meta.client_info
270
+ return ClientInfo(
271
+ name=info.name if info is not None else None,
272
+ version=info.version if info is not None else None,
273
+ capabilities=params.meta.client_capabilities or {},
274
+ )
275
+
276
+ def answers_for(self, params: Params) -> Mapping[str, Any]:
277
+ return unwrap_answers(params.input_responses)
278
+
279
+ def operation_for(self, method: str) -> Operation | None:
280
+ return METHODS.get(method)
281
+
282
+ def method_for(self, operation: Operation) -> str | None:
283
+ return METHOD_NAMES.get(operation)
284
+
285
+ def encode_value(self, call: Call, registry: RegistryProtocol, result: Any) -> dict[str, Any]:
286
+ meta = getattr(result, "meta", None) or Meta()
287
+ updates: dict[str, Any] = {"meta": meta.model_copy(update={"server_info": registry.info})}
288
+ if getattr(result, "result_type", "absent") is None:
289
+ updates["result_type"] = "complete"
290
+ if getattr(result, "ttl_ms", "absent") is None:
291
+ ttl_ms, cache_scope = 0, "private"
292
+ if isinstance(result, ReadResourceResult):
293
+ spec = registry.match_resource(call.target or "")
294
+ if spec is not None:
295
+ if spec[0].cache_ttl_ms is not None:
296
+ ttl_ms = spec[0].cache_ttl_ms
297
+ if spec[0].cache_scope is not None:
298
+ cache_scope = spec[0].cache_scope
299
+ updates["ttl_ms"] = ttl_ms
300
+ updates["cache_scope"] = cache_scope
301
+ result = result.model_copy(update=updates)
302
+ return {"jsonrpc": "2.0", "id": call.id, "result": result.wire()}
303
+
304
+ def encode_input_required(
305
+ self, call: Call, registry: RegistryProtocol, out: NeedsInput
306
+ ) -> dict[str, Any]:
307
+ if out.state is not None and not isinstance(out.state, str):
308
+ raise TypeError("requestState must be sealed before wire encoding")
309
+ result = InputRequiredResult(
310
+ input_requests=dict(out.requests),
311
+ request_state=out.state,
312
+ meta=Meta(server_info=registry.info),
313
+ )
314
+ return {"jsonrpc": "2.0", "id": call.id, "result": result.wire()}
315
+
316
+ def describe_server(self, registry: RegistryProtocol, call: Call) -> DiscoverResult:
317
+ return DiscoverResult(
318
+ supported_versions=list(self.supported_versions),
319
+ capabilities=self.capabilities(registry),
320
+ instructions=registry.instructions,
321
+ )
322
+
323
+ def describe_tool(self, spec: ToolSpec) -> ToolDef | None:
324
+ if spec.min_revision is not None and spec.min_revision > self.version:
325
+ return None
326
+ return spec.definition(spec.input_schema, spec.output_schema)
327
+
328
+ def client_headers(
329
+ self,
330
+ method: str,
331
+ name: str | None = None,
332
+ params: Mapping[str, Any] | None = None,
333
+ tool: ToolDef | None = None,
334
+ ) -> Mapping[str, str]:
335
+ headers = {"MCP-Protocol-Version": self.version, "Mcp-Method": method}
336
+ if name is not None and method in NAME_HEADER_METHODS:
337
+ headers["Mcp-Name"] = encode_header(name)
338
+ if method == "tools/call" and params is not None and tool is not None:
339
+ arguments = params.get("arguments", {})
340
+ if isinstance(arguments, Mapping):
341
+ for path, suffix in header_params(tool.input_schema):
342
+ value = path_value(arguments, path)
343
+ if value is not MISSING and value is not None:
344
+ headers[f"Mcp-Param-{suffix}"] = encode_header(value)
345
+ return headers
346
+
347
+ def client_handshake_params(self, client: ClientProfile) -> Params:
348
+ return Params()
349
+
350
+ def client_decorate_params(self, params: Params, client: ClientProfile) -> Params:
351
+ # No handshake or session: send client context on every request.
352
+ params.meta.protocol_version = self.version
353
+ params.meta.client_info = client.info
354
+ params.meta.client_capabilities = dict(client.capabilities)
355
+ params.meta.log_level = client.log_level
356
+ return params
357
+
358
+ def client_input_requests(
359
+ self, result: Mapping[str, Any]
360
+ ) -> tuple[Mapping[str, InputRequest], Any] | None:
361
+ if result.get("resultType") != "input_required":
362
+ return None
363
+ return result.get("inputRequests") or {}, result.get("requestState")
File without changes
@@ -0,0 +1,195 @@
1
+ """Redis `SessionStore` and `Hub` backends."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import AsyncIterator, Mapping, Sequence
7
+ from typing import Any
8
+
9
+ from .hub import START, Cursor, Hub
10
+ from .sessions import SessionRecord, SessionStore
11
+
12
+ try:
13
+ from redis.asyncio import Redis
14
+ except ImportError as absent: # pragma: no cover - depends on what is installed
15
+ raise ImportError(
16
+ 'aiohttp_tiny_mcp.redis needs redis. Install "aiohttp-tiny-mcp[redis]".'
17
+ ) from absent
18
+
19
+ BEGINNING = "0-0"
20
+
21
+ CREATE = """
22
+ if redis.call('EXISTS', KEYS[1]) == 1 then return 0 end
23
+ redis.call('HSET', KEYS[1], 'data', ARGV[1], 'version', 1)
24
+ redis.call('EXPIRE', KEYS[1], ARGV[2])
25
+ return 1
26
+ """
27
+
28
+ SAVE = """
29
+ local version = redis.call('HGET', KEYS[1], 'version')
30
+ if not version or tonumber(version) ~= tonumber(ARGV[2]) then return 0 end
31
+ redis.call('HSET', KEYS[1], 'data', ARGV[1], 'version', ARGV[3])
32
+ redis.call('EXPIRE', KEYS[1], ARGV[4])
33
+ return 1
34
+ """
35
+
36
+
37
+ def text(value: Any) -> str:
38
+ """Redis answers in bytes, or in strings where the client decodes them."""
39
+ return value.decode() if isinstance(value, bytes) else str(value)
40
+
41
+
42
+ class RedisStorage:
43
+ """A Redis client shared by the store and hub."""
44
+
45
+ prefix: str = "mcp"
46
+
47
+ def __init__(
48
+ self,
49
+ client: Redis,
50
+ *,
51
+ prefix: str | None = None,
52
+ owned: bool = False,
53
+ ) -> None:
54
+ self.client = client
55
+ self.owned = owned
56
+ if prefix is not None:
57
+ self.prefix = prefix
58
+
59
+ @classmethod
60
+ def from_url(cls, url: str, **settings: Any) -> RedisStorage:
61
+ """Build an owned Redis client from a URL."""
62
+ return cls(Redis.from_url(url), owned=True, **settings)
63
+
64
+ async def open(self) -> Redis:
65
+ return self.client
66
+
67
+ async def close(self) -> None:
68
+ """Close an owned client."""
69
+ if self.owned:
70
+ await self.client.aclose()
71
+
72
+ async def cleanup_ctx(self, app: Any) -> AsyncIterator[None]:
73
+ """Manage an owned client for an aiohttp app."""
74
+ await self.open()
75
+ try:
76
+ yield
77
+ finally:
78
+ await self.close()
79
+
80
+
81
+ class RedisSessionStore(SessionStore):
82
+ """`SessionStore` on one key per session, with version-checked writes."""
83
+
84
+ def __init__(self, storage: RedisStorage, *, prefix: str | None = None) -> None:
85
+ self.storage = storage
86
+ self.prefix = prefix if prefix is not None else storage.prefix
87
+ self.scripts: dict[str, Any] = {}
88
+
89
+ async def script(self, source: str) -> Any:
90
+ """Register a script once per client, and keep it by its source."""
91
+ if source not in self.scripts:
92
+ client = await self.storage.open()
93
+ self.scripts[source] = client.register_script(source)
94
+ return self.scripts[source]
95
+
96
+ def key(self, session_id: str) -> str:
97
+ return f"{self.prefix}:session:{session_id}"
98
+
99
+ async def create(self, session_id: str, data: Mapping[str, Any], *, ttl_seconds: int) -> bool:
100
+ create = await self.script(CREATE)
101
+ written: Any = await create(
102
+ keys=[self.key(session_id)], args=[json.dumps(data), ttl_seconds]
103
+ )
104
+ return bool(written)
105
+
106
+ async def get(self, session_id: str) -> SessionRecord | None:
107
+ client = await self.storage.open()
108
+ held: Any = await client.hmget(self.key(session_id), ["data", "version"])
109
+ data, version = held
110
+ if data is None or version is None:
111
+ return None
112
+ return SessionRecord(data=json.loads(data), version=int(version))
113
+
114
+ async def save(
115
+ self,
116
+ session_id: str,
117
+ data: Mapping[str, Any],
118
+ *,
119
+ expected_version: int,
120
+ ttl_seconds: int,
121
+ ) -> bool:
122
+ save = await self.script(SAVE)
123
+ written: Any = await save(
124
+ keys=[self.key(session_id)],
125
+ args=[json.dumps(data), expected_version, expected_version + 1, ttl_seconds],
126
+ )
127
+ return bool(written)
128
+
129
+ async def delete(self, session_id: str) -> None:
130
+ client = await self.storage.open()
131
+ await client.delete(self.key(session_id))
132
+
133
+
134
+ class RedisHub(Hub):
135
+ """A Redis stream per topic."""
136
+
137
+ keep: int = 1000
138
+
139
+ ttl_seconds: int = 3600
140
+
141
+ def __init__(
142
+ self,
143
+ storage: RedisStorage,
144
+ *,
145
+ prefix: str | None = None,
146
+ keep: int | None = None,
147
+ ttl_seconds: int | None = None,
148
+ ) -> None:
149
+ self.storage = storage
150
+ self.prefix = prefix if prefix is not None else storage.prefix
151
+ if keep is not None:
152
+ self.keep = keep
153
+ if ttl_seconds is not None:
154
+ self.ttl_seconds = ttl_seconds
155
+
156
+ def key(self, topic: str) -> str:
157
+ return f"{self.prefix}:topic:{topic}"
158
+
159
+ async def publish(self, topic: str, message: Mapping[str, Any]) -> None:
160
+ client = await self.storage.open()
161
+ key = self.key(topic)
162
+ await client.xadd(key, {"message": json.dumps(message)}, maxlen=self.keep, approximate=True)
163
+ await client.expire(key, self.ttl_seconds)
164
+
165
+ async def position(self, topic: str) -> Cursor:
166
+ client = await self.storage.open()
167
+ last: Any = await client.xrevrange(self.key(topic), count=1)
168
+ return text(last[0][0]) if last else START
169
+
170
+ async def poll(
171
+ self, topic: str, cursor: Cursor, *, timeout: float
172
+ ) -> tuple[Sequence[Mapping[str, Any]], Cursor]:
173
+ client = await self.storage.open()
174
+ found: Any = await client.xread(
175
+ {self.key(topic): cursor or BEGINNING}, block=max(1, int(timeout * 1000))
176
+ )
177
+ if not found:
178
+ return [], cursor
179
+ entries = found[0][1]
180
+ messages = [json.loads(next(iter(fields.values()))) for _, fields in entries]
181
+ return messages, text(entries[-1][0])
182
+
183
+ async def delete(self, topic: str) -> None:
184
+ client = await self.storage.open()
185
+ await client.delete(self.key(topic))
186
+
187
+
188
+ __all__ = [
189
+ "BEGINNING",
190
+ "CREATE",
191
+ "SAVE",
192
+ "RedisHub",
193
+ "RedisSessionStore",
194
+ "RedisStorage",
195
+ ]