diff --git a/docs/docs/cli.md b/docs/docs/cli.md index 4675ce1023b..3da2484cc39 100644 --- a/docs/docs/cli.md +++ b/docs/docs/cli.md @@ -534,3 +534,46 @@ To only remove a specific package from a cache, you have to specify the cache en ```bash poetry cache clear pypi:requests:2.24.0 ``` + +## plugin + +The `plugin` namespace regroups sub commands to manage Poetry plugins. + +### `plugin add` + +The `plugin add` command installs Poetry plugins and make them available at runtime. + +For example, to install the `poetry-plugin` plugin, you can run: + +```bash +poetry plugin add my-plugin +``` + +The package specification formats supported by the `plugin add` command are the same as the ones supported +by the [`add` command](#add). + +If you just want to check what would happen by installing a plugin, you can use the `--dry-run` option + +```bash +poetry plugin add my-plugin --dry-run +``` + +#### Options + +* `--dry-run`: Outputs the operations but will not execute anything (implicitly enables --verbose). + +### `plugin list` + +The `plugin list` command lists all the currently installed plugins. + +```bash +poetry plugin list +``` + +### `plugin remove` + +The `plugin remove` command lists all the currently installed plugins. + +```bash +poetry plugin remove poetry-plugin +``` diff --git a/docs/docs/plugins.md b/docs/docs/plugins.md index 22e76ba573f..cd37da8c4f6 100644 --- a/docs/docs/plugins.md +++ b/docs/docs/plugins.md @@ -78,15 +78,15 @@ from poetry.plugins.application_plugin import ApplicationPlugin class CustomCommand(Command): - + name = "my-command" - + def handle(self) -> int: self.line("My command") - + return 0 - + def factory(): return CustomCommand() @@ -159,10 +159,10 @@ class MyApplicationPlugin(ApplicationPlugin): def load_dotenv( self, event: ConsoleCommandEvent, event_name: str, dispatcher: EventDispatcher ) -> None: - command = event.io + command = event.command if not isinstance(command, EnvCommand): return - + io = event.io if io.is_debug(): @@ -170,3 +170,64 @@ class MyApplicationPlugin(ApplicationPlugin): load_dotenv() ``` + + +## Using plugins + +Installed plugin packages are automatically loaded when Poetry starts up. + +You have multiple ways to install plugins for Poetry + +### The `plugin add` command + +This is the easiest way and should account for all the ways Poetry can be installed. + +```bash +poetry plugin add poetry-plugin +``` + +The `plugin add` command will ensure that the plugin is compatible with the current version of Poetry +and install the needed packages for the plugin to work. + +The package specification formats supported by the `plugin add` command are the same as the ones supported +by the [`add` command](/docs/cli/#add). + +If you no longer need a plugin and want to uninstall it, you can use the `plugin remove` command. + +```shell +poetry plugin remove poetry-plugin +``` + +You can also list all currently installed plugins by running: + +```shell +poetry plugin list +``` + +### With `pipx inject` + +If you used `pipx` to install Poetry you can add the plugin packages via the `pipx inject` command. + +```shell +pips inject poetry poetry-plugin +``` + +If you want to uninstall a plugin, you can run: + +```shell +pipx runpip poetry uninstall poetry-plugin +``` + +### With `pip` + +If you used `pip` to install Poetry you can add the plugin packages via the `pip install` command. + +```shell +pip install --user poetry-plugin +``` + +If you want to uninstall a plugin, you can run: + +```shell +pip uninstall poetry-plugin +``` diff --git a/poetry/console/application.py b/poetry/console/application.py index ca0bb73f785..268aa7cb9ce 100644 --- a/poetry/console/application.py +++ b/poetry/console/application.py @@ -70,6 +70,8 @@ def _load() -> Type[Command]: "env list", "env remove", "env use", + # Plugin commands + "plugin add", # Self commands "self update", ] @@ -78,6 +80,7 @@ def _load() -> Type[Command]: if TYPE_CHECKING: from cleo.io.inputs.definition import Definition + from poetry.console.commands.installer_command import InstallerCommand from poetry.poetry import Poetry @@ -92,8 +95,8 @@ def __init__(self) -> None: dispatcher = EventDispatcher() dispatcher.add_listener(COMMAND, self.register_command_loggers) - dispatcher.add_listener(COMMAND, self.set_env) - dispatcher.add_listener(COMMAND, self.set_installer) + dispatcher.add_listener(COMMAND, self.configure_env) + dispatcher.add_listener(COMMAND, self.configure_installer) self.set_event_dispatcher(dispatcher) command_loader = CommandLoader({name: load_command(name) for name in COMMANDS}) @@ -239,7 +242,9 @@ def register_command_loggers( logger.setLevel(level) - def set_env(self, event: ConsoleCommandEvent, event_name: str, _: Any) -> None: + def configure_env( + self, event: ConsoleCommandEvent, event_name: str, _: Any + ) -> None: from .commands.env_command import EnvCommand command: EnvCommand = cast(EnvCommand, event.command) @@ -262,7 +267,7 @@ def set_env(self, event: ConsoleCommandEvent, event_name: str, _: Any) -> None: command.set_env(env) - def set_installer( + def configure_installer( self, event: ConsoleCommandEvent, event_name: str, _: Any ) -> None: from .commands.installer_command import InstallerCommand @@ -276,11 +281,14 @@ def set_installer( if command.installer is not None: return + self._configure_installer(command, event.io) + + def _configure_installer(self, command: "InstallerCommand", io: "IO") -> None: from poetry.installation.installer import Installer poetry = command.poetry installer = Installer( - event.io, + io, command.env, poetry.package, poetry.locker, diff --git a/poetry/console/commands/command.py b/poetry/console/commands/command.py index be87fe99b7a..a717fa4e666 100644 --- a/poetry/console/commands/command.py +++ b/poetry/console/commands/command.py @@ -1,4 +1,5 @@ from typing import TYPE_CHECKING +from typing import Optional from cleo.commands.command import Command as BaseCommand @@ -11,9 +12,17 @@ class Command(BaseCommand): loggers = [] + _poetry: Optional["Poetry"] = None + @property def poetry(self) -> "Poetry": - return self.get_application().poetry + if self._poetry is None: + return self.get_application().poetry + + return self._poetry + + def set_poetry(self, poetry: "Poetry") -> None: + self._poetry = poetry def get_application(self) -> "Application": return self.application diff --git a/poetry/console/commands/env_command.py b/poetry/console/commands/env_command.py index beb40e1e88e..fd44b415c00 100644 --- a/poetry/console/commands/env_command.py +++ b/poetry/console/commands/env_command.py @@ -4,7 +4,7 @@ if TYPE_CHECKING: - from poetry.utils.env import VirtualEnv + from poetry.utils.env import Env class EnvCommand(Command): @@ -14,8 +14,8 @@ def __init__(self) -> None: super(EnvCommand, self).__init__() @property - def env(self) -> "VirtualEnv": + def env(self) -> "Env": return self._env - def set_env(self, env: "VirtualEnv") -> None: + def set_env(self, env: "Env") -> None: self._env = env diff --git a/poetry/console/commands/init.py b/poetry/console/commands/init.py index fbc94b9358c..246dc2312d5 100644 --- a/poetry/console/commands/init.py +++ b/poetry/console/commands/init.py @@ -434,10 +434,16 @@ def _parse_requirements(self, requirements: List[str]) -> List[Dict[str, str]]: result.append(pair) continue - elif (os.path.sep in requirement or "/" in requirement) and cwd.joinpath( - requirement - ).exists(): - path = cwd.joinpath(requirement) + elif (os.path.sep in requirement or "/" in requirement) and ( + cwd.joinpath(requirement).exists() + or Path(requirement).expanduser().exists() + and Path(requirement).expanduser().is_absolute() + ): + path = Path(requirement).expanduser() + + if not path.is_absolute(): + path = cwd.joinpath(requirement) + if path.is_file(): package = Provider.get_package_from_file(path.resolve()) else: @@ -447,7 +453,12 @@ def _parse_requirements(self, requirements: List[str]) -> List[Dict[str, str]]: dict( [ ("name", package.name), - ("path", path.relative_to(cwd).as_posix()), + ( + "path", + path.relative_to(cwd).as_posix() + if not path.is_absolute() + else path.as_posix(), + ), ] + ([("extras", extras)] if extras else []) ) diff --git a/poetry/console/commands/plugin/__init__.py b/poetry/console/commands/plugin/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/poetry/console/commands/plugin/add.py b/poetry/console/commands/plugin/add.py new file mode 100644 index 00000000000..32a9dca99e9 --- /dev/null +++ b/poetry/console/commands/plugin/add.py @@ -0,0 +1,185 @@ +import os + +from typing import TYPE_CHECKING +from typing import cast + +from cleo.helpers import argument +from cleo.helpers import option + +from ..init import InitCommand + + +if TYPE_CHECKING: + from pathlib import Path + + from poetry.console.application import Application # noqa + from poetry.console.commands.update import UpdateCommand + from poetry.packages.project_package import ProjectPackage + + +class PluginAddCommand(InitCommand): + + name = "plugin add" + + description = "Adds new plugins." + + arguments = [ + argument("plugins", "The names of the plugins to install.", multiple=True), + ] + + options = [ + option( + "dry-run", + None, + "Output the operations but do not execute anything (implicitly enables --verbose).", + ) + ] + + help = """ +The plugin add command installs Poetry plugins globally. + +It works similarly to the add command: + +If you do not specify a version constraint, poetry will choose a suitable one based on the available package versions. + +You can specify a package in the following forms: + + - A single name (requests) + - A name and a constraint (requests@^2.23.0) + - A git url (git+https://github.com/python-poetry/poetry.git) + - A git url with a revision (git+https://github.com/python-poetry/poetry.git#develop) + - A git SSH url (git+ssh://github.com/python-poetry/poetry.git) + - A git SSH url with a revision (git+ssh://github.com/python-poetry/poetry.git#develop) + - A file path (../my-package/my-package.whl) + - A directory (../my-package/) + - A url (https://example.com/packages/my-package-0.1.0.tar.gz)\ +""" + + def handle(self) -> int: + from pathlib import Path + + from cleo.io.inputs.string_input import StringInput + from cleo.io.io import IO + + from poetry.factory import Factory + from poetry.packages.project_package import ProjectPackage + from poetry.puzzle.provider import Provider + from poetry.repositories.installed_repository import InstalledRepository + from poetry.repositories.repository import Repository + from poetry.utils.env import EnvManager + + plugins = self.argument("plugins") + plugins = self._determine_requirements(plugins) + + # Plugins should be installed in the system env to be globally available + system_env = EnvManager.get_system_env() + + # We retrieve the packages installed in the system environment. + # We assume that this environment will be a self contained virtual environment + # built by the official installer or by pipx. + # If not, it might lead to side effects since other installed packages + # might not be required by Poetry but still taken into account when resolving dependencies. + installed_repository = InstalledRepository.load( + system_env, with_dependencies=True + ) + repository = Repository() + + root_package = None + for package in installed_repository.packages: + if package.name in Provider.UNSAFE_PACKAGES: + continue + + if package.name == "poetry": + root_package = ProjectPackage(package.name, package.version) + for dependency in package.requires: + root_package.add_dependency(dependency) + + continue + + repository.add_package(package) + + plugin_names = [] + for plugin in plugins: + plugin_name = plugin.pop("name") + root_package.add_dependency(Factory.create_dependency(plugin_name, plugin)) + plugin_names.append(plugin_name) + + root_package.python_versions = ".".join( + str(v) for v in system_env.version_info[:3] + ) + env_dir = Path( + os.getenv("POETRY_HOME") if os.getenv("POETRY_HOME") else system_env.path + ) + # We create a `pyproject.toml` file based on all the information + # we have about the current environment and the requested plugins. + self.create_pyproject_from_package(root_package, env_dir) + + # From this point forward, all the logic will be deferred to + # the update command, by using the previously created `pyproject.toml` + # file. + application = cast("Application", self.application) + update_command: "UpdateCommand" = cast( + "UpdateCommand", application.find("update") + ) + # We won't go through the event dispatching done by the application + # so we need to configure the command manually + update_command.set_poetry(Factory().create_poetry(env_dir)) + update_command.set_env(system_env) + application._configure_installer(update_command, self._io) + + argv = ["update"] + plugin_names + if self.option("dry-run"): + argv.append("--dry-run") + + return update_command.run( + IO( + StringInput(" ".join(argv)), + self._io.output, + self._io.error_output, + ) + ) + + def create_pyproject_from_package( + self, package: "ProjectPackage", path: "Path" + ) -> None: + import tomlkit + + from poetry.layouts.layout import POETRY_DEFAULT + + pyproject = tomlkit.loads(POETRY_DEFAULT) + content = pyproject["tool"]["poetry"] + + content["name"] = package.name + content["version"] = package.version.text + content["description"] = package.description + content["authors"] = package.authors + + dependency_section = content["dependencies"] + dependency_section["python"] = package.python_versions + + for dep in package.requires: + constraint = tomlkit.inline_table() + if dep.is_vcs(): + constraint[dep.vcs] = dep.source_url + + if dep.reference: + constraint["rev"] = dep.reference + elif dep.is_file() or dep.is_directory(): + constraint["path"] = dep.source_url + else: + constraint["version"] = dep.pretty_constraint + + if not dep.marker.is_any(): + constraint["markers"] = str(dep.marker) + + if dep.extras: + constraint["extras"] = list(sorted(dep.extras)) + + if len(constraint) == 1 and "version" in constraint: + constraint = constraint["version"] + + dependency_section[dep.name] = constraint + + path.joinpath("pyproject.toml").write_text( + pyproject.as_string(), encoding="utf-8" + ) diff --git a/poetry/factory.py b/poetry/factory.py index 31f5edd308a..2b0776744f1 100644 --- a/poetry/factory.py +++ b/poetry/factory.py @@ -4,6 +4,7 @@ from pathlib import Path from typing import TYPE_CHECKING from typing import Dict +from typing import List from typing import Optional from cleo.io.io import IO @@ -80,32 +81,9 @@ def create_poetry( ) # Configuring sources - sources = poetry.local_config.get("source", []) - for source in sources: - repository = self.create_legacy_repository(source, config) - is_default = source.get("default", False) - is_secondary = source.get("secondary", False) - if io.is_debug(): - message = "Adding repository {} ({})".format( - repository.name, repository.url - ) - if is_default: - message += " and setting it as the default one" - elif is_secondary: - message += " and setting it as secondary" - - io.write_line(message) - - poetry.pool.add_repository(repository, is_default, secondary=is_secondary) - - # Always put PyPI last to prefer private repositories - # but only if we have no other default source - if not poetry.pool.has_default(): - has_sources = bool(sources) - poetry.pool.add_repository(PyPiRepository(), not has_sources, has_sources) - else: - if io.is_debug(): - io.write_line("Deactivating the PyPI repository") + self.configure_sources( + poetry, poetry.local_config.get("source", []), config, io + ) plugin_manager = PluginManager("plugin", disable_plugins=disable_plugins) plugin_manager.load_plugins() @@ -154,8 +132,39 @@ def create_config(cls, io: Optional[IO] = None) -> Config: return config + @classmethod + def configure_sources( + cls, poetry: "Poetry", sources: List[Dict[str, str]], config: "Config", io: "IO" + ) -> None: + for source in sources: + repository = cls.create_legacy_repository(source, config) + is_default = source.get("default", False) + is_secondary = source.get("secondary", False) + if io.is_debug(): + message = "Adding repository {} ({})".format( + repository.name, repository.url + ) + if is_default: + message += " and setting it as the default one" + elif is_secondary: + message += " and setting it as secondary" + + io.write_line(message) + + poetry.pool.add_repository(repository, is_default, secondary=is_secondary) + + # Always put PyPI last to prefer private repositories + # but only if we have no other default source + if not poetry.pool.has_default(): + has_sources = bool(sources) + poetry.pool.add_repository(PyPiRepository(), not has_sources, has_sources) + else: + if io.is_debug(): + io.write_line("Deactivating the PyPI repository") + + @classmethod def create_legacy_repository( - self, source: Dict[str, str], auth_config: Config + cls, source: Dict[str, str], auth_config: Config ) -> "LegacyRepository": from .repositories.legacy_repository import LegacyRepository from .utils.helpers import get_cert diff --git a/poetry/installation/installer.py b/poetry/installation/installer.py index b778711479b..bc254c135e3 100644 --- a/poetry/installation/installer.py +++ b/poetry/installation/installer.py @@ -40,7 +40,7 @@ def __init__( locker: Locker, pool: Pool, config: Config, - installed: Union[InstalledRepository, None] = None, + installed: Union[Repository, None] = None, executor: Optional[Executor] = None, ): self._io = io diff --git a/poetry/locations.py b/poetry/locations.py index 5bd4b7feb17..ff38c9c9e82 100644 --- a/poetry/locations.py +++ b/poetry/locations.py @@ -2,9 +2,11 @@ from .utils.appdirs import user_cache_dir from .utils.appdirs import user_config_dir +from .utils.appdirs import user_data_dir CACHE_DIR = user_cache_dir("pypoetry") +DATA_DIR = user_data_dir("pypoetry") CONFIG_DIR = user_config_dir("pypoetry") REPOSITORY_CACHE_DIR = Path(CACHE_DIR) / "cache" / "repositories" diff --git a/poetry/packages/locker.py b/poetry/packages/locker.py index f06d36d6caf..eecf385c6e8 100644 --- a/poetry/packages/locker.py +++ b/poetry/packages/locker.py @@ -596,3 +596,19 @@ def _dump_package(self, package: Package) -> dict: data["develop"] = package.develop return data + + +class NullLocker(Locker): + def __init__(self, locked=False): # type: (bool) -> None + self._locked = locked + + def is_locked(self): # type: () -> bool + return self._locked + + def set_lock_data(self, root, packages): # type: (...) -> bool + return True + + def locked_repository( + self, with_dev_reqs=False + ): # type: (bool) -> poetry.repositories.Repository + return poetry.repositories.Repository() diff --git a/poetry/puzzle/provider.py b/poetry/puzzle/provider.py index 6c4823a8d69..b7428502f7c 100644 --- a/poetry/puzzle/provider.py +++ b/poetry/puzzle/provider.py @@ -431,7 +431,6 @@ def incompatibilities_for( ] def complete_package(self, package: DependencyPackage) -> DependencyPackage: - if package.is_root(): package = package.clone() requires = package.all_requires diff --git a/poetry/repositories/installed_repository.py b/poetry/repositories/installed_repository.py index 6fba0dd372e..d50b1ecc812 100644 --- a/poetry/repositories/installed_repository.py +++ b/poetry/repositories/installed_repository.py @@ -100,10 +100,12 @@ def is_vcs_package(cls, package: Union[Path, Package], env: Env) -> bool: return True @classmethod - def load(cls, env: Env) -> "InstalledRepository": + def load(cls, env: Env, with_dependencies: bool = False) -> "InstalledRepository": """ Load installed packages. """ + from poetry.core.packages import dependency_from_pep_508 + repo = cls() seen = set() @@ -118,6 +120,11 @@ def load(cls, env: Env) -> "InstalledRepository": package = Package(name, version, version) package.description = distribution.metadata.get("summary", "") + if with_dependencies: + for require in distribution.metadata.get_all("requires-dist", []): + dep = dependency_from_pep_508(require) + package.add_dependency(dep) + if package.name in seen: continue diff --git a/poetry/utils/env.py b/poetry/utils/env.py index ff78bf0161b..eb29389984a 100644 --- a/poetry/utils/env.py +++ b/poetry/utils/env.py @@ -800,7 +800,7 @@ def create_venv( p_venv = os.path.normcase(str(venv)) if any(p.startswith(p_venv) for p in paths): # Running properly in the virtualenv, don't need to do anything - return SystemEnv(Path(sys.prefix), self.get_base_prefix()) + return self.get_system_env() return VirtualEnv(venv) @@ -853,7 +853,12 @@ def remove_venv(cls, path: Union[Path, str]) -> None: elif file_path.is_dir(): shutil.rmtree(str(file_path)) - def get_base_prefix(self) -> Path: + @classmethod + def get_system_env(cls) -> "SystemEnv": + return SystemEnv(Path(sys.prefix), cls.get_base_prefix()) + + @classmethod + def get_base_prefix(cls) -> Path: if hasattr(sys, "real_prefix"): return Path(sys.real_prefix) diff --git a/tests/console/commands/plugin/__init__.py b/tests/console/commands/plugin/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/console/commands/plugin/test_add.py b/tests/console/commands/plugin/test_add.py new file mode 100644 index 00000000000..5d6a34a3fdb --- /dev/null +++ b/tests/console/commands/plugin/test_add.py @@ -0,0 +1,99 @@ +import pytest + +from poetry.__version__ import __version__ +from poetry.core.packages.package import Package +from poetry.factory import Factory +from poetry.repositories.installed_repository import InstalledRepository +from poetry.repositories.pool import Pool +from poetry.utils.env import EnvManager + + +@pytest.fixture() +def tester(command_tester_factory): + return command_tester_factory("plugin add") + + +@pytest.fixture() +def installed(): + repository = InstalledRepository() + + repository.add_package(Package("poetry", __version__)) + + return repository + + +def configure_sources_factory(repo): + def _configure_sources(poetry, sources, config, io): + pool = Pool() + pool.add_repository(repo) + poetry.set_pool(pool) + + return _configure_sources + + +def test_add_no_constraint(app, repo, tester, env, installed, mocker): + mocker.patch.object(EnvManager, "get_system_env", return_value=env) + mocker.patch.object(InstalledRepository, "load", return_value=installed) + mocker.patch.object( + Factory, "configure_sources", side_effect=configure_sources_factory(repo) + ) + + repo.add_package(Package("poetry-plugin", "0.1.0")) + + tester.execute("poetry-plugin") + + expected = """\ +Using version ^0.1.0 for poetry-plugin +Updating dependencies +Resolving dependencies... + +Writing lock file + +Package operations: 1 install, 0 updates, 0 removals + + • Installing poetry-plugin (0.1.0) +""" + + assert tester.io.fetch_output() == expected + + update_command = app.find("update") + assert update_command.poetry.file.parent == env.path + assert update_command.poetry.locker.lock.parent == env.path + + content = update_command.poetry.file.read()["tool"]["poetry"] + assert "poetry-plugin" in content["dependencies"] + assert content["dependencies"]["poetry-plugin"] == "^0.1.0" + + +def test_add_with_constraint(app, repo, tester, env, installed, mocker): + mocker.patch.object(EnvManager, "get_system_env", return_value=env) + mocker.patch.object(InstalledRepository, "load", return_value=installed) + mocker.patch.object( + Factory, "configure_sources", side_effect=configure_sources_factory(repo) + ) + + repo.add_package(Package("poetry-plugin", "0.1.0")) + repo.add_package(Package("poetry-plugin", "0.2.0")) + + tester.execute("poetry-plugin@^0.2.0") + + expected = """\ +Updating dependencies +Resolving dependencies... + +Writing lock file + +Package operations: 1 install, 0 updates, 0 removals + + • Installing poetry-plugin (0.2.0) +""" + + assert tester.io.fetch_output() == expected + + update_command = app.find("update") + assert update_command.poetry.file.parent == env.path + assert update_command.poetry.locker.lock.parent == env.path + + content = update_command.poetry.file.read()["tool"]["poetry"] + assert "poetry-plugin" in content["dependencies"] + assert content["dependencies"]["poetry-plugin"] == "^0.2.0"