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
  5import builtins
  6from typing import Any
  7
  8from ctfy.sdk._helpers import _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    ) -> builtins.list[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)
class ChallengesResource:
 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    ) -> builtins.list[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("GET", f"/challenges/{challenge_id}/attachments/{filename}")
 82        _raise_for_status(resp)
 83        return resp.content
 84
 85    def solve_attempts(
 86        self,
 87        challenge_id: str,
 88        *,
 89        include_unsolved: bool = False,
 90        limit: int = 500,
 91        competition_id: str = "",
 92    ) -> ChallengeSolveAttemptsResponse:
 93        """Per-team archived-instance breakdown for one challenge
 94        (attempts, solve time, solved flag count).
 95
 96        ``competition_id`` restricts the rows to that competition's
 97        teams; without it the breakdown spans every event the challenge
 98        has ever appeared in.
 99        """
100        params: dict[str, Any] = {"include_unsolved": include_unsolved, "limit": limit}
101        if competition_id:
102            params["competition_id"] = competition_id
103        resp = self._http.request(
104            "GET",
105            f"/challenges/{challenge_id}/solve-attempts",
106            params=params,
107        )
108        _raise_for_status(resp)
109        return ChallengeSolveAttemptsResponse.model_validate(resp.json())
110
111    def facets(self, *, competition_id: str = "") -> ChallengeFacets:
112        """Available difficulty / tag facets (with counts) for the
113        challenge browser. Scope to one competition's catalog by
114        passing ``competition_id``."""
115        params = {"competition_id": competition_id} if competition_id else {}
116        resp = self._http.request("GET", "/challenges/facets", params=params)
117        _raise_for_status(resp)
118        return ChallengeFacets.model_validate(resp.json())
119
120    def feedback_stats(self, challenge_id: str) -> FeedbackStats:
121        """Dense per-reaction count aggregate for one challenge."""
122        resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/stats")
123        _raise_for_status(resp)
124        return FeedbackStats.model_validate(resp.json())
125
126    def my_reactions(self, challenge_id: str) -> MyReactionsResponse:
127        """The calling user's active reactions on a challenge (empty if unset)."""
128        resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/me")
129        _raise_for_status(resp)
130        return MyReactionsResponse.model_validate(resp.json())
131
132    def add_reaction(self, challenge_id: str, reaction: str) -> MyReactionsResponse:
133        """Add one reaction chip to the user's set for this challenge.
134
135        Multi-select: stacks alongside any other reactions the user
136        already has. Idempotent — calling twice with the same triple
137        is a no-op. ``reaction`` must be one of the nine values
138        declared in :data:`ctfy.core.state.models.REACTIONS`.
139
140        Returns the user's full active reaction set after the write.
141        """
142        resp = self._http.request("PUT", f"/challenges/{challenge_id}/feedback/{reaction}")
143        _raise_for_status(resp)
144        return MyReactionsResponse.model_validate(resp.json())
145
146    def remove_reaction(self, challenge_id: str, reaction: str) -> None:
147        """Remove one reaction chip from the user's set (204, idempotent)."""
148        resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback/{reaction}")
149        _raise_for_status(resp)
150
151    def clear_reactions(self, challenge_id: str) -> None:
152        """Clear every active reaction the user has on this challenge (204)."""
153        resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback")
154        _raise_for_status(resp)

Global challenge catalog + per-challenge attachments and solve feedback.

ChallengesResource(http: ctfy.sdk.base.BaseHttpClient)
24    def __init__(self, http: BaseHttpClient) -> None:
25        self._http = http
def list( self, difficulty: str | None = None, tag: str = '', category: str = '', q: str = '', offset: int = 0, limit: int = 50) -> list[ctfy.server.models.ChallengeInfo]:
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    ) -> builtins.list[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)
def get( self, challenge_id: str, *, competition_id: str = '') -> ctfy.server.models.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.

def attachments(self, challenge_id: str) -> ctfy.server.models.AttachmentList:
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.

def download_attachment(self, challenge_id: str, filename: str) -> bytes:
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("GET", f"/challenges/{challenge_id}/attachments/{filename}")
82        _raise_for_status(resp)
83        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.

def solve_attempts( self, challenge_id: str, *, include_unsolved: bool = False, limit: int = 500, competition_id: str = '') -> ctfy.server.models.ChallengeSolveAttemptsResponse:
 85    def solve_attempts(
 86        self,
 87        challenge_id: str,
 88        *,
 89        include_unsolved: bool = False,
 90        limit: int = 500,
 91        competition_id: str = "",
 92    ) -> ChallengeSolveAttemptsResponse:
 93        """Per-team archived-instance breakdown for one challenge
 94        (attempts, solve time, solved flag count).
 95
 96        ``competition_id`` restricts the rows to that competition's
 97        teams; without it the breakdown spans every event the challenge
 98        has ever appeared in.
 99        """
100        params: dict[str, Any] = {"include_unsolved": include_unsolved, "limit": limit}
101        if competition_id:
102            params["competition_id"] = competition_id
103        resp = self._http.request(
104            "GET",
105            f"/challenges/{challenge_id}/solve-attempts",
106            params=params,
107        )
108        _raise_for_status(resp)
109        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.

def facets( self, *, competition_id: str = '') -> ctfy.server.models.ChallengeFacets:
111    def facets(self, *, competition_id: str = "") -> ChallengeFacets:
112        """Available difficulty / tag facets (with counts) for the
113        challenge browser. Scope to one competition's catalog by
114        passing ``competition_id``."""
115        params = {"competition_id": competition_id} if competition_id else {}
116        resp = self._http.request("GET", "/challenges/facets", params=params)
117        _raise_for_status(resp)
118        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.

def feedback_stats(self, challenge_id: str) -> ctfy.server.models.FeedbackStats:
120    def feedback_stats(self, challenge_id: str) -> FeedbackStats:
121        """Dense per-reaction count aggregate for one challenge."""
122        resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/stats")
123        _raise_for_status(resp)
124        return FeedbackStats.model_validate(resp.json())

Dense per-reaction count aggregate for one challenge.

def my_reactions( self, challenge_id: str) -> ctfy.server.models.MyReactionsResponse:
126    def my_reactions(self, challenge_id: str) -> MyReactionsResponse:
127        """The calling user's active reactions on a challenge (empty if unset)."""
128        resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/me")
129        _raise_for_status(resp)
130        return MyReactionsResponse.model_validate(resp.json())

The calling user's active reactions on a challenge (empty if unset).

def add_reaction( self, challenge_id: str, reaction: str) -> ctfy.server.models.MyReactionsResponse:
132    def add_reaction(self, challenge_id: str, reaction: str) -> MyReactionsResponse:
133        """Add one reaction chip to the user's set for this challenge.
134
135        Multi-select: stacks alongside any other reactions the user
136        already has. Idempotent — calling twice with the same triple
137        is a no-op. ``reaction`` must be one of the nine values
138        declared in :data:`ctfy.core.state.models.REACTIONS`.
139
140        Returns the user's full active reaction set after the write.
141        """
142        resp = self._http.request("PUT", f"/challenges/{challenge_id}/feedback/{reaction}")
143        _raise_for_status(resp)
144        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.

def remove_reaction(self, challenge_id: str, reaction: str) -> None:
146    def remove_reaction(self, challenge_id: str, reaction: str) -> None:
147        """Remove one reaction chip from the user's set (204, idempotent)."""
148        resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback/{reaction}")
149        _raise_for_status(resp)

Remove one reaction chip from the user's set (204, idempotent).

def clear_reactions(self, challenge_id: str) -> None:
151    def clear_reactions(self, challenge_id: str) -> None:
152        """Clear every active reaction the user has on this challenge (204)."""
153        resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback")
154        _raise_for_status(resp)

Clear every active reaction the user has on this challenge (204).