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: 3 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,8 @@ make test_nondeterministic # Run nondeterministic tests only
make fmt # Run ruff formatter + JSON formatting
make ruff # Run ruff linter
make vulture # Find dead code
make ty # Run typer type checker
make ci # Run all CI checks (ruff, vulture, ty)
make ty # Run type checker
make ci # Run all CI checks (ruff, vulture, ty, import_lint, docs_lint, check_deps)

# Dependencies
uv sync # Install dependencies (not pip install)
Expand All @@ -46,11 +46,10 @@ uv run pytest path/to/test.py # Run specific test
- `<name>.yaml` - Optional split configs (loaded as root key `<name>`)
- `global_config.py` - Config class (access via `from common import global_config`)
- `.env` - Secrets/API keys (git-ignored)
- **src/** - Source code (api/, db/, utils/, stripe/)
- **src/** - Source code (utils/)
- **utils/llm/** - LLM inference with DSPY (`dspy_inference.py`) and LangFuse observability
- **tests/** - pytest tests inheriting from `TestTemplate` in `test_template.py`
- **init/** - Initialization scripts (banner generation)
- **alembic/** - Database migrations

## Code Style

Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ Opinionated Python stack for fast development. The `saas` branch extends `main`
- `make all` - runs `main.py`
- `make fmt` - runs `ruff format` + JSON formatting
- `make banner` - create a new banner that makes the README nice 😊
- `make test` - runs all tests defined by `TEST_TARGETS = tests/folder1 tests/folder2`
- `make test` - runs all tests in `tests/`
- `make ci` - runs all CI checks (ruff, vulture, ty, etc.)



Expand Down
42 changes: 21 additions & 21 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -6,32 +6,32 @@ authors = [
{ name = "Miyamura80", email = "eitomiyamura@gmail.com" }
]
dependencies = [
"pyyaml>=6.0.2",
"python-dotenv>=1.0.1",
"pyyaml>=6.0.3",
"python-dotenv>=1.2.1",
"human-id>=0.2.0",
"import-linter>=2.0.0",
"pytest>=8.3.3",
"import-linter>=2.1.1",
"pytest>=8.3.4",
"pytest-xdist>=3.6.1",
"termcolor>=2.4.0",
"termcolor>=2.5.0",
"loguru>=0.7.3",
"vulture>=2.14",
"dspy>=2.6.24",
"langfuse>=2.60.5",
"litellm>=1.70.0",
"tenacity>=9.1.2",
"pillow>=11.2.1",
"google-genai>=1.15.0",
"ty>=0.0.1a9",
"pytest-env>=1.1.5",
"pydantic-settings>=2.12.0",
"dspy>=3.1.2",
"langfuse>=3.12.1",
"litellm>=1.59.8",
"tenacity>=9.0.0",
"pillow>=12.1.0",
"google-genai>=1.60.0",
"ty>=0.0.14",
"pytest-env>=1.2.0",
"pydantic-settings>=2.7.1",
"pytest-cov>=7.0.0",
"pytest-repeat>=0.9.4",
"pylint>=3.3.0",
"deptry>=0.24.0",
"openfeature-sdk>=0.8.4",
"pytest-repeat>=0.9.3",
"pylint>=3.3.3",
"deptry>=0.23.0",
"openfeature-sdk>=0.7.1",
"scrubadub>=2.0.1",
"pydantic>=2.0.0",
"numpy>=1.26.0",
"pydantic>=2.10.6",
"numpy>=2.2.2",
]
readme = "README.md"
requires-python = ">= 3.12"
Expand All @@ -44,7 +44,7 @@ build-backend = "hatchling.build"
allow-direct-references = true

[tool.hatch.build.targets.wheel]
packages = ["src/python_template"]
packages = ["src", "common", "utils"]

[tool.ruff]
line-length = 88
Expand Down
2 changes: 1 addition & 1 deletion tests/healthcheck/test_env_var_loading.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ def test_env_var_loading_precedence(monkeypatch):

# 3. Reload the common module to pick up the new .env file
importlib.reload(common_module)
reloaded_config = common_module.global_config # type: ignore
reloaded_config = common_module.global_config

# 4. Assert that the variables are loaded with the correct precedence
assert reloaded_config.DEV_ENV == "dotenv", "Should load from .env first"
Expand Down
2 changes: 1 addition & 1 deletion tests/healthcheck/test_pydantic_type_coercion.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def test_pydantic_type_coercion(monkeypatch):

# Reload the config module to pick up the new environment variables
importlib.reload(common_module)
config = common_module.global_config # type: ignore[attr-defined]
config = common_module.global_config

# Verify integer coercion
assert isinstance(config.default_llm.default_max_tokens, int), (
Expand Down
52 changes: 34 additions & 18 deletions tests/test_logging_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,23 +14,29 @@ def test_email_redaction(self):
"""Test that email addresses are redacted from log messages."""
record = {"message": "User email is test@example.com", "exception": None}
scrub_sensitive_data(record)
assert "test@example.com" not in record["message"]
assert "{{EMAIL}}" in record["message"]
message = record["message"]
assert isinstance(message, str)
assert "test@example.com" not in message
assert "{{EMAIL}}" in message

def test_phone_redaction(self):
"""Test that phone numbers are redacted (new capability via scrubadub)."""
record = {"message": "Call me at 1-800-555-0199", "exception": None}
scrub_sensitive_data(record)
assert "1-800-555-0199" not in record["message"]
assert "{{PHONE}}" in record["message"]
message = record["message"]
assert isinstance(message, str)
assert "1-800-555-0199" not in message
assert "{{PHONE}}" in message

def test_api_key_redaction(self):
"""Test that OpenAI API keys are redacted from log messages."""
api_key = "sk-abc123def456ghi789jkl012mno345pqr678stu901"
record = {"message": f"Using key: {api_key}", "exception": None}
scrub_sensitive_data(record)
assert api_key not in record["message"]
assert "[REDACTED_API_KEY]" in record["message"]
message = record["message"]
assert isinstance(message, str)
assert api_key not in message
assert "[REDACTED_API_KEY]" in message

def test_multiple_redactions(self):
"""Test redacting multiple sensitive items in a single message."""
Expand All @@ -39,10 +45,12 @@ def test_multiple_redactions(self):
"exception": None,
}
scrub_sensitive_data(record)
assert "{{EMAIL}}" in record["message"]
assert "[REDACTED_API_KEY]" in record["message"]
assert "test@example.com" not in record["message"]
assert "sk-123456789012345678901234" not in record["message"]
message = record["message"]
assert isinstance(message, str)
assert "{{EMAIL}}" in message
assert "[REDACTED_API_KEY]" in message
assert "test@example.com" not in message
assert "sk-123456789012345678901234" not in message

def test_exception_message_redaction(self):
"""Test that PII is redacted from exception messages."""
Expand Down Expand Up @@ -87,8 +95,10 @@ def test_anthropic_api_key_redaction(self):
api_key = "sk-ant-api03-abc123def456ghi789jkl012mno345pqr678"
record = {"message": f"Using Anthropic key: {api_key}", "exception": None}
scrub_sensitive_data(record)
assert api_key not in record["message"]
assert "[REDACTED_API_KEY]" in record["message"]
message = record["message"]
assert isinstance(message, str)
assert api_key not in message
assert "[REDACTED_API_KEY]" in message

def test_stripe_api_key_redaction(self):
"""Test that Stripe API keys are redacted."""
Expand All @@ -100,16 +110,20 @@ def test_stripe_api_key_redaction(self):
key = prefix + suffix
record = {"message": f"Stripe key: {key}", "exception": None}
scrub_sensitive_data(record)
assert key not in record["message"]
assert "[REDACTED_API_KEY]" in record["message"]
message = record["message"]
assert isinstance(message, str)
assert key not in message
assert "[REDACTED_API_KEY]" in message

def test_bearer_token_redaction(self):
"""Test that Authorization Bearer tokens are redacted."""
token = "Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkw"
record = {"message": f"Authorization: {token}", "exception": None}
scrub_sensitive_data(record)
assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in record["message"]
assert "[REDACTED_BEARER_TOKEN]" in record["message"]
message = record["message"]
assert isinstance(message, str)
assert "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" not in message
assert "[REDACTED_BEARER_TOKEN]" in message

def test_generic_api_key_redaction(self):
"""Test that generic api_key patterns are redacted."""
Expand All @@ -123,5 +137,7 @@ def test_generic_api_key_redaction(self):
for pattern in patterns:
record = {"message": f"Config: {pattern}", "exception": None}
scrub_sensitive_data(record)
assert "abc123def456ghi789jkl012" not in record["message"]
assert "[REDACTED_KEY]" in record["message"]
message = record["message"]
assert isinstance(message, str)
assert "abc123def456ghi789jkl012" not in message
assert "[REDACTED_KEY]" in message
8 changes: 4 additions & 4 deletions utils/llm/dspy_langfuse.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
from dspy.utils.callback import BaseCallback
from langfuse.client import Langfuse, StatefulGenerationClient # type: ignore
from langfuse.decorators import langfuse_context # type: ignore
from litellm.cost_calculator import completion_cost # type: ignore
from litellm.cost_calculator import completion_cost
from loguru import logger as log
from pydantic import BaseModel, Field, ValidationError

Expand Down Expand Up @@ -138,7 +138,7 @@ def on_lm_start( # noqa
parent_observation_id = langfuse_context.get_current_observation_id()
span_obj: StatefulGenerationClient | None = None
if trace_id:
span_obj = self.langfuse.generation( # type: ignore (Langfuse fails the type check in this function, grr...)
span_obj = self.langfuse.generation(
input=user_input,
name=model_name,
trace_id=trace_id,
Expand Down Expand Up @@ -314,7 +314,7 @@ def on_lm_end( # noqa
"total": total_cost,
# "cache_read_input_tokens": 0.0, # Optional
}
span.update( # type: ignore[call-arg] # Langfuse typing for update can be tricky
span.update(
usage_details=usage_details_update,
cost_details=cost_details_update,
)
Expand Down Expand Up @@ -355,7 +355,7 @@ def on_lm_end( # noqa
"status_message": status_message,
}
# Langfuse client's `end` method handles None for these specific optional parameters.
span.end(**end_args) # type: ignore[call-arg] # Langfuse typing for end can be tricky
span.end(**end_args)
self.current_span.set(None)

if level == "DEFAULT" and completion_content is not None:
Expand Down
Loading
Loading