Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
1b73640
refactor: move activate_virtualenv_in_precommit_hooks to the end of t…
56kyle May 26, 2025
c1f3224
feat: ensure rust session aren't created if not using maturin and add…
56kyle May 27, 2025
0dc51bf
chore: update jinja templating to hopefully match proper syntax in re…
56kyle May 28, 2025
973bd8d
build: add setup-venv as a nox session and expand upon the setup-git …
56kyle May 28, 2025
3d0f341
build: add a `uv lock` step to the setup-venv nox session
56kyle May 28, 2025
2304db3
fix: swap maturin and uv build locations in the build-python nox session
56kyle May 29, 2025
481418d
feat: expand and refactor integration tests for the template
56kyle May 29, 2025
0181f59
chore: remove no longer used constants from noxfile.py
56kyle May 29, 2025
dbf8313
feat: add new scripts in scripts folder and move some of the initial …
56kyle May 29, 2025
70a7bff
build: add the ability to specify where generated demos are stored an…
56kyle May 29, 2025
30183ef
chore: improve docstrings for the generated project's scripts
56kyle May 29, 2025
00f4f26
chore: small formatting tweak
56kyle May 29, 2025
146fb5a
chore: add docstring to setup-venv nox session
56kyle May 29, 2025
0850778
chore: add docstrings to the templates noxfile.py
56kyle May 29, 2025
74961f9
fix: use os.getenv to get environment variable in the template noxfil…
56kyle May 29, 2025
c3f07bb
fix: add a Path wrapper around PROJECT_DEMOS_FOLDER in noxfile.py
56kyle May 29, 2025
32f3bef
fix: adjust generate-demo-project.py to only remove a demo of the sam…
56kyle May 29, 2025
ee877a0
feat: add a bunch of safety logic to generate-demo-project.py to avoi…
56kyle May 29, 2025
1e2e6d9
fix: adjust syntax in setup-git.py
56kyle May 29, 2025
acb33ef
feat: change generate-demo-project.py to regenerate demos on top of e…
56kyle May 29, 2025
ea3ce44
build: attempt at fixing the jinja templating in release-python.yml
56kyle May 29, 2025
53475bc
build: remove unneeded python venvs for sessions that just notify oth…
56kyle May 30, 2025
4636893
build: small fixes in build process and tests
56kyle May 31, 2025
6b6f2e0
feat: test out using tags for nox sessions
56kyle Jun 3, 2025
71ba308
fix: adjust test sessions to account for noxfile changes
56kyle Jun 3, 2025
5718ac5
feat: improve git setup logic
56kyle Jun 3, 2025
dfbe1b3
fix: adjust some jinja escaping to properly work in github action wor…
56kyle Jun 3, 2025
db221d5
feat: add prettier to the .pre-commit-config.yaml
56kyle Jun 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
59 changes: 30 additions & 29 deletions noxfile.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""Noxfile for the cookiecutter-robust-python template."""

import os
import shutil
import tempfile
from pathlib import Path
Expand All @@ -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}}"


Expand All @@ -26,89 +27,78 @@
)
).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,
)


@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()
Expand Down Expand Up @@ -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."""
Expand Down
43 changes: 35 additions & 8 deletions scripts/generate-demo-project.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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,
Expand All @@ -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()
Expand All @@ -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)
Expand Down
44 changes: 15 additions & 29 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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)
9 changes: 5 additions & 4 deletions tests/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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",
Expand All @@ -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"]
39 changes: 36 additions & 3 deletions tests/integration_tests/test_robust_python_demo.py
Original file line number Diff line number Diff line change
Expand Up @@ -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



Loading