Skip to content

Commit 28bbbf3

Browse files
committed
add support for PEP 621: poetry add - change "--optional" to require an extra the optional dependency is added to (#9135)
1 parent 1aaabd6 commit 28bbbf3

File tree

3 files changed

+99
-18
lines changed

3 files changed

+99
-18
lines changed

docs/cli.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -456,7 +456,7 @@ about dependency groups.
456456
* `--dev (-D)`: Add package as development dependency. (**Deprecated**, use `-G dev` instead)
457457
* `--editable (-e)`: Add vcs/path dependencies as editable.
458458
* `--extras (-E)`: Extras to activate for the dependency. (multiple values allowed)
459-
* `--optional`: Add as an optional dependency.
459+
* `--optional`: Add as an optional dependency to an extra.
460460
* `--python`: Python version for which the dependency must be installed.
461461
* `--platform`: Platforms for which the dependency must be installed.
462462
* `--source`: Name of the source to use to install the package.

src/poetry/console/commands/add.py

+36-8
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,12 @@ class AddCommand(InstallerCommand, InitCommand):
5454
flag=False,
5555
multiple=True,
5656
),
57-
option("optional", None, "Add as an optional dependency."),
57+
option(
58+
"optional",
59+
None,
60+
"Add as an optional dependency to an extra.",
61+
flag=False,
62+
),
5863
option(
5964
"python",
6065
None,
@@ -137,6 +142,10 @@ def handle(self) -> int:
137142
"You can only specify one package when using the --extras option"
138143
)
139144

145+
optional = self.option("optional")
146+
if optional and group != MAIN_GROUP:
147+
raise ValueError("You can only add optional dependencies to the main group")
148+
140149
# tomlkit types are awkward to work with, treat content as a mostly untyped
141150
# dictionary.
142151
content: dict[str, Any] = self.poetry.file.read()
@@ -156,13 +165,19 @@ def handle(self) -> int:
156165
or "optional-dependencies" in project_content
157166
):
158167
use_project_section = True
168+
if optional:
169+
project_section = project_content.get(
170+
"optional-dependencies", {}
171+
).get(optional, array())
172+
else:
173+
project_section = project_content.get("dependencies", array())
159174
project_dependency_names = [
160-
Dependency.create_from_pep_508(dep).name
161-
for dep in project_content.get("dependencies", {})
175+
Dependency.create_from_pep_508(dep).name for dep in project_section
162176
]
177+
else:
178+
project_section = array()
163179

164180
poetry_section = poetry_content.get("dependencies", table())
165-
project_section = project_content.get("dependencies", array())
166181
else:
167182
if "group" not in poetry_content:
168183
poetry_content["group"] = table(is_super_table=True)
@@ -194,6 +209,13 @@ def handle(self) -> int:
194209
self.line("Nothing to add.")
195210
return 0
196211

212+
if optional and not use_project_section:
213+
self.line_error(
214+
"<warning>Optional dependencies will not be added to extras"
215+
" in legacy mode. Consider converting your project to use the [project]"
216+
" section.</warning>"
217+
)
218+
197219
requirements = self._determine_requirements(
198220
packages,
199221
allow_prereleases=self.option("allow-prereleases"),
@@ -214,7 +236,7 @@ def handle(self) -> int:
214236

215237
constraint[key] = value
216238

217-
if self.option("optional"):
239+
if optional:
218240
constraint["optional"] = True
219241

220242
if self.option("allow-prereleases"):
@@ -290,7 +312,7 @@ def handle(self) -> int:
290312
# that cannot be stored in the project section
291313
poetry_constraint: dict[str, Any] = inline_table()
292314
if not isinstance(constraint, str):
293-
for key in ["optional", "allow-prereleases", "develop", "source"]:
315+
for key in ["allow-prereleases", "develop", "source"]:
294316
if value := constraint.get(key):
295317
poetry_constraint[key] = value
296318
if poetry_constraint:
@@ -310,9 +332,15 @@ def handle(self) -> int:
310332
poetry_section[constraint_name] = poetry_constraint
311333

312334
# Refresh the locker
313-
if project_section and "dependencies" not in project_content:
335+
if project_section:
314336
assert group == MAIN_GROUP
315-
project_content["dependencies"] = project_section
337+
if optional:
338+
if "optional-dependencies" not in project_content:
339+
project_content["optional-dependencies"] = table()
340+
if optional not in project_content["optional-dependencies"]:
341+
project_content["optional-dependencies"][optional] = project_section
342+
elif "dependencies" not in project_content:
343+
project_content["dependencies"] = project_section
316344
if poetry_section:
317345
if "tool" not in content:
318346
content["tool"] = table()

tests/console/commands/test_add.py

+62-9
Original file line numberDiff line numberDiff line change
@@ -795,10 +795,40 @@ def test_add_url_constraint_wheel_with_extras(
795795
}
796796

797797

798+
@pytest.mark.parametrize("project_dependencies", [True, False])
799+
@pytest.mark.parametrize(
800+
("existing_extras", "expected_extras"),
801+
[
802+
(None, {"my-extra": ["cachy (==0.2.0)"]}),
803+
(
804+
{"other": ["foo>2"]},
805+
{"other": ["foo>2"], "my-extra": ["cachy (==0.2.0)"]},
806+
),
807+
({"my-extra": ["foo>2"]}, {"my-extra": ["foo>2", "cachy (==0.2.0)"]}),
808+
(
809+
{"my-extra": ["foo>2", "cachy (==0.1.0)", "bar>1"]},
810+
{"my-extra": ["foo>2", "cachy (==0.2.0)", "bar>1"]},
811+
),
812+
],
813+
)
798814
def test_add_constraint_with_optional(
799-
app: PoetryTestApplication, tester: CommandTester
815+
app: PoetryTestApplication,
816+
tester: CommandTester,
817+
project_dependencies: bool,
818+
existing_extras: dict[str, list[str]] | None,
819+
expected_extras: dict[str, list[str]],
800820
) -> None:
801-
tester.execute("cachy=0.2.0 --optional")
821+
pyproject: dict[str, Any] = app.poetry.file.read()
822+
if project_dependencies:
823+
pyproject["project"]["dependencies"] = ["foo>1"]
824+
if existing_extras:
825+
pyproject["project"]["optional-dependencies"] = existing_extras
826+
else:
827+
pyproject["tool"]["poetry"]["dependencies"]["foo"] = "^1.0"
828+
pyproject = cast("TOMLDocument", pyproject)
829+
app.poetry.file.write(pyproject)
830+
831+
tester.execute("cachy=0.2.0 --optional my-extra")
802832
expected = """\
803833
804834
Updating dependencies
@@ -813,14 +843,37 @@ def test_add_constraint_with_optional(
813843
assert isinstance(tester.command, InstallerCommand)
814844
assert tester.command.installer.executor.installations_count == 0
815845

816-
pyproject: dict[str, Any] = app.poetry.file.read()
817-
content = pyproject["tool"]["poetry"]
846+
pyproject2: dict[str, Any] = app.poetry.file.read()
847+
project_content = pyproject2["project"]
848+
poetry_content = pyproject2["tool"]["poetry"]
818849

819-
assert "cachy" in content["dependencies"]
820-
assert content["dependencies"]["cachy"] == {
821-
"version": "0.2.0",
822-
"optional": True,
823-
}
850+
if project_dependencies:
851+
assert "cachy" not in poetry_content["dependencies"]
852+
assert "cachy" not in project_content["dependencies"]
853+
assert "my-extra" in project_content["optional-dependencies"]
854+
assert project_content["optional-dependencies"] == expected_extras
855+
assert not tester.io.fetch_error()
856+
else:
857+
assert "dependencies" not in project_content
858+
assert "optional-dependencies" not in project_content
859+
assert "cachy" in poetry_content["dependencies"]
860+
assert poetry_content["dependencies"]["cachy"] == {
861+
"version": "0.2.0",
862+
"optional": True,
863+
}
864+
assert (
865+
"Optional dependencies will not be added to extras in legacy mode."
866+
in tester.io.fetch_error()
867+
)
868+
869+
870+
def test_add_constraint_with_optional_not_main_group(
871+
app: PoetryTestApplication, tester: CommandTester
872+
) -> None:
873+
with pytest.raises(ValueError) as e:
874+
tester.execute("cachy=0.2.0 --group dev --optional my-extra")
875+
876+
assert str(e.value) == "You can only add optional dependencies to the main group"
824877

825878

826879
def test_add_constraint_with_python(

0 commit comments

Comments
 (0)