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,306 @@
|
|
|
1
|
+
"""PowerPoint (.pptx) reader backed by ``python-pptx``.
|
|
2
|
+
|
|
3
|
+
Each slide becomes one ``Page``:
|
|
4
|
+
- ``page.number`` is the slide's 1-based index.
|
|
5
|
+
- ``page.name`` holds the slide title (from the title placeholder) when
|
|
6
|
+
present. Same field as the sheet name for XLSX \u2014 kept consistent
|
|
7
|
+
rather than inventing a ``Slide`` type.
|
|
8
|
+
- ``page.text`` is Markdown assembled from the slide's text frames,
|
|
9
|
+
bullet lists, tables and images, in slide order.
|
|
10
|
+
- ``page.tables`` and ``page.images`` are populated in parallel so
|
|
11
|
+
downstream code can access structural data without re-parsing.
|
|
12
|
+
|
|
13
|
+
Tables go through ``tabulate`` (GFM pipe format); images are embedded
|
|
14
|
+
as ``data:image/...;base64,...`` URLs so the document is self-contained
|
|
15
|
+
and can be fed directly to a multimodal LLM.
|
|
16
|
+
|
|
17
|
+
File naming rule: ``python_pptx.py`` \u2014 the core driver is ``python-pptx``.
|
|
18
|
+
(Module name uses ``_`` rather than ``-`` because Python identifiers
|
|
19
|
+
cannot contain hyphens.)
|
|
20
|
+
"""
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import base64
|
|
24
|
+
import io
|
|
25
|
+
from datetime import timezone
|
|
26
|
+
from pathlib import Path
|
|
27
|
+
from typing import Any, Optional
|
|
28
|
+
|
|
29
|
+
from ..base import Reader
|
|
30
|
+
from ..._core.document import Document, Image, Meta, Page, Table
|
|
31
|
+
from ...errors import ParseError
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class PptxReader(Reader):
|
|
35
|
+
name = "python-pptx"
|
|
36
|
+
formats = ("pptx",)
|
|
37
|
+
is_default = True
|
|
38
|
+
|
|
39
|
+
def read(self, data: bytes, *, source_name: Optional[str] = None, **_) -> Document:
|
|
40
|
+
try:
|
|
41
|
+
from pptx import Presentation
|
|
42
|
+
except ImportError as e:
|
|
43
|
+
raise ParseError(
|
|
44
|
+
"python-pptx is required for PPTX parsing: pip install python-pptx"
|
|
45
|
+
) from e
|
|
46
|
+
try:
|
|
47
|
+
from pptx.enum.shapes import MSO_SHAPE_TYPE
|
|
48
|
+
except ImportError as e: # pragma: no cover
|
|
49
|
+
raise ParseError(f"python-pptx import failed: {e}") from e
|
|
50
|
+
|
|
51
|
+
warnings: list[str] = []
|
|
52
|
+
|
|
53
|
+
try:
|
|
54
|
+
prs = Presentation(io.BytesIO(data))
|
|
55
|
+
except Exception as e:
|
|
56
|
+
raise ParseError(f"python-pptx failed to open PPTX: {e}") from e
|
|
57
|
+
|
|
58
|
+
pages: list[Page] = []
|
|
59
|
+
for idx, slide in enumerate(prs.slides, start=1):
|
|
60
|
+
page = _render_slide(slide, idx, MSO_SHAPE_TYPE, warnings)
|
|
61
|
+
pages.append(page)
|
|
62
|
+
|
|
63
|
+
if not pages:
|
|
64
|
+
pages.append(Page(text="", number=1))
|
|
65
|
+
warnings.append("pptx: no slides found")
|
|
66
|
+
|
|
67
|
+
title, author, created_at = _read_core_props(prs, warnings)
|
|
68
|
+
|
|
69
|
+
if not title and source_name:
|
|
70
|
+
try:
|
|
71
|
+
title = Path(source_name).stem or None
|
|
72
|
+
except (TypeError, ValueError):
|
|
73
|
+
title = None
|
|
74
|
+
|
|
75
|
+
try:
|
|
76
|
+
meta = Meta(
|
|
77
|
+
format="pptx",
|
|
78
|
+
pages=len(pages),
|
|
79
|
+
size=len(data),
|
|
80
|
+
title=title,
|
|
81
|
+
author=author,
|
|
82
|
+
created_at=created_at,
|
|
83
|
+
reader=self.name,
|
|
84
|
+
warnings=warnings,
|
|
85
|
+
)
|
|
86
|
+
return Document(pages=pages, meta=meta)
|
|
87
|
+
except Exception as e: # pragma: no cover - defensive
|
|
88
|
+
raise ParseError(f"pptx reader failed to build Document: {e}") from e
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _read_core_props(prs, warnings: list[str]):
|
|
92
|
+
"""Best-effort read of PPTX core properties. Never raises."""
|
|
93
|
+
try:
|
|
94
|
+
cp = prs.core_properties
|
|
95
|
+
title = (cp.title or None) or None
|
|
96
|
+
author = (cp.author or None) or None
|
|
97
|
+
created_at = cp.created
|
|
98
|
+
if created_at is not None and created_at.tzinfo is None:
|
|
99
|
+
created_at = created_at.replace(tzinfo=timezone.utc)
|
|
100
|
+
return title, author, created_at
|
|
101
|
+
except Exception as e:
|
|
102
|
+
warnings.append(f"PPTX metadata read failed: {e}")
|
|
103
|
+
return None, None, None
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _render_slide(slide: Any, slide_no: int, MSO_SHAPE_TYPE: Any, warnings: list[str]) -> Page:
|
|
107
|
+
"""Walk the slide's shapes and assemble Markdown + structural elements."""
|
|
108
|
+
md_parts: list[str] = []
|
|
109
|
+
tables: list[Table] = []
|
|
110
|
+
images: list[Image] = []
|
|
111
|
+
|
|
112
|
+
slide_title = _slide_title(slide)
|
|
113
|
+
if slide_title:
|
|
114
|
+
md_parts.append(f"# {slide_title}")
|
|
115
|
+
|
|
116
|
+
for shape in slide.shapes:
|
|
117
|
+
# Skip the title placeholder we already rendered as H1.
|
|
118
|
+
if _is_title_placeholder(shape) and slide_title:
|
|
119
|
+
continue
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
if shape.has_text_frame:
|
|
123
|
+
block = _render_text_frame(shape.text_frame)
|
|
124
|
+
if block:
|
|
125
|
+
md_parts.append(block)
|
|
126
|
+
continue
|
|
127
|
+
if shape.has_table:
|
|
128
|
+
md, tbl = _render_table(shape.table, slide_no, warnings)
|
|
129
|
+
if tbl is not None:
|
|
130
|
+
tables.append(tbl)
|
|
131
|
+
if md:
|
|
132
|
+
md_parts.append(md)
|
|
133
|
+
continue
|
|
134
|
+
if getattr(shape, "shape_type", None) == MSO_SHAPE_TYPE.PICTURE:
|
|
135
|
+
md, img = _render_picture(shape, slide_no, warnings)
|
|
136
|
+
if img is not None:
|
|
137
|
+
images.append(img)
|
|
138
|
+
if md:
|
|
139
|
+
md_parts.append(md)
|
|
140
|
+
continue
|
|
141
|
+
except Exception as e:
|
|
142
|
+
warnings.append(f"pptx slide {slide_no}: shape render failed: {e}")
|
|
143
|
+
continue
|
|
144
|
+
|
|
145
|
+
# Notes are optional; include them at the bottom under a subheading.
|
|
146
|
+
notes = _slide_notes(slide)
|
|
147
|
+
if notes:
|
|
148
|
+
md_parts.append("## Speaker notes")
|
|
149
|
+
md_parts.append(notes)
|
|
150
|
+
|
|
151
|
+
page_text = "\n\n".join(p for p in md_parts if p)
|
|
152
|
+
|
|
153
|
+
return Page(
|
|
154
|
+
text=page_text,
|
|
155
|
+
number=slide_no,
|
|
156
|
+
name=slide_title or None,
|
|
157
|
+
tables=tables,
|
|
158
|
+
images=images,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _sanitize(text: str, line_sep: str = "\n") -> str:
|
|
163
|
+
"""Normalise PowerPoint control characters in extracted strings.
|
|
164
|
+
|
|
165
|
+
PowerPoint stores soft line breaks (``Shift+Enter``) as the vertical tab
|
|
166
|
+
byte ``\\x0b`` (VT) inside a single paragraph. ``python-pptx`` returns
|
|
167
|
+
those verbatim via ``paragraph.text`` / ``text_frame.text``, which leaks
|
|
168
|
+
the raw control byte into our Markdown and into ``Page.name`` / titles.
|
|
169
|
+
|
|
170
|
+
We collapse those to ``line_sep``:
|
|
171
|
+
- ``"\n"`` for body / paragraph text — preserves the "line break within
|
|
172
|
+
paragraph" semantics.
|
|
173
|
+
- ``" "`` for titles — a slide title is rendered on one line in Markdown
|
|
174
|
+
anyway, and embedding a newline would break the heading.
|
|
175
|
+
"""
|
|
176
|
+
if not text:
|
|
177
|
+
return text
|
|
178
|
+
return text.replace("\x0b", line_sep).replace("\x0c", line_sep)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _slide_title(slide: Any) -> Optional[str]:
|
|
182
|
+
"""Extract the slide title placeholder text, or ``None``."""
|
|
183
|
+
try:
|
|
184
|
+
title_shape = slide.shapes.title
|
|
185
|
+
except Exception:
|
|
186
|
+
return None
|
|
187
|
+
if title_shape is None:
|
|
188
|
+
return None
|
|
189
|
+
try:
|
|
190
|
+
text = (title_shape.text_frame.text or "").strip()
|
|
191
|
+
except Exception:
|
|
192
|
+
return None
|
|
193
|
+
text = _sanitize(text, line_sep=" ").strip()
|
|
194
|
+
return text or None
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _is_title_placeholder(shape: Any) -> bool:
|
|
198
|
+
try:
|
|
199
|
+
if not shape.has_text_frame:
|
|
200
|
+
return False
|
|
201
|
+
ph = getattr(shape, "placeholder_format", None)
|
|
202
|
+
if ph is None:
|
|
203
|
+
return False
|
|
204
|
+
# ``idx == 0`` is the title placeholder across layouts.
|
|
205
|
+
return getattr(ph, "idx", None) == 0
|
|
206
|
+
except Exception:
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _render_text_frame(tf: Any) -> str:
|
|
211
|
+
"""Render a text frame as Markdown paragraphs / bullets.
|
|
212
|
+
|
|
213
|
+
Heuristic: paragraphs with ``level > 0`` or short indented content
|
|
214
|
+
become bullet items; others become paragraphs.
|
|
215
|
+
"""
|
|
216
|
+
lines: list[str] = []
|
|
217
|
+
for para in tf.paragraphs:
|
|
218
|
+
text = _sanitize((para.text or ""), line_sep="\n").strip()
|
|
219
|
+
if not text:
|
|
220
|
+
continue
|
|
221
|
+
level = getattr(para, "level", 0) or 0
|
|
222
|
+
if level > 0:
|
|
223
|
+
indent = " " * (level - 0)
|
|
224
|
+
lines.append(f"{indent}- {text}")
|
|
225
|
+
else:
|
|
226
|
+
# Treat a leading dash / bullet glyph as a bullet too.
|
|
227
|
+
if text.startswith(("\u2022", "-", "*")):
|
|
228
|
+
lines.append(f"- {text.lstrip('\u2022-* ').strip()}")
|
|
229
|
+
else:
|
|
230
|
+
lines.append(text)
|
|
231
|
+
return "\n".join(lines)
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _render_table(tbl: Any, slide_no: int, warnings: list[str]) -> tuple[str, Optional[Table]]:
|
|
235
|
+
"""Render a python-pptx table as a GFM pipe table (via tabulate)."""
|
|
236
|
+
try:
|
|
237
|
+
from tabulate import tabulate as _tabulate
|
|
238
|
+
except ImportError:
|
|
239
|
+
warnings.append("tabulate not installed; skipping pptx table render")
|
|
240
|
+
return "", None
|
|
241
|
+
|
|
242
|
+
rows_raw: list[list[str]] = []
|
|
243
|
+
for row in tbl.rows:
|
|
244
|
+
row_cells: list[str] = []
|
|
245
|
+
for cell in row.cells:
|
|
246
|
+
try:
|
|
247
|
+
cell_text = _sanitize((cell.text or ""), line_sep="\n").strip()
|
|
248
|
+
except Exception:
|
|
249
|
+
cell_text = ""
|
|
250
|
+
row_cells.append(cell_text)
|
|
251
|
+
rows_raw.append(row_cells)
|
|
252
|
+
|
|
253
|
+
if not rows_raw:
|
|
254
|
+
return "", None
|
|
255
|
+
|
|
256
|
+
headers = rows_raw[0]
|
|
257
|
+
body = rows_raw[1:]
|
|
258
|
+
try:
|
|
259
|
+
md = _tabulate(body, headers=headers, tablefmt="github")
|
|
260
|
+
except Exception as e:
|
|
261
|
+
warnings.append(f"pptx slide {slide_no}: table render failed: {e}")
|
|
262
|
+
return "", None
|
|
263
|
+
|
|
264
|
+
return md, Table(text=md, rows=body, headers=headers, page=slide_no)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def _render_picture(shape: Any, slide_no: int, warnings: list[str]) -> tuple[str, Optional[Image]]:
|
|
268
|
+
"""Extract a picture shape's bytes into a ``data:`` URL and Markdown token."""
|
|
269
|
+
try:
|
|
270
|
+
image = shape.image
|
|
271
|
+
except Exception as e:
|
|
272
|
+
warnings.append(f"pptx slide {slide_no}: picture access failed: {e}")
|
|
273
|
+
return "", None
|
|
274
|
+
|
|
275
|
+
try:
|
|
276
|
+
blob: bytes = image.blob or b""
|
|
277
|
+
content_type: str = image.content_type or "application/octet-stream"
|
|
278
|
+
except Exception as e:
|
|
279
|
+
warnings.append(f"pptx slide {slide_no}: picture read failed: {e}")
|
|
280
|
+
return "", None
|
|
281
|
+
|
|
282
|
+
if not blob:
|
|
283
|
+
return "", None
|
|
284
|
+
|
|
285
|
+
b64 = base64.b64encode(blob).decode("ascii")
|
|
286
|
+
data_url = f"data:{content_type};base64,{b64}"
|
|
287
|
+
|
|
288
|
+
caption: Optional[str] = None
|
|
289
|
+
name = getattr(shape, "name", "") or ""
|
|
290
|
+
if name:
|
|
291
|
+
caption = name
|
|
292
|
+
|
|
293
|
+
md_token = f""
|
|
294
|
+
img = Image(data_url=data_url, data=blob, caption=caption, page=slide_no)
|
|
295
|
+
return md_token, img
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _slide_notes(slide: Any) -> str:
|
|
299
|
+
"""Return the slide's speaker notes as plain text, or ``""``."""
|
|
300
|
+
try:
|
|
301
|
+
if not slide.has_notes_slide:
|
|
302
|
+
return ""
|
|
303
|
+
notes_tf = slide.notes_slide.notes_text_frame
|
|
304
|
+
return _sanitize((notes_tf.text or ""), line_sep="\n").strip()
|
|
305
|
+
except Exception:
|
|
306
|
+
return ""
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
"""SQLite reader backed by Python's standard library (``sqlite3``).
|
|
2
|
+
|
|
3
|
+
Strategy:
|
|
4
|
+
- SQLite ``.db`` / ``.sqlite`` / ``.sqlite3`` files are a native fyle input:
|
|
5
|
+
a single-file database is still a *file*, so it fits ``fyle.open``.
|
|
6
|
+
- We produce a **read-only schema overview** with a tiny sample of rows per
|
|
7
|
+
table/view, so an LLM can understand what the database holds without
|
|
8
|
+
pulling the entire dataset. Actual ad-hoc querying is offered through
|
|
9
|
+
``fyle.sqlite`` (``tables`` / ``schema`` / ``query``), which is a thin
|
|
10
|
+
wrapper around ``sqlite3`` intended for tool-call use.
|
|
11
|
+
|
|
12
|
+
Per-table Markdown layout:
|
|
13
|
+
|
|
14
|
+
# {table_name}
|
|
15
|
+
|
|
16
|
+
**Schema**
|
|
17
|
+
|
|
18
|
+
| column | type | nullable | default | pk |
|
|
19
|
+
|---|---|---|---|---|
|
|
20
|
+
...
|
|
21
|
+
|
|
22
|
+
**Sample rows** (10 of N)
|
|
23
|
+
|
|
24
|
+
| col1 | col2 | ... |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
...
|
|
27
|
+
|
|
28
|
+
- ``page.number`` is the table's position in alphabetical order.
|
|
29
|
+
- ``page.name`` is the table or view name (reuses the ``Page.name`` slot
|
|
30
|
+
we already use for XLSX sheets and PPTX slide titles).
|
|
31
|
+
- ``page.tables`` always carries two ``Table`` objects: ``[schema, sample]``
|
|
32
|
+
so callers can consume the data without re-parsing Markdown.
|
|
33
|
+
- ``page.text`` embeds both tables plus the row-count summary.
|
|
34
|
+
|
|
35
|
+
Sample cap is **10 rows** by design — the reader exists to produce a
|
|
36
|
+
compact prompt context, not to dump data. Full scans belong to the
|
|
37
|
+
``fyle.sqlite`` query helper (or an external tool) where the LLM
|
|
38
|
+
itself decides the row count via ``LIMIT``.
|
|
39
|
+
|
|
40
|
+
File naming rule: ``stdlib.py`` — the core driver is the Python standard
|
|
41
|
+
library's ``sqlite3`` module.
|
|
42
|
+
"""
|
|
43
|
+
from __future__ import annotations
|
|
44
|
+
|
|
45
|
+
import os
|
|
46
|
+
import sqlite3
|
|
47
|
+
import tempfile
|
|
48
|
+
import weakref
|
|
49
|
+
from datetime import datetime, timezone
|
|
50
|
+
from pathlib import Path
|
|
51
|
+
from typing import Optional
|
|
52
|
+
|
|
53
|
+
from ..base import Reader
|
|
54
|
+
from ..._core.document import Document, Meta, Page, Table
|
|
55
|
+
from ...errors import ParseError
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
# Number of rows previewed per table/view inside the Document. Kept small
|
|
59
|
+
# on purpose: this reader's job is to give an LLM enough shape to reason
|
|
60
|
+
# about schema. Callers who want more data should use ``fyle.sqlite.query``
|
|
61
|
+
# or the ``doc.table(name).query(sql)`` fluent API.
|
|
62
|
+
_SAMPLE_ROWS = 10
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
class SqliteTable:
|
|
66
|
+
"""Interactive handle for one table/view inside a :class:`SqliteDocument`.
|
|
67
|
+
|
|
68
|
+
Returned by ``doc.table(name)``. Deliberately *not* a
|
|
69
|
+
:class:`fyle._core.document.Table`: that one is a Pydantic value object
|
|
70
|
+
used across every reader for schema + data rows. This class instead
|
|
71
|
+
bundles a table name with its parent database path so an LLM can chain
|
|
72
|
+
``.query(sql)`` / ``.schema()`` off a single reference.
|
|
73
|
+
|
|
74
|
+
The SQL passed to :meth:`query` is free-form — it can JOIN other tables
|
|
75
|
+
in the same database. ``table(name)`` is a starting point for the LLM's
|
|
76
|
+
train of thought, not a scope restriction.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
__slots__ = ("name", "_db_path")
|
|
80
|
+
|
|
81
|
+
def __init__(self, name: str, db_path: str) -> None:
|
|
82
|
+
self.name = name
|
|
83
|
+
self._db_path = db_path
|
|
84
|
+
|
|
85
|
+
def query(self, sql: str, params: Optional[list] = None) -> str:
|
|
86
|
+
"""Run read-only SQL against the parent database. Returns Markdown."""
|
|
87
|
+
from ... import sqlite as _fyle_sqlite
|
|
88
|
+
|
|
89
|
+
return _fyle_sqlite.query(self._db_path, sql, params)
|
|
90
|
+
|
|
91
|
+
def schema(self) -> str:
|
|
92
|
+
"""Return this table's column schema as a Markdown pipe table."""
|
|
93
|
+
from ... import sqlite as _fyle_sqlite
|
|
94
|
+
|
|
95
|
+
return _fyle_sqlite.schema(self._db_path, self.name)
|
|
96
|
+
|
|
97
|
+
def __repr__(self) -> str: # pragma: no cover - debug aid
|
|
98
|
+
return f"SqliteTable(name={self.name!r})"
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class SqliteDocument(Document):
|
|
102
|
+
"""Document subclass returned by :class:`SqliteReader`.
|
|
103
|
+
|
|
104
|
+
Adds :meth:`table` for chained SQL access and manages the lifetime of
|
|
105
|
+
the backing temporary database file. ``sqlite3`` only opens databases
|
|
106
|
+
from a filesystem path, so fyle spills the input bytes to a temp file
|
|
107
|
+
and keeps it alive for the Document's lifetime; cleanup is triggered
|
|
108
|
+
by garbage collection, an explicit :meth:`close`, or a ``with`` block.
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
# ``__weakref__`` is required so ``weakref.finalize`` can attach to
|
|
112
|
+
# an instance whose parent class already uses ``__slots__``.
|
|
113
|
+
__slots__ = ("_db_path", "_table_names", "_finalizer", "__weakref__")
|
|
114
|
+
|
|
115
|
+
def __init__(
|
|
116
|
+
self,
|
|
117
|
+
*,
|
|
118
|
+
pages: list,
|
|
119
|
+
meta: Meta,
|
|
120
|
+
db_path: str,
|
|
121
|
+
table_names: list[str],
|
|
122
|
+
) -> None:
|
|
123
|
+
super().__init__(pages=pages, meta=meta)
|
|
124
|
+
self._db_path = db_path
|
|
125
|
+
self._table_names = frozenset(table_names)
|
|
126
|
+
self._finalizer = weakref.finalize(self, _unlink_quiet, db_path)
|
|
127
|
+
|
|
128
|
+
def table(self, name: str) -> SqliteTable:
|
|
129
|
+
"""Return a handle to ``name`` so the caller can run SQL against it.
|
|
130
|
+
|
|
131
|
+
Raises ``KeyError`` if ``name`` is not a known table or view in
|
|
132
|
+
this database. The available names are exactly the ones present
|
|
133
|
+
as ``page.name`` on ``doc.pages``.
|
|
134
|
+
"""
|
|
135
|
+
if name not in self._table_names:
|
|
136
|
+
raise KeyError(
|
|
137
|
+
f"Unknown table or view: {name!r}. "
|
|
138
|
+
f"Available: {sorted(self._table_names)}"
|
|
139
|
+
)
|
|
140
|
+
return SqliteTable(name, self._db_path)
|
|
141
|
+
|
|
142
|
+
def close(self) -> None:
|
|
143
|
+
"""Eagerly delete the backing temp db file.
|
|
144
|
+
|
|
145
|
+
Safe to call multiple times. Called automatically on ``__exit__``
|
|
146
|
+
and (as a fallback) by the weakref finalizer at GC time.
|
|
147
|
+
"""
|
|
148
|
+
self._finalizer()
|
|
149
|
+
|
|
150
|
+
def __exit__(self, exc_type, exc, tb) -> bool:
|
|
151
|
+
self.close()
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _unlink_quiet(path: str) -> None:
|
|
156
|
+
"""Delete ``path`` if it still exists, swallowing OSError."""
|
|
157
|
+
try:
|
|
158
|
+
os.unlink(path)
|
|
159
|
+
except OSError:
|
|
160
|
+
pass
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class SqliteReader(Reader):
|
|
164
|
+
name = "sqlite-stdlib"
|
|
165
|
+
formats = ("sqlite",)
|
|
166
|
+
is_default = True
|
|
167
|
+
|
|
168
|
+
def read(self, data: bytes, *, source_name: Optional[str] = None, **_) -> Document:
|
|
169
|
+
if not data:
|
|
170
|
+
raise ParseError("sqlite-stdlib reader: input is empty")
|
|
171
|
+
if not data.startswith(b"SQLite format 3\x00"):
|
|
172
|
+
raise ParseError(
|
|
173
|
+
"sqlite-stdlib reader: not a valid SQLite 3 database "
|
|
174
|
+
"(missing 'SQLite format 3' header)"
|
|
175
|
+
)
|
|
176
|
+
|
|
177
|
+
warnings: list[str] = []
|
|
178
|
+
|
|
179
|
+
# sqlite3 only opens from a filesystem path, so spill bytes to a
|
|
180
|
+
# temp file and keep it alive for the Document's lifetime. The
|
|
181
|
+
# SqliteDocument takes ownership of cleanup.
|
|
182
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
183
|
+
prefix="fyle-sqlite-", suffix=".db", delete=False
|
|
184
|
+
)
|
|
185
|
+
tmp.write(data)
|
|
186
|
+
tmp.flush()
|
|
187
|
+
tmp.close()
|
|
188
|
+
tmp_path = tmp.name
|
|
189
|
+
|
|
190
|
+
handed_off = False
|
|
191
|
+
try:
|
|
192
|
+
uri = f"file:{tmp_path}?mode=ro&immutable=1"
|
|
193
|
+
try:
|
|
194
|
+
conn = sqlite3.connect(uri, uri=True)
|
|
195
|
+
except sqlite3.Error as e:
|
|
196
|
+
raise ParseError(f"sqlite3 failed to open database: {e}") from e
|
|
197
|
+
try:
|
|
198
|
+
pages = _build_pages(conn, warnings)
|
|
199
|
+
finally:
|
|
200
|
+
conn.close()
|
|
201
|
+
|
|
202
|
+
if not pages:
|
|
203
|
+
pages.append(
|
|
204
|
+
Page(text="(database contains no user tables or views)", number=1)
|
|
205
|
+
)
|
|
206
|
+
warnings.append("sqlite: no user tables or views found")
|
|
207
|
+
|
|
208
|
+
title: Optional[str] = None
|
|
209
|
+
if source_name:
|
|
210
|
+
try:
|
|
211
|
+
title = Path(source_name).stem or None
|
|
212
|
+
except (TypeError, ValueError):
|
|
213
|
+
title = None
|
|
214
|
+
|
|
215
|
+
try:
|
|
216
|
+
meta = Meta(
|
|
217
|
+
format="sqlite",
|
|
218
|
+
pages=len(pages),
|
|
219
|
+
size=len(data),
|
|
220
|
+
title=title,
|
|
221
|
+
reader=self.name,
|
|
222
|
+
created_at=datetime.now(timezone.utc),
|
|
223
|
+
warnings=warnings,
|
|
224
|
+
)
|
|
225
|
+
doc = SqliteDocument(
|
|
226
|
+
pages=pages,
|
|
227
|
+
meta=meta,
|
|
228
|
+
db_path=tmp_path,
|
|
229
|
+
table_names=[p.name for p in pages if p.name],
|
|
230
|
+
)
|
|
231
|
+
except Exception as e: # pragma: no cover - defensive
|
|
232
|
+
raise ParseError(f"sqlite reader failed to build Document: {e}") from e
|
|
233
|
+
|
|
234
|
+
handed_off = True
|
|
235
|
+
return doc
|
|
236
|
+
finally:
|
|
237
|
+
if not handed_off:
|
|
238
|
+
_unlink_quiet(tmp_path)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _build_pages(conn: sqlite3.Connection, warnings: list[str]) -> list[Page]:
|
|
242
|
+
"""Walk every user table and view, render each as a ``Page``."""
|
|
243
|
+
cur = conn.cursor()
|
|
244
|
+
cur.execute(
|
|
245
|
+
"SELECT name, type FROM sqlite_master "
|
|
246
|
+
"WHERE type IN ('table', 'view') "
|
|
247
|
+
"AND name NOT LIKE 'sqlite_%' "
|
|
248
|
+
"ORDER BY name"
|
|
249
|
+
)
|
|
250
|
+
entries = cur.fetchall()
|
|
251
|
+
|
|
252
|
+
pages: list[Page] = []
|
|
253
|
+
for idx, (obj_name, obj_type) in enumerate(entries, start=1):
|
|
254
|
+
try:
|
|
255
|
+
pages.append(_render_object(conn, obj_name, obj_type, idx, warnings))
|
|
256
|
+
except Exception as e:
|
|
257
|
+
warnings.append(f"sqlite: failed to render {obj_type} {obj_name!r}: {e}")
|
|
258
|
+
pages.append(
|
|
259
|
+
Page(text=f"# {obj_name}\n\n(render failed: {e})", number=idx, name=obj_name)
|
|
260
|
+
)
|
|
261
|
+
return pages
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _render_object(
|
|
265
|
+
conn: sqlite3.Connection,
|
|
266
|
+
obj_name: str,
|
|
267
|
+
obj_type: str,
|
|
268
|
+
page_no: int,
|
|
269
|
+
warnings: list[str],
|
|
270
|
+
) -> Page:
|
|
271
|
+
"""Render one table/view into schema + sample Markdown + structural tables."""
|
|
272
|
+
cur = conn.cursor()
|
|
273
|
+
|
|
274
|
+
# Schema via PRAGMA table_info (works for views too — reports column order
|
|
275
|
+
# and types as SQLite sees them).
|
|
276
|
+
schema_headers = ["column", "type", "nullable", "default", "pk"]
|
|
277
|
+
schema_rows: list[list[str]] = []
|
|
278
|
+
try:
|
|
279
|
+
cur.execute(f'PRAGMA table_info("{obj_name}")')
|
|
280
|
+
for _cid, col_name, col_type, notnull, dflt, pk in cur.fetchall():
|
|
281
|
+
schema_rows.append([
|
|
282
|
+
str(col_name),
|
|
283
|
+
str(col_type or ""),
|
|
284
|
+
"NO" if notnull else "YES",
|
|
285
|
+
"" if dflt is None else str(dflt),
|
|
286
|
+
str(pk) if pk else "",
|
|
287
|
+
])
|
|
288
|
+
except sqlite3.Error as e:
|
|
289
|
+
warnings.append(f"sqlite: PRAGMA table_info({obj_name!r}) failed: {e}")
|
|
290
|
+
|
|
291
|
+
# Row count (views report count of their materialised result).
|
|
292
|
+
total_rows: Optional[int] = None
|
|
293
|
+
try:
|
|
294
|
+
cur.execute(f'SELECT COUNT(*) FROM "{obj_name}"')
|
|
295
|
+
total_rows = int(cur.fetchone()[0])
|
|
296
|
+
except sqlite3.Error as e:
|
|
297
|
+
warnings.append(f"sqlite: COUNT(*) on {obj_name!r} failed: {e}")
|
|
298
|
+
|
|
299
|
+
# Sample rows.
|
|
300
|
+
sample_headers: list[str] = []
|
|
301
|
+
sample_rows: list[list[str]] = []
|
|
302
|
+
try:
|
|
303
|
+
cur.execute(f'SELECT * FROM "{obj_name}" LIMIT {_SAMPLE_ROWS}')
|
|
304
|
+
sample_headers = [d[0] for d in (cur.description or [])]
|
|
305
|
+
for row in cur.fetchall():
|
|
306
|
+
sample_rows.append(["NULL" if v is None else str(v) for v in row])
|
|
307
|
+
except sqlite3.Error as e:
|
|
308
|
+
warnings.append(f"sqlite: SELECT sample on {obj_name!r} failed: {e}")
|
|
309
|
+
|
|
310
|
+
# Markdown assembly.
|
|
311
|
+
md_parts: list[str] = [f"# {obj_name}"]
|
|
312
|
+
kind_hint = "view" if obj_type == "view" else "table"
|
|
313
|
+
if total_rows is not None:
|
|
314
|
+
md_parts.append(f"_type: {kind_hint}, rows: {total_rows}_")
|
|
315
|
+
else:
|
|
316
|
+
md_parts.append(f"_type: {kind_hint}_")
|
|
317
|
+
|
|
318
|
+
md_parts.append("**Schema**")
|
|
319
|
+
md_parts.append(_render_md_table(schema_headers, schema_rows))
|
|
320
|
+
|
|
321
|
+
if sample_headers:
|
|
322
|
+
shown = len(sample_rows)
|
|
323
|
+
if total_rows is not None and total_rows > shown:
|
|
324
|
+
md_parts.append(f"**Sample rows** ({shown} of {total_rows})")
|
|
325
|
+
else:
|
|
326
|
+
md_parts.append(f"**Sample rows** ({shown})")
|
|
327
|
+
md_parts.append(_render_md_table(sample_headers, sample_rows))
|
|
328
|
+
|
|
329
|
+
page_text = "\n\n".join(md_parts)
|
|
330
|
+
|
|
331
|
+
schema_table = Table(
|
|
332
|
+
text=_render_md_table(schema_headers, schema_rows),
|
|
333
|
+
rows=schema_rows,
|
|
334
|
+
headers=schema_headers,
|
|
335
|
+
page=page_no,
|
|
336
|
+
)
|
|
337
|
+
tables: list[Table] = [schema_table]
|
|
338
|
+
if sample_headers:
|
|
339
|
+
tables.append(
|
|
340
|
+
Table(
|
|
341
|
+
text=_render_md_table(sample_headers, sample_rows),
|
|
342
|
+
rows=sample_rows,
|
|
343
|
+
headers=sample_headers,
|
|
344
|
+
page=page_no,
|
|
345
|
+
)
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
return Page(text=page_text, number=page_no, name=obj_name, tables=tables)
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def _render_md_table(headers: list[str], rows: list[list[str]]) -> str:
|
|
352
|
+
"""Render a GFM pipe table. Uses ``tabulate`` when available, else manual."""
|
|
353
|
+
if not headers:
|
|
354
|
+
return "(no columns)"
|
|
355
|
+
try:
|
|
356
|
+
from tabulate import tabulate as _tabulate
|
|
357
|
+
return _tabulate(rows, headers=headers, tablefmt="github")
|
|
358
|
+
except ImportError:
|
|
359
|
+
# Minimal manual fallback; kept simple because ``tabulate`` is a
|
|
360
|
+
# standard fyle dependency so this path is rarely exercised.
|
|
361
|
+
esc = lambda s: str(s).replace("|", "\\|")
|
|
362
|
+
out = ["| " + " | ".join(esc(h) for h in headers) + " |"]
|
|
363
|
+
out.append("|" + "|".join(["---"] * len(headers)) + "|")
|
|
364
|
+
for row in rows:
|
|
365
|
+
out.append("| " + " | ".join(esc(c) for c in row) + " |")
|
|
366
|
+
return "\n".join(out)
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
"""Plain-text reader — passthrough of ``.txt`` / ``.log`` files.
|
|
2
|
+
|
|
3
|
+
File name inside the subpackage is the *core driver library* of the
|
|
4
|
+
reader implementation (here: the Python standard library), so any future
|
|
5
|
+
alternative implementation can live alongside under its own library name.
|
|
6
|
+
"""
|
|
7
|
+
from . import stdlib # noqa: F401
|