ctfy.sdk.client

Platform HTTP client — the player-facing client for the ctfy platform.

This is the client agents and players reach for. It drives the platform's /api/v1/* REST API with typed Pydantic returns and auto-retry on transient errors. Operations split into two tiers:

The admin / operator surface is a separate ~ctfy.sdk.admin.AdminClient, reached via admin. A few server-status / realtime / version helpers stay top-level on the client: health(), get_meta(), cluster_info(), events(), check_server_compatibility().

  1"""Platform HTTP client — the player-facing client for the ctfy platform.
  2
  3This is the client agents and players reach for. It drives the platform's
  4``/api/v1/*`` REST API with typed Pydantic returns and auto-retry on transient
  5errors. Operations split into two tiers:
  6
  7* **Global / account** namespaces on the client itself —
  8  :attr:`~PlatformClient.auth`, :attr:`~PlatformClient.me`,
  9  :attr:`~PlatformClient.users`, :attr:`~PlatformClient.teams`,
 10  :attr:`~PlatformClient.challenges` (global catalog),
 11  :attr:`~PlatformClient.instances`, :attr:`~PlatformClient.submissions`,
 12  :attr:`~PlatformClient.achievements`, :attr:`~PlatformClient.activities`,
 13  :attr:`~PlatformClient.scoreboard`, :attr:`~PlatformClient.nodes`, and
 14  :attr:`~PlatformClient.competitions` (discovery).
 15* **A competition scope** — :meth:`competition` returns a
 16  :class:`~ctfy.sdk.competition.Competition` handle for the operations that are
 17  inherently competition-scoped (register, your team, the comp's challenges,
 18  team search, standings), so ``competition_id`` is named once. Instance
 19  lifecycle and answer submission stay flat (keyed by ``instance_id``)::
 20
 21      from ctfy.sdk import PlatformClient
 22
 23      client = PlatformClient("http://localhost:8100", token="pf_xxx")
 24      comp = client.competition(client.competitions.list(phase="running")[0].id)
 25      comp.register(mode="solo")
 26      ready = client.instances.start(comp.challenges()[0].id, competition_id=comp.id)
 27      result = client.submissions.submit(ready.id, "FLAG{...}")
 28      board = comp.scoreboard()
 29
 30The admin / operator surface is a separate
 31:class:`~ctfy.sdk.admin.AdminClient`, reached via :attr:`admin`. A few
 32server-status / realtime / version helpers stay top-level on the client:
 33:meth:`health`, :meth:`get_meta`, :meth:`cluster_info`, :meth:`events`,
 34:meth:`check_server_compatibility`.
 35"""
 36
 37from __future__ import annotations
 38
 39from functools import cached_property
 40
 41import httpx
 42
 43from ctfy.core.constants import DEFAULT_CLIENT_TIMEOUT
 44from ctfy.core.version import VersionCheck, check_compatibility, package_version
 45from ctfy.sdk._helpers import (
 46    InstanceReadyResult,
 47    _raise_for_status,
 48)
 49from ctfy.sdk._helpers import (
 50    _extract_items as _extract_items,
 51)
 52from ctfy.sdk._helpers import (
 53    _poll_instance_ready as _poll_instance_ready,
 54)
 55from ctfy.sdk.admin import AdminClient
 56from ctfy.sdk.base import BaseHttpClient
 57from ctfy.sdk.competition import Competition
 58from ctfy.sdk.events import EventStream
 59from ctfy.sdk.resources.achievements import AchievementsResource
 60from ctfy.sdk.resources.activities import ActivitiesResource
 61from ctfy.sdk.resources.auth import AuthResource
 62from ctfy.sdk.resources.awd import AwdResource
 63from ctfy.sdk.resources.challenges import ChallengesResource
 64from ctfy.sdk.resources.competitions import CompetitionsResource
 65from ctfy.sdk.resources.eval import EvalResource
 66from ctfy.sdk.resources.instances import InstancesResource
 67from ctfy.sdk.resources.me import MeResource
 68from ctfy.sdk.resources.nodes import NodesResource
 69from ctfy.sdk.resources.registration import RegistrationResource
 70from ctfy.sdk.resources.reports import ReportsResource
 71from ctfy.sdk.resources.scoreboard import ScoreboardResource
 72from ctfy.sdk.resources.series import SeriesResource
 73from ctfy.sdk.resources.submissions import PatchesResource, SubmissionsResource
 74from ctfy.sdk.resources.teams import TeamsResource
 75from ctfy.sdk.resources.users import UsersResource
 76from ctfy.sdk.resources.vendor import VendorResource
 77from ctfy.server.models import ClusterInfo, HealthResponse, MetaResponse
 78
 79__all__ = ["InstanceReadyResult", "PlatformClient"]
 80
 81
 82class PlatformClient(BaseHttpClient):
 83    """Player-facing HTTP client for the ctfy platform.
 84
 85    Methods are grouped into resource namespaces (``client.teams.list()``, …)
 86    plus a per-competition scope via ``client.competition(id)``. The admin
 87    surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via
 88    :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers.
 89    """
 90
 91    def __init__(
 92        self,
 93        server_url: str,
 94        token: str = "",
 95        max_retries: int = 3,
 96        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 97    ):
 98        self._url = server_url.rstrip("/")
 99        super().__init__(f"{self._url}/api/v1", token, timeout=timeout, max_retries=max_retries)
100
101    @property
102    def server_url(self) -> str:
103        """The platform root, without the ``/api/v1`` suffix.
104
105        Exposed for callers that must build a non-``/api/v1`` URL of
106        their own — today the shell WebSocket, whose ticket carries a
107        platform-relative path the client has to absolutise against the
108        scheme and host *it* reached us on.
109        """
110        return self._url
111
112    # -- resource namespaces ------------------------------------------------
113
114    @cached_property
115    def registration(self) -> RegistrationResource:
116        """The caller's own per-competition registration + team logo."""
117        return RegistrationResource(self)
118
119    @cached_property
120    def teams(self) -> TeamsResource:
121        """Public team discovery + profile reads."""
122        return TeamsResource(self)
123
124    @cached_property
125    def users(self) -> UsersResource:
126        """Public per-user profile reads."""
127        return UsersResource(self)
128
129    @cached_property
130    def me(self) -> MeResource:
131        """The calling user's profile, inbox, account, and progress."""
132        return MeResource(self)
133
134    @cached_property
135    def auth(self) -> AuthResource:
136        """Password auth, OAuth identities, API tokens, sign-in discovery."""
137        return AuthResource(self)
138
139    @cached_property
140    def achievements(self) -> AchievementsResource:
141        """Public badge catalog, recent-unlock feed, easter eggs."""
142        return AchievementsResource(self)
143
144    @cached_property
145    def challenges(self) -> ChallengesResource:
146        """Global challenge catalog, attachments, facets, feedback chips."""
147        return ChallengesResource(self)
148
149    @cached_property
150    def competitions(self) -> CompetitionsResource:
151        """Discover competitions (``list`` / ``get``). To act within one, use
152        :meth:`competition`."""
153        return CompetitionsResource(self)
154
155    def competition(self, competition_id: str) -> Competition:
156        """Scope to one competition. The inherently competition-scoped
157        operations — register, your team (captain tooling + invites +
158        join-requests), the comp's challenges, team search, and standings —
159        hang off the returned :class:`~ctfy.sdk.competition.Competition`
160        handle, so ``competition_id`` is named once instead of on every call.
161        (Instance lifecycle and answer submission stay on :attr:`instances` /
162        :attr:`submissions`, keyed by ``instance_id``.)"""
163        return Competition(self, competition_id)
164
165    @cached_property
166    def series(self) -> SeriesResource:
167        """A recurring contest's ladder (public), not its schedule —
168        configuring a series is ``client.admin.series``."""
169        return SeriesResource(self)
170
171    @cached_property
172    def eval(self) -> EvalResource:
173        """Public model-evaluation leaderboard (ranking + dimensional breakdowns)."""
174        return EvalResource(self)
175
176    @cached_property
177    def vendor(self) -> VendorResource:
178        """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer)."""
179        return VendorResource(self)
180
181    @cached_property
182    def instances(self) -> InstancesResource:
183        """Launch / control / inspect challenge instances by instance id
184        (the competition is inferred server-side)."""
185        return InstancesResource(self)
186
187    @cached_property
188    def submissions(self) -> SubmissionsResource:
189        """Submit / verify answers + list submissions (by instance id), plus
190        the instance-less QA challenges."""
191        return SubmissionsResource(self)
192
193    @cached_property
194    def patches(self) -> PatchesResource:
195        """AWD+ defence: submit a patch, then watch for its verdict."""
196        return PatchesResource(self)
197
198    @cached_property
199    def awd(self) -> AwdResource:
200        """Classic AWD: submit a round's captured flags in one batch."""
201        return AwdResource(self)
202
203    @cached_property
204    def reports(self) -> ReportsResource:
205        """The engagement report: save a version, read the current one."""
206        return ReportsResource(self)
207
208    @cached_property
209    def scoreboard(self) -> ScoreboardResource:
210        """Global standings, per-challenge stats, persisted snapshots."""
211        return ScoreboardResource(self)
212
213    @cached_property
214    def activities(self) -> ActivitiesResource:
215        """Platform activity log + histogram."""
216        return ActivitiesResource(self)
217
218    @cached_property
219    def nodes(self) -> NodesResource:
220        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
221        return NodesResource(self)
222
223    @cached_property
224    def admin(self) -> AdminClient:
225        """Admin / operator surface (separate, role-gated server-side).
226
227        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
228        """
229        return AdminClient(self)
230
231    # -- top-level convenience: server status / realtime / version ----------
232
233    def health(self) -> HealthResponse:
234        resp = self.request("GET", "/health")
235        _raise_for_status(resp)
236        return HealthResponse.model_validate(resp.json())
237
238    def get_meta(self) -> MetaResponse:
239        """Server identity + challenge repo SHA + build version.
240        Admin tokens additionally see cluster capacity + team / solve
241        counts in the same payload."""
242        resp = self.request("GET", "/meta")
243        _raise_for_status(resp)
244        return MetaResponse.model_validate(resp.json())
245
246    def cluster_info(self) -> ClusterInfo:
247        """Aggregate worker-node capacity / utilisation (the public
248        capacity banner; no per-node detail)."""
249        resp = self.request("GET", "/cluster-info")
250        _raise_for_status(resp)
251        return ClusterInfo.model_validate(resp.json())
252
253    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
254        """Compare this client's ``ctfy`` version against the server's.
255
256        Uses the cheap unauthenticated ``/health`` probe (pass an
257        already-fetched :class:`HealthResponse` to avoid a second round
258        trip). Pure classification — never prints, never raises on a
259        mismatch, never mutates anything. The caller (CLI, MCP, harness)
260        decides what to do with :class:`~ctfy.core.version.VersionCheck`
261        (typically: print ``.message`` to stderr when not ``.quiet``).
262
263        A server too old to report its version yields
264        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
265        is ``.quiet`` — so this stays silent against legacy servers
266        rather than crying wolf.
267        """
268        h = health if health is not None else self.health()
269        return check_compatibility(package_version(), h.version)
270
271    def events(self, *, auto_reconnect: bool = True) -> EventStream:
272        """Open the platform SSE event stream.
273
274        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
275        Filters: team-scoped events for every team the caller's user is
276        on (across every per-comp competition), plus all global events;
277        admin tokens additionally see admin-only frames.
278
279        Usage::
280
281            with client.events() as stream:
282                for event in stream:
283                    if event["event"] == "solve":
284                        ...
285
286        Auto-reconnects on transient network errors with exponential
287        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
288        the iterator raise instead.
289        """
290        token = self._token
291
292        def _factory() -> httpx.Client:
293            # New client per session so a reconnect after a stale
294            # connection drops fresh sockets, not warmed-over ones.
295            return httpx.Client(base_url=self._base_url, timeout=None)
296
297        return EventStream(
298            client_factory=_factory,
299            path="/events",
300            token=token,
301            auto_reconnect=auto_reconnect,
302        )
@dataclass
class InstanceReadyResult:
82@dataclass
83class InstanceReadyResult:
84    """Result of polling an instance until ready."""
85
86    id: str
87    surface: AttackSurface
88    cert_volume: str = ""

Result of polling an instance until ready.

InstanceReadyResult( id: str, surface: ctfy.core.target.AttackSurface, cert_volume: str = '')
id: str
surface: ctfy.core.target.AttackSurface
cert_volume: str = ''
class PlatformClient(ctfy.sdk.base.BaseHttpClient):
 83class PlatformClient(BaseHttpClient):
 84    """Player-facing HTTP client for the ctfy platform.
 85
 86    Methods are grouped into resource namespaces (``client.teams.list()``, …)
 87    plus a per-competition scope via ``client.competition(id)``. The admin
 88    surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via
 89    :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers.
 90    """
 91
 92    def __init__(
 93        self,
 94        server_url: str,
 95        token: str = "",
 96        max_retries: int = 3,
 97        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 98    ):
 99        self._url = server_url.rstrip("/")
100        super().__init__(f"{self._url}/api/v1", token, timeout=timeout, max_retries=max_retries)
101
102    @property
103    def server_url(self) -> str:
104        """The platform root, without the ``/api/v1`` suffix.
105
106        Exposed for callers that must build a non-``/api/v1`` URL of
107        their own — today the shell WebSocket, whose ticket carries a
108        platform-relative path the client has to absolutise against the
109        scheme and host *it* reached us on.
110        """
111        return self._url
112
113    # -- resource namespaces ------------------------------------------------
114
115    @cached_property
116    def registration(self) -> RegistrationResource:
117        """The caller's own per-competition registration + team logo."""
118        return RegistrationResource(self)
119
120    @cached_property
121    def teams(self) -> TeamsResource:
122        """Public team discovery + profile reads."""
123        return TeamsResource(self)
124
125    @cached_property
126    def users(self) -> UsersResource:
127        """Public per-user profile reads."""
128        return UsersResource(self)
129
130    @cached_property
131    def me(self) -> MeResource:
132        """The calling user's profile, inbox, account, and progress."""
133        return MeResource(self)
134
135    @cached_property
136    def auth(self) -> AuthResource:
137        """Password auth, OAuth identities, API tokens, sign-in discovery."""
138        return AuthResource(self)
139
140    @cached_property
141    def achievements(self) -> AchievementsResource:
142        """Public badge catalog, recent-unlock feed, easter eggs."""
143        return AchievementsResource(self)
144
145    @cached_property
146    def challenges(self) -> ChallengesResource:
147        """Global challenge catalog, attachments, facets, feedback chips."""
148        return ChallengesResource(self)
149
150    @cached_property
151    def competitions(self) -> CompetitionsResource:
152        """Discover competitions (``list`` / ``get``). To act within one, use
153        :meth:`competition`."""
154        return CompetitionsResource(self)
155
156    def competition(self, competition_id: str) -> Competition:
157        """Scope to one competition. The inherently competition-scoped
158        operations — register, your team (captain tooling + invites +
159        join-requests), the comp's challenges, team search, and standings —
160        hang off the returned :class:`~ctfy.sdk.competition.Competition`
161        handle, so ``competition_id`` is named once instead of on every call.
162        (Instance lifecycle and answer submission stay on :attr:`instances` /
163        :attr:`submissions`, keyed by ``instance_id``.)"""
164        return Competition(self, competition_id)
165
166    @cached_property
167    def series(self) -> SeriesResource:
168        """A recurring contest's ladder (public), not its schedule —
169        configuring a series is ``client.admin.series``."""
170        return SeriesResource(self)
171
172    @cached_property
173    def eval(self) -> EvalResource:
174        """Public model-evaluation leaderboard (ranking + dimensional breakdowns)."""
175        return EvalResource(self)
176
177    @cached_property
178    def vendor(self) -> VendorResource:
179        """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer)."""
180        return VendorResource(self)
181
182    @cached_property
183    def instances(self) -> InstancesResource:
184        """Launch / control / inspect challenge instances by instance id
185        (the competition is inferred server-side)."""
186        return InstancesResource(self)
187
188    @cached_property
189    def submissions(self) -> SubmissionsResource:
190        """Submit / verify answers + list submissions (by instance id), plus
191        the instance-less QA challenges."""
192        return SubmissionsResource(self)
193
194    @cached_property
195    def patches(self) -> PatchesResource:
196        """AWD+ defence: submit a patch, then watch for its verdict."""
197        return PatchesResource(self)
198
199    @cached_property
200    def awd(self) -> AwdResource:
201        """Classic AWD: submit a round's captured flags in one batch."""
202        return AwdResource(self)
203
204    @cached_property
205    def reports(self) -> ReportsResource:
206        """The engagement report: save a version, read the current one."""
207        return ReportsResource(self)
208
209    @cached_property
210    def scoreboard(self) -> ScoreboardResource:
211        """Global standings, per-challenge stats, persisted snapshots."""
212        return ScoreboardResource(self)
213
214    @cached_property
215    def activities(self) -> ActivitiesResource:
216        """Platform activity log + histogram."""
217        return ActivitiesResource(self)
218
219    @cached_property
220    def nodes(self) -> NodesResource:
221        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
222        return NodesResource(self)
223
224    @cached_property
225    def admin(self) -> AdminClient:
226        """Admin / operator surface (separate, role-gated server-side).
227
228        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
229        """
230        return AdminClient(self)
231
232    # -- top-level convenience: server status / realtime / version ----------
233
234    def health(self) -> HealthResponse:
235        resp = self.request("GET", "/health")
236        _raise_for_status(resp)
237        return HealthResponse.model_validate(resp.json())
238
239    def get_meta(self) -> MetaResponse:
240        """Server identity + challenge repo SHA + build version.
241        Admin tokens additionally see cluster capacity + team / solve
242        counts in the same payload."""
243        resp = self.request("GET", "/meta")
244        _raise_for_status(resp)
245        return MetaResponse.model_validate(resp.json())
246
247    def cluster_info(self) -> ClusterInfo:
248        """Aggregate worker-node capacity / utilisation (the public
249        capacity banner; no per-node detail)."""
250        resp = self.request("GET", "/cluster-info")
251        _raise_for_status(resp)
252        return ClusterInfo.model_validate(resp.json())
253
254    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
255        """Compare this client's ``ctfy`` version against the server's.
256
257        Uses the cheap unauthenticated ``/health`` probe (pass an
258        already-fetched :class:`HealthResponse` to avoid a second round
259        trip). Pure classification — never prints, never raises on a
260        mismatch, never mutates anything. The caller (CLI, MCP, harness)
261        decides what to do with :class:`~ctfy.core.version.VersionCheck`
262        (typically: print ``.message`` to stderr when not ``.quiet``).
263
264        A server too old to report its version yields
265        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
266        is ``.quiet`` — so this stays silent against legacy servers
267        rather than crying wolf.
268        """
269        h = health if health is not None else self.health()
270        return check_compatibility(package_version(), h.version)
271
272    def events(self, *, auto_reconnect: bool = True) -> EventStream:
273        """Open the platform SSE event stream.
274
275        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
276        Filters: team-scoped events for every team the caller's user is
277        on (across every per-comp competition), plus all global events;
278        admin tokens additionally see admin-only frames.
279
280        Usage::
281
282            with client.events() as stream:
283                for event in stream:
284                    if event["event"] == "solve":
285                        ...
286
287        Auto-reconnects on transient network errors with exponential
288        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
289        the iterator raise instead.
290        """
291        token = self._token
292
293        def _factory() -> httpx.Client:
294            # New client per session so a reconnect after a stale
295            # connection drops fresh sockets, not warmed-over ones.
296            return httpx.Client(base_url=self._base_url, timeout=None)
297
298        return EventStream(
299            client_factory=_factory,
300            path="/events",
301            token=token,
302            auto_reconnect=auto_reconnect,
303        )

Player-facing HTTP client for the ctfy platform.

Methods are grouped into resource namespaces (client.teams.list(), …) plus a per-competition scope via client.competition(id). The admin surface is a separate ~ctfy.sdk.admin.AdminClient reached via admin. Used by the CLI, the SDK, the MCP server, and external callers.

PlatformClient( server_url: str, token: str = '', max_retries: int = 3, timeout: int = 600)
 92    def __init__(
 93        self,
 94        server_url: str,
 95        token: str = "",
 96        max_retries: int = 3,
 97        timeout: int = DEFAULT_CLIENT_TIMEOUT,
 98    ):
 99        self._url = server_url.rstrip("/")
100        super().__init__(f"{self._url}/api/v1", token, timeout=timeout, max_retries=max_retries)
server_url: str
102    @property
103    def server_url(self) -> str:
104        """The platform root, without the ``/api/v1`` suffix.
105
106        Exposed for callers that must build a non-``/api/v1`` URL of
107        their own — today the shell WebSocket, whose ticket carries a
108        platform-relative path the client has to absolutise against the
109        scheme and host *it* reached us on.
110        """
111        return self._url

The platform root, without the /api/v1 suffix.

Exposed for callers that must build a non-/api/v1 URL of their own — today the shell WebSocket, whose ticket carries a platform-relative path the client has to absolutise against the scheme and host it reached us on.

115    @cached_property
116    def registration(self) -> RegistrationResource:
117        """The caller's own per-competition registration + team logo."""
118        return RegistrationResource(self)

The caller's own per-competition registration + team logo.

120    @cached_property
121    def teams(self) -> TeamsResource:
122        """Public team discovery + profile reads."""
123        return TeamsResource(self)

Public team discovery + profile reads.

125    @cached_property
126    def users(self) -> UsersResource:
127        """Public per-user profile reads."""
128        return UsersResource(self)

Public per-user profile reads.

130    @cached_property
131    def me(self) -> MeResource:
132        """The calling user's profile, inbox, account, and progress."""
133        return MeResource(self)

The calling user's profile, inbox, account, and progress.

135    @cached_property
136    def auth(self) -> AuthResource:
137        """Password auth, OAuth identities, API tokens, sign-in discovery."""
138        return AuthResource(self)

Password auth, OAuth identities, API tokens, sign-in discovery.

140    @cached_property
141    def achievements(self) -> AchievementsResource:
142        """Public badge catalog, recent-unlock feed, easter eggs."""
143        return AchievementsResource(self)

Public badge catalog, recent-unlock feed, easter eggs.

145    @cached_property
146    def challenges(self) -> ChallengesResource:
147        """Global challenge catalog, attachments, facets, feedback chips."""
148        return ChallengesResource(self)

Global challenge catalog, attachments, facets, feedback chips.

150    @cached_property
151    def competitions(self) -> CompetitionsResource:
152        """Discover competitions (``list`` / ``get``). To act within one, use
153        :meth:`competition`."""
154        return CompetitionsResource(self)

Discover competitions (list / get). To act within one, use competition().

def competition(self, competition_id: str) -> ctfy.sdk.competition.Competition:
156    def competition(self, competition_id: str) -> Competition:
157        """Scope to one competition. The inherently competition-scoped
158        operations — register, your team (captain tooling + invites +
159        join-requests), the comp's challenges, team search, and standings —
160        hang off the returned :class:`~ctfy.sdk.competition.Competition`
161        handle, so ``competition_id`` is named once instead of on every call.
162        (Instance lifecycle and answer submission stay on :attr:`instances` /
163        :attr:`submissions`, keyed by ``instance_id``.)"""
164        return Competition(self, competition_id)

Scope to one competition. The inherently competition-scoped operations — register, your team (captain tooling + invites + join-requests), the comp's challenges, team search, and standings — hang off the returned ~ctfy.sdk.competition.Competition handle, so competition_id is named once instead of on every call. (Instance lifecycle and answer submission stay on instances / submissions, keyed by instance_id.)

166    @cached_property
167    def series(self) -> SeriesResource:
168        """A recurring contest's ladder (public), not its schedule —
169        configuring a series is ``client.admin.series``."""
170        return SeriesResource(self)

A recurring contest's ladder (public), not its schedule — configuring a series is client.admin.series.

172    @cached_property
173    def eval(self) -> EvalResource:
174        """Public model-evaluation leaderboard (ranking + dimensional breakdowns)."""
175        return EvalResource(self)

Public model-evaluation leaderboard (ranking + dimensional breakdowns).

177    @cached_property
178    def vendor(self) -> VendorResource:
179        """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer)."""
180        return VendorResource(self)

Vendor tenancy — the calling vendor's own models / runs (pv_ bearer).

182    @cached_property
183    def instances(self) -> InstancesResource:
184        """Launch / control / inspect challenge instances by instance id
185        (the competition is inferred server-side)."""
186        return InstancesResource(self)

Launch / control / inspect challenge instances by instance id (the competition is inferred server-side).

188    @cached_property
189    def submissions(self) -> SubmissionsResource:
190        """Submit / verify answers + list submissions (by instance id), plus
191        the instance-less QA challenges."""
192        return SubmissionsResource(self)

Submit / verify answers + list submissions (by instance id), plus the instance-less QA challenges.

194    @cached_property
195    def patches(self) -> PatchesResource:
196        """AWD+ defence: submit a patch, then watch for its verdict."""
197        return PatchesResource(self)

AWD+ defence: submit a patch, then watch for its verdict.

199    @cached_property
200    def awd(self) -> AwdResource:
201        """Classic AWD: submit a round's captured flags in one batch."""
202        return AwdResource(self)

Classic AWD: submit a round's captured flags in one batch.

204    @cached_property
205    def reports(self) -> ReportsResource:
206        """The engagement report: save a version, read the current one."""
207        return ReportsResource(self)

The engagement report: save a version, read the current one.

209    @cached_property
210    def scoreboard(self) -> ScoreboardResource:
211        """Global standings, per-challenge stats, persisted snapshots."""
212        return ScoreboardResource(self)

Global standings, per-challenge stats, persisted snapshots.

214    @cached_property
215    def activities(self) -> ActivitiesResource:
216        """Platform activity log + histogram."""
217        return ActivitiesResource(self)

Platform activity log + histogram.

219    @cached_property
220    def nodes(self) -> NodesResource:
221        """Public worker-node list (operator node mgmt is on ``admin.nodes``)."""
222        return NodesResource(self)

Public worker-node list (operator node mgmt is on admin.nodes).

admin: ctfy.sdk.AdminClient
224    @cached_property
225    def admin(self) -> AdminClient:
226        """Admin / operator surface (separate, role-gated server-side).
227
228        Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`.
229        """
230        return AdminClient(self)

Admin / operator surface (separate, role-gated server-side).

Shares this client's transport. See ~ctfy.sdk.admin.AdminClient.

def health(self) -> ctfy.server.models.HealthResponse:
234    def health(self) -> HealthResponse:
235        resp = self.request("GET", "/health")
236        _raise_for_status(resp)
237        return HealthResponse.model_validate(resp.json())
def get_meta(self) -> ctfy.server.models.MetaResponse:
239    def get_meta(self) -> MetaResponse:
240        """Server identity + challenge repo SHA + build version.
241        Admin tokens additionally see cluster capacity + team / solve
242        counts in the same payload."""
243        resp = self.request("GET", "/meta")
244        _raise_for_status(resp)
245        return MetaResponse.model_validate(resp.json())

Server identity + challenge repo SHA + build version. Admin tokens additionally see cluster capacity + team / solve counts in the same payload.

def cluster_info(self) -> ctfy.server.models.ClusterInfo:
247    def cluster_info(self) -> ClusterInfo:
248        """Aggregate worker-node capacity / utilisation (the public
249        capacity banner; no per-node detail)."""
250        resp = self.request("GET", "/cluster-info")
251        _raise_for_status(resp)
252        return ClusterInfo.model_validate(resp.json())

Aggregate worker-node capacity / utilisation (the public capacity banner; no per-node detail).

def check_server_compatibility( self, *, health: ctfy.server.models.HealthResponse | None = None) -> ctfy.core.version.VersionCheck:
254    def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck:
255        """Compare this client's ``ctfy`` version against the server's.
256
257        Uses the cheap unauthenticated ``/health`` probe (pass an
258        already-fetched :class:`HealthResponse` to avoid a second round
259        trip). Pure classification — never prints, never raises on a
260        mismatch, never mutates anything. The caller (CLI, MCP, harness)
261        decides what to do with :class:`~ctfy.core.version.VersionCheck`
262        (typically: print ``.message`` to stderr when not ``.quiet``).
263
264        A server too old to report its version yields
265        :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which
266        is ``.quiet`` — so this stays silent against legacy servers
267        rather than crying wolf.
268        """
269        h = health if health is not None else self.health()
270        return check_compatibility(package_version(), h.version)

Compare this client's ctfy version against the server's.

Uses the cheap unauthenticated /health probe (pass an already-fetched HealthResponse to avoid a second round trip). Pure classification — never prints, never raises on a mismatch, never mutates anything. The caller (CLI, MCP, harness) decides what to do with ~ctfy.core.version.VersionCheck (typically: print .message to stderr when not .quiet).

A server too old to report its version yields Compatibility.UNKNOWN (health.version == ""), which is .quiet — so this stays silent against legacy servers rather than crying wolf.

def events(self, *, auto_reconnect: bool = True) -> ctfy.sdk.events.EventStream:
272    def events(self, *, auto_reconnect: bool = True) -> EventStream:
273        """Open the platform SSE event stream.
274
275        Yields ``{"event": <name>, "data": <dict>}`` for each frame.
276        Filters: team-scoped events for every team the caller's user is
277        on (across every per-comp competition), plus all global events;
278        admin tokens additionally see admin-only frames.
279
280        Usage::
281
282            with client.events() as stream:
283                for event in stream:
284                    if event["event"] == "solve":
285                        ...
286
287        Auto-reconnects on transient network errors with exponential
288        backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make
289        the iterator raise instead.
290        """
291        token = self._token
292
293        def _factory() -> httpx.Client:
294            # New client per session so a reconnect after a stale
295            # connection drops fresh sockets, not warmed-over ones.
296            return httpx.Client(base_url=self._base_url, timeout=None)
297
298        return EventStream(
299            client_factory=_factory,
300            path="/events",
301            token=token,
302            auto_reconnect=auto_reconnect,
303        )

Open the platform SSE event stream.

Yields {"event": <name>, "data": <dict>} for each frame. Filters: team-scoped events for every team the caller's user is on (across every per-comp competition), plus all global events; admin tokens additionally see admin-only frames.

Usage::

with client.events() as stream:
    for event in stream:
        if event["event"] == "solve":
            ...

Auto-reconnects on transient network errors with exponential backoff (1s → 30s capped). Set auto_reconnect=False to make the iterator raise instead.