ctfy.sdk.resources.challenges
client.challenges — global catalog, attachments, facets, feedback chips.
1"""``client.challenges`` — global catalog, attachments, facets, feedback chips.""" 2 3from __future__ import annotations 4 5from typing import Any 6 7from ctfy.core.attachment_layout import attachment_url_path 8from ctfy.sdk._helpers import PagedList, _extract_items, _raise_for_status 9from ctfy.sdk.base import BaseHttpClient 10from ctfy.server.models import ( 11 AttachmentList, 12 ChallengeFacets, 13 ChallengeInfo, 14 ChallengeSolveAttemptsResponse, 15 FeedbackStats, 16 MyReactionsResponse, 17) 18 19 20class ChallengesResource: 21 """Global challenge catalog + per-challenge attachments and solve feedback.""" 22 23 def __init__(self, http: BaseHttpClient) -> None: 24 self._http = http 25 26 def list( 27 self, 28 difficulty: str | None = None, 29 tag: str = "", 30 category: str = "", 31 q: str = "", 32 offset: int = 0, 33 limit: int = 50, 34 ) -> PagedList[ChallengeInfo]: 35 params: dict[str, Any] = {"offset": offset, "limit": limit} 36 if difficulty: 37 params["difficulty"] = difficulty 38 if tag: 39 params["tag"] = tag 40 if category: 41 params["category"] = category 42 if q: 43 params["q"] = q 44 resp = self._http.request("GET", "/challenges", params=params) 45 _raise_for_status(resp) 46 return _extract_items(resp.json(), ChallengeInfo) 47 48 def get(self, challenge_id: str, *, competition_id: str = "") -> ChallengeInfo: 49 """One challenge's catalog row. 50 51 ``competition_id`` scopes the tallies to that competition's 52 teams; without it they span every event on the platform. 53 """ 54 params: dict[str, Any] = {} 55 if competition_id: 56 params["competition_id"] = competition_id 57 resp = self._http.request("GET", f"/challenges/{challenge_id}", params=params) 58 _raise_for_status(resp) 59 return ChallengeInfo.model_validate(resp.json()) 60 61 def attachments(self, challenge_id: str) -> AttachmentList: 62 """List the files shipped under the challenge's ``attachments/`` dir. 63 64 Same data as :attr:`ChallengeInfo.attachments` but addressable 65 directly. Empty list when the challenge ships nothing. 66 """ 67 resp = self._http.request("GET", f"/challenges/{challenge_id}/attachments") 68 _raise_for_status(resp) 69 return AttachmentList.model_validate(resp.json()) 70 71 def download_attachment(self, challenge_id: str, filename: str) -> bytes: 72 """Download one attachment, returning the raw bytes. 73 74 ``filename`` is the relative path under the challenge's 75 ``attachments/`` directory (the name from 76 :meth:`attachments`). Streaming for very large files lives on 77 the underlying httpx client; this wrapper materialises the 78 whole body so the common case stays simple. 79 """ 80 resp = self._http.request( 81 "GET", f"/challenges/{challenge_id}/attachments/{attachment_url_path(filename)}" 82 ) 83 _raise_for_status(resp) 84 return resp.content 85 86 def solve_attempts( 87 self, 88 challenge_id: str, 89 *, 90 include_unsolved: bool = False, 91 limit: int = 500, 92 competition_id: str = "", 93 ) -> ChallengeSolveAttemptsResponse: 94 """Per-team archived-instance breakdown for one challenge 95 (attempts, solve time, solved flag count). 96 97 ``competition_id`` restricts the rows to that competition's 98 teams; without it the breakdown spans every event the challenge 99 has ever appeared in. 100 """ 101 params: dict[str, Any] = {"include_unsolved": include_unsolved, "limit": limit} 102 if competition_id: 103 params["competition_id"] = competition_id 104 resp = self._http.request( 105 "GET", 106 f"/challenges/{challenge_id}/solve-attempts", 107 params=params, 108 ) 109 _raise_for_status(resp) 110 return ChallengeSolveAttemptsResponse.model_validate(resp.json()) 111 112 def facets(self, *, competition_id: str = "") -> ChallengeFacets: 113 """Available difficulty / tag facets (with counts) for the 114 challenge browser. Scope to one competition's catalog by 115 passing ``competition_id``.""" 116 params = {"competition_id": competition_id} if competition_id else {} 117 resp = self._http.request("GET", "/challenges/facets", params=params) 118 _raise_for_status(resp) 119 return ChallengeFacets.model_validate(resp.json()) 120 121 def feedback_stats(self, challenge_id: str) -> FeedbackStats: 122 """Dense per-reaction count aggregate for one challenge.""" 123 resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/stats") 124 _raise_for_status(resp) 125 return FeedbackStats.model_validate(resp.json()) 126 127 def my_reactions(self, challenge_id: str) -> MyReactionsResponse: 128 """The calling user's active reactions on a challenge (empty if unset).""" 129 resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/me") 130 _raise_for_status(resp) 131 return MyReactionsResponse.model_validate(resp.json()) 132 133 def add_reaction(self, challenge_id: str, reaction: str) -> MyReactionsResponse: 134 """Add one reaction chip to the user's set for this challenge. 135 136 Multi-select: stacks alongside any other reactions the user 137 already has. Idempotent — calling twice with the same triple 138 is a no-op. ``reaction`` must be one of the nine values 139 declared in :data:`ctfy.core.state.models.REACTIONS`. 140 141 Returns the user's full active reaction set after the write. 142 """ 143 resp = self._http.request("PUT", f"/challenges/{challenge_id}/feedback/{reaction}") 144 _raise_for_status(resp) 145 return MyReactionsResponse.model_validate(resp.json()) 146 147 def remove_reaction(self, challenge_id: str, reaction: str) -> None: 148 """Remove one reaction chip from the user's set (204, idempotent).""" 149 resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback/{reaction}") 150 _raise_for_status(resp) 151 152 def clear_reactions(self, challenge_id: str) -> None: 153 """Clear every active reaction the user has on this challenge (204).""" 154 resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback") 155 _raise_for_status(resp)
21class ChallengesResource: 22 """Global challenge catalog + per-challenge attachments and solve feedback.""" 23 24 def __init__(self, http: BaseHttpClient) -> None: 25 self._http = http 26 27 def list( 28 self, 29 difficulty: str | None = None, 30 tag: str = "", 31 category: str = "", 32 q: str = "", 33 offset: int = 0, 34 limit: int = 50, 35 ) -> PagedList[ChallengeInfo]: 36 params: dict[str, Any] = {"offset": offset, "limit": limit} 37 if difficulty: 38 params["difficulty"] = difficulty 39 if tag: 40 params["tag"] = tag 41 if category: 42 params["category"] = category 43 if q: 44 params["q"] = q 45 resp = self._http.request("GET", "/challenges", params=params) 46 _raise_for_status(resp) 47 return _extract_items(resp.json(), ChallengeInfo) 48 49 def get(self, challenge_id: str, *, competition_id: str = "") -> ChallengeInfo: 50 """One challenge's catalog row. 51 52 ``competition_id`` scopes the tallies to that competition's 53 teams; without it they span every event on the platform. 54 """ 55 params: dict[str, Any] = {} 56 if competition_id: 57 params["competition_id"] = competition_id 58 resp = self._http.request("GET", f"/challenges/{challenge_id}", params=params) 59 _raise_for_status(resp) 60 return ChallengeInfo.model_validate(resp.json()) 61 62 def attachments(self, challenge_id: str) -> AttachmentList: 63 """List the files shipped under the challenge's ``attachments/`` dir. 64 65 Same data as :attr:`ChallengeInfo.attachments` but addressable 66 directly. Empty list when the challenge ships nothing. 67 """ 68 resp = self._http.request("GET", f"/challenges/{challenge_id}/attachments") 69 _raise_for_status(resp) 70 return AttachmentList.model_validate(resp.json()) 71 72 def download_attachment(self, challenge_id: str, filename: str) -> bytes: 73 """Download one attachment, returning the raw bytes. 74 75 ``filename`` is the relative path under the challenge's 76 ``attachments/`` directory (the name from 77 :meth:`attachments`). Streaming for very large files lives on 78 the underlying httpx client; this wrapper materialises the 79 whole body so the common case stays simple. 80 """ 81 resp = self._http.request( 82 "GET", f"/challenges/{challenge_id}/attachments/{attachment_url_path(filename)}" 83 ) 84 _raise_for_status(resp) 85 return resp.content 86 87 def solve_attempts( 88 self, 89 challenge_id: str, 90 *, 91 include_unsolved: bool = False, 92 limit: int = 500, 93 competition_id: str = "", 94 ) -> ChallengeSolveAttemptsResponse: 95 """Per-team archived-instance breakdown for one challenge 96 (attempts, solve time, solved flag count). 97 98 ``competition_id`` restricts the rows to that competition's 99 teams; without it the breakdown spans every event the challenge 100 has ever appeared in. 101 """ 102 params: dict[str, Any] = {"include_unsolved": include_unsolved, "limit": limit} 103 if competition_id: 104 params["competition_id"] = competition_id 105 resp = self._http.request( 106 "GET", 107 f"/challenges/{challenge_id}/solve-attempts", 108 params=params, 109 ) 110 _raise_for_status(resp) 111 return ChallengeSolveAttemptsResponse.model_validate(resp.json()) 112 113 def facets(self, *, competition_id: str = "") -> ChallengeFacets: 114 """Available difficulty / tag facets (with counts) for the 115 challenge browser. Scope to one competition's catalog by 116 passing ``competition_id``.""" 117 params = {"competition_id": competition_id} if competition_id else {} 118 resp = self._http.request("GET", "/challenges/facets", params=params) 119 _raise_for_status(resp) 120 return ChallengeFacets.model_validate(resp.json()) 121 122 def feedback_stats(self, challenge_id: str) -> FeedbackStats: 123 """Dense per-reaction count aggregate for one challenge.""" 124 resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/stats") 125 _raise_for_status(resp) 126 return FeedbackStats.model_validate(resp.json()) 127 128 def my_reactions(self, challenge_id: str) -> MyReactionsResponse: 129 """The calling user's active reactions on a challenge (empty if unset).""" 130 resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/me") 131 _raise_for_status(resp) 132 return MyReactionsResponse.model_validate(resp.json()) 133 134 def add_reaction(self, challenge_id: str, reaction: str) -> MyReactionsResponse: 135 """Add one reaction chip to the user's set for this challenge. 136 137 Multi-select: stacks alongside any other reactions the user 138 already has. Idempotent — calling twice with the same triple 139 is a no-op. ``reaction`` must be one of the nine values 140 declared in :data:`ctfy.core.state.models.REACTIONS`. 141 142 Returns the user's full active reaction set after the write. 143 """ 144 resp = self._http.request("PUT", f"/challenges/{challenge_id}/feedback/{reaction}") 145 _raise_for_status(resp) 146 return MyReactionsResponse.model_validate(resp.json()) 147 148 def remove_reaction(self, challenge_id: str, reaction: str) -> None: 149 """Remove one reaction chip from the user's set (204, idempotent).""" 150 resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback/{reaction}") 151 _raise_for_status(resp) 152 153 def clear_reactions(self, challenge_id: str) -> None: 154 """Clear every active reaction the user has on this challenge (204).""" 155 resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback") 156 _raise_for_status(resp)
Global challenge catalog + per-challenge attachments and solve feedback.
27 def list( 28 self, 29 difficulty: str | None = None, 30 tag: str = "", 31 category: str = "", 32 q: str = "", 33 offset: int = 0, 34 limit: int = 50, 35 ) -> PagedList[ChallengeInfo]: 36 params: dict[str, Any] = {"offset": offset, "limit": limit} 37 if difficulty: 38 params["difficulty"] = difficulty 39 if tag: 40 params["tag"] = tag 41 if category: 42 params["category"] = category 43 if q: 44 params["q"] = q 45 resp = self._http.request("GET", "/challenges", params=params) 46 _raise_for_status(resp) 47 return _extract_items(resp.json(), ChallengeInfo)
49 def get(self, challenge_id: str, *, competition_id: str = "") -> ChallengeInfo: 50 """One challenge's catalog row. 51 52 ``competition_id`` scopes the tallies to that competition's 53 teams; without it they span every event on the platform. 54 """ 55 params: dict[str, Any] = {} 56 if competition_id: 57 params["competition_id"] = competition_id 58 resp = self._http.request("GET", f"/challenges/{challenge_id}", params=params) 59 _raise_for_status(resp) 60 return ChallengeInfo.model_validate(resp.json())
One challenge's catalog row.
competition_id scopes the tallies to that competition's
teams; without it they span every event on the platform.
62 def attachments(self, challenge_id: str) -> AttachmentList: 63 """List the files shipped under the challenge's ``attachments/`` dir. 64 65 Same data as :attr:`ChallengeInfo.attachments` but addressable 66 directly. Empty list when the challenge ships nothing. 67 """ 68 resp = self._http.request("GET", f"/challenges/{challenge_id}/attachments") 69 _raise_for_status(resp) 70 return AttachmentList.model_validate(resp.json())
List the files shipped under the challenge's attachments/ dir.
Same data as ChallengeInfo.attachments but addressable
directly. Empty list when the challenge ships nothing.
72 def download_attachment(self, challenge_id: str, filename: str) -> bytes: 73 """Download one attachment, returning the raw bytes. 74 75 ``filename`` is the relative path under the challenge's 76 ``attachments/`` directory (the name from 77 :meth:`attachments`). Streaming for very large files lives on 78 the underlying httpx client; this wrapper materialises the 79 whole body so the common case stays simple. 80 """ 81 resp = self._http.request( 82 "GET", f"/challenges/{challenge_id}/attachments/{attachment_url_path(filename)}" 83 ) 84 _raise_for_status(resp) 85 return resp.content
Download one attachment, returning the raw bytes.
filename is the relative path under the challenge's
attachments/ directory (the name from
attachments()). Streaming for very large files lives on
the underlying httpx client; this wrapper materialises the
whole body so the common case stays simple.
87 def solve_attempts( 88 self, 89 challenge_id: str, 90 *, 91 include_unsolved: bool = False, 92 limit: int = 500, 93 competition_id: str = "", 94 ) -> ChallengeSolveAttemptsResponse: 95 """Per-team archived-instance breakdown for one challenge 96 (attempts, solve time, solved flag count). 97 98 ``competition_id`` restricts the rows to that competition's 99 teams; without it the breakdown spans every event the challenge 100 has ever appeared in. 101 """ 102 params: dict[str, Any] = {"include_unsolved": include_unsolved, "limit": limit} 103 if competition_id: 104 params["competition_id"] = competition_id 105 resp = self._http.request( 106 "GET", 107 f"/challenges/{challenge_id}/solve-attempts", 108 params=params, 109 ) 110 _raise_for_status(resp) 111 return ChallengeSolveAttemptsResponse.model_validate(resp.json())
Per-team archived-instance breakdown for one challenge (attempts, solve time, solved flag count).
competition_id restricts the rows to that competition's
teams; without it the breakdown spans every event the challenge
has ever appeared in.
113 def facets(self, *, competition_id: str = "") -> ChallengeFacets: 114 """Available difficulty / tag facets (with counts) for the 115 challenge browser. Scope to one competition's catalog by 116 passing ``competition_id``.""" 117 params = {"competition_id": competition_id} if competition_id else {} 118 resp = self._http.request("GET", "/challenges/facets", params=params) 119 _raise_for_status(resp) 120 return ChallengeFacets.model_validate(resp.json())
Available difficulty / tag facets (with counts) for the
challenge browser. Scope to one competition's catalog by
passing competition_id.
122 def feedback_stats(self, challenge_id: str) -> FeedbackStats: 123 """Dense per-reaction count aggregate for one challenge.""" 124 resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/stats") 125 _raise_for_status(resp) 126 return FeedbackStats.model_validate(resp.json())
Dense per-reaction count aggregate for one challenge.
128 def my_reactions(self, challenge_id: str) -> MyReactionsResponse: 129 """The calling user's active reactions on a challenge (empty if unset).""" 130 resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/me") 131 _raise_for_status(resp) 132 return MyReactionsResponse.model_validate(resp.json())
The calling user's active reactions on a challenge (empty if unset).
134 def add_reaction(self, challenge_id: str, reaction: str) -> MyReactionsResponse: 135 """Add one reaction chip to the user's set for this challenge. 136 137 Multi-select: stacks alongside any other reactions the user 138 already has. Idempotent — calling twice with the same triple 139 is a no-op. ``reaction`` must be one of the nine values 140 declared in :data:`ctfy.core.state.models.REACTIONS`. 141 142 Returns the user's full active reaction set after the write. 143 """ 144 resp = self._http.request("PUT", f"/challenges/{challenge_id}/feedback/{reaction}") 145 _raise_for_status(resp) 146 return MyReactionsResponse.model_validate(resp.json())
Add one reaction chip to the user's set for this challenge.
Multi-select: stacks alongside any other reactions the user
already has. Idempotent — calling twice with the same triple
is a no-op. reaction must be one of the nine values
declared in ctfy.core.state.models.REACTIONS.
Returns the user's full active reaction set after the write.
148 def remove_reaction(self, challenge_id: str, reaction: str) -> None: 149 """Remove one reaction chip from the user's set (204, idempotent).""" 150 resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback/{reaction}") 151 _raise_for_status(resp)
Remove one reaction chip from the user's set (204, idempotent).
153 def clear_reactions(self, challenge_id: str) -> None: 154 """Clear every active reaction the user has on this challenge (204).""" 155 resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback") 156 _raise_for_status(resp)
Clear every active reaction the user has on this challenge (204).