From 1d7f821ab845fdb4304f2ef8503473853751c1fc Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 4 Jun 2026 06:33:01 -0700 Subject: [PATCH] feat: ingest CLI entry point Per sub-project-2 plan Task 10. argparse entry wiring named fetcher + HeuristicProposer into ingest_search/ingest_candidate; --query/--resolve, --limit, --catalog, --media-root (env HEF_MEDIA_ROOT), --dominant-color. Unknown archive -> exit 2, deferred archive -> exit 3. Secrets env-only. Co-Authored-By: Claude Opus 4.8 (1M context) --- tests/test_ingest_cli.py | 25 ++++++++++ tools/ingest_cli.py | 99 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 tests/test_ingest_cli.py create mode 100644 tools/ingest_cli.py diff --git a/tests/test_ingest_cli.py b/tests/test_ingest_cli.py new file mode 100644 index 0000000..1c57691 --- /dev/null +++ b/tests/test_ingest_cli.py @@ -0,0 +1,25 @@ +import pytest + +from tools.ingest_cli import main + + +def test_help_parses(): + with pytest.raises(SystemExit) as e: + main(["--help"]) + assert e.value.code == 0 + + +def test_unknown_archive_errors_cleanly(capsys): + rc = main(["bogus", "--query", "x"]) + assert rc == 2 + assert "unknown archive" in capsys.readouterr().err + + +def test_missing_query_and_resolve_errors(): + assert main(["nasa"]) == 2 + + +def test_deferred_archive_reports_not_implemented(capsys): + rc = main(["musopen", "--query", "bach"]) + assert rc == 3 + assert "deferred" in capsys.readouterr().err diff --git a/tools/ingest_cli.py b/tools/ingest_cli.py new file mode 100644 index 0000000..8322017 --- /dev/null +++ b/tools/ingest_cli.py @@ -0,0 +1,99 @@ +"""CLI entry point for ingest: `python -m tools.ingest_cli ...`. + +Wires a named fetcher + the HeuristicProposer into the ingest pipeline. Secrets +(e.g. FREESOUND_API_TOKEN) are read from the environment only, never as flags. +""" + +from __future__ import annotations + +import argparse +import os +import sys + +from tools.drafting import HeuristicProposer +from tools.http import HttpClient +from tools.ingest.base import ingest_candidate, ingest_search +from tools.ingest.fma import FmaFetcher +from tools.ingest.freesound import FreesoundFetcher +from tools.ingest.internet_archive import InternetArchiveFetcher +from tools.ingest.librivox import LibriVoxFetcher +from tools.ingest.musopen import MusopenFetcher +from tools.ingest.nasa import NasaFetcher + +FETCHERS = { + "librivox": LibriVoxFetcher, + "nasa": NasaFetcher, + "internet_archive": InternetArchiveFetcher, + "musopen": MusopenFetcher, + "fma": FmaFetcher, + "freesound": FreesoundFetcher, +} + +DEFAULT_CATALOG = "catalog/library.jsonl" +DEFAULT_MEDIA_ROOT = "./media" + + +def _build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="tools.ingest_cli", + description="Ingest candidates from an archive into the catalog as proposed records.", + ) + p.add_argument("archive", help=f"source archive ({', '.join(sorted(FETCHERS))})") + p.add_argument("--query", help="search query") + p.add_argument("--resolve", metavar="IDENTIFIER", help="ingest a single item by id/URL") + p.add_argument("--limit", type=int, default=5, help="max search hits (default 5)") + p.add_argument("--catalog", default=DEFAULT_CATALOG, help=f"catalog JSONL (default {DEFAULT_CATALOG})") + p.add_argument( + "--media-root", + default=os.environ.get("HEF_MEDIA_ROOT", DEFAULT_MEDIA_ROOT), + help="download root (env HEF_MEDIA_ROOT; default ./media)", + ) + p.add_argument( + "--dominant-color", + action="store_true", + help="compute dominant_color for video/av (opt-in, ffmpeg-only)", + ) + return p + + +def main(argv=None) -> int: + args = _build_parser().parse_args(argv) + + cls = FETCHERS.get(args.archive) + if cls is None: + print( + f"error: unknown archive {args.archive!r}; " + f"choose one of {', '.join(sorted(FETCHERS))}", + file=sys.stderr, + ) + return 2 + if not args.query and not args.resolve: + print("error: provide --query or --resolve", file=sys.stderr) + return 2 + + fetcher = cls(HttpClient()) + kw = dict( + catalog_path=args.catalog, + media_root=args.media_root, + proposer=HeuristicProposer(), + compute_color=args.dominant_color, + ) + + try: + if args.resolve: + rec = ingest_candidate(fetcher.resolve(args.resolve), **kw) + appended = [rec] if rec is not None else [] + else: + appended = ingest_search(fetcher, args.query, limit=args.limit, **kw) + except NotImplementedError as exc: + print(f"error: {exc}", file=sys.stderr) + return 3 + + print(f"ingested {len(appended)} proposed record(s) into {args.catalog}") + for rec in appended: + print(f" {rec.id} [{rec.mode}] {rec.title}") + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main())