ezmd 0.1.0rc1__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 (98) hide show
  1. ezmd-0.1.0rc1/.gitignore +22 -0
  2. ezmd-0.1.0rc1/PKG-INFO +40 -0
  3. ezmd-0.1.0rc1/README.md +1 -0
  4. ezmd-0.1.0rc1/pyproject.toml +53 -0
  5. ezmd-0.1.0rc1/src/ezmd/__init__.py +48 -0
  6. ezmd-0.1.0rc1/src/ezmd/builtin.py +26 -0
  7. ezmd-0.1.0rc1/src/ezmd/chains.py +27 -0
  8. ezmd-0.1.0rc1/src/ezmd/cli/__init__.py +44 -0
  9. ezmd-0.1.0rc1/src/ezmd/cli/app.py +47 -0
  10. ezmd-0.1.0rc1/src/ezmd/cli/batch.py +366 -0
  11. ezmd-0.1.0rc1/src/ezmd/cli/config.py +200 -0
  12. ezmd-0.1.0rc1/src/ezmd/cli/convert.py +204 -0
  13. ezmd-0.1.0rc1/src/ezmd/cli/doctor.py +284 -0
  14. ezmd-0.1.0rc1/src/ezmd/cli/exitcodes.py +100 -0
  15. ezmd-0.1.0rc1/src/ezmd/cli/info.py +180 -0
  16. ezmd-0.1.0rc1/src/ezmd/cli/options.py +113 -0
  17. ezmd-0.1.0rc1/src/ezmd/cli/output.py +178 -0
  18. ezmd-0.1.0rc1/src/ezmd/cli/remote.py +223 -0
  19. ezmd-0.1.0rc1/src/ezmd/cli/shadow.py +414 -0
  20. ezmd-0.1.0rc1/src/ezmd/context.py +170 -0
  21. ezmd-0.1.0rc1/src/ezmd/core/__init__.py +1 -0
  22. ezmd-0.1.0rc1/src/ezmd/core/licensing.py +76 -0
  23. ezmd-0.1.0rc1/src/ezmd/core/logging.py +88 -0
  24. ezmd-0.1.0rc1/src/ezmd/core/netguard.py +631 -0
  25. ezmd-0.1.0rc1/src/ezmd/core/sandbox.py +273 -0
  26. ezmd-0.1.0rc1/src/ezmd/core/textclean.py +45 -0
  27. ezmd-0.1.0rc1/src/ezmd/detect.py +407 -0
  28. ezmd-0.1.0rc1/src/ezmd/inputs.py +205 -0
  29. ezmd-0.1.0rc1/src/ezmd/ir.py +928 -0
  30. ezmd-0.1.0rc1/src/ezmd/library.py +333 -0
  31. ezmd-0.1.0rc1/src/ezmd/pipeline.py +82 -0
  32. ezmd-0.1.0rc1/src/ezmd/profiles.py +285 -0
  33. ezmd-0.1.0rc1/src/ezmd/py.typed +0 -0
  34. ezmd-0.1.0rc1/src/ezmd/registry.py +523 -0
  35. ezmd-0.1.0rc1/src/ezmd/render/__init__.py +81 -0
  36. ezmd-0.1.0rc1/src/ezmd/render/assemble.py +296 -0
  37. ezmd-0.1.0rc1/src/ezmd/render/base.py +90 -0
  38. ezmd-0.1.0rc1/src/ezmd/render/blocks.py +358 -0
  39. ezmd-0.1.0rc1/src/ezmd/render/children.py +266 -0
  40. ezmd-0.1.0rc1/src/ezmd/render/chunker.py +261 -0
  41. ezmd-0.1.0rc1/src/ezmd/render/context.py +159 -0
  42. ezmd-0.1.0rc1/src/ezmd/render/frontmatter.py +368 -0
  43. ezmd-0.1.0rc1/src/ezmd/render/headings.py +149 -0
  44. ezmd-0.1.0rc1/src/ezmd/render/injection.py +355 -0
  45. ezmd-0.1.0rc1/src/ezmd/render/injection_patterns.py +106 -0
  46. ezmd-0.1.0rc1/src/ezmd/render/inline.py +150 -0
  47. ezmd-0.1.0rc1/src/ezmd/render/jsonr.py +45 -0
  48. ezmd-0.1.0rc1/src/ezmd/render/links.py +66 -0
  49. ezmd-0.1.0rc1/src/ezmd/render/markdown.py +269 -0
  50. ezmd-0.1.0rc1/src/ezmd/render/plaintext.py +203 -0
  51. ezmd-0.1.0rc1/src/ezmd/render/sidecar.py +149 -0
  52. ezmd-0.1.0rc1/src/ezmd/render/source_types.py +122 -0
  53. ezmd-0.1.0rc1/src/ezmd/render/tables.py +285 -0
  54. ezmd-0.1.0rc1/src/ezmd/render/text.py +146 -0
  55. ezmd-0.1.0rc1/src/ezmd/render/tokens.py +109 -0
  56. ezmd-0.1.0rc1/src/ezmd/render/transcript.py +151 -0
  57. ezmd-0.1.0rc1/src/ezmd/render/units.py +316 -0
  58. ezmd-0.1.0rc1/src/ezmd/testing/__init__.py +1 -0
  59. ezmd-0.1.0rc1/src/ezmd/testing/cli.py +80 -0
  60. ezmd-0.1.0rc1/src/ezmd/testing/fixtures.py +267 -0
  61. ezmd-0.1.0rc1/src/ezmd/testing/pytest_fixtures.py +73 -0
  62. ezmd-0.1.0rc1/src/ezmd/testing/score.py +284 -0
  63. ezmd-0.1.0rc1/src/ezmd/warnings/__init__.py +5 -0
  64. ezmd-0.1.0rc1/src/ezmd/warnings/codes.py +820 -0
  65. ezmd-0.1.0rc1/tests/cli/conftest.py +23 -0
  66. ezmd-0.1.0rc1/tests/cli/test_cli_batch.py +140 -0
  67. ezmd-0.1.0rc1/tests/cli/test_cli_config.py +146 -0
  68. ezmd-0.1.0rc1/tests/cli/test_cli_convert.py +207 -0
  69. ezmd-0.1.0rc1/tests/cli/test_cli_doctor.py +61 -0
  70. ezmd-0.1.0rc1/tests/cli/test_cli_misc.py +90 -0
  71. ezmd-0.1.0rc1/tests/cli/test_cli_remote.py +153 -0
  72. ezmd-0.1.0rc1/tests/cli/test_cli_shadow.py +154 -0
  73. ezmd-0.1.0rc1/tests/conftest.py +11 -0
  74. ezmd-0.1.0rc1/tests/core_factories.py +80 -0
  75. ezmd-0.1.0rc1/tests/render/render_builders.py +184 -0
  76. ezmd-0.1.0rc1/tests/render/test_render_blocks.py +320 -0
  77. ezmd-0.1.0rc1/tests/render/test_render_contract_v11.py +103 -0
  78. ezmd-0.1.0rc1/tests/render/test_render_family_requests.py +430 -0
  79. ezmd-0.1.0rc1/tests/render/test_render_harbor_lane.py +93 -0
  80. ezmd-0.1.0rc1/tests/render/test_render_profiles.py +248 -0
  81. ezmd-0.1.0rc1/tests/render/test_render_rag_agent.py +169 -0
  82. ezmd-0.1.0rc1/tests/render/test_render_structure.py +208 -0
  83. ezmd-0.1.0rc1/tests/render/test_render_tables.py +141 -0
  84. ezmd-0.1.0rc1/tests/test_cli_exit_codes.py +51 -0
  85. ezmd-0.1.0rc1/tests/test_context.py +193 -0
  86. ezmd-0.1.0rc1/tests/test_converter_contract.py +115 -0
  87. ezmd-0.1.0rc1/tests/test_core_requests_p1.py +200 -0
  88. ezmd-0.1.0rc1/tests/test_detect.py +130 -0
  89. ezmd-0.1.0rc1/tests/test_detect_data_formats.py +81 -0
  90. ezmd-0.1.0rc1/tests/test_docs_generated.py +63 -0
  91. ezmd-0.1.0rc1/tests/test_ir.py +205 -0
  92. ezmd-0.1.0rc1/tests/test_ir_v11.py +142 -0
  93. ezmd-0.1.0rc1/tests/test_library.py +109 -0
  94. ezmd-0.1.0rc1/tests/test_logging_licensing_textclean.py +61 -0
  95. ezmd-0.1.0rc1/tests/test_pipeline_cli_options.py +108 -0
  96. ezmd-0.1.0rc1/tests/test_registry.py +224 -0
  97. ezmd-0.1.0rc1/tests/test_sandbox.py +74 -0
  98. ezmd-0.1.0rc1/tests/test_warning_codes.py +27 -0
@@ -0,0 +1,22 @@
1
+ .venv/
2
+ __pycache__/
3
+ *.py[cod]
4
+ .pytest_cache/
5
+ .mypy_cache/
6
+ .ruff_cache/
7
+ .coverage
8
+ coverage.xml
9
+ htmlcov/
10
+ dist/
11
+ build/
12
+ *.egg-info/
13
+ node_modules/
14
+ apps/web/dist/
15
+ site/
16
+ .env
17
+ /data/
18
+ /*.sqlite
19
+ /*.db
20
+ .DS_Store
21
+ playwright-report/
22
+ test-results/
ezmd-0.1.0rc1/PKG-INFO ADDED
@@ -0,0 +1,40 @@
1
+ Metadata-Version: 2.5
2
+ Name: ezmd
3
+ Version: 0.1.0rc1
4
+ Summary: Convert anything to LLM-ready Markdown.
5
+ Project-URL: Homepage, https://github.com/larpey/ezmd
6
+ Project-URL: Documentation, https://github.com/larpey/ezmd/tree/main/docs
7
+ Project-URL: Repository, https://github.com/larpey/ezmd
8
+ Project-URL: Issues, https://github.com/larpey/ezmd/issues
9
+ Project-URL: Changelog, https://github.com/larpey/ezmd/blob/main/CHANGELOG.md
10
+ Author: ezmd contributors
11
+ License-Expression: Apache-2.0
12
+ Requires-Python: >=3.12
13
+ Requires-Dist: ezmd-converters==0.1.0rc1
14
+ Requires-Dist: httpx>=0.28
15
+ Requires-Dist: magika>=0.6
16
+ Requires-Dist: markdown-it-py>=3.0
17
+ Requires-Dist: pydantic>=2.9
18
+ Requires-Dist: python-magic-bin>=0.4.14; sys_platform == 'win32'
19
+ Requires-Dist: python-magic>=0.4.27; sys_platform != 'win32'
20
+ Requires-Dist: pyyaml>=6.0
21
+ Requires-Dist: rapidfuzz>=3.10
22
+ Requires-Dist: rich>=13.9
23
+ Requires-Dist: tiktoken>=0.8
24
+ Requires-Dist: typer>=0.15
25
+ Provides-Extra: 7z
26
+ Requires-Dist: ezmd-converters[7z]; extra == '7z'
27
+ Provides-Extra: all
28
+ Requires-Dist: ezmd-converters[7z,data,docs]; extra == 'all'
29
+ Requires-Dist: ezmd-mcp; extra == 'all'
30
+ Provides-Extra: data
31
+ Requires-Dist: ezmd-converters[data]; extra == 'data'
32
+ Provides-Extra: docs
33
+ Requires-Dist: ezmd-converters[docs]; extra == 'docs'
34
+ Provides-Extra: mcp
35
+ Requires-Dist: ezmd-mcp; extra == 'mcp'
36
+ Provides-Extra: nonfree
37
+ Requires-Dist: ezmd-converters[nonfree]; extra == 'nonfree'
38
+ Description-Content-Type: text/markdown
39
+
40
+ # core
@@ -0,0 +1 @@
1
+ # core
@@ -0,0 +1,53 @@
1
+ [project]
2
+ name = "ezmd"
3
+ version = "0.1.0rc1"
4
+ description = "Convert anything to LLM-ready Markdown."
5
+ readme = "README.md"
6
+ requires-python = ">=3.12"
7
+ license = "Apache-2.0"
8
+ authors = [{ name = "ezmd contributors" }]
9
+ dependencies = [
10
+ "ezmd-converters==0.1.0rc1",
11
+ "pydantic>=2.9",
12
+ "pyyaml>=6.0",
13
+ "tiktoken>=0.8",
14
+ "magika>=0.6",
15
+ "python-magic>=0.4.27; sys_platform != 'win32'",
16
+ "python-magic-bin>=0.4.14; sys_platform == 'win32'",
17
+ "rapidfuzz>=3.10",
18
+ "typer>=0.15",
19
+ "rich>=13.9",
20
+ "httpx>=0.28",
21
+ "markdown-it-py>=3.0",
22
+ ]
23
+
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/larpey/ezmd"
27
+ Documentation = "https://github.com/larpey/ezmd/tree/main/docs"
28
+ Repository = "https://github.com/larpey/ezmd"
29
+ Issues = "https://github.com/larpey/ezmd/issues"
30
+ Changelog = "https://github.com/larpey/ezmd/blob/main/CHANGELOG.md"
31
+ [project.optional-dependencies]
32
+ # docs/spec/part4.md 4.3.3: users install `ezmd[<extra>]`; the converter extras live on ezmd-converters
33
+ # (D-0003), so ezmd forwards them. media, ocr, web and fetch arrive with their phases; server needs ezmd-api,
34
+ # which is not published yet.
35
+ docs = ["ezmd-converters[docs]"]
36
+ data = ["ezmd-converters[data]"]
37
+ 7z = ["ezmd-converters[7z]"]
38
+ mcp = ["ezmd-mcp"]
39
+ all = ["ezmd-converters[docs,data,7z]", "ezmd-mcp"]
40
+ # Forbidden licenses (GPL extract-msg); never part of `all` (docs/spec/part4.md 4.3.3).
41
+ nonfree = ["ezmd-converters[nonfree]"]
42
+
43
+ [project.scripts]
44
+ ezmd = "ezmd.cli:app"
45
+ ezmd-score = "ezmd.testing.cli:score"
46
+ ezmd-golden = "ezmd.testing.cli:golden"
47
+
48
+ [build-system]
49
+ requires = ["hatchling>=1.26"]
50
+ build-backend = "hatchling.build"
51
+
52
+ [tool.hatch.build.targets.wheel]
53
+ packages = ["src/ezmd"]
@@ -0,0 +1,48 @@
1
+ """ezmd: convert anything to LLM-ready Markdown.
2
+
3
+ >>> import ezmd
4
+ >>> result = ezmd.convert("report.pdf", profile="compact")
5
+ >>> print(result.markdown)
6
+
7
+ Names are resolved lazily so `import ezmd` stays fast (no engines, no renderer at import time).
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ __version__ = "0.1.0rc1"
15
+
16
+ __all__ = [
17
+ "Options",
18
+ "Progress",
19
+ "Result",
20
+ "__version__",
21
+ "capabilities",
22
+ "convert",
23
+ "convert_async",
24
+ "convert_many",
25
+ "register_converter",
26
+ "unload_models",
27
+ ]
28
+
29
+ if TYPE_CHECKING:
30
+ from ezmd.library import (
31
+ Options,
32
+ Progress,
33
+ Result,
34
+ capabilities,
35
+ convert,
36
+ convert_async,
37
+ convert_many,
38
+ register_converter,
39
+ unload_models,
40
+ )
41
+
42
+
43
+ def __getattr__(name: str) -> Any:
44
+ if name in __all__ and name != "__version__":
45
+ from ezmd import library
46
+
47
+ return getattr(library, name)
48
+ raise AttributeError(f"module 'ezmd' has no attribute {name!r}")
@@ -0,0 +1,26 @@
1
+ """ezmd.builtin: registers the converters shipped in `ezmd-converters`.
2
+
3
+ Built-in converters live in the separate `ezmd_converters` package (docs/spec/part1.md section 1.3
4
+ layout). They are registered here with source "builtin" rather than through entry points so that
5
+ `ezmd` always resolves them first and a broken third-party plugin cannot shadow them (D-0003).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from typing import TYPE_CHECKING
12
+
13
+ if TYPE_CHECKING:
14
+ from ezmd.registry import ConverterRegistry
15
+
16
+ log = logging.getLogger(__name__)
17
+
18
+
19
+ def register_builtins(reg: ConverterRegistry) -> None:
20
+ try:
21
+ from ezmd_converters import builtin_converters
22
+ except ImportError as e:
23
+ log.warning("ezmd-converters is not installed; no built-in converters: %s", e)
24
+ return
25
+ for conv in builtin_converters():
26
+ reg.register(conv, source="builtin") # Unavailable entries are recorded with their reason
@@ -0,0 +1,27 @@
1
+ """ezmd.chains: default fallback chains per mime type (docs/spec/part1.md section 5.3).
2
+
3
+ Exact keys are matched before wildcard keys (`audio/*`). Each converter family declares the chains for
4
+ the mimes it owns in `ezmd_converters.<family>.CHAINS` (D-0020); `DEFAULT_CHAINS` holds only chains
5
+ that span families or exist without ezmd-converters installed.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+
12
+ log = logging.getLogger(__name__)
13
+
14
+ DEFAULT_CHAINS: dict[str, list[str]] = {}
15
+
16
+
17
+ def default_chains() -> dict[str, list[str]]:
18
+ chains = dict(DEFAULT_CHAINS)
19
+ try:
20
+ from ezmd_converters import builtin_chains
21
+ except ImportError:
22
+ return chains
23
+ for mime, ids in builtin_chains().items():
24
+ if mime in chains:
25
+ log.warning("family chain for %s overrides the core default", mime)
26
+ chains[mime] = ids
27
+ return chains
@@ -0,0 +1,44 @@
1
+ """The `ezmd` command line (docs/spec/part4.md 4.2).
2
+
3
+ Commands: convert, batch, doctor, capabilities, detect, version, serve. Every command is a thin client
4
+ over the library (`ezmd.convert`, `ezmd.capabilities`) or, with `--remote`, the REST API.
5
+
6
+ Exit codes (part4 4.2.2): 0 success; 1 generic failure; 2 bad arguments or missing dependency; 3 partial
7
+ success (a usable result that carries an error-severity warning, D-0017; or a batch with failures under
8
+ --continue-on-error); 4 fetch blocked by platform; 5 input too large; 6 unsupported type; 130 interrupted.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from ezmd.cli.app import app
14
+ from ezmd.cli.exitcodes import (
15
+ EXIT_ARGS,
16
+ EXIT_BLOCKED,
17
+ EXIT_FAIL,
18
+ EXIT_INTERRUPTED,
19
+ EXIT_OK,
20
+ EXIT_PARTIAL,
21
+ EXIT_TOO_LARGE,
22
+ EXIT_UNSUPPORTED,
23
+ )
24
+
25
+ __all__ = [
26
+ "EXIT_ARGS",
27
+ "EXIT_BLOCKED",
28
+ "EXIT_FAIL",
29
+ "EXIT_INTERRUPTED",
30
+ "EXIT_OK",
31
+ "EXIT_PARTIAL",
32
+ "EXIT_TOO_LARGE",
33
+ "EXIT_UNSUPPORTED",
34
+ "app",
35
+ "main",
36
+ ]
37
+
38
+
39
+ def main() -> None: # pragma: no cover
40
+ app()
41
+
42
+
43
+ if __name__ == "__main__": # pragma: no cover
44
+ main()
@@ -0,0 +1,47 @@
1
+ """The typer application: command registration and logging setup (docs/spec/part4.md 4.2)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+
7
+ import typer
8
+
9
+ from ezmd.cli.batch import batch
10
+ from ezmd.cli.convert import convert
11
+ from ezmd.cli.doctor import doctor
12
+ from ezmd.cli.info import capabilities, detect, serve, version
13
+ from ezmd.cli.shadow import shadow_run
14
+
15
+ __all__ = ["app"]
16
+
17
+ app = typer.Typer(
18
+ add_completion=True,
19
+ no_args_is_help=True,
20
+ help="Convert anything to LLM-ready Markdown.",
21
+ context_settings={"help_option_names": ["-h", "--help"]},
22
+ pretty_exceptions_enable=False,
23
+ )
24
+
25
+
26
+ @app.callback()
27
+ def _setup() -> None:
28
+ """Convert anything to LLM-ready Markdown."""
29
+ logger = logging.getLogger("ezmd")
30
+ if not any(getattr(h, "_ezmd_cli", False) for h in logger.handlers):
31
+ from rich.logging import RichHandler
32
+
33
+ from ezmd.cli.output import err_console
34
+
35
+ handler = RichHandler(console=err_console(), show_time=False, show_path=False, level=logging.WARNING)
36
+ handler._ezmd_cli = True # type: ignore[attr-defined]
37
+ logger.addHandler(handler)
38
+
39
+
40
+ app.command()(convert)
41
+ app.command()(batch)
42
+ app.command()(doctor)
43
+ app.command()(capabilities)
44
+ app.command()(detect)
45
+ app.command()(version)
46
+ app.command()(serve)
47
+ app.command(name="shadow-run")(shadow_run)
@@ -0,0 +1,366 @@
1
+ """`ezmd batch`: convert a directory or glob into a mirrored output tree, idempotently.
2
+
3
+ Idempotency: before converting, each input's SHA-256 is compared with the previous run's record, taken
4
+ from the previous manifest line for that input (`sha256`, plus matching profile and format) or, failing
5
+ that, from the output's sidecar (`frontmatter.source_hash` and `profile`). A match with the output still
6
+ present is reported as `skipped`. Conversion goes through `ezmd.convert`, in-process for one worker and
7
+ in a process pool otherwise.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import glob as globlib
13
+ import hashlib
14
+ import json
15
+ import multiprocessing
16
+ import os
17
+ import time
18
+ from concurrent.futures import FIRST_COMPLETED, Future, ProcessPoolExecutor, wait
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Annotated, Any
22
+
23
+ import typer
24
+
25
+ from ezmd.cli.config import SUPPORTED_FORMATS, ConfigError, check_format, check_profile
26
+ from ezmd.cli.convert import build_options, emit_json, fail, load_config_or_exit
27
+ from ezmd.cli.exitcodes import EXIT_FAIL, EXIT_INTERRUPTED, EXIT_OK, EXIT_PARTIAL, classify
28
+ from ezmd.cli.output import EXTENSIONS, err_console, outcome_from_result, sidecar_path, write_outcome
29
+
30
+ __all__ = ["Job", "batch", "convert_job", "default_workers", "plan"]
31
+
32
+ _GLOB_CHARS = set("*?[")
33
+ _HASH_CHUNK = 1024 * 1024
34
+
35
+
36
+ def default_workers() -> int:
37
+ """CPU count minus one, at least 1, at most 8 (Part 4 4.2.2)."""
38
+ return max(1, min((os.cpu_count() or 2) - 1, 8))
39
+
40
+
41
+ @dataclass(frozen=True, slots=True)
42
+ class Job:
43
+ """One input to convert. Plain data so it pickles into worker processes."""
44
+
45
+ src: str
46
+ out: str
47
+ profile: str
48
+ format: str
49
+ options: dict[str, object]
50
+ sidecar: bool
51
+ sha256: str
52
+
53
+
54
+ def file_sha256(path: Path) -> str:
55
+ h = hashlib.sha256()
56
+ with path.open("rb") as f:
57
+ while chunk := f.read(_HASH_CHUNK):
58
+ h.update(chunk)
59
+ return h.hexdigest()
60
+
61
+
62
+ def _hidden(rel: Path) -> bool:
63
+ return any(part.startswith(".") for part in rel.parts)
64
+
65
+
66
+ def _within(path: Path, root: Path) -> bool:
67
+ try:
68
+ path.relative_to(root)
69
+ except ValueError:
70
+ return False
71
+ return True
72
+
73
+
74
+ def discover(
75
+ target: str, recursive: bool, out_dir: Path, manifest: Path, exclude: frozenset[str] = frozenset()
76
+ ) -> tuple[Path, list[Path]]:
77
+ """Input files (sorted) and the base directory the output tree mirrors. Dotfiles, the output tree,
78
+ the manifest, sidecar files, and `exclude` (previous outputs, as resolved posix paths) are never inputs."""
79
+ out_root = out_dir.resolve()
80
+ if any(c in target for c in _GLOB_CHARS):
81
+ parts = Path(target).parts
82
+ fixed = list(parts[: next(i for i, p in enumerate(parts) if any(c in p for c in _GLOB_CHARS))])
83
+ base = Path(*fixed) if fixed else Path(".")
84
+ found = [Path(p) for p in globlib.glob(target, recursive=recursive)]
85
+ else:
86
+ root = Path(target)
87
+ if root.is_file():
88
+ return root.parent, [root]
89
+ if not root.is_dir():
90
+ raise FileNotFoundError(target)
91
+ base = root
92
+ found = list(root.rglob("*") if recursive else root.iterdir())
93
+ files: list[Path] = []
94
+ for p in found:
95
+ if not p.is_file():
96
+ continue
97
+ resolved = p.resolve()
98
+ rel = resolved.relative_to(base.resolve()) if _within(resolved, base.resolve()) else Path(p.name)
99
+ if _hidden(rel) or resolved == manifest.resolve() or p.name.endswith(".ezmd.json"):
100
+ continue
101
+ if resolved.as_posix() in exclude:
102
+ continue
103
+ if _within(resolved, out_root) and out_root != base.resolve():
104
+ continue
105
+ files.append(p)
106
+ return base, sorted(files, key=lambda q: q.as_posix())
107
+
108
+
109
+ def _key(path: Path | str) -> str:
110
+ return Path(path).resolve().as_posix()
111
+
112
+
113
+ def read_manifest(path: Path) -> dict[str, dict[str, Any]]:
114
+ """Previous manifest lines keyed by resolved input path. A corrupt line is ignored."""
115
+ prev: dict[str, dict[str, Any]] = {}
116
+ try:
117
+ lines = path.read_text(encoding="utf-8").splitlines()
118
+ except OSError:
119
+ return prev
120
+ for line in lines:
121
+ try:
122
+ rec = json.loads(line)
123
+ except ValueError:
124
+ rec = None
125
+ if isinstance(rec, dict) and isinstance(rec.get("path"), str):
126
+ prev[_key(rec["path"])] = rec
127
+ return prev
128
+
129
+
130
+ def _sidecar_matches(target: Path, sha: str, profile: str) -> bool:
131
+ side = sidecar_path(target)
132
+ if not side.is_file():
133
+ return False
134
+ try:
135
+ data = json.loads(side.read_text(encoding="utf-8"))
136
+ except (OSError, ValueError):
137
+ return False
138
+ front = data.get("frontmatter") if isinstance(data, dict) else None
139
+ if not isinstance(front, dict):
140
+ return False
141
+ return front.get("source_hash") == f"sha256:{sha}" and data.get("profile") == profile
142
+
143
+
144
+ def unchanged(job: Job, prev: dict[str, Any] | None) -> bool:
145
+ target = Path(job.out)
146
+ if not target.is_file():
147
+ return False
148
+ if (
149
+ prev is not None
150
+ and prev.get("sha256") == job.sha256
151
+ and prev.get("profile") == job.profile
152
+ and prev.get("format") == job.format
153
+ and prev.get("status") in ("converted", "partial", "skipped")
154
+ ):
155
+ return True
156
+ return job.format != "json" and _sidecar_matches(target, job.sha256, job.profile)
157
+
158
+
159
+ def plan(base: Path, files: list[Path], out_dir: Path, fmt: str) -> list[tuple[Path, Path]]:
160
+ """(input, output) pairs mirroring the input tree. Inputs whose stems collide in one directory
161
+ (a.txt and a.md) keep their full name (a.txt.md, a.md.md) so no output overwrites another."""
162
+ ext = EXTENSIONS[fmt]
163
+ rels = []
164
+ for f in files:
165
+ resolved = f.resolve()
166
+ b = base.resolve()
167
+ rels.append(resolved.relative_to(b) if _within(resolved, b) else Path(f.name))
168
+ stems: dict[Path, int] = {}
169
+ for r in rels:
170
+ stems[r.parent / r.stem] = stems.get(r.parent / r.stem, 0) + 1
171
+ pairs = []
172
+ for f, r in zip(files, rels, strict=True):
173
+ name = r.stem if stems[r.parent / r.stem] == 1 else r.name
174
+ pairs.append((f, out_dir / r.parent / f"{name}{ext}"))
175
+ return pairs
176
+
177
+
178
+ def convert_job(job: Job) -> dict[str, Any]:
179
+ """Convert one input and write it; never raises (runs in worker processes). Returns a manifest record."""
180
+ import ezmd.library as lib
181
+
182
+ start = time.perf_counter()
183
+ record: dict[str, Any] = {
184
+ "path": job.src,
185
+ "out": job.out,
186
+ "sha256": job.sha256,
187
+ "profile": job.profile,
188
+ "format": job.format,
189
+ }
190
+ try:
191
+ result = lib.convert(job.src, profile=job.profile, options=lib.Options(**job.options))
192
+ outcome = outcome_from_result(result, source=job.src, profile=job.profile, fmt=job.format)
193
+ write_outcome(outcome, Path(job.out), sidecar=job.sidecar)
194
+ record.update(
195
+ status="partial" if outcome.status == "partial" else "converted",
196
+ warnings=outcome.warnings,
197
+ tokens=outcome.tokens,
198
+ )
199
+ except Exception as e:
200
+ f = classify(e)
201
+ record.update(
202
+ status="failed",
203
+ out=None,
204
+ warnings=[{"kind": f.warning_kind, "severity": "error", "message": f.message}],
205
+ tokens=0,
206
+ error={"code": f.code, "message": f.message, "exit_code": f.exit_code},
207
+ )
208
+ record["seconds"] = round(time.perf_counter() - start, 3)
209
+ return record
210
+
211
+
212
+ class _Run:
213
+ """Collects records, streams them to the manifest, and drives the progress bar."""
214
+
215
+ def __init__(self, manifest: Path, show: bool, total: int) -> None:
216
+ manifest.parent.mkdir(parents=True, exist_ok=True)
217
+ self.fh = manifest.open("w", encoding="utf-8", newline="\n")
218
+ self.records: list[dict[str, Any]] = []
219
+ self.bar: Any = None
220
+ self.task: Any = None
221
+ if show and total and err_console().is_terminal:
222
+ from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
223
+
224
+ cols = (TextColumn("Converting"), BarColumn(), MofNCompleteColumn(), TimeElapsedColumn())
225
+ self.bar = Progress(*cols, console=err_console(), transient=True)
226
+ self.bar.start()
227
+ self.task = self.bar.add_task("batch", total=total)
228
+
229
+ def add(self, rec: dict[str, Any], advance: bool = True) -> None:
230
+ self.records.append(rec)
231
+ self.fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
232
+ self.fh.flush()
233
+ if advance and self.bar is not None:
234
+ self.bar.advance(self.task)
235
+
236
+ def close(self) -> None:
237
+ if self.bar is not None:
238
+ self.bar.stop()
239
+ self.fh.close()
240
+
241
+
242
+ def _execute(jobs: list[Job], workers: int, keep_going: bool, run: _Run) -> bool:
243
+ """Run jobs; returns False when stopped early by a failure (without --continue-on-error)."""
244
+ if workers <= 1 or len(jobs) <= 1:
245
+ for i, job in enumerate(jobs):
246
+ rec = convert_job(job)
247
+ run.add(rec)
248
+ if rec["status"] == "failed" and not keep_going:
249
+ for rest in jobs[i + 1 :]:
250
+ run.add(_cancelled(rest), advance=False)
251
+ return False
252
+ return True
253
+ # spawn, not fork: forking a process that already runs threads (Magika, tokenizers) can deadlock
254
+ with ProcessPoolExecutor(max_workers=workers, mp_context=multiprocessing.get_context("spawn")) as pool:
255
+ pending: dict[Future[dict[str, Any]], Job] = {pool.submit(convert_job, j): j for j in jobs}
256
+ stopped = False
257
+ while pending:
258
+ done, _ = wait(pending, return_when=FIRST_COMPLETED)
259
+ for fut in done:
260
+ job = pending.pop(fut)
261
+ if fut.cancelled():
262
+ run.add(_cancelled(job), advance=False)
263
+ continue
264
+ rec = fut.result()
265
+ run.add(rec)
266
+ if rec["status"] == "failed" and not keep_going and not stopped:
267
+ stopped = True
268
+ for other in pending:
269
+ other.cancel()
270
+ return not stopped
271
+
272
+
273
+ def _cancelled(job: Job) -> dict[str, Any]:
274
+ return {"path": job.src, "status": "cancelled", "out": None, "sha256": job.sha256, "profile": job.profile,
275
+ "format": job.format, "warnings": [], "seconds": 0.0, "tokens": 0} # fmt: skip
276
+
277
+
278
+ def _summary(records: list[dict[str, Any]]) -> dict[str, int]:
279
+ counts = {"converted": 0, "skipped": 0, "failed": 0, "cancelled": 0}
280
+ for r in records:
281
+ status = "converted" if r["status"] == "partial" else str(r["status"])
282
+ counts[status] = counts.get(status, 0) + 1
283
+ counts["total_tokens"] = sum(int(r.get("tokens") or 0) for r in records)
284
+ counts["total"] = len(records)
285
+ return counts
286
+
287
+
288
+ def _print_table(summary: dict[str, int], records: list[dict[str, Any]], manifest: Path) -> None:
289
+ from rich.table import Table
290
+
291
+ con = err_console()
292
+ for r in records:
293
+ if r["status"] == "failed":
294
+ con.print(f"FAIL {r['path']}: {r['error']['message']}", markup=False, highlight=False)
295
+ t = Table("converted", "skipped", "failed", "total tokens", title="ezmd batch")
296
+ t.add_row(*(str(summary[k]) for k in ("converted", "skipped", "failed", "total_tokens")))
297
+ con.print(t)
298
+ con.print(f"manifest: {manifest}", markup=False, highlight=False)
299
+
300
+
301
+ def batch(
302
+ inputs: Annotated[str, typer.Argument(help="Directory or glob (quote globs).")],
303
+ out: Annotated[Path | None, typer.Option("--out", "-o", help="Output directory (mirrors the input tree).")] = None,
304
+ recursive: Annotated[bool, typer.Option("--recursive", "-r", help="Descend into subdirectories.")] = False,
305
+ workers: Annotated[int | None, typer.Option("--workers", "-w", min=1, help="Worker processes.")] = None,
306
+ profile: Annotated[str | None, typer.Option("--profile", "-p", help="full | compact | rag | agent")] = None,
307
+ fmt: Annotated[str | None, typer.Option("--format", "-f", help="md | txt | json")] = None,
308
+ keep_going: Annotated[bool, typer.Option("--continue-on-error", help="Keep going after a failure.")] = False,
309
+ manifest: Annotated[Path | None, typer.Option("--manifest", help="[default: <out>/manifest.jsonl]")] = None,
310
+ sidecar: Annotated[bool | None, typer.Option("--sidecar/--no-sidecar", help="Write sidecars.")] = None,
311
+ opt: Annotated[list[str] | None, typer.Option("--opt", help="key=value converter or profile option.")] = None,
312
+ as_json: Annotated[bool, typer.Option("--json", help="Summary JSON on stdout.")] = False,
313
+ quiet: Annotated[bool, typer.Option("--quiet", "-q", help="No progress or table on stderr.")] = False,
314
+ ) -> None:
315
+ """Convert many files; unchanged inputs are skipped on re-runs."""
316
+ cfg = load_config_or_exit(as_json=as_json, quiet=quiet)
317
+ try:
318
+ chosen_fmt = check_format(fmt or cfg.format, "--format")
319
+ if chosen_fmt not in SUPPORTED_FORMATS:
320
+ raise ConfigError(f"--format {chosen_fmt} is not yet supported; use md, txt, or json")
321
+ chosen_profile = check_profile(profile or cfg.profile or "full", "--profile")
322
+ options = build_options(cfg, opt or [], None, None)
323
+ import ezmd.library as lib
324
+
325
+ lib.Options(**options) # validate once, before any work
326
+ out_dir = out if out is not None else (Path(cfg.out_dir) if cfg.out_dir else None)
327
+ if out_dir is None:
328
+ raise ValueError("--out is required (or set defaults.out_dir in the config file)")
329
+ manifest_path = manifest or out_dir / "manifest.jsonl"
330
+ prev = read_manifest(manifest_path)
331
+ outputs = frozenset(_key(r["out"]) for r in prev.values() if isinstance(r.get("out"), str))
332
+ base, files = discover(inputs, recursive, out_dir, manifest_path, outputs)
333
+ except FileNotFoundError as e:
334
+ raise fail(f"no such file or directory: {inputs}", as_json=as_json, source=inputs) from e
335
+ except ValueError as e:
336
+ raise fail(str(e), as_json=as_json, source=inputs) from e
337
+ write_sidecar = cfg.sidecar if sidecar is None else sidecar
338
+ run = _Run(manifest_path, show=not quiet and not as_json, total=len(files))
339
+ finished = True
340
+ try:
341
+ todo: list[Job] = []
342
+ for src, target in plan(base, files, out_dir, chosen_fmt):
343
+ job = Job(str(src), str(target), chosen_profile, chosen_fmt, options, write_sidecar, file_sha256(src))
344
+ old = prev.get(_key(src))
345
+ if unchanged(job, old):
346
+ rec = {"path": job.src, "status": "skipped", "out": job.out, "sha256": job.sha256,
347
+ "profile": job.profile, "format": job.format,
348
+ "warnings": (old or {}).get("warnings", []), "seconds": 0.0,
349
+ "tokens": int((old or {}).get("tokens") or 0)} # fmt: skip
350
+ run.add(rec)
351
+ else:
352
+ todo.append(job)
353
+ finished = _execute(todo, workers or default_workers(), keep_going, run)
354
+ except KeyboardInterrupt:
355
+ run.close()
356
+ err_console().print("interrupted", markup=False)
357
+ raise typer.Exit(EXIT_INTERRUPTED) from None
358
+ run.close()
359
+ summary = _summary(run.records)
360
+ failed = summary["failed"] > 0
361
+ code = EXIT_OK if not failed else (EXIT_PARTIAL if keep_going and finished else EXIT_FAIL)
362
+ if as_json:
363
+ emit_json({**summary, "exit_code": code, "manifest": str(manifest_path), "files": run.records})
364
+ elif not quiet:
365
+ _print_table(summary, run.records, manifest_path)
366
+ raise typer.Exit(code)