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

feat(cli): create project directory in pn init <name> - #27

Merged
owenthcarey merged 2 commits into
pythonnative:mainfrom
Adebowale-Morakinyo:feat/cli-init-project-dir
Aug 29, 2026
Merged

owenthcarey merged 2 commits into
pythonnative:mainfrom
Adebowale-Morakinyo:feat/cli-init-project-dir

Conversation

Adebowale-Morakinyo commented Aug 29, 2026 •
edited
Loading

Copy link
Copy Markdown
Contributor

What

  • pn init my_app now creates ./my_app/ and scaffolds app/main.py,
    pythonnative.toml, and .gitignore into it.
  • pn init with no name is unchanged: it scaffolds into the current directory,
    named after it.
  • The named form refuses when the target directory exists and is not empty,
    unless --force is passed. An existing but empty directory is fine.
  • A plain file at ./my_app is always refused, with or without --force.
  • The closing hint leads with cd my_app when a name was given.

Why

Closes #21. Every comparable scaffolder (cargo new, create-react-app,
django-admin startproject) creates the directory, and docs/examples.md
already told people to cd my-app after pn init my-app, which didn't work.
Newcomers were surprised to find their working directory filled in.

How (brief)

init_project() resolves a target directory up front and points app/,
pythonnative.toml, and .gitignore at it; everything downstream is
unchanged. Four guards run before any filesystem work:

  • the name must be a single directory component, so a path can't be passed as
    a project name;
  • the resolved target must be a direct child of the current directory, which
    refuses a symlink at ./<name> pointing elsewhere;
  • a plain file at ./<name> is refused, since --force can't turn a file
    into a directory;
  • a non-empty target directory is refused unless --force is passed.

--force lifts only the last of these. Every refusal keeps the CLI's existing
Refusing to ... phrasing.

The first two guards deserve a note. Before this change the name argument never
touched the filesystem, so path-like names were harmless: they only fed the
project name and the app id. Now the name resolves to a path, which means
pn init .. --force, or a symlink at ./<name>, would write outside the
current directory. Both are closed here rather than left for a follow-up, since
this change is what introduces the exposure. Worth knowing that Path("..").name
returns "..", so ".." is rejected by name rather than by the component check.

target.mkdir() is never called: app_dir.mkdir(parents=True) already creates
the parent. No new helpers, imports, or dependencies.

Testing

./scripts/check.sh passes locally (Ruff, Black, MyPy, build, pytest, E2E
coverage), and mkdocs build --strict is clean.

New tests in tests/test_cli.py cover the named form creating and populating
./my_app/ with the cwd otherwise untouched, the no-name form scaffolding into
the cwd with a deterministic derived id, the non-empty refusal including that
--force scaffolds over rather than wiping, an existing empty target
succeeding without --force, an existing file refused both ways, path-like
names across seven forms, and symlinked targets with and without --force.

The traversal and symlink tests are mutation-checked: with the relevant guard
removed they fail, and the escape assertions compare the external file
byte-for-byte rather than only counting directory entries, so they pin the
overwrite rather than a side effect of it.

Five existing tests assumed the old layout and now point their follow-up
commands at the created subdirectory, keeping the MyApp argument so the
com.example.myapp derivation stays covered. One of them,
test_cli_doctor_runs, was passing without exercising a scaffolded project at
all, since pn doctor prints its banner and exits 1 even with no config;
retargeting restores what it was written to cover.
test_cli_init_refuses_overwrite needed no change and is untouched.

Risks/Impact

Behavior change for anyone scripting pn init <name> and expecting output in
the current directory: artifacts now land in ./<name>/, so follow-up commands
need a cd or a path. The no-name form is a drop-in.

Docs/Follow-ups

Updated docs/getting-started.md, docs/api/cli.md (both the hand-written
bullet and the mkdocstrings-rendered docstring), CONTRIBUTING.md, and
docs/meta/troubleshooting.md with an entry per new message, following that
file's convention. docs/examples.md needed no change; it already described
this behavior.

Two things I'd rather you(@owenthcarey) decide than assume:

  • A trailing separator (pn init my_app/) is rejected by the single-component
    check. Normalizing it would add ambiguity for an argument documented as a
    project name, so I left it strict.
  • pn init "my app" passes validation but prints a cd my app hint that won't
    work. Happy to reject such names or quote the hint if you'd prefer.

Separately, and out of scope here: a project name containing a quote or a
control character isn't escaped when written into pythonnative.toml. That
predates this change, since the name always flowed straight into the config.
I can open an issue if it's useful.

Closes #21

Demo

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.
The getting-started walkthrough and the CONTRIBUTING snippet both left
you outside the project they had just created, and the CLI reference
bullet didn't say where a named project lands. Documents the two new
refusal messages in troubleshooting, following that file's convention of
one heading per literal error string.
owenthcarey merged commit 5f84246 into pythonnative:main Aug 29, 2026
17 checks passed

Copy link
Copy Markdown
Contributor

Thanks for a great first contribution. The guards, tests, and docs all went beyond what the issue asked for. Merged as v0.30.0.

On your questions: agreed on keeping the trailing separator strict, and pn init "my app" is fine as shipped. If you want a small follow-up, quoting the hint would be welcome.

And yes, please open the issue for the unescaped name in pythonnative.toml.

Copy link
Copy Markdown
Contributor Author

Thanks for a great first contribution. The guards, tests, and docs all went beyond what the issue asked for. Merged as v0.30.0.

On your questions: agreed on keeping the trailing separator strict, and pn init "my app" is fine as shipped. If you want a small follow-up, quoting the hint would be welcome.

And yes, please open the issue for the unescaped name in pythonnative.toml.

Got it, thanks!

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
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make pn init <name> create the project directory

2 participants


Back | FazBrowse Home | New Git URL