Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/plugins.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,12 @@ custom-plugin = {path = "custom_plugin", develop = true}
If the plugin is not installed in Poetry's own environment when running `poetry install`,
it will be installed only for the current project under `.poetry/plugins`
in the project's directory.
Newly installed application plugins are activated during that same `install` or `sync`
command, so their `TERMINATE` listeners can run immediately. Events that have already
been dispatched, such as `COMMAND`, are not replayed for newly installed plugins.
This activates newly discovered plugins; it does not reload modules that have already
been imported. If installing project plugins replaces an already loaded plugin or
one of its dependencies, start a new Poetry invocation to use the installed version.

The syntax to specify `plugins` is the same as for [dependencies]({{< relref "managing-dependencies" >}}).
Plugins can be installed in editable mode using path dependencies with `develop = true`,
Expand Down
21 changes: 20 additions & 1 deletion src/poetry/console/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from cleo.io.outputs.output import Output

from poetry.console.commands.installer_command import InstallerCommand
from poetry.plugins.plugin_manager import PluginManager
from poetry.poetry import Poetry


Expand Down Expand Up @@ -130,6 +131,8 @@ def __init__(self) -> None:
self._disable_plugins = False
self._disable_cache = False
self._plugins_loaded = False
self._plugin_manager: PluginManager | None = None
self._project_plugin_pth_files: set[Path] = set()
self._working_directory = Path.cwd()
self._project_directory: Path | None = None

Expand Down Expand Up @@ -638,13 +641,29 @@ def _load_plugins(self, io: IO) -> None:
from poetry.plugins.application_plugin import ApplicationPlugin
from poetry.plugins.plugin_manager import PluginManager

PluginManager.add_project_plugin_path(self.project_directory)
self._project_plugin_pth_files = PluginManager.add_project_plugin_path(
self.project_directory
)
manager = PluginManager(ApplicationPlugin.group)
manager.load_plugins()
manager.activate(self)
self._plugin_manager = manager

self._plugins_loaded = True

def load_project_plugins(self) -> None:
if self._disable_plugins:
return

from poetry.plugins.plugin_manager import PluginManager

self._project_plugin_pth_files = PluginManager.add_project_plugin_path(
self.project_directory, known_pth_files=self._project_plugin_pth_files
)
if self._plugin_manager is not None:
self._plugin_manager.load_plugins()
self._plugin_manager.activate(self, only_new=True)


def main() -> int:
exit_code: int = Application().run()
Expand Down
1 change: 1 addition & 0 deletions src/poetry/console/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ def handle(self) -> int:

if not self.option("no-plugins"):
PluginManager.ensure_project_plugins(self.poetry, self.io)
self.get_application().load_project_plugins()

if self.option("extras") and self.option("all-extras"):
self.line_error(
Expand Down
42 changes: 34 additions & 8 deletions src/poetry/plugins/plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
from functools import cached_property
from importlib import metadata
from pathlib import Path
from site import addpackage
from site import addsitedir
from typing import TYPE_CHECKING

Expand Down Expand Up @@ -50,40 +51,65 @@ class PluginManager:
def __init__(self, group: str) -> None:
self._group = group
self._plugins: list[Plugin] = []
self._loaded_entry_points: set[metadata.EntryPoint] = set()
self._activated_plugin_count = 0

@staticmethod
def add_project_plugin_path(directory: Path) -> None:
def add_project_plugin_path(
directory: Path, *, known_pth_files: set[Path] | None = None
) -> set[Path]:
"""Add the plugin path and process .pth files added since discovery."""
from poetry.factory import Factory

try:
pyproject_toml = Factory.locate(directory)
except RuntimeError:
# no pyproject.toml -> no project plugins
return
return set()

plugin_path = pyproject_toml.parent / ProjectPluginCache.PATH
pth_files = set(plugin_path.glob("*.pth"))
if plugin_path.exists():
plugin_path_string = str(plugin_path)
system_path = EnvManager.get_system_env(naive=True).sys_path
already_added = plugin_path_string in system_path
if already_added:
system_path.remove(plugin_path_string)
# insert at the beginning to allow overriding dependencies
EnvManager.get_system_env(naive=True).sys_path.insert(0, str(plugin_path))
# process .pth files (among other things)
addsitedir(str(plugin_path))
system_path.insert(0, plugin_path_string)
# Existing .pth files must not execute again during rediscovery.
if not already_added:
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated
addsitedir(plugin_path_string)
elif known_pth_files is not None:
# Only files created during installation need processing. In
# particular, do not execute existing import lines a second time.
for path in sorted(pth_files - known_pth_files):
if not path.name.startswith("."):
addpackage(plugin_path_string, path.name, None)
return pth_files

@classmethod
def ensure_project_plugins(cls, poetry: Poetry, io: IO) -> None:
ProjectPluginCache(poetry, io).ensure_plugins()

def load_plugins(self) -> None:
# A second discovery after installing project plugins must not instantiate
# plugins that have already registered commands or event listeners.
plugin_entrypoints = self.get_plugin_entry_points()

for ep in plugin_entrypoints:
self._load_plugin_entry_point(ep)
if ep not in self._loaded_entry_points:
self._load_plugin_entry_point(ep)
self._loaded_entry_points.add(ep)
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
Outdated

def get_plugin_entry_points(self) -> list[metadata.EntryPoint]:
return list(metadata.entry_points(group=self._group))

def activate(self, *args: Any, **kwargs: Any) -> None:
for plugin in self._plugins:
def activate(self, *args: Any, only_new: bool = False, **kwargs: Any) -> None:
start = self._activated_plugin_count if only_new else 0
for index, plugin in enumerate(self._plugins[start:], start=start):
plugin.activate(*args, **kwargs)
self._activated_plugin_count = index + 1

def _add_plugin(self, plugin: Plugin) -> None:
if not isinstance(plugin, (Plugin, ApplicationPlugin)):
Expand Down
98 changes: 98 additions & 0 deletions tests/console/test_application.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,104 @@ def test_application_execute_plugin_command(with_add_command_plugin: None) -> No
assert tester.status_code == 0


@pytest.mark.parametrize("command", ["install", "sync"])
@pytest.mark.parametrize("no_plugins", [False, True])
@pytest.mark.parametrize("already_installed", [False, True])
def test_install_activates_new_project_plugins(
command: str,
no_plugins: bool,
already_installed: bool,
mocker: MockerFixture,
set_project_context: SetProjectContext,
) -> None:
from cleo.events.console_events import TERMINATE

from poetry.plugins.plugin_manager import PluginManager

installed = already_installed
activated = []
terminated = []

class ProjectPlugin(AddCommandPlugin):
def activate(self, application: Application) -> None:
super().activate(application)
activated.append(application)
assert application.event_dispatcher is not None
application.event_dispatcher.add_listener(
TERMINATE, lambda *args: terminated.append(command)
)

# Keep the same entry point identity when querying metadata a second time.
ep = mocker.Mock()
ep.load.return_value = ProjectPlugin
mocker.patch(
"poetry.plugins.plugin_manager.metadata.entry_points",
side_effect=lambda **kwargs: (
[ep] if installed and kwargs.get("group") == ApplicationPlugin.group else []
),
)

def ensure_plugins(*args: object) -> None:
nonlocal installed
installed = True

ensure = mocker.patch.object(
PluginManager, "ensure_project_plugins", side_effect=ensure_plugins
)
mocker.patch("poetry.installation.installer.Installer.run", return_value=0)

with set_project_context("sample_project"):
app = Application()
tester = ApplicationTester(app)
tester.execute(f"{command} --no-root" + (" --no-plugins" if no_plugins else ""))

assert tester.status_code == 0
assert len(activated) == (0 if no_plugins else 1)
assert terminated == ([] if no_plugins else [command])
assert ensure.call_count == (0 if no_plugins else 1)
if not no_plugins:
assert app.find("foo").name == "foo"


@pytest.mark.parametrize("command", ["install", "sync"])
def test_install_processes_only_new_project_plugin_pth_files(
command: str,
tmp_path: Path,
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
set_project_context: SetProjectContext,
) -> None:
from poetry.plugins.plugin_manager import PluginManager

marker = tmp_path / "executions.txt"
monkeypatch.setattr(sys, "path", sys.path.copy())
env = mocker.Mock()
env.sys_path = sys.path
mocker.patch.object(EnvManager, "get_system_env", return_value=env)
mocker.patch("poetry.installation.installer.Installer.run", return_value=0)

with set_project_context("sample_project") as project:
plugin_path = project / ProjectPluginCache.PATH
plugin_path.mkdir(parents=True)
(plugin_path / "old.pth").write_text(
f"import pathlib; pathlib.Path({str(marker)!r}).open('a').write('old\\n')\n"
)

def ensure_plugins(*args: object) -> None:
(plugin_path / "new.pth").write_text(
f"import pathlib; pathlib.Path({str(marker)!r}).open('a').write('new\\n')\n"
)

mocker.patch.object(
PluginManager, "ensure_project_plugins", side_effect=ensure_plugins
)
tester = ApplicationTester(Application())
tester.execute(f"{command} --no-root")

assert tester.status_code == 0
assert marker.read_text() == "old\nnew\n"


def test_application_execute_plugin_command_with_plugins_disabled(
with_add_command_plugin: None,
) -> None:
Expand Down
106 changes: 106 additions & 0 deletions tests/plugins/test_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,112 @@ def test_load_plugins_and_activate(
assert io.fetch_output() == "Setting readmes\n"


def test_project_plugin_path_is_added_only_once(
tmp_path: Path, mocker: MockerFixture, monkeypatch: pytest.MonkeyPatch
) -> None:
(tmp_path / "pyproject.toml").touch()
plugin_path = tmp_path / ProjectPluginCache.PATH
plugin_path.mkdir(parents=True)
monkeypatch.setattr(sys, "path", sys.path.copy())
env = mocker.Mock()
env.sys_path = sys.path
mocker.patch(
"poetry.plugins.plugin_manager.EnvManager.get_system_env", return_value=env
)
addsitedir = mocker.patch("poetry.plugins.plugin_manager.addsitedir")

PluginManager.add_project_plugin_path(tmp_path)
PluginManager.add_project_plugin_path(tmp_path)

assert sys.path.count(str(plugin_path)) == 1
addsitedir.assert_called_once_with(str(plugin_path))


@pytest.mark.parametrize("existing_path", [False, True])
def test_new_project_plugin_pth_files(
tmp_path: Path,
mocker: MockerFixture,
monkeypatch: pytest.MonkeyPatch,
existing_path: bool,
) -> None:
(tmp_path / "pyproject.toml").touch()
plugin_path = tmp_path / ProjectPluginCache.PATH
plugin_path.mkdir(parents=True)
marker = tmp_path / "executions.txt"
old_pth = plugin_path / "old.pth"
old_pth.write_text(
f"import pathlib; pathlib.Path({str(marker)!r}).open('a').write('old\\n')\n"
)
monkeypatch.setattr(sys, "path", sys.path.copy())
env = mocker.Mock()
env.sys_path = sys.path
mocker.patch(
"poetry.plugins.plugin_manager.EnvManager.get_system_env", return_value=env
)
if existing_path:
sys.path.append(str(plugin_path))
marker.write_text("old\n")
known = PluginManager.add_project_plugin_path(tmp_path)
assert marker.read_text() == "old\n"

editable_source = tmp_path / "editable-source"
editable_source.mkdir()
(plugin_path / "new.pth").write_text(
f"{editable_source}\n"
f"import pathlib; pathlib.Path({str(marker)!r}).open('a').write('new\\n')\n"
)
known = PluginManager.add_project_plugin_path(tmp_path, known_pth_files=known)
assert marker.read_text() == "old\nnew\n"
assert str(editable_source) in sys.path
PluginManager.add_project_plugin_path(tmp_path, known_pth_files=known)
assert marker.read_text() == "old\nnew\n"
assert sys.path[0] == str(plugin_path)
assert sys.path.count(str(plugin_path)) == 1


def test_reload_plugins_activates_only_new_entry_points(
manager_factory: ManagerFactory,
poetry: Poetry,
io: BufferedIO,
mocker: MockerFixture,
) -> None:
from importlib.metadata import EntryPoint

existing = EntryPoint(
name="existing",
value="tests.plugins.test_plugin_manager:MyPlugin",
group=Plugin.group,
)
added = EntryPoint(
name="added",
value="tests.plugins.test_plugin_manager:MyPlugin",
group=Plugin.group,
)
entry_points = mocker.patch(
"poetry.plugins.plugin_manager.metadata.entry_points", return_value=[existing]
)
manager = manager_factory()
manager.load_plugins()
manager.activate(poetry, io)
assert io.fetch_output() == "Setting readmes\n"

# Metadata discovery creates fresh EntryPoint instances on each query.
entry_points.return_value = [
EntryPoint(name=existing.name, value=existing.value, group=existing.group),
added,
]
manager.load_plugins()
manager.activate(poetry, io, only_new=True)
assert io.fetch_output() == "Setting readmes\n"
manager.load_plugins()
manager.activate(poetry, io, only_new=True)
assert io.fetch_output() == ""

# Preserve the ability to activate all plugins explicitly.
manager.activate(poetry, io)
assert io.fetch_output() == "Setting readmes\nSetting readmes\n"


def test_load_plugins_with_invalid_plugin(
manager_factory: ManagerFactory,
poetry: Poetry,
Expand Down
Loading