feat(manifest): U5 — booths that say who posted them and why

The index card showed a name, an item count and a countdown, and nothing
the poster chose. An agent with something to show therefore had no way to
make the booth say "look at this" and posted a URL to the link board
instead — which is why 145 of that board's 210 rows (69%) ended up
pointing at booths that had already been swept. The board was absorbing a
job it was never shaped for. This is the shape.

Each booth carries `.booth.json` — {handle, title, why, created} — written
by the CLI from $ALTHING_HANDLE, and the provenance line renders on both
index lanes and on the booth page header.

WHAT IS WHERE

- booth/manifest.py, stdlib-only and importing nothing from booth.* either:
  scripts/booth imports it under the system python3 with no venv, and a
  cross-import between two stdlib-only modules is a second way for that
  invariant to break. It joins the shared test_stdlib_only list and keeps
  a stricter copy of its own.
- The read is lenient and cannot raise. list_booths touches every booth on
  every index load, so a manifest that cannot be parsed costs that booth's
  provenance and nothing else. That is the v0.2.2 lesson applied before the
  same mistake rather than after it.
- Absent and damaged render differently — `unannounced` and `unreadable`.
  Folding "cannot be read" into "never said" would hide the one case
  somebody has to go and fix.
- Re-announcing preserves `created`. A second `booth add` sharpening the
  why is not a second appearance of the booth.
- The write is atomic (invariant 5); the temp file is itself a dotfile, so
  no listing can see it mid-write.

THREE OPERATOR CALLS, 2026-09-22

Flags on the existing new/add verbs rather than a separate `announce` verb
(a second step is the step that gets forgotten, which is the rot's own
mechanism). Unannounced booths get a quiet marker rather than nothing — the
convention is only adoptable if the gap is visible. U5 adds provenance only
and does NOT add a second index ordering keyed on announcement time; that
is a different surface needing its own stated rule, parked for v1.1.

NO EXEMPTION LIST

A pickup booth and the standing link board are created by the service, so
they announce themselves with handle `booth`, which is true rather than
manufactured. One rule — a booth with no manifest is unannounced — instead
of a growing set of special cases.

ALSO

tests/test_booth.py's keep/release assertion was slicing the page on the
bare word `boothhead`, which has lived in the stylesheet far longer than
the assertion has; it was reading CSS and passing on luck, and went red the
first time a new rule landed above the old one. Same assertion, aimed at
the markup. A U5 test had the mirror-image bug: pytest derives tmp_path
from the test name and the index renders data_dir, so a test named
`test_an_unannounced_booth_says_so` put the needle in the haystack itself
and passed against a template that did not yet exist.

310 tests (304 before this unit's CLI half). Live service restarted, 26/26
booth pages verified 200, end-to-end smoke through the real CLI.

NOT TAGGED. The cold contract-review panel is still in flight and the
code-review and bug-hunt gates have not run. Tagging with a gate
outstanding is what made v0.2.0 premature.
This commit is contained in:
Vuong Hoang
2026-09-22 00:48:41 -07:00
parent 109190b0d6
commit a48ef83ef5
12 changed files with 761 additions and 7 deletions
+25 -1
View File
@@ -141,6 +141,12 @@ from booth.inline import ( # noqa: E402
has_placeholders, has_placeholders,
place as place_asks, place as place_asks,
) )
from booth.manifest import ( # noqa: E402
MANIFEST_FILE,
SERVICE_HANDLE,
read_manifest,
write_manifest,
)
from booth.links import ( # noqa: E402 from booth.links import ( # noqa: E402
LINK_LOCK, LINK_LOCK,
LINKS_FILE, LINKS_FILE,
@@ -273,6 +279,11 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
# — which is why marks live in one file per booth rather than a sidecar # — which is why marks live in one file per booth rather than a sidecar
# per mark. This loop runs on every index page load. # per mark. This loop runs on every index page load.
marks = marks_for(child) marks = marks_for(child)
# The booth's own announcement — who posted it and why. One more small
# read per booth, beside the marks read already here, and `read_manifest`
# cannot raise for the same reason `marks_for` must not: this loop runs
# over EVERY booth on every index page load.
manifest = read_manifest(child)
kinds = {"image": 0, "video": 0, "audio": 0, "other": 0} kinds = {"image": 0, "video": 0, "audio": 0, "other": 0}
thumb_url = None thumb_url = None
thumb_blurred = False thumb_blurred = False
@@ -290,6 +301,7 @@ def list_booths(data_dir: Path, ttl_seconds: float, now: float | None = None) ->
{ {
"name": child.name, "name": child.name,
"name_url": quote(child.name, safe=""), "name_url": quote(child.name, safe=""),
"manifest": manifest,
"count": len(items), "count": len(items),
"kinds": kinds, "kinds": kinds,
"thumb_url": thumb_url, "thumb_url": thumb_url,
@@ -748,6 +760,10 @@ def create_app(
}, },
"booth_marks": marks_for_target(marks, None), "booth_marks": marks_for_target(marks, None),
"uploaded": (booth / UPLOAD_MARKER).exists(), "uploaded": (booth / UPLOAD_MARKER).exists(),
# The same provenance line the index card carries. Deliberate:
# a booth URL handed to the operator lands HERE, never on the
# index, and job 5 is "operator, look at this".
"manifest": read_manifest(booth),
"expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)), "expires_in": max(0.0, ttl_seconds - booth_age_seconds(booth)),
}, },
) )
@@ -1086,9 +1102,17 @@ def create_app(
dest = data_dir / booth_id dest = data_dir / booth_id
dest.mkdir(parents=True) dest.mkdir(parents=True)
(dest / UPLOAD_MARKER).write_text("") # stamp as an upload (dotfile, not listed) (dest / UPLOAD_MARKER).write_text("") # stamp as an upload (dotfile, not listed)
# A booth the SERVICE made says so, rather than being exempted from the
# unannounced marker. One rule instead of an exemption list, and the
# handle is true: nobody's agent posted this, the browser did.
write_manifest(dest, SERVICE_HANDLE, title=booth_id,
why="browser upload, for pickup")
total = 0 total = 0
used: set = {UPLOAD_MARKER} # Both markers are belt-and-braces: `safe_upload_name` strips leading
# dots, so an uploaded file can never be named either of them. Listed
# anyway so the set says what the directory already contains.
used: set = {UPLOAD_MARKER, MANIFEST_FILE}
try: try:
for i, f in enumerate(files): for i, f in enumerate(files):
name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used) name = _dedupe_name(safe_upload_name(f.filename, f"file-{i + 1}"), used)
+164
View File
@@ -0,0 +1,164 @@
"""A booth's own announcement — who posted it, and why.
U5. The index card used to show a name, an item count and a countdown, and
nothing the poster chose. An agent with something to show therefore had no way
to make the booth say "look at this" and posted a URL to the link board
instead — which is why 145 of that board's 210 rows (69%) ended up pointing at
booths that had already been swept. The board was absorbing a job it was never
shaped for. This is the shape.
.booth.json -> {"handle": ..., "title": ..., "why": ..., "created": ...}
⚠ STDLIB ONLY, and it imports nothing from `booth.*` either.
`scripts/booth` — the CLI every fleet session uses — imports this module
directly under the system `python3` with no venv, through a `python3 -c`
heredoc no AST extractor can see. A single third-party import here breaks
`booth new` and `booth add` on every host, and the failure surfaces in an
agent's session rather than in ours. The ban extends to sibling `booth` modules:
importing `marks` to reuse its atomic write would drag marks' own import list
into this one's, so the four-line pattern is copied instead. `test_stdlib_only`
in tests/test_manifest.py is the only thing standing here.
Contract: docs/contracts/u5_booth_manifest.contract.md.
"""
from __future__ import annotations
import json
import os
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
MANIFEST_FILE = ".booth.json"
# A `why` renders inside a card's sub-line, so it is one line by construction
# rather than by convention — enforced at the WRITE so nothing downstream has to
# remember. The caps are display budgets, not storage limits.
HANDLE_MAX = 64
TITLE_MAX = 120
WHY_MAX = 200
# The handle a booth created by the service itself carries. A pickup booth and
# the standing link board are made by the Booth, not by an agent, and saying so
# is true rather than manufactured — which is the whole reason there is no
# exemption list. One rule: a booth with no manifest is unannounced.
SERVICE_HANDLE = "booth"
@dataclass(frozen=True)
class Manifest:
"""One booth's announcement.
`handle` is an althing agent handle, or `SERVICE_HANDLE` for a booth the
Booth made. `error` is a read-time verdict and is never stored.
"""
handle: str
title: str
why: str
created: str
error: str | None = None
def _one_line(value, limit: int) -> str:
if not isinstance(value, str):
return ""
return " ".join(value.split())[:limit]
def _now() -> str:
return datetime.now().astimezone().isoformat(timespec="seconds")
def read_manifest(booth: Path) -> Manifest | None:
"""This booth's announcement, or None if it never made one.
LENIENT, AND IT NEVER RAISES (INV-2). `list_booths` calls this once per
booth on every index page load, so a read that can raise is a service-wide
outage wearing a single-booth bug's clothes. That is not hypothetical: a
poisoned `.marks.json` did exactly that to `/` and `/healthz` across all 25
live booths, and the fix shipped in v0.2.2. Same posture, applied before the
same mistake rather than after it.
Absent -> None. Present but unreadable -> a Manifest carrying `error`, so a
card can say `unreadable` instead of quietly showing the same thing as a
booth that never announced (INV-5). Folding the two together would hide the
one case somebody has to go and fix.
Only `handle` is required. A hand-written manifest is a supported input —
the file is plain JSON in a folder the operator owns, and half the point of
the Booth is that a booth is just a directory.
"""
booth = Path(booth)
path = booth / MANIFEST_FILE
try:
text = path.read_text(encoding="utf-8")
except FileNotFoundError:
return None
except (OSError, UnicodeDecodeError) as exc:
return _broken(booth, f"cannot be read: {exc}")
if not text.strip():
return _broken(booth, "is empty")
try:
raw = json.loads(text)
except ValueError as exc:
return _broken(booth, f"is not valid JSON: {exc}")
if not isinstance(raw, dict):
return _broken(booth, "is not a JSON object")
handle = _one_line(raw.get("handle"), HANDLE_MAX)
if not handle:
return _broken(booth, "names no handle")
return Manifest(
handle=handle,
title=_one_line(raw.get("title"), TITLE_MAX) or booth.name,
why=_one_line(raw.get("why"), WHY_MAX),
created=_one_line(raw.get("created"), 64),
)
def _broken(booth: Path, reason: str) -> Manifest:
return Manifest(handle="", title=booth.name, why="", created="",
error=f"{MANIFEST_FILE} {reason}")
def write_manifest(booth: Path, handle: str, *, title: str = "",
why: str = "") -> Manifest:
"""Announce a booth, atomically (CLAUDE.md invariant 5).
Temp file + `os.replace`, because the CLI writes this in one process while
the browser reads it in another — a reader must never see a half-written
document. The temp file is itself a dotfile (`.booth.json.tmp`), so no
listing, gallery or zip can see it mid-write either.
RE-ANNOUNCING PRESERVES `created` (INV-3). It is when the booth APPEARED,
and saying something more about it later is not a second appearance —
`booth add` on an existing booth is the common case, where the poster drops
the second batch and sharpens the why. A `created` that cannot be read back
is replaced rather than guessed at: a stamp that is silently wrong is worse
than one that is silently new.
"""
booth = Path(booth)
booth.mkdir(parents=True, exist_ok=True)
prior = read_manifest(booth)
created = prior.created if prior and not prior.error and prior.created else _now()
record = Manifest(
handle=_one_line(handle, HANDLE_MAX) or SERVICE_HANDLE,
title=_one_line(title, TITLE_MAX) or booth.name,
why=_one_line(why, WHY_MAX),
created=created,
)
path = booth / MANIFEST_FILE
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(
json.dumps(
{"handle": record.handle, "title": record.title,
"why": record.why, "created": record.created},
ensure_ascii=False, indent=2,
) + "\n",
encoding="utf-8",
)
os.replace(tmp, path)
return record
+17
View File
@@ -0,0 +1,17 @@
{# THE ANNOUNCEMENT — who posted this booth and why. Defined ONCE and called
from both index lanes and the booth page header: the kept lane is a separate
block, and patching only the ephemeral one would leave the durable,
most-looked-at boards with exactly the defect this closes.
Four states, and `unannounced` is distinct from `unreadable` on purpose —
folding "cannot be read" into "never said" hides the one case somebody has to
go and fix. The classes are the test hooks; the words are for the operator. #}
{% macro provenance(m) -%}
{% if m is none %}
<div class="prov prov-none">unannounced</div>
{% elif m.error %}
<div class="prov prov-broken" title="{{ m.error }}">unreadable</div>
{% else %}
<div class="prov"><span class="prov-who">{{ m.handle }}</span>{% if m.why %} · <span class="prov-why">{{ m.why }}</span>{% endif %}</div>
{% endif %}
{%- endmacro %}
+19
View File
@@ -255,6 +255,22 @@
.card .name:hover{text-decoration:none;color:var(--aus-bright-cyan)} .card .name:hover{text-decoration:none;color:var(--aus-bright-cyan)}
.card .sub{color:var(--fg-3);font-size:.72rem;font-family:var(--font-mono);letter-spacing:.03em;margin-top:.3rem} .card .sub{color:var(--fg-3);font-size:.72rem;font-family:var(--font-mono);letter-spacing:.03em;margin-top:.3rem}
/* THE ANNOUNCEMENT — who posted this booth and why (U5). Same size and
rhythm as .sub above it, because it is the same class of information: a
second line of card metadata, not a heading. The handle carries the only
colour, so a scan down the index reads as a column of posters. */
.prov{margin-top:.28rem;font-size:.72rem;font-family:var(--font-mono);
letter-spacing:.03em;color:var(--fg-3);line-height:1.45;
overflow-wrap:anywhere}
.prov-who{color:var(--fg-2)}
.prov-why{color:var(--fg-3)}
/* Quiet on purpose. 26 booths arrived before this convention existed and
rsync keeps making more, so the marker has to be visible-if-you-look and
never a badge shouting 26 times. `unreadable` gets the warning tint
because, unlike `unannounced`, it is something somebody has to fix. */
.prov-none{color:var(--fg-muted);font-style:italic}
.prov-broken{color:var(--aus-bright-yellow,#e8c547);font-style:italic;cursor:help}
.wipe{position:absolute;top:.5rem;right:.5rem;margin:0} .wipe{position:absolute;top:.5rem;right:.5rem;margin:0}
/* ★ keep, mirroring .wipe on the other shoulder of the card. Same /* ★ keep, mirroring .wipe on the other shoulder of the card. Same
hover-to-reveal language as .release in the kept lane. */ hover-to-reveal language as .release in the kept lane. */
@@ -408,6 +424,9 @@
.boothhead h1{margin:0;font-family:var(--font-display);font-weight:600;font-size:1.5rem; .boothhead h1{margin:0;font-family:var(--font-display);font-weight:600;font-size:1.5rem;
letter-spacing:-.01em;word-break:break-word;flex:1 1 auto;color:var(--fg-0)} letter-spacing:-.01em;word-break:break-word;flex:1 1 auto;color:var(--fg-0)}
.boothhead .sub{color:var(--fg-3);font-size:.74rem;font-family:var(--font-mono);letter-spacing:.06em} .boothhead .sub{color:var(--fg-3);font-size:.74rem;font-family:var(--font-mono);letter-spacing:.06em}
/* Its own row under the title, not another chip in the flex line — a `why`
can run to WHY_MAX and would otherwise shove the zip link around. */
.boothhead .prov{flex:0 0 100%;margin-top:-.35rem}
.wipe-lg{position:static} .wipe-lg{position:static}
/* red-outline danger button — legible on the dark canvas, fills on hover */ /* red-outline danger button — legible on the dark canvas, fills on hover */
.wipe-lg button{width:auto;height:auto;padding:.42rem .85rem;border-radius:var(--radius-md); .wipe-lg button{width:auto;height:auto;padding:.42rem .85rem;border-radius:var(--radius-md);
+2
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_provenance.html" import provenance %}
{# The blur toggle, defined ONCE. There are three item branches in this file {# The blur toggle, defined ONCE. There are three item branches in this file
(doc / media / other) and the first cut of this feature patched only one of (doc / media / other) and the first cut of this feature patched only one of
them, so docs rendered with no control at all. A macro makes "patched two of them, so docs rendered with no control at all. A macro makes "patched two of
@@ -58,6 +59,7 @@
<h1>{{ name }}</h1> <h1>{{ name }}</h1>
<span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span> <span class="sub">{% if uploaded %}<span class="badge">⬆ pickup</span> {% endif %}{% if board %}{{ board|length }} link{{ '' if board|length == 1 else 's' }}{% if items %} · {{ items|length }} file{{ '' if items|length == 1 else 's' }}{% endif %}{% else %}{% if marks_open %}<span class="badge badge-mark">{{ marks_open }} open</span> · {% endif %}{{ items|length }} item{{ '' if items|length == 1 else 's' }} · expires in {{ expires_in|dur }}{% endif %}</span>
{% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %} {% if items %}<a class="dl-link" href="/b/{{ name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a>{% endif %}
{{ provenance(manifest) }}
{# A durable multi-writer board gets no one-click wipe — same rule as the {# A durable multi-writer board gets no one-click wipe — same rule as the
kept lane on the index. Remove rows with the per-row ×, or release the kept lane on the index. Remove rows with the per-row ×, or release the
board from the index and wipe it from there. #} board from the index and wipe it from there. #}
+3
View File
@@ -1,4 +1,5 @@
{% extends "base.html" %} {% extends "base.html" %}
{% from "_provenance.html" import provenance %}
{% block content %} {% block content %}
<form class="uploader" method="post" action="/upload" enctype="multipart/form-data"> <form class="uploader" method="post" action="/upload" enctype="multipart/form-data">
<label class="drop" for="booth-files"> <label class="drop" for="booth-files">
@@ -39,6 +40,7 @@
<div class="meta"> <div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a> <a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div> <div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · kept · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
{{ provenance(b.manifest) }}
</div> </div>
{# There IS a × here now (operator, 2026-09-21). The old rule was {# There IS a × here now (operator, 2026-09-21). The old rule was
release-then-find-it-in-the-other-lane, on the theory that two release-then-find-it-in-the-other-lane, on the theory that two
@@ -110,6 +112,7 @@
<div class="meta"> <div class="meta">
<a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a> <a class="name" href="/b/{{ b.name_url }}/">{{ b.name }}</a>
<div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div> <div class="sub">{{ b.count }} item{{ '' if b.count == 1 else 's' }} · expires in {{ b.expires_in|dur }} · <a class="dl-link" href="/b/{{ b.name_url }}/?download=1" title="download this booth as a zip">⬇ zip</a></div>
{{ provenance(b.manifest) }}
</div> </div>
{# Promote to the kept lane. The /keep route and the `booth keep` CLI verb {# Promote to the kept lane. The /keep route and the `booth keep` CLI verb
both predate this button; until 2026-09-19 the UI could only RELEASE a both predate this button; until 2026-09-19 the UI could only RELEASE a
+12 -1
View File
@@ -26,7 +26,18 @@ _As of 2026-09-22:_
the deploy. U1 `ce598b3`; U2 `c7f9437` released as `v0.2.0`, then `5e41108` as the deploy. U1 `ce598b3`; U2 `c7f9437` released as `v0.2.0`, then `5e41108` as
`v0.2.1` (four contract-panel findings), then `v0.2.2` carrying the `v0.2.1` (four contract-panel findings), then `v0.2.2` carrying the
**bug-hunt panel's** nine (below). **bug-hunt panel's** nine (below).
- **U5 is the next unit** (operator, 2026-09-21): **self-announcing booths.** - **U5 is IMPLEMENTED and unreleased** as of 2026-09-22. `booth/manifest.py`
(stdlib-only, INV-1), `.booth.json` per booth, the provenance line on both
index lanes and the booth page header, `--why` / `--title` on `booth new` and
`booth add`, and the link board + pickup booths announcing themselves as the
service's own. 310 tests, live service restarted, 26/26 booth pages verified
200 and all 26 rendering `unannounced`. **Deliberately NOT tagged yet**: the
cold `/heid-contract-review` panel is still in flight and the code-review and
bug-hunt gates have not run. That ordering is the 2026-09-21 lesson applied —
a release whose gate is outstanding is premature even when the tier is right.
Contract: `docs/contracts/u5_booth_manifest.contract.md` (carries its own
seam-review section).
- **U5's original framing** (operator, 2026-09-21): **self-announcing booths.**
`.booth.json` carrying `{handle, title, why, created}`, written by the CLI from `.booth.json` carrying `{handle, title, why, created}`, written by the CLI from
`$ALTHING_HANDLE`; the index card gains provenance and a one-line purpose, and `$ALTHING_HANDLE`; the index card gains provenance and a one-line purpose, and
the index becomes the "what landed" feed the link board was being used as. It the index becomes the "what landed" feed the link board was being used as. It
+75 -3
View File
@@ -3,8 +3,10 @@
# folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL # folder under $BOOTH_DATA_DIR; this is sugar over mkdir/cp so you get the URL
# back. # back.
# #
# booth new <name> make an empty booth, print its URL # booth new <name> [--why W] [--title T]
# booth add <name> <file>... copy files into a booth (creates it), print URL # make an empty booth, print its URL
# booth add <name> <file>... [--why W] [--title T]
# copy files into a booth (creates it), print URL
# booth url <name> print a booth's URL # booth url <name> print a booth's URL
# booth ls list booths (kept ones marked ★) # booth ls list booths (kept ones marked ★)
# booth rm <name> wipe a booth now (TTL would eventually anyway) # booth rm <name> wipe a booth now (TTL would eventually anyway)
@@ -75,6 +77,18 @@
# URLs that then drown in terminal scrollback. They go on a standing kept board # URLs that then drown in terminal scrollback. They go on a standing kept board
# instead, with provenance, so they outlive the session that produced them. # instead, with provenance, so they outlive the session that produced them.
# #
# ANNOUNCE YOUR BOOTH. `--why` is one line saying what the operator is looking
# at and why he should care; it lands on the index card and on the booth page
# beside your handle, taken from $ALTHING_HANDLE. It is optional and nothing
# breaks without it — but a booth that cannot say what it is has no way to ask
# for attention except by posting its URL somewhere, which is exactly how the
# link board came to be 69% dead rows. The booth is the place to say it.
#
# booth add r18-ab out/*.png --why "pick the denoiser, left column is v3"
#
# Re-announcing (a second `new` or `add` on the same booth) updates the why and
# KEEPS the original creation stamp: the booth appeared once.
#
# On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.: # On a host that is NOT nh3-dev, rsync into the data dir instead, e.g.:
# rsync -a ./out/ nh3-dev:booth-data/my-run/ # rsync -a ./out/ nh3-dev:booth-data/my-run/
set -euo pipefail set -euo pipefail
@@ -85,23 +99,76 @@ KEEP=".forever" # must match KEEP_MARKER in b
BLUR=".blurred" # one booth-relative item path per line; see `blur` below BLUR=".blurred" # one booth-relative item path per line; see `blur` below
LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}" LINKS_BOARD="${BOOTH_LINKS_BOARD:-links}"
# `--why` / `--title` for `new` and `add`. Pulled out of "$@" wherever they
# appear, so `booth add b *.png --why "..."` and `booth add b --why "..." *.png`
# both work — a glob is usually last and a flag usually after it, but nothing
# enforces that and a session should not have to care.
WHY=""; TITLE=""; ARGS=()
strip_announce_flags() {
ARGS=()
while [ $# -gt 0 ]; do
case "$1" in
--why) [ $# -ge 2 ] || usage; WHY="$2"; shift 2 ;;
--title) [ $# -ge 2 ] || usage; TITLE="$2"; shift 2 ;;
--why=*) WHY="${1#--why=}"; shift ;;
--title=*) TITLE="${1#--title=}"; shift ;;
*) ARGS+=("$1"); shift ;;
esac
done
}
# Announce a booth. Goes through booth/manifest.py rather than printf-ing JSON
# from the shell, because a why containing a quote, a backslash or a newline is
# not an edge case — it is a sentence somebody wrote.
announce() {
BOOTH_SRC="$(cd "$(dirname -- "$(readlink -f -- "$0")")/.." && pwd)" \
BOOTH_ANN_DIR="$1" BOOTH_ANN_HANDLE="$2" \
BOOTH_ANN_TITLE="${3:-}" BOOTH_ANN_WHY="${4:-}" python3 -c '
import os, pathlib, sys
sys.path.insert(0, os.environ["BOOTH_SRC"])
try:
from booth.manifest import write_manifest
write_manifest(pathlib.Path(os.environ["BOOTH_ANN_DIR"]),
os.environ["BOOTH_ANN_HANDLE"],
title=os.environ["BOOTH_ANN_TITLE"],
why=os.environ["BOOTH_ANN_WHY"])
except Exception as exc:
# A booth that could not announce itself is still a booth. Say so on stderr
# and carry on: failing `booth add` over its metadata would lose the files
# the session just copied, which is a far worse trade.
print(f"booth: could not write the announcement: {exc}", file=sys.stderr)
'
}
# Who is posting. The same chain `link` uses for its rows, so provenance means
# the same thing on the board and on the card.
whoami_handle() {
echo "${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
}
usage() { usage() {
echo "usage: booth {new <name>|add <name> <file>...|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2 echo "usage: booth {new <name> [--why W] [--title T]|add <name> <file>... [--why W] [--title T]|url <name>|ls|rm <name>|keep <name>|unkeep <name>|blur <name> <file>...|unblur <name> <file>...|link <url> [description]|links|unlink <id|index>|ask <name> <id> <prompt> <option>... [--no-notes]|marks <name> [--wait [SECS]]|asks <name> (deprecated alias for marks)|answer <name> <id> [--wait [SECS]]|marks-import <name>}" >&2
exit 2 exit 2
} }
cmd="${1:-}"; shift || true cmd="${1:-}"; shift || true
case "$cmd" in case "$cmd" in
new) new)
strip_announce_flags "$@"
set -- ${ARGS+"${ARGS[@]}"}
[ $# -ge 1 ] || usage [ $# -ge 1 ] || usage
mkdir -p -- "$DATA/$1" mkdir -p -- "$DATA/$1"
announce "$DATA/$1" "$(whoami_handle)" "$TITLE" "$WHY"
echo "$URL/b/$1/" echo "$URL/b/$1/"
;; ;;
add) add)
strip_announce_flags "$@"
set -- ${ARGS+"${ARGS[@]}"}
[ $# -ge 2 ] || usage [ $# -ge 2 ] || usage
name="$1"; shift name="$1"; shift
mkdir -p -- "$DATA/$name" mkdir -p -- "$DATA/$name"
cp -- "$@" "$DATA/$name/" cp -- "$@" "$DATA/$name/"
announce "$DATA/$name" "$(whoami_handle)" "$TITLE" "$WHY"
echo "$URL/b/$name/" echo "$URL/b/$name/"
;; ;;
url) url)
@@ -177,6 +244,11 @@ case "$cmd" in
board="$DATA/$LINKS_BOARD" board="$DATA/$LINKS_BOARD"
mkdir -p -- "$board" mkdir -p -- "$board"
: > "$board/$KEEP" # the board is durable by definition : > "$board/$KEEP" # the board is durable by definition
# The board announces itself as the SERVICE's, not as any one agent's:
# seventeen handles post to it, so no handle owns it. Idempotent — a second
# link keeps the original creation stamp.
announce "$board" "booth" "$LINKS_BOARD" \
"the standing link board — every agent session posts here"
# Provenance, because a bare URL is unreadable three days later: who posted # Provenance, because a bare URL is unreadable three days later: who posted
# it, from where, and when. # it, from where, and when.
who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}" who="${ALTHING_HANDLE:-${BOOTH_SOURCE:-$(hostname -s 2>/dev/null || echo unknown)}}"
+7 -1
View File
@@ -1537,7 +1537,13 @@ def test_booth_page_offers_keep_when_ephemeral_and_release_when_kept(client):
_png(d / "x.png") _png(d / "x.png")
body = c.get("/b/bo/").text body = c.get("/b/bo/").text
assert "☆ keep" in body and "release" not in body.split("boothhead")[1][:900] # Sliced on the ELEMENT, not the bare word: `boothhead` has appeared in the
# stylesheet this page carries since long before this assertion, so
# `split("boothhead")[1]` was reading CSS and passing on luck. It went red
# the first time a new rule landed above the old one (U5's .prov), which is
# the only reason anybody noticed. Same assertion, aimed at the markup.
head = body.split('class="boothhead"')[1][:900]
assert "☆ keep" in body and "release" not in head
c.post("/b/bo/keep", data={"next": "/b/bo/"}, follow_redirects=False) c.post("/b/bo/keep", data={"next": "/b/bo/"}, follow_redirects=False)
body = c.get("/b/bo/").text body = c.get("/b/bo/").text
+88
View File
@@ -119,3 +119,91 @@ def test_answer_on_a_note_id_says_no_such_pick(booth):
r = run(data, "answer", "b", "note-1") r = run(data, "answer", "b", "note-1")
assert r.returncode == NO_SUCH_PICK assert r.returncode == NO_SUCH_PICK
assert "no such pick" in r.stderr assert "no such pick" in r.stderr
# ---- U5: self-announcing booths ---------------------------------------------
def _manifest(booth_dir):
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent.parent))
from booth.manifest import read_manifest
return read_manifest(booth_dir)
def test_new_announces_the_booth(tmp_path):
"""`$ALTHING_HANDLE` is the whole provenance story: the session already has
it, so the booth can say who made it without anybody typing a name."""
env = {**os.environ, "ALTHING_HANDLE": "shutter-dev"}
r = subprocess.run([str(SCRIPT), "new", "r18-ab", "--why", "pick the winner"],
capture_output=True, text=True, timeout=30,
env={**env, "BOOTH_DATA_DIR": str(tmp_path),
"BOOTH_URL": "http://booth.invalid"})
assert r.returncode == 0, r.stderr
m = _manifest(tmp_path / "r18-ab")
assert m.handle == "shutter-dev"
assert m.why == "pick the winner"
def test_new_without_a_why_is_still_legal(tmp_path):
"""The flags are optional and existing call sites keep working. A booth
that says only who made it is still a booth that said something."""
r = subprocess.run([str(SCRIPT), "new", "scratch"], capture_output=True,
text=True, timeout=30,
env={**os.environ, "ALTHING_HANDLE": "booth-dev",
"BOOTH_DATA_DIR": str(tmp_path),
"BOOTH_URL": "http://booth.invalid"})
assert r.returncode == 0, r.stderr
m = _manifest(tmp_path / "scratch")
assert m.handle == "booth-dev" and m.why == ""
def test_add_announces_and_still_copies_the_files(tmp_path):
"""`add` is the verb most sessions actually use — it creates the booth AND
fills it — so the why has to ride on it or it rides nowhere."""
src = tmp_path / "src"
src.mkdir()
(src / "a.txt").write_text("content")
r = subprocess.run([str(SCRIPT), "add", "r18-ab", str(src / "a.txt"),
"--why", "second pass", "--title", "R18 A/B"],
capture_output=True, text=True, timeout=30,
env={**os.environ, "ALTHING_HANDLE": "booth-dev",
"BOOTH_DATA_DIR": str(tmp_path),
"BOOTH_URL": "http://booth.invalid"})
assert r.returncode == 0, r.stderr
assert (tmp_path / "r18-ab" / "a.txt").read_text() == "content"
m = _manifest(tmp_path / "r18-ab")
assert m.why == "second pass" and m.title == "R18 A/B"
def test_add_re_announcing_keeps_the_original_created(tmp_path):
"""The common shape: `new` opens the booth, `add` drops the second batch and
sharpens the why. The booth appeared once."""
env = {**os.environ, "ALTHING_HANDLE": "booth-dev",
"BOOTH_DATA_DIR": str(tmp_path), "BOOTH_URL": "http://booth.invalid"}
src = tmp_path / "a.txt"
src.write_text("x")
subprocess.run([str(SCRIPT), "new", "b", "--why", "first"], check=True,
capture_output=True, timeout=30, env=env)
first = _manifest(tmp_path / "b").created
subprocess.run([str(SCRIPT), "add", "b", str(src), "--why", "sharper"],
check=True, capture_output=True, timeout=30, env=env)
after = _manifest(tmp_path / "b")
assert after.created == first
assert after.why == "sharper"
def test_the_link_board_announces_itself_as_the_booths_own(tmp_path):
"""No exemption list. The standing board is made by the service and posted
to by seventeen handles, so no single agent owns it — `booth` is the
truthful answer, and it keeps the rule to one line."""
r = subprocess.run([str(SCRIPT), "link", "http://example.invalid", "a thing"],
capture_output=True, text=True, timeout=30,
env={**os.environ, "ALTHING_HANDLE": "booth-dev",
"BOOTH_DATA_DIR": str(tmp_path),
"BOOTH_URL": "http://booth.invalid"})
assert r.returncode == 0, r.stderr
m = _manifest(tmp_path / "links")
assert m is not None and m.handle == "booth"
assert m.why
+345
View File
@@ -0,0 +1,345 @@
"""U5 — self-announcing booths.
A booth carries `.booth.json` saying who posted it and why, and the index card
and the booth page render it. Closes job 5 (`Announce`) — the job nobody named,
whose absence is the measured cause of 145 dead link rows.
See docs/contracts/u5_booth_manifest.contract.md.
"""
import ast
import json
import os
import pathlib
import sys
import pytest
from booth.manifest import MANIFEST_FILE, Manifest, read_manifest, write_manifest
# ---- slice 1: the record and its storage ------------------------------------
def test_an_announcement_round_trips(tmp_path):
b = tmp_path / "r18-ab"
b.mkdir()
written = write_manifest(b, "booth-dev", why="pick the winning denoiser")
assert (b / MANIFEST_FILE).is_file()
got = read_manifest(b)
assert got == written
assert got.handle == "booth-dev"
assert got.why == "pick the winning denoiser"
assert got.error is None
def test_the_title_falls_back_to_the_directory_name(tmp_path):
"""A booth always has a display name. `title` is the one the poster chose
when there is one, and the folder name is a perfectly good one when there
is not — an empty heading on a card is worse than a plain one."""
b = tmp_path / "r18-ab"
b.mkdir()
assert write_manifest(b, "booth-dev").title == "r18-ab"
assert write_manifest(b, "booth-dev", title="R18 A/B").title == "R18 A/B"
def test_a_booth_that_never_announced_reads_as_none(tmp_path):
"""The normal case for every booth that predates this unit, and for every
booth that arrives by rsync — the documented path for any host that is not
nh3-dev, which never runs the CLI at all."""
b = tmp_path / "quiet"
b.mkdir()
assert read_manifest(b) is None
assert read_manifest(tmp_path / "does-not-exist") is None
def test_one_line_by_construction_not_by_convention(tmp_path):
"""`why` renders inside a card's sub-line, so a newline in it would break
the card rather than the field. Truncation and newline-stripping happen at
the WRITE, so nothing downstream has to remember."""
b = tmp_path / "b"
b.mkdir()
m = write_manifest(b, "booth-dev", why="first line\nsecond line\r\nthird")
assert "\n" not in m.why and "\r" not in m.why
assert "first line" in m.why and "second line" in m.why
long = write_manifest(b, "booth-dev", why="x" * 5000)
assert len(long.why) <= 200
assert len(write_manifest(b, "y" * 500).handle) <= 64
assert len(write_manifest(b, "booth-dev", title="t" * 500).title) <= 120
# ---- slice 2: the read cannot raise (INV-2) ---------------------------------
@pytest.mark.parametrize(
"payload",
[
b"{truncated", # not JSON at all
b"[]", # JSON, wrong shape
b'"a string"', # JSON, wronger shape
b"null",
b'{"handle": 7}', # right shape, wrong type
b'{"why": "no handle here"}', # the one required field missing
b"\xff\xfe not utf-8",
b"",
],
ids=["truncated", "list", "string", "null", "wrong-type", "no-handle",
"not-utf8", "empty"],
)
def test_a_damaged_manifest_never_raises(tmp_path, payload):
"""INV-2. `list_booths` calls this once per booth on every index page load,
so a read that can raise is a service-wide outage wearing a single-booth
bug's clothes. That is not a hypothetical — a poisoned `.marks.json` did
exactly that to `/` and `/healthz` across all 25 booths, and the fix shipped
in v0.2.2. The same reader posture, applied before the same mistake."""
b = tmp_path / "b"
b.mkdir()
(b / MANIFEST_FILE).write_bytes(payload)
got = read_manifest(b)
assert isinstance(got, Manifest)
assert got.error, "a damaged manifest read clean"
def test_damaged_is_not_the_same_as_absent(tmp_path):
"""INV-5. Silently folding "cannot be read" into "never announced" would
hide the one case somebody has to go and fix."""
absent = tmp_path / "absent"
absent.mkdir()
damaged = tmp_path / "damaged"
damaged.mkdir()
(damaged / MANIFEST_FILE).write_text("{oops")
assert read_manifest(absent) is None
assert read_manifest(damaged).error
def test_a_manifest_the_module_did_not_write_still_reads(tmp_path):
"""Hand-written is a supported input: the file is plain JSON in a folder the
operator owns, and half the point is that a booth is just a directory. Only
`handle` is required; everything else has a default."""
b = tmp_path / "b"
b.mkdir()
(b / MANIFEST_FILE).write_text(json.dumps({"handle": "shutter-dev"}))
got = read_manifest(b)
assert got.handle == "shutter-dev" and got.error is None
assert got.title == "b"
assert got.why == ""
# ---- slice 3: re-announcement (INV-3) ---------------------------------------
def test_re_announcing_preserves_created(tmp_path):
"""INV-3. `created` is when the booth APPEARED. Saying something more about
it later is not a second appearance, and a `booth add` on an existing booth
is the common case — the poster adds the second batch and sharpens the why."""
b = tmp_path / "b"
b.mkdir()
first = write_manifest(b, "booth-dev", why="first pass")
second = write_manifest(b, "booth-dev", why="second pass, sharper")
assert second.created == first.created
assert second.why == "second pass, sharper"
def test_re_announcing_over_a_damaged_file_does_not_inherit_its_created(tmp_path):
"""A `created` that cannot be read back is replaced rather than guessed at.
The alternative is a stamp that is silently wrong, which is worse than one
that is silently new."""
b = tmp_path / "b"
b.mkdir()
(b / MANIFEST_FILE).write_text("{not json")
m = write_manifest(b, "booth-dev", why="rescued")
assert m.created and m.error is None
assert read_manifest(b).why == "rescued"
# ---- slice 4: the write is atomic, and invisible to every listing -----------
def test_the_write_is_atomic(tmp_path):
"""INV-5 of CLAUDE.md. The CLI writes this in one process while the browser
reads it in another, so a reader must never see a half-written document."""
b = tmp_path / "b"
b.mkdir()
write_manifest(b, "booth-dev", why="x")
assert not list(b.glob("*.tmp")), "a temp file survived the write"
def test_a_manifest_is_not_an_item(tmp_path):
"""The whole integration story: it is a DOTFILE, so the existing
`startswith('.')` skip in `booth_items` already keeps it out of tiles,
counts and zips. No new exclusion rule anywhere. Asserted rather than
assumed, because the claim is load-bearing for the contract's scope."""
from booth.app import zip_booth
from booth.items import booth_items
b = tmp_path / "b"
b.mkdir()
(b / "a.txt").write_text("real content")
write_manifest(b, "booth-dev", why="x")
assert [i.rel for i in booth_items(b)] == ["a.txt"]
assert MANIFEST_FILE not in zip_booth(b).decode("latin-1")
def test_announcing_is_activity(tmp_path):
"""A manifest is a dotfile but not a `.lock` dotfile, so `_newest_mtime`
counts it. Creating or re-announcing a booth resets its TTL, which is right:
both are somebody touching it. The lock exemption added in v0.2.2 is for
machinery a READ path creates; this is a deliberate write."""
from booth.app import booth_age_seconds
b = tmp_path / "b"
b.mkdir()
old = 1_000_000_000
os.utime(b, (old, old))
write_manifest(b, "booth-dev", why="look at this")
assert booth_age_seconds(b, now=old + 90_000) < 86_400
def test_stdlib_only():
"""INV-4, and the reason this module exists separately from anything that
imports a third-party package. `scripts/booth` imports it under the system
python3 with NO venv, through a `python3 -c` heredoc no AST extractor can
see. It must also not import `booth.*`: a cross-import between two
stdlib-only modules is a second way for the invariant to break."""
src = pathlib.Path(__file__).parent.parent / "booth" / "manifest.py"
roots = set()
for node in ast.walk(ast.parse(src.read_text())):
if isinstance(node, ast.Import):
roots.update(a.name.split(".")[0] for a in node.names)
elif isinstance(node, ast.ImportFrom) and node.level == 0 and node.module:
roots.add(node.module.split(".")[0])
assert not (roots - set(sys.stdlib_module_names)), (
f"booth/manifest.py imports outside the stdlib: "
f"{sorted(roots - set(sys.stdlib_module_names))}"
)
# ---- slice 5: what the operator actually sees -------------------------------
@pytest.fixture
def client(tmp_path):
from fastapi.testclient import TestClient
from booth.app import create_app
return TestClient(create_app(tmp_path, ttl_hours=24, start_sweeper=False)), tmp_path
def _booth(data, name, *, kept=False):
b = data / name
b.mkdir()
(b / "a.txt").write_text("content")
if kept:
(b / ".forever").touch()
return b
@pytest.mark.parametrize("kept", [False, True], ids=["ephemeral", "kept"])
def test_the_index_card_carries_the_announcement(client, kept):
"""BOTH LANES. Kept boards render first and are a separate block in
index.html, so patching only the ephemeral lane would leave the 15 kept
booths — the durable, most-looked-at ones — with exactly the defect this
unit closes. Same lesson as the `blurtoggle` macro: three branches, one
definition; here it is two lanes and one rule."""
c, data = client
b = _booth(data, "r18-ab", kept=kept)
write_manifest(b, "booth-dev", why="pick the winning denoiser")
html = c.get("/").text
assert "booth-dev" in html
assert "pick the winning denoiser" in html
@pytest.mark.parametrize("kept", [False, True], ids=["ephemeral", "kept"])
def test_a_booth_that_never_spoke_up_is_marked(client, kept):
"""All 26 live booths are in this state, and rsync keeps making more. The
marker is what makes the convention adoptable at all: the link board rotted
to 69% precisely because nothing ever showed which rows were dead.
ASSERTED ON THE CLASS, not on the word, and the test is named around it.
`pytest`'s `tmp_path` is derived from the TEST NAME and the index renders
`data_dir` in its empty-state hint — so a test called
`test_an_unannounced_booth_says_so` put the literal string "unannounced"
into the page and passed against a template that did not yet exist. A
structural hook cannot be spelled by accident — though it has to be the
rendered ELEMENT and not the bare class, since base.html ships a
`.prov-none{...}` rule into the very same page."""
c, data = client
_booth(data, "quiet", kept=kept)
html = c.get("/").text
assert 'class="prov prov-none"' in html
assert "unannounced" in html
def test_a_damaged_manifest_reads_differently_from_an_absent_one(client):
"""INV-5 on the surface the operator looks at, not just in the reader."""
c, data = client
b = _booth(data, "damaged")
(b / MANIFEST_FILE).write_text("{oops")
html = c.get("/").text
assert 'class="prov prov-broken"' in html
assert "unreadable" in html
assert c.get("/b/damaged/").status_code == 200
def test_an_announced_booth_with_no_why_shows_only_its_handle(client):
"""`booth new x` with no --why is legal and common. The card shows who made
it and does not invent a purpose or leave a dangling separator."""
c, data = client
b = _booth(data, "scratch")
write_manifest(b, "booth-dev")
html = c.get("/").text
assert "booth-dev" in html
assert 'class="prov prov-none"' not in html
def test_the_booth_page_header_carries_it_too(client):
"""Deliberate scope, not creep: a booth URL handed to the operator lands
HERE, never on the index. Job 5 is 'operator, look at this', so the page he
actually opens is where the answer has to be."""
c, data = client
b = _booth(data, "r18-ab")
write_manifest(b, "booth-dev", why="pick the winning denoiser")
html = c.get("/b/r18-ab/").text
assert "booth-dev" in html
assert "pick the winning denoiser" in html
def test_a_poisoned_manifest_cannot_take_down_the_index(client):
"""The v0.2.2 lesson, asserted for the new reader before it can repeat:
`list_booths` touches every booth on every page load, so one bad file must
cost that booth's provenance and nothing else."""
c, data = client
_booth(data, "good")
bad = _booth(data, "bad")
(bad / MANIFEST_FILE).write_bytes(b"\xff\xfe not utf-8 at all")
assert c.get("/").status_code == 200
assert c.get("/healthz").status_code == 200
def test_a_pickup_booth_announces_itself_as_the_booths_own(client):
"""No exemption list. A booth the service made says the service made it,
which is true — and it keeps the rule to one line: a booth with no manifest
is unannounced."""
c, data = client
r = c.post("/upload", files=[("files", ("a.txt", b"hello", "text/plain"))],
follow_redirects=False)
assert r.status_code in (200, 303)
booth = next(p for p in data.iterdir() if p.is_dir())
got = read_manifest(booth)
assert got is not None and got.handle == "booth"
assert 'class="prov prov-none"' not in c.get("/").text
+4 -1
View File
@@ -276,12 +276,15 @@ def test_as_dict_round_trips_through_json(tmp_path):
# ---- the stdlib-only invariant (INV-5) -------------------------------------- # ---- the stdlib-only invariant (INV-5) --------------------------------------
@pytest.mark.parametrize("module", ["marks", "asks", "links"]) @pytest.mark.parametrize("module", ["marks", "asks", "links", "manifest"])
def test_stdlib_only(module): def test_stdlib_only(module):
"""INV-5. scripts/booth imports these under the system python3 with NO venv, """INV-5. scripts/booth imports these under the system python3 with NO venv,
through a `python3 -c` heredoc that no AST extractor can see — so nothing through a `python3 -c` heredoc that no AST extractor can see — so nothing
but this test stands between a casual third-party import and `booth ask` but this test stands between a casual third-party import and `booth ask`
breaking on every fleet host.""" breaking on every fleet host."""
# `manifest` also carries a stricter copy in tests/test_manifest.py, which
# additionally forbids importing `booth.*` — a cross-import between two
# stdlib-only modules is a second way for this invariant to break.
src = pathlib.Path(__file__).parent.parent / "booth" / f"{module}.py" src = pathlib.Path(__file__).parent.parent / "booth" / f"{module}.py"
tree = ast.parse(src.read_text()) tree = ast.parse(src.read_text())
roots = set() roots = set()