|
| 1 | +Plugin Discovery at Build Time |
| 2 | +=========================== |
| 3 | + |
| 4 | +This reference guide explains how plux discovers plugins at build time. Understanding this process is helpful for troubleshooting plugin discovery issues and for extending plux to support custom plugin discovery mechanisms. |
| 5 | + |
| 6 | +Overview |
| 7 | +------- |
| 8 | + |
| 9 | +At build time, plux scans your project's source code to find plugins. This is done through a series of abstractions that handle different aspects of the discovery process: |
| 10 | + |
| 11 | +1. **PackageFinder**: Finds Python packages to scan |
| 12 | +2. **PluginFinder**: Finds plugins within those packages |
| 13 | +3. **PluginSpecResolver**: Resolves plugin specifications from various sources |
| 14 | + |
| 15 | +The Discovery Process |
| 16 | +------------------ |
| 17 | + |
| 18 | +The build-time discovery process follows these steps: |
| 19 | + |
| 20 | +1. A **PackageFinder** implementation (e.g., ``DistributionPackageFinder``) identifies Python packages to scan |
| 21 | +2. A **PluginFromPackageFinder** uses the PackageFinder to load modules from those packages |
| 22 | +3. A **ModuleScanningPluginFinder** scans the loaded modules for plugin specifications |
| 23 | +4. A **PluginSpecResolver** resolves plugin specifications from various sources (classes, functions, etc.) |
| 24 | +5. The discovered plugin specifications are written to a plugin index file (``plux.json``) |
| 25 | +6. The plugin index is used to generate entry points in ``entry_points.txt`` |
| 26 | + |
| 27 | +Package Discovery |
| 28 | +-------------- |
| 29 | + |
| 30 | +The ``PackageFinder`` abstraction is responsible for finding Python packages to scan for plugins: |
| 31 | + |
| 32 | +.. code-block:: python |
| 33 | +
|
| 34 | + class PackageFinder: |
| 35 | + def find_packages(self) -> t.Iterable[str]: |
| 36 | + """ |
| 37 | + Returns an Iterable of Python packages. Each item is a string-representation |
| 38 | + of a Python package (for example, ``plux.core``, ``myproject.mypackage.utils``, ...) |
| 39 | + """ |
| 40 | + raise NotImplementedError |
| 41 | + |
| 42 | + @property |
| 43 | + def path(self) -> str: |
| 44 | + """ |
| 45 | + The root file path under which the packages are located. |
| 46 | + """ |
| 47 | + raise NotImplementedError |
| 48 | +
|
| 49 | +Plux provides several implementations: |
| 50 | + |
| 51 | +1. **DistributionPackageFinder**: Uses setuptools to find packages in a distribution |
| 52 | +2. **SetuptoolsPackageFinder**: Uses setuptools directly to find packages |
| 53 | + |
| 54 | +Module Loading |
| 55 | +----------- |
| 56 | + |
| 57 | +The ``PluginFromPackageFinder`` class loads modules from the packages found by the ``PackageFinder``: |
| 58 | + |
| 59 | +.. code-block:: python |
| 60 | +
|
| 61 | + class PluginFromPackageFinder(PluginFinder): |
| 62 | + def __init__(self, finder: PackageFinder): |
| 63 | + self.finder = finder |
| 64 | + |
| 65 | + def find_plugins(self) -> list[PluginSpec]: |
| 66 | + collector = ModuleScanningPluginFinder(self._load_modules()) |
| 67 | + return collector.find_plugins() |
| 68 | + |
| 69 | + def _load_modules(self) -> t.Generator[ModuleType, None, None]: |
| 70 | + # Load modules from packages |
| 71 | + ... |
| 72 | +
|
| 73 | +This class: |
| 74 | + |
| 75 | +1. Gets a list of package names from the ``PackageFinder`` |
| 76 | +2. Converts package names to module names |
| 77 | +3. Imports each module using ``importlib.import_module()`` |
| 78 | +4. Passes the loaded modules to a ``ModuleScanningPluginFinder`` |
| 79 | + |
| 80 | +Module Scanning |
| 81 | +------------ |
| 82 | + |
| 83 | +The ``ModuleScanningPluginFinder`` class scans loaded modules for plugin specifications: |
| 84 | + |
| 85 | +.. code-block:: python |
| 86 | +
|
| 87 | + class ModuleScanningPluginFinder(PluginFinder): |
| 88 | + def __init__(self, modules: t.Iterable[ModuleType], resolver: PluginSpecResolver = None) -> None: |
| 89 | + self.modules = modules |
| 90 | + self.resolver = resolver or PluginSpecResolver() |
| 91 | + |
| 92 | + def find_plugins(self) -> list[PluginSpec]: |
| 93 | + plugins = list() |
| 94 | + |
| 95 | + for module in self.modules: |
| 96 | + members = inspect.getmembers(module) |
| 97 | + |
| 98 | + for member in members: |
| 99 | + if type(member) is tuple: |
| 100 | + try: |
| 101 | + spec = self.resolver.resolve(member[1]) |
| 102 | + plugins.append(spec) |
| 103 | + except Exception: |
| 104 | + pass |
| 105 | + |
| 106 | + return plugins |
| 107 | +
|
| 108 | +This class: |
| 109 | + |
| 110 | +1. Iterates through each module |
| 111 | +2. Gets all members of the module using ``inspect.getmembers()`` |
| 112 | +3. Tries to resolve each member as a plugin specification |
| 113 | +4. Collects all successfully resolved plugin specifications |
| 114 | + |
| 115 | +Plugin Specification Resolution |
| 116 | +---------------------------- |
| 117 | + |
| 118 | +The ``PluginSpecResolver`` class resolves plugin specifications from various sources: |
| 119 | + |
| 120 | +.. code-block:: python |
| 121 | +
|
| 122 | + class PluginSpecResolver: |
| 123 | + def resolve(self, source: t.Any) -> PluginSpec: |
| 124 | + if isinstance(source, PluginSpec): |
| 125 | + return source |
| 126 | + |
| 127 | + if inspect.isclass(source): |
| 128 | + if issubclass(source, Plugin): |
| 129 | + return PluginSpec(source.namespace, source.name, source) |
| 130 | + |
| 131 | + if inspect.isfunction(source): |
| 132 | + spec = getattr(source, "__pluginspec__", None) |
| 133 | + if spec and isinstance(spec, PluginSpec): |
| 134 | + return spec |
| 135 | + |
| 136 | + raise ValueError("cannot resolve plugin specification from %s" % source) |
| 137 | +
|
| 138 | +This class can resolve plugin specifications from: |
| 139 | + |
| 140 | +1. Existing ``PluginSpec`` instances |
| 141 | +2. Plugin classes (subclasses of ``Plugin`` with ``namespace`` and ``name`` attributes) |
| 142 | +3. Functions decorated with ``@plugin`` (which have a ``__pluginspec__`` attribute) |
| 143 | + |
| 144 | +Filtering Packages |
| 145 | +--------------- |
| 146 | + |
| 147 | +Plux provides filtering capabilities to include or exclude specific packages during discovery: |
| 148 | + |
| 149 | +.. code-block:: python |
| 150 | +
|
| 151 | + class Filter: |
| 152 | + def __init__(self, patterns: t.Iterable[str]): |
| 153 | + self._patterns = patterns |
| 154 | + |
| 155 | + def __call__(self, item: str): |
| 156 | + return any(fnmatchcase(item, pat) for pat in self._patterns) |
| 157 | +
|
| 158 | +Filters can be configured in ``pyproject.toml`` or via command-line arguments: |
| 159 | + |
| 160 | +.. code-block:: toml |
| 161 | +
|
| 162 | + [tool.plux] |
| 163 | + exclude = ["**/database/alembic*", "tests*"] |
| 164 | + include = ["myapp/plugins*"] |
| 165 | +
|
| 166 | +Plugin Index Building |
| 167 | +------------------ |
| 168 | + |
| 169 | +The ``PluginIndexBuilder`` class builds a plugin index from discovered plugins: |
| 170 | + |
| 171 | +.. code-block:: python |
| 172 | +
|
| 173 | + class PluginIndexBuilder: |
| 174 | + def __init__(self, finder: PluginFinder): |
| 175 | + self.finder = finder |
| 176 | + |
| 177 | + def write(self, fp, output_format="json") -> EntryPointDict: |
| 178 | + plugins = self.finder.find_plugins() |
| 179 | + entry_points = discover_entry_points(plugins) |
| 180 | + |
| 181 | + if output_format == "json": |
| 182 | + json.dump(entry_points, fp, indent=2) |
| 183 | + elif output_format == "ini": |
| 184 | + write_ini(entry_points, fp) |
| 185 | + |
| 186 | + return entry_points |
| 187 | +
|
| 188 | +This class: |
| 189 | + |
| 190 | +1. Uses a ``PluginFinder`` to discover plugins |
| 191 | +2. Converts the discovered plugins to entry points |
| 192 | +3. Writes the entry points to a file in the specified format (JSON or INI) |
| 193 | + |
| 194 | +Entry Point Generation |
| 195 | +------------------- |
| 196 | + |
| 197 | +The final step is generating entry points from the plugin index: |
| 198 | + |
| 199 | +1. In **build-hooks mode**, plux hooks into the setuptools build process: |
| 200 | + - The ``plugins`` command writes discovered plugins to ``plux.json`` |
| 201 | + - The ``egg_info`` command reads ``plux.json`` and updates ``entry_points.txt`` |
| 202 | + |
| 203 | +2. In **manual mode**, plux writes entry points to ``plux.ini``, which you include in your build configuration: |
| 204 | + |
| 205 | + .. code-block:: toml |
| 206 | +
|
| 207 | + [project] |
| 208 | + dynamic = ["entry-points"] |
| 209 | + |
| 210 | + [tool.setuptools.dynamic] |
| 211 | + entry-points = {file = ["plux.ini"]} |
| 212 | +
|
| 213 | +Extending Plugin Discovery |
| 214 | +----------------------- |
| 215 | + |
| 216 | +You can extend plux's plugin discovery mechanism by implementing custom finders: |
| 217 | + |
| 218 | +Custom Package Finder |
| 219 | +~~~~~~~~~~~~~~~~~~ |
| 220 | + |
| 221 | +To customize how packages are discovered: |
| 222 | + |
| 223 | +.. code-block:: python |
| 224 | +
|
| 225 | + from plux.build.discovery import PackageFinder |
| 226 | + |
| 227 | + class MyPackageFinder(PackageFinder): |
| 228 | + def __init__(self, custom_packages): |
| 229 | + self.custom_packages = custom_packages |
| 230 | + |
| 231 | + def find_packages(self) -> t.Iterable[str]: |
| 232 | + return self.custom_packages |
| 233 | + |
| 234 | + @property |
| 235 | + def path(self) -> str: |
| 236 | + return "." |
| 237 | +
|
| 238 | +Custom Plugin Finder |
| 239 | +~~~~~~~~~~~~~~~~~ |
| 240 | + |
| 241 | +To customize how plugins are discovered: |
| 242 | + |
| 243 | +.. code-block:: python |
| 244 | +
|
| 245 | + from plux import PluginFinder, PluginSpec |
| 246 | + |
| 247 | + class MyPluginFinder(PluginFinder): |
| 248 | + def find_plugins(self) -> list[PluginSpec]: |
| 249 | + # Custom plugin discovery logic |
| 250 | + return discovered_plugins |
| 251 | +
|
| 252 | +Troubleshooting |
| 253 | +------------ |
| 254 | + |
| 255 | +Common issues with build-time plugin discovery: |
| 256 | + |
| 257 | +1. **Plugins not being discovered**: |
| 258 | + - Check that your plugins correctly define ``namespace`` and ``name`` attributes |
| 259 | + - Verify that your include/exclude patterns aren't filtering out your plugins |
| 260 | + - Try running with verbose logging: ``python -m plux entrypoints -v`` |
| 261 | + |
| 262 | +2. **Import errors during discovery**: |
| 263 | + - Plux imports modules to discover plugins, which can cause issues if modules have side effects |
| 264 | + - Use exclude patterns to skip problematic modules: ``exclude = ["**/problematic_module*"]`` |
| 265 | + |
| 266 | +3. **Performance issues**: |
| 267 | + - Scanning large codebases can be slow |
| 268 | + - Use include patterns to focus on specific packages: ``include = ["myapp/plugins*"]`` |
| 269 | + |
| 270 | +Summary |
| 271 | +------ |
| 272 | + |
| 273 | +Plux's build-time plugin discovery process involves: |
| 274 | + |
| 275 | +1. Finding Python packages to scan |
| 276 | +2. Loading modules from those packages |
| 277 | +3. Scanning modules for plugin specifications |
| 278 | +4. Resolving plugin specifications from various sources |
| 279 | +5. Building a plugin index |
| 280 | +6. Generating entry points from the plugin index |
| 281 | + |
| 282 | +This process is highly customizable through the ``PackageFinder`` and ``PluginFinder`` abstractions, and can be configured using include/exclude patterns in ``pyproject.toml`` or via command-line arguments. |
0 commit comments