From 5cc7bd9eb24a8a0e77358e83aad48553914b4b92 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Fri, 23 Jan 2026 18:51:17 +0100 Subject: [PATCH 1/2] add docs project --- Makefile | 9 +++++++++ docs/Makefile | 20 ++++++++++++++++++++ docs/conf.py | 50 ++++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.rst | 17 +++++++++++++++++ docs/make.bat | 35 +++++++++++++++++++++++++++++++++++ pyproject.toml | 5 +++++ 6 files changed, 136 insertions(+) create mode 100644 docs/Makefile create mode 100644 docs/conf.py create mode 100644 docs/index.rst create mode 100644 docs/make.bat diff --git a/Makefile b/Makefile index 30a72ee..01f439c 100644 --- a/Makefile +++ b/Makefile @@ -11,6 +11,15 @@ $(VENV_DIR)/bin/activate: pyproject.toml $(VENV_ACTIVATE); pip install -e ".[dev]" touch $(VENV_DIR)/bin/activate +$(VENV_DIR)/.docs-install: pyproject.toml $(VENV_DIR)/bin/activate + $(VENV_ACTIVATE); pip install -e .[docs] + touch $(VENV_DIR)/.docs-install + +install-docs: $(VENV_DIR)/.docs-install + +docs: install-docs + $(VENV_ACTIVATE); cd docs && make html + clean: rm -rf build/ rm -rf .eggs/ diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d4bb2cb --- /dev/null +++ b/docs/Makefile @@ -0,0 +1,20 @@ +# Minimal makefile for Sphinx documentation +# + +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build + +# Put it first so that "make" without argument is like "make help". +help: + @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) + +.PHONY: help Makefile + +# Catch-all target: route all unknown targets to Sphinx using the new +# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). +%: Makefile + @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) diff --git a/docs/conf.py b/docs/conf.py new file mode 100644 index 0000000..9410e40 --- /dev/null +++ b/docs/conf.py @@ -0,0 +1,50 @@ +# Configuration file for the Sphinx documentation builder. +# +# For the full list of built-in configuration values, see the documentation: +# https://www.sphinx-doc.org/en/master/usage/configuration.html + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +project = 'Plux' +copyright = '2026, LocalStack' +author = 'Thomas Rausch' +release = '1.14.0' + +# -- General configuration --------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration + +extensions = [ + 'myst_parser' +] + +templates_path = ['_templates'] +exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] + + + +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output + +html_theme = 'furo' +html_static_path = ['_static'] +html_title = "Plux documentation" + +html_theme_options = { + "top_of_page_buttons": ["view", "edit"], + "source_repository": "https://github.com/localstack/plux/", + "source_branch": "main", + "source_directory": "docs/", + "footer_icons": [ + { + "name": "GitHub", + "url": "https://github.com/localstack/plux", + "html": """ + + `_ +documentation for details. + + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..32bb245 --- /dev/null +++ b/docs/make.bat @@ -0,0 +1,35 @@ +@ECHO OFF + +pushd %~dp0 + +REM Command file for Sphinx documentation + +if "%SPHINXBUILD%" == "" ( + set SPHINXBUILD=sphinx-build +) +set SOURCEDIR=. +set BUILDDIR=_build + +%SPHINXBUILD% >NUL 2>NUL +if errorlevel 9009 ( + echo. + echo.The 'sphinx-build' command was not found. Make sure you have Sphinx + echo.installed, then set the SPHINXBUILD environment variable to point + echo.to the full path of the 'sphinx-build' executable. Alternatively you + echo.may add the Sphinx directory to PATH. + echo. + echo.If you don't have Sphinx installed, grab it from + echo.https://www.sphinx-doc.org/ + exit /b 1 +) + +if "%1" == "" goto help + +%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% +goto end + +:help +%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% + +:end +popd diff --git a/pyproject.toml b/pyproject.toml index ca6a9e9..1f96e9f 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -34,6 +34,11 @@ dev = [ "pytest==8.4.1", "ruff==0.9.1", ] +docs = [ + "sphinx", + "furo", + "myst_parser", +] [tool.hatch.version] path = "plux/__init__.py" From 38c4588554c90a92d5b82554eb13f4f38f619722 Mon Sep 17 00:00:00 2001 From: Thomas Rausch Date: Fri, 23 Jan 2026 20:26:24 +0100 Subject: [PATCH 2/2] add generated docs --- docs/conf.py | 33 +- docs/index.rst | 38 +- docs/reference/build_discovery.rst | 282 +++++++++++++ docs/reference/runtime_discovery.rst | 348 ++++++++++++++++ docs/reference/setuptools_integration.rst | 410 +++++++++++++++++++ docs/tutorials/index.rst | 120 ++++++ docs/user_guide/build_integration.rst | 277 +++++++++++++ docs/user_guide/cli.rst | 240 +++++++++++ docs/user_guide/defining_loading_plugins.rst | 218 ++++++++++ docs/user_guide/filters.rst | 170 ++++++++ docs/user_guide/lifecycle_listener.rst | 300 ++++++++++++++ docs/user_guide/plugin_manager.rst | 238 +++++++++++ docs/user_guide/quickstart.rst | 103 +++++ 13 files changed, 2746 insertions(+), 31 deletions(-) create mode 100644 docs/reference/build_discovery.rst create mode 100644 docs/reference/runtime_discovery.rst create mode 100644 docs/reference/setuptools_integration.rst create mode 100644 docs/tutorials/index.rst create mode 100644 docs/user_guide/build_integration.rst create mode 100644 docs/user_guide/cli.rst create mode 100644 docs/user_guide/defining_loading_plugins.rst create mode 100644 docs/user_guide/filters.rst create mode 100644 docs/user_guide/lifecycle_listener.rst create mode 100644 docs/user_guide/plugin_manager.rst create mode 100644 docs/user_guide/quickstart.rst diff --git a/docs/conf.py b/docs/conf.py index 9410e40..253de39 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -6,28 +6,25 @@ # -- Project information ----------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information -project = 'Plux' -copyright = '2026, LocalStack' -author = 'Thomas Rausch' -release = '1.14.0' +project = "Plux" +copyright = "2026, LocalStack" +author = "Thomas Rausch" +release = "1.14.0" # -- General configuration --------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration -extensions = [ - 'myst_parser' -] - -templates_path = ['_templates'] -exclude_patterns = ['_build', 'Thumbs.db', '.DS_Store'] +extensions = ["myst_parser"] +templates_path = ["_templates"] +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] # -- Options for HTML output ------------------------------------------------- # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output -html_theme = 'furo' -html_static_path = ['_static'] +html_theme = "furo" +html_static_path = ["_static"] html_title = "Plux documentation" html_theme_options = { @@ -35,16 +32,4 @@ "source_repository": "https://github.com/localstack/plux/", "source_branch": "main", "source_directory": "docs/", - "footer_icons": [ - { - "name": "GitHub", - "url": "https://github.com/localstack/plux", - "html": """ - - `_ -documentation for details. +Plux is the dynamic code loading framework used in `LocalStack `_. It builds a higher-level plugin mechanism around `Python's entry point mechanism `_. +Plux provides tools to load plugins from entry points at run time, and to discover entry points from plugins at build time (so you don't have to declare entry points statically in your ``setup.cfg`` or ``pyproject.toml``). + +.. image:: plux-architecture.png + :alt: Plux Architecture + :align: center + +.. toctree:: + :maxdepth: 1 + :caption: User Guide + + user_guide/quickstart + user_guide/defining_loading_plugins + user_guide/plugin_manager + user_guide/filters + user_guide/lifecycle_listener + user_guide/build_integration + user_guide/cli + +.. toctree:: + :maxdepth: 1 + :caption: Reference + + reference/build_discovery + reference/runtime_discovery + reference/setuptools_integration .. toctree:: - :maxdepth: 2 - :caption: Contents: + :maxdepth: 1 + :caption: Tutorials + tutorials/index diff --git a/docs/reference/build_discovery.rst b/docs/reference/build_discovery.rst new file mode 100644 index 0000000..fd63b2e --- /dev/null +++ b/docs/reference/build_discovery.rst @@ -0,0 +1,282 @@ +Plugin Discovery at Build Time +=========================== + +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. + +Overview +------- + +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: + +1. **PackageFinder**: Finds Python packages to scan +2. **PluginFinder**: Finds plugins within those packages +3. **PluginSpecResolver**: Resolves plugin specifications from various sources + +The Discovery Process +------------------ + +The build-time discovery process follows these steps: + +1. A **PackageFinder** implementation (e.g., ``DistributionPackageFinder``) identifies Python packages to scan +2. A **PluginFromPackageFinder** uses the PackageFinder to load modules from those packages +3. A **ModuleScanningPluginFinder** scans the loaded modules for plugin specifications +4. A **PluginSpecResolver** resolves plugin specifications from various sources (classes, functions, etc.) +5. The discovered plugin specifications are written to a plugin index file (``plux.json``) +6. The plugin index is used to generate entry points in ``entry_points.txt`` + +Package Discovery +-------------- + +The ``PackageFinder`` abstraction is responsible for finding Python packages to scan for plugins: + +.. code-block:: python + + class PackageFinder: + def find_packages(self) -> t.Iterable[str]: + """ + Returns an Iterable of Python packages. Each item is a string-representation + of a Python package (for example, ``plux.core``, ``myproject.mypackage.utils``, ...) + """ + raise NotImplementedError + + @property + def path(self) -> str: + """ + The root file path under which the packages are located. + """ + raise NotImplementedError + +Plux provides several implementations: + +1. **DistributionPackageFinder**: Uses setuptools to find packages in a distribution +2. **SetuptoolsPackageFinder**: Uses setuptools directly to find packages + +Module Loading +----------- + +The ``PluginFromPackageFinder`` class loads modules from the packages found by the ``PackageFinder``: + +.. code-block:: python + + class PluginFromPackageFinder(PluginFinder): + def __init__(self, finder: PackageFinder): + self.finder = finder + + def find_plugins(self) -> list[PluginSpec]: + collector = ModuleScanningPluginFinder(self._load_modules()) + return collector.find_plugins() + + def _load_modules(self) -> t.Generator[ModuleType, None, None]: + # Load modules from packages + ... + +This class: + +1. Gets a list of package names from the ``PackageFinder`` +2. Converts package names to module names +3. Imports each module using ``importlib.import_module()`` +4. Passes the loaded modules to a ``ModuleScanningPluginFinder`` + +Module Scanning +------------ + +The ``ModuleScanningPluginFinder`` class scans loaded modules for plugin specifications: + +.. code-block:: python + + class ModuleScanningPluginFinder(PluginFinder): + def __init__(self, modules: t.Iterable[ModuleType], resolver: PluginSpecResolver = None) -> None: + self.modules = modules + self.resolver = resolver or PluginSpecResolver() + + def find_plugins(self) -> list[PluginSpec]: + plugins = list() + + for module in self.modules: + members = inspect.getmembers(module) + + for member in members: + if type(member) is tuple: + try: + spec = self.resolver.resolve(member[1]) + plugins.append(spec) + except Exception: + pass + + return plugins + +This class: + +1. Iterates through each module +2. Gets all members of the module using ``inspect.getmembers()`` +3. Tries to resolve each member as a plugin specification +4. Collects all successfully resolved plugin specifications + +Plugin Specification Resolution +---------------------------- + +The ``PluginSpecResolver`` class resolves plugin specifications from various sources: + +.. code-block:: python + + class PluginSpecResolver: + def resolve(self, source: t.Any) -> PluginSpec: + if isinstance(source, PluginSpec): + return source + + if inspect.isclass(source): + if issubclass(source, Plugin): + return PluginSpec(source.namespace, source.name, source) + + if inspect.isfunction(source): + spec = getattr(source, "__pluginspec__", None) + if spec and isinstance(spec, PluginSpec): + return spec + + raise ValueError("cannot resolve plugin specification from %s" % source) + +This class can resolve plugin specifications from: + +1. Existing ``PluginSpec`` instances +2. Plugin classes (subclasses of ``Plugin`` with ``namespace`` and ``name`` attributes) +3. Functions decorated with ``@plugin`` (which have a ``__pluginspec__`` attribute) + +Filtering Packages +--------------- + +Plux provides filtering capabilities to include or exclude specific packages during discovery: + +.. code-block:: python + + class Filter: + def __init__(self, patterns: t.Iterable[str]): + self._patterns = patterns + + def __call__(self, item: str): + return any(fnmatchcase(item, pat) for pat in self._patterns) + +Filters can be configured in ``pyproject.toml`` or via command-line arguments: + +.. code-block:: toml + + [tool.plux] + exclude = ["**/database/alembic*", "tests*"] + include = ["myapp/plugins*"] + +Plugin Index Building +------------------ + +The ``PluginIndexBuilder`` class builds a plugin index from discovered plugins: + +.. code-block:: python + + class PluginIndexBuilder: + def __init__(self, finder: PluginFinder): + self.finder = finder + + def write(self, fp, output_format="json") -> EntryPointDict: + plugins = self.finder.find_plugins() + entry_points = discover_entry_points(plugins) + + if output_format == "json": + json.dump(entry_points, fp, indent=2) + elif output_format == "ini": + write_ini(entry_points, fp) + + return entry_points + +This class: + +1. Uses a ``PluginFinder`` to discover plugins +2. Converts the discovered plugins to entry points +3. Writes the entry points to a file in the specified format (JSON or INI) + +Entry Point Generation +------------------- + +The final step is generating entry points from the plugin index: + +1. In **build-hooks mode**, plux hooks into the setuptools build process: + - The ``plugins`` command writes discovered plugins to ``plux.json`` + - The ``egg_info`` command reads ``plux.json`` and updates ``entry_points.txt`` + +2. In **manual mode**, plux writes entry points to ``plux.ini``, which you include in your build configuration: + + .. code-block:: toml + + [project] + dynamic = ["entry-points"] + + [tool.setuptools.dynamic] + entry-points = {file = ["plux.ini"]} + +Extending Plugin Discovery +----------------------- + +You can extend plux's plugin discovery mechanism by implementing custom finders: + +Custom Package Finder +~~~~~~~~~~~~~~~~~~ + +To customize how packages are discovered: + +.. code-block:: python + + from plux.build.discovery import PackageFinder + + class MyPackageFinder(PackageFinder): + def __init__(self, custom_packages): + self.custom_packages = custom_packages + + def find_packages(self) -> t.Iterable[str]: + return self.custom_packages + + @property + def path(self) -> str: + return "." + +Custom Plugin Finder +~~~~~~~~~~~~~~~~~ + +To customize how plugins are discovered: + +.. code-block:: python + + from plux import PluginFinder, PluginSpec + + class MyPluginFinder(PluginFinder): + def find_plugins(self) -> list[PluginSpec]: + # Custom plugin discovery logic + return discovered_plugins + +Troubleshooting +------------ + +Common issues with build-time plugin discovery: + +1. **Plugins not being discovered**: + - Check that your plugins correctly define ``namespace`` and ``name`` attributes + - Verify that your include/exclude patterns aren't filtering out your plugins + - Try running with verbose logging: ``python -m plux entrypoints -v`` + +2. **Import errors during discovery**: + - Plux imports modules to discover plugins, which can cause issues if modules have side effects + - Use exclude patterns to skip problematic modules: ``exclude = ["**/problematic_module*"]`` + +3. **Performance issues**: + - Scanning large codebases can be slow + - Use include patterns to focus on specific packages: ``include = ["myapp/plugins*"]`` + +Summary +------ + +Plux's build-time plugin discovery process involves: + +1. Finding Python packages to scan +2. Loading modules from those packages +3. Scanning modules for plugin specifications +4. Resolving plugin specifications from various sources +5. Building a plugin index +6. Generating entry points from the plugin index + +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. \ No newline at end of file diff --git a/docs/reference/runtime_discovery.rst b/docs/reference/runtime_discovery.rst new file mode 100644 index 0000000..ce8045f --- /dev/null +++ b/docs/reference/runtime_discovery.rst @@ -0,0 +1,348 @@ +Plugin Discovery at Run Time +========================= + +This reference guide explains how plux discovers plugins at runtime. Understanding this process is helpful for troubleshooting runtime plugin discovery issues and for extending plux to support custom runtime plugin discovery mechanisms. + +Overview +------- + +At runtime, plux uses Python's entry point mechanism to discover plugins. This is done through a series of abstractions that handle different aspects of the discovery process: + +1. **PluginFinder**: Finds plugins from entry points +2. **PluginManager**: Manages the lifecycle of discovered plugins +3. **PluginFilter**: Filters which plugins should be loaded + +The Discovery Process +------------------ + +The runtime discovery process follows these steps: + +1. A **PluginManager** is created for a specific namespace +2. The manager uses a **MetadataPluginFinder** to find plugins in that namespace +3. The finder uses Python's ``importlib.metadata`` to discover entry points +4. Entry points are loaded and resolved to **PluginSpec** instances +5. The manager creates **PluginContainer** instances for each spec +6. When requested, the manager initializes and loads plugins from their containers + +Entry Point Discovery +----------------- + +The ``MetadataPluginFinder`` class discovers plugins from entry points: + +.. code-block:: python + + from importlib.metadata import entry_points + from plux import PluginFinder, PluginSpec, PluginSpecResolver + + class MetadataPluginFinder(PluginFinder): + def __init__(self, namespace: str, resolver: PluginSpecResolver = None): + self.namespace = namespace + self.resolver = resolver or PluginSpecResolver() + + def find_plugins(self) -> list[PluginSpec]: + plugins = [] + + # Get all entry points in the namespace + for entry_point in entry_points(group=self.namespace): + try: + # Load the entry point + obj = entry_point.load() + + # Resolve the plugin specification + spec = self.resolver.resolve(obj) + plugins.append(spec) + except Exception as e: + # Handle errors + pass + + return plugins + +This class: + +1. Gets all entry points in the specified namespace using ``importlib.metadata.entry_points()`` +2. Loads each entry point using ``entry_point.load()`` +3. Resolves the loaded object to a ``PluginSpec`` using a ``PluginSpecResolver`` +4. Returns a list of all successfully resolved plugin specifications + +Plugin Resolution +-------------- + +The ``PluginSpecResolver`` class resolves plugin specifications from entry points: + +.. code-block:: python + + class PluginSpecResolver: + def resolve(self, source: t.Any) -> PluginSpec: + if isinstance(source, PluginSpec): + return source + + if inspect.isclass(source): + if issubclass(source, Plugin): + return PluginSpec(source.namespace, source.name, source) + + if inspect.isfunction(source): + spec = getattr(source, "__pluginspec__", None) + if spec and isinstance(spec, PluginSpec): + return spec + + raise ValueError("cannot resolve plugin specification from %s" % source) + +This class can resolve plugin specifications from: + +1. Existing ``PluginSpec`` instances +2. Plugin classes (subclasses of ``Plugin`` with ``namespace`` and ``name`` attributes) +3. Functions decorated with ``@plugin`` (which have a ``__pluginspec__`` attribute) + +Plugin Container Management +------------------------ + +The ``PluginManager`` creates and manages ``PluginContainer`` instances for each discovered plugin: + +.. code-block:: python + + class PluginContainer(t.Generic[P]): + def __init__(self, plugin_spec: PluginSpec): + self.plugin_spec = plugin_spec + self.plugin = None + self.loaded = False + self.disabled = False + self.disabled_reason = None + self._distribution = None + + @property + def distribution(self) -> Distribution | None: + if self._distribution is None: + self._distribution = resolve_distribution_information(self.plugin_spec) + return self._distribution + +A ``PluginContainer`` holds: + +1. The ``PluginSpec`` for the plugin +2. The plugin instance (once initialized) +3. The loading status of the plugin +4. Whether the plugin is disabled +5. Distribution information for the plugin + +Plugin Initialization and Loading +------------------------------ + +The ``PluginManager`` handles plugin initialization and loading: + +.. code-block:: python + + def _load_plugin(self, container: PluginContainer) -> t.Any: + # Check if already loaded + if container.loaded: + return container.load_result + + # Check if disabled + if container.disabled: + raise PluginDisabled( + container.plugin_spec.namespace, + container.plugin_spec.name, + container.disabled_reason, + ) + + # Initialize plugin if not already initialized + if container.plugin is None: + container.plugin = self._plugin_from_spec(container.plugin_spec) + + plugin = container.plugin + + # Check if plugin should be loaded + if not plugin.should_load(): + return None + + # Prepare load arguments + load_args = list(self.load_args) if self.load_args else [] + load_kwargs = dict(self.load_kwargs) if self.load_kwargs else {} + + # Fire lifecycle event + self._fire_on_load_before(container.plugin_spec, plugin, load_args, load_kwargs) + + # Load the plugin + result = plugin.load(*load_args, **load_kwargs) + + # Update container state + container.loaded = True + container.load_result = result + + # Fire lifecycle event + self._fire_on_load_after(container.plugin_spec, plugin, result) + + return result + +This method: + +1. Checks if the plugin is already loaded or disabled +2. Initializes the plugin if needed +3. Checks if the plugin should be loaded +4. Prepares load arguments +5. Fires lifecycle events before loading +6. Loads the plugin +7. Updates the container state +8. Fires lifecycle events after loading +9. Returns the load result + +Plugin Filtering +------------- + +The ``PluginManager`` applies filters to control which plugins are loaded: + +.. code-block:: python + + def _init_plugin_index(self): + # Find plugins + plugin_specs = self.finder.find_plugins() + + # Apply filters + for spec in plugin_specs: + # Check global filter + if global_plugin_filter(spec): + continue + + # Check manager-specific filters + if any(f(spec) for f in self.filters): + continue + + # Create container for the plugin + container = self._create_container(spec) + self._plugins_by_name[spec.name] = container + +This method: + +1. Finds plugins using the ``PluginFinder`` +2. Applies the global plugin filter +3. Applies manager-specific filters +4. Creates containers for plugins that pass all filters + +Distribution Information +--------------------- + +Plux can resolve distribution information for plugins: + +.. code-block:: python + + def resolve_distribution_information(plugin_spec: PluginSpec) -> Distribution | None: + try: + # Get the module of the plugin factory + module = inspect.getmodule(plugin_spec.factory) + if not module: + return None + + # Get the distribution for the module + return Distribution.from_module(module) + except Exception: + return None + +This function: + +1. Gets the module of the plugin factory +2. Resolves the distribution information for that module +3. Returns a ``Distribution`` object with name, version, and other metadata + +Editable Installs Support +---------------------- + +Plux has special support for editable installs: + +1. When using ``pip install -e .``, plux creates a link from the installed dist-info directory to the source egg-info directory +2. This link is stored in ``entry_points_editable.txt`` in the dist-info directory +3. At runtime, plux checks for this file and follows the link to find entry points + +.. code-block:: python + + def entry_points_from_metadata_path(path: str) -> EntryPointDict: + # Check for editable install link + editable_link = os.path.join(path, "entry_points_editable.txt") + if os.path.exists(editable_link): + with open(editable_link, "r") as fd: + path = fd.read().strip() + + # Read entry points from the path + entry_points_file = os.path.join(path, "entry_points.txt") + if os.path.exists(entry_points_file): + return read_entry_points(entry_points_file) + + return {} + +This function: + +1. Checks for an editable install link +2. If found, follows the link to the source egg-info directory +3. Reads entry points from the entry_points.txt file + +Extending Runtime Discovery +------------------------ + +You can extend plux's runtime discovery mechanism by implementing custom finders: + +Custom Plugin Finder +~~~~~~~~~~~~~~~~~ + +To customize how plugins are discovered at runtime: + +.. code-block:: python + + from plux import PluginFinder, PluginSpec + + class MyPluginFinder(PluginFinder): + def __init__(self, namespace: str): + self.namespace = namespace + + def find_plugins(self) -> list[PluginSpec]: + # Custom plugin discovery logic + return discovered_plugins + + # Use the custom finder with a PluginManager + manager = PluginManager("my.plugins", finder=MyPluginFinder("my.plugins")) + +Custom Plugin Filter +~~~~~~~~~~~~~~~~ + +To customize how plugins are filtered: + +.. code-block:: python + + from plux.runtime.filter import PluginFilter + from plux.core.plugin import PluginSpec + + class MyPluginFilter: + def __call__(self, spec: PluginSpec) -> bool: + # Return True to filter out (disable) the plugin + # Return False to include the plugin + return should_filter_plugin(spec) + + # Use the custom filter with a PluginManager + manager = PluginManager("my.plugins", filters=[MyPluginFilter()]) + +Troubleshooting +------------ + +Common issues with runtime plugin discovery: + +1. **Plugins not being discovered**: + - Check that entry points were correctly generated during the build + - Verify that the package is correctly installed + - Check that you're using the correct namespace + +2. **Entry points not being found**: + - For editable installs, make sure you're using a recent version of pip and setuptools + - Try reinstalling with ``pip install -e . --no-build-isolation`` + +3. **Plugin loading errors**: + - Use a ``PluginLifecycleListener`` to debug plugin loading issues + - Check that plugins correctly implement the ``Plugin`` interface + +Summary +------ + +Plux's runtime plugin discovery process involves: + +1. Finding entry points in a specific namespace +2. Loading entry points and resolving them to plugin specifications +3. Creating containers for each plugin specification +4. Initializing and loading plugins when requested +5. Applying filters to control which plugins are loaded + +This process is highly customizable through the ``PluginFinder`` and ``PluginFilter`` abstractions, and includes special support for editable installs to make development easier. \ No newline at end of file diff --git a/docs/reference/setuptools_integration.rst b/docs/reference/setuptools_integration.rst new file mode 100644 index 0000000..d89d916 --- /dev/null +++ b/docs/reference/setuptools_integration.rst @@ -0,0 +1,410 @@ +Setuptools Integration +=================== + +This reference guide explains how plux integrates with setuptools. Understanding this integration is helpful for troubleshooting build issues and for extending plux to support custom build processes. + +Overview +------- + +Plux integrates with setuptools to discover plugins at build time and generate entry points. This integration involves several components: + +1. **Custom setuptools commands**: The ``plugins`` command for discovering plugins +2. **Build process hooks**: Patches to setuptools commands like ``egg_info`` and ``editable_wheel`` +3. **Entry point writers**: Hooks into setuptools' entry point generation process +4. **Project configuration**: Integration with ``pyproject.toml`` and ``setup.py`` + +The Integration Process +-------------------- + +The setuptools integration process follows these steps: + +1. Plux registers a custom setuptools command ``plugins`` +2. When the command is run, it discovers plugins and writes them to a ``plux.json`` file +3. Plux patches the ``egg_info`` command to read the ``plux.json`` file and update entry points +4. Plux also patches the ``editable_wheel`` command to support editable installs +5. The generated entry points are included in the distribution metadata + +Custom Setuptools Command +---------------------- + +Plux provides a custom setuptools command ``plugins``: + +.. code-block:: python + + class plugins(InfoCommon, setuptools.Command): + """ + Setuptools command that discovers plugins and writes them into the egg_info + directory to a ``plux.json`` file. + """ + + description = "Discover plux plugins and store them in .egg_info" + + user_options = [ + ( + "exclude=", + "e", + "a sequence of paths to exclude; '*' can be used as a wildcard in the names.", + ), + ( + "include=", + "i", + "a sequence of paths to include; If it's specified, only the named items will be included.", + ), + ] + + def initialize_options(self) -> None: + self.plux_json_path = None + self.exclude = None + self.include = None + self.plux_config = None + + def finalize_options(self) -> None: + self.plux_json_path = get_plux_json_path(self.distribution) + self.ensure_string_list("exclude") + self.ensure_string_list("include") + + # Merge CLI arguments with pyproject.toml configuration + self.plux_config = read_plux_configuration(self.distribution) + self.plux_config = self.plux_config.merge( + exclude=self.exclude, + include=self.include, + ) + + def run(self) -> None: + # Create a plugin index builder + index_builder = create_plugin_index_builder(self.plux_config, self.distribution) + + # Write discovered plugins to plux.json + self.mkpath(os.path.dirname(self.plux_json_path)) + with open(self.plux_json_path, "w") as fp: + ep = index_builder.write(fp) + + # Update distribution entry points + update_entrypoints(self.distribution, ep) + +This command: + +1. Reads configuration from ``pyproject.toml`` and command-line arguments +2. Creates a plugin index builder +3. Discovers plugins and writes them to a ``plux.json`` file +4. Updates the distribution's entry points + +Build Process Hooks +---------------- + +Plux patches several setuptools commands to integrate with the build process: + +Patching the egg_info Command +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``egg_info`` command is patched to read the ``plux.json`` file and update entry points: + +.. code-block:: python + + def patch_egg_info_command(): + _run_orig = egg_info.run + + def _run(self): + # Check if we're in manual mode + cfg = read_plux_configuration(self.distribution) + if cfg.entrypoint_build_mode == EntrypointBuildMode.MANUAL: + return _run_orig(self) + + # Check if we should read from an existing egg_info directory + should_read, meta_dir = _should_read_existing_egg_info() + + if should_read: + # Copy plux.json from the existing egg_info directory + plux_json = os.path.join(meta_dir, "plux.json") + if os.path.exists(plux_json): + self.mkpath(self.egg_info) + if not os.path.exists(os.path.join(self.egg_info, "plux.json")): + shutil.copy(plux_json, self.egg_info) + + return _run_orig(self) + + egg_info.run = _run + +This patch: + +1. Checks if plux is in manual mode +2. Checks if there's an existing egg_info directory with a ``plux.json`` file +3. If found, copies the ``plux.json`` file to the new egg_info directory +4. Calls the original ``egg_info.run`` method + +Patching the editable_wheel Command +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``editable_wheel`` command is patched to support editable installs: + +.. code-block:: python + + def patch_editable_wheel_command(): + _ensure_dist_info_orig = editable_wheel._ensure_dist_info + + def _ensure_dist_info(self): + _ensure_dist_info_orig(self) + + # Create a link to the original entry points + target = Path(self.dist_info_dir, "entry_points_editable.txt") + target.write_text(os.path.join(find_egg_info_dir(), "entry_points.txt")) + + editable_wheel._ensure_dist_info = _ensure_dist_info + +This patch: + +1. Calls the original ``_ensure_dist_info`` method +2. Creates a link from the dist-info directory to the egg-info directory +3. This link is used at runtime to find entry points in editable installs + +Entry Point Writers +--------------- + +Plux hooks into setuptools' entry point generation process: + +.. code-block:: python + + def load_plux_entrypoints(cmd, file_name, file_path): + """ + This method is called indirectly by setuptools through the ``egg_info.writers`` plugin. + """ + if not os.path.exists(file_path): + return + + # Check if we're in manual mode + cfg = read_plux_configuration(cmd.distribution) + if cfg.entrypoint_build_mode == EntrypointBuildMode.MANUAL: + return + + # Read plux.json and update entry points + with open(file_path, "r") as fd: + ep = json.load(fd) + + update_entrypoints(cmd.distribution, ep) + + # Write the updated entry points + ep_file = "entry_points.txt" + ep_path = os.path.join(os.path.dirname(file_path), ep_file) + write_entries(cmd, ep_file, ep_path) + +This function: + +1. Checks if the ``plux.json`` file exists +2. Checks if plux is in manual mode +3. Reads the ``plux.json`` file and updates the distribution's entry points +4. Writes the updated entry points to ``entry_points.txt`` + +This function is registered as an entry point in plux's own ``pyproject.toml``: + +.. code-block:: toml + + [project.entry-points."egg_info.writers"] + "plux.json" = "plux.build.setuptools:load_plux_entrypoints" + +Project Configuration +------------------ + +Plux integrates with project configuration in several ways: + +Reading Configuration from pyproject.toml +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Plux reads configuration from the ``[tool.plux]`` section of ``pyproject.toml``: + +.. code-block:: python + + def read_plux_configuration(distribution: setuptools.Distribution) -> config.PluxConfiguration: + dirs = distribution.package_dir + pyproject_base = (dirs or {}).get("", os.curdir) + return config.read_plux_config_from_workdir(pyproject_base) + +This function: + +1. Gets the package directory from the distribution +2. Reads the ``pyproject.toml`` file from that directory +3. Parses the ``[tool.plux]`` section into a ``PluxConfiguration`` object + +Direct Integration in setup.py +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For older projects using ``setup.py``, plux provides direct integration: + +.. code-block:: python + + from setuptools import setup + from plux.build.setuptools import find_plugins + + setup( + name="my-project", + # ... + entry_points=find_plugins() + ) + +The ``find_plugins`` function: + +1. Discovers plugins in the project +2. Returns a dictionary of entry points +3. This dictionary can be passed directly to ``setup()`` + +SetuptoolsProject Class +-------------------- + +Plux provides a ``SetuptoolsProject`` class that implements the ``Project`` interface for setuptools: + +.. code-block:: python + + class SetuptoolsProject(Project): + def __init__(self, workdir: str = None): + super().__init__(workdir) + self.distribution = get_distribution_from_workdir(str(self.workdir)) + + def find_entry_point_file(self) -> Path: + if egg_info_dir := find_egg_info_dir(): + return Path(egg_info_dir, "entry_points.txt") + raise FileNotFoundError("No .egg-info directory found.") + + def find_plux_index_file(self) -> Path: + if self.config.entrypoint_build_mode == EntrypointBuildMode.MANUAL: + return self.workdir / self.config.entrypoint_static_file + return Path(get_plux_json_path(self.distribution)) + + def create_plugin_index_builder(self) -> PluginIndexBuilder: + return create_plugin_index_builder(self.config, self.distribution) + + def create_package_finder(self) -> PackageFinder: + exclude = [_path_to_module(item) for item in self.config.exclude] + include = [_path_to_module(item) for item in self.config.include] + return DistributionPackageFinder(self.distribution, exclude=exclude, include=include) + + def build_entrypoints(self): + dist = self.distribution + + # Run the plugins command + dist.command_options["plugins"] = { + "exclude": ("command line", ",".join(self.config.exclude) or None), + "include": ("command line", ",".join(self.config.include) or None), + } + dist.run_command("plugins") + + # Run the egg_info command + dist.run_command("egg_info") + +This class: + +1. Wraps a setuptools ``Distribution`` object +2. Provides methods for finding entry point files and plugin index files +3. Creates plugin index builders and package finders +4. Builds entry points by running setuptools commands + +Package Finders +------------ + +Plux provides several package finders for setuptools: + +DistributionPackageFinder +~~~~~~~~~~~~~~~~~~~~~ + +The ``DistributionPackageFinder`` finds packages in a setuptools distribution: + +.. code-block:: python + + class DistributionPackageFinder(PackageFinder): + def __init__( + self, + distribution: setuptools.Distribution, + exclude: t.Iterable[str] | None = None, + include: t.Iterable[str] | None = None, + ): + self.distribution = distribution + self.exclude = Filter(exclude or []) + self.include = Filter(include) if include else MatchAllFilter() + + def find_packages(self) -> t.Iterable[str]: + if self.distribution.packages is None: + raise ValueError( + "No packages found in setuptools distribution. Is your project configured correctly?" + ) + return self.filter_packages(self.distribution.packages) + + @property + def path(self) -> str: + if not self.distribution.package_dir: + where = "." + else: + if self.distribution.package_dir[""]: + where = self.distribution.package_dir[""] + else: + where = "." + return where + + def filter_packages(self, packages: t.Iterable[str]) -> t.Iterable[str]: + return [item for item in packages if not self.exclude(item) and self.include(item)] + +This class: + +1. Gets packages from the distribution +2. Filters packages based on include/exclude patterns +3. Returns the path to the packages + +SetuptoolsPackageFinder +~~~~~~~~~~~~~~~~~~~ + +The ``SetuptoolsPackageFinder`` uses setuptools directly to find packages: + +.. code-block:: python + + class SetuptoolsPackageFinder(PackageFinder): + def __init__(self, where=".", exclude=(), include=("*",), namespace=True) -> None: + self.where = where + self.exclude = exclude + self.include = include + self.namespace = namespace + + def find_packages(self) -> t.Iterable[str]: + if self.namespace: + return setuptools.find_namespace_packages(self.where, self.exclude, self.include) + else: + return setuptools.find_packages(self.where, self.exclude, self.include) + + @property + def path(self) -> str: + return self.where + +This class: + +1. Uses ``setuptools.find_packages`` or ``setuptools.find_namespace_packages`` +2. Returns the packages found by setuptools +3. Returns the path to the packages + +Troubleshooting +------------ + +Common issues with setuptools integration: + +1. **Entry points not being generated**: + - Check that plux is included in your build dependencies + - Verify that your ``pyproject.toml`` configuration is correct + - Try running ``python setup.py plugins`` manually to see if plugins are discovered + +2. **Plugins not being discovered**: + - Check that your plugins correctly define ``namespace`` and ``name`` attributes + - Verify that your include/exclude patterns aren't filtering out your plugins + - Try running with verbose logging: ``python setup.py -v plugins`` + +3. **Editable install issues**: + - Make sure you're using a recent version of pip and setuptools + - Try reinstalling with ``pip install -e . --no-build-isolation`` + - Check that the ``entry_points_editable.txt`` file is created in the dist-info directory + +Summary +------ + +Plux's setuptools integration involves: + +1. A custom setuptools command ``plugins`` for discovering plugins +2. Patches to setuptools commands like ``egg_info`` and ``editable_wheel`` +3. Hooks into setuptools' entry point generation process +4. Integration with ``pyproject.toml`` and ``setup.py`` +5. Package finders for discovering packages in setuptools distributions + +This integration allows plux to discover plugins at build time and generate entry points that can be used at runtime to load plugins. \ No newline at end of file diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst new file mode 100644 index 0000000..6680f4f --- /dev/null +++ b/docs/tutorials/index.rst @@ -0,0 +1,120 @@ +Tutorials +======== + +This section will contain tutorials on how to use plux for more complex end-to-end scenarios. These tutorials will be added in future updates. + +Coming Soon +--------- + +* **Building a Plugin-based Application**: A tutorial on how to design and build an application that uses plugins for extensibility. + +* **Creating a Plugin Ecosystem**: How to create a plugin ecosystem for your application, including guidelines for plugin developers. + +* **Advanced Plugin Patterns**: Advanced patterns for plugin development, including dependency injection, plugin composition, and more. + +* **Testing Plugins**: Strategies for testing plugins and plugin-based applications. + +* **Performance Optimization**: Tips and tricks for optimizing plugin discovery and loading performance. + +Placeholder Examples +----------------- + +While detailed tutorials are being developed, here are some simple examples to get you started: + +Example: Building a Simple Plugin-based CLI +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # app.py + from plux import Plugin, PluginManager + import argparse + + class CommandPlugin(Plugin): + namespace = "my.app.commands" + + def get_command_name(self): + return self.name + + def add_arguments(self, parser): + pass + + def execute(self, args): + pass + + def main(): + # Create the main parser + parser = argparse.ArgumentParser(description="My Plugin-based CLI") + subparsers = parser.add_subparsers(dest="command") + + # Load command plugins + manager = PluginManager("my.app.commands") + commands = {} + + for command in manager.load_all(): + command_name = command.get_command_name() + command_parser = subparsers.add_parser(command_name) + command.add_arguments(command_parser) + commands[command_name] = command + + # Parse arguments and execute command + args = parser.parse_args() + + if args.command and args.command in commands: + commands[args.command].execute(args) + else: + parser.print_help() + + if __name__ == "__main__": + main() + +Example: Implementing a Command Plugin +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # plugins/hello.py + from app import CommandPlugin + + class HelloCommand(CommandPlugin): + name = "hello" + + def add_arguments(self, parser): + parser.add_argument("--name", default="World", help="Name to greet") + + def execute(self, args): + print(f"Hello, {args.name}!") + +Example: Using Plugin Lifecycle Listeners +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: python + + # listeners.py + from plux import PluginLifecycleListener + import logging + + class LoggingListener(PluginLifecycleListener): + def __init__(self): + self.logger = logging.getLogger("plugin.lifecycle") + + def on_resolve_after(self, plugin_spec): + self.logger.info(f"Resolved plugin: {plugin_spec.namespace}:{plugin_spec.name}") + + def on_init_after(self, plugin_spec, plugin): + self.logger.info(f"Initialized plugin: {plugin_spec.name}") + + def on_load_after(self, plugin_spec, plugin, load_result): + self.logger.info(f"Loaded plugin: {plugin_spec.name}") + + def on_load_exception(self, plugin_spec, plugin, exception): + self.logger.error(f"Error loading plugin {plugin_spec.name}: {exception}") + + # Using the listener + manager = PluginManager("my.plugins", listener=LoggingListener()) + plugins = manager.load_all() + +Stay Tuned +-------- + +More detailed tutorials will be added in future updates. In the meantime, check out the User Guide and Reference sections for comprehensive documentation on plux's features and APIs. \ No newline at end of file diff --git a/docs/user_guide/build_integration.rst b/docs/user_guide/build_integration.rst new file mode 100644 index 0000000..f905e48 --- /dev/null +++ b/docs/user_guide/build_integration.rst @@ -0,0 +1,277 @@ +Build System Integration +===================== + +Plux integrates with your build system to discover plugins at build time and generate entry points. This guide explains how to integrate plux with your build system, focusing on setuptools which is currently the primary supported build backend. + +Understanding Build Modes +---------------------- + +Plux supports two modes for building entry points: + +1. **Build-hooks mode** (default): Plux automatically hooks into the build process to generate entry points +2. **Manual mode**: You manually control when and how entry points are generated + +Configuring Plux in pyproject.toml +------------------------------- + +You can configure plux in the ``[tool.plux]`` section of your ``pyproject.toml`` file: + +.. code-block:: toml + + [tool.plux] + # The build mode for entry points: "build-hooks" (default) or "manual" + entrypoint_build_mode = "build-hooks" + + # The file path to scan for plugins (optional) + path = "src" + + # Python packages to exclude during discovery (optional) + exclude = ["**/database/alembic*", "tests*"] + + # Python packages to include during discovery (optional) + # Setting this will ignore all other paths + include = ["**/plugins*"] + +Build-hooks Mode (Default) +----------------------- + +In build-hooks mode, plux automatically hooks into the setuptools build process to discover plugins and generate entry points. + +Setting Up Your Project +~~~~~~~~~~~~~~~~~~~~~ + +1. Add plux as a build dependency in your ``pyproject.toml``: + +.. code-block:: toml + + [build-system] + requires = ["setuptools>=42", "wheel", "plux>=1.3.1"] + build-backend = "setuptools.build_meta" + +2. Build your project as usual: + +.. code-block:: bash + + # Using pip + pip install -e . + + # Or using setuptools directly + python setup.py develop + + # Or building a distribution + python -m build + +How Build-hooks Mode Works +~~~~~~~~~~~~~~~~~~~~~~~ + +When you build your project: + +1. Plux scans your codebase for plugins +2. It creates a ``plux.json`` file in the ``.egg-info`` directory +3. It extends the entry points in ``entry_points.txt`` +4. These entry points are then included in your distribution + +Manual Mode +--------- + +Manual mode gives you more control over when and how entry points are generated. This is useful for isolated build environments or when build hooks don't fit your build process. + +Setting Up Manual Mode +~~~~~~~~~~~~~~~~~~~ + +1. Enable manual mode in your ``pyproject.toml``: + +.. code-block:: toml + + [tool.plux] + entrypoint_build_mode = "manual" + +2. Configure your project to use the generated entry points: + +.. code-block:: toml + + [project] + dynamic = ["entry-points"] + + [tool.setuptools.package-data] + "*" = ["plux.ini"] + + [tool.setuptools.dynamic] + entry-points = {file = ["plux.ini"]} + +3. Generate entry points manually: + +.. code-block:: bash + + python -m plux entrypoints + +This creates a ``plux.ini`` file in your working directory with the discovered plugins. + +Customizing Plugin Discovery +------------------------- + +You can customize which parts of your codebase are scanned for plugins: + +Excluding Packages +~~~~~~~~~~~~~~~ + +To exclude specific packages from plugin discovery: + +.. code-block:: bash + + # Exclude database migration scripts + python -m plux entrypoints --exclude "**/database/alembic*" + + # Exclude multiple patterns (comma-separated) + python -m plux discover --exclude "tests*,docs*" --format ini + +You can also specify these in your ``pyproject.toml``: + +.. code-block:: toml + + [tool.plux] + exclude = ["**/database/alembic*", "tests*"] + +Including Specific Packages +~~~~~~~~~~~~~~~~~~~~~~~~ + +To only include specific packages: + +.. code-block:: bash + + # Include only specific patterns + python -m plux discover --include "myapp/plugins*,myapp/extensions*" --format ini + +Or in your ``pyproject.toml``: + +.. code-block:: toml + + [tool.plux] + include = ["myapp/plugins*", "myapp/extensions*"] + +When ``include`` is specified, plux ignores all other paths. + +Using the Plux CLI +--------------- + +The plux CLI provides several commands for working with plugins and entry points: + +Generating Entry Points +~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + # Generate entry points based on your pyproject.toml configuration + python -m plux entrypoints + + # With custom include/exclude patterns + python -m plux entrypoints --include "myapp/plugins*" --exclude "tests*" + +Discovering Plugins +~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + # Discover plugins and output in JSON format + python -m plux discover --format json + + # Discover plugins and save to a file + python -m plux discover --format ini --output plux.ini + + # Discover plugins in a specific path + python -m plux discover --path src/myapp + +Showing Generated Entry Points +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. code-block:: bash + + # Show the generated entry points + python -m plux show + +Resolving Plugins +~~~~~~~~~~~~~~ + +.. code-block:: bash + + # Resolve plugins in a specific namespace + python -m plux resolve --namespace my.plugins.services + +Integration with setuptools +------------------------ + +Plux integrates with setuptools in several ways: + +Custom setuptools Command +~~~~~~~~~~~~~~~~~~~~~ + +Plux provides a custom setuptools command ``plugins`` that you can use in your ``setup.py``: + +.. code-block:: bash + + python setup.py plugins + +This command discovers plugins and writes them to a ``plux.json`` file in the ``.egg-info`` directory. + +Setuptools Build Process Hooks +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Plux hooks into the setuptools build process to: + +1. Generate entry points during the ``egg_info`` command +2. Handle editable installs with ``pip install -e .`` +3. Support building wheels from source distributions + +Direct Integration in setup.py +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For older projects using ``setup.py``, you can directly integrate plux: + +.. code-block:: python + + from setuptools import setup + from plux.build.setuptools import find_plugins + + setup( + name="my-project", + # ... + entry_points=find_plugins() + ) + +Troubleshooting +------------ + +Common issues and solutions: + +Plugins Not Being Discovered +~~~~~~~~~~~~~~~~~~~~~~~~~ + +If your plugins aren't being discovered: + +1. Check that your plugins correctly define ``namespace`` and ``name`` attributes +2. Verify that your include/exclude patterns aren't filtering out your plugins +3. Try running with verbose logging: ``python -m plux entrypoints -v`` + +Entry Points Not Being Generated +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If entry points aren't being generated: + +1. Check your ``pyproject.toml`` configuration +2. Verify that plux is included in your build dependencies +3. Try manual mode to see if it resolves the issue + +Editable Install Issues +~~~~~~~~~~~~~~~~~~~ + +For issues with editable installs: + +1. Make sure you're using a recent version of pip and setuptools +2. Try reinstalling with ``pip install -e . --no-build-isolation`` + +Summary +------ + +Plux provides flexible integration with build systems, particularly setuptools. You can choose between automatic build-hooks mode or more controlled manual mode. The plux CLI offers commands for discovering plugins, generating entry points, and troubleshooting your setup. + +For most projects, the default build-hooks mode with minimal configuration in ``pyproject.toml`` will work well. For more complex scenarios, manual mode and the CLI provide additional control. \ No newline at end of file diff --git a/docs/user_guide/cli.rst b/docs/user_guide/cli.rst new file mode 100644 index 0000000..f49f079 --- /dev/null +++ b/docs/user_guide/cli.rst @@ -0,0 +1,240 @@ +Command Line Interface (CLI) +========================= + +Plux provides a command-line interface (CLI) that makes it easy to work with plugins from the terminal. This guide explains how to use the plux CLI for discovering plugins, generating entry points, and more. + +Overview +------- + +The plux CLI is accessible through the ``python -m plux`` command. It provides several subcommands: + +* ``entrypoints``: Discover plugins and generate entry points +* ``discover``: Discover plugins and output them in various formats +* ``show``: Show the generated entry points +* ``resolve``: Resolve plugins in a specific namespace + +Common Options +----------- + +All plux commands support these common options: + +.. code-block:: bash + + # Specify a working directory (defaults to current directory) + python -m plux --workdir /path/to/project + + # Enable verbose logging + python -m plux -v + python -m plux --verbose + +Generating Entry Points +-------------------- + +The ``entrypoints`` command discovers plugins in your project and generates entry points: + +.. code-block:: bash + + # Generate entry points based on your pyproject.toml configuration + python -m plux entrypoints + + # Exclude specific packages + python -m plux entrypoints --exclude "tests*,docs*" + + # Include only specific packages + python -m plux entrypoints --include "myapp/plugins*" + +This command respects your ``pyproject.toml`` configuration, including the build mode: + +* In **build-hooks mode** (default), it runs the setuptools build process to generate entry points +* In **manual mode**, it creates a ``plux.ini`` file in your working directory + +Discovering Plugins +---------------- + +The ``discover`` command finds plugins and outputs them without modifying your project: + +.. code-block:: bash + + # Discover plugins and output in JSON format (default) + python -m plux discover + + # Output in INI format + python -m plux discover --format ini + + # Save to a file instead of stdout + python -m plux discover --format json --output plugins.json + + # Discover plugins in a specific path + python -m plux discover --path src/myapp + + # Filter packages + python -m plux discover --exclude "tests*" --include "myapp/plugins*" + +This is useful for: + +* Debugging plugin discovery issues +* Generating custom entry point files +* Checking which plugins would be discovered without modifying your project + +Showing Entry Points +----------------- + +The ``show`` command displays the generated entry points: + +.. code-block:: bash + + # Show the generated entry points + python -m plux show + +This command looks for the entry points file in your project's metadata directory (e.g., ``.egg-info/entry_points.txt``) and displays its contents. + +Resolving Plugins +-------------- + +The ``resolve`` command finds plugins in a specific namespace at runtime: + +.. code-block:: bash + + # Resolve plugins in a namespace + python -m plux resolve --namespace my.plugins.services + +This command: + +1. Creates a ``PluginManager`` for the specified namespace +2. Lists all plugin specifications found in that namespace +3. Displays the module and name of each plugin's factory + +This is useful for debugging runtime plugin discovery issues. + +Examples +------- + +Here are some common use cases for the plux CLI: + +Initial Project Setup +~~~~~~~~~~~~~~~~~~ + +When setting up a new project with plux: + +.. code-block:: bash + + # Add plux to your project + pip install plux + + # Generate entry points + python -m plux entrypoints + + # Check that plugins were discovered + python -m plux show + +Debugging Plugin Discovery +~~~~~~~~~~~~~~~~~~~~~~~ + +If you're having trouble with plugin discovery: + +.. code-block:: bash + + # Enable verbose logging + python -m plux -v entrypoints + + # Check which plugins would be discovered + python -m plux discover + + # Check if plugins are resolved at runtime + python -m plux resolve --namespace my.plugins + +Customizing Entry Point Generation +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For more control over entry point generation: + +.. code-block:: bash + + # Discover plugins with custom filters + python -m plux discover --include "myapp/plugins*" --exclude "myapp/plugins/experimental*" --format ini --output custom_plugins.ini + + # Use the custom file in your pyproject.toml + # [tool.setuptools.dynamic] + # entry-points = {file = ["custom_plugins.ini"]} + +Continuous Integration +~~~~~~~~~~~~~~~~~~~ + +In CI environments: + +.. code-block:: bash + + # Generate entry points in manual mode + python -m plux entrypoints + + # Build your project + python -m build + +CLI Reference +----------- + +Here's a complete reference of all CLI commands and options: + +entrypoints +~~~~~~~~~~ + +.. code-block:: bash + + python -m plux entrypoints [options] + + Options: + -e, --exclude EXCLUDE Comma-separated list of paths to exclude + -i, --include INCLUDE Comma-separated list of paths to include + +discover +~~~~~~~ + +.. code-block:: bash + + python -m plux discover [options] + + Options: + -p, --path PATH The file path where to look for plugins + -e, --exclude EXCLUDE Comma-separated list of paths to exclude + -i, --include INCLUDE Comma-separated list of paths to include + -f, --format FORMAT Output format: 'json' or 'ini' (default: 'json') + -o, --output OUTPUT Output file path (default: stdout) + +show +~~~~ + +.. code-block:: bash + + python -m plux show + +resolve +~~~~~~ + +.. code-block:: bash + + python -m plux resolve [options] + + Options: + --namespace NAMESPACE The plugin namespace (required) + +Best Practices +----------- + +Here are some best practices for using the plux CLI: + +1. **Use version control**: Run ``python -m plux entrypoints`` before committing changes to ensure entry points are up to date. + +2. **Automate in CI/CD**: Include entry point generation in your CI/CD pipeline to catch plugin discovery issues early. + +3. **Be specific with include/exclude**: Use specific patterns to avoid scanning unnecessary code. + +4. **Check your configuration**: Use ``python -m plux discover`` to verify that your include/exclude patterns work as expected. + +5. **Use verbose mode for debugging**: The ``-v`` flag provides detailed information about what plux is doing. + +Summary +------ + +The plux CLI provides powerful tools for working with plugins from the command line. Whether you're setting up a new project, debugging plugin discovery issues, or customizing entry point generation, the CLI offers the flexibility you need. + +For most projects, the ``entrypoints`` command with your ``pyproject.toml`` configuration will be sufficient. For more complex scenarios, the ``discover`` and ``resolve`` commands provide additional control and debugging capabilities. \ No newline at end of file diff --git a/docs/user_guide/defining_loading_plugins.rst b/docs/user_guide/defining_loading_plugins.rst new file mode 100644 index 0000000..47d7116 --- /dev/null +++ b/docs/user_guide/defining_loading_plugins.rst @@ -0,0 +1,218 @@ +Defining and Loading Plugins +============================ + +This guide covers the different ways to define plugins in plux and how to load them at runtime. + +Plugin Basics +------------- + +In plux, a plugin is an object that implements the ``Plugin`` interface, which requires two methods: + +* ``should_load()``: Determines whether the plugin should be loaded +* ``load(*args, **kwargs)``: Performs the actual loading of the plugin + +The Plugin Class +---------------- + +The most basic way to define a plugin is by subclassing the ``Plugin`` class: + +.. code-block:: python + + from plux import Plugin + + class MyPlugin(Plugin): + namespace = "my.plugins" + name = "my_plugin" + + def should_load(self): + # Optional: Override to add custom logic + # By default, returns True + return True + + def load(self, *args, **kwargs): + # Implement your plugin's loading logic + # Can return any value, which will be passed back to the caller + return "Plugin loaded successfully" + +Plugin Namespaces and Names +------------------------- + +Every plugin must have: + +* A ``namespace``: A dot-separated string that groups related plugins (e.g., "my.plugins.services") +* A ``name``: A unique identifier within that namespace (e.g., "s3") + +These attributes are used by the ``PluginManager`` to locate and load plugins. + +Different Ways to Define Plugins +------------------------------ + +Plux offers several approaches to define plugins: + +1. One Class Per Plugin +~~~~~~~~~~~~~~~~~~~~~ + +This is the most straightforward approach, where each plugin is a separate class: + +.. code-block:: python + + class S3Plugin(Plugin): + namespace = "my.plugins.services" + name = "s3" + + def load(self, config): + # Initialize S3 service + pass + + class DynamoDBPlugin(Plugin): + namespace = "my.plugins.services" + name = "dynamodb" + + def load(self, config): + # Initialize DynamoDB service + pass + +2. Re-usable Plugins +~~~~~~~~~~~~~~~~~~ + +When you have many similar plugins, you can use a single plugin class with different instances: + +.. code-block:: python + + from plux import Plugin, PluginFactory, PluginSpec + import importlib + + class ServicePlugin(Plugin): + def __init__(self, service_name): + self.service_name = service_name + self.service = None + + def should_load(self): + # Check if this service is enabled in config + return self.service_name in config.SERVICES + + def load(self): + # Import and initialize the service + module = importlib.import_module(f"my.services.{self.service_name}") + self.service = module.Service() + return self.service + + def service_plugin_factory(name) -> PluginFactory: + def create(): + return ServicePlugin(name) + return create + + # Define plugin specs that will be discovered at build time + s3 = PluginSpec("my.plugins.services", "s3", service_plugin_factory("s3")) + dynamodb = PluginSpec("my.plugins.services", "dynamodb", service_plugin_factory("dynamodb")) + +3. Function Plugins +~~~~~~~~~~~~~~~~ + +For simple plugins, you can use the ``@plugin`` decorator to turn functions into plugins: + +.. code-block:: python + + from plux import plugin + + @plugin(namespace="my.plugins.initializers") + def setup_logging(config): + # Configure logging based on config + pass + + @plugin(namespace="my.plugins.initializers", + should_load=lambda: config.DEBUG_MODE) + def setup_debug_tools(config): + # Initialize debug tools + pass + +The ``@plugin`` decorator wraps the function in a ``FunctionPlugin`` that implements the ``Plugin`` interface. + +Loading Plugins +------------- + +Once plugins are defined, you can load them using the ``PluginManager``: + +.. code-block:: python + + from plux import PluginManager + + # Create a manager for a specific namespace + manager = PluginManager("my.plugins.services") + + # Load a specific plugin by name + s3_service = manager.load("s3") + + # Load all plugins in the namespace + all_services = manager.load_all() + + # Check if a plugin exists + if manager.exists("dynamodb"): + # Load it only if it exists + dynamodb = manager.load("dynamodb") + + # Check if a plugin is already loaded + if manager.is_loaded("s3"): + # Get the loaded plugin + s3 = manager.get_container("s3").plugin + +Passing Arguments to Plugins +-------------------------- + +You can pass arguments to plugins when loading them: + +.. code-block:: python + + # Pass arguments to all plugins loaded by this manager + manager = PluginManager("my.plugins.services", + load_args=(config,)) + + # Pass arguments to a specific plugin + s3 = manager.load("s3", additional_arg="value") + + # For function plugins, you can call them with arguments + @plugin(namespace="my.plugins.handlers") + def process_event(event, context): + # Process the event + return result + + # Load and call the function plugin + handler = manager.load("process_event") + result = handler(event, context) + +Plugin Load Return Values +----------------------- + +The ``load`` method of a plugin can return any value, which is passed back to the caller: + +.. code-block:: python + + class ConfigPlugin(Plugin): + namespace = "my.plugins.config" + name = "database" + + def load(self): + # Return a configuration dictionary + return { + "host": "localhost", + "port": 5432, + "username": "user", + "password": "password" + } + + # The return value is passed back to the caller + db_config = manager.load("database") + + # Use the configuration + connection = connect_to_db(**db_config) + +Summary +------ + +Plux provides flexible ways to define plugins: + +* Subclass ``Plugin`` for full control +* Use ``PluginSpec`` with factories for reusable plugins +* Use the ``@plugin`` decorator for function-based plugins + +The ``PluginManager`` makes it easy to load plugins at runtime, with support for passing arguments and handling return values. \ No newline at end of file diff --git a/docs/user_guide/filters.rst b/docs/user_guide/filters.rst new file mode 100644 index 0000000..d2e6bed --- /dev/null +++ b/docs/user_guide/filters.rst @@ -0,0 +1,170 @@ +Filters +====== + +Filters in plux allow you to control which plugins are loaded at runtime. This guide explains how to use filters to include or exclude plugins based on various criteria. + +Understanding Plugin Filters +-------------------------- + +A plugin filter is a callable that takes a ``PluginSpec`` and returns a boolean value: + +* ``True`` means the plugin should be filtered out (disabled) +* ``False`` means the plugin should be included (enabled) + +Plux provides the ``MatchingPluginFilter`` class, which makes it easy to create filters based on plugin attributes. + +Using MatchingPluginFilter +------------------------ + +The ``MatchingPluginFilter`` class allows you to exclude plugins based on their namespace, name, or entry point value: + +.. code-block:: python + + from plux.runtime.filter import MatchingPluginFilter + + # Create a filter + my_filter = MatchingPluginFilter() + + # Add exclusion rules + my_filter.add_exclusion(namespace="my.plugins.experimental") # Exclude a specific namespace + my_filter.add_exclusion(name="legacy_plugin") # Exclude a specific plugin name + my_filter.add_exclusion(value="my.package.deprecated.*") # Exclude plugins from a package + +Wildcard Patterns +-------------- + +Filters support wildcard patterns using ``fnmatch``: + +.. code-block:: python + + # Exclude all plugins in experimental namespaces + my_filter.add_exclusion(namespace="*.experimental.*") + + # Exclude all plugins with names starting with "test_" + my_filter.add_exclusion(name="test_*") + + # Exclude all plugins from test modules + my_filter.add_exclusion(value="*.tests.*") + +Combining Criteria +--------------- + +You can combine multiple criteria in a single exclusion rule to create an AND condition: + +.. code-block:: python + + # Exclude plugins that match ALL of these criteria + my_filter.add_exclusion( + namespace="my.plugins.services", + name="legacy_*" + ) + +This will only exclude plugins that are both in the "my.plugins.services" namespace AND have a name starting with "legacy_". + +Applying Filters to PluginManager +------------------------------ + +To use filters with a ``PluginManager``, pass them when creating the manager: + +.. code-block:: python + + from plux import PluginManager + from plux.runtime.filter import MatchingPluginFilter + + # Create a filter + my_filter = MatchingPluginFilter() + my_filter.add_exclusion(namespace="my.plugins.experimental.*") + + # Create a manager with the filter + manager = PluginManager( + namespace="my.plugins.services", + filters=[my_filter] + ) + + # Now when you call load_all(), experimental plugins will be excluded + plugins = manager.load_all() + +You can pass multiple filters to a PluginManager: + +.. code-block:: python + + # Create filters for different purposes + dev_filter = MatchingPluginFilter() + dev_filter.add_exclusion(namespace="*.production.*") + + performance_filter = MatchingPluginFilter() + performance_filter.add_exclusion(name="*_slow") + + # Apply both filters + manager = PluginManager( + namespace="my.plugins", + filters=[dev_filter, performance_filter] + ) + +Global Plugin Filter +----------------- + +Plux provides a global plugin filter that affects all PluginManagers: + +.. code-block:: python + + from plux.runtime.filter import global_plugin_filter + + # Configure the global filter + global_plugin_filter.add_exclusion(namespace="*.deprecated.*") + global_plugin_filter.add_exclusion(name="legacy_*") + + # All PluginManagers will now apply these filters + manager1 = PluginManager("namespace1") + manager2 = PluginManager("namespace2") + + # Both managers will exclude deprecated plugins + +Creating Custom Filters +-------------------- + +You can create custom filters by implementing the ``PluginFilter`` protocol: + +.. code-block:: python + + from plux.runtime.filter import PluginFilter + from plux.core.plugin import PluginSpec + + class EnvironmentFilter: + def __init__(self, env): + self.env = env + + def __call__(self, spec: PluginSpec) -> bool: + # Filter out plugins not meant for this environment + if hasattr(spec.factory, "environments"): + return self.env not in spec.factory.environments + return False + + # Use the custom filter + prod_filter = EnvironmentFilter("production") + manager = PluginManager("my.plugins", filters=[prod_filter]) + +Filter Execution Order +------------------- + +When multiple filters are applied, they are executed in order. If any filter returns ``True`` (meaning the plugin should be disabled), the plugin is excluded without checking the remaining filters. + +Best Practices +----------- + +Here are some best practices for using filters: + +1. **Use specific filters**: Target specific plugins or namespaces rather than using broad patterns. + +2. **Document your filters**: Make it clear which plugins are being excluded and why. + +3. **Use environment-specific filters**: Create different filters for development, testing, and production environments. + +4. **Be cautious with global filters**: The global filter affects all PluginManagers, which might have unintended consequences. + +5. **Test your filters**: Verify that the right plugins are being included or excluded. + +Summary +------ + +Filters provide a powerful way to control which plugins are loaded at runtime. The ``MatchingPluginFilter`` class makes it easy to exclude plugins based on their namespace, name, or entry point value. You can apply filters to specific PluginManagers or use the global filter to affect all managers. \ No newline at end of file diff --git a/docs/user_guide/lifecycle_listener.rst b/docs/user_guide/lifecycle_listener.rst new file mode 100644 index 0000000..658b846 --- /dev/null +++ b/docs/user_guide/lifecycle_listener.rst @@ -0,0 +1,300 @@ +PluginLifecycleListener +===================== + +The ``PluginLifecycleListener`` interface allows you to monitor and react to plugin lifecycle events. This guide explains how to create and use lifecycle listeners to track plugin loading, handle errors, and customize the plugin lifecycle. + +Understanding the Plugin Lifecycle +------------------------------- + +A plugin managed by a ``PluginManager`` goes through several lifecycle phases: + +1. **Resolution**: The entry point pointing to the ``PluginSpec`` is imported and the ``PluginSpec`` instance is created. +2. **Initialization**: The ``PluginFactory`` of the ``PluginSpec`` is invoked to create a ``Plugin`` instance. +3. **Loading**: The ``load`` method of the ``Plugin`` is invoked. + +At each phase, events are triggered that can be captured by a ``PluginLifecycleListener``. + +Creating a PluginLifecycleListener +------------------------------- + +To create a lifecycle listener, implement the ``PluginLifecycleListener`` interface: + +.. code-block:: python + + from plux import PluginLifecycleListener, PluginSpec, Plugin + + class MyLifecycleListener(PluginLifecycleListener): + # Override the methods you're interested in + pass + +The interface provides several methods that you can override to handle different lifecycle events. + +Resolution Phase Events +-------------------- + +Events related to the resolution phase: + +.. code-block:: python + + def on_resolve_after(self, plugin_spec: PluginSpec): + """ + Called after a PluginSpec is successfully resolved from an entry point. + + :param plugin_spec: The resolved PluginSpec + """ + print(f"Resolved plugin: {plugin_spec.namespace}:{plugin_spec.name}") + + def on_resolve_exception(self, namespace: str, entrypoint, exception: Exception): + """ + Called when an exception occurs during plugin resolution. + + :param namespace: The namespace of the plugin being resolved + :param entrypoint: The entry point that was being resolved + :param exception: The exception that occurred + """ + print(f"Error resolving plugin in namespace {namespace}: {exception}") + +Initialization Phase Events +------------------------ + +Events related to the initialization phase: + +.. code-block:: python + + def on_init_after(self, plugin_spec: PluginSpec, plugin: Plugin): + """ + Called after a Plugin is successfully initialized. + + :param plugin_spec: The PluginSpec used to create the plugin + :param plugin: The newly created Plugin instance + """ + print(f"Initialized plugin: {plugin_spec.name}") + + def on_init_exception(self, plugin_spec: PluginSpec, exception: Exception): + """ + Called when an exception occurs during plugin initialization. + + :param plugin_spec: The PluginSpec that was being used + :param exception: The exception that occurred + """ + print(f"Error initializing plugin {plugin_spec.name}: {exception}") + +Loading Phase Events +----------------- + +Events related to the loading phase: + +.. code-block:: python + + def on_load_before(self, plugin_spec: PluginSpec, plugin: Plugin, load_args: list | tuple, load_kwargs: dict): + """ + Called just before a plugin's load method is invoked. + + :param plugin_spec: The PluginSpec of the plugin + :param plugin: The Plugin instance + :param load_args: Positional arguments that will be passed to load() + :param load_kwargs: Keyword arguments that will be passed to load() + """ + print(f"About to load plugin: {plugin_spec.name} with args: {load_args}, kwargs: {load_kwargs}") + + def on_load_after(self, plugin_spec: PluginSpec, plugin: Plugin, load_result: any = None): + """ + Called after a plugin's load method is successfully invoked. + + :param plugin_spec: The PluginSpec of the plugin + :param plugin: The Plugin instance + :param load_result: The value returned by the plugin's load method + """ + print(f"Loaded plugin: {plugin_spec.name}, result: {load_result}") + + def on_load_exception(self, plugin_spec: PluginSpec, plugin: Plugin, exception: Exception): + """ + Called when an exception occurs during plugin loading. + + :param plugin_spec: The PluginSpec of the plugin + :param plugin: The Plugin instance + :param exception: The exception that occurred + """ + print(f"Error loading plugin {plugin_spec.name}: {exception}") + +Using a PluginLifecycleListener +---------------------------- + +To use a lifecycle listener, pass it to the ``PluginManager`` constructor: + +.. code-block:: python + + from plux import PluginManager + + # Create a listener + listener = MyLifecycleListener() + + # Create a manager with the listener + manager = PluginManager( + namespace="my.plugins", + listener=listener + ) + +You can also add a listener to an existing manager: + +.. code-block:: python + + manager = PluginManager("my.plugins") + manager.add_listener(MyLifecycleListener()) + +Using Multiple Listeners +--------------------- + +You can use multiple listeners with a single ``PluginManager``: + +.. code-block:: python + + # Create listeners for different purposes + logging_listener = LoggingListener() + metrics_listener = MetricsListener() + + # Add them to the manager + manager = PluginManager( + namespace="my.plugins", + listener=[logging_listener, metrics_listener] + ) + + # Or add them individually + manager.add_listener(ErrorHandlingListener()) + +Practical Examples +--------------- + +Here are some practical examples of how to use lifecycle listeners: + +Logging Listener +~~~~~~~~~~~~~~ + +A listener that logs all plugin lifecycle events: + +.. code-block:: python + + import logging + from plux import PluginLifecycleListener + + class LoggingListener(PluginLifecycleListener): + def __init__(self, logger=None): + self.logger = logger or logging.getLogger("plux.plugins") + + def on_resolve_after(self, plugin_spec): + self.logger.debug(f"Resolved plugin: {plugin_spec.namespace}:{plugin_spec.name}") + + def on_init_after(self, plugin_spec, plugin): + self.logger.debug(f"Initialized plugin: {plugin_spec.name}") + + def on_load_before(self, plugin_spec, plugin, load_args, load_kwargs): + self.logger.debug(f"Loading plugin: {plugin_spec.name}") + + def on_load_after(self, plugin_spec, plugin, load_result): + self.logger.info(f"Loaded plugin: {plugin_spec.name}") + + def on_load_exception(self, plugin_spec, plugin, exception): + self.logger.error(f"Error loading plugin {plugin_spec.name}: {exception}", exc_info=True) + +Metrics Listener +~~~~~~~~~~~~~ + +A listener that collects metrics about plugin loading: + +.. code-block:: python + + import time + from plux import PluginLifecycleListener + + class MetricsListener(PluginLifecycleListener): + def __init__(self): + self.load_times = {} + self.error_count = 0 + + def on_load_before(self, plugin_spec, plugin, load_args, load_kwargs): + self.load_times[plugin_spec.name] = {"start": time.time()} + + def on_load_after(self, plugin_spec, plugin, load_result): + if plugin_spec.name in self.load_times: + start = self.load_times[plugin_spec.name]["start"] + self.load_times[plugin_spec.name]["duration"] = time.time() - start + + def on_load_exception(self, plugin_spec, plugin, exception): + self.error_count += 1 + + def get_metrics(self): + return { + "load_times": self.load_times, + "error_count": self.error_count, + "total_plugins": len(self.load_times), + "avg_load_time": sum(data.get("duration", 0) for data in self.load_times.values()) / len(self.load_times) if self.load_times else 0 + } + +Dependency Injection Listener +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +A listener that injects dependencies into plugins: + +.. code-block:: python + + from plux import PluginLifecycleListener + + class DependencyInjectionListener(PluginLifecycleListener): + def __init__(self, dependencies): + self.dependencies = dependencies + + def on_init_after(self, plugin_spec, plugin): + # Inject dependencies into the plugin + if hasattr(plugin, "set_dependencies"): + plugin.set_dependencies(self.dependencies) + + # Or set attributes directly + for name, dependency in self.dependencies.items(): + if hasattr(plugin, name) and getattr(plugin, name) is None: + setattr(plugin, name, dependency) + +Advanced Usage: Modifying the Plugin Lifecycle +------------------------------------------- + +Lifecycle listeners can also modify the plugin lifecycle by raising exceptions: + +.. code-block:: python + + from plux import PluginLifecycleListener, PluginDisabled + + class FeatureFlagListener(PluginLifecycleListener): + def __init__(self, feature_flags): + self.feature_flags = feature_flags + + def on_resolve_after(self, plugin_spec): + # Check if this plugin is enabled by feature flags + feature_name = f"plugin.{plugin_spec.namespace}.{plugin_spec.name}" + if feature_name in self.feature_flags and not self.feature_flags[feature_name]: + # Disable the plugin + raise PluginDisabled( + plugin_spec.namespace, + plugin_spec.name, + "Disabled by feature flag" + ) + +When a ``PluginDisabled`` exception is raised, the ``PluginManager`` will mark the plugin as disabled and skip it during loading. + +Best Practices +----------- + +Here are some best practices for using lifecycle listeners: + +1. **Keep listeners focused**: Each listener should have a specific purpose. + +2. **Handle exceptions gracefully**: Especially in the exception handling methods. + +3. **Avoid heavy processing**: Listeners are called synchronously during plugin loading, so keep them lightweight. + +4. **Use composition**: Combine multiple listeners for different aspects of plugin management. + +5. **Log important events**: Use listeners to create a clear audit trail of plugin activity. + +Summary +------ + +The ``PluginLifecycleListener`` interface provides a powerful way to monitor and customize the plugin lifecycle. By implementing the various lifecycle methods, you can track plugin loading, handle errors, collect metrics, and even modify the plugin loading process. \ No newline at end of file diff --git a/docs/user_guide/plugin_manager.rst b/docs/user_guide/plugin_manager.rst new file mode 100644 index 0000000..99bcfb1 --- /dev/null +++ b/docs/user_guide/plugin_manager.rst @@ -0,0 +1,238 @@ +PluginManager: Managing Plugins +============================= + +The ``PluginManager`` is the central component in plux for managing plugins at runtime. This guide covers how to use the PluginManager API to load, lookup, and manage plugins. + +Creating a PluginManager +---------------------- + +To create a PluginManager, you need to specify the namespace of plugins you want to manage: + +.. code-block:: python + + from plux import PluginManager + + # Create a manager for a specific namespace + manager = PluginManager("my.plugins.services") + +You can also provide default arguments that will be passed to all plugins when they are loaded: + +.. code-block:: python + + # Create a manager with default load arguments + config = load_configuration() + manager = PluginManager( + namespace="my.plugins.services", + load_args=(config,), # Positional arguments + load_kwargs={"debug": True} # Keyword arguments + ) + +Loading Plugins +------------- + +The PluginManager provides several methods for loading plugins: + +Loading a Single Plugin +~~~~~~~~~~~~~~~~~~~~~ + +To load a specific plugin by name: + +.. code-block:: python + + # Load a plugin by name + s3_service = manager.load("s3") + + # Load a plugin with additional arguments + dynamodb = manager.load("dynamodb", additional_arg="value") + +The ``load`` method: + +1. Resolves the plugin specification +2. Initializes the plugin instance +3. Calls the plugin's ``should_load()`` method to check if it should be loaded +4. If ``should_load()`` returns True, calls the plugin's ``load()`` method +5. Returns the result of the ``load()`` method + +Loading All Plugins +~~~~~~~~~~~~~~~~~ + +To load all plugins in a namespace: + +.. code-block:: python + + # Load all plugins in the namespace + all_services = manager.load_all() + + # By default, exceptions in one plugin won't stop others from loading + # Set propagate_exceptions=True to change this behavior + all_services = manager.load_all(propagate_exceptions=True) + +The ``load_all`` method returns a list of successfully loaded plugins. + +Checking Plugin Status +------------------- + +The PluginManager provides methods to check the status of plugins: + +.. code-block:: python + + # Check if a plugin exists (is discoverable) + if manager.exists("s3"): + # Plugin exists + pass + + # Check if a plugin is already loaded + if manager.is_loaded("s3"): + # Plugin is loaded + pass + +Listing Plugins +------------ + +You can list available plugins in several ways: + +.. code-block:: python + + # List all plugin specifications in the namespace + specs = manager.list_plugin_specs() + + # List just the names of available plugins + names = manager.list_names() + + # List plugin containers (which include metadata about the plugins) + containers = manager.list_containers() + +Accessing Plugin Containers +------------------------ + +A ``PluginContainer`` holds metadata about a plugin, including its specification, instance, and loading status: + +.. code-block:: python + + # Get a container for a specific plugin + container = manager.get_container("s3") + + # Access container properties + spec = container.plugin_spec # The plugin specification + plugin = container.plugin # The plugin instance (if initialized) + is_loaded = container.loaded # Whether the plugin has been loaded + + # Get distribution information + dist = container.distribution + if dist: + print(f"Plugin from package: {dist.name} {dist.version}") + +Error Handling +----------- + +The PluginManager handles errors that occur during plugin loading: + +.. code-block:: python + + try: + plugin = manager.load("non_existent") + except KeyError: + # Plugin not found + pass + + try: + plugin = manager.load("problematic") + except Exception as e: + # Error during plugin loading + print(f"Failed to load plugin: {e}") + +For ``load_all()``, by default, exceptions are caught and logged, but loading continues for other plugins. Set ``propagate_exceptions=True`` to raise exceptions immediately. + +Working with Plugin Lifecycle Listeners +------------------------------------ + +You can add lifecycle listeners to a PluginManager to monitor and react to plugin lifecycle events: + +.. code-block:: python + + from plux import PluginLifecycleListener, PluginManager + + class MyListener(PluginLifecycleListener): + def on_load_before(self, plugin_spec, plugin, load_args, load_kwargs): + print(f"About to load plugin: {plugin_spec.name}") + + def on_load_after(self, plugin_spec, plugin, load_result): + print(f"Plugin loaded: {plugin_spec.name}, result: {load_result}") + + def on_load_exception(self, plugin_spec, plugin, exception): + print(f"Error loading plugin {plugin_spec.name}: {exception}") + + # Create a manager with a listener + manager = PluginManager( + namespace="my.plugins.services", + listener=MyListener() + ) + + # Or add a listener to an existing manager + manager.add_listener(MyListener()) + +You can add multiple listeners to a single manager. + +Using Filters with PluginManager +----------------------------- + +You can apply filters to control which plugins are loaded: + +.. code-block:: python + + from plux import PluginManager + from plux.runtime.filter import MatchingPluginFilter + + # Create a filter + my_filter = MatchingPluginFilter() + + # Exclude specific plugins + my_filter.add_exclusion(name="legacy_plugin") + my_filter.add_exclusion(namespace="my.plugins.experimental.*") + + # Create a manager with the filter + manager = PluginManager( + namespace="my.plugins.services", + filters=[my_filter] + ) + +Generic Type Support +----------------- + +The PluginManager supports generic typing to help with type checking: + +.. code-block:: python + + from plux import Plugin, PluginManager + + class ServicePlugin(Plugin): + def load(self) -> "Service": + pass + + # Use the generic type parameter to specify the plugin type + manager: PluginManager[ServicePlugin] = PluginManager("my.plugins.services") + + # Now IDE and type checkers know the type of loaded plugins + service = manager.load("s3") # Type is inferred as Service + +Best Practices +----------- + +Here are some best practices for using PluginManager: + +1. **Create managers for specific namespaces**: Keep your plugin namespaces organized by functionality. + +2. **Use lifecycle listeners for monitoring**: Implement listeners to track plugin loading and handle errors. + +3. **Handle errors gracefully**: Always handle potential exceptions when loading plugins. + +4. **Use filters for control**: Apply filters to exclude plugins in certain environments or configurations. + +5. **Leverage generic typing**: Use type hints to improve code quality and IDE support. + +6. **Reuse managers**: Create managers once and reuse them throughout your application. + +Summary +------ + +The PluginManager is a powerful tool for managing plugins at runtime. It provides methods for loading plugins, checking their status, and accessing metadata. With lifecycle listeners and filters, you can customize the plugin loading process to suit your application's needs. \ No newline at end of file diff --git a/docs/user_guide/quickstart.rst b/docs/user_guide/quickstart.rst new file mode 100644 index 0000000..6341971 --- /dev/null +++ b/docs/user_guide/quickstart.rst @@ -0,0 +1,103 @@ +Quickstart +========== + +This guide will help you get started with plux by walking through a simple example of defining and loading a plugin. + +Installation +------------ + +First, install plux using pip: + +.. code-block:: bash + + pip install plux + +Core Concepts +------------- + +Before diving into the code, let's understand the core concepts of plux: + +* **Plugin**: An object that exposes ``should_load`` and ``load`` methods +* **PluginSpec**: Describes a plugin with a namespace, name, and factory +* **PluginManager**: Manages the runtime lifecycle of plugins +* **PluginFinder**: Finds plugins at build time or runtime + +Simple Plugin Example +--------------------- + +Let's create a simple plugin and load it using the PluginManager. + +1. Define a Plugin +~~~~~~~~~~~~~~~~~~ + +First, create a plugin by subclassing the ``Plugin`` class: + +.. code-block:: python + + from plux import Plugin + + class GreetingPlugin(Plugin): + namespace = "my.plugins.greetings" + name = "hello" + + def load(self): + return "Hello, World!" + +2. Load the Plugin +~~~~~~~~~~~~~~~~~~ + +Now, let's use the ``PluginManager`` to load our plugin: + +.. code-block:: python + + from plux import PluginManager + + # Create a plugin manager for our namespace + manager = PluginManager("my.plugins.greetings") + + # Load the plugin by name + plugin = manager.load("hello") + + # The result is the return value of the plugin's load method + print(plugin) # Output: Hello, World! + +Function Plugin Example +----------------------- + +You can also create plugins from functions using the ``@plugin`` decorator: + +.. code-block:: python + + from plux import plugin + + @plugin(namespace="my.plugins.greetings") + def say_goodbye(): + return "Goodbye, World!" + + # Load the function plugin + manager = PluginManager("my.plugins.greetings") + goodbye_plugin = manager.load("say_goodbye") + + # Call the function plugin + print(goodbye_plugin()) # Output: Goodbye, World! + +Building and Discovering Plugins +-------------------------------- + +For plux to discover your plugins at runtime, you need to build entry points. The simplest way is to use the plux CLI: + +.. code-block:: bash + + # Generate entry points for your project + python -m plux entrypoints + +This will scan your project for plugins and generate the necessary entry points. + +Next Steps +---------- + +This quickstart guide covered the basics of defining and loading plugins with plux. For more detailed information, check out: + +* :doc:`defining_loading_plugins` - Learn more about different ways to define plugins +* :doc:`plugin_manager` - Explore the PluginManager API in depth +* :doc:`build_integration` - Understand how to integrate plux with your build system \ No newline at end of file