FazBrowse GitHub Viewer | Trending |
URL:
| Home
Tools: [Download Repo ZIP]   [Original HTTPS Page]

feat(cli)!: validate project names and escape TOML values (#32) · pythonnative/pythonnative@3e78f6e · GitHub

Repository navigation

Commit 3e78f6e

Browse files
feat(cli)!: validate project names and escape TOML values (#32)
Refs: #29
1 parent a9b5147 commit 3e78f6e

7 files changed

Lines changed: 295 additions & 28 deletions

File tree

‎docs/api/cli.md‎

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ the documented behavior never drifts from the code.
88

99
- `pn init [name]`: scaffold a new project (creates `app/`,
1010
`pythonnative.toml`, `.gitignore`). With a name it creates `./<name>/`
11-
and scaffolds into it; without one it uses the current directory.
11+
and scaffolds into it; the name must match `^[a-z][a-z0-9_-]*$`.
12+
Without one it uses the current directory, whatever it's called.
1213
Flag: `--force` to overwrite existing files or scaffold into a
1314
non-empty directory. See [Configuration](../guides/configuration.md).
1415
- `pn doctor [android|ios]`: diagnose the local toolchain and validate

‎docs/getting-started.md‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,22 @@ pn --help
88
## Create a project
99

1010
```bash
11-
pn init MyApp
12-
cd MyApp
11+
pn init my_app
12+
cd my_app
1313
```
1414

15-
This creates a `MyApp/` directory containing:
15+
This creates a `my_app/` directory containing:
1616

1717
- `app/` with a minimal `main.py`
1818
- `pythonnative.toml`: your project configuration (app id, version,
1919
permissions, assets, and signing). See
2020
[Configuration](guides/configuration.md).
2121
- `.gitignore`
2222

23-
Run `pn init` without a name to scaffold into the current directory
24-
instead, named after it.
23+
A name has to be lowercase letters, digits, `-`, and `_`, starting with
24+
a letter, so the directory name and the `name` field in the generated
25+
config stay identical. Run `pn init` without a name to scaffold into the
26+
current directory instead, named after it; that name is used as-is.
2527

2628
A minimal `app/main.py` looks like:
2729

@@ -80,8 +82,8 @@ splash, third-party packages, and signing) lives in a single
8082

8183
```toml
8284
[app]
83-
id = "com.example.myapp"
84-
name = "myapp"
85+
id = "com.example.my_app"
86+
name = "my_app"
8587
display_name = "My App"
8688
version = "1.0.0"
8789
build = 1

‎docs/meta/troubleshooting.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,20 @@ lands inside the current directory. Pass a plain name like `my_app`, or
4848
`cd` to the directory you want the project in and run `pn init` with no
4949
name at all. `--force` doesn't lift this one.
5050

51+
### `Invalid project name: 'MyApp'`
52+
53+
A name you pass to `pn init` has to match `^[a-z][a-z0-9_-]*$`: lowercase
54+
letters, digits, `-`, and `_`, starting with a letter. That's the same
55+
spirit as `flutter create` and `cargo new`, and it keeps the directory
56+
name and the `name` field in the config identical. The error suggests a
57+
legal name you can paste straight back, so `MyApp` suggests `myapp` and
58+
`my app` suggests `my_app`. `--force` doesn't lift this one.
59+
60+
This applies only to a name you type. `pn init` with no name takes the
61+
current directory's name as-is, so a directory called `MyProject` is
62+
fine. To use a display name outside this set, edit `display_name` in
63+
`pythonnative.toml` after scaffolding.
64+
5165
### `Refusing to scaffold through a link or outside the current directory: link`
5266

5367
The name resolves somewhere other than a directory directly inside the

‎src/pythonnative/cli/pn.py‎

Lines changed: 58 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,35 @@ def App():
9898
_GITIGNORE = "# PythonNative\n__pycache__/\n*.pyc\n.venv/\nbuild/\n.DS_Store\n"
9999

100100

101+
_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
102+
"""Legal ``pn init`` project names, in the spirit of ``flutter create`` / ``cargo new``."""
103+
104+
_FALLBACK_NAME = "my_app"
105+
106+
107+
def _sanitize_name(name: str) -> str:
108+
"""Return a legal project name derived from ``name``.
109+
110+
Lowercases, collapses each run of illegal characters to one
111+
underscore, trims leading and trailing ``_`` and ``-``, and prefixes
112+
a name that doesn't start with a letter. The result always matches
113+
``_NAME_RE``, falling back to ``_FALLBACK_NAME`` when nothing usable
114+
survives.
115+
116+
Args:
117+
name: The rejected name, which may be empty.
118+
119+
Returns:
120+
A name suitable for suggesting back to the user.
121+
"""
122+
slug = re.sub(r"[^a-z0-9_-]+", "_", name.lower()).strip("_-")
123+
if not slug:
124+
return _FALLBACK_NAME
125+
if not slug[0].isascii() or not slug[0].isalpha():
126+
slug = f"app_{slug}"
127+
return slug
128+
129+
101130
def _app_id_from_name(name: str) -> str:
102131
slug = re.sub(r"[^a-z0-9_]", "", name.lower())
103132
if not slug or not slug[0].isalpha():
@@ -113,9 +142,17 @@ def init_project(args: argparse.Namespace) -> None:
113142
it. Either way it writes ``app/main.py``, ``pythonnative.toml``, and
114143
``.gitignore``.
115144
116-
The name has to be a single directory name, so the project always lands
117-
inside the current directory. Anything that reads as a path, such as
118-
``a/b``, ``..``, or ``/tmp/app``, is refused, and so is a name that
145+
A name you pass has to match ``^[a-z][a-z0-9_-]*$``: lowercase letters,
146+
digits, ``-``, and ``_``, starting with a letter. Anything else is
147+
refused with a legal suggestion. That keeps the directory name and the
148+
``name`` field in the generated config identical, in the same spirit as
149+
``flutter create`` and ``cargo new``. The name taken from the current
150+
directory when you pass none is used as-is, so an existing directory
151+
with any name still works.
152+
153+
The name also has to be a single directory name, so the project always
154+
lands inside the current directory. Anything that reads as a path, such
155+
as ``a/b``, ``..``, or ``/tmp/app``, is refused, and so is a name that
119156
resolves somewhere else, such as a symlink to another directory.
120157
121158
It won't scaffold into a target directory that already holds files, and it
@@ -137,6 +174,19 @@ def init_project(args: argparse.Namespace) -> None:
137174
print(f"Refusing to treat a path as a project name: {name!r}. Use a single directory name like my_app.")
138175
sys.exit(1)
139176

177+
# Charset check, still lexical, so it stays ahead of ``Path.cwd()`` below.
178+
# ``is not None`` rather than truthiness: "" is invalid under the pattern,
179+
# and falling through to the no-name path would silently scaffold here.
180+
# ``fullmatch``, not ``match``: ``$`` also matches before a trailing
181+
# newline, so ``match`` would accept "app\n" and create a directory
182+
# whose name contains one.
183+
if name is not None and not _NAME_RE.fullmatch(name):
184+
print(
185+
f"Invalid project name: {name!r}. Use lowercase letters, digits, '-', and '_', "
186+
f"starting with a letter. Try: {_sanitize_name(name)}"
187+
)
188+
sys.exit(1)
189+
140190
cwd = Path.cwd()
141191
target = cwd / name if name else cwd
142192
project_name: str = name or cwd.name
@@ -947,7 +997,11 @@ def _build_parser() -> argparse.ArgumentParser:
947997
subparsers = parser.add_subparsers()
948998

949999
parser_init = subparsers.add_parser("init", help="Scaffold a new project")
950-
parser_init.add_argument("name", nargs="?", help="Project name; creates ./<name>/ (default: current directory)")
1000+
parser_init.add_argument(
1001+
"name",
1002+
nargs="?",
1003+
help="Project name, matching ^[a-z][a-z0-9_-]*$; creates ./<name>/ (default: current directory)",
1004+
)
9511005
parser_init.add_argument("--force", action="store_true", help="Overwrite existing files or a non-empty directory")
9521006
parser_init.set_defaults(func=init_project)
9531007

‎src/pythonnative/project/config.py‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -586,9 +586,52 @@ def entrypoint_to_module(entry_point: str) -> str:
586586
return normalized or "app.main"
587587

588588

589+
# TOML v1.0.0 basic strings must escape the quotation mark, the backslash,
590+
# and every control character except tab: U+0000-U+0008, U+000A-U+001F, and
591+
# U+007F. Tab is legal raw, and U+000B has no compact escape, so anything
592+
# without one falls through to \uXXXX.
593+
_TOML_COMPACT_ESCAPES = {
594+
"\\": "\\\\",
595+
'"': '\\"',
596+
"\b": "\\b",
597+
"\f": "\\f",
598+
"\n": "\\n",
599+
"\r": "\\r",
600+
}
601+
602+
603+
def _toml_escape(value: str) -> str:
604+
"""Escape ``value`` for use inside a TOML basic string.
605+
606+
Applied at the render boundary rather than relying on the caller,
607+
since this module is public API and reachable without ``pn init``'s
608+
name validation in front of it.
609+
610+
Args:
611+
value: The raw string to embed between double quotes.
612+
613+
Returns:
614+
The escaped text, without the surrounding quotes.
615+
"""
616+
out = []
617+
for char in value:
618+
escaped = _TOML_COMPACT_ESCAPES.get(char)
619+
if escaped is not None:
620+
out.append(escaped)
621+
elif char != "\t" and (char < "\x20" or char == "\x7f"):
622+
out.append(f"\\u{ord(char):04X}")
623+
else:
624+
out.append(char)
625+
return "".join(out)
626+
627+
589628
def render_default_toml(*, name: str, app_id: str, python_version: str = "3.11") -> str:
590629
"""Render a starter ``pythonnative.toml`` for ``pn init``.
591630
631+
Every interpolated value is escaped for a TOML basic string, so a
632+
name containing a quote, a backslash, or a control character still
633+
produces a parseable file.
634+
592635
Args:
593636
name: Project name.
594637
app_id: Reverse-DNS app identifier.
@@ -599,6 +642,10 @@ def render_default_toml(*, name: str, app_id: str, python_version: str = "3.11")
599642
for the optional tables.
600643
"""
601644
display = name.replace("_", " ").replace("-", " ").strip().title() or name
645+
name = _toml_escape(name)
646+
display = _toml_escape(display)
647+
app_id = _toml_escape(app_id)
648+
python_version = _toml_escape(python_version)
602649
return f"""# PythonNative project configuration.
603650
# Docs: https://pythonnative.com/guides/configuration/
604651

‎tests/project/test_config.py‎

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -190,3 +190,66 @@ def test_rendered_default_toml_parses_and_loads() -> None:
190190
assert cfg.app_id == "com.example.my_app"
191191
assert cfg.display_name == "My App"
192192
assert cfg.requirements == []
193+
194+
195+
# `pn init` rejects these names, but render_default_toml is public API and is
196+
# called directly here and by library callers, with no validation in front of
197+
# it. Escaping is what makes the render boundary safe on its own.
198+
@pytest.mark.parametrize(
199+
"raw",
200+
[
201+
pytest.param('bad"name', id="quote"),
202+
pytest.param("back\\slash", id="backslash"),
203+
pytest.param("two\nlines", id="newline"),
204+
pytest.param("with\ttab", id="tab"),
205+
pytest.param("vt\x0bhere", id="vertical-tab"),
206+
pytest.param("nul\x00here", id="nul"),
207+
pytest.param("del\x7fhere", id="delete"),
208+
pytest.param('q"\\b\n\t\x0b\x00\x7fz', id="all-at-once"),
209+
pytest.param("café", id="non-ascii"),
210+
],
211+
)
212+
def test_rendered_toml_escapes_awkward_names(raw: str) -> None:
213+
text = render_default_toml(name=raw, app_id="com.example.x")
214+
215+
data = tomllib.loads(text)
216+
assert data["app"]["name"] == raw
217+
218+
219+
def test_rendered_toml_escapes_every_interpolated_value() -> None:
220+
raw = 'q"\\ \n\t\x0b\x00\x7f z'
221+
app_id = 'id"\\x'
222+
223+
data = tomllib.loads(render_default_toml(name=raw, app_id=app_id, python_version='3"11'))
224+
225+
assert data["app"]["name"] == raw
226+
assert data["app"]["id"] == app_id
227+
assert data["app"]["python_version"] == '3"11'
228+
# display_name is derived from name, so it is escaped separately.
229+
assert data["app"]["display_name"] == raw.replace("_", " ").replace("-", " ").strip().title()
230+
231+
232+
def test_rendered_toml_leaves_tab_unescaped_and_escapes_vertical_tab() -> None:
233+
# TOML allows a raw tab in a basic string; U+000B has no compact escape.
234+
text = render_default_toml(name="a\tb\x0bc", app_id="com.example.x")
235+
236+
name_line = next(line for line in text.splitlines() if line.startswith("name = "))
237+
assert name_line == 'name = "a\tb\\u000Bc"'
238+
assert tomllib.loads(text)["app"]["name"] == "a\tb\x0bc"
239+
240+
241+
def test_rendered_toml_escapes_the_commented_examples() -> None:
242+
# url_schemes, bundle_id, and key_alias are commented out, so tomllib
243+
# never sees them and the other tests can't catch a missing escape there.
244+
# Uncomment them and the file still has to parse.
245+
raw = 'q"\\x'
246+
text = render_default_toml(name=raw, app_id=raw)
247+
248+
prefixes = ("# url_schemes = ", "# bundle_id = ", "# key_alias = ")
249+
uncommented = [line[len("# ") :] for line in text.splitlines() if line.startswith(prefixes)]
250+
assert len(uncommented) == 3, uncommented
251+
252+
data = tomllib.loads("\n".join(uncommented))
253+
assert data["url_schemes"] == [raw]
254+
assert data["bundle_id"] == raw
255+
assert data["key_alias"] == raw

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL