echoact 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 (80) hide show
  1. echoact/__init__.py +3 -0
  2. echoact/__main__.py +117 -0
  3. echoact/app.py +315 -0
  4. echoact/audio/__init__.py +0 -0
  5. echoact/audio/devices.py +192 -0
  6. echoact/audio/player.py +611 -0
  7. echoact/audio/wav.py +854 -0
  8. echoact/config/__init__.py +0 -0
  9. echoact/config/budget.py +370 -0
  10. echoact/config/settings.py +1244 -0
  11. echoact/db/__init__.py +0 -0
  12. echoact/db/backup.py +2429 -0
  13. echoact/db/migrations.py +434 -0
  14. echoact/db/schema.sql +214 -0
  15. echoact/db/store.py +2062 -0
  16. echoact/diagnostics.py +902 -0
  17. echoact/domain.py +487 -0
  18. echoact/engine/__init__.py +0 -0
  19. echoact/engine/container.py +843 -0
  20. echoact/engine/protocol.py +241 -0
  21. echoact/engine/runtime.py +324 -0
  22. echoact/engine/supervisor.py +961 -0
  23. echoact/engine/worker.py +659 -0
  24. echoact/errors.py +281 -0
  25. echoact/instance.py +172 -0
  26. echoact/jobs/__init__.py +0 -0
  27. echoact/jobs/engine.py +776 -0
  28. echoact/jobs/request.py +300 -0
  29. echoact/mcp/__init__.py +0 -0
  30. echoact/mcp/__main__.py +50 -0
  31. echoact/mcp/client.py +202 -0
  32. echoact/mcp/config.py +112 -0
  33. echoact/mcp/server.py +340 -0
  34. echoact/models/__init__.py +0 -0
  35. echoact/models/catalog.py +273 -0
  36. echoact/models/manifest.py +278 -0
  37. echoact/models/registry.py +1551 -0
  38. echoact/paths.py +93 -0
  39. echoact/policy.py +189 -0
  40. echoact/security/__init__.py +0 -0
  41. echoact/security/credentials.py +930 -0
  42. echoact/security/ratelimit.py +534 -0
  43. echoact/service/__init__.py +20 -0
  44. echoact/service/app.py +182 -0
  45. echoact/service/deps.py +563 -0
  46. echoact/service/errors.py +241 -0
  47. echoact/service/routes.py +1125 -0
  48. echoact/service/schemas.py +509 -0
  49. echoact/service/server.py +270 -0
  50. echoact/text/__init__.py +0 -0
  51. echoact/text/language.py +44 -0
  52. echoact/text/loader.py +577 -0
  53. echoact/text/normalize.py +924 -0
  54. echoact/text/segment.py +499 -0
  55. echoact/text/sniff.py +1202 -0
  56. echoact/ui/__init__.py +0 -0
  57. echoact/ui/bridge.py +50 -0
  58. echoact/ui/controls.py +360 -0
  59. echoact/ui/credential_dialog.py +131 -0
  60. echoact/ui/fonts.py +94 -0
  61. echoact/ui/i18n.py +260 -0
  62. echoact/ui/icons.py +440 -0
  63. echoact/ui/library.py +1642 -0
  64. echoact/ui/licence.py +162 -0
  65. echoact/ui/main_window.py +1202 -0
  66. echoact/ui/mcp_setup.py +494 -0
  67. echoact/ui/models_view.py +1142 -0
  68. echoact/ui/notifications.py +202 -0
  69. echoact/ui/reading.py +494 -0
  70. echoact/ui/settings_view.py +2258 -0
  71. echoact/ui/status_view.py +1193 -0
  72. echoact/ui/theme.py +579 -0
  73. echoact/util/__init__.py +0 -0
  74. echoact/util/ids.py +62 -0
  75. echoact/util/logging.py +127 -0
  76. echoact-0.1.0.dist-info/METADATA +162 -0
  77. echoact-0.1.0.dist-info/RECORD +80 -0
  78. echoact-0.1.0.dist-info/WHEEL +4 -0
  79. echoact-0.1.0.dist-info/entry_points.txt +3 -0
  80. echoact-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,534 @@
1
+ """Section 4.1's request limits, as sliding windows.
2
+
3
+ Three limits live here, and 4.1 keeps them separate on purpose: 10 generation
4
+ requests per minute per client, 120 other requests per minute per client, and
5
+ authentication failures, which are counted per connection origin rather than
6
+ per client because a failed authentication has not identified a client yet.
7
+
8
+ Two requirements shape the implementation more than the numbers do.
9
+
10
+ N-23 forbids an unbounded queue: a request over the limit is refused now, so
11
+ nothing here sleeps, blocks, or holds a caller. Every rejection carries a
12
+ retry-after computed from the window -- the moment the oldest counted request
13
+ falls out of it, or the moment a lockout ends -- because F-57 promises the
14
+ hint exists so a client need not guess a backoff. A made-up constant would
15
+ satisfy the letter and mislead the caller.
16
+
17
+ Memory is bounded on both axes. Windows are trimmed on every touch, empty
18
+ buckets are dropped, and the number of tracked keys is capped; at the cap a
19
+ key that is not already tracked is refused rather than admitted untracked or
20
+ swapped in over an existing one. Untracked admission would make the cap a way
21
+ to bypass the limit, and eviction would make it a way to clear one's own
22
+ lockout, both by inventing keys.
23
+
24
+ No web framework is imported: F-69 and F-71 show the same state in the GUI,
25
+ and 4.1's authentication lockout must never take the service or the GUI down
26
+ with it -- it refuses authentication attempts from one origin and nothing else.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import threading
32
+ from collections import deque
33
+ from collections.abc import Callable
34
+ from dataclasses import dataclass
35
+ from enum import StrEnum
36
+ from typing import Final
37
+
38
+ from ..errors import Code, EchoActError
39
+ from ..policy import (
40
+ AUTH_FAILURES_PER_MIN,
41
+ AUTH_LOCKOUT_S,
42
+ RATE_GENERATION_PER_MIN,
43
+ RATE_OTHER_PER_MIN,
44
+ )
45
+ from ..util.ids import monotonic
46
+
47
+ #: The "per minute" in every one of policy's ``*_PER_MIN`` figures. A sliding
48
+ #: window rather than a calendar minute: a fixed bucket would let a client
49
+ #: spend two full allowances back to back across the boundary.
50
+ WINDOW_S: Final = 60.0
51
+
52
+ #: How many distinct keys each limiter will track -- for authentication that
53
+ #: is origins, counted once each whether the origin is accumulating failures
54
+ #: or serving a lockout. 4.1 fixes no figure, because
55
+ #: on a loopback-only service the real bound is the number of issued
56
+ #: credentials; this is the backstop that keeps the table finite anyway. Both
57
+ #: are far above any plausible legitimate count -- 4.1 allows one credential
58
+ #: per integration, not thousands -- so reaching either means something is
59
+ #: wrong, and being refused is the right answer to that.
60
+ MAX_TRACKED_CLIENTS: Final = 1024
61
+ MAX_TRACKED_ORIGINS: Final = 1024
62
+
63
+ Clock = Callable[[], float]
64
+
65
+
66
+ class RequestClass(StrEnum):
67
+ """4.1 counts generation separately from everything else.
68
+
69
+ A generation request consumes only the generation allowance: the two
70
+ limits are stated as separate sentences, and charging a generation request
71
+ to both would silently make the "other" limit 110 for a busy client.
72
+ """
73
+
74
+ GENERATION = "generation"
75
+ OTHER = "other"
76
+
77
+
78
+ _LIMITS: Final[dict[RequestClass, int]] = {
79
+ RequestClass.GENERATION: RATE_GENERATION_PER_MIN,
80
+ RequestClass.OTHER: RATE_OTHER_PER_MIN,
81
+ }
82
+
83
+
84
+ @dataclass(frozen=True, slots=True)
85
+ class Decision:
86
+ """The answer to one rate-limit question.
87
+
88
+ ``retry_after_s`` is 0.0 when allowed and strictly positive when not, so a
89
+ caller can pass it straight into F-57's hint without a special case.
90
+ """
91
+
92
+ allowed: bool
93
+ request_class: RequestClass
94
+ limit: int
95
+ remaining: int
96
+ retry_after_s: float
97
+
98
+ def raise_if_limited(self) -> None:
99
+ if self.allowed:
100
+ return
101
+ raise EchoActError(
102
+ Code.RATE_LIMITED,
103
+ retry_after_s=self.retry_after_s,
104
+ detail={
105
+ "limit": self.limit,
106
+ "window_s": WINDOW_S,
107
+ "request_class": self.request_class.value,
108
+ },
109
+ )
110
+
111
+
112
+ @dataclass(frozen=True, slots=True)
113
+ class Lockout:
114
+ """The authentication state of one connection origin (4.1).
115
+
116
+ ``locked`` is what blocks an attempt; ``failures`` is how many are inside
117
+ the current window, which F-69's service screen can show without the owner
118
+ having to trigger a lockout to find out.
119
+ """
120
+
121
+ locked: bool
122
+ failures: int
123
+ limit: int
124
+ retry_after_s: float
125
+
126
+ def raise_if_locked(self) -> None:
127
+ if not self.locked:
128
+ return
129
+ raise EchoActError(
130
+ Code.AUTH_LOCKED_OUT,
131
+ retry_after_s=self.retry_after_s,
132
+ detail={"limit": self.limit, "window_s": WINDOW_S},
133
+ )
134
+
135
+
136
+ class _Window:
137
+ """Timestamps of the events counted in one sliding window."""
138
+
139
+ __slots__ = ("_hits",)
140
+
141
+ def __init__(self) -> None:
142
+ self._hits: deque[float] = deque()
143
+
144
+ def trim(self, now: float) -> None:
145
+ cutoff = now - WINDOW_S
146
+ hits = self._hits
147
+ while hits and hits[0] <= cutoff:
148
+ hits.popleft()
149
+
150
+ def __len__(self) -> int:
151
+ return len(self._hits)
152
+
153
+ @property
154
+ def empty(self) -> bool:
155
+ return not self._hits
156
+
157
+ def add(self, now: float) -> None:
158
+ self._hits.append(now)
159
+
160
+ def retry_after(self, now: float) -> float:
161
+ """When the oldest counted event leaves the window.
162
+
163
+ This is the earliest instant at which the same request could succeed,
164
+ which is what F-57's hint is supposed to mean. An empty window would
165
+ mean the caller was not limited at all, so it answers 0.0.
166
+ """
167
+ if not self._hits:
168
+ return 0.0
169
+ return max(0.0, self._hits[0] + WINDOW_S - now)
170
+
171
+
172
+ class RateLimiter:
173
+ """Per-client request rates (4.1), thread-safe and bounded.
174
+
175
+ The REST service serves several threads at once, and 4.1's limits are per
176
+ client rather than per connection, so the counting has to be shared and
177
+ therefore locked. Every operation is O(expired events) and never waits.
178
+ """
179
+
180
+ def __init__(
181
+ self,
182
+ *,
183
+ clock: Clock = monotonic,
184
+ max_clients: int = MAX_TRACKED_CLIENTS,
185
+ ) -> None:
186
+ # A monotonic clock, never the wall clock: a clock adjustment must not
187
+ # extend or erase a limit window (see ``echoact.util.ids``).
188
+ self._clock = clock
189
+ self._max_clients = max_clients
190
+ self._lock = threading.Lock()
191
+ self._windows: dict[str, dict[RequestClass, _Window]] = {}
192
+
193
+ @property
194
+ def tracked_clients(self) -> int:
195
+ with self._lock:
196
+ return len(self._windows)
197
+
198
+ def check(self, client_id: str, request_class: RequestClass) -> Decision:
199
+ """Count one request and say whether it is allowed.
200
+
201
+ Consumes the allowance when it allows. Call it once per request, at
202
+ the point the request is admitted, so a rejected request costs nothing
203
+ against the caller's own limit.
204
+ """
205
+ return self._evaluate(client_id, request_class, consume=True)
206
+
207
+ def peek(self, client_id: str, request_class: RequestClass) -> Decision:
208
+ """The same answer without consuming, for F-69's status screen."""
209
+ return self._evaluate(client_id, request_class, consume=False)
210
+
211
+ def raise_if_limited(self, client_id: str, request_class: RequestClass) -> Decision:
212
+ """``check`` that raises RATE_LIMITED with F-57's hint."""
213
+ decision = self.check(client_id, request_class)
214
+ decision.raise_if_limited()
215
+ return decision
216
+
217
+ def forget(self, client_id: str) -> None:
218
+ """Drop a client's windows, e.g. once its credential is revoked."""
219
+ with self._lock:
220
+ self._windows.pop(client_id, None)
221
+
222
+ def sweep(self) -> int:
223
+ """Drop clients with nothing counted any more; returns how many.
224
+
225
+ The cap already bounds the table, so this is housekeeping rather than
226
+ safety: it keeps ``tracked_clients`` truthful for F-69's screen and
227
+ keeps N-26's eight-hour run free of a table that only ever grows.
228
+ """
229
+ now = self._clock()
230
+ with self._lock:
231
+ stale = [k for k, b in self._windows.items() if _all_expired(b, now)]
232
+ for client_id in stale:
233
+ del self._windows[client_id]
234
+ return len(stale)
235
+
236
+ def _evaluate(self, client_id: str, request_class: RequestClass, *, consume: bool) -> Decision:
237
+ limit = _LIMITS[request_class]
238
+ now = self._clock()
239
+ with self._lock:
240
+ buckets = self._windows.get(client_id)
241
+ if buckets is None:
242
+ if self._prune_locked(now):
243
+ # At the cap, an unknown client is refused rather than
244
+ # admitted untracked, which would make the cap a bypass.
245
+ return Decision(
246
+ allowed=False,
247
+ request_class=request_class,
248
+ limit=limit,
249
+ remaining=0,
250
+ retry_after_s=self._soonest_free_locked(now),
251
+ )
252
+ if not consume:
253
+ # A peek must not create the bucket it is asking about, or
254
+ # F-69's screen would fill the table by drawing itself.
255
+ return Decision(
256
+ allowed=True,
257
+ request_class=request_class,
258
+ limit=limit,
259
+ remaining=limit,
260
+ retry_after_s=0.0,
261
+ )
262
+ buckets = {c: _Window() for c in RequestClass}
263
+ self._windows[client_id] = buckets
264
+
265
+ window = buckets[request_class]
266
+ window.trim(now)
267
+ if len(window) >= limit:
268
+ return Decision(
269
+ allowed=False,
270
+ request_class=request_class,
271
+ limit=limit,
272
+ remaining=0,
273
+ retry_after_s=window.retry_after(now),
274
+ )
275
+ if consume:
276
+ window.add(now)
277
+ return Decision(
278
+ allowed=True,
279
+ request_class=request_class,
280
+ limit=limit,
281
+ remaining=limit - len(window),
282
+ retry_after_s=0.0,
283
+ )
284
+
285
+ def _prune_locked(self, now: float) -> bool:
286
+ """Drop clients with nothing left in either window. True if still full."""
287
+ if len(self._windows) < self._max_clients:
288
+ return False
289
+ for client_id in [k for k, b in self._windows.items() if _all_expired(b, now)]:
290
+ del self._windows[client_id]
291
+ return len(self._windows) >= self._max_clients
292
+
293
+ def _soonest_free_locked(self, now: float) -> float:
294
+ """How long until some tracked client's window empties a slot.
295
+
296
+ The table cannot stay full longer than one window, so this is a real
297
+ figure rather than a guess, and it is capped at the window for the
298
+ degenerate case where every bucket was just touched.
299
+ """
300
+ soonest = WINDOW_S
301
+ for buckets in self._windows.values():
302
+ for window in buckets.values():
303
+ after = window.retry_after(now)
304
+ if 0.0 < after < soonest:
305
+ soonest = after
306
+ return soonest
307
+
308
+
309
+ class AuthFailureLimiter:
310
+ """4.1's separate limit on authentication failures, per connection origin.
311
+
312
+ Ten failures inside a minute block authentication retries from that origin
313
+ for sixty seconds. The block is exactly that: the service keeps serving
314
+ every other origin, already-authenticated requests are untouched, and the
315
+ GUI never notices -- 4.1 says the local service as a whole and the GUI are
316
+ not terminated, so a lockout may not be escalated into a shutdown.
317
+
318
+ "Origin" is whatever the service can attribute a connection to, normally
319
+ the peer address. On a loopback-only listener (N-17) that is one value in
320
+ practice, which is the honest reading: every caller is on this machine, so
321
+ the lockout is a global brake on password guessing rather than a way to
322
+ isolate one attacker from another.
323
+ """
324
+
325
+ def __init__(
326
+ self,
327
+ *,
328
+ clock: Clock = monotonic,
329
+ max_origins: int = MAX_TRACKED_ORIGINS,
330
+ ) -> None:
331
+ self._clock = clock
332
+ self._max_origins = max_origins
333
+ self._lock = threading.Lock()
334
+ self._failures: dict[str, _Window] = {}
335
+ self._locked_until: dict[str, float] = {}
336
+
337
+ @property
338
+ def tracked_origins(self) -> int:
339
+ with self._lock:
340
+ return self._tracked_locked()
341
+
342
+ def sweep(self) -> int:
343
+ """Drop origins with no live failures and no live lockout.
344
+
345
+ The count is what ``tracked_origins`` drops by, which is only true
346
+ because the two tables are disjoint: a locked origin is counted once,
347
+ under its lockout, and reclaiming it means the lockout has lapsed.
348
+ """
349
+ now = self._clock()
350
+ with self._lock:
351
+ stale = [o for o, w in self._failures.items() if _window_expired(w, now)]
352
+ for origin in stale:
353
+ del self._failures[origin]
354
+ lapsed = [o for o, until in self._locked_until.items() if until <= now]
355
+ for origin in lapsed:
356
+ del self._locked_until[origin]
357
+ return len(stale) + len(lapsed)
358
+
359
+ def check(self, origin: str) -> Lockout:
360
+ """Ask whether this origin may attempt authentication at all.
361
+
362
+ Call it before verifying a credential. It counts nothing: a locked-out
363
+ attempt must not extend its own lockout, or an eager client would never
364
+ be let back in.
365
+ """
366
+ now = self._clock()
367
+ with self._lock:
368
+ return self._state_locked(origin, now)
369
+
370
+ def raise_if_locked(self, origin: str) -> None:
371
+ """``check`` that raises AUTH_LOCKED_OUT with the remaining time."""
372
+ self.check(origin).raise_if_locked()
373
+
374
+ def record_failure(self, origin: str) -> Lockout:
375
+ """Count one authentication failure and report the resulting state.
376
+
377
+ The lockout starts at the failure that reaches the limit and runs for
378
+ 4.1's sixty seconds from that instant, not from the first failure in
379
+ the window.
380
+ """
381
+ now = self._clock()
382
+ with self._lock:
383
+ state = self._state_locked(origin, now)
384
+ if state.locked:
385
+ return state
386
+ window = self._failures.get(origin)
387
+ if window is None:
388
+ if self._prune_locked(now):
389
+ # Table full: treat the origin as locked out rather than
390
+ # untracked. Refusing an authentication attempt is the
391
+ # safe direction, and the caller is told when to retry.
392
+ return Lockout(
393
+ locked=True,
394
+ failures=0,
395
+ limit=AUTH_FAILURES_PER_MIN,
396
+ retry_after_s=self._soonest_free_locked(now),
397
+ )
398
+ window = _Window()
399
+ self._failures[origin] = window
400
+ window.add(now)
401
+ if len(window) >= AUTH_FAILURES_PER_MIN:
402
+ # The lockout replaces the window rather than sitting beside
403
+ # an emptied one: the lockout entry is now what tracks this
404
+ # origin, and leaving a spent window behind made the origin
405
+ # count twice in ``sweep`` and zero times against the cap.
406
+ self._locked_until[origin] = now + AUTH_LOCKOUT_S
407
+ del self._failures[origin]
408
+ return Lockout(
409
+ locked=True,
410
+ failures=AUTH_FAILURES_PER_MIN,
411
+ limit=AUTH_FAILURES_PER_MIN,
412
+ retry_after_s=AUTH_LOCKOUT_S,
413
+ )
414
+ return Lockout(
415
+ locked=False,
416
+ failures=len(window),
417
+ limit=AUTH_FAILURES_PER_MIN,
418
+ retry_after_s=0.0,
419
+ )
420
+
421
+ def record_success(self, origin: str) -> None:
422
+ """Clear the window after an authentication that worked.
423
+
424
+ A client that mistypes a credential twice and then presents a valid one
425
+ has demonstrated it is not guessing; carrying those failures forward
426
+ for the rest of the minute would eventually lock out the legitimate
427
+ integration. It concedes nothing: reaching this point already required
428
+ a valid credential.
429
+ """
430
+ with self._lock:
431
+ self._failures.pop(origin, None)
432
+ self._locked_until.pop(origin, None)
433
+
434
+ def forget(self, origin: str) -> None:
435
+ with self._lock:
436
+ self._failures.pop(origin, None)
437
+ self._locked_until.pop(origin, None)
438
+
439
+ def _state_locked(self, origin: str, now: float) -> Lockout:
440
+ until = self._locked_until.get(origin)
441
+ if until is not None:
442
+ if now < until:
443
+ return Lockout(
444
+ locked=True,
445
+ failures=AUTH_FAILURES_PER_MIN,
446
+ limit=AUTH_FAILURES_PER_MIN,
447
+ retry_after_s=until - now,
448
+ )
449
+ # The lockout has run out. Forget the failures that caused it, or
450
+ # the next single mistake would re-lock the origin immediately.
451
+ del self._locked_until[origin]
452
+ self._failures.pop(origin, None)
453
+ window = self._failures.get(origin)
454
+ if window is None:
455
+ return Lockout(
456
+ locked=False, failures=0, limit=AUTH_FAILURES_PER_MIN, retry_after_s=0.0
457
+ )
458
+ window.trim(now)
459
+ if window.empty:
460
+ del self._failures[origin]
461
+ return Lockout(
462
+ locked=False, failures=0, limit=AUTH_FAILURES_PER_MIN, retry_after_s=0.0
463
+ )
464
+ return Lockout(
465
+ locked=False, failures=len(window), limit=AUTH_FAILURES_PER_MIN, retry_after_s=0.0
466
+ )
467
+
468
+ def _tracked_locked(self) -> int:
469
+ """How many origins the two tables hold between them.
470
+
471
+ They are disjoint by construction -- an origin is either accumulating
472
+ failures or locked out, and locking moves it from one to the other --
473
+ so this is a sum, not a union. Were that ever to stop being true the
474
+ sum would over-count, which refuses an untracked attempt sooner: the
475
+ safe direction for a cap whose job is to stay finite.
476
+ """
477
+ return len(self._failures) + len(self._locked_until)
478
+
479
+ def _prune_locked(self, now: float) -> bool:
480
+ """Drop lapsed origins. True if the tables are still full.
481
+
482
+ MAX_TRACKED_ORIGINS covers lockouts too. A cap on ``_failures``
483
+ alone bounds nothing: ten failures move an origin into
484
+ ``_locked_until`` and out of the table being measured, so an inventor
485
+ of origins buys one permanent entry per ten attempts -- exactly the
486
+ traffic the limit exists to answer.
487
+ """
488
+ if self._tracked_locked() < self._max_origins:
489
+ return False
490
+ for origin in [o for o, w in self._failures.items() if _window_expired(w, now)]:
491
+ del self._failures[origin]
492
+ for origin in [o for o, until in self._locked_until.items() if until <= now]:
493
+ del self._locked_until[origin]
494
+ return self._tracked_locked() >= self._max_origins
495
+
496
+ def _soonest_free_locked(self, now: float) -> float:
497
+ """When a slot next frees up, for the refusal's retry hint.
498
+
499
+ Lockouts are counted here for the same reason they are counted
500
+ against the cap: once an origin locks it has no failure window left,
501
+ so a table full of lockouts would answer with the bare WINDOW_S
502
+ default -- a made-up constant, which is what F-57's hint must not be.
503
+ """
504
+ soonest = max(WINDOW_S, AUTH_LOCKOUT_S)
505
+ for window in self._failures.values():
506
+ after = window.retry_after(now)
507
+ if 0.0 < after < soonest:
508
+ soonest = after
509
+ for until in self._locked_until.values():
510
+ after = until - now
511
+ if 0.0 < after < soonest:
512
+ soonest = after
513
+ return soonest
514
+
515
+
516
+ def _window_expired(window: _Window, now: float) -> bool:
517
+ window.trim(now)
518
+ return window.empty
519
+
520
+
521
+ def _all_expired(buckets: dict[RequestClass, _Window], now: float) -> bool:
522
+ return all(_window_expired(w, now) for w in buckets.values())
523
+
524
+
525
+ __all__ = [
526
+ "MAX_TRACKED_CLIENTS",
527
+ "MAX_TRACKED_ORIGINS",
528
+ "WINDOW_S",
529
+ "AuthFailureLimiter",
530
+ "Decision",
531
+ "Lockout",
532
+ "RateLimiter",
533
+ "RequestClass",
534
+ ]
@@ -0,0 +1,20 @@
1
+ """The local REST service: Section 2.10's twelve operations, and nothing else.
2
+
3
+ Import order matters here only in that ``server`` pulls in uvicorn, which the
4
+ GUI does not need until the owner actually starts the service; everything the
5
+ routes need is reachable without it.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from .app import CONTRACT_OPERATIONS, CONTRACT_PATHS, create_app
11
+ from .deps import ServiceContext
12
+ from .server import ServiceRunner
13
+
14
+ __all__ = [
15
+ "CONTRACT_OPERATIONS",
16
+ "CONTRACT_PATHS",
17
+ "ServiceContext",
18
+ "ServiceRunner",
19
+ "create_app",
20
+ ]