Skip to content

Commit

Permalink
add support for PEP 621: poetry add - change "--optional" to require …
Browse files Browse the repository at this point in the history
…an extra the optional dependency is added to (#9135)
  • Loading branch information
radoering committed Sep 15, 2024
1 parent 1aaabd6 commit 28bbbf3
Show file tree
Hide file tree
Showing 3 changed files with 99 additions and 18 deletions.
2 changes: 1 addition & 1 deletion docs/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
44 changes: 36 additions & 8 deletions src/poetry/console/commands/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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()
Expand All @@ -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)
Expand Down Expand Up @@ -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(
"<warning>Optional dependencies will not be added to extras"
" in legacy mode. Consider converting your project to use the [project]"
" section.</warning>"
)

requirements = self._determine_requirements(
packages,
allow_prereleases=self.option("allow-prereleases"),
Expand All @@ -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"):
Expand Down Expand Up @@ -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:
Expand All @@ -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()
Expand Down
71 changes: 62 additions & 9 deletions tests/console/commands/test_add.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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(
Expand Down

0 comments on commit 28bbbf3

Please sign in to comment.