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
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
repos:
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.4.0
hooks:
- id: ruff
args: [--fix]
- id: ruff-format
10 changes: 7 additions & 3 deletions pretty_please/stats.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,13 @@ def record(tone: str) -> None:
if byte is None:
return
path = _log_path()
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("ab") as f:
f.write(bytes([byte]))
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("ab") as f:
f.write(bytes([byte]))
except OSError:
# Hooks should never fail just because telemetry can't be recorded.
return


def get_stats() -> dict:
Expand Down
3 changes: 3 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,9 @@ Issues = "https://github.com/msrdic/pretty-please/issues"

[tool.pytest.ini_options]
testpaths = ["tests"]
markers = [
"e2e: end-to-end tests that call real APIs (require API keys)",
]

[tool.hatch.build.targets.wheel]
packages = ["pretty_please"]
109 changes: 108 additions & 1 deletion tests/test_adapters.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"""Tests for pretty_please adapters (no real API calls)."""

import importlib
from unittest.mock import MagicMock, patch


Expand Down Expand Up @@ -86,7 +87,6 @@ def test_completion_passes_polite_messages(self):
mock_litellm = MagicMock()
with patch.dict("sys.modules", {"litellm": mock_litellm}):
from pretty_please.adapters import litellm as adapter
import importlib

importlib.reload(adapter)

Expand All @@ -96,6 +96,42 @@ def test_completion_passes_polite_messages(self):
call_messages = mock_litellm.completion.call_args[1]["messages"]
assert call_messages[0]["content"].startswith("Please, ")

def test_completion_raises_when_litellm_missing(self):
with patch.dict("sys.modules", {"litellm": None}):
from pretty_please.adapters import litellm as adapter

importlib.reload(adapter)
try:
import pytest

with pytest.raises(ImportError, match="litellm package is required"):
adapter.completion(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello."}],
)
finally:
importlib.reload(adapter)

def test_acompletion_raises_when_litellm_missing(self):
import asyncio

with patch.dict("sys.modules", {"litellm": None}):
from pretty_please.adapters import litellm as adapter

importlib.reload(adapter)
try:
import pytest

with pytest.raises(ImportError, match="litellm package is required"):
asyncio.run(
adapter.acompletion(
model="gpt-4o",
messages=[{"role": "user", "content": "Hello."}],
)
)
finally:
importlib.reload(adapter)


class TestClaudeCodeHook:
def test_hook_transforms_prompt(self):
Expand Down Expand Up @@ -150,3 +186,74 @@ def test_hook_event_name_present(self):
event = {"hook_event_name": "UserPromptSubmit", "prompt": "Explain gravity."}
result = process(event)
assert result["hookSpecificOutput"]["hookEventName"] == "UserPromptSubmit"


class TestHookStatsIntegration:
"""Verify that hook invocations actually record stats."""

def test_claude_code_hook_records_curt_stat(self, tmp_path, monkeypatch):
monkeypatch.setenv("PRETTY_PLEASE_STATS_DIR", str(tmp_path))
from pretty_please.adapters.claude_code.hook import process
from pretty_please.stats import get_stats

process({"hook_event_name": "UserPromptSubmit", "prompt": "List the planets."})
data = get_stats()
assert data["total"] == 1
assert data["by_tone"]["curt"] == 1

def test_claude_code_hook_records_polite_stat(self, tmp_path, monkeypatch):
monkeypatch.setenv("PRETTY_PLEASE_STATS_DIR", str(tmp_path))
from pretty_please.adapters.claude_code.hook import process
from pretty_please.stats import get_stats

process(
{
"hook_event_name": "UserPromptSubmit",
"prompt": "Please list the planets.",
}
)
data = get_stats()
assert data["total"] == 1
assert data["passed_through"] == 1

def test_codex_hook_records_curt_stat(self, tmp_path, monkeypatch):
monkeypatch.setenv("PRETTY_PLEASE_STATS_DIR", str(tmp_path))
from pretty_please.adapters.codex.hook import process
from pretty_please.stats import get_stats

process({"hook_event_name": "UserPromptSubmit", "prompt": "List the planets."})
data = get_stats()
assert data["total"] == 1
assert data["by_tone"]["curt"] == 1

def test_codex_hook_records_polite_stat(self, tmp_path, monkeypatch):
monkeypatch.setenv("PRETTY_PLEASE_STATS_DIR", str(tmp_path))
from pretty_please.adapters.codex.hook import process
from pretty_please.stats import get_stats

process(
{
"hook_event_name": "UserPromptSubmit",
"prompt": "Please list the planets.",
}
)
data = get_stats()
assert data["total"] == 1
assert data["passed_through"] == 1

def test_multiple_hook_calls_accumulate_stats(self, tmp_path, monkeypatch):
monkeypatch.setenv("PRETTY_PLEASE_STATS_DIR", str(tmp_path))
from pretty_please.adapters.claude_code.hook import process
from pretty_please.stats import get_stats

process({"hook_event_name": "UserPromptSubmit", "prompt": "List the planets."})
process(
{"hook_event_name": "UserPromptSubmit", "prompt": "Please explain this."}
)
process(
{"hook_event_name": "UserPromptSubmit", "prompt": "I need help with this."}
)
data = get_stats()
assert data["total"] == 3
assert data["transformed"] == 2
assert data["passed_through"] == 1
212 changes: 212 additions & 0 deletions tests/test_e2e_sdk.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"""
E2E tests for SDK adapters — require real API keys.

Skipped automatically when the relevant env var is absent:
ANTHROPIC_API_KEY → Anthropic tests
OPENAI_API_KEY → OpenAI tests + LiteLLM tests (routed via OpenAI)

Run all e2e tests:
pytest -m e2e

Run only one provider:
pytest -m e2e -k anthropic
pytest -m e2e -k openai
pytest -m e2e -k litellm
"""

import os

import pytest

pytestmark = pytest.mark.e2e


# ---------------------------------------------------------------------------
# Anthropic
# ---------------------------------------------------------------------------

anthropic_key = pytest.mark.skipif(
not os.environ.get("ANTHROPIC_API_KEY"),
reason="ANTHROPIC_API_KEY not set",
)


@anthropic_key
def test_anthropic_transforms_and_gets_response():
from pretty_please.adapters.anthropic import PrettyAnthropicClient

client = PrettyAnthropicClient()
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=64,
messages=[{"role": "user", "content": "Say the word OK and nothing else."}],
)
assert response.content[0].text.strip()


@anthropic_key
def test_anthropic_polite_prompt_not_double_transformed():
from pretty_please.adapters.anthropic import PrettyAnthropicClient, _polite_messages

messages = [{"role": "user", "content": "Please say OK and nothing else."}]
transformed = _polite_messages(messages)
# Already polite — should not gain a second "Please,"
content = transformed[0]["content"]
assert content.lower().count("please") == 1

client = PrettyAnthropicClient()
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=64,
messages=messages,
)
assert response.content[0].text.strip()


@anthropic_key
def test_anthropic_content_block_messages():
from pretty_please.adapters.anthropic import PrettyAnthropicClient

client = PrettyAnthropicClient()
response = client.messages.create(
model="claude-haiku-4-5-20251001",
max_tokens=64,
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "Say the word OK and nothing else."}
],
}
],
)
assert response.content[0].text.strip()


# ---------------------------------------------------------------------------
# OpenAI
# ---------------------------------------------------------------------------

openai_key = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set",
)


@openai_key
def test_openai_transforms_and_gets_response():
from pretty_please.adapters.openai import PrettyOpenAIClient

client = PrettyOpenAIClient()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Say the word OK and nothing else."}],
max_tokens=16,
)
assert response.choices[0].message.content.strip()


@openai_key
def test_openai_polite_prompt_not_double_transformed():
from pretty_please.adapters.openai import _polite_messages

messages = [{"role": "user", "content": "Please say OK and nothing else."}]
transformed = _polite_messages(messages)
content = transformed[0]["content"]
assert content.lower().count("please") == 1

from pretty_please.adapters.openai import PrettyOpenAIClient

client = PrettyOpenAIClient()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=messages,
max_tokens=16,
)
assert response.choices[0].message.content.strip()


@openai_key
def test_openai_system_message_untouched():
from pretty_please.adapters.openai import PrettyOpenAIClient

client = PrettyOpenAIClient()
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Say the word OK and nothing else."},
],
max_tokens=16,
)
assert response.choices[0].message.content.strip()


# ---------------------------------------------------------------------------
# LiteLLM (routed via OpenAI)
# ---------------------------------------------------------------------------

litellm_key = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set (required for LiteLLM e2e tests)",
)


@litellm_key
def test_litellm_completion_transforms_and_gets_response():
from pretty_please.adapters.litellm import completion

response = completion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Say the word OK and nothing else."}],
max_tokens=16,
)
assert response.choices[0].message.content.strip()


@litellm_key
def test_litellm_completion_polite_prompt_not_double_transformed():
from pretty_please.adapters.litellm import _polite_messages, completion

messages = [{"role": "user", "content": "Please say OK and nothing else."}]
transformed = _polite_messages(messages)
assert transformed[0]["content"].lower().count("please") == 1

response = completion(
model="gpt-4o-mini",
messages=messages,
max_tokens=16,
)
assert response.choices[0].message.content.strip()


@litellm_key
def test_litellm_completion_system_message_untouched():
from pretty_please.adapters.litellm import completion

response = completion(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": "You are a helpful assistant."},
{"role": "user", "content": "Say the word OK and nothing else."},
],
max_tokens=16,
)
assert response.choices[0].message.content.strip()


@litellm_key
def test_litellm_acompletion_transforms_and_gets_response():
import asyncio

from pretty_please.adapters.litellm import acompletion

async def _run():
return await acompletion(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Say the word OK and nothing else."}],
max_tokens=16,
)

response = asyncio.run(_run())
assert response.choices[0].message.content.strip()
Loading
Loading