From 5e2dc9bc562c7b923bf5e0a001ec9ee439b8a3c3 Mon Sep 17 00:00:00 2001 From: Ray Date: Wed, 26 Aug 2026 14:29:26 +0800 Subject: [PATCH 1/3] fix: .env stays unset when the cwd tree has none; a local client with a blank chat_model refuses at the chat door; storage_path is typed PathLike MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit find_dotenv(usecwd=True) returns '' when nothing is reachable from the cwd, and `or None` turned that into load_dotenv's own upward walk from utils.py — the install-dir leak the cwd search was added to replace. A pip-installed SDK could load another project's .env from above site-packages, silently. _local_chat treats a blank chat_model as "managed chat", which a client without an api_key does not have: chat_completions() then reached for LocalAPI.chat_completions and raised a bare AttributeError. The managed branch now refuses as a PageIndexAPIError naming chat_model. py.typed made the annotations authoritative while storage_path was typed str; _ARG_TYPES accepts os.PathLike, so Path(...) ran fine and failed the user's type check. Both signatures and LocalIndexConfig now say so. Claude-Session: https://claude.ai/code/session_017Fd7jVm366S2Xamzhxv6yb --- pageindex/client.py | 9 +++++++-- pageindex/types.py | 3 ++- pageindex/utils.py | 2 +- tests/test_client.py | 35 +++++++++++++++++++++++++++++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/pageindex/client.py b/pageindex/client.py index 2fc4b2c79..1a291984e 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -346,7 +346,7 @@ def __init__( model: Optional[str] = None, summary_model: Optional[str] = None, retrieve_model: Optional[str] = None, - storage_path: Optional[str] = None, + storage_path: Optional[Union[str, os.PathLike[str]]] = None, index_backend: Optional[dict[str, Any]] = None, chat_backend: Optional[dict[str, Any]] = None, ): @@ -915,6 +915,11 @@ def chat_completions( reasoning_effort=reasoning_effort, extra_body=extra_body, extra_headers=extra_headers, backend=backend, ) + if not getattr(self, "api_key", None): + raise PageIndexAPIError( + "chat_model is empty — it configures nothing, and a local " + "client has no managed chat to fall back to. Set " + "chat_model=... to run the agent with your own model.") if (model is not None or max_turns is not None or top_p is not None or max_tokens is not None or reasoning_effort is not None or extra_body is not None or extra_headers is not None @@ -1649,7 +1654,7 @@ def __init__( model: Optional[str] = None, summary_model: Optional[str] = None, retrieve_model: Optional[str] = None, - storage_path: Optional[str] = None, + storage_path: Optional[Union[str, os.PathLike[str]]] = None, index_backend: Optional[dict[str, Any]] = None, chat_backend: Optional[dict[str, Any]] = None, ): diff --git a/pageindex/types.py b/pageindex/types.py index ee0c44902..b02962899 100644 --- a/pageindex/types.py +++ b/pageindex/types.py @@ -11,6 +11,7 @@ """ from __future__ import annotations +import os from typing import Literal, TypedDict, Union PAGEINDEX_CLOUD = "pageindex-cloud" @@ -32,7 +33,7 @@ class LocalIndexConfig(TypedDict, total=False): model: str summary_model: str backend: dict - storage_path: str + storage_path: Union[str, os.PathLike[str]] class ChatConfig(TypedDict, total=False): diff --git a/pageindex/utils.py b/pageindex/utils.py index 1ad54b1b9..47920a029 100644 --- a/pageindex/utils.py +++ b/pageindex/utils.py @@ -11,7 +11,7 @@ import asyncio from io import BytesIO from dotenv import find_dotenv, load_dotenv -load_dotenv(find_dotenv(usecwd=True) or None) +load_dotenv(find_dotenv(usecwd=True)) import logging import yaml from pathlib import Path diff --git a/tests/test_client.py b/tests/test_client.py index b965a82b6..640faabff 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -396,6 +396,31 @@ def test_env_key_found_from_cwd(tmp_path): assert out.stdout.strip() == "ok" +def test_env_not_found_from_cwd_stays_unset(tmp_path, tmp_path_factory): + """The cwd search finding nothing must end the search: find_dotenv + returns '' then, and `or None` handed load_dotenv its own upward walk + from utils.py — the install-dir leak the cwd search replaced. A + symlinked package puts utils.py under a tree whose root holds a .env; + the cwd tree holds none.""" + site = tmp_path / "site" + site.mkdir() + (site / "pageindex").symlink_to(Path(__file__).parent.parent / "pageindex") + (tmp_path / ".env").write_text("PAGEINDEX_API_KEY=pi-leaked\n") + cwd = tmp_path_factory.mktemp("elsewhere") + (cwd / "app.py").write_text( + "from pageindex import PageIndexCloudClient, PageIndexAPIError\n" + "try:\n" + " print(PageIndexCloudClient().api_key)\n" + "except PageIndexAPIError:\n" + " print('unset')\n") + env = {**os.environ, "PYTHONPATH": str(site)} + env.pop("PAGEINDEX_API_KEY", None) + out = subprocess.run([sys.executable, "app.py"], cwd=cwd, env=env, + capture_output=True, text=True) + assert out.returncode == 0, out.stderr + assert out.stdout.strip() != "pi-leaked", out.stdout + + def test_empty_values_refused_never_silent(): """An empty value configures nothing — pre-fix, an empty chat-side value on a cloud client silently selected own-model chat on the @@ -1916,6 +1941,16 @@ def test_blank_chat_model_assignment_stays_managed(): assert not client._local_chat +def test_local_client_blank_chat_model_refuses_at_chat_door(local_client): + """A local client has no managed chat to fall back to: with chat_model + blanked, chat_completions() must refuse as a PageIndexAPIError, not + surface LocalAPI's missing chat_completions as an AttributeError.""" + for blank in ("", " ", None): + local_client.chat_model = blank + with pytest.raises(PageIndexAPIError, match="chat_model is empty"): + local_client.chat_completions("hi") + + def test_blank_chat_model_carries_no_model_into_agent_config(): """Same rule at the config door: a blank chat_model must not become a model literally named " " in the returned config.""" From 4e9c56c6c83314bdec8828010c4712e690a87e2b Mon Sep 17 00:00:00 2001 From: Ray Date: Wed, 26 Aug 2026 14:36:25 +0800 Subject: [PATCH 2/3] fix: the exported config shapes pass into index=/chat=; a comment and two docstrings stop overclaiming MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The slots were annotated dict[str, Any]. A TypedDict is consistent with Mapping[str, object], never with dict (PEP 589: a dict-typed receiver could write arbitrary keys through it), so the four shapes types.py exports — and py.typed advertises to installed callers' checkers — could not be passed to the one place they describe. pyright on a probe that does exactly that: 9 errors before, 0 after. The constructor only reads the slot (items(), then a fresh conf dict), so Mapping is the honest bound; a plain dict is a Mapping, and TypedDict instances are plain dicts at runtime, so nothing moves at runtime. The _ARG_TYPES comment said "every value" is shape-checked; api_key is not in the table (its empty check is separate, its type check stays unchecked by ruling), so the comment now speaks for the table only. _local_doc_scope and _require_local_scope still explained the cloud drop as "scoping is server-side" — true of the managed chat, which never reaches either function. What reaches them on a cloud client is own-model chat and the config helpers, whose cloud tools take no allowlist: targeting there is prompt-level only, as the error message between them already said. 434 passed; pyright on pageindex/ unchanged at 235 (0 in the touched files, before and after). Claude-Session: https://claude.ai/code/session_01TxG8u8x29XRnK4yscZVCch --- pageindex/agent_tools.py | 4 ++-- pageindex/client.py | 21 +++++++++++---------- 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/pageindex/agent_tools.py b/pageindex/agent_tools.py index 412b8bb8d..1c419bff9 100644 --- a/pageindex/agent_tools.py +++ b/pageindex/agent_tools.py @@ -1486,8 +1486,8 @@ def _require_doc_selection(doc_ids) -> None: def _require_local_scope(client, doc_ids) -> None: - """The allowlist is enforced in-process; cloud lookups run server-side, - so accepting doc_ids there would be advisory-only — refuse loudly.""" + """The allowlist is enforced in-process; cloud tools take none, so + accepting doc_ids there would be advisory-only — refuse loudly.""" _require_doc_selection(doc_ids) if doc_ids is not None and getattr(client, "api_key", None): raise PageIndexAPIError( diff --git a/pageindex/client.py b/pageindex/client.py index 1a291984e..f6b9c77f9 100644 --- a/pageindex/client.py +++ b/pageindex/client.py @@ -6,7 +6,7 @@ import threading import time import warnings -from typing import Any, Callable, Iterator, Optional, Union, cast +from typing import Any, Callable, Iterator, Mapping, Optional, Union, cast from .errors import PageIndexAPIError @@ -78,7 +78,7 @@ def _env_cloud_key(spelling: str, inline: str = "api_key=...") -> str: return key -# One argument vocabulary regardless of spelling: every value is shape- +# One argument vocabulary regardless of spelling: these values are shape- # checked in the constructor, so a wrong type or an empty value refuses # there as a PageIndexAPIError — never later, never silently. _ARG_TYPES: "dict[str, tuple[type, ...]]" = { @@ -338,8 +338,8 @@ def __init__( self, api_key: Optional[str] = None, *, - index: Optional[Union[dict[str, Any], str]] = None, - chat: Optional[Union[dict[str, Any], str]] = None, + index: Optional[Union[Mapping[str, Any], str]] = None, + chat: Optional[Union[Mapping[str, Any], str]] = None, mode: Optional[str] = None, index_model: Optional[str] = None, chat_model: Optional[str] = None, @@ -1254,8 +1254,9 @@ def as_openai_tools(self, include_management: bool = False, def _local_doc_scope(self, doc_id): """doc_id for the tool layer: passed through locally (structural - allowlist), dropped on cloud where scoping is server-side and the - config helpers keep prompt-level targeting.""" + allowlist), dropped on cloud — its tools take no allowlist, so + own-model chat and the config helpers target at the prompt level + only.""" from .agent_tools import _require_doc_selection _require_doc_selection(doc_id) if not getattr(self, "api_key", None): @@ -1617,8 +1618,8 @@ def __init__( self, api_key: Optional[str] = None, *, - index: Optional[Union[dict[str, Any], str]] = None, - chat: Optional[Union[dict[str, Any], str]] = None, + index: Optional[Union[Mapping[str, Any], str]] = None, + chat: Optional[Union[Mapping[str, Any], str]] = None, chat_model: Optional[str] = None, retrieve_model: Optional[str] = None, chat_backend: Optional[dict[str, Any]] = None, @@ -1647,8 +1648,8 @@ class PageIndexLocalClient(PageIndexClient): def __init__( self, *, - index: Optional[Union[dict[str, Any], str]] = None, - chat: Optional[Union[dict[str, Any], str]] = None, + index: Optional[Union[Mapping[str, Any], str]] = None, + chat: Optional[Union[Mapping[str, Any], str]] = None, index_model: Optional[str] = None, chat_model: Optional[str] = None, model: Optional[str] = None, From 90d6289bd918f1dea55cbe1adca5f8bdfb284f6d Mon Sep 17 00:00:00 2001 From: Ray Date: Wed, 26 Aug 2026 14:42:41 +0800 Subject: [PATCH 3/3] test: the install-dir .env test is named for what it asserts Claude-Session: https://claude.ai/code/session_017Fd7jVm366S2Xamzhxv6yb --- tests/test_client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_client.py b/tests/test_client.py index 640faabff..06860267c 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -396,7 +396,7 @@ def test_env_key_found_from_cwd(tmp_path): assert out.stdout.strip() == "ok" -def test_env_not_found_from_cwd_stays_unset(tmp_path, tmp_path_factory): +def test_env_not_loaded_from_install_dir(tmp_path, tmp_path_factory): """The cwd search finding nothing must end the search: find_dotenv returns '' then, and `or None` handed load_dotenv its own upward walk from utils.py — the install-dir leak the cwd search replaced. A