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