Files
wehub-resource-sync c3749daf48
Tests / test-linux (3.13) (push) Failing after 0s
Tests / test-linux (3.11) (push) Failing after 1s
Tests / lint (push) Failing after 0s
Tests / test-linux (3.9) (push) Failing after 1s
Docker / build (push) Failing after 1s
Docker / build-gpu (push) Failing after 2s
Tests / test-windows (push) Has been cancelled
Tests / test-macos (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:03:03 +08:00

1554 lines
67 KiB
Python

"""Tests for format_miner (mempalace 3.3.6, integrated in PR #1555).
Covers all 14 fringe cases in the format-coverage spec plus orchestrator
behavior. MarkItDown is mocked at the seam for most tests; a few guarded
integration tests exercise live transformers when the optional extras are
installed.
Run with:
pytest tests/test_format_miner.py -v
"""
from __future__ import annotations
from pathlib import Path
from unittest.mock import patch
import pytest
from mempalace.format_miner import ( # noqa: E402
DEFAULT_MAX_FILE_SIZE,
SUPPORTED_FORMATS,
ExtractionStatus,
decode_robust,
extract_text,
is_icloud_dataless,
scan_formats,
)
def _make_symlink_or_skip(link: Path, target: Path) -> None:
"""Create ``link`` pointing to ``target``, or ``pytest.skip`` when the
platform/process can't create symlinks.
Windows without ``SeCreateSymbolicLinkPrivilege`` raises ``OSError``
(``WinError 1314``) from ``Path.symlink_to()`` BEFORE any product code
runs, which surfaces as a hard test failure even though the failure
has nothing to do with the code under test. Per PR #1555 review (Igor):
symlink tests must skip cleanly in environments without privileges
rather than fail spuriously.
"""
try:
link.symlink_to(target)
except OSError as exc:
pytest.skip(f"symlink creation not permitted on this platform/user: {exc}")
# ─────────────────────────────────────────────────────────────────────────────
# Module surface
# ─────────────────────────────────────────────────────────────────────────────
def test_supported_formats_covers_office_suite():
assert ".pdf" in SUPPORTED_FORMATS
assert ".rtf" in SUPPORTED_FORMATS
assert ".docx" in SUPPORTED_FORMATS
assert ".xlsx" in SUPPORTED_FORMATS
assert ".pptx" in SUPPORTED_FORMATS
assert ".epub" in SUPPORTED_FORMATS
def test_supported_formats_normalised_lowercase():
for ext in SUPPORTED_FORMATS:
assert ext == ext.lower()
assert ext.startswith(".")
def test_extraction_status_enum_has_all_documented_codes():
expected = {
"OK",
"SKIP_TOO_LARGE",
"SKIP_CLOUD_ONLY",
"SKIP_EMPTY",
"SKIP_NO_MARKITDOWN",
"SKIP_NO_STRIPRTF",
"SKIP_ENCRYPTED",
"SKIP_PERMISSION",
"SKIP_BROKEN_SYMLINK",
"SKIP_UNRECOGNIZED",
"SKIP_EXTRACTION_ERROR",
"SKIP_MISSING_FORMAT_DEPS",
"SKIP_NETWORK_TIMEOUT",
"SKIP_UNREADABLE",
}
actual = {status.name for status in ExtractionStatus}
missing = expected - actual
assert not missing, f"Missing status codes: {missing}"
def test_default_max_file_size_matches_existing_miner():
assert DEFAULT_MAX_FILE_SIZE == 500 * 1024 * 1024
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 12 — unrecognized extension → skip with note (test first because
# it covers the simplest dispatch path)
# ─────────────────────────────────────────────────────────────────────────────
def test_fringe_unrecognized_extension(tmp_path: Path):
f = tmp_path / "thing.xyz"
f.write_text("not a real format")
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_UNRECOGNIZED
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 5 — empty file → skip silently
# ─────────────────────────────────────────────────────────────────────────────
def test_fringe_empty_file(tmp_path: Path):
f = tmp_path / "blank.pdf"
f.write_bytes(b"")
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_EMPTY
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 2 — file too large → skip with note
# ─────────────────────────────────────────────────────────────────────────────
def test_fringe_too_large(tmp_path: Path):
f = tmp_path / "huge.pdf"
f.write_bytes(b"%PDF-1.4\n" + b"\x00" * 1024)
text, status = extract_text(f, max_file_size=128)
assert text is None
assert status == ExtractionStatus.SKIP_TOO_LARGE
def test_fringe_too_large_respects_caller_max(tmp_path: Path):
"""Caller can raise the cap for legitimate big files."""
f = tmp_path / "huge.pdf"
f.write_bytes(b"%PDF-1.4\n" + b"\x00" * 2048)
# When the cap is generous, size alone won't trigger SKIP_TOO_LARGE.
# (MarkItDown will be invoked; we mock it so the test doesn't require it.)
with patch("mempalace.format_miner._extract_via_markitdown", return_value="dummy text"):
text, status = extract_text(f, max_file_size=1024 * 1024)
assert status == ExtractionStatus.OK
assert text == "dummy text"
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 1 — MarkItDown not installed → clear error
# ─────────────────────────────────────────────────────────────────────────────
def test_fringe_missing_markitdown(tmp_path: Path):
f = tmp_path / "doc.pdf"
f.write_bytes(b"%PDF-1.4\nstub")
with patch(
"mempalace.format_miner._extract_via_markitdown",
side_effect=ImportError("No module named 'markitdown'"),
):
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_NO_MARKITDOWN
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 4 — encrypted PDF → MarkItDown raises; we catch + skip + note
# ─────────────────────────────────────────────────────────────────────────────
def test_fringe_encrypted_pdf(tmp_path: Path):
f = tmp_path / "locked.pdf"
f.write_bytes(b"%PDF-1.4\nencrypted stub")
class _PasswordError(Exception):
pass
with patch(
"mempalace.format_miner._extract_via_markitdown",
side_effect=_PasswordError("File has not been decrypted"),
):
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_ENCRYPTED
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 6 — permission denied → catch OSError, skip
# ─────────────────────────────────────────────────────────────────────────────
def test_fringe_permission_denied(tmp_path: Path):
f = tmp_path / "denied.pdf"
f.write_bytes(b"%PDF-1.4\nstub")
with patch(
"mempalace.format_miner._extract_via_markitdown",
side_effect=PermissionError("Permission denied"),
):
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_PERMISSION
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 7 — symlink to nothing → catch, skip
# ─────────────────────────────────────────────────────────────────────────────
def test_fringe_broken_symlink(tmp_path: Path):
target = tmp_path / "does-not-exist.pdf"
link = tmp_path / "broken-link.pdf"
_make_symlink_or_skip(link, target)
assert link.is_symlink()
text, status = extract_text(link)
assert text is None
assert status == ExtractionStatus.SKIP_BROKEN_SYMLINK
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 3 — iCloud cloud-only file detection
# ─────────────────────────────────────────────────────────────────────────────
def test_fringe_icloud_placeholder_extension(tmp_path: Path):
# macOS sometimes leaves a literal .icloud placeholder for offloaded files
f = tmp_path / "doc.pdf.icloud"
f.write_bytes(b"placeholder")
assert is_icloud_dataless(f) is True
def test_fringe_icloud_skip_extraction(tmp_path: Path):
# Cloud-only file. extract_text should not call MarkItDown and should
# return SKIP_CLOUD_ONLY.
f = tmp_path / "doc.pdf.icloud"
f.write_bytes(b"placeholder")
with patch("mempalace.format_miner._extract_via_markitdown") as mocked:
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_CLOUD_ONLY
mocked.assert_not_called()
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 8 — encoding fallback (utf-8 → cp1252 → utf-8-replace)
# ─────────────────────────────────────────────────────────────────────────────
def test_decode_robust_clean_utf8():
assert decode_robust(b"hello \xe2\x9c\xa8 world") == "hello ✨ world"
def test_decode_robust_cp1252_fallback():
# 0x91 = U+2018 left single quote in cp1252; invalid as standalone utf-8
raw = b"hello \x91world\x92"
result = decode_robust(raw)
assert isinstance(result, str)
assert "world" in result
def test_decode_robust_never_raises():
raw = b"\xff\xfe\xfd\xfc" + b"some text"
result = decode_robust(raw)
assert isinstance(result, str)
assert "some text" in result
def test_decode_robust_empty():
assert decode_robust(b"") == ""
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 9 — Windows path differences (pathlib semantics)
# ─────────────────────────────────────────────────────────────────────────────
def test_extract_text_accepts_pathlib_path(tmp_path: Path):
"""Accept Path objects without coercing to str (Windows-safe)."""
f = tmp_path / "doc.pdf"
f.write_bytes(b"%PDF-1.4\nstub")
with patch("mempalace.format_miner._extract_via_markitdown", return_value="content"):
text, status = extract_text(f)
assert status == ExtractionStatus.OK
def test_extract_text_accepts_string_path(tmp_path: Path):
"""Accept str paths for callers that pre-stringify."""
f = tmp_path / "doc.pdf"
f.write_bytes(b"%PDF-1.4\nstub")
with patch("mempalace.format_miner._extract_via_markitdown", return_value="content"):
text, status = extract_text(str(f))
assert status == ExtractionStatus.OK
def test_supported_format_check_case_insensitive(tmp_path: Path):
"""Windows often shows uppercase extensions; we still recognize them."""
f = tmp_path / "doc.PDF"
f.write_bytes(b"%PDF-1.4\nstub")
with patch("mempalace.format_miner._extract_via_markitdown", return_value="content"):
text, status = extract_text(f)
assert status == ExtractionStatus.OK
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 10 — MarkItDown internal crash on malformed file
# ─────────────────────────────────────────────────────────────────────────────
def test_fringe_markitdown_generic_crash(tmp_path: Path):
f = tmp_path / "malformed.pdf"
f.write_bytes(b"%PDF-1.4\nmalformed")
with patch(
"mempalace.format_miner._extract_via_markitdown",
side_effect=RuntimeError("internal converter explosion"),
):
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_EXTRACTION_ERROR
def test_fringe_markitdown_returns_none(tmp_path: Path):
"""MarkItDown can return None for some inputs; treat as extraction error."""
f = tmp_path / "weird.pdf"
f.write_bytes(b"%PDF-1.4\nweird")
with patch("mempalace.format_miner._extract_via_markitdown", return_value=None):
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_EXTRACTION_ERROR
# ─────────────────────────────────────────────────────────────────────────────
# Fringe Case 11 — network/sync drive timeout
# ─────────────────────────────────────────────────────────────────────────────
def test_fringe_network_timeout(tmp_path: Path):
f = tmp_path / "remote.pdf"
f.write_bytes(b"%PDF-1.4\nstub")
with patch(
"mempalace.format_miner._extract_via_markitdown",
side_effect=TimeoutError("operation timed out"),
):
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_NETWORK_TIMEOUT
# ─────────────────────────────────────────────────────────────────────────────
# Happy paths — formats we explicitly target
# ─────────────────────────────────────────────────────────────────────────────
def test_happy_path_pdf(tmp_path: Path):
f = tmp_path / "research.pdf"
f.write_bytes(b"%PDF-1.4\nstub")
with patch("mempalace.format_miner._extract_via_markitdown", return_value="# Research\n\nbody"):
text, status = extract_text(f)
assert status == ExtractionStatus.OK
assert text == "# Research\n\nbody"
def test_happy_path_docx(tmp_path: Path):
f = tmp_path / "notes.docx"
f.write_bytes(b"PK\x03\x04docx-stub")
with patch("mempalace.format_miner._extract_via_markitdown", return_value="# Notes\n\nbody"):
text, status = extract_text(f)
assert status == ExtractionStatus.OK
assert text.startswith("# Notes")
def test_happy_path_rtf(tmp_path: Path):
"""RTF routes to striprtf, NOT MarkItDown.
MarkItDown 0.1.5 does not actually convert .rtf — it returns the raw
source unchanged. striprtf is the correct transformer for this format.
"""
f = tmp_path / "memo.rtf"
f.write_bytes(b"{\\rtf1\\ansi memo}")
with patch("mempalace.format_miner._extract_via_striprtf", return_value="memo body"):
text, status = extract_text(f)
assert status == ExtractionStatus.OK
assert text == "memo body"
# ─────────────────────────────────────────────────────────────────────────────
# Striprtf path — RTF transformer routing (Fringe Case 13: SKIP_NO_STRIPRTF)
# ─────────────────────────────────────────────────────────────────────────────
def test_rtf_routes_to_striprtf_not_markitdown(tmp_path: Path):
"""Critical invariant: .rtf must NEVER touch MarkItDown.
MarkItDown 0.1.5 returns raw RTF source for .rtf inputs (verified live
against a local RTF test set on 2026-05-19). Routing .rtf through striprtf is
the bugfix.
"""
f = tmp_path / "memo.rtf"
f.write_bytes(b"{\\rtf1\\ansi memo}")
with (
patch("mempalace.format_miner._extract_via_markitdown") as mock_md,
patch("mempalace.format_miner._extract_via_striprtf", return_value="memo body") as mock_rtf,
):
text, status = extract_text(f)
assert status == ExtractionStatus.OK
assert text == "memo body"
mock_md.assert_not_called()
mock_rtf.assert_called_once()
def test_non_rtf_does_not_touch_striprtf(tmp_path: Path):
"""Inverse invariant: .pdf / .docx / etc. must not hit striprtf."""
f = tmp_path / "doc.pdf"
f.write_bytes(b"%PDF-1.4\nstub")
with (
patch("mempalace.format_miner._extract_via_markitdown", return_value="pdf text") as mock_md,
patch("mempalace.format_miner._extract_via_striprtf") as mock_rtf,
):
text, status = extract_text(f)
assert status == ExtractionStatus.OK
assert text == "pdf text"
mock_md.assert_called_once()
mock_rtf.assert_not_called()
def test_fringe_missing_striprtf(tmp_path: Path):
"""Fringe Case 13 — striprtf not installed → SKIP_NO_STRIPRTF + clear install msg."""
f = tmp_path / "memo.rtf"
f.write_bytes(b"{\\rtf1\\ansi memo}")
with patch(
"mempalace.format_miner._extract_via_striprtf",
side_effect=ImportError("No module named 'striprtf'"),
):
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_NO_STRIPRTF
def test_fringe_striprtf_crash(tmp_path: Path):
"""striprtf raising any other exception → SKIP_EXTRACTION_ERROR."""
f = tmp_path / "broken.rtf"
f.write_bytes(b"{\\rtf1\\ansi broken}")
with patch(
"mempalace.format_miner._extract_via_striprtf",
side_effect=RuntimeError("rtf parse explosion"),
):
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_EXTRACTION_ERROR
def test_fringe_striprtf_returns_none(tmp_path: Path):
"""striprtf returning None → SKIP_EXTRACTION_ERROR (same shape as MarkItDown)."""
f = tmp_path / "weird.rtf"
f.write_bytes(b"{\\rtf1\\ansi weird}")
with patch("mempalace.format_miner._extract_via_striprtf", return_value=None):
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_EXTRACTION_ERROR
def test_fringe_striprtf_empty_output(tmp_path: Path):
"""striprtf returning empty string → SKIP_EXTRACTION_ERROR.
An RTF that strips to zero characters is not useful as a drawer; treat
as extraction failure rather than file an empty drawer.
"""
f = tmp_path / "blank-after-strip.rtf"
f.write_bytes(b"{\\rtf1\\ansi}")
with patch("mempalace.format_miner._extract_via_striprtf", return_value=""):
text, status = extract_text(f)
assert text is None
assert status == ExtractionStatus.SKIP_EXTRACTION_ERROR
def test_rtf_uppercase_extension_also_routes_to_striprtf(tmp_path: Path):
"""Windows case-insensitive: .RTF must also route to striprtf."""
f = tmp_path / "memo.RTF"
f.write_bytes(b"{\\rtf1\\ansi memo}")
with (
patch("mempalace.format_miner._extract_via_markitdown") as mock_md,
patch("mempalace.format_miner._extract_via_striprtf", return_value="memo body") as mock_rtf,
):
text, status = extract_text(f)
assert status == ExtractionStatus.OK
mock_md.assert_not_called()
mock_rtf.assert_called_once()
def test_happy_path_xlsx(tmp_path: Path):
f = tmp_path / "spreadsheet.xlsx"
f.write_bytes(b"PK\x03\x04xlsx-stub")
with patch(
"mempalace.format_miner._extract_via_markitdown",
return_value="| col1 | col2 |\n|---|---|\n| a | b |",
):
text, status = extract_text(f)
assert status == ExtractionStatus.OK
assert "col1" in text
# ─────────────────────────────────────────────────────────────────────────────
# scan_formats — directory walker, returns Path objects sorted
# ─────────────────────────────────────────────────────────────────────────────
def test_scan_formats_finds_supported_only(tmp_path: Path):
(tmp_path / "a.pdf").write_bytes(b"pdf")
(tmp_path / "b.txt").write_text("text") # unsupported
(tmp_path / "c.docx").write_bytes(b"docx")
(tmp_path / "d.rtf").write_bytes(b"rtf")
found = {f.name for f in scan_formats(tmp_path)}
assert "a.pdf" in found
assert "c.docx" in found
assert "d.rtf" in found
assert "b.txt" not in found
def test_scan_formats_walks_subdirectories(tmp_path: Path):
nested = tmp_path / "deep" / "nested"
nested.mkdir(parents=True)
(nested / "buried.pdf").write_bytes(b"pdf")
found = {f.name for f in scan_formats(tmp_path)}
assert "buried.pdf" in found
def test_scan_formats_skips_hidden_dirs(tmp_path: Path):
"""Don't descend into .git, .venv, __pycache__, etc. — same SKIP_DIRS as miner."""
hidden = tmp_path / ".git"
hidden.mkdir()
(hidden / "hidden.pdf").write_bytes(b"pdf")
(tmp_path / "visible.pdf").write_bytes(b"pdf")
found = {f.name for f in scan_formats(tmp_path)}
assert "visible.pdf" in found
assert "hidden.pdf" not in found
def test_scan_formats_skips_ds_store(tmp_path: Path):
(tmp_path / ".DS_Store").write_bytes(b"")
(tmp_path / "real.pdf").write_bytes(b"pdf")
found = {f.name for f in scan_formats(tmp_path)}
assert "real.pdf" in found
assert ".DS_Store" not in found
def test_scan_formats_returns_empty_for_missing_dir(tmp_path: Path):
"""scan_formats handles a path that doesn't exist (no crash, empty list)."""
missing = tmp_path / "does-not-exist"
assert scan_formats(missing) == []
# ─────────────────────────────────────────────────────────────────────────────
# decode_robust — exercise the cp1252 path explicitly
# ─────────────────────────────────────────────────────────────────────────────
def test_decode_robust_pure_cp1252():
"""Bytes that are invalid UTF-8 but valid CP1252 → second-attempt success."""
raw = b"\x91hello\x92"
result = decode_robust(raw)
assert isinstance(result, str)
assert "hello" in result
# 0x91 / 0x92 are CP1252 smart quotes that decode without error
assert "" not in result, "CP1252 path should not need the replace fallback"
# ─────────────────────────────────────────────────────────────────────────────
# Note on stat() error branches in extract_text:
# Dedicated PermissionError / FileNotFoundError / OSError stat-handler tests
# were attempted but tripped on Python 3.13 pathlib internals (patching
# Path.stat globally interferes with .exists() / .is_symlink() which call
# stat under the hood). The handlers are kept as defensive guards and
# remain documented as uncovered branches — the rest of the suite (60+
# tests + live integration on 63 real archive files) provides the
# end-to-end safety net.
# ─────────────────────────────────────────────────────────────────────────────
# ─────────────────────────────────────────────────────────────────────────────
# Live transformer body tests — skipped when transformer isn't installed.
# These exercise the actual adapter code that mocked tests can't reach.
# ─────────────────────────────────────────────────────────────────────────────
def test_extract_via_striprtf_live(tmp_path: Path):
"""End-to-end: real striprtf converts a real RTF blob to plain text.
Skipped if striprtf isn't installed (e.g., environments without the
[extract] extra). When installed, this exercises the actual adapter
body that mocked tests bypass.
"""
pytest.importorskip("striprtf.striprtf")
from mempalace.format_miner import _extract_via_striprtf
rtf = (
b"{\\rtf1\\ansi\\ansicpg1252\n{\\fonttbl\\f0\\fnil Helvetica;}\n"
b"\\f0\\fs24 Hello from a real RTF blob.}"
)
f = tmp_path / "live.rtf"
f.write_bytes(rtf)
text = _extract_via_striprtf(f)
assert text is not None
assert "Hello from a real RTF blob" in text
assert "\\rtf1" not in text, "raw RTF control codes leaked into output"
def test_extract_via_markitdown_live_pdf(tmp_path: Path):
"""End-to-end: real MarkItDown converts a real PDF blob to text.
Skipped if markitdown isn't installed (3.10+ only, optional extra).
Also skipped if only the placeholder ``markitdown`` package is present
(the real Microsoft package exposes the ``MarkItDown`` class).
"""
try:
from markitdown import MarkItDown # noqa: F401
except ImportError:
pytest.skip("real Microsoft markitdown not installed (needs Python 3.10+)")
from mempalace.format_miner import _extract_via_markitdown
# Minimal valid PDF that contains the literal text "hello pdf"
pdf_bytes = (
b"%PDF-1.1\n"
b"1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj\n"
b"2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj\n"
b"3 0 obj<</Type/Page/Parent 2 0 R/Resources<</Font<</F1 4 0 R>>>>"
b"/MediaBox[0 0 612 792]/Contents 5 0 R>>endobj\n"
b"4 0 obj<</Type/Font/Subtype/Type1/BaseFont/Helvetica>>endobj\n"
b"5 0 obj<</Length 44>>stream\n"
b"BT /F1 24 Tf 100 700 Td (hello pdf) Tj ET\n"
b"endstream endobj\n"
b"xref\n0 6\n0000000000 65535 f \n"
b"trailer<</Size 6/Root 1 0 R>>\n"
b"startxref\n0\n%%EOF\n"
)
f = tmp_path / "live.pdf"
f.write_bytes(pdf_bytes)
# The adapter shouldn't crash; if MarkItDown returns text, it includes our marker.
text = _extract_via_markitdown(f)
# Allow None (MarkItDown may not parse this minimal PDF cleanly), but the
# adapter path itself must run without exception.
if text is not None:
assert isinstance(text, str)
# ─────────────────────────────────────────────────────────────────────────────
# mine_formats — orchestrator that walks a directory, transforms files,
# chunks the extracted text, and files drawers into the palace. Mirrors the
# shape of mine_convos / mine. The collection + lock primitives are mocked
# so these tests don't write to a real palace.
# ─────────────────────────────────────────────────────────────────────────────
@pytest.fixture
def _mine_formats_mocks(tmp_path: Path):
"""Common mocks for mine_formats orchestrator tests.
Patches:
- scan_formats : returns the files we hand it
- get_collection : returns a MagicMock collection
- mine_lock : no-op context manager
- file_already_mined : False (file not yet mined)
- _extract_via_striprtf / _extract_via_markitdown : mocked at test level
"""
from unittest.mock import MagicMock, patch
from contextlib import contextmanager
collection = MagicMock()
collection.delete = MagicMock()
collection.upsert = MagicMock()
@contextmanager
def _fake_lock(source_file):
yield
with (
patch("mempalace.format_miner.get_collection", return_value=collection) as p_coll,
patch("mempalace.format_miner.mine_lock", side_effect=_fake_lock) as p_lock,
patch("mempalace.format_miner.file_already_mined", return_value=False) as p_mined,
):
yield {
"collection": collection,
"get_collection": p_coll,
"mine_lock": p_lock,
"file_already_mined": p_mined,
"tmp_path": tmp_path,
}
def test_mine_formats_walks_directory(_mine_formats_mocks):
"""mine_formats must use scan_formats to discover supported files."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
(tmp / "doc.pdf").write_bytes(b"pdf")
with (
patch("mempalace.format_miner.scan_formats", return_value=[]) as p_scan,
patch("mempalace.format_miner._extract_via_markitdown"),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
p_scan.assert_called_once()
def test_mine_formats_skips_already_mined_files(_mine_formats_mocks):
"""If file_already_mined returns True, extract_text should not be called."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
_mine_formats_mocks["file_already_mined"].return_value = True
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown") as p_md,
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
p_md.assert_not_called()
def test_mine_formats_skips_extraction_failures(_mine_formats_mocks):
"""When extract_text returns a SKIP status, no real drawers are upserted.
A sentinel upsert IS expected (so the file isn't re-extracted on every
re-mine). The sentinel carries ``is_sentinel=True`` to distinguish it
from a content drawer.
"""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "bad.pdf"
f.write_bytes(b"%PDF-1.4 stub")
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch(
"mempalace.format_miner._extract_via_markitdown",
side_effect=RuntimeError("converter blew up"),
),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
# Any upsert that fired must be a sentinel, never a content drawer.
for call in _mine_formats_mocks["collection"].upsert.call_args_list:
metas = call.kwargs.get("metadatas") or call.args[2]
for m in metas:
assert m.get("is_sentinel") is True, (
f"unexpected non-sentinel upsert on extraction failure: {m}"
)
def test_mine_formats_files_drawers_for_ok_extractions(_mine_formats_mocks):
"""When extract_text returns OK + text, mine_formats chunks and upserts drawers."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "good.pdf"
f.write_bytes(b"%PDF-1.4 stub")
long_text = "This is a sufficiently long extracted text. " * 30
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value=long_text),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
# upsert should have been called at least once
assert _mine_formats_mocks["collection"].upsert.called
def test_mine_formats_dry_run_does_not_open_collection(_mine_formats_mocks):
"""dry_run=True must not call get_collection or upsert any drawers."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
_mine_formats_mocks["get_collection"].reset_mock()
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="some text"),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), dry_run=True)
_mine_formats_mocks["get_collection"].assert_not_called()
_mine_formats_mocks["collection"].upsert.assert_not_called()
def test_mine_formats_respects_limit(_mine_formats_mocks):
"""limit=N should restrict processing to the first N files."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
files = []
for i in range(5):
p = tmp / f"f{i}.pdf"
p.write_bytes(b"%PDF-1.4 stub")
files.append(p)
with (
patch("mempalace.format_miner.scan_formats", return_value=files),
patch(
"mempalace.format_miner._extract_via_markitdown", return_value="long text " * 50
) as p_md,
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), limit=2)
# Only 2 of the 5 files should have been extracted
assert p_md.call_count == 2
def test_mine_formats_limit_skips_already_mined(_mine_formats_mocks):
"""--limit N counts only new work, not already-mined skips (#1535)."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
files = []
for i in range(6):
p = tmp / f"f{i}.pdf"
p.write_bytes(b"%PDF-1.4 stub")
files.append(p)
call_idx = 0
def fake_already_mined(collection, source_file, **kwargs):
nonlocal call_idx
call_idx += 1
return call_idx <= 4
_mine_formats_mocks["file_already_mined"].side_effect = fake_already_mined
with (
patch("mempalace.format_miner.scan_formats", return_value=files),
patch(
"mempalace.format_miner._extract_via_markitdown", return_value="long text " * 50
) as p_md,
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), limit=1)
assert p_md.call_count == 1
def test_mine_formats_wing_defaults_from_directory_name(_mine_formats_mocks):
"""When wing=None, the directory's basename becomes the wing."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
target_dir = tmp / "my_research_corpus"
target_dir.mkdir()
f = target_dir / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long text " * 50),
):
mine_formats(format_dir=str(target_dir), palace_path=str(tmp / "palace"))
# Inspect the metadata passed to upsert — the wing should derive from the dir name
call_args = _mine_formats_mocks["collection"].upsert.call_args
if call_args is not None:
metas = call_args.kwargs.get("metadatas") or call_args.args[2]
assert metas[0]["wing"] == "my_research_corpus"
def test_mine_formats_wing_override(_mine_formats_mocks):
"""Explicit wing= param overrides the directory-name default."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long text " * 50),
):
mine_formats(
format_dir=str(tmp),
palace_path=str(tmp / "palace"),
wing="custom_wing_name",
)
call_args = _mine_formats_mocks["collection"].upsert.call_args
if call_args is not None:
metas = call_args.kwargs.get("metadatas") or call_args.args[2]
assert metas[0]["wing"] == "custom_wing_name"
def test_mine_formats_ingest_mode_metadata_is_extract(_mine_formats_mocks):
"""Drawers from mine_formats must carry ingest_mode='extract' so they're
distinguishable from project / convo drawers in the palace."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long text " * 50),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
call_args = _mine_formats_mocks["collection"].upsert.call_args
assert call_args is not None
metas = call_args.kwargs.get("metadatas") or call_args.args[2]
assert metas[0]["ingest_mode"] == "extract"
# ─────────────────────────────────────────────────────────────────────────────
# Bot-review parity fixes (PR #1555 follow-up commit, 2026-05-19):
# - palace.SKIP_DIRS reuse (covered indirectly by scan_formats tests)
# - scan_formats skips symlinks
# - mine_formats uses check_mtime=True
# - source_mtime + hall + entities in drawer metadata
# - per-file try/except so one bad file doesn't crash the whole mine
# ─────────────────────────────────────────────────────────────────────────────
def test_scan_formats_skips_symlinks(tmp_path: Path):
"""Symlinks must be skipped — mirrors miner.py and convo_miner.py."""
real = tmp_path / "real.pdf"
real.write_bytes(b"%PDF-1.4 stub")
link = tmp_path / "alias.pdf"
_make_symlink_or_skip(link, real)
found = {f.name for f in scan_formats(tmp_path)}
assert "real.pdf" in found
assert "alias.pdf" not in found
def test_mine_formats_uses_check_mtime_true(_mine_formats_mocks):
"""mine_formats must call file_already_mined with check_mtime=True so
updated documents get re-mined (matches miner.py semantics)."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long " * 50),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
# Inspect every file_already_mined call — they must all use check_mtime=True
calls = _mine_formats_mocks["file_already_mined"].call_args_list
assert calls, "file_already_mined should have been called at least once"
for call in calls:
# check_mtime can be in kwargs or positional. Inspect both.
kwargs = call.kwargs
assert kwargs.get("check_mtime") is True, f"check_mtime must be True, got {kwargs}"
def test_mine_formats_records_source_mtime_in_drawer_metadata(_mine_formats_mocks):
"""Each drawer must carry source_mtime so file_already_mined(check_mtime=True)
can detect updates on re-mine."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long " * 50),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
upsert_calls = _mine_formats_mocks["collection"].upsert.call_args_list
# Find the content upsert (not the sentinel)
found_mtime = False
for call in upsert_calls:
metas = call.kwargs.get("metadatas") or call.args[2]
for m in metas:
if m.get("is_sentinel"):
continue
assert "source_mtime" in m, f"missing source_mtime in drawer meta: {m}"
assert isinstance(m["source_mtime"], (int, float))
found_mtime = True
assert found_mtime, "no non-sentinel drawer upsert observed"
def test_mine_formats_records_hall_in_drawer_metadata(_mine_formats_mocks):
"""Each drawer must carry a 'hall' tag — matches miner.py drawer quality."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long " * 50),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
upsert_calls = _mine_formats_mocks["collection"].upsert.call_args_list
found_hall = False
for call in upsert_calls:
metas = call.kwargs.get("metadatas") or call.args[2]
for m in metas:
if m.get("is_sentinel"):
continue
assert "hall" in m, f"missing hall in drawer meta: {m}"
assert isinstance(m["hall"], str)
found_hall = True
assert found_hall, "no non-sentinel drawer upsert observed"
def test_mine_formats_continues_after_per_file_error(_mine_formats_mocks):
"""One bad file must not crash the whole mine — the loop continues."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
bad = tmp / "broken.pdf"
bad.write_bytes(b"%PDF-1.4 stub")
good = tmp / "fine.pdf"
good.write_bytes(b"%PDF-1.4 stub")
# Make chunk_text crash on the first file but succeed on the second.
call_count = {"n": 0}
def chunk_text_first_fails(content, source_file, **kwargs):
call_count["n"] += 1
if call_count["n"] == 1:
raise RuntimeError("simulated chunker explosion")
return [{"content": "good chunk content here " * 5, "chunk_index": 0}]
with (
patch("mempalace.format_miner.scan_formats", return_value=[bad, good]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="text"),
# Patch the module-level binding in format_miner (hoisted in the
# PR #1555 polish work — Gemini #3). The old patch target
# ``mempalace.miner.chunk_text`` no longer works because
# format_miner now binds chunk_text at its own module scope.
patch("mempalace.format_miner.chunk_text", side_effect=chunk_text_first_fails),
):
# Must not raise
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
# The second file should still have produced an upsert
upsert_calls = _mine_formats_mocks["collection"].upsert.call_args_list
non_sentinel_upserts = [
call
for call in upsert_calls
if not any(
(m or {}).get("is_sentinel") for m in (call.kwargs.get("metadatas") or call.args[2])
)
]
assert non_sentinel_upserts, (
"the good file should still have produced a content upsert after the bad file errored"
)
# ─────────────────────────────────────────────────────────────────────────────
# Amendment #3 — parity with miner.py: detect_room + tunnel computation.
# Tests written FIRST (TDD) to prove the gaps exist, then format_miner.py is
# amended to make them pass. Pattern mirrors miner.py exactly.
# ─────────────────────────────────────────────────────────────────────────────
def test_mine_formats_calls_load_config_for_rooms(_mine_formats_mocks):
"""mine_formats must load mempalace.yaml (via load_config) to get the
rooms list — same as miner.py:1154. Without this, drawers fall back to
a single 'documents' room."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
fake_config = {
"wing": "wing_aya",
"rooms": [{"name": "documents", "keywords": ["documents"]}],
}
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long " * 50),
patch("mempalace.format_miner.load_config", return_value=fake_config) as p_cfg,
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
p_cfg.assert_called_once()
def test_mine_formats_calls_detect_room_per_file(_mine_formats_mocks):
"""mine_formats must call detect_room(filepath, content, rooms, project_path)
for each file — same as miner.py:904. Hardcoding room='documents' is the
bug; this test fails until detect_room is wired in."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
fake_config = {
"wing": "wing_aya",
"rooms": [{"name": "documents", "keywords": ["documents"]}],
}
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long " * 50),
patch("mempalace.format_miner.load_config", return_value=fake_config),
patch("mempalace.format_miner.detect_room", return_value="family") as p_room,
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
p_room.assert_called_once()
# First positional arg should be the file Path; last arg should be project_path
call_args = p_room.call_args
# detect_room(filepath, content, rooms, project_path)
assert len(call_args.args) == 4 or "filepath" in call_args.kwargs
def test_mine_formats_uses_detected_room_in_drawer_metadata(_mine_formats_mocks):
"""The room field on the drawer metadata must reflect what detect_room
returned, NOT the hardcoded 'documents'. This is the visible bug from
the 2026-05-19 mine: every drawer landed in room='documents'."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
fake_config = {
"wing": "wing_aya",
"rooms": [{"name": "family", "keywords": ["family"]}],
}
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long " * 50),
patch("mempalace.format_miner.load_config", return_value=fake_config),
patch("mempalace.format_miner.detect_room", return_value="family"),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"))
upsert_calls = _mine_formats_mocks["collection"].upsert.call_args_list
found_room = None
for call in upsert_calls:
metas = call.kwargs.get("metadatas") or call.args[2]
for m in metas:
if m.get("is_sentinel"):
continue
found_room = m.get("room")
break
if found_room:
break
assert found_room == "family", (
f"drawer room must be 'family' (detect_room's return), not {found_room!r}"
)
def test_mine_formats_calls_compute_topic_tunnels_after_loop(_mine_formats_mocks):
"""After the per-file loop, mine_formats must call
_compute_topic_tunnels_for_wing(wing) exactly once. Mirrors miner.py:1241.
Without this, cross-wing topic tunnels never materialize for format-mined
wings (audit confirmed 0 tunnels in the 2026-05-19 v6 mine)."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
fake_config = {
"wing": "wing_aya",
"rooms": [{"name": "documents", "keywords": ["documents"]}],
}
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long " * 50),
patch("mempalace.format_miner.load_config", return_value=fake_config),
patch("mempalace.format_miner.detect_room", return_value="documents"),
patch("mempalace.format_miner._compute_topic_tunnels_for_wing", return_value=0) as p_tun,
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), wing="wing_aya")
p_tun.assert_called_once_with("wing_aya")
def test_mine_formats_tunnel_failure_does_not_crash_mine(_mine_formats_mocks):
"""If _compute_topic_tunnels_for_wing raises, the mine must still
complete (summary still prints, no exception propagates). Mirrors the
try/except wrap at miner.py:1244-1249."""
from unittest.mock import patch
from mempalace.format_miner import mine_formats
tmp = _mine_formats_mocks["tmp_path"]
f = tmp / "doc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
fake_config = {
"wing": "wing_aya",
"rooms": [{"name": "documents", "keywords": ["documents"]}],
}
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long " * 50),
patch("mempalace.format_miner.load_config", return_value=fake_config),
patch("mempalace.format_miner.detect_room", return_value="documents"),
patch(
"mempalace.format_miner._compute_topic_tunnels_for_wing",
side_effect=RuntimeError("simulated tunnel-compute failure"),
),
):
# Must NOT raise
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), wing="wing_aya")
# ─────────────────────────────────────────────────────────────────────────────
# PR #1555 review (Igor) — missing-format-deps surfacing + pyproject extras
# ─────────────────────────────────────────────────────────────────────────────
def test_extract_text_missing_format_dep_returns_distinct_status(tmp_path: Path, monkeypatch):
"""A MarkItDown ``MissingDependencyException`` (raised when a per-format
sub-extra like ``markitdown[pdf]`` isn't installed) must surface as
``SKIP_MISSING_FORMAT_DEPS`` — NOT the generic ``SKIP_EXTRACTION_ERROR``.
Per PR #1555 review (Igor): real PDFs hit this in production today
and users see "extraction error" with no signal that the fix is
``pip install markitdown[pdf]``. The dispatcher must catch by type
name so the real markitdown package doesn't have to be import-
resolvable for the catch to fire.
"""
from mempalace import format_miner
# Build a fake exception class with the right __name__ so the
# type-name catch in extract_text recognises it without requiring
# the real markitdown to be installed in the test environment.
class MissingDependencyException(Exception):
pass
def fake_extract(p):
raise MissingDependencyException(
"MarkItDown failed: install with `pip install markitdown[pdf]`"
)
monkeypatch.setattr(format_miner, "_extract_via_markitdown", fake_extract)
pdf = tmp_path / "fake.pdf"
pdf.write_bytes(b"%PDF-1.4 stub")
text, status = extract_text(pdf)
assert text is None
assert status == ExtractionStatus.SKIP_MISSING_FORMAT_DEPS, (
f"expected SKIP_MISSING_FORMAT_DEPS, got {status}"
)
def test_pyproject_extract_extra_pulls_markitdown_format_subdeps():
"""The ``mempalace[extract]`` extra must include MarkItDown's per-format
sub-extras (``pdf``, ``docx``, ``pptx``, ``xlsx``) — without them, real
PDF/DOCX/etc files hit ``MissingDependencyException`` at runtime even
after ``pip install mempalace[extract]``. Per PR #1555 review (Igor).
``.rtf`` is covered by the separate ``striprtf`` dependency and ``.epub``
ships in base MarkItDown (``EpubConverter`` uses ``beautifulsoup4``
which is a base requirement, not extra-gated), so neither needs a
sub-extra here.
"""
try:
import tomllib # 3.11+
except ImportError: # pragma: no cover — only hit on 3.9/3.10
import tomli as tomllib # type: ignore[import-not-found, no-redef]
pyproject_path = Path(__file__).resolve().parent.parent / "pyproject.toml"
with open(pyproject_path, "rb") as f:
data = tomllib.load(f)
extract = data["project"]["optional-dependencies"]["extract"]
extract_str = " ".join(extract).lower()
# Accept any layout — comma-separated ``markitdown[docx,pdf,pptx,xlsx]``
# or separate ``markitdown[pdf]`` entries — as long as each format
# appears inside SOME ``markitdown[...]`` bracketed group.
import re
bracketed = "".join(re.findall(r"markitdown\[([^\]]+)\]", extract_str))
for sub in ("pdf", "docx", "pptx", "xlsx"):
assert sub in bracketed, (
f"[extract] must include markitdown[{sub}]; "
f"got bracketed extras: {bracketed!r}; full extract: {extract}"
)
# ─────────────────────────────────────────────────────────────────────────────
# PR #1555 review polish — bot feedback items addressed post-merge.
# Five behavioral changes; the trivial items (path expanduser, signature
# annotations, docstring/doc updates) don't need their own RED tests.
# ─────────────────────────────────────────────────────────────────────────────
def test_extract_text_nonexistent_regular_file_returns_unreadable_not_broken_symlink(
tmp_path: Path,
):
"""A non-existent REGULAR file (not a symlink) must return
``SKIP_UNREADABLE``, not ``SKIP_BROKEN_SYMLINK``.
Per PR #1555 review (Copilot #8): the ``FileNotFoundError`` arm in
``extract_text`` was unconditionally mapping to ``SKIP_BROKEN_SYMLINK``,
which is misleading when the path was a regular file that got deleted
between scan and extract. ``SKIP_BROKEN_SYMLINK`` should only fire
when ``p.is_symlink()`` is true.
"""
p = tmp_path / "deleted.pdf"
assert not p.exists()
assert not p.is_symlink()
text, status = extract_text(p)
assert text is None
assert status == ExtractionStatus.SKIP_UNREADABLE, (
f"Expected SKIP_UNREADABLE for non-existent regular file (file was "
f"deleted between scan and extract); got {status}. "
f"SKIP_BROKEN_SYMLINK should only fire when the path is_symlink()."
)
def test_mine_formats_passes_extract_mode_format_to_file_already_mined(monkeypatch, tmp_path: Path):
"""``mine_formats`` must call ``file_already_mined`` with
``extract_mode='format'`` so format-mode idempotency is scoped to its
own drawer set. Otherwise drawers from convo_miner / project miner
on the same source file falsely indicate "already mined" to the
format miner (and vice versa).
Per PR #1555 review (Copilot #11 + #12). Both call sites — the
pre-lock check in ``mine_formats`` and the post-lock recheck in
``_file_chunks_locked`` — must pass the kwarg.
"""
from mempalace import format_miner
from mempalace.format_miner import mine_formats
calls: list = []
def fake_check(collection, source_file, check_mtime=False, extract_mode=None):
calls.append(
{
"source_file": source_file,
"check_mtime": check_mtime,
"extract_mode": extract_mode,
}
)
return False # never already mined — let the mine proceed
monkeypatch.setattr(format_miner, "file_already_mined", fake_check)
tmp = tmp_path / "src"
tmp.mkdir()
f = tmp / "x.pdf"
f.write_bytes(b"%PDF-1.4 stub")
fake_config = {
"wing": "wing_test",
"rooms": [{"name": "documents", "keywords": ["documents"]}],
}
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long " * 50),
patch("mempalace.format_miner.load_config", return_value=fake_config),
patch("mempalace.format_miner.detect_room", return_value="documents"),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), wing="wing_test")
assert calls, "expected file_already_mined to be called at least once"
for call in calls:
assert call["extract_mode"] == "format", (
f"file_already_mined call missing extract_mode='format': {call}"
)
def test_mine_formats_does_not_write_sentinel_for_skip_no_markitdown(monkeypatch, tmp_path: Path):
"""Sentinel writing must be skipped when the status is a transient
"missing optional dependency" (``SKIP_NO_MARKITDOWN``,
``SKIP_NO_STRIPRTF``, ``SKIP_MISSING_FORMAT_DEPS``). Otherwise the
file is permanently marked as "already mined" — installing the missing
extra later does NOT trigger a re-mine, which is a real user surprise.
Per PR #1555 review (Copilot #14).
"""
from mempalace import format_miner
from mempalace.format_miner import mine_formats
register_calls: list = []
def fake_register(collection, source_file, wing, agent):
register_calls.append(source_file)
monkeypatch.setattr(format_miner, "_register_file", fake_register)
tmp = tmp_path / "src"
tmp.mkdir()
f = tmp / "y.pdf"
f.write_bytes(b"%PDF-1.4 stub")
fake_config = {
"wing": "wing_test",
"rooms": [{"name": "documents", "keywords": ["documents"]}],
}
# Force the extract path to raise ImportError so we hit SKIP_NO_MARKITDOWN.
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch(
"mempalace.format_miner._extract_via_markitdown",
side_effect=ImportError("no markitdown"),
),
patch("mempalace.format_miner.load_config", return_value=fake_config),
patch("mempalace.format_miner.detect_room", return_value="documents"),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), wing="wing_test")
assert str(f) not in register_calls, (
f"sentinel was written for SKIP_NO_MARKITDOWN file; should be skipped "
f"so installing markitdown later triggers a re-mine. "
f"register_calls={register_calls}"
)
def test_mine_formats_does_not_write_sentinel_for_skip_missing_format_deps(
monkeypatch, tmp_path: Path
):
"""Same as the SKIP_NO_MARKITDOWN test but for SKIP_MISSING_FORMAT_DEPS
(raised when markitdown is installed but a per-format sub-extra like
``markitdown[pdf]`` is missing).
"""
from mempalace import format_miner
from mempalace.format_miner import mine_formats
register_calls: list = []
def fake_register(collection, source_file, wing, agent):
register_calls.append(source_file)
monkeypatch.setattr(format_miner, "_register_file", fake_register)
tmp = tmp_path / "src"
tmp.mkdir()
f = tmp / "z.pdf"
f.write_bytes(b"%PDF-1.4 stub")
fake_config = {
"wing": "wing_test",
"rooms": [{"name": "documents", "keywords": ["documents"]}],
}
class _FakeMissingDep(Exception):
pass
_FakeMissingDep.__name__ = "MissingDependencyException"
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch(
"mempalace.format_miner._extract_via_markitdown",
side_effect=_FakeMissingDep("install markitdown[pdf]"),
),
patch("mempalace.format_miner.load_config", return_value=fake_config),
patch("mempalace.format_miner.detect_room", return_value="documents"),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), wing="wing_test")
assert str(f) not in register_calls, (
f"sentinel was written for SKIP_MISSING_FORMAT_DEPS file; should be "
f"skipped so installing markitdown[<fmt>] later triggers a re-mine. "
f"register_calls={register_calls}"
)
def test_mine_formats_catches_unexpected_exception_and_prints_summary(
monkeypatch, tmp_path: Path, capsys
):
"""If an unexpected error happens during mining (something the
per-file try/except doesn't catch — e.g., the topic-tunnel block
blowing up in a way the inner handler misses), mine_formats must
catch it at the outer level, print a partial-progress summary, and
clean up the PID file. Not crash with a traceback to the user.
Per PR #1555 review (Gemini #5).
"""
from mempalace.format_miner import mine_formats
tmp = tmp_path / "src"
tmp.mkdir()
f = tmp / "exc.pdf"
f.write_bytes(b"%PDF-1.4 stub")
fake_config = {
"wing": "wing_test",
"rooms": [{"name": "documents", "keywords": ["documents"]}],
}
# Patch the file enumeration step to raise something unexpected —
# this fires OUTSIDE the per-file try/except. Without an outer
# ``except Exception`` clause, the traceback propagates and the
# summary block never prints.
def angry_enumerate(*args, **kwargs):
raise RuntimeError("simulated outer-loop explosion")
with (
patch("mempalace.format_miner.scan_formats", side_effect=angry_enumerate),
patch("mempalace.format_miner.load_config", return_value=fake_config),
):
# Must NOT raise — outer except Exception should catch.
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), wing="wing_test")
def test_mine_formats_threads_chunk_size_from_user_config(monkeypatch, tmp_path: Path):
"""``mine_formats`` must pass ``chunk_size`` (and ``chunk_overlap``,
``min_chunk_size``) from MempalaceConfig through to ``chunk_text``.
Currently the config is loaded only to validate readability; the
custom chunk parameters are never used during format-mode mining,
so users who tuned their config see no effect.
Per PR #1555 review (Gemini #3).
"""
from mempalace import format_miner
from mempalace.format_miner import mine_formats
chunk_calls: list = []
def fake_chunk_text(content, source_file, **kwargs):
chunk_calls.append(kwargs)
return [{"content": content, "chunk_index": 0}]
monkeypatch.setattr(format_miner, "chunk_text", fake_chunk_text)
tmp = tmp_path / "src"
tmp.mkdir()
f = tmp / "config.pdf"
f.write_bytes(b"%PDF-1.4 stub")
fake_config = {
"wing": "wing_test",
"rooms": [{"name": "documents", "keywords": ["documents"]}],
}
# Inject custom config values via a fake MempalaceConfig.
class _FakeMempalaceConfig:
chunk_size = 1234
chunk_overlap = 56
min_chunk_size = 78
@property
def palace_path(self):
return str(tmp_path / "palace")
monkeypatch.setattr(format_miner, "MempalaceConfig", _FakeMempalaceConfig)
with (
patch("mempalace.format_miner.scan_formats", return_value=[f]),
patch("mempalace.format_miner._extract_via_markitdown", return_value="long " * 200),
patch("mempalace.format_miner.load_config", return_value=fake_config),
patch("mempalace.format_miner.detect_room", return_value="documents"),
patch("mempalace.format_miner.file_already_mined", return_value=False),
):
mine_formats(format_dir=str(tmp), palace_path=str(tmp / "palace"), wing="wing_test")
assert chunk_calls, "expected chunk_text to be called at least once"
call = chunk_calls[0]
assert call.get("chunk_size") == 1234, (
f"chunk_text called without user's chunk_size from MempalaceConfig "
f"(got {call.get('chunk_size')}, expected 1234). kwargs={call}"
)
assert call.get("chunk_overlap") == 56, (
f"chunk_text called without user's chunk_overlap "
f"(got {call.get('chunk_overlap')}, expected 56). kwargs={call}"
)
assert call.get("min_chunk_size") == 78, (
f"chunk_text called without user's min_chunk_size "
f"(got {call.get('min_chunk_size')}, expected 78). kwargs={call}"
)