ctfy.sdk.competition
client.competition(id) — a handle scoped to one competition.
Collects the operations that are inherently competition-scoped — the ones
that used to repeat competition_id on every call: registration, your team
(captain tooling, invites, join-requests), the competition's challenge list,
team search, and standings. competition_id is named once
(client.competition(id)).
Discovery of competitions stays on client.competitions (.list() /
.get(id)). Instance lifecycle (client.instances) and answer submission
(client.submissions) stay flat: they're keyed by instance_id and the
server infers the competition, so they don't belong to a comp handle.
comp = client.competition("spring-ctf")
comp.register(mode="solo")
board = comp.scoreboard()
comp.team.invites.create(max_uses=3)
1"""``client.competition(id)`` — a handle scoped to one competition. 2 3Collects the operations that are *inherently* competition-scoped — the ones 4that used to repeat ``competition_id`` on every call: registration, your team 5(captain tooling, invites, join-requests), the competition's challenge list, 6team search, and standings. ``competition_id`` is named once 7(``client.competition(id)``). 8 9Discovery of competitions stays on ``client.competitions`` (``.list()`` / 10``.get(id)``). Instance lifecycle (``client.instances``) and answer submission 11(``client.submissions``) stay flat: they're keyed by ``instance_id`` and the 12server infers the competition, so they don't belong to a comp handle. 13 14 comp = client.competition("spring-ctf") 15 comp.register(mode="solo") 16 board = comp.scoreboard() 17 comp.team.invites.create(max_uses=3) 18""" 19 20from __future__ import annotations 21 22from functools import cached_property 23from typing import Any, Literal 24 25from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 26from ctfy.sdk.base import BaseHttpClient 27from ctfy.sdk.competition.team import CompetitionTeam 28from ctfy.server.models import ( 29 AdminSolveMatrix, 30 ChallengeInfo, 31 CompetitionApplicationInfo, 32 CompetitionChallengeBreakdown, 33 CompetitionDetail, 34 CompetitionRoster, 35 CompetitionScoreDistribution, 36 CompetitionScoreHistory, 37 DefenceMatrixResponse, 38 ScoreboardEntry, 39 TeamDetail, 40 TeamInfo, 41 TeamInviteInfo, 42) 43 44__all__ = ["Competition"] 45 46 47class Competition: 48 """A competition-scoped view: registration, your team, the comp's 49 challenges, team search, and standings — all within one competition.""" 50 51 def __init__(self, http: BaseHttpClient, competition_id: str) -> None: 52 self._http = http 53 self._cid = competition_id 54 55 @property 56 def id(self) -> str: 57 """The competition id this handle is bound to.""" 58 return self._cid 59 60 # -- info ------------------------------------------------------------- 61 62 def detail(self) -> CompetitionDetail: 63 """Full competition detail including resolved challenge summaries 64 (``.challenges``) in the admin's curated order.""" 65 resp = self._http.request("GET", f"/competitions/{self._cid}") 66 _raise_for_status(resp) 67 return CompetitionDetail.model_validate(resp.json()) 68 69 def challenges( 70 self, 71 difficulty: str | None = None, 72 tag: str = "", 73 q: str = "", 74 offset: int = 0, 75 limit: int = 50, 76 ) -> PagedList[ChallengeInfo]: 77 """Challenges scoped to this competition, curated-order sorted.""" 78 params: dict[str, Any] = {"offset": offset, "limit": limit} 79 if difficulty: 80 params["difficulty"] = difficulty 81 if tag: 82 params["tag"] = tag 83 if q: 84 params["q"] = q 85 resp = self._http.request("GET", f"/competitions/{self._cid}/challenges", params=params) 86 _raise_for_status(resp) 87 return _extract_items(resp.json(), ChallengeInfo) 88 89 def search_teams(self, q: str = "", offset: int = 0, limit: int = 50) -> PagedList[TeamInfo]: 90 """Substring search over team names registered in this competition 91 (the join-a-team picker).""" 92 params: dict[str, Any] = {"offset": offset, "limit": limit} 93 if q: 94 params["q"] = q 95 resp = self._http.request("GET", f"/competitions/{self._cid}/teams/search", params=params) 96 _raise_for_status(resp) 97 return _extract_items(resp.json(), TeamInfo) 98 99 # -- register / join -------------------------------------------------- 100 101 def register( 102 self, 103 mode: Literal["solo", "create", "join"] = "solo", 104 *, 105 name: str = "", 106 description: str = "", 107 code: str = "", 108 ) -> TeamDetail: 109 """Get on a team for this competition. 110 111 - ``mode="solo"`` — mint a 1-person team auto-named after you 112 (idempotent on (you, competition)). 113 - ``mode="create"`` — mint a multi-user team you captain; requires 114 ``name``. 409 if you already have a team for this comp. 115 - ``mode="join"`` — redeem a captain's invite ``code`` to join their 116 team. 409 if the code was for a different comp or you already have 117 a team here. 118 """ 119 if mode == "join": 120 if not code: 121 raise ValueError('register(mode="join") requires a `code`') 122 resp = self._http.request( 123 "POST", f"/competitions/{self._cid}/teams/redeem", json={"code": code} 124 ) 125 else: 126 if mode == "create" and not name: 127 raise ValueError('register(mode="create") requires a `name`') 128 body: dict[str, str] = {"mode": mode} 129 if mode == "create": 130 body["name"] = name 131 body["description"] = description 132 resp = self._http.request("POST", f"/competitions/{self._cid}/teams", json=body) 133 _raise_for_status(resp) 134 return TeamDetail.model_validate(resp.json()) 135 136 def request_to_join(self, team_id: str) -> TeamInviteInfo: 137 """Ask to join ``team_id`` in this competition. The captain sees it 138 in their inbox and approves / rejects (``team.requests``).""" 139 resp = self._http.request("POST", f"/competitions/{self._cid}/teams/{team_id}/join-request") 140 _raise_for_status(resp) 141 return TeamInviteInfo.model_validate(resp.json()) 142 143 def accept_invite(self, invite_id: str) -> TeamDetail: 144 """Accept a direct invite sent to you → join the captain's team.""" 145 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/accept") 146 _raise_for_status(resp) 147 return TeamDetail.model_validate(resp.json()) 148 149 def decline_invite(self, invite_id: str) -> None: 150 """Decline a direct invite sent to you (only the named target may).""" 151 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/decline") 152 _raise_for_status(resp) 153 154 # -- asking to be let in (a private, listed competition) --------------- 155 156 def application(self) -> CompetitionApplicationInfo | None: 157 """Your application to this competition, or ``None`` if you have 158 not applied. 159 160 ``None`` is the server's ``null`` at 200 — a hidden competition 161 already answers 404, so "never applied" is not an error. 162 """ 163 resp = self._http.request("GET", f"/competitions/{self._cid}/application") 164 _raise_for_status(resp) 165 body = resp.json() 166 return None if body is None else CompetitionApplicationInfo.model_validate(body) 167 168 def apply(self, message: str = "") -> CompetitionApplicationInfo: 169 """Ask the organisers of a private competition to invite you. 170 171 For an event that is listed but invitation-only. Calling it again 172 while the application is pending edits ``message`` and keeps its 173 place in the queue. An approval arrives as an invitation, after 174 which :meth:`register` works as for anybody invited. 175 176 Refused (409) with a ``code`` saying why: ``application_not_needed`` 177 (public — just register), ``already_invited`` (you may already take 178 part), ``registration_closed``, ``application_rejected`` (final; 179 the organisers can still invite you). Not a team join request — 180 that is :meth:`request_to_join`, and asks a captain. 181 """ 182 resp = self._http.request( 183 "PUT", f"/competitions/{self._cid}/application", json={"message": message} 184 ) 185 _raise_for_status(resp) 186 return CompetitionApplicationInfo.model_validate(resp.json()) 187 188 def withdraw_application(self) -> None: 189 """Withdraw a pending application. A decided one stays, and asking 190 is ``application_already_decided`` (409).""" 191 resp = self._http.request("DELETE", f"/competitions/{self._cid}/application") 192 _raise_for_status(resp) 193 194 # -- standings -------------------------------------------------------- 195 196 def scoreboard( 197 self, offset: int = 0, limit: int = 50, division: str = "" 198 ) -> PagedList[ScoreboardEntry]: 199 """Ranked standings for this competition. 200 201 ``division`` narrows to one side of a mixed board: ``"human"`` 202 or ``"agent"``. Empty — the default — is everybody, which is the 203 board this method has always returned. ⚠️ Split here rather than 204 by filtering the result: the rows are paginated server-side, so a 205 client-side filter yields whichever of one page matched, still 206 carrying their *merged* ranks. 207 """ 208 params: dict[str, Any] = {"offset": offset, "limit": limit} 209 if division: 210 params["division"] = division 211 resp = self._http.request( 212 "GET", 213 f"/competitions/{self._cid}/scoreboard", 214 params=params, 215 ) 216 _raise_for_status(resp) 217 return _extract_items(resp.json(), ScoreboardEntry) 218 219 def score_history(self, *, top: int = 10) -> CompetitionScoreHistory: 220 """Score-progression series for the top ``top`` teams.""" 221 resp = self._http.request( 222 "GET", f"/competitions/{self._cid}/score-history", params={"top": top} 223 ) 224 _raise_for_status(resp) 225 return CompetitionScoreHistory.model_validate(resp.json()) 226 227 def score_distribution(self) -> CompetitionScoreDistribution: 228 """Score histogram buckets for this competition.""" 229 resp = self._http.request("GET", f"/competitions/{self._cid}/scoreboard/distribution") 230 _raise_for_status(resp) 231 return CompetitionScoreDistribution.model_validate(resp.json()) 232 233 def solve_matrix(self) -> AdminSolveMatrix: 234 """Per-team × per-challenge solve grid scoped to this competition.""" 235 resp = self._http.request("GET", f"/competitions/{self._cid}/solve-matrix") 236 _raise_for_status(resp) 237 return AdminSolveMatrix.model_validate(resp.json()) 238 239 def defence_matrix(self) -> DefenceMatrixResponse: 240 """Per-team × per-challenge **defence** verdicts for this competition. 241 242 The defence-side twin of :meth:`solve_matrix`, and deliberately a 243 separate call rather than a flag on it: a solve cell is binary 244 and monotone, a defence cell is one of five verdicts, and three 245 of them describe a team that tried and is not done. 246 247 A team that fixed a challenge and later submitted a worse patch 248 still reads ``fixed`` — that is what the board scores them on, so 249 this and the standings cannot disagree about who is holding what. 250 A pair with no submission is **absent** from ``cells``, never a 251 present empty string. 252 """ 253 resp = self._http.request("GET", f"/competitions/{self._cid}/defence/matrix") 254 _raise_for_status(resp) 255 return DefenceMatrixResponse.model_validate(resp.json()) 256 257 def roster(self) -> CompetitionRoster: 258 """Every registered team in this competition with its members. 259 260 One request whatever the field size — the per-team 261 :meth:`ctfy.sdk.resources.teams.TeamsResource.get` answers the 262 same question one team at a time, which for a 400-team event is 263 400 round trips. Carries public identity only (display name + 264 avatar), never the entrant dossier. 265 """ 266 resp = self._http.request("GET", f"/competitions/{self._cid}/roster") 267 _raise_for_status(resp) 268 return CompetitionRoster.model_validate(resp.json()) 269 270 def challenge_breakdown(self) -> CompetitionChallengeBreakdown: 271 """Per-challenge solve / attempt counts for this competition.""" 272 resp = self._http.request("GET", f"/competitions/{self._cid}/challenge-breakdown") 273 _raise_for_status(resp) 274 return CompetitionChallengeBreakdown.model_validate(resp.json()) 275 276 # -- my team ---------------------------------------------------------- 277 278 @cached_property 279 def team(self) -> CompetitionTeam: 280 """My team in this competition (rename / leave / kick + invites + requests).""" 281 return CompetitionTeam(self._http, self._cid)
48class Competition: 49 """A competition-scoped view: registration, your team, the comp's 50 challenges, team search, and standings — all within one competition.""" 51 52 def __init__(self, http: BaseHttpClient, competition_id: str) -> None: 53 self._http = http 54 self._cid = competition_id 55 56 @property 57 def id(self) -> str: 58 """The competition id this handle is bound to.""" 59 return self._cid 60 61 # -- info ------------------------------------------------------------- 62 63 def detail(self) -> CompetitionDetail: 64 """Full competition detail including resolved challenge summaries 65 (``.challenges``) in the admin's curated order.""" 66 resp = self._http.request("GET", f"/competitions/{self._cid}") 67 _raise_for_status(resp) 68 return CompetitionDetail.model_validate(resp.json()) 69 70 def challenges( 71 self, 72 difficulty: str | None = None, 73 tag: str = "", 74 q: str = "", 75 offset: int = 0, 76 limit: int = 50, 77 ) -> PagedList[ChallengeInfo]: 78 """Challenges scoped to this competition, curated-order sorted.""" 79 params: dict[str, Any] = {"offset": offset, "limit": limit} 80 if difficulty: 81 params["difficulty"] = difficulty 82 if tag: 83 params["tag"] = tag 84 if q: 85 params["q"] = q 86 resp = self._http.request("GET", f"/competitions/{self._cid}/challenges", params=params) 87 _raise_for_status(resp) 88 return _extract_items(resp.json(), ChallengeInfo) 89 90 def search_teams(self, q: str = "", offset: int = 0, limit: int = 50) -> PagedList[TeamInfo]: 91 """Substring search over team names registered in this competition 92 (the join-a-team picker).""" 93 params: dict[str, Any] = {"offset": offset, "limit": limit} 94 if q: 95 params["q"] = q 96 resp = self._http.request("GET", f"/competitions/{self._cid}/teams/search", params=params) 97 _raise_for_status(resp) 98 return _extract_items(resp.json(), TeamInfo) 99 100 # -- register / join -------------------------------------------------- 101 102 def register( 103 self, 104 mode: Literal["solo", "create", "join"] = "solo", 105 *, 106 name: str = "", 107 description: str = "", 108 code: str = "", 109 ) -> TeamDetail: 110 """Get on a team for this competition. 111 112 - ``mode="solo"`` — mint a 1-person team auto-named after you 113 (idempotent on (you, competition)). 114 - ``mode="create"`` — mint a multi-user team you captain; requires 115 ``name``. 409 if you already have a team for this comp. 116 - ``mode="join"`` — redeem a captain's invite ``code`` to join their 117 team. 409 if the code was for a different comp or you already have 118 a team here. 119 """ 120 if mode == "join": 121 if not code: 122 raise ValueError('register(mode="join") requires a `code`') 123 resp = self._http.request( 124 "POST", f"/competitions/{self._cid}/teams/redeem", json={"code": code} 125 ) 126 else: 127 if mode == "create" and not name: 128 raise ValueError('register(mode="create") requires a `name`') 129 body: dict[str, str] = {"mode": mode} 130 if mode == "create": 131 body["name"] = name 132 body["description"] = description 133 resp = self._http.request("POST", f"/competitions/{self._cid}/teams", json=body) 134 _raise_for_status(resp) 135 return TeamDetail.model_validate(resp.json()) 136 137 def request_to_join(self, team_id: str) -> TeamInviteInfo: 138 """Ask to join ``team_id`` in this competition. The captain sees it 139 in their inbox and approves / rejects (``team.requests``).""" 140 resp = self._http.request("POST", f"/competitions/{self._cid}/teams/{team_id}/join-request") 141 _raise_for_status(resp) 142 return TeamInviteInfo.model_validate(resp.json()) 143 144 def accept_invite(self, invite_id: str) -> TeamDetail: 145 """Accept a direct invite sent to you → join the captain's team.""" 146 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/accept") 147 _raise_for_status(resp) 148 return TeamDetail.model_validate(resp.json()) 149 150 def decline_invite(self, invite_id: str) -> None: 151 """Decline a direct invite sent to you (only the named target may).""" 152 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/decline") 153 _raise_for_status(resp) 154 155 # -- asking to be let in (a private, listed competition) --------------- 156 157 def application(self) -> CompetitionApplicationInfo | None: 158 """Your application to this competition, or ``None`` if you have 159 not applied. 160 161 ``None`` is the server's ``null`` at 200 — a hidden competition 162 already answers 404, so "never applied" is not an error. 163 """ 164 resp = self._http.request("GET", f"/competitions/{self._cid}/application") 165 _raise_for_status(resp) 166 body = resp.json() 167 return None if body is None else CompetitionApplicationInfo.model_validate(body) 168 169 def apply(self, message: str = "") -> CompetitionApplicationInfo: 170 """Ask the organisers of a private competition to invite you. 171 172 For an event that is listed but invitation-only. Calling it again 173 while the application is pending edits ``message`` and keeps its 174 place in the queue. An approval arrives as an invitation, after 175 which :meth:`register` works as for anybody invited. 176 177 Refused (409) with a ``code`` saying why: ``application_not_needed`` 178 (public — just register), ``already_invited`` (you may already take 179 part), ``registration_closed``, ``application_rejected`` (final; 180 the organisers can still invite you). Not a team join request — 181 that is :meth:`request_to_join`, and asks a captain. 182 """ 183 resp = self._http.request( 184 "PUT", f"/competitions/{self._cid}/application", json={"message": message} 185 ) 186 _raise_for_status(resp) 187 return CompetitionApplicationInfo.model_validate(resp.json()) 188 189 def withdraw_application(self) -> None: 190 """Withdraw a pending application. A decided one stays, and asking 191 is ``application_already_decided`` (409).""" 192 resp = self._http.request("DELETE", f"/competitions/{self._cid}/application") 193 _raise_for_status(resp) 194 195 # -- standings -------------------------------------------------------- 196 197 def scoreboard( 198 self, offset: int = 0, limit: int = 50, division: str = "" 199 ) -> PagedList[ScoreboardEntry]: 200 """Ranked standings for this competition. 201 202 ``division`` narrows to one side of a mixed board: ``"human"`` 203 or ``"agent"``. Empty — the default — is everybody, which is the 204 board this method has always returned. ⚠️ Split here rather than 205 by filtering the result: the rows are paginated server-side, so a 206 client-side filter yields whichever of one page matched, still 207 carrying their *merged* ranks. 208 """ 209 params: dict[str, Any] = {"offset": offset, "limit": limit} 210 if division: 211 params["division"] = division 212 resp = self._http.request( 213 "GET", 214 f"/competitions/{self._cid}/scoreboard", 215 params=params, 216 ) 217 _raise_for_status(resp) 218 return _extract_items(resp.json(), ScoreboardEntry) 219 220 def score_history(self, *, top: int = 10) -> CompetitionScoreHistory: 221 """Score-progression series for the top ``top`` teams.""" 222 resp = self._http.request( 223 "GET", f"/competitions/{self._cid}/score-history", params={"top": top} 224 ) 225 _raise_for_status(resp) 226 return CompetitionScoreHistory.model_validate(resp.json()) 227 228 def score_distribution(self) -> CompetitionScoreDistribution: 229 """Score histogram buckets for this competition.""" 230 resp = self._http.request("GET", f"/competitions/{self._cid}/scoreboard/distribution") 231 _raise_for_status(resp) 232 return CompetitionScoreDistribution.model_validate(resp.json()) 233 234 def solve_matrix(self) -> AdminSolveMatrix: 235 """Per-team × per-challenge solve grid scoped to this competition.""" 236 resp = self._http.request("GET", f"/competitions/{self._cid}/solve-matrix") 237 _raise_for_status(resp) 238 return AdminSolveMatrix.model_validate(resp.json()) 239 240 def defence_matrix(self) -> DefenceMatrixResponse: 241 """Per-team × per-challenge **defence** verdicts for this competition. 242 243 The defence-side twin of :meth:`solve_matrix`, and deliberately a 244 separate call rather than a flag on it: a solve cell is binary 245 and monotone, a defence cell is one of five verdicts, and three 246 of them describe a team that tried and is not done. 247 248 A team that fixed a challenge and later submitted a worse patch 249 still reads ``fixed`` — that is what the board scores them on, so 250 this and the standings cannot disagree about who is holding what. 251 A pair with no submission is **absent** from ``cells``, never a 252 present empty string. 253 """ 254 resp = self._http.request("GET", f"/competitions/{self._cid}/defence/matrix") 255 _raise_for_status(resp) 256 return DefenceMatrixResponse.model_validate(resp.json()) 257 258 def roster(self) -> CompetitionRoster: 259 """Every registered team in this competition with its members. 260 261 One request whatever the field size — the per-team 262 :meth:`ctfy.sdk.resources.teams.TeamsResource.get` answers the 263 same question one team at a time, which for a 400-team event is 264 400 round trips. Carries public identity only (display name + 265 avatar), never the entrant dossier. 266 """ 267 resp = self._http.request("GET", f"/competitions/{self._cid}/roster") 268 _raise_for_status(resp) 269 return CompetitionRoster.model_validate(resp.json()) 270 271 def challenge_breakdown(self) -> CompetitionChallengeBreakdown: 272 """Per-challenge solve / attempt counts for this competition.""" 273 resp = self._http.request("GET", f"/competitions/{self._cid}/challenge-breakdown") 274 _raise_for_status(resp) 275 return CompetitionChallengeBreakdown.model_validate(resp.json()) 276 277 # -- my team ---------------------------------------------------------- 278 279 @cached_property 280 def team(self) -> CompetitionTeam: 281 """My team in this competition (rename / leave / kick + invites + requests).""" 282 return CompetitionTeam(self._http, self._cid)
A competition-scoped view: registration, your team, the comp's challenges, team search, and standings — all within one competition.
56 @property 57 def id(self) -> str: 58 """The competition id this handle is bound to.""" 59 return self._cid
The competition id this handle is bound to.
63 def detail(self) -> CompetitionDetail: 64 """Full competition detail including resolved challenge summaries 65 (``.challenges``) in the admin's curated order.""" 66 resp = self._http.request("GET", f"/competitions/{self._cid}") 67 _raise_for_status(resp) 68 return CompetitionDetail.model_validate(resp.json())
Full competition detail including resolved challenge summaries
(.challenges) in the admin's curated order.
70 def challenges( 71 self, 72 difficulty: str | None = None, 73 tag: str = "", 74 q: str = "", 75 offset: int = 0, 76 limit: int = 50, 77 ) -> PagedList[ChallengeInfo]: 78 """Challenges scoped to this competition, curated-order sorted.""" 79 params: dict[str, Any] = {"offset": offset, "limit": limit} 80 if difficulty: 81 params["difficulty"] = difficulty 82 if tag: 83 params["tag"] = tag 84 if q: 85 params["q"] = q 86 resp = self._http.request("GET", f"/competitions/{self._cid}/challenges", params=params) 87 _raise_for_status(resp) 88 return _extract_items(resp.json(), ChallengeInfo)
Challenges scoped to this competition, curated-order sorted.
90 def search_teams(self, q: str = "", offset: int = 0, limit: int = 50) -> PagedList[TeamInfo]: 91 """Substring search over team names registered in this competition 92 (the join-a-team picker).""" 93 params: dict[str, Any] = {"offset": offset, "limit": limit} 94 if q: 95 params["q"] = q 96 resp = self._http.request("GET", f"/competitions/{self._cid}/teams/search", params=params) 97 _raise_for_status(resp) 98 return _extract_items(resp.json(), TeamInfo)
Substring search over team names registered in this competition (the join-a-team picker).
102 def register( 103 self, 104 mode: Literal["solo", "create", "join"] = "solo", 105 *, 106 name: str = "", 107 description: str = "", 108 code: str = "", 109 ) -> TeamDetail: 110 """Get on a team for this competition. 111 112 - ``mode="solo"`` — mint a 1-person team auto-named after you 113 (idempotent on (you, competition)). 114 - ``mode="create"`` — mint a multi-user team you captain; requires 115 ``name``. 409 if you already have a team for this comp. 116 - ``mode="join"`` — redeem a captain's invite ``code`` to join their 117 team. 409 if the code was for a different comp or you already have 118 a team here. 119 """ 120 if mode == "join": 121 if not code: 122 raise ValueError('register(mode="join") requires a `code`') 123 resp = self._http.request( 124 "POST", f"/competitions/{self._cid}/teams/redeem", json={"code": code} 125 ) 126 else: 127 if mode == "create" and not name: 128 raise ValueError('register(mode="create") requires a `name`') 129 body: dict[str, str] = {"mode": mode} 130 if mode == "create": 131 body["name"] = name 132 body["description"] = description 133 resp = self._http.request("POST", f"/competitions/{self._cid}/teams", json=body) 134 _raise_for_status(resp) 135 return TeamDetail.model_validate(resp.json())
Get on a team for this competition.
mode="solo"— mint a 1-person team auto-named after you (idempotent on (you, competition)).mode="create"— mint a multi-user team you captain; requiresname. 409 if you already have a team for this comp.mode="join"— redeem a captain's invitecodeto join their team. 409 if the code was for a different comp or you already have a team here.
137 def request_to_join(self, team_id: str) -> TeamInviteInfo: 138 """Ask to join ``team_id`` in this competition. The captain sees it 139 in their inbox and approves / rejects (``team.requests``).""" 140 resp = self._http.request("POST", f"/competitions/{self._cid}/teams/{team_id}/join-request") 141 _raise_for_status(resp) 142 return TeamInviteInfo.model_validate(resp.json())
Ask to join team_id in this competition. The captain sees it
in their inbox and approves / rejects (team.requests).
144 def accept_invite(self, invite_id: str) -> TeamDetail: 145 """Accept a direct invite sent to you → join the captain's team.""" 146 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/accept") 147 _raise_for_status(resp) 148 return TeamDetail.model_validate(resp.json())
Accept a direct invite sent to you → join the captain's team.
150 def decline_invite(self, invite_id: str) -> None: 151 """Decline a direct invite sent to you (only the named target may).""" 152 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/decline") 153 _raise_for_status(resp)
Decline a direct invite sent to you (only the named target may).
157 def application(self) -> CompetitionApplicationInfo | None: 158 """Your application to this competition, or ``None`` if you have 159 not applied. 160 161 ``None`` is the server's ``null`` at 200 — a hidden competition 162 already answers 404, so "never applied" is not an error. 163 """ 164 resp = self._http.request("GET", f"/competitions/{self._cid}/application") 165 _raise_for_status(resp) 166 body = resp.json() 167 return None if body is None else CompetitionApplicationInfo.model_validate(body)
Your application to this competition, or None if you have
not applied.
None is the server's null at 200 — a hidden competition
already answers 404, so "never applied" is not an error.
169 def apply(self, message: str = "") -> CompetitionApplicationInfo: 170 """Ask the organisers of a private competition to invite you. 171 172 For an event that is listed but invitation-only. Calling it again 173 while the application is pending edits ``message`` and keeps its 174 place in the queue. An approval arrives as an invitation, after 175 which :meth:`register` works as for anybody invited. 176 177 Refused (409) with a ``code`` saying why: ``application_not_needed`` 178 (public — just register), ``already_invited`` (you may already take 179 part), ``registration_closed``, ``application_rejected`` (final; 180 the organisers can still invite you). Not a team join request — 181 that is :meth:`request_to_join`, and asks a captain. 182 """ 183 resp = self._http.request( 184 "PUT", f"/competitions/{self._cid}/application", json={"message": message} 185 ) 186 _raise_for_status(resp) 187 return CompetitionApplicationInfo.model_validate(resp.json())
Ask the organisers of a private competition to invite you.
For an event that is listed but invitation-only. Calling it again
while the application is pending edits message and keeps its
place in the queue. An approval arrives as an invitation, after
which register() works as for anybody invited.
Refused (409) with a code saying why: application_not_needed
(public — just register), already_invited (you may already take
part), registration_closed, application_rejected (final;
the organisers can still invite you). Not a team join request —
that is request_to_join(), and asks a captain.
189 def withdraw_application(self) -> None: 190 """Withdraw a pending application. A decided one stays, and asking 191 is ``application_already_decided`` (409).""" 192 resp = self._http.request("DELETE", f"/competitions/{self._cid}/application") 193 _raise_for_status(resp)
Withdraw a pending application. A decided one stays, and asking
is application_already_decided (409).
197 def scoreboard( 198 self, offset: int = 0, limit: int = 50, division: str = "" 199 ) -> PagedList[ScoreboardEntry]: 200 """Ranked standings for this competition. 201 202 ``division`` narrows to one side of a mixed board: ``"human"`` 203 or ``"agent"``. Empty — the default — is everybody, which is the 204 board this method has always returned. ⚠️ Split here rather than 205 by filtering the result: the rows are paginated server-side, so a 206 client-side filter yields whichever of one page matched, still 207 carrying their *merged* ranks. 208 """ 209 params: dict[str, Any] = {"offset": offset, "limit": limit} 210 if division: 211 params["division"] = division 212 resp = self._http.request( 213 "GET", 214 f"/competitions/{self._cid}/scoreboard", 215 params=params, 216 ) 217 _raise_for_status(resp) 218 return _extract_items(resp.json(), ScoreboardEntry)
Ranked standings for this competition.
division narrows to one side of a mixed board: "human"
or "agent". Empty — the default — is everybody, which is the
board this method has always returned. ⚠️ Split here rather than
by filtering the result: the rows are paginated server-side, so a
client-side filter yields whichever of one page matched, still
carrying their merged ranks.
220 def score_history(self, *, top: int = 10) -> CompetitionScoreHistory: 221 """Score-progression series for the top ``top`` teams.""" 222 resp = self._http.request( 223 "GET", f"/competitions/{self._cid}/score-history", params={"top": top} 224 ) 225 _raise_for_status(resp) 226 return CompetitionScoreHistory.model_validate(resp.json())
Score-progression series for the top top teams.
228 def score_distribution(self) -> CompetitionScoreDistribution: 229 """Score histogram buckets for this competition.""" 230 resp = self._http.request("GET", f"/competitions/{self._cid}/scoreboard/distribution") 231 _raise_for_status(resp) 232 return CompetitionScoreDistribution.model_validate(resp.json())
Score histogram buckets for this competition.
234 def solve_matrix(self) -> AdminSolveMatrix: 235 """Per-team × per-challenge solve grid scoped to this competition.""" 236 resp = self._http.request("GET", f"/competitions/{self._cid}/solve-matrix") 237 _raise_for_status(resp) 238 return AdminSolveMatrix.model_validate(resp.json())
Per-team × per-challenge solve grid scoped to this competition.
240 def defence_matrix(self) -> DefenceMatrixResponse: 241 """Per-team × per-challenge **defence** verdicts for this competition. 242 243 The defence-side twin of :meth:`solve_matrix`, and deliberately a 244 separate call rather than a flag on it: a solve cell is binary 245 and monotone, a defence cell is one of five verdicts, and three 246 of them describe a team that tried and is not done. 247 248 A team that fixed a challenge and later submitted a worse patch 249 still reads ``fixed`` — that is what the board scores them on, so 250 this and the standings cannot disagree about who is holding what. 251 A pair with no submission is **absent** from ``cells``, never a 252 present empty string. 253 """ 254 resp = self._http.request("GET", f"/competitions/{self._cid}/defence/matrix") 255 _raise_for_status(resp) 256 return DefenceMatrixResponse.model_validate(resp.json())
Per-team × per-challenge defence verdicts for this competition.
The defence-side twin of solve_matrix(), and deliberately a
separate call rather than a flag on it: a solve cell is binary
and monotone, a defence cell is one of five verdicts, and three
of them describe a team that tried and is not done.
A team that fixed a challenge and later submitted a worse patch
still reads fixed — that is what the board scores them on, so
this and the standings cannot disagree about who is holding what.
A pair with no submission is absent from cells, never a
present empty string.
258 def roster(self) -> CompetitionRoster: 259 """Every registered team in this competition with its members. 260 261 One request whatever the field size — the per-team 262 :meth:`ctfy.sdk.resources.teams.TeamsResource.get` answers the 263 same question one team at a time, which for a 400-team event is 264 400 round trips. Carries public identity only (display name + 265 avatar), never the entrant dossier. 266 """ 267 resp = self._http.request("GET", f"/competitions/{self._cid}/roster") 268 _raise_for_status(resp) 269 return CompetitionRoster.model_validate(resp.json())
Every registered team in this competition with its members.
One request whatever the field size — the per-team
ctfy.sdk.resources.teams.TeamsResource.get() answers the
same question one team at a time, which for a 400-team event is
400 round trips. Carries public identity only (display name +
avatar), never the entrant dossier.
271 def challenge_breakdown(self) -> CompetitionChallengeBreakdown: 272 """Per-challenge solve / attempt counts for this competition.""" 273 resp = self._http.request("GET", f"/competitions/{self._cid}/challenge-breakdown") 274 _raise_for_status(resp) 275 return CompetitionChallengeBreakdown.model_validate(resp.json())
Per-challenge solve / attempt counts for this competition.