PageIndex/tests/test_architecture.py
mountain b2756c73d2 refactor(sdk): typed returns, protocol contract, parser layering
Engineering-quality cleanups from the SDK review (no behavior change):

- Return-type discoverability: add pageindex/types.py with TypedDicts
  (DocumentInfo, DocumentDetail, PageContent) and annotate Collection /
  Backend methods with them; add docstrings to every public Collection
  method (including the get_page_content `pages` spec). Exported from the
  package. Zero runtime cost — these are plain dicts.

- Backend protocol as a real contract:
  * query_stream is an async generator, so the protocol now declares it
    as `def ... -> AsyncIterator[QueryEvent]` (not `async def`, which
    typed it as a coroutine and never matched the implementations).
  * custom-parser support is expressed as a runtime_checkable
    SupportsParserRegistration capability protocol; the client uses
    isinstance(...) instead of hasattr(...) duck-typing.

- Parser layering: move count_tokens into a leaf module pageindex/tokens.py
  so parser/* imports it from there instead of reaching back into
  pageindex.index (a reverse dependency). index.utils re-exports it for
  backward compatibility.

Adds tests/test_architecture.py enforcing: parser never imports index,
count_tokens is a single shared leaf, the capability protocol works,
both backends satisfy Backend, and the TypedDicts are exported.

Claude-Session: https://claude.ai/code/session_01Kx5DgKbhK1N8autqXH8SmS
2026-07-07 12:15:34 +08:00

56 lines
2.2 KiB
Python

"""Layering / protocol-contract guards for the SDK."""
import ast
from pathlib import Path
import pytest
from pageindex.backend.protocol import Backend, SupportsParserRegistration
from pageindex.backend.cloud import CloudBackend
def test_parser_layer_does_not_import_index():
"""parser/* must not depend on the index package (reverse dependency)."""
parser_dir = Path(__file__).parent.parent / "pageindex" / "parser"
offenders = []
for py in parser_dir.glob("*.py"):
tree = ast.parse(py.read_text())
for node in ast.walk(tree):
if isinstance(node, ast.ImportFrom) and node.module and "index" in node.module.split("."):
offenders.append(f"{py.name}: from {node.module}")
assert not offenders, f"parser imports index: {offenders}"
def test_count_tokens_lives_in_leaf_module():
from pageindex.tokens import count_tokens
from pageindex.index.utils import count_tokens as reexport
from pageindex.parser.pdf import count_tokens as parser_ct
# index re-exports the leaf function; parser imports the same leaf.
assert reexport is count_tokens is parser_ct
def test_parser_registration_is_a_capability_protocol():
from unittest.mock import MagicMock
from pageindex.backend.local import LocalBackend
lb = LocalBackend(storage=MagicMock(), files_dir="/tmp/x", model="m")
assert isinstance(lb, SupportsParserRegistration)
assert not isinstance(CloudBackend(api_key="pi-test"), SupportsParserRegistration)
def test_register_parser_rejected_in_cloud_mode():
from pageindex import CloudClient
from pageindex.errors import PageIndexError
client = CloudClient(api_key="pi-test")
with pytest.raises(PageIndexError, match="not supported in cloud mode"):
client.register_parser(object())
def test_both_backends_satisfy_backend_protocol():
from unittest.mock import MagicMock
from pageindex.backend.local import LocalBackend
assert isinstance(CloudBackend(api_key="pi-test"), Backend)
assert isinstance(LocalBackend(storage=MagicMock(), files_dir="/tmp/x", model="m"), Backend)
def test_typed_dicts_are_exported():
import pageindex.types as t
assert {"DocumentInfo", "DocumentDetail", "PageContent"} <= set(dir(t))