From fb723623d94c31f765c9e3680161b19ac0b4beae Mon Sep 17 00:00:00 2001 From: Mladen S Date: Thu, 16 Apr 2026 08:20:49 +0200 Subject: [PATCH 1/3] make stats writes best-effort --- pretty_please/stats.py | 10 +++++++--- tests/test_stats.py | 13 +++++++++++++ 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/pretty_please/stats.py b/pretty_please/stats.py index e1bcb44..7837be9 100644 --- a/pretty_please/stats.py +++ b/pretty_please/stats.py @@ -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: diff --git a/tests/test_stats.py b/tests/test_stats.py index fb95b1a..d785680 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -41,6 +41,19 @@ def test_unknown_tone_ignored(self): record("unknown") assert get_stats()["total"] == 0 + def test_write_failure_is_ignored(self, monkeypatch, tmp_path): + from pretty_please import stats + + monkeypatch.setenv("PRETTY_PLEASE_STATS_DIR", str(tmp_path)) + path = tmp_path / "stats.log" + + def fail_open(*args, **kwargs): + raise OSError() + + monkeypatch.setattr(type(path), "open", fail_open) + record("curt") + assert get_stats()["total"] == 0 + def test_log_file_is_binary(self, tmp_path, monkeypatch): monkeypatch.setenv("PRETTY_PLEASE_STATS_DIR", str(tmp_path)) record("curt") From 377b264fd33e79167e49b5f4afc2128df117fe62 Mon Sep 17 00:00:00 2001 From: Mladen S Date: Thu, 16 Apr 2026 08:29:07 +0200 Subject: [PATCH 2/3] Fix ruff lint error and add pre-commit config Remove unused import in test_write_failure_is_ignored; add .pre-commit-config.yaml with ruff check+format hooks to catch this class of issue locally before CI. --- .pre-commit-config.yaml | 7 +++++++ tests/test_stats.py | 2 -- 2 files changed, 7 insertions(+), 2 deletions(-) create mode 100644 .pre-commit-config.yaml diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..855f194 --- /dev/null +++ b/.pre-commit-config.yaml @@ -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 diff --git a/tests/test_stats.py b/tests/test_stats.py index d785680..bb6aec1 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -42,8 +42,6 @@ def test_unknown_tone_ignored(self): assert get_stats()["total"] == 0 def test_write_failure_is_ignored(self, monkeypatch, tmp_path): - from pretty_please import stats - monkeypatch.setenv("PRETTY_PLEASE_STATS_DIR", str(tmp_path)) path = tmp_path / "stats.log" From 65e19c937f228aee8e3cf8db22f689e7034f13d9 Mon Sep 17 00:00:00 2001 From: Mladen S Date: Thu, 16 Apr 2026 14:05:06 +0200 Subject: [PATCH 3/3] Add e2e SDK tests and fill unit test gaps Co-Authored-By: Claude Sonnet 4.6 --- pyproject.toml | 3 + tests/test_adapters.py | 109 ++++++++++++++++++++- tests/test_e2e_sdk.py | 212 +++++++++++++++++++++++++++++++++++++++++ tests/test_stats.py | 15 +++ 4 files changed, 338 insertions(+), 1 deletion(-) create mode 100644 tests/test_e2e_sdk.py diff --git a/pyproject.toml b/pyproject.toml index f6d68b6..34d1e8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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"] diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 431193c..0cf9723 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1,5 +1,6 @@ """Tests for pretty_please adapters (no real API calls).""" +import importlib from unittest.mock import MagicMock, patch @@ -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) @@ -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): @@ -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 diff --git a/tests/test_e2e_sdk.py b/tests/test_e2e_sdk.py new file mode 100644 index 0000000..2b847d5 --- /dev/null +++ b/tests/test_e2e_sdk.py @@ -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() diff --git a/tests/test_stats.py b/tests/test_stats.py index bb6aec1..c8a55ff 100644 --- a/tests/test_stats.py +++ b/tests/test_stats.py @@ -60,6 +60,21 @@ def test_log_file_is_binary(self, tmp_path, monkeypatch): data = (tmp_path / "stats.log").read_bytes() assert data == bytes([0x00, 0x01, 0x02]) + def test_concurrent_writes_all_land(self, tmp_path, monkeypatch): + import threading + + monkeypatch.setenv("PRETTY_PLEASE_STATS_DIR", str(tmp_path)) + + n = 100 + threads = [threading.Thread(target=record, args=("curt",)) for _ in range(n)] + for t in threads: + t.start() + for t in threads: + t.join() + + data = get_stats() + assert data["by_tone"]["curt"] == n + class TestTrackedTransform: def test_returns_transformed_prompt(self):