Skip to content

Commit

Permalink
Add a basic plugin system
Browse files Browse the repository at this point in the history
  • Loading branch information
sdispater committed Jan 11, 2020
1 parent f797e17 commit 605d786
Show file tree
Hide file tree
Showing 23 changed files with 618 additions and 10 deletions.
147 changes: 147 additions & 0 deletions docs/docs/plugins.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
# Plugins

You may 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 `clikit.api.io.IO`.

Using these two objects all configuration can be read
and all internal objects and state can be manipulated as desired.

Example:

```python
from poetry.plugins import Plugin


class MyPlugin(Plugin):

def activate(self, poetry, io): # type: (Poetry, IO) -> None
version = self.get_custom_version()
io.write_line("Setting package version to {}".format(version))

poetry.package.version = version

def get_custom_version(self): # type: () -> 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 poetry.plugins import ApplicationPlugin


class MyApplicationPlugin(ApplicationPlugin):

def activate(self, application):
application.add(FooCommand())
```

It also must be declared in the `pyproject.toml` file as a `application.plugin` plugin:

```toml
[tool.poetry.plugins."poetry.application.plugin"]
foo-command = "poetry_demo_plugin.plugin:MyApplicationPlugin"
```


### Event handler

Plugins can also listens to specific events and act on them if necessary.

There are two types of events: application events and generic events.

All event types are represented by the `poetry.events.Events` enum class.
Here are the various events fired during Poetry's execution process:

- `APPLICATION_BOOT`: occurs before the application is fully booted.

And since Poetry's application is powered by [CliKit](https://github.com/sdispater/clikit),
the following events are also fired. Note that all events are accessible from
the `clikit.api.event.ConsoleEvents` enum.

- `PRE_RESOLVE`: occurs before resolving the command.
- `PRE_HANDLE`: occurs before the command is executed.
- `CONFIG`: occurs before the application's configuration is finalized.

Let's see how to implement an application event handler. For this example
we want to add an option to the application and, if it is set, trigger
a specific handler.

!!!note

This is how the `-h/--help` option of poetry works.

```python
from clikit.api.event import ConsoleEvents
from clikit.api.resolver import ResolvedCommand
from poetry.plugins import ApplicationPlugin


class MyApplicationPlugin(ApplicationPlugin):

def activate(self, application):
application.config.add_option("foo", description="Call the foo command")
application.add_command(FooCommmand())
application.event_dispatcher.add_listener(ConsoleEvents.PRE_RESOLVE, self.resolve_foo_command)

def resolve_foo_command(self, event, event_name, dispatcher):
# The event is a PreResolveEvent instance which gives
# access to the raw arguments and the application
args = event.raw_args
application = event.application

if args.has_token("--foo"):
command = application.find("foo")

# Enable lenient parsing
parsed_args = command.parse(args, True)

event.set_resolved_command(ResolvedCommand(command, parsed_args))

# Since we have properly resolved the command
# there is no need to go further, so we stop
# the event propagation.
event.stop_propagation()
```
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
2 changes: 1 addition & 1 deletion poetry.lock

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

114 changes: 107 additions & 7 deletions poetry/console/application.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,19 @@
import sys

from cleo import Application as BaseApplication
from clikit.api.args.format import ArgsFormat
from clikit.api.command import CommandCollection
from clikit.api.io import IO
from clikit.api.io.flags import VERY_VERBOSE
from clikit.args import ArgvArgs
from clikit.io import ConsoleIO
from clikit.io import NullIO
from clikit.ui.components.exception_trace import ExceptionTrace

from poetry import __version__
from poetry.events.application_boot_event import ApplicationBootEvent
from poetry.events.events import Events
from poetry.plugins.plugin_manager import PluginManager

from .commands.about import AboutCommand
from .commands.add import AddCommand
Expand Down Expand Up @@ -29,14 +42,21 @@

class Application(BaseApplication):
def __init__(self):
super(Application, self).__init__(
"poetry", __version__, config=ApplicationConfig("poetry", __version__)
)

self._config = ApplicationConfig("poetry", __version__)
self._preliminary_io = ConsoleIO()
self._dispatcher = None
self._commands = CommandCollection()
self._named_commands = CommandCollection()
self._default_commands = CommandCollection()
self._global_args_format = ArgsFormat()
self._booted = False
self._poetry = None
self._io = NullIO()

for command in self.get_default_commands():
self.add(command)
# Enable trace output for exceptions thrown during boot
self._preliminary_io.set_verbosity(VERY_VERBOSE)

self._disable_plugins = False

@property
def poetry(self):
Expand All @@ -46,10 +66,82 @@ def poetry(self):
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
)
self._poetry.set_event_dispatcher(self._config.dispatcher)

return self._poetry

def run(self, args=None, input_stream=None, output_stream=None, error_stream=None):
self._io = self._preliminary_io

try:
if args is None:
args = ArgvArgs()

self._disable_plugins = (
args.has_token("--no-plugins")
or args.tokens
and args.tokens[0] == "new"
)

if not self._disable_plugins:
plugin_manager = PluginManager("application.plugin")
plugin_manager.load_plugins()
plugin_manager.activate(self)

self.boot()

io_factory = self._config.io_factory

self._io = io_factory(
self, args, input_stream, output_stream, error_stream
) # type: IO

resolved_command = self.resolve_command(args)
command = resolved_command.command
parsed_args = resolved_command.args

status_code = command.handle(parsed_args, self._io)
except Exception as e:
if not self._config.is_exception_caught():
raise

trace = ExceptionTrace(e)
trace.render(self._io)

status_code = self.exception_to_exit_code(e)

if self._config.is_terminated_after_run():
sys.exit(status_code)

return status_code

def boot(self): # type: () -> None
if self._booted:
return

dispatcher = self._config.dispatcher

self._dispatcher = dispatcher
self._global_args_format = ArgsFormat(
list(self._config.arguments.values()) + list(self._config.options.values())
)

for command_config in self._config.command_configs:
self.add_command(command_config)

for command in self.get_default_commands():
self.add(command)

if dispatcher and dispatcher.has_listeners(Events.APPLICATION_BOOT):
dispatcher.dispatch(
Events.APPLICATION_BOOT, ApplicationBootEvent(self._config)
)

self._booted = True

def reset_poetry(self): # type: () -> None
self._poetry = None

Expand Down Expand Up @@ -89,6 +181,14 @@ def get_default_commands(self): # type: () -> list

return commands

def get_plugin_commands(self):
plugin_manager = self.poetry.plugin_manager
providers = plugin_manager.command_providers

for provider in providers:
for command in provider.commands:
yield command


if __name__ == "__main__":
Application().run()
2 changes: 2 additions & 0 deletions poetry/console/config/application_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ class ApplicationConfig(BaseApplicationConfig):
def configure(self):
super(ApplicationConfig, self).configure()

self.add_option("no-plugins", description="Disable plugins")

self.add_style(Style("c1").fg("cyan"))
self.add_style(Style("info").fg("blue"))
self.add_style(Style("comment").fg("green"))
Expand Down
2 changes: 2 additions & 0 deletions poetry/events/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from .application_boot_event import ApplicationBootEvent
from .events import Events
18 changes: 18 additions & 0 deletions poetry/events/application_boot_event.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
from clikit.api.event import Event


class ApplicationBootEvent(Event):
"""
Event triggered when the application before the application is booted.
It receives an ApplicationConfig instance.
"""

def __init__(self, config):
super(ApplicationBootEvent, self).__init__()

self._config = config

@property
def config(self):
return self._config
8 changes: 8 additions & 0 deletions poetry/events/events.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
from enum import Enum


class Events(Enum):

# The APPLICATION_BOOT event occurs before
# the console application is fully booted
APPLICATION_BOOT = "application-boot"
8 changes: 7 additions & 1 deletion poetry/factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
from .packages.dependency import Dependency
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
from .spdx import license_by_id
Expand All @@ -30,7 +31,7 @@ class Factory:
"""

def create_poetry(
self, cwd=None, io=None
self, cwd=None, io=None, disable_plugins=False
): # type: (Optional[Path], Optional[IO]) -> Poetry
if io is None:
io = NullIO()
Expand Down Expand Up @@ -190,6 +191,11 @@ def create_poetry(
if io.is_debug():
io.write_line("Deactivating the PyPI repository")

plugin_manager = PluginManager("plugin", disable_plugins)
plugin_manager.load_plugins()
poetry.set_plugin_manager(plugin_manager)
plugin_manager.activate(poetry, io)

return poetry

@classmethod
Expand Down
Loading

0 comments on commit 605d786

Please sign in to comment.