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