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 subclass found across the dependency graph, including intermediate base classes.
Plugin.leaves() Every leaf subclass found across the dependency graph (intermediate base classes are dropped), with leaves that share a merge_key() combined into one generated subclass.
Plugin.concrete_leaves() The same as Plugin.leaves(), excluding abstract classes from the returned result.
Plugin.sorted_subclasses(subclasses) A given iterable of subclasses ordered by sort_key().
Plugin.merge_key() The key deciding which leaf subclasses get merged together; leaves with an equal key are combined. Defaults to the class name.
Plugin.leaf() The single leaf subclass, the class itself if none exist, or a generated subclass merging every leaf that shares a merge key. Computed fresh on every call.
Plugin.L The same result as Plugin.leaf(), cached per class and reused on every subsequent access.
Plugin.I A cached instance of Plugin.L.
for plugin in Plugin.concrete_leaves():
    ...

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, discovery order is stable across runs but is a byproduct of module import order, not a deliberate priority — it can shift if files are renamed or imports are reorganized. When you need an order you control, override sort_key() and iterate with sorted_subclasses().

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.

Before L/I pick a leaf, leaves are grouped by merge_key(). Leaves that return an equal key are merged into one generated subclass that inherits from all of them, so independently-installed packages can each override the same class and still resolve to a single type. The default key is the class name, so this merge happens automatically whenever each override reuses the base class's own name — give unrelated implementations distinct names (or override merge_key() yourself) to keep them apart instead of merging them.

Which leaf's behavior wins for any method more than one of them define is then decided by ordinary MRO precedence over discovery order — stable, but not something you control — so resolve the ambiguity yourself (see below) whenever that matters. If the leaves sharing a merge key have mutually inconsistent base orderings, the merge itself is impossible and L/I raise TypeError instead — resolving the ambiguity yourself avoids that failure too.

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, both reusing its name so they share the default merge key:

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


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


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

With both installed, Greeting has two leaf subclasses that share the default merge key (both are named Greeting). Greeting.L becomes an automatically generated subclass of both, and Greeting.I an instance of it. Since the generated class does not override run() itself, which implementation answers the call depends on ordinary MRO precedence over discovery order — the same on every run, but a byproduct of import order rather than something you chose:

Greeting.I.run()  # "hi" or "hey" — whichever leaf discovery finds first

When you need a specific or custom-combined result instead of leaving it to chance, define your own resolving class in a package that depends on both, inheriting from the leaves in the order you intend. Being the single most-derived leaf, it is what discovery finds, taking priority over the automatically generated merge:

# my_other_project/plugins/greeting.py
from plugin_one.plugins.greeting import Greeting as GreetingOne
from plugin_two.plugins.greeting import Greeting as 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