ctfy.sdk.resources.authoring
client.authoring — uploading a challenge you wrote.
For the competition format where entrants set challenges for each other.
The upload is a .tar.gz of one benchmark directory, exactly the
shape tar czf mine.tgz mychallenge/ produces — the wrapping
directory is stripped server-side, so an author never has to know
whether to include it.
⚠️ The server records the upload pending and does not judge it: a
verdict means running the audit and a smoke test on a worker, which
takes minutes. review_status on the returned row is what to poll.
1"""``client.authoring`` — uploading a challenge you wrote. 2 3For the competition format where entrants set challenges for each other. 4The upload is a ``.tar.gz`` of one benchmark directory, exactly the 5shape ``tar czf mine.tgz mychallenge/`` produces — the wrapping 6directory is stripped server-side, so an author never has to know 7whether to include it. 8 9⚠️ The server records the upload ``pending`` and does not judge it: a 10verdict means running the audit and a smoke test on a worker, which 11takes minutes. ``review_status`` on the returned row is what to poll. 12""" 13 14from __future__ import annotations 15 16from ctfy.challenge.benchmark import ChallengeCategory 17from ctfy.challenge.scaffold import Stack 18from ctfy.core.enums import ( 19 AuthoredAiAssistanceState, 20 AuthoredCredit, 21 AuthoredDestination, 22 AuthoredLicenseState, 23 AuthoredProvenanceState, 24) 25from ctfy.sdk._helpers import _raise_for_status 26from ctfy.sdk.base import BaseHttpClient 27from ctfy.server.models import ( 28 AuthoredAuditReport, 29 AuthoredChallengeInfo, 30 AuthoredChallengeRow, 31 AuthoredRevisionInfo, 32 AuthoringReceptionInfo, 33 AuthoringTemplateManifest, 34) 35 36 37class AuthoringResource: 38 """Upload and list the calling team's authored challenges.""" 39 40 def __init__(self, http: BaseHttpClient) -> None: 41 self._http = http 42 43 def upload( 44 self, 45 competition_id: str, 46 *, 47 slug: str, 48 archive: bytes, 49 filename: str = "challenge.tar.gz", 50 license: AuthoredLicenseState = "", 51 provenance: AuthoredProvenanceState = "", 52 source_url: str = "", 53 ai_assisted: AuthoredAiAssistanceState = "", 54 eval_use: bool = False, 55 credit: AuthoredCredit = "anonymous", 56 accepted_terms_version: str = "", 57 destination: AuthoredDestination = "public", 58 ) -> AuthoredChallengeInfo: 59 """Upload one challenge tree. 60 61 ``slug`` is your own label for the upload, never its id: the 62 platform mints the benchmark id (``AUTHORED-<hex>``) and answers 63 with it on the returned row. The label is normalised and refused 64 rather than sanitised if it cannot be one. 65 66 Re-uploading the same slug replaces the tree **while it is still 67 pending**; once an operator has ruled on it the id is fixed. An 68 approved upload then takes a revision (:meth:`revise`); a rejected 69 one is kept as it is, and a new version goes under a new name. 70 71 The contributor agreement: when the competition names one 72 (``authoring_terms_version`` / ``authoring_terms_url`` on its 73 detail), pass that version as ``accepted_terms_version`` and 74 answer ``license``, ``provenance`` (with ``source_url`` for a 75 ``port``) and ``ai_assisted``. ``eval_use`` and ``credit`` are 76 grants, refused unless given. 77 78 ``destination`` is where it goes once published (plan P3.4): the 79 public catalog, or ``eval_only`` — the evaluation pool, kept out of 80 every public listing and launched only through a competition that 81 is not public and curates it. The pool takes an original challenge 82 (``provenance="original"``) whose author granted ``eval_use``; 83 AI-assisted is allowed, and marked on the evaluation board. 84 85 The account must hold a verified email address, on every 86 deployment: an unproved one is refused 87 ``authoring_requires_verified_email`` before the archive is read. 88 89 ``auto_smoke`` on the returned row says whether the deployment is 90 smoking the upload on an isolated worker (``queued`` — the verdict 91 lands on the listing's ``smoke_verdict``) and, if not, why: 92 ``budget_spent`` carries when the next run is free. ``None`` is a 93 server that did not say, never *not smoked*. 94 95 ⚠️ **Only what you name is sent.** An absent declaration is 96 *nobody said* on the platform, and putting ``license=`` or 97 ``eval_use=false`` on the wire for a caller that chose neither 98 would send a value nobody picked. 99 """ 100 declared: dict[str, str] = { 101 name: value 102 for name, value in ( 103 ("license", license), 104 ("provenance", provenance), 105 ("source_url", source_url), 106 ("ai_assisted", ai_assisted), 107 ("accepted_terms_version", accepted_terms_version), 108 ) 109 if value 110 } 111 if eval_use: 112 declared["eval_use"] = "true" 113 if credit != "anonymous": 114 declared["credit"] = credit 115 if destination != "public": 116 declared["destination"] = destination 117 resp = self._http.request( 118 "POST", 119 f"/competitions/{competition_id}/authored-challenges", 120 data={"slug": slug, **declared}, 121 files={"file": (filename, archive, "application/gzip")}, 122 ) 123 _raise_for_status(resp) 124 return AuthoredChallengeInfo.model_validate(resp.json()) 125 126 def revise( 127 self, 128 competition_id: str, 129 challenge_id: str, 130 *, 131 archive: bytes, 132 filename: str = "challenge.tar.gz", 133 ) -> AuthoredRevisionInfo: 134 """Propose a new tree for one of your **approved** uploads (plan P3.1). 135 136 Any of the upload's recorded authors may. The revision is reviewed 137 as a diff against the live version and a platform admin applies 138 it; until then players keep the live one, and boxes already 139 running keep it until they expire. A second proposal replaces the 140 first, whatever its verdict — the listing's ``revision`` says 141 where the current one stands. 142 143 Refused ``authored_revision_drops_questions`` when the tree drops a 144 question the live version has (solves are recorded against them), 145 ``authored_revision_unchanged`` for an identical tree, and 146 ``authored_challenge_not_revisable`` for an upload that is not 147 approved. The event's authoring window does not apply: a revision 148 maintains a challenge that outlives its event. 149 """ 150 resp = self._http.request( 151 "POST", 152 f"/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions", 153 files={"file": (filename, archive, "application/gzip")}, 154 ) 155 _raise_for_status(resp) 156 return AuthoredRevisionInfo.model_validate(resp.json()) 157 158 def template( 159 self, 160 *, 161 category: ChallengeCategory = ChallengeCategory.WEB, 162 stack: Stack | None = None, 163 competition_id: str = "", 164 ) -> bytes: 165 """A starter challenge tree as ``.tar.gz`` bytes. 166 167 The same tree ``ctfy authoring init`` writes and the authoring 168 page downloads — all three call the server's own 169 ``scaffold_authored``, so a template fixed on one path cannot be 170 stale on another. 171 172 ⚠️ **Bytes, not a model.** The route answers an archive; parsing 173 it here would mean this library deciding where an author's 174 files land, which is `_pack`'s job in the opposite direction and 175 the caller's here. 176 177 ⚠️ ``stack`` belongs to ``web`` alone — the server refuses the 178 pair on any other category and refuses ``web`` without one — 179 and it is **omitted** rather than sent empty, since the query 180 parameter is an enum and ``""`` is not a member. 181 182 ⚠️ ``competition_id`` is optional because the template contains 183 nothing an event owns, but it is *accepted*: ``authoring`` is a 184 competition-scoped category, so a ``selected`` fine-grained 185 token names its event here as it does on the upload. 186 """ 187 params: dict[str, str] = {"category": category.value} 188 if stack is not None: 189 params["stack"] = stack.value 190 if competition_id: 191 params["competition_id"] = competition_id 192 resp = self._http.request("GET", "/authoring/template", params=params) 193 _raise_for_status(resp) 194 return bytes(resp.content) 195 196 def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport: 197 """What the challenge audit says about one of your uploads. 198 199 A defect list you can fix and re-upload against, without waiting 200 for a reviewer to read the tree and write the same findings out 201 by hand. 202 203 ⚠️ ``outcome`` is the field to read first: ``ok`` means the 204 audit ran and ``errors`` / ``warnings`` are its verdict, while 205 ``timed_out`` and ``failed`` mean it could not say — an empty 206 ``findings`` list is not by itself a clean bill. 207 """ 208 resp = self._http.request( 209 "GET", 210 f"/competitions/{competition_id}/authored-challenges/{challenge_id}/audit", 211 ) 212 _raise_for_status(resp) 213 return AuthoredAuditReport.model_validate(resp.json()) 214 215 def template_manifest( 216 self, 217 *, 218 category: ChallengeCategory = ChallengeCategory.WEB, 219 stack: Stack | None = None, 220 competition_id: str = "", 221 ) -> AuthoringTemplateManifest: 222 """What :meth:`template` would hand over, without downloading it. 223 224 The same tree, described rather than packed — so a caller can 225 show an author the files before spending the transfer, and the 226 two answers cannot disagree because the server builds one tree 227 for both routes. 228 229 ⚠️ The pairing rules are :meth:`template`'s, verbatim, and are 230 not restated here: the server refuses the same shapes with the 231 same message, which is what makes one refusal a menu rather 232 than three. 233 """ 234 params: dict[str, str] = {"category": category.value} 235 if stack is not None: 236 params["stack"] = stack.value 237 if competition_id: 238 params["competition_id"] = competition_id 239 resp = self._http.request("GET", "/authoring/template/manifest", params=params) 240 _raise_for_status(resp) 241 return AuthoringTemplateManifest.model_validate(resp.json()) 242 243 def list(self, competition_id: str) -> list[AuthoredChallengeRow]: 244 """The calling team's own uploads for one competition. 245 246 ⚠️ The team's, never the event's: before the release point a 247 list of everybody's challenge names is a preview of what the 248 field will have to solve. 249 250 ⚠️ **:class:`AuthoredChallengeRow`, never the ``Info`` the 251 upload answers with, and the difference is invisible without 252 this line.** That model adds ``files`` — the tree's paths — with 253 a ``default_factory``, so validating a listing row against it 254 *succeeds* and hands every caller ``files == []``: not a missing 255 field but a **claim that the upload has no files**, which is the 256 one sentence the two-model split exists to stop. Two live 257 readers would have said it — ``ctfy authoring list --json`` 258 emits the key, and the MCP tool hands it to an agent — 259 and `test_sdk_openapi_parity` cannot see it, because that guard 260 asks whether a route is *reached* and never whether the answer 261 is *understood*. 262 """ 263 resp = self._http.request("GET", f"/competitions/{competition_id}/authored-challenges") 264 _raise_for_status(resp) 265 return [AuthoredChallengeRow.model_validate(row) for row in resp.json()] 266 267 def reception(self, competition_id: str) -> AuthoringReceptionInfo: 268 """How this event's field fared against the challenges you wrote. 269 270 The other half of the board's authoring column: that one says 271 *how much*, this says which of your challenges earned it and how 272 close each landed to the band the curve pays for. 273 274 ⚠️ Read ``state`` per row before the numbers. ``priced`` is the 275 only one with a rate; ``not_curated`` means the organiser has 276 not put that challenge in this event's set, and ``no_field`` 277 that your division is too small to produce a rate at all. Both 278 render as a blank cell and only one of them is about the 279 challenge. 280 281 ⚠️ ``scored`` is ``False`` on every competition not running 282 ``peer_authoring``, with no rows — not an error, just an event 283 that does not pay for authoring. 284 """ 285 resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/reception") 286 _raise_for_status(resp) 287 return AuthoringReceptionInfo.model_validate(resp.json())
38class AuthoringResource: 39 """Upload and list the calling team's authored challenges.""" 40 41 def __init__(self, http: BaseHttpClient) -> None: 42 self._http = http 43 44 def upload( 45 self, 46 competition_id: str, 47 *, 48 slug: str, 49 archive: bytes, 50 filename: str = "challenge.tar.gz", 51 license: AuthoredLicenseState = "", 52 provenance: AuthoredProvenanceState = "", 53 source_url: str = "", 54 ai_assisted: AuthoredAiAssistanceState = "", 55 eval_use: bool = False, 56 credit: AuthoredCredit = "anonymous", 57 accepted_terms_version: str = "", 58 destination: AuthoredDestination = "public", 59 ) -> AuthoredChallengeInfo: 60 """Upload one challenge tree. 61 62 ``slug`` is your own label for the upload, never its id: the 63 platform mints the benchmark id (``AUTHORED-<hex>``) and answers 64 with it on the returned row. The label is normalised and refused 65 rather than sanitised if it cannot be one. 66 67 Re-uploading the same slug replaces the tree **while it is still 68 pending**; once an operator has ruled on it the id is fixed. An 69 approved upload then takes a revision (:meth:`revise`); a rejected 70 one is kept as it is, and a new version goes under a new name. 71 72 The contributor agreement: when the competition names one 73 (``authoring_terms_version`` / ``authoring_terms_url`` on its 74 detail), pass that version as ``accepted_terms_version`` and 75 answer ``license``, ``provenance`` (with ``source_url`` for a 76 ``port``) and ``ai_assisted``. ``eval_use`` and ``credit`` are 77 grants, refused unless given. 78 79 ``destination`` is where it goes once published (plan P3.4): the 80 public catalog, or ``eval_only`` — the evaluation pool, kept out of 81 every public listing and launched only through a competition that 82 is not public and curates it. The pool takes an original challenge 83 (``provenance="original"``) whose author granted ``eval_use``; 84 AI-assisted is allowed, and marked on the evaluation board. 85 86 The account must hold a verified email address, on every 87 deployment: an unproved one is refused 88 ``authoring_requires_verified_email`` before the archive is read. 89 90 ``auto_smoke`` on the returned row says whether the deployment is 91 smoking the upload on an isolated worker (``queued`` — the verdict 92 lands on the listing's ``smoke_verdict``) and, if not, why: 93 ``budget_spent`` carries when the next run is free. ``None`` is a 94 server that did not say, never *not smoked*. 95 96 ⚠️ **Only what you name is sent.** An absent declaration is 97 *nobody said* on the platform, and putting ``license=`` or 98 ``eval_use=false`` on the wire for a caller that chose neither 99 would send a value nobody picked. 100 """ 101 declared: dict[str, str] = { 102 name: value 103 for name, value in ( 104 ("license", license), 105 ("provenance", provenance), 106 ("source_url", source_url), 107 ("ai_assisted", ai_assisted), 108 ("accepted_terms_version", accepted_terms_version), 109 ) 110 if value 111 } 112 if eval_use: 113 declared["eval_use"] = "true" 114 if credit != "anonymous": 115 declared["credit"] = credit 116 if destination != "public": 117 declared["destination"] = destination 118 resp = self._http.request( 119 "POST", 120 f"/competitions/{competition_id}/authored-challenges", 121 data={"slug": slug, **declared}, 122 files={"file": (filename, archive, "application/gzip")}, 123 ) 124 _raise_for_status(resp) 125 return AuthoredChallengeInfo.model_validate(resp.json()) 126 127 def revise( 128 self, 129 competition_id: str, 130 challenge_id: str, 131 *, 132 archive: bytes, 133 filename: str = "challenge.tar.gz", 134 ) -> AuthoredRevisionInfo: 135 """Propose a new tree for one of your **approved** uploads (plan P3.1). 136 137 Any of the upload's recorded authors may. The revision is reviewed 138 as a diff against the live version and a platform admin applies 139 it; until then players keep the live one, and boxes already 140 running keep it until they expire. A second proposal replaces the 141 first, whatever its verdict — the listing's ``revision`` says 142 where the current one stands. 143 144 Refused ``authored_revision_drops_questions`` when the tree drops a 145 question the live version has (solves are recorded against them), 146 ``authored_revision_unchanged`` for an identical tree, and 147 ``authored_challenge_not_revisable`` for an upload that is not 148 approved. The event's authoring window does not apply: a revision 149 maintains a challenge that outlives its event. 150 """ 151 resp = self._http.request( 152 "POST", 153 f"/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions", 154 files={"file": (filename, archive, "application/gzip")}, 155 ) 156 _raise_for_status(resp) 157 return AuthoredRevisionInfo.model_validate(resp.json()) 158 159 def template( 160 self, 161 *, 162 category: ChallengeCategory = ChallengeCategory.WEB, 163 stack: Stack | None = None, 164 competition_id: str = "", 165 ) -> bytes: 166 """A starter challenge tree as ``.tar.gz`` bytes. 167 168 The same tree ``ctfy authoring init`` writes and the authoring 169 page downloads — all three call the server's own 170 ``scaffold_authored``, so a template fixed on one path cannot be 171 stale on another. 172 173 ⚠️ **Bytes, not a model.** The route answers an archive; parsing 174 it here would mean this library deciding where an author's 175 files land, which is `_pack`'s job in the opposite direction and 176 the caller's here. 177 178 ⚠️ ``stack`` belongs to ``web`` alone — the server refuses the 179 pair on any other category and refuses ``web`` without one — 180 and it is **omitted** rather than sent empty, since the query 181 parameter is an enum and ``""`` is not a member. 182 183 ⚠️ ``competition_id`` is optional because the template contains 184 nothing an event owns, but it is *accepted*: ``authoring`` is a 185 competition-scoped category, so a ``selected`` fine-grained 186 token names its event here as it does on the upload. 187 """ 188 params: dict[str, str] = {"category": category.value} 189 if stack is not None: 190 params["stack"] = stack.value 191 if competition_id: 192 params["competition_id"] = competition_id 193 resp = self._http.request("GET", "/authoring/template", params=params) 194 _raise_for_status(resp) 195 return bytes(resp.content) 196 197 def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport: 198 """What the challenge audit says about one of your uploads. 199 200 A defect list you can fix and re-upload against, without waiting 201 for a reviewer to read the tree and write the same findings out 202 by hand. 203 204 ⚠️ ``outcome`` is the field to read first: ``ok`` means the 205 audit ran and ``errors`` / ``warnings`` are its verdict, while 206 ``timed_out`` and ``failed`` mean it could not say — an empty 207 ``findings`` list is not by itself a clean bill. 208 """ 209 resp = self._http.request( 210 "GET", 211 f"/competitions/{competition_id}/authored-challenges/{challenge_id}/audit", 212 ) 213 _raise_for_status(resp) 214 return AuthoredAuditReport.model_validate(resp.json()) 215 216 def template_manifest( 217 self, 218 *, 219 category: ChallengeCategory = ChallengeCategory.WEB, 220 stack: Stack | None = None, 221 competition_id: str = "", 222 ) -> AuthoringTemplateManifest: 223 """What :meth:`template` would hand over, without downloading it. 224 225 The same tree, described rather than packed — so a caller can 226 show an author the files before spending the transfer, and the 227 two answers cannot disagree because the server builds one tree 228 for both routes. 229 230 ⚠️ The pairing rules are :meth:`template`'s, verbatim, and are 231 not restated here: the server refuses the same shapes with the 232 same message, which is what makes one refusal a menu rather 233 than three. 234 """ 235 params: dict[str, str] = {"category": category.value} 236 if stack is not None: 237 params["stack"] = stack.value 238 if competition_id: 239 params["competition_id"] = competition_id 240 resp = self._http.request("GET", "/authoring/template/manifest", params=params) 241 _raise_for_status(resp) 242 return AuthoringTemplateManifest.model_validate(resp.json()) 243 244 def list(self, competition_id: str) -> list[AuthoredChallengeRow]: 245 """The calling team's own uploads for one competition. 246 247 ⚠️ The team's, never the event's: before the release point a 248 list of everybody's challenge names is a preview of what the 249 field will have to solve. 250 251 ⚠️ **:class:`AuthoredChallengeRow`, never the ``Info`` the 252 upload answers with, and the difference is invisible without 253 this line.** That model adds ``files`` — the tree's paths — with 254 a ``default_factory``, so validating a listing row against it 255 *succeeds* and hands every caller ``files == []``: not a missing 256 field but a **claim that the upload has no files**, which is the 257 one sentence the two-model split exists to stop. Two live 258 readers would have said it — ``ctfy authoring list --json`` 259 emits the key, and the MCP tool hands it to an agent — 260 and `test_sdk_openapi_parity` cannot see it, because that guard 261 asks whether a route is *reached* and never whether the answer 262 is *understood*. 263 """ 264 resp = self._http.request("GET", f"/competitions/{competition_id}/authored-challenges") 265 _raise_for_status(resp) 266 return [AuthoredChallengeRow.model_validate(row) for row in resp.json()] 267 268 def reception(self, competition_id: str) -> AuthoringReceptionInfo: 269 """How this event's field fared against the challenges you wrote. 270 271 The other half of the board's authoring column: that one says 272 *how much*, this says which of your challenges earned it and how 273 close each landed to the band the curve pays for. 274 275 ⚠️ Read ``state`` per row before the numbers. ``priced`` is the 276 only one with a rate; ``not_curated`` means the organiser has 277 not put that challenge in this event's set, and ``no_field`` 278 that your division is too small to produce a rate at all. Both 279 render as a blank cell and only one of them is about the 280 challenge. 281 282 ⚠️ ``scored`` is ``False`` on every competition not running 283 ``peer_authoring``, with no rows — not an error, just an event 284 that does not pay for authoring. 285 """ 286 resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/reception") 287 _raise_for_status(resp) 288 return AuthoringReceptionInfo.model_validate(resp.json())
Upload and list the calling team's authored challenges.
44 def upload( 45 self, 46 competition_id: str, 47 *, 48 slug: str, 49 archive: bytes, 50 filename: str = "challenge.tar.gz", 51 license: AuthoredLicenseState = "", 52 provenance: AuthoredProvenanceState = "", 53 source_url: str = "", 54 ai_assisted: AuthoredAiAssistanceState = "", 55 eval_use: bool = False, 56 credit: AuthoredCredit = "anonymous", 57 accepted_terms_version: str = "", 58 destination: AuthoredDestination = "public", 59 ) -> AuthoredChallengeInfo: 60 """Upload one challenge tree. 61 62 ``slug`` is your own label for the upload, never its id: the 63 platform mints the benchmark id (``AUTHORED-<hex>``) and answers 64 with it on the returned row. The label is normalised and refused 65 rather than sanitised if it cannot be one. 66 67 Re-uploading the same slug replaces the tree **while it is still 68 pending**; once an operator has ruled on it the id is fixed. An 69 approved upload then takes a revision (:meth:`revise`); a rejected 70 one is kept as it is, and a new version goes under a new name. 71 72 The contributor agreement: when the competition names one 73 (``authoring_terms_version`` / ``authoring_terms_url`` on its 74 detail), pass that version as ``accepted_terms_version`` and 75 answer ``license``, ``provenance`` (with ``source_url`` for a 76 ``port``) and ``ai_assisted``. ``eval_use`` and ``credit`` are 77 grants, refused unless given. 78 79 ``destination`` is where it goes once published (plan P3.4): the 80 public catalog, or ``eval_only`` — the evaluation pool, kept out of 81 every public listing and launched only through a competition that 82 is not public and curates it. The pool takes an original challenge 83 (``provenance="original"``) whose author granted ``eval_use``; 84 AI-assisted is allowed, and marked on the evaluation board. 85 86 The account must hold a verified email address, on every 87 deployment: an unproved one is refused 88 ``authoring_requires_verified_email`` before the archive is read. 89 90 ``auto_smoke`` on the returned row says whether the deployment is 91 smoking the upload on an isolated worker (``queued`` — the verdict 92 lands on the listing's ``smoke_verdict``) and, if not, why: 93 ``budget_spent`` carries when the next run is free. ``None`` is a 94 server that did not say, never *not smoked*. 95 96 ⚠️ **Only what you name is sent.** An absent declaration is 97 *nobody said* on the platform, and putting ``license=`` or 98 ``eval_use=false`` on the wire for a caller that chose neither 99 would send a value nobody picked. 100 """ 101 declared: dict[str, str] = { 102 name: value 103 for name, value in ( 104 ("license", license), 105 ("provenance", provenance), 106 ("source_url", source_url), 107 ("ai_assisted", ai_assisted), 108 ("accepted_terms_version", accepted_terms_version), 109 ) 110 if value 111 } 112 if eval_use: 113 declared["eval_use"] = "true" 114 if credit != "anonymous": 115 declared["credit"] = credit 116 if destination != "public": 117 declared["destination"] = destination 118 resp = self._http.request( 119 "POST", 120 f"/competitions/{competition_id}/authored-challenges", 121 data={"slug": slug, **declared}, 122 files={"file": (filename, archive, "application/gzip")}, 123 ) 124 _raise_for_status(resp) 125 return AuthoredChallengeInfo.model_validate(resp.json())
Upload one challenge tree.
slug is your own label for the upload, never its id: the
platform mints the benchmark id (AUTHORED-<hex>) and answers
with it on the returned row. The label is normalised and refused
rather than sanitised if it cannot be one.
Re-uploading the same slug replaces the tree while it is still
pending; once an operator has ruled on it the id is fixed. An
approved upload then takes a revision (revise()); a rejected
one is kept as it is, and a new version goes under a new name.
The contributor agreement: when the competition names one
(authoring_terms_version / authoring_terms_url on its
detail), pass that version as accepted_terms_version and
answer license, provenance (with source_url for a
port) and ai_assisted. eval_use and credit are
grants, refused unless given.
destination is where it goes once published (plan P3.4): the
public catalog, or eval_only — the evaluation pool, kept out of
every public listing and launched only through a competition that
is not public and curates it. The pool takes an original challenge
(provenance="original") whose author granted eval_use;
AI-assisted is allowed, and marked on the evaluation board.
The account must hold a verified email address, on every
deployment: an unproved one is refused
authoring_requires_verified_email before the archive is read.
auto_smoke on the returned row says whether the deployment is
smoking the upload on an isolated worker (queued — the verdict
lands on the listing's smoke_verdict) and, if not, why:
budget_spent carries when the next run is free. None is a
server that did not say, never not smoked.
⚠️ Only what you name is sent. An absent declaration is
nobody said on the platform, and putting license= or
eval_use=false on the wire for a caller that chose neither
would send a value nobody picked.
127 def revise( 128 self, 129 competition_id: str, 130 challenge_id: str, 131 *, 132 archive: bytes, 133 filename: str = "challenge.tar.gz", 134 ) -> AuthoredRevisionInfo: 135 """Propose a new tree for one of your **approved** uploads (plan P3.1). 136 137 Any of the upload's recorded authors may. The revision is reviewed 138 as a diff against the live version and a platform admin applies 139 it; until then players keep the live one, and boxes already 140 running keep it until they expire. A second proposal replaces the 141 first, whatever its verdict — the listing's ``revision`` says 142 where the current one stands. 143 144 Refused ``authored_revision_drops_questions`` when the tree drops a 145 question the live version has (solves are recorded against them), 146 ``authored_revision_unchanged`` for an identical tree, and 147 ``authored_challenge_not_revisable`` for an upload that is not 148 approved. The event's authoring window does not apply: a revision 149 maintains a challenge that outlives its event. 150 """ 151 resp = self._http.request( 152 "POST", 153 f"/competitions/{competition_id}/authored-challenges/{challenge_id}/revisions", 154 files={"file": (filename, archive, "application/gzip")}, 155 ) 156 _raise_for_status(resp) 157 return AuthoredRevisionInfo.model_validate(resp.json())
Propose a new tree for one of your approved uploads (plan P3.1).
Any of the upload's recorded authors may. The revision is reviewed
as a diff against the live version and a platform admin applies
it; until then players keep the live one, and boxes already
running keep it until they expire. A second proposal replaces the
first, whatever its verdict — the listing's revision says
where the current one stands.
Refused authored_revision_drops_questions when the tree drops a
question the live version has (solves are recorded against them),
authored_revision_unchanged for an identical tree, and
authored_challenge_not_revisable for an upload that is not
approved. The event's authoring window does not apply: a revision
maintains a challenge that outlives its event.
159 def template( 160 self, 161 *, 162 category: ChallengeCategory = ChallengeCategory.WEB, 163 stack: Stack | None = None, 164 competition_id: str = "", 165 ) -> bytes: 166 """A starter challenge tree as ``.tar.gz`` bytes. 167 168 The same tree ``ctfy authoring init`` writes and the authoring 169 page downloads — all three call the server's own 170 ``scaffold_authored``, so a template fixed on one path cannot be 171 stale on another. 172 173 ⚠️ **Bytes, not a model.** The route answers an archive; parsing 174 it here would mean this library deciding where an author's 175 files land, which is `_pack`'s job in the opposite direction and 176 the caller's here. 177 178 ⚠️ ``stack`` belongs to ``web`` alone — the server refuses the 179 pair on any other category and refuses ``web`` without one — 180 and it is **omitted** rather than sent empty, since the query 181 parameter is an enum and ``""`` is not a member. 182 183 ⚠️ ``competition_id`` is optional because the template contains 184 nothing an event owns, but it is *accepted*: ``authoring`` is a 185 competition-scoped category, so a ``selected`` fine-grained 186 token names its event here as it does on the upload. 187 """ 188 params: dict[str, str] = {"category": category.value} 189 if stack is not None: 190 params["stack"] = stack.value 191 if competition_id: 192 params["competition_id"] = competition_id 193 resp = self._http.request("GET", "/authoring/template", params=params) 194 _raise_for_status(resp) 195 return bytes(resp.content)
A starter challenge tree as .tar.gz bytes.
The same tree ctfy authoring init writes and the authoring
page downloads — all three call the server's own
scaffold_authored, so a template fixed on one path cannot be
stale on another.
⚠️ Bytes, not a model. The route answers an archive; parsing
it here would mean this library deciding where an author's
files land, which is _pack's job in the opposite direction and
the caller's here.
⚠️ stack belongs to web alone — the server refuses the
pair on any other category and refuses web without one —
and it is omitted rather than sent empty, since the query
parameter is an enum and "" is not a member.
⚠️ competition_id is optional because the template contains
nothing an event owns, but it is accepted: authoring is a
competition-scoped category, so a selected fine-grained
token names its event here as it does on the upload.
197 def audit(self, competition_id: str, challenge_id: str) -> AuthoredAuditReport: 198 """What the challenge audit says about one of your uploads. 199 200 A defect list you can fix and re-upload against, without waiting 201 for a reviewer to read the tree and write the same findings out 202 by hand. 203 204 ⚠️ ``outcome`` is the field to read first: ``ok`` means the 205 audit ran and ``errors`` / ``warnings`` are its verdict, while 206 ``timed_out`` and ``failed`` mean it could not say — an empty 207 ``findings`` list is not by itself a clean bill. 208 """ 209 resp = self._http.request( 210 "GET", 211 f"/competitions/{competition_id}/authored-challenges/{challenge_id}/audit", 212 ) 213 _raise_for_status(resp) 214 return AuthoredAuditReport.model_validate(resp.json())
What the challenge audit says about one of your uploads.
A defect list you can fix and re-upload against, without waiting for a reviewer to read the tree and write the same findings out by hand.
⚠️ outcome is the field to read first: ok means the
audit ran and errors / warnings are its verdict, while
timed_out and failed mean it could not say — an empty
findings list is not by itself a clean bill.
216 def template_manifest( 217 self, 218 *, 219 category: ChallengeCategory = ChallengeCategory.WEB, 220 stack: Stack | None = None, 221 competition_id: str = "", 222 ) -> AuthoringTemplateManifest: 223 """What :meth:`template` would hand over, without downloading it. 224 225 The same tree, described rather than packed — so a caller can 226 show an author the files before spending the transfer, and the 227 two answers cannot disagree because the server builds one tree 228 for both routes. 229 230 ⚠️ The pairing rules are :meth:`template`'s, verbatim, and are 231 not restated here: the server refuses the same shapes with the 232 same message, which is what makes one refusal a menu rather 233 than three. 234 """ 235 params: dict[str, str] = {"category": category.value} 236 if stack is not None: 237 params["stack"] = stack.value 238 if competition_id: 239 params["competition_id"] = competition_id 240 resp = self._http.request("GET", "/authoring/template/manifest", params=params) 241 _raise_for_status(resp) 242 return AuthoringTemplateManifest.model_validate(resp.json())
What template() would hand over, without downloading it.
The same tree, described rather than packed — so a caller can show an author the files before spending the transfer, and the two answers cannot disagree because the server builds one tree for both routes.
⚠️ The pairing rules are template()'s, verbatim, and are
not restated here: the server refuses the same shapes with the
same message, which is what makes one refusal a menu rather
than three.
244 def list(self, competition_id: str) -> list[AuthoredChallengeRow]: 245 """The calling team's own uploads for one competition. 246 247 ⚠️ The team's, never the event's: before the release point a 248 list of everybody's challenge names is a preview of what the 249 field will have to solve. 250 251 ⚠️ **:class:`AuthoredChallengeRow`, never the ``Info`` the 252 upload answers with, and the difference is invisible without 253 this line.** That model adds ``files`` — the tree's paths — with 254 a ``default_factory``, so validating a listing row against it 255 *succeeds* and hands every caller ``files == []``: not a missing 256 field but a **claim that the upload has no files**, which is the 257 one sentence the two-model split exists to stop. Two live 258 readers would have said it — ``ctfy authoring list --json`` 259 emits the key, and the MCP tool hands it to an agent — 260 and `test_sdk_openapi_parity` cannot see it, because that guard 261 asks whether a route is *reached* and never whether the answer 262 is *understood*. 263 """ 264 resp = self._http.request("GET", f"/competitions/{competition_id}/authored-challenges") 265 _raise_for_status(resp) 266 return [AuthoredChallengeRow.model_validate(row) for row in resp.json()]
The calling team's own uploads for one competition.
⚠️ The team's, never the event's: before the release point a list of everybody's challenge names is a preview of what the field will have to solve.
⚠️ AuthoredChallengeRow, never the Info the
upload answers with, and the difference is invisible without
this line. That model adds files — the tree's paths — with
a default_factory, so validating a listing row against it
succeeds and hands every caller files == []: not a missing
field but a claim that the upload has no files, which is the
one sentence the two-model split exists to stop. Two live
readers would have said it — ctfy authoring list --json
emits the key, and the MCP tool hands it to an agent —
and test_sdk_openapi_parity cannot see it, because that guard
asks whether a route is reached and never whether the answer
is understood.
268 def reception(self, competition_id: str) -> AuthoringReceptionInfo: 269 """How this event's field fared against the challenges you wrote. 270 271 The other half of the board's authoring column: that one says 272 *how much*, this says which of your challenges earned it and how 273 close each landed to the band the curve pays for. 274 275 ⚠️ Read ``state`` per row before the numbers. ``priced`` is the 276 only one with a rate; ``not_curated`` means the organiser has 277 not put that challenge in this event's set, and ``no_field`` 278 that your division is too small to produce a rate at all. Both 279 render as a blank cell and only one of them is about the 280 challenge. 281 282 ⚠️ ``scored`` is ``False`` on every competition not running 283 ``peer_authoring``, with no rows — not an error, just an event 284 that does not pay for authoring. 285 """ 286 resp = self._http.request("GET", f"/competitions/{competition_id}/authoring/reception") 287 _raise_for_status(resp) 288 return AuthoringReceptionInfo.model_validate(resp.json())
How this event's field fared against the challenges you wrote.
The other half of the board's authoring column: that one says how much, this says which of your challenges earned it and how close each landed to the band the curve pays for.
⚠️ Read state per row before the numbers. priced is the
only one with a rate; not_curated means the organiser has
not put that challenge in this event's set, and no_field
that your division is too small to produce a rate at all. Both
render as a blank cell and only one of them is about the
challenge.
⚠️ scored is False on every competition not running
peer_authoring, with no rows — not an error, just an event
that does not pay for authoring.