any2md 1.1.0__tar.gz → 1.2.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 (165) hide show
  1. {any2md-1.1.0 → any2md-1.2.0}/.gitignore +0 -1
  2. {any2md-1.1.0 → any2md-1.2.0}/CHANGELOG.md +140 -0
  3. {any2md-1.1.0 → any2md-1.2.0}/CONTRIBUTING.md +19 -1
  4. {any2md-1.1.0 → any2md-1.2.0}/PKG-INFO +5 -5
  5. {any2md-1.1.0 → any2md-1.2.0}/SECURITY.md +22 -0
  6. {any2md-1.1.0 → any2md-1.2.0}/any2md/__init__.py +1 -1
  7. {any2md-1.1.0 → any2md-1.2.0}/any2md/_docling.py +1 -1
  8. {any2md-1.1.0 → any2md-1.2.0}/any2md/_docling_cache.py +3 -1
  9. any2md-1.2.0/any2md/_http.py +199 -0
  10. any2md-1.2.0/any2md/_logging.py +61 -0
  11. {any2md-1.1.0 → any2md-1.2.0}/any2md/cli.py +11 -6
  12. {any2md-1.1.0 → any2md-1.2.0}/any2md/config.py +7 -15
  13. {any2md-1.1.0 → any2md-1.2.0}/any2md/converters/__init__.py +2 -2
  14. {any2md-1.1.0 → any2md-1.2.0}/any2md/converters/docx.py +3 -16
  15. {any2md-1.1.0 → any2md-1.2.0}/any2md/converters/html.py +143 -5
  16. {any2md-1.1.0 → any2md-1.2.0}/any2md/converters/pdf.py +27 -13
  17. {any2md-1.1.0 → any2md-1.2.0}/any2md/converters/txt.py +3 -4
  18. {any2md-1.1.0 → any2md-1.2.0}/any2md/frontmatter.py +30 -0
  19. {any2md-1.1.0 → any2md-1.2.0}/any2md/heuristics.py +9 -6
  20. {any2md-1.1.0 → any2md-1.2.0}/any2md/utils.py +57 -10
  21. {any2md-1.1.0 → any2md-1.2.0}/docs/architecture.md +3 -0
  22. {any2md-1.1.0 → any2md-1.2.0}/docs/output-format.md +46 -8
  23. any2md-1.2.0/docs/superpowers/plans/2026-05-06-v1.1.1-security-hardening.md +2543 -0
  24. any2md-1.2.0/docs/superpowers/specs/2026-05-06-v1.1.1-security-design.md +553 -0
  25. {any2md-1.1.0 → any2md-1.2.0}/pyproject.toml +3 -3
  26. any2md-1.2.0/requirements.txt +81 -0
  27. any2md-1.2.0/tests/fixtures/docs/web_bold_title_first_line.html +15 -0
  28. any2md-1.2.0/tests/fixtures/docs/web_h1_in_hero.html +38 -0
  29. any2md-1.2.0/tests/fixtures/docs/web_h3_sections.html +21 -0
  30. any2md-1.2.0/tests/fixtures/docs/web_no_title.html +12 -0
  31. any2md-1.2.0/tests/integration/test_html_h1.py +148 -0
  32. any2md-1.2.0/tests/unit/test_atomic_write_symlink_race.py +39 -0
  33. any2md-1.2.0/tests/unit/test_config_toml_warn.py +36 -0
  34. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_docling_cache.py +2 -2
  35. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_frontmatter_compose.py +28 -0
  36. any2md-1.2.0/tests/unit/test_frontmatter_key_injection.py +59 -0
  37. any2md-1.2.0/tests/unit/test_heuristics_wikipedia_host.py +22 -0
  38. any2md-1.2.0/tests/unit/test_html_heading_levels.py +72 -0
  39. any2md-1.2.0/tests/unit/test_html_title_h1.py +117 -0
  40. any2md-1.2.0/tests/unit/test_http_ip_allowlist.py +68 -0
  41. any2md-1.2.0/tests/unit/test_http_ssrf_pin.py +63 -0
  42. any2md-1.2.0/tests/unit/test_http_user_agent.py +10 -0
  43. any2md-1.2.0/tests/unit/test_lockfile_freshness.py +41 -0
  44. any2md-1.2.0/tests/unit/test_logging_module.py +85 -0
  45. any2md-1.2.0/tests/unit/test_pdf_image_symlink.py +54 -0
  46. any2md-1.2.0/tests/unit/test_security_error_strings.py +86 -0
  47. any2md-1.2.0/tests/unit/test_url_creds_scrubbed.py +76 -0
  48. any2md-1.2.0/tests/unit/test_workflow_permissions.py +44 -0
  49. any2md-1.1.0/any2md/_http.py +0 -106
  50. any2md-1.1.0/requirements.txt +0 -10
  51. {any2md-1.1.0 → any2md-1.2.0}/CODE_OF_CONDUCT.md +0 -0
  52. {any2md-1.1.0 → any2md-1.2.0}/LICENSE +0 -0
  53. {any2md-1.1.0 → any2md-1.2.0}/README.md +0 -0
  54. {any2md-1.1.0 → any2md-1.2.0}/any2md/__main__.py +0 -0
  55. {any2md-1.1.0 → any2md-1.2.0}/any2md/pipeline/__init__.py +0 -0
  56. {any2md-1.1.0 → any2md-1.2.0}/any2md/pipeline/cleanup.py +0 -0
  57. {any2md-1.1.0 → any2md-1.2.0}/any2md/pipeline/structured.py +0 -0
  58. {any2md-1.1.0 → any2md-1.2.0}/any2md/pipeline/text.py +0 -0
  59. {any2md-1.1.0 → any2md-1.2.0}/any2md/validators.py +0 -0
  60. {any2md-1.1.0 → any2md-1.2.0}/assets/logo-original.jpeg +0 -0
  61. {any2md-1.1.0 → any2md-1.2.0}/assets/logo.png +0 -0
  62. {any2md-1.1.0 → any2md-1.2.0}/assets/social-preview.jpg +0 -0
  63. {any2md-1.1.0 → any2md-1.2.0}/docs/cli-reference.md +0 -0
  64. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase1-foundation.md +0 -0
  65. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase2-docling-backend.md +0 -0
  66. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase3-figures-ocr-text-lane.md +0 -0
  67. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1-phase4-polish-config-docs.md +0 -0
  68. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-any2md-v1.0.2-implementation.md +0 -0
  69. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-26-issue-17-toc-followups.md +0 -0
  70. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-04-27-v1.0.6-security-hardening.md +0 -0
  71. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/plans/2026-05-04-v1.1.0-persistent-converter.md +0 -0
  72. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-any2md-v1-design.md +0 -0
  73. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-any2md-v1.0.2-design.md +0 -0
  74. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-issue-17-toc-followups-design.md +0 -0
  75. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-04-26-v1.0.3-empty-title-orphan-punct-design.md +0 -0
  76. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-04-27-v1.0.6-security-hardening-design.md +0 -0
  77. {any2md-1.1.0 → any2md-1.2.0}/docs/superpowers/specs/2026-05-04-v1.1.0-persistent-converter-design.md +0 -0
  78. {any2md-1.1.0 → any2md-1.2.0}/docs/troubleshooting.md +0 -0
  79. {any2md-1.1.0 → any2md-1.2.0}/docs/upgrading-from-0.7.md +0 -0
  80. {any2md-1.1.0 → any2md-1.2.0}/scripts/audit-outputs.py +0 -0
  81. {any2md-1.1.0 → any2md-1.2.0}/scripts/bench_docling_cache.py +0 -0
  82. {any2md-1.1.0 → any2md-1.2.0}/scripts/validate-phase1.sh +0 -0
  83. {any2md-1.1.0 → any2md-1.2.0}/tests/__init__.py +0 -0
  84. {any2md-1.1.0 → any2md-1.2.0}/tests/cli/__init__.py +0 -0
  85. {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_backend.py +0 -0
  86. {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_existing_flags.py +0 -0
  87. {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_exit_codes.py +0 -0
  88. {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_high_fidelity.py +0 -0
  89. {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_meta.py +0 -0
  90. {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_no_arxiv_lookup.py +0 -0
  91. {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_ocr_save_images.py +0 -0
  92. {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_quiet_verbose.py +0 -0
  93. {any2md-1.1.0 → any2md-1.2.0}/tests/cli/test_cli_strict.py +0 -0
  94. {any2md-1.1.0 → any2md-1.2.0}/tests/conftest.py +0 -0
  95. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/__init__.py +0 -0
  96. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/docs/ligatures_and_softhyphens.txt +0 -0
  97. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/docs/multi_column.pdf +0 -0
  98. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/docs/table_heavy.docx +0 -0
  99. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/docs/web_page.html +0 -0
  100. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/make_fixtures.py +0 -0
  101. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/ligatures_and_softhyphens.md +0 -0
  102. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/multi_column.docling.md +0 -0
  103. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/multi_column.fallback.md +0 -0
  104. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/table_heavy.docling.md +0 -0
  105. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/table_heavy.fallback.md +0 -0
  106. {any2md-1.1.0 → any2md-1.2.0}/tests/fixtures/snapshots/web_page.md +0 -0
  107. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/__init__.py +0 -0
  108. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_docling_persistence.py +0 -0
  109. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_docx_backend_override.py +0 -0
  110. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_docx_docling.py +0 -0
  111. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_docx_fallback_on_warn.py +0 -0
  112. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_docx_mammoth_fallback.py +0 -0
  113. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_html_trafilatura.py +0 -0
  114. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_pdf_backend_override.py +0 -0
  115. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_pdf_docling.py +0 -0
  116. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_pdf_pymupdf_fallback.py +0 -0
  117. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_save_images.py +0 -0
  118. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_snapshots.py +0 -0
  119. {any2md-1.1.0 → any2md-1.2.0}/tests/integration/test_txt.py +0 -0
  120. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/__init__.py +0 -0
  121. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/__init__.py +0 -0
  122. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_footnote_markers.py +0 -0
  123. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_html_entities.py +0 -0
  124. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_html_entities_double_encoded.py +0 -0
  125. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_ligatures.py +0 -0
  126. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_nfc.py +0 -0
  127. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_quotes_dashes.py +0 -0
  128. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_soft_hyphens.py +0 -0
  129. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_validate.py +0 -0
  130. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_cleanup_whitespace.py +0 -0
  131. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_runner.py +0 -0
  132. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_strip_orphan_punctuation.py +0 -0
  133. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_structured_body_cleanup_stages.py +0 -0
  134. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_structured_compact_tables.py +0 -0
  135. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_structured_heading_hierarchy.py +0 -0
  136. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_structured_lift_figures.py +0 -0
  137. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_structured_normalize_cites.py +0 -0
  138. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_paragraphs.py +0 -0
  139. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_toc.py +0 -0
  140. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_dedupe_toc_table.py +0 -0
  141. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_dehyphenate.py +0 -0
  142. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_repair_line_wraps.py +0 -0
  143. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_restore_lists_code.py +0 -0
  144. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_cover_artifacts.py +0 -0
  145. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_headers_footers.py +0 -0
  146. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_repeated_byline.py +0 -0
  147. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/pipeline/test_text_strip_web_fragments.py +0 -0
  148. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/scripts/__init__.py +0 -0
  149. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/scripts/test_audit_outputs.py +0 -0
  150. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_atomic_write.py +0 -0
  151. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_auto_id.py +0 -0
  152. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_config.py +0 -0
  153. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_config_walkup.py +0 -0
  154. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_content_hash.py +0 -0
  155. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_docling_helper.py +0 -0
  156. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_frontmatter_helpers.py +0 -0
  157. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_heuristics.py +0 -0
  158. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_http_safe.py +0 -0
  159. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_log_sanitizer.py +0 -0
  160. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_meta_overrides.py +0 -0
  161. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_meta_reserved_keys.py +0 -0
  162. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_pdf_complexity.py +0 -0
  163. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_safe_dir_name.py +0 -0
  164. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_validators.py +0 -0
  165. {any2md-1.1.0 → any2md-1.2.0}/tests/unit/test_zipbomb_guard.py +0 -0
@@ -43,4 +43,3 @@ logo/
43
43
  # ZERG / dev tooling
44
44
  .gsd/
45
45
  .zerg/
46
- .devcontainer/
@@ -4,6 +4,146 @@ 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.2.0] — 2026-11-09
8
+
9
+ Drops Python 3.10 to take the Pillow 13 security fixes, and fixes
10
+ missing H1 headings in HTML/URL output.
11
+
12
+ ### Removed
13
+
14
+ - **Python 3.10 support.** `requires-python` is now `>=3.11`. Pillow
15
+ 13.0.0 is the only release that fixes the advisories below, and it
16
+ requires Python 3.11. Python 3.10 reached end-of-life in October 2026.
17
+ pip on 3.10 keeps resolving any2md 1.1.1, which pins the vulnerable
18
+ pillow 12.x; upgrade the interpreter to get these fixes. The `tomli`
19
+ fallback dependency is gone with it (stdlib `tomllib` covers 3.11+),
20
+ and the CI matrix now tests 3.11, 3.12, and 3.13.
21
+
22
+ ### Security
23
+
24
+ - **Pillow 13.0.0.** The `pillow>=12.2.0,<13` range held installs on a
25
+ release with 39 published advisories (e.g. CVE-2026-112395), all fixed
26
+ in 13.0.0. The range is now `pillow>=13.0.0,<14`. Docling's packages
27
+ accept `pillow<14`, so the `high-fidelity` extra still resolves.
28
+ - **Locked dev-container dependencies.** `.devcontainer/requirements.lock`
29
+ and `requirements.txt` move certifi, lxml-html-clean, soupsieve, and
30
+ urllib3 to their fixed releases (lxml follows to 6.1.3). Both
31
+ lockfiles now audit clean with `pip-audit`.
32
+
33
+ ### Fixed
34
+
35
+ - **HTML/URL bodies had no H1.** trafilatura keeps a heading only
36
+ when it sits inside the content node it selects. A page `<h1>` in a
37
+ hero banner or `<header>` block outside that node was dropped, so
38
+ nearly every blog page warned `validator: H1 count is 0 (expected 1)`
39
+ (106 of 106 labs.zenity.io posts, 182 of 214 zenity.io/blog posts).
40
+ The HTML converter now restores the H1 before the pipeline runs:
41
+ - The body already has an H1: unchanged.
42
+ - The first body line repeats the title as plain or bold text: that
43
+ line is promoted to the H1, so the title appears once.
44
+ - Otherwise `# <title>` is inserted as the first line.
45
+ - No title is available: unchanged (the validator still warns).
46
+
47
+ The H1 text is the page's own `<h1>` when the page has exactly one
48
+ non-empty `<h1>`, else trafilatura's metadata title. `content_hash`,
49
+ `token_estimate`, `word_count`, `recommended_chunk_level`, and
50
+ `abstract_for_rag` are computed from the body with the H1 in place.
51
+ PDF, DOCX, and TXT output is unchanged.
52
+ - **HTML/URL heading levels skipped below the H1.** Many CMS templates
53
+ mark body sections as H3 or H4 directly under the page H1, so the
54
+ restored H1 surfaced `validator: heading level skip h1 -> h3` (34 of
55
+ the 320 zenity posts above). Headings are now re-ranked by section
56
+ depth below the H1: siblings stay siblings, nesting is kept, and only
57
+ the `#` prefix changes (`H3, H3, H4, H3` becomes `H2, H2, H3, H2`).
58
+ Bodies without skips are byte-identical. All 320 posts now convert
59
+ with 0 validator warnings.
60
+
61
+ ### Added
62
+
63
+ - **`seo_title`** frontmatter extension field (HTML/URL only). Holds
64
+ trafilatura's metadata title (`og:title` / `<title>`) when it differs
65
+ from `title`. Omitted when the two match.
66
+
67
+ ### Changed
68
+
69
+ - **CI pins ruff to 0.15.22.** Ruff 0.16 and later enable a wider default
70
+ rule set this repo has not adopted, which failed the required lint check
71
+ on an unchanged `main`. The `dev` extra pins the same version.
72
+ - **Release workflow pins `pypa/gh-action-pypi-publish` v1.14.3.** The
73
+ previous pin bundled twine 6.1, which rejects the Metadata-Version 2.5
74
+ that current hatchling writes, so uploads failed before reaching the
75
+ index. The validate step now pins `build==1.6.1` and `twine==7.0.0`.
76
+ - **HTML/URL `title` follows the visible page heading.** `title` is
77
+ still derived from the body H1, and the body H1 is now the page's
78
+ `<h1>`. On pages whose `og:title` differs from their `<h1>`, `title`
79
+ changes from the SEO title to the visible heading. The previous value
80
+ moves to `seo_title`. Pin a title with `--meta title=...` if you
81
+ depend on the old value.
82
+
83
+ ## [1.1.1] — 2026-05-07
84
+
85
+ Security patch release. Closes 10 findings from a comprehensive scan
86
+ (bandit, snyk-code, gitleaks, pip-audit, trivy, manual review) plus
87
+ a 5-round adversarial review of the implementation plan.
88
+
89
+ ### Security
90
+
91
+ - **SSRF intra-hop DNS-rebind**: `safe_fetch` now resolves the
92
+ hostname once, validates the IP against an allowlist, and passes
93
+ the validated IP through to `socket.create_connection` via a
94
+ custom `HTTPSConnection`. Defeats attacks that return a public IP
95
+ to a validator and a private IP (e.g., `169.254.169.254`) to the
96
+ connector. `ProxyHandler({})` explicitly suppresses
97
+ `HTTP_PROXY`/`HTTPS_PROXY` env vars that would otherwise route
98
+ traffic past the validator.
99
+ - **IP allowlist**: switched from a four-flag denylist
100
+ (`is_private | is_reserved | is_loopback | is_link_local`) to an
101
+ allowlist (`is_global and not is_multicast and not is_unspecified`).
102
+ **Breaking** for users on multicast / CGNAT / IETF-reserved
103
+ networks fetching same-network resources; pre-fetch with `curl`
104
+ and pass `--from-file` as a workaround.
105
+ - **Frontmatter override-key validation**: keys must now match
106
+ `^[A-Za-z_][A-Za-z0-9_-]*$`. Defends against newline-injection in
107
+ TOML basic-string keys that produced split YAML lines.
108
+ **Breaking** for TOML configs with quoted keys containing
109
+ whitespace or punctuation.
110
+ - **URL credentials and sensitive query parameters** in `userinfo`
111
+ and matching `(?i)(token|api[_-]?key|access[_-]?token|bearer|signature|sig|password|secret|aws[_-]?session[_-]?token)$`
112
+ are scrubbed before any logging, persistence, or filename
113
+ derivation. IPv6 host brackets preserved across the scrub.
114
+ - **Docling image saves** use `O_EXCL|O_NOFOLLOW` to refuse symlink
115
+ TOCTOU.
116
+ - **`atomic_write_text`** tightens the symlink-check window via
117
+ `os.lstat` immediately before `os.replace`. Parent-dir hardening
118
+ deferred to v1.2 per the operator-trusted threat model.
119
+ - **Stderr/stdout sanitizer** extended with Unicode bidi
120
+ (U+202A–E, U+2066–9), zero-width chars (U+200B–F, U+FEFF), and
121
+ the BOM. New `any2md._logging` module; `_sanitize_log_text`
122
+ re-exported from `any2md.converters.docx` for one release.
123
+ - **`publish.yml` workflow**: top-level `permissions: contents: read`;
124
+ both publish jobs flip to `attestations: true` for PEP 740 sigstore
125
+ provenance.
126
+
127
+ ### Fixed
128
+
129
+ - `wikipedia.org` heuristic host check no longer matches
130
+ `evilwikipedia.org`. Pure correctness fix; impact was incorrect
131
+ text formatting only, not a security boundary.
132
+ - `_USER_AGENT` no longer hardcoded to `any2md/1.0.6`; derives from
133
+ `__version__`.
134
+
135
+ ### Changed
136
+
137
+ - `requirements.txt` is now a generated artifact; canonical source
138
+ is `.devcontainer/requirements.lock` with hashes
139
+ (`--require-hashes` enforced in `.devcontainer/devcontainer.json`).
140
+ See `CONTRIBUTING.md` for the dep-update workflow.
141
+ - `load_toml()` now warns to stderr on parse error rather than
142
+ silently returning `{}`.
143
+ - `.devcontainer/` is now tracked in git (was previously ignored)
144
+ so the security-critical Codespace bootstrap config is
145
+ version-controlled.
146
+
7
147
  ## [1.1.0] — 2026-05-04
8
148
 
9
149
  Performance and library-mode hygiene release.
@@ -29,7 +29,7 @@ the test suite gates Docling-dependent tests with
29
29
  `@pytest.mark.skipif(not has_docling(), …)` so they're transparently skipped
30
30
  without it. Coverage of fallback paths is unaffected.
31
31
 
32
- Python 3.10 or later is required.
32
+ Python 3.11 or later is required.
33
33
 
34
34
  ## Running tests
35
35
 
@@ -131,6 +131,24 @@ The flow:
131
131
  Don't tag and release directly without the PR — CI gates exist for a
132
132
  reason.
133
133
 
134
+ ## Updating dependencies
135
+
136
+ Dependencies are declared in `pyproject.toml` and locked into both:
137
+
138
+ - `.devcontainer/requirements.lock` — hash-locked, used by Codespace `postCreateCommand` (`pip install --require-hashes`).
139
+ - `requirements.txt` — non-hash form, kept for backwards compatibility with `pip install -r requirements.txt` consumers.
140
+
141
+ To add or update a dep:
142
+
143
+ 1. Edit `pyproject.toml` `[project] dependencies` (or the relevant `[project.optional-dependencies]` group).
144
+ 2. Regenerate both lockfiles in a venv with `pip-tools` installed:
145
+
146
+ pip-compile pyproject.toml --generate-hashes --resolver=backtracking \
147
+ -o .devcontainer/requirements.lock
148
+ pip-compile pyproject.toml --resolver=backtracking -o requirements.txt
149
+
150
+ 3. Commit all three files together.
151
+
134
152
  ## Pull request process
135
153
 
136
154
  - Branch off `main` (or off a phase branch like `v1.0` if one is open for
@@ -1,6 +1,6 @@
1
- Metadata-Version: 2.4
1
+ Metadata-Version: 2.5
2
2
  Name: any2md
3
- Version: 1.1.0
3
+ Version: 1.2.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
@@ -11,13 +11,13 @@ Classifier: License :: OSI Approved :: MIT License
11
11
  Classifier: Operating System :: OS Independent
12
12
  Classifier: Programming Language :: Python :: 3
13
13
  Classifier: Topic :: Text Processing :: Markup :: Markdown
14
- Requires-Python: >=3.10
14
+ Requires-Python: >=3.11
15
15
  Requires-Dist: beautifulsoup4<5,>=4.12.0
16
16
  Requires-Dist: defusedxml<1,>=0.7.1
17
17
  Requires-Dist: lxml<7,>=6.1.0
18
18
  Requires-Dist: mammoth<2,>=1.6.0
19
19
  Requires-Dist: markdownify<2,>=0.13.0
20
- Requires-Dist: pillow<13,>=12.2.0
20
+ Requires-Dist: pillow<14,>=13.0.0
21
21
  Requires-Dist: pymupdf4llm<1,>=0.0.17
22
22
  Requires-Dist: pymupdf<2,>=1.24.0
23
23
  Requires-Dist: trafilatura<3,>=1.12.0
@@ -27,7 +27,7 @@ Requires-Dist: pytest-snapshot>=0.9.0; extra == 'dev'
27
27
  Requires-Dist: pytest>=8.0.0; extra == 'dev'
28
28
  Requires-Dist: pyyaml>=6.0; extra == 'dev'
29
29
  Requires-Dist: reportlab>=4.0.0; extra == 'dev'
30
- Requires-Dist: ruff>=0.6.0; extra == 'dev'
30
+ Requires-Dist: ruff==0.15.22; extra == 'dev'
31
31
  Provides-Extra: high-fidelity
32
32
  Requires-Dist: docling>=2.0.0; extra == 'high-fidelity'
33
33
  Description-Content-Type: text/markdown
@@ -81,3 +81,25 @@ If you operate any2md in a hardened environment:
81
81
 
82
82
  No publicly disclosed vulnerabilities to date. Future advisories will
83
83
  appear at https://github.com/rocklambros/any2md/security/advisories.
84
+
85
+ ## Trust boundaries
86
+
87
+ `any2md` is an operator-trusted CLI distributed via PyPI for desktop and CI use.
88
+
89
+ **Trusted:** the operator, their filesystem, their `--output-dir`, and the Python interpreter they invoke. CLI flags and `--meta key=val` arguments are operator-controlled.
90
+
91
+ **Untrusted:**
92
+ - URLs passed to `any2md` (may resolve to internal IPs, redirect, or carry credentials in userinfo/query).
93
+ - Documents (PDF/DOCX/HTML) passed for conversion (may embed crafted images, control characters, malicious metadata).
94
+ - `.any2md.toml` config files discovered via cwd-walking (an attacker who can drop a file in any ancestor directory can inject frontmatter overrides).
95
+ - HTTP responses from URLs (may carry hostile redirects, oversized bodies, control characters in headers).
96
+
97
+ **Out of scope:** multi-tenant service hosting; running `any2md` against attacker-supplied output directories; supply-chain compromise of pinned dependencies; protection against an operator who has chosen `--output-dir` pointing at a shared/symlinked directory.
98
+
99
+ ## Dependency-drift policy
100
+
101
+ If `pip-audit` reports a NEW HIGH/CRITICAL vulnerability between baseline and release-cut, the policy is:
102
+
103
+ 1. Bump the dep within its compatible range (regenerate `.devcontainer/requirements.lock` and `requirements.txt`) and re-cut the rc.
104
+ 2. If no compatible fix exists, document in CHANGELOG `## Known issues` and ship only if the codepath is mitigated by other code (e.g., XXE blocked by `defusedxml`).
105
+ 3. If neither is possible, hold the release until upstream lands a fix.
@@ -2,7 +2,7 @@
2
2
 
3
3
  from any2md._docling_cache import docling_session, release_models
4
4
 
5
- __version__ = "1.1.0"
5
+ __version__ = "1.2.0"
6
6
 
7
7
  __all__ = [
8
8
  "__version__",
@@ -33,5 +33,5 @@ def install_hint() -> None:
33
33
  global _hint_emitted
34
34
  if _hint_emitted:
35
35
  return
36
- print(f" WARN: {INSTALL_HINT_MSG}", file=sys.stderr)
36
+ print(f" WARN: {INSTALL_HINT_MSG}", file=sys.stderr) # log-safe: literal
37
37
  _hint_emitted = True
@@ -271,7 +271,9 @@ class ConverterCache:
271
271
 
272
272
  if is_quiet() or not sys.stderr.isatty():
273
273
  return True
274
- print(" Loading Docling models (one-time)...", file=sys.stderr)
274
+ print(
275
+ " Loading Docling models (one-time)...", file=sys.stderr
276
+ ) # log-safe: literal
275
277
  return True
276
278
 
277
279
 
@@ -0,0 +1,199 @@
1
+ """SSRF-safe HTTP fetching for any2md.
2
+
3
+ Defenses against:
4
+ - non-http(s) schemes
5
+ - URLs resolving to non-public addresses (private, multicast, CGNAT, etc.)
6
+ - DNS rebinding via redirect (per-hop revalidation)
7
+ - DNS rebinding INTRA-hop (IP pinned through to socket.create_connection,
8
+ so the IP we validated is the IP we connect to)
9
+ - Environment-proxy bypass (ProxyHandler({}) suppresses HTTP_PROXY/HTTPS_PROXY)
10
+ - Decompression / huge-response DoS (20 MB body cap)
11
+ - Redirect loops / excessive hops (max 3)
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import http.client
17
+ import ipaddress
18
+ import socket
19
+ import urllib.error
20
+ import urllib.parse
21
+ import urllib.request
22
+
23
+ from any2md import __version__
24
+
25
+ _MAX_REDIRECT_HOPS = 3
26
+ _FETCH_TIMEOUT = 15 # seconds
27
+ _MAX_RESPONSE_BYTES = 20 * 1024 * 1024 # 20 MB cap on body read
28
+ _USER_AGENT = f"any2md/{__version__}"
29
+
30
+
31
+ def _addr_allowed(addr) -> bool:
32
+ """Allowlist policy: globally routable, non-multicast, non-unspecified."""
33
+ return bool(addr.is_global) and not addr.is_multicast and not addr.is_unspecified
34
+
35
+
36
+ def _resolve_and_validate(hostname: str) -> tuple[list[str], str | None]:
37
+ """One-shot DNS resolution + IP-class validation.
38
+
39
+ Returns ``(ips, None)`` on success or ``([], error)`` on rejection.
40
+ Deny-on-any: if ANY resolved address fails policy, the whole hostname
41
+ is rejected (defends against multi-record split-result attacks).
42
+ """
43
+ try:
44
+ infos = socket.getaddrinfo(hostname, None, type=socket.SOCK_STREAM)
45
+ except socket.gaierror:
46
+ return [], f"cannot resolve host: {hostname}"
47
+ ips: list[str] = []
48
+ for *_, sockaddr in infos:
49
+ ip_str = sockaddr[0]
50
+ try:
51
+ addr = ipaddress.ip_address(ip_str)
52
+ except ValueError:
53
+ continue
54
+ if not _addr_allowed(addr):
55
+ return [], f"URL resolves to disallowed address: {addr}"
56
+ ips.append(ip_str)
57
+ if not ips:
58
+ return [], f"no usable address for {hostname}"
59
+ return ips, None
60
+
61
+
62
+ def validate_url(url: str) -> str | None:
63
+ """Validate scheme + hostname + IP class. Returns error or None.
64
+
65
+ One-shot DNS resolution; intra-hop rebind defense lives in
66
+ :func:`safe_fetch` (which pins the connection to the resolved IP).
67
+ """
68
+ parsed = urllib.parse.urlsplit(url)
69
+ if parsed.scheme not in ("http", "https"):
70
+ return f"unsupported scheme: {parsed.scheme!r}"
71
+ if not parsed.hostname:
72
+ return f"no hostname in URL: {url}"
73
+ _ips, err = _resolve_and_validate(parsed.hostname)
74
+ return err
75
+
76
+
77
+ class _NoFollowRedirect(urllib.request.HTTPRedirectHandler):
78
+ """Disable urllib's automatic redirect following."""
79
+
80
+ def redirect_request(self, *args, **kwargs): # noqa: ARG002
81
+ return None
82
+
83
+
84
+ class _PinnedHTTPSConnection(http.client.HTTPSConnection):
85
+ def __init__(self, host: str, *args, pinned_ip: str, **kwargs):
86
+ super().__init__(host, *args, **kwargs)
87
+ self._pinned_ip = pinned_ip
88
+
89
+ def connect(self):
90
+ sock = socket.create_connection(
91
+ (self._pinned_ip, self.port), timeout=self.timeout
92
+ )
93
+ if self._tunnel_host:
94
+ self.sock = sock
95
+ self._tunnel()
96
+ # SNI + cert validation use the original hostname, NOT the IP
97
+ self.sock = self._context.wrap_socket(sock, server_hostname=self.host)
98
+
99
+
100
+ class _PinnedHTTPConnection(http.client.HTTPConnection):
101
+ def __init__(self, host: str, *args, pinned_ip: str, **kwargs):
102
+ super().__init__(host, *args, **kwargs)
103
+ self._pinned_ip = pinned_ip
104
+
105
+ def connect(self):
106
+ self.sock = socket.create_connection(
107
+ (self._pinned_ip, self.port), timeout=self.timeout
108
+ )
109
+
110
+
111
+ def _build_pinned_opener(pinned_ip: str, scheme: str) -> urllib.request.OpenerDirector:
112
+ """Build an opener that:
113
+ - suppresses HTTP_PROXY/HTTPS_PROXY env (ProxyHandler({}))
114
+ - disables urllib's auto-redirect (we walk hops manually)
115
+ - pins HTTP/S connections to the validated IP
116
+ """
117
+ proxy_handler = urllib.request.ProxyHandler({})
118
+ no_redirect = _NoFollowRedirect()
119
+
120
+ if scheme == "https":
121
+
122
+ class _Handler(urllib.request.HTTPSHandler):
123
+ def https_open(self, req): # noqa: D401
124
+ return self.do_open(
125
+ lambda host, **kw: _PinnedHTTPSConnection(
126
+ host, pinned_ip=pinned_ip, **kw
127
+ ),
128
+ req,
129
+ )
130
+
131
+ pinned_handler = _Handler()
132
+ else:
133
+
134
+ class _Handler(urllib.request.HTTPHandler):
135
+ def http_open(self, req): # noqa: D401
136
+ return self.do_open(
137
+ lambda host, **kw: _PinnedHTTPConnection(
138
+ host, pinned_ip=pinned_ip, **kw
139
+ ),
140
+ req,
141
+ )
142
+
143
+ pinned_handler = _Handler()
144
+
145
+ return urllib.request.build_opener(proxy_handler, no_redirect, pinned_handler)
146
+
147
+
148
+ def safe_fetch(
149
+ url: str,
150
+ *,
151
+ method: str = "GET",
152
+ max_hops: int = _MAX_REDIRECT_HOPS,
153
+ ) -> tuple[bytes | None, dict | None, str | None]:
154
+ """Fetch ``url`` with manual redirect walking + per-hop revalidation
155
+ + IP-pinned connection.
156
+
157
+ Returns ``(body, headers, None)`` on 2xx, or ``(None, None, error)``
158
+ on rejection / failure.
159
+ """
160
+ visited: list[str] = []
161
+ current = url
162
+ for hop in range(max_hops + 1):
163
+ if current in visited:
164
+ return None, None, "redirect loop"
165
+ visited.append(current)
166
+
167
+ parsed = urllib.parse.urlsplit(current)
168
+ if parsed.scheme not in ("http", "https"):
169
+ return None, None, f"unsupported scheme: {parsed.scheme!r}"
170
+ if not parsed.hostname:
171
+ return None, None, f"no hostname in URL: {current}"
172
+
173
+ ips, err = _resolve_and_validate(parsed.hostname)
174
+ if err:
175
+ return None, None, err
176
+
177
+ opener = _build_pinned_opener(ips[0], parsed.scheme)
178
+ req = urllib.request.Request(
179
+ current, method=method, headers={"User-Agent": _USER_AGENT}
180
+ )
181
+ try:
182
+ resp = opener.open(req, timeout=_FETCH_TIMEOUT)
183
+ except urllib.error.HTTPError as e:
184
+ if e.code in (301, 302, 303, 307, 308):
185
+ if hop >= max_hops:
186
+ return None, None, f"too many redirects (>{max_hops})"
187
+ location = e.headers.get("Location") if e.headers else None
188
+ if not location:
189
+ return None, None, f"HTTP {e.code} without Location"
190
+ current = urllib.parse.urljoin(current, location)
191
+ continue
192
+ return None, None, f"HTTP {e.code}"
193
+ except (urllib.error.URLError, OSError, TimeoutError) as e:
194
+ return None, None, f"fetch error: {e}"
195
+ body = resp.read(_MAX_RESPONSE_BYTES + 1)
196
+ if len(body) > _MAX_RESPONSE_BYTES:
197
+ return None, None, f"response exceeds {_MAX_RESPONSE_BYTES} bytes"
198
+ return body, dict(resp.headers), None
199
+ return None, None, f"too many redirects (>{max_hops})"
@@ -0,0 +1,61 @@
1
+ """Stderr/stdout sanitizer + structured-prefix log helpers.
2
+
3
+ Centralizes control-character + Unicode-spoof stripping for any string
4
+ interpolated from untrusted input (filenames, URLs, exception text,
5
+ HTTP response data). Provides ``warn`` / ``fail`` helpers that wrap
6
+ ``sys.stderr.write`` with a fixed two-space indent + uppercase prefix.
7
+
8
+ Threat model: attacker-supplied tokens reach a terminal via stderr.
9
+ We strip C0/C1 control chars (incl. ANSI escapes), Unicode bidi
10
+ embeds/overrides/isolates, zero-width chars, and the BOM. Newlines
11
+ are preserved by ``_sanitize_log_text`` (multi-line tracebacks remain
12
+ readable) and stripped by ``safe_oneline`` (single-line interpolation).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import re
18
+ import sys
19
+
20
+ _LOG_CONTROL_CHARS_RE = re.compile(
21
+ r"["
22
+ r"\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f" # C0 / C1 control chars
23
+ r"‪-‮" # bidi embeds + override
24
+ r"⁦-⁩" # bidi isolates
25
+ r"​-‏" # zero-width + LRM/RLM
26
+ r"" # BOM
27
+ r"]"
28
+ )
29
+
30
+ _NEWLINE_CR_RE = re.compile(r"[\r\n]")
31
+
32
+
33
+ def _sanitize_log_text(s: str) -> str:
34
+ """Strip control chars + Unicode bidi/zero-width. Preserves newlines."""
35
+ return _LOG_CONTROL_CHARS_RE.sub("", s)
36
+
37
+
38
+ def safe_oneline(s: str) -> str:
39
+ """Sanitize and collapse all newlines/CRs to single spaces.
40
+
41
+ Use for single-line interpolation of untrusted tokens (filenames,
42
+ URLs, ``str(exception)``) into log messages.
43
+ """
44
+ return _NEWLINE_CR_RE.sub(" ", _sanitize_log_text(s))
45
+
46
+
47
+ def warn(msg: str, *, prefix: str = "WARN") -> None:
48
+ """Write ``f' {prefix}: {sanitized}\\n'`` to stderr.
49
+
50
+ Applies ``safe_oneline`` to ``msg`` so embedded newlines/CRs in
51
+ interpolated values (filenames, exception text, URL components)
52
+ cannot forge additional log lines. The output is a single line.
53
+ For multi-line output (e.g., a full traceback), call
54
+ ``sys.stderr.write`` directly with ``_sanitize_log_text``.
55
+ """
56
+ sys.stderr.write(f" {prefix}: {safe_oneline(msg)}\n")
57
+
58
+
59
+ def fail(msg: str, *, prefix: str = "FAIL") -> None:
60
+ """Same as :func:`warn` with ``prefix='FAIL'``."""
61
+ warn(msg, prefix=prefix)
@@ -6,6 +6,7 @@ import time
6
6
  from pathlib import Path
7
7
  from typing import Any
8
8
 
9
+ from any2md import _logging
9
10
  from any2md.config import (
10
11
  discover_config,
11
12
  extract_document_id_settings,
@@ -236,7 +237,9 @@ def main():
236
237
 
237
238
  if args.meta_file is not None:
238
239
  if not args.meta_file.is_file():
239
- print(f"Error: --meta-file not found: {args.meta_file}", file=sys.stderr)
240
+ sys.stderr.write(
241
+ f"Error: --meta-file not found: {_logging.safe_oneline(str(args.meta_file))}\n"
242
+ )
240
243
  sys.exit(1)
241
244
  cfg = load_toml(args.meta_file)
242
245
  overrides = _deep_merge_overrides(overrides, extract_meta_overrides(cfg))
@@ -252,7 +255,7 @@ def main():
252
255
  try:
253
256
  cli_meta = parse_meta_args(args.meta)
254
257
  except ValueError as e:
255
- print(f"Error: {e}", file=sys.stderr)
258
+ sys.stderr.write(f"Error: {_logging.safe_oneline(str(e))}\n")
256
259
  sys.exit(1)
257
260
  overrides = _deep_merge_overrides(overrides, cli_meta)
258
261
  overrides = filter_reserved_overrides(
@@ -319,7 +322,7 @@ def main():
319
322
  if not p.is_absolute():
320
323
  p = Path.cwd() / p
321
324
  if not p.exists():
322
- print(f" NOT FOUND: {f}", file=sys.stderr)
325
+ _logging.fail(f"{f}", prefix="NOT FOUND")
323
326
  continue
324
327
  if p.is_dir():
325
328
  glob_method = p.rglob if args.recursive else p.glob
@@ -332,12 +335,14 @@ def main():
332
335
  )
333
336
  continue
334
337
  if p.suffix.lower() not in SUPPORTED_EXTENSIONS:
335
- print(f" UNSUPPORTED FORMAT: {f}", file=sys.stderr)
338
+ _logging.fail(f"{f}", prefix="UNSUPPORTED FORMAT")
336
339
  continue
337
340
  file_paths.append(p)
338
341
  elif args.input_dir:
339
342
  if not args.input_dir.is_dir():
340
- print(f"Error: not a directory: {args.input_dir}", file=sys.stderr)
343
+ sys.stderr.write(
344
+ f"Error: not a directory: {_logging.safe_oneline(str(args.input_dir))}\n"
345
+ )
341
346
  sys.exit(1)
342
347
  glob_method = args.input_dir.rglob if args.recursive else args.input_dir.glob
343
348
  file_paths = sorted(
@@ -393,7 +398,7 @@ def main():
393
398
  try:
394
399
  file_size = file_path.stat().st_size
395
400
  except OSError as e:
396
- print(f" FAIL: {file_path.name} -- {e}", file=sys.stderr)
401
+ _logging.fail(f"{file_path.name} -- {e}")
397
402
  fail += 1
398
403
  continue
399
404
 
@@ -5,25 +5,16 @@ is treated as frontmatter overrides (deep-merged below ``--meta-file``
5
5
  and CLI ``--meta`` arguments). The ``[document_id]`` table provides
6
6
  ``publisher_prefix`` / ``type_code`` overrides for ``--auto-id``.
7
7
 
8
- TOML parsing uses stdlib ``tomllib`` on Python 3.11+. On older
9
- interpreters we fall back to the optional ``tomli`` package; if neither
10
- is importable, ``load_toml`` returns ``{}`` and config is silently
11
- disabled (no hard error — config is opt-in convenience).
8
+ TOML parsing uses stdlib ``tomllib`` (any2md requires Python 3.11+).
12
9
  """
13
10
 
14
11
  from __future__ import annotations
15
12
 
16
- import sys
13
+ import tomllib
17
14
  from pathlib import Path
18
15
  from typing import Any
19
16
 
20
- if sys.version_info >= (3, 11):
21
- import tomllib
22
- else: # pragma: no cover - exercised only on Python 3.10
23
- try:
24
- import tomli as tomllib # type: ignore[import-not-found,no-redef]
25
- except ImportError:
26
- tomllib = None # type: ignore[assignment]
17
+ from any2md import _logging
27
18
 
28
19
 
29
20
  def load_toml(path: Path) -> dict[str, Any]:
@@ -33,12 +24,13 @@ def load_toml(path: Path) -> dict[str, Any]:
33
24
  not block conversion. Callers that need strict validation should
34
25
  parse the file themselves.
35
26
  """
36
- if tomllib is None: # pragma: no cover - 3.10 without tomli
37
- return {}
38
27
  try:
39
28
  with open(path, "rb") as f:
40
29
  return tomllib.load(f)
41
- except (OSError, ValueError):
30
+ except FileNotFoundError:
31
+ return {} # silent — discovery walks ancestors and most won't have one
32
+ except (OSError, ValueError) as e:
33
+ _logging.warn(f"failed to parse {path}: {e}")
42
34
  return {}
43
35
 
44
36
 
@@ -1,8 +1,8 @@
1
1
  """Converter dispatcher for any2md."""
2
2
 
3
- import sys
4
3
  from pathlib import Path
5
4
 
5
+ from any2md import _logging
6
6
  from any2md.pipeline import PipelineOptions
7
7
 
8
8
  SUPPORTED_EXTENSIONS = {".pdf", ".docx", ".html", ".htm", ".txt"}
@@ -84,5 +84,5 @@ def convert_file(
84
84
  from any2md.converters.txt import convert_txt
85
85
 
86
86
  return convert_txt(file_path, output_dir, options=options, force=force)
87
- print(f" UNSUPPORTED: {file_path.name} (no converter for {ext})", file=sys.stderr)
87
+ _logging.fail(f"{file_path.name} (no converter for {ext})", prefix="UNSUPPORTED")
88
88
  return False