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.
@@ -0,0 +1,19 @@
1
+ """
2
+ Auto-discovers and imports every converter module in this package.
3
+
4
+ Dropping a new file in morph/converters/ (that calls `register(...)` at
5
+ import time) is enough to add a conversion — nothing else needs editing.
6
+ Files prefixed with `_` are treated as private engines (e.g. _csv_to_excel.py)
7
+ and are not auto-imported themselves; they're imported by whichever module
8
+ wraps them.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import importlib
14
+ import pkgutil
15
+
16
+ for _finder, _name, _ispkg in pkgutil.iter_modules(__path__):
17
+ if _name.startswith("_"):
18
+ continue
19
+ importlib.import_module(f"{__name__}.{_name}")
@@ -0,0 +1,85 @@
1
+ """
2
+ converters/archives.py — archive family (zip <-> tar variants), stdlib only.
3
+
4
+ Conversion here means: extract every member from the source archive, then
5
+ repack them into the destination format, preserving relative paths.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import tarfile
11
+ import tempfile
12
+ import zipfile
13
+ from pathlib import Path
14
+
15
+ from ..registry import ConversionResult, OptionSpec, register
16
+
17
+ # format id -> tarfile mode suffix ("" for plain tar, or compression codec)
18
+ _TAR_MODES = {"tar": "", "tar.gz": "gz", "tar.bz2": "bz2", "tar.xz": "xz"}
19
+ FORMATS = ["zip", *_TAR_MODES.keys()]
20
+
21
+ OPTIONS = [
22
+ OptionSpec("strip_top_level", ("--strip-top-level",),
23
+ "Drop a single shared top-level folder when repacking, if every member has one.",
24
+ default=False, action="store_true"),
25
+ ]
26
+
27
+
28
+ def _extract(input_path: Path, dst_dir: Path, fmt: str) -> None:
29
+ if fmt == "zip":
30
+ with zipfile.ZipFile(input_path) as zf:
31
+ zf.extractall(dst_dir)
32
+ else:
33
+ with tarfile.open(input_path, f"r:{_TAR_MODES[fmt]}" if _TAR_MODES[fmt] else "r:") as tf:
34
+ tf.extractall(dst_dir, filter="data")
35
+
36
+
37
+ def _maybe_strip_top_level(root: Path) -> Path:
38
+ entries = list(root.iterdir())
39
+ if len(entries) == 1 and entries[0].is_dir():
40
+ return entries[0]
41
+ return root
42
+
43
+
44
+ def _pack(src_dir: Path, output_path: Path, fmt: str) -> int:
45
+ count = 0
46
+ if fmt == "zip":
47
+ with zipfile.ZipFile(output_path, "w", zipfile.ZIP_DEFLATED) as zf:
48
+ for f in src_dir.rglob("*"):
49
+ if f.is_file():
50
+ zf.write(f, f.relative_to(src_dir))
51
+ count += 1
52
+ else:
53
+ mode = f"w:{_TAR_MODES[fmt]}" if _TAR_MODES[fmt] else "w"
54
+ with tarfile.open(output_path, mode) as tf:
55
+ for f in src_dir.rglob("*"):
56
+ if f.is_file():
57
+ tf.add(f, f.relative_to(src_dir))
58
+ count += 1
59
+ return count
60
+
61
+
62
+ def _make_converter(src_fmt: str, dst_fmt: str):
63
+ def _convert(input_path: Path, output_path: Path, *, strip_top_level: bool = False,
64
+ **_options) -> ConversionResult:
65
+ with tempfile.TemporaryDirectory() as tmp:
66
+ tmp_path = Path(tmp)
67
+ _extract(input_path, tmp_path, src_fmt)
68
+ pack_root = _maybe_strip_top_level(tmp_path) if strip_top_level else tmp_path
69
+ output_path.parent.mkdir(parents=True, exist_ok=True)
70
+ n_files = _pack(pack_root, output_path, dst_fmt)
71
+ return ConversionResult(output=output_path, extra={"files": n_files})
72
+ return _convert
73
+
74
+
75
+ for _src in FORMATS:
76
+ for _dst in FORMATS:
77
+ if _dst == _src:
78
+ continue
79
+ register(
80
+ _src, _dst,
81
+ backend="stdlib",
82
+ family="archive",
83
+ description=f"{_src} → {_dst}",
84
+ options=OPTIONS,
85
+ )(_make_converter(_src, _dst))
@@ -0,0 +1,58 @@
1
+ """
2
+ converters/audio.py — audio family, powered by ffmpeg.
3
+
4
+ ffmpeg picks a sensible default codec per container from the output
5
+ extension alone in almost all cases, so the base conversion needs no
6
+ codec-specific logic — options here just override the defaults.
7
+
8
+ Every hop reports real fractional progress (not just a spinner) via
9
+ ffmpeg's -progress stream, parsed in ffmpeg_utils.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ from pathlib import Path
15
+ from typing import Optional
16
+
17
+ from ..ffmpeg_utils import ProgressCallback, run_ffmpeg
18
+ from ..registry import ConversionResult, OptionSpec, register
19
+
20
+ FORMATS = ["mp3", "wav", "flac", "ogg", "aac", "m4a", "opus", "wma"]
21
+
22
+ OPTIONS = [
23
+ OptionSpec("bitrate", ("-b", "--bitrate"), "Audio bitrate, e.g. '192k'. Default: ffmpeg's per-codec default."),
24
+ OptionSpec("sample_rate", ("--sample-rate",), "Output sample rate in Hz, e.g. 44100.", type=int),
25
+ OptionSpec("channels", ("--channels",), "Output channel count: 1 (mono) or 2 (stereo).", type=int),
26
+ ]
27
+
28
+
29
+ def _audio_convert(input_path: Path, output_path: Path, *, bitrate: Optional[str] = None,
30
+ sample_rate: Optional[int] = None, channels: Optional[int] = None,
31
+ _progress: Optional[ProgressCallback] = None, **_options) -> ConversionResult:
32
+ output_path.parent.mkdir(parents=True, exist_ok=True)
33
+ cmd = ["ffmpeg", "-y", "-loglevel", "error", "-i", str(input_path)]
34
+ if bitrate:
35
+ cmd += ["-b:a", bitrate]
36
+ if sample_rate:
37
+ cmd += ["-ar", str(sample_rate)]
38
+ if channels:
39
+ cmd += ["-ac", str(channels)]
40
+ cmd.append(str(output_path))
41
+ run_ffmpeg(cmd, input_path=input_path, progress=_progress)
42
+ return ConversionResult(output=output_path)
43
+
44
+
45
+ for _src in FORMATS:
46
+ for _dst in FORMATS:
47
+ if _dst == _src:
48
+ continue
49
+ register(
50
+ _src, _dst,
51
+ backend="ffmpeg",
52
+ requires_binary="ffmpeg",
53
+ family="audio",
54
+ description=f"{_src} → {_dst} (ffmpeg)",
55
+ lossy=(_dst in ("mp3", "aac", "ogg", "m4a")),
56
+ options=OPTIONS,
57
+ supports_progress=True,
58
+ )(_audio_convert)
@@ -0,0 +1,28 @@
1
+ """converters/csv_json.py — csv <-> json, native pandas implementation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ import pandas as pd
9
+
10
+ from ..registry import ConversionResult, register
11
+
12
+
13
+ @register("csv", "json", backend="native", family="data", description="csv → json (records)")
14
+ def csv_to_json(input_path: Path, output_path: Path, **options) -> ConversionResult:
15
+ df = pd.read_csv(input_path, dtype=str, keep_default_na=True)
16
+ records = json.loads(df.to_json(orient="records", date_format="iso"))
17
+ output_path.parent.mkdir(parents=True, exist_ok=True)
18
+ output_path.write_text(json.dumps(records, indent=2, ensure_ascii=False), encoding="utf-8")
19
+ return ConversionResult(output=output_path, rows=len(df))
20
+
21
+
22
+ @register("json", "csv", backend="native", family="data", description="json (list of records) → csv", lossy=True)
23
+ def json_to_csv(input_path: Path, output_path: Path, **options) -> ConversionResult:
24
+ data = json.loads(input_path.read_text(encoding="utf-8"))
25
+ df = pd.json_normalize(data)
26
+ output_path.parent.mkdir(parents=True, exist_ok=True)
27
+ df.to_csv(output_path, index=False)
28
+ return ConversionResult(output=output_path, rows=len(df))
@@ -0,0 +1,290 @@
1
+ """
2
+ converters/csv_to_xlsx.py — csv -> xlsx, a fully self-contained converter.
3
+
4
+ Everything needed to turn a CSV into a polished, styled workbook lives in
5
+ this one file: encoding/delimiter sniffing, type inference, styling engine,
6
+ and the register() call with its full OptionSpec flag surface. No hidden
7
+ private engine module — what you see here is what runs.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import csv
13
+ from datetime import datetime
14
+ from pathlib import Path
15
+ from typing import Optional, cast
16
+
17
+ import chardet
18
+ import openpyxl
19
+ import openpyxl.cell
20
+ import pandas as pd
21
+ from openpyxl.styles import Alignment, Border, Font, PatternFill, Side
22
+ from openpyxl.utils import get_column_letter
23
+ from openpyxl.worksheet.table import Table, TableStyleInfo
24
+
25
+ from ..registry import ConversionResult, OptionSpec, register
26
+
27
+ # ─────────────────────────── helpers ────────────────────────────────────────
28
+
29
+ def _detect_encoding(path: Path) -> str:
30
+ """Return best-guess encoding using chardet."""
31
+ raw = path.read_bytes()
32
+ result = chardet.detect(raw)
33
+ enc = result.get("encoding") or "utf-8"
34
+ if enc.lower() == "ascii":
35
+ enc = "utf-8"
36
+ return enc
37
+
38
+
39
+ def _detect_delimiter(path: Path, encoding: str) -> str:
40
+ """Sniff the CSV delimiter; fall back to comma."""
41
+ try:
42
+ sample = path.read_text(encoding=encoding, errors="replace")[:8192]
43
+ dialect = csv.Sniffer().sniff(sample, delimiters=",;\t|")
44
+ return dialect.delimiter
45
+ except csv.Error:
46
+ return ","
47
+
48
+
49
+ def _infer_types(df: pd.DataFrame, date_input_fmt: Optional[str] = None) -> pd.DataFrame:
50
+ """Coerce every column to the tightest appropriate type:
51
+ bool -> bool, numeric -> float/int, datetime -> datetime, else str."""
52
+ bool_map: dict[str, bool] = {
53
+ "true": True, "false": False,
54
+ "yes": True, "no": False,
55
+ "1": True, "0": False,
56
+ }
57
+
58
+ for col in df.columns:
59
+ series: pd.Series = cast(pd.Series, df[col])
60
+
61
+ lower: pd.Series = cast(pd.Series, series.dropna()).astype(str).str.strip().str.lower()
62
+ if len(lower) > 0 and lower.isin(bool_map).all():
63
+ _bm = bool_map
64
+ df[col] = series.map(
65
+ lambda v, bm=_bm: bm.get(str(v).strip().lower(), v) # type: ignore[return-value]
66
+ if pd.notna(v) else v # type: ignore[arg-type]
67
+ )
68
+ continue
69
+
70
+ num: pd.Series = pd.to_numeric(series, errors="coerce") # type: ignore[assignment]
71
+ if num.notna().sum() / max(len(series), 1) >= 0.9:
72
+ df[col] = num
73
+ continue
74
+
75
+ try:
76
+ if date_input_fmt:
77
+ dt: pd.Series = pd.to_datetime( # type: ignore[assignment]
78
+ series, format=date_input_fmt, errors="coerce"
79
+ )
80
+ else:
81
+ dt = pd.to_datetime(series, format="mixed", errors="coerce") # type: ignore[assignment]
82
+ if dt.notna().sum() / max(len(series), 1) >= 0.8:
83
+ df[col] = dt
84
+ continue
85
+ except Exception:
86
+ pass
87
+
88
+ return df
89
+
90
+
91
+ def _px_width(text: str) -> float:
92
+ return max(len(str(text)) + 2, 10)
93
+
94
+
95
+ def _apply_table_style(ws, n_rows: int, n_cols: int, table_style: str = "TableStyleMedium9") -> None:
96
+ if n_rows < 1:
97
+ return
98
+ ref = f"A1:{get_column_letter(n_cols)}{n_rows + 1}"
99
+ tbl = Table(displayName=f"Table_{ws.title.replace(' ', '_')}", ref=ref)
100
+ style = TableStyleInfo(name=table_style, showFirstColumn=False, showLastColumn=False,
101
+ showRowStripes=True, showColumnStripes=False)
102
+ tbl.tableStyleInfo = style
103
+ ws.add_table(tbl)
104
+
105
+
106
+ def _style_header(ws, n_cols: int, header_bg: str = "1F3864", header_fg: str = "FFFFFF") -> None:
107
+ fill = PatternFill("solid", fgColor=header_bg)
108
+ font = Font(bold=True, color=header_fg, size=11)
109
+ alignment = Alignment(horizontal="center", vertical="center", wrap_text=True)
110
+ border = Border(bottom=Side(style="medium", color="FFFFFF"))
111
+ for col_idx in range(1, n_cols + 1):
112
+ cell = ws.cell(row=1, column=col_idx)
113
+ cell.fill = fill
114
+ cell.font = font
115
+ cell.alignment = alignment
116
+ cell.border = border
117
+
118
+
119
+ def _apply_row_banding(ws, n_rows: int, n_cols: int, band_color: str = "EEF2FF") -> None:
120
+ fill = PatternFill("solid", fgColor=band_color)
121
+ for row_idx in range(3, n_rows + 2, 2):
122
+ for col_idx in range(1, n_cols + 1):
123
+ ws.cell(row=row_idx, column=col_idx).fill = fill
124
+
125
+
126
+ def _autofit_columns(ws, df: pd.DataFrame) -> None:
127
+ for col_idx, col_name in enumerate(df.columns, start=1):
128
+ col_letter = get_column_letter(col_idx)
129
+ max_w = _px_width(col_name)
130
+ for val in df[col_name]:
131
+ max_w = max(max_w, _px_width(val))
132
+ ws.column_dimensions[col_letter].width = min(max_w, 60)
133
+
134
+
135
+ def _apply_number_formats(ws, df: pd.DataFrame) -> None:
136
+ for col_idx, col_name in enumerate(df.columns, start=1):
137
+ dtype = df[col_name].dtype
138
+ fmt = None
139
+ if pd.api.types.is_datetime64_any_dtype(dtype):
140
+ fmt = "YYYY-MM-DD HH:MM:SS"
141
+ elif pd.api.types.is_float_dtype(dtype):
142
+ fmt = "#,##0.00"
143
+ elif pd.api.types.is_integer_dtype(dtype):
144
+ fmt = "#,##0"
145
+ if fmt:
146
+ for row_idx in range(2, ws.max_row + 1):
147
+ ws.cell(row=row_idx, column=col_idx).number_format = fmt
148
+
149
+
150
+ def _write_workbook(
151
+ input_path: Path, output_path: Path, *,
152
+ sheet_name: str = "Sheet1", delimiter: Optional[str] = None, encoding: Optional[str] = None,
153
+ header: bool = True, infer_types: bool = True, date_input_fmt: Optional[str] = None,
154
+ freeze_header: bool = True, freeze_cols: int = 0, add_table: bool = True,
155
+ table_style: str = "TableStyleMedium9", row_banding: bool = True, band_color: str = "EEF2FF",
156
+ header_bg: str = "1F3864", header_fg: str = "FFFFFF", autofit: bool = True,
157
+ number_formats: bool = True, zoom: int = 100, row_height: Optional[float] = None,
158
+ password: Optional[str] = None, workbook_password: Optional[str] = None,
159
+ append_to_existing: bool = False, skiprows: int = 0, nrows: Optional[int] = None,
160
+ usecols: Optional[str] = None,
161
+ ) -> dict:
162
+ """Convert a single CSV file to a styled Excel sheet. Returns {rows, cols, sheet, output}."""
163
+ enc = encoding or _detect_encoding(input_path)
164
+ delim = delimiter or _detect_delimiter(input_path, enc)
165
+
166
+ read_kwargs: dict = dict(
167
+ filepath_or_buffer=input_path, sep=delim, encoding=enc,
168
+ header=0 if header else None, skiprows=skiprows if skiprows else None,
169
+ nrows=nrows, dtype=str, keep_default_na=True, on_bad_lines="warn",
170
+ )
171
+ if usecols:
172
+ cols = [c.strip() for c in usecols.split(",")]
173
+ try:
174
+ cols = [int(c) for c in cols]
175
+ except ValueError:
176
+ pass
177
+ read_kwargs["usecols"] = cols
178
+
179
+ df = pd.read_csv(**read_kwargs)
180
+
181
+ if infer_types:
182
+ df = _infer_types(df, date_input_fmt=date_input_fmt)
183
+
184
+ n_rows, n_cols = df.shape
185
+
186
+ if append_to_existing and output_path.exists():
187
+ wb = openpyxl.load_workbook(output_path)
188
+ existing = wb.sheetnames
189
+ base = sheet_name
190
+ suffix = 1
191
+ while sheet_name in existing:
192
+ sheet_name = f"{base}_{suffix}"
193
+ suffix += 1
194
+ ws = wb.create_sheet(title=sheet_name)
195
+ else:
196
+ wb = openpyxl.Workbook()
197
+ ws = wb.active
198
+ assert ws is not None
199
+ ws.title = sheet_name
200
+
201
+ if header:
202
+ for col_idx, col_name in enumerate(df.columns, start=1):
203
+ ws.cell(row=1, column=col_idx, value=str(col_name))
204
+
205
+ for row_idx, row in enumerate(df.itertuples(index=False), start=2):
206
+ for col_idx, value in enumerate(row, start=1):
207
+ cell = cast(openpyxl.cell.Cell, ws.cell(row=row_idx, column=col_idx))
208
+ if pd.isna(value) if not isinstance(value, str) else False: # type: ignore[arg-type]
209
+ cell.value = None
210
+ elif isinstance(value, (int, float, bool)):
211
+ cell.value = value
212
+ elif isinstance(value, datetime):
213
+ cell.value = value
214
+ else:
215
+ cell.value = str(value) if value is not None else None
216
+
217
+ if header and n_rows > 0:
218
+ _style_header(ws, n_cols, header_bg=header_bg, header_fg=header_fg)
219
+ if row_banding and n_rows > 0:
220
+ _apply_row_banding(ws, n_rows, n_cols, band_color=band_color)
221
+ if autofit:
222
+ _autofit_columns(ws, df)
223
+ if number_formats:
224
+ _apply_number_formats(ws, df)
225
+
226
+ if freeze_header or freeze_cols > 0:
227
+ freeze_row = 2 if freeze_header and header else 1
228
+ freeze_col = freeze_cols + 1
229
+ ws.freeze_panes = f"{get_column_letter(freeze_col)}{freeze_row}"
230
+
231
+ if add_table and header and n_rows > 0:
232
+ _apply_table_style(ws, n_rows, n_cols, table_style=table_style)
233
+
234
+ ws.row_dimensions[1].height = 20
235
+ if row_height is not None:
236
+ for row_idx in range(2, n_rows + 2):
237
+ ws.row_dimensions[row_idx].height = row_height
238
+
239
+ ws.sheet_view.zoomScale = max(10, min(zoom, 400))
240
+
241
+ if password:
242
+ ws.protection.sheet = True
243
+ ws.protection.password = password
244
+ ws.protection.enable()
245
+
246
+ if workbook_password:
247
+ wb.security.workbookPassword = workbook_password
248
+ wb.security.lockStructure = True
249
+
250
+ output_path.parent.mkdir(parents=True, exist_ok=True)
251
+ wb.save(output_path)
252
+
253
+ return {"rows": n_rows, "cols": n_cols, "sheet": ws.title, "output": output_path}
254
+
255
+
256
+ # ─────────────────────────── registry wiring ────────────────────────────────
257
+
258
+ OPTIONS = [
259
+ OptionSpec("delimiter", ("-d", "--delimiter"), "Field delimiter. Auto-detected if omitted."),
260
+ OptionSpec("encoding", ("-e", "--encoding"), "Input encoding. Auto-detected if omitted."),
261
+ OptionSpec("header", ("--no-header",), "Treat first row as data, not a header.", default=True, action="store_false"),
262
+ OptionSpec("skiprows", ("--skip-rows",), "Rows to skip at the top of the CSV.", default=0, type=int),
263
+ OptionSpec("nrows", ("--nrows",), "Maximum data rows to read.", type=int),
264
+ OptionSpec("usecols", ("--cols",), "Comma-separated column names or indices to include."),
265
+ OptionSpec("date_input_fmt", ("--date-fmt-in",), "strftime format of dates in the CSV."),
266
+ OptionSpec("sheet_name", ("-s", "--sheet"), "Sheet name in the output workbook.", default="Sheet1"),
267
+ OptionSpec("infer_types", ("--no-infer",), "Disable smart type inference.", default=True, action="store_false"),
268
+ OptionSpec("freeze_header", ("--no-freeze",), "Disable frozen header row.", default=True, action="store_false"),
269
+ OptionSpec("freeze_cols", ("--freeze-cols",), "Left-most columns to freeze.", default=0, type=int),
270
+ OptionSpec("add_table", ("--no-table",), "Disable Excel Table / auto-filter.", default=True, action="store_false"),
271
+ OptionSpec("table_style", ("--table-style",), "Excel table style name.", default="TableStyleMedium9"),
272
+ OptionSpec("row_banding", ("--no-banding",), "Disable alternating row banding.", default=True, action="store_false"),
273
+ OptionSpec("band_color", ("--band-color",), "Hex colour for banded rows (no #).", default="EEF2FF"),
274
+ OptionSpec("autofit", ("--no-autofit",), "Disable auto column width.", default=True, action="store_false"),
275
+ OptionSpec("number_formats", ("--no-formats",), "Disable number/date format strings.", default=True, action="store_false"),
276
+ OptionSpec("header_bg", ("--header-bg",), "Header background hex colour (no #).", default="1F3864"),
277
+ OptionSpec("header_fg", ("--header-fg",), "Header text hex colour (no #).", default="FFFFFF"),
278
+ OptionSpec("zoom", ("--zoom",), "Sheet zoom level (10-400).", default=100, type=int),
279
+ OptionSpec("row_height", ("--row-height",), "Data row height in points.", type=float),
280
+ OptionSpec("password", ("--password",), "Password-protect the output sheet."),
281
+ OptionSpec("workbook_password", ("--wb-password",), "Password-protect the workbook structure."),
282
+ OptionSpec("append_to_existing", ("-a", "--append"), "Append as a new sheet into an existing workbook.",
283
+ default=False, action="store_true"),
284
+ ]
285
+
286
+ @register("csv", "xlsx", backend="native", family="data",
287
+ description="csv → xlsx (styled workbook)", options=OPTIONS)
288
+ def csv_to_xlsx(input_path: Path, output_path: Path, **options) -> ConversionResult:
289
+ stats = _write_workbook(input_path, output_path, **options)
290
+ return ConversionResult(output=stats["output"], rows=stats["rows"])