morphconv 1.0.1__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.
- morph/__init__.py +0 -0
- morph/batch.py +492 -0
- morph/cli.py +703 -0
- morph/converters/__init__.py +19 -0
- morph/converters/archives.py +85 -0
- morph/converters/audio.py +58 -0
- morph/converters/csv_json.py +28 -0
- morph/converters/csv_to_xlsx.py +290 -0
- morph/converters/documents.py +252 -0
- morph/converters/ebooks.py +36 -0
- morph/converters/fonts.py +72 -0
- morph/converters/images.py +119 -0
- morph/converters/json_yaml.py +26 -0
- morph/converters/models_3d.py +149 -0
- morph/converters/ocr.py +49 -0
- morph/converters/pandas_extra.py +127 -0
- morph/converters/svg.py +63 -0
- morph/converters/video.py +153 -0
- morph/converters/vtracer_converter.py +84 -0
- morph/converters/web.py +92 -0
- morph/converters/xlsx_to_csv.py +272 -0
- morph/deps.py +228 -0
- morph/ffmpeg_utils.py +79 -0
- morph/history.py +136 -0
- morph/progress.py +78 -0
- morph/registry.py +236 -0
- morph/tui.py +792 -0
- morphconv-1.0.1.dist-info/METADATA +396 -0
- morphconv-1.0.1.dist-info/RECORD +33 -0
- morphconv-1.0.1.dist-info/WHEEL +5 -0
- morphconv-1.0.1.dist-info/entry_points.txt +2 -0
- morphconv-1.0.1.dist-info/licenses/LICENSE +21 -0
- morphconv-1.0.1.dist-info/top_level.txt +1 -0
morph/history.py
ADDED
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
"""
|
|
2
|
+
history.py — persistent conversion log.
|
|
3
|
+
|
|
4
|
+
Every conversion (single or batch) appends one line to
|
|
5
|
+
~/.morph_history.jsonl. Entries record source, destination,
|
|
6
|
+
route, timing, and success/failure. The `morph history`
|
|
7
|
+
subcommand reads this file back and renders a rich table.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
from dataclasses import asdict, dataclass, field
|
|
15
|
+
from datetime import datetime
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Optional
|
|
18
|
+
|
|
19
|
+
HISTORY_FILE = Path.home() / ".morph_history.jsonl"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class HistoryEntry:
|
|
24
|
+
ts: str
|
|
25
|
+
src_path: str
|
|
26
|
+
src_fmt: str
|
|
27
|
+
dst_path: str
|
|
28
|
+
dst_fmt: str
|
|
29
|
+
route: str
|
|
30
|
+
backend: str
|
|
31
|
+
success: bool
|
|
32
|
+
elapsed_s: float
|
|
33
|
+
mode: str = "single" # "single" | "batch"
|
|
34
|
+
batch_id: Optional[str] = None
|
|
35
|
+
error: Optional[str] = None
|
|
36
|
+
extra: dict = field(default_factory=dict)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def append_entry(entry: HistoryEntry) -> None:
|
|
40
|
+
"""Append one entry to the history JSONL file."""
|
|
41
|
+
try:
|
|
42
|
+
with open(HISTORY_FILE, "a", encoding="utf-8") as f:
|
|
43
|
+
f.write(json.dumps(asdict(entry), ensure_ascii=False) + "\n")
|
|
44
|
+
except OSError:
|
|
45
|
+
pass # never let history I/O break a conversion
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def read_entries(
|
|
49
|
+
*,
|
|
50
|
+
limit: int = 20,
|
|
51
|
+
failed_only: bool = False,
|
|
52
|
+
fmt_filter: Optional[str] = None,
|
|
53
|
+
) -> list[HistoryEntry]:
|
|
54
|
+
"""Read the last `limit` entries, optionally filtered."""
|
|
55
|
+
if not HISTORY_FILE.exists():
|
|
56
|
+
return []
|
|
57
|
+
entries: list[HistoryEntry] = []
|
|
58
|
+
try:
|
|
59
|
+
with open(HISTORY_FILE, "r", encoding="utf-8") as f:
|
|
60
|
+
for line in f:
|
|
61
|
+
line = line.strip()
|
|
62
|
+
if not line:
|
|
63
|
+
continue
|
|
64
|
+
try:
|
|
65
|
+
d = json.loads(line)
|
|
66
|
+
except json.JSONDecodeError:
|
|
67
|
+
continue
|
|
68
|
+
entry = HistoryEntry(
|
|
69
|
+
ts=d.get("ts", ""),
|
|
70
|
+
src_path=d.get("src_path", ""),
|
|
71
|
+
src_fmt=d.get("src_fmt", ""),
|
|
72
|
+
dst_path=d.get("dst_path", ""),
|
|
73
|
+
dst_fmt=d.get("dst_fmt", ""),
|
|
74
|
+
route=d.get("route", ""),
|
|
75
|
+
backend=d.get("backend", ""),
|
|
76
|
+
success=d.get("success", True),
|
|
77
|
+
elapsed_s=d.get("elapsed_s", 0.0),
|
|
78
|
+
mode=d.get("mode", "single"),
|
|
79
|
+
batch_id=d.get("batch_id"),
|
|
80
|
+
error=d.get("error"),
|
|
81
|
+
extra=d.get("extra", {}),
|
|
82
|
+
)
|
|
83
|
+
if failed_only and entry.success:
|
|
84
|
+
continue
|
|
85
|
+
if fmt_filter:
|
|
86
|
+
norm = fmt_filter.lower().lstrip(".")
|
|
87
|
+
if norm not in (entry.src_fmt.lower(), entry.dst_fmt.lower()):
|
|
88
|
+
continue
|
|
89
|
+
entries.append(entry)
|
|
90
|
+
except OSError:
|
|
91
|
+
return []
|
|
92
|
+
return entries[-limit:]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def clear() -> bool:
|
|
96
|
+
"""Delete the history file. Returns True if it existed."""
|
|
97
|
+
if HISTORY_FILE.exists():
|
|
98
|
+
HISTORY_FILE.unlink()
|
|
99
|
+
return True
|
|
100
|
+
return False
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def make_entry(
|
|
104
|
+
src_path: Path,
|
|
105
|
+
src_fmt: str,
|
|
106
|
+
dst_path: Path,
|
|
107
|
+
dst_fmt: str,
|
|
108
|
+
route: str,
|
|
109
|
+
backend: str,
|
|
110
|
+
success: bool,
|
|
111
|
+
elapsed_s: float,
|
|
112
|
+
*,
|
|
113
|
+
mode: str = "single",
|
|
114
|
+
batch_id: Optional[str] = None,
|
|
115
|
+
error: Optional[str] = None,
|
|
116
|
+
) -> HistoryEntry:
|
|
117
|
+
"""Helper to build and return a HistoryEntry."""
|
|
118
|
+
return HistoryEntry(
|
|
119
|
+
ts=datetime.now().strftime("%Y-%m-%dT%H:%M:%S"),
|
|
120
|
+
src_path=str(src_path),
|
|
121
|
+
src_fmt=src_fmt,
|
|
122
|
+
dst_path=str(dst_path),
|
|
123
|
+
dst_fmt=dst_fmt,
|
|
124
|
+
route=route,
|
|
125
|
+
backend=backend,
|
|
126
|
+
success=success,
|
|
127
|
+
elapsed_s=round(elapsed_s, 2),
|
|
128
|
+
mode=mode,
|
|
129
|
+
batch_id=batch_id,
|
|
130
|
+
error=error,
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def generate_batch_id() -> str:
|
|
135
|
+
"""Short unique id for grouping batch entries."""
|
|
136
|
+
return f"b-{datetime.now().strftime('%Y%m%d-%H%M%S')}"
|
morph/progress.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
"""
|
|
2
|
+
progress.py — live status while a hop runs.
|
|
3
|
+
|
|
4
|
+
Every conversion function is a blocking call (subprocess.run, Pillow, a
|
|
5
|
+
pandas loop, ...). To show *something* live instead of a dead terminal:
|
|
6
|
+
|
|
7
|
+
• If the spec supports real progress (currently: ffmpeg-backed hops), the
|
|
8
|
+
function is called in a background thread with a `_progress(frac, status)`
|
|
9
|
+
callback injected, and a determinate Rich progress bar tracks it.
|
|
10
|
+
• Otherwise, the function still runs in a background thread, but the
|
|
11
|
+
foreground just shows an indeterminate spinner with the backend name
|
|
12
|
+
("via pandoc", "via ffmpeg", ...) — so it's always visible *what* is
|
|
13
|
+
doing the work, even when we can't say how far along it is.
|
|
14
|
+
|
|
15
|
+
Rich's Progress has its own auto-refreshing Live display, so a plain
|
|
16
|
+
`thread.join()` on the main thread is enough — no manual polling loop needed.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import threading
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import Any
|
|
24
|
+
|
|
25
|
+
from rich.console import Console
|
|
26
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeElapsedColumn
|
|
27
|
+
|
|
28
|
+
from .registry import ConversionResult, ConverterSpec
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def run_hop(spec: ConverterSpec, input_path: Path, output_path: Path,
|
|
32
|
+
options: dict[str, Any], console: Console, *, quiet: bool = False) -> ConversionResult:
|
|
33
|
+
if quiet:
|
|
34
|
+
call_options = dict(options)
|
|
35
|
+
if spec.supports_progress:
|
|
36
|
+
call_options["_progress"] = lambda frac, status: None
|
|
37
|
+
return spec.func(input_path, output_path, **call_options)
|
|
38
|
+
|
|
39
|
+
label = f"{spec.src} → {spec.dst}"
|
|
40
|
+
tool_tag = f"[muted]via {spec.backend}[/muted]"
|
|
41
|
+
|
|
42
|
+
result_box: dict[str, Any] = {}
|
|
43
|
+
error_box: dict[str, BaseException] = {}
|
|
44
|
+
|
|
45
|
+
columns = [
|
|
46
|
+
SpinnerColumn(style="cyan"),
|
|
47
|
+
TextColumn("[progress.description]{task.description}"),
|
|
48
|
+
BarColumn(bar_width=28),
|
|
49
|
+
TextColumn("[muted]{task.fields[status]}[/muted]"),
|
|
50
|
+
TimeElapsedColumn(),
|
|
51
|
+
]
|
|
52
|
+
|
|
53
|
+
with Progress(*columns, console=console, transient=True) as progress:
|
|
54
|
+
total = 100 if spec.supports_progress else None
|
|
55
|
+
task_id = progress.add_task(f"{label} {tool_tag}", total=total, status="")
|
|
56
|
+
|
|
57
|
+
def _on_progress(frac: float, status: str) -> None:
|
|
58
|
+
progress.update(task_id, completed=frac * 100, status=status)
|
|
59
|
+
|
|
60
|
+
def _target() -> None:
|
|
61
|
+
try:
|
|
62
|
+
call_options = dict(options)
|
|
63
|
+
if spec.supports_progress:
|
|
64
|
+
call_options["_progress"] = _on_progress
|
|
65
|
+
result_box["result"] = spec.func(input_path, output_path, **call_options)
|
|
66
|
+
except BaseException as exc: # noqa: BLE001 — re-raised on the main thread below
|
|
67
|
+
error_box["error"] = exc
|
|
68
|
+
|
|
69
|
+
thread = threading.Thread(target=_target, daemon=True)
|
|
70
|
+
thread.start()
|
|
71
|
+
thread.join()
|
|
72
|
+
|
|
73
|
+
if not error_box:
|
|
74
|
+
progress.update(task_id, completed=100, status="done")
|
|
75
|
+
|
|
76
|
+
if "error" in error_box:
|
|
77
|
+
raise error_box["error"]
|
|
78
|
+
return result_box["result"]
|
morph/registry.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""
|
|
2
|
+
registry.py — the heart of morph's "anything to anything" engine.
|
|
3
|
+
|
|
4
|
+
Every converter (native Python or a wrapper around an external binary like
|
|
5
|
+
pandoc/ffmpeg) registers itself as a directed edge: src format -> dst format.
|
|
6
|
+
morph then does a breadth-first search over that graph so that even formats
|
|
7
|
+
with no *direct* converter can still be reached via a chain
|
|
8
|
+
(e.g. odt -> docx -> pdf), as long as every edge on the path is registered.
|
|
9
|
+
|
|
10
|
+
Nothing in here knows about pandoc, ffmpeg, or pandas specifically — that
|
|
11
|
+
knowledge lives in morph/converters/*.py, which import `register` from here
|
|
12
|
+
and populate the graph at import time.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections import deque
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Callable, Optional
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class ConversionResult:
|
|
25
|
+
"""What every converter function returns."""
|
|
26
|
+
output: Path
|
|
27
|
+
rows: Optional[int] = None # for tabular data
|
|
28
|
+
pages: Optional[int] = None # for documents
|
|
29
|
+
duration: Optional[float] = None # seconds, for audio/video
|
|
30
|
+
extra: dict = field(default_factory=dict)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class OptionSpec:
|
|
35
|
+
"""One CLI flag a converter accepts, e.g. --table-style or --no-header.
|
|
36
|
+
|
|
37
|
+
`name` must match the keyword argument the converter function expects.
|
|
38
|
+
`action` mirrors argparse: "store_true"/"store_false" for flags, None for
|
|
39
|
+
a value-taking option (parsed as `type`).
|
|
40
|
+
"""
|
|
41
|
+
name: str
|
|
42
|
+
flags: tuple[str, ...]
|
|
43
|
+
help: str = ""
|
|
44
|
+
default: object = None
|
|
45
|
+
type: type = str
|
|
46
|
+
action: Optional[str] = None
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass(frozen=True)
|
|
50
|
+
class ConverterSpec:
|
|
51
|
+
src: str
|
|
52
|
+
dst: str
|
|
53
|
+
func: Callable[..., ConversionResult]
|
|
54
|
+
backend: str # "native" | "pandoc" | "ffmpeg" | ...
|
|
55
|
+
requires_binary: Optional[str] = None
|
|
56
|
+
family: str = "data" # data | document | image | audio | video | archive | font | ebook
|
|
57
|
+
description: str = ""
|
|
58
|
+
lossy: bool = False # flag conversions that can't round-trip cleanly
|
|
59
|
+
options: tuple[OptionSpec, ...] = field(default_factory=tuple)
|
|
60
|
+
supports_progress: bool = False # True if func accepts a `_progress(frac, status)` kwarg
|
|
61
|
+
|
|
62
|
+
@property
|
|
63
|
+
def key(self) -> tuple[str, str]:
|
|
64
|
+
return (self.src, self.dst)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
class Registry:
|
|
68
|
+
def __init__(self) -> None:
|
|
69
|
+
self._edges: dict[tuple[str, str], ConverterSpec] = {}
|
|
70
|
+
|
|
71
|
+
def register(
|
|
72
|
+
self,
|
|
73
|
+
src: str,
|
|
74
|
+
dst: str,
|
|
75
|
+
*,
|
|
76
|
+
backend: str,
|
|
77
|
+
requires_binary: Optional[str] = None,
|
|
78
|
+
family: str = "data",
|
|
79
|
+
description: str = "",
|
|
80
|
+
lossy: bool = False,
|
|
81
|
+
options: Optional[list[OptionSpec]] = None,
|
|
82
|
+
supports_progress: bool = False,
|
|
83
|
+
):
|
|
84
|
+
"""Decorator: register `func` as a converter for src -> dst."""
|
|
85
|
+
def deco(func: Callable[..., ConversionResult]):
|
|
86
|
+
spec = ConverterSpec(
|
|
87
|
+
src=_norm(src), dst=_norm(dst), func=func, backend=backend,
|
|
88
|
+
requires_binary=requires_binary, family=family,
|
|
89
|
+
description=description, lossy=lossy,
|
|
90
|
+
options=tuple(options or ()), supports_progress=supports_progress,
|
|
91
|
+
)
|
|
92
|
+
self._edges[spec.key] = spec
|
|
93
|
+
return func
|
|
94
|
+
return deco
|
|
95
|
+
|
|
96
|
+
def all_formats(self) -> set[str]:
|
|
97
|
+
fmts: set[str] = set()
|
|
98
|
+
for s, d in self._edges:
|
|
99
|
+
fmts.add(s)
|
|
100
|
+
fmts.add(d)
|
|
101
|
+
return fmts
|
|
102
|
+
|
|
103
|
+
def direct(self, src: str, dst: str) -> Optional[ConverterSpec]:
|
|
104
|
+
return self._edges.get((_norm(src), _norm(dst)))
|
|
105
|
+
|
|
106
|
+
def find_path(self, src: str, dst: str) -> Optional[list[ConverterSpec]]:
|
|
107
|
+
"""BFS shortest conversion path (fewest hops) from src to dst."""
|
|
108
|
+
src, dst = _norm(src), _norm(dst)
|
|
109
|
+
if src == dst:
|
|
110
|
+
return []
|
|
111
|
+
|
|
112
|
+
graph: dict[str, list[ConverterSpec]] = {}
|
|
113
|
+
for spec in self._edges.values():
|
|
114
|
+
graph.setdefault(spec.src, []).append(spec)
|
|
115
|
+
|
|
116
|
+
visited = {src}
|
|
117
|
+
queue: deque[tuple[str, list[ConverterSpec]]] = deque([(src, [])])
|
|
118
|
+
while queue:
|
|
119
|
+
node, path = queue.popleft()
|
|
120
|
+
for spec in graph.get(node, []):
|
|
121
|
+
if spec.dst == dst:
|
|
122
|
+
return path + [spec]
|
|
123
|
+
if spec.dst not in visited:
|
|
124
|
+
visited.add(spec.dst)
|
|
125
|
+
queue.append((spec.dst, path + [spec]))
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
def reachable_targets(self, src: str) -> dict[str, list[ConverterSpec]]:
|
|
129
|
+
"""Every format reachable from src, mapped to the path that gets there."""
|
|
130
|
+
src = _norm(src)
|
|
131
|
+
graph: dict[str, list[ConverterSpec]] = {}
|
|
132
|
+
for spec in self._edges.values():
|
|
133
|
+
graph.setdefault(spec.src, []).append(spec)
|
|
134
|
+
|
|
135
|
+
results: dict[str, list[ConverterSpec]] = {}
|
|
136
|
+
visited = {src}
|
|
137
|
+
queue: deque[tuple[str, list[ConverterSpec]]] = deque([(src, [])])
|
|
138
|
+
while queue:
|
|
139
|
+
node, path = queue.popleft()
|
|
140
|
+
for spec in graph.get(node, []):
|
|
141
|
+
if spec.dst not in visited:
|
|
142
|
+
visited.add(spec.dst)
|
|
143
|
+
new_path = path + [spec]
|
|
144
|
+
results[spec.dst] = new_path
|
|
145
|
+
queue.append((spec.dst, new_path))
|
|
146
|
+
return results
|
|
147
|
+
|
|
148
|
+
def combined_options(self, path: list[ConverterSpec]) -> list[OptionSpec]:
|
|
149
|
+
"""All options across every hop in a path, flattened for the CLI parser."""
|
|
150
|
+
combined: list[OptionSpec] = []
|
|
151
|
+
seen_flags: set[str] = set()
|
|
152
|
+
for spec in path:
|
|
153
|
+
for opt in spec.options:
|
|
154
|
+
colliding = seen_flags & set(opt.flags)
|
|
155
|
+
if colliding:
|
|
156
|
+
# two hops in the same chain want the same flag name — extremely
|
|
157
|
+
# unlikely given today's converters, but fail loudly rather than
|
|
158
|
+
# silently letting one shadow the other.
|
|
159
|
+
raise ValueError(
|
|
160
|
+
f"Flag collision on {colliding} between hops in this conversion path"
|
|
161
|
+
)
|
|
162
|
+
seen_flags.update(opt.flags)
|
|
163
|
+
combined.append(opt)
|
|
164
|
+
return combined
|
|
165
|
+
|
|
166
|
+
def all_options(self) -> dict[str, list[OptionSpec]]:
|
|
167
|
+
"""All options across the entire registry, grouped by family."""
|
|
168
|
+
families: dict[str, list[OptionSpec]] = {}
|
|
169
|
+
seen_names: set[str] = set()
|
|
170
|
+
|
|
171
|
+
for spec in self._edges.values():
|
|
172
|
+
for opt in spec.options:
|
|
173
|
+
if opt.name not in seen_names:
|
|
174
|
+
seen_names.add(opt.name)
|
|
175
|
+
families.setdefault(spec.family, []).append(opt)
|
|
176
|
+
|
|
177
|
+
return families
|
|
178
|
+
|
|
179
|
+
def known_binaries(self) -> set[str]:
|
|
180
|
+
return {spec.requires_binary for spec in self._edges.values() if spec.requires_binary}
|
|
181
|
+
|
|
182
|
+
def required_binaries(self, path: list[ConverterSpec]) -> list[str]:
|
|
183
|
+
seen: list[str] = []
|
|
184
|
+
for spec in path:
|
|
185
|
+
if spec.requires_binary and spec.requires_binary not in seen:
|
|
186
|
+
seen.append(spec.requires_binary)
|
|
187
|
+
return seen
|
|
188
|
+
|
|
189
|
+
def formats_by_family(self) -> dict[str, set[str]]:
|
|
190
|
+
"""All formats grouped by their converter family."""
|
|
191
|
+
families: dict[str, set[str]] = {}
|
|
192
|
+
for spec in self._edges.values():
|
|
193
|
+
families.setdefault(spec.family, set()).add(spec.src)
|
|
194
|
+
families.setdefault(spec.family, set()).add(spec.dst)
|
|
195
|
+
return families
|
|
196
|
+
|
|
197
|
+
def edges_by_family(self) -> dict[str, list[ConverterSpec]]:
|
|
198
|
+
"""All direct edges grouped by family."""
|
|
199
|
+
families: dict[str, list[ConverterSpec]] = {}
|
|
200
|
+
for spec in self._edges.values():
|
|
201
|
+
families.setdefault(spec.family, []).append(spec)
|
|
202
|
+
return families
|
|
203
|
+
|
|
204
|
+
def family_backends(self) -> dict[str, set[str]]:
|
|
205
|
+
"""Primary backends used by each family."""
|
|
206
|
+
result: dict[str, set[str]] = {}
|
|
207
|
+
for spec in self._edges.values():
|
|
208
|
+
result.setdefault(spec.family, set()).add(spec.backend)
|
|
209
|
+
return result
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _norm(fmt: str) -> str:
|
|
213
|
+
return fmt.lower().lstrip(".")
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
# extensions where the true format spans more than one suffix
|
|
217
|
+
_COMPOUND_EXTENSIONS = {
|
|
218
|
+
".tar.gz": "tar.gz", ".tgz": "tar.gz",
|
|
219
|
+
".tar.bz2": "tar.bz2", ".tbz2": "tar.bz2",
|
|
220
|
+
".tar.xz": "tar.xz", ".txz": "tar.xz",
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def detect_format(path: Path) -> str:
|
|
225
|
+
"""Format id for a file path, correctly handling compound extensions
|
|
226
|
+
like .tar.gz (where Path.suffix alone would only see '.gz')."""
|
|
227
|
+
name = path.name.lower()
|
|
228
|
+
for ext, fmt in _COMPOUND_EXTENSIONS.items():
|
|
229
|
+
if name.endswith(ext):
|
|
230
|
+
return fmt
|
|
231
|
+
return _norm(path.suffix)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# Single shared instance every converter module registers against.
|
|
235
|
+
registry = Registry()
|
|
236
|
+
register = registry.register
|