Skip to content

Plugin Discovery

Plugin discovery lets you define a base class and have all of its subclasses found automatically across your own package and every installed package that depends on it with no registry, no entry points, and no manual imports.

Declaring a base class

Subclass DependencySubclass and implement discovery_module(), a classmethod that returns the module or package that scopes where implementations live:

# my_project/plugins/base/plugin.py
from abc import abstractmethod
from types import ModuleType

from pyrig_runtime.core.dependencies.subclass import DependencySubclass

import my_project.plugins


class Plugin(DependencySubclass):
    @classmethod
    def discovery_module(cls) -> ModuleType:
        return my_project.plugins

    @abstractmethod
    def run(self) -> str:
        """Return this plugin's result."""

The returned module scopes discovery. Discovery looks for subclasses whose defining module name starts with the returned module's name, both in your own distribution and at the same sub-path inside every installed package that depends on its root package — so a dependent package contributes plugins by mirroring the path (for example, otherpkg.plugins).

Return a package to widen discovery to its whole module hierarchy, which is imported and searched recursively. Return a plain module to keep discovery narrow, limiting it to that single file.

Defining implementations

Put concrete subclasses in any module covered by the declared scope:

# my_project/plugins/greeting.py
from my_project.plugins.base import Plugin


class Greeting(Plugin):
    def run(self) -> str:
        return "hello"

You never import or register them anywhere. When the scope is a package it is traversed recursively, so it does not matter how deeply a module or class is nested — every module under the package, at any depth, is imported as a side effect, and simply defining the class is enough for it to be found. When the scope is a plain module, define the subclass in that module (or one whose name shares its prefix).

Discovering and using subclasses

Accessor Returns
Plugin.subclasses() Every leaf subclass found across the dependency graph (intermediate base classes are dropped).
Plugin.concrete_subclasses() The same as Plugin.subclasses(), excluding abstract classes from the returned result.
Plugin.subclasses_sorted(subclasses) A given iterable of subclasses ordered by sort_key().
Plugin.L The single leaf subclass — or the class itself if none exist. Raises if more than one leaf is found.
Plugin.I A cached instance of Plugin.L.
for plugin in Plugin.concrete_subclasses():
    ...

result = Plugin.I.run()  # call the one active implementation

Ordering

Across packages, subclasses are discovered in dependency order — a package is processed before the packages that depend on it. Within a single package the order is not guaranteed. When you need a deterministic order, override sort_key() and iterate with subclasses_sorted().

A single active implementation

L and I are for hierarchies that are meant to have exactly one active implementation: the most-derived (leaf) subclass wins, which lets a dependent package override a base implementation simply by subclassing it. If two unrelated leaves are found, L raises — that ambiguity is treated as a configuration error that must be resolved.

Resolving conflicts

Call the active implementation through I. With only the base Greeting defined, it is the single leaf, so this works:

Greeting.I.run()  # "hello"

Now suppose two installed packages each override Greeting:

# plugin_one/plugins/greeting.py
from my_project.plugins.greeting import Greeting


class GreetingOne(Greeting):
    def run(self) -> str:
        return "hi"
# plugin_two/plugins/greeting.py
from my_project.plugins.greeting import Greeting


class GreetingTwo(Greeting):
    def run(self) -> str:
        return "hey"

With both installed, Greeting has two leaf subclasses, so Greeting.I (and Greeting.L) raises — the active implementation is ambiguous.

Resolve it in a package that depends on both by defining a class that inherits from each conflicting leaf. It becomes the single most-derived leaf, so discovery picks it unambiguously:

# my_other_project/plugins/greeting.py
from plugin_one.plugins.greeting import GreetingOne
from plugin_two.plugins.greeting import GreetingTwo


class GreetingResolved(GreetingOne, GreetingTwo):
    def run(self) -> str:
        # combine both behaviors however you like
        return GreetingOne.run(self) + GreetingTwo.run(self)
Greeting.I.run()  # "hihey" — GreetingResolved combines both

See also