Skip to content

Commit

Permalink
feat: pdm init template argument (#2053)
Browse files Browse the repository at this point in the history
  • Loading branch information
frostming authored Jun 27, 2023
1 parent c4f4a68 commit 5149924
Show file tree
Hide file tree
Showing 28 changed files with 654 additions and 215 deletions.
1 change: 1 addition & 0 deletions docs/docs/usage/project.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ pdm init
```

You will need to answer a few questions, to help PDM to create a `pyproject.toml` file for you.
For more usages of `pdm init`, please read [Create your project from a template](./template.md).

## Choose a Python interpreter

Expand Down
35 changes: 35 additions & 0 deletions docs/docs/usage/template.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Create Project From a Template

Similar to `yarn create` and `npm create`, PDM also supports initializing or creating a project from a template.
The template is given as a positional argument of `pdm init`, in one of the following forms:

- `pdm init flask` - Initialize the project from the template `https://github.com/pdm-project/template-flask`
- `pdm init https://github.com/frostming/pdm-template-flask` - Initialize the project from a Git URL. Both HTTPS and SSH URL are acceptable.
- `pdm init /path/to/template` - Initialize the project from a template directory on local filesystem.

And `pdm init` will use the default template built in.

The project will be initialized at the current directory, existing files with the same name will be overwritten. You can also use the `-p <path>` option to create a project at a new path.

## Contribute a template

According to the first form of the template argument, `pdm init <name>` will refer to the template repository located at `https://github.com/pdm-project/template-<name>`. To contribute a template, you can create a template repository and establish a request to transfer the
ownership to `pdm-project` organization(it can be found at the bottom of the repository settings page). The administrators of the organization will review the request and complete the subsequent steps. You will be added as the repository maintainer if the transfer is accepted.

## Requirements for a template

A template repository must be a pyproject-based project, which contains a `pyproject.toml` file with PEP-621 compliant metadata.
No other special config files are required.

## Project name replacement

On initialization, the project name in the template will be replaced by the name of the new project. This is done by a recursive full-text search and replace. The import name, which is derived from the project name by replacing all non-alphanumeric characters with underscores and lowercasing, will also be replaced in the same way.

For example, if the project name is `foo-project` in the template and you want to initialize a new project named `bar-project`, the following replacements will be made:

- `foo-project` -> `bar-project` in all `.md` files and `.rst` files
- `foo_project` -> `bar_project` in all `.py` files
- `foo_project` -> `bar_project` in the directory name
- `foo_project.py` -> `bar_project.py` in the file name

Therefore, we don't support name replacement if the import name isn't derived from the project name.
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ nav:
- usage/advanced.md
- usage/venv.md
- usage/pep582.md
- usage/template.md
- Reference:
- reference/pep621.md
- reference/configuration.md
Expand Down
15 changes: 14 additions & 1 deletion pdm.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ dependencies = [
"installer<0.8,>=0.7",
"cachecontrol[filecache]>=0.13.0",
"tomli>=1.1.0; python_version < \"3.11\"",
"importlib-resources>=5; python_version < \"3.9\"",
"importlib-metadata>=3.6; python_version < \"3.10\"",
]
readme = "README.md"
Expand Down
211 changes: 89 additions & 122 deletions src/pdm/cli/commands/init.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
from __future__ import annotations

import argparse
from pathlib import Path
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Any

from pdm import termui
from pdm.cli import actions
from pdm.cli.commands.base import BaseCommand
from pdm.cli.hooks import HookManager
from pdm.cli.options import skip_option
from pdm.cli.templates import ProjectTemplate
from pdm.models.backends import _BACKENDS, DEFAULT_BACKEND, BuildBackend, get_backend
from pdm.models.python import PythonInfo
from pdm.models.specifiers import get_specifier
Expand All @@ -25,23 +25,66 @@ class Command(BaseCommand):
def __init__(self) -> None:
self.interactive = True

@staticmethod
def do_init(
project: Project,
name: str = "",
version: str = "",
description: str = "",
license: str = "MIT",
author: str = "",
email: str = "",
python_requires: str = "",
build_backend: type[BuildBackend] | None = None,
hooks: HookManager | None = None,
self, project: Project, metadata: dict[str, Any], hooks: HookManager, options: argparse.Namespace
) -> None:
"""Bootstrap the project and create a pyproject.toml"""
with ProjectTemplate(options.template) as template:
template.generate(project.root, metadata)
project.pyproject.reload()
hooks.try_emit("post_init")

def set_interactive(self, value: bool) -> None:
self.interactive = value

def ask(self, question: str, default: str) -> str:
if not self.interactive:
return default
return termui.ask(question, default=default)

def get_metadata_from_input(self, project: Project, options: argparse.Namespace) -> dict[str, Any]:
from pdm.formats.base import array_of_inline_tables, make_array, make_inline_table

hooks = hooks or HookManager(project)
is_library = options.lib
if not is_library and self.interactive:
is_library = termui.confirm(
"Is the project a library that is installable?\n"
"If yes, we will need to ask a few more questions to include "
"the project name and build backend"
)
build_backend: type[BuildBackend] | None = None
if is_library:
name = self.ask("Project name", project.root.name)
version = self.ask("Project version", "0.1.0")
description = self.ask("Project description", "")
if options.backend:
build_backend = get_backend(options.backend)
elif self.interactive:
all_backends = list(_BACKENDS)
project.core.ui.echo("Which build backend to use?")
for i, backend in enumerate(all_backends):
project.core.ui.echo(f"{i}. [success]{backend}[/]")
selected_backend = termui.ask(
"Please select",
prompt_type=int,
choices=[str(i) for i in range(len(all_backends))],
show_choices=False,
default=0,
)
build_backend = get_backend(all_backends[int(selected_backend)])
else:
build_backend = DEFAULT_BACKEND
else:
name, version, description = "", "", ""
license = self.ask("License(SPDX name)", "MIT")

git_user, git_email = get_user_email_from_git()
author = self.ask("Author name", git_user)
email = self.ask("Author email", git_email)
python = project.python
python_version = f"{python.major}.{python.minor}"
python_requires = self.ask("Python requires('*' to allow any)", f">={python_version}")

data = {
"project": {
"name": name,
Expand All @@ -52,50 +95,14 @@ def do_init(
"dependencies": make_array([], True),
},
}
if build_backend is not None:
data["build-system"] = build_backend.build_system()

if python_requires and python_requires != "*":
get_specifier(python_requires)
data["project"]["requires-python"] = python_requires
if name and version:
readme = next(project.root.glob("README*"), None)
if readme is None:
readme = project.root.joinpath("README.md")
readme.write_text(f"# {name}\n\n{description}\n", encoding="utf-8")
data["project"]["readme"] = readme.name
get_specifier(python_requires)
project.pyproject._data.update(data)
project.pyproject.write()
Command._write_gitignore(project.root.joinpath(".gitignore"))
hooks.try_emit("post_init")

@staticmethod
def _write_gitignore(path: Path) -> None:
import requests

url = "https://raw.githubusercontent.com/github/gitignore/master/Python.gitignore"
if not path.exists():
try:
resp = requests.get(url, timeout=5)
resp.raise_for_status()
except requests.exceptions.RequestException:
content = "\n".join(["build/", "dist/", "*.egg-info/", "__pycache__/", "*.py[cod]"]) + "\n"
else:
content = resp.text
content += ".pdm-python\n"
else:
content = path.read_text(encoding="utf-8")
if ".pdm-python" in content:
return
content += ".pdm-python\n"
path.write_text(content, encoding="utf-8")

def set_interactive(self, value: bool) -> None:
self.interactive = value
if build_backend is not None:
data["build-system"] = build_backend.build_system()

def ask(self, question: str, default: str) -> str:
if not self.interactive:
return default
return termui.ask(question, default=default)
return data

def add_arguments(self, parser: argparse.ArgumentParser) -> None:
skip_option.add_to_parser(parser)
Expand All @@ -108,110 +115,70 @@ def add_arguments(self, parser: argparse.ArgumentParser) -> None:
parser.add_argument("--python", help="Specify the Python version/path to use")
parser.add_argument("--backend", choices=list(_BACKENDS), help="Specify the build backend")
parser.add_argument("--lib", action="store_true", help="Create a library project")
parser.add_argument(
"template", nargs="?", help="Specify the project template, which can be a local path or a Git URL"
)
parser.set_defaults(search_parent=False)

def handle(self, project: Project, options: argparse.Namespace) -> None:
def set_python(self, project: Project, python: str | None, hooks: HookManager) -> None:
from pdm.cli.commands.use import Command as UseCommand

hooks = HookManager(project, options.skip)
if project.pyproject.exists():
project.core.ui.echo("pyproject.toml already exists, update it now.", style="primary")
else:
project.core.ui.echo("Creating a pyproject.toml for PDM...", style="primary")
self.set_interactive(not options.non_interactive)
do_use = UseCommand().do_use
if self.interactive:
python = do_use(
python_info = do_use(
project,
options.python or "",
first=bool(options.python),
python or "",
first=bool(python),
ignore_remembered=True,
ignore_requires_python=True,
save=False,
hooks=hooks,
)
else:
python = do_use(
python_info = do_use(
project,
options.python or "3",
python or "3",
first=True,
ignore_remembered=True,
ignore_requires_python=True,
save=False,
hooks=hooks,
)
if project.config["python.use_venv"] and python.get_venv() is None:
if project.config["python.use_venv"] and python_info.get_venv() is None:
if not self.interactive or termui.confirm(
f"Would you like to create a virtualenv with [success]{python.executable}[/]?",
f"Would you like to create a virtualenv with [success]{python_info.executable}[/]?",
default=True,
):
project._python = python
project._python = python_info
try:
path = project._create_virtualenv()
python = PythonInfo.from_path(get_venv_python(path))
python_info = PythonInfo.from_path(get_venv_python(path))
except Exception as e: # pragma: no cover
project.core.ui.echo(
f"Error occurred when creating virtualenv: {e}\nPlease fix it and create later.",
style="error",
err=True,
)
if python.get_venv() is None:
if python_info.get_venv() is None:
project.core.ui.echo(
"You are using the PEP 582 mode, no virtualenv is created.\n"
"For more info, please visit https://peps.python.org/pep-0582/",
style="success",
)
is_library = options.lib
if not is_library and self.interactive:
is_library = termui.confirm(
"Is the project a library that is installable?\n"
"If yes, we will need to ask a few more questions to include "
"the project name and build backend"
)
build_backend: type[BuildBackend] | None = None
if is_library:
name = self.ask("Project name", project.root.name)
version = self.ask("Project version", "0.1.0")
description = self.ask("Project description", "")
if options.backend:
build_backend = get_backend(options.backend)
elif self.interactive:
all_backends = list(_BACKENDS)
project.core.ui.echo("Which build backend to use?")
for i, backend in enumerate(all_backends):
project.core.ui.echo(f"{i}. [success]{backend}[/]")
selected_backend = termui.ask(
"Please select",
prompt_type=int,
choices=[str(i) for i in range(len(all_backends))],
show_choices=False,
default=0,
)
build_backend = get_backend(all_backends[int(selected_backend)])
else:
build_backend = DEFAULT_BACKEND
project.python = python_info

def handle(self, project: Project, options: argparse.Namespace) -> None:
hooks = HookManager(project, options.skip)
if project.pyproject.exists():
project.core.ui.echo("pyproject.toml already exists, update it now.", style="primary")
else:
name, version, description = "", "", ""
license = self.ask("License(SPDX name)", "MIT")
project.core.ui.echo("Creating a pyproject.toml for PDM...", style="primary")
self.set_interactive(not options.non_interactive)

git_user, git_email = get_user_email_from_git()
author = self.ask("Author name", git_user)
email = self.ask("Author email", git_email)
python_version = f"{python.major}.{python.minor}"
python_requires = self.ask("Python requires('*' to allow any)", f">={python_version}")
project.python = python

self.do_init(
project,
name=name,
version=version,
description=description,
license=license,
author=author,
email=email,
python_requires=python_requires,
build_backend=build_backend,
hooks=hooks,
)
self.set_python(project, options.python, hooks)

metadata = self.get_metadata_from_input(project, options)
self.do_init(project, metadata, hooks=hooks, options=options)
project.core.ui.echo("Project is initialized successfully", style="primary")
if self.interactive:
actions.ask_for_import(project)
Loading

0 comments on commit 5149924

Please sign in to comment.