fix(templates): validate cookiecutter project_name as python identifier (#263)

* fix(templates): add pre_gen_project hook validating project_name identifier

* fix(templates): use valid identifier defaults in cookiecutter.json

* test(templates): add regression tests for project_name identifier validation

* fix(tests): resolve fullstack package name via backend ctx

---------

Co-authored-by: opencode-agent <agent@opencode.local>
This commit is contained in:
Sergey 2026-08-04 14:36:33 +03:00 committed by GitHub
parent 06899b3c01
commit e4f9313641
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 253 additions and 7 deletions

View file

@ -1,5 +1,5 @@
{
"project_name": "my-project",
"project_name": "my_project",
"project_type": "backend",
"description": "Project description",
"use_auth": ["no", "yes"],

View file

@ -0,0 +1,35 @@
"""Pre-generation hook for the backend cookiecutter template.
Validates that ``project_name`` is a valid Python identifier so the
generated package dir + imports (``from <project_name>.X import Y``) do
not raise ``SyntaxError``. Hyphens, dots, spaces and leading digits are
rejected with a hint to use an underscore-separated name instead.
See issue #262: default ``my-project`` used to render as
``from my-project.config.settings import settings`` which is a
``SyntaxError`` (``-`` is not allowed in a Python identifier).
"""
from __future__ import annotations
import re
import sys
_VALID_IDENTIFIER = re.compile(r"^[a-z][a-z0-9_]*$")
def main() -> None:
project_name = "{{ cookiecutter.project_name }}"
if not _VALID_IDENTIFIER.fullmatch(project_name):
sys.exit(
f"Invalid project_name: {project_name!r}\n"
"project_name must be a valid Python identifier matching "
"^[a-z][a-z0-9_]*$ (lowercase, no hyphens/dots/spaces, "
"no leading digit).\n"
f"Use 'my_project' instead of 'my-project' (or 'my.project', "
"'my project')."
)
if __name__ == "__main__":
main()

View file

@ -1,5 +1,5 @@
{
"project_name": "my-cli",
"project_name": "my_cli",
"project_type": "cli",
"description": "CLI tool description",
"use_auth": ["no", "yes"],

View file

@ -0,0 +1,34 @@
"""Pre-generation hook for the cli cookiecutter template.
Validates that ``project_name`` is a valid Python identifier so the
generated package dir + imports (``from <project_name>.X import Y``) do
not raise ``SyntaxError``. Hyphens, dots, spaces and leading digits are
rejected with a hint to use an underscore-separated name instead.
See issue #262: default ``my-cli`` used to render as
``from my-cli.core import app`` which is a ``SyntaxError`` (``-`` is not
allowed in a Python identifier).
"""
from __future__ import annotations
import re
import sys
_VALID_IDENTIFIER = re.compile(r"^[a-z][a-z0-9_]*$")
def main() -> None:
project_name = "{{ cookiecutter.project_name }}"
if not _VALID_IDENTIFIER.fullmatch(project_name):
sys.exit(
f"Invalid project_name: {project_name!r}\n"
"project_name must be a valid Python identifier matching "
"^[a-z][a-z0-9_]*$ (lowercase, no hyphens/dots/spaces, "
"no leading digit).\n"
f"Use 'my_cli' instead of 'my-cli' (or 'my.cli', 'my cli')."
)
if __name__ == "__main__":
main()

View file

@ -1,5 +1,5 @@
{
"project_name": "my-fullstack",
"project_name": "my_fullstack",
"project_type": "fullstack",
"description": "Fullstack project description",
"use_auth": ["no", "yes"],

View file

@ -0,0 +1,35 @@
"""Pre-generation hook for the fullstack cookiecutter template.
Validates that ``project_name`` is a valid Python identifier so the
generated package dir + imports (``from <project_name>.X import Y``) do
not raise ``SyntaxError``. Hyphens, dots, spaces and leading digits are
rejected with a hint to use an underscore-separated name instead.
See issue #262: default ``my-fullstack`` used to render as
``from my-fullstack.config.settings import settings`` which is a
``SyntaxError`` (``-`` is not allowed in a Python identifier).
"""
from __future__ import annotations
import re
import sys
_VALID_IDENTIFIER = re.compile(r"^[a-z][a-z0-9_]*$")
def main() -> None:
project_name = "{{ cookiecutter.project_name }}"
if not _VALID_IDENTIFIER.fullmatch(project_name):
sys.exit(
f"Invalid project_name: {project_name!r}\n"
"project_name must be a valid Python identifier matching "
"^[a-z][a-z0-9_]*$ (lowercase, no hyphens/dots/spaces, "
"no leading digit).\n"
f"Use 'my_fullstack' instead of 'my-fullstack' (or 'my.fullstack', "
"'my fullstack')."
)
if __name__ == "__main__":
main()

View file

@ -14,12 +14,15 @@ Covers:
from __future__ import annotations
import ast
import importlib.util
import json
import re
import sys
from pathlib import Path
import pytest
from cookiecutter.exceptions import FailedHookException
from cookiecutter.main import cookiecutter
REPO_ROOT = Path(__file__).resolve().parent.parent
@ -53,15 +56,15 @@ def render(tmp_path, template_name, extra_context):
@pytest.mark.parametrize(
"template_name, extra_context",
[
("backend", {"project_name": "demo-be"}),
("cli", {"project_name": "demo-cli"}),
("fullstack", {"project_name": "demo-fs"}),
("backend", {"project_name": "demo_be"}),
("cli", {"project_name": "demo_cli"}),
("fullstack", {"project_name": "demo_fs"}),
],
)
def test_template_dir_exists(render):
"""Each template renders to a project dir of the given name."""
assert render.is_dir()
assert render.name in {"demo-be", "demo-cli", "demo-fs"}
assert render.name in {"demo_be", "demo_cli", "demo_fs"}
@pytest.mark.parametrize("template_name", ["backend", "cli", "fullstack"])
@ -824,3 +827,142 @@ def test_main_imports_setup_logging_unconditionally(render):
# ``setup_logging()`` call without a preceding import in the same file.
import_lines = [ln for ln in main.splitlines() if "import" in ln and "setup_logging" in ln]
assert import_lines, "setup_logging not imported in main.py"
# ── issue #262: project_name must be a valid Python identifier ──────────────
# Regex mirrored in every ``pre_gen_project.py`` hook (kept here as a single
# source of truth for the test, validated against the actual hook files).
_VALID_PROJECT_NAME = re.compile(r"^[a-z][a-z0-9_]*$")
@pytest.mark.parametrize(
"template_name, bad_name",
[
("backend", "my-project"), # hyphen
("backend", "my.project"), # dot
("backend", "my project"), # space
("backend", "My_Project"), # uppercase
("backend", "123abc"), # leading digit
("fullstack", "my-fullstack"),
("fullstack", "My_Fullstack"),
("cli", "my-cli"),
("cli", "123cli"),
],
)
def test_pre_gen_hook_rejects_invalid_project_name(tmp_path, template_name, bad_name):
"""pre_gen_project.py exits non-zero for invalid project_name.
Regression guard for issue #262: hyphens/dots/spaces/uppercase/leading
digits in ``project_name`` would render as ``from my-project.X import Y``
which is a ``SyntaxError`` in the generated project. The hook must abort
cookiecutter with ``FailedHookException`` BEFORE any file is rendered.
"""
with pytest.raises(FailedHookException):
cookiecutter(
template=str(TEMPLATES_DIR / template_name),
no_input=True,
output_dir=str(tmp_path),
extra_context={"project_name": bad_name},
)
# nothing rendered
assert not (tmp_path / bad_name).exists()
@pytest.mark.parametrize(
"template_name, good_name",
[
("backend", "my_project"),
("fullstack", "my_fullstack"),
("cli", "my_cli"),
("backend", "be"),
("cli", "cl"),
("fullstack", "fs"),
],
)
def test_pre_gen_hook_accepts_valid_project_name(tmp_path, template_name, good_name):
"""Valid identifiers pass the hook and render a project."""
out = cookiecutter(
template=str(TEMPLATES_DIR / template_name),
no_input=True,
output_dir=str(tmp_path),
extra_context={"project_name": good_name},
)
assert Path(out).is_dir()
@pytest.mark.parametrize("template_name", ["backend", "cli", "fullstack"])
def test_cookiecutter_json_default_is_valid_identifier(template_name):
"""The default ``project_name`` in cookiecutter.json passes the hook regex.
Issue #262: defaults must NOT contain hyphens (``my-project``) — they
are used as-is in paths/imports and would break the no-input flow.
"""
cfg = json.loads((TEMPLATES_DIR / template_name / "cookiecutter.json").read_text())
default = cfg["project_name"]
assert _VALID_PROJECT_NAME.fullmatch(default), (
f"default project_name {default!r} in {template_name}/cookiecutter.json "
f"is not a valid Python identifier (^[a-z][a-z0-9_]*$)"
)
@pytest.mark.parametrize(
"template_name, extra_context",
[
("backend", {"project_name": "demo_be", "use_db": "yes", "use_auth": "yes"}),
("cli", {"project_name": "demo_cli"}),
("fullstack", {"project_name": "demo_fs", "use_db": "yes", "use_auth": "yes"}),
],
)
def test_generated_imports_are_parseable(render, template_name, extra_context):
"""Every generated ``.py`` parses with ``ast.parse`` (no SyntaxError).
Issue #262: hyphens in ``project_name`` produced ``from my-project.X
import Y`` which is a ``SyntaxError``. With the pre_gen hook the name
is already a valid identifier, so all generated imports must parse.
"""
pkg = extra_context["project_name"]
offenders: list[str] = []
for py in render.rglob("*.py"):
text = py.read_text()
try:
ast.parse(text)
except SyntaxError as exc:
offenders.append(f"{py.relative_to(render)}: {exc}")
assert not offenders, f"SyntaxError in generated files (pkg={pkg}):\n" + "\n".join(offenders)
@pytest.mark.parametrize(
"template_name, extra_context",
[
("backend", {"project_name": "demo_be"}),
("cli", {"project_name": "demo_cli"}),
("fullstack", {"project_name": "demo_fs"}),
],
)
def test_resolve_package_name_matches_directory(render, template_name, extra_context):
"""``_resolve_package_name`` from project-status matches the package dir.
Issue #262: with a valid identifier ``project_name``, the directory name
equals the package name, and ``_resolve_package_name`` (which normalizes
``[project].name`` via PEP 503) returns the same value. This is the
contract that ``project-status`` relies on to detect BACKEND/FULLSTACK/
CLI without false FAIL/WARN.
"""
pkg = extra_context["project_name"]
ctx = ps.RepoCtx(root=render, config=ps.load_config(render))
# fullstack: pyproject.toml lives under ``backend/`` (see _api_dirs_for
# in project-status.py), so resolve against a backend-rooted context.
if template_name == "fullstack":
ctx = ps.RepoCtx(root=render / "backend", config=ps.load_config(render / "backend"))
resolved = ps._resolve_package_name(ctx)
assert resolved is not None, "_resolve_package_name returned None"
assert resolved == pkg, (
f"_resolve_package_name()={resolved!r} != directory name {pkg!r}; "
f"project-status would look for src/{resolved}/ but the real dir is src/{pkg}/"
)
# fullstack: the package dir lives under backend/src/
if template_name == "fullstack":
assert (render / "backend" / "src" / pkg).is_dir()
else:
assert (render / "src" / pkg).is_dir()