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 CompetitionChallengeBreakdown, 32 CompetitionDetail, 33 CompetitionRoster, 34 CompetitionScoreDistribution, 35 CompetitionScoreHistory, 36 ScoreboardEntry, 37 TeamDetail, 38 TeamInfo, 39 TeamInviteInfo, 40) 41 42__all__ = ["Competition"] 43 44 45class Competition: 46 """A competition-scoped view: registration, your team, the comp's 47 challenges, team search, and standings — all within one competition.""" 48 49 def __init__(self, http: BaseHttpClient, competition_id: str) -> None: 50 self._http = http 51 self._cid = competition_id 52 53 @property 54 def id(self) -> str: 55 """The competition id this handle is bound to.""" 56 return self._cid 57 58 # -- info ------------------------------------------------------------- 59 60 def detail(self) -> CompetitionDetail: 61 """Full competition detail including resolved challenge summaries 62 (``.challenges``) in the admin's curated order.""" 63 resp = self._http.request("GET", f"/competitions/{self._cid}") 64 _raise_for_status(resp) 65 return CompetitionDetail.model_validate(resp.json()) 66 67 def challenges( 68 self, 69 difficulty: str | None = None, 70 tag: str = "", 71 q: str = "", 72 offset: int = 0, 73 limit: int = 50, 74 ) -> PagedList[ChallengeInfo]: 75 """Challenges scoped to this competition, curated-order sorted.""" 76 params: dict[str, Any] = {"offset": offset, "limit": limit} 77 if difficulty: 78 params["difficulty"] = difficulty 79 if tag: 80 params["tag"] = tag 81 if q: 82 params["q"] = q 83 resp = self._http.request("GET", f"/competitions/{self._cid}/challenges", params=params) 84 _raise_for_status(resp) 85 return _extract_items(resp.json(), ChallengeInfo) 86 87 def search_teams(self, q: str = "", offset: int = 0, limit: int = 50) -> PagedList[TeamInfo]: 88 """Substring search over team names registered in this competition 89 (the join-a-team picker).""" 90 params: dict[str, Any] = {"offset": offset, "limit": limit} 91 if q: 92 params["q"] = q 93 resp = self._http.request("GET", f"/competitions/{self._cid}/teams/search", params=params) 94 _raise_for_status(resp) 95 return _extract_items(resp.json(), TeamInfo) 96 97 # -- register / join -------------------------------------------------- 98 99 def register( 100 self, 101 mode: Literal["solo", "create", "join"] = "solo", 102 *, 103 name: str = "", 104 description: str = "", 105 code: str = "", 106 ) -> TeamDetail: 107 """Get on a team for this competition. 108 109 - ``mode="solo"`` — mint a 1-person team auto-named after you 110 (idempotent on (you, competition)). 111 - ``mode="create"`` — mint a multi-user team you captain; requires 112 ``name``. 409 if you already have a team for this comp. 113 - ``mode="join"`` — redeem a captain's invite ``code`` to join their 114 team. 409 if the code was for a different comp or you already have 115 a team here. 116 """ 117 if mode == "join": 118 if not code: 119 raise ValueError('register(mode="join") requires a `code`') 120 resp = self._http.request( 121 "POST", f"/competitions/{self._cid}/teams/redeem", json={"code": code} 122 ) 123 else: 124 if mode == "create" and not name: 125 raise ValueError('register(mode="create") requires a `name`') 126 body: dict[str, str] = {"mode": mode} 127 if mode == "create": 128 body["name"] = name 129 body["description"] = description 130 resp = self._http.request("POST", f"/competitions/{self._cid}/teams", json=body) 131 _raise_for_status(resp) 132 return TeamDetail.model_validate(resp.json()) 133 134 def request_to_join(self, team_id: str) -> TeamInviteInfo: 135 """Ask to join ``team_id`` in this competition. The captain sees it 136 in their inbox and approves / rejects (``team.requests``).""" 137 resp = self._http.request("POST", f"/competitions/{self._cid}/teams/{team_id}/join-request") 138 _raise_for_status(resp) 139 return TeamInviteInfo.model_validate(resp.json()) 140 141 def accept_invite(self, invite_id: str) -> TeamDetail: 142 """Accept a direct invite sent to you → join the captain's team.""" 143 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/accept") 144 _raise_for_status(resp) 145 return TeamDetail.model_validate(resp.json()) 146 147 def decline_invite(self, invite_id: str) -> None: 148 """Decline a direct invite sent to you (only the named target may).""" 149 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/decline") 150 _raise_for_status(resp) 151 152 # -- standings -------------------------------------------------------- 153 154 def scoreboard(self, offset: int = 0, limit: int = 50) -> PagedList[ScoreboardEntry]: 155 """Ranked standings for this competition.""" 156 resp = self._http.request( 157 "GET", 158 f"/competitions/{self._cid}/scoreboard", 159 params={"offset": offset, "limit": limit}, 160 ) 161 _raise_for_status(resp) 162 return _extract_items(resp.json(), ScoreboardEntry) 163 164 def score_history(self, *, top: int = 10) -> CompetitionScoreHistory: 165 """Score-progression series for the top ``top`` teams.""" 166 resp = self._http.request( 167 "GET", f"/competitions/{self._cid}/score-history", params={"top": top} 168 ) 169 _raise_for_status(resp) 170 return CompetitionScoreHistory.model_validate(resp.json()) 171 172 def score_distribution(self) -> CompetitionScoreDistribution: 173 """Score histogram buckets for this competition.""" 174 resp = self._http.request("GET", f"/competitions/{self._cid}/scoreboard/distribution") 175 _raise_for_status(resp) 176 return CompetitionScoreDistribution.model_validate(resp.json()) 177 178 def solve_matrix(self) -> AdminSolveMatrix: 179 """Per-team × per-challenge solve grid scoped to this competition.""" 180 resp = self._http.request("GET", f"/competitions/{self._cid}/solve-matrix") 181 _raise_for_status(resp) 182 return AdminSolveMatrix.model_validate(resp.json()) 183 184 def roster(self) -> CompetitionRoster: 185 """Every registered team in this competition with its members. 186 187 One request whatever the field size — the per-team 188 :meth:`ctfy.sdk.resources.teams.TeamsResource.get` answers the 189 same question one team at a time, which for a 400-team event is 190 400 round trips. Carries public identity only (display name + 191 avatar), never the entrant dossier. 192 """ 193 resp = self._http.request("GET", f"/competitions/{self._cid}/roster") 194 _raise_for_status(resp) 195 return CompetitionRoster.model_validate(resp.json()) 196 197 def challenge_breakdown(self) -> CompetitionChallengeBreakdown: 198 """Per-challenge solve / attempt counts for this competition.""" 199 resp = self._http.request("GET", f"/competitions/{self._cid}/challenge-breakdown") 200 _raise_for_status(resp) 201 return CompetitionChallengeBreakdown.model_validate(resp.json()) 202 203 # -- my team ---------------------------------------------------------- 204 205 @cached_property 206 def team(self) -> CompetitionTeam: 207 """My team in this competition (rename / leave / kick + invites + requests).""" 208 return CompetitionTeam(self._http, self._cid)
46class Competition: 47 """A competition-scoped view: registration, your team, the comp's 48 challenges, team search, and standings — all within one competition.""" 49 50 def __init__(self, http: BaseHttpClient, competition_id: str) -> None: 51 self._http = http 52 self._cid = competition_id 53 54 @property 55 def id(self) -> str: 56 """The competition id this handle is bound to.""" 57 return self._cid 58 59 # -- info ------------------------------------------------------------- 60 61 def detail(self) -> CompetitionDetail: 62 """Full competition detail including resolved challenge summaries 63 (``.challenges``) in the admin's curated order.""" 64 resp = self._http.request("GET", f"/competitions/{self._cid}") 65 _raise_for_status(resp) 66 return CompetitionDetail.model_validate(resp.json()) 67 68 def challenges( 69 self, 70 difficulty: str | None = None, 71 tag: str = "", 72 q: str = "", 73 offset: int = 0, 74 limit: int = 50, 75 ) -> PagedList[ChallengeInfo]: 76 """Challenges scoped to this competition, curated-order sorted.""" 77 params: dict[str, Any] = {"offset": offset, "limit": limit} 78 if difficulty: 79 params["difficulty"] = difficulty 80 if tag: 81 params["tag"] = tag 82 if q: 83 params["q"] = q 84 resp = self._http.request("GET", f"/competitions/{self._cid}/challenges", params=params) 85 _raise_for_status(resp) 86 return _extract_items(resp.json(), ChallengeInfo) 87 88 def search_teams(self, q: str = "", offset: int = 0, limit: int = 50) -> PagedList[TeamInfo]: 89 """Substring search over team names registered in this competition 90 (the join-a-team picker).""" 91 params: dict[str, Any] = {"offset": offset, "limit": limit} 92 if q: 93 params["q"] = q 94 resp = self._http.request("GET", f"/competitions/{self._cid}/teams/search", params=params) 95 _raise_for_status(resp) 96 return _extract_items(resp.json(), TeamInfo) 97 98 # -- register / join -------------------------------------------------- 99 100 def register( 101 self, 102 mode: Literal["solo", "create", "join"] = "solo", 103 *, 104 name: str = "", 105 description: str = "", 106 code: str = "", 107 ) -> TeamDetail: 108 """Get on a team for this competition. 109 110 - ``mode="solo"`` — mint a 1-person team auto-named after you 111 (idempotent on (you, competition)). 112 - ``mode="create"`` — mint a multi-user team you captain; requires 113 ``name``. 409 if you already have a team for this comp. 114 - ``mode="join"`` — redeem a captain's invite ``code`` to join their 115 team. 409 if the code was for a different comp or you already have 116 a team here. 117 """ 118 if mode == "join": 119 if not code: 120 raise ValueError('register(mode="join") requires a `code`') 121 resp = self._http.request( 122 "POST", f"/competitions/{self._cid}/teams/redeem", json={"code": code} 123 ) 124 else: 125 if mode == "create" and not name: 126 raise ValueError('register(mode="create") requires a `name`') 127 body: dict[str, str] = {"mode": mode} 128 if mode == "create": 129 body["name"] = name 130 body["description"] = description 131 resp = self._http.request("POST", f"/competitions/{self._cid}/teams", json=body) 132 _raise_for_status(resp) 133 return TeamDetail.model_validate(resp.json()) 134 135 def request_to_join(self, team_id: str) -> TeamInviteInfo: 136 """Ask to join ``team_id`` in this competition. The captain sees it 137 in their inbox and approves / rejects (``team.requests``).""" 138 resp = self._http.request("POST", f"/competitions/{self._cid}/teams/{team_id}/join-request") 139 _raise_for_status(resp) 140 return TeamInviteInfo.model_validate(resp.json()) 141 142 def accept_invite(self, invite_id: str) -> TeamDetail: 143 """Accept a direct invite sent to you → join the captain's team.""" 144 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/accept") 145 _raise_for_status(resp) 146 return TeamDetail.model_validate(resp.json()) 147 148 def decline_invite(self, invite_id: str) -> None: 149 """Decline a direct invite sent to you (only the named target may).""" 150 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/decline") 151 _raise_for_status(resp) 152 153 # -- standings -------------------------------------------------------- 154 155 def scoreboard(self, offset: int = 0, limit: int = 50) -> PagedList[ScoreboardEntry]: 156 """Ranked standings for this competition.""" 157 resp = self._http.request( 158 "GET", 159 f"/competitions/{self._cid}/scoreboard", 160 params={"offset": offset, "limit": limit}, 161 ) 162 _raise_for_status(resp) 163 return _extract_items(resp.json(), ScoreboardEntry) 164 165 def score_history(self, *, top: int = 10) -> CompetitionScoreHistory: 166 """Score-progression series for the top ``top`` teams.""" 167 resp = self._http.request( 168 "GET", f"/competitions/{self._cid}/score-history", params={"top": top} 169 ) 170 _raise_for_status(resp) 171 return CompetitionScoreHistory.model_validate(resp.json()) 172 173 def score_distribution(self) -> CompetitionScoreDistribution: 174 """Score histogram buckets for this competition.""" 175 resp = self._http.request("GET", f"/competitions/{self._cid}/scoreboard/distribution") 176 _raise_for_status(resp) 177 return CompetitionScoreDistribution.model_validate(resp.json()) 178 179 def solve_matrix(self) -> AdminSolveMatrix: 180 """Per-team × per-challenge solve grid scoped to this competition.""" 181 resp = self._http.request("GET", f"/competitions/{self._cid}/solve-matrix") 182 _raise_for_status(resp) 183 return AdminSolveMatrix.model_validate(resp.json()) 184 185 def roster(self) -> CompetitionRoster: 186 """Every registered team in this competition with its members. 187 188 One request whatever the field size — the per-team 189 :meth:`ctfy.sdk.resources.teams.TeamsResource.get` answers the 190 same question one team at a time, which for a 400-team event is 191 400 round trips. Carries public identity only (display name + 192 avatar), never the entrant dossier. 193 """ 194 resp = self._http.request("GET", f"/competitions/{self._cid}/roster") 195 _raise_for_status(resp) 196 return CompetitionRoster.model_validate(resp.json()) 197 198 def challenge_breakdown(self) -> CompetitionChallengeBreakdown: 199 """Per-challenge solve / attempt counts for this competition.""" 200 resp = self._http.request("GET", f"/competitions/{self._cid}/challenge-breakdown") 201 _raise_for_status(resp) 202 return CompetitionChallengeBreakdown.model_validate(resp.json()) 203 204 # -- my team ---------------------------------------------------------- 205 206 @cached_property 207 def team(self) -> CompetitionTeam: 208 """My team in this competition (rename / leave / kick + invites + requests).""" 209 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.
54 @property 55 def id(self) -> str: 56 """The competition id this handle is bound to.""" 57 return self._cid
The competition id this handle is bound to.
61 def detail(self) -> CompetitionDetail: 62 """Full competition detail including resolved challenge summaries 63 (``.challenges``) in the admin's curated order.""" 64 resp = self._http.request("GET", f"/competitions/{self._cid}") 65 _raise_for_status(resp) 66 return CompetitionDetail.model_validate(resp.json())
Full competition detail including resolved challenge summaries
(.challenges) in the admin's curated order.
68 def challenges( 69 self, 70 difficulty: str | None = None, 71 tag: str = "", 72 q: str = "", 73 offset: int = 0, 74 limit: int = 50, 75 ) -> PagedList[ChallengeInfo]: 76 """Challenges scoped to this competition, curated-order sorted.""" 77 params: dict[str, Any] = {"offset": offset, "limit": limit} 78 if difficulty: 79 params["difficulty"] = difficulty 80 if tag: 81 params["tag"] = tag 82 if q: 83 params["q"] = q 84 resp = self._http.request("GET", f"/competitions/{self._cid}/challenges", params=params) 85 _raise_for_status(resp) 86 return _extract_items(resp.json(), ChallengeInfo)
Challenges scoped to this competition, curated-order sorted.
88 def search_teams(self, q: str = "", offset: int = 0, limit: int = 50) -> PagedList[TeamInfo]: 89 """Substring search over team names registered in this competition 90 (the join-a-team picker).""" 91 params: dict[str, Any] = {"offset": offset, "limit": limit} 92 if q: 93 params["q"] = q 94 resp = self._http.request("GET", f"/competitions/{self._cid}/teams/search", params=params) 95 _raise_for_status(resp) 96 return _extract_items(resp.json(), TeamInfo)
Substring search over team names registered in this competition (the join-a-team picker).
100 def register( 101 self, 102 mode: Literal["solo", "create", "join"] = "solo", 103 *, 104 name: str = "", 105 description: str = "", 106 code: str = "", 107 ) -> TeamDetail: 108 """Get on a team for this competition. 109 110 - ``mode="solo"`` — mint a 1-person team auto-named after you 111 (idempotent on (you, competition)). 112 - ``mode="create"`` — mint a multi-user team you captain; requires 113 ``name``. 409 if you already have a team for this comp. 114 - ``mode="join"`` — redeem a captain's invite ``code`` to join their 115 team. 409 if the code was for a different comp or you already have 116 a team here. 117 """ 118 if mode == "join": 119 if not code: 120 raise ValueError('register(mode="join") requires a `code`') 121 resp = self._http.request( 122 "POST", f"/competitions/{self._cid}/teams/redeem", json={"code": code} 123 ) 124 else: 125 if mode == "create" and not name: 126 raise ValueError('register(mode="create") requires a `name`') 127 body: dict[str, str] = {"mode": mode} 128 if mode == "create": 129 body["name"] = name 130 body["description"] = description 131 resp = self._http.request("POST", f"/competitions/{self._cid}/teams", json=body) 132 _raise_for_status(resp) 133 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.
135 def request_to_join(self, team_id: str) -> TeamInviteInfo: 136 """Ask to join ``team_id`` in this competition. The captain sees it 137 in their inbox and approves / rejects (``team.requests``).""" 138 resp = self._http.request("POST", f"/competitions/{self._cid}/teams/{team_id}/join-request") 139 _raise_for_status(resp) 140 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).
142 def accept_invite(self, invite_id: str) -> TeamDetail: 143 """Accept a direct invite sent to you → join the captain's team.""" 144 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/accept") 145 _raise_for_status(resp) 146 return TeamDetail.model_validate(resp.json())
Accept a direct invite sent to you → join the captain's team.
148 def decline_invite(self, invite_id: str) -> None: 149 """Decline a direct invite sent to you (only the named target may).""" 150 resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/decline") 151 _raise_for_status(resp)
Decline a direct invite sent to you (only the named target may).
155 def scoreboard(self, offset: int = 0, limit: int = 50) -> PagedList[ScoreboardEntry]: 156 """Ranked standings for this competition.""" 157 resp = self._http.request( 158 "GET", 159 f"/competitions/{self._cid}/scoreboard", 160 params={"offset": offset, "limit": limit}, 161 ) 162 _raise_for_status(resp) 163 return _extract_items(resp.json(), ScoreboardEntry)
Ranked standings for this competition.
165 def score_history(self, *, top: int = 10) -> CompetitionScoreHistory: 166 """Score-progression series for the top ``top`` teams.""" 167 resp = self._http.request( 168 "GET", f"/competitions/{self._cid}/score-history", params={"top": top} 169 ) 170 _raise_for_status(resp) 171 return CompetitionScoreHistory.model_validate(resp.json())
Score-progression series for the top top teams.
173 def score_distribution(self) -> CompetitionScoreDistribution: 174 """Score histogram buckets for this competition.""" 175 resp = self._http.request("GET", f"/competitions/{self._cid}/scoreboard/distribution") 176 _raise_for_status(resp) 177 return CompetitionScoreDistribution.model_validate(resp.json())
Score histogram buckets for this competition.
179 def solve_matrix(self) -> AdminSolveMatrix: 180 """Per-team × per-challenge solve grid scoped to this competition.""" 181 resp = self._http.request("GET", f"/competitions/{self._cid}/solve-matrix") 182 _raise_for_status(resp) 183 return AdminSolveMatrix.model_validate(resp.json())
Per-team × per-challenge solve grid scoped to this competition.
185 def roster(self) -> CompetitionRoster: 186 """Every registered team in this competition with its members. 187 188 One request whatever the field size — the per-team 189 :meth:`ctfy.sdk.resources.teams.TeamsResource.get` answers the 190 same question one team at a time, which for a 400-team event is 191 400 round trips. Carries public identity only (display name + 192 avatar), never the entrant dossier. 193 """ 194 resp = self._http.request("GET", f"/competitions/{self._cid}/roster") 195 _raise_for_status(resp) 196 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.
198 def challenge_breakdown(self) -> CompetitionChallengeBreakdown: 199 """Per-challenge solve / attempt counts for this competition.""" 200 resp = self._http.request("GET", f"/competitions/{self._cid}/challenge-breakdown") 201 _raise_for_status(resp) 202 return CompetitionChallengeBreakdown.model_validate(resp.json())
Per-challenge solve / attempt counts for this competition.