You need to sign in or sign up before continuing.
Commit 4b8384cd by Sébastien Eustace

Update the add and remove commands to support groups

parent 26f13f78
# -*- coding: utf-8 -*-
from typing import Dict from typing import Dict
from typing import List from typing import List
...@@ -16,6 +15,13 @@ class AddCommand(InstallerCommand, InitCommand): ...@@ -16,6 +15,13 @@ class AddCommand(InstallerCommand, InitCommand):
arguments = [argument("name", "The packages to add.", multiple=True)] arguments = [argument("name", "The packages to add.", multiple=True)]
options = [ options = [
option(
"group",
"-G",
"The group to add the dependency to.",
flag=False,
default="default",
),
option("dev", "D", "Add as a development dependency."), option("dev", "D", "Add as a development dependency."),
option("editable", "e", "Add vcs/path dependencies as editable."), option("editable", "e", "Add vcs/path dependencies as editable."),
option( option(
...@@ -71,31 +77,55 @@ class AddCommand(InstallerCommand, InitCommand): ...@@ -71,31 +77,55 @@ class AddCommand(InstallerCommand, InitCommand):
def handle(self) -> int: def handle(self) -> int:
from tomlkit import inline_table from tomlkit import inline_table
from tomlkit import parse as parse_toml
from tomlkit import table
from poetry.core.semver.helpers import parse_constraint from poetry.core.semver.helpers import parse_constraint
from poetry.factory import Factory
packages = self.argument("name") packages = self.argument("name")
is_dev = self.option("dev") if self.option("dev"):
self.line(
"<warning>The --dev option is deprecated, "
"use the `--group dev` notation instead.</warning>"
)
self.line("")
group = "dev"
else:
group = self.option("group")
if self.option("extras") and len(packages) > 1: if self.option("extras") and len(packages) > 1:
raise ValueError( raise ValueError(
"You can only specify one package " "when using the --extras option" "You can only specify one package when using the --extras option"
) )
section = "dependencies"
if is_dev:
section = "dev-dependencies"
original_content = self.poetry.file.read()
content = self.poetry.file.read() content = self.poetry.file.read()
poetry_content = content["tool"]["poetry"] poetry_content = content["tool"]["poetry"]
if section not in poetry_content: if group == "default":
poetry_content[section] = {} if "dependencies" not in poetry_content:
poetry_content["dependencies"] = table()
existing_packages = self.get_existing_packages_from_input( section = poetry_content["dependencies"]
packages, poetry_content, section else:
) if "group" not in poetry_content:
group_table = table()
group_table._is_super_table = True
poetry_content.value._insert_after("dependencies", "group", group_table)
groups = poetry_content["group"]
if group not in groups:
group_table = parse_toml(
f"[tool.poetry.group.{group}.dependencies]\n\n"
)["tool"]["poetry"]["group"][group]
poetry_content["group"][group] = group_table
if "dependencies" not in poetry_content["group"][group]:
poetry_content["group"][group]["dependencies"] = table()
section = poetry_content["group"][group]["dependencies"]
existing_packages = self.get_existing_packages_from_input(packages, section)
if existing_packages: if existing_packages:
self.notify_about_existing_packages(existing_packages) self.notify_about_existing_packages(existing_packages)
...@@ -165,53 +195,48 @@ class AddCommand(InstallerCommand, InitCommand): ...@@ -165,53 +195,48 @@ class AddCommand(InstallerCommand, InitCommand):
if len(constraint) == 1 and "version" in constraint: if len(constraint) == 1 and "version" in constraint:
constraint = constraint["version"] constraint = constraint["version"]
poetry_content[section][_constraint["name"]] = constraint section[_constraint["name"]] = constraint
self.poetry.package.add_dependency(
Factory.create_dependency(
_constraint["name"],
constraint,
groups=[group],
root_dir=self.poetry.file.parent,
)
)
try: # Refresh the locker
# Write new content self.poetry.set_locker(
self.poetry.file.write(content) self.poetry.locker.__class__(self.poetry.locker.lock.path, poetry_content)
)
self._installer.set_locker(self.poetry.locker)
# Cosmetic new line # Cosmetic new line
self.line("") self.line("")
# Update packages self._installer.set_package(self.poetry.package)
self.reset_poetry() self._installer.dry_run(self.option("dry-run"))
self._installer.verbose(self._io.is_verbose())
self._installer.set_package(self.poetry.package) self._installer.update(True)
self._installer.dry_run(self.option("dry-run")) if self.option("lock"):
self._installer.verbose(self._io.is_verbose()) self._installer.lock()
self._installer.update(True)
if self.option("lock"): self._installer.whitelist([r["name"] for r in requirements])
self._installer.lock()
self._installer.whitelist([r["name"] for r in requirements])
status = self._installer.run()
except BaseException:
# Using BaseException here as some exceptions, eg: KeyboardInterrupt, do not inherit from Exception
self.poetry.file.write(original_content)
raise
if status != 0 or self.option("dry-run"):
# Revert changes
if not self.option("dry-run"):
self.line_error(
"\n"
"<error>Failed to add packages, reverting the pyproject.toml file "
"to its original content.</error>"
)
self.poetry.file.write(original_content) status = self._installer.run()
if status == 0 and not self.option("dry-run"):
self.poetry.file.write(content)
return status return status
def get_existing_packages_from_input( def get_existing_packages_from_input(
self, packages: List[str], poetry_content: Dict, target_section: str self, packages: List[str], section: Dict
) -> List[str]: ) -> List[str]:
existing_packages = [] existing_packages = []
for name in packages: for name in packages:
for key in poetry_content[target_section]: for key in section:
if key.lower() == name.lower(): if key.lower() == name.lower():
existing_packages.append(name) existing_packages.append(name)
......
from typing import Any
from typing import Dict
from typing import List
from cleo.helpers import argument from cleo.helpers import argument
from cleo.helpers import option from cleo.helpers import option
from ...utils.helpers import canonicalize_name
from .installer_command import InstallerCommand from .installer_command import InstallerCommand
...@@ -12,6 +15,7 @@ class RemoveCommand(InstallerCommand): ...@@ -12,6 +15,7 @@ class RemoveCommand(InstallerCommand):
arguments = [argument("packages", "The packages to remove.", multiple=True)] arguments = [argument("packages", "The packages to remove.", multiple=True)]
options = [ options = [
option("group", "G", "The group to remove the dependency from.", flag=False),
option("dev", "D", "Remove a package from the development dependencies."), option("dev", "D", "Remove a package from the development dependencies."),
option( option(
"dry-run", "dry-run",
...@@ -30,39 +34,70 @@ list of installed packages ...@@ -30,39 +34,70 @@ list of installed packages
def handle(self) -> int: def handle(self) -> int:
packages = self.argument("packages") packages = self.argument("packages")
is_dev = self.option("dev")
if self.option("dev"):
self.line(
"<warning>The --dev option is deprecated, "
"use the `--group dev` notation instead.</warning>"
)
self.line("")
group = "dev"
else:
group = self.option("group")
content = self.poetry.file.read() content = self.poetry.file.read()
poetry_content = content["tool"]["poetry"] poetry_content = content["tool"]["poetry"]
section = "dependencies"
if is_dev: if group is None:
section = "dev-dependencies" removed = []
group_sections = []
# Deleting entries for group_name, group_section in poetry_content.get("group", {}).items():
requirements = {} group_sections.append(
for name in packages: (group_name, group_section.get("dependencies", {}))
found = False )
for key in poetry_content[section]:
if key.lower() == name.lower(): for group_name, section in [
found = True ("default", poetry_content["dependencies"])
requirements[key] = poetry_content[section][key] ] + group_sections:
break removed += self._remove_packages(packages, section, group_name)
if group_name != "default":
if not found: if not section:
raise ValueError("Package {} not found".format(name)) del poetry_content["group"][group_name]
else:
for key in requirements: poetry_content["group"][group_name]["dependencies"] = section
del poetry_content[section][key] elif group == "dev" and "dev-dependencies" in poetry_content:
# We need to account for the old `dev-dependencies` section
dependencies = ( removed = self._remove_packages(
self.poetry.package.requires packages, poetry_content["dev-dependencies"], "dev"
if section == "dependencies" )
else self.poetry.package.dev_requires
if not poetry_content["dev-dependencies"]:
del poetry_content["dev-dependencies"]
else:
removed = self._remove_packages(
packages, poetry_content["group"][group].get("dependencies", {}), group
) )
for i, dependency in enumerate(reversed(dependencies)): if not poetry_content["group"][group]:
if dependency.name == canonicalize_name(key): del poetry_content["group"][group]
del dependencies[-i]
if "group" in poetry_content and not poetry_content["group"]:
del poetry_content["group"]
removed = set(removed)
not_found = set(packages).difference(removed)
if not_found:
raise ValueError(
"The following packages were not found: {}".format(
", ".join(sorted(not_found))
)
)
# Refresh the locker
self.poetry.set_locker(
self.poetry.locker.__class__(self.poetry.locker.lock.path, poetry_content)
)
self._installer.set_locker(self.poetry.locker)
# Update packages # Update packages
self._installer.use_executor( self._installer.use_executor(
...@@ -72,7 +107,7 @@ list of installed packages ...@@ -72,7 +107,7 @@ list of installed packages
self._installer.dry_run(self.option("dry-run")) self._installer.dry_run(self.option("dry-run"))
self._installer.verbose(self._io.is_verbose()) self._installer.verbose(self._io.is_verbose())
self._installer.update(True) self._installer.update(True)
self._installer.whitelist(requirements) self._installer.whitelist(removed)
status = self._installer.run() status = self._installer.run()
...@@ -80,3 +115,19 @@ list of installed packages ...@@ -80,3 +115,19 @@ list of installed packages
self.poetry.file.write(content) self.poetry.file.write(content)
return status return status
def _remove_packages(
self, packages: List[str], section: Dict[str, Any], group_name: str
) -> List[str]:
removed = []
group = self.poetry.package.dependency_group(group_name)
section_keys = list(section.keys())
for package in packages:
for existing_package in section_keys:
if existing_package.lower() == package.lower():
del section[existing_package]
removed.append(package)
group.remove_dependency(package)
return removed
...@@ -667,13 +667,52 @@ def test_add_constraint_not_found_with_source(app, poetry, mocker, tester): ...@@ -667,13 +667,52 @@ def test_add_constraint_not_found_with_source(app, poetry, mocker, tester):
assert "Could not find a matching version of package cachy" == str(e.value) assert "Could not find a matching version of package cachy" == str(e.value)
def test_add_to_section_that_does_no_exist_yet(app, repo, tester): def test_add_to_section_that_does_not_exist_yet(app, repo, tester):
repo.add_package(get_package("cachy", "0.1.0"))
repo.add_package(get_package("cachy", "0.2.0"))
tester.execute("cachy --group dev")
expected = """\
Using version ^0.2.0 for cachy
Updating dependencies
Resolving dependencies...
Writing lock file
Package operations: 1 install, 0 updates, 0 removals
• Installing cachy (0.2.0)
"""
assert expected == tester.io.fetch_output()
assert 1 == tester.command.installer.executor.installations_count
content = app.poetry.file.read()["tool"]["poetry"]
assert "cachy" in content["group"]["dev"]["dependencies"]
assert content["group"]["dev"]["dependencies"]["cachy"] == "^0.2.0"
expected = """\
[tool.poetry.group.dev.dependencies]
cachy = "^0.2.0"
"""
assert expected in content.as_string()
def test_add_to_dev_section_deprecated(app, repo, tester):
repo.add_package(get_package("cachy", "0.1.0")) repo.add_package(get_package("cachy", "0.1.0"))
repo.add_package(get_package("cachy", "0.2.0")) repo.add_package(get_package("cachy", "0.2.0"))
tester.execute("cachy --dev") tester.execute("cachy --dev")
expected = """\ expected = """\
The --dev option is deprecated, use the `--group dev` notation instead.
Using version ^0.2.0 for cachy Using version ^0.2.0 for cachy
Updating dependencies Updating dependencies
...@@ -691,8 +730,8 @@ Package operations: 1 install, 0 updates, 0 removals ...@@ -691,8 +730,8 @@ Package operations: 1 install, 0 updates, 0 removals
content = app.poetry.file.read()["tool"]["poetry"] content = app.poetry.file.read()["tool"]["poetry"]
assert "cachy" in content["dev-dependencies"] assert "cachy" in content["group"]["dev"]["dependencies"]
assert content["dev-dependencies"]["cachy"] == "^0.2.0" assert content["group"]["dev"]["dependencies"]["cachy"] == "^0.2.0"
def test_add_should_not_select_prereleases(app, repo, tester): def test_add_should_not_select_prereleases(app, repo, tester):
...@@ -1487,7 +1526,7 @@ def test_add_to_section_that_does_no_exist_yet_old_installer( ...@@ -1487,7 +1526,7 @@ def test_add_to_section_that_does_no_exist_yet_old_installer(
repo.add_package(get_package("cachy", "0.1.0")) repo.add_package(get_package("cachy", "0.1.0"))
repo.add_package(get_package("cachy", "0.2.0")) repo.add_package(get_package("cachy", "0.2.0"))
old_tester.execute("cachy --dev") old_tester.execute("cachy --group dev")
expected = """\ expected = """\
Using version ^0.2.0 for cachy Using version ^0.2.0 for cachy
...@@ -1508,8 +1547,8 @@ Package operations: 1 install, 0 updates, 0 removals ...@@ -1508,8 +1547,8 @@ Package operations: 1 install, 0 updates, 0 removals
content = app.poetry.file.read()["tool"]["poetry"] content = app.poetry.file.read()["tool"]["poetry"]
assert "cachy" in content["dev-dependencies"] assert "cachy" in content["group"]["dev"]["dependencies"]
assert content["dev-dependencies"]["cachy"] == "^0.2.0" assert content["group"]["dev"]["dependencies"]["cachy"] == "^0.2.0"
def test_add_should_not_select_prereleases_old_installer( def test_add_should_not_select_prereleases_old_installer(
......
import pytest import pytest
import tomlkit
from poetry.core.packages.package import Package from poetry.core.packages.package import Package
from poetry.factory import Factory
@pytest.fixture() @pytest.fixture()
...@@ -8,6 +10,152 @@ def tester(command_tester_factory): ...@@ -8,6 +10,152 @@ def tester(command_tester_factory):
return command_tester_factory("remove") return command_tester_factory("remove")
def test_remove_without_specific_group_removes_from_all_groups(
tester, app, repo, command_tester_factory, installed
):
"""
Removing without specifying a group removes packages from all groups.
"""
installed.add_package(Package("foo", "2.0.0"))
repo.add_package(Package("foo", "2.0.0"))
repo.add_package(Package("baz", "1.0.0"))
content = app.poetry.file.read()
groups_content = tomlkit.parse(
"""\
[tool.poetry.group.bar.dependencies]
foo = "^2.0.0"
baz = "^1.0.0"
"""
)
content["tool"]["poetry"]["dependencies"]["foo"] = "^2.0.0"
content["tool"]["poetry"].value._insert_after(
"dependencies", "group", groups_content["tool"]["poetry"]["group"]
)
app.poetry.file.write(content)
app.poetry.package.add_dependency(Factory.create_dependency("foo", "^2.0.0"))
app.poetry.package.add_dependency(
Factory.create_dependency("foo", "^2.0.0", groups=["bar"])
)
app.poetry.package.add_dependency(
Factory.create_dependency("baz", "^1.0.0", groups=["bar"])
)
tester.execute("foo")
content = app.poetry.file.read()["tool"]["poetry"]
assert "foo" not in content["dependencies"]
assert "foo" not in content["group"]["bar"]["dependencies"]
assert "baz" in content["group"]["bar"]["dependencies"]
expected = """\
[tool.poetry.group.bar.dependencies]
baz = "^1.0.0"
"""
assert expected in content.as_string()
def test_remove_without_specific_group_removes_from_specific_groups(
tester, app, repo, command_tester_factory, installed
):
"""
Removing with a specific group given removes packages only from this group.
"""
installed.add_package(Package("foo", "2.0.0"))
repo.add_package(Package("foo", "2.0.0"))
repo.add_package(Package("baz", "1.0.0"))
content = app.poetry.file.read()
groups_content = tomlkit.parse(
"""\
[tool.poetry.group.bar.dependencies]
foo = "^2.0.0"
baz = "^1.0.0"
"""
)
content["tool"]["poetry"]["dependencies"]["foo"] = "^2.0.0"
content["tool"]["poetry"].value._insert_after(
"dependencies", "group", groups_content["tool"]["poetry"]["group"]
)
app.poetry.file.write(content)
app.poetry.package.add_dependency(Factory.create_dependency("foo", "^2.0.0"))
app.poetry.package.add_dependency(
Factory.create_dependency("foo", "^2.0.0", groups=["bar"])
)
app.poetry.package.add_dependency(
Factory.create_dependency("baz", "^1.0.0", groups=["bar"])
)
tester.execute("foo --group bar")
content = app.poetry.file.read()["tool"]["poetry"]
assert "foo" in content["dependencies"]
assert "foo" not in content["group"]["bar"]["dependencies"]
assert "baz" in content["group"]["bar"]["dependencies"]
expected = """\
[tool.poetry.group.bar.dependencies]
baz = "^1.0.0"
"""
assert expected in content.as_string()
def test_remove_does_not_live_empty_groups(
tester, app, repo, command_tester_factory, installed
):
"""
Empty groups are automatically discarded after package removal.
"""
installed.add_package(Package("foo", "2.0.0"))
repo.add_package(Package("foo", "2.0.0"))
repo.add_package(Package("baz", "1.0.0"))
content = app.poetry.file.read()
groups_content = tomlkit.parse(
"""\
[tool.poetry.group.bar.dependencies]
foo = "^2.0.0"
baz = "^1.0.0"
"""
)
content["tool"]["poetry"]["dependencies"]["foo"] = "^2.0.0"
content["tool"]["poetry"].value._insert_after(
"dependencies", "group", groups_content["tool"]["poetry"]["group"]
)
app.poetry.file.write(content)
app.poetry.package.add_dependency(Factory.create_dependency("foo", "^2.0.0"))
app.poetry.package.add_dependency(
Factory.create_dependency("foo", "^2.0.0", groups=["bar"])
)
app.poetry.package.add_dependency(
Factory.create_dependency("baz", "^1.0.0", groups=["bar"])
)
tester.execute("foo baz --group bar")
content = app.poetry.file.read()["tool"]["poetry"]
assert "foo" in content["dependencies"]
assert "foo" not in content["group"]["bar"]["dependencies"]
assert "baz" not in content["group"]["bar"]["dependencies"]
assert "[tool.poetry.group.bar]" not in content.as_string()
assert "[tool.poetry.group]" not in content.as_string()
def test_remove_command_should_not_write_changes_upon_installer_errors( def test_remove_command_should_not_write_changes_upon_installer_errors(
tester, app, repo, command_tester_factory, mocker tester, app, repo, command_tester_factory, mocker
): ):
......
...@@ -823,7 +823,6 @@ def test_exporter_can_export_requirements_txt_with_git_packages(tmp_dir, poetry) ...@@ -823,7 +823,6 @@ def test_exporter_can_export_requirements_txt_with_git_packages(tmp_dir, poetry)
} }
) )
set_package_requires(poetry) set_package_requires(poetry)
print(poetry.package.all_requires)
exporter = Exporter(poetry) exporter = Exporter(poetry)
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment