Skip to content

Commit

Permalink
Implement a plugin system
Browse files Browse the repository at this point in the history
  • Loading branch information
sdispater committed Feb 12, 2021
1 parent 74ec169 commit ab7a7a0
Show file tree
Hide file tree
Showing 19 changed files with 643 additions and 17 deletions.
172 changes: 172 additions & 0 deletions docs/docs/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
# 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()
```
1 change: 1 addition & 0 deletions docs/mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 23 additions & 7 deletions poetry.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

67 changes: 62 additions & 5 deletions poetry/console/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,10 +19,10 @@
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


Expand Down Expand Up @@ -76,6 +76,8 @@ def _load() -> Type[Command]:


if TYPE_CHECKING:
from cleo.io.inputs.definition import Definition

from poetry.poetry import Poetry


Expand All @@ -84,16 +86,17 @@ def __init__(self) -> None:
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)
dispatcher.add_listener(COMMAND, self.set_env)
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
Expand All @@ -105,10 +108,16 @@ def poetry(self) -> "Poetry":
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

Expand Down Expand Up @@ -138,8 +147,17 @@ def create_io(
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.
Expand Down Expand Up @@ -272,6 +290,45 @@ def set_installer(
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()
Expand Down
12 changes: 12 additions & 0 deletions poetry/console/command_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
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
Empty file.
Empty file.
Loading

0 comments on commit ab7a7a0

Please sign in to comment.