mirror of
https://github.com/VectifyAI/PageIndex.git
synced 2026-07-24 21:41:04 +02:00
- cloud: _get_folder_id raises CollectionNotFoundError when the folder is gone instead of silently falling back to the account-wide space; delete stays idempotent - cloud: CloudBackend honors a base_url override (PageIndexClient.BASE_URL previously only reached the legacy API) - cloud: query_stream runs doc-id listing and thread join off the event loop (asyncio.to_thread) - cloud_api: non-streaming chat_completions gets a 300s read timeout - local: unexplained IntegrityError is wrapped as CollectionNotFoundError/ IndexingError instead of leaking raw sqlite3 errors - index: check_title_appearance rejects below-range physical_index instead of wrapping to the last page; page_index_main coerces legacy 'yes'/'no' string flags - config: correct max_concurrency doc and warn when a per-index value exceeds the process ceiling - compat: restore pageindex.utils config alias; add legacy names back to __all__
215 lines
7 KiB
Python
215 lines
7 KiB
Python
"""Regression tests for the PR #272 review findings #9-#15."""
|
|
import asyncio
|
|
import sqlite3
|
|
import threading
|
|
import warnings
|
|
|
|
import pytest
|
|
|
|
from pageindex.errors import CollectionNotFoundError, IndexingError
|
|
|
|
|
|
# ── #9: below-range physical_index must not wrap to the last page ────────────
|
|
|
|
def test_check_title_appearance_rejects_below_range_index(monkeypatch):
|
|
from pageindex.index import page_index as pi
|
|
|
|
async def _fail(*a, **k):
|
|
raise AssertionError("LLM must not be called for a below-range index")
|
|
|
|
monkeypatch.setattr(pi, "llm_acompletion", _fail)
|
|
item = {"title": "Intro", "physical_index": 0, "list_index": 3}
|
|
# A single-page page_list: without the guard, page_list[0-1] silently
|
|
# reads this (last) page instead of erroring.
|
|
result = asyncio.run(
|
|
pi.check_title_appearance(item, [("last page text", 10)], start_index=1)
|
|
)
|
|
assert result["answer"] == "no"
|
|
assert result["page_number"] is None
|
|
|
|
|
|
# ── #6: page_index_main must coerce legacy 'yes'/'no' string flags ───────────
|
|
|
|
def test_page_index_main_coerces_legacy_string_flags():
|
|
from types import SimpleNamespace
|
|
from pageindex.index.page_index import page_index_main
|
|
|
|
opt = SimpleNamespace(model=None, if_add_node_id='yes', if_add_node_text='no',
|
|
if_add_node_summary='no', if_add_doc_description='no')
|
|
# Coercion runs before input validation, which rejects the non-PDF path.
|
|
with pytest.raises(ValueError, match="Unsupported input type"):
|
|
page_index_main('not-a-pdf.txt', opt)
|
|
assert opt.if_add_node_id is True
|
|
assert opt.if_add_node_text is False
|
|
assert opt.if_add_node_summary is False
|
|
assert opt.if_add_doc_description is False
|
|
|
|
|
|
# ── #10: per-index max_concurrency above the ceiling must warn ────────────────
|
|
|
|
def test_max_concurrency_scope_warns_above_ceiling():
|
|
from pageindex.config import (
|
|
max_concurrency_scope,
|
|
set_max_concurrency,
|
|
_process_wide_max_concurrency,
|
|
)
|
|
|
|
original = _process_wide_max_concurrency()
|
|
set_max_concurrency(5)
|
|
try:
|
|
with pytest.warns(UserWarning, match="exceeds the process-wide ceiling"):
|
|
with max_concurrency_scope(20):
|
|
pass
|
|
# A narrowing value is the supported use and must stay silent.
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("error")
|
|
with max_concurrency_scope(3):
|
|
pass
|
|
finally:
|
|
set_max_concurrency(original)
|
|
|
|
|
|
# ── #11: non-streaming legacy chat_completions needs a long read timeout ─────
|
|
|
|
def test_legacy_chat_completions_nonstream_timeout(monkeypatch):
|
|
from pageindex import cloud_api as ca
|
|
|
|
captured = {}
|
|
|
|
class FakeResp:
|
|
status_code = 200
|
|
text = ""
|
|
|
|
def json(self):
|
|
return {"choices": []}
|
|
|
|
def fake_request(method, url, headers=None, **kwargs):
|
|
captured.clear()
|
|
captured.update(kwargs)
|
|
return FakeResp()
|
|
|
|
monkeypatch.setattr(ca.requests, "request", fake_request)
|
|
api = ca.LegacyCloudAPI(api_key="pi-test")
|
|
|
|
api.chat_completions(messages=[{"role": "user", "content": "q"}], stream=False)
|
|
assert captured["timeout"] == 300
|
|
|
|
api.chat_completions(messages=[{"role": "user", "content": "q"}], stream=True)
|
|
assert captured["timeout"] == 120 # between-chunks timeout, unchanged
|
|
|
|
|
|
# ── #12: query_stream must not run the doc-id listing on the loop thread ─────
|
|
|
|
def test_query_stream_lists_docs_off_event_loop(monkeypatch):
|
|
import pageindex.backend.cloud as cloud_mod
|
|
from pageindex.backend.cloud import CloudBackend
|
|
|
|
backend = CloudBackend(api_key="pi-test")
|
|
seen = {}
|
|
|
|
def fake_get_all(collection):
|
|
seen["thread"] = threading.current_thread()
|
|
return ["d1"]
|
|
|
|
monkeypatch.setattr(backend, "_get_all_doc_ids", fake_get_all)
|
|
|
|
class FakeResponse:
|
|
status_code = 200
|
|
text = ""
|
|
|
|
def iter_lines(self, decode_unicode=True):
|
|
yield "data: [DONE]"
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
monkeypatch.setattr(cloud_mod.requests, "post", lambda *a, **k: FakeResponse())
|
|
|
|
async def _run():
|
|
async for _ in backend.query_stream("col", "q"): # doc_ids=None → list all
|
|
pass
|
|
return threading.current_thread()
|
|
|
|
loop_thread = asyncio.run(_run())
|
|
assert seen["thread"] is not loop_thread
|
|
|
|
|
|
# ── #13: an unexplained IntegrityError must surface as a PageIndexError ──────
|
|
|
|
class _RaceStorage:
|
|
"""Collection exists at the fail-fast check, then save trips the FK."""
|
|
|
|
def __init__(self, collections_after_start):
|
|
self._after = collections_after_start
|
|
self._calls = 0
|
|
|
|
def list_collections(self):
|
|
self._calls += 1
|
|
return ["col"] if self._calls == 1 else self._after
|
|
|
|
def find_document_by_hash(self, collection, file_hash):
|
|
return None
|
|
|
|
def save_document(self, *a, **k):
|
|
raise sqlite3.IntegrityError("FOREIGN KEY constraint failed")
|
|
|
|
|
|
def _make_backend(tmp_path, monkeypatch, storage):
|
|
import pageindex.backend.local as local_mod
|
|
from pageindex.backend.local import LocalBackend
|
|
|
|
backend = LocalBackend(storage=storage, files_dir=str(tmp_path / "files"))
|
|
|
|
class FakeParsed:
|
|
doc_name = "doc"
|
|
nodes = []
|
|
|
|
class FakeParser:
|
|
def parse(self, path, model=None, images_dir=None):
|
|
return FakeParsed()
|
|
|
|
monkeypatch.setattr(backend, "_resolve_parser", lambda p: FakeParser())
|
|
monkeypatch.setattr(
|
|
local_mod, "build_index", lambda parsed, model=None, opt=None: {"structure": []}
|
|
)
|
|
pdf = tmp_path / "doc.pdf"
|
|
pdf.write_bytes(b"%PDF-1.4 fake")
|
|
return backend, str(pdf)
|
|
|
|
|
|
def test_concurrent_collection_delete_raises_collection_not_found(tmp_path, monkeypatch):
|
|
backend, pdf = _make_backend(tmp_path, monkeypatch, _RaceStorage([]))
|
|
with pytest.raises(CollectionNotFoundError):
|
|
backend.add_document("col", pdf)
|
|
|
|
|
|
def test_unexplained_integrity_error_wrapped_as_indexing_error(tmp_path, monkeypatch):
|
|
backend, pdf = _make_backend(tmp_path, monkeypatch, _RaceStorage(["col"]))
|
|
with pytest.raises(IndexingError):
|
|
backend.add_document("col", pdf)
|
|
|
|
|
|
# ── #14: legacy `from pageindex.utils import config` must keep working ───────
|
|
|
|
def test_legacy_config_alias_importable():
|
|
from types import SimpleNamespace
|
|
with warnings.catch_warnings():
|
|
warnings.simplefilter("ignore") # module-level deprecation shim warning
|
|
from pageindex.utils import config
|
|
assert config is SimpleNamespace
|
|
|
|
|
|
# ── #15: star import must bind the legacy pre-SDK names ──────────────────────
|
|
|
|
def test_star_import_binds_legacy_names():
|
|
ns = {}
|
|
exec("from pageindex import *", ns)
|
|
for name in (
|
|
"page_index",
|
|
"page_index_main",
|
|
"tree_parser",
|
|
"ConfigLoader",
|
|
"llm_completion",
|
|
"llm_acompletion",
|
|
):
|
|
assert name in ns, f"{name} missing from star import"
|