Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,7 @@ Dependencies run strictly downward: `llm`/`netutil`/`sources` ← `smstext`/`wea

Underscore prefixes still mean "internal to Palmer", not "private to this module" — `smstext._sms_clean` is imported by six modules. Grep before renaming.

**Patching in tests follows the code, not the name.** `patch("agent.client")` stopped working when functions moved out; patch the module the function actually lives in (`patch("userprofile.client")`). A dead patch target does not fail loudly — it lets the test make real API calls. Watch the suite runtime: ~1,400 tests in ~6s, and a jump means something is hitting the network.
**Patching in tests follows the code, not the name.** `patch("agent.client")` stopped working when functions moved out; patch the module the function actually lives in (`patch("userprofile.client")`). A dead patch target used to fail silently by letting the test make a real API call; `tests/conftest.py` now refuses any socket connection and any unpatched Anthropic call, so most escapes fail by name instead — except through `netutil._http_get_json` (flights, hotels, serpapi, opening, traffic, tickers), which catches every exception by contract and returns `None`, so an escape there still reads as an empty result rather than a named failure. The suite is ~1,430 tests in ~5s, in 15 files grouped by the module under test, with shared fakes in `tests/helpers.py`.

### Scheduler cadence (main.py)
```
Expand Down
Empty file added tests/__init__.py
Empty file.
67 changes: 67 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Shared fixtures. Each one exists to delete a block that was copy-pasted
across the suite.

The env placeholders are set before any palmer module is imported: a few
modules read their key at import time (datafeeds raises KeyError without
TAVILY_API_KEY), and PALMER_NO_SCHEDULER keeps `from palmer import main` from
starting the job loop. Nothing in the suite needs a real value, and `.env` is
deliberately not loaded — real keys reaching a test is how the suite used to
go to the network by accident.
"""
import os
import socket

import pytest

for _k, _v in {
"ANTHROPIC_API_KEY": "test",
"TWILIO_ACCOUNT_SID": "ACtest",
"TWILIO_AUTH_TOKEN": "test",
"TWILIO_PHONE_NUMBER": "+15550000000",
"TAVILY_API_KEY": "test",
"TOMTOM_API_KEY": "test",
"SERP_API_KEY": "test",
"PALMER_NO_SCHEDULER": "1",
}.items():
os.environ.setdefault(_k, _v)

from palmer import db # after the env block, on purpose


@pytest.fixture(autouse=True)
def _no_network(monkeypatch):
"""Every test is offline. A fetch that escapes its patch used to succeed
silently on a machine with live keys and show up only as a slower suite;
now it fails, naming itself. Socket level, because fetch functions are
re-exported and patched under many module names."""
def _refuse(*_a, **_k):
raise RuntimeError("network call inside the test suite — patch the fetch")
monkeypatch.setattr(socket.socket, "connect", _refuse)
monkeypatch.setattr(socket, "create_connection", _refuse)
monkeypatch.setattr(socket, "getaddrinfo", _refuse)


@pytest.fixture(autouse=True)
def _no_llm(monkeypatch):
"""An Anthropic call nobody patched fails at once instead of reaching the
network. Found the day the socket guard landed: a dozen tests were
calling the real API with a placeholder key on every run and taking
1.4s each in the SDK's retry backoff, then swallowing the error. Class
level, so `patch.object(x.client.messages, "create")` in a test still
wins — an instance attribute shadows this."""
from palmer.llm import client
def _refuse(*_a, **_k):
raise RuntimeError("unpatched Anthropic call inside the test suite")
monkeypatch.setattr(type(client.messages), "create", _refuse)


@pytest.fixture
def fresh_db(tmp_path, monkeypatch):
"""An empty schema in a temp file, in place of the repo-root palmer.db."""
assert not db._DATABASE_URL, (
"DATABASE_URL is set — fresh_db only isolates the SQLite path, so "
"these tests would run against that real database. Unset it first."
)
monkeypatch.setattr(db, "_DB_PATH", tmp_path / "test.db")
db.init_db()
return db
64 changes: 64 additions & 0 deletions tests/helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
"""Fakes shared across test files. Import what you need:

from tests.helpers import llm_reply, Block, Resp, tool_by_name, drive_tool
"""
from contextlib import ExitStack
from unittest.mock import MagicMock, patch

from palmer.tools_def import TOOLS


def llm_reply(text: str) -> MagicMock:
"""A messages.create response whose one content block carries `text`."""
block = MagicMock()
block.text = text
resp = MagicMock()
resp.content = [block]
return resp


class Block:
"""A content block as the Anthropic SDK shapes it: attribute access only."""
def __init__(self, **kw):
self.__dict__.update(kw)


class Resp:
"""A messages.create response with real `content` and `stop_reason`."""
def __init__(self, content, stop_reason):
self.content, self.stop_reason = content, stop_reason


def tool_by_name(name: str) -> dict | None:
return next((t for t in TOOLS if t["name"] == name), None)


def drive_tool(tool_name, tool_input, *, message="hi", reply="done", profile=None,
history=(), patches=()):
"""Run agent.get_reply through exactly one tool call.

The model is faked to call `tool_name` with `tool_input`, then answer
`reply`. Returns (text, tool_result, mocks): the reply text, the tool_result
string the model was handed back, and the entered values of `patches` in
order — pass `patch.object(...)` objects and read the mocks back here."""
from palmer import agent
calls = []
responses = [
Resp([Block(type="tool_use", name=tool_name, id="t1", input=tool_input)], "tool_use"),
Resp([Block(type="text", text=reply)], "end_turn"),
]

def _create(**kw):
calls.append(kw)
return responses[len(calls) - 1]

with ExitStack() as stack:
stack.enter_context(patch.object(agent, "_build_system", return_value="sys"))
stack.enter_context(patch.object(agent, "get_history", return_value=list(history)))
stack.enter_context(patch.object(agent, "get_profile",
return_value=profile if profile is not None else {}))
mocks = [stack.enter_context(p) for p in patches]
stack.enter_context(patch.object(agent.client.messages, "create", side_effect=_create))
text, _gif = agent.get_reply("+1555", message)
result = calls[1]["messages"][-1]["content"][0]["content"]
return text, result, mocks
82 changes: 0 additions & 82 deletions tests/test_add_price_watch_tool.py

This file was deleted.

Loading
Loading