diff --git a/design/ingestion-pipeline.md b/design/ingestion-pipeline.md
index 09b05d2..8bf42e4 100644
--- a/design/ingestion-pipeline.md
+++ b/design/ingestion-pipeline.md
@@ -95,7 +95,11 @@ reruns, newly-drained waiting cohorts — and **the same session immediately
completes the cognitive steps** (harvest → digest → wiki) for all of them,
exactly as for a fresh capture. The enrichment-newer-than-digest comparison
in `enrich status` is purely an **interrupted-session backstop** (a session
-died between fetch and digest), never the handoff mechanism.
+died between fetch and digest), never the handoff mechanism. It lists only
+items that **owe no further work**: an item holding a `manual`, `error`,
+`blocked` or `waiting` unit derives `raw`, and a raw item is one the ingest
+procedure forbids digesting — listing it would name the same permanently
+parked item as work to do on every run, forever.
**Session-end invariant**: everything processable is processed — items are
digested whole, after their children land. The only entries that survive a
@@ -129,16 +133,24 @@ digits, so two units under one item do collide (a real `web-6968e3.md` /
`file-6968e3.md` pair was found by hand), and a name-pattern unlink deleted
the neighbour's enrichment while its ledger line still read `done`. Nothing
is dropped for a replacement that is not itself on disk. Works in both
-directions (web→file,
-file→web). Loop guard is per-run state: one correction per hash per run,
-then `manual` "re-detection loop"; across runs a unit may redetect again —
-the world changes.
+directions (web→file, file→web), and carries two further discoveries that
+only a fetched body can make: a page whose audio is its subject, fetched
+by the catch-all → `podcast` (§9), and a GitHub blob whose bytes are a
+document → `file` (§3). Loop guard is per-run state: one correction per
+hash per run, then `manual` "re-detection loop"; across runs a unit may
+redetect again — the world changes.
The run report also derives a listing for **no-source items** (text-only
and image-only captures — no URLs, no work units): they surface as
cognitive work ("awaiting description + digest") so a capture with nothing
to fetch is never invisible to the session.
+**An incomplete item says so on the report.** Every item the run touched
+that still owes work gets a line stating the shape of it — "3 of 4 units
+landed — 1 waiting on transcription" — so completeness is read, never
+inferred from a list of units. Items the run did not touch are the standing
+view's job (`enrich status`), not the run report's.
+
## 2. Interfaces
`typing.Protocol` throughout (structural interfaces; engine floor is 3.11).
@@ -265,11 +277,11 @@ vocabulary only and never become work units:
|---|---|---|
| `youtube` | ✓ | captions; fallback → `needs: transcribe`. Identity is the video id (every watch/short-link/live/shorts/embed shape → `watch?v=`); a playlist page keys on its list id and parks `manual` — picking its videos is judgment. Channel addresses (`/@handle` and its tabs, percent-encoded `/%40handle` included, `/user/…`, `/c/…`, `/channel/…`, and the legacy bare vanity name `/veritasium`), hashtag feeds and search-result pages park `manual` the same way and **before the probe**: yt-dlp answers those URLs by enumerating every video the channel holds, and the transcribe drain would then pull every one of those audio files over a single filename. The vanity form has no marker at all — youtube.com's root namespace belongs to channels — so the driver names youtube.com's own **functional first segments** (`/watch`, `/playlist`, `/results`, `/feed`, `/embed`, `/live`, `/shorts`, `/v`, plus the product and account surfaces) and reads every other bare root segment as a channel. The list errs one way on purpose: a functional segment missing from it parks `manual`, one recoverable ledger line, while a channel mistaken for a functional path reaches the probe and enumerates thousands of videos. A channel's `/live` path is one video and still fetches |
| `x` | ✓ | renamed from `tweet`; thread walk-up (§8) |
-| `github` | ✓ | repos / profiles / gists / issues / blobs. Every route goes through the authenticated `gh` CLI, blobs included — `raw.githubusercontent.com` is unauthenticated, so it 404s every private-repo blob however the machine is signed in, and that 404 classified live content `dead`. Losing raw.githubusercontent.com costs the **ref/path boundary**, which that host resolved server-side and the contents API cannot: branch names hold slashes (`blob/automation/bors/auto/README.md`), and the `blob/refs/heads//` permalink form spends two segments before the name even begins, so splitting at the first segment sent a wrong `?ref=` with a wrong path and 404'd live files into `dead`. The driver guesses the shortest ref the URL's shape allows — free, and right for nearly every link — and only when that 404s asks `git/matching-refs//` which of the repo's own refs the path starts with, matching segment-wise (`automation/bors` must not claim a URL whose branch is `automation/bors-next`) and taking the longest. A repo with thousands of branches costs the same one page as a repo with three. When no ref re-splits the URL, or the lookup itself fails, the original classification stands: a genuinely missing path is still `dead`, never unclassifiable. A blob too large for the contents API to serve inline parks `manual`, never `dead`. github.com's **reserved first segments** (`/features`, `/topics`, `/sponsors`, `/orgs`, `/collections`, `/marketplace`, `/trending`, `/about`, `/pricing`, `/settings`, `/explore`, …) can be neither user nor repo, so the driver declines them and registry order hands them to `web`, which extracts them like any page — driving them as repo work 404'd pages that render fine in a browser into `dead`. Only the first segment is screened: `acme/topics` is an ordinary repo. **Blob bytes are sniffed — by filename — before they are fenced**: a PDF or any other binary committed to a repo parks `manual` naming what it is, never a code fence full of replacement characters ledgered `done`. The name is what catches the two extractable shapes that carry no byte signature: a CSV, and an unsmudged **Git-LFS pointer**, whose 130 bytes of `oid sha256:…` decode as clean UTF-8 and fenced `done` as though they were the document they stand for (the contents API serves the pointer, never the object). A committed CSV parks the same way, deliberately — allowing text-shaped documents to fence is exactly what let the LFS pointer for a `.csv` through, and a captured CSV reaches `csv-builtin` as a real table instead of a fence truncated at 40k characters. It does not re-detect to `file` work, because a GitHub blob URL serves an HTML viewer rather than the bytes — the file driver would fetch that page, find HTML, and re-detect straight back (a loop park on a public repo, `dead` on a private one). The rescue route is capturing the file itself, which is already `file` work |
+| `github` | ✓ | repos / profiles / gists / issues / blobs. Every route goes through the authenticated `gh` CLI, blobs included — `raw.githubusercontent.com` is unauthenticated, so it 404s every private-repo blob however the machine is signed in, and that 404 classified live content `dead`. That route is a **shared seam** (`drivers/gh.py`), not this driver's property: the file driver reads blob bytes through the same module, so neither driver imports the other and the ref boundary below is resolved one way for both. Losing raw.githubusercontent.com costs the **ref/path boundary**, which that host resolved server-side and the contents API cannot: branch names hold slashes (`blob/automation/bors/auto/README.md`), and the `blob/refs/heads//` permalink form spends two segments before the name even begins, so splitting at the first segment sent a wrong `?ref=` with a wrong path and 404'd live files into `dead`. The seam guesses the shortest ref the URL's shape allows — free, and right for nearly every link — and only when that 404s asks `git/matching-refs//` which of the repo's own refs the path starts with, matching segment-wise (`automation/bors` must not claim a URL whose branch is `automation/bors-next`) and taking the longest. A repo with thousands of branches costs the same one page as a repo with three. When no ref re-splits the URL, or the lookup itself fails, the original classification stands: a genuinely missing path is still `dead`, never unclassifiable. A blob too large for the contents API to serve inline parks `manual`, never `dead`. github.com's **reserved first segments** (`/features`, `/topics`, `/sponsors`, `/orgs`, `/collections`, `/marketplace`, `/trending`, `/about`, `/pricing`, `/settings`, `/explore`, …) can be neither user nor repo, so the driver declines them and registry order hands them to `web`, which extracts them like any page — driving them as repo work 404'd pages that render fine in a browser into `dead`. Only the first segment is screened: `acme/topics` is an ordinary repo. **Blob bytes are sniffed — by filename — before they are fenced**: a document committed to a repo re-detects to `file` work rather than being fenced as source (a code fence full of replacement characters, ledgered `done`, was the incident), and any other binary parks `manual` naming what it is. The re-detection is only safe because the file driver shares the seam: fetching the blob URL over plain HTTP would find the HTML viewer page and re-detect straight back (a loop park on a public repo, `dead` on a private one), while HTML arriving from the contents API is a committed HTML file and never bounces. The **name** is what catches the two extractable shapes that carry no byte signature: a CSV, and an unsmudged **Git-LFS pointer**, whose 130 bytes of `oid sha256:…` decode as clean UTF-8 and fenced `done` as though they were the document they stand for (the contents API serves the pointer, never the object). Both re-detect to `file` like every other document — a committed CSV reaches an extractor as a real table instead of a fence truncated at 40k characters, and a pointer meets the file driver's LFS guard, which parks it `manual` before any extractor is handed 130 bytes of stand-in text |
| `paper` | ✓ | arxiv / openreview / hf-papers |
| `podcast` | ✓ | new — Apple/Spotify/RSS episode links (§9) |
| `web` | ✓ | renamed from `blog`; registry catch-all, always last |
-| `file` | ✓ | URL-served or captured binaries; routes by Format |
+| `file` | ✓ | URL-served, repo-committed (github blobs, through the shared `drivers/gh.py` seam) or captured binaries; routes by Format |
| `image` | — | corpus-only: described cognitively at ingest |
| `text` | — | corpus-only: note-only capture, nothing to fetch |
@@ -282,10 +294,16 @@ vocabulary only and never become work units:
**Need** — `transcribe, extract, ocr`. Needs are mechanical and
resource-keyed only. Cognitive obligations on *items* (re-judge under new
harvest rules, refresh a stale digest) are never queued — they are **derived
-state**, computed on demand from files already on disk (`passes.jsonl` rules
-version vs the current constant; digest date vs the ledger's last `done` for
-the item). Mechanical obligations are queued because they're work to be
-done; staleness is derived because it's a fact that shows.
+state**, computed on demand from state already committed (`passes.jsonl`
+rules version vs the current constant; the item's digest pass date in
+`passes.jsonl` vs the ledger's last `done` for the item). Mechanical
+obligations are queued because they're work to be done; staleness is
+derived because it's a fact that shows. **Both comparands are dates in
+committed state, never file mtimes**: git stamps every file at checkout, so
+on a second machine the whole tree shares one mtime and staleness becomes
+undetectable. Day granularity is the intent — enriching and digesting in
+one session is not stale. The digest file's own `date:` is the item's
+*share* date, so it can never serve here.
**`via`** (provenance) stays a documented string, not an enum —
`harvest, thread, media, sniff, extract-asset, migration-` — because
@@ -599,6 +617,22 @@ waiting-transcribe park
that carries an enclosure pointer **always writes its park file** — §9's
round-trip depends on the frontmatter pointer existing, show notes or not.
+**A park never discards content it already fetched.** Both transcribable
+kinds write what they have at park time — a podcast's show notes, a
+video's description — and the drain **appends** the transcript to that
+file rather than replacing it: a source that goes private during a
+transcription backlog would otherwise take the fetched content with it.
+One body shape per kind, whichever route produced it — the transcript is
+always its own `## Transcript` section, so the drain can split a stored
+body on that heading and compose onto what is already there. **The
+frontmatter, not the heading, says whether a body holds a transcript at
+all**: the transcriber stamps `via`, neither park does, so a park's body is
+notes end to end — a description or show notes that name `## Transcript`
+themselves were otherwise truncated at the author's own line and the
+truncation written back to disk. Once a body does hold a transcript, the
+split takes the LAST such section, because that is the one the drain
+appended.
+
**Capability report** (a render surface): each capability, active provider,
dormant upgrades and what they'd need —
`transcribe: whisper-local (active) · whisper-api available — set OPENAI_API_KEY`.
@@ -665,8 +699,23 @@ a download beyond the cap and beside a description of something else.
post — the terminal-mislabel class this design exists to kill.
- **Thread walk-up inside the driver**, not via children: the chain is
context for the captured post, not new first-class sources. One enrichment
- file, one ledger entry. fxtwitter parent pointers, **cap 20 hops**, all
- authors included, cap-hit noted in the ledger only.
+ file, one ledger entry. fxtwitter parent pointers, all authors
+ included, cap-hit noted in enrichment frontmatter (below). The walk is
+ **bounded at 100 hops** — a sanity bound against a chain that never ends,
+ not an editorial one: the chain is one piece of content and the
+ walk is linear with no fan-out, unlike the 12-URL harvest cap, which
+ bounds how much one item drags in. A thread that exceeds even 100 is
+ still recorded honestly rather than silently truncated.
+- **Cycles end the walk where they repeat, and hops are paced.** A post
+ naming itself (or an ancestor) as its parent is not a long thread: the
+ ids already walked are remembered, so the repeat stops the walk at once
+ and is recorded like any short chain (`chain_incomplete` + a note saying
+ where it looped). Without that, one self-referencing parent spent the
+ whole 100-hop bound as 100 back-to-back requests to a free community API
+ for a single unit. The walk also **sleeps 1s between parent fetches**:
+ the driver's 4s politeness is spent between units, and a 30-post thread
+ is 30 requests inside one — a second a hop keeps an ordinary thread
+ under a minute while making the walk a paced sequence, not a burst.
- Fetch order is bottom-to-top (parent pointers); **storage is reading
order** — root first, captured post last, each post attributed
(`@who — date`); frontmatter records which post was captured. A
@@ -718,7 +767,35 @@ underneath; the audio lives in the feed's ``:
mapping for this one case).
Spotify exclusives fail honestly → `manual` (Claude may rescue via the
show's own site).
-- **Direct RSS / indie episode page** → enclosure or ` ` in head.
+- **Direct RSS / indie episode page** → the feed the page's ` `
+ names (its notes are richer than the page's markup), falling back to the
+ enclosure the page carries itself when that feed is unreachable or does
+ not hold the episode. Either way the unit resolves — it must, because the
+ route it arrives by is a re-detection and bouncing back would park as a
+ loop.
+
+ **The route is content-driven, never URL-guessing.** `matches()` stays
+ narrow (Apple, Spotify, explicit `.rss`) because bare `/feed` and `/rss`
+ suffixes are blog vocabulary and an RSS ` ` in a head says only
+ "this site has a feed". Registry order does the work instead: nothing
+ claims an indie episode page, so the catch-all fetches it, and a page
+ whose **audio is its subject** re-detects to `podcast` (§1's mid-fetch
+ discovery, `web → podcast`). Two markups say that, and only two: an
+ `og:audio` pointer — the publisher naming the audio as the page's own
+ object — or an `` element **on a page with no substantial body**,
+ where the player is all there is. An `` element beside a real
+ article is not the signal: mainstream publishers ship read-aloud
+ text-to-speech widgets and encyclopedias embed media samples, and the
+ catch-all rule handed those articles to `podcast`, which resolved the
+ widget as the enclosure and parked `waiting: transcribe` with an **empty
+ body** — the article never extracted, its links never harvested (~1 in
+ 145 real web URLs). The asymmetry decides every tie: a false positive
+ costs the whole article, a false negative costs a podcast page keeping
+ its show notes instead of a transcript. A bare link to an mp3 is not the
+ signal either: a post linking one is still a post. The signal itself is
+ `drivers/audio.py`, a shared seam beside `transport.py` and `gh.py` —
+ both drivers read it (web to route, podcast to resolve) and neither
+ imports the other.
Then: audio → `cache/audio/` → `needs: transcribe` → whisper drains
(primed with title + show notes). Show notes from the **feed** (richer than
@@ -1167,10 +1244,16 @@ Skill changes shipping with this:
interpretive context (what a linked video is, how a thread relates)
belongs in the digest, not the item body; thread context itself lives in
the enrichment via walk-up. After creation, exactly two frontmatter
- fields ever change (`status`, `enrichment:` listing), both derived from
- disk, both written by corpus.py. The derivation rule: `status:
- enriched` iff `enrichment//` holds markdown files, and the listing
- is those filenames, sorted. Every run reconciles **every** item against
+ fields ever change (`status`, `enrichment:` listing), both derived, both
+ written by corpus.py. The derivation rule: the listing is the markdown
+ filenames in `enrichment//`, sorted; `status: enriched` iff the item
+ holds enrichment **and no unit it owns is still outstanding** — queued,
+ waiting, blocked, error, or manual. An item is one unit of knowledge (the
+ post, the thread parents above it, the links harvest promoted, the media,
+ the video awaiting its transcript) and nothing about it advances to
+ digest or wiki until every part has landed, so one pending transcript
+ holds the whole item at `raw`; a `dead` or `skipped` unit owes nothing and
+ holds nothing hostage. Every run reconciles **every** item against
disk — not just the units it drained — writing only on change, so
enrichment written outside the drain (media descriptions, cognitive
heals) converges at the next run; `enrich mark` and `enrich pass`
diff --git a/instance/skills/dex-run/SKILL.md b/instance/skills/dex-run/SKILL.md
index 1975261..d174375 100644
--- a/instance/skills/dex-run/SKILL.md
+++ b/instance/skills/dex-run/SKILL.md
@@ -108,7 +108,9 @@ Then do a run now, whatever the host.
8. **Backstop.** `bin/dex enrich status` — any item listed under
"enrichment newer than digest" is an interrupted previous session:
- complete its digest → place → wiki steps now.
+ complete its digest → place → wiki steps now. Every item listed is
+ digestible; one still owing a unit is `raw` and never appears there,
+ however long it stays parked.
9. **Health check.** If `wiki/log.md` shows no health check (a `| lint`
entry) in the past 7 days, run the dex-lint skill
diff --git a/instance/skills/dex-run/references/ingest-item.md b/instance/skills/dex-run/references/ingest-item.md
index 905965f..916ef4a 100644
--- a/instance/skills/dex-run/references/ingest-item.md
+++ b/instance/skills/dex-run/references/ingest-item.md
@@ -36,8 +36,10 @@ page or video title, once known). The body is the owner's note **verbatim
and stays that way** — your interpretive context (what a linked video is,
how a thread relates) belongs in the digest, never the item body; thread
context lives in the enrichment via walk-up. After creation, exactly two
-frontmatter fields ever change (`status`, `enrichment:`), both derived
-from disk, both written by the engine.
+frontmatter fields ever change (`status`, `enrichment:`), both derived by
+the engine: the listing from the enrichment directory, the status from the
+ledger — `enriched` only once every unit the item owns has landed (or is
+confirmed gone or deliberately skipped), `raw` while any is still owed.
Then delete the capture file — the capture is preserved in git history and
its content lives on in the corpus.
@@ -120,7 +122,10 @@ otherwise 2–5 kebab-case candidates. Then
A parked item (waiting/blocked/manual) still exists — provenance and note
were captured at ingest — but gets no digest or wiki work until its
-sources land. Don't force it; the report tracks it.
+sources land. Don't force it; the run report's **incomplete** section names
+each such item and what it is still owed ("3 of 4 units landed — 1 waiting
+on transcription"), and the item stays `status: raw` until the last unit
+lands.
## 7. Place (judgment)
diff --git a/instance/skills/dex-run/references/schema.md b/instance/skills/dex-run/references/schema.md
index b808763..2fcb2a3 100644
--- a/instance/skills/dex-run/references/schema.md
+++ b/instance/skills/dex-run/references/schema.md
@@ -45,8 +45,10 @@ media: # media captures: repo-relative path
files the phone shortcut does), exporter name (e.g. `discord`) for
backfills. Every source goes through the same pipeline and carries the
same weight; nothing about a source implies age or importance.
-- `status` / `enrichment` — engine-owned, derived from disk: the pipeline
- flips `status` to `enriched` and lists the files under `enrichment//`.
+- `status` / `enrichment` — engine-owned and derived, never hand-edited: the
+ listing from the files under `enrichment//`, the status from the
+ ledger — `enriched` only once every unit the item owns has landed (or is
+ confirmed gone or deliberately skipped), `raw` while any is still owed.
After creation these are the ONLY two frontmatter fields that ever change.
- `urls` is immutable capture provenance. Harvest-promoted URLs are ledger
entries (`via: harvest`), never frontmatter edits.
diff --git a/src/dex_engine/drivers/audio.py b/src/dex_engine/drivers/audio.py
new file mode 100644
index 0000000..86701ce
--- /dev/null
+++ b/src/dex_engine/drivers/audio.py
@@ -0,0 +1,86 @@
+"""The drivers' audio-on-a-page seam: what audio a page carries as its own.
+
+Two drivers read this one signal from opposite ends, and neither owns it:
+the web driver asks it of every page it fetches, to decide whether the page
+in hand is an episode page at all, and the podcast driver asks it again of
+a page already routed, to resolve the enclosure the transcribe drain will
+reach for.
+
+It sits beside :mod:`dex_engine.drivers.transport` and
+:mod:`dex_engine.drivers.gh`, for those modules' reason: a seam several
+drivers share is not itself a driver, and a driver must never import
+another driver. Like them it reaches only for the stdlib — nothing here
+knows about work units or results.
+"""
+
+import html as html_lib
+import re
+from dataclasses import dataclass
+from urllib.parse import urljoin
+
+__all__ = ["AudioEnclosure", "audio_enclosure"]
+
+# The two ways a page carries audio: the og:audio pointer and an
+# element (its own src, or a nested ).
+_OG_AUDIO_RES = (
+ re.compile(
+ r" ]+(?:property|name)=[\"']og:audio[\"'][^>]+content=[\"']([^\"'\r\n]+)",
+ re.IGNORECASE,
+ ),
+ re.compile(
+ r" ]+content=[\"']([^\"'\r\n]+)[\"'][^>]+(?:property|name)=[\"']og:audio[\"']",
+ re.IGNORECASE,
+ ),
+)
+_AUDIO_ELEMENT_RE = re.compile(
+ r"]*>.*? |]*/?>", re.IGNORECASE | re.DOTALL
+)
+_SRC_RE = re.compile(r"\bsrc=[\"']([^\"'\r\n]+)[\"']", re.IGNORECASE)
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class AudioEnclosure:
+ """Audio a page carries, and how strongly the page claims it.
+
+ ``declared`` separates the two markups, because they say different
+ things. ``og:audio`` is the publisher naming this audio as the page's
+ own object — the machine-readable claim "this page IS the audio". An
+ ```` element is only a player: an episode page uses one, and so
+ does an article's read-aloud widget or an encyclopedia's media sample.
+ """
+
+ url: str
+ declared: bool
+
+
+def audio_enclosure(page: str, base_url: str) -> AudioEnclosure | None:
+ """The audio this page carries as its own, or None.
+
+ A bare link to an audio file is deliberately not audio the page
+ carries: a post linking one mp3 is still a post.
+
+ Args:
+ page: The fetched HTML.
+ base_url: The page's URL — relative sources resolve against it.
+
+ Returns:
+ The enclosure (absolute http(s) URL, and whether ``og:audio``
+ declared it), or None.
+ """
+ for pattern in _OG_AUDIO_RES:
+ match = pattern.search(page)
+ if match:
+ resolved = _absolute(match.group(1), base_url)
+ if resolved is not None:
+ return AudioEnclosure(url=resolved, declared=True)
+ for element in _AUDIO_ELEMENT_RE.finditer(page):
+ for source in _SRC_RE.finditer(element.group(0)):
+ resolved = _absolute(source.group(1), base_url)
+ if resolved is not None:
+ return AudioEnclosure(url=resolved, declared=False)
+ return None
+
+
+def _absolute(value: str, base_url: str) -> str | None:
+ candidate = urljoin(base_url, html_lib.unescape(value).strip())
+ return candidate if candidate.startswith(("http://", "https://")) else None
diff --git a/src/dex_engine/drivers/file.py b/src/dex_engine/drivers/file.py
index c7efe54..7de2524 100644
--- a/src/dex_engine/drivers/file.py
+++ b/src/dex_engine/drivers/file.py
@@ -1,11 +1,16 @@
"""The file driver: local repo files and URL-served binaries, routed by Format.
-Two work shapes, one driver: ``file:`` keys (materialized media
-captures) read from the instance tree; http(s) URLs (a PDF served from an
-arbitrary address, rerouted here by detection's HEAD sniff) fetch through
-the transport with classified failures. Bytes are then byte-signature
-sniffed — authoritative over whatever a server claimed — and handed to
-the first available mechanical extractor for the format.
+Three work shapes, one driver: ``file:`` keys (materialized
+media captures) read from the instance tree; http(s) URLs (a PDF served
+from an arbitrary address, rerouted here by detection's HEAD sniff) fetch
+through the transport with classified failures; and github blob URLs, whose
+bytes come from the shared authenticated seam in
+:mod:`dex_engine.drivers.gh` because the URL itself serves an HTML viewer
+page (and, on a private repo, 404s to an unauthenticated fetch) — the same
+seam the github driver reads them through, so a blob's ref resolves the one
+way for both. Bytes are then byte-signature sniffed —
+authoritative over whatever a server claimed — and handed to the first
+available mechanical extractor for the format.
No provider for the format → ``waiting`` + ``needs: extract`` with the
registry's stated reason — and so does a provider that reported available
@@ -27,6 +32,7 @@
from dex_engine.capabilities import Capabilities
from dex_engine.pipeline.classify import (
+ Classification,
ProviderUnavailableError,
ScannedDocumentError,
classify_connection,
@@ -44,6 +50,7 @@
)
from dex_engine.pipeline.urls import base_canonical, resolve_repo_path
+from .gh import BlobRef, Gh, blob_ref, fetch_blob, run_gh
from .transport import Transport, urllib_transport
__all__ = ["FileDriver"]
@@ -63,8 +70,9 @@ def __init__(
capabilities: Capabilities,
root: Path | None = None,
transport: Transport = urllib_transport,
+ gh: Gh = run_gh,
) -> None:
- """Wire the extract registry, the instance root, and the HTTP seam.
+ """Wire the extract registry, the instance root, and the two fetch seams.
Args:
capabilities: The resolved capability registries.
@@ -72,10 +80,13 @@ def __init__(
only for registries that never fetch local files (pattern
matching, normalize).
transport: The HTTP seam for URL-served binaries.
+ gh: The gh-CLI seam — a github blob URL's bytes come from the
+ authenticated contents API, never from the viewer page.
"""
self._capabilities = capabilities
self._root = root
self._transport = transport
+ self._gh = gh
def matches(self, url: str) -> bool:
"""True for local-file work keys; URLs reach this driver by sniff."""
@@ -107,11 +118,7 @@ def fetch(self, unit: WorkUnit) -> Result:
# header over signature-less bytes (a real CSV) must proceed to
# extraction under its claimed format. Only for http(s) work: a
# local captured file is not a page to fetch.
- if (
- not unit.url.startswith("file:")
- and sniff_format(data) is None
- and looks_like_html(data)
- ):
+ if _rerouteable(unit.url) and sniff_format(data) is None and looks_like_html(data):
return Result(status=Status.QUEUED, meta={}, redetect=Redetection(kind=Kind.WEB))
fmt = sniff_format(data, name=name) or unit.format
if fmt is None:
@@ -128,8 +135,23 @@ def fetch(self, unit: WorkUnit) -> Result:
def _load(self, unit: WorkUnit) -> tuple[bytes, str | None] | Result:
if unit.url.startswith("file:"):
return self._read_local(unit.url.removeprefix("file:"))
+ ref = blob_ref(unit.url)
+ if ref is not None:
+ return self._read_blob(ref)
return self._download(unit.url)
+ def _read_blob(self, ref: BlobRef) -> tuple[bytes, str | None] | Result:
+ """A repo-committed document's bytes, through the gh seam.
+
+ The plain transport cannot serve these: a blob URL answers with the
+ HTML viewer page, and on a private repo the unauthenticated fetch
+ 404s — which classified live content ``dead``.
+ """
+ blob = fetch_blob(self._gh, ref)
+ if isinstance(blob, Classification):
+ return Result(status=blob.status, meta={}, reason=blob.reason)
+ return blob.data, blob.path.rsplit("/", 1)[-1] or None
+
def _read_local(self, repo_path: str) -> tuple[bytes, str | None] | Result:
if self._root is None:
# An engine wiring bug, not a content problem: the run layer's
@@ -195,3 +217,15 @@ def _extract(self, data: bytes, fmt: Format, name: str | None) -> Result:
return Result(
status=Status.DONE, meta=meta, body=extraction.markdown, assets=extraction.assets
)
+
+
+def _rerouteable(url: str) -> bool:
+ """Whether HTML bytes at ``url`` mean "this was never file work".
+
+ Only for plain http(s) fetches. A captured local file is not a page to
+ fetch, and a github blob's bytes came from the contents API — HTML
+ there is an HTML file someone committed, not a viewer page, and
+ re-routing it to ``web`` would fetch that viewer and bounce straight
+ back (a loop park).
+ """
+ return not url.startswith("file:") and blob_ref(url) is None
diff --git a/src/dex_engine/drivers/gh.py b/src/dex_engine/drivers/gh.py
new file mode 100644
index 0000000..6a48949
--- /dev/null
+++ b/src/dex_engine/drivers/gh.py
@@ -0,0 +1,311 @@
+"""The drivers' authenticated GitHub seam: ``gh`` calls, and a blob's bytes.
+
+Two drivers need the same authenticated route into a repo, and neither
+owns it: the github driver reads a blob to fence it as source, and the file
+driver re-reads that same blob when its bytes turn out to be a document to
+extract. Everything that route involves lives here — the ``gh`` invocation,
+the JSON parse and its failure classification, and the whole blob round
+trip: where a ``/blob/`` URL's ref stops and its path starts, the
+contents-API endpoint, the base64 decode, and the park for a file too large
+to be served inline.
+
+It sits beside :mod:`dex_engine.drivers.transport`, for that module's
+reason: a seam several drivers share is not itself a driver, and a driver
+must never import another driver. Like transport it reaches only for
+pipeline vocabulary — nothing here knows about work units or results.
+
+``gh`` is a declared instance dependency — an environment without it is not
+a dex environment, so a missing binary propagates as an engine error rather
+than being classified. API failures ARE classified: gh's stderr names the
+HTTP status (``gh: Not Found (HTTP 404)``), which routes through the
+central classifier; anything without a visible code is ``blocked``, never
+silently terminal.
+
+``raw.githubusercontent.com`` is not an option for the bytes: it is
+unauthenticated, so it 404s every private-repo blob however the machine is
+signed in, and that 404 classified live content ``dead``. Losing that host
+costs the ref/path boundary it used to resolve server-side, which is why
+:func:`fetch_blob` has to settle the boundary itself — and why it answers
+with the path the winning split left, since that name is what the bytes
+have to be sniffed under.
+"""
+
+import base64
+import binascii
+import json
+import re
+import subprocess
+import urllib.parse
+from collections.abc import Callable, Sequence
+from dataclasses import dataclass
+
+from dex_engine.pipeline.classify import Classification, classify_http, scrub
+from dex_engine.pipeline.types import Status
+from dex_engine.pipeline.urls import host_of
+
+__all__ = [
+ "Blob",
+ "BlobRef",
+ "Gh",
+ "GhResult",
+ "blob_ref",
+ "fetch_blob",
+ "gh_api",
+ "gh_api_list",
+ "run_gh",
+]
+
+_GH_HTTP_RE = re.compile(r"HTTP (\d{3})")
+_GH_TIMEOUT = 120.0
+
+# A blob URL hides its ref/path boundary: `blob/automation/bors/auto/README.md`
+# is branch `automation/bors/auto` holding `README.md`, and nothing in the
+# string says where the ref stops. raw.githubusercontent.com resolved that
+# boundary server-side; the contents API wants the two halves separately, so
+# the seam has to settle it. The `refs/heads/`, `refs/tags/` permalink form
+# names its own namespace, and one segment is right for nearly every other
+# link — so guess that first (no extra request), and only when the guess 404s
+# ask the repo which of its refs the path actually starts with.
+_REF_NAMESPACES = ("heads", "tags")
+_QUALIFIED_REF_SEGMENTS = 4 # refs///, at minimum
+
+_BLOB_SEGMENTS = 4 # /owner/repo/blob/[/…
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class GhResult:
+ """One ``gh`` invocation's outcome."""
+
+ returncode: int
+ stdout: str
+ stderr: str
+
+
+# The seam every caller injects so tests are hermetic; `run_gh` is the one
+# real implementation.
+Gh = Callable[[Sequence[str]], GhResult]
+
+
+def run_gh(args: Sequence[str]) -> GhResult:
+ """Run ``gh`` with ``args``; a missing binary raises (engine error).
+
+ A hung invocation is the world misbehaving, not an engine bug: the
+ timeout comes back as a failed GhResult so classification makes it
+ ``blocked`` (no HTTP code in the stderr), never ``error``.
+ """
+ try:
+ completed = subprocess.run( # noqa: S603 — gh is a declared dependency; args are code-built, no shell
+ ["gh", *args], # noqa: S607 — resolved from PATH by design (instances install gh, not a path)
+ capture_output=True,
+ text=True,
+ check=False,
+ timeout=_GH_TIMEOUT,
+ )
+ except subprocess.TimeoutExpired:
+ return GhResult(returncode=124, stdout="", stderr=f"gh timed out after {_GH_TIMEOUT:g}s")
+ return GhResult(
+ returncode=completed.returncode, stdout=completed.stdout, stderr=completed.stderr
+ )
+
+
+def gh_api(gh: Gh, endpoint: str) -> dict | Classification:
+ """``gh api ]`` parsed as a JSON object, or the classified failure."""
+ result = gh(["api", endpoint])
+ if result.returncode != 0:
+ return _classify_gh_failure(result.stderr)
+ try:
+ payload = json.loads(result.stdout)
+ except json.JSONDecodeError:
+ return Classification(status=Status.BLOCKED, reason="gh api returned unparseable JSON")
+ if not isinstance(payload, dict):
+ return Classification(status=Status.BLOCKED, reason="gh api returned an unexpected shape")
+ return payload
+
+
+def gh_api_list(gh: Gh, endpoint: str) -> list:
+ """``gh api `` parsed as a JSON array; every failure yields [].
+
+ Both callers treat an empty answer as "no extra information": a profile
+ notes a missing repo listing in its body rather than failing the unit,
+ and a blob whose ref lookup came back empty keeps the classification its
+ contents call already earned.
+ """
+ result = gh(["api", endpoint])
+ if result.returncode != 0:
+ return []
+ try:
+ payload = json.loads(result.stdout)
+ except json.JSONDecodeError:
+ return []
+ return payload if isinstance(payload, list) else []
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class BlobRef:
+ """A repo, and the ref/path tail a ``/blob/`` URL has not yet split.
+
+ The tail is kept whole on purpose: which segments are the ref is not
+ knowable from the URL, and :func:`fetch_blob` may have to ask the repo.
+ """
+
+ owner: str
+ repo: str
+ tail: tuple[str, ...]
+
+
+@dataclass(frozen=True, slots=True, kw_only=True)
+class Blob:
+ """One blob's bytes, under the path the resolved ref left behind."""
+
+ path: str
+ data: bytes
+
+
+def blob_ref(url: str) -> BlobRef | None:
+ """The blob a github.com URL addresses, or None for every other shape.
+
+ Both drivers ask: the github driver to fetch one, the file driver both
+ to re-fetch a document blob and to know that HTML from this seam is a
+ committed file rather than a viewer page to re-route.
+ """
+ if host_of(url) != "github.com":
+ return None
+ segments = [segment for segment in urllib.parse.urlsplit(url).path.split("/") if segment]
+ if len(segments) < _BLOB_SEGMENTS or segments[2] != "blob":
+ return None
+ return BlobRef(owner=segments[0], repo=segments[1], tail=tuple(segments[3:]))
+
+
+def fetch_blob(gh: Gh, ref: BlobRef) -> Blob | Classification:
+ """A blob's bytes and its real path, through the authenticated contents API.
+
+ The ref/path split is guessed from the URL's shape first — free, and
+ right for nearly every link. A 404 on that guess is the one failure a
+ different boundary could fix, so it costs one extra request to ask the
+ repo which of its own refs the path really starts with; when nothing
+ re-splits, or the lookup itself fails, the guess's own classification
+ stands and a genuinely missing path is still ``dead``.
+ """
+ split = _guessed_split(ref.tail)
+ payload = gh_api(gh, _contents_endpoint(ref, split))
+ if isinstance(payload, Classification) and payload.status is Status.DEAD:
+ resplit = _resolve_split(gh, ref)
+ if resplit is not None and resplit != split:
+ split = resplit
+ payload = gh_api(gh, _contents_endpoint(ref, split))
+ if isinstance(payload, Classification):
+ return payload
+ _, file_path = split
+ data = _blob_bytes(payload)
+ if data is None:
+ # Over 1MB the contents API serves `encoding: "none"` and an empty
+ # body — the file exists, we just cannot read it here.
+ return Classification(
+ status=Status.MANUAL,
+ reason=(
+ f"{file_path} is larger than the contents API serves inline — read it from a clone"
+ ),
+ )
+ return Blob(path=file_path, data=data)
+
+
+def _resolve_split(gh: Gh, ref: BlobRef) -> tuple[str, str] | None:
+ """The ref/path split the repo's own refs support, or None for no match.
+
+ ``git/matching-refs`` is asked only about refs starting with the tail's
+ first segment, so a repo with thousands of branches costs the same one
+ page as a repo with three. A SHA ref matches nothing and answers ``[]``,
+ which leaves the guess (and its classification) alone.
+ """
+ tail = ref.tail
+ namespaces, start = _ref_search(tail)
+ for namespace in namespaces:
+ refs = gh_api_list(
+ gh,
+ f"repos/{ref.owner}/{ref.repo}/git/matching-refs/"
+ f"{namespace}/{_requote(tail[start], safe='')}",
+ )
+ length = _longest_ref_match(_ref_names(refs, namespace), tail, start)
+ if length is not None:
+ boundary = start + length
+ return "/".join(tail[:boundary]), "/".join(tail[boundary:])
+ return None
+
+
+def _contents_endpoint(ref: BlobRef, split: tuple[str, str]) -> str:
+ """The contents-API endpoint for one blob at one ref."""
+ ref_name, file_path = split
+ return (
+ f"repos/{ref.owner}/{ref.repo}/contents/{_requote(file_path, safe='/')}"
+ f"?ref={_requote(ref_name, safe='')}"
+ )
+
+
+def _is_qualified_ref(tail: tuple[str, ...]) -> bool:
+ """True for the ``blob/refs/heads//…`` permalink form."""
+ return len(tail) >= _QUALIFIED_REF_SEGMENTS and tail[0] == "refs" and tail[1] in _REF_NAMESPACES
+
+
+def _guessed_split(tail: tuple[str, ...]) -> tuple[str, str]:
+ """The ref/path split assuming the shortest ref the tail's shape allows."""
+ if _is_qualified_ref(tail):
+ return "/".join(tail[:3]), "/".join(tail[3:])
+ return tail[0], "/".join(tail[1:])
+
+
+def _ref_search(tail: tuple[str, ...]) -> tuple[tuple[str, ...], int]:
+ """Which ref namespaces to search, and where in the tail the ref starts."""
+ if _is_qualified_ref(tail):
+ return (tail[1],), 2
+ return _REF_NAMESPACES, 0
+
+
+def _ref_names(refs: list, namespace: str) -> list[str]:
+ """``refs/heads/x/y`` entries reduced to the bare ref names (``x/y``)."""
+ prefix = f"refs/{namespace}/"
+ names = [ref.get("ref") for ref in refs if isinstance(ref, dict)]
+ return [
+ name.removeprefix(prefix)
+ for name in names
+ if isinstance(name, str) and name.startswith(prefix)
+ ]
+
+
+def _longest_ref_match(names: list[str], tail: tuple[str, ...], start: int) -> int | None:
+ """Segment count of the longest name matching ``tail[start:]``, or None.
+
+ Compared segment by segment, never as a string prefix: ``automation/bors``
+ must not claim a URL whose branch is ``automation/bors-next``. A name that
+ consumes the whole tail is rejected — that would leave no file path.
+ """
+ best: int | None = None
+ for name in names:
+ segments = [segment for segment in name.split("/") if segment]
+ length = len(segments)
+ if start + length >= len(tail):
+ continue
+ if tail[start : start + length] == tuple(segments) and (best is None or length > best):
+ best = length
+ return best
+
+
+def _requote(value: str, *, safe: str) -> str:
+ """Encode a URL piece for the API endpoint without double-encoding it."""
+ return urllib.parse.quote(urllib.parse.unquote(value), safe=safe)
+
+
+def _blob_bytes(payload: dict) -> bytes | None:
+ """The blob's bytes, or None when the API served no inline content."""
+ if payload.get("encoding") != "base64":
+ return None
+ try:
+ return base64.b64decode(payload.get("content") or "", validate=False)
+ except (binascii.Error, ValueError):
+ return None
+
+
+def _classify_gh_failure(stderr: str) -> Classification:
+ match = _GH_HTTP_RE.search(stderr)
+ if match:
+ return classify_http(int(match.group(1)))
+ return Classification(status=Status.BLOCKED, reason=f"gh api failed: {scrub(stderr)}")
diff --git a/src/dex_engine/drivers/github.py b/src/dex_engine/drivers/github.py
index 7670ae5..ca00b6b 100644
--- a/src/dex_engine/drivers/github.py
+++ b/src/dex_engine/drivers/github.py
@@ -1,46 +1,33 @@
"""The github driver: repos / profiles / gists / issues / blobs via the gh CLI.
-``gh`` is a declared instance dependency — an environment without it
-is not a dex environment, so a missing binary propagates as an engine
-error rather than being classified. API failures ARE classified: gh's
-stderr names the HTTP status (``gh: Not Found (HTTP 404)``), which routes
-through the central classifier; anything without a visible code is
-``blocked``, never silently terminal.
-
-A blob URL does not say where its ref ends and its file path begins —
-branch names hold slashes, and the ``refs/heads/`` permalink form spends
-two segments before the name even starts. The driver guesses the shortest
-ref the shape allows and, only when that 404s, asks the repo which of its
-refs the path really starts with.
-
-Blob bytes are sniffed *by name* before they are fenced: a document or any
-other binary committed to a repo parks ``manual`` naming what it is,
-because a GitHub blob URL serves an HTML viewer rather than the bytes, so
-no other driver can re-fetch it — the rescue is to capture the file
-itself. The name matters because two extractable shapes carry no
-signature: a CSV, and a Git-LFS pointer, whose 130 bytes of stand-in text
-would otherwise be fenced as though they were the document.
+Every route goes through :mod:`dex_engine.drivers.gh`, the authenticated
+seam this driver shares with the file driver. That module owns the ``gh``
+invocation, the failure classification, and the whole blob round trip
+including where a blob URL's ref stops and its path starts; this driver
+owns only what a github URL's *shape* means and what to do with the bytes
+that come back.
+
+Blob bytes are sniffed *by name* before they are fenced: a document
+committed to a repo re-detects to ``file`` work — the file driver reads the
+bytes back through the same seam, so extraction runs on the committed file
+itself. Any other binary parks ``manual`` naming what it is, never a code
+fence full of replacement characters. The name matters because two
+extractable shapes carry no byte signature: a CSV, and an unsmudged Git-LFS
+pointer, whose 130 bytes of stand-in text would otherwise be fenced as
+though they were the document they point at.
"""
-import base64
-import binascii
-import json
import re
-import subprocess
import urllib.parse
-from collections.abc import Callable, Sequence
-from dataclasses import dataclass
-from dex_engine.pipeline.classify import (
- Classification,
- classify_http,
- scrub,
-)
+from dex_engine.pipeline.classify import Classification
from dex_engine.pipeline.detect import sniff_format
-from dex_engine.pipeline.types import Kind, Result, Status, WorkUnit
+from dex_engine.pipeline.types import Kind, Redetection, Result, Status, WorkUnit
from dex_engine.pipeline.urls import base_canonical, host_of
-__all__ = ["GhResult", "GitHubDriver", "run_gh"]
+from .gh import BlobRef, Gh, blob_ref, fetch_blob, gh_api, gh_api_list, run_gh
+
+__all__ = ["GitHubDriver"]
_HOSTS = frozenset({"github.com", "gist.github.com"})
@@ -98,20 +85,6 @@
}
)
-_GH_HTTP_RE = re.compile(r"HTTP (\d{3})")
-_GH_TIMEOUT = 120.0
-
-# A blob URL hides its ref/path boundary: `blob/automation/bors/auto/README.md`
-# is branch `automation/bors/auto` holding `README.md`, and nothing in the
-# string says where the ref stops. raw.githubusercontent.com resolved that
-# boundary server-side; the contents API wants the two halves separately, so
-# the driver has to settle it. The `refs/heads/`, `refs/tags/` permalink form
-# names its own namespace, and one segment is right for nearly every other
-# link — so guess that first (no extra request), and only when the guess 404s
-# ask the repo which of its refs the path actually starts with.
-_REF_NAMESPACES = ("heads", "tags")
-_QUALIFIED_REF_SEGMENTS = 4 # refs///, at minimum
-
# Gist id shapes in the wild: 32-char hex today, 20-char hex from the 2013
# era, short sequential decimals before that. A bare gist.github.com/
# link is a legacy share shape that still resolves; a one-segment path that
@@ -126,44 +99,13 @@
_TOPIC_LIMIT = 8
-@dataclass(frozen=True, slots=True, kw_only=True)
-class GhResult:
- """One ``gh`` invocation's outcome."""
-
- returncode: int
- stdout: str
- stderr: str
-
-
-def run_gh(args: Sequence[str]) -> GhResult:
- """Run ``gh`` with ``args``; a missing binary raises (engine error).
-
- A hung invocation is the world misbehaving, not an engine bug: the
- timeout comes back as a failed GhResult so classification makes it
- ``blocked`` (no HTTP code in the stderr), never ``error``.
- """
- try:
- completed = subprocess.run( # noqa: S603 — gh is a declared dependency; args are code-built, no shell
- ["gh", *args], # noqa: S607 — resolved from PATH by design (instances install gh, not a path)
- capture_output=True,
- text=True,
- check=False,
- timeout=_GH_TIMEOUT,
- )
- except subprocess.TimeoutExpired:
- return GhResult(returncode=124, stdout="", stderr=f"gh timed out after {_GH_TIMEOUT:g}s")
- return GhResult(
- returncode=completed.returncode, stdout=completed.stdout, stderr=completed.stderr
- )
-
-
class GitHubDriver:
"""Fetch GitHub content by URL shape: gist, profile, blob, issue/PR, repo."""
kind: Kind = Kind.GITHUB
sleep: float = 0.3
- def __init__(self, *, gh: Callable[[Sequence[str]], GhResult] = run_gh) -> None:
+ def __init__(self, *, gh: Gh = run_gh) -> None:
"""Wire the gh-CLI seam.
Args:
@@ -198,8 +140,9 @@ def fetch(self, unit: WorkUnit) -> Result:
if len(segments) == 1:
return self._fetch_profile(segments[0])
owner, repo = segments[0], segments[1]
- if len(segments) >= 4 and segments[2] == "blob": # noqa: PLR2004 — /owner/repo/blob/[/…
- return self._fetch_blob(owner, repo, segments[3:])
+ blob = blob_ref(unit.url)
+ if blob is not None:
+ return self._fetch_blob(blob)
if len(segments) >= 4 and segments[2] in ("issues", "pull"): # noqa: PLR2004
return self._fetch_issue(owner, repo, segments[3])
return self._fetch_repo(owner, repo)
@@ -229,7 +172,7 @@ def _fetch_profile(self, user: str) -> Result:
payload = self._api(f"users/{user}")
if isinstance(payload, Classification):
return _classified(payload)
- repos = self._api_list(f"users/{user}/repos?sort=pushed&per_page=100")
+ repos = gh_api_list(self._gh, f"users/{user}/repos?sort=pushed&per_page=100")
listing = _repo_listing(repos)
meta = {"title": payload.get("name") or user, "followers": payload.get("followers")}
body = (
@@ -238,58 +181,35 @@ def _fetch_profile(self, user: str) -> Result:
)
return Result(status=Status.DONE, meta=meta, body=body)
- def _fetch_blob(self, owner: str, repo: str, tail: list[str]) -> Result:
- ref, file_path = _guessed_split(tail)
- payload = self._api(_contents_endpoint(owner, repo, ref, file_path))
- if isinstance(payload, Classification) and payload.status is Status.DEAD:
- # 404/410 on ref+path is the one failure a different boundary
- # could fix. Re-splitting on a ref the repo really has costs an
- # extra request only here; when there is no such ref, or the
- # lookup itself fails, the original classification stands and a
- # genuinely missing path stays `dead`.
- resplit = self._resolve_split(owner, repo, tail)
- if resplit is not None and resplit != (ref, file_path):
- ref, file_path = resplit
- payload = self._api(_contents_endpoint(owner, repo, ref, file_path))
- if isinstance(payload, Classification):
- return _classified(payload)
- data = _blob_bytes(payload)
- meta: dict[str, str | int | None] = {"file": file_path}
- if data is None:
- # Over 1MB the contents API serves `encoding: "none"` and an
- # empty body — the file exists, we just cannot read it here.
- return Result(
- status=Status.MANUAL,
- meta=meta,
- reason=(
- f"{file_path} is larger than the contents API serves inline — "
- "read it from a clone"
- ),
- )
+ def _fetch_blob(self, ref: BlobRef) -> Result:
+ blob = fetch_blob(self._gh, ref)
+ if isinstance(blob, Classification):
+ return _classified(blob)
+ meta: dict[str, str | int | None] = {"file": blob.path}
# Named, because a signature is not always there to find: an
# unsmudged Git-LFS pointer is 130 bytes of honest UTF-8 standing in
# for a document, and a CSV has no signature at all. Unnamed, both
# decoded cleanly and fenced — the pointer text presented as the
- # document it points at.
- fmt = sniff_format(data, name=file_path)
+ # document it stands for.
+ fmt = sniff_format(blob.data, name=blob.path)
if fmt is not None:
+ # A document committed to a repo IS extractable work: the file
+ # driver reaches these bytes back through the shared seam, so
+ # the refusal that used to park this manual ("no other driver
+ # can re-fetch a blob URL") no longer holds. An LFS pointer that
+ # sniffs by name is caught there, before any extractor sees it.
return Result(
- status=Status.MANUAL,
- meta=meta,
- reason=(
- f"{file_path} is a {fmt.value} document, not source — capture the file "
- "itself so the extractors can read it"
- ),
+ status=Status.QUEUED, meta={}, redetect=Redetection(kind=Kind.FILE, format=fmt)
)
try:
- text = data.decode("utf-8")
+ text = blob.data.decode("utf-8")
except UnicodeDecodeError:
# Decoding with errors="replace" fenced 40k characters of
# replacement-character soup and ledgered it done.
return Result(
status=Status.MANUAL,
meta=meta,
- reason=f"{file_path} is binary, not UTF-8 text — there is nothing to fence",
+ reason=f"{blob.path} is binary, not UTF-8 text — there is nothing to fence",
)
return Result(status=Status.DONE, meta=meta, body=f"```\n{text[:_MAX_BLOB_CHARS]}\n```")
@@ -320,132 +240,8 @@ def _fetch_repo(self, owner: str, repo: str) -> Result:
body = readme.stdout if readme.returncode == 0 else "(no README)"
return Result(status=Status.DONE, meta=meta, body=body[:_MAX_README_CHARS])
- # -- blob ref resolution ---------------------------------------------
-
- def _resolve_split(self, owner: str, repo: str, tail: list[str]) -> tuple[str, str] | None:
- """The ref/path split the repo's own refs support, or None for no match.
-
- ``git/matching-refs`` is asked only about refs starting with the
- tail's first segment, so a repo with thousands of branches costs the
- same one page as a repo with three. A SHA ref matches nothing and
- answers ``[]``, which leaves the guess (and its classification) alone.
- """
- namespaces, start = _ref_search(tail)
- for namespace in namespaces:
- refs = self._api_list(
- f"repos/{owner}/{repo}/git/matching-refs/"
- f"{namespace}/{_requote(tail[start], safe='')}"
- )
- length = _longest_ref_match(_ref_names(refs, namespace), tail, start)
- if length is not None:
- boundary = start + length
- return "/".join(tail[:boundary]), "/".join(tail[boundary:])
- return None
-
- # -- gh plumbing -----------------------------------------------------
-
def _api(self, endpoint: str) -> dict | Classification:
- """``gh api ]`` parsed as a JSON object, or the classified failure."""
- result = self._gh(["api", endpoint])
- if result.returncode != 0:
- return _classify_gh_failure(result.stderr)
- try:
- payload = json.loads(result.stdout)
- except json.JSONDecodeError:
- return Classification(status=Status.BLOCKED, reason="gh api returned unparseable JSON")
- if not isinstance(payload, dict):
- return Classification(
- status=Status.BLOCKED, reason="gh api returned an unexpected shape"
- )
- return payload
-
- def _api_list(self, endpoint: str) -> list:
- """``gh api `` parsed as a JSON array; failures yield [].
-
- Both callers treat an empty answer as "no extra information": the
- profile notes a missing repo listing in its body rather than failing
- the unit, and a blob whose ref lookup came back empty keeps the
- classification its contents call already earned.
- """
- result = self._gh(["api", endpoint])
- if result.returncode != 0:
- return []
- try:
- payload = json.loads(result.stdout)
- except json.JSONDecodeError:
- return []
- return payload if isinstance(payload, list) else []
-
-
-def _contents_endpoint(owner: str, repo: str, ref: str, file_path: str) -> str:
- """The contents-API endpoint for one blob at one ref."""
- return (
- f"repos/{owner}/{repo}/contents/{_requote(file_path, safe='/')}"
- f"?ref={_requote(ref, safe='')}"
- )
-
-
-def _is_qualified_ref(tail: list[str]) -> bool:
- """True for the ``blob/refs/heads//…`` permalink form."""
- return len(tail) >= _QUALIFIED_REF_SEGMENTS and tail[0] == "refs" and tail[1] in _REF_NAMESPACES
-
-
-def _guessed_split(tail: list[str]) -> tuple[str, str]:
- """The ref/path split assuming the shortest ref the tail's shape allows."""
- if _is_qualified_ref(tail):
- return "/".join(tail[:3]), "/".join(tail[3:])
- return tail[0], "/".join(tail[1:])
-
-
-def _ref_search(tail: list[str]) -> tuple[tuple[str, ...], int]:
- """Which ref namespaces to search, and where in the tail the ref starts."""
- if _is_qualified_ref(tail):
- return (tail[1],), 2
- return _REF_NAMESPACES, 0
-
-
-def _ref_names(refs: list, namespace: str) -> list[str]:
- """``refs/heads/x/y`` entries reduced to the bare ref names (``x/y``)."""
- prefix = f"refs/{namespace}/"
- names = [ref.get("ref") for ref in refs if isinstance(ref, dict)]
- return [
- name.removeprefix(prefix)
- for name in names
- if isinstance(name, str) and name.startswith(prefix)
- ]
-
-
-def _longest_ref_match(names: list[str], tail: list[str], start: int) -> int | None:
- """Segment count of the longest name matching ``tail[start:]``, or None.
-
- Compared segment by segment, never as a string prefix: ``automation/bors``
- must not claim a URL whose branch is ``automation/bors-next``. A name that
- consumes the whole tail is rejected — that would leave no file path.
- """
- best: int | None = None
- for name in names:
- segments = [segment for segment in name.split("/") if segment]
- length = len(segments)
- if start + length >= len(tail):
- continue
- if tail[start : start + length] == segments and (best is None or length > best):
- best = length
- return best
-
-
-def _requote(value: str, *, safe: str) -> str:
- """Encode a URL piece for the API endpoint without double-encoding it."""
- return urllib.parse.quote(urllib.parse.unquote(value), safe=safe)
-
-
-def _blob_bytes(payload: dict) -> bytes | None:
- """The blob's bytes, or None when the API served no inline content."""
- if payload.get("encoding") != "base64":
- return None
- try:
- return base64.b64decode(payload.get("content") or "", validate=False)
- except (binascii.Error, ValueError):
- return None
+ return gh_api(self._gh, endpoint)
def _gist_id(segments: list[str]) -> str | None:
@@ -457,13 +253,6 @@ def _gist_id(segments: list[str]) -> str | None:
return None
-def _classify_gh_failure(stderr: str) -> Classification:
- match = _GH_HTTP_RE.search(stderr)
- if match:
- return classify_http(int(match.group(1)))
- return Classification(status=Status.BLOCKED, reason=f"gh api failed: {scrub(stderr)}")
-
-
def _classified(failure: Classification) -> Result:
return Result(status=failure.status, meta={}, reason=failure.reason)
diff --git a/src/dex_engine/drivers/podcast.py b/src/dex_engine/drivers/podcast.py
index cf8042d..c8cd23e 100644
--- a/src/dex_engine/drivers/podcast.py
+++ b/src/dex_engine/drivers/podcast.py
@@ -7,8 +7,11 @@
- **Spotify link** → og-title from the page → iTunes *search* → RSS → match.
Spotify exclusives fail honestly → ``manual`` (Claude may rescue via the
show's own site).
-- **Direct RSS / indie episode page** → enclosure, or the `` ``
- feed pointer in the page head.
+- **Direct RSS / indie episode page** → the `` `` feed pointer in
+ the page head (its notes are richer), falling back to the enclosure the
+ page carries itself. Such a page arrives here by re-detection from the
+ web driver, on that same content signal — URL shape cannot tell an
+ episode page from a post.
The driver only ever RESOLVES: the enclosure URL and the show notes (from
the feed — richer than the page) ride the waiting Result's meta/body, the
@@ -22,12 +25,13 @@
import re
import xml.etree.ElementTree as ET
from dataclasses import dataclass
-from urllib.parse import parse_qs, quote, urlsplit
+from urllib.parse import parse_qs, quote, urljoin, urlsplit
from dex_engine.pipeline.classify import Classification, classify_connection, classify_http
from dex_engine.pipeline.types import Kind, Need, Result, Status, WorkUnit
from dex_engine.pipeline.urls import base_canonical, host_of
+from .audio import audio_enclosure
from .transport import Transport, urllib_transport
__all__ = ["PodcastDriver"]
@@ -96,9 +100,11 @@ def matches(self, url: str) -> bool:
Deliberately narrow: bare ``/feed`` / ``/rss`` path suffixes and
``feeds.*`` / ``feed.*`` hosts are blog vocabulary too — the web
- driver keeps those. The long-term answer for a
- feed-shaped URL whose resolution finds no audio is corrected-kind
- re-entry — out of scope this phase.
+ driver keeps those. Indie episode pages reach this driver by
+ content, not by URL shape: the web driver fetches them (registry
+ order, catch-all last), finds the page carrying its own audio, and
+ re-detects the unit to ``podcast`` — so no pattern here has to
+ guess which ``/episodes/…`` path is a podcast and which is a blog.
"""
host = host_of(url)
if host == "podcasts.apple.com":
@@ -219,7 +225,30 @@ def _resolve_rss_ish(self, url: str) -> "_Episode | Result":
return self._resolve_episode_page(url, body)
def _resolve_episode_page(self, url: str, page: str) -> "_Episode | Result":
- feed_url = _feed_link(page)
+ """Resolve an indie episode page: its feed first, its own audio second.
+
+ The feed is preferred because the feed's show notes are richer than
+ the page's markup — but a page that carries its audio is resolvable
+ with or without one, and it must be: the web driver routed this
+ unit here on that signal, and bouncing it back would be a
+ re-detection loop park.
+ """
+ episode = self._episode_from_page_feed(url, page)
+ if isinstance(episode, _Episode):
+ return episode
+ enclosure = audio_enclosure(page, url)
+ if enclosure is None:
+ return episode # the feed route's own failure stands
+ return _Episode(
+ title=_og_title(page),
+ show=None,
+ enclosure=enclosure.url,
+ published=None,
+ notes="",
+ )
+
+ def _episode_from_page_feed(self, url: str, page: str) -> "_Episode | Result":
+ feed_url = _feed_link(page, url)
if feed_url is None:
return _manual("page exposes no RSS feed link — rescue by hand")
feed = self._feed(feed_url)
@@ -457,11 +486,13 @@ def _og_title(page: str) -> str | None:
return None
-def _feed_link(page: str) -> str | None:
+def _feed_link(page: str, base_url: str) -> str | None:
for tag in _FEED_LINK_RE.finditer(page):
href = _HREF_RE.search(tag.group(0))
if href:
- return html_lib.unescape(href.group(1))
+ # Feed pointers are routinely relative ("/feed.xml"); the
+ # driver fetches what it is handed, so it is absolutized here.
+ return urljoin(base_url, html_lib.unescape(href.group(1)).strip())
return None
diff --git a/src/dex_engine/drivers/transport.py b/src/dex_engine/drivers/transport.py
index 558d73c..a37ae8b 100644
--- a/src/dex_engine/drivers/transport.py
+++ b/src/dex_engine/drivers/transport.py
@@ -8,6 +8,12 @@
``OSError`` for ``classify_connection``. ``http.client``'s own protocol
failures are normalized into that same ``OSError`` shape here, once, rather
than at each of the eight call sites (:func:`normalize_httplib_errors`).
+URLs the request line cannot carry are encoded for the wire here too
+(:func:`_ascii_url`): ``http.client`` ascii-encodes the request line and
+rejects the controls, space and DEL within it, so an accented Wikipedia
+path, a CJK slug or an unencoded space in an href failed before a byte
+left the machine — these URLs are fetchable, and this is where they get
+fetched.
The browser UA is deliberate: the motivating incident was Cloudflare
challenging trafilatura's own fetch client; urllib with a browser UA avoids
@@ -16,7 +22,9 @@
import contextlib
import http.client
+import re
import urllib.error
+import urllib.parse
import urllib.request
from collections.abc import Iterator
from dataclasses import dataclass
@@ -102,6 +110,75 @@ def _content_length(raw: str | None) -> int | None:
return int(raw.strip())
+# What each component may legally carry unescaped, plus ``%`` itself: an
+# already-encoded URL must pass through untouched, never have its "%C3%A9"
+# doubly escaped into "%25C3%25A9".
+_PATH_SAFE = "/%:@!$&'()*+,;="
+_QUERY_SAFE = _PATH_SAFE + "?"
+
+# What ``http.client`` refuses to carry in the request line, whatever the
+# encoding: the C0 controls, space and DEL. Every one of them is ASCII, so
+# "is it ASCII already" is not the question the encoder needs answered.
+_REQUEST_LINE_FORBIDDEN = re.compile(r"[\x00-\x20\x7f]")
+
+
+def _needs_encoding(component: str) -> bool:
+ """True when ``component`` cannot go on the wire as it stands."""
+ return not component.isascii() or _REQUEST_LINE_FORBIDDEN.search(component) is not None
+
+
+def _ascii_netloc(netloc: str) -> str:
+ """Punycode the host of ``netloc``; userinfo percent-encoded, port kept."""
+ userinfo, at, hostport = netloc.rpartition("@")
+ host, colon, port = hostport.rpartition(":")
+ if not port.isdigit(): # no port, or an IPv6 literal's own colons
+ host, colon, port = hostport, "", ""
+ if not host.isascii():
+ host = host.encode("idna").decode("ascii")
+ if _needs_encoding(userinfo):
+ userinfo = urllib.parse.quote(userinfo, safe=":%")
+ return f"{userinfo}{at}{host}{colon}{port}"
+
+
+def _ascii_url(url: str) -> str:
+ """Encode ``url`` for the wire: punycoded host, percent-encoded path/query.
+
+ ``http.client`` puts the request line through ``encode("ascii")``, so a
+ URL carrying any non-ASCII character — an accented Wikipedia path, an
+ IDN host, a CJK slug — raised ``UnicodeEncodeError`` before a byte left
+ the machine, and being a ``ValueError`` it escaped every caller's
+ connection guard as a raw codec message. Nothing is wrong with these
+ URLs; what they need is the encoding a browser applies for them.
+
+ Being ASCII is not enough to skip that: ``http.client`` also rejects
+ the C0 controls, space and DEL in the request line outright
+ (:data:`_REQUEST_LINE_FORBIDDEN`), so the unencoded space that hrefs
+ carry all the time raised ``InvalidURL`` and parked the item
+ ``blocked`` — retried, and looked up in the wayback, on a condition no
+ retry can change. ``quote`` already maps it to ``%20``.
+
+ Raises:
+ ValueError: The host is not encodable for DNS (an over-long or
+ empty IDN label) — stated, never the codec's own words.
+ """
+ if not _needs_encoding(url):
+ return url # the overwhelming majority, byte-for-byte as asked
+ parts = urllib.parse.urlsplit(url)
+ try:
+ netloc = _ascii_netloc(parts.netloc)
+ except UnicodeError as e:
+ raise ValueError(f"host of {url!r} is not encodable for DNS: {e}") from e
+ return urllib.parse.urlunsplit(
+ (
+ parts.scheme,
+ netloc,
+ urllib.parse.quote(parts.path, safe=_PATH_SAFE),
+ urllib.parse.quote(parts.query, safe=_QUERY_SAFE),
+ urllib.parse.quote(parts.fragment, safe=_QUERY_SAFE),
+ )
+ )
+
+
def urllib_transport(url: str, *, method: str = "GET") -> HttpResponse:
"""Fetch ``url`` over urllib with the browser UA.
@@ -113,7 +190,8 @@ def urllib_transport(url: str, *, method: str = "GET") -> HttpResponse:
The response — 4xx/5xx included, never raised.
Raises:
- ValueError: ``url`` is not http(s).
+ ValueError: ``url`` is not http(s), or its host is not encodable
+ for DNS (:func:`_ascii_url`).
OSError: Connection-level failure (DNS, refused, reset, timeout, a
truncated body); ``urllib.error.URLError`` is an ``OSError``
subclass and ``http.client``'s family is normalized into one.
@@ -121,7 +199,7 @@ def urllib_transport(url: str, *, method: str = "GET") -> HttpResponse:
if not url.startswith(("http://", "https://")):
raise ValueError(f"transport fetches http(s) URLs only, got {url!r}")
request = urllib.request.Request( # noqa: S310 — scheme checked above
- url, headers={"User-Agent": BROWSER_UA}, method=method
+ _ascii_url(url), headers={"User-Agent": BROWSER_UA}, method=method
)
with normalize_httplib_errors():
try:
diff --git a/src/dex_engine/drivers/web.py b/src/dex_engine/drivers/web.py
index e034e24..1b76a72 100644
--- a/src/dex_engine/drivers/web.py
+++ b/src/dex_engine/drivers/web.py
@@ -16,6 +16,18 @@
all: detection's HEAD lied or was inconclusive. The driver signals a
redetection to ``file`` work and the run layer re-routes the unit — never
``manual`` for content the file driver can read.
+
+The same mid-fetch discovery carries indie podcast episode pages to the
+``podcast`` driver, and being the catch-all is what lets that be decided on
+content rather than on URL patterns that cannot tell ``/feed`` the blog
+from ``/feed`` the show. What counts as evidence is narrow, because the two
+mistakes cost differently: handing an article to the podcast driver parks
+it ``waiting: transcribe`` with an EMPTY body — the article is never
+extracted and its links are never harvested — while handing an episode
+page to ``web`` merely stores its show notes. So an episode is a page whose
+``og:audio`` names the audio as the page's own object, or one carrying a
+player and no body worth extracting. An ```` element beside a real
+article is a read-aloud widget or a media sample, and the article wins.
"""
import html as html_lib
@@ -36,6 +48,7 @@
from dex_engine.pipeline.types import Format, Kind, Redetection, Result, Status, WorkUnit
from dex_engine.pipeline.urls import base_canonical
+from .audio import audio_enclosure
from .transport import Transport, urllib_transport
__all__ = ["HtmlExtract", "WebDriver", "trafilatura_extract"]
@@ -127,7 +140,18 @@ def fetch(self, unit: WorkUnit) -> Result:
meta={},
redetect=Redetection(kind=Kind.FILE, format=fmt),
)
- return self._extracted(page.html, base_url=unit.url, allow_media=True) or Result(
+ enclosure = audio_enclosure(page.html, unit.url)
+ if enclosure is not None and enclosure.declared:
+ return _podcast_redetection()
+ extracted = self._extracted(page.html, base_url=unit.url, allow_media=True)
+ if extracted is not None:
+ return extracted
+ if enclosure is not None:
+ # A player and nothing worth extracting: the audio is what
+ # the page is. Ordering is the whole rule — an article's
+ # read-aloud widget was reached above, by its own body.
+ return _podcast_redetection()
+ return Result(
status=Status.MANUAL,
meta=_title_meta(page.html),
reason=THIN_EXTRACTION_REASON,
@@ -194,6 +218,11 @@ def _wayback_snapshot(self, url: str) -> tuple[str | None, str | None]:
return closest["url"], None
+def _podcast_redetection() -> Result:
+ """Hand the unit to the podcast driver — identity only, no outputs."""
+ return Result(status=Status.QUEUED, meta={}, redetect=Redetection(kind=Kind.PODCAST))
+
+
def _document_format(page: _Page) -> Format | None:
"""The extractable Format of a fetched body, or None for real web content.
diff --git a/src/dex_engine/drivers/x.py b/src/dex_engine/drivers/x.py
index 85cc6d7..60c293e 100644
--- a/src/dex_engine/drivers/x.py
+++ b/src/dex_engine/drivers/x.py
@@ -7,14 +7,16 @@
The chain above a captured post is context, not new first-class sources:
one enrichment file, one ledger entry. The walk follows fxtwitter parent
-pointers bottom-to-top (cap 20 hops); storage is reading order — root
+pointers bottom-to-top (bounded at 100 hops, paced a second apart, ending
+at the first id it has already walked); storage is reading order — root
first, captured post last, each post attributed. Quoted posts stay inline
as blockquotes; promoting a quote is a harvest judgment. Chain media is
pooled, the captured post's first.
Incomplete chains are recorded, never silently presented as complete: a
-parent fetch failing mid-walk sets ``chain_incomplete`` in meta with how
-far the walk got. Walk-down is explicitly unsolved — backlog.
+parent fetch failing mid-walk, or a chain looping back on itself, sets
+``chain_incomplete`` in meta with how far the walk got. Walk-down is
+explicitly unsolved — backlog.
Long-form articles carry their prose under ``article`` (``text`` is empty
and ``raw_text`` holds only the shortlink), so they render from title and
@@ -25,7 +27,9 @@
import json
import re
+import time
import urllib.parse
+from collections.abc import Callable
from dex_engine.pipeline.classify import (
Classification,
@@ -47,8 +51,21 @@
# with or without a /photo/1-style tail.
_STATUS_PATH_RE = re.compile(r"/status(?:es)?/(\d+)")
-# Thread walk-up cap: 20 parent hops above the captured post.
-MAX_HOPS = 20
+# Thread walk-up bound: 100 parent hops above the captured post. A thread
+# is ONE piece of content — one work unit, one enrichment file — and the
+# walk is a linear chain of cheap calls with no fan-out, so this is a
+# sanity bound against a chain that never ends, not an editorial one:
+# 30-post threads are ordinary, and half an argument stored as though it
+# were whole is the failure worth avoiding. Cycles do NOT reach it: an id
+# already walked ends the walk where it repeats.
+MAX_HOPS = 100
+
+# Pacing between parent fetches. The driver's own 4s politeness is spent
+# between UNITS, and a walk is many requests inside one — a 30-post thread
+# went out as 30 back-to-back calls to a free community API. One second a
+# hop keeps an ordinary thread under a minute while making the walk a
+# paced sequence rather than a burst.
+HOP_SLEEP = 1.0
# A body that is nothing but x's own t.co shortlink is a pointer to
# content, never the content itself.
@@ -61,9 +78,22 @@ class XDriver:
kind: Kind = Kind.X
sleep: float = 4.0
- def __init__(self, *, transport: Transport = urllib_transport) -> None:
- """Wire the HTTP seam (fxtwitter is plain JSON over GET)."""
+ def __init__(
+ self,
+ *,
+ transport: Transport = urllib_transport,
+ pace: Callable[[float], None] = time.sleep,
+ ) -> None:
+ """Wire the HTTP seam (fxtwitter is plain JSON over GET) and its pacing.
+
+ Args:
+ transport: The HTTP seam; injected so tests are hermetic.
+ pace: The walk's own pacing seam — ``sleep`` names the driver's
+ per-unit politeness, which the run layer spends. Injected
+ so tests are hermetic.
+ """
self._transport = transport
+ self._pace = pace
def matches(self, url: str) -> bool:
"""True for x.com, twitter.com, and mobile.twitter.com hosts."""
@@ -116,17 +146,34 @@ def _fetch_post(self, api_path: str) -> dict | Classification:
return tweet
def _walk_up(self, captured: dict) -> tuple[list[dict], dict[str, str | int | None]]:
- """Follow parent pointers up the chain; record gaps and cap hits in meta."""
+ """Follow parent pointers up the chain; record gaps and cap hits in meta.
+
+ The ids already walked are remembered because fxtwitter will
+ happily point a post at itself: without that, one such post spent
+ the whole hop bound on 100 back-to-back requests.
+ """
posts = [captured]
walk_meta: dict[str, str | int | None] = {}
current = captured
+ seen = {str(captured["id"])} if captured.get("id") is not None else set()
while (parent_id := current.get("replying_to_status")) is not None:
+ if str(parent_id) in seen:
+ # A post naming itself (or an ancestor) as its parent: the
+ # chain above is a loop, not more thread. Recorded like any
+ # short walk — never presented as a complete chain.
+ walk_meta["chain_incomplete"] = "true"
+ walk_meta["chain_note"] = (
+ f"parent chain loops back to post {parent_id} after {len(posts)} post(s)"
+ )
+ break
if len(posts) - 1 >= MAX_HOPS:
# Cap hit: recorded in meta (and thereby the enrichment
# frontmatter), never on user-facing surfaces.
walk_meta["thread_cap_hit"] = "true"
break
+ seen.add(str(parent_id))
screen = current.get("replying_to") or "i"
+ self._pace(HOP_SLEEP)
parent = self._fetch_post(f"{screen}/status/{parent_id}")
if isinstance(parent, Classification):
# Mid-walk fetch failure is mechanical: record the gap, keep
diff --git a/src/dex_engine/drivers/youtube.py b/src/dex_engine/drivers/youtube.py
index d0f883b..15fc4a9 100644
--- a/src/dex_engine/drivers/youtube.py
+++ b/src/dex_engine/drivers/youtube.py
@@ -20,7 +20,10 @@
The driver NEVER downloads audio — audio acquisition belongs to the
transcribe drain. No usable captions means
``Result(waiting, needs=transcribe)``: the work is parked for the
-capability, not given up on.
+capability, not given up on — and the park carries the description it
+already fetched, so the run layer writes that content now rather than
+holding it hostage to a transcription backlog the video may not survive.
+The drain appends the transcript to that same file.
Probe failures are mapped honestly: an HTTP code buried in yt-dlp's message
routes through the central classifier; private/sign-in walls are ``manual``;
@@ -126,6 +129,13 @@
# defaults to `blocked`: the motivating-incident class, again.
_GONE_MARKERS = ("video unavailable", "removed", "terminated")
+# The youtube body's two sections. The transcribe drain splits a stored
+# body on these same headings to append a transcript to a park's
+# description (pipeline/transcribe.py); drivers cannot import that module
+# (it imports this one), so the pairing is pinned by test instead.
+_DESCRIPTION_HEADING = "## Description"
+_TRANSCRIPT_HEADING = "## Transcript"
+
_VTT_NOISE_PREFIXES = ("WEBVTT", "Kind:", "Language:", "NOTE", "align:")
_VTT_TAG_RE = re.compile(r"<[^>]+>")
@@ -219,6 +229,7 @@ def fetch(self, unit: WorkUnit) -> Result:
return Result(
status=Status.WAITING,
meta=meta,
+ body=_description_section(info) or None,
needs=Need.TRANSCRIBE,
reason="no captions available",
)
@@ -248,6 +259,7 @@ def _fetch_transcript(
return Result(
status=Status.WAITING,
meta=meta,
+ body=_description_section(info) or None,
needs=Need.TRANSCRIBE,
reason="captions track too thin to be a transcript",
)
@@ -379,12 +391,29 @@ def _caption_track_url(info: dict) -> str | None:
return None
-def _body(info: dict, transcript: str) -> str:
- """Description + transcript sections, as today; transcript alone otherwise."""
+def _description_section(info: dict) -> str:
+ """The description as its own body section, or "" when there is none.
+
+ A park writes this on its own: the description is content already
+ fetched, and a video that goes private during a transcription backlog
+ would otherwise take it with it. The transcript is appended to this
+ same section later, never written over it.
+ """
description = (info.get("description") or "").strip()
- if description:
- return f"## Description\n\n{description}\n\n## Transcript\n\n{transcript}"
- return transcript
+ return f"{_DESCRIPTION_HEADING}\n\n{description}" if description else ""
+
+
+def _body(info: dict, transcript: str) -> str:
+ """Description section + transcript section — one body shape per kind.
+
+ The transcript is always its own labelled section, description or not:
+ the drain splits a stored body on that heading to append a transcript
+ to what the park already wrote (``pipeline/transcribe.py``).
+ """
+ section = _description_section(info)
+ if section:
+ return f"{section}\n\n{_TRANSCRIPT_HEADING}\n\n{transcript}"
+ return f"{_TRANSCRIPT_HEADING}\n\n{transcript}"
def clean_vtt(vtt: str) -> str:
diff --git a/src/dex_engine/pipeline/run.py b/src/dex_engine/pipeline/run.py
index 3862507..b10a07d 100644
--- a/src/dex_engine/pipeline/run.py
+++ b/src/dex_engine/pipeline/run.py
@@ -113,6 +113,10 @@
HARVEST_RULES_VERSION = 1
_PARKED = frozenset({Status.WAITING, Status.BLOCKED, Status.ERROR, Status.MANUAL})
+# Work still owed on an item: queued and every parked status. Its complement
+# — done, dead, skipped — is a unit that has landed, is confirmed gone, or
+# was deliberately closed out; none of the three is owed anything further.
+_OUTSTANDING = _PARKED | {Status.QUEUED}
_PASS_STAGES = frozenset({"harvest", "digest", "wiki"})
# Whitespace and control characters anywhere in a URL make the HTTP client
@@ -291,6 +295,10 @@ class _Drain:
item_paths: dict[str, Path] = field(default_factory=dict)
item_status: dict[str, str] = field(default_factory=dict)
no_source_items: list[str] = field(default_factory=list)
+ # Items this run wrote an outcome for — the report's incompleteness
+ # section covers exactly these (an item nothing happened to this run has
+ # nothing new to say; `enrich status` holds the standing view).
+ touched: set[str] = field(default_factory=set)
# Rerun-cohort pacing bookkeeping (full drains only). The counted set
# exists because a redetected unit re-enters the live queue and pops
# twice — one logical unit must spend one slot of the cohort, not two.
@@ -634,6 +642,11 @@ def _transcribe_unit(self, entry: LedgerEntry) -> None:
)
result = Result(status=Status.DONE, meta=meta, body=body)
path = self._write_output(entry, result)
+ # A unit corrected to a transcribable kind (web → podcast) lands its
+ # own output HERE, never through _apply_done — the pre-correction
+ # kind's file leaves on the same rule, or the item carries two views
+ # of one unit forever.
+ _drop_superseded_outputs(self.ctx.instance, entry, path)
title = meta.get("title")
self.record_outcome(
entry, status=Status.DONE, path=path, title=title if isinstance(title, str) else None
@@ -653,12 +666,17 @@ def _note_first_run(self, transcriber: Transcriber) -> None:
def _acquire_audio(self, entry: LedgerEntry) -> Acquired | Classification:
"""Audio acquisition belongs to the drain, per kind."""
audio_dir = self.ctx.instance.cache_dir / "audio"
+ # Both kinds park with content already in hand (a description, show
+ # notes) and both compose the transcript onto what that park wrote,
+ # so both acquisitions read the unit's own output file.
+ name = f"{entry.kind.value}-{entry.hash[:6]}.md"
+ enrichment = self.ctx.instance.enrichment_dir / entry.item / name
match entry.kind:
case Kind.YOUTUBE:
- return acquire_youtube_audio(entry, audio_dir, self.ctx.download_audio)
+ return acquire_youtube_audio(
+ entry, enrichment, audio_dir, self.ctx.download_audio
+ )
case Kind.PODCAST:
- name = f"{entry.kind.value}-{entry.hash[:6]}.md"
- enrichment = self.ctx.instance.enrichment_dir / entry.item / name
return acquire_podcast_audio(entry, enrichment, audio_dir, self.ctx.transport)
case _:
return Classification(
@@ -1156,6 +1174,7 @@ def record(self, entry: LedgerEntry, *, count: bool = False) -> None:
self.entries[stamped.hash] = stamped
if count:
self.counts[stamped.status] = self.counts.get(stamped.status, 0) + 1
+ self.touched.add(stamped.item)
if count and stamped.status in _PARKED:
self.parked.append(
{
@@ -1215,7 +1234,9 @@ def _refresh_items(self) -> None:
write-only-on-change rule keeps untouched items untouched.
"""
for item_id, path in self.item_paths.items():
- detail = _refresh_item_frontmatter(self.ctx.instance, item_id, path)
+ detail = _refresh_item_frontmatter(
+ self.ctx.instance, item_id, path, entries=self.entries
+ )
if detail is not None:
self.notes.append(
f"item {item_id}: an enrichment file cannot be listed in "
@@ -1258,6 +1279,9 @@ def report_payload(self) -> dict[str, object]:
"items": items,
"parked": self.parked,
}
+ incomplete = self._incomplete_items()
+ if incomplete:
+ payload["incomplete"] = incomplete
cognitive = self._cognitive_jobs()
if cognitive:
payload["cognitive"] = cognitive
@@ -1265,6 +1289,48 @@ def report_payload(self) -> dict[str, object]:
payload["notes"] = list(self.notes)
return payload
+ def _incomplete_items(self) -> list[dict[str, object]]:
+ """Why each touched item is still raw — the shape of what is missing.
+
+ An item is one unit of knowledge, and none of it advances to digest
+ or wiki until every part has landed. The report states that shape
+ per item so a session never has to infer completeness by reading a
+ list of units.
+
+ Cap-fire markers are not units: a harvest that overran the URL cap
+ recorded refused work, which no user surface reports (§12). Counted
+ as landed they inflate both halves of the shape — "15 of 16 units
+ landed" for an item that admitted twelve.
+ """
+ rows: list[dict[str, object]] = []
+ for item_id in sorted(self.touched):
+ units = [
+ entry
+ for entry in self.entries.values()
+ if entry.item == item_id and not _is_cap_refusal(entry)
+ ]
+ outstanding = [entry for entry in units if entry.status in _OUTSTANDING]
+ if not outstanding:
+ continue
+ groups: dict[tuple[str, str | None], int] = {}
+ for entry in outstanding:
+ key = (entry.status.value, entry.needs.value if entry.needs else None)
+ groups[key] = groups.get(key, 0) + 1
+ rows.append(
+ {
+ "item": item_id,
+ "landed": len(units) - len(outstanding),
+ "total": len(units),
+ "outstanding": [
+ {"status": status, "count": count}
+ if needs is None
+ else {"status": status, "needs": needs, "count": count}
+ for (status, needs), count in groups.items()
+ ],
+ }
+ )
+ return rows
+
def _cognitive_jobs(self) -> list[dict[str, str]]:
"""Waiting jobs that resolve to the cognitive floor.
@@ -1291,8 +1357,9 @@ def _drop_superseded_outputs(instance: Instance, entry: LedgerEntry, path: str)
The stale file leaves the disk here, on the success that replaces it,
and never earlier: a corrected fetch that parks must leave the item
exactly as enriched as it found it. The ledger's audit trail keeps the
- history either way. Both routes to a unit's own output come through
- here — the drain's write, and a hand-written file closed by ``mark``.
+ history either way. Every route to a unit's own output comes through
+ here — the drain's fetch write, the transcribe drain's landing, and a
+ hand-written file closed by ``mark``.
Candidate names are the closed ``-.md`` set, and each
candidate must PROVE it belongs to this unit by the URL it records:
@@ -1316,9 +1383,18 @@ def _drop_superseded_outputs(instance: Instance, entry: LedgerEntry, path: str)
def _refresh_item_frontmatter(
- instance: Instance, item_id: str, path: Path | None = None
+ instance: Instance,
+ item_id: str,
+ path: Path | None = None,
+ *,
+ entries: dict[str, LedgerEntry] | None = None,
) -> str | None:
- """Derive one item's ``status``/``enrichment:`` from disk; write only on change.
+ """Derive one item's ``status``/``enrichment:``; write only on change.
+
+ The listing is the enrichment directory's markdown files; the status is
+ the ledger's answer to "has the whole item landed" — ``enriched`` only
+ when no unit the item owns is still outstanding, ``raw`` while one is.
+ Callers holding the ledger pass ``entries``; the rest read it here.
Silent when the corpus file is gone or unreadable: a heal may outlive
its item (excluded after capture), an unreadable item is seeding's to
@@ -1339,9 +1415,10 @@ def _refresh_item_frontmatter(
# had landed, and the retry duplicated the record.
return None
files = sorted(p.name for p in (instance.enrichment_dir / item_id).glob("*.md"))
+ units = _item_units(instance, item_id, entries)
try:
updated = dataclasses.replace(
- item, status="enriched" if files else "raw", enrichment=files
+ item, status=_derived_status(item, files, units), enrichment=files
)
if updated != item:
corpus.write_item(path, updated)
@@ -1350,6 +1427,48 @@ def _refresh_item_frontmatter(
return None
+def _item_units(
+ instance: Instance, item_id: str, entries: dict[str, LedgerEntry] | None
+) -> list[LedgerEntry] | None:
+ """The item's ledger units — from the caller's map, or read here.
+
+ None means the ledger could not be read at all: the quiet callers write
+ first and refresh after, so a nonconforming line must leave the status
+ alone rather than raise past a write that already landed.
+ """
+ if entries is None:
+ entries = _ledger_or_none(instance)
+ if entries is None:
+ return None
+ return [entry for entry in entries.values() if entry.item == item_id]
+
+
+def _ledger_or_none(instance: Instance) -> dict[str, LedgerEntry] | None:
+ """The whole ledger, or None when it cannot be read at all."""
+ try:
+ return ledger.load(instance.ledger_path)
+ except (OSError, UnicodeDecodeError, ledger.LedgerSchemaError):
+ return None
+
+
+def _derived_status(
+ item: corpus.CorpusItem, files: list[str], units: list[LedgerEntry] | None
+) -> str:
+ """``enriched`` iff the item holds enrichment and owes no further work.
+
+ An item is one unit of knowledge — the post, the thread parents above
+ it, the links harvest promoted, the media, the video awaiting its
+ transcript — so one outstanding unit keeps the whole item ``raw`` and
+ out of digest/wiki. A dead link or a deliberately skipped unit owes
+ nothing and holds nothing hostage.
+ """
+ if not files:
+ return "raw"
+ if units is None:
+ return item.status
+ return "raw" if any(unit.status in _OUTSTANDING for unit in units) else "enriched"
+
+
# ---------------------------------------------------------------------------
# Verbs (called by the CLI; zero business logic lives there).
# ---------------------------------------------------------------------------
@@ -1658,32 +1777,108 @@ def digest_orphans(instance: Instance) -> list[str]:
Shared by ``enrich status`` and lint's health check: both list the same
interrupted-session orphans, computed in one place.
+ Staleness is read from committed dates, never file mtimes: git stamps
+ every file at checkout, so on a second machine the whole tree carries
+ one mtime and staleness would be undetectable. The two dates are the
+ item's newest ``done`` ledger line and its digest pass record — both
+ travel in git, and day granularity is the intent (enriching and
+ digesting in one session is not stale).
+
+ An item still owing a unit is never listed, however long it has owed
+ it. Such an item derives ``raw``, and a raw item is one the ingest
+ procedure forbids digesting — so listing it would report the same
+ permanently-parked item as work to do on every run forever. A
+ ``manual`` unit awaiting judgment, an ``error`` unit on an unchanged
+ engine, a ``blocked`` unit past its attempts and a ``waiting`` unit
+ with no provider all sit there indefinitely by design.
+
Args:
instance: The instance.
Returns:
- Item ids whose enrichment outputs are newer than their digest (or
- that have outputs and no digest at all).
+ Item ids that owe no further work and whose enrichment landed
+ after their digest pass (or that have enrichment and no digest at
+ all).
"""
orphans = []
if not instance.enrichment_dir.is_dir():
return orphans
+ entries = _ledger_or_none(instance)
+ owing = _items_owing_work(entries)
+ enriched_on = _last_enriched(entries)
+ digested_on = _last_digested(instance)
for item_dir in sorted(instance.enrichment_dir.iterdir()):
if not item_dir.is_dir():
continue
- outputs = list(item_dir.glob("*.md"))
- if not outputs:
+ if not any(item_dir.glob("*.md")):
continue
- digest = instance.digests_dir / f"{item_dir.name}.md"
- if not digest.exists():
+ if item_dir.name in owing:
+ continue
+ if not (instance.digests_dir / f"{item_dir.name}.md").exists():
orphans.append(item_dir.name)
continue
- newest = max(path.stat().st_mtime for path in outputs)
- if newest > digest.stat().st_mtime:
+ landed = enriched_on.get(item_dir.name)
+ digested = digested_on.get(item_dir.name)
+ # No dates on either side is no claim: enrichment written outside
+ # the ledger (a media description) and digests older than the pass
+ # record are facts about history, not staleness.
+ if landed is not None and digested is not None and digested < landed:
orphans.append(item_dir.name)
return orphans
+def _items_owing_work(entries: dict[str, LedgerEntry] | None) -> set[str]:
+ """The items a unit is still outstanding on — the ones deriving ``raw``.
+
+ An unreadable ledger claims nothing: no item is held back, exactly as
+ no item is called stale.
+ """
+ if entries is None:
+ return set()
+ return {entry.item for entry in entries.values() if entry.status in _OUTSTANDING}
+
+
+def _last_enriched(entries: dict[str, LedgerEntry] | None) -> dict[str, datetime.date]:
+ """Each item's newest ``done`` ledger date.
+
+ A nonconforming ledger (``entries`` None) is lint's own loud finding;
+ the staleness backstop simply has nothing to compare and says nothing.
+ """
+ if entries is None:
+ return {}
+ newest: dict[str, datetime.date] = {}
+ for entry in entries.values():
+ if entry.status is Status.DONE:
+ newest[entry.item] = max(newest.get(entry.item, entry.date), entry.date)
+ return newest
+
+
+def _last_digested(instance: Instance) -> dict[str, datetime.date]:
+ """Each item's newest recorded digest pass date."""
+ path = instance.passes_path
+ if not path.exists():
+ return {}
+ newest: dict[str, datetime.date] = {}
+ for line in path.read_text(encoding="utf-8").split("\n"):
+ if not line.strip():
+ continue
+ try:
+ record = json.loads(line)
+ except json.JSONDecodeError:
+ continue # lint parses this file loudly; the backstop skips the line
+ if not isinstance(record, dict) or record.get("stage") != "digest":
+ continue
+ item, raw_date = record.get("item"), record.get("date")
+ if not isinstance(item, str) or not isinstance(raw_date, str):
+ continue
+ try:
+ date = datetime.date.fromisoformat(raw_date)
+ except ValueError:
+ continue
+ newest[item] = max(newest.get(item, date), date)
+ return newest
+
+
def mark( # noqa: PLR0913 — the verb mirrors its CLI flags
ctx: RunContext,
url: str,
@@ -1775,7 +1970,9 @@ def mark( # noqa: PLR0913 — the verb mirrors its CLI flags
# drop the item keeps two files for one unit and serves the stale
# pre-correction view to the digest and query layers forever.
_drop_superseded_outputs(ctx.instance, healed, effective_path)
- _refresh_item_frontmatter(ctx.instance, prior.item)
+ # The heal's own line included: the drain's map is the ledger as of this
+ # write, so a heal that completes an item flips its status in the same call.
+ _refresh_item_frontmatter(ctx.instance, prior.item, entries=drain.entries)
return f"marked {prior.url} ({prior.hash}) {status.value}"
diff --git a/src/dex_engine/pipeline/transcribe.py b/src/dex_engine/pipeline/transcribe.py
index 2d4f4ad..4f6c1b7 100644
--- a/src/dex_engine/pipeline/transcribe.py
+++ b/src/dex_engine/pipeline/transcribe.py
@@ -67,7 +67,17 @@
_SEPARATOR = " — "
_AUDIO_EXT_DEFAULT = "mp3"
+# The body sections both transcribable kinds compose around. The youtube
+# driver writes the same two headings on its parks (drivers/youtube.py);
+# it cannot import them from here (this module imports that one), so the
+# pairing is pinned by test.
_TRANSCRIPT_HEADING = "## Transcript"
+_DESCRIPTION_HEADING = "## Description"
+
+# The frontmatter key the transcriber stamps (run.py writes via/model onto
+# every transcript it composes). Neither park writes it, so it is the one
+# fact on disk that says whether a body already holds a transcript.
+_TRANSCRIBED_FIELD = "via"
@dataclass(frozen=True, slots=True, kw_only=True)
@@ -148,12 +158,18 @@ class Acquired:
def acquire_youtube_audio(
- entry: LedgerEntry, cache_dir: Path, download: DownloadAudio
+ entry: LedgerEntry, enrichment_path: Path, cache_dir: Path, download: DownloadAudio
) -> Acquired | Classification:
"""Acquire a YouTube unit's audio via the yt-dlp seam.
+ The description the park already wrote is the body the transcript
+ joins — the same composition the podcast path uses — so a re-drain
+ appends to what is on disk instead of writing over it. A park that had
+ no description to write falls back to the probe's own.
+
Args:
entry: The waiting ledger entry.
+ enrichment_path: ``enrichment/- /youtube-
.md``.
cache_dir: ``cache/audio/``.
download: The yt-dlp seam.
@@ -173,8 +189,20 @@ def acquire_youtube_audio(
"duration_min": audio.duration_min,
"upload_date": audio.upload_date,
}
- prompt = _prompt(audio.title, audio.channel, audio.description)
- return Acquired(audio=audio.path, meta=meta, prompt=prompt, prefix=audio.description)
+ description = _stored_description(enrichment_path) or audio.description
+ prompt = _prompt(audio.title, audio.channel, description)
+ return Acquired(audio=audio.path, meta=meta, prompt=prompt, prefix=description)
+
+
+def _stored_description(path: Path) -> str:
+ """The description a youtube park already wrote, or "" when there is none."""
+ if not path.exists():
+ return ""
+ fields, body = read_enrichment(path)
+ head = _pre_transcript(fields, body)
+ if head.startswith(_DESCRIPTION_HEADING):
+ return head[len(_DESCRIPTION_HEADING) :].strip()
+ return head
def acquire_podcast_audio(
@@ -215,7 +243,7 @@ def acquire_podcast_audio(
"podcast driver re-resolves it"
),
)
- notes = _pre_transcript(body)
+ notes = _pre_transcript(fields, body)
meta: dict[str, str | int | None] = {
key: value for key, value in fields.items() if key not in ("url", "fetched")
}
@@ -229,16 +257,27 @@ def acquire_podcast_audio(
return Acquired(audio=audio, meta=meta, prompt=prompt, prefix=notes)
-def _pre_transcript(body: str) -> str:
+def _pre_transcript(fields: dict[str, str], body: str) -> str:
"""The show-notes half of a park/output body — everything before the transcript.
+ A body only holds a transcript section if this drain composed it, and
+ the frontmatter is what says so. A park's body is notes end to end,
+ however many "## Transcript" lines a description or a publisher's show
+ notes happen to contain: reading it by the heading alone truncated the
+ notes at the author's own line, and the drain then wrote that
+ truncation back to disk, losing the tail for good.
+
A drained no-notes episode's body STARTS with the transcript heading;
the newline-anchored split below would miss it and hand the previous
- transcript back as "notes", duplicating it on a re-drain.
+ transcript back as "notes", duplicating it on a re-drain. That split
+ takes the LAST section, because the transcript is what the drain
+ appended last.
"""
+ if _TRANSCRIBED_FIELD not in fields:
+ return body
if body == _TRANSCRIPT_HEADING or body.startswith(f"{_TRANSCRIPT_HEADING}\n"):
return ""
- return body.split(f"\n{_TRANSCRIPT_HEADING}\n", maxsplit=1)[0].rstrip()
+ return body.rsplit(f"\n{_TRANSCRIPT_HEADING}\n", maxsplit=1)[0].rstrip()
# In-flight artifacts: yt-dlp's `.part` bodies (also `.part-Frag…`) and
@@ -447,10 +486,15 @@ def keep_last_tokens(text: str, budget: float) -> str:
def youtube_body(description: str, transcript: str) -> str:
- """Description + transcript sections — the youtube driver's own pattern."""
+ """Description + transcript sections — the youtube driver's own pattern.
+
+ The transcript is always its own labelled section: a re-drain splits
+ the stored body on that heading, and a bare transcript would come back
+ as "description" and be duplicated under itself.
+ """
if description:
- return f"## Description\n\n{description}\n\n{_TRANSCRIPT_HEADING}\n\n{transcript}"
- return transcript
+ return f"{_DESCRIPTION_HEADING}\n\n{description}\n\n{_TRANSCRIPT_HEADING}\n\n{transcript}"
+ return f"{_TRANSCRIPT_HEADING}\n\n{transcript}"
def podcast_body(show_notes: str, transcript: str) -> str:
diff --git a/src/dex_engine/render/surfaces.py b/src/dex_engine/render/surfaces.py
index 906850a..d9c7e1a 100644
--- a/src/dex_engine/render/surfaces.py
+++ b/src/dex_engine/render/surfaces.py
@@ -205,6 +205,13 @@ def _render_enrich_report(payload: Mapping[str, object]) -> str:
# fresh / rerun / waiting-drained
"parked": [{"item": str, "url": str,
"status": str, "reason": str}], # survives the session
+ "incomplete": [{"item": str, # optional: touched items
+ "landed": int, # still owed work — the
+ "total": int, # ledger units that have
+ "outstanding": [ # landed, of all it owns
+ {"status": str, # an outstanding status
+ "needs": str, # optional
+ "count": int}]}],
"cognitive": [{"item": str, "url": str,
"need": str}], # optional: jobs for the session
"issues_filed": int, # optional, default 0
@@ -216,10 +223,11 @@ def _render_enrich_report(payload: Mapping[str, object]) -> str:
surface,
payload,
required=frozenset({"counts", "items", "parked"}),
- optional=frozenset({"cognitive", "issues_filed", "notes"}),
+ optional=frozenset({"incomplete", "cognitive", "issues_filed", "notes"}),
)
counts = _counts_at(surface, payload, "counts")
item_rows = _enrich_item_rows(surface, payload)
+ incomplete_rows = _enrich_incomplete_rows(surface, payload)
parked_rows = _enrich_parked_rows(surface, payload)
cognitive_rows = _enrich_cognitive_rows(surface, payload)
issues_filed = _int_at(surface, payload, "issues_filed", default=0)
@@ -237,6 +245,13 @@ def _render_enrich_report(payload: Mapping[str, object]) -> str:
lines.append(kernel.table(item_rows, indent=2).rstrip("\n"))
else:
lines.append("cognitive work — none (nothing new or changed)")
+ if incomplete_rows:
+ lines.append("")
+ lines.append(
+ f"incomplete — {_plural(len(incomplete_rows), 'item')} still raw "
+ "until every unit lands:"
+ )
+ lines.append(kernel.table(incomplete_rows, indent=2).rstrip("\n"))
lines.append("")
if parked_rows:
verb = "survives" if len(parked_rows) == 1 else "survive"
@@ -272,6 +287,70 @@ def _enrich_item_rows(surface: str, payload: Mapping[str, object]) -> list[list[
return rows
+# What an outstanding unit is waiting for, as prose. Statuses that are not
+# outstanding never reach this surface — the payload validation refuses them.
+_OUTSTANDING_STATUSES = frozenset(_PARKED_STATUSES | {Status.QUEUED})
+_NEED_NOUNS = {Need.TRANSCRIBE: "transcription", Need.EXTRACT: "extraction", Need.OCR: "OCR"}
+_STATUS_PHRASES = {
+ Status.QUEUED: "queued",
+ Status.WAITING: "waiting",
+ Status.BLOCKED: "blocked",
+ Status.ERROR: "in error",
+ Status.MANUAL: "needing a decision",
+}
+
+
+def _enrich_incomplete_rows(surface: str, payload: Mapping[str, object]) -> list[list[str]]:
+ rows = []
+ for i, entry in enumerate(_obj_list_at(surface, payload, "incomplete", required=False)):
+ where = f"incomplete[{i}]."
+ _check_keys(
+ surface,
+ entry,
+ required=frozenset({"item", "landed", "total", "outstanding"}),
+ where=where,
+ )
+ landed = _int_at(surface, entry, "landed", where)
+ total = _int_at(surface, entry, "total", where)
+ if landed > total:
+ _fail(surface, f"{where}landed ({landed}) exceeds total ({total})")
+ shape = f"{landed} of {_plural(total, 'unit')} landed"
+ outstanding = _enrich_outstanding(surface, entry, where)
+ rows.append([_str_at(surface, entry, "item", where), f"{shape} — {outstanding}"])
+ return rows
+
+
+def _enrich_outstanding(surface: str, entry: Mapping[str, object], where: str) -> str:
+ parts = []
+ for j, group in enumerate(_obj_list_at(surface, entry, "outstanding", required=True)):
+ gwhere = f"{where}outstanding[{j}]."
+ _check_keys(
+ surface,
+ group,
+ required=frozenset({"status", "count"}),
+ optional=frozenset({"needs"}),
+ where=gwhere,
+ )
+ status = _status_at(surface, group, "status", gwhere)
+ if status not in _OUTSTANDING_STATUSES:
+ allowed = ", ".join(sorted(s.value for s in _OUTSTANDING_STATUSES))
+ _fail(
+ surface,
+ f"{gwhere}status must be an outstanding status ({allowed}), got {status.value!r}",
+ )
+ phrase = _STATUS_PHRASES[status]
+ if "needs" in group:
+ need = _str_at(surface, group, "needs", gwhere)
+ if need not in {n.value for n in Need}:
+ options = ", ".join(n.value for n in Need)
+ _fail(surface, f"{gwhere}needs must be one of {options}, got {need!r}")
+ phrase += f" on {_NEED_NOUNS[Need(need)]}"
+ parts.append(f"{_int_at(surface, group, 'count', gwhere)} {phrase}")
+ if not parts:
+ _fail(surface, f"{where}outstanding must name at least one outstanding unit")
+ return ", ".join(parts)
+
+
def _enrich_parked_rows(surface: str, payload: Mapping[str, object]) -> list[list[str]]:
rows = []
for i, entry in enumerate(_obj_list_at(surface, payload, "parked", required=True)):
diff --git a/tests/drivers/conftest.py b/tests/drivers/conftest.py
index d0b6165..5d5b80a 100644
--- a/tests/drivers/conftest.py
+++ b/tests/drivers/conftest.py
@@ -1,15 +1,17 @@
"""Shared driver-test plumbing: fixture loading, fake transports, work units."""
+import base64
import contextlib
import json
import re
import socket
import threading
-from collections.abc import Iterator, Mapping
+from collections.abc import Iterator, Mapping, Sequence
from pathlib import Path
import pytest
+from dex_engine.drivers.gh import GhResult
from dex_engine.drivers.transport import HttpResponse
from dex_engine.pipeline.types import Format, Kind, Result, WorkUnit
from dex_engine.pipeline.urls import work_hash
@@ -81,6 +83,45 @@ def fake_transport():
return FakeTransport
+class FakeGh:
+ """args tuple -> GhResult; unexpected invocations are loud.
+
+ Shared because two drivers read the same gh seam: the github driver and
+ the file driver both fetch blob bytes through it.
+ """
+
+ def __init__(self, responses: Mapping[tuple[str, ...], GhResult]) -> None:
+ self.responses = responses
+ self.calls: list[tuple[str, ...]] = []
+
+ def __call__(self, args: Sequence[str]) -> GhResult:
+ key = tuple(args)
+ self.calls.append(key)
+ if key not in self.responses:
+ raise AssertionError(f"unexpected gh invocation {key!r}")
+ return self.responses[key]
+
+
+def gh_ok(stdout: str) -> GhResult:
+ return GhResult(returncode=0, stdout=stdout, stderr="")
+
+
+def gh_fail(stderr: str) -> GhResult:
+ return GhResult(returncode=1, stdout="", stderr=stderr)
+
+
+def gh_contents(data: bytes) -> GhResult:
+ """The contents-API payload shape: base64 `content` plus its encoding."""
+ return gh_ok(
+ json.dumps({"encoding": "base64", "content": base64.b64encode(data).decode("ascii")})
+ )
+
+
+def gh_matching_refs(*names: str) -> GhResult:
+ """A ``git/matching-refs`` page, in the API's own shape."""
+ return gh_ok(json.dumps([{"ref": name, "object": {"sha": "0" * 40}} for name in names]))
+
+
def _drain_request(conn: socket.socket) -> None:
"""Read the whole request — headers AND body — before answering.
diff --git a/tests/drivers/test_file.py b/tests/drivers/test_file.py
index 6b86c33..7711559 100644
--- a/tests/drivers/test_file.py
+++ b/tests/drivers/test_file.py
@@ -1,5 +1,7 @@
"""Tests for the file driver: format routing, honest parking, asset passthrough."""
+import json
+
import pytest
from dex_engine.capabilities import Capabilities
@@ -8,7 +10,16 @@
from dex_engine.pipeline.classify import ProviderInputError, ProviderUnavailableError
from dex_engine.pipeline.types import Config, Format, Kind, Need, Status
from tests.capabilities.conftest import FakeExtractor, fixture_bytes
-from tests.drivers.conftest import FakeTransport, make_unit, reason_of
+from tests.drivers.conftest import (
+ FakeGh,
+ FakeTransport,
+ gh_contents,
+ gh_fail,
+ gh_matching_refs,
+ gh_ok,
+ make_unit,
+ reason_of,
+)
PDF_URL = "https://example.test/whitepaper"
@@ -167,6 +178,111 @@ def test_server_lied_bytes_still_route_by_signature(self):
assert extractor.calls[0][1] is Format.DOCX
+class TestGithubBlobs:
+ """Repo-committed documents: bytes through gh, never the viewer page."""
+
+ URL = "https://github.com/acme/pipeline-kit/blob/main/docs/whitepaper.pdf"
+ CONTENTS = ("api", "repos/acme/pipeline-kit/contents/docs/whitepaper.pdf?ref=main")
+
+ def refuse_transport(self, url, *, method="GET"):
+ raise AssertionError(f"a blob URL must never be fetched over HTTP ({method} {url})")
+
+ def test_a_blob_pdf_extracts_through_the_gh_seam(self):
+ gh = FakeGh({self.CONTENTS: gh_contents(fixture_bytes("paper.pdf"))})
+ extractor = FakeExtractor()
+ d = FileDriver(capabilities=caps(extractor), transport=self.refuse_transport, gh=gh)
+ result = d.fetch(make_unit(self.URL, Kind.FILE, fmt=Format.PDF))
+ assert result.status is Status.DONE
+ assert extractor.calls[0][1] is Format.PDF
+ assert result.meta["title"] == "whitepaper.pdf"
+
+ def test_a_private_repo_403_classifies_blocked_not_dead(self):
+ gh = FakeGh({self.CONTENTS: gh_fail("gh: API rate limit exceeded (HTTP 403)")})
+ d = FileDriver(capabilities=caps(FakeExtractor()), transport=self.refuse_transport, gh=gh)
+ result = d.fetch(make_unit(self.URL, Kind.FILE, fmt=Format.PDF))
+ assert result.status is Status.BLOCKED
+
+ def test_an_oversize_blob_is_manual_with_the_clone_route(self):
+ gh = FakeGh({self.CONTENTS: gh_ok(json.dumps({"encoding": "none", "content": ""}))})
+ d = FileDriver(capabilities=caps(FakeExtractor()), transport=self.refuse_transport, gh=gh)
+ result = d.fetch(make_unit(self.URL, Kind.FILE, fmt=Format.PDF))
+ assert result.status is Status.MANUAL
+ assert "read it from a clone" in reason_of(result)
+
+ def test_a_slashed_branch_resolves_on_the_way_back_in(self):
+ # The github driver hands this URL over verbatim after re-detection,
+ # so the file driver has to settle the same ref/path boundary. Split
+ # at the first segment it would 404 — and a 404 on contents is
+ # terminally dead, which is the whole blocker the shared seam fixes.
+ repo = "repos/rust-lang/rust"
+ url = "https://github.com/rust-lang/rust/blob/automation/bors/auto/paper.pdf"
+ gh = FakeGh(
+ {
+ ("api", f"{repo}/contents/bors/auto/paper.pdf?ref=automation"): gh_fail(
+ "gh: Not Found (HTTP 404)"
+ ),
+ ("api", f"{repo}/git/matching-refs/heads/automation"): gh_matching_refs(
+ "refs/heads/automation/bors/auto"
+ ),
+ (
+ "api",
+ f"{repo}/contents/paper.pdf?ref=automation%2Fbors%2Fauto",
+ ): gh_contents(fixture_bytes("paper.pdf")),
+ }
+ )
+ extractor = FakeExtractor()
+ d = FileDriver(capabilities=caps(extractor), transport=self.refuse_transport, gh=gh)
+ result = d.fetch(make_unit(url, Kind.FILE, fmt=Format.PDF))
+ assert result.status is Status.DONE
+ # The name comes from the resolved split, not the guessed one.
+ assert result.meta["title"] == "paper.pdf"
+
+ def test_a_blob_with_no_signature_is_routed_by_its_name(self):
+ # A CSV carries no byte signature at all, so only the filename that
+ # came back with the bytes says what it is. Unnamed, these bytes are
+ # "unrecognized" and park — the committed table never reaches an
+ # extractor.
+ url = "https://github.com/acme/pipeline-kit/blob/main/data/runs.csv"
+ args = ("api", "repos/acme/pipeline-kit/contents/data/runs.csv?ref=main")
+ gh = FakeGh({args: gh_contents(b"run,status\n1,done\n2,dead\n")})
+ extractor = FakeExtractor("csv-builtin", formats=frozenset({Format.CSV}))
+ d = FileDriver(capabilities=caps(extractor), transport=self.refuse_transport, gh=gh)
+ result = d.fetch(make_unit(url, Kind.FILE)) # no declared format to fall back on
+ assert result.status is Status.DONE
+ assert extractor.calls[0][1] is Format.CSV
+
+ def test_an_lfs_pointer_blob_parks_rather_than_extracting_its_stand_in_text(self):
+ # The github driver re-detects a `.pdf` blob by name, pointer bytes
+ # or not — it cannot tell. This is where the 130 bytes of
+ # `oid sha256:…` stop, before any extractor is handed them.
+ pointer = (
+ b"version https://git-lfs.github.com/spec/v1\n"
+ b"oid sha256:08709a87567d8311d6fd29c4f4a5386801153e71450e628c4a5a5d7e85feda8b\n"
+ b"size 7416886\n"
+ )
+ gh = FakeGh({self.CONTENTS: gh_contents(pointer)})
+ extractor = FakeExtractor()
+ d = FileDriver(capabilities=caps(extractor), transport=self.refuse_transport, gh=gh)
+ result = d.fetch(make_unit(self.URL, Kind.FILE, fmt=Format.PDF))
+ assert result.status is Status.MANUAL
+ assert "git lfs pull" in reason_of(result)
+ assert extractor.calls == []
+
+ def test_html_committed_to_a_repo_never_bounces_back_to_web(self):
+ # These bytes came from the contents API, not from a viewer page:
+ # an HTML file in a repo is a file, and re-routing it to web would
+ # fetch the viewer and re-detect straight back — a loop park.
+ html = b"\na committed page"
+ url = "https://github.com/acme/pipeline-kit/blob/main/docs/index.html"
+ args = ("api", "repos/acme/pipeline-kit/contents/docs/index.html?ref=main")
+ gh = FakeGh({args: gh_contents(html)})
+ d = FileDriver(capabilities=caps(FakeExtractor()), transport=self.refuse_transport, gh=gh)
+ result = d.fetch(make_unit(url, Kind.FILE))
+ assert result.redetect is None
+ assert result.status is Status.MANUAL
+ assert "unrecognized file format" in reason_of(result)
+
+
class TestRedetection:
HTML = b"\na page pretending to be a paper"
diff --git a/tests/drivers/test_gh.py b/tests/drivers/test_gh.py
new file mode 100644
index 0000000..b42c932
--- /dev/null
+++ b/tests/drivers/test_gh.py
@@ -0,0 +1,339 @@
+"""Tests for drivers/gh.py: the blob round trip both drivers share.
+
+The ref/path boundary is pinned here, at the seam, rather than in either
+driver: a copy in the github driver's tests would let the file driver's own
+blob fetches regress to the 404-into-`dead` this resolution exists to stop.
+"""
+
+import json
+
+import pytest
+
+from dex_engine.drivers.gh import Blob, BlobRef, blob_ref, fetch_blob, gh_api, gh_api_list
+from dex_engine.pipeline.classify import Classification
+from dex_engine.pipeline.types import Status
+from tests.drivers.conftest import FakeGh, gh_contents, gh_fail, gh_matching_refs, gh_ok
+
+
+def blob_of(url: str) -> BlobRef:
+ ref = blob_ref(url)
+ assert ref is not None
+ return ref
+
+
+def fetched(url: str, gh: FakeGh) -> Blob | Classification:
+ return fetch_blob(gh, blob_of(url))
+
+
+def bytes_of(outcome: Blob | Classification) -> bytes:
+ assert isinstance(outcome, Blob)
+ return outcome.data
+
+
+def status_of(outcome: Blob | Classification) -> Status:
+ assert isinstance(outcome, Classification)
+ return outcome.status
+
+
+class TestBlobRef:
+ def test_a_blob_url_yields_the_repo_and_the_unsplit_tail(self):
+ ref = blob_of("https://github.com/acme/kit/blob/main/docs/a.md")
+ assert (ref.owner, ref.repo) == ("acme", "kit")
+ assert ref.tail == ("main", "docs", "a.md")
+
+ @pytest.mark.parametrize(
+ "url",
+ [
+ "https://github.com/acme/kit",
+ "https://github.com/acme/kit/issues/7",
+ "https://gist.github.com/acme/deadbeef",
+ "https://example.test/acme/kit/blob/main/a.md",
+ ],
+ )
+ def test_every_other_shape_is_not_a_blob(self, url):
+ assert blob_ref(url) is None
+
+
+class TestBlobBytes:
+ URL = "https://github.com/acme/kit/blob/main/src/detect.py"
+ CONTENTS = ("api", "repos/acme/kit/contents/src/detect.py?ref=main")
+
+ def test_the_bytes_and_the_path_come_back_together(self):
+ # raw.githubusercontent.com is unauthenticated: it 404s for every
+ # private-repo blob however the machine is signed in, and that 404
+ # classified live content as dead. gh carries the auth.
+ outcome = fetched(self.URL, FakeGh({self.CONTENTS: gh_contents(b"def detect(): ...")}))
+ assert isinstance(outcome, Blob)
+ assert outcome.path == "src/detect.py"
+ assert outcome.data == b"def detect(): ..."
+
+ @pytest.mark.parametrize("spelling", ["a b.md", "a%20b.md"])
+ def test_a_path_reaches_the_api_encoded_exactly_once(self, spelling):
+ args = ("api", "repos/acme/kit/contents/docs/a%20b.md?ref=main")
+ gh = FakeGh({args: gh_contents(b"hello")})
+ assert bytes_of(fetched(f"https://github.com/acme/kit/blob/main/docs/{spelling}", gh))
+
+ def test_an_oversize_blob_is_manual_never_dead(self):
+ # Over 1MB the contents API answers `encoding: "none"` with an empty
+ # body — the file is there, just not inline.
+ payload = gh_ok(json.dumps({"encoding": "none", "content": "", "size": 4645520}))
+ outcome = fetched(self.URL, FakeGh({self.CONTENTS: payload}))
+ assert status_of(outcome) is Status.MANUAL
+ assert isinstance(outcome, Classification)
+ assert "larger than the contents API serves inline" in str(outcome.reason)
+
+ def test_a_403_stays_blocked(self):
+ gh = FakeGh({self.CONTENTS: gh_fail("gh: API rate limit exceeded (HTTP 403)")})
+ assert status_of(fetched(self.URL, gh)) is Status.BLOCKED
+
+
+class TestRefBoundary:
+ """Where the ref stops and the path starts is not in the URL — resolve it.
+
+ raw.githubusercontent.com settled the boundary server-side. The contents
+ API takes the two halves apart, so a slashed branch or a `refs/heads/`
+ permalink sent a wrong ref AND a wrong path, 404'd, and ledgered live
+ files `dead`.
+ """
+
+ REPO = "repos/rust-lang/rust"
+
+ def test_a_slashed_branch_resolves_through_the_repos_own_refs(self):
+ gh = FakeGh(
+ {
+ # The one-segment guess: branch `automation`, path `bors/…`.
+ ("api", f"{self.REPO}/contents/bors/auto/README.md?ref=automation"): gh_fail(
+ "gh: Not Found (HTTP 404)"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/heads/automation"): gh_matching_refs(
+ "refs/heads/automation/bors/auto",
+ "refs/heads/automation/bors/auto-merge",
+ "refs/heads/automation/bors/try",
+ ),
+ (
+ "api",
+ f"{self.REPO}/contents/README.md?ref=automation%2Fbors%2Fauto",
+ ): gh_contents(b"# The Rust Programming Language"),
+ }
+ )
+ url = "https://github.com/rust-lang/rust/blob/automation/bors/auto/README.md"
+ outcome = fetched(url, gh)
+ assert isinstance(outcome, Blob)
+ # The path the WINNING split left, not the guess's: whatever sniffs
+ # these bytes is handed the real filename.
+ assert outcome.path == "README.md"
+ assert b"Rust" in outcome.data
+
+ def test_a_sibling_ref_never_claims_the_path_by_string_prefix(self):
+ # The repo has `automation/bors`; the URL's branch is
+ # `automation/bors-next`, which the repo has NOT pushed. As a string
+ # the short one is a prefix of the URL, and taking it would send
+ # `?ref=automation/bors` with a path of `-next/README.md` — a request
+ # this fake would not even recognize. Segment-wise it does not match
+ # at all, so nothing re-splits and the guess's 404 stands.
+ gh = FakeGh(
+ {
+ ("api", f"{self.REPO}/contents/bors-next/README.md?ref=automation"): gh_fail(
+ "gh: Not Found (HTTP 404)"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/heads/automation"): gh_matching_refs(
+ "refs/heads/automation/bors"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/tags/automation"): gh_matching_refs(),
+ }
+ )
+ url = "https://github.com/rust-lang/rust/blob/automation/bors-next/README.md"
+ assert status_of(fetched(url, gh)) is Status.DEAD
+
+ def test_the_right_sibling_still_wins_when_the_repo_has_both(self):
+ gh = FakeGh(
+ {
+ ("api", f"{self.REPO}/contents/bors/README.md?ref=automation"): gh_fail(
+ "gh: Not Found (HTTP 404)"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/heads/automation"): gh_matching_refs(
+ "refs/heads/automation/bors-next", "refs/heads/automation/bors"
+ ),
+ ("api", f"{self.REPO}/contents/README.md?ref=automation%2Fbors"): gh_contents(
+ b"ok"
+ ),
+ }
+ )
+ url = "https://github.com/rust-lang/rust/blob/automation/bors/README.md"
+ assert bytes_of(fetched(url, gh)) == b"ok"
+
+ def test_the_longest_matching_ref_wins(self):
+ gh = FakeGh(
+ {
+ ("api", f"{self.REPO}/contents/1.2/docs/x.md?ref=release"): gh_fail(
+ "gh: Not Found (HTTP 404)"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/heads/release"): gh_matching_refs(
+ "refs/heads/release", "refs/heads/release/1.2"
+ ),
+ ("api", f"{self.REPO}/contents/docs/x.md?ref=release%2F1.2"): gh_contents(b"ok"),
+ }
+ )
+ url = "https://github.com/rust-lang/rust/blob/release/1.2/docs/x.md"
+ assert bytes_of(fetched(url, gh)) == b"ok"
+
+ def test_a_ref_that_would_swallow_the_whole_tail_is_not_a_split(self):
+ # Branch `docs/x.md` exists, but then the URL addresses no file at
+ # all — the guess (and its 404) stands.
+ gh = FakeGh(
+ {
+ ("api", f"{self.REPO}/contents/x.md?ref=docs"): gh_fail("gh: Not Found (HTTP 404)"),
+ ("api", f"{self.REPO}/git/matching-refs/heads/docs"): gh_matching_refs(
+ "refs/heads/docs/x.md"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/tags/docs"): gh_matching_refs(),
+ }
+ )
+ url = "https://github.com/rust-lang/rust/blob/docs/x.md"
+ assert status_of(fetched(url, gh)) is Status.DEAD
+
+ def test_a_slashed_tag_resolves_after_the_branches_come_back_empty(self):
+ gh = FakeGh(
+ {
+ ("api", f"{self.REPO}/contents/9/README.md?ref=v1"): gh_fail(
+ "gh: Not Found (HTTP 404)"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/heads/v1"): gh_matching_refs(),
+ ("api", f"{self.REPO}/git/matching-refs/tags/v1"): gh_matching_refs(
+ "refs/tags/v1/9"
+ ),
+ ("api", f"{self.REPO}/contents/README.md?ref=v1%2F9"): gh_contents(b"ok"),
+ }
+ )
+ url = "https://github.com/rust-lang/rust/blob/v1/9/README.md"
+ assert bytes_of(fetched(url, gh)) == b"ok"
+
+ @pytest.mark.parametrize(
+ ("tail", "endpoint"),
+ [
+ ("refs/heads/master/README", "contents/README?ref=refs%2Fheads%2Fmaster"),
+ ("refs/tags/v1.0.0/docs/x.md", "contents/docs/x.md?ref=refs%2Ftags%2Fv1.0.0"),
+ ],
+ )
+ def test_the_refs_prefix_permalink_form_needs_no_lookup(self, tail, endpoint):
+ # GitHub code search returns four figures of `blob/refs/heads/` links;
+ # the form names its own namespace, so the split is free.
+ gh = FakeGh({("api", f"{self.REPO}/{endpoint}"): gh_contents(b"Hello World!")})
+ assert bytes_of(fetched(f"https://github.com/rust-lang/rust/blob/{tail}", gh))
+ assert len(gh.calls) == 1
+
+ def test_a_refs_prefix_permalink_on_a_slashed_branch_still_resolves(self):
+ gh = FakeGh(
+ {
+ (
+ "api",
+ f"{self.REPO}/contents/bors/auto/README.md?ref=refs%2Fheads%2Fautomation",
+ ): gh_fail("gh: Not Found (HTTP 404)"),
+ ("api", f"{self.REPO}/git/matching-refs/heads/automation"): gh_matching_refs(
+ "refs/heads/automation/bors/auto"
+ ),
+ (
+ "api",
+ f"{self.REPO}/contents/README.md?ref=refs%2Fheads%2Fautomation%2Fbors%2Fauto",
+ ): gh_contents(b"ok"),
+ }
+ )
+ url = "https://github.com/rust-lang/rust/blob/refs/heads/automation/bors/auto/README.md"
+ assert bytes_of(fetched(url, gh)) == b"ok"
+
+ def test_a_sha_ref_needs_no_lookup(self):
+ sha = "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d"
+ args = ("api", f"repos/acme/kit/contents/src/detect.py?ref={sha}")
+ gh = FakeGh({args: gh_contents(b"def detect(): ...")})
+ assert bytes_of(fetched(f"https://github.com/acme/kit/blob/{sha}/src/detect.py", gh))
+ assert len(gh.calls) == 1 # the guess was right; no ref lookup was spent
+
+ def test_a_genuinely_missing_path_is_still_dead(self):
+ # The floor the resolution must not break: no ref rescues a file
+ # that is not there, and the unit must not go unclassifiable.
+ gh = FakeGh(
+ {
+ ("api", f"{self.REPO}/contents/no-such-file.txt?ref=master"): gh_fail(
+ "gh: Not Found (HTTP 404)"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/heads/master"): gh_matching_refs(
+ "refs/heads/master"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/tags/master"): gh_matching_refs(),
+ }
+ )
+ url = "https://github.com/rust-lang/rust/blob/master/no-such-file.txt"
+ assert status_of(fetched(url, gh)) is Status.DEAD
+
+ def test_a_missing_path_on_a_resolved_slashed_branch_is_dead(self):
+ gh = FakeGh(
+ {
+ ("api", f"{self.REPO}/contents/bors/auto/nope.md?ref=automation"): gh_fail(
+ "gh: Not Found (HTTP 404)"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/heads/automation"): gh_matching_refs(
+ "refs/heads/automation/bors/auto"
+ ),
+ ("api", f"{self.REPO}/contents/nope.md?ref=automation%2Fbors%2Fauto"): gh_fail(
+ "gh: Not Found (HTTP 404)"
+ ),
+ }
+ )
+ url = "https://github.com/rust-lang/rust/blob/automation/bors/auto/nope.md"
+ assert status_of(fetched(url, gh)) is Status.DEAD
+
+ def test_a_failing_ref_lookup_leaves_the_original_classification(self):
+ gh = FakeGh(
+ {
+ ("api", f"{self.REPO}/contents/bors/auto/README.md?ref=automation"): gh_fail(
+ "gh: Not Found (HTTP 404)"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/heads/automation"): gh_fail(
+ "gh: API rate limit exceeded (HTTP 403)"
+ ),
+ ("api", f"{self.REPO}/git/matching-refs/tags/automation"): gh_fail(
+ "gh: API rate limit exceeded (HTTP 403)"
+ ),
+ }
+ )
+ url = "https://github.com/rust-lang/rust/blob/automation/bors/auto/README.md"
+ assert status_of(fetched(url, gh)) is Status.DEAD
+
+ def test_a_non_404_failure_never_spends_a_ref_lookup(self):
+ gh = FakeGh(
+ {
+ ("api", f"{self.REPO}/contents/bors/auto/README.md?ref=automation"): gh_fail(
+ "gh: API rate limit exceeded (HTTP 403)"
+ )
+ }
+ )
+ url = "https://github.com/rust-lang/rust/blob/automation/bors/auto/README.md"
+ assert status_of(fetched(url, gh)) is Status.BLOCKED
+ assert len(gh.calls) == 1
+
+
+class TestGhApi:
+ def test_a_timeout_is_blocked_with_no_http_code_to_read(self):
+ gh = FakeGh({("api", "repos/acme/kit"): gh_fail("gh timed out after 120s")})
+ outcome = gh_api(gh, "repos/acme/kit")
+ assert isinstance(outcome, Classification)
+ assert outcome.status is Status.BLOCKED
+
+ def test_unparseable_json_is_blocked(self):
+ gh = FakeGh({("api", "repos/acme/kit"): gh_ok("oops")})
+ outcome = gh_api(gh, "repos/acme/kit")
+ assert isinstance(outcome, Classification)
+ assert "unparseable JSON" in str(outcome.reason)
+
+ def test_an_array_where_an_object_belongs_is_blocked(self):
+ gh = FakeGh({("api", "repos/acme/kit"): gh_ok("[]")})
+ outcome = gh_api(gh, "repos/acme/kit")
+ assert isinstance(outcome, Classification)
+ assert "unexpected shape" in str(outcome.reason)
+
+ @pytest.mark.parametrize("payload", [gh_fail("boom"), gh_ok("not json"), gh_ok("{}")])
+ def test_a_list_call_answers_empty_for_every_failure(self, payload):
+ # Both callers read an empty answer as "no extra information", never
+ # as a failure: a profile's repo listing, and a ref lookup that must
+ # leave the contents call's own classification standing.
+ assert gh_api_list(FakeGh({("api", "x"): payload}), "x") == []
diff --git a/tests/drivers/test_github.py b/tests/drivers/test_github.py
index 71ae31a..69316de 100644
--- a/tests/drivers/test_github.py
+++ b/tests/drivers/test_github.py
@@ -1,52 +1,34 @@
-"""Tests for drivers/github.py: URL-shape routing and classified gh failures."""
+"""Tests for drivers/github.py: URL-shape routing, and what blob bytes mean.
+
+The blob round trip itself — the ref/path boundary, the contents endpoint,
+the oversize park — belongs to the shared seam and is pinned in test_gh.py.
+What is here is the driver's own judgment: fence it, re-detect it, or park.
+"""
-import base64
import json
-from collections.abc import Sequence
import pytest
-from dex_engine.drivers.github import GhResult, GitHubDriver
+from dex_engine.drivers.github import GitHubDriver
from dex_engine.pipeline.detect import detect_kind
from dex_engine.pipeline.registry import DRIVERS
-from dex_engine.pipeline.types import Kind, Status
-from tests.drivers.conftest import body_of, fixture_text, make_unit, reason_of
-
-
-class FakeGh:
- """args tuple -> GhResult; unexpected invocations are loud."""
-
- def __init__(self, responses: dict[tuple[str, ...], GhResult]) -> None:
- self.responses = responses
- self.calls: list[tuple[str, ...]] = []
-
- def __call__(self, args: Sequence[str]) -> GhResult:
- key = tuple(args)
- self.calls.append(key)
- if key not in self.responses:
- raise AssertionError(f"unexpected gh invocation {key!r}")
- return self.responses[key]
-
-
-def ok(stdout: str) -> GhResult:
- return GhResult(returncode=0, stdout=stdout, stderr="")
-
-
-def fail(stderr: str) -> GhResult:
- return GhResult(returncode=1, stdout="", stderr=stderr)
+from dex_engine.pipeline.types import Format, Kind, Status
+from tests.drivers.conftest import (
+ FakeGh,
+ body_of,
+ fixture_text,
+ gh_contents,
+ gh_fail,
+ gh_ok,
+ make_unit,
+ reason_of,
+)
def driver_for(gh_responses: dict | None = None) -> GitHubDriver:
return GitHubDriver(gh=FakeGh(gh_responses or {}))
-def contents(data: bytes) -> GhResult:
- """The contents-API payload shape: base64 `content` plus its encoding."""
- return ok(
- json.dumps({"encoding": "base64", "content": base64.b64encode(data).decode("ascii")})
- )
-
-
README_ARGS = (
"api",
"repos/acme/pipeline-kit/readme",
@@ -112,8 +94,8 @@ class TestRepo:
def test_repo_meta_and_readme_body(self):
driver = driver_for(
{
- ("api", "repos/acme/pipeline-kit"): ok(fixture_text("github", "repo.json")),
- README_ARGS: ok(fixture_text("github", "readme.md")),
+ ("api", "repos/acme/pipeline-kit"): gh_ok(fixture_text("github", "repo.json")),
+ README_ARGS: gh_ok(fixture_text("github", "readme.md")),
}
)
result = driver.fetch(make_unit("https://github.com/acme/pipeline-kit", Kind.GITHUB))
@@ -127,8 +109,8 @@ def test_repo_meta_and_readme_body(self):
def test_missing_readme_yields_placeholder_body(self):
driver = driver_for(
{
- ("api", "repos/acme/pipeline-kit"): ok(fixture_text("github", "repo.json")),
- README_ARGS: fail("gh: Not Found (HTTP 404)"),
+ ("api", "repos/acme/pipeline-kit"): gh_ok(fixture_text("github", "repo.json")),
+ README_ARGS: gh_fail("gh: Not Found (HTTP 404)"),
}
)
result = driver.fetch(make_unit("https://github.com/acme/pipeline-kit", Kind.GITHUB))
@@ -136,13 +118,13 @@ def test_missing_readme_yields_placeholder_body(self):
assert body_of(result) == "(no README)"
def test_deleted_repo_is_dead(self):
- driver = driver_for({("api", "repos/acme/gone"): fail("gh: Not Found (HTTP 404)")})
+ driver = driver_for({("api", "repos/acme/gone"): gh_fail("gh: Not Found (HTTP 404)")})
result = driver.fetch(make_unit("https://github.com/acme/gone", Kind.GITHUB))
assert result.status is Status.DEAD
def test_rate_limited_api_is_blocked(self):
driver = driver_for(
- {("api", "repos/acme/pipeline-kit"): fail("gh: API rate limit exceeded (HTTP 403)")}
+ {("api", "repos/acme/pipeline-kit"): gh_fail("gh: API rate limit exceeded (HTTP 403)")}
)
result = driver.fetch(make_unit("https://github.com/acme/pipeline-kit", Kind.GITHUB))
assert result.status is Status.BLOCKED
@@ -150,7 +132,8 @@ def test_rate_limited_api_is_blocked(self):
def test_gh_timeout_shape_classifies_blocked(self):
# run_gh converts TimeoutExpired to this GhResult shape — a hung gh
# is the world misbehaving, never an engine error.
- driver = driver_for({("api", "repos/acme/pipeline-kit"): fail("gh timed out after 120s")})
+ timeout = gh_fail("gh timed out after 120s")
+ driver = driver_for({("api", "repos/acme/pipeline-kit"): timeout})
result = driver.fetch(make_unit("https://github.com/acme/pipeline-kit", Kind.GITHUB))
assert result.status is Status.BLOCKED
assert "timed out" in reason_of(result)
@@ -158,7 +141,7 @@ def test_gh_timeout_shape_classifies_blocked(self):
def test_gh_failure_without_a_code_is_blocked_with_scrubbed_reason(self):
driver = driver_for(
{
- ("api", "repos/acme/pipeline-kit"): fail(
+ ("api", "repos/acme/pipeline-kit"): gh_fail(
"error connecting to api.github.com from /Users/owner/base"
)
}
@@ -172,8 +155,8 @@ class TestProfile:
def test_profile_with_top_repos_by_stars(self):
driver = driver_for(
{
- ("api", "users/octomaint"): ok(fixture_text("github", "user.json")),
- ("api", "users/octomaint/repos?sort=pushed&per_page=100"): ok(
+ ("api", "users/octomaint"): gh_ok(fixture_text("github", "user.json")),
+ ("api", "users/octomaint/repos?sort=pushed&per_page=100"): gh_ok(
fixture_text("github", "user-repos.json")
),
}
@@ -188,8 +171,8 @@ def test_profile_with_top_repos_by_stars(self):
def test_repo_listing_failure_does_not_fail_the_profile(self):
driver = driver_for(
{
- ("api", "users/octomaint"): ok(fixture_text("github", "user.json")),
- ("api", "users/octomaint/repos?sort=pushed&per_page=100"): fail(
+ ("api", "users/octomaint"): gh_ok(fixture_text("github", "user.json")),
+ ("api", "users/octomaint/repos?sort=pushed&per_page=100"): gh_fail(
"gh: API rate limit exceeded (HTTP 403)"
),
}
@@ -202,7 +185,7 @@ def test_repo_listing_failure_does_not_fail_the_profile(self):
class TestGist:
def test_gist_files_render_fenced(self):
driver = driver_for(
- {("api", "gists/abc123def456"): ok(fixture_text("github", "gist.json"))}
+ {("api", "gists/abc123def456"): gh_ok(fixture_text("github", "gist.json"))}
)
url = "https://gist.github.com/octomaint/abc123def456"
result = driver.fetch(make_unit(url, Kind.GITHUB))
@@ -229,7 +212,8 @@ def test_gist_index_is_skipped_with_reason(self):
def test_bare_gist_id_links_fetch_by_id(self, gist_id):
# Legacy gist.github.com/ share links (no username segment)
# still resolve — the API call only ever needed the id.
- driver = driver_for({("api", f"gists/{gist_id}"): ok(fixture_text("github", "gist.json"))})
+ gist = gh_ok(fixture_text("github", "gist.json"))
+ driver = driver_for({("api", f"gists/{gist_id}"): gist})
result = driver.fetch(make_unit(f"https://gist.github.com/{gist_id}", Kind.GITHUB))
assert result.status is Status.DONE
assert "### compact.py" in body_of(result)
@@ -237,9 +221,8 @@ def test_bare_gist_id_links_fetch_by_id(self, gist_id):
class TestIssue:
def test_issue_title_and_body(self):
- driver = driver_for(
- {("api", "repos/acme/pipeline-kit/issues/42"): ok(fixture_text("github", "issue.json"))}
- )
+ issue = gh_ok(fixture_text("github", "issue.json"))
+ driver = driver_for({("api", "repos/acme/pipeline-kit/issues/42"): issue})
url = "https://github.com/acme/pipeline-kit/issues/42"
result = driver.fetch(make_unit(url, Kind.GITHUB))
assert result.status is Status.DONE
@@ -247,9 +230,8 @@ def test_issue_title_and_body(self):
assert "Cloudflare 403" in body_of(result)
def test_pull_urls_route_through_the_issues_api(self):
- gh = FakeGh(
- {("api", "repos/acme/pipeline-kit/issues/7"): ok(fixture_text("github", "issue.json"))}
- )
+ issue = gh_ok(fixture_text("github", "issue.json"))
+ gh = FakeGh({("api", "repos/acme/pipeline-kit/issues/7"): issue})
driver = GitHubDriver(gh=gh)
url = "https://github.com/acme/pipeline-kit/pull/7"
assert driver.fetch(make_unit(url, Kind.GITHUB)).status is Status.DONE
@@ -263,314 +245,109 @@ def test_blob_fetches_through_the_authenticated_gh_seam_and_fences(self):
# raw.githubusercontent.com is unauthenticated: it 404s for every
# private-repo blob however the machine is signed in, and that 404
# classified live content as dead. gh carries the auth.
- driver = driver_for({self.CONTENTS: contents(b"def detect(): ...")})
+ driver = driver_for({self.CONTENTS: gh_contents(b"def detect(): ...")})
result = driver.fetch(make_unit(self.URL, Kind.GITHUB))
assert result.status is Status.DONE
assert result.meta["file"] == "src/detect.py"
assert body_of(result) == "```\ndef detect(): ...\n```"
- @pytest.mark.parametrize("spelling", ["a b.md", "a%20b.md"])
- def test_blob_path_reaches_the_api_encoded_exactly_once(self, spelling):
- args = ("api", "repos/acme/pipeline-kit/contents/docs/a%20b.md?ref=main")
- gh = FakeGh({args: contents(b"hello")})
- url = f"https://github.com/acme/pipeline-kit/blob/main/docs/{spelling}"
- assert GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB)).status is Status.DONE
-
def test_missing_path_is_dead_through_the_gh_404(self):
# The 404 sends the driver to look for a longer ref first; `main` is
# the whole ref, so nothing re-splits and the 404 stands.
driver = driver_for(
{
- self.CONTENTS: fail("gh: Not Found (HTTP 404)"),
- ("api", "repos/acme/pipeline-kit/git/matching-refs/heads/main"): ok(
+ self.CONTENTS: gh_fail("gh: Not Found (HTTP 404)"),
+ ("api", "repos/acme/pipeline-kit/git/matching-refs/heads/main"): gh_ok(
json.dumps([{"ref": "refs/heads/main"}])
),
- ("api", "repos/acme/pipeline-kit/git/matching-refs/tags/main"): ok("[]"),
+ ("api", "repos/acme/pipeline-kit/git/matching-refs/tags/main"): gh_ok("[]"),
}
)
assert driver.fetch(make_unit(self.URL, Kind.GITHUB)).status is Status.DEAD
- def test_a_document_blob_parks_manual_naming_its_format(self):
+ def test_a_document_blob_redetects_to_file_work(self):
# A real ledger PDF blob fenced 40k characters of
- # replacement-character soup and went done.
- driver = driver_for({self.CONTENTS: contents(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n1 0 obj")})
+ # replacement-character soup and went done. It is not source — but
+ # it IS extractable, now that the file driver reads blob bytes
+ # through the same authenticated API.
+ driver = driver_for({self.CONTENTS: gh_contents(b"%PDF-1.7\n%\xe2\xe3\xcf\xd3\n1 0 obj")})
result = driver.fetch(make_unit(self.URL, Kind.GITHUB))
- assert result.status is Status.MANUAL
- assert "is a pdf document" in reason_of(result)
+ assert result.status is Status.QUEUED
+ assert result.redetect is not None
+ assert result.redetect.kind is Kind.FILE
+ assert result.redetect.format is Format.PDF
def test_an_unrecognized_binary_blob_parks_manual_never_fenced(self):
- driver = driver_for({self.CONTENTS: contents(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")})
+ driver = driver_for({self.CONTENTS: gh_contents(b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR")})
result = driver.fetch(make_unit(self.URL, Kind.GITHUB))
assert result.status is Status.MANUAL
assert "binary, not UTF-8 text" in reason_of(result)
assert result.body is None
def test_utf8_source_with_non_ascii_still_fences(self):
- driver = driver_for({self.CONTENTS: contents("# naïve — résumé\n".encode())})
+ driver = driver_for({self.CONTENTS: gh_contents("# naïve — résumé\n".encode())})
result = driver.fetch(make_unit(self.URL, Kind.GITHUB))
assert result.status is Status.DONE
assert "naïve — résumé" in body_of(result)
- def test_an_lfs_pointer_parks_manual_instead_of_fencing_its_stand_in_text(self):
+ def test_an_lfs_pointer_is_never_fenced_as_the_document_it_stands_for(self):
# An unsmudged LFS pointer is honest UTF-8 with no signature, so an
# unnamed sniff let 130 bytes of `oid sha256:…` fence and ledger
- # `done` as though it were the document it points at.
+ # `done` as though it were the document it points at. Named, it is
+ # pdf work like any other committed pdf — and the file driver, which
+ # fetches these same bytes back, is where a pointer is finally
+ # parked, before an extractor is handed the stand-in text.
pointer = (
b"version https://git-lfs.github.com/spec/v1\n"
b"oid sha256:08709a87567d8311d6fd29c4f4a5386801153e71450e628c4a5a5d7e85feda8b\n"
b"size 7416886\n"
)
args = ("api", "repos/acme/pipeline-kit/contents/docs/sicp.pdf?ref=main")
- driver = driver_for({args: contents(pointer)})
+ driver = driver_for({args: gh_contents(pointer)})
url = "https://github.com/acme/pipeline-kit/blob/main/docs/sicp.pdf"
result = driver.fetch(make_unit(url, Kind.GITHUB))
- assert result.status is Status.MANUAL
- assert "is a pdf document" in reason_of(result)
assert result.body is None
-
- def test_a_committed_csv_parks_for_the_extractor_rather_than_fencing(self):
- # Judgment call: a CSV is text and would fence, but it has no
- # signature either, so allowing it to fence is what lets an LFS
- # pointer for a .csv through. One rule — an extractable document
- # parks for capture — and csv-builtin then renders a real table
- # instead of a fence truncated at 40k characters.
+ assert result.redetect is not None
+ assert result.redetect.format is Format.PDF
+
+ def test_a_committed_csv_goes_to_the_extractor_rather_than_fencing(self):
+ # A CSV has no signature either, so only the name catches it — and
+ # letting text-shaped documents fence is exactly what let an LFS
+ # pointer for a .csv through. It re-detects like every other
+ # document rather than parking: an extractor reads the committed
+ # bytes into a real table, where a fence would truncate at 40k
+ # characters and read as a wall of commas.
args = ("api", "repos/acme/pipeline-kit/contents/data/runs.csv?ref=main")
- driver = driver_for({args: contents(b"run,status\n1,done\n2,dead\n")})
+ driver = driver_for({args: gh_contents(b"run,status\n1,done\n2,dead\n")})
url = "https://github.com/acme/pipeline-kit/blob/main/data/runs.csv"
result = driver.fetch(make_unit(url, Kind.GITHUB))
- assert result.status is Status.MANUAL
- assert "is a csv document" in reason_of(result)
+ assert result.status is Status.QUEUED
+ assert result.redetect is not None
+ assert result.redetect.kind is Kind.FILE
+ assert result.redetect.format is Format.CSV
@pytest.mark.parametrize("path", ["src/detect.py", "README.md", "Makefile", "docs/notes.txt"])
def test_source_and_prose_extensions_still_fence(self, path):
# The extension fallback only knows Format values: nothing a repo
# actually holds as source or prose is diverted by naming the file.
args = ("api", f"repos/acme/pipeline-kit/contents/{path}?ref=main")
- driver = driver_for({args: contents(b"def detect(): ...")})
+ driver = driver_for({args: gh_contents(b"def detect(): ...")})
url = f"https://github.com/acme/pipeline-kit/blob/main/{path}"
result = driver.fetch(make_unit(url, Kind.GITHUB))
assert result.status is Status.DONE
assert body_of(result) == "```\ndef detect(): ...\n```"
- def test_a_sha_ref_needs_no_lookup(self):
- sha = "7fd1a60b01f91b314f59955a4e4d4e80d8edf11d"
- args = ("api", f"repos/acme/pipeline-kit/contents/src/detect.py?ref={sha}")
- gh = FakeGh({args: contents(b"def detect(): ...")})
- url = f"https://github.com/acme/pipeline-kit/blob/{sha}/src/detect.py"
- assert GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB)).status is Status.DONE
- assert len(gh.calls) == 1 # the guess was right; no ref lookup was spent
-
def test_oversize_blob_parks_manual_never_dead(self):
# Over 1MB the contents API answers `encoding: "none"` with an empty
# body — the file is there, just not inline.
driver = driver_for(
- {self.CONTENTS: ok(json.dumps({"encoding": "none", "content": "", "size": 4645520}))}
+ {self.CONTENTS: gh_ok(json.dumps({"encoding": "none", "content": "", "size": 4645520}))}
)
result = driver.fetch(make_unit(self.URL, Kind.GITHUB))
assert result.status is Status.MANUAL
assert "larger than the contents API serves inline" in reason_of(result)
-def matching_refs(*names: str) -> GhResult:
- """A ``git/matching-refs`` page, in the API's own shape."""
- return ok(json.dumps([{"ref": name, "object": {"sha": "0" * 40}} for name in names]))
-
-
-class TestBlobRefBoundary:
- """Where the ref stops and the path starts is not in the URL — resolve it.
-
- raw.githubusercontent.com settled the boundary server-side. The contents
- API takes the two halves apart, so a slashed branch or a `refs/heads/`
- permalink sent a wrong ref AND a wrong path, 404'd, and ledgered live
- files `dead`.
- """
-
- REPO = "repos/rust-lang/rust"
-
- def test_a_slashed_branch_resolves_through_the_repos_own_refs(self):
- gh = FakeGh(
- {
- # The one-segment guess: branch `automation`, path `bors/…`.
- ("api", f"{self.REPO}/contents/bors/auto/README.md?ref=automation"): fail(
- "gh: Not Found (HTTP 404)"
- ),
- ("api", f"{self.REPO}/git/matching-refs/heads/automation"): matching_refs(
- "refs/heads/automation/bors/auto",
- "refs/heads/automation/bors/auto-merge",
- "refs/heads/automation/bors/try",
- ),
- (
- "api",
- f"{self.REPO}/contents/README.md?ref=automation%2Fbors%2Fauto",
- ): contents(b"# The Rust Programming Language"),
- }
- )
- url = "https://github.com/rust-lang/rust/blob/automation/bors/auto/README.md"
- result = GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB))
- assert result.status is Status.DONE
- assert result.meta["file"] == "README.md"
- assert "Rust" in body_of(result)
-
- def test_a_sibling_ref_never_claims_the_path_by_string_prefix(self):
- # `automation/bors-next` starts with `automation/bors` as a string;
- # segment-wise it is a different branch and must not take the URL.
- gh = FakeGh(
- {
- ("api", f"{self.REPO}/contents/bors/README.md?ref=automation"): fail(
- "gh: Not Found (HTTP 404)"
- ),
- ("api", f"{self.REPO}/git/matching-refs/heads/automation"): matching_refs(
- "refs/heads/automation/bors-next", "refs/heads/automation/bors"
- ),
- ("api", f"{self.REPO}/contents/README.md?ref=automation%2Fbors"): contents(b"ok"),
- }
- )
- url = "https://github.com/rust-lang/rust/blob/automation/bors/README.md"
- assert GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB)).status is Status.DONE
-
- def test_the_longest_matching_ref_wins(self):
- gh = FakeGh(
- {
- ("api", f"{self.REPO}/contents/1.2/docs/x.md?ref=release"): fail(
- "gh: Not Found (HTTP 404)"
- ),
- ("api", f"{self.REPO}/git/matching-refs/heads/release"): matching_refs(
- "refs/heads/release", "refs/heads/release/1.2"
- ),
- ("api", f"{self.REPO}/contents/docs/x.md?ref=release%2F1.2"): contents(b"ok"),
- }
- )
- url = "https://github.com/rust-lang/rust/blob/release/1.2/docs/x.md"
- assert GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB)).status is Status.DONE
-
- def test_a_ref_that_would_swallow_the_whole_tail_is_not_a_split(self):
- # Branch `docs/x.md` exists, but then the URL addresses no file at
- # all — the guess (and its 404) stands.
- gh = FakeGh(
- {
- ("api", f"{self.REPO}/contents/x.md?ref=docs"): fail("gh: Not Found (HTTP 404)"),
- ("api", f"{self.REPO}/git/matching-refs/heads/docs"): matching_refs(
- "refs/heads/docs/x.md"
- ),
- ("api", f"{self.REPO}/git/matching-refs/tags/docs"): matching_refs(),
- }
- )
- url = "https://github.com/rust-lang/rust/blob/docs/x.md"
- assert GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB)).status is Status.DEAD
-
- def test_a_slashed_tag_resolves_after_the_branches_come_back_empty(self):
- gh = FakeGh(
- {
- ("api", f"{self.REPO}/contents/9/README.md?ref=v1"): fail(
- "gh: Not Found (HTTP 404)"
- ),
- ("api", f"{self.REPO}/git/matching-refs/heads/v1"): matching_refs(),
- ("api", f"{self.REPO}/git/matching-refs/tags/v1"): matching_refs("refs/tags/v1/9"),
- ("api", f"{self.REPO}/contents/README.md?ref=v1%2F9"): contents(b"ok"),
- }
- )
- url = "https://github.com/rust-lang/rust/blob/v1/9/README.md"
- assert GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB)).status is Status.DONE
-
- @pytest.mark.parametrize(
- ("tail", "endpoint"),
- [
- ("refs/heads/master/README", "contents/README?ref=refs%2Fheads%2Fmaster"),
- ("refs/tags/v1.0.0/docs/x.md", "contents/docs/x.md?ref=refs%2Ftags%2Fv1.0.0"),
- ],
- )
- def test_the_refs_prefix_permalink_form_needs_no_lookup(self, tail, endpoint):
- # GitHub code search returns four figures of `blob/refs/heads/` links;
- # the form names its own namespace, so the split is free.
- gh = FakeGh({("api", f"{self.REPO}/{endpoint}"): contents(b"Hello World!")})
- url = f"https://github.com/rust-lang/rust/blob/{tail}"
- result = GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB))
- assert result.status is Status.DONE
- assert len(gh.calls) == 1
-
- def test_a_refs_prefix_permalink_on_a_slashed_branch_still_resolves(self):
- gh = FakeGh(
- {
- (
- "api",
- f"{self.REPO}/contents/bors/auto/README.md?ref=refs%2Fheads%2Fautomation",
- ): fail("gh: Not Found (HTTP 404)"),
- ("api", f"{self.REPO}/git/matching-refs/heads/automation"): matching_refs(
- "refs/heads/automation/bors/auto"
- ),
- (
- "api",
- f"{self.REPO}/contents/README.md?ref=refs%2Fheads%2Fautomation%2Fbors%2Fauto",
- ): contents(b"ok"),
- }
- )
- url = "https://github.com/rust-lang/rust/blob/refs/heads/automation/bors/auto/README.md"
- assert GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB)).status is Status.DONE
-
- def test_a_genuinely_missing_path_is_still_dead(self):
- # The floor the resolution must not break: no ref rescues a file
- # that is not there, and the unit must not go unclassifiable.
- gh = FakeGh(
- {
- ("api", f"{self.REPO}/contents/no-such-file.txt?ref=master"): fail(
- "gh: Not Found (HTTP 404)"
- ),
- ("api", f"{self.REPO}/git/matching-refs/heads/master"): matching_refs(
- "refs/heads/master"
- ),
- ("api", f"{self.REPO}/git/matching-refs/tags/master"): matching_refs(),
- }
- )
- url = "https://github.com/rust-lang/rust/blob/master/no-such-file.txt"
- assert GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB)).status is Status.DEAD
-
- def test_a_missing_path_on_a_resolved_slashed_branch_is_dead(self):
- gh = FakeGh(
- {
- ("api", f"{self.REPO}/contents/bors/auto/nope.md?ref=automation"): fail(
- "gh: Not Found (HTTP 404)"
- ),
- ("api", f"{self.REPO}/git/matching-refs/heads/automation"): matching_refs(
- "refs/heads/automation/bors/auto"
- ),
- ("api", f"{self.REPO}/contents/nope.md?ref=automation%2Fbors%2Fauto"): fail(
- "gh: Not Found (HTTP 404)"
- ),
- }
- )
- url = "https://github.com/rust-lang/rust/blob/automation/bors/auto/nope.md"
- assert GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB)).status is Status.DEAD
-
- def test_a_failing_ref_lookup_leaves_the_original_classification(self):
- gh = FakeGh(
- {
- ("api", f"{self.REPO}/contents/bors/auto/README.md?ref=automation"): fail(
- "gh: Not Found (HTTP 404)"
- ),
- ("api", f"{self.REPO}/git/matching-refs/heads/automation"): fail(
- "gh: API rate limit exceeded (HTTP 403)"
- ),
- ("api", f"{self.REPO}/git/matching-refs/tags/automation"): fail(
- "gh: API rate limit exceeded (HTTP 403)"
- ),
- }
- )
- url = "https://github.com/rust-lang/rust/blob/automation/bors/auto/README.md"
- assert GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB)).status is Status.DEAD
-
- def test_a_non_404_failure_never_spends_a_ref_lookup(self):
- gh = FakeGh(
- {
- ("api", f"{self.REPO}/contents/bors/auto/README.md?ref=automation"): fail(
- "gh: API rate limit exceeded (HTTP 403)"
- )
- }
- )
- url = "https://github.com/rust-lang/rust/blob/automation/bors/auto/README.md"
- assert GitHubDriver(gh=gh).fetch(make_unit(url, Kind.GITHUB)).status is Status.BLOCKED
- assert len(gh.calls) == 1
-
-
class TestEdges:
def test_github_root_is_skipped_with_reason(self):
result = driver_for().fetch(make_unit("https://github.com", Kind.GITHUB))
@@ -578,7 +355,7 @@ def test_github_root_is_skipped_with_reason(self):
assert "root" in reason_of(result)
def test_unparseable_api_json_is_blocked(self):
- driver = driver_for({("api", "repos/acme/pipeline-kit"): ok("oops")})
+ driver = driver_for({("api", "repos/acme/pipeline-kit"): gh_ok("oops")})
result = driver.fetch(make_unit("https://github.com/acme/pipeline-kit", Kind.GITHUB))
assert result.status is Status.BLOCKED
assert "unparseable JSON" in reason_of(result)
diff --git a/tests/drivers/test_isolation.py b/tests/drivers/test_isolation.py
new file mode 100644
index 0000000..0dc6b00
--- /dev/null
+++ b/tests/drivers/test_isolation.py
@@ -0,0 +1,70 @@
+"""The isolation rule: a driver never imports another driver.
+
+Drivers are dumb and isolated — anything two of them share is a lib beside
+``transport.py`` and ``gh.py``, not one driver reaching into the other's
+module. The rule is checked over the source rather than trusted to review:
+it was broken once by a helper that looked too small to move.
+
+``paper`` → ``web`` is the one blessed exception: openreview and
+huggingface pages read as articles, so the paper driver composes the whole
+web driver as its fetch strategy (design/ingestion-pipeline.md §2). That is
+delegation of a driver, not a shared helper, and it is named here so that
+any NEW pairing fails.
+"""
+
+import ast
+from pathlib import Path
+
+DRIVERS_DIR = Path(__file__).parent.parent.parent / "src" / "dex_engine" / "drivers"
+
+DELEGATIONS = frozenset({("paper", "web")})
+
+
+def driver_modules() -> dict[str, ast.Module]:
+ """Every module in the drivers package that defines a driver class."""
+ modules = {}
+ for path in sorted(DRIVERS_DIR.glob("*.py")):
+ if path.name == "__init__.py":
+ continue
+ tree = ast.parse(path.read_text(encoding="utf-8"))
+ if any(
+ isinstance(node, ast.ClassDef) and node.name.endswith("Driver")
+ for node in tree.body
+ ):
+ modules[path.stem] = tree
+ return modules
+
+
+def imported_siblings(tree: ast.Module) -> set[str]:
+ """The names of sibling driver-package modules this module imports."""
+ package = "dex_engine.drivers"
+ siblings = set()
+ for node in ast.walk(tree):
+ if isinstance(node, ast.ImportFrom):
+ if node.level == 1 and node.module:
+ siblings.add(node.module.split(".")[0])
+ elif node.module and node.module.startswith(f"{package}."):
+ siblings.add(node.module[len(package) + 1 :].split(".")[0])
+ elif isinstance(node, ast.Import):
+ for alias in node.names:
+ if alias.name.startswith(f"{package}."):
+ siblings.add(alias.name[len(package) + 1 :].split(".")[0])
+ return siblings
+
+
+class TestDriversAreIsolated:
+ def test_the_package_holds_drivers_at_all(self):
+ # The scan is only meaningful if it finds the drivers it walks.
+ assert {"web", "podcast", "youtube", "x", "github", "file", "paper"} <= set(
+ driver_modules()
+ )
+
+ def test_no_driver_imports_another_driver(self):
+ drivers = driver_modules()
+ offences = sorted(
+ (name, sibling)
+ for name, tree in drivers.items()
+ for sibling in imported_siblings(tree)
+ if sibling in drivers and (name, sibling) not in DELEGATIONS
+ )
+ assert offences == []
diff --git a/tests/drivers/test_live.py b/tests/drivers/test_live.py
index 05469f4..d1f4080 100644
--- a/tests/drivers/test_live.py
+++ b/tests/drivers/test_live.py
@@ -10,12 +10,14 @@
import pytest
+from dex_engine.drivers.gh import Blob, blob_ref, fetch_blob, run_gh
from dex_engine.drivers.github import GitHubDriver
from dex_engine.drivers.paper import PaperDriver
from dex_engine.drivers.podcast import PodcastDriver
from dex_engine.drivers.transport import urllib_transport
+from dex_engine.drivers.web import WebDriver
from dex_engine.drivers.x import XDriver
-from dex_engine.pipeline.types import Kind, Need, Status
+from dex_engine.pipeline.types import Format, Kind, Need, Status
from tests.drivers.conftest import body_of, make_unit
pytestmark = pytest.mark.live
@@ -80,14 +82,25 @@ def test_the_contents_api_still_accepts_a_fully_qualified_ref(self):
def test_an_lfs_tracked_blob_still_arrives_as_its_pointer(self):
# The contents API serves Git-LFS pointer text, never the object it
- # names. The blob route depends on that staying true: the pointer is
- # clean UTF-8 with no signature, so only the filename keeps its 130
- # bytes of `oid sha256:…` from being fenced as the document.
+ # names. Asserted on the seam's bytes, because no status downstream
+ # can show it: a pointer and the real document both re-detect to
+ # pdf work, and only the bytes say which one arrived.
+ url = "https://github.com/sarabander/sicp-pdf/blob/master/sicp.pdf"
+ ref = blob_ref(url)
+ assert ref is not None
+ blob = fetch_blob(run_gh, ref)
+ assert isinstance(blob, Blob)
+ assert blob.data.startswith(b"version https://git-lfs.github.com/spec/v1")
+
+ def test_an_lfs_pointer_is_never_fenced_as_the_document(self):
+ # The floor the pointer must not fall through: whatever the driver
+ # decides, it must not present 130 bytes of `oid sha256:…` as the
+ # document they stand for.
url = "https://github.com/sarabander/sicp-pdf/blob/master/sicp.pdf"
result = GitHubDriver().fetch(make_unit(url, Kind.GITHUB))
- assert result.status is Status.MANUAL
- assert "is a pdf document" in str(result.reason)
assert result.body is None
+ assert result.redetect is not None
+ assert result.redetect.format is Format.PDF
def test_a_slashed_branch_resolves_through_matching_refs(self):
# rust-lang/rust's bors branches are the routine slashed-name shape.
@@ -114,6 +127,18 @@ def test_an_unclaimed_bare_name_is_a_404_not_a_product_page(self):
assert not urllib_transport("https://www.youtube.com/zzqqxxnotachannel1234").ok
+class TestPageAudioIsNotAnEpisode:
+ def test_an_encyclopedia_article_with_embedded_audio_still_extracts(self):
+ # en.wikipedia.org/wiki/Podcast embeds media samples in a 60k-char
+ # article — the live shape the over-broad signal discarded.
+ # Real markup, because this is exactly what a fixture cannot pin.
+ driver = WebDriver(transport=urllib_transport)
+ result = driver.fetch(make_unit("https://en.wikipedia.org/wiki/Podcast", Kind.WEB))
+ assert result.redetect is None
+ assert result.status is Status.DONE
+ assert len(body_of(result)) > 10_000
+
+
class TestWaybackShape:
def test_the_availability_api_shape_holds(self):
lookup = "https://archive.org/wayback/available?url=example.com"
diff --git a/tests/drivers/test_podcast.py b/tests/drivers/test_podcast.py
index f57afcc..ad2cf94 100644
--- a/tests/drivers/test_podcast.py
+++ b/tests/drivers/test_podcast.py
@@ -29,6 +29,11 @@ def feed() -> HttpResponse:
return xml_response(fixture_text("podcast", "feed.xml"))
+def indie_page() -> HttpResponse:
+ """A realistic indie episode page: a player, a feed link, real notes."""
+ return html_response(fixture_text("podcast", "indie-episode-page.html"))
+
+
class TestDetection:
def test_matches_apple_spotify_and_explicit_rss(self):
d = PodcastDriver()
@@ -254,6 +259,54 @@ def test_indie_episode_page_resolves_via_its_feed_link(self):
assert result.status is Status.WAITING
assert result.meta["enclosure"] == ENCLOSURE
+ def test_a_redetected_indie_page_prefers_the_feeds_episode(self):
+ # The page carries its own audio, but the FEED's notes are richer —
+ # so a resolvable feed link wins, and the enclosure is the feed's.
+ d = driver({PAGE_URL: indie_page(), FEED_URL: feed()})
+ result = d.fetch(make_unit(PAGE_URL, Kind.PODCAST))
+ assert result.status is Status.WAITING
+ assert result.needs is Need.TRANSCRIBE
+ assert result.meta["enclosure"] == ENCLOSURE
+ assert "Ada Guest" in (result.body or "") # the feed's show notes
+
+ def test_a_page_whose_feed_is_unreachable_falls_back_to_its_own_audio(self):
+ # The web driver routed this unit here on the page's audio; the
+ # driver must resolve from that same signal rather than bounce it
+ # back, which the run layer would park as a re-detection loop.
+ d = driver({PAGE_URL: indie_page(), FEED_URL: OSError("connection reset")})
+ result = d.fetch(make_unit(PAGE_URL, Kind.PODCAST))
+ assert result.status is Status.WAITING
+ assert result.meta["enclosure"] == "https://engineering-distilled.test/audio/ep42.mp3"
+ assert result.meta["title"] == "Ledgers as Work Queues"
+
+ def test_a_page_with_no_feed_link_still_resolves_from_its_player(self):
+ page = indie_page().text().replace(
+ ' ',
+ "",
+ )
+ d = driver({PAGE_URL: html_response(page)})
+ result = d.fetch(make_unit(PAGE_URL, Kind.PODCAST))
+ assert result.status is Status.WAITING
+ assert result.meta["enclosure"] == "https://engineering-distilled.test/audio/ep42.mp3"
+
+ def test_an_og_audio_pointer_counts_as_an_enclosure(self):
+ page = (
+ ' '
+ ' '
+ "notes"
+ )
+ d = driver({PAGE_URL: html_response(page)})
+ result = d.fetch(make_unit(PAGE_URL, Kind.PODCAST))
+ assert result.status is Status.WAITING
+ assert result.meta["enclosure"] == "https://cdn.pods.test/solo.mp3"
+
+ def test_a_page_with_neither_feed_nor_audio_is_manual(self):
+ d = driver({PAGE_URL: html_response("just a post")})
+ result = d.fetch(make_unit(PAGE_URL, Kind.PODCAST))
+ assert result.status is Status.MANUAL
+ assert "no RSS feed link" in reason_of(result)
+
def test_enclosureless_episode_is_manual(self):
page_url = "https://engineering-distilled.test/episodes/no-audio.rss"
d = driver(
diff --git a/tests/drivers/test_transport.py b/tests/drivers/test_transport.py
index ec154f5..d9c4f37 100644
--- a/tests/drivers/test_transport.py
+++ b/tests/drivers/test_transport.py
@@ -2,22 +2,70 @@
The truncated-body cases run against a real localhost socket — the failure
lives inside ``http.client``'s read path and no in-process double reproduces
-it faithfully.
+it faithfully. The non-ASCII cases use one for the same reason: what a URL
+becomes on the wire is only visible from the other end of the socket.
"""
+import contextlib
import http.client
+import socket
+import threading
+from collections.abc import Iterator
import pytest
from dex_engine.capabilities import Capabilities
from dex_engine.capabilities.transcribe.whisper_api import WhisperApi, urllib_multipart_post
from dex_engine.drivers.file import FileDriver
-from dex_engine.drivers.transport import normalize_httplib_errors, urllib_transport
+from dex_engine.drivers.transport import (
+ _REQUEST_LINE_FORBIDDEN,
+ _ascii_url,
+ normalize_httplib_errors,
+ urllib_transport,
+)
from dex_engine.pipeline.classify import ProviderInputError, classify_connection, classify_http
from dex_engine.pipeline.types import Kind, Status
from tests.drivers.conftest import make_unit, truncating_server
+@contextlib.contextmanager
+def recording_server() -> Iterator[tuple[str, list[str]]]:
+ """Serve 200 OK, recording each request line. Yields the base URL and the log."""
+ listener = socket.socket()
+ listener.bind(("127.0.0.1", 0))
+ listener.listen(8)
+ host, port = listener.getsockname()
+ seen: list[str] = []
+
+ def serve() -> None:
+ while True:
+ try:
+ conn, _ = listener.accept()
+ except OSError:
+ return # the listener closed: the context manager is done
+ with conn, contextlib.suppress(OSError):
+ data = b""
+ while b"\r\n\r\n" not in data:
+ chunk = conn.recv(65536)
+ if not chunk:
+ break
+ data += chunk
+ seen.append(data.split(b"\r\n")[0].decode("ascii", "replace"))
+ conn.sendall(
+ b"HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n"
+ b"Content-Length: 2\r\nConnection: close\r\n\r\nhi"
+ )
+ conn.shutdown(socket.SHUT_WR)
+
+ thread = threading.Thread(target=serve, daemon=True)
+ thread.start()
+ try:
+ yield f"http://{host}:{port}", seen
+ finally:
+ listener.close()
+ thread.join(timeout=5)
+
+
class TestHttplibNormalization:
def test_incomplete_read_becomes_a_classified_connection_failure(self):
# A server that closes mid-body — the routine failure mode for a
@@ -90,3 +138,73 @@ def test_truncated_download_is_blocked_never_an_engine_error(self):
result = driver.fetch(make_unit(url, Kind.FILE))
assert result.status is Status.BLOCKED
assert "truncated response body" in (result.reason or "")
+
+
+class TestNonAsciiUrls:
+ """A URL with an accent — or a space — in it is a URL to fetch, not an error."""
+
+ @pytest.mark.parametrize(
+ ("path", "wire"),
+ [
+ ("/wiki/Café", "/wiki/Caf%C3%A9"), # an accented Wikipedia path
+ ("/记/分布式", "/%E8%AE%B0/%E5%88%86%E5%B8%83%E5%BC%8F"), # a CJK slug
+ ("/search?q=café", "/search?q=caf%C3%A9"), # the query too
+ ],
+ )
+ def test_the_url_reaches_the_wire_percent_encoded(self, path, wire):
+ # http.client ascii-encodes the request line, so every one of these
+ # raised UnicodeEncodeError before a byte left the machine — and a
+ # UnicodeEncodeError IS a ValueError, so it walked past the drivers'
+ # `except OSError` guards and parked the item manual, unfetched,
+ # with a raw codec message as the operator's stated reason.
+ with recording_server() as (base, seen):
+ response = urllib_transport(base + path)
+ assert response.status == 200
+ assert seen == [f"GET {wire} HTTP/1.1"]
+
+ def test_a_space_in_the_path_reaches_the_wire_encoded(self):
+ # http.client rejects [\x00-\x20\x7f] in the request line, not
+ # merely non-ASCII: an unencoded space in an href — everyday in a
+ # corpus — raised InvalidURL and parked the item blocked, spending
+ # five attempts and five wayback lookups on a condition no retry
+ # can change. The URL was fetchable all along.
+ with recording_server() as (base, seen):
+ response = urllib_transport(base + "/reports/annual report.pdf")
+ assert response.status == 200
+ assert seen == ["GET /reports/annual%20report.pdf HTTP/1.1"]
+
+ @pytest.mark.parametrize("char", ["\x00", " ", "\x1f", "\x7f"])
+ def test_no_character_the_request_line_forbids_survives_the_encoder(self, char):
+ assert _REQUEST_LINE_FORBIDDEN.search(_ascii_url(f"https://example.com/a{char}b")) is None
+
+ def test_an_idn_host_is_punycoded(self):
+ assert _ascii_url("https://münchen.example/rathaus") == (
+ "https://xn--mnchen-3ya.example/rathaus"
+ )
+
+ def test_an_idn_host_keeps_its_port_and_encodes_its_path(self):
+ assert _ascii_url("https://例え.テスト:8443/パス?q=検索") == (
+ "https://xn--r8jz45g.xn--zckzah:8443/%E3%83%91%E3%82%B9?q=%E6%A4%9C%E7%B4%A2"
+ )
+
+ def test_an_already_encoded_url_passes_through_untouched(self):
+ # The encoding is idempotent or it corrupts the canonical URL the
+ # ledger keys on: %C3%A9 must never become %25C3%25A9. The pin has
+ # to be a URL that REACHES the encoder — one already-encoded escape
+ # beside a character that still needs encoding — because a URL the
+ # encoder returns at the guard pins nothing about what quote does.
+ assert _ascii_url("https://fr.wikipedia.org/wiki/Caf%C3%A9?q=thé#frag") == (
+ "https://fr.wikipedia.org/wiki/Caf%C3%A9?q=th%C3%A9#frag"
+ )
+ url = "https://fr.wikipedia.org/wiki/Caf%C3%A9?a=b&c=d#frag"
+ assert _ascii_url(url) == url
+ assert _ascii_url(_ascii_url("https://fr.wikipedia.org/wiki/Café")) == (
+ "https://fr.wikipedia.org/wiki/Caf%C3%A9"
+ )
+
+ def test_an_unencodable_host_fails_with_a_stated_reason(self):
+ # The one genuinely unfetchable shape: an IDN label DNS cannot
+ # carry. It still fails as a stated ValueError the bad-seed
+ # containment can park on, never the codec's own words.
+ with pytest.raises(ValueError, match="is not encodable for DNS"):
+ urllib_transport("https://" + "ü" * 200 + ".example/")
diff --git a/tests/drivers/test_web.py b/tests/drivers/test_web.py
index 0bade26..164b044 100644
--- a/tests/drivers/test_web.py
+++ b/tests/drivers/test_web.py
@@ -85,6 +85,71 @@ def test_declared_content_type_catches_signature_less_formats(self):
assert result.redetect is not None
assert result.redetect.format is Format.CSV
+ def test_an_episode_page_whose_audio_is_its_substance_signals_podcast(self):
+ # A player and a paragraph of notes: extraction has nothing to keep,
+ # so the audio IS the page. Real extraction, because the threshold
+ # is half the decision.
+ page = fixture_text("podcast", "indie-episode-page.html")
+ driver = driver_for({URL: html_response(page)}, extract=trafilatura_extract)
+ result = driver.fetch(make_unit(URL, Kind.WEB))
+ assert result.redetect is not None
+ assert result.redetect.kind is Kind.PODCAST
+ assert result.status is Status.QUEUED
+
+ def test_an_og_audio_declaration_signals_podcast_over_any_body(self):
+ # og:audio is the publisher naming the audio as this page's object —
+ # an episode page with full show notes is still an episode page.
+ declared = ARTICLE.replace(
+ "",
+ ' ',
+ )
+ result = driver_for({URL: html_response(declared)}).fetch(make_unit(URL, Kind.WEB))
+ assert result.redetect is not None
+ assert result.redetect.kind is Kind.PODCAST
+
+ def test_a_listen_to_this_article_widget_never_steals_the_article(self):
+ # The incident: mainstream publishers ship text-to-speech players
+ # and encyclopedias embed media samples. An element beside
+ # a real article is an accessory — parking that article as an
+ # episode loses the whole body AND its links.
+ narrated = ARTICLE.replace(
+ "
+
+
+
+ Ledgers as Work Queues
+ Episode 42 · 12 August 2026 · 58 min
+
+
+
+ Your browser does not support the audio element.
+
+
+ We discuss append-only ledgers with Ada Guest: why JSONL merges, and
+ what last-per-hash buys you when two machines both wrote.
+
+
+
+
+'
+ )
+ driver = driver_for({URL: html_response(linked)})
+ assert driver.fetch(make_unit(URL, Kind.WEB)).redetect is None
+
def test_ordinary_thin_html_stays_manual_never_redetects(self):
driver = driver_for({URL: html_response(THIN)}, extract=lambda _html: None)
result = driver.fetch(make_unit(URL, Kind.WEB))
diff --git a/tests/drivers/test_x.py b/tests/drivers/test_x.py
index ba575ed..4bbcddb 100644
--- a/tests/drivers/test_x.py
+++ b/tests/drivers/test_x.py
@@ -2,7 +2,7 @@
import json
-from dex_engine.drivers.x import MAX_HOPS, XDriver
+from dex_engine.drivers.x import HOP_SLEEP, MAX_HOPS, XDriver
from dex_engine.pipeline.classify import PAYWALL_REASON
from dex_engine.pipeline.types import Kind, Status
from dex_engine.pipeline.urls import work_hash
@@ -25,7 +25,7 @@ def api_fixture(name: str):
def driver_for(responses: dict) -> XDriver:
- return XDriver(transport=FakeTransport(responses))
+ return XDriver(transport=FakeTransport(responses), pace=lambda _seconds: None)
def full_chain() -> dict:
@@ -119,7 +119,7 @@ def test_mid_walk_fetch_failure_records_the_gap(self):
assert "@alice" not in body
assert body.index("@bob") < body.index("@carol")
- def test_walk_stops_at_the_20_hop_cap_and_notes_it_in_meta(self):
+ def test_walk_stops_at_the_hop_bound_and_notes_it_in_meta(self):
base = 1000
responses = {}
for i in range(base, base + MAX_HOPS + 5):
@@ -138,7 +138,69 @@ def test_walk_stops_at_the_20_hop_cap_and_notes_it_in_meta(self):
result = driver_for(responses).fetch(make_unit(url, Kind.X))
assert result.status is Status.DONE
assert result.meta["thread_cap_hit"] == "true"
- assert result.meta["thread_length"] == MAX_HOPS + 1 # captured + 20 hops
+ assert result.meta["thread_length"] == MAX_HOPS + 1 # the captured post + the bound
+
+ def _looping_post(self, status_id: str, parent_id: str) -> dict:
+ return {
+ "id": status_id,
+ "text": f"post {status_id}",
+ "created_at": "Thu Aug 20 10:00:00 +0000 2026",
+ "author": {"name": f"User {status_id}", "screen_name": f"user{status_id}"},
+ "replying_to": f"user{parent_id}",
+ "replying_to_status": parent_id,
+ }
+
+ def test_a_self_referencing_parent_stops_at_the_first_repeat(self):
+ # A post naming itself as its own parent used to walk the full
+ # bound: 100 back-to-back requests to a free community API for one
+ # unit. Seeing the id already walked ends it at zero further calls.
+ transport = FakeTransport({API + "status/500": json_response(
+ {"tweet": self._looping_post("500", "500")}
+ )})
+ result = XDriver(transport=transport, pace=lambda _seconds: None).fetch(
+ make_unit("https://x.com/loop/status/500", Kind.X)
+ )
+ assert result.status is Status.DONE
+ assert transport.calls == [("GET", API + "status/500")] # the captured post, and stop
+ assert "thread_cap_hit" not in result.meta
+ assert result.meta["chain_incomplete"] == "true"
+ assert "loops back" in str(result.meta["chain_note"])
+
+ def test_a_cycle_further_up_the_chain_stops_there(self):
+ # A -> B -> A: the repeat is two hops up, not at the captured post.
+ transport = FakeTransport(
+ {
+ API + "status/700": json_response({"tweet": self._looping_post("700", "800")}),
+ API + "user800/status/800": json_response(
+ {"tweet": self._looping_post("800", "700")}
+ ),
+ }
+ )
+ result = XDriver(transport=transport, pace=lambda _seconds: None).fetch(
+ make_unit("https://x.com/user700/status/700", Kind.X)
+ )
+ assert result.status is Status.DONE
+ assert result.meta["thread_length"] == 2 # both real posts kept
+ assert len(transport.calls) == 2 # and no third request
+ assert result.meta["chain_incomplete"] == "true"
+
+ def test_the_walk_paces_itself_between_hops(self):
+ # A thread is one unit, and the driver's 4s politeness is spent
+ # between units — without pacing here a 30-post thread is 30
+ # unpaced requests to a free API in one burst.
+ slept: list[float] = []
+ driver = XDriver(transport=FakeTransport(full_chain()), pace=slept.append)
+ driver.fetch(make_unit(CAPTURED_URL, Kind.X))
+ assert slept == [HOP_SLEEP, HOP_SLEEP] # one per parent fetched, none for the captured
+
+ def test_a_post_with_no_parent_never_sleeps(self):
+ responses = full_chain()
+ responses[API + "status/300"] = api_fixture("root-100.json")
+ slept: list[float] = []
+ XDriver(transport=FakeTransport(responses), pace=slept.append).fetch(
+ make_unit(CAPTURED_URL, Kind.X)
+ )
+ assert slept == []
class TestShareShapeFetches:
@@ -146,7 +208,7 @@ def test_i_web_share_link_fetches_the_live_post(self):
# The app's standard share form: fxtwitter 404s on /i/web/… — sent
# verbatim it would mark a live post terminally dead.
transport = FakeTransport(full_chain())
- driver = XDriver(transport=transport)
+ driver = XDriver(transport=transport, pace=lambda _seconds: None)
result = driver.fetch(make_unit("https://x.com/i/web/status/300", Kind.X))
assert result.status is Status.DONE
assert ("GET", API + "status/300") in transport.calls
@@ -154,7 +216,9 @@ def test_i_web_share_link_fetches_the_live_post(self):
def test_username_form_fetches_by_the_bare_status_path_too(self):
transport = FakeTransport(full_chain())
- result = XDriver(transport=transport).fetch(make_unit(CAPTURED_URL, Kind.X))
+ result = XDriver(transport=transport, pace=lambda _seconds: None).fetch(
+ make_unit(CAPTURED_URL, Kind.X)
+ )
assert result.status is Status.DONE
assert transport.calls[0] == ("GET", API + "status/300")
diff --git a/tests/drivers/test_youtube.py b/tests/drivers/test_youtube.py
index 3a7fc94..92624a8 100644
--- a/tests/drivers/test_youtube.py
+++ b/tests/drivers/test_youtube.py
@@ -314,6 +314,16 @@ def test_captions_become_description_and_transcript_sections(self):
assert "## Transcript" in body
assert "not receipts, they are the queue" in body
+ def test_a_captioned_video_with_no_description_still_labels_its_transcript(self):
+ # The transcript is its own labelled section, description or not:
+ # the drain splits a stored body on that heading, so a bare
+ # transcript comes back as "description" and duplicates itself.
+ info = {k: v for k, v in INFO_WITH.items() if k != "description"}
+ driver = driver_for(info, {TRACK_URL: vtt_response(VTT)})
+ body = body_of(driver.fetch(make_unit(URL, Kind.YOUTUBE)))
+ assert body.startswith("## Transcript\n\n")
+ assert "## Description" not in body
+
def test_vtt_is_cleaned_tags_cues_and_duplicates_stripped(self):
driver = driver_for(INFO_WITH, {TRACK_URL: vtt_response(VTT)})
body = body_of(driver.fetch(make_unit(URL, Kind.YOUTUBE)))
@@ -357,12 +367,29 @@ def test_no_captions_parks_waiting_transcribe_and_never_downloads_audio(self):
assert reason_of(result) == "no captions available"
assert result.meta["title"] == "Unindexed Conference Talk"
+ def test_the_park_carries_the_description_it_already_fetched(self):
+ # A video that goes private during a transcription backlog must not
+ # take its description with it: the run layer writes this now.
+ driver = driver_for(INFO_WITHOUT)
+ body = body_of(driver.fetch(make_unit(URL, Kind.YOUTUBE)))
+ assert body.startswith("## Description")
+ assert "Recorded on a phone" in body
+ assert "## Transcript" not in body # the drain appends that later
+
+ def test_a_description_less_video_still_parks_cleanly(self):
+ info = {k: v for k, v in INFO_WITHOUT.items() if k != "description"}
+ result = driver_for(info).fetch(make_unit(URL, Kind.YOUTUBE))
+ assert result.status is Status.WAITING
+ assert result.needs is Need.TRANSCRIBE
+ assert result.body is None # nothing to write, nothing invented
+
def test_thin_captions_track_parks_waiting_transcribe(self):
thin = "WEBVTT\n\n1\n00:00:00.000 --> 00:00:01.000\nhi\n"
driver = driver_for(INFO_WITH, {TRACK_URL: vtt_response(thin)})
result = driver.fetch(make_unit(URL, Kind.YOUTUBE))
assert result.status is Status.WAITING
assert result.needs is Need.TRANSCRIBE
+ assert "## Description" in body_of(result) # the description still lands
class TestProbeClassification:
diff --git a/tests/fixtures/podcast/indie-episode-page.html b/tests/fixtures/podcast/indie-episode-page.html
new file mode 100644
index 0000000..0328482
--- /dev/null
+++ b/tests/fixtures/podcast/indie-episode-page.html
@@ -0,0 +1,34 @@
+
+
+