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)
class Competition:
 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.

Competition(http: ctfy.sdk.base.BaseHttpClient, competition_id: str)
52    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
53        self._http = http
54        self._cid = competition_id
id: str
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.

def detail(self) -> ctfy.server.models.CompetitionDetail:
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.

def challenges( self, difficulty: str | None = None, tag: str = '', q: str = '', offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.ChallengeInfo]:
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.

def search_teams( self, q: str = '', offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.TeamInfo]:
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).

def register( self, mode: Literal['solo', 'create', 'join'] = 'solo', *, name: str = '', description: str = '', code: str = '') -> ctfy.server.models.TeamDetail:
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; requires name. 409 if you already have a team for this comp.
  • mode="join" — redeem a captain's invite code to join their team. 409 if the code was for a different comp or you already have a team here.
def request_to_join(self, team_id: str) -> ctfy.server.models.TeamInviteInfo:
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).

def accept_invite(self, invite_id: str) -> ctfy.server.models.TeamDetail:
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.

def decline_invite(self, invite_id: str) -> None:
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).

def application(self) -> ctfy.server.models.CompetitionApplicationInfo | None:
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.

def apply( self, message: str = '') -> ctfy.server.models.CompetitionApplicationInfo:
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.

def withdraw_application(self) -> None:
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).

def scoreboard( self, offset: int = 0, limit: int = 50, division: str = '') -> ctfy.sdk._helpers.PagedList[ctfy.server.models.ScoreboardEntry]:
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.

def score_history( self, *, top: int = 10) -> ctfy.server.models.CompetitionScoreHistory:
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.

def score_distribution(self) -> ctfy.server.models.CompetitionScoreDistribution:
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.

def solve_matrix(self) -> ctfy.server.models.AdminSolveMatrix:
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.

def defence_matrix(self) -> ctfy.server.models.DefenceMatrixResponse:
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.

def roster(self) -> ctfy.server.models.CompetitionRoster:
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.

def challenge_breakdown(self) -> ctfy.server.models.CompetitionChallengeBreakdown:
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.

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)

My team in this competition (rename / leave / kick + invites + requests).