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,136 @@
|
|
|
1
|
+
"""Image reader implementation backed by the Python standard library.
|
|
2
|
+
|
|
3
|
+
Strategy:
|
|
4
|
+
- Detect the concrete image subtype (png / jpeg / webp / ...) from magic
|
|
5
|
+
bytes or the file extension.
|
|
6
|
+
- Wrap the raw bytes as a ``data:<mime>;base64,<payload>`` URL.
|
|
7
|
+
- Expose the image both as an ``Image`` element and as a Markdown image
|
|
8
|
+
token in ``Page.text`` so the document can be fed directly into a
|
|
9
|
+
multimodal LLM prompt.
|
|
10
|
+
- Optionally record pixel dimensions in ``meta.warnings`` if Pillow is
|
|
11
|
+
available; failure to read dimensions never fails the parse.
|
|
12
|
+
|
|
13
|
+
Why no OCR here: fyle's contract is "open anything, return Markdown".
|
|
14
|
+
OCR / VLM calls are an application-level choice (network, cost, model
|
|
15
|
+
selection) and are deliberately kept out of the reader layer.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import base64
|
|
20
|
+
from datetime import datetime, timezone
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
from typing import Optional
|
|
23
|
+
|
|
24
|
+
from ..base import Reader
|
|
25
|
+
from ..._core.document import Document, Image, Meta, Page
|
|
26
|
+
from ...errors import ParseError
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# Magic-byte prefixes that pin down the concrete image subtype. Order
|
|
30
|
+
# matters: WEBP also starts with ``RIFF``, so it is checked after the
|
|
31
|
+
# RIFF-aware case below.
|
|
32
|
+
_MAGIC_MIME: list[tuple[bytes, str]] = [
|
|
33
|
+
(b"\x89PNG\r\n\x1a\n", "image/png"),
|
|
34
|
+
(b"\xff\xd8\xff", "image/jpeg"),
|
|
35
|
+
(b"GIF87a", "image/gif"),
|
|
36
|
+
(b"GIF89a", "image/gif"),
|
|
37
|
+
(b"BM", "image/bmp"),
|
|
38
|
+
# TIFF little / big endian
|
|
39
|
+
(b"II*\x00", "image/tiff"),
|
|
40
|
+
(b"MM\x00*", "image/tiff"),
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
# Fallback extension -> MIME if magic-byte detection missed.
|
|
44
|
+
_EXT_MIME: dict[str, str] = {
|
|
45
|
+
".png": "image/png",
|
|
46
|
+
".jpg": "image/jpeg",
|
|
47
|
+
".jpeg": "image/jpeg",
|
|
48
|
+
".gif": "image/gif",
|
|
49
|
+
".bmp": "image/bmp",
|
|
50
|
+
".tif": "image/tiff",
|
|
51
|
+
".tiff": "image/tiff",
|
|
52
|
+
".webp": "image/webp",
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _detect_mime(data: bytes, source_name: Optional[str]) -> str:
|
|
57
|
+
"""Return the best-guess ``image/*`` MIME for ``data``.
|
|
58
|
+
|
|
59
|
+
Falls back to ``application/octet-stream`` only if nothing matches;
|
|
60
|
+
callers receive a usable data URL either way.
|
|
61
|
+
"""
|
|
62
|
+
# WEBP: ``RIFF....WEBP`` (4-byte size between RIFF and WEBP).
|
|
63
|
+
if len(data) >= 12 and data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
64
|
+
return "image/webp"
|
|
65
|
+
for prefix, mime in _MAGIC_MIME:
|
|
66
|
+
if data.startswith(prefix):
|
|
67
|
+
return mime
|
|
68
|
+
if source_name:
|
|
69
|
+
ext = Path(source_name).suffix.lower()
|
|
70
|
+
if ext in _EXT_MIME:
|
|
71
|
+
return _EXT_MIME[ext]
|
|
72
|
+
return "application/octet-stream"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _try_dimensions(data: bytes, warnings: list[str]) -> Optional[tuple[int, int]]:
|
|
76
|
+
"""Best-effort (width, height) via Pillow. Never raises on failure."""
|
|
77
|
+
try:
|
|
78
|
+
from PIL import Image as _PILImage
|
|
79
|
+
except ImportError:
|
|
80
|
+
return None
|
|
81
|
+
try:
|
|
82
|
+
import io as _io
|
|
83
|
+
with _PILImage.open(_io.BytesIO(data)) as im:
|
|
84
|
+
return int(im.width), int(im.height)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
warnings.append(f"image dimension read failed: {e}")
|
|
87
|
+
return None
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class ImageReader(Reader):
|
|
91
|
+
name = "image-stdlib"
|
|
92
|
+
formats = ("image",)
|
|
93
|
+
is_default = True
|
|
94
|
+
|
|
95
|
+
def read(self, data: bytes, *, source_name: Optional[str] = None, **_) -> Document:
|
|
96
|
+
if not data:
|
|
97
|
+
raise ParseError("image-stdlib reader: input is empty")
|
|
98
|
+
|
|
99
|
+
warnings: list[str] = []
|
|
100
|
+
|
|
101
|
+
mime = _detect_mime(data, source_name)
|
|
102
|
+
b64 = base64.b64encode(data).decode("ascii")
|
|
103
|
+
data_url = f"data:{mime};base64,{b64}"
|
|
104
|
+
|
|
105
|
+
# Caption / title: filename stem if available, else the format name.
|
|
106
|
+
stem: Optional[str] = None
|
|
107
|
+
if source_name:
|
|
108
|
+
try:
|
|
109
|
+
stem = Path(source_name).stem or None
|
|
110
|
+
except (TypeError, ValueError):
|
|
111
|
+
stem = None
|
|
112
|
+
caption = stem or mime.split("/", 1)[-1]
|
|
113
|
+
|
|
114
|
+
dims = _try_dimensions(data, warnings)
|
|
115
|
+
if dims is not None:
|
|
116
|
+
warnings.append(f"image dimensions: {dims[0]}x{dims[1]}")
|
|
117
|
+
|
|
118
|
+
# Markdown image token. LLM prompts can consume this verbatim.
|
|
119
|
+
page_text = f""
|
|
120
|
+
|
|
121
|
+
image = Image(data_url=data_url, data=data, caption=caption, page=1)
|
|
122
|
+
|
|
123
|
+
try:
|
|
124
|
+
page = Page(text=page_text, number=1, images=[image])
|
|
125
|
+
meta = Meta(
|
|
126
|
+
format="image",
|
|
127
|
+
pages=1,
|
|
128
|
+
size=len(data),
|
|
129
|
+
title=stem,
|
|
130
|
+
reader=self.name,
|
|
131
|
+
created_at=datetime.now(timezone.utc),
|
|
132
|
+
warnings=warnings,
|
|
133
|
+
)
|
|
134
|
+
return Document(pages=[page], meta=meta)
|
|
135
|
+
except Exception as e: # pragma: no cover - defensive
|
|
136
|
+
raise ParseError(f"image reader failed to build Document: {e}") from e
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
"""Markdown reader backed by the Python standard library.
|
|
2
|
+
|
|
3
|
+
For ``.md`` / ``.markdown`` inputs the content *is already* our target
|
|
4
|
+
representation, so ``Page.text`` is a **byte-preserving passthrough** of
|
|
5
|
+
the decoded source. On top of that passthrough we populate ``doc.tables``
|
|
6
|
+
and ``doc.images`` via the shared Markdown structure extractor
|
|
7
|
+
(``_md_structure``), which delegates to ``markdown-it-py`` + BeautifulSoup.
|
|
8
|
+
|
|
9
|
+
File naming rule: ``stdlib.py`` — the *core* dependency is the Python
|
|
10
|
+
standard library (decoding). ``markdown-it-py`` and BeautifulSoup are
|
|
11
|
+
ancillary parsers used for structural extraction and do not determine the
|
|
12
|
+
file name.
|
|
13
|
+
"""
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from datetime import datetime, timezone
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Optional
|
|
19
|
+
|
|
20
|
+
from ..base import Reader
|
|
21
|
+
from ..._core.document import Document, Meta, Page
|
|
22
|
+
from ...errors import ParseError
|
|
23
|
+
from .._md_structure import extract_images, extract_tables
|
|
24
|
+
from ..text.stdlib import _decode_text
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class MarkdownReader(Reader):
|
|
28
|
+
name = "markdown-stdlib"
|
|
29
|
+
formats = ("markdown",)
|
|
30
|
+
is_default = True
|
|
31
|
+
|
|
32
|
+
def read(self, data: bytes, *, source_name: Optional[str] = None, **_) -> Document:
|
|
33
|
+
warnings: list[str] = []
|
|
34
|
+
text, decode_warning = _decode_text(data)
|
|
35
|
+
if decode_warning:
|
|
36
|
+
warnings.append(decode_warning)
|
|
37
|
+
|
|
38
|
+
title: Optional[str] = None
|
|
39
|
+
if source_name:
|
|
40
|
+
try:
|
|
41
|
+
title = Path(source_name).stem or None
|
|
42
|
+
except (TypeError, ValueError):
|
|
43
|
+
title = None
|
|
44
|
+
|
|
45
|
+
tables = extract_tables(text, page=1, warnings=warnings)
|
|
46
|
+
images = extract_images(text, page=1, warnings=warnings, include_html_img=True)
|
|
47
|
+
|
|
48
|
+
try:
|
|
49
|
+
page = Page(text=text, number=1, tables=tables, images=images)
|
|
50
|
+
meta = Meta(
|
|
51
|
+
format="markdown",
|
|
52
|
+
pages=1,
|
|
53
|
+
size=len(data),
|
|
54
|
+
title=title,
|
|
55
|
+
reader=self.name,
|
|
56
|
+
created_at=datetime.now(timezone.utc),
|
|
57
|
+
warnings=warnings,
|
|
58
|
+
)
|
|
59
|
+
return Document(pages=[page], meta=meta)
|
|
60
|
+
except Exception as e: # pragma: no cover - defensive
|
|
61
|
+
raise ParseError(f"markdown-stdlib reader failed: {e}") from e
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""PDF reader powered by ``pymupdf4llm`` — the default PDF reader.
|
|
2
|
+
|
|
3
|
+
Rationale: ``pymupdf4llm`` (maintained by the PyMuPDF team) already produces
|
|
4
|
+
LLM-friendly Markdown from PDFs and handles headings, lists, tables, and
|
|
5
|
+
embedded images. It is substantially less work than rebuilding those heuristics
|
|
6
|
+
on top of raw PyMuPDF.
|
|
7
|
+
|
|
8
|
+
Strategy:
|
|
9
|
+
- Open the PDF once with ``pymupdf`` to read document-level metadata.
|
|
10
|
+
- Call ``pymupdf4llm.to_markdown(doc, page_chunks=True, embed_images=True)`` to
|
|
11
|
+
get per-page Markdown with images inlined as base64 data URLs.
|
|
12
|
+
- Populate ``Page.text`` from the returned per-page Markdown. ``Page.tables``
|
|
13
|
+
and ``Page.images`` are back-filled via the shared ``_md_structure``
|
|
14
|
+
extractor (GFM table tokens + native / HTML image references) so the
|
|
15
|
+
surface stays consistent with the Markdown / DOCX / HTML readers.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import contextlib
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from typing import Iterator, Optional
|
|
24
|
+
|
|
25
|
+
from ..._core.document import Document, Meta, Page
|
|
26
|
+
from ...errors import ParseError
|
|
27
|
+
from ..base import Reader
|
|
28
|
+
from .._md_structure import extract_images, extract_tables
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class PyMuPdf4LlmReader(Reader):
|
|
32
|
+
name = "pymupdf4llm"
|
|
33
|
+
formats = ("pdf",)
|
|
34
|
+
is_default = True
|
|
35
|
+
|
|
36
|
+
def read(self, data: bytes, *, source_name: Optional[str] = None, **_) -> Document:
|
|
37
|
+
try:
|
|
38
|
+
import pymupdf as fitz # PyMuPDF >= 1.24
|
|
39
|
+
except ImportError:
|
|
40
|
+
try:
|
|
41
|
+
import fitz # type: ignore[no-redef]
|
|
42
|
+
except ImportError as e:
|
|
43
|
+
raise ParseError(
|
|
44
|
+
"PyMuPDF (pymupdf) is required: pip install pymupdf"
|
|
45
|
+
) from e
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
import pymupdf4llm # noqa: F401
|
|
49
|
+
except ImportError as e:
|
|
50
|
+
raise ParseError(
|
|
51
|
+
"pymupdf4llm is required for PDF parsing: pip install pymupdf4llm"
|
|
52
|
+
) from e
|
|
53
|
+
|
|
54
|
+
warnings: list[str] = []
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
pdf = fitz.open(stream=data, filetype="pdf")
|
|
58
|
+
except Exception as e:
|
|
59
|
+
raise ParseError(f"Failed to open PDF: {e}") from e
|
|
60
|
+
|
|
61
|
+
try:
|
|
62
|
+
try:
|
|
63
|
+
# pymupdf4llm + MuPDF emit a ``=== Document parser
|
|
64
|
+
# messages ===`` banner (e.g. "Using Tesseract for OCR
|
|
65
|
+
# processing.", "OCR on page.number=0/1.") during
|
|
66
|
+
# ``to_markdown``. Some lines come from Python
|
|
67
|
+
# ``print`` calls, but the per-page OCR progress and
|
|
68
|
+
# MuPDF warnings are emitted by the C extension
|
|
69
|
+
# directly via ``fprintf(stderr, ...)`` / ``stdout``,
|
|
70
|
+
# which ``contextlib.redirect_stdout`` cannot touch
|
|
71
|
+
# because it only rebinds ``sys.stdout`` at the Python
|
|
72
|
+
# level. We therefore redirect file descriptors 1 and 2
|
|
73
|
+
# at the OS level — the only way to silence a C
|
|
74
|
+
# extension's writes — for the duration of the call.
|
|
75
|
+
# ``show_progress=False`` still helps but does not
|
|
76
|
+
# cover the banner or the OCR lines.
|
|
77
|
+
with _silence_fds():
|
|
78
|
+
chunks = pymupdf4llm.to_markdown(
|
|
79
|
+
pdf,
|
|
80
|
+
page_chunks=True,
|
|
81
|
+
embed_images=True,
|
|
82
|
+
write_images=False,
|
|
83
|
+
show_progress=False,
|
|
84
|
+
)
|
|
85
|
+
except Exception as e:
|
|
86
|
+
raise ParseError(f"pymupdf4llm.to_markdown failed: {e}") from e
|
|
87
|
+
|
|
88
|
+
pages: list[Page] = []
|
|
89
|
+
for idx, chunk in enumerate(chunks, start=1):
|
|
90
|
+
text = (chunk.get("text") or "").strip()
|
|
91
|
+
tables = extract_tables(text, page=idx, warnings=warnings)
|
|
92
|
+
images = extract_images(
|
|
93
|
+
text, page=idx, warnings=warnings, include_html_img=True
|
|
94
|
+
)
|
|
95
|
+
pages.append(Page(text=text, number=idx, tables=tables, images=images))
|
|
96
|
+
|
|
97
|
+
if not pages:
|
|
98
|
+
# Degenerate case: pymupdf4llm returned nothing. Keep at least
|
|
99
|
+
# one empty page so ``doc.pages`` is never an empty list.
|
|
100
|
+
pages.append(Page(text="", number=1))
|
|
101
|
+
warnings.append("pymupdf4llm returned no page chunks")
|
|
102
|
+
|
|
103
|
+
md = pdf.metadata or {}
|
|
104
|
+
title = _clean(md.get("title"))
|
|
105
|
+
author = _clean(md.get("author"))
|
|
106
|
+
created_at = _parse_pdf_date(md.get("creationDate"))
|
|
107
|
+
|
|
108
|
+
if not title and source_name:
|
|
109
|
+
title = source_name
|
|
110
|
+
|
|
111
|
+
meta = Meta(
|
|
112
|
+
format="pdf",
|
|
113
|
+
pages=len(pages),
|
|
114
|
+
size=len(data),
|
|
115
|
+
title=title,
|
|
116
|
+
author=author,
|
|
117
|
+
created_at=created_at,
|
|
118
|
+
reader=self.name,
|
|
119
|
+
warnings=warnings,
|
|
120
|
+
)
|
|
121
|
+
finally:
|
|
122
|
+
try:
|
|
123
|
+
pdf.close()
|
|
124
|
+
except Exception:
|
|
125
|
+
pass
|
|
126
|
+
|
|
127
|
+
return Document(pages=pages, meta=meta)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _clean(value) -> Optional[str]:
|
|
131
|
+
if not value:
|
|
132
|
+
return None
|
|
133
|
+
if isinstance(value, bytes):
|
|
134
|
+
try:
|
|
135
|
+
value = value.decode("utf-8", errors="ignore")
|
|
136
|
+
except Exception:
|
|
137
|
+
return None
|
|
138
|
+
s = str(value).strip()
|
|
139
|
+
return s or None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
@contextlib.contextmanager
|
|
143
|
+
def _silence_fds() -> Iterator[None]:
|
|
144
|
+
"""Redirect fds 1 and 2 to ``/dev/null`` at the OS level.
|
|
145
|
+
|
|
146
|
+
``contextlib.redirect_stdout`` only rebinds ``sys.stdout`` at the
|
|
147
|
+
Python level, so any C extension that calls ``fprintf(stdout, ...)``
|
|
148
|
+
or ``fprintf(stderr, ...)`` directly (MuPDF warnings, the Tesseract
|
|
149
|
+
OCR progress banner, etc.) bypasses it. Duplicating ``/dev/null``
|
|
150
|
+
onto file descriptors 1 and 2 is the only way to silence those
|
|
151
|
+
writes without a third-party dependency. fds are restored on exit
|
|
152
|
+
even if the wrapped call raises.
|
|
153
|
+
"""
|
|
154
|
+
# Flush Python-level buffers so nothing we care about ends up
|
|
155
|
+
# arriving at the silenced fds.
|
|
156
|
+
sys.stdout.flush()
|
|
157
|
+
sys.stderr.flush()
|
|
158
|
+
saved_stdout_fd = os.dup(1)
|
|
159
|
+
saved_stderr_fd = os.dup(2)
|
|
160
|
+
devnull_fd = os.open(os.devnull, os.O_WRONLY)
|
|
161
|
+
try:
|
|
162
|
+
os.dup2(devnull_fd, 1)
|
|
163
|
+
os.dup2(devnull_fd, 2)
|
|
164
|
+
try:
|
|
165
|
+
yield
|
|
166
|
+
finally:
|
|
167
|
+
sys.stdout.flush()
|
|
168
|
+
sys.stderr.flush()
|
|
169
|
+
os.dup2(saved_stdout_fd, 1)
|
|
170
|
+
os.dup2(saved_stderr_fd, 2)
|
|
171
|
+
finally:
|
|
172
|
+
os.close(devnull_fd)
|
|
173
|
+
os.close(saved_stdout_fd)
|
|
174
|
+
os.close(saved_stderr_fd)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _parse_pdf_date(s) -> Optional[datetime]:
|
|
178
|
+
"""Parse the PDF standard date format ``D:YYYYMMDDHHmmSS+OFFSET``.
|
|
179
|
+
|
|
180
|
+
Returns ``None`` on any parse failure.
|
|
181
|
+
"""
|
|
182
|
+
if not s:
|
|
183
|
+
return None
|
|
184
|
+
if isinstance(s, bytes):
|
|
185
|
+
try:
|
|
186
|
+
s = s.decode("utf-8", errors="ignore")
|
|
187
|
+
except Exception:
|
|
188
|
+
return None
|
|
189
|
+
s = str(s).strip()
|
|
190
|
+
if s.startswith("D:"):
|
|
191
|
+
s = s[2:]
|
|
192
|
+
if len(s) >= 14:
|
|
193
|
+
try:
|
|
194
|
+
return datetime.strptime(s[:14], "%Y%m%d%H%M%S")
|
|
195
|
+
except ValueError:
|
|
196
|
+
pass
|
|
197
|
+
if len(s) >= 8:
|
|
198
|
+
try:
|
|
199
|
+
return datetime.strptime(s[:8], "%Y%m%d")
|
|
200
|
+
except ValueError:
|
|
201
|
+
pass
|
|
202
|
+
return None
|