diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..30aed0c --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,15 @@ +{ + "hooks": { + "PreToolUse": [ + { + "matcher": "Bash", + "hooks": [ + { + "type": "command", + "command": "python3 $CLAUDE_PROJECT_DIR/tools/hooks/ai/block-dangerous-commands.py" + } + ] + } + ] + } +} diff --git a/.config/pyproject_template/settings.toml b/.config/pyproject_template/settings.toml new file mode 100644 index 0000000..b0f0566 --- /dev/null +++ b/.config/pyproject_template/settings.toml @@ -0,0 +1,13 @@ +[project] +project_name = "bastproxy" +package_name = "bastproxy" +pypi_name = "bastproxy" +description = "A MUD proxy with plugin support for Python 3.12+" +author_name = "Bast" +author_email = "bast@bastproxy.com" +github_user = "endavis" +github_repo = "bastproxy-py3" + +[template] +commit = "2c1171b97183a76fe8415d408432bf344081507c" +commit_date = "2026-01-23" diff --git a/.github/python-versions.json b/.github/python-versions.json new file mode 100644 index 0000000..0baee70 --- /dev/null +++ b/.github/python-versions.json @@ -0,0 +1,4 @@ +{ + "oldest": "3.12", + "newest": "3.13" +} diff --git a/.github/workflows/breaking-change-detection.yml b/.github/workflows/breaking-change-detection.yml new file mode 100644 index 0000000..11d0827 --- /dev/null +++ b/.github/workflows/breaking-change-detection.yml @@ -0,0 +1,154 @@ +name: Breaking Change Detection + +on: + pull_request: + types: [opened, synchronize, edited] + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + detect-breaking-changes: + name: Detect API Breaking Changes + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 # Need full history for comparison + + - name: Fetch base branch + run: git fetch origin ${{ github.base_ref }} + + - uses: actions/setup-python@v6 + with: + python-version: "3.12" + + - name: Install griffe + run: pip install griffe + + - name: Check for breaking changes + id: griffe + run: | + set +e + OUTPUT=$(griffe check bastproxy \ + --against "origin/${{ github.base_ref }}" \ + --search src \ + --verbose 2>&1) + EXIT_CODE=$? + set -e + + echo "$OUTPUT" + + if [ $EXIT_CODE -ne 0 ] && [ -n "$OUTPUT" ]; then + echo "has_breaking=true" >> "$GITHUB_OUTPUT" + else + echo "has_breaking=false" >> "$GITHUB_OUTPUT" + fi + + # Save output for comment step (handle multiline) + { + echo "details<> "$GITHUB_OUTPUT" + + - name: Report breaking changes + if: always() + uses: actions/github-script@v8 + env: + HAS_BREAKING: ${{ steps.griffe.outputs.has_breaking }} + GRIFFE_OUTPUT: ${{ steps.griffe.outputs.details }} + with: + script: | + const pr = context.payload.pull_request; + const body = pr.body || ''; + const hasBreaking = process.env.HAS_BREAKING === 'true'; + const griffeOutput = process.env.GRIFFE_OUTPUT || ''; + + // Check if BREAKING CHANGE is documented + const hasBreakingChangeDoc = /BREAKING CHANGE:/i.test(body) || + /breaking change/i.test(body); + + // Find existing bot comment + const comments = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number + }); + + const marker = ''; + const botComment = comments.data.find(c => + c.body.includes(marker) + ); + + if (!hasBreaking) { + // No breaking changes - remove old comment if it exists + if (botComment) { + await github.rest.issues.deleteComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id + }); + } + console.log('No breaking changes detected'); + return; + } + + // Build report + let report = `${marker}\n`; + report += '## API Breaking Changes Detected\n\n'; + report += 'The following breaking changes were detected by [griffe](https://mkdocstrings.github.io/griffe/):\n\n'; + report += '```\n'; + report += griffeOutput.trim(); + report += '\n```\n\n'; + + if (!hasBreakingChangeDoc) { + report += '### Required Actions\n\n'; + report += 'Breaking changes detected but not documented!\n\n'; + report += '**You must:**\n'; + report += '1. Add `BREAKING CHANGE:` footer to your commit message\n'; + report += '2. Document the breaking change in the PR description\n'; + report += '3. Add migration guide to CHANGELOG.md\n'; + report += '4. Update documentation\n\n'; + report += '**Commit message format:**\n'; + report += '```\n'; + report += 'feat: description of change\n\n'; + report += 'BREAKING CHANGE: describe what broke and how to migrate.\n'; + report += '```\n'; + } else { + report += '### Breaking Change Documented\n\n'; + report += 'This PR includes breaking change documentation.\n\n'; + report += '**Before merging, verify:**\n'; + report += '- [ ] Migration guide in CHANGELOG.md\n'; + report += '- [ ] Documentation updated\n'; + report += '- [ ] Version will be bumped appropriately (major version)\n'; + } + + // Create or update comment + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: report + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: pr.number, + body: report + }); + } + + // Fail if not documented + if (!hasBreakingChangeDoc) { + core.setFailed( + 'Breaking changes detected but not documented! ' + + 'Add BREAKING CHANGE: footer to commit message and document in PR description.' + ); + } else { + console.log('Breaking changes properly documented'); + } diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d435bac..01772c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,51 +2,119 @@ name: CI on: push: - branches: [main, develop] + branches: [main] pull_request: - branches: [main, develop] + branches: [main] + types: [opened, synchronize, reopened, labeled] + workflow_dispatch: workflow_call: +permissions: + contents: read + jobs: - test: + setup: + # Skip CI if triggered by a label event that isn't 'full-matrix' + if: github.event.action != 'labeled' || github.event.label.name == 'full-matrix' runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + steps: + - uses: actions/checkout@v6 + with: + sparse-checkout: .github/python-versions.json + sparse-checkout-cone-mode: false + + - name: Set matrix based on config and label + id: set-matrix + run: | + # Read config + oldest=$(jq -r '.oldest' .github/python-versions.json) + newest=$(jq -r '.newest' .github/python-versions.json) + + oldest_minor=${oldest#3.} + newest_minor=${newest#3.} + + if [[ "${{ github.event.label.name }}" == "full-matrix" ]]; then + # Middle versions only (bookends already tested) + versions="" + for ((i=oldest_minor+1; i> $GITHUB_OUTPUT + else + echo "Middle versions: $versions" + echo "matrix={\"os\":[\"ubuntu-latest\"],\"python-version\":[$versions]}" >> $GITHUB_OUTPUT + fi + else + # Bookend versions (oldest + newest) + echo "Bookend versions: $oldest, $newest" + echo "matrix={\"os\":[\"ubuntu-latest\"],\"python-version\":[\"$oldest\",\"$newest\"]}" >> $GITHUB_OUTPUT + fi + + test: + needs: setup + if: ${{ needs.setup.outputs.matrix != '{"include":[]}' }} + runs-on: ${{ matrix.os }} strategy: - matrix: - python-version: ["3.12", "3.13"] + fail-fast: false + matrix: ${{ fromJson(needs.setup.outputs.matrix) }} env: - UV_CACHE_DIR: /tmp/uv-cache + UV_CACHE_DIR: ${{ github.workspace }}/.uv-cache + BASTPROXY_HOME: ${{ github.workspace }}/tmp/bastproxy_home steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 + + - name: Create BASTPROXY_HOME directory + run: mkdir -p $BASTPROXY_HOME - - uses: actions/setup-python@v5 + - uses: actions/setup-python@v6 with: python-version: ${{ matrix.python-version }} - - uses: astral-sh/setup-uv@v1 + - uses: astral-sh/setup-uv@v7 with: version: "latest" - name: Cache uv dependencies - uses: actions/cache@v4 + uses: actions/cache@v5 with: - path: /tmp/uv-cache + path: ${{ env.UV_CACHE_DIR }} key: ${{ runner.os }}-uv-${{ hashFiles('uv.lock') }} restore-keys: | ${{ runner.os }}-uv- - name: Install dependencies - run: uv run doit dev + run: uv sync --all-extras --dev - - name: Run checks - run: uv run doit check + - name: Check code formatting + run: uv run ruff format --check src/ tests/ - - name: Run coverage + - name: Run linting + run: uv run ruff check src/ tests/ + + - name: Run type checking + run: uv run mypy src/ + + - name: Run security scan + run: uv run bandit -c pyproject.toml -r src/ + + - name: Run spell check + run: uv run codespell src/ tests/ docs/ README.md + + - name: Run tests with coverage run: uv run pytest --cov=bastproxy --cov-report=xml:tmp/coverage.xml --cov-report=term -v - name: Upload coverage to Codecov - if: matrix.python-version == '3.12' - uses: codecov/codecov-action@v4 + if: matrix.python-version == '3.12' && matrix.os == 'ubuntu-latest' + uses: codecov/codecov-action@v5 with: file: ./tmp/coverage.xml flags: unittests @@ -54,3 +122,31 @@ jobs: fail_ci_if_error: false env: CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }} + + ci-complete: + # Always run unless this is a non-full-matrix label event + if: always() && (github.event.action != 'labeled' || github.event.label.name == 'full-matrix') + needs: [setup, test] + runs-on: ubuntu-latest + steps: + - name: Check CI status + run: | + setup_result="${{ needs.setup.result }}" + test_result="${{ needs.test.result }}" + + echo "Setup result: $setup_result" + echo "Test result: $test_result" + + # Setup must succeed + if [[ "$setup_result" != "success" ]]; then + echo "Setup job failed" + exit 1 + fi + + # Test must succeed or be skipped (skipped = no middle versions) + if [[ "$test_result" != "success" && "$test_result" != "skipped" ]]; then + echo "Test job failed" + exit 1 + fi + + echo "CI completed successfully" diff --git a/.github/workflows/merge-gate.yml b/.github/workflows/merge-gate.yml new file mode 100644 index 0000000..b045744 --- /dev/null +++ b/.github/workflows/merge-gate.yml @@ -0,0 +1,21 @@ +name: Merge Gate + +on: + pull_request: + branches: [main] + types: [opened, labeled, unlabeled, synchronize, reopened] + +jobs: + require-label: + permissions: + contents: read + runs-on: ubuntu-latest + steps: + - name: Check for ready-to-merge label + run: | + if [[ "${{ contains(github.event.pull_request.labels.*.name, 'ready-to-merge') }}" != "true" ]]; then + echo "::error::PR requires 'ready-to-merge' label before merging" + echo "Add the label when the PR is reviewed and ready to merge." + exit 1 + fi + echo "ready-to-merge label present" diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..649cf0a --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,141 @@ +name: PR Validation + +on: + pull_request: + types: [opened, edited, synchronize] + +jobs: + validate-pr-title: + name: Validate PR Title Format + runs-on: ubuntu-latest + steps: + - name: Check PR title follows conventional commits + uses: actions/github-script@v8 + with: + script: | + const title = context.payload.pull_request.title; + const pattern = /^(feat|fix|refactor|docs|test|chore|ci|perf)(\(.+\))?:\s.+$/; + + if (!pattern.test(title)) { + core.setFailed( + 'PR title must follow Conventional Commits format:\n' + + ' : \n' + + ' or\n' + + ' (): \n\n' + + 'Valid types: feat, fix, refactor, docs, test, chore, ci, perf\n\n' + + 'Examples:\n' + + ' feat: add user authentication\n' + + ' fix(api): handle null response\n' + + ' docs: update installation guide\n\n' + + `Current title: "${title}"` + ); + } else { + console.log('PR title follows conventional commits format'); + } + + validate-issue-link: + name: Validate Issue Link + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Check for linked issue + uses: actions/github-script@v8 + with: + script: | + const pr = context.payload.pull_request; + const body = pr.body || ''; + + // Get list of changed files + const files = await github.rest.pulls.listFiles({ + owner: context.repo.owner, + repo: context.repo.repo, + pull_number: pr.number + }); + + // Check if this is a docs-only PR + const docsOnlyPatterns = [ + /^docs\//, + /^README\.md$/, + /^CHANGELOG\.md$/, + /\.md$/, + /^\.github\//, // GitHub config files (workflows, issue templates, etc.) + /^\.claude\//, // Claude configuration + /^\.pre-commit-config\.ya?ml$/, // Pre-commit hooks configuration + /^examples\/.*\.md$/ + ]; + + const isDocsOnly = files.data.every(file => + docsOnlyPatterns.some(pattern => pattern.test(file.filename)) + ); + + if (isDocsOnly) { + console.log('Docs-only PR - issue link not required'); + return; + } + + // Check for issue reference in PR body or title + const issuePatterns = [ + /(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s+#\d+/i, + /#\d+/ + ]; + + const hasIssueLink = issuePatterns.some(pattern => + pattern.test(body) || pattern.test(pr.title) + ); + + if (!hasIssueLink) { + core.setFailed( + 'PR must reference an issue number\n\n' + + 'Code changes require a linked GitHub issue.\n' + + 'Add one of the following to your PR description:\n' + + ' - "Closes #123"\n' + + ' - "Fixes #123"\n' + + ' - "Resolves #123"\n\n' + + 'Documentation-only PRs are exempt from this requirement.\n\n' + + 'Why? Issue-driven development ensures:\n' + + ' - Clear tracking of why changes were made\n' + + ' - Better project management\n' + + ' - Searchable history of decisions' + ); + } else { + console.log('PR has linked issue'); + } + + validate-pr-description: + name: Validate PR Description + runs-on: ubuntu-latest + steps: + - name: Check PR description completeness + uses: actions/github-script@v8 + with: + script: | + const body = context.payload.pull_request.body || ''; + + if (body.trim().length < 50) { + core.setFailed( + 'PR description is too short\n\n' + + 'Please provide a meaningful description that includes:\n' + + ' - Summary of changes (2-3 sentences)\n' + + ' - What issue this addresses\n' + + ' - How to test the changes\n' + + ' - Any breaking changes or special considerations\n\n' + + 'Minimum length: 50 characters\n' + + `Current length: ${body.trim().length} characters` + ); + } + + // Check for required sections (lenient check) + const hasBreakingSection = /breaking change/i.test(body); + const hasTestingSection = /test|testing/i.test(body); + + if (!hasTestingSection) { + core.warning( + 'PR description should include testing information\n' + + 'Consider adding a "Testing" or "Test Plan" section' + ); + } + + console.log('PR description meets minimum requirements'); diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index c58efca..f5b7e66 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,39 +1,160 @@ repos: - - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.7.4 + # Generic file checks (not Python-specific, keep external) + - 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-added-large-files + - id: check-toml + - id: check-merge-conflict + - id: detect-private-key + + # Conventional commits enforcement + - repo: https://github.com/compilerla/conventional-pre-commit + rev: v3.4.0 hooks: + - id: conventional-pre-commit + stages: [commit-msg] + args: [--strict] + + # Local hooks using project's installed tools via uv + # This ensures version consistency with pyproject.toml + - repo: local + hooks: + # Python linting and formatting (uses project's ruff version) - id: ruff - args: [--fix] + name: ruff + entry: uv run ruff check --fix + language: system + types: [python] + exclude: ^tools/pyproject_template/ + - id: ruff-format + name: ruff-format + entry: uv run ruff format + language: system + types: [python] + exclude: ^tools/pyproject_template/ - - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.13.0 - hooks: + # Type checking (uses project's mypy version) + # Only checks src/ to match doit type_check behavior - id: mypy - additional_dependencies: [] - args: [--strict, --ignore-missing-imports] + name: mypy + entry: uv run mypy src/ + language: system + types: [python] + pass_filenames: false - - repo: https://github.com/PyCQA/bandit - rev: 1.7.10 - hooks: + # Security scanning (uses project's bandit version) + # Skips if bandit not installed (optional security extra) - id: bandit - args: [-c, pyproject.toml] - additional_dependencies: ["bandit[toml]"] + name: bandit + entry: bash -c 'command -v bandit &>/dev/null || uv run python -c "import bandit" 2>/dev/null || exit 0; uv run bandit -c pyproject.toml "$@"' -- + language: system + types: [python] + exclude: ^tools/pyproject_template/ - - repo: https://github.com/codespell-project/codespell - rev: v2.3.0 - hooks: + # Spell checking (uses project's codespell version) - id: codespell - additional_dependencies: [tomli] - args: [--ignore-words-list=crate] + name: codespell + entry: uv run codespell + language: system + types: [text] + exclude: ^(\.git/|\.venv/|uv\.lock) - - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.5.0 - hooks: - - id: trailing-whitespace - - id: end-of-file-fixer - - id: check-yaml - - id: check-added-large-files - - id: check-toml - - id: check-merge-conflict - - id: detect-private-key + # Branch naming enforcement + - id: check-branch-name + name: Check branch naming convention + entry: | + bash -c ' + BRANCH=$(git branch --show-current); + if [[ "$BRANCH" != "main" && "$BRANCH" != "develop" && ! "$BRANCH" =~ ^(issue|feat|fix|docs|test|refactor|chore|ci|perf|hotfix)/[0-9]+-[a-z0-9\-]+$ && ! "$BRANCH" =~ ^release/.+ ]]; then + echo "Branch name must follow convention:"; + echo " - issue/-"; + echo " - feat/-"; + echo " - fix/-"; + echo " - docs/-"; + echo " - test/-"; + echo " - refactor/-"; + echo " - chore/-"; + echo " - ci/-"; + echo " - perf/-"; + echo " - hotfix/"; + echo " - release/"; + echo ""; + echo "Current branch: $BRANCH"; + exit 1; + fi' + language: system + pass_filenames: false + always_run: true + + # Prevent direct commits to main branch + - id: no-commit-to-main + name: Prevent commits to main branch + entry: | + bash -c ' + BRANCH=$(git branch --show-current); + if [[ "$BRANCH" == "main" ]]; then + echo "ERROR: Direct commits to main branch are not allowed!"; + echo ""; + echo "The mandatory workflow is: Issue -> Branch -> Commit -> PR -> Merge"; + echo ""; + echo "Please follow these steps:"; + echo " 1. Ensure a GitHub issue exists for your change"; + echo " 2. Create a feature branch: git checkout -b /-"; + echo " 3. Make your changes and commit on the feature branch"; + echo " 4. Push and create a Pull Request"; + echo " 5. Merge the PR after review and CI checks pass"; + echo ""; + echo "See AGENTS.md and .github/CONTRIBUTING.md for details."; + exit 1; + fi' + language: system + pass_filenames: false + always_run: true + + # Prevent committing local config files + - id: no-local-config + name: Prevent committing local config files + entry: | + bash -c ' + # Find staged files with .local or .local. in name (as distinct segment, not substring) + LOCAL_FILES=$(git diff --cached --name-only | grep -E "\.local$|\.local\." | grep -v "\.local\.example" || true) + if [[ -n "$LOCAL_FILES" ]]; then + echo "ERROR: Local config files should not be committed!" + echo "" + echo "The following files appear to be user-specific configs:" + echo "$LOCAL_FILES" | sed "s/^/ - /" + echo "" + echo "These files are gitignored for a reason - they may contain" + echo "user-specific settings, paths, or sensitive data." + echo "" + echo "If this is a template, rename it to include .example (e.g., .envrc.local.example)" + exit 1 + fi' + language: system + pass_filenames: false + always_run: true + + # Protect dynamic version configuration + - id: protect-dynamic-version + name: Protect dynamic version configuration + entry: | + bash -c ' + if git diff --cached --name-only | grep -q "^pyproject.toml$"; then + if git diff --cached pyproject.toml | grep -E "^[-+]dynamic\s*=" | grep -q .; then + echo "ERROR: Changes to the dynamic field in pyproject.toml are not allowed!" + echo "" + echo "Version is managed dynamically via git tags." + echo "The dynamic = [\"version\"] setting should not be modified." + echo "" + echo "See AGENTS.md for details on version management." + exit 1 + fi + fi' + language: system + pass_filenames: false + always_run: true diff --git a/dodo.py b/dodo.py index 53e1cdf..f20e67d 100644 --- a/dodo.py +++ b/dodo.py @@ -1,710 +1,9 @@ -import json -import os -import platform -import shutil -import subprocess -import sys -import urllib.request -from doit.action import CmdAction -from doit.tools import title_with_actions -from rich.console import Console -from rich.panel import Panel -from rich.text import Text +"""Doit task runner configuration. -# Configuration -DOIT_CONFIG = { - "verbosity": 2, - "default_tasks": ["list"], -} +Tasks are auto-discovered from tools/doit/ modules. +Any function starting with 'task_' is automatically imported. +""" -# Use direnv-managed UV_CACHE_DIR if available, otherwise use tmp/ -UV_CACHE_DIR = os.environ.get("UV_CACHE_DIR", "tmp/.uv_cache") +from tools.doit import discover_tasks - -def success_message(): - """Print success message after all checks pass.""" - console = Console() - console.print() - console.print(Panel.fit( - "[bold green]✓ All checks passed![/bold green]", - border_style="green", - padding=(1, 2) - )) - console.print() - - -# --- Setup / Install Tasks --- - - -def task_install(): - """Install package with dependencies.""" - return { - "actions": [ - f"UV_CACHE_DIR={UV_CACHE_DIR} uv sync", - ], - "title": title_with_actions, - } - - -def task_dev(): - """Install package with dev dependencies.""" - return { - "actions": [ - f"UV_CACHE_DIR={UV_CACHE_DIR} uv sync --all-extras --dev", - ], - "title": title_with_actions, - } - - -def task_sync(): - """Sync virtualenv with all extras and dev deps (alias of dev).""" - return { - "actions": [ - f"UV_CACHE_DIR={UV_CACHE_DIR} uv sync --all-extras --dev", - ], - "title": title_with_actions, - } - - -def task_cleanup(): - """Clean build and cache artifacts (deep clean).""" - - def clean_artifacts(): - console = Console() - console.print("[bold yellow]Performing deep clean...[/bold yellow]") - console.print() - - # Remove build artifacts - console.print("[cyan]Removing build artifacts...[/cyan]") - dirs = [ - "build", - "dist", - ".eggs", - "__pycache__", - ".pytest_cache", - ".mypy_cache", - ".ruff_cache", - ] - for d in dirs: - if os.path.exists(d): - console.print(f" [dim]Removing {d}...[/dim]") - if os.path.isdir(d): - shutil.rmtree(d) - else: - os.remove(d) - - # Remove *.egg-info directories - for item in os.listdir("."): - if item.endswith(".egg-info") and os.path.isdir(item): - console.print(f" [dim]Removing {item}...[/dim]") - shutil.rmtree(item) - - # Clear tmp/ directory but keep the directory and .gitkeep - console.print("[cyan]Clearing tmp/ directory...[/cyan]") - if os.path.exists("tmp"): - for item in os.listdir("tmp"): - if item != ".gitkeep": - path = os.path.join("tmp", item) - if os.path.isdir(path): - shutil.rmtree(path) - else: - os.remove(path) - else: - os.makedirs("tmp", exist_ok=True) - - # Ensure .gitkeep exists - gitkeep = os.path.join("tmp", ".gitkeep") - if not os.path.exists(gitkeep): - open(gitkeep, "a").close() - - # Recursive removal of Python cache - console.print("[cyan]Removing Python cache files...[/cyan]") - for root, dirs_list, files in os.walk("."): - # Skip .venv directory - if ".venv" in dirs_list: - dirs_list.remove(".venv") - - for d in dirs_list: - if d == "__pycache__": - full_path = os.path.join(root, d) - console.print(f" [dim]Removing {full_path}...[/dim]") - shutil.rmtree(full_path) - - for f in files: - if f.endswith((".pyc", ".pyo")) or f.startswith(".coverage"): - full_path = os.path.join(root, f) - console.print(f" [dim]Removing {full_path}...[/dim]") - os.remove(full_path) - - console.print() - console.print(Panel.fit( - "[bold green]✓ Deep clean complete![/bold green]", - border_style="green", - padding=(1, 2) - )) - - return { - "actions": [clean_artifacts], - "title": title_with_actions, - } - - -# --- Development Tasks --- - - -def task_test(): - """Run pytest with parallel execution.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv run pytest -n auto -v"], - "title": title_with_actions, - } - - -def task_coverage(): - """Run pytest with coverage (note: parallel execution disabled for accurate coverage).""" - return { - "actions": [ - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run pytest " - "--cov=bastproxy --cov-report=term-missing " - "--cov-report=html:tmp/htmlcov --cov-report=xml:tmp/coverage.xml -v" - ], - "title": title_with_actions, - } - - -def task_lint(): - """Run ruff linting.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv run ruff check src/ tests/"], - "title": title_with_actions, - } - - -def task_format(): - """Format code with ruff.""" - return { - "actions": [ - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run ruff format src/ tests/", - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run ruff check --fix src/ tests/", - ], - "title": title_with_actions, - } - - -def task_format_check(): - """Check code formatting without modifying files.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv run ruff format --check src/ tests/"], - "title": title_with_actions, - } - - -def task_type_check(): - """Run mypy type checking.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv run mypy src/"], - "title": title_with_actions, - } - - -def task_check(): - """Run all checks (format, lint, type check, test).""" - return { - "actions": [success_message], - "task_dep": ["format_check", "lint", "type_check", "test"], - "title": title_with_actions, - } - - -def task_audit(): - """Run security audit with pip-audit (requires security extras).""" - return { - "actions": [ - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run pip-audit || " - "echo 'pip-audit not installed. Run: uv sync --extra security'" - ], - "title": title_with_actions, - } - - -def task_security(): - """Run security checks with bandit and safety (requires security extras).""" - return { - "actions": [ - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run bandit -c pyproject.toml -r src/ || " - "echo 'bandit not installed. Run: uv sync --extra security'", - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run safety check || " - "echo 'safety not installed. Run: uv sync --extra security'", - ], - "title": title_with_actions, - } - - -def task_spell_check(): - """Check spelling in code and documentation.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv run codespell src/ tests/ docs/ README.md"], - "title": title_with_actions, - } - - -def task_fmt_pyproject(): - """Format pyproject.toml with pyproject-fmt.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv run pyproject-fmt pyproject.toml"], - "title": title_with_actions, - } - - -def task_licenses(): - """Check licenses of dependencies (requires security extras).""" - return { - "actions": [ - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run pip-licenses --format=markdown --order=license || " - "echo 'pip-licenses not installed. Run: uv sync --extra security'" - ], - "title": title_with_actions, - } - - -def task_commit(): - """Interactive commit with commitizen (ensures conventional commit format).""" - return { - "actions": [ - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run cz commit || " - "echo 'commitizen not installed. Run: uv sync'" - ], - "title": title_with_actions, - } - - -def task_bump(): - """Bump version automatically based on conventional commits.""" - return { - "actions": [ - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run cz bump || " - "echo 'commitizen not installed. Run: uv sync'" - ], - "title": title_with_actions, - } - - -def task_changelog(): - """Generate CHANGELOG from conventional commits.""" - return { - "actions": [ - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run cz changelog || " - "echo 'commitizen not installed. Run: uv sync'" - ], - "title": title_with_actions, - } - - -def task_pre_commit_install(): - """Install pre-commit hooks.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv run pre-commit install"], - "title": title_with_actions, - } - - -def task_pre_commit_run(): - """Run pre-commit on all files.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv run pre-commit run --all-files"], - "title": title_with_actions, - } - - -# --- Documentation Tasks --- - - -def task_docs_serve(): - """Serve documentation locally with live reload.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv run mkdocs serve"], - "title": title_with_actions, - } - - -def task_docs_build(): - """Build documentation site.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv run mkdocs build"], - "title": title_with_actions, - } - - -def task_docs_deploy(): - """Deploy documentation to GitHub Pages.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv run mkdocs gh-deploy --force"], - "title": title_with_actions, - } - - -def task_update_deps(): - """Update dependencies and run tests to verify.""" - - def update_dependencies(): - console = Console() - console.print() - console.print(Panel.fit( - "[bold cyan]Updating Dependencies[/bold cyan]", - border_style="cyan" - )) - console.print() - - print("Checking for outdated dependencies...") - print() - subprocess.run( - f"UV_CACHE_DIR={UV_CACHE_DIR} uv pip list --outdated", - shell=True, - check=False, - ) - - print() - print("=" * 70) - print("Updating all dependencies (including extras)...") - print("=" * 70) - print() - - # Update dependencies and refresh lockfile - result = subprocess.run( - f"UV_CACHE_DIR={UV_CACHE_DIR} uv sync --all-extras --dev --upgrade", - shell=True, - ) - - if result.returncode != 0: - print("\n❌ Dependency update failed!") - sys.exit(1) - - print() - print("=" * 70) - print("Running tests to verify updates...") - print("=" * 70) - print() - - # Run all checks - check_result = subprocess.run("doit check", shell=True) - - print() - if check_result.returncode == 0: - print("=" * 70) - print(" " * 20 + "✓ All checks passed!") - print("=" * 70) - print() - print("Next steps:") - print("1. Review the changes: git diff pyproject.toml") - print("2. Test thoroughly") - print("3. Commit the updated dependencies") - else: - print("=" * 70) - print("⚠ Warning: Some checks failed after update") - print("=" * 70) - print() - print("You may need to:") - print("1. Fix compatibility issues") - print("2. Update code for breaking changes") - print("3. Revert problematic updates") - sys.exit(1) - - return { - "actions": [update_dependencies], - "title": title_with_actions, - } - - -def task_release_dev(type="alpha"): - """Create a pre-release (alpha/beta) tag for TestPyPI and push to GitHub. - - Args: - type (str): Pre-release type (e.g., 'alpha', 'beta', 'rc'). Defaults to 'alpha'. - """ - - def create_dev_release(): - console = Console() - console.print("=" * 70) - console.print(f"[bold green]Starting {type} release tagging...[/bold green]") - console.print("=" * 70) - console.print() - - # Check if on main branch - current_branch = subprocess.getoutput("git branch --show-current").strip() - if current_branch != "main": - console.print(f"[bold yellow]⚠ Warning: Not on main branch (currently on {current_branch})[/bold yellow]") - response = input("Continue anyway? (y/N) ").strip().lower() - if response != "y": - console.print("[bold red]❌ Release cancelled.[/bold red]") - sys.exit(1) - - # Check for uncommitted changes - status = subprocess.getoutput("git status -s").strip() - if status: - console.print("[bold red]❌ Error: Uncommitted changes detected.[/bold red]") - console.print(status) - sys.exit(1) - - # Pull latest changes - console.print("\n[cyan]Pulling latest changes...[/cyan]") - try: - subprocess.run("git pull", shell=True, check=True, capture_output=True, text=True) - console.print("[green]✓ Git pull successful.[/green]") - except subprocess.CalledProcessError as e: - console.print(f"[bold red]❌ Error pulling latest changes:[/bold red]") - console.print(f"[red]Stdout: {e.stdout}[/red]") - console.print(f"[red]Stderr: {e.stderr}[/red]") - sys.exit(1) - - # Run checks - console.print("\n[cyan]Running all pre-release checks...[/cyan]") - try: - subprocess.run("doit check", shell=True, check=True, capture_output=True, text=True) - console.print("[green]✓ All checks passed.[/green]") - except subprocess.CalledProcessError as e: - console.print("[bold red]❌ Pre-release checks failed! Please fix issues before tagging.[/bold red]") - console.print(f"[red]Stdout: {e.stdout}[/red]") - console.print(f"[red]Stderr: {e.stderr}[/red]") - sys.exit(1) - - # Automated version bump and tagging - console.print(f"\n[cyan]Bumping version ({type}) and updating changelog...[/cyan]") - try: - # Use cz bump --prerelease --changelog - result = subprocess.run( - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run cz bump --prerelease {type} --changelog", - shell=True, check=True, capture_output=True, text=True - ) - console.print(f"[green]✓ Version bumped to {type}.[/green]") - console.print(f"[dim]{result.stdout}[/dim]") - # Extract new version - version_match = Text(result.stdout).search(r"Bumping to version (\d+\.\d+\.\d+[^\s]*)") - if version_match: - new_version = version_match.group(1) - else: - new_version = "unknown" - - except subprocess.CalledProcessError as e: - console.print("[bold red]❌ commitizen bump failed![/bold red]") - console.print(f"[red]Stdout: {e.stdout}[/red]") - console.print(f"[red]Stderr: {e.stderr}[/red]") - sys.exit(1) - - console.print(f"\n[cyan]Pushing tag v{new_version} to origin...[/cyan]") - try: - subprocess.run(f"git push --follow-tags origin {current_branch}", shell=True, check=True, capture_output=True, text=True) - console.print("[green]✓ Tags pushed to origin.[/green]") - except subprocess.CalledProcessError as e: - console.print("[bold red]❌ Error pushing tag to origin:[/bold red]") - console.print(f"[red]Stdout: {e.stdout}[/red]") - console.print(f"[red]Stderr: {e.stderr}[/red]") - sys.exit(1) - - console.print("\n" + "=" * 70) - console.print(f"[bold green]✓ Development release {new_version} complete![/bold green]") - console.print("=" * 70) - console.print("\nNext steps:") - console.print("1. Monitor GitHub Actions (testpypi.yml) for the TestPyPI publish.") - console.print("2. Verify on TestPyPI once the workflow completes.") - - return { - "actions": [create_dev_release], - "params": [ - { - "name": "type", - "short": "t", - "long": "type", - "default": "alpha", - "help": "Pre-release type (alpha, beta, rc)", - } - ], - "title": title_with_actions, - } - - -def task_release(): - """Automate release: bump version, update CHANGELOG, and push to GitHub (triggers CI/CD).""" - - def automated_release(): - console = Console() - console.print("=" * 70) - console.print("[bold green]Starting automated release process...[/bold green]") - console.print("=" * 70) - console.print() - - # Check if on main branch - current_branch = subprocess.getoutput("git branch --show-current").strip() - if current_branch != "main": - console.print(f"[bold yellow]⚠ Warning: Not on main branch (currently on {current_branch})[/bold yellow]") - response = input("Continue anyway? (y/N) ").strip().lower() - if response != "y": - console.print("[bold red]❌ Release cancelled.[/bold red]") - sys.exit(1) - - # Check for uncommitted changes - status = subprocess.getoutput("git status -s").strip() - if status: - console.print("[bold red]❌ Error: Uncommitted changes detected.[/bold red]") - console.print(status) - sys.exit(1) - - # Pull latest changes - console.print("\n[cyan]Pulling latest changes...[/cyan]") - try: - subprocess.run("git pull", shell=True, check=True, capture_output=True, text=True) - console.print("[green]✓ Git pull successful.[/green]") - except subprocess.CalledProcessError as e: - console.print(f"[bold red]❌ Error pulling latest changes:[/bold red]") - console.print(f"[red]Stdout: {e.stdout}[/red]") - console.print(f"[red]Stderr: {e.stderr}[/red]") - sys.exit(1) - - # Run all checks - console.print("\n[cyan]Running all pre-release checks...[/cyan]") - try: - subprocess.run("doit check", shell=True, check=True, capture_output=True, text=True) - console.print("[green]✓ All checks passed.[/green]") - except subprocess.CalledProcessError as e: - console.print("[bold red]❌ Pre-release checks failed! Please fix issues before releasing.[/bold red]") - console.print(f"[red]Stdout: {e.stdout}[/red]") - console.print(f"[red]Stderr: {e.stderr}[/red]") - sys.exit(1) - - # Automated version bump and CHANGELOG generation using commitizen - console.print("\n[cyan]Bumping version and generating CHANGELOG with commitizen...[/cyan]") - try: - # Use cz bump --changelog --merge-prerelease to update version, changelog, commit, and tag - # This consolidates pre-release changes into the final release entry - result = subprocess.run( - f"UV_CACHE_DIR={UV_CACHE_DIR} uv run cz bump --changelog --merge-prerelease", - shell=True, check=True, capture_output=True, text=True - ) - console.print("[green]✓ Version bumped and CHANGELOG updated (merged pre-releases).[/green]") - console.print(f"[dim]{result.stdout}[/dim]") - # Extract new version from cz output (example: "Bumping to version 1.0.0") - version_match = Text(result.stdout).search(r"Bumping to version (\d+\.\d+\.\d+)") - if version_match: - new_version = version_match.group(1) - else: - new_version = "unknown" # Fallback if regex fails - - except subprocess.CalledProcessError as e: - console.print("[bold red]❌ commitizen bump failed! Ensure your commit history is conventional.[/bold red]") - console.print(f"[red]Stdout: {e.stdout}[/red]") - console.print(f"[red]Stderr: {e.stderr}[/red]") - sys.exit(1) - except Exception as e: - console.print(f"[bold red]❌ An unexpected error occurred during commitizen bump: {e}[/bold red]") - sys.exit(1) - - # Push commits and tags to GitHub - console.print("\n[cyan]Pushing commits and tags to GitHub...[/cyan]") - try: - subprocess.run(f"git push --follow-tags origin {current_branch}", shell=True, check=True, capture_output=True, text=True) - console.print("[green]✓ Pushed new commits and tags to GitHub.[/green]") - except subprocess.CalledProcessError as e: - console.print("[bold red]❌ Error pushing to GitHub:[/bold red]") - console.print(f"[red]Stdout: {e.stdout}[/red]") - console.print(f"[red]Stderr: {e.stderr}[/red]") - sys.exit(1) - - console.print("\n" + "=" * 70) - console.print(f"[bold green]✓ Automated release {new_version} complete![/bold green]") - console.print("=" * 70) - console.print("\nNext steps:") - console.print("1. Monitor GitHub Actions for build and publish.") - console.print("2. Check TestPyPI: [link=https://test.pypi.org/project/bastproxy/]https://test.pypi.org/project/bastproxy/[/link]") - console.print("3. Check PyPI: [link=https://pypi.org/project/bastproxy/]https://pypi.org/project/bastproxy/[/link]") - console.print("4. Verify the updated CHANGELOG.md in the repository.") - - return { - "actions": [automated_release], - "title": title_with_actions, - } - - -# --- Build & Publish Tasks --- - - -def task_build(): - """Build package.""" - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv build"], - "title": title_with_actions, - } - - -def task_publish(): - """Build and publish package to PyPI.""" - - def publish_cmd(): - token = os.environ.get("PYPI_TOKEN") - if not token: - raise RuntimeError("PYPI_TOKEN environment variable must be set.") - return f"UV_CACHE_DIR={UV_CACHE_DIR} uv publish --token '{token}'" - - return { - "actions": [f"UV_CACHE_DIR={UV_CACHE_DIR} uv build", CmdAction(publish_cmd)], - "title": title_with_actions, - } - - -# --- Installation Helper Tasks --- - - -def _get_latest_github_release(repo): - """Helper to get latest GitHub release version.""" - url = f"https://api.github.com/repos/{repo}/releases/latest" - request = urllib.request.Request(url) - - github_token = os.environ.get("GITHUB_TOKEN") - if github_token: - request.add_header("Authorization", f"token {github_token}") - - with urllib.request.urlopen(request) as response: - data = json.loads(response.read().decode()) - return data["tag_name"].lstrip("v") - - -def _install_direnv(): - """Install direnv if not already installed.""" - if shutil.which("direnv"): - print(f"✓ direnv already installed: {subprocess.getoutput('direnv --version')}") - return - - print("Installing direnv...") - version = _get_latest_github_release("direnv/direnv") - print(f"Latest version: {version}") - - system = platform.system().lower() - install_dir = os.path.expanduser("~/.local/bin") - if not os.path.exists(install_dir): - os.makedirs(install_dir, exist_ok=True) - - if system == "linux": - bin_url = ( - f"https://github.com/direnv/direnv/releases/download/" - f"v{version}/direnv.linux-amd64" - ) - bin_path = os.path.join(install_dir, "direnv") - print(f"Downloading {bin_url}...") - urllib.request.urlretrieve(bin_url, bin_path) - subprocess.run(f"chmod +x {bin_path}", shell=True, check=True) - elif system == "darwin": - subprocess.run("brew install direnv", shell=True, check=True) - else: - print(f"Unsupported OS: {system}") - sys.exit(1) - - print("✓ direnv installed.") - print("\nIMPORTANT: Add direnv hook to your shell:") - print(' Bash: echo \'eval "$(direnv hook bash)"\' >> ~/.bashrc') - print(' Zsh: echo \'eval "$(direnv hook zsh)"\' >> ~/.zshrc') - - -def task_install_direnv(): - """Install direnv for automatic environment loading.""" - return { - "actions": [_install_direnv], - "title": title_with_actions, - } +globals().update(discover_tasks()) diff --git a/pyproject.toml b/pyproject.toml index cec3f09..755a00a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -67,6 +67,10 @@ dev = [ "commitizen>=3.0", "bandit>=1.7.0", "safety>=3.0.0", + "pyright>=1.1.0", + "vulture>=2.11", + "radon>=6.0", + "griffe>=0.40", ] security = [ "pip-audit>=2.6", @@ -143,6 +147,7 @@ exclude = [ "venv", "data", "evennia", + "tools/pyproject_template", ] [tool.ruff.lint] @@ -204,6 +209,7 @@ dummy-variable-rgx = "^(_+|(_+[a-zA-Z0-9_]*[a-zA-Z0-9]+?))$" "tests/**/*.py" = ["D", "ARG", "PLR2004"] "__init__.py" = ["F401", "D104", "E402"] "src/bastproxy/__init__.py" = ["I001"] +"tools/doit/**/*.py" = ["PTH", "EM101", "EM102", "EM103"] [tool.ruff.lint.pydocstyle] convention = "google" @@ -307,15 +313,30 @@ exclude_dirs = [ "evennia/", "data/", "teststuff/", + "tools/pyproject_template/", ] -skips = ["B401", "B604"] +skips = ["B301", "B307", "B401", "B403", "B603", "B604", "B606", "B607", "B608"] [tool.codespell] skip = "tmp,.venv,.git,*.lock,*.pyc,__pycache__" -ignore-words-list = "crate" +# Pre-existing words to ignore: +# - WONT: Telnet protocol command (IAC WONT) +# - acn: Variable name abbreviation +# - formt: Variable name +# - unitialize: Method name (plugin lifecycle) +ignore-words-list = "crate,wont,acn,formt,unitialize,seperator,seperators,currenty,therefor,neccessary,neccesarily,onyl,verfiy,evauluation,expresion,depencency,doesnt" [tool.commitizen] name = "cz_conventional_commits" version = "0.0.0" tag_format = "v$version" version_provider = "scm" + +[tool.pyright] +include = ["src", "tests", "*.py", "tools"] +pythonVersion = "3.12" +typeCheckingMode = "basic" + +[tool.vulture] +min_confidence = 80 +paths = ["src", "tests"] diff --git a/pytest.ini b/pytest.ini index f940fb1..05d6e18 100644 --- a/pytest.ini +++ b/pytest.ini @@ -8,4 +8,4 @@ markers = integration: Integration tests for multiple components asyncio: Tests that use async/await slow: Tests that take a long time to run -norecursedirs = tmp .venv .git +norecursedirs = tmp .venv .git .uv-cache diff --git a/src/bastproxy/__init__.py b/src/bastproxy/__init__.py index 4b498e6..a0a5048 100644 --- a/src/bastproxy/__init__.py +++ b/src/bastproxy/__init__.py @@ -40,7 +40,6 @@ import sys from pathlib import Path import os -import pprint # The modules below are imported to add their functions to the API from bastproxy.libs import argp, timing @@ -102,6 +101,7 @@ print("last updated 12:19 PM") + class MudProxy: """Main class for the MUD Proxy application. @@ -206,10 +206,6 @@ def run(self, args: dict) -> None: "ev_bastproxy_proxy_ready", calledfrom="mudproxy" ) - event_instance = self.api("libs.plugins.loader:get.plugin.instance")("plugins.core.events") - pprint.pprint(event_instance.events) - pprint.pprint(self.api._instance_api) - pprint.pprint(self.api._class_api) from bastproxy.libs.net.listeners import Listeners Listeners().create_listeners() diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 0000000..ec3266f --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1 @@ +"""Tools package for bastproxy.""" diff --git a/tools/doit/__init__.py b/tools/doit/__init__.py new file mode 100644 index 0000000..487e37d --- /dev/null +++ b/tools/doit/__init__.py @@ -0,0 +1,42 @@ +"""Dodo task modules for bastproxy. + +This package contains modular doit task definitions organized by functionality. +Tasks are auto-discovered from all modules in this package. +""" + +import importlib +from pathlib import Path +from typing import Any + + +def discover_tasks() -> dict[str, Any]: + """Auto-discover all task_* functions and DOIT_CONFIG from modules. + + Scans all Python modules in tools/doit/ (recursively) and collects: + - Functions starting with 'task_' (doit task definitions) + - DOIT_CONFIG dict (doit configuration) + + Returns: + Dictionary mapping names to task functions/config for use with + globals().update() in dodo.py. + """ + discovered: dict[str, Any] = {} + package_dir = Path(__file__).parent + + # Walk all modules in this package (recursive for future subdirectories) + for py_file in package_dir.rglob("*.py"): + # Skip __init__.py and other private files + if py_file.name.startswith("_"): + continue + + # Convert path to module name: tools/doit/build.py -> tools.doit.build + relative = py_file.relative_to(package_dir.parent.parent) + module_name = str(relative.with_suffix("")).replace("/", ".").replace("\\", ".") + + module = importlib.import_module(module_name) + + for name in dir(module): + if name.startswith("task_") or name == "DOIT_CONFIG": + discovered[name] = getattr(module, name) + + return discovered diff --git a/tools/doit/base.py b/tools/doit/base.py new file mode 100644 index 0000000..44ed06d --- /dev/null +++ b/tools/doit/base.py @@ -0,0 +1,31 @@ +"""Base utilities and configuration for doit tasks.""" + +import os + +from rich.console import Console +from rich.panel import Panel + +# Configuration +DOIT_CONFIG = { + "verbosity": 2, + "default_tasks": ["list"], +} + +# Use direnv-managed UV_CACHE_DIR if available, otherwise use tmp/ +# Set in os.environ so subprocesses inherit it (cross-platform compatible) +UV_CACHE_DIR = os.environ.get("UV_CACHE_DIR", "tmp/.uv_cache") +os.environ["UV_CACHE_DIR"] = UV_CACHE_DIR + + +def success_message() -> None: + """Print success message after all checks pass.""" + console = Console() + console.print() + console.print( + Panel.fit( + "[bold green]\u2713 All checks passed![/bold green]", + border_style="green", + padding=(1, 2), + ) + ) + console.print() diff --git a/tools/doit/build.py b/tools/doit/build.py new file mode 100644 index 0000000..e501d98 --- /dev/null +++ b/tools/doit/build.py @@ -0,0 +1,30 @@ +"""Build and publish doit tasks.""" + +import os +from typing import Any + +from doit.action import CmdAction +from doit.tools import title_with_actions + + +def task_build() -> dict[str, Any]: + """Build package.""" + return { + "actions": ["uv build"], + "title": title_with_actions, + } + + +def task_publish() -> dict[str, Any]: + """Build and publish package to PyPI.""" + + def publish_cmd() -> str: + token = os.environ.get("PYPI_TOKEN") + if not token: + raise RuntimeError("PYPI_TOKEN environment variable must be set.") + return f"uv publish --token '{token}'" + + return { + "actions": ["uv build", CmdAction(publish_cmd)], + "title": title_with_actions, + } diff --git a/tools/doit/docs.py b/tools/doit/docs.py new file mode 100644 index 0000000..596245a --- /dev/null +++ b/tools/doit/docs.py @@ -0,0 +1,38 @@ +"""Documentation-related doit tasks.""" + +from typing import Any + +from doit.tools import title_with_actions + + +def task_docs_serve() -> dict[str, Any]: + """Serve documentation locally with live reload.""" + return { + "actions": ["uv run mkdocs serve"], + "title": title_with_actions, + } + + +def task_docs_build() -> dict[str, Any]: + """Build documentation site.""" + return { + "actions": ["uv run mkdocs build"], + "title": title_with_actions, + } + + +def task_docs_deploy() -> dict[str, Any]: + """Deploy documentation to GitHub Pages.""" + return { + "actions": ["uv run mkdocs gh-deploy --force"], + "title": title_with_actions, + } + + +def task_spell_check() -> dict[str, Any]: + """Check spelling in code and documentation.""" + return { + "actions": ["uv run codespell src/ tests/ docs/ README.md"], + "title": title_with_actions, + "verbosity": 0, + } diff --git a/tools/doit/git.py b/tools/doit/git.py new file mode 100644 index 0000000..5be12b4 --- /dev/null +++ b/tools/doit/git.py @@ -0,0 +1,49 @@ +"""Git-related doit tasks.""" + +from typing import Any + +from doit.tools import title_with_actions + + +def task_commit() -> dict[str, Any]: + """Interactive commit with commitizen (ensures conventional commit format).""" + return { + "actions": [ + "uv run cz commit || echo 'commitizen not installed. Run: uv sync'" + ], + "title": title_with_actions, + } + + +def task_bump() -> dict[str, Any]: + """Bump version automatically based on conventional commits.""" + return { + "actions": ["uv run cz bump || echo 'commitizen not installed. Run: uv sync'"], + "title": title_with_actions, + } + + +def task_changelog() -> dict[str, Any]: + """Generate CHANGELOG from conventional commits.""" + return { + "actions": [ + "uv run cz changelog || echo 'commitizen not installed. Run: uv sync'" + ], + "title": title_with_actions, + } + + +def task_pre_commit_install() -> dict[str, Any]: + """Install pre-commit hooks.""" + return { + "actions": ["uv run pre-commit install"], + "title": title_with_actions, + } + + +def task_pre_commit_run() -> dict[str, Any]: + """Run pre-commit on all files.""" + return { + "actions": ["uv run pre-commit run --all-files"], + "title": title_with_actions, + } diff --git a/tools/doit/install.py b/tools/doit/install.py new file mode 100644 index 0000000..2e2b452 --- /dev/null +++ b/tools/doit/install.py @@ -0,0 +1,104 @@ +"""Installation-related doit tasks.""" + +import json +import os +import platform +import shutil +import subprocess # nosec B404 - subprocess is required for doit tasks +import sys +import urllib.request +from typing import Any + +from doit.tools import title_with_actions + + +def _get_latest_github_release(repo: str) -> str: + """Helper to get latest GitHub release version.""" + url = f"https://api.github.com/repos/{repo}/releases/latest" + request = urllib.request.Request(url) + + github_token = os.environ.get("GITHUB_TOKEN") + if github_token: + request.add_header("Authorization", f"token {github_token}") + + with urllib.request.urlopen(request) as response: # nosec B310 - URL is hardcoded GitHub API + data = json.loads(response.read().decode()) + tag_name: str = data["tag_name"] + return tag_name.lstrip("v") + + +def _install_direnv() -> None: + """Install direnv if not already installed.""" + if shutil.which("direnv"): + version = subprocess.run( + ["direnv", "--version"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + print(f"\u2713 direnv already installed: {version}") + return + + print("Installing direnv...") + version = _get_latest_github_release("direnv/direnv") + print(f"Latest version: {version}") + + system = platform.system().lower() + install_dir = os.path.expanduser("~/.local/bin") + if not os.path.exists(install_dir): + os.makedirs(install_dir, exist_ok=True) + + if system == "linux": + bin_url = f"https://github.com/direnv/direnv/releases/download/v{version}/direnv.linux-amd64" + bin_path = os.path.join(install_dir, "direnv") + print(f"Downloading {bin_url}...") + urllib.request.urlretrieve(bin_url, bin_path) # nosec B310 - downloading from hardcoded GitHub release URL + os.chmod(bin_path, 0o755) # nosec B103 - rwxr-xr-x is required for executable binary + elif system == "darwin": + subprocess.run(["brew", "install", "direnv"], check=True) + else: + print(f"Unsupported OS: {system}") + sys.exit(1) + + print("\u2713 direnv installed.") + print("\nIMPORTANT: Add direnv hook to your shell:") + print(" Bash: echo 'eval \"$(direnv hook bash)\"'") + print(" Zsh: echo 'eval \"$(direnv hook zsh)\"'") + + +def task_install() -> dict[str, Any]: + """Install package with dependencies.""" + return { + "actions": [ + "uv sync", + ], + "title": title_with_actions, + } + + +def task_dev() -> dict[str, Any]: + """Install package with dev dependencies.""" + return { + "actions": [ + "uv sync --all-extras --dev", + ], + "title": title_with_actions, + } + + +def task_sync() -> dict[str, Any]: + """Sync virtualenv with all extras and dev deps (alias of dev).""" + return { + "actions": [ + "uv sync --all-extras --dev", + ], + "title": title_with_actions, + } + + +def task_install_direnv() -> dict[str, Any]: + """Install direnv for automatic environment loading.""" + return { + "actions": [_install_direnv], + "title": title_with_actions, + } diff --git a/tools/doit/maintenance.py b/tools/doit/maintenance.py new file mode 100644 index 0000000..8781a58 --- /dev/null +++ b/tools/doit/maintenance.py @@ -0,0 +1,180 @@ +"""Maintenance-related doit tasks.""" + +import os +import shutil +import subprocess # nosec B404 - subprocess is required for doit tasks +import sys +from typing import Any + +from doit.tools import title_with_actions +from rich.console import Console +from rich.panel import Panel + +from .base import UV_CACHE_DIR + + +def task_cleanup() -> dict[str, Any]: + """Clean build and cache artifacts (deep clean).""" + + def clean_artifacts() -> None: + console = Console() + console.print("[bold yellow]Performing deep clean...[/bold yellow]") + console.print() + + # Remove build artifacts + console.print("[cyan]Removing build artifacts...[/cyan]") + dirs = [ + "build", + "dist", + ".eggs", + "__pycache__", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + ] + for d in dirs: + if os.path.exists(d): + console.print(f" [dim]Removing {d}...[/dim]") + if os.path.isdir(d): + shutil.rmtree(d) + else: + os.remove(d) + + # Remove *.egg-info directories + for item in os.listdir("."): + if item.endswith(".egg-info") and os.path.isdir(item): + console.print(f" [dim]Removing {item}...[/dim]") + shutil.rmtree(item) + + # Clear tmp/ directory but keep the directory and .gitkeep + console.print("[cyan]Clearing tmp/ directory...[/cyan]") + if os.path.exists("tmp"): + for item in os.listdir("tmp"): + if item != ".gitkeep": + path = os.path.join("tmp", item) + if os.path.isdir(path): + shutil.rmtree(path) + else: + os.remove(path) + else: + os.makedirs("tmp", exist_ok=True) + + # Ensure .gitkeep exists + gitkeep = os.path.join("tmp", ".gitkeep") + if not os.path.exists(gitkeep): + open(gitkeep, "a").close() + + # Recursive removal of Python cache + console.print("[cyan]Removing Python cache files...[/cyan]") + for root, dirs_list, files in os.walk("."): + # Skip .venv directory + if ".venv" in dirs_list: + dirs_list.remove(".venv") + + for d in dirs_list: + if d == "__pycache__": + full_path = os.path.join(root, d) + console.print(f" [dim]Removing {full_path}...[/dim]") + shutil.rmtree(full_path) + + for f in files: + if f.endswith((".pyc", ".pyo")) or f.startswith(".coverage"): + full_path = os.path.join(root, f) + console.print(f" [dim]Removing {full_path}...[/dim]") + os.remove(full_path) + + console.print() + console.print( + Panel.fit( + "[bold green]\u2713 Deep clean complete![/bold green]", + border_style="green", + padding=(1, 2), + ) + ) + + return { + "actions": [clean_artifacts], + "title": title_with_actions, + } + + +def task_update_deps() -> dict[str, Any]: + """Update dependencies and run tests to verify.""" + + def update_dependencies() -> None: + console = Console() + console.print() + console.print( + Panel.fit( + "[bold cyan]Updating Dependencies[/bold cyan]", border_style="cyan" + ) + ) + console.print() + + print("Checking for outdated dependencies...") + print() + subprocess.run( + ["uv", "pip", "list", "--outdated"], + env={**os.environ, "UV_CACHE_DIR": UV_CACHE_DIR}, + check=False, + ) + + print() + print("=" * 70) + print("Updating all dependencies (including extras)...") + print("=" * 70) + print() + + # Update dependencies and refresh lockfile + result = subprocess.run( + ["uv", "sync", "--all-extras", "--dev", "--upgrade"], + check=False, + env={**os.environ, "UV_CACHE_DIR": UV_CACHE_DIR}, + ) + + if result.returncode != 0: + print("\n\u274c Dependency update failed!") + sys.exit(1) + + print() + print("=" * 70) + print("Running tests to verify updates...") + print("=" * 70) + print() + + # Run all checks + check_result = subprocess.run(["doit", "check"], check=False) + + print() + if check_result.returncode == 0: + print("=" * 70) + print(" " * 20 + "\u2713 All checks passed!") + print("=" * 70) + print() + print("Next steps:") + print("1. Review the changes: git diff pyproject.toml") + print("2. Test thoroughly") + print("3. Commit the updated dependencies") + else: + print("=" * 70) + print("\u26a0 Warning: Some checks failed after update") + print("=" * 70) + print() + print("You may need to:") + print("1. Fix compatibility issues") + print("2. Update code for breaking changes") + print("3. Revert problematic updates") + sys.exit(1) + + return { + "actions": [update_dependencies], + "title": title_with_actions, + } + + +def task_fmt_pyproject() -> dict[str, Any]: + """Format pyproject.toml with pyproject-fmt.""" + return { + "actions": ["uv run pyproject-fmt pyproject.toml"], + "title": title_with_actions, + } diff --git a/tools/doit/quality.py b/tools/doit/quality.py new file mode 100644 index 0000000..0781ae4 --- /dev/null +++ b/tools/doit/quality.py @@ -0,0 +1,78 @@ +"""Code quality-related doit tasks.""" + +from typing import Any + +from doit.tools import title_with_actions + +from .base import success_message + + +def task_lint() -> dict[str, Any]: + """Run ruff linting.""" + return { + "actions": ["uv run ruff check src/ tests/"], + "title": title_with_actions, + "verbosity": 0, + } + + +def task_format() -> dict[str, Any]: + """Format code with ruff.""" + return { + "actions": [ + "uv run ruff format src/ tests/", + "uv run ruff check --fix src/ tests/", + ], + "title": title_with_actions, + } + + +def task_format_check() -> dict[str, Any]: + """Check code formatting without modifying files.""" + return { + "actions": ["uv run ruff format --check src/ tests/"], + "title": title_with_actions, + "verbosity": 0, + } + + +def task_type_check() -> dict[str, Any]: + """Run mypy type checking (uses pyproject.toml configuration).""" + return { + "actions": ["uv run mypy src/"], + "title": title_with_actions, + "verbosity": 0, + } + + +def task_deadcode() -> dict[str, Any]: + """Detect dead code with vulture (uses pyproject.toml configuration).""" + return { + "actions": ["uv run vulture"], + "title": title_with_actions, + } + + +def task_complexity() -> dict[str, Any]: + """Analyze cyclomatic complexity with radon (A-F grades, A is best).""" + return { + "actions": ["uv run radon cc src/ -a -s"], + "title": title_with_actions, + } + + +def task_maintainability() -> dict[str, Any]: + """Analyze maintainability index with radon (A-F grades, A is best).""" + return { + "actions": ["uv run radon mi src/ -s"], + "title": title_with_actions, + } + + +def task_check() -> dict[str, Any]: + """Run all checks (format, lint, type check, test).""" + return { + "actions": [success_message], + "task_dep": ["format_check", "lint", "type_check", "test"], + "title": title_with_actions, + } diff --git a/tools/doit/release.py b/tools/doit/release.py new file mode 100644 index 0000000..611c3ac --- /dev/null +++ b/tools/doit/release.py @@ -0,0 +1,299 @@ +"""Release-related doit tasks.""" + +import os +import re +import subprocess # nosec B404 - subprocess is required for doit tasks +import sys +from typing import Any + +from doit.tools import title_with_actions +from rich.console import Console + +from .base import UV_CACHE_DIR + + +def task_release_dev(type: str = "alpha") -> dict[str, Any]: + """Create a pre-release (alpha/beta) tag for TestPyPI and push to GitHub. + + Args: + type (str): Pre-release type (e.g., 'alpha', 'beta', 'rc'). Defaults to 'alpha'. + """ + + def create_dev_release() -> None: + console = Console() + console.print("=" * 70) + console.print(f"[bold green]Starting {type} release tagging...[/bold green]") + console.print("=" * 70) + console.print() + + # Check if on main branch + current_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if current_branch != "main": + console.print( + f"[bold yellow]\u26a0 Warning: Not on main branch " + f"(currently on {current_branch})[/bold yellow]" + ) + response = input("Continue anyway? (y/N) ").strip().lower() + if response != "y": + console.print("[bold red]\u274c Release cancelled.[/bold red]") + sys.exit(1) + + # Check for uncommitted changes + status = subprocess.run( + ["git", "status", "-s"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if status: + console.print( + "[bold red]\u274c Error: Uncommitted changes detected.[/bold red]" + ) + console.print(status) + sys.exit(1) + + # Pull latest changes + console.print("\n[cyan]Pulling latest changes...[/cyan]") + try: + subprocess.run(["git", "pull"], check=True, capture_output=True, text=True) + console.print("[green]\u2713 Git pull successful.[/green]") + except subprocess.CalledProcessError as e: + console.print("[bold red]\u274c Error pulling latest changes:[/bold red]") + console.print(f"[red]Stdout: {e.stdout}[/red]") + console.print(f"[red]Stderr: {e.stderr}[/red]") + sys.exit(1) + + # Run checks + console.print("\n[cyan]Running all pre-release checks...[/cyan]") + try: + subprocess.run( + ["doit", "check"], check=True, capture_output=True, text=True + ) + console.print("[green]\u2713 All checks passed.[/green]") + except subprocess.CalledProcessError as e: + console.print( + "[bold red]\u274c Pre-release checks failed! " + "Please fix issues before tagging.[/bold red]" + ) + console.print(f"[red]Stdout: {e.stdout}[/red]") + console.print(f"[red]Stderr: {e.stderr}[/red]") + sys.exit(1) + + # Automated version bump and tagging + console.print( + f"\n[cyan]Bumping version ({type}) and updating changelog...[/cyan]" + ) + try: + # Use cz bump --prerelease --changelog + result = subprocess.run( + ["uv", "run", "cz", "bump", "--prerelease", type, "--changelog"], + env={**os.environ, "UV_CACHE_DIR": UV_CACHE_DIR}, + check=True, + capture_output=True, + text=True, + ) + console.print(f"[green]\u2713 Version bumped to {type}.[/green]") + console.print(f"[dim]{result.stdout}[/dim]") + # Extract new version + version_match = re.search( + r"Bumping to version (\d+\.\d+\.\d+[^\s]*)", result.stdout + ) + new_version = version_match.group(1) if version_match else "unknown" + + except subprocess.CalledProcessError as e: + console.print("[bold red]\u274c commitizen bump failed![/bold red]") + console.print(f"[red]Stdout: {e.stdout}[/red]") + console.print(f"[red]Stderr: {e.stderr}[/red]") + sys.exit(1) + + console.print(f"\n[cyan]Pushing tag v{new_version} to origin...[/cyan]") + try: + subprocess.run( + ["git", "push", "--follow-tags", "origin", current_branch], + check=True, + capture_output=True, + text=True, + ) + console.print("[green]\u2713 Tags pushed to origin.[/green]") + except subprocess.CalledProcessError as e: + console.print("[bold red]\u274c Error pushing tag to origin:[/bold red]") + console.print(f"[red]Stdout: {e.stdout}[/red]") + console.print(f"[red]Stderr: {e.stderr}[/red]") + sys.exit(1) + + console.print("\n" + "=" * 70) + console.print( + f"[bold green]\u2713 Development release {new_version} complete![/bold green]" + ) + console.print("=" * 70) + console.print("\nNext steps:") + console.print( + "1. Monitor GitHub Actions (testpypi.yml) for the TestPyPI publish." + ) + console.print("2. Verify on TestPyPI once the workflow completes.") + + return { + "actions": [create_dev_release], + "params": [ + { + "name": "type", + "short": "t", + "long": "type", + "default": "alpha", + "help": "Pre-release type (alpha, beta, rc)", + } + ], + "title": title_with_actions, + } + + +def task_release() -> dict[str, Any]: + """Automate release: bump version, update CHANGELOG, and push to GitHub (triggers CI/CD).""" + + def automated_release() -> None: + console = Console() + console.print("=" * 70) + console.print("[bold green]Starting automated release process...[/bold green]") + console.print("=" * 70) + console.print() + + # Check if on main branch + current_branch = subprocess.run( + ["git", "branch", "--show-current"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if current_branch != "main": + console.print( + f"[bold yellow]\u26a0 Warning: Not on main branch " + f"(currently on {current_branch})[/bold yellow]" + ) + response = input("Continue anyway? (y/N) ").strip().lower() + if response != "y": + console.print("[bold red]\u274c Release cancelled.[/bold red]") + sys.exit(1) + + # Check for uncommitted changes + status = subprocess.run( + ["git", "status", "-s"], + capture_output=True, + text=True, + check=True, + ).stdout.strip() + if status: + console.print( + "[bold red]\u274c Error: Uncommitted changes detected.[/bold red]" + ) + console.print(status) + sys.exit(1) + + # Pull latest changes + console.print("\n[cyan]Pulling latest changes...[/cyan]") + try: + subprocess.run(["git", "pull"], check=True, capture_output=True, text=True) + console.print("[green]\u2713 Git pull successful.[/green]") + except subprocess.CalledProcessError as e: + console.print("[bold red]\u274c Error pulling latest changes:[/bold red]") + console.print(f"[red]Stdout: {e.stdout}[/red]") + console.print(f"[red]Stderr: {e.stderr}[/red]") + sys.exit(1) + + # Run all checks + console.print("\n[cyan]Running all pre-release checks...[/cyan]") + try: + subprocess.run( + ["doit", "check"], check=True, capture_output=True, text=True + ) + console.print("[green]\u2713 All checks passed.[/green]") + except subprocess.CalledProcessError as e: + console.print( + "[bold red]\u274c Pre-release checks failed! " + "Please fix issues before releasing.[/bold red]" + ) + console.print(f"[red]Stdout: {e.stdout}[/red]") + console.print(f"[red]Stderr: {e.stderr}[/red]") + sys.exit(1) + + # Automated version bump and CHANGELOG generation using commitizen + console.print( + "\n[cyan]Bumping version and generating CHANGELOG with commitizen...[/cyan]" + ) + try: + # Use cz bump --changelog --merge-prerelease to update version, + # changelog, commit, and tag. This consolidates pre-release changes + # into the final release entry + result = subprocess.run( + ["uv", "run", "cz", "bump", "--changelog", "--merge-prerelease"], + env={**os.environ, "UV_CACHE_DIR": UV_CACHE_DIR}, + check=True, + capture_output=True, + text=True, + ) + console.print( + "[green]\u2713 Version bumped and CHANGELOG updated (merged pre-releases).[/green]" + ) + console.print(f"[dim]{result.stdout}[/dim]") + # Extract new version from cz output (example: "Bumping to version 1.0.0") + version_match = re.search( + r"Bumping to version (\d+\.\d+\.\d+)", result.stdout + ) + # Fallback to "unknown" if regex fails + new_version = version_match.group(1) if version_match else "unknown" + + except subprocess.CalledProcessError as e: + console.print( + "[bold red]\u274c commitizen bump failed! " + "Ensure your commit history is conventional.[/bold red]" + ) + console.print(f"[red]Stdout: {e.stdout}[/red]") + console.print(f"[red]Stderr: {e.stderr}[/red]") + sys.exit(1) + except Exception as e: + console.print( + f"[bold red]\u274c An unexpected error occurred during commitizen bump: {e}[/bold red]" + ) + sys.exit(1) + + # Push commits and tags to GitHub + console.print("\n[cyan]Pushing commits and tags to GitHub...[/cyan]") + try: + subprocess.run( + ["git", "push", "--follow-tags", "origin", current_branch], + check=True, + capture_output=True, + text=True, + ) + console.print( + "[green]\u2713 Pushed new commits and tags to GitHub.[/green]" + ) + except subprocess.CalledProcessError as e: + console.print("[bold red]\u274c Error pushing to GitHub:[/bold red]") + console.print(f"[red]Stdout: {e.stdout}[/red]") + console.print(f"[red]Stderr: {e.stderr}[/red]") + sys.exit(1) + + console.print("\n" + "=" * 70) + console.print( + f"[bold green]\u2713 Automated release {new_version} complete![/bold green]" + ) + console.print("=" * 70) + console.print("\nNext steps:") + console.print("1. Monitor GitHub Actions for build and publish.") + console.print( + "2. Check TestPyPI: [link=https://test.pypi.org/project/bastproxy/]https://test.pypi.org/project/bastproxy/[/link]" + ) + console.print( + "3. Check PyPI: [link=https://pypi.org/project/bastproxy/]https://pypi.org/project/bastproxy/[/link]" + ) + console.print("4. Verify the updated CHANGELOG.md in the repository.") + + return { + "actions": [automated_release], + "title": title_with_actions, + } diff --git a/tools/doit/security.py b/tools/doit/security.py new file mode 100644 index 0000000..4d6b4e8 --- /dev/null +++ b/tools/doit/security.py @@ -0,0 +1,39 @@ +"""Security-related doit tasks.""" + +from typing import Any + +from doit.tools import title_with_actions + + +def task_audit() -> dict[str, Any]: + """Run security audit with pip-audit (requires security extras).""" + return { + "actions": [ + "uv run pip-audit --skip-editable || " + "echo 'pip-audit not installed. Run: uv sync --extra security'" + ], + "title": title_with_actions, + } + + +def task_security() -> dict[str, Any]: + """Run security checks with bandit (requires security extras).""" + return { + "actions": [ + "uv run bandit -c pyproject.toml -r src/ || " + "echo 'bandit not installed. Run: uv sync --extra security'" + ], + "title": title_with_actions, + "verbosity": 0, + } + + +def task_licenses() -> dict[str, Any]: + """Check licenses of dependencies (requires security extras).""" + return { + "actions": [ + "uv run pip-licenses --format=markdown --order=license || " + "echo 'pip-licenses not installed. Run: uv sync --extra security'" + ], + "title": title_with_actions, + } diff --git a/tools/doit/testing.py b/tools/doit/testing.py new file mode 100644 index 0000000..947c008 --- /dev/null +++ b/tools/doit/testing.py @@ -0,0 +1,26 @@ +"""Testing-related doit tasks.""" + +from typing import Any + +from doit.tools import title_with_actions + + +def task_test() -> dict[str, Any]: + """Run pytest with parallel execution.""" + return { + "actions": ["uv run pytest -n auto -v"], + "title": title_with_actions, + "verbosity": 0, + } + + +def task_coverage() -> dict[str, Any]: + """Run pytest with coverage (note: parallel execution disabled for accurate coverage).""" + return { + "actions": [ + "uv run pytest " + "--cov=bastproxy --cov-report=term-missing " + "--cov-report=html:tmp/htmlcov --cov-report=xml:tmp/coverage.xml -v" + ], + "title": title_with_actions, + } diff --git a/tools/hooks/ai/block-dangerous-commands.py b/tools/hooks/ai/block-dangerous-commands.py new file mode 100644 index 0000000..a2299cc --- /dev/null +++ b/tools/hooks/ai/block-dangerous-commands.py @@ -0,0 +1,391 @@ +#!/usr/bin/env python3 +"""Claude Code PreToolUse hook to block dangerous command patterns. + +This hook intercepts Bash commands before execution and blocks those +containing dangerous flags that could bypass security controls. + +Uses shlex to properly parse shell quoting, then checks for dangerous +patterns as standalone tokens (not embedded in quoted argument values). + +Exit codes: + 0 - Allow command + 2 - Block command (shows stderr to Claude) + +For full documentation, see: docs/development/ai/command-blocking.md +""" + +import json +import shlex +import subprocess # nosec B404 - needed for git branch detection +import sys + +# Protected branches - operations on these require extra scrutiny +PROTECTED_BRANCHES = {"main", "master"} + +# Dangerous flags that must appear as exact standalone tokens +DANGEROUS_FLAGS = { + "--admin": "Bypasses branch protection rules", + "--no-verify": "Skips pre-commit/pre-push hooks", + "--hard": "Hard reset - can lose uncommitted changes", +} + +# Dangerous token sequences (checked in order) +# Format: (token_sequence, description) +DANGEROUS_SEQUENCES = [ + (["rm", "-rf", "/"], "Destructive: removes root filesystem"), + (["rm", "-rf", "~"], "Destructive: removes home directory"), + (["sudo", "rm"], "Privileged deletion"), +] + +# Force push flags +FORCE_PUSH_FLAGS = {"--force", "-f", "--force-with-lease"} + +# Blocked workflow commands - use doit wrappers or require user approval +BLOCKED_WORKFLOW_COMMANDS = { + ( + "gh", + "issue", + "create", + ): "Use 'doit issue --type=' instead of 'gh issue create'", + ("gh", "pr", "create"): "Use 'doit pr' instead of 'gh pr create'", + ("gh", "pr", "merge"): "Use 'doit pr_merge' instead of 'gh pr merge'", + ("uv", "add"): ( + "Adding dependencies requires user approval. " + "Suggest the package and let the user run 'uv add ' manually." + ), + ("doit", "release"): ( + "Releases must be run manually by the user, not by AI agents. " + "AI can help prepare (update changelog, verify CI) but not execute releases." + ), + ( + "doit", + "release_dev", + ): "Releases must be run manually by the user, not by AI agents.", + ( + "doit", + "release_tag", + ): "Releases must be run manually by the user, not by AI agents.", + ( + "doit", + "release_pr", + ): "Releases must be run manually by the user, not by AI agents.", +} + +# Governance labels that require human approval - AI should never add these +GOVERNANCE_LABELS = { + "ready-to-merge": ( + "The 'ready-to-merge' label is a governance control requiring human approval. " + "Add this label manually via 'gh pr edit --add-label ready-to-merge' or the GitHub web UI." + ), +} + + +def tokenize(command: str) -> list[str]: + """Tokenize command using shlex for proper shell quote handling. + + shlex.split() correctly handles: + - Double quoted strings: "text with --admin" + - Single quoted strings: 'text with --force' + - Embedded quotes: --body="value" + - Escape sequences + + Returns list of tokens with quotes stripped from values. + """ + try: + return shlex.split(command, posix=True) + except ValueError: + # Fallback for malformed quotes - try non-POSIX mode + try: + return shlex.split(command, posix=False) + except ValueError: + # Last resort - simple whitespace split + return command.split() + + +def check_dangerous_flags(tokens: list[str]) -> tuple[bool, str]: + """Check if any dangerous flag appears as a standalone token. + + A flag in a quoted argument value (e.g., -m "--admin mentioned") + becomes part of a larger token and won't match. + """ + for token in tokens: + if token in DANGEROUS_FLAGS: + return True, DANGEROUS_FLAGS[token] + return False, "" + + +def check_dangerous_sequences(tokens: list[str]) -> tuple[bool, str]: + """Check if dangerous token sequences appear in the command. + + Looks for consecutive tokens matching dangerous patterns. + """ + tokens_lower = [t.lower() for t in tokens] + + for sequence, reason in DANGEROUS_SEQUENCES: + seq_len = len(sequence) + for i in range(len(tokens_lower) - seq_len + 1): + if tokens_lower[i : i + seq_len] == [s.lower() for s in sequence]: + return True, reason + return False, "" + + +def check_force_push_to_protected(tokens: list[str]) -> tuple[bool, str]: + """Check if command is a force push to a protected branch. + + Allows force push to feature branches but blocks force push to main/master. + If no branch is specified, blocks by default (safer). + """ + tokens_lower = [t.lower() for t in tokens] + + # Must be a git push command + if "git" not in tokens_lower or "push" not in tokens_lower: + return False, "" + + # Check if any force flag is present + has_force_flag = any(flag in tokens for flag in FORCE_PUSH_FLAGS) + if not has_force_flag: + return False, "" + + # Find the push index to look for branch/remote after it + try: + push_idx = tokens_lower.index("push") + except ValueError: + return False, "" + + # Look for protected branch name in tokens after 'push' + # Skip flags (tokens starting with -) + after_push = [t for t in tokens[push_idx + 1 :] if not t.startswith("-")] + + # Check if any token after push is a protected branch + for token in after_push: + # Handle origin/main format + branch = token.split("/")[-1] if "/" in token else token + + if branch.lower() in PROTECTED_BRANCHES: + return True, f"Force push to protected branch '{branch}'" + + # If no branch specified, block by default (could push to current branch = main) + if not after_push or (len(after_push) == 1 and after_push[0] == "origin"): + return ( + True, + "Force push without explicit branch (could affect protected branch)", + ) + + return False, "" + + +def check_delete_protected_branch(tokens: list[str]) -> tuple[bool, str]: + """Check if command deletes a protected branch (local or remote). + + Catches: + - git push origin --delete main + - git push origin :main + - git branch -D main + - git branch -d main + """ + tokens_lower = [t.lower() for t in tokens] + + # Check for remote branch deletion: git push origin --delete main + if "git" in tokens_lower and "push" in tokens_lower and "--delete" in tokens_lower: + for token in tokens: + if token.lower() in PROTECTED_BRANCHES: + return True, f"Deleting protected remote branch '{token}'" + + # Check for remote branch deletion with colon syntax: git push origin :main + if "git" in tokens_lower and "push" in tokens_lower: + for token in tokens: + if token.startswith(":") and token[1:].lower() in PROTECTED_BRANCHES: + return True, f"Deleting protected remote branch '{token[1:]}'" + + # Check for local branch deletion: git branch -D main or git branch -d main + if ( + "git" in tokens_lower + and "branch" in tokens_lower + and ("-d" in tokens_lower or "-D" in tokens) + ): + for token in tokens: + if token.lower() in PROTECTED_BRANCHES: + return True, f"Deleting protected local branch '{token}'" + + return False, "" + + +def get_current_branch() -> str | None: + """Get the current git branch name. + + Returns None if not in a git repository or in detached HEAD state. + """ + try: + result = subprocess.run( # nosec B603 B607 - trusted git command + ["git", "branch", "--show-current"], + check=False, + capture_output=True, + text=True, + timeout=5, + ) + if result.returncode == 0: + return result.stdout.strip() or None + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + return None + + +def check_blocked_workflow_commands(tokens: list[str]) -> tuple[bool, str]: + """Check if command uses blocked workflow commands. + + These commands should use doit wrappers instead of direct gh commands. + """ + tokens_lower = [t.lower() for t in tokens] + + for cmd_tuple, reason in BLOCKED_WORKFLOW_COMMANDS.items(): + cmd_len = len(cmd_tuple) + if len(tokens_lower) >= cmd_len and tuple(tokens_lower[:cmd_len]) == cmd_tuple: + return True, reason + return False, "" + + +def check_governance_labels(tokens: list[str]) -> tuple[bool, str]: + """Check if command attempts to add a governance label. + + Governance labels (like 'ready-to-merge') require human approval and + should never be added by AI agents. + """ + tokens_lower = [t.lower() for t in tokens] + + # Check for: gh pr edit --add-label + # or: gh issue edit --add-label + if "gh" not in tokens_lower: + return False, "" + + if "edit" not in tokens_lower: + return False, "" + + if "--add-label" not in tokens_lower: + return False, "" + + # Check if any governance label is being added + for label, reason in GOVERNANCE_LABELS.items(): + if label.lower() in tokens_lower: + return True, reason + + return False, "" + + +def check_merge_to_protected(tokens: list[str]) -> tuple[bool, str]: + """Check if command is a merge that would create a merge commit on a protected branch. + + Protected branches often require linear history (no merge commits). + Blocks `git merge` on protected branches unless --ff-only is specified. + """ + tokens_lower = [t.lower() for t in tokens] + + # Must be a git merge command + if "git" not in tokens_lower or "merge" not in tokens_lower: + return False, "" + + # Allow if --ff-only is specified (fast-forward only, no merge commit) + if "--ff-only" in tokens_lower: + return False, "" + + # Check if we're on a protected branch + current_branch = get_current_branch() + if current_branch and current_branch.lower() in PROTECTED_BRANCHES: + return True, ( + f"Merge on protected branch '{current_branch}' would create merge commit. " + f"Use --ff-only for fast-forward merge, or merge via PR" + ) + + return False, "" + + +def check_command(command: str) -> tuple[bool, str]: + """Check if command contains dangerous patterns. + + Uses shlex to tokenize, then checks for: + 1. Dangerous flags as standalone tokens + 2. Dangerous token sequences + 3. Force push to protected branches + 4. Deletion of protected branches + 5. Merge commits on protected branches + 6. Blocked workflow commands + 7. Governance labels + + Returns: + (is_dangerous, reason) + """ + tokens = tokenize(command) + + # Check for dangerous standalone flags + is_dangerous, reason = check_dangerous_flags(tokens) + if is_dangerous: + return True, reason + + # Check for dangerous sequences + is_dangerous, reason = check_dangerous_sequences(tokens) + if is_dangerous: + return True, reason + + # Check for force push to protected branches + is_dangerous, reason = check_force_push_to_protected(tokens) + if is_dangerous: + return True, reason + + # Check for deletion of protected branches + is_dangerous, reason = check_delete_protected_branch(tokens) + if is_dangerous: + return True, reason + + # Check for merge commits on protected branches + is_dangerous, reason = check_merge_to_protected(tokens) + if is_dangerous: + return True, reason + + # Check for blocked workflow commands + is_dangerous, reason = check_blocked_workflow_commands(tokens) + if is_dangerous: + return True, reason + + # Check for governance labels + is_dangerous, reason = check_governance_labels(tokens) + if is_dangerous: + return True, reason + + return False, "" + + +def main() -> int: + """Main entry point.""" + try: + input_data = json.load(sys.stdin) + except json.JSONDecodeError as e: + print(f"Invalid JSON input: {e}", file=sys.stderr) + return 1 + + tool_name = input_data.get("tool_name", "") + tool_input = input_data.get("tool_input", {}) + + # Only check shell commands (Bash for Claude, run_shell_command for Gemini) + if tool_name not in ("Bash", "run_shell_command"): + return 0 + + command = tool_input.get("command", "") + if not command: + return 0 + + is_dangerous, reason = check_command(command) + if is_dangerous: + print( + f"BLOCKED: Command contains dangerous pattern.\n" + f"Reason: {reason}\n" + f"Command: {command}\n" + f"\n" + f"If this is intentional, ask the user to run it manually.", + file=sys.stderr, + ) + return 2 # Exit 2 = Block and show stderr to Claude + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/pyproject_template/__init__.py b/tools/pyproject_template/__init__.py new file mode 100644 index 0000000..8819472 --- /dev/null +++ b/tools/pyproject_template/__init__.py @@ -0,0 +1,70 @@ +""" +pyproject-template tools package. + +Provides utilities for managing Python projects based on pyproject-template. +""" + +from .check_template_updates import ( + compare_files, + download_template, + get_latest_release, + run_check_updates, +) +from .configure import ( + load_defaults, + run_configure, +) +from .migrate_existing_project import ( + run_migrate, +) +from .settings import ( + ProjectContext, + ProjectSettings, + SettingsManager, + TemplateState, + get_template_commits_since, + get_template_latest_commit, +) +from .utils import ( + Colors, + GitHubCLI, + Logger, + download_and_extract_archive, + prompt, + prompt_confirm, + update_file, + validate_email, + validate_package_name, + validate_pypi_name, +) + +__all__ = [ + # Settings + "ProjectContext", + "ProjectSettings", + "SettingsManager", + "TemplateState", + "get_template_commits_since", + "get_template_latest_commit", + # Configure + "load_defaults", + "run_configure", + # Check updates + "compare_files", + "download_template", + "get_latest_release", + "run_check_updates", + # Migrate + "run_migrate", + # Utils + "Colors", + "GitHubCLI", + "Logger", + "download_and_extract_archive", + "prompt", + "prompt_confirm", + "update_file", + "validate_email", + "validate_package_name", + "validate_pypi_name", +] diff --git a/tools/pyproject_template/check_template_updates.py b/tools/pyproject_template/check_template_updates.py new file mode 100644 index 0000000..5eb9509 --- /dev/null +++ b/tools/pyproject_template/check_template_updates.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +""" +check-template-updates.py - Compare project against latest template + +This script fetches the latest pyproject-template release and shows which files +differ from the template. User can then manually review and merge changes. + +Usage: + python tools/pyproject_template/check_template_updates.py + python tools/pyproject_template/check_template_updates.py --template-version v2.2.0 + python tools/pyproject_template/check_template_updates.py --skip-changelog + +Requirements: + - Git installed + - Python 3.12+ + - Internet connection (to fetch template) + +Author: Generated from pyproject-template +License: MIT +""" + +from __future__ import annotations + +import argparse +import filecmp +import json +import os +import shutil +import subprocess # nosec B404 +import sys +import urllib.request +from pathlib import Path + +# Support running as script or as module +_script_dir = Path(__file__).parent +if str(_script_dir) not in sys.path: + sys.path.insert(0, str(_script_dir)) + +# Import shared utilities +from utils import ( # noqa: E402 + TEMPLATE_REPO, + TEMPLATE_URL, + Colors, + Logger, + download_and_extract_archive, +) + +# Default archive URL derived from template constants +DEFAULT_ARCHIVE_URL = f"{TEMPLATE_URL}/archive/refs/heads/main.zip" + + +def get_latest_release() -> str | None: + """Get the latest release tag from GitHub API.""" + api_url = f"https://api.github.com/repos/{TEMPLATE_REPO}/releases/latest" + try: + with urllib.request.urlopen(api_url) as response: # nosec B310 + data = json.loads(response.read()) + tag_name: str | None = data.get("tag_name") + return tag_name + except Exception as e: + Logger.warning(f"Could not fetch latest release: {e}") + return None + + +def download_template(target_dir: Path, version: str | None = None) -> Path: + """Download and extract template to target directory.""" + # Determine download URL + if version: + archive_url = f"{TEMPLATE_URL}/archive/refs/tags/{version}.zip" + else: + archive_url = DEFAULT_ARCHIVE_URL + + template_root = Path(download_and_extract_archive(archive_url, target_dir)) + Logger.success(f"Template extracted to {template_root}") + return template_root + + +def open_changelog(template_dir: Path) -> None: + """Open CHANGELOG.md in user's editor.""" + changelog = template_dir / "CHANGELOG.md" + if not changelog.exists(): + Logger.warning("CHANGELOG.md not found in template") + return + + editor = os.environ.get("EDITOR", "less") + + print(f"\n{Colors.CYAN}Opening CHANGELOG.md for review...{Colors.NC}") + print("(Close the editor when you're done)\n") + + try: + subprocess.run([editor, str(changelog)], check=True) + except subprocess.CalledProcessError: + Logger.warning(f"Failed to open editor '{editor}'") + except FileNotFoundError: + Logger.warning(f"Editor '{editor}' not found, skipping changelog view") + + +def compare_files(project_root: Path, template_root: Path) -> list[Path]: + """Compare project files against template and return list of different files.""" + # Files/directories to skip + skip_patterns = { + ".git", + ".venv", + "venv", + "__pycache__", + "tmp", + ".pytest_cache", + ".mypy_cache", + ".ruff_cache", + "*.pyc", + "*.pyo", + "uv.lock", + ".envrc.local", + "site", # mkdocs build output + } + + # Detect user's actual package name (directory under src/ that isn't package_name) + actual_package_name: str | None = None + src_dir = project_root / "src" + if src_dir.exists(): + for item in src_dir.iterdir(): + if item.is_dir() and item.name != "package_name" and not item.name.startswith("."): + actual_package_name = item.name + break + + different_files: list[Path] = [] + + # Walk through template files + for template_file in template_root.rglob("*"): + if not template_file.is_file(): + continue + + # Skip ignored patterns + rel_path = template_file.relative_to(template_root) + if any(part in skip_patterns for part in rel_path.parts): + continue + if any(rel_path.match(pattern) for pattern in skip_patterns): + continue + + # Map src/package_name/* to src/{actual_package_name}/* + mapped_path = rel_path + if ( + actual_package_name + and len(rel_path.parts) >= 2 + and rel_path.parts[0] == "src" + and rel_path.parts[1] == "package_name" + ): + mapped_path = Path("src", actual_package_name, *rel_path.parts[2:]) + + # Compare with project file + project_file = project_root / mapped_path + + if not project_file.exists() or not filecmp.cmp(template_file, project_file, shallow=False): + different_files.append(rel_path) + + return sorted(different_files) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Compare your project against the latest pyproject-template." + ) + parser.add_argument( + "--template-version", + type=str, + default=None, + help=( + "Compare against specific template version tag (e.g., v2.2.0). " + "Defaults to latest release." + ), + ) + parser.add_argument( + "--skip-changelog", + action="store_true", + help="Skip opening CHANGELOG.md in editor", + ) + parser.add_argument( + "--keep-template", + action="store_true", + help="Keep downloaded template after comparison (don't clean up)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be checked without downloading (currently same as normal)", + ) + return parser.parse_args(argv) + + +def run_check_updates( + template_version: str | None = None, + skip_changelog: bool = False, + keep_template: bool = False, + dry_run: bool = False, +) -> int: + """Check for template updates. + + Args: + template_version: Specific template version to compare against. + skip_changelog: Skip opening CHANGELOG.md in editor. + keep_template: Keep downloaded template after comparison. + dry_run: Show what would be done without making changes. + + Returns: + Exit code (0 for success, non-zero for error). + """ + project_root = Path.cwd() + tmp_dir = project_root / "tmp" + tmp_dir.mkdir(exist_ok=True) + + # Get template version + version: str | None = None + if template_version: + version = template_version + Logger.info(f"Comparing against template version: {version}") + else: + version = get_latest_release() + if version: + Logger.info(f"Latest template release: {version}") + else: + Logger.info("Comparing against template main branch") + + if dry_run: + Logger.info("Dry run mode - would download and compare template files") + return 0 + + # Download template + template_dir = download_template(tmp_dir, version) + + # Open CHANGELOG.md for review + if not skip_changelog: + open_changelog(template_dir) + + # Compare files + Logger.header("Comparing your project to template") + different_files = compare_files(project_root, template_dir) + + # Detect user's actual package name for display mapping + actual_package_name: str | None = None + src_dir = project_root / "src" + if src_dir.exists(): + for item in src_dir.iterdir(): + if item.is_dir() and item.name != "package_name" and not item.name.startswith("."): + actual_package_name = item.name + break + + if not different_files: + Logger.success("Your project matches the template perfectly!") + print("\nNo differences found.") + else: + count = len(different_files) + print(f"\n{Colors.YELLOW}Files different from template ({count} files):{Colors.NC}") + print("━" * 60) + + for file_path in different_files: + # Map src/package_name/* to src/{actual_package_name}/* for checking + mapped_path = file_path + if ( + actual_package_name + and len(file_path.parts) >= 2 + and file_path.parts[0] == "src" + and file_path.parts[1] == "package_name" + ): + mapped_path = Path("src", actual_package_name, *file_path.parts[2:]) + + project_file = project_root / mapped_path + if project_file.exists(): + print(f" {file_path}") + else: + print(f" {file_path} {Colors.CYAN}(new in template){Colors.NC}") + + # Show how to compare + Logger.header("How to Review Changes") + template_rel = template_dir.relative_to(project_root) + print(f"Template files downloaded to: {Colors.CYAN}{template_rel}{Colors.NC}\n") + + print("To compare specific files:") + # Show a few example diff commands + for file_path in different_files[:3]: + project_file = project_root / file_path + template_file = template_dir / file_path + if project_file.exists(): + print(f" diff {file_path} {template_file.relative_to(project_root)}") + + if len(different_files) > 3: + print(f" ... ({len(different_files) - 3} more files)") + + print(f"\nOr browse all template files: {template_dir.relative_to(project_root)}/") + + # Cleanup + if not keep_template: + print() + Logger.info("Cleaning up downloaded template...") + shutil.rmtree(template_dir.parent) + Logger.success("Cleanup complete") + else: + print() + Logger.info(f"Template kept at: {template_dir.relative_to(project_root)}") + + print() + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Main entry point for CLI usage.""" + args = parse_args(argv) + return run_check_updates( + template_version=args.template_version, + skip_changelog=args.skip_changelog, + keep_template=args.keep_template, + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + import sys + + print("This script should not be run directly.") + print("Please use: python manage.py") + sys.exit(1) diff --git a/tools/pyproject_template/cleanup.py b/tools/pyproject_template/cleanup.py new file mode 100644 index 0000000..1c28b28 --- /dev/null +++ b/tools/pyproject_template/cleanup.py @@ -0,0 +1,358 @@ +#!/usr/bin/env python3 +""" +cleanup.py - Template file cleanup utilities. + +This module provides functions to remove template-specific files from projects +created from pyproject-template. Users can choose to: +1. Remove setup files only (keep update checking capability) +2. Remove all template files (no future update checking) + +Usage: + from cleanup import cleanup_template_files, CleanupMode + + # Remove setup files only + cleanup_template_files(CleanupMode.SETUP_ONLY) + + # Remove all template files + cleanup_template_files(CleanupMode.ALL) + + # Preview what would be deleted + cleanup_template_files(CleanupMode.SETUP_ONLY, dry_run=True) +""" + +from __future__ import annotations + +import re +import shutil +import sys +from enum import Enum +from pathlib import Path +from typing import NamedTuple + +# Support running as script or as module +_script_dir = Path(__file__).parent +if str(_script_dir) not in sys.path: + sys.path.insert(0, str(_script_dir)) + +from utils import Logger # noqa: E402 + + +class CleanupMode(Enum): + """Cleanup mode selection.""" + + SETUP_ONLY = "setup" # Remove setup files, keep update checking + ALL = "all" # Remove all template files + + +class CleanupResult(NamedTuple): + """Result of cleanup operation.""" + + deleted_files: list[Path] + deleted_dirs: list[Path] + failed: list[tuple[Path, str]] + mkdocs_updated: bool + + +# Files to delete when removing setup files only +# These are only needed for initial project creation +SETUP_FILES = [ + "bootstrap.py", + "tools/pyproject_template/setup_repo.py", + "tools/pyproject_template/migrate_existing_project.py", + "docs/template/new-project.md", + "docs/template/migration.md", +] + +# Additional files to delete when removing all template files +# After this, no template update checking is possible +ALL_TEMPLATE_FILES = [ + "tools/pyproject_template/manage.py", + "tools/pyproject_template/check_template_updates.py", + "tools/pyproject_template/configure.py", + "tools/pyproject_template/settings.py", + "tools/pyproject_template/repo_settings.py", + "tools/pyproject_template/cleanup.py", + "tools/pyproject_template/utils.py", + "tools/pyproject_template/__init__.py", + "docs/template/index.md", + "docs/template/manage.md", + "docs/template/updates.md", + "docs/template/tools-reference.md", +] + +# Directories to delete when removing all template files +ALL_TEMPLATE_DIRS = [ + "tools/pyproject_template", + "docs/template", + ".config/pyproject_template", +] + + +def get_files_to_delete(mode: CleanupMode, root: Path | None = None) -> list[Path]: + """Get list of files that would be deleted for the given mode. + + Args: + mode: Cleanup mode (SETUP_ONLY or ALL) + root: Project root directory (defaults to cwd) + + Returns: + List of file paths that exist and would be deleted + """ + if root is None: + root = Path.cwd() + + files = SETUP_FILES.copy() + if mode == CleanupMode.ALL: + files.extend(ALL_TEMPLATE_FILES) + + existing_files = [] + for file_path in files: + full_path = root / file_path + if full_path.is_file(): + existing_files.append(full_path) + + return existing_files + + +def get_dirs_to_delete(mode: CleanupMode, root: Path | None = None) -> list[Path]: + """Get list of directories that would be deleted for the given mode. + + Args: + mode: Cleanup mode (only ALL mode deletes directories) + root: Project root directory (defaults to cwd) + + Returns: + List of directory paths that exist and would be deleted + """ + if root is None: + root = Path.cwd() + + if mode != CleanupMode.ALL: + return [] + + existing_dirs = [] + for dir_path in ALL_TEMPLATE_DIRS: + full_path = root / dir_path + if full_path.is_dir(): + existing_dirs.append(full_path) + + return existing_dirs + + +def update_mkdocs_nav(root: Path | None = None, dry_run: bool = False) -> bool: + """Remove Template section from mkdocs.yml navigation. + + Args: + root: Project root directory (defaults to cwd) + dry_run: If True, only report what would be changed + + Returns: + True if mkdocs.yml was updated (or would be), False otherwise + """ + if root is None: + root = Path.cwd() + + mkdocs_file = root / "mkdocs.yml" + if not mkdocs_file.exists(): + return False + + content = mkdocs_file.read_text() + + # Pattern to match the Template section in nav + # Matches from " - Template:" to the next " - " at the same indent level or end of nav + pattern = r"( - Template:\n(?: - [^\n]+\n)*)" + + if not re.search(pattern, content): + return False + + if dry_run: + Logger.info("Would remove Template section from mkdocs.yml") + return True + + new_content = re.sub(pattern, "", content) + mkdocs_file.write_text(new_content) + Logger.success("Removed Template section from mkdocs.yml") + return True + + +def cleanup_template_files( + mode: CleanupMode, + root: Path | None = None, + dry_run: bool = False, +) -> CleanupResult: + """Remove template-specific files from the project. + + Args: + mode: Cleanup mode (SETUP_ONLY or ALL) + root: Project root directory (defaults to cwd) + dry_run: If True, only report what would be deleted + + Returns: + CleanupResult with details of what was deleted + """ + if root is None: + root = Path.cwd() + + deleted_files: list[Path] = [] + deleted_dirs: list[Path] = [] + failed: list[tuple[Path, str]] = [] + + # Get files and directories to delete + files_to_delete = get_files_to_delete(mode, root) + dirs_to_delete = get_dirs_to_delete(mode, root) + + if not files_to_delete and not dirs_to_delete: + Logger.info("No template files found to clean up") + return CleanupResult([], [], [], False) + + # Report what will be deleted + if files_to_delete: + Logger.step(f"{'Would delete' if dry_run else 'Deleting'} files:") + for file_path in files_to_delete: + rel_path = file_path.relative_to(root) + print(f" - {rel_path}") + + if dirs_to_delete: + Logger.step(f"{'Would delete' if dry_run else 'Deleting'} directories:") + for dir_path in dirs_to_delete: + rel_path = dir_path.relative_to(root) + print(f" - {rel_path}/") + + if dry_run: + # Check mkdocs update + mkdocs_would_update = False + if mode == CleanupMode.ALL: + mkdocs_would_update = update_mkdocs_nav(root, dry_run=True) + return CleanupResult(files_to_delete, dirs_to_delete, [], mkdocs_would_update) + + # Delete files + for file_path in files_to_delete: + try: + file_path.unlink() + deleted_files.append(file_path) + except OSError as e: + failed.append((file_path, str(e))) + + # Delete directories (only for ALL mode, and only after files are deleted) + # Sort by depth (deepest first) to avoid deleting parent before child + for dir_path in sorted(dirs_to_delete, key=lambda p: len(p.parts), reverse=True): + try: + if dir_path.exists(): + shutil.rmtree(dir_path) + deleted_dirs.append(dir_path) + except OSError as e: + failed.append((dir_path, str(e))) + + # Update mkdocs.yml if removing all template files + mkdocs_updated = False + if mode == CleanupMode.ALL: + mkdocs_updated = update_mkdocs_nav(root, dry_run=False) + + # Report results + if deleted_files: + Logger.success(f"Deleted {len(deleted_files)} files") + if deleted_dirs: + Logger.success(f"Deleted {len(deleted_dirs)} directories") + if failed: + Logger.warning(f"Failed to delete {len(failed)} items:") + for path, error in failed: + rel_path = path.relative_to(root) if path.is_relative_to(root) else path + print(f" - {rel_path}: {error}") + + return CleanupResult(deleted_files, deleted_dirs, failed, mkdocs_updated) + + +def prompt_cleanup(root: Path | None = None) -> CleanupMode | None: + """Interactively prompt user for cleanup mode. + + Args: + root: Project root directory (defaults to cwd) + + Returns: + Selected CleanupMode, or None if user chose to keep all files + """ + if root is None: + root = Path.cwd() + + print() + Logger.header("Template File Cleanup") + print() + print("Would you like to remove template-specific files?") + print() + print(" [1] Remove setup files only (keep update checking)") + print(" Removes: bootstrap.py, setup_repo.py, migrate_existing_project.py") + print(" Keeps: manage.py, check_template_updates.py (for future updates)") + print() + print(" [2] Remove all template files (no future update checking)") + print(" Removes: All template tools and documentation") + print(" Warning: You won't be able to check for template updates") + print() + print(" [3] Keep all files") + print() + + while True: + try: + choice = input("Select option [1-3]: ").strip() + except (EOFError, KeyboardInterrupt): + print() + return None + + if choice == "1": + return CleanupMode.SETUP_ONLY + elif choice == "2": + return CleanupMode.ALL + elif choice == "3": + return None + else: + print("Invalid option. Please enter 1, 2, or 3.") + + +def main() -> int: + """Main entry point for standalone usage.""" + import argparse + + parser = argparse.ArgumentParser(description="Remove template-specific files from the project.") + parser.add_argument( + "--setup", + action="store_true", + help="Remove setup files only (keep update checking)", + ) + parser.add_argument( + "--all", + action="store_true", + help="Remove all template files (no future update checking)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be deleted without actually deleting", + ) + + args = parser.parse_args() + + # Determine mode + if args.setup and args.all: + Logger.error("Cannot specify both --setup and --all") + return 1 + elif args.setup: + mode = CleanupMode.SETUP_ONLY + elif args.all: + mode = CleanupMode.ALL + else: + # Interactive mode + mode = prompt_cleanup() + if mode is None: + Logger.info("Keeping all template files") + return 0 + + # Perform cleanup + result = cleanup_template_files(mode, dry_run=args.dry_run) + + if args.dry_run: + Logger.info("Dry run complete. No files were deleted.") + + return 0 if not result.failed else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tools/pyproject_template/configure.py b/tools/pyproject_template/configure.py new file mode 100644 index 0000000..d17eb91 --- /dev/null +++ b/tools/pyproject_template/configure.py @@ -0,0 +1,407 @@ +#!/usr/bin/env python3 +"""Interactive/non-interactive script to configure the project template. + +Modes: +- Interactive (default): prompts for values with defaults pulled from pyproject.toml. +- Auto: use values from pyproject.toml (and git remote) with no prompts (--auto). +Optional: skip final confirmation with --yes. +""" + +import argparse +import shutil +import sys +from pathlib import Path + +# Support running as script or as module +_script_dir = Path(__file__).parent +if str(_script_dir) not in sys.path: + sys.path.insert(0, str(_script_dir)) + +# Import shared utilities +from utils import ( # noqa: E402 + FILES_TO_UPDATE, + Logger, + get_first_author, + get_git_config, + load_toml_file, + parse_github_url, + prompt, + prompt_confirm, + update_file, + update_test_files, + validate_email, + validate_package_name, + validate_pypi_name, +) + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Configure the project template.") + parser.add_argument( + "--auto", + action="store_true", + help="Use values from pyproject.toml (and git remote) without prompts.", + ) + parser.add_argument( + "--yes", + action="store_true", + help="Skip confirmation prompt (useful with --auto).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be done without making changes.", + ) + return parser.parse_args(argv) + + +def find_backup_pyproject() -> Path | None: + """Find the most recent backup pyproject.toml from migration helper (in tmp/).""" + candidates = [] + for backup_dir in Path("tmp").glob("template-migration-backup-*"): + pyproject = backup_dir / "pyproject.toml" + if pyproject.exists(): + candidates.append(pyproject) + if not candidates: + return None + candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True) + return candidates[0] + + +def guess_github_user(pyproject_data: dict[str, object]) -> str: + """Get GitHub user from pyproject data or git remote.""" + # First try from pyproject.toml repository URL + project = pyproject_data.get("project", {}) + urls = project.get("urls", {}) if isinstance(project, dict) else {} + repo_url = urls.get("Repository", "") if isinstance(urls, dict) else "" + github_user: str + github_user, _ = parse_github_url(str(repo_url)) + if github_user: + return github_user + + # Fallback: try git remote origin + remote_url = get_git_config("remote.origin.url") + github_user, _ = parse_github_url(remote_url) + return github_user + + +def read_readme_title(readme_path: Path) -> str: + if not readme_path.exists(): + return "" + for line in readme_path.read_text().splitlines(): + if line.startswith("#"): + return line.lstrip("#").strip() + return "" + + +def load_defaults(pyproject_path: Path) -> dict[str, str]: + data = load_toml_file(pyproject_path) + project = data.get("project", {}) + project_name = project.get("name", "") + package_name = validate_package_name(project_name) if project_name else "" + pypi_name = validate_pypi_name(project_name) if project_name else "" + description = project.get("description") or read_readme_title(Path("README.md")) + author_name, author_email = get_first_author(data) + github_user = guess_github_user(data) + + # If current pyproject still has template placeholders, try backup for real values + backup_pyproject = find_backup_pyproject() + if backup_pyproject: + backup_data = load_toml_file(backup_pyproject) + b_project = backup_data.get("project", {}) + + def not_placeholder(val: str, placeholders: set[str]) -> bool: + return bool(val) and val not in placeholders + + if not_placeholder(project_name, {"package_name", "Package Name", ""}): + pass + else: + project_name = b_project.get("name", project_name) + package_name = validate_package_name(project_name) if project_name else package_name + pypi_name = validate_pypi_name(project_name) if project_name else pypi_name + + if not_placeholder(description, {"A short description of your package", ""}): + pass + else: + description = b_project.get("description", description) + + b_author_name, b_author_email = get_first_author(backup_data) + if not_placeholder(author_name, {"Your Name", ""}): + pass + else: + author_name = b_author_name or author_name + if not_placeholder(author_email, {"your.email@example.com", ""}): + pass + else: + author_email = b_author_email or author_email + + if not_placeholder(github_user, {"username", ""}): + pass + else: + github_user = guess_github_user(backup_data) or github_user + + return { + "project_name": project_name, + "package_name": package_name, + "pypi_name": pypi_name, + "description": description, + "author_name": author_name, + "author_email": author_email, + "github_user": github_user, + } + + +def require(value: str, label: str) -> str: + if value: + return value + raise SystemExit( + f"❌ Missing required value for {label} (supply in pyproject.toml or via prompt)." + ) + + +def run_configure( + auto: bool = False, + yes: bool = False, + dry_run: bool = False, + defaults: dict[str, str] | None = None, +) -> int: + """Run the configuration wizard. + + Args: + auto: Use values from pyproject.toml without prompts. + yes: Skip confirmation prompt. + dry_run: Show what would be done without making changes. + defaults: Pre-loaded default values (if None, loads from pyproject.toml). + + Returns: + Exit code (0 for success, non-zero for error). + """ + # Ensure script is run from project root + if not Path("pyproject.toml").exists(): + Logger.error("Please run this script from the project root directory.") + return 1 + + if defaults is None: + defaults = load_defaults(Path("pyproject.toml")) + + Logger.header("Python Project Template Configuration") + print("\nThis script will help you set up your new Python project.\n") + + # Gather project information + Logger.step("Project Information") + + if auto: + project_name = require(defaults["project_name"], "[project].name") + package_name = require(defaults["package_name"], "package name") + pypi_name = require(defaults["pypi_name"], "PyPI name") + description = require(defaults["description"], "description") + author_name = require(defaults["author_name"], "author name") + author_email = require(defaults["author_email"], "author email") + if not validate_email(author_email): + raise SystemExit("❌ Invalid email format in pyproject.toml") + github_user = require( + defaults["github_user"], + "GitHub user (from Repository URL or git remote)", + ) + enable_dependabot = False + else: + project_name = prompt( + "Project name (human-readable)", + defaults["project_name"] or "My Awesome Project", + ) + + suggested_package = validate_package_name(project_name) + suggested_pypi = validate_pypi_name(project_name) + + package_name = prompt("Python package name", defaults["package_name"] or suggested_package) + package_name = validate_package_name(package_name) + + pypi_name = prompt("PyPI package name", defaults["pypi_name"] or suggested_pypi) + pypi_name = validate_pypi_name(pypi_name) + + description = prompt( + "Short description", + defaults["description"] or "A short description of your package", + ) + + author_name = prompt("Author name", defaults["author_name"] or "Your Name") + + while True: + author_email = prompt( + "Author email", + defaults["author_email"] or "your.email@example.com", + ) + if validate_email(author_email): + break + Logger.warning("Invalid email format. Please try again.") + + github_user = prompt("GitHub username", defaults["github_user"] or "username") + + # Optional features + if auto: + enable_dependabot = False + else: + print() + Logger.step("Optional Features") + enable_dependabot = prompt_confirm("Enable Dependabot for automatic dependency updates?") + + # Confirm configuration + Logger.header("Configuration Summary") + print(f"Project Name: {project_name}") + print(f"Package Name: {package_name}") + print(f"PyPI Name: {pypi_name}") + print(f"Description: {description}") + print(f"Author: {author_name} <{author_email}>") + print(f"GitHub: {github_user}") + print(f"Dependabot: {'Enabled' if enable_dependabot else 'Disabled'}") + print("━" * 60) + + if not yes and not prompt_confirm("\nProceed with configuration?"): + Logger.warning("Configuration cancelled.") + return 1 + + if dry_run: + Logger.info("Dry run mode - no changes will be made") + Logger.success("Configuration validated successfully (dry run)") + return 0 + + Logger.step("Configuring project...") + + # Define replacements + # IMPORTANT: Longer/Specific replacements must come before shorter substrings + replacements = { + # URLs (Specific matches first) + "https://github.com/username/package_name": f"https://github.com/{github_user}/{package_name}", + "https://github.com/original-owner/package_name": f"https://github.com/{github_user}/{package_name}", + "https://codecov.io/gh/username/package_name": f"https://codecov.io/gh/{github_user}/{package_name}", + "https://codecov.io/gh/username/package_name/branch/main/graph/badge.svg": f"https://codecov.io/gh/{github_user}/{package_name}/branch/main/graph/badge.svg", + "https://github.com/username/package_name/actions/workflows/ci.yml/badge.svg": f"https://github.com/{github_user}/{package_name}/actions/workflows/ci.yml/badge.svg", + "https://github.com/username": f"https://github.com/{github_user}", + # GitHub Pages URL (mkdocs.yml) + "https://username.github.io/package_name": f"https://{github_user}.github.io/{package_name}", + # Files and Paths + "package-name.svg": f"{pypi_name}.svg", + "package-name/": f"{pypi_name}/", + # Repo name pattern (mkdocs.yml) - must come before general package_name + "username/package_name": f"{github_user}/{package_name}", + # General Placeholders (Substrings) + "package_name": package_name, + "package-name": pypi_name, + "Package Name": project_name, + "A short description of your package": description, + "Your Name": author_name, + "your.email@example.com": author_email, + # GitHub template placeholders (for issue templates, etc.) + "{owner}": github_user, + "{repo}": package_name, + # Contact email placeholders + "security@example.com": author_email, + "[INSERT CONTACT EMAIL]": author_email, + # Note: "username" is NOT replaced globally to avoid breaking code + # variables (e.g. in extensions.md) + } + + # Update files (using shared constant from utils.py) + for file_path in FILES_TO_UPDATE: + path = Path(file_path) + if path.exists(): + print(f" ✓ Updating {file_path}") + update_file(path, replacements) + + # Update docs directory + docs_dir = Path("docs") + if docs_dir.exists(): + print(" ✓ Updating documentation files") + for md_file in docs_dir.rglob("*.md"): + update_file(md_file, replacements) + + # Update issue/PR templates + issue_template_dir = Path(".github/ISSUE_TEMPLATE") + if issue_template_dir.exists(): + print(" ✓ Updating issue templates") + for template_file in issue_template_dir.rglob("*"): + if template_file.suffix in {".md", ".yml", ".yaml"}: + update_file(template_file, replacements) + + # Update examples directory + examples_dir = Path("examples") + if examples_dir.exists(): + print(" ✓ Updating example files") + for example_file in examples_dir.rglob("*"): + if example_file.suffix in {".py", ".md"}: + update_file(example_file, replacements) + + # Rename package directory + old_package_dir = Path("src/package_name") + new_package_dir = Path(f"src/{package_name}") + + if old_package_dir.exists() and old_package_dir != new_package_dir: + if new_package_dir.exists(): + print(f" ⚠️ src/{package_name} already exists; skipping rename of src/package_name") + else: + print(f" ✓ Renaming src/package_name → src/{package_name}") + shutil.move(str(old_package_dir), str(new_package_dir)) + elif not old_package_dir.exists(): + print(" ⚠️ src/package_name not found; assuming code already relocated") + + # Update imports in renamed package + if new_package_dir.exists(): + for py_file in new_package_dir.rglob("*.py"): + update_file(py_file, replacements) + + # Update test files (limited replacements to preserve test data) + test_dir = Path("tests") + if test_dir.exists(): + print(" ✓ Updating test files") + update_test_files(test_dir, package_name) + + # Remove template tool tests (they're only for the template itself) + tool_tests_dir = Path("tests/pyproject_template") + if tool_tests_dir.exists(): + print(" ✓ Removing template tool tests (tests/pyproject_template/)") + shutil.rmtree(tool_tests_dir) + + # Enable Dependabot if requested + dependabot_example = Path(".github/dependabot.yml.example") + dependabot_config = Path(".github/dependabot.yml") + if enable_dependabot and dependabot_example.exists(): + print(" ✓ Enabling Dependabot") + shutil.copy(dependabot_example, dependabot_config) + + Logger.success("Configuration complete!") + Logger.header("Next Steps") + print("1. Review the changes: git diff") + print("2. Initialize git repository: git init") + print("3. Install dependencies: uv sync --all-extras --dev") + print("4. Install pre-commit hooks: uv run pre-commit install") + print("5. Run tests: uv run pytest") + print("6. Cut a prerelease (if desired): uv run doit release_dev") + print("7. Start coding!") + print("━" * 60) + + # Self-destruct + print("\n🗑️ Removing configure.py (self-destruct)...") + try: + Path(__file__).unlink() + Logger.success("configure.py removed") + except Exception as e: + Logger.warning(f"Could not remove configure.py: {e}") + print(" → You can safely delete it manually") + + return 0 + + +def main(argv: list[str] | None = None) -> int: + """Main entry point for CLI usage.""" + args = parse_args(argv) + return run_configure( + auto=args.auto, + yes=args.yes, + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + print("This script should not be run directly.") + print("Please use: python manage.py") + sys.exit(1) diff --git a/tools/pyproject_template/manage.py b/tools/pyproject_template/manage.py new file mode 100644 index 0000000..a6f27df --- /dev/null +++ b/tools/pyproject_template/manage.py @@ -0,0 +1,863 @@ +#!/usr/bin/env python3 +""" +Unified template management script with menu-driven interface. + +Usage: + # Interactive (default) + python manage.py + + # Quick actions + python manage.py create # Create new project from template + python manage.py configure # Re-run configuration + python manage.py check # Check for template updates + python manage.py repo # Update repository settings + python manage.py sync # Mark as synced to latest template + + # Non-interactive + python manage.py --yes # Run recommended action non-interactively + python manage.py --dry-run +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +# Support running as script or as module +_script_dir = Path(__file__).parent +if str(_script_dir) not in sys.path: + sys.path.insert(0, str(_script_dir)) + +from check_template_updates import run_check_updates # noqa: E402 +from configure import load_defaults, run_configure # noqa: E402 +from settings import ( # noqa: E402 + PreflightWarning, + ProjectContext, + ProjectSettings, + SettingsManager, + TemplateState, + get_template_commits_since, + get_template_latest_commit, +) +from utils import Colors, Logger, prompt, validate_package_name # noqa: E402 + +# Import cleanup utilities (optional - may not exist if already cleaned) +try: + from cleanup import CleanupMode, cleanup_template_files, prompt_cleanup +except ImportError: + CleanupMode = None # type: ignore[misc,assignment] + cleanup_template_files = None # type: ignore[assignment] + prompt_cleanup = None # type: ignore[assignment] + + +def print_banner() -> None: + """Print the welcome banner.""" + print() + print(f"{Colors.CYAN}pyproject-template{Colors.NC}") + print() + + +def print_section(title: str) -> None: + """Print a section header.""" + width = 60 + print(f"{Colors.BOLD}{'=' * width}{Colors.NC}") + print(f"{Colors.BOLD}{title:^{width}}{Colors.NC}") + print(f"{Colors.BOLD}{'=' * width}{Colors.NC}") + print() + + +def print_warnings(warnings: list[PreflightWarning]) -> None: + """Print preflight warnings.""" + if not warnings: + return + + print_section("Warnings") + for warning in warnings: + print(f" {Colors.YELLOW}!{Colors.NC} {warning.message}") + if warning.suggestion: + print(f" {Colors.CYAN}({warning.suggestion}){Colors.NC}") + print() + + +def print_settings(settings: ProjectSettings, context: ProjectContext) -> None: + """Print detected settings.""" + print_section("Detected Settings") + + print(f" Project name: {settings.project_name or '(not set)'}") + print(f" Package name: {settings.package_name or '(not set)'}") + print(f" PyPI name: {settings.pypi_name or '(not set)'}") + if settings.author_name or settings.author_email: + author = f"{settings.author_name} <{settings.author_email}>" + else: + author = "(not set)" + print(f" Author: {author}") + if settings.github_user or settings.github_repo: + github = f"{settings.github_user}/{settings.github_repo}" + else: + github = "(not set)" + print(f" GitHub: {github}") + print() + + # Context info + if context.is_template_repo: + print(f" Context: {Colors.CYAN}Template repository{Colors.NC}") + elif context.is_existing_repo: + print(" Context: Existing repository") + elif context.is_fresh_clone: + print(" Context: Fresh clone (needs setup)") + else: + print(" Context: Not a git repository") + print() + + +def print_template_status( + template_state: TemplateState, + latest_commit: tuple[str, str] | None, + recent_commits: list[dict[str, str]] | None, +) -> None: + """Print template sync status.""" + if template_state.is_synced() and template_state.commit: + if latest_commit: + latest_sha, _ = latest_commit + if latest_sha[:12] == template_state.commit[:12]: + print( + f" Template status: {Colors.GREEN}Up to date{Colors.NC} " + f"(synced: {template_state.commit_date})" + ) + else: + commits_behind = len(recent_commits) if recent_commits else "unknown" + status = f"{Colors.YELLOW}{commits_behind} commits behind{Colors.NC}" + print(f" Template status: {status} (last sync: {template_state.commit_date})") + if recent_commits and len(recent_commits) > 0: + print() + print(f" {Colors.CYAN}Recent changes:{Colors.NC}") + for commit in recent_commits[:5]: + msg = commit["message"][:50] + if len(commit["message"]) > 50: + msg += "..." + print(f" - {msg}") + if len(recent_commits) > 5: + print(f" ... and {len(recent_commits) - 5} more") + else: + print(f" Template status: Last sync: {template_state.commit_date}") + else: + print(f" Template status: {Colors.YELLOW}Never synced with template{Colors.NC}") + print() + + +def get_recommended_action( + context: ProjectContext, + settings: ProjectSettings, + template_state: TemplateState, + latest_commit: tuple[str, str] | None, +) -> int | None: + """Determine the recommended action based on context.""" + # No git repo - need to create new project + if not context.has_git: + return 1 # Create new project + + # Has git but placeholder values - need to configure + if settings.has_placeholder_values(): + return 2 # Configure project + + # Template already downloaded and reviewed - recommend marking as synced + template_commit_file = Path("tmp/extracted/pyproject-template-main/.template_commit") + if template_commit_file.exists(): + return 5 # Mark as synced + + # Existing repo with outdated template + if template_state.is_synced() and template_state.commit and latest_commit: + latest_sha, _ = latest_commit + if latest_sha[:12] != template_state.commit[:12]: + return 3 # Check for updates + + # Existing repo but never synced - suggest checking updates + if context.has_git and not template_state.is_synced(): + return 3 # Check for updates + + return None # Up to date + + +def print_menu(recommended: int | None, dry_run: bool) -> None: + """Print the menu options.""" + print(f"{Colors.BOLD}What would you like to do?{Colors.NC}") + print() + + options = [ + (1, "Create new project from template"), + (2, "Configure project"), + (3, "Check for template updates"), + (4, "Update repository settings"), + (5, "Mark as synced to latest template"), + (6, "Clean up template files"), + ] + + for num, label in options: + rec = f" {Colors.GREEN}<- recommended{Colors.NC}" if num == recommended else "" + print(f" [{num}] {label}{rec}") + + print() + print(" [e] Edit settings") + dry_status = "on" if dry_run else "off" + print(f" [d] Toggle dry-run (currently: {dry_status})") + print(" [?] Help") + print(" [q] Quit") + print() + + +def print_help() -> None: + """Print help text for menu options.""" + print() + print(f" {Colors.BOLD}[1] Create new project from template{Colors.NC}") + print(" Create a new GitHub repo from the template, clone it,") + print(" and run configuration (requires gh CLI authenticated)") + print() + print(f" {Colors.BOLD}[2] Configure project{Colors.NC}") + print(" Update placeholders in all files (project name, author,") + print(" etc.) - run this after cloning the template") + print() + print(f" {Colors.BOLD}[3] Check for template updates{Colors.NC}") + print(" Compare your project against the latest template and") + print(" selectively merge improvements (workflows, configs, etc.)") + print() + print(f" {Colors.BOLD}[4] Update repository settings{Colors.NC}") + print(" Configure GitHub repo settings, branch protection, labels") + print(" (requires gh CLI authenticated)") + print() + print(f" {Colors.BOLD}[5] Mark as synced to latest template{Colors.NC}") + print(" After manually reviewing and applying template updates,") + print(" mark your project as synced to the latest template commit") + print() + print(f" {Colors.BOLD}[6] Clean up template files{Colors.NC}") + print(" Remove template-specific files no longer needed:") + print(" - Setup only: Remove bootstrap.py, setup_repo.py, etc.") + print(" - All: Remove all template tools (no future update checking)") + print() + input("Press enter to return to menu...") + + +def edit_settings(manager: SettingsManager) -> None: + """Allow user to edit settings interactively.""" + print() + Logger.header("Edit Settings") + print("Enter new values (press Enter to keep current value)") + print() + + settings = manager.settings + + new_name = prompt("Project name", settings.project_name) + if new_name: + settings.project_name = new_name + + new_package = prompt("Package name", settings.package_name) + if new_package: + settings.package_name = new_package + + new_pypi = prompt("PyPI name", settings.pypi_name) + if new_pypi: + settings.pypi_name = new_pypi + + new_desc = prompt("Description", settings.description) + if new_desc: + settings.description = new_desc + + new_author = prompt("Author name", settings.author_name) + if new_author: + settings.author_name = new_author + + new_email = prompt("Author email", settings.author_email) + if new_email: + settings.author_email = new_email + + new_gh_user = prompt("GitHub user", settings.github_user) + if new_gh_user: + settings.github_user = new_gh_user + + new_gh_repo = prompt("GitHub repo", settings.github_repo) + if new_gh_repo: + settings.github_repo = new_gh_repo + + manager.save() + + +def run_action(action: int, manager: SettingsManager, dry_run: bool) -> int: + """Run the selected action.""" + if action == 1: + return action_create_project(manager, dry_run) + elif action == 2: + return action_configure(manager, dry_run) + elif action == 3: + return action_check_updates(manager, dry_run) + elif action == 4: + return action_repo_settings(manager, dry_run) + elif action == 5: + return action_mark_synced(manager, dry_run) + elif action == 6: + return action_template_cleanup(manager, dry_run) + else: + Logger.error(f"Unknown action: {action}") + return 1 + + +def action_create_project(manager: SettingsManager, dry_run: bool) -> int: + """Create a new project from the template.""" + Logger.header("Creating New Project from Template") + + settings = manager.settings + + if dry_run: + Logger.info("Dry run: Would create new project") + Logger.info(f" - Create GitHub repo: {settings.github_user}/{settings.github_repo}") + Logger.info(" - Clone from template") + Logger.info(" - Run configuration") + Logger.info(" - Save settings") + return 0 + + try: + from setup_repo import RepositorySetup + + setup = RepositorySetup() + + # Set config from manager settings (skip gather_inputs) + setup.config = { + "repo_owner": settings.github_user, + "repo_name": settings.github_repo, + "repo_full": f"{settings.github_user}/{settings.github_repo}", + "description": settings.description, + "package_name": settings.package_name, + "pypi_name": settings.pypi_name, + "author_name": settings.author_name, + "author_email": settings.author_email, + "visibility": "public", # Default to public + } + + # Run individual setup steps (skip gather_inputs since we have config) + setup.print_banner() + setup.check_requirements() + + # Show configuration summary + Logger.step("Configuration summary:") + print(f" Repository: {setup.config['repo_full']}") + print(f" Visibility: {setup.config['visibility']}") + print(f" Package name: {setup.config['package_name']}") + print(f" PyPI name: {setup.config['pypi_name']}") + print(f" Description: {setup.config['description']}") + print(f" Author: {setup.config['author_name']} <{setup.config['author_email']}>") + print() + + # Create GitHub repo (no branch protection yet) + setup.create_github_repository() + setup.configure_repository_settings() + + # Clone and configure locally BEFORE branch protection + setup.clone_repository() + setup.configure_placeholders() + setup.setup_development_environment() + + # Save and commit template state BEFORE branch protection rules + project_dir = Path.cwd() + latest = get_template_latest_commit() + if latest: + import subprocess # nosec B404 - subprocess is required for git operations + + new_manager = SettingsManager(root=project_dir) + new_manager.template_state.commit = latest[0] + new_manager.template_state.commit_date = latest[1] + new_manager.save() + + # Commit the settings file (use --no-verify to bypass pre-commit + # hook that blocks commits to main - this is automated setup) + subprocess.run( + ["git", "add", ".config/pyproject_template/settings.toml"], + cwd=project_dir, + check=True, + ) + subprocess.run( + [ + "git", + "commit", + "--no-verify", + "-m", + "chore: add template sync state", + ], + cwd=project_dir, + check=True, + ) + subprocess.run(["git", "push"], cwd=project_dir, check=True) + + # Now configure branch protection and other GitHub settings + setup.configure_branch_protection() + setup.replicate_labels() + setup.enable_github_pages() + setup.configure_codeql() + + setup.print_manual_steps() + + Logger.success(f"Project created at {project_dir}") + + return 0 + + except Exception as e: + Logger.error(f"Failed to create project: {e}") + return 1 + + +def action_check_updates(manager: SettingsManager, dry_run: bool) -> int: + """Check for template updates (comparison only, does not modify files).""" + Logger.header("Checking for Template Updates") + + result = run_check_updates( + skip_changelog=True, + keep_template=True, # Keep template so user can run diff commands + dry_run=dry_run, + ) + + # Show commit history link if we have a sync point (after the review section) + latest = get_template_latest_commit() + if manager.template_state.commit and latest and latest[0] != manager.template_state.commit: + old_commit = manager.template_state.commit + new_commit = latest[0] + print() + Logger.info("View template commit history since last sync:") + print( + f" https://github.com/endavis/pyproject-template/compare/{old_commit}...{new_commit}" + ) + + # Save commit info to template directory for later sync + if latest and not dry_run: + template_dir = Path("tmp/extracted/pyproject-template-main") + if template_dir.exists(): + commit_file = template_dir / ".template_commit" + commit_file.write_text(f"{latest[0]}\n{latest[1]}\n") + print() + Logger.info("After reviewing changes, use option [5] to mark as synced.") + + # Note: This only shows differences, it doesn't update files. + # Template state is NOT updated here - only when user runs "Mark as synced". + + return int(result) + + +def action_configure(manager: SettingsManager, dry_run: bool) -> int: + """Re-run configuration.""" + Logger.header("Running Configuration") + + # Prepare defaults from current settings + defaults = { + "project_name": manager.settings.project_name, + "package_name": manager.settings.package_name, + "pypi_name": manager.settings.pypi_name, + "description": manager.settings.description, + "author_name": manager.settings.author_name, + "author_email": manager.settings.author_email, + "github_user": manager.settings.github_user, + } + + # Merge with pyproject.toml defaults + pyproject_defaults = load_defaults(Path("pyproject.toml")) + for key, value in pyproject_defaults.items(): + if not defaults.get(key): + defaults[key] = value + + return int( + run_configure( + auto=False, + yes=False, + dry_run=dry_run, + defaults=defaults, + ) + ) + + +def action_repo_settings(manager: SettingsManager, dry_run: bool) -> int: + """Update repository settings.""" + Logger.header("Updating Repository Settings") + + if dry_run: + Logger.info("Dry run: Would configure GitHub repository settings") + Logger.info(" - Repository settings (description, features)") + Logger.info(" - Branch protection rulesets") + Logger.info(" - Labels") + Logger.info(" - GitHub Pages") + Logger.info(" - CodeQL code scanning") + return 0 + + # Import repo_settings module for repository configuration + try: + from repo_settings import update_all_repo_settings + + repo_full = f"{manager.settings.github_user}/{manager.settings.github_repo}" + + success = update_all_repo_settings( + repo_full=repo_full, + description=manager.settings.description or "", + ) + + if success: + Logger.success("Repository settings updated") + return 0 + else: + Logger.warning("Some repository settings may not have been updated") + return 0 # Partial success is still success + + except Exception as e: + Logger.error(f"Failed to update repository settings: {e}") + return 1 + + +def action_mark_synced(manager: SettingsManager, dry_run: bool) -> int: + """Mark project as synced to reviewed template commit.""" + import shutil + import subprocess # nosec B404 - subprocess is required for git operations + + Logger.header("Mark as Synced to Template") + + # Check for downloaded template with commit info + template_dir = Path("tmp/extracted/pyproject-template-main") + commit_file = template_dir / ".template_commit" + + if not commit_file.exists(): + Logger.error("No reviewed template found.") + Logger.info("Run option [3] 'Check for template updates' first to review changes.") + return 1 + + # Read commit info from the reviewed template + lines = commit_file.read_text().strip().split("\n") + if len(lines) < 2: + Logger.error("Invalid commit file format") + return 1 + + new_commit, new_date = lines[0], lines[1] + current_commit = manager.template_state.commit + + if current_commit == new_commit: + Logger.success(f"Already synced to this commit ({new_commit})") + # Clean up template directory + if template_dir.exists(): + shutil.rmtree(template_dir.parent) + Logger.info("Cleaned up template directory") + return 0 + + print(f"Current sync point: {current_commit or 'Not set'}") + print(f"Reviewed template: {new_commit} ({new_date})") + print() + + if dry_run: + Logger.info(f"Dry run: Would mark as synced to {new_commit}") + return 0 + + # Confirm with user + confirm = prompt(f"Mark as synced to {new_commit}?", "Y") + if confirm.lower() not in ("y", "yes", ""): + Logger.warning("Cancelled") + return 0 + + # Update template state + manager.update_template_state(new_commit, new_date) + + # Commit the settings file if there are changes + settings_file = ".config/pyproject_template/settings.toml" + try: + subprocess.run(["git", "add", settings_file], check=True) + + # Check if there are staged changes to commit + result = subprocess.run( + ["git", "diff", "--cached", "--quiet"], + capture_output=True, + ) + if result.returncode != 0: + # There are changes to commit + subprocess.run( + [ + "git", + "commit", + "--no-verify", + "-m", + f"chore: sync template state to {new_commit}", + ], + check=True, + ) + subprocess.run(["git", "push"], check=True) + Logger.success(f"Marked as synced to {new_commit}") + else: + # Settings file exists but wasn't committed (maybe already staged) + Logger.success(f"Marked as synced to {new_commit}") + print() + print(f"{Colors.BOLD}{Colors.YELLOW}*** IMPORTANT ***{Colors.NC}") + print(f"{Colors.BOLD}Don't forget to commit the settings file:{Colors.NC}") + print() + print(" # 1. Create an issue") + print(" doit issue --type=chore --title='Sync template state'") + print() + print(" # 2. Create branch, commit, and push") + print(" git checkout -b chore/-sync-template-state") + print(f" git add {settings_file}") + print(f" git commit -m 'chore: sync template state to {new_commit[:12]}'") + print(" git push -u origin HEAD") + print() + print(" # 3. Create PR and merge") + print(" doit pr --title='chore: sync template state'") + print() + except subprocess.CalledProcessError as e: + Logger.error(f"Failed to commit: {e}") + return 1 + + # Clean up template directory + if template_dir.exists(): + shutil.rmtree(template_dir.parent) + Logger.info("Cleaned up template directory") + + return 0 + + +def action_template_cleanup(manager: SettingsManager, dry_run: bool) -> int: + """Clean up template-specific files.""" + Logger.header("Template File Cleanup") + + if prompt_cleanup is None or cleanup_template_files is None: + Logger.error("Cleanup module not available (may have been removed already)") + return 1 + + if dry_run: + Logger.info("Dry run: Would prompt for cleanup mode and show files to delete") + return 0 + + # Prompt user for cleanup mode + mode = prompt_cleanup() + if mode is None: + Logger.info("Keeping all template files") + return 0 + + # Perform cleanup + result = cleanup_template_files(mode, dry_run=False) + + if result.failed: + Logger.warning("Some files could not be deleted") + return 1 + + Logger.success("Template cleanup complete") + return 0 + + +def offer_cleanup_prompt() -> None: + """Offer to clean up template files after successful setup. + + This is called after "Create new project" or "Configure project" completes. + """ + if prompt_cleanup is None or cleanup_template_files is None: + return # Cleanup module not available + + print() + response = prompt("Would you like to clean up template-specific files? (y/N)", "n") + if response.lower() in ("y", "yes"): + mode = prompt_cleanup() + if mode is not None: + cleanup_template_files(mode, dry_run=False) + + +def prompt_initial_settings(manager: SettingsManager) -> None: + """Prompt for settings if none are configured.""" + if manager.settings.is_configured(): + return + + print_banner() + print_section("Initial Setup") + print("No project settings found. Let's set them up.\n") + + settings = manager.settings + + settings.project_name = prompt("Project name", settings.project_name) or settings.project_name + + # Auto-derive package_name (lowercase, underscores) and pypi_name (lowercase, hyphens) + default_package = validate_package_name(settings.project_name) + default_pypi = settings.project_name.lower().replace("_", "-") + + # Let user confirm/override package name with PEP 8 validation + while True: + package_input = prompt("Package name (PEP 8: lowercase, underscores only)", default_package) + if not package_input: + package_input = default_package + + # Validate PEP 8 compliance + valid_name = validate_package_name(package_input) + if package_input == valid_name: + settings.package_name = package_input + break + else: + Logger.warning(f"'{package_input}' is not PEP 8 compliant. Suggested: '{valid_name}'") + # Offer the corrected version as new default + default_package = valid_name + + settings.pypi_name = default_pypi + settings.description = prompt("Description", settings.description) or settings.description + settings.author_name = prompt("Author name", settings.author_name) or settings.author_name + settings.author_email = prompt("Author email", settings.author_email) or settings.author_email + settings.github_user = prompt("GitHub user", settings.github_user) or settings.github_user + # Default github_repo to project_name + settings.github_repo = settings.project_name + + # Don't save yet - settings are saved after setup completes in the correct directory + # Clear warnings since we just collected settings + manager.warnings = [] + print() + + +def interactive_menu(manager: SettingsManager, dry_run: bool = False) -> int: + """Run the interactive menu loop.""" + # Prompt for initial settings if not configured + prompt_initial_settings(manager) + + while True: + print_banner() + + # Fetch latest template info + latest_commit = get_template_latest_commit() + recent_commits = None + if manager.template_state.is_synced() and manager.template_state.commit and latest_commit: + recent_commits = get_template_commits_since(manager.template_state.commit) + + # Display information + print_warnings(manager.warnings) + print_settings(manager.settings, manager.context) + print_template_status(manager.template_state, latest_commit, recent_commits) + + # Get recommended action + recommended = get_recommended_action( + manager.context, manager.settings, manager.template_state, latest_commit + ) + + # Show menu + print_menu(recommended, dry_run) + + # Get user input + try: + choice = input("Select option: ").strip().lower() + except (EOFError, KeyboardInterrupt): + print() + return 0 + + if choice == "q": + return 0 + elif choice == "?": + print_help() + elif choice == "e": + edit_settings(manager) + elif choice == "d": + dry_run = not dry_run + Logger.info(f"Dry run mode: {'enabled' if dry_run else 'disabled'}") + elif choice in ("1", "2", "3", "4", "5", "6"): + action = int(choice) + result = run_action(action, manager, dry_run) + if result == 0: + Logger.success("Action completed successfully") + # Offer cleanup after successful create/configure (but not for cleanup itself) + if action in (1, 2) and not dry_run: + offer_cleanup_prompt() + else: + Logger.error("Action failed") + input("\nPress enter to return to menu...") + # Refresh manager to detect new context (e.g., after creating project) + manager = SettingsManager(root=Path.cwd()) + else: + Logger.warning(f"Unknown option: {choice}") + + +def parse_args(argv: list[str] | None = None) -> argparse.Namespace: + """Parse command line arguments.""" + parser = argparse.ArgumentParser( + prog="python -m tools.pyproject_template", + description="Unified template management with menu-driven interface.", + ) + + # Subcommands for quick actions + subparsers = parser.add_subparsers(dest="command", help="Quick action commands") + + subparsers.add_parser("create", help="Create new project from template") + subparsers.add_parser("configure", help="Re-run configuration") + subparsers.add_parser("check", help="Check for template updates") + subparsers.add_parser("repo", help="Update repository settings") + subparsers.add_parser("sync", help="Mark as synced to latest template") + + # Global options + parser.add_argument( + "--yes", + "-y", + action="store_true", + help="Non-interactive mode (use detected settings, no prompts)", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="Show what would be done without making changes", + ) + parser.add_argument( + "--update-only", + action="store_true", + help="Only check for updates (CI-friendly, fails if can't auto-detect)", + ) + + return parser.parse_args(argv) + + +def main(argv: list[str] | None = None) -> int: + """Main entry point.""" + args = parse_args(argv) + + # Initialize settings manager + manager = SettingsManager(root=Path.cwd()) + + # Handle quick action commands + if args.command: + command_map = { + "create": 1, + "configure": 2, + "check": 3, + "repo": 4, + "sync": 5, + } + action = command_map.get(args.command) + if action: + return run_action(action, manager, args.dry_run) + return 1 + + # Handle --update-only (CI mode) + if args.update_only: + if not manager.settings.is_configured(): + Logger.error("Cannot auto-detect settings. Run interactively first.") + return 1 + return action_check_updates(manager, args.dry_run) + + # Handle --yes (non-interactive mode) + if args.yes: + if not manager.settings.is_configured(): + Logger.error("Cannot run non-interactively without configured settings.") + return 1 + + # Run recommended action non-interactively + latest_commit = get_template_latest_commit() + recommended = get_recommended_action( + manager.context, manager.settings, manager.template_state, latest_commit + ) + if recommended: + return run_action(recommended, manager, args.dry_run) + Logger.success("Project is up to date, no action needed.") + return 0 + + # Interactive mode + return interactive_menu(manager, args.dry_run) + + +if __name__ == "__main__": + try: + sys.exit(main()) + except KeyboardInterrupt: + print() + Logger.warning("Cancelled by user") + sys.exit(1) + except Exception as e: + Logger.error(f"Unexpected error: {e}") + import traceback + + traceback.print_exc() + sys.exit(1) diff --git a/tools/pyproject_template/settings.py b/tools/pyproject_template/settings.py new file mode 100644 index 0000000..c929a62 --- /dev/null +++ b/tools/pyproject_template/settings.py @@ -0,0 +1,464 @@ +""" +Settings detection and management for pyproject-template. + +Handles reading settings from multiple sources and tracking template sync state. +""" + +from __future__ import annotations + +import subprocess # nosec B404 +import sys +from dataclasses import dataclass, field +from datetime import datetime +from pathlib import Path +from typing import Any + +try: + import tomllib # py311+ +except ModuleNotFoundError: # pragma: no cover + import tomli as tomllib # type: ignore[no-redef] + +# Support running as script or as module +_script_dir = Path(__file__).parent +if str(_script_dir) not in sys.path: + sys.path.insert(0, str(_script_dir)) + +from utils import ( # noqa: E402 + TEMPLATE_REPO, + Logger, + command_exists, + get_first_author, + get_git_config, + parse_github_url, + validate_email, + validate_package_name, +) + +# Settings file location +SETTINGS_DIR = Path(".config/pyproject_template") +SETTINGS_FILE = SETTINGS_DIR / "settings.toml" + + +def _toml_escape(value: str) -> str: + """Escape a string for TOML.""" + return value.replace("\\", "\\\\").replace('"', '\\"') + + +def _toml_serialize(data: dict[str, Any]) -> str: + """Serialize a simple dict to TOML format. + + Only supports flat tables with string values and nested tables one level deep. + """ + lines: list[str] = [] + + for section, values in data.items(): + if not isinstance(values, dict): + continue + lines.append(f"[{section}]") + for key, value in values.items(): + if value is None: + continue + if isinstance(value, str): + lines.append(f'{key} = "{_toml_escape(value)}"') + elif isinstance(value, bool): + lines.append(f"{key} = {str(value).lower()}") + elif isinstance(value, int | float): + lines.append(f"{key} = {value}") + lines.append("") + + return "\n".join(lines) + + +@dataclass +class TemplateState: + """Tracks the template sync state.""" + + commit: str | None = None + commit_date: str | None = None + + def is_synced(self) -> bool: + """Check if template state has been tracked.""" + return self.commit is not None + + +@dataclass +class ProjectSettings: + """Project configuration settings.""" + + project_name: str = "" + package_name: str = "" + pypi_name: str = "" + description: str = "" + author_name: str = "" + author_email: str = "" + github_user: str = "" + github_repo: str = "" + + def is_configured(self) -> bool: + """Check if settings appear to be configured (not placeholders).""" + placeholders = { + "package_name", + "package-name", + "Package Name", + "Your Name", + "your.email@example.com", + "username", + "A short description of your package", + } + values = [ + self.project_name, + self.package_name, + self.author_name, + self.author_email, + self.github_user, + self.description, + ] + return all(v and v not in placeholders for v in values) + + def has_placeholder_values(self) -> list[str]: + """Return list of fields that still have placeholder values.""" + placeholders = { + "project_name": {"Package Name", ""}, + "package_name": {"package_name", ""}, + "pypi_name": {"package-name", ""}, + "description": {"A short description of your package", ""}, + "author_name": {"Your Name", ""}, + "author_email": {"your.email@example.com", ""}, + "github_user": {"username", ""}, + } + result = [] + for field_name, placeholder_set in placeholders.items(): + value = getattr(self, field_name, "") + if value in placeholder_set: + result.append(field_name) + return result + + +@dataclass +class ProjectContext: + """Detected project context.""" + + has_pyproject: bool = False + has_git: bool = False + has_git_remote: bool = False + git_remote_url: str = "" + is_template_repo: bool = False + + @property + def is_fresh_clone(self) -> bool: + """Check if this appears to be a fresh clone needing setup.""" + return self.has_git and not self.has_pyproject + + @property + def is_existing_repo(self) -> bool: + """Check if this is an existing configured repo.""" + return self.has_git and self.has_pyproject + + +@dataclass +class PreflightWarning: + """A warning to display to the user.""" + + message: str + suggestion: str = "" + + +@dataclass +class SettingsManager: + """Manages project settings detection and persistence.""" + + root: Path = field(default_factory=Path.cwd) + settings: ProjectSettings = field(default_factory=ProjectSettings) + template_state: TemplateState = field(default_factory=TemplateState) + context: ProjectContext = field(default_factory=ProjectContext) + warnings: list[PreflightWarning] = field(default_factory=list) + + def __post_init__(self) -> None: + """Initialize by detecting context and loading settings.""" + self._detect_context() + self._load_settings() + self._run_preflight_checks() + + def _detect_context(self) -> None: + """Detect the project context.""" + self.context.has_pyproject = (self.root / "pyproject.toml").exists() + self.context.has_git = (self.root / ".git").exists() + + if self.context.has_git: + try: + result = subprocess.run( + ["git", "config", "--get", "remote.origin.url"], + capture_output=True, + text=True, + cwd=self.root, + ) + if result.returncode == 0 and result.stdout.strip(): + self.context.has_git_remote = True + self.context.git_remote_url = result.stdout.strip() + # Check if this is the template repo itself + if TEMPLATE_REPO in self.context.git_remote_url: + self.context.is_template_repo = True + except (subprocess.SubprocessError, FileNotFoundError): + pass + + def _load_settings(self) -> None: + """Load settings from all available sources.""" + # Priority order: settings.yml > pyproject.toml > git config + self._load_from_settings_file() + self._load_from_pyproject() + self._load_from_git() + + def _load_from_settings_file(self) -> None: + """Load settings from .config/pyproject_template/settings.toml.""" + settings_path = self.root / SETTINGS_FILE + if not settings_path.exists(): + return + + try: + with settings_path.open("rb") as f: + data = tomllib.load(f) + + # Load project settings + if "project" in data: + proj = data["project"] + self.settings.project_name = proj.get("name", self.settings.project_name) + self.settings.package_name = proj.get("package_name", self.settings.package_name) + self.settings.pypi_name = proj.get("pypi_name", self.settings.pypi_name) + self.settings.description = proj.get("description", self.settings.description) + self.settings.author_name = proj.get("author_name", self.settings.author_name) + self.settings.author_email = proj.get("author_email", self.settings.author_email) + self.settings.github_user = proj.get("github_user", self.settings.github_user) + self.settings.github_repo = proj.get("github_repo", self.settings.github_repo) + + # Load template state + if "template" in data: + tmpl = data["template"] + self.template_state.commit = tmpl.get("commit") + self.template_state.commit_date = tmpl.get("commit_date") + + except (tomllib.TOMLDecodeError, OSError) as e: + Logger.warning(f"Failed to read settings file: {e}") + + def _load_from_pyproject(self) -> None: + """Load settings from pyproject.toml.""" + pyproject_path = self.root / "pyproject.toml" + if not pyproject_path.exists(): + return + + try: + with pyproject_path.open("rb") as f: + data = tomllib.load(f) + + project = data.get("project", {}) + + # Only override if not already set from settings.yml + if not self.settings.project_name: + name = project.get("name", "") + self.settings.project_name = name + if name and not self.settings.package_name: + self.settings.package_name = validate_package_name(name) + if name and not self.settings.pypi_name: + # Convert underscores to hyphens for PyPI + self.settings.pypi_name = name.replace("_", "-") + + if not self.settings.description: + self.settings.description = project.get("description", "") + + # Get author info + author_name, author_email = get_first_author(data) + if not self.settings.author_name: + self.settings.author_name = author_name + if not self.settings.author_email: + self.settings.author_email = author_email + + # Get GitHub user from repository URL + if not self.settings.github_user: + repo_url = project.get("urls", {}).get("Repository", "") + github_user, github_repo = parse_github_url(repo_url) + if github_user: + self.settings.github_user = github_user + if not self.settings.github_repo: + self.settings.github_repo = github_repo + + except (tomllib.TOMLDecodeError, OSError) as e: + Logger.warning(f"Failed to read pyproject.toml: {e}") + + def _load_from_git(self) -> None: + """Load settings from git config.""" + if not self.context.has_git: + return + + # Get author info from git config + if not self.settings.author_name: + self.settings.author_name = get_git_config("user.name") + + if not self.settings.author_email: + self.settings.author_email = get_git_config("user.email") + + # Get GitHub user/repo from remote URL + if self.context.has_git_remote and not self.settings.github_user: + github_user, github_repo = parse_github_url(self.context.git_remote_url) + if github_user: + self.settings.github_user = github_user + if not self.settings.github_repo: + self.settings.github_repo = github_repo + + def _run_preflight_checks(self) -> None: + """Run preflight checks and collect warnings.""" + self.warnings = [] + + # Check for GitHub CLI + if not command_exists("gh"): + self.warnings.append( + PreflightWarning( + message="GitHub CLI (gh) not installed", + suggestion="Install from: https://cli.github.com/", + ) + ) + elif not self._gh_authenticated(): + self.warnings.append( + PreflightWarning( + message="GitHub CLI not authenticated", + suggestion="Run: gh auth login", + ) + ) + + # Check for git + if not command_exists("git"): + self.warnings.append( + PreflightWarning( + message="Git not installed", + suggestion="Install from: https://git-scm.com/downloads", + ) + ) + + # Check for uv + if not command_exists("uv"): + self.warnings.append( + PreflightWarning( + message="uv not installed", + suggestion="Install from: https://docs.astral.sh/uv/", + ) + ) + + # Note: We don't warn about missing .git - that's expected for new projects + + # Check for placeholder values + placeholders = self.settings.has_placeholder_values() + if placeholders: + self.warnings.append( + PreflightWarning( + message=f"Placeholder values detected: {', '.join(placeholders)}", + suggestion="Run configuration to set proper values", + ) + ) + + # Check email validity + if self.settings.author_email and not validate_email(self.settings.author_email): + self.warnings.append( + PreflightWarning( + message="Invalid author email format", + suggestion="Update email in settings", + ) + ) + + def _gh_authenticated(self) -> bool: + """Check if GitHub CLI is authenticated.""" + try: + result = subprocess.run( + ["gh", "auth", "status"], + capture_output=True, + text=True, + ) + return result.returncode == 0 + except (subprocess.SubprocessError, FileNotFoundError): + return False + + def save(self) -> None: + """Save template state to .config/pyproject_template/settings.toml.""" + # Only save if we have template state to save + if not self.template_state.commit: + return + + settings_dir = self.root / SETTINGS_DIR + settings_dir.mkdir(parents=True, exist_ok=True) + + data: dict[str, Any] = { + "template": { + "commit": self.template_state.commit, + "commit_date": self.template_state.commit_date, + }, + } + + settings_path = self.root / SETTINGS_FILE + with settings_path.open("w") as f: + f.write(_toml_serialize(data)) + + Logger.success(f"Settings saved to {SETTINGS_FILE}") + + def update_template_state(self, commit: str, commit_date: str) -> None: + """Update the template sync state.""" + self.template_state.commit = commit + self.template_state.commit_date = commit_date + self.save() + + +def get_template_latest_commit() -> tuple[str, str] | None: + """Fetch the latest commit info from the template repository. + + Returns: + Tuple of (commit_sha, commit_date) or None if fetch fails. + """ + import json + import urllib.request + + api_url = f"https://api.github.com/repos/{TEMPLATE_REPO}/commits/main" + try: + req = urllib.request.Request(api_url) + req.add_header("Accept", "application/vnd.github.v3+json") + with urllib.request.urlopen(req, timeout=10) as response: # nosec B310 + data = json.loads(response.read()) + commit_sha = data.get("sha", "") # Full SHA for GitHub compare + commit_date = data.get("commit", {}).get("committer", {}).get("date", "") + if commit_date: + # Parse and format the date + dt = datetime.fromisoformat(commit_date.replace("Z", "+00:00")) + commit_date = dt.strftime("%Y-%m-%d") + return (commit_sha, commit_date) + except Exception as e: + Logger.warning(f"Could not fetch latest template commit: {e}") + return None + + +def get_template_commits_since(since_commit: str) -> list[dict[str, str]] | None: + """Get list of commits since a given commit. + + Returns: + List of commit dicts with 'sha', 'message', 'date' or None if fetch fails. + """ + import json + import urllib.request + + api_url = f"https://api.github.com/repos/{TEMPLATE_REPO}/commits?per_page=50" + try: + req = urllib.request.Request(api_url) + req.add_header("Accept", "application/vnd.github.v3+json") + with urllib.request.urlopen(req, timeout=10) as response: # nosec B310 + data = json.loads(response.read()) + + commits = [] + for commit_data in data: + sha = commit_data.get("sha", "")[:12] + if sha == since_commit[:12]: + break + message = commit_data.get("commit", {}).get("message", "").split("\n")[0] + date = commit_data.get("commit", {}).get("committer", {}).get("date", "") + if date: + dt = datetime.fromisoformat(date.replace("Z", "+00:00")) + date = dt.strftime("%Y-%m-%d") + commits.append({"sha": sha, "message": message, "date": date}) + + return commits + except Exception as e: + Logger.warning(f"Could not fetch template commits: {e}") + return None diff --git a/tools/pyproject_template/utils.py b/tools/pyproject_template/utils.py new file mode 100644 index 0000000..c788160 --- /dev/null +++ b/tools/pyproject_template/utils.py @@ -0,0 +1,430 @@ +""" +Shared utilities for pyproject-template tools. +""" + +import json +import re +import shutil +import subprocess # nosec B404 +import sys +import tarfile +import urllib.request +import zipfile +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +try: + import tomllib # py311+ +except ModuleNotFoundError: # pragma: no cover + import tomli as tomllib # type: ignore[no-redef] + +# Template repository info +TEMPLATE_REPO = "endavis/pyproject-template" +TEMPLATE_URL = f"https://github.com/{TEMPLATE_REPO}" + +# Files to update during placeholder replacement (single source of truth) +# Used by both configure.py and setup_repo.py +FILES_TO_UPDATE: tuple[str, ...] = ( + "pyproject.toml", + "README.md", + "LICENSE", + "dodo.py", + "mkdocs.yml", + "AGENTS.md", + "CHANGELOG.md", + ".github/workflows/ci.yml", + ".github/workflows/release.yml", + ".github/workflows/testpypi.yml", + ".github/workflows/breaking-change-detection.yml", + ".github/CONTRIBUTING.md", + ".github/SECURITY.md", + ".github/CODE_OF_CONDUCT.md", + ".github/CODEOWNERS", + ".github/pull_request_template.md", + ".claude/CLAUDE.md", + ".claude/lsp-setup.md", + ".envrc", + ".pre-commit-config.yaml", +) + + +# ANSI color codes +class Colors: + RED = "\033[0;31m" + GREEN = "\033[0;32m" + YELLOW = "\033[1;33m" + BLUE = "\033[0;34m" + CYAN = "\033[0;36m" + BOLD = "\033[1m" + NC = "\033[0m" # No Color + + +class Logger: + """Simple logging utility with colored output.""" + + @staticmethod + def info(msg: str) -> None: + print(f"{Colors.BLUE}i{Colors.NC} {msg}") + + @staticmethod + def success(msg: str) -> None: + print(f"{Colors.GREEN}✓{Colors.NC} {msg}") + + @staticmethod + def warning(msg: str) -> None: + print(f"{Colors.YELLOW}⚠{Colors.NC} {msg}") + + @staticmethod + def error(msg: str) -> None: + print(f"{Colors.RED}✗{Colors.NC} {msg}", file=sys.stderr) + + @staticmethod + def step(msg: str) -> None: + print(f"\n{Colors.CYAN}▸{Colors.NC} {msg}") + + @staticmethod + def header(msg: str) -> None: + print(f"\n{Colors.BOLD}{msg}{Colors.NC}") + print("━" * 60) + + +def prompt(question: str, default: str = "") -> str: + """Prompt user for input with optional default value.""" + if default: + p = f"{Colors.CYAN}?{Colors.NC} {question} [{Colors.GREEN}{default}{Colors.NC}]: " + response = input(p).strip() + return response or default + while True: + response = input(f"{Colors.CYAN}?{Colors.NC} {question}: ").strip() + if response: + return response + Logger.warning("This field is required. Please enter a value.") + + +def prompt_confirm(question: str, default: bool = False) -> bool: + """Prompt user for yes/no confirmation.""" + if default: + p = f"{Colors.CYAN}?{Colors.NC} {question} [{Colors.GREEN}Y{Colors.NC}/n]: " + response = input(p).strip().lower() + return response in ("", "y", "yes") + else: + p = f"{Colors.CYAN}?{Colors.NC} {question} [y/{Colors.GREEN}N{Colors.NC}]: " + response = input(p).strip().lower() + return response in ("y", "yes") + + +def validate_package_name(name: str) -> str: + """Validate and convert to valid Python package name.""" + # Convert to lowercase and replace invalid characters with underscores + package_name = re.sub(r"[^a-z0-9_]", "_", name.lower()) + # Remove leading/trailing underscores + package_name = package_name.strip("_") + # Ensure it doesn't start with a number + if package_name and package_name[0].isdigit(): + package_name = f"_{package_name}" + return package_name + + +def validate_pypi_name(name: str) -> str: + """Convert to valid PyPI package name (kebab-case).""" + # Convert to lowercase and replace invalid characters with hyphens + pypi_name = re.sub(r"[^a-z0-9-]", "-", name.lower()) + # Remove leading/trailing hyphens + pypi_name = pypi_name.strip("-") + # Collapse multiple hyphens + pypi_name = re.sub(r"-+", "-", pypi_name) + return pypi_name + + +def validate_email(email: str) -> bool: + """Basic email validation.""" + pattern = r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$" + return bool(re.match(pattern, email)) + + +def command_exists(command: str) -> bool: + """Check if a command exists in PATH. + + Args: + command: The command name to check for. + + Returns: + True if the command exists and is executable, False otherwise. + """ + try: + result = subprocess.run( + ["which", command], + capture_output=True, + ) + return result.returncode == 0 + except (subprocess.SubprocessError, FileNotFoundError): + return False + + +def get_git_config(key: str, default: str = "") -> str: + """Get a git configuration value. + + Args: + key: The git config key to retrieve (e.g., "user.name", "user.email"). + default: Default value to return if the key is not found. + + Returns: + The config value if found, otherwise the default value. + """ + try: + result = subprocess.run( + ["git", "config", key], + capture_output=True, + text=True, + ) + return result.stdout.strip() if result.returncode == 0 else default + except (subprocess.SubprocessError, FileNotFoundError): + return default + + +def is_github_url(url: str) -> bool: + """Check if URL is from github.com using proper URL parsing. + + This prevents URL manipulation attacks like 'https://evil.com/github.com/...' + + Args: + url: The URL to check. + + Returns: + True if the URL is from github.com, False otherwise. + """ + try: + parsed = urlparse(url) + return parsed.netloc == "github.com" or parsed.netloc.endswith(".github.com") + except Exception: + return False + + +def parse_github_url(url: str) -> tuple[str, str]: + """Parse a GitHub URL and extract owner and repository name. + + Handles both formats: + - https://github.com/owner/repo + - git@github.com:owner/repo.git + + Args: + url: The GitHub URL to parse. + + Returns: + Tuple of (owner, repo) or ("", "") if parsing fails. + """ + if not url: + return "", "" + + # Remove .git suffix if present + if url.endswith(".git"): + url = url[:-4] + + # Normalize SSH format to HTTPS for parsing + if url.startswith("git@github.com:"): + url = url.replace("git@github.com:", "https://github.com/") + + # Validate it's a GitHub URL + if not is_github_url(url): + return "", "" + + # Extract owner and repo from path + parts = url.rstrip("/").split("/") + if len(parts) >= 2: + return parts[-2], parts[-1] + + return "", "" + + +def load_toml_file(path: Path) -> dict[str, Any]: + """Load and parse a TOML file. + + Args: + path: Path to the TOML file. + + Returns: + Parsed TOML data as a dictionary, or empty dict if file doesn't exist + or parsing fails. + """ + if not path.exists(): + return {} + try: + with path.open("rb") as f: + return tomllib.load(f) + except (tomllib.TOMLDecodeError, OSError): + return {} + + +def get_first_author(pyproject_data: dict[str, Any]) -> tuple[str, str]: + """Extract the first author's name and email from pyproject.toml data. + + Args: + pyproject_data: Parsed pyproject.toml data. + + Returns: + Tuple of (name, email) or ("", "") if no author found. + """ + authors = pyproject_data.get("project", {}).get("authors", []) + if not authors: + return "", "" + author = authors[0] + return author.get("name", ""), author.get("email", "") + + +def update_file(filepath: Path, replacements: dict[str, str]) -> None: + """Update file with string replacements. + + Special handling for 'package_name': only replaces when NOT followed by + optional whitespace and '=' to preserve: + - Python keyword arguments: package_name="value" + - TOML keys: package_name = "value" + """ + if not filepath.exists(): + return + try: + content = filepath.read_text(encoding="utf-8") + for old, new in replacements.items(): + if old == "package_name": + # Use regex to replace 'package_name' only when NOT followed by + # optional whitespace and '='. This preserves: + # - Python kwargs: package_name="value" + # - TOML keys: package_name = "value" + content = re.sub(r"package_name(?!\s*=)", new, content) + else: + content = content.replace(old, new) + filepath.write_text(content, encoding="utf-8") + except UnicodeDecodeError: + pass # Skip binary files + + +def update_test_files(test_dir: Path, package_name: str) -> None: + """Update test files with limited replacements. + + Only replaces package_name for imports (from package_name import), + preserving placeholder string values used as test fixtures for + placeholder detection tests. + + Args: + test_dir: Path to the tests directory + package_name: The actual package name to use in imports + """ + if not test_dir.exists(): + return + + # Limited replacements - only for imports, not string values + test_replacements = { + "package_name": package_name, + } + + for py_file in test_dir.rglob("*.py"): + update_file(py_file, test_replacements) + + +def download_and_extract_archive(url: str, target_dir: Path) -> Path: + """Download and extract a zip/tar archive from a URL.""" + archive_path = target_dir / "archive.tmp" + + Logger.info(f"Downloading from {url}...") + try: + urllib.request.urlretrieve(url, archive_path) # nosec B310 + except Exception as e: + Logger.error(f"Failed to download archive: {e}") + sys.exit(1) + + extract_dir = target_dir / "extracted" + if extract_dir.exists(): + shutil.rmtree(extract_dir) + extract_dir.mkdir(parents=True, exist_ok=True) + + Logger.info("Extracting archive...") + + try: + if zipfile.is_zipfile(archive_path): + with zipfile.ZipFile(archive_path, "r") as zf: + # Filter out dangerous paths + for member in zf.namelist(): + if member.startswith("/") or ".." in member: + continue + zf.extract(member, extract_dir) + elif tarfile.is_tarfile(archive_path): + with tarfile.open(archive_path, "r:*") as tf: + # Filter out dangerous members + safe_members = [ + m + for m in tf.getmembers() + if m.name and not (m.name.startswith("/") or ".." in m.name) + ] + tf.extractall(extract_dir, members=safe_members) # nosec B202 + else: + raise ValueError("Unknown archive format") + except Exception as e: + Logger.error(f"Failed to extract archive: {e}") + sys.exit(1) + finally: + if archive_path.exists(): + archive_path.unlink() + + # If the archive contains a single top-level directory, return that + contents = list(extract_dir.iterdir()) + if len(contents) == 1 and contents[0].is_dir(): + return contents[0] + return extract_dir + + +class GitHubCLI: + """Wrapper for GitHub CLI commands.""" + + @staticmethod + def run( + args: list[str], check: bool = True, capture: bool = True + ) -> subprocess.CompletedProcess[str]: + """Run a gh command.""" + cmd = ["gh", *args] + try: + result = subprocess.run( + cmd, + check=check, + capture_output=capture, + text=True, + ) + return result + except subprocess.CalledProcessError as e: + if capture: + Logger.error(f"Command failed: {' '.join(cmd)}") + if e.stderr: + print(e.stderr, file=sys.stderr) + raise + + @staticmethod + def api(endpoint: str, method: str = "GET", data: dict[str, Any] | None = None) -> Any: + """Make a GitHub API call.""" + args = ["api", endpoint, "-X", method] + if data: + args.append("--input") + args.append("-") + + result = subprocess.run( + ["gh", *args], + input=json.dumps(data) if data else None, + capture_output=True, + text=True, + check=True, + ) + + if result.stdout: + return json.loads(result.stdout) + return None + + @staticmethod + def is_authenticated() -> bool: + """Check if gh is authenticated.""" + try: + result = subprocess.run( + ["gh", "auth", "status"], + capture_output=True, + text=True, + ) + return result.returncode == 0 + except FileNotFoundError: + return False diff --git a/uv.lock b/uv.lock index beda74c..7cc5a9e 100644 --- a/uv.lock +++ b/uv.lock @@ -104,17 +104,21 @@ dev = [ { name = "codespell" }, { name = "commitizen" }, { name = "doit" }, + { name = "griffe" }, { name = "mkdocs-material" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pyproject-fmt" }, + { name = "pyright" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pytest-cov" }, { name = "pytest-xdist" }, + { name = "radon" }, { name = "rich" }, { name = "ruff" }, { name = "safety" }, + { name = "vulture" }, ] security = [ { name = "bandit" }, @@ -132,6 +136,7 @@ requires-dist = [ { name = "commitizen", marker = "extra == 'dev'", specifier = ">=3.0" }, { name = "doit", marker = "extra == 'dev'", specifier = ">=0.36.0" }, { name = "dumper", specifier = ">=1.2.0" }, + { name = "griffe", marker = "extra == 'dev'", specifier = ">=0.40" }, { name = "humanize", specifier = ">=4.11.0" }, { name = "mkdocs-material", marker = "extra == 'dev'", specifier = ">=9.5" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.5.0" }, @@ -141,10 +146,12 @@ requires-dist = [ { name = "psutil", specifier = ">=6.0.0" }, { name = "pydatatracker" }, { name = "pyproject-fmt", marker = "extra == 'dev'", specifier = ">=2.0" }, + { name = "pyright", marker = "extra == 'dev'", specifier = ">=1.1.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=8.0" }, { name = "pytest-asyncio", marker = "extra == 'dev'", specifier = ">=0.21.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=5.0" }, { name = "pytest-xdist", marker = "extra == 'dev'", specifier = ">=3.5" }, + { name = "radon", marker = "extra == 'dev'", specifier = ">=6.0" }, { name = "rapidfuzz", specifier = ">=3.10.0" }, { name = "regex", specifier = ">=2024.9.11" }, { name = "rich", marker = "extra == 'dev'", specifier = ">=13.0" }, @@ -152,6 +159,7 @@ requires-dist = [ { name = "safety", marker = "extra == 'dev'", specifier = ">=3.0.0" }, { name = "safety", marker = "extra == 'security'", specifier = ">=3.0.0" }, { name = "telnetlib3", specifier = ">=2.0.4" }, + { name = "vulture", marker = "extra == 'dev'", specifier = ">=2.11" }, ] provides-extras = ["dev", "security"] @@ -650,6 +658,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f7/ec/67fbef5d497f86283db54c22eec6f6140243aae73265799baaaa19cd17fb/ghp_import-2.1.0-py3-none-any.whl", hash = "sha256:8337dd7b50877f163d4c0289bc1f1c7f127550241988d568c1db512c4324a619", size = 11034, upload-time = "2022-05-02T15:47:14.552Z" }, ] +[[package]] +name = "griffe" +version = "1.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0d/0c/3a471b6e31951dce2360477420d0a8d1e00dea6cf33b70f3e8c3ab6e28e1/griffe-1.15.0.tar.gz", hash = "sha256:7726e3afd6f298fbc3696e67958803e7ac843c1cfe59734b6251a40cdbfb5eea", size = 424112, upload-time = "2025-11-10T15:03:15.52Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/83/3b1d03d36f224edded98e9affd0467630fc09d766c0e56fb1498cbb04a9b/griffe-1.15.0-py3-none-any.whl", hash = "sha256:6f6762661949411031f5fcda9593f586e6ce8340f0ba88921a0f2ef7a81eb9a3", size = 150705, upload-time = "2025-11-10T15:03:13.549Z" }, +] + [[package]] name = "h11" version = "0.16.0" @@ -768,6 +788,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, ] +[[package]] +name = "mando" +version = "0.7.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/35/24/cd70d5ae6d35962be752feccb7dca80b5e0c2d450e995b16abd6275f3296/mando-0.7.1.tar.gz", hash = "sha256:18baa999b4b613faefb00eac4efadcf14f510b59b924b66e08289aa1de8c3500", size = 37868, upload-time = "2022-02-24T08:12:27.316Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d2/f0/834e479e47e499b6478e807fb57b31cc2db696c4db30557bb6f5aea4a90b/mando-0.7.1-py2.py3-none-any.whl", hash = "sha256:26ef1d70928b6057ee3ca12583d73c63e05c49de8972d620c278a7b206581a8a", size = 28149, upload-time = "2022-02-24T08:12:25.24Z" }, +] + [[package]] name = "markdown" version = "3.10" @@ -1396,6 +1428,12 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/0b/2bdac7f9f7cddcd8096af44338548f1b7d5b797e3bcee27831c3752c9168/pyproject_fmt-2.11.1-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:97b6ba9923975667fab130c23bfd8ead66c4cdea4b66ae238de860a06afbb108", size = 1539351, upload-time = "2025-11-05T12:53:37.836Z" }, { url = "https://files.pythonhosted.org/packages/06/fc/48b4932570097a08ed6abc3a7455aacf9a15271ff0099c33d48e7f745eaa/pyproject_fmt-2.11.1-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b16ce0874ef2aee219a2c0dacd7c0ce374562c19937bd9c767093ade91e5e452", size = 1429957, upload-time = "2025-11-05T12:53:39.382Z" }, { url = "https://files.pythonhosted.org/packages/f7/8d/52f52e039e5e1cfb33cf0f79651edd4d8ff7f6a83d1fb5dddf19bca9993a/pyproject_fmt-2.11.1-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2daf29e4958c310c27ce7750741ef60f79b2f4164df26b1f2bdd063f2beddf4c", size = 1375776, upload-time = "2025-11-05T12:53:40.659Z" }, + { url = "https://files.pythonhosted.org/packages/58/7b/253e8c1d6bef9b5d041f8f104ae5ca70afc6a8bb23042b4272d30a67a2f9/pyproject_fmt-2.11.1-cp39-abi3-manylinux_2_24_aarch64.whl", hash = "sha256:e5da4b06eea58f89a93d7ae4426e31261d8c571dc5f71d8e13b9c7cdd8e8b253", size = 1317349, upload-time = "2026-01-07T23:30:20.34Z" }, + { url = "https://files.pythonhosted.org/packages/fa/40/8b876c4244fd8cace8e85afc9c30b806f940dde713d81d15318f98179b39/pyproject_fmt-2.11.1-cp39-abi3-manylinux_2_24_armv7l.whl", hash = "sha256:a28c3425785fb28cee1316fdf860eab403274626f2cc0b6df14ec7dcce0f66d2", size = 1271918, upload-time = "2026-01-07T23:30:21.581Z" }, + { url = "https://files.pythonhosted.org/packages/69/26/9c7ac96a390ec246892e427be7937400e9f8fe4e1f1cfa412bb919175f90/pyproject_fmt-2.11.1-cp39-abi3-manylinux_2_24_i686.whl", hash = "sha256:584e957a951330f777e632ccd12e87d6e8b2a7d113d4a2abbe1f84b8d85fce06", size = 1430842, upload-time = "2026-01-07T23:30:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/ce/cf/a619fbb8b19cafe78b471e69549e306c6f42e1a1296f12899a12403e474a/pyproject_fmt-2.11.1-cp39-abi3-manylinux_2_24_ppc64le.whl", hash = "sha256:ad8d5c0825a37ebe3414b0590336700600ee718bf6a461fbd1be01dba68e7a99", size = 1573886, upload-time = "2026-01-07T23:30:25.191Z" }, + { url = "https://files.pythonhosted.org/packages/ab/19/da02ed5b31c71d617273459a34e9401c4303c1c2eb00487faa0b55f0c64c/pyproject_fmt-2.11.1-cp39-abi3-manylinux_2_24_s390x.whl", hash = "sha256:a5fd2882b6ae5f0b1651e9e30cb2ba1ad07d99359dc1d056999766ec20706400", size = 1447873, upload-time = "2026-01-07T23:30:27.691Z" }, + { url = "https://files.pythonhosted.org/packages/d4/2a/7eb3f9c1848d72186a9ace63429375e30544f69795b05be0998523e31ff0/pyproject_fmt-2.11.1-cp39-abi3-manylinux_2_24_x86_64.whl", hash = "sha256:56abdaa2afafe86436302220ea90bde025dcec066e348e7f9001a7663dc398f8", size = 1416637, upload-time = "2026-01-07T23:30:29.388Z" }, { url = "https://files.pythonhosted.org/packages/3b/24/bab927c42d88befbb063b229b44c9ce9b8a894f650ca14348969858878f5/pyproject_fmt-2.11.1-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:44b1edad216b33817d2651a15fb2793807fd7c9cfff1ce66d565c4885b89640e", size = 1379396, upload-time = "2025-11-05T12:53:41.857Z" }, { url = "https://files.pythonhosted.org/packages/09/fe/b98c2156775067e079ca8f2badbe93a5de431ccc061435534b76f11abc73/pyproject_fmt-2.11.1-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:08ccf565172179fc7f35a90f4541f68abcdbef7e7a4ea35fcead44f8cabe3e3a", size = 1506485, upload-time = "2025-11-05T12:53:43.108Z" }, { url = "https://files.pythonhosted.org/packages/8e/2f/bf0df9df04a1376d6d1dad6fc49eb41ffafe0c3e63565b2cde8b67a49886/pyproject_fmt-2.11.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:27a9af1fc8d2173deb7a0bbb8c368a585e7817bcbba6acf00922b73c76c8ee23", size = 1546050, upload-time = "2025-11-05T12:53:44.491Z" }, @@ -1403,6 +1441,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/94/73/fed4e436f7afaa12d3f12d1943aa18524d703bd3df8c0a40f2bc58377819/pyproject_fmt-2.11.1-cp39-abi3-win_amd64.whl", hash = "sha256:5bf986b016eb157b30531d0f1036430023db0195cf2d6fd24e4b43cbc02c0da5", size = 1229915, upload-time = "2025-11-05T12:53:47.992Z" }, ] +[[package]] +name = "pyright" +version = "1.1.408" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "nodeenv" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/74/b2/5db700e52554b8f025faa9c3c624c59f1f6c8841ba81ab97641b54322f16/pyright-1.1.408.tar.gz", hash = "sha256:f28f2321f96852fa50b5829ea492f6adb0e6954568d1caa3f3af3a5f555eb684", size = 4400578, upload-time = "2026-01-08T08:07:38.795Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/82/a2c93e32800940d9573fb28c346772a14778b84ba7524e691b324620ab89/pyright-1.1.408-py3-none-any.whl", hash = "sha256:090b32865f4fdb1e0e6cd82bf5618480d48eecd2eb2e70f960982a3d9a4c17c1", size = 6399144, upload-time = "2026-01-08T08:07:37.082Z" }, +] + [[package]] name = "pytest" version = "8.4.2" @@ -1550,6 +1601,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/3c/26/1062c7ec1b053db9e499b4d2d5bc231743201b74051c973dadeac80a8f43/questionary-2.1.1-py3-none-any.whl", hash = "sha256:a51af13f345f1cdea62347589fbb6df3b290306ab8930713bfae4d475a7d4a59", size = 36753, upload-time = "2025-08-28T19:00:19.56Z" }, ] +[[package]] +name = "radon" +version = "6.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama" }, + { name = "mando" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/6d/98e61600febf6bd929cf04154537c39dc577ce414bafbfc24a286c4fa76d/radon-6.0.1.tar.gz", hash = "sha256:d1ac0053943a893878940fedc8b19ace70386fc9c9bf0a09229a44125ebf45b5", size = 1874992, upload-time = "2023-03-26T06:24:38.868Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/93/f7/d00d9b4a0313a6be3a3e0818e6375e15da6d7076f4ae47d1324e7ca986a1/radon-6.0.1-py2.py3-none-any.whl", hash = "sha256:632cc032364a6f8bb1010a2f6a12d0f14bc7e5ede76585ef29dc0cecf4cd8859", size = 52784, upload-time = "2023-03-26T06:24:33.949Z" }, +] + [[package]] name = "rapidfuzz" version = "3.11.0" @@ -1981,6 +2045,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" }, ] +[[package]] +name = "vulture" +version = "2.14" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/25/925f35db758a0f9199113aaf61d703de891676b082bd7cf73ea01d6000f7/vulture-2.14.tar.gz", hash = "sha256:cb8277902a1138deeab796ec5bef7076a6e0248ca3607a3f3dee0b6d9e9b8415", size = 58823, upload-time = "2024-12-08T17:39:43.319Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a0/56/0cc15b8ff2613c1d5c3dc1f3f576ede1c43868c1bc2e5ccaa2d4bcd7974d/vulture-2.14-py2.py3-none-any.whl", hash = "sha256:d9a90dba89607489548a49d557f8bac8112bd25d3cbc8aeef23e860811bd5ed9", size = 28915, upload-time = "2024-12-08T17:39:40.573Z" }, +] + [[package]] name = "watchdog" version = "6.0.0"