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.
- {convert_docs-0.2.0 → convert_docs-0.3.0}/PKG-INFO +17 -1
- {convert_docs-0.2.0 → convert_docs-0.3.0}/README.md +16 -0
- {convert_docs-0.2.0 → convert_docs-0.3.0}/pyproject.toml +1 -1
- {convert_docs-0.2.0 → convert_docs-0.3.0}/src/convert_docs/cli.py +69 -18
- {convert_docs-0.2.0 → convert_docs-0.3.0}/.github/workflows/publish.yml +0 -0
- {convert_docs-0.2.0 → convert_docs-0.3.0}/.gitignore +0 -0
- {convert_docs-0.2.0 → convert_docs-0.3.0}/src/convert_docs/__init__.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: convert-docs
|
|
3
|
-
Version: 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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
File without changes
|
|
File without changes
|