pennyrouter 0.3.19 → 0.3.21
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.
- package/README.md +13 -2
- package/package.json +2 -1
- package/src/cli.js +13 -0
- package/src/launch.js +60 -4
- package/src/launch.test.js +8 -0
- package/standalone/.env.example +8 -0
- package/standalone/README-original.md +79 -0
- package/standalone/README.md +60 -0
- package/standalone/claude-proxy.sh +220 -0
- package/standalone/codex-proxy.sh +208 -0
- package/standalone/delta.py +41 -0
- package/standalone/demo_example.md +7 -0
- package/standalone/hud.py +122 -0
- package/standalone/install.sh +58 -0
- package/standalone/pane.py +430 -0
- package/standalone/penny-compare +318 -0
- package/standalone/penny-compare-viewer +10 -0
- package/standalone/penny-session +103 -0
- package/standalone/pricing.py +139 -0
- package/standalone/report.py +137 -0
- package/standalone/usage.py +575 -0
- package/standalone/viewer.py +232 -0
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Local, dependency-free browser for penny-compare reports.
|
|
3
|
+
|
|
4
|
+
Run with: python3 apps/compare/viewer.py
|
|
5
|
+
"""
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import argparse
|
|
9
|
+
import json
|
|
10
|
+
import sqlite3
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
13
|
+
from urllib.parse import parse_qs, urlparse
|
|
14
|
+
|
|
15
|
+
HERE = Path(__file__).resolve().parent
|
|
16
|
+
REPORTS = HERE / "reports"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def json_file(path: Path):
|
|
20
|
+
try:
|
|
21
|
+
return json.loads(path.read_text())
|
|
22
|
+
except (OSError, ValueError):
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def find_transcript(side: dict) -> Path | None:
|
|
27
|
+
direct = Path(side.get("transcript_path", "")) if side.get("transcript_path") else None
|
|
28
|
+
if direct and direct.exists():
|
|
29
|
+
return direct
|
|
30
|
+
sid, harness = side.get("session_id"), side.get("harness")
|
|
31
|
+
if harness == "opencode":
|
|
32
|
+
stored = side.get("transcript_path", "")
|
|
33
|
+
database = stored.split("#", 1)[0] if "#" in stored else str(Path.home() / ".local/share/opencode/opencode.db")
|
|
34
|
+
path = Path(database)
|
|
35
|
+
return path if path.exists() else None
|
|
36
|
+
if not sid:
|
|
37
|
+
return None
|
|
38
|
+
roots = []
|
|
39
|
+
if harness == "claude-code":
|
|
40
|
+
roots = [Path.home() / ".claude" / "projects"]
|
|
41
|
+
elif harness == "codex":
|
|
42
|
+
roots = [Path.home() / ".codex" / "sessions"]
|
|
43
|
+
for root in roots:
|
|
44
|
+
if not root.exists():
|
|
45
|
+
continue
|
|
46
|
+
for path in root.rglob("*.jsonl"):
|
|
47
|
+
if path.stem == sid or sid in path.name:
|
|
48
|
+
return path
|
|
49
|
+
if harness == "claude-code":
|
|
50
|
+
try:
|
|
51
|
+
with path.open() as handle:
|
|
52
|
+
first = handle.readline()
|
|
53
|
+
if json.loads(first).get("sessionId") == sid:
|
|
54
|
+
return path
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
pass
|
|
57
|
+
return None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def text_content(value) -> str:
|
|
61
|
+
if isinstance(value, str):
|
|
62
|
+
return value
|
|
63
|
+
if isinstance(value, list):
|
|
64
|
+
parts = []
|
|
65
|
+
for item in value:
|
|
66
|
+
if isinstance(item, str):
|
|
67
|
+
parts.append(item)
|
|
68
|
+
elif isinstance(item, dict):
|
|
69
|
+
if item.get("type") in ("text", "output_text", "input_text"):
|
|
70
|
+
parts.append(str(item.get("text") or item.get("content") or ""))
|
|
71
|
+
return "\n".join(p for p in parts if p)
|
|
72
|
+
return ""
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def read_turns(side: dict) -> list[dict]:
|
|
76
|
+
if side.get("harness") == "opencode":
|
|
77
|
+
stored = side.get("transcript_path", "")
|
|
78
|
+
session_id = side.get("session_id") or (stored.split("#", 1)[1] if "#" in stored else "")
|
|
79
|
+
database = stored.split("#", 1)[0] if "#" in stored else str(Path.home() / ".local/share/opencode/opencode.db")
|
|
80
|
+
if not session_id:
|
|
81
|
+
return []
|
|
82
|
+
turns = []
|
|
83
|
+
try:
|
|
84
|
+
db = sqlite3.connect(f"file:{database}?mode=ro", uri=True, timeout=0.2)
|
|
85
|
+
rows = db.execute(
|
|
86
|
+
"select data from part where session_id = ? order by time_created, id",
|
|
87
|
+
(session_id,),
|
|
88
|
+
)
|
|
89
|
+
for (raw,) in rows:
|
|
90
|
+
try:
|
|
91
|
+
obj = json.loads(raw)
|
|
92
|
+
except (TypeError, ValueError):
|
|
93
|
+
continue
|
|
94
|
+
if obj.get("type") != "text" or not str(obj.get("text") or "").strip():
|
|
95
|
+
continue
|
|
96
|
+
# OpenCode's text parts do not carry role; assistant parts have a time field,
|
|
97
|
+
# while the initial prompt is the only user text part in normal sessions.
|
|
98
|
+
role = "assistant" if obj.get("time") else "user"
|
|
99
|
+
turns.append({"role": role, "text": str(obj["text"]), "timestamp": None, "id": None})
|
|
100
|
+
db.close()
|
|
101
|
+
except (OSError, sqlite3.Error):
|
|
102
|
+
return []
|
|
103
|
+
return turns
|
|
104
|
+
path = find_transcript(side)
|
|
105
|
+
if not path:
|
|
106
|
+
return []
|
|
107
|
+
harness = side.get("harness")
|
|
108
|
+
turns = []
|
|
109
|
+
try:
|
|
110
|
+
lines = path.read_text().splitlines()
|
|
111
|
+
except OSError:
|
|
112
|
+
return turns
|
|
113
|
+
for raw in lines:
|
|
114
|
+
try:
|
|
115
|
+
obj = json.loads(raw)
|
|
116
|
+
except ValueError:
|
|
117
|
+
continue
|
|
118
|
+
payload = obj.get("payload") or {}
|
|
119
|
+
role, content = None, ""
|
|
120
|
+
if harness == "claude-code":
|
|
121
|
+
message = obj.get("message") or {}
|
|
122
|
+
role, content = message.get("role"), text_content(message.get("content"))
|
|
123
|
+
else:
|
|
124
|
+
if obj.get("type") == "response_item" and payload.get("type") == "message":
|
|
125
|
+
role, content = payload.get("role"), text_content(payload.get("content"))
|
|
126
|
+
elif obj.get("type") == "event_msg" and payload.get("type") == "agent_message":
|
|
127
|
+
role, content = "assistant", text_content(payload.get("message"))
|
|
128
|
+
if role not in ("user", "assistant") or not content.strip():
|
|
129
|
+
continue
|
|
130
|
+
turns.append({
|
|
131
|
+
"role": role,
|
|
132
|
+
"text": content,
|
|
133
|
+
"timestamp": obj.get("timestamp"),
|
|
134
|
+
"id": (obj.get("message", {}).get("id") if harness == "claude-code" else payload.get("id")),
|
|
135
|
+
})
|
|
136
|
+
return turns
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def report_summaries():
|
|
140
|
+
result = []
|
|
141
|
+
for path in sorted(REPORTS.glob("compare-*.json"), reverse=True):
|
|
142
|
+
report = json_file(path)
|
|
143
|
+
if not report:
|
|
144
|
+
continue
|
|
145
|
+
result.append({
|
|
146
|
+
"id": path.name,
|
|
147
|
+
"created_at": report.get("created_at"),
|
|
148
|
+
"pricing_profile": report.get("pricing_profile"),
|
|
149
|
+
"left": report.get("left", {}), "right": report.get("right", {}),
|
|
150
|
+
"savings": report.get("savings_native_minus_penny", {}),
|
|
151
|
+
})
|
|
152
|
+
return result
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def full_report(name: str):
|
|
156
|
+
if Path(name).name != name or not name.startswith("compare-"):
|
|
157
|
+
return None
|
|
158
|
+
report = json_file(REPORTS / name)
|
|
159
|
+
if not report:
|
|
160
|
+
return None
|
|
161
|
+
for side_name in ("left", "right"):
|
|
162
|
+
side = report.get(side_name, {})
|
|
163
|
+
transcript = find_transcript(side)
|
|
164
|
+
side["turns"] = read_turns(side)
|
|
165
|
+
side["transcript_available"] = bool(transcript)
|
|
166
|
+
side["transcript_path"] = str(transcript) if transcript else ""
|
|
167
|
+
return report
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
PAGE = r'''<!doctype html>
|
|
171
|
+
<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
|
172
|
+
<title>Penny Compare</title><style>
|
|
173
|
+
:root{color-scheme:dark;--bg:#0d1117;--panel:#151c25;--line:#273443;--muted:#8b9aaa;--text:#edf4f7;--green:#55e6a5;--blue:#79b8ff;--amber:#ffc857}
|
|
174
|
+
*{box-sizing:border-box}body{margin:0;background:radial-gradient(circle at 85% -10%,#19332f 0,#0d1117 38%);color:var(--text);font:14px/1.5 Inter,ui-sans-serif,system-ui,sans-serif}main{max-width:1440px;margin:auto;padding:34px 28px 70px}h1{font-size:31px;margin:0;letter-spacing:-.04em}h2{font-size:18px;margin:0 0 15px}.eyebrow{color:var(--green);font-size:11px;font-weight:700;letter-spacing:.15em;text-transform:uppercase}.top{display:flex;align-items:end;justify-content:space-between;gap:20px;margin-bottom:28px}.top p{color:var(--muted);margin:8px 0 0}.select{background:var(--panel);border:1px solid var(--line);color:var(--text);border-radius:9px;padding:10px 12px;min-width:310px}.panel{background:color-mix(in srgb,var(--panel) 92%,transparent);border:1px solid var(--line);border-radius:14px;padding:20px;box-shadow:0 15px 45px #0002}.summary{display:grid;grid-template-columns:repeat(4,1fr);gap:12px;margin-bottom:18px}.metric{padding:16px;border:1px solid var(--line);border-radius:10px;background:#10161e}.metric label{display:block;color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.08em}.metric strong{display:block;font-size:23px;margin-top:5px}.good{color:var(--green)}.bad{color:#ff8a8a}.compare{display:grid;grid-template-columns:1fr 1fr;gap:14px;margin-bottom:18px}.sidehead{display:flex;justify-content:space-between;align-items:start;border-bottom:1px solid var(--line);padding-bottom:13px;margin-bottom:14px}.role{font-weight:750;font-size:17px}.model,.meta{color:var(--muted);font-size:12px}.statrow{display:flex;justify-content:space-between;padding:7px 0;border-bottom:1px solid #22303c}.statrow span:first-child{color:var(--muted)}.bar{height:7px;background:#26323c;border-radius:99px;margin-top:14px;overflow:hidden}.bar i{display:block;height:100%;background:linear-gradient(90deg,var(--blue),var(--green));border-radius:inherit}.turngrid{display:grid;grid-template-columns:1fr 1fr;gap:14px}.turns{display:flex;flex-direction:column;gap:7px;max-height:520px;overflow:auto}.turn{border:1px solid var(--line);background:#10161e;border-radius:9px;padding:11px 13px;cursor:pointer;text-align:left;color:var(--text)}.turn:hover,.turn.active{border-color:var(--green);background:#162620}.turntop{display:flex;justify-content:space-between;color:var(--muted);font-size:11px;text-transform:uppercase}.turntext{display:block;margin-top:5px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.detail{margin-top:14px;background:#0b1016;border:1px solid var(--line);border-radius:10px;padding:16px;min-height:145px}.detail pre{font:13px/1.6 ui-monospace,SFMono-Regular,monospace;white-space:pre-wrap;overflow-wrap:anywhere;margin:8px 0 0;color:#d6e3e8}.empty{color:var(--muted);padding:25px 4px}.session{font:11px ui-monospace,monospace;color:var(--muted);word-break:break-all;margin-top:12px}@media(max-width:800px){main{padding:22px 14px}.top,.compare,.turngrid{display:block}.select{margin-top:18px;width:100%;min-width:0}.summary{grid-template-columns:1fr 1fr}.compare>.panel,.turngrid>.panel{margin-bottom:14px}}
|
|
175
|
+
</style></head><body><main><div class="top"><div><div class="eyebrow">Penny Router / Lab</div><h1>Comparison reports</h1><p>Inspect the cost, token shape, and actual transcript behind every run.</p></div><select id="reports" class="select"></select></div><div id="app"></div></main><script>
|
|
176
|
+
const $=s=>document.querySelector(s), fmt=n=>Number(n||0).toLocaleString(), money=n=>'$'+Math.abs(Number(n||0)).toFixed(4), money2=n=>'$'+Math.abs(Number(n||0)).toFixed(2), pct=n=>Math.abs(Number(n||0)).toFixed(0)+'%';
|
|
177
|
+
let report;
|
|
178
|
+
function savingCopy(value, percent, noun){const saved=Number(value||0)>=0;return {label:saved?'Penny '+noun:'Penny '+noun.replace('savings','cost'), amount:(saved?'':'−')+money(value), detail:pct(percent)+(saved?' less than native':' more than native')};}
|
|
179
|
+
function sideCard(side){const t=side.totals||{}, max=Math.max((report.left.totals||{}).total||0,(report.right.totals||{}).total||0,1);return `<section class="panel"><div class="sidehead"><div><div class="role">${esc(side.label||side.role||'Side')}</div><div class="model">${esc(side.variant||'')} · ${esc(side.harness||'')}</div></div><div class="eyebrow">${esc(side.role||'')}</div></div><div class="statrow"><span>Cost</span><b>${money(t.cost)}</b></div><div class="statrow"><span>Total tokens</span><b>${fmt(t.total)}</b></div><div class="statrow"><span>Input / cache read</span><b>${fmt(t.fresh_input)} / ${fmt(t.cache_read)}</b></div><div class="statrow"><span>Output</span><b>${fmt(t.output)}</b></div><div class="statrow"><span>Model calls</span><b>${fmt(side.call_count)}</b></div><div class="bar"><i style="width:${Math.max(2,(t.total||0)/max*100)}%"></i></div><div class="session"><span>session ${esc(side.session_id||'not recorded')}</span><span class="transcript-label">JSONL path</span><span class="transcript-path">${esc(side.transcript_path||'Transcript unavailable for this older report')}</span></div></section>`}
|
|
180
|
+
function esc(v){return String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]))}
|
|
181
|
+
function turns(side,which){let list=side.turns||[];return `<section class="panel"><div class="sidehead"><h2>${esc(side.label||which)} turns</h2><span class="meta">${list.length} messages</span></div><div class="turns">${list.length?list.map((x,i)=>`<button class="turn turn-${esc(x.role)}" aria-expanded="false" data-side="${which}" data-index="${i}"><span class="turntop"><span>${esc(x.timestamp||'')}</span></span><span class="turntext">${esc(x.text.slice(0,320))}${x.text.length>320?'…':''}</span></button>`).join(''):'<div class="empty">No linked JSONL transcript found.</div>'}</div></section>`}
|
|
182
|
+
function render(){let l=report.left||{},r=report.right||{},s=report.savings_native_minus_penny||{},c=savingCopy(s.cost,s.cost_percent,'savings'),t=savingCopy(s.tokens,s.token_percent,'token savings');$('#app').innerHTML=`<div class="summary"><div class="metric"><label>${c.label}</label><strong class="${s.cost>=0?'good':'bad'}">${s.cost>=0?'':'−'}${money2(s.cost)} (${pct(s.cost_percent)})</strong></div><div class="metric"><label>${t.label}</label><strong class="${s.tokens>=0?'good':'bad'}">${s.tokens>=0?'':'−'}${fmt(s.tokens)} (${pct(s.token_percent)})</strong></div><div class="metric"><label>Run date</label><strong>${esc(new Date(report.created_at).toLocaleDateString())}</strong><span class="meta">${esc(new Date(report.created_at).toLocaleTimeString())}</span></div><div class="metric"><label>Pricing</label><strong>${esc(report.pricing_profile||'standard')}</strong><span class="meta">${esc(l.call_count||0)} vs ${esc(r.call_count||0)} calls</span></div></div><div class="compare">${sideCard(l)}${sideCard(r)}</div><div class="turngrid">${turns(l,'left')}${turns(r,'right')}</div>`;document.querySelectorAll('.turn').forEach(b=>b.onclick=()=>{const side=report[b.dataset.side],x=side.turns[Number(b.dataset.index)],expanded=b.classList.toggle('expanded');document.querySelectorAll('.turn').forEach(other=>{if(other!==b){other.classList.remove('expanded','active');other.setAttribute('aria-expanded','false');const otherSide=report[other.dataset.side],otherTurn=otherSide.turns[Number(other.dataset.index)];other.querySelector('.turntext').textContent=otherTurn.text.slice(0,320)+(otherTurn.text.length>320?'…':'')}});b.classList.toggle('active',expanded);b.setAttribute('aria-expanded',String(expanded));b.querySelector('.turntext').textContent=expanded?x.text.slice(0):x.text.slice(0,320)+(x.text.length>320?'…':'')})}
|
|
183
|
+
async function load(){let list=await fetch('/api/reports').then(r=>r.json());$('#reports').innerHTML=list.map(x=>`<option value="${esc(x.id)}">${new Date(x.created_at).toLocaleString()} · ${esc(x.left.label||'left')} vs ${esc(x.right.label||'right')}</option>`).join('');if(!list.length){$('#app').innerHTML='<div class="panel empty">No reports found in apps/compare/reports.</div>';return}async function choose(id){report=await fetch('/api/report?id='+encodeURIComponent(id)).then(r=>r.json());render()}$('#reports').onchange=()=>choose($('#reports').value);choose(list[0].id)}load();
|
|
184
|
+
</script></body></html>'''
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class Handler(BaseHTTPRequestHandler):
|
|
188
|
+
def send_json(self, value, status=200):
|
|
189
|
+
body = json.dumps(value).encode()
|
|
190
|
+
self.send_response(status); self.send_header("Content-Type", "application/json"); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body)
|
|
191
|
+
def do_GET(self):
|
|
192
|
+
parsed = urlparse(self.path)
|
|
193
|
+
if parsed.path == "/":
|
|
194
|
+
enhancement = '''<style>:root{color-scheme:light;--paper:#f3ede1;--paper-raised:#fbf7ef;--paper-sunk:#e8e0d0;--line:#d8cfbd;--line-strong:#b9ad96;--navy:#233044;--navy-soft:#3e4c61;--ink:#1c1814;--ink-soft:#4d473f;--ink-faint:#837a6e;--brick:#b23a2c;--brick-deep:#8f2b1f;--brick-tint:#f4e2dd;--green:#2f7d4f;--green-tint:#dcecdf}body{background-color:var(--paper);background-image:radial-gradient(rgba(28,24,20,.022) 1px,transparent 1px),radial-gradient(rgba(28,24,20,.018) 1px,transparent 1px);background-size:7px 7px,11px 11px;color:var(--ink);font-family:Arial,system-ui,sans-serif}.top p,.model,.meta,.barlabel{color:var(--ink-faint)}h1,h2{color:var(--navy)}.eyebrow{color:var(--brick)}.select{background:var(--paper-raised);border-color:var(--line-strong);color:var(--ink)}.panel,.metric{background:var(--paper-raised);border-color:var(--line);box-shadow:0 1px 2px rgba(28,24,20,.05),0 10px 28px -14px rgba(28,24,20,.18)}.statrow{border-color:var(--line)}.statrow span:first-child{color:var(--ink-soft)}.bar{background:var(--paper-sunk)}.bar i{background:linear-gradient(90deg,#2f7d4f,#d49b35 58%,#b23a2c)}.barlabel{font-size:12px;margin-top:7px}.session{display:none}.turn{background:var(--paper-raised);border-color:var(--line);color:var(--ink)}.turn:hover,.turn.active{border-color:var(--brick);background:var(--brick-tint)}.turntop{color:var(--ink-faint)}.detail{background:var(--paper-sunk);border-color:var(--line)}.detail pre{color:var(--ink)}.good{color:var(--green)}.bad{color:var(--brick)}</style><script>new MutationObserver(()=>document.querySelectorAll('.bar').forEach(bar=>{if(bar.nextElementSibling?.classList.contains('barlabel'))return;const row=[...bar.closest('.panel').querySelectorAll('.statrow')].find(x=>x.firstElementChild?.textContent==='Total tokens');if(!row)return;const label=document.createElement('div');label.className='barlabel';label.textContent='total tokens '+row.lastElementChild.textContent;bar.after(label)})).observe(document.documentElement,{childList:true,subtree:true});</script>'''
|
|
195
|
+
enhancement += '''<style>
|
|
196
|
+
.compare,.turngrid{grid-template-columns:minmax(0,1fr) minmax(0,1fr)}
|
|
197
|
+
.compare>.panel,.turngrid>.panel,.turns,.detail{min-width:0}
|
|
198
|
+
.turn{display:block;width:88%;min-width:0;border-color:var(--line);border-radius:16px;padding:13px 16px;transition:border-color .15s,box-shadow .15s}
|
|
199
|
+
.turn-user{align-self:flex-end;background:#e9f0fb;border-color:#d8e3f2;color:#172b4d}
|
|
200
|
+
.turn-assistant{align-self:flex-start;background:var(--paper-raised)}
|
|
201
|
+
.turn:hover,.turn.active{border-color:var(--line-strong);background:inherit;box-shadow:0 0 0 2px rgba(62,76,97,.08)}
|
|
202
|
+
.turn-user:hover,.turn-user.active{background:#e9f0fb}
|
|
203
|
+
.turntop{justify-content:flex-end;gap:12px;min-width:0}.turntop span{min-width:0;overflow-wrap:anywhere;font-size:10px}
|
|
204
|
+
.turntext{white-space:normal;overflow-wrap:anywhere;word-break:break-word}
|
|
205
|
+
.session{display:grid;gap:4px;word-break:break-word;overflow-wrap:anywhere}
|
|
206
|
+
.transcript-label{font:10px Arial,system-ui,sans-serif;letter-spacing:.08em;text-transform:uppercase;color:var(--ink-faint)}
|
|
207
|
+
.transcript-path{display:block}
|
|
208
|
+
@media(max-width:800px){.compare,.turngrid{display:block}}
|
|
209
|
+
</style>'''
|
|
210
|
+
enhancement += '''<script>new MutationObserver(()=>document.querySelectorAll('.bar i').forEach(fill=>{const width=parseFloat(fill.style.width);if(width>0)fill.style.backgroundSize=(10000/width)+'% 100%'})).observe(document.documentElement,{childList:true,subtree:true});</script>'''
|
|
211
|
+
body = PAGE.replace("</head>", enhancement + "</head>").encode(); self.send_response(200); self.send_header("Content-Type", "text/html; charset=utf-8"); self.send_header("Content-Length", str(len(body))); self.end_headers(); self.wfile.write(body); return
|
|
212
|
+
if parsed.path == "/api/reports": self.send_json(report_summaries()); return
|
|
213
|
+
if parsed.path == "/api/report":
|
|
214
|
+
data = full_report(parse_qs(parsed.query).get("id", [""])[0]); self.send_json(data or {"error": "report not found"}, 404 if not data else 200); return
|
|
215
|
+
self.send_error(404)
|
|
216
|
+
def log_message(self, fmt, *args): pass
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def main():
|
|
220
|
+
global REPORTS
|
|
221
|
+
parser = argparse.ArgumentParser(description="View penny-compare reports in a browser")
|
|
222
|
+
parser.add_argument("--port", type=int, default=8788)
|
|
223
|
+
parser.add_argument("--reports", type=Path, default=REPORTS)
|
|
224
|
+
args = parser.parse_args()
|
|
225
|
+
REPORTS = args.reports.resolve()
|
|
226
|
+
server = ThreadingHTTPServer(("127.0.0.1", args.port), Handler)
|
|
227
|
+
print(f"Penny Compare viewer: http://127.0.0.1:{args.port}")
|
|
228
|
+
try: server.serve_forever()
|
|
229
|
+
except KeyboardInterrupt: pass
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
if __name__ == "__main__": main()
|