ctfy.sdk.resources.submissions

client.submissions — graded submit, oracle verify, QA challenges.

  1"""``client.submissions`` — graded submit, oracle verify, QA challenges."""
  2
  3from __future__ import annotations
  4
  5import builtins
  6from typing import Any
  7
  8from ctfy.core.state.models import SubmissionState
  9from ctfy.sdk._helpers import _extract_items, _raise_for_status
 10from ctfy.sdk.base import BaseHttpClient
 11from ctfy.server.models import QaChallengeView, SubmissionResponse, VerifyAnswerResponse
 12
 13
 14class SubmissionsResource:
 15    """Answer submission (graded), oracle verification (no record), and QA."""
 16
 17    def __init__(self, http: BaseHttpClient) -> None:
 18        self._http = http
 19
 20    def submit(
 21        self,
 22        instance_id: str,
 23        answer: str | builtins.list[str],
 24        *,
 25        question_id: str = "flag",
 26    ) -> SubmissionResponse:
 27        """Submit ``answer`` against ``question_id`` on ``instance_id``.
 28
 29        The server reads challenge_id, team_id and competition_id off
 30        the instance row; the calling user must be on the same team
 31        that started this instance (in the same competition).
 32
 33        ``question_id`` defaults to ``"flag"`` for single-question
 34        challenges (the most common case). Multi-question challenges
 35        (see :class:`ChallengeInfo.questions`) require an explicit id.
 36        ``answer`` is a single string for dynamic / static /
 37        single_select questions and a list of strings for multi_select.
 38        """
 39        resp = self._http.request(
 40            "POST",
 41            "/submissions",
 42            json={
 43                "instance_id": instance_id,
 44                "question_id": question_id,
 45                "answer": answer,
 46            },
 47        )
 48        _raise_for_status(resp)
 49        return SubmissionResponse.model_validate(resp.json())
 50
 51    def verify(
 52        self,
 53        instance_id: str,
 54        answer: str | builtins.list[str],
 55        *,
 56        question_id: str = "flag",
 57    ) -> VerifyAnswerResponse:
 58        """Server-side answer verification — no submission record.
 59
 60        Same grader as :meth:`submit` (mode-aware,
 61        ``$ANSWER_<UPPER_ID>`` tolerance for dynamic, set-equal for
 62        ``multi_select``, etc.) but writes nothing to the audit log.
 63        Use for oracle-style probes; for graded competitions use
 64        :meth:`submit`.
 65
 66        Returns :class:`VerifyAnswerResponse` with ``.correct`` and
 67        ``.question_id`` (the latter echoed on a correct match).
 68        """
 69        resp = self._http.request(
 70            "POST",
 71            f"/instances/{instance_id}/verify-answer",
 72            json={"question_id": question_id, "answer": answer},
 73        )
 74        _raise_for_status(resp)
 75        return VerifyAnswerResponse.model_validate(resp.json())
 76
 77    def list(
 78        self, challenge_id: str = "", offset: int = 0, limit: int = 50
 79    ) -> builtins.list[SubmissionState]:
 80        """List current team's submissions."""
 81        params: dict[str, Any] = {"offset": offset, "limit": limit}
 82        if challenge_id:
 83            params["challenge_id"] = challenge_id
 84        resp = self._http.request("GET", "/submissions", params=params)
 85        _raise_for_status(resp)
 86        return _extract_items(resp.json(), SubmissionState)
 87
 88    def submit_qa(
 89        self,
 90        challenge_id: str,
 91        competition_id: str,
 92        answer: str | builtins.list[str],
 93        *,
 94        question_id: str = "answer",
 95    ) -> SubmissionResponse:
 96        """Submit ``answer`` against a pure question-answer challenge.
 97
 98        Unlike :meth:`submit`, QA challenges have no instance — the
 99        caller passes ``challenge_id`` + ``competition_id`` explicitly,
100        and the server resolves the team for the competition via the
101        standard auth helper. ``question_id`` defaults to ``"answer"``
102        (the canonical id for MCQ items produced by ``ctfy-admin
103        challenge import``).
104        """
105        resp = self._http.request(
106            "POST",
107            "/qa/submissions",
108            json={
109                "challenge_id": challenge_id,
110                "competition_id": competition_id,
111                "question_id": question_id,
112                "answer": answer,
113            },
114        )
115        _raise_for_status(resp)
116        return SubmissionResponse.model_validate(resp.json())
117
118    def list_qa_challenges(self, competition_id: str) -> builtins.list[QaChallengeView]:
119        """All QA challenges in scope of one competition + team status.
120
121        Returns one :class:`QaChallengeView` per challenge with the
122        calling team's per-question solve / wrong-attempt state
123        pre-joined. The quiz UI renders the full surface from this
124        single response.
125        """
126        resp = self._http.request(
127            "GET", "/qa/challenges", params={"competition_id": competition_id}
128        )
129        _raise_for_status(resp)
130        data = resp.json()
131        return [QaChallengeView.model_validate(item) for item in data]
class SubmissionsResource:
 15class SubmissionsResource:
 16    """Answer submission (graded), oracle verification (no record), and QA."""
 17
 18    def __init__(self, http: BaseHttpClient) -> None:
 19        self._http = http
 20
 21    def submit(
 22        self,
 23        instance_id: str,
 24        answer: str | builtins.list[str],
 25        *,
 26        question_id: str = "flag",
 27    ) -> SubmissionResponse:
 28        """Submit ``answer`` against ``question_id`` on ``instance_id``.
 29
 30        The server reads challenge_id, team_id and competition_id off
 31        the instance row; the calling user must be on the same team
 32        that started this instance (in the same competition).
 33
 34        ``question_id`` defaults to ``"flag"`` for single-question
 35        challenges (the most common case). Multi-question challenges
 36        (see :class:`ChallengeInfo.questions`) require an explicit id.
 37        ``answer`` is a single string for dynamic / static /
 38        single_select questions and a list of strings for multi_select.
 39        """
 40        resp = self._http.request(
 41            "POST",
 42            "/submissions",
 43            json={
 44                "instance_id": instance_id,
 45                "question_id": question_id,
 46                "answer": answer,
 47            },
 48        )
 49        _raise_for_status(resp)
 50        return SubmissionResponse.model_validate(resp.json())
 51
 52    def verify(
 53        self,
 54        instance_id: str,
 55        answer: str | builtins.list[str],
 56        *,
 57        question_id: str = "flag",
 58    ) -> VerifyAnswerResponse:
 59        """Server-side answer verification — no submission record.
 60
 61        Same grader as :meth:`submit` (mode-aware,
 62        ``$ANSWER_<UPPER_ID>`` tolerance for dynamic, set-equal for
 63        ``multi_select``, etc.) but writes nothing to the audit log.
 64        Use for oracle-style probes; for graded competitions use
 65        :meth:`submit`.
 66
 67        Returns :class:`VerifyAnswerResponse` with ``.correct`` and
 68        ``.question_id`` (the latter echoed on a correct match).
 69        """
 70        resp = self._http.request(
 71            "POST",
 72            f"/instances/{instance_id}/verify-answer",
 73            json={"question_id": question_id, "answer": answer},
 74        )
 75        _raise_for_status(resp)
 76        return VerifyAnswerResponse.model_validate(resp.json())
 77
 78    def list(
 79        self, challenge_id: str = "", offset: int = 0, limit: int = 50
 80    ) -> builtins.list[SubmissionState]:
 81        """List current team's submissions."""
 82        params: dict[str, Any] = {"offset": offset, "limit": limit}
 83        if challenge_id:
 84            params["challenge_id"] = challenge_id
 85        resp = self._http.request("GET", "/submissions", params=params)
 86        _raise_for_status(resp)
 87        return _extract_items(resp.json(), SubmissionState)
 88
 89    def submit_qa(
 90        self,
 91        challenge_id: str,
 92        competition_id: str,
 93        answer: str | builtins.list[str],
 94        *,
 95        question_id: str = "answer",
 96    ) -> SubmissionResponse:
 97        """Submit ``answer`` against a pure question-answer challenge.
 98
 99        Unlike :meth:`submit`, QA challenges have no instance — the
100        caller passes ``challenge_id`` + ``competition_id`` explicitly,
101        and the server resolves the team for the competition via the
102        standard auth helper. ``question_id`` defaults to ``"answer"``
103        (the canonical id for MCQ items produced by ``ctfy-admin
104        challenge import``).
105        """
106        resp = self._http.request(
107            "POST",
108            "/qa/submissions",
109            json={
110                "challenge_id": challenge_id,
111                "competition_id": competition_id,
112                "question_id": question_id,
113                "answer": answer,
114            },
115        )
116        _raise_for_status(resp)
117        return SubmissionResponse.model_validate(resp.json())
118
119    def list_qa_challenges(self, competition_id: str) -> builtins.list[QaChallengeView]:
120        """All QA challenges in scope of one competition + team status.
121
122        Returns one :class:`QaChallengeView` per challenge with the
123        calling team's per-question solve / wrong-attempt state
124        pre-joined. The quiz UI renders the full surface from this
125        single response.
126        """
127        resp = self._http.request(
128            "GET", "/qa/challenges", params={"competition_id": competition_id}
129        )
130        _raise_for_status(resp)
131        data = resp.json()
132        return [QaChallengeView.model_validate(item) for item in data]

Answer submission (graded), oracle verification (no record), and QA.

SubmissionsResource(http: ctfy.sdk.base.BaseHttpClient)
18    def __init__(self, http: BaseHttpClient) -> None:
19        self._http = http
def submit( self, instance_id: str, answer: str | list[str], *, question_id: str = 'flag') -> ctfy.server.models.SubmissionResponse:
21    def submit(
22        self,
23        instance_id: str,
24        answer: str | builtins.list[str],
25        *,
26        question_id: str = "flag",
27    ) -> SubmissionResponse:
28        """Submit ``answer`` against ``question_id`` on ``instance_id``.
29
30        The server reads challenge_id, team_id and competition_id off
31        the instance row; the calling user must be on the same team
32        that started this instance (in the same competition).
33
34        ``question_id`` defaults to ``"flag"`` for single-question
35        challenges (the most common case). Multi-question challenges
36        (see :class:`ChallengeInfo.questions`) require an explicit id.
37        ``answer`` is a single string for dynamic / static /
38        single_select questions and a list of strings for multi_select.
39        """
40        resp = self._http.request(
41            "POST",
42            "/submissions",
43            json={
44                "instance_id": instance_id,
45                "question_id": question_id,
46                "answer": answer,
47            },
48        )
49        _raise_for_status(resp)
50        return SubmissionResponse.model_validate(resp.json())

Submit answer against question_id on instance_id.

The server reads challenge_id, team_id and competition_id off the instance row; the calling user must be on the same team that started this instance (in the same competition).

question_id defaults to "flag" for single-question challenges (the most common case). Multi-question challenges (see ChallengeInfo.questions) require an explicit id. answer is a single string for dynamic / static / single_select questions and a list of strings for multi_select.

def verify( self, instance_id: str, answer: str | list[str], *, question_id: str = 'flag') -> ctfy.server.models.VerifyAnswerResponse:
52    def verify(
53        self,
54        instance_id: str,
55        answer: str | builtins.list[str],
56        *,
57        question_id: str = "flag",
58    ) -> VerifyAnswerResponse:
59        """Server-side answer verification — no submission record.
60
61        Same grader as :meth:`submit` (mode-aware,
62        ``$ANSWER_<UPPER_ID>`` tolerance for dynamic, set-equal for
63        ``multi_select``, etc.) but writes nothing to the audit log.
64        Use for oracle-style probes; for graded competitions use
65        :meth:`submit`.
66
67        Returns :class:`VerifyAnswerResponse` with ``.correct`` and
68        ``.question_id`` (the latter echoed on a correct match).
69        """
70        resp = self._http.request(
71            "POST",
72            f"/instances/{instance_id}/verify-answer",
73            json={"question_id": question_id, "answer": answer},
74        )
75        _raise_for_status(resp)
76        return VerifyAnswerResponse.model_validate(resp.json())

Server-side answer verification — no submission record.

Same grader as submit() (mode-aware, $ANSWER_<UPPER_ID> tolerance for dynamic, set-equal for multi_select, etc.) but writes nothing to the audit log. Use for oracle-style probes; for graded competitions use submit().

Returns VerifyAnswerResponse with .correct and .question_id (the latter echoed on a correct match).

def list( self, challenge_id: str = '', offset: int = 0, limit: int = 50) -> list[ctfy.core.state.models.SubmissionState]:
78    def list(
79        self, challenge_id: str = "", offset: int = 0, limit: int = 50
80    ) -> builtins.list[SubmissionState]:
81        """List current team's submissions."""
82        params: dict[str, Any] = {"offset": offset, "limit": limit}
83        if challenge_id:
84            params["challenge_id"] = challenge_id
85        resp = self._http.request("GET", "/submissions", params=params)
86        _raise_for_status(resp)
87        return _extract_items(resp.json(), SubmissionState)

List current team's submissions.

def submit_qa( self, challenge_id: str, competition_id: str, answer: str | list[str], *, question_id: str = 'answer') -> ctfy.server.models.SubmissionResponse:
 89    def submit_qa(
 90        self,
 91        challenge_id: str,
 92        competition_id: str,
 93        answer: str | builtins.list[str],
 94        *,
 95        question_id: str = "answer",
 96    ) -> SubmissionResponse:
 97        """Submit ``answer`` against a pure question-answer challenge.
 98
 99        Unlike :meth:`submit`, QA challenges have no instance — the
100        caller passes ``challenge_id`` + ``competition_id`` explicitly,
101        and the server resolves the team for the competition via the
102        standard auth helper. ``question_id`` defaults to ``"answer"``
103        (the canonical id for MCQ items produced by ``ctfy-admin
104        challenge import``).
105        """
106        resp = self._http.request(
107            "POST",
108            "/qa/submissions",
109            json={
110                "challenge_id": challenge_id,
111                "competition_id": competition_id,
112                "question_id": question_id,
113                "answer": answer,
114            },
115        )
116        _raise_for_status(resp)
117        return SubmissionResponse.model_validate(resp.json())

Submit answer against a pure question-answer challenge.

Unlike submit(), QA challenges have no instance — the caller passes challenge_id + competition_id explicitly, and the server resolves the team for the competition via the standard auth helper. question_id defaults to "answer" (the canonical id for MCQ items produced by ctfy-admin challenge import).

def list_qa_challenges( self, competition_id: str) -> list[ctfy.server.models.QaChallengeView]:
119    def list_qa_challenges(self, competition_id: str) -> builtins.list[QaChallengeView]:
120        """All QA challenges in scope of one competition + team status.
121
122        Returns one :class:`QaChallengeView` per challenge with the
123        calling team's per-question solve / wrong-attempt state
124        pre-joined. The quiz UI renders the full surface from this
125        single response.
126        """
127        resp = self._http.request(
128            "GET", "/qa/challenges", params={"competition_id": competition_id}
129        )
130        _raise_for_status(resp)
131        data = resp.json()
132        return [QaChallengeView.model_validate(item) for item in data]

All QA challenges in scope of one competition + team status.

Returns one QaChallengeView per challenge with the calling team's per-question solve / wrong-attempt state pre-joined. The quiz UI renders the full surface from this single response.