parro-api-client 0.5.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.
- parro/__init__.py +17 -0
- parro/api_client.py +413 -0
- parro/archive.py +235 -0
- parro/attachments.py +516 -0
- parro/authenticator.py +292 -0
- parro/cache.py +177 -0
- parro/cli.py +502 -0
- parro/errors.py +79 -0
- parro/geon.py +131 -0
- parro/http.py +158 -0
- parro/icalendar.py +148 -0
- parro/login_methods.py +425 -0
- parro/mcp_server.py +600 -0
- parro/naming.py +26 -0
- parro/operations.py +786 -0
- parro/pkce.py +46 -0
- parro/rendering.py +921 -0
- parro/settings.py +221 -0
- parro/summaries.py +397 -0
- parro/token_store.py +223 -0
- parro_api_client-0.5.0.dist-info/METADATA +396 -0
- parro_api_client-0.5.0.dist-info/RECORD +27 -0
- parro_api_client-0.5.0.dist-info/WHEEL +5 -0
- parro_api_client-0.5.0.dist-info/entry_points.txt +3 -0
- parro_api_client-0.5.0.dist-info/licenses/LICENSE +395 -0
- parro_api_client-0.5.0.dist-info/licenses/LICENSE-CODE +21 -0
- parro_api_client-0.5.0.dist-info/top_level.txt +1 -0
parro/attachments.py
ADDED
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
"""Attachments of announcements and calendar items: find them, download them, read them.
|
|
2
|
+
|
|
3
|
+
Where they live (spec: "Attachments"):
|
|
4
|
+
* announcements keep them in `attachments[]`;
|
|
5
|
+
* calendar items keep them in `calendarItem.attachments[]` (fix 4: never found before).
|
|
6
|
+
Each attachment has `entries[]`: the SOURCE file and, for images, resized variants. Every entry has
|
|
7
|
+
a CloudFront `url` that is unsigned: whoever has it can download the file. So:
|
|
8
|
+
* the Parro Authorization header is never sent to the CDN;
|
|
9
|
+
* full attachment URLs are never logged (fix 7); logs show the host only.
|
|
10
|
+
|
|
11
|
+
The parser is chosen by `contentType`, not by the entry's dtype (fix 6): a photo's SOURCE entry can
|
|
12
|
+
be an RAttachmentDocumentEntry. The file name is only a fallback for missing or generic types.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import base64
|
|
16
|
+
import io
|
|
17
|
+
import logging
|
|
18
|
+
import urllib.parse
|
|
19
|
+
import zipfile
|
|
20
|
+
from dataclasses import dataclass, field
|
|
21
|
+
from html.parser import HTMLParser
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
from typing import TYPE_CHECKING, Any
|
|
24
|
+
from xml.etree import ElementTree
|
|
25
|
+
|
|
26
|
+
from .errors import AttachmentError
|
|
27
|
+
from .geon import get_object_id
|
|
28
|
+
from .http import HttpRequest, HttpResponse
|
|
29
|
+
|
|
30
|
+
if TYPE_CHECKING: # only for type hints: api_client does not import this module back
|
|
31
|
+
from .api_client import ParroClient
|
|
32
|
+
|
|
33
|
+
log = logging.getLogger(__name__)
|
|
34
|
+
|
|
35
|
+
SOURCE_ENTRY_TYPE = "SOURCE"
|
|
36
|
+
PDF_TYPE = "application/pdf"
|
|
37
|
+
DOCX_TYPE = "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
|
|
38
|
+
# Content types that say nothing about the format; then the file extension decides.
|
|
39
|
+
GENERIC_CONTENT_TYPES = frozenset({"", "application/octet-stream", "binary/octet-stream"})
|
|
40
|
+
PLAIN_TEXT_TYPES = frozenset({"application/json", "application/xml", "text/csv"})
|
|
41
|
+
PLAIN_TEXT_EXTENSIONS = frozenset({".txt", ".csv", ".md", ".json", ".xml"})
|
|
42
|
+
HTML_EXTENSIONS = frozenset({".html", ".htm"})
|
|
43
|
+
|
|
44
|
+
# How each format is recognised: by content type first, file extension as a fallback.
|
|
45
|
+
FORMAT_BY_EXTENSION = {
|
|
46
|
+
".pdf": "pdf",
|
|
47
|
+
".docx": "docx",
|
|
48
|
+
**{extension: "html" for extension in HTML_EXTENSIONS},
|
|
49
|
+
**{extension: "text" for extension in PLAIN_TEXT_EXTENSIONS},
|
|
50
|
+
".jpg": "image",
|
|
51
|
+
".jpeg": "image",
|
|
52
|
+
".png": "image",
|
|
53
|
+
".gif": "image",
|
|
54
|
+
".webp": "image",
|
|
55
|
+
".heic": "image",
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class AttachmentFile:
|
|
61
|
+
"""The downloadable SOURCE file of one attachment."""
|
|
62
|
+
|
|
63
|
+
attachment_id: int | None
|
|
64
|
+
kind: str | None # attachmentType, e.g. "PDF" or "IMAGE"
|
|
65
|
+
filename: str | None
|
|
66
|
+
content_type: str | None
|
|
67
|
+
size: int | None
|
|
68
|
+
url: str | None
|
|
69
|
+
upload_id: str | None = None
|
|
70
|
+
|
|
71
|
+
def cache_key(self) -> str | None:
|
|
72
|
+
"""'<attachment id>-<upload id>-<size>': the same key always means the same bytes."""
|
|
73
|
+
if self.attachment_id is None or not self.upload_id or self.size is None:
|
|
74
|
+
return None
|
|
75
|
+
return f"{self.attachment_id}-{self.upload_id}-{self.size}"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def find_attachments(event: dict[str, Any]) -> list[dict[str, Any]]:
|
|
79
|
+
"""All attachments of an event: announcement-style and calendar-item-style (fix 4)."""
|
|
80
|
+
found = [a for a in event.get("attachments") or [] if isinstance(a, dict)]
|
|
81
|
+
calendar_item = event.get("calendarItem")
|
|
82
|
+
if isinstance(calendar_item, dict):
|
|
83
|
+
found.extend(a for a in calendar_item.get("attachments") or [] if isinstance(a, dict))
|
|
84
|
+
return found
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def pick_source_entry(attachment: dict[str, Any]) -> dict[str, Any]:
|
|
88
|
+
"""The original file among the attachment's entries: the SOURCE one, else the first."""
|
|
89
|
+
entries = [entry for entry in attachment.get("entries") or [] if isinstance(entry, dict)]
|
|
90
|
+
for entry in entries:
|
|
91
|
+
if entry.get("type") == SOURCE_ENTRY_TYPE:
|
|
92
|
+
return entry
|
|
93
|
+
return entries[0] if entries else {}
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def describe_attachment(attachment: dict[str, Any]) -> AttachmentFile:
|
|
97
|
+
entry = pick_source_entry(attachment)
|
|
98
|
+
return AttachmentFile(
|
|
99
|
+
attachment_id=get_object_id(attachment),
|
|
100
|
+
kind=attachment.get("attachmentType"),
|
|
101
|
+
filename=entry.get("filename"),
|
|
102
|
+
content_type=entry.get("contentType"),
|
|
103
|
+
size=entry.get("size"),
|
|
104
|
+
url=entry.get("url"),
|
|
105
|
+
upload_id=attachment.get("uploadId"),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def detect_format(content_type: str | None, filename: str | None) -> str | None:
|
|
110
|
+
"""'pdf', 'docx', 'html', 'text', 'image' or None (unknown), chosen by content type first."""
|
|
111
|
+
media_type = (content_type or "").split(";", 1)[0].strip().lower()
|
|
112
|
+
if media_type == PDF_TYPE:
|
|
113
|
+
return "pdf"
|
|
114
|
+
if media_type == DOCX_TYPE:
|
|
115
|
+
return "docx"
|
|
116
|
+
if media_type in ("text/html", "application/xhtml+xml"):
|
|
117
|
+
return "html"
|
|
118
|
+
if media_type.startswith("image/"): # includes the non-standard image/jpg (fix 6)
|
|
119
|
+
return "image"
|
|
120
|
+
if media_type.startswith("text/") or media_type in PLAIN_TEXT_TYPES:
|
|
121
|
+
return "text"
|
|
122
|
+
if media_type not in GENERIC_CONTENT_TYPES:
|
|
123
|
+
return None
|
|
124
|
+
extension = _extension_of(filename)
|
|
125
|
+
return FORMAT_BY_EXTENSION.get(extension) if extension else None
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def can_extract_text(content_type: str | None, filename: str | None) -> bool:
|
|
129
|
+
return detect_format(content_type, filename) in ("pdf", "docx", "html", "text")
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _extension_of(filename: str | None) -> str | None:
|
|
133
|
+
if not filename or "." not in filename:
|
|
134
|
+
return None
|
|
135
|
+
return "." + filename.rsplit(".", 1)[1].lower()
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ------------------------------------------------------------------------------- download
|
|
139
|
+
|
|
140
|
+
# Below these limits a photo is passed on unchanged; above them it is downscaled (if Pillow exists).
|
|
141
|
+
IMAGE_BYTES_WITHOUT_RESIZE = 1_000_000
|
|
142
|
+
JPEG_QUALITY = 85
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@dataclass(frozen=True)
|
|
146
|
+
class DownloadedFile:
|
|
147
|
+
data: bytes = field(repr=False)
|
|
148
|
+
content_type: str # from the entry when known, else what the server sent
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def download_attachment(
|
|
152
|
+
client: "ParroClient", url: str, size_hint: int | None, known_content_type: str | None
|
|
153
|
+
) -> DownloadedFile:
|
|
154
|
+
"""Download one file. CDN URLs get no Parro headers at all; API-host URLs get the usual ones."""
|
|
155
|
+
limit = client.settings.max_download_bytes
|
|
156
|
+
if size_hint and size_hint > limit:
|
|
157
|
+
raise AttachmentError(
|
|
158
|
+
f"The attachment is {size_hint / 1e6:.1f} MB, more than the {limit / 1e6:.0f} MB limit "
|
|
159
|
+
"(raise PARRO_MAX_DOWNLOAD to allow it)."
|
|
160
|
+
)
|
|
161
|
+
scheme = urllib.parse.urlsplit(url).scheme
|
|
162
|
+
if scheme not in ("https", "http"):
|
|
163
|
+
raise AttachmentError("Only http(s) attachment URLs can be downloaded.")
|
|
164
|
+
if url.startswith(client.settings.api_base_uri + "/"):
|
|
165
|
+
response = client.download_from_api_host(url)
|
|
166
|
+
else:
|
|
167
|
+
response = _download_from_cdn(client, url)
|
|
168
|
+
if len(response.body) > limit:
|
|
169
|
+
raise AttachmentError("The attachment is larger than PARRO_MAX_DOWNLOAD allows.")
|
|
170
|
+
served_type = response.header("Content-Type") or ""
|
|
171
|
+
content_type = known_content_type or served_type
|
|
172
|
+
if (known_content_type or "").lower() in GENERIC_CONTENT_TYPES:
|
|
173
|
+
content_type = served_type or (known_content_type or "")
|
|
174
|
+
return DownloadedFile(response.body, content_type)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _download_from_cdn(client: "ParroClient", url: str) -> HttpResponse:
|
|
178
|
+
# The CDN link is unsigned: it needs no token, and the Parro token must never go there.
|
|
179
|
+
host = urllib.parse.urlsplit(url).hostname or "?"
|
|
180
|
+
log.info("downloading an attachment from %s", host) # host only: the path is the key (fix 7)
|
|
181
|
+
request = HttpRequest("GET", url, {"Accept": "*/*"}, log_label=f"GET attachment from {host}")
|
|
182
|
+
response = client.transport.send(request)
|
|
183
|
+
if response.status not in (200, 206):
|
|
184
|
+
raise AttachmentError(
|
|
185
|
+
f"Downloading the attachment from {host} failed (HTTP {response.status})."
|
|
186
|
+
)
|
|
187
|
+
return response
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def filename_from_url(url: str | None) -> str | None:
|
|
191
|
+
"""The last path segment of a URL, decoded: '.../1-abc.pdf' → '1-abc.pdf'."""
|
|
192
|
+
if not url:
|
|
193
|
+
return None
|
|
194
|
+
last_segment = urllib.parse.urlsplit(url).path.rsplit("/", 1)[-1]
|
|
195
|
+
return urllib.parse.unquote(last_segment) or None
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
# ------------------------------------------------------------------------- text extraction
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
@dataclass(frozen=True)
|
|
202
|
+
class ExtractedText:
|
|
203
|
+
text: str
|
|
204
|
+
details: dict[str, Any] # e.g. {"format": "pdf", "pages": 3, "engine": "pymupdf"}
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def extract_text(data: bytes, content_type: str | None, filename: str | None) -> ExtractedText:
|
|
208
|
+
"""Plain text from a PDF, docx, HTML or text file. Images and unknown types raise."""
|
|
209
|
+
file_format = detect_format(content_type, filename)
|
|
210
|
+
if file_format == "pdf":
|
|
211
|
+
pdf = extract_pdf_text(data)
|
|
212
|
+
details = {"format": "pdf", "pages": pdf.page_count, "engine": pdf.engine}
|
|
213
|
+
return ExtractedText(tidy_whitespace(pdf.text), details)
|
|
214
|
+
if file_format == "docx":
|
|
215
|
+
return ExtractedText(tidy_whitespace(extract_docx_text(data)), {"format": "docx"})
|
|
216
|
+
if file_format == "html":
|
|
217
|
+
return ExtractedText(
|
|
218
|
+
tidy_whitespace(extract_html_text(decode_text(data))), {"format": "html"}
|
|
219
|
+
)
|
|
220
|
+
if file_format == "text":
|
|
221
|
+
return ExtractedText(tidy_whitespace(decode_text(data)), {"format": "text"})
|
|
222
|
+
if file_format == "image":
|
|
223
|
+
raise AttachmentError(
|
|
224
|
+
f"This attachment is an image ({content_type or filename}); it holds no text."
|
|
225
|
+
)
|
|
226
|
+
raise AttachmentError(
|
|
227
|
+
f"No text reader for content type {content_type!r} (file {filename!r}). "
|
|
228
|
+
"Save the file instead (CLI: --save-to)."
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
@dataclass(frozen=True)
|
|
233
|
+
class PdfText:
|
|
234
|
+
text: str
|
|
235
|
+
page_count: int
|
|
236
|
+
engine: str # which optional library read it
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def extract_pdf_text(data: bytes) -> PdfText:
|
|
240
|
+
"""Text of every page. Uses PyMuPDF when installed, else pypdf; both are optional."""
|
|
241
|
+
try:
|
|
242
|
+
import fitz # PyMuPDF
|
|
243
|
+
|
|
244
|
+
with fitz.open(stream=data, filetype="pdf") as document:
|
|
245
|
+
page_texts = [page.get_text("text") for page in document]
|
|
246
|
+
return PdfText("\n".join(page_texts), page_count=len(page_texts), engine="pymupdf")
|
|
247
|
+
except ImportError:
|
|
248
|
+
pass
|
|
249
|
+
except Exception as failure: # a damaged PDF: pypdf may still manage
|
|
250
|
+
log.warning("PyMuPDF could not read the PDF (%s); trying pypdf", type(failure).__name__)
|
|
251
|
+
try:
|
|
252
|
+
from pypdf import PdfReader
|
|
253
|
+
except ImportError:
|
|
254
|
+
raise AttachmentError(
|
|
255
|
+
"No PDF library is installed. Install one into this Python: "
|
|
256
|
+
"pip install pymupdf (or: pip install pypdf)"
|
|
257
|
+
) from None
|
|
258
|
+
try:
|
|
259
|
+
page_texts = [page.extract_text() or "" for page in PdfReader(io.BytesIO(data)).pages]
|
|
260
|
+
except Exception as failure:
|
|
261
|
+
raise AttachmentError(f"This PDF could not be read: {failure}") from None
|
|
262
|
+
return PdfText("\n".join(page_texts), page_count=len(page_texts), engine="pypdf")
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
WORD_NAMESPACE = "{http://schemas.openxmlformats.org/wordprocessingml/2006/main}"
|
|
266
|
+
# A zip bomb guard: real newsletters have a document.xml of a few MB at most.
|
|
267
|
+
MAX_DOCX_XML_BYTES = 100 * 1024 * 1024
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def extract_docx_text(data: bytes) -> str:
|
|
271
|
+
"""Paragraph text of a .docx (a zip with word/document.xml), one paragraph per line."""
|
|
272
|
+
try:
|
|
273
|
+
with zipfile.ZipFile(io.BytesIO(data)) as archive:
|
|
274
|
+
if archive.getinfo("word/document.xml").file_size > MAX_DOCX_XML_BYTES:
|
|
275
|
+
raise AttachmentError("This .docx unpacks to an unreasonably large document.")
|
|
276
|
+
document = ElementTree.fromstring(archive.read("word/document.xml"))
|
|
277
|
+
except (zipfile.BadZipFile, KeyError, ElementTree.ParseError) as failure:
|
|
278
|
+
raise AttachmentError(
|
|
279
|
+
f"This .docx file could not be read ({type(failure).__name__})."
|
|
280
|
+
) from None
|
|
281
|
+
lines = []
|
|
282
|
+
for paragraph in document.iter(f"{WORD_NAMESPACE}p"):
|
|
283
|
+
pieces = []
|
|
284
|
+
for element in paragraph.iter():
|
|
285
|
+
if element.tag == f"{WORD_NAMESPACE}t":
|
|
286
|
+
pieces.append(element.text or "")
|
|
287
|
+
elif element.tag == f"{WORD_NAMESPACE}tab":
|
|
288
|
+
pieces.append("\t")
|
|
289
|
+
elif element.tag in (f"{WORD_NAMESPACE}br", f"{WORD_NAMESPACE}cr"):
|
|
290
|
+
pieces.append("\n")
|
|
291
|
+
lines.append("".join(pieces))
|
|
292
|
+
return "\n".join(lines)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
class _HtmlTextCollector(HTMLParser):
|
|
296
|
+
"""Visible text of an HTML document; block elements become line breaks."""
|
|
297
|
+
|
|
298
|
+
SKIPPED = frozenset({"script", "style", "head", "title"})
|
|
299
|
+
LINE_BREAKING = frozenset(
|
|
300
|
+
{"br", "p", "div", "li", "tr", "h1", "h2", "h3", "h4", "h5", "h6", "table", "ul", "ol"}
|
|
301
|
+
)
|
|
302
|
+
|
|
303
|
+
def __init__(self) -> None:
|
|
304
|
+
super().__init__(convert_charrefs=True)
|
|
305
|
+
self.pieces: list[str] = []
|
|
306
|
+
self._skipping_depth = 0
|
|
307
|
+
|
|
308
|
+
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
|
309
|
+
if tag in self.SKIPPED:
|
|
310
|
+
self._skipping_depth += 1
|
|
311
|
+
elif tag == "br":
|
|
312
|
+
self.pieces.append("\n")
|
|
313
|
+
|
|
314
|
+
def handle_endtag(self, tag: str) -> None:
|
|
315
|
+
if tag in self.SKIPPED and self._skipping_depth:
|
|
316
|
+
self._skipping_depth -= 1
|
|
317
|
+
elif tag in self.LINE_BREAKING:
|
|
318
|
+
self.pieces.append("\n")
|
|
319
|
+
|
|
320
|
+
def handle_data(self, data: str) -> None:
|
|
321
|
+
if not self._skipping_depth:
|
|
322
|
+
self.pieces.append(data)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def extract_html_text(html: str) -> str:
|
|
326
|
+
collector = _HtmlTextCollector()
|
|
327
|
+
collector.feed(html)
|
|
328
|
+
collector.close()
|
|
329
|
+
return "".join(collector.pieces)
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def decode_text(data: bytes) -> str:
|
|
333
|
+
"""UTF-8 (a byte-order mark is dropped); undecodable bytes become U+FFFD."""
|
|
334
|
+
return data.decode("utf-8-sig", errors="replace")
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def tidy_whitespace(text: str) -> str:
|
|
338
|
+
"""One space between words, no trailing spaces, at most one empty line in a row."""
|
|
339
|
+
tidy_lines: list[str] = []
|
|
340
|
+
for line in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"):
|
|
341
|
+
words = line.replace("\t", " ").split()
|
|
342
|
+
tidy_line = " ".join(words)
|
|
343
|
+
if tidy_line or (tidy_lines and tidy_lines[-1]):
|
|
344
|
+
tidy_lines.append(tidy_line)
|
|
345
|
+
return "\n".join(tidy_lines).strip()
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ----------------------------------------------------------------------------------- images
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
def shrink_image(data: bytes, content_type: str, max_pixels: int) -> tuple[bytes, str]:
|
|
352
|
+
"""Downscale a large photo to at most `max_pixels` on its longest side, as JPEG.
|
|
353
|
+
|
|
354
|
+
Phone photos are several MB; as base64 that is a lot of tokens for no extra detail. Without
|
|
355
|
+
Pillow (optional) the original is returned unchanged.
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
from PIL import Image
|
|
359
|
+
except ImportError:
|
|
360
|
+
return data, content_type
|
|
361
|
+
try:
|
|
362
|
+
with Image.open(io.BytesIO(data)) as image:
|
|
363
|
+
if max(image.size) <= max_pixels and len(data) <= IMAGE_BYTES_WITHOUT_RESIZE:
|
|
364
|
+
return data, content_type
|
|
365
|
+
image.thumbnail((max_pixels, max_pixels))
|
|
366
|
+
converted = image if image.mode in ("RGB", "L") else image.convert("RGB")
|
|
367
|
+
output = io.BytesIO()
|
|
368
|
+
converted.save(output, format="JPEG", quality=JPEG_QUALITY, optimize=True)
|
|
369
|
+
except Exception as failure: # an unreadable image: better the original than nothing
|
|
370
|
+
log.warning("could not downscale an image (%s); sending it as is", type(failure).__name__)
|
|
371
|
+
return data, content_type
|
|
372
|
+
return output.getvalue(), "image/jpeg"
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
# ------------------------------------------------------------------------- one attachment
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@dataclass(frozen=True)
|
|
379
|
+
class ReadOptions:
|
|
380
|
+
"""How to present a downloaded attachment."""
|
|
381
|
+
|
|
382
|
+
max_chars: int = 40_000
|
|
383
|
+
include_images: bool = True # MCP: return photos as images; CLI: never (it prints JSON)
|
|
384
|
+
save_directory: Path | None = None # CLI --save-to: also keep the original file
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
@dataclass(frozen=True)
|
|
388
|
+
class AttachmentImage:
|
|
389
|
+
base64_data: str = field(repr=False)
|
|
390
|
+
mime_type: str
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
@dataclass(frozen=True)
|
|
394
|
+
class AttachmentReading:
|
|
395
|
+
"""The outcome for one file: a JSON-ready summary and, for photos, the image itself."""
|
|
396
|
+
|
|
397
|
+
summary: dict[str, Any]
|
|
398
|
+
image: AttachmentImage | None = None
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
def read_attachment_file(
|
|
402
|
+
client: "ParroClient",
|
|
403
|
+
url: str,
|
|
404
|
+
filename: str | None,
|
|
405
|
+
content_type: str | None,
|
|
406
|
+
size_hint: int | None,
|
|
407
|
+
options: ReadOptions,
|
|
408
|
+
cache_key: str | None = None,
|
|
409
|
+
) -> AttachmentReading:
|
|
410
|
+
"""Download one attachment and return its text, or the (downscaled) image for photos.
|
|
411
|
+
|
|
412
|
+
With a `cache_key` (see AttachmentFile.cache_key) the file and its text come from the cache
|
|
413
|
+
when they are there, and go into it when they are not.
|
|
414
|
+
"""
|
|
415
|
+
downloaded = download_or_reuse(client, url, size_hint, content_type, cache_key)
|
|
416
|
+
name = filename or filename_from_url(url)
|
|
417
|
+
summary: dict[str, Any] = {
|
|
418
|
+
"filename": name,
|
|
419
|
+
"contentType": downloaded.content_type,
|
|
420
|
+
"bytes": size_hint or len(downloaded.data),
|
|
421
|
+
}
|
|
422
|
+
if options.save_directory is not None:
|
|
423
|
+
summary["saved_to"] = str(save_file(options.save_directory, name, downloaded.data))
|
|
424
|
+
if detect_format(downloaded.content_type, name) == "image":
|
|
425
|
+
return _read_image(client, downloaded, summary, options)
|
|
426
|
+
extracted = extract_or_reuse(client, downloaded, name, cache_key)
|
|
427
|
+
return AttachmentReading(_text_summary(summary, extracted, url, options.max_chars))
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def download_or_reuse(
|
|
431
|
+
client: "ParroClient",
|
|
432
|
+
url: str,
|
|
433
|
+
size_hint: int | None,
|
|
434
|
+
content_type: str | None,
|
|
435
|
+
cache_key: str | None,
|
|
436
|
+
) -> DownloadedFile:
|
|
437
|
+
"""The file from the cache, or downloaded (and then cached)."""
|
|
438
|
+
if cache_key:
|
|
439
|
+
data = client.cache.get_bytes("attachments", f"{cache_key}.bin")
|
|
440
|
+
meta = client.cache.get_json("attachments", f"{cache_key}.json")
|
|
441
|
+
if data is not None and isinstance(meta, dict):
|
|
442
|
+
log.info("attachment taken from the cache")
|
|
443
|
+
return DownloadedFile(data, str(meta.get("contentType") or content_type or ""))
|
|
444
|
+
downloaded = download_attachment(client, url, size_hint, content_type)
|
|
445
|
+
if cache_key:
|
|
446
|
+
client.cache.put_bytes("attachments", f"{cache_key}.bin", downloaded.data)
|
|
447
|
+
client.cache.put_json(
|
|
448
|
+
"attachments", f"{cache_key}.json", {"contentType": downloaded.content_type}
|
|
449
|
+
)
|
|
450
|
+
return downloaded
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def extract_or_reuse(
|
|
454
|
+
client: "ParroClient", downloaded: DownloadedFile, name: str | None, cache_key: str | None
|
|
455
|
+
) -> ExtractedText:
|
|
456
|
+
"""The text from the cache, or extracted (and then cached). Always the whole text."""
|
|
457
|
+
if cache_key:
|
|
458
|
+
cached = client.cache.get_json("attachments", f"{cache_key}.text.json")
|
|
459
|
+
if isinstance(cached, dict) and isinstance(cached.get("text"), str):
|
|
460
|
+
return ExtractedText(cached["text"], dict(cached.get("details") or {}))
|
|
461
|
+
extracted = extract_text(downloaded.data, downloaded.content_type, name)
|
|
462
|
+
if cache_key:
|
|
463
|
+
client.cache.put_json(
|
|
464
|
+
"attachments",
|
|
465
|
+
f"{cache_key}.text.json",
|
|
466
|
+
{"text": extracted.text, "details": extracted.details},
|
|
467
|
+
)
|
|
468
|
+
return extracted
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _read_image(
|
|
472
|
+
client: "ParroClient", downloaded: DownloadedFile, summary: dict[str, Any], options: ReadOptions
|
|
473
|
+
) -> AttachmentReading:
|
|
474
|
+
if not options.include_images:
|
|
475
|
+
summary["returned_as"] = "nothing (an image; save it with --save-to)"
|
|
476
|
+
return AttachmentReading(summary)
|
|
477
|
+
data, mime_type = shrink_image(
|
|
478
|
+
downloaded.data, downloaded.content_type, client.settings.max_image_pixels
|
|
479
|
+
)
|
|
480
|
+
summary["returned_as"] = "image"
|
|
481
|
+
image = AttachmentImage(base64.b64encode(data).decode("ascii"), mime_type)
|
|
482
|
+
return AttachmentReading(summary, image)
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
def _text_summary(
|
|
486
|
+
summary: dict[str, Any], extracted: ExtractedText, url: str, max_chars: int
|
|
487
|
+
) -> dict[str, Any]:
|
|
488
|
+
text = extracted.text
|
|
489
|
+
summary["chars"] = len(text)
|
|
490
|
+
summary.update(extracted.details)
|
|
491
|
+
if len(text) > max_chars:
|
|
492
|
+
summary["truncated"] = True
|
|
493
|
+
summary["shown_chars"] = max_chars
|
|
494
|
+
summary["hint"] = "Raise `max_chars` to read the rest."
|
|
495
|
+
text = text[:max_chars]
|
|
496
|
+
if not text.strip():
|
|
497
|
+
summary["warning"] = (
|
|
498
|
+
"No text found. This is probably a scanned or image-only document, which would "
|
|
499
|
+
"need OCR. The file itself can still be opened from its url."
|
|
500
|
+
)
|
|
501
|
+
summary["url"] = url # output for the user, never logged (fix 7)
|
|
502
|
+
summary["text"] = text
|
|
503
|
+
return summary
|
|
504
|
+
|
|
505
|
+
|
|
506
|
+
def save_file(directory: Path, filename: str | None, data: bytes) -> Path:
|
|
507
|
+
"""Write `data` into `directory` under a safe version of `filename`, never overwriting."""
|
|
508
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
509
|
+
safe_name = Path(filename or "attachment").name.replace("\\", "_") or "attachment"
|
|
510
|
+
target = directory / safe_name
|
|
511
|
+
counter = 2
|
|
512
|
+
while target.exists():
|
|
513
|
+
target = directory / f"{Path(safe_name).stem} ({counter}){Path(safe_name).suffix}"
|
|
514
|
+
counter += 1
|
|
515
|
+
target.write_bytes(data)
|
|
516
|
+
return target
|