convert-docs 0.2.0__tar.gz → 0.3.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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: convert-docs
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: Recursively convert documents (pdf, docx, pptx, xlsx, ...) to Markdown via markitdown, mirroring the source folder structure into a destination folder.
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: markitdown[docx,outlook,pdf,pptx,xls,xlsx]>=0.1.5
@@ -47,6 +47,7 @@ convert-docs -s ~/Documents/Reports -o ~/Documents/Reports_MD
47
47
  convert-docs -e pdf,docx # restrict which extensions get converted
48
48
  convert-docs -f # force re-conversion, ignoring the up-to-date skip
49
49
  convert-docs --last # re-run with the same source/destination/extensions as last time
50
+ convert-docs -j 8 # convert 8 files in parallel instead of the default 4
50
51
  ```
51
52
 
52
53
  | Flag | Description |
@@ -56,10 +57,25 @@ convert-docs --last # re-run with the same source/destination/extens
56
57
  | `-e, --ext` | Comma-separated extensions to convert |
57
58
  | `-f, --force` | Force re-conversion even if output `.md` already exists and is up to date |
58
59
  | `-l, --last` | Reuse the source, destination, and extensions from the last run (skips prompts; cannot be combined with `-s`/`-o`) |
60
+ | `-j, --jobs` | Convert this many files in parallel using separate processes (default: `min(4, cpu_count)`; use `1` for sequential) |
59
61
 
60
62
  Files with unsupported extensions are listed at the end instead of being silently
61
63
  skipped, and any conversion failures are reported with the underlying error.
62
64
 
65
+ ### Parallel conversion
66
+
67
+ Files convert using separate OS processes (real parallelism across CPU cores, not
68
+ `asyncio` — conversion is CPU-bound parsing work, not I/O waiting, so asyncio's
69
+ cooperative concurrency wouldn't actually help). Benchmarked on a 10-core Mac converting
70
+ 12–30 real docx files: `-j 4` (the default) cut wall time by ~2.3x vs. sequential.
71
+ Pushing higher didn't help further — `-j 8` was consistently *slower* than `-j 4` in
72
+ testing, since each worker process pays a fixed startup cost importing `markitdown`'s
73
+ dependencies (onnxruntime, magika, pandas), which outweighs the extra parallelism for
74
+ typical batch sizes. If you're converting a very large batch (hundreds of files), it's
75
+ worth experimenting with higher `-j` values yourself; for typical folders the default
76
+ is a reasonable balance. Progress lines print in completion order, not scan order, since
77
+ files finish out of sequence when running in parallel.
78
+
63
79
  ### Re-running on new files
64
80
 
65
81
  Every run saves its source/destination/extensions to `~/.config/convert-docs/last_run.json`.
@@ -39,6 +39,7 @@ convert-docs -s ~/Documents/Reports -o ~/Documents/Reports_MD
39
39
  convert-docs -e pdf,docx # restrict which extensions get converted
40
40
  convert-docs -f # force re-conversion, ignoring the up-to-date skip
41
41
  convert-docs --last # re-run with the same source/destination/extensions as last time
42
+ convert-docs -j 8 # convert 8 files in parallel instead of the default 4
42
43
  ```
43
44
 
44
45
  | Flag | Description |
@@ -48,10 +49,25 @@ convert-docs --last # re-run with the same source/destination/extens
48
49
  | `-e, --ext` | Comma-separated extensions to convert |
49
50
  | `-f, --force` | Force re-conversion even if output `.md` already exists and is up to date |
50
51
  | `-l, --last` | Reuse the source, destination, and extensions from the last run (skips prompts; cannot be combined with `-s`/`-o`) |
52
+ | `-j, --jobs` | Convert this many files in parallel using separate processes (default: `min(4, cpu_count)`; use `1` for sequential) |
51
53
 
52
54
  Files with unsupported extensions are listed at the end instead of being silently
53
55
  skipped, and any conversion failures are reported with the underlying error.
54
56
 
57
+ ### Parallel conversion
58
+
59
+ Files convert using separate OS processes (real parallelism across CPU cores, not
60
+ `asyncio` — conversion is CPU-bound parsing work, not I/O waiting, so asyncio's
61
+ cooperative concurrency wouldn't actually help). Benchmarked on a 10-core Mac converting
62
+ 12–30 real docx files: `-j 4` (the default) cut wall time by ~2.3x vs. sequential.
63
+ Pushing higher didn't help further — `-j 8` was consistently *slower* than `-j 4` in
64
+ testing, since each worker process pays a fixed startup cost importing `markitdown`'s
65
+ dependencies (onnxruntime, magika, pandas), which outweighs the extra parallelism for
66
+ typical batch sizes. If you're converting a very large batch (hundreds of files), it's
67
+ worth experimenting with higher `-j` values yourself; for typical folders the default
68
+ is a reasonable balance. Progress lines print in completion order, not scan order, since
69
+ files finish out of sequence when running in parallel.
70
+
55
71
  ### Re-running on new files
56
72
 
57
73
  Every run saves its source/destination/extensions to `~/.config/convert-docs/last_run.json`.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "convert-docs"
3
- version = "0.2.0"
3
+ version = "0.3.0"
4
4
  description = "Recursively convert documents (pdf, docx, pptx, xlsx, ...) to Markdown via markitdown, mirroring the source folder structure into a destination folder."
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
@@ -4,6 +4,7 @@ import argparse
4
4
  import json
5
5
  import os
6
6
  import sys
7
+ from concurrent.futures import ProcessPoolExecutor, as_completed
7
8
  from pathlib import Path
8
9
 
9
10
  DEFAULT_EXTENSIONS = [
@@ -106,6 +107,13 @@ def parse_args(argv=None) -> argparse.Namespace:
106
107
  help="Reuse the source, destination, and extensions from the last run "
107
108
  "(skips prompts; cannot be combined with -s/-o)",
108
109
  )
110
+ parser.add_argument(
111
+ "-j", "--jobs",
112
+ type=int,
113
+ default=min(4, os.cpu_count() or 1),
114
+ help="Convert this many files in parallel using separate processes "
115
+ "(default: %(default)s; use 1 for sequential)",
116
+ )
109
117
  return parser.parse_args(argv)
110
118
 
111
119
 
@@ -139,32 +147,75 @@ def collect_files(src_dir: Path, dest_dir: Path, ext_set: set) -> tuple:
139
147
  return target_files, other_files
140
148
 
141
149
 
142
- def convert_files(target_files: list, src_dir: Path, dest_dir: Path, force: bool) -> tuple:
150
+ _worker_md = None
151
+
152
+
153
+ def _init_worker() -> None:
154
+ global _worker_md
143
155
  from markitdown import MarkItDown
156
+ _worker_md = MarkItDown()
144
157
 
145
- md = MarkItDown()
146
- total = len(target_files)
147
- converted, failed, skipped = [], [], []
148
158
 
149
- for i, path in enumerate(target_files, start=1):
159
+ def _convert_one(path_str: str, out_path_str: str) -> tuple:
160
+ try:
161
+ result = _worker_md.convert(path_str)
162
+ Path(out_path_str).write_text(result.text_content, encoding="utf-8")
163
+ return (True, None)
164
+ except Exception as exc:
165
+ return (False, str(exc))
166
+
167
+
168
+ def plan_conversions(target_files: list, src_dir: Path, dest_dir: Path, force: bool) -> tuple:
169
+ to_convert = []
170
+ skipped = []
171
+ for path in target_files:
150
172
  rel_path = path.relative_to(src_dir)
151
173
  out_path = (dest_dir / rel_path).with_suffix(".md")
152
- print(f"[{i}/{total}] {rel_path} ... ", end="", flush=True)
153
-
154
174
  if not force and out_path.exists() and out_path.stat().st_mtime >= path.stat().st_mtime:
155
- print("skipped (up to date)")
156
175
  skipped.append(str(rel_path))
157
176
  continue
158
-
159
177
  out_path.parent.mkdir(parents=True, exist_ok=True)
160
- try:
161
- result = md.convert(str(path))
162
- out_path.write_text(result.text_content, encoding="utf-8")
163
- print("OK")
164
- converted.append(str(rel_path))
165
- except Exception as exc:
166
- print("FAILED")
167
- failed.append(f"{rel_path} :: {exc}")
178
+ to_convert.append((path, rel_path, out_path))
179
+ return to_convert, skipped
180
+
181
+
182
+ def convert_files(target_files: list, src_dir: Path, dest_dir: Path, force: bool, jobs: int = 1) -> tuple:
183
+ to_convert, skipped = plan_conversions(target_files, src_dir, dest_dir, force)
184
+ total = len(to_convert)
185
+ converted, failed = [], []
186
+
187
+ if total == 0:
188
+ return converted, failed, skipped
189
+
190
+ if jobs <= 1 or total == 1:
191
+ from markitdown import MarkItDown
192
+ md = MarkItDown()
193
+ for i, (path, rel_path, out_path) in enumerate(to_convert, start=1):
194
+ print(f"[{i}/{total}] {rel_path} ... ", end="", flush=True)
195
+ try:
196
+ result = md.convert(str(path))
197
+ out_path.write_text(result.text_content, encoding="utf-8")
198
+ print("OK")
199
+ converted.append(str(rel_path))
200
+ except Exception as exc:
201
+ print("FAILED")
202
+ failed.append(f"{rel_path} :: {exc}")
203
+ return converted, failed, skipped
204
+
205
+ with ProcessPoolExecutor(max_workers=jobs, initializer=_init_worker) as executor:
206
+ futures = {
207
+ executor.submit(_convert_one, str(path), str(out_path)): rel_path
208
+ for path, rel_path, out_path in to_convert
209
+ }
210
+ for i, future in enumerate(as_completed(futures), start=1):
211
+ rel_path = futures[future]
212
+ ok, err = future.result()
213
+ if ok:
214
+ print(f"[{i}/{total}] {rel_path} ... OK")
215
+ converted.append(str(rel_path))
216
+ else:
217
+ print(f"[{i}/{total}] {rel_path} ... FAILED")
218
+ failed.append(f"{rel_path} :: {err}")
168
219
 
169
220
  return converted, failed, skipped
170
221
 
@@ -207,7 +258,7 @@ def main(argv=None) -> int:
207
258
  print(f"Found {len(target_files)} convertible file(s), {len(other_files)} file(s) with unsupported extensions.")
208
259
  print(SEPARATOR)
209
260
 
210
- converted, failed, skipped = convert_files(target_files, src_dir, dest_dir, args.force)
261
+ converted, failed, skipped = convert_files(target_files, src_dir, dest_dir, args.force, jobs=args.jobs)
211
262
 
212
263
  print(SEPARATOR)
213
264
  print("Summary:")
File without changes