Commit 26dc84d1 by Arun Babu Neelicattu

cli: introduce self add/remove/install commands

parent b1b3ce90
...@@ -83,6 +83,9 @@ COMMANDS = [ ...@@ -83,6 +83,9 @@ COMMANDS = [
"plugin remove", "plugin remove",
"plugin show", "plugin show",
# Self commands # Self commands
"self add",
"self install",
"self remove",
"self update", "self update",
# Source commands # Source commands
"source add", "source add",
......
...@@ -72,10 +72,7 @@ class AddCommand(InstallerCommand, InitCommand): ...@@ -72,10 +72,7 @@ class AddCommand(InstallerCommand, InitCommand):
), ),
option("lock", None, "Do not perform operations (only update the lockfile)."), option("lock", None, "Do not perform operations (only update the lockfile)."),
] ]
help = """\ examples = """\
The add command adds required packages to your <comment>pyproject.toml</> and installs\
them.
If you do not specify a version constraint, poetry will choose a suitable one based on\ If you do not specify a version constraint, poetry will choose a suitable one based on\
the available package versions. the available package versions.
...@@ -92,6 +89,12 @@ You can specify a package in the following forms: ...@@ -92,6 +89,12 @@ You can specify a package in the following forms:
- A directory (<b>../my-package/</b>) - A directory (<b>../my-package/</b>)
- A url (<b>https://example.com/packages/my-package-0.1.0.tar.gz</b>) - A url (<b>https://example.com/packages/my-package-0.1.0.tar.gz</b>)
""" """
help = f"""\
The add command adds required packages to your <comment>pyproject.toml</> and installs\
them.
{examples}
"""
loggers = ["poetry.repositories.pypi_repository", "poetry.inspection.info"] loggers = ["poetry.repositories.pypi_repository", "poetry.inspection.info"]
......
...@@ -104,11 +104,7 @@ list of installed packages ...@@ -104,11 +104,7 @@ list of installed packages
) )
self._installer.set_locker(self.poetry.locker) self._installer.set_locker(self.poetry.locker)
# Update packages self._installer.set_package(self.poetry.package)
self._installer.use_executor(
self.poetry.config.get("experimental.new-installer", False)
)
self._installer.dry_run(self.option("dry-run", False)) self._installer.dry_run(self.option("dry-run", False))
self._installer.verbose(self._io.is_verbose()) self._installer.verbose(self._io.is_verbose())
self._installer.update(True) self._installer.update(True)
......
from __future__ import annotations
from poetry.console.commands.add import AddCommand
from poetry.console.commands.self.self_command import SelfCommand
class SelfAddCommand(SelfCommand, AddCommand):
name = "self add"
description = "Add additional packages to Poetry's runtime environment."
options = [
o
for o in AddCommand.options
if o.name in {"editable", "extras", "source", "dry-run", "allow-prereleases"}
]
help = f"""\
The <c1>self add</c1> command installs additional package's to Poetry's runtime \
environment.
This is managed in the <comment>{SelfCommand.get_default_system_pyproject_file()}</> \
file.
{AddCommand.examples}
"""
from __future__ import annotations
try:
from poetry.core.packages.dependency_group import MAIN_GROUP
except ImportError:
MAIN_GROUP = "default"
from poetry.console.commands.install import InstallCommand
from poetry.console.commands.self.self_command import SelfCommand
class SelfInstallCommand(SelfCommand, InstallCommand):
name = "self install"
description = (
"Install locked packages (incl. addons) required by this Poetry installation."
)
options = [o for o in InstallCommand.options if o.name in {"sync", "dry-run"}]
help = f"""\
The <c1>self install</c1> command ensures all additional packages specified are \
installed in the current runtime environment.
This is managed in the <comment>{SelfCommand.get_default_system_pyproject_file()}</> \
file.
You can add more packages using the <c1>self add</c1> command and remove them using \
the <c1>self remove</c1> command.
"""
@property
def activated_groups(self) -> set[str]:
return {MAIN_GROUP, self.default_group}
from __future__ import annotations
from poetry.console.commands.remove import RemoveCommand
from poetry.console.commands.self.self_command import SelfCommand
class SelfRemoveCommand(SelfCommand, RemoveCommand):
name = "self remove"
description = "Remove additional packages from Poetry's runtime environment."
options = [o for o in RemoveCommand.options if o.name in {"dry-run"}]
help = f"""\
The <c1>self remove</c1> command removes additional package's to Poetry's runtime \
environment.
This is managed in the <comment>{SelfCommand.get_default_system_pyproject_file()}</> \
file.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from typing import cast
from poetry.core.packages.dependency import Dependency
from poetry.core.packages.project_package import ProjectPackage
from poetry.core.pyproject.toml import PyProjectTOML
from poetry.__version__ import __version__
from poetry.console.commands.installer_command import InstallerCommand
from poetry.factory import Factory
from poetry.poetry import Poetry
from poetry.utils.env import EnvManager
from poetry.utils.env import SystemEnv
from poetry.utils.helpers import directory
if TYPE_CHECKING:
from poetry.utils.env import Env
class SelfCommand(InstallerCommand):
ADDITIONAL_PACKAGE_GROUP = "additional"
@staticmethod
def get_default_system_pyproject_file() -> Path:
# We separate this out to avoid unwanted side effect during testing while
# maintaining dynamic use in help text.
#
# This is not ideal, but is the simplest solution for now.
from poetry.locations import CONFIG_DIR
return Path(CONFIG_DIR).joinpath("pyproject.toml")
@property
def system_pyproject(self) -> Path:
file = self.get_default_system_pyproject_file()
file.parent.mkdir(parents=True, exist_ok=True)
return file
def reset_env(self) -> None:
self._env = EnvManager.get_system_env(naive=True)
@property
def env(self) -> Env:
if self._env is None or not isinstance(self._env, SystemEnv):
self.reset_env()
return self._env
@property
def default_group(self) -> str:
return self.ADDITIONAL_PACKAGE_GROUP
@property
def activated_groups(self) -> set[str]:
return {self.default_group}
def generate_system_pyproject(self) -> None:
preserved = {}
if self.system_pyproject.exists():
content = PyProjectTOML(self.system_pyproject).poetry_config
for key in {"group", "source"}:
if key in content:
preserved[key] = content[key]
package = ProjectPackage(name="poetry-instance", version=__version__)
package.add_dependency(Dependency(name="poetry", constraint=f"^{__version__}"))
package.python_versions = ".".join(str(v) for v in self.env.version_info[:3])
content = Factory.create_pyproject_from_package(package=package)
for key in preserved:
content[key] = preserved[key]
self.system_pyproject.write_text(content.as_string(), encoding="utf-8")
def reset_poetry(self) -> None:
with directory(self.system_pyproject.parent):
self.generate_system_pyproject()
self._poetry = Factory().create_poetry(
self.system_pyproject.parent, io=self._io, disable_plugins=True
)
@property
def poetry(self) -> Poetry:
if self._poetry is None:
self.reset_poetry()
return cast(Poetry, self._poetry)
def _system_project_handle(self) -> int:
"""
This is a helper method that by default calls the handle method implemented in
the child class's next MRO sibling. Override this if you want special handling
either before calling the handle() from the super class or have custom logic
to handle the command.
The default implementations handles cases where a `self` command delegates
handling to an existing command. Eg: `SelfAddCommand(SelfCommand, AddCommand)`.
"""
return super().handle()
def reset(self) -> None:
"""
Reset current command instance's environment and poetry instances to ensure
use of the system specific ones.
"""
self.reset_env()
self.reset_poetry()
def handle(self) -> int:
# We override the base class's handle() method to ensure that poetry and env
# are reset to work within the system project instead of current context.
# Further, during execution, the working directory is temporarily changed
# to parent directory of Poetry system pyproject.toml file.
#
# This method **should not** be overridden in child classes as it may have
# unexpected consequences.
self.reset()
with directory(self.system_pyproject.parent):
return self._system_project_handle()
from __future__ import annotations from __future__ import annotations
import os from typing import cast
import shutil
import site
from functools import cmp_to_key
from pathlib import Path
from typing import TYPE_CHECKING
from cleo.helpers import argument from cleo.helpers import argument
from cleo.helpers import option from cleo.helpers import option
from cleo.io.inputs.string_input import StringInput
from cleo.io.io import IO
from poetry.console.commands.command import Command from poetry.console.application import Application
from poetry.console.commands.add import AddCommand
from poetry.console.commands.self.self_command import SelfCommand
if TYPE_CHECKING:
from poetry.core.packages.package import Package
from poetry.core.semver.version import Version
from poetry.repositories.pool import Pool
class SelfUpdateCommand(Command): class SelfUpdateCommand(SelfCommand):
name = "self update" name = "self update"
description = "Updates Poetry to the latest version." description = "Updates Poetry to the latest version."
arguments = [argument("version", "The version to update to.", optional=True)] arguments = [
argument(
"version", "The version to update to.", optional=True, default="latest"
)
]
options = [ options = [
option("preview", None, "Allow the installation of pre-release versions."), option("preview", None, "Allow the installation of pre-release versions."),
option( option(
...@@ -36,192 +30,30 @@ class SelfUpdateCommand(Command): ...@@ -36,192 +30,30 @@ class SelfUpdateCommand(Command):
"(implicitly enables --verbose).", "(implicitly enables --verbose).",
), ),
] ]
help = """\
The <c1>self update</c1> command updates Poetry version in its current runtime \
environment.
"""
_data_dir = None def _system_project_handle(self) -> int:
_bin_dir = None self.write("<info>Updating Poetry version ...</info>\n\n")
_pool = None application = cast(Application, self.application)
add_command: AddCommand = cast(AddCommand, application.find("add"))
@property add_command.set_env(self.env)
def data_dir(self) -> Path: application._configure_installer(add_command, self._io)
if self._data_dir is not None:
return self._data_dir
from poetry.locations import data_dir
self._data_dir = data_dir()
return self._data_dir
@property
def bin_dir(self) -> Path:
if self._data_dir is not None:
return self._data_dir
from poetry.utils._compat import WINDOWS
home = os.getenv("POETRY_HOME")
if home:
return Path(home, "bin").expanduser()
user_base = site.getuserbase() argv = ["add", f"poetry@{self.argument('version')}"]
if WINDOWS: if self.option("dry-run"):
bin_dir = os.path.join(user_base, "Scripts") argv.append("--dry-run")
else:
bin_dir = os.path.join(user_base, "bin")
self._bin_dir = Path(bin_dir)
return self._bin_dir
@property
def pool(self) -> Pool:
if self._pool is not None:
return self._pool
from poetry.repositories.pool import Pool
from poetry.repositories.pypi_repository import PyPiRepository
pool = Pool()
pool.add_repository(PyPiRepository())
return pool
def handle(self) -> int:
from poetry.core.packages.dependency import Dependency
from poetry.core.semver.version import Version
from poetry.__version__ import __version__
version = self.argument("version")
if not version:
version = ">=" + __version__
repo = self.pool.repositories[0]
packages = repo.find_packages(
Dependency("poetry", version, allows_prereleases=self.option("preview"))
)
if not packages:
self.line("No release found for the specified version")
return 1
def cmp(x: Package, y: Package) -> int:
if x.version == y.version:
return 0
return int(x.version < y.version or -1)
packages.sort(key=cmp_to_key(cmp))
release = None
for package in packages:
if package.is_prerelease():
if self.option("preview"): if self.option("preview"):
release = package argv.append("--allow-prereleases")
break
continue
release = package
break
if release is None:
self.line("No new release found")
return 1
if release.version == Version.parse(__version__):
self.line("You are using the latest version")
return 0
self.line(f"Updating <c1>Poetry</c1> to <c2>{release.version}</c2>")
self.line("")
self.update(release)
self.line("")
self.line(
f"<c1>Poetry</c1> (<c2>{release.version}</c2>) is installed now. Great!"
)
return 0
def update(self, release: Package) -> None:
from poetry.utils.env import EnvManager
version = release.version
env = EnvManager.get_system_env(naive=True)
# We can't use is_relative_to() since it's only available in Python 3.9+
try:
env.path.relative_to(self.data_dir)
except ValueError:
# Poetry was not installed using the recommended installer
from poetry.console.exceptions import PoetrySimpleConsoleException
raise PoetrySimpleConsoleException(
"Poetry was not installed with the recommended installer, "
"so it cannot be updated automatically."
)
self._update(version)
self._make_bin()
def _update(self, version: Version) -> None:
from poetry.core.packages.dependency import Dependency
from poetry.core.packages.project_package import ProjectPackage
from poetry.config.config import Config
from poetry.installation.installer import Installer
from poetry.packages.locker import NullLocker
from poetry.repositories.installed_repository import InstalledRepository
from poetry.utils.env import EnvManager
env = EnvManager.get_system_env(naive=True)
installed = InstalledRepository.load(env)
root = ProjectPackage("poetry-updater", "0.0.0")
root.python_versions = ".".join(str(c) for c in env.version_info[:3])
root.add_dependency(Dependency("poetry", version.text))
installer = Installer(
self.io,
env,
root,
NullLocker(self.data_dir.joinpath("poetry.lock"), {}),
self.pool,
config=Config.create(),
installed=installed,
)
installer.update(True)
installer.dry_run(self.option("dry-run"))
installer.run()
def _make_bin(self) -> None:
from poetry.utils._compat import WINDOWS
self.line("")
self.line("Updating the <c1>poetry</c1> script")
self.bin_dir.mkdir(parents=True, exist_ok=True)
script = "poetry"
target_script = "venv/bin/poetry"
if WINDOWS:
script = "poetry.exe"
target_script = "venv/Scripts/poetry.exe"
if self.bin_dir.joinpath(script).exists():
self.bin_dir.joinpath(script).unlink()
try: return add_command.run(
self.bin_dir.joinpath(script).symlink_to( IO(
self.data_dir.joinpath(target_script) StringInput(" ".join(argv)),
self._io.output,
self._io.error_output,
) )
except OSError:
# This can happen if the user
# does not have the correct permission on Windows
shutil.copy(
self.data_dir.joinpath(target_script), self.bin_dir.joinpath(script)
) )
...@@ -6,9 +6,11 @@ import shutil ...@@ -6,9 +6,11 @@ import shutil
import stat import stat
import tempfile import tempfile
from contextlib import contextmanager
from pathlib import Path from pathlib import Path
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from typing import Any from typing import Any
from typing import Iterator
from typing import Mapping from typing import Mapping
...@@ -32,6 +34,16 @@ def module_name(name: str) -> str: ...@@ -32,6 +34,16 @@ def module_name(name: str) -> str:
return canonicalize_name(name).replace(".", "_").replace("-", "_") return canonicalize_name(name).replace(".", "_").replace("-", "_")
@contextmanager
def directory(path: Path) -> Iterator[Path]:
cwd = Path.cwd()
try:
os.chdir(path)
yield path
finally:
os.chdir(cwd)
def _on_rm_error(func: Callable[[str], None], path: str, exc_info: Exception) -> None: def _on_rm_error(func: Callable[[str], None], path: str, exc_info: Exception) -> None:
if not os.path.exists(path): if not os.path.exists(path):
return return
......
...@@ -9,10 +9,7 @@ from poetry.core.packages.package import Package ...@@ -9,10 +9,7 @@ from poetry.core.packages.package import Package
from poetry.core.semver.version import Version from poetry.core.semver.version import Version
from poetry.__version__ import __version__ from poetry.__version__ import __version__
from poetry.console.exceptions import PoetrySimpleConsoleException
from poetry.factory import Factory from poetry.factory import Factory
from poetry.repositories.installed_repository import InstalledRepository
from poetry.repositories.pool import Pool
from poetry.repositories.repository import Repository from poetry.repositories.repository import Repository
from poetry.utils.env import EnvManager from poetry.utils.env import EnvManager
...@@ -24,28 +21,47 @@ if TYPE_CHECKING: ...@@ -24,28 +21,47 @@ if TYPE_CHECKING:
from pytest_mock import MockerFixture from pytest_mock import MockerFixture
from poetry.utils.env import VirtualEnv from poetry.utils.env import VirtualEnv
from tests.helpers import TestRepository
from tests.types import CommandTesterFactory from tests.types import CommandTesterFactory
FIXTURES = Path(__file__).parent.joinpath("fixtures") FIXTURES = Path(__file__).parent.joinpath("fixtures")
@pytest.fixture() @pytest.fixture
def tester(command_tester_factory: CommandTesterFactory) -> CommandTester: def installed_repository() -> Repository:
return command_tester_factory("self update") return Repository()
def test_self_update_can_update_from_recommended_installation( @pytest.fixture(autouse=True)
tester: CommandTester, def save_environ(environ: None) -> Repository:
http: type[httpretty.httpretty], yield
@pytest.fixture(autouse=True)
def setup_mocks(
mocker: MockerFixture, mocker: MockerFixture,
environ: None,
tmp_venv: VirtualEnv, tmp_venv: VirtualEnv,
installed_repository: Repository,
http: type[httpretty.httpretty],
): ):
mocker.patch.object(EnvManager, "get_system_env", return_value=tmp_venv) mocker.patch.object(EnvManager, "get_system_env", return_value=tmp_venv)
mocker.patch("poetry.installation.executor.pip_install")
mocker.patch(
"poetry.installation.installer.Installer._get_installed",
return_value=installed_repository,
)
command = tester.command @pytest.fixture()
command._data_dir = tmp_venv.path.parent def tester(command_tester_factory: CommandTesterFactory) -> CommandTester:
return command_tester_factory("self update")
def test_self_update_can_update_from_recommended_installation(
tester: CommandTester,
repo: TestRepository,
installed_repository: TestRepository,
):
new_version = Version.parse(__version__).next_minor().text new_version = Version.parse(__version__).next_minor().text
old_poetry = Package("poetry", __version__) old_poetry = Package("poetry", __version__)
...@@ -54,73 +70,28 @@ def test_self_update_can_update_from_recommended_installation( ...@@ -54,73 +70,28 @@ def test_self_update_can_update_from_recommended_installation(
new_poetry = Package("poetry", new_version) new_poetry = Package("poetry", new_version)
new_poetry.add_dependency(Factory.create_dependency("cleo", "^1.0.0")) new_poetry.add_dependency(Factory.create_dependency("cleo", "^1.0.0"))
installed_repository = Repository()
installed_repository.add_package(old_poetry) installed_repository.add_package(old_poetry)
installed_repository.add_package(Package("cleo", "0.8.2")) installed_repository.add_package(Package("cleo", "0.8.2"))
repository = Repository() repo.add_package(new_poetry)
repository.add_package(new_poetry) repo.add_package(Package("cleo", "1.0.0"))
repository.add_package(Package("cleo", "1.0.0"))
pool = Pool()
pool.add_repository(repository)
command._pool = pool
mocker.patch.object(InstalledRepository, "load", return_value=installed_repository)
tester.execute() tester.execute()
expected_output = f"""\ expected_output = f"""\
Updating Poetry to 1.2.0 Updating Poetry version ...
Using version ^{new_version} for poetry
Updating dependencies Updating dependencies
Resolving dependencies... Resolving dependencies...
Package operations: 0 installs, 2 updates, 0 removals Writing lock file
- Updating cleo (0.8.2 -> 1.0.0)
- Updating poetry ({__version__} -> {new_version})
Updating the poetry script Package operations: 0 installs, 2 updates, 0 removals
Poetry ({new_version}) is installed now. Great! • Updating cleo (0.8.2 -> 1.0.0)
• Updating poetry ({__version__} -> {new_version})
""" """
assert tester.io.fetch_output() == expected_output assert tester.io.fetch_output() == expected_output
def test_self_update_does_not_update_non_recommended_installation(
tester: CommandTester,
http: type[httpretty.httpretty],
mocker: MockerFixture,
environ: None,
tmp_venv: VirtualEnv,
):
mocker.patch.object(EnvManager, "get_system_env", return_value=tmp_venv)
command = tester.command
new_version = Version.parse(__version__).next_minor().text
old_poetry = Package("poetry", __version__)
old_poetry.add_dependency(Factory.create_dependency("cleo", "^0.8.2"))
new_poetry = Package("poetry", new_version)
new_poetry.add_dependency(Factory.create_dependency("cleo", "^1.0.0"))
installed_repository = Repository()
installed_repository.add_package(old_poetry)
installed_repository.add_package(Package("cleo", "0.8.2"))
repository = Repository()
repository.add_package(new_poetry)
repository.add_package(Package("cleo", "1.0.0"))
pool = Pool()
pool.add_repository(repository)
command._pool = pool
with pytest.raises(PoetrySimpleConsoleException):
tester.execute()
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