Metadata-Version: 2.4
Name: mm-pyplugin
Version: 2.3.3
Summary: Python Plugin Project
Project-URL: Homepage, https://gitlab.com/maciej.matuszak/mm-pyplugin
Project-URL: Repository, https://gitlab.com/maciej.matuszak/mm-pyplugin.git
Project-URL: Issues, https://gitlab.com/maciej.matuszak/mm-pyplugin/-/issues
Project-URL: Changelog, https://gitlab.com/maciej.matuszak/mm-pyplugin/-/releases
Requires-Python: >=3.12
Requires-Dist: pydantic>=2.0.0
Description-Content-Type: text/markdown

# mm-pyplugin

Plugin infrastructure for Python applications with Pydantic configuration validation, lifecycle management, and dependency injection.

## Features

- **Structured plugin lifecycle**: `__init__` → `configure()` → `setup()` → `teardown()`
- **Two base styles**: `ComposablePluginBase` (configure-then-setup) and `SimplePluginBase` (config at construction)
- **Typed config, zero boilerplate**: config schema is inferred from the generic parameter — no `config_schema()` to write
- **Dependency injection**: a `context` container shares services (loggers, databases, etc.) across plugins
- **Fluent API**: method chaining for clean, readable setup
- **Declarative composition**: nest sub-plugins by declaring typed fields — resolved recursively
- **Singletons**: opt-in per class via `is_singleton`
- **Automatic discovery**: entry-point-based plugin registration, with test overrides

## Installation

```bash
pip install mm-pyplugin
```

For local development:
```bash
cd mm-pyplugin
uv sync
```

## Core Concepts

### Generics

`PluginBase` is generic over two type parameters:

```python
PluginBase[TContext, TConfig]
```

- **`TContext`** — the type of the dependency-injection container (often `dict`).
- **`TConfig`** — the plugin's configuration model; **must subclass `PluginConfig`**.

The config schema is derived automatically from `TConfig` by walking the class's
generic bases, so you never implement `config_schema()` by hand (override it only
for runtime schema selection).

### Configuration models

Config schemas subclass **`PluginConfig`** (a Pydantic `BaseModel` that allows extra
fields and carries an optional `plugin_class` discriminator):

```python
from mm_pyplugin import PluginConfig

class MyConfig(PluginConfig):
    host: str = "localhost"
    port: int = 8080
    ssl_enabled: bool = False
```

Config can be supplied as a dict, a JSON string, a `Path` to a JSON file, or a
`PluginConfig` instance.

## Quick Start

### Composable plugin (configure → setup)

`ComposablePluginBase` splits construction from configuration. Accessing `config`
before `configure()` raises.

```python
import logging
from mm_pyplugin import ComposablePluginBase, PluginConfig

class MyConfig(PluginConfig):
    timeout: int = 30
    retries: int = 3

class MyPlugin(ComposablePluginBase[dict, MyConfig]):
    def setup(self) -> "MyPlugin":
        super().setup()                       # call FIRST — guards double-setup
        logger = self.context.get("logger") if self.context else None
        if logger:
            logger.info(f"setup with timeout={self.config.timeout}")
        return self

    def teardown(self) -> None:
        logger = self.context.get("logger") if self.context else None
        if logger:
            logger.info("cleaning up")
        super().teardown()                    # call LAST

# DI container of shared services
context = {"logger": logging.getLogger("app")}

plugin = (MyPlugin(context)
    .configure({"timeout": 60, "retries": 5})
    .setup())

assert plugin.was_setup
plugin.context["logger"].info("ready")
plugin.teardown()
```

### Simple plugin (config at construction)

`SimplePluginBase` takes config in the constructor, so `config` is available
immediately — there is no `configure()` step.

```python
from mm_pyplugin import SimplePluginBase

class MyPlugin(SimplePluginBase[dict, MyConfig]):
    def setup(self) -> "MyPlugin":
        super().setup()
        return self

    def teardown(self) -> None:
        super().teardown()

plugin = MyPlugin({"timeout": 60}, context).setup()
```

## Dependency Injection

`context` is a plain container (typically a `dict`) of shared services, passed at
construction and reachable via the read-only `self.context` property throughout the
lifecycle:

```python
context = {
    "logger": logging.getLogger("app"),
    "database": Database("localhost:5432"),
    "cache": RedisCache("localhost:6379"),
}

# All plugins share the same services
p1 = LogParser(context).configure(cfg1).setup()
p2 = OutputWriter(context).configure(cfg2).setup()

p1.context["database"].query("SELECT 1")
```

**Benefits:** one shared connection across plugins; trivial testing by injecting
mocks; per-environment wiring (dev/prod) without touching plugin code.

## Composition (sub-plugin injection)

Composition is **declarative**, not manual: a parent declares a sub-plugin as a
typed attribute, and its config declares the matching sub-config field.

```python
from mm_pyplugin import ComposablePluginBase, PluginConfig

class ChildConfig(PluginConfig):
    greeting: str = "Hello"

class Child(ComposablePluginBase[dict, ChildConfig]):
    def setup(self): return super().setup()
    def teardown(self): super().teardown()

class ParentConfig(PluginConfig):
    child: ChildConfig = ChildConfig()        # sub-config field

class Parent(ComposablePluginBase[dict, ParentConfig]):
    child: Child                              # matching typed attribute

    def setup(self): return super().setup()
    def teardown(self): super().teardown()
```

Create the parent with `PluginFactory.create_plugin(..., recursive=True)` (the
parent must be discoverable — see [Plugin Discovery](#plugin-discovery)). Each
config field whose value is a `PluginConfig` and whose parent attribute is
annotated as a `PluginBase` subclass is instantiated depth-first, set up, and
assigned to the parent:

```python
from mm_pyplugin import PluginFactory

cfg = ParentConfig(plugin_class="mypackage.plugins.Parent", child={"greeting": "Hi"})
parent = PluginFactory(context).create_plugin(cfg, recursive=True)

assert isinstance(parent.child, Child)
```

Pass `setup=False` to `create_plugin` to get configured-but-not-set-up plugins.

## Plugin Discovery

Register plugins under the `mm_pyplugin` entry-point group in `pyproject.toml`:

```toml
[project.entry-points.mm_pyplugin]
my_plugin = "mypackage.plugins:MyPlugin"
```

Then discover and instantiate by class name:

```python
from mm_pyplugin import PluginFactory, PluginConfig

# {"mypackage.plugins.MyPlugin": <class MyPlugin>, ...}
plugins = PluginFactory.find_plugins()

# Instantiate from a PluginConfig that names its plugin_class
cfg = PluginConfig(plugin_class="mypackage.plugins.MyPlugin", timeout=10)
plugin = PluginFactory(context).create_plugin(cfg, recursive=True)
```

`create_plugin(instance_config, context=None, recursive=False, setup=True)` looks up
`instance_config.plugin_class` in the registry (raising `KeyError` if absent),
instantiates it, optionally injects sub-plugins, and optionally calls `setup()`.

## Singletons

Set `is_singleton = True` on a plugin class to reuse a single live instance: when one
already exists in the registry, `PluginFactory` returns it instead of constructing a
new one.

```python
class Cache(SimplePluginBase[dict, MyConfig]):
    is_singleton = True
    ...
```

## Key Classes

| Class                   | Role                                                              |
|-------------------------|------------------------------------------------------------------|
| `PluginBase`            | Abstract, generic base; lifecycle, `context`, `config_schema()`  |
| `SimplePluginBase`      | Config supplied at construction; `config` available immediately  |
| `ComposablePluginBase`  | Adds a `configure()` step before `setup()`                       |
| `PluginConfig`          | Pydantic base for all config schemas (extra fields allowed)      |
| `PluginFactory`         | Discovery (`find_plugins`) and instantiation (`create_plugin`)   |
| `PluginSetupException`  | Raised on lifecycle-ordering violations                          |

### Lifecycle rules

- `setup()` — call `super().setup()` **first**; raises `PluginSetupException` if
  already set up. `was_setup` becomes `True`.
- `teardown()` — call `super().teardown()` **last**; raises `PluginSetupException`
  if not set up. `was_setup` becomes `False`.

## Testing

Inject mocks via the context, and swap real plugins for test doubles with the
`plugin_overrides` context manager (no entry points required):

```python
from mm_pyplugin.testing import plugin_overrides
from mm_pyplugin import PluginFactory, PluginConfig

with plugin_overrides(FakeSource, FakeTransformer):
    cfg = PluginConfig(plugin_class="tests.FakeSource")
    plugin = PluginFactory({"database": MockDatabase()}).create_plugin(cfg)
# overrides cleared automatically on exit
```

### Running tests

```bash
uv run python -m pytest
```

## Examples

Runnable examples live in [`tests/examples/`](tests/examples/):

- `ExamplePlugin` / `ExamplePluginConfig` — composable plugin with DI
- `SimpleExamplePlugin` — `SimplePluginBase` variant
- `ParentPlugin` / `ParentPluginConfig` — declarative sub-plugin injection

See [`USAGE_EXAMPLES.md`](USAGE_EXAMPLES.md) for additional patterns.

## License

MIT
