gather-engine 1.5.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
gather/__init__.py ADDED
@@ -0,0 +1,31 @@
1
+ """Gather: an accountable research-intake organ.
2
+
3
+ The one place network access, third-party tools, and credentials are allowed to live,
4
+ isolated behind source adapters, so the rest of the constellation stays clean. Every
5
+ ingested item carries a provenance receipt, and a gather run emits a witnessed digest
6
+ that index, refine, and the crucible consume. A peer organ: it composes through the
7
+ seam, it does not absorb or get absorbed.
8
+
9
+ The stable surface is re-exported here (``from gather import make_item, Corpus, gather_run``);
10
+ adapters and the network/credentials edges are imported from their submodules when needed.
11
+ """
12
+
13
+ from gather.derive import NullSynthesizer, Synthesizer, derive, synthesize_item
14
+ from gather.digest import Digest, digest, digest_of_receipts, verify_digest
15
+ from gather.item import Item, Provenance, content_hash, make_item
16
+ from gather.provenance import NullProvenanceProvider, ProvenanceProvider
17
+ from gather.recall import Query, recall, recall_audited
18
+ from gather.run import RunRecord, gather_run, verify_record
19
+ from gather.scope import filter_scope, in_scope
20
+ from gather.source import Catalog, Source
21
+ from gather.store import Corpus
22
+
23
+ __version__ = "1.5.0"
24
+
25
+ __all__ = [
26
+ "Catalog", "Corpus", "Digest", "Item", "NullProvenanceProvider", "NullSynthesizer",
27
+ "Provenance", "ProvenanceProvider", "Query", "RunRecord", "Source", "Synthesizer",
28
+ "content_hash", "derive", "digest", "digest_of_receipts", "filter_scope", "gather_run",
29
+ "in_scope", "make_item", "recall", "recall_audited", "synthesize_item", "verify_digest",
30
+ "verify_record", "__version__",
31
+ ]
gather/api.py ADDED
@@ -0,0 +1,113 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from typing import Any
6
+
7
+ from gather.credentials import require_secret
8
+ from gather.item import Item, make_item
9
+ from gather.net import decode_body, http_get
10
+
11
+
12
+ def _records(payload: Any, items_key: str | None) -> list[dict]:
13
+ """The records in an API payload: the array at ``items_key``, or the top-level list, or the
14
+ single object wrapped in a list."""
15
+ if items_key is not None:
16
+ data = payload.get(items_key, []) if isinstance(payload, dict) else []
17
+ else:
18
+ data = payload
19
+ if isinstance(data, dict):
20
+ return [data]
21
+ if isinstance(data, list):
22
+ return [r for r in data if isinstance(r, dict)]
23
+ return []
24
+
25
+
26
+ def parse_api(
27
+ payload: str | bytes,
28
+ url: str,
29
+ *,
30
+ fetched_at: float,
31
+ items_key: str | None = None,
32
+ id_key: str = "id",
33
+ title_key: str = "title",
34
+ text_key: str | None = None,
35
+ method: str = "api-get",
36
+ ) -> list[Item]:
37
+ """Turn a JSON API response into one Item per record. Pure: no network.
38
+
39
+ Each record becomes an Item whose text is the record's ``text_key`` field if given, else the
40
+ record serialized as canonical JSON (so the receipt fingerprints the exact record). A non-string
41
+ ``text_key`` field is itself serialized as canonical JSON, never a Python repr, so the receipt
42
+ always fingerprints JSON. The id and title are read from ``id_key`` / ``title_key`` when present;
43
+ id uniqueness across records is the API's responsibility. ``ref`` is the request URL. No
44
+ credential is ever placed in an Item or its receipt. Raises ValueError on malformed JSON.
45
+ """
46
+ try:
47
+ data = json.loads(payload)
48
+ except json.JSONDecodeError as exc:
49
+ raise ValueError(f"not valid API JSON: {exc}") from exc
50
+ items: list[Item] = []
51
+ for i, rec in enumerate(_records(data, items_key)):
52
+ if text_key:
53
+ field = rec.get(text_key, "")
54
+ text = field if isinstance(field, str) else json.dumps(field, sort_keys=True, ensure_ascii=False)
55
+ else:
56
+ text = json.dumps(rec, sort_keys=True, ensure_ascii=False)
57
+ rid = str(rec.get(id_key, i))
58
+ items.append(
59
+ make_item(
60
+ kind="record", id=rid, title=str(rec.get(title_key, "")), text=text,
61
+ source="api", ref=url, method=method, fetched_at=fetched_at,
62
+ )
63
+ )
64
+ return items
65
+
66
+
67
+ class ApiSource:
68
+ """An authenticated JSON-API adapter: the worked example of the credentials-isolation pattern.
69
+
70
+ The impure edge that needs a secret. The token is read from the environment by name through
71
+ require_secret (never from source, a config, or the URL), sent in an Authorization header (so
72
+ it never reaches the URL, the receipt, or the disk), and the records come back through the pure
73
+ parse_api. Point another authenticated source at this shape: env in, header out, secret never
74
+ witnessed. fetch(target) takes the full request URL; needs the named env var set and network.
75
+ """
76
+
77
+ name = "api"
78
+
79
+ def __init__(
80
+ self,
81
+ *,
82
+ clock=time.time,
83
+ auth_env: str = "GATHER_API_TOKEN",
84
+ auth_scheme: str = "Bearer",
85
+ items_key: str | None = None,
86
+ id_key: str = "id",
87
+ title_key: str = "title",
88
+ text_key: str | None = None,
89
+ timeout: float = 20.0,
90
+ ) -> None:
91
+ self._clock = clock
92
+ self._auth_env = auth_env
93
+ self._auth_scheme = auth_scheme
94
+ self._items_key = items_key
95
+ self._id_key = id_key
96
+ self._title_key = title_key
97
+ self._text_key = text_key
98
+ self._timeout = timeout
99
+
100
+ def fetch(self, target: str) -> list[Item]:
101
+ token = require_secret(self._auth_env)
102
+ if token in target:
103
+ # the URL is witnessed in the receipt's ref; the secret must travel in the header only
104
+ raise ValueError("the credential must not appear in the URL (ref is witnessed); it is sent as a header")
105
+ body, ctype = http_get(
106
+ target, timeout=self._timeout,
107
+ headers={"Authorization": f"{self._auth_scheme} {token}"},
108
+ )
109
+ return parse_api(
110
+ decode_body(body, ctype), target, fetched_at=float(self._clock()),
111
+ items_key=self._items_key, id_key=self._id_key,
112
+ title_key=self._title_key, text_key=self._text_key,
113
+ )
gather/arxiv.py ADDED
@@ -0,0 +1,130 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import time
5
+ import urllib.parse
6
+ import xml.etree.ElementTree as ET
7
+
8
+ from gather.item import Item, make_item
9
+ from gather.net import http_get
10
+
11
+ ARXIV_API = "https://export.arxiv.org/api/query"
12
+ _ARXIV_ID = re.compile(r"^\d{4}\.\d{4,5}(v\d+)?$|^[a-z-]+(\.[A-Za-z-]+)?/\d{7}(v\d+)?$", re.IGNORECASE)
13
+
14
+
15
+ def _local(tag: str) -> str:
16
+ return tag.rsplit("}", 1)[-1]
17
+
18
+
19
+ def _arxiv_id(id_url: str) -> str:
20
+ """The bare arXiv id from an abs URL: http://arxiv.org/abs/2301.12345v2 -> 2301.12345v2."""
21
+ if "/abs/" in id_url:
22
+ return id_url.split("/abs/", 1)[1].rstrip("/")
23
+ return id_url.rsplit("/", 1)[-1]
24
+
25
+
26
+ def is_arxiv_id(target: str) -> bool:
27
+ """True if ``target`` is a bare arXiv id (new-style ``2301.12345`` or old ``hep-th/9901001``)
28
+ rather than a free-text query. The single classifier deciding id-fetch vs search."""
29
+ return bool(_ARXIV_ID.match(target.strip()))
30
+
31
+
32
+ def arxiv_query_url(target: str, *, max_results: int = 10) -> str:
33
+ """Build the arXiv API URL for a target. Pure and deterministic.
34
+
35
+ A bare arXiv id (``2301.12345``, optionally versioned, or an old ``cs.AI/0601001``) is
36
+ fetched by id; anything else is treated as a free-text search over all fields, returning
37
+ up to ``max_results`` by relevance. Every value is urlencoded, so a query cannot break out
38
+ of the query string.
39
+ """
40
+ target = target.strip()
41
+ if is_arxiv_id(target):
42
+ params = {"id_list": target, "max_results": "1"}
43
+ else:
44
+ params = {
45
+ "search_query": f"all:{target}", "start": "0",
46
+ "max_results": str(max_results), "sortBy": "relevance",
47
+ }
48
+ return f"{ARXIV_API}?{urllib.parse.urlencode(params)}"
49
+
50
+
51
+ def parse_arxiv(xml: str | bytes, *, fetched_at: float, method: str = "arxiv-api") -> list[Item]:
52
+ """Parse an arXiv API Atom response into one paper Item per entry. Pure: no network.
53
+
54
+ Each Item's text is the ABSTRACT, not the full paper (the API returns abstracts); the PDF
55
+ link is recorded in ``meta`` for the separate full-text adapter, so an abstract is never
56
+ mistaken for the paper. Authors, categories (which include the primary), primary category,
57
+ publication date, and DOI are carried in ``meta``. An entry without an id is skipped (real
58
+ arXiv always sends one). Raises ValueError on malformed XML.
59
+ """
60
+ try:
61
+ root = ET.fromstring(xml)
62
+ except ET.ParseError as exc:
63
+ raise ValueError(f"not valid arXiv API XML: {exc}") from exc
64
+
65
+ items: list[Item] = []
66
+ for entry in (e for e in root if _local(e.tag) == "entry"):
67
+ id_url = title = abstract = published = primary = doi = pdf = ""
68
+ authors: list[str] = []
69
+ cats: list[str] = []
70
+ for ch in entry:
71
+ n = _local(ch.tag)
72
+ if n == "id":
73
+ id_url = (ch.text or "").strip()
74
+ elif n == "title":
75
+ title = " ".join((ch.text or "").split())
76
+ elif n == "summary":
77
+ abstract = " ".join("".join(ch.itertext()).split())
78
+ elif n == "published":
79
+ published = (ch.text or "").strip()
80
+ elif n == "author":
81
+ authors.extend(s.text.strip() for s in ch if _local(s.tag) == "name" and s.text)
82
+ elif n == "primary_category":
83
+ primary = ch.get("term") or primary
84
+ elif n == "category":
85
+ term = ch.get("term")
86
+ if term:
87
+ cats.append(term)
88
+ elif n == "link" and (ch.get("title") == "pdf" or ch.get("type") == "application/pdf"):
89
+ pdf = ch.get("href") or pdf
90
+ elif n == "doi":
91
+ doi = (ch.text or "").strip()
92
+ if not id_url:
93
+ continue # an entry with no id has no identity; real arXiv always sends one
94
+ meta: dict[str, object] = {}
95
+ for key, val in (("authors", authors), ("published", published), ("primary_category", primary),
96
+ ("categories", cats), ("pdf", pdf), ("doi", doi)):
97
+ if val:
98
+ meta[key] = val
99
+ items.append(
100
+ make_item(
101
+ kind="paper", id=_arxiv_id(id_url), title=title, text=abstract,
102
+ source="arxiv", ref=id_url, method=method,
103
+ fetched_at=fetched_at, meta=meta,
104
+ )
105
+ )
106
+ return items
107
+
108
+
109
+ class ArxivSource:
110
+ """arXiv paper intake via the public arXiv API. The isolated impure edge; parsing is pure.
111
+
112
+ fetch(target) takes an arXiv id or a free-text query and returns paper Items carrying the
113
+ abstract and metadata. Needs network. The full text behind the PDF link is a separate
114
+ adapter (see gather.pdf); this returns abstracts, and the receipt's method says so.
115
+ """
116
+
117
+ name = "arxiv"
118
+
119
+ def __init__(self, *, clock=time.time, timeout: float = 20.0, max_results: int = 10) -> None:
120
+ self._clock = clock
121
+ self._timeout = timeout
122
+ self._max_results = max_results
123
+
124
+ def fetch(self, target: str) -> list[Item]:
125
+ by_id = is_arxiv_id(target)
126
+ url = arxiv_query_url(target, max_results=self._max_results)
127
+ body, _ = http_get(url, timeout=self._timeout)
128
+ # the receipt records HOW the paper was found: a direct id lookup, or a relevance search
129
+ method = "arxiv-api-id" if by_id else "arxiv-api-search"
130
+ return parse_arxiv(body, fetched_at=float(self._clock()), method=method)
gather/browser.py ADDED
@@ -0,0 +1,66 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ import time
5
+
6
+ from gather.item import Item, make_item
7
+ from gather.net import validate_public_http_url
8
+ from gather.web import html_to_title_text
9
+
10
+
11
+ def parse_browser(html: str, url: str, *, fetched_at: float, method: str = "browser-extract") -> Item:
12
+ """Turn rendered DOM HTML into one webpage Item. Pure: no subprocess. Reuses the web text
13
+ extractor, but the method is ``browser-extract`` (JavaScript WAS run), not ``http-get``, so a
14
+ JS-rendered page is honestly distinguished from a raw fetch."""
15
+ title, text = html_to_title_text(html)
16
+ return make_item(
17
+ kind="webpage", id=url, title=title or url, text=text,
18
+ source="browser", ref=url, method=method, fetched_at=fetched_at,
19
+ )
20
+
21
+
22
+ class BrowserSource:
23
+ """JavaScript-walled page intake via a headless browser. The isolated external-tool edge for
24
+ pages the static `web` adapter can only see the shell of.
25
+
26
+ Shells out to a headless Chromium-family browser (an external tool, not a Python dependency)
27
+ to dump the rendered DOM, then extracts text with the pure parser. The receipt's
28
+ ``browser-extract`` method records that JavaScript was executed to produce this text.
29
+
30
+ Two safety boundaries an operator must understand, because this edge is more powerful and more
31
+ exposed than the http edge:
32
+
33
+ - The scheme + private-host guard is applied only to the INITIAL navigation. Once the page
34
+ loads, a real browser follows its own redirects and loads sub-resources (fetch/XHR, iframes,
35
+ images) with NO host filtering, so a hostile or open-redirecting page can still reach an
36
+ internal address (an SSRF the receipt would then attest as gathered). The DNS-rebinding
37
+ window is wider here than for the http edge. Do not point this at untrusted URLs in an
38
+ environment with reachable internal services.
39
+ - The Chromium sandbox is left ON by default. ``no_sandbox=True`` disables it (sometimes needed
40
+ to run as root in a container) and is a real hardening downgrade while executing untrusted
41
+ JavaScript; use it only when you must, and prefer running as a non-root user instead.
42
+
43
+ fetch() needs the browser on PATH and network.
44
+ """
45
+
46
+ name = "browser"
47
+
48
+ def __init__(self, *, clock=time.time, browser: str = "chromium", timeout: float = 60.0,
49
+ virtual_time_ms: int = 8000, no_sandbox: bool = False) -> None:
50
+ self._clock = clock
51
+ self._browser = browser
52
+ self._timeout = timeout
53
+ self._virtual_time_ms = virtual_time_ms
54
+ self._no_sandbox = no_sandbox
55
+
56
+ def fetch(self, target: str) -> list[Item]:
57
+ url = validate_public_http_url(target) # guards the INITIAL navigation only (see class doc)
58
+ cmd = [self._browser, "--headless=new", "--disable-gpu"]
59
+ if self._no_sandbox:
60
+ cmd.append("--no-sandbox")
61
+ cmd += [f"--virtual-time-budget={int(self._virtual_time_ms)}", "--dump-dom", url]
62
+ proc = subprocess.run(cmd, capture_output=True, timeout=self._timeout)
63
+ if proc.returncode != 0:
64
+ raise RuntimeError(f"browser failed: {proc.stderr.decode('utf-8', 'replace').strip()[:200]}")
65
+ html = proc.stdout.decode("utf-8", "replace")
66
+ return [parse_browser(html, url, fetched_at=float(self._clock()))]
gather/cli.py ADDED
@@ -0,0 +1,141 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from gather import __version__
7
+ from gather.commands import (
8
+ cmd_api,
9
+ cmd_arxiv,
10
+ cmd_browser,
11
+ cmd_docs,
12
+ cmd_feed,
13
+ cmd_ocr,
14
+ cmd_parse,
15
+ cmd_pdf,
16
+ cmd_run,
17
+ cmd_transcribe,
18
+ cmd_video,
19
+ cmd_web,
20
+ )
21
+ from gather.corpus_cmd import cmd_corpus
22
+
23
+
24
+ def _add_common(p: argparse.ArgumentParser) -> None:
25
+ p.add_argument("--scope", default=None, help="comma-separated scope terms; keep items mentioning any")
26
+ p.add_argument("--json", action="store_true", help="emit the catalog and digest as JSON")
27
+ p.add_argument("--store", default=None, metavar="DIR", help="persist gathered items into a corpus at DIR")
28
+
29
+
30
+ def build_parser() -> argparse.ArgumentParser:
31
+ parser = argparse.ArgumentParser(prog="gather", description="Gather: accountable research intake.")
32
+ parser.add_argument("--version", action="version", version=f"gather {__version__}")
33
+ sub = parser.add_subparsers(dest="command")
34
+
35
+ parse = sub.add_parser("parse", help="parse a saved yt-dlp info.json (+ optional .vtt), offline, no network")
36
+ parse.add_argument("info", help="path to a yt-dlp info.json")
37
+ parse.add_argument("--vtt", default=None, help="path to a .vtt captions file")
38
+ parse.add_argument("--auto-captions", action="store_true",
39
+ help="captions are machine-generated: collapse rolling-window growth, stamp auto-caption")
40
+ _add_common(parse)
41
+ parse.set_defaults(func=cmd_parse)
42
+
43
+ video = sub.add_parser("video", help="fetch a video via yt-dlp (needs yt-dlp on PATH and network)")
44
+ video.add_argument("url")
45
+ video.add_argument("--comments", action="store_true", help="also gather comments")
46
+ _add_common(video)
47
+ video.set_defaults(func=cmd_video)
48
+
49
+ web = sub.add_parser("web", help="fetch a static web page via http(s) and extract readable text")
50
+ web.add_argument("url")
51
+ _add_common(web)
52
+ web.set_defaults(func=cmd_web)
53
+
54
+ feed = sub.add_parser("feed", help="fetch an RSS or Atom feed via http(s)")
55
+ feed.add_argument("url")
56
+ _add_common(feed)
57
+ feed.set_defaults(func=cmd_feed)
58
+
59
+ docs = sub.add_parser("docs", help="read a local text file or a directory of them, offline")
60
+ docs.add_argument("path")
61
+ _add_common(docs)
62
+ docs.set_defaults(func=cmd_docs)
63
+
64
+ arxiv = sub.add_parser("arxiv", help="fetch papers from the arXiv API by id or free-text query")
65
+ arxiv.add_argument("query", help="an arXiv id (2301.12345) or a search query")
66
+ arxiv.add_argument("--max-results", type=int, default=10, help="max results for a search query")
67
+ _add_common(arxiv)
68
+ arxiv.set_defaults(func=cmd_arxiv)
69
+
70
+ pdf = sub.add_parser("pdf", help="extract text from a local PDF (needs pdftotext on PATH)")
71
+ pdf.add_argument("path")
72
+ _add_common(pdf)
73
+ pdf.set_defaults(func=cmd_pdf)
74
+
75
+ api = sub.add_parser("api", help="fetch a JSON API with a bearer token from the environment")
76
+ api.add_argument("url")
77
+ api.add_argument("--auth-env", default="GATHER_API_TOKEN", help="env var holding the bearer token")
78
+ api.add_argument("--items-key", default=None, help="key of the records array in the JSON response")
79
+ api.add_argument("--text-key", default=None, help="record field to use as item text (else the whole record)")
80
+ api.add_argument("--id-key", default="id", help="record field to use as item id")
81
+ api.add_argument("--title-key", default="title", help="record field to use as item title")
82
+ _add_common(api)
83
+ api.set_defaults(func=cmd_api)
84
+
85
+ browser = sub.add_parser("browser", help="fetch a JS-rendered page via a headless browser (needs chromium on PATH)")
86
+ browser.add_argument("url")
87
+ browser.add_argument("--browser", default="chromium", help="headless browser binary")
88
+ browser.add_argument("--no-sandbox", action="store_true",
89
+ help="disable the Chromium sandbox (only if running as root in a container; a downgrade)")
90
+ _add_common(browser)
91
+ browser.set_defaults(func=cmd_browser)
92
+
93
+ ocr = sub.add_parser("ocr", help="recognize text in a local image via tesseract (needs tesseract on PATH)")
94
+ ocr.add_argument("path")
95
+ ocr.add_argument("--lang", default="eng", help="tesseract language code")
96
+ _add_common(ocr)
97
+ ocr.set_defaults(func=cmd_ocr)
98
+
99
+ transcribe = sub.add_parser("transcribe", help="transcribe a local audio file via whisper (needs whisper on PATH)")
100
+ transcribe.add_argument("path")
101
+ transcribe.add_argument("--model", default="base", help="whisper model name")
102
+ _add_common(transcribe)
103
+ transcribe.set_defaults(func=cmd_transcribe)
104
+
105
+ run = sub.add_parser("run", help="run a multi-source gather session from a JSON config")
106
+ run.add_argument("config",
107
+ help="JSON config: {jobs:[{source,target}], scope, store, "
108
+ "synthesize | synthesizer:[cmd...], synth_prompt}")
109
+ run.add_argument("--json", action="store_true", help="emit the witnessed run record as JSON")
110
+ run.set_defaults(func=cmd_run)
111
+
112
+ corpus = sub.add_parser("corpus", help="inspect a stored corpus: list/verify/digest/runs/search/stats/prune")
113
+ corpus.add_argument("action", choices=["list", "verify", "digest", "runs", "search", "stats", "prune"])
114
+ corpus.add_argument("dir", help="the corpus directory (created by --store)")
115
+ corpus.add_argument("--json", action="store_true", help="emit as JSON")
116
+ corpus.add_argument("--verify", action="store_true", help="with runs: re-check each record's seal")
117
+ corpus.add_argument("--apply", action="store_true", help="with prune: actually delete orphan objects")
118
+ corpus.add_argument("--terms", default=None,
119
+ help="with search: scope keywords, case-insensitive substrings of title+body (any match)")
120
+ corpus.add_argument("--source", default=None,
121
+ help="with search: keep items from any of these sources (comma-sep, OR within)")
122
+ corpus.add_argument("--kind", default=None, help="with search: keep items of any of these kinds (comma-sep)")
123
+ corpus.add_argument("--method", default=None, help="with search: keep items of any of these methods (comma-sep)")
124
+ corpus.add_argument("--limit", type=int, default=None, help="with search: cap the matches (<=0 means none)")
125
+ corpus.set_defaults(func=cmd_corpus)
126
+
127
+ return parser
128
+
129
+
130
+ def main(argv=None) -> int:
131
+ parser = build_parser()
132
+ args = parser.parse_args(argv)
133
+ func = getattr(args, "func", None)
134
+ if func is None:
135
+ parser.print_help()
136
+ return 1
137
+ return func(args)
138
+
139
+
140
+ if __name__ == "__main__":
141
+ sys.exit(main())