fylepy 0.1.0__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.
Files changed (44) hide show
  1. fyle/__init__.py +46 -0
  2. fyle/_core/__init__.py +5 -0
  3. fyle/_core/api.py +164 -0
  4. fyle/_core/chunking.py +107 -0
  5. fyle/_core/document.py +345 -0
  6. fyle/_core/fetcher.py +68 -0
  7. fyle/_core/registry.py +107 -0
  8. fyle/_core/sniffer.py +251 -0
  9. fyle/_readers/__init__.py +32 -0
  10. fyle/_readers/_md_structure.py +208 -0
  11. fyle/_readers/_whisper.py +126 -0
  12. fyle/_readers/archive/__init__.py +8 -0
  13. fyle/_readers/archive/stdlib.py +513 -0
  14. fyle/_readers/audio/__init__.py +9 -0
  15. fyle/_readers/audio/faster_whisper.py +162 -0
  16. fyle/_readers/base.py +70 -0
  17. fyle/_readers/csv/__init__.py +6 -0
  18. fyle/_readers/csv/stdlib.py +119 -0
  19. fyle/_readers/docx/__init__.py +6 -0
  20. fyle/_readers/docx/mammoth.py +130 -0
  21. fyle/_readers/html/__init__.py +6 -0
  22. fyle/_readers/html/markdownify.py +113 -0
  23. fyle/_readers/image/__init__.py +18 -0
  24. fyle/_readers/image/stdlib.py +136 -0
  25. fyle/_readers/markdown/__init__.py +6 -0
  26. fyle/_readers/markdown/stdlib.py +61 -0
  27. fyle/_readers/pdf/__init__.py +2 -0
  28. fyle/_readers/pdf/pymupdf4llm.py +202 -0
  29. fyle/_readers/pptx/__init__.py +7 -0
  30. fyle/_readers/pptx/python_pptx.py +306 -0
  31. fyle/_readers/sqlite/__init__.py +8 -0
  32. fyle/_readers/sqlite/stdlib.py +366 -0
  33. fyle/_readers/text/__init__.py +7 -0
  34. fyle/_readers/text/stdlib.py +76 -0
  35. fyle/_readers/video/__init__.py +10 -0
  36. fyle/_readers/video/scenedetect.py +330 -0
  37. fyle/_readers/xlsx/__init__.py +6 -0
  38. fyle/_readers/xlsx/openpyxl.py +158 -0
  39. fyle/errors.py +42 -0
  40. fyle/sqlite.py +175 -0
  41. fylepy-0.1.0.dist-info/METADATA +272 -0
  42. fylepy-0.1.0.dist-info/RECORD +44 -0
  43. fylepy-0.1.0.dist-info/WHEEL +4 -0
  44. fylepy-0.1.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,126 @@
1
+ """Shared Whisper-transcription helpers used by the audio and video readers.
2
+
3
+ Both readers transcribe audio through ``faster-whisper``. Keeping the
4
+ model-loading and transcript-formatting logic here avoids two almost
5
+ identical copies and ensures the same model instance can be reused
6
+ across calls within a single process.
7
+
8
+ Design notes
9
+ ------------
10
+ - **Backend**: ``faster-whisper`` on CPU with int8 quantisation. CPU +
11
+ int8 is ~4x faster than the original openai-whisper on a typical
12
+ laptop, while keeping the total install footprint (CTranslate2 wheel
13
+ + PyAV + onnxruntime) around 90 MB. No PyTorch, no CUDA assumption.
14
+ - **Model**: hard-coded to ``base`` (~140 MB). Small enough to download
15
+ in under a minute on residential broadband, accurate enough for most
16
+ spoken content including Chinese and English. Developers who need a
17
+ different size can override via the ``FYLE_WHISPER_MODEL`` env var.
18
+ - **Caching**: delegated to faster-whisper / huggingface_hub, which
19
+ stores model weights under ``~/.cache/huggingface/hub/``. First call
20
+ triggers a one-time download; subsequent calls are fully offline.
21
+ - **Lazy import**: ``faster_whisper`` is imported inside the helper
22
+ functions, not at module top. This lets ``import fyle`` succeed in
23
+ environments that installed the base package without the ``audio`` /
24
+ ``video`` extras; the informative ImportError surfaces only when the
25
+ user actually tries to open an audio / video file.
26
+ """
27
+ from __future__ import annotations
28
+
29
+ import os
30
+ from functools import lru_cache
31
+ from typing import TYPE_CHECKING, Optional
32
+
33
+ from ..errors import NotImplementedReaderError
34
+
35
+ if TYPE_CHECKING:
36
+ from faster_whisper import WhisperModel
37
+
38
+
39
+ _DEFAULT_MODEL = "base"
40
+ _ENV_MODEL = "FYLE_WHISPER_MODEL"
41
+
42
+
43
+ def _require_faster_whisper():
44
+ """Lazy-import faster_whisper, raising a helpful error if absent."""
45
+ try:
46
+ import faster_whisper # noqa: F401 — imported for side-effect availability
47
+ except ImportError as e:
48
+ raise NotImplementedReaderError(
49
+ "Transcription requires the 'faster-whisper' package. "
50
+ "Install the optional extra: pip install 'fyle[audio]' "
51
+ "(or 'fyle[video]' for video support)."
52
+ ) from e
53
+ return faster_whisper
54
+
55
+
56
+ @lru_cache(maxsize=1)
57
+ def _get_model(size: str) -> "WhisperModel":
58
+ """Load (and memoise) the Whisper model.
59
+
60
+ Cached per-size for the life of the process — transcribing ten files
61
+ in a loop should not reload the 140 MB model ten times.
62
+ """
63
+ fw = _require_faster_whisper()
64
+ return fw.WhisperModel(size, device="cpu", compute_type="int8")
65
+
66
+
67
+ def load_model(size: Optional[str] = None) -> "WhisperModel":
68
+ """Return the Whisper model, honouring ``FYLE_WHISPER_MODEL`` override."""
69
+ chosen = size or os.environ.get(_ENV_MODEL) or _DEFAULT_MODEL
70
+ return _get_model(chosen)
71
+
72
+
73
+ def model_size() -> str:
74
+ """Return the active model-size string (for ``meta.warnings`` reporting)."""
75
+ return os.environ.get(_ENV_MODEL) or _DEFAULT_MODEL
76
+
77
+
78
+ def transcribe(path: str) -> tuple[list[dict], dict]:
79
+ """Transcribe ``path`` and return ``(segments, info)``.
80
+
81
+ ``segments`` is a list of ``{"start", "end", "text"}`` dicts (floats
82
+ for timestamps, seconds). ``info`` is a dict with ``language``,
83
+ ``language_probability`` and ``duration``.
84
+
85
+ Exhausts the faster-whisper generator eagerly so callers can reason
86
+ about the result as plain data.
87
+ """
88
+ model = load_model()
89
+ # ``beam_size=1`` keeps it fast (greedy). Agents don't need the small
90
+ # WER boost from beam_size=5 given the quality/speed trade-off.
91
+ seg_iter, info = model.transcribe(path, beam_size=1, vad_filter=False)
92
+ segments = [
93
+ {"start": float(s.start), "end": float(s.end), "text": s.text.strip()}
94
+ for s in seg_iter
95
+ ]
96
+ info_dict = {
97
+ "language": getattr(info, "language", None),
98
+ "language_probability": float(getattr(info, "language_probability", 0.0)),
99
+ "duration": float(getattr(info, "duration", 0.0)),
100
+ }
101
+ return segments, info_dict
102
+
103
+
104
+ def format_timestamp(seconds: float) -> str:
105
+ """Format seconds as ``HH:MM:SS`` (dropping the hour if under an hour)."""
106
+ if seconds < 0 or seconds != seconds: # handles NaN too
107
+ seconds = 0.0
108
+ total = int(seconds)
109
+ hours, rem = divmod(total, 3600)
110
+ minutes, secs = divmod(rem, 60)
111
+ if hours > 0:
112
+ return f"{hours:02d}:{minutes:02d}:{secs:02d}"
113
+ return f"{minutes:02d}:{secs:02d}"
114
+
115
+
116
+ def format_transcript(segments: list[dict]) -> str:
117
+ """Render segments as ``[MM:SS] text`` lines separated by blank lines.
118
+
119
+ Blank lines between segments keep the transcript readable and give
120
+ LLM chunkers a natural split point when segmentation matters.
121
+ """
122
+ if not segments:
123
+ return "_(no speech detected)_"
124
+ return "\n\n".join(
125
+ f"[{format_timestamp(s['start'])}] {s['text']}" for s in segments if s["text"]
126
+ ) or "_(no speech detected)_"
@@ -0,0 +1,8 @@
1
+ """Archive reader — extract to disk and report a Markdown listing.
2
+
3
+ File naming rule: ``stdlib.py`` — the core drivers are Python's built-in
4
+ ``zipfile``, ``tarfile`` and ``gzip`` modules.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from . import stdlib # noqa: F401
@@ -0,0 +1,513 @@
1
+ """Archive reader backed by Python's standard library (``zipfile`` / ``tarfile`` / ``gzip``).
2
+
3
+ Philosophy
4
+ ----------
5
+ This reader does **one** thing: turn an archive file on disk into a
6
+ directory of extracted files, and report that outcome as Markdown.
7
+
8
+ It does **not**:
9
+
10
+ - recurse into inner archives,
11
+ - parse inner files (no ``fyle.open`` chaining),
12
+ - expose a Python fluent API like ``doc.file(path)`` or ``doc.extracted_to``.
13
+
14
+ The rationale is Unix-style tool composition: an LLM agent that needs the
15
+ contents of ``data/sales.csv`` inside ``archive.zip`` will read the text
16
+ (``Extracted to: .../archive/``), decide for itself, and issue a fresh
17
+ ``fyle.open("archive/data/sales.csv")`` call. fyle is a file reader, not
18
+ a file-tree orchestrator.
19
+
20
+ Extraction location
21
+ -------------------
22
+ - When the source is a real local file, we extract **into the source's
23
+ own directory**: ``~/Downloads/pack.zip`` → ``~/Downloads/pack/``.
24
+ - When the source is a URL / raw bytes / a file-like object without a
25
+ path, we fall back to ``Path.cwd()``.
26
+ - If the destination directory already exists, we append ``-2`` / ``-3``
27
+ / ... until we find a free name. We never overwrite, never merge.
28
+
29
+ Safety
30
+ ------
31
+ Two CVE-grade defences always apply:
32
+
33
+ 1. **Path traversal**: every archive member's resolved absolute path
34
+ must remain inside the destination directory. Escapees are dropped
35
+ with a warning.
36
+ 2. **Symlinks**: tar archives can ship symlinks; we refuse to create
37
+ any symlink entry and instead record a warning. Zip has no native
38
+ symlink representation so this only applies to tar.
39
+
40
+ Everything else the user may fear (zip bombs, .exe members, nested
41
+ archives, 10 GB files) is deliberately *not* policed here. The caller
42
+ has the final say — fyle does not decide what files are "safe" to
43
+ unpack.
44
+
45
+ File naming rule: ``stdlib.py`` — powered by Python's ``zipfile``,
46
+ ``tarfile`` and ``gzip`` modules; no third-party dependency.
47
+ """
48
+ from __future__ import annotations
49
+
50
+ import gzip
51
+ import io
52
+ import os
53
+ import tarfile
54
+ import zipfile
55
+ from datetime import datetime, timezone
56
+ from pathlib import Path
57
+ from typing import Iterable, Optional
58
+
59
+ from ..base import Reader
60
+ from ..._core.document import Document, Meta, Page
61
+ from ...errors import ParseError
62
+
63
+
64
+ # Recognised compound extensions (two-segment) that the archive reader
65
+ # should collapse when deriving the destination directory name.
66
+ # ``data.tar.gz`` → ``data`` (strip both segments), not ``data.tar``.
67
+ _COMPOUND_SUFFIXES = (
68
+ ".tar.gz",
69
+ ".tar.bz2",
70
+ ".tar.xz",
71
+ ".tgz",
72
+ ".tbz2",
73
+ ".txz",
74
+ )
75
+
76
+
77
+ class ArchiveEntry:
78
+ """Lightweight record of one extracted member (purely for listing)."""
79
+
80
+ __slots__ = ("path", "size", "modified", "kind")
81
+
82
+ def __init__(
83
+ self,
84
+ path: str,
85
+ size: int,
86
+ modified: Optional[datetime],
87
+ kind: str,
88
+ ) -> None:
89
+ self.path = path
90
+ self.size = size
91
+ self.modified = modified
92
+ self.kind = kind # "file" | "dir"
93
+
94
+
95
+ class ArchiveReader(Reader):
96
+ name = "archive-stdlib"
97
+ formats = ("archive",)
98
+ is_default = True
99
+
100
+ def read(
101
+ self,
102
+ data: bytes,
103
+ *,
104
+ source_name: Optional[str] = None,
105
+ source_path: Optional[str] = None,
106
+ **_,
107
+ ) -> Document:
108
+ if not data:
109
+ raise ParseError("archive-stdlib reader: input is empty")
110
+
111
+ warnings: list[str] = []
112
+ kind = _detect_kind(data, source_name)
113
+ if kind is None:
114
+ raise ParseError(
115
+ "archive-stdlib reader: could not detect archive type "
116
+ f"(source_name={source_name!r})"
117
+ )
118
+
119
+ # Decide destination directory: same dir as source file, else cwd.
120
+ dest_parent = _destination_parent(source_path)
121
+ base_name = _base_name_from_source(source_name, kind)
122
+ dest_dir = _unique_dir(dest_parent / base_name)
123
+ dest_dir.mkdir(parents=True, exist_ok=False)
124
+
125
+ # Extract.
126
+ try:
127
+ if kind == "zip":
128
+ entries = _extract_zip(data, dest_dir, warnings)
129
+ elif kind == "tar":
130
+ entries = _extract_tar(data, dest_dir, mode="r:*", warnings=warnings)
131
+ elif kind == "gz-single":
132
+ entries = _extract_gzip_single(data, dest_dir, source_name, warnings)
133
+ else: # pragma: no cover - defensive
134
+ raise ParseError(f"archive-stdlib reader: unhandled kind {kind!r}")
135
+ except ParseError:
136
+ raise
137
+ except Exception as e:
138
+ raise ParseError(f"archive-stdlib reader: extraction failed: {e}") from e
139
+
140
+ # Build the Markdown report.
141
+ md = _render_report(
142
+ archive_name=source_name or "(unnamed archive)",
143
+ dest_dir=dest_dir,
144
+ entries=entries,
145
+ )
146
+ page = Page(text=md, number=1)
147
+
148
+ # Pick a stable, human-friendly ext for ``meta.ext``. The dispatcher
149
+ # would otherwise fill in just the last suffix (e.g. ``gz`` for
150
+ # ``.tar.gz``), which hides the real shape of the file.
151
+ ext = _canonical_ext(source_name)
152
+
153
+ meta = Meta(
154
+ format="archive",
155
+ ext=ext,
156
+ pages=1,
157
+ size=len(data),
158
+ title=Path(source_name).stem if source_name else None,
159
+ reader=self.name,
160
+ created_at=datetime.now(timezone.utc),
161
+ warnings=warnings,
162
+ )
163
+ return Document(pages=[page], meta=meta)
164
+
165
+
166
+ # ----------------------------------------------------------------------
167
+ # Helpers
168
+ # ----------------------------------------------------------------------
169
+
170
+ def _detect_kind(data: bytes, source_name: Optional[str]) -> Optional[str]:
171
+ """Return ``"zip"`` / ``"tar"`` / ``"gz-single"`` or ``None``.
172
+
173
+ We prefer magic bytes, then fall back to extension. ``gz-single`` means
174
+ a bare gzip wrapping a single non-tar payload (``.gz`` without ``.tar``).
175
+ """
176
+ # ZIP magic.
177
+ if data.startswith((b"PK\x03\x04", b"PK\x05\x06", b"PK\x07\x08")):
178
+ return "zip"
179
+
180
+ # Uncompressed tar: offset 257 holds "ustar".
181
+ if len(data) >= 263 and data[257:262] in (b"ustar", b"ustar\x00"):
182
+ return "tar"
183
+
184
+ # Gzipped payload. Could be ``.tar.gz`` or a standalone ``.gz``.
185
+ if data.startswith(b"\x1f\x8b"):
186
+ name = (source_name or "").lower()
187
+ if name.endswith((".tar.gz", ".tgz")):
188
+ return "tar"
189
+ # Try treating as tar.gz first (many tar.gz files are named .gz
190
+ # in the wild); fall back to single-file gzip on failure.
191
+ try:
192
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:gz"):
193
+ return "tar"
194
+ except (tarfile.TarError, OSError):
195
+ return "gz-single"
196
+
197
+ # Bzip2.
198
+ if data.startswith(b"BZh"):
199
+ name = (source_name or "").lower()
200
+ if name.endswith((".tar.bz2", ".tbz2")):
201
+ return "tar"
202
+ try:
203
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:bz2"):
204
+ return "tar"
205
+ except (tarfile.TarError, OSError):
206
+ return None
207
+
208
+ # xz.
209
+ if data.startswith(b"\xfd7zXZ\x00"):
210
+ name = (source_name or "").lower()
211
+ if name.endswith((".tar.xz", ".txz")):
212
+ return "tar"
213
+ try:
214
+ with tarfile.open(fileobj=io.BytesIO(data), mode="r:xz"):
215
+ return "tar"
216
+ except (tarfile.TarError, OSError):
217
+ return None
218
+
219
+ # Extension-only fallbacks (rare: e.g. empty / truncated archives).
220
+ name = (source_name or "").lower()
221
+ if name.endswith(".zip"):
222
+ return "zip"
223
+ if name.endswith((".tar", ".tar.gz", ".tgz", ".tar.bz2", ".tbz2", ".tar.xz", ".txz")):
224
+ return "tar"
225
+ if name.endswith(".gz"):
226
+ return "gz-single"
227
+ return None
228
+
229
+
230
+ def _destination_parent(source_path: Optional[str]) -> Path:
231
+ """Where to place the extracted directory.
232
+
233
+ Local files extract into their own directory; URLs / bytes extract
234
+ into the current working directory.
235
+ """
236
+ if source_path:
237
+ try:
238
+ p = Path(source_path).resolve()
239
+ if p.is_file():
240
+ return p.parent
241
+ except OSError:
242
+ pass
243
+ return Path.cwd()
244
+
245
+
246
+ def _base_name_from_source(source_name: Optional[str], kind: str) -> str:
247
+ """Derive the extracted directory's base name (no path, no extension).
248
+
249
+ Compound suffixes like ``.tar.gz`` are stripped in one go so
250
+ ``data.tar.gz`` becomes ``data`` rather than ``data.tar``.
251
+ """
252
+ if not source_name:
253
+ return "fyle-archive" if kind != "gz-single" else "fyle-gzip"
254
+ lower = source_name.lower()
255
+ for suf in _COMPOUND_SUFFIXES:
256
+ if lower.endswith(suf):
257
+ return source_name[: -len(suf)] or "fyle-archive"
258
+ return Path(source_name).stem or "fyle-archive"
259
+
260
+
261
+ def _unique_dir(candidate: Path) -> Path:
262
+ """Return ``candidate`` if free, otherwise append ``-2`` / ``-3`` / ..."""
263
+ if not candidate.exists():
264
+ return candidate
265
+ for i in range(2, 10_000):
266
+ alt = candidate.with_name(f"{candidate.name}-{i}")
267
+ if not alt.exists():
268
+ return alt
269
+ raise ParseError(
270
+ f"archive-stdlib reader: could not find a free destination near {candidate}"
271
+ )
272
+
273
+
274
+ def _canonical_ext(source_name: Optional[str]) -> Optional[str]:
275
+ """Return a user-facing ext string (``zip`` / ``tar.gz`` / ...)."""
276
+ if not source_name:
277
+ return None
278
+ lower = source_name.lower()
279
+ for suf in _COMPOUND_SUFFIXES:
280
+ if lower.endswith(suf):
281
+ return suf.lstrip(".")
282
+ return Path(lower).suffix.lstrip(".") or None
283
+
284
+
285
+ def _is_inside(dest: Path, candidate: Path) -> bool:
286
+ """True iff ``candidate`` (resolved) is inside ``dest`` (resolved)."""
287
+ try:
288
+ candidate.resolve().relative_to(dest.resolve())
289
+ return True
290
+ except (ValueError, OSError):
291
+ return False
292
+
293
+
294
+ def _safe_join(dest: Path, member_name: str) -> Optional[Path]:
295
+ """Join ``dest`` and ``member_name``, rejecting traversal attempts."""
296
+ # Normalise leading slashes and drive letters; treat as relative.
297
+ clean = member_name.replace("\\", "/").lstrip("/")
298
+ if not clean:
299
+ return None
300
+ target = (dest / clean).resolve()
301
+ if not _is_inside(dest, target):
302
+ return None
303
+ return target
304
+
305
+
306
+ # ----------------------------------------------------------------------
307
+ # Extractors
308
+ # ----------------------------------------------------------------------
309
+
310
+ def _extract_zip(
311
+ data: bytes,
312
+ dest: Path,
313
+ warnings: list[str],
314
+ ) -> list[ArchiveEntry]:
315
+ entries: list[ArchiveEntry] = []
316
+ with zipfile.ZipFile(io.BytesIO(data)) as zf:
317
+ for info in zf.infolist():
318
+ target = _safe_join(dest, info.filename)
319
+ if target is None:
320
+ warnings.append(f"archive: skipped path traversal entry {info.filename!r}")
321
+ continue
322
+
323
+ if info.is_dir():
324
+ target.mkdir(parents=True, exist_ok=True)
325
+ entries.append(
326
+ ArchiveEntry(
327
+ path=_relative(target, dest),
328
+ size=0,
329
+ modified=_zip_mtime(info),
330
+ kind="dir",
331
+ )
332
+ )
333
+ continue
334
+
335
+ target.parent.mkdir(parents=True, exist_ok=True)
336
+ with zf.open(info, "r") as src, open(target, "wb") as out:
337
+ _copy_stream(src, out)
338
+ entries.append(
339
+ ArchiveEntry(
340
+ path=_relative(target, dest),
341
+ size=info.file_size,
342
+ modified=_zip_mtime(info),
343
+ kind="file",
344
+ )
345
+ )
346
+ return entries
347
+
348
+
349
+ def _extract_tar(
350
+ data: bytes,
351
+ dest: Path,
352
+ *,
353
+ mode: str,
354
+ warnings: list[str],
355
+ ) -> list[ArchiveEntry]:
356
+ entries: list[ArchiveEntry] = []
357
+ with tarfile.open(fileobj=io.BytesIO(data), mode=mode) as tf:
358
+ for member in tf.getmembers():
359
+ if member.issym() or member.islnk():
360
+ warnings.append(
361
+ f"archive: skipped symlink/hardlink entry {member.name!r}"
362
+ )
363
+ continue
364
+ if member.isdev() or member.isfifo():
365
+ warnings.append(f"archive: skipped device/fifo entry {member.name!r}")
366
+ continue
367
+
368
+ target = _safe_join(dest, member.name)
369
+ if target is None:
370
+ warnings.append(f"archive: skipped path traversal entry {member.name!r}")
371
+ continue
372
+
373
+ mtime = (
374
+ datetime.fromtimestamp(member.mtime, tz=timezone.utc)
375
+ if member.mtime
376
+ else None
377
+ )
378
+
379
+ if member.isdir():
380
+ target.mkdir(parents=True, exist_ok=True)
381
+ entries.append(
382
+ ArchiveEntry(
383
+ path=_relative(target, dest),
384
+ size=0,
385
+ modified=mtime,
386
+ kind="dir",
387
+ )
388
+ )
389
+ continue
390
+
391
+ target.parent.mkdir(parents=True, exist_ok=True)
392
+ f = tf.extractfile(member)
393
+ if f is None:
394
+ warnings.append(f"archive: unreadable entry {member.name!r}")
395
+ continue
396
+ try:
397
+ with open(target, "wb") as out:
398
+ _copy_stream(f, out)
399
+ finally:
400
+ f.close()
401
+ entries.append(
402
+ ArchiveEntry(
403
+ path=_relative(target, dest),
404
+ size=member.size,
405
+ modified=mtime,
406
+ kind="file",
407
+ )
408
+ )
409
+ return entries
410
+
411
+
412
+ def _extract_gzip_single(
413
+ data: bytes,
414
+ dest: Path,
415
+ source_name: Optional[str],
416
+ warnings: list[str],
417
+ ) -> list[ArchiveEntry]:
418
+ """Decompress a standalone ``.gz`` (not ``.tar.gz``) into one file."""
419
+ # Pick the inner filename. ``payload.gz`` → ``payload``.
420
+ inner_name = (
421
+ source_name[:-3] if source_name and source_name.lower().endswith(".gz")
422
+ else "payload"
423
+ )
424
+ inner_name = Path(inner_name).name or "payload"
425
+ target = dest / inner_name
426
+ with gzip.GzipFile(fileobj=io.BytesIO(data), mode="rb") as gz:
427
+ payload = gz.read()
428
+ target.write_bytes(payload)
429
+ return [
430
+ ArchiveEntry(
431
+ path=_relative(target, dest),
432
+ size=len(payload),
433
+ modified=None,
434
+ kind="file",
435
+ )
436
+ ]
437
+
438
+
439
+ # ----------------------------------------------------------------------
440
+ # Reporting
441
+ # ----------------------------------------------------------------------
442
+
443
+ def _render_report(
444
+ archive_name: str,
445
+ dest_dir: Path,
446
+ entries: list[ArchiveEntry],
447
+ ) -> str:
448
+ files = [e for e in entries if e.kind == "file"]
449
+ dirs = [e for e in entries if e.kind == "dir"]
450
+ total_size = sum(e.size for e in files)
451
+
452
+ lines: list[str] = [
453
+ f"# Archive: {archive_name}",
454
+ "",
455
+ f"Extracted to: `{dest_dir}`",
456
+ "",
457
+ f"## Contents ({len(files)} files, {len(dirs)} dirs, "
458
+ f"{_format_bytes(total_size)} total)",
459
+ "",
460
+ ]
461
+
462
+ if not entries:
463
+ lines.append("_(archive contained no extractable entries)_")
464
+ return "\n".join(lines)
465
+
466
+ # Sort by path for stable, diff-friendly output.
467
+ entries_sorted = sorted(entries, key=lambda e: e.path)
468
+ lines.append("| path | size | modified |")
469
+ lines.append("|---|---|---|")
470
+ for e in entries_sorted:
471
+ path = e.path + ("/" if e.kind == "dir" and not e.path.endswith("/") else "")
472
+ size = "-" if e.kind == "dir" else _format_bytes(e.size)
473
+ mtime = e.modified.strftime("%Y-%m-%d %H:%M") if e.modified else "-"
474
+ lines.append(f"| {_md_escape(path)} | {size} | {mtime} |")
475
+
476
+ return "\n".join(lines)
477
+
478
+
479
+ def _format_bytes(n: int) -> str:
480
+ if n >= 1024 * 1024 * 1024:
481
+ return f"{n / (1024 ** 3):.2f} GB"
482
+ if n >= 1024 * 1024:
483
+ return f"{n / (1024 * 1024):.2f} MB"
484
+ if n >= 1024:
485
+ return f"{n / 1024:.1f} KB"
486
+ return f"{n} B"
487
+
488
+
489
+ def _md_escape(s: str) -> str:
490
+ return s.replace("|", "\\|")
491
+
492
+
493
+ def _relative(target: Path, base: Path) -> str:
494
+ try:
495
+ return target.resolve().relative_to(base.resolve()).as_posix()
496
+ except (ValueError, OSError):
497
+ return target.as_posix()
498
+
499
+
500
+ def _zip_mtime(info: zipfile.ZipInfo) -> Optional[datetime]:
501
+ try:
502
+ # ZipInfo.date_time is naive; treat as local time.
503
+ return datetime(*info.date_time)
504
+ except (ValueError, TypeError):
505
+ return None
506
+
507
+
508
+ def _copy_stream(src, dst, chunk: int = 1024 * 1024) -> None:
509
+ while True:
510
+ buf = src.read(chunk)
511
+ if not buf:
512
+ break
513
+ dst.write(buf)
@@ -0,0 +1,9 @@
1
+ """Audio reader.
2
+
3
+ File naming rule: ``faster_whisper.py`` — the core driver library.
4
+ The heavy deps (CTranslate2, PyAV, onnxruntime) are optional and only
5
+ required at read-time; see ``pyproject.toml`` ``[project.optional-dependencies].audio``.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from . import faster_whisper # noqa: F401