#!/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()