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

feat(cli): create project directory in pn init <name> (#27) · pythonnative/pythonnative@5f84246 · GitHub

Repository navigation

Commit 5f84246

Browse files
feat(cli): create project directory in pn init <name> (#27)
Refs: #21
1 parent 390643a commit 5f84246

6 files changed

Lines changed: 242 additions & 28 deletions

File tree

‎CONTRIBUTING.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ pn --help
2525

2626
# create a new sample app from the bundled templates
2727
pn init my_app
28+
cd my_app
2829

2930
# run the Hello World example
3031
cd examples/hello-world && pn run android

‎docs/api/cli.md‎

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ the documented behavior never drifts from the code.
77
## Subcommands
88

99
- `pn init [name]`: scaffold a new project (creates `app/`,
10-
`pythonnative.toml`, `.gitignore`). Flag: `--force` to overwrite
11-
existing files. See [Configuration](../guides/configuration.md).
10+
`pythonnative.toml`, `.gitignore`). With a name it creates `./<name>/`
11+
and scaffolds into it; without one it uses the current directory.
12+
Flag: `--force` to overwrite existing files or scaffold into a
13+
non-empty directory. See [Configuration](../guides/configuration.md).
1214
- `pn doctor [android|ios]`: diagnose the local toolchain and validate
1315
`pythonnative.toml`. Exits non-zero when something will block a build.
1416
- `pn preview [component]`: render the app in a desktop (Tkinter) window

‎docs/getting-started.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,16 +9,20 @@ pn --help
99

1010
```bash
1111
pn init MyApp
12+
cd MyApp
1213
```
1314

14-
This scaffolds:
15+
This creates a `MyApp/` directory containing:
1516

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

23+
Run `pn init` without a name to scaffold into the current directory
24+
instead, named after it.
25+
2226
A minimal `app/main.py` looks like:
2327

2428
```python

‎docs/meta/troubleshooting.md‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,34 @@ is finding a different Python. Reactivate the venv or run
2828
`pn init` won't clobber existing project files. Pass `--force` or
2929
remove the listed files first.
3030

31+
### `Refusing to overwrite existing non-empty directory: my_app/`
32+
33+
`pn init my_app` creates `my_app/` and scaffolds into it, so it stops
34+
when that directory already holds files. Pass `--force` to scaffold over
35+
it, choose a different name, or run `pn init` from inside the directory
36+
to use it as is. An existing but empty directory is fine.
37+
38+
### `Refusing to overwrite existing file: my_app`
39+
40+
Something other than a directory is already at `./my_app`. `--force`
41+
won't help here, since `pn init` can't turn a file into a directory.
42+
Remove or rename it, or choose a different project name.
43+
44+
### `Refusing to treat a path as a project name: '../app'`
45+
46+
`pn init` takes a single directory name, not a path, so the project always
47+
lands inside the current directory. Pass a plain name like `my_app`, or
48+
`cd` to the directory you want the project in and run `pn init` with no
49+
name at all. `--force` doesn't lift this one.
50+
51+
### `Refusing to scaffold through a link or outside the current directory: link`
52+
53+
The name resolves somewhere other than a directory directly inside the
54+
current one, so `pn init` stops rather than writing through it. A symlink
55+
at `./link` is the usual cause. Scaffold into a real directory, or `cd` to
56+
the directory the link points at and run `pn init` with no name. `--force`
57+
doesn't lift this one either.
58+
3159
### `Do not list 'pythonnative' in [requirements].packages`
3260

3361
The CLI bundles the installed `pythonnative` package directly into

‎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.
@@ -104,21 +106,62 @@ def _app_id_from_name(name: str) -> str:
104106

105107

106108
def init_project(args: argparse.Namespace) -> None:
107-
"""Scaffold a new PythonNative project in the current directory.
109+
"""Scaffold a new PythonNative project.
108110
109-
Creates ``app/main.py``, ``pythonnative.toml``, and ``.gitignore``.
110-
Refuses to overwrite existing files unless ``--force`` is passed.
111+
Given a name, this creates ``./<name>/`` and scaffolds into it. Without
112+
one, it scaffolds into the current directory and names the project after
113+
it. Either way it writes ``app/main.py``, ``pythonnative.toml``, and
114+
``.gitignore``.
115+
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
119+
resolves somewhere else, such as a symlink to another directory.
120+
121+
It won't scaffold into a target directory that already holds files, and it
122+
won't overwrite any of the three paths above; pass ``--force`` to override
123+
both. An existing but empty target directory is fine. A plain file at
124+
``./<name>`` is always refused, since ``--force`` can't turn it into a
125+
directory, and ``--force`` lifts neither of the rules above.
111126
112127
Args:
113128
args: Parsed namespace with ``name`` (optional) and ``force``.
114129
"""
115-
cwd = Path.cwd()
116-
project_name: str = getattr(args, "name", None) or cwd.name
130+
name: Optional[str] = getattr(args, "name", None)
117131
force: bool = getattr(args, "force", False)
118132

119-
app_dir = cwd / "app"
120-
config_path = cwd / CONFIG_FILENAME
121-
gitignore_path = cwd / ".gitignore"
133+
# Lexical check, before anything reads the filesystem. ``Path("..").name``
134+
# is "..", so ".." needs naming explicitly; the rest (absolute, nested,
135+
# trailing separator, ".") fall out of the name check.
136+
if name and (name in (os.curdir, os.pardir) or Path(name).name != name):
137+
print(f"Refusing to treat a path as a project name: {name!r}. Use a single directory name like my_app.")
138+
sys.exit(1)
139+
140+
cwd = Path.cwd()
141+
target = cwd / name if name else cwd
142+
project_name: str = name or cwd.name
143+
144+
# A lexically clean name can still resolve elsewhere, and ``exists()`` and
145+
# ``is_dir()`` below follow symlinks. Check containment rather than just
146+
# ``is_symlink()`` so the whole class is closed, not one spelling of it.
147+
if name and (target.is_symlink() or target.resolve().parent != cwd.resolve()):
148+
print(
149+
f"Refusing to scaffold through a link or outside the current directory: {name}. "
150+
"Use a plain directory name."
151+
)
152+
sys.exit(1)
153+
154+
app_dir = target / "app"
155+
config_path = target / CONFIG_FILENAME
156+
gitignore_path = target / ".gitignore"
157+
158+
if name and target.exists():
159+
if not target.is_dir():
160+
print(f"Refusing to overwrite existing file: {name}. Remove it or choose a different name.")
161+
sys.exit(1)
162+
if any(target.iterdir()) and not force:
163+
print(f"Refusing to overwrite existing non-empty directory: {name}/. Use --force to overwrite.")
164+
sys.exit(1)
122165

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

145-
print(f"Initialized PythonNative project in {cwd}.")
146-
print("Next: pn preview (desktop) | pn run android | pn run ios")
188+
print(f"Initialized PythonNative project in {target}.")
189+
next_steps = "pn preview (desktop) | pn run android | pn run ios"
190+
if name:
191+
next_steps = f"cd {name} | {next_steps}"
192+
print(f"Next: {next_steps}")
147193

148194

149195
# ======================================================================
@@ -873,8 +919,8 @@ def _build_parser() -> argparse.ArgumentParser:
873919
subparsers = parser.add_subparsers()
874920

875921
parser_init = subparsers.add_parser("init", help="Scaffold a new project")
876-
parser_init.add_argument("name", nargs="?", help="Project name (defaults to current directory name)")
877-
parser_init.add_argument("--force", action="store_true", help="Overwrite existing files if present")
922+
parser_init.add_argument("name", nargs="?", help="Project name; creates ./<name>/ (default: current directory)")
923+
parser_init.add_argument("--force", action="store_true", help="Overwrite existing files or a non-empty directory")
878924
parser_init.set_defaults(func=init_project)
879925

880926
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
@@ -36,21 +36,22 @@ def test_cli_init_and_clean() -> None:
3636
try:
3737
result = run_pn(["init", "MyApp"], tmpdir)
3838
assert result.returncode == 0, result.stderr
39-
assert os.path.isdir(os.path.join(tmpdir, "app"))
39+
project_dir = os.path.join(tmpdir, "MyApp")
40+
assert os.path.isdir(os.path.join(project_dir, "app"))
4041

41-
main_path = os.path.join(tmpdir, "app", "main.py")
42+
main_path = os.path.join(project_dir, "app", "main.py")
4243
assert os.path.isfile(main_path)
4344
content = Path(main_path).read_text(encoding="utf-8")
4445
assert "def App(" in content
4546
assert "Stack.Navigator" in content
4647

47-
config_path = os.path.join(tmpdir, "pythonnative.toml")
48+
config_path = os.path.join(project_dir, "pythonnative.toml")
4849
assert os.path.isfile(config_path)
4950
toml_text = Path(config_path).read_text(encoding="utf-8")
5051
assert 'id = "com.example.myapp"' in toml_text
51-
assert os.path.isfile(os.path.join(tmpdir, ".gitignore"))
52+
assert os.path.isfile(os.path.join(project_dir, ".gitignore"))
5253
# The legacy JSON config and requirements.txt are no longer scaffolded.
53-
assert not os.path.exists(os.path.join(tmpdir, "pythonnative.json"))
54+
assert not os.path.exists(os.path.join(project_dir, "pythonnative.json"))
5455

5556
# clean on empty build is a no-op
5657
result = run_pn(["clean"], tmpdir)
@@ -76,6 +77,136 @@ def test_cli_init_refuses_overwrite() -> None:
7677
shutil.rmtree(tmpdir, ignore_errors=True)
7778

7879

80+
def test_cli_init_creates_named_directory(tmp_path: Path) -> None:
81+
result = run_pn(["init", "my_app"], str(tmp_path))
82+
assert result.returncode == 0, result.stderr
83+
assert "cd my_app" in result.stdout
84+
85+
project_dir = os.path.join(str(tmp_path), "my_app")
86+
assert os.path.isfile(os.path.join(project_dir, "app", "main.py"))
87+
assert os.path.isfile(os.path.join(project_dir, "pythonnative.toml"))
88+
assert os.path.isfile(os.path.join(project_dir, ".gitignore"))
89+
toml_text = Path(os.path.join(project_dir, "pythonnative.toml")).read_text(encoding="utf-8")
90+
assert 'id = "com.example.my_app"' in toml_text
91+
# Nothing is scaffolded beside the project directory.
92+
assert os.listdir(str(tmp_path)) == ["my_app"]
93+
94+
95+
def test_cli_init_without_name_uses_cwd(tmp_path: Path) -> None:
96+
project_dir = tmp_path / "widgets"
97+
project_dir.mkdir()
98+
99+
result = run_pn(["init"], str(project_dir))
100+
assert result.returncode == 0, result.stderr
101+
assert "cd " not in result.stdout
102+
103+
assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py"))
104+
assert os.path.isfile(os.path.join(str(project_dir), ".gitignore"))
105+
toml_text = Path(os.path.join(str(project_dir), "pythonnative.toml")).read_text(encoding="utf-8")
106+
assert 'id = "com.example.widgets"' in toml_text
107+
assert 'name = "widgets"' in toml_text
108+
109+
110+
def test_cli_init_refuses_non_empty_directory(tmp_path: Path) -> None:
111+
project_dir = tmp_path / "my_app"
112+
project_dir.mkdir()
113+
keeper = project_dir / "README.md"
114+
keeper.write_text("keep me\n", encoding="utf-8")
115+
116+
result = run_pn(["init", "my_app"], str(tmp_path))
117+
assert result.returncode != 0
118+
assert "Refusing to overwrite" in result.stdout
119+
assert "non-empty directory" in result.stdout
120+
assert not os.path.exists(os.path.join(str(project_dir), "app"))
121+
assert keeper.read_text(encoding="utf-8") == "keep me\n"
122+
123+
result = run_pn(["init", "my_app", "--force"], str(tmp_path))
124+
assert result.returncode == 0, result.stderr
125+
assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py"))
126+
# --force scaffolds over the directory; it doesn't empty it first.
127+
assert keeper.read_text(encoding="utf-8") == "keep me\n"
128+
129+
130+
def test_cli_init_accepts_existing_empty_directory(tmp_path: Path) -> None:
131+
project_dir = tmp_path / "my_app"
132+
project_dir.mkdir()
133+
134+
result = run_pn(["init", "my_app"], str(tmp_path))
135+
assert result.returncode == 0, result.stderr
136+
assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py"))
137+
assert os.path.isfile(os.path.join(str(project_dir), "pythonnative.toml"))
138+
139+
140+
def test_cli_init_refuses_existing_file(tmp_path: Path) -> None:
141+
blocker = tmp_path / "my_app"
142+
blocker.write_text("not a project\n", encoding="utf-8")
143+
144+
result = run_pn(["init", "my_app"], str(tmp_path))
145+
assert result.returncode != 0
146+
assert "Refusing to overwrite existing file" in result.stdout
147+
148+
# --force can't turn a file into a directory, so it is refused too.
149+
result = run_pn(["init", "my_app", "--force"], str(tmp_path))
150+
assert result.returncode != 0
151+
assert "Refusing to overwrite existing file" in result.stdout
152+
assert blocker.read_text(encoding="utf-8") == "not a project\n"
153+
154+
155+
@pytest.mark.parametrize("name", ["{absolute}", "nested/my_app", "my_app/", ".", "..", "../", "a/.."])
156+
def test_cli_init_rejects_path_like_names(tmp_path: Path, name: str) -> None:
157+
work_dir = tmp_path / "work"
158+
work_dir.mkdir()
159+
160+
result = run_pn(["init", name.format(absolute=str(tmp_path / "elsewhere"))], str(work_dir))
161+
assert result.returncode != 0
162+
assert "Refusing to" in result.stdout
163+
assert "project name" in result.stdout
164+
# Nothing was created in the working directory or anywhere above it.
165+
assert os.listdir(str(work_dir)) == []
166+
assert os.listdir(str(tmp_path)) == ["work"]
167+
168+
169+
def test_cli_init_force_does_not_escape_to_parent(tmp_path: Path) -> None:
170+
parent_config = tmp_path / "pythonnative.toml"
171+
parent_config.write_text("# hand-written\n", encoding="utf-8")
172+
work_dir = tmp_path / "work"
173+
work_dir.mkdir()
174+
175+
result = run_pn(["init", "..", "--force"], str(work_dir))
176+
assert result.returncode != 0
177+
assert "Refusing to" in result.stdout
178+
# --force does not lift the single-name rule, so the parent is untouched.
179+
assert parent_config.read_text(encoding="utf-8") == "# hand-written\n"
180+
assert not os.path.exists(os.path.join(str(tmp_path), "app"))
181+
assert not os.path.exists(os.path.join(str(tmp_path), ".gitignore"))
182+
assert os.listdir(str(work_dir)) == []
183+
184+
185+
@pytest.mark.parametrize("extra_args", [[], ["--force"]])
186+
@pytest.mark.parametrize("populated", [True, False])
187+
def test_cli_init_rejects_symlinked_target(tmp_path: Path, populated: bool, extra_args: List[str]) -> None:
188+
outside = tmp_path / "outside"
189+
outside.mkdir()
190+
outside_config = outside / "pythonnative.toml"
191+
if populated:
192+
outside_config.write_text("# hand-written\n", encoding="utf-8")
193+
before = sorted(os.listdir(str(outside)))
194+
195+
work_dir = tmp_path / "work"
196+
work_dir.mkdir()
197+
os.symlink(str(outside), os.path.join(str(work_dir), "link"))
198+
199+
result = run_pn(["init", "link"] + extra_args, str(work_dir))
200+
assert result.returncode != 0
201+
assert "Refusing to" in result.stdout
202+
# exists() and is_dir() follow symlinks, so the destination must stay untouched:
203+
# no new entries, and no rewrite of a config that was already there.
204+
assert sorted(os.listdir(str(outside))) == before
205+
if populated:
206+
assert outside_config.read_text(encoding="utf-8") == "# hand-written\n"
207+
assert os.listdir(str(work_dir)) == ["link"]
208+
209+
79210
def test_cli_run_help_lists_flags() -> None:
80211
tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_")
81212
try:
@@ -119,15 +250,16 @@ def test_cli_run_without_config_errors() -> None:
119250

120251
def test_cli_app_id_resolves(tmp_path: Path) -> None:
121252
assert run_pn(["init", "MyApp"], str(tmp_path)).returncode == 0
122-
result = run_pn(["app-id", "android"], str(tmp_path))
253+
project_dir = str(tmp_path / "MyApp")
254+
result = run_pn(["app-id", "android"], project_dir)
123255
assert result.returncode == 0, result.stderr
124256
assert result.stdout.strip() == "com.example.myapp"
125-
assert run_pn(["app-id", "ios"], str(tmp_path)).stdout.strip() == "com.example.myapp"
257+
assert run_pn(["app-id", "ios"], project_dir).stdout.strip() == "com.example.myapp"
126258

127259

128260
def test_cli_doctor_runs(tmp_path: Path) -> None:
129261
assert run_pn(["init", "MyApp"], str(tmp_path)).returncode == 0
130-
result = run_pn(["doctor", "android"], str(tmp_path))
262+
result = run_pn(["doctor", "android"], str(tmp_path / "MyApp"))
131263
assert "PythonNative doctor" in result.stdout
132264
# android-only doctor on a CI box without adb still produces warnings, not errors.
133265
assert result.returncode in (0, 1)
@@ -137,10 +269,11 @@ def test_cli_run_prepare_only_android_and_ios() -> None:
137269
tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_")
138270
try:
139271
assert run_pn(["init", "MyApp"], tmpdir).returncode == 0
272+
project_dir = os.path.join(tmpdir, "MyApp")
140273

141-
result = run_pn(["run", "android", "--prepare-only", "--no-logs"], tmpdir)
274+
result = run_pn(["run", "android", "--prepare-only", "--no-logs"], project_dir)
142275
assert result.returncode == 0, result.stderr
143-
android_root = os.path.join(tmpdir, "build", "android", "android_template")
276+
android_root = os.path.join(project_dir, "build", "android", "android_template")
144277
assert os.path.isdir(android_root)
145278
# Package relocated to the configured application id.
146279
relocated = os.path.join(
@@ -154,9 +287,9 @@ def test_cli_run_prepare_only_android_and_ios() -> None:
154287
gradle = Path(os.path.join(android_root, "app", "build.gradle")).read_text(encoding="utf-8")
155288
assert "com.example.myapp" in gradle
156289

157-
result = run_pn(["run", "ios", "--prepare-only", "--no-logs"], tmpdir)
290+
result = run_pn(["run", "ios", "--prepare-only", "--no-logs"], project_dir)
158291
assert result.returncode == 0, result.stderr
159-
ios_root = os.path.join(tmpdir, "build", "ios", "ios_template")
292+
ios_root = os.path.join(project_dir, "build", "ios", "ios_template")
160293
assert os.path.isdir(ios_root)
161294
info_plist = Path(os.path.join(ios_root, "ios_template", "Info.plist")).read_bytes()
162295
assert b"CFBundleDisplayName" in info_plist

0 commit comments

Comments
 (0)

Back | FazBrowse Home | New Git URL