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..5e660fff266
--- /dev/null
+++ b/poetry/console/commands/plugin/add.py
@@ -0,0 +1,182 @@
+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 retrieved 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"] = str(dep.constraint)
+
+ if not dep.marker.is_any():
+ constraint["markers"] = str(dep.marker)
+
+ if dep.extras:
+ constraint["extras"] = list(sorted(dep.extras))
+
+ dependency_section[dep.name] = constraint
+
+ path.joinpath("pyproject.toml").write_text(
+ pyproject.as_string(), encoding="utf-8"
+ )
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..e69de29bb2d