feat: implement blog project with CI pipeline
All checks were successful
ci/woodpecker/pr/lint Pipeline was successful
ci/woodpecker/pr/test Pipeline was successful
ci/woodpecker/pr/type Pipeline was successful
ci/woodpecker/pr/comment_pr Pipeline was successful

This commit is contained in:
2026-04-25 19:15:34 +03:00
parent 9c3b44b561
commit 2e930ffbe5
46 changed files with 1315 additions and 15 deletions

65
scripts/README.md Normal file
View File

@@ -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
```

22
scripts/clean_cache.sh Executable file
View File

@@ -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"

64
scripts/commit-msg Executable file
View File

@@ -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 <files>"
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: <type>: <short description>"
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

18
scripts/post-commit Executable file
View File

@@ -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

358
scripts/update_readme.py Normal file
View File

@@ -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() -> 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()