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.
Files changed (64) hide show
  1. agentconnect/__init__.py +3 -0
  2. agentconnect/a2a_adapter/__init__.py +6 -0
  3. agentconnect/a2a_adapter/adapter.py +565 -0
  4. agentconnect/a2a_adapter/cards.py +149 -0
  5. agentconnect/a2a_adapter/convert.py +157 -0
  6. agentconnect/a2a_adapter/facade.py +560 -0
  7. agentconnect/a2a_adapter/seen.py +49 -0
  8. agentconnect/a2a_adapter/serve.py +67 -0
  9. agentconnect/adapter.py +319 -0
  10. agentconnect/adapters.py +52 -0
  11. agentconnect/apiclient.py +51 -0
  12. agentconnect/backoff.py +42 -0
  13. agentconnect/callback.py +314 -0
  14. agentconnect/cli.py +2324 -0
  15. agentconnect/cli_json.py +470 -0
  16. agentconnect/cli_support.py +444 -0
  17. agentconnect/clock.py +161 -0
  18. agentconnect/config.py +1213 -0
  19. agentconnect/daemon/__init__.py +41 -0
  20. agentconnect/deliveries.py +295 -0
  21. agentconnect/directory_client.py +255 -0
  22. agentconnect/failpoints.py +99 -0
  23. agentconnect/identity.py +111 -0
  24. agentconnect/isolation.py +345 -0
  25. agentconnect/keystore.py +279 -0
  26. agentconnect/limits.py +457 -0
  27. agentconnect/log.py +143 -0
  28. agentconnect/mcp/__init__.py +30 -0
  29. agentconnect/mcp/backend.py +405 -0
  30. agentconnect/mcp/server.py +382 -0
  31. agentconnect/mcp/session.py +373 -0
  32. agentconnect/mcp/tools.py +583 -0
  33. agentconnect/ownerfile.py +238 -0
  34. agentconnect/payloads.py +109 -0
  35. agentconnect/policy.py +1066 -0
  36. agentconnect/provision.py +239 -0
  37. agentconnect/py.typed +0 -0
  38. agentconnect/relay.py +405 -0
  39. agentconnect/runtime/__init__.py +46 -0
  40. agentconnect/runtime/agent.py +225 -0
  41. agentconnect/runtime/api.py +2043 -0
  42. agentconnect/runtime/engine.py +5359 -0
  43. agentconnect/runtime/errors.py +47 -0
  44. agentconnect/runtime/floor.py +649 -0
  45. agentconnect/runtime/groups.py +1351 -0
  46. agentconnect/runtime/knocks.py +1422 -0
  47. agentconnect/runtime/ledger.py +94 -0
  48. agentconnect/runtime/listing.py +1307 -0
  49. agentconnect/runtime/registry.py +53 -0
  50. agentconnect/runtime/runtime.py +158 -0
  51. agentconnect/runtime/sas.py +1002 -0
  52. agentconnect/sas_emoji.py +83 -0
  53. agentconnect/schemas.py +381 -0
  54. agentconnect/shapes.py +56 -0
  55. agentconnect/sockets.py +86 -0
  56. agentconnect/sse.py +234 -0
  57. agentconnect/store.py +3282 -0
  58. agentconnect/tasks.py +35 -0
  59. agentconnect/tokens.py +63 -0
  60. agentconnect/webhook.py +199 -0
  61. agentconnect_cli-0.1.0a1.dist-info/METADATA +103 -0
  62. agentconnect_cli-0.1.0a1.dist-info/RECORD +64 -0
  63. agentconnect_cli-0.1.0a1.dist-info/WHEEL +4 -0
  64. agentconnect_cli-0.1.0a1.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,444 @@
1
+ """Shared plumbing for the `agentconnect` commands: exit codes, error mapping,
2
+ output helpers and daemon-API conveniences. Protocol logic stays in the
3
+ daemon; nothing here decides anything about a group."""
4
+
5
+ from __future__ import annotations
6
+
7
+ import json
8
+ import re
9
+ import time
10
+ import unicodedata
11
+ from collections.abc import Callable, Generator, Iterable, Iterator
12
+ from contextlib import contextmanager
13
+ from dataclasses import dataclass
14
+ from pathlib import Path
15
+ from typing import Any, override
16
+
17
+ import typer
18
+ from typer.core import TyperCommand
19
+
20
+ from agentconnect.apiclient import ApiClient, ApiClientError
21
+ from agentconnect.config import Config, ConfigError, load_config
22
+ from agentconnect.sas_emoji import describe_emoji, render_emoji
23
+
24
+ # Exit codes (documented in docs/cli.md and `agentconnect --help`).
25
+ EXIT_OK = 0
26
+ EXIT_FAILED = 1 # the daemon or relay reported an error
27
+ EXIT_USAGE = 2 # bad arguments, bad invite URL, bad configuration
28
+ EXIT_REFUSED = 3 # refused: declined at a prompt, wrong token, wrong relay
29
+ EXIT_INTERCEPTED = 4 # invite already used or revoked: possible interception
30
+ EXIT_UNREACHABLE = 5 # the daemon is not running
31
+ EXIT_MISMATCH = 6 # the human reported different short codes; the group was left
32
+ EXIT_UNVERIFIED = 7 # a listing failed verification: a suspect directory, not "no answer"
33
+
34
+ # The same codes with their meaning, for the docs (scripts/gen_reference.py).
35
+ EXIT_CODES: tuple[tuple[int, str, str], ...] = (
36
+ (EXIT_OK, "EXIT_OK", "Success."),
37
+ (
38
+ EXIT_FAILED,
39
+ "EXIT_FAILED",
40
+ "Failed: the daemon or relay reported an error (not found, relay down, ...).",
41
+ ),
42
+ (
43
+ EXIT_USAGE,
44
+ "EXIT_USAGE",
45
+ "Usage: bad arguments, malformed invite URL, bad configuration, an unreadable or "
46
+ "invalid card or token file.",
47
+ ),
48
+ (
49
+ EXIT_REFUSED,
50
+ "EXIT_REFUSED",
51
+ "Refused: declined at a prompt, human-only operation, the group changed while "
52
+ "comparing, invite for a different relay (nothing is sent), a listing token for "
53
+ "another identity or key.",
54
+ ),
55
+ (
56
+ EXIT_INTERCEPTED,
57
+ "EXIT_INTERCEPTED",
58
+ "Interception warning: the invite was already used or revoked.",
59
+ ),
60
+ (EXIT_UNREACHABLE, "EXIT_UNREACHABLE", "The daemon is not running or unreachable."),
61
+ (
62
+ EXIT_MISMATCH,
63
+ "EXIT_MISMATCH",
64
+ "`verify` mismatch: you reported different codes and left the group.",
65
+ ),
66
+ (
67
+ EXIT_UNVERIFIED,
68
+ "EXIT_UNVERIFIED",
69
+ "`knock`: the listing the directory served failed verification (signature, "
70
+ "canonical form, the token under the pinned keys, another name's listing, or "
71
+ "older than one seen before). The directory may be lying; nothing was sent.",
72
+ ),
73
+ )
74
+
75
+ EXIT_CODES_HELP = (
76
+ "Exit codes: 0 ok, 1 failed, 2 usage or bad invite, 3 refused (declined, "
77
+ "wrong token, other relay), 4 invite already used or revoked (possible "
78
+ "interception), 5 daemon not running, 6 short-code mismatch reported, 7 a "
79
+ "listing failed verification."
80
+ )
81
+
82
+ HUMAN_ONLY_HELP = (
83
+ "HUMAN ONLY: uses the human token. Never give an agent the human token or "
84
+ "run this for the human; it exists so a person can make this decision."
85
+ )
86
+ LOCAL_ONLY_HELP = (
87
+ "LOCAL ONLY: works on a stopped runtime's data directory, with no API and "
88
+ "no token. A person runs it; no agent tool offers it."
89
+ )
90
+
91
+ INTERCEPTION_WARNING = (
92
+ "This invite was already used or revoked. If you didn't expect this, the "
93
+ "link may have been intercepted. Tell the sender."
94
+ )
95
+ SHARE_REMINDER = (
96
+ "Share this over a channel you trust. After joining, compare the short code "
97
+ "with the other members over a different channel (a call or in person)."
98
+ )
99
+ REPLICA_REMINDER = (
100
+ "Accept it on the replica's host only (`agentconnect accept <url>` there): only an "
101
+ "instance of your own identity can join through it, and with --instance only that one. "
102
+ "Without --instance, approve only the request whose instance is the one `identity "
103
+ "import` printed. The other members will see a new instance of yours join."
104
+ )
105
+ COMPARE_REMINDER = (
106
+ "Compare the short code with each other member over a different channel (a call "
107
+ "or in person): both of you run `agentconnect verify <group> <member>` at the same time."
108
+ )
109
+
110
+ # SPEC 4.3: the maximum invite lifetime.
111
+ MAX_TTL_SECS = 7 * 86_400
112
+ _TTL_UNITS = {"s": 1, "m": 60, "h": 3600, "d": 86_400}
113
+
114
+
115
+ # Publish refusals that are about the files or the token the person gave:
116
+ # fix the input (usage) or get the right token (refused).
117
+ LISTING_EXITS = {
118
+ "unknown_directory": EXIT_USAGE,
119
+ "bad_card": EXIT_USAGE,
120
+ "bad_token": EXIT_USAGE,
121
+ "token_not_for_identity": EXIT_REFUSED,
122
+ "unknown_key": EXIT_REFUSED,
123
+ }
124
+
125
+
126
+ # Knock refusals: a listing that fails verification is a suspect directory
127
+ # (unverified), not a failure to answer; the rest are about the request.
128
+ KNOCK_EXITS = {
129
+ "listing_invalid": EXIT_UNVERIFIED,
130
+ "listing_mismatch": EXIT_UNVERIFIED,
131
+ "listing_rolled_back": EXIT_UNVERIFIED,
132
+ "token_invalid": EXIT_UNVERIFIED,
133
+ "self_knock": EXIT_REFUSED,
134
+ "bad_listing_ref": EXIT_USAGE,
135
+ "unknown_directory": EXIT_USAGE,
136
+ }
137
+ NO_ANSWER = (
138
+ "No answer by {time}. The owner may be offline or busy, or chose not to answer; "
139
+ "nothing tells them apart."
140
+ )
141
+
142
+
143
+ class CliError(Exception):
144
+ """A failure to report: a machine-readable code, a message, an exit code."""
145
+
146
+ def __init__(self, code: str, message: str, exit_code: int = EXIT_FAILED) -> None:
147
+ super().__init__(message)
148
+ self.code, self.message, self.exit_code = code, message, exit_code
149
+
150
+
151
+ def clean(text: object) -> str:
152
+ """Peer-supplied text made safe for a terminal: control, format (bidi)
153
+ and surrogate characters become ``?``. Names and purposes are attacker
154
+ controlled (SPEC 4.2)."""
155
+ return "".join("?" if unicodedata.category(c)[0] == "C" else c for c in str(text))
156
+
157
+
158
+ def emit_json(value: Any) -> None:
159
+ typer.echo(json.dumps(value, ensure_ascii=False))
160
+
161
+
162
+ def emit_error(error: CliError, as_json: bool) -> None:
163
+ if as_json:
164
+ emit_json({"error": {"code": error.code, "message": error.message}})
165
+ else:
166
+ typer.echo(error.message, err=True)
167
+
168
+
169
+ def map_api_error(e: ApiClientError) -> CliError: # noqa: PLR0911
170
+ """A daemon error as a readable message and an exit code."""
171
+ if e.code == "not_running":
172
+ return CliError(
173
+ "daemon_unreachable",
174
+ "cannot reach the daemon; start it with `agentconnect daemon run`",
175
+ EXIT_UNREACHABLE,
176
+ )
177
+ if e.code == "auth_failed":
178
+ return CliError("auth_failed", "the daemon rejected the API token", EXIT_REFUSED)
179
+ if e.code == "human_only":
180
+ return CliError(
181
+ "human_only",
182
+ "this operation needs the human token; it is for a person, not an agent",
183
+ EXIT_REFUSED,
184
+ )
185
+ if e.code in ("forbidden", "not_permitted"):
186
+ # not_permitted: the group policy doesn't let this identity (SPEC 9.2).
187
+ return CliError(e.code, e.message or "not allowed", EXIT_REFUSED)
188
+ if e.code == "peer_refused":
189
+ return CliError(
190
+ "peer_refused",
191
+ "the daemon refused this OS user (separate-user mode); human commands run "
192
+ "as the daemon's user, see docs/isolation.md",
193
+ EXIT_REFUSED,
194
+ )
195
+ if e.code == "invite_not_open":
196
+ return CliError("invite_not_open", INTERCEPTION_WARNING, EXIT_INTERCEPTED)
197
+ if e.code == "wrong_relay":
198
+ return CliError(
199
+ "wrong_relay",
200
+ "This invite is for a different relay than this daemon uses. "
201
+ "AgentConnect v0 supports one relay, so nothing was sent. "
202
+ "Ask the sender for a link on your relay.",
203
+ EXIT_REFUSED,
204
+ )
205
+ if e.code == "bad_request":
206
+ return CliError("bad_request", e.message or "invalid request", EXIT_USAGE)
207
+ if e.code in LISTING_EXITS:
208
+ return CliError(e.code, e.message or e.code, LISTING_EXITS[e.code])
209
+ friendly = {
210
+ "not_found": "not found",
211
+ "expired": "expired",
212
+ "stale_epoch": "the group changed; look at the new short code and try again",
213
+ "rate_limited": "rate limited; try again shortly",
214
+ "relay": "the relay refused the request",
215
+ }
216
+ detail = e.message or friendly.get(e.code, e.code)
217
+ return CliError(e.code, detail, EXIT_FAILED)
218
+
219
+
220
+ @contextmanager
221
+ def guard(as_json: bool) -> Generator[None]:
222
+ """Turn failures into a message (or JSON error) and the right exit code."""
223
+ try:
224
+ yield
225
+ except CliError as e:
226
+ emit_error(e, as_json)
227
+ raise typer.Exit(e.exit_code) from None
228
+ except ApiClientError as e:
229
+ err = map_api_error(e)
230
+ emit_error(err, as_json)
231
+ raise typer.Exit(err.exit_code) from None
232
+ except typer.Abort:
233
+ emit_error(CliError("aborted", "aborted; nothing was changed", EXIT_REFUSED), as_json)
234
+ raise typer.Exit(EXIT_REFUSED) from None
235
+
236
+
237
+ def options_first(params: Iterable[Any], args: list[str]) -> list[str]:
238
+ """``args`` reordered as the known options (with their values), then
239
+ ``--``, then every other token in its original order.
240
+
241
+ Group, invite and request ids are random base64url, so they can start
242
+ with ``-``; so can a message text. The parser would take such a token
243
+ for an option. Only exact names of the command's own options count as
244
+ options here; anything else is a positional, and ``--`` makes the
245
+ parser read it as one. Everything after a ``--`` the user typed stays
246
+ positional."""
247
+ arity: dict[str, int] = {}
248
+ for p in params:
249
+ if getattr(p, "param_type_name", None) != "option":
250
+ continue
251
+ n = 0 if getattr(p, "is_flag", False) or getattr(p, "count", False) else p.nargs
252
+ for name in (*p.opts, *p.secondary_opts):
253
+ arity[name] = n
254
+ options: list[str] = []
255
+ positionals: list[str] = []
256
+ i = 0
257
+ while i < len(args):
258
+ arg = args[i]
259
+ if arg == "--":
260
+ positionals.extend(args[i + 1 :])
261
+ break
262
+ name, attached = arg.split("=", 1)[0], "=" in arg
263
+ if arg.startswith("--") and name in arity:
264
+ take = 0 if attached else arity[name]
265
+ elif arg in arity:
266
+ take = arity[arg]
267
+ else:
268
+ positionals.append(arg)
269
+ i += 1
270
+ continue
271
+ if i + take >= len(args) and take:
272
+ raise MissingValueError(name)
273
+ options.extend(args[i : i + 1 + take])
274
+ i += 1 + take
275
+ return [*options, "--", *positionals] if positionals else options
276
+
277
+
278
+ class MissingValueError(ValueError):
279
+ """An option that takes a value came last, without one."""
280
+
281
+
282
+ class IdArgsCommand(TyperCommand):
283
+ """A command whose positional arguments may start with ``-`` (ids are
284
+ random base64url). See :func:`options_first`."""
285
+
286
+ @override
287
+ def parse_args(self, ctx: Any, args: list[str]) -> list[str]:
288
+ try:
289
+ ordered = options_first(self.get_params(ctx), args)
290
+ except MissingValueError as e:
291
+ msg = f"option {e.args[0]!r} requires an argument"
292
+ raise typer.BadParameter(msg, ctx=ctx) from None
293
+ return super().parse_args(ctx, ordered)
294
+
295
+
296
+ def load(path: Path | None, *, need_relay: bool = False) -> Config:
297
+ try:
298
+ cfg = load_config(path)
299
+ if need_relay:
300
+ cfg.require_relay()
301
+ return cfg
302
+ except ConfigError as e:
303
+ raise CliError("config", f"config error: {e}", EXIT_USAGE) from None
304
+
305
+
306
+ def connect(path: Path | None, *, human: bool = False) -> ApiClient:
307
+ """A client for the daemon. ``human=True`` reads the human token, only
308
+ for the operations SPEC 6.2 and 11 reserve to a person."""
309
+ return ApiClient(load(path), human=human)
310
+
311
+
312
+ def pause(seconds: float) -> None:
313
+ """Wait between polls (a seam: tests replace it)."""
314
+ time.sleep(seconds)
315
+
316
+
317
+ def parse_ttl(text: str) -> int:
318
+ """``90``, ``30m``, ``24h`` or ``7d`` as seconds."""
319
+ m = re.fullmatch(r"(\d+)([smhd]?)", text.strip().lower())
320
+ if m is None:
321
+ msg = f"bad --ttl {text!r}; use e.g. 30m, 24h or 7d"
322
+ raise CliError("bad_ttl", msg, EXIT_USAGE)
323
+ secs = int(m.group(1)) * _TTL_UNITS[m.group(2) or "s"]
324
+ if not 0 < secs <= MAX_TTL_SECS:
325
+ raise CliError("bad_ttl", "--ttl must be between 1 second and 7 days", EXIT_USAGE)
326
+ return secs
327
+
328
+
329
+ def resolve_group(api: ApiClient, ref: str) -> str:
330
+ """A group id from a full id, a unique id prefix, or an exact name."""
331
+ groups = api.request("GET", "/v0/groups")["groups"]
332
+ for g in groups:
333
+ if g["group_id"] == ref:
334
+ return str(g["group_id"])
335
+ found = [g for g in groups if g["group_id"].startswith(ref)] or [
336
+ g for g in groups if g["name"] == ref
337
+ ]
338
+ if len(found) == 1:
339
+ return str(found[0]["group_id"])
340
+ if not found:
341
+ raise CliError("not_found", f"no group matches {ref!r}", EXIT_FAILED)
342
+ raise CliError("ambiguous", f"{ref!r} matches {len(found)} groups; use more characters")
343
+
344
+
345
+ def events_after(
346
+ api: ApiClient, after: int = 0, on_gap: Callable[[int], None] | None = None
347
+ ) -> Iterator[dict[str, Any]]:
348
+ """Every event with ``seq > after``, in order. ``on_gap`` hears the
349
+ lowest ``seq`` left when retention removed some of them."""
350
+ while True:
351
+ reply = api.request("GET", f"/v0/events?after={after}")
352
+ if reply.get("gap") and on_gap is not None:
353
+ on_gap(int(reply.get("oldest_available", 0)))
354
+ on_gap = None
355
+ page = reply["events"]
356
+ yield from page
357
+ if not page:
358
+ return
359
+ after = int(page[-1]["seq"])
360
+
361
+
362
+ @dataclass(frozen=True)
363
+ class Sas:
364
+ decimal: str
365
+ emoji: str
366
+ names: str
367
+ indices: list[int]
368
+
369
+ @classmethod
370
+ def of(cls, raw: dict[str, Any]) -> Sas:
371
+ idx = [int(i) for i in raw["emoji_indices"]]
372
+ return cls(str(raw["decimal"]), render_emoji(idx), describe_emoji(idx), idx)
373
+
374
+ def as_json(self) -> dict[str, Any]:
375
+ return {"decimal": self.decimal, "emoji": self.emoji, "emoji_names": self.names.split(", ")}
376
+
377
+ def lines(self) -> list[str]:
378
+ return [f" {self.emoji}", f" ({self.names})", f" {self.decimal}"]
379
+
380
+
381
+ def unverified_hint(info: dict[str, Any]) -> str | None:
382
+ """The agent-facing nudge when other members exist and aren't all verified."""
383
+ trust = info["trust_state"]
384
+ if trust["members"] > 1 and trust["self"] != "verified" and info["status"] == "active":
385
+ return (
386
+ "ask your human to compare the short code with each other member: "
387
+ f"`agentconnect verify {info['group_id']} <member>` on both sides"
388
+ )
389
+ return None
390
+
391
+
392
+ def pending_for(api: ApiClient, group_id: str) -> list[dict[str, Any]]:
393
+ return [
394
+ a for a in api.request("GET", "/v0/approvals")["approvals"] if a["group_id"] == group_id
395
+ ]
396
+
397
+
398
+ def group_view(api: ApiClient, group_id: str) -> dict[str, Any]:
399
+ """The stable JSON shape of one group (docs/cli.md, ``groups show``)."""
400
+ info = api.request("GET", f"/v0/groups/{group_id}")
401
+ trust = info["trust_state"]
402
+ others = max(trust["members"] - 1, 0)
403
+ return {
404
+ "group_id": info["group_id"],
405
+ "name": info["name"],
406
+ "purpose": info["purpose"],
407
+ "status": info["status"],
408
+ "epoch": info["epoch"],
409
+ "out_of_sync": info["out_of_sync"],
410
+ "out_of_sync_reason": info["out_of_sync_reason"],
411
+ "require_verified": bool(info["policy"].get("require_verified")),
412
+ "trust_state": trust,
413
+ "verified_summary": f"{trust['verified_members']} of {others} verified",
414
+ "leaving": info.get("leaving"),
415
+ "policy_summary": info["policy_summary"],
416
+ "floor": info["floor"],
417
+ "pending_leaves": info["pending_leaves"],
418
+ "members": [
419
+ {
420
+ "identity": m["identity"],
421
+ "fingerprint": m["fingerprint"],
422
+ "display_name": m["display_name"],
423
+ "display_name_verified": m["display_name_verified"],
424
+ "role": m["role"],
425
+ "verified": m["verified"],
426
+ "self": m["self"],
427
+ "instances": [
428
+ {
429
+ "instance_id": i["instance_id"],
430
+ "leaf_index": i["leaf_index"],
431
+ "joined_epoch": i["joined_epoch"],
432
+ "self": bool(i.get("self")),
433
+ "new_since_verified": None
434
+ if m["instances_since_verified"] is None
435
+ else i["instance_id"] in m["instances_since_verified"],
436
+ }
437
+ for i in m["instances"]
438
+ ],
439
+ }
440
+ for m in info["members"]
441
+ ],
442
+ "pending_approvals": len(pending_for(api, group_id)),
443
+ "hint": unverified_hint(info),
444
+ }
agentconnect/clock.py ADDED
@@ -0,0 +1,161 @@
1
+ """Injectable time source.
2
+
3
+ Daemon code must not call ``datetime.now()``, ``time.time()`` or
4
+ ``asyncio.sleep()`` directly: it takes a :class:`Clock` so tests can drive
5
+ time with :class:`FakeClock` instead of sleeping. Mirrors
6
+ ``agentconnect_core::clock`` on the Rust side.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import threading
13
+ from collections.abc import Callable
14
+ from datetime import UTC, datetime, timedelta
15
+ from typing import Any, Protocol, runtime_checkable
16
+
17
+ # 2025-01-01T00:00:00Z: a fixed, recognisable default start for fake clocks.
18
+ DEFAULT_FAKE_START = datetime(2025, 1, 1, tzinfo=UTC)
19
+
20
+ _EPOCH = datetime(1970, 1, 1, tzinfo=UTC)
21
+
22
+
23
+ @runtime_checkable
24
+ class Clock(Protocol):
25
+ """A source of wall-clock time and timers."""
26
+
27
+ def now(self) -> datetime:
28
+ """The current time as a timezone-aware UTC datetime."""
29
+ ...
30
+
31
+ def unix_millis(self) -> int:
32
+ """Whole milliseconds since the Unix epoch."""
33
+ ...
34
+
35
+ async def sleep(self, seconds: float, *, deadline: bool = False) -> None:
36
+ """Wait until ``seconds`` of this clock's time have passed.
37
+ ``deadline=True`` marks a timer that bounds other work (a handler
38
+ timeout) rather than a pause before the next attempt; a fake clock
39
+ doesn't count it as a sleeper."""
40
+ ...
41
+
42
+ async def sleep_until(self, unix: float, *, deadline: bool = False) -> None:
43
+ """Wait until this clock reads ``unix`` (Unix seconds). A timer set
44
+ against the clock itself, so a clock that moves before the timer
45
+ starts (a fake one moved by a test) can't delay it."""
46
+ ...
47
+
48
+
49
+ def _millis(moment: datetime) -> int:
50
+ return (moment - _EPOCH) // timedelta(milliseconds=1)
51
+
52
+
53
+ def unix_seconds(clock: Clock) -> int:
54
+ """Whole seconds since the Unix epoch on ``clock``."""
55
+ return clock.unix_millis() // 1000
56
+
57
+
58
+ class SystemClock:
59
+ """The real clock."""
60
+
61
+ def now(self) -> datetime:
62
+ return datetime.now(UTC)
63
+
64
+ def unix_millis(self) -> int:
65
+ return _millis(self.now())
66
+
67
+ async def sleep(self, seconds: float, *, deadline: bool = False) -> None:
68
+ await asyncio.sleep(max(seconds, 0))
69
+
70
+ async def sleep_until(self, unix: float, *, deadline: bool = False) -> None:
71
+ await asyncio.sleep(max(unix - self.unix_millis() / 1000, 0))
72
+
73
+
74
+ def _wake(fut: asyncio.Future[Any]) -> None:
75
+ if not fut.done():
76
+ fut.set_result(None)
77
+
78
+
79
+ class FakeClock:
80
+ """A manually driven, thread-safe clock for tests.
81
+
82
+ Time only moves through :meth:`advance` and :meth:`set`. :meth:`sleep`
83
+ returns once the clock has been moved past its deadline, so timers
84
+ (reconnect backoff, retries) run without real waiting.
85
+ """
86
+
87
+ def __init__(self, start: datetime = DEFAULT_FAKE_START) -> None:
88
+ self._lock = threading.Lock()
89
+ self._now = self._checked(start)
90
+ self._sleepers: list[tuple[datetime, asyncio.AbstractEventLoop, asyncio.Future[Any]]] = []
91
+ self._deadlines: set[int] = set() # ids of the futures of deadline sleeps
92
+
93
+ @staticmethod
94
+ def _checked(moment: datetime) -> datetime:
95
+ if moment.tzinfo is None:
96
+ msg = "FakeClock needs a timezone-aware datetime"
97
+ raise ValueError(msg)
98
+ return moment.astimezone(UTC)
99
+
100
+ def now(self) -> datetime:
101
+ with self._lock:
102
+ return self._now
103
+
104
+ def unix_millis(self) -> int:
105
+ return _millis(self.now())
106
+
107
+ @property
108
+ def sleepers(self) -> int:
109
+ """How many :meth:`sleep` calls are waiting, not counting deadlines
110
+ (timers that bound other work, such as a handler timeout): tests
111
+ read this as "the runtime is waiting to retry"."""
112
+ with self._lock:
113
+ return len([s for s in self._sleepers if id(s[2]) not in self._deadlines])
114
+
115
+ async def sleep(self, seconds: float, *, deadline: bool = False) -> None:
116
+ await self._sleep(lambda now: now + timedelta(seconds=max(seconds, 0)), deadline)
117
+
118
+ async def sleep_until(self, unix: float, *, deadline: bool = False) -> None:
119
+ await self._sleep(lambda _now: _EPOCH + timedelta(seconds=unix), deadline)
120
+
121
+ async def _sleep(self, until: Callable[[datetime], datetime], deadline: bool) -> None:
122
+ loop = asyncio.get_running_loop()
123
+ fut = loop.create_future()
124
+ with self._lock:
125
+ entry = (until(self._now), loop, fut)
126
+ if entry[0] <= self._now:
127
+ fut.set_result(None)
128
+ else:
129
+ self._sleepers.append(entry)
130
+ if deadline:
131
+ self._deadlines.add(id(fut))
132
+ try:
133
+ await fut
134
+ finally:
135
+ with self._lock:
136
+ if entry in self._sleepers:
137
+ self._sleepers.remove(entry)
138
+ self._deadlines.discard(id(fut))
139
+
140
+ def _release_due(self) -> None:
141
+ # Called with the lock held.
142
+ due = [s for s in self._sleepers if s[0] <= self._now]
143
+ for s in due:
144
+ self._sleepers.remove(s)
145
+ _, loop, fut = s
146
+ if not loop.is_closed():
147
+ loop.call_soon_threadsafe(_wake, fut)
148
+
149
+ def advance(self, by: timedelta | float) -> None:
150
+ """Move forward by a timedelta or a number of seconds."""
151
+ delta = by if isinstance(by, timedelta) else timedelta(seconds=by)
152
+ with self._lock:
153
+ self._now += delta
154
+ self._release_due()
155
+
156
+ def set(self, to: datetime) -> None:
157
+ """Jump to an absolute time (may move backwards)."""
158
+ moment = self._checked(to)
159
+ with self._lock:
160
+ self._now = moment
161
+ self._release_due()