feat(raw): raw derive reads PDF and Outlook MSG; doctor shows each import filter
PDF is read with pypdf (page markers, scan warning, encrypted files refused), Outlook .msg with olefile as a container like an .eml, and a .msg attached to a mail is quoted. Both packages are in tools/requirements.txt and imported only when such a file is derived, so a missing one stops that format alone. doctor reports one import-filter line per package-backed format. Files changed: - CHANGES.md - VERSION - docs/how-to/ingest-a-source.md - docs/reference/commands/instance-health.md - docs/reference/commands/raw-material-and-uploads.md - tools/CONTRACT.md - tools/README.md - tools/chemenu/commands/doctor.py - tools/chemenu/commands/raw_cmd.py - tools/chemenu/filters/__init__.py - tools/chemenu/filters/mail.py - tools/chemenu/filters/msg.py - tools/chemenu/filters/pdf.py - tools/chemenu/tests/binary_formats.py - tools/chemenu/tests/test_raw_derive_binary.py - tools/requirements.txt Co-Authored-By: Claude Code <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SnAJ7Z3CpVD3PRbN73QtU2
This commit is contained in:
1 parent
6659ca5409
commit
0e6a50aa98
16 files changed
+812
-17
No files matched your search
@@ -0,0 +1,224 @@
|
||||
"""Builders for the two binary formats `raw derive` reads with a package: a PDF and an
|
||||
Outlook `.msg` (a compound file). Built here, byte by byte, so no binary sits in the
|
||||
repository and every fixture is readable in the test that uses it. Whether each one is a
|
||||
valid document is settled by the package that reads it in the tests."""
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import Optional
|
||||
|
||||
END, FREE, FATSECT = 0xFFFFFFFE, 0xFFFFFFFF, 0xFFFFFFFD
|
||||
|
||||
|
||||
def build_pdf(pages: list[list[str]], *, title: Optional[str] = None) -> bytes:
|
||||
"""A PDF with one text line per list item; an empty page is an empty content stream."""
|
||||
objects: dict[int, bytes] = {}
|
||||
page_ids = [3 + 2 * n for n in range(len(pages))]
|
||||
font_id = 3 + 2 * len(pages)
|
||||
info_id = font_id + 1
|
||||
objects[1] = b"<< /Type /Catalog /Pages 2 0 R >>"
|
||||
kids = " ".join(f"{page} 0 R" for page in page_ids)
|
||||
objects[2] = f"<< /Type /Pages /Kids [{kids}] /Count {len(pages)} >>".encode()
|
||||
for page_id, lines in zip(page_ids, pages):
|
||||
ops = ["BT /F1 12 Tf 72 720 Td 14 TL"]
|
||||
for line in lines:
|
||||
escaped = line.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
|
||||
ops.append(f"({escaped}) Tj T*")
|
||||
ops.append("ET")
|
||||
stream = "\n".join(ops).encode("latin-1") if lines else b""
|
||||
objects[page_id] = (
|
||||
f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Contents {page_id + 1} 0 R "
|
||||
f"/Resources << /Font << /F1 {font_id} 0 R >> >> >>"
|
||||
).encode()
|
||||
objects[page_id + 1] = b"<< /Length %d >>\nstream\n" % len(stream) + stream + b"\nendstream"
|
||||
objects[font_id] = b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>"
|
||||
trailer = "/Root 1 0 R"
|
||||
if title is not None:
|
||||
objects[info_id] = f"<< /Title ({title}) >>".encode("latin-1")
|
||||
trailer += f" /Info {info_id} 0 R"
|
||||
out = bytearray(b"%PDF-1.4\n")
|
||||
offsets = {}
|
||||
for number in sorted(objects):
|
||||
offsets[number] = len(out)
|
||||
out += f"{number} 0 obj\n".encode() + objects[number] + b"\nendobj\n"
|
||||
xref = len(out)
|
||||
size = max(objects) + 1
|
||||
out += f"xref\n0 {size}\n".encode() + b"0000000000 65535 f \n"
|
||||
for number in range(1, size):
|
||||
out += f"{offsets[number]:010d} 00000 n \n".encode()
|
||||
out += f"trailer\n<< /Size {size} {trailer} >>\nstartxref\n{xref}\n%%EOF\n".encode()
|
||||
return bytes(out)
|
||||
|
||||
|
||||
class _Node:
|
||||
def __init__(self, name: str, kind: int, data: bytes = b"") -> None:
|
||||
self.name, self.kind, self.data = name, kind, data
|
||||
self.children: dict[str, "_Node"] = {}
|
||||
self.index = 0
|
||||
self.start = END
|
||||
self.size = 0
|
||||
|
||||
|
||||
def build_cfb(files: dict[str, bytes]) -> bytes:
|
||||
"""A compound file (version 3) holding `files` (path with `/` -> bytes). Streams under
|
||||
4096 bytes go into the mini stream, as the format requires."""
|
||||
root = _Node("Root Entry", 5)
|
||||
for path, data in files.items():
|
||||
*folders, leaf = path.split("/")
|
||||
node = root
|
||||
for folder in folders:
|
||||
node = node.children.setdefault(folder, _Node(folder, 1))
|
||||
node.children[leaf] = _Node(leaf, 2, data)
|
||||
order: list[_Node] = []
|
||||
|
||||
def number(node: _Node) -> None:
|
||||
node.index = len(order)
|
||||
order.append(node)
|
||||
for child in node.children.values():
|
||||
number(child)
|
||||
|
||||
number(root)
|
||||
streams = [n for n in order if n.kind == 2]
|
||||
small = [n for n in streams if 0 < len(n.data) < 4096]
|
||||
big = [n for n in streams if len(n.data) >= 4096]
|
||||
|
||||
mini = bytearray()
|
||||
mini_chain: list[int] = []
|
||||
for node in small:
|
||||
node.start = len(mini) // 64
|
||||
node.size = len(node.data)
|
||||
blocks = -(-len(node.data) // 64)
|
||||
mini += node.data.ljust(blocks * 64, b"\x00")
|
||||
mini_chain += [node.start + i + 1 for i in range(blocks - 1)] + [END]
|
||||
for node in streams:
|
||||
node.size = len(node.data)
|
||||
|
||||
n_minifat = -(-len(mini_chain) // 128)
|
||||
n_dir = -(-len(order) // 4)
|
||||
n_mini = -(-len(mini) // 512)
|
||||
big_sectors = {id(n): -(-len(n.data) // 512) for n in big}
|
||||
total = 1 + n_minifat + n_dir + n_mini + sum(big_sectors.values())
|
||||
assert total <= 128, "the builder writes one FAT sector"
|
||||
|
||||
fat = [FREE] * 128
|
||||
fat[0] = FATSECT
|
||||
cursor = 1
|
||||
first_minifat = cursor if n_minifat else END
|
||||
for i in range(n_minifat):
|
||||
fat[cursor + i] = cursor + i + 1 if i < n_minifat - 1 else END
|
||||
cursor += n_minifat
|
||||
first_dir = cursor
|
||||
for i in range(n_dir):
|
||||
fat[cursor + i] = cursor + i + 1 if i < n_dir - 1 else END
|
||||
cursor += n_dir
|
||||
root.start = cursor if n_mini else END
|
||||
root.size = len(mini)
|
||||
for i in range(n_mini):
|
||||
fat[cursor + i] = cursor + i + 1 if i < n_mini - 1 else END
|
||||
cursor += n_mini
|
||||
for node in big:
|
||||
node.start = cursor
|
||||
count = big_sectors[id(node)]
|
||||
for i in range(count):
|
||||
fat[cursor + i] = cursor + i + 1 if i < count - 1 else END
|
||||
cursor += count
|
||||
|
||||
def entry(node: Optional[_Node], right: int = FREE) -> bytes:
|
||||
if node is None:
|
||||
return struct.pack("<64sHBBIII16sIQQIQ", b"", 0, 0, 0, FREE, FREE, FREE, b"", 0, 0, 0, 0, 0)
|
||||
name = node.name.encode("utf-16-le")
|
||||
kids = list(node.children.values())
|
||||
return struct.pack(
|
||||
"<64sHBBIII16sIQQIQ", name.ljust(64, b"\x00"), len(name) + 2, node.kind, 1, FREE, right,
|
||||
kids[0].index if kids else FREE, b"", 0, 0, 0, node.start, node.size,
|
||||
)
|
||||
|
||||
rights: dict[int, int] = {}
|
||||
for node in order:
|
||||
kids = list(node.children.values())
|
||||
for current, following in zip(kids, kids[1:]):
|
||||
rights[current.index] = following.index
|
||||
directory = b"".join(entry(n, rights.get(n.index, FREE)) for n in order)
|
||||
empty_entry = entry(None)
|
||||
directory = directory[: len(order) * 128] + empty_entry * (n_dir * 4 - len(order))
|
||||
|
||||
header = struct.pack(
|
||||
"<8s16sHHHHH6sIIIIIIIII", b"\xd0\xcf\x11\xe0\xa1\xb1\x1a\xe1", b"", 0x3E, 3, 0xFFFE, 9, 6, b"", 0, 1,
|
||||
first_dir, 0, 4096, first_minifat, n_minifat, END, 0,
|
||||
) + struct.pack("<109I", 0, *([FREE] * 108))
|
||||
|
||||
minifat = struct.pack(f"<{n_minifat * 128}I", *(mini_chain + [FREE] * (n_minifat * 128 - len(mini_chain))))
|
||||
out = bytearray(header)
|
||||
out += struct.pack("<128I", *fat)
|
||||
out += minifat
|
||||
out += directory
|
||||
out += bytes(mini).ljust(n_mini * 512, b"\x00")
|
||||
for node in big:
|
||||
out += node.data.ljust(big_sectors[id(node)] * 512, b"\x00")
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def _utf16(text: str) -> bytes:
|
||||
return text.encode("utf-16-le")
|
||||
|
||||
|
||||
def build_msg(
|
||||
*,
|
||||
subject: str = "",
|
||||
body: Optional[str] = None,
|
||||
html: Optional[str] = None,
|
||||
rtf: Optional[bytes] = None,
|
||||
sender: str = "",
|
||||
sender_address: str = "",
|
||||
to: str = "",
|
||||
cc: str = "",
|
||||
attachments: Optional[list[tuple[str, bytes]]] = None,
|
||||
inline_images: Optional[list[tuple[str, bytes]]] = None,
|
||||
embedded: Optional[list[str]] = None,
|
||||
sent: Optional[int] = None,
|
||||
) -> bytes:
|
||||
"""An Outlook message. `sent` is a FILETIME (100 ns ticks since 1601)."""
|
||||
files: dict[str, bytes] = {}
|
||||
|
||||
def text(prefix: str, tag: str, value: str) -> None:
|
||||
if value:
|
||||
files[f"{prefix}__substg1.0_{tag}001F"] = _utf16(value)
|
||||
|
||||
text("", "0037", subject)
|
||||
text("", "0C1A", sender)
|
||||
text("", "5D01", sender_address)
|
||||
text("", "0E04", to)
|
||||
text("", "0E03", cc)
|
||||
if body is not None:
|
||||
text("", "1000", body)
|
||||
if html is not None:
|
||||
files["__substg1.0_10130102"] = html.encode("utf-8")
|
||||
if rtf is not None:
|
||||
files["__substg1.0_10090102"] = rtf
|
||||
props = b"\x00" * 32
|
||||
if sent is not None:
|
||||
props += struct.pack("<IIQ", 0x00390040, 6, sent)
|
||||
files["__properties_version1.0"] = props
|
||||
|
||||
number = 0
|
||||
for name, data in attachments or []:
|
||||
prefix = f"__attach_version1.0_#{number:08X}/"
|
||||
text(prefix, "3707", name)
|
||||
text(prefix, "370E", "application/octet-stream")
|
||||
files[f"{prefix}__substg1.0_37010102"] = data
|
||||
files[f"{prefix}__properties_version1.0"] = b"\x00" * 8 + struct.pack("<IIQ", 0x37050003, 6, 1)
|
||||
number += 1
|
||||
for name, data in inline_images or []:
|
||||
prefix = f"__attach_version1.0_#{number:08X}/"
|
||||
text(prefix, "3707", name)
|
||||
text(prefix, "370E", "image/png")
|
||||
text(prefix, "3712", "cid-" + name)
|
||||
files[f"{prefix}__substg1.0_37010102"] = data
|
||||
files[f"{prefix}__properties_version1.0"] = b"\x00" * 8 + struct.pack("<IIQ", 0x37050003, 6, 1)
|
||||
number += 1
|
||||
for name in embedded or []:
|
||||
prefix = f"__attach_version1.0_#{number:08X}/"
|
||||
text(prefix, "3707", name)
|
||||
files[f"{prefix}__properties_version1.0"] = b"\x00" * 8 + struct.pack("<IIQ", 0x37050003, 6, 5)
|
||||
number += 1
|
||||
return build_cfb(files)
|
||||
@@ -0,0 +1,246 @@
|
||||
"""`wikitool raw derive` for the two formats read through a package: PDF (`pypdf`) and
|
||||
Outlook `.msg` (`olefile`). The documents come from `binary_formats`, so no binary sits
|
||||
in the repository; whether the builders write valid files is settled by the packages
|
||||
reading them here. The tests that need a package skip where it is not installed - CI
|
||||
installs `tools/requirements.txt`, so they run there."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import io
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from chemenu import filters
|
||||
from chemenu.commands import doctor
|
||||
from chemenu.tests.binary_formats import build_msg, build_pdf
|
||||
from chemenu.tests.test_raw_derive import ( # noqa: F401 (tree is a fixture)
|
||||
_derive,
|
||||
_header_and_text,
|
||||
_put,
|
||||
_refused,
|
||||
_snapshot,
|
||||
make_docx,
|
||||
make_mail,
|
||||
make_zip,
|
||||
tree,
|
||||
)
|
||||
|
||||
needs_pypdf = pytest.mark.skipif(importlib.util.find_spec("pypdf") is None, reason="pypdf is not installed")
|
||||
needs_olefile = pytest.mark.skipif(importlib.util.find_spec("olefile") is None, reason="olefile is not installed")
|
||||
|
||||
LONG_LINE = "A sentence long enough that the page is clearly not a scan of a picture."
|
||||
|
||||
|
||||
# --- PDF ---------------------------------------------------------------------
|
||||
|
||||
|
||||
@needs_pypdf
|
||||
def test_a_pdf_becomes_a_folder_with_page_markers(tree, capsys):
|
||||
original = build_pdf([[LONG_LINE, "Second line (with brackets)"], [LONG_LINE + " Two."]], title="Quarterly report")
|
||||
_put(tree, "Report.pdf", original)
|
||||
_derive("incoming/Report.pdf")
|
||||
out = capsys.readouterr().out
|
||||
|
||||
folder = tree / "incoming/Report"
|
||||
assert (folder / "original.pdf").read_bytes() == original
|
||||
header, text = _header_and_text(folder / "derived.md")
|
||||
assert header["filter"] == "pdf" and "pypdf" in header["engine"]
|
||||
assert int(header["pages"]) == 2 and header["title"] == "Quarterly report"
|
||||
assert text.index("<!-- page 1 -->") < text.index("Second line (with brackets)") < text.index("<!-- page 2 -->")
|
||||
assert "WARN" not in out
|
||||
assert out.count("CHECK") == 1 and "page" in out
|
||||
|
||||
|
||||
@needs_pypdf
|
||||
def test_a_pdf_without_text_is_derived_with_a_scan_warning(tree, capsys):
|
||||
_put(tree, "scan.pdf", build_pdf([[], [], []]))
|
||||
_derive("incoming/scan.pdf")
|
||||
out = capsys.readouterr().out
|
||||
assert "WARN" in out and "scan" in out and "3 page" in out
|
||||
assert (tree / "incoming/scan/original.pdf").is_file()
|
||||
|
||||
|
||||
@needs_pypdf
|
||||
def test_a_pdf_that_is_not_a_pdf_is_refused_untouched(tree):
|
||||
_put(tree, "fake.pdf", b"this is not a pdf at all")
|
||||
before = _snapshot(tree / "incoming")
|
||||
_refused("incoming/fake.pdf")
|
||||
assert _snapshot(tree / "incoming") == before
|
||||
|
||||
|
||||
@needs_pypdf
|
||||
def test_an_encrypted_pdf_is_refused_untouched(tree, capsys):
|
||||
import pypdf
|
||||
|
||||
writer = pypdf.PdfWriter(clone_from=pypdf.PdfReader(io.BytesIO(build_pdf([[LONG_LINE]]))))
|
||||
try:
|
||||
writer.encrypt("secret", algorithm="RC4-128")
|
||||
except Exception as exc: # no crypto provider for the algorithm on this machine
|
||||
pytest.skip(f"cannot build an encrypted PDF here: {exc}")
|
||||
buffer = io.BytesIO()
|
||||
writer.write(buffer)
|
||||
_put(tree, "locked.pdf", buffer.getvalue())
|
||||
before = _snapshot(tree / "incoming")
|
||||
_refused("incoming/locked.pdf")
|
||||
assert "encrypted" in capsys.readouterr().out
|
||||
assert _snapshot(tree / "incoming") == before
|
||||
|
||||
|
||||
@needs_pypdf
|
||||
def test_a_pdf_inside_a_zip_gets_its_md(tree):
|
||||
_put(tree, "bundle.zip", make_zip({"a.pdf": build_pdf([[LONG_LINE]]), "b.txt": b"x"}))
|
||||
_derive("incoming/bundle.zip")
|
||||
folder = tree / "incoming/bundle"
|
||||
assert LONG_LINE in (folder / "a.md").read_text(encoding="utf-8")
|
||||
assert (folder / "a.pdf").is_file() and not (folder / "b.md").exists()
|
||||
|
||||
|
||||
@needs_pypdf
|
||||
def test_a_pdf_attached_to_a_mail_gets_its_md(tree):
|
||||
_put(tree, "mail.eml", make_mail([("offer.pdf", build_pdf([[LONG_LINE]]), "application/pdf")]))
|
||||
_derive("incoming/mail.eml")
|
||||
assert LONG_LINE in (tree / "incoming/mail/offer.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_a_missing_package_stops_only_its_own_format(tree, monkeypatch, capsys):
|
||||
pdf_filter = filters.filter_for("a.pdf")
|
||||
monkeypatch.setattr(type(pdf_filter), "missing", lambda self: ["pypdf"])
|
||||
_put(tree, "a.pdf", b"%PDF-1.4")
|
||||
_put(tree, "b.docx", make_docx("<w:p><w:r><w:t>Still works</w:t></w:r></w:p>"))
|
||||
before = _snapshot(tree / "incoming")
|
||||
_refused("incoming/a.pdf")
|
||||
out = capsys.readouterr().out
|
||||
assert "pypdf" in out and "preflight" in out
|
||||
assert _snapshot(tree / "incoming") == before
|
||||
_derive("incoming/b.docx")
|
||||
assert "Still works" in (tree / "incoming/b/derived.md").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_a_package_missing_inside_a_zip_refuses_the_whole_zip(tree, monkeypatch):
|
||||
monkeypatch.setattr(type(filters.filter_for("a.pdf")), "missing", lambda self: ["pypdf"])
|
||||
_put(tree, "bundle.zip", make_zip({"a.pdf": b"%PDF-1.4", "b.txt": b"x"}))
|
||||
before = _snapshot(tree / "incoming")
|
||||
_refused("incoming/bundle.zip")
|
||||
assert _snapshot(tree / "incoming") == before
|
||||
|
||||
|
||||
# --- Outlook .msg --------------------------------------------------------------
|
||||
|
||||
|
||||
SENT = 134_000_000_000_000_000 # 2025-08-18T14:13:20+00:00
|
||||
|
||||
|
||||
@needs_olefile
|
||||
def test_a_msg_becomes_a_folder_like_a_mail(tree, capsys):
|
||||
data = [b"attachment bytes" * 400, b"small"]
|
||||
original = build_msg(
|
||||
subject="Unterlagen", body="Hello Bob\r\nSee attached.", sender="Anna", sender_address="anna@example.org",
|
||||
to="bob@example.org; carol@example.org", sent=SENT,
|
||||
attachments=[("report.bin", data[0]), ("note.txt", data[1])],
|
||||
)
|
||||
_put(tree, "Mail.msg", original)
|
||||
_derive("incoming/Mail.msg")
|
||||
out = capsys.readouterr().out
|
||||
|
||||
folder = tree / "incoming/Mail"
|
||||
assert (folder / "original.msg").read_bytes() == original
|
||||
assert (folder / "report.bin").read_bytes() == data[0]
|
||||
assert (folder / "note.txt").read_bytes() == data[1]
|
||||
header, text = _header_and_text(folder / "derived.md")
|
||||
assert header["filter"] == "msg" and "olefile" in header["engine"]
|
||||
assert header["from"] == "Anna <anna@example.org>"
|
||||
assert header["to"] == ["bob@example.org", "carol@example.org"]
|
||||
assert header["subject"] == "Unterlagen" and str(header["date"]).startswith("2025-08-18")
|
||||
assert header["attachments"] == ["report.bin", "note.txt"]
|
||||
assert text.strip() == "Hello Bob\nSee attached."
|
||||
assert "report.bin" in out
|
||||
|
||||
|
||||
@needs_olefile
|
||||
def test_a_msg_with_only_html_takes_the_html_text(tree):
|
||||
_put(tree, "h.msg", build_msg(subject="S", html="<html><body><p>Only <b>html</b> here</p></body></html>"))
|
||||
_derive("incoming/h.msg")
|
||||
_header, text = _header_and_text(tree / "incoming/h/derived.md")
|
||||
assert "Only" in text and "html here" in text and "<p>" not in text
|
||||
|
||||
|
||||
@needs_olefile
|
||||
def test_a_msg_with_only_rtf_warns_and_has_no_text(tree, capsys):
|
||||
_put(tree, "r.msg", build_msg(subject="S", rtf=b"{\\rtf1 hello}"))
|
||||
_derive("incoming/r.msg")
|
||||
out = capsys.readouterr().out
|
||||
assert "WARN" in out and "RTF" in out
|
||||
_header, text = _header_and_text(tree / "incoming/r/derived.md")
|
||||
assert text.strip() == ""
|
||||
|
||||
|
||||
@needs_olefile
|
||||
def test_a_msg_names_inline_images_and_embedded_messages_without_writing_them(tree, capsys):
|
||||
_put(tree, "m.msg", build_msg(
|
||||
subject="S", body="text", inline_images=[("logo.png", b"png")], embedded=["forwarded.msg"],
|
||||
attachments=[("real.bin", b"x")],
|
||||
))
|
||||
_derive("incoming/m.msg")
|
||||
out = capsys.readouterr().out
|
||||
assert sorted(p.name for p in (tree / "incoming/m").iterdir()) == ["derived.md", "original.msg", "real.bin"]
|
||||
assert "inline image" in out and "forwarded.msg" in out
|
||||
|
||||
|
||||
@needs_olefile
|
||||
def test_a_msg_attached_to_a_mail_is_quoted(tree, capsys):
|
||||
inner = build_msg(subject="Inner subject", body="Inner words", sender="Dora", to="x@example.org")
|
||||
_put(tree, "mail.eml", make_mail([("inner.msg", inner, "application/vnd.ms-outlook")]))
|
||||
_derive("incoming/mail.eml")
|
||||
folder = tree / "incoming/mail"
|
||||
assert sorted(p.name for p in folder.iterdir()) == ["derived.md", "original.eml"]
|
||||
_header, text = _header_and_text(folder / "derived.md")
|
||||
assert "## Attached message: Inner subject" in text and "> Inner words" in text
|
||||
|
||||
|
||||
@needs_olefile
|
||||
def test_a_file_that_is_not_a_msg_is_refused_untouched(tree):
|
||||
_put(tree, "x.msg", b"plain text pretending to be a message")
|
||||
before = _snapshot(tree / "incoming")
|
||||
_refused("incoming/x.msg")
|
||||
assert _snapshot(tree / "incoming") == before
|
||||
|
||||
|
||||
@needs_olefile
|
||||
def test_a_compound_file_that_is_not_a_message_is_refused(tree):
|
||||
from chemenu.tests.binary_formats import build_cfb
|
||||
|
||||
_put(tree, "x.msg", build_cfb({"WordDocument": b"abc"}))
|
||||
_refused("incoming/x.msg")
|
||||
assert not (tree / "incoming/x").exists()
|
||||
|
||||
|
||||
def test_a_msg_without_the_package_is_refused_and_a_docx_still_works(tree, monkeypatch, capsys):
|
||||
monkeypatch.setattr(type(filters.filter_for("a.msg")), "missing", lambda self: ["olefile"])
|
||||
_put(tree, "a.msg", b"\xd0\xcf\x11\xe0")
|
||||
before = _snapshot(tree / "incoming")
|
||||
_refused("incoming/a.msg")
|
||||
assert "olefile" in capsys.readouterr().out
|
||||
assert _snapshot(tree / "incoming") == before
|
||||
|
||||
|
||||
# --- doctor ---------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_doctor_names_each_filter_that_needs_a_package():
|
||||
names = {check.name for check in doctor.check_import_filters()}
|
||||
assert names == {"import-filter-pdf", "import-filter-msg"}
|
||||
|
||||
|
||||
def test_doctor_warns_for_a_missing_package_and_never_fails(monkeypatch):
|
||||
monkeypatch.setattr(type(filters.filter_for("a.pdf")), "missing", lambda self: ["pypdf"])
|
||||
checks = {check.name: check for check in doctor.check_import_filters()}
|
||||
assert checks["import-filter-pdf"].status == "WARN"
|
||||
assert "pypdf" in checks["import-filter-pdf"].detail and checks["import-filter-pdf"].fix
|
||||
assert all(check.status != "FAIL" for check in checks.values())
|
||||
|
||||
|
||||
@needs_pypdf
|
||||
def test_doctor_reports_an_installed_package_with_its_version():
|
||||
check = next(c for c in doctor.check_import_filters() if c.name == "import-filter-pdf")
|
||||
assert check.status == "OK" and check.detail.startswith("pypdf ") and ".pdf" in check.detail
|
||||
Reference in new issue
Block a user