| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -8,7 +8,8 @@ the documented behavior never drifts from the code. | |||
| 8 | 8 | ||
| 9 | 9 | - `pn init [name]`: scaffold a new project (creates `app/`, | |
| 10 | 10 | `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. | ||
| 12 | 13 | Flag: `--force` to overwrite existing files or scaffold into a | |
| 13 | 14 | non-empty directory. See [Configuration](../guides/configuration.md). | |
| 14 | 15 | - `pn doctor [android|ios]`: diagnose the local toolchain and validate | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -8,20 +8,22 @@ pn --help | |||
| 8 | 8 | ## Create a project | |
| 9 | 9 | ||
| 10 | 10 | ```bash | |
| 11 | - pn init MyApp | ||
| 12 | - cd MyApp | ||
| 11 | + pn init my_app | ||
| 12 | + cd my_app | ||
| 13 | 13 | ``` | |
| 14 | 14 | ||
| 15 | - This creates a `MyApp/` directory containing: | ||
| 15 | + This creates a `my_app/` directory containing: | ||
| 16 | 16 | ||
| 17 | 17 | - `app/` with a minimal `main.py` | |
| 18 | 18 | - `pythonnative.toml`: your project configuration (app id, version, | |
| 19 | 19 | permissions, assets, and signing). See | |
| 20 | 20 | [Configuration](guides/configuration.md). | |
| 21 | 21 | - `.gitignore` | |
| 22 | 22 | ||
| 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. | ||
| 25 | 27 | ||
| 26 | 28 | A minimal `app/main.py` looks like: | |
| 27 | 29 | ||
@@ -80,8 +82,8 @@ splash, third-party packages, and signing) lives in a single | |||
| 80 | 82 | ||
| 81 | 83 | ```toml | |
| 82 | 84 | [app] | |
| 83 | - id = "com.example.myapp" | ||
| 84 | - name = "myapp" | ||
| 85 | + id = "com.example.my_app" | ||
| 86 | + name = "my_app" | ||
| 85 | 87 | display_name = "My App" | |
| 86 | 88 | version = "1.0.0" | |
| 87 | 89 | build = 1 | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -48,6 +48,20 @@ lands inside the current directory. Pass a plain name like `my_app`, or | |||
| 48 | 48 | `cd` to the directory you want the project in and run `pn init` with no | |
| 49 | 49 | name at all. `--force` doesn't lift this one. | |
| 50 | 50 | ||
| 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 | + | ||
| 51 | 65 | ### `Refusing to scaffold through a link or outside the current directory: link` | |
| 52 | 66 | ||
| 53 | 67 | The name resolves somewhere other than a directory directly inside the | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -98,6 +98,35 @@ def App(): | |||
| 98 | 98 | _GITIGNORE = "# PythonNative\n__pycache__/\n*.pyc\n.venv/\nbuild/\n.DS_Store\n" | |
| 99 | 99 | ||
| 100 | 100 | ||
| 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 | + | ||
| 101 | 130 | def _app_id_from_name(name: str) -> str: | |
| 102 | 131 | slug = re.sub(r"[^a-z0-9_]", "", name.lower()) | |
| 103 | 132 | if not slug or not slug[0].isalpha(): | |
@@ -113,9 +142,17 @@ def init_project(args: argparse.Namespace) -> None: | |||
| 113 | 142 | it. Either way it writes ``app/main.py``, ``pythonnative.toml``, and | |
| 114 | 143 | ``.gitignore``. | |
| 115 | 144 | ||
| 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 | ||
| 119 | 156 | resolves somewhere else, such as a symlink to another directory. | |
| 120 | 157 | ||
| 121 | 158 | 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: | |||
| 137 | 174 | print(f"Refusing to treat a path as a project name: {name!r}. Use a single directory name like my_app.") | |
| 138 | 175 | sys.exit(1) | |
| 139 | 176 | ||
| 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 | + | ||
| 140 | 190 | cwd = Path.cwd() | |
| 141 | 191 | target = cwd / name if name else cwd | |
| 142 | 192 | project_name: str = name or cwd.name | |
@@ -947,7 +997,11 @@ def _build_parser() -> argparse.ArgumentParser: | |||
| 947 | 997 | subparsers = parser.add_subparsers() | |
| 948 | 998 | ||
| 949 | 999 | 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 | + ) | ||
| 951 | 1005 | parser_init.add_argument("--force", action="store_true", help="Overwrite existing files or a non-empty directory") | |
| 952 | 1006 | parser_init.set_defaults(func=init_project) | |
| 953 | 1007 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -586,9 +586,52 @@ def entrypoint_to_module(entry_point: str) -> str: | |||
| 586 | 586 | return normalized or "app.main" | |
| 587 | 587 | ||
| 588 | 588 | ||
| 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 | + | ||
| 589 | 628 | def render_default_toml(*, name: str, app_id: str, python_version: str = "3.11") -> str: | |
| 590 | 629 | """Render a starter ``pythonnative.toml`` for ``pn init``. | |
| 591 | 630 | ||
| 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 | + | ||
| 592 | 635 | Args: | |
| 593 | 636 | name: Project name. | |
| 594 | 637 | app_id: Reverse-DNS app identifier. | |
@@ -599,6 +642,10 @@ def render_default_toml(*, name: str, app_id: str, python_version: str = "3.11") | |||
| 599 | 642 | for the optional tables. | |
| 600 | 643 | """ | |
| 601 | 644 | 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) | ||
| 602 | 649 | return f"""# PythonNative project configuration. | |
| 603 | 650 | # Docs: https://pythonnative.com/guides/configuration/ | |
| 604 | 651 | ||
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -190,3 +190,66 @@ def test_rendered_default_toml_parses_and_loads() -> None: | |||
| 190 | 190 | assert cfg.app_id == "com.example.my_app" | |
| 191 | 191 | assert cfg.display_name == "My App" | |
| 192 | 192 | 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 | ||
| Back | FazBrowse Home | New Git URL |
0 commit comments