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/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