diff --git a/.github/actions/bootstrap/action.yml b/.github/actions/bootstrap/action.yml index 7b5787f..bddcaff 100644 --- a/.github/actions/bootstrap/action.yml +++ b/.github/actions/bootstrap/action.yml @@ -16,13 +16,21 @@ inputs: runs: using: 'composite' steps: - - name: Create run_script for running scripts downstream + - name: Create run_script for running scripts downstream (Unix) + if: runner.os != 'Windows' shell: 'bash --noprofile --norc -Eeuo pipefail {0}' working-directory: ${{ inputs.working-directory }} run: | run_script="uv run --frozen" echo "run_script=${run_script}" | tee -a "${GITHUB_ENV}" + - name: Create run_script for running scripts downstream (Windows) + if: runner.os == 'Windows' + shell: pwsh + working-directory: ${{ inputs.working-directory }} + run: | + echo "run_script=uv run --frozen" | Tee-Object -Append $env:GITHUB_ENV + - name: Setup uv uses: astral-sh/setup-uv@v4 with: @@ -37,6 +45,7 @@ runs: repo-token: ${{ inputs.token }} - name: Add Homebrew to the path + if: runner.os != 'Windows' shell: 'bash --noprofile --norc -Eeuo pipefail {0}' # This ensures compatibility with macOS runners and Linux runners with Homebrew run: | @@ -67,19 +76,45 @@ runs: fi working-directory: ${{ inputs.working-directory }} - - name: Set Python hash for caching + - name: Install trufflehog + if: runner.os != 'Windows' + shell: 'bash --noprofile --norc -Eeuo pipefail {0}' + run: | + curl -sSfL https://raw.githubusercontent.com/trufflesecurity/trufflehog/main/scripts/install.sh | sh -s -- -b /usr/local/bin + + - name: Set Python hash for caching (Unix) + if: runner.os != 'Windows' shell: 'bash --noprofile --norc -Eeuo pipefail {0}' run: | # Create a hash of the Python version for better cache keys echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" | tee -a "${GITHUB_ENV}" + - name: Set Python hash for caching (Windows) + if: runner.os == 'Windows' + shell: pwsh + run: | + $pyVersion = python -VV 2>&1 + $hash = [System.BitConverter]::ToString( + [System.Security.Cryptography.SHA256]::Create().ComputeHash( + [System.Text.Encoding]::UTF8.GetBytes($pyVersion) + ) + ).Replace("-", "").ToLower() + echo "PY=$hash" | Tee-Object -Append $env:GITHUB_ENV + - name: Cache pre-commit environments uses: actions/cache@v4 with: path: ~/.cache/pre-commit key: pre-commit|${{ env.PY }}|${{ hashFiles(format('{0}/.pre-commit-config.yaml', inputs.working-directory)) }} - - name: Initialize the repository + - name: Initialize the repository (Unix) + if: runner.os != 'Windows' working-directory: ${{ inputs.working-directory }} shell: 'bash --noprofile --norc -Eeuo pipefail {0}' run: task -v init + + - name: Initialize the repository (Windows) + if: runner.os == 'Windows' + working-directory: ${{ inputs.working-directory }} + shell: pwsh + run: task -v init diff --git a/.github/etc/dictionary.txt b/.github/etc/dictionary.txt index 99b9149..cad5dda 100644 --- a/.github/etc/dictionary.txt +++ b/.github/etc/dictionary.txt @@ -1,9 +1,11 @@ allstar anchore buildx +conftest cookiecutter dependabot digestabot +docstrings dockerhub htmlcov pylance @@ -11,5 +13,6 @@ pythonpath refurb skopeo syft +taskfile zenable zizmor diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e514934..49da940 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -102,6 +102,147 @@ jobs: name: vuln-scan-results path: vulns.json if-no-files-found: error + windows-smoke-test: + name: Windows Smoke Test + runs-on: windows-latest + steps: + # Note: no checkout step. The cookiecutter template directory contains + # characters (pipe, quotes) that are illegal on NTFS, so we cannot check + # out the repo on Windows. Instead, cookiecutter fetches the template + # directly from the remote branch. + - name: Setup uv + uses: astral-sh/setup-uv@v4 + with: + python-version: ${{ env.python_version }} + - name: Install Task + uses: go-task/setup-task@v1 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + - name: Generate project from template + shell: bash + env: + RUN_POST_HOOK: 'true' + SKIP_GIT_PUSH: 'true' + TEMPLATE_REF: ${{ github.event.pull_request.head.sha || github.sha }} + run: | + git config --global user.name "CI Automation" + git config --global user.email "ci@zenable.io" + + # The template directory name contains NTFS-illegal characters + # (double quotes, pipe). Use extract_template_zip.py to safely + # extract and rename only the top-level template dir. + zipUrl="https://github.com/${{ github.repository }}/archive/${TEMPLATE_REF}.zip" + scriptUrl="https://raw.githubusercontent.com/${{ github.repository }}/${TEMPLATE_REF}/scripts/extract_template_zip.py" + tmpdir=$(mktemp -d) + curl -fsSL "$zipUrl" -o "$tmpdir/template.zip" + curl -fsSL "$scriptUrl" -o "$tmpdir/extract_template_zip.py" + repoDir=$(python3 "$tmpdir/extract_template_zip.py" "$tmpdir/template.zip" "$tmpdir/src") + + uvx --with gitpython cookiecutter "$repoDir" --no-input --output-dir "$RUNNER_TEMP" + - name: Verify generated project + shell: pwsh + run: | + $project = Join-Path $env:RUNNER_TEMP "replace-me" + + # Verify the project directory was created + if (-not (Test-Path $project)) { + Write-Error "Project directory not found at $project" + exit 1 + } + + # Verify key files exist + $requiredFiles = @( + "pyproject.toml", + "Taskfile.yml", + "Dockerfile", + "CLAUDE.md", + ".github/project.yml", + ".github/workflows/ci.yml" + ) + foreach ($file in $requiredFiles) { + $filePath = Join-Path $project $file + if (-not (Test-Path $filePath)) { + Write-Error "Required file missing: $file" + exit 1 + } + } + + # Verify no unrendered cookiecutter variables remain + $pattern = '\{\{\s*cookiecutter\.' + $matches = Get-ChildItem -Path $project -Recurse -File -Exclude '.git' | + Where-Object { $_.FullName -notmatch '[\\/]\.git[\\/]' } | + Select-String -Pattern $pattern + if ($matches) { + Write-Error "Unrendered cookiecutter variables found:" + $matches | ForEach-Object { Write-Error $_.ToString() } + exit 1 + } + + # Verify git repo was initialized and has a commit + $gitDir = Join-Path $project ".git" + if (-not (Test-Path $gitDir)) { + Write-Error "Git repository not initialized" + exit 1 + } + + Push-Location $project + $commitCount = git rev-list --count HEAD 2>$null + Pop-Location + if ($commitCount -lt 1) { + Write-Error "No commits found in generated project" + exit 1 + } + + Write-Host "Windows smoke test passed: project generated and verified successfully" + - name: Setup WSL with Docker + shell: bash + run: | + wsl --install -d Ubuntu-24.04 --no-launch + wsl -d Ubuntu-24.04 -u root -- bash -ec " + apt-get update -qq + apt-get install -y -qq curl ca-certificates >/dev/null + curl -fsSL https://get.docker.com | sh -s -- --quiet + service docker start + " + + # Create docker wrappers that route to WSL's Docker. + # - .bat for Task's mvdan/sh (Go's exec.LookPath needs a Windows extension) + # - bash script for Git Bash steps + mkdir -p "$HOME/bin" + printf '@wsl -d Ubuntu-24.04 -u root -- docker %%*\r\n' > "$HOME/bin/docker.bat" + cat > "$HOME/bin/docker" << 'WRAPPER' + #!/bin/bash + exec wsl -d Ubuntu-24.04 -u root -- docker "$@" + WRAPPER + chmod +x "$HOME/bin/docker" + echo "$HOME/bin" >> "$GITHUB_PATH" + - name: Initialize generated project + shell: bash + run: | + cd "$RUNNER_TEMP/replace-me" + task -v init + - name: Run unit tests + shell: bash + # Integration tests require Docker (Linux images) which is not + # available on Windows runners; those are covered by the Linux CI job. + run: | + cd "$RUNNER_TEMP/replace-me" + task -v unit-test + - name: Build Docker image + shell: bash + run: | + cd "$RUNNER_TEMP/replace-me" + task -v build + - name: Verify Docker image + shell: bash + run: | + docker run --rm zenable-io/replace-me:latest --version + docker run --rm zenable-io/replace-me:latest --help + - name: Verify zenable CLI + shell: bash + run: | + export PATH="$HOME/.zenable/bin:$PATH" + zenable version finalizer: # This gives us something to set as required in the repo settings. Some projects use dynamic fan-outs using matrix strategies and the fromJSON function, so # you can't hard-code what _should_ run vs not. Having a finalizer simplifies that so you can just check that the finalizer succeeded, and if so, your @@ -110,7 +251,7 @@ jobs: name: Finalize the pipeline runs-on: ubuntu-24.04 # Keep this aligned with the above jobs - needs: [lint, test] + needs: [lint, test, windows-smoke-test] if: always() # Ensure it runs even if "needs" fails or is cancelled steps: - name: Check for failed or cancelled jobs diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index f075b5e..a3e3cf0 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -43,8 +43,11 @@ repos: rev: ad6fc8fb446b8fafbf7ea8193d2d6bfd42f45690 # frozen: v3.90.11 hooks: - id: trufflehog - # Check the past 2 commits; it's useful to make this go further back than main when running this where main and HEAD are equal - entry: trufflehog git file://. --since-commit main~1 --no-verification --fail + # Resolve the repo root via git-common-dir so this works in both normal repos and worktrees + # (trufflehog doesn't support .git files used by worktrees). + # Guard against detached HEAD (e.g. CI) by falling back to the commit SHA. + language: system + entry: bash -c 'BRANCH=$(git rev-parse --abbrev-ref HEAD); [ "$BRANCH" = "HEAD" ] && BRANCH=$(git rev-parse HEAD); trufflehog git "file://$(cd "$(git rev-parse --git-common-dir)/.." && pwd)" --branch "$BRANCH" --since-commit HEAD~1 --no-verification --fail' - repo: https://github.com/python-openapi/openapi-spec-validator rev: a76da2ffdaf698a7fdbd755f89b051fef4c790fd # frozen: 0.8.0b1 hooks: diff --git a/.pre-commit-hooks.yaml b/.pre-commit-hooks.yaml index c19581b..652c03c 100644 --- a/.pre-commit-hooks.yaml +++ b/.pre-commit-hooks.yaml @@ -2,5 +2,5 @@ - id: zenable-check name: Run a Zenable check on all changed files language: system - entry: uvx zenable-mcp@latest check + entry: zenable check pass_filenames: true diff --git a/docs/ai-ide-support.md b/docs/ai-ide-support.md index 1a0141f..b6acf6f 100644 --- a/docs/ai-ide-support.md +++ b/docs/ai-ide-support.md @@ -7,11 +7,23 @@ The AI-Native Python template automatically configures AI-powered development to ## Automatic Configuration -When you generate a new project, the post-generation hook automatically detects which IDEs and AI assistants you have installed and creates appropriate configuration files: +When you generate a new project, the post-generation hook automatically installs the [Zenable CLI](https://cli.zenable.app) and configures your IDE integrations: -- Model Context Protocol (MCP) configuration for [Zenable](https://zenable.io) and other MCP servers (if supported tools are detected) -- IDE-specific configuration files based on what's installed (Claude, GitHub Copilot, Cursor, etc.) -- Project-specific context and guidelines tailored to your project +**Installation (if the Zenable CLI is not already installed):** + +macOS/Linux: +```bash +curl -fsSL https://cli.zenable.app/install.sh | bash +``` + +Windows: +```powershell +powershell -ExecutionPolicy Bypass -Command "irm https://cli.zenable.app/install.ps1 | iex" +``` + +**IDE Configuration:** + +Once installed, `zenable install` detects which IDEs and AI assistants you have installed and creates appropriate configuration files for 15+ supported IDEs including Claude Code, Cursor, Windsurf, VS Code, GitHub Copilot, and more. These configurations are dynamically generated based on your installed IDEs and project settings, and include: diff --git a/hooks/post_gen_project.py b/hooks/post_gen_project.py index 14e5615..1a93f37 100755 --- a/hooks/post_gen_project.py +++ b/hooks/post_gen_project.py @@ -4,15 +4,18 @@ """ import datetime +import hashlib import json import os import pprint import shutil import subprocess import sys +import tempfile from collections import OrderedDict from logging import basicConfig, getLogger from pathlib import Path +from urllib.request import HTTPSHandler, build_opener import yaml @@ -141,30 +144,159 @@ def notify_dockerhub_secrets() -> None: print("=" * 70 + "\n") -def opportunistically_install_zenable_tools() -> None: - """Opportunistically install zenable-mcp if uvx is available.""" - # Check if uvx is not available - if not shutil.which("uvx"): - # uvx is not available, notify the user - print("\n" + "=" * 70) - print("NOTE: Skipped configuring the Zenable AI coding guardrails") - print("=" * 70) - print("\nConfiguring the Zenable AI coding guardrails requires the uv package manager.") - print("To set this up later:") - print("\n1. Install uv via https://docs.astral.sh/uv/getting-started/installation/") - print("2. Run: uvx zenable-mcp@latest install") - print("=" * 70 + "\n") +def _find_zenable_binary() -> str | None: + """Find the zenable binary in PATH or the default install location.""" + zenable_path = shutil.which("zenable") + if zenable_path: + return zenable_path + + # Check the default install location + binary_name = "zenable.exe" if sys.platform == "win32" else "zenable" + default_path = Path.home() / ".zenable" / "bin" / binary_name + if default_path.is_file(): + return str(default_path) + + return None + + +ZENABLE_RELEASE_URL = "https://cli.zenable.app/zenable/latest" + + +_https_opener = build_opener(HTTPSHandler()) + + +def _fetch_release_metadata() -> dict: + """Fetch the Zenable CLI release metadata from cli.zenable.app.""" + with _https_opener.open(ZENABLE_RELEASE_URL, timeout=30) as resp: + return json.loads(resp.read()) + + +def _download_url(url: str) -> bytes: + """Download a URL and return the raw bytes.""" + with _https_opener.open(url, timeout=60) as resp: + return resp.read() + + +def _verify_checksum(data: bytes, expected_sha256: str) -> None: + """Verify SHA-256 checksum of data against the expected value. + + Raises ValueError if the checksum does not match. + """ + actual = hashlib.sha256(data).hexdigest() + if actual != expected_sha256: + msg = f"Checksum mismatch: expected {expected_sha256}, got {actual}" + raise ValueError(msg) - LOG.warning("uvx was not found in PATH, so the Zenable integrations were not installed.") - return - # uvx is available, attempt to install zenable-mcp - LOG.debug("uvx is available in PATH, attempting to install the Zenable tools...") +def _install_zenable_binary() -> bool: + """Install the zenable CLI binary. + + Fetches the release metadata from cli.zenable.app/zenable/latest, + downloads the appropriate installer for the current platform, verifies + its SHA-256 checksum, then executes it non-interactively. The install + script itself also performs cosign signature verification of the + downloaded binary. + + Returns True if installation succeeded, False otherwise. + """ + env = {**os.environ, "ZENABLE_YES": "1"} + + try: + metadata = _fetch_release_metadata() + + if sys.platform == "win32": + installer_key = "install.ps1" + else: + installer_key = "install.sh" + + install_url = metadata["installers"][installer_key] + expected_checksum = metadata["installer_checksums"][installer_key] + + install_script = _download_url(install_url) + _verify_checksum(install_script, expected_checksum) + + if sys.platform == "win32": + # Write to a temp file because PowerShell's -Command - does not + # reliably read scripts from stdin. + tmp = tempfile.NamedTemporaryFile(suffix=".ps1", delete=False, mode="wb") + tmp.write(install_script) + tmp.close() + cmd = ["powershell", "-ExecutionPolicy", "Bypass", "-File", tmp.name] + input_data = None + else: + cmd = ["bash"] + input_data = install_script + tmp = None + + result = subprocess.run( + cmd, + input=input_data, + check=True, + capture_output=True, + timeout=120, + env=env, + ) + + if tmp is not None: + Path(tmp.name).unlink(missing_ok=True) + if result.stdout: + LOG.info("Zenable installer stdout: %s", result.stdout.decode("utf-8", errors="replace").strip()) + if result.stderr: + LOG.info("Zenable installer stderr: %s", result.stderr.decode("utf-8", errors="replace").strip()) + return True + except ValueError: + LOG.warning("Zenable install script checksum verification failed") + return False + except Exception: + LOG.warning("Failed to install the Zenable CLI binary") + return False + + +def opportunistically_install_zenable_tools() -> None: + """Opportunistically install the Zenable CLI and configure IDE integrations.""" + zenable_bin = _find_zenable_binary() + + if not zenable_bin: + LOG.debug("Zenable CLI not found, attempting to install...") + if not _install_zenable_binary(): + print("\n" + "=" * 70) + print("NOTE: Skipped configuring the Zenable AI coding guardrails") + print("=" * 70) + print("\nTo set this up later, install the Zenable CLI:") + print("\n curl -fsSL https://cli.zenable.app/install.sh | bash") + print("\nThen run: zenable install") + print("=" * 70 + "\n") + + LOG.warning("Zenable CLI could not be installed.") + return + + # The installer updates PATH for future shells/steps (e.g. via + # GITHUB_PATH or the user's shell profile) but not the current + # process. Add the default install directory so we can find the + # binary immediately. + zenable_bin_dir = str(Path.home() / ".zenable" / "bin") + os.environ["PATH"] = zenable_bin_dir + os.pathsep + os.environ.get("PATH", "") + + zenable_bin = _find_zenable_binary() + if not zenable_bin: + # Diagnostic: log what the installer actually created + zenable_dir = Path.home() / ".zenable" + if zenable_dir.exists(): + contents = [str(p.relative_to(zenable_dir)) for p in zenable_dir.rglob("*")] + LOG.warning("Install dir %s contents: %s", zenable_dir, contents) + else: + LOG.warning("Install directory does not exist: %s", zenable_dir) + LOG.warning("Current PATH: %s", os.environ.get("PATH", "")) + LOG.warning("Zenable CLI was installed but could not be found in PATH or default location.") + return + + # Zenable CLI is available, attempt to configure IDE integrations + LOG.debug("Zenable CLI found at %s, configuring IDE integrations...", zenable_bin) try: - subprocess.run(["uvx", "zenable-mcp@latest", "install"], check=True, timeout=60) + subprocess.run([zenable_bin, "install"], check=True, timeout=60) print("\n" + "=" * 70) print("Successfully configured the Zenable AI coding guardrails 🚀") - print("To start using it, just open the IDE of your choice, login to the MCP server, and you're all set 🤖") + print("To start using it, just open the IDE of your choice, login, and you're all set 🤖") print("Learn more at https://docs.zenable.io") print("=" * 70 + "\n") except Exception: @@ -174,7 +306,7 @@ def opportunistically_install_zenable_tools() -> None: print("WARNING: Failed to configure the Zenable AI coding guardrails") print("=" * 70) print("You can retry it later by running:") - print("\n uvx zenable-mcp@latest install") + print("\n zenable install") print("\nTo report issues, please contact:") print(" • https://zenable.io/feedback") print(" • support@zenable.io") diff --git a/scripts/extract_template_zip.py b/scripts/extract_template_zip.py new file mode 100755 index 0000000..88ec0fb --- /dev/null +++ b/scripts/extract_template_zip.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +"""Extract a cookiecutter template from a GitHub archive zip. + +The top-level template directory in the zip contains NTFS-illegal characters +(pipe, double-quotes) from the Jinja2 expression in +``{{cookiecutter.project_name|replace(" ", "")}}``. This script renames +**only** that top-level directory to the safe ``{{cookiecutter.project_name}}`` +form while preserving nested cookiecutter directories like +``{{cookiecutter.project_slug}}`` so cookiecutter renders them correctly. + +Usage: + python extract_template_zip.py + +Prints the path to the extracted template root on stdout. +""" + +import os +import sys +import zipfile + + +def extract(zip_path: str, dest: str) -> str: + """Extract the zip, renaming only the top-level template directory.""" + with zipfile.ZipFile(zip_path) as zf: + for info in zf.infolist(): + parts = info.filename.split("/") + + safe = [] + for i, part in enumerate(parts): + # Index 0 is the repo root (e.g. ai-native-python-). + # Index 1 is the template directory with NTFS-illegal chars. + if i == 1 and "{{cookiecutter." in part: + safe.append("{{cookiecutter.project_name}}") + else: + safe.append(part) + + target = os.path.join(dest, *[s for s in safe if s]) + + if info.is_dir(): + os.makedirs(target, exist_ok=True) + else: + os.makedirs(os.path.dirname(target), exist_ok=True) + with zf.open(info) as src, open(target, "wb") as dst: + dst.write(src.read()) + + return os.path.join(dest, os.listdir(dest)[0]) + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print(f"Usage: {sys.argv[0]} ", file=sys.stderr) + sys.exit(1) + print(extract(sys.argv[1], sys.argv[2])) diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/.github/etc/dictionary.txt" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/.github/etc/dictionary.txt" index 4518ebc..22fa581 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/.github/etc/dictionary.txt" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/.github/etc/dictionary.txt" @@ -11,9 +11,12 @@ {{ item }} {% endfor %} allstar +conftest dependabot +docstrings refurb renovatebot skopeo syft +taskfile zenable diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" index 4f0955e..4673cd6 100644 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/Taskfile.yml" @@ -13,12 +13,12 @@ vars: PROJECT_SLUG: {{ cookiecutter.project_slug }} PYTHON_VERSION: {{ cookiecutter.python_version }} SUPPORTED_PLATFORMS: 'linux/amd64,linux/arm64' - VERSION: - sh: uv run python -c 'from src.{{ '{{.PROJECT_SLUG}}' }} import __version__; print(__version__)' RUN_SCRIPT: 'uv run --frozen' SCRIPTS_DIR: 'scripts' + VERSION: + sh: "{{ '{{.RUN_SCRIPT}}' }} python -c \"import sys; sys.path.insert(0, 'src'); from {{ '{{.PROJECT_SLUG}}' }} import __version__; print(__version__)\"" LOCAL_PLATFORM: - sh: "{{ '{{.RUN_SCRIPT}}' }} {{ '{{.SCRIPTS_DIR}}' }}/get_platform.sh" + sh: "bash {{ '{{.SCRIPTS_DIR}}' }}/get_platform.sh" # Use PLATFORM if specified, otherwise use LOCAL_PLATFORM PLATFORM: '{{ '{{if .PLATFORM}}' }}{{ '{{.PLATFORM}}' }}{{ '{{else}}' }}{{ '{{.LOCAL_PLATFORM}}' }}{{ '{{end}}' }}' # Output redirect based on CI environment @@ -88,7 +88,7 @@ tasks: TIMESTAMP: sh: '{{ '{{.RUN_SCRIPT}}' }} {{ '{{.SCRIPTS_DIR}}' }}/get_rfc3339_timestamp.py' EPOCH: - sh: '{{ '{{.RUN_SCRIPT}}' }} {{ '{{.SCRIPTS_DIR}}' }}/get_epoch.sh' + sh: 'bash {{ '{{.SCRIPTS_DIR}}' }}/get_epoch.sh' COMMIT_HASH: sh: git rev-parse HEAD BUILD_PLATFORM: '{{ '{{if eq .PLATFORM "all"}}' }}{{ '{{.SUPPORTED_PLATFORMS}}' }}{{ '{{else if .PLATFORM}}' }}{{ '{{.PLATFORM}}' }}{{ '{{else}}' }}{{ '{{.LOCAL_PLATFORM}}' }}{{ '{{end}}' }}' diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" index 6aa3cd6..8ae9470 100755 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/src/main.py" @@ -3,11 +3,19 @@ {{ cookiecutter.project_name }} script entrypoint """ -from {{ cookiecutter.project_slug }} import config +import argparse + +from {{ cookiecutter.project_slug }} import __version__, config def main(): """Main entry point for the application.""" + parser = argparse.ArgumentParser(description="{{ cookiecutter.project_short_description | replace('"', '\\"') | replace("'", "\\\\'") }}") + parser.add_argument( + "--version", action="version", version=f"%(prog)s {__version__}" + ) + parser.parse_args() + log = config.setup_logging() log.debug("Logging initialized with level: %s", log.level) diff --git "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" index 18bae2b..9414b00 100755 --- "a/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" +++ "b/{{cookiecutter.project_name|replace(\" \", \"\")}}/tests/test_main.py" @@ -22,14 +22,20 @@ def test_main_function(): """Test that main() raises NotImplementedError""" from main import main - # Mock the argument parsing to avoid conflicts with pytest args - with patch("{{ cookiecutter.project_slug }}.config.get_args_config") as mock_args: - import logging + with patch("sys.argv", ["main"]): + with pytest.raises(NotImplementedError): + main() - mock_args.return_value = {"loglevel": logging.WARNING} - with pytest.raises(NotImplementedError): +@pytest.mark.unit +def test_main_version(): + """Test that --version prints the version and exits""" + from main import main + + with patch("sys.argv", ["main", "--version"]): + with pytest.raises(SystemExit) as exc_info: main() + assert exc_info.value.code == 0 @pytest.mark.unit @@ -46,3 +52,20 @@ def test_main_as_script(): # Should exit with code 1 due to NotImplementedError assert result.returncode == 1 assert "NotImplementedError" in result.stderr + + +@pytest.mark.unit +def test_main_as_script_version(): + """Test that --version works when run as a script""" + main_path = Path(__file__).parent.parent / "src" / "main.py" + + result = subprocess.run( + [sys.executable, str(main_path), "--version"], + capture_output=True, + text=True, + ) + + from {{ cookiecutter.project_slug }} import __version__ + + assert result.returncode == 0 + assert __version__ in result.stdout