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

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
This commit is contained in:
Ray
2026-08-26 14:29:26 +08:00
parent 920db2b1b1
commit 5e2dc9bc56
4 changed files with 45 additions and 4 deletions
+7 -2
View File
@@ -346,7 +346,7 @@ class PageIndexClient:
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 @@ class PageIndexClient:
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 @@ class PageIndexLocalClient(PageIndexClient):
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,
):
+2 -1
View File
@@ -11,6 +11,7 @@ other keys. The ``"pageindex-cloud"`` string is the label spelling for
"""
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):
+1 -1
View File
@@ -11,7 +11,7 @@ import copy
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
+35
View File
@@ -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."""