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

feat(cli): create project directory in pn init <name> by Adebowale-Morakinyo · Pull Request #27 · 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  (4) .py  (2) 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
1 change: 1 addition & 0 deletions CONTRIBUTING.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 @@ -25,6 +25,7 @@ pn --help

# create a new sample app from the bundled templates
pn init my_app
cd my_app

# run the Hello World example
cd examples/hello-world && pn run android
Expand Down
6 changes: 4 additions & 2 deletions 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 @@ -7,8 +7,10 @@ the documented behavior never drifts from the code.
## Subcommands

- `pn init [name]`: scaffold a new project (creates `app/`,
`pythonnative.toml`, `.gitignore`). Flag: `--force` to overwrite
existing files. See [Configuration](../guides/configuration.md).
`pythonnative.toml`, `.gitignore`). With a name it creates `./<name>/`
and scaffolds into it; without one it uses the current directory.
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
`pythonnative.toml`. Exits non-zero when something will block a build.
- `pn preview [component]`: render the app in a desktop (Tkinter) window
Expand Down
6 changes: 5 additions & 1 deletion 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 @@ -9,16 +9,20 @@ pn --help

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

This scaffolds:
This creates a `MyApp/` 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 minimal `app/main.py` looks like:

```python
Expand Down
28 changes: 28 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 @@ -28,6 +28,34 @@ is finding a different Python. Reactivate the venv or run
`pn init` won't clobber existing project files. Pass `--force` or
remove the listed files first.

### `Refusing to overwrite existing non-empty directory: my_app/`

`pn init my_app` creates `my_app/` and scaffolds into it, so it stops
when that directory already holds files. Pass `--force` to scaffold over
it, choose a different name, or run `pn init` from inside the directory
to use it as is. An existing but empty directory is fine.

### `Refusing to overwrite existing file: my_app`

Something other than a directory is already at `./my_app`. `--force`
won't help here, since `pn init` can't turn a file into a directory.
Remove or rename it, or choose a different project name.

### `Refusing to treat a path as a project name: '../app'`

`pn init` takes a single directory name, not a path, so the project always
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.

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

The name resolves somewhere other than a directory directly inside the
current one, so `pn init` stops rather than writing through it. A symlink
at `./link` is the usual cause. Scaffold into a real directory, or `cd` to
the directory the link points at and run `pn init` with no name. `--force`
doesn't lift this one either.

### `Do not list 'pythonnative' in [requirements].packages`

The CLI bundles the installed `pythonnative` package directly into
Expand Down
72 changes: 59 additions & 13 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 @@ -2,7 +2,9 @@

The console script `pn` (declared in `pyproject.toml`) dispatches to:

- `pn init [name]`: scaffold a new project (``pythonnative.toml`` + ``app/``).
- `pn init [name]`: scaffold a new project (``pythonnative.toml`` +
``app/``) into ``./name/``, or into the current directory when no name
is given.
- `pn doctor [platform]`: diagnose the local toolchain and config.
- `pn preview [component]`: render the app in a desktop (Tkinter) window
with Fast Refresh, the fast inner dev loop, no device required.
Expand Down Expand Up @@ -103,21 +105,62 @@ def _app_id_from_name(name: str) -> str:


def init_project(args: argparse.Namespace) -> None:
"""Scaffold a new PythonNative project in the current directory.
"""Scaffold a new PythonNative project.

Creates ``app/main.py``, ``pythonnative.toml``, and ``.gitignore``.
Refuses to overwrite existing files unless ``--force`` is passed.
Given a name, this creates ``./<name>/`` and scaffolds into it. Without
one, it scaffolds into the current directory and names the project after
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
resolves somewhere else, such as a symlink to another directory.

It won't scaffold into a target directory that already holds files, and it
won't overwrite any of the three paths above; pass ``--force`` to override
both. An existing but empty target directory is fine. A plain file at
``./<name>`` is always refused, since ``--force`` can't turn it into a
directory, and ``--force`` lifts neither of the rules above.

Args:
args: Parsed namespace with ``name`` (optional) and ``force``.
"""
cwd = Path.cwd()
project_name: str = getattr(args, "name", None) or cwd.name
name: Optional[str] = getattr(args, "name", None)
force: bool = getattr(args, "force", False)

app_dir = cwd / "app"
config_path = cwd / CONFIG_FILENAME
gitignore_path = cwd / ".gitignore"
# Lexical check, before anything reads the filesystem. ``Path("..").name``
# is "..", so ".." needs naming explicitly; the rest (absolute, nested,
# trailing separator, ".") fall out of the name check.
if name and (name in (os.curdir, os.pardir) or Path(name).name != name):
print(f"Refusing to treat a path as a project name: {name!r}. Use a single directory name like my_app.")
sys.exit(1)

cwd = Path.cwd()
target = cwd / name if name else cwd
project_name: str = name or cwd.name

# A lexically clean name can still resolve elsewhere, and ``exists()`` and
# ``is_dir()`` below follow symlinks. Check containment rather than just
# ``is_symlink()`` so the whole class is closed, not one spelling of it.
if name and (target.is_symlink() or target.resolve().parent != cwd.resolve()):
print(
f"Refusing to scaffold through a link or outside the current directory: {name}. "
"Use a plain directory name."
)
sys.exit(1)

app_dir = target / "app"
config_path = target / CONFIG_FILENAME
gitignore_path = target / ".gitignore"

if name and target.exists():
if not target.is_dir():
print(f"Refusing to overwrite existing file: {name}. Remove it or choose a different name.")
sys.exit(1)
if any(target.iterdir()) and not force:
print(f"Refusing to overwrite existing non-empty directory: {name}/. Use --force to overwrite.")
sys.exit(1)

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

print(f"Initialized PythonNative project in {cwd}.")
print("Next: pn preview (desktop) | pn run android | pn run ios")
print(f"Initialized PythonNative project in {target}.")
next_steps = "pn preview (desktop) | pn run android | pn run ios"
if name:
next_steps = f"cd {name} | {next_steps}"
print(f"Next: {next_steps}")


# ======================================================================
Expand Down Expand Up @@ -866,8 +912,8 @@ 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 (defaults to current directory name)")
parser_init.add_argument("--force", action="store_true", help="Overwrite existing files if present")
parser_init.add_argument("name", nargs="?", help="Project name; 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)

parser_doctor = subparsers.add_parser("doctor", help="Diagnose the local toolchain and config")
Expand Down
157 changes: 145 additions & 12 deletions tests/test_cli.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 @@ -22,21 +22,22 @@ def test_cli_init_and_clean() -> None:
try:
result = run_pn(["init", "MyApp"], tmpdir)
assert result.returncode == 0, result.stderr
assert os.path.isdir(os.path.join(tmpdir, "app"))
project_dir = os.path.join(tmpdir, "MyApp")
assert os.path.isdir(os.path.join(project_dir, "app"))

main_path = os.path.join(tmpdir, "app", "main.py")
main_path = os.path.join(project_dir, "app", "main.py")
assert os.path.isfile(main_path)
content = Path(main_path).read_text(encoding="utf-8")
assert "def App(" in content
assert "Stack.Navigator" in content

config_path = os.path.join(tmpdir, "pythonnative.toml")
config_path = os.path.join(project_dir, "pythonnative.toml")
assert os.path.isfile(config_path)
toml_text = Path(config_path).read_text(encoding="utf-8")
assert 'id = "com.example.myapp"' in toml_text
assert os.path.isfile(os.path.join(tmpdir, ".gitignore"))
assert os.path.isfile(os.path.join(project_dir, ".gitignore"))
# The legacy JSON config and requirements.txt are no longer scaffolded.
assert not os.path.exists(os.path.join(tmpdir, "pythonnative.json"))
assert not os.path.exists(os.path.join(project_dir, "pythonnative.json"))

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


def test_cli_init_creates_named_directory(tmp_path: Path) -> None:
result = run_pn(["init", "my_app"], str(tmp_path))
assert result.returncode == 0, result.stderr
assert "cd my_app" in result.stdout

project_dir = os.path.join(str(tmp_path), "my_app")
assert os.path.isfile(os.path.join(project_dir, "app", "main.py"))
assert os.path.isfile(os.path.join(project_dir, "pythonnative.toml"))
assert os.path.isfile(os.path.join(project_dir, ".gitignore"))
toml_text = Path(os.path.join(project_dir, "pythonnative.toml")).read_text(encoding="utf-8")
assert 'id = "com.example.my_app"' in toml_text
# Nothing is scaffolded beside the project directory.
assert os.listdir(str(tmp_path)) == ["my_app"]


def test_cli_init_without_name_uses_cwd(tmp_path: Path) -> None:
project_dir = tmp_path / "widgets"
project_dir.mkdir()

result = run_pn(["init"], str(project_dir))
assert result.returncode == 0, result.stderr
assert "cd " not in result.stdout

assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py"))
assert os.path.isfile(os.path.join(str(project_dir), ".gitignore"))
toml_text = Path(os.path.join(str(project_dir), "pythonnative.toml")).read_text(encoding="utf-8")
assert 'id = "com.example.widgets"' in toml_text
assert 'name = "widgets"' in toml_text


def test_cli_init_refuses_non_empty_directory(tmp_path: Path) -> None:
project_dir = tmp_path / "my_app"
project_dir.mkdir()
keeper = project_dir / "README.md"
keeper.write_text("keep me\n", encoding="utf-8")

result = run_pn(["init", "my_app"], str(tmp_path))
assert result.returncode != 0
assert "Refusing to overwrite" in result.stdout
assert "non-empty directory" in result.stdout
assert not os.path.exists(os.path.join(str(project_dir), "app"))
assert keeper.read_text(encoding="utf-8") == "keep me\n"

result = run_pn(["init", "my_app", "--force"], str(tmp_path))
assert result.returncode == 0, result.stderr
assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py"))
# --force scaffolds over the directory; it doesn't empty it first.
assert keeper.read_text(encoding="utf-8") == "keep me\n"


def test_cli_init_accepts_existing_empty_directory(tmp_path: Path) -> None:
project_dir = tmp_path / "my_app"
project_dir.mkdir()

result = run_pn(["init", "my_app"], str(tmp_path))
assert result.returncode == 0, result.stderr
assert os.path.isfile(os.path.join(str(project_dir), "app", "main.py"))
assert os.path.isfile(os.path.join(str(project_dir), "pythonnative.toml"))


def test_cli_init_refuses_existing_file(tmp_path: Path) -> None:
blocker = tmp_path / "my_app"
blocker.write_text("not a project\n", encoding="utf-8")

result = run_pn(["init", "my_app"], str(tmp_path))
assert result.returncode != 0
assert "Refusing to overwrite existing file" in result.stdout

# --force can't turn a file into a directory, so it is refused too.
result = run_pn(["init", "my_app", "--force"], str(tmp_path))
assert result.returncode != 0
assert "Refusing to overwrite existing file" in result.stdout
assert blocker.read_text(encoding="utf-8") == "not a project\n"


@pytest.mark.parametrize("name", ["{absolute}", "nested/my_app", "my_app/", ".", "..", "../", "a/.."])
def test_cli_init_rejects_path_like_names(tmp_path: Path, name: str) -> None:
work_dir = tmp_path / "work"
work_dir.mkdir()

result = run_pn(["init", name.format(absolute=str(tmp_path / "elsewhere"))], str(work_dir))
assert result.returncode != 0
assert "Refusing to" in result.stdout
assert "project name" in result.stdout
# Nothing was created in the working directory or anywhere above it.
assert os.listdir(str(work_dir)) == []
assert os.listdir(str(tmp_path)) == ["work"]


def test_cli_init_force_does_not_escape_to_parent(tmp_path: Path) -> None:
parent_config = tmp_path / "pythonnative.toml"
parent_config.write_text("# hand-written\n", encoding="utf-8")
work_dir = tmp_path / "work"
work_dir.mkdir()

result = run_pn(["init", "..", "--force"], str(work_dir))
assert result.returncode != 0
assert "Refusing to" in result.stdout
# --force does not lift the single-name rule, so the parent is untouched.
assert parent_config.read_text(encoding="utf-8") == "# hand-written\n"
assert not os.path.exists(os.path.join(str(tmp_path), "app"))
assert not os.path.exists(os.path.join(str(tmp_path), ".gitignore"))
assert os.listdir(str(work_dir)) == []


@pytest.mark.parametrize("extra_args", [[], ["--force"]])
@pytest.mark.parametrize("populated", [True, False])
def test_cli_init_rejects_symlinked_target(tmp_path: Path, populated: bool, extra_args: List[str]) -> None:
outside = tmp_path / "outside"
outside.mkdir()
outside_config = outside / "pythonnative.toml"
if populated:
outside_config.write_text("# hand-written\n", encoding="utf-8")
before = sorted(os.listdir(str(outside)))

work_dir = tmp_path / "work"
work_dir.mkdir()
os.symlink(str(outside), os.path.join(str(work_dir), "link"))

result = run_pn(["init", "link"] + extra_args, str(work_dir))
assert result.returncode != 0
assert "Refusing to" in result.stdout
# exists() and is_dir() follow symlinks, so the destination must stay untouched:
# no new entries, and no rewrite of a config that was already there.
assert sorted(os.listdir(str(outside))) == before
if populated:
assert outside_config.read_text(encoding="utf-8") == "# hand-written\n"
assert os.listdir(str(work_dir)) == ["link"]


def test_cli_run_help_lists_flags() -> None:
tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_")
try:
Expand Down Expand Up @@ -105,15 +236,16 @@ def test_cli_run_without_config_errors() -> None:

def test_cli_app_id_resolves(tmp_path: Path) -> None:
assert run_pn(["init", "MyApp"], str(tmp_path)).returncode == 0
result = run_pn(["app-id", "android"], str(tmp_path))
project_dir = str(tmp_path / "MyApp")
result = run_pn(["app-id", "android"], project_dir)
assert result.returncode == 0, result.stderr
assert result.stdout.strip() == "com.example.myapp"
assert run_pn(["app-id", "ios"], str(tmp_path)).stdout.strip() == "com.example.myapp"
assert run_pn(["app-id", "ios"], project_dir).stdout.strip() == "com.example.myapp"


def test_cli_doctor_runs(tmp_path: Path) -> None:
assert run_pn(["init", "MyApp"], str(tmp_path)).returncode == 0
result = run_pn(["doctor", "android"], str(tmp_path))
result = run_pn(["doctor", "android"], str(tmp_path / "MyApp"))
assert "PythonNative doctor" in result.stdout
# android-only doctor on a CI box without adb still produces warnings, not errors.
assert result.returncode in (0, 1)
Expand All @@ -123,10 +255,11 @@ def test_cli_run_prepare_only_android_and_ios() -> None:
tmpdir = tempfile.mkdtemp(prefix="pn_cli_test_")
try:
assert run_pn(["init", "MyApp"], tmpdir).returncode == 0
project_dir = os.path.join(tmpdir, "MyApp")

result = run_pn(["run", "android", "--prepare-only", "--no-logs"], tmpdir)
result = run_pn(["run", "android", "--prepare-only", "--no-logs"], project_dir)
assert result.returncode == 0, result.stderr
android_root = os.path.join(tmpdir, "build", "android", "android_template")
android_root = os.path.join(project_dir, "build", "android", "android_template")
assert os.path.isdir(android_root)
# Package relocated to the configured application id.
relocated = os.path.join(
Expand All @@ -140,9 +273,9 @@ def test_cli_run_prepare_only_android_and_ios() -> None:
gradle = Path(os.path.join(android_root, "app", "build.gradle")).read_text(encoding="utf-8")
assert "com.example.myapp" in gradle

result = run_pn(["run", "ios", "--prepare-only", "--no-logs"], tmpdir)
result = run_pn(["run", "ios", "--prepare-only", "--no-logs"], project_dir)
assert result.returncode == 0, result.stderr
ios_root = os.path.join(tmpdir, "build", "ios", "ios_template")
ios_root = os.path.join(project_dir, "build", "ios", "ios_template")
assert os.path.isdir(ios_root)
info_plist = Path(os.path.join(ios_root, "ios_template", "Info.plist")).read_bytes()
assert b"CFBundleDisplayName" in info_plist
Expand Down
Loading

Back | FazBrowse Home | New Git URL