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.core.enums import ApplicationPurpose
 26from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
 27from ctfy.sdk.base import BaseHttpClient
 28from ctfy.sdk.competition.team import CompetitionTeam
 29from ctfy.server.models import (
 30    AcceptTeamAgreementRequest,
 31    AdminSolveMatrix,
 32    ChallengeInfo,
 33    CompetitionApplicationInfo,
 34    CompetitionBoardsResponse,
 35    CompetitionChallengeBreakdown,
 36    CompetitionDetail,
 37    CompetitionRoster,
 38    CompetitionScoreDistribution,
 39    CompetitionScoreHistory,
 40    DefenceMatrixResponse,
 41    ScoreboardEntry,
 42    TeamDetail,
 43    TeamInfo,
 44    TeamInviteInfo,
 45    TeamNameCheck,
 46)
 47
 48__all__ = ["Competition"]
 49
 50
 51class Competition:
 52    """A competition-scoped view: registration, your team, the comp's
 53    challenges, team search, and standings — all within one competition."""
 54
 55    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
 56        self._http = http
 57        self._cid = competition_id
 58
 59    @property
 60    def id(self) -> str:
 61        """The competition id this handle is bound to."""
 62        return self._cid
 63
 64    # -- info -------------------------------------------------------------
 65
 66    def detail(self) -> CompetitionDetail:
 67        """Full competition detail including resolved challenge summaries
 68        (``.challenges``) in the admin's curated order."""
 69        resp = self._http.request("GET", f"/competitions/{self._cid}")
 70        _raise_for_status(resp)
 71        return CompetitionDetail.model_validate(resp.json())
 72
 73    def challenges(
 74        self,
 75        difficulty: str | None = None,
 76        tag: str = "",
 77        q: str = "",
 78        offset: int = 0,
 79        limit: int = 50,
 80    ) -> PagedList[ChallengeInfo]:
 81        """Challenges scoped to this competition, curated-order sorted."""
 82        params: dict[str, Any] = {"offset": offset, "limit": limit}
 83        if difficulty:
 84            params["difficulty"] = difficulty
 85        if tag:
 86            params["tag"] = tag
 87        if q:
 88            params["q"] = q
 89        resp = self._http.request("GET", f"/competitions/{self._cid}/challenges", params=params)
 90        _raise_for_status(resp)
 91        return _extract_items(resp.json(), ChallengeInfo)
 92
 93    def search_teams(self, q: str = "", offset: int = 0, limit: int = 50) -> PagedList[TeamInfo]:
 94        """Substring search over team names registered in this competition
 95        (the join-a-team picker)."""
 96        params: dict[str, Any] = {"offset": offset, "limit": limit}
 97        if q:
 98            params["q"] = q
 99        resp = self._http.request("GET", f"/competitions/{self._cid}/teams/search", params=params)
100        _raise_for_status(resp)
101        return _extract_items(resp.json(), TeamInfo)
102
103    # -- register / join --------------------------------------------------
104
105    def register(
106        self,
107        mode: Literal["solo", "create", "join"] = "solo",
108        *,
109        name: str = "",
110        description: str = "",
111        code: str = "",
112        agreement: AcceptTeamAgreementRequest | None = None,
113    ) -> TeamDetail:
114        """Get on a team for this competition.
115
116        ``agreement`` is the contributor agreement, accepted for the new team
117        by you — its captain (#1965 B). A competition that takes uploads and
118        names an agreement (``authoring_terms_version`` on its detail)
119        refuses a ``solo`` or ``create`` registration without it (422
120        ``registration_agreement_required``). Only a person's own session can
121        send it: a fine-grained token is refused 403
122        ``registration_agreement_needs_a_person``. ``join`` takes none — the
123        team's captain already accepted.
124
125        - ``mode="solo"`` — mint a 1-person team (idempotent on (you,
126          competition)). Named ``name`` when given — the entry name an
127          individual event shows on its board — else after your display name.
128        - ``mode="create"`` — mint a multi-user team you captain; requires
129          ``name``. 409 if you already have a team for this comp.
130        - ``mode="join"`` — redeem a captain's invite ``code`` to join their
131          team. 409 if the code was for a different comp or you already have
132          a team here.
133        """
134        if mode == "join":
135            if not code:
136                raise ValueError('register(mode="join") requires a `code`')
137            resp = self._http.request(
138                "POST", f"/competitions/{self._cid}/teams/redeem", json={"code": code}
139            )
140        else:
141            if mode == "create" and not name:
142                raise ValueError('register(mode="create") requires a `name`')
143            body: dict[str, object] = {"mode": mode}
144            if name:
145                body["name"] = name
146            if mode == "create":
147                body["description"] = description
148            if agreement is not None:
149                body["agreement"] = agreement.model_dump(mode="json")
150            resp = self._http.request("POST", f"/competitions/{self._cid}/teams", json=body)
151        _raise_for_status(resp)
152        return TeamDetail.model_validate(resp.json())
153
154    def check_team_name(self, name: str) -> TeamNameCheck:
155        """May ``name`` be a team's name in this competition? (#2238)
156
157        Asks the rule every registration and rename applies, without writing:
158        ``name`` is what would be stored, ``available`` the verdict, and
159        ``refusal`` a coded reason (``team_name_has_link`` / ``_reserved`` /
160        ``_blocked`` / ``_too_long`` / ``_too_short`` / ``_taken``) or ``""``.
161        Advisory -- the registration itself decides, since a free name can be
162        taken before you submit.
163        """
164        resp = self._http.request(
165            "GET", f"/competitions/{self._cid}/team-name-check", params={"name": name}
166        )
167        _raise_for_status(resp)
168        return TeamNameCheck.model_validate(resp.json())
169
170    def request_to_join(self, team_id: str) -> TeamInviteInfo:
171        """Ask to join ``team_id`` in this competition. The captain sees it
172        in their inbox and approves / rejects (``team.requests``)."""
173        resp = self._http.request("POST", f"/competitions/{self._cid}/teams/{team_id}/join-request")
174        _raise_for_status(resp)
175        return TeamInviteInfo.model_validate(resp.json())
176
177    def accept_invite(self, invite_id: str) -> TeamDetail:
178        """Accept a direct invite sent to you → join the captain's team."""
179        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/accept")
180        _raise_for_status(resp)
181        return TeamDetail.model_validate(resp.json())
182
183    def decline_invite(self, invite_id: str) -> None:
184        """Decline a direct invite sent to you (only the named target may)."""
185        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/decline")
186        _raise_for_status(resp)
187
188    # -- asking to be let in (a private, listed competition) ---------------
189
190    def application(self) -> CompetitionApplicationInfo | None:
191        """Your application to this competition, or ``None`` if you have
192        not applied.
193
194        ``None`` is the server's ``null`` at 200 — a hidden competition
195        already answers 404, so "never applied" is not an error.
196        """
197        resp = self._http.request("GET", f"/competitions/{self._cid}/application")
198        _raise_for_status(resp)
199        body = resp.json()
200        return None if body is None else CompetitionApplicationInfo.model_validate(body)
201
202    def apply(
203        self,
204        message: str = "",
205        *,
206        organization: str,
207        purpose: ApplicationPurpose,
208        contact_email: str,
209        purpose_detail: str = "",
210    ) -> CompetitionApplicationInfo:
211        """Ask the organisers of a private competition or suite to invite you.
212
213        For an event or suite that is listed but invitation-only. The form
214        asks who is applying and why (#2306): ``organization`` and
215        ``contact_email`` are required, ``purpose`` is ``vendor_evaluation`` /
216        ``academic_research`` / ``teaching`` / ``other`` (``other`` needs
217        ``purpose_detail``: ``application_purpose_detail_required``). Calling
218        it again while the application is pending replaces the answers and
219        keeps its place in the queue. An approval arrives as an invitation,
220        after which :meth:`register` works -- and a suite's challenges open --
221        as for anybody invited.
222
223        Refused (409) with a ``code`` saying why: ``application_not_needed``
224        (public — just register), ``already_invited`` (you may already take
225        part), ``registration_closed``, ``application_rejected`` (final;
226        the organisers can still invite you). Not a team join request —
227        that is :meth:`request_to_join`, and asks a captain.
228        """
229        resp = self._http.request(
230            "PUT",
231            f"/competitions/{self._cid}/application",
232            json={
233                "message": message,
234                "organization": organization,
235                "purpose": purpose,
236                "purpose_detail": purpose_detail,
237                "contact_email": contact_email,
238            },
239        )
240        _raise_for_status(resp)
241        return CompetitionApplicationInfo.model_validate(resp.json())
242
243    def withdraw_application(self) -> None:
244        """Withdraw a pending application. A decided one stays, and asking
245        is ``application_already_decided`` (409)."""
246        resp = self._http.request("DELETE", f"/competitions/{self._cid}/application")
247        _raise_for_status(resp)
248
249    # -- standings --------------------------------------------------------
250
251    def scoreboard(
252        self, offset: int = 0, limit: int = 50, division: str = "", board: str = ""
253    ) -> PagedList[ScoreboardEntry]:
254        """Ranked standings for this competition.
255
256        ``board`` names which of the competition's boards (#1965 G) — a
257        slug :meth:`boards` lists. Empty (or ``"main"``) is the primary,
258        the competition's own rule, which is the board this method has
259        always returned. An unknown slug, or a shadow board for anybody
260        but an organiser, raises ``404 competition_board_not_found``.
261
262        ``division`` narrows to one side of a mixed board: ``"human"``
263        or ``"agent"``. Empty — the default — is everybody, which is the
264        board this method has always returned. ⚠️ Split here rather than
265        by filtering the result: the rows are paginated server-side, so a
266        client-side filter yields whichever of one page matched, still
267        carrying their *merged* ranks.
268        """
269        params: dict[str, Any] = {"offset": offset, "limit": limit}
270        if division:
271            params["division"] = division
272        if board:
273            params["board"] = board
274        resp = self._http.request(
275            "GET",
276            f"/competitions/{self._cid}/scoreboard",
277            params=params,
278        )
279        _raise_for_status(resp)
280        return _extract_items(resp.json(), ScoreboardEntry)
281
282    def boards(self) -> CompetitionBoardsResponse:
283        """This competition's boards (#1965 G): the primary first
284        (``primary=True``, slug ``main``), then each published extra — a
285        separate standing under its own rule, e.g. an authoring board beside
286        the solving one. An organiser also receives the shadow boards.
287        Pass a ``slug`` to :meth:`scoreboard` to read one."""
288        resp = self._http.request("GET", f"/competitions/{self._cid}/boards")
289        _raise_for_status(resp)
290        return CompetitionBoardsResponse.model_validate(resp.json())
291
292    def score_history(self, *, top: int = 10) -> CompetitionScoreHistory:
293        """Score-progression series for the top ``top`` teams."""
294        resp = self._http.request(
295            "GET", f"/competitions/{self._cid}/score-history", params={"top": top}
296        )
297        _raise_for_status(resp)
298        return CompetitionScoreHistory.model_validate(resp.json())
299
300    def score_distribution(self) -> CompetitionScoreDistribution:
301        """Score histogram buckets for this competition."""
302        resp = self._http.request("GET", f"/competitions/{self._cid}/scoreboard/distribution")
303        _raise_for_status(resp)
304        return CompetitionScoreDistribution.model_validate(resp.json())
305
306    def solve_matrix(self) -> AdminSolveMatrix:
307        """Per-team × per-challenge solve grid scoped to this competition."""
308        resp = self._http.request("GET", f"/competitions/{self._cid}/solve-matrix")
309        _raise_for_status(resp)
310        return AdminSolveMatrix.model_validate(resp.json())
311
312    def defence_matrix(self) -> DefenceMatrixResponse:
313        """Per-team × per-challenge **defence** verdicts for this competition.
314
315        The defence-side twin of :meth:`solve_matrix`, and deliberately a
316        separate call rather than a flag on it: a solve cell is binary
317        and monotone, a defence cell is one of five verdicts, and three
318        of them describe a team that tried and is not done.
319
320        A team that fixed a challenge and later submitted a worse patch
321        still reads ``fixed`` — that is what the board scores them on, so
322        this and the standings cannot disagree about who is holding what.
323        A pair with no submission is **absent** from ``cells``, never a
324        present empty string.
325        """
326        resp = self._http.request("GET", f"/competitions/{self._cid}/defence/matrix")
327        _raise_for_status(resp)
328        return DefenceMatrixResponse.model_validate(resp.json())
329
330    def roster(self) -> CompetitionRoster:
331        """Every registered team in this competition with its members.
332
333        One request whatever the field size — the per-team
334        :meth:`ctfy.sdk.resources.teams.TeamsResource.get` answers the
335        same question one team at a time, which for a 400-team event is
336        400 round trips. Carries public identity only (display name +
337        avatar), never the entrant dossier.
338        """
339        resp = self._http.request("GET", f"/competitions/{self._cid}/roster")
340        _raise_for_status(resp)
341        return CompetitionRoster.model_validate(resp.json())
342
343    def challenge_breakdown(self) -> CompetitionChallengeBreakdown:
344        """Per-challenge solve / attempt counts for this competition."""
345        resp = self._http.request("GET", f"/competitions/{self._cid}/challenge-breakdown")
346        _raise_for_status(resp)
347        return CompetitionChallengeBreakdown.model_validate(resp.json())
348
349    # -- my team ----------------------------------------------------------
350
351    @cached_property
352    def team(self) -> CompetitionTeam:
353        """My team in this competition (rename / leave / kick + invites + requests)."""
354        return CompetitionTeam(self._http, self._cid)
class Competition:
 52class Competition:
 53    """A competition-scoped view: registration, your team, the comp's
 54    challenges, team search, and standings — all within one competition."""
 55
 56    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
 57        self._http = http
 58        self._cid = competition_id
 59
 60    @property
 61    def id(self) -> str:
 62        """The competition id this handle is bound to."""
 63        return self._cid
 64
 65    # -- info -------------------------------------------------------------
 66
 67    def detail(self) -> CompetitionDetail:
 68        """Full competition detail including resolved challenge summaries
 69        (``.challenges``) in the admin's curated order."""
 70        resp = self._http.request("GET", f"/competitions/{self._cid}")
 71        _raise_for_status(resp)
 72        return CompetitionDetail.model_validate(resp.json())
 73
 74    def challenges(
 75        self,
 76        difficulty: str | None = None,
 77        tag: str = "",
 78        q: str = "",
 79        offset: int = 0,
 80        limit: int = 50,
 81    ) -> PagedList[ChallengeInfo]:
 82        """Challenges scoped to this competition, curated-order sorted."""
 83        params: dict[str, Any] = {"offset": offset, "limit": limit}
 84        if difficulty:
 85            params["difficulty"] = difficulty
 86        if tag:
 87            params["tag"] = tag
 88        if q:
 89            params["q"] = q
 90        resp = self._http.request("GET", f"/competitions/{self._cid}/challenges", params=params)
 91        _raise_for_status(resp)
 92        return _extract_items(resp.json(), ChallengeInfo)
 93
 94    def search_teams(self, q: str = "", offset: int = 0, limit: int = 50) -> PagedList[TeamInfo]:
 95        """Substring search over team names registered in this competition
 96        (the join-a-team picker)."""
 97        params: dict[str, Any] = {"offset": offset, "limit": limit}
 98        if q:
 99            params["q"] = q
100        resp = self._http.request("GET", f"/competitions/{self._cid}/teams/search", params=params)
101        _raise_for_status(resp)
102        return _extract_items(resp.json(), TeamInfo)
103
104    # -- register / join --------------------------------------------------
105
106    def register(
107        self,
108        mode: Literal["solo", "create", "join"] = "solo",
109        *,
110        name: str = "",
111        description: str = "",
112        code: str = "",
113        agreement: AcceptTeamAgreementRequest | None = None,
114    ) -> TeamDetail:
115        """Get on a team for this competition.
116
117        ``agreement`` is the contributor agreement, accepted for the new team
118        by you — its captain (#1965 B). A competition that takes uploads and
119        names an agreement (``authoring_terms_version`` on its detail)
120        refuses a ``solo`` or ``create`` registration without it (422
121        ``registration_agreement_required``). Only a person's own session can
122        send it: a fine-grained token is refused 403
123        ``registration_agreement_needs_a_person``. ``join`` takes none — the
124        team's captain already accepted.
125
126        - ``mode="solo"`` — mint a 1-person team (idempotent on (you,
127          competition)). Named ``name`` when given — the entry name an
128          individual event shows on its board — else after your display name.
129        - ``mode="create"`` — mint a multi-user team you captain; requires
130          ``name``. 409 if you already have a team for this comp.
131        - ``mode="join"`` — redeem a captain's invite ``code`` to join their
132          team. 409 if the code was for a different comp or you already have
133          a team here.
134        """
135        if mode == "join":
136            if not code:
137                raise ValueError('register(mode="join") requires a `code`')
138            resp = self._http.request(
139                "POST", f"/competitions/{self._cid}/teams/redeem", json={"code": code}
140            )
141        else:
142            if mode == "create" and not name:
143                raise ValueError('register(mode="create") requires a `name`')
144            body: dict[str, object] = {"mode": mode}
145            if name:
146                body["name"] = name
147            if mode == "create":
148                body["description"] = description
149            if agreement is not None:
150                body["agreement"] = agreement.model_dump(mode="json")
151            resp = self._http.request("POST", f"/competitions/{self._cid}/teams", json=body)
152        _raise_for_status(resp)
153        return TeamDetail.model_validate(resp.json())
154
155    def check_team_name(self, name: str) -> TeamNameCheck:
156        """May ``name`` be a team's name in this competition? (#2238)
157
158        Asks the rule every registration and rename applies, without writing:
159        ``name`` is what would be stored, ``available`` the verdict, and
160        ``refusal`` a coded reason (``team_name_has_link`` / ``_reserved`` /
161        ``_blocked`` / ``_too_long`` / ``_too_short`` / ``_taken``) or ``""``.
162        Advisory -- the registration itself decides, since a free name can be
163        taken before you submit.
164        """
165        resp = self._http.request(
166            "GET", f"/competitions/{self._cid}/team-name-check", params={"name": name}
167        )
168        _raise_for_status(resp)
169        return TeamNameCheck.model_validate(resp.json())
170
171    def request_to_join(self, team_id: str) -> TeamInviteInfo:
172        """Ask to join ``team_id`` in this competition. The captain sees it
173        in their inbox and approves / rejects (``team.requests``)."""
174        resp = self._http.request("POST", f"/competitions/{self._cid}/teams/{team_id}/join-request")
175        _raise_for_status(resp)
176        return TeamInviteInfo.model_validate(resp.json())
177
178    def accept_invite(self, invite_id: str) -> TeamDetail:
179        """Accept a direct invite sent to you → join the captain's team."""
180        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/accept")
181        _raise_for_status(resp)
182        return TeamDetail.model_validate(resp.json())
183
184    def decline_invite(self, invite_id: str) -> None:
185        """Decline a direct invite sent to you (only the named target may)."""
186        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/decline")
187        _raise_for_status(resp)
188
189    # -- asking to be let in (a private, listed competition) ---------------
190
191    def application(self) -> CompetitionApplicationInfo | None:
192        """Your application to this competition, or ``None`` if you have
193        not applied.
194
195        ``None`` is the server's ``null`` at 200 — a hidden competition
196        already answers 404, so "never applied" is not an error.
197        """
198        resp = self._http.request("GET", f"/competitions/{self._cid}/application")
199        _raise_for_status(resp)
200        body = resp.json()
201        return None if body is None else CompetitionApplicationInfo.model_validate(body)
202
203    def apply(
204        self,
205        message: str = "",
206        *,
207        organization: str,
208        purpose: ApplicationPurpose,
209        contact_email: str,
210        purpose_detail: str = "",
211    ) -> CompetitionApplicationInfo:
212        """Ask the organisers of a private competition or suite to invite you.
213
214        For an event or suite that is listed but invitation-only. The form
215        asks who is applying and why (#2306): ``organization`` and
216        ``contact_email`` are required, ``purpose`` is ``vendor_evaluation`` /
217        ``academic_research`` / ``teaching`` / ``other`` (``other`` needs
218        ``purpose_detail``: ``application_purpose_detail_required``). Calling
219        it again while the application is pending replaces the answers and
220        keeps its place in the queue. An approval arrives as an invitation,
221        after which :meth:`register` works -- and a suite's challenges open --
222        as for anybody invited.
223
224        Refused (409) with a ``code`` saying why: ``application_not_needed``
225        (public — just register), ``already_invited`` (you may already take
226        part), ``registration_closed``, ``application_rejected`` (final;
227        the organisers can still invite you). Not a team join request —
228        that is :meth:`request_to_join`, and asks a captain.
229        """
230        resp = self._http.request(
231            "PUT",
232            f"/competitions/{self._cid}/application",
233            json={
234                "message": message,
235                "organization": organization,
236                "purpose": purpose,
237                "purpose_detail": purpose_detail,
238                "contact_email": contact_email,
239            },
240        )
241        _raise_for_status(resp)
242        return CompetitionApplicationInfo.model_validate(resp.json())
243
244    def withdraw_application(self) -> None:
245        """Withdraw a pending application. A decided one stays, and asking
246        is ``application_already_decided`` (409)."""
247        resp = self._http.request("DELETE", f"/competitions/{self._cid}/application")
248        _raise_for_status(resp)
249
250    # -- standings --------------------------------------------------------
251
252    def scoreboard(
253        self, offset: int = 0, limit: int = 50, division: str = "", board: str = ""
254    ) -> PagedList[ScoreboardEntry]:
255        """Ranked standings for this competition.
256
257        ``board`` names which of the competition's boards (#1965 G) — a
258        slug :meth:`boards` lists. Empty (or ``"main"``) is the primary,
259        the competition's own rule, which is the board this method has
260        always returned. An unknown slug, or a shadow board for anybody
261        but an organiser, raises ``404 competition_board_not_found``.
262
263        ``division`` narrows to one side of a mixed board: ``"human"``
264        or ``"agent"``. Empty — the default — is everybody, which is the
265        board this method has always returned. ⚠️ Split here rather than
266        by filtering the result: the rows are paginated server-side, so a
267        client-side filter yields whichever of one page matched, still
268        carrying their *merged* ranks.
269        """
270        params: dict[str, Any] = {"offset": offset, "limit": limit}
271        if division:
272            params["division"] = division
273        if board:
274            params["board"] = board
275        resp = self._http.request(
276            "GET",
277            f"/competitions/{self._cid}/scoreboard",
278            params=params,
279        )
280        _raise_for_status(resp)
281        return _extract_items(resp.json(), ScoreboardEntry)
282
283    def boards(self) -> CompetitionBoardsResponse:
284        """This competition's boards (#1965 G): the primary first
285        (``primary=True``, slug ``main``), then each published extra — a
286        separate standing under its own rule, e.g. an authoring board beside
287        the solving one. An organiser also receives the shadow boards.
288        Pass a ``slug`` to :meth:`scoreboard` to read one."""
289        resp = self._http.request("GET", f"/competitions/{self._cid}/boards")
290        _raise_for_status(resp)
291        return CompetitionBoardsResponse.model_validate(resp.json())
292
293    def score_history(self, *, top: int = 10) -> CompetitionScoreHistory:
294        """Score-progression series for the top ``top`` teams."""
295        resp = self._http.request(
296            "GET", f"/competitions/{self._cid}/score-history", params={"top": top}
297        )
298        _raise_for_status(resp)
299        return CompetitionScoreHistory.model_validate(resp.json())
300
301    def score_distribution(self) -> CompetitionScoreDistribution:
302        """Score histogram buckets for this competition."""
303        resp = self._http.request("GET", f"/competitions/{self._cid}/scoreboard/distribution")
304        _raise_for_status(resp)
305        return CompetitionScoreDistribution.model_validate(resp.json())
306
307    def solve_matrix(self) -> AdminSolveMatrix:
308        """Per-team × per-challenge solve grid scoped to this competition."""
309        resp = self._http.request("GET", f"/competitions/{self._cid}/solve-matrix")
310        _raise_for_status(resp)
311        return AdminSolveMatrix.model_validate(resp.json())
312
313    def defence_matrix(self) -> DefenceMatrixResponse:
314        """Per-team × per-challenge **defence** verdicts for this competition.
315
316        The defence-side twin of :meth:`solve_matrix`, and deliberately a
317        separate call rather than a flag on it: a solve cell is binary
318        and monotone, a defence cell is one of five verdicts, and three
319        of them describe a team that tried and is not done.
320
321        A team that fixed a challenge and later submitted a worse patch
322        still reads ``fixed`` — that is what the board scores them on, so
323        this and the standings cannot disagree about who is holding what.
324        A pair with no submission is **absent** from ``cells``, never a
325        present empty string.
326        """
327        resp = self._http.request("GET", f"/competitions/{self._cid}/defence/matrix")
328        _raise_for_status(resp)
329        return DefenceMatrixResponse.model_validate(resp.json())
330
331    def roster(self) -> CompetitionRoster:
332        """Every registered team in this competition with its members.
333
334        One request whatever the field size — the per-team
335        :meth:`ctfy.sdk.resources.teams.TeamsResource.get` answers the
336        same question one team at a time, which for a 400-team event is
337        400 round trips. Carries public identity only (display name +
338        avatar), never the entrant dossier.
339        """
340        resp = self._http.request("GET", f"/competitions/{self._cid}/roster")
341        _raise_for_status(resp)
342        return CompetitionRoster.model_validate(resp.json())
343
344    def challenge_breakdown(self) -> CompetitionChallengeBreakdown:
345        """Per-challenge solve / attempt counts for this competition."""
346        resp = self._http.request("GET", f"/competitions/{self._cid}/challenge-breakdown")
347        _raise_for_status(resp)
348        return CompetitionChallengeBreakdown.model_validate(resp.json())
349
350    # -- my team ----------------------------------------------------------
351
352    @cached_property
353    def team(self) -> CompetitionTeam:
354        """My team in this competition (rename / leave / kick + invites + requests)."""
355        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)
56    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
57        self._http = http
58        self._cid = competition_id
id: str
60    @property
61    def id(self) -> str:
62        """The competition id this handle is bound to."""
63        return self._cid

The competition id this handle is bound to.

def detail(self) -> ctfy.server.models.CompetitionDetail:
67    def detail(self) -> CompetitionDetail:
68        """Full competition detail including resolved challenge summaries
69        (``.challenges``) in the admin's curated order."""
70        resp = self._http.request("GET", f"/competitions/{self._cid}")
71        _raise_for_status(resp)
72        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]:
74    def challenges(
75        self,
76        difficulty: str | None = None,
77        tag: str = "",
78        q: str = "",
79        offset: int = 0,
80        limit: int = 50,
81    ) -> PagedList[ChallengeInfo]:
82        """Challenges scoped to this competition, curated-order sorted."""
83        params: dict[str, Any] = {"offset": offset, "limit": limit}
84        if difficulty:
85            params["difficulty"] = difficulty
86        if tag:
87            params["tag"] = tag
88        if q:
89            params["q"] = q
90        resp = self._http.request("GET", f"/competitions/{self._cid}/challenges", params=params)
91        _raise_for_status(resp)
92        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]:
 94    def search_teams(self, q: str = "", offset: int = 0, limit: int = 50) -> PagedList[TeamInfo]:
 95        """Substring search over team names registered in this competition
 96        (the join-a-team picker)."""
 97        params: dict[str, Any] = {"offset": offset, "limit": limit}
 98        if q:
 99            params["q"] = q
100        resp = self._http.request("GET", f"/competitions/{self._cid}/teams/search", params=params)
101        _raise_for_status(resp)
102        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 = '', agreement: ctfy.server.models.AcceptTeamAgreementRequest | None = None) -> ctfy.server.models.TeamDetail:
106    def register(
107        self,
108        mode: Literal["solo", "create", "join"] = "solo",
109        *,
110        name: str = "",
111        description: str = "",
112        code: str = "",
113        agreement: AcceptTeamAgreementRequest | None = None,
114    ) -> TeamDetail:
115        """Get on a team for this competition.
116
117        ``agreement`` is the contributor agreement, accepted for the new team
118        by you — its captain (#1965 B). A competition that takes uploads and
119        names an agreement (``authoring_terms_version`` on its detail)
120        refuses a ``solo`` or ``create`` registration without it (422
121        ``registration_agreement_required``). Only a person's own session can
122        send it: a fine-grained token is refused 403
123        ``registration_agreement_needs_a_person``. ``join`` takes none — the
124        team's captain already accepted.
125
126        - ``mode="solo"`` — mint a 1-person team (idempotent on (you,
127          competition)). Named ``name`` when given — the entry name an
128          individual event shows on its board — else after your display name.
129        - ``mode="create"`` — mint a multi-user team you captain; requires
130          ``name``. 409 if you already have a team for this comp.
131        - ``mode="join"`` — redeem a captain's invite ``code`` to join their
132          team. 409 if the code was for a different comp or you already have
133          a team here.
134        """
135        if mode == "join":
136            if not code:
137                raise ValueError('register(mode="join") requires a `code`')
138            resp = self._http.request(
139                "POST", f"/competitions/{self._cid}/teams/redeem", json={"code": code}
140            )
141        else:
142            if mode == "create" and not name:
143                raise ValueError('register(mode="create") requires a `name`')
144            body: dict[str, object] = {"mode": mode}
145            if name:
146                body["name"] = name
147            if mode == "create":
148                body["description"] = description
149            if agreement is not None:
150                body["agreement"] = agreement.model_dump(mode="json")
151            resp = self._http.request("POST", f"/competitions/{self._cid}/teams", json=body)
152        _raise_for_status(resp)
153        return TeamDetail.model_validate(resp.json())

Get on a team for this competition.

agreement is the contributor agreement, accepted for the new team by you — its captain (#1965 B). A competition that takes uploads and names an agreement (authoring_terms_version on its detail) refuses a solo or create registration without it (422 registration_agreement_required). Only a person's own session can send it: a fine-grained token is refused 403 registration_agreement_needs_a_person. join takes none — the team's captain already accepted.

  • mode="solo" — mint a 1-person team (idempotent on (you, competition)). Named name when given — the entry name an individual event shows on its board — else after your display name.
  • 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 check_team_name(self, name: str) -> ctfy.server.models.TeamNameCheck:
155    def check_team_name(self, name: str) -> TeamNameCheck:
156        """May ``name`` be a team's name in this competition? (#2238)
157
158        Asks the rule every registration and rename applies, without writing:
159        ``name`` is what would be stored, ``available`` the verdict, and
160        ``refusal`` a coded reason (``team_name_has_link`` / ``_reserved`` /
161        ``_blocked`` / ``_too_long`` / ``_too_short`` / ``_taken``) or ``""``.
162        Advisory -- the registration itself decides, since a free name can be
163        taken before you submit.
164        """
165        resp = self._http.request(
166            "GET", f"/competitions/{self._cid}/team-name-check", params={"name": name}
167        )
168        _raise_for_status(resp)
169        return TeamNameCheck.model_validate(resp.json())

May name be a team's name in this competition? (#2238)

Asks the rule every registration and rename applies, without writing: name is what would be stored, available the verdict, and refusal a coded reason (team_name_has_link / _reserved / _blocked / _too_long / _too_short / _taken) or "". Advisory -- the registration itself decides, since a free name can be taken before you submit.

def request_to_join(self, team_id: str) -> ctfy.server.models.TeamInviteInfo:
171    def request_to_join(self, team_id: str) -> TeamInviteInfo:
172        """Ask to join ``team_id`` in this competition. The captain sees it
173        in their inbox and approves / rejects (``team.requests``)."""
174        resp = self._http.request("POST", f"/competitions/{self._cid}/teams/{team_id}/join-request")
175        _raise_for_status(resp)
176        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:
178    def accept_invite(self, invite_id: str) -> TeamDetail:
179        """Accept a direct invite sent to you → join the captain's team."""
180        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/accept")
181        _raise_for_status(resp)
182        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:
184    def decline_invite(self, invite_id: str) -> None:
185        """Decline a direct invite sent to you (only the named target may)."""
186        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/decline")
187        _raise_for_status(resp)

Decline a direct invite sent to you (only the named target may).

def application(self) -> ctfy.server.models.CompetitionApplicationInfo | None:
191    def application(self) -> CompetitionApplicationInfo | None:
192        """Your application to this competition, or ``None`` if you have
193        not applied.
194
195        ``None`` is the server's ``null`` at 200 — a hidden competition
196        already answers 404, so "never applied" is not an error.
197        """
198        resp = self._http.request("GET", f"/competitions/{self._cid}/application")
199        _raise_for_status(resp)
200        body = resp.json()
201        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 = '', *, organization: str, purpose: Literal['vendor_evaluation', 'academic_research', 'teaching', 'other'], contact_email: str, purpose_detail: str = '') -> ctfy.server.models.CompetitionApplicationInfo:
203    def apply(
204        self,
205        message: str = "",
206        *,
207        organization: str,
208        purpose: ApplicationPurpose,
209        contact_email: str,
210        purpose_detail: str = "",
211    ) -> CompetitionApplicationInfo:
212        """Ask the organisers of a private competition or suite to invite you.
213
214        For an event or suite that is listed but invitation-only. The form
215        asks who is applying and why (#2306): ``organization`` and
216        ``contact_email`` are required, ``purpose`` is ``vendor_evaluation`` /
217        ``academic_research`` / ``teaching`` / ``other`` (``other`` needs
218        ``purpose_detail``: ``application_purpose_detail_required``). Calling
219        it again while the application is pending replaces the answers and
220        keeps its place in the queue. An approval arrives as an invitation,
221        after which :meth:`register` works -- and a suite's challenges open --
222        as for anybody invited.
223
224        Refused (409) with a ``code`` saying why: ``application_not_needed``
225        (public — just register), ``already_invited`` (you may already take
226        part), ``registration_closed``, ``application_rejected`` (final;
227        the organisers can still invite you). Not a team join request —
228        that is :meth:`request_to_join`, and asks a captain.
229        """
230        resp = self._http.request(
231            "PUT",
232            f"/competitions/{self._cid}/application",
233            json={
234                "message": message,
235                "organization": organization,
236                "purpose": purpose,
237                "purpose_detail": purpose_detail,
238                "contact_email": contact_email,
239            },
240        )
241        _raise_for_status(resp)
242        return CompetitionApplicationInfo.model_validate(resp.json())

Ask the organisers of a private competition or suite to invite you.

For an event or suite that is listed but invitation-only. The form asks who is applying and why (#2306): organization and contact_email are required, purpose is vendor_evaluation / academic_research / teaching / other (other needs purpose_detail: application_purpose_detail_required). Calling it again while the application is pending replaces the answers and keeps its place in the queue. An approval arrives as an invitation, after which register() works -- and a suite's challenges open -- 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:
244    def withdraw_application(self) -> None:
245        """Withdraw a pending application. A decided one stays, and asking
246        is ``application_already_decided`` (409)."""
247        resp = self._http.request("DELETE", f"/competitions/{self._cid}/application")
248        _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 = '', board: str = '') -> ctfy.sdk._helpers.PagedList[ctfy.server.models.ScoreboardEntry]:
252    def scoreboard(
253        self, offset: int = 0, limit: int = 50, division: str = "", board: str = ""
254    ) -> PagedList[ScoreboardEntry]:
255        """Ranked standings for this competition.
256
257        ``board`` names which of the competition's boards (#1965 G) — a
258        slug :meth:`boards` lists. Empty (or ``"main"``) is the primary,
259        the competition's own rule, which is the board this method has
260        always returned. An unknown slug, or a shadow board for anybody
261        but an organiser, raises ``404 competition_board_not_found``.
262
263        ``division`` narrows to one side of a mixed board: ``"human"``
264        or ``"agent"``. Empty — the default — is everybody, which is the
265        board this method has always returned. ⚠️ Split here rather than
266        by filtering the result: the rows are paginated server-side, so a
267        client-side filter yields whichever of one page matched, still
268        carrying their *merged* ranks.
269        """
270        params: dict[str, Any] = {"offset": offset, "limit": limit}
271        if division:
272            params["division"] = division
273        if board:
274            params["board"] = board
275        resp = self._http.request(
276            "GET",
277            f"/competitions/{self._cid}/scoreboard",
278            params=params,
279        )
280        _raise_for_status(resp)
281        return _extract_items(resp.json(), ScoreboardEntry)

Ranked standings for this competition.

board names which of the competition's boards (#1965 G) — a slug boards() lists. Empty (or "main") is the primary, the competition's own rule, which is the board this method has always returned. An unknown slug, or a shadow board for anybody but an organiser, raises 404 competition_board_not_found.

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 boards(self) -> ctfy.server.models.CompetitionBoardsResponse:
283    def boards(self) -> CompetitionBoardsResponse:
284        """This competition's boards (#1965 G): the primary first
285        (``primary=True``, slug ``main``), then each published extra — a
286        separate standing under its own rule, e.g. an authoring board beside
287        the solving one. An organiser also receives the shadow boards.
288        Pass a ``slug`` to :meth:`scoreboard` to read one."""
289        resp = self._http.request("GET", f"/competitions/{self._cid}/boards")
290        _raise_for_status(resp)
291        return CompetitionBoardsResponse.model_validate(resp.json())

This competition's boards (#1965 G): the primary first (primary=True, slug main), then each published extra — a separate standing under its own rule, e.g. an authoring board beside the solving one. An organiser also receives the shadow boards. Pass a slug to scoreboard() to read one.

def score_history( self, *, top: int = 10) -> ctfy.server.models.CompetitionScoreHistory:
293    def score_history(self, *, top: int = 10) -> CompetitionScoreHistory:
294        """Score-progression series for the top ``top`` teams."""
295        resp = self._http.request(
296            "GET", f"/competitions/{self._cid}/score-history", params={"top": top}
297        )
298        _raise_for_status(resp)
299        return CompetitionScoreHistory.model_validate(resp.json())

Score-progression series for the top top teams.

def score_distribution(self) -> ctfy.server.models.CompetitionScoreDistribution:
301    def score_distribution(self) -> CompetitionScoreDistribution:
302        """Score histogram buckets for this competition."""
303        resp = self._http.request("GET", f"/competitions/{self._cid}/scoreboard/distribution")
304        _raise_for_status(resp)
305        return CompetitionScoreDistribution.model_validate(resp.json())

Score histogram buckets for this competition.

def solve_matrix(self) -> ctfy.server.models.AdminSolveMatrix:
307    def solve_matrix(self) -> AdminSolveMatrix:
308        """Per-team × per-challenge solve grid scoped to this competition."""
309        resp = self._http.request("GET", f"/competitions/{self._cid}/solve-matrix")
310        _raise_for_status(resp)
311        return AdminSolveMatrix.model_validate(resp.json())

Per-team × per-challenge solve grid scoped to this competition.

def defence_matrix(self) -> ctfy.server.models.DefenceMatrixResponse:
313    def defence_matrix(self) -> DefenceMatrixResponse:
314        """Per-team × per-challenge **defence** verdicts for this competition.
315
316        The defence-side twin of :meth:`solve_matrix`, and deliberately a
317        separate call rather than a flag on it: a solve cell is binary
318        and monotone, a defence cell is one of five verdicts, and three
319        of them describe a team that tried and is not done.
320
321        A team that fixed a challenge and later submitted a worse patch
322        still reads ``fixed`` — that is what the board scores them on, so
323        this and the standings cannot disagree about who is holding what.
324        A pair with no submission is **absent** from ``cells``, never a
325        present empty string.
326        """
327        resp = self._http.request("GET", f"/competitions/{self._cid}/defence/matrix")
328        _raise_for_status(resp)
329        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:
331    def roster(self) -> CompetitionRoster:
332        """Every registered team in this competition with its members.
333
334        One request whatever the field size — the per-team
335        :meth:`ctfy.sdk.resources.teams.TeamsResource.get` answers the
336        same question one team at a time, which for a 400-team event is
337        400 round trips. Carries public identity only (display name +
338        avatar), never the entrant dossier.
339        """
340        resp = self._http.request("GET", f"/competitions/{self._cid}/roster")
341        _raise_for_status(resp)
342        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:
344    def challenge_breakdown(self) -> CompetitionChallengeBreakdown:
345        """Per-challenge solve / attempt counts for this competition."""
346        resp = self._http.request("GET", f"/competitions/{self._cid}/challenge-breakdown")
347        _raise_for_status(resp)
348        return CompetitionChallengeBreakdown.model_validate(resp.json())

Per-challenge solve / attempt counts for this competition.

352    @cached_property
353    def team(self) -> CompetitionTeam:
354        """My team in this competition (rename / leave / kick + invites + requests)."""
355        return CompetitionTeam(self._http, self._cid)

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