thirst 0.3.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.
thirst-0.3.0/PKG-INFO ADDED
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: thirst
3
+ Version: 0.3.0
4
+ Summary: Run GPU jobs at the right hour and speed to use less water and CO2, with a measured receipt.
5
+ Author: Team hello_world
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://github.com/Neal006/Thirst
8
+ Project-URL: Source, https://github.com/Neal006/Thirst
9
+ Project-URL: Issues, https://github.com/Neal006/Thirst/issues
10
+ Keywords: gpu,sustainability,water,carbon,nvml,scheduling,india
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Science/Research
13
+ Classifier: Operating System :: POSIX :: Linux
14
+ Classifier: Operating System :: Microsoft :: Windows
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
17
+ Classifier: Topic :: System :: Hardware
18
+ Requires-Python: >=3.10
19
+ Description-Content-Type: text/markdown
20
+ Requires-Dist: nvidia-ml-py>=12
21
+ Requires-Dist: httpx>=0.27
22
+ Requires-Dist: tomli>=2; python_version < "3.11"
23
+ Requires-Dist: tomli-w>=1
24
+ Provides-Extra: sweep
25
+ Requires-Dist: torch; extra == "sweep"
26
+ Requires-Dist: torchvision; extra == "sweep"
27
+
28
+ # thirst
29
+
30
+ **Run your GPU job at the right hour and the right speed, so it takes less water and CO₂. Get a signed receipt that proves it.**
31
+
32
+ Thirst is a command-line tool for people who train AI models on NVIDIA GPUs in India. It plans when to start a job and how much to lower the GPU power limit, runs your command on that plan, measures the real GPU energy, and sends a receipt to your lab's page on the Thirst leaderboard.
33
+
34
+ ```bash
35
+ pip install thirst
36
+
37
+ thirst register --name "CSE GPU Lab" --city Chennai --cooling room_ac --gpus 4
38
+ thirst plan --hours 6 --deadline "2026-10-12 09:00" --pausable
39
+ thirst run --hours 6 --deadline "2026-10-12 09:00" --pausable -- python train.py
40
+ ```
41
+
42
+ | Command | What it does |
43
+ |---|---|
44
+ | `thirst register` | Creates your lab's site once and saves its key to `~/.thirst/config.toml` |
45
+ | `thirst plan` | Shows the best start time and power cap, with savings as ranges |
46
+ | `thirst run -- <cmd>` | Waits, caps the GPU, runs your command, pauses or resumes it, restores the power limit, posts a receipt |
47
+ | `thirst run --modelled` | No NVIDIA GPU: estimates energy instead and labels it "modelled" |
48
+ | `thirst doctor` | Checks GPU, power-cap permission, clock and API |
49
+ | `thirst sync` | Sends receipts that were saved while offline |
50
+
51
+ Energy is **measured** from the GPU (NVML). Water and CO₂ are **modelled** from it, using Indian grid and weather data, and every receipt says which is which.
52
+
53
+ Project, website and the science: https://github.com/Neal006/Thirst · MIT licence.
thirst-0.3.0/README.md ADDED
@@ -0,0 +1,26 @@
1
+ # thirst
2
+
3
+ **Run your GPU job at the right hour and the right speed, so it takes less water and CO₂. Get a signed receipt that proves it.**
4
+
5
+ Thirst is a command-line tool for people who train AI models on NVIDIA GPUs in India. It plans when to start a job and how much to lower the GPU power limit, runs your command on that plan, measures the real GPU energy, and sends a receipt to your lab's page on the Thirst leaderboard.
6
+
7
+ ```bash
8
+ pip install thirst
9
+
10
+ thirst register --name "CSE GPU Lab" --city Chennai --cooling room_ac --gpus 4
11
+ thirst plan --hours 6 --deadline "2026-10-12 09:00" --pausable
12
+ thirst run --hours 6 --deadline "2026-10-12 09:00" --pausable -- python train.py
13
+ ```
14
+
15
+ | Command | What it does |
16
+ |---|---|
17
+ | `thirst register` | Creates your lab's site once and saves its key to `~/.thirst/config.toml` |
18
+ | `thirst plan` | Shows the best start time and power cap, with savings as ranges |
19
+ | `thirst run -- <cmd>` | Waits, caps the GPU, runs your command, pauses or resumes it, restores the power limit, posts a receipt |
20
+ | `thirst run --modelled` | No NVIDIA GPU: estimates energy instead and labels it "modelled" |
21
+ | `thirst doctor` | Checks GPU, power-cap permission, clock and API |
22
+ | `thirst sync` | Sends receipts that were saved while offline |
23
+
24
+ Energy is **measured** from the GPU (NVML). Water and CO₂ are **modelled** from it, using Indian grid and weather data, and every receipt says which is which.
25
+
26
+ Project, website and the science: https://github.com/Neal006/Thirst · MIT licence.
@@ -0,0 +1,45 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "thirst"
7
+ version = "0.3.0"
8
+ description = "Run GPU jobs at the right hour and speed to use less water and CO2, with a measured receipt."
9
+ readme = "README.md"
10
+ requires-python = ">=3.10"
11
+ license = "MIT"
12
+ authors = [{ name = "Team hello_world" }]
13
+ keywords = ["gpu", "sustainability", "water", "carbon", "nvml", "scheduling", "india"]
14
+ classifiers = [
15
+ "Environment :: Console",
16
+ "Intended Audience :: Science/Research",
17
+ "Operating System :: POSIX :: Linux",
18
+ "Operating System :: Microsoft :: Windows",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Scientific/Engineering :: Artificial Intelligence",
21
+ "Topic :: System :: Hardware",
22
+ ]
23
+ dependencies = [
24
+ "nvidia-ml-py>=12",
25
+ "httpx>=0.27",
26
+ "tomli>=2; python_version < '3.11'",
27
+ "tomli-w>=1",
28
+ ]
29
+
30
+ [project.urls]
31
+ Homepage = "https://github.com/Neal006/Thirst"
32
+ Source = "https://github.com/Neal006/Thirst"
33
+ Issues = "https://github.com/Neal006/Thirst/issues"
34
+
35
+ [project.optional-dependencies]
36
+ sweep = ["torch", "torchvision"]
37
+
38
+ [project.scripts]
39
+ thirst = "thirst.__main__:main"
40
+
41
+ [tool.setuptools.packages.find]
42
+ include = ["thirst*"]
43
+
44
+ [tool.setuptools.package-data]
45
+ thirst = ["data/*.json"]
thirst-0.3.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Thirst CLI: plan, run and measure GPU jobs."""
2
+
3
+ __version__ = "0.3.0"
@@ -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())
@@ -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