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.
- outcat-0.1.0/.gitignore +10 -0
- outcat-0.1.0/LICENSE +21 -0
- outcat-0.1.0/PKG-INFO +78 -0
- outcat-0.1.0/README.md +57 -0
- outcat-0.1.0/outcat/__init__.py +34 -0
- outcat-0.1.0/outcat/client.py +196 -0
- outcat-0.1.0/outcat/models.py +245 -0
- outcat-0.1.0/pyproject.toml +47 -0
- outcat-0.1.0/tests/test_client.py +326 -0
- outcat-0.1.0/uv.lock +189 -0
outcat-0.1.0/.gitignore
ADDED
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
|
+
]
|