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,76 @@
|
|
|
1
|
+
"""Plain-text reader backed by the Python standard library only.
|
|
2
|
+
|
|
3
|
+
Plain text has no structure. We decode bytes and drop them into ``Page.text``
|
|
4
|
+
**unchanged**: no escaping of Markdown-special characters (``*`` / ``_`` /
|
|
5
|
+
``#`` / ``|``). Rationale: a ``.txt`` file has no semantic notion of
|
|
6
|
+
emphasis or headings; whatever the author typed is what the LLM should see.
|
|
7
|
+
Downstream consumers that need true plain-text rendering should treat the
|
|
8
|
+
``.text`` as an opaque string, not render it as Markdown.
|
|
9
|
+
|
|
10
|
+
File naming rule (see design doc §12.0): this reader's file name is the
|
|
11
|
+
name of its *core driver library*. Plain text needs nothing beyond the
|
|
12
|
+
stdlib, hence ``stdlib.py``. ``Reader.name`` is namespaced with the format
|
|
13
|
+
(``"text-stdlib"``) because several readers share the same stdlib driver.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from datetime import datetime, timezone
|
|
18
|
+
from pathlib import Path
|
|
19
|
+
from typing import Optional
|
|
20
|
+
|
|
21
|
+
from ..base import Reader
|
|
22
|
+
from ..._core.document import Document, Meta, Page
|
|
23
|
+
from ...errors import ParseError
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PlainTextReader(Reader):
|
|
27
|
+
name = "text-stdlib"
|
|
28
|
+
formats = ("text",)
|
|
29
|
+
is_default = True
|
|
30
|
+
|
|
31
|
+
def read(self, data: bytes, *, source_name: Optional[str] = None, **_) -> Document:
|
|
32
|
+
warnings: list[str] = []
|
|
33
|
+
text, decode_warning = _decode_text(data)
|
|
34
|
+
if decode_warning:
|
|
35
|
+
warnings.append(decode_warning)
|
|
36
|
+
|
|
37
|
+
title: Optional[str] = None
|
|
38
|
+
if source_name:
|
|
39
|
+
try:
|
|
40
|
+
title = Path(source_name).stem or None
|
|
41
|
+
except (TypeError, ValueError):
|
|
42
|
+
title = None
|
|
43
|
+
|
|
44
|
+
try:
|
|
45
|
+
meta = Meta(
|
|
46
|
+
format="text",
|
|
47
|
+
pages=1,
|
|
48
|
+
size=len(data),
|
|
49
|
+
title=title,
|
|
50
|
+
reader=self.name,
|
|
51
|
+
created_at=datetime.now(timezone.utc),
|
|
52
|
+
warnings=warnings,
|
|
53
|
+
)
|
|
54
|
+
page = Page(text=text, number=1)
|
|
55
|
+
return Document(pages=[page], meta=meta)
|
|
56
|
+
except Exception as e: # pragma: no cover - defensive
|
|
57
|
+
raise ParseError(f"text-stdlib reader failed: {e}") from e
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _decode_text(data: bytes) -> tuple[str, Optional[str]]:
|
|
61
|
+
"""Decode bytes as UTF-8 (with optional BOM); fall back to latin-1 on failure.
|
|
62
|
+
|
|
63
|
+
Returns the decoded text plus a warning string (or ``None`` if the decode
|
|
64
|
+
was clean UTF-8).
|
|
65
|
+
|
|
66
|
+
Exposed at module level (prefixed ``_``) so that sibling readers
|
|
67
|
+
(``markdown``, ``csv``, ``html``) can reuse the same decoding policy
|
|
68
|
+
without duplicating it.
|
|
69
|
+
"""
|
|
70
|
+
try:
|
|
71
|
+
return data.decode("utf-8-sig"), None
|
|
72
|
+
except UnicodeDecodeError:
|
|
73
|
+
return (
|
|
74
|
+
data.decode("latin-1", errors="replace"),
|
|
75
|
+
"text was not valid UTF-8; decoded as latin-1 (some characters may be wrong)",
|
|
76
|
+
)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""Video reader.
|
|
2
|
+
|
|
3
|
+
File naming rule: ``scenedetect.py`` — the core driver that picks
|
|
4
|
+
keyframes. The reader also invokes ``faster_whisper`` for the audio
|
|
5
|
+
track. Heavy deps (PyAV, scenedetect, CTranslate2) are optional; see
|
|
6
|
+
``pyproject.toml`` ``[project.optional-dependencies].video``.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import scenedetect # noqa: F401
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
"""Video reader — scene-change keyframes + Whisper transcription.
|
|
2
|
+
|
|
3
|
+
What ``doc.text`` looks like
|
|
4
|
+
----------------------------
|
|
5
|
+
A single Markdown page with three sections:
|
|
6
|
+
|
|
7
|
+
1. **Header** — source name, duration, keyframe count, detected language.
|
|
8
|
+
2. **Keyframes list** — timestamps of every extracted frame so the LLM
|
|
9
|
+
can cross-reference by time without decoding the image bytes.
|
|
10
|
+
3. **Transcript** — ``[MM:SS] text`` lines produced by faster-whisper.
|
|
11
|
+
|
|
12
|
+
``doc.images`` carries the actual frames as ``data:image/jpeg;base64,...``
|
|
13
|
+
URLs. Each image's ``caption`` is the ``MM:SS`` timestamp so the LLM can
|
|
14
|
+
join the visual and the transcript by time.
|
|
15
|
+
|
|
16
|
+
Why this design
|
|
17
|
+
---------------
|
|
18
|
+
Video is just *audio track + sequence of images* to an LLM. Rather than
|
|
19
|
+
invent a multimodal format, we emit the same text shape as the audio
|
|
20
|
+
reader (timestamped transcript) plus a normal ``doc.images`` list that
|
|
21
|
+
any downstream multimodal prompt already knows how to splice in.
|
|
22
|
+
|
|
23
|
+
Scene detection: ``scenedetect.ContentDetector``. Content-aware delta on
|
|
24
|
+
HSV histograms catches slide changes / cut edits / camera moves —
|
|
25
|
+
exactly what makes a new frame informative. The frame picked per scene
|
|
26
|
+
is the scene's temporal midpoint, which is typically the most stable
|
|
27
|
+
(post-transition, pre-next-transition) sample.
|
|
28
|
+
|
|
29
|
+
No hidden caps
|
|
30
|
+
--------------
|
|
31
|
+
We emit one keyframe per detected scene. Long / busy videos can
|
|
32
|
+
produce hundreds of frames, but silently truncating would hide data
|
|
33
|
+
from the caller — callers who need a cap can slice ``doc.images``
|
|
34
|
+
themselves. The only warning-level observation we log is the final
|
|
35
|
+
count so the caller sees it at a glance.
|
|
36
|
+
|
|
37
|
+
File naming rule: ``scenedetect.py`` — the characteristic driver that
|
|
38
|
+
distinguishes this from a hypothetical alternative video reader (e.g.
|
|
39
|
+
one using a vision-language model for shot boundaries).
|
|
40
|
+
"""
|
|
41
|
+
from __future__ import annotations
|
|
42
|
+
|
|
43
|
+
import base64
|
|
44
|
+
import io
|
|
45
|
+
import os
|
|
46
|
+
import tempfile
|
|
47
|
+
from datetime import datetime, timezone
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
from typing import Optional
|
|
50
|
+
|
|
51
|
+
from .. import _whisper
|
|
52
|
+
from ..base import Reader
|
|
53
|
+
from ..._core.document import Document, Image, Meta, Page
|
|
54
|
+
from ...errors import NotImplementedReaderError, ParseError
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
_KNOWN_VIDEO_SUFFIXES = {".mp4", ".m4v", ".mov", ".avi", ".mkv", ".webm"}
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SceneDetectVideoReader(Reader):
|
|
61
|
+
name = "video-scenedetect"
|
|
62
|
+
formats = ("video",)
|
|
63
|
+
is_default = True
|
|
64
|
+
|
|
65
|
+
def read(
|
|
66
|
+
self,
|
|
67
|
+
data: bytes,
|
|
68
|
+
*,
|
|
69
|
+
source_name: Optional[str] = None,
|
|
70
|
+
source_path: Optional[str] = None,
|
|
71
|
+
**_,
|
|
72
|
+
) -> Document:
|
|
73
|
+
if not data:
|
|
74
|
+
raise ParseError("video-scenedetect reader: input is empty")
|
|
75
|
+
|
|
76
|
+
# Lazy-import both heavy deps so ``import fyle`` stays cheap when
|
|
77
|
+
# the user never opens a video file.
|
|
78
|
+
av = _require_av()
|
|
79
|
+
detect, ContentDetector = _require_scenedetect()
|
|
80
|
+
|
|
81
|
+
warnings: list[str] = []
|
|
82
|
+
suffix = _pick_suffix(source_name)
|
|
83
|
+
|
|
84
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
85
|
+
prefix="fyle-video-",
|
|
86
|
+
suffix=suffix,
|
|
87
|
+
delete=False,
|
|
88
|
+
)
|
|
89
|
+
try:
|
|
90
|
+
tmp.write(data)
|
|
91
|
+
tmp.close()
|
|
92
|
+
|
|
93
|
+
# 1. Scene boundaries. On failure we still try keyframe+ASR so
|
|
94
|
+
# the caller gets a partial Document rather than a crash.
|
|
95
|
+
try:
|
|
96
|
+
scenes = detect(tmp.name, ContentDetector())
|
|
97
|
+
except Exception as e:
|
|
98
|
+
warnings.append(f"scene detection failed: {e}")
|
|
99
|
+
scenes = []
|
|
100
|
+
|
|
101
|
+
midpoints = [
|
|
102
|
+
(s.get_seconds() + e.get_seconds()) / 2 for s, e in scenes
|
|
103
|
+
]
|
|
104
|
+
# Fall back to a single opening frame if scenedetect returned
|
|
105
|
+
# nothing (e.g. ultra-short clips or a single static scene).
|
|
106
|
+
if not midpoints:
|
|
107
|
+
midpoints = [0.0]
|
|
108
|
+
warnings.append(
|
|
109
|
+
"no scene boundaries detected; falling back to one opening frame"
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# 2. Keyframes.
|
|
113
|
+
frames = _extract_keyframes(av, tmp.name, midpoints, warnings)
|
|
114
|
+
|
|
115
|
+
# 3. Transcription (same file — faster-whisper / PyAV pick
|
|
116
|
+
# the audio track automatically).
|
|
117
|
+
try:
|
|
118
|
+
segments, info = _whisper.transcribe(tmp.name)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
warnings.append(f"audio transcription failed: {e}")
|
|
121
|
+
segments = []
|
|
122
|
+
info = {}
|
|
123
|
+
finally:
|
|
124
|
+
try:
|
|
125
|
+
os.unlink(tmp.name)
|
|
126
|
+
except OSError:
|
|
127
|
+
pass
|
|
128
|
+
|
|
129
|
+
# 4. Document assembly.
|
|
130
|
+
images = _build_images(frames)
|
|
131
|
+
page_text = _render_page_text(
|
|
132
|
+
source_name=source_name,
|
|
133
|
+
duration_sec=float(info.get("duration", 0.0) or 0.0),
|
|
134
|
+
language=info.get("language"),
|
|
135
|
+
keyframe_ts=[ts for ts, _ in frames],
|
|
136
|
+
segments=segments,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
if info.get("language"):
|
|
140
|
+
warnings.append(
|
|
141
|
+
f"detected language: {info['language']} "
|
|
142
|
+
f"(p={info.get('language_probability', 0):.2f})"
|
|
143
|
+
)
|
|
144
|
+
if info.get("duration"):
|
|
145
|
+
warnings.append(
|
|
146
|
+
f"video duration: {_whisper.format_timestamp(info['duration'])}"
|
|
147
|
+
)
|
|
148
|
+
warnings.append(f"whisper model: {_whisper.model_size()}")
|
|
149
|
+
warnings.append(f"keyframes: {len(frames)}")
|
|
150
|
+
|
|
151
|
+
title = Path(source_name).stem if source_name else None
|
|
152
|
+
page = Page(text=page_text, number=1, images=images)
|
|
153
|
+
meta = Meta(
|
|
154
|
+
format="video",
|
|
155
|
+
pages=1,
|
|
156
|
+
size=len(data),
|
|
157
|
+
title=title,
|
|
158
|
+
reader=self.name,
|
|
159
|
+
created_at=datetime.now(timezone.utc),
|
|
160
|
+
warnings=warnings,
|
|
161
|
+
)
|
|
162
|
+
return Document(pages=[page], meta=meta)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# ----------------------------------------------------------------------
|
|
166
|
+
# Lazy-import guards (separate so each has its own error message)
|
|
167
|
+
# ----------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
def _require_av():
|
|
170
|
+
try:
|
|
171
|
+
import av # noqa: F401
|
|
172
|
+
except ImportError as e:
|
|
173
|
+
raise NotImplementedReaderError(
|
|
174
|
+
"Video reading requires the 'av' (PyAV) package. "
|
|
175
|
+
"Install the optional extra: pip install 'fyle[video]'."
|
|
176
|
+
) from e
|
|
177
|
+
return av
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _require_scenedetect():
|
|
181
|
+
try:
|
|
182
|
+
from scenedetect import detect, ContentDetector
|
|
183
|
+
except ImportError as e:
|
|
184
|
+
raise NotImplementedReaderError(
|
|
185
|
+
"Video reading requires the 'scenedetect' package. "
|
|
186
|
+
"Install the optional extra: pip install 'fyle[video]'."
|
|
187
|
+
) from e
|
|
188
|
+
return detect, ContentDetector
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ----------------------------------------------------------------------
|
|
192
|
+
# Helpers
|
|
193
|
+
# ----------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
def _pick_suffix(source_name: Optional[str]) -> str:
|
|
196
|
+
"""Return a video suffix PyAV will recognise. Fallback to ``.mp4``."""
|
|
197
|
+
if source_name:
|
|
198
|
+
ext = Path(source_name).suffix.lower()
|
|
199
|
+
if ext in _KNOWN_VIDEO_SUFFIXES:
|
|
200
|
+
return ext
|
|
201
|
+
return ".mp4"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def _extract_keyframes(
|
|
205
|
+
av_module,
|
|
206
|
+
path: str,
|
|
207
|
+
timestamps_sec: list[float],
|
|
208
|
+
warnings: list[str],
|
|
209
|
+
) -> list[tuple[float, bytes]]:
|
|
210
|
+
"""For each target timestamp (seconds), return the nearest decoded frame.
|
|
211
|
+
|
|
212
|
+
Output is a list of ``(timestamp_sec, jpeg_bytes)`` tuples in the
|
|
213
|
+
same order as ``timestamps_sec``. Failures for individual timestamps
|
|
214
|
+
are recorded in ``warnings`` and the timestamp is skipped rather
|
|
215
|
+
than raising — we prefer a partial video over none.
|
|
216
|
+
"""
|
|
217
|
+
results: list[tuple[float, bytes]] = []
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
container = av_module.open(path)
|
|
221
|
+
except Exception as e:
|
|
222
|
+
warnings.append(f"failed to open video for keyframe extraction: {e}")
|
|
223
|
+
return results
|
|
224
|
+
|
|
225
|
+
try:
|
|
226
|
+
try:
|
|
227
|
+
stream = container.streams.video[0]
|
|
228
|
+
except (IndexError, AttributeError):
|
|
229
|
+
warnings.append("no video stream found for keyframe extraction")
|
|
230
|
+
return results
|
|
231
|
+
|
|
232
|
+
time_base = float(stream.time_base) if stream.time_base else 0.0
|
|
233
|
+
if time_base <= 0:
|
|
234
|
+
warnings.append("video stream reports an invalid time_base; skipping keyframes")
|
|
235
|
+
return results
|
|
236
|
+
|
|
237
|
+
for ts_sec in timestamps_sec:
|
|
238
|
+
try:
|
|
239
|
+
frame = _grab_frame_at(container, stream, ts_sec, time_base)
|
|
240
|
+
except Exception as e:
|
|
241
|
+
warnings.append(f"keyframe extraction at {ts_sec:.2f}s failed: {e}")
|
|
242
|
+
continue
|
|
243
|
+
if frame is None:
|
|
244
|
+
continue
|
|
245
|
+
try:
|
|
246
|
+
img = frame.to_image() # PIL Image
|
|
247
|
+
buf = io.BytesIO()
|
|
248
|
+
img.save(buf, format="JPEG", quality=85)
|
|
249
|
+
results.append((ts_sec, buf.getvalue()))
|
|
250
|
+
except Exception as e:
|
|
251
|
+
warnings.append(f"JPEG encode at {ts_sec:.2f}s failed: {e}")
|
|
252
|
+
finally:
|
|
253
|
+
try:
|
|
254
|
+
container.close()
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
|
|
258
|
+
return results
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _grab_frame_at(container, stream, ts_sec: float, time_base: float):
|
|
262
|
+
"""Seek to the nearest keyframe before ``ts_sec`` and decode forward."""
|
|
263
|
+
ts_units = int(ts_sec / time_base)
|
|
264
|
+
container.seek(ts_units, stream=stream, backward=True)
|
|
265
|
+
best = None
|
|
266
|
+
for frame in container.decode(stream):
|
|
267
|
+
if frame.pts is None:
|
|
268
|
+
continue
|
|
269
|
+
frame_sec = float(frame.pts * stream.time_base)
|
|
270
|
+
if frame_sec >= ts_sec:
|
|
271
|
+
return frame
|
|
272
|
+
best = frame
|
|
273
|
+
return best
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def _build_images(frames: list[tuple[float, bytes]]) -> list[Image]:
|
|
277
|
+
images: list[Image] = []
|
|
278
|
+
for ts, jpeg_bytes in frames:
|
|
279
|
+
b64 = base64.b64encode(jpeg_bytes).decode("ascii")
|
|
280
|
+
data_url = f"data:image/jpeg;base64,{b64}"
|
|
281
|
+
images.append(
|
|
282
|
+
Image(
|
|
283
|
+
data_url=data_url,
|
|
284
|
+
data=jpeg_bytes,
|
|
285
|
+
caption=_whisper.format_timestamp(ts),
|
|
286
|
+
page=1,
|
|
287
|
+
)
|
|
288
|
+
)
|
|
289
|
+
return images
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _render_page_text(
|
|
293
|
+
*,
|
|
294
|
+
source_name: Optional[str],
|
|
295
|
+
duration_sec: float,
|
|
296
|
+
language: Optional[str],
|
|
297
|
+
keyframe_ts: list[float],
|
|
298
|
+
segments: list[dict],
|
|
299
|
+
) -> str:
|
|
300
|
+
title = source_name or "(unnamed video)"
|
|
301
|
+
dur = _whisper.format_timestamp(duration_sec) if duration_sec else "-"
|
|
302
|
+
lang = language or "-"
|
|
303
|
+
|
|
304
|
+
if keyframe_ts:
|
|
305
|
+
kf_lines = "\n".join(
|
|
306
|
+
f"- `{_whisper.format_timestamp(ts)}` — keyframe {i + 1}"
|
|
307
|
+
for i, ts in enumerate(keyframe_ts)
|
|
308
|
+
)
|
|
309
|
+
else:
|
|
310
|
+
kf_lines = "_(no keyframes extracted)_"
|
|
311
|
+
|
|
312
|
+
transcript = _whisper.format_transcript(segments)
|
|
313
|
+
|
|
314
|
+
return (
|
|
315
|
+
f"# Video: {title}\n"
|
|
316
|
+
"\n"
|
|
317
|
+
f"- Duration: `{dur}`\n"
|
|
318
|
+
f"- Keyframes: {len(keyframe_ts)}\n"
|
|
319
|
+
f"- Language: `{lang}`\n"
|
|
320
|
+
"\n"
|
|
321
|
+
"## Keyframes\n"
|
|
322
|
+
"\n"
|
|
323
|
+
f"{kf_lines}\n"
|
|
324
|
+
"\n"
|
|
325
|
+
"(full frame bytes are in `doc.images`, each caption is its timestamp)\n"
|
|
326
|
+
"\n"
|
|
327
|
+
"## Transcript\n"
|
|
328
|
+
"\n"
|
|
329
|
+
f"{transcript}"
|
|
330
|
+
)
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""XLSX reader backed by ``openpyxl``.
|
|
2
|
+
|
|
3
|
+
Strategy:
|
|
4
|
+
- Open the workbook read-only with ``openpyxl`` (``data_only=True`` so we
|
|
5
|
+
get cell values, not formulas).
|
|
6
|
+
- Treat **each sheet as one ``Page``**. ``meta.pages`` therefore equals the
|
|
7
|
+
sheet count; page numbers start at 1 in workbook order. Each ``Page``
|
|
8
|
+
carries ``page.name = ws.title`` so consumers can address sheets by name
|
|
9
|
+
(e.g. ``[p for p in doc.pages if p.name == "Summary"]``) — this keeps the
|
|
10
|
+
surface uniform across formats (no separate ``Sheet`` model) while still
|
|
11
|
+
exposing the sheet identity.
|
|
12
|
+
- Render every sheet as a single Markdown table via ``tabulate``. First row
|
|
13
|
+
is the header (standard spreadsheet convention). Per-cell escaping is
|
|
14
|
+
applied before handing off to ``tabulate`` (same three hazards as CSV:
|
|
15
|
+
literal ``|``, embedded newlines, and number re-formatting that would
|
|
16
|
+
drop leading zeros).
|
|
17
|
+
- Prefix each page's Markdown with an ``# {sheet_name}`` heading so the
|
|
18
|
+
concatenated ``doc.text`` reads naturally.
|
|
19
|
+
|
|
20
|
+
File naming rule: ``openpyxl.py`` — the core driver. ``tabulate`` is a
|
|
21
|
+
post-processor and does not determine the file name.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import io
|
|
26
|
+
from datetime import datetime, timezone
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import Any, Optional
|
|
29
|
+
|
|
30
|
+
from tabulate import tabulate
|
|
31
|
+
|
|
32
|
+
from ..base import Reader
|
|
33
|
+
from ..._core.document import Document, Meta, Page, Table
|
|
34
|
+
from ...errors import ParseError
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _escape_md_cell(cell: Any) -> str:
|
|
38
|
+
"""Escape a single cell so it is safe inside a Markdown pipe table."""
|
|
39
|
+
if cell is None:
|
|
40
|
+
return ""
|
|
41
|
+
if isinstance(cell, datetime):
|
|
42
|
+
s = cell.isoformat()
|
|
43
|
+
elif isinstance(cell, bytes):
|
|
44
|
+
s = cell.decode("utf-8", errors="replace")
|
|
45
|
+
else:
|
|
46
|
+
s = str(cell)
|
|
47
|
+
return s.replace("|", "\\|").replace("\r\n", "<br>").replace("\n", "<br>").replace("\r", "<br>")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class XlsxReader(Reader):
|
|
51
|
+
name = "openpyxl"
|
|
52
|
+
formats = ("xlsx",)
|
|
53
|
+
is_default = True
|
|
54
|
+
|
|
55
|
+
def read(self, data: bytes, *, source_name: Optional[str] = None, **_) -> Document:
|
|
56
|
+
try:
|
|
57
|
+
from openpyxl import load_workbook
|
|
58
|
+
except ImportError as e:
|
|
59
|
+
raise ParseError(
|
|
60
|
+
"openpyxl is required for XLSX parsing: pip install openpyxl"
|
|
61
|
+
) from e
|
|
62
|
+
|
|
63
|
+
warnings: list[str] = []
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
wb = load_workbook(io.BytesIO(data), data_only=True, read_only=True)
|
|
67
|
+
except Exception as e:
|
|
68
|
+
raise ParseError(f"failed to open xlsx: {e}") from e
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
pages: list[Page] = []
|
|
72
|
+
for page_no, ws in enumerate(wb.worksheets, start=1):
|
|
73
|
+
page = _render_sheet(ws, page_no, warnings)
|
|
74
|
+
pages.append(page)
|
|
75
|
+
|
|
76
|
+
if not pages:
|
|
77
|
+
pages.append(Page(text="", number=1))
|
|
78
|
+
warnings.append("workbook has no sheets")
|
|
79
|
+
|
|
80
|
+
props = wb.properties
|
|
81
|
+
title = (getattr(props, "title", None) or None) or None
|
|
82
|
+
author = (getattr(props, "creator", None) or None) or None
|
|
83
|
+
created_at = getattr(props, "created", None)
|
|
84
|
+
if created_at is not None and created_at.tzinfo is None:
|
|
85
|
+
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
86
|
+
finally:
|
|
87
|
+
try:
|
|
88
|
+
wb.close()
|
|
89
|
+
except Exception:
|
|
90
|
+
pass
|
|
91
|
+
|
|
92
|
+
if not title and source_name:
|
|
93
|
+
title = Path(source_name).stem or None
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
meta = Meta(
|
|
97
|
+
format="xlsx",
|
|
98
|
+
pages=len(pages),
|
|
99
|
+
size=len(data),
|
|
100
|
+
title=title,
|
|
101
|
+
author=author,
|
|
102
|
+
created_at=created_at,
|
|
103
|
+
reader=self.name,
|
|
104
|
+
warnings=warnings,
|
|
105
|
+
)
|
|
106
|
+
return Document(pages=pages, meta=meta)
|
|
107
|
+
except Exception as e: # pragma: no cover - defensive
|
|
108
|
+
raise ParseError(f"xlsx reader failed to build Document: {e}") from e
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _render_sheet(ws, page_no: int, warnings: list[str]) -> Page:
|
|
112
|
+
"""Render one worksheet as a ``Page`` with a single Markdown table.
|
|
113
|
+
|
|
114
|
+
Always sets ``page.name = ws.title`` so downstream code can identify
|
|
115
|
+
sheets by their human name without having to look at ``page.text``.
|
|
116
|
+
"""
|
|
117
|
+
sheet_name = ws.title
|
|
118
|
+
sheet_heading = f"# {sheet_name}"
|
|
119
|
+
|
|
120
|
+
rows_iter = ws.iter_rows(values_only=True)
|
|
121
|
+
try:
|
|
122
|
+
header_row = next(rows_iter)
|
|
123
|
+
except StopIteration:
|
|
124
|
+
# Empty sheet.
|
|
125
|
+
return Page(
|
|
126
|
+
text=f"{sheet_heading}\n\n_(empty sheet)_",
|
|
127
|
+
number=page_no,
|
|
128
|
+
name=sheet_name,
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
headers = [_stringify(h) for h in header_row]
|
|
132
|
+
body: list[list[str]] = []
|
|
133
|
+
for row in rows_iter:
|
|
134
|
+
body.append([_stringify(c) for c in row])
|
|
135
|
+
|
|
136
|
+
safe_headers = [_escape_md_cell(h) for h in headers]
|
|
137
|
+
safe_body = [[_escape_md_cell(c) for c in row] for row in body]
|
|
138
|
+
md_table = tabulate(
|
|
139
|
+
safe_body,
|
|
140
|
+
headers=safe_headers,
|
|
141
|
+
tablefmt="pipe",
|
|
142
|
+
disable_numparse=True,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
page_md = f"{sheet_heading}\n\n{md_table}" if md_table else f"{sheet_heading}\n\n_(no rows)_"
|
|
146
|
+
table = Table(text=md_table, rows=body, headers=headers, page=page_no)
|
|
147
|
+
return Page(text=page_md, number=page_no, name=sheet_name, tables=[table])
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _stringify(cell: Any) -> str:
|
|
151
|
+
"""Normalise an openpyxl cell value into a plain string for ``Table.rows``."""
|
|
152
|
+
if cell is None:
|
|
153
|
+
return ""
|
|
154
|
+
if isinstance(cell, datetime):
|
|
155
|
+
return cell.isoformat()
|
|
156
|
+
if isinstance(cell, bytes):
|
|
157
|
+
return cell.decode("utf-8", errors="replace")
|
|
158
|
+
return str(cell)
|
fyle/errors.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
"""Public exception types.
|
|
2
|
+
|
|
3
|
+
Fatal vs partial failure dichotomy:
|
|
4
|
+
- Fatal: ``ParseError`` / ``UnsupportedFormatError`` / ``ReaderNotFoundError`` /
|
|
5
|
+
``DownloadError``. These are raised from ``fyle.open`` / ``fyle.read``.
|
|
6
|
+
- Partial: recorded in ``doc.meta.warnings``; no exception is raised.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class UnsupportedFormatError(Exception):
|
|
12
|
+
"""Raised when format sniffing fails on all three paths.
|
|
13
|
+
|
|
14
|
+
Extension, magic bytes, and HTTP Content-Type all produced no match.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ParseError(Exception):
|
|
19
|
+
"""Fatal parse failure: corrupted file, reader/format mismatch, etc.
|
|
20
|
+
|
|
21
|
+
fyle does not auto-fallback to another reader; the caller must handle it.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ReaderNotFoundError(Exception):
|
|
26
|
+
"""Reader name is unknown, or the reader does not support the target format."""
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class DownloadError(Exception):
|
|
30
|
+
"""URL fetch failed: network error, timeout, or response exceeds max_bytes.
|
|
31
|
+
|
|
32
|
+
Wraps the underlying ``httpx`` exception.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class NotImplementedReaderError(NotImplementedError):
|
|
37
|
+
"""The format is recognised, but no real reader implementation ships yet.
|
|
38
|
+
|
|
39
|
+
Used by placeholder readers that reserve a format slot (e.g. audio, video)
|
|
40
|
+
so that ``fyle.open`` can produce a clear, format-specific error instead
|
|
41
|
+
of falling through to ``UnsupportedFormatError``.
|
|
42
|
+
"""
|