section-anything 0.1.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.
@@ -0,0 +1,90 @@
1
+ """Section Anything: find the sections of a PDF, with a cost/accuracy dial.
2
+
3
+ >>> import section_anything
4
+ >>> tree = section_anything.section("paper.pdf", mode="free")
5
+ >>> print(tree.to_markdown())
6
+
7
+ Pre-release: the API may change before 1.0. Names load lazily, so importing the package is cheap.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from importlib import import_module
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ __version__ = "0.1.0"
16
+
17
+ _EXPORTS = {
18
+ "section": "section_anything.api",
19
+ "asection": "section_anything.api",
20
+ "SectionTree": "section_anything.api",
21
+ "resolve_mode": "section_anything.api",
22
+ "Settings": "section_anything.config",
23
+ "Price": "section_anything.config",
24
+ "DEFAULT_PRICES": "section_anything.config",
25
+ "extract_pdf": "section_anything.isolate",
26
+ "check_pdf": "section_anything.isolate",
27
+ "Extraction": "section_anything.extract",
28
+ "SectionAnythingError": "section_anything.errors",
29
+ "ConfigError": "section_anything.errors",
30
+ "UnpricedModelError": "section_anything.errors",
31
+ "CostCapExceeded": "section_anything.errors",
32
+ "LlmCallFailed": "section_anything.errors",
33
+ "PdfError": "section_anything.errors",
34
+ "PdfTooLarge": "section_anything.errors",
35
+ "ExtractionTimeout": "section_anything.errors",
36
+ "ExtractionFailed": "section_anything.errors",
37
+ }
38
+
39
+ __all__ = [
40
+ "DEFAULT_PRICES",
41
+ "ConfigError",
42
+ "CostCapExceeded",
43
+ "Extraction",
44
+ "ExtractionFailed",
45
+ "ExtractionTimeout",
46
+ "LlmCallFailed",
47
+ "PdfError",
48
+ "PdfTooLarge",
49
+ "Price",
50
+ "SectionAnythingError",
51
+ "SectionTree",
52
+ "Settings",
53
+ "UnpricedModelError",
54
+ "__version__",
55
+ "asection",
56
+ "check_pdf",
57
+ "extract_pdf",
58
+ "resolve_mode",
59
+ "section",
60
+ ]
61
+
62
+ if TYPE_CHECKING:
63
+ from section_anything.api import SectionTree, asection, resolve_mode, section
64
+ from section_anything.config import DEFAULT_PRICES, Price, Settings
65
+ from section_anything.errors import (
66
+ ConfigError,
67
+ CostCapExceeded,
68
+ ExtractionFailed,
69
+ ExtractionTimeout,
70
+ LlmCallFailed,
71
+ PdfError,
72
+ PdfTooLarge,
73
+ SectionAnythingError,
74
+ UnpricedModelError,
75
+ )
76
+ from section_anything.extract import Extraction
77
+ from section_anything.isolate import check_pdf, extract_pdf
78
+
79
+
80
+ def __getattr__(name: str) -> Any:
81
+ module = _EXPORTS.get(name)
82
+ if module is None:
83
+ raise AttributeError(f"module 'section_anything' has no attribute {name!r}")
84
+ value = getattr(import_module(module), name)
85
+ globals()[name] = value
86
+ return value
87
+
88
+
89
+ def __dir__() -> list[str]:
90
+ return sorted(__all__)
@@ -0,0 +1,5 @@
1
+ import sys
2
+
3
+ from section_anything.cli import main
4
+
5
+ sys.exit(main())
@@ -0,0 +1,66 @@
1
+ """Extraction worker: ``python -m section_anything._worker PDF OUT MAX_PAGES MAX_CHARS MEMORY_MB``.
2
+
3
+ Started by `section_anything.isolate`. Writes the extraction to OUT as JSON and reports progress
4
+ and the outcome as JSON lines on its original stdout. Error messages carry only text written by
5
+ this package or an exception's type name, never document text.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+
15
+
16
+ def _limit_memory(mb: int) -> None:
17
+ """Cap the address space on Linux. macOS does not enforce RLIMIT_AS, so it is skipped there."""
18
+ if mb <= 0 or not sys.platform.startswith("linux"):
19
+ return
20
+ try:
21
+ import resource
22
+
23
+ limit = mb * 1024 * 1024
24
+ resource.setrlimit(resource.RLIMIT_AS, (limit, limit))
25
+ except (ImportError, ValueError, OSError):
26
+ pass
27
+
28
+
29
+ def main(argv: list[str] | None = None) -> int:
30
+ args = sys.argv[1:] if argv is None else argv
31
+ pdf, out, max_pages, max_chars, memory_mb = args[0], args[1], int(args[2]), int(args[3]), int(args[4])
32
+ # Keep the protocol on the original stdout; anything else that prints goes to stderr.
33
+ proto = os.fdopen(os.dup(1), "w", buffering=1, encoding="utf-8")
34
+ os.dup2(2, 1)
35
+ sys.stdout = sys.stderr
36
+
37
+ def emit(msg: dict) -> None:
38
+ proto.write(json.dumps(msg) + "\n")
39
+ proto.flush()
40
+
41
+ _limit_memory(memory_mb)
42
+ from section_anything.errors import PdfError, PdfTooLarge
43
+ from section_anything.extract import extract
44
+
45
+ try:
46
+ ext = extract(Path(pdf), lambda n, total: emit({"page": n, "total": total}),
47
+ max_pages=max_pages, max_chars=max_chars)
48
+ ext.save(Path(out))
49
+ except PdfTooLarge as exc:
50
+ emit({"error": "too_large", "message": str(exc)})
51
+ return 2
52
+ except PdfError as exc:
53
+ emit({"error": "pdf", "message": str(exc)})
54
+ return 2
55
+ except MemoryError:
56
+ emit({"error": "memory", "message": f"out of memory (extract_memory_mb={memory_mb})"})
57
+ return 3
58
+ except Exception as exc: # noqa: BLE001 — reported by type only: messages may quote the PDF
59
+ emit({"error": "internal", "message": type(exc).__name__})
60
+ return 1
61
+ emit({"done": True})
62
+ return 0
63
+
64
+
65
+ if __name__ == "__main__":
66
+ sys.exit(main())
@@ -0,0 +1,267 @@
1
+ """Public API: `section(pdf, mode)` -> `SectionTree`.
2
+
3
+ Modes, cheapest first:
4
+
5
+ - ``free``: no model. PDF bookmarks or the printed table of contents as the outline, then
6
+ typography and numbering rules. Nothing leaves the machine.
7
+ - ``quick``: the rules propose candidate heading lines; a small model keeps the real ones and
8
+ sets their levels. Sends only the candidate lines (plus a short peek at the next line).
9
+ - ``standard``: a model reads every line, with font hints, and marks where sections start and
10
+ end. Sends the document text.
11
+ - ``local:<model>``: quick, answered by a local Ollama model. Free; nothing leaves the machine.
12
+
13
+ Costs: a cloud model with no known price is refused before anything is read. Before a paid mode
14
+ runs, its estimated cost is checked against the per-document cap (`max_usd`); a run over the cap
15
+ is downgraded (standard -> quick -> free) or refused (``over_budget="fail"``). While it runs, the
16
+ cap is re-checked before every call and each call's output is limited to what the remaining budget
17
+ can pay for.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import concurrent.futures
24
+ import json
25
+ import os
26
+ import time
27
+ from collections.abc import Callable, Mapping
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ from section_anything.config import LOCAL_PREFIX, Price, Settings
33
+ from section_anything.errors import ConfigError, CostCapExceeded, PdfError, UnpricedModelError
34
+ from section_anything.export import heading_text, markdown_hashes, section_bodies
35
+ from section_anything.extract import Extraction
36
+ from section_anything.isolate import extract_pdf
37
+ from section_anything.llm import Usage
38
+
39
+ MODES = ("free", "quick", "standard", "local")
40
+ STRATEGY = {"free": "rules", "quick": "llm_review", "standard": "llm_text", "local": "llm_review"}
41
+ DOWNGRADE = {"standard": "quick", "quick": "free", "local": None, "free": None}
42
+ SECTION_KEYS = ("number", "title", "level", "page_start", "page_end", "start", "end", "flags")
43
+
44
+ Progress = Callable[[float, str], None]
45
+
46
+
47
+ def resolve_mode(mode: str, settings: Settings, model: str | None = None) -> tuple[str, str | None]:
48
+ """'free' | 'quick' | 'standard' | 'local:<ollama model>' -> (mode, model id or None).
49
+
50
+ `model` replaces the configured model of a quick or standard run, or names the local model."""
51
+ m = (mode or "free").strip()
52
+ if m == "local" or m.startswith("local:"):
53
+ tag = m.partition(":")[2].strip() or (model or "").strip() or settings.local_model.strip()
54
+ if not tag:
55
+ raise ConfigError("local mode needs a model: mode='local:<ollama model>' or SECTIONANY_LOCAL_MODEL")
56
+ return "local", LOCAL_PREFIX + tag
57
+ if m == "free":
58
+ return "free", None
59
+ if m == "quick":
60
+ return "quick", model or settings.quick_model
61
+ if m == "standard":
62
+ return "standard", model or settings.standard_model
63
+ raise ConfigError(f"unknown mode {m!r}: use free, quick, standard or local:<ollama model>")
64
+
65
+
66
+ def _require_price(mode: str, model: str | None, settings: Settings) -> Price | None:
67
+ if model is None:
68
+ return None
69
+ price = settings.price_for(model)
70
+ if price is None:
71
+ raise UnpricedModelError(
72
+ f"no price for model {model!r}: pass prices={{{model!r}: {{'input': ..., 'output': ...}}}} "
73
+ "(USD per 1M tokens) or set SECTIONANY_PRICES, so the per-document cost cap can be enforced"
74
+ )
75
+ if mode in ("quick", "standard") and settings.api_key is None:
76
+ raise ConfigError(f"{mode} mode needs an API key: set SECTIONANY_API_KEY or pass api_key=")
77
+ return price
78
+
79
+
80
+ @dataclass
81
+ class SectionTree:
82
+ """The sections of one document, in reading order.
83
+
84
+ `sections` holds one dict per section with number, title, level (1 = top), page_start,
85
+ page_end, start and end (character offsets into `text`) and flags (for example
86
+ ``doc_title``, ``front_matter``, ``start_marker_fuzzy``)."""
87
+
88
+ sections: list[dict]
89
+ mode: str
90
+ model: str | None
91
+ pages: int
92
+ usage: dict
93
+ seconds: float
94
+ downgraded: list[str] = field(default_factory=list)
95
+ extraction: Extraction | None = field(default=None, repr=False)
96
+ resolved: list[dict] = field(default_factory=list, repr=False) # full pipeline output
97
+
98
+ @property
99
+ def text(self) -> str:
100
+ """The document text the character offsets refer to."""
101
+ return self.extraction.text if self.extraction is not None else ""
102
+
103
+ @property
104
+ def cost_usd(self) -> float:
105
+ return float(self.usage.get("cost_usd") or 0.0)
106
+
107
+ def summary(self) -> dict:
108
+ """Mode, model, size, calls, tokens and cost of the run."""
109
+ return {
110
+ "mode": self.mode,
111
+ "model": self.model,
112
+ "pages": self.pages,
113
+ "sections": len(self.sections),
114
+ "flagged": sum(bool(s["flags"]) for s in self.sections),
115
+ "calls": self.usage.get("calls", 0),
116
+ "input_tokens": self.usage.get("input_tokens", 0),
117
+ "output_tokens": self.usage.get("output_tokens", 0),
118
+ "cost_usd": self.cost_usd,
119
+ "seconds": self.seconds,
120
+ "downgraded": list(self.downgraded),
121
+ }
122
+
123
+ def to_markdown(self, body: bool = False) -> str:
124
+ """Headings as Markdown: the document title (flag ``doc_title``) as '#', other headings
125
+ as '#' x (level + 1), capped at six. With `body`, each heading is followed by its section's
126
+ text reflowed into paragraphs; otherwise front matter is left out."""
127
+ out: list[str] = []
128
+ if body and self.extraction is not None and self.resolved:
129
+ from section_anything.structure import furniture_of
130
+
131
+ bodies = section_bodies(self.extraction, self.resolved, furniture_of(self.extraction))
132
+ for sec, paras in zip(self.resolved, bodies, strict=True):
133
+ if "front_matter" not in sec["flags"]:
134
+ out.append(f"{markdown_hashes(sec)} {heading_text(sec)}")
135
+ out.extend(paras)
136
+ return "\n\n".join(out) + "\n"
137
+ for sec in self.sections:
138
+ if "front_matter" in sec["flags"]:
139
+ continue
140
+ out.append(f"{markdown_hashes(sec)} {heading_text(sec)}")
141
+ return "\n".join(out) + ("\n" if out else "")
142
+
143
+ def to_dict(self) -> dict:
144
+ from section_anything import __version__
145
+
146
+ return {"section_anything": __version__, **self.summary(), "usage": self.usage,
147
+ "sections": self.sections}
148
+
149
+ def to_json(self, indent: int | None = 2) -> str:
150
+ return json.dumps(self.to_dict(), ensure_ascii=False, indent=indent)
151
+
152
+
153
+ def _public(sec: dict) -> dict:
154
+ out = {k: (list(sec[k]) if k == "flags" else sec[k]) for k in SECTION_KEYS}
155
+ if sec.get("title_from_text") is False and "front_matter" not in out["flags"]:
156
+ out["flags"].append("generated_title")
157
+ return out
158
+
159
+
160
+ def _settings_for(settings: Settings | None, **overrides: Any) -> Settings:
161
+ base = settings if settings is not None else Settings()
162
+ prices = overrides.pop("prices", None)
163
+ if prices is not None:
164
+ overrides["prices"] = {m: Price.model_validate(p) for m, p in dict(prices).items()}
165
+ return base.with_overrides(**overrides)
166
+
167
+
168
+ async def asection(
169
+ pdf_path: str | os.PathLike[str],
170
+ mode: str = "free",
171
+ *,
172
+ settings: Settings | None = None,
173
+ api_key: str | None = None,
174
+ base_url: str | None = None,
175
+ model: str | None = None,
176
+ max_usd: float | None = None,
177
+ prices: Mapping[str, Mapping[str, float] | Price] | None = None,
178
+ over_budget: str | None = None,
179
+ max_pages: int | None = None,
180
+ extract_timeout: float | None = None,
181
+ progress: Progress | None = None,
182
+ ) -> SectionTree:
183
+ """Async form of `section`."""
184
+ from section_anything import pipeline
185
+
186
+ s = _settings_for(settings, api_key=api_key, base_url=base_url, max_usd_per_doc=max_usd, prices=prices,
187
+ over_budget=over_budget, max_pages=max_pages, extract_timeout_seconds=extract_timeout)
188
+ mode, model = resolve_mode(mode, s, model)
189
+ _require_price(mode, model, s) # before the PDF is read: a refused run costs nothing
190
+ report = progress or (lambda _f, _m: None)
191
+
192
+ t0 = time.monotonic()
193
+ ext = await asyncio.to_thread(
194
+ extract_pdf, Path(pdf_path), settings=s,
195
+ on_page=lambda n, total: report(0.5 * n / total, f"Reading page {n} of {total}"),
196
+ )
197
+ if not ext.text.strip():
198
+ raise PdfError("no text layer: the PDF needs OCR first")
199
+
200
+ cap = s.max_usd_per_doc
201
+ usage = Usage() # one tally per document: a downgrade continues the same budget
202
+ downgraded: list[str] = []
203
+ while True:
204
+ strategy = pipeline.BY_ID[STRATEGY[mode]]
205
+ price = s.price_for(model) if model else None
206
+ if model and price is not None and strategy.uses_llm:
207
+ est = strategy.estimate(ext)
208
+ usd = price.cost(est.input_tokens, est.output_tokens)
209
+ if usage.cost_usd + usd > cap:
210
+ note = f"{mode}: estimated ${usd:.2f}, over the ${cap:.2f} per-document cap"
211
+ if s.over_budget == "fail" or DOWNGRADE[mode] is None:
212
+ raise CostCapExceeded(f"{note} (max_usd, SECTIONANY_MAX_USD_PER_DOC)")
213
+ downgraded.append(note)
214
+ mode, model = _next_mode(mode, s, downgraded)
215
+ continue
216
+
217
+ def stage(f: float, msg: str) -> None:
218
+ report(0.5 + 0.5 * f, msg)
219
+
220
+ try:
221
+ _raw, resolved, _metrics = await pipeline.run(
222
+ ext, strategy.id, model or "", cap, stage, usage=usage, settings=s)
223
+ except CostCapExceeded as exc:
224
+ if s.over_budget == "fail" or DOWNGRADE[mode] is None:
225
+ raise
226
+ downgraded.append(f"{mode}: {exc}")
227
+ mode, model = _next_mode(mode, s, downgraded)
228
+ continue
229
+ return SectionTree(
230
+ sections=[_public(x) for x in resolved], mode=mode, model=model, pages=len(ext.pages),
231
+ usage=usage.summary(), seconds=round(time.monotonic() - t0, 2), downgraded=downgraded,
232
+ extraction=ext, resolved=resolved,
233
+ )
234
+
235
+
236
+ def _next_mode(mode: str, s: Settings, notes: list[str]) -> tuple[str, str | None]:
237
+ nxt = DOWNGRADE[mode]
238
+ while nxt is not None:
239
+ nxt_mode, nxt_model = resolve_mode(nxt, s)
240
+ if nxt_model is None or s.price_for(nxt_model) is not None:
241
+ return nxt_mode, nxt_model
242
+ notes.append(f"{nxt}: skipped, no price for model {nxt_model!r}")
243
+ nxt = DOWNGRADE[nxt]
244
+ raise ConfigError("no mode left to downgrade to") # unreachable: free is always available
245
+
246
+
247
+ def section(
248
+ pdf_path: str | os.PathLike[str],
249
+ mode: str = "free",
250
+ **opts: Any,
251
+ ) -> SectionTree:
252
+ """Find the sections of a PDF.
253
+
254
+ `mode` is "free", "quick", "standard" or "local:<ollama model>". Options (each overrides the
255
+ matching ``SECTIONANY_*`` environment variable): `api_key`, `base_url`, `model`, `max_usd`
256
+ (per-document cap), `prices` ({model: {"input", "output"}} USD per 1M tokens, merged with the
257
+ built-in list), `over_budget` ("downgrade" or "fail"), `max_pages`, `extract_timeout`
258
+ (seconds), `progress` (callback(fraction, message)) and `settings` (a `Settings` object).
259
+ """
260
+ coro = asection(pdf_path, mode, **opts)
261
+ try:
262
+ asyncio.get_running_loop()
263
+ except RuntimeError:
264
+ return asyncio.run(coro)
265
+ # Called from inside an event loop (a notebook, an async app): run on a separate thread.
266
+ with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
267
+ return pool.submit(asyncio.run, coro).result()
@@ -0,0 +1,70 @@
1
+ """Browser bridge: answer a run's model calls with a model on someone else's machine.
2
+
3
+ A run whose model is "browser/<name>" never calls a provider. Each model call is queued here; a
4
+ client (for example a web page that can reach the user's own Ollama or LM Studio) long-polls
5
+ `next()`, sends the prompt to its model and posts the reply back with `resolve()`. The reply is
6
+ validated against the call's schema like any other model output.
7
+
8
+ The queue is in memory and per process: the host application must route a run's polling
9
+ requests to the process that runs it.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import asyncio
15
+ import uuid
16
+ from typing import Any
17
+
18
+ from section_anything.config import BROWSER_PREFIX
19
+ from section_anything.errors import SectionAnythingError
20
+
21
+ __all__ = ["BROWSER_PREFIX", "BridgeError", "BridgeTimeout", "BrowserBridge"]
22
+
23
+
24
+ class BridgeError(SectionAnythingError, RuntimeError):
25
+ """The remote model reported an error."""
26
+
27
+
28
+ class BridgeTimeout(BridgeError, TimeoutError):
29
+ """No reply arrived in time."""
30
+
31
+
32
+ class BrowserBridge:
33
+ def __init__(self, wait_seconds: float = 300.0) -> None:
34
+ self.wait_seconds = wait_seconds
35
+ self._queue: asyncio.Queue[dict[str, Any]] = asyncio.Queue()
36
+ self._pending: dict[str, asyncio.Future[str]] = {}
37
+
38
+ async def call(self, payload: dict[str, Any], wait_seconds: float | None = None) -> str:
39
+ """Queue one model call and wait for the reply text."""
40
+ rid = uuid.uuid4().hex
41
+ fut: asyncio.Future[str] = asyncio.get_running_loop().create_future()
42
+ self._pending[rid] = fut
43
+ await self._queue.put({"id": rid, **payload})
44
+ try:
45
+ async with asyncio.timeout(self.wait_seconds if wait_seconds is None else wait_seconds):
46
+ return await fut
47
+ except TimeoutError as exc:
48
+ raise BridgeTimeout(
49
+ "no reply from your local model: keep the page open and check that the model is running"
50
+ ) from exc
51
+ finally:
52
+ self._pending.pop(rid, None)
53
+
54
+ async def next(self, wait: float) -> dict[str, Any] | None:
55
+ """The next queued call, or None after `wait` seconds."""
56
+ try:
57
+ async with asyncio.timeout(wait):
58
+ return await self._queue.get()
59
+ except TimeoutError:
60
+ return None
61
+
62
+ def resolve(self, rid: str, content: str | None, error: str | None) -> bool:
63
+ fut = self._pending.get(rid)
64
+ if fut is None or fut.done():
65
+ return False
66
+ if error is not None:
67
+ fut.set_exception(BridgeError(f"local model error: {error[:300]}"))
68
+ else:
69
+ fut.set_result(content or "")
70
+ return True
@@ -0,0 +1,77 @@
1
+ """Command line: ``section-anything FILE.pdf --mode quick --format md``.
2
+
3
+ The result goes to stdout (or ``--output``); a one-line cost summary goes to stderr.
4
+ Exit codes: 0 done, 2 bad arguments or configuration, 3 refused by a cost cap, 4 the PDF could not
5
+ be read, 1 anything else.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import sys
12
+ from pathlib import Path
13
+
14
+ from section_anything.errors import ConfigError, CostCapExceeded, ExtractionFailed, ExtractionTimeout, PdfError
15
+
16
+
17
+ def build_parser() -> argparse.ArgumentParser:
18
+ from section_anything import __version__
19
+
20
+ p = argparse.ArgumentParser(
21
+ prog="section-anything",
22
+ description="Find the sections (headings, levels, page and character spans) of a PDF.",
23
+ )
24
+ p.add_argument("pdf", type=Path, help="PDF file")
25
+ p.add_argument("--mode", default="free",
26
+ help="free (default), quick, standard, or local:<ollama model>")
27
+ p.add_argument("--format", choices=("md", "json"), default="md", help="output format (default md)")
28
+ p.add_argument("--body", action="store_true", help="md: include each section's text, not only headings")
29
+ p.add_argument("--max-usd", type=float, default=None, help="per-document cost cap in USD (default 0.50)")
30
+ p.add_argument("--over-budget", choices=("downgrade", "fail"), default=None,
31
+ help="over the cap: downgrade standard -> quick -> free (default), or fail")
32
+ p.add_argument("--model", default=None, help="model for quick/standard, overriding the configured one")
33
+ p.add_argument("--base-url", default=None, help="OpenAI-compatible endpoint (default OpenAI)")
34
+ p.add_argument("--max-pages", type=int, default=None, help="refuse PDFs with more pages (default 500)")
35
+ p.add_argument("--timeout", type=float, default=None, help="extraction time limit in seconds (default 120)")
36
+ p.add_argument("-o", "--output", type=Path, default=None, help="write the result to this file")
37
+ p.add_argument("-q", "--quiet", action="store_true", help="no summary on stderr")
38
+ p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
39
+ return p
40
+
41
+
42
+ def main(argv: list[str] | None = None) -> int:
43
+ args = build_parser().parse_args(argv)
44
+ from section_anything.api import section
45
+
46
+ try:
47
+ tree = section(
48
+ args.pdf, args.mode, model=args.model, base_url=args.base_url, max_usd=args.max_usd,
49
+ over_budget=args.over_budget, max_pages=args.max_pages, extract_timeout=args.timeout,
50
+ )
51
+ except (PdfError, ExtractionTimeout, ExtractionFailed) as exc:
52
+ print(f"section-anything: {exc}", file=sys.stderr)
53
+ return 4
54
+ except CostCapExceeded as exc:
55
+ print(f"section-anything: {exc}", file=sys.stderr)
56
+ return 3
57
+ except (ConfigError, ValueError) as exc: # includes invalid settings values
58
+ print(f"section-anything: {exc}", file=sys.stderr)
59
+ return 2
60
+
61
+ out = tree.to_json() + "\n" if args.format == "json" else tree.to_markdown(body=args.body)
62
+ if args.output:
63
+ args.output.write_text(out, encoding="utf-8")
64
+ else:
65
+ sys.stdout.write(out)
66
+ if not args.quiet:
67
+ s = tree.summary()
68
+ cost = "unpriced" if tree.usage.get("cost_usd") is None else f"${s['cost_usd']:.4f}"
69
+ note = f"; downgraded: {' | '.join(s['downgraded'])}" if s["downgraded"] else ""
70
+ print(f"{s['sections']} sections, {s['pages']} pages, mode {s['mode']}"
71
+ f"{' (' + s['model'] + ')' if s['model'] else ''}, {s['calls']} model calls, {cost}, "
72
+ f"{s['seconds']:.1f} s{note}", file=sys.stderr)
73
+ return 0
74
+
75
+
76
+ if __name__ == "__main__":
77
+ sys.exit(main())