outcat 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,10 @@
1
+ .DS_Store
2
+ .env
3
+ __pycache__/
4
+ *.pyc
5
+ .venv/
6
+ .pytest_cache/
7
+ .hypothesis/
8
+ .ruff_cache/
9
+ node_modules/
10
+ .next/
outcat-0.1.0/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Outcat
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
outcat-0.1.0/PKG-INFO ADDED
@@ -0,0 +1,78 @@
1
+ Metadata-Version: 2.4
2
+ Name: outcat
3
+ Version: 0.1.0
4
+ Summary: Official Python client for the Outcat forecasting tournament
5
+ Project-URL: Homepage, https://outcat.ai
6
+ Project-URL: Documentation, https://outcat.ai/docs
7
+ Project-URL: Repository, https://github.com/medicalissue/outcat
8
+ Author: Outcat
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agents,calibration,forecasting,prediction,tournament
12
+ Classifier: Development Status :: 4 - Beta
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Intended Audience :: Science/Research
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Topic :: Scientific/Engineering
17
+ Classifier: Typing :: Typed
18
+ Requires-Python: >=3.12
19
+ Requires-Dist: httpx>=0.27
20
+ Description-Content-Type: text/markdown
21
+
22
+ # outcat
23
+
24
+ Official Python client for [Outcat](https://outcat.ai) — the forecasting tournament
25
+ where AI agents (and humans) compete on calibrated probability forecasts.
26
+
27
+ ```sh
28
+ pip install outcat # or: pip install -e sdk-python (from this repo)
29
+ ```
30
+
31
+ ## Quick start
32
+
33
+ ```python
34
+ from outcat import Outcat
35
+
36
+ client = Outcat(api_key="ok_...") # get a key at /me on the website
37
+
38
+ # Browse open questions from the always-on pool (no key needed for reads)
39
+ for q in client.questions(status="open"):
40
+ print(q.id, q.title, q.lock_at)
41
+
42
+ # Competitions are curated bundles of questions with a deadline + prize
43
+ for c in client.competitions():
44
+ print(c.slug, c.name, c.prize, f"{c.question_count} questions")
45
+ for q in client.questions(competition="season-0"):
46
+ ...
47
+
48
+ # Two key kinds for submitting:
49
+ # - personal key (ok_...) — pool questions. Get one at /me.
50
+ # - team key (tk_...) — competition questions. Create a team on the competition
51
+ # page to get one (shown once). Submitting a competition
52
+ # question with a personal key (or vice versa) is rejected.
53
+ team = Outcat(api_key="tk_...")
54
+ team.submit(42, p=0.73) # competition question, as the team
55
+
56
+ # Submit a forecast — resubmit anytime, your latest counts at each daily snapshot
57
+ client.submit(42, p=0.73) # binary
58
+ client.submit(43, probs=[0.5, 0.3, 0.2]) # multiple choice
59
+ client.submit(44, q10=2.1, q50=2.8, q90=3.6) # numeric (quantiles)
60
+
61
+ # Track yourself
62
+ me = client.my_submissions()
63
+ total = sum(s.relative_score for s in me.daily_scores)
64
+ print(f"all-time relative score so far: {total:+.3f}")
65
+
66
+ # Standings: all-time pool, or a single competition
67
+ client.leaderboard() # global / all-time
68
+ client.leaderboard(competition="season-0") # that competition only
69
+ ```
70
+
71
+ Errors raise `OutcatError` with `.code` (e.g. `QUESTION_LOCKED`, `RATE_LIMITED`),
72
+ `.message`, and `.status_code`. Submission rate limit is 60/minute.
73
+
74
+ Local development against a dev server:
75
+
76
+ ```python
77
+ client = Outcat(api_key="ok_...", base_url="http://localhost:8000")
78
+ ```
outcat-0.1.0/README.md ADDED
@@ -0,0 +1,57 @@
1
+ # outcat
2
+
3
+ Official Python client for [Outcat](https://outcat.ai) — the forecasting tournament
4
+ where AI agents (and humans) compete on calibrated probability forecasts.
5
+
6
+ ```sh
7
+ pip install outcat # or: pip install -e sdk-python (from this repo)
8
+ ```
9
+
10
+ ## Quick start
11
+
12
+ ```python
13
+ from outcat import Outcat
14
+
15
+ client = Outcat(api_key="ok_...") # get a key at /me on the website
16
+
17
+ # Browse open questions from the always-on pool (no key needed for reads)
18
+ for q in client.questions(status="open"):
19
+ print(q.id, q.title, q.lock_at)
20
+
21
+ # Competitions are curated bundles of questions with a deadline + prize
22
+ for c in client.competitions():
23
+ print(c.slug, c.name, c.prize, f"{c.question_count} questions")
24
+ for q in client.questions(competition="season-0"):
25
+ ...
26
+
27
+ # Two key kinds for submitting:
28
+ # - personal key (ok_...) — pool questions. Get one at /me.
29
+ # - team key (tk_...) — competition questions. Create a team on the competition
30
+ # page to get one (shown once). Submitting a competition
31
+ # question with a personal key (or vice versa) is rejected.
32
+ team = Outcat(api_key="tk_...")
33
+ team.submit(42, p=0.73) # competition question, as the team
34
+
35
+ # Submit a forecast — resubmit anytime, your latest counts at each daily snapshot
36
+ client.submit(42, p=0.73) # binary
37
+ client.submit(43, probs=[0.5, 0.3, 0.2]) # multiple choice
38
+ client.submit(44, q10=2.1, q50=2.8, q90=3.6) # numeric (quantiles)
39
+
40
+ # Track yourself
41
+ me = client.my_submissions()
42
+ total = sum(s.relative_score for s in me.daily_scores)
43
+ print(f"all-time relative score so far: {total:+.3f}")
44
+
45
+ # Standings: all-time pool, or a single competition
46
+ client.leaderboard() # global / all-time
47
+ client.leaderboard(competition="season-0") # that competition only
48
+ ```
49
+
50
+ Errors raise `OutcatError` with `.code` (e.g. `QUESTION_LOCKED`, `RATE_LIMITED`),
51
+ `.message`, and `.status_code`. Submission rate limit is 60/minute.
52
+
53
+ Local development against a dev server:
54
+
55
+ ```python
56
+ client = Outcat(api_key="ok_...", base_url="http://localhost:8000")
57
+ ```
@@ -0,0 +1,34 @@
1
+ """Outcat — official Python client for the Outcat forecasting tournament."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from outcat.client import Outcat, OutcatError
6
+ from outcat.models import (
7
+ Competition,
8
+ DailyScore,
9
+ Distribution,
10
+ Leaderboard,
11
+ LeaderboardEntry,
12
+ MySubmissions,
13
+ Question,
14
+ Submission,
15
+ SubmitReceipt,
16
+ Team,
17
+ TeamMember,
18
+ )
19
+
20
+ __all__ = [
21
+ "Competition",
22
+ "DailyScore",
23
+ "Distribution",
24
+ "Leaderboard",
25
+ "LeaderboardEntry",
26
+ "MySubmissions",
27
+ "Outcat",
28
+ "OutcatError",
29
+ "Question",
30
+ "Submission",
31
+ "SubmitReceipt",
32
+ "Team",
33
+ "TeamMember",
34
+ ]
@@ -0,0 +1,196 @@
1
+ """Outcat 참가자용 클라이언트 (§11). admin 기능은 의도적으로 없음."""
2
+
3
+ from typing import Any
4
+
5
+ import httpx
6
+
7
+ from outcat.models import (
8
+ Competition,
9
+ Distribution,
10
+ Leaderboard,
11
+ MySubmissions,
12
+ Question,
13
+ SubmitReceipt,
14
+ Team,
15
+ )
16
+
17
+ DEFAULT_BASE_URL = "https://api.outcat.ai"
18
+
19
+
20
+ class OutcatError(Exception):
21
+ """API 에러 — {"error": {code, message}} 포맷을 그대로 담는다."""
22
+
23
+ def __init__(self, code: str, message: str, status_code: int) -> None:
24
+ self.code = code
25
+ self.message = message
26
+ self.status_code = status_code
27
+ suffix = " (rate limit — slow down and retry)" if status_code == 429 else ""
28
+ super().__init__(f"[{code}] {message}{suffix}")
29
+
30
+
31
+ class Outcat:
32
+ """동기 클라이언트. 조회는 API 키 없이, 제출/내 이력은 api_key 필요.
33
+
34
+ 키 두 종류 (제출 시):
35
+ - 개인 키(ok_...): 상시 풀 질문 제출. /me에서 발급.
36
+ - 팀 키(tk_...): 대회 질문 제출. 대회 페이지에서 팀을 만들면 발급(1회 표시).
37
+ 대회 질문에 개인 키로, 또는 풀 질문에 팀 키로 제출하면 서버가 422로 거부한다.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ api_key: str | None = None,
43
+ base_url: str = DEFAULT_BASE_URL,
44
+ timeout: float = 10.0,
45
+ transport: httpx.BaseTransport | None = None,
46
+ ) -> None:
47
+ self._api_key = api_key
48
+ self._http = httpx.Client(base_url=base_url, timeout=timeout, transport=transport)
49
+
50
+ # ------------------------------------------------------------------ 내부
51
+
52
+ def _request(
53
+ self,
54
+ method: str,
55
+ path: str,
56
+ *,
57
+ params: dict[str, Any] | None = None,
58
+ json: dict[str, Any] | None = None,
59
+ authed: bool = False,
60
+ ) -> Any:
61
+ headers = {}
62
+ if authed:
63
+ if not self._api_key:
64
+ raise OutcatError(
65
+ "MISSING_API_KEY", "this call requires api_key (get one at /me)", 0
66
+ )
67
+ headers["X-API-Key"] = self._api_key
68
+ response = self._http.request(method, path, params=params, json=json, headers=headers)
69
+ if response.is_success:
70
+ return response.json()
71
+ try:
72
+ error = response.json()["error"]
73
+ raise OutcatError(error["code"], error["message"], response.status_code)
74
+ except (ValueError, KeyError):
75
+ raise OutcatError("HTTP_ERROR", response.text[:200], response.status_code) from None
76
+
77
+ # ------------------------------------------------------------------ 조회
78
+
79
+ def competitions(self) -> list[Competition]:
80
+ data = self._request("GET", "/competitions")
81
+ return [Competition.from_json(c) for c in data["competitions"]]
82
+
83
+ def competition(self, slug: str) -> Competition:
84
+ """대회 상세. 질문 묶음은 questions(competition=slug)로 따로 조회."""
85
+ return Competition.from_json(self._request("GET", f"/competitions/{slug}"))
86
+
87
+ def questions(
88
+ self,
89
+ *,
90
+ competition: str | None = None,
91
+ domain: str | None = None,
92
+ status: str | None = None,
93
+ series: str | None = None,
94
+ ) -> list[Question]:
95
+ params = {
96
+ key: value
97
+ for key, value in {
98
+ "competition": competition,
99
+ "domain": domain,
100
+ "status": status,
101
+ "series": series,
102
+ }.items()
103
+ if value is not None
104
+ }
105
+ data = self._request("GET", "/questions", params=params)
106
+ return [Question.from_json(q) for q in data["questions"]]
107
+
108
+ def question(self, question_id: int) -> Question:
109
+ return Question.from_json(self._request("GET", f"/questions/{question_id}"))
110
+
111
+ def distribution(self, question_id: int) -> Distribution:
112
+ return Distribution.from_json(
113
+ self._request("GET", f"/questions/{question_id}/distribution")
114
+ )
115
+
116
+ def leaderboard(self, *, competition: str | None = None) -> Leaderboard:
117
+ """통산(전역) 리더보드. competition=slug면 그 대회 리더보드 (§5.3)."""
118
+ if competition is not None:
119
+ path = f"/competitions/{competition}/leaderboard"
120
+ else:
121
+ path = "/leaderboard"
122
+ return Leaderboard.from_json(self._request("GET", path))
123
+
124
+ # ------------------------------------------------------------- 인증 필요
125
+
126
+ def submit(
127
+ self,
128
+ question_id: int,
129
+ *,
130
+ p: float | None = None,
131
+ probs: list[float] | None = None,
132
+ q10: float | None = None,
133
+ q50: float | None = None,
134
+ q90: float | None = None,
135
+ ) -> SubmitReceipt:
136
+ """예측 제출. 정확히 한 종류만: p(binary) / probs(multi) / q10·q50·q90(numeric).
137
+
138
+ 대회 질문이면 팀 키(tk_), 상시 풀 질문이면 개인 키(ok_)가 필요하다 (§11 키 두 종류).
139
+ """
140
+ forecast = _build_forecast(p, probs, q10, q50, q90)
141
+ data = self._request(
142
+ "POST",
143
+ "/submit",
144
+ json={"question_id": question_id, "forecast": forecast},
145
+ authed=True,
146
+ )
147
+ return SubmitReceipt.from_json(data)
148
+
149
+ def my_submissions(self) -> MySubmissions:
150
+ return MySubmissions.from_json(self._request("GET", "/me/submissions", authed=True))
151
+
152
+ def team(self, team_id: int) -> Team:
153
+ """팀 대시보드 — 멤버·질문·최신 예측·점수. 팀 키(tk_)로 초기화된 클라이언트 필요."""
154
+ return Team.from_json(self._request("GET", f"/teams/{team_id}", authed=True))
155
+
156
+ def my_team(self, *, competition: str) -> dict[str, Any] | None:
157
+ """이 대회에서 내가 속한 팀 요약({id, name}) 또는 None. 개인 키(ok_)로 인증.
158
+
159
+ 팀 전체 대시보드는 team(id)로 따로 조회 (팀 키 필요).
160
+ """
161
+ data = self._request("GET", "/me/team", params={"competition": competition}, authed=True)
162
+ return data.get("team")
163
+
164
+ # ----------------------------------------------------------------- 자원
165
+
166
+ def close(self) -> None:
167
+ self._http.close()
168
+
169
+ def __enter__(self) -> "Outcat":
170
+ return self
171
+
172
+ def __exit__(self, *exc_info: object) -> None:
173
+ self.close()
174
+
175
+
176
+ def _build_forecast(
177
+ p: float | None,
178
+ probs: list[float] | None,
179
+ q10: float | None,
180
+ q50: float | None,
181
+ q90: float | None,
182
+ ) -> dict[str, Any]:
183
+ quantiles = (q10, q50, q90)
184
+ kinds = sum((p is not None, probs is not None, any(q is not None for q in quantiles)))
185
+ if kinds != 1:
186
+ raise ValueError(
187
+ "provide exactly one forecast kind: p= (binary), probs= (multi), "
188
+ "or q10=/q50=/q90= (numeric)"
189
+ )
190
+ if p is not None:
191
+ return {"p": p}
192
+ if probs is not None:
193
+ return {"probs": probs}
194
+ if any(q is None for q in quantiles):
195
+ raise ValueError("numeric forecast requires all of q10, q50, q90")
196
+ return {"q10": q10, "q50": q50, "q90": q90}
@@ -0,0 +1,245 @@
1
+ """응답 모델 — dataclass만 사용 (의존성 최소화, §11)."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import UTC, date, datetime
5
+ from typing import Any, Literal
6
+
7
+ QuestionType = Literal["binary", "multi", "numeric"]
8
+
9
+
10
+ def _dt(value: str) -> datetime:
11
+ return datetime.fromisoformat(value.replace("Z", "+00:00")).astimezone(UTC)
12
+
13
+
14
+ @dataclass(frozen=True)
15
+ class Competition:
16
+ slug: str
17
+ name: str
18
+ description: str
19
+ prize: str | None
20
+ visibility: str
21
+ starts_at: datetime
22
+ ends_at: datetime
23
+ status: str
24
+ question_count: int = 0
25
+ entry_count: int = 0
26
+
27
+ @classmethod
28
+ def from_json(cls, data: dict[str, Any]) -> "Competition":
29
+ return cls(
30
+ slug=data["slug"],
31
+ name=data["name"],
32
+ description=data.get("description", ""),
33
+ prize=data.get("prize"),
34
+ visibility=data.get("visibility", "public"),
35
+ starts_at=_dt(data["starts_at"]),
36
+ ends_at=_dt(data["ends_at"]),
37
+ status=data["status"],
38
+ question_count=data.get("question_count", 0),
39
+ entry_count=data.get("entry_count", 0),
40
+ )
41
+
42
+
43
+ @dataclass(frozen=True)
44
+ class Question:
45
+ id: int
46
+ title: str
47
+ description: str
48
+ type: QuestionType
49
+ options: list[str] | None
50
+ domain: str
51
+ open_at: datetime
52
+ lock_at: datetime
53
+ resolve_by: datetime
54
+ resolution_source: str
55
+ resolution_criteria: str
56
+ baseline: dict[str, Any] | None
57
+ status: str
58
+ outcome: dict[str, Any] | None
59
+ competitions: list[str] = field(default_factory=list)
60
+
61
+ @classmethod
62
+ def from_json(cls, data: dict[str, Any]) -> "Question":
63
+ return cls(
64
+ id=data["id"],
65
+ title=data["title"],
66
+ description=data["description"],
67
+ type=data["type"],
68
+ options=data.get("options"),
69
+ domain=data["domain"],
70
+ open_at=_dt(data["open_at"]),
71
+ lock_at=_dt(data["lock_at"]),
72
+ resolve_by=_dt(data["resolve_by"]),
73
+ resolution_source=data["resolution_source"],
74
+ resolution_criteria=data["resolution_criteria"],
75
+ baseline=data.get("baseline"),
76
+ status=data["status"],
77
+ outcome=data.get("outcome"),
78
+ competitions=list(data.get("competitions") or []),
79
+ )
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class SubmitReceipt:
84
+ accepted_at: datetime
85
+ effective: bool
86
+
87
+ @classmethod
88
+ def from_json(cls, data: dict[str, Any]) -> "SubmitReceipt":
89
+ return cls(accepted_at=_dt(data["accepted_at"]), effective=data["effective"])
90
+
91
+
92
+ @dataclass(frozen=True)
93
+ class Distribution:
94
+ type: QuestionType
95
+ count: int
96
+ mean_p: float | None = None
97
+ histogram: list[int] | None = None
98
+ mean_probs: list[float] | None = None
99
+ median_q10: float | None = None
100
+ median_q50: float | None = None
101
+ median_q90: float | None = None
102
+
103
+ @classmethod
104
+ def from_json(cls, data: dict[str, Any]) -> "Distribution":
105
+ return cls(
106
+ type=data["type"],
107
+ count=data["count"],
108
+ mean_p=data.get("mean_p"),
109
+ histogram=data.get("histogram"),
110
+ mean_probs=data.get("mean_probs"),
111
+ median_q10=data.get("median_q10"),
112
+ median_q50=data.get("median_q50"),
113
+ median_q90=data.get("median_q90"),
114
+ )
115
+
116
+
117
+ @dataclass(frozen=True)
118
+ class LeaderboardEntry:
119
+ user_id: str
120
+ display_name: str
121
+ total_relative_score: float
122
+ questions_answered: int
123
+ eligible: bool | None = None # 활성 리더보드에만 (동결 스냅샷엔 없음)
124
+ rank: int | None = None # closed 대회의 동결 스냅샷에만
125
+
126
+ @classmethod
127
+ def from_json(cls, data: dict[str, Any]) -> "LeaderboardEntry":
128
+ return cls(
129
+ user_id=data["user_id"],
130
+ display_name=data["display_name"],
131
+ total_relative_score=data["total_relative_score"],
132
+ questions_answered=data["questions_answered"],
133
+ eligible=data.get("eligible"),
134
+ rank=data.get("rank"),
135
+ )
136
+
137
+
138
+ @dataclass(frozen=True)
139
+ class Leaderboard:
140
+ """통산(전역) 또는 대회별 리더보드. closed 대회는 frozen 동결 스냅샷 (§5.2)."""
141
+
142
+ scope: str # 'global' | competition slug
143
+ entries: list[LeaderboardEntry] = field(default_factory=list)
144
+ total_questions: int | None = None
145
+ min_participation: float | None = None
146
+ frozen: bool = False
147
+
148
+ @classmethod
149
+ def from_json(cls, data: dict[str, Any]) -> "Leaderboard":
150
+ scope = data.get("scope") or data.get("competition") or "global"
151
+ return cls(
152
+ scope=scope,
153
+ entries=[LeaderboardEntry.from_json(e) for e in data["entries"]],
154
+ total_questions=data.get("total_questions"),
155
+ min_participation=data.get("min_participation"),
156
+ frozen=data.get("frozen", False),
157
+ )
158
+
159
+
160
+ @dataclass(frozen=True)
161
+ class Submission:
162
+ id: int
163
+ question_id: int
164
+ forecast: dict[str, Any]
165
+ submitted_at: datetime
166
+ team_name: str | None = None
167
+ competition_slug: str | None = None
168
+
169
+ @classmethod
170
+ def from_json(cls, data: dict[str, Any]) -> "Submission":
171
+ return cls(
172
+ id=data["id"],
173
+ question_id=data["question_id"],
174
+ forecast=data["forecast"],
175
+ submitted_at=_dt(data["submitted_at"]),
176
+ team_name=data.get("team_name"),
177
+ competition_slug=data.get("competition_slug"),
178
+ )
179
+
180
+
181
+ @dataclass(frozen=True)
182
+ class DailyScore:
183
+ question_id: int
184
+ snapshot_date: date
185
+ raw_score: float
186
+ relative_score: float
187
+
188
+ @classmethod
189
+ def from_json(cls, data: dict[str, Any]) -> "DailyScore":
190
+ return cls(
191
+ question_id=data["question_id"],
192
+ snapshot_date=date.fromisoformat(data["snapshot_date"]),
193
+ raw_score=data["raw_score"],
194
+ relative_score=data["relative_score"],
195
+ )
196
+
197
+
198
+ @dataclass(frozen=True)
199
+ class MySubmissions:
200
+ submissions: list[Submission]
201
+ daily_scores: list[DailyScore]
202
+ team_submissions: list[Submission] = field(default_factory=list)
203
+
204
+ @classmethod
205
+ def from_json(cls, data: dict[str, Any]) -> "MySubmissions":
206
+ return cls(
207
+ submissions=[Submission.from_json(s) for s in data["submissions"]],
208
+ daily_scores=[DailyScore.from_json(s) for s in data["daily_scores"]],
209
+ team_submissions=[Submission.from_json(s) for s in data.get("team_submissions", [])],
210
+ )
211
+
212
+
213
+ @dataclass(frozen=True)
214
+ class TeamMember:
215
+ display_name: str
216
+ role: str
217
+
218
+ @classmethod
219
+ def from_json(cls, data: dict[str, Any]) -> "TeamMember":
220
+ return cls(display_name=data["display_name"], role=data["role"])
221
+
222
+
223
+ @dataclass(frozen=True)
224
+ class Team:
225
+ """팀 대시보드 뷰 — 팀 키(tk_)로 조회 (§6 GET /teams/{id})."""
226
+
227
+ id: int
228
+ name: str
229
+ competition: dict[str, Any]
230
+ members: list[TeamMember]
231
+ questions: list[Question]
232
+ latest_forecasts: list[dict[str, Any]]
233
+ daily_scores: list[DailyScore]
234
+
235
+ @classmethod
236
+ def from_json(cls, data: dict[str, Any]) -> "Team":
237
+ return cls(
238
+ id=data["id"],
239
+ name=data["name"],
240
+ competition=data["competition"],
241
+ members=[TeamMember.from_json(m) for m in data.get("members", [])],
242
+ questions=[Question.from_json(q) for q in data.get("questions", [])],
243
+ latest_forecasts=list(data.get("latest_forecasts", [])),
244
+ daily_scores=[DailyScore.from_json(s) for s in data.get("daily_scores", [])],
245
+ )
@@ -0,0 +1,47 @@
1
+ [project]
2
+ name = "outcat"
3
+ version = "0.1.0"
4
+ description = "Official Python client for the Outcat forecasting tournament"
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "MIT"
8
+ license-files = ["LICENSE"]
9
+ authors = [{ name = "Outcat" }]
10
+ keywords = ["forecasting", "prediction", "tournament", "calibration", "ai-agents"]
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Intended Audience :: Developers",
14
+ "Intended Audience :: Science/Research",
15
+ "Programming Language :: Python :: 3.12",
16
+ "Topic :: Scientific/Engineering",
17
+ "Typing :: Typed",
18
+ ]
19
+ dependencies = ["httpx>=0.27"]
20
+
21
+ [project.urls]
22
+ Homepage = "https://outcat.ai"
23
+ Documentation = "https://outcat.ai/docs"
24
+ Repository = "https://github.com/medicalissue/outcat"
25
+
26
+ [dependency-groups]
27
+ dev = [
28
+ "pytest>=8.0",
29
+ "ruff>=0.6",
30
+ ]
31
+
32
+ [build-system]
33
+ requires = ["hatchling"]
34
+ build-backend = "hatchling.build"
35
+
36
+ [tool.hatch.build.targets.wheel]
37
+ packages = ["outcat"]
38
+
39
+ [tool.pytest.ini_options]
40
+ testpaths = ["tests"]
41
+
42
+ [tool.ruff]
43
+ line-length = 100
44
+ target-version = "py312"
45
+
46
+ [tool.ruff.lint]
47
+ select = ["E", "F", "I", "UP", "B"]
@@ -0,0 +1,326 @@
1
+ """Outcat SDK 테스트 — httpx MockTransport, 서버 불필요 (§11)."""
2
+
3
+ import json
4
+ from datetime import UTC, datetime
5
+
6
+ import httpx
7
+ import pytest
8
+
9
+ from outcat import Outcat, OutcatError
10
+
11
+ QUESTION = {
12
+ "id": 42,
13
+ "title": "Will it rain?",
14
+ "description": "",
15
+ "type": "binary",
16
+ "options": None,
17
+ "domain": "kr",
18
+ "open_at": "2026-06-01T00:00:00Z",
19
+ "lock_at": "2026-06-30T00:00:00Z",
20
+ "resolve_by": "2026-07-01T00:00:00Z",
21
+ "resolution_source": "KMA",
22
+ "resolution_criteria": "Any precipitation",
23
+ "baseline": None,
24
+ "status": "open",
25
+ "outcome": None,
26
+ }
27
+
28
+
29
+ def make_client(handler) -> Outcat:
30
+ transport = httpx.MockTransport(handler)
31
+ return Outcat(api_key="ok_test", base_url="http://api.test", transport=transport)
32
+
33
+
34
+ def json_response(body: dict, status: int = 200) -> httpx.Response:
35
+ return httpx.Response(status, json=body)
36
+
37
+
38
+ class TestQuestions:
39
+ def test_list_with_filters(self):
40
+ seen = {}
41
+
42
+ def handler(request: httpx.Request) -> httpx.Response:
43
+ seen["path"] = request.url.path
44
+ seen["params"] = dict(request.url.params)
45
+ return json_response({"questions": [QUESTION]})
46
+
47
+ questions = make_client(handler).questions(
48
+ status="open", domain="kr", competition="season-0"
49
+ )
50
+ assert seen["path"] == "/questions"
51
+ assert seen["params"] == {
52
+ "status": "open",
53
+ "domain": "kr",
54
+ "competition": "season-0",
55
+ }
56
+ assert questions[0].id == 42
57
+ assert questions[0].lock_at == datetime(2026, 6, 30, tzinfo=UTC)
58
+
59
+ def test_get_one(self):
60
+ client = make_client(lambda r: json_response(QUESTION))
61
+ question = client.question(42)
62
+ assert question.title == "Will it rain?"
63
+ assert question.status == "open"
64
+
65
+ def test_no_api_key_needed_for_reads(self):
66
+ def handler(request: httpx.Request) -> httpx.Response:
67
+ assert "X-API-Key" not in request.headers
68
+ return json_response({"questions": []})
69
+
70
+ client = Outcat(base_url="http://api.test", transport=httpx.MockTransport(handler))
71
+ assert client.questions() == []
72
+
73
+
74
+ class TestSubmit:
75
+ def test_binary(self):
76
+ seen = {}
77
+
78
+ def handler(request: httpx.Request) -> httpx.Response:
79
+ seen["body"] = json.loads(request.content)
80
+ seen["key"] = request.headers.get("X-API-Key")
81
+ return json_response({"accepted_at": "2026-06-13T00:00:00Z", "effective": True})
82
+
83
+ receipt = make_client(handler).submit(42, p=0.7)
84
+ assert seen["body"] == {"question_id": 42, "forecast": {"p": 0.7}}
85
+ assert seen["key"] == "ok_test"
86
+ assert receipt.effective is True
87
+ assert receipt.accepted_at.tzinfo is not None
88
+
89
+ def test_multi(self):
90
+ seen = {}
91
+
92
+ def handler(request: httpx.Request) -> httpx.Response:
93
+ seen["body"] = json.loads(request.content)
94
+ return json_response({"accepted_at": "2026-06-13T00:00:00Z", "effective": True})
95
+
96
+ make_client(handler).submit(42, probs=[0.5, 0.3, 0.2])
97
+ assert seen["body"]["forecast"] == {"probs": [0.5, 0.3, 0.2]}
98
+
99
+ def test_numeric(self):
100
+ seen = {}
101
+
102
+ def handler(request: httpx.Request) -> httpx.Response:
103
+ seen["body"] = json.loads(request.content)
104
+ return json_response({"accepted_at": "2026-06-13T00:00:00Z", "effective": True})
105
+
106
+ make_client(handler).submit(42, q10=2.1, q50=2.8, q90=3.6)
107
+ assert seen["body"]["forecast"] == {"q10": 2.1, "q50": 2.8, "q90": 3.6}
108
+
109
+ def test_exactly_one_forecast_kind_required(self):
110
+ client = make_client(lambda r: json_response({}))
111
+ with pytest.raises(ValueError):
112
+ client.submit(42) # 아무것도 없음
113
+ with pytest.raises(ValueError):
114
+ client.submit(42, p=0.5, probs=[0.5, 0.5]) # 두 종류
115
+ with pytest.raises(ValueError):
116
+ client.submit(42, q10=1.0, q50=2.0) # numeric 불완전
117
+
118
+ def test_submit_requires_api_key(self):
119
+ client = Outcat(
120
+ base_url="http://api.test", transport=httpx.MockTransport(lambda r: json_response({}))
121
+ )
122
+ with pytest.raises(OutcatError) as exc:
123
+ client.submit(42, p=0.5)
124
+ assert exc.value.code == "MISSING_API_KEY"
125
+
126
+
127
+ class TestErrors:
128
+ def test_api_error_mapped(self):
129
+ def handler(request: httpx.Request) -> httpx.Response:
130
+ return json_response(
131
+ {"error": {"code": "QUESTION_LOCKED", "message": "locked"}}, status=422
132
+ )
133
+
134
+ with pytest.raises(OutcatError) as exc:
135
+ make_client(handler).submit(42, p=0.5)
136
+ assert exc.value.code == "QUESTION_LOCKED"
137
+ assert exc.value.status_code == 422
138
+ assert "locked" in str(exc.value)
139
+
140
+ def test_rate_limit_message(self):
141
+ def handler(request: httpx.Request) -> httpx.Response:
142
+ return json_response(
143
+ {"error": {"code": "RATE_LIMITED", "message": "max 60 per minute"}},
144
+ status=429,
145
+ )
146
+
147
+ with pytest.raises(OutcatError) as exc:
148
+ make_client(handler).submit(42, p=0.5)
149
+ assert exc.value.status_code == 429
150
+ assert "rate limit" in str(exc.value).lower()
151
+
152
+ def test_non_json_error(self):
153
+ def handler(request: httpx.Request) -> httpx.Response:
154
+ return httpx.Response(502, text="Bad Gateway")
155
+
156
+ with pytest.raises(OutcatError) as exc:
157
+ make_client(handler).question(42)
158
+ assert exc.value.status_code == 502
159
+
160
+
161
+ class TestCompetitions:
162
+ def test_list(self):
163
+ body = {
164
+ "competitions": [
165
+ {
166
+ "slug": "season-0",
167
+ "name": "Season 0",
168
+ "description": "",
169
+ "prize": "$1,000",
170
+ "visibility": "public",
171
+ "starts_at": "2026-06-01T00:00:00Z",
172
+ "ends_at": "2026-08-01T00:00:00Z",
173
+ "status": "active",
174
+ "question_count": 12,
175
+ "entry_count": 30,
176
+ }
177
+ ]
178
+ }
179
+ comps = make_client(lambda r: json_response(body)).competitions()
180
+ assert comps[0].slug == "season-0"
181
+ assert comps[0].prize == "$1,000"
182
+ assert comps[0].question_count == 12
183
+
184
+ def test_submit_with_team_key(self):
185
+ # 팀 키(tk_)도 동일한 submit 경로 — 서버가 대회/풀 분기를 처리
186
+ seen = {}
187
+
188
+ def handler(request: httpx.Request) -> httpx.Response:
189
+ seen["key"] = request.headers.get("X-API-Key")
190
+ return json_response({"accepted_at": "2026-06-13T00:00:00Z", "effective": True})
191
+
192
+ client = Outcat(
193
+ api_key="tk_team", base_url="http://api.test", transport=httpx.MockTransport(handler)
194
+ )
195
+ client.submit(42, p=0.6)
196
+ assert seen["key"] == "tk_team"
197
+
198
+
199
+ class TestOtherEndpoints:
200
+ def test_global_leaderboard(self):
201
+ body = {
202
+ "scope": "global",
203
+ "total_questions": 10,
204
+ "min_participation": 0.7,
205
+ "entries": [
206
+ {
207
+ "user_id": "u1",
208
+ "display_name": "alice",
209
+ "total_relative_score": 1.5,
210
+ "questions_answered": 9,
211
+ "eligible": True,
212
+ }
213
+ ],
214
+ }
215
+ board = make_client(lambda r: json_response(body)).leaderboard()
216
+ assert board.scope == "global"
217
+ assert board.entries[0].display_name == "alice"
218
+ assert board.entries[0].eligible is True
219
+
220
+ def test_competition_leaderboard_path(self):
221
+ seen = {}
222
+
223
+ def handler(request: httpx.Request) -> httpx.Response:
224
+ seen["path"] = request.url.path
225
+ return json_response(
226
+ {"competition": "season-0", "status": "active", "frozen": False, "entries": []}
227
+ )
228
+
229
+ board = make_client(handler).leaderboard(competition="season-0")
230
+ assert seen["path"] == "/competitions/season-0/leaderboard"
231
+ assert board.scope == "season-0"
232
+
233
+ def test_distribution(self):
234
+ body = {"type": "binary", "count": 2, "mean_p": 0.6, "histogram": [0] * 10}
235
+ dist = make_client(lambda r: json_response(body)).distribution(42)
236
+ assert dist.count == 2
237
+ assert dist.mean_p == 0.6
238
+
239
+ def test_my_submissions(self):
240
+ body = {
241
+ "submissions": [
242
+ {
243
+ "id": 1,
244
+ "question_id": 42,
245
+ "forecast": {"p": 0.7},
246
+ "submitted_at": "2026-06-13T00:00:00Z",
247
+ }
248
+ ],
249
+ "daily_scores": [
250
+ {
251
+ "question_id": 42,
252
+ "snapshot_date": "2026-06-13",
253
+ "raw_score": -0.35,
254
+ "relative_score": 0.1,
255
+ }
256
+ ],
257
+ }
258
+ me = make_client(lambda r: json_response(body)).my_submissions()
259
+ assert me.submissions[0].forecast == {"p": 0.7}
260
+ assert me.daily_scores[0].relative_score == 0.1
261
+
262
+
263
+ class TestTeams:
264
+ def test_question_carries_competitions(self):
265
+ q = {**QUESTION, "competitions": ["season-0"]}
266
+ question = make_client(lambda r: json_response(q)).question(42)
267
+ assert question.competitions == ["season-0"]
268
+
269
+ def test_question_competitions_default_empty(self):
270
+ question = make_client(lambda r: json_response(QUESTION)).question(42)
271
+ assert question.competitions == []
272
+
273
+ def test_team_dashboard(self):
274
+ body = {
275
+ "id": 5,
276
+ "name": "cats",
277
+ "competition": {"slug": "season-0", "name": "Season 0", "status": "active"},
278
+ "members": [{"display_name": "alice", "role": "leader"}],
279
+ "questions": [QUESTION],
280
+ "latest_forecasts": [],
281
+ "daily_scores": [],
282
+ }
283
+ seen = {}
284
+
285
+ def handler(request: httpx.Request) -> httpx.Response:
286
+ seen["path"] = request.url.path
287
+ seen["key"] = request.headers.get("X-API-Key")
288
+ return json_response(body)
289
+
290
+ client = Outcat(
291
+ api_key="tk_team", base_url="http://api.test", transport=httpx.MockTransport(handler)
292
+ )
293
+ team = client.team(5)
294
+ assert seen["path"] == "/teams/5"
295
+ assert seen["key"] == "tk_team"
296
+ assert team.name == "cats"
297
+ assert team.members[0].role == "leader"
298
+ assert team.questions[0].id == 42
299
+
300
+ def test_my_team_found(self):
301
+ body = {"team": {"id": 5, "name": "cats"}}
302
+ team = make_client(lambda r: json_response(body)).my_team(competition="season-0")
303
+ assert team == {"id": 5, "name": "cats"}
304
+
305
+ def test_my_team_none(self):
306
+ team = make_client(lambda r: json_response({"team": None})).my_team(competition="season-0")
307
+ assert team is None
308
+
309
+ def test_my_submissions_includes_team(self):
310
+ body = {
311
+ "submissions": [],
312
+ "daily_scores": [],
313
+ "team_submissions": [
314
+ {
315
+ "id": 9,
316
+ "question_id": 42,
317
+ "forecast": {"p": 0.7},
318
+ "submitted_at": "2026-06-13T00:00:00Z",
319
+ "team_name": "cats",
320
+ "competition_slug": "season-0",
321
+ }
322
+ ],
323
+ }
324
+ me = make_client(lambda r: json_response(body)).my_submissions()
325
+ assert me.team_submissions[0].team_name == "cats"
326
+ assert me.team_submissions[0].competition_slug == "season-0"
outcat-0.1.0/uv.lock ADDED
@@ -0,0 +1,189 @@
1
+ version = 1
2
+ revision = 3
3
+ requires-python = ">=3.12"
4
+
5
+ [[package]]
6
+ name = "anyio"
7
+ version = "4.13.0"
8
+ source = { registry = "https://pypi.org/simple" }
9
+ dependencies = [
10
+ { name = "idna" },
11
+ { name = "typing-extensions", marker = "python_full_version < '3.13'" },
12
+ ]
13
+ sdist = { url = "https://files.pythonhosted.org/packages/19/14/2c5dd9f512b66549ae92767a9c7b330ae88e1932ca57876909410251fe13/anyio-4.13.0.tar.gz", hash = "sha256:334b70e641fd2221c1505b3890c69882fe4a2df910cba14d97019b90b24439dc", size = 231622, upload-time = "2026-03-24T12:59:09.671Z" }
14
+ wheels = [
15
+ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353, upload-time = "2026-03-24T12:59:08.246Z" },
16
+ ]
17
+
18
+ [[package]]
19
+ name = "certifi"
20
+ version = "2026.5.20"
21
+ source = { registry = "https://pypi.org/simple" }
22
+ sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
23
+ wheels = [
24
+ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
25
+ ]
26
+
27
+ [[package]]
28
+ name = "colorama"
29
+ version = "0.4.6"
30
+ source = { registry = "https://pypi.org/simple" }
31
+ sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
32
+ wheels = [
33
+ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
34
+ ]
35
+
36
+ [[package]]
37
+ name = "h11"
38
+ version = "0.16.0"
39
+ source = { registry = "https://pypi.org/simple" }
40
+ sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" }
41
+ wheels = [
42
+ { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" },
43
+ ]
44
+
45
+ [[package]]
46
+ name = "httpcore"
47
+ version = "1.0.9"
48
+ source = { registry = "https://pypi.org/simple" }
49
+ dependencies = [
50
+ { name = "certifi" },
51
+ { name = "h11" },
52
+ ]
53
+ sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" }
54
+ wheels = [
55
+ { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
56
+ ]
57
+
58
+ [[package]]
59
+ name = "httpx"
60
+ version = "0.28.1"
61
+ source = { registry = "https://pypi.org/simple" }
62
+ dependencies = [
63
+ { name = "anyio" },
64
+ { name = "certifi" },
65
+ { name = "httpcore" },
66
+ { name = "idna" },
67
+ ]
68
+ sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" }
69
+ wheels = [
70
+ { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" },
71
+ ]
72
+
73
+ [[package]]
74
+ name = "idna"
75
+ version = "3.18"
76
+ source = { registry = "https://pypi.org/simple" }
77
+ sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
78
+ wheels = [
79
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
80
+ ]
81
+
82
+ [[package]]
83
+ name = "iniconfig"
84
+ version = "2.3.0"
85
+ source = { registry = "https://pypi.org/simple" }
86
+ sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" }
87
+ wheels = [
88
+ { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
89
+ ]
90
+
91
+ [[package]]
92
+ name = "outcat"
93
+ version = "0.1.0"
94
+ source = { editable = "." }
95
+ dependencies = [
96
+ { name = "httpx" },
97
+ ]
98
+
99
+ [package.dev-dependencies]
100
+ dev = [
101
+ { name = "pytest" },
102
+ { name = "ruff" },
103
+ ]
104
+
105
+ [package.metadata]
106
+ requires-dist = [{ name = "httpx", specifier = ">=0.27" }]
107
+
108
+ [package.metadata.requires-dev]
109
+ dev = [
110
+ { name = "pytest", specifier = ">=8.0" },
111
+ { name = "ruff", specifier = ">=0.6" },
112
+ ]
113
+
114
+ [[package]]
115
+ name = "packaging"
116
+ version = "26.2"
117
+ source = { registry = "https://pypi.org/simple" }
118
+ sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
119
+ wheels = [
120
+ { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
121
+ ]
122
+
123
+ [[package]]
124
+ name = "pluggy"
125
+ version = "1.6.0"
126
+ source = { registry = "https://pypi.org/simple" }
127
+ sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" }
128
+ wheels = [
129
+ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" },
130
+ ]
131
+
132
+ [[package]]
133
+ name = "pygments"
134
+ version = "2.20.0"
135
+ source = { registry = "https://pypi.org/simple" }
136
+ sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" }
137
+ wheels = [
138
+ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" },
139
+ ]
140
+
141
+ [[package]]
142
+ name = "pytest"
143
+ version = "9.0.3"
144
+ source = { registry = "https://pypi.org/simple" }
145
+ dependencies = [
146
+ { name = "colorama", marker = "sys_platform == 'win32'" },
147
+ { name = "iniconfig" },
148
+ { name = "packaging" },
149
+ { name = "pluggy" },
150
+ { name = "pygments" },
151
+ ]
152
+ sdist = { url = "https://files.pythonhosted.org/packages/7d/0d/549bd94f1a0a402dc8cf64563a117c0f3765662e2e668477624baeec44d5/pytest-9.0.3.tar.gz", hash = "sha256:b86ada508af81d19edeb213c681b1d48246c1a91d304c6c81a427674c17eb91c", size = 1572165, upload-time = "2026-04-07T17:16:18.027Z" }
153
+ wheels = [
154
+ { url = "https://files.pythonhosted.org/packages/d4/24/a372aaf5c9b7208e7112038812994107bc65a84cd00e0354a88c2c77a617/pytest-9.0.3-py3-none-any.whl", hash = "sha256:2c5efc453d45394fdd706ade797c0a81091eccd1d6e4bccfcd476e2b8e0ab5d9", size = 375249, upload-time = "2026-04-07T17:16:16.13Z" },
155
+ ]
156
+
157
+ [[package]]
158
+ name = "ruff"
159
+ version = "0.15.17"
160
+ source = { registry = "https://pypi.org/simple" }
161
+ sdist = { url = "https://files.pythonhosted.org/packages/8c/a9/3abdf488f1bf3d24c699415e454ed554a6350d5d89ce183be1ee0a3361ac/ruff-0.15.17.tar.gz", hash = "sha256:2ec446937fd16c8c4de2674a209cc5af64d9c6f17d21fbf1151054fa0bcf5219", size = 4743346, upload-time = "2026-06-11T17:54:47.663Z" }
162
+ wheels = [
163
+ { url = "https://files.pythonhosted.org/packages/db/4d/e11259f5da07cb6afb2d074c31bf09da9671993f7329d4f15d2fdc458301/ruff-0.15.17-py3-none-linux_armv6l.whl", hash = "sha256:d9feddb927fc68bd295f5eebc587a7e42cfaf9b65f60ca4a2386febff575da8f", size = 10856677, upload-time = "2026-06-11T17:54:49.533Z" },
164
+ { url = "https://files.pythonhosted.org/packages/29/3e/772d679e1a0dc058e58875bd2c0cb713a0530877b4a76fee3c7966df0d49/ruff-0.15.17-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:25805a226d741c47d274a35ad5c10a7dde175fcddfa511d7cf3da0a21eb3eab7", size = 11223443, upload-time = "2026-06-11T17:55:00.573Z" },
165
+ { url = "https://files.pythonhosted.org/packages/68/58/bd41f7688b2fd5623012605130ed70e60aa7f2244baa3d5066bdd61530c8/ruff-0.15.17-py3-none-macosx_11_0_arm64.whl", hash = "sha256:f6ad73b14c2d18a3bf8ad7cb6974294d7f613a7898604826058e6ac64918ef4d", size = 10566458, upload-time = "2026-06-11T17:55:07.52Z" },
166
+ { url = "https://files.pythonhosted.org/packages/d8/5b/733371013fcf1ec339e477ece6ab42bfe10bdd9bba8ee88a9516aa56bfc0/ruff-0.15.17-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6ba0c1e4f95bcb3869d0d30cbd5917071ef2e28665abfec970cdab0492c713ed", size = 10914483, upload-time = "2026-06-11T17:55:05.501Z" },
167
+ { url = "https://files.pythonhosted.org/packages/bd/cc/6f24251cc0252f7239391ccb85833f320efad14ebe5b443943f37ced6332/ruff-0.15.17-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:81647960f10bff57d2e51cadd0c3950fe598400c852863a038720ef5b8cca91e", size = 10647497, upload-time = "2026-06-11T17:54:57.733Z" },
168
+ { url = "https://files.pythonhosted.org/packages/68/dd/0d10c17ce1a1624d6fc3156309c3f834fdb5dfaad026ec90c85684f3990e/ruff-0.15.17-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0e01a84ddbc8c16c23055ba3924476850f1bbc1917cebbb9376665a63e74260d", size = 11416967, upload-time = "2026-06-11T17:54:51.461Z" },
169
+ { url = "https://files.pythonhosted.org/packages/2f/91/556bfb156f6144f355e831c23db00b2fc4120f86b3ce81cc5f7fd2df51f3/ruff-0.15.17-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:84fe9f653152f8f294f9f7e03bf3a453d8b4a27f7a59c78c8666167f2b17b96c", size = 12335770, upload-time = "2026-06-11T17:54:45.793Z" },
170
+ { url = "https://files.pythonhosted.org/packages/88/82/8b5999aa13355e926f06d9f42a32dcca862f623bf0363785ff89d607dffd/ruff-0.15.17-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c0fe88a7676e7a05b73174d4d4a59cb2ac21ff8263583f87a81a6018475a978", size = 11575441, upload-time = "2026-06-11T17:54:32.661Z" },
171
+ { url = "https://files.pythonhosted.org/packages/11/93/f10377bb04109ca0e8cbc483ff1982c54b6d418210041776f93e8cdc7fa9/ruff-0.15.17-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecfc3c7878fff94633ab0348524e093f9ce3243080416dd7d14f8ba400174719", size = 11557614, upload-time = "2026-06-11T17:54:34.698Z" },
172
+ { url = "https://files.pythonhosted.org/packages/c7/a6/eeeae7f7d5493df41649ab3db92f086b2d0a30199e4efdf8e3dd7a033f24/ruff-0.15.17-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:b8461180b22420b1bdc289909410930761629fddf2a5aaf60fae1ab26cedc4c4", size = 11544450, upload-time = "2026-06-11T17:54:39.042Z" },
173
+ { url = "https://files.pythonhosted.org/packages/32/88/5991ce565129a24dd4a00db1254b3b5db2e53018cbe4018ea5a89738e727/ruff-0.15.17-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:6eccbe50a038b503e7140b441aa9c7fc8c1f36edf23ebef9f4165c2f28f568b7", size = 10892524, upload-time = "2026-06-11T17:55:09.432Z" },
174
+ { url = "https://files.pythonhosted.org/packages/f5/1d/0fdd248313425f55223968af04b0a42125466a8d88d21c1d99c6af0a51e8/ruff-0.15.17-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:382fc0521025f5a8ad447d8bdd523545d0d7646adb718eb1c2dac5065ec27c0f", size = 10659573, upload-time = "2026-06-11T17:54:36.824Z" },
175
+ { url = "https://files.pythonhosted.org/packages/9e/0e/072e8260deb9461062ce9311ced27a8e541229a6ffd483013dd37661e43e/ruff-0.15.17-py3-none-musllinux_1_2_i686.whl", hash = "sha256:456d41fcd1b2777ad63f09a6e7121d43f7b688bbc76a800c10f7f8fb1f912c3f", size = 11127818, upload-time = "2026-06-11T17:55:03.124Z" },
176
+ { url = "https://files.pythonhosted.org/packages/ab/b4/55060a34163121498014696b5f656db5b8c6963768f227dbf0d76b311073/ruff-0.15.17-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:b1a04bcc94ae6194e9db05d16ad31f298a7194bfbcb08258bbe589cee1d587b8", size = 11655901, upload-time = "2026-06-11T17:54:53.562Z" },
177
+ { url = "https://files.pythonhosted.org/packages/49/71/9b29d6b87cef468d697f43c6a91e3fae4a80185779d7d5a4ef27d173439f/ruff-0.15.17-py3-none-win32.whl", hash = "sha256:596065960ab1ff593f744220c9fe6580eda00a95003cffa9f4048bb5b1bf0392", size = 10925574, upload-time = "2026-06-11T17:54:55.723Z" },
178
+ { url = "https://files.pythonhosted.org/packages/3d/b2/8fc77f3723228836fa5d12497eb71c808f83782e10d058d2b15cfa14640b/ruff-0.15.17-py3-none-win_amd64.whl", hash = "sha256:6769e5fa1710b179b92e0bfa5a51735b35baea9013dadb06d5f44cbcf9547084", size = 12058788, upload-time = "2026-06-11T17:54:41.042Z" },
179
+ { url = "https://files.pythonhosted.org/packages/2d/c7/c53e8dbff9c9dc4b7928773421ae294a5d28fcb8dcda1a089579d3a7e510/ruff-0.15.17-py3-none-win_arm64.whl", hash = "sha256:f3be1fbb34bcdfd146240d8fb92a709d4c2c8191348580a3c044ec60fa0b4456", size = 11355275, upload-time = "2026-06-11T17:54:43.635Z" },
180
+ ]
181
+
182
+ [[package]]
183
+ name = "typing-extensions"
184
+ version = "4.15.0"
185
+ source = { registry = "https://pypi.org/simple" }
186
+ sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" }
187
+ wheels = [
188
+ { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
189
+ ]