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)
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    ) -> 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.

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) -> ctfy.sdk._helpers.PagedList[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    ) -> 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)
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(
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.

def solve_attempts( self, challenge_id: str, *, include_unsolved: bool = False, limit: int = 500, competition_id: str = '') -> ctfy.server.models.ChallengeSolveAttemptsResponse:
 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.

def facets( self, *, competition_id: str = '') -> ctfy.server.models.ChallengeFacets:
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.

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

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

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

def remove_reaction(self, challenge_id: str, reaction: str) -> None:
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).

def clear_reactions(self, challenge_id: str) -> None:
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).