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,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"![{caption or ''}]({data_url})"
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,8 @@
1
+ """SQLite reader.
2
+
3
+ File naming rule: ``stdlib.py`` — the core driver is Python's built-in
4
+ ``sqlite3`` module.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ from . import stdlib # noqa: F401
@@ -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