| FazBrowse GitHub Viewer | Trending | | Home |
| Tools: [Download Repo ZIP] [Original HTTPS Page] |
1 parent b57586f commit 0f68f82
2 files changed
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -2,7 +2,9 @@ | |||
| 2 | 2 | ||
| 3 | 3 | The console script `pn` (declared in `pyproject.toml`) dispatches to: | |
| 4 | 4 | ||
| 5 | - - `pn init [name]`: scaffold a new project (``pythonnative.toml`` + ``app/``). | ||
| 5 | + - `pn init [name]`: scaffold a new project (``pythonnative.toml`` + | ||
| 6 | + ``app/``) into ``./name/``, or into the current directory when no name | ||
| 7 | + is given. | ||
| 6 | 8 | - `pn doctor [platform]`: diagnose the local toolchain and config. | |
| 7 | 9 | - `pn preview [component]`: render the app in a desktop (Tkinter) window | |
| 8 | 10 | with Fast Refresh, the fast inner dev loop, no device required. | |
@@ -103,21 +105,62 @@ def _app_id_from_name(name: str) -> str: | |||
| 103 | 105 | ||
| 104 | 106 | ||
| 105 | 107 | def init_project(args: argparse.Namespace) -> None: | |
| 106 | - """Scaffold a new PythonNative project in the current directory. | ||
| 108 | + """Scaffold a new PythonNative project. | ||
| 107 | 109 | ||
| 108 | - Creates ``app/main.py``, ``pythonnative.toml``, and ``.gitignore``. | ||
| 109 | - Refuses to overwrite existing files unless ``--force`` is passed. | ||
| 110 | + Given a name, this creates ``./<name>/`` and scaffolds into it. Without | ||
| 111 | + one, it scaffolds into the current directory and names the project after | ||
| 112 | + it. Either way it writes ``app/main.py``, ``pythonnative.toml``, and | ||
| 113 | + ``.gitignore``. | ||
| 114 | + | ||
| 115 | + The name has to be a single directory name, so the project always lands | ||
| 116 | + inside the current directory. Anything that reads as a path, such as | ||
| 117 | + ``a/b``, ``..``, or ``/tmp/app``, is refused, and so is a name that | ||
| 118 | + resolves somewhere else, such as a symlink to another directory. | ||
| 119 | + | ||
| 120 | + It won't scaffold into a target directory that already holds files, and it | ||
| 121 | + won't overwrite any of the three paths above; pass ``--force`` to override | ||
| 122 | + both. An existing but empty target directory is fine. A plain file at | ||
| 123 | + ``./<name>`` is always refused, since ``--force`` can't turn it into a | ||
| 124 | + directory, and ``--force`` lifts neither of the rules above. | ||
| 110 | 125 | ||
| 111 | 126 | Args: | |
| 112 | 127 | args: Parsed namespace with ``name`` (optional) and ``force``. | |
| 113 | 128 | """ | |
| 114 | - cwd = Path.cwd() | ||
| 115 | - project_name: str = getattr(args, "name", None) or cwd.name | ||
| 129 | + name: Optional[str] = getattr(args, "name", None) | ||
| 116 | 130 | force: bool = getattr(args, "force", False) | |
| 117 | 131 | ||
| 118 | - app_dir = cwd / "app" | ||
| 119 | - config_path = cwd / CONFIG_FILENAME | ||
| 120 | - gitignore_path = cwd / ".gitignore" | ||
| 132 | + # Lexical check, before anything reads the filesystem. ``Path("..").name`` | ||
| 133 | + # is "..", so ".." needs naming explicitly; the rest (absolute, nested, | ||
| 134 | + # trailing separator, ".") fall out of the name check. | ||
| 135 | + if name and (name in (os.curdir, os.pardir) or Path(name).name != name): | ||
| 136 | + print(f"Refusing to treat a path as a project name: {name!r}. Use a single directory name like my_app.") | ||
| 137 | + sys.exit(1) | ||
| 138 | + | ||
| 139 | + cwd = Path.cwd() | ||
| 140 | + target = cwd / name if name else cwd | ||
| 141 | + project_name: str = name or cwd.name | ||
| 142 | + | ||
| 143 | + # A lexically clean name can still resolve elsewhere, and ``exists()`` and | ||
| 144 | + # ``is_dir()`` below follow symlinks. Check containment rather than just | ||
| 145 | + # ``is_symlink()`` so the whole class is closed, not one spelling of it. | ||
| 146 | + if name and (target.is_symlink() or target.resolve().parent != cwd.resolve()): | ||
| 147 | + print( | ||
| 148 | + f"Refusing to scaffold through a link or outside the current directory: {name}. " | ||
| 149 | + "Use a plain directory name." | ||
| 150 | + ) | ||
| 151 | + sys.exit(1) | ||
| 152 | + | ||
| 153 | + app_dir = target / "app" | ||
| 154 | + config_path = target / CONFIG_FILENAME | ||
| 155 | + gitignore_path = target / ".gitignore" | ||
| 156 | + | ||
| 157 | + if name and target.exists(): | ||
| 158 | + if not target.is_dir(): | ||
| 159 | + print(f"Refusing to overwrite existing file: {name}. Remove it or choose a different name.") | ||
| 160 | + sys.exit(1) | ||
| 161 | + if any(target.iterdir()) and not force: | ||
| 162 | + print(f"Refusing to overwrite existing non-empty directory: {name}/. Use --force to overwrite.") | ||
| 163 | + sys.exit(1) | ||
| 121 | 164 | ||
| 122 | 165 | if not force: | |
| 123 | 166 | existing = [ | |
@@ -141,8 +184,11 @@ def init_project(args: argparse.Namespace) -> None: | |||
| 141 | 184 | if force or not gitignore_path.exists(): | |
| 142 | 185 | gitignore_path.write_text(_GITIGNORE, encoding="utf-8") | |
| 143 | 186 | ||
| 144 | - print(f"Initialized PythonNative project in {cwd}.") | ||
| 145 | - print("Next: pn preview (desktop) | pn run android | pn run ios") | ||
| 187 | + print(f"Initialized PythonNative project in {target}.") | ||
| 188 | + next_steps = "pn preview (desktop) | pn run android | pn run ios" | ||
| 189 | + if name: | ||
| 190 | + next_steps = f"cd {name} | {next_steps}" | ||
| 191 | + print(f"Next: {next_steps}") | ||
| 146 | 192 | ||
| 147 | 193 | ||
| 148 | 194 | # ====================================================================== | |
@@ -866,8 +912,8 @@ def _build_parser() -> argparse.ArgumentParser: | |||
| 866 | 912 | subparsers = parser.add_subparsers() | |
| 867 | 913 | ||
| 868 | 914 | parser_init = subparsers.add_parser("init", help="Scaffold a new project") | |
| 869 | - parser_init.add_argument("name", nargs="?", help="Project name (defaults to current directory name)") | ||
| 870 | - parser_init.add_argument("--force", action="store_true", help="Overwrite existing files if present") | ||
| 915 | + parser_init.add_argument("name", nargs="?", help="Project name; creates ./<name>/ (default: current directory)") | ||
| 916 | + parser_init.add_argument("--force", action="store_true", help="Overwrite existing files or a non-empty directory") | ||
| 871 | 917 | parser_init.set_defaults(func=init_project) | |
| 872 | 918 | ||
| 873 | 919 | parser_doctor = subparsers.add_parser("doctor", help="Diagnose the local toolchain and config") | |
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
@@ -22,21 +22,22 @@ def test_cli_init_and_clean() -> None: | |||
| 22 | 22 | try: | |
| 23 | 23 | result = run_pn(["init", "MyApp"], tmpdir) | |
| 24 | 24 | assert result.returncode == 0, result.stderr | |
| 25 | - assert os.path.isdir(os.path.join(tmpdir, "app")) | ||
| 25 | + project_dir = os.path.join(tmpdir, "MyApp") | ||
| 26 | + assert os.path.isdir(os.path.join(project_dir, "app")) | ||
| 26 | 27 | ||
| 27 | - main_path = os.path.join(tmpdir, "app", "main.py") | ||
| 28 | + main_path = os.path.join(project_dir, "app", "main.py") | ||
| 28 | 29 | assert os.path.isfile(main_path) | |
| 29 | 30 | content = Path(main_path).read_text(encoding="utf-8") | |
| 30 | 31 | assert "def App(" in content | |
| 31 | 32 | assert "Stack.Navigator" in content | |
| 32 | 33 | ||
| 33 | - config_path = os.path.join(tmpdir, "pythonnative.toml") | ||
| 34 | + config_path = os.path.join(project_dir, "pythonnative.toml") | ||
| 34 | 35 | assert os.path.isfile(config_path) | |
| 35 | 36 | toml_text = Path(config_path).read_text(encoding="utf-8") | |
| 36 | 37 | assert 'id = "com.example.myapp"' in toml_text | |
| 37 | - assert os.path.isfile(os.path.join(tmpdir, ".gitignore")) | ||
| 38 | + assert os.path.isfile(os.path.join(project_dir, ".gitignore")) | ||
| 38 | 39 | # The legacy JSON config and requirements.txt are no longer scaffolded. | |
| 39 | - assert not os.path.exists(os.path.join(tmpdir, "pythonnative.json")) | ||
| 40 | + assert not os.path.exists(os.path.join(project_dir, "pythonnative.json")) | ||
| 40 | 41 | ||
| 41 | 42 | # clean on empty build is a no-op | |
| 42 | 43 | result = run_pn(["clean"], tmpdir) | |
@@ -62,6 +63,136 @@ def test_cli_init_refuses_overwrite() -> None: | |||
| 62 | 63 | shutil.rmtree(tmpdir, ignore_errors=True) | |
| 63 | 64 | ||
| 64 | 65 | ||
| 66 | + def test_cli_init_creates_named_directory(tmp_path: Path) -> None: | ||
| 67 | + result = run_pn(["init", "my_app"], str(tmp_path)) | ||
| 68 | + assert result.returncode == 0, result.stderr | ||
| 69 | + assert "cd my_app" in result.stdout | ||
| 70 | + | ||
| 71 | + project_dir = os.path.join(str(tmp_path), "my_app") | ||
| 72 | + assert os.path.isfile(os.path.join(project_dir, "app", "main.py")) | ||
| 73 | + assert os.path.isfile(os.path.join(project_dir, "pythonnative.toml")) | ||
| 74 | + assert os.path.isfile(os.path.join(project_dir, ".gitignore")) | ||
| 75 | + toml_text = Path(os.path.join(project_dir, "pythonnative.toml")).read_text(encoding="utf-8") | ||
| 76 | + assert 'id = "com.example.my_app"' in toml_text | ||
| 77 | + # Nothing is scaffolded beside the project directory. | ||
| 78 | + assert os.listdir(str(tmp_path)) == ["my_app"] | ||
| 79 | + | ||
| 80 | + | ||
| 81 | + def test_cli_init_without_name_uses_cwd(tmp_path: Path) -> None: | ||
| 82 | + project_dir = tmp_path / "widgets" | ||
| 83 | + project_dir.mkdir() | ||
| 84 | + | ||
| 85 | + result = run_pn(["init"], str(project_dir)) | ||
| 86 | + assert result.returncode == 0, result.stderr | ||
| 87 | + assert "cd " not in result.stdout | ||
| 88 | + | ||
| 89 | + assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py")) | ||
| 90 | + assert os.path.isfile(os.path.join(str(project_dir), ".gitignore")) | ||
| 91 | + toml_text = Path(os.path.join(str(project_dir), "pythonnative.toml")).read_text(encoding="utf-8") | ||
| 92 | + assert 'id = "com.example.widgets"' in toml_text | ||
| 93 | + assert 'name = "widgets"' in toml_text | ||
| 94 | + | ||
| 95 | + | ||
| 96 | + def test_cli_init_refuses_non_empty_directory(tmp_path: Path) -> None: | ||
| 97 | + project_dir = tmp_path / "my_app" | ||
| 98 | + project_dir.mkdir() | ||
| 99 | + keeper = project_dir / "README.md" | ||
| 100 | + keeper.write_text("keep me\n", encoding="utf-8") | ||
| 101 | + | ||
| 102 | + result = run_pn(["init", "my_app"], str(tmp_path)) | ||
| 103 | + assert result.returncode != 0 | ||
| 104 | + assert "Refusing to overwrite" in result.stdout | ||
| 105 | + assert "non-empty directory" in result.stdout | ||
| 106 | + assert not os.path.exists(os.path.join(str(project_dir), "app")) | ||
| 107 | + assert keeper.read_text(encoding="utf-8") == "keep me\n" | ||
| 108 | + | ||
| 109 | + result = run_pn(["init", "my_app", "--force"], str(tmp_path)) | ||
| 110 | + assert result.returncode == 0, result.stderr | ||
| 111 | + assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py")) | ||
| 112 | + # --force scaffolds over the directory; it doesn't empty it first. | ||
| 113 | + assert keeper.read_text(encoding="utf-8") == "keep me\n" | ||
| 114 | + | ||
| 115 | + | ||
| 116 | + def test_cli_init_accepts_existing_empty_directory(tmp_path: Path) -> None: | ||
| 117 | + project_dir = tmp_path / "my_app" | ||
| 118 | + project_dir.mkdir() | ||
| 119 | + | ||
| 120 | + result = run_pn(["init", "my_app"], str(tmp_path)) | ||
| 121 | + assert result.returncode == 0, result.stderr | ||
| 122 | + assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py")) | ||
| 123 | + assert os.path.isfile(os.path.join(str(project_dir), "pythonnative.toml")) | ||
| 124 | + | ||
| 125 | + | ||
| 126 | + def test_cli_init_refuses_existing_file(tmp_path: Path) -> None: | ||
| 127 | + blocker = tmp_path / "my_app" | ||
| 128 | + blocker.write_text("not a project\n", encoding="utf-8") | ||
| 129 | + | ||
| 130 | + result = run_pn(["init", "my_app"], str(tmp_path)) | ||
| 131 | + assert result.returncode != 0 | ||
| 132 | + assert "Refusing to overwrite existing file" in result.stdout | ||
| 133 | + | ||
| 134 | + # --force can't turn a file into a directory, so it is refused too. | ||
| 135 | + result = run_pn(["init", "my_app", "--force"], str(tmp_path)) | ||
| 136 | + assert result.returncode != 0 | ||
| 137 | + assert "Refusing to overwrite existing file" in result.stdout | ||
| 138 | + assert blocker.read_text(encoding="utf-8") == "not a project\n" | ||
| 139 | + | ||
| 140 | + | ||
| 141 | + @pytest.mark.parametrize("name", ["{absolute}", "nested/my_app", "my_app/", ".", "..", "../", "a/.."]) | ||
| 142 | + def test_cli_init_rejects_path_like_names(tmp_path: Path, name: str) -> None: | ||
| 143 | + work_dir = tmp_path / "work" | ||
| 144 | + work_dir.mkdir() | ||
| 145 | + | ||
| 146 | + result = run_pn(["init", name.format(absolute=str(tmp_path / "elsewhere"))], str(work_dir)) | ||
| 147 | + assert result.returncode != 0 | ||
| 148 | + assert "Refusing to" in result.stdout | ||
| 149 | + assert "project name" in result.stdout | ||
| 150 | + # Nothing was created in the working directory or anywhere above it. | ||
| 151 | + assert os.listdir(str(work_dir)) == [] | ||
| 152 | + assert os.listdir(str(tmp_path)) == ["work"] | ||
| 153 | + | ||
| 154 | + | ||
| 155 | + def test_cli_init_force_does_not_escape_to_parent(tmp_path: Path) -> None: | ||
| 156 | + parent_config = tmp_path / "pythonnative.toml" | ||
| 157 | + parent_config.write_text("# hand-written\n", encoding="utf-8") | ||
| 158 | + work_dir = tmp_path / "work" | ||
| 159 | + work_dir.mkdir() | ||
| 160 | + | ||
| 161 | + result = run_pn(["init", "..", "--force"], str(work_dir)) | ||
| 162 | + assert result.returncode != 0 | ||
| 163 | + assert "Refusing to" in result.stdout | ||
| 164 | + # --force does not lift the single-name rule, so the parent is untouched. | ||
| 165 | + assert parent_config.read_text(encoding="utf-8") == "# hand-written\n" | ||
| 166 | + assert not os.path.exists(os.path.join(str(tmp_path), "app")) | ||
| 167 | + assert not os.path.exists(os.path.join(str(tmp_path), ".gitignore")) | ||
| 168 | + assert os.listdir(str(work_dir)) == [] | ||
| 169 | + | ||
| 170 | + | ||
| 171 | + @pytest.mark.parametrize("extra_args", [[], ["--force"]]) | ||
| 172 | + @pytest.mark.parametrize("populated", [True, False]) | ||
| 173 | + def test_cli_init_rejects_symlinked_target(tmp_path: Path, populated: bool, extra_args: List[str]) -> None: | ||
| 174 | + outside = tmp_path / "outside" | ||
| 175 | + outside.mkdir() | ||
| 176 | + outside_config = outside / "pythonnative.toml" | ||
| 177 | + if populated: | ||
| 178 | + outside_config.write_text("# hand-written\n", encoding="utf-8") | ||
| 179 | + before = sorted(os.listdir(str(outside))) | ||
| 180 | + | ||
| 181 | + work_dir = tmp_path / "work" | ||
| 182 | + work_dir.mkdir() | ||
| 183 | + os.symlink(str(outside), os.path.join(str(work_dir), "link")) | ||
| 184 | + | ||
| 185 | + result = run_pn(["init", "link"] + extra_args, str(work_dir)) | ||
| 186 | + assert result.returncode != 0 | ||
| 187 | + assert "Refusing to" in result.stdout | ||
| 188 | + # exists() and is_dir() follow symlinks, so the destination must stay untouched: | ||
| 189 | + # no new entries, and no rewrite of a config that was already there. | ||
| 190 | + assert sorted(os.listdir(str(outside))) == before | ||
| 191 | + if populated: | ||
| 192 | + assert outside_config.read_text(encoding="utf-8") == "# hand-written\n" | ||
| 193 | + assert os.listdir(str(work_dir)) == ["link"] | ||
| 194 | + | ||
| 195 | + | ||
| 65 | 196 | def test_cli_run_help_lists_flags() -> None: | |
| 66 | 197 | tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") | |
| 67 | 198 | try: | |
@@ -105,15 +236,16 @@ def test_cli_run_without_config_errors() -> None: | |||
| 105 | 236 | ||
| 106 | 237 | def test_cli_app_id_resolves(tmp_path: Path) -> None: | |
| 107 | 238 | assert run_pn(["init", "MyApp"], str(tmp_path)).returncode == 0 | |
| 108 | - result = run_pn(["app-id", "android"], str(tmp_path)) | ||
| 239 | + project_dir = str(tmp_path / "MyApp") | ||
| 240 | + result = run_pn(["app-id", "android"], project_dir) | ||
| 109 | 241 | assert result.returncode == 0, result.stderr | |
| 110 | 242 | assert result.stdout.strip() == "com.example.myapp" | |
| 111 | - assert run_pn(["app-id", "ios"], str(tmp_path)).stdout.strip() == "com.example.myapp" | ||
| 243 | + assert run_pn(["app-id", "ios"], project_dir).stdout.strip() == "com.example.myapp" | ||
| 112 | 244 | ||
| 113 | 245 | ||
| 114 | 246 | def test_cli_doctor_runs(tmp_path: Path) -> None: | |
| 115 | 247 | assert run_pn(["init", "MyApp"], str(tmp_path)).returncode == 0 | |
| 116 | - result = run_pn(["doctor", "android"], str(tmp_path)) | ||
| 248 | + result = run_pn(["doctor", "android"], str(tmp_path / "MyApp")) | ||
| 117 | 249 | assert "PythonNative doctor" in result.stdout | |
| 118 | 250 | # android-only doctor on a CI box without adb still produces warnings, not errors. | |
| 119 | 251 | assert result.returncode in (0, 1) | |
@@ -123,10 +255,11 @@ def test_cli_run_prepare_only_android_and_ios() -> None: | |||
| 123 | 255 | tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_") | |
| 124 | 256 | try: | |
| 125 | 257 | assert run_pn(["init", "MyApp"], tmpdir).returncode == 0 | |
| 258 | + project_dir = os.path.join(tmpdir, "MyApp") | ||
| 126 | 259 | ||
| 127 | - result = run_pn(["run", "android", "--prepare-only", "--no-logs"], tmpdir) | ||
| 260 | + result = run_pn(["run", "android", "--prepare-only", "--no-logs"], project_dir) | ||
| 128 | 261 | assert result.returncode == 0, result.stderr | |
| 129 | - android_root = os.path.join(tmpdir, "build", "android", "android_template") | ||
| 262 | + android_root = os.path.join(project_dir, "build", "android", "android_template") | ||
| 130 | 263 | assert os.path.isdir(android_root) | |
| 131 | 264 | # Package relocated to the configured application id. | |
| 132 | 265 | relocated = os.path.join( | |
@@ -140,9 +273,9 @@ def test_cli_run_prepare_only_android_and_ios() -> None: | |||
| 140 | 273 | gradle = Path(os.path.join(android_root, "app", "build.gradle")).read_text(encoding="utf-8") | |
| 141 | 274 | assert "com.example.myapp" in gradle | |
| 142 | 275 | ||
| 143 | - result = run_pn(["run", "ios", "--prepare-only", "--no-logs"], tmpdir) | ||
| 276 | + result = run_pn(["run", "ios", "--prepare-only", "--no-logs"], project_dir) | ||
| 144 | 277 | assert result.returncode == 0, result.stderr | |
| 145 | - ios_root = os.path.join(tmpdir, "build", "ios", "ios_template") | ||
| 278 | + ios_root = os.path.join(project_dir, "build", "ios", "ios_template") | ||
| 146 | 279 | assert os.path.isdir(ios_root) | |
| 147 | 280 | info_plist = Path(os.path.join(ios_root, "ios_template", "Info.plist")).read_bytes() | |
| 148 | 281 | assert b"CFBundleDisplayName" in info_plist | |
| Back | FazBrowse Home | New Git URL |
0 commit comments