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,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,6 @@
1
+ """XLSX reader — uses ``openpyxl`` + ``tabulate``; one sheet per ``Page``.
2
+
3
+ File name inside the subpackage is the *core driver library* (``openpyxl``);
4
+ ``tabulate`` is a post-processor for Markdown table rendering.
5
+ """
6
+ from . import openpyxl # noqa: F401
@@ -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
+ """