diff --git a/docs/cli.md b/docs/cli.md index dee328483da..c83b0ea0a46 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -102,6 +102,12 @@ my-package ### Options * `--name`: Set the resulting package name. +* `--description`: Description of the package. +* `--package-version`: Set the version of the package. +* `--author`: Author of the package. +* `--python`: Compatible Python versions. +* `--license`: License of the package. +* `--dependency`: Package to require with an optional version constraint, e.g. `requests:^2.10.0` or `requests=2.11.1`. (see [add]({{< relref "#add" >}})). * `--src`: Use the src layout for the project. * `--readme`: Specify the readme file extension. Default is `md`. If you intend to publish to PyPI keep the [recommendations for a PyPI-friendly README](https://packaging.python.org/en/latest/guides/making-a-pypi-friendly-readme/) @@ -124,8 +130,8 @@ poetry init * `--name`: Name of the package. * `--description`: Description of the package. * `--author`: Author of the package. -* `--python` Compatible Python versions. -* `--dependency`: Package to require with a version constraint. Should be in format `foo:1.0.0`. +* `--python`: Compatible Python versions. +* `--dependency`: Package to require with an optional version constraint, e.g. `requests:^2.10.0` or `requests=2.11.1`. (see [add]({{< relref "#add" >}})). * `--dev-dependency`: Development requirements, see `--dependency`. diff --git a/src/poetry/console/commands/init.py b/src/poetry/console/commands/init.py index e2ebf9cf9b7..6fc8e5b0c0e 100644 --- a/src/poetry/console/commands/init.py +++ b/src/poetry/console/commands/init.py @@ -2,30 +2,25 @@ import sys -from pathlib import Path from typing import TYPE_CHECKING from typing import Any -from typing import Dict -from typing import Mapping -from typing import Union from cleo.helpers import option from packaging.utils import canonicalize_name -from tomlkit import inline_table from poetry.console.commands.command import Command -from poetry.console.commands.env_command import EnvCommand -from poetry.utils.dependency_specification import parse_dependency_specification +from poetry.utils.requirements import determine_requirements_from_list +from poetry.utils.requirements import find_best_version_for_package +from poetry.utils.requirements import format_requirements +from poetry.utils.requirements import parse_requirements if TYPE_CHECKING: from packaging.utils import NormalizedName from poetry.core.packages.package import Package - from tomlkit.items import InlineTable from poetry.repositories import Pool - -Requirements = Dict[str, Union[str, Mapping[str, Any]]] + from poetry.utils.requirements import Requirements class InitCommand(Command): @@ -55,7 +50,7 @@ class InitCommand(Command): flag=False, multiple=True, ), - option("license", "l", "License of the package.", flag=False), + option("license", "License of the package.", flag=False), ] help = """\ @@ -162,7 +157,7 @@ def handle(self) -> int: requirements: Requirements = {} if self.option("dependency"): - requirements = self._format_requirements( + requirements = format_requirements( self._determine_requirements(self.option("dependency")) ) @@ -184,15 +179,13 @@ def handle(self) -> int: if self.io.is_interactive(): self.line(help_message) help_displayed = True - requirements.update( - self._format_requirements(self._determine_requirements([])) - ) + requirements.update(format_requirements(self._determine_requirements([]))) if self.io.is_interactive(): self.line("") dev_requirements: Requirements = {} if self.option("dev-dependency"): - dev_requirements = self._format_requirements( + dev_requirements = format_requirements( self._determine_requirements(self.option("dev-dependency")) ) @@ -204,7 +197,7 @@ def handle(self) -> int: self.line(help_message) dev_requirements.update( - self._format_requirements(self._determine_requirements([])) + format_requirements(self._determine_requirements([])) ) if self.io.is_interactive(): self.line("") @@ -258,185 +251,107 @@ def _generate_choice_list( return choices - def _determine_requirements( - self, - requires: list[str], - allow_prereleases: bool = False, - source: str | None = None, - ) -> list[dict[str, Any]]: - if not requires: - result = [] - - question = self.create_question( - "Package to add or search for (leave blank to skip):" - ) - question.set_validator(self._validate_package) - - package = self.ask(question) - while package: - constraint = self._parse_requirements([package])[0] - if ( - "git" in constraint - or "url" in constraint - or "path" in constraint - or "version" in constraint - ): - self.line(f"Adding {package}") - result.append(constraint) - package = self.ask("\nAdd a package (leave blank to skip):") - continue - - canonicalized_name = canonicalize_name(constraint["name"]) - matches = self._get_pool().search(canonicalized_name) - if not matches: - self.line_error("Unable to find package") - package = False - else: - choices = self._generate_choice_list(matches, canonicalized_name) - - info_string = ( - f"Found {len(matches)} packages matching" - f" {package}" - ) - - if len(matches) > 10: - info_string += "\nShowing the first 10 matches" - - self.line(info_string) - - # Default to an empty value to signal no package was selected - choices.append("") + def _determine_requirements_interactive(self) -> list[dict[str, Any]]: + result = [] - package = self.choice( - "\nEnter package # to add, or the complete package name if it" - " is not listed", - choices, - attempts=3, - default=len(choices) - 1, - ) + question = self.create_question( + "Package to add or search for (leave blank to skip):" + ) + question.set_validator(self._validate_package) + + package = self.ask(question) + while package: + constraint = parse_requirements([package], self, None)[0] + if ( + "git" in constraint + or "url" in constraint + or "path" in constraint + or "version" in constraint + ): + self.line(f"Adding {package}") + result.append(constraint) + package = self.ask("\nAdd a package:") + continue - if not package: - self.line("No package selected") + canonicalized_name = canonicalize_name(constraint["name"]) + matches = self._get_pool().search(canonicalized_name) + if not matches: + self.line_error("Unable to find package") + package = False + else: + choices = self._generate_choice_list(matches, canonicalized_name) - # package selected by user, set constraint name to package name - if package: - constraint["name"] = package + info_string = ( + f"Found {len(matches)} packages matching" + f" {package}" + ) - # no constraint yet, determine the best version automatically - if package and "version" not in constraint: - question = self.create_question( - "Enter the version constraint to require " - "(or leave blank to use the latest version):" - ) - question.attempts = 3 - question.validator = lambda x: (x or "").strip() or False + if len(matches) > 10: + info_string += "\nShowing the first 10 matches" - package_constraint = self.ask(question) + self.line(info_string) - if package_constraint is None: - _, package_constraint = self._find_best_version_for_package( - package - ) + # Default to an empty value to signal no package was selected + choices.append("") - self.line( - f"Using version {package_constraint} for" - f" {package}" - ) + package = self.choice( + "\nEnter package # to add, or the complete package name if it" + " is not listed", + choices, + attempts=3, + default=len(choices) - 1, + ) - constraint["version"] = package_constraint + if not package: + self.line("No package selected") + # package selected by user, set constraint name to package name if package: - result.append(constraint) + constraint["name"] = package - if self.io.is_interactive(): - package = self.ask("\nAdd a package (leave blank to skip):") + # no constraint yet, determine the best version automatically + if package and "version" not in constraint: + question = self.create_question( + "Enter the version constraint to require " + "(or leave blank to use the latest version):" + ) + question.attempts = 3 + question.validator = lambda x: (x or "").strip() or False - return result + package_constraint = self.ask(question) - result = [] - for requirement in self._parse_requirements(requires): - if "git" in requirement or "url" in requirement or "path" in requirement: - result.append(requirement) - continue - elif "version" not in requirement: - # determine the best version automatically - name, version = self._find_best_version_for_package( - requirement["name"], - allow_prereleases=allow_prereleases, - source=source, - ) - requirement["version"] = version - requirement["name"] = name + if package_constraint is None: + _, package_constraint = find_best_version_for_package( + self._get_pool(), package + ) - self.line(f"Using version {version} for {name}") - else: - # check that the specified version/constraint exists - # before we proceed - name, _ = self._find_best_version_for_package( - requirement["name"], - requirement["version"], - allow_prereleases=allow_prereleases, - source=source, - ) + self.line( + f"Using version {package_constraint} for" + f" {package}" + ) + + constraint["version"] = package_constraint - requirement["name"] = name + if package: + result.append(constraint) - result.append(requirement) + if self.io.is_interactive(): + package = self.ask("\nAdd a package (leave blank to skip):") return result - def _find_best_version_for_package( + def _determine_requirements( self, - name: str, - required_version: str | None = None, + requires: list[str], allow_prereleases: bool = False, source: str | None = None, - ) -> tuple[str, str]: - from poetry.version.version_selector import VersionSelector - - selector = VersionSelector(self._get_pool()) - package = selector.find_best_candidate( - name, required_version, allow_prereleases=allow_prereleases, source=source - ) - - if not package: - # TODO: find similar - raise ValueError(f"Could not find a matching version of package {name}") - - return package.pretty_name, selector.find_recommended_require_version(package) - - def _parse_requirements(self, requirements: list[str]) -> list[dict[str, Any]]: - from poetry.core.pyproject.exceptions import PyProjectException - - try: - cwd = self.poetry.file.parent - except (PyProjectException, RuntimeError): - cwd = Path.cwd() - - return [ - parse_dependency_specification( - requirement=requirement, - env=self.env if isinstance(self, EnvCommand) else None, - cwd=cwd, + ) -> list[dict[str, Any]]: + if not requires: + return self._determine_requirements_interactive() + else: + return determine_requirements_from_list( + self, self._get_pool(), requires, allow_prereleases, source ) - for requirement in requirements - ] - - def _format_requirements(self, requirements: list[dict[str, str]]) -> Requirements: - requires: Requirements = {} - for requirement in requirements: - name = requirement.pop("name") - constraint: str | InlineTable - if "version" in requirement and len(requirement) == 1: - constraint = requirement["version"] - else: - constraint = inline_table() - constraint.trivia.trail = "\n" - constraint.update(requirement) - - requires[name] = constraint - - return requires def _validate_author(self, author: str, default: str) -> str | None: from poetry.core.packages.package import AUTHOR_REGEX @@ -466,9 +381,6 @@ def _get_pool(self) -> Pool: from poetry.repositories import Pool from poetry.repositories.pypi_repository import PyPiRepository - if isinstance(self, EnvCommand): - return self.poetry.pool - if self._pool is None: self._pool = Pool() self._pool.add_repository(PyPiRepository()) diff --git a/src/poetry/console/commands/new.py b/src/poetry/console/commands/new.py index cde571aa202..fc09b421528 100644 --- a/src/poetry/console/commands/new.py +++ b/src/poetry/console/commands/new.py @@ -3,11 +3,19 @@ import sys from contextlib import suppress +from typing import TYPE_CHECKING from cleo.helpers import argument from cleo.helpers import option from poetry.console.commands.command import Command +from poetry.utils.requirements import determine_requirements_from_list +from poetry.utils.requirements import format_requirements + + +if TYPE_CHECKING: + from poetry.repositories import Pool + from poetry.utils.requirements import Requirements class NewCommand(Command): @@ -17,6 +25,19 @@ class NewCommand(Command): arguments = [argument("path", "The path to create the project at.")] options = [ option("name", None, "Set the resulting package name.", flag=False), + option("description", None, "Description of the package.", flag=False), + option("package-version", None, "Set the version of the package.", flag=False), + option("author", None, "Author name of the package.", flag=False), + option("python", None, "Compatible Python versions.", flag=False), + option( + "dependency", + None, + "Package to require, with an optional version constraint, " + "e.g. requests:^2.10.0 or requests=2.11.1.", + flag=False, + multiple=True, + ), + option("license", "License of the package.", flag=False), option("src", None, "Use the src layout for the project."), option( "readme", @@ -26,6 +47,11 @@ class NewCommand(Command): ), ] + def __init__(self) -> None: + super().__init__() + + self._pool: Pool | None = None + def handle(self) -> int: from pathlib import Path @@ -49,6 +75,10 @@ def handle(self) -> int: if not name: name = path.name + description = self.option("description") or "" + license = self.option("license") or "" + version = self.option("package-version") or "0.1.0" + if path.exists() and list(path.glob("*")): # Directory is not empty. Aborting. raise RuntimeError( @@ -58,22 +88,36 @@ def handle(self) -> int: readme_format = self.option("readme") or "md" config = GitConfig() - author = None - if config.get("user.name"): + author = self.option("author") + if not author and config.get("user.name"): author = config["user.name"] author_email = config.get("user.email") if author_email: author += f" <{author_email}>" current_env = SystemEnv(Path(sys.executable)) - default_python = "^" + ".".join(str(v) for v in current_env.version_info[:2]) + + python = self.option("python") + if not python: + python = "^" + ".".join(str(v) for v in current_env.version_info[:2]) + + requirements: Requirements = {} + if self.option("dependency"): + requirements = format_requirements( + determine_requirements_from_list( + self, self._get_pool(), self.option("dependency") + ) + ) layout_ = layout_cls( name, - "0.1.0", + version, + description=description, author=author, + license=license, + python=python, + dependencies=requirements, readme_format=readme_format, - python=default_python, ) layout_.create(path) @@ -88,3 +132,13 @@ def handle(self) -> int: ) return 0 + + def _get_pool(self) -> Pool: + from poetry.repositories import Pool + from poetry.repositories.pypi_repository import PyPiRepository + + if self._pool is None: + self._pool = Pool() + self._pool.add_repository(PyPiRepository()) + + return self._pool diff --git a/src/poetry/utils/requirements.py b/src/poetry/utils/requirements.py new file mode 100644 index 00000000000..4937bcfa919 --- /dev/null +++ b/src/poetry/utils/requirements.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING +from typing import Any +from typing import Dict +from typing import Mapping +from typing import Union + +from tomlkit import inline_table + +from poetry.utils.dependency_specification import parse_dependency_specification + + +if TYPE_CHECKING: + from tomlkit.items import InlineTable + + from poetry.console.commands.command import Command + from poetry.repositories import Pool + from poetry.utils.env import Env + + +Requirements = Dict[str, Union[str, Mapping[str, Any]]] + + +def parse_requirements( + requirements: list[str], command: Command, env: Env | None +) -> list[dict[str, Any]]: + from poetry.core.pyproject.exceptions import PyProjectException + + try: + cwd = command.poetry.file.parent + except (PyProjectException, RuntimeError): + cwd = Path.cwd() + + return [ + parse_dependency_specification( + requirement=requirement, + env=env, + cwd=cwd, + ) + for requirement in requirements + ] + + +def format_requirements(requirements: list[dict[str, str]]) -> Requirements: + requires: Requirements = {} + for requirement in requirements: + name = requirement.pop("name") + constraint: str | InlineTable + if "version" in requirement and len(requirement) == 1: + constraint = requirement["version"] + else: + constraint = inline_table() + constraint.trivia.trail = "\n" + constraint.update(requirement) + + requires[name] = constraint + + return requires + + +def find_best_version_for_package( + pool: Pool, + name: str, + required_version: str | None = None, + allow_prereleases: bool = False, + source: str | None = None, +) -> tuple[str, str]: + from poetry.version.version_selector import VersionSelector + + selector = VersionSelector(pool) + package = selector.find_best_candidate( + name, required_version, allow_prereleases=allow_prereleases, source=source + ) + + if not package: + # TODO: find similar + raise ValueError(f"Could not find a matching version of package {name}") + + return package.pretty_name, selector.find_recommended_require_version(package) + + +def determine_requirements_from_list( + command: Command, + pool: Pool, + requires: list[str], + allow_prereleases: bool = False, + source: str | None = None, +) -> list[dict[str, Any]]: + result = [] + for requirement in parse_requirements(requires, command, None): + if "git" in requirement or "url" in requirement or "path" in requirement: + result.append(requirement) + continue + elif "version" not in requirement: + # determine the best version automatically + name, version = find_best_version_for_package( + pool, + requirement["name"], + allow_prereleases=allow_prereleases, + source=source, + ) + requirement["version"] = version + requirement["name"] = name + + command.line(f"Using version {version} for {name}") + else: + # check that the specified version/constraint exists + # before we proceed + name, _ = find_best_version_for_package( + pool, + requirement["name"], + requirement["version"], + allow_prereleases=allow_prereleases, + source=source, + ) + + requirement["name"] = name + + result.append(requirement) + + return result diff --git a/tests/console/commands/test_init.py b/tests/console/commands/test_init.py index 5f176ba0eb9..d5032feb7b0 100644 --- a/tests/console/commands/test_init.py +++ b/tests/console/commands/test_init.py @@ -15,6 +15,7 @@ from poetry.console.commands.init import InitCommand from poetry.repositories import Pool from poetry.utils._compat import decode +from poetry.utils.requirements import parse_requirements from tests.helpers import PoetryTestApplication from tests.helpers import get_package @@ -876,7 +877,7 @@ def test_predefined_all_options(tester: CommandTester, repo: TestRepository): def test_add_package_with_extras_and_whitespace(tester: CommandTester): - result = tester.command._parse_requirements(["databases[postgresql, sqlite]"]) + result = parse_requirements(["databases[postgresql, sqlite]"], tester.command, None) assert result[0]["name"] == "databases" assert len(result[0]["extras"]) == 2 diff --git a/tests/console/commands/test_new.py b/tests/console/commands/test_new.py index 79f09e20f27..b6cb5a5e14f 100644 --- a/tests/console/commands/test_new.py +++ b/tests/console/commands/test_new.py @@ -6,12 +6,14 @@ import pytest from poetry.factory import Factory +from tests.helpers import get_package if TYPE_CHECKING: from cleo.testers.command_tester import CommandTester from poetry.poetry import Poetry + from tests.helpers import TestRepository from tests.types import CommandTesterFactory @@ -21,7 +23,10 @@ def tester(command_tester_factory: CommandTesterFactory) -> CommandTester: def verify_project_directory( - path: Path, package_name: str, package_path: str, include_from: str | None = None + path: Path, + package_name: str, + package_path: str, + include_from: str | None = None, ) -> Poetry: package_path = Path(package_path) assert path.is_dir() @@ -170,3 +175,41 @@ def test_command_new_with_readme(fmt: str | None, tester: CommandTester, tmp_dir poetry = verify_project_directory(path, package, package, None) assert poetry.local_config.get("readme") == f"README.{fmt or 'md'}" + + +def test_command_new_with_dependencies( + tester: CommandTester, repo: TestRepository, tmp_dir: str +): + repo.add_package(get_package("pendulum", "2.0.0")) + repo.add_package(get_package("pytest", "3.6.0")) + + package_path = "custompackage" + path = Path(tmp_dir) / package_path + options = [ + path.as_posix(), + "--name custompackage", + "--package-version 1.2.3", + "--description 'My Description'", + "--author 'Your Name ' ", + "--license 'My License'", + "--python '^3.8' ", + "--dependency pendulum", + ] + tester.execute(" ".join(options)) + poetry = verify_project_directory(path, "custompackage", package_path, None) + + expected = """\ +[tool.poetry] +name = "custompackage" +version = "1.2.3" +description = "My Description" +authors = ["Your Name "] +license = "My License" +readme = "README.md" + +[tool.poetry.dependencies] +python = "^3.8" +pendulum = "^2.0.0" +""" + # Replacing possible \r\n because as_string adds them on Windows + assert expected in poetry.pyproject.data.as_string().replace("\r\n", "\n")