thirst 0.3.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.
thirst/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Thirst CLI: plan, run and measure GPU jobs."""
2
+
3
+ __version__ = "0.3.0"
thirst/__main__.py ADDED
@@ -0,0 +1,153 @@
1
+ """Thirst CLI entry point: argument parsing, dispatch and plain-words error handling (plan 04.0).
2
+
3
+ Exit codes: 0 ok, 2 bad arguments, 3 config, 4 API, 5 GPU, 130 Ctrl-C. An expected failure prints
4
+ one line ("thirst: ...") and never a stack trace; a surprise prints a generic line and, with
5
+ ``--verbose``, the log (which never contains the site key or a signature).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import logging
12
+ import sys
13
+ from collections.abc import Sequence
14
+
15
+ from . import __version__, commands
16
+ from .context import Context, default_context, stderr_writer, stdout_writer
17
+ from .errors import EXIT_BAD_ARGS, EXIT_INTERRUPTED, EXIT_OK, CliError
18
+ from .planning import MAX_WORK_HOURS
19
+
20
+ logger = logging.getLogger("thirst")
21
+ logger.addHandler(logging.NullHandler())
22
+
23
+ HANDLERS = {
24
+ "register": "cmd_register",
25
+ "plan": "cmd_plan",
26
+ "run": "cmd_run",
27
+ "sweep": "cmd_sweep",
28
+ "doctor": "cmd_doctor",
29
+ "sync": "cmd_sync",
30
+ "receipt": "cmd_receipt",
31
+ }
32
+
33
+
34
+ def _job_options(sub: argparse.ArgumentParser) -> None:
35
+ sub.add_argument(
36
+ "--hours", type=float, required=True, help=f"work at full power (max {MAX_WORK_HOURS:g})"
37
+ )
38
+ sub.add_argument("--deadline", required=True, help='e.g. "2026-10-10 09:00" (India time)')
39
+ sub.add_argument("--pausable", action="store_true", help="allow pauses (single-process jobs)")
40
+ sub.add_argument("--alpha", type=float, default=0.5, help="0 = least CO2, 1 = least water")
41
+ sub.add_argument("--gpu-model", default="auto", help="GPU model id; default: detect")
42
+ sub.add_argument("--gpus", type=int, default=None, help="number of GPUs; default: detect")
43
+
44
+
45
+ def build_parser() -> argparse.ArgumentParser:
46
+ parser = argparse.ArgumentParser(prog="thirst", description="Plan, run and measure GPU jobs.")
47
+ parser.add_argument("--version", action="version", version=f"thirst {__version__}")
48
+ parser.add_argument("--verbose", action="store_true", help="log requests (never secrets)")
49
+ commands_ = parser.add_subparsers(dest="command_name")
50
+
51
+ reg = commands_.add_parser("register", help="create this lab's site")
52
+ reg.add_argument("--api-url", default=None, help="API address (or THIRST_API_URL)")
53
+ reg.add_argument("--name", required=True)
54
+ reg.add_argument("--city", required=True)
55
+ reg.add_argument("--cooling", required=True, choices=["room_ac", "air_chiller", "evaporative"])
56
+ reg.add_argument("--gpus", type=int, required=True, help="GPUs in the lab")
57
+ reg.add_argument("--org", default=None)
58
+ reg.add_argument("--coverage", type=float, default=0.0, help="share of work covered, 0 to 1")
59
+ reg.add_argument("--private", action="store_true", help="keep the site off the leaderboard")
60
+ reg.add_argument("--public", action="store_true", help="list the site (the default)")
61
+ reg.add_argument("--force", action="store_true", help="replace an existing registration")
62
+
63
+ plan = commands_.add_parser("plan", help="show the plan for a job without running it")
64
+ _job_options(plan)
65
+ plan.add_argument("--json", action="store_true", help="print the raw server response")
66
+
67
+ run = commands_.add_parser("run", help="plan, then run your command: thirst run ... -- cmd")
68
+ _job_options(run)
69
+ run.add_argument("--now", action="store_true", help="start right away (no waiting)")
70
+ run.add_argument("--modelled", action="store_true", help="no GPU: estimate the energy")
71
+ run.add_argument("--no-cap", action="store_true", help="never change the power limit")
72
+ run.add_argument("--dry-run", action="store_true", help="show the plan, start nothing")
73
+ run.add_argument("--fallback-clocks", action="store_true", help="lock clocks instead of power")
74
+ run.add_argument("--slot-seconds", type=float, default=None, help=argparse.SUPPRESS)
75
+
76
+ swp = commands_.add_parser("sweep", help="measure the power-cap sweep (needs a GPU and torch)")
77
+ swp.add_argument("--levels", default="100,85,70")
78
+ swp.add_argument("--steps", type=int, default=300)
79
+ swp.add_argument("--warmup", type=int, default=30)
80
+ swp.add_argument("--idle-seconds", type=float, default=60.0)
81
+ swp.add_argument("--gpu-model", default="auto")
82
+ swp.add_argument("--out", default=None, help="powercap_sweep.json to update")
83
+
84
+ doc = commands_.add_parser("doctor", help="check the setup")
85
+ doc.add_argument("--restore", action="store_true", help="undo a power cap left behind")
86
+
87
+ commands_.add_parser("sync", help="post receipts waiting in the outbox")
88
+
89
+ rec = commands_.add_parser("receipt", help="saved receipts")
90
+ rec_sub = rec.add_subparsers(dest="receipt_action", required=True)
91
+ show = rec_sub.add_parser("show", help="print a saved receipt")
92
+ show.add_argument("job_id")
93
+ return parser
94
+
95
+
96
+ def _split_command(argv: list[str]) -> tuple[list[str], list[str] | None]:
97
+ """Split ``thirst run ... -- cmd args`` at the first ``--`` (only for `run`)."""
98
+ if "--" in argv and "run" in argv[: argv.index("--")]:
99
+ cut = argv.index("--")
100
+ return argv[:cut], argv[cut + 1 :]
101
+ return argv, None
102
+
103
+
104
+ def _parse(argv: list[str], ctx: Context) -> argparse.Namespace | int:
105
+ head, command = _split_command(argv)
106
+ parser = build_parser()
107
+ try:
108
+ args = parser.parse_args(head)
109
+ except SystemExit as exc:
110
+ return exc.code if isinstance(exc.code, int) else EXIT_BAD_ARGS
111
+ if args.command_name is None:
112
+ for line in parser.format_help().rstrip("\n").split("\n"):
113
+ ctx.out(line)
114
+ return EXIT_OK
115
+ args.command = command
116
+ return args
117
+
118
+
119
+ def _setup_logging(verbose: bool) -> None:
120
+ if verbose:
121
+ handler = logging.StreamHandler(sys.stderr)
122
+ handler.setFormatter(logging.Formatter("%(asctime)s %(name)s %(levelname)s %(message)s"))
123
+ logger.addHandler(handler)
124
+ logger.setLevel(logging.INFO)
125
+
126
+
127
+ def main(argv: Sequence[str] | None = None, *, context: Context | None = None) -> int:
128
+ """Run the CLI and return the process exit code."""
129
+ ctx = context or default_context(stdout_writer(), stderr_writer())
130
+ parsed = _parse(list(sys.argv[1:] if argv is None else argv), ctx)
131
+ if isinstance(parsed, int):
132
+ return parsed
133
+ _setup_logging(parsed.verbose)
134
+ handler = getattr(commands, HANDLERS[parsed.command_name])
135
+ try:
136
+ return int(handler(parsed, ctx))
137
+ except CliError as err:
138
+ ctx.err(f"thirst: {err.message}")
139
+ return err.exit_code
140
+ except KeyboardInterrupt:
141
+ ctx.err("thirst: cancelled.")
142
+ return EXIT_INTERRUPTED
143
+ except Exception:
144
+ logger.exception("unexpected error")
145
+ ctx.err(
146
+ "thirst: something unexpected went wrong. "
147
+ "Run again with --verbose and share the output with your team."
148
+ )
149
+ return 1
150
+
151
+
152
+ if __name__ == "__main__": # pragma: no cover
153
+ raise SystemExit(main())
thirst/client.py ADDED
@@ -0,0 +1,196 @@
1
+ """HTTP client for the Thirst API (plan 04.2, contract 08 §3, TRD 7.1).
2
+
3
+ Receipts are signed: ``signature = hex(HMAC_SHA256(token_ascii, ts + "." + raw_body))``. The body is
4
+ serialised once to bytes and exactly those bytes are signed and sent. Retries (1, 2, 4 s) only
5
+ happen on network errors and 5xx, never on 4xx. The token and signature are never logged.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import logging
14
+ import re
15
+ import time
16
+ from collections.abc import Callable, Mapping
17
+ from dataclasses import dataclass
18
+ from datetime import datetime
19
+ from email.utils import parsedate_to_datetime
20
+ from typing import Any
21
+
22
+ import httpx
23
+
24
+ from . import __version__
25
+ from .errors import ApiError
26
+
27
+ logger = logging.getLogger(__name__)
28
+
29
+ CONNECT_TIMEOUT_S = 5.0
30
+ READ_TIMEOUT_S = 15.0
31
+ BACKOFF_SECONDS = (1.0, 2.0, 4.0)
32
+ _SITE_ID = re.compile(r"s_[a-z2-7]{10}")
33
+ _GENERIC = "The Thirst server had a problem. Please try again in a few minutes."
34
+
35
+
36
+ @dataclass(frozen=True)
37
+ class ReceiptPost:
38
+ """Result of posting a receipt. ``duplicate`` means the server already had this job."""
39
+
40
+ response: dict[str, Any] | None
41
+ duplicate: bool
42
+
43
+
44
+ def sign(token: str, timestamp: str, body: bytes) -> str:
45
+ """Hex HMAC-SHA256 of ``timestamp + "." + body`` keyed by the token's ASCII bytes."""
46
+ message = timestamp.encode("ascii") + b"." + body
47
+ return hmac.new(token.encode("ascii"), message, hashlib.sha256).hexdigest()
48
+
49
+
50
+ def encode_body(payload: Mapping[str, Any]) -> bytes:
51
+ """The single serialisation of a request body. Refuses NaN and Infinity."""
52
+ try:
53
+ text = json.dumps(payload, separators=(",", ":"), sort_keys=True, allow_nan=False)
54
+ except (TypeError, ValueError):
55
+ raise ApiError(
56
+ "Could not prepare the request: a value was missing or not a finite number.",
57
+ code="bad_request",
58
+ ) from None
59
+ return text.encode("ascii") # ensure_ascii is on, so this cannot fail
60
+
61
+
62
+ def _error_for(response: httpx.Response) -> ApiError:
63
+ """Turn an error response into a readable ``ApiError``, using the server's own words."""
64
+ status = response.status_code
65
+ try:
66
+ data = response.json()
67
+ except ValueError:
68
+ data = None
69
+ if isinstance(data, dict) and isinstance(data.get("error"), str):
70
+ message = data.get("message")
71
+ text = message if isinstance(message, str) and message else _GENERIC
72
+ return ApiError(text, code=data["error"], status=status)
73
+ if status >= 500:
74
+ return ApiError(_GENERIC, code="server_error", status=status)
75
+ return ApiError(f"The server refused the request (HTTP {status}).", "http_error", status)
76
+
77
+
78
+ class Client:
79
+ def __init__(
80
+ self,
81
+ api_url: str,
82
+ token: str = "",
83
+ site_id: str = "",
84
+ *,
85
+ transport: httpx.BaseTransport | None = None,
86
+ sleep: Callable[[float], None] = time.sleep,
87
+ clock: Callable[[], float] = time.time,
88
+ ) -> None:
89
+ self._base = api_url.rstrip("/")
90
+ self._token = token
91
+ self._site_id = site_id
92
+ self._sleep = sleep
93
+ self._clock = clock
94
+ self._http = httpx.Client(
95
+ transport=transport,
96
+ timeout=httpx.Timeout(READ_TIMEOUT_S, connect=CONNECT_TIMEOUT_S),
97
+ follow_redirects=False,
98
+ )
99
+
100
+ def __repr__(self) -> str:
101
+ return f"Client(api_url={self._base!r}, site_id={self._site_id!r})"
102
+
103
+ def close(self) -> None:
104
+ self._http.close()
105
+
106
+ # ------------------------------------------------------------ public calls
107
+
108
+ def post_plan(self, payload: Mapping[str, Any]) -> dict[str, Any]:
109
+ return self._json_call("POST", "/plan", encode_body(payload), signed=False)
110
+
111
+ def post_site(self, payload: Mapping[str, Any]) -> dict[str, Any]:
112
+ return self._json_call("POST", "/sites", encode_body(payload), signed=False)
113
+
114
+ def get_site(self, site_id: str) -> dict[str, Any]:
115
+ if not _SITE_ID.fullmatch(site_id):
116
+ raise ApiError("That is not a valid site id.", code="bad_request")
117
+ return self._json_call("GET", f"/sites/{site_id}", None, signed=False)
118
+
119
+ def post_receipt(self, payload: Mapping[str, Any]) -> ReceiptPost:
120
+ """Post a signed receipt. A 409 ``duplicate_job`` is not an error: it was recorded."""
121
+ try:
122
+ data = self._json_call("POST", "/receipts", encode_body(payload), signed=True)
123
+ except ApiError as err:
124
+ if err.code == "duplicate_job":
125
+ return ReceiptPost(response=None, duplicate=True)
126
+ raise
127
+ return ReceiptPost(response=data, duplicate=False)
128
+
129
+ def get_server_time(self) -> datetime:
130
+ """The server's clock, from the ``Date`` header of ``GET /assumptions``."""
131
+ response = self._send("GET", "/assumptions", None, signed=False)
132
+ header = response.headers.get("date")
133
+ try:
134
+ if not header:
135
+ raise ValueError("no date")
136
+ return parsedate_to_datetime(header)
137
+ except (TypeError, ValueError):
138
+ raise ApiError(
139
+ "The server did not report its time.", code="bad_response", status=200
140
+ ) from None
141
+
142
+ # ------------------------------------------------------------ plumbing
143
+
144
+ def _json_call(
145
+ self, method: str, path: str, body: bytes | None, *, signed: bool
146
+ ) -> dict[str, Any]:
147
+ response = self._send(method, path, body, signed=signed)
148
+ try:
149
+ data = response.json()
150
+ except ValueError:
151
+ data = None
152
+ if not isinstance(data, dict):
153
+ raise ApiError(
154
+ "The server sent a reply the CLI could not read.",
155
+ code="bad_response",
156
+ status=response.status_code,
157
+ )
158
+ return data
159
+
160
+ def _headers(self, body: bytes | None, signed: bool) -> dict[str, str]:
161
+ headers = {"User-Agent": f"thirst-cli/{__version__}"}
162
+ if body is not None:
163
+ headers["Content-Type"] = "application/json"
164
+ if signed:
165
+ stamp = str(int(self._clock()))
166
+ headers["X-Thirst-Timestamp"] = stamp
167
+ headers["X-Thirst-Signature"] = sign(self._token, stamp, body or b"")
168
+ headers["X-Thirst-Site"] = self._site_id
169
+ return headers
170
+
171
+ def _send(self, method: str, path: str, body: bytes | None, *, signed: bool) -> httpx.Response:
172
+ delays = (*BACKOFF_SECONDS, None)
173
+ for delay in delays:
174
+ started = time.monotonic()
175
+ try:
176
+ response = self._http.request(
177
+ method, self._base + path, content=body, headers=self._headers(body, signed)
178
+ )
179
+ except httpx.HTTPError as exc:
180
+ logger.info("%s %s -> network error (%s)", method, path, type(exc).__name__)
181
+ failure = ApiError(
182
+ "Could not reach the Thirst server. Check your internet connection.",
183
+ code="network_error",
184
+ )
185
+ else:
186
+ took_ms = int((time.monotonic() - started) * 1000)
187
+ logger.info("%s %s -> %d in %d ms", method, path, response.status_code, took_ms)
188
+ if response.status_code < 400:
189
+ return response
190
+ failure = _error_for(response)
191
+ if response.status_code < 500:
192
+ raise failure
193
+ if delay is None:
194
+ raise failure
195
+ self._sleep(delay)
196
+ raise AssertionError("unreachable") # pragma: no cover