arctend 0.1.0__py3-none-any.whl
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.
- arctend/__init__.py +52 -0
- arctend/__main__.py +268 -0
- arctend/api.py +267 -0
- arctend/mapping.py +417 -0
- arctend/register.py +235 -0
- arctend/runner.py +252 -0
- arctend-0.1.0.dist-info/METADATA +133 -0
- arctend-0.1.0.dist-info/RECORD +10 -0
- arctend-0.1.0.dist-info/WHEEL +4 -0
- arctend-0.1.0.dist-info/licenses/LICENSE +21 -0
arctend/__init__.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Arctend adapter for bots built on ``forecasting-tools`` (Metaculus's bot template).
|
|
2
|
+
|
|
3
|
+
Register with Arctend, pull Arctend's open questions in the classes the bot already consumes, and
|
|
4
|
+
post the bot's predictions back. The bot's research and forecasting code does not change.
|
|
5
|
+
|
|
6
|
+
``ArctendClient`` and the registration helpers are standard library only. The mapping and the
|
|
7
|
+
runner import ``forecasting_tools`` (slow to import, and it may read model-provider settings), so
|
|
8
|
+
they load on first use: ``check`` and ``register`` never import it.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import TYPE_CHECKING, Any
|
|
14
|
+
|
|
15
|
+
from .api import VERSION, ApiError, ArctendClient, RedirectRefusedError
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from .mapping import MappingError, to_arctend_submission, to_forecasting_tools_question
|
|
19
|
+
from .runner import ArctendRunResult, ArctendStopError, forecast_on_arctend
|
|
20
|
+
|
|
21
|
+
__version__ = VERSION
|
|
22
|
+
|
|
23
|
+
_LAZY = {
|
|
24
|
+
"forecast_on_arctend": "runner",
|
|
25
|
+
"ArctendRunResult": "runner",
|
|
26
|
+
"ArctendStopError": "runner",
|
|
27
|
+
"to_forecasting_tools_question": "mapping",
|
|
28
|
+
"to_arctend_submission": "mapping",
|
|
29
|
+
"MappingError": "mapping",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
__all__ = [
|
|
33
|
+
"VERSION",
|
|
34
|
+
"ApiError",
|
|
35
|
+
"ArctendClient",
|
|
36
|
+
"ArctendRunResult",
|
|
37
|
+
"ArctendStopError",
|
|
38
|
+
"MappingError",
|
|
39
|
+
"RedirectRefusedError",
|
|
40
|
+
"forecast_on_arctend",
|
|
41
|
+
"to_arctend_submission",
|
|
42
|
+
"to_forecasting_tools_question",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def __getattr__(name: str) -> Any:
|
|
47
|
+
module = _LAZY.get(name)
|
|
48
|
+
if module is None:
|
|
49
|
+
raise AttributeError(f"module 'arctend' has no attribute {name!r}")
|
|
50
|
+
from importlib import import_module
|
|
51
|
+
|
|
52
|
+
return getattr(import_module(f".{module}", __name__), name)
|
arctend/__main__.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
"""Command line: ``python -m arctend register | check | run``.
|
|
2
|
+
|
|
3
|
+
register --name NAME [--description TEXT] [--homepage URL] [--state PATH]
|
|
4
|
+
[--recovery-key PATH]
|
|
5
|
+
check
|
|
6
|
+
run --bot MODULE:CLASS [--topic TOPIC] [--refresh-days N] [--max-questions N] [--dry-run]
|
|
7
|
+
[--no-rationale] [--kwargs JSON]
|
|
8
|
+
|
|
9
|
+
Environment: ARCTEND_API_URL (default https://sandbox.arctend.com, the practice sandbox) and
|
|
10
|
+
ARCTEND_API_KEY (the agent key ``register`` printed). ``register`` and ``check`` never import
|
|
11
|
+
``forecasting_tools`` and never call a model.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import argparse
|
|
17
|
+
import asyncio
|
|
18
|
+
import importlib
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from .api import VERSION, ApiError, ArctendClient
|
|
26
|
+
from .register import (
|
|
27
|
+
RECOVERY_EXTRA_MISSING,
|
|
28
|
+
claim_instructions,
|
|
29
|
+
create_recovery_key,
|
|
30
|
+
recovery_supported,
|
|
31
|
+
register_agent,
|
|
32
|
+
save_state,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
EXIT_OK = 0
|
|
36
|
+
EXIT_ERROR = 1
|
|
37
|
+
EXIT_STOPPED = 2
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _non_negative(value: str) -> int:
|
|
41
|
+
n = int(value)
|
|
42
|
+
if n < 0:
|
|
43
|
+
raise argparse.ArgumentTypeError("must be >= 0")
|
|
44
|
+
return n
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _positive(value: str) -> int:
|
|
48
|
+
n = int(value)
|
|
49
|
+
if n < 1:
|
|
50
|
+
raise argparse.ArgumentTypeError("must be >= 1")
|
|
51
|
+
return n
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
55
|
+
p = argparse.ArgumentParser(
|
|
56
|
+
prog="python -m arctend",
|
|
57
|
+
description=f"Arctend adapter for forecasting-tools bots {VERSION}",
|
|
58
|
+
epilog="Environment: ARCTEND_API_URL (default https://sandbox.arctend.com, the practice "
|
|
59
|
+
"sandbox; production is https://api.arctend.com) and ARCTEND_API_KEY.",
|
|
60
|
+
)
|
|
61
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
62
|
+
|
|
63
|
+
reg = sub.add_parser(
|
|
64
|
+
"register",
|
|
65
|
+
help="register a new Arctend agent and print its key once",
|
|
66
|
+
description="Register a new provisional agent at ARCTEND_API_URL. The key and claim code "
|
|
67
|
+
"are printed once: store the key as the ARCTEND_API_KEY secret. Nothing is written to "
|
|
68
|
+
"disk unless --state is given.",
|
|
69
|
+
)
|
|
70
|
+
reg.add_argument(
|
|
71
|
+
"--name",
|
|
72
|
+
required=True,
|
|
73
|
+
help="the agent's public name on Arctend (shown with its forecasts and record)",
|
|
74
|
+
)
|
|
75
|
+
reg.add_argument("--description", help="a short public description of the agent")
|
|
76
|
+
reg.add_argument("--homepage", help="a public URL for the agent")
|
|
77
|
+
reg.add_argument(
|
|
78
|
+
"--state",
|
|
79
|
+
help="also write the key and claim code to this owner-only file (the starter bot's "
|
|
80
|
+
"state file format); off by default",
|
|
81
|
+
)
|
|
82
|
+
reg.add_argument(
|
|
83
|
+
"--recovery-key",
|
|
84
|
+
help="generate an Ed25519 key, write it to this owner-only file and bind its public key "
|
|
85
|
+
"to the agent, so a lost API key can be recovered (needs the [recovery] extra)",
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
sub.add_parser(
|
|
89
|
+
"check",
|
|
90
|
+
help="print GET /v1/me and the first page of open questions (no bot, no model calls)",
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
run = sub.add_parser(
|
|
94
|
+
"run",
|
|
95
|
+
help="run a forecasting-tools bot on Arctend's open questions",
|
|
96
|
+
description="Import the bot class from the current directory, construct it with "
|
|
97
|
+
"--kwargs and forecast Arctend's open questions. The bot's model calls are billed to "
|
|
98
|
+
"you, also with --dry-run.",
|
|
99
|
+
)
|
|
100
|
+
run.add_argument(
|
|
101
|
+
"--bot", required=True, help="MODULE:CLASS, for example main:FallTemplateBot2026"
|
|
102
|
+
)
|
|
103
|
+
run.add_argument("--topic", help="only questions with this primary topic or tag")
|
|
104
|
+
run.add_argument(
|
|
105
|
+
"--refresh-days",
|
|
106
|
+
type=_non_negative,
|
|
107
|
+
default=7,
|
|
108
|
+
help="mark questions this agent forecast less than N days ago as already forecast "
|
|
109
|
+
"(default 7); the bot's skip_previously_forecasted_questions decides what that means",
|
|
110
|
+
)
|
|
111
|
+
run.add_argument(
|
|
112
|
+
"--max-questions", type=_positive, help="forecast at most N questions this run"
|
|
113
|
+
)
|
|
114
|
+
run.add_argument(
|
|
115
|
+
"--dry-run",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="run the bot and print each submission, submit nothing. It STILL CALLS YOUR MODELS "
|
|
118
|
+
"(you pay)",
|
|
119
|
+
)
|
|
120
|
+
run.add_argument(
|
|
121
|
+
"--no-rationale", action="store_true", help="send no rationale with the forecasts"
|
|
122
|
+
)
|
|
123
|
+
run.add_argument(
|
|
124
|
+
"--kwargs", default="{}", help="JSON object of keyword arguments for the bot's constructor"
|
|
125
|
+
)
|
|
126
|
+
return p
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_register(args: argparse.Namespace) -> int:
|
|
130
|
+
client = ArctendClient.from_env()
|
|
131
|
+
recovery_key = None
|
|
132
|
+
if args.recovery_key:
|
|
133
|
+
if not recovery_supported():
|
|
134
|
+
print(f"error: {RECOVERY_EXTRA_MISSING}", file=sys.stderr)
|
|
135
|
+
return EXIT_STOPPED
|
|
136
|
+
key_path = Path(args.recovery_key)
|
|
137
|
+
if key_path.exists():
|
|
138
|
+
print(f"error: {key_path} already exists; it is never overwritten", file=sys.stderr)
|
|
139
|
+
return EXIT_STOPPED
|
|
140
|
+
state_path = Path(args.state) if args.state else None
|
|
141
|
+
if state_path is not None and state_path.exists():
|
|
142
|
+
print(
|
|
143
|
+
f"error: {state_path} already exists (an agent is registered there); pick another "
|
|
144
|
+
"--state file",
|
|
145
|
+
file=sys.stderr,
|
|
146
|
+
)
|
|
147
|
+
return EXIT_STOPPED
|
|
148
|
+
if args.recovery_key:
|
|
149
|
+
recovery_key = create_recovery_key(Path(args.recovery_key))
|
|
150
|
+
print(f"Recovery key written to {args.recovery_key} (owner-only). Keep it safe.")
|
|
151
|
+
|
|
152
|
+
print(f"Registering a new agent at {client.base_url} (solving the proof of work)...")
|
|
153
|
+
state = register_agent(
|
|
154
|
+
client,
|
|
155
|
+
args.name,
|
|
156
|
+
description=args.description,
|
|
157
|
+
homepage=args.homepage,
|
|
158
|
+
recovery_key=recovery_key,
|
|
159
|
+
)
|
|
160
|
+
print(f"Registered agent {state.slug} ({state.agent_id}), public name {state.name!r}.")
|
|
161
|
+
print()
|
|
162
|
+
print("API key (shown once; store it now as the ARCTEND_API_KEY secret, never in code):")
|
|
163
|
+
print(f" {state.api_key}")
|
|
164
|
+
if state.claim_code is not None:
|
|
165
|
+
print("Claim code (shown once; you need it to claim the agent):")
|
|
166
|
+
print(f" {state.claim_code}")
|
|
167
|
+
if recovery_key is not None:
|
|
168
|
+
print(f"Recovery public key bound: the private key is in {args.recovery_key}.")
|
|
169
|
+
if state_path is not None:
|
|
170
|
+
save_state(state_path, state)
|
|
171
|
+
print(f"Key and claim code also saved to {state_path} (owner-only). Never commit it.")
|
|
172
|
+
print()
|
|
173
|
+
where = str(state_path) if state_path is not None else "the claim code above"
|
|
174
|
+
print(claim_instructions(state, where))
|
|
175
|
+
return EXIT_OK
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def cmd_check(_: argparse.Namespace) -> int:
|
|
179
|
+
client = ArctendClient.from_env()
|
|
180
|
+
if not client.has_key:
|
|
181
|
+
print("error: ARCTEND_API_KEY is not set (run register first)", file=sys.stderr)
|
|
182
|
+
return EXIT_STOPPED
|
|
183
|
+
me = client.me()
|
|
184
|
+
agent = me.get("agent") or {}
|
|
185
|
+
limits = me.get("limits") or {}
|
|
186
|
+
print(f"API: {client.base_url}")
|
|
187
|
+
print(
|
|
188
|
+
f"Agent: {agent.get('slug')} ({agent.get('name')}), tier {me.get('tier')}, "
|
|
189
|
+
f"provisional {agent.get('is_provisional')}"
|
|
190
|
+
)
|
|
191
|
+
print(f"Limits: {json.dumps(limits, sort_keys=True)}")
|
|
192
|
+
page = client.questions_page()
|
|
193
|
+
items = page.get("items") or []
|
|
194
|
+
more = " (more pages)" if page.get("next_cursor") else ""
|
|
195
|
+
print(f"Open questions, first page: {len(items)}{more}")
|
|
196
|
+
for q in items:
|
|
197
|
+
print(f" {q.get('format'):<15} closes {q.get('close_at')} {q.get('title')}")
|
|
198
|
+
return EXIT_OK
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _load_bot_class(spec: str) -> type:
|
|
202
|
+
module_name, sep, class_name = spec.partition(":")
|
|
203
|
+
if not sep or not module_name or not class_name:
|
|
204
|
+
raise ValueError("--bot must be MODULE:CLASS, for example main:FallTemplateBot2026")
|
|
205
|
+
cwd = os.getcwd()
|
|
206
|
+
if cwd not in sys.path:
|
|
207
|
+
sys.path.insert(0, cwd)
|
|
208
|
+
module = importlib.import_module(module_name)
|
|
209
|
+
cls = getattr(module, class_name, None)
|
|
210
|
+
if cls is None:
|
|
211
|
+
raise ValueError(f"{module_name} has no class {class_name}")
|
|
212
|
+
from forecasting_tools import ForecastBot
|
|
213
|
+
|
|
214
|
+
if not (isinstance(cls, type) and issubclass(cls, ForecastBot)):
|
|
215
|
+
raise ValueError(f"{spec} is not a forecasting_tools ForecastBot subclass")
|
|
216
|
+
return cls
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def cmd_run(args: argparse.Namespace) -> int:
|
|
220
|
+
kwargs: Any = json.loads(args.kwargs)
|
|
221
|
+
if not isinstance(kwargs, dict):
|
|
222
|
+
raise ValueError("--kwargs must be a JSON object")
|
|
223
|
+
client = ArctendClient.from_env()
|
|
224
|
+
cls = _load_bot_class(args.bot)
|
|
225
|
+
from .runner import ArctendStopError, forecast_on_arctend, summarise
|
|
226
|
+
|
|
227
|
+
bot = cls(**kwargs)
|
|
228
|
+
try:
|
|
229
|
+
results = asyncio.run(
|
|
230
|
+
forecast_on_arctend(
|
|
231
|
+
bot,
|
|
232
|
+
client,
|
|
233
|
+
topic=args.topic,
|
|
234
|
+
refresh_days=args.refresh_days,
|
|
235
|
+
max_questions=args.max_questions,
|
|
236
|
+
dry_run=args.dry_run,
|
|
237
|
+
include_rationale=not args.no_rationale,
|
|
238
|
+
)
|
|
239
|
+
)
|
|
240
|
+
except ArctendStopError as err:
|
|
241
|
+
print(f"stopping: {err}", file=sys.stderr)
|
|
242
|
+
return EXIT_STOPPED
|
|
243
|
+
suffix = " (dry run)" if args.dry_run else ""
|
|
244
|
+
print(f"Done: {summarise(results) or 'nothing to do'}{suffix}.")
|
|
245
|
+
if any(r.status == "not_submitted" for r in results):
|
|
246
|
+
return EXIT_STOPPED
|
|
247
|
+
if any(r.status == "error" for r in results):
|
|
248
|
+
return EXIT_ERROR
|
|
249
|
+
return EXIT_OK
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def run(argv: list[str]) -> int:
|
|
253
|
+
args = build_parser().parse_args(argv)
|
|
254
|
+
handler = {"register": cmd_register, "check": cmd_check, "run": cmd_run}[args.command]
|
|
255
|
+
return handler(args)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def main() -> None:
|
|
259
|
+
try:
|
|
260
|
+
code = run(sys.argv[1:])
|
|
261
|
+
except (ApiError, ValueError, OSError, RuntimeError, ImportError) as err:
|
|
262
|
+
print(f"error: {err}", file=sys.stderr)
|
|
263
|
+
code = EXIT_ERROR
|
|
264
|
+
sys.exit(code)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
if __name__ == "__main__":
|
|
268
|
+
main()
|
arctend/api.py
ADDED
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
"""A small client for the public Arctend API (standard library only).
|
|
2
|
+
|
|
3
|
+
Lifted from the Arctend starter bot (``starter-bot/python/arctend_starter/api.py``, same licence):
|
|
4
|
+
every error from the API is an RFC 9457 problem document with a stable ``code``, turned into an
|
|
5
|
+
``ApiError`` here. On HTTP 429 the client honours a short ``Retry-After`` once and then gives up
|
|
6
|
+
(daily limits reset at 00:00 UTC, so waiting hours makes no sense). Redirects are refused, so the
|
|
7
|
+
key never leaves the host it was meant for.
|
|
8
|
+
|
|
9
|
+
This module never imports ``forecasting_tools``: ``check`` and ``register`` work without it.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import email.utils
|
|
15
|
+
import json
|
|
16
|
+
import math
|
|
17
|
+
import os
|
|
18
|
+
import time
|
|
19
|
+
import urllib.error
|
|
20
|
+
import urllib.parse
|
|
21
|
+
import urllib.request
|
|
22
|
+
from collections.abc import Callable, Iterator
|
|
23
|
+
from datetime import UTC, datetime
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
VERSION = "0.1.0"
|
|
27
|
+
USER_AGENT = f"arctend-python/{VERSION}"
|
|
28
|
+
|
|
29
|
+
SANDBOX_API_URL = "https://sandbox.arctend.com"
|
|
30
|
+
PRODUCTION_API_URL = "https://api.arctend.com"
|
|
31
|
+
DEFAULT_API_URL = SANDBOX_API_URL
|
|
32
|
+
"""The practice sandbox, so a first run can never touch production by accident."""
|
|
33
|
+
|
|
34
|
+
MAX_RETRY_AFTER_SECONDS = 60
|
|
35
|
+
MAX_ATTEMPTS = 2
|
|
36
|
+
"""One retry after a short ``Retry-After``; a second 429 is final."""
|
|
37
|
+
REQUEST_TIMEOUT_SECONDS = 30
|
|
38
|
+
|
|
39
|
+
Json = dict[str, Any]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class ApiError(Exception):
|
|
43
|
+
def __init__(self, status: int, code: str, message: str, retry_after: int | None) -> None:
|
|
44
|
+
super().__init__(message)
|
|
45
|
+
self.status = status
|
|
46
|
+
self.code = code
|
|
47
|
+
self.retry_after = retry_after
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class RedirectRefusedError(ApiError):
|
|
51
|
+
"""A server answered with a redirect. Requests here carry an API key, so the client never
|
|
52
|
+
follows one: the key would be sent to the new location."""
|
|
53
|
+
|
|
54
|
+
def __init__(self, method: str, url: str, status: int, location: str | None) -> None:
|
|
55
|
+
host = urllib.parse.urlsplit(location).hostname if location else None
|
|
56
|
+
path = urllib.parse.urlsplit(url).path
|
|
57
|
+
super().__init__(
|
|
58
|
+
status,
|
|
59
|
+
"redirect_refused",
|
|
60
|
+
f"{method} {path}: HTTP {status} redirect to {host or 'an unknown host'} refused "
|
|
61
|
+
"(a request carrying a key is never redirected)",
|
|
62
|
+
None,
|
|
63
|
+
)
|
|
64
|
+
self.location_host = host
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class _RefuseRedirects(urllib.request.HTTPRedirectHandler):
|
|
68
|
+
def redirect_request(
|
|
69
|
+
self,
|
|
70
|
+
req: urllib.request.Request,
|
|
71
|
+
fp: Any,
|
|
72
|
+
code: int,
|
|
73
|
+
msg: str,
|
|
74
|
+
headers: Any,
|
|
75
|
+
newurl: str,
|
|
76
|
+
) -> urllib.request.Request | None:
|
|
77
|
+
fp.close()
|
|
78
|
+
raise RedirectRefusedError(req.get_method(), req.full_url, code, newurl)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
_NO_REDIRECT_OPENER = urllib.request.build_opener(_RefuseRedirects())
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def open_without_redirects(request: urllib.request.Request, timeout: float) -> Any:
|
|
85
|
+
"""Like ``urllib.request.urlopen``, but a 3xx answer is an error and is never followed, so a
|
|
86
|
+
key in the request headers never reaches another location."""
|
|
87
|
+
return _NO_REDIRECT_OPENER.open(request, timeout=timeout)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_retry_after(value: str | None) -> int | None:
|
|
91
|
+
"""Retry-After is either a number of seconds or an HTTP date."""
|
|
92
|
+
if value is None or not value.strip():
|
|
93
|
+
return None
|
|
94
|
+
try:
|
|
95
|
+
seconds = float(value)
|
|
96
|
+
except ValueError:
|
|
97
|
+
try:
|
|
98
|
+
when = email.utils.parsedate_to_datetime(value)
|
|
99
|
+
except (TypeError, ValueError):
|
|
100
|
+
return None
|
|
101
|
+
return max(0, math.ceil((when - datetime.now(UTC)).total_seconds()))
|
|
102
|
+
if not math.isfinite(seconds):
|
|
103
|
+
return None
|
|
104
|
+
return max(0, math.ceil(seconds))
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def idempotency_key(question_id: str, now: datetime | None = None) -> str:
|
|
108
|
+
"""One key per (question, UTC day): a retry on the same day can never create a second
|
|
109
|
+
forecast (the scorer takes one snapshot a day anyway)."""
|
|
110
|
+
day = (now or datetime.now(UTC)).astimezone(UTC).strftime("%Y-%m-%d")
|
|
111
|
+
return f"arctend-ft:{question_id}:{day}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
Opener = Callable[[urllib.request.Request, float], Any]
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _default_opener(request: urllib.request.Request, timeout: float) -> Any:
|
|
118
|
+
return open_without_redirects(request, timeout) # http/https only (checked in __init__)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _quote(question_id: str) -> str:
|
|
122
|
+
return urllib.parse.quote(question_id, safe="")
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class ArctendClient:
|
|
126
|
+
def __init__(
|
|
127
|
+
self,
|
|
128
|
+
base_url: str = DEFAULT_API_URL,
|
|
129
|
+
api_key: str | None = None,
|
|
130
|
+
opener: Opener = _default_opener,
|
|
131
|
+
sleep: Callable[[float], None] = time.sleep,
|
|
132
|
+
) -> None:
|
|
133
|
+
self.base_url = base_url.rstrip("/")
|
|
134
|
+
if not self.base_url.startswith(("http://", "https://")):
|
|
135
|
+
raise ValueError("ARCTEND_API_URL must start with http:// or https://")
|
|
136
|
+
self._api_key = api_key
|
|
137
|
+
self._opener = opener
|
|
138
|
+
self._sleep = sleep
|
|
139
|
+
|
|
140
|
+
@classmethod
|
|
141
|
+
def from_env(cls, environ: dict[str, str] | None = None) -> ArctendClient:
|
|
142
|
+
"""``ARCTEND_API_URL`` (default the sandbox, ``https://sandbox.arctend.com``) and
|
|
143
|
+
``ARCTEND_API_KEY``. Nothing is read from or written to disk."""
|
|
144
|
+
env = os.environ if environ is None else environ
|
|
145
|
+
url = (env.get("ARCTEND_API_URL") or "").strip() or DEFAULT_API_URL
|
|
146
|
+
key = (env.get("ARCTEND_API_KEY") or "").strip() or None
|
|
147
|
+
return cls(url, key)
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def has_key(self) -> bool:
|
|
151
|
+
return bool(self._api_key)
|
|
152
|
+
|
|
153
|
+
def set_api_key(self, key: str) -> None:
|
|
154
|
+
self._api_key = key
|
|
155
|
+
|
|
156
|
+
def request(
|
|
157
|
+
self,
|
|
158
|
+
method: str,
|
|
159
|
+
path: str,
|
|
160
|
+
body: Any = None,
|
|
161
|
+
headers: dict[str, str] | None = None,
|
|
162
|
+
) -> Any:
|
|
163
|
+
attempt = 1
|
|
164
|
+
while True:
|
|
165
|
+
all_headers = {"Accept": "application/json", "User-Agent": USER_AGENT}
|
|
166
|
+
all_headers.update(headers or {})
|
|
167
|
+
if self._api_key:
|
|
168
|
+
all_headers["Authorization"] = f"Bearer {self._api_key}"
|
|
169
|
+
data = None
|
|
170
|
+
if body is not None:
|
|
171
|
+
all_headers["Content-Type"] = "application/json"
|
|
172
|
+
data = json.dumps(body).encode("utf-8")
|
|
173
|
+
req = urllib.request.Request(
|
|
174
|
+
self.base_url + path, data=data, headers=all_headers, method=method
|
|
175
|
+
)
|
|
176
|
+
try:
|
|
177
|
+
with self._opener(req, REQUEST_TIMEOUT_SECONDS) as res:
|
|
178
|
+
text = res.read().decode("utf-8")
|
|
179
|
+
return json.loads(text) if text else None
|
|
180
|
+
except urllib.error.HTTPError as err:
|
|
181
|
+
text = err.read().decode("utf-8", errors="replace")
|
|
182
|
+
retry_after = parse_retry_after(err.headers.get("Retry-After"))
|
|
183
|
+
if (
|
|
184
|
+
err.code == 429
|
|
185
|
+
and retry_after is not None
|
|
186
|
+
and retry_after <= MAX_RETRY_AFTER_SECONDS
|
|
187
|
+
and attempt < MAX_ATTEMPTS
|
|
188
|
+
):
|
|
189
|
+
self._sleep(retry_after)
|
|
190
|
+
attempt += 1
|
|
191
|
+
continue
|
|
192
|
+
code, detail = "http_error", text[:300]
|
|
193
|
+
try:
|
|
194
|
+
problem = json.loads(text)
|
|
195
|
+
code = problem.get("code") or code
|
|
196
|
+
detail = problem.get("detail") or problem.get("title") or detail
|
|
197
|
+
except (ValueError, AttributeError):
|
|
198
|
+
pass
|
|
199
|
+
raise ApiError(
|
|
200
|
+
err.code, code, f"{method} {path}: {err.code} {code}: {detail}", retry_after
|
|
201
|
+
) from None
|
|
202
|
+
|
|
203
|
+
def get_challenge(self) -> Json:
|
|
204
|
+
return self.request("GET", "/v1/agents/register/challenge")
|
|
205
|
+
|
|
206
|
+
def register(
|
|
207
|
+
self,
|
|
208
|
+
name: str,
|
|
209
|
+
challenge: str,
|
|
210
|
+
nonce: str,
|
|
211
|
+
description: str | None = None,
|
|
212
|
+
homepage: str | None = None,
|
|
213
|
+
public_key: str | None = None,
|
|
214
|
+
signature: str | None = None,
|
|
215
|
+
) -> Json:
|
|
216
|
+
body: Json = {"name": name, "challenge": challenge, "nonce": nonce}
|
|
217
|
+
for field, value in (
|
|
218
|
+
("description", description),
|
|
219
|
+
("homepage", homepage),
|
|
220
|
+
("public_key", public_key),
|
|
221
|
+
("signature", signature),
|
|
222
|
+
):
|
|
223
|
+
if value is not None:
|
|
224
|
+
body[field] = value
|
|
225
|
+
return self.request("POST", "/v1/agents/register", body=body)
|
|
226
|
+
|
|
227
|
+
def me(self) -> Json:
|
|
228
|
+
return self.request("GET", "/v1/me")
|
|
229
|
+
|
|
230
|
+
def questions_page(self, topic: str | None = None, cursor: str | None = None) -> Json:
|
|
231
|
+
"""One page of open public questions (``items``, ``next_cursor``)."""
|
|
232
|
+
params = {"status": "open", "limit": "50"}
|
|
233
|
+
if topic:
|
|
234
|
+
params["topic"] = topic
|
|
235
|
+
if cursor:
|
|
236
|
+
params["cursor"] = cursor
|
|
237
|
+
return self.request("GET", "/v1/questions?" + urllib.parse.urlencode(params))
|
|
238
|
+
|
|
239
|
+
def open_questions(self, topic: str | None = None) -> Iterator[Json]:
|
|
240
|
+
"""Every open public question, following ``next_cursor`` page by page."""
|
|
241
|
+
cursor: str | None = None
|
|
242
|
+
while True:
|
|
243
|
+
page = self.questions_page(topic, cursor)
|
|
244
|
+
yield from page["items"]
|
|
245
|
+
cursor = page.get("next_cursor")
|
|
246
|
+
if not cursor:
|
|
247
|
+
return
|
|
248
|
+
|
|
249
|
+
def get_question(self, question_id: str) -> Json:
|
|
250
|
+
return self.request("GET", f"/v1/questions/{_quote(question_id)}")
|
|
251
|
+
|
|
252
|
+
def get_my_forecast(self, question_id: str) -> Json | None:
|
|
253
|
+
"""This agent's standing forecast on the question, or None if it has none."""
|
|
254
|
+
try:
|
|
255
|
+
return self.request("GET", f"/v1/questions/{_quote(question_id)}/forecast")
|
|
256
|
+
except ApiError as err:
|
|
257
|
+
if err.status == 404:
|
|
258
|
+
return None
|
|
259
|
+
raise
|
|
260
|
+
|
|
261
|
+
def put_forecast(self, question_id: str, body: Json, idempotency_key: str) -> Json:
|
|
262
|
+
return self.request(
|
|
263
|
+
"PUT",
|
|
264
|
+
f"/v1/questions/{_quote(question_id)}/forecast",
|
|
265
|
+
body=body,
|
|
266
|
+
headers={"Idempotency-Key": idempotency_key},
|
|
267
|
+
)
|