landauer-gap 0.4.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.
joules/__init__.py ADDED
@@ -0,0 +1,2 @@
1
+ """joules: measure the energy, cost and carbon of any command or local model."""
2
+ __version__ = "0.4.0"
joules/__main__.py ADDED
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
joules/bench.py ADDED
@@ -0,0 +1,58 @@
1
+ """Benchmarks a local model server and counts the tokens it produced.
2
+
3
+ Targets:
4
+ ollama:<model> Ollama on http://localhost:11434 (or --url)
5
+ openai:<model> any OpenAI-compatible server: vLLM, llama.cpp, LM Studio, TGI
6
+ (default --url http://localhost:8000/v1)
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import time
12
+ import urllib.error
13
+ import urllib.request
14
+ from dataclasses import dataclass
15
+
16
+ DEFAULT_PROMPT = ("Explain, in about 200 words, why data centres measure power usage effectiveness "
17
+ "and what a good value looks like.")
18
+
19
+
20
+ @dataclass
21
+ class Target:
22
+ kind: str # ollama | openai
23
+ model: str
24
+ url: str
25
+
26
+
27
+ def parse_target(spec: str, url: str | None) -> Target:
28
+ kind, sep, model = spec.partition(":")
29
+ if not sep or kind not in ("ollama", "openai") or not model:
30
+ raise ValueError("target must be ollama:<model> or openai:<model>, e.g. ollama:llama3.1:8b")
31
+ default = "http://localhost:11434" if kind == "ollama" else "http://localhost:8000/v1"
32
+ return Target(kind, model, (url or default).rstrip("/"))
33
+
34
+
35
+ def _post(url: str, body: dict, timeout: float) -> dict:
36
+ req = urllib.request.Request(url, data=json.dumps(body).encode(), headers={"Content-Type": "application/json"})
37
+ try:
38
+ with urllib.request.urlopen(req, timeout=timeout) as r:
39
+ return json.loads(r.read().decode())
40
+ except urllib.error.HTTPError as e:
41
+ raise RuntimeError(f"{url} answered HTTP {e.code}: {e.read().decode(errors='replace')[:300]}") from None
42
+ except urllib.error.URLError as e:
43
+ raise RuntimeError(f"cannot reach {url} ({e.reason}). Is the model server running?") from None
44
+
45
+
46
+ def generate(t: Target, prompt: str, max_tokens: int, timeout: float = 600) -> dict:
47
+ """One request. Returns output tokens, prompt tokens and the server-reported generation time."""
48
+ if t.kind == "ollama":
49
+ r = _post(f"{t.url}/api/generate", {"model": t.model, "prompt": prompt, "stream": False,
50
+ "options": {"num_predict": max_tokens, "temperature": 0}}, timeout)
51
+ return {"output_tokens": int(r.get("eval_count", 0)), "prompt_tokens": int(r.get("prompt_eval_count", 0)),
52
+ "gen_s": r.get("eval_duration", 0) / 1e9 or None}
53
+ t0 = time.monotonic()
54
+ r = _post(f"{t.url}/chat/completions", {"model": t.model, "messages": [{"role": "user", "content": prompt}],
55
+ "max_tokens": max_tokens, "temperature": 0}, timeout)
56
+ u = r.get("usage") or {}
57
+ return {"output_tokens": int(u.get("completion_tokens", 0)), "prompt_tokens": int(u.get("prompt_tokens", 0)),
58
+ "gen_s": time.monotonic() - t0}
joules/ci.py ADDED
@@ -0,0 +1,175 @@
1
+ """joules ci: an energy check for pull requests.
2
+
3
+ Runs a command on this checkout and on the base branch (in a temporary git worktree), several times,
4
+ alternating so drift hits both sides equally, and compares the medians. Writes a Markdown summary
5
+ (the GitHub job summary when it runs in Actions), can post or update one pull request comment, and
6
+ fails when energy grows by more than a budget.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import contextlib
11
+ import json
12
+ import os
13
+ import shutil
14
+ import statistics
15
+ import subprocess
16
+ import tempfile
17
+ import time
18
+ import urllib.error
19
+ import urllib.request
20
+
21
+ from .meters import Discovery
22
+ from .sampler import Sampler
23
+
24
+ MARKER = "<!-- landauer-gap-energy-check -->"
25
+
26
+
27
+ def measure(d: Discovery, cmd: list[str], cwd: str, interval: float) -> dict:
28
+ with Sampler(d.meters, interval) as s:
29
+ p = subprocess.run(cmd, cwd=cwd, stdin=subprocess.DEVNULL)
30
+ return {"energy_j": sum(m.energy_j for m in d.meters), "duration_s": s.duration_s, "exit_code": p.returncode,
31
+ "devices": {m.name: m.energy_j for m in d.meters}}
32
+
33
+
34
+ def git(*args: str, cwd: str | None = None) -> str:
35
+ p = subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True)
36
+ if p.returncode != 0:
37
+ raise RuntimeError(f"git {' '.join(args)}: {(p.stderr or p.stdout).strip()[:300]}")
38
+ return p.stdout.strip()
39
+
40
+
41
+ @contextlib.contextmanager
42
+ def worktree(ref: str):
43
+ """A clean checkout of ref next to this one, removed afterwards."""
44
+ tmp = tempfile.mkdtemp(prefix="joules-base-")
45
+ path = os.path.join(tmp, "base")
46
+ git("-c", "advice.detachedHead=false", "worktree", "add", "--detach", path, ref)
47
+ try:
48
+ yield path
49
+ finally:
50
+ subprocess.run(["git", "worktree", "remove", "--force", path], capture_output=True)
51
+ shutil.rmtree(tmp, ignore_errors=True)
52
+
53
+
54
+ def default_base() -> str | None:
55
+ b = os.environ.get("GITHUB_BASE_REF")
56
+ return f"origin/{b}" if b else None
57
+
58
+
59
+ def summarize(head: list[dict], base: list[dict] | None, budget: float | None) -> dict:
60
+ h = statistics.median(r["energy_j"] for r in head)
61
+ out = {"head_j": h, "head_s": statistics.median(r["duration_s"] for r in head),
62
+ "head_range": [min(r["energy_j"] for r in head), max(r["energy_j"] for r in head)], "runs": len(head)}
63
+ if base:
64
+ b = statistics.median(r["energy_j"] for r in base)
65
+ out.update(base_j=b, base_s=statistics.median(r["duration_s"] for r in base),
66
+ base_range=[min(r["energy_j"] for r in base), max(r["energy_j"] for r in base)])
67
+ out["change_pct"] = (h - b) / b * 100 if b > 0 else None
68
+ # The runs overlap: the difference is not bigger than the run-to-run spread.
69
+ out["within_noise"] = out["head_range"][0] <= out["base_range"][1] and out["base_range"][0] <= out["head_range"][1]
70
+ out["over_budget"] = bool(budget is not None and out["change_pct"] is not None and out["change_pct"] > budget
71
+ and not out["within_noise"])
72
+ out["budget_pct"] = budget
73
+ return out
74
+
75
+
76
+ def _j(x: float) -> str:
77
+ for unit, k in (("MJ", 1e6), ("kJ", 1e3)):
78
+ if abs(x) >= k:
79
+ return f"{x / k:.3g} {unit}"
80
+ return f"{x:.3g} J"
81
+
82
+
83
+ def markdown(s: dict, *, head_name: str, base_name: str | None, devices: list[str], estimated: bool,
84
+ command: str, track_link: str | None = None) -> str:
85
+ lines = [MARKER, "### โšก Energy check", ""]
86
+ if "base_j" in s:
87
+ c = s["change_pct"]
88
+ if c is None:
89
+ verdict = "no comparison (the base used no energy)"
90
+ elif s["over_budget"]:
91
+ verdict = f"โŒ **{c:+.1f}%**, over the {s['budget_pct']:g}% budget"
92
+ elif s["within_noise"]:
93
+ verdict = f"โ‰ˆ **{c:+.1f}%**, within run-to-run noise"
94
+ elif c > 0:
95
+ verdict = f"๐Ÿ”บ **{c:+.1f}%** more energy"
96
+ else:
97
+ verdict = f"โœ… **{c:+.1f}%**, less energy"
98
+ lines += [f"{verdict}", "",
99
+ f"| | energy (median of {s['runs']}) | range | time |", "|---|---|---|---|",
100
+ f"| this change `{head_name}` | **{_j(s['head_j'])}** | {_j(s['head_range'][0])} โ€“ {_j(s['head_range'][1])} | {s['head_s']:.2f} s |",
101
+ f"| base `{base_name}` | {_j(s['base_j'])} | {_j(s['base_range'][0])} โ€“ {_j(s['base_range'][1])} | {s['base_s']:.2f} s |"]
102
+ else:
103
+ lines += [f"| | energy (median of {s['runs']}) | range | time |", "|---|---|---|---|",
104
+ f"| `{head_name}` | **{_j(s['head_j'])}** | {_j(s['head_range'][0])} โ€“ {_j(s['head_range'][1])} | {s['head_s']:.2f} s |"]
105
+ lines += ["", f"Command: `{command.replace('`', chr(39))[:200]}` ", f"Measured with: {', '.join(devices)}"]
106
+ if estimated:
107
+ lines.append("> Estimated from CPU time: this runner has no energy counters. Good for spotting changes; "
108
+ "run on a self-hosted runner for measured joules.")
109
+ if track_link:
110
+ lines.append(f"\n[History of this check on Landauer Gap]({track_link})")
111
+ lines.append("\n<sub>[Landauer Gap](https://landauer-gap.vercel.app) ยท `joules ci`</sub>")
112
+ return "\n".join(lines) + "\n"
113
+
114
+
115
+ def _api(method: str, url: str, token: str, body: dict | None = None):
116
+ req = urllib.request.Request(url, method=method, data=json.dumps(body).encode() if body is not None else None, headers={
117
+ "Authorization": f"Bearer {token}", "Accept": "application/vnd.github+json", "User-Agent": "joules-ci",
118
+ "X-GitHub-Api-Version": "2022-11-28", "Content-Type": "application/json"})
119
+ with urllib.request.urlopen(req, timeout=20) as r:
120
+ return json.loads(r.read().decode() or "null")
121
+
122
+
123
+ def post_comment(md: str, env: dict | None = None) -> str:
124
+ """Creates or updates this check's comment on the pull request. Returns what happened."""
125
+ env = env if env is not None else os.environ
126
+ token, repo = env.get("GITHUB_TOKEN"), env.get("GITHUB_REPOSITORY")
127
+ api = (env.get("GITHUB_API_URL") or "https://api.github.com").rstrip("/")
128
+ try:
129
+ with open(env.get("GITHUB_EVENT_PATH") or "") as f:
130
+ pr = (json.load(f).get("pull_request") or {}).get("number")
131
+ except (OSError, ValueError):
132
+ pr = None
133
+ if not (token and repo and pr):
134
+ return "not a pull request (or no GITHUB_TOKEN): comment skipped"
135
+ try:
136
+ existing = None
137
+ for page in range(1, 11):
138
+ items = _api("GET", f"{api}/repos/{repo}/issues/{pr}/comments?per_page=100&page={page}", token) or []
139
+ existing = next((c for c in items if MARKER in (c.get("body") or "")), None)
140
+ if existing or len(items) < 100:
141
+ break
142
+ if existing:
143
+ _api("PATCH", f"{api}/repos/{repo}/issues/comments/{existing['id']}", token, {"body": md})
144
+ return f"updated the energy comment on #{pr}"
145
+ _api("POST", f"{api}/repos/{repo}/issues/{pr}/comments", token, {"body": md})
146
+ return f"commented on #{pr}"
147
+ except urllib.error.HTTPError as e:
148
+ hint = " (pull requests from forks get a read-only token; give the job `permissions: pull-requests: write`)" if e.code in (403, 404) else ""
149
+ return f"could not comment: HTTP {e.code}{hint}"
150
+ except urllib.error.URLError as e:
151
+ return f"could not comment: {e.reason}"
152
+
153
+
154
+ def write_outputs(s: dict, env: dict | None = None) -> None:
155
+ env = env if env is not None else os.environ
156
+ out = env.get("GITHUB_OUTPUT")
157
+ if not out:
158
+ return
159
+ with open(out, "a") as f:
160
+ f.write(f"energy-j={s['head_j']:.6g}\n")
161
+ if "base_j" in s:
162
+ f.write(f"base-energy-j={s['base_j']:.6g}\n")
163
+ f.write(f"change-pct={'' if s['change_pct'] is None else format(s['change_pct'], '.4g')}\n")
164
+ f.write(f"over-budget={'true' if s.get('over_budget') else 'false'}\n")
165
+
166
+
167
+ def run_setup(cmd: str | None, cwd: str) -> None:
168
+ if cmd:
169
+ p = subprocess.run(cmd, shell=True, cwd=cwd)
170
+ if p.returncode != 0:
171
+ raise RuntimeError(f"setup failed in {cwd} (exit {p.returncode}): {cmd}")
172
+
173
+
174
+ def now_iso() -> str:
175
+ return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())