Skip to content

Commit 22ab7a3

Browse files
authored
fix(cli): keep user's name form in scaffold READMEs (#143)
`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.
1 parent ef2a54e commit 22ab7a3

8 files changed

Lines changed: 156 additions & 8 deletions

File tree

‎framework/cli/simple_module_cli/case.py‎

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,24 @@
99

1010
import re
1111

12-
__all__ = ["to_kebab_case", "to_pascal_case", "to_snake_case"]
12+
__all__ = [
13+
"InvalidScaffoldNameError",
14+
"to_kebab_case",
15+
"to_pascal_case",
16+
"to_snake_case",
17+
"validate_scaffold_name",
18+
]
19+
20+
21+
class InvalidScaffoldNameError(ValueError):
22+
"""Raised when a user-supplied scaffold name can't be canonicalized.
23+
24+
Names that mix case, lead with a digit, contain spaces or non
25+
``[a-z0-9_-]`` characters, or mix ``_`` and ``-`` separators within
26+
the same identifier are ambiguous: the scaffolder would have to guess
27+
a canonical form, and the directory name would diverge from the
28+
READMEs that reference it. Reject up front instead.
29+
"""
1330

1431

1532
def to_snake_case(name: str) -> str:
@@ -36,3 +53,27 @@ def to_pascal_case(name: str) -> str:
3653
"""'my-feature' / 'my_feature' -> 'MyFeature' (the display name in Meta)."""
3754
snake = to_snake_case(name)
3855
return "".join(part.capitalize() for part in snake.split("_") if part)
56+
57+
58+
_VALID_NAME_RE = re.compile(r"^[a-z][a-z0-9]*(?:[_-][a-z0-9]+)*$")
59+
60+
61+
def validate_scaffold_name(name: str) -> str:
62+
"""Reject ambiguous host/module names; return the canonical display form.
63+
64+
A valid name is lowercase alphanumerics with at most one separator
65+
style — either ``_`` or ``-``, not both. Examples:
66+
67+
* ``simple_module_chat`` -> ``simple_module_chat``
68+
* ``simple-module-chat`` -> ``simple-module-chat``
69+
* ``MyApp`` -> rejected (mixed case is ambiguous: ``my-app`` or ``my_app``?)
70+
* ``1chat`` -> rejected (must start with a letter)
71+
* ``foo_bar-baz`` -> rejected (mixed separators leave no canonical form)
72+
"""
73+
if not name or not _VALID_NAME_RE.match(name) or ("_" in name and "-" in name):
74+
raise InvalidScaffoldNameError(
75+
f"{name!r} is not a valid scaffold name. Use lowercase letters and "
76+
"digits with at most one separator style (all '_' or all '-'), "
77+
"starting with a letter. e.g. 'my_app', 'my-app', or 'myapp'."
78+
)
79+
return name

‎framework/cli/simple_module_cli/new.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import typer
1212

1313
from simple_module_cli.app_project import create_app_project
14+
from simple_module_cli.case import InvalidScaffoldNameError, to_kebab_case, validate_scaffold_name
1415
from simple_module_cli.catalog import PRESETS, expand_deps
1516
from simple_module_cli.wizard import run_wizard
1617

@@ -76,6 +77,14 @@ def new_project(
7677
] = False,
7778
) -> None:
7879
"""Scaffold a new SimpleModule app, optionally with background jobs."""
80+
try:
81+
validate_scaffold_name(name)
82+
except InvalidScaffoldNameError as exc:
83+
typer.echo(f"ERROR: {exc}", err=True)
84+
raise typer.Exit(code=1) from exc
85+
pypi_name = to_kebab_case(name)
86+
if pypi_name != name:
87+
typer.echo(f"Normalizing PyPI name to {pypi_name!r}.")
7988
target = dest or Path.cwd() / name
8089
extra_list = [m.strip() for m in extra.split(",") if m.strip()]
8190
flag_driven = preset is not None or bool(extra_list)

‎framework/cli/simple_module_cli/scaffolding.py‎

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,12 @@
2121
from collections.abc import Mapping, Sequence
2222
from pathlib import Path
2323

24-
from simple_module_cli.case import to_kebab_case, to_pascal_case, to_snake_case
24+
from simple_module_cli.case import (
25+
to_kebab_case,
26+
to_pascal_case,
27+
to_snake_case,
28+
validate_scaffold_name,
29+
)
2530

2631
__all__ = ["create_host", "create_module", "create_workspace"]
2732

@@ -100,7 +105,10 @@ def create_workspace(
100105
_apply_template_files(
101106
_resolve_template_root("workspace", template_root),
102107
dest,
103-
{"{{HOST_NAME}}": to_kebab_case(name)},
108+
{
109+
"{{HOST_NAME}}": validate_scaffold_name(name),
110+
"{{HOST_PYPI_NAME}}": to_kebab_case(name),
111+
},
104112
)
105113
logger.info("Scaffolded workspace root at %s", dest)
106114
return dest
@@ -120,7 +128,8 @@ def create_host(
120128
_resolve_template_root("host", template_root),
121129
dest,
122130
{
123-
"{{HOST_NAME}}": name,
131+
"{{HOST_NAME}}": validate_scaffold_name(name),
132+
"{{HOST_PYPI_NAME}}": to_kebab_case(name),
124133
"{{MODULE_DEPS}}": module_dep_lines,
125134
"{{FRAMEWORK_VERSION}}": framework_version,
126135
},

‎framework/cli/simple_module_cli/templates/host/client_app/package.json.tpl‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "{{HOST_NAME}}-client-app",
2+
"name": "{{HOST_PYPI_NAME}}-client-app",
33
"private": true,
44
"type": "module",
55
"scripts": {

‎framework/cli/simple_module_cli/templates/host/pyproject.toml.tpl‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[project]
2-
name = "{{HOST_NAME}}"
2+
name = "{{HOST_PYPI_NAME}}"
33
version = "0.1.0"
44
description = "SimpleModule host application"
55
requires-python = ">=3.12"

‎framework/cli/simple_module_cli/templates/workspace/package.json.tpl‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"name": "{{HOST_NAME}}",
2+
"name": "{{HOST_PYPI_NAME}}",
33
"private": true,
44
"type": "module",
55
"workspaces": [

‎framework/cli/simple_module_cli/templates/workspace/pyproject.toml.tpl‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
[project]
2-
name = "{{HOST_NAME}}"
2+
name = "{{HOST_PYPI_NAME}}"
33
version = "0.1.0"
44
description = "SimpleModule application workspace root"
55
requires-python = ">=3.12"

‎framework/cli/tests/test_cli_new_regressions.py‎

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -118,3 +118,92 @@ def test_sm_new_registers_landing_route_at_root(tmp_path: Path) -> None:
118118

119119
landing_tsx = target / "host" / "client_app" / "pages" / "Landing.tsx"
120120
assert landing_tsx.is_file(), "Landing.tsx must ship in the host's pages dir"
121+
122+
123+
def test_sm_new_underscored_name_keeps_user_form_in_readme(tmp_path: Path) -> None:
124+
"""Issue #139: when the user types underscores, the README, tree diagram,
125+
and directory name must agree. Only the PyPI fields normalize to hyphens."""
126+
runner = CliRunner()
127+
target = tmp_path / "simple_module_chat"
128+
result = runner.invoke(
129+
app,
130+
["new", "simple_module_chat", "--yes", "--no-install", "--dest", str(target)],
131+
)
132+
assert result.exit_code == 0, result.output
133+
readme = (target / "README.md").read_text()
134+
assert "# simple_module_chat" in readme, "README heading should match the user's input"
135+
assert "simple_module_chat/" in readme, "tree diagram should match the directory name"
136+
assert "simple-module-chat" not in readme, "no hyphenated form should leak into the README"
137+
138+
139+
def test_sm_new_underscored_name_pypi_fields_normalize(tmp_path: Path) -> None:
140+
"""Issue #139: pyproject + package.json still use the PEP 503 hyphenated form."""
141+
runner = CliRunner()
142+
target = tmp_path / "simple_module_chat"
143+
runner.invoke(
144+
app,
145+
["new", "simple_module_chat", "--yes", "--no-install", "--dest", str(target)],
146+
)
147+
workspace_pyproject = (target / "pyproject.toml").read_text()
148+
assert 'name = "simple-module-chat"' in workspace_pyproject
149+
workspace_pkg = json.loads((target / "package.json").read_text())
150+
assert workspace_pkg["name"] == "simple-module-chat"
151+
152+
153+
def test_sm_new_underscored_name_warns_about_normalization(tmp_path: Path) -> None:
154+
"""Issue #139: when the PyPI form diverges from user input, the CLI says so."""
155+
runner = CliRunner()
156+
target = tmp_path / "simple_module_chat"
157+
result = runner.invoke(
158+
app,
159+
["new", "simple_module_chat", "--yes", "--no-install", "--dest", str(target)],
160+
)
161+
assert result.exit_code == 0, result.output
162+
assert "simple-module-chat" in result.output, "expected normalization notice in output"
163+
164+
165+
def test_sm_new_hyphenated_name_stays_consistent(tmp_path: Path) -> None:
166+
"""Issue #139: a clean kebab-case input gives one form everywhere — no warning."""
167+
runner = CliRunner()
168+
target = tmp_path / "simple-module-chat"
169+
result = runner.invoke(
170+
app,
171+
["new", "simple-module-chat", "--yes", "--no-install", "--dest", str(target)],
172+
)
173+
assert result.exit_code == 0, result.output
174+
assert "Normalizing PyPI name" not in result.output
175+
readme = (target / "README.md").read_text()
176+
assert "# simple-module-chat" in readme
177+
workspace_pyproject = (target / "pyproject.toml").read_text()
178+
assert 'name = "simple-module-chat"' in workspace_pyproject
179+
180+
181+
def test_sm_new_rejects_mixed_case_name(tmp_path: Path) -> None:
182+
"""Issue #139: mixed case is ambiguous (my-app vs my_app) — refuse, don't guess."""
183+
runner = CliRunner()
184+
result = runner.invoke(
185+
app,
186+
["new", "MyApp", "--yes", "--no-install", "--dest", str(tmp_path / "out")],
187+
)
188+
assert result.exit_code != 0
189+
assert "MyApp" in result.output or "valid" in result.output.lower()
190+
191+
192+
def test_sm_new_rejects_leading_digit_name(tmp_path: Path) -> None:
193+
"""Issue #139: names starting with a digit aren't valid Python package names."""
194+
runner = CliRunner()
195+
result = runner.invoke(
196+
app,
197+
["new", "1chat", "--yes", "--no-install", "--dest", str(tmp_path / "out")],
198+
)
199+
assert result.exit_code != 0
200+
201+
202+
def test_sm_new_rejects_mixed_separators(tmp_path: Path) -> None:
203+
"""Issue #139: foo_bar-baz mixes separators — no canonical form, refuse."""
204+
runner = CliRunner()
205+
result = runner.invoke(
206+
app,
207+
["new", "foo_bar-baz", "--yes", "--no-install", "--dest", str(tmp_path / "out")],
208+
)
209+
assert result.exit_code != 0

0 commit comments

Comments
 (0)