diff --git a/.gitignore b/.gitignore index 2b56efe..c50b30a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,8 @@ .env .env.local !.env.staging + +# Recorded demos and the scripts for them, written per PR (demo/README.md) +demo/casts/ +demo/scripts/ +__pycache__/ diff --git a/demo/README.md b/demo/README.md new file mode 100644 index 0000000..15616f1 --- /dev/null +++ b/demo/README.md @@ -0,0 +1,70 @@ +# Recording demos for PR descriptions + +`demo/` records terminal demos of the CLI as [asciicasts](https://docs.asciinema.org/manual/asciicast/v2/), so a PR can show what a change looks like to use. Each recording runs against a stub of the source.coop API filled with made-up records, so nothing touches a real account or a real login. + +```bash +cargo build +# write demo/scripts/pr-60.py for your change (see "Writing a demo"), then: +python3 -I demo/record.py demo/scripts/pr-60.py # every demo in the script, to demo/casts/ +python3 -I demo/record.py demo/scripts/pr-60.py product-create # just one +``` + +It needs Python 3.10 or later on macOS or Linux. To render GIFs it needs [agg](https://github.com/asciinema/agg), and to upload, [asciinema](https://asciinema.org) (`brew install agg asciinema`). Demos that pipe through `jq` need it installed. + +## What's here + +- `stub.py` serves `fixtures.json` under `/api/v1` from memory. What a demo creates, edits or deletes, the commands after it see. Every demo starts from a fresh copy. It answers what the CLI asks, in the API's shapes, and checks nothing: it is for demos, not tests, which use `wiremock`. +- `fixtures.json` holds the records: the signed-in `demo-user`, who owns the organization `demo-org`, with its products, data connections, service accounts, trusts, API keys and memberships. Add what a new demo needs here. +- `record.py` runs bash in a pseudo-terminal at 120×34 and types each demo's commands like a person. It records with real timing, capping idle time at 2.5s. The recording ends at the last prompt, and with agg installed each is also rendered as a GIF beside its cast. +- No demo scripts are kept. Each PR writes its own, for what it changes, in `demo/scripts/`, which git ignores. A script that isn't run between PRs only goes stale, and whoever made a change knows what to show of it. + +## Writing a demo + +A script is a Python file defining `DEMOS = {name: (title, function)}`, where each function drives a `Session`. `record.py` provides `DOWN`, `UP`, `RIGHT`, `LEFT`, `ESC` and `BACKSPACE`. + +```python +# demo/scripts/pr-60.py +def create(s): + s.run("source-coop product create") # type a command at the prompt and run it + s.see("Account") # wait for text (a regex) to appear + s.key(DOWN) # press keys: DOWN UP RIGHT LEFT ESC BACKSPACE, or any text + s.enter() + s.see("Product ID") + s.type("sea-ice") # type, a character at a time + s.enter() + s.see("Create this product") + s.pause(3) # give the viewer time to read + s.enter() + + +DEMOS = {"product-create": ("source-coop product create (prompted)", create)} +``` + +Write one demo for each command or flow the PR changes, showing what changed: prompts and their order, `-i` screens and their keys, refusals and exit codes. Add whatever the demos need to `stub.py` and `fixtures.json`. Those are kept, so the next PR starts from them. + +Wait with `see` before every keypress that answers a prompt. Run each command after the first with `s.prompt()` in between, so it waits for the one before. If a `see` never matches, recording stops and prints the end of the screen. + +Each session gets `SOURCE_TOKEN=demo`, `SOURCE_API_URL` pointing at the stub, and a throwaway `HOME`. So the CLI never reads the real login session or the OS keyring, and drafts land in the throwaway `HOME`. The one thing that does reach the network is the CLI's own lookups elsewhere, such as a GitHub repository's subject claim. Use a public repository for those, like `source-cooperative/source.coop`. + +## In a PR description + +GitHub fetches images in PR descriptions through its proxy, which can't load asciinema.org's previews. So a PR shows each demo as a GIF served from this repo's `demo-assets` branch, linking to its asciinema.org player. For PR 60: + +```bash +python3 -I demo/record.py demo/scripts/pr-60.py --upload \ + --gif-base https://github.com/source-cooperative/source-coop-cli/raw/demo-assets/pr-60 > demos.md + +git worktree add ../demo-assets demo-assets # the branch holds only images +mkdir -p ../demo-assets/pr-60 && cp demo/casts/*.gif ../demo-assets/pr-60/ +git -C ../demo-assets add pr-60 && git -C ../demo-assets commit -m "Demo GIFs for #60" && git -C ../demo-assets push +``` + +Paste `demos.md` into the description. Each entry looks like this: + +```markdown +**source-coop product create (prompted)** + +[![product-create](https://github.com/source-cooperative/source-coop-cli/raw/demo-assets/pr-60/product-create.gif)](https://asciinema.org/a/ID) +``` + +The GIF plays inline, and clicking it opens the player, where you can pause and copy text. Anyone with a link can watch the recording, so record only the stub's made-up data. URLs in the demos name the stub (`http://localhost:PORT`): say so in the PR. diff --git a/demo/fixtures.json b/demo/fixtures.json new file mode 100644 index 0000000..6fc2ffe --- /dev/null +++ b/demo/fixtures.json @@ -0,0 +1,254 @@ +{ + "whoami": { + "identity_id": "00000000-0000-0000-0000-000000000001", + "account": { + "account_id": "demo-user", + "type": "individual", + "name": "Demo User", + "flags": [ + "create_repositories" + ], + "disabled": false + }, + "memberships": [ + { + "membership_id": "00000000-0000-0000-0000-0000000000a1", + "account_id": "demo-user", + "membership_account_id": "demo-org", + "role": "owners", + "state": "member", + "state_changed": "2026-01-05T10:00:00Z" + } + ] + }, + "accounts": [ + { + "account_id": "demo-org", + "type": "organization", + "name": "Demo Org", + "disabled": false, + "flags": [ + "create_service_accounts" + ] + }, + { + "account_id": "demo-org--nightly-sync", + "type": "service", + "name": "Nightly sync", + "owner_account_id": "demo-org", + "disabled": false, + "flags": [], + "metadata_public": {}, + "created_at": "2026-09-02T17:04:11Z", + "updated_at": "2026-09-02T17:04:11Z" + }, + { + "account_id": "demo-org--stac-indexer", + "type": "service", + "name": "STAC indexer", + "owner_account_id": "demo-org", + "disabled": false, + "flags": [], + "metadata_public": {}, + "created_at": "2026-09-02T17:04:11Z", + "updated_at": "2026-09-02T17:04:11Z" + }, + { + "account_id": "demo-org--old-backfill", + "type": "service", + "name": "Old backfill", + "owner_account_id": "demo-org", + "disabled": true, + "flags": [], + "metadata_public": {}, + "created_at": "2025-03-11T08:00:00Z", + "updated_at": "2026-02-01T08:00:00Z" + } + ], + "trusts": [ + { + "account_id": "demo-org--nightly-sync", + "issuer": "https://token.actions.githubusercontent.com", + "subject": "repo:source-cooperative/source.coop:ref:refs/heads/main", + "created_at": "2026-09-02T17:04:11Z", + "created_by": "demo-user" + }, + { + "account_id": "demo-org--stac-indexer", + "issuer": "https://token.actions.githubusercontent.com", + "subject": "repo:source-cooperative/source.coop:environment:production", + "created_at": "2026-09-02T17:04:11Z", + "created_by": "demo-user" + } + ], + "memberships": [ + { + "membership_id": "00000000-0000-0000-0000-0000000000b1", + "account_id": "demo-org--nightly-sync", + "membership_account_id": "demo-org", + "product_id": "climate-grid", + "role": "write_data", + "state": "member", + "state_changed": "2026-09-02T17:04:11Z" + }, + { + "membership_id": "00000000-0000-0000-0000-0000000000b2", + "account_id": "demo-org--stac-indexer", + "membership_account_id": "demo-org", + "product_id": "demo-data", + "role": "read_data", + "state": "member", + "state_changed": "2026-09-02T17:04:11Z" + } + ], + "data_connections": [ + { + "data_connection_id": "demo-s3", + "name": "Demo S3", + "read_only": false, + "allowed_visibilities": [ + "public", + "unlisted", + "restricted" + ], + "details": { + "provider": "s3" + } + }, + { + "data_connection_id": "public-archive", + "name": "Public archive", + "read_only": true, + "allowed_visibilities": [ + "public" + ], + "details": { + "provider": "s3" + } + } + ], + "products": [ + { + "account_id": "demo-org", + "product_id": "climate-grid", + "title": "Climate Grid", + "description": "Daily gridded temperature and precipitation, 1950 to today.", + "visibility": "public", + "disabled": false, + "featured": 1, + "created_at": "2025-06-01T12:00:00Z", + "updated_at": "2026-09-30T08:15:00Z", + "metadata": { + "tags": [ + "climate", + "zarr" + ], + "primary_mirror": "demo-s3", + "mirrors": { + "demo-s3": { + "connection_id": "demo-s3", + "prefix": "demo-org/climate-grid/", + "is_primary": true + } + } + } + }, + { + "account_id": "demo-org", + "product_id": "demo-data", + "title": "Demo Data", + "description": "A small sample dataset for trying things out.", + "visibility": "unlisted", + "disabled": false, + "featured": 0, + "created_at": "2026-02-14T09:30:00Z", + "updated_at": "2026-02-14T09:30:00Z", + "metadata": { + "tags": [ + "sample" + ], + "primary_mirror": "demo-s3", + "mirrors": { + "demo-s3": { + "connection_id": "demo-s3", + "prefix": "demo-org/demo-data/", + "is_primary": true + } + } + } + }, + { + "account_id": "demo-org", + "product_id": "landsat-mirror", + "title": "Landsat Mirror", + "description": "Cloud-optimized GeoTIFFs mirrored from the USGS archive.", + "visibility": "restricted", + "disabled": false, + "featured": 0, + "created_at": "2025-11-20T16:45:00Z", + "updated_at": "2026-08-01T11:00:00Z", + "metadata": { + "tags": [ + "landsat", + "cog" + ], + "primary_mirror": "demo-s3", + "mirrors": { + "demo-s3": { + "connection_id": "demo-s3", + "prefix": "demo-org/landsat-mirror/", + "is_primary": true + } + } + } + }, + { + "account_id": "demo-user", + "product_id": "field-notes", + "title": "Field Notes", + "description": "Survey photos and GPS tracks.", + "visibility": "public", + "disabled": false, + "featured": 0, + "created_at": "2026-04-02T07:00:00Z", + "updated_at": "2026-04-02T07:00:00Z", + "metadata": { + "tags": [ + "survey" + ], + "primary_mirror": "demo-s3", + "mirrors": { + "demo-s3": { + "connection_id": "demo-s3", + "prefix": "demo-user/field-notes/", + "is_primary": true + } + } + } + } + ], + "keys": [ + { + "key_id": "00000000-0000-0000-0000-0000000000c1", + "account_id": "demo-org--nightly-sync", + "label": "HPC cron job", + "hint": "Qx7Lm2", + "created_at": "2026-08-20T10:00:00Z", + "created_by": "demo-user", + "expires_at": "2027-02-01T00:00:00Z", + "last_used_at": "2026-10-13T02:00:05Z" + }, + { + "key_id": "00000000-0000-0000-0000-0000000000c2", + "account_id": "demo-org--nightly-sync", + "label": "Old laptop", + "hint": "bR4tZ9", + "created_at": "2025-12-01T10:00:00Z", + "created_by": "demo-user", + "expires_at": null, + "last_used_at": "2026-03-02T18:30:00Z", + "revoked_at": "2026-03-05T09:12:00Z", + "revoked_via": "github" + } + ] +} diff --git a/demo/record.py b/demo/record.py new file mode 100644 index 0000000..757dd7c --- /dev/null +++ b/demo/record.py @@ -0,0 +1,209 @@ +"""Record terminal demos of the CLI as asciicasts, for PR descriptions. + +Each demo runs bash in a pseudo-terminal against demo/stub.py, with a fresh +copy of the fixtures, a throwaway HOME and SOURCE_TOKEN set, so it never +reads the real login session or keychain. It types and presses keys like a +person, and the recording keeps the real timing (idle time capped). + + python3 -I demo/record.py SCRIPT [--bin DIR] [--out DIR] [--upload] [NAME ...] + +SCRIPT defines DEMOS = {name: (title, function)}; each function drives a +`Session` (see demo/README.md). Each cast is also rendered as a GIF +with agg, when it's installed. --upload sends each cast to asciinema.org, +where anyone with the link can watch it, and prints markdown for a PR +description: the GIF, served from --gif-base, linking to the player. +""" + +import argparse, fcntl, json, os, pty, re, runpy, shutil, socket, struct +import subprocess, sys, tempfile, termios, threading, time + +COLS, ROWS = 120, 34 +KEYS = {"DOWN": "\x1b[B", "UP": "\x1b[A", "RIGHT": "\x1b[C", "LEFT": "\x1b[D", + "ESC": "\x1b", "BACKSPACE": "\x7f"} +ANSI = re.compile(r"\x1b(\[[0-9;?]*[ -/]*[@-~]|[()][0-9A-Za-z]|[=>78])") + + +class Session: + """bash in a pty, recorded from start to the last prompt.""" + + def __init__(self, title, env, cwd): + self.title, self.events, self.text, self.seen = title, [], "", 0 + self.lock = threading.Lock() + pid, fd = pty.fork() + if pid == 0: + os.chdir(cwd) + os.execve("/bin/bash", ["bash", "--noprofile", "--norc", "-i"], env) + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0)) + self.pid, self.fd, self.t0 = pid, fd, time.time() + threading.Thread(target=self._read, daemon=True).start() + self.prompt() + + def _read(self): + while True: + try: + data = os.read(self.fd, 65536) + except OSError: + return + if not data: + return + text = data.decode("utf-8", "replace") + with self.lock: + self.events.append([round(time.time() - self.t0, 6), "o", text]) + self.text += text + + def see(self, pattern, timeout=20): + """Wait for `pattern` (a regex) in what has appeared since the last wait.""" + end = time.time() + timeout + while time.time() < end: + with self.lock: + if re.search(pattern, ANSI.sub("", self.text[self.seen:])): + # ponytail: everything so far counts as seen; each wait is + # for something new, so nothing needs a precise offset. + self.seen = len(self.text) + return + time.sleep(0.05) + tail = ANSI.sub("", self.text[-1500:]) + raise SystemExit(f"{self.title}: never saw {pattern!r}. The screen ended:\n{tail}") + + def prompt(self): + """Wait for the shell's prompt: the command before has finished.""" + self.see(r"\$ $") + + def pause(self, seconds): + time.sleep(seconds) + + def send(self, keys): + os.write(self.fd, keys.encode()) + + def type(self, text, before=0.4): + self.pause(before) + for c in text: + self.send(c) + time.sleep(0.045) + + def run(self, command): + """Type a command at the prompt and run it.""" + self.type(command, 0.7) + self.pause(0.5) + self.send("\r") + + def enter(self): + self.pause(0.6) + self.send("\r") + + def key(self, keys, times=1): + for _ in range(times): + self.pause(0.4) + self.send(keys) + + def finish(self, path): + """Wait for the last command, then save the cast and close the shell.""" + self.prompt() + self.pause(2.5) + with self.lock: + self.events.append([round(time.time() - self.t0, 6), "o", ""]) + kept = list(self.events) + self.send("exit\r") + os.waitpid(self.pid, 0) + header = {"version": 2, "width": COLS, "height": ROWS, "timestamp": int(self.t0), + "idle_time_limit": 2.5, "title": self.title, + "env": {"TERM": "xterm-256color", "SHELL": "/bin/bash"}} + with open(path, "w") as f: + f.write(json.dumps(header) + "\n") + f.writelines(json.dumps(e) + "\n" for e in kept) + + +def free_port(): + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def wait_for(port): + for _ in range(100): + try: + socket.create_connection(("127.0.0.1", port), 0.1).close() + return + except OSError: + time.sleep(0.05) + raise SystemExit("the stub didn't start") + + +def record(name, title, fn, bin_dir, out): + here = os.path.dirname(os.path.abspath(__file__)) + port = free_port() + stub = subprocess.Popen([sys.executable, "-I", os.path.join(here, "stub.py"), str(port)]) + home = tempfile.mkdtemp(prefix="source-coop-demo-") + try: + wait_for(port) + env = {"PATH": f"{bin_dir}:{os.environ.get('PATH', '/usr/bin:/bin')}", "HOME": home, + "TERM": "xterm-256color", "LANG": "en_US.UTF-8", "SOURCE_TOKEN": "demo", + "SOURCE_API_URL": f"http://localhost:{port}", "BASH_SILENCE_DEPRECATION_WARNING": "1", + "PS1": "\\[\\e[1;32m\\]$\\[\\e[0m\\] "} + session = Session(title, env, home) + fn(session) + path = os.path.join(out, f"{name}.cast") + session.finish(path) + return path + finally: + stub.kill() + stub.wait() + shutil.rmtree(home, ignore_errors=True) + + +def gif(cast): + """The cast as an animated GIF beside it, or None without agg.""" + if not shutil.which("agg"): + return None + out = cast.removesuffix(".cast") + ".gif" + subprocess.run(["agg", "--font-size", "14", "--idle-time-limit", "2.5", "--last-frame-duration", "4", + cast, out], check=True, capture_output=True) + return out + + +def upload(path): + done = subprocess.run(["asciinema", "upload", path], capture_output=True, text=True, check=True) + # asciinema prints the URL on stderr or stdout, depending on its version. + url = re.search(r"https://asciinema\.org/a/\w+", done.stdout + done.stderr) + if not url: + raise SystemExit(f"asciinema didn't say where {path} went:\n{done.stdout}{done.stderr}") + return url[0] + + +def main(): + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("script", help="a demo script defining DEMOS") + p.add_argument("names", nargs="*", help="which demos to record (all when omitted)") + p.add_argument("--bin", default="target/debug", help="the directory holding source-coop [target/debug]") + p.add_argument("--out", default="demo/casts", help="where to write the .cast files [demo/casts]") + p.add_argument("--upload", action="store_true", help="upload each cast to asciinema.org and print markdown") + p.add_argument("--gif-base", help="with --upload: the URL the GIFs will be served from, e.g. " + "https://github.com/source-cooperative/source-coop-cli/raw/demo-assets/pr-60") + args = p.parse_args() + if args.upload and not args.gif_base: + # GitHub's image proxy can't fetch asciinema.org's previews, so a PR + # shows GIFs served from GitHub, linking to the player. + p.error("--upload needs --gif-base: where the GIFs will be served (see demo/README.md)") + demos = runpy.run_path(args.script, init_globals=dict(KEYS))["DEMOS"] + bin_dir = os.path.abspath(args.bin) + if not os.path.exists(os.path.join(bin_dir, "source-coop")): + raise SystemExit(f"no source-coop in {bin_dir}: run `cargo build` first, or pass --bin") + os.makedirs(args.out, exist_ok=True) + markdown = [] + for name in args.names or demos: + title, fn = demos[name] + path = record(name, title, fn, bin_dir, args.out) + print(f"recorded {path}", file=sys.stderr) + rendered = gif(path) + if rendered: + print(f"rendered {rendered}", file=sys.stderr) + if args.upload: + url = upload(path) + image = f"{args.gif_base.rstrip('/')}/{name}.gif" + markdown += [f"**{title}**", "", f"[![{name}]({image})]({url})", ""] + if markdown: + print("\n".join(markdown)) + + +if __name__ == "__main__": + main() diff --git a/demo/stub.py b/demo/stub.py new file mode 100644 index 0000000..1c48382 --- /dev/null +++ b/demo/stub.py @@ -0,0 +1,198 @@ +"""A stand-in for the source.coop API, for recording demos. + +Serves the made-up records in fixtures.json under /api/v1, in memory: what a +demo creates, edits or deletes is seen by the commands after it, and gone +when the stub stops. It answers only what the CLI asks, in the API's shapes; +it checks nothing, so it is no substitute for the API in tests. + + python3 -I demo/stub.py PORT [FIXTURES] +""" + +import copy, json, os, re, secrets, string, sys, uuid +from datetime import datetime, timezone +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer +from urllib.parse import parse_qs, urlparse + +GITHUB = "https://token.actions.githubusercontent.com" +here = os.path.dirname(os.path.abspath(__file__)) +data = json.load(open(sys.argv[2] if len(sys.argv) > 2 else os.path.join(here, "fixtures.json"))) +accounts = {a["account_id"]: a for a in data["accounts"]} +products, trusts, memberships = data["products"], data["trusts"], data["memberships"] +connections = data["data_connections"] +keys = data.get("keys", []) +me = data["whoami"]["account"]["account_id"] + + +def now(): + return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + +def page(items): + return {"items": items, "next_cursor": None} + + +def error(status, code, message): + return status, {"error": {"code": code, "message": message}} + + +def issue(account_id, body): + """A new API key for `account_id`: its record, with the key shown this once.""" + key = "sck_" + "".join(secrets.choice(string.ascii_letters + string.digits) for _ in range(36)) + k = {"key_id": str(uuid.uuid4()), "account_id": account_id, "label": body.get("label", ""), + "hint": key[-6:], "created_at": now(), "created_by": me, "expires_at": body.get("expires_at")} + keys.append(k) + return dict(k, key=key) + + +def with_account(p): + """A product as the API returns it, with its owner's public view.""" + a = accounts.get(p["account_id"], {"account_id": p["account_id"]}) + return dict(p, account={k: a[k] for k in ("account_id", "name", "type") if k in a}) + + +def find_product(account, product): + return next((p for p in products if (p["account_id"], p["product_id"]) == (account, product)), None) + + +def handle(method, path, q, body): + if path == "/whoami": + return 200, data["whoami"] + + if path == "/products": + found = [p for p in products if p["visibility"] == "public"] + if "q" in q: + found = [p for p in found if q["q"].lower() in json.dumps(p).lower()] + if q.get("featured") == "true": + found = [p for p in found if p["featured"]] + return 200, page([with_account(p) for p in found]) + if path == "/products/featured": + return 200, page([with_account(p) for p in products if p["featured"]]) + if r := re.fullmatch(r"/products/([^/]+)", path): + if method == "POST": + if find_product(r[1], body.get("product_id")): + return 409, {"error": {"code": "conflict", "message": "A product with that ID exists", + "field_errors": {"product_id": ["is taken"]}}} + c = body.get("data_connection_id", "demo-s3") + p = {"account_id": r[1], "product_id": body.get("product_id"), "title": body.get("title", ""), + "description": body.get("description", ""), "visibility": body.get("visibility", "public"), + "disabled": False, "featured": 0, "created_at": now(), "updated_at": now(), + "metadata": {"tags": [], "primary_mirror": c, + "mirrors": {c: {"connection_id": c, "prefix": f"{r[1]}/{body.get('product_id')}/", "is_primary": True}}}} + products.append(p) + return 201, with_account(p) + return 200, page([with_account(p) for p in products if p["account_id"] == r[1]]) + if r := re.fullmatch(r"/products/([^/]+)/([^/]+)", path): + p = find_product(r[1], r[2]) + if not p: + return error(404, "not_found", f"No product {r[1]}/{r[2]}") + if method == "PATCH": + p.update(body, updated_at=now()) + if method == "DELETE": + products.remove(p) + return 200, with_account(p) + + if path == "/data-connections": + return 200, page(connections) + + if r := re.fullmatch(r"/accounts/([^/]+)/service-accounts", path): + owner = r[1] + if method == "POST": + sid = f"{owner}--{body.get('local_id')}" + if sid in accounts: + return 409, {"error": {"code": "conflict", "message": "That ID is taken", + "field_errors": {"local_id": ["is taken"]}}} + a = {"account_id": sid, "type": "service", "name": body.get("name", ""), "owner_account_id": owner, + "disabled": False, "flags": [], "metadata_public": {}, "created_at": now(), "updated_at": now()} + accounts[sid] = a + trusts.extend({"account_id": sid, "issuer": GITHUB, "subject": s, "created_at": now(), "created_by": me} + for s in body.get("github_subjects", [])) + memberships.extend({"membership_id": str(uuid.uuid4()), "account_id": sid, "membership_account_id": owner, + "product_id": g["product_id"], "role": g["role"], "state": "member", + "state_changed": now()} for g in body.get("grants", [])) + if body.get("key"): + return 201, dict(a, key=issue(sid, body["key"])) + return 201, a + return 200, page([a for a in accounts.values() if a.get("owner_account_id") == owner]) + if r := re.fullmatch(r"/accounts/([^/]+)/trusts", path): + sid = r[1] + if method == "POST": + t = {"account_id": sid, "issuer": GITHUB, "subject": body.get("subject"), "created_at": now(), "created_by": me} + trusts.append(t) + return 201, t + mine = [t for t in trusts if t["account_id"] == sid] + if method == "DELETE": + t = next((t for t in mine if (t["issuer"], t["subject"]) == (q.get("issuer"), q.get("subject"))), None) + if not t: + return error(404, "not_found", "No such trust") + trusts.remove(t) + return 200, t + return 200, page(mine) + if r := re.fullmatch(r"/accounts/([^/]+)/keys", path): + if method == "POST": + return 201, issue(r[1], body) + return 200, page([k for k in keys if k["account_id"] == r[1]]) + if r := re.fullmatch(r"/accounts/([^/]+)/keys/([^/]+)", path): + k = next((k for k in keys if (k["account_id"], k["key_id"]) == (r[1], r[2])), None) + if not k: + return error(404, "not_found", "No such key") + if body.get("revoked"): + if k.get("revoked_at"): + return error(409, "conflict", "The key is already revoked") + k.update(revoked_at=now(), revoked_via="owner", revoked_by=me) + if "expires_at" in body: + k["expires_at"] = body["expires_at"] + return 200, k + if r := re.fullmatch(r"/accounts/([^/]+)", path): + a = accounts.get(r[1]) + if not a: + return error(404, "not_found", f"No account {r[1]}") + if method == "PATCH": + a.update(body, updated_at=now()) + if method == "DELETE": + del accounts[r[1]] + return 200, a + + if path == "/memberships": + if method == "POST": + m = dict(body, membership_id=str(uuid.uuid4()), state="member", state_changed=now()) + memberships.append(m) + return 201, m + filters = ("account_id", "membership_account_id", "product_id", "state") + return 200, page([m for m in memberships if all(m.get(f) == q[f] for f in filters if f in q)]) + if r := re.fullmatch(r"/memberships/([^/]+)", path): + m = next((m for m in memberships if m["membership_id"] == r[1]), None) + if not m: + return error(404, "not_found", "No such membership") + m.update(body, state_changed=now()) + return 200, m + + if path == "/service-account-keys/revocations": + return 204, None + return error(404, "not_found", f"The demo stub has no {method} {path}") + + +class Handler(BaseHTTPRequestHandler): + def log_message(self, *args): + pass + + def answer(self, method): + url = urlparse(self.path) + q = {k: v[0] for k, v in parse_qs(url.query).items()} + n = int(self.headers.get("Content-Length") or 0) + body = json.loads(self.rfile.read(n) or b"{}") + status, out = handle(method, url.path.removeprefix("/api/v1"), q, body) + raw = b"" if out is None else json.dumps(copy.deepcopy(out)).encode() + self.send_response(status) + if out is not None: + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(raw))) + self.end_headers() + self.wfile.write(raw) + + def do_GET(self): self.answer("GET") + def do_POST(self): self.answer("POST") + def do_PATCH(self): self.answer("PATCH") + def do_DELETE(self): self.answer("DELETE") + + +ThreadingHTTPServer(("127.0.0.1", int(sys.argv[1])), Handler).serve_forever()