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_PLATFORM_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.authoring import AuthoringResource 63from ctfy.sdk.resources.awd import AwdResource 64from ctfy.sdk.resources.challenges import ChallengesResource 65from ctfy.sdk.resources.changelog import ChangelogResource 66from ctfy.sdk.resources.competitions import CompetitionsResource 67from ctfy.sdk.resources.eval import EvalResource, RegistryResource 68from ctfy.sdk.resources.instances import InstancesResource 69from ctfy.sdk.resources.me import MeResource 70from ctfy.sdk.resources.nodes import NodesResource 71from ctfy.sdk.resources.registration import RegistrationResource 72from ctfy.sdk.resources.reports import ReportsResource 73from ctfy.sdk.resources.scoreboard import ScoreboardResource 74from ctfy.sdk.resources.series import SeriesResource 75from ctfy.sdk.resources.submissions import PatchesResource, SubmissionsResource 76from ctfy.sdk.resources.teams import TeamsResource 77from ctfy.sdk.resources.users import UsersResource 78from ctfy.sdk.resources.vendor import VendorResource 79from ctfy.server.models import ClusterInfo, HealthResponse, MetaResponse 80 81__all__ = ["InstanceReadyResult", "PlatformClient"] 82 83 84class PlatformClient(BaseHttpClient): 85 """Player-facing HTTP client for the ctfy platform. 86 87 Methods are grouped into resource namespaces (``client.teams.list()``, …) 88 plus a per-competition scope via ``client.competition(id)``. The admin 89 surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via 90 :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers. 91 """ 92 93 def __init__( 94 self, 95 server_url: str, 96 token: str = "", 97 max_retries: int = 2, 98 timeout: int = DEFAULT_PLATFORM_TIMEOUT, 99 transport: httpx.BaseTransport | None = None, 100 ): 101 self._url = server_url.rstrip("/") 102 super().__init__( 103 f"{self._url}/api/v1", 104 token, 105 timeout=timeout, 106 max_retries=max_retries, 107 transport=transport, 108 ) 109 110 @property 111 def server_url(self) -> str: 112 """The platform root, without the ``/api/v1`` suffix. 113 114 Exposed for callers that must build a non-``/api/v1`` URL of 115 their own — today the shell WebSocket, whose ticket carries a 116 platform-relative path the client has to absolutise against the 117 scheme and host *it* reached us on. 118 """ 119 return self._url 120 121 # -- resource namespaces ------------------------------------------------ 122 123 @cached_property 124 def registration(self) -> RegistrationResource: 125 """The caller's own per-competition registration + team logo.""" 126 return RegistrationResource(self) 127 128 @property 129 def authoring(self) -> AuthoringResource: 130 """Upload a challenge your team wrote, for a peer-authored event.""" 131 return AuthoringResource(self) 132 133 @cached_property 134 def teams(self) -> TeamsResource: 135 """Public team discovery + profile reads.""" 136 return TeamsResource(self) 137 138 @cached_property 139 def users(self) -> UsersResource: 140 """Public per-user profile reads.""" 141 return UsersResource(self) 142 143 @cached_property 144 def me(self) -> MeResource: 145 """The calling user's profile, inbox, account, and progress.""" 146 return MeResource(self) 147 148 @cached_property 149 def auth(self) -> AuthResource: 150 """Password auth, OAuth identities, API tokens, sign-in discovery.""" 151 return AuthResource(self) 152 153 @cached_property 154 def achievements(self) -> AchievementsResource: 155 """Public badge catalog, recent-unlock feed, easter eggs.""" 156 return AchievementsResource(self) 157 158 @cached_property 159 def challenges(self) -> ChallengesResource: 160 """Global challenge catalog, attachments, facets, feedback chips.""" 161 return ChallengesResource(self) 162 163 @cached_property 164 def changelog(self) -> ChangelogResource: 165 """Public "What's New" feed, plus this account's seen marker.""" 166 return ChangelogResource(self) 167 168 @cached_property 169 def competitions(self) -> CompetitionsResource: 170 """Discover competitions (``list`` / ``get``). To act within one, use 171 :meth:`competition`.""" 172 return CompetitionsResource(self) 173 174 def competition(self, competition_id: str) -> Competition: 175 """Scope to one competition. The inherently competition-scoped 176 operations — register, your team (captain tooling + invites + 177 join-requests), the comp's challenges, team search, and standings — 178 hang off the returned :class:`~ctfy.sdk.competition.Competition` 179 handle, so ``competition_id`` is named once instead of on every call. 180 (Instance lifecycle and answer submission stay on :attr:`instances` / 181 :attr:`submissions`, keyed by ``instance_id``.)""" 182 return Competition(self, competition_id) 183 184 @cached_property 185 def series(self) -> SeriesResource: 186 """A recurring contest's ladder (public), not its schedule — 187 configuring a series is ``client.admin.series``.""" 188 return SeriesResource(self) 189 190 @cached_property 191 def eval(self) -> EvalResource: 192 """Public model-evaluation leaderboard (ranking + dimensional breakdowns).""" 193 return EvalResource(self) 194 195 @property 196 def registry(self) -> RegistryResource: 197 """The container registry's token endpoint (docker follows it itself).""" 198 return RegistryResource(self) 199 200 @cached_property 201 def vendor(self) -> VendorResource: 202 """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer).""" 203 return VendorResource(self) 204 205 @cached_property 206 def instances(self) -> InstancesResource: 207 """Launch / control / inspect challenge instances by instance id 208 (the competition is inferred server-side).""" 209 return InstancesResource(self) 210 211 @cached_property 212 def submissions(self) -> SubmissionsResource: 213 """Submit / verify answers + list submissions (by instance id), plus 214 the instance-less QA challenges.""" 215 return SubmissionsResource(self) 216 217 @cached_property 218 def patches(self) -> PatchesResource: 219 """AWD+ defence: submit a patch, then watch for its verdict.""" 220 return PatchesResource(self) 221 222 @cached_property 223 def awd(self) -> AwdResource: 224 """Classic AWD: submit a round's captured flags in one batch.""" 225 return AwdResource(self) 226 227 @cached_property 228 def reports(self) -> ReportsResource: 229 """The engagement report: save a version, read the current one.""" 230 return ReportsResource(self) 231 232 @cached_property 233 def scoreboard(self) -> ScoreboardResource: 234 """Global standings, per-challenge stats, persisted snapshots.""" 235 return ScoreboardResource(self) 236 237 @cached_property 238 def activities(self) -> ActivitiesResource: 239 """Platform activity log + histogram.""" 240 return ActivitiesResource(self) 241 242 @cached_property 243 def nodes(self) -> NodesResource: 244 """Public worker-node list (operator node mgmt is on ``admin.nodes``).""" 245 return NodesResource(self) 246 247 @cached_property 248 def admin(self) -> AdminClient: 249 """Admin / operator surface (separate, role-gated server-side). 250 251 Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`. 252 """ 253 return AdminClient(self) 254 255 # -- top-level convenience: server status / realtime / version ---------- 256 257 def health(self) -> HealthResponse: 258 resp = self.request("GET", "/health") 259 _raise_for_status(resp) 260 return HealthResponse.model_validate(resp.json()) 261 262 def get_meta(self) -> MetaResponse: 263 """Server identity + challenge repo SHA + build version. 264 Admin tokens additionally see cluster capacity + team / solve 265 counts in the same payload.""" 266 resp = self.request("GET", "/meta") 267 _raise_for_status(resp) 268 return MetaResponse.model_validate(resp.json()) 269 270 def cluster_info(self) -> ClusterInfo: 271 """Aggregate worker-node capacity / utilisation (the public 272 capacity banner; no per-node detail).""" 273 resp = self.request("GET", "/cluster-info") 274 _raise_for_status(resp) 275 return ClusterInfo.model_validate(resp.json()) 276 277 def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck: 278 """Compare this client's ``ctfy`` version against the server's. 279 280 Uses the cheap unauthenticated ``/health`` probe (pass an 281 already-fetched :class:`HealthResponse` to avoid a second round 282 trip). Pure classification — never prints, never raises on a 283 mismatch, never mutates anything. The caller (CLI, MCP, harness) 284 decides what to do with :class:`~ctfy.core.version.VersionCheck` 285 (typically: print ``.message`` to stderr when not ``.quiet``). 286 287 A server too old to report its version yields 288 :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which 289 is ``.quiet`` — so this stays silent against legacy servers 290 rather than crying wolf. 291 """ 292 h = health if health is not None else self.health() 293 return check_compatibility(package_version(), h.version) 294 295 def events(self, *, auto_reconnect: bool = True) -> EventStream: 296 """Open the platform SSE event stream. 297 298 Yields ``{"event": <name>, "data": <dict>}`` for each frame. 299 Filters: team-scoped events for every team the caller's user is 300 on (across every per-comp competition), plus all global events; 301 admin tokens additionally see admin-only frames. 302 303 Usage:: 304 305 with client.events() as stream: 306 for event in stream: 307 if event["event"] == "solve": 308 ... 309 310 Auto-reconnects on transient network errors with exponential 311 backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make 312 the iterator raise instead. 313 """ 314 token = self._token 315 316 def _factory() -> httpx.Client: 317 # New client per session so a reconnect after a stale 318 # connection drops fresh sockets, not warmed-over ones. 319 return httpx.Client(base_url=self._base_url, timeout=None) 320 321 return EventStream( 322 client_factory=_factory, 323 path="/events", 324 token=token, 325 auto_reconnect=auto_reconnect, 326 )
88@dataclass 89class InstanceReadyResult: 90 """Result of polling an instance until ready.""" 91 92 id: str 93 surface: AttackSurface 94 cert_volume: str = ""
Result of polling an instance until ready.
85class PlatformClient(BaseHttpClient): 86 """Player-facing HTTP client for the ctfy platform. 87 88 Methods are grouped into resource namespaces (``client.teams.list()``, …) 89 plus a per-competition scope via ``client.competition(id)``. The admin 90 surface is a separate :class:`~ctfy.sdk.admin.AdminClient` reached via 91 :attr:`admin`. Used by the CLI, the SDK, the MCP server, and external callers. 92 """ 93 94 def __init__( 95 self, 96 server_url: str, 97 token: str = "", 98 max_retries: int = 2, 99 timeout: int = DEFAULT_PLATFORM_TIMEOUT, 100 transport: httpx.BaseTransport | None = None, 101 ): 102 self._url = server_url.rstrip("/") 103 super().__init__( 104 f"{self._url}/api/v1", 105 token, 106 timeout=timeout, 107 max_retries=max_retries, 108 transport=transport, 109 ) 110 111 @property 112 def server_url(self) -> str: 113 """The platform root, without the ``/api/v1`` suffix. 114 115 Exposed for callers that must build a non-``/api/v1`` URL of 116 their own — today the shell WebSocket, whose ticket carries a 117 platform-relative path the client has to absolutise against the 118 scheme and host *it* reached us on. 119 """ 120 return self._url 121 122 # -- resource namespaces ------------------------------------------------ 123 124 @cached_property 125 def registration(self) -> RegistrationResource: 126 """The caller's own per-competition registration + team logo.""" 127 return RegistrationResource(self) 128 129 @property 130 def authoring(self) -> AuthoringResource: 131 """Upload a challenge your team wrote, for a peer-authored event.""" 132 return AuthoringResource(self) 133 134 @cached_property 135 def teams(self) -> TeamsResource: 136 """Public team discovery + profile reads.""" 137 return TeamsResource(self) 138 139 @cached_property 140 def users(self) -> UsersResource: 141 """Public per-user profile reads.""" 142 return UsersResource(self) 143 144 @cached_property 145 def me(self) -> MeResource: 146 """The calling user's profile, inbox, account, and progress.""" 147 return MeResource(self) 148 149 @cached_property 150 def auth(self) -> AuthResource: 151 """Password auth, OAuth identities, API tokens, sign-in discovery.""" 152 return AuthResource(self) 153 154 @cached_property 155 def achievements(self) -> AchievementsResource: 156 """Public badge catalog, recent-unlock feed, easter eggs.""" 157 return AchievementsResource(self) 158 159 @cached_property 160 def challenges(self) -> ChallengesResource: 161 """Global challenge catalog, attachments, facets, feedback chips.""" 162 return ChallengesResource(self) 163 164 @cached_property 165 def changelog(self) -> ChangelogResource: 166 """Public "What's New" feed, plus this account's seen marker.""" 167 return ChangelogResource(self) 168 169 @cached_property 170 def competitions(self) -> CompetitionsResource: 171 """Discover competitions (``list`` / ``get``). To act within one, use 172 :meth:`competition`.""" 173 return CompetitionsResource(self) 174 175 def competition(self, competition_id: str) -> Competition: 176 """Scope to one competition. The inherently competition-scoped 177 operations — register, your team (captain tooling + invites + 178 join-requests), the comp's challenges, team search, and standings — 179 hang off the returned :class:`~ctfy.sdk.competition.Competition` 180 handle, so ``competition_id`` is named once instead of on every call. 181 (Instance lifecycle and answer submission stay on :attr:`instances` / 182 :attr:`submissions`, keyed by ``instance_id``.)""" 183 return Competition(self, competition_id) 184 185 @cached_property 186 def series(self) -> SeriesResource: 187 """A recurring contest's ladder (public), not its schedule — 188 configuring a series is ``client.admin.series``.""" 189 return SeriesResource(self) 190 191 @cached_property 192 def eval(self) -> EvalResource: 193 """Public model-evaluation leaderboard (ranking + dimensional breakdowns).""" 194 return EvalResource(self) 195 196 @property 197 def registry(self) -> RegistryResource: 198 """The container registry's token endpoint (docker follows it itself).""" 199 return RegistryResource(self) 200 201 @cached_property 202 def vendor(self) -> VendorResource: 203 """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer).""" 204 return VendorResource(self) 205 206 @cached_property 207 def instances(self) -> InstancesResource: 208 """Launch / control / inspect challenge instances by instance id 209 (the competition is inferred server-side).""" 210 return InstancesResource(self) 211 212 @cached_property 213 def submissions(self) -> SubmissionsResource: 214 """Submit / verify answers + list submissions (by instance id), plus 215 the instance-less QA challenges.""" 216 return SubmissionsResource(self) 217 218 @cached_property 219 def patches(self) -> PatchesResource: 220 """AWD+ defence: submit a patch, then watch for its verdict.""" 221 return PatchesResource(self) 222 223 @cached_property 224 def awd(self) -> AwdResource: 225 """Classic AWD: submit a round's captured flags in one batch.""" 226 return AwdResource(self) 227 228 @cached_property 229 def reports(self) -> ReportsResource: 230 """The engagement report: save a version, read the current one.""" 231 return ReportsResource(self) 232 233 @cached_property 234 def scoreboard(self) -> ScoreboardResource: 235 """Global standings, per-challenge stats, persisted snapshots.""" 236 return ScoreboardResource(self) 237 238 @cached_property 239 def activities(self) -> ActivitiesResource: 240 """Platform activity log + histogram.""" 241 return ActivitiesResource(self) 242 243 @cached_property 244 def nodes(self) -> NodesResource: 245 """Public worker-node list (operator node mgmt is on ``admin.nodes``).""" 246 return NodesResource(self) 247 248 @cached_property 249 def admin(self) -> AdminClient: 250 """Admin / operator surface (separate, role-gated server-side). 251 252 Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`. 253 """ 254 return AdminClient(self) 255 256 # -- top-level convenience: server status / realtime / version ---------- 257 258 def health(self) -> HealthResponse: 259 resp = self.request("GET", "/health") 260 _raise_for_status(resp) 261 return HealthResponse.model_validate(resp.json()) 262 263 def get_meta(self) -> MetaResponse: 264 """Server identity + challenge repo SHA + build version. 265 Admin tokens additionally see cluster capacity + team / solve 266 counts in the same payload.""" 267 resp = self.request("GET", "/meta") 268 _raise_for_status(resp) 269 return MetaResponse.model_validate(resp.json()) 270 271 def cluster_info(self) -> ClusterInfo: 272 """Aggregate worker-node capacity / utilisation (the public 273 capacity banner; no per-node detail).""" 274 resp = self.request("GET", "/cluster-info") 275 _raise_for_status(resp) 276 return ClusterInfo.model_validate(resp.json()) 277 278 def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck: 279 """Compare this client's ``ctfy`` version against the server's. 280 281 Uses the cheap unauthenticated ``/health`` probe (pass an 282 already-fetched :class:`HealthResponse` to avoid a second round 283 trip). Pure classification — never prints, never raises on a 284 mismatch, never mutates anything. The caller (CLI, MCP, harness) 285 decides what to do with :class:`~ctfy.core.version.VersionCheck` 286 (typically: print ``.message`` to stderr when not ``.quiet``). 287 288 A server too old to report its version yields 289 :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which 290 is ``.quiet`` — so this stays silent against legacy servers 291 rather than crying wolf. 292 """ 293 h = health if health is not None else self.health() 294 return check_compatibility(package_version(), h.version) 295 296 def events(self, *, auto_reconnect: bool = True) -> EventStream: 297 """Open the platform SSE event stream. 298 299 Yields ``{"event": <name>, "data": <dict>}`` for each frame. 300 Filters: team-scoped events for every team the caller's user is 301 on (across every per-comp competition), plus all global events; 302 admin tokens additionally see admin-only frames. 303 304 Usage:: 305 306 with client.events() as stream: 307 for event in stream: 308 if event["event"] == "solve": 309 ... 310 311 Auto-reconnects on transient network errors with exponential 312 backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make 313 the iterator raise instead. 314 """ 315 token = self._token 316 317 def _factory() -> httpx.Client: 318 # New client per session so a reconnect after a stale 319 # connection drops fresh sockets, not warmed-over ones. 320 return httpx.Client(base_url=self._base_url, timeout=None) 321 322 return EventStream( 323 client_factory=_factory, 324 path="/events", 325 token=token, 326 auto_reconnect=auto_reconnect, 327 )
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.
94 def __init__( 95 self, 96 server_url: str, 97 token: str = "", 98 max_retries: int = 2, 99 timeout: int = DEFAULT_PLATFORM_TIMEOUT, 100 transport: httpx.BaseTransport | None = None, 101 ): 102 self._url = server_url.rstrip("/") 103 super().__init__( 104 f"{self._url}/api/v1", 105 token, 106 timeout=timeout, 107 max_retries=max_retries, 108 transport=transport, 109 )
max_retries is how many times a request that could not
connect, or timed out, is tried again — 0 is one attempt.
⚠️ It counted attempts: the loop ran range(max_retries), so
0 sent nothing and died on a bare AssertionError, the one
value whose meaning the ecosystem agrees on (anthropic and
openai's max_retries, httpx's retries). The default
kept its behaviour rather than its number — three attempts, now
spelled 2 — and a negative count is refused here, not at the
first request.
transport swaps where requests are delivered, not what
delivers them.
⚠️ It exists so nothing has to substitute the client itself.
The retry loop below catches httpx.ConnectError /
httpx.TimeoutException, so this class is coupled to httpx's
exception hierarchy and not merely to "something that can send a
request" — a different library's client would satisfy a
structural reading of the seam and then raise past those
handlers. Tests used to assemble instances with
__new__ + a hand-set _client; a real parameter is the
supported version of what they were reaching for, and it keeps
self._client genuinely an httpx.Client.
None is httpx's own default, so an ordinary caller is
unaffected.
111 @property 112 def server_url(self) -> str: 113 """The platform root, without the ``/api/v1`` suffix. 114 115 Exposed for callers that must build a non-``/api/v1`` URL of 116 their own — today the shell WebSocket, whose ticket carries a 117 platform-relative path the client has to absolutise against the 118 scheme and host *it* reached us on. 119 """ 120 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.
124 @cached_property 125 def registration(self) -> RegistrationResource: 126 """The caller's own per-competition registration + team logo.""" 127 return RegistrationResource(self)
The caller's own per-competition registration + team logo.
134 @cached_property 135 def teams(self) -> TeamsResource: 136 """Public team discovery + profile reads.""" 137 return TeamsResource(self)
Public team discovery + profile reads.
139 @cached_property 140 def users(self) -> UsersResource: 141 """Public per-user profile reads.""" 142 return UsersResource(self)
Public per-user profile reads.
144 @cached_property 145 def me(self) -> MeResource: 146 """The calling user's profile, inbox, account, and progress.""" 147 return MeResource(self)
The calling user's profile, inbox, account, and progress.
149 @cached_property 150 def auth(self) -> AuthResource: 151 """Password auth, OAuth identities, API tokens, sign-in discovery.""" 152 return AuthResource(self)
Password auth, OAuth identities, API tokens, sign-in discovery.
154 @cached_property 155 def achievements(self) -> AchievementsResource: 156 """Public badge catalog, recent-unlock feed, easter eggs.""" 157 return AchievementsResource(self)
Public badge catalog, recent-unlock feed, easter eggs.
159 @cached_property 160 def challenges(self) -> ChallengesResource: 161 """Global challenge catalog, attachments, facets, feedback chips.""" 162 return ChallengesResource(self)
Global challenge catalog, attachments, facets, feedback chips.
164 @cached_property 165 def changelog(self) -> ChangelogResource: 166 """Public "What's New" feed, plus this account's seen marker.""" 167 return ChangelogResource(self)
Public "What's New" feed, plus this account's seen marker.
169 @cached_property 170 def competitions(self) -> CompetitionsResource: 171 """Discover competitions (``list`` / ``get``). To act within one, use 172 :meth:`competition`.""" 173 return CompetitionsResource(self)
Discover competitions (list / get). To act within one, use
competition().
175 def competition(self, competition_id: str) -> Competition: 176 """Scope to one competition. The inherently competition-scoped 177 operations — register, your team (captain tooling + invites + 178 join-requests), the comp's challenges, team search, and standings — 179 hang off the returned :class:`~ctfy.sdk.competition.Competition` 180 handle, so ``competition_id`` is named once instead of on every call. 181 (Instance lifecycle and answer submission stay on :attr:`instances` / 182 :attr:`submissions`, keyed by ``instance_id``.)""" 183 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.)
185 @cached_property 186 def series(self) -> SeriesResource: 187 """A recurring contest's ladder (public), not its schedule — 188 configuring a series is ``client.admin.series``.""" 189 return SeriesResource(self)
A recurring contest's ladder (public), not its schedule —
configuring a series is client.admin.series.
191 @cached_property 192 def eval(self) -> EvalResource: 193 """Public model-evaluation leaderboard (ranking + dimensional breakdowns).""" 194 return EvalResource(self)
Public model-evaluation leaderboard (ranking + dimensional breakdowns).
196 @property 197 def registry(self) -> RegistryResource: 198 """The container registry's token endpoint (docker follows it itself).""" 199 return RegistryResource(self)
The container registry's token endpoint (docker follows it itself).
201 @cached_property 202 def vendor(self) -> VendorResource: 203 """Vendor tenancy — the calling vendor's own models / runs (``pv_`` bearer).""" 204 return VendorResource(self)
Vendor tenancy — the calling vendor's own models / runs (pv_ bearer).
206 @cached_property 207 def instances(self) -> InstancesResource: 208 """Launch / control / inspect challenge instances by instance id 209 (the competition is inferred server-side).""" 210 return InstancesResource(self)
Launch / control / inspect challenge instances by instance id (the competition is inferred server-side).
212 @cached_property 213 def submissions(self) -> SubmissionsResource: 214 """Submit / verify answers + list submissions (by instance id), plus 215 the instance-less QA challenges.""" 216 return SubmissionsResource(self)
Submit / verify answers + list submissions (by instance id), plus the instance-less QA challenges.
218 @cached_property 219 def patches(self) -> PatchesResource: 220 """AWD+ defence: submit a patch, then watch for its verdict.""" 221 return PatchesResource(self)
AWD+ defence: submit a patch, then watch for its verdict.
223 @cached_property 224 def awd(self) -> AwdResource: 225 """Classic AWD: submit a round's captured flags in one batch.""" 226 return AwdResource(self)
Classic AWD: submit a round's captured flags in one batch.
228 @cached_property 229 def reports(self) -> ReportsResource: 230 """The engagement report: save a version, read the current one.""" 231 return ReportsResource(self)
The engagement report: save a version, read the current one.
233 @cached_property 234 def scoreboard(self) -> ScoreboardResource: 235 """Global standings, per-challenge stats, persisted snapshots.""" 236 return ScoreboardResource(self)
Global standings, per-challenge stats, persisted snapshots.
238 @cached_property 239 def activities(self) -> ActivitiesResource: 240 """Platform activity log + histogram.""" 241 return ActivitiesResource(self)
Platform activity log + histogram.
243 @cached_property 244 def nodes(self) -> NodesResource: 245 """Public worker-node list (operator node mgmt is on ``admin.nodes``).""" 246 return NodesResource(self)
Public worker-node list (operator node mgmt is on admin.nodes).
248 @cached_property 249 def admin(self) -> AdminClient: 250 """Admin / operator surface (separate, role-gated server-side). 251 252 Shares this client's transport. See :class:`~ctfy.sdk.admin.AdminClient`. 253 """ 254 return AdminClient(self)
Admin / operator surface (separate, role-gated server-side).
Shares this client's transport. See ~ctfy.sdk.admin.AdminClient.
263 def get_meta(self) -> MetaResponse: 264 """Server identity + challenge repo SHA + build version. 265 Admin tokens additionally see cluster capacity + team / solve 266 counts in the same payload.""" 267 resp = self.request("GET", "/meta") 268 _raise_for_status(resp) 269 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.
271 def cluster_info(self) -> ClusterInfo: 272 """Aggregate worker-node capacity / utilisation (the public 273 capacity banner; no per-node detail).""" 274 resp = self.request("GET", "/cluster-info") 275 _raise_for_status(resp) 276 return ClusterInfo.model_validate(resp.json())
Aggregate worker-node capacity / utilisation (the public capacity banner; no per-node detail).
278 def check_server_compatibility(self, *, health: HealthResponse | None = None) -> VersionCheck: 279 """Compare this client's ``ctfy`` version against the server's. 280 281 Uses the cheap unauthenticated ``/health`` probe (pass an 282 already-fetched :class:`HealthResponse` to avoid a second round 283 trip). Pure classification — never prints, never raises on a 284 mismatch, never mutates anything. The caller (CLI, MCP, harness) 285 decides what to do with :class:`~ctfy.core.version.VersionCheck` 286 (typically: print ``.message`` to stderr when not ``.quiet``). 287 288 A server too old to report its version yields 289 :attr:`Compatibility.UNKNOWN` (``health.version == ""``), which 290 is ``.quiet`` — so this stays silent against legacy servers 291 rather than crying wolf. 292 """ 293 h = health if health is not None else self.health() 294 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.
296 def events(self, *, auto_reconnect: bool = True) -> EventStream: 297 """Open the platform SSE event stream. 298 299 Yields ``{"event": <name>, "data": <dict>}`` for each frame. 300 Filters: team-scoped events for every team the caller's user is 301 on (across every per-comp competition), plus all global events; 302 admin tokens additionally see admin-only frames. 303 304 Usage:: 305 306 with client.events() as stream: 307 for event in stream: 308 if event["event"] == "solve": 309 ... 310 311 Auto-reconnects on transient network errors with exponential 312 backoff (1s → 30s capped). Set ``auto_reconnect=False`` to make 313 the iterator raise instead. 314 """ 315 token = self._token 316 317 def _factory() -> httpx.Client: 318 # New client per session so a reconnect after a stale 319 # connection drops fresh sockets, not warmed-over ones. 320 return httpx.Client(base_url=self._base_url, timeout=None) 321 322 return EventStream( 323 client_factory=_factory, 324 path="/events", 325 token=token, 326 auto_reconnect=auto_reconnect, 327 )
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.