Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Register plugin from entry points #1872

Open
wants to merge 22 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
91b3a3c
Add test data to load plugins from entry points
Ni-g-3l Nov 11, 2024
f8a8f49
Load REZ plugins from setuptools entry points
Ni-g-3l Nov 11, 2024
da9e66c
Fix flake8 report
Ni-g-3l Nov 11, 2024
7fbed7c
Fix import of importlib metadata in python 3.8, 3.9
Ni-g-3l Nov 18, 2024
e776bb9
Signed-off-by: Nig3l <[email protected]>
Ni-g-3l Jan 25, 2025
55ed2ce
Remove dupplicated plugin definition
Ni-g-3l Jan 25, 2025
ee6f9bf
Install test plugin to test data to speed up test
Ni-g-3l Feb 2, 2025
e6a3256
Add try except on load plugin from entry points
Ni-g-3l Feb 2, 2025
e30e7cd
Update plugin install doc
Ni-g-3l Feb 2, 2025
0742acf
use modern build backend, use dist-info, fix tests and simplify file …
JeanChristopheMorinPerso Feb 2, 2025
4e6a47a
Update src/rez/tests/util.py
JeanChristopheMorinPerso Feb 2, 2025
25c020a
Remove vendored code for safety reason.
JeanChristopheMorinPerso Feb 2, 2025
9579331
Re-add importlib-metadata (this time with the right version to suppor…
JeanChristopheMorinPerso Feb 2, 2025
ac70f9a
Make it compatible with python 3.8 and 3.9
JeanChristopheMorinPerso Feb 2, 2025
ffbe7f6
Fix python compat again...
JeanChristopheMorinPerso Feb 2, 2025
ab8e162
Last fix for real
JeanChristopheMorinPerso Feb 2, 2025
a7fa195
Fix flake8
JeanChristopheMorinPerso Feb 2, 2025
cab8f04
Add new plugin install methods
Ni-g-3l Feb 3, 2025
9d939f1
Organize entrypoints by plugin type to avoid having plugins being reg…
JeanChristopheMorinPerso Feb 15, 2025
46219f0
Use entrypoint name for plugin name instead of module name
JeanChristopheMorinPerso Feb 15, 2025
fc381b0
Add debug logs to match the other plugin loading modes
JeanChristopheMorinPerso Feb 15, 2025
861e0f1
Fix tests
JeanChristopheMorinPerso Feb 15, 2025
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
38 changes: 34 additions & 4 deletions docs/source/plugins.rst
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,6 @@ Rez plugins require a specific folder structure as follows:
/plugin_file2.py (your plugin file)
etc.

To make your plugin available to rez, you can install them directly under
``src/rezplugins`` (that's called a namespace package) or you can add
the path to :envvar:`REZ_PLUGIN_PATH`.

Registering subcommands
-----------------------

Expand Down Expand Up @@ -137,4 +133,38 @@ so that the plugin manager will find them.
from rez.plugin_managers import extend_path
__path__ = extend_path(__path__, __name__)

Install plugins
---------------

1. Copy directly to rez install folder

To make your plugin available to rez, you can install it directly under
``src/rezplugins`` (that's called a namespace package).

2. Add the source path to :envvar:`REZ_PLUGIN_PATH`

Add the source path to the REZ_PLUGIN_PATH environment variable in order to make your plugin available to rez.

3. Create a setup.py

To make your plugin available to rez, you can also create a ``setup.py`` file
that will allow you to install your plugin with `pip install` command.

.. code-block:: python
:caption: setup.py

from setuptools import setup, find_packages

setup(
name="foo",
version="0.1.0",
package_dir={
"foo": "foo"
},
packages=find_packages(where="."),
entry_points={
'rez.plugins': [
'foo_cmd = foo',
]
}
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
Metadata-Version: 2.4
Name: baz
Version: 0.1.0
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
[rez.plugins]
baz_cmd = baz
38 changes: 38 additions & 0 deletions src/rez/data/tests/extensions/baz/baz/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
"""
baz plugin
"""

from rez.command import Command

# This attribute is optional, default behavior will be applied if not present.
command_behavior = {
"hidden": False, # (bool): default False
"arg_mode": None, # (str): "passthrough", "grouped", default None
}


def setup_parser(parser, completions=False):
parser.add_argument(
"-m", "--message", action="store_true", help="Print message from world."
)


def command(opts, parser=None, extra_arg_groups=None):
from baz import core

if opts.message:
msg = core.get_message_from_baz()
print(msg)
return

print("Please use '-h' flag to see what you can do to this world !")


class BazCommand(Command):
@classmethod
def name(cls):
return "baz"


def register_plugin():
return BazCommand
4 changes: 4 additions & 0 deletions src/rez/data/tests/extensions/baz/baz/core.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
def get_message_from_baz():
from rez.config import config
message = config.plugins.command.baz.message
return message
3 changes: 3 additions & 0 deletions src/rez/data/tests/extensions/baz/baz/rezconfig.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
baz = {
"message": "welcome to this world."
}
10 changes: 10 additions & 0 deletions src/rez/data/tests/extensions/baz/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[project]
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you prefer this format of build system, we should update plugins doc by replacing setup.py part by a pyproject.toml.

name = "baz"
version = "0.1.0"

[project.entry-points."rez.plugins"]
baz_cmd = "baz"
118 changes: 74 additions & 44 deletions src/rez/plugin_managers.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@
import os.path
import sys

if sys.version_info.minor >= 8:
from importlib.metadata import entry_points
else:
from rez.vendor.importlib_metadata import entry_points


# modified from pkgutil standard library:
# this function is called from the __init__.py files of each plugin type inside
Expand Down Expand Up @@ -109,6 +114,10 @@ def register_plugin(self, plugin_name, plugin_class, plugin_module):
self.plugin_modules[plugin_name] = plugin_module

def load_plugins(self):
self.load_plugins_from_namespace()
self.load_plugins_from_entry_points()

def load_plugins_from_namespace(self):
import pkgutil
from importlib import import_module
type_module_name = 'rezplugins.' + self.type_name
Expand Down Expand Up @@ -153,57 +162,78 @@ def load_plugins(self):
if config.debug("plugins"):
print_debug("loading %s plugin at %s: %s..."
% (self.type_name, path, modname))

try:
# https://github.com/AcademySoftwareFoundation/rez/pull/218
# load_module will force reload the module if it's
# already loaded, so check for that
plugin_module = sys.modules.get(modname)
if plugin_module is None:
loader = importer.find_module(modname)
plugin_module = loader.load_module(modname)

elif os.path.dirname(plugin_module.__file__) != path:
if config.debug("plugins"):
# this should not happen but if it does, tell why.
print_warning(
"plugin module %s is not loaded from current "
"load path but reused from previous imported "
"path: %s" % (modname, plugin_module.__file__))

if (hasattr(plugin_module, "register_plugin")
and callable(plugin_module.register_plugin)):

plugin_class = plugin_module.register_plugin()
if plugin_class is not None:
self.register_plugin(plugin_name,
plugin_class,
plugin_module)
else:
if config.debug("plugins"):
print_warning(
"'register_plugin' function at %s: %s did "
"not return a class." % (path, modname))
else:
if config.debug("plugins"):
print_warning(
"no 'register_plugin' function at %s: %s"
% (path, modname))

# delete from sys.modules?

self.register_plugin_module(plugin_name, plugin_module, path)
self.load_config_from_plugin(plugin_module)
except Exception as e:
nameish = modname.split('.')[-1]
self.failed_plugins[nameish] = str(e)
if config.debug("plugins"):
import traceback
from io import StringIO
out = StringIO()
traceback.print_exc(file=out)
print_debug(out.getvalue())

# load config
data, _ = _load_config_from_filepaths([os.path.join(path, "rezconfig")])
deep_update(self.config_data, data)
self.print_log_plugins_error(modname, e)

def load_plugins_from_entry_points(self):
discovered_plugins = entry_points(group='rez.plugins')
for plugin in discovered_plugins:
try:
plugin = plugin.load()
plugin_name = plugin.__name__
plugin_path = os.path.dirname(plugin.__file__)
self.register_plugin_module(plugin_name, plugin, plugin_path)
self.load_config_from_plugin(plugin)
except Exception as e:
self.print_log_plugins_error(plugin.__name__, e)

def print_log_plugins_error(self, module_name, error):
nameish = module_name.split('.')[-1]
self.failed_plugins[nameish] = str(error)

if not config.debug("plugins"):
return

import traceback
from io import StringIO
out = StringIO()
traceback.print_exc(file=out)
print_debug(out.getvalue())

def load_config_from_plugin(self, plugin):
plugin_path = os.path.dirname(plugin.__file__)
data, _ = _load_config_from_filepaths([os.path.join(plugin_path, "rezconfig")])
deep_update(self.config_data, data)

def register_plugin_module(self, plugin_name, plugin_module, plugin_path):
module_name = plugin_module.__name__
if os.path.dirname(plugin_module.__file__) != plugin_path:
if config.debug("plugins"):
# this should not happen but if it does, tell why.
print_warning(
"plugin module %s is not loaded from current "
"load path but reused from previous imported "
"path: %s" % (module_name, plugin_module.__file__))

if (hasattr(plugin_module, "register_plugin")
and callable(plugin_module.register_plugin)):

plugin_class = plugin_module.register_plugin()
if plugin_class is not None:
self.register_plugin(
plugin_name,
plugin_class,
plugin_module
)
else:
if config.debug("plugins"):
print_warning(
"'register_plugin' function at %s: %s did "
"not return a class." % (plugin_path, module_name))
else:
if config.debug("plugins"):
print_warning(
"no 'register_plugin' function at %s: %s"
% (plugin_path, module_name))

def get_plugin_class(self, plugin_name):
"""Returns the class registered under the given plugin name."""
Expand Down
12 changes: 10 additions & 2 deletions src/rez/tests/test_plugin_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
from rez.tests.util import TestBase, TempdirMixin, restore_sys_path
from rez.plugin_managers import plugin_manager, uncache_rezplugins_module_paths
from rez.package_repository import package_repository_manager
import os
import sys
import unittest

Expand Down Expand Up @@ -49,7 +50,7 @@ def setUp(self):
TestBase.setUp(self)
self._reset_plugin_manager()

def test_old_loading_style(self):
def test_load_plugin_from_plugin_path(self):
"""Test loading rez plugin from plugin_path"""
self.update_settings(dict(
plugin_path=[self.data_path("extensions", "foo")]
Expand All @@ -59,7 +60,7 @@ def test_old_loading_style(self):
"package_repository", "cloud")
self.assertEqual(cloud_cls.name(), "cloud")

def test_new_loading_style(self):
def test_load_plugin_from_python_module(self):
"""Test loading rez plugin from python modules"""
with restore_sys_path():
sys.path.append(self.data_path("extensions"))
Expand All @@ -68,6 +69,13 @@ def test_new_loading_style(self):
"package_repository", "cloud")
self.assertEqual(cloud_cls.name(), "cloud")

def test_load_plugin_from_entry_points(self):
"""Test loading rez plugin from setuptools entry points"""
with restore_sys_path():
sys.path.append(self.data_path("extensions", "baz"))
baz_cls = plugin_manager.get_plugin_class("command", "baz")
self.assertEqual(baz_cls.name(), "baz")

def test_plugin_override_1(self):
"""Test plugin from plugin_path can override the default"""
self.update_settings(dict(
Expand Down
Loading