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

feat(cli): create project directory in pn init <name> · pythonnative/pythonnative@0f68f82 · GitHub

Repository navigation

Commit 0f68f82

Browse files
feat(cli): create project directory in pn init <name>
pn init my_app now creates ./my_app/ and scaffolds into it, matching cargo new, create-react-app, and django-admin startproject, and matching what docs/examples.md already describes. pn init with no name is unchanged and still scaffolds into the current directory. The named form refuses to proceed when the target directory exists and is not empty, unless --force is passed, and always refuses when the target is a regular file since --force cannot resolve that. Both refusals keep the existing "Refusing to overwrite" prefix. The closing hint now leads with "cd my_app" when a name was given. Four existing tests assumed the old layout and now point their follow-up commands at the created subdirectory. One of them, test_cli_doctor_runs, was passing without exercising a scaffolded project at all; retargeting restores what it was written to cover. The name argument must be a single relative directory component, and the resolved target must be a direct child of the current directory. Before this change the name never touched the filesystem, so path-like names were harmless; now they would resolve elsewhere, and `pn init .. --force` or a symlink at ./<name> would overwrite files outside the current directory. Note that Path("..").name returns "..", so ".." is rejected by name rather than by the component check. --force lifts neither guard. Deliberately not addressed: any project-name charset restriction, which is a product decision; and TOML escaping for names containing quotes or control characters, which predates this change since name always flowed straight into the config.
1 parent b57586f commit 0f68f82

2 files changed

Lines changed: 204 additions & 25 deletions

File tree

‎src/pythonnative/cli/pn.py‎

Lines changed: 59 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
33
The console script `pn` (declared in `pyproject.toml`) dispatches to:
44
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.
68
- `pn doctor [platform]`: diagnose the local toolchain and config.
79
- `pn preview [component]`: render the app in a desktop (Tkinter) window
810
with Fast Refresh, the fast inner dev loop, no device required.
@@ -103,21 +105,62 @@ def _app_id_from_name(name: str) -> str:
103105

104106

105107
def init_project(args: argparse.Namespace) -> None:
106-
"""Scaffold a new PythonNative project in the current directory.
108+
"""Scaffold a new PythonNative project.
107109
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.
110125
111126
Args:
112127
args: Parsed namespace with ``name`` (optional) and ``force``.
113128
"""
114-
cwd = Path.cwd()
115-
project_name: str = getattr(args, "name", None) or cwd.name
129+
name: Optional[str] = getattr(args, "name", None)
116130
force: bool = getattr(args, "force", False)
117131

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)
121164

122165
if not force:
123166
existing = [
@@ -141,8 +184,11 @@ def init_project(args: argparse.Namespace) -> None:
141184
if force or not gitignore_path.exists():
142185
gitignore_path.write_text(_GITIGNORE, encoding="utf-8")
143186

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}")
146192

147193

148194
# ======================================================================
@@ -866,8 +912,8 @@ def _build_parser() -> argparse.ArgumentParser:
866912
subparsers = parser.add_subparsers()
867913

868914
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")
871917
parser_init.set_defaults(func=init_project)
872918

873919
parser_doctor = subparsers.add_parser("doctor", help="Diagnose the local toolchain and config")

‎tests/test_cli.py‎

Lines changed: 145 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -22,21 +22,22 @@ def test_cli_init_and_clean() -> None:
2222
try:
2323
result = run_pn(["init", "MyApp"], tmpdir)
2424
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"))
2627

27-
main_path = os.path.join(tmpdir, "app", "main.py")
28+
main_path = os.path.join(project_dir, "app", "main.py")
2829
assert os.path.isfile(main_path)
2930
content = Path(main_path).read_text(encoding="utf-8")
3031
assert "def App(" in content
3132
assert "Stack.Navigator" in content
3233

33-
config_path = os.path.join(tmpdir, "pythonnative.toml")
34+
config_path = os.path.join(project_dir, "pythonnative.toml")
3435
assert os.path.isfile(config_path)
3536
toml_text = Path(config_path).read_text(encoding="utf-8")
3637
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"))
3839
# 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"))
4041

4142
# clean on empty build is a no-op
4243
result = run_pn(["clean"], tmpdir)
@@ -62,6 +63,136 @@ def test_cli_init_refuses_overwrite() -> None:
6263
shutil.rmtree(tmpdir, ignore_errors=True)
6364

6465

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+
65196
def test_cli_run_help_lists_flags() -> None:
66197
tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_")
67198
try:
@@ -105,15 +236,16 @@ def test_cli_run_without_config_errors() -> None:
105236

106237
def test_cli_app_id_resolves(tmp_path: Path) -> None:
107238
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)
109241
assert result.returncode == 0, result.stderr
110242
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"
112244

113245

114246
def test_cli_doctor_runs(tmp_path: Path) -> None:
115247
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"))
117249
assert "PythonNative doctor" in result.stdout
118250
# android-only doctor on a CI box without adb still produces warnings, not errors.
119251
assert result.returncode in (0, 1)
@@ -123,10 +255,11 @@ def test_cli_run_prepare_only_android_and_ios() -> None:
123255
tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_")
124256
try:
125257
assert run_pn(["init", "MyApp"], tmpdir).returncode == 0
258+
project_dir = os.path.join(tmpdir, "MyApp")
126259

127-
result = run_pn(["run", "android", "--prepare-only", "--no-logs"], tmpdir)
260+
result = run_pn(["run", "android", "--prepare-only", "--no-logs"], project_dir)
128261
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")
130263
assert os.path.isdir(android_root)
131264
# Package relocated to the configured application id.
132265
relocated = os.path.join(
@@ -140,9 +273,9 @@ def test_cli_run_prepare_only_android_and_ios() -> None:
140273
gradle = Path(os.path.join(android_root, "app", "build.gradle")).read_text(encoding="utf-8")
141274
assert "com.example.myapp" in gradle
142275

143-
result = run_pn(["run", "ios", "--prepare-only", "--no-logs"], tmpdir)
276+
result = run_pn(["run", "ios", "--prepare-only", "--no-logs"], project_dir)
144277
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")
146279
assert os.path.isdir(ios_root)
147280
info_plist = Path(os.path.join(ios_root, "ios_template", "Info.plist")).read_bytes()
148281
assert b"CFBundleDisplayName" in info_plist

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL