Ok, personally I think having magic files beginning with dot so nobody realizes they exist is dumb, but when in Rome...
Ha — fair. The leading-dot convention is pure Unix "hide it from ls" lore that stuck. For what it's worth, the .env name is at least a widely-recognized convention, so tools (and future-you) will know what it is. Rome it is.
Let me check that it's in place and make sure it's gitignored, without printing the secrets:
ls -la .env 2>/dev/null && echo "--- keys present? ---" && grep -oE '^(FRINGE_API_KEY|FRINGE_API_SECRET)=' .env 2>/dev/null && echo "--- gitignore ---" && cat .gitignore 2>/dev/null || echo "(no .gitignore yet)"
-rw-r--r-- 1 david david 83 Jul 2 09:21 .env --- keys present? --- FRINGE_API_KEY= FRINGE_API_SECRET= --- gitignore --- (no .gitignore yet)
Good — both keys are populated (values not shown), and there's no .gitignore yet. First order of business: protect that file before anything else. Then I'll build the tool.
Let me confirm the Python situation and lock down git:
python3 --version; echo "---"; git check-ignore .env 2>/dev/null && echo ".env already ignored" || echo ".env NOT ignored yet"
# Secrets — never commit .env # Local data cache *.sqlite *.sqlite-journal fringe.db # Python __pycache__/ *.pyc .venv/ venv/
File created successfully at: /home/david/projects/play/richhall/.gitignore (file state is current in your context — no need to Read it back)
.env is now protected. Now the tool. I'm going zero-dependency, stdlib-only (urllib, hmac, sqlite3, argparse) so there's nothing to pip install and it just runs. Writing it now:
#!/usr/bin/env python3
"""fringe — fetch Edinburgh Festival Fringe event data locally and query it
by day, performance start time, and distance from the city centre.
Zero dependencies: uses only the Python standard library.
Usage:
python3 fringe.py fetch # download/refresh the local cache
python3 fringe.py search --day 2026-08-15 --before 20:00 --max-distance 3km
python3 fringe.py info # show what's in the cache
Credentials are read from a .env file (or the environment):
FRINGE_API_KEY=...
FRINGE_API_SECRET=...
"""
import argparse
import hashlib
import hmac
import json
import math
import os
import sqlite3
import sys
import urllib.parse
import urllib.request
from datetime import datetime
API_DOMAIN = "https://api.edinburghfestivalcity.com"
DEFAULT_YEAR = 2026
DEFAULT_FESTIVAL = "fringe"
PAGE_SIZE = 100 # API maximum
# Rough centre of the Old Town / Royal Mile — a sensible "middle of town".
DEFAULT_CENTRE = (55.9497, -3.1875)
HERE = os.path.dirname(os.path.abspath(__file__))
DB_PATH = os.path.join(HERE, "fringe.db")
ENV_PATH = os.path.join(HERE, ".env")
# --------------------------------------------------------------------------- #
# Config / credentials
# --------------------------------------------------------------------------- #
def load_env(path=ENV_PATH):
"""Load KEY=VALUE lines from a .env file into os.environ (without
clobbering vars already set in the real environment)."""
if not os.path.exists(path):
return
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
key, value = key.strip(), value.strip().strip('"').strip("'")
os.environ.setdefault(key, value)
def get_credentials():
load_env()
key = os.environ.get("FRINGE_API_KEY", "").strip()
secret = os.environ.get("FRINGE_API_SECRET", "").strip()
if not key or not secret:
sys.exit(
"error: FRINGE_API_KEY and FRINGE_API_SECRET must be set "
f"(in the environment or in {ENV_PATH})."
)
return key, secret
# --------------------------------------------------------------------------- #
# Signed API requests
# --------------------------------------------------------------------------- #
def signed_url(path, params, key, secret):
"""Build a signed request URL.
The API signs the full path + query string (including the key, excluding
the domain) with HMAC-SHA1 keyed by the secret, then the hex signature is
appended as &signature=... The signed string must exactly match what is
sent, so we build the query string once and reuse it verbatim.
"""
items = list(params.items()) + [("key", key)]
query = urllib.parse.urlencode(items, quote_via=urllib.parse.quote)
signed_path = f"{path}?{query}"
signature = hmac.new(
secret.encode("utf-8"), signed_path.encode("utf-8"), hashlib.sha1
).hexdigest()
return f"{API_DOMAIN}{signed_path}&signature={signature}"
def api_get(path, params, key, secret):
url = signed_url(path, params, key, secret)
req = urllib.request.Request(url, headers={"Accept": "application/json"})
try:
with urllib.request.urlopen(req, timeout=60) as resp:
return json.loads(resp.read().decode("utf-8"))
except urllib.error.HTTPError as exc:
body = exc.read().decode("utf-8", "replace")[:500]
sys.exit(f"error: HTTP {exc.code} from {path}: {body}")
except urllib.error.URLError as exc:
sys.exit(f"error: could not reach API: {exc.reason}")
def paged_get(path, params, key, secret):
"""Yield records across all pages of a listing endpoint."""
offset = 0
while True:
page_params = dict(params, size=PAGE_SIZE, **{"from": offset})
data = api_get(path, page_params, key, secret)
# The listing endpoints return a JSON array; be tolerant of a dict
# wrapper just in case.
if isinstance(data, dict):
records = data.get("events") or data.get("venues") or data.get("results") or []
else:
records = data
if not records:
break
for rec in records:
yield rec
if len(records) < PAGE_SIZE:
break
offset += PAGE_SIZE
# --------------------------------------------------------------------------- #
# Database
# --------------------------------------------------------------------------- #
SCHEMA = """
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT,
sub_title TEXT,
artist TEXT,
genre TEXT,
genre_tags TEXT,
country TEXT,
age_category TEXT,
status TEXT,
description TEXT,
url TEXT,
website TEXT,
venue_id TEXT,
venue_name TEXT,
latitude REAL,
longitude REAL,
updated TEXT,
raw TEXT
);
CREATE TABLE IF NOT EXISTS performances (
perf_id TEXT PRIMARY KEY,
event_id TEXT,
start TEXT,
end TEXT,
duration_minutes INTEGER,
is_at_fixed_time INTEGER,
price_string TEXT,
price REAL,
title TEXT
);
CREATE TABLE IF NOT EXISTS venues (
id TEXT PRIMARY KEY,
name TEXT,
address TEXT,
post_code TEXT,
code TEXT,
latitude REAL,
longitude REAL,
raw TEXT
);
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
CREATE INDEX IF NOT EXISTS idx_perf_event ON performances(event_id);
CREATE INDEX IF NOT EXISTS idx_perf_start ON performances(start);
"""
def connect():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA)
return conn
def _to_float(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def store_event(conn, ev):
venue = ev.get("venue") or {}
pos = venue.get("position") or {}
lat = _to_float(ev.get("latitude")) or _to_float(pos.get("lat"))
lon = _to_float(ev.get("longitude")) or _to_float(pos.get("lon"))
conn.execute(
"""INSERT OR REPLACE INTO events
(id, title, sub_title, artist, genre, genre_tags, country,
age_category, status, description, url, website, venue_id,
venue_name, latitude, longitude, updated, raw)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
ev.get("id"),
ev.get("title"),
ev.get("sub_title"),
ev.get("artist"),
ev.get("genre"),
", ".join(ev.get("genre_tags") or []) if isinstance(ev.get("genre_tags"), list) else ev.get("genre_tags"),
ev.get("country"),
ev.get("age_category"),
ev.get("status"),
ev.get("description_teaser") or ev.get("description"),
ev.get("url"),
ev.get("website"),
venue.get("id"),
venue.get("name"),
lat,
lon,
ev.get("updated"),
json.dumps(ev),
),
)
# Refresh this event's performances.
conn.execute("DELETE FROM performances WHERE event_id = ?", (ev.get("id"),))
for p in ev.get("performances") or []:
conn.execute(
"""INSERT OR REPLACE INTO performances
(perf_id, event_id, start, end, duration_minutes,
is_at_fixed_time, price_string, price, title)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
p.get("id"),
ev.get("id"),
p.get("start"),
p.get("end"),
p.get("duration_minutes"),
1 if p.get("is_at_fixed_time") else 0,
p.get("price_string"),
_to_float(p.get("price")),
p.get("title"),
),
)
def store_venue(conn, v):
pos = v.get("position") or {}
conn.execute(
"""INSERT OR REPLACE INTO venues
(id, name, address, post_code, code, latitude, longitude, raw)
VALUES (?,?,?,?,?,?,?,?)""",
(
v.get("id"),
v.get("name"),
v.get("address"),
v.get("post_code"),
v.get("code"),
_to_float(pos.get("lat")),
_to_float(pos.get("lon")),
json.dumps(v),
),
)
# --------------------------------------------------------------------------- #
# Commands
# --------------------------------------------------------------------------- #
def cmd_fetch(args):
key, secret = get_credentials()
conn = connect()
base = {"festival": args.festival, "year": args.year}
print(f"Fetching {args.festival} events for {args.year} …")
n_events = 0
for ev in paged_get("/events", base, key, secret):
store_event(conn, ev)
n_events += 1
if n_events % 100 == 0:
print(f" {n_events} events …")
conn.commit()
conn.commit()
print(f"Fetching {args.festival} venues for {args.year} …")
n_venues = 0
for v in paged_get("/venues", base, key, secret):
store_venue(conn, v)
n_venues += 1
conn.commit()
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('last_fetch', ?)",
(datetime.now().isoformat(timespec="seconds"),),
)
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('festival', ?)",
(args.festival,),
)
conn.execute(
"INSERT OR REPLACE INTO meta (key, value) VALUES ('year', ?)",
(str(args.year),),
)
conn.commit()
print(f"Done. {n_events} events, {n_venues} venues cached in {DB_PATH}")
_print_date_range(conn)
conn.close()
def _parse_dt(value):
"""Parse an API datetime string tolerantly. Returns a datetime or None."""
if not value:
return None
value = value.strip().replace("T", " ")
# Drop timezone suffix / fractional seconds if present.
value = value.split("+")[0].split(".")[0].strip()
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(value, fmt)
except ValueError:
continue
return None
def _print_date_range(conn):
row = conn.execute(
"SELECT MIN(start) AS lo, MAX(start) AS hi, COUNT(*) AS n "
"FROM performances WHERE start IS NOT NULL AND start != ''"
).fetchone()
if row and row["n"]:
lo, hi = _parse_dt(row["lo"]), _parse_dt(row["hi"])
lo_s = lo.date().isoformat() if lo else row["lo"]
hi_s = hi.date().isoformat() if hi else row["hi"]
print(f"Performances span {lo_s} → {hi_s} ({row['n']} showings).")
def haversine_km(a, b):
lat1, lon1 = a
lat2, lon2 = b
r = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(h))
def _parse_distance(text):
"""Parse '3km' / '2miles' / '1.5mi' / '3' (km) into kilometres."""
text = text.strip().lower()
num = ""
for ch in text:
if ch.isdigit() or ch == ".":
num += ch
else:
break
if not num:
raise argparse.ArgumentTypeError(f"bad distance: {text!r}")
val = float(num)
unit = text[len(num):].strip()
if unit.startswith("mi"):
return val * 1.609344
return val # default km
def _parse_hm(text):
"""Parse 'HH:MM' into minutes-since-midnight."""
try:
h, m = text.split(":")
return int(h) * 60 + int(m)
except Exception:
raise argparse.ArgumentTypeError(f"bad time (want HH:MM): {text!r}")
def cmd_search(args):
conn = connect()
centre = DEFAULT_CENTRE
if args.centre:
try:
lat, lon = (float(x) for x in args.centre.split(","))
centre = (lat, lon)
except Exception:
sys.exit("error: --centre must be 'lat,lon' e.g. 55.9497,-3.1875")
max_km = _parse_distance(args.max_distance) if args.max_distance else None
after = _parse_hm(args.after) if args.after else None
before = _parse_hm(args.before) if args.before else None
sql = [
"SELECT p.start AS start, p.price_string AS price_string,",
" p.duration_minutes AS duration_minutes,",
" e.title AS title, e.artist AS artist, e.genre AS genre,",
" e.age_category AS age_category, e.status AS status,",
" e.venue_name AS venue_name, e.latitude AS lat, e.longitude AS lon,",
" e.url AS url",
"FROM performances p JOIN events e ON e.id = p.event_id",
"WHERE 1=1",
]
params = []
# Exclude cancelled/deleted by default.
sql.append("AND (e.status IS NULL OR e.status NOT IN ('cancelled','deleted'))")
if args.day:
sql.append("AND substr(p.start,1,10) = ?")
params.append(args.day)
if args.date_from:
sql.append("AND substr(p.start,1,10) >= ?")
params.append(args.date_from)
if args.date_to:
sql.append("AND substr(p.start,1,10) <= ?")
params.append(args.date_to)
if args.genre:
sql.append("AND (lower(e.genre) LIKE ? OR lower(e.genre_tags) LIKE ?)")
params += [f"%{args.genre.lower()}%", f"%{args.genre.lower()}%"]
if args.title:
sql.append("AND lower(e.title) LIKE ?")
params.append(f"%{args.title.lower()}%")
if args.artist:
sql.append("AND lower(e.artist) LIKE ?")
params.append(f"%{args.artist.lower()}%")
rows = conn.execute(" ".join(sql), params).fetchall()
# Post-filter on parsed time + computed distance (not easily done in SQL).
results = []
for r in rows:
dt = _parse_dt(r["start"])
if dt is None:
continue
minutes = dt.hour * 60 + dt.minute
if after is not None and minutes < after:
continue
if before is not None and minutes > before:
continue
dist = None
if r["lat"] is not None and r["lon"] is not None:
dist = haversine_km(centre, (r["lat"], r["lon"]))
if max_km is not None:
if dist is None or dist > max_km:
continue
results.append((dt, dist, r))
if args.sort == "distance":
results.sort(key=lambda x: (x[1] is None, x[1] if x[1] is not None else 0, x[0]))
else:
results.sort(key=lambda x: x[0])
_print_results(results, limit=args.limit)
conn.close()
def _print_results(results, limit):
if not results:
print("No performances match those filters.")
return
total = len(results)
shown = results[:limit] if limit else results
for dt, dist, r in shown:
when = dt.strftime("%a %d %b %H:%M")
dist_s = f"{dist:4.1f}km" if dist is not None else " ? km"
dur = f"{r['duration_minutes']}m" if r["duration_minutes"] else ""
price = r["price_string"] or ""
print(f"{when} {dist_s} {r['title']}")
meta = " · ".join(x for x in [r["venue_name"], r["genre"], dur, price] if x)
if meta:
print(f" {meta}")
print(f"\n{len(shown)} of {total} matching performances"
+ (f" (showing first {limit})" if limit and total > limit else ""))
def cmd_info(args):
if not os.path.exists(DB_PATH):
sys.exit(f"No cache yet. Run: python3 {os.path.basename(__file__)} fetch")
conn = connect()
meta = {row["key"]: row["value"] for row in conn.execute("SELECT key, value FROM meta")}
n_ev = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
n_pf = conn.execute("SELECT COUNT(*) FROM performances").fetchone()[0]
n_vn = conn.execute("SELECT COUNT(*) FROM venues").fetchone()[0]
print(f"Cache: {DB_PATH}")
print(f"Festival: {meta.get('festival','?')} Year: {meta.get('year','?')}")
print(f"Last fetch: {meta.get('last_fetch','never')}")
print(f"Events: {n_ev} Performances: {n_pf} Venues: {n_vn}")
_print_date_range(conn)
conn.close()
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def build_parser():
p = argparse.ArgumentParser(
prog="fringe",
description="Fetch and query Edinburgh Festival Fringe shows locally.",
)
sub = p.add_subparsers(dest="command", required=True)
f = sub.add_parser("fetch", help="download/refresh the local cache")
f.add_argument("--festival", default=DEFAULT_FESTIVAL)
f.add_argument("--year", type=int, default=DEFAULT_YEAR)
f.set_defaults(func=cmd_fetch)
s = sub.add_parser("search", help="query the local cache")
s.add_argument("--day", help="single date YYYY-MM-DD")
s.add_argument("--date-from", help="earliest date YYYY-MM-DD")
s.add_argument("--date-to", help="latest date YYYY-MM-DD")
s.add_argument("--after", help="earliest start time HH:MM")
s.add_argument("--before", help="latest start time HH:MM (e.g. 20:00)")
s.add_argument("--max-distance", help="max distance from centre, e.g. 3km or 2miles")
s.add_argument("--centre", help="centre point 'lat,lon' (default: Royal Mile)")
s.add_argument("--genre", help="genre substring, e.g. comedy")
s.add_argument("--title", help="title substring")
s.add_argument("--artist", help="artist substring")
s.add_argument("--sort", choices=["time", "distance"], default="time")
s.add_argument("--limit", type=int, default=50, help="max rows (0 = all)")
s.set_defaults(func=cmd_search)
i = sub.add_parser("info", help="show cache contents / date range")
i.set_defaults(func=cmd_info)
return p
def main(argv=None):
args = build_parser().parse_args(argv)
args.func(args)
if __name__ == "__main__":
main()
File created successfully at: /home/david/projects/play/richhall/fringe.py (file state is current in your context — no need to Read it back)
chmod +x fringe.py && python3 fringe.py --help && echo "=== FETCH ===" && python3 fringe.py fetch
usage: fringe [-h] {fetch,search,info} ...
Fetch and query Edinburgh Festival Fringe shows locally.
positional arguments:
{fetch,search,info}
fetch download/refresh the local cache
search query the local cache
info show cache contents / date range
options:
-h, --help show this help message and exit
=== FETCH ===
Fetching fringe events for 2026 …
100 events …
200 events …
300 events …
400 events …
500 events …
600 events …
700 events …
800 events …
900 events …
1000 events …
1100 events …
1200 events …
1300 events …
1400 events …
1500 events …
1600 events …
1700 events …
1800 events …
1900 events …
2000 events …
2100 events …
2200 events …
2300 events …
2400 events …
2500 events …
2600 events …
2700 events …
2800 events …
2900 events …
3000 events …
3100 events …
3200 events …
3300 events …
3400 events …
3500 events …
3600 events …
3700 events …
3800 events …
3900 events …
4000 events …
4100 events …
Fetching fringe venues for 2026 …
Done. 4113 events, 282 venues cached in /home/david/projects/play/richhall/fringe.db
Performances span 2026-08-02 → 2026-09-01 (59926 showings).It works, and 2026 data is confirmed live: 4,113 events, 282 venues, ~60k showings spanning 2 Aug → 1 Sep 2026. Auth signing is correct on the first try. Now let me exercise the actual use case — a realistic query:
echo "=== INFO ===" && python3 fringe.py info && echo && echo "=== SEARCH: comedy, one day, before 8pm, within 2km, nearest first ===" && python3 fringe.py search --day 2026-08-15 --before 20:00 --max-distance 2km --genre comedy --sort distance --limit 12
=== INFO ===
Cache: /home/david/projects/play/richhall/fringe.db
Festival: fringe Year: 2026
Last fetch: 2026-07-02T09:29:11
Events: 4113 Performances: 59926 Venues: 282
Performances span 2026-08-02 → 2026-09-01 (59926 showings).
=== SEARCH: comedy, one day, before 8pm, within 2km, nearest first ===
Sat 15 Aug 12:10 0.0km Chris Cantrill: Rewilding
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £15
Sat 15 Aug 13:35 0.0km Rachel Fairburn: Vexy Beasts
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £14 (12)
Sat 15 Aug 15:00 0.0km Amy Matthews: Definitions of Toast
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £14
Sat 15 Aug 16:25 0.0km Tom Ballard: Be Funny Challenge (Impossible)
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £15
Sat 15 Aug 17:50 0.0km Rob Copland: ONE (more please)
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £14 (13)
Sat 15 Aug 19:15 0.0km Katie Boyle: Irish Immigrant
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £15 (12)
Sat 15 Aug 12:15 0.0km Robin Boot's Rockomedy: Pun to the Hills
PBH's Free Fringe @ Whistlebinkies · Comedy · 60m · Free
Sat 15 Aug 16:00 0.0km Friz Frizzle: A Certain Appeal
PBH's Free Fringe @ Whistlebinkies · Comedy · 60m · Free
Sat 15 Aug 16:15 0.0km Affirmation (WIP)
PBH's Free Fringe @ Whistlebinkies · Comedy · 50m · Free
Sat 15 Aug 17:15 0.0km Jollybox Party Games
PBH's Free Fringe @ Whistlebinkies · Comedy · 50m · Free
Sat 15 Aug 12:30 0.0km 1 Hour of Clean Comedy
Just The Tonic at The Hive · Comedy · 60m · £6
Sat 15 Aug 13:50 0.0km Barry Potter and the Magic of Wizardry
Just The Tonic at The Hive · Children's Shows · 60m · £12 (11)
12 of 1146 matching performances (showing first 12)End-to-end success — signing, pagination, storage, and all three filters (day, time, distance) work against live 2026 data. But one thing looks off: every distance is 0.0km. Those venues are central, but they're not all on the exact same spot — I'd expect 0.1–0.3km variation. Let me verify the distance calc isn't silently broken:
python3 - <<'PY'
import sqlite3, fringe
c = sqlite3.connect("fringe.db"); c.row_factory = sqlite3.Row
print("centre:", fringe.DEFAULT_CENTRE)
print("\n-- sample event coords + computed distance --")
for r in c.execute("SELECT DISTINCT venue_name, latitude, longitude FROM events WHERE latitude IS NOT NULL LIMIT 8"):
d = fringe.haversine_km(fringe.DEFAULT_CENTRE, (r['latitude'], r['longitude']))
print(f"{d:6.3f}km ({r['latitude']}, {r['longitude']}) {r['venue_name']}")
print("\n-- distinct coordinate count across all events --")
print(c.execute("SELECT COUNT(DISTINCT latitude||','||longitude) FROM events WHERE latitude IS NOT NULL").fetchone()[0], "distinct points")
print("events with NULL coords:", c.execute("SELECT COUNT(*) FROM events WHERE latitude IS NULL").fetchone()[0])
print("\n-- do venues table coords differ from events table? sample venue --")
for r in c.execute("SELECT name, latitude, longitude FROM venues WHERE latitude IS NOT NULL LIMIT 5"):
d = fringe.haversine_km(fringe.DEFAULT_CENTRE, (r['latitude'], r['longitude']))
print(f"{d:6.3f}km ({r['latitude']}, {r['longitude']}) {r['name']}")
PYcentre: (55.9497, -3.1875) -- sample event coords + computed distance -- 0.344km (55.948164, -3.192293) Underbelly, Cowgate 0.398km (55.948552, -3.193563) PBH's Free Fringe @ Liquid Room 0.384km (55.94922, -3.1936) Greenside @ Riddles Court 0.180km (55.950279, -3.190207) Edinburgh City Chambers (Meeting Point) 0.185km (55.948038, -3.187322) Gilded Balloon Patter House 1.125km (55.945758, -3.20414) Hoots @ Hilton (Bread Street) 0.709km (55.953551, -3.196569) Greenside @ George Street 0.745km (55.955873, -3.192146) The Stand Comedy Club 2 -- distinct coordinate count across all events -- 277 distinct points events with NULL coords: 0 -- do venues table coords differ from events table? sample venue -- 3.032km (55.939088, -3.232348) Tynecastle Park 0.924km (55.944936, -3.199666) Novotel 0.572km (55.944553, -3.187476) Outside Appleton Tower, Crichton St 0.180km (55.950279, -3.190207) Edinburgh City Chambers (Meeting Point) 3.293km (55.926914, -3.153717) Meeting Point at Bridgend Farmhouse
Good — the calculation is correct (distances range 0.18–3.3km, 277 distinct points, zero nulls). The 0.0km readout was real, not a bug: I happened to center on the Royal Mile, and those venues (O'Neill's at the Tron, Whistlebinkies and The Hive on Niddry Street) literally cluster within ~100m of that point — so 1-decimal rounding flattens them all to 0.0. Let me confirm and bump the display precision, since for a walkable-city decision sub-100m resolution actually matters:
python3 - <<'PY'
import sqlite3, fringe
c = sqlite3.connect("fringe.db"); c.row_factory = sqlite3.Row
for name in ("O'Neill's","Whistlebinkies","The Hive"):
r = c.execute("SELECT DISTINCT venue_name, latitude, longitude FROM events WHERE venue_name LIKE ? LIMIT 1", (f"%{name}%",)).fetchone()
if r:
d = fringe.haversine_km(fringe.DEFAULT_CENTRE, (r['latitude'], r['longitude']))
print(f"{d*1000:5.0f} m {r['venue_name']}")
PY10 m Monkey Barrel Comedy at O'Neill's (The Tron) 31 m PBH's Free Fringe @ Whistlebinkies 32 m Just The Tonic at The Hive
Confirmed genuine — 10m, 31m, 32m from the centre point. Not a bug at all; just coarse display. I'll bump distance to 2 decimals so sub-100m differences are visible:
dist_s = f"{dist:4.1f}km" if dist is not None else " ? km" dist_s = f"{dist:5.2f}km" if dist is not None else " ? km"The file /home/david/projects/play/richhall/fringe.py has been updated successfully. (file state is current in your context — no need to Read it back)
python3 fringe.py search --day 2026-08-15 --before 20:00 --max-distance 2km --genre comedy --sort distance --limit 6
Sat 15 Aug 12:10 0.01km Chris Cantrill: Rewilding
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £15
Sat 15 Aug 13:35 0.01km Rachel Fairburn: Vexy Beasts
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £14 (12)
Sat 15 Aug 15:00 0.01km Amy Matthews: Definitions of Toast
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £14
Sat 15 Aug 16:25 0.01km Tom Ballard: Be Funny Challenge (Impossible)
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £15
Sat 15 Aug 17:50 0.01km Rob Copland: ONE (more please)
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £14 (13)
Sat 15 Aug 19:15 0.01km Katie Boyle: Irish Immigrant
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £15 (12)
6 of 1146 matching performances (showing first 6)The tool is complete and working end-to-end. Let me add a short README so the usage is self-documenting, then I'll summarize:
# fringe
A tiny, zero-dependency CLI to copy Edinburgh Festival Fringe event data
locally and query it by the things that actually decide a ticket purchase:
**which day**, **what time it starts**, and **how far the venue is from the
centre of town**.
Only the Python 3 standard library is used — nothing to install.
## Setup
Put your API credentials in a `.env` file next to `fringe.py` (already
gitignored):
```
FRINGE_API_KEY=your_key
FRINGE_API_SECRET=your_secret
```
## Usage
```bash
# Download / refresh the local cache (SQLite: fringe.db)
python3 fringe.py fetch # defaults: festival=fringe, year=2026
# What's cached?
python3 fringe.py info
# Find shows: a given day, starting before 8pm, within 3km of the centre
python3 fringe.py search --day 2026-08-15 --before 20:00 --max-distance 3km
# Everything is composable:
python3 fringe.py search \
--date-from 2026-08-15 --date-to 2026-08-16 \
--after 12:00 --before 20:00 \
--max-distance 2km --genre comedy \
--sort distance
```
### search filters
| flag | meaning |
|-------------------|------------------------------------------------|
| `--day` | single date `YYYY-MM-DD` |
| `--date-from/-to` | date range |
| `--after` | earliest start time `HH:MM` |
| `--before` | latest start time `HH:MM` (e.g. `20:00`) |
| `--max-distance` | e.g. `3km`, `2miles` — from the centre point |
| `--centre` | override centre as `lat,lon` (default: Royal Mile) |
| `--genre` | substring match, e.g. `comedy` |
| `--title`/`--artist` | substring match |
| `--sort` | `time` (default) or `distance` |
| `--limit` | max rows (0 = all) |
## How it works
- **Auth**: each request is signed with HMAC-SHA1 over the path+query string
(including the key), per the Fringe API docs.
- **Storage**: `fringe.db` holds one row per *performance* (the natural grain
for picking a specific showing), plus events and venues, and the raw JSON.
- **Distance**: haversine from a configurable centre using each venue's
latitude/longitude from the API — no geocoding needed.
<tool_use_error>File has not been read yet. Read it first before writing to it.</tool_use_error>
# richhall
Utility for Edinburgh Festival analytics.
A tiny, zero-dependency CLI (`fringe.py`) to copy Edinburgh Festival Fringe
event data locally and query it by the things that actually decide a ticket
purchase: **which day**, **what time it starts**, and **how far the venue is
from the centre of town**.
Only the Python 3 standard library is used — nothing to install.
## Setup
Put your API credentials in a `.env` file next to `fringe.py` (already
gitignored):
```
FRINGE_API_KEY=your_key
FRINGE_API_SECRET=your_secret
```
## Usage
```bash
# Download / refresh the local cache (SQLite: fringe.db)
python3 fringe.py fetch # defaults: festival=fringe, year=2026
# What's cached?
python3 fringe.py info
# Find shows: a given day, starting before 8pm, within 3km of the centre
python3 fringe.py search --day 2026-08-15 --before 20:00 --max-distance 3km
# Everything is composable:
python3 fringe.py search \
--date-from 2026-08-15 --date-to 2026-08-16 \
--after 12:00 --before 20:00 \
--max-distance 2km --genre comedy \
--sort distance
```
### search filters
| flag | meaning |
|----------------------|----------------------------------------------------|
| `--day` | single date `YYYY-MM-DD` |
| `--date-from/-to` | date range |
| `--after` | earliest start time `HH:MM` |
| `--before` | latest start time `HH:MM` (e.g. `20:00`) |
| `--max-distance` | e.g. `3km`, `2miles` — from the centre point |
| `--centre` | override centre as `lat,lon` (default: Royal Mile) |
| `--genre` | substring match, e.g. `comedy` |
| `--title`/`--artist` | substring match |
| `--sort` | `time` (default) or `distance` |
| `--limit` | max rows (0 = all) |
## How it works
- **Auth**: each request is signed with HMAC-SHA1 over the path+query string
(including the key), per the Fringe API docs.
- **Storage**: `fringe.db` holds one row per *performance* (the natural grain
for picking a specific showing), plus events and venues, and the raw JSON.
- **Distance**: haversine from a configurable centre using each venue's
latitude/longitude from the API — no geocoding needed.
The file /home/david/projects/play/richhall/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
Done. The tool is built, working against the live API, and documented.
A single, zero-dependency script fringe.py (stdlib only — nothing to pip install) with three commands:
fetch — signs requests with HMAC-SHA1, pages through all events + venues, and stores them in a local SQLite cache (fringe.db).search — your three filters plus extras: --day / --date-from/-to, --after / --before (start time), --max-distance (3km/2miles from a configurable centre), --genre, --title, --artist, --sort time|distance.info — shows what's cached and the date span.The first real fetch pulled 4,113 events, 282 venues, ~60k performances for 2026 (confirmed by the absolute dates: 2 Aug → 1 Sep 2026). The example query — comedy, 15 Aug, before 8pm, within 2km, nearest first — returned sensible results, and I confirmed the distance math against known venues (O'Neill's at the Tron = 10m from the Royal Mile centre point).
55.9497,-3.1875); override per-query with --centre lat,lon. Displayed to 2 decimals so walkable sub-100m differences show..env, fringe.db, and Python cruft are all gitignored.Two natural follow-ups when you want them:
1. A two-day-trip helper — one command that takes your two dates + a distance/time budget and ranks candidate shows (or flags time-conflicting overlaps so you can plan a double-bill).
2. Incremental sync — the API exposes modified_from, so fetch could do fast delta updates instead of a full re-pull.
I haven't committed anything — say the word and I'll commit the initial tool. Want me to build either follow-up?
Ok that all looks awesome thanks! Before we proceed, while I appreciate your zeal in creating a single no-dependency script, I think this project will likely grow larger in the future and I'm comfortable with a Python CLI tool recipe we have used several times in other projects. You can see it in this project: https://github.com/stirlingbridge/machine . Perhaps you could modify this project to use a similar technique, or feel free to suggest alternative approaches if you know of a better way. We have also used "shiv" to create stand-alone redistributable "binaries" for similar Python projects, but that's not a requirement here yet.
Let me study the reference project's structure so I match the convention you already use. Let me pull its layout and key files:
cd /tmp/claude-1000/-home-david-projects-play-richhall/c0c82fca-9e48-466a-9038-dd858a280304/scratchpad && gh repo view stirlingbridge/machine 2>&1 | head -20; echo "=== TREE ==="; gh api repos/stirlingbridge/machine/git/trees/HEAD?recursive=1 --jq '.tree[].path' 2>&1 | grep -vE '(^\.github|tests/fixtures)' | head -80
To get started with GitHub CLI, please run: gh auth login Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token. === TREE === To get started with GitHub CLI, please run: gh auth login Alternatively, populate the GH_TOKEN environment variable with a GitHub API authentication token. Shell cwd was reset to /home/david/projects/play/richhall
curl -sS -m 30 https://api.github.com/repos/stirlingbridge/machine/git/trees/HEAD?recursive=1 2>&1 | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('message','')) or [print(t['path']) for t in d.get('tree',[]) if not t['path'].startswith('.github')]" 2>&1 | grep -vE 'tests/fixtures' | head -80.envrc .gitignore .vscode .vscode/launch.json .vscode/settings.json CLAUDE.md LICENSE MANIFEST.in Makefile README.md machine machine/__init__.py machine/cloud_config.py machine/config.py machine/constants.py machine/defaults.py machine/di.py machine/factory.py machine/log.py machine/main.py machine/provider.py machine/providers machine/providers/__init__.py machine/providers/digitalocean.py machine/providers/gcp.py machine/providers/vultr.py machine/subcommands machine/subcommands/__init__.py machine/subcommands/check.py machine/subcommands/create.py machine/subcommands/destroy.py machine/subcommands/domains.py machine/subcommands/info.py machine/subcommands/list.py machine/subcommands/list_domain.py machine/subcommands/projects.py machine/subcommands/ssh_keys.py machine/subcommands/status.py machine/subcommands/types.py machine/types.py machine/util.py pyproject.toml sh sh/build-package.sh sh/dev-setup.sh sh/lint.sh sh/make-release-tag.sh tests tests/E2E.md tests/__init__.py tests/test_cli_integration.py tests/test_cloud_config.py tests/test_config.py tests/test_e2e.py tests/test_input_validation.py uv.lock
base="https://raw.githubusercontent.com/stirlingbridge/machine/HEAD" for f in pyproject.toml Makefile MANIFEST.in .envrc CLAUDE.md machine/main.py machine/subcommands/__init__.py machine/config.py machine/constants.py machine/log.py machine/di.py machine/factory.py; do echo "########## $f ##########" curl -sS -m 20 "$base/$f" 2>&1 echo done
########## pyproject.toml ##########
[project]
name = "machine"
version = "1.0.0"
description = "Utility for creating and managing VMs"
readme = "README.md"
license = "AGPL-3.0-only"
requires-python = ">=3.10"
authors = [
{ name = "Stirlingbridge", email = "info@stirlingbridge.website" },
]
classifiers = [
"Programming Language :: Python :: 3.10",
"Operating System :: OS Independent",
]
dependencies = [
"click==8.1.7",
"python-digitalocean==1.17.0",
"ruamel.yaml>=0.17.32",
"requests>=2.32.4",
"expandvars==1.0.0",
"vultr-python>=0.1.5",
"google-cloud-compute>=1.19.0",
"google-cloud-dns>=0.35.0",
]
[project.scripts]
machine = "machine.main:main"
[project.urls]
Homepage = "https://github.com/stirlingbridge/machine"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
markers = [
"e2e: end-to-end tests that create real resources on DigitalOcean (require E2E_* env vars)",
]
addopts = "-m 'not e2e'"
[dependency-groups]
dev = [
"ruff",
"pytest",
]
[tool.ruff]
line-length = 132
[tool.ruff.lint]
select = ["E", "F", "W", "C90"]
ignore = ["E203"]
[tool.ruff.lint.mccabe]
max-complexity = 25
[tool.ruff.lint.per-file-ignores]
"machine/cloud_config.py" = ["E501"]
########## Makefile ##########
# Setup dev environment
dev:
uv sync
build:
./sh/build-package.sh
test:
uv run pytest tests/ -v
test-e2e:
uv run pytest tests/test_e2e.py -v -m e2e
lint:
uv run ruff check machine/
uv run ruff format --check machine/
########## MANIFEST.in ##########
include LICENSE
########## .envrc ##########
. dev/bin/activate
########## CLAUDE.md ##########
# CLAUDE.md
## Project Overview
CLI tool (`machine`) for creating and managing VMs on DigitalOcean and Vultr. Built with Python and Click, packaged as a single-file executable via shiv.
- **Org**: Stirlingbridge (`github.com/stirlingbridge/machine`)
- **License**: AGPL-3.0-only
- **Python**: >=3.8 (CI builds on 3.8)
## Tech Stack
- **CLI framework**: Click 8.1.7
- **Cloud providers**: python-digitalocean 1.17.0, vultr-python >=0.1.5
- **Config**: ruamel.yaml (reads `~/.machine/config.yml`)
- **Build tooling**: uv (dependency management), hatchling (build backend), shiv (zipapp packaging)
## Project Structure
```
machine/ # Main package
main.py # Click group entry point
config.py # Config file loading
provider.py # Abstract CloudProvider base class
di.py # Dependency injection / globals
factory.py # VM creation factory
cloud_config.py # Cloud-init config generation
providers/ # Provider implementations
__init__.py # Provider registry & factory
digitalocean.py # DigitalOcean provider
vultr.py # Vultr provider
subcommands/ # Click subcommands (create, destroy, list, status, etc.)
sh/ # Shell scripts (build, lint, dev-setup)
pyproject.toml # Project metadata and dependencies
```
## Development Commands
```bash
uv sync # Install dependencies (creates .venv)
uv run machine --help # Run CLI in development
uv run ruff check machine/ # Lint
./sh/lint.sh --fix # Auto-format with ruff, then lint
./sh/build-package.sh # Build shiv executable to build/machine
make dev # Alias for uv sync
make build # Alias for build-package.sh
make lint # Alias for ruff check + format check
```
## Code Style
- **Linter & Formatter**: ruff (line length 132, max complexity 25, E203 ignored)
- Config in `pyproject.toml` under `[tool.ruff]`
## CI/CD
GitHub Actions workflow (`.github/workflows/build-release.yml`) builds a shiv package and publishes it as a GitHub release on push to `main`.
########## machine/main.py ##########
import os
import click
from machine import config
from machine import constants
from machine.di import d
from machine.log import output
from machine.providers import create_provider
from machine.subcommands import check, create, destroy, info, list, projects, ssh_keys, domains, list_domain, types, status
from machine.types import CliOptions, MainCmdCtx
from machine.util import load_session_id
CLICK_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group(context_settings=CLICK_CONTEXT_SETTINGS)
@click.option("--debug", is_flag=True, default=False, help="Enable debug output")
@click.option("--quiet", is_flag=True, default=False, help="Suppress all non-essential output")
@click.option("--verbose", is_flag=True, default=False, help="Enable verbose output")
@click.option("--dry-run", is_flag=True, default=False, help="Run but do not do anything")
@click.option("--config-file", metavar="<PATH>", help=f"Specify the config file (default {constants.default_config_file_path})")
@click.option(
"--session-id", metavar="<ID>", default=load_session_id, help="Override the default session ID (default: from session-id.yml)"
)
@click.pass_context
def main(context, debug, quiet, verbose, dry_run, config_file, session_id):
options = CliOptions(debug, quiet, verbose, dry_run)
d.opt = options
# Skip config loading for version subcommand since it doesn't need it
# and should work even when no config file exists (#25)
if context.invoked_subcommand == "version":
return
cfg = config.get(config_file)
provider = create_provider(cfg.provider_name, cfg.provider_config)
main_context = MainCmdCtx(cfg, session_id, provider)
context.obj = main_context
@main.command()
@click.pass_context
def version(context):
try:
version_file = os.path.join(os.path.dirname(__file__), "version.txt")
with open(version_file) as f:
version_string = f.read().strip()
except FileNotFoundError:
version_string = "dev"
output(version_string)
main.add_command(check.command, "check")
main.add_command(create.command, "create")
main.add_command(destroy.command, "destroy")
main.add_command(domains.command, "domains")
main.add_command(info.command, "info")
main.add_command(list.command, "list")
main.add_command(list_domain.command, "list-domain")
main.add_command(projects.command, "projects")
main.add_command(ssh_keys.command, "ssh-keys")
main.add_command(types.command, "types")
main.add_command(status.command, "status")
########## machine/subcommands/__init__.py ##########
########## machine/config.py ##########
import os
import re
from pathlib import Path
from machine.di import d
from machine.factory import yaml
from machine.log import fatal_error, debug
from machine.types import Config, MachineConfig
from machine import constants
from machine.providers import KNOWN_PROVIDERS
_env_var_pattern = re.compile(r"\$\{([^}]+)\}")
def _expand_env_vars(value):
if isinstance(value, str):
def _replace(match):
expr = match.group(1)
if ":-" in expr:
var_name, default = expr.split(":-", 1)
return os.environ.get(var_name, default)
else:
if expr not in os.environ:
fatal_error(f"Environment variable '{expr}' referenced in config is not set")
return os.environ[expr]
return _env_var_pattern.sub(_replace, value)
elif isinstance(value, dict):
return {k: _expand_env_vars(v) for k, v in value.items()}
elif isinstance(value, list):
return [_expand_env_vars(item) for item in value]
return value
class _loaded_config:
c: any = None
def resolve_config_file_path(config_file_name: str) -> str:
if not config_file_name:
config_file_name = constants.default_config_file_path
return str(Path(os.path.expanduser(config_file_name)))
def _load_config_data(config_file_name: str):
config_path = Path(resolve_config_file_path(config_file_name))
if not config_path.exists():
fatal_error(f"Error: Config file: {config_path} not found")
config = _expand_env_vars(yaml().load(open(config_path, "r")))
if d.opt.debug:
debug(f"Loaded config file: {config_path}")
debug(f"Parsed config: {config}")
_loaded_config.c = config
return config
def _require_key(d, key, section_name):
if key not in d:
fatal_error(f"Required key '{key}' not found in '{section_name}' section of config file")
return d[key]
def _get_ssh_keys(provider_config, section_name) -> list:
"""Read the 'ssh-key' config value, accepting either a single name (a
string) or a list of names. Always returns a non-empty list of names."""
value = _require_key(provider_config, "ssh-key", section_name)
if isinstance(value, str):
keys = [value]
elif isinstance(value, list):
keys = [str(k) for k in value]
else:
fatal_error(f"Config key 'ssh-key' in '{section_name}' section must be a name or a list of names")
if not keys:
fatal_error(f"Config key 'ssh-key' in '{section_name}' section must specify at least one SSH key")
return keys
def get(config_file_name: str) -> Config:
config = _load_config_data(config_file_name)
# Auto-detect provider from config sections
provider_name = config.get("provider")
if not provider_name:
found = [p for p in KNOWN_PROVIDERS if p in config]
if len(found) == 0:
fatal_error(
"No provider section found in config file. Expected one of: " + ", ".join(KNOWN_PROVIDERS)
)
if len(found) > 1:
fatal_error(
"Multiple provider sections found in config file. Please add a 'provider:' key to select one."
)
provider_name = found[0]
if provider_name not in config:
fatal_error(f"Provider '{provider_name}' specified but no '{provider_name}' section found in config file")
provider_config = config[provider_name]
return Config(
provider_name=provider_name,
provider_config=provider_config,
ssh_keys=_get_ssh_keys(provider_config, provider_name),
dns_zone=provider_config.get("dns-zone"),
machine_size=_require_key(provider_config, "machine-size", provider_name),
image=_require_key(provider_config, "image", provider_name),
region=_require_key(provider_config, "region", provider_name),
project=provider_config.get("project"),
)
def get_machine(name: str) -> MachineConfig:
if not _loaded_config.c:
fatal_error("Attempt to fetch machine data before config loaded")
config = _loaded_config.c
if "machines" not in config:
fatal_error("Required 'machines' section not found in config file")
config_machines = config["machines"]
if name not in config_machines:
fatal_error(f"Machine type '{name}' not found in config file. Available types: {', '.join(config_machines.keys())}")
target_config = config_machines[name]
return MachineConfig(
_require_key(target_config, "new-user-name", f"machines.{name}"),
target_config.get("script-url"),
target_config.get("script-dir"),
target_config.get("script-path"),
target_config.get("script-args"),
)
def get_machines():
if not _loaded_config.c:
fatal_error("Attempt to fetch machine data before config loaded")
config = _loaded_config.c
if "machines" not in config:
fatal_error("Required 'machines' section not found in config file")
ret = {}
for name in config["machines"]:
ret[name] = get_machine(name)
return ret
########## machine/constants.py ##########
import sys
from pathlib import Path
_new_config_dir = str(Path.home() / ".config" / "machine")
_old_config_dir = str(Path.home() / ".machine")
def _resolve_config_dir():
new_path = Path(_new_config_dir)
old_path = Path(_old_config_dir)
if new_path.exists():
return _new_config_dir
if old_path.exists():
print(
f"Warning: config directory {_old_config_dir} is deprecated, "
f"please move it to {_new_config_dir}",
file=sys.stderr,
)
return _old_config_dir
return _new_config_dir
default_config_dir_path = _resolve_config_dir()
default_config_file_path = default_config_dir_path + "/config.yml"
default_session_id_file_path = default_config_dir_path + "/session-id.yml"
########## machine/log.py ##########
import sys
def fatal_error(s: str):
print(s, file=sys.stderr)
sys.exit(1)
def debug(s: str):
print(s, file=sys.stderr)
def info(s: str):
print(s, file=sys.stderr)
def output(s: str):
print(s)
########## machine/di.py ##########
# Exposing the truth that dependency injection is just a fancy name for global variables
from machine.types import CliOptions
class d:
opt: CliOptions = None
########## machine/factory.py ##########
import ruamel.yaml
def yaml():
yaml = ruamel.yaml.YAML()
yaml.preserve_quotes = True
yaml.indent(sequence=3, offset=1)
return yamlLet me grab a sample subcommand, types.py, and the shell scripts to fully match the idiom:
base="https://raw.githubusercontent.com/stirlingbridge/machine/HEAD" for f in machine/subcommands/list.py machine/subcommands/info.py machine/types.py machine/util.py sh/build-package.sh sh/dev-setup.sh sh/lint.sh .gitignore; do echo "########## $f ##########" curl -sS -m 20 "$base/$f" 2>&1 echo done
########## machine/subcommands/list.py ##########
import click
import json
from machine.log import fatal_error, output
from machine.types import MainCmdCtx, TAG_MACHINE_TYPE_PREFIX, TAG_MACHINE_SESSION_PREFIX
from machine.util import get_machine_type, is_machine_created, is_same_session, vm_to_json_obj
def print_normal(vms):
for vm in vms:
region = vm.region if vm.region else "unknown"
output(f"{vm.name} ({vm.id}, {region}, {get_machine_type(vm)}): {vm.ip_address}")
def print_quiet(vms):
for vm in vms:
output(vm.id)
def print_json(vms):
output(json.dumps([vm_to_json_obj(v) for v in vms]))
def get_vms(command_context, id=None, name=None, tag=None, type=None, region=None, all=False):
provider = command_context.provider
vms = []
if id:
vm = provider.get_vm(id)
if vm:
vms.append(vm)
if all:
vms = provider.list_vms()
else:
vms = provider.list_vms(tag=TAG_MACHINE_SESSION_PREFIX + command_context.session_id)
# we can't combine most filters over the API, so we also filter ourselves
if name:
vms = filter(lambda v: v.name == name, vms)
if tag:
vms = filter(lambda v: tag in v.tags, vms)
if type:
vms = filter(lambda v: TAG_MACHINE_TYPE_PREFIX + type.lower() in v.tags, vms)
if region:
vms = filter(lambda v: v.region and region == v.region, vms)
if not all:
vms = filter(lambda v: is_machine_created(v) and is_same_session(command_context, v), vms)
return list(vms)
@click.command(help="List machines")
@click.option("--id", metavar="<MACHINE-ID>", help="Filter by id")
@click.option("--name", "-n", metavar="<MACHINE-NAME>", help="Filter by name")
@click.option("--tag", "-t", metavar="<TAG-TEXT>", help="Filter by tag")
@click.option("--type", "-m", metavar="<MACHINE-TYPE>", help="Filter by type")
@click.option("--region", "-r", metavar="<REGION>", help="Filter by region")
@click.option("--output", "-o", metavar="<FORMAT>", help="Output format")
@click.option(
"--all",
is_flag=True,
default=False,
help="All machines, including those not created by this tool or by other sessions",
)
@click.option("--quiet", "-q", is_flag=True, default=False, help="Only display machine IDs")
@click.option(
"--unique",
is_flag=True,
default=False,
help="Return an error if there is more than one match",
)
@click.pass_context
def command(context, id, name, tag, type, region, all, output, quiet, unique):
command_context: MainCmdCtx = context.obj
vms = get_vms(command_context, id, name, tag, type, region, all)
if unique and len(vms) > 1:
fatal_error(f"ERROR: --unique match required but {len(vms)} matches found.")
if output == "json":
print_json(vms)
elif quiet:
print_quiet(vms)
else:
print_normal(vms)
########## machine/subcommands/info.py ##########
import click
from machine.config import resolve_config_file_path
from machine.constants import default_session_id_file_path
from machine.log import output
from machine.providers import KNOWN_PROVIDERS
from machine.types import MainCmdCtx
@click.command(help="Show diagnostic information about the current configuration")
@click.pass_context
def command(context):
command_context: MainCmdCtx = context.obj
config_file_option = context.parent.params.get("config_file")
config_file = resolve_config_file_path(config_file_option)
output(f"Config file: {config_file}")
output("")
output("Config file contents:")
with open(config_file, "r") as f:
output(f.read().rstrip())
output("")
output(f"Session ID file: {default_session_id_file_path}")
output(f"Session ID: {command_context.session_id}")
output("")
output(f"Supported providers: {', '.join(KNOWN_PROVIDERS)}")
output(f"Active provider: {command_context.config.provider_name}")
########## machine/types.py ##########
from dataclasses import dataclass
TAG_MACHINE_CREATED = "machine:created"
TAG_MACHINE_TYPE_PREFIX = "machine:type:"
TAG_MACHINE_SESSION_PREFIX = "machine:session:"
@dataclass
class CliOptions:
debug: bool
quiet: bool
verbose: bool
dry_run: bool
@dataclass
class Config:
provider_name: str
provider_config: dict
ssh_keys: list
dns_zone: str
machine_size: str
image: str
region: str
project: str
@dataclass
class MainCmdCtx:
config: Config
session_id: str
provider: object # CloudProvider instance
@dataclass
class MachineConfig:
new_user_name: str
script_url: str
script_dir: str
script_path: str
script_args: str
########## machine/util.py ##########
import os
import uuid
from machine.factory import yaml
from machine.constants import default_config_dir_path, default_session_id_file_path
from machine.types import TAG_MACHINE_TYPE_PREFIX, TAG_MACHINE_CREATED
from machine.types import MainCmdCtx, TAG_MACHINE_SESSION_PREFIX
def get_machine_type(vm):
type = next((t for t in vm.tags if TAG_MACHINE_TYPE_PREFIX in t), "").replace(TAG_MACHINE_TYPE_PREFIX, "")
if not type:
return None
return type
def is_machine_created(vm):
return TAG_MACHINE_CREATED in vm.tags
def is_same_session(command_context: MainCmdCtx, vm):
return TAG_MACHINE_SESSION_PREFIX + command_context.session_id in vm.tags
def load_session_id():
if not os.path.exists(default_config_dir_path):
os.makedirs(default_config_dir_path, exist_ok=True)
if not os.path.exists(default_session_id_file_path):
with open(default_session_id_file_path, "w") as f:
f.write("id: " + str(uuid.uuid4()).replace("-", "")[0:8])
sessionid_config = yaml().load(open(default_session_id_file_path, "r"))
return sessionid_config["id"]
def vm_to_json_obj(vm):
return {
"id": vm.id,
"name": vm.name,
"tags": vm.tags,
"region": vm.region,
"ip": vm.ip_address,
"type": get_machine_type(vm),
}
def dns_record_to_json_obj(dns_record, zone, vm):
if vm:
vm = vm_to_json_obj(vm)
return {
"id": dns_record.id,
"machine": vm,
"name": dns_record.name,
"fqdn": f"{dns_record.name}.{zone}",
"zone": zone,
"data": dns_record.data,
"ttl": dns_record.ttl,
"type": dns_record.type,
}
########## sh/build-package.sh ##########
#!/bin/bash
set -e
mkdir -p ./build
./sh/make-release-tag.sh > ./machine/version.txt
uvx shiv -c machine -o build/machine .
########## sh/dev-setup.sh ##########
uv sync
########## sh/lint.sh ##########
#!/bin/bash
if [[ "$1" == "--fix" ]]; then
uv run ruff format machine/
uv run ruff check --fix machine/
else
uv run ruff format --check machine/
uv run ruff check machine/
fi
########## .gitignore ##########
# Ignore byte-compiled / optimized / DLL files
__pycache__/
*.py[cod]
# Ignore Egg files
*.egg-info/
# Ignore virtual environments
dev
.venv
# Ignore the zipfile build directory
build
# Generated at build time
machine/version.txtI now have the full convention. Here's what your machine project establishes as the house style:
uv for deps + hatchling build backend + shiv for standalone binariesmain.py, one module per subcommand under subcommands/ (each exposes command)log.py (fatal_error/info/output), di.py (the d globals holder), constants.py (XDG ~/.config/<tool>/ paths), config.py (YAML + ${ENV} expansion), types.py (dataclasses), factory.pyMakefile (dev/build/test/lint) delegating to sh/*.sh; ruff (line-length 132); .envrc; MANIFEST.in; CLAUDE.md; tests/ with pytestversion subcommand reading a build-time version.txtLet me check what tooling is available here before I commit to the exact mechanics:
for t in uv python3 direnv git; do printf "%-8s " "$t"; command -v $t >/dev/null && $t --version 2>&1 | head -1 || echo "NOT FOUND"; done; echo "---"; python3 -c "import click" 2>&1 && echo "click: yes" || echo "click: no"; python3 -c "import ruamel.yaml" 2>&1 && echo "ruamel: yes" || echo "ruamel: no"
uv uv 0.9.24 python3 Python 3.12.3 direnv NOT FOUND git git version 2.43.0 --- click: yes Traceback (most recent call last): File "<string>", line 1, in <module> ModuleNotFoundError: No module named 'ruamel' ruamel: no
Good — uv 0.9.24 is here, click/requests-style stack is what you use, and uv will manage ruamel.yaml in the venv. direnv isn't installed but I'll include .envrc for parity.
Here's my plan, matching your machine layout:
richhall/
main.py # Click group + version cmd
constants.py # API domain, XDG paths, defaults
config.py # config.yml (${ENV} expansion) + .env + defaults
di.py log.py types.py factory.py
api.py # HMAC-SHA1 signing + paged fetch (requests)
db.py # SQLite schema + upserts
geo.py # haversine + distance/time/date parsing
subcommands/
fetch.py search.py info.py # each exposes `command`
sh/ (build-package.sh, make-release-tag.sh, dev-setup.sh, lint.sh)
Makefile pyproject.toml MANIFEST.in .envrc CLAUDE.md
tests/ (test_signing.py, test_geo.py)
Two decisions I'm making (say the word to change): (1) package + command named richhall to match your repo==package==command convention — trivial to add a fringe alias if you'd rather; (2) the SQLite cache moves out of the repo to the XDG data dir ~/.local/share/richhall/fringe.db (a pip/shiv-installed CLI has no project dir), and I'll migrate your existing DB so no re-fetch is needed. Credentials still come from .env/env (FRINGE_API_KEY/SECRET), now also acceptable via config.yml.
Building it now:
File created successfully at: /home/david/projects/play/richhall/richhall/__init__.py (file state is current in your context — no need to Read it back)
import os from pathlib import Path API_DOMAIN = "https://api.edinburghfestivalcity.com" PAGE_SIZE = 100 # API maximum page size DEFAULT_FESTIVAL = "fringe" DEFAULT_YEAR = 2026 # Rough centre of the Old Town / Royal Mile — a sensible "middle of town". DEFAULT_CENTRE = (55.9497, -3.1875) config_dir_path = str(Path.home() / ".config" / "richhall") default_config_file_path = os.path.join(config_dir_path, "config.yml") data_dir_path = str(Path.home() / ".local" / "share" / "richhall") default_db_path = os.path.join(data_dir_path, "fringe.db")
File created successfully at: /home/david/projects/play/richhall/richhall/constants.py (file state is current in your context — no need to Read it back)
from dataclasses import dataclass
@dataclass
class CliOptions:
debug: bool
quiet: bool
@dataclass
class Config:
festival: str
year: int
centre: tuple
api_key: str
api_secret: str
db_path: str
File created successfully at: /home/david/projects/play/richhall/richhall/types.py (file state is current in your context — no need to Read it back)
# Exposing the truth that dependency injection is just a fancy name for global variables
from richhall.types import CliOptions
class d:
opt: CliOptions = None
File created successfully at: /home/david/projects/play/richhall/richhall/di.py (file state is current in your context — no need to Read it back)
import sys
from richhall.di import d
def fatal_error(s: str):
print(s, file=sys.stderr)
sys.exit(1)
def debug(s: str):
if d.opt and d.opt.debug:
print(s, file=sys.stderr)
def info(s: str):
if d.opt and d.opt.quiet:
return
print(s, file=sys.stderr)
def output(s: str):
print(s)
File created successfully at: /home/david/projects/play/richhall/richhall/log.py (file state is current in your context — no need to Read it back)
import ruamel.yaml
def yaml():
y = ruamel.yaml.YAML()
y.preserve_quotes = True
return y
File created successfully at: /home/david/projects/play/richhall/richhall/factory.py (file state is current in your context — no need to Read it back)
import os
import re
from richhall import constants
from richhall.factory import yaml
from richhall.log import debug, fatal_error
from richhall.types import Config
_env_var_pattern = re.compile(r"\$\{([^}]+)\}")
def _expand_env_vars(value):
"""Recursively expand ${VAR} / ${VAR:-default} references in config values."""
if isinstance(value, str):
def _replace(match):
expr = match.group(1)
if ":-" in expr:
var_name, default = expr.split(":-", 1)
return os.environ.get(var_name, default)
if expr not in os.environ:
fatal_error(f"Environment variable '{expr}' referenced in config is not set")
return os.environ[expr]
return _env_var_pattern.sub(_replace, value)
if isinstance(value, dict):
return {k: _expand_env_vars(v) for k, v in value.items()}
if isinstance(value, list):
return [_expand_env_vars(item) for item in value]
return value
def _load_dotenv(path):
"""Load KEY=VALUE lines from a .env file into the environment (without
clobbering vars already set)."""
if not os.path.exists(path):
return
with open(path, encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
key, _, value = line.partition("=")
os.environ.setdefault(key.strip(), value.strip().strip('"').strip("'"))
def _parse_centre(text):
try:
lat, lon = (float(x) for x in str(text).split(","))
return (lat, lon)
except Exception:
fatal_error(f"Invalid centre '{text}': expected 'lat,lon' e.g. 55.9497,-3.1875")
def get(config_file_name=None) -> Config:
# Load a project-local .env for developer convenience.
_load_dotenv(os.path.join(os.getcwd(), ".env"))
data = {}
path = config_file_name or constants.default_config_file_path
if os.path.exists(path):
data = _expand_env_vars(yaml().load(open(path, "r")) or {})
debug(f"Loaded config file: {path}")
api_key = str(data.get("api-key") or os.environ.get("FRINGE_API_KEY", "")).strip()
api_secret = str(data.get("api-secret") or os.environ.get("FRINGE_API_SECRET", "")).strip()
centre = _parse_centre(data["centre"]) if data.get("centre") else constants.DEFAULT_CENTRE
return Config(
festival=data.get("festival", constants.DEFAULT_FESTIVAL),
year=int(data.get("year", constants.DEFAULT_YEAR)),
centre=centre,
api_key=api_key,
api_secret=api_secret,
db_path=os.path.expanduser(data.get("db-path", constants.default_db_path)),
)
def require_credentials(cfg: Config):
if not cfg.api_key or not cfg.api_secret:
fatal_error(
"FRINGE_API_KEY and FRINGE_API_SECRET must be set "
"(in the environment, a .env file, or the config file)."
)
File created successfully at: /home/david/projects/play/richhall/richhall/config.py (file state is current in your context — no need to Read it back)
import hashlib
import hmac
import urllib.parse
import requests
from richhall import constants
from richhall.log import fatal_error
def signed_url(path, params, key, secret):
"""Build a signed request URL.
The API signs the full path + query string (including the key, excluding
the domain) with HMAC-SHA1 keyed by the secret; the hex signature is then
appended as &signature=... The signed string must exactly match what is
sent, so we build the query string once and reuse it verbatim.
"""
items = list(params.items()) + [("key", key)]
query = urllib.parse.urlencode(items, quote_via=urllib.parse.quote)
signed_path = f"{path}?{query}"
signature = hmac.new(secret.encode("utf-8"), signed_path.encode("utf-8"), hashlib.sha1).hexdigest()
return f"{constants.API_DOMAIN}{signed_path}&signature={signature}"
def api_get(path, params, cfg):
url = signed_url(path, params, cfg.api_key, cfg.api_secret)
try:
resp = requests.get(url, headers={"Accept": "application/json"}, timeout=60)
resp.raise_for_status()
return resp.json()
except requests.HTTPError as exc:
status = exc.response.status_code if exc.response is not None else "?"
body = exc.response.text[:500] if exc.response is not None else ""
fatal_error(f"HTTP {status} from {path}: {body}")
except requests.RequestException as exc:
fatal_error(f"Could not reach API: {exc}")
def paged_get(path, params, cfg):
"""Yield records across all pages of a listing endpoint."""
offset = 0
while True:
page_params = dict(params, size=constants.PAGE_SIZE, **{"from": offset})
data = api_get(path, page_params, cfg)
# Listing endpoints return a JSON array; tolerate a dict wrapper too.
if isinstance(data, dict):
records = data.get("events") or data.get("venues") or data.get("results") or []
else:
records = data
if not records:
break
for rec in records:
yield rec
if len(records) < constants.PAGE_SIZE:
break
offset += constants.PAGE_SIZE
File created successfully at: /home/david/projects/play/richhall/richhall/api.py (file state is current in your context — no need to Read it back)
import json
import os
import sqlite3
SCHEMA = """
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
title TEXT,
sub_title TEXT,
artist TEXT,
genre TEXT,
genre_tags TEXT,
country TEXT,
age_category TEXT,
status TEXT,
description TEXT,
url TEXT,
website TEXT,
venue_id TEXT,
venue_name TEXT,
latitude REAL,
longitude REAL,
updated TEXT,
raw TEXT
);
CREATE TABLE IF NOT EXISTS performances (
perf_id TEXT PRIMARY KEY,
event_id TEXT,
start TEXT,
end TEXT,
duration_minutes INTEGER,
is_at_fixed_time INTEGER,
price_string TEXT,
price REAL,
title TEXT
);
CREATE TABLE IF NOT EXISTS venues (
id TEXT PRIMARY KEY,
name TEXT,
address TEXT,
post_code TEXT,
code TEXT,
latitude REAL,
longitude REAL,
raw TEXT
);
CREATE TABLE IF NOT EXISTS meta (key TEXT PRIMARY KEY, value TEXT);
CREATE INDEX IF NOT EXISTS idx_perf_event ON performances(event_id);
CREATE INDEX IF NOT EXISTS idx_perf_start ON performances(start);
"""
def connect(db_path):
parent = os.path.dirname(db_path)
if parent:
os.makedirs(parent, exist_ok=True)
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
conn.executescript(SCHEMA)
return conn
def to_float(value):
try:
return float(value)
except (TypeError, ValueError):
return None
def set_meta(conn, key, value):
conn.execute("INSERT OR REPLACE INTO meta (key, value) VALUES (?, ?)", (key, value))
def get_meta(conn):
return {row["key"]: row["value"] for row in conn.execute("SELECT key, value FROM meta")}
def performance_date_range(conn):
"""Return (first_date, last_date, count) as YYYY-MM-DD strings."""
row = conn.execute(
"SELECT MIN(start) AS lo, MAX(start) AS hi, COUNT(*) AS n "
"FROM performances WHERE start IS NOT NULL AND start != ''"
).fetchone()
if not row or not row["n"]:
return (None, None, 0)
return (str(row["lo"])[:10], str(row["hi"])[:10], row["n"])
def store_event(conn, ev):
venue = ev.get("venue") or {}
pos = venue.get("position") or {}
lat = to_float(ev.get("latitude")) or to_float(pos.get("lat"))
lon = to_float(ev.get("longitude")) or to_float(pos.get("lon"))
genre_tags = ev.get("genre_tags")
if isinstance(genre_tags, list):
genre_tags = ", ".join(genre_tags)
conn.execute(
"""INSERT OR REPLACE INTO events
(id, title, sub_title, artist, genre, genre_tags, country,
age_category, status, description, url, website, venue_id,
venue_name, latitude, longitude, updated, raw)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""",
(
ev.get("id"),
ev.get("title"),
ev.get("sub_title"),
ev.get("artist"),
ev.get("genre"),
genre_tags,
ev.get("country"),
ev.get("age_category"),
ev.get("status"),
ev.get("description_teaser") or ev.get("description"),
ev.get("url"),
ev.get("website"),
venue.get("id"),
venue.get("name"),
lat,
lon,
ev.get("updated"),
json.dumps(ev),
),
)
# Refresh this event's performances.
conn.execute("DELETE FROM performances WHERE event_id = ?", (ev.get("id"),))
for p in ev.get("performances") or []:
conn.execute(
"""INSERT OR REPLACE INTO performances
(perf_id, event_id, start, end, duration_minutes,
is_at_fixed_time, price_string, price, title)
VALUES (?,?,?,?,?,?,?,?,?)""",
(
p.get("id"),
ev.get("id"),
p.get("start"),
p.get("end"),
p.get("duration_minutes"),
1 if p.get("is_at_fixed_time") else 0,
p.get("price_string"),
to_float(p.get("price")),
p.get("title"),
),
)
def store_venue(conn, v):
pos = v.get("position") or {}
conn.execute(
"""INSERT OR REPLACE INTO venues
(id, name, address, post_code, code, latitude, longitude, raw)
VALUES (?,?,?,?,?,?,?,?)""",
(
v.get("id"),
v.get("name"),
v.get("address"),
v.get("post_code"),
v.get("code"),
to_float(pos.get("lat")),
to_float(pos.get("lon")),
json.dumps(v),
),
)
File created successfully at: /home/david/projects/play/richhall/richhall/db.py (file state is current in your context — no need to Read it back)
import math
from datetime import datetime
MILES_TO_KM = 1.609344
def haversine_km(a, b):
"""Great-circle distance in kilometres between two (lat, lon) points."""
lat1, lon1 = a
lat2, lon2 = b
r = 6371.0
p1, p2 = math.radians(lat1), math.radians(lat2)
dp = math.radians(lat2 - lat1)
dl = math.radians(lon2 - lon1)
h = math.sin(dp / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2
return 2 * r * math.asin(math.sqrt(h))
def parse_distance(text):
"""Parse '3km' / '2miles' / '1.5mi' / '3' (km) into kilometres."""
text = text.strip().lower()
num = ""
for ch in text:
if ch.isdigit() or ch == ".":
num += ch
else:
break
if not num:
raise ValueError(f"bad distance: {text!r}")
val = float(num)
unit = text[len(num):].strip()
if unit.startswith("mi"):
return val * MILES_TO_KM
return val # default km
def parse_hm(text):
"""Parse 'HH:MM' into minutes since midnight."""
try:
h, m = text.split(":")
return int(h) * 60 + int(m)
except Exception:
raise ValueError(f"bad time (want HH:MM): {text!r}")
def parse_dt(value):
"""Parse an API datetime string tolerantly. Returns a datetime or None."""
if not value:
return None
value = value.strip().replace("T", " ")
value = value.split("+")[0].split(".")[0].strip()
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M", "%Y-%m-%d"):
try:
return datetime.strptime(value, fmt)
except ValueError:
continue
return None
File created successfully at: /home/david/projects/play/richhall/richhall/geo.py (file state is current in your context — no need to Read it back)
File created successfully at: /home/david/projects/play/richhall/richhall/subcommands/__init__.py (file state is current in your context — no need to Read it back)
from datetime import datetime
import click
from richhall import api, config, db
from richhall.log import info, output
from richhall.types import Config
@click.command(help="Download or refresh the local cache of events and venues")
@click.pass_context
def command(context):
cfg: Config = context.obj
config.require_credentials(cfg)
conn = db.connect(cfg.db_path)
base = {"festival": cfg.festival, "year": cfg.year}
info(f"Fetching {cfg.festival} events for {cfg.year} …")
n_events = 0
for ev in api.paged_get("/events", base, cfg):
db.store_event(conn, ev)
n_events += 1
if n_events % 100 == 0:
info(f" {n_events} events …")
conn.commit()
conn.commit()
info(f"Fetching {cfg.festival} venues for {cfg.year} …")
n_venues = 0
for v in api.paged_get("/venues", base, cfg):
db.store_venue(conn, v)
n_venues += 1
conn.commit()
db.set_meta(conn, "last_fetch", datetime.now().isoformat(timespec="seconds"))
db.set_meta(conn, "festival", cfg.festival)
db.set_meta(conn, "year", str(cfg.year))
conn.commit()
output(f"Done. {n_events} events, {n_venues} venues cached in {cfg.db_path}")
lo, hi, n = db.performance_date_range(conn)
if n:
output(f"Performances span {lo} → {hi} ({n} showings).")
conn.close()
File created successfully at: /home/david/projects/play/richhall/richhall/subcommands/fetch.py (file state is current in your context — no need to Read it back)
import click
from richhall import db
from richhall.geo import haversine_km, parse_distance, parse_dt, parse_hm
from richhall.log import fatal_error, output
from richhall.types import Config
def _build_query(args):
sql = [
"SELECT p.start AS start, p.price_string AS price_string,",
" p.duration_minutes AS duration_minutes,",
" e.title AS title, e.artist AS artist, e.genre AS genre,",
" e.venue_name AS venue_name, e.latitude AS lat, e.longitude AS lon,",
" e.url AS url",
"FROM performances p JOIN events e ON e.id = p.event_id",
"WHERE 1=1",
"AND (e.status IS NULL OR e.status NOT IN ('cancelled','deleted'))",
]
params = []
if args["day"]:
sql.append("AND substr(p.start,1,10) = ?")
params.append(args["day"])
if args["date_from"]:
sql.append("AND substr(p.start,1,10) >= ?")
params.append(args["date_from"])
if args["date_to"]:
sql.append("AND substr(p.start,1,10) <= ?")
params.append(args["date_to"])
if args["genre"]:
sql.append("AND (lower(e.genre) LIKE ? OR lower(e.genre_tags) LIKE ?)")
params += [f"%{args['genre'].lower()}%", f"%{args['genre'].lower()}%"]
if args["title"]:
sql.append("AND lower(e.title) LIKE ?")
params.append(f"%{args['title'].lower()}%")
if args["artist"]:
sql.append("AND lower(e.artist) LIKE ?")
params.append(f"%{args['artist'].lower()}%")
return " ".join(sql), params
def _print_results(results, limit):
if not results:
output("No performances match those filters.")
return
total = len(results)
shown = results[:limit] if limit else results
for dt, dist, r in shown:
when = dt.strftime("%a %d %b %H:%M")
dist_s = f"{dist:5.2f}km" if dist is not None else " ? km"
dur = f"{r['duration_minutes']}m" if r["duration_minutes"] else ""
price = r["price_string"] or ""
output(f"{when} {dist_s} {r['title']}")
meta = " · ".join(x for x in [r["venue_name"], r["genre"], dur, price] if x)
if meta:
output(f" {meta}")
tail = f" (showing first {limit})" if limit and total > limit else ""
output(f"\n{len(shown)} of {total} matching performances{tail}")
@click.command(help="Query the local cache for shows matching your constraints")
@click.option("--day", metavar="<YYYY-MM-DD>", help="Single date")
@click.option("--date-from", metavar="<YYYY-MM-DD>", help="Earliest date")
@click.option("--date-to", metavar="<YYYY-MM-DD>", help="Latest date")
@click.option("--after", metavar="<HH:MM>", help="Earliest start time")
@click.option("--before", metavar="<HH:MM>", help="Latest start time, e.g. 20:00")
@click.option("--max-distance", metavar="<DIST>", help="Max distance from centre, e.g. 3km or 2miles")
@click.option("--centre", metavar="<LAT,LON>", help="Override centre point (default: config / Royal Mile)")
@click.option("--genre", metavar="<TEXT>", help="Genre substring, e.g. comedy")
@click.option("--title", metavar="<TEXT>", help="Title substring")
@click.option("--artist", metavar="<TEXT>", help="Artist substring")
@click.option("--sort", type=click.Choice(["time", "distance"]), default="time", help="Sort order")
@click.option("--limit", type=int, default=50, help="Max rows (0 = all)")
@click.pass_context
def command(context, day, date_from, date_to, after, before, max_distance, centre, genre, title, artist, sort, limit):
cfg: Config = context.obj
centre_point = cfg.centre
if centre:
try:
lat, lon = (float(x) for x in centre.split(","))
centre_point = (lat, lon)
except Exception:
fatal_error("--centre must be 'lat,lon' e.g. 55.9497,-3.1875")
try:
max_km = parse_distance(max_distance) if max_distance else None
after_min = parse_hm(after) if after else None
before_min = parse_hm(before) if before else None
except ValueError as exc:
fatal_error(str(exc))
conn = db.connect(cfg.db_path)
sql, params = _build_query(
dict(day=day, date_from=date_from, date_to=date_to, genre=genre, title=title, artist=artist)
)
rows = conn.execute(sql, params).fetchall()
# Post-filter on parsed start time and computed distance.
results = []
for r in rows:
dt = parse_dt(r["start"])
if dt is None:
continue
minutes = dt.hour * 60 + dt.minute
if after_min is not None and minutes < after_min:
continue
if before_min is not None and minutes > before_min:
continue
dist = None
if r["lat"] is not None and r["lon"] is not None:
dist = haversine_km(centre_point, (r["lat"], r["lon"]))
if max_km is not None and (dist is None or dist > max_km):
continue
results.append((dt, dist, r))
if sort == "distance":
results.sort(key=lambda x: (x[1] is None, x[1] if x[1] is not None else 0, x[0]))
else:
results.sort(key=lambda x: x[0])
_print_results(results, limit)
conn.close()
File created successfully at: /home/david/projects/play/richhall/richhall/subcommands/search.py (file state is current in your context — no need to Read it back)
import os
import click
from richhall import db
from richhall.log import fatal_error, output
from richhall.types import Config
@click.command(help="Show what is in the local cache")
@click.pass_context
def command(context):
cfg: Config = context.obj
if not os.path.exists(cfg.db_path):
fatal_error(f"No cache yet at {cfg.db_path}. Run: richhall fetch")
conn = db.connect(cfg.db_path)
meta = db.get_meta(conn)
n_ev = conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
n_pf = conn.execute("SELECT COUNT(*) FROM performances").fetchone()[0]
n_vn = conn.execute("SELECT COUNT(*) FROM venues").fetchone()[0]
output(f"Cache: {cfg.db_path}")
output(f"Festival: {meta.get('festival', '?')} Year: {meta.get('year', '?')}")
output(f"Last fetch: {meta.get('last_fetch', 'never')}")
output(f"Events: {n_ev} Performances: {n_pf} Venues: {n_vn}")
lo, hi, n = db.performance_date_range(conn)
if n:
output(f"Performances span {lo} → {hi} ({n} showings).")
conn.close()
File created successfully at: /home/david/projects/play/richhall/richhall/subcommands/info.py (file state is current in your context — no need to Read it back)
import os
import click
from richhall import config as config_mod
from richhall import constants
from richhall.di import d
from richhall.log import output
from richhall.subcommands import fetch, info, search
from richhall.types import CliOptions
CLICK_CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
@click.group(context_settings=CLICK_CONTEXT_SETTINGS)
@click.option("--debug", is_flag=True, default=False, help="Enable debug output")
@click.option("--quiet", is_flag=True, default=False, help="Suppress non-essential output")
@click.option("--config-file", metavar="<PATH>", help=f"Config file (default {constants.default_config_file_path})")
@click.pass_context
def main(context, debug, quiet, config_file):
d.opt = CliOptions(debug=debug, quiet=quiet)
if context.invoked_subcommand == "version":
return
context.obj = config_mod.get(config_file)
@main.command()
def version():
try:
version_file = os.path.join(os.path.dirname(__file__), "version.txt")
with open(version_file) as f:
output(f.read().strip())
except FileNotFoundError:
output("dev")
main.add_command(fetch.command, "fetch")
main.add_command(search.command, "search")
main.add_command(info.command, "info")
File created successfully at: /home/david/projects/play/richhall/richhall/main.py (file state is current in your context — no need to Read it back)
[project]
name = "richhall"
version = "0.1.0"
description = "Utility for Edinburgh Festival analytics"
readme = "README.md"
requires-python = ">=3.10"
authors = [
{ name = "David Boreham" },
]
classifiers = [
"Programming Language :: Python :: 3.10",
"Operating System :: OS Independent",
]
dependencies = [
"click>=8.1.7",
"requests>=2.32.4",
"ruamel.yaml>=0.17.32",
]
[project.scripts]
richhall = "richhall.main:main"
[project.urls]
Homepage = "https://github.com/stirlingbridge/richhall"
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[tool.pytest.ini_options]
addopts = "-q"
[dependency-groups]
dev = [
"ruff",
"pytest",
]
[tool.ruff]
line-length = 132
[tool.ruff.lint]
select = ["E", "F", "W", "C90"]
ignore = ["E203"]
[tool.ruff.lint.mccabe]
max-complexity = 25
File created successfully at: /home/david/projects/play/richhall/pyproject.toml (file state is current in your context — no need to Read it back)
# Setup dev environment dev: uv sync build: ./sh/build-package.sh test: uv run pytest tests/ -v lint: uv run ruff check richhall/ uv run ruff format --check richhall/
File created successfully at: /home/david/projects/play/richhall/Makefile (file state is current in your context — no need to Read it back)
#!/bin/bash uv sync
File created successfully at: /home/david/projects/play/richhall/sh/dev-setup.sh (file state is current in your context — no need to Read it back)
#!/bin/bash if [[ "$1" == "--fix" ]]; then uv run ruff format richhall/ uv run ruff check --fix richhall/ else uv run ruff format --check richhall/ uv run ruff check richhall/ fi
File created successfully at: /home/david/projects/play/richhall/sh/lint.sh (file state is current in your context — no need to Read it back)
#!/bin/bash # Emit a version string for the build (git describe, or a fallback). git describe --tags --always --dirty 2>/dev/null || echo dev
File created successfully at: /home/david/projects/play/richhall/sh/make-release-tag.sh (file state is current in your context — no need to Read it back)
#!/bin/bash set -e mkdir -p ./build ./sh/make-release-tag.sh > ./richhall/version.txt uvx shiv -c richhall -o build/richhall .
File created successfully at: /home/david/projects/play/richhall/sh/build-package.sh (file state is current in your context — no need to Read it back)
File created successfully at: /home/david/projects/play/richhall/MANIFEST.in (file state is current in your context — no need to Read it back)
File created successfully at: /home/david/projects/play/richhall/.envrc (file state is current in your context — no need to Read it back)
# Secrets — never commit .env # Byte-compiled / optimized files __pycache__/ *.py[cod] # Egg / build artifacts *.egg-info/ build # Virtual environments .venv dev # Generated at build time richhall/version.txt # Local data cache (usually lives in ~/.local/share/richhall) *.sqlite *.sqlite-journal fringe.db
The file /home/david/projects/play/richhall/.gitignore has been updated successfully. (file state is current in your context — no need to Read it back)
File created successfully at: /home/david/projects/play/richhall/tests/__init__.py (file state is current in your context — no need to Read it back)
import hashlib
import hmac
from richhall.api import signed_url
def test_signed_url_orders_key_last_and_signs_path_query():
"""The signature must cover the exact path+query that is sent, with the
access key included last, per the Fringe API docs."""
url = signed_url("/events", {"festival": "jazz"}, "KEY", "SECRET")
expected_query = "/events?festival=jazz&key=KEY"
expected_sig = hmac.new(b"SECRET", expected_query.encode(), hashlib.sha1).hexdigest()
assert url == "https://api.edinburghfestivalcity.com" + expected_query + "&signature=" + expected_sig
def test_signed_url_url_encodes_parameters():
url = signed_url("/events", {"title": "a b&c"}, "KEY", "SECRET")
# Space and ampersand in the value must be percent-encoded before signing.
assert "title=a%20b%26c" in url
assert url.count("&signature=") == 1
File created successfully at: /home/david/projects/play/richhall/tests/test_signing.py (file state is current in your context — no need to Read it back)
import math
import pytest
from richhall.geo import haversine_km, parse_distance, parse_dt, parse_hm
def test_haversine_zero_distance():
assert haversine_km((55.9497, -3.1875), (55.9497, -3.1875)) == 0
def test_haversine_one_degree_latitude_is_about_111km():
d = haversine_km((0, 0), (1, 0))
assert 111.0 < d < 111.5
def test_parse_distance_units():
assert parse_distance("3km") == pytest.approx(3.0)
assert parse_distance("5") == pytest.approx(5.0) # bare number = km
assert parse_distance("2miles") == pytest.approx(3.218688)
assert parse_distance("1.5mi") == pytest.approx(2.414016)
def test_parse_distance_rejects_garbage():
with pytest.raises(ValueError):
parse_distance("soon")
def test_parse_hm():
assert parse_hm("20:00") == 1200
assert parse_hm("09:30") == 570
def test_parse_hm_rejects_garbage():
with pytest.raises(ValueError):
parse_hm("8pm")
def test_parse_dt_handles_iso_and_space_forms():
assert parse_dt("2026-08-15T19:30:00").hour == 19
assert parse_dt("2026-08-15 19:30:00").minute == 30
assert parse_dt("2026-08-15").day == 15
assert parse_dt("") is None
assert parse_dt("not-a-date") is None
File created successfully at: /home/david/projects/play/richhall/tests/test_geo.py (file state is current in your context — no need to Read it back)
# CLAUDE.md
## Project Overview
CLI tool (`richhall`) for selecting Edinburgh Festival Fringe shows to attend.
It copies event and venue data locally via the Fringe API, then filters by the
criteria that actually decide a ticket purchase: **which day**, **performance
start time**, and **distance of the venue from the centre of town**.
- **Org**: Stirlingbridge (`github.com/stirlingbridge/richhall`)
- **Python**: >=3.10
## Tech Stack
- **CLI framework**: Click
- **HTTP**: requests (signed HMAC-SHA1 requests to the Fringe API)
- **Storage**: SQLite (`~/.local/share/richhall/fringe.db`), one row per performance
- **Config**: ruamel.yaml (`~/.config/richhall/config.yml`, optional) + `.env`
- **Build tooling**: uv (deps), hatchling (build backend), shiv (zipapp packaging)
## Project Structure
```
richhall/ # Main package
main.py # Click group entry point
config.py # Config file + .env loading, ${ENV} expansion
constants.py # API domain, XDG paths, defaults (festival/year/centre)
di.py # Dependency injection / globals (d.opt)
log.py # fatal_error / debug / info / output helpers
types.py # CliOptions, Config dataclasses
factory.py # ruamel.yaml factory
api.py # Request signing + paginated fetch
db.py # SQLite schema + upserts
geo.py # haversine + distance/time/date parsing
subcommands/ # Click subcommands
fetch.py # download/refresh the cache
search.py # query with day/time/distance filters
info.py # show cache contents
sh/ # Shell scripts (build, lint, dev-setup, release-tag)
tests/ # pytest unit tests
pyproject.toml # Project metadata and dependencies
```
## Credentials
The Fringe API needs an access key + secret (HMAC-SHA1 signing). Provide them via:
- environment: `FRINGE_API_KEY`, `FRINGE_API_SECRET`, or
- a project-local `.env` file (gitignored), or
- `~/.config/richhall/config.yml` as `api-key` / `api-secret` (supports `${ENV}`).
## Development Commands
```bash
uv sync # Install dependencies (creates .venv)
uv run richhall --help # Run CLI in development
uv run richhall fetch # Download/refresh the local cache
uv run pytest tests/ # Run tests
./sh/lint.sh --fix # Auto-format with ruff, then lint
./sh/build-package.sh # Build shiv executable to build/richhall
make dev | build | test | lint
```
## Code Style
- **Linter & Formatter**: ruff (line length 132, max complexity 25, E203 ignored)
- Config in `pyproject.toml` under `[tool.ruff]`
File created successfully at: /home/david/projects/play/richhall/CLAUDE.md (file state is current in your context — no need to Read it back)
Now update the README for the new invocation, remove the old single-file script, migrate the DB, and verify everything:
# richhall
Utility for Edinburgh Festival analytics.
A CLI (`richhall`) to copy Edinburgh Festival Fringe event data locally and
query it by the things that actually decide a ticket purchase: **which day**,
**what time it starts**, and **how far the venue is from the centre of town**.
## Setup
```bash
uv sync # create .venv and install dependencies
```
Provide your Fringe API credentials in any of these (checked in this order):
- environment variables `FRINGE_API_KEY` / `FRINGE_API_SECRET`
- a project-local `.env` file (gitignored):
```
FRINGE_API_KEY=your_key
FRINGE_API_SECRET=your_secret
```
- `~/.config/richhall/config.yml` (`api-key` / `api-secret`, `${ENV}` supported)
## Usage
```bash
# Download / refresh the local cache (SQLite in ~/.local/share/richhall)
uv run richhall fetch # defaults: festival=fringe, year=2026
# What's cached?
uv run richhall info
# Find shows: a given day, starting before 8pm, within 3km of the centre
uv run richhall search --day 2026-08-15 --before 20:00 --max-distance 3km
# Everything is composable:
uv run richhall search \
--date-from 2026-08-15 --date-to 2026-08-16 \
--after 12:00 --before 20:00 \
--max-distance 2km --genre comedy \
--sort distance
```
### search filters
| flag | meaning |
|----------------------|----------------------------------------------------|
| `--day` | single date `YYYY-MM-DD` |
| `--date-from/-to` | date range |
| `--after` | earliest start time `HH:MM` |
| `--before` | latest start time `HH:MM` (e.g. `20:00`) |
| `--max-distance` | e.g. `3km`, `2miles` — from the centre point |
| `--centre` | override centre as `lat,lon` (default: Royal Mile) |
| `--genre` | substring match, e.g. `comedy` |
| `--title`/`--artist` | substring match |
| `--sort` | `time` (default) or `distance` |
| `--limit` | max rows (0 = all) |
### optional config
`~/.config/richhall/config.yml` can set defaults so you don't repeat flags:
```yaml
festival: fringe
year: 2026
centre: "55.9497,-3.1875" # your preferred "middle of town"
# api-key: ${FRINGE_API_KEY}
# api-secret: ${FRINGE_API_SECRET}
```
## How it works
- **Auth**: each request is signed with HMAC-SHA1 over the path+query string
(including the key), per the Fringe API docs.
- **Storage**: SQLite holds one row per *performance* (the natural grain for
picking a specific showing), plus events and venues, and the raw JSON.
- **Distance**: haversine from a configurable centre using each venue's
latitude/longitude from the API — no geocoding needed.
## Development
```bash
make dev # uv sync
make test # pytest
make lint # ruff check + format check
make build # shiv single-file executable -> build/richhall
```
See `CLAUDE.md` for the full project layout.
The file /home/david/projects/play/richhall/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
Now sync deps, migrate the existing DB to the new data dir (no re-fetch), run the tests, and verify the CLI end-to-end:
set -e rm -f fringe.py mkdir -p ~/.local/share/richhall if [ -f fringe.db ]; then mv fringe.db ~/.local/share/richhall/fringe.db && echo "Migrated existing cache to ~/.local/share/richhall/fringe.db"; fi echo "=== uv sync ===" && uv sync 2>&1 | tail -5 echo "=== lint ===" && uv run ruff check richhall/ 2>&1 | tail -20 echo "=== tests ===" && uv run pytest tests/ -q 2>&1 | tail -20
Migrated existing cache to ~/.local/share/richhall/fringe.db === uv sync === + requests==2.34.2 + richhall==0.1.0 (from file:///home/david/projects/play/richhall) + ruamel-yaml==0.19.1 + ruff==0.15.20 + urllib3==2.7.0 === lint === All checks passed! === tests === ......... [100%]
echo "=== --help ===" && uv run richhall --help 2>&1 echo && echo "=== version ===" && uv run richhall version 2>&1 echo && echo "=== info ===" && uv run richhall info 2>&1 echo && echo "=== search (distance-sorted) ===" && uv run richhall search --day 2026-08-15 --before 20:00 --max-distance 2km --genre comedy --sort distance --limit 5 2>&1
=== --help ===
Usage: richhall [OPTIONS] COMMAND [ARGS]...
Options:
--debug Enable debug output
--quiet Suppress non-essential output
--config-file <PATH> Config file (default
/home/david/.config/richhall/config.yml)
-h, --help Show this message and exit.
Commands:
fetch Download or refresh the local cache of events and venues
info Show what is in the local cache
search Query the local cache for shows matching your constraints
version
=== version ===
dev
=== info ===
Cache: /home/david/.local/share/richhall/fringe.db
Festival: fringe Year: 2026
Last fetch: 2026-07-02T09:29:11
Events: 4113 Performances: 59926 Venues: 282
Performances span 2026-08-02 → 2026-09-01 (59926 showings).
=== search (distance-sorted) ===
Sat 15 Aug 12:10 0.01km Chris Cantrill: Rewilding
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £15
Sat 15 Aug 13:35 0.01km Rachel Fairburn: Vexy Beasts
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £14 (12)
Sat 15 Aug 15:00 0.01km Amy Matthews: Definitions of Toast
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £14
Sat 15 Aug 16:25 0.01km Tom Ballard: Be Funny Challenge (Impossible)
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £15
Sat 15 Aug 17:50 0.01km Rob Copland: ONE (more please)
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £14 (13)
5 of 1146 matching performances (showing first 5)Migrated cache intact, all commands work. One last thing — I swapped urllib for requests, so let me smoke-test the live network path (signing through requests) with a tiny request rather than a full re-fetch:
uv run python -c "
from richhall import config, api
cfg = config.get()
config.require_credentials(cfg)
data = api.api_get('/events', {'festival': cfg.festival, 'year': cfg.year, 'size': 2, 'from': 0}, cfg)
print('records returned:', len(data) if isinstance(data, list) else type(data))
print('first title:', (data[0].get('title') if isinstance(data, list) and data else None))
print('first perf start:', (data[0].get('performances',[{}])[0].get('start') if isinstance(data, list) and data else None))
" 2>&1Everything is verified end-to-end. The live signed request works through requests, the migrated cache is intact, lint is clean, and all 9 tests pass.
machine house styleThe single-file fringe.py is gone, replaced by a proper uv + click + hatchling package that mirrors your reference project:
richhall/
main.py Click group + version cmd
config.py config.yml (${ENV} expansion) + .env + defaults
constants.py di.py log.py types.py factory.py
api.py HMAC-SHA1 signing + paginated fetch (requests)
db.py geo.py
subcommands/ fetch.py search.py info.py (each exposes `command`)
sh/ build-package.sh make-release-tag.sh dev-setup.sh lint.sh
Makefile pyproject.toml MANIFEST.in .envrc CLAUDE.md
tests/ test_signing.py test_geo.py (9 tests, passing)
What maps to what in your convention: di.d.opt globals, log.fatal_error/info/output, constants.py XDG paths, config.py with ${VAR}/${VAR:-default} expansion, ruff at line-length 132, Makefile → sh/*.sh, a version subcommand reading a build-time version.txt, and sh/build-package.sh wired for shiv (make build → build/richhall) — ready if/when you want the standalone binary, not required now.
Decisions I made (both easily reversed):
- Command/package = richhall (matches repo==package==command). Want a fringe alias? One line in [project.scripts].
- Cache moved to ~/.local/share/richhall/fringe.db and I migrated your existing data — info confirms all 4,113 events / 59,926 performances survived, so no re-fetch needed.
Credentials now resolve from env → .env → config.yml (api-key/api-secret), so your existing .env keeps working unchanged.
uv run richhall fetch
uv run richhall search --day 2026-08-15 --before 20:00 --max-distance 3km --genre comedy
uv run richhall info
make dev | test | lint | build
Nothing committed yet. When you're ready I can commit this (the diff is: delete fringe.py, add the package + tooling). And the two follow-ups still stand whenever you want them: a two-day-trip planner (rank/deconflict shows across your two dates) and incremental sync via the API's modified_from. Want me to commit, or move on to one of those?
Sounds good. Everything's in a clean, working state on your end then.
Whenever you want to pick it back up, the two natural next steps are still on the table:
modified_from so fetch does fast delta updates instead of a full re-pull.Just ping me when you're ready.
Let's implement your idea of having a "fringe" command alias and update the docs to reflect that usage.
[project.scripts] richhall = "richhall.main:main"
[project.scripts] richhall = "richhall.main:main" fringe = "richhall.main:main"
The file /home/david/projects/play/richhall/pyproject.toml has been updated successfully. (file state is current in your context — no need to Read it back)
Now re-sync so the new entry point registers, and verify both commands work (Click auto-picks up the invoked name in --help):
uv sync 2>&1 | tail -3 echo "=== fringe --help ===" && uv run fringe --help 2>&1 | head -6 echo && echo "=== fringe info ===" && uv run fringe info 2>&1 | head -2 echo && echo "=== richhall still works ===" && uv run richhall version 2>&1
Uninstalled 1 package in 0.33ms Installed 1 package in 0.48ms ~ richhall==0.1.0 (from file:///home/david/projects/play/richhall) === fringe --help === Usage: fringe [OPTIONS] COMMAND [ARGS]... Options: --debug Enable debug output --quiet Suppress non-essential output --config-file <PATH> Config file (default === fringe info === Cache: /home/david/.local/share/richhall/fringe.db Festival: fringe Year: 2026 === richhall still works === dev
Both commands work, and Click correctly shows fringe in its own usage. Now let me update the docs to use fringe as the primary command. First the README:
A CLI (`richhall`) to copy Edinburgh Festival Fringe event data locally and query it by the things that actually decide a ticket purchase: **which day**, **what time it starts**, and **how far the venue is from the centre of town**.
A CLI to copy Edinburgh Festival Fringe event data locally and query it by the things that actually decide a ticket purchase: **which day**, **what time it starts**, and **how far the venue is from the centre of town**. The command is installed under two names — `fringe` (used throughout this README) and `richhall` — which are interchangeable aliases for the same tool.
The file /home/david/projects/play/richhall/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
# Download / refresh the local cache (SQLite in ~/.local/share/richhall)
uv run richhall fetch # defaults: festival=fringe, year=2026
# What's cached?
uv run richhall info
# Find shows: a given day, starting before 8pm, within 3km of the centre
uv run richhall search --day 2026-08-15 --before 20:00 --max-distance 3km
# Everything is composable:
uv run richhall search \
--date-from 2026-08-15 --date-to 2026-08-16 \
--after 12:00 --before 20:00 \
--max-distance 2km --genre comedy \
--sort distance# Download / refresh the local cache (SQLite in ~/.local/share/richhall)
uv run fringe fetch # defaults: festival=fringe, year=2026
# What's cached?
uv run fringe info
# Find shows: a given day, starting before 8pm, within 3km of the centre
uv run fringe search --day 2026-08-15 --before 20:00 --max-distance 3km
# Everything is composable:
uv run fringe search \
--date-from 2026-08-15 --date-to 2026-08-16 \
--after 12:00 --before 20:00 \
--max-distance 2km --genre comedy \
--sort distanceThe file /home/david/projects/play/richhall/README.md has been updated successfully. (file state is current in your context — no need to Read it back)
fatal_error(f"No cache yet at {cfg.db_path}. Run: richhall fetch") fatal_error(f"No cache yet at {cfg.db_path}. Run: fringe fetch")The file /home/david/projects/play/richhall/richhall/subcommands/info.py has been updated successfully. (file state is current in your context — no need to Read it back)
CLI tool (`richhall`) for selecting Edinburgh Festival Fringe shows to attend. It copies event and venue data locally via the Fringe API, then filters by the criteria that actually decide a ticket purchase: **which day**, **performance start time**, and **distance of the venue from the centre of town**.
CLI tool for selecting Edinburgh Festival Fringe shows to attend. It copies event and venue data locally via the Fringe API, then filters by the criteria that actually decide a ticket purchase: **which day**, **performance start time**, and **distance of the venue from the centre of town**. The command is installed under two interchangeable names, `fringe` and `richhall` (both defined in `[project.scripts]`).
The file /home/david/projects/play/richhall/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
uv sync # Install dependencies (creates .venv) uv run richhall --help # Run CLI in development uv run richhall fetch # Download/refresh the local cache uv run pytest tests/ # Run tests
uv sync # Install dependencies (creates .venv) uv run fringe --help # Run CLI in development uv run fringe fetch # Download/refresh the local cache uv run pytest tests/ # Run tests
The file /home/david/projects/play/richhall/CLAUDE.md has been updated successfully. (file state is current in your context — no need to Read it back)
uv run ruff check richhall/ 2>&1 | tail -2 uv run pytest tests/ -q 2>&1 | tail -3 echo "=== fringe search ===" && uv run fringe search --day 2026-08-16 --before 20:00 --max-distance 1km --sort distance --limit 3 2>&1
All checks passed!
......... [100%]
=== fringe search ===
Sun 16 Aug 12:10 0.01km Chris Cantrill: Rewilding
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £15
Sun 16 Aug 13:35 0.01km Rachel Fairburn: Vexy Beasts
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £14 (12)
Sun 16 Aug 15:00 0.01km Amy Matthews: Definitions of Toast
Monkey Barrel Comedy at O'Neill's (The Tron) · Comedy · 60m · £14
3 of 1502 matching performances (showing first 3)Done. The fringe alias is live and the docs now use it as the primary command.
What changed:
- pyproject.toml — added fringe = "richhall.main:main" alongside richhall in [project.scripts]. Both names invoke the same CLI, and Click shows the correct invoked name in --help.
- README.md — all examples now use fringe, with a note that richhall is an interchangeable alias.
- CLAUDE.md — overview and dev commands updated to fringe.
- richhall/subcommands/info.py — the "no cache yet" hint now says fringe fetch.
Verified: re-synced so the entry point registered, both fringe and richhall work, lint clean, all 9 tests pass, and a live fringe search returns results.
One note for whenever you build the standalone binary: sh/build-package.sh still produces a single executable named build/richhall (via shiv -c richhall). The dual command names are a Python entry-point feature, so the shiv binary itself has one name — trivial to add a second shiv -c fringe build if you ever want a fringe-named binary too. Left it as-is for now since shiv isn't in play yet.
Nothing committed — I'll leave that to you again.