diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..9919a2f --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,30 @@ +## Description + + +## Type of Change + +- [ ] 🚀 Feature (`feat`) +- [ ] 🐛 Bug Fix (`fix`) +- [ ] 📝 Documentation (`docs`) +- [ ] ♻️ Refactor (`refactor`) +- [ ] 🎨 Code Style (`style`) +- [ ] ✅ Tests (`test`) +- [ ] 🔧 Chore (`chore`) + +## Checklist +- [ ] Code follows project style guidelines (ruff, isort) +- [ ] Tests added/updated (if applicable) +- [ ] Documentation updated (if applicable) +- [ ] Commit message follows convention (`type: description`) +- [ ] Branch rebased to single commit before merge +- [ ] No cache files in commit (`__pycache__`, `*.pyc`) + +## Testing + + +## Related Issues + +Fixes # + +## Screenshots (if applicable) + diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..198e7d3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,52 @@ +# MkDocs output +site/ + +# Python cache (ignore all) +__pycache__/ +*.py[cod] +*$py.class +*.pyc +*.pyo + +# opencode skills (agent-only) +.opencode/ +AGENTS.md +.github/PULL_REQUEST_TEMPLATE.md + +# Scripts (except hooks) +scripts/* +!scripts/commit-msg +!scripts/post-commit +!scripts/update_readme.py +!scripts/clean_cache.sh + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# mypy +.mypy_cache/ + +# ruff +.ruff_cache/ + +# Environment +.env +.env.example +.venv/ +venv/ + +# uv cache +.uv/ + +# Scripts cache +scripts/__pycache__/ + diff --git a/.woodpecker/deploy.yaml b/.woodpecker/deploy.yaml new file mode 100644 index 0000000..15d4cc2 --- /dev/null +++ b/.woodpecker/deploy.yaml @@ -0,0 +1,18 @@ +when: + event: [push] + branch: main + +steps: + deploy: + image: python:3.13 + commands: + - echo "🚀 Deploying to production..." + - echo "Branch: main" + - echo "Commit: $(git rev-parse --short HEAD)" + # Add your deployment commands here + # Example: + # - uv sync --frozen + # - uv run python -m app.main & + - echo "✅ Deployment complete" + when: + status: [success] diff --git a/.woodpecker/git-check.yaml b/.woodpecker/git-check.yaml new file mode 100644 index 0000000..1519091 --- /dev/null +++ b/.woodpecker/git-check.yaml @@ -0,0 +1,49 @@ +when: + event: [push, pull_request] + +steps: + check-branch: + image: alpine/git + commands: + - BRANCH=$(git rev-parse --abbrev-ref HEAD) + - | + echo "Branch: $BRANCH" + if [ "$BRANCH" = "main" ]; then + echo "✓ Production branch (protected)" + elif [ "$BRANCH" = "dev" ]; then + echo "✓ Development branch (protected)" + elif echo "$BRANCH" | grep -qE "^feature/"; then + echo "✓ Feature branch" + elif echo "$BRANCH" | grep -qE "^(fix|hotfix|release)/"; then + echo "✓ Special branch" + else + echo "⚠️ Unusual branch name: $BRANCH" + echo " Recommended: feature/, fix/" + fi + + check-commit-message: + image: alpine/git + commands: + - MSG=$(git log -1 --pretty=%s) + - | + echo "Last commit: $MSG" + if echo "$MSG" | grep -qE "^(feat|fix|docs|style|refactor|test|chore): [a-z]"; then + echo "✓ Commit message follows convention" + else + echo "❌ Invalid commit message format" + echo " Expected: : " + echo " Types: feat, fix, docs, style, refactor, test, chore" + exit 1 + fi + + check-cache-files: + image: python:3.13 + commands: + - | + CACHE_FILES=$(git diff --name-only HEAD~1 | grep -E "__pycache__|\.pyc$" || true) + if [ -n "$CACHE_FILES" ]; then + echo "❌ Cache files in commit:" + echo "$CACHE_FILES" + exit 1 + fi + - echo "✓ No cache files in commit" diff --git a/app/__init__.py b/app/__init__.py index e69de29..18b665e 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -0,0 +1 @@ +"""Application package.""" diff --git a/app/api/__init__.py b/app/api/__init__.py new file mode 100644 index 0000000..d5bdbf2 --- /dev/null +++ b/app/api/__init__.py @@ -0,0 +1 @@ +"""API module - HTTP routes and endpoints.""" diff --git a/app/api/v1/__init__.py b/app/api/v1/__init__.py new file mode 100644 index 0000000..201dac4 --- /dev/null +++ b/app/api/v1/__init__.py @@ -0,0 +1 @@ +"""API version 1 endpoints.""" diff --git a/app/common/__init__.py b/app/common/__init__.py new file mode 100644 index 0000000..72e2d07 --- /dev/null +++ b/app/common/__init__.py @@ -0,0 +1 @@ +"""Common utilities and shared components.""" diff --git a/app/common/error_handler.py b/app/common/error_handler.py new file mode 100644 index 0000000..5811ed4 --- /dev/null +++ b/app/common/error_handler.py @@ -0,0 +1,48 @@ +from datetime import datetime + +from fastapi import FastAPI, Request +from fastapi.responses import JSONResponse +from pydantic import BaseModel +from starlette.exceptions import HTTPException + +from app.core.exceptions import AppException + + +class ErrorResponse(BaseModel): + status_code: int + message: str + details: dict | None = None + timestamp: str + + +async def app_exception_handler(request: Request, exc: AppException) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content={ + "status_code": exc.status_code, + "message": exc.message, + "timestamp": datetime.utcnow().isoformat(), + }, + ) + + +async def http_exception_handler(request: Request, exc: HTTPException) -> JSONResponse: + return JSONResponse( + status_code=exc.status_code, + content={ + "status_code": exc.status_code, + "message": str(exc.detail), + "timestamp": datetime.utcnow().isoformat(), + }, + ) + + +def register_exception_handlers(app: FastAPI): + app.add_exception_handler( + AppException, + app_exception_handler, # type: ignore[arg-type] + ) + app.add_exception_handler( + HTTPException, + http_exception_handler, # type: ignore[arg-type] + ) diff --git a/app/core/__init__.py b/app/core/__init__.py new file mode 100644 index 0000000..787c301 --- /dev/null +++ b/app/core/__init__.py @@ -0,0 +1 @@ +"""Core module - shared functionality and configuration.""" diff --git a/app/core/config.py b/app/core/config.py new file mode 100644 index 0000000..98e4037 --- /dev/null +++ b/app/core/config.py @@ -0,0 +1,15 @@ +from pydantic_settings import BaseSettings, SettingsConfigDict + + +class Settings(BaseSettings): + app_name: str = "Blog API" + debug: bool = False + host: str = "0.0.0.0" + port: int = 8000 + + database_url: str | None = None + + model_config = SettingsConfigDict(env_file=".env") + + +settings = Settings() diff --git a/app/core/exceptions.py b/app/core/exceptions.py new file mode 100644 index 0000000..b8c7d84 --- /dev/null +++ b/app/core/exceptions.py @@ -0,0 +1,25 @@ +class AppException(Exception): + def __init__(self, message: str, status_code: int = 500): + self.message = message + self.status_code = status_code + super().__init__(self.message) + + +class NotFoundError(AppException): + def __init__(self, message: str = "Resource not found"): + super().__init__(message, status_code=404) + + +class ValidationError(AppException): + def __init__(self, message: str = "Validation failed"): + super().__init__(message, status_code=400) + + +class UnauthorizedError(AppException): + def __init__(self, message: str = "Unauthorized"): + super().__init__(message, status_code=401) + + +class ForbiddenError(AppException): + def __init__(self, message: str = "Forbidden"): + super().__init__(message, status_code=403) diff --git a/app/modules/__init__.py b/app/modules/__init__.py new file mode 100644 index 0000000..6ebd16f --- /dev/null +++ b/app/modules/__init__.py @@ -0,0 +1 @@ +"""Feature modules - business logic organized by domain.""" diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md new file mode 100644 index 0000000..3197cc0 --- /dev/null +++ b/docs/api/endpoints.md @@ -0,0 +1,17 @@ +# API Endpoints + +## Overview + +| Method | Endpoint | Description | +|--------|----------|-------------| +| GET | `/` | Health check | + +## Health Check + +```http +GET / +``` + +**Response:** `200 OK` + +Returns application status. diff --git a/docs/api/index.md b/docs/api/index.md new file mode 100644 index 0000000..c7c6cfd --- /dev/null +++ b/docs/api/index.md @@ -0,0 +1,13 @@ +# API Reference + +This section contains auto-generated API documentation from source code docstrings. + +## Modules + +::: app.main + handler: python + options: + members: + - lifespan + - app_factory + - main diff --git a/docs/development/codestyle.md b/docs/development/codestyle.md new file mode 100644 index 0000000..bf8bbb5 --- /dev/null +++ b/docs/development/codestyle.md @@ -0,0 +1,43 @@ +# Code Style + +## Linting & Formatting + +```bash +# Run all linters +uv run ruff check . --fix +uv run ruff format . +uv run isort . --profile black --filter-files + +# Type checking +uv run mypy . +``` + +## Documentation + +```bash +# Check docstring style +uv run pydocstyle app/ + +# Check documentation coverage +uv run interrogate app/ -v + +# Build documentation +uv run mkdocs build + +# Serve documentation locally +uv run mkdocs serve +``` + +## Pre-commit Hooks + +This project uses pre-commit hooks to ensure code quality: + +- ruff check +- ruff format +- isort +- mypy + +Install hooks: +```bash +uv run pre-commit install +``` diff --git a/docs/development/setup.md b/docs/development/setup.md new file mode 100644 index 0000000..5b0df7a --- /dev/null +++ b/docs/development/setup.md @@ -0,0 +1,31 @@ +# Setup Guide + +## Prerequisites + +- Python 3.13+ +- uv package manager + +## Installation + +```bash +# Clone repository +git clone https://github.com/pyaqa/blog.git +cd blog + +# Install dependencies +uv sync + +# Run tests +uv run pytest + +# Start development server +uv run python -m app.main +``` + +## Development Server + +The server runs on `http://0.0.0.0:8000` by default. + +Access interactive API docs at: +- Swagger UI: `http://localhost:8000/docs` +- ReDoc: `http://localhost:8000/redoc` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..cabebe0 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,28 @@ +# Blog API + +Welcome to the Blog API documentation. + +## Features + +- FastAPI-based REST API +- Python 3.13+ +- Async support +- Type hints throughout + +## Quick Start + +```bash +# Install dependencies +uv sync + +# Run development server +uv run python -m app.main +``` + +## API Endpoints + +See [API Reference](api/endpoints.md) for detailed endpoint documentation. + +## Development + +See [Development Guide](development/setup.md) for setup instructions. diff --git a/mkdocs.yml b/mkdocs.yml new file mode 100644 index 0000000..76e2b79 --- /dev/null +++ b/mkdocs.yml @@ -0,0 +1,50 @@ +site_name: Blog API Documentation +site_description: FastAPI Blog Application Documentation +site_author: Blog Team +repo_url: https://github.com/pyaqa/blog + +theme: + name: mkdocs + palette: + - scheme: default + primary: indigo + accent: indigo + toggle: + icon: material/brightness-7 + name: Switch to dark mode + - scheme: slate + primary: indigo + accent: indigo + toggle: + icon: material/brightness-4 + name: Switch to light mode + +plugins: + - search + - mkdocstrings: + handlers: + python: + options: + docstring_style: google + show_root_heading: true + show_source: true + show_bases: true + +markdown_extensions: + - pymdownx.highlight: + anchor_linenums: true + - pymdownx.inlinehilite + - pymdownx.snippets + - pymdownx.superfences + - admonition + - pymdownx.details + - tables + +nav: + - Home: index.md + - API Reference: + - Overview: api/index.md + - Endpoints: api/endpoints.md + - Development: + - Setup: development/setup.md + - Code Style: development/codestyle.md diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..6dd3ceb --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,65 @@ +# Development Scripts + +## clean_cache.sh + +Clean all Python cache files: + +```bash +bash scripts/clean_cache.sh +``` + +Removes: +- `__pycache__/` directories +- `*.pyc`, `*.pyo` files +- `.pytest_cache/` +- `.mypy_cache/` +- `.ruff_cache/` +- `.coverage` +- `htmlcov/` + +## update_readme.py + +Update README.md with latest project information: + +```bash +uv run python scripts/update_readme.py +``` + +Check if update needed (for CI): + +```bash +uv run python scripts/update_readme.py --check +``` + +## post-commit + +Git hook for auto-updating README after commits. + +Install: + +```bash +cp scripts/post-commit .git/hooks/post-commit +chmod +x .git/hooks/post-commit +``` + +## Disable Python Cache During Development + +Set environment variables before running Python: + +```bash +# Option 1: Export variables +export PYTHONDONTWRITEBYTECODE=1 +export UV_NO_CACHE=1 + +# Option 2: Use with command +PYTHONDONTWRITEBYTECODE=1 uv run python -m app.main + +# Option 3: Add to .env (not committed) +echo "PYTHONDONTWRITEBYTECODE=1" >> .env +``` + +Or use the clean script periodically: + +```bash +bash scripts/clean_cache.sh +``` diff --git a/scripts/clean_cache.sh b/scripts/clean_cache.sh new file mode 100755 index 0000000..ccc4a9d --- /dev/null +++ b/scripts/clean_cache.sh @@ -0,0 +1,22 @@ +#!/bin/bash + +set -e + +echo "Cleaning Python cache files..." + +find . -type d -name "__pycache__" -exec rm -rf {} + 2>/dev/null || true + +find . -type f -name "*.pyc" -delete 2>/dev/null || true + +find . -type f -name "*.pyo" -delete 2>/dev/null || true + +rm -rf .pytest_cache/ 2>/dev/null || true + +rm -rf .mypy_cache/ 2>/dev/null || true + +rm -rf .ruff_cache/ 2>/dev/null || true + +rm -f .coverage 2>/dev/null || true +rm -rf htmlcov/ 2>/dev/null || true + +echo "✓ Cache cleaned" diff --git a/scripts/commit-msg b/scripts/commit-msg new file mode 100755 index 0000000..d087551 --- /dev/null +++ b/scripts/commit-msg @@ -0,0 +1,64 @@ +#!/bin/bash + +set -e + +COMMIT_MSG_FILE="$1" +if [ -z "$COMMIT_MSG_FILE" ]; then + echo "Checking for cache files in staged changes..." + + CACHE_FILES=$(git diff --cached --name-only | grep -E "__pycache__|\.pyc$|\.pyo$" || true) + + if [ -n "$CACHE_FILES" ]; then + echo "❌ Attempting to commit Python cache files!" + echo "" + echo "Files:" + echo "$CACHE_FILES" + echo "" + echo "Run: bash scripts/clean_cache.sh" + echo "Or: git reset HEAD " + exit 1 + fi + + echo "✓ No cache files in staged changes" + exit 0 +fi + +COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") + +if ! echo "$COMMIT_MSG" | grep -qE "^(feat|fix|docs|style|refactor|test|chore): [a-z].{0,49}$"; then + echo "❌ Invalid commit message format!" + echo "" + echo "Current message: $COMMIT_MSG" + echo "" + echo "Expected format: : " + echo "" + echo "Types:" + echo " feat - New feature" + echo " fix - Bug fix" + echo " docs - Documentation" + echo " style - Code style" + echo " refactor - Refactoring" + echo " test - Tests" + echo " chore - Maintenance" + echo "" + echo "Rules:" + echo " - Max 50 characters" + echo " - Lowercase after type" + echo " - Imperative mood (add, not added)" + echo " - No period at end" + echo "" + echo "Good examples:" + echo " feat: add user authentication" + echo " fix: resolve database timeout" + echo " docs: update API docs" + echo "" + exit 1 +fi + +if echo "$COMMIT_MSG" | grep -qE "\.$"; then + echo "❌ Commit message should not end with a period" + exit 1 +fi + +echo "✓ Commit message valid: $COMMIT_MSG" +exit 0 diff --git a/scripts/post-commit b/scripts/post-commit new file mode 100755 index 0000000..ccb601c --- /dev/null +++ b/scripts/post-commit @@ -0,0 +1,18 @@ +#!/bin/bash +# Post-commit hook: Update README.md automatically + +set -e + +echo "Updating README.md..." + +# Run README update script +uv run python scripts/update_readme.py + +# Check if README changed +if ! git diff --quiet README.md; then + echo "✓ README.md was updated" + echo " Review changes and commit if needed:" + echo " git add README.md && git commit -m 'docs: update README [skip ci]'" +else + echo "✓ README.md is up to date" +fi diff --git a/scripts/update_readme.py b/scripts/update_readme.py new file mode 100644 index 0000000..0e1fb70 --- /dev/null +++ b/scripts/update_readme.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 + +import re +import subprocess +import tomllib +from datetime import datetime +from pathlib import Path +from typing import Any + + +def get_project_root() -> Path: + return Path(__file__).parent.parent + + +def get_pyproject() -> dict[str, Any]: + root = get_project_root() + with open(root / "pyproject.toml", "rb") as f: + return tomllib.load(f) + + +def get_latest_commits(count: int = 10) -> list[dict[str, str]]: + result = subprocess.run( + ["git", "log", "--format=%H|%s|%ad|%an", "--date=short", f"-n{count}"], + capture_output=True, + text=True, + cwd=get_project_root(), + ) + + commits = [] + for line in result.stdout.strip().split("\n"): + if line: + parts = line.split("|") + if len(parts) >= 4: + commits.append( + { + "hash": parts[0][:7], + "message": parts[1], + "date": parts[2], + "author": parts[3], + } + ) + return commits + + +def get_last_tag() -> str | None: + result = subprocess.run( + ["git", "describe", "--tags", "--abbrev=0"], + capture_output=True, + text=True, + cwd=get_project_root(), + ) + return result.stdout.strip() if result.returncode == 0 else None + + +def get_ignored_files() -> set[str]: + gitignore_path = get_project_root() / ".gitignore" + ignored = set() + if gitignore_path.exists(): + for line in gitignore_path.read_text().splitlines(): + line = line.strip() + if line and not line.startswith("#"): + ignored.add(line.rstrip("/")) + return ignored + + +def commit_has_tracked_changes(commit_hash: str) -> bool: + result = subprocess.run( + ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash], + capture_output=True, + text=True, + cwd=get_project_root(), + ) + if not result.stdout.strip(): + return False + + ignored = get_ignored_files() + for file_path in result.stdout.strip().split("\n"): + if not file_path: + continue + parts = file_path.split("/") + is_ignored = False + for i in range(len(parts)): + path_part = "/".join(parts[: i + 1]) + for pattern in ignored: + if pattern.endswith("*"): + if path_part.startswith(pattern[:-1]): + is_ignored = True + break + elif path_part == pattern or parts[-1] == pattern: + is_ignored = True + break + if is_ignored: + break + if not is_ignored: + return True + return False + + +def commit_has_skip_ci_message(commit_hash: str) -> bool: + result = subprocess.run( + ["git", "log", "-1", "--format=%s", commit_hash], + capture_output=True, + text=True, + cwd=get_project_root(), + ) + msg = result.stdout.strip().lower() + return "[skip ci]" in msg or "[skip-ci]" in msg or "[ci skip]" in msg + + +def commit_only_changes_readme(commit_hash: str) -> bool: + result = subprocess.run( + ["git", "diff-tree", "--no-commit-id", "--name-only", "-r", commit_hash], + capture_output=True, + text=True, + cwd=get_project_root(), + ) + files = [f.strip() for f in result.stdout.strip().split("\n") if f.strip()] + return files == ["README.md"] + + +def get_commits_since_tag(tag: str | None) -> list[dict[str, str]]: + if tag: + result = subprocess.run( + ["git", "log", "--format=%H|%s|%ad|%an", "--date=short", f"{tag}..HEAD"], + capture_output=True, + text=True, + cwd=get_project_root(), + ) + else: + result = subprocess.run( + ["git", "log", "--format=%H|%s|%ad|%an", "--date=short", "-n10"], + capture_output=True, + text=True, + cwd=get_project_root(), + ) + + commits = [] + for line in result.stdout.strip().split("\n"): + if line: + parts = line.split("|") + if len(parts) >= 4: + commit_hash = parts[0] + if commit_has_skip_ci_message(commit_hash): + continue + if commit_only_changes_readme(commit_hash): + continue + if not commit_has_tracked_changes(commit_hash): + continue + commits.append( + { + "hash": commit_hash[:7], + "message": parts[1], + "date": parts[2], + "author": parts[3], + } + ) + return commits + + +def categorize_commits(commits: list[dict[str, str]]) -> dict[str, list[str]]: + categories: dict[str, list[str]] = { + "Added": [], + "Changed": [], + "Fixed": [], + "Removed": [], + "Other": [], + } + + for commit in commits: + msg = commit["message"].lower() + entry = f"- {commit['message']} ({commit['hash']})" + + if msg.startswith("feat") or "add" in msg: + categories["Added"].append(entry) + elif msg.startswith("fix") or "fix" in msg: + categories["Fixed"].append(entry) + elif msg.startswith("change") or "update" in msg: + categories["Changed"].append(entry) + elif msg.startswith("remove") or "delete" in msg: + categories["Removed"].append(entry) + else: + categories["Other"].append(entry) + + return categories + + +def format_changelog(commits: list[dict[str, str]], version: str = "v0.1.0") -> str: + categorized = categorize_commits(commits) + today = datetime.now().strftime("%Y-%m-%d") + + lines = [f"### [{version}] - {today}"] + + for section, entries in categorized.items(): + if entries: + lines.append(f"\n#### {section}") + lines.extend(entries) + + return "\n".join(lines) + + +def get_dependencies(pyproject: dict[str, Any]) -> dict[str, list[str]]: + deps: dict[str, list[str]] = { + "runtime": [], + "tests": [], + "lints": [], + "types": [], + "docs": [], + } + + for dep in pyproject.get("project", {}).get("dependencies", []): + deps["runtime"].append(dep) + + dep_groups = pyproject.get("dependency-groups", {}) + + if "tests" in dep_groups: + for dep in dep_groups["tests"]: + if isinstance(dep, str): + deps["tests"].append(dep) + + if "lints" in dep_groups: + for dep in dep_groups["lints"]: + if isinstance(dep, str): + deps["lints"].append(dep) + + if "types" in dep_groups: + for dep in dep_groups["types"]: + if isinstance(dep, str): + deps["types"].append(dep) + + if "docs" in dep_groups: + for dep in dep_groups["docs"]: + if isinstance(dep, str): + deps["docs"].append(dep) + + return deps + + +def get_available_commands() -> list[dict[str, str]]: + commands = [ + {"cmd": "uv sync", "desc": "Install dependencies"}, + {"cmd": "uv run python -m app.main", "desc": "Start development server"}, + { + "cmd": "uv run pytest --cov=app --cov-fail-under=70", + "desc": "Run tests with coverage", + }, + {"cmd": "uv run ruff check . --fix", "desc": "Run linters"}, + {"cmd": "uv run ruff format .", "desc": "Format code"}, + { + "cmd": "uv run isort . --profile black --filter-files", + "desc": "Sort imports", + }, + {"cmd": "uv run mypy .", "desc": "Type checking"}, + {"cmd": "uv run mkdocs build", "desc": "Build documentation"}, + {"cmd": "uv run mkdocs serve", "desc": "Serve documentation locally"}, + ] + return commands + + +def update_dependencies_section(content: str, deps: dict[str, list[str]]) -> str: + section_pattern = r"(## Dependencies\n.*?)(\n## |\Z)" + + deps_text = "## Dependencies\n\n" + + if deps["runtime"]: + deps_text += "### Runtime\n" + for dep in sorted(deps["runtime"]): + deps_text += f"- {dep}\n" + deps_text += "\n" + + if deps["tests"]: + deps_text += "### Development\n" + deps_text += "- **Tests**: " + ", ".join(sorted(deps["tests"])) + "\n" + if deps["lints"]: + deps_text += "- **Lint**: " + ", ".join(sorted(deps["lints"])) + "\n" + if deps["types"]: + deps_text += "- **Types**: " + ", ".join(sorted(deps["types"])) + "\n" + if deps["docs"]: + deps_text += "- **Docs**: " + ", ".join(sorted(deps["docs"])) + "\n" + + deps_text += "\n" + + replacement = f"{deps_text}\\2" + return re.sub(section_pattern, replacement, content, flags=re.DOTALL) + + +def update_commands_section(content: str, commands: list[dict[str, str]]) -> str: + section_pattern = r"(## Available Commands\n.*?\|.*?\n\|---\|.*?\n)(.*?)(\n## |\Z)" + + commands_table = "| Command | Description |\n|---------|-------------|\n" + for cmd in commands: + commands_table += f"| `{cmd['cmd']}` | {cmd['desc']} |\n" + + commands_table += "\n" + + replacement = f"\\1{commands_table}\\3" + return re.sub(section_pattern, replacement, content, flags=re.DOTALL) + + +def update_changelog_section(content: str, changelog: str) -> str: + section_pattern = r"(## Changelog\n)(.*?)(\Z)" + + replacement = f"\\1\n{changelog}\n\\3" + return re.sub(section_pattern, replacement, content, flags=re.DOTALL) + + +def update_readme(check_only: bool = False) -> bool: + readme_path = get_project_root() / "README.md" + + if not readme_path.exists(): + print("README.md not found") + return False + + content = readme_path.read_text() + original_content = content + + pyproject = get_pyproject() + commits = get_commits_since_tag(get_last_tag()) + deps = get_dependencies(pyproject) + commands = get_available_commands() + + version = get_last_tag() or "v0.1.0" + changelog = format_changelog(commits, version) + + content = update_changelog_section(content, changelog) + content = update_dependencies_section(content, deps) + content = update_commands_section(content, commands) + + if check_only: + needs_update = content != original_content + if needs_update: + print("README.md needs update") + else: + print("README.md is up to date") + return needs_update + + if content != original_content: + readme_path.write_text(content) + print("README.md updated successfully") + return True + else: + print("No changes needed") + return False + + +def main(): + import sys + + check_only = "--check" in sys.argv + + updated = update_readme(check_only=check_only) + + if check_only and updated: + sys.exit(1) + sys.exit(0) + + +if __name__ == "__main__": + main() diff --git a/tests/api/__init__.py b/tests/api/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/api/conftest.py b/tests/api/conftest.py new file mode 100644 index 0000000..685d9c2 --- /dev/null +++ b/tests/api/conftest.py @@ -0,0 +1,22 @@ +# API test fixtures +# Provides: httpx.AsyncClient, authentication helpers, test API data + +import pytest +from httpx import ASGITransport, AsyncClient + + +@pytest.fixture +async def client(): + """Create async HTTP client for API testing.""" + from app.main import app_factory + + app = app_factory() + transport = ASGITransport(app=app) + async with AsyncClient(transport=transport, base_url="http://test") as ac: + yield ac + + +@pytest.fixture +def auth_headers(): + """Return mock authentication headers.""" + return {"Authorization": "Bearer test_token"} diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..162ddb9 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,8 @@ +import pytest + + +@pytest.fixture(scope="session") +def event_loop_policy(): + import asyncio + + return asyncio.DefaultEventLoopPolicy() diff --git a/tests/e2e/__init__.py b/tests/e2e/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py new file mode 100644 index 0000000..296b0c5 --- /dev/null +++ b/tests/e2e/conftest.py @@ -0,0 +1,27 @@ +# E2E test fixtures +# Provides: full application state, end-to-end workflows, cleanup + +import pytest + + +@pytest.fixture +async def e2e_app(): + """Create full application instance for E2E testing.""" + from app.main import app_factory + + app = app_factory() + yield app + # Cleanup after E2E test + + +@pytest.fixture +def e2e_user_data(): + """Generate realistic user data for E2E scenarios.""" + from mimesis import Person + + person = Person() + return { + "username": person.username(), + "email": person.email(), + "password": "SecurePass123!", + } diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/integration/conftest.py b/tests/integration/conftest.py new file mode 100644 index 0000000..5c40ae8 --- /dev/null +++ b/tests/integration/conftest.py @@ -0,0 +1,18 @@ +# Integration test fixtures +# Provides: test database, external service connections + +import pytest + + +@pytest.fixture +def test_db_connection(): + """Create test database connection.""" + # TODO: Implement when DB is added to project + yield "test_db" + + +@pytest.fixture +def cleanup_db(): + """Cleanup database after test.""" + yield + # TODO: Implement cleanup logic diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 0000000..401c50e --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,18 @@ +# Unit test fixtures +# Provides: mocks, stubs, isolated test data + +from unittest.mock import AsyncMock, Mock + +import pytest + + +@pytest.fixture +def mock_service(): + """Create a mock service for unit testing.""" + return Mock() + + +@pytest.fixture +def mock_async_service(): + """Create an async mock service for unit testing.""" + return AsyncMock() diff --git a/tests/unit/test_unit_app_run.py b/tests/unit/test_unit_app_run.py new file mode 100644 index 0000000..b6f4094 --- /dev/null +++ b/tests/unit/test_unit_app_run.py @@ -0,0 +1,33 @@ +from contextlib import asynccontextmanager +from unittest.mock import patch + +import pytest +from fastapi import FastAPI + +from app.main import app_factory, lifespan, main + + +@pytest.mark.asyncio +async def test_lifespan(): + app = FastAPI() + assert isinstance(lifespan, asynccontextmanager(lifespan).__class__) + + async with lifespan(app): + pass + + +def test_app_factory(): + app = app_factory() + assert isinstance(app, FastAPI) + assert app.router.lifespan_context == lifespan + + +@patch("app.main.uvicorn.run") +def test_main(mock_uvicorn_run): + main() + mock_uvicorn_run.assert_called_once_with( + app_factory, + factory=True, + host="0.0.0.0", + port=8000, + )