docline 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.
Files changed (95) hide show
  1. docline/__init__.py +1 -0
  2. docline/__main__.py +16 -0
  3. docline/_tools/__init__.py +9 -0
  4. docline/_tools/docling_worker.py +404 -0
  5. docline/app.py +1048 -0
  6. docline/app_models.py +201 -0
  7. docline/cli.py +595 -0
  8. docline/config.py +57 -0
  9. docline/dependencies.py +86 -0
  10. docline/elt/__init__.py +1 -0
  11. docline/elt/config.py +202 -0
  12. docline/elt/execute.py +608 -0
  13. docline/elt/manifest_models.py +93 -0
  14. docline/elt/models.py +82 -0
  15. docline/elt/orchestrate.py +47 -0
  16. docline/elt/paths.py +74 -0
  17. docline/elt/source_keys.py +79 -0
  18. docline/fetch/__init__.py +0 -0
  19. docline/fetch/crawl.py +557 -0
  20. docline/fetch/html_extract.py +290 -0
  21. docline/fetch/html_normalize.py +92 -0
  22. docline/fetch/http.py +159 -0
  23. docline/fetch/models.py +43 -0
  24. docline/fetch/sitemap.py +272 -0
  25. docline/fetch/staging.py +166 -0
  26. docline/fetch/url_canonical.py +127 -0
  27. docline/fetch/url_policy.py +92 -0
  28. docline/mcp/__init__.py +1 -0
  29. docline/mcp/exceptions.py +7 -0
  30. docline/mcp/server.py +113 -0
  31. docline/paths.py +145 -0
  32. docline/process/__init__.py +5 -0
  33. docline/process/assemble.py +162 -0
  34. docline/process/ast_lint.py +65 -0
  35. docline/process/batch_dispatch.py +243 -0
  36. docline/process/canonical_url.py +149 -0
  37. docline/process/correction.py +62 -0
  38. docline/process/cross_doc_links.py +179 -0
  39. docline/process/docfx_includes.py +126 -0
  40. docline/process/docfx_normalize.py +144 -0
  41. docline/process/docfx_tabs.py +105 -0
  42. docline/process/fidelity_scorer.py +870 -0
  43. docline/process/hashing.py +22 -0
  44. docline/process/heading_validation.py +241 -0
  45. docline/process/identity.py +37 -0
  46. docline/process/manifest.py +84 -0
  47. docline/process/metadata.py +86 -0
  48. docline/process/ocr_cap.py +99 -0
  49. docline/process/output.py +25 -0
  50. docline/process/output_contract.py +449 -0
  51. docline/process/page_range.py +199 -0
  52. docline/process/pdf_batch.py +568 -0
  53. docline/process/pdf_triage.py +1114 -0
  54. docline/process/prompts.py +57 -0
  55. docline/process/quality_metrics.py +213 -0
  56. docline/process/quarantine.py +66 -0
  57. docline/process/segment.py +211 -0
  58. docline/process/toc_parser.py +122 -0
  59. docline/process/transcripts.py +134 -0
  60. docline/progress.py +279 -0
  61. docline/quarantine_viewer.py +125 -0
  62. docline/readers/__init__.py +1 -0
  63. docline/readers/documents.py +54 -0
  64. docline/readers/docx.py +692 -0
  65. docline/readers/github.py +207 -0
  66. docline/readers/limits.py +106 -0
  67. docline/readers/mistral.py +201 -0
  68. docline/readers/openapi/__init__.py +11 -0
  69. docline/readers/openapi/convert.py +339 -0
  70. docline/readers/openapi/detect.py +124 -0
  71. docline/readers/openapi/errors.py +29 -0
  72. docline/readers/openapi/loader.py +176 -0
  73. docline/readers/openapi/reader.py +313 -0
  74. docline/readers/openapi/render.py +418 -0
  75. docline/readers/openapi/resolve.py +192 -0
  76. docline/readers/pdf.py +940 -0
  77. docline/readers/pdf_splitter.py +139 -0
  78. docline/readers/picture_sink.py +113 -0
  79. docline/readers/text.py +47 -0
  80. docline/readers/transcripts.py +215 -0
  81. docline/router.py +46 -0
  82. docline/runtime/__init__.py +30 -0
  83. docline/runtime/ocr_budget.py +168 -0
  84. docline/runtime/resource_probe.py +369 -0
  85. docline/schema/__init__.py +0 -0
  86. docline/schema/export.py +43 -0
  87. docline/schema/library.py +200 -0
  88. docline/schema/models.py +71 -0
  89. docline/types.py +36 -0
  90. docline-0.1.0.dist-info/METADATA +277 -0
  91. docline-0.1.0.dist-info/RECORD +95 -0
  92. docline-0.1.0.dist-info/WHEEL +5 -0
  93. docline-0.1.0.dist-info/entry_points.txt +2 -0
  94. docline-0.1.0.dist-info/licenses/LICENSE +21 -0
  95. docline-0.1.0.dist-info/top_level.txt +1 -0
docline/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """docline — document ingestion and normalization pipeline."""
docline/__main__.py ADDED
@@ -0,0 +1,16 @@
1
+ """Package entrypoint for ``python -m docline``."""
2
+
3
+ from docline.cli import main as cli_main
4
+
5
+
6
+ def main() -> int:
7
+ """Run the package entrypoint.
8
+
9
+ Returns:
10
+ Process exit code.
11
+ """
12
+ return cli_main()
13
+
14
+
15
+ if __name__ == "__main__":
16
+ raise SystemExit(main())
@@ -0,0 +1,9 @@
1
+ """Internal subprocess CLIs invoked by the docline batch processor.
2
+
3
+ These tools are not part of the public docline CLI surface; they exist
4
+ so the batch processor can run docling (which loads heavy PyTorch
5
+ models and can OOM with hard-to-catch C-level errors) in an isolated
6
+ child process. If a child crashes, the OS reaps it cleanly and the
7
+ parent records a non-zero exit code, downgrading that chunk to the
8
+ heuristic engine.
9
+ """
@@ -0,0 +1,404 @@
1
+ """Subprocess CLI that runs docling on a single PDF or a batch of PDFs.
2
+
3
+ Invoked by :mod:`docline.process.pdf_batch` and
4
+ :mod:`docline.process.pdf_triage`.
5
+
6
+ Single-chunk mode (legacy)::
7
+
8
+ python -m docline._tools.docling_worker INPUT_PDF OUTPUT_MD [--no-ocr] [--ocr-scale=N]
9
+
10
+ Batched mode (030-F T3)::
11
+
12
+ python -m docline._tools.docling_worker --batch MANIFEST_JSON
13
+
14
+ In batched mode the worker imports docling and loads its layout model
15
+ ONCE, then iterates the manifest writing one envelope per chunk to its
16
+ output path. This amortizes the ~5-10s docling cold-start cost across
17
+ N chunks instead of paying it per invocation. The manifest format is::
18
+
19
+ {
20
+ "chunks": [
21
+ {"input": "/abs/path/chunk-1.pdf", "output": "/abs/path/chunk-1.md"},
22
+ {"input": "/abs/path/chunk-2.pdf", "output": "/abs/path/chunk-2.md"}
23
+ ]
24
+ }
25
+
26
+ Each chunk may include an optional ``"do_ocr"`` boolean (default ``true``);
27
+ set it ``false`` to skip OCR for ranges whose pages already carry an
28
+ extractable text layer (034-F). The single-chunk CLI exposes the same
29
+ control via the optional ``--no-ocr`` flag. Each chunk may also include an
30
+ optional ``"ocr_scale"`` float (040-F) overriding the page render scale
31
+ (``images_scale``); a lower value cuts OCR peak memory. The single-chunk CLI
32
+ exposes this via ``--ocr-scale=N``.
33
+
34
+ Per-chunk failures during batched processing write an error envelope to
35
+ that chunk's output path (with the legacy schema_version=1 plus an
36
+ ``error`` field) and the worker continues with the next chunk. The
37
+ process exit code is 0 if AT LEAST ONE chunk succeeded; non-zero only
38
+ if docling import or model load itself failed (so the parent decides
39
+ whether to retry single-chunk or fall back to heuristic for everything).
40
+
41
+ Writes a JSON envelope (see schema below) describing the docling-rendered
42
+ Markdown to the output path(s) and exits 0 on success. On failure (missing
43
+ input, docling import error, docling runtime error, or any other
44
+ exception), writes a structured JSON diagnostic line to stderr and
45
+ exits non-zero so the parent batch processor can route the chunk to
46
+ the heuristic fallback.
47
+
48
+ The exit codes are:
49
+
50
+ * ``0`` — success (at least one chunk succeeded in batched mode)
51
+ * ``2`` — bad CLI arguments or malformed manifest
52
+ * ``3`` — input PDF does not exist (single-chunk mode only;
53
+ per-chunk in batched mode writes an error envelope instead)
54
+ * ``4`` — docling extras not installed
55
+ * ``5`` — docling raised an exception during conversion
56
+ (single-chunk mode; or import / model load failure in batched mode)
57
+ * ``6`` — any other unexpected error (including envelope serialization
58
+ or all-chunks-failed in batched mode)
59
+
60
+ Output envelope (schema_version 1)
61
+ ----------------------------------
62
+
63
+ The output file is a JSON object with the following shape::
64
+
65
+ {
66
+ "schema_version": 1,
67
+ "pages": ["page 1 markdown", "page 2 markdown", ...],
68
+ "page_count": N,
69
+ "text": "page 1 markdown\\n\\npage 2 markdown\\n\\n..."
70
+ }
71
+
72
+ In batched mode, a chunk that failed mid-loop has the additional
73
+ ``error`` field set to ``repr(exception)`` and ``pages``/``page_count``
74
+ empty/zero so consumers can detect the failure and route to heuristic
75
+ fallback for that chunk only.
76
+
77
+ Fields:
78
+
79
+ * ``schema_version`` — integer; bumped on any breaking envelope change.
80
+ * ``pages`` — list of per-page markdown strings, one entry per source
81
+ PDF page.
82
+ * ``page_count`` — integer; redundancy check, equals ``len(pages)``.
83
+ * ``text`` — convenience field; equals ``"\\n\\n".join(pages)``.
84
+ * ``error`` — present only when this chunk failed in batched mode;
85
+ consumers check ``"error" in envelope`` to detect.
86
+
87
+ Running each chunk in its own subprocess gives the OS a chance to
88
+ reclaim torch tensor working set between calls (PyTorch's CPU
89
+ allocator does not return memory to the OS reliably) and contains
90
+ C-level crashes (``c10::Error``, SIGABRT, etc.) that would otherwise
91
+ abort the parent. Batched mode trades that per-chunk isolation for
92
+ shared model-load cost — use it for chunk groups where the per-chunk
93
+ isolation is less critical than the model-load amortization.
94
+ """
95
+
96
+ from __future__ import annotations
97
+
98
+ import json
99
+ import sys
100
+ from pathlib import Path
101
+ from typing import Any
102
+
103
+ ENVELOPE_SCHEMA_VERSION = 1
104
+
105
+
106
+ def _emit_diagnostic(stage: str, error: str, exception: str | None = None) -> None:
107
+ """Write a one-line JSON diagnostic to stderr."""
108
+
109
+ payload: dict[str, str] = {"stage": stage, "error": error}
110
+ if exception:
111
+ payload["exception"] = exception
112
+ sys.stderr.write(json.dumps(payload) + "\n")
113
+ sys.stderr.flush()
114
+
115
+
116
+ def _build_envelope(pages: list[str]) -> dict[str, object]:
117
+ """Build the JSON envelope from a per-page markdown list.
118
+
119
+ Args:
120
+ pages: Per-page markdown strings as returned by
121
+ :func:`docline.readers.pdf._read_pdf_docling_pages`.
122
+
123
+ Returns:
124
+ A dict with ``schema_version``, ``pages``, ``page_count``,
125
+ and ``text`` keys. Safe to pass to :func:`json.dumps`.
126
+ """
127
+
128
+ return {
129
+ "schema_version": ENVELOPE_SCHEMA_VERSION,
130
+ "pages": pages,
131
+ "page_count": len(pages),
132
+ "text": "\n\n".join(pages),
133
+ }
134
+
135
+
136
+ def _build_error_envelope(exception: Exception) -> dict[str, object]:
137
+ """Build an error envelope for a failed chunk in batched mode.
138
+
139
+ Args:
140
+ exception: The exception raised while processing the chunk.
141
+
142
+ Returns:
143
+ An envelope with empty ``pages`` and an ``error`` field
144
+ containing ``repr(exception)`` for diagnostic surfacing.
145
+ """
146
+
147
+ return {
148
+ "schema_version": ENVELOPE_SCHEMA_VERSION,
149
+ "pages": [],
150
+ "page_count": 0,
151
+ "text": "",
152
+ "error": repr(exception),
153
+ }
154
+
155
+
156
+ def _write_envelope(output_path: Path, envelope: dict[str, object]) -> None:
157
+ """Serialize and write an envelope to ``output_path``."""
158
+
159
+ body = json.dumps(envelope, ensure_ascii=False)
160
+ output_path.parent.mkdir(parents=True, exist_ok=True)
161
+ output_path.write_text(body, encoding="utf-8")
162
+
163
+
164
+ def _run_single(
165
+ input_path: Path,
166
+ output_path: Path,
167
+ *,
168
+ do_ocr: bool = True,
169
+ ocr_scale: float | None = None,
170
+ ) -> int:
171
+ """Single-chunk mode entry point.
172
+
173
+ Args:
174
+ input_path: Source PDF path.
175
+ output_path: Envelope output path.
176
+ do_ocr: When ``False`` the docling OCR engine is skipped (034-F).
177
+ ocr_scale: Optional page render-scale override (040-F); ``None`` keeps
178
+ the docling default.
179
+
180
+ Returns the process exit code per the table in the module docstring.
181
+ """
182
+
183
+ if not input_path.exists():
184
+ _emit_diagnostic("input", f"input PDF not found: {input_path}")
185
+ return 3
186
+
187
+ try:
188
+ from docline.dependencies import DependencyUnavailableError
189
+ from docline.readers.pdf import _read_pdf_docling_pages
190
+ except ImportError as err:
191
+ _emit_diagnostic("import", "could not import docline.readers.pdf", str(err))
192
+ return 6
193
+
194
+ # Forward do_ocr / ocr_scale only when they diverge from the reader defaults
195
+ # so the existing default-path call contract remains unchanged.
196
+ reader_kwargs: dict[str, Any] = {}
197
+ if not do_ocr:
198
+ reader_kwargs["do_ocr"] = False
199
+ if ocr_scale is not None:
200
+ reader_kwargs["ocr_scale"] = ocr_scale
201
+ try:
202
+ pages = _read_pdf_docling_pages(input_path, **reader_kwargs)
203
+ except DependencyUnavailableError as err:
204
+ _emit_diagnostic("docling-extras", "docling extras not installed", str(err))
205
+ return 4
206
+ except Exception as err: # noqa: BLE001 — surface to parent via exit code
207
+ _emit_diagnostic("docling-runtime", "docling raised during conversion", repr(err))
208
+ return 5
209
+
210
+ try:
211
+ envelope = _build_envelope(pages)
212
+ except (TypeError, ValueError) as err:
213
+ _emit_diagnostic("envelope", "could not serialize envelope to JSON", repr(err))
214
+ return 6
215
+
216
+ try:
217
+ _write_envelope(output_path, envelope)
218
+ except OSError as err:
219
+ _emit_diagnostic("output", f"could not write {output_path}", str(err))
220
+ return 6
221
+ except (TypeError, ValueError) as err:
222
+ _emit_diagnostic("envelope", "could not serialize envelope to JSON", repr(err))
223
+ return 6
224
+
225
+ return 0
226
+
227
+
228
+ def _run_batched(manifest_path: Path) -> int:
229
+ """Batched mode entry point: shared docling model load across N chunks.
230
+
231
+ Returns the process exit code per the table in the module docstring.
232
+ """
233
+
234
+ if not manifest_path.exists():
235
+ _emit_diagnostic("batch-manifest", f"manifest file not found: {manifest_path}")
236
+ return 2
237
+
238
+ try:
239
+ manifest_raw = manifest_path.read_text(encoding="utf-8")
240
+ manifest: Any = json.loads(manifest_raw)
241
+ except (OSError, json.JSONDecodeError) as err:
242
+ _emit_diagnostic("batch-manifest", "could not read or parse manifest", str(err))
243
+ return 2
244
+
245
+ if (
246
+ not isinstance(manifest, dict)
247
+ or not isinstance(manifest.get("chunks"), list)
248
+ or not manifest["chunks"]
249
+ ):
250
+ _emit_diagnostic("batch-manifest", "manifest must have non-empty 'chunks' list")
251
+ return 2
252
+
253
+ chunks: list[dict[str, Any]] = manifest["chunks"]
254
+ for i, entry in enumerate(chunks):
255
+ if (
256
+ not isinstance(entry, dict)
257
+ or not isinstance(entry.get("input"), str)
258
+ or not isinstance(entry.get("output"), str)
259
+ ):
260
+ _emit_diagnostic(
261
+ "batch-manifest",
262
+ f"chunk[{i}] must have string 'input' and 'output' fields",
263
+ )
264
+ return 2
265
+
266
+ # Single import / model load — this is the perf win.
267
+ try:
268
+ from docline.dependencies import DependencyUnavailableError
269
+ from docline.readers.pdf import _read_pdf_docling_pages
270
+ except ImportError as err:
271
+ _emit_diagnostic("import", "could not import docline.readers.pdf", str(err))
272
+ return 6
273
+
274
+ success_count = 0
275
+ failure_count = 0
276
+
277
+ for i, entry in enumerate(chunks):
278
+ input_path = Path(entry["input"])
279
+ output_path = Path(entry["output"])
280
+ chunk_do_ocr = bool(entry.get("do_ocr", True))
281
+ chunk_ocr_scale = entry.get("ocr_scale")
282
+
283
+ if not input_path.exists():
284
+ err_env = _build_error_envelope(FileNotFoundError(f"input PDF not found: {input_path}"))
285
+ try:
286
+ _write_envelope(output_path, err_env)
287
+ except OSError as werr:
288
+ _emit_diagnostic(
289
+ "batch-output",
290
+ f"could not write error envelope for chunk[{i}] to {output_path}",
291
+ str(werr),
292
+ )
293
+ # Cannot record this failure to consumers; treat as critical.
294
+ return 6
295
+ failure_count += 1
296
+ continue
297
+
298
+ try:
299
+ reader_kwargs: dict[str, Any] = {}
300
+ if not chunk_do_ocr:
301
+ reader_kwargs["do_ocr"] = False
302
+ if chunk_ocr_scale is not None:
303
+ scale_val = float(chunk_ocr_scale)
304
+ if scale_val <= 0:
305
+ raise ValueError(f"ocr_scale must be > 0, got {scale_val}")
306
+ reader_kwargs["ocr_scale"] = scale_val
307
+ pages = _read_pdf_docling_pages(input_path, **reader_kwargs)
308
+ except DependencyUnavailableError as err:
309
+ # Extras missing affects ALL chunks identically; abort.
310
+ _emit_diagnostic("docling-extras", "docling extras not installed", str(err))
311
+ return 4
312
+ except Exception as err: # noqa: BLE001 — per-chunk isolation
313
+ err_env = _build_error_envelope(err)
314
+ try:
315
+ _write_envelope(output_path, err_env)
316
+ except OSError as werr:
317
+ _emit_diagnostic(
318
+ "batch-output",
319
+ f"could not write error envelope for chunk[{i}] to {output_path}",
320
+ str(werr),
321
+ )
322
+ return 6
323
+ failure_count += 1
324
+ continue
325
+
326
+ try:
327
+ envelope = _build_envelope(pages)
328
+ _write_envelope(output_path, envelope)
329
+ except (OSError, TypeError, ValueError) as err:
330
+ err_env = _build_error_envelope(err)
331
+ try:
332
+ _write_envelope(output_path, err_env)
333
+ except OSError as werr:
334
+ _emit_diagnostic(
335
+ "batch-output",
336
+ f"could not write error envelope for chunk[{i}] to {output_path}",
337
+ str(werr),
338
+ )
339
+ return 6
340
+ failure_count += 1
341
+ continue
342
+
343
+ success_count += 1
344
+
345
+ if success_count == 0:
346
+ _emit_diagnostic(
347
+ "batch-runtime",
348
+ f"all {failure_count} chunks failed in batched mode",
349
+ )
350
+ return 6
351
+ return 0
352
+
353
+
354
+ def main(argv: list[str] | None = None) -> int:
355
+ """Entry point for ``python -m docline._tools.docling_worker``.
356
+
357
+ Args:
358
+ argv: Command-line arguments (without the program name).
359
+ Defaults to ``sys.argv[1:]``.
360
+
361
+ Returns:
362
+ Process exit code per the table in the module docstring.
363
+ """
364
+
365
+ args = sys.argv[1:] if argv is None else argv
366
+
367
+ if len(args) == 2 and args[0] == "--batch":
368
+ return _run_batched(Path(args[1]))
369
+
370
+ # Single-chunk mode: optional --no-ocr and --ocr-scale=<float> flags.
371
+ do_ocr = True
372
+ ocr_scale: float | None = None
373
+ positional: list[str] = []
374
+ for arg in args:
375
+ if arg == "--no-ocr":
376
+ do_ocr = False
377
+ elif arg.startswith("--ocr-scale="):
378
+ raw = arg.split("=", 1)[1]
379
+ try:
380
+ ocr_scale = float(raw)
381
+ except ValueError:
382
+ _emit_diagnostic("cli", f"invalid --ocr-scale value: {raw}")
383
+ return 2
384
+ if ocr_scale <= 0:
385
+ _emit_diagnostic("cli", f"--ocr-scale must be > 0, got {ocr_scale}")
386
+ return 2
387
+ elif arg.startswith("--"):
388
+ _emit_diagnostic("cli", f"unknown flag: {arg}")
389
+ return 2
390
+ else:
391
+ positional.append(arg)
392
+
393
+ if len(positional) != 2:
394
+ _emit_diagnostic(
395
+ "cli",
396
+ "expected: INPUT_PDF OUTPUT_MD [--no-ocr] [--ocr-scale=N] | --batch MANIFEST_JSON",
397
+ )
398
+ return 2
399
+
400
+ return _run_single(Path(positional[0]), Path(positional[1]), do_ocr=do_ocr, ocr_scale=ocr_scale)
401
+
402
+
403
+ if __name__ == "__main__":
404
+ raise SystemExit(main())