ctfy.sdk.events

Sync consumer for the platform's SSE event stream (GET /events).

The platform broadcasts every team-scoped and global event over an SSE endpoint; admin tokens additionally see admin-only frames (see ctfy.server.sse). This module turns that wire-level stream into an ergonomic Python iterator that the CLI's ctfy events watch and direct SDK callers can consume.

Usage::

with client.events() as stream:
    for event in stream:
        print(event["event"], event["data"])

The stream auto-reconnects on transient network errors with exponential backoff; close the context manager (or break the loop) to stop.

Frame format on the wire (from ctfy.server.sse.SSEHub._encode())::

event: <name>\n
data: <json>\n
\n

Comment lines (: prefix) — including the keepalive every ctfy.core.constants.SSE_KEEPALIVE_INTERVAL seconds — are swallowed silently.

  1"""Sync consumer for the platform's SSE event stream (``GET /events``).
  2
  3The platform broadcasts every team-scoped and global event over an SSE
  4endpoint; admin tokens additionally see admin-only frames (see
  5:mod:`ctfy.server.sse`). This module turns that wire-level stream into
  6an ergonomic Python iterator that the CLI's ``ctfy events watch`` and
  7direct SDK callers can consume.
  8
  9Usage::
 10
 11    with client.events() as stream:
 12        for event in stream:
 13            print(event["event"], event["data"])
 14
 15The stream auto-reconnects on transient network errors with exponential
 16backoff; close the context manager (or break the loop) to stop.
 17
 18Frame format on the wire (from :func:`ctfy.server.sse.SSEHub._encode`)::
 19
 20    event: <name>\\n
 21    data: <json>\\n
 22    \\n
 23
 24Comment lines (``:`` prefix) — including the keepalive every
 25:data:`ctfy.core.constants.SSE_KEEPALIVE_INTERVAL` seconds — are
 26swallowed silently.
 27"""
 28
 29from __future__ import annotations
 30
 31import json
 32import time
 33from collections.abc import Callable, Iterator
 34from typing import Any
 35
 36import httpx
 37
 38_RECONNECT_BACKOFF_MAX_S = 30.0
 39_RECONNECT_BACKOFF_START_S = 1.0
 40
 41
 42class EventStream:
 43    """Iterator over events from a single live HTTP connection, with
 44    auto-reconnect.
 45
 46    Construct via :meth:`ctfy.sdk.PlatformClient.events`; don't
 47    instantiate directly.
 48    """
 49
 50    def __init__(
 51        self,
 52        *,
 53        client_factory: Callable[[], httpx.Client],
 54        path: str,
 55        token: str = "",
 56        auto_reconnect: bool = True,
 57    ) -> None:
 58        self._client_factory = client_factory
 59        self._path = path
 60        self._token = token
 61        self._auto_reconnect = auto_reconnect
 62        self._stopped = False
 63
 64    def __enter__(self) -> EventStream:
 65        return self
 66
 67    def __exit__(self, *_exc: object) -> None:
 68        self.close()
 69
 70    def __iter__(self) -> Iterator[dict[str, Any]]:
 71        backoff = _RECONNECT_BACKOFF_START_S
 72        while not self._stopped:
 73            try:
 74                yield from self._read_one_session()
 75            except (httpx.HTTPError, ConnectionError, OSError):
 76                if not self._auto_reconnect or self._stopped:
 77                    raise
 78                time.sleep(backoff)
 79                backoff = min(backoff * 2, _RECONNECT_BACKOFF_MAX_S)
 80                continue
 81            # Clean EOF on the connection (server closed) — reconnect
 82            # immediately if auto_reconnect, otherwise stop.
 83            if not self._auto_reconnect or self._stopped:
 84                break
 85            backoff = _RECONNECT_BACKOFF_START_S
 86
 87    def close(self) -> None:
 88        """Stop the loop; in-flight ``read_one_session`` finishes its
 89        current iter_lines step before the iterator exits."""
 90        self._stopped = True
 91
 92    # -- internal -----------------------------------------------------
 93
 94    def _read_one_session(self) -> Iterator[dict[str, Any]]:
 95        # ⚠️ **The header, never ``?token=``** (#1954). The stream took its
 96        # bearer as a query parameter for the browser's sake, and this
 97        # client copied it — so every ``ctfy events watch`` wrote a
 98        # full-account credential into the ingress's access log, httpx's
 99        # own INFO request line included. The platform now refuses a
100        # bearer in any URL (``400 bearer_in_url``), and the browser sends
101        # the same header this does (#1958).
102        headers = {"Authorization": f"Bearer {self._token}"} if self._token else None
103        with (
104            self._client_factory() as cli,
105            cli.stream("GET", self._path, headers=headers, timeout=None) as resp,
106        ):
107            resp.raise_for_status()
108            event_name = ""
109            data_lines: list[str] = []
110            for line in resp.iter_lines():
111                if self._stopped:
112                    return
113                if not line:
114                    # Blank line → frame boundary. Flush whatever we have.
115                    if event_name and data_lines:
116                        body = "\n".join(data_lines)
117                        try:
118                            data: Any = json.loads(body)
119                        except json.JSONDecodeError:
120                            data = body
121                        yield {"event": event_name, "data": data}
122                    event_name = ""
123                    data_lines = []
124                    continue
125                if line.startswith(":"):
126                    # Comment line (server keepalive, initial ": connected").
127                    continue
128                if line.startswith("event:"):
129                    event_name = line[6:].lstrip()
130                elif line.startswith("data:"):
131                    # Per the SSE spec a single ``data:`` value may span
132                    # multiple lines; rejoin with ``\n``. ctfy's encoder
133                    # emits exactly one ``data:`` line so this is normally
134                    # a single entry, but be tolerant.
135                    data_lines.append(line[5:].lstrip())
136                # ``id:`` / ``retry:`` lines: ignored — the platform
137                # doesn't currently emit them.
class EventStream:
 43class EventStream:
 44    """Iterator over events from a single live HTTP connection, with
 45    auto-reconnect.
 46
 47    Construct via :meth:`ctfy.sdk.PlatformClient.events`; don't
 48    instantiate directly.
 49    """
 50
 51    def __init__(
 52        self,
 53        *,
 54        client_factory: Callable[[], httpx.Client],
 55        path: str,
 56        token: str = "",
 57        auto_reconnect: bool = True,
 58    ) -> None:
 59        self._client_factory = client_factory
 60        self._path = path
 61        self._token = token
 62        self._auto_reconnect = auto_reconnect
 63        self._stopped = False
 64
 65    def __enter__(self) -> EventStream:
 66        return self
 67
 68    def __exit__(self, *_exc: object) -> None:
 69        self.close()
 70
 71    def __iter__(self) -> Iterator[dict[str, Any]]:
 72        backoff = _RECONNECT_BACKOFF_START_S
 73        while not self._stopped:
 74            try:
 75                yield from self._read_one_session()
 76            except (httpx.HTTPError, ConnectionError, OSError):
 77                if not self._auto_reconnect or self._stopped:
 78                    raise
 79                time.sleep(backoff)
 80                backoff = min(backoff * 2, _RECONNECT_BACKOFF_MAX_S)
 81                continue
 82            # Clean EOF on the connection (server closed) — reconnect
 83            # immediately if auto_reconnect, otherwise stop.
 84            if not self._auto_reconnect or self._stopped:
 85                break
 86            backoff = _RECONNECT_BACKOFF_START_S
 87
 88    def close(self) -> None:
 89        """Stop the loop; in-flight ``read_one_session`` finishes its
 90        current iter_lines step before the iterator exits."""
 91        self._stopped = True
 92
 93    # -- internal -----------------------------------------------------
 94
 95    def _read_one_session(self) -> Iterator[dict[str, Any]]:
 96        # ⚠️ **The header, never ``?token=``** (#1954). The stream took its
 97        # bearer as a query parameter for the browser's sake, and this
 98        # client copied it — so every ``ctfy events watch`` wrote a
 99        # full-account credential into the ingress's access log, httpx's
100        # own INFO request line included. The platform now refuses a
101        # bearer in any URL (``400 bearer_in_url``), and the browser sends
102        # the same header this does (#1958).
103        headers = {"Authorization": f"Bearer {self._token}"} if self._token else None
104        with (
105            self._client_factory() as cli,
106            cli.stream("GET", self._path, headers=headers, timeout=None) as resp,
107        ):
108            resp.raise_for_status()
109            event_name = ""
110            data_lines: list[str] = []
111            for line in resp.iter_lines():
112                if self._stopped:
113                    return
114                if not line:
115                    # Blank line → frame boundary. Flush whatever we have.
116                    if event_name and data_lines:
117                        body = "\n".join(data_lines)
118                        try:
119                            data: Any = json.loads(body)
120                        except json.JSONDecodeError:
121                            data = body
122                        yield {"event": event_name, "data": data}
123                    event_name = ""
124                    data_lines = []
125                    continue
126                if line.startswith(":"):
127                    # Comment line (server keepalive, initial ": connected").
128                    continue
129                if line.startswith("event:"):
130                    event_name = line[6:].lstrip()
131                elif line.startswith("data:"):
132                    # Per the SSE spec a single ``data:`` value may span
133                    # multiple lines; rejoin with ``\n``. ctfy's encoder
134                    # emits exactly one ``data:`` line so this is normally
135                    # a single entry, but be tolerant.
136                    data_lines.append(line[5:].lstrip())
137                # ``id:`` / ``retry:`` lines: ignored — the platform
138                # doesn't currently emit them.

Iterator over events from a single live HTTP connection, with auto-reconnect.

Construct via ctfy.sdk.PlatformClient.events(); don't instantiate directly.

EventStream( *, client_factory: Callable[[], httpx.Client], path: str, token: str = '', auto_reconnect: bool = True)
51    def __init__(
52        self,
53        *,
54        client_factory: Callable[[], httpx.Client],
55        path: str,
56        token: str = "",
57        auto_reconnect: bool = True,
58    ) -> None:
59        self._client_factory = client_factory
60        self._path = path
61        self._token = token
62        self._auto_reconnect = auto_reconnect
63        self._stopped = False
def close(self) -> None:
88    def close(self) -> None:
89        """Stop the loop; in-flight ``read_one_session`` finishes its
90        current iter_lines step before the iterator exits."""
91        self._stopped = True

Stop the loop; in-flight read_one_session finishes its current iter_lines step before the iterator exits.