Source code for modelly.plugins
"""Third-party extensions found through package entry points.
A package extends Modelly by declaring entry points in one of the groups of ``GROUPS``,
for example in its ``pyproject.toml``::
[project.entry-points."modelly.spaces"]
hexworld = "hexworld.space:HexWorld"
``discover(group)`` lists what is installed without importing anything; each ``Plugin`` is
imported the first time it is loaded. A plugin that fails to import raises
``PluginError`` naming the plugin and its distribution, and never stops the others from
being found or loaded: ``load_all`` returns the loaded objects and the errors apart.
from modelly.plugins import discover
spaces = discover("modelly.spaces") # {"hexworld": Plugin(...)}
HexWorld = spaces["hexworld"].load()
"""
from __future__ import annotations
import warnings
from dataclasses import dataclass, field
from importlib.metadata import EntryPoint, entry_points
from typing import Any
GROUPS = (
"modelly.spaces",
"modelly.executors",
"modelly.samplers",
"modelly.collectors",
"modelly.studio_views",
"modelly.lint_rules",
)
# Marks a plugin not imported yet (a plugin may legitimately load as None).
_UNLOADED = object()
[docs]
class PluginError(ImportError):
"""A plugin could not be loaded; ``__cause__`` is the original error."""
[docs]
@dataclass
class Plugin:
"""One entry point: imported on the first ``load`` and cached.
Attributes:
name: the entry point's name, the key in ``discover``'s result.
group: its group, such as ``"modelly.spaces"``.
value: the ``module:attribute`` it points to.
distribution: the installed distribution that declares it, if known.
"""
name: str
group: str
value: str
distribution: str | None
_entry_point: EntryPoint = field(repr=False, compare=False)
_loaded: Any = field(default=_UNLOADED, repr=False, compare=False)
[docs]
def load(self) -> Any:
"""Import and return the object the entry point names.
Raises:
PluginError: if importing or resolving it fails, with the original error
as its cause.
"""
if self._loaded is _UNLOADED:
try:
self._loaded = self._entry_point.load()
# Any error of third-party import code is isolated to this one plugin.
except Exception as error:
raise PluginError(
f"Plugin {self.name!r} ({self.value}) of group {self.group!r} "
f"from {self.distribution or 'an unknown distribution'} "
f"failed to load: {error}"
) from error
return self._loaded
__call__ = load
def _plugin(entry_point: EntryPoint, group: str) -> Plugin:
"""A ``Plugin`` for ``entry_point``, with the name of its distribution."""
dist = getattr(entry_point, "dist", None)
name = dist.metadata["Name"] if dist is not None else None
return Plugin(entry_point.name, group, entry_point.value, name, entry_point)
[docs]
def discover(group: str) -> dict[str, Plugin]:
"""The installed plugins of ``group``, by name, sorted; nothing is imported.
Any group name works, so a feature may define its own; ``GROUPS`` lists Modelly's.
When two distributions declare the same name, the first distribution in
alphabetical order wins and a ``RuntimeWarning`` names both.
Example:
>>> discover("modelly.no_such_group")
{}
"""
found: dict[str, Plugin] = {}
plugins = [_plugin(ep, group) for ep in entry_points(group=group)]
for plugin in sorted(plugins, key=lambda p: (p.name, p.distribution or "")):
if plugin.name in found:
warnings.warn(
f"Plugin {plugin.name!r} of {group!r} is declared by "
f"{found[plugin.name].distribution} and {plugin.distribution}; "
f"using {found[plugin.name].distribution}.",
RuntimeWarning,
stacklevel=2,
)
continue
found[plugin.name] = plugin
return found
[docs]
def load_all(group: str) -> tuple[dict[str, Any], dict[str, PluginError]]:
"""Load every plugin of ``group``: ``(objects by name, errors by name)``.
A plugin that fails is reported in the second dictionary; the others still load.
"""
loaded: dict[str, Any] = {}
errors: dict[str, PluginError] = {}
for name, plugin in discover(group).items():
try:
loaded[name] = plugin.load()
except PluginError as error:
errors[name] = error
return loaded, errors