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.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.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_PLATFORM_TIMEOUT 44from ctfy.core.unavailable import UNAVAILABLE_BUDGET_S 45from ctfy.core.version import VersionCheck, check_compatibility, package_version 46from ctfy.sdk._helpers import ( 47 InstanceReadyResult, 48 _raise_for_status, 49) 50from ctfy.sdk._helpers import ( 51 _extract_items as _extract_items, 52) 53from ctfy.sdk._helpers import ( 54 _poll_instance_ready as _poll_instance_ready, 55) 56from ctfy.sdk.admin import AdminClient 57from ctfy.sdk.base import BaseHttpClient 58from ctfy.sdk.competition import Competition 59from ctfy.sdk.events import EventStream 60from ctfy.sdk.resources.achievements import AchievementsResource 61from ctfy.sdk.resources.activities import ActivitiesResource 62from ctfy.sdk.resources.auth import AuthResource 63from ctfy.sdk.resources.authoring import AuthoringResource 64from ctfy.sdk.resources.awd import AwdResource 65from ctfy.sdk.resources.challenges import ChallengesResource 66from ctfy.sdk.resources.changelog import ChangelogResource 67from ctfy.sdk.resources.competitions import CompetitionsResource 68from ctfy.sdk.resources.eval import EvalResource, RegistryResource 69from ctfy.sdk.resources.hints import HintsResource 70from ctfy.sdk.resources.instances import InstancesResource 71from ctfy.sdk.resources.me import MeResource 72from ctfy.sdk.resources.messages import MessagesResource 73from ctfy.sdk.resources.nodes import NodesResource 74from ctfy.sdk.resources.registration import RegistrationResource 75from ctfy.sdk.resources.reports import ReportsResource 76from ctfy.sdk.resources.search import SearchResource 77from ctfy.sdk.resources.series import SeriesResource 78from ctfy.sdk.resources.site import SiteResource 79from ctfy.sdk.resources.submissions import PatchesResource, SubmissionsResource 80from ctfy.sdk.resources.suites import SuitesResource 81from ctfy.sdk.resources.tasks import TasksResource 82from ctfy.sdk.resources.teams import TeamsResource 83from ctfy.sdk.resources.users import UsersResource 84from ctfy.sdk.resources.vendor import VendorResource 85from ctfy.server.models import ( 86 ClusterInfo, 87 HealthResponse, 88 LandingResponse, 89 MetaResponse, 90 PublicStatsResponse, 91) 92 93__all__ = ["InstanceReadyResult", "PlatformClient"] 94 95 96class PlatformClient(BaseHttpClient): 97 """Player-facing HTTP client for the ctfy platform. 98 99 Methods are grouped into resource namespaces (``client.teams.list()``, …) 100 plus a per-competition scope via ``client.competition(id)``. The admin 101 surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via 102 :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers. 103 """ 104 105 def __init__( 106 self, 107 server_url: str, 108 token: str = "", 109 max_retries: int = 2, 110 timeout: int = DEFAULT_PLATFORM_TIMEOUT, 111 transport: httpx.BaseTransport | None = None, 112 unavailable_budget_s: float = UNAVAILABLE_BUDGET_S, 113 ): 114 """``unavailable_budget_s``: how long one call waits out a ``503`` + 115 ``Retry-After`` (#3312: a redeploy is a wait, not a failed tool 116 call). ``0`` never waits — the platform's own MCP endpoint calling 117 itself, whose tool thread holds one of the pool's slots (#3504).""" 118 self._url = server_url.rstrip("/") 119 super().__init__( 120 f"{self._url}/api/v1", 121 token, 122 timeout=timeout, 123 max_retries=max_retries, 124 transport=transport, 125 unavailable_budget_s=unavailable_budget_s, 126 ) 127 128 @property 129 def server_url(self) -> str: 130 """The platform root, without the ``/api/v1`` suffix. 131 132 Exposed for callers that must build a non-``/api/v1`` URL of 133 their own — today the shell WebSocket, whose ticket carries a 134 platform-relative path the client has to absolutise against the 135 scheme and host *it* reached us on. 136 """ 137 return self._url 138 139 # -- resource namespaces ------------------------------------------------ 140 141 @cached_property 142 def registration(self) -> RegistrationResource: 143 """The caller's own per-competition registration + team logo.""" 144 return RegistrationResource(self) 145 146 @property 147 def authoring(self) -> AuthoringResource: 148 """Upload a challenge your team wrote, for a peer-authored event.""" 149 return AuthoringResource(self) 150 151 @cached_property 152 def teams(self) -> TeamsResource: 153 """Public team discovery + profile reads.""" 154 return TeamsResource(self) 155 156 @cached_property 157 def users(self) -> UsersResource: 158 """Public per-user profile reads.""" 159 return UsersResource(self) 160 161 @cached_property 162 def me(self) -> MeResource: 163 """The calling user's profile, inbox, account, and progress.""" 164 return MeResource(self) 165 166 @cached_property 167 def messages(self) -> MessagesResource: 168 """Site messages and the inbox stream (#2073).""" 169 return MessagesResource(self) 170 171 @cached_property 172 def auth(self) -> AuthResource: 173 """Password auth, OAuth identities, API tokens, sign-in discovery.""" 174 return AuthResource(self) 175 176 @cached_property 177 def achievements(self) -> AchievementsResource: 178 """Public badge catalog, recent-unlock feed, easter eggs.""" 179 return AchievementsResource(self) 180 181 @cached_property 182 def challenges(self) -> ChallengesResource: 183 """Global challenge catalog, attachments, facets, feedback chips.""" 184 return ChallengesResource(self) 185 186 @cached_property 187 def site(self) -> SiteResource: 188 """The operator's site pages and their Contact form (#3414).""" 189 return SiteResource(self) 190 191 @cached_property 192 def hints(self) -> HintsResource: 193 """A challenge's hint tiers in a competition: read, and open (#2101).""" 194 return HintsResource(self) 195 196 @cached_property 197 def changelog(self) -> ChangelogResource: 198 """Public "What's New" feed, plus this account's seen marker.""" 199 return ChangelogResource(self) 200 201 @cached_property 202 def competitions(self) -> CompetitionsResource: 203 """Discover competitions (``list`` / ``get``). To act within one, use 204 :meth:`competition`.""" 205 return CompetitionsResource(self) 206 207 def competition(self, competition_id: str) -> Competition: 208 """Scope to one competition. The inherently competition-scoped 209 operations — register, your team (captain tooling + invites + 210 join-requests), the comp's challenges, team search, and standings — 211 hang off the returned :class:`~ctfy.sdk.competition.Competition` 212 handle, so ``competition_id`` is named once instead of on every call. 213 (Instance lifecycle and answer submission stay on :attr:`instances` / 214 :attr:`submissions`, keyed by ``instance_id``.)""" 215 return Competition(self, competition_id) 216 217 @cached_property 218 def series(self) -> SeriesResource: 219 """A recurring contest's ladder (public), not its schedule — 220 configuring a series is ``client.admin.series``.""" 221 return SeriesResource(self) 222 223 @property 224 def suites(self) -> SuitesResource: 225 """The benchmark suites -- public challenge sets anybody practises or 226 evaluates against at any time (#2097). Playing one is 227 ``client.competition(id)``, like any competition.""" 228 return SuitesResource(self) 229 230 @cached_property 231 def eval(self) -> EvalResource: 232 """Public model-evaluation leaderboard (ranking + dimensional breakdowns).""" 233 return EvalResource(self) 234 235 @property 236 def registry(self) -> RegistryResource: 237 """The container registry's token endpoint (docker follows it itself).""" 238 return RegistryResource(self) 239 240 @cached_property 241 def vendor(self) -> VendorResource: 242 """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer).""" 243 return VendorResource(self) 244 245 @cached_property 246 def instances(self) -> InstancesResource: 247 """Launch / control / inspect challenge instances by instance id 248 (the competition is inferred server-side).""" 249 return InstancesResource(self) 250 251 @cached_property 252 def submissions(self) -> SubmissionsResource: 253 """Submit / verify answers + list submissions (by instance id), plus 254 the instance-less QA challenges.""" 255 return SubmissionsResource(self) 256 257 @cached_property 258 def patches(self) -> PatchesResource: 259 """AWD+ defence: submit a patch, then watch for its verdict.""" 260 return PatchesResource(self) 261 262 @cached_property 263 def awd(self) -> AwdResource: 264 """Classic AWD: submit a round's captured flags in one batch.""" 265 return AwdResource(self) 266 267 @cached_property 268 def reports(self) -> ReportsResource: 269 """The engagement report: save a version, read the current one.""" 270 return ReportsResource(self) 271 272 @cached_property 273 def search(self) -> SearchResource: 274 """Global search: competitions, challenges and teams by name.""" 275 return SearchResource(self) 276 277 @cached_property 278 def tasks(self) -> TasksResource: 279 """The background tasks you may see: when each waited, ran and ended.""" 280 return TasksResource(self) 281 282 @cached_property 283 def activities(self) -> ActivitiesResource: 284 """Platform activity log + histogram.""" 285 return ActivitiesResource(self) 286 287 @cached_property 288 def nodes(self) -> NodesResource: 289 """Public worker-node list (operator node mgmt is on ``admin.nodes``).""" 290 return NodesResource(self) 291 292 @cached_property 293 def admin(self) -> AdminClient: 294 """Admin / operator surface (separate, role-gated server-side). 295 296 Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`. 297 """ 298 return AdminClient(self) 299 300 # -- top-level convenience: server status / realtime / version ---------- 301 302 def health(self) -> HealthResponse: 303 resp = self.request("GET", "/health") 304 _raise_for_status(resp) 305 return HealthResponse.model_validate(resp.json()) 306 307 def get_meta(self) -> MetaResponse: 308 """Server identity + challenge repo SHA + build version. 309 Admin tokens additionally see cluster capacity + team / solve 310 counts in the same payload.""" 311 resp = self.request("GET", "/meta") 312 _raise_for_status(resp) 313 return MetaResponse.model_validate(resp.json()) 314 315 def get_landing(self) -> LandingResponse: 316 """What the public home page shows: the featured event (``None`` 317 when nothing featured may be shown) and the catalog by domain.""" 318 resp = self.request("GET", "/landing") 319 _raise_for_status(resp) 320 return LandingResponse.model_validate(resp.json()) 321 322 def get_public_stats(self) -> PublicStatsResponse: 323 """The home page's live eval numbers and rounded platform scale 324 (#3413). Public and the same for every caller.""" 325 resp = self.request("GET", "/stats/public") 326 _raise_for_status(resp) 327 return PublicStatsResponse.model_validate(resp.json()) 328 329 def cluster_info(self) -> ClusterInfo: 330 """Aggregate worker-node capacity / utilisation (the public 331 capacity banner; no per-node detail).""" 332 resp = self.request("GET", "/cluster-info") 333 _raise_for_status(resp) 334 return ClusterInfo.model_validate(resp.json()) 335 336 def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck: 337 """Compare this client's ``ctfy`` version against the server's. 338 339 Uses the cheap unauthenticated ``/health`` probe (pass an 340 already-fetched :class:`HealthResponse` to avoid a second round 341 trip). Pure classification — never prints, never raises on a 342 mismatch, never mutates anything. The caller (CLI, MCP, harness) 343 decides what to do with :class:`~ctfy.core.version.VersionCheck` 344 (typically: print ``.message`` to stderr when not ``.quiet``). 345 346 A server too old to report its version yields 347 :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which 348 is ``.quiet`` — so this stays silent against legacy servers 349 rather than crying wolf. 350 """ 351 h = health if health is not None else self.health() 352 return check_compatibility(package_version(), h.version) 353 354 def events(self, *, auto_reconnect: bool = True) -> EventStream: 355 """Open the platform SSE event stream. 356 357 Yields ``{"event": <name>, "data": <dict>}`` for each frame. 358 Filters: team-scoped events for every team the caller's user is 359 on (across every per-comp competition), plus all global events; 360 admin tokens additionally see admin-only frames. 361 362 Usage:: 363 364 with client.events() as stream: 365 for event in stream: 366 if event["event"] == "solve": 367 ... 368 369 Auto-reconnects on transient network errors with exponential 370 backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make 371 the iterator raise instead. 372 """ 373 token = self._token 374 375 def _factory() -> httpx.Client: 376 # New client per session so a reconnect after a stale 377 # connection drops fresh sockets, not warmed-over ones. 378 return httpx.Client(base_url=self._base_url, timeout=None) 379 380 return EventStream( 381 client_factory=_factory, 382 path="/events", 383 token=token, 384 auto_reconnect=auto_reconnect, 385 )
89@dataclass 90class InstanceReadyResult: 91 """Result of polling an instance until ready.""" 92 93 id: str 94 surface: AttackSurface 95 cert_volume: str = ""
Result of polling an instance until ready.
97class PlatformClient(BaseHttpClient): 98 """Player-facing HTTP client for the ctfy platform. 99 100 Methods are grouped into resource namespaces (``client.teams.list()``, …) 101 plus a per-competition scope via ``client.competition(id)``. The admin 102 surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via 103 :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers. 104 """ 105 106 def __init__( 107 self, 108 server_url: str, 109 token: str = "", 110 max_retries: int = 2, 111 timeout: int = DEFAULT_PLATFORM_TIMEOUT, 112 transport: httpx.BaseTransport | None = None, 113 unavailable_budget_s: float = UNAVAILABLE_BUDGET_S, 114 ): 115 """``unavailable_budget_s``: how long one call waits out a ``503`` + 116 ``Retry-After`` (#3312: a redeploy is a wait, not a failed tool 117 call). ``0`` never waits — the platform's own MCP endpoint calling 118 itself, whose tool thread holds one of the pool's slots (#3504).""" 119 self._url = server_url.rstrip("/") 120 super().__init__( 121 f"{self._url}/api/v1", 122 token, 123 timeout=timeout, 124 max_retries=max_retries, 125 transport=transport, 126 unavailable_budget_s=unavailable_budget_s, 127 ) 128 129 @property 130 def server_url(self) -> str: 131 """The platform root, without the ``/api/v1`` suffix. 132 133 Exposed for callers that must build a non-``/api/v1`` URL of 134 their own — today the shell WebSocket, whose ticket carries a 135 platform-relative path the client has to absolutise against the 136 scheme and host *it* reached us on. 137 """ 138 return self._url 139 140 # -- resource namespaces ------------------------------------------------ 141 142 @cached_property 143 def registration(self) -> RegistrationResource: 144 """The caller's own per-competition registration + team logo.""" 145 return RegistrationResource(self) 146 147 @property 148 def authoring(self) -> AuthoringResource: 149 """Upload a challenge your team wrote, for a peer-authored event.""" 150 return AuthoringResource(self) 151 152 @cached_property 153 def teams(self) -> TeamsResource: 154 """Public team discovery + profile reads.""" 155 return TeamsResource(self) 156 157 @cached_property 158 def users(self) -> UsersResource: 159 """Public per-user profile reads.""" 160 return UsersResource(self) 161 162 @cached_property 163 def me(self) -> MeResource: 164 """The calling user's profile, inbox, account, and progress.""" 165 return MeResource(self) 166 167 @cached_property 168 def messages(self) -> MessagesResource: 169 """Site messages and the inbox stream (#2073).""" 170 return MessagesResource(self) 171 172 @cached_property 173 def auth(self) -> AuthResource: 174 """Password auth, OAuth identities, API tokens, sign-in discovery.""" 175 return AuthResource(self) 176 177 @cached_property 178 def achievements(self) -> AchievementsResource: 179 """Public badge catalog, recent-unlock feed, easter eggs.""" 180 return AchievementsResource(self) 181 182 @cached_property 183 def challenges(self) -> ChallengesResource: 184 """Global challenge catalog, attachments, facets, feedback chips.""" 185 return ChallengesResource(self) 186 187 @cached_property 188 def site(self) -> SiteResource: 189 """The operator's site pages and their Contact form (#3414).""" 190 return SiteResource(self) 191 192 @cached_property 193 def hints(self) -> HintsResource: 194 """A challenge's hint tiers in a competition: read, and open (#2101).""" 195 return HintsResource(self) 196 197 @cached_property 198 def changelog(self) -> ChangelogResource: 199 """Public "What's New" feed, plus this account's seen marker.""" 200 return ChangelogResource(self) 201 202 @cached_property 203 def competitions(self) -> CompetitionsResource: 204 """Discover competitions (``list`` / ``get``). To act within one, use 205 :meth:`competition`.""" 206 return CompetitionsResource(self) 207 208 def competition(self, competition_id: str) -> Competition: 209 """Scope to one competition. The inherently competition-scoped 210 operations — register, your team (captain tooling + invites + 211 join-requests), the comp's challenges, team search, and standings — 212 hang off the returned :class:`~ctfy.sdk.competition.Competition` 213 handle, so ``competition_id`` is named once instead of on every call. 214 (Instance lifecycle and answer submission stay on :attr:`instances` / 215 :attr:`submissions`, keyed by ``instance_id``.)""" 216 return Competition(self, competition_id) 217 218 @cached_property 219 def series(self) -> SeriesResource: 220 """A recurring contest's ladder (public), not its schedule — 221 configuring a series is ``client.admin.series``.""" 222 return SeriesResource(self) 223 224 @property 225 def suites(self) -> SuitesResource: 226 """The benchmark suites -- public challenge sets anybody practises or 227 evaluates against at any time (#2097). Playing one is 228 ``client.competition(id)``, like any competition.""" 229 return SuitesResource(self) 230 231 @cached_property 232 def eval(self) -> EvalResource: 233 """Public model-evaluation leaderboard (ranking + dimensional breakdowns).""" 234 return EvalResource(self) 235 236 @property 237 def registry(self) -> RegistryResource: 238 """The container registry's token endpoint (docker follows it itself).""" 239 return RegistryResource(self) 240 241 @cached_property 242 def vendor(self) -> VendorResource: 243 """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer).""" 244 return VendorResource(self) 245 246 @cached_property 247 def instances(self) -> InstancesResource: 248 """Launch / control / inspect challenge instances by instance id 249 (the competition is inferred server-side).""" 250 return InstancesResource(self) 251 252 @cached_property 253 def submissions(self) -> SubmissionsResource: 254 """Submit / verify answers + list submissions (by instance id), plus 255 the instance-less QA challenges.""" 256 return SubmissionsResource(self) 257 258 @cached_property 259 def patches(self) -> PatchesResource: 260 """AWD+ defence: submit a patch, then watch for its verdict.""" 261 return PatchesResource(self) 262 263 @cached_property 264 def awd(self) -> AwdResource: 265 """Classic AWD: submit a round's captured flags in one batch.""" 266 return AwdResource(self) 267 268 @cached_property 269 def reports(self) -> ReportsResource: 270 """The engagement report: save a version, read the current one.""" 271 return ReportsResource(self) 272 273 @cached_property 274 def search(self) -> SearchResource: 275 """Global search: competitions, challenges and teams by name.""" 276 return SearchResource(self) 277 278 @cached_property 279 def tasks(self) -> TasksResource: 280 """The background tasks you may see: when each waited, ran and ended.""" 281 return TasksResource(self) 282 283 @cached_property 284 def activities(self) -> ActivitiesResource: 285 """Platform activity log + histogram.""" 286 return ActivitiesResource(self) 287 288 @cached_property 289 def nodes(self) -> NodesResource: 290 """Public worker-node list (operator node mgmt is on ``admin.nodes``).""" 291 return NodesResource(self) 292 293 @cached_property 294 def admin(self) -> AdminClient: 295 """Admin / operator surface (separate, role-gated server-side). 296 297 Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`. 298 """ 299 return AdminClient(self) 300 301 # -- top-level convenience: server status / realtime / version ---------- 302 303 def health(self) -> HealthResponse: 304 resp = self.request("GET", "/health") 305 _raise_for_status(resp) 306 return HealthResponse.model_validate(resp.json()) 307 308 def get_meta(self) -> MetaResponse: 309 """Server identity + challenge repo SHA + build version. 310 Admin tokens additionally see cluster capacity + team / solve 311 counts in the same payload.""" 312 resp = self.request("GET", "/meta") 313 _raise_for_status(resp) 314 return MetaResponse.model_validate(resp.json()) 315 316 def get_landing(self) -> LandingResponse: 317 """What the public home page shows: the featured event (``None`` 318 when nothing featured may be shown) and the catalog by domain.""" 319 resp = self.request("GET", "/landing") 320 _raise_for_status(resp) 321 return LandingResponse.model_validate(resp.json()) 322 323 def get_public_stats(self) -> PublicStatsResponse: 324 """The home page's live eval numbers and rounded platform scale 325 (#3413). Public and the same for every caller.""" 326 resp = self.request("GET", "/stats/public") 327 _raise_for_status(resp) 328 return PublicStatsResponse.model_validate(resp.json()) 329 330 def cluster_info(self) -> ClusterInfo: 331 """Aggregate worker-node capacity / utilisation (the public 332 capacity banner; no per-node detail).""" 333 resp = self.request("GET", "/cluster-info") 334 _raise_for_status(resp) 335 return ClusterInfo.model_validate(resp.json()) 336 337 def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck: 338 """Compare this client's ``ctfy`` version against the server's. 339 340 Uses the cheap unauthenticated ``/health`` probe (pass an 341 already-fetched :class:`HealthResponse` to avoid a second round 342 trip). Pure classification — never prints, never raises on a 343 mismatch, never mutates anything. The caller (CLI, MCP, harness) 344 decides what to do with :class:`~ctfy.core.version.VersionCheck` 345 (typically: print ``.message`` to stderr when not ``.quiet``). 346 347 A server too old to report its version yields 348 :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which 349 is ``.quiet`` — so this stays silent against legacy servers 350 rather than crying wolf. 351 """ 352 h = health if health is not None else self.health() 353 return check_compatibility(package_version(), h.version) 354 355 def events(self, *, auto_reconnect: bool = True) -> EventStream: 356 """Open the platform SSE event stream. 357 358 Yields ``{"event": <name>, "data": <dict>}`` for each frame. 359 Filters: team-scoped events for every team the caller's user is 360 on (across every per-comp competition), plus all global events; 361 admin tokens additionally see admin-only frames. 362 363 Usage:: 364 365 with client.events() as stream: 366 for event in stream: 367 if event["event"] == "solve": 368 ... 369 370 Auto-reconnects on transient network errors with exponential 371 backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make 372 the iterator raise instead. 373 """ 374 token = self._token 375 376 def _factory() -> httpx.Client: 377 # New client per session so a reconnect after a stale 378 # connection drops fresh sockets, not warmed-over ones. 379 return httpx.Client(base_url=self._base_url, timeout=None) 380 381 return EventStream( 382 client_factory=_factory, 383 path="/events", 384 token=token, 385 auto_reconnect=auto_reconnect, 386 )
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.
106 def __init__( 107 self, 108 server_url: str, 109 token: str = "", 110 max_retries: int = 2, 111 timeout: int = DEFAULT_PLATFORM_TIMEOUT, 112 transport: httpx.BaseTransport | None = None, 113 unavailable_budget_s: float = UNAVAILABLE_BUDGET_S, 114 ): 115 """``unavailable_budget_s``: how long one call waits out a ``503`` + 116 ``Retry-After`` (#3312: a redeploy is a wait, not a failed tool 117 call). ``0`` never waits — the platform's own MCP endpoint calling 118 itself, whose tool thread holds one of the pool's slots (#3504).""" 119 self._url = server_url.rstrip("/") 120 super().__init__( 121 f"{self._url}/api/v1", 122 token, 123 timeout=timeout, 124 max_retries=max_retries, 125 transport=transport, 126 unavailable_budget_s=unavailable_budget_s, 127 )
unavailable_budget_s: how long one call waits out a 503 +
Retry-After (#3312: a redeploy is a wait, not a failed tool
call). 0 never waits — the platform's own MCP endpoint calling
itself, whose tool thread holds one of the pool's slots (#3504).
129 @property 130 def server_url(self) -> str: 131 """The platform root, without the ``/api/v1`` suffix. 132 133 Exposed for callers that must build a non-``/api/v1`` URL of 134 their own — today the shell WebSocket, whose ticket carries a 135 platform-relative path the client has to absolutise against the 136 scheme and host *it* reached us on. 137 """ 138 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.
142 @cached_property 143 def registration(self) -> RegistrationResource: 144 """The caller's own per-competition registration + team logo.""" 145 return RegistrationResource(self)
The caller's own per-competition registration + team logo.
152 @cached_property 153 def teams(self) -> TeamsResource: 154 """Public team discovery + profile reads.""" 155 return TeamsResource(self)
Public team discovery + profile reads.
157 @cached_property 158 def users(self) -> UsersResource: 159 """Public per-user profile reads.""" 160 return UsersResource(self)
Public per-user profile reads.
162 @cached_property 163 def me(self) -> MeResource: 164 """The calling user's profile, inbox, account, and progress.""" 165 return MeResource(self)
The calling user's profile, inbox, account, and progress.
167 @cached_property 168 def messages(self) -> MessagesResource: 169 """Site messages and the inbox stream (#2073).""" 170 return MessagesResource(self)
Site messages and the inbox stream (#2073).
172 @cached_property 173 def auth(self) -> AuthResource: 174 """Password auth, OAuth identities, API tokens, sign-in discovery.""" 175 return AuthResource(self)
Password auth, OAuth identities, API tokens, sign-in discovery.
177 @cached_property 178 def achievements(self) -> AchievementsResource: 179 """Public badge catalog, recent-unlock feed, easter eggs.""" 180 return AchievementsResource(self)
Public badge catalog, recent-unlock feed, easter eggs.
182 @cached_property 183 def challenges(self) -> ChallengesResource: 184 """Global challenge catalog, attachments, facets, feedback chips.""" 185 return ChallengesResource(self)
Global challenge catalog, attachments, facets, feedback chips.
187 @cached_property 188 def site(self) -> SiteResource: 189 """The operator's site pages and their Contact form (#3414).""" 190 return SiteResource(self)
The operator's site pages and their Contact form (#3414).
192 @cached_property 193 def hints(self) -> HintsResource: 194 """A challenge's hint tiers in a competition: read, and open (#2101).""" 195 return HintsResource(self)
A challenge's hint tiers in a competition: read, and open (#2101).
197 @cached_property 198 def changelog(self) -> ChangelogResource: 199 """Public "What's New" feed, plus this account's seen marker.""" 200 return ChangelogResource(self)
Public "What's New" feed, plus this account's seen marker.
202 @cached_property 203 def competitions(self) -> CompetitionsResource: 204 """Discover competitions (``list`` / ``get``). To act within one, use 205 :meth:`competition`.""" 206 return CompetitionsResource(self)
Discover competitions (list / get). To act within one, use
competition().
208 def competition(self, competition_id: str) -> Competition: 209 """Scope to one competition. The inherently competition-scoped 210 operations — register, your team (captain tooling + invites + 211 join-requests), the comp's challenges, team search, and standings — 212 hang off the returned :class:`~ctfy.sdk.competition.Competition` 213 handle, so ``competition_id`` is named once instead of on every call. 214 (Instance lifecycle and answer submission stay on :attr:`instances` / 215 :attr:`submissions`, keyed by ``instance_id``.)""" 216 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.)
218 @cached_property 219 def series(self) -> SeriesResource: 220 """A recurring contest's ladder (public), not its schedule — 221 configuring a series is ``client.admin.series``.""" 222 return SeriesResource(self)
A recurring contest's ladder (public), not its schedule —
configuring a series is client.admin.series.
224 @property 225 def suites(self) -> SuitesResource: 226 """The benchmark suites -- public challenge sets anybody practises or 227 evaluates against at any time (#2097). Playing one is 228 ``client.competition(id)``, like any competition.""" 229 return SuitesResource(self)
The benchmark suites -- public challenge sets anybody practises or
evaluates against at any time (#2097). Playing one is
client.competition(id), like any competition.
231 @cached_property 232 def eval(self) -> EvalResource: 233 """Public model-evaluation leaderboard (ranking + dimensional breakdowns).""" 234 return EvalResource(self)
Public model-evaluation leaderboard (ranking + dimensional breakdowns).
236 @property 237 def registry(self) -> RegistryResource: 238 """The container registry's token endpoint (docker follows it itself).""" 239 return RegistryResource(self)
The container registry's token endpoint (docker follows it itself).
241 @cached_property 242 def vendor(self) -> VendorResource: 243 """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer).""" 244 return VendorResource(self)
Vendor tenancy — the calling vendor's own models / runs (pv_ bearer).
246 @cached_property 247 def instances(self) -> InstancesResource: 248 """Launch / control / inspect challenge instances by instance id 249 (the competition is inferred server-side).""" 250 return InstancesResource(self)
Launch / control / inspect challenge instances by instance id (the competition is inferred server-side).
252 @cached_property 253 def submissions(self) -> SubmissionsResource: 254 """Submit / verify answers + list submissions (by instance id), plus 255 the instance-less QA challenges.""" 256 return SubmissionsResource(self)
Submit / verify answers + list submissions (by instance id), plus the instance-less QA challenges.
258 @cached_property 259 def patches(self) -> PatchesResource: 260 """AWD+ defence: submit a patch, then watch for its verdict.""" 261 return PatchesResource(self)
AWD+ defence: submit a patch, then watch for its verdict.
263 @cached_property 264 def awd(self) -> AwdResource: 265 """Classic AWD: submit a round's captured flags in one batch.""" 266 return AwdResource(self)
Classic AWD: submit a round's captured flags in one batch.
268 @cached_property 269 def reports(self) -> ReportsResource: 270 """The engagement report: save a version, read the current one.""" 271 return ReportsResource(self)
The engagement report: save a version, read the current one.
273 @cached_property 274 def search(self) -> SearchResource: 275 """Global search: competitions, challenges and teams by name.""" 276 return SearchResource(self)
Global search: competitions, challenges and teams by name.
278 @cached_property 279 def tasks(self) -> TasksResource: 280 """The background tasks you may see: when each waited, ran and ended.""" 281 return TasksResource(self)
The background tasks you may see: when each waited, ran and ended.
283 @cached_property 284 def activities(self) -> ActivitiesResource: 285 """Platform activity log + histogram.""" 286 return ActivitiesResource(self)
Platform activity log + histogram.
288 @cached_property 289 def nodes(self) -> NodesResource: 290 """Public worker-node list (operator node mgmt is on ``admin.nodes``).""" 291 return NodesResource(self)
Public worker-node list (operator node mgmt is on admin.nodes).
293 @cached_property 294 def admin(self) -> AdminClient: 295 """Admin / operator surface (separate, role-gated server-side). 296 297 Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`. 298 """ 299 return AdminClient(self)
Admin / operator surface (separate, role-gated server-side).
Shares this client's transport. See ~ctfy.sdk.admin.AdminClient.
308 def get_meta(self) -> MetaResponse: 309 """Server identity + challenge repo SHA + build version. 310 Admin tokens additionally see cluster capacity + team / solve 311 counts in the same payload.""" 312 resp = self.request("GET", "/meta") 313 _raise_for_status(resp) 314 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.
316 def get_landing(self) -> LandingResponse: 317 """What the public home page shows: the featured event (``None`` 318 when nothing featured may be shown) and the catalog by domain.""" 319 resp = self.request("GET", "/landing") 320 _raise_for_status(resp) 321 return LandingResponse.model_validate(resp.json())
What the public home page shows: the featured event (None
when nothing featured may be shown) and the catalog by domain.
323 def get_public_stats(self) -> PublicStatsResponse: 324 """The home page's live eval numbers and rounded platform scale 325 (#3413). Public and the same for every caller.""" 326 resp = self.request("GET", "/stats/public") 327 _raise_for_status(resp) 328 return PublicStatsResponse.model_validate(resp.json())
The home page's live eval numbers and rounded platform scale (#3413). Public and the same for every caller.
330 def cluster_info(self) -> ClusterInfo: 331 """Aggregate worker-node capacity / utilisation (the public 332 capacity banner; no per-node detail).""" 333 resp = self.request("GET", "/cluster-info") 334 _raise_for_status(resp) 335 return ClusterInfo.model_validate(resp.json())
Aggregate worker-node capacity / utilisation (the public capacity banner; no per-node detail).
337 def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck: 338 """Compare this client's ``ctfy`` version against the server's. 339 340 Uses the cheap unauthenticated ``/health`` probe (pass an 341 already-fetched :class:`HealthResponse` to avoid a second round 342 trip). Pure classification — never prints, never raises on a 343 mismatch, never mutates anything. The caller (CLI, MCP, harness) 344 decides what to do with :class:`~ctfy.core.version.VersionCheck` 345 (typically: print ``.message`` to stderr when not ``.quiet``). 346 347 A server too old to report its version yields 348 :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which 349 is ``.quiet`` — so this stays silent against legacy servers 350 rather than crying wolf. 351 """ 352 h = health if health is not None else self.health() 353 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.
355 def events(self, *, auto_reconnect: bool = True) -> EventStream: 356 """Open the platform SSE event stream. 357 358 Yields ``{"event": <name>, "data": <dict>}`` for each frame. 359 Filters: team-scoped events for every team the caller's user is 360 on (across every per-comp competition), plus all global events; 361 admin tokens additionally see admin-only frames. 362 363 Usage:: 364 365 with client.events() as stream: 366 for event in stream: 367 if event["event"] == "solve": 368 ... 369 370 Auto-reconnects on transient network errors with exponential 371 backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make 372 the iterator raise instead. 373 """ 374 token = self._token 375 376 def _factory() -> httpx.Client: 377 # New client per session so a reconnect after a stale 378 # connection drops fresh sockets, not warmed-over ones. 379 return httpx.Client(base_url=self._base_url, timeout=None) 380 381 return EventStream( 382 client_factory=_factory, 383 path="/events", 384 token=token, 385 auto_reconnect=auto_reconnect, 386 )
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.