agentconnect-cli 0.1.0a1__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.
- agentconnect/__init__.py +3 -0
- agentconnect/a2a_adapter/__init__.py +6 -0
- agentconnect/a2a_adapter/adapter.py +565 -0
- agentconnect/a2a_adapter/cards.py +149 -0
- agentconnect/a2a_adapter/convert.py +157 -0
- agentconnect/a2a_adapter/facade.py +560 -0
- agentconnect/a2a_adapter/seen.py +49 -0
- agentconnect/a2a_adapter/serve.py +67 -0
- agentconnect/adapter.py +319 -0
- agentconnect/adapters.py +52 -0
- agentconnect/apiclient.py +51 -0
- agentconnect/backoff.py +42 -0
- agentconnect/callback.py +314 -0
- agentconnect/cli.py +2324 -0
- agentconnect/cli_json.py +470 -0
- agentconnect/cli_support.py +444 -0
- agentconnect/clock.py +161 -0
- agentconnect/config.py +1213 -0
- agentconnect/daemon/__init__.py +41 -0
- agentconnect/deliveries.py +295 -0
- agentconnect/directory_client.py +255 -0
- agentconnect/failpoints.py +99 -0
- agentconnect/identity.py +111 -0
- agentconnect/isolation.py +345 -0
- agentconnect/keystore.py +279 -0
- agentconnect/limits.py +457 -0
- agentconnect/log.py +143 -0
- agentconnect/mcp/__init__.py +30 -0
- agentconnect/mcp/backend.py +405 -0
- agentconnect/mcp/server.py +382 -0
- agentconnect/mcp/session.py +373 -0
- agentconnect/mcp/tools.py +583 -0
- agentconnect/ownerfile.py +238 -0
- agentconnect/payloads.py +109 -0
- agentconnect/policy.py +1066 -0
- agentconnect/provision.py +239 -0
- agentconnect/py.typed +0 -0
- agentconnect/relay.py +405 -0
- agentconnect/runtime/__init__.py +46 -0
- agentconnect/runtime/agent.py +225 -0
- agentconnect/runtime/api.py +2043 -0
- agentconnect/runtime/engine.py +5359 -0
- agentconnect/runtime/errors.py +47 -0
- agentconnect/runtime/floor.py +649 -0
- agentconnect/runtime/groups.py +1351 -0
- agentconnect/runtime/knocks.py +1422 -0
- agentconnect/runtime/ledger.py +94 -0
- agentconnect/runtime/listing.py +1307 -0
- agentconnect/runtime/registry.py +53 -0
- agentconnect/runtime/runtime.py +158 -0
- agentconnect/runtime/sas.py +1002 -0
- agentconnect/sas_emoji.py +83 -0
- agentconnect/schemas.py +381 -0
- agentconnect/shapes.py +56 -0
- agentconnect/sockets.py +86 -0
- agentconnect/sse.py +234 -0
- agentconnect/store.py +3282 -0
- agentconnect/tasks.py +35 -0
- agentconnect/tokens.py +63 -0
- agentconnect/webhook.py +199 -0
- agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
- agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
- agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
- agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,405 @@
|
|
|
1
|
+
"""Where the MCP server's tools go: a runtime of its own, or one it attaches to.
|
|
2
|
+
|
|
3
|
+
* :class:`HostBackend` runs a :class:`~agentconnect.runtime.Runtime` in this
|
|
4
|
+
process with the delivery queue as its adapter, and calls the runtime's
|
|
5
|
+
:class:`~agentconnect.runtime.AgentApi` and the queue directly. It holds
|
|
6
|
+
the data directory's lock.
|
|
7
|
+
* :class:`AttachBackend` talks to the runtime that holds the lock (another
|
|
8
|
+
MCP session, a daemon or an embedded agent) over its local API with the
|
|
9
|
+
agent token (``api-token``), and pulls deliveries with
|
|
10
|
+
``GET /v0/deliveries``. It opens no key and no database, and never reads
|
|
11
|
+
``human-token``.
|
|
12
|
+
|
|
13
|
+
Both answer in the shapes of the local API's routes (``docs/api.md``), and
|
|
14
|
+
both turn a refused operation into :class:`ToolError` with the API's code.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import math
|
|
20
|
+
import re
|
|
21
|
+
from collections.abc import Sequence
|
|
22
|
+
from dataclasses import dataclass, replace
|
|
23
|
+
from typing import Any, Literal, Protocol
|
|
24
|
+
from urllib.parse import quote
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
from agentconnect.runtime import (
|
|
29
|
+
AgentApi,
|
|
30
|
+
Clock,
|
|
31
|
+
Config,
|
|
32
|
+
DeliveryQueue,
|
|
33
|
+
Keystore,
|
|
34
|
+
Options,
|
|
35
|
+
Runtime,
|
|
36
|
+
error_reply,
|
|
37
|
+
)
|
|
38
|
+
from agentconnect.sockets import bind_path
|
|
39
|
+
from agentconnect.tokens import API_TOKEN_FILE
|
|
40
|
+
|
|
41
|
+
POLL_SECS = 60 # one GET /v0/deliveries waits at most this long
|
|
42
|
+
HTTP_SLACK_SECS = 15.0 # on top of the poll, before the HTTP client gives up
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class ToolError(Exception):
|
|
46
|
+
"""A refused tool call: a short ``code`` (the local API's) and a message."""
|
|
47
|
+
|
|
48
|
+
def __init__(self, code: str, message: str, status: int = 400) -> None:
|
|
49
|
+
super().__init__(message)
|
|
50
|
+
self.code, self.status = code, status
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class OwnerGoneError(Exception):
|
|
54
|
+
"""The runtime this session attached to doesn't answer. ``sent``: the
|
|
55
|
+
request may have reached it before it went (so it may have happened)."""
|
|
56
|
+
|
|
57
|
+
def __init__(self, sent: bool) -> None:
|
|
58
|
+
super().__init__("the runtime that owns the data directory isn't answering")
|
|
59
|
+
self.sent = sent
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
ID_RE = re.compile(r"(?=[A-Za-z0-9_-]*[A-Za-z0-9])[A-Za-z0-9_-]+") # base64url, not all - _
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def segment(value: str) -> str:
|
|
66
|
+
"""An id as one path segment of the local API: group and message ids
|
|
67
|
+
are base64url, so anything else (`/`, `?`, `%`, `:`, `.`) is refused;
|
|
68
|
+
quoted anyway."""
|
|
69
|
+
if ID_RE.fullmatch(value) is None:
|
|
70
|
+
raise ToolError("bad_request", "not an id")
|
|
71
|
+
return quote(value, safe="")
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
@dataclass(frozen=True)
|
|
75
|
+
class Pulled:
|
|
76
|
+
"""One leased delivery: ``delivery_id`` acknowledges it."""
|
|
77
|
+
|
|
78
|
+
delivery_id: str
|
|
79
|
+
payload: dict[str, Any]
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass(frozen=True)
|
|
83
|
+
class Pulls:
|
|
84
|
+
"""One take's batch: ``lease`` acknowledges it."""
|
|
85
|
+
|
|
86
|
+
lease: str
|
|
87
|
+
items: list[Pulled]
|
|
88
|
+
more: bool
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
class Backend(Protocol):
|
|
92
|
+
"""The agent-scope operations a tool may call, and the delivery queue."""
|
|
93
|
+
|
|
94
|
+
@property
|
|
95
|
+
def mode(self) -> Literal["host", "attach"]: ...
|
|
96
|
+
|
|
97
|
+
async def create_group(self, name: str, purpose: str) -> dict[str, Any]: ...
|
|
98
|
+
async def create_invite(self, group: str, max_uses: int, ttl_secs: int) -> dict[str, Any]: ...
|
|
99
|
+
async def accept(self, url: str, display_name: str | None) -> dict[str, Any]: ...
|
|
100
|
+
async def send(
|
|
101
|
+
self, group: str, text: str, to: Sequence[str], reply_to: Sequence[str] | None
|
|
102
|
+
) -> dict[str, Any]: ...
|
|
103
|
+
async def groups(self) -> dict[str, Any]: ...
|
|
104
|
+
async def group_info(self, group: str) -> dict[str, Any]: ...
|
|
105
|
+
async def leave(self, group: str) -> dict[str, Any]: ...
|
|
106
|
+
async def events(self, after: int, group: str | None) -> dict[str, Any]: ...
|
|
107
|
+
async def knock_inspect(self, listing: str, domain_check: bool | None) -> dict[str, Any]: ...
|
|
108
|
+
async def knock(
|
|
109
|
+
self,
|
|
110
|
+
listing: str,
|
|
111
|
+
*,
|
|
112
|
+
listing_sha256: str | None,
|
|
113
|
+
display_name: str | None,
|
|
114
|
+
note: str | None,
|
|
115
|
+
domain_check: bool | None,
|
|
116
|
+
) -> dict[str, Any]: ...
|
|
117
|
+
async def knocks(self) -> dict[str, Any]: ...
|
|
118
|
+
async def listing_status(self) -> dict[str, Any]: ...
|
|
119
|
+
async def take(self, group: str | None, addressed_only: bool, wait_secs: float) -> Pulls: ...
|
|
120
|
+
async def ack(self, lease: str, delivery_ids: Sequence[str]) -> list[str]: ...
|
|
121
|
+
async def release(self, lease: str, delivery_ids: Sequence[str]) -> None: ...
|
|
122
|
+
async def close(self) -> None: ...
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _refused(e: Exception) -> ToolError | None:
|
|
126
|
+
reply = error_reply(e)
|
|
127
|
+
if reply is None:
|
|
128
|
+
return None
|
|
129
|
+
status, code, message = reply
|
|
130
|
+
return ToolError(code, message, status)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
class HostBackend:
|
|
134
|
+
"""A runtime in this process (the session that holds the lock)."""
|
|
135
|
+
|
|
136
|
+
mode: Literal["host", "attach"] = "host"
|
|
137
|
+
|
|
138
|
+
def __init__(self, runtime: Runtime, queue: DeliveryQueue) -> None:
|
|
139
|
+
self.runtime, self.queue = runtime, queue
|
|
140
|
+
self._agent: AgentApi = runtime.agent
|
|
141
|
+
|
|
142
|
+
@classmethod
|
|
143
|
+
async def start(
|
|
144
|
+
cls,
|
|
145
|
+
config: Config,
|
|
146
|
+
clock: Clock,
|
|
147
|
+
*,
|
|
148
|
+
keystore: Keystore | None = None,
|
|
149
|
+
options: Options | None = None,
|
|
150
|
+
) -> HostBackend:
|
|
151
|
+
"""Take the lock (``DataDirLockedError`` if another runtime has it),
|
|
152
|
+
open the keys and databases and start the runtime, its local API
|
|
153
|
+
included. Nothing stays open if this fails or is cancelled."""
|
|
154
|
+
queue = DeliveryQueue(clock)
|
|
155
|
+
runtime = Runtime(
|
|
156
|
+
config,
|
|
157
|
+
adapters=[queue],
|
|
158
|
+
clock=clock,
|
|
159
|
+
keystore=keystore,
|
|
160
|
+
options=replace(options or Options(), lock_owner="mcp"),
|
|
161
|
+
)
|
|
162
|
+
await runtime.start() # stops it again itself if this fails
|
|
163
|
+
return cls(runtime, queue)
|
|
164
|
+
|
|
165
|
+
async def _run(self, call: Any) -> dict[str, Any]:
|
|
166
|
+
try:
|
|
167
|
+
result: dict[str, Any] = await call
|
|
168
|
+
except Exception as e:
|
|
169
|
+
refused = _refused(e)
|
|
170
|
+
if refused is None:
|
|
171
|
+
raise
|
|
172
|
+
raise refused from None
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
async def create_group(self, name: str, purpose: str) -> dict[str, Any]:
|
|
176
|
+
return await self._run(self._agent.create_group(name, purpose))
|
|
177
|
+
|
|
178
|
+
async def create_invite(self, group: str, max_uses: int, ttl_secs: int) -> dict[str, Any]:
|
|
179
|
+
return await self._run(
|
|
180
|
+
self._agent.create_invite(group, ttl_secs=ttl_secs, max_uses=max_uses)
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
async def accept(self, url: str, display_name: str | None) -> dict[str, Any]:
|
|
184
|
+
return await self._run(self._agent.accept_invite(url, display_name))
|
|
185
|
+
|
|
186
|
+
async def send(
|
|
187
|
+
self, group: str, text: str, to: Sequence[str], reply_to: Sequence[str] | None
|
|
188
|
+
) -> dict[str, Any]:
|
|
189
|
+
return await self._run(self._agent.send(group, text=text, to=to, reply_to=reply_to))
|
|
190
|
+
|
|
191
|
+
async def groups(self) -> dict[str, Any]:
|
|
192
|
+
return await self._run(self._agent.list_groups())
|
|
193
|
+
|
|
194
|
+
async def group_info(self, group: str) -> dict[str, Any]:
|
|
195
|
+
return await self._run(self._agent.group_info(group))
|
|
196
|
+
|
|
197
|
+
async def leave(self, group: str) -> dict[str, Any]:
|
|
198
|
+
return await self._run(self._agent.leave(group))
|
|
199
|
+
|
|
200
|
+
async def events(self, after: int, group: str | None) -> dict[str, Any]:
|
|
201
|
+
return await self._run(self._agent.events(after, group))
|
|
202
|
+
|
|
203
|
+
async def knock_inspect(self, listing: str, domain_check: bool | None) -> dict[str, Any]:
|
|
204
|
+
return await self._run(self._agent.knock_inspect(listing, domain_check=domain_check))
|
|
205
|
+
|
|
206
|
+
async def knock(
|
|
207
|
+
self,
|
|
208
|
+
listing: str,
|
|
209
|
+
*,
|
|
210
|
+
listing_sha256: str | None,
|
|
211
|
+
display_name: str | None,
|
|
212
|
+
note: str | None,
|
|
213
|
+
domain_check: bool | None,
|
|
214
|
+
) -> dict[str, Any]:
|
|
215
|
+
return await self._run(
|
|
216
|
+
self._agent.knock(
|
|
217
|
+
listing,
|
|
218
|
+
listing_sha256=listing_sha256,
|
|
219
|
+
display_name=display_name,
|
|
220
|
+
note=note,
|
|
221
|
+
domain_check=domain_check,
|
|
222
|
+
)
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
async def knocks(self) -> dict[str, Any]:
|
|
226
|
+
return await self._run(self._agent.knocks())
|
|
227
|
+
|
|
228
|
+
async def listing_status(self) -> dict[str, Any]:
|
|
229
|
+
return await self._run(self._agent.listing_status())
|
|
230
|
+
|
|
231
|
+
async def take(self, group: str | None, addressed_only: bool, wait_secs: float) -> Pulls:
|
|
232
|
+
if group is not None:
|
|
233
|
+
await self.group_info(group) # an unknown group is not_found, not silence
|
|
234
|
+
batch = await self.queue.take(group, addressed_only=addressed_only, wait_secs=wait_secs)
|
|
235
|
+
items = [Pulled(str(t.delivery_id), t.payload) for t in batch.items]
|
|
236
|
+
return Pulls(batch.lease, items, batch.more)
|
|
237
|
+
|
|
238
|
+
async def ack(self, lease: str, delivery_ids: Sequence[str]) -> list[str]:
|
|
239
|
+
return [str(d) for d in self.queue.ack(lease, [int(d) for d in delivery_ids])]
|
|
240
|
+
|
|
241
|
+
async def release(self, lease: str, delivery_ids: Sequence[str]) -> None:
|
|
242
|
+
self.queue.release(lease, [int(d) for d in delivery_ids])
|
|
243
|
+
|
|
244
|
+
async def close(self) -> None:
|
|
245
|
+
"""Stop the runtime: queued sends get the bounded flush, the lock is
|
|
246
|
+
released (cancellation-safe: :meth:`Runtime.stop`)."""
|
|
247
|
+
await self.runtime.stop()
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
class AttachBackend:
|
|
251
|
+
"""A client of the runtime that holds the lock, with the agent token."""
|
|
252
|
+
|
|
253
|
+
mode: Literal["host", "attach"] = "attach"
|
|
254
|
+
|
|
255
|
+
def __init__(
|
|
256
|
+
self,
|
|
257
|
+
config: Config,
|
|
258
|
+
holder: str | None,
|
|
259
|
+
transport: httpx.AsyncBaseTransport | None = None,
|
|
260
|
+
) -> None:
|
|
261
|
+
self.holder = holder # what owns the directory: "mcp", "daemon", "embedded"
|
|
262
|
+
try:
|
|
263
|
+
token = (config.data_dir / API_TOKEN_FILE).read_text(encoding="ascii").strip()
|
|
264
|
+
except FileNotFoundError:
|
|
265
|
+
msg = f"no {API_TOKEN_FILE} in {config.data_dir}: is its runtime running?"
|
|
266
|
+
raise ToolError("not_running", msg, 503) from None
|
|
267
|
+
if transport is None:
|
|
268
|
+
transport = (
|
|
269
|
+
httpx.AsyncHTTPTransport()
|
|
270
|
+
if config.api_tcp_port
|
|
271
|
+
else httpx.AsyncHTTPTransport(uds=str(bind_path(config.socket_path)))
|
|
272
|
+
)
|
|
273
|
+
base = (
|
|
274
|
+
f"http://127.0.0.1:{config.api_tcp_port}"
|
|
275
|
+
if config.api_tcp_port
|
|
276
|
+
else "http://agentconnectd"
|
|
277
|
+
)
|
|
278
|
+
self._http = httpx.AsyncClient(
|
|
279
|
+
transport=transport,
|
|
280
|
+
base_url=base,
|
|
281
|
+
timeout=POLL_SECS + HTTP_SLACK_SECS,
|
|
282
|
+
trust_env=False,
|
|
283
|
+
headers={"authorization": f"Bearer {token}"},
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
async def _request(
|
|
287
|
+
self, method: str, path: str, body: dict[str, Any] | None = None, **params: Any
|
|
288
|
+
) -> dict[str, Any]:
|
|
289
|
+
try:
|
|
290
|
+
resp = await self._http.request(method, path, json=body, params=params or None)
|
|
291
|
+
except (httpx.ConnectError, httpx.ConnectTimeout) as e:
|
|
292
|
+
raise OwnerGoneError(sent=False) from e
|
|
293
|
+
except httpx.TransportError as e:
|
|
294
|
+
raise OwnerGoneError(sent=True) from e
|
|
295
|
+
try:
|
|
296
|
+
data = resp.json()
|
|
297
|
+
except ValueError:
|
|
298
|
+
data = {}
|
|
299
|
+
if not isinstance(data, dict):
|
|
300
|
+
data = {}
|
|
301
|
+
if resp.status_code != 200: # noqa: PLR2004
|
|
302
|
+
code = str(data.get("code", "error"))
|
|
303
|
+
raise ToolError(code, str(data.get("message", "")), resp.status_code)
|
|
304
|
+
return data
|
|
305
|
+
|
|
306
|
+
async def create_group(self, name: str, purpose: str) -> dict[str, Any]:
|
|
307
|
+
return await self._request("POST", "/v0/groups", {"name": name, "purpose": purpose})
|
|
308
|
+
|
|
309
|
+
async def create_invite(self, group: str, max_uses: int, ttl_secs: int) -> dict[str, Any]:
|
|
310
|
+
body = {"max_uses": max_uses, "ttl_secs": ttl_secs}
|
|
311
|
+
return await self._request("POST", f"/v0/groups/{segment(group)}/invites", body)
|
|
312
|
+
|
|
313
|
+
async def accept(self, url: str, display_name: str | None) -> dict[str, Any]:
|
|
314
|
+
body: dict[str, Any] = {"url": url}
|
|
315
|
+
if display_name is not None:
|
|
316
|
+
body["display_name"] = display_name
|
|
317
|
+
return await self._request("POST", "/v0/invites/accept", body)
|
|
318
|
+
|
|
319
|
+
async def send(
|
|
320
|
+
self, group: str, text: str, to: Sequence[str], reply_to: Sequence[str] | None
|
|
321
|
+
) -> dict[str, Any]:
|
|
322
|
+
body: dict[str, Any] = {"text": text, "to": list(to)}
|
|
323
|
+
if reply_to is not None:
|
|
324
|
+
body["reply_to"] = list(reply_to)
|
|
325
|
+
return await self._request("POST", f"/v0/groups/{segment(group)}/messages", body)
|
|
326
|
+
|
|
327
|
+
async def groups(self) -> dict[str, Any]:
|
|
328
|
+
return await self._request("GET", "/v0/groups")
|
|
329
|
+
|
|
330
|
+
async def group_info(self, group: str) -> dict[str, Any]:
|
|
331
|
+
return await self._request("GET", f"/v0/groups/{segment(group)}")
|
|
332
|
+
|
|
333
|
+
async def leave(self, group: str) -> dict[str, Any]:
|
|
334
|
+
return await self._request("POST", f"/v0/groups/{segment(group)}/leave")
|
|
335
|
+
|
|
336
|
+
async def events(self, after: int, group: str | None) -> dict[str, Any]:
|
|
337
|
+
params: dict[str, Any] = {"after": after}
|
|
338
|
+
if group is not None:
|
|
339
|
+
params["group"] = group
|
|
340
|
+
return await self._request("GET", "/v0/events", **params)
|
|
341
|
+
|
|
342
|
+
async def knock_inspect(self, listing: str, domain_check: bool | None) -> dict[str, Any]:
|
|
343
|
+
body: dict[str, Any] = {"listing": listing}
|
|
344
|
+
if domain_check is not None:
|
|
345
|
+
body["domain_check"] = domain_check
|
|
346
|
+
return await self._request("POST", "/v0/knocks/inspect", body)
|
|
347
|
+
|
|
348
|
+
async def knock(
|
|
349
|
+
self,
|
|
350
|
+
listing: str,
|
|
351
|
+
*,
|
|
352
|
+
listing_sha256: str | None,
|
|
353
|
+
display_name: str | None,
|
|
354
|
+
note: str | None,
|
|
355
|
+
domain_check: bool | None,
|
|
356
|
+
) -> dict[str, Any]:
|
|
357
|
+
fields = {
|
|
358
|
+
"listing_sha256": listing_sha256,
|
|
359
|
+
"display_name": display_name,
|
|
360
|
+
"note": note,
|
|
361
|
+
"domain_check": domain_check,
|
|
362
|
+
}
|
|
363
|
+
body = {"listing": listing} | {k: v for k, v in fields.items() if v is not None}
|
|
364
|
+
return await self._request("POST", "/v0/knocks", body)
|
|
365
|
+
|
|
366
|
+
async def knocks(self) -> dict[str, Any]:
|
|
367
|
+
return await self._request("GET", "/v0/knocks")
|
|
368
|
+
|
|
369
|
+
async def listing_status(self) -> dict[str, Any]:
|
|
370
|
+
return await self._request("GET", "/v0/listing")
|
|
371
|
+
|
|
372
|
+
async def take(self, group: str | None, addressed_only: bool, wait_secs: float) -> Pulls:
|
|
373
|
+
"""Long-poll ``GET /v0/deliveries`` in steps of :data:`POLL_SECS`
|
|
374
|
+
until something comes or ``wait_secs`` is used up."""
|
|
375
|
+
if group is not None:
|
|
376
|
+
await self.group_info(group)
|
|
377
|
+
steps = max(1, math.ceil(wait_secs / POLL_SECS))
|
|
378
|
+
left = wait_secs
|
|
379
|
+
for _ in range(steps):
|
|
380
|
+
wait = min(POLL_SECS, math.ceil(left))
|
|
381
|
+
left -= wait
|
|
382
|
+
params: dict[str, Any] = {"addressed_only": int(addressed_only), "timeout": wait}
|
|
383
|
+
if group is not None:
|
|
384
|
+
params["group"] = group
|
|
385
|
+
data = await self._request("GET", "/v0/deliveries", **params)
|
|
386
|
+
rows = data.get("deliveries") or []
|
|
387
|
+
if rows:
|
|
388
|
+
pulled = [Pulled(str(r["delivery_id"]), dict(r["message"])) for r in rows]
|
|
389
|
+
return Pulls(str(data.get("lease", "")), pulled, bool(data.get("more")))
|
|
390
|
+
return Pulls("", [], more=False)
|
|
391
|
+
|
|
392
|
+
async def ack(self, lease: str, delivery_ids: Sequence[str]) -> list[str]:
|
|
393
|
+
if not delivery_ids:
|
|
394
|
+
return []
|
|
395
|
+
body = {"lease": lease, "ids": list(delivery_ids)}
|
|
396
|
+
refused = (await self._request("POST", "/v0/deliveries/ack", body)).get("refused") or []
|
|
397
|
+
return [d for d in delivery_ids if d not in refused]
|
|
398
|
+
|
|
399
|
+
async def release(self, lease: str, delivery_ids: Sequence[str]) -> None:
|
|
400
|
+
if delivery_ids:
|
|
401
|
+
body = {"lease": lease, "ids": list(delivery_ids)}
|
|
402
|
+
await self._request("POST", "/v0/deliveries/release", body)
|
|
403
|
+
|
|
404
|
+
async def close(self) -> None:
|
|
405
|
+
await self._http.aclose()
|