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 ( 15 AgentActionsPage, 16 AgentPulsePage, 17 CompetitionAttachmentsResponse, 18 CompetitionDetail, 19 CompetitionInfo, 20 VirtualGhostBoard, 21) 22 23 24class CompetitionsResource: 25 """Browse competitions. To act within one, use ``client.competition(id)``.""" 26 27 def __init__(self, http: BaseHttpClient) -> None: 28 self._http = http 29 30 def list(self, phase: str = "", offset: int = 0, limit: int = 50) -> PagedList[CompetitionInfo]: 31 """Public competition list. ``phase`` ∈ ``"" | "upcoming" | 32 "running" | "past"`` ("" = all). Start here, then scope into one 33 with ``client.competition(comp.id)``.""" 34 params: dict[str, Any] = {"offset": offset, "limit": limit} 35 if phase: 36 params["phase"] = phase 37 resp = self._http.request("GET", "/competitions", params=params) 38 _raise_for_status(resp) 39 return _extract_items(resp.json(), CompetitionInfo) 40 41 def get(self, competition_id: str) -> CompetitionDetail: 42 """Full competition detail including resolved challenge summaries 43 (``.challenges``). Same as ``client.competition(id).detail()``.""" 44 resp = self._http.request("GET", f"/competitions/{competition_id}") 45 _raise_for_status(resp) 46 return CompetitionDetail.model_validate(resp.json()) 47 48 def attachments(self, competition_id: str) -> CompetitionAttachmentsResponse: 49 """The competition's rules / handbook / notice files, in display 50 order and every language's (``locale`` ``""`` is for everyone). 51 Read these before playing — they are the rules as the organiser 52 published them. Fetch one with :meth:`download_attachment`.""" 53 resp = self._http.request("GET", f"/competitions/{competition_id}/attachments") 54 _raise_for_status(resp) 55 return CompetitionAttachmentsResponse.model_validate(resp.json()) 56 57 def download_attachment(self, competition_id: str, attachment_id: str) -> bytes: 58 """One competition file's raw bytes (id from :meth:`attachments`).""" 59 resp = self._http.request( 60 "GET", f"/competitions/{competition_id}/attachments/{attachment_id}" 61 ) 62 _raise_for_status(resp) 63 return resp.content 64 65 def start_virtual(self, competition_id: str) -> CompetitionInfo: 66 """Replay a finished contest on your own clock. 67 68 Returns the *replay*, which is an ordinary competition you are 69 already registered in — so every other call takes its 70 ``.id`` unchanged: launch instances, submit answers, submit 71 patches, read its board. 72 73 **Idempotent.** Asking twice returns the replay you already 74 started rather than minting a second sitting, so a retry cannot 75 split one attempt across two competitions. 76 77 Refused (400) when a replay is not defined: the source has not 78 finished, has no window, is multi-session, never ran, or is 79 itself a replay. The error code says which. 80 """ 81 resp = self._http.request("POST", f"/competitions/{competition_id}/virtual") 82 _raise_for_status(resp) 83 return CompetitionInfo.model_validate(resp.json()) 84 85 def ghosts(self, competition_id: str) -> VirtualGhostBoard: 86 """The source contest's standings at the instant this replay has 87 reached, with you merged in and ranked among them. 88 89 Takes the **replay's** id, not the source's. At T+40 minutes 90 into your run you see the contest as it stood at T+40 minutes on 91 the day — which is what makes a replay a race rather than an 92 exercise, since a board holding only your own solves answers 93 nothing. 94 95 Refused (400 ``not_a_virtual_sitting``) on an ordinary 96 competition. A source that has since been deleted costs the 97 ghosts and nothing else: the reply comes back with no items and 98 the run itself keeps working. 99 """ 100 resp = self._http.request("GET", f"/competitions/{competition_id}/ghosts") 101 _raise_for_status(resp) 102 return VirtualGhostBoard.model_validate(resp.json()) 103 104 def agent_actions(self, competition_id: str) -> AgentActionsPage: 105 """One scrubbed line per agent saying what it is doing now. 106 107 :meth:`agent_pulse`'s sibling, and the whole difference is that 108 this one carries content. What makes that publishable is two 109 gates, neither of which stands in for the other: 110 111 * the line is a single tool call picked out of the newest 112 finished conversation, run through the platform's secret-shape 113 table and cut — so the round's minted flag and the untrusted 114 markup do not travel; 115 * a row exists only for an entrant whose **author** opted in 116 (``POST /eval/runs/{run_id}/wall``), which is the answer to 117 the one rule no scrubbing dissolves: the conversation belongs 118 to the person who ran it. 119 120 ⚠️ **An empty ``rows`` is four different things and the three 121 numbers beside it say which**: ``indexing: false`` is a 122 deployment with no transcript index, ``entrants: 0`` is nobody 123 having entered an agent, ``published: 0`` is nobody's author 124 having agreed, and all three set with no rows is everybody 125 having agreed and no call having finished yet. 126 127 ⚠️ **At most ``max_rows`` entrants are read**, because each one 128 costs an archived-transcript read; the reply publishes that 129 ceiling beside ``published`` so a truncated answer cannot read 130 as a complete one. 131 132 Refused (403 ``silent_standings``) on a silent competition, for 133 :meth:`agent_pulse`'s reason. 134 135 Wraps ``GET /competitions/{competition_id}/agent-actions``.""" 136 resp = self._http.request("GET", f"/competitions/{competition_id}/agent-actions") 137 _raise_for_status(resp) 138 return AgentActionsPage.model_validate(resp.json()) 139 140 def agent_pulse(self, competition_id: str, *, limit: int = 200) -> AgentPulsePage: 141 """What this event's agents have been asking a model, without it. 142 143 One lane per entrant, one beat per **finished** model call. The 144 gateway composes a record once, at completion — streamed 145 completions included — so no caller of this can see a 146 conversation in flight, whatever cadence they poll at. 147 148 ⚠️ **Carries no prompt and no completion, and cannot be made 149 to.** A relayed conversation holds the challenge's own minted 150 answer, which on an AWD gamebox is the round's flag, and its 151 prompt half is written by whoever is attacking the challenge — 152 so what travels is the shape of the traffic: how much each call 153 wrote, how long it took, and what went wrong. The bodies live 154 behind ``/admin/llm-calls/{id}`` and the run's own author-scoped 155 route, both of which rule on who may read them. 156 157 Empty means one of two opposite things and the reply says 158 which: ``indexing: false`` is a deployment that keeps no 159 transcript index at all. 160 161 Refused (403 ``silent_standings``) on a silent competition, like 162 the board — a lane per entrant saying who is still working is 163 the field's progress. 164 """ 165 resp = self._http.request( 166 "GET", 167 f"/competitions/{competition_id}/agent-pulse", 168 params={"limit": limit}, 169 ) 170 _raise_for_status(resp) 171 return AgentPulsePage.model_validate(resp.json())
25class CompetitionsResource: 26 """Browse competitions. To act within one, use ``client.competition(id)``.""" 27 28 def __init__(self, http: BaseHttpClient) -> None: 29 self._http = http 30 31 def list(self, phase: str = "", offset: int = 0, limit: int = 50) -> PagedList[CompetitionInfo]: 32 """Public competition list. ``phase`` ∈ ``"" | "upcoming" | 33 "running" | "past"`` ("" = all). Start here, then scope into one 34 with ``client.competition(comp.id)``.""" 35 params: dict[str, Any] = {"offset": offset, "limit": limit} 36 if phase: 37 params["phase"] = phase 38 resp = self._http.request("GET", "/competitions", params=params) 39 _raise_for_status(resp) 40 return _extract_items(resp.json(), CompetitionInfo) 41 42 def get(self, competition_id: str) -> CompetitionDetail: 43 """Full competition detail including resolved challenge summaries 44 (``.challenges``). Same as ``client.competition(id).detail()``.""" 45 resp = self._http.request("GET", f"/competitions/{competition_id}") 46 _raise_for_status(resp) 47 return CompetitionDetail.model_validate(resp.json()) 48 49 def attachments(self, competition_id: str) -> CompetitionAttachmentsResponse: 50 """The competition's rules / handbook / notice files, in display 51 order and every language's (``locale`` ``""`` is for everyone). 52 Read these before playing — they are the rules as the organiser 53 published them. Fetch one with :meth:`download_attachment`.""" 54 resp = self._http.request("GET", f"/competitions/{competition_id}/attachments") 55 _raise_for_status(resp) 56 return CompetitionAttachmentsResponse.model_validate(resp.json()) 57 58 def download_attachment(self, competition_id: str, attachment_id: str) -> bytes: 59 """One competition file's raw bytes (id from :meth:`attachments`).""" 60 resp = self._http.request( 61 "GET", f"/competitions/{competition_id}/attachments/{attachment_id}" 62 ) 63 _raise_for_status(resp) 64 return resp.content 65 66 def start_virtual(self, competition_id: str) -> CompetitionInfo: 67 """Replay a finished contest on your own clock. 68 69 Returns the *replay*, which is an ordinary competition you are 70 already registered in — so every other call takes its 71 ``.id`` unchanged: launch instances, submit answers, submit 72 patches, read its board. 73 74 **Idempotent.** Asking twice returns the replay you already 75 started rather than minting a second sitting, so a retry cannot 76 split one attempt across two competitions. 77 78 Refused (400) when a replay is not defined: the source has not 79 finished, has no window, is multi-session, never ran, or is 80 itself a replay. The error code says which. 81 """ 82 resp = self._http.request("POST", f"/competitions/{competition_id}/virtual") 83 _raise_for_status(resp) 84 return CompetitionInfo.model_validate(resp.json()) 85 86 def ghosts(self, competition_id: str) -> VirtualGhostBoard: 87 """The source contest's standings at the instant this replay has 88 reached, with you merged in and ranked among them. 89 90 Takes the **replay's** id, not the source's. At T+40 minutes 91 into your run you see the contest as it stood at T+40 minutes on 92 the day — which is what makes a replay a race rather than an 93 exercise, since a board holding only your own solves answers 94 nothing. 95 96 Refused (400 ``not_a_virtual_sitting``) on an ordinary 97 competition. A source that has since been deleted costs the 98 ghosts and nothing else: the reply comes back with no items and 99 the run itself keeps working. 100 """ 101 resp = self._http.request("GET", f"/competitions/{competition_id}/ghosts") 102 _raise_for_status(resp) 103 return VirtualGhostBoard.model_validate(resp.json()) 104 105 def agent_actions(self, competition_id: str) -> AgentActionsPage: 106 """One scrubbed line per agent saying what it is doing now. 107 108 :meth:`agent_pulse`'s sibling, and the whole difference is that 109 this one carries content. What makes that publishable is two 110 gates, neither of which stands in for the other: 111 112 * the line is a single tool call picked out of the newest 113 finished conversation, run through the platform's secret-shape 114 table and cut — so the round's minted flag and the untrusted 115 markup do not travel; 116 * a row exists only for an entrant whose **author** opted in 117 (``POST /eval/runs/{run_id}/wall``), which is the answer to 118 the one rule no scrubbing dissolves: the conversation belongs 119 to the person who ran it. 120 121 ⚠️ **An empty ``rows`` is four different things and the three 122 numbers beside it say which**: ``indexing: false`` is a 123 deployment with no transcript index, ``entrants: 0`` is nobody 124 having entered an agent, ``published: 0`` is nobody's author 125 having agreed, and all three set with no rows is everybody 126 having agreed and no call having finished yet. 127 128 ⚠️ **At most ``max_rows`` entrants are read**, because each one 129 costs an archived-transcript read; the reply publishes that 130 ceiling beside ``published`` so a truncated answer cannot read 131 as a complete one. 132 133 Refused (403 ``silent_standings``) on a silent competition, for 134 :meth:`agent_pulse`'s reason. 135 136 Wraps ``GET /competitions/{competition_id}/agent-actions``.""" 137 resp = self._http.request("GET", f"/competitions/{competition_id}/agent-actions") 138 _raise_for_status(resp) 139 return AgentActionsPage.model_validate(resp.json()) 140 141 def agent_pulse(self, competition_id: str, *, limit: int = 200) -> AgentPulsePage: 142 """What this event's agents have been asking a model, without it. 143 144 One lane per entrant, one beat per **finished** model call. The 145 gateway composes a record once, at completion — streamed 146 completions included — so no caller of this can see a 147 conversation in flight, whatever cadence they poll at. 148 149 ⚠️ **Carries no prompt and no completion, and cannot be made 150 to.** A relayed conversation holds the challenge's own minted 151 answer, which on an AWD gamebox is the round's flag, and its 152 prompt half is written by whoever is attacking the challenge — 153 so what travels is the shape of the traffic: how much each call 154 wrote, how long it took, and what went wrong. The bodies live 155 behind ``/admin/llm-calls/{id}`` and the run's own author-scoped 156 route, both of which rule on who may read them. 157 158 Empty means one of two opposite things and the reply says 159 which: ``indexing: false`` is a deployment that keeps no 160 transcript index at all. 161 162 Refused (403 ``silent_standings``) on a silent competition, like 163 the board — a lane per entrant saying who is still working is 164 the field's progress. 165 """ 166 resp = self._http.request( 167 "GET", 168 f"/competitions/{competition_id}/agent-pulse", 169 params={"limit": limit}, 170 ) 171 _raise_for_status(resp) 172 return AgentPulsePage.model_validate(resp.json())
Browse competitions. To act within one, use client.competition(id).
31 def list(self, phase: str = "", offset: int = 0, limit: int = 50) -> PagedList[CompetitionInfo]: 32 """Public competition list. ``phase`` ∈ ``"" | "upcoming" | 33 "running" | "past"`` ("" = all). Start here, then scope into one 34 with ``client.competition(comp.id)``.""" 35 params: dict[str, Any] = {"offset": offset, "limit": limit} 36 if phase: 37 params["phase"] = phase 38 resp = self._http.request("GET", "/competitions", params=params) 39 _raise_for_status(resp) 40 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).
42 def get(self, competition_id: str) -> CompetitionDetail: 43 """Full competition detail including resolved challenge summaries 44 (``.challenges``). Same as ``client.competition(id).detail()``.""" 45 resp = self._http.request("GET", f"/competitions/{competition_id}") 46 _raise_for_status(resp) 47 return CompetitionDetail.model_validate(resp.json())
Full competition detail including resolved challenge summaries
(ctfy.sdk.resources.challenges). Same as client.competition(id).detail().
49 def attachments(self, competition_id: str) -> CompetitionAttachmentsResponse: 50 """The competition's rules / handbook / notice files, in display 51 order and every language's (``locale`` ``""`` is for everyone). 52 Read these before playing — they are the rules as the organiser 53 published them. Fetch one with :meth:`download_attachment`.""" 54 resp = self._http.request("GET", f"/competitions/{competition_id}/attachments") 55 _raise_for_status(resp) 56 return CompetitionAttachmentsResponse.model_validate(resp.json())
The competition's rules / handbook / notice files, in display
order and every language's (locale "" is for everyone).
Read these before playing — they are the rules as the organiser
published them. Fetch one with download_attachment().
58 def download_attachment(self, competition_id: str, attachment_id: str) -> bytes: 59 """One competition file's raw bytes (id from :meth:`attachments`).""" 60 resp = self._http.request( 61 "GET", f"/competitions/{competition_id}/attachments/{attachment_id}" 62 ) 63 _raise_for_status(resp) 64 return resp.content
One competition file's raw bytes (id from attachments()).
66 def start_virtual(self, competition_id: str) -> CompetitionInfo: 67 """Replay a finished contest on your own clock. 68 69 Returns the *replay*, which is an ordinary competition you are 70 already registered in — so every other call takes its 71 ``.id`` unchanged: launch instances, submit answers, submit 72 patches, read its board. 73 74 **Idempotent.** Asking twice returns the replay you already 75 started rather than minting a second sitting, so a retry cannot 76 split one attempt across two competitions. 77 78 Refused (400) when a replay is not defined: the source has not 79 finished, has no window, is multi-session, never ran, or is 80 itself a replay. The error code says which. 81 """ 82 resp = self._http.request("POST", f"/competitions/{competition_id}/virtual") 83 _raise_for_status(resp) 84 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.
86 def ghosts(self, competition_id: str) -> VirtualGhostBoard: 87 """The source contest's standings at the instant this replay has 88 reached, with you merged in and ranked among them. 89 90 Takes the **replay's** id, not the source's. At T+40 minutes 91 into your run you see the contest as it stood at T+40 minutes on 92 the day — which is what makes a replay a race rather than an 93 exercise, since a board holding only your own solves answers 94 nothing. 95 96 Refused (400 ``not_a_virtual_sitting``) on an ordinary 97 competition. A source that has since been deleted costs the 98 ghosts and nothing else: the reply comes back with no items and 99 the run itself keeps working. 100 """ 101 resp = self._http.request("GET", f"/competitions/{competition_id}/ghosts") 102 _raise_for_status(resp) 103 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.
105 def agent_actions(self, competition_id: str) -> AgentActionsPage: 106 """One scrubbed line per agent saying what it is doing now. 107 108 :meth:`agent_pulse`'s sibling, and the whole difference is that 109 this one carries content. What makes that publishable is two 110 gates, neither of which stands in for the other: 111 112 * the line is a single tool call picked out of the newest 113 finished conversation, run through the platform's secret-shape 114 table and cut — so the round's minted flag and the untrusted 115 markup do not travel; 116 * a row exists only for an entrant whose **author** opted in 117 (``POST /eval/runs/{run_id}/wall``), which is the answer to 118 the one rule no scrubbing dissolves: the conversation belongs 119 to the person who ran it. 120 121 ⚠️ **An empty ``rows`` is four different things and the three 122 numbers beside it say which**: ``indexing: false`` is a 123 deployment with no transcript index, ``entrants: 0`` is nobody 124 having entered an agent, ``published: 0`` is nobody's author 125 having agreed, and all three set with no rows is everybody 126 having agreed and no call having finished yet. 127 128 ⚠️ **At most ``max_rows`` entrants are read**, because each one 129 costs an archived-transcript read; the reply publishes that 130 ceiling beside ``published`` so a truncated answer cannot read 131 as a complete one. 132 133 Refused (403 ``silent_standings``) on a silent competition, for 134 :meth:`agent_pulse`'s reason. 135 136 Wraps ``GET /competitions/{competition_id}/agent-actions``.""" 137 resp = self._http.request("GET", f"/competitions/{competition_id}/agent-actions") 138 _raise_for_status(resp) 139 return AgentActionsPage.model_validate(resp.json())
One scrubbed line per agent saying what it is doing now.
agent_pulse()'s sibling, and the whole difference is that
this one carries content. What makes that publishable is two
gates, neither of which stands in for the other:
- the line is a single tool call picked out of the newest finished conversation, run through the platform's secret-shape table and cut — so the round's minted flag and the untrusted markup do not travel;
- a row exists only for an entrant whose author opted in
(
POST /eval/runs/{run_id}/wall), which is the answer to the one rule no scrubbing dissolves: the conversation belongs to the person who ran it.
⚠️ An empty rows is four different things and the three
numbers beside it say which: indexing: false is a
deployment with no transcript index, entrants: 0 is nobody
having entered an agent, published: 0 is nobody's author
having agreed, and all three set with no rows is everybody
having agreed and no call having finished yet.
⚠️ At most max_rows entrants are read, because each one
costs an archived-transcript read; the reply publishes that
ceiling beside published so a truncated answer cannot read
as a complete one.
Refused (403 silent_standings) on a silent competition, for
agent_pulse()'s reason.
Wraps GET /competitions/{competition_id}/agent-actions.
141 def agent_pulse(self, competition_id: str, *, limit: int = 200) -> AgentPulsePage: 142 """What this event's agents have been asking a model, without it. 143 144 One lane per entrant, one beat per **finished** model call. The 145 gateway composes a record once, at completion — streamed 146 completions included — so no caller of this can see a 147 conversation in flight, whatever cadence they poll at. 148 149 ⚠️ **Carries no prompt and no completion, and cannot be made 150 to.** A relayed conversation holds the challenge's own minted 151 answer, which on an AWD gamebox is the round's flag, and its 152 prompt half is written by whoever is attacking the challenge — 153 so what travels is the shape of the traffic: how much each call 154 wrote, how long it took, and what went wrong. The bodies live 155 behind ``/admin/llm-calls/{id}`` and the run's own author-scoped 156 route, both of which rule on who may read them. 157 158 Empty means one of two opposite things and the reply says 159 which: ``indexing: false`` is a deployment that keeps no 160 transcript index at all. 161 162 Refused (403 ``silent_standings``) on a silent competition, like 163 the board — a lane per entrant saying who is still working is 164 the field's progress. 165 """ 166 resp = self._http.request( 167 "GET", 168 f"/competitions/{competition_id}/agent-pulse", 169 params={"limit": limit}, 170 ) 171 _raise_for_status(resp) 172 return AgentPulsePage.model_validate(resp.json())
What this event's agents have been asking a model, without it.
One lane per entrant, one beat per finished model call. The gateway composes a record once, at completion — streamed completions included — so no caller of this can see a conversation in flight, whatever cadence they poll at.
⚠️ Carries no prompt and no completion, and cannot be made
to. A relayed conversation holds the challenge's own minted
answer, which on an AWD gamebox is the round's flag, and its
prompt half is written by whoever is attacking the challenge —
so what travels is the shape of the traffic: how much each call
wrote, how long it took, and what went wrong. The bodies live
behind /admin/llm-calls/{id} and the run's own author-scoped
route, both of which rule on who may read them.
Empty means one of two opposite things and the reply says
which: indexing: false is a deployment that keeps no
transcript index at all.
Refused (403 silent_standings) on a silent competition, like
the board — a lane per entrant saying who is still working is
the field's progress.