From 924d8a12f277484fd2dd4f0fb3d4920642331152 Mon Sep 17 00:00:00 2001 From: Arun Babu Neelicattu Date: Sat, 2 Mar 2024 02:12:11 +0100 Subject: [PATCH] cli/new: support interactive config This change allows new command to be used interactively similar to the init command. Additionally, this also allows for configuration of description, author, python and dependencies via command line options. --- docs/cli.md | 6 ++ src/poetry/console/commands/init.py | 154 +++++++++++++++++----------- src/poetry/console/commands/new.py | 83 +++++---------- src/poetry/layouts/layout.py | 7 +- tests/console/commands/test_init.py | 33 ------ tests/console/commands/test_new.py | 9 ++ 6 files changed, 141 insertions(+), 151 deletions(-) diff --git a/docs/cli.md b/docs/cli.md index 7a10d62d63f..d47617007fe 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -102,11 +102,17 @@ my-package ### Options +* `--interactive (-i)`: Allow interactive specification of project configuration. * `--name`: Set the resulting package name. * `--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/) in mind. +* `--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`. +* `--dev-dependency`: Development requirements, see `--dependency`. ## init diff --git a/src/poetry/console/commands/init.py b/src/poetry/console/commands/init.py index fe708c98ab4..53efafa7826 100644 --- a/src/poetry/console/commands/init.py +++ b/src/poetry/console/commands/init.py @@ -1,5 +1,6 @@ from __future__ import annotations +from contextlib import suppress from pathlib import Path from typing import TYPE_CHECKING from typing import Any @@ -69,13 +70,6 @@ def __init__(self) -> None: def handle(self) -> int: from pathlib import Path - from poetry.core.vcs.git import GitConfig - - from poetry.config.config import Config - from poetry.layouts import layout - from poetry.pyproject.toml import PyProjectTOML - from poetry.utils.env import EnvManager - project_path = Path.cwd() if self.io.input.option("directory"): @@ -86,6 +80,24 @@ def handle(self) -> int: ) return 1 + return self._init_pyproject(project_path=project_path) + + def _init_pyproject( + self, + project_path: Path, + allow_interactive: bool = True, + layout_name: str = "standard", + readme_format: str = "md", + ) -> int: + from poetry.core.vcs.git import GitConfig + + from poetry.config.config import Config + from poetry.layouts import layout + from poetry.pyproject.toml import PyProjectTOML + from poetry.utils.env import EnvManager + + is_interactive = self.io.is_interactive() and allow_interactive + pyproject = PyProjectTOML(project_path / "pyproject.toml") if pyproject.file.exists(): @@ -105,7 +117,7 @@ def handle(self) -> int: vcs_config = GitConfig() - if self.io.is_interactive(): + if is_interactive: self.line("") self.line( "This command will guide you through creating your" @@ -115,21 +127,24 @@ def handle(self) -> int: name = self.option("name") if not name: - name = Path.cwd().name.lower() + name = project_path.name.lower() - question = self.create_question( - f"Package name [{name}]: ", default=name - ) - name = self.ask(question) + if is_interactive: + question = self.create_question( + f"Package name [{name}]: ", default=name + ) + name = self.ask(question) version = "0.1.0" - question = self.create_question( - f"Version [{version}]: ", default=version - ) - version = self.ask(question) - description = self.option("description") - if not description: + if is_interactive: + question = self.create_question( + f"Version [{version}]: ", default=version + ) + version = self.ask(question) + + description = self.option("description") or "" + if not description and is_interactive: description = self.ask(self.create_question("Description []: ", default="")) author = self.option("author") @@ -139,22 +154,23 @@ def handle(self) -> int: if author_email: author += f" <{author_email}>" - question = self.create_question( - f"Author [{author}, n to skip]: ", default=author - ) - question.set_validator(lambda v: self._validate_author(v, author)) - author = self.ask(question) + if is_interactive: + question = self.create_question( + f"Author [{author}, n to skip]: ", default=author + ) + question.set_validator(lambda v: self._validate_author(v, author)) + author = self.ask(question) authors = [author] if author else [] - license = self.option("license") - if not license: - license = self.ask(self.create_question("License []: ", default="")) + license_name = self.option("license") + if not license_name and is_interactive: + license_name = self.ask(self.create_question("License []: ", default="")) python = self.option("python") if not python: config = Config.create() - default_python = ( + python = ( "^" + EnvManager.get_python_version( precision=2, @@ -163,13 +179,14 @@ def handle(self) -> int: ).to_string() ) - question = self.create_question( - f"Compatible Python versions [{default_python}]: ", - default=default_python, - ) - python = self.ask(question) + if is_interactive: + question = self.create_question( + f"Compatible Python versions [{python}]: ", + default=python, + ) + python = self.ask(question) - if self.io.is_interactive(): + if is_interactive: self.line("") requirements: Requirements = {} @@ -180,27 +197,25 @@ def handle(self) -> int: question_text = "Would you like to define your main dependencies interactively?" help_message = """\ -You can specify a package in the following forms: - - A single name (requests): this will search for matches on PyPI - - A name and a constraint (requests@^2.23.0) - - A git url (git+https://github.com/python-poetry/poetry.git) - - A git url with a revision\ - (git+https://github.com/python-poetry/poetry.git#develop) - - A file path (../my-package/my-package.whl) - - A directory (../my-package/) - - A url (https://example.com/packages/my-package-0.1.0.tar.gz) -""" + You can specify a package in the following forms: + - A single name (requests): this will search for matches on PyPI + - A name and a constraint (requests@^2.23.0) + - A git url (git+https://github.com/python-poetry/poetry.git) + - A git url with a revision\ + (git+https://github.com/python-poetry/poetry.git#develop) + - A file path (../my-package/my-package.whl) + - A directory (../my-package/) + - A url (https://example.com/packages/my-package-0.1.0.tar.gz) + """ help_displayed = False - if self.confirm(question_text, True): - if self.io.is_interactive(): - self.line(help_message) - help_displayed = True + if is_interactive and self.confirm(question_text, True): + self.line(help_message) + help_displayed = True requirements.update( self._format_requirements(self._determine_requirements([])) ) - if self.io.is_interactive(): - self.line("") + self.line("") dev_requirements: Requirements = {} if self.option("dev-dependency"): @@ -211,44 +226,61 @@ def handle(self) -> int: question_text = ( "Would you like to define your development dependencies interactively?" ) - if self.confirm(question_text, True): - if self.io.is_interactive() and not help_displayed: + if is_interactive and self.confirm(question_text, True): + if not help_displayed: self.line(help_message) dev_requirements.update( self._format_requirements(self._determine_requirements([])) ) - if self.io.is_interactive(): - self.line("") - layout_ = layout("standard")( + self.line("") + + layout_ = layout(layout_name)( name, version, description=description, author=authors[0] if authors else None, - license=license, + readme_format=readme_format, + license=license_name, python=python, dependencies=requirements, dev_dependencies=dev_requirements, ) + create_layout = not project_path.exists() + + if create_layout: + layout_.create(project_path, with_pyproject=False) + content = layout_.generate_poetry_content() for section, item in content.items(): pyproject.data.append(section, item) - if self.io.is_interactive(): + if is_interactive: self.line("Generated file") self.line("") self.line(pyproject.data.as_string().replace("\r\n", "\n")) self.line("") - if not self.confirm("Do you confirm generation?", True): + if is_interactive and not self.confirm("Do you confirm generation?", True): self.line_error("Command aborted") return 1 pyproject.save() + if create_layout: + path = project_path.resolve() + + with suppress(ValueError): + path = path.relative_to(Path.cwd()) + + self.line( + f"Created package {layout_._package_name} in" + f" {path.as_posix()}" + ) + return 0 def _generate_choice_list( @@ -276,7 +308,11 @@ def _determine_requirements( requires: list[str], allow_prereleases: bool = False, source: str | None = None, + is_interactive: bool | None = None, ) -> list[dict[str, Any]]: + if is_interactive is None: + is_interactive = self.io.is_interactive() + if not requires: result = [] @@ -366,7 +402,7 @@ def _determine_requirements( if package: result.append(constraint) - if self.io.is_interactive(): + if is_interactive: package = self.ask(follow_up_question) return result diff --git a/src/poetry/console/commands/new.py b/src/poetry/console/commands/new.py index 5f896cf80e8..b9f853cf228 100644 --- a/src/poetry/console/commands/new.py +++ b/src/poetry/console/commands/new.py @@ -1,19 +1,23 @@ from __future__ import annotations -from contextlib import suppress - from cleo.helpers import argument from cleo.helpers import option -from poetry.console.commands.command import Command +from poetry.console.commands.init import InitCommand -class NewCommand(Command): +class NewCommand(InitCommand): name = "new" description = "Creates a new Python project at ." arguments = [argument("path", "The path to create the project at.")] options = [ + option( + "interactive", + "i", + "Allow interactive specification of project configuration.", + flag=True, + ), option("name", None, "Set the resulting package name.", flag=False), option("src", None, "Use the src layout for the project."), option( @@ -22,80 +26,45 @@ class NewCommand(Command): "Specify the readme file format. One of md (default) or rst", flag=False, ), + *[ + o + for o in InitCommand.options + if o.name + in { + "description", + "author", + "python", + "dependency", + "dev-dependency", + "license", + } + ], ] def handle(self) -> int: from pathlib import Path - from poetry.core.vcs.git import GitConfig - - from poetry.config.config import Config - from poetry.layouts import layout - from poetry.utils.env import EnvManager - if self.io.input.option("directory"): self.line_error( "--directory only makes sense with existing projects, and will" " be ignored. You should consider the option --path instead." ) - layout_cls = layout("src") if self.option("src") else layout("standard") - path = Path(self.argument("path")) if not path.is_absolute(): # we do not use resolve here due to compatibility issues # for path.resolve(strict=False) path = Path.cwd().joinpath(path) - name = self.option("name") - if not name: - name = path.name - if path.exists() and list(path.glob("*")): # Directory is not empty. Aborting. raise RuntimeError( f"Destination {path} exists and is not empty" ) - readme_format = self.option("readme") or "md" - - config = GitConfig() - author = None - if config.get("user.name"): - author = config["user.name"] - author_email = config.get("user.email") - if author_email: - author += f" <{author_email}>" - - poetry_config = Config.create() - default_python = ( - "^" - + EnvManager.get_python_version( - precision=2, - prefer_active_python=poetry_config.get( - "virtualenvs.prefer-active-python" - ), - io=self.io, - ).to_string() - ) - - layout_ = layout_cls( - name, - "0.1.0", - author=author, - readme_format=readme_format, - python=default_python, - ) - layout_.create(path) - - path = path.resolve() - - with suppress(ValueError): - path = path.relative_to(Path.cwd()) - - self.line( - f"Created package {layout_._package_name} in" - f" {path.as_posix()}" + return self._init_pyproject( + project_path=path, + allow_interactive=self.option("interactive"), + layout_name="src" if self.option("src") else "standard", + readme_format=self.option("readme") or "md", ) - - return 0 diff --git a/src/poetry/layouts/layout.py b/src/poetry/layouts/layout.py index 41b6d6a8d80..f5174ba3951 100644 --- a/src/poetry/layouts/layout.py +++ b/src/poetry/layouts/layout.py @@ -103,7 +103,9 @@ def get_package_include(self) -> InlineTable | None: return package - def create(self, path: Path, with_tests: bool = True) -> None: + def create( + self, path: Path, with_tests: bool = True, with_pyproject: bool = True + ) -> None: path.mkdir(parents=True, exist_ok=True) self._create_default(path) @@ -112,7 +114,8 @@ def create(self, path: Path, with_tests: bool = True) -> None: if with_tests: self._create_tests(path) - self._write_poetry(path) + if with_pyproject: + self._write_poetry(path) def generate_poetry_content(self) -> TOMLDocument: template = POETRY_DEFAULT diff --git a/tests/console/commands/test_init.py b/tests/console/commands/test_init.py index 953926fbf3d..2f8ddbce675 100644 --- a/tests/console/commands/test_init.py +++ b/tests/console/commands/test_init.py @@ -60,39 +60,6 @@ def tester(patches: None) -> CommandTester: return CommandTester(app.find("init")) -@pytest.fixture -def init_basic_inputs() -> str: - return "\n".join( - [ - "my-package", # Package name - "1.2.3", # Version - "This is a description", # Description - "n", # Author - "MIT", # License - "~2.7 || ^3.6", # Python - "n", # Interactive packages - "n", # Interactive dev packages - "\n", # Generate - ] - ) - - -@pytest.fixture() -def init_basic_toml() -> str: - return """\ -[tool.poetry] -name = "my-package" -version = "1.2.3" -description = "This is a description" -authors = ["Your Name "] -license = "MIT" -readme = "README.md" - -[tool.poetry.dependencies] -python = "~2.7 || ^3.6" -""" - - def test_basic_interactive( tester: CommandTester, init_basic_inputs: str, init_basic_toml: str ) -> None: diff --git a/tests/console/commands/test_new.py b/tests/console/commands/test_new.py index 9bbef7fff17..3bd1da82fa7 100644 --- a/tests/console/commands/test_new.py +++ b/tests/console/commands/test_new.py @@ -229,3 +229,12 @@ def mock_check_output(cmd: str, *_: Any, **__: Any) -> str: """ assert expected in pyproject_file.read_text() + + +def test_basic_interactive_new( + tester: CommandTester, tmp_path: Path, init_basic_inputs: str, init_basic_toml: str +) -> None: + path = tmp_path / "somepackage" + tester.execute(f"--interactive {path.as_posix()}", inputs=init_basic_inputs) + verify_project_directory(path, "my-package", "my_package", None) + assert init_basic_toml in tester.io.fetch_output()