diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..6c33340 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,26 @@ +# Normalize tracked text files to LF for stable diffs across platforms. +* text=auto eol=lf + +# Keep markdown readable in diffs and consistently normalized. +*.md text eol=lf diff=markdown + +# Keep Python and shell automation normalized for reliable review and execution. +*.py text eol=lf +*.sh text eol=lf +*.ps1 text eol=lf + +# Keep structured config normalized so tooling behaves consistently. +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +*.json text eol=lf +*.jsonc text eol=lf + +# Keep ignore and attribute files themselves normalized for portability. +.gitignore text eol=lf +.gitattributes text eol=lf + +# Add project-specific binary types below. +# *.png binary +# *.pdf binary +# *.docx binary diff --git a/.github/actions/setup-python/action.yml b/.github/actions/setup-python/action.yml new file mode 100644 index 0000000..44bb9db --- /dev/null +++ b/.github/actions/setup-python/action.yml @@ -0,0 +1,53 @@ +name: Setup Python environment +description: > + Install a Python interpreter, create a venv via scripts/setup.sh or + scripts/setup.ps1, and add the venv to PATH so every subsequent step + uses the same isolated environment that local development does. + +inputs: + python-version: + description: Python version to install + required: false + default: "3.12" + +runs: + using: composite + steps: + - name: Install Python ${{ inputs.python-version }} + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ inputs.python-version }} + cache: pip + cache-dependency-path: pyproject.toml + + - name: Install uv when uv.lock is present + if: ${{ hashFiles('uv.lock') != '' }} + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + + - name: Create venv and install package (Linux/macOS) + if: runner.os != 'Windows' + shell: bash + run: bash .github/scripts/setup.sh + env: + PROJECT_ROOT: ${{ github.workspace }} + + - name: Create venv and install package (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: .\.github\scripts\setup.ps1 + env: + PROJECT_ROOT: ${{ github.workspace }} + + - name: Activate venv for subsequent steps (Linux/macOS) + if: runner.os != 'Windows' + shell: bash + run: | + echo "${GITHUB_WORKSPACE}/.venv/bin" >> "$GITHUB_PATH" + echo "VIRTUAL_ENV=${GITHUB_WORKSPACE}/.venv" >> "$GITHUB_ENV" + + - name: Activate venv for subsequent steps (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + "$env:GITHUB_WORKSPACE\.venv\Scripts" | Out-File -Append -FilePath $env:GITHUB_PATH -Encoding utf8 + "VIRTUAL_ENV=$env:GITHUB_WORKSPACE\.venv" | Out-File -Append -FilePath $env:GITHUB_ENV -Encoding utf8 diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..5827c6d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + + - package-ecosystem: pip + directory: / + schedule: + interval: weekly diff --git a/.github/scripts/.version b/.github/scripts/.version new file mode 100644 index 0000000..621e94f --- /dev/null +++ b/.github/scripts/.version @@ -0,0 +1 @@ +none diff --git a/.github/scripts/check_lint.py b/.github/scripts/check_lint.py new file mode 100644 index 0000000..dfe0989 --- /dev/null +++ b/.github/scripts/check_lint.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def _load_pyproject() -> dict[str, Any]: + path = Path("pyproject.toml") + if not path.exists(): + return {} + with open(path, "rb") as f: + return tomllib.load(f) + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run ruff lint and format checks.") + parser.add_argument("--fix", action="store_true", help="Auto-fix lint issues and reformat") + parser.add_argument("--paths", nargs="+", help="Override source paths to check") + args = parser.parse_args() + + pyproject = _load_pyproject() + paths = args.paths or pyproject.get("tool", {}).get("ruff", {}).get("src", ["src"]) + + if args.fix: + rc1 = _run([_tool("ruff"), "check", "--fix", *paths], "Ruff Fix") + rc2 = _run([_tool("ruff"), "format", *paths], "Ruff Format") + else: + rc1 = _run([_tool("ruff"), "check", *paths], "Ruff Check") + rc2 = _run([_tool("ruff"), "format", "--check", *paths], "Ruff Format Check") + + return 1 if (rc1 or rc2) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/check_package.py b/.github/scripts/check_package.py new file mode 100644 index 0000000..102c426 --- /dev/null +++ b/.github/scripts/check_package.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import glob +import os +import shutil +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def _load_pyproject() -> dict[str, Any]: + path = Path("pyproject.toml") + if not path.exists(): + return {} + with open(path, "rb") as f: + return tomllib.load(f) + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def _cleanup() -> None: + shutil.rmtree("dist", ignore_errors=True) + for egg_dir in glob.glob("*.egg-info"): + shutil.rmtree(egg_dir, ignore_errors=True) + for egg_dir in glob.glob("src/*.egg-info"): + shutil.rmtree(egg_dir, ignore_errors=True) + + +def main() -> int: + argparse.ArgumentParser(description="Validate package build, metadata, and entry points.").parse_args() + + pyproject = _load_pyproject() + + if "build-system" not in pyproject: + print("No [build-system] found, skipping package check") + return 0 + + entry_points = pyproject.get("project", {}).get("scripts", {}) + + try: + rc = _run([_tool("validate-pyproject"), "pyproject.toml"], "Validate pyproject.toml") + if rc != 0: + return rc + + rc = _run([sys.executable, "-m", "build"], "Build sdist+wheel") + if rc != 0: + return rc + + dist_files = glob.glob("dist/*") + if not dist_files: + is_ci = os.environ.get("GITHUB_ACTIONS") == "true" + print("::error::No dist files produced" if is_ci else "ERROR: No dist files produced") + return 1 + + rc = _run([_tool("twine"), "check", "--strict", *dist_files], "Twine Check") + if rc != 0: + return rc + + for name in entry_points: + tool_path = shutil.which(name) or _tool(name) + if shutil.which(name) is None and tool_path == name: + print(f" Entry point '{name}' not found on PATH, skipping smoke test") + continue + rc = _run([tool_path, "--help"], f"Entry point: {name} --help") + if rc != 0: + return rc + + finally: + _cleanup() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/check_security.py b/.github/scripts/check_security.py new file mode 100644 index 0000000..cf58375 --- /dev/null +++ b/.github/scripts/check_security.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def main() -> int: + argparse.ArgumentParser(description="Run pip-audit for dependency vulnerability scanning.").parse_args() + + return _run([_tool("pip-audit"), "--skip-editable"], "Pip-Audit") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/check_spelling.py b/.github/scripts/check_spelling.py new file mode 100644 index 0000000..30951c6 --- /dev/null +++ b/.github/scripts/check_spelling.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run codespell for typo detection.") + parser.add_argument("--fix", action="store_true", help="Auto-fix spelling mistakes") + args = parser.parse_args() + + cmd = [_tool("codespell")] + if args.fix: + cmd.append("--write-changes") + + return _run(cmd, "Codespell") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/check_tests.py b/.github/scripts/check_tests.py new file mode 100644 index 0000000..dd24de3 --- /dev/null +++ b/.github/scripts/check_tests.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def _write_coverage_summary() -> None: + coverage_path = Path("coverage.json") + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not coverage_path.exists() or not summary_path: + return + + with open(coverage_path) as f: + data = json.load(f) + + lines = [ + "## Coverage Summary", + "", + "| Module | Statements | Missed | Coverage |", + "|--------|-----------|--------|----------|", + ] + + files = data.get("files", {}) + for module, info in sorted(files.items()): + summary = info.get("summary", {}) + stmts = summary.get("num_statements", 0) + missed = summary.get("missing_lines", 0) + covered = summary.get("percent_covered", 0.0) + lines.append(f"| {module} | {stmts} | {missed} | {covered:.1f}% |") + + totals = data.get("totals", {}) + total_stmts = totals.get("num_statements", 0) + total_missed = totals.get("missing_lines", 0) + total_covered = totals.get("percent_covered", 0.0) + lines.append(f"| **Total** | **{total_stmts}** | **{total_missed}** | **{total_covered:.1f}%** |") + + with open(summary_path, "a") as f: + f.write("\n".join(lines) + "\n") + + coverage_path.unlink() + + +def main() -> int: + argparse.ArgumentParser(description="Run pytest with coverage.").parse_args() + + is_ci = os.environ.get("GITHUB_ACTIONS") == "true" + + cmd = [_tool("pytest")] + if is_ci: + cmd.append("--cov-report=json:coverage.json") + + rc = _run(cmd, "Pytest") + + if is_ci: + _write_coverage_summary() + + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/check_types.py b/.github/scripts/check_types.py new file mode 100644 index 0000000..c845f9a --- /dev/null +++ b/.github/scripts/check_types.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def _load_pyproject() -> dict[str, Any]: + path = Path("pyproject.toml") + if not path.exists(): + return {} + with open(path, "rb") as f: + return tomllib.load(f) + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run mypy type checking.") + parser.add_argument("--paths", nargs="+", help="Override source paths to check") + args = parser.parse_args() + + pyproject = _load_pyproject() + paths = args.paths or pyproject.get("tool", {}).get("ruff", {}).get("src", ["src"]) + + return _run([_tool("mypy"), *paths], "Mypy") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/qa.py b/.github/scripts/qa.py new file mode 100644 index 0000000..874b54c --- /dev/null +++ b/.github/scripts/qa.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template +"""Local QA orchestrator. Discovers and runs all check_*.py scripts. + +Usage: + python scripts/qa.py [--fix] [--skip name ...] +""" + +from __future__ import annotations + +import argparse +import glob +import shutil +import subprocess +import sys +import time +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = SCRIPT_DIR.parent + + +# --------------------------------------------------------------------------- +# pyproject.toml helpers (stdlib only) +# --------------------------------------------------------------------------- + + +def _has_build_system() -> bool: + """Return True if pyproject.toml contains a [build-system] section.""" + pyproject = PROJECT_ROOT / "pyproject.toml" + if not pyproject.exists(): + return False + try: + text = pyproject.read_text(encoding="utf-8") + except OSError: + return False + for line in text.splitlines(): + stripped = line.strip() + if stripped == "[build-system]": + return True + return False + + +# --------------------------------------------------------------------------- +# Check execution +# --------------------------------------------------------------------------- + + +def _short_name(script_path: Path) -> str: + """Derive the short check name from a script filename. + + check_lint.py -> lint + check_types.py -> types + """ + stem = script_path.stem # e.g. "check_lint" + if stem.startswith("check_"): + return stem[len("check_") :] + return stem + + +def _run_check( + script: Path, + extra_args: list[str] | None = None, +) -> tuple[int, float]: + """Run a single check script and return (exit_code, duration_seconds).""" + name = _short_name(script) + print(f"\n{'=' * 60}") + print(f" Running: {name}") + print(f"{'=' * 60}\n") + + start = time.monotonic() + result = subprocess.run( + [sys.executable, str(script), *(extra_args or [])], + cwd=PROJECT_ROOT, + ) + duration = time.monotonic() - start + return result.returncode, duration + + +# --------------------------------------------------------------------------- +# External tool helpers +# --------------------------------------------------------------------------- + + +def _run_external_tool( + name: str, + cmd: list[str], +) -> tuple[int, float]: + """Run an external tool and return (exit_code, duration_seconds).""" + print(f"\n{'=' * 60}") + print(f" Running: {name}") + print(f"{'=' * 60}\n") + + start = time.monotonic() + result = subprocess.run(cmd, cwd=PROJECT_ROOT) + duration = time.monotonic() - start + return result.returncode, duration + + +def _find_files(pattern: str) -> list[str]: + """Glob for files relative to PROJECT_ROOT.""" + return sorted(glob.glob(pattern, root_dir=str(PROJECT_ROOT), recursive=True)) + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + + +def _print_summary( + results: list[tuple[str, str, str]], + section_title: str = "QA Summary", +) -> int: + """Print a formatted summary table. + + *results* is a list of (name, status, duration_str) tuples. + Returns the number of FAILed checks. + """ + col_name = max(len(r[0]) for r in results) if results else 5 + col_name = max(col_name, 5) # minimum width + col_status = 6 # "RESULT" / "PASS" / "FAIL" / "SKIP" + col_dur = 8 + + bar = "=" * 40 + print(f"\n{bar}") + print(f" {section_title}") + print(bar) + header = f" {'Check':<{col_name}} {'Result':<{col_status}} {'Duration':<{col_dur}}" + sep = f" {'-' * col_name} {'-' * col_status} {'-' * col_dur}" + print(header) + print(sep) + for name, status, dur in results: + print(f" {name:<{col_name}} {status:<{col_status}} {dur:<{col_dur}}") + + failures = [r for r in results if r[1] == "FAIL"] + ran = [r for r in results if r[1] != "SKIP"] + print(bar) + if failures: + print(f" Result: FAIL ({len(failures)} of {len(ran)} checks failed)") + else: + print(f" Result: PASS ({len(ran)} of {len(ran)} checks passed)") + print(bar) + return len(failures) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run all local QA checks.") + parser.add_argument( + "--fix", + action="store_true", + help="Pass --fix to check_lint.py and check_spelling.py", + ) + parser.add_argument( + "--skip", + action="append", + default=[], + metavar="NAME", + help="Skip a check by short name (e.g. --skip package). Can be repeated.", + ) + args = parser.parse_args() + + skips: set[str] = {s.lower() for s in args.skip} + + # Auto-skip check_package when there is no [build-system] + if not _has_build_system(): + skips.add("package") + + # ----------------------------------------------------------------- + # Discover check scripts + # ----------------------------------------------------------------- + check_scripts = sorted(SCRIPT_DIR.glob("check_*.py")) + + check_results: list[tuple[str, str, str]] = [] + for script in check_scripts: + name = _short_name(script) + if name in skips: + check_results.append((name, "SKIP", "-")) + continue + + # Determine extra args + extra: list[str] = [] + if args.fix and name in ("lint", "spelling"): + extra.append("--fix") + + exit_code, duration = _run_check(script, extra) + status = "PASS" if exit_code == 0 else "FAIL" + check_results.append((name, status, f"{duration:.1f}s")) + + # ----------------------------------------------------------------- + # External tools + # ----------------------------------------------------------------- + external_results: list[tuple[str, str, str]] = [] + + externals: list[tuple[str, str, list[str] | None]] = [] + + # shellcheck + sh_files = _find_files("**/*.sh") + if sh_files: + externals.append( + ( + "shellcheck", + "shellcheck", + ["shellcheck", *sh_files], + ) + ) + else: + externals.append(("shellcheck", "shellcheck", None)) + + # markdownlint-cli2 + md_files = _find_files("**/*.md") + if md_files: + externals.append( + ( + "markdownlint", + "markdownlint-cli2", + ["markdownlint-cli2", *md_files], + ) + ) + else: + externals.append(("markdownlint", "markdownlint-cli2", None)) + + # actionlint + yml_files = _find_files(".github/workflows/*.yml") + if yml_files: + externals.append(("actionlint", "actionlint", ["actionlint"])) + else: + externals.append(("actionlint", "actionlint", None)) + + for name, binary, cmd in externals: + if shutil.which(binary) is None: + external_results.append((name, "SKIP", "-")) + continue + if cmd is None: + # Tool exists but no matching files + external_results.append((name, "SKIP", "-")) + continue + exit_code, duration = _run_external_tool(name, cmd) + status = "PASS" if exit_code == 0 else "FAIL" + external_results.append((name, status, f"{duration:.1f}s")) + + # ----------------------------------------------------------------- + # Summary + # ----------------------------------------------------------------- + failures = _print_summary(check_results, "QA Summary") + + if external_results: + ext_failures = _print_summary(external_results, "External Tools") + failures += ext_failures + + return 1 if failures > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.github/scripts/setup.ps1 b/.github/scripts/setup.ps1 new file mode 100644 index 0000000..f161560 --- /dev/null +++ b/.github/scripts/setup.ps1 @@ -0,0 +1,78 @@ +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $PSCommandPath +$ProjectRoot = if ($env:PROJECT_ROOT) { $env:PROJECT_ROOT } else { Split-Path -Parent $ScriptDir } + +Write-Host "Project root: $ProjectRoot" +Set-Location $ProjectRoot + +# --------------------------------------------------------------------------- +# Detect toolchain +# --------------------------------------------------------------------------- +if (Test-Path 'uv.lock') { + Write-Host '' + Write-Host 'Detected uv.lock — using uv toolchain.' + Write-Host '' + + & uv venv .venv + if ($LASTEXITCODE -ne 0) { throw 'uv venv failed.' } + + & uv sync + if ($LASTEXITCODE -ne 0) { throw 'uv sync failed.' } + + Write-Host '' + Write-Host 'Setup complete (uv).' + Write-Host ' Activate: .venv\Scripts\Activate.ps1' +} +else { + Write-Host '' + Write-Host 'No uv.lock found — using pip + venv toolchain.' + Write-Host '' + + & python -m venv .venv + if ($LASTEXITCODE -ne 0) { throw 'Failed to create virtual environment.' } + + $VenvPython = Join-Path $ProjectRoot '.venv\Scripts\python.exe' + + & $VenvPython -m pip install --upgrade pip + if ($LASTEXITCODE -ne 0) { throw 'Failed to upgrade pip.' } + + # Check for [project.optional-dependencies] dev in pyproject.toml + $HasDevExtras = $false + $PyprojectPath = Join-Path $ProjectRoot 'pyproject.toml' + + if (Test-Path $PyprojectPath) { + $InSection = $false + foreach ($Line in (Get-Content $PyprojectPath)) { + $Trimmed = $Line.Trim() + if ($Trimmed -eq '[project.optional-dependencies]') { + $InSection = $true + continue + } + if ($InSection -and $Trimmed -match '^\[') { + $InSection = $false + } + if ($InSection -and $Trimmed -match '^dev\s*=') { + $HasDevExtras = $true + break + } + } + } + + if ($HasDevExtras) { + Write-Host 'Installing package with dev extras...' + & $VenvPython -m pip install -e '.[dev]' + if ($LASTEXITCODE -ne 0) { throw 'Failed to install package with dev extras.' } + } + else { + Write-Host 'Installing package (no dev extras detected)...' + & $VenvPython -m pip install -e . + if ($LASTEXITCODE -ne 0) { throw 'Failed to install package.' } + } + + Write-Host '' + Write-Host 'Setup complete (pip + venv).' + Write-Host " Activate: .venv\Scripts\Activate.ps1" +} diff --git a/.github/scripts/setup.sh b/.github/scripts/setup.sh new file mode 100644 index 0000000..dc4144e --- /dev/null +++ b/.github/scripts/setup.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +echo "Project root: ${PROJECT_ROOT}" +cd "$PROJECT_ROOT" + +# --------------------------------------------------------------------------- +# Detect toolchain +# --------------------------------------------------------------------------- +if [ -f "uv.lock" ]; then + echo "" + echo "Detected uv.lock — using uv toolchain." + echo "" + + uv venv .venv + uv sync + + echo "" + echo "Setup complete (uv)." + echo " Activate: source .venv/bin/activate" +else + echo "" + echo "No uv.lock found — using pip + venv toolchain." + echo "" + + python3 -m venv .venv + + .venv/bin/python -m pip install --upgrade pip + + # Check for [project.optional-dependencies] dev in pyproject.toml + HAS_DEV_EXTRAS=false + if [ -f "pyproject.toml" ]; then + if grep -qE '^\[project\.optional-dependencies\]' pyproject.toml; then + # Look for a "dev" key after the section header + if awk ' + /^\[project\.optional-dependencies\]/ { in_section=1; next } + /^\[/ { in_section=0 } + in_section && /^[[:space:]]*dev[[:space:]]*=/ { found=1; exit } + END { exit !found } + ' pyproject.toml 2>/dev/null; then + HAS_DEV_EXTRAS=true + fi + fi + fi + + if [ "$HAS_DEV_EXTRAS" = true ]; then + echo "Installing package with dev extras..." + .venv/bin/python -m pip install -e ".[dev]" + else + echo "Installing package (no dev extras detected)..." + .venv/bin/python -m pip install -e . + fi + + echo "" + echo "Setup complete (pip + venv)." + echo " Activate: source .venv/bin/activate" +fi diff --git a/.github/workflows/auto-release.yml b/.github/workflows/auto-release.yml new file mode 100644 index 0000000..afe2149 --- /dev/null +++ b/.github/workflows/auto-release.yml @@ -0,0 +1,41 @@ +name: Auto Release + +on: + push: + branches: [main] + paths: + - 'scripts/**' + +permissions: + contents: write + +jobs: + release: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Determine next version + id: version + shell: bash + run: | + LATEST=$(git tag --sort=-v:refname --list 'v*' | head -1) + if [ -z "$LATEST" ]; then + echo "tag=v1.0.0" >> "$GITHUB_OUTPUT" + else + MAJOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f1) + MINOR=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f2) + PATCH=$(echo "$LATEST" | sed 's/^v//' | cut -d. -f3) + echo "tag=v${MAJOR}.${MINOR}.$((PATCH + 1))" >> "$GITHUB_OUTPUT" + fi + echo "Resolved next version: $(cat "$GITHUB_OUTPUT" | grep tag)" + + - name: Create release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + gh release create "${{ steps.version.outputs.tag }}" \ + --title "${{ steps.version.outputs.tag }}" \ + --generate-notes diff --git a/.github/workflows/python-qa.yml b/.github/workflows/python-qa.yml new file mode 100644 index 0000000..85d3cd2 --- /dev/null +++ b/.github/workflows/python-qa.yml @@ -0,0 +1,211 @@ +name: Python QA + +on: + workflow_call: + inputs: + python-min: + description: Minimum Python version + type: string + default: "3.11" + python-max: + description: Maximum Python version + type: string + default: "3.14" + full-os-matrix: + description: Run full 3-OS matrix or Ubuntu-only + type: boolean + default: true + run-package-check: + description: Run the packaging gate + type: boolean + default: true + +permissions: + contents: read + +jobs: + lint: + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(inputs.full-os-matrix && '["ubuntu-latest","windows-latest","macos-latest"]' || '["ubuntu-latest"]') }} + python: ["${{ inputs.python-min }}", "${{ inputs.python-max }}"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python }} + - name: Install uv when uv.lock is present + if: ${{ hashFiles('uv.lock') != '' }} + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + - name: Setup environment + shell: bash + if: runner.os != 'Windows' + run: bash scripts/setup.sh + - name: Setup environment (Windows) + shell: pwsh + if: runner.os == 'Windows' + run: .\scripts\setup.ps1 + - name: Activate venv + shell: bash + if: runner.os != 'Windows' + run: echo "${{ github.workspace }}/.venv/bin" >> "$GITHUB_PATH" + - name: Activate venv (Windows) + shell: pwsh + if: runner.os == 'Windows' + run: echo "${{ github.workspace }}\.venv\Scripts" | Out-File -FilePath $env:GITHUB_PATH -Append + - name: Run lint check + run: python scripts/check_lint.py + + types: + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(inputs.full-os-matrix && '["ubuntu-latest","windows-latest","macos-latest"]' || '["ubuntu-latest"]') }} + python: ["${{ inputs.python-min }}", "${{ inputs.python-max }}"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python }} + - name: Install uv when uv.lock is present + if: ${{ hashFiles('uv.lock') != '' }} + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + - name: Setup environment + shell: bash + if: runner.os != 'Windows' + run: bash scripts/setup.sh + - name: Setup environment (Windows) + shell: pwsh + if: runner.os == 'Windows' + run: .\scripts\setup.ps1 + - name: Activate venv + shell: bash + if: runner.os != 'Windows' + run: echo "${{ github.workspace }}/.venv/bin" >> "$GITHUB_PATH" + - name: Activate venv (Windows) + shell: pwsh + if: runner.os == 'Windows' + run: echo "${{ github.workspace }}\.venv\Scripts" | Out-File -FilePath $env:GITHUB_PATH -Append + - name: Run type check + run: python scripts/check_types.py + + tests: + strategy: + fail-fast: false + matrix: + os: ${{ fromJSON(inputs.full-os-matrix && '["ubuntu-latest","windows-latest","macos-latest"]' || '["ubuntu-latest"]') }} + python: ["${{ inputs.python-min }}", "${{ inputs.python-max }}"] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ matrix.python }} + - name: Install uv when uv.lock is present + if: ${{ hashFiles('uv.lock') != '' }} + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + - name: Setup environment + shell: bash + if: runner.os != 'Windows' + run: bash scripts/setup.sh + - name: Setup environment (Windows) + shell: pwsh + if: runner.os == 'Windows' + run: .\scripts\setup.ps1 + - name: Activate venv + shell: bash + if: runner.os != 'Windows' + run: echo "${{ github.workspace }}/.venv/bin" >> "$GITHUB_PATH" + - name: Activate venv (Windows) + shell: pwsh + if: runner.os == 'Windows' + run: echo "${{ github.workspace }}\.venv\Scripts" | Out-File -FilePath $env:GITHUB_PATH -Append + - name: Run tests + run: python scripts/check_tests.py + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ inputs.python-min }} + - name: Install uv when uv.lock is present + if: ${{ hashFiles('uv.lock') != '' }} + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + - name: Setup environment + shell: bash + run: bash scripts/setup.sh + - name: Activate venv + shell: bash + run: echo "${{ github.workspace }}/.venv/bin" >> "$GITHUB_PATH" + - name: Run security check + run: python scripts/check_security.py + + spelling: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ inputs.python-min }} + - name: Install uv when uv.lock is present + if: ${{ hashFiles('uv.lock') != '' }} + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + - name: Setup environment + shell: bash + run: bash scripts/setup.sh + - name: Activate venv + shell: bash + run: echo "${{ github.workspace }}/.venv/bin" >> "$GITHUB_PATH" + - name: Run spelling check + run: python scripts/check_spelling.py + + package: + if: inputs.run-package-check + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: ${{ inputs.python-min }} + - name: Install uv when uv.lock is present + if: ${{ hashFiles('uv.lock') != '' }} + uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 + - name: Setup environment + shell: bash + run: bash scripts/setup.sh + - name: Activate venv + shell: bash + run: echo "${{ github.workspace }}/.venv/bin" >> "$GITHUB_PATH" + - name: Run package check + run: python scripts/check_package.py + + ci-passed: + if: always() + needs: [lint, types, tests, security, spelling, package] + runs-on: ubuntu-latest + steps: + - name: Verify all checks passed + shell: bash + run: | + echo "Lint: ${{ needs.lint.result }}" + echo "Types: ${{ needs.types.result }}" + echo "Tests: ${{ needs.tests.result }}" + echo "Security: ${{ needs.security.result }}" + echo "Spelling: ${{ needs.spelling.result }}" + echo "Package: ${{ needs.package.result }}" + + if [[ "${{ needs.lint.result }}" != "success" ]] || \ + [[ "${{ needs.types.result }}" != "success" ]] || \ + [[ "${{ needs.tests.result }}" != "success" ]] || \ + [[ "${{ needs.security.result }}" != "success" ]] || \ + [[ "${{ needs.spelling.result }}" != "success" ]] || \ + [[ "${{ needs.package.result }}" != "success" && "${{ needs.package.result }}" != "skipped" ]]; then + echo "::error::One or more quality checks failed" + exit 1 + fi + echo "All quality checks passed" diff --git a/.github/workflows/self-update.yml b/.github/workflows/self-update.yml new file mode 100644 index 0000000..49d6d6d --- /dev/null +++ b/.github/workflows/self-update.yml @@ -0,0 +1,79 @@ +name: Self Update + +on: + schedule: + - cron: '0 2 * * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + check-and-update: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + fetch-depth: 0 + + - name: Check for new release + id: check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + LATEST=$(gh release view --json tagName -q .tagName 2>/dev/null || echo "") + CURRENT=$(cat .github/scripts/.version 2>/dev/null || echo "none") + echo "latest=$LATEST" >> "$GITHUB_OUTPUT" + echo "current=$CURRENT" >> "$GITHUB_OUTPUT" + if [ -n "$LATEST" ] && [ "$LATEST" != "$CURRENT" ]; then + echo "update=true" >> "$GITHUB_OUTPUT" + echo "New release detected: $LATEST (current: $CURRENT)" + else + echo "update=false" >> "$GITHUB_OUTPUT" + echo "No update needed (latest: ${LATEST:-none}, current: $CURRENT)" + fi + + - name: Download scripts from release + if: steps.check.outputs.update == 'true' + shell: bash + run: | + TAG="${{ steps.check.outputs.latest }}" + git fetch origin tag "$TAG" --no-tags + mkdir -p .github/scripts + git archive "$TAG" -- scripts/ | tar -x --strip-components=1 -C .github/scripts/ + echo "$TAG" > .github/scripts/.version + echo "Updated .github/scripts/ to $TAG" + + - name: Create pull request + if: steps.check.outputs.update == 'true' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + TAG="${{ steps.check.outputs.latest }}" + BRANCH="self-update/${TAG}" + + # Check if PR already exists for this release + EXISTING=$(gh pr list --head "$BRANCH" --json number -q '.[0].number' 2>/dev/null || echo "") + if [ -n "$EXISTING" ]; then + echo "PR #${EXISTING} already exists for ${TAG}, skipping" + exit 0 + fi + + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git checkout -b "$BRANCH" + git add .github/scripts/ + git commit -m "chore: update dogfood scripts to ${TAG}" + git push -u origin "$BRANCH" + + BODY="## Self-update" + BODY="${BODY}"$'\n\n'"Updates \`.github/scripts/\` to match the released scripts from \`${TAG}\`." + BODY="${BODY}"$'\n\n'"This PR was automatically created by the nightly self-update workflow." + BODY="${BODY}"$'\n'"The template repo dogfoods its own released scripts for CI validation." + + gh pr create \ + --title "chore: update dogfood scripts to ${TAG}" \ + --body "${BODY}" diff --git a/.github/workflows/sync-downstream.yml b/.github/workflows/sync-downstream.yml new file mode 100644 index 0000000..e4019cc --- /dev/null +++ b/.github/workflows/sync-downstream.yml @@ -0,0 +1,214 @@ +name: Sync Downstream + +on: + release: + types: [published] + workflow_dispatch: + inputs: + tag: + description: Release tag to sync + required: true + +permissions: + contents: read + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + + - name: Sync template files to downstream repos + env: + GH_TOKEN: ${{ secrets.TEMPLATE_SYNC_PAT }} + TAG: ${{ github.event.release.tag_name || inputs.tag }} + shell: bash + run: | + python3 << 'PYEOF' + import json + import os + import re + import subprocess + import sys + from pathlib import Path + + tag = os.environ["TAG"] + template_root = Path(os.environ["GITHUB_WORKSPACE"]) + manifest_path = template_root / "sync-manifest.json" + + if not manifest_path.exists(): + print("::error::sync-manifest.json not found in repository root") + sys.exit(1) + + with open(manifest_path) as f: + manifest = json.load(f) + + repos = manifest.get("downstream_repos", []) + files = manifest.get("files", []) + if not repos: + print("No downstream repos configured in sync-manifest.json") + sys.exit(0) + + def run(cmd, **kwargs): + print(f" $ {' '.join(cmd) if isinstance(cmd, list) else cmd}") + result = subprocess.run(cmd, capture_output=True, text=True, **kwargs) + if result.returncode != 0: + print(f" STDOUT: {result.stdout}") + print(f" STDERR: {result.stderr}") + return result + + def marker_preserve_copy(src: Path, dst: Path) -> None: + """Copy src to dst, preserving content outside template marker regions in dst.""" + if not dst.exists(): + # No existing file — straight copy + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(src.read_text()) + return + + src_text = src.read_text() + dst_text = dst.read_text() + + # Pattern matches: // #region Template: ... // #endregion Template: + region_pattern = re.compile( + r"(//\s*#region\s+Template:\s*\S+.*?\n)(.*?)(//\s*#endregion\s+Template:)", + re.DOTALL, + ) + + # Build a map of region name -> new content from the source + src_regions = {} + for match in region_pattern.finditer(src_text): + region_header = match.group(1) + region_content = match.group(2) + # Extract region name from header + name_match = re.search(r"Template:\s*(\S+)", region_header) + if name_match: + src_regions[name_match.group(1)] = region_content + + def replace_region(match): + region_header = match.group(1) + region_footer = match.group(3) + name_match = re.search(r"Template:\s*(\S+)", region_header) + if name_match and name_match.group(1) in src_regions: + return region_header + src_regions[name_match.group(1)] + region_footer + return match.group(0) + + result_text = region_pattern.sub(replace_region, dst_text) + dst.write_text(result_text) + + errors = [] + + for repo in repos: + print(f"\n{'='*60}") + print(f"Syncing to {repo}") + print(f"{'='*60}") + + work_dir = Path(f"/tmp/sync-{repo.replace('/', '-')}") + branch_name = f"template-sync/{tag}" + + # Clone downstream repo + result = run( + ["git", "clone", f"https://x-access-token:{os.environ['GH_TOKEN']}@github.com/{repo}.git", str(work_dir)] + ) + if result.returncode != 0: + print(f"::error::Failed to clone {repo}") + errors.append(repo) + continue + + # Configure git + run(["git", "config", "user.name", "template-sync[bot]"], cwd=work_dir) + run(["git", "config", "user.email", "template-sync[bot]@users.noreply.github.com"], cwd=work_dir) + + # Create sync branch + result = run(["git", "checkout", "-b", branch_name], cwd=work_dir) + if result.returncode != 0: + print(f"::warning::Branch {branch_name} may already exist in {repo}, skipping") + errors.append(repo) + continue + + changed_files = [] + + for file_mapping in files: + src_path = template_root / file_mapping["src"] + dst_path = work_dir / file_mapping["dest"] + mode = file_mapping.get("mode", "overwrite") + + if not src_path.exists(): + print(f" Warning: source file {file_mapping['src']} not found, skipping") + continue + + dst_path.parent.mkdir(parents=True, exist_ok=True) + + if mode == "marker-preserve": + marker_preserve_copy(src_path, dst_path) + else: + # Default: overwrite + dst_path.write_text(src_path.read_text()) + + changed_files.append(file_mapping["dest"]) + print(f" Copied: {file_mapping['src']} -> {file_mapping['dest']} (mode={mode})") + + if not changed_files: + print(f" No files changed for {repo}, skipping") + continue + + # Stage and commit + run(["git", "add", "-A"], cwd=work_dir) + + # Check if there are actual changes + diff_result = run(["git", "diff", "--cached", "--quiet"], cwd=work_dir) + if diff_result.returncode == 0: + print(f" No actual changes detected for {repo}, skipping") + continue + + commit_msg = f"chore: sync template files from python-template@{tag}" + run(["git", "commit", "-m", commit_msg], cwd=work_dir) + + # Push branch + result = run(["git", "push", "origin", branch_name], cwd=work_dir) + if result.returncode != 0: + print(f"::error::Failed to push branch to {repo}") + errors.append(repo) + continue + + # Build file list for PR body + file_list = "\n".join(f"- `{f}`" for f in changed_files) + + pr_body = f"""## Template Sync: {tag} + + This PR syncs template-managed files from [nwarila/python-template@{tag}](https://github.com/nwarila/python-template/releases/tag/{tag}). + + ### Changed files + {file_list} + + ### Release notes + See the [full release notes](https://github.com/nwarila/python-template/releases/tag/{tag}). + + --- + 🤖 Automated by [python-template sync](https://github.com/nwarila/python-template)""" + + # Create PR via gh CLI + result = run( + [ + "gh", "pr", "create", + "--repo", repo, + "--head", branch_name, + "--title", f"chore: template sync {tag}", + "--body", pr_body, + ] + ) + if result.returncode != 0: + print(f"::error::Failed to create PR in {repo}") + errors.append(repo) + else: + print(f" PR created: {result.stdout.strip()}") + + if errors: + print(f"\n::error::Sync failed for repos: {', '.join(errors)}") + sys.exit(1) + + print("\nAll downstream repos synced successfully") + PYEOF diff --git a/.github/workflows/template-ci.yml b/.github/workflows/template-ci.yml new file mode 100644 index 0000000..03e0cc4 --- /dev/null +++ b/.github/workflows/template-ci.yml @@ -0,0 +1,148 @@ +name: Template CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + dependency-review: + if: github.event_name == 'pull_request' + permissions: + contents: read + pull-requests: read + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: actions/dependency-review-action@2031cfc080254a8a887f58cffee85186f0e49e48 # v4.9.0 + + lint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-python + with: + python-version: "3.11" + - name: Run lint check + run: python .github/scripts/check_lint.py + + types: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-python + with: + python-version: "3.11" + - name: Run type check + run: python .github/scripts/check_types.py + + tests: + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-python + with: + python-version: "3.11" + - name: Run tests + run: python .github/scripts/check_tests.py + + security: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-python + with: + python-version: "3.11" + - name: Run security check + run: python .github/scripts/check_security.py + + spelling: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-python + with: + python-version: "3.11" + - name: Run spelling check + run: python .github/scripts/check_spelling.py + + package: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: ./.github/actions/setup-python + with: + python-version: "3.11" + - name: Run package check + run: python .github/scripts/check_package.py + + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Run shellcheck + run: shellcheck .github/scripts/setup.sh + + actionlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: reviewdog/action-actionlint@6fb7acc99f4a1008869fa8a0f09cfca740837d9d # v1.72.0 + with: + reporter: local + filter_mode: nofilter + fail_level: error + + markdownlint: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 # v23.0.0 + with: + globs: "**/*.md" + + ci-passed: + if: always() + needs: [dependency-review, lint, types, tests, security, spelling, package, shellcheck, actionlint, markdownlint] + runs-on: ubuntu-latest + steps: + - name: Verify all checks passed + shell: bash + run: | + echo "Dependency review: ${{ needs.dependency-review.result }}" + echo "Lint: ${{ needs.lint.result }}" + echo "Types: ${{ needs.types.result }}" + echo "Tests: ${{ needs.tests.result }}" + echo "Security: ${{ needs.security.result }}" + echo "Spelling: ${{ needs.spelling.result }}" + echo "Package: ${{ needs.package.result }}" + echo "Shellcheck: ${{ needs.shellcheck.result }}" + echo "Actionlint: ${{ needs.actionlint.result }}" + echo "Markdownlint: ${{ needs.markdownlint.result }}" + + if [[ "${{ needs.dependency-review.result }}" != "success" && "${{ needs.dependency-review.result }}" != "skipped" ]] || \ + [[ "${{ needs.lint.result }}" != "success" ]] || \ + [[ "${{ needs.types.result }}" != "success" ]] || \ + [[ "${{ needs.tests.result }}" != "success" ]] || \ + [[ "${{ needs.security.result }}" != "success" ]] || \ + [[ "${{ needs.spelling.result }}" != "success" ]] || \ + [[ "${{ needs.package.result }}" != "success" ]] || \ + [[ "${{ needs.shellcheck.result }}" != "success" ]] || \ + [[ "${{ needs.actionlint.result }}" != "success" ]] || \ + [[ "${{ needs.markdownlint.result }}" != "success" ]]; then + echo "::error::One or more quality checks failed" + exit 1 + fi + echo "All quality checks passed" diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5fb586a --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +** +# Allow the allowlist itself so tracked scope stays auditable. +!/.gitignore + +# Allow git attributes so normalization rules stay versioned. +!/.gitattributes + +# Allow core repo configuration and documentation. +!/.pre-commit-config.yaml +!/.markdownlint-cli2.jsonc +!/pyproject.toml +!/LICENSE +!/README.md +!/PLAN.md +!/sync-manifest.json + +# Allow the org-standard automation and reference directories. +!/.github/ +!/.github/** +!/reference/ +!/reference/** +!/scripts/ +!/scripts/** +!/tests/ +!/tests/** + +# Ignore generated artifacts even inside allowed directories. +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.venv/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.tmp/ +pytest-cache-files-*/ +.coverage +coverage.json +coverage.* +htmlcov/ diff --git a/.markdownlint-cli2.jsonc b/.markdownlint-cli2.jsonc new file mode 100644 index 0000000..db7eb88 --- /dev/null +++ b/.markdownlint-cli2.jsonc @@ -0,0 +1,14 @@ +// Managed by nwarila/python-template — do not edit manually. +// Source: https://github.com/nwarila/python-template +{ + "config": { + "MD013": false, + "MD033": false, + "MD034": false, + "MD041": false, + "MD060": false + }, + "ignores": [ + ".venv/**" + ] +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..286c00e --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,47 @@ +default_install_hook_types: [pre-commit, pre-push] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-json + - id: check-merge-conflict + - id: check-added-large-files + args: ["--maxkb=500"] + - id: detect-private-key + - id: no-commit-to-branch + args: ["--branch=main"] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.12 + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.16.0 + hooks: + - id: mypy + args: ["--strict"] + pass_filenames: false + entry: mypy scripts tests + + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.24 + hooks: + - id: validate-pyproject + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..d974f21 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,1172 @@ +# Python Template Plan + +## Mission + +Create `nwarila/python-template` and `nwarila/.github` as the two-layer +standard for every Python repository in the organization. + +- `.github` owns organization-wide governance, repository policy, workflow + templates, and non-language-specific automation. +- `python-template` owns the Python-specific developer experience, quality + gates, reusable workflows, and reference configuration. +- Each downstream repository owns its domain logic, package metadata, + product-specific workflows, and documentation. + +This is also a portfolio asset. The consistency, polish, and rigor across the +organization should be visible to recruiters before they read any source code. + +## Desired End State + +- Any engineer can move between Python repos and find the same setup flow, the + same QA commands, and the same CI contract. +- Local development, pre-commit, and CI all execute the same underlying checks. +- New repos can be brought to a "green" baseline quickly, with minimal custom + glue. +- Shared standard changes arrive in downstream repos as reviewable PRs, not as + hidden drift. +- Exceptions are explicit, versioned, and rare. +- Recruiters see stable checks, coverage visibility, clean READMEs, and + intentional engineering standards everywhere. + +## Guiding Principles + +- `pyproject.toml` is the center of gravity for package metadata and tool + configuration. +- Local must match CI. If the behavior differs, the template is wrong. +- Cross-platform support is a first-class requirement: Windows, macOS, and + Linux must all be considered during design, not patched later. +- The standard should be opinionated by default, but have documented and + auditable escape hatches. +- Template-owned files should stay small, generic, and stable. Repo-specific + concerns stay in the repo. +- One stable required status check should represent "the Python bar was met." +- Visible quality is part of product quality. + +## Standards Stack + +| Layer | Owns | Examples | +| --- | --- | --- | +| `.github` | Org-wide governance and workflow entry points | Community health files, issue/PR templates, ruleset guidance, workflow templates, dependency-review enforcement, markdown/action lint, link checking | +| `python-template` | Python-specific standard | Reusable Python QA workflow, setup action, QA scripts, reference `pyproject.toml`, pre-commit config, VSCode defaults, packaging/security/type/test policy | +| Downstream repo | Product-specific behavior | Package metadata, runtime dependencies, README, release workflow, deploy workflow, repo-specific tasks, repo-specific ignore rules | + +## Current Reality + +The repository is still partway through extraction from the resume project. + +- The checked-in scripts still assume they live under `.github/scripts`, while + the repo now stores them under `scripts/`. +- Multiple scripts and reference files still hardcode resume-specific package + names, paths, build flows, and artifacts. +- Only `actions/setup-python` exists today; the rest of the proposed shared CI + surface is still aspirational. +- The current plan assumes external composite actions will power CI, but that + conflicts with the stated goal that local and CI should run the exact same + downstream scripts. +- Ownership boundaries for syncable files versus repo-owned files are not yet + crisp enough to prevent merge friction. + +This revision tightens the architecture so the implementation backlog matches +the quality bar we are trying to set. + +## Recommended Architecture + +### Core decision + +Use reusable workflows for centrally maintained CI orchestration, and keep +composite actions small and tactical. + +Why this is the better fit: + +- GitHub's current documentation distinguishes reusable workflows from + composite actions: reusable workflows can contain multiple jobs, can use + secrets, and preserve step-level logging. +- GitHub also documents that when a reusable workflow in another repository + uses `actions/checkout`, it checks out the caller repository, not the called + repository. That means a centrally maintained workflow can still execute the + synced local scripts in the downstream repo. +- Composite actions still have value for small step bundles such as environment + bootstrap, but they should not be the main abstraction for our full CI + contract. + +### Resulting model + +1. `python-template` ships the canonical `scripts/` implementation. +2. Downstream repos sync the template-owned script/config files into their own + repository. +3. Local development runs those synced scripts directly. +4. CI in downstream repos calls a reusable workflow from `python-template`. +5. That reusable workflow checks out the downstream repo and runs the same + synced scripts that developers use locally. +6. Branch protection or rulesets target a single stable `ci-passed` check. + +### Repository shape + +```text +nwarila/python-template +├── .github/ +│ ├── actions/ +│ │ └── setup-python/ +│ │ └── action.yml # Small shared bootstrap action +│ ├── scripts/ # Released script copies for self-dogfooding +│ │ ├── .version # Tracks which release these scripts came from +│ │ └── (mirrors scripts/) +│ └── workflows/ +│ ├── auto-release.yml # Auto-creates a release when scripts/ changes +│ ├── python-qa.yml # Reusable workflow for downstream repos +│ ├── self-update.yml # Nightly: syncs .github/scripts/ from latest release +│ ├── sync-downstream.yml # Release-triggered sync to downstream repos +│ └── template-ci.yml # This repo's own CI (uses .github/scripts/) +├── scripts/ +│ ├── check_lint.py +│ ├── check_types.py +│ ├── check_tests.py +│ ├── check_security.py +│ ├── check_spelling.py +│ ├── check_package.py +│ ├── qa.py +│ ├── setup.sh +│ └── setup.ps1 +├── reference/ +│ ├── pyproject.toml +│ ├── pre-commit-config.yaml +│ ├── markdownlint-cli2.jsonc +│ ├── tasks.json +│ ├── settings.json +│ ├── extensions.json +│ ├── gitignore +│ ├── gitattributes +│ └── repo-ci.yml +├── sync-manifest.json # Source→dest mappings, ownership mode, merge strategy +└── README.md +``` + +## Standard Contract For Downstream Repositories + +### Required baseline + +Every Python repo that adopts the standard should have: + +- `pyproject.toml` +- A declared Python support range +- A clear source layout +- A test location +- An org-standard `.gitignore` that starts with `**` and explicitly allowlists tracked roots +- An org-standard `.gitattributes` baseline aligned with `.github` +- Synced `.github/scripts/` from `python-template` +- A `.pre-commit-config.yaml` +- A CI workflow that delegates to the shared reusable workflow or mirrors its + contract exactly + +### Preferred repository profile + +V1 should optimize for repos that have importable Python code under `src/`. +This includes libraries, CLIs, internal apps, and most automation projects. + +Script-only repos are still in scope, but should initially be supported via an +explicit opt-out of the packaging gate rather than through a separate script- +first architecture. + +### Configuration philosophy + +No custom config namespace. Scripts infer behavior from standard `pyproject.toml` +sections that repo admins already control: + +| Script behavior | Inferred from | Default when absent | +| --- | --- | --- | +| Source paths for lint/types | `[tool.ruff] src` | `["src"]` | +| Test paths | `[tool.pytest.ini_options] testpaths` | `["tests"]` | +| Coverage threshold | `[tool.pytest.ini_options] --cov-fail-under` | 90 | +| Strict typing | `[tool.mypy] strict` | `true` | +| Run package check | `[build-system]` section exists | Skip if absent | +| Smoke entry points | `[project.scripts]` section exists | Skip if absent | +| Codespell ignore list | `[tool.codespell] ignore-words-list` | Empty | + +CI matrix dimensions (Python versions, OS coverage) are controlled via +reusable workflow inputs — not pyproject.toml, since they are CI concerns. + +Local overrides use CLI arguments: `qa.py --skip package` or +`check_lint.py --fix`. Repo admins control their quality bar through the +standard tool config sections they already maintain. + +### Python support window policy + +The template should align its defaults to CPython's upstream support policy, +not to habit. + +- New repos should not launch on a minimum Python version with less than + 12 months of upstream support remaining. +- As of April 7, 2026, CPython 3.10 is already in security-fix-only support + and reaches end-of-life in October 2026, so the default floor for new repos + should remain `>=3.11`. +- Template defaults should be reviewed after each annual CPython feature + release and ratcheted forward intentionally, not ad hoc. +- Scheduled or non-required CI may include prerelease interpreters, but the + required branch-protection matrix should target supported stable versions. + +## Python Quality Standard + +| Concern | Standard direction | Notes | +| --- | --- | --- | +| Project metadata | `pyproject.toml` with `[build-system]`, `[project]`, and `[tool.*]` | Keep package metadata and QA config centralized | +| Source layout | Prefer `src/` for importable code | Reduces accidental imports from the repo root | +| Lint + format | Ruff owns both | Avoid parallel Black/isort/Flake8 duplication | +| Type checking | Mypy baseline, strict by default for new repos | Legacy repos may adopt in stages, but must ratchet upward | +| Tests | `pytest` with explicit `testpaths` and `--import-mode=importlib` for new repos | Keeps import behavior closer to installed reality | +| Coverage | Enforced threshold plus Actions job summary | New repos default to 90% | +| Security | `pip-audit` in CI plus GitHub dependency review on PRs | CodeQL belongs in `.github` policy, not a V1 blocker | +| Packaging | `validate-pyproject`, build wheel/sdist, `twine check`, optional entry-point smoke | Auto-enabled when `[build-system]` exists | +| Spelling | `codespell` | Repo-local `[tool.codespell]` controls ignore list | +| Hooks | `pre-commit` with `pre-commit` and `pre-push` installation | CI remains the source of truth | +| Editor DX | Shared VSCode settings/extensions/tasks where practical | Generic only; no repo-specific build logic in template-owned files | + +### Ruff rule set + +The org-standard ruff rule selection: + +```toml +[tool.ruff] +target-version = "py311" +line-length = 120 + +[tool.ruff.lint] +select = [ + "E", # pycodestyle errors + "F", # pyflakes + "W", # pycodestyle warnings + "I", # isort (import ordering) + "UP", # pyupgrade (modernize syntax) + "B", # flake8-bugbear (common bug patterns) + "S", # flake8-bandit (security) + "SIM", # flake8-simplify + "C4", # flake8-comprehensions (cleaner comprehensions) + "PT", # flake8-pytest-style (pytest best practices) + "T20", # flake8-print (no print statements in library code) + "RUF", # ruff-specific rules +] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] # assert is fine in tests +"scripts/**" = ["T20"] # print is fine in QA scripts +``` + +**Rationale**: `E`/`F`/`W` are baseline correctness. `I` ensures consistent +imports (ruff replaces isort). `UP` keeps code modern. `B` catches real bugs. +`S` aligns with the security-first philosophy. `SIM`/`C4` enforce clean +idiomatic Python. `PT` standardizes pytest usage. `T20` prevents debug prints +from reaching library code (QA scripts are excluded). `RUF` catches +ruff-specific issues. `line-length = 120` balances readability with modern +wide displays. + +### Coverage and typing policy + +The standard distinguishes greenfield from legacy adoption: + +- New repos: default to `strict = true` in `[tool.mypy]` and + `--cov-fail-under=90` in `[tool.pytest.ini_options]`. +- Existing repos: start from their real baseline if needed, but never lower the + threshold after adopting the standard. +- Any temporary waiver must be explicit, visible, and time-bounded. + +### Inference from current sources + +Official docs strongly support `pyproject.toml`, `src/` layout, Ruff, reusable +workflows, dependency review, and pre-commit. They also suggest that `uv` is +mature enough to pilot as the default developer toolchain, but not yet so +uniformly supported across the ecosystem that we should declare it final +without first validating it in pilot repos. + +## Toolchain Recommendation To Ratify + +### Proposed default for new repos + +- Use `uv` as the primary environment and dependency workflow. +- Keep `pyproject.toml` as the metadata source of truth. +- Use `[dependency-groups]` for local development groups if `uv` is ratified. +- Commit `uv.lock` for deterministic installs. +- Use wrapper scripts and VSCode tasks so developers interact with a stable + repo interface, not a moving tool CLI. + +### Why this is attractive + +- `uv` now has official guidance for GitHub Actions, lockfiles, dependency + groups, and dependency-bot integration. +- It reduces tool sprawl while still allowing export to `requirements.txt`, + `pylock.toml`, and CycloneDX when needed. +- It supports Windows, macOS, and Linux, which fits the org standard. + +### Why this is not locked yet + +- The `uv` docs explicitly note that dependency groups are standardized but not + yet supported by all tools. +- Dependabot support exists, but Astral's own docs still call out incomplete + scenarios. +- We should prove the workflow in at least two real repos before making it the + mandatory baseline. + +### Dependency graph and advisory visibility caveat + +`uv` looks strong as a developer workflow, but GitHub's current dependency- +graph documentation still describes Python support primarily around +`requirements.txt`, `pipenv`, and Poetry-style manifests. Before `uv` becomes +mandatory, pilot repos need to prove that the GitHub-native security surface is +still good enough: + +- Dependency graph visibility +- Dependabot alerts and updates +- Dependency review on pull requests +- Any needed lockfile or dependency-submission compatibility workarounds + +If the pilot exposes a gap, the standard should add a compensating mechanism +instead of hand-waving the gap away. + +## Sync And Ownership Model + +### Template-owned and synced + +These should be auto-updated by release-triggered PRs: + +- `scripts/**` +- `.pre-commit-config.yaml` +- `.markdownlint-cli2.jsonc` +- `.vscode/settings.json` +- `.vscode/extensions.json` + +### Sync-managed with marker-preserving merge + +These are synced by release-triggered PRs, but use region-delimited sections +to preserve repo-specific content: + +- `.vscode/tasks.json` — template-owned QA regions are replaced; repo-specific + task regions are preserved + +### Reference-only starters + +These define mandatory org-standard starting points for new repos, but are not +auto-overwritten after initial creation in V1 because repo-specific extensions +still need explicit review: + +- `reference/pyproject.toml` +- `reference/repo-ci.yml` +- `reference/gitignore` - starts with `**` and uses an explicit allowlist model +- `reference/gitattributes` - comment-rich normalization and diff baseline aligned with `.github` + +### Repo-owned + +These always remain local to the downstream repository: + +- `README.md` +- `LICENSE` +- Package name, description, runtime dependencies, and entry points +- Deployment and release workflows +- Product-specific VSCode tasks +- Repo-specific tracked-root allowlist additions in `.gitignore` +- Repo-specific binary or file-type additions in `.gitattributes` + +### Sync policy + +- Every sync PR must reference the source template release tag. +- Every sync PR must include migration notes when behavior changes. +- Every synced file should carry a lightweight "managed by template" header + comment where the file format allows it. +- High-churn files should remain reference-only unless we build a safe + marker-preserving merge strategy. +- Sync automation should read from a machine-readable manifest + (for example `sync-manifest.json`) that defines source path, destination + path, ownership mode, and merge strategy. The workflow should never rely on + a hardcoded path list buried in implementation code. + +## CI And Workflow Model + +### `.github` responsibilities + +The `.github` repository should provide: + +- Workflow templates that new repos can select from the GitHub UI +- Workflow-template metadata files (`.properties.json`) and `$default-branch` + placeholders where appropriate +- Issue and PR templates +- Community health files +- Ruleset guidance or enforcement for required workflows +- Shared non-language checks such as markdown lint, action lint, shell lint, + and link checking +- Dependency review as an org-level required workflow where appropriate + +### `python-template` responsibilities + +`python-template` should provide: + +- A reusable `python-qa.yml` workflow that runs against the caller repo +- A stable `ci-passed` aggregator job +- A small bootstrap action for Python environment setup if still useful +- The synced local scripts that implement the actual checks + +### Self-dogfooding model + +The template repo eats its own dog food by running CI against *released* +scripts rather than the development source. This ensures that new script +changes are validated by the same mechanism downstream repos use. + +**How it works:** + +1. `scripts/` is the development source for all check scripts and setup + scripts. +2. When changes to `scripts/` are merged to `main`, `auto-release.yml` + automatically creates a new patch release (auto-incrementing from the + latest tag). +3. A nightly scheduled workflow (`self-update.yml`) checks whether a new + release exists. When it detects one, it downloads the released scripts + into `.github/scripts/` and opens a pull request. +4. `template-ci.yml` runs all quality gates from `.github/scripts/` — the + released copies — not from `scripts/` directly. +5. The reusable workflow (`python-qa.yml`) and `sync-downstream.yml` continue + to reference `scripts/` because they operate on the source or distribute + it to downstream repos. + +**Why this matters:** + +- The template validates itself using the same artifacts it ships to consumers. +- Script regressions are caught before downstream repos receive them. +- The release-and-sync pipeline is exercised continuously, not only at + manually triggered milestones. + +**Bootstrap:** `.github/scripts/` is initially seeded from the current +`scripts/` directory. After the first release and nightly cycle, it is kept +in sync automatically. + +### Default CI policy + +- PR workflow: required, comprehensive, and still expected to stay fast for + this organization's current small repositories +- Main-branch or scheduled workflow: broader compatibility matrix when needed +- One required `ci-passed` check name across repos + +### Default matrix policy + +Current default: + +- PRs: 3 operating systems x min/max supported Python versions +- Main or scheduled: keep the same baseline, and optionally add prerelease or + extended compatibility jobs as non-required checks + +This is intentionally stricter than a typical "Ubuntu-only on PRs" baseline. +For this portfolio, the repos are small enough that the matrix cost is +acceptable and the cross-platform signal is part of the value proposition. + +### Security policy + +- Pin third-party GitHub Actions to full-length commit SHAs. +- Internal `python-template` reusable workflow references may use `@v1` as the + semver contract. +- Required job names must be unique across workflows to avoid ambiguous branch + protection behavior. +- Use Dependabot version updates for GitHub Actions and reusable workflow + references so SHA-pinned dependencies still move forward deliberately. +- Do not rely on GitHub security alerts alone for SHA-pinned actions; GitHub's + dependency-graph docs explicitly scope action alerts to semantic-versioned + refs rather than SHA pins. + +### Ruleset baseline + +Rulesets should be treated as the governance primitive for the organization. + +- Use repository-level rulesets everywhere they are available. +- Use organization-wide rulesets when the GitHub plan supports them; otherwise + document a repo-level baseline in `.github` and apply it consistently. +- The default protected-branch baseline should include: + - Require a pull request before merging + - Require status checks to pass before merging + - Require linear history + - Block force pushes + - Require code scanning results where CodeQL is enabled + - Require dependency review where the workflow exists +- Evaluate required signed commits separately after confirming bot, release, + and sync-automation compatibility. + +### Supply chain and release integrity + +For published artifacts, the standard should leave room for stronger supply- +chain signals than pass/fail CI alone. + +- Release workflows should be designed so SBOM export and provenance + attestations can be added cleanly. +- GitHub's attestation guidance makes reusable workflows especially valuable: + attestations alone provide SLSA Build Level 2, and shared reusable build + workflows help move toward Build Level 3. +- This does not need to block V1 for every repo, but the architecture should + not paint us into a corner. + +## Release And Compatibility Policy + +`python-template` is itself a product and needs a stable upgrade contract. + +- The template follows semantic versioning. +- Breaking changes ship only in major releases. +- Tightening a default quality gate, changing a managed file's shape, removing + a synced file, or changing script/workflow interfaces counts as a breaking + change unless explicitly backward-compatible. +- Deprecations must be announced at least one minor release before removal. +- Every release must include migration notes, managed-file impact, and any + required downstream action. +- Downstream repos may use `@v1` for normal consumption, but exact release tags + should remain easy to reference for investigations and rollback. + +## Implementation Plan + +### Phase 0: Ratify the contract + +- [ ] Finalize the architecture shift to reusable workflows for CI +- [ ] Codify `uv` as the pilot default and document the `pip` + `venv` + fallback path +- [ ] Document the inference rules: how scripts derive behavior from standard + `pyproject.toml` sections (no custom config namespace) +- [ ] Define the Python support policy relative to the CPython lifecycle +- [ ] Decide which files are sync-managed versus reference-only +- [ ] Decide the ruleset baseline and repo-level fallback if org-wide rulesets + are not available on the current GitHub plan +- [ ] Codify the default PR matrix and 90% greenfield coverage floor in the + reference config and docs + +Exit criteria: + +- The plan is internally consistent +- The repo contract is documented +- No major ownership ambiguity remains + +### Phase 1: Generic script foundation + +- [ ] Establish the inline config-reading pattern (stdlib `tomllib`, read + `[tool.ruff]`, `[tool.mypy]`, `[tool.pytest.ini_options]`, and + `[build-system]` sections) that each script will duplicate independently + — no shared module +- [ ] Remove every `.github/scripts` path assumption from the scripts +- [ ] Remove every resume-specific path, package name, and CLI assumption +- [ ] Standardize script CLI contracts (`--fix`, `--paths`, `--skip`, config + lookup, clean exit codes) +- [ ] Make `qa.py` auto-discover `check_*.py` scripts and honor repo profile + opt-outs +- [ ] Teach `check_package.py` to discover `[project.scripts]` and only run + entry-point smoke tests when appropriate +- [ ] Add coverage summary output to `$GITHUB_STEP_SUMMARY` + +Exit criteria: + +- A minimal smoke project can run the scripts locally on Windows, macOS, and + Linux +- No checked-in script contains resume-specific references +- The script interface is documented and stable + +### Phase 2: Workflow architecture + +- [ ] Add `.github/workflows/python-qa.yml` as the reusable downstream QA + workflow +- [ ] Add `.github/workflows/template-ci.yml` to dogfood the template itself +- [ ] Decide whether `actions/setup-python` remains the bootstrap action or is + replaced by a more general `setup-project` +- [ ] Add the machine-readable sync manifest and the release-triggered sync PR + workflow +- [ ] Add `qa-gate` behavior as a stable aggregator job +- [ ] Pin all third-party actions to full-length commit SHAs +- [ ] Add dependency review to the template repo's own PR workflow +- [ ] Add Dependabot version updates for GitHub Actions and reusable workflow + references +- [ ] Decide whether SBOM export and provenance attestations land in V1 or + immediately after V1 + +Exit criteria: + +- A downstream repo can call the reusable workflow and still execute its own + synced scripts +- The template repo enforces the same standards it asks others to adopt + +### Phase 3: Reference assets + +- [ ] Replace `reference/pyproject.toml` with a generic baseline +- [ ] Restructure `reference/tasks.json` with region-delimited template-owned + sections (setup, QA) and a clearly marked repo-specific region +- [ ] Replace `reference/settings.json` and `reference/extensions.json` with + Python-generic defaults +- [ ] Strip resume-specific content from `.gitignore`, `.gitattributes`, and + workflow examples +- [ ] Remove `reference/build-resume.yml`, + `reference/build-resumes-action.yml`, and `reference/release.yml` +- [ ] Ensure `reference/pre-commit-config.yaml` matches the chosen toolchain +- [ ] Ensure `reference/pyproject.toml` includes `--import-mode=importlib` in + pytest config and `testpaths = ["tests"]` as recommended defaults + +Exit criteria: + +- The reference directory can seed a new Python repo without leaking unrelated + project assumptions +- Every file in `reference/` is either generic or intentionally marked as + future work + +### Phase 4: Template self-validation + +- [ ] Lint and format all Python scripts +- [ ] Type-check all Python scripts +- [ ] Validate reusable workflow YAML and action metadata +- [ ] Markdown-lint the documentation +- [ ] Smoke-test the scripts against a generated minimal project +- [ ] Integration-test the reusable workflow against a sample caller workflow + +Exit criteria: + +- `python-template` is fully dogfooding itself +- The test suite validates both local and CI execution paths + +### Phase 5: Documentation and rollout + +- [ ] Write a polished `README.md` with quick start, architecture, adoption + guide, and migration guide +- [ ] Publish release notes that clearly distinguish breaking versus non- + breaking changes +- [ ] Add workflow templates in `.github` that call the shared reusable + workflow, including the required `.properties.json` metadata files +- [ ] Pilot the standard in `nwarila/resume` +- [ ] Pilot the standard in at least one additional Python repo with a + different profile +- [ ] Cut `v1.0.0` and maintain the floating `v1` tag + +Exit criteria: + +- Two real repos have adopted the standard successfully +- The docs are good enough that a future repo can onboard without tribal + knowledge +- The release and upgrade contract is proven + +## Definition Of Done For V1 + +V1 is complete when all of the following are true: + +- No template-owned file contains resume-specific logic or naming +- The script contract is stable and documented +- The reusable workflow runs the same downstream scripts that local developers + run +- The template repo passes its own full QA suite +- At least two pilot repos have adopted the standard successfully +- The org has a clear rule for required checks, dependency review, and workflow + templates +- The Python support policy is documented and avoids near-EOL interpreter + defaults +- Sync automation is manifest-driven and reviewable +- The release contract (`v1.0.0` plus floating `v1`) is documented and used + +## Risks And Mitigations + +- `uv` adoption risk + Mitigation: treat `uv` as a pilot default until two repos prove the workflow. + +- `uv` plus GitHub dependency-graph visibility gap + Mitigation: validate alerts, dependency review, and graph visibility during + pilot; add dependency submission or compatibility artifacts if needed. + +- Template drift versus repo customization + Mitigation: keep high-conflict files reference-only until we have a safe + merge strategy. + +- CI cost and slowness + Mitigation: separate required PR coverage from broader scheduled or + main-branch compatibility testing. + +- Strict typing friction in legacy repos + Mitigation: allow staged adoption, but require ratcheting and visible waivers. + +- Ambiguous required checks in GitHub + Mitigation: keep job names unique and route branch protection through one + stable `ci-passed` check. + +- Ruleset capability differs by GitHub plan + Mitigation: standardize the baseline behavior first, then implement it via + org-wide rulesets where available and repo-level rulesets where necessary. + +- Standards becoming performative instead of useful + Mitigation: keep local setup simple, logs readable, and failure messages + actionable. + +## Design Details + +### Script architecture + +Each `check_*.py` and `qa.py` must be: + +- **Fully standalone** — no shared module, no cross-script imports. Each script + is independently runnable. Duplicating small helper logic (config reading, + path resolution) across scripts is acceptable. +- **Stdlib-only** — scripts use only the Python standard library. They shell + out to tools (`ruff`, `mypy`, `pytest`, etc.) via `subprocess`. This avoids + polluting downstream dev dependencies with template infrastructure. + +Python was chosen because a single `.py` file runs on any OS. The scripts are +thin wrappers that read config from `pyproject.toml` (via `tomllib`, stdlib +since Python 3.11), resolve paths, invoke the tool, and report results. Each +script that reads pyproject.toml does so inline — the pattern is ~10 lines +and repeating it is cleaner than importing it. + +### Check dispatch in `qa.py` + +`qa.py` is the local orchestrator (for VSCode tasks and command-line use). It +auto-discovers `check_*.py` scripts in its directory and runs them +sequentially. It infers which checks to run from `pyproject.toml`: + +- If `[build-system]` is absent, `check_package.py` is skipped +- If `[project.scripts]` is absent, entry-point smoke tests are skipped +- CLI `--skip=` overrides for ad-hoc local runs (e.g., `--skip package`) + +`qa.py` is **not used in CI**. The reusable workflow runs each check as a +separate job for better Actions UI presentation. + +### Coverage summary generation + +`check_tests.py` needs to write a coverage table to `$GITHUB_STEP_SUMMARY`. +Since scripts are stdlib-only, the approach is: + +- Run pytest with `--cov-report=json:coverage.json --cov-report=term` +- Parse `coverage.json` with stdlib `json` module +- Write a markdown summary table to `$GITHUB_STEP_SUMMARY` (only when + `GITHUB_ACTIONS=true`) +- Clean up `coverage.json` after processing + +### Pre-commit convergence model + +Pre-commit hooks call tools directly (not wrapper scripts) because pre-commit +manages its own venvs per hook. `pyproject.toml` is the convergence point — +both hooks and scripts read the same `[tool.ruff]`, `[tool.mypy]`, and +`[tool.codespell]` config sections. The scripts add orchestration, summary +reporting, annotations, and `$GITHUB_STEP_SUMMARY` output that hooks don't +need. + +### `ci-passed` aggregator + +The `ci-passed` job lives inside the reusable `python-qa.yml` workflow, not as +a separate action. The reusable workflow owns the full contract: it runs all +check jobs and includes a final `ci-passed` job that `if: always()` evaluates +all upstream results. Downstream branch protection targets this single job name. + +### Reusable workflow structure + +The `python-qa.yml` reusable workflow runs each check as an **independent job** +so that every gate gets its own status icon, collapsible log section, and +pass/fail indicator in the PR checks UI. This maximizes reviewer clarity. + +**Jobs in the reusable workflow:** + +1. `lint` — runs `check_lint.py` across the matrix +2. `types` — runs `check_types.py` across the matrix +3. `tests` — runs `check_tests.py` across the matrix (writes coverage summary) +4. `security` — runs `check_security.py` (single OS is sufficient) +5. `spelling` — runs `check_spelling.py` (single OS is sufficient) +6. `package` — runs `check_package.py` (conditional, single OS) +7. `ci-passed` — aggregator, `if: always()`, evaluates all upstream results + +Each matrix job runs setup-python independently (jobs don't share state). For +small repos this overhead is negligible and the UI benefit is worth it. + +**Workflow inputs:** + +| Input | Default | Purpose | +| --- | --- | --- | +| `python-min` | `"3.11"` | Minimum Python version for matrix | +| `python-max` | `"3.14"` | Maximum Python version for matrix | +| `full-os-matrix` | `true` | Whether to run all 3 OS or Ubuntu-only | +| `run-package-check` | `true` | Whether to run the packaging gate | + +All quality-gate configuration (coverage threshold, strict typing, codespell +ignores) is read from the caller repo's `pyproject.toml` by the scripts at +runtime. The workflow interface stays stable even as the check contract evolves. + +### Setup script and `uv` compatibility + +`setup.sh` and `setup.ps1` need to handle both toolchains. The decision logic: + +1. If `uv.lock` exists in the project root, use `uv` +2. Otherwise, fall back to `python -m venv` + `pip` + +This is file-presence detection, not configuration — a repo that commits +`uv.lock` opts into `uv` automatically. The check scripts don't care which +path was taken; they run against the activated venv regardless. + +### Sync mechanism + +A workflow in `python-template` fires on `release: published`. It uses `gh` CLI +with a fine-grained Personal Access Token (stored as a repository secret) to +open PRs in downstream repos. The workflow: + +1. Reads `sync-manifest.json` for file mappings and the downstream repo list +2. For each downstream repo: + a. Clones the downstream repo + b. Creates a branch named `template-sync/v{release-tag}` + c. Copies fully-managed files (scripts, pre-commit, etc.) + d. Runs marker-preserving merge for `tasks.json` (a small inline Python + script finds `// #region` markers, replaces template-owned regions, + preserves repo-owned regions) + e. Updates managed-by-template headers with the new version + f. Opens a PR via `gh pr create` with a structured body: + - Release tag reference + - Link to release notes / changelog + - Migration notes if behavior changed + +The fine-grained PAT has `contents: write` and `pull_requests: write` scopes +on the listed downstream repos only. The downstream repo list is explicit in +`sync-manifest.json` — no topic-based discovery (too fragile). + +**`sync-manifest.json` schema:** + +```json +{ + "downstream_repos": ["nwarila/resume"], + "files": [ + { "src": "scripts/", "dest": "scripts/", "mode": "overwrite" }, + { "src": "reference/pre-commit-config.yaml", "dest": ".pre-commit-config.yaml", "mode": "overwrite" }, + { "src": "reference/markdownlint-cli2.jsonc", "dest": ".markdownlint-cli2.jsonc", "mode": "overwrite" }, + { "src": "reference/settings.json", "dest": ".vscode/settings.json", "mode": "overwrite" }, + { "src": "reference/extensions.json", "dest": ".vscode/extensions.json", "mode": "overwrite" }, + { "src": "reference/tasks.json", "dest": ".vscode/tasks.json", "mode": "marker-preserve" } + ] +} +``` + +### Managed-by-template file headers + +Synced files carry a header comment identifying their source: + +```python +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template +# Version: v1.2.3 +``` + +For JSONC files (`.vscode/settings.json`, etc.), use a JSONC comment at the +top of the file in the same format. + +### VSCode settings standard + +The org-standard `reference/settings.json` includes only universal settings: + +```jsonc +// Managed by nwarila/python-template — do not edit manually. +{ + // Python formatting + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.tabSize": 4, + "editor.rulers": [120] + }, + "[yaml]": { "editor.tabSize": 2 }, + "[toml]": { "editor.tabSize": 2 }, + + // File hygiene + "files.eol": "\n", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, + + // Search and explorer noise reduction + "search.exclude": { "**/__pycache__": true, "**/.venv": true, "**/dist": true, "**/*.egg-info": true }, + "files.exclude": { "**/__pycache__": true, "**/*.pyc": true }, + + // Python environment + "python.defaultInterpreterPath": "${workspaceFolder}/.venv", + + // Explicit folding regions + "explicitFolding.rules": { /* #region / //region patterns */ } +} +``` + +**Changes from current reference**: rulers move from 96/98 to 120 (matching +`line-length`). Resume-specific exclusions (`output/`, `data/`, etc.) are +removed — those belong in the downstream repo's own settings if needed. + +### Dependency review ownership + +`.github` enforces dependency review org-wide via rulesets or required +workflows. `python-template` also includes dependency review in its own +`template-ci.yml` for self-validation. These are complementary, not +conflicting — the template dogfoods what the org requires. + +## Resolved Decisions + +1. **`uv` becomes mandatory after pilot.** Scripts are venv-agnostic — they run + against an activated environment regardless of how it was created. Only + `setup.sh`/`setup.ps1` and the lock mechanism change. After two repos + prove the `uv` workflow, it becomes the default for new repos. A documented + fallback to `pip`+`venv` remains available. + +2. **Greenfield repos default to 90% coverage.** Achievable when tests are + written alongside code from day one, especially for the low-complexity, + single-function repos in this portfolio. Legacy repos adopt via ratchet-up, + never lowering the threshold after adoption. + +3. **`tasks.json` uses marker-preserving sync.** Template-owned regions + (delimited by `// #region` comments) are replaced by the sync PR. + Repo-specific tasks live in their own regions outside the managed blocks. + This preserves the "identical QA experience" promise while allowing + repo-specific build tasks. + +4. **Full 3-OS × min/max Python matrix on every PR.** Cross-platform-first is + a stated principle, the repos are small enough that 6 jobs are fast, and + the green matrix grid is a visible quality signal. Controlled by the + reusable workflow's `full-os-matrix` input (default `true`) so repos can + opt into a leaner matrix if needed. + +5. **Dependabot, not Renovate.** Native to GitHub, zero extra setup, already + established in `.github` repo for Actions updates. Simpler and more + "native" in a GitHub-centric portfolio. If Dependabot's `uv` support has + gaps during pilot, that's useful signal for the `uv` decision itself. + +6. **CodeQL lands now, owned by `.github`.** Independent of `python-template` + V1 — it's a workflow template in `.github`, not a python-template concern. + Adds security tab visibility and the "Code scanning" badge immediately. + +7. **`qa.py` runs checks sequentially.** Simpler output, easier to debug. + +8. **Reference configs live in `reference/` directory on main branch.** A + separate branch would be harder to discover and maintain. + +9. **No `Makefile`.** `qa.py` is cross-platform, VSCode tasks cover the IDE. + +10. **`.gitignore` and `.gitattributes` follow org-standard baselines aligned + with `.github`.** In V1 they remain reference-managed rather than + auto-synced, but every repo starts from the shared templates. The baseline + `.gitignore` begins with `**`, and repo-specific tracked roots are added + explicitly. + +11. **Coverage summary renders in `$GITHUB_STEP_SUMMARY`.** Visible quality + signal on every workflow run — not just pass/fail, but concrete numbers. + +12. **Mypy uses `strict = true` with pinned version.** Behavior stability + comes from the pinned mypy version in dev dependencies, not from + enumerating individual strict flags. + +13. **Pre-commit hooks call tools directly, not wrapper scripts.** `pyproject.toml` + is the convergence point for consistent flags. Scripts add orchestration, + annotations, and summary output that hooks don't need. + +14. **Scripts are standalone and stdlib-only.** No `_common.py`, no cross-script + imports, no third-party dependencies. Each `.py` file runs independently + with just the Python standard library. Python was chosen because one file + runs on any OS. + +15. **The default minimum Python version for new repos is 3.11.** As of + April 7, 2026, Python 3.10 reaches end-of-life in October 2026, so it is + too close to retirement to be the default floor for newly created repos. + +16. **Rulesets are the governance primitive.** Use organization-wide rulesets + when the GitHub plan supports them; otherwise apply the same baseline with + repository-level rulesets and document the fallback in `.github`. + +17. **Sync automation is manifest-driven.** Source-to-destination mappings, + ownership mode, and merge strategy live in a machine-readable manifest, not + in workflow code. + +18. **No custom `[tool.nwarila.template]` config.** Scripts infer behavior from + standard pyproject.toml sections (`[build-system]`, `[project.scripts]`, + `[tool.mypy]`, `[tool.pytest.ini_options]`, `[tool.ruff]`). Repo admins + control their quality bar through the tool configs they already maintain. + +19. **Reusable workflow runs each check as a separate job.** `qa.py` is the + local orchestrator only (for VSCode tasks). CI uses independent jobs per + check for better PR reviewer experience — each gate gets its own status + icon and collapsible log. + +20. **Ruff rule set: `E`/`F`/`W`/`I`/`UP`/`B`/`S`/`SIM`/`C4`/`PT`/`T20`/`RUF` + with `line-length = 120`.** Curated for correctness, security, modern + idioms, and clean pytest style. `T20` prevents debug prints in library code + (scripts are excluded). 120-char lines balance readability with modern + displays. + +21. **VSCode rulers at 120.** Matching ruff `line-length`. The previous 96/98 + rulers were resume-specific and are removed from the org standard. + +## Pilot Migration: `nwarila/resume` + +The resume repo is the first adopter and the original motivation for this +template. Here is the concrete migration path: + +### Files to delete from resume + +- `.github/scripts/` — entire directory (replaced by synced `scripts/`) +- `.github/actions/setup-python/` — replaced by the template's action or + reusable workflow's built-in setup +- Local copies of `check_lint.py`, `check_types.py`, etc. under `.github/` + +### Files to replace (via first sync PR) + +| Resume file | Replaced by | +| --- | --- | +| `.github/scripts/*.py` | `scripts/*.py` (synced from template) | +| `.pre-commit-config.yaml` | Synced from template (generic, no `python-docx` mypy dep) | +| `.vscode/settings.json` | Synced from template (rulers at 120, no resume-specific exclusions) | +| `.vscode/extensions.json` | Synced from template | +| `.vscode/tasks.json` | Synced with marker-preserve (QA regions from template, build regions kept) | +| `.markdownlint-cli2.jsonc` | Synced from template | + +### Files to rewrite + +- **`.github/workflows/repo-ci.yml`** — rewrite to call the reusable + `python-qa.yml` workflow from `python-template`. The resume-specific + `build-resumes` job stays as a repo-owned job. Structure: + + ```yaml + jobs: + python-qa: + uses: nwarila/python-template/.github/workflows/python-qa.yml@v1 + with: + python-min: "3.11" + python-max: "3.12" + + build-resumes: + needs: [python-qa] + # ... resume-specific build logic stays here ... + + # release job stays repo-owned + ``` + +- **`pyproject.toml`** — update tool config sections to match org standard: + - Ruff: add `C4`, `PT`, `T20` to `select`; update `src` paths to + `["src", "tests"]` (remove `.github/scripts`) + - Pytest: add `--import-mode=importlib`, update `--cov-fail-under=90` + - Mypy: already `strict = true`, just verify `python_version` + - Codespell: keep repo-specific `ignore-words-list` + - Remove resume-specific `python-docx` from mypy deps (it's a runtime dep, + not a mypy plugin) + +### Files that stay unchanged (repo-owned) + +- `README.md`, `LICENSE` +- `src/`, `tests/`, `data/`, `maps/`, `templates/` +- `.gitignore`, `.gitattributes` remain repo-local files after initial + adoption, but should preserve the org-standard structure and extend only in + repo-specific sections +- Release and build workflows (resume-specific) +- `pyproject.toml` `[project]` section (package metadata, runtime deps) + +### Migration checklist + +- [ ] Template V1 is released and tagged +- [ ] First sync PR is opened against resume +- [ ] Resume CI workflow is rewritten to call reusable workflow +- [ ] Resume pyproject.toml tool configs are aligned to org standard +- [ ] Resume passes the full quality gate via the reusable workflow +- [ ] Old `.github/scripts/` and `.github/actions/` are deleted +- [ ] Resume README is updated if it references old script paths + +## Research Anchors + +These sources directly informed the plan revision. + +- PyPA `pyproject.toml` guide + https://packaging.python.org/en/latest/guides/writing-pyproject-toml/ + Key takeaways: keep `[build-system]` present, use `[project]` for new + projects, and centralize tool config in `[tool.*]`. + +- PyPA `src` layout discussion + https://packaging.python.org/en/latest/discussions/src-layout-vs-flat-layout/ + Key takeaway: `src/` helps avoid accidental imports from the repo root and + better matches installed behavior. + +- PyPA dependency groups specification + https://packaging.python.org/en/latest/specifications/dependency-groups/ + Key takeaway: dependency groups are meant for local development needs and are + not included in built package metadata. + +- Python Developer's Guide: status of Python versions + https://devguide.python.org/versions/ + Key takeaways: as of April 7, 2026, Python 3.10 is in security-only support + and reaches end-of-life in October 2026; Python 3.11 remains supported until + October 2027, making `>=3.11` the more durable floor for new repos. + +- uv project and integration docs + https://docs.astral.sh/uv/ + https://docs.astral.sh/uv/concepts/projects/dependencies/ + https://docs.astral.sh/uv/concepts/projects/sync/ + https://docs.astral.sh/uv/guides/integration/github/ + https://docs.astral.sh/uv/guides/integration/dependabot/ + https://docs.astral.sh/uv/guides/integration/renovate/ + Key takeaways: `uv` now covers project management, lockfiles, GitHub Actions, + dependency groups, and dependency-bot integration; however, dependency-group + ecosystem support is still uneven enough to justify a pilot before making it + mandatory. + +- Ruff formatter docs + https://docs.astral.sh/ruff/formatter/ + Key takeaway: Ruff is intentionally a unified formatter and linter toolchain, + which supports the goal of reducing duplicated Python tooling. + +- pytest good practices + https://docs.pytest.org/en/stable/explanation/goodpractices.html + Key takeaways: use `pyproject.toml`, prefer `src/` layout, and use + `--import-mode=importlib` for new projects. + +- mypy command line docs + https://mypy.readthedocs.io/en/stable/command_line.html + Key takeaway: `--strict` enables a changing subset of optional checks, so if + we want long-term stability we may eventually prefer explicit strict flags + over a bare `strict = true`. + +- pre-commit docs + https://pre-commit.com/ + Key takeaways: `pre-commit run --all-files` is suitable for CI, and + `default_install_hook_types` supports installing both `pre-commit` and + `pre-push` hooks by default. + +- GitHub Actions reusable workflow docs + https://docs.github.com/en/actions/concepts/workflows-and-actions/reusing-workflow-configurations + https://docs.github.com/en/actions/how-tos/reuse-automations/reuse-workflows + Key takeaways: reusable workflows are centrally maintainable, preserve + step-level logs, support multiple jobs and secrets, and run actions in the + caller context. + +- GitHub workflow-template docs + https://docs.github.com/en/actions/how-tos/reuse-automations/create-workflow-templates + https://docs.github.com/en/actions/reference/workflows-and-actions/reusing-workflow-configurations + Key takeaways: organization workflow templates belong in the `.github` + repository, require matching `.properties.json` metadata files, and support + `$default-branch` placeholders. + +- GitHub secure use guidance + https://docs.github.com/en/actions/reference/security/secure-use + Key takeaway: third-party actions should be pinned to full-length commit SHAs. + +- GitHub rulesets docs + https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/about-rulesets + https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-rulesets/available-rules-for-rulesets + Key takeaways: rulesets are available for public repositories on GitHub Free, + organization-wide rulesets depend on plan level, and rulesets can require + pull requests, required checks, linear history, signed commits, and code + scanning results. + +- GitHub protected-branch and dependency-review docs + https://docs.github.com/en/repositories/configuring-branches-and-merges-in-your-repository/managing-protected-branches + https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/collaborating-on-repositories-with-code-quality-features/troubleshooting-required-status-checks + https://docs.github.com/en/code-security/concepts/supply-chain-security/about-dependency-review + Key takeaways: required job names must be unique, required checks must be + healthy recently enough to remain selectable, and dependency review can be + enforced at scale via rulesets. + +- GitHub dependency-graph and dependency-submission docs + https://docs.github.com/en/code-security/supply-chain-security/understanding-your-software-supply-chain/dependency-graph-supported-package-ecosystems + https://docs.github.com/en/code-security/reference/supply-chain-security/automatic-dependency-submission + Key takeaways: GitHub's dependency graph has explicit supported-ecosystem + rules, currently lists Python support around pip and Poetry manifests rather + than `uv.lock`, only generates GitHub Actions alerts for semantic-versioned + refs, and can be supplemented with automatic or manual dependency submission + when static analysis is incomplete. + +- GitHub Dependabot for Actions docs + https://docs.github.com/en/code-security/dependabot/working-with-dependabot/keeping-your-actions-up-to-date-with-dependabot + Key takeaway: Dependabot version updates can keep GitHub Actions and reusable + workflow references current even when the workflow files pin specific refs. + +- GitHub code-scanning and attestation docs + https://docs.github.com/en/enterprise-cloud@latest/code-security/concepts/code-scanning/setup-types + https://docs.github.com/en/actions/concepts/security/artifact-attestations + Key takeaways: GitHub recommends default CodeQL setup for eligible repos, and + reusable workflows combine well with artifact attestations for stronger + supply-chain posture. + +- pip-audit project docs + https://github.com/pypa/pip-audit + Key takeaways: `pip-audit` can scan local environments and lock-style inputs, + supports machine-readable output, and remains a good baseline dependency + vulnerability gate for Python repos. diff --git a/README.md b/README.md index e347b00..af3abac 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,149 @@ # python-template -Opinionated Python project template with CI/CD, linting, testing, and packaging best practices. + +Reusable Python quality-gate scripts, a reusable CI workflow, released script copies for self-dogfooding, and reference configurations that define a consistent developer experience across all Python repositories in the **nwarila** GitHub organization. + +This repo is the Python-specific layer of a two-layer governance model. For org-wide community health files, issue templates, and baseline CI, see [nwarila/.github](https://github.com/nwarila/.github). + +## Architecture + +| Layer | Repo | Responsibility | +| --- | --- | --- | +| Org governance | `nwarila/.github` | Community health files, issue and PR templates, baseline CI, workflow templates | +| Python QA | `nwarila/python-template` | Check scripts, reusable workflow, setup action, released script mirror, reference configs | + +Downstream Python repos consume both layers through different mechanisms. The `.github` repo provides defaults through GitHub's built-in inheritance; this repo ships Python-specific scripts and configs through tagged reusable workflows and release-triggered sync PRs. + +## What This Repo Provides + +- **Canonical QA scripts** in `scripts/` for linting, typing, tests, security, spelling, packaging, and local orchestration. +- **Released script mirror** in `.github/scripts/`, used by this repo's own CI so the template validates the same artifacts it ships. +- **Composite setup action** in `.github/actions/setup-python/` for Python plus dependency bootstrap. +- **Reusable CI workflow** in `.github/workflows/python-qa.yml` for downstream repositories. +- **Reference baselines** in `reference/`, including `pyproject.toml`, `.pre-commit-config.yaml`, VSCode settings, `gitignore`, `gitattributes`, and `repo-ci.yml`. +- **Release automation** through `auto-release.yml`, `self-update.yml`, and `sync-downstream.yml`. + +## Quality Gates + +| Check | Tool | Config Source | +| --- | --- | --- | +| Lint + Format | ruff | `[tool.ruff]` in `pyproject.toml` | +| Type Checking | mypy | `[tool.mypy]` in `pyproject.toml` | +| Tests + Coverage | pytest + pytest-cov | `[tool.pytest.ini_options]` in `pyproject.toml` | +| Security | pip-audit | Environment and dependency metadata | +| Spelling | codespell | `[tool.codespell]` in `pyproject.toml` | +| Packaging | build + twine | `[build-system]` in `pyproject.toml` | + +## Repository Structure + +```text +python-template/ +|-- .github/ +| |-- actions/ +| | `-- setup-python/ +| | `-- action.yml # Composite action for Python + dependency setup +| |-- scripts/ +| | |-- .version # Release tag currently mirrored into this directory +| | `-- ... # Released copies of the QA scripts +| `-- workflows/ +| |-- auto-release.yml # Creates a release when scripts/ changes land on main +| |-- python-qa.yml # Reusable CI workflow for downstream repos +| |-- self-update.yml # Refreshes .github/scripts/ from the latest release +| |-- sync-downstream.yml # Release-triggered sync PRs for downstream repos +| `-- template-ci.yml # CI for this repo, running released scripts +|-- reference/ +| |-- pyproject.toml # Reference project config +| |-- pre-commit-config.yaml # Pre-commit hook definitions +| |-- settings.json # VSCode editor settings +| |-- tasks.json # VSCode task definitions +| |-- extensions.json # VSCode recommended extensions +| |-- gitignore # Reference .gitignore +| |-- gitattributes # Reference .gitattributes +| |-- markdownlint-cli2.jsonc # Markdown lint config +| `-- repo-ci.yml # Starter CI workflow for downstream repos +|-- scripts/ +| |-- check_lint.py # ruff lint + format +| |-- check_types.py # mypy +| |-- check_tests.py # pytest + coverage +| |-- check_security.py # pip-audit +| |-- check_spelling.py # codespell +| |-- check_package.py # build + twine check +| |-- qa.py # Local orchestrator +| |-- setup.sh # Unix venv bootstrap +| `-- setup.ps1 # Windows venv bootstrap +|-- sync-manifest.json # Downstream repo list and source-to-dest mappings +|-- pyproject.toml # Config for this repo +`-- README.md +``` + +## How Downstream Repos Use It + +### CI + +Downstream repos copy `reference/repo-ci.yml` into `.github/workflows/ci.yml` and call the reusable workflow from a tagged release: + +```yaml +jobs: + python-qa: + uses: nwarila/python-template/.github/workflows/python-qa.yml@v1 +``` + +The reusable workflow runs each quality gate as a separate job and publishes a single stable `ci-passed` aggregator result. + +### Local Development + +Downstream repos run the synced `scripts/` directly: + +```bash +.venv/bin/python scripts/qa.py +.venv/bin/python scripts/qa.py --fix +.venv/bin/python scripts/qa.py --skip tests security +``` + +VSCode tasks in `reference/tasks.json` expose the same checks in the editor. + +### Pre-commit Hooks + +The synced `.pre-commit-config.yaml` calls the same tools with the same `pyproject.toml` configuration so issues are caught before code leaves the developer's machine. + +### Sync + +When this repo publishes a release, `sync-downstream.yml` reads `sync-manifest.json` and opens PRs in downstream repos to update template-owned files such as `scripts/` and reference configs. + +## Self-Dogfooding Model + +This repo tests the released form of the standard, not only the development source. + +1. `scripts/` is the canonical source of truth for the QA scripts. +2. When `scripts/` changes merge to `main`, `auto-release.yml` creates the next patch release. +3. `self-update.yml` downloads the latest released scripts into `.github/scripts/` and updates `.github/scripts/.version`. +4. `template-ci.yml` runs the checks from `.github/scripts/`, which mirrors what downstream repos receive from releases. +5. `python-qa.yml` and `sync-downstream.yml` continue to use `scripts/` as the distributable source. + +This keeps the release pipeline exercised continuously and helps catch drift between source scripts and shipped scripts. + +## Git Hygiene Standard + +The org-standard `.gitignore` uses an explicit allowlist model and starts with `**`, matching the control-plane style used in `nwarila/.github`. Repos intentionally allow tracked roots and keep generated artifacts ignored even inside allowed paths. + +The org-standard `.gitattributes` is comment-rich and standardized, defining LF normalization and markdown diff behavior in a format aligned with `nwarila/.github`. + +## Quick Start For A New Repo + +1. Copy the reference configs from `reference/` into your repo, including `reference/repo-ci.yml` as the starting CI workflow. +2. Extend `.gitignore` by allowlisting any repo-specific tracked roots beyond the standard baseline. +3. Customize `pyproject.toml` for your project metadata, dependencies, and entry points. +4. Add the repo to `sync-manifest.json` if it should receive automated sync PRs. +5. Call the reusable workflow from your repo's CI and install the dev tooling locally. + +## Design Principles + +- **Local must match CI.** The same scripts define the quality bar in both environments. +- **Scripts are standalone and stdlib-only.** Each check script shells out to configured tools without shared helper modules. +- **`pyproject.toml` is the center of gravity.** Tool configuration stays centralized instead of spreading across dotfiles. +- **Cross-platform first.** Setup scripts and QA scripts are designed for Linux, macOS, and Windows. +- **Git hygiene is standardized.** `.gitignore` and `.gitattributes` align with the org baseline. +- **Visible quality matters.** Separate jobs, clear logs, and consistent configs make the standard easy to review and trust. + +## License + +See [LICENSE](LICENSE). diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..3cc6401 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,52 @@ +[build-system] +requires = ["setuptools>=75.0"] +build-backend = "setuptools.build_meta" + +[tool.setuptools] +packages = [] + +[project] +name = "python-template" +version = "1.0.0" +description = "Reusable Python quality-gate scripts, workflows, and reference configurations." +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" + +[project.optional-dependencies] +dev = [ + "build", + "codespell", + "mypy>=1.16", + "pip-audit", + "pre-commit", + "pytest", + "pytest-cov", + "ruff>=0.11", + "twine", + "validate-pyproject", +] + +[tool.ruff] +target-version = "py311" +line-length = 120 +src = ["scripts", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "S", "SIM", "C4", "PT", "T20", "RUF"] + +[tool.ruff.lint.per-file-ignores] +"scripts/**" = ["T20", "S603", "S607"] +"tests/**" = ["S101"] + +[tool.mypy] +python_version = "3.11" +strict = true +files = ["scripts", "tests"] + +[tool.pytest.ini_options] +addopts = "-ra --import-mode=importlib --cov-fail-under=90" +testpaths = ["tests"] + +[tool.codespell] +skip = ".venv,dist,.git,.mypy_cache,.ruff_cache,.pytest_cache,reference" diff --git a/reference/extensions.json b/reference/extensions.json new file mode 100644 index 0000000..cf0a65d --- /dev/null +++ b/reference/extensions.json @@ -0,0 +1,14 @@ +// Managed by nwarila/python-template — do not edit manually. +// Source: https://github.com/nwarila/python-template +{ + "recommendations": [ + "charliermarsh.ruff", + "ms-python.python", + "ms-python.mypy-type-checker", + "redhat.vscode-yaml", + "tamasfe.even-better-toml", + "streetsidesoftware.code-spell-checker", + "GitHub.vscode-pull-request-github", + "zokugun.explicit-folding" + ] +} diff --git a/reference/gitattributes b/reference/gitattributes new file mode 100644 index 0000000..6c33340 --- /dev/null +++ b/reference/gitattributes @@ -0,0 +1,26 @@ +# Normalize tracked text files to LF for stable diffs across platforms. +* text=auto eol=lf + +# Keep markdown readable in diffs and consistently normalized. +*.md text eol=lf diff=markdown + +# Keep Python and shell automation normalized for reliable review and execution. +*.py text eol=lf +*.sh text eol=lf +*.ps1 text eol=lf + +# Keep structured config normalized so tooling behaves consistently. +*.yml text eol=lf +*.yaml text eol=lf +*.toml text eol=lf +*.json text eol=lf +*.jsonc text eol=lf + +# Keep ignore and attribute files themselves normalized for portability. +.gitignore text eol=lf +.gitattributes text eol=lf + +# Add project-specific binary types below. +# *.png binary +# *.pdf binary +# *.docx binary diff --git a/reference/gitignore b/reference/gitignore new file mode 100644 index 0000000..985050f --- /dev/null +++ b/reference/gitignore @@ -0,0 +1,60 @@ +** +# Allow the allowlist itself so tracked scope stays auditable. +!/.gitignore + +# Allow git attributes so normalization rules stay versioned. +!/.gitattributes + +# Allow core project configuration and documentation. +!/pyproject.toml +!/LICENSE +!/README.md +!/uv.lock +!/.markdownlint-cli2.jsonc +!/.pre-commit-config.yaml + +# Allow the org-standard automation and editor directories. +!/.github/ +!/.github/** +!/.vscode/ +!/.vscode/** + +# Allow the standard Python source and test roots. +!/src/ +!/src/** +!/tests/ +!/tests/** +!/scripts/ +!/scripts/** + +# Allow common documentation roots. +!/docs/ +!/docs/** + +# Add any repo-specific tracked roots below this line. +# !/data/ +# !/data/** +# !/assets/ +# !/assets/** +# !/templates/ +# !/templates/** + +# Ignore generated artifacts even inside allowed directories. +__pycache__/ +*.py[cod] +*$py.class +*.egg-info/ +dist/ +build/ +.venv/ +.mypy_cache/ +.pytest_cache/ +.ruff_cache/ +.tox/ +.nox/ +.coverage +coverage.json +coverage.* +htmlcov/ +.tmp/ +pytest-cache-files-*/ diff --git a/reference/markdownlint-cli2.jsonc b/reference/markdownlint-cli2.jsonc new file mode 100644 index 0000000..db7eb88 --- /dev/null +++ b/reference/markdownlint-cli2.jsonc @@ -0,0 +1,14 @@ +// Managed by nwarila/python-template — do not edit manually. +// Source: https://github.com/nwarila/python-template +{ + "config": { + "MD013": false, + "MD033": false, + "MD034": false, + "MD041": false, + "MD060": false + }, + "ignores": [ + ".venv/**" + ] +} diff --git a/reference/pre-commit-config.yaml b/reference/pre-commit-config.yaml new file mode 100644 index 0000000..ba02e50 --- /dev/null +++ b/reference/pre-commit-config.yaml @@ -0,0 +1,49 @@ +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template +default_install_hook_types: [pre-commit, pre-push] + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-json + - id: check-merge-conflict + - id: check-added-large-files + args: ["--maxkb=500"] + - id: detect-private-key + - id: no-commit-to-branch + args: ["--branch=main"] + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.11.12 + hooks: + - id: ruff + args: ["--fix"] + - id: ruff-format + + - repo: https://github.com/pre-commit/mirrors-mypy + rev: v1.16.0 + hooks: + - id: mypy + args: ["--strict"] + pass_filenames: false + entry: mypy src + + - repo: https://github.com/codespell-project/codespell + rev: v2.4.1 + hooks: + - id: codespell + + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.24 + hooks: + - id: validate-pyproject + + - repo: https://github.com/shellcheck-py/shellcheck-py + rev: v0.10.0.1 + hooks: + - id: shellcheck diff --git a/reference/pyproject.toml b/reference/pyproject.toml new file mode 100644 index 0000000..1ed5a9e --- /dev/null +++ b/reference/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["setuptools>=75.0"] +build-backend = "setuptools.build_meta" + +[project] +name = "my-project" +version = "0.1.0" +description = "A short project description." +readme = "README.md" +requires-python = ">=3.11" +license = "MIT" +# dependencies = [] + +[project.optional-dependencies] +dev = [ + "build", + "codespell", + "mypy>=1.16", + "pip-audit", + "pre-commit", + "pytest", + "pytest-cov", + "ruff>=0.11", + "twine", + "validate-pyproject", +] + +# [project.scripts] +# my-cli = "my_project.cli:main" + +[tool.ruff] +target-version = "py311" +line-length = 120 +src = ["src", "tests"] + +[tool.ruff.lint] +select = ["E", "F", "W", "I", "UP", "B", "S", "SIM", "C4", "PT", "T20", "RUF"] + +[tool.ruff.lint.per-file-ignores] +"tests/**" = ["S101"] +"scripts/**" = ["T20", "S603", "S607"] + +[tool.mypy] +python_version = "3.11" +strict = true + +[tool.pytest.ini_options] +addopts = "-ra --import-mode=importlib --cov=src --cov-report=term-missing --cov-fail-under=90" +testpaths = ["tests"] +pythonpath = ["src"] + +[tool.codespell] +skip = ".venv,dist,*.egg-info,.git,.mypy_cache,.pytest_cache,.ruff_cache" diff --git a/reference/repo-ci.yml b/reference/repo-ci.yml new file mode 100644 index 0000000..de03231 --- /dev/null +++ b/reference/repo-ci.yml @@ -0,0 +1,33 @@ +# Example CI workflow for a Python repo adopting nwarila/python-template. +# Copy to .github/workflows/ci.yml and customize as needed. +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +jobs: + python-qa: + uses: nwarila/python-template/.github/workflows/python-qa.yml@v1 + with: + python-min: "3.11" + python-max: "3.12" + # full-os-matrix: true # default + # run-package-check: true # default + + # Add repo-specific jobs below. For example: + # build: + # needs: [python-qa] + # runs-on: ubuntu-latest + # steps: + # - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + # - run: echo "Build step here" diff --git a/reference/settings.json b/reference/settings.json new file mode 100644 index 0000000..b69f66d --- /dev/null +++ b/reference/settings.json @@ -0,0 +1,54 @@ +// Managed by nwarila/python-template — do not edit manually. +// Source: https://github.com/nwarila/python-template +{ + // #region Template: Python ------------------------------------------------- + "[python]": { + "editor.defaultFormatter": "charliermarsh.ruff", + "editor.formatOnSave": true, + "editor.tabSize": 4, + "editor.rulers": [120] + }, + "[yaml]": { + "editor.tabSize": 2 + }, + "[toml]": { + "editor.tabSize": 2 + }, + // #endregion Template: Python + + // #region Template: File hygiene ------------------------------------------- + "files.eol": "\n", + "files.insertFinalNewline": true, + "files.trimTrailingWhitespace": true, + // #endregion Template: File hygiene + + // #region Template: Noise reduction ---------------------------------------- + "search.exclude": { + "**/__pycache__": true, + "**/.venv": true, + "**/dist": true, + "**/*.egg-info": true, + "**/.mypy_cache": true, + "**/.pytest_cache": true, + "**/.ruff_cache": true + }, + "files.exclude": { + "**/__pycache__": true, + "**/*.pyc": true + }, + // #endregion Template: Noise reduction + + // #region Template: Environment -------------------------------------------- + "python.defaultInterpreterPath": "${workspaceFolder}/.venv", + "mypy-type-checker.importStrategy": "fromEnvironment", + // #endregion Template: Environment + + // #region Template: Folding ------------------------------------------------ + "explicitFolding.rules": { + "*": { + "beginRegex": "(?:(?:#|//)\\s*#?region)\\b", + "endRegex": "(?:(?:#|//)\\s*#?endregion)\\b" + } + } + // #endregion Template: Folding +} diff --git a/reference/tasks.json b/reference/tasks.json new file mode 100644 index 0000000..28781ed --- /dev/null +++ b/reference/tasks.json @@ -0,0 +1,121 @@ +// Managed by nwarila/python-template — do not edit manually (template regions only). +// Source: https://github.com/nwarila/python-template +{ + "version": "2.0.0", + "tasks": [ + // #region Template: Setup ------------------------------------------------ + { + "label": "Setup: Create venv & install", + "type": "shell", + "command": "bash", + "args": ["scripts/setup.sh"], + "windows": { + "command": "powershell", + "args": ["-ExecutionPolicy", "Bypass", "-File", "scripts/setup.ps1"] + }, + "problemMatcher": [], + "group": "build" + }, + // #endregion Template: Setup + + // #region Template: QA Individual ---------------------------------------- + { + "label": "QA: Lint", + "type": "shell", + "command": ".venv/bin/python", + "args": ["scripts/check_lint.py"], + "windows": { "command": ".venv\\Scripts\\python.exe" }, + "problemMatcher": [], + "group": "test" + }, + { + "label": "QA: Lint (fix)", + "type": "shell", + "command": ".venv/bin/python", + "args": ["scripts/check_lint.py", "--fix"], + "windows": { "command": ".venv\\Scripts\\python.exe" }, + "problemMatcher": [], + "group": "test" + }, + { + "label": "QA: Type Check", + "type": "shell", + "command": ".venv/bin/python", + "args": ["scripts/check_types.py"], + "windows": { "command": ".venv\\Scripts\\python.exe" }, + "problemMatcher": [], + "group": "test" + }, + { + "label": "QA: Tests", + "type": "shell", + "command": ".venv/bin/python", + "args": ["scripts/check_tests.py"], + "windows": { "command": ".venv\\Scripts\\python.exe" }, + "problemMatcher": [], + "group": "test" + }, + { + "label": "QA: Security", + "type": "shell", + "command": ".venv/bin/python", + "args": ["scripts/check_security.py"], + "windows": { "command": ".venv\\Scripts\\python.exe" }, + "problemMatcher": [], + "group": "test" + }, + { + "label": "QA: Spelling", + "type": "shell", + "command": ".venv/bin/python", + "args": ["scripts/check_spelling.py"], + "windows": { "command": ".venv\\Scripts\\python.exe" }, + "problemMatcher": [], + "group": "test" + }, + { + "label": "QA: Spelling (fix)", + "type": "shell", + "command": ".venv/bin/python", + "args": ["scripts/check_spelling.py", "--fix"], + "windows": { "command": ".venv\\Scripts\\python.exe" }, + "problemMatcher": [], + "group": "test" + }, + { + "label": "QA: Package", + "type": "shell", + "command": ".venv/bin/python", + "args": ["scripts/check_package.py"], + "windows": { "command": ".venv\\Scripts\\python.exe" }, + "problemMatcher": [], + "group": "test" + }, + // #endregion Template: QA Individual + + // #region Template: QA Composite ----------------------------------------- + { + "label": "QA: All Checks", + "type": "shell", + "command": ".venv/bin/python", + "args": ["scripts/qa.py"], + "windows": { "command": ".venv\\Scripts\\python.exe" }, + "problemMatcher": [], + "group": "test" + }, + { + "label": "QA: Auto-fix All", + "type": "shell", + "command": ".venv/bin/python", + "args": ["scripts/qa.py", "--fix"], + "windows": { "command": ".venv\\Scripts\\python.exe" }, + "problemMatcher": [], + "group": "test" + }, + // #endregion Template: QA Composite + + // #region Repo-specific -------------------------------------------------- + // Add project-specific tasks here. This region is preserved during template sync. + // #endregion Repo-specific + ] +} diff --git a/scripts/check_lint.py b/scripts/check_lint.py new file mode 100644 index 0000000..dfe0989 --- /dev/null +++ b/scripts/check_lint.py @@ -0,0 +1,61 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def _load_pyproject() -> dict[str, Any]: + path = Path("pyproject.toml") + if not path.exists(): + return {} + with open(path, "rb") as f: + return tomllib.load(f) + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run ruff lint and format checks.") + parser.add_argument("--fix", action="store_true", help="Auto-fix lint issues and reformat") + parser.add_argument("--paths", nargs="+", help="Override source paths to check") + args = parser.parse_args() + + pyproject = _load_pyproject() + paths = args.paths or pyproject.get("tool", {}).get("ruff", {}).get("src", ["src"]) + + if args.fix: + rc1 = _run([_tool("ruff"), "check", "--fix", *paths], "Ruff Fix") + rc2 = _run([_tool("ruff"), "format", *paths], "Ruff Format") + else: + rc1 = _run([_tool("ruff"), "check", *paths], "Ruff Check") + rc2 = _run([_tool("ruff"), "format", "--check", *paths], "Ruff Format Check") + + return 1 if (rc1 or rc2) else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_package.py b/scripts/check_package.py new file mode 100644 index 0000000..102c426 --- /dev/null +++ b/scripts/check_package.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import glob +import os +import shutil +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def _load_pyproject() -> dict[str, Any]: + path = Path("pyproject.toml") + if not path.exists(): + return {} + with open(path, "rb") as f: + return tomllib.load(f) + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def _cleanup() -> None: + shutil.rmtree("dist", ignore_errors=True) + for egg_dir in glob.glob("*.egg-info"): + shutil.rmtree(egg_dir, ignore_errors=True) + for egg_dir in glob.glob("src/*.egg-info"): + shutil.rmtree(egg_dir, ignore_errors=True) + + +def main() -> int: + argparse.ArgumentParser(description="Validate package build, metadata, and entry points.").parse_args() + + pyproject = _load_pyproject() + + if "build-system" not in pyproject: + print("No [build-system] found, skipping package check") + return 0 + + entry_points = pyproject.get("project", {}).get("scripts", {}) + + try: + rc = _run([_tool("validate-pyproject"), "pyproject.toml"], "Validate pyproject.toml") + if rc != 0: + return rc + + rc = _run([sys.executable, "-m", "build"], "Build sdist+wheel") + if rc != 0: + return rc + + dist_files = glob.glob("dist/*") + if not dist_files: + is_ci = os.environ.get("GITHUB_ACTIONS") == "true" + print("::error::No dist files produced" if is_ci else "ERROR: No dist files produced") + return 1 + + rc = _run([_tool("twine"), "check", "--strict", *dist_files], "Twine Check") + if rc != 0: + return rc + + for name in entry_points: + tool_path = shutil.which(name) or _tool(name) + if shutil.which(name) is None and tool_path == name: + print(f" Entry point '{name}' not found on PATH, skipping smoke test") + continue + rc = _run([tool_path, "--help"], f"Entry point: {name} --help") + if rc != 0: + return rc + + finally: + _cleanup() + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_security.py b/scripts/check_security.py new file mode 100644 index 0000000..cf58375 --- /dev/null +++ b/scripts/check_security.py @@ -0,0 +1,38 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def main() -> int: + argparse.ArgumentParser(description="Run pip-audit for dependency vulnerability scanning.").parse_args() + + return _run([_tool("pip-audit"), "--skip-editable"], "Pip-Audit") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_spelling.py b/scripts/check_spelling.py new file mode 100644 index 0000000..30951c6 --- /dev/null +++ b/scripts/check_spelling.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +from pathlib import Path + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run codespell for typo detection.") + parser.add_argument("--fix", action="store_true", help="Auto-fix spelling mistakes") + args = parser.parse_args() + + cmd = [_tool("codespell")] + if args.fix: + cmd.append("--write-changes") + + return _run(cmd, "Codespell") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_tests.py b/scripts/check_tests.py new file mode 100644 index 0000000..dd24de3 --- /dev/null +++ b/scripts/check_tests.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +from pathlib import Path + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def _write_coverage_summary() -> None: + coverage_path = Path("coverage.json") + summary_path = os.environ.get("GITHUB_STEP_SUMMARY") + if not coverage_path.exists() or not summary_path: + return + + with open(coverage_path) as f: + data = json.load(f) + + lines = [ + "## Coverage Summary", + "", + "| Module | Statements | Missed | Coverage |", + "|--------|-----------|--------|----------|", + ] + + files = data.get("files", {}) + for module, info in sorted(files.items()): + summary = info.get("summary", {}) + stmts = summary.get("num_statements", 0) + missed = summary.get("missing_lines", 0) + covered = summary.get("percent_covered", 0.0) + lines.append(f"| {module} | {stmts} | {missed} | {covered:.1f}% |") + + totals = data.get("totals", {}) + total_stmts = totals.get("num_statements", 0) + total_missed = totals.get("missing_lines", 0) + total_covered = totals.get("percent_covered", 0.0) + lines.append(f"| **Total** | **{total_stmts}** | **{total_missed}** | **{total_covered:.1f}%** |") + + with open(summary_path, "a") as f: + f.write("\n".join(lines) + "\n") + + coverage_path.unlink() + + +def main() -> int: + argparse.ArgumentParser(description="Run pytest with coverage.").parse_args() + + is_ci = os.environ.get("GITHUB_ACTIONS") == "true" + + cmd = [_tool("pytest")] + if is_ci: + cmd.append("--cov-report=json:coverage.json") + + rc = _run(cmd, "Pytest") + + if is_ci: + _write_coverage_summary() + + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/check_types.py b/scripts/check_types.py new file mode 100644 index 0000000..c845f9a --- /dev/null +++ b/scripts/check_types.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template + +from __future__ import annotations + +import argparse +import os +import subprocess +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def _load_pyproject() -> dict[str, Any]: + path = Path("pyproject.toml") + if not path.exists(): + return {} + with open(path, "rb") as f: + return tomllib.load(f) + + +def _tool(name: str) -> str: + exe_dir = Path(sys.executable).resolve().parent + candidates = [exe_dir / name, exe_dir / f"{name}.exe"] + for candidate in candidates: + if candidate.exists(): + return str(candidate) + return name + + +def _run(cmd: list[str], label: str) -> int: + print(f"\n--- {label} ---") + result = subprocess.run(cmd) + if result.returncode != 0 and os.environ.get("GITHUB_ACTIONS") == "true": + print(f"::error::{label} failed with exit code {result.returncode}") + return result.returncode + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run mypy type checking.") + parser.add_argument("--paths", nargs="+", help="Override source paths to check") + args = parser.parse_args() + + pyproject = _load_pyproject() + paths = args.paths or pyproject.get("tool", {}).get("ruff", {}).get("src", ["src"]) + + return _run([_tool("mypy"), *paths], "Mypy") + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/qa.py b/scripts/qa.py new file mode 100644 index 0000000..874b54c --- /dev/null +++ b/scripts/qa.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template +"""Local QA orchestrator. Discovers and runs all check_*.py scripts. + +Usage: + python scripts/qa.py [--fix] [--skip name ...] +""" + +from __future__ import annotations + +import argparse +import glob +import shutil +import subprocess +import sys +import time +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +PROJECT_ROOT = SCRIPT_DIR.parent + + +# --------------------------------------------------------------------------- +# pyproject.toml helpers (stdlib only) +# --------------------------------------------------------------------------- + + +def _has_build_system() -> bool: + """Return True if pyproject.toml contains a [build-system] section.""" + pyproject = PROJECT_ROOT / "pyproject.toml" + if not pyproject.exists(): + return False + try: + text = pyproject.read_text(encoding="utf-8") + except OSError: + return False + for line in text.splitlines(): + stripped = line.strip() + if stripped == "[build-system]": + return True + return False + + +# --------------------------------------------------------------------------- +# Check execution +# --------------------------------------------------------------------------- + + +def _short_name(script_path: Path) -> str: + """Derive the short check name from a script filename. + + check_lint.py -> lint + check_types.py -> types + """ + stem = script_path.stem # e.g. "check_lint" + if stem.startswith("check_"): + return stem[len("check_") :] + return stem + + +def _run_check( + script: Path, + extra_args: list[str] | None = None, +) -> tuple[int, float]: + """Run a single check script and return (exit_code, duration_seconds).""" + name = _short_name(script) + print(f"\n{'=' * 60}") + print(f" Running: {name}") + print(f"{'=' * 60}\n") + + start = time.monotonic() + result = subprocess.run( + [sys.executable, str(script), *(extra_args or [])], + cwd=PROJECT_ROOT, + ) + duration = time.monotonic() - start + return result.returncode, duration + + +# --------------------------------------------------------------------------- +# External tool helpers +# --------------------------------------------------------------------------- + + +def _run_external_tool( + name: str, + cmd: list[str], +) -> tuple[int, float]: + """Run an external tool and return (exit_code, duration_seconds).""" + print(f"\n{'=' * 60}") + print(f" Running: {name}") + print(f"{'=' * 60}\n") + + start = time.monotonic() + result = subprocess.run(cmd, cwd=PROJECT_ROOT) + duration = time.monotonic() - start + return result.returncode, duration + + +def _find_files(pattern: str) -> list[str]: + """Glob for files relative to PROJECT_ROOT.""" + return sorted(glob.glob(pattern, root_dir=str(PROJECT_ROOT), recursive=True)) + + +# --------------------------------------------------------------------------- +# Summary +# --------------------------------------------------------------------------- + + +def _print_summary( + results: list[tuple[str, str, str]], + section_title: str = "QA Summary", +) -> int: + """Print a formatted summary table. + + *results* is a list of (name, status, duration_str) tuples. + Returns the number of FAILed checks. + """ + col_name = max(len(r[0]) for r in results) if results else 5 + col_name = max(col_name, 5) # minimum width + col_status = 6 # "RESULT" / "PASS" / "FAIL" / "SKIP" + col_dur = 8 + + bar = "=" * 40 + print(f"\n{bar}") + print(f" {section_title}") + print(bar) + header = f" {'Check':<{col_name}} {'Result':<{col_status}} {'Duration':<{col_dur}}" + sep = f" {'-' * col_name} {'-' * col_status} {'-' * col_dur}" + print(header) + print(sep) + for name, status, dur in results: + print(f" {name:<{col_name}} {status:<{col_status}} {dur:<{col_dur}}") + + failures = [r for r in results if r[1] == "FAIL"] + ran = [r for r in results if r[1] != "SKIP"] + print(bar) + if failures: + print(f" Result: FAIL ({len(failures)} of {len(ran)} checks failed)") + else: + print(f" Result: PASS ({len(ran)} of {len(ran)} checks passed)") + print(bar) + return len(failures) + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser(description="Run all local QA checks.") + parser.add_argument( + "--fix", + action="store_true", + help="Pass --fix to check_lint.py and check_spelling.py", + ) + parser.add_argument( + "--skip", + action="append", + default=[], + metavar="NAME", + help="Skip a check by short name (e.g. --skip package). Can be repeated.", + ) + args = parser.parse_args() + + skips: set[str] = {s.lower() for s in args.skip} + + # Auto-skip check_package when there is no [build-system] + if not _has_build_system(): + skips.add("package") + + # ----------------------------------------------------------------- + # Discover check scripts + # ----------------------------------------------------------------- + check_scripts = sorted(SCRIPT_DIR.glob("check_*.py")) + + check_results: list[tuple[str, str, str]] = [] + for script in check_scripts: + name = _short_name(script) + if name in skips: + check_results.append((name, "SKIP", "-")) + continue + + # Determine extra args + extra: list[str] = [] + if args.fix and name in ("lint", "spelling"): + extra.append("--fix") + + exit_code, duration = _run_check(script, extra) + status = "PASS" if exit_code == 0 else "FAIL" + check_results.append((name, status, f"{duration:.1f}s")) + + # ----------------------------------------------------------------- + # External tools + # ----------------------------------------------------------------- + external_results: list[tuple[str, str, str]] = [] + + externals: list[tuple[str, str, list[str] | None]] = [] + + # shellcheck + sh_files = _find_files("**/*.sh") + if sh_files: + externals.append( + ( + "shellcheck", + "shellcheck", + ["shellcheck", *sh_files], + ) + ) + else: + externals.append(("shellcheck", "shellcheck", None)) + + # markdownlint-cli2 + md_files = _find_files("**/*.md") + if md_files: + externals.append( + ( + "markdownlint", + "markdownlint-cli2", + ["markdownlint-cli2", *md_files], + ) + ) + else: + externals.append(("markdownlint", "markdownlint-cli2", None)) + + # actionlint + yml_files = _find_files(".github/workflows/*.yml") + if yml_files: + externals.append(("actionlint", "actionlint", ["actionlint"])) + else: + externals.append(("actionlint", "actionlint", None)) + + for name, binary, cmd in externals: + if shutil.which(binary) is None: + external_results.append((name, "SKIP", "-")) + continue + if cmd is None: + # Tool exists but no matching files + external_results.append((name, "SKIP", "-")) + continue + exit_code, duration = _run_external_tool(name, cmd) + status = "PASS" if exit_code == 0 else "FAIL" + external_results.append((name, status, f"{duration:.1f}s")) + + # ----------------------------------------------------------------- + # Summary + # ----------------------------------------------------------------- + failures = _print_summary(check_results, "QA Summary") + + if external_results: + ext_failures = _print_summary(external_results, "External Tools") + failures += ext_failures + + return 1 if failures > 0 else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/setup.ps1 b/scripts/setup.ps1 new file mode 100644 index 0000000..f161560 --- /dev/null +++ b/scripts/setup.ps1 @@ -0,0 +1,78 @@ +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template +$ErrorActionPreference = 'Stop' + +$ScriptDir = Split-Path -Parent $PSCommandPath +$ProjectRoot = if ($env:PROJECT_ROOT) { $env:PROJECT_ROOT } else { Split-Path -Parent $ScriptDir } + +Write-Host "Project root: $ProjectRoot" +Set-Location $ProjectRoot + +# --------------------------------------------------------------------------- +# Detect toolchain +# --------------------------------------------------------------------------- +if (Test-Path 'uv.lock') { + Write-Host '' + Write-Host 'Detected uv.lock — using uv toolchain.' + Write-Host '' + + & uv venv .venv + if ($LASTEXITCODE -ne 0) { throw 'uv venv failed.' } + + & uv sync + if ($LASTEXITCODE -ne 0) { throw 'uv sync failed.' } + + Write-Host '' + Write-Host 'Setup complete (uv).' + Write-Host ' Activate: .venv\Scripts\Activate.ps1' +} +else { + Write-Host '' + Write-Host 'No uv.lock found — using pip + venv toolchain.' + Write-Host '' + + & python -m venv .venv + if ($LASTEXITCODE -ne 0) { throw 'Failed to create virtual environment.' } + + $VenvPython = Join-Path $ProjectRoot '.venv\Scripts\python.exe' + + & $VenvPython -m pip install --upgrade pip + if ($LASTEXITCODE -ne 0) { throw 'Failed to upgrade pip.' } + + # Check for [project.optional-dependencies] dev in pyproject.toml + $HasDevExtras = $false + $PyprojectPath = Join-Path $ProjectRoot 'pyproject.toml' + + if (Test-Path $PyprojectPath) { + $InSection = $false + foreach ($Line in (Get-Content $PyprojectPath)) { + $Trimmed = $Line.Trim() + if ($Trimmed -eq '[project.optional-dependencies]') { + $InSection = $true + continue + } + if ($InSection -and $Trimmed -match '^\[') { + $InSection = $false + } + if ($InSection -and $Trimmed -match '^dev\s*=') { + $HasDevExtras = $true + break + } + } + } + + if ($HasDevExtras) { + Write-Host 'Installing package with dev extras...' + & $VenvPython -m pip install -e '.[dev]' + if ($LASTEXITCODE -ne 0) { throw 'Failed to install package with dev extras.' } + } + else { + Write-Host 'Installing package (no dev extras detected)...' + & $VenvPython -m pip install -e . + if ($LASTEXITCODE -ne 0) { throw 'Failed to install package.' } + } + + Write-Host '' + Write-Host 'Setup complete (pip + venv).' + Write-Host " Activate: .venv\Scripts\Activate.ps1" +} diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100644 index 0000000..dc4144e --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,62 @@ +#!/usr/bin/env bash +# Managed by nwarila/python-template — do not edit manually. +# Source: https://github.com/nwarila/python-template +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/.." && pwd)}" + +echo "Project root: ${PROJECT_ROOT}" +cd "$PROJECT_ROOT" + +# --------------------------------------------------------------------------- +# Detect toolchain +# --------------------------------------------------------------------------- +if [ -f "uv.lock" ]; then + echo "" + echo "Detected uv.lock — using uv toolchain." + echo "" + + uv venv .venv + uv sync + + echo "" + echo "Setup complete (uv)." + echo " Activate: source .venv/bin/activate" +else + echo "" + echo "No uv.lock found — using pip + venv toolchain." + echo "" + + python3 -m venv .venv + + .venv/bin/python -m pip install --upgrade pip + + # Check for [project.optional-dependencies] dev in pyproject.toml + HAS_DEV_EXTRAS=false + if [ -f "pyproject.toml" ]; then + if grep -qE '^\[project\.optional-dependencies\]' pyproject.toml; then + # Look for a "dev" key after the section header + if awk ' + /^\[project\.optional-dependencies\]/ { in_section=1; next } + /^\[/ { in_section=0 } + in_section && /^[[:space:]]*dev[[:space:]]*=/ { found=1; exit } + END { exit !found } + ' pyproject.toml 2>/dev/null; then + HAS_DEV_EXTRAS=true + fi + fi + fi + + if [ "$HAS_DEV_EXTRAS" = true ]; then + echo "Installing package with dev extras..." + .venv/bin/python -m pip install -e ".[dev]" + else + echo "Installing package (no dev extras detected)..." + .venv/bin/python -m pip install -e . + fi + + echo "" + echo "Setup complete (pip + venv)." + echo " Activate: source .venv/bin/activate" +fi diff --git a/sync-manifest.json b/sync-manifest.json new file mode 100644 index 0000000..0d20121 --- /dev/null +++ b/sync-manifest.json @@ -0,0 +1,21 @@ +{ + "downstream_repos": [ + "nwarila/resume" + ], + "files": [ + { "src": "scripts/check_lint.py", "dest": "scripts/check_lint.py", "mode": "overwrite" }, + { "src": "scripts/check_types.py", "dest": "scripts/check_types.py", "mode": "overwrite" }, + { "src": "scripts/check_tests.py", "dest": "scripts/check_tests.py", "mode": "overwrite" }, + { "src": "scripts/check_security.py", "dest": "scripts/check_security.py", "mode": "overwrite" }, + { "src": "scripts/check_spelling.py", "dest": "scripts/check_spelling.py", "mode": "overwrite" }, + { "src": "scripts/check_package.py", "dest": "scripts/check_package.py", "mode": "overwrite" }, + { "src": "scripts/qa.py", "dest": "scripts/qa.py", "mode": "overwrite" }, + { "src": "scripts/setup.sh", "dest": "scripts/setup.sh", "mode": "overwrite" }, + { "src": "scripts/setup.ps1", "dest": "scripts/setup.ps1", "mode": "overwrite" }, + { "src": "reference/pre-commit-config.yaml", "dest": ".pre-commit-config.yaml", "mode": "overwrite" }, + { "src": "reference/markdownlint-cli2.jsonc", "dest": ".markdownlint-cli2.jsonc", "mode": "overwrite" }, + { "src": "reference/settings.json", "dest": ".vscode/settings.json", "mode": "overwrite" }, + { "src": "reference/extensions.json", "dest": ".vscode/extensions.json", "mode": "overwrite" }, + { "src": "reference/tasks.json", "dest": ".vscode/tasks.json", "mode": "marker-preserve" } + ] +} diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..f67c8c3 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,116 @@ +"""Shared fixtures for script smoke tests.""" + +from __future__ import annotations + +import shutil +import textwrap +from pathlib import Path + +import pytest + + +@pytest.fixture +def tmp_project(tmp_path: Path) -> Path: + """Create a minimal Python project with passing quality gates.""" + # pyproject.toml — minimal, all checks enabled + (tmp_path / "pyproject.toml").write_text( + textwrap.dedent("""\ + [build-system] + requires = ["setuptools>=75.0"] + build-backend = "setuptools.build_meta" + + [project] + name = "smoke-project" + version = "0.1.0" + description = "Minimal smoke-test project." + readme = "README.md" + requires-python = ">=3.11" + license = "MIT" + + [project.optional-dependencies] + dev = [ + "build", + "codespell", + "mypy>=1.16", + "pip-audit", + "pytest", + "pytest-cov", + "ruff>=0.11", + "twine", + "validate-pyproject", + ] + + [project.scripts] + smoke-cli = "smoke_project.cli:main" + + [tool.ruff] + target-version = "py311" + line-length = 120 + src = ["src"] + + [tool.ruff.lint] + select = ["E", "F", "W", "I", "UP", "B", "S", "SIM", "C4", "PT", "T20", "RUF"] + + [tool.ruff.lint.per-file-ignores] + "tests/**" = ["S101"] + + [tool.mypy] + python_version = "3.11" + strict = true + + [tool.pytest.ini_options] + addopts = "-ra --import-mode=importlib --cov=src --cov-report=term-missing --cov-fail-under=90" + testpaths = ["tests"] + pythonpath = ["src"] + + [tool.codespell] + skip = ".venv,dist,*.egg-info,.git" + """) + ) + + # README (required for twine --strict) + (tmp_path / "README.md").write_text("# Smoke Project\n\nMinimal test project.\n") + + # Source code + src_dir = tmp_path / "src" / "smoke_project" + src_dir.mkdir(parents=True) + (src_dir / "__init__.py").write_text('"""Smoke project."""\n') + (src_dir / "cli.py").write_text( + textwrap.dedent("""\ + \"\"\"CLI entry point.\"\"\" + + from __future__ import annotations + + import argparse + + + def main(argv: list[str] | None = None) -> None: + \"\"\"Run the CLI.\"\"\" + parser = argparse.ArgumentParser(description="Smoke CLI") + parser.parse_args(argv) + """) + ) + + # Tests + test_dir = tmp_path / "tests" + test_dir.mkdir() + (test_dir / "__init__.py").write_text("") + (test_dir / "test_cli.py").write_text( + textwrap.dedent("""\ + \"\"\"Tests for the CLI module.\"\"\" + + from smoke_project.cli import main + + + def test_main_runs() -> None: + \"\"\"Verify main() runs without error.\"\"\" + main([]) + """) + ) + + # Copy scripts into the temp project (simulating a synced downstream repo) + scripts_src = Path(__file__).resolve().parent.parent / "scripts" + scripts_dst = tmp_path / "scripts" + shutil.copytree(scripts_src, scripts_dst) + + return tmp_path diff --git a/tests/test_scripts.py b/tests/test_scripts.py new file mode 100644 index 0000000..9dd66b4 --- /dev/null +++ b/tests/test_scripts.py @@ -0,0 +1,153 @@ +"""Smoke tests for check scripts. + +Each test runs a check script against a minimal temporary Python project +and verifies exit codes. These tests validate that the scripts are +functional, generic, and produce correct results. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def _run_script(project: Path, script_name: str, *extra_args: str) -> subprocess.CompletedProcess[str]: + """Run a check script inside the given project directory.""" + script = project / "scripts" / script_name + return subprocess.run( # noqa: S603 - controlled test invocation of local scripts + [sys.executable, str(script), *extra_args], + cwd=project, + capture_output=True, + text=True, + ) + + +class TestCheckLint: + """Tests for check_lint.py.""" + + def test_clean_project_passes(self, tmp_project: Path) -> None: + result = _run_script(tmp_project, "check_lint.py") + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + + def test_fix_flag_accepted(self, tmp_project: Path) -> None: + result = _run_script(tmp_project, "check_lint.py", "--fix") + assert result.returncode == 0 + + def test_bad_formatting_fails(self, tmp_project: Path) -> None: + bad_file = tmp_project / "src" / "smoke_project" / "bad.py" + bad_file.write_text("x=1\ny = 2\n") + result = _run_script(tmp_project, "check_lint.py") + assert result.returncode != 0 + + +class TestCheckTypes: + """Tests for check_types.py.""" + + def test_clean_project_passes(self, tmp_project: Path) -> None: + result = _run_script(tmp_project, "check_types.py") + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + + def test_type_error_fails(self, tmp_project: Path) -> None: + bad_file = tmp_project / "src" / "smoke_project" / "bad.py" + bad_file.write_text("def add(a: int, b: int) -> int:\n return a + b\n\nx: str = add(1, 2)\n") + result = _run_script(tmp_project, "check_types.py") + assert result.returncode != 0 + + +class TestCheckTests: + """Tests for check_tests.py.""" + + def test_passing_tests_succeed(self, tmp_project: Path) -> None: + result = _run_script(tmp_project, "check_tests.py") + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + + def test_failing_test_fails(self, tmp_project: Path) -> None: + (tmp_project / "tests" / "test_fail.py").write_text("def test_always_fails() -> None:\n assert False\n") + result = _run_script(tmp_project, "check_tests.py") + assert result.returncode != 0 + + +class TestCheckSecurity: + """Tests for check_security.py.""" + + def test_runs_without_error(self, tmp_project: Path) -> None: + result = _run_script(tmp_project, "check_security.py") + # pip-audit may or may not find issues depending on the environment, + # but the script itself should not crash + assert result.returncode in (0, 1) + + def test_help_flag(self, tmp_project: Path) -> None: + result = _run_script(tmp_project, "check_security.py", "--help") + assert result.returncode == 0 + assert "security" in result.stdout.lower() or "audit" in result.stdout.lower() + + +class TestCheckSpelling: + """Tests for check_spelling.py.""" + + def test_clean_project_passes(self, tmp_project: Path) -> None: + result = _run_script(tmp_project, "check_spelling.py") + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + + def test_typo_detected(self, tmp_project: Path) -> None: + (tmp_project / "src" / "smoke_project" / "typo.py").write_text( + "# This file has a " + "".join(["t", "eh"]) + " typo in it.\n" + ) + result = _run_script(tmp_project, "check_spelling.py") + assert result.returncode != 0 + + +class TestCheckPackage: + """Tests for check_package.py.""" + + def test_package_build_succeeds(self, tmp_project: Path) -> None: + result = _run_script(tmp_project, "check_package.py") + assert result.returncode == 0, f"stdout: {result.stdout}\nstderr: {result.stderr}" + # dist/ should be cleaned up + assert not (tmp_project / "dist").exists() + + def test_no_build_system_skips(self, tmp_project: Path) -> None: + # Remove [build-system] from pyproject.toml + pyproject = tmp_project / "pyproject.toml" + content = pyproject.read_text() + lines = content.split("\n") + filtered = [] + skip = False + for line in lines: + if line.startswith("[build-system]"): + skip = True + continue + if skip and line.startswith("["): + skip = False + if not skip: + filtered.append(line) + pyproject.write_text("\n".join(filtered)) + + result = _run_script(tmp_project, "check_package.py") + assert result.returncode == 0 + assert "skipping" in result.stdout.lower() or "skip" in result.stdout.lower() + + +class TestQa: + """Tests for qa.py orchestrator.""" + + def test_help_flag(self, tmp_project: Path) -> None: + result = _run_script(tmp_project, "qa.py", "--help") + assert result.returncode == 0 + assert "skip" in result.stdout.lower() + + def test_skip_flag(self, tmp_project: Path) -> None: + result = _run_script( + tmp_project, + "qa.py", + "--skip", + "types", + "--skip", + "tests", + "--skip", + "security", + "--skip", + "package", + ) + assert "SKIP" in result.stdout