diff --git a/.opencode/templates/backend/cookiecutter.json b/.opencode/templates/backend/cookiecutter.json index 313f5c2..8e16a53 100644 --- a/.opencode/templates/backend/cookiecutter.json +++ b/.opencode/templates/backend/cookiecutter.json @@ -1,5 +1,5 @@ { - "project_name": "my-project", + "project_name": "my_project", "project_type": "backend", "description": "Project description", "use_auth": ["no", "yes"], diff --git a/.opencode/templates/backend/hooks/pre_gen_project.py b/.opencode/templates/backend/hooks/pre_gen_project.py new file mode 100644 index 0000000..05edf89 --- /dev/null +++ b/.opencode/templates/backend/hooks/pre_gen_project.py @@ -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 .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() \ No newline at end of file diff --git a/.opencode/templates/cli/cookiecutter.json b/.opencode/templates/cli/cookiecutter.json index ac8dbe2..02b0160 100644 --- a/.opencode/templates/cli/cookiecutter.json +++ b/.opencode/templates/cli/cookiecutter.json @@ -1,5 +1,5 @@ { - "project_name": "my-cli", + "project_name": "my_cli", "project_type": "cli", "description": "CLI tool description", "use_auth": ["no", "yes"], diff --git a/.opencode/templates/cli/hooks/pre_gen_project.py b/.opencode/templates/cli/hooks/pre_gen_project.py new file mode 100644 index 0000000..3bcdfa9 --- /dev/null +++ b/.opencode/templates/cli/hooks/pre_gen_project.py @@ -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 .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() \ No newline at end of file diff --git a/.opencode/templates/fullstack/cookiecutter.json b/.opencode/templates/fullstack/cookiecutter.json index ad8f1e8..2702b94 100644 --- a/.opencode/templates/fullstack/cookiecutter.json +++ b/.opencode/templates/fullstack/cookiecutter.json @@ -1,5 +1,5 @@ { - "project_name": "my-fullstack", + "project_name": "my_fullstack", "project_type": "fullstack", "description": "Fullstack project description", "use_auth": ["no", "yes"], diff --git a/.opencode/templates/fullstack/hooks/pre_gen_project.py b/.opencode/templates/fullstack/hooks/pre_gen_project.py new file mode 100644 index 0000000..7e4583b --- /dev/null +++ b/.opencode/templates/fullstack/hooks/pre_gen_project.py @@ -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 .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() \ No newline at end of file diff --git a/tests/test_cookiecutter_templates.py b/tests/test_cookiecutter_templates.py index 6d98646..0ed94a1 100644 --- a/tests/test_cookiecutter_templates.py +++ b/tests/test_cookiecutter_templates.py @@ -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()