Skip to content

Commit 38e70f5

Browse files
committed
fix(cli): keep user's name form in scaffold READMEs (#139)
`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 c6798dc commit 38e70f5

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
@@ -59,3 +59,92 @@ def test_sm_new_sample_module_seeds_static_dist_placeholder(tmp_path: Path) -> N
5959
)
6060
static_dist = target / "modules" / "hello" / "hello" / "static" / "dist"
6161
assert static_dist.is_dir(), "static/dist/ must exist for hatch force-include"
62+
63+
64+
def test_sm_new_underscored_name_keeps_user_form_in_readme(tmp_path: Path) -> None:
65+
"""Issue #139: when the user types underscores, the README, tree diagram,
66+
and directory name must agree. Only the PyPI fields normalize to hyphens."""
67+
runner = CliRunner()
68+
target = tmp_path / "simple_module_chat"
69+
result = runner.invoke(
70+
app,
71+
["new", "simple_module_chat", "--yes", "--no-install", "--dest", str(target)],
72+
)
73+
assert result.exit_code == 0, result.output
74+
readme = (target / "README.md").read_text()
75+
assert "# simple_module_chat" in readme, "README heading should match the user's input"
76+
assert "simple_module_chat/" in readme, "tree diagram should match the directory name"
77+
assert "simple-module-chat" not in readme, "no hyphenated form should leak into the README"
78+
79+
80+
def test_sm_new_underscored_name_pypi_fields_normalize(tmp_path: Path) -> None:
81+
"""Issue #139: pyproject + package.json still use the PEP 503 hyphenated form."""
82+
runner = CliRunner()
83+
target = tmp_path / "simple_module_chat"
84+
runner.invoke(
85+
app,
86+
["new", "simple_module_chat", "--yes", "--no-install", "--dest", str(target)],
87+
)
88+
workspace_pyproject = (target / "pyproject.toml").read_text()
89+
assert 'name = "simple-module-chat"' in workspace_pyproject
90+
workspace_pkg = json.loads((target / "package.json").read_text())
91+
assert workspace_pkg["name"] == "simple-module-chat"
92+
93+
94+
def test_sm_new_underscored_name_warns_about_normalization(tmp_path: Path) -> None:
95+
"""Issue #139: when the PyPI form diverges from user input, the CLI says so."""
96+
runner = CliRunner()
97+
target = tmp_path / "simple_module_chat"
98+
result = runner.invoke(
99+
app,
100+
["new", "simple_module_chat", "--yes", "--no-install", "--dest", str(target)],
101+
)
102+
assert result.exit_code == 0, result.output
103+
assert "simple-module-chat" in result.output, "expected normalization notice in output"
104+
105+
106+
def test_sm_new_hyphenated_name_stays_consistent(tmp_path: Path) -> None:
107+
"""Issue #139: a clean kebab-case input gives one form everywhere — no warning."""
108+
runner = CliRunner()
109+
target = tmp_path / "simple-module-chat"
110+
result = runner.invoke(
111+
app,
112+
["new", "simple-module-chat", "--yes", "--no-install", "--dest", str(target)],
113+
)
114+
assert result.exit_code == 0, result.output
115+
assert "Normalizing PyPI name" not in result.output
116+
readme = (target / "README.md").read_text()
117+
assert "# simple-module-chat" in readme
118+
workspace_pyproject = (target / "pyproject.toml").read_text()
119+
assert 'name = "simple-module-chat"' in workspace_pyproject
120+
121+
122+
def test_sm_new_rejects_mixed_case_name(tmp_path: Path) -> None:
123+
"""Issue #139: mixed case is ambiguous (my-app vs my_app) — refuse, don't guess."""
124+
runner = CliRunner()
125+
result = runner.invoke(
126+
app,
127+
["new", "MyApp", "--yes", "--no-install", "--dest", str(tmp_path / "out")],
128+
)
129+
assert result.exit_code != 0
130+
assert "MyApp" in result.output or "valid" in result.output.lower()
131+
132+
133+
def test_sm_new_rejects_leading_digit_name(tmp_path: Path) -> None:
134+
"""Issue #139: names starting with a digit aren't valid Python package names."""
135+
runner = CliRunner()
136+
result = runner.invoke(
137+
app,
138+
["new", "1chat", "--yes", "--no-install", "--dest", str(tmp_path / "out")],
139+
)
140+
assert result.exit_code != 0
141+
142+
143+
def test_sm_new_rejects_mixed_separators(tmp_path: Path) -> None:
144+
"""Issue #139: foo_bar-baz mixes separators — no canonical form, refuse."""
145+
runner = CliRunner()
146+
result = runner.invoke(
147+
app,
148+
["new", "foo_bar-baz", "--yes", "--no-install", "--dest", str(tmp_path / "out")],
149+
)
150+
assert result.exit_code != 0

0 commit comments

Comments
 (0)