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.
- fyle/__init__.py +46 -0
- fyle/_core/__init__.py +5 -0
- fyle/_core/api.py +164 -0
- fyle/_core/chunking.py +107 -0
- fyle/_core/document.py +345 -0
- fyle/_core/fetcher.py +68 -0
- fyle/_core/registry.py +107 -0
- fyle/_core/sniffer.py +251 -0
- fyle/_readers/__init__.py +32 -0
- fyle/_readers/_md_structure.py +208 -0
- fyle/_readers/_whisper.py +126 -0
- fyle/_readers/archive/__init__.py +8 -0
- fyle/_readers/archive/stdlib.py +513 -0
- fyle/_readers/audio/__init__.py +9 -0
- fyle/_readers/audio/faster_whisper.py +162 -0
- fyle/_readers/base.py +70 -0
- fyle/_readers/csv/__init__.py +6 -0
- fyle/_readers/csv/stdlib.py +119 -0
- fyle/_readers/docx/__init__.py +6 -0
- fyle/_readers/docx/mammoth.py +130 -0
- fyle/_readers/html/__init__.py +6 -0
- fyle/_readers/html/markdownify.py +113 -0
- fyle/_readers/image/__init__.py +18 -0
- fyle/_readers/image/stdlib.py +136 -0
- fyle/_readers/markdown/__init__.py +6 -0
- fyle/_readers/markdown/stdlib.py +61 -0
- fyle/_readers/pdf/__init__.py +2 -0
- fyle/_readers/pdf/pymupdf4llm.py +202 -0
- fyle/_readers/pptx/__init__.py +7 -0
- fyle/_readers/pptx/python_pptx.py +306 -0
- fyle/_readers/sqlite/__init__.py +8 -0
- fyle/_readers/sqlite/stdlib.py +366 -0
- fyle/_readers/text/__init__.py +7 -0
- fyle/_readers/text/stdlib.py +76 -0
- fyle/_readers/video/__init__.py +10 -0
- fyle/_readers/video/scenedetect.py +330 -0
- fyle/_readers/xlsx/__init__.py +6 -0
- fyle/_readers/xlsx/openpyxl.py +158 -0
- fyle/errors.py +42 -0
- fyle/sqlite.py +175 -0
- fylepy-0.1.0.dist-info/METADATA +272 -0
- fylepy-0.1.0.dist-info/RECORD +44 -0
- fylepy-0.1.0.dist-info/WHEEL +4 -0
- 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
|