ctfy.sdk.competition

client.competition(id) — a handle scoped to one competition.

Collects the operations that are inherently competition-scoped — the ones that used to repeat competition_id on every call: registration, your team (captain tooling, invites, join-requests), the competition's challenge list, team search, and standings. competition_id is named once (client.competition(id)).

Discovery of competitions stays on client.competitions (.list() / .get(id)). Instance lifecycle (client.instances) and answer submission (client.submissions) stay flat: they're keyed by instance_id and the server infers the competition, so they don't belong to a comp handle.

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

A competition-scoped view: registration, your team, the comp's challenges, team search, and standings — all within one competition.

Competition(http: ctfy.sdk.base.BaseHttpClient, competition_id: str)
49    def __init__(self, http: BaseHttpClient, competition_id: str) -> None:
50        self._http = http
51        self._cid = competition_id
id: str
53    @property
54    def id(self) -> str:
55        """The competition id this handle is bound to."""
56        return self._cid

The competition id this handle is bound to.

def detail(self) -> ctfy.server.models.CompetitionDetail:
60    def detail(self) -> CompetitionDetail:
61        """Full competition detail including resolved challenge summaries
62        (``.challenges``) in the admin's curated order."""
63        resp = self._http.request("GET", f"/competitions/{self._cid}")
64        _raise_for_status(resp)
65        return CompetitionDetail.model_validate(resp.json())

Full competition detail including resolved challenge summaries (.challenges) in the admin's curated order.

def challenges( self, difficulty: str | None = None, tag: str = '', q: str = '', offset: int = 0, limit: int = 50) -> list[ctfy.server.models.ChallengeInfo]:
67    def challenges(
68        self,
69        difficulty: str | None = None,
70        tag: str = "",
71        q: str = "",
72        offset: int = 0,
73        limit: int = 50,
74    ) -> list[ChallengeInfo]:
75        """Challenges scoped to this competition, curated-order sorted."""
76        params: dict[str, Any] = {"offset": offset, "limit": limit}
77        if difficulty:
78            params["difficulty"] = difficulty
79        if tag:
80            params["tag"] = tag
81        if q:
82            params["q"] = q
83        resp = self._http.request("GET", f"/competitions/{self._cid}/challenges", params=params)
84        _raise_for_status(resp)
85        return _extract_items(resp.json(), ChallengeInfo)

Challenges scoped to this competition, curated-order sorted.

def search_teams( self, q: str = '', offset: int = 0, limit: int = 50) -> list[ctfy.server.models.TeamInfo]:
87    def search_teams(self, q: str = "", offset: int = 0, limit: int = 50) -> list[TeamInfo]:
88        """Substring search over team names registered in this competition
89        (the join-a-team picker)."""
90        params: dict[str, Any] = {"offset": offset, "limit": limit}
91        if q:
92            params["q"] = q
93        resp = self._http.request("GET", f"/competitions/{self._cid}/teams/search", params=params)
94        _raise_for_status(resp)
95        return _extract_items(resp.json(), TeamInfo)

Substring search over team names registered in this competition (the join-a-team picker).

def register( self, mode: Literal['solo', 'create', 'join'] = 'solo', *, name: str = '', description: str = '', code: str = '') -> ctfy.server.models.TeamDetail:
 99    def register(
100        self,
101        mode: Literal["solo", "create", "join"] = "solo",
102        *,
103        name: str = "",
104        description: str = "",
105        code: str = "",
106    ) -> TeamDetail:
107        """Get on a team for this competition.
108
109        - ``mode="solo"`` — mint a 1-person team auto-named after you
110          (idempotent on (you, competition)).
111        - ``mode="create"`` — mint a multi-user team you captain; requires
112          ``name``. 409 if you already have a team for this comp.
113        - ``mode="join"`` — redeem a captain's invite ``code`` to join their
114          team. 409 if the code was for a different comp or you already have
115          a team here.
116        """
117        if mode == "join":
118            if not code:
119                raise ValueError('register(mode="join") requires a `code`')
120            resp = self._http.request(
121                "POST", f"/competitions/{self._cid}/teams/redeem", json={"code": code}
122            )
123        else:
124            if mode == "create" and not name:
125                raise ValueError('register(mode="create") requires a `name`')
126            body: dict[str, str] = {"mode": mode}
127            if mode == "create":
128                body["name"] = name
129                body["description"] = description
130            resp = self._http.request("POST", f"/competitions/{self._cid}/teams", json=body)
131        _raise_for_status(resp)
132        return TeamDetail.model_validate(resp.json())

Get on a team for this competition.

  • mode="solo" — mint a 1-person team auto-named after you (idempotent on (you, competition)).
  • mode="create" — mint a multi-user team you captain; 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:
134    def request_to_join(self, team_id: str) -> TeamInviteInfo:
135        """Ask to join ``team_id`` in this competition. The captain sees it
136        in their inbox and approves / rejects (``team.requests``)."""
137        resp = self._http.request("POST", f"/competitions/{self._cid}/teams/{team_id}/join-request")
138        _raise_for_status(resp)
139        return TeamInviteInfo.model_validate(resp.json())

Ask to join team_id in this competition. The captain sees it in their inbox and approves / rejects (team.requests).

def accept_invite(self, invite_id: str) -> ctfy.server.models.TeamDetail:
141    def accept_invite(self, invite_id: str) -> TeamDetail:
142        """Accept a direct invite sent to you → join the captain's team."""
143        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/accept")
144        _raise_for_status(resp)
145        return TeamDetail.model_validate(resp.json())

Accept a direct invite sent to you → join the captain's team.

def decline_invite(self, invite_id: str) -> None:
147    def decline_invite(self, invite_id: str) -> None:
148        """Decline a direct invite sent to you (only the named target may)."""
149        resp = self._http.request("POST", f"/competitions/{self._cid}/invites/{invite_id}/decline")
150        _raise_for_status(resp)

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

def scoreboard( self, offset: int = 0, limit: int = 50) -> list[ctfy.server.models.ScoreboardEntry]:
154    def scoreboard(self, offset: int = 0, limit: int = 50) -> list[ScoreboardEntry]:
155        """Ranked standings for this competition."""
156        resp = self._http.request(
157            "GET",
158            f"/competitions/{self._cid}/scoreboard",
159            params={"offset": offset, "limit": limit},
160        )
161        _raise_for_status(resp)
162        return _extract_items(resp.json(), ScoreboardEntry)

Ranked standings for this competition.

def score_history( self, *, top: int = 10) -> ctfy.server.models.CompetitionScoreHistory:
164    def score_history(self, *, top: int = 10) -> CompetitionScoreHistory:
165        """Score-progression series for the top ``top`` teams."""
166        resp = self._http.request(
167            "GET", f"/competitions/{self._cid}/score-history", params={"top": top}
168        )
169        _raise_for_status(resp)
170        return CompetitionScoreHistory.model_validate(resp.json())

Score-progression series for the top top teams.

def score_distribution(self) -> ctfy.server.models.CompetitionScoreDistribution:
172    def score_distribution(self) -> CompetitionScoreDistribution:
173        """Score histogram buckets for this competition."""
174        resp = self._http.request("GET", f"/competitions/{self._cid}/scoreboard/distribution")
175        _raise_for_status(resp)
176        return CompetitionScoreDistribution.model_validate(resp.json())

Score histogram buckets for this competition.

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

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

def challenge_breakdown(self) -> ctfy.server.models.CompetitionChallengeBreakdown:
184    def challenge_breakdown(self) -> CompetitionChallengeBreakdown:
185        """Per-challenge solve / attempt counts for this competition."""
186        resp = self._http.request("GET", f"/competitions/{self._cid}/challenge-breakdown")
187        _raise_for_status(resp)
188        return CompetitionChallengeBreakdown.model_validate(resp.json())

Per-challenge solve / attempt counts for this competition.

192    @cached_property
193    def team(self) -> CompetitionTeam:
194        """My team in this competition (rename / leave / kick + invites + requests)."""
195        return CompetitionTeam(self._http, self._cid)

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