From 8aed074c9a7f78b81969896bda9b6ff43afff132 Mon Sep 17 00:00:00 2001 From: Anto Subash Date: Tue, 12 May 2026 23:39:31 +0200 Subject: [PATCH] fix(cli): keep user's name form in scaffold READMEs (#139) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `smpy new simple_module_chat` created the directory with underscores but produced a README header and tree diagram using `simple-module-chat` (hyphens), because `create_workspace` substituted `{{HOST_NAME}}` with the PyPI-normalized form everywhere. Split the template placeholder: `{{HOST_NAME}}` is now the user-typed display form (used in README heading + tree); `{{HOST_PYPI_NAME}}` is the kebab-case PEP 503 form (used in `pyproject.toml` and `package.json` `name` fields). `smpy new` validates the input up front — rejecting mixed case, leading digits, and mixed `_`/`-` separators that have no canonical display form — and prints a one-line "Normalizing PyPI name to ..." notice when the two forms diverge. --- framework/cli/simple_module_cli/case.py | 43 ++++++++- framework/cli/simple_module_cli/new.py | 9 ++ .../cli/simple_module_cli/scaffolding.py | 15 +++- .../host/client_app/package.json.tpl | 2 +- .../templates/host/pyproject.toml.tpl | 2 +- .../templates/workspace/package.json.tpl | 2 +- .../templates/workspace/pyproject.toml.tpl | 2 +- .../cli/tests/test_cli_new_regressions.py | 89 +++++++++++++++++++ 8 files changed, 156 insertions(+), 8 deletions(-) diff --git a/framework/cli/simple_module_cli/case.py b/framework/cli/simple_module_cli/case.py index a5d5a38c..fba081a6 100644 --- a/framework/cli/simple_module_cli/case.py +++ b/framework/cli/simple_module_cli/case.py @@ -9,7 +9,24 @@ import re -__all__ = ["to_kebab_case", "to_pascal_case", "to_snake_case"] +__all__ = [ + "InvalidScaffoldNameError", + "to_kebab_case", + "to_pascal_case", + "to_snake_case", + "validate_scaffold_name", +] + + +class InvalidScaffoldNameError(ValueError): + """Raised when a user-supplied scaffold name can't be canonicalized. + + Names that mix case, lead with a digit, contain spaces or non + ``[a-z0-9_-]`` characters, or mix ``_`` and ``-`` separators within + the same identifier are ambiguous: the scaffolder would have to guess + a canonical form, and the directory name would diverge from the + READMEs that reference it. Reject up front instead. + """ def to_snake_case(name: str) -> str: @@ -36,3 +53,27 @@ def to_pascal_case(name: str) -> str: """'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta).""" snake = to_snake_case(name) return "".join(part.capitalize() for part in snake.split("_") if part) + + +_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$") + + +def validate_scaffold_name(name: str) -> str: + """Reject ambiguous host/module names; return the canonical display form. + + A valid name is lowercase alphanumerics with at most one separator + style — either ``_`` or ``-``, not both. Examples: + + * ``simple_module_chat`` -> ``simple_module_chat`` + * ``simple-module-chat`` -> ``simple-module-chat`` + * ``MyApp`` -> rejected (mixed case is ambiguous: ``my-app`` or ``my_app``?) + * ``1chat`` -> rejected (must start with a letter) + * ``foo_bar-baz`` -> rejected (mixed separators leave no canonical form) + """ + if not name or not _VALID_NAME_RE.match(name) or ("_" in name and "-" in name): + raise InvalidScaffoldNameError( + f"{name!r} is not a valid scaffold name. Use lowercase letters and " + "digits with at most one separator style (all '_' or all '-'), " + "starting with a letter. e.g. 'my_app', 'my-app', or 'myapp'." + ) + return name diff --git a/framework/cli/simple_module_cli/new.py b/framework/cli/simple_module_cli/new.py index 16b99195..b9ff336a 100644 --- a/framework/cli/simple_module_cli/new.py +++ b/framework/cli/simple_module_cli/new.py @@ -11,6 +11,7 @@ import typer from simple_module_cli.app_project import create_app_project +from simple_module_cli.case import InvalidScaffoldNameError, to_kebab_case, validate_scaffold_name from simple_module_cli.catalog import PRESETS, expand_deps from simple_module_cli.wizard import run_wizard @@ -76,6 +77,14 @@ def new_project( ] = False, ) -> None: """Scaffold a new SimpleModule app, optionally with background jobs.""" + try: + validate_scaffold_name(name) + except InvalidScaffoldNameError as exc: + typer.echo(f"ERROR: {exc}", err=True) + raise typer.Exit(code=1) from exc + pypi_name = to_kebab_case(name) + if pypi_name != name: + typer.echo(f"Normalizing PyPI name to {pypi_name!r}.") target = dest or Path.cwd() / name extra_list = [m.strip() for m in extra.split(",") if m.strip()] flag_driven = preset is not None or bool(extra_list) diff --git a/framework/cli/simple_module_cli/scaffolding.py b/framework/cli/simple_module_cli/scaffolding.py index 17049924..17e27810 100644 --- a/framework/cli/simple_module_cli/scaffolding.py +++ b/framework/cli/simple_module_cli/scaffolding.py @@ -21,7 +21,12 @@ from collections.abc import Mapping, Sequence from pathlib import Path -from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case +from simple_module_cli.case import ( + to_kebab_case, + to_pascal_case, + to_snake_case, + validate_scaffold_name, +) __all__ = ["create_host", "create_module", "create_workspace"] @@ -100,7 +105,10 @@ def create_workspace( _apply_template_files( _resolve_template_root("workspace", template_root), dest, - {"{{HOST_NAME}}": to_kebab_case(name)}, + { + "{{HOST_NAME}}": validate_scaffold_name(name), + "{{HOST_PYPI_NAME}}": to_kebab_case(name), + }, ) logger.info("Scaffolded workspace root at %s", dest) return dest @@ -120,7 +128,8 @@ def create_host( _resolve_template_root("host", template_root), dest, { - "{{HOST_NAME}}": name, + "{{HOST_NAME}}": validate_scaffold_name(name), + "{{HOST_PYPI_NAME}}": to_kebab_case(name), "{{MODULE_DEPS}}": module_dep_lines, "{{FRAMEWORK_VERSION}}": framework_version, }, diff --git a/framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl b/framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl index 8e32527d..45606998 100644 --- a/framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl +++ b/framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl @@ -1,5 +1,5 @@ { - "name": "{{HOST_NAME}}-client-app", + "name": "{{HOST_PYPI_NAME}}-client-app", "private": true, "type": "module", "scripts": { diff --git a/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl index c1282d99..5eefebdb 100644 --- a/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl +++ b/framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl @@ -1,5 +1,5 @@ [project] -name = "{{HOST_NAME}}" +name = "{{HOST_PYPI_NAME}}" version = "0.1.0" description = "SimpleModule host application" requires-python = ">=3.12" diff --git a/framework/cli/simple_module_cli/templates/workspace/package.json.tpl b/framework/cli/simple_module_cli/templates/workspace/package.json.tpl index 2614b280..6f313560 100644 --- a/framework/cli/simple_module_cli/templates/workspace/package.json.tpl +++ b/framework/cli/simple_module_cli/templates/workspace/package.json.tpl @@ -1,5 +1,5 @@ { - "name": "{{HOST_NAME}}", + "name": "{{HOST_PYPI_NAME}}", "private": true, "type": "module", "workspaces": [ diff --git a/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl b/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl index f35ab0f1..2010d858 100644 --- a/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl +++ b/framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl @@ -1,5 +1,5 @@ [project] -name = "{{HOST_NAME}}" +name = "{{HOST_PYPI_NAME}}" version = "0.1.0" description = "SimpleModule application workspace root" requires-python = ">=3.12" diff --git a/framework/cli/tests/test_cli_new_regressions.py b/framework/cli/tests/test_cli_new_regressions.py index c0c7376d..588ae4f9 100644 --- a/framework/cli/tests/test_cli_new_regressions.py +++ b/framework/cli/tests/test_cli_new_regressions.py @@ -118,3 +118,92 @@ def test_sm_new_registers_landing_route_at_root(tmp_path: Path) -> None: landing_tsx = target / "host" / "client_app" / "pages" / "Landing.tsx" assert landing_tsx.is_file(), "Landing.tsx must ship in the host's pages dir" + + +def test_sm_new_underscored_name_keeps_user_form_in_readme(tmp_path: Path) -> None: + """Issue #139: when the user types underscores, the README, tree diagram, + and directory name must agree. Only the PyPI fields normalize to hyphens.""" + runner = CliRunner() + target = tmp_path / "simple_module_chat" + result = runner.invoke( + app, + ["new", "simple_module_chat", "--yes", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + readme = (target / "README.md").read_text() + assert "# simple_module_chat" in readme, "README heading should match the user's input" + assert "simple_module_chat/" in readme, "tree diagram should match the directory name" + assert "simple-module-chat" not in readme, "no hyphenated form should leak into the README" + + +def test_sm_new_underscored_name_pypi_fields_normalize(tmp_path: Path) -> None: + """Issue #139: pyproject + package.json still use the PEP 503 hyphenated form.""" + runner = CliRunner() + target = tmp_path / "simple_module_chat" + runner.invoke( + app, + ["new", "simple_module_chat", "--yes", "--no-install", "--dest", str(target)], + ) + workspace_pyproject = (target / "pyproject.toml").read_text() + assert 'name = "simple-module-chat"' in workspace_pyproject + workspace_pkg = json.loads((target / "package.json").read_text()) + assert workspace_pkg["name"] == "simple-module-chat" + + +def test_sm_new_underscored_name_warns_about_normalization(tmp_path: Path) -> None: + """Issue #139: when the PyPI form diverges from user input, the CLI says so.""" + runner = CliRunner() + target = tmp_path / "simple_module_chat" + result = runner.invoke( + app, + ["new", "simple_module_chat", "--yes", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + assert "simple-module-chat" in result.output, "expected normalization notice in output" + + +def test_sm_new_hyphenated_name_stays_consistent(tmp_path: Path) -> None: + """Issue #139: a clean kebab-case input gives one form everywhere — no warning.""" + runner = CliRunner() + target = tmp_path / "simple-module-chat" + result = runner.invoke( + app, + ["new", "simple-module-chat", "--yes", "--no-install", "--dest", str(target)], + ) + assert result.exit_code == 0, result.output + assert "Normalizing PyPI name" not in result.output + readme = (target / "README.md").read_text() + assert "# simple-module-chat" in readme + workspace_pyproject = (target / "pyproject.toml").read_text() + assert 'name = "simple-module-chat"' in workspace_pyproject + + +def test_sm_new_rejects_mixed_case_name(tmp_path: Path) -> None: + """Issue #139: mixed case is ambiguous (my-app vs my_app) — refuse, don't guess.""" + runner = CliRunner() + result = runner.invoke( + app, + ["new", "MyApp", "--yes", "--no-install", "--dest", str(tmp_path / "out")], + ) + assert result.exit_code != 0 + assert "MyApp" in result.output or "valid" in result.output.lower() + + +def test_sm_new_rejects_leading_digit_name(tmp_path: Path) -> None: + """Issue #139: names starting with a digit aren't valid Python package names.""" + runner = CliRunner() + result = runner.invoke( + app, + ["new", "1chat", "--yes", "--no-install", "--dest", str(tmp_path / "out")], + ) + assert result.exit_code != 0 + + +def test_sm_new_rejects_mixed_separators(tmp_path: Path) -> None: + """Issue #139: foo_bar-baz mixes separators — no canonical form, refuse.""" + runner = CliRunner() + result = runner.invoke( + app, + ["new", "foo_bar-baz", "--yes", "--no-install", "--dest", str(tmp_path / "out")], + ) + assert result.exit_code != 0