Claude Code transcript - page 2/4

Bye!

No response requested.

Ok, personally I think having magic files beginning with dot so nobody realizes they exist is dumb, but when in Rome...

Thinking

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:

$ Bash
Check .env presence and gitignore status
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)
Thinking

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:

$ Bash
Check Python version and .env ignore status
python3 --version; echo "---"; git check-ignore .env 2>/dev/null && echo ".env already ignored" || echo ".env NOT ignored yet"
Python 3.12.3
---
.env NOT ignored yet
📝 Write .gitignore
/home/david/projects/play/richhall/.gitignore
# 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)
Thinking

.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:

📝 Write fringe.py
/home/david/projects/play/richhall/fringe.py
#!/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)

Now let me verify it runs and does the first real fetch against the API:

$ Bash
Run CLI help and first live fetch
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).
Thinking

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:

$ Bash
Run info and a realistic filtered search
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)
Thinking

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:

$ Bash
Inspect stored coords and distance computation
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']}")
PY
centre: (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
Thinking

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:

$ Bash
Confirm the near-zero distances are genuine
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']}")
PY
   10 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:

✏️ Edit fringe.py
/home/david/projects/play/richhall/fringe.py
        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)
$ Bash
Re-run search with improved distance precision
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)
Thinking

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:

📝 Write README.md
/home/david/projects/play/richhall/README.md
# 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>
Thinking
Read
{
  "file_path": "/home/david/projects/play/richhall/README.md"
}
1	# richhall
2	Utility for Edinburgh Festival analytics
3	
📝 Write README.md
/home/david/projects/play/richhall/README.md
# 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.

What you've got

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.

Verified live

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).

Notes & things you might want next

  • Distance grain: default centre is the Royal Mile (55.9497,-3.1875); override per-query with --centre lat,lon. Displayed to 2 decimals so walkable sub-100m differences show.
  • Cancelled/deleted shows are filtered out of search by default.
  • Data protection: .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.

Thinking

Let me study the reference project's structure so I match the convention you already use. Let me pull its layout and key files:

$ Bash
Fetch reference repo overview and file tree
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
Thinking

gh isn't authenticated. Let me try the public GitHub API directly:

$ Bash
Fetch repo tree via public GitHub API
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
Thinking

Now the key files that define the convention. Let me fetch the important ones:

$ Bash
Fetch reference project convention files
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 yaml
Thinking

Let me grab a sample subcommand, types.py, and the shell scripts to fully match the idiom:

$ Bash
Fetch sample subcommand, types, and shell scripts
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.txt
Thinking

I 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 binaries
  • Click group in main.py, one module per subcommand under subcommands/ (each exposes command)
  • Support modules: 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.py
  • Makefile (dev/build/test/lint) delegating to sh/*.sh; ruff (line-length 132); .envrc; MANIFEST.in; CLAUDE.md; tests/ with pytest
  • version subcommand reading a build-time version.txt

Let me check what tooling is available here before I commit to the exact mechanics:

$ Bash
Check for uv, click, ruamel, direnv availability
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
Thinking

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:

📝 Write __init__.py
/home/david/projects/play/richhall/richhall/__init__.py
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)
📝 Write constants.py
/home/david/projects/play/richhall/richhall/constants.py
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)
📝 Write types.py
/home/david/projects/play/richhall/richhall/types.py
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)
📝 Write di.py
/home/david/projects/play/richhall/richhall/di.py
# 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)
📝 Write log.py
/home/david/projects/play/richhall/richhall/log.py
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)
📝 Write factory.py
/home/david/projects/play/richhall/richhall/factory.py
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)
📝 Write config.py
/home/david/projects/play/richhall/richhall/config.py
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)
📝 Write api.py
/home/david/projects/play/richhall/richhall/api.py
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)
📝 Write db.py
/home/david/projects/play/richhall/richhall/db.py
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)
📝 Write geo.py
/home/david/projects/play/richhall/richhall/geo.py
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)

Now the subcommands:

📝 Write __init__.py
/home/david/projects/play/richhall/richhall/subcommands/__init__.py
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)
📝 Write fetch.py
/home/david/projects/play/richhall/richhall/subcommands/fetch.py
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)
📝 Write search.py
/home/david/projects/play/richhall/richhall/subcommands/search.py
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)
📝 Write info.py
/home/david/projects/play/richhall/richhall/subcommands/info.py
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)
📝 Write main.py
/home/david/projects/play/richhall/richhall/main.py
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)

Now the packaging/tooling files:

📝 Write pyproject.toml
/home/david/projects/play/richhall/pyproject.toml
[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)
📝 Write Makefile
/home/david/projects/play/richhall/Makefile
# 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)
📝 Write dev-setup.sh
/home/david/projects/play/richhall/sh/dev-setup.sh
#!/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)
📝 Write lint.sh
/home/david/projects/play/richhall/sh/lint.sh
#!/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)
📝 Write make-release-tag.sh
/home/david/projects/play/richhall/sh/make-release-tag.sh
#!/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)
📝 Write build-package.sh
/home/david/projects/play/richhall/sh/build-package.sh
#!/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)
📝 Write MANIFEST.in
/home/david/projects/play/richhall/MANIFEST.in
include README.md
File created successfully at: /home/david/projects/play/richhall/MANIFEST.in (file state is current in your context — no need to Read it back)
📝 Write .envrc
/home/david/projects/play/richhall/.envrc
. .venv/bin/activate
File created successfully at: /home/david/projects/play/richhall/.envrc (file state is current in your context — no need to Read it back)

Now update .gitignore to match the house style, and add tests + CLAUDE.md:

📝 Write .gitignore
/home/david/projects/play/richhall/.gitignore
# 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)
📝 Write __init__.py
/home/david/projects/play/richhall/tests/__init__.py
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)
📝 Write test_signing.py
/home/david/projects/play/richhall/tests/test_signing.py
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)
📝 Write test_geo.py
/home/david/projects/play/richhall/tests/test_geo.py
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)
📝 Write CLAUDE.md
/home/david/projects/play/richhall/CLAUDE.md
# 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:

📝 Write README.md
/home/david/projects/play/richhall/README.md
# 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:

$ Bash
Remove old script, migrate DB, sync deps, lint, test
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%]
Thinking

Lint clean, 9 tests pass. Now verify the CLI works end-to-end against the migrated cache:

$ Bash
Verify CLI help, version, info, and search
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)
Thinking

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:

$ Bash
Smoke-test live signed request through requests
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>&1
records returned: 2
first title: Raves R Us
first perf start: 2026-08-05 22:10:00
Thinking

Everything 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.

Done — restructured to your machine house style

The 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, Makefilesh/*.sh, a version subcommand reading a build-time version.txt, and sh/build-package.sh wired for shiv (make buildbuild/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 → .envconfig.yml (api-key/api-secret), so your existing .env keeps working unchanged.

Usage is now

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?

Thanks, I've taken care of the commit in another window.

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:

  1. Two-day-trip planner — feed it your two dates + time/distance budget and have it rank candidate shows and flag time conflicts so you can plan a double-bill.
  2. Incremental sync — use the API's 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.

Thinking
✏️ Edit pyproject.toml
/home/david/projects/play/richhall/pyproject.toml
[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):

$ Bash
Re-sync and verify both fringe and richhall commands
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:

✏️ Edit README.md
/home/david/projects/play/richhall/README.md
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)
✏️ Edit README.md
/home/david/projects/play/richhall/README.md
# 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 distance
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)
Thinking

Now update the CLAUDE.md dev commands and the one user-facing hint in the code:

✏️ Edit info.py
/home/david/projects/play/richhall/richhall/subcommands/info.py
        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)
✏️ Edit CLAUDE.md
/home/david/projects/play/richhall/CLAUDE.md
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)
✏️ Edit CLAUDE.md
/home/david/projects/play/richhall/CLAUDE.md
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)
Thinking

Let me confirm everything still passes and do a final search through the fringe alias:

$ Bash
Final lint, tests, and a fringe-alias search
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.