Commit d151f743 by Sébastien Eustace Committed by Arun Babu Neelicattu

Implement a plugin system

parent cdfcd146
# Plugins
Poetry supports using and building plugins if you wish to
alter or expand Poetry's functionality with your own.
For example if your environment poses special requirements
on the behaviour of Poetry which do not apply to the majority of its users
or if you wish to accomplish something with Poetry in a way that is not desired by most users.
In these cases you could consider creating a plugin to handle your specific logic.
## Creating a plugin
A plugin is a regular Python package which ships its code as part of the package
and may also depend on further packages.
### Plugin package
The plugin package must depend on Poetry
and declare a proper [plugin](/docs/pyproject/#plugins) in the `pyproject.toml` file.
```toml
[tool.poetry]
name = "my-poetry-plugin"
version = "1.0.0"
# ...
[tool.poetry.dependency]
python = "~2.7 || ^3.7"
poetry = "^1.0"
[tool.poetry.plugins."poetry.plugin"]
demo = "poetry_demo_plugin.plugin:MyPlugin"
```
### Generic plugins
Every plugin has to supply a class which implements the `poetry.plugins.Plugin` interface.
The `activate()` method of the plugin is called after the plugin is loaded
and receives an instance of `Poetry` as well as an instance of `cleo.io.IO`.
Using these two objects all configuration can be read
and all public internal objects and state can be manipulated as desired.
Example:
```python
from cleo.io.io import IO
from poetry.plugins.plugin import Plugin
from poetry.poetry import Poetry
class MyPlugin(Plugin):
def activate(self, poetry: Poetry, io: IO):
version = self.get_custom_version()
io.write_line(f"Setting package version to <b>{version}</b>")
poetry.package.set_version(version)
def get_custom_version(self) -> str:
...
```
### Application plugins
If you want to add commands or options to the `poetry` script you need
to create an application plugin which implements the `poetry.plugins.ApplicationPlugin` interface.
The `activate()` method of the application plugin is called after the plugin is loaded
and receives an instance of `console.Application`.
```python
from cleo.commands.command import Command
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()
class MyApplicationPlugin(ApplicationPlugin):
def activate(self, application):
application.command_loader.register_factory("my-command", factory)
```
!!!note
It's possible to do the following to register the command:
```python
application.add(MyCommand())
```
However, it is **strongly** recommended to register a new factory
in the command loader to defer the loading of the command when it's actually
called.
This will help keep the performances of Poetry good.
The plugin also must be declared in the `pyproject.toml` file of the plugin package
as an `application.plugin` plugin:
```toml
[tool.poetry.plugins."poetry.application.plugin"]
foo-command = "poetry_demo_plugin.plugin:MyApplicationPlugin"
```
!!!warning
A plugin **must not** remove or modify in any way the core commands of Poetry.
### Event handler
Plugins can also listen to specific events and act on them if necessary.
There are two types of events: application events and generic events.
These events are fired by [Cleo](https://github.com/sdispater/cleo)
and are accessible from the `cleo.events.console_events` module.
- `COMMAND`: this event allows attaching listeners before any command is executed.
- `SIGNAL`: this event allows some actions to be performed after the command execution is interrupted.
- `TERMINATE`: this event allows listeners to be attached after the command.
- `ERROR`: this event occurs when an uncaught exception is raised.
Let's see how to implement an application event handler. For this example
we will see how to load environment variables from a `.env` file before executing
a command.
```python
from cleo.events.console_events import COMMAND
from cleo.events.console_command_event import ConsoleCommandEvent
from cleo.events.event_dispatcher import EventDispatcher
from dotenv import load_dotenv
from poetry.console.application import Application
from poetry.console.commands.env_command import EnvCommand
from poetry.plugins.application_plugin import ApplicationPlugin
class MyApplicationPlugin(ApplicationPlugin):
def activate(self, application: Application):
application.event_dispatcher.add_listener(COMMAND, self.load_dotenv)
def load_dotenv(
self, event: ConsoleCommandEvent, event_name: str, dispatcher: EventDispatcher
) -> None:
command = event.io
if not isinstance(command, EnvCommand):
return
io = event.io
if io.is_debug():
io.write_line("<debug>Loading environment variables.</debug>")
load_dotenv()
```
......@@ -16,6 +16,7 @@ nav:
- Repositories: repositories.md
- Managing environments: managing-environments.md
- Dependency specification: dependency-specification.md
- Plugins: plugins.md
- The pyproject.toml file: pyproject.md
- Contributing: contributing.md
- FAQ: faq.md
......
......@@ -174,6 +174,14 @@ optional = false
python-versions = "*"
[[package]]
name = "entrypoints"
version = "0.3"
description = "Discover and load entry points from installed packages."
category = "main"
optional = false
python-versions = ">=2.7"
[[package]]
name = "filelock"
version = "3.0.12"
description = "A platform independent file lock."
......@@ -697,7 +705,7 @@ testing = ["pytest (>=4.6)", "pytest-checkdocs (>=1.2.3)", "pytest-flake8", "pyt
[metadata]
lock-version = "1.1"
python-versions = "^3.6"
content-hash = "6cbc07e5853bcf1280421b77b6fca85f2f7eb5a6ff12049f65ea116b256d94ea"
content-hash = "c72b0807603d4902cff83901d0e65165e243937b5be90b05c17d3c92a06b4fc8"
[metadata.files]
appdirs = [
......@@ -859,6 +867,10 @@ distlib = [
{file = "distlib-0.3.1-py2.py3-none-any.whl", hash = "sha256:8c09de2c67b3e7deef7184574fc060ab8a793e7adbb183d942c389c8b13c52fb"},
{file = "distlib-0.3.1.zip", hash = "sha256:edf6116872c863e1aa9d5bb7cb5e05a022c519a4594dc703843343a9ddd9bff1"},
]
entrypoints = [
{file = "entrypoints-0.3-py2.py3-none-any.whl", hash = "sha256:589f874b313739ad35be6e0cd7efde2a4e9b6fea91edcc34e58ecbb8dbe56d19"},
{file = "entrypoints-0.3.tar.gz", hash = "sha256:c70dd71abe5a8c85e55e12c19bd91ccfeec11a6e99044204511f9ed547d48451"},
]
filelock = [
{file = "filelock-3.0.12-py3-none-any.whl", hash = "sha256:929b7d63ec5b7d6b71b0fa5ac14e030b3f70b75747cef1b10da9b879fef15836"},
{file = "filelock-3.0.12.tar.gz", hash = "sha256:18d82244ee114f543149c66a6e0c14e9c4f8a1044b5cdaadd0f82159d6a6ff59"},
......
......@@ -19,10 +19,10 @@ from cleo.io.inputs.argv_input import ArgvInput
from cleo.io.inputs.input import Input
from cleo.io.io import IO
from cleo.io.outputs.output import Output
from cleo.loaders.factory_command_loader import FactoryCommandLoader
from poetry.__version__ import __version__
from .command_loader import CommandLoader
from .commands.command import Command
......@@ -76,6 +76,8 @@ COMMANDS = [
if TYPE_CHECKING:
from cleo.io.inputs.definition import Definition
from poetry.poetry import Poetry
......@@ -84,6 +86,9 @@ class Application(BaseApplication):
super(Application, self).__init__("poetry", __version__)
self._poetry = None
self._io: Optional[IO] = None
self._disable_plugins = False
self._plugins_loaded = False
dispatcher = EventDispatcher()
dispatcher.add_listener(COMMAND, self.register_command_loggers)
......@@ -91,9 +96,7 @@ class Application(BaseApplication):
dispatcher.add_listener(COMMAND, self.set_installer)
self.set_event_dispatcher(dispatcher)
command_loader = FactoryCommandLoader(
{name: load_command(name) for name in COMMANDS}
)
command_loader = CommandLoader({name: load_command(name) for name in COMMANDS})
self.set_command_loader(command_loader)
@property
......@@ -105,10 +108,16 @@ class Application(BaseApplication):
if self._poetry is not None:
return self._poetry
self._poetry = Factory().create_poetry(Path.cwd())
self._poetry = Factory().create_poetry(
Path.cwd(), io=self._io, disable_plugins=self._disable_plugins
)
return self._poetry
@property
def command_loader(self) -> CommandLoader:
return self._command_loader
def reset_poetry(self) -> None:
self._poetry = None
......@@ -138,8 +147,17 @@ class Application(BaseApplication):
io.output.set_formatter(formatter)
io.error_output.set_formatter(formatter)
self._io = io
return io
def _run(self, io: IO) -> int:
self._disable_plugins = io.input.parameter_option("--no-plugins")
self._load_plugins(io)
return super()._run(io)
def _configure_io(self, io: IO) -> None:
# We need to check if the command being run
# is the "run" command.
......@@ -272,6 +290,45 @@ class Application(BaseApplication):
installer.use_executor(poetry.config.get("experimental.new-installer", False))
command.set_installer(installer)
def _load_plugins(self, io: IO) -> None:
if self._plugins_loaded:
return
from cleo.exceptions import CommandNotFoundException
name = self._get_command_name(io)
command_name = ""
if name:
try:
command_name = self.find(name).name
except CommandNotFoundException:
pass
self._disable_plugins = (
io.input.has_parameter_option("--no-plugins") or command_name == "new"
)
if not self._disable_plugins:
from poetry.plugins.plugin_manager import PluginManager
manager = PluginManager("application.plugin")
manager.load_plugins()
manager.activate(self)
self._plugins_loaded = True
@property
def _default_definition(self) -> "Definition":
from cleo.io.inputs.option import Option
definition = super()._default_definition
definition.add_option(
Option("--no-plugins", flag=True, description="Disables plugins.")
)
return definition
def main() -> int:
return Application().run()
......
from typing import Callable
from cleo.exceptions import LogicException
from cleo.loaders.factory_command_loader import FactoryCommandLoader
class CommandLoader(FactoryCommandLoader):
def register_factory(self, command_name: str, factory: Callable) -> None:
if command_name in self._factories:
raise LogicException(f'The command "{command_name}" already exists.')
self._factories[command_name] = factory
......@@ -16,6 +16,8 @@ from .config.config import Config
from .config.file_config_source import FileConfigSource
from .locations import CONFIG_DIR
from .packages.locker import Locker
from .packages.project_package import ProjectPackage
from .plugins.plugin_manager import PluginManager
from .poetry import Poetry
from .repositories.pypi_repository import PyPiRepository
......@@ -30,7 +32,10 @@ class Factory(BaseFactory):
"""
def create_poetry(
self, cwd: Optional[Path] = None, io: Optional[IO] = None
self,
cwd: Optional[Path] = None,
io: Optional[IO] = None,
disable_plugins: bool = False,
) -> Poetry:
if io is None:
io = NullIO()
......@@ -102,9 +107,18 @@ class Factory(BaseFactory):
if io.is_debug():
io.write_line("Deactivating the PyPI repository")
plugin_manager = PluginManager("plugin", disable_plugins=disable_plugins)
plugin_manager.load_plugins()
poetry.set_plugin_manager(plugin_manager)
plugin_manager.activate(poetry, io)
return poetry
@classmethod
def get_package(cls, name: str, version: str) -> ProjectPackage:
return ProjectPackage(name, version, version)
@classmethod
def create_config(cls, io: Optional[IO] = None) -> Config:
if io is None:
io = NullIO()
......
from typing import TYPE_CHECKING
from typing import Optional
from typing import Union
from poetry.core.packages.project_package import ProjectPackage as _ProjectPackage
if TYPE_CHECKING:
from poetry.core.semver.version import Version # noqa
class ProjectPackage(_ProjectPackage):
def set_version(
self, version: Union[str, "Version"], pretty_version: Optional[str] = None
) -> "ProjectPackage":
from poetry.core.semver.version import Version # noqa
if not isinstance(version, Version):
self._version = Version.parse(version)
self._pretty_version = pretty_version or version
else:
self._version = version
self._pretty_version = pretty_version or version.text
from .application_plugin import ApplicationPlugin
from .plugin import Plugin
__all__ = ["ApplicationPlugin", "Plugin"]
from .base_plugin import BasePlugin
class ApplicationPlugin(BasePlugin):
"""
Base class for plugins.
"""
type = "application.plugin"
def activate(self, application):
raise NotImplementedError()
class BasePlugin(object):
"""
Base class for all plugin types
"""
PLUGIN_API_VERSION = "1.0.0"
from .base_plugin import BasePlugin
class Plugin(BasePlugin):
"""
Generic plugin not related to the console application.
The activate() method must be implemented and receives
the Poetry instance.
"""
type = "plugin"
def activate(self, poetry, io):
raise NotImplementedError()
import logging
import entrypoints
from .application_plugin import ApplicationPlugin
from .plugin import Plugin
logger = logging.getLogger(__name__)
class PluginManager(object):
"""
This class registers and activates plugins.
"""
def __init__(self, type, disable_plugins=False): # type: (str, bool) -> None
self._type = type
self._disable_plugins = disable_plugins
self._plugins = []
def load_plugins(self): # type: () -> None
if self._disable_plugins:
return
plugin_entrypoints = entrypoints.get_group_all("poetry.{}".format(self._type))
for entrypoint in plugin_entrypoints:
self._load_plugin_entrypoint(entrypoint)
def add_plugin(self, plugin): # type: (Plugin) -> None
if not isinstance(plugin, (Plugin, ApplicationPlugin)):
raise ValueError(
"The Poetry plugin must be an instance of Plugin or ApplicationPlugin"
)
self._plugins.append(plugin)
def activate(self, *args, **kwargs):
for plugin in self._plugins:
plugin.activate(*args, **kwargs)
def _load_plugin_entrypoint(
self, entrypoint
): # type: (entrypoints.EntryPoint) -> None
logger.debug("Loading the {} plugin".format(entrypoint.name))
plugin = entrypoint.load()
if not issubclass(plugin, (Plugin, ApplicationPlugin)):
raise ValueError(
"The Poetry plugin must be an instance of Plugin or ApplicationPlugin"
)
self.add_plugin(plugin())
......@@ -11,6 +11,7 @@ if TYPE_CHECKING:
from .config.config import Config
from .packages.locker import Locker
from .plugins.plugin_manager import PluginManager
from .repositories.pool import Pool
......@@ -33,6 +34,7 @@ class Poetry(BasePoetry):
self._locker = locker
self._config = config
self._pool = Pool()
self._plugin_manager = None
@property
def locker(self) -> "Locker":
......@@ -60,3 +62,8 @@ class Poetry(BasePoetry):
self._config = config
return self
def set_plugin_manager(self, plugin_manager: "PluginManager") -> "Poetry":
self._plugin_manager = plugin_manager
return self
......@@ -42,6 +42,7 @@ pexpect = "^4.7.0"
packaging = "^20.4"
virtualenv = "^20.4.3"
keyring = "^21.2.0"
entrypoints = "^0.3"
importlib-metadata = {version = "^1.6.0", python = "<3.8"}
[tool.poetry.dev-dependencies]
......
import re
from cleo.testers.application_tester import ApplicationTester
from entrypoints import EntryPoint
from poetry.console.application import Application
from poetry.console.commands.command import Command
from poetry.plugins.application_plugin import ApplicationPlugin
class FooCommand(Command):
name = "foo"
description = "Foo Command"
def handle(self):
self.line("foo called")
return 0
class AddCommandPlugin(ApplicationPlugin):
def activate(self, application: Application):
application.command_loader.register_factory("foo", lambda: FooCommand())
def test_application_with_plugins(mocker):
mocker.patch(
"entrypoints.get_group_all",
return_value=[
EntryPoint(
"my-plugin", "tests.console.test_application", "AddCommandPlugin"
)
],
)
app = Application()
tester = ApplicationTester(app)
tester.execute("")
assert re.search(r"\s+foo\s+Foo Command", tester.io.fetch_output()) is not None
assert 0 == tester.status_code
def test_application_with_plugins_disabled(mocker):
mocker.patch(
"entrypoints.get_group_all",
return_value=[
EntryPoint(
"my-plugin", "tests.console.test_application", "AddCommandPlugin"
)
],
)
app = Application()
tester = ApplicationTester(app)
tester.execute("--no-plugins")
assert re.search(r"\s+foo\s+Foo Command", tester.io.fetch_output()) is None
assert 0 == tester.status_code
def test_application_execute_plugin_command(mocker):
mocker.patch(
"entrypoints.get_group_all",
return_value=[
EntryPoint(
"my-plugin", "tests.console.test_application", "AddCommandPlugin"
)
],
)
app = Application()
tester = ApplicationTester(app)
tester.execute("foo")
assert "foo called\n" == tester.io.fetch_output()
assert 0 == tester.status_code
def test_application_execute_plugin_command_with_plugins_disabled(mocker):
mocker.patch(
"entrypoints.get_group_all",
return_value=[
EntryPoint(
"my-plugin", "tests.console.test_application", "AddCommandPlugin"
)
],
)
app = Application()
tester = ApplicationTester(app)
tester.execute("foo --no-plugins")
assert "" == tester.io.fetch_output()
assert '\nThe command "foo" does not exist.\n' == tester.io.fetch_error()
assert 1 == tester.status_code
from pathlib import Path
import pytest
from cleo.io.buffered_io import BufferedIO
from entrypoints import EntryPoint
from poetry.packages.locker import Locker
from poetry.packages.project_package import ProjectPackage
from poetry.plugins import ApplicationPlugin
from poetry.plugins import Plugin
from poetry.plugins.plugin_manager import PluginManager
from poetry.poetry import Poetry
CWD = Path(__file__).parent.parent / "fixtures" / "simple_project"
class MyPlugin(Plugin):
def activate(self, poetry, io):
io.write_line("Updating version")
poetry.package.set_version("9.9.9")
class MyCommandPlugin(ApplicationPlugin):
@property
def commands(self):
return []
class InvalidPlugin:
def activate(self, poetry, io):
io.write_line("Updating version")
poetry.package.version = "9.9.9"
@pytest.fixture()
def poetry(tmp_dir, config):
poetry = Poetry(
CWD / "pyproject.toml",
{},
ProjectPackage("simple-project", "1.2.3"),
Locker(CWD / "poetry.lock", {}),
config,
)
return poetry
@pytest.fixture()
def io():
return BufferedIO()
@pytest.fixture()
def manager_factory(poetry, io):
def _manager(type="plugin"):
return PluginManager(type)
return _manager
@pytest.fixture()
def no_plugin_manager(poetry, io):
return PluginManager("plugin", disable_plugins=True)
def test_load_plugins_and_activate(manager_factory, poetry, io, mocker):
manager = manager_factory()
mocker.patch(
"entrypoints.get_group_all",
return_value=[
EntryPoint("my-plugin", "tests.plugins.test_plugin_manager", "MyPlugin")
],
)
manager.load_plugins()
manager.activate(poetry, io)
assert "9.9.9" == poetry.package.version.text
assert "Updating version\n" == io.fetch_output()
def test_load_plugins_with_invalid_plugin(manager_factory, poetry, io, mocker):
manager = manager_factory()
mocker.patch(
"entrypoints.get_group_all",
return_value=[
EntryPoint(
"my-plugin", "tests.plugins.test_plugin_manager", "InvalidPlugin"
)
],
)
with pytest.raises(ValueError):
manager.load_plugins()
def test_load_plugins_with_plugins_disabled(no_plugin_manager, poetry, io, mocker):
mocker.patch(
"entrypoints.get_group_all",
return_value=[
EntryPoint("my-plugin", "tests.plugins.test_plugin_manager", "MyPlugin")
],
)
no_plugin_manager.load_plugins()
assert "1.2.3" == poetry.package.version.text
assert "" == io.fetch_output()
......@@ -6,8 +6,11 @@ from pathlib import Path
import pytest
from entrypoints import EntryPoint
from poetry.core.toml.file import TOMLFile
from poetry.factory import Factory
from poetry.plugins.plugin import Plugin
from poetry.repositories.legacy_repository import LegacyRepository
from poetry.repositories.pypi_repository import PyPiRepository
......@@ -15,6 +18,12 @@ from poetry.repositories.pypi_repository import PyPiRepository
fixtures_dir = Path(__file__).parent / "fixtures"
class MyPlugin(Plugin):
def activate(self, poetry, io):
io.write_line("Updating version")
poetry.package.set_version("9.9.9")
def test_create_poetry():
poetry = Factory().create_poetry(fixtures_dir / "sample_project")
......@@ -224,3 +233,14 @@ def test_create_poetry_with_local_config(fixture_dir):
assert not poetry.config.get("virtualenvs.create")
assert not poetry.config.get("virtualenvs.options.always-copy")
assert not poetry.config.get("virtualenvs.options.system-site-packages")
def test_create_poetry_with_plugins(mocker):
mocker.patch(
"entrypoints.get_group_all",
return_value=[EntryPoint("my-plugin", "tests.test_factory", "MyPlugin")],
)
poetry = Factory().create_poetry(fixtures_dir / "sample_project")
assert "9.9.9" == poetry.package.version.text
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