From e764a90475a5ed514e58515dfc430acc525508aa Mon Sep 17 00:00:00 2001 From: Arun Babu Neelicattu Date: Mon, 4 Mar 2024 00:15:21 +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 | 82 +++++---------- src/poetry/layouts/layout.py | 7 +- tests/console/commands/conftest.py | 36 +++++++ tests/console/commands/test_init.py | 33 ------ tests/console/commands/test_new.py | 9 ++ 7 files changed, 177 insertions(+), 150 deletions(-) create mode 100644 tests/console/commands/conftest.py diff --git a/docs/cli.md b/docs/cli.md index 161ff107c5c..2e6a8fdd21c 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 670fa4cb09e..3e93adc763d 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 @@ -71,13 +72,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"): @@ -88,6 +82,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(): @@ -107,7 +119,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" @@ -117,21 +129,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") @@ -141,22 +156,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, @@ -165,13 +181,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 = {} @@ -182,27 +199,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"): @@ -213,44 +228,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( @@ -278,7 +310,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 = [] @@ -368,7 +404,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 fd32fa6137f..dc2b89e0a2b 100644 --- a/src/poetry/console/commands/new.py +++ b/src/poetry/console/commands/new.py @@ -1,13 +1,12 @@ from __future__ import annotations -from contextlib import suppress from typing import TYPE_CHECKING from typing import ClassVar from cleo.helpers import argument from cleo.helpers import option -from poetry.console.commands.command import Command +from poetry.console.commands.init import InitCommand if TYPE_CHECKING: @@ -15,7 +14,7 @@ from cleo.io.inputs.option import Option -class NewCommand(Command): +class NewCommand(InitCommand): name = "new" description = "Creates a new Python project at ." @@ -23,6 +22,12 @@ class NewCommand(Command): argument("path", "The path to create the project at.") ] options: ClassVar[list[Option]] = [ + 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( @@ -31,80 +36,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() + 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", ) - - 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 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/conftest.py b/tests/console/commands/conftest.py new file mode 100644 index 00000000000..8c095a6bb81 --- /dev/null +++ b/tests/console/commands/conftest.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +import pytest + + +@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" +""" 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()