everydeck 0.3.0__tar.gz → 0.3.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: everydeck
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: everydeck Python SDK: bounded buffer, background sender, flush on shutdown, reported data gaps
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://everydeck.ai
@@ -40,7 +40,7 @@ everydeck.signal("job.created", operation_id)
40
40
  - `signal()`, `error()` and `log()` never block and never raise: they enqueue into a bounded in-memory buffer; a background thread sends batches (at most 50 events, 64 KiB, gzip-compressed).
41
41
  - Retries only on 429, 5xx, timeout and network errors; `Retry-After` is honoured; final rejections are dropped and counted, never reported as sent.
42
42
  - A buffer overflow is reported as a `gap` event, so everydeck shows a known data gap instead of silence.
43
- - `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"cv-worker"`).
43
+ - `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"worker"`).
44
44
  - `release_key="auto"` derives the version from the deployment commit (`EVERYDECK_RELEASE_KEY`, `GITHUB_SHA`, `VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT`, …) or local git.
45
45
  - `everydeck.verify.verify_everydeck_request` checks everydeck's signature on bridge calls.
46
46
  - Read bridge (0.3.0): `everydeck.read_route.EverydeckReadBridge(endpoint, key, db)` is the ready-made route. It fetches its read scope from everydeck (`GET <endpoint>/v1/read-scope` with the bridge key) and uses it only with everydeck's signature; no read-scope file, no read role, no views. `db` is the connection the app already has: `PsycopgReadOnly(conninfo)` runs every query in `BEGIN READ ONLY`, `SupabaseReadOnly(client)` reads through the app's Supabase client with `select()` only (GET requests, which PostgREST runs read-only; only tables in schemas the Supabase API exposes).
@@ -24,7 +24,7 @@ everydeck.signal("job.created", operation_id)
24
24
  - `signal()`, `error()` and `log()` never block and never raise: they enqueue into a bounded in-memory buffer; a background thread sends batches (at most 50 events, 64 KiB, gzip-compressed).
25
25
  - Retries only on 429, 5xx, timeout and network errors; `Retry-After` is honoured; final rejections are dropped and counted, never reported as sent.
26
26
  - A buffer overflow is reported as a `gap` event, so everydeck shows a known data gap instead of silence.
27
- - `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"cv-worker"`).
27
+ - `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"worker"`).
28
28
  - `release_key="auto"` derives the version from the deployment commit (`EVERYDECK_RELEASE_KEY`, `GITHUB_SHA`, `VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT`, …) or local git.
29
29
  - `everydeck.verify.verify_everydeck_request` checks everydeck's signature on bridge calls.
30
30
  - Read bridge (0.3.0): `everydeck.read_route.EverydeckReadBridge(endpoint, key, db)` is the ready-made route. It fetches its read scope from everydeck (`GET <endpoint>/v1/read-scope` with the bridge key) and uses it only with everydeck's signature; no read-scope file, no read role, no views. `db` is the connection the app already has: `PsycopgReadOnly(conninfo)` runs every query in `BEGIN READ ONLY`, `SupabaseReadOnly(client)` reads through the app's Supabase client with `select()` only (GET requests, which PostgREST runs read-only; only tables in schemas the Supabase API exposes).
@@ -1,32 +1,32 @@
1
- """everydeck Python SDK (R0, Transportvertrag 1.0-r0).
2
-
3
- Zusagen (docs/05, belegt in P2a und tests/):
4
- - `signal()/error()/log()` blockieren nie und werfen nie: O(1) Enqueue in einen begrenzten Speicherpuffer. Kein Datei- oder Netz-IO im Aufrufer.
5
- - Ein Sender-Thread überträgt Batches (max. 50 Ereignisse, 64 KiB). Drei getrennte Ergebnisse je Batch:
6
- angenommen (202) · erneut versuchen (429, 5xx, Timeout, Netz) · endgültig abgewiesen (400/401/403/409/413/422).
7
- Abgewiesene Batches zählen nie als gesendet; 401/403 pausieren den Sender (auth_pause_seconds) und melden eine Warnung.
8
- - B30 Schnitt D (0.1.1): Batches gehen gzip-komprimiert (`compress=False` schaltet es ab), `Retry-After` bei 429/503 wird befolgt,
9
- Wiederholungen mit vollem Zufallsanteil (`full_jitter`); `stats`: batches_compressed, bytes_raw, bytes_sent, retry_after_waits – wie das TypeScript-SDK.
10
- - B30 Schnitt E (0.1.2): `everydeck.verify.verify_everydeck_request` prüft die Signatur von everydeck an Brücken (5-Minuten-Fenster, request_id einmalig); `ReadBridge` prüft selbst (`signatures="required"`).
11
- - B33 (0.1.5): `error(..., exc=e)` schickt zu bis zu 5 Frames im Code der App die Code-Zeilen mit (Fundzeile und je 5 davor und danach, je höchstens 200 Zeichen, bereinigt wie Fehlermeldungen); gelesen im Sender-Thread, nie im Aufruf; `Everydeck(..., source_context=False)` schaltet es ab. `error.context_status` sagt, ob Zeilen dabei sind oder warum nicht.
12
- - B34b (0.1.6): `error(..., response={code, message})` wie im TS-SDK (Code nur als Kennung, Meldung bereinigt, 300 Zeichen); `answer_of(exc)` liest beides aus Attributen der Ausnahme (z. B. postgrest `APIError.code`/`.message`), nie aus ihrem Text.
13
- - B32 (0.1.4): `signal`, `error` und `log` nehmen `object_type`/`object_ref` wie das TS-SDK (`objectType`/`objectRef`).
14
- - B31 (0.1.3): `Everydeck.error(..., exc=e)` sendet die Stack-Frames der Ausnahme (`error.frames`, innerste zuerst) – oder fertige `frames=[…]`; `ReadBridge(match=psycopg_match(conn))` liefert die Nachrichten eines Gesprächs auf Abruf (`GET /everydeck/read/messages/{type}/{ref}`).
15
- - Der Sender-Thread endet nie unbemerkt: jede Ausnahme wird gezählt, gemeldet und die Schleife fortgesetzt.
16
- - Pufferüberlauf wird als Ereignis `gap` (buffer_overflow) gemeldet: eine *erkennbare* Datenlücke.
17
- - Optionaler Spool (JSONL, begrenzte Größe) läuft in einem eigenen Thread, der als einziger die Datei anfasst: Schreibvorgänge und
18
- Bestätigungen (angenommene/abgewiesene Ereignisse) kommen über zwei begrenzte Warteschlangen im RAM (`max_spool_queue`) und werden im
19
- Spool-Thread gemeinsam ausgewertet – eine Bestätigung, die vor dem Schreiben eintrifft, verhindert das Schreiben, statt eine Waise zu
20
- hinterlassen. Weder Aufrufer noch Sender halten während Datei-IO eine Sperre. Fehler (voll, nicht beschreibbar) werden gezählt und
21
- gemeldet, betreffen aber weder Kundenrequests noch den Sender. Beim Start werden unbeschädigte Spool-Zeilen nachgeliefert (`gap`
22
- restart_recovered); beschädigte Zeilen (abgebrochener Schreibvorgang) werden gezählt und verworfen. Ohne Spool bleibt ein SIGKILL unsichtbar.
23
- Laufen die Warteschlangen über (Spool-Thread hängt an langsamem Datenträger), werden die ältesten Einträge verworfen und gezählt
24
- (`spool_queue_dropped`, `spool_ack_dropped`); Folge einer verworfenen Bestätigung ist höchstens eine erneute Zustellung bereits angenommener
25
- Ereignisse nach einem Neustart (everydeck dedupliziert je event_id), nie ein Verlust.
26
- - Lebenszeichen (R1): mit `heartbeat_interval` sendet der Sender-Thread alle n Sekunden ein Signal `everydeck.sdk.heartbeat`
27
- (operation_id `everydeck.sdk:<instance_id>`, Felder instance_id/interval_seconds/pid). Ausbleibende Lebenszeichen sind für everydeck eine
28
- Überwachungslücke, kein bewiesener Ausfall der App.
29
- - Das SDK kennt keine Datenbank von everydeck und entscheidet keine Engine-Urteile.
1
+ """everydeck Python SDK (transport contract 1.0-r0).
2
+
3
+ Guarantees:
4
+ - `signal()/error()/log()` never block and never raise: O(1) enqueue into a bounded in-memory buffer. No file or network IO in the caller.
5
+ - A sender thread transmits batches (max. 50 events, 64 KiB). Three separate outcomes per batch:
6
+ accepted (202) · retry (429, 5xx, timeout, network) · finally rejected (400/401/403/409/413/422).
7
+ Rejected batches never count as sent; 401/403 pause the sender (auth_pause_seconds) and report a warning.
8
+ - Batches are sent gzip-compressed (`compress=False` turns it off), `Retry-After` on 429/503 is honored,
9
+ retries use full jitter (`full_jitter`); `stats`: batches_compressed, bytes_raw, bytes_sent, retry_after_waits, as in the TypeScript SDK.
10
+ - `everydeck.verify.verify_everydeck_request` checks everydeck's signature at bridges (5-minute window, request_id used once); `ReadBridge` checks it itself (`signatures="required"`).
11
+ - `error(..., exc=e)` sends the code lines for up to 5 frames in the app's code (the error line and 5 before and after, at most 200 characters each, scrubbed like error messages); read in the sender thread, never in the call; `Everydeck(..., source_context=False)` turns it off. `error.context_status` says whether lines are included or why not.
12
+ - `error(..., response={code, message})` as in the TS SDK (code only as an identifier, message scrubbed, 300 characters); `answer_of(exc)` reads both from attributes of the exception (e.g. postgrest `APIError.code`/`.message`), never from its text.
13
+ - `signal`, `error` and `log` take `object_type`/`object_ref` like the TS SDK (`objectType`/`objectRef`).
14
+ - `Everydeck.error(..., exc=e)` sends the exception's stack frames (`error.frames`, innermost first), or ready-made `frames=[…]`; `ReadBridge(match=psycopg_match(conn))` delivers the messages of one conversation on demand (`GET /everydeck/read/messages/{type}/{ref}`).
15
+ - The sender thread never ends silently: every exception is counted, reported, and the loop continues.
16
+ - Buffer overflow is reported as a `gap` event (buffer_overflow): a *detectable* data gap.
17
+ - An optional spool (JSONL, bounded size) runs in its own thread, which is the only one touching the file: writes and
18
+ acknowledgements (accepted/rejected events) arrive through two bounded in-memory queues (`max_spool_queue`) and are evaluated
19
+ together in the spool thread; an acknowledgement that arrives before the write prevents the write instead of leaving an orphan
20
+ behind. Neither caller nor sender holds a lock during file IO. Errors (full, not writable) are counted and
21
+ reported but affect neither customer requests nor the sender. On start, intact spool lines are replayed (`gap`
22
+ restart_recovered); corrupt lines (interrupted write) are counted and discarded. Without a spool a SIGKILL stays invisible.
23
+ If the queues overflow (spool thread stuck on a slow disk), the oldest entries are discarded and counted
24
+ (`spool_queue_dropped`, `spool_ack_dropped`); a discarded acknowledgement can at most cause already accepted events to be
25
+ delivered again after a restart (everydeck deduplicates by event_id), never a loss.
26
+ - Heartbeats: with `heartbeat_interval` the sender thread sends a signal `everydeck.sdk.heartbeat` every n seconds
27
+ (operation_id `everydeck.sdk:<instance_id>`, fields instance_id/interval_seconds/pid). Missing heartbeats are a
28
+ monitoring gap for everydeck, not a proven outage of the app.
29
+ - The SDK knows no everydeck database and makes no engine verdicts.
30
30
  """
31
31
  from __future__ import annotations
32
32
 
@@ -51,7 +51,7 @@ from typing import Any, Callable, Literal
51
51
  __all__ = ["Everydeck", "AfterCommit", "SCHEMA_VERSION", "BATCH_MAX", "error_class_of", "scrub_text", "answer_of"]
52
52
 
53
53
  SCHEMA_VERSION = "1.3-r0"
54
- # B23: Herkunft jedes Ereignisses – ein Server-Schlüssel meldet immer die Plattform `server`
54
+ # origin of every event: a server key always reports the platform `server`
55
55
  CLIENT = {"platform": "server", "runtime": "python"}
56
56
  BATCH_MAX = 50
57
57
  BATCH_MAX_BYTES = 64 * 1024
@@ -70,9 +70,9 @@ _COMMIT_ENV = ("GITHUB_SHA", "VERCEL_GIT_COMMIT_SHA", "RENDER_GIT_COMMIT", "CF_P
70
70
 
71
71
 
72
72
  def resolve_release_key(env: dict[str, str] | None = None, cwd: str | None = None) -> str:
73
- """B15/C5: automatische Versionskennung – gleiche Regel wie @everydeck/sdk resolveReleaseKey und everydeck-agent version.
73
+ """Automatic release key: same rule as @everydeck/sdk resolveReleaseKey and everydeck-agent version.
74
74
 
75
- EVERYDECK_RELEASE_KEY (außer "auto") > Commit aus CI/Hosting (git:<sha12>) > lokales Git (git:<sha12>[+dirty.<hash8>]) > "unknown".
75
+ EVERYDECK_RELEASE_KEY (except "auto") > commit from CI/hosting (git:<sha12>) > local Git (git:<sha12>[+dirty.<hash8>]) > "unknown".
76
76
  """
77
77
  import hashlib
78
78
  import re
@@ -113,13 +113,13 @@ def _dumps(v: Any) -> str:
113
113
 
114
114
 
115
115
  def full_jitter(failures: int, cap: float, rnd: Callable[[], float] = random.random) -> float:
116
- """B30 Schnitt D: Backoff mit vollem Zufallsanteil („full jitter"): gleichverteilt in [0, min(cap, 0.5 s · 2^n)] – wie das TypeScript-SDK."""
116
+ """Backoff with full jitter: uniform in [0, min(cap, 0.5 s · 2^n)], as in the TypeScript SDK."""
117
117
  ceiling = min(cap, 0.5 * 2 ** max(0, min(6, failures)))
118
118
  return rnd() * ceiling
119
119
 
120
120
 
121
121
  def parse_retry_after(value: str | None, now: float | None = None) -> float | None:
122
- """`Retry-After` als Sekunden oder HTTP-Datum → Sekunden, begrenzt auf eine Stunde; None, wenn fehlt oder unlesbar."""
122
+ """`Retry-After` as seconds or HTTP date → seconds, capped at one hour; None when missing or unreadable."""
123
123
  if not value:
124
124
  return None
125
125
  v = value.strip()
@@ -164,7 +164,7 @@ class Everydeck:
164
164
  self.credential = credential or ""
165
165
  self.release_key = resolve_release_key() if not release_key or release_key == "auto" else release_key
166
166
  self.manifest_digest = manifest_digest or ""
167
- # B15/C7: Prüflauf der Einrichtung (nur Verifikationsskripte)
167
+ # setup check run (verification scripts only)
168
168
  self.probe_id = probe_id
169
169
  self.max_buffer = max_buffer
170
170
  self.flush_interval = flush_interval
@@ -175,18 +175,18 @@ class Everydeck:
175
175
  self.max_spool_queue = max(1, int(max_spool_queue))
176
176
  self.auth_pause_seconds = auth_pause_seconds
177
177
  self.max_send_backoff = max_send_backoff
178
- # B30 Schnitt D: Batches gzip-komprimiert senden (Standard an); everydeck nimmt Content-Encoding: gzip an
178
+ # send batches gzip-compressed (on by default); everydeck accepts Content-Encoding: gzip
179
179
  self.compress = compress
180
- # B33: Code-Zeilen zu den Frames im Code der App (Standard an); Dateien liest der Sender-Thread, zwischengespeichert je Pfad
180
+ # code lines for frames in the app's code (on by default); the sender thread reads the files, cached per path
181
181
  self.source_context = source_context
182
182
  self._source_cache: dict[str, list[str] | None] = {}
183
183
  self._warn_cb = on_warning or (lambda m: _log.warning(m))
184
184
  self.enabled = enabled
185
- # B36: fehlt Adresse oder Schlüssel (z. B. nach einem Update mit neuen Variablennamen), schaltet das SDK ab – aber laut, nie still
185
+ # if address or key is missing (e.g. after an update with new variable names), the SDK turns itself off, loudly, never silently
186
186
  if self.enabled and (not self.endpoint or not self.credential or not self.manifest_digest):
187
187
  self.enabled = False
188
188
  missing = "no endpoint" if not self.endpoint else "no key" if not self.credential else "no manifest digest"
189
- self._warn_cb(f"everydeck: disabled: {missing} – nothing is sent to everydeck until the app passes endpoint, key and manifest digest")
189
+ self._warn_cb(f"everydeck: disabled: {missing}; nothing is sent to everydeck until the app passes endpoint, key and manifest digest")
190
190
  self.heartbeat_interval = heartbeat_interval
191
191
  self.instance_id = instance_id or f"{socket.gethostname()[:40]}-{os.getpid()}-{uuid.uuid4().hex[:6]}"
192
192
  self._next_heartbeat = 0.0
@@ -198,10 +198,10 @@ class Everydeck:
198
198
  self._unreported_loss = 0
199
199
  self._paused_until = 0.0
200
200
  self._consecutive_failures = 0
201
- self._spool_q: deque[dict[str, Any]] = deque() # ausstehende Schreibvorgänge (begrenzt)
202
- self._spool_acks: dict[str, None] = {} # ausstehende Bestätigungen, geordnet (begrenzt)
203
- self._spool_q_lock = threading.Lock() # nur Warteschlangen (Aufrufer, Sender, Spool-Thread), nie während Datei-IO gehalten
204
- self._spool_lock = threading.Lock() # Datei; nur Spool-Thread (und Wiederherstellung im Konstruktor)
201
+ self._spool_q: deque[dict[str, Any]] = deque() # pending writes (bounded)
202
+ self._spool_acks: dict[str, None] = {} # pending acknowledgements, ordered (bounded)
203
+ self._spool_q_lock = threading.Lock() # queues only (caller, sender, spool thread), never held during file IO
204
+ self._spool_lock = threading.Lock() # file; spool thread only (and recovery in the constructor)
205
205
  self._spool_ack_overflow_warned = False
206
206
  self._spool_wake = threading.Event()
207
207
  self._spool_disabled = False
@@ -212,7 +212,7 @@ class Everydeck:
212
212
  "auth_failures": 0, "last_rejection": None, "sender_errors": 0,
213
213
  "spool_errors": 0, "spool_corrupt_lines": 0, "spool_dropped": 0, "spool_queue_dropped": 0, "spool_ack_dropped": 0,
214
214
  "replayed_from_spool": 0, "spool_disabled": False, "heartbeats": 0,
215
- "batches_compressed": 0, "bytes_raw": 0, "bytes_sent": 0, "retry_after_waits": 0, # B30 Schnitt D
215
+ "batches_compressed": 0, "bytes_raw": 0, "bytes_sent": 0, "retry_after_waits": 0,
216
216
  }
217
217
  if not enabled:
218
218
  return
@@ -236,18 +236,18 @@ class Everydeck:
236
236
 
237
237
  _signal.signal(sig, handler)
238
238
 
239
- # ---------------- API (nie blockierend, nie werfend) ----------------
239
+ # ---------------- API (never blocking, never raising) ----------------
240
240
  def signal(self, name: str, operation_id: str, *, attempt_id: str | None = None, subject_ref: str | None = None,
241
241
  account_ref: str | None = None, fields: dict[str, Any] | None = None, request_id: str | None = None,
242
242
  business_key: str | None = None, object_type: str | None = None, object_ref: str | None = None) -> str | None:
243
- """B32 (0.1.4): `object_type`/`object_ref` nennen das Objekt des Ergebnisses (wie `objectType`/`objectRef` im TS-SDK)."""
243
+ """`object_type`/`object_ref` name the object of the outcome (like `objectType`/`objectRef` in the TS SDK)."""
244
244
  return self._enq({"kind": "signal", "name": name, "operation_id": str(operation_id),
245
245
  **_opt(attempt_id=attempt_id, subject_ref=subject_ref, account_ref=account_ref, fields=fields,
246
246
  request_id=request_id, business_key=business_key, object_type=object_type, object_ref=object_ref)})
247
247
 
248
248
  def heartbeat(self, interval_seconds: float, *, job: str | None = None) -> str | None:
249
- """Lebenszeichen auf Abruf. Mit `job` (1.9-r0, `jobs[].id` im Manifest): dieser Job ist gelaufen – am Ende jedes erfolgreichen Laufs
250
- senden; everydeck meldet einen Job, der zwei Takte schweigt. Ohne `job`: diese Prozessinstanz läuft (wie `heartbeat_interval`)."""
249
+ """Heartbeat on demand. With `job` (1.9-r0, `jobs[].id` in the manifest): this job has run; send it at the end of every successful run;
250
+ everydeck reports a job that stays silent for two intervals. Without `job`: this process instance is running (like `heartbeat_interval`)."""
251
251
  import re as _re
252
252
  if job is not None and not _re.fullmatch(r"[a-z][a-z0-9_.-]{0,63}", job):
253
253
  _log.warning("everydeck: heartbeat job id rejected: %r", job)
@@ -262,12 +262,12 @@ class Everydeck:
262
262
  fields: dict[str, Any] | None = None, http: dict[str, Any] | None = None, request_id: str | None = None,
263
263
  frames: list[dict[str, Any]] | None = None, exc: BaseException | None = None, object_type: str | None = None,
264
264
  object_ref: str | None = None, response: dict[str, Any] | None = None) -> str | None:
265
- """B31: mit `exc` (die abgefangene Ausnahme) bildet das SDK die Stack-Frames selbst – innerste zuerst, höchstens 20,
266
- Datei als die letzten zwei Pfadteile wie im Browser-SDK; `frames` übergibt sie fertig ({function, file?, line?})."""
265
+ """With `exc` (the caught exception) the SDK builds the stack frames itself: innermost first, at most 20,
266
+ file as the last two path parts as in the browser SDK; `frames` passes them ready-made ({function, file?, line?})."""
267
267
  err: dict[str, Any] = {"class": error_class}
268
268
  if message:
269
269
  err["message"] = str(message)[:1024]
270
- # 1.4-r0 (B33b, B34b): Fehlercode und Meldung aus der Antwort, die die App bekam – wie im TS-SDK (Code nur als Kennung, Meldung bereinigt, 300 Zeichen)
270
+ # 1.4-r0: error code and message from the response the app received, as in the TS SDK (code only as an identifier, message scrubbed, 300 characters)
271
271
  if response:
272
272
  ans: dict[str, Any] = {}
273
273
  code = response.get("code"); msg = response.get("message")
@@ -279,7 +279,7 @@ class Everydeck:
279
279
  err["response"] = ans
280
280
  fr = frames if frames is not None else (_frames_of(exc) if exc is not None else None)
281
281
  if fr:
282
- keep_abs = frames is None and self.source_context # der volle Pfad bleibt nur bis zum Sender-Thread (Code-Zeilen), er wird nie gesendet
282
+ keep_abs = frames is None and self.source_context # the full path only lives until the sender thread (code lines); it is never sent
283
283
  err["frames"] = [{"function": str(f.get("function") or "anonymous")[:128], **({"file": str(f["file"])[:240]} if f.get("file") else {}), **({"line": int(f["line"])} if f.get("line") else {}),
284
284
  **({"in_app": bool(f["in_app"])} if isinstance(f.get("in_app"), bool) else {}), **({"_abs": f["_abs"]} if keep_abs and f.get("_abs") else {})} for f in fr[:20]]
285
285
  if not self.source_context:
@@ -296,8 +296,8 @@ class Everydeck:
296
296
  def identify(self, subject_ref: str, *, account_ref: str | None = None, display_name: str | None = None,
297
297
  email: str | None = None, login_method: str | None = None, provider_user_id: str | None = None,
298
298
  org_name: str | None = None) -> str | None:
299
- """Identitätsquelle (Manifest 1.1 `people`): freigegebene Stammdaten einer Person. everydeck speichert nur die im freigegebenen
300
- Manifest genannten Felder; alle anderen werden verworfen und gezählt. Kein Vorgang, keine Regel. Nur serverseitig sinnvoll."""
299
+ """Identity source (manifest 1.1 `people`): approved master data of a person. everydeck stores only the fields named in the approved
300
+ manifest; all others are discarded and counted. No operation, no rule. Only meaningful on the server side."""
301
301
  fields: dict[str, Any] = {k: str(v)[:256] for k, v in {"display_name": display_name, "email": email, "login_method": login_method,
302
302
  "provider_user_id": provider_user_id, "org_name": org_name}.items() if v is not None}
303
303
  self.stats["identities"] = self.stats.get("identities", 0) + 1
@@ -310,7 +310,7 @@ class Everydeck:
310
310
  **_opt(fields=fields, operation_id=operation_id, request_id=request_id, object_type=object_type, object_ref=object_ref)})
311
311
 
312
312
  def flush(self, timeout: float | None = None) -> bool:
313
- """Wartet, bis der Puffer geleert ist. False bei Ablauf des Budgets (z. B. everydeck nicht erreichbar)."""
313
+ """Waits until the buffer is empty. False when the budget runs out (e.g. everydeck not reachable)."""
314
314
  if not self.enabled:
315
315
  return True
316
316
  deadline = None if timeout is None else time.monotonic() + timeout
@@ -321,7 +321,7 @@ class Everydeck:
321
321
  return True
322
322
  if deadline is not None and time.monotonic() >= deadline:
323
323
  return False
324
- self._paused_until = 0.0 # ein expliziter Flush hebt die Auth-Pause auf (ein Versuch)
324
+ self._paused_until = 0.0 # an explicit flush lifts the auth pause (one attempt)
325
325
  self._wake.set()
326
326
  time.sleep(0.02)
327
327
 
@@ -336,7 +336,7 @@ class Everydeck:
336
336
  self._spool_wake.set()
337
337
  st = getattr(self, "_st", None)
338
338
  if st is not None and st is not threading.current_thread():
339
- st.join(max(0.0, deadline - time.monotonic())) # ausstehende Spool-Bestätigungen noch anwenden (begrenzt)
339
+ st.join(max(0.0, deadline - time.monotonic())) # still apply pending spool acknowledgements (bounded)
340
340
  return ok
341
341
 
342
342
  # ---------------- Enqueue ----------------
@@ -354,8 +354,8 @@ class Everydeck:
354
354
  spool = bool(self.spool_path) and not self._spool_disabled
355
355
  spool_dropped = 0
356
356
  if spool:
357
- # Reihenfolge ist Absicht: erst Schreibauftrag, dann Sendepuffer. Der Sender kann ein Ereignis erst nach diesem
358
- # Schritt sehen; der Spool-Thread sieht deshalb eine Bestätigung nie ohne den zugehörigen Schreibauftrag.
357
+ # The order is intentional: write job first, then send buffer. The sender can only see an event after this
358
+ # step; the spool thread therefore never sees an acknowledgement without its write job.
359
359
  with self._spool_q_lock:
360
360
  if len(self._spool_q) >= self.max_spool_queue:
361
361
  self._spool_q.popleft()
@@ -375,11 +375,11 @@ class Everydeck:
375
375
  if wake:
376
376
  self._wake.set()
377
377
  return ev["event_id"]
378
- except Exception as e: # Kundenrequest darf nie scheitern
378
+ except Exception as e: # a customer request must never fail
379
379
  self._warn(f"everydeck: enqueue failed: {e!r}")
380
380
  return None
381
381
 
382
- # ---------------- Sender-Thread ----------------
382
+ # ---------------- Sender thread ----------------
383
383
  def _run(self) -> None:
384
384
  while True:
385
385
  try:
@@ -390,13 +390,13 @@ class Everydeck:
390
390
  self._send_pending()
391
391
  if self._stop.is_set() and not self._q:
392
392
  break
393
- except Exception as e: # Thread endet nie unbemerkt
393
+ except Exception as e: # the thread never ends silently
394
394
  self.stats["sender_errors"] += 1
395
395
  self._warn(f"everydeck: sender error (continuing): {e!r}")
396
396
  time.sleep(full_jitter(self._consecutive_failures, self.max_send_backoff))
397
397
 
398
398
  def _maybe_heartbeat(self) -> None:
399
- """Lebenszeichen im Sender-Thread; läuft über denselben Puffer wie alle Ereignisse (kein Sonderweg, keine Garantie)."""
399
+ """Heartbeat in the sender thread; goes through the same buffer as all events (no special path, no guarantee)."""
400
400
  if not self.heartbeat_interval or self._stop.is_set():
401
401
  return
402
402
  now = time.monotonic()
@@ -438,8 +438,8 @@ class Everydeck:
438
438
  return self._source_cache.get(path)
439
439
 
440
440
  def _with_source_context(self, ev: dict[str, Any]) -> dict[str, Any]:
441
- """B33: Code-Zeilen zu höchstens 5 Frames im Code der App; das Ereignis bleibt unter EVENT_MAX_BYTES (was nicht passt: partial).
442
- Der volle Pfad (`_abs`) verlässt das SDK nie. Kopie – das Original im Puffer und im Spool bleibt unverändert."""
441
+ """Code lines for at most 5 frames in the app's code; the event stays below EVENT_MAX_BYTES (what does not fit: partial).
442
+ The full path (`_abs`) never leaves the SDK. Works on a copy; the original in the buffer and in the spool stays unchanged."""
443
443
  err = ev.get("error") if isinstance(ev.get("error"), dict) else None
444
444
  if not err or not err.get("frames"):
445
445
  return ev
@@ -484,7 +484,7 @@ class Everydeck:
484
484
  return
485
485
  items = self._take_batch()
486
486
  self._inflight = True
487
- items = [self._with_source_context(it) for it in items] # B33: Datei-IO außerhalb der Sperre, auf Kopien (der Spool-Thread liest die Originale)
487
+ items = [self._with_source_context(it) for it in items] # file IO outside the lock, on copies (the spool thread reads the originals)
488
488
  try:
489
489
  outcome, detail = self._post(items)
490
490
  except Exception as e:
@@ -494,7 +494,7 @@ class Everydeck:
494
494
  ack: set[str] | None = None
495
495
  warn: str | None = None
496
496
  stop_after = False
497
- with self._lock: # nur Zähler und Zustand; Datei-IO und Callbacks laufen außerhalb dieser Sperre
497
+ with self._lock: # counters and state only; file IO and callbacks run outside this lock
498
498
  if outcome == "accepted":
499
499
  self.stats["accepted"] += len(items)
500
500
  self.stats["batches_accepted"] += 1
@@ -504,7 +504,7 @@ class Everydeck:
504
504
  self.stats["batches_rejected"] += 1
505
505
  self.stats["dropped_rejected"] += len(items)
506
506
  self.stats["last_rejection"] = detail
507
- ack = {i["event_id"] for i in items} # kein erneuter Versuch, auch nicht nach Neustart
507
+ ack = {i["event_id"] for i in items} # no retry, not even after a restart
508
508
  code = int(detail.get("status", 0)) if isinstance(detail, dict) else 0
509
509
  if code in _AUTH:
510
510
  self.stats["auth_failures"] += 1
@@ -518,7 +518,7 @@ class Everydeck:
518
518
  self.stats["retries"] += 1
519
519
  self._consecutive_failures += 1
520
520
  self._requeue(items)
521
- # B30 Schnitt D: Retry-After von everydeck (429 Budget/Kontingent, 503 Schutz) wird befolgt, sonst Backoff mit vollem Zufallsanteil
521
+ # Retry-After from everydeck (429 budget/quota, 503 protection) is honored, otherwise backoff with full jitter
522
522
  retry_after = detail.get("retry_after") if isinstance(detail, dict) else None
523
523
  if retry_after is not None:
524
524
  self.stats["retry_after_waits"] += 1
@@ -527,7 +527,7 @@ class Everydeck:
527
527
  self._paused_until = time.monotonic() + full_jitter(self._consecutive_failures, self.max_send_backoff)
528
528
  stop_after = True
529
529
  if ack:
530
- self._spool_enqueue_ack(ack) # vor dem Ende von inflight: flush() sieht erst danach „leer“, close() wartet auf den Spool
530
+ self._spool_enqueue_ack(ack) # before inflight ends: flush() only sees "empty" afterwards, close() waits for the spool
531
531
  with self._lock:
532
532
  self._inflight = False
533
533
  if warn:
@@ -536,12 +536,12 @@ class Everydeck:
536
536
  return
537
537
 
538
538
  def _post(self, items: list[dict[str, Any]]) -> tuple[Outcome, Any]:
539
- """Genau ein Zustellversuch. Wiederholungen steuert der Sender (Backoff bis max_send_backoff)."""
539
+ """Exactly one delivery attempt. The sender controls retries (backoff up to max_send_backoff)."""
540
540
  body = _dumps({"schema_version": SCHEMA_VERSION, "batch_id": str(uuid.uuid4()), "release_key": self.release_key,
541
541
  "manifest_digest": self.manifest_digest, **({"probe_id": self.probe_id} if self.probe_id else {}), "events": items}).encode()
542
542
  headers = {"Content-Type": "application/json", "Authorization": "Bearer " + self.credential}
543
543
  self.stats["bytes_raw"] += len(body)
544
- if self.compress: # B30 Schnitt D
544
+ if self.compress:
545
545
  body = gzip.compress(body, compresslevel=6, mtime=0)
546
546
  headers["Content-Encoding"] = "gzip"
547
547
  self.stats["batches_compressed"] += 1
@@ -563,9 +563,9 @@ class Everydeck:
563
563
  except Exception as e:
564
564
  return "retry", {"error": repr(e)[:200]}
565
565
 
566
- # ---------------- Spool-Thread (dauerhafter Hinweis) ----------------
566
+ # ---------------- Spool thread (durable record) ----------------
567
567
  def _spool_open(self, mode: str):
568
- return open(self.spool_path, mode, encoding="utf-8") # separat, damit Tests Fehler injizieren können
568
+ return open(self.spool_path, mode, encoding="utf-8") # separate so tests can inject errors
569
569
 
570
570
  def _spool_run(self) -> None:
571
571
  while True:
@@ -583,7 +583,7 @@ class Everydeck:
583
583
  self._spool_fail(e)
584
584
 
585
585
  def _spool_enqueue_ack(self, ids: set[str]) -> None:
586
- """Vom Sender: Ereignisse sind endgültig erledigt (angenommen oder abgewiesen). Nur Warteschlange, kein Datei-IO."""
586
+ """From the sender: events are finally done (accepted or rejected). Queue only, no file IO."""
587
587
  if not self.spool_path or self._spool_disabled:
588
588
  return
589
589
  over = 0
@@ -592,7 +592,7 @@ class Everydeck:
592
592
  self._spool_acks[i] = None
593
593
  over = len(self._spool_acks) - self.max_spool_queue
594
594
  if over > 0:
595
- for k in list(self._spool_acks)[:over]: # älteste zuerst; die Schreibaufträge dazu sind ebenfalls die ältesten
595
+ for k in list(self._spool_acks)[:over]: # oldest first; their write jobs are the oldest as well
596
596
  del self._spool_acks[k]
597
597
  self.stats["spool_ack_dropped"] += over
598
598
  if over > 0 and not self._spool_ack_overflow_warned:
@@ -601,7 +601,7 @@ class Everydeck:
601
601
  self._spool_wake.set()
602
602
 
603
603
  def _spool_cycle(self) -> None:
604
- """Ein Durchlauf: Schreibaufträge und Bestätigungen gemeinsam betrachten. Bestätigt und noch nicht geschrieben → gar nicht schreiben."""
604
+ """One pass: look at write jobs and acknowledgements together. Acknowledged and not yet written → do not write at all."""
605
605
  with self._spool_q_lock:
606
606
  writes = list(self._spool_q)
607
607
  self._spool_q.clear()
@@ -617,12 +617,12 @@ class Everydeck:
617
617
  if writes:
618
618
  self._spool_write(writes)
619
619
  if acks:
620
- self._spool_ack(acks) # Bestätigungen ohne Zeile (Nachlieferungs-/Lückenereignisse, verworfene Schreibaufträge) verfallen hier
620
+ self._spool_ack(acks) # acknowledgements without a line (replay/gap events, discarded write jobs) expire here
621
621
 
622
622
  def _spool_write(self, batch: list[dict[str, Any]]) -> None:
623
623
  with self._spool_lock:
624
- # Bis zum Limit auffüllen (ganze Zeilen), den Rest verwerfen und den Spool schließen – unabhängig davon, wie viele
625
- # Ereignisse ein Zyklus zufällig einsammelt.
624
+ # Fill up to the limit (whole lines), discard the rest and close the spool, regardless of how many
625
+ # events a cycle happens to collect.
626
626
  lines: list[str] = []
627
627
  size = 0
628
628
  dropped = 0
@@ -659,7 +659,7 @@ class Everydeck:
659
659
  self._warn(f"everydeck: spool unavailable ({e!r}); spool disabled, delivery continues in memory only")
660
660
 
661
661
  def _spool_ack(self, ids: set[str]) -> None:
662
- """Nur Spool-Thread: bestätigte Zeilen entfernen (atomares Zurückschreiben)."""
662
+ """Spool thread only: remove acknowledged lines (atomic rewrite)."""
663
663
  if not self.spool_path or self._spool_disabled:
664
664
  return
665
665
  try:
@@ -674,7 +674,7 @@ class Everydeck:
674
674
  f.writelines(keep)
675
675
  f.flush()
676
676
  os.fsync(f.fileno())
677
- os.replace(tmp, self.spool_path) # atomar: kein halb geschriebener Spool
677
+ os.replace(tmp, self.spool_path) # atomic: no half-written spool
678
678
  self._spool_bytes = sum(len(l.encode("utf-8")) for l in keep)
679
679
  except Exception as e:
680
680
  self._spool_fail(e)
@@ -705,7 +705,7 @@ class Everydeck:
705
705
  self.stats["replayed_from_spool"] += 1
706
706
  self._q.append({"event_id": str(uuid.uuid4()), "occurred_at": _now(), "kind": "gap", "name": "everydeck.sdk.recovered",
707
707
  "gap": {"cause": "restart_recovered", "replayed": len(replay), "dropped": dropped}})
708
- with self._spool_lock: # Spool auf die nachgelieferten, gültigen Zeilen zurückschreiben
708
+ with self._spool_lock: # rewrite the spool to the replayed, valid lines
709
709
  tmp = self.spool_path + ".tmp"
710
710
  with open(tmp, "w", encoding="utf-8") as f:
711
711
  f.writelines(_dumps(ev) + "\n" for ev in replay)
@@ -731,7 +731,7 @@ def _safe_id(line: str) -> str | None:
731
731
 
732
732
 
733
733
  class AfterCommit:
734
- """Ergebnisbeleg = Commit (P1-Befund): Signale erst nach erfolgreichem Commit senden."""
734
+ """Outcome evidence = commit: send signals only after a successful commit."""
735
735
 
736
736
  def __init__(self) -> None:
737
737
  self._fns: list[Callable[[], Any]] = []
@@ -755,8 +755,8 @@ _ANSWER_CODE = re.compile(r"^[A-Za-z0-9_.:-]{1,64}$")
755
755
 
756
756
 
757
757
  def answer_of(exc: BaseException | None) -> dict[str, Any] | None:
758
- """B34b: Fehlercode und Meldung, die eine Ausnahme als eigene Attribute trägt (z. B. postgrest APIError: `code`, `message`) – für
759
- `error(..., response=answer_of(exc))`. Nur Attribute, kein Lesen im Text der Ausnahme; ohne beide Attribute None."""
758
+ """Error code and message an exception carries as its own attributes (e.g. postgrest APIError: `code`, `message`), for
759
+ `error(..., response=answer_of(exc))`. Attributes only, no parsing of the exception text; None without both attributes."""
760
760
  if exc is None:
761
761
  return None
762
762
  code = getattr(exc, "code", None); msg = getattr(exc, "message", None)
@@ -765,7 +765,7 @@ def answer_of(exc: BaseException | None) -> dict[str, Any] | None:
765
765
 
766
766
 
767
767
  def error_class_of(exc: BaseException | type | str) -> str:
768
- """B31: stabile Fehlerklasse aus dem Namen einer Ausnahme – Abkürzungen bleiben zusammen: APIError → api_error, HTTPException → http_exception, TypeError → type_error."""
768
+ """Stable error class from an exception name; acronyms stay together: APIError → api_error, HTTPException → http_exception, TypeError → type_error."""
769
769
  import re as _re
770
770
  name = exc if isinstance(exc, str) else (exc.__name__ if isinstance(exc, type) else type(exc).__name__)
771
771
  s = _re.sub(r"([A-Z]+)([A-Z][a-z])", r"\1_\2", name); s = _re.sub(r"([a-z0-9])([A-Z])", r"\1_\2", s)
@@ -775,7 +775,7 @@ def error_class_of(exc: BaseException | type | str) -> str:
775
775
 
776
776
 
777
777
  def _library_roots() -> tuple[str, ...]:
778
- """Standardbibliothek und installierte Pakete – kein Code der App (B33)"""
778
+ """Standard library and installed packages: not the app's code"""
779
779
  import sysconfig
780
780
  roots = {sysconfig.get_paths().get(k) for k in ("stdlib", "platstdlib", "purelib", "platlib")}
781
781
  return tuple(os.path.realpath(r) + os.sep for r in roots if r)
@@ -796,8 +796,8 @@ def _in_app(path: str) -> bool:
796
796
 
797
797
 
798
798
  def _frames_of(exc: BaseException) -> list[dict[str, Any]]:
799
- """Stack-Frames einer Ausnahme, innerste zuerst (wie ein JS-Stack), Datei = die letzten zwei Pfadteile; `in_app` und der volle Pfad
800
- (`_abs`, nur intern für die Code-Zeilen, B33)"""
799
+ """Stack frames of an exception, innermost first (like a JS stack), file = the last two path parts; `in_app` and the full path
800
+ (`_abs`, internal only, for the code lines)"""
801
801
  import traceback
802
802
  out: list[dict[str, Any]] = []
803
803
  for fs in reversed(traceback.extract_tb(exc.__traceback__)):
@@ -808,7 +808,7 @@ def _frames_of(exc: BaseException) -> list[dict[str, Any]]:
808
808
  return out
809
809
 
810
810
 
811
- # ── B33: Code-Zeilen und Bereinigung – dieselbe Regel wie @everydeck/sdk (TS) und everydeck (packages/contracts scrubText); die Paritätsprüfung vergleicht sie ──
811
+ # ── Code lines and scrubbing: the same rule as @everydeck/sdk (TS) and everydeck's scrubText; a parity check compares them ──
812
812
  CONTEXT_FRAMES = 5
813
813
  CONTEXT_LINES = 5
814
814
  CONTEXT_LINE_MAX = 200
@@ -827,7 +827,7 @@ _EMAIL = re.compile(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}")
827
827
 
828
828
 
829
829
  def scrub_text(text: str) -> str:
830
- """Geheimnisse, Bearer-Token und E-Mail-Adressen entfernen (Fehlermeldung oder Code-Zeile)"""
830
+ """Remove secrets, bearer tokens and e-mail addresses (error message or code line)"""
831
831
  out = _BEARER.sub("Bearer [redacted]", str(text))
832
832
  for rx in _SECRET_PATTERNS:
833
833
  out = rx.sub("[redacted]", out)
@@ -836,7 +836,7 @@ def scrub_text(text: str) -> str:
836
836
 
837
837
 
838
838
  def _context_of(lines: list[str], line: int) -> dict[str, Any] | None:
839
- """Fundzeile und je CONTEXT_LINES davor und danach; None, wenn die Zeile nicht in der Datei liegt (nichts raten)"""
839
+ """The error line and CONTEXT_LINES before and after; None when the line is not in the file (no guessing)"""
840
840
  if line < 1 or line > len(lines):
841
841
  return None
842
842
  cut = lambda x: scrub_text(x[:CONTEXT_LINE_MAX])[:CONTEXT_LINE_MAX]
@@ -1,8 +1,8 @@
1
- """Die allgemeine Lese-Brücke (B29 Abnahme, docs/04 „Lese-Bridge“) – Python-Fassung des TypeScript-SDKs (read-bridge.ts), gleiches Verhalten.
1
+ """The general read bridge: Python version of the TypeScript SDK (read-bridge.ts), same behavior.
2
2
 
3
- Ein Endpunkt je deklarierter Tabelle, nur deklarierte Spalten, mit Cursor. Der Agent schreibt keine Brücke je App: er holt den Lese-Umfang
4
- (`everydeck/everydeck-read-scope.json`, aus dem Befund abgeleitet) und gibt eine Funktion `rows(table, columns, keys, after, limit)` mit, die Zeilen aus
5
- der Datenbank der App liest – über eine eigene Leserolle (nur SELECT). `psycopg_rows(conn)` ist die fertige Fassung für psycopg (2 oder 3).
3
+ One endpoint per declared table, only declared columns, with a cursor. The agent writes no bridge per app: it takes the read scope
4
+ (`everydeck/everydeck-read-scope.json`, derived from the app map) and passes a function `rows(table, columns, keys, after, limit)` that reads rows from
5
+ the app's database through a dedicated reader role (SELECT only). `psycopg_rows(conn)` is the ready-made version for psycopg (2 or 3).
6
6
 
7
7
  bridge = ReadBridge(scope=json.load(open("everydeck/everydeck-read-scope.json")), token=os.environ["EVERYDECK_READ_BRIDGE_TOKEN"], rows=psycopg_rows(conn))
8
8
  # FastAPI: @app.api_route("/everydeck/read/{rest:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"])
@@ -10,9 +10,9 @@ der Datenbank der App liest – über eine eigene Leserolle (nur SELECT). `psyco
10
10
  # status, body = bridge.handle(request.method, request.url.path, dict(request.query_params), request.headers.get("authorization"))
11
11
  # return JSONResponse(body, status_code=status)
12
12
 
13
- Vertrag: GET /everydeck/read/capabilities · GET /everydeck/read/objects/{type}?cursor&limit · GET /everydeck/read/people?cursor&limit; eigener Bearer-Schlüssel;
14
- jedes Schreibverb wird abgewiesen; Seiten ≤ 200; Kennungen als Text (mehrere Schlüsselspalten mit „|“); Zeitstempel ISO-8601; Objektwerte
15
- als JSON-Text; kein Wert länger als 256 Zeichen. Beziehungen werden wie Objektarten geliefert (type = relations[].id).
13
+ Contract: GET /everydeck/read/capabilities · GET /everydeck/read/objects/{type}?cursor&limit · GET /everydeck/read/people?cursor&limit; own Bearer key;
14
+ every write verb is rejected; pages ≤ 200; identifiers as text (several key columns joined with "|"); timestamps ISO-8601; object values
15
+ as JSON text; no value longer than 256 characters. Relations are delivered like object types (type = relations[].id).
16
16
  """
17
17
  from __future__ import annotations
18
18
 
@@ -28,7 +28,7 @@ from typing import Any, Callable, Iterable, Mapping, Sequence
28
28
 
29
29
  PAGE_MAX = 200
30
30
  VALUE_MAX = 256
31
- # rows(table, columns, keys, after, limit[, changed]) – changed = {"column", "since"}: nur Geändertes, stabil nach (Änderungsspalte, Schlüssel); Cursor dann [Änderungszeit, …Schlüssel] (B30 Schnitt A)
31
+ # rows(table, columns, keys, after, limit[, changed]); changed = {"column", "since"}: only changed rows, stable by (change column, keys); cursor is then [change time, …keys]
32
32
  Rows = Callable[..., Iterable[Mapping[str, Any]]]
33
33
 
34
34
 
@@ -84,7 +84,7 @@ GRANT_MAX_SECONDS = 300
84
84
 
85
85
 
86
86
  def sign_everydeck_grant(keys: Iterable[str], grant: Mapping[str, Any]) -> dict[str, str]:
87
- """Gegenstück (everydeck und Tests): Query-Parameter einer Freigabe – grant = base64url(JSON), grant_sig = v1=<hex> je Schlüssel."""
87
+ """Counterpart (everydeck and tests): query parameters of a grant; grant = base64url(JSON), grant_sig = v1=<hex> per key."""
88
88
  g = base64.urlsafe_b64encode(json.dumps(grant, separators=(",", ":")).encode()).decode().rstrip("=")
89
89
  sigs = []
90
90
  for k in keys:
@@ -100,8 +100,8 @@ def _is_name(x: Any) -> bool:
100
100
 
101
101
 
102
102
  def grant_of(keys: Iterable[str], query: Mapping[str, str] | None, typ: str, ref: str) -> dict[str, Any] | str:
103
- """B46 Punkt 3: Freigabe prüfen – Signatur mit einem gültigen Schlüssel, genau dieses Gespräch, Grund, nicht abgelaufen, Nachrichtentabelle vollständig.
104
- Gibt das Gespräch ({messages, attachments}) zurück oder den Grund der Ablehnung."""
103
+ """Check a grant: signature with a valid key, exactly this conversation, a reason, not expired, message table complete.
104
+ Returns the conversation ({messages, attachments}) or the reason for rejection."""
105
105
  q = query or {}; g = q.get("grant"); sig = q.get("grant_sig")
106
106
  if not g or not sig:
107
107
  return "grant missing"
@@ -131,12 +131,12 @@ class ReadBridge:
131
131
  deleted: Callable[[str, Sequence[str], str, int], Sequence[Mapping[str, Any]]] | None = None,
132
132
  signatures: str = "required", keys: Sequence[str] | None = None, seen: Callable[[str, float], bool] | None = None,
133
133
  match: Callable[..., Iterable[Mapping[str, Any]]] | None = None) -> None:
134
- # B31 Schnitt A: match(table, columns, where_column, values, order, after, limit) → Zeilen eines Gesprächs (psycopg_match); ohne match keine Gespräche
134
+ # match(table, columns, where_column, values, order, after, limit) → rows of one conversation (psycopg_match); without match no conversations
135
135
  self.match = match
136
- # B30 Schnitt A: one(table, columns, keys, ref) → eine Zeile; deleted(table, keys, since, limit) → [{ref, deleted_at}] (Löschungen, die die App kennt)
136
+ # one(table, columns, keys, ref) → one row; deleted(table, keys, since, limit) → [{ref, deleted_at}] (deletions the app knows about)
137
137
  self.one = one; self.deleted = deleted
138
- # B30 Schnitt E: everydeck signiert jeden Aufruf – "required" (Standard) lehnt unsignierte ab, "optional" nimmt den Bearer ohne Signatur (ältere everydeck-Stände), "off" prüft nur den Bearer;
139
- # keys = gültige Schlüssel (Standard: token; während eines Tauschs der neue und der alte), seen = Replay-Schutz über mehrere Instanzen
138
+ # everydeck signs every call: "required" (default) rejects unsigned calls, "optional" accepts the Bearer without a signature (everydeck instances that do not sign yet), "off" checks only the Bearer;
139
+ # keys = valid keys (default: token; during a rotation the new and the old one), seen = replay protection across several instances
140
140
  if signatures not in ("required", "optional", "off"): raise ValueError("signatures must be required, optional or off")
141
141
  self.signatures = signatures; self.keys = list(keys) if keys else [token]; self.seen = seen
142
142
  self.scope = scope
@@ -144,7 +144,7 @@ class ReadBridge:
144
144
  self.rows = rows
145
145
  self.app = app
146
146
  self.page_limit = min(PAGE_MAX, max(1, page_limit))
147
- self._rights = rights # fest, oder eine Funktion, die beim ersten Aufruf prüft (psycopg_rights) – nie behauptet
147
+ self._rights = rights # fixed, or a function that checks on the first call (psycopg_rights); never just claimed
148
148
  self._rights_cache: dict[str, Any] | None = None
149
149
  self.types: dict[str, dict[str, Any]] = {}
150
150
  for o in scope.get("objects", []):
@@ -161,7 +161,7 @@ class ReadBridge:
161
161
  return self._rights_cache
162
162
 
163
163
  def capabilities(self) -> dict[str, Any]:
164
- # B30 Schnitt A: je Objektart, ob changed_since geht (Änderungsspalte im Umfang), und ob der Einzelabruf geht
164
+ # per object type whether changed_since works (change column in the scope), and whether fetching one object works
165
165
  objects = [{"type": o["type"], "fields": [f["name"] for f in o["fields"]], **({"statuses": list(o["statuses"])} if o.get("statuses") else {}), "changed_since": bool(o.get("changed"))} for o in self.scope.get("objects", [])]
166
166
  objects += [{"type": r["id"], "fields": [f["name"] for f in r["fields"]], "changed_since": False} for r in self.scope.get("relations", [])]
167
167
  people = self.scope.get("people")
@@ -179,7 +179,7 @@ class ReadBridge:
179
179
 
180
180
  def handle(self, method: str, path: str, query: Mapping[str, str] | None = None, authorization: str | None = None,
181
181
  headers: Mapping[str, str] | None = None, body: bytes | str | None = None, url: str | None = None) -> tuple[int, Any]:
182
- # headers/url/body (B30 Schnitt E): Kopfzeilen und Pfad mit Query, wie die App sie sieht – für die Signaturprüfung; FastAPI: request.headers, str(request.url), await request.body()
182
+ # headers/url/body: headers and path with query as the app sees them, for the signature check; FastAPI: request.headers, str(request.url), await request.body()
183
183
  full = url if url is not None else (path + ("?" + "&".join(f"{k}={v}" for k, v in (query or {}).items()) if query else ""))
184
184
  path = path.split("?", 1)[0]
185
185
  if not path.startswith("/everydeck/read/"):
@@ -192,8 +192,8 @@ class ReadBridge:
192
192
  if not v.ok:
193
193
  return 401, {"error": f"signature rejected: {v.reason}"}
194
194
  elif self.signatures == "required":
195
- return 401, {"error": "signature required (X-Everydeck-Signature missing) – everydeck signs every call since B30; use signatures=\"optional\" for older everydeck instances"}
196
- elif not hmac.compare_digest((authorization or "").encode(), f"Bearer {self.token}".encode()): # zeitkonstant
195
+ return 401, {"error": "signature required (X-Everydeck-Signature missing): everydeck signs every call; use signatures=\"optional\" only for everydeck instances that do not sign calls yet"}
196
+ elif not hmac.compare_digest((authorization or "").encode(), f"Bearer {self.token}".encode()): # constant time
197
197
  return 401, {"error": "read key invalid"}
198
198
  if method.upper() != "GET":
199
199
  return 405, {"error": "read only"}
@@ -212,7 +212,7 @@ class ReadBridge:
212
212
  rows = list(self.rows(p["table"], cols, [p["ref"]], after, limit + 1))
213
213
  kept = rows[:limit]
214
214
 
215
- def name_of(r): # B35 Punkt 3: Anzeigename nach der Regel der App – erste Stufe mit Inhalt, Teile mit dem Trennzeichen der App (wie read-bridge.ts)
215
+ def name_of(r): # display name by the app's rule: first step with content, parts joined with the app's separator (as in read-bridge.ts)
216
216
  for st in steps:
217
217
  parts = [x for x in ("" if r.get(c) is None else _text(r[c]).strip() for c in st["columns"]) if x]
218
218
  if parts:
@@ -220,11 +220,11 @@ class ReadBridge:
220
220
  return None
221
221
  items = [{"subject_ref": _text(r[p["ref"]])[:128], **{f: (None if r.get(c) is None else _text(r[c])[:VALUE_MAX]) for f, c in p["fields"].items()}, **({"display_name": name_of(r)} if steps and "display_name" in p["fields"] else {})} for r in kept]
222
222
  return 200, {"as_of": as_of, "items": items, "next_cursor": _b64([_text(kept[-1][p["ref"]])]) if len(rows) > limit else None}
223
- if path.startswith("/everydeck/read/messages/") and path[len("/everydeck/read/messages/"):].count("/") == 1: # B31 Schnitt A: Nachrichten eines Gesprächs, nur auf Abruf
223
+ if path.startswith("/everydeck/read/messages/") and path[len("/everydeck/read/messages/"):].count("/") == 1: # messages of one conversation, on demand only
224
224
  typ, ref = path[len("/everydeck/read/messages/"):].split("/", 1)
225
225
  if self.match is None:
226
226
  return 404, {"error": "this bridge reads no conversations"}
227
- # B46 Punkt 3: nur mit der Freigabe von everydeck für genau dieses Gespräch (geöffnet mit Grund); Tabelle und Spalten stehen nur in ihr
227
+ # only with everydeck's grant for exactly this conversation (opened with a reason); table and columns are only in the grant
228
228
  conv = grant_of(self.keys, query, typ, ref)
229
229
  if isinstance(conv, str):
230
230
  return 403, {"error": f"a conversation is read only when a moderator opens it with a reason in everydeck ({conv})"}
@@ -244,7 +244,7 @@ class ReadBridge:
244
244
  "text": "\n".join(_text(r[c]) for c in m["text"] if r.get(c) is not None)[:4000], **({"attachments": att[ids[i]]} if att.get(ids[i]) else {})} for i, r in enumerate(kept)]
245
245
  return 200, {"type": typ, "ref": ref[:128], "as_of": as_of, "items": items, "next_cursor": _b64([_iso(kept[-1][m["time"]]) or "", *(_text(kept[-1][k]) for k in m["ref"])]) if len(rows) > limit else None}
246
246
  rest = path[len("/everydeck/read/objects/"):] if path.startswith("/everydeck/read/objects/") else None
247
- if rest is not None and rest.count("/") == 1: # B30 Schnitt A: Einzelabruf GET /everydeck/read/objects/{type}/{ref}
247
+ if rest is not None and rest.count("/") == 1: # fetch one object: GET /everydeck/read/objects/{type}/{ref}
248
248
  typ, ref_raw = rest.split("/", 1)
249
249
  x = self.types.get(typ)
250
250
  if not x:
@@ -267,7 +267,7 @@ class ReadBridge:
267
267
  if bad:
268
268
  return 400, {"error": "bad cursor"}
269
269
  since_raw = (query or {}).get("changed_since")
270
- if since_raw is not None: # B30 Schnitt A: nur Geändertes seit since, stabil nach (Änderungsspalte, Schlüssel); dazu Gelöschtes, wo die App es kennt
270
+ if since_raw is not None: # only rows changed since `since`, stable by (change column, keys); plus deletions where the app knows them
271
271
  if not x["changed"]:
272
272
  return 400, {"error": f"changed_since is not supported for {typ}: no column the database sets on every change"}
273
273
  since = _iso(since_raw)
@@ -306,7 +306,7 @@ class ReadBridge:
306
306
 
307
307
 
308
308
  def _iso(v: Any) -> str | None:
309
- """Zeitstempel als ISO-8601 (UTC, Z) oder None"""
309
+ """Timestamp as ISO-8601 (UTC, Z) or None"""
310
310
  if v is None:
311
311
  return None
312
312
  if isinstance(v, _dt.datetime):
@@ -325,18 +325,18 @@ def _ident(name: str) -> str:
325
325
 
326
326
 
327
327
  def read_view_name(table: str) -> str:
328
- """Name der Sicht im Schema der Leserolle (everydeck’s SQL legt sie an): Tabelle, außerhalb von public `<schema>__<tabelle>`."""
328
+ """Name of the view in the reader role's schema (everydeck's SQL creates it): the table name, outside public `<schema>__<table>`."""
329
329
  return table.replace(".", "__", 1) if "." in table else table
330
330
 
331
331
 
332
332
  def psycopg_rows(conn: Any, schema: str | None = None) -> Rows:
333
- """`rows` für psycopg 2/3: Keyset-Cursor über die Schlüsselspalten, nur die genannten Spalten; die Verbindung gehört der Leserolle.
334
- `schema`: die Sichten der Leserolle (z. B. everydeck_read) statt der Tabellen – so greifen die Zeilenrechte der App nicht für den Leser."""
333
+ """`rows` for psycopg 2/3: keyset cursor over the key columns, only the given columns; the connection belongs to the reader role.
334
+ `schema`: the reader role's views (e.g. everydeck_read) instead of the tables, so the app's row-level policies do not apply to the reader."""
335
335
  def rows(table: str, columns: Sequence[str], keys: Sequence[str], after: Sequence[str] | None, limit: int, changed: Mapping[str, str] | None = None) -> list[dict[str, Any]]:
336
336
  src = f"{_ident(schema)}.{_ident(read_view_name(table))}" if schema else _ident(table)
337
337
  lim = f"LIMIT {max(1, min(PAGE_MAX + 1, int(limit)))}"
338
338
  cur = conn.cursor()
339
- if changed: # B30 Schnitt A: nur Geändertes seit since, stabil nach (Änderungsspalte, Schlüssel); Cursor = [Änderungszeit, …Schlüssel]
339
+ if changed: # only rows changed since `since`, stable by (change column, keys); cursor = [change time, …keys]
340
340
  order = [changed["column"], *keys]; params: list[Any] = [changed["since"]]
341
341
  where = f" WHERE {_ident(changed['column'])} >= %s"
342
342
  if after and len(after) == len(order):
@@ -352,7 +352,7 @@ def psycopg_rows(conn: Any, schema: str | None = None) -> Rows:
352
352
 
353
353
 
354
354
  def psycopg_one(conn: Any, schema: str | None = None) -> Callable[[str, Sequence[str], Sequence[str], Sequence[str]], Mapping[str, Any] | None]:
355
- """B30 Schnitt A: `one` für psycopg – genau eine Zeile nach ihren Schlüsselwerten."""
355
+ """`one` for psycopg: exactly one row by its key values."""
356
356
  def one(table: str, columns: Sequence[str], keys: Sequence[str], ref: Sequence[str]) -> Mapping[str, Any] | None:
357
357
  src = f"{_ident(schema)}.{_ident(read_view_name(table))}" if schema else _ident(table)
358
358
  cur = conn.cursor()
@@ -365,7 +365,7 @@ def psycopg_one(conn: Any, schema: str | None = None) -> Callable[[str, Sequence
365
365
 
366
366
 
367
367
  def psycopg_match(conn: Any, schema: str | None = None) -> Callable[..., list[dict[str, Any]]]:
368
- """B31 Schnitt A: `match` für psycopg – Zeilen mit where_column = ANY(values), Keyset-Cursor über order (Nachrichten eines Gesprächs)."""
368
+ """`match` for psycopg: rows with where_column = ANY(values), keyset cursor over order (messages of one conversation)."""
369
369
  def match(table: str, columns: Sequence[str], where_column: str, values: Sequence[str], order: Sequence[str], after: Sequence[str] | None, limit: int) -> list[dict[str, Any]]:
370
370
  src = f"{_ident(schema)}.{_ident(read_view_name(table))}" if schema else _ident(table)
371
371
  params: list[Any] = [list(values)]; cond = f"{_ident(where_column)}::text = ANY(%s)"
@@ -380,7 +380,7 @@ def psycopg_match(conn: Any, schema: str | None = None) -> Callable[..., list[di
380
380
 
381
381
 
382
382
  def scope_tables(scope: Mapping[str, Any]) -> list[str]:
383
- """Alle Tabellen des Umfangs (Objekte, Beziehungen, Gespräche, Personen)."""
383
+ """All tables of the scope (objects, relations, conversations, people)."""
384
384
  out: list[str] = []
385
385
  convs = [t for c in (scope.get("conversations") or []) for t in [c["table"], c["messages"]["table"], *([c["attachments"]["table"]] if c.get("attachments") else [])]]
386
386
  for t in [o["table"] for o in scope.get("objects", [])] + [r["table"] for r in scope.get("relations", [])] + convs + ([scope["people"]["table"]] if scope.get("people") else []):
@@ -397,22 +397,22 @@ EXPOSURE_SQL = """SELECT
397
397
 
398
398
 
399
399
  def psycopg_rights(conn: Any, scope: Mapping[str, Any], view_schema: str | None = None) -> Callable[[], dict[str, Any]]:
400
- """Rechte der Rolle prüfen, nicht behaupten: Superuser (pg_roles.rolsuper) und INSERT/UPDATE/DELETE je Tabelle des Umfangs (has_table_privilege);
401
- mit `view_schema` (Sichten der Leserolle, z. B. everydeck_read) zusätzlich, ob das Schema über die Supabase-API (pgrst.db_schemas) oder für anon/authenticated/PUBLIC erreichbar ist."""
400
+ """Check the role's rights instead of claiming them: superuser (pg_roles.rolsuper) and INSERT/UPDATE/DELETE per scope table (has_table_privilege);
401
+ with `view_schema` (the reader role's views, e.g. everydeck_read) also whether the schema is reachable through the Supabase API (pgrst.db_schemas) or for anon/authenticated/PUBLIC."""
402
402
  def check() -> dict[str, Any]:
403
403
  cur = conn.cursor()
404
404
  cur.execute("SELECT current_user::text, (SELECT rolsuper FROM pg_roles WHERE rolname = current_user)")
405
405
  role, superuser = cur.fetchone()
406
406
  writable = []
407
407
  for t in scope_tables(scope):
408
- # über den Katalog (pg_class), nicht über den Namen: has_table_privilege(name) verlangt USAGE auf dem Schema – eine Leserolle hat das nicht
408
+ # via the catalog (pg_class), not by name: has_table_privilege(name) requires USAGE on the schema, which a reader role does not have
409
409
  schema, name = t.split(".", 1) if "." in t else ("public", t)
410
410
  cur.execute("SELECT (has_table_privilege(current_user, c.oid, 'INSERT') OR has_table_privilege(current_user, c.oid, 'UPDATE') OR has_table_privilege(current_user, c.oid, 'DELETE')) FROM pg_class c JOIN pg_namespace n ON n.oid = c.relnamespace WHERE n.nspname = %s AND c.relname = %s", [schema, name])
411
411
  row = cur.fetchone()
412
412
  if row and row[0]:
413
413
  writable.append(t)
414
414
  out = {"role": role, "superuser": bool(superuser), "writable": writable, "select_only": not superuser and not writable, "checked_at": _dt.datetime.now(_dt.timezone.utc).isoformat().replace("+00:00", "Z")}
415
- if view_schema: # die Sichten laufen als Eigentümer (keine Zeilenrechte der App): niemand außer der Leserolle darf an das Schema
415
+ if view_schema: # the views run as their owner (no app row-level policies): nobody but the reader role may access the schema
416
416
  cur.execute(EXPOSURE_SQL, {"s": view_schema})
417
417
  api, anon, authenticated, pub = cur.fetchone()
418
418
  out["exposed"] = [n for n, v in (("supabase api", api), ("anon", anon), ("authenticated", authenticated), ("public", pub)) if v]
@@ -1,8 +1,8 @@
1
- """B46 (Entscheidung E2): die Lese-Brücke als fertige Route – Python-Fassung von @everydeck/sdk/read-bridge, gleiches Verhalten.
1
+ """The read bridge as a ready-made route: Python version of @everydeck/sdk/read-bridge, same behavior.
2
2
 
3
- Die App bindet sie mit der Verbindung ein, die sie schon hat; keine Leserolle, keine Sichten, keine Datei mit dem Lese-Umfang im Repo:
3
+ The app mounts it with the connection it already has; no reader role, no views, no read scope file in the repo:
4
4
 
5
- from everydeck.read_route import EverydeckReadBridge, SupabaseReadOnly # oder PsycopgReadOnly(os.environ["DATABASE_URL"])
5
+ from everydeck.read_route import EverydeckReadBridge, SupabaseReadOnly # or PsycopgReadOnly(os.environ["DATABASE_URL"])
6
6
  bridge = EverydeckReadBridge(os.environ["EVERYDECK_URL"], os.environ["EVERYDECK_READ_BRIDGE_TOKEN"], SupabaseReadOnly(supabase_admin))
7
7
  @app.api_route("/everydeck/read/{rest:path}", methods=["GET", "POST", "PUT", "PATCH", "DELETE"], include_in_schema=False)
8
8
  async def everydeck_read(rest: str, request: Request):
@@ -10,13 +10,13 @@ Die App bindet sie mit der Verbindung ein, die sie schon hat; keine Leserolle, k
10
10
  request.headers.get("authorization"), request.headers, await request.body(), str(request.url))
11
11
  return JSONResponse(body, status_code=status)
12
12
 
13
- - Den Lese-Umfang holt die Route bei everydeck (GET <endpoint>/v1/read-scope mit dem Brückenschlüssel als Bearer) und nutzt ihn nur mit gültiger
14
- Signatur (HMAC-SHA256 mit demselben Schlüssel über "EVERYDECK-SCOPE-V1\\n" + payload); sonst liest sie nichts (503).
15
- - PsycopgReadOnly: jede Abfrage in einer eigenen Transaktion BEGIN READ ONLY auf der Verbindung der App – die Datenbank lehnt darin jedes Schreiben ab.
16
- Die Rechte werden geprüft: Rolle, Superuser, rolbypassrls, current_setting('transaction_read_only').
17
- - SupabaseReadOnly: der Supabase-Client der App (PostgREST). Die Route ruft nur select() auf, also nur GET-Anfragen; PostgREST führt GET-Anfragen
18
- in einer Nur-Lesen-Transaktion aus. Erreichbar sind nur Tabellen in Schemas, die die Supabase-API freigibt.
19
- Keine Abhängigkeiten außer der Standardbibliothek; psycopg bzw. supabase bringt die App mit.
13
+ - The route fetches the read scope from everydeck (GET <endpoint>/v1/read-scope with the bridge key as Bearer) and uses it only with a valid
14
+ signature (HMAC-SHA256 with the same key over "EVERYDECK-SCOPE-V1\\n" + payload); otherwise it reads nothing (503).
15
+ - PsycopgReadOnly: every query runs in its own BEGIN READ ONLY transaction on the app's connection; the database rejects any write inside it.
16
+ The rights are checked: role, superuser, rolbypassrls, current_setting('transaction_read_only').
17
+ - SupabaseReadOnly: the app's Supabase client (PostgREST). The route only calls select(), so only GET requests; PostgREST runs GET requests
18
+ in a read-only transaction. Only tables in schemas exposed by the Supabase API are reachable.
19
+ No dependencies beyond the standard library; the app brings psycopg or supabase itself.
20
20
  """
21
21
  from __future__ import annotations
22
22
 
@@ -63,7 +63,7 @@ def verify_scope_signature(keys: Iterable[str], payload: str, signature: str | N
63
63
 
64
64
 
65
65
  class PsycopgReadOnly:
66
- """Die Verbindung der App über psycopg 3: `PsycopgReadOnly(conninfo)` oder `PsycopgReadOnly(connect=lambda: …)`; jede Abfrage in BEGIN READ ONLY … COMMIT."""
66
+ """The app's connection via psycopg 3: `PsycopgReadOnly(conninfo)` or `PsycopgReadOnly(connect=lambda: …)`; every query in BEGIN READ ONLY … COMMIT."""
67
67
 
68
68
  def __init__(self, conninfo: str | None = None, *, connect: Callable[[], Any] | None = None) -> None:
69
69
  if conninfo is None and connect is None:
@@ -87,7 +87,7 @@ class PsycopgReadOnly:
87
87
  try:
88
88
  conn.cursor().execute("ROLLBACK")
89
89
  except Exception:
90
- self._conn = None # Verbindung kaputt: beim nächsten Aufruf neu
90
+ self._conn = None # connection broken: reconnect on the next call
91
91
  raise
92
92
 
93
93
  def rows(self, *a: Any, **k: Any) -> list[dict[str, Any]]:
@@ -110,12 +110,12 @@ class PsycopgReadOnly:
110
110
 
111
111
 
112
112
  def _quote(v: str) -> str:
113
- """Wert in einem PostgREST-Filter (or=…): in doppelten Anführungszeichen, \\ und \" maskiert"""
113
+ """Value in a PostgREST filter (or=…): in double quotes, with \\ and \" escaped"""
114
114
  return '"' + str(v).replace("\\", "\\\\").replace('"', '\\"') + '"'
115
115
 
116
116
 
117
117
  class SupabaseReadOnly:
118
- """Der Supabase-Client der App (supabase-py, PostgREST): nur select(), also nur GET-Anfragen – PostgREST führt sie in einer Nur-Lesen-Transaktion aus."""
118
+ """The app's Supabase client (supabase-py, PostgREST): only select(), so only GET requests; PostgREST runs them in a read-only transaction."""
119
119
 
120
120
  def __init__(self, client: Any) -> None:
121
121
  self.client = client
@@ -163,21 +163,21 @@ class SupabaseReadOnly:
163
163
  return list(q.limit(max(1, min(PAGE_MAX + 1, int(limit)))).execute().data or [])
164
164
 
165
165
  def rights(self, scope: Mapping[str, Any]) -> dict[str, Any]:
166
- # Über PostgREST lässt sich die Rolle nicht per SQL prüfen; fest steht der Weg: nur GET (select), von PostgREST nur lesend ausgeführt.
166
+ # The role cannot be checked by SQL through PostgREST; the path is fixed: only GET (select), run read-only by PostgREST.
167
167
  return {"role": "supabase api (postgrest)", "select_only": False, "read_only_transaction": True, "checked_at": _dt.datetime.now(_dt.timezone.utc).isoformat().replace("+00:00", "Z")}
168
168
 
169
169
 
170
170
  def _default_fetch(url: str, headers: Mapping[str, str]) -> tuple[int, str]:
171
171
  req = urllib.request.Request(url, headers=dict(headers), method="GET")
172
172
  try:
173
- with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310 – Adresse von everydeck aus der Umgebung der App
173
+ with urllib.request.urlopen(req, timeout=10) as r: # noqa: S310 (everydeck address from the app's environment)
174
174
  return r.status, r.read().decode()
175
175
  except urllib.error.HTTPError as e:
176
176
  return e.code, ""
177
177
 
178
178
 
179
179
  class EverydeckReadBridge:
180
- """Die fertige Lese-Brücke: Lese-Umfang signiert von everydeck, Lesen über `db` (PsycopgReadOnly oder SupabaseReadOnly)."""
180
+ """The ready-made read bridge: read scope signed by everydeck, reads through `db` (PsycopgReadOnly or SupabaseReadOnly)."""
181
181
 
182
182
  def __init__(self, endpoint: str | None, key: str | None, db: Any, *, previous_key: str | None = None, app: str | None = None, scope_ttl: float = 60.0,
183
183
  seen: Callable[[str, float], bool] | None = None, fetch: Callable[[str, Mapping[str, str]], tuple[int, str]] | None = None) -> None:
@@ -1,13 +1,13 @@
1
- """B30 Schnitt E – Prüfhilfe für Brücken: everydeck signiert jeden Aufruf an eine Lese- oder App-Brücke (wie Stripe).
1
+ """Verification helper for bridges: everydeck signs every call to a read or app bridge (like Stripe).
2
2
 
3
- Kopfzeilen: X-Everydeck-Timestamp (Unix-Sekunden), X-Everydeck-Request-Id (UUID), X-Everydeck-Signature (v1=<hex>[,v1=<hex>] – eine je gültigem Schlüssel).
4
- Signatur = HMAC-SHA256(Schlüssel, "EVERYDECK-V1\\n" + METHOD + "\\n" + Pfad ab /everydeck/ mit Query + "\\n" + Zeitstempel + "\\n" + request_id + "\\n" + SHA-256(Körper) hex).
5
- Die App lehnt ab, wenn der Zeitstempel mehr als 5 Minuten abweicht oder die request_id in dieser Zeit schon gesehen wurde.
6
- B31 Abnahme (A8): Die request_id der Signatur (X-Everydeck-Request-Id) ist je HTTP-Aufruf neu, auch wenn everydeck denselben Auftrag wiederholt – der
7
- Replay-Schutz trifft nur eine wörtlich wiederholte Anfrage. Die Idempotenz einer Aktion trägt `request_id` im Körper und `Idempotency-Key`;
8
- eine Wiederholung beantwortet die Tür mit dem festgehaltenen Ergebnis, nie mit 401.
3
+ Headers: X-Everydeck-Timestamp (Unix seconds), X-Everydeck-Request-Id (UUID), X-Everydeck-Signature (v1=<hex>[,v1=<hex>], one per valid key).
4
+ Signature = HMAC-SHA256(key, "EVERYDECK-V1\\n" + METHOD + "\\n" + path from /everydeck/ with query + "\\n" + timestamp + "\\n" + request_id + "\\n" + SHA-256(body) hex).
5
+ The app rejects the call when the timestamp is off by more than 5 minutes or the request_id was already seen within that window.
6
+ The signature's request_id (X-Everydeck-Request-Id) is new for every HTTP call, even when everydeck retries the same job; replay
7
+ protection only catches a literally repeated request. Idempotency of an action is carried by `request_id` in the body and `Idempotency-Key`;
8
+ the bridge route answers a retry with the recorded result, never with 401.
9
9
 
10
- Eine Zeile in der Tür der App (FastAPI):
10
+ One line in the app's bridge route (FastAPI):
11
11
  v = verify_everydeck_request(request.method, str(request.url), request.headers, await request.body(), keys=[os.environ["EVERYDECK_BRIDGE_KEY"]])
12
12
  if not v.ok: raise HTTPException(401)
13
13
  """
@@ -42,7 +42,7 @@ def signature_for(key: str, canonical: str) -> str:
42
42
 
43
43
 
44
44
  class MemorySeen:
45
- """Replay-Speicher je Prozess: request_id → Zeitstempel, aufgeräumt nach dem Fenster (mehrere Instanzen: eigene Prüfung, z. B. Redis/Postgres)."""
45
+ """Per-process replay store: request_id → timestamp, pruned after the window (several instances: use your own check, e.g. Redis/Postgres)."""
46
46
 
47
47
  def __init__(self, window_seconds: int = SIGNATURE_WINDOW_SECONDS, now: Callable[[], float] = time.time) -> None:
48
48
  self._m: dict[str, float] = {}; self._lock = threading.Lock(); self.window = window_seconds; self.now = now
@@ -109,7 +109,7 @@ def verify_everydeck_request(method: str, url_or_path: str, headers: Mapping[str
109
109
 
110
110
 
111
111
  def sign_everydeck_request(keys: Iterable[str], method: str, url_or_path: str, body: bytes | str | None = None, request_id: str | None = None, now: float | None = None) -> dict[str, str]:
112
- """Gegenstück für Tests und Prüfskripte der App: Kopfzeilen, wie everydeck sie schickt (erster Schlüssel aktuell, weitere vorige). Im Produkt signiert nur everydeck."""
112
+ """Counterpart for the app's tests and check scripts: headers as everydeck sends them (first key current, further keys previous). In production only everydeck signs."""
113
113
  import uuid
114
114
  timestamp = int((time.time() if now is None else now))
115
115
  rid = request_id or str(uuid.uuid4())
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: everydeck
3
- Version: 0.3.0
3
+ Version: 0.3.2
4
4
  Summary: everydeck Python SDK: bounded buffer, background sender, flush on shutdown, reported data gaps
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://everydeck.ai
@@ -40,7 +40,7 @@ everydeck.signal("job.created", operation_id)
40
40
  - `signal()`, `error()` and `log()` never block and never raise: they enqueue into a bounded in-memory buffer; a background thread sends batches (at most 50 events, 64 KiB, gzip-compressed).
41
41
  - Retries only on 429, 5xx, timeout and network errors; `Retry-After` is honoured; final rejections are dropped and counted, never reported as sent.
42
42
  - A buffer overflow is reported as a `gap` event, so everydeck shows a known data gap instead of silence.
43
- - `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"cv-worker"`).
43
+ - `heartbeat_interval` sends `everydeck.sdk.heartbeat` from the sender thread. Give each component a stable `instance_id` (for example `"api"`, `"worker"`).
44
44
  - `release_key="auto"` derives the version from the deployment commit (`EVERYDECK_RELEASE_KEY`, `GITHUB_SHA`, `VERCEL_GIT_COMMIT_SHA`, `RENDER_GIT_COMMIT`, …) or local git.
45
45
  - `everydeck.verify.verify_everydeck_request` checks everydeck's signature on bridge calls.
46
46
  - Read bridge (0.3.0): `everydeck.read_route.EverydeckReadBridge(endpoint, key, db)` is the ready-made route. It fetches its read scope from everydeck (`GET <endpoint>/v1/read-scope` with the bridge key) and uses it only with everydeck's signature; no read-scope file, no read role, no views. `db` is the connection the app already has: `PsycopgReadOnly(conninfo)` runs every query in `BEGIN READ ONLY`, `SupabaseReadOnly(client)` reads through the app's Supabase client with `select()` only (GET requests, which PostgREST runs read-only; only tables in schemas the Supabase API exposes).
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "everydeck"
3
- version = "0.3.0"
3
+ version = "0.3.2"
4
4
  description = "everydeck Python SDK: bounded buffer, background sender, flush on shutdown, reported data gaps"
5
5
  readme = "README.md"
6
6
  license = "MIT"
File without changes
File without changes