diff --git a/docs/docs/usage/project.md b/docs/docs/usage/project.md index c553113437..5dcbf500dd 100644 --- a/docs/docs/usage/project.md +++ b/docs/docs/usage/project.md @@ -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 diff --git a/docs/docs/usage/template.md b/docs/docs/usage/template.md new file mode 100644 index 0000000000..239edf8be2 --- /dev/null +++ b/docs/docs/usage/template.md @@ -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 ` option to create a project at a new path. + +## Contribute a template + +According to the first form of the template argument, `pdm init ` will refer to the template repository located at `https://github.com/pdm-project/template-`. 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. diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index 55a5f3728f..bd23ee050c 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -60,6 +60,7 @@ nav: - usage/advanced.md - usage/venv.md - usage/pep582.md + - usage/template.md - Reference: - reference/pep621.md - reference/configuration.md diff --git a/pdm.lock b/pdm.lock index 8ae8084034..2db08f3945 100644 --- a/pdm.lock +++ b/pdm.lock @@ -178,6 +178,15 @@ dependencies = [ "zipp>=0.5", ] +[[package]] +name = "importlib-resources" +version = "5.12.0" +requires_python = ">=3.7" +summary = "Read resources from Python packages" +dependencies = [ + "zipp>=3.1.0; python_version < \"3.10\"", +] + [[package]] name = "incremental" version = "22.10.0" @@ -723,7 +732,7 @@ summary = "Backport of pathlib-compatible object wrapper for zip files" lock_version = "4.2" cross_platform = true groups = ["default", "doc", "pytest", "test", "tox", "workflow"] -content_hash = "sha256:dd7cd42af7334c1eec30d57c8f37a17ec39a10143a65867bd03a5b2e5274f542" +content_hash = "sha256:e885ddc388a789db6322ec93944f7c1a66933f4acacea458d864d8b9d702d35b" [metadata.files] "arpeggio 2.0.0" = [ @@ -944,6 +953,10 @@ content_hash = "sha256:dd7cd42af7334c1eec30d57c8f37a17ec39a10143a65867bd03a5b2e5 {url = "https://files.pythonhosted.org/packages/a3/82/f6e29c8d5c098b6be61460371c2c5591f4a335923639edec43b3830650a4/importlib_metadata-6.7.0.tar.gz", hash = "sha256:1aaf550d4f73e5d6783e7acb77aec43d49da8017410afae93822cc9cca98c4d4"}, {url = "https://files.pythonhosted.org/packages/ff/94/64287b38c7de4c90683630338cf28f129decbba0a44f0c6db35a873c73c4/importlib_metadata-6.7.0-py3-none-any.whl", hash = "sha256:cb52082e659e97afc5dac71e79de97d8681de3aa07ff18578330904a9d18e5b5"}, ] +"importlib-resources 5.12.0" = [ + {url = "https://files.pythonhosted.org/packages/38/71/c13ea695a4393639830bf96baea956538ba7a9d06fcce7cef10bfff20f72/importlib_resources-5.12.0-py3-none-any.whl", hash = "sha256:7b1deeebbf351c7578e09bf2f63fa2ce8b5ffec296e0d349139d43cca061a81a"}, + {url = "https://files.pythonhosted.org/packages/4e/a2/3cab1de83f95dd15297c15bdc04d50902391d707247cada1f021bbfe2149/importlib_resources-5.12.0.tar.gz", hash = "sha256:4be82589bf5c1d7999aedf2a45159d10cb3ca4f19b2271f8792bc8e6da7b22f6"}, +] "incremental 22.10.0" = [ {url = "https://files.pythonhosted.org/packages/77/51/8073577012492fcd15628e811db585f447c500fa407e944ab3a18ec55fb7/incremental-22.10.0-py2.py3-none-any.whl", hash = "sha256:b864a1f30885ee72c5ac2835a761b8fe8aa9c28b9395cacf27286602688d3e51"}, {url = "https://files.pythonhosted.org/packages/86/42/9e87f04fa2cd40e3016f27a4b4572290e95899c6dce317e2cdb580f3ff09/incremental-22.10.0.tar.gz", hash = "sha256:912feeb5e0f7e0188e6f42241d2f450002e11bbc0937c65865045854c24c0bd0"}, diff --git a/pyproject.toml b/pyproject.toml index 498ce002ce..ccc2d7888d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/src/pdm/cli/commands/init.py b/src/pdm/cli/commands/init.py index 15174b6d01..f934e80f06 100644 --- a/src/pdm/cli/commands/init.py +++ b/src/pdm/cli/commands/init.py @@ -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 @@ -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, @@ -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) @@ -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) diff --git a/src/pdm/cli/commands/run.py b/src/pdm/cli/commands/run.py index cae6ff05fe..9629af6870 100644 --- a/src/pdm/cli/commands/run.py +++ b/src/pdm/cli/commands/run.py @@ -248,7 +248,7 @@ def run_task(self, task: Task, args: Sequence[str] = (), opts: TaskOptions | Non split = shlex.split(script) cmd = split[0] subargs = split[1:] + ([] if should_interpolate else args) - code = self.run(cmd, subargs, options) + code = self.run(cmd, subargs, options, chdir=True) if code != 0: return code return code @@ -259,7 +259,7 @@ def run_task(self, task: Task, args: Sequence[str] = (), opts: TaskOptions | Non **exec_opts(self.global_options, options, opts), ) - def run(self, command: str, args: list[str], opts: TaskOptions | None = None) -> int: + def run(self, command: str, args: list[str], opts: TaskOptions | None = None, chdir: bool = False) -> int: if command in self.hooks.skip: return 0 task = self.get_task(command) @@ -281,6 +281,7 @@ def run(self, command: str, args: list[str], opts: TaskOptions | None = None) -> else: return self._run_process( [command, *args], + chdir=chdir, **exec_opts(self.global_options, opts), ) diff --git a/src/pdm/cli/completions/pdm.zsh b/src/pdm/cli/completions/pdm.zsh index c2d030dacb..fdcd8b856e 100644 --- a/src/pdm/cli/completions/pdm.zsh +++ b/src/pdm/cli/completions/pdm.zsh @@ -188,6 +188,7 @@ _pdm() { '--backend[Specify the build backend]:backend:(pdm-backend setuptools hatchling flit pdm-pep517)' '--lib[Create a library project]' '--python[Specify the Python version/path to use]:python:' + '1:filename:_files' ) ;; install) diff --git a/src/pdm/cli/templates/__init__.py b/src/pdm/cli/templates/__init__.py new file mode 100644 index 0000000000..e46a9a5f19 --- /dev/null +++ b/src/pdm/cli/templates/__init__.py @@ -0,0 +1,156 @@ +from __future__ import annotations + +import os +import re +import shutil +import subprocess +import tempfile +from pathlib import Path +from typing import TYPE_CHECKING, Any + +from pdm.exceptions import PdmException +from pdm.utils import normalize_name + +if TYPE_CHECKING: + from importlib.resources.abc import Traversable + from typing import Callable, TypeVar + + ST = TypeVar("ST", Traversable, Path) + +BUILTIN_TEMPLATE = "pdm.cli.templates.default" + + +class ProjectTemplate: + _path: Path + + def __init__(self, path_or_url: str | None) -> None: + self.template = path_or_url + + def __enter__(self) -> "ProjectTemplate": + self._path = Path(tempfile.mkdtemp(suffix="-template", prefix="pdm-")) + self.prepare_template() + return self + + def __exit__(self, *args: Any) -> None: + shutil.rmtree(self._path, ignore_errors=True) + + def generate(self, target_path: Path, metadata: dict[str, Any]) -> None: + from pdm.compat import tomllib + + def replace_all(path: str, old: str, new: str) -> None: + with open(path, encoding=encoding) as fp: + content = fp.read() + content = re.sub(rf"\b{old}\b", new, content) + with open(path, "w", encoding=encoding) as fp: + fp.write(content) + + if metadata.get("project", {}).get("name"): + try: + with open(self._path / "pyproject.toml", "rb") as fp: + pyproject = tomllib.load(fp) + except FileNotFoundError: + raise PdmException("Template pyproject.toml not found") from None + new_name = metadata["project"]["name"] + new_import_name = normalize_name(new_name).replace("-", "_") + try: + original_name = pyproject["project"]["name"] + except KeyError: + raise PdmException("Template pyproject.toml is not PEP-621 compliant") from None + import_name = normalize_name(original_name).replace("-", "_") + encoding = "utf-8" + for root, dirs, filenames in os.walk(self._path): + for i, d in enumerate(dirs): + if d == import_name: + os.rename(os.path.join(root, d), os.path.join(root, new_import_name)) + dirs[i] = new_import_name + for f in filenames: + if f.endswith(".py"): + replace_all(os.path.join(root, f), import_name, new_import_name) + if f == import_name + ".py": + os.rename(os.path.join(root, f), os.path.join(root, new_import_name + ".py")) + elif f.endswith((".md", ".rst")): + replace_all(os.path.join(root, f), original_name, new_name) + elif Path(root) == self._path and f == "pyproject.toml": + replace_all(os.path.join(root, f), import_name, new_import_name) + + target_path.mkdir(exist_ok=True, parents=True) + self.mirror(self._path, target_path, [self._path / "pyproject.toml"]) + self._generate_pyproject(target_path / "pyproject.toml", metadata) + + def prepare_template(self) -> None: + if self.template is None: + self._prepare_package_template(BUILTIN_TEMPLATE) + elif "://" in self.template or self.template.startswith("git@"): + self._prepare_git_template(self.template) + elif os.path.exists(self.template): + self._prepare_local_template(self.template) + else: # template name + template = f"https://github.com/pdm-project/template-{self.template}" + self._prepare_git_template(template) + + @staticmethod + def mirror( + src: ST, + dst: Path, + skip: list[ST] | None = None, + copyfunc: Callable[[ST, Path], Any] = shutil.copy2, # type: ignore[assignment] + ) -> None: + if skip and src in skip: + return + if src.is_dir(): + dst.mkdir(exist_ok=True) + for child in src.iterdir(): + ProjectTemplate.mirror(child, dst / child.name, skip, copyfunc) + else: + copyfunc(src, dst) + + @staticmethod + def _copy_package_file(src: Traversable, dst: Path) -> Path: + from pdm.compat import importlib_resources + + with importlib_resources.as_file(src) as f: + return shutil.copy2(f, dst) + + def _generate_pyproject(self, path: Path, metadata: dict[str, Any]) -> None: + import tomlkit + + from pdm.cli.utils import merge_dictionary + + try: + with open(path, encoding="utf-8") as fp: + content = tomlkit.load(fp) + except FileNotFoundError: + content = tomlkit.document() + try: + with open(self._path / "pyproject.toml", encoding="utf-8") as fp: + template_content = tomlkit.load(fp) + except FileNotFoundError: + template_content = tomlkit.document() + + merge_dictionary(content, template_content) + merge_dictionary(content, metadata) + if "build-system" in metadata: + content["build-system"] = metadata["build-system"] + else: + content.pop("build-system", None) + with open(path, "w", encoding="utf-8") as fp: + fp.write(tomlkit.dumps(content)) + + def _prepare_package_template(self, import_name: str) -> None: + from pdm.compat import importlib_resources + + files = importlib_resources.files(import_name) + + self.mirror(files, self._path, skip=[files / "__init__.py"], copyfunc=self._copy_package_file) + + def _prepare_git_template(self, url: str) -> None: + git_command = ["git", "clone", "--depth", "1", "--recursive", url, self._path.as_posix()] + result = subprocess.run(git_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True) + if result.returncode != 0: + raise PdmException(f"Failed to clone template from git repository {url}: {result.stderr}") + shutil.rmtree(self._path / ".git", ignore_errors=True) + + def _prepare_local_template(self, path: str) -> None: + src = Path(path) + + self.mirror(src, self._path, skip=[src / ".git", src / ".svn", src / ".hg"]) diff --git a/src/pdm/cli/templates/default/.gitignore b/src/pdm/cli/templates/default/.gitignore new file mode 100644 index 0000000000..4b3ab3003f --- /dev/null +++ b/src/pdm/cli/templates/default/.gitignore @@ -0,0 +1,162 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ diff --git a/src/pdm/cli/templates/default/README.md b/src/pdm/cli/templates/default/README.md new file mode 100644 index 0000000000..ed66ce54d2 --- /dev/null +++ b/src/pdm/cli/templates/default/README.md @@ -0,0 +1 @@ +# example-package diff --git a/src/pdm/cli/templates/default/__init__.py b/src/pdm/cli/templates/default/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pdm/cli/templates/default/pyproject.toml b/src/pdm/cli/templates/default/pyproject.toml new file mode 100644 index 0000000000..cb299fe7c4 --- /dev/null +++ b/src/pdm/cli/templates/default/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "example-package" +version = "0.1.0" +description = "Default template for PDM package" +authors = [] +dependencies = [] +requires-python = ">=3.8" +readme = "README.md" +license = {text = "MIT"} + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" diff --git a/src/pdm/cli/templates/default/src/example_package/__init__.py b/src/pdm/cli/templates/default/src/example_package/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pdm/cli/templates/default/tests/__init__.py b/src/pdm/cli/templates/default/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/src/pdm/cli/utils.py b/src/pdm/cli/utils.py index 0589401aaa..d5c413183d 100644 --- a/src/pdm/cli/utils.py +++ b/src/pdm/cli/utils.py @@ -601,7 +601,7 @@ def format_resolution_impossible(err: ResolutionImpossible) -> str: return "\n".join(result) -def merge_dictionary(target: MutableMapping[Any, Any], input: Mapping[Any, Any]) -> None: +def merge_dictionary(target: MutableMapping[Any, Any], input: Mapping[Any, Any], append_array: bool = True) -> None: """Merge the input dict with the target while preserving the existing values properly. This will update the target dictionary in place. List values will be extended, but only if the value is not already in the list. @@ -610,8 +610,8 @@ def merge_dictionary(target: MutableMapping[Any, Any], input: Mapping[Any, Any]) if key not in target: target[key] = value elif isinstance(value, dict): - merge_dictionary(target[key], value) - elif isinstance(value, list): + merge_dictionary(target[key], value, append_array=append_array) + elif isinstance(value, list) and append_array: target[key].extend(x for x in value if x not in target[key]) if hasattr(target[key], "multiline"): target[key].multiline(True) # type: ignore[attr-defined] diff --git a/src/pdm/compat.py b/src/pdm/compat.py index fbffbef988..5c75b5c4a2 100644 --- a/src/pdm/compat.py +++ b/src/pdm/compat.py @@ -67,7 +67,13 @@ def __get__(self, inst, cls=None): import importlib_metadata +if sys.version_info >= (3, 9): + import importlib.resources as importlib_resources +else: + import importlib_resources + + Distribution = importlib_metadata.Distribution -__all__ = ["tomllib", "cached_property", "importlib_metadata", "Distribution"] +__all__ = ["tomllib", "cached_property", "importlib_metadata", "Distribution", "importlib_resources"] diff --git a/src/pdm/pytest.py b/src/pdm/pytest.py index 9807462424..ce59b7a589 100644 --- a/src/pdm/pytest.py +++ b/src/pdm/pytest.py @@ -25,6 +25,7 @@ import os import shutil import sys +from argparse import Namespace from dataclasses import dataclass from io import BufferedReader, BytesIO, StringIO from pathlib import Path @@ -54,7 +55,7 @@ from pdm.environments import BaseEnvironment, PythonEnvironment from pdm.exceptions import CandidateInfoNotFound from pdm.installers.installers import install_wheel -from pdm.models.backends import get_backend +from pdm.models.backends import DEFAULT_BACKEND from pdm.models.candidates import Candidate from pdm.models.repositories import BaseRepository from pdm.models.requirements import ( @@ -422,13 +423,20 @@ def project(project_no_init: Project) -> Project: from pdm.cli.commands.init import Command hooks = HookManager(project_no_init, ["post_init"]) - Command.do_init( - project_no_init, - "test_project", - "0.0.0", - hooks=hooks, - build_backend=get_backend("pdm-pep517"), - ) + data = { + "project": { + "name": "test-project", + "version": "0.0.0", + "description": "", + "authors": [], + "license": {"text": "MIT"}, + "dependencies": [], + "requires-python": ">=3.7", + }, + "build-system": DEFAULT_BACKEND.build_system(), + } + + Command().do_init(project_no_init, data, hooks=hooks, options=Namespace(template=None)) # Clean the cached property project_no_init._environment = None return project_no_init diff --git a/tests/cli/test_build.py b/tests/cli/test_build.py index bb347c34ec..1edd5cda2e 100644 --- a/tests/cli/test_build.py +++ b/tests/cli/test_build.py @@ -63,13 +63,13 @@ def test_build_package(fixture_project): project = fixture_project("demo-package") Command.do_build(project) - tar_names = get_tarball_names(project.root / "dist/demo-package-0.1.0.tar.gz") - assert "demo-package-0.1.0/my_package/__init__.py" in tar_names - assert "demo-package-0.1.0/my_package/data.json" in tar_names - assert "demo-package-0.1.0/single_module.py" not in tar_names - assert "demo-package-0.1.0/data_out.json" not in tar_names + tar_names = get_tarball_names(project.root / "dist/my-package-0.1.0.tar.gz") + assert "my-package-0.1.0/my_package/__init__.py" in tar_names + assert "my-package-0.1.0/my_package/data.json" in tar_names + assert "my-package-0.1.0/single_module.py" not in tar_names + assert "my-package-0.1.0/data_out.json" not in tar_names - zip_names = get_wheel_names(project.root / "dist/demo_package-0.1.0-py3-none-any.whl") + zip_names = get_wheel_names(project.root / "dist/my_package-0.1.0-py3-none-any.whl") assert "my_package/__init__.py" in zip_names assert "my_package/data.json" in zip_names assert "single_module.py" not in zip_names @@ -100,13 +100,13 @@ def test_build_package_include(fixture_project): project.pyproject.write() Command.do_build(project) - tar_names = get_tarball_names(project.root / "dist/demo-package-0.1.0.tar.gz") - assert "demo-package-0.1.0/my_package/__init__.py" in tar_names - assert "demo-package-0.1.0/my_package/data.json" not in tar_names - assert "demo-package-0.1.0/single_module.py" in tar_names - assert "demo-package-0.1.0/data_out.json" in tar_names + tar_names = get_tarball_names(project.root / "dist/my-package-0.1.0.tar.gz") + assert "my-package-0.1.0/my_package/__init__.py" in tar_names + assert "my-package-0.1.0/my_package/data.json" not in tar_names + assert "my-package-0.1.0/single_module.py" in tar_names + assert "my-package-0.1.0/data_out.json" in tar_names - zip_names = get_wheel_names(project.root / "dist/demo_package-0.1.0-py3-none-any.whl") + zip_names = get_wheel_names(project.root / "dist/my_package-0.1.0-py3-none-any.whl") assert "my_package/__init__.py" in zip_names assert "my_package/data.json" not in zip_names assert "single_module.py" in zip_names diff --git a/tests/cli/test_init.py b/tests/cli/test_init.py index a97a1befbf..73d2d8b677 100644 --- a/tests/cli/test_init.py +++ b/tests/cli/test_init.py @@ -1,18 +1,16 @@ import sys from unittest.mock import ANY -import pytest - -from pdm.cli.commands.init import Command -from pdm.models.backends import get_backend +from pdm.compat import tomllib from pdm.models.python import PythonInfo PYTHON_VERSION = f"{sys.version_info[0]}.{sys.version_info[1]}" -def test_init_validate_python_requires(project_no_init): - with pytest.raises(ValueError): - Command.do_init(project_no_init, python_requires="3.7") +def test_init_validate_python_requires(project_no_init, pdm): + result = pdm(["init"], input="\n\n\n\n\n3.7\n", obj=project_no_init) + assert result.exit_code != 0 + assert "InvalidSpecifier" in result.stderr def test_init_command(project_no_init, pdm, mocker): @@ -20,21 +18,23 @@ def test_init_command(project_no_init, pdm, mocker): "pdm.cli.commands.init.get_user_email_from_git", return_value=("Testing", "me@example.org"), ) - do_init = mocker.patch.object(Command, "do_init") pdm(["init"], input="\n\n\n\n\n\n", strict=True, obj=project_no_init) python_version = f"{project_no_init.python.major}.{project_no_init.python.minor}" - do_init.assert_called_with( - project_no_init, - name="", - version="", - description="", - license="MIT", - author="Testing", - email="me@example.org", - python_requires=f">={python_version}", - build_backend=None, - hooks=ANY, - ) + data = { + "project": { + "authors": [{"email": "me@example.org", "name": "Testing"}], + "dependencies": [], + "description": "", + "license": {"text": "MIT"}, + "name": "", + "requires-python": f">={python_version}", + "readme": "README.md", + "version": "", + }, + } + + with open(project_no_init.root.joinpath("pyproject.toml"), "rb") as fp: + assert tomllib.load(fp) == data def test_init_command_library(project_no_init, pdm, mocker): @@ -42,7 +42,6 @@ def test_init_command_library(project_no_init, pdm, mocker): "pdm.cli.commands.init.get_user_email_from_git", return_value=("Testing", "me@example.org"), ) - do_init = mocker.patch.object(Command, "do_init") result = pdm( ["init"], input="\ny\ntest-project\n\nTest Project\n1\n\n\n\n\n", @@ -50,18 +49,22 @@ def test_init_command_library(project_no_init, pdm, mocker): ) assert result.exit_code == 0 python_version = f"{project_no_init.python.major}.{project_no_init.python.minor}" - do_init.assert_called_with( - project_no_init, - name="test-project", - version="0.1.0", - description="Test Project", - license="MIT", - author="Testing", - email="me@example.org", - python_requires=f">={python_version}", - build_backend=get_backend("setuptools"), - hooks=ANY, - ) + data = { + "project": { + "authors": [{"email": "me@example.org", "name": "Testing"}], + "dependencies": [], + "description": "Test Project", + "license": {"text": "MIT"}, + "name": "test-project", + "requires-python": f">={python_version}", + "readme": "README.md", + "version": "0.1.0", + }, + "build-system": {"build-backend": "setuptools.build_meta", "requires": ["setuptools>=61", "wheel"]}, + } + + with open(project_no_init.root.joinpath("pyproject.toml"), "rb") as fp: + assert tomllib.load(fp) == data def test_init_non_interactive(project_no_init, pdm, mocker): @@ -69,7 +72,6 @@ def test_init_non_interactive(project_no_init, pdm, mocker): "pdm.cli.commands.init.get_user_email_from_git", return_value=("Testing", "me@example.org"), ) - do_init = mocker.patch.object(Command, "do_init") do_use = mocker.patch("pdm.cli.commands.use.Command.do_use", return_value=PythonInfo.from_path(sys.executable)) result = pdm(["init", "-n"], obj=project_no_init) assert result.exit_code == 0 @@ -83,18 +85,21 @@ def test_init_non_interactive(project_no_init, pdm, mocker): save=False, hooks=ANY, ) - do_init.assert_called_with( - project_no_init, - name="", - version="", - description="", - license="MIT", - author="Testing", - email="me@example.org", - python_requires=f">={python_version}", - build_backend=None, - hooks=ANY, - ) + data = { + "project": { + "authors": [{"email": "me@example.org", "name": "Testing"}], + "dependencies": [], + "description": "", + "license": {"text": "MIT"}, + "name": "", + "requires-python": f">={python_version}", + "readme": "README.md", + "version": "", + }, + } + + with open(project_no_init.root.joinpath("pyproject.toml"), "rb") as fp: + assert tomllib.load(fp) == data def test_init_auto_create_venv(project_no_init, pdm, mocker): diff --git a/tests/cli/test_list.py b/tests/cli/test_list.py index c0978df067..43a4812c61 100644 --- a/tests/cli/test_list.py +++ b/tests/cli/test_list.py @@ -624,7 +624,7 @@ def test_list_json_fields_licences(project, pdm): def test_list_markdown_fields_licences(project, pdm): result = pdm(["list", "--markdown", "--fields", "name,version,licenses"], obj=project) expected = ( - "# test_project licenses\n" + "# test-project licenses\n" "## foo\n\n" "| Name | foo |\n" "|----|----|\n" diff --git a/tests/cli/test_others.py b/tests/cli/test_others.py index 045e566166..2ea1634dce 100644 --- a/tests/cli/test_others.py +++ b/tests/cli/test_others.py @@ -162,7 +162,7 @@ def test_show_self_package(project, pdm): result = pdm(["show", "--name", "--version"], obj=project) assert result.exit_code == 0 - assert "test_project\n0.0.0\n" == result.output + assert "test-project\n0.0.0\n" == result.output def test_export_to_requirements_txt(pdm, fixture_project): diff --git a/tests/cli/test_template.py b/tests/cli/test_template.py new file mode 100644 index 0000000000..f3744a284e --- /dev/null +++ b/tests/cli/test_template.py @@ -0,0 +1,61 @@ +import pytest + +from pdm.cli.templates import ProjectTemplate +from pdm.exceptions import PdmException + + +def test_non_pyproject_template_disallowed(project_no_init): + with ProjectTemplate("tests/fixtures/projects/demo_extras") as template: + with pytest.raises(PdmException, match="Template pyproject.toml not found"): + template.generate(project_no_init.root, {"project": {"name": "foo"}}) + + +def test_module_project_template(project_no_init): + metadata = { + "project": {"name": "foo", "version": "0.1.0", "requires-python": ">=3.10"}, + "build-system": {"requires": ["pdm-backend"], "build-backend": "pdm.backend"}, + } + + with ProjectTemplate("tests/fixtures/projects/demo") as template: + template.generate(project_no_init.root, metadata) + + project_no_init.pyproject.reload() + assert project_no_init.pyproject.metadata["name"] == "foo" + assert project_no_init.pyproject.metadata["requires-python"] == ">=3.10" + assert project_no_init.pyproject._data["build-system"] == metadata["build-system"] + assert project_no_init.pyproject.metadata["dependencies"] == ["idna", "chardet; os_name=='nt'"] + assert project_no_init.pyproject.metadata["optional-dependencies"]["tests"] == ["pytest"] + assert (project_no_init.root / "foo.py").exists() + + +def test_module_project_template_generate_application(project_no_init): + metadata = { + "project": {"name": "", "version": "", "requires-python": ">=3.10"}, + } + + with ProjectTemplate("tests/fixtures/projects/demo") as template: + template.generate(project_no_init.root, metadata) + + project_no_init.pyproject.reload() + assert project_no_init.pyproject.metadata["name"] == "" + assert "build-system" not in project_no_init.pyproject._data + assert project_no_init.pyproject.metadata["dependencies"] == ["idna", "chardet; os_name=='nt'"] + assert (project_no_init.root / "demo.py").exists() + + +def test_package_project_template(project_no_init): + metadata = { + "project": {"name": "foo", "version": "0.1.0", "requires-python": ">=3.10"}, + "build-system": {"requires": ["pdm-backend"], "build-backend": "pdm.backend"}, + } + + with ProjectTemplate("tests/fixtures/projects/demo-package") as template: + template.generate(project_no_init.root, metadata) + + project_no_init.pyproject.reload() + assert project_no_init.pyproject.metadata["name"] == "foo" + assert project_no_init.pyproject.metadata["requires-python"] == ">=3.10" + assert project_no_init.pyproject._data["build-system"] == metadata["build-system"] + assert (project_no_init.root / "foo").is_dir() + assert (project_no_init.root / "foo/__init__.py").exists() + assert project_no_init.pyproject.settings["version"] == {"from": "foo/__init__.py"} diff --git a/tests/fixtures/projects/demo-package/README.md b/tests/fixtures/projects/demo-package/README.md index b71cf80043..39a1ceb117 100644 --- a/tests/fixtures/projects/demo-package/README.md +++ b/tests/fixtures/projects/demo-package/README.md @@ -1 +1 @@ -# This is a demo module +# my-package diff --git a/tests/fixtures/projects/demo-package/pyproject.toml b/tests/fixtures/projects/demo-package/pyproject.toml index 94c65c5579..ac6c16ae1e 100644 --- a/tests/fixtures/projects/demo-package/pyproject.toml +++ b/tests/fixtures/projects/demo-package/pyproject.toml @@ -13,7 +13,7 @@ requires-python = ">=3.5" license = {text = "MIT"} dependencies = ["flask"] description = "" -name = "demo-package" +name = "my-package" readme = "README.md" [project.optional-dependencies] diff --git a/tests/fixtures/projects/demo-package/setup.txt b/tests/fixtures/projects/demo-package/setup.txt index 0c0aa0503d..e135c48c2f 100644 --- a/tests/fixtures/projects/demo-package/setup.txt +++ b/tests/fixtures/projects/demo-package/setup.txt @@ -11,7 +11,7 @@ INSTALL_REQUIRES = [ ] setup_kwargs = { - 'name': 'demo-package', + 'name': 'my-package', 'version': '0.1.0', 'description': '', 'long_description': long_description, diff --git a/tests/fixtures/projects/demo/pyproject.toml b/tests/fixtures/projects/demo/pyproject.toml new file mode 100644 index 0000000000..ec0b12e3af --- /dev/null +++ b/tests/fixtures/projects/demo/pyproject.toml @@ -0,0 +1,22 @@ + +[project] +name = "demo" +version = "0.0.1" +description = "test demo" +requires-python = ">=3.3" +dependencies = [ + "idna", + "chardet; os_name=='nt'", +] + +[project.optional-dependencies] +tests = [ + "pytest", +] +security = [ + "requests; python_version>=\"3.6\"", +] + +[build-system] +requires = ["pdm-backend"] +build-backend = "pdm.backend" diff --git a/tests/fixtures/projects/demo/setup.py b/tests/fixtures/projects/demo/setup.py deleted file mode 100644 index 3266916c79..0000000000 --- a/tests/fixtures/projects/demo/setup.py +++ /dev/null @@ -1,15 +0,0 @@ -from setuptools import setup - - -setup( - name="demo", - version="0.0.1", - description="test demo", - py_modules=["demo"], - python_requires=">=3.3", - install_requires=["idna", "chardet; os_name=='nt'"], - extras_require={ - "tests": ["pytest"], - "security": ['requests; python_version>="3.6"'], - }, -)