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