fix: restore 0.2.x config resolution and legacy submodule attribute access

page_index() and ConfigLoader silently dropped config.yaml after the
refactor: defaults came from IndexConfig's hardcoded fields, flipping
if_add_doc_description on (an extra billed LLM call per document) and
discarding user-edited YAML including a custom default_path. Both now
resolve explicit args > YAML > IndexConfig field defaults via
IndexConfig.from_yaml, matching the CLI. The new SDK keeps its pure-code
config path.

import pageindex also lost the utils / page_index_md submodule
attributes (pageindex.utils.print_tree raised AttributeError). A module
__getattr__ now imports the shims lazily, so plain imports stay free of
deprecation warnings while first use of a legacy attribute binds the
module and warns.

Also trims non-essential comments from the BASE_URL fix.

Claude-Session: https://claude.ai/code/session_014B4HZkjdSiZXDmJtH5Jexn
This commit is contained in:
Ray 2026-07-19 03:42:00 +08:00
parent dea211b4a2
commit 0f593c65d9
7 changed files with 82 additions and 15 deletions

View file

@ -74,3 +74,12 @@ __all__ = [
"get_document_structure",
"get_page_content",
]
def __getattr__(name):
# Lazy so plain `import pageindex` never trips the shims' deprecation
# warnings; they fire only when the legacy attribute is actually used.
if name in ("utils", "page_index_md"):
import importlib
return importlib.import_module(f".{name}", __name__)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")

View file

@ -40,8 +40,6 @@ class CloudBackend:
@property
def base_url(self) -> str:
# A callable is resolved per request so reassigning client.BASE_URL
# after construction takes effect, matching 0.2.x call-time semantics.
return self._base_url() if callable(self._base_url) else self._base_url
# ── HTTP helpers ──────────────────────────────────────────────────────

View file

@ -74,8 +74,8 @@ class PageIndexClient:
def _init_cloud(self, api_key: str):
from .backend.cloud import CloudBackend
from .cloud_api import LegacyCloudAPI
# Pass a callable so BASE_URL is re-read on every request — 0.2.x
# allowed reassigning client.BASE_URL after construction.
# Callable: re-read per request so post-construction BASE_URL
# reassignment (a 0.2.x pattern) still applies.
base_url = lambda: self.BASE_URL
self._backend = CloudBackend(api_key=api_key, base_url=base_url)
self._legacy_cloud_api = LegacyCloudAPI(api_key=api_key, base_url=base_url)

View file

@ -25,8 +25,6 @@ class LegacyCloudAPI:
@property
def base_url(self) -> str:
# A callable is resolved per request so reassigning client.BASE_URL
# after construction takes effect, matching 0.2.x call-time semantics.
return self._base_url() if callable(self._base_url) else self._base_url
@base_url.setter

View file

@ -1314,7 +1314,7 @@ def page_index(doc, model=None, toc_check_page_num=None, max_page_num_each_node=
"if_add_node_text": if_add_node_text,
}
user_opt = {k: v for k, v in user_opt.items() if v is not None}
opt = IndexConfig(**user_opt)
opt = IndexConfig.from_yaml(**user_opt)
return page_index_main(doc, opt)

View file

@ -953,14 +953,14 @@ def _coerce_bool(value):
class ConfigLoader:
"""Legacy 0.2.x config helper. Defaults now come from IndexConfig; this
class no longer reads the packaged ``config.yaml`` (the CLI still uses it
via ``IndexConfig.from_yaml``). Prefer ``pageindex.IndexConfig``.
"""Legacy 0.2.x config helper. Defaults come from ``default_path`` (or the
packaged ``config.yaml``), with IndexConfig field defaults filling any keys
the YAML omits. Prefer ``pageindex.IndexConfig`` in new code.
"""
def __init__(self, default_path=None):
from ..config import IndexConfig
self._default_dict = IndexConfig().model_dump()
self._default_dict = IndexConfig.from_yaml(default_path).model_dump()
def _validate_keys(self, user_dict):
unknown_keys = set(user_dict) - set(self._default_dict)
@ -968,7 +968,7 @@ class ConfigLoader:
raise ValueError(f"Unknown config keys: {unknown_keys}")
def load(self, user_opt=None) -> _config:
"""Merge user options over IndexConfig defaults, returning a namespace."""
"""Merge user options over the YAML defaults, returning a namespace."""
if user_opt is None:
user_dict = {}
elif isinstance(user_opt, _config):

View file

@ -62,16 +62,78 @@ def test_get_leaf_nodes_has_331_fix():
assert leaves == [{"title": "Leaf", "start_index": 1, "end_index": 2}]
def test_configloader_no_longer_needs_config_yaml():
"""ConfigLoader must build defaults from IndexConfig, not read config.yaml."""
def test_configloader_defaults_come_from_packaged_yaml():
"""ConfigLoader must read the packaged config.yaml as its defaults, like
0.2.x notably if_add_doc_description ships as "no" there, while the
IndexConfig field default is True (the new-SDK default)."""
from pageindex.index.utils import ConfigLoader
cfg = ConfigLoader().load({"model": "gpt-5.4"})
assert cfg.model == "gpt-5.4"
assert cfg.if_add_node_summary is True # IndexConfig default
assert cfg.if_add_node_summary is True # config.yaml: "yes"
assert cfg.if_add_doc_description is False # config.yaml: "no"
with pytest.raises(ValueError, match="Unknown config keys"):
ConfigLoader().load({"nope": 1})
def test_configloader_reads_custom_yaml_path(tmp_path):
"""A custom default_path must be honored; keys the YAML omits fall back to
IndexConfig field defaults."""
from pageindex.index.utils import ConfigLoader
custom = tmp_path / "my.yaml"
custom.write_text('model: "my-model"\nif_add_node_summary: "no"\n')
cfg = ConfigLoader(str(custom)).load()
assert cfg.model == "my-model"
assert cfg.if_add_node_summary is False
assert cfg.if_add_node_id is True # omitted -> IndexConfig default
def test_configloader_missing_custom_yaml_raises(tmp_path):
from pageindex.index.utils import ConfigLoader
with pytest.raises(FileNotFoundError):
ConfigLoader(str(tmp_path / "nope.yaml"))
def test_legacy_submodule_attrs_lazy_bound():
"""Shim warnings fire on first attribute use, never at package import.
Subprocess: in-process the attrs may already be bound by other tests."""
import subprocess
import sys
code = (
"import warnings\n"
"with warnings.catch_warnings(record=True) as w:\n"
" warnings.simplefilter('always')\n"
" import pageindex\n"
"assert not any('has moved' in str(x.message) for x in w), 'import warned'\n"
"with warnings.catch_warnings(record=True) as w:\n"
" warnings.simplefilter('always')\n"
" assert callable(pageindex.utils.print_tree)\n"
"assert any('pageindex.utils has moved' in str(x.message) for x in w)\n"
"assert callable(pageindex.page_index_md.md_to_tree)\n"
)
result = subprocess.run([sys.executable, "-c", code],
capture_output=True, text=True, timeout=120)
assert result.returncode == 0, result.stderr
def test_unknown_package_attr_still_raises():
import pageindex
with pytest.raises(AttributeError, match="no attribute 'definitely_not_real'"):
pageindex.definitely_not_real
def test_page_index_defaults_follow_config_yaml(monkeypatch):
"""page_index() resolution order: explicit args > config.yaml > IndexConfig
field defaults (the 0.2.x contract)."""
import pageindex.index.page_index as pi
captured = {}
monkeypatch.setattr(pi, "page_index_main",
lambda doc, opt: captured.setdefault("opt", opt))
pi.page_index("dummy.pdf", model="my-model")
opt = captured["opt"]
assert opt.model == "my-model" # explicit arg wins
assert opt.if_add_doc_description is False # config.yaml "no", not True
def test_configloader_coerces_legacy_yes_no_strings():
"""A legacy caller passing 'no' must get a real False, not a truthy
string page_index_main's `if opt.if_add_node_summary:` checks (bare