Merge branch 'feat/phase1-scaffold'

This commit is contained in:
2026-02-24 09:16:03 -06:00
26 changed files with 545 additions and 0 deletions

15
.env.example Normal file
View File

@@ -0,0 +1,15 @@
# SneakySwole Environment Configuration
# Copy this file to .env and fill in real values.
# Admin credentials (used to create admin user on first run)
ADMIN_USERNAME=admin
ADMIN_PASSWORD=changeme
# App settings
APP_ENV=development
APP_HOST=0.0.0.0
APP_PORT=8000
APP_LOG_LEVEL=info
# Database
DATABASE_URL=sqlite:///data/sneakyswole.db

23
Dockerfile Normal file
View File

@@ -0,0 +1,23 @@
# -- Build stage --
FROM python:3.12-slim AS base
# Prevent Python from writing .pyc files and enable unbuffered output
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
# Install dependencies first (layer caching)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
COPY app/ ./app/
COPY config/ ./config/
# Create data directory for SQLite
RUN mkdir -p /app/data
EXPOSE 8000
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

0
app/__init__.py Normal file
View File

53
app/config.py Normal file
View File

@@ -0,0 +1,53 @@
"""Application configuration loader.
Reads environment variables (with .env file support) and provides
a typed, validated Settings object used throughout the application.
"""
from functools import lru_cache
from pathlib import Path
from dotenv import load_dotenv
from pydantic_settings import BaseSettings
from pydantic_settings import SettingsConfigDict
# Load .env file from project root
_env_path = Path(__file__).resolve().parent.parent / ".env"
load_dotenv(_env_path)
class Settings(BaseSettings):
"""Typed application settings loaded from environment variables.
Attributes:
admin_username: Admin login username (from ADMIN_USERNAME env var).
admin_password: Admin login password (from ADMIN_PASSWORD env var).
app_env: Runtime environment — 'development' or 'production'.
app_host: Host address to bind the server to.
app_port: Port number for the server.
app_log_level: Minimum log level for structlog output.
database_url: SQLite connection string.
"""
admin_username: str
admin_password: str
app_env: str = "development"
app_host: str = "0.0.0.0"
app_port: int = 8000
app_log_level: str = "info"
database_url: str = "sqlite:///data/sneakyswole.db"
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
)
@lru_cache(maxsize=1)
def get_settings() -> Settings:
"""Return a cached Settings instance (singleton pattern).
Returns:
The application Settings object.
"""
return Settings()

48
app/logging_config.py Normal file
View File

@@ -0,0 +1,48 @@
"""Structured logging configuration using structlog.
Call setup_logging() once at application startup to configure
structlog with human-readable (dev) or JSON (prod) output.
"""
import logging
import sys
import structlog
def setup_logging(log_level: str = "info") -> None:
"""Configure structlog for the application.
In development, output is colorized and human-readable.
In production, output is JSON for structured log aggregation.
Args:
log_level: Minimum log level as a string (e.g., 'info', 'debug').
"""
numeric_level = getattr(logging, log_level.upper(), logging.INFO)
# Configure standard library logging (captured by structlog)
logging.basicConfig(
format="%(message)s",
stream=sys.stdout,
level=numeric_level,
)
structlog.configure(
processors=[
structlog.contextvars.merge_contextvars,
structlog.stdlib.filter_by_level,
structlog.stdlib.add_logger_name,
structlog.stdlib.add_log_level,
structlog.stdlib.PositionalArgumentsFormatter(),
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.StackInfoRenderer(),
structlog.processors.format_exc_info,
structlog.processors.UnicodeDecoder(),
structlog.dev.ConsoleRenderer(),
],
wrapper_class=structlog.stdlib.BoundLogger,
context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(),
cache_logger_on_first_use=True,
)

59
app/main.py Normal file
View File

@@ -0,0 +1,59 @@
"""FastAPI application factory for SneakySwole.
Creates and configures the FastAPI app with routes, templates,
static files, and structured logging.
"""
from pathlib import Path
import structlog
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from app.config import get_settings
from app.logging_config import setup_logging
from app.routes.health import router as health_router
from app.routes.pages import router as pages_router
logger = structlog.get_logger(__name__)
# Template and static file directories
_BASE_DIR = Path(__file__).resolve().parent
TEMPLATES_DIR = _BASE_DIR / "templates"
STATIC_DIR = _BASE_DIR / "static"
def create_app() -> FastAPI:
"""Create and configure the FastAPI application.
Returns:
A fully configured FastAPI application instance.
"""
settings = get_settings()
setup_logging(log_level=settings.app_log_level)
app = FastAPI(
title="SneakySwole",
description="Open-source workout tracking and programming",
version="0.1.0",
)
# Mount static files
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
# Jinja2 templates (available to routes via request.state or dependency)
templates = Jinja2Templates(directory=str(TEMPLATES_DIR))
app.state.templates = templates
# Register route modules
app.include_router(health_router)
app.include_router(pages_router)
logger.info("app_started", environment=settings.app_env)
return app
# Uvicorn entry point
app = create_app()

0
app/models/__init__.py Normal file
View File

0
app/routes/__init__.py Normal file
View File

23
app/routes/health.py Normal file
View File

@@ -0,0 +1,23 @@
"""Health check route for monitoring and readiness probes.
Provides a simple GET /health endpoint that returns application
status, name, and version.
"""
from fastapi import APIRouter
router = APIRouter(tags=["health"])
@router.get("/health")
async def health_check() -> dict:
"""Return application health status.
Returns:
JSON with app name, version, and status.
"""
return {
"app": "sneakyswole",
"version": "0.1.0",
"status": "ok",
}

23
app/routes/pages.py Normal file
View File

@@ -0,0 +1,23 @@
"""Page routes for serving full HTML pages.
Renders Jinja2 templates for user-facing pages.
"""
from fastapi import APIRouter, Request
from fastapi.responses import HTMLResponse
router = APIRouter(tags=["pages"])
@router.get("/")
async def home_page(request: Request) -> HTMLResponse:
"""Render the home page.
Args:
request: The incoming HTTP request.
Returns:
Rendered home page HTML.
"""
templates = request.app.state.templates
return templates.TemplateResponse(request, "pages/home.html")

0
app/services/__init__.py Normal file
View File

View File

@@ -0,0 +1,36 @@
/* SneakySwole custom CSS overrides
Built on top of Pico CSS dark theme.
Keep Pico mostly untouched — only override what's necessary. */
/* Slightly bolder nav styling */
nav h1 {
margin-bottom: 0;
font-weight: 700;
}
/* Active nav link indicator */
nav a[aria-current="page"] {
font-weight: 700;
text-decoration: underline;
}
/* Spacing for main content area */
main.container {
padding-top: 1rem;
padding-bottom: 2rem;
}
/* Flash message styling */
.flash-success {
color: var(--pico-ins-color);
border-left: 4px solid var(--pico-ins-color);
padding: 0.5rem 1rem;
margin-bottom: 1rem;
}
.flash-error {
color: var(--pico-del-color);
border-left: 4px solid var(--pico-del-color);
padding: 0.5rem 1rem;
margin-bottom: 1rem;
}

47
app/templates/base.html Normal file
View File

@@ -0,0 +1,47 @@
<!DOCTYPE html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}SneakySwole{% endblock %}</title>
<!-- Pico CSS (dark theme via data-theme="dark" on <html>) -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/@picocss/pico@2/css/pico.min.css">
<!-- Custom overrides -->
<link rel="stylesheet" href="/static/css/sneakyswole.css">
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@2.0.4"
integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"
crossorigin="anonymous"></script>
{% block head_extra %}{% endblock %}
</head>
<body>
<header class="container">
<nav>
<ul>
<li><strong><a href="/">SneakySwole</a></strong></li>
</ul>
<ul>
{% block nav_items %}
<!-- Phase 3 adds: profile switcher, login/logout -->
{% endblock %}
</ul>
</nav>
</header>
<main class="container">
{% block flash %}{% endblock %}
{% block content %}{% endblock %}
</main>
<footer class="container">
<small>SneakySwole — Open-source workout tracking</small>
</footer>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,12 @@
{% extends "base.html" %}
{% block title %}SneakySwole — Home{% endblock %}
{% block content %}
<hgroup>
<h1>SneakySwole</h1>
<p>Your open-source workout tracker</p>
</hgroup>
<p>Welcome to SneakySwole. Get started by logging in.</p>
{% endblock %}

0
app/utils/__init__.py Normal file
View File

0
data/.gitkeep Normal file
View File

23
docker-compose.yaml Normal file
View File

@@ -0,0 +1,23 @@
services:
app:
build:
context: .
dockerfile: Dockerfile
container_name: sneakyswole
ports:
- "8000:8000"
volumes:
- sneakyswole-data:/app/data
env_file:
- .env
restart: unless-stopped
healthcheck:
test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:8000/health')"]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
sneakyswole-data:
driver: local

9
pyproject.toml Normal file
View File

@@ -0,0 +1,9 @@
[project]
name = "sneakyswole"
version = "0.1.0"
description = "Open-source workout tracking and programming app"
requires-python = ">=3.12"
[build-system]
requires = ["setuptools>=75.0"]
build-backend = "setuptools.backends._legacy:_Backend"

13
requirements.txt Normal file
View File

@@ -0,0 +1,13 @@
fastapi>=0.115.0,<1.0.0
uvicorn[standard]>=0.34.0,<1.0.0
jinja2>=3.1.0,<4.0.0
structlog>=24.0.0,<25.0.0
python-dotenv>=1.0.0,<2.0.0
bcrypt>=4.2.0,<5.0.0
pyyaml>=6.0.0,<7.0.0
sqlmodel>=0.0.22,<1.0.0
alembic>=1.14.0,<2.0.0
httpx>=0.28.0,<1.0.0
pytest>=8.0.0,<9.0.0
pytest-asyncio>=0.24.0,<1.0.0
pydantic-settings>=2.7.0,<3.0.0

0
tests/__init__.py Normal file
View File

28
tests/conftest.py Normal file
View File

@@ -0,0 +1,28 @@
"""Shared test fixtures for the SneakySwole test suite."""
import os
from unittest.mock import patch
import pytest
from fastapi.testclient import TestClient
@pytest.fixture(autouse=True)
def _set_test_env() -> None:
"""Ensure required env vars are set for all tests."""
with patch.dict(os.environ, {
"ADMIN_USERNAME": "testadmin",
"ADMIN_PASSWORD": "testpass123",
"APP_ENV": "development",
"DATABASE_URL": "sqlite:///data/test_sneakyswole.db",
}, clear=False):
yield
@pytest.fixture
def client() -> TestClient:
"""Create a FastAPI TestClient for integration tests."""
from app.main import create_app
app = create_app()
return TestClient(app)

12
tests/test_app.py Normal file
View File

@@ -0,0 +1,12 @@
"""Tests for the FastAPI application factory."""
from fastapi.testclient import TestClient
class TestCreateApp:
"""Tests for the application factory and basic setup."""
def test_app_starts_without_error(self, client: TestClient) -> None:
"""The app should initialize and serve requests."""
response = client.get("/health")
assert response.status_code == 200

47
tests/test_config.py Normal file
View File

@@ -0,0 +1,47 @@
"""Tests for the application configuration loader."""
import os
from unittest.mock import patch
from app.config import Settings, get_settings
class TestSettings:
"""Tests for the Settings configuration class."""
def test_settings_loads_defaults(self) -> None:
"""Settings should have sensible defaults for all fields."""
env = {
"ADMIN_USERNAME": "testadmin",
"ADMIN_PASSWORD": "testpass123",
}
# Remove DATABASE_URL so we test the actual default
env_clean = {k: v for k, v in os.environ.items() if k != "DATABASE_URL"}
env_clean.update(env)
with patch.dict(os.environ, env_clean, clear=True):
settings = Settings()
assert settings.admin_username == "testadmin"
assert settings.admin_password == "testpass123"
assert settings.app_env == "development"
assert settings.app_host == "0.0.0.0"
assert settings.app_port == 8000
assert settings.database_url == "sqlite:///data/sneakyswole.db"
def test_settings_requires_admin_username(self) -> None:
"""Settings should require ADMIN_USERNAME to be set."""
with patch.dict(os.environ, {"ADMIN_PASSWORD": "testpass"}, clear=True):
try:
Settings()
assert False, "Should have raised an error"
except Exception:
pass
def test_get_settings_returns_singleton(self) -> None:
"""get_settings should return the same instance on repeated calls."""
with patch.dict(os.environ, {
"ADMIN_USERNAME": "admin",
"ADMIN_PASSWORD": "pass",
}, clear=False):
s1 = get_settings()
s2 = get_settings()
assert s1 is s2

30
tests/test_health.py Normal file
View File

@@ -0,0 +1,30 @@
"""Tests for the health check endpoint."""
from fastapi.testclient import TestClient
class TestHealthCheck:
"""Tests for GET /health."""
def test_health_returns_200(self, client: TestClient) -> None:
"""GET /health should return HTTP 200."""
response = client.get("/health")
assert response.status_code == 200
def test_health_returns_status_ok(self, client: TestClient) -> None:
"""GET /health should return a JSON body with status 'ok'."""
response = client.get("/health")
data = response.json()
assert data["status"] == "ok"
def test_health_includes_app_name(self, client: TestClient) -> None:
"""GET /health should include the application name."""
response = client.get("/health")
data = response.json()
assert data["app"] == "sneakyswole"
def test_health_includes_version(self, client: TestClient) -> None:
"""GET /health should include the application version."""
response = client.get("/health")
data = response.json()
assert "version" in data

22
tests/test_logging.py Normal file
View File

@@ -0,0 +1,22 @@
"""Tests for structured logging configuration."""
import structlog
from app.logging_config import setup_logging
class TestLogging:
"""Tests for the structlog configuration."""
def test_setup_logging_configures_structlog(self) -> None:
"""setup_logging should configure structlog without errors."""
setup_logging(log_level="info")
logger = structlog.get_logger("test")
assert logger is not None
def test_logger_can_log_message(self) -> None:
"""A configured logger should be able to emit a log message."""
setup_logging(log_level="debug")
logger = structlog.get_logger("test_module")
# Should not raise
logger.info("test message", key="value")

22
tests/test_pages.py Normal file
View File

@@ -0,0 +1,22 @@
"""Tests for HTML page routes."""
from fastapi.testclient import TestClient
class TestHomePage:
"""Tests for GET /."""
def test_home_returns_200(self, client: TestClient) -> None:
"""GET / should return HTTP 200."""
response = client.get("/")
assert response.status_code == 200
def test_home_contains_app_name(self, client: TestClient) -> None:
"""GET / should render HTML containing the app name."""
response = client.get("/")
assert "SneakySwole" in response.text
def test_home_has_dark_theme(self, client: TestClient) -> None:
"""GET / should use the dark theme."""
response = client.get("/")
assert 'data-theme="dark"' in response.text