diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md deleted file mode 100644 index 9919a2f..0000000 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ /dev/null @@ -1,30 +0,0 @@ -## 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 index 31dce73..81bfca0 100644 --- a/.gitignore +++ b/.gitignore @@ -8,14 +8,6 @@ site/ *.pyc *.pyo -# opencode skills (agent-only) -.opencode/ -AGENTS.md -.github/ - -# Scripts (except hooks) -scripts/ - # IDE .idea/ .vscode/ @@ -36,13 +28,8 @@ htmlcov/ # Environment .env -.env.example .venv/ venv/ # uv cache .uv/ - -# Scripts cache -scripts/__pycache__/ - diff --git a/.woodpecker/comment_pr.yaml b/.woodpecker/comment_pr.yaml deleted file mode 100644 index f44d57b..0000000 --- a/.woodpecker/comment_pr.yaml +++ /dev/null @@ -1,24 +0,0 @@ -when: - event: [push, pull_request] - -steps: - - name: comment - image: mcs94/gitea-comment - settings: - gitea_address: https://git.pyaqa.ru - gitea_token: - from_secret: gitea_token - comment: > - ✅ Build ${CI_BUILD_EVENT} of `${CI_REPO_NAME}` has status `${CI_BUILD_STATUS}`. - - 📝 Commit by ${CI_COMMIT_AUTHOR} on `${CI_COMMIT_BRANCH}`: - - `${CI_COMMIT_MESSAGE}` - - 🌐 ${CI_BUILD_LINK} - -depends_on: - - lint - - type - - test - diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..c29d16c --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,44 @@ +# Blog AGENTS.md + +## Stack +- Python 3.13+, FastAPI, pydantic, uvicorn +- Package manager: `uv` +- CI: Woodpecker (lint, test, type on push/PR to `dev`) + +## Commands +```bash +uv sync --group dev # Install all dev dependencies +uv run pytest # Run tests (coverage >= 70% required) +uv run pytest tests/unit/ # Run single test directory +uv run ruff check . --fix # Lint +uv run ruff format # Format +uv run isort . # Sort imports +uv run mypy . # Type check (strict mode) +uv run blog # Start dev server (port 8000) +``` + +## Pre-commit order +`ruff check --fix` → `ruff format` → `isort` → `mypy` + +## Architecture +``` +app/ + main.py # Entry point, uvicorn.run(app_factory) + core/config.py # Settings from .env via pydantic-settings + core/exceptions.py + common/error_handler.py + api/v1/ + modules/ +tests/ + unit/ + integration/ + e2e/ + api/ +``` + +## Key conventions +- All commands use `uv run` prefix +- pytest: asyncio_mode=auto, coverage on `app/` +- mypy: strict=true with pydantic plugin +- isort: black profile, filter_files=true +- `.env` loaded by pydantic-settings (not in repo) diff --git a/docs/api/endpoints.md b/docs/api/endpoints.md deleted file mode 100644 index 3197cc0..0000000 --- a/docs/api/endpoints.md +++ /dev/null @@ -1,17 +0,0 @@ -# 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 deleted file mode 100644 index c7c6cfd..0000000 --- a/docs/api/index.md +++ /dev/null @@ -1,13 +0,0 @@ -# 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 deleted file mode 100644 index bf8bbb5..0000000 --- a/docs/development/codestyle.md +++ /dev/null @@ -1,43 +0,0 @@ -# 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 deleted file mode 100644 index 5b0df7a..0000000 --- a/docs/development/setup.md +++ /dev/null @@ -1,31 +0,0 @@ -# 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 deleted file mode 100644 index cabebe0..0000000 --- a/docs/index.md +++ /dev/null @@ -1,28 +0,0 @@ -# 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 deleted file mode 100644 index 76e2b79..0000000 --- a/mkdocs.yml +++ /dev/null @@ -1,50 +0,0 @@ -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 deleted file mode 100644 index 6dd3ceb..0000000 --- a/scripts/README.md +++ /dev/null @@ -1,65 +0,0 @@ -# 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 deleted file mode 100755 index ccc4a9d..0000000 --- a/scripts/clean_cache.sh +++ /dev/null @@ -1,22 +0,0 @@ -#!/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 deleted file mode 100755 index d087551..0000000 --- a/scripts/commit-msg +++ /dev/null @@ -1,64 +0,0 @@ -#!/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 deleted file mode 100755 index ccb601c..0000000 --- a/scripts/post-commit +++ /dev/null @@ -1,18 +0,0 @@ -#!/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 deleted file mode 100644 index 97e1be3..0000000 --- a/scripts/update_readme.py +++ /dev/null @@ -1,358 +0,0 @@ -#!/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() -> None: - 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()