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/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