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.
Files changed (67) hide show
  1. ezmd/__init__.py +48 -0
  2. ezmd/builtin.py +26 -0
  3. ezmd/chains.py +27 -0
  4. ezmd/cli/__init__.py +44 -0
  5. ezmd/cli/app.py +74 -0
  6. ezmd/cli/batch.py +393 -0
  7. ezmd/cli/config.py +214 -0
  8. ezmd/cli/convert.py +207 -0
  9. ezmd/cli/doctor.py +367 -0
  10. ezmd/cli/exitcodes.py +113 -0
  11. ezmd/cli/info.py +184 -0
  12. ezmd/cli/options.py +120 -0
  13. ezmd/cli/output.py +203 -0
  14. ezmd/cli/remote.py +223 -0
  15. ezmd/cli/shadow.py +414 -0
  16. ezmd/context.py +170 -0
  17. ezmd/core/__init__.py +1 -0
  18. ezmd/core/licensing.py +76 -0
  19. ezmd/core/logging.py +88 -0
  20. ezmd/core/netguard.py +717 -0
  21. ezmd/core/sandbox.py +291 -0
  22. ezmd/core/textclean.py +45 -0
  23. ezmd/detect.py +511 -0
  24. ezmd/inputs.py +205 -0
  25. ezmd/ir.py +928 -0
  26. ezmd/library.py +346 -0
  27. ezmd/pipeline.py +83 -0
  28. ezmd/profiles.py +285 -0
  29. ezmd/py.typed +0 -0
  30. ezmd/registry.py +563 -0
  31. ezmd/render/__init__.py +81 -0
  32. ezmd/render/assemble.py +299 -0
  33. ezmd/render/base.py +90 -0
  34. ezmd/render/blocks.py +358 -0
  35. ezmd/render/children.py +266 -0
  36. ezmd/render/chunker.py +261 -0
  37. ezmd/render/context.py +178 -0
  38. ezmd/render/frontmatter.py +377 -0
  39. ezmd/render/headings.py +149 -0
  40. ezmd/render/injection.py +364 -0
  41. ezmd/render/injection_patterns.py +106 -0
  42. ezmd/render/inline.py +154 -0
  43. ezmd/render/jsonr.py +45 -0
  44. ezmd/render/links.py +89 -0
  45. ezmd/render/markdown.py +274 -0
  46. ezmd/render/plaintext.py +203 -0
  47. ezmd/render/sidecar.py +149 -0
  48. ezmd/render/source_types.py +122 -0
  49. ezmd/render/tables.py +285 -0
  50. ezmd/render/text.py +146 -0
  51. ezmd/render/tokens.py +109 -0
  52. ezmd/render/transcript.py +151 -0
  53. ezmd/render/units.py +316 -0
  54. ezmd/testing/__init__.py +1 -0
  55. ezmd/testing/cli.py +80 -0
  56. ezmd/testing/fixtures.py +435 -0
  57. ezmd/testing/invariants.py +233 -0
  58. ezmd/testing/pytest_fixtures.py +73 -0
  59. ezmd/testing/score.py +286 -0
  60. ezmd/warnings/__init__.py +5 -0
  61. ezmd/warnings/codes.py +820 -0
  62. ezmd-0.1.0.dist-info/METADATA +78 -0
  63. ezmd-0.1.0.dist-info/RECORD +67 -0
  64. ezmd-0.1.0.dist-info/WHEEL +4 -0
  65. ezmd-0.1.0.dist-info/entry_points.txt +4 -0
  66. ezmd-0.1.0.dist-info/licenses/LICENSE +202 -0
  67. 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)