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