ezmd 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ezmd/__init__.py +48 -0
- ezmd/builtin.py +26 -0
- ezmd/chains.py +27 -0
- ezmd/cli/__init__.py +44 -0
- ezmd/cli/app.py +74 -0
- ezmd/cli/batch.py +393 -0
- ezmd/cli/config.py +214 -0
- ezmd/cli/convert.py +207 -0
- ezmd/cli/doctor.py +367 -0
- ezmd/cli/exitcodes.py +113 -0
- ezmd/cli/info.py +184 -0
- ezmd/cli/options.py +120 -0
- ezmd/cli/output.py +203 -0
- ezmd/cli/remote.py +223 -0
- ezmd/cli/shadow.py +414 -0
- ezmd/context.py +170 -0
- ezmd/core/__init__.py +1 -0
- ezmd/core/licensing.py +76 -0
- ezmd/core/logging.py +88 -0
- ezmd/core/netguard.py +717 -0
- ezmd/core/sandbox.py +291 -0
- ezmd/core/textclean.py +45 -0
- ezmd/detect.py +511 -0
- ezmd/inputs.py +205 -0
- ezmd/ir.py +928 -0
- ezmd/library.py +346 -0
- ezmd/pipeline.py +83 -0
- ezmd/profiles.py +285 -0
- ezmd/py.typed +0 -0
- ezmd/registry.py +563 -0
- ezmd/render/__init__.py +81 -0
- ezmd/render/assemble.py +299 -0
- ezmd/render/base.py +90 -0
- ezmd/render/blocks.py +358 -0
- ezmd/render/children.py +266 -0
- ezmd/render/chunker.py +261 -0
- ezmd/render/context.py +178 -0
- ezmd/render/frontmatter.py +377 -0
- ezmd/render/headings.py +149 -0
- ezmd/render/injection.py +364 -0
- ezmd/render/injection_patterns.py +106 -0
- ezmd/render/inline.py +154 -0
- ezmd/render/jsonr.py +45 -0
- ezmd/render/links.py +89 -0
- ezmd/render/markdown.py +274 -0
- ezmd/render/plaintext.py +203 -0
- ezmd/render/sidecar.py +149 -0
- ezmd/render/source_types.py +122 -0
- ezmd/render/tables.py +285 -0
- ezmd/render/text.py +146 -0
- ezmd/render/tokens.py +109 -0
- ezmd/render/transcript.py +151 -0
- ezmd/render/units.py +316 -0
- ezmd/testing/__init__.py +1 -0
- ezmd/testing/cli.py +80 -0
- ezmd/testing/fixtures.py +435 -0
- ezmd/testing/invariants.py +233 -0
- ezmd/testing/pytest_fixtures.py +73 -0
- ezmd/testing/score.py +286 -0
- ezmd/warnings/__init__.py +5 -0
- ezmd/warnings/codes.py +820 -0
- ezmd-0.1.0.dist-info/METADATA +78 -0
- ezmd-0.1.0.dist-info/RECORD +67 -0
- ezmd-0.1.0.dist-info/WHEEL +4 -0
- ezmd-0.1.0.dist-info/entry_points.txt +4 -0
- ezmd-0.1.0.dist-info/licenses/LICENSE +202 -0
- ezmd-0.1.0.dist-info/licenses/NOTICE +5 -0
ezmd/__init__.py
ADDED
|
@@ -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.0"
|
|
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}")
|
ezmd/builtin.py
ADDED
|
@@ -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/internal/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
|
ezmd/chains.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""ezmd.chains: default fallback chains per mime type (docs/internal/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
|
ezmd/cli/__init__.py
ADDED
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""The `ezmd` command line (docs/internal/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()
|
ezmd/cli/app.py
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""The typer application: command registration and logging setup (docs/internal/spec/part4.md 4.2)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Annotated
|
|
8
|
+
|
|
9
|
+
import typer
|
|
10
|
+
|
|
11
|
+
from ezmd.cli.batch import batch
|
|
12
|
+
from ezmd.cli.convert import convert
|
|
13
|
+
from ezmd.cli.doctor import doctor
|
|
14
|
+
from ezmd.cli.info import capabilities, detect, serve, version
|
|
15
|
+
from ezmd.cli.shadow import shadow_run
|
|
16
|
+
|
|
17
|
+
__all__ = ["app"]
|
|
18
|
+
|
|
19
|
+
app = typer.Typer(
|
|
20
|
+
add_completion=True,
|
|
21
|
+
no_args_is_help=True,
|
|
22
|
+
help="Convert anything to LLM-ready Markdown.",
|
|
23
|
+
context_settings={"help_option_names": ["-h", "--help"]},
|
|
24
|
+
pretty_exceptions_enable=False,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _utf8_streams() -> None:
|
|
29
|
+
"""Markdown output is UTF-8 everywhere. A Windows pipe or redirect gets the legacy code page (cp1252) as
|
|
30
|
+
the stream encoding, which cannot encode most of what documents contain, so switch the standard streams to
|
|
31
|
+
UTF-8 before any command writes. Consoles keep working: Python writes to them through the wide-char API."""
|
|
32
|
+
for stream in (sys.stdout, sys.stderr):
|
|
33
|
+
encoding = (getattr(stream, "encoding", None) or "").lower().replace("-", "")
|
|
34
|
+
reconfigure = getattr(stream, "reconfigure", None)
|
|
35
|
+
if encoding != "utf8" and callable(reconfigure):
|
|
36
|
+
reconfigure(encoding="utf-8")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _print_version(value: bool) -> None:
|
|
40
|
+
"""`ezmd --version` / `-V`: the same line as `ezmd version`, then exit."""
|
|
41
|
+
if value:
|
|
42
|
+
_utf8_streams()
|
|
43
|
+
version()
|
|
44
|
+
raise typer.Exit()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@app.callback()
|
|
48
|
+
def _setup(
|
|
49
|
+
_version: Annotated[
|
|
50
|
+
bool,
|
|
51
|
+
typer.Option("--version", "-V", callback=_print_version, is_eager=True, help="Print the version and exit."),
|
|
52
|
+
] = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Convert anything to LLM-ready Markdown."""
|
|
55
|
+
_utf8_streams()
|
|
56
|
+
logger = logging.getLogger("ezmd")
|
|
57
|
+
if not any(getattr(h, "_ezmd_cli", False) for h in logger.handlers):
|
|
58
|
+
from rich.logging import RichHandler
|
|
59
|
+
|
|
60
|
+
from ezmd.cli.output import err_console
|
|
61
|
+
|
|
62
|
+
handler = RichHandler(console=err_console(), show_time=False, show_path=False, level=logging.WARNING)
|
|
63
|
+
handler._ezmd_cli = True # type: ignore[attr-defined]
|
|
64
|
+
logger.addHandler(handler)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
app.command()(convert)
|
|
68
|
+
app.command()(batch)
|
|
69
|
+
app.command()(doctor)
|
|
70
|
+
app.command()(capabilities)
|
|
71
|
+
app.command()(detect)
|
|
72
|
+
app.command()(version)
|
|
73
|
+
app.command()(serve)
|
|
74
|
+
app.command(name="shadow-run")(shadow_run)
|
ezmd/cli/batch.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
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 (
|
|
29
|
+
EXTENSIONS,
|
|
30
|
+
err_console,
|
|
31
|
+
outcome_from_result,
|
|
32
|
+
sidecar_path,
|
|
33
|
+
silence_progress_bars,
|
|
34
|
+
write_outcome,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
__all__ = ["Job", "batch", "convert_job", "default_workers", "plan"]
|
|
38
|
+
|
|
39
|
+
_GLOB_CHARS = set("*?[")
|
|
40
|
+
_HASH_CHUNK = 1024 * 1024
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def default_workers() -> int:
|
|
44
|
+
"""CPU count minus one, at least 1, at most 8 (Part 4 4.2.2)."""
|
|
45
|
+
return max(1, min((os.cpu_count() or 2) - 1, 8))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class Job:
|
|
50
|
+
"""One input to convert. Plain data so it pickles into worker processes."""
|
|
51
|
+
|
|
52
|
+
src: str
|
|
53
|
+
out: str
|
|
54
|
+
profile: str
|
|
55
|
+
format: str
|
|
56
|
+
options: dict[str, object]
|
|
57
|
+
sidecar: bool
|
|
58
|
+
sha256: str
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def file_sha256(path: Path) -> str:
|
|
62
|
+
h = hashlib.sha256()
|
|
63
|
+
with path.open("rb") as f:
|
|
64
|
+
while chunk := f.read(_HASH_CHUNK):
|
|
65
|
+
h.update(chunk)
|
|
66
|
+
return h.hexdigest()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _hidden(rel: Path) -> bool:
|
|
70
|
+
return any(part.startswith(".") for part in rel.parts)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _within(path: Path, root: Path) -> bool:
|
|
74
|
+
try:
|
|
75
|
+
path.relative_to(root)
|
|
76
|
+
except ValueError:
|
|
77
|
+
return False
|
|
78
|
+
return True
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def discover(
|
|
82
|
+
target: str, recursive: bool, out_dir: Path, manifest: Path, exclude: frozenset[str] = frozenset()
|
|
83
|
+
) -> tuple[Path, list[Path]]:
|
|
84
|
+
"""Input files (sorted) and the base directory the output tree mirrors. Dotfiles, the output tree,
|
|
85
|
+
the manifest, sidecar files, and `exclude` (previous outputs, as resolved posix paths) are never inputs."""
|
|
86
|
+
out_root = out_dir.resolve()
|
|
87
|
+
if any(c in target for c in _GLOB_CHARS):
|
|
88
|
+
parts = Path(target).parts
|
|
89
|
+
fixed = list(parts[: next(i for i, p in enumerate(parts) if any(c in p for c in _GLOB_CHARS))])
|
|
90
|
+
base = Path(*fixed) if fixed else Path(".")
|
|
91
|
+
found = [Path(p) for p in globlib.glob(target, recursive=recursive)]
|
|
92
|
+
else:
|
|
93
|
+
root = Path(target)
|
|
94
|
+
if root.is_file():
|
|
95
|
+
return root.parent, [root]
|
|
96
|
+
if not root.is_dir():
|
|
97
|
+
raise FileNotFoundError(target)
|
|
98
|
+
base = root
|
|
99
|
+
found = list(root.rglob("*") if recursive else root.iterdir())
|
|
100
|
+
files: list[Path] = []
|
|
101
|
+
for p in found:
|
|
102
|
+
if not p.is_file():
|
|
103
|
+
continue
|
|
104
|
+
resolved = p.resolve()
|
|
105
|
+
rel = resolved.relative_to(base.resolve()) if _within(resolved, base.resolve()) else Path(p.name)
|
|
106
|
+
if _hidden(rel) or resolved == manifest.resolve() or p.name.endswith(".ezmd.json"):
|
|
107
|
+
continue
|
|
108
|
+
if resolved.as_posix() in exclude:
|
|
109
|
+
continue
|
|
110
|
+
if _within(resolved, out_root) and out_root != base.resolve():
|
|
111
|
+
continue
|
|
112
|
+
files.append(p)
|
|
113
|
+
return base, sorted(files, key=lambda q: q.as_posix())
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _key(path: Path | str) -> str:
|
|
117
|
+
return Path(path).resolve().as_posix()
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def read_manifest(path: Path) -> dict[str, dict[str, Any]]:
|
|
121
|
+
"""Previous manifest lines keyed by resolved input path. A corrupt line is ignored."""
|
|
122
|
+
prev: dict[str, dict[str, Any]] = {}
|
|
123
|
+
try:
|
|
124
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
125
|
+
except OSError:
|
|
126
|
+
return prev
|
|
127
|
+
for line in lines:
|
|
128
|
+
try:
|
|
129
|
+
rec = json.loads(line)
|
|
130
|
+
except ValueError:
|
|
131
|
+
rec = None
|
|
132
|
+
if isinstance(rec, dict) and isinstance(rec.get("path"), str):
|
|
133
|
+
prev[_key(rec["path"])] = rec
|
|
134
|
+
return prev
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _sidecar_matches(target: Path, sha: str, profile: str) -> bool:
|
|
138
|
+
side = sidecar_path(target)
|
|
139
|
+
if not side.is_file():
|
|
140
|
+
return False
|
|
141
|
+
try:
|
|
142
|
+
data = json.loads(side.read_text(encoding="utf-8"))
|
|
143
|
+
except (OSError, ValueError):
|
|
144
|
+
return False
|
|
145
|
+
front = data.get("frontmatter") if isinstance(data, dict) else None
|
|
146
|
+
if not isinstance(front, dict):
|
|
147
|
+
return False
|
|
148
|
+
return front.get("source_hash") == f"sha256:{sha}" and data.get("profile") == profile
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def unchanged(job: Job, prev: dict[str, Any] | None) -> bool:
|
|
152
|
+
target = Path(job.out)
|
|
153
|
+
if not target.is_file():
|
|
154
|
+
return False
|
|
155
|
+
if (
|
|
156
|
+
prev is not None
|
|
157
|
+
and prev.get("sha256") == job.sha256
|
|
158
|
+
and prev.get("profile") == job.profile
|
|
159
|
+
and prev.get("format") == job.format
|
|
160
|
+
and prev.get("status") in ("converted", "partial", "skipped")
|
|
161
|
+
):
|
|
162
|
+
return True
|
|
163
|
+
return job.format != "json" and _sidecar_matches(target, job.sha256, job.profile)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def plan(base: Path, files: list[Path], out_dir: Path, fmt: str) -> list[tuple[Path, Path]]:
|
|
167
|
+
"""(input, output) pairs mirroring the input tree. Inputs whose stems collide in one directory
|
|
168
|
+
(a.txt and a.md) keep their full name (a.txt.md, a.md.md) so no output overwrites another."""
|
|
169
|
+
ext = EXTENSIONS[fmt]
|
|
170
|
+
rels = []
|
|
171
|
+
for f in files:
|
|
172
|
+
resolved = f.resolve()
|
|
173
|
+
b = base.resolve()
|
|
174
|
+
rels.append(resolved.relative_to(b) if _within(resolved, b) else Path(f.name))
|
|
175
|
+
stems: dict[Path, int] = {}
|
|
176
|
+
for r in rels:
|
|
177
|
+
stems[r.parent / r.stem] = stems.get(r.parent / r.stem, 0) + 1
|
|
178
|
+
pairs = []
|
|
179
|
+
for f, r in zip(files, rels, strict=True):
|
|
180
|
+
name = r.stem if stems[r.parent / r.stem] == 1 else r.name
|
|
181
|
+
pairs.append((f, out_dir / r.parent / f"{name}{ext}"))
|
|
182
|
+
return pairs
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def convert_job(job: Job) -> dict[str, Any]:
|
|
186
|
+
"""Convert one input and write it; never raises (runs in worker processes). Returns a manifest record."""
|
|
187
|
+
import ezmd.library as lib
|
|
188
|
+
|
|
189
|
+
start = time.perf_counter()
|
|
190
|
+
record: dict[str, Any] = {
|
|
191
|
+
"path": job.src,
|
|
192
|
+
"out": job.out,
|
|
193
|
+
"sha256": job.sha256,
|
|
194
|
+
"profile": job.profile,
|
|
195
|
+
"format": job.format,
|
|
196
|
+
}
|
|
197
|
+
try:
|
|
198
|
+
result = lib.convert(job.src, profile=job.profile, options=lib.Options(**job.options))
|
|
199
|
+
outcome = outcome_from_result(result, source=job.src, profile=job.profile, fmt=job.format)
|
|
200
|
+
write_outcome(outcome, Path(job.out), sidecar=job.sidecar)
|
|
201
|
+
record.update(
|
|
202
|
+
status="partial" if outcome.status == "partial" else "converted",
|
|
203
|
+
warnings=outcome.warnings,
|
|
204
|
+
tokens=outcome.tokens,
|
|
205
|
+
)
|
|
206
|
+
except Exception as e:
|
|
207
|
+
f = classify(e)
|
|
208
|
+
record.update(
|
|
209
|
+
status="failed",
|
|
210
|
+
out=None,
|
|
211
|
+
warnings=[{"kind": f.warning_kind, "severity": "error", "message": f.message}],
|
|
212
|
+
tokens=0,
|
|
213
|
+
error={"code": f.code, "message": f.message, "exit_code": f.exit_code},
|
|
214
|
+
)
|
|
215
|
+
record["seconds"] = round(time.perf_counter() - start, 3)
|
|
216
|
+
return record
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class _Run:
|
|
220
|
+
"""Collects records, streams them to the manifest, and drives the progress bar."""
|
|
221
|
+
|
|
222
|
+
def __init__(self, manifest: Path, show: bool, total: int) -> None:
|
|
223
|
+
manifest.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
self.fh = manifest.open("w", encoding="utf-8", newline="\n")
|
|
225
|
+
self.records: list[dict[str, Any]] = []
|
|
226
|
+
self.bar: Any = None
|
|
227
|
+
self.task: Any = None
|
|
228
|
+
if show and total and err_console().is_terminal:
|
|
229
|
+
from rich.progress import BarColumn, MofNCompleteColumn, Progress, TextColumn, TimeElapsedColumn
|
|
230
|
+
|
|
231
|
+
cols = (TextColumn("Converting"), BarColumn(), MofNCompleteColumn(), TimeElapsedColumn())
|
|
232
|
+
self.bar = Progress(*cols, console=err_console(), transient=True)
|
|
233
|
+
self.bar.start()
|
|
234
|
+
self.task = self.bar.add_task("batch", total=total)
|
|
235
|
+
|
|
236
|
+
def add(self, rec: dict[str, Any], advance: bool = True) -> None:
|
|
237
|
+
self.records.append(rec)
|
|
238
|
+
self.fh.write(json.dumps(rec, ensure_ascii=False) + "\n")
|
|
239
|
+
self.fh.flush()
|
|
240
|
+
if advance and self.bar is not None:
|
|
241
|
+
self.bar.advance(self.task)
|
|
242
|
+
|
|
243
|
+
def close(self) -> None:
|
|
244
|
+
if self.bar is not None:
|
|
245
|
+
self.bar.stop()
|
|
246
|
+
self.fh.close()
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _execute(jobs: list[Job], workers: int, keep_going: bool, run: _Run) -> bool:
|
|
250
|
+
"""Run jobs; returns False when stopped early by a failure (without --continue-on-error)."""
|
|
251
|
+
if workers <= 1 or len(jobs) <= 1:
|
|
252
|
+
for i, job in enumerate(jobs):
|
|
253
|
+
rec = convert_job(job)
|
|
254
|
+
run.add(rec)
|
|
255
|
+
if rec["status"] == "failed" and not keep_going:
|
|
256
|
+
for rest in jobs[i + 1 :]:
|
|
257
|
+
run.add(_cancelled(rest), advance=False)
|
|
258
|
+
return False
|
|
259
|
+
return True
|
|
260
|
+
# spawn, not fork: forking a process that already runs threads (Magika, tokenizers) can deadlock.
|
|
261
|
+
# At most `workers` jobs are in flight: a job handed to the pool cannot be taken back once a worker (or
|
|
262
|
+
# the pool's call queue) has it, so submitting everything up front kept converting after a failure.
|
|
263
|
+
queue = iter(jobs)
|
|
264
|
+
stopped = False
|
|
265
|
+
with ProcessPoolExecutor(max_workers=workers, mp_context=multiprocessing.get_context("spawn")) as pool:
|
|
266
|
+
pending: dict[Future[dict[str, Any]], Job] = {}
|
|
267
|
+
|
|
268
|
+
def fill() -> None:
|
|
269
|
+
while not stopped and len(pending) < workers:
|
|
270
|
+
job = next(queue, None)
|
|
271
|
+
if job is None:
|
|
272
|
+
return
|
|
273
|
+
pending[pool.submit(convert_job, job)] = job
|
|
274
|
+
|
|
275
|
+
fill()
|
|
276
|
+
while pending:
|
|
277
|
+
done, _ = wait(pending, return_when=FIRST_COMPLETED)
|
|
278
|
+
for fut in done:
|
|
279
|
+
rec = fut.result()
|
|
280
|
+
del pending[fut]
|
|
281
|
+
run.add(rec)
|
|
282
|
+
if rec["status"] == "failed" and not keep_going:
|
|
283
|
+
stopped = True
|
|
284
|
+
fill()
|
|
285
|
+
for rest in queue:
|
|
286
|
+
run.add(_cancelled(rest), advance=False)
|
|
287
|
+
return not stopped
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _cancelled(job: Job) -> dict[str, Any]:
|
|
291
|
+
return {"path": job.src, "status": "cancelled", "out": None, "sha256": job.sha256, "profile": job.profile,
|
|
292
|
+
"format": job.format, "warnings": [], "seconds": 0.0, "tokens": 0} # fmt: skip
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def _summary(records: list[dict[str, Any]]) -> dict[str, int]:
|
|
296
|
+
counts = {"converted": 0, "skipped": 0, "failed": 0, "cancelled": 0}
|
|
297
|
+
for r in records:
|
|
298
|
+
status = "converted" if r["status"] == "partial" else str(r["status"])
|
|
299
|
+
counts[status] = counts.get(status, 0) + 1
|
|
300
|
+
counts["total_tokens"] = sum(int(r.get("tokens") or 0) for r in records)
|
|
301
|
+
counts["total"] = len(records)
|
|
302
|
+
return counts
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _print_table(summary: dict[str, int], records: list[dict[str, Any]], manifest: Path) -> None:
|
|
306
|
+
from rich.table import Table
|
|
307
|
+
|
|
308
|
+
con = err_console()
|
|
309
|
+
for r in records:
|
|
310
|
+
if r["status"] == "failed":
|
|
311
|
+
con.print(f"FAIL {r['path']}: {r['error']['message']}", markup=False, highlight=False)
|
|
312
|
+
cols = ["converted", "skipped", "failed", *(["cancelled"] if summary.get("cancelled") else [])]
|
|
313
|
+
t = Table(*cols, "total tokens", title="ezmd batch")
|
|
314
|
+
t.add_row(*(str(summary[k]) for k in [*cols, "total_tokens"]))
|
|
315
|
+
con.print(t)
|
|
316
|
+
if summary.get("cancelled"):
|
|
317
|
+
con.print(
|
|
318
|
+
f"stopped after the first failure; {summary['cancelled']} not converted "
|
|
319
|
+
"(pass --continue-on-error to convert the rest)",
|
|
320
|
+
markup=False,
|
|
321
|
+
highlight=False,
|
|
322
|
+
)
|
|
323
|
+
con.print(f"manifest: {manifest}", markup=False, highlight=False)
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def batch(
|
|
327
|
+
inputs: Annotated[str, typer.Argument(help="Directory or glob (quote globs).")],
|
|
328
|
+
out: Annotated[Path | None, typer.Option("--out", "-o", help="Output directory (mirrors the input tree).")] = None,
|
|
329
|
+
recursive: Annotated[bool, typer.Option("--recursive", "-r", help="Descend into subdirectories.")] = False,
|
|
330
|
+
workers: Annotated[int | None, typer.Option("--workers", "-w", min=1, help="Worker processes.")] = None,
|
|
331
|
+
profile: Annotated[str | None, typer.Option("--profile", "-p", help="full | compact | rag | agent")] = None,
|
|
332
|
+
fmt: Annotated[str | None, typer.Option("--format", "-f", help="md | txt | json")] = None,
|
|
333
|
+
keep_going: Annotated[bool, typer.Option("--continue-on-error", help="Keep going after a failure.")] = False,
|
|
334
|
+
manifest: Annotated[Path | None, typer.Option("--manifest", help="[default: <out>/manifest.jsonl]")] = None,
|
|
335
|
+
sidecar: Annotated[bool | None, typer.Option("--sidecar/--no-sidecar", help="Write sidecars.")] = None,
|
|
336
|
+
opt: Annotated[list[str] | None, typer.Option("--opt", help="key=value converter or profile option.")] = None,
|
|
337
|
+
as_json: Annotated[bool, typer.Option("--json", help="Summary JSON on stdout.")] = False,
|
|
338
|
+
quiet: Annotated[bool, typer.Option("--quiet", "-q", help="No progress or table on stderr.")] = False,
|
|
339
|
+
) -> None:
|
|
340
|
+
"""Convert many files; unchanged inputs are skipped on re-runs."""
|
|
341
|
+
cfg = load_config_or_exit(as_json=as_json, quiet=quiet)
|
|
342
|
+
if quiet or as_json:
|
|
343
|
+
silence_progress_bars() # inherited by the spawned workers
|
|
344
|
+
try:
|
|
345
|
+
chosen_fmt = check_format(fmt or cfg.format, "--format")
|
|
346
|
+
if chosen_fmt not in SUPPORTED_FORMATS:
|
|
347
|
+
raise ConfigError(f"--format {chosen_fmt} is not yet supported; use md, txt, or json")
|
|
348
|
+
chosen_profile = check_profile(profile or cfg.profile or "full", "--profile")
|
|
349
|
+
options = build_options(cfg, opt or [], None, None)
|
|
350
|
+
import ezmd.library as lib
|
|
351
|
+
|
|
352
|
+
lib.Options(**options) # validate once, before any work
|
|
353
|
+
out_dir = out if out is not None else (Path(cfg.out_dir) if cfg.out_dir else None)
|
|
354
|
+
if out_dir is None:
|
|
355
|
+
raise ValueError("--out is required (or set defaults.out_dir in the config file)")
|
|
356
|
+
manifest_path = manifest or out_dir / "manifest.jsonl"
|
|
357
|
+
prev = read_manifest(manifest_path)
|
|
358
|
+
outputs = frozenset(_key(r["out"]) for r in prev.values() if isinstance(r.get("out"), str))
|
|
359
|
+
base, files = discover(inputs, recursive, out_dir, manifest_path, outputs)
|
|
360
|
+
except FileNotFoundError as e:
|
|
361
|
+
raise fail(f"no such file or directory: {inputs}", as_json=as_json, source=inputs) from e
|
|
362
|
+
except ValueError as e:
|
|
363
|
+
raise fail(str(e), as_json=as_json, source=inputs) from e
|
|
364
|
+
write_sidecar = cfg.sidecar if sidecar is None else sidecar
|
|
365
|
+
run = _Run(manifest_path, show=not quiet and not as_json, total=len(files))
|
|
366
|
+
finished = True
|
|
367
|
+
try:
|
|
368
|
+
todo: list[Job] = []
|
|
369
|
+
for src, target in plan(base, files, out_dir, chosen_fmt):
|
|
370
|
+
job = Job(str(src), str(target), chosen_profile, chosen_fmt, options, write_sidecar, file_sha256(src))
|
|
371
|
+
old = prev.get(_key(src))
|
|
372
|
+
if unchanged(job, old):
|
|
373
|
+
rec = {"path": job.src, "status": "skipped", "out": job.out, "sha256": job.sha256,
|
|
374
|
+
"profile": job.profile, "format": job.format,
|
|
375
|
+
"warnings": (old or {}).get("warnings", []), "seconds": 0.0,
|
|
376
|
+
"tokens": int((old or {}).get("tokens") or 0)} # fmt: skip
|
|
377
|
+
run.add(rec)
|
|
378
|
+
else:
|
|
379
|
+
todo.append(job)
|
|
380
|
+
finished = _execute(todo, workers or default_workers(), keep_going, run)
|
|
381
|
+
except KeyboardInterrupt:
|
|
382
|
+
run.close()
|
|
383
|
+
err_console().print("interrupted", markup=False)
|
|
384
|
+
raise typer.Exit(EXIT_INTERRUPTED) from None
|
|
385
|
+
run.close()
|
|
386
|
+
summary = _summary(run.records)
|
|
387
|
+
failed = summary["failed"] > 0
|
|
388
|
+
code = EXIT_OK if not failed else (EXIT_PARTIAL if keep_going and finished else EXIT_FAIL)
|
|
389
|
+
if as_json:
|
|
390
|
+
emit_json({**summary, "exit_code": code, "manifest": str(manifest_path), "files": run.records})
|
|
391
|
+
elif not quiet:
|
|
392
|
+
_print_table(summary, run.records, manifest_path)
|
|
393
|
+
raise typer.Exit(code)
|