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.
Files changed (142) hide show
  1. {any2md-1.0.7 → any2md-1.1.0}/CHANGELOG.md +72 -0
  2. {any2md-1.0.7 → any2md-1.1.0}/PKG-INFO +11 -1
  3. {any2md-1.0.7 → any2md-1.1.0}/README.md +9 -0
  4. any2md-1.1.0/any2md/__init__.py +11 -0
  5. any2md-1.1.0/any2md/_docling_cache.py +434 -0
  6. {any2md-1.0.7 → any2md-1.1.0}/any2md/converters/docx.py +15 -3
  7. {any2md-1.0.7 → any2md-1.1.0}/any2md/converters/pdf.py +16 -8
  8. {any2md-1.0.7 → any2md-1.1.0}/docs/architecture.md +8 -5
  9. any2md-1.1.0/docs/superpowers/plans/2026-05-04-v1.1.0-persistent-converter.md +2243 -0
  10. any2md-1.1.0/docs/superpowers/specs/2026-05-04-v1.1.0-persistent-converter-design.md +951 -0
  11. {any2md-1.0.7 → any2md-1.1.0}/docs/troubleshooting.md +76 -0
  12. {any2md-1.0.7 → any2md-1.1.0}/pyproject.toml +1 -0
  13. any2md-1.1.0/scripts/bench_docling_cache.py +87 -0
  14. any2md-1.1.0/tests/conftest.py +43 -0
  15. any2md-1.1.0/tests/integration/test_docling_persistence.py +105 -0
  16. any2md-1.1.0/tests/unit/test_docling_cache.py +703 -0
  17. any2md-1.0.7/any2md/__init__.py +0 -3
  18. any2md-1.0.7/tests/conftest.py +0 -25
  19. {any2md-1.0.7 → any2md-1.1.0}/.gitignore +0 -0
  20. {any2md-1.0.7 → any2md-1.1.0}/CODE_OF_CONDUCT.md +0 -0
  21. {any2md-1.0.7 → any2md-1.1.0}/CONTRIBUTING.md +0 -0
  22. {any2md-1.0.7 → any2md-1.1.0}/LICENSE +0 -0
  23. {any2md-1.0.7 → any2md-1.1.0}/SECURITY.md +0 -0
  24. {any2md-1.0.7 → any2md-1.1.0}/any2md/__main__.py +0 -0
  25. {any2md-1.0.7 → any2md-1.1.0}/any2md/_docling.py +0 -0
  26. {any2md-1.0.7 → any2md-1.1.0}/any2md/_http.py +0 -0
  27. {any2md-1.0.7 → any2md-1.1.0}/any2md/cli.py +0 -0
  28. {any2md-1.0.7 → any2md-1.1.0}/any2md/config.py +0 -0
  29. {any2md-1.0.7 → any2md-1.1.0}/any2md/converters/__init__.py +0 -0
  30. {any2md-1.0.7 → any2md-1.1.0}/any2md/converters/html.py +0 -0
  31. {any2md-1.0.7 → any2md-1.1.0}/any2md/converters/txt.py +0 -0
  32. {any2md-1.0.7 → any2md-1.1.0}/any2md/frontmatter.py +0 -0
  33. {any2md-1.0.7 → any2md-1.1.0}/any2md/heuristics.py +0 -0
  34. {any2md-1.0.7 → any2md-1.1.0}/any2md/pipeline/__init__.py +0 -0
  35. {any2md-1.0.7 → any2md-1.1.0}/any2md/pipeline/cleanup.py +0 -0
  36. {any2md-1.0.7 → any2md-1.1.0}/any2md/pipeline/structured.py +0 -0
  37. {any2md-1.0.7 → any2md-1.1.0}/any2md/pipeline/text.py +0 -0
  38. {any2md-1.0.7 → any2md-1.1.0}/any2md/utils.py +0 -0
  39. {any2md-1.0.7 → any2md-1.1.0}/any2md/validators.py +0 -0
  40. {any2md-1.0.7 → any2md-1.1.0}/assets/logo-original.jpeg +0 -0
  41. {any2md-1.0.7 → any2md-1.1.0}/assets/logo.png +0 -0
  42. {any2md-1.0.7 → any2md-1.1.0}/assets/social-preview.jpg +0 -0
  43. {any2md-1.0.7 → any2md-1.1.0}/docs/cli-reference.md +0 -0
  44. {any2md-1.0.7 → any2md-1.1.0}/docs/output-format.md +0 -0
  45. {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
  46. {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
  47. {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
  48. {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
  49. {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
  50. {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +0 -0
  51. {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/plans/2026-04-27-v1.0.6-security-hardening.md +0 -0
  52. {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
  53. {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
  54. {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +0 -0
  55. {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
  56. {any2md-1.0.7 → any2md-1.1.0}/docs/superpowers/specs/2026-04-27-v1.0.6-security-hardening-design.md +0 -0
  57. {any2md-1.0.7 → any2md-1.1.0}/docs/upgrading-from-0.7.md +0 -0
  58. {any2md-1.0.7 → any2md-1.1.0}/requirements.txt +0 -0
  59. {any2md-1.0.7 → any2md-1.1.0}/scripts/audit-outputs.py +0 -0
  60. {any2md-1.0.7 → any2md-1.1.0}/scripts/validate-phase1.sh +0 -0
  61. {any2md-1.0.7 → any2md-1.1.0}/tests/__init__.py +0 -0
  62. {any2md-1.0.7 → any2md-1.1.0}/tests/cli/__init__.py +0 -0
  63. {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_backend.py +0 -0
  64. {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_existing_flags.py +0 -0
  65. {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_exit_codes.py +0 -0
  66. {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_high_fidelity.py +0 -0
  67. {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_meta.py +0 -0
  68. {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
  69. {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_ocr_save_images.py +0 -0
  70. {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_quiet_verbose.py +0 -0
  71. {any2md-1.0.7 → any2md-1.1.0}/tests/cli/test_cli_strict.py +0 -0
  72. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/__init__.py +0 -0
  73. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
  74. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/docs/multi_column.pdf +0 -0
  75. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/docs/table_heavy.docx +0 -0
  76. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/docs/web_page.html +0 -0
  77. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/make_fixtures.py +0 -0
  78. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
  79. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
  80. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
  81. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
  82. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
  83. {any2md-1.0.7 → any2md-1.1.0}/tests/fixtures/snapshots/web_page.md +0 -0
  84. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/__init__.py +0 -0
  85. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_docx_backend_override.py +0 -0
  86. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_docx_docling.py +0 -0
  87. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_docx_fallback_on_warn.py +0 -0
  88. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_docx_mammoth_fallback.py +0 -0
  89. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_html_trafilatura.py +0 -0
  90. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_pdf_backend_override.py +0 -0
  91. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_pdf_docling.py +0 -0
  92. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_pdf_pymupdf_fallback.py +0 -0
  93. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_save_images.py +0 -0
  94. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_snapshots.py +0 -0
  95. {any2md-1.0.7 → any2md-1.1.0}/tests/integration/test_txt.py +0 -0
  96. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/__init__.py +0 -0
  97. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/__init__.py +0 -0
  98. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
  99. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
  100. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +0 -0
  101. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
  102. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
  103. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
  104. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
  105. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
  106. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
  107. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_runner.py +0 -0
  108. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_strip_orphan_punctuation.py +0 -0
  109. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_structured_body_cleanup_stages.py +0 -0
  110. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
  111. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
  112. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
  113. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
  114. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
  115. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
  116. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_dedupe_toc_table.py +0 -0
  117. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
  118. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
  119. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
  120. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +0 -0
  121. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
  122. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_strip_repeated_byline.py +0 -0
  123. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/pipeline/test_text_strip_web_fragments.py +0 -0
  124. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/scripts/__init__.py +0 -0
  125. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/scripts/test_audit_outputs.py +0 -0
  126. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_atomic_write.py +0 -0
  127. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_auto_id.py +0 -0
  128. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_config.py +0 -0
  129. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_config_walkup.py +0 -0
  130. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_content_hash.py +0 -0
  131. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_docling_helper.py +0 -0
  132. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_frontmatter_compose.py +0 -0
  133. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_frontmatter_helpers.py +0 -0
  134. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_heuristics.py +0 -0
  135. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_http_safe.py +0 -0
  136. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_log_sanitizer.py +0 -0
  137. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_meta_overrides.py +0 -0
  138. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_meta_reserved_keys.py +0 -0
  139. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_pdf_complexity.py +0 -0
  140. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_safe_dir_name.py +0 -0
  141. {any2md-1.0.7 → any2md-1.1.0}/tests/unit/test_validators.py +0 -0
  142. {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.7
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,11 @@
1
+ """any2md — convert documents to LLM-friendly Markdown."""
2
+
3
+ from any2md._docling_cache import docling_session, release_models
4
+
5
+ __version__ = "1.1.0"
6
+
7
+ __all__ = [
8
+ "__version__",
9
+ "docling_session",
10
+ "release_models",
11
+ ]
@@ -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 docling.document_converter import DocumentConverter
182
+ from any2md._docling_cache import (
183
+ evict_on_convert_failure,
184
+ get_docx_converter,
185
+ )
178
186
 
179
- converter = DocumentConverter()
187
+ converter = get_docx_converter()
180
188
  with _DoclingMswordWarningCapture() as cap:
181
- result = converter.convert(str(docx_path))
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
- from docling.document_converter import DocumentConverter, PdfFormatOption
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 = DocumentConverter(
134
- format_options={
135
- InputFormat.PDF: PdfFormatOption(pipeline_options=pipeline_opts),
136
- }
137
- )
138
- result = converter.convert(str(pdf_path))
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 includes 2–10 seconds
684
- of model load. For batch conversions, this is amortized; for single-file
685
- invocations, it dominates. A long-running daemon that holds Docling open
686
- between conversions would eliminate this. Out of scope for v1.0 (the
687
- project is a CLI, not a service).
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