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

feat(cli)!: validate project names and escape TOML values by Adebowale-Morakinyo · Pull Request #32 · pythonnative/pythonnative · GitHub

Repository navigation

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension .md  (3) .py  (4) All 2 file types selected
Viewed files
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Unified
Split
Hide whitespace
Diff view
Unified
Split
Hide whitespace
3 changes: 2 additions & 1 deletion docs/api/cli.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@ the documented behavior never drifts from the code.

- `pn init [name]`: scaffold a new project (creates `app/`,
`pythonnative.toml`, `.gitignore`). With a name it creates `./<name>/`
and scaffolds into it; without one it uses the current directory.
and scaffolds into it; the name must match `^[a-z][a-z0-9_-]*$`.
Without one it uses the current directory, whatever it's called.
Flag: `--force` to overwrite existing files or scaffold into a
non-empty directory. See [Configuration](../guides/configuration.md).
- `pn doctor [android|ios]`: diagnose the local toolchain and validate
Expand Down
16 changes: 9 additions & 7 deletions docs/getting-started.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,22 @@ pn --help
## Create a project

```bash
pn init MyApp
cd MyApp
pn init my_app
cd my_app
```

This creates a `MyApp/` directory containing:
This creates a `my_app/` directory containing:

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

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

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

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

```toml
[app]
id = "com.example.myapp"
name = "myapp"
id = "com.example.my_app"
name = "my_app"
display_name = "My App"
version = "1.0.0"
build = 1
Expand Down
14 changes: 14 additions & 0 deletions docs/meta/troubleshooting.md
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,20 @@ lands inside the current directory. Pass a plain name like `my_app`, or
`cd` to the directory you want the project in and run `pn init` with no
name at all. `--force` doesn't lift this one.

### `Invalid project name: 'MyApp'`

A name you pass to `pn init` has to match `^[a-z][a-z0-9_-]*$`: lowercase
letters, digits, `-`, and `_`, starting with a letter. That's the same
spirit as `flutter create` and `cargo new`, and it keeps the directory
name and the `name` field in the config identical. The error suggests a
legal name you can paste straight back, so `MyApp` suggests `myapp` and
`my app` suggests `my_app`. `--force` doesn't lift this one.

This applies only to a name you type. `pn init` with no name takes the
current directory's name as-is, so a directory called `MyProject` is
fine. To use a display name outside this set, edit `display_name` in
`pythonnative.toml` after scaffolding.

### `Refusing to scaffold through a link or outside the current directory: link`

The name resolves somewhere other than a directory directly inside the
Expand Down
62 changes: 58 additions & 4 deletions src/pythonnative/cli/pn.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,35 @@ def App():
_GITIGNORE = "# PythonNative\n__pycache__/\n*.pyc\n.venv/\nbuild/\n.DS_Store\n"


_NAME_RE = re.compile(r"^[a-z][a-z0-9_-]*$")
"""Legal ``pn init`` project names, in the spirit of ``flutter create`` / ``cargo new``."""

_FALLBACK_NAME = "my_app"


def _sanitize_name(name: str) -> str:
"""Return a legal project name derived from ``name``.

Lowercases, collapses each run of illegal characters to one
underscore, trims leading and trailing ``_`` and ``-``, and prefixes
a name that doesn't start with a letter. The result always matches
``_NAME_RE``, falling back to ``_FALLBACK_NAME`` when nothing usable
survives.

Args:
name: The rejected name, which may be empty.

Returns:
A name suitable for suggesting back to the user.
"""
slug = re.sub(r"[^a-z0-9_-]+", "_", name.lower()).strip("_-")
if not slug:
return _FALLBACK_NAME
if not slug[0].isascii() or not slug[0].isalpha():
slug = f"app_{slug}"
return slug


def _app_id_from_name(name: str) -> str:
slug = re.sub(r"[^a-z0-9_]", "", name.lower())
if not slug or not slug[0].isalpha():
Expand All @@ -113,9 +142,17 @@ def init_project(args: argparse.Namespace) -> None:
it. Either way it writes ``app/main.py``, ``pythonnative.toml``, and
``.gitignore``.

The name has to be a single directory name, so the project always lands
inside the current directory. Anything that reads as a path, such as
``a/b``, ``..``, or ``/tmp/app``, is refused, and so is a name that
A name you pass has to match ``^[a-z][a-z0-9_-]*$``: lowercase letters,
digits, ``-``, and ``_``, starting with a letter. Anything else is
refused with a legal suggestion. That keeps the directory name and the
``name`` field in the generated config identical, in the same spirit as
``flutter create`` and ``cargo new``. The name taken from the current
directory when you pass none is used as-is, so an existing directory
with any name still works.

The name also has to be a single directory name, so the project always
lands inside the current directory. Anything that reads as a path, such
as ``a/b``, ``..``, or ``/tmp/app``, is refused, and so is a name that
resolves somewhere else, such as a symlink to another directory.

It won't scaffold into a target directory that already holds files, and it
Expand All @@ -137,6 +174,19 @@ def init_project(args: argparse.Namespace) -> None:
print(f"Refusing to treat a path as a project name: {name!r}. Use a single directory name like my_app.")
sys.exit(1)

# Charset check, still lexical, so it stays ahead of ``Path.cwd()`` below.
# ``is not None`` rather than truthiness: "" is invalid under the pattern,
# and falling through to the no-name path would silently scaffold here.
# ``fullmatch``, not ``match``: ``$`` also matches before a trailing
# newline, so ``match`` would accept "app\n" and create a directory
# whose name contains one.
if name is not None and not _NAME_RE.fullmatch(name):
print(
f"Invalid project name: {name!r}. Use lowercase letters, digits, '-', and '_', "
f"starting with a letter. Try: {_sanitize_name(name)}"
)
sys.exit(1)

cwd = Path.cwd()
target = cwd / name if name else cwd
project_name: str = name or cwd.name
Expand Down Expand Up @@ -947,7 +997,11 @@ def _build_parser() -> argparse.ArgumentParser:
subparsers = parser.add_subparsers()

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

Expand Down
47 changes: 47 additions & 0 deletions src/pythonnative/project/config.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -586,9 +586,52 @@ def entrypoint_to_module(entry_point: str) -> str:
return normalized or "app.main"


# TOML v1.0.0 basic strings must escape the quotation mark, the backslash,
# and every control character except tab: U+0000-U+0008, U+000A-U+001F, and
# U+007F. Tab is legal raw, and U+000B has no compact escape, so anything
# without one falls through to \uXXXX.
_TOML_COMPACT_ESCAPES = {
"\\": "\\\\",
'"': '\\"',
"\b": "\\b",
"\f": "\\f",
"\n": "\\n",
"\r": "\\r",
}


def _toml_escape(value: str) -> str:
"""Escape ``value`` for use inside a TOML basic string.

Applied at the render boundary rather than relying on the caller,
since this module is public API and reachable without ``pn init``'s
name validation in front of it.

Args:
value: The raw string to embed between double quotes.

Returns:
The escaped text, without the surrounding quotes.
"""
out = []
for char in value:
escaped = _TOML_COMPACT_ESCAPES.get(char)
if escaped is not None:
out.append(escaped)
elif char != "\t" and (char < "\x20" or char == "\x7f"):
out.append(f"\\u{ord(char):04X}")
else:
out.append(char)
return "".join(out)


def render_default_toml(*, name: str, app_id: str, python_version: str = "3.11") -> str:
"""Render a starter ``pythonnative.toml`` for ``pn init``.

Every interpolated value is escaped for a TOML basic string, so a
name containing a quote, a backslash, or a control character still
produces a parseable file.

Args:
name: Project name.
app_id: Reverse-DNS app identifier.
Expand All @@ -599,6 +642,10 @@ def render_default_toml(*, name: str, app_id: str, python_version: str = "3.11")
for the optional tables.
"""
display = name.replace("_", " ").replace("-", " ").strip().title() or name
name = _toml_escape(name)
display = _toml_escape(display)
app_id = _toml_escape(app_id)
python_version = _toml_escape(python_version)
return f"""# PythonNative project configuration.
# Docs: https://pythonnative.com/guides/configuration/

Expand Down
63 changes: 63 additions & 0 deletions tests/project/test_config.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters. Learn more about bidirectional Unicode characters
Original file line number Diff line number Diff line change
Expand Up @@ -190,3 +190,66 @@ def test_rendered_default_toml_parses_and_loads() -> None:
assert cfg.app_id == "com.example.my_app"
assert cfg.display_name == "My App"
assert cfg.requirements == []


# `pn init` rejects these names, but render_default_toml is public API and is
# called directly here and by library callers, with no validation in front of
# it. Escaping is what makes the render boundary safe on its own.
@pytest.mark.parametrize(
"raw",
[
pytest.param('bad"name', id="quote"),
pytest.param("back\\slash", id="backslash"),
pytest.param("two\nlines", id="newline"),
pytest.param("with\ttab", id="tab"),
pytest.param("vt\x0bhere", id="vertical-tab"),
pytest.param("nul\x00here", id="nul"),
pytest.param("del\x7fhere", id="delete"),
pytest.param('q"\\b\n\t\x0b\x00\x7fz', id="all-at-once"),
pytest.param("café", id="non-ascii"),
],
)
def test_rendered_toml_escapes_awkward_names(raw: str) -> None:
text = render_default_toml(name=raw, app_id="com.example.x")

data = tomllib.loads(text)
assert data["app"]["name"] == raw


def test_rendered_toml_escapes_every_interpolated_value() -> None:
raw = 'q"\\ \n\t\x0b\x00\x7f z'
app_id = 'id"\\x'

data = tomllib.loads(render_default_toml(name=raw, app_id=app_id, python_version='3"11'))

assert data["app"]["name"] == raw
assert data["app"]["id"] == app_id
assert data["app"]["python_version"] == '3"11'
# display_name is derived from name, so it is escaped separately.
assert data["app"]["display_name"] == raw.replace("_", " ").replace("-", " ").strip().title()


def test_rendered_toml_leaves_tab_unescaped_and_escapes_vertical_tab() -> None:
# TOML allows a raw tab in a basic string; U+000B has no compact escape.
text = render_default_toml(name="a\tb\x0bc", app_id="com.example.x")

name_line = next(line for line in text.splitlines() if line.startswith("name = "))
assert name_line == 'name = "a\tb\\u000Bc"'
assert tomllib.loads(text)["app"]["name"] == "a\tb\x0bc"


def test_rendered_toml_escapes_the_commented_examples() -> None:
# url_schemes, bundle_id, and key_alias are commented out, so tomllib
# never sees them and the other tests can't catch a missing escape there.
# Uncomment them and the file still has to parse.
raw = 'q"\\x'
text = render_default_toml(name=raw, app_id=raw)

prefixes = ("# url_schemes = ", "# bundle_id = ", "# key_alias = ")
uncommented = [line[len("# ") :] for line in text.splitlines() if line.startswith(prefixes)]
assert len(uncommented) == 3, uncommented

data = tomllib.loads("\n".join(uncommented))
assert data["url_schemes"] == [raw]
assert data["bundle_id"] == raw
assert data["key_alias"] == raw
Loading
Loading

Back | FazBrowse Home | New Git URL