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