diff --git a/docs/cli.md b/docs/cli.md index 6ff3f48664a..085f1fe86a9 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -456,7 +456,7 @@ about dependency groups. * `--dev (-D)`: Add package as development dependency. (**Deprecated**, use `-G dev` instead) * `--editable (-e)`: Add vcs/path dependencies as editable. * `--extras (-E)`: Extras to activate for the dependency. (multiple values allowed) -* `--optional`: Add as an optional dependency. +* `--optional`: Add as an optional dependency to an extra. * `--python`: Python version for which the dependency must be installed. * `--platform`: Platforms for which the dependency must be installed. * `--source`: Name of the source to use to install the package. diff --git a/src/poetry/console/commands/add.py b/src/poetry/console/commands/add.py index 303aca542da..57d4d5ff2f0 100644 --- a/src/poetry/console/commands/add.py +++ b/src/poetry/console/commands/add.py @@ -54,7 +54,12 @@ class AddCommand(InstallerCommand, InitCommand): flag=False, multiple=True, ), - option("optional", None, "Add as an optional dependency."), + option( + "optional", + None, + "Add as an optional dependency to an extra.", + flag=False, + ), option( "python", None, @@ -137,6 +142,10 @@ def handle(self) -> int: "You can only specify one package when using the --extras option" ) + optional = self.option("optional") + if optional and group != MAIN_GROUP: + raise ValueError("You can only add optional dependencies to the main group") + # tomlkit types are awkward to work with, treat content as a mostly untyped # dictionary. content: dict[str, Any] = self.poetry.file.read() @@ -156,13 +165,19 @@ def handle(self) -> int: or "optional-dependencies" in project_content ): use_project_section = True + if optional: + project_section = project_content.get( + "optional-dependencies", {} + ).get(optional, array()) + else: + project_section = project_content.get("dependencies", array()) project_dependency_names = [ - Dependency.create_from_pep_508(dep).name - for dep in project_content.get("dependencies", {}) + Dependency.create_from_pep_508(dep).name for dep in project_section ] + else: + project_section = array() poetry_section = poetry_content.get("dependencies", table()) - project_section = project_content.get("dependencies", array()) else: if "group" not in poetry_content: poetry_content["group"] = table(is_super_table=True) @@ -194,6 +209,13 @@ def handle(self) -> int: self.line("Nothing to add.") return 0 + if optional and not use_project_section: + self.line_error( + "Optional dependencies will not be added to extras" + " in legacy mode. Consider converting your project to use the [project]" + " section." + ) + requirements = self._determine_requirements( packages, allow_prereleases=self.option("allow-prereleases"), @@ -214,7 +236,7 @@ def handle(self) -> int: constraint[key] = value - if self.option("optional"): + if optional: constraint["optional"] = True if self.option("allow-prereleases"): @@ -290,7 +312,7 @@ def handle(self) -> int: # that cannot be stored in the project section poetry_constraint: dict[str, Any] = inline_table() if not isinstance(constraint, str): - for key in ["optional", "allow-prereleases", "develop", "source"]: + for key in ["allow-prereleases", "develop", "source"]: if value := constraint.get(key): poetry_constraint[key] = value if poetry_constraint: @@ -310,9 +332,15 @@ def handle(self) -> int: poetry_section[constraint_name] = poetry_constraint # Refresh the locker - if project_section and "dependencies" not in project_content: + if project_section: assert group == MAIN_GROUP - project_content["dependencies"] = project_section + if optional: + if "optional-dependencies" not in project_content: + project_content["optional-dependencies"] = table() + if optional not in project_content["optional-dependencies"]: + project_content["optional-dependencies"][optional] = project_section + elif "dependencies" not in project_content: + project_content["dependencies"] = project_section if poetry_section: if "tool" not in content: content["tool"] = table() diff --git a/tests/console/commands/test_add.py b/tests/console/commands/test_add.py index 2289bfb8ba9..ebdea972688 100644 --- a/tests/console/commands/test_add.py +++ b/tests/console/commands/test_add.py @@ -795,10 +795,40 @@ def test_add_url_constraint_wheel_with_extras( } +@pytest.mark.parametrize("project_dependencies", [True, False]) +@pytest.mark.parametrize( + ("existing_extras", "expected_extras"), + [ + (None, {"my-extra": ["cachy (==0.2.0)"]}), + ( + {"other": ["foo>2"]}, + {"other": ["foo>2"], "my-extra": ["cachy (==0.2.0)"]}, + ), + ({"my-extra": ["foo>2"]}, {"my-extra": ["foo>2", "cachy (==0.2.0)"]}), + ( + {"my-extra": ["foo>2", "cachy (==0.1.0)", "bar>1"]}, + {"my-extra": ["foo>2", "cachy (==0.2.0)", "bar>1"]}, + ), + ], +) def test_add_constraint_with_optional( - app: PoetryTestApplication, tester: CommandTester + app: PoetryTestApplication, + tester: CommandTester, + project_dependencies: bool, + existing_extras: dict[str, list[str]] | None, + expected_extras: dict[str, list[str]], ) -> None: - tester.execute("cachy=0.2.0 --optional") + pyproject: dict[str, Any] = app.poetry.file.read() + if project_dependencies: + pyproject["project"]["dependencies"] = ["foo>1"] + if existing_extras: + pyproject["project"]["optional-dependencies"] = existing_extras + else: + pyproject["tool"]["poetry"]["dependencies"]["foo"] = "^1.0" + pyproject = cast("TOMLDocument", pyproject) + app.poetry.file.write(pyproject) + + tester.execute("cachy=0.2.0 --optional my-extra") expected = """\ Updating dependencies @@ -813,14 +843,37 @@ def test_add_constraint_with_optional( assert isinstance(tester.command, InstallerCommand) assert tester.command.installer.executor.installations_count == 0 - pyproject: dict[str, Any] = app.poetry.file.read() - content = pyproject["tool"]["poetry"] + pyproject2: dict[str, Any] = app.poetry.file.read() + project_content = pyproject2["project"] + poetry_content = pyproject2["tool"]["poetry"] - assert "cachy" in content["dependencies"] - assert content["dependencies"]["cachy"] == { - "version": "0.2.0", - "optional": True, - } + if project_dependencies: + assert "cachy" not in poetry_content["dependencies"] + assert "cachy" not in project_content["dependencies"] + assert "my-extra" in project_content["optional-dependencies"] + assert project_content["optional-dependencies"] == expected_extras + assert not tester.io.fetch_error() + else: + assert "dependencies" not in project_content + assert "optional-dependencies" not in project_content + assert "cachy" in poetry_content["dependencies"] + assert poetry_content["dependencies"]["cachy"] == { + "version": "0.2.0", + "optional": True, + } + assert ( + "Optional dependencies will not be added to extras in legacy mode." + in tester.io.fetch_error() + ) + + +def test_add_constraint_with_optional_not_main_group( + app: PoetryTestApplication, tester: CommandTester +) -> None: + with pytest.raises(ValueError) as e: + tester.execute("cachy=0.2.0 --group dev --optional my-extra") + + assert str(e.value) == "You can only add optional dependencies to the main group" def test_add_constraint_with_python(