diff --git a/noxfile.py b/noxfile.py index 60c6728..b0fe2db 100644 --- a/noxfile.py +++ b/noxfile.py @@ -1,5 +1,5 @@ """Noxfile for the cookiecutter-robust-python template.""" - +import os import shutil import tempfile from pathlib import Path @@ -15,6 +15,7 @@ DEFAULT_TEMPLATE_PYTHON_VERSION = "3.9" REPO_ROOT: Path = Path(__file__).parent.resolve() +SCRIPTS_FOLDER: Path = REPO_ROOT / "scripts" TEMPLATE_FOLDER: Path = REPO_ROOT / "{{cookiecutter.project_name}}" @@ -26,78 +27,66 @@ ) ).resolve() -PROJECT_DEMOS_FOLDER: Path = COOKIECUTTER_ROBUST_PYTHON_CACHE_FOLDER / "project_demos" -DEFAULT_DEMO_NAME: str = "demo-project" +DEFAULT_PROJECT_DEMOS_FOLDER = COOKIECUTTER_ROBUST_PYTHON_CACHE_FOLDER / "project_demos" +PROJECT_DEMOS_FOLDER: Path = Path(os.getenv( + "COOKIECUTTER_ROBUST_PYTHON_PROJECT_DEMOS_FOLDER", default=DEFAULT_PROJECT_DEMOS_FOLDER +)).resolve() +DEFAULT_DEMO_NAME: str = "robust-python-demo" DEMO_ROOT_FOLDER: Path = PROJECT_DEMOS_FOLDER / DEFAULT_DEMO_NAME +GENERATE_DEMO_PROJECT_SCRIPT: Path = SCRIPTS_FOLDER / "generate-demo-project.py" GENERATE_DEMO_PROJECT_OPTIONS: tuple[str, ...] = ( *("--repo-folder", REPO_ROOT), *("--demos-cache-folder", PROJECT_DEMOS_FOLDER), *("--demo-name", DEFAULT_DEMO_NAME), ) +SYNC_UV_WITH_DEMO_SCRIPT: Path = SCRIPTS_FOLDER / "sync-uv-with-demo.py" SYNC_UV_WITH_DEMO_OPTIONS: tuple[str, ...] = ( *("--template-folder", TEMPLATE_FOLDER), *("--demos-cache-folder", PROJECT_DEMOS_FOLDER), *("--demo-name", DEFAULT_DEMO_NAME), ) -TEMPLATE_PYTHON_LOCATIONS: tuple[Path, ...] = (Path("noxfile.py"), Path("scripts"), Path("hooks")) - -TEMPLATE_CONFIG_AND_DOCS: tuple[Path, ...] = ( - Path("pyproject.toml"), - Path(".ruff.toml"), - Path(".editorconfig"), - Path(".gitignore"), - Path(".pre-commit-config.yaml"), - Path(".cz.toml"), - Path("cookiecutter.json"), - Path("README.md"), - Path("LICENSE"), - Path("CODE_OF_CONDUCT.md"), - Path("CHANGELOG.md"), - Path("docs/"), -) - @nox.session(name="generate-demo-project", python=DEFAULT_TEMPLATE_PYTHON_VERSION) def generate_demo_project(session: Session) -> None: + """Generates a project demo using the cookiecutter-robust-python template.""" session.install("cookiecutter", "platformdirs", "loguru", "typer") session.run( "python", - "scripts/generate-demo-project.py", + GENERATE_DEMO_PROJECT_SCRIPT, *GENERATE_DEMO_PROJECT_OPTIONS, - external=True, + *session.posargs ) @nox.session(name="sync-uv-with-demo", python=DEFAULT_TEMPLATE_PYTHON_VERSION) def sync_uv_with_demo(session: Session) -> None: + """Syncs the uv environment with the current demo project.""" session.install("cookiecutter", "platformdirs", "loguru", "typer") session.run( "python", - "scripts/sync-uv-with-demo.py", + SYNC_UV_WITH_DEMO_SCRIPT, *SYNC_UV_WITH_DEMO_OPTIONS, - external=True, ) @nox.session(name="uv-in-demo", python=DEFAULT_TEMPLATE_PYTHON_VERSION) def uv_in_demo(session: Session) -> None: + """Runs a uv command in a new project demo project then syncs with it.""" session.install("cookiecutter", "platformdirs", "loguru", "typer") session.run( "python", - "scripts/generate-demo-project.py", + GENERATE_DEMO_PROJECT_SCRIPT, *GENERATE_DEMO_PROJECT_OPTIONS, - external=True, ) original_dir: Path = Path.cwd() session.cd(DEMO_ROOT_FOLDER) session.run("uv", *session.posargs) session.cd(original_dir) session.run( - "python", - "scripts/sync-uv-with-demo.py", + SYNC_UV_WITH_DEMO_SCRIPT, *SYNC_UV_WITH_DEMO_OPTIONS, external=True, ) @@ -105,10 +94,11 @@ def uv_in_demo(session: Session) -> None: @nox.session(name="in-demo", python=DEFAULT_TEMPLATE_PYTHON_VERSION) def in_demo(session: Session) -> None: + """Generates a project demo and run a uv command in it.""" session.install("cookiecutter", "platformdirs", "loguru", "typer") session.run( "python", - "scripts/generate-demo-project.py", + GENERATE_DEMO_PROJECT_SCRIPT, *GENERATE_DEMO_PROJECT_OPTIONS, ) original_dir: Path = Path.cwd() @@ -141,6 +131,17 @@ def lint(session: Session): session.run("ruff", "check", "--verbose", "--fix") +@nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION, name="lint-generated-project", tags=[]) +def lint_generated_project(session: Session): + """Lint the generated project's Python files and configurations.""" + session.log("Installing linting dependencies for the generated project...") + session.install("-e", ".", "--group", "dev", "--group", "lint") + session._runner.posargs = ["nox", "-s", "pre-commit"] + in_demo(session) + session._runner.posargs = [""] + session.run("retrocookie") + + @nox.session(python=DEFAULT_TEMPLATE_PYTHON_VERSION) def docs(session: Session): """Build the template documentation website.""" diff --git a/scripts/generate-demo-project.py b/scripts/generate-demo-project.py index e1dba07..4a75139 100644 --- a/scripts/generate-demo-project.py +++ b/scripts/generate-demo-project.py @@ -1,10 +1,13 @@ """Python script for generating a demo project.""" - +import os import shutil +import stat import sys from functools import partial from pathlib import Path from typing import Annotated +from typing import Any +from typing import Callable import typer from cookiecutter.main import cookiecutter @@ -16,10 +19,11 @@ ) -def generate_demo_project(repo_folder: Path, demos_cache_folder: Path, demo_name: str) -> Path: +def generate_demo_project(repo_folder: Path, demos_cache_folder: Path, demo_name: str, no_cache: bool) -> Path: """Generates a demo project and returns its root path.""" demos_cache_folder.mkdir(exist_ok=True) - _remove_any_existing_demo(demos_cache_folder) + if no_cache: + _remove_existing_demo(demo_path=demos_cache_folder / demo_name) cookiecutter( template=str(repo_folder), no_input=True, @@ -30,10 +34,27 @@ def generate_demo_project(repo_folder: Path, demos_cache_folder: Path, demo_name return demos_cache_folder / demo_name -def _remove_any_existing_demo(parent_path: Path) -> None: - """Removes any existing demos.""" - for path in parent_path.iterdir(): - shutil.rmtree(path) +def _remove_existing_demo(demo_path: Path) -> None: + """Removes the existing demo if present.""" + if demo_path.exists() and demo_path.is_dir(): + previous_demo_pyproject: Path = Path(demo_path, "pyproject.toml") + if not previous_demo_pyproject.exists(): + typer.secho(f"No pyproject.toml found at {previous_demo_pyproject=}.", fg="red") + typer.confirm( + "This folder may not be a demo, are you sure you would like to continue?", + default=False, + abort=True, + show_default=True + ) + + typer.secho(f"Removing existing demo project at {demo_path=}.", fg="yellow") + shutil.rmtree(demo_path, onerror=remove_readonly) + + +def remove_readonly(func: Callable[[str], Any], path: str, _: Any) -> None: + """Clears the readonly bit and attempts to call the provided function.""" + os.chmod(path, stat.S_IWRITE) + func(path) cli: typer.Typer = typer.Typer() @@ -44,10 +65,16 @@ def main( repo_folder: Annotated[Path, FolderOption("--repo-folder", "-r")], demos_cache_folder: Annotated[Path, FolderOption("--demos-cache-folder", "-c")], demo_name: Annotated[str, typer.Option("--demo-name", "-d")], + no_cache: Annotated[bool, typer.Option("--no-cache", "-n")] = False, ) -> None: """Updates the poetry.lock file.""" try: - generate_demo_project(repo_folder=repo_folder, demos_cache_folder=demos_cache_folder, demo_name=demo_name) + generate_demo_project( + repo_folder=repo_folder, + demos_cache_folder=demos_cache_folder, + demo_name=demo_name, + no_cache=no_cache + ) except Exception as error: typer.secho(f"error: {error}", fg="red") sys.exit(1) diff --git a/tests/conftest.py b/tests/conftest.py index dbc72df..25dfb41 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,9 +1,7 @@ """Fixtures used in all tests for cookiecutter-robust-python.""" -import os import subprocess from pathlib import Path -from typing import Generator import pytest from _pytest.tmpdir import TempPathFactory @@ -16,49 +14,37 @@ @pytest.fixture(scope="session") -def robust_python_demo_path(tmp_path_factory: TempPathFactory) -> Path: +def demos_folder(tmp_path_factory: TempPathFactory) -> Path: + """Temp Folder used for storing demos while testing.""" + return tmp_path_factory.mktemp("demos") + + +@pytest.fixture(scope="session") +def robust_python_demo_path(demos_folder: Path) -> Path: """Creates a temporary example python project for testing against and returns its Path.""" - demos_path: Path = tmp_path_factory.mktemp("demos") cookiecutter( str(REPO_FOLDER), no_input=True, overwrite_if_exists=True, - output_dir=demos_path, + output_dir=demos_folder, extra_context={"project_name": "robust-python-demo", "add_rust_extension": False}, ) - path: Path = demos_path / "robust-python-demo" - subprocess.run(["uv", "lock"], cwd=path) + path: Path = demos_folder / "robust-python-demo" + subprocess.run(["nox", "-s", "setup-repo"], cwd=path, capture_output=True) return path @pytest.fixture(scope="session") -def robust_maturin_demo_path(tmp_path_factory: TempPathFactory) -> Path: +def robust_maturin_demo_path(demos_folder: Path) -> Path: """Creates a temporary example maturin project for testing against and returns its Path.""" - demos_path: Path = tmp_path_factory.mktemp("demos") cookiecutter( str(REPO_FOLDER), no_input=True, overwrite_if_exists=True, - output_dir=demos_path, - extra_context={"project_name": "robust-maturin-demo", "add_rust_extension": True}, + output_dir=demos_folder, + extra_context={"project_name": "robust-maturin-demo", "add_rust_extension": True} ) - path: Path = demos_path / "robust-maturin-demo" - subprocess.run(["uv", "sync"], cwd=path) + path: Path = demos_folder / "robust-maturin-demo" + subprocess.run(["nox", "-s", "setup-repo"], cwd=path, capture_output=True) return path - -@pytest.fixture(scope="function") -def inside_robust_python_demo(robust_python_demo_path: Path) -> Generator[Path, None, None]: - """Changes the current working directory to the robust-python-demo project.""" - original_path: Path = Path.cwd() - os.chdir(robust_python_demo_path) - yield robust_python_demo_path - os.chdir(original_path) - - -@pytest.fixture(scope="function") -def inside_robust_maturin_demo(robust_maturin_demo_path: Path) -> Generator[Path, None, None]: - original_path: Path = Path.cwd() - os.chdir(robust_maturin_demo_path) - yield robust_maturin_demo_path - os.chdir(original_path) diff --git a/tests/constants.py b/tests/constants.py index c11bb7d..2251b76 100644 --- a/tests/constants.py +++ b/tests/constants.py @@ -9,6 +9,7 @@ COOKIECUTTER_FOLDER: Path = REPO_FOLDER / "{{cookiecutter.project_name}}" HOOKS_FOLDER: Path = REPO_FOLDER / "hooks" SCRIPTS_FOLDER: Path = REPO_FOLDER / "scripts" +GITHUB_ACTIONS_FOLDER: Path = COOKIECUTTER_FOLDER / ".github" COOKIECUTTER_JSON_PATH: Path = REPO_FOLDER / "cookiecutter.json" COOKIECUTTER_JSON: dict[str, Any] = json.loads(COOKIECUTTER_JSON_PATH.read_text()) @@ -18,18 +19,18 @@ PYTHON_VERSIONS: list[str] = [f"3.{VERSION_SLUG}" for VERSION_SLUG in range(MIN_PYTHON_SLUG, MAX_PYTHON_SLUG + 1)] DEFAULT_PYTHON_VERSION: str = PYTHON_VERSIONS[1] - +FIRST_TIME_SETUP_NOX_SESSIONS: list[str] = ["setup-git", "setup-venv", "setup-repo"] TYPE_CHECK_NOX_SESSIONS: list[str] = [f"typecheck-{python_version}" for python_version in PYTHON_VERSIONS] TESTS_NOX_SESSIONS: list[str] = [f"tests-{python_version}" for python_version in PYTHON_VERSIONS] CHECK_NOX_SESSIONS: list[str] = [f"check-{python_version}" for python_version in PYTHON_VERSIONS] FULL_CHECK_NOX_SESSIONS: list[str] = [f"full-check-{python_version}" for python_version in PYTHON_VERSIONS] + GLOBAL_NOX_SESSIONS: list[str] = [ "pre-commit", - "format-python", - "lint-python", *TYPE_CHECK_NOX_SESSIONS, + *TESTS_NOX_SESSIONS, "docs-build", "build-python", "build-container", @@ -41,4 +42,4 @@ "coverage", ] -RUST_NOX_SESSIONS: list[str] = ["format-rust", "lint-rust", "tests-rust", "publish-rust"] +RUST_NOX_SESSIONS: list[str] = ["tests-rust", "publish-rust"] diff --git a/tests/integration_tests/test_robust_python_demo.py b/tests/integration_tests/test_robust_python_demo.py index 47a5c6e..1952bea 100644 --- a/tests/integration_tests/test_robust_python_demo.py +++ b/tests/integration_tests/test_robust_python_demo.py @@ -13,15 +13,48 @@ def test_demo_project_generation(robust_python_demo_path: Path) -> None: @pytest.mark.parametrize("session", GLOBAL_NOX_SESSIONS) -def test_demo_project_noxfile(robust_python_demo_path: Path, session: str) -> None: - command: list[str] = ["uvx", "nox", "-s", session] +def test_demo_project_nox_session(robust_python_demo_path: Path, session: str) -> None: + command: list[str] = ["nox", "-s", session] result: subprocess.CompletedProcess = subprocess.run( command, cwd=robust_python_demo_path, capture_output=True, text=True, - timeout=10.0, + timeout=20.0 ) print(result.stdout) print(result.stderr) result.check_returncode() + + +def test_demo_project_nox_pre_commit(robust_python_demo_path: Path) -> None: + command: list[str] = ["nox", "-s", "pre-commit"] + result: subprocess.CompletedProcess = subprocess.run( + command, + cwd=robust_python_demo_path, + capture_output=True, + text=True, + timeout=20.0 + ) + assert result.returncode == 0 + + +def test_demo_project_nox_pre_commit_with_install(robust_python_demo_path: Path) -> None: + command: list[str] = ["nox", "-s", "pre-commit", "--", "install"] + pre_commit_hook_path: Path = robust_python_demo_path / ".git" / "hooks" / "pre-commit" + assert not pre_commit_hook_path.exists() + + result: subprocess.CompletedProcess = subprocess.run( + command, + cwd=robust_python_demo_path, + capture_output=True, + text=True, + timeout=20.0 + ) + assert pre_commit_hook_path.exists() + assert pre_commit_hook_path.is_file() + + assert result.returncode == 0 + + + diff --git a/{{cookiecutter.project_name}}/.github/workflows/release-python.yml b/{{cookiecutter.project_name}}/.github/workflows/release-python.yml index 15f95da..e7b23d9 100644 --- a/{{cookiecutter.project_name}}/.github/workflows/release-python.yml +++ b/{{cookiecutter.project_name}}/.github/workflows/release-python.yml @@ -29,7 +29,7 @@ jobs: - name: Checkout code uses: actions/checkout@v4 with: - ref: {% raw %}${{ github.event_name == 'push' && github.ref || github.event.inputs.tag }}{% endraw %} + ref: {{"${{ github.event_name == 'push' && github.ref || github.event.inputs.tag }}"}} - name: Set up uv uses: astral-sh/setup-uv@v6 @@ -50,7 +50,7 @@ jobs: - name: Download built package artifacts uses: actions/download-artifact@v4 with: - name: distribution-packages-{% raw %}{{ github.event.inputs.tag }}{% endraw %} + name: distribution-packages-{{"{{ github.event.inputs.tag }}"}} path: dist/ # --- Publish to TestPyPI Step --- @@ -61,7 +61,7 @@ jobs: env: # TestPyPI credentials stored as secrets in GitHub Settings -> Secrets TWINE_USERNAME: __token__ # Standard username when using API tokens - TWINE_PASSWORD: {% raw %}${{ secrets.TESTPYPI_API_TOKEN }}{% endraw } # Use GitHub Encrypted Secret + TWINE_PASSWORD: {{"${{ secrets.TESTPYPI_API_TOKEN }}"}} # Use GitHub Encrypted Secret # Optional: If uv publish requires different config for repository URL, pass TWINE_REPOSITORY or similar run: uvx nox -s publish-package -- --repository testpypi # Call the publish-package session, passing repository arg @@ -73,13 +73,13 @@ jobs: uses: simple-changelog/action@v3 # Action to parse CHANGELOG.md with: path: CHANGELOG.md # Path to your CHANGELOG.md - tag: { "${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag }}" } # Pass the tag name + tag: {{"${{ github.event_name == 'push' && github.ref_name || github.event.inputs.tag }}"}} # Pass the tag name # Define outputs from this job that other jobs (like create_github_release) can use. outputs: changelog_body: description: "Release notes body extracted from CHANGELOG.md" - value: {% raw %}${{ steps.changelog.outputs.changes }}{% endraw %} # Output the extracted changelog body + value: {{"${{ steps.changelog.outputs.changes }}"}} # Output the extracted changelog body # Job 2: Publish to Production PyPI # This job runs only if Job 1 completes successfully (implicit dependency) @@ -91,7 +91,7 @@ jobs: needs: build_and_testpypi # Only run on tag push events, NOT on manual dispatch for the final PyPI publish - if: { "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" } + if: "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" steps: - name: Download package artifacts uses: actions/download-artifact@v4 @@ -115,7 +115,7 @@ jobs: env: # Production PyPI credentials stored as secrets in GitHub Settings -> Secrets TWINE_USERNAME: __token__ - TWINE_PASSWORD: {% raw %}${{ secrets.PYPI_API_TOKEN }}{% endraw %} # Use GitHub Encrypted Secret + TWINE_PASSWORD: {{"${{ secrets.PYPI_API_TOKEN }}"}} # Use GitHub Encrypted Secret # Optional: TWINE_REPOSITORY if publishing to a custom production index run: uvx nox -s publish-package # Call the publish-package session (defaults to pypi.org) @@ -128,7 +128,7 @@ jobs: needs: build_and_testpypi # Only run this job if triggered by a tag push - if: {% raw %}(github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v'){% endraw %} + if: "github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')" steps: - name: Download package artifacts # Get built artifacts for release assets @@ -138,22 +138,22 @@ jobs: - name: Get tag name id: get_tag - run: echo "tag={% raw %}${{ github.ref_name }}{% endraw %}" >> $GITHUB_OUTPUT + run: echo "tag={{"${{ github.ref_name }}"}}" >> $GITHUB_OUTPUT - name: Create GitHub Release # Uses a standard action to create a release in GitHub based on the tag. uses: softprops/action-gh-release@v2 with: # The Git tag the release is associated with - tag_name: {% raw %}${{ steps.get_tag.outputs.tag }}{% endraw %} + tag_name: {{"${{ steps.get_tag.outputs.tag }}"}} # The name of the release (often the same as the tag) - name: Release {% raw %}${{ steps.get_tag.outputs.tag }}{% endraw %} + name: Release {{"${{ steps.get_tag.outputs.tag }}"}} # The body of the release notes - access the output from the 'build_and_testpypi' job - body: {% raw %}${{ needs.build_and_testpypi.outputs.changelog_body }}{% endraw %} # Access changelog body from dependent job output + body: {{"${{ needs.build_and_testpypi.outputs.changelog_body }}"}} # Access changelog body from dependent job output files: dist/* # Attach built sdist and wheel files as release assets # Optional: Mark as a draft release for manual review before publishing # draft: true # Optional: Mark as a pre-release for tags containing hyphens (e.g., v1.0.0-rc1) - prerelease: {% raw %}${{ contains(steps.get_tag.outputs.tag, '-') }}{% endraw %} # Checks if tag contains hyphen (e.g. v1.0.0-rc.1) + prerelease: {{"${{ contains(steps.get_tag.outputs.tag, '-') }}"}} # Checks if tag contains hyphen (e.g. v1.0.0-rc.1) diff --git a/{{cookiecutter.project_name}}/.github/workflows/test-python.yml b/{{cookiecutter.project_name}}/.github/workflows/test-python.yml index f531c77..b427918 100644 --- a/{{cookiecutter.project_name}}/.github/workflows/test-python.yml +++ b/{{cookiecutter.project_name}}/.github/workflows/test-python.yml @@ -28,8 +28,8 @@ on: jobs: test-python: - name: Run Python Tests on {% raw %}${{ matrix.os }}/{{"{{"}} matrix.python-version{{"}}"}} {% endraw %} - runs-on: {% raw %}${{ matrix.os }}{% endraw %} + name: Run Python Tests on {{"${{ matrix.os }}/${{ matrix.python-version }}"}} + runs-on: {{"${{ matrix.os }}"}} strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] @@ -45,7 +45,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: {% raw %}${{ matrix.python-version }}{% endraw %} + python-version: {{"${{ matrix.python-version }}"}} - name: Run test suite run: uvx nox -s test-python @@ -53,13 +53,13 @@ jobs: - name: Upload test reports uses: actions/upload-artifact@v4 with: - name: test-results-{% raw %}${{ matrix.os }}{% endraw %}-py{% raw %}${{ matrix.python-version }}{% endraw %} + name: {{"test-results-${{ matrix.os }}-py${{ matrix.python-version }}"}} path: test-results/*.xml retention-days: 5 - name: Upload coverage report uses: actions/upload-artifact@v4 with: - name: coverage-report-{% raw %}${{ matrix.os }}{% endraw %}-py{% raw %}${{ matrix.python-version }}{% endraw %} + name: {{"coverage-report-${{ matrix.os }}-py${{ matrix.python-version }}"}} path: coverage.xml retention-days: 5 diff --git a/{{cookiecutter.project_name}}/.github/workflows/typecheck-python.yml b/{{cookiecutter.project_name}}/.github/workflows/typecheck-python.yml index 968be08..9c61d43 100644 --- a/{{cookiecutter.project_name}}/.github/workflows/typecheck-python.yml +++ b/{{cookiecutter.project_name}}/.github/workflows/typecheck-python.yml @@ -30,8 +30,8 @@ on: jobs: typecheck-python: - name: Run Python Type Checks on {% raw %}${{ matrix.os }}/{{"{{"}} matrix.python-version{{"}}"}} {% endraw %} - runs-on: {% raw %}${{ matrix.os }}{% endraw %} + name: Run Python Type Checks on {{"${{ matrix.os }}/${{ matrix.python-version }}"}} + runs-on: {{"${{ matrix.os }}"}} strategy: matrix: python-version: ["3.9", "3.10", "3.11", "3.12", "3.13"] @@ -47,7 +47,7 @@ jobs: - name: Set up Python uses: actions/setup-python@v5 with: - python-version: {% raw %}${{ matrix.python-version }}{% endraw %} + python-version: {{"${{ matrix.python-version }}"}} - name: Run Python type checking run: uvx nox -s typecheck-python diff --git a/{{cookiecutter.project_name}}/.pre-commit-config.yaml b/{{cookiecutter.project_name}}/.pre-commit-config.yaml index eff2767..b132171 100644 --- a/{{cookiecutter.project_name}}/.pre-commit-config.yaml +++ b/{{cookiecutter.project_name}}/.pre-commit-config.yaml @@ -22,6 +22,19 @@ repos: - id: ruff args: [--fix, --exit-non-zero-on-fix, --config=.ruff.toml] + - repo: https://github.com/doublify/pre-commit-rust + rev: master + hooks: + - id: fmt + - id: clippy + args: ["--all-features", "--", "--write"] + - id: cargo-check + + - repo: https://github.com/pre-commit/mirrors-prettier + rev: v2.6.0 + hooks: + - id: prettier + - repo: https://github.com/commitizen-tools/commitizen rev: v4.8.2 hooks: diff --git a/{{cookiecutter.project_name}}/noxfile.py b/{{cookiecutter.project_name}}/noxfile.py index d9a97a4..6508621 100644 --- a/{{cookiecutter.project_name}}/noxfile.py +++ b/{{cookiecutter.project_name}}/noxfile.py @@ -23,79 +23,41 @@ ] DEFAULT_PYTHON_VERSION: str = PYTHON_VERSIONS[-1] -REPO_ROOT: Path = Path(__file__).parent +REPO_ROOT: Path = Path(__file__).parent.resolve() +SCRIPTS_FOLDER: Path = REPO_ROOT / "scripts" CRATES_FOLDER: Path = REPO_ROOT / "rust" -PACKAGE_NAME: str = "{{cookiecutter.package_name}}" - - -def activate_virtualenv_in_precommit_hooks(session: Session) -> None: - """Activate virtualenv in hooks installed by pre-commit. - - This function patches git hooks installed by pre-commit to activate the - session's virtual environment. This allows pre-commit to locate hooks in - that environment when invoked from git. - - Args: - session: The Session object. - """ - assert session.bin is not None # nosec - - # Only patch hooks containing a reference to this session's bindir. Support - # quoting rules for Python and bash, but strip the outermost quotes so we - # can detect paths within the bindir, like /python. - bindirs = [ - bindir[1:-1] if bindir[0] in "'\"" else bindir for bindir in (repr(session.bin), shlex.quote(session.bin)) - ] - - virtualenv = session.env.get("VIRTUAL_ENV") - if virtualenv is None: - return - - headers = { - # pre-commit < 2.16.0 - "python": f"""\ - import os - os.environ["VIRTUAL_ENV"] = {virtualenv!r} - os.environ["PATH"] = os.pathsep.join(( - {session.bin!r}, - os.environ.get("PATH", ""), - )) - """, - # pre-commit >= 2.16.0 - "bash": f"""\ - VIRTUAL_ENV={shlex.quote(virtualenv)} - PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" - """, - # pre-commit >= 2.17.0 on Windows forces sh shebang - "/bin/sh": f"""\ - VIRTUAL_ENV={shlex.quote(virtualenv)} - PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" - """, - } - hookdir: Path = Path(".git") / "hooks" - if not hookdir.is_dir(): - return - - for hook in hookdir.iterdir(): - if hook.name.endswith(".sample") or not hook.is_file(): - continue - - if not hook.read_bytes().startswith(b"#!"): - continue - - text: str = hook.read_text() - - if not any((Path("A") == Path("a") and bindir.lower() in text.lower()) or bindir in text for bindir in bindirs): - continue +PROJECT_NAME: str = "{{cookiecutter.project_name}}" +PACKAGE_NAME: str = "{{cookiecutter.package_name}}" +GITHUB_USER: str = "{{cookiecutter.github_user}}" + +ENV: str = "env" +STYLE: str = "style" +LINT: str = "lint" +TYPE: str = "type" +TEST: str = "test" +COVERAGE: str = "coverage" +SECURITY: str = "security" +PERF: str = "perf" +DOCS: str = "docs" +BUILD: str = "build" +RELEASE: str = "release" +MAINTENANCE: str = "maintenance" +CI: str = "ci" + + +@nox.session(python=None, name="setup-git", tags=[ENV]) +def setup_git(session: Session) -> None: + """Set up the git repo for the current project.""" + session.run( + "python", SCRIPTS_FOLDER / "setup-git.py", REPO_ROOT, "-u", GITHUB_USER, "-n", PROJECT_NAME, external=True + ) - lines: list[str] = text.splitlines() - for executable, header in headers.items(): - if executable in lines[0].lower(): - lines.insert(1, dedent(header)) - hook.write_text("\n".join(lines)) - break +@nox.session(python=None, name="setup-venv", tags=[ENV]) +def setup_venv(session: Session) -> None: + """Set up the virtual environment for the current project.""" + session.run("python", SCRIPTS_FOLDER / "setup-venv.py", REPO_ROOT, "-p", PYTHON_VERSIONS[0], external=True) @nox.session(python=DEFAULT_PYTHON_VERSION, name="pre-commit") @@ -111,28 +73,7 @@ def precommit(session: Session) -> None: activate_virtualenv_in_precommit_hooks(session) -@nox.session(python=DEFAULT_PYTHON_VERSION, name="format-python") -def format_python(session: Session) -> None: - """Run Python code formatter (Ruff format).""" - session.log("Installing formatting dependencies...") - session.install("-e", ".", "--group", "dev", "--group", "lint") - - session.log(f"Running Ruff formatter check with py{session.python}.") - # Use --check, not fix. Fixing is done by pre-commit or manual run. - session.run("ruff", "format", *session.posargs) - - -@nox.session(python=DEFAULT_PYTHON_VERSION, name="lint-python") -def lint_python(session: Session) -> None: - """Run Python code linters (Ruff check, Pydocstyle rules).""" - session.log("Installing linting dependencies...") - session.install("-e", ".", "--group", "dev", "--group", "lint") - - session.log(f"Running Ruff check with py{session.python}.") - session.run("ruff", "check", "--verbose") - - -@nox.session(python=PYTHON_VERSIONS) +@nox.session(python=PYTHON_VERSIONS, name="typecheck", tags=[TYPE]) def typecheck(session: Session) -> None: """Run static type checking (Pyright) on Python code.""" session.log("Installing type checking dependencies...") @@ -142,7 +83,7 @@ def typecheck(session: Session) -> None: session.run("pyright") -@nox.session(python=DEFAULT_PYTHON_VERSION, name="security-python") +@nox.session(python=DEFAULT_PYTHON_VERSION, name="security-python", tags=[SECURITY]) def security_python(session: Session) -> None: """Run code security checks (Bandit) on Python code.""" session.log("Installing security dependencies...") @@ -155,7 +96,17 @@ def security_python(session: Session) -> None: session.run("pip-audit") -@nox.session(python=PYTHON_VERSIONS, name="tests-python") +{% if cookiecutter.add_rust_extension == 'y' -%} +@nox.session(python=None, name="security-rust", tags=[SECURITY]) +def security_rust(session: Session) -> None: + """Run code security checks (cargo audit).""" + session.log("Installing security dependencies...") + session.run("cargo", "install", "cargo-audit", external=True) + session.run("cargo", "audit", "--all", external=True) + + +{% endif -%} +@nox.session(python=PYTHON_VERSIONS, name="tests-python", tags=[TEST]) def tests_python(session: Session) -> None: """Run the Python test suite (pytest with coverage).""" session.log("Installing test dependencies...") @@ -175,7 +126,8 @@ def tests_python(session: Session) -> None: ) -@nox.session(python=None, name="tests-rust") +{% if cookiecutter.add_rust_extension == 'y' -%} +@nox.session(python=None, name="tests-rust", tags=[TEST]) def tests_rust(session: Session) -> None: """Test the project's rust crates.""" crates: list[Path] = [cargo_toml.parent for cargo_toml in CRATES_FOLDER.glob("*/Cargo.toml")] @@ -184,7 +136,8 @@ def tests_rust(session: Session) -> None: session.run("cargo", "test", "--all-features", *crate_kwargs, external=True) -@nox.session(python=DEFAULT_PYTHON_VERSION, name="docs-build") +{% endif -%} +@nox.session(python=DEFAULT_PYTHON_VERSION, name="docs-build", tags=[DOCS, BUILD]) def docs_build(session: Session) -> None: """Build the project documentation (Sphinx).""" session.log("Installing documentation dependencies...") @@ -200,7 +153,7 @@ def docs_build(session: Session) -> None: session.run("sphinx-build", "-b", "html", "docs", str(docs_build_dir), "-W") -@nox.session(python=DEFAULT_PYTHON_VERSION, name="build-python") +@nox.session(python=DEFAULT_PYTHON_VERSION, name="build-python", tags=[BUILD]) def build_python(session: Session) -> None: """Build sdist and wheel packages (uv build).""" session.log("Installing build dependencies...") @@ -208,10 +161,10 @@ def build_python(session: Session) -> None: session.install("-e", ".", "--group", "dev") session.log(f"Building sdist and wheel packages with py{session.python}.") - {% if cookiecutter.add_rust_extension == 'y' -%} - session.run("uv", "build", "--sdist", "--wheel", "--outdir", "dist/", external=True) - {% else -%} + {% if cookiecutter.add_rust_extension == "y" -%} session.run("maturin", "develop", "--uv") + {% else -%} + session.run("uv", "build", "--sdist", "--wheel", "--outdir", "dist/", external=True) {% endif -%} session.log("Built packages in ./dist directory:") @@ -219,7 +172,7 @@ def build_python(session: Session) -> None: session.log(f"- {path.name}") -@nox.session(python=DEFAULT_PYTHON_VERSION, name="build-container") +@nox.session(python=DEFAULT_PYTHON_VERSION, name="build-container", tags=[BUILD]) def build_container(session: Session) -> None: """Build the Docker container image. @@ -250,7 +203,7 @@ def build_container(session: Session) -> None: session.log(f"Container image {project_image_name}:latest built locally.") -@nox.session(python=DEFAULT_PYTHON_VERSION, name="publish-python") +@nox.session(python=DEFAULT_PYTHON_VERSION, name="publish-python", tags=[RELEASE]) def publish_python(session: Session) -> None: """Publish sdist and wheel packages to PyPI via uv publish. @@ -266,7 +219,8 @@ def publish_python(session: Session) -> None: session.run("uv", "publish", "dist/*", external=True) -@nox.session(python=None, name="publish-rust") +{% if cookiecutter.add_rust_extension == "y" -%} +@nox.session(python=None, name="publish-rust", tags=[RELEASE]) def publish_rust(session: Session) -> None: """Publish built crates to crates.io.""" session.log("Publishing crates to crates.io") @@ -275,7 +229,8 @@ def publish_rust(session: Session) -> None: session.run("cargo", "publish", "-p", crate_folder.name) -@nox.session(venv_backend="none") +{% endif -%} +@nox.session(venv_backend="none", tags=[RELEASE]) def release(session: Session) -> None: """Run the release process using Commitizen. @@ -314,10 +269,7 @@ def release(session: Session) -> None: ) -# --- COMPATIBILITY SESSIONS --- -# Sessions needed for compatibility with other tools or ecosystems. - -@nox.session(venv_backend="none") +@nox.session(venv_backend="none", tags=[MAINTENANCE]) def tox(session: Session) -> None: """Run the 'tox' test matrix. @@ -340,49 +292,7 @@ def tox(session: Session) -> None: session.run("tox", *session.posargs) -# --- COMBINED/ORCHESTRATION SESSIONS --- -# These sessions provide easy entry points by notifying or calling granular sessions. -# Their names often align with the intended CI workflow steps (e.g., 'build' orchestrates builds). - -@nox.session(python=DEFAULT_PYTHON_VERSION) # Run the orchestrator on the default Python version -def build(session: Session) -> None: - """Orchestrates building all project artifacts (Python packages, potentially Rust).""" - session.log(f"Queueing build sessions for py{session.python} if applicable.") - # Build Rust crate first if included - {% if cookiecutter.add_rust_extension == 'y' %} - session.notify("build_rust") # Build Rust crate first if Rust is enabled - {% endif %} - # Then build the Python package (uv build) - session.notify("build-python") # Build Python sdist/wheel - - -@nox.session(python=DEFAULT_PYTHON_VERSION) # Run the orchestrator on the default Python version -def publish(session: Session) -> None: - """Orchestrates publishing all project artifacts (Python packages, potentially Rust).""" - session.log(f"Queueing publish sessions for py{session.python} if applicable.") - session.notify("publish-python") # Publish Python sdist/wheel - # Note: publish_rust session might be notified here if needed. - - -@nox.session(python=PYTHON_VERSIONS) -def check(session: Session) -> None: - """Run primary quality checks (format, lint, typecheck, security).""" - session.log(f"Queueing core check sessions for py{session.python} if applicable.") - session.notify("format-python") - session.notify("lint-python") - session.notify("typecheck") - session.notify("security-python") - - -@nox.session(python=PYTHON_VERSIONS, name="full-check") -def full_check(session: Session) -> None: - """Run all core quality checks and tests.""" - session.log(f"Queueing all check and test sessions for py{session.python} if applicable.") - session.notify("check") - session.notify("tests-python") - - -@nox.session(python=DEFAULT_PYTHON_VERSION) +@nox.session(python=DEFAULT_PYTHON_VERSION, tags=[COVERAGE]) def coverage(session: Session) -> None: """Collect and report coverage. @@ -416,3 +326,73 @@ def coverage(session: Session) -> None: session.run("coverage", "report") session.log(f"Coverage reports generated in ./{coverage_html_dir} and terminal.") + + +def activate_virtualenv_in_precommit_hooks(session: Session) -> None: + """Activate virtualenv in hooks installed by pre-commit. + + This function patches git hooks installed by pre-commit to activate the + session's virtual environment. This allows pre-commit to locate hooks in + that environment when invoked from git. + + Args: + session: The Session object. + """ + assert session.bin is not None # nosec + + # Only patch hooks containing a reference to this session's bindir. Support + # quoting rules for Python and bash, but strip the outermost quotes so we + # can detect paths within the bindir, like /python. + bindirs = [ + bindir[1:-1] if bindir[0] in "'\"" else bindir for bindir in (repr(session.bin), shlex.quote(session.bin)) + ] + + virtualenv = session.env.get("VIRTUAL_ENV") + if virtualenv is None: + return + + headers = { + # pre-commit < 2.16.0 + "python": f"""\ + import os + os.environ["VIRTUAL_ENV"] = {virtualenv!r} + os.environ["PATH"] = os.pathsep.join(( + {session.bin!r}, + os.environ.get("PATH", ""), + )) + """, + # pre-commit >= 2.16.0 + "bash": f"""\ + VIRTUAL_ENV={shlex.quote(virtualenv)} + PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" + """, + # pre-commit >= 2.17.0 on Windows forces sh shebang + "/bin/sh": f"""\ + VIRTUAL_ENV={shlex.quote(virtualenv)} + PATH={shlex.quote(session.bin)}"{os.pathsep}$PATH" + """, + } + + hookdir: Path = Path(".git") / "hooks" + if not hookdir.is_dir(): + return + + for hook in hookdir.iterdir(): + if hook.name.endswith(".sample") or not hook.is_file(): + continue + + if not hook.read_bytes().startswith(b"#!"): + continue + + text: str = hook.read_text() + + if not any((Path("A") == Path("a") and bindir.lower() in text.lower()) or bindir in text for bindir in bindirs): + continue + + lines: list[str] = text.splitlines() + + for executable, header in headers.items(): + if executable in lines[0].lower(): + lines.insert(1, dedent(header)) + hook.write_text("\n".join(lines)) + break diff --git a/{{cookiecutter.project_name}}/scripts/setup-git.py b/{{cookiecutter.project_name}}/scripts/setup-git.py new file mode 100644 index 0000000..69b0fa3 --- /dev/null +++ b/{{cookiecutter.project_name}}/scripts/setup-git.py @@ -0,0 +1,62 @@ +"""Script responsible for first time setup of the project's git repo. + +Since this a first time setup script, we intentionally only use builtin Python dependencies. +""" +import argparse +import subprocess +from pathlib import Path + +from util import check_dependencies +from util import existing_dir + + +def main() -> None: + """Parses command line input and passes it through to setup_git.""" + parser: argparse.ArgumentParser = get_parser() + args: argparse.Namespace = parser.parse_args() + setup_git(path=args.path, github_user=args.github_user, repo_name=args.repo_name) + + +def setup_git(path: Path, github_user: str, repo_name: str) -> None: + """Set up the provided cookiecutter-robust-python project's git repo.""" + commands: list[list[str]] = [ + ["git", "init"], + ["git", "branch", "-m", "master", "main"], + ["git", "checkout", "main"], + ["git", "remote", "add", "origin", f"https://github.com/{github_user}/{repo_name}.git"], + ["git", "remote", "set-url", "origin", f"https://github.com/{github_user}/{repo_name}.git"], + ["git", "fetch", "origin"], + ["git", "pull"], + ["git", "push", "-u", "origin", "main"], + ["git", "checkout", "-b", "develop", "main"], + ["git", "push", "-u", "origin", "develop"], + ["git", "add", "."], + ["git", "commit", "-m", "feat: initial commit"], + ["git", "push", "origin", "develop"] + ] + check_dependencies(path=path, dependencies=["git"]) + + for command in commands: + subprocess.run(command, cwd=path, stderr=subprocess.STDOUT) + + +def get_parser() -> argparse.ArgumentParser: + """Creates the argument parser for setup-git.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser( + prog="setup-git", + usage="python ./scripts/setup-git.py . -u 56kyle -n robust-python-demo", + description="Set up the provided cookiecutter-robust-python project's git repo.", + ) + parser.add_argument( + "path", + type=existing_dir, + metavar="PATH", + help="Path to the repo's root directory (must already exist).", + ) + parser.add_argument("-u", "--user", dest="github_user", help="GitHub user name.") + parser.add_argument("-n", "--name", dest="repo_name", help="Name of the repo.") + return parser + + +if __name__ == '__main__': + main() diff --git a/{{cookiecutter.project_name}}/scripts/setup-venv.py b/{{cookiecutter.project_name}}/scripts/setup-venv.py new file mode 100644 index 0000000..c89e167 --- /dev/null +++ b/{{cookiecutter.project_name}}/scripts/setup-venv.py @@ -0,0 +1,63 @@ +"""Script responsible for first time setup of the project's venv. + +Since this a first time setup script, we intentionally only use builtin Python dependencies. +""" +import argparse +import shutil +import subprocess +from pathlib import Path + +from util import check_dependencies +from util import existing_dir +from util import remove_readonly + + +def main() -> None: + """Parses args and passes through to setup_venv.""" + parser: argparse.ArgumentParser = get_parser() + args: argparse.Namespace = parser.parse_args() + setup_venv(path=args.path, python_version=args.python_version) + + +def get_parser() -> argparse.ArgumentParser: + """Creates the argument parser for setup-venv.""" + parser: argparse.ArgumentParser = argparse.ArgumentParser( + prog="setup-venv", + usage="python ./scripts/setup-venv.py . -p '3.9'" + ) + parser.add_argument( + "path", + type=existing_dir, + metavar="PATH", + help="Path to the repo's root directory (must already exist).", + ) + parser.add_argument( + "-p", + "--python", + dest="python_version", + help="The Python version that will serve as the main working version used by the IDE." + ) + return parser + + +def setup_venv(path: Path, python_version: str) -> None: + """Set up the provided cookiecutter-robust-python project's venv.""" + commands: list[list[str]] = [ + ["uv", "lock"], + ["uv", "venv", ".venv"], + ["uv", "python", "install", python_version], + ["uv", "python", "pin", python_version], + ["uv", "sync", "--all-groups"] + ] + check_dependencies(path=path, dependencies=["uv"]) + + venv_path: Path = path / ".venv" + if venv_path.exists(): + shutil.rmtree(venv_path, onerror=remove_readonly) + + for command in commands: + subprocess.run(command, cwd=path, capture_output=True) + + +if __name__ == '__main__': + main() diff --git a/{{cookiecutter.project_name}}/scripts/util.py b/{{cookiecutter.project_name}}/scripts/util.py new file mode 100644 index 0000000..7aee685 --- /dev/null +++ b/{{cookiecutter.project_name}}/scripts/util.py @@ -0,0 +1,47 @@ +"""Module containing util""" +import argparse +import os +import stat +import subprocess +from pathlib import Path +from typing import Any +from typing import Callable + + +class MissingDependencyError(Exception): + """Exception raised when a depedency is missing from the system running setup-repo.""" + def __init__(self, project: Path, dependency: str): + super().__init__("\n".join([ + f"Unable to find {dependency=}.", + f"Please ensure that {dependency} is installed before setting up the repo at {project.absolute()}" + ])) + + +def check_dependencies(path: Path, dependencies: list[str]) -> None: + """Checks for any passed dependencies.""" + for dependency in dependencies: + try: + subprocess.check_call([dependency, "--version"], cwd=path) + except subprocess.CalledProcessError: + raise MissingDependencyError(path, dependency) + + +def existing_dir(value: str) -> Path: + """Responsible for validating argparse inputs and returning them as pathlib Path's if they meet criteria.""" + path = Path(value).expanduser().resolve() + + if not path.exists(): + raise argparse.ArgumentTypeError(f"{path} does not exist.") + if not path.is_dir(): + raise argparse.ArgumentTypeError(f"{path} is not a directory.") + + return path + + +def remove_readonly(func: Callable[[str], Any], path: str, _: Any) -> None: + """Clears the readonly bit and attempts to call the provided function. + + This is passed to shutil.rmtree as the onerror kwarg. + """ + os.chmod(path, stat.S_IWRITE) + func(path)