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) -> ChallengeInfo:
 49        resp = self._http.request("GET", f"/challenges/{challenge_id}")
 50        _raise_for_status(resp)
 51        return ChallengeInfo.model_validate(resp.json())
 52
 53    def attachments(self, challenge_id: str) -> AttachmentList:
 54        """List the files shipped under the challenge's ``attachments/`` dir.
 55
 56        Same data as :attr:`ChallengeInfo.attachments` but addressable
 57        directly. Empty list when the challenge ships nothing.
 58        """
 59        resp = self._http.request("GET", f"/challenges/{challenge_id}/attachments")
 60        _raise_for_status(resp)
 61        return AttachmentList.model_validate(resp.json())
 62
 63    def download_attachment(self, challenge_id: str, filename: str) -> bytes:
 64        """Download one attachment, returning the raw bytes.
 65
 66        ``filename`` is the relative path under the challenge's
 67        ``attachments/`` directory (the name from
 68        :meth:`attachments`). Streaming for very large files lives on
 69        the underlying httpx client; this wrapper materialises the
 70        whole body so the common case stays simple.
 71        """
 72        resp = self._http.request("GET", f"/challenges/{challenge_id}/attachments/{filename}")
 73        _raise_for_status(resp)
 74        return resp.content
 75
 76    def solve_attempts(
 77        self,
 78        challenge_id: str,
 79        *,
 80        include_unsolved: bool = False,
 81        limit: int = 500,
 82    ) -> ChallengeSolveAttemptsResponse:
 83        """Per-team archived-instance breakdown for one challenge
 84        (attempts, solve time, solved flag count)."""
 85        resp = self._http.request(
 86            "GET",
 87            f"/challenges/{challenge_id}/solve-attempts",
 88            params={"include_unsolved": include_unsolved, "limit": limit},
 89        )
 90        _raise_for_status(resp)
 91        return ChallengeSolveAttemptsResponse.model_validate(resp.json())
 92
 93    def facets(self, *, competition_id: str = "") -> ChallengeFacets:
 94        """Available difficulty / tag facets (with counts) for the
 95        challenge browser. Scope to one competition's catalog by
 96        passing ``competition_id``."""
 97        params = {"competition_id": competition_id} if competition_id else {}
 98        resp = self._http.request("GET", "/challenges/facets", params=params)
 99        _raise_for_status(resp)
100        return ChallengeFacets.model_validate(resp.json())
101
102    def feedback_stats(self, challenge_id: str) -> FeedbackStats:
103        """Dense per-reaction count aggregate for one challenge."""
104        resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/stats")
105        _raise_for_status(resp)
106        return FeedbackStats.model_validate(resp.json())
107
108    def my_reactions(self, challenge_id: str) -> MyReactionsResponse:
109        """The calling user's active reactions on a challenge (empty if unset)."""
110        resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/me")
111        _raise_for_status(resp)
112        return MyReactionsResponse.model_validate(resp.json())
113
114    def add_reaction(self, challenge_id: str, reaction: str) -> MyReactionsResponse:
115        """Add one reaction chip to the user's set for this challenge.
116
117        Multi-select: stacks alongside any other reactions the user
118        already has. Idempotent — calling twice with the same triple
119        is a no-op. ``reaction`` must be one of the nine values
120        declared in :data:`ctfy.core.state.models.REACTIONS`.
121
122        Returns the user's full active reaction set after the write.
123        """
124        resp = self._http.request("PUT", f"/challenges/{challenge_id}/feedback/{reaction}")
125        _raise_for_status(resp)
126        return MyReactionsResponse.model_validate(resp.json())
127
128    def remove_reaction(self, challenge_id: str, reaction: str) -> None:
129        """Remove one reaction chip from the user's set (204, idempotent)."""
130        resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback/{reaction}")
131        _raise_for_status(resp)
132
133    def clear_reactions(self, challenge_id: str) -> None:
134        """Clear every active reaction the user has on this challenge (204)."""
135        resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback")
136        _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) -> ChallengeInfo:
 50        resp = self._http.request("GET", f"/challenges/{challenge_id}")
 51        _raise_for_status(resp)
 52        return ChallengeInfo.model_validate(resp.json())
 53
 54    def attachments(self, challenge_id: str) -> AttachmentList:
 55        """List the files shipped under the challenge's ``attachments/`` dir.
 56
 57        Same data as :attr:`ChallengeInfo.attachments` but addressable
 58        directly. Empty list when the challenge ships nothing.
 59        """
 60        resp = self._http.request("GET", f"/challenges/{challenge_id}/attachments")
 61        _raise_for_status(resp)
 62        return AttachmentList.model_validate(resp.json())
 63
 64    def download_attachment(self, challenge_id: str, filename: str) -> bytes:
 65        """Download one attachment, returning the raw bytes.
 66
 67        ``filename`` is the relative path under the challenge's
 68        ``attachments/`` directory (the name from
 69        :meth:`attachments`). Streaming for very large files lives on
 70        the underlying httpx client; this wrapper materialises the
 71        whole body so the common case stays simple.
 72        """
 73        resp = self._http.request("GET", f"/challenges/{challenge_id}/attachments/{filename}")
 74        _raise_for_status(resp)
 75        return resp.content
 76
 77    def solve_attempts(
 78        self,
 79        challenge_id: str,
 80        *,
 81        include_unsolved: bool = False,
 82        limit: int = 500,
 83    ) -> ChallengeSolveAttemptsResponse:
 84        """Per-team archived-instance breakdown for one challenge
 85        (attempts, solve time, solved flag count)."""
 86        resp = self._http.request(
 87            "GET",
 88            f"/challenges/{challenge_id}/solve-attempts",
 89            params={"include_unsolved": include_unsolved, "limit": limit},
 90        )
 91        _raise_for_status(resp)
 92        return ChallengeSolveAttemptsResponse.model_validate(resp.json())
 93
 94    def facets(self, *, competition_id: str = "") -> ChallengeFacets:
 95        """Available difficulty / tag facets (with counts) for the
 96        challenge browser. Scope to one competition's catalog by
 97        passing ``competition_id``."""
 98        params = {"competition_id": competition_id} if competition_id else {}
 99        resp = self._http.request("GET", "/challenges/facets", params=params)
100        _raise_for_status(resp)
101        return ChallengeFacets.model_validate(resp.json())
102
103    def feedback_stats(self, challenge_id: str) -> FeedbackStats:
104        """Dense per-reaction count aggregate for one challenge."""
105        resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/stats")
106        _raise_for_status(resp)
107        return FeedbackStats.model_validate(resp.json())
108
109    def my_reactions(self, challenge_id: str) -> MyReactionsResponse:
110        """The calling user's active reactions on a challenge (empty if unset)."""
111        resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/me")
112        _raise_for_status(resp)
113        return MyReactionsResponse.model_validate(resp.json())
114
115    def add_reaction(self, challenge_id: str, reaction: str) -> MyReactionsResponse:
116        """Add one reaction chip to the user's set for this challenge.
117
118        Multi-select: stacks alongside any other reactions the user
119        already has. Idempotent — calling twice with the same triple
120        is a no-op. ``reaction`` must be one of the nine values
121        declared in :data:`ctfy.core.state.models.REACTIONS`.
122
123        Returns the user's full active reaction set after the write.
124        """
125        resp = self._http.request("PUT", f"/challenges/{challenge_id}/feedback/{reaction}")
126        _raise_for_status(resp)
127        return MyReactionsResponse.model_validate(resp.json())
128
129    def remove_reaction(self, challenge_id: str, reaction: str) -> None:
130        """Remove one reaction chip from the user's set (204, idempotent)."""
131        resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback/{reaction}")
132        _raise_for_status(resp)
133
134    def clear_reactions(self, challenge_id: str) -> None:
135        """Clear every active reaction the user has on this challenge (204)."""
136        resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback")
137        _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) -> ctfy.server.models.ChallengeInfo:
49    def get(self, challenge_id: str) -> ChallengeInfo:
50        resp = self._http.request("GET", f"/challenges/{challenge_id}")
51        _raise_for_status(resp)
52        return ChallengeInfo.model_validate(resp.json())
def attachments(self, challenge_id: str) -> ctfy.server.models.AttachmentList:
54    def attachments(self, challenge_id: str) -> AttachmentList:
55        """List the files shipped under the challenge's ``attachments/`` dir.
56
57        Same data as :attr:`ChallengeInfo.attachments` but addressable
58        directly. Empty list when the challenge ships nothing.
59        """
60        resp = self._http.request("GET", f"/challenges/{challenge_id}/attachments")
61        _raise_for_status(resp)
62        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:
64    def download_attachment(self, challenge_id: str, filename: str) -> bytes:
65        """Download one attachment, returning the raw bytes.
66
67        ``filename`` is the relative path under the challenge's
68        ``attachments/`` directory (the name from
69        :meth:`attachments`). Streaming for very large files lives on
70        the underlying httpx client; this wrapper materialises the
71        whole body so the common case stays simple.
72        """
73        resp = self._http.request("GET", f"/challenges/{challenge_id}/attachments/{filename}")
74        _raise_for_status(resp)
75        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) -> ctfy.server.models.ChallengeSolveAttemptsResponse:
77    def solve_attempts(
78        self,
79        challenge_id: str,
80        *,
81        include_unsolved: bool = False,
82        limit: int = 500,
83    ) -> ChallengeSolveAttemptsResponse:
84        """Per-team archived-instance breakdown for one challenge
85        (attempts, solve time, solved flag count)."""
86        resp = self._http.request(
87            "GET",
88            f"/challenges/{challenge_id}/solve-attempts",
89            params={"include_unsolved": include_unsolved, "limit": limit},
90        )
91        _raise_for_status(resp)
92        return ChallengeSolveAttemptsResponse.model_validate(resp.json())

Per-team archived-instance breakdown for one challenge (attempts, solve time, solved flag count).

def facets( self, *, competition_id: str = '') -> ctfy.server.models.ChallengeFacets:
 94    def facets(self, *, competition_id: str = "") -> ChallengeFacets:
 95        """Available difficulty / tag facets (with counts) for the
 96        challenge browser. Scope to one competition's catalog by
 97        passing ``competition_id``."""
 98        params = {"competition_id": competition_id} if competition_id else {}
 99        resp = self._http.request("GET", "/challenges/facets", params=params)
100        _raise_for_status(resp)
101        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:
103    def feedback_stats(self, challenge_id: str) -> FeedbackStats:
104        """Dense per-reaction count aggregate for one challenge."""
105        resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/stats")
106        _raise_for_status(resp)
107        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:
109    def my_reactions(self, challenge_id: str) -> MyReactionsResponse:
110        """The calling user's active reactions on a challenge (empty if unset)."""
111        resp = self._http.request("GET", f"/challenges/{challenge_id}/feedback/me")
112        _raise_for_status(resp)
113        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:
115    def add_reaction(self, challenge_id: str, reaction: str) -> MyReactionsResponse:
116        """Add one reaction chip to the user's set for this challenge.
117
118        Multi-select: stacks alongside any other reactions the user
119        already has. Idempotent — calling twice with the same triple
120        is a no-op. ``reaction`` must be one of the nine values
121        declared in :data:`ctfy.core.state.models.REACTIONS`.
122
123        Returns the user's full active reaction set after the write.
124        """
125        resp = self._http.request("PUT", f"/challenges/{challenge_id}/feedback/{reaction}")
126        _raise_for_status(resp)
127        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:
129    def remove_reaction(self, challenge_id: str, reaction: str) -> None:
130        """Remove one reaction chip from the user's set (204, idempotent)."""
131        resp = self._http.request("DELETE", f"/challenges/{challenge_id}/feedback/{reaction}")
132        _raise_for_status(resp)

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

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

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