2026-07-08 17:33:41 +08:00
|
|
|
"""Markdown page-content selection must return exactly the requested lines,
|
|
|
|
|
mirroring the PDF path — not the whole [min, max] range (PR #272 review / #280)."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _md_structure():
|
|
|
|
|
# line_num 40 sits *between* 5 and 100 but is NOT requested below.
|
|
|
|
|
return [
|
|
|
|
|
{"line_num": 5, "text": "line five", "nodes": [
|
|
|
|
|
{"line_num": 40, "text": "line forty (should be excluded)", "nodes": []},
|
|
|
|
|
]},
|
|
|
|
|
{"line_num": 100, "text": "line hundred", "nodes": []},
|
|
|
|
|
{"line_num": 101, "text": "line 101", "nodes": []},
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_md_page_content_returns_only_requested_lines():
|
|
|
|
|
from pageindex.index.utils import get_md_page_content
|
|
|
|
|
|
|
|
|
|
out = get_md_page_content(_md_structure(), [5, 100])
|
|
|
|
|
# exactly the two requested lines — not 5, 40, 100 (the old range behavior)
|
|
|
|
|
assert [r["page"] for r in out] == [5, 100]
|
|
|
|
|
assert all("forty" not in r["content"] for r in out)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_get_md_page_content_empty_spec():
|
|
|
|
|
from pageindex.index.utils import get_md_page_content
|
|
|
|
|
|
|
|
|
|
assert get_md_page_content(_md_structure(), []) == []
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_retrieve_md_page_content_returns_only_requested_lines():
|
|
|
|
|
# The legacy retrieve path has its own copy of the same logic.
|
|
|
|
|
from pageindex.retrieve import _get_md_page_content
|
|
|
|
|
|
|
|
|
|
out = _get_md_page_content({"structure": _md_structure()}, [5, 100])
|
|
|
|
|
assert [r["page"] for r in out] == [5, 100]
|
fix: CMYK image drop, empty-doc crash, page_index shadowing, sqlite hardening, flaky tests
Addresses items 9-13 and a/b/c/f from the max-effort review of PR #272.
- pdf.py: image colorspace check was `pix.n > 4`, which treats CMYK-without-
alpha (n==4, same as RGBA) as not needing RGB conversion; pix.save() as .png
then raises "unsupported colorspace", silently dropped by the surrounding
except. Fixed to `pix.n - pix.alpha >= 4` (correctly converts CMYK, leaves
RGBA untouched).
- pipeline.py: detect_strategy([]) (an empty/whitespace-only source file)
returned "content_based", routing into the PDF-oriented TOC-detection
pipeline -- wasting a real LLM call before raising IndexingError. Empty
node lists now route to level_based, whose build_tree_from_levels([])
returns an empty structure instantly with zero LLM calls.
- page_index.py (shim): pageindex/__init__.py binds the canonical `page_index`
function as the package attribute, but this file is ALSO a real submodule
of the same name -- importing it anywhere (import machinery, unconditional)
overwrites that attribute with the module object, breaking
`from pageindex import page_index; page_index(x)` for the rest of the
process. Made the shim module itself callable (delegates to the real
function via a ModuleType subclass), so whichever object ends up in that
slot is callable regardless of import order.
- storage/sqlite.py: create_collection let a raw sqlite3.IntegrityError escape
on a duplicate name (new CollectionAlreadyExistsError); the collections
table's CHECK constraint only validated the name's first character (GLOB
'*' is a wildcard, not a regex quantifier over the preceding class) --
fixed to validate the whole string, and SQLiteStorage now also validates in
Python (it's a public StorageEngine usable directly, bypassing
LocalBackend's own check).
- tests/test_review_fixes_2.py: two tests used a ContentNode with no `level`
set, so build_index took the content_based path and made real (retried,
slow, and -- with a valid key -- billable) LLM calls instead of testing the
text-stripping logic they claimed to. Mocked out _content_based_pipeline.
- retrieve.py: _parse_pages/_get_pdf_page_content were independent copies of
the canonical parse_pages/get_pdf_page_content that had already drifted
(missing the p>=1 filter and 1000-page DoS cap) -- delegate to canonical
now, so the legacy pageindex.get_page_content path can't silently regress
again.
- parser/markdown.py: a leading UTF-8 BOM broke first-header detection
(not whitespace, .strip() doesn't remove it) -- decode utf-8-sig. Only
backtick fences were recognized as code blocks, so a '#'-prefixed line
inside a ~~~-fenced block (valid CommonMark) was misparsed as a heading --
recognize both fence styles.
- run_pageindex.py: --if-thinning wasn't migrated to the bare-flag +
legacy-yes/no convention the other four --if-add-* flags got; bare usage
raised an argparse error and it never went through the shared coercion.
- types.py: DocumentDetail's `structure` field was inside the class's
total=False body, so TypedDict rules made it optional even though every
backend always populates it. Split into a required base class.
Adds regression tests for all of the above. Full suite: 244 passed, 2 skipped
(one pre-existing, unrelated flaky cloud-streaming test).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:58:59 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_retrieve_parse_pages_delegates_to_canonical_and_enforces_dos_cap():
|
|
|
|
|
"""retrieve._parse_pages used to be an independent copy that lacked the
|
|
|
|
|
canonical parse_pages' p>=1 filter and 1000-page cap — a caller of the
|
|
|
|
|
legacy pageindex.get_page_content could bypass the DoS guard the SDK path
|
|
|
|
|
enforces. Now it's a one-line delegate, so they can't drift again."""
|
|
|
|
|
from pageindex.retrieve import _parse_pages
|
|
|
|
|
from pageindex.index.utils import parse_pages
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
assert _parse_pages("5-7") == parse_pages("5-7") == [5, 6, 7]
|
|
|
|
|
with pytest.raises(ValueError, match="too large"):
|
|
|
|
|
_parse_pages("1-99999999")
|
|
|
|
|
|
|
|
|
|
|
2026-07-10 14:49:55 +08:00
|
|
|
def test_parse_pages_caps_a_huge_range_without_materializing_it():
|
|
|
|
|
"""Regression: the 1000-page cap was checked only AFTER
|
|
|
|
|
`result.extend(range(start, end + 1))`, so a single huge span like
|
|
|
|
|
'1-2000000000' allocated billions of ints and OOM'd before the check ran.
|
|
|
|
|
The span must be rejected up front, quickly, without building the list."""
|
|
|
|
|
import time
|
|
|
|
|
import pytest
|
|
|
|
|
from pageindex.index.utils import parse_pages
|
|
|
|
|
|
|
|
|
|
start = time.monotonic()
|
|
|
|
|
with pytest.raises(ValueError, match="too large"):
|
|
|
|
|
parse_pages("1-2000000000")
|
|
|
|
|
# Must be near-instant (no billion-element allocation). Generous bound to
|
|
|
|
|
# avoid flakiness while still failing loudly on a re-materializing regression.
|
|
|
|
|
assert time.monotonic() - start < 1.0
|
|
|
|
|
|
|
|
|
|
# Boundary: exactly 1000 pages is allowed; 1001 is rejected.
|
|
|
|
|
assert parse_pages("1-1000") == list(range(1, 1001))
|
|
|
|
|
with pytest.raises(ValueError, match="too large"):
|
|
|
|
|
parse_pages("1-1001")
|
|
|
|
|
# A range that fits but whose accumulation across parts crosses the cap.
|
|
|
|
|
with pytest.raises(ValueError, match="too large"):
|
|
|
|
|
parse_pages("1-600,700-1400")
|
|
|
|
|
|
|
|
|
|
|
fix: CMYK image drop, empty-doc crash, page_index shadowing, sqlite hardening, flaky tests
Addresses items 9-13 and a/b/c/f from the max-effort review of PR #272.
- pdf.py: image colorspace check was `pix.n > 4`, which treats CMYK-without-
alpha (n==4, same as RGBA) as not needing RGB conversion; pix.save() as .png
then raises "unsupported colorspace", silently dropped by the surrounding
except. Fixed to `pix.n - pix.alpha >= 4` (correctly converts CMYK, leaves
RGBA untouched).
- pipeline.py: detect_strategy([]) (an empty/whitespace-only source file)
returned "content_based", routing into the PDF-oriented TOC-detection
pipeline -- wasting a real LLM call before raising IndexingError. Empty
node lists now route to level_based, whose build_tree_from_levels([])
returns an empty structure instantly with zero LLM calls.
- page_index.py (shim): pageindex/__init__.py binds the canonical `page_index`
function as the package attribute, but this file is ALSO a real submodule
of the same name -- importing it anywhere (import machinery, unconditional)
overwrites that attribute with the module object, breaking
`from pageindex import page_index; page_index(x)` for the rest of the
process. Made the shim module itself callable (delegates to the real
function via a ModuleType subclass), so whichever object ends up in that
slot is callable regardless of import order.
- storage/sqlite.py: create_collection let a raw sqlite3.IntegrityError escape
on a duplicate name (new CollectionAlreadyExistsError); the collections
table's CHECK constraint only validated the name's first character (GLOB
'*' is a wildcard, not a regex quantifier over the preceding class) --
fixed to validate the whole string, and SQLiteStorage now also validates in
Python (it's a public StorageEngine usable directly, bypassing
LocalBackend's own check).
- tests/test_review_fixes_2.py: two tests used a ContentNode with no `level`
set, so build_index took the content_based path and made real (retried,
slow, and -- with a valid key -- billable) LLM calls instead of testing the
text-stripping logic they claimed to. Mocked out _content_based_pipeline.
- retrieve.py: _parse_pages/_get_pdf_page_content were independent copies of
the canonical parse_pages/get_pdf_page_content that had already drifted
(missing the p>=1 filter and 1000-page DoS cap) -- delegate to canonical
now, so the legacy pageindex.get_page_content path can't silently regress
again.
- parser/markdown.py: a leading UTF-8 BOM broke first-header detection
(not whitespace, .strip() doesn't remove it) -- decode utf-8-sig. Only
backtick fences were recognized as code blocks, so a '#'-prefixed line
inside a ~~~-fenced block (valid CommonMark) was misparsed as a heading --
recognize both fence styles.
- run_pageindex.py: --if-thinning wasn't migrated to the bare-flag +
legacy-yes/no convention the other four --if-add-* flags got; bare usage
raised an argparse error and it never went through the shared coercion.
- types.py: DocumentDetail's `structure` field was inside the class's
total=False body, so TypedDict rules made it optional even though every
backend always populates it. Split into a required base class.
Adds regression tests for all of the above. Full suite: 244 passed, 2 skipped
(one pre-existing, unrelated flaky cloud-streaming test).
Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-09 11:58:59 +08:00
|
|
|
def test_retrieve_get_pdf_page_content_falls_back_to_canonical(tmp_path, monkeypatch):
|
|
|
|
|
"""When no cached 'pages' are present, the file-read fallback must
|
|
|
|
|
delegate to the canonical get_pdf_page_content instead of re-implementing
|
|
|
|
|
PDF text extraction inline (a second, independently-maintained copy)."""
|
|
|
|
|
from pageindex.retrieve import _get_pdf_page_content
|
|
|
|
|
import pageindex.retrieve as retrieve_mod
|
|
|
|
|
|
|
|
|
|
calls = []
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
retrieve_mod, "get_pdf_page_content",
|
|
|
|
|
lambda path, page_nums: calls.append((path, page_nums)) or [{"page": 1, "content": "x"}],
|
|
|
|
|
)
|
|
|
|
|
result = _get_pdf_page_content({"path": "/fake/doc.pdf"}, [1])
|
|
|
|
|
assert calls == [("/fake/doc.pdf", [1])]
|
|
|
|
|
assert result == [{"page": 1, "content": "x"}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_retrieve_get_pdf_page_content_prefers_cache_over_file():
|
|
|
|
|
from pageindex.retrieve import _get_pdf_page_content
|
|
|
|
|
|
|
|
|
|
doc_info = {"path": "/should/not/be/opened.pdf",
|
|
|
|
|
"pages": [{"page": 1, "content": "cached one"}, {"page": 2, "content": "cached two"}]}
|
|
|
|
|
result = _get_pdf_page_content(doc_info, [2])
|
|
|
|
|
assert result == [{"page": 2, "content": "cached two"}]
|