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
8 changes: 4 additions & 4 deletions .github/workflows/python-app.yml
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,16 @@ jobs:
runs-on: ubuntu-latest # self-hosted
strategy:
matrix:
python-version: [3.12]
python-version: ["3.12", "3.13"]

steps:
- uses: actions/checkout@v4
- name: Install Poetry
run: pipx install poetry
- uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
- name: Install dependencies
run: |
python -m pip install --upgrade pip poetry pre-commit
cache: poetry
- name: Install project dependencies
run: |
poetry install
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -226,3 +226,6 @@ __marimo__/

# Streamlit
.streamlit/secrets.toml

# aislop guardrail config (read from disk regardless of git)
.aislop/
7 changes: 7 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,13 @@ repos:
hooks:
- id: ruff
args: [.]
- id: ruff-format

- repo: https://github.com/pre-commit/mirrors-mypy
rev: v1.11.2
hooks:
- id: mypy
additional_dependencies: [pydantic, pydantic-settings]

- repo: https://github.com/commitizen-tools/commitizen
rev: v4.4.1
Expand Down
19 changes: 14 additions & 5 deletions app/config.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
from pydantic_settings import BaseSettings
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict

_VALID_LOG_LEVELS = {"DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"}


class Settings(BaseSettings):
Expand All @@ -7,10 +10,16 @@ class Settings(BaseSettings):
app_name: str = "Python Template"
log_level: str = "INFO"

model_config: dict[str, str] = {
"env_file": ".env",
"env_file_encoding": "utf-8",
}
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")

@field_validator("log_level")
@classmethod
def _validate_log_level(cls, value: str) -> str:
normalized = value.upper()
if normalized not in _VALID_LOG_LEVELS:
valid = sorted(_VALID_LOG_LEVELS)
raise ValueError(f"Invalid log level {value!r}; expected one of {valid}")
return normalized


settings = Settings()
Expand Down
5 changes: 5 additions & 0 deletions app/logger.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ def configure_logger(settings: Settings) -> logging.Logger:
file_handler.setFormatter(formatter)
file_handler.setLevel(settings.log_level.upper())

console_handler = logging.StreamHandler()
console_handler.setFormatter(formatter)
console_handler.setLevel(settings.log_level.upper())

if not logger.handlers:
logger.addHandler(file_handler)
logger.addHandler(console_handler)

return logger
5 changes: 2 additions & 3 deletions main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
"""Application entry point for the Python project template."""

from app.config import Settings
from app.config import settings
from app.logger import configure_logger


Expand All @@ -9,10 +9,9 @@ def main() -> None:

Loads environment settings, configures logging, and emits startup log messages.
"""
settings: Settings = Settings()
logger = configure_logger(settings)
logger.info("Application started")
logger.debug("Current settings loaded: %s", settings.dict())
logger.debug("Current settings loaded: %s", settings.model_dump())
logger.info("Hello from the Python project template!")


Expand Down
382 changes: 361 additions & 21 deletions poetry.lock

Large diffs are not rendered by default.

17 changes: 15 additions & 2 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,9 @@ python-dotenv = "^1.0"

[tool.poetry.group.dev.dependencies]
pytest = "^7.4"
ruff = "^0.0"
pytest-cov = "^5.0"
ruff = "^0.15"
mypy = "^1.11"
pre-commit = "^3.4"
commitizen = "^4.4"

Expand All @@ -25,10 +27,21 @@ build-backend = "poetry.core.masonry.api"

[tool.ruff]
line-length = 88
exclude = ["logs", "tests/__pycache__", ".venv", "build", "dist"]

[tool.ruff.lint]
select = ["E", "F", "W", "C90", "B", "N"]
exclude = ["logs", "tests/__pycache__", ".venv", "build", "dist"]

[tool.mypy]
python_version = "3.12"
warn_unused_configs = true
warn_redundant_casts = true
warn_unused_ignores = true
disallow_untyped_defs = true
exclude = ["logs", "build", "dist", ".venv"]

[tool.pytest.ini_options]
addopts = "--cov=app --cov=main --cov-report=term-missing"

[tool.commitizen]
name = "cz_conventional_commits"
Expand Down
28 changes: 26 additions & 2 deletions tests/test_main.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
from pathlib import Path

import pytest

import main as main_module
from app.config import Settings
from app.logger import configure_logger


def test_settings_load_from_env(tmp_path: Path, monkeypatch) -> None:
def test_settings_load_from_env(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Verify settings load from a temporary .env file."""
env_file = tmp_path / ".env"
env_file.write_text("APP_NAME=TestApp\nLOG_LEVEL=DEBUG\n")
Expand All @@ -16,7 +21,9 @@ def test_settings_load_from_env(tmp_path: Path, monkeypatch) -> None:
assert settings.log_level == "DEBUG"


def test_logger_writes_to_log_file(tmp_path: Path, monkeypatch) -> None:
def test_logger_writes_to_log_file(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Verify logger writes entries into the logs directory."""
monkeypatch.chdir(tmp_path)
settings = Settings(app_name="TestApp", log_level="DEBUG")
Expand All @@ -28,3 +35,20 @@ def test_logger_writes_to_log_file(tmp_path: Path, monkeypatch) -> None:
assert log_file.exists()
content = log_file.read_text(encoding="utf-8")
assert "Unit test log entry" in content


def test_main_runs_without_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Verify the entry point runs end-to-end without raising."""
monkeypatch.chdir(tmp_path)

main_module.main()

assert (tmp_path / "logs" / "app.log").exists()


def test_invalid_log_level_rejected() -> None:
"""Verify an unknown log level is rejected at settings construction."""
with pytest.raises(ValueError):
Settings(log_level="NOPE")
Loading