Skip to content

Commit b9a9a3b

Browse files
authored
fix: the .env search ends at the cwd tree; a local client with a blank chat_model refuses at the chat door; storage_path is typed PathLike (#428)
* 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 * fix: the exported config shapes pass into index=/chat=; a comment and two docstrings stop overclaiming 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 * test: the install-dir .env test is named for what it asserts Claude-Session: https://claude.ai/code/session_017Fd7jVm366S2Xamzhxv6yb
1 parent 920db2b commit b9a9a3b

5 files changed

Lines changed: 58 additions & 16 deletions

File tree

pageindex/agent_tools.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,8 +1486,8 @@ def _require_doc_selection(doc_ids) -> None:
14861486

14871487

14881488
def _require_local_scope(client, doc_ids) -> None:
1489-
"""The allowlist is enforced in-process; cloud lookups run server-side,
1490-
so accepting doc_ids there would be advisory-only — refuse loudly."""
1489+
"""The allowlist is enforced in-process; cloud tools take none, so
1490+
accepting doc_ids there would be advisory-only — refuse loudly."""
14911491
_require_doc_selection(doc_ids)
14921492
if doc_ids is not None and getattr(client, "api_key", None):
14931493
raise PageIndexAPIError(

pageindex/client.py

Lines changed: 18 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
import threading
77
import time
88
import warnings
9-
from typing import Any, Callable, Iterator, Optional, Union, cast
9+
from typing import Any, Callable, Iterator, Mapping, Optional, Union, cast
1010

1111
from .errors import PageIndexAPIError
1212

@@ -78,7 +78,7 @@ def _env_cloud_key(spelling: str, inline: str = "api_key=...") -> str:
7878
return key
7979

8080

81-
# One argument vocabulary regardless of spelling: every value is shape-
81+
# One argument vocabulary regardless of spelling: these values are shape-
8282
# checked in the constructor, so a wrong type or an empty value refuses
8383
# there as a PageIndexAPIError — never later, never silently.
8484
_ARG_TYPES: "dict[str, tuple[type, ...]]" = {
@@ -338,15 +338,15 @@ def __init__(
338338
self,
339339
api_key: Optional[str] = None,
340340
*,
341-
index: Optional[Union[dict[str, Any], str]] = None,
342-
chat: Optional[Union[dict[str, Any], str]] = None,
341+
index: Optional[Union[Mapping[str, Any], str]] = None,
342+
chat: Optional[Union[Mapping[str, Any], str]] = None,
343343
mode: Optional[str] = None,
344344
index_model: Optional[str] = None,
345345
chat_model: Optional[str] = None,
346346
model: Optional[str] = None,
347347
summary_model: Optional[str] = None,
348348
retrieve_model: Optional[str] = None,
349-
storage_path: Optional[str] = None,
349+
storage_path: Optional[Union[str, os.PathLike[str]]] = None,
350350
index_backend: Optional[dict[str, Any]] = None,
351351
chat_backend: Optional[dict[str, Any]] = None,
352352
):
@@ -915,6 +915,11 @@ def chat_completions(
915915
reasoning_effort=reasoning_effort, extra_body=extra_body,
916916
extra_headers=extra_headers, backend=backend,
917917
)
918+
if not getattr(self, "api_key", None):
919+
raise PageIndexAPIError(
920+
"chat_model is empty — it configures nothing, and a local "
921+
"client has no managed chat to fall back to. Set "
922+
"chat_model=... to run the agent with your own model.")
918923
if (model is not None or max_turns is not None or top_p is not None
919924
or max_tokens is not None or reasoning_effort is not None
920925
or extra_body is not None or extra_headers is not None
@@ -1249,8 +1254,9 @@ def as_openai_tools(self, include_management: bool = False,
12491254

12501255
def _local_doc_scope(self, doc_id):
12511256
"""doc_id for the tool layer: passed through locally (structural
1252-
allowlist), dropped on cloud where scoping is server-side and the
1253-
config helpers keep prompt-level targeting."""
1257+
allowlist), dropped on cloud — its tools take no allowlist, so
1258+
own-model chat and the config helpers target at the prompt level
1259+
only."""
12541260
from .agent_tools import _require_doc_selection
12551261
_require_doc_selection(doc_id)
12561262
if not getattr(self, "api_key", None):
@@ -1612,8 +1618,8 @@ def __init__(
16121618
self,
16131619
api_key: Optional[str] = None,
16141620
*,
1615-
index: Optional[Union[dict[str, Any], str]] = None,
1616-
chat: Optional[Union[dict[str, Any], str]] = None,
1621+
index: Optional[Union[Mapping[str, Any], str]] = None,
1622+
chat: Optional[Union[Mapping[str, Any], str]] = None,
16171623
chat_model: Optional[str] = None,
16181624
retrieve_model: Optional[str] = None,
16191625
chat_backend: Optional[dict[str, Any]] = None,
@@ -1642,14 +1648,14 @@ class PageIndexLocalClient(PageIndexClient):
16421648
def __init__(
16431649
self,
16441650
*,
1645-
index: Optional[Union[dict[str, Any], str]] = None,
1646-
chat: Optional[Union[dict[str, Any], str]] = None,
1651+
index: Optional[Union[Mapping[str, Any], str]] = None,
1652+
chat: Optional[Union[Mapping[str, Any], str]] = None,
16471653
index_model: Optional[str] = None,
16481654
chat_model: Optional[str] = None,
16491655
model: Optional[str] = None,
16501656
summary_model: Optional[str] = None,
16511657
retrieve_model: Optional[str] = None,
1652-
storage_path: Optional[str] = None,
1658+
storage_path: Optional[Union[str, os.PathLike[str]]] = None,
16531659
index_backend: Optional[dict[str, Any]] = None,
16541660
chat_backend: Optional[dict[str, Any]] = None,
16551661
):

pageindex/types.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"""
1212
from __future__ import annotations
1313

14+
import os
1415
from typing import Literal, TypedDict, Union
1516

1617
PAGEINDEX_CLOUD = "pageindex-cloud"
@@ -32,7 +33,7 @@ class LocalIndexConfig(TypedDict, total=False):
3233
model: str
3334
summary_model: str
3435
backend: dict
35-
storage_path: str
36+
storage_path: Union[str, os.PathLike[str]]
3637

3738

3839
class ChatConfig(TypedDict, total=False):

pageindex/utils.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
import asyncio
1212
from io import BytesIO
1313
from dotenv import find_dotenv, load_dotenv
14-
load_dotenv(find_dotenv(usecwd=True) or None)
14+
load_dotenv(find_dotenv(usecwd=True))
1515
import logging
1616
import yaml
1717
from pathlib import Path

tests/test_client.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -396,6 +396,31 @@ def test_env_key_found_from_cwd(tmp_path):
396396
assert out.stdout.strip() == "ok"
397397

398398

399+
def test_env_not_loaded_from_install_dir(tmp_path, tmp_path_factory):
400+
"""The cwd search finding nothing must end the search: find_dotenv
401+
returns '' then, and `or None` handed load_dotenv its own upward walk
402+
from utils.py — the install-dir leak the cwd search replaced. A
403+
symlinked package puts utils.py under a tree whose root holds a .env;
404+
the cwd tree holds none."""
405+
site = tmp_path / "site"
406+
site.mkdir()
407+
(site / "pageindex").symlink_to(Path(__file__).parent.parent / "pageindex")
408+
(tmp_path / ".env").write_text("PAGEINDEX_API_KEY=pi-leaked\n")
409+
cwd = tmp_path_factory.mktemp("elsewhere")
410+
(cwd / "app.py").write_text(
411+
"from pageindex import PageIndexCloudClient, PageIndexAPIError\n"
412+
"try:\n"
413+
" print(PageIndexCloudClient().api_key)\n"
414+
"except PageIndexAPIError:\n"
415+
" print('unset')\n")
416+
env = {**os.environ, "PYTHONPATH": str(site)}
417+
env.pop("PAGEINDEX_API_KEY", None)
418+
out = subprocess.run([sys.executable, "app.py"], cwd=cwd, env=env,
419+
capture_output=True, text=True)
420+
assert out.returncode == 0, out.stderr
421+
assert out.stdout.strip() != "pi-leaked", out.stdout
422+
423+
399424
def test_empty_values_refused_never_silent():
400425
"""An empty value configures nothing — pre-fix, an empty chat-side
401426
value on a cloud client silently selected own-model chat on the
@@ -1916,6 +1941,16 @@ def test_blank_chat_model_assignment_stays_managed():
19161941
assert not client._local_chat
19171942

19181943

1944+
def test_local_client_blank_chat_model_refuses_at_chat_door(local_client):
1945+
"""A local client has no managed chat to fall back to: with chat_model
1946+
blanked, chat_completions() must refuse as a PageIndexAPIError, not
1947+
surface LocalAPI's missing chat_completions as an AttributeError."""
1948+
for blank in ("", " ", None):
1949+
local_client.chat_model = blank
1950+
with pytest.raises(PageIndexAPIError, match="chat_model is empty"):
1951+
local_client.chat_completions("hi")
1952+
1953+
19191954
def test_blank_chat_model_carries_no_model_into_agent_config():
19201955
"""Same rule at the config door: a blank chat_model must not become
19211956
a model literally named " " in the returned config."""

0 commit comments

Comments
 (0)