Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
43 changes: 42 additions & 1 deletion framework/cli/simple_module_cli/case.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
9 changes: 9 additions & 0 deletions framework/cli/simple_module_cli/new.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
15 changes: 12 additions & 3 deletions framework/cli/simple_module_cli/scaffolding.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]

Expand Down Expand Up @@ -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
Expand All @@ -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,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "{{HOST_NAME}}-client-app",
"name": "{{HOST_PYPI_NAME}}-client-app",
"private": true,
"type": "module",
"scripts": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[project]
name = "{{HOST_NAME}}"
name = "{{HOST_PYPI_NAME}}"
version = "0.1.0"
description = "SimpleModule host application"
requires-python = ">=3.12"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"name": "{{HOST_NAME}}",
"name": "{{HOST_PYPI_NAME}}",
"private": true,
"type": "module",
"workspaces": [
Expand Down
Original file line number Diff line number Diff line change
@@ -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"
Expand Down
89 changes: 89 additions & 0 deletions framework/cli/tests/test_cli_new_regressions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Loading