any2md 1.0.7__tar.gz → 1.1.0__tar.gz
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.
- {any2md-1.0.7 → any2md-1.1.0}/CHANGELOG.md +72 -0
- {any2md-1.0.7 → any2md-1.1.0}/PKG-INFO +11 -1
- {any2md-1.0.7 → any2md-1.1.0}/README.md +9 -0
- any2md-1.1.0/any2md/__init__.py +11 -0
- any2md-1.1.0/any2md/_docling_cache.py +434 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/converters/docx.py +15 -3
- {any2md-1.0.7 → any2md-1.1.0}/any2md/converters/pdf.py +16 -8
- {any2md-1.0.7 → any2md-1.1.0}/docs/architecture.md +8 -5
- any2md-1.1.0/docs/superpowers/plans/2026-05-04-v1.1.0-persistent-converter.md +2243 -0
- any2md-1.1.0/docs/superpowers/specs/2026-05-04-v1.1.0-persistent-converter-design.md +951 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/troubleshooting.md +76 -0
- {any2md-1.0.7 → any2md-1.1.0}/pyproject.toml +1 -0
- any2md-1.1.0/scripts/bench_docling_cache.py +87 -0
- any2md-1.1.0/tests/conftest.py +43 -0
- any2md-1.1.0/tests/integration/test_docling_persistence.py +105 -0
- any2md-1.1.0/tests/unit/test_docling_cache.py +703 -0
- any2md-1.0.7/any2md/__init__.py +0 -3
- any2md-1.0.7/tests/conftest.py +0 -25
- {any2md-1.0.7 → any2md-1.1.0}/.gitignore +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/CODE_OF_CONDUCT.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/CONTRIBUTING.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/LICENSE +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/SECURITY.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/__main__.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/_docling.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/_http.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/cli.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/config.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/converters/__init__.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/converters/html.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/converters/txt.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/frontmatter.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/heuristics.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/pipeline/__init__.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/pipeline/cleanup.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/pipeline/structured.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/pipeline/text.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/utils.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/any2md/validators.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/assets/logo-original.jpeg +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/assets/logo.png +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/assets/social-preview.jpg +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/cli-reference.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/output-format.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-27-v1.0.6-security-hardening.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/specs/2026-04-27-v1.0.6-security-hardening-design.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/docs/upgrading-from-0.7.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/requirements.txt +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/scripts/audit-outputs.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/scripts/validate-phase1.sh +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/__init__.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/cli/__init__.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_backend.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_existing_flags.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_exit_codes.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_high_fidelity.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_meta.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_ocr_save_images.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_quiet_verbose.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_strict.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/__init__.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/docs/multi_column.pdf +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/docs/table_heavy.docx +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/docs/web_page.html +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/make_fixtures.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/web_page.md +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/__init__.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_docx_backend_override.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_docx_docling.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_docx_fallback_on_warn.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_docx_mammoth_fallback.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_html_trafilatura.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_pdf_backend_override.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_pdf_docling.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_pdf_pymupdf_fallback.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_save_images.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_snapshots.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_txt.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/__init__.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/__init__.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_runner.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_strip_orphan_punctuation.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_structured_body_cleanup_stages.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_dedupe_toc_table.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_strip_repeated_byline.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_strip_web_fragments.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/scripts/__init__.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/scripts/test_audit_outputs.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_atomic_write.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_auto_id.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_config.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_config_walkup.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_content_hash.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_docling_helper.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_frontmatter_compose.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_frontmatter_helpers.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_heuristics.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_http_safe.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_log_sanitizer.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_meta_overrides.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_meta_reserved_keys.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_pdf_complexity.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_safe_dir_name.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_validators.py +0 -0
- {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_zipbomb_guard.py +0 -0
|
@@ -4,6 +4,78 @@ All notable changes to this project will be documented in this file.
|
|
|
4
4
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
6
6
|
|
|
7
|
+
## [1.1.0] — 2026-05-04
|
|
8
|
+
|
|
9
|
+
Performance and library-mode hygiene release.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- `any2md.release_models()` — imperative escape hatch to free
|
|
14
|
+
cached Docling `DocumentConverter` instances. Stability:
|
|
15
|
+
experimental for v1.1.0.
|
|
16
|
+
- `any2md.docling_session()` — contextmanager (preferred shape
|
|
17
|
+
for library use). Stability: experimental for v1.1.0.
|
|
18
|
+
- `ANY2MD_DOCLING_CACHE=0` env-var to disable the persistent
|
|
19
|
+
converter cache (per-call construction, equivalent to v1.0.x
|
|
20
|
+
behavior).
|
|
21
|
+
- `__all__` declared on `any2md/__init__.py` (formalizes the
|
|
22
|
+
public surface).
|
|
23
|
+
- New benchmark `scripts/bench_docling_cache.py` for maintainer
|
|
24
|
+
pre-release verification.
|
|
25
|
+
|
|
26
|
+
### Changed
|
|
27
|
+
|
|
28
|
+
- `DocumentConverter` is now constructed once per process and
|
|
29
|
+
reused across all files in a batch (was: once per file). New
|
|
30
|
+
one-time stderr line `Loading Docling models (one-time)...`
|
|
31
|
+
printed on first PDF/DOCX in non-quiet TTY runs.
|
|
32
|
+
- `.github/workflows/ci.yml` now runs `pytest tests/unit` on each
|
|
33
|
+
push and PR (was: lint + import smoke + pip-audit only).
|
|
34
|
+
|
|
35
|
+
### Performance
|
|
36
|
+
|
|
37
|
+
- Measured: CPU first-call ~2.47s (model load), warm calls ~0.87s
|
|
38
|
+
(vs prior ~2.47s per file in v1.0.x). Mac MPS first-call ~2.77s,
|
|
39
|
+
warm ~0.14s. RSS floor +535 MB sustained for the process lifetime
|
|
40
|
+
(see `docs/troubleshooting.md` for `release_models()` mitigation).
|
|
41
|
+
|
|
42
|
+
### Migration
|
|
43
|
+
|
|
44
|
+
- None required for CLI users. The `Loading Docling models...`
|
|
45
|
+
stderr line is the only user-visible default-output change.
|
|
46
|
+
- Library/embedder users holding any2md across long-running
|
|
47
|
+
processes should call `any2md.release_models()` or use
|
|
48
|
+
`with any2md.docling_session():` to free model state between
|
|
49
|
+
workloads.
|
|
50
|
+
- Test/instrumentation code that monkey-patches
|
|
51
|
+
`docling.document_converter.DocumentConverter` AFTER any2md's
|
|
52
|
+
first call will see no effect on subsequent calls (cache returns
|
|
53
|
+
the pre-patch instance). Workaround: call `release_models()`
|
|
54
|
+
after applying the patch, or set `ANY2MD_DOCLING_CACHE=0` in the
|
|
55
|
+
test environment.
|
|
56
|
+
|
|
57
|
+
### Stability notice
|
|
58
|
+
|
|
59
|
+
The new public APIs (`release_models`, `docling_session`) are
|
|
60
|
+
marked experimental for v1.1.0; their shape may change in
|
|
61
|
+
subsequent v1.x releases without a major bump until promoted to
|
|
62
|
+
stable in a later release.
|
|
63
|
+
|
|
64
|
+
### Known limitations
|
|
65
|
+
|
|
66
|
+
- HF Hub outages during cold-start now cause N retries instead of
|
|
67
|
+
one silent fallback (the v1.0.x failure-cache behavior was
|
|
68
|
+
rejected on quality grounds). Bounded retry-with-backoff
|
|
69
|
+
deferred to a future hardening PR.
|
|
70
|
+
- Forking a process (`multiprocessing` with `fork` start method)
|
|
71
|
+
after the first `convert()` call inherits torch/CUDA state
|
|
72
|
+
unsafely regardless of cache. Use
|
|
73
|
+
`multiprocessing.set_start_method("spawn")` or call
|
|
74
|
+
`release_models()` before fork.
|
|
75
|
+
- `os.register_at_fork` is POSIX-only; on Windows the fork-reset
|
|
76
|
+
path is dead code (no fork on Windows). Cache works normally
|
|
77
|
+
on Windows.
|
|
78
|
+
|
|
7
79
|
## [1.0.7] — 2026-04-29
|
|
8
80
|
|
|
9
81
|
Branding/rebrand release. The frontmatter contract that any2md emits is
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: any2md
|
|
3
|
-
Version: 1.0
|
|
3
|
+
Version: 1.1.0
|
|
4
4
|
Summary: Convert PDF, DOCX, HTML, and TXT files — or web pages by URL — to clean, LLM-optimized Markdown with YAML frontmatter.
|
|
5
5
|
Project-URL: Homepage, https://github.com/rocklambros/any2md
|
|
6
6
|
Project-URL: Issues, https://github.com/rocklambros/any2md/issues
|
|
@@ -25,6 +25,7 @@ Requires-Dist: urllib3<3,>=2.6.3
|
|
|
25
25
|
Provides-Extra: dev
|
|
26
26
|
Requires-Dist: pytest-snapshot>=0.9.0; extra == 'dev'
|
|
27
27
|
Requires-Dist: pytest>=8.0.0; extra == 'dev'
|
|
28
|
+
Requires-Dist: pyyaml>=6.0; extra == 'dev'
|
|
28
29
|
Requires-Dist: reportlab>=4.0.0; extra == 'dev'
|
|
29
30
|
Requires-Dist: ruff>=0.6.0; extra == 'dev'
|
|
30
31
|
Provides-Extra: high-fidelity
|
|
@@ -360,6 +361,15 @@ any2md -f -r ./corpus/
|
|
|
360
361
|
|
|
361
362
|
You'll see the OK lines as if the conversion were fresh; previously-written outputs are overwritten.
|
|
362
363
|
|
|
364
|
+
> **v1.1.0+:** when processing PDFs or DOCX files via the Docling
|
|
365
|
+
> backend, the converter (and its loaded model weights) is constructed
|
|
366
|
+
> once on the first PDF/DOCX in an invocation and reused for all
|
|
367
|
+
> subsequent files; subsequent files extract in ~0.5–1s rather than
|
|
368
|
+
> re-paying the ~2.5s cold-load tax. Library users embedding any2md
|
|
369
|
+
> in long-running processes should call `any2md.release_models()` or
|
|
370
|
+
> use `with any2md.docling_session():` — see
|
|
371
|
+
> `docs/troubleshooting.md`.
|
|
372
|
+
|
|
363
373
|
## The output format
|
|
364
374
|
|
|
365
375
|
Every file converted by any2md has the same frontmatter shape, regardless of source format. The shape is **SAGE-compatible** — it matches the field names, types, and ordering of the Security Analysis and Guidance Exchange specification, but values aren't required to match SAGE's controlled vocabularies (most converted documents aren't security research). Fields that require a controlled vocabulary (`document_type`, `content_domain`, `tlp`) are emitted empty unless you supply them via `--meta` or `.any2md.toml`.
|
|
@@ -327,6 +327,15 @@ any2md -f -r ./corpus/
|
|
|
327
327
|
|
|
328
328
|
You'll see the OK lines as if the conversion were fresh; previously-written outputs are overwritten.
|
|
329
329
|
|
|
330
|
+
> **v1.1.0+:** when processing PDFs or DOCX files via the Docling
|
|
331
|
+
> backend, the converter (and its loaded model weights) is constructed
|
|
332
|
+
> once on the first PDF/DOCX in an invocation and reused for all
|
|
333
|
+
> subsequent files; subsequent files extract in ~0.5–1s rather than
|
|
334
|
+
> re-paying the ~2.5s cold-load tax. Library users embedding any2md
|
|
335
|
+
> in long-running processes should call `any2md.release_models()` or
|
|
336
|
+
> use `with any2md.docling_session():` — see
|
|
337
|
+
> `docs/troubleshooting.md`.
|
|
338
|
+
|
|
330
339
|
## The output format
|
|
331
340
|
|
|
332
341
|
Every file converted by any2md has the same frontmatter shape, regardless of source format. The shape is **SAGE-compatible** — it matches the field names, types, and ordering of the Security Analysis and Guidance Exchange specification, but values aren't required to match SAGE's controlled vocabularies (most converted documents aren't security research). Fields that require a controlled vocabulary (`document_type`, `content_domain`, `tlp`) are emitted empty unless you supply them via `--meta` or `.any2md.toml`.
|
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Process-lifetime cache of DocumentConverter instances.
|
|
2
|
+
|
|
3
|
+
Stability: experimental for v1.1.0. Public API surface:
|
|
4
|
+
- `release_models()` — imperative escape hatch
|
|
5
|
+
- `docling_session()` — contextmanager (preferred)
|
|
6
|
+
- `ANY2MD_DOCLING_CACHE=0` — disable cache entirely
|
|
7
|
+
|
|
8
|
+
Other names in this module are internal and may change.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import hashlib
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import sys
|
|
17
|
+
import threading
|
|
18
|
+
from collections import OrderedDict
|
|
19
|
+
from contextlib import contextmanager
|
|
20
|
+
from dataclasses import dataclass
|
|
21
|
+
from typing import Any, Callable, Iterator
|
|
22
|
+
|
|
23
|
+
_MAX_RESIDENT = 2 # Empirically: pdf + docx are the only construction
|
|
24
|
+
# sites; 2 slots covers 99% of mixed batches with
|
|
25
|
+
# bounded RSS (~1GB extra steady-state worst case).
|
|
26
|
+
|
|
27
|
+
_CACHE_DISABLED_ENV = "ANY2MD_DOCLING_CACHE"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _canonicalize(obj: Any) -> Any:
|
|
31
|
+
"""Recursively canonicalize a JSON-able structure for stable hashing.
|
|
32
|
+
|
|
33
|
+
`json.dumps(..., sort_keys=True)` only sorts dict keys at every
|
|
34
|
+
level, NOT list contents. Pydantic v2 `model_dump(mode="json")`
|
|
35
|
+
serializes `set`/`frozenset` to `list` with iteration order
|
|
36
|
+
affected by `PYTHONHASHSEED` — producing different bytes for
|
|
37
|
+
identical option payloads across processes. We sort lists too
|
|
38
|
+
so the cache key is stable.
|
|
39
|
+
|
|
40
|
+
Trade-off: this is lossy if a future Docling field uses list
|
|
41
|
+
ORDER as semantic. None do today (verified by inspection of
|
|
42
|
+
Docling 2.x `PdfPipelineOptions`). If a future field requires
|
|
43
|
+
order preservation, switch this canonicalizer to a tagged form
|
|
44
|
+
(e.g., wrap ordered lists in a sentinel) and update this docstring.
|
|
45
|
+
|
|
46
|
+
Input contract: ``obj`` must be the output of Pydantic
|
|
47
|
+
``model_dump(mode="json")`` or equivalent JSON-clean structure
|
|
48
|
+
(string-keyed dicts, JSON-scalar leaves). Non-string dict keys or
|
|
49
|
+
non-JSON types (e.g., ``bytes``) are NOT supported — they will
|
|
50
|
+
raise downstream from ``json.dumps`` rather than here. Callers
|
|
51
|
+
that pass arbitrary user data must validate first; the cache's
|
|
52
|
+
only call site (``_hash_opts``) goes through ``model_dump`` so the
|
|
53
|
+
contract is upheld.
|
|
54
|
+
"""
|
|
55
|
+
if isinstance(obj, dict):
|
|
56
|
+
return {k: _canonicalize(obj[k]) for k in sorted(obj)}
|
|
57
|
+
if isinstance(obj, list):
|
|
58
|
+
# Sort by canonical-JSON of each element so heterogeneous
|
|
59
|
+
# lists still produce a deterministic order.
|
|
60
|
+
return sorted(
|
|
61
|
+
(_canonicalize(item) for item in obj),
|
|
62
|
+
key=lambda v: json.dumps(v, sort_keys=True),
|
|
63
|
+
)
|
|
64
|
+
return obj
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class _Key:
|
|
69
|
+
"""Composite cache key. `fmt` is load-bearing for safety: digest
|
|
70
|
+
space alone is not unique across formats (e.g., `_hash_opts(None)`
|
|
71
|
+
produces the same zero bytes for both PDF and DOCX call sites).
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
fmt: str # "pdf" | "docx"
|
|
75
|
+
digest: bytes # 32-byte sha256 of canonical opts json (or zeros for None)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass
|
|
79
|
+
class CacheStats:
|
|
80
|
+
"""In-process counters. No persistence, no telemetry, no opt-in.
|
|
81
|
+
Exposed via the module-level `stats()` function (NOT
|
|
82
|
+
`ConverterCache.stats()`, which is an instance method).
|
|
83
|
+
|
|
84
|
+
Counters are eventually-consistent under thread contention;
|
|
85
|
+
snapshots are not transactionally coherent. Acceptable for
|
|
86
|
+
debug/observability use, not for control logic.
|
|
87
|
+
"""
|
|
88
|
+
|
|
89
|
+
model_loads: int = 0
|
|
90
|
+
cache_hits: int = 0
|
|
91
|
+
cache_evictions: int = 0
|
|
92
|
+
convert_failures: int = 0
|
|
93
|
+
fallback_count: int = 0
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _hash_opts(opts: Any | None) -> bytes:
|
|
97
|
+
"""Canonical content-hash of Docling pipeline options.
|
|
98
|
+
|
|
99
|
+
Uses Pydantic `model_dump(mode="json")` then a recursive
|
|
100
|
+
canonicalizer (sorts dicts AND lists) to produce a process-stable
|
|
101
|
+
digest. Non-JSON-representable types raise from `model_dump` with
|
|
102
|
+
`mode="json"` — that is the desired loud-failure behavior.
|
|
103
|
+
|
|
104
|
+
Known limitation: if a future Docling field uses
|
|
105
|
+
`default_factory=lambda: random_value()` (unique per construction),
|
|
106
|
+
the cache becomes a silent no-op for that field — different digest
|
|
107
|
+
every call. Acceptance criterion #1 (`model_loads == 1` across two
|
|
108
|
+
same-options calls) is the canary; the maintainer benchmark script
|
|
109
|
+
`scripts/bench_docling_cache.py` is the periodic verifier.
|
|
110
|
+
"""
|
|
111
|
+
if opts is None:
|
|
112
|
+
return b"\x00" * 32
|
|
113
|
+
canonical = _canonicalize(opts.model_dump(mode="json"))
|
|
114
|
+
payload = json.dumps(canonical, sort_keys=True).encode()
|
|
115
|
+
return hashlib.sha256(payload).digest()
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _cache_disabled() -> bool:
|
|
119
|
+
"""Read the env var on every call (intentional — supports test
|
|
120
|
+
harnesses that toggle it). Cost is one `os.environ.get` per
|
|
121
|
+
`get_or_build`; negligible vs the work the cache guards.
|
|
122
|
+
|
|
123
|
+
Accepted disable values (case-insensitive): ``0``, ``off``,
|
|
124
|
+
``false``. Other values (including ``no``, ``n``, ``disabled``)
|
|
125
|
+
do NOT disable the cache. Set ``ANY2MD_DOCLING_CACHE=0`` per
|
|
126
|
+
the documented public surface.
|
|
127
|
+
"""
|
|
128
|
+
return os.environ.get(_CACHE_DISABLED_ENV, "").lower() in {"0", "off", "false"}
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class ConverterCache:
|
|
132
|
+
"""Thread-safe LRU cache of DocumentConverter instances.
|
|
133
|
+
|
|
134
|
+
Build runs OUTSIDE the lock so two distinct keys can construct in
|
|
135
|
+
parallel; double-check on insert prevents racing same-key builds
|
|
136
|
+
from polluting the cache (one of the two builds is discarded).
|
|
137
|
+
"""
|
|
138
|
+
|
|
139
|
+
def __init__(self, maxsize: int = _MAX_RESIDENT) -> None:
|
|
140
|
+
assert maxsize >= 1, "maxsize must be >= 1"
|
|
141
|
+
self._lock = threading.Lock()
|
|
142
|
+
self._store: OrderedDict[_Key, Any] = OrderedDict()
|
|
143
|
+
self._maxsize = maxsize
|
|
144
|
+
self._stats = CacheStats()
|
|
145
|
+
self._first_load_announced = False
|
|
146
|
+
# POSIX-only: Windows has no fork(2). Guard so module imports
|
|
147
|
+
# cleanly on Windows.
|
|
148
|
+
if hasattr(os, "register_at_fork"):
|
|
149
|
+
os.register_at_fork(after_in_child=self._after_fork)
|
|
150
|
+
|
|
151
|
+
def clear(self) -> None:
|
|
152
|
+
"""Release all cached converters AND reset stats + announce
|
|
153
|
+
flag.
|
|
154
|
+
|
|
155
|
+
Resetting stats is intentional: ``release_models()`` is the
|
|
156
|
+
public release verb and the autouse test-isolation fixture; if
|
|
157
|
+
``clear()`` only emptied ``_store``, callers asserting exact
|
|
158
|
+
counter values (e.g. ``stats().model_loads == 1``) would
|
|
159
|
+
observe leakage from prior tests/sessions. Mirrors the state
|
|
160
|
+
reset performed by ``_after_fork``.
|
|
161
|
+
"""
|
|
162
|
+
with self._lock:
|
|
163
|
+
self._store.clear()
|
|
164
|
+
self._stats = CacheStats()
|
|
165
|
+
self._first_load_announced = False
|
|
166
|
+
|
|
167
|
+
def stats(self) -> CacheStats:
|
|
168
|
+
with self._lock:
|
|
169
|
+
return CacheStats(**vars(self._stats))
|
|
170
|
+
|
|
171
|
+
def _after_fork(self) -> None:
|
|
172
|
+
# Reassign lock (parent's may be in held state) and clear all
|
|
173
|
+
# per-process state — including stats counters, since AC#1
|
|
174
|
+
# uses exact equality (`model_loads == 1`) and child must
|
|
175
|
+
# start fresh.
|
|
176
|
+
self._lock = threading.Lock()
|
|
177
|
+
self._store.clear()
|
|
178
|
+
self._stats = CacheStats()
|
|
179
|
+
self._first_load_announced = False
|
|
180
|
+
|
|
181
|
+
def get_or_build(self, fmt: str, opts: Any | None, build: Callable[[], Any]) -> Any:
|
|
182
|
+
if _cache_disabled():
|
|
183
|
+
return build() # bypass entirely
|
|
184
|
+
key = _Key(fmt, _hash_opts(opts))
|
|
185
|
+
with self._lock:
|
|
186
|
+
if key in self._store:
|
|
187
|
+
self._stats.cache_hits += 1
|
|
188
|
+
self._store.move_to_end(key)
|
|
189
|
+
return self._store[key]
|
|
190
|
+
# Build outside lock; rollback announce-flag if build raises so
|
|
191
|
+
# a subsequent successful build still announces.
|
|
192
|
+
announced_now = self._announce_first_load_if_needed()
|
|
193
|
+
try:
|
|
194
|
+
conv = build()
|
|
195
|
+
except Exception:
|
|
196
|
+
if announced_now:
|
|
197
|
+
with self._lock:
|
|
198
|
+
self._first_load_announced = False
|
|
199
|
+
raise
|
|
200
|
+
with self._lock:
|
|
201
|
+
self._stats.model_loads += 1
|
|
202
|
+
if key not in self._store:
|
|
203
|
+
self._store[key] = conv
|
|
204
|
+
self._store.move_to_end(key)
|
|
205
|
+
while len(self._store) > self._maxsize:
|
|
206
|
+
self._store.popitem(last=False)
|
|
207
|
+
self._stats.cache_evictions += 1
|
|
208
|
+
# Defensive `.get(key, conv)` rather than `[key]`: the
|
|
209
|
+
# current control flow guarantees presence (insert +
|
|
210
|
+
# move_to_end places our key at MRU position; eviction
|
|
211
|
+
# only pops LRU), but a future maintainer reordering
|
|
212
|
+
# eviction-before-insert or moving the eviction loop
|
|
213
|
+
# outside this lock would otherwise raise KeyError here.
|
|
214
|
+
return self._store.get(key, conv)
|
|
215
|
+
|
|
216
|
+
def _evict_unlocked(self, key: _Key) -> bool:
|
|
217
|
+
"""Drop ``key`` from the store and increment cache_evictions.
|
|
218
|
+
Returns True if a slot was removed, False if the key wasn't
|
|
219
|
+
present. MUST be called with ``self._lock`` already held.
|
|
220
|
+
"""
|
|
221
|
+
if key in self._store:
|
|
222
|
+
del self._store[key]
|
|
223
|
+
self._stats.cache_evictions += 1
|
|
224
|
+
return True
|
|
225
|
+
return False
|
|
226
|
+
|
|
227
|
+
def evict(self, fmt: str, opts: Any | None) -> bool:
|
|
228
|
+
"""Remove the cache entry matching (fmt, opts). Returns True
|
|
229
|
+
if a slot was actually removed.
|
|
230
|
+
|
|
231
|
+
For converters: prefer the module-level
|
|
232
|
+
``evict_on_convert_failure(fmt, opts)`` helper instead of
|
|
233
|
+
calling this method directly. The helper short-circuits when
|
|
234
|
+
the cache is disabled and atomically increments the
|
|
235
|
+
``convert_failures`` counter via ``evict_and_record_failure``.
|
|
236
|
+
Direct ``evict()`` calls skip both behaviors.
|
|
237
|
+
|
|
238
|
+
Use this method only for explicit eviction (e.g., test
|
|
239
|
+
cleanup, library users releasing a specific format slot).
|
|
240
|
+
"""
|
|
241
|
+
key = _Key(fmt, _hash_opts(opts))
|
|
242
|
+
with self._lock:
|
|
243
|
+
return self._evict_unlocked(key)
|
|
244
|
+
|
|
245
|
+
def evict_and_record_failure(self, fmt: str, opts: Any | None) -> None:
|
|
246
|
+
"""Atomic counterpart to `evict()` that also increments the
|
|
247
|
+
convert-failure counter inside the same lock acquisition.
|
|
248
|
+
Always increments (we observed a convert failure even if the
|
|
249
|
+
slot was already evicted by a concurrent caller)."""
|
|
250
|
+
key = _Key(fmt, _hash_opts(opts))
|
|
251
|
+
with self._lock:
|
|
252
|
+
self._evict_unlocked(key)
|
|
253
|
+
self._stats.convert_failures += 1
|
|
254
|
+
|
|
255
|
+
def _announce_first_load_if_needed(self) -> bool:
|
|
256
|
+
"""Returns True if THIS call performed the announcement (so
|
|
257
|
+
the caller can roll back the flag on build failure). Returns
|
|
258
|
+
False if announcement had already happened.
|
|
259
|
+
|
|
260
|
+
Compare-and-set inside the lock so two racing first-loaders
|
|
261
|
+
don't both print. I/O happens outside the lock.
|
|
262
|
+
"""
|
|
263
|
+
with self._lock:
|
|
264
|
+
if self._first_load_announced:
|
|
265
|
+
return False
|
|
266
|
+
self._first_load_announced = True
|
|
267
|
+
# Lazy import avoids a load-time cycle: any2md.converters
|
|
268
|
+
# imports from this module, so we resolve is_quiet() at call
|
|
269
|
+
# time when both modules are fully initialized.
|
|
270
|
+
from any2md.converters import is_quiet
|
|
271
|
+
|
|
272
|
+
if is_quiet() or not sys.stderr.isatty():
|
|
273
|
+
return True
|
|
274
|
+
print(" Loading Docling models (one-time)...", file=sys.stderr)
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
_INSTANCE: ConverterCache | None = None
|
|
279
|
+
_INSTANCE_LOCK = threading.Lock()
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _reset_module_lock_after_fork() -> None:
|
|
283
|
+
"""Reassign the module-level lazy-init lock in the child process
|
|
284
|
+
after fork. If a parent thread held `_INSTANCE_LOCK` at fork
|
|
285
|
+
time, the child would inherit a permanently-locked Lock;
|
|
286
|
+
reassigning gives the child a fresh, unlocked one. Mirrors the
|
|
287
|
+
instance-level treatment in ``ConverterCache._after_fork``.
|
|
288
|
+
"""
|
|
289
|
+
global _INSTANCE_LOCK
|
|
290
|
+
_INSTANCE_LOCK = threading.Lock()
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
# POSIX-only; matches the hasattr-guard pattern in ConverterCache.__init__.
|
|
294
|
+
if hasattr(os, "register_at_fork"):
|
|
295
|
+
os.register_at_fork(after_in_child=_reset_module_lock_after_fork)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _get_instance() -> ConverterCache:
|
|
299
|
+
"""Lock-serialized lazy init. Without the lock, two threads
|
|
300
|
+
racing first-call could each construct a ConverterCache; the
|
|
301
|
+
losing instance's `register_at_fork` callback remains registered
|
|
302
|
+
and runs on stale state at the next fork.
|
|
303
|
+
"""
|
|
304
|
+
global _INSTANCE
|
|
305
|
+
if _INSTANCE is None:
|
|
306
|
+
with _INSTANCE_LOCK:
|
|
307
|
+
if _INSTANCE is None: # double-check after acquiring lock
|
|
308
|
+
_INSTANCE = ConverterCache()
|
|
309
|
+
return _INSTANCE
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
# ---- Public API surface (re-exported via any2md/__init__.py) ----
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def release_models() -> None:
|
|
316
|
+
"""Release all cached DocumentConverter instances.
|
|
317
|
+
|
|
318
|
+
Stability: experimental for v1.1.0. Use this in long-running
|
|
319
|
+
processes (services, notebooks, batch workers) to free Docling
|
|
320
|
+
model state between workloads. CLI users typically don't need it
|
|
321
|
+
— process exit is sufficient.
|
|
322
|
+
|
|
323
|
+
Short-circuits when no singleton exists yet — avoids phantom
|
|
324
|
+
`register_at_fork` callbacks from constructing a ConverterCache
|
|
325
|
+
just to clear an empty store.
|
|
326
|
+
"""
|
|
327
|
+
if _INSTANCE is None:
|
|
328
|
+
return
|
|
329
|
+
_get_instance().clear()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
@contextmanager
|
|
333
|
+
def docling_session() -> Iterator[None]:
|
|
334
|
+
"""Context manager that releases cached converters on exit.
|
|
335
|
+
|
|
336
|
+
Stability: experimental for v1.1.0. Preferred shape for library
|
|
337
|
+
use; guarantees release on `__exit__` even if the body raises.
|
|
338
|
+
|
|
339
|
+
from any2md import docling_session
|
|
340
|
+
from any2md.converters import convert_file
|
|
341
|
+
with docling_session():
|
|
342
|
+
convert_file("a.pdf", out_dir)
|
|
343
|
+
convert_file("b.pdf", out_dir)
|
|
344
|
+
# models freed here
|
|
345
|
+
|
|
346
|
+
Note: ``convert_file`` lives in ``any2md.converters``; only
|
|
347
|
+
``docling_session`` and ``release_models`` are re-exported from
|
|
348
|
+
the top-level ``any2md`` package in v1.1.0.
|
|
349
|
+
"""
|
|
350
|
+
try:
|
|
351
|
+
yield
|
|
352
|
+
finally:
|
|
353
|
+
release_models()
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def stats() -> CacheStats:
|
|
357
|
+
"""Public read of the in-process counters. For debug visibility
|
|
358
|
+
in v1.1.0:
|
|
359
|
+
from any2md._docling_cache import stats
|
|
360
|
+
print(stats())
|
|
361
|
+
A CLI flag (`--debug-cache-stats`) lands in v1.2.0.
|
|
362
|
+
|
|
363
|
+
Short-circuits to a zero-valued snapshot when no singleton exists
|
|
364
|
+
yet — same rationale as ``release_models``.
|
|
365
|
+
"""
|
|
366
|
+
if _INSTANCE is None:
|
|
367
|
+
return CacheStats()
|
|
368
|
+
return _get_instance().stats()
|
|
369
|
+
|
|
370
|
+
|
|
371
|
+
# ---- Internal accessors used by converters ----
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def get_pdf_converter(pipeline_opts) -> Any:
|
|
375
|
+
"""Return a cached DocumentConverter for the given PDF pipeline options.
|
|
376
|
+
|
|
377
|
+
Docling imports happen INSIDE the build closure so callers pay
|
|
378
|
+
the import cost only on cache miss (when ``_build`` actually
|
|
379
|
+
runs). This honors the lazy-import contract for cache-hit paths.
|
|
380
|
+
"""
|
|
381
|
+
|
|
382
|
+
def _build():
|
|
383
|
+
from docling.datamodel.base_models import InputFormat
|
|
384
|
+
from docling.document_converter import DocumentConverter, PdfFormatOption
|
|
385
|
+
|
|
386
|
+
return DocumentConverter(
|
|
387
|
+
format_options={
|
|
388
|
+
InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_opts)
|
|
389
|
+
}
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
return _get_instance().get_or_build("pdf", pipeline_opts, _build)
|
|
393
|
+
|
|
394
|
+
|
|
395
|
+
def get_docx_converter() -> Any:
|
|
396
|
+
"""Return a cached DocumentConverter for DOCX (no pipeline options today).
|
|
397
|
+
|
|
398
|
+
Docling import happens INSIDE the build closure so callers pay
|
|
399
|
+
the import cost only on cache miss.
|
|
400
|
+
"""
|
|
401
|
+
|
|
402
|
+
def _build():
|
|
403
|
+
from docling.document_converter import DocumentConverter
|
|
404
|
+
|
|
405
|
+
return DocumentConverter()
|
|
406
|
+
|
|
407
|
+
return _get_instance().get_or_build("docx", None, _build)
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def evict_on_convert_failure(fmt: str, opts: Any | None) -> None:
|
|
411
|
+
"""Convenience for converters: drop the cache slot after a
|
|
412
|
+
convert exception AND increment the failure counter, atomically.
|
|
413
|
+
|
|
414
|
+
Short-circuits when ``ANY2MD_DOCLING_CACHE=0`` is set — the
|
|
415
|
+
env-var disables the cache entirely, so we don't lazily
|
|
416
|
+
construct a `ConverterCache` singleton (with its `register_at_fork`
|
|
417
|
+
side-effect) just to bump a counter that the user has chosen not
|
|
418
|
+
to consume.
|
|
419
|
+
|
|
420
|
+
Wrapped in a broad except so this helper, called during exception
|
|
421
|
+
handling, cannot itself raise and mask the original exception.
|
|
422
|
+
"""
|
|
423
|
+
if _cache_disabled():
|
|
424
|
+
return
|
|
425
|
+
try:
|
|
426
|
+
_get_instance().evict_and_record_failure(fmt, opts)
|
|
427
|
+
except Exception:
|
|
428
|
+
# Intentional: this helper is invoked from converter `except`
|
|
429
|
+
# blocks during convert failure. Re-raising here would mask
|
|
430
|
+
# the original Docling exception (the one the caller is
|
|
431
|
+
# already handling) and prevent the upstream
|
|
432
|
+
# pymupdf4llm/mammoth fallback path. See spec
|
|
433
|
+
# "Error handling" table.
|
|
434
|
+
pass
|
|
@@ -173,12 +173,24 @@ def _extract_via_docling(docx_path: Path) -> tuple[str, str, list[str]]:
|
|
|
173
173
|
``captured_warnings`` is the list of WARNING+ messages emitted by
|
|
174
174
|
Docling's ``msword_backend`` logger during the conversion. It is
|
|
175
175
|
empty for clean runs. Raises on Docling errors.
|
|
176
|
+
|
|
177
|
+
v1.1.0: uses the persistent ConverterCache rather than constructing
|
|
178
|
+
a fresh DocumentConverter per call. On any convert exception the
|
|
179
|
+
offending cache slot is evicted (guards against torch internal-
|
|
180
|
+
state contamination from a malformed input).
|
|
176
181
|
"""
|
|
177
|
-
from
|
|
182
|
+
from any2md._docling_cache import (
|
|
183
|
+
evict_on_convert_failure,
|
|
184
|
+
get_docx_converter,
|
|
185
|
+
)
|
|
178
186
|
|
|
179
|
-
converter =
|
|
187
|
+
converter = get_docx_converter()
|
|
180
188
|
with _DoclingMswordWarningCapture() as cap:
|
|
181
|
-
|
|
189
|
+
try:
|
|
190
|
+
result = converter.convert(str(docx_path))
|
|
191
|
+
except Exception:
|
|
192
|
+
evict_on_convert_failure("docx", None)
|
|
193
|
+
raise
|
|
182
194
|
return result.document.export_to_markdown(), "docling", list(cap.messages)
|
|
183
195
|
|
|
184
196
|
|
|
@@ -119,10 +119,18 @@ def _extract_via_docling(
|
|
|
119
119
|
|
|
120
120
|
When ``options.save_images`` is True, extracted picture images are
|
|
121
121
|
written to ``<output_dir>/images/<pdf_stem>/imgN.png``.
|
|
122
|
+
|
|
123
|
+
v1.1.0: uses the persistent ConverterCache rather than constructing
|
|
124
|
+
a fresh DocumentConverter per call. On any convert exception the
|
|
125
|
+
offending cache slot is evicted (guards against torch internal-
|
|
126
|
+
state contamination from a malformed input).
|
|
122
127
|
"""
|
|
123
|
-
from docling.datamodel.base_models import InputFormat
|
|
124
128
|
from docling.datamodel.pipeline_options import PdfPipelineOptions
|
|
125
|
-
|
|
129
|
+
|
|
130
|
+
from any2md._docling_cache import (
|
|
131
|
+
evict_on_convert_failure,
|
|
132
|
+
get_pdf_converter,
|
|
133
|
+
)
|
|
126
134
|
|
|
127
135
|
pipeline_opts = PdfPipelineOptions(
|
|
128
136
|
do_ocr=options.ocr_figures,
|
|
@@ -130,12 +138,12 @@ def _extract_via_docling(
|
|
|
130
138
|
generate_picture_images=options.save_images,
|
|
131
139
|
)
|
|
132
140
|
|
|
133
|
-
converter =
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
141
|
+
converter = get_pdf_converter(pipeline_opts)
|
|
142
|
+
try:
|
|
143
|
+
result = converter.convert(str(pdf_path))
|
|
144
|
+
except Exception:
|
|
145
|
+
evict_on_convert_failure("pdf", pipeline_opts)
|
|
146
|
+
raise
|
|
139
147
|
|
|
140
148
|
if options.save_images:
|
|
141
149
|
pictures = getattr(result.document, "pictures", None) or []
|
|
@@ -680,11 +680,14 @@ linear in the number of fields. For a typical document, this phase is under
|
|
|
680
680
|
|
|
681
681
|
In rough order of impact:
|
|
682
682
|
|
|
683
|
-
1. **Docling model warmup.** First-invocation latency
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
683
|
+
1. **Docling model warmup (v1.1.0+: amortized).** First-invocation latency
|
|
684
|
+
on CPU is ~2.5s; warm calls are ~0.9s. On Mac MPS: ~2.8s cold, ~0.15s
|
|
685
|
+
warm. From v1.1.0 onward, the `DocumentConverter` is constructed once
|
|
686
|
+
per process and reused across all PDF/DOCX files in a batch (see
|
|
687
|
+
`any2md/_docling_cache.py`). For library/embedder use, call
|
|
688
|
+
`any2md.release_models()` or use `with any2md.docling_session():` to
|
|
689
|
+
free model state between workloads. Disable the cache entirely with
|
|
690
|
+
`ANY2MD_DOCLING_CACHE=0`.
|
|
688
691
|
2. **Parallel batch processing.** Currently sequential. Adding multiprocess
|
|
689
692
|
parallelism for batch mode would scale linearly with cores for the
|
|
690
693
|
pymupdf4llm path; for the Docling path, GPU contention may limit
|