ctfy.sdk.resources.competitions

client.competitions — discover competitions (list / get).

Acting within a competition — register, your team, invites, challenges, instances, submissions, standings — lives on the scoped handle client.competition(id) (ctfy.sdk.competition.Competition).

 1"""``client.competitions`` — discover competitions (list / get).
 2
 3Acting *within* a competition — register, your team, invites, challenges,
 4instances, submissions, standings — lives on the scoped handle
 5``client.competition(id)`` (:class:`ctfy.sdk.competition.Competition`).
 6"""
 7
 8from __future__ import annotations
 9
10from typing import Any
11
12from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status
13from ctfy.sdk.base import BaseHttpClient
14from ctfy.server.models import CompetitionDetail, CompetitionInfo, VirtualGhostBoard
15
16
17class CompetitionsResource:
18    """Browse competitions. To act within one, use ``client.competition(id)``."""
19
20    def __init__(self, http: BaseHttpClient) -> None:
21        self._http = http
22
23    def list(self, phase: str = "", offset: int = 0, limit: int = 50) -> PagedList[CompetitionInfo]:
24        """Public competition list. ``phase`` ∈ ``"" | "upcoming" |
25        "running" | "past"`` ("" = all). Start here, then scope into one
26        with ``client.competition(comp.id)``."""
27        params: dict[str, Any] = {"offset": offset, "limit": limit}
28        if phase:
29            params["phase"] = phase
30        resp = self._http.request("GET", "/competitions", params=params)
31        _raise_for_status(resp)
32        return _extract_items(resp.json(), CompetitionInfo)
33
34    def get(self, competition_id: str) -> CompetitionDetail:
35        """Full competition detail including resolved challenge summaries
36        (``.challenges``). Same as ``client.competition(id).detail()``."""
37        resp = self._http.request("GET", f"/competitions/{competition_id}")
38        _raise_for_status(resp)
39        return CompetitionDetail.model_validate(resp.json())
40
41    def start_virtual(self, competition_id: str) -> CompetitionInfo:
42        """Replay a finished contest on your own clock.
43
44        Returns the *replay*, which is an ordinary competition you are
45        already registered in — so every other call takes its
46        ``.id`` unchanged: launch instances, submit answers, submit
47        patches, read its board.
48
49        **Idempotent.** Asking twice returns the replay you already
50        started rather than minting a second sitting, so a retry cannot
51        split one attempt across two competitions.
52
53        Refused (400) when a replay is not defined: the source has not
54        finished, has no window, is multi-session, never ran, or is
55        itself a replay. The error code says which.
56        """
57        resp = self._http.request("POST", f"/competitions/{competition_id}/virtual")
58        _raise_for_status(resp)
59        return CompetitionInfo.model_validate(resp.json())
60
61    def ghosts(self, competition_id: str) -> VirtualGhostBoard:
62        """The source contest's standings at the instant this replay has
63        reached, with you merged in and ranked among them.
64
65        Takes the **replay's** id, not the source's. At T+40 minutes
66        into your run you see the contest as it stood at T+40 minutes on
67        the day — which is what makes a replay a race rather than an
68        exercise, since a board holding only your own solves answers
69        nothing.
70
71        Refused (400 ``not_a_virtual_sitting``) on an ordinary
72        competition. A source that has since been deleted costs the
73        ghosts and nothing else: the reply comes back with no items and
74        the run itself keeps working.
75        """
76        resp = self._http.request("GET", f"/competitions/{competition_id}/ghosts")
77        _raise_for_status(resp)
78        return VirtualGhostBoard.model_validate(resp.json())
class CompetitionsResource:
18class CompetitionsResource:
19    """Browse competitions. To act within one, use ``client.competition(id)``."""
20
21    def __init__(self, http: BaseHttpClient) -> None:
22        self._http = http
23
24    def list(self, phase: str = "", offset: int = 0, limit: int = 50) -> PagedList[CompetitionInfo]:
25        """Public competition list. ``phase`` ∈ ``"" | "upcoming" |
26        "running" | "past"`` ("" = all). Start here, then scope into one
27        with ``client.competition(comp.id)``."""
28        params: dict[str, Any] = {"offset": offset, "limit": limit}
29        if phase:
30            params["phase"] = phase
31        resp = self._http.request("GET", "/competitions", params=params)
32        _raise_for_status(resp)
33        return _extract_items(resp.json(), CompetitionInfo)
34
35    def get(self, competition_id: str) -> CompetitionDetail:
36        """Full competition detail including resolved challenge summaries
37        (``.challenges``). Same as ``client.competition(id).detail()``."""
38        resp = self._http.request("GET", f"/competitions/{competition_id}")
39        _raise_for_status(resp)
40        return CompetitionDetail.model_validate(resp.json())
41
42    def start_virtual(self, competition_id: str) -> CompetitionInfo:
43        """Replay a finished contest on your own clock.
44
45        Returns the *replay*, which is an ordinary competition you are
46        already registered in — so every other call takes its
47        ``.id`` unchanged: launch instances, submit answers, submit
48        patches, read its board.
49
50        **Idempotent.** Asking twice returns the replay you already
51        started rather than minting a second sitting, so a retry cannot
52        split one attempt across two competitions.
53
54        Refused (400) when a replay is not defined: the source has not
55        finished, has no window, is multi-session, never ran, or is
56        itself a replay. The error code says which.
57        """
58        resp = self._http.request("POST", f"/competitions/{competition_id}/virtual")
59        _raise_for_status(resp)
60        return CompetitionInfo.model_validate(resp.json())
61
62    def ghosts(self, competition_id: str) -> VirtualGhostBoard:
63        """The source contest's standings at the instant this replay has
64        reached, with you merged in and ranked among them.
65
66        Takes the **replay's** id, not the source's. At T+40 minutes
67        into your run you see the contest as it stood at T+40 minutes on
68        the day — which is what makes a replay a race rather than an
69        exercise, since a board holding only your own solves answers
70        nothing.
71
72        Refused (400 ``not_a_virtual_sitting``) on an ordinary
73        competition. A source that has since been deleted costs the
74        ghosts and nothing else: the reply comes back with no items and
75        the run itself keeps working.
76        """
77        resp = self._http.request("GET", f"/competitions/{competition_id}/ghosts")
78        _raise_for_status(resp)
79        return VirtualGhostBoard.model_validate(resp.json())

Browse competitions. To act within one, use client.competition(id).

CompetitionsResource(http: ctfy.sdk.base.BaseHttpClient)
21    def __init__(self, http: BaseHttpClient) -> None:
22        self._http = http
def list( self, phase: str = '', offset: int = 0, limit: int = 50) -> ctfy.sdk._helpers.PagedList[ctfy.server.models.CompetitionInfo]:
24    def list(self, phase: str = "", offset: int = 0, limit: int = 50) -> PagedList[CompetitionInfo]:
25        """Public competition list. ``phase`` ∈ ``"" | "upcoming" |
26        "running" | "past"`` ("" = all). Start here, then scope into one
27        with ``client.competition(comp.id)``."""
28        params: dict[str, Any] = {"offset": offset, "limit": limit}
29        if phase:
30            params["phase"] = phase
31        resp = self._http.request("GET", "/competitions", params=params)
32        _raise_for_status(resp)
33        return _extract_items(resp.json(), CompetitionInfo)

Public competition list. phase"" | "upcoming" | "running" | "past" ("" = all). Start here, then scope into one with client.competition(comp.id).

def get( self, competition_id: str) -> ctfy.server.models.CompetitionDetail:
35    def get(self, competition_id: str) -> CompetitionDetail:
36        """Full competition detail including resolved challenge summaries
37        (``.challenges``). Same as ``client.competition(id).detail()``."""
38        resp = self._http.request("GET", f"/competitions/{competition_id}")
39        _raise_for_status(resp)
40        return CompetitionDetail.model_validate(resp.json())

Full competition detail including resolved challenge summaries (ctfy.sdk.resources.challenges). Same as client.competition(id).detail().

def start_virtual( self, competition_id: str) -> ctfy.server.models.CompetitionInfo:
42    def start_virtual(self, competition_id: str) -> CompetitionInfo:
43        """Replay a finished contest on your own clock.
44
45        Returns the *replay*, which is an ordinary competition you are
46        already registered in — so every other call takes its
47        ``.id`` unchanged: launch instances, submit answers, submit
48        patches, read its board.
49
50        **Idempotent.** Asking twice returns the replay you already
51        started rather than minting a second sitting, so a retry cannot
52        split one attempt across two competitions.
53
54        Refused (400) when a replay is not defined: the source has not
55        finished, has no window, is multi-session, never ran, or is
56        itself a replay. The error code says which.
57        """
58        resp = self._http.request("POST", f"/competitions/{competition_id}/virtual")
59        _raise_for_status(resp)
60        return CompetitionInfo.model_validate(resp.json())

Replay a finished contest on your own clock.

Returns the replay, which is an ordinary competition you are already registered in — so every other call takes its .id unchanged: launch instances, submit answers, submit patches, read its board.

Idempotent. Asking twice returns the replay you already started rather than minting a second sitting, so a retry cannot split one attempt across two competitions.

Refused (400) when a replay is not defined: the source has not finished, has no window, is multi-session, never ran, or is itself a replay. The error code says which.

def ghosts( self, competition_id: str) -> ctfy.server.models.VirtualGhostBoard:
62    def ghosts(self, competition_id: str) -> VirtualGhostBoard:
63        """The source contest's standings at the instant this replay has
64        reached, with you merged in and ranked among them.
65
66        Takes the **replay's** id, not the source's. At T+40 minutes
67        into your run you see the contest as it stood at T+40 minutes on
68        the day — which is what makes a replay a race rather than an
69        exercise, since a board holding only your own solves answers
70        nothing.
71
72        Refused (400 ``not_a_virtual_sitting``) on an ordinary
73        competition. A source that has since been deleted costs the
74        ghosts and nothing else: the reply comes back with no items and
75        the run itself keeps working.
76        """
77        resp = self._http.request("GET", f"/competitions/{competition_id}/ghosts")
78        _raise_for_status(resp)
79        return VirtualGhostBoard.model_validate(resp.json())

The source contest's standings at the instant this replay has reached, with you merged in and ranked among them.

Takes the replay's id, not the source's. At T+40 minutes into your run you see the contest as it stood at T+40 minutes on the day — which is what makes a replay a race rather than an exercise, since a board holding only your own solves answers nothing.

Refused (400 not_a_virtual_sitting) on an ordinary competition. A source that has since been deleted costs the ghosts and nothing else: the reply comes back with no items and the run itself keeps working.