From cff93d3a7fb4c14500f966950f21546ad4ab16fb Mon Sep 17 00:00:00 2001 From: Mike Knepper Date: Wed, 31 Dec 2025 14:43:23 -0600 Subject: [PATCH 1/8] plugin system updates --- _tmp_notes.md | 111 ++++++++++++++++++ src/data_designer/plugins/errors.py | 3 + src/data_designer/plugins/plugin.py | 70 +++++++++-- src/data_designer/plugins/testing/__init__.py | 8 ++ src/data_designer/plugins/testing/examples.py | 73 ++++++++++++ src/data_designer/plugins/testing/utils.py | 15 +++ tests/plugins/test_plugin.py | 101 ++++++---------- tests/plugins/test_plugin_registry.py | 45 +------ 8 files changed, 312 insertions(+), 114 deletions(-) create mode 100644 _tmp_notes.md create mode 100644 src/data_designer/plugins/testing/__init__.py create mode 100644 src/data_designer/plugins/testing/examples.py create mode 100644 src/data_designer/plugins/testing/utils.py diff --git a/_tmp_notes.md b/_tmp_notes.md new file mode 100644 index 00000000..ed89adfc --- /dev/null +++ b/_tmp_notes.md @@ -0,0 +1,111 @@ +# Plugin system updates + +## Requirements + +1. Plugins MUST support defining both a configuration object (a Pydantic model) and some `engine`-related implementation object (`ConfigurableTask`, `ColumnGenerator`, etc.). +1. The UX for making plugins discoverable MUST be simple. We should only require users define a single `Plugin` object that gets referenced in a single entry point. +1. The plugin system MUST NOT introduce a dependency chain that makes any `config` module depend on any `engine` module. + a. Breaks "slim install" support, because `engine` code may include third-party deps that a `config`-only slim install will not include. + b. Introduces a high risk of circular imports, because `engine` code depends on `config` modules. +1. A client using a slim-install of the library SHOULD be able to use plugins. + + +## Current state + +The current plugin system violates REQ 3 (and by extension REQ 4): + +``` +config.column_types -> data_designer.plugin_manager -> data_designer.plugins.plugin -> data_designer.engine.configurable_task +``` +(`->` means "imports" aka "depends on") + + +## Blessed engine modules? + +One idea that was floated is to refactor existing `engine` code so that base classes exist in some "blessed" module(s) that we would ensure do not create circular imports with `config` modules, +but this seems... +- hard to enforce/guarantee +- potentially restricting (what if a plugin author wants to use objects from other parts of `engine`) +- conceptually not ideal (it's just simpler to say "`engine` can import/depend on `config`, but not vice-versa" full stop instead of carving out exceptions) +- potentially complicated with respect to however we restructure packages to support slim installs + + +## Idea proposed in this branch + +Make the `Plugin` object "lazy" by defining the config and and task types as fully-qualified strings rather than objects. + +By using strings in the `Plugin` object fields, **if** the plugin is structured with multiple files (e.g. `config.py` and `task.py`)*, +then the core library's `config` code that uses plugins (to extend discriminated union types) can load the plugin and resolve **only** +the config class type; it would not need to resolve/load/import the plugin's task-related module where `engine` base classes are imported and subclassed. + +> *This multi-file setup wouldn't be **required** out of the box; see "Plugin development lifecycle" below. + +Example: +```python +# src/my_plugin/config.py +from data_designer.config.column_types import SingleColumnConfig + +class MyPluginConfig(SingleColumnConfig): + foo: str + + + +# src/my_plugin/generator.py +from data_designer.engine.column_generators.generators.base import ColumnGenerator +from my_plugin.config import MyPluginConfig + +class MyPluginGenerator(ColumnGenerator[MyPluginConfig]): + pass + + + +# src/my_plugin/plugin.py +from data_designer.plugins.plugin import Plugin, PluginType + +plugin = Plugin( + config_cls="my_plugin.config.MyPluginConfig", + task_cls="my_plugin.generator.MyPluginGenerator", + plugin_type=PluginType.COLUMN_GENERATOR, +) +``` + + +### Strings instead of concrete types? + +Yeah, a little sad, but seems a reasonable compromise given the benefits this unlocks. + +To mitigate against dumb stuff like typos, I suggest we ship a test helper function that we'd encourage plugin authors use in their unit tests: +```python +# my_plugin/tests/test_plugin.py +from data_designer.plugins.test import is_valid_plugin +from my_plugin.plugin import plugin + + +def test_plugin_validity(): + assert is_valid_plugin(plugin) +``` +(Similar to `pd.testing.assert_frame_equal`.) + +To start, that test helper would ensure two things: +1. The string class names resolve to concrete types that do exist +2. The resolved concrete types are subclasses of the expected base classes + +In the future, we could extend the helper to validate other things that are more complex than just Pydantic field type validations. + +Remember: we can't implement this validation as a Pydantic validator because it would break the laziness. +We **can** at least validate that the module exists (and this branch does so), but only the test helper +can go further and actually fully resolve the two fields. + + +### Plugin development lifecycle + +A plugin author _could_ continue defining everything in one Python file and things would still work in the library. +The limitation would be that a plugin defined that way would not support slim installs, and so clients like NMP would not be able to use it. +**This might be perfectly fine for many plugins**, especially in the early going. +A reasonable "plugin development lifecycle" might be: +1. Develop everything in one file and get it working with the library +2. Refactor the plugin to support slim installs (if ever desired) + +Plugin authors would only need to do step 2 if/when we want to make the plugin available in NMP. +That step 2 refactor would involve breaking the plugin implementation up into multiple files _and_ (if necessary) making sure any heavyweight, +task-only third party dependencies are included under an `engine` extra. diff --git a/src/data_designer/plugins/errors.py b/src/data_designer/plugins/errors.py index de6e4435..7be1bcf4 100644 --- a/src/data_designer/plugins/errors.py +++ b/src/data_designer/plugins/errors.py @@ -4,6 +4,9 @@ from data_designer.errors import DataDesignerError +class PluginLoadError(DataDesignerError): ... + + class PluginRegistrationError(DataDesignerError): ... diff --git a/src/data_designer/plugins/plugin.py b/src/data_designer/plugins/plugin.py index 6553e45e..c7bc6f40 100644 --- a/src/data_designer/plugins/plugin.py +++ b/src/data_designer/plugins/plugin.py @@ -1,14 +1,22 @@ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 +from __future__ import annotations + +import importlib +import importlib.util from enum import Enum -from typing import Literal, get_origin +from functools import cached_property +from typing import TYPE_CHECKING, Annotated, Literal, get_origin -from pydantic import BaseModel, model_validator +from pydantic import AfterValidator, BaseModel, model_validator from typing_extensions import Self -from data_designer.config.base import ConfigBase -from data_designer.engine.configurable_task import ConfigurableTask +from data_designer.plugins.errors import PluginLoadError + +if TYPE_CHECKING: + from data_designer.config.base import ConfigBase + from data_designer.engine.configurable_task import ConfigurableTask class PluginType(str, Enum): @@ -26,9 +34,30 @@ def display_name(self) -> str: return self.value.replace("-", " ") +def _get_module_and_object_names(fully_qualified_object: str) -> tuple[str, str]: + try: + module_name, object_name = fully_qualified_object.rsplit(".", 1) + except ValueError: + # If fully_qualified_object does not have any periods, the rsplit call will return + # a list of length 1 and the variable assignment above will raise ValueError + raise PluginLoadError("Expected a fully-qualified object name, e.g. 'my_plugin.config.MyConfig'") + + return module_name, object_name + + +def _is_valid_module(value: str) -> str: + module_name, _ = _get_module_and_object_names(value) + try: + importlib.util.find_spec(module_name) + except: + raise PluginLoadError(f"Could not find module {module_name!r}.") + + return value + + class Plugin(BaseModel): - task_cls: type[ConfigurableTask] - config_cls: type[ConfigBase] + task_class_name: Annotated[str, AfterValidator(_is_valid_module)] + config_class_name: Annotated[str, AfterValidator(_is_valid_module)] plugin_type: PluginType emoji: str = "🔌" @@ -50,20 +79,37 @@ def discriminator_field(self) -> str: @model_validator(mode="after") def validate_discriminator_field(self) -> Self: - cfg = self.config_cls.__name__ + _, cfg = _get_module_and_object_names(self.config_class_name) field = self.plugin_type.discriminator_field if field not in self.config_cls.model_fields: - raise ValueError(f"Discriminator field '{field}' not found in config class {cfg}") + raise ValueError(f"Discriminator field {field!r} not found in config class {cfg!r}") field_info = self.config_cls.model_fields[field] if get_origin(field_info.annotation) is not Literal: - raise ValueError(f"Field '{field}' of {cfg} must be a Literal type, not {field_info.annotation}.") + raise ValueError(f"Field {field!r} of {cfg!r} must be a Literal type, not {field_info.annotation!r}.") if not isinstance(field_info.default, str): - raise ValueError(f"The default of '{field}' must be a string, not {type(field_info.default)}.") + raise ValueError(f"The default of {field!r} must be a string, not {type(field_info.default)!r}.") enum_key = field_info.default.replace("-", "_").upper() if not enum_key.isidentifier(): raise ValueError( - f"The default value '{field_info.default}' for discriminator field '{field}' " - f"cannot be converted to a valid enum key. The converted key '{enum_key}' " + f"The default value {field_info.default!r} for discriminator field {field!r} " + f"cannot be converted to a valid enum key. The converted key {enum_key!r} " f"must be a valid Python identifier." ) return self + + @cached_property + def config_cls(self) -> type[ConfigBase]: + return self._load(self.config_class_name) + + @cached_property + def task_cls(self) -> type[ConfigurableTask]: + return self._load(self.task_class_name) + + @staticmethod + def _load(fully_qualified_object: str) -> type: + module_name, object_name = _get_module_and_object_names(fully_qualified_object) + module = importlib.import_module(module_name) + try: + return getattr(module, object_name) + except AttributeError: + raise PluginLoadError(f"Could not find class {object_name!r} in module {module_name!r}") diff --git a/src/data_designer/plugins/testing/__init__.py b/src/data_designer/plugins/testing/__init__.py new file mode 100644 index 00000000..e2c48497 --- /dev/null +++ b/src/data_designer/plugins/testing/__init__.py @@ -0,0 +1,8 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from data_designer.plugins.testing.utils import is_valid_plugin + +__all__ = [ + is_valid_plugin.__name__, +] diff --git a/src/data_designer/plugins/testing/examples.py b/src/data_designer/plugins/testing/examples.py new file mode 100644 index 00000000..342a641a --- /dev/null +++ b/src/data_designer/plugins/testing/examples.py @@ -0,0 +1,73 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from typing import Literal + +from data_designer.config.base import ConfigBase +from data_designer.config.column_configs import SingleColumnConfig +from data_designer.engine.configurable_task import ConfigurableTask, ConfigurableTaskMetadata + +MODULE_NAME = __name__ + + +class ValidTestConfig(SingleColumnConfig): + """Valid config for testing plugin creation.""" + + column_type: Literal["test-generator"] = "test-generator" + name: str + + +class ValidTestTask(ConfigurableTask[ValidTestConfig]): + """Valid task for testing plugin creation.""" + + @staticmethod + def metadata() -> ConfigurableTaskMetadata: + return ConfigurableTaskMetadata( + name="test_generator", + description="Test generator", + required_resources=None, + ) + + +class ConfigWithoutDiscriminator(ConfigBase): + some_field: str + + +class ConfigWithStringField(ConfigBase): + column_type: str = "test-generator" + + +class ConfigWithNonStringDefault(ConfigBase): + column_type: Literal["test-generator"] = 123 # type: ignore + + +class ConfigWithInvalidKey(ConfigBase): + column_type: Literal["invalid-key-!@#"] = "invalid-key-!@#" + + +class StubPluginConfigA(SingleColumnConfig): + column_type: Literal["test-plugin-a"] = "test-plugin-a" + + +class StubPluginConfigB(SingleColumnConfig): + column_type: Literal["test-plugin-b"] = "test-plugin-b" + + +class StubPluginTaskA(ConfigurableTask[StubPluginConfigA]): + @staticmethod + def metadata() -> ConfigurableTaskMetadata: + return ConfigurableTaskMetadata( + name="test_plugin_a", + description="Test plugin A", + required_resources=None, + ) + + +class StubPluginTaskB(ConfigurableTask[StubPluginConfigB]): + @staticmethod + def metadata() -> ConfigurableTaskMetadata: + return ConfigurableTaskMetadata( + name="test_plugin_b", + description="Test plugin B", + required_resources=None, + ) diff --git a/src/data_designer/plugins/testing/utils.py b/src/data_designer/plugins/testing/utils.py new file mode 100644 index 00000000..0da38d6a --- /dev/null +++ b/src/data_designer/plugins/testing/utils.py @@ -0,0 +1,15 @@ +# SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from data_designer.config.base import ConfigBase +from data_designer.engine.configurable_task import ConfigurableTask +from data_designer.plugins.plugin import Plugin + + +def is_valid_plugin(plugin: Plugin) -> bool: + if not isinstance(plugin.config_cls, ConfigBase): + return False + if not isinstance(plugin.task_cls, ConfigurableTask): + return False + + return True diff --git a/tests/plugins/test_plugin.py b/tests/plugins/test_plugin.py index ac5b17cc..a65cee29 100644 --- a/tests/plugins/test_plugin.py +++ b/tests/plugins/test_plugin.py @@ -1,43 +1,23 @@ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from typing import Literal - import pytest -from pydantic import ValidationError from data_designer.config.base import ConfigBase -from data_designer.config.column_configs import SamplerColumnConfig, SingleColumnConfig +from data_designer.config.column_configs import SamplerColumnConfig from data_designer.engine.column_generators.generators.samplers import SamplerColumnGenerator -from data_designer.engine.configurable_task import ConfigurableTask, ConfigurableTaskMetadata +from data_designer.engine.configurable_task import ConfigurableTask +from data_designer.plugins.errors import PluginLoadError from data_designer.plugins.plugin import Plugin, PluginType - - -class ValidTestConfig(SingleColumnConfig): - """Valid config for testing plugin creation.""" - - column_type: Literal["test-generator"] = "test-generator" - name: str - - -class ValidTestTask(ConfigurableTask[ValidTestConfig]): - """Valid task for testing plugin creation.""" - - @staticmethod - def metadata() -> ConfigurableTaskMetadata: - return ConfigurableTaskMetadata( - name="test_generator", - description="Test generator", - required_resources=None, - ) +from data_designer.plugins.testing.examples import MODULE_NAME, ValidTestConfig, ValidTestTask @pytest.fixture def valid_plugin() -> Plugin: """Fixture providing a valid plugin instance for testing.""" return Plugin( - task_cls=ValidTestTask, - config_cls=ValidTestConfig, + task_class_name=f"{MODULE_NAME}.ValidTestTask", + config_class_name=f"{MODULE_NAME}.ValidTestConfig", plugin_type=PluginType.COLUMN_GENERATOR, ) @@ -81,18 +61,6 @@ def test_plugin_discriminator_field_from_type(valid_plugin: Plugin) -> None: assert valid_plugin.discriminator_field == "column_type" -def test_plugin_requires_all_fields() -> None: - """Test that Plugin creation fails without required fields.""" - with pytest.raises(ValidationError): - Plugin() # type: ignore - - with pytest.raises(ValidationError): - Plugin(task_cls=ValidTestTask) # type: ignore - - with pytest.raises(ValidationError): - Plugin(config_cls=ValidTestConfig) # type: ignore - - # ============================================================================= # Plugin Validation Tests # ============================================================================= @@ -101,55 +69,64 @@ def test_plugin_requires_all_fields() -> None: def test_validation_fails_when_config_missing_discriminator_field() -> None: """Test validation fails when config lacks the required discriminator field.""" - class ConfigWithoutDiscriminator(ConfigBase): - some_field: str - with pytest.raises(ValueError, match="Discriminator field 'column_type' not found in config class"): Plugin( - task_cls=ValidTestTask, - config_cls=ConfigWithoutDiscriminator, + task_class_name=f"{MODULE_NAME}.ValidTestTask", + config_class_name=f"{MODULE_NAME}.ConfigWithoutDiscriminator", plugin_type=PluginType.COLUMN_GENERATOR, ) def test_validation_fails_when_discriminator_field_not_literal_type() -> None: """Test validation fails when discriminator field is not a Literal type.""" - - class ConfigWithStringField(ConfigBase): - column_type: str = "test-generator" - with pytest.raises(ValueError, match="Field 'column_type' of .* must be a Literal type"): Plugin( - task_cls=ValidTestTask, - config_cls=ConfigWithStringField, + task_class_name=f"{MODULE_NAME}.ValidTestTask", + config_class_name=f"{MODULE_NAME}.ConfigWithStringField", plugin_type=PluginType.COLUMN_GENERATOR, ) def test_validation_fails_when_discriminator_default_not_string() -> None: """Test validation fails when discriminator field default is not a string.""" - - class ConfigWithNonStringDefault(ConfigBase): - column_type: Literal["test-generator"] = 123 # type: ignore - with pytest.raises(ValueError, match="The default of 'column_type' must be a string"): Plugin( - task_cls=ValidTestTask, - config_cls=ConfigWithNonStringDefault, + task_class_name=f"{MODULE_NAME}.ValidTestTask", + config_class_name=f"{MODULE_NAME}.ConfigWithNonStringDefault", plugin_type=PluginType.COLUMN_GENERATOR, ) def test_validation_fails_with_invalid_enum_key_conversion() -> None: """Test validation fails when default value cannot be converted to valid Python identifier.""" + with pytest.raises(ValueError, match="cannot be converted to a valid enum key"): + Plugin( + task_class_name=f"{MODULE_NAME}.ValidTestTask", + config_class_name=f"{MODULE_NAME}.ConfigWithInvalidKey", + plugin_type=PluginType.COLUMN_GENERATOR, + ) - class ConfigWithInvalidKey(ConfigBase): - column_type: Literal["invalid-key-!@#"] = "invalid-key-!@#" - with pytest.raises(ValueError, match="cannot be converted to a valid enum key"): +def test_validation_fails_with_invalid_modules() -> None: + """Test validation fails when task or config class modules are invalid.""" + with pytest.raises(PluginLoadError, match="Could not find module"): + Plugin( + task_class_name=f"{MODULE_NAME}.ValidTestTask", + config_class_name="invalid.module.ValidTestConfig", + plugin_type=PluginType.COLUMN_GENERATOR, + ) + + with pytest.raises(PluginLoadError, match="Could not find module"): + Plugin( + task_class_name="invalid.module.ValidTestTask", + config_class_name=f"{MODULE_NAME}.ValidTestConfig", + plugin_type=PluginType.COLUMN_GENERATOR, + ) + + with pytest.raises(PluginLoadError, match="Expected a fully-qualified object name"): Plugin( - task_cls=ValidTestTask, - config_cls=ConfigWithInvalidKey, + task_class_name="ValidTestTask", + config_class_name="ValidTestConfig", plugin_type=PluginType.COLUMN_GENERATOR, ) @@ -162,8 +139,8 @@ class ConfigWithInvalidKey(ConfigBase): def test_plugin_works_with_real_sampler_column_generator() -> None: """Test that Plugin works with actual SamplerColumnGenerator from the codebase.""" plugin = Plugin( - task_cls=SamplerColumnGenerator, - config_cls=SamplerColumnConfig, + task_class_name="data_designer.engine.column_generators.generators.samplers.SamplerColumnGenerator", + config_class_name="data_designer.config.column_configs.SamplerColumnConfig", plugin_type=PluginType.COLUMN_GENERATOR, ) diff --git a/tests/plugins/test_plugin_registry.py b/tests/plugins/test_plugin_registry.py index 5a7feb5c..5f004d49 100644 --- a/tests/plugins/test_plugin_registry.py +++ b/tests/plugins/test_plugin_registry.py @@ -4,50 +4,15 @@ import threading from contextlib import contextmanager from importlib.metadata import EntryPoint -from typing import Literal from unittest.mock import MagicMock, patch import pytest from data_designer.config.base import ConfigBase -from data_designer.config.column_configs import SingleColumnConfig -from data_designer.engine.configurable_task import ConfigurableTask, ConfigurableTaskMetadata from data_designer.plugins.errors import PluginNotFoundError from data_designer.plugins.plugin import Plugin, PluginType from data_designer.plugins.registry import PluginRegistry - -# ============================================================================= -# Test Stubs -# ============================================================================= - - -class StubPluginConfigA(SingleColumnConfig): - column_type: Literal["test-plugin-a"] = "test-plugin-a" - - -class StubPluginConfigB(SingleColumnConfig): - column_type: Literal["test-plugin-b"] = "test-plugin-b" - - -class StubPluginTaskA(ConfigurableTask[StubPluginConfigA]): - @staticmethod - def metadata() -> ConfigurableTaskMetadata: - return ConfigurableTaskMetadata( - name="test_plugin_a", - description="Test plugin A", - required_resources=None, - ) - - -class StubPluginTaskB(ConfigurableTask[StubPluginConfigB]): - @staticmethod - def metadata() -> ConfigurableTaskMetadata: - return ConfigurableTaskMetadata( - name="test_plugin_b", - description="Test plugin B", - required_resources=None, - ) - +from data_designer.plugins.testing.examples import MODULE_NAME, StubPluginConfigA, StubPluginConfigB # ============================================================================= # Test Fixtures @@ -57,8 +22,8 @@ def metadata() -> ConfigurableTaskMetadata: @pytest.fixture def plugin_a() -> Plugin: return Plugin( - task_cls=StubPluginTaskA, - config_cls=StubPluginConfigA, + task_class_name=f"{MODULE_NAME}.StubPluginTaskA", + config_class_name=f"{MODULE_NAME}.StubPluginConfigA", plugin_type=PluginType.COLUMN_GENERATOR, ) @@ -66,8 +31,8 @@ def plugin_a() -> Plugin: @pytest.fixture def plugin_b() -> Plugin: return Plugin( - task_cls=StubPluginTaskB, - config_cls=StubPluginConfigB, + task_class_name=f"{MODULE_NAME}.StubPluginTaskB", + config_class_name=f"{MODULE_NAME}.StubPluginConfigB", plugin_type=PluginType.COLUMN_GENERATOR, ) From 5f7c21ad5b209c66c0e45d59d267f7e347305f29 Mon Sep 17 00:00:00 2001 From: Mike Knepper Date: Fri, 2 Jan 2026 13:42:09 -0600 Subject: [PATCH 2/8] Modify test helper to make assert statements --- src/data_designer/plugins/testing/__init__.py | 4 +-- src/data_designer/plugins/testing/utils.py | 10 +++---- tests/plugins/test_plugin.py | 26 +++++++++++++++++++ 3 files changed, 31 insertions(+), 9 deletions(-) diff --git a/src/data_designer/plugins/testing/__init__.py b/src/data_designer/plugins/testing/__init__.py index e2c48497..61ee469b 100644 --- a/src/data_designer/plugins/testing/__init__.py +++ b/src/data_designer/plugins/testing/__init__.py @@ -1,8 +1,8 @@ # SPDX-FileCopyrightText: Copyright (c) 2025 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -from data_designer.plugins.testing.utils import is_valid_plugin +from data_designer.plugins.testing.utils import assert_valid_plugin __all__ = [ - is_valid_plugin.__name__, + assert_valid_plugin.__name__, ] diff --git a/src/data_designer/plugins/testing/utils.py b/src/data_designer/plugins/testing/utils.py index 0da38d6a..ff96a038 100644 --- a/src/data_designer/plugins/testing/utils.py +++ b/src/data_designer/plugins/testing/utils.py @@ -6,10 +6,6 @@ from data_designer.plugins.plugin import Plugin -def is_valid_plugin(plugin: Plugin) -> bool: - if not isinstance(plugin.config_cls, ConfigBase): - return False - if not isinstance(plugin.task_cls, ConfigurableTask): - return False - - return True +def assert_valid_plugin(plugin: Plugin) -> None: + assert issubclass(plugin.config_cls, ConfigBase), "Plugin config class is not a subclass of ConfigBase" + assert issubclass(plugin.task_cls, ConfigurableTask), "Plugin task class is not a subclass of ConfigurableTask" diff --git a/tests/plugins/test_plugin.py b/tests/plugins/test_plugin.py index a65cee29..6a926a6d 100644 --- a/tests/plugins/test_plugin.py +++ b/tests/plugins/test_plugin.py @@ -10,6 +10,7 @@ from data_designer.plugins.errors import PluginLoadError from data_designer.plugins.plugin import Plugin, PluginType from data_designer.plugins.testing.examples import MODULE_NAME, ValidTestConfig, ValidTestTask +from data_designer.plugins.testing.utils import assert_valid_plugin @pytest.fixture @@ -130,6 +131,31 @@ def test_validation_fails_with_invalid_modules() -> None: plugin_type=PluginType.COLUMN_GENERATOR, ) + with pytest.raises(PluginLoadError, match="Could not find class"): + Plugin( + task_class_name=f"{MODULE_NAME}.ValidTestTask", + config_class_name=f"{MODULE_NAME}.NotADefinedClass", + plugin_type=PluginType.COLUMN_GENERATOR, + ) + + +def test_helper_utility_identifies_invalid_classes() -> None: + """Test the helper utility provides deeper validation of config classes.""" + valid_plugin = Plugin( + task_class_name=f"{MODULE_NAME}.ValidTestTask", + config_class_name=f"{MODULE_NAME}.ValidTestConfig", + plugin_type=PluginType.COLUMN_GENERATOR, + ) + assert_valid_plugin(valid_plugin) + + plugin_with_improper_task_class_type = Plugin( + task_class_name=f"{MODULE_NAME}.ValidTestConfig", + config_class_name=f"{MODULE_NAME}.ValidTestConfig", + plugin_type=PluginType.COLUMN_GENERATOR, + ) + with pytest.raises(AssertionError): + assert_valid_plugin(plugin_with_improper_task_class_type) + # ============================================================================= # Integration Tests From f0fe161f4570cff20fb981f2d2c444b6278bd249 Mon Sep 17 00:00:00 2001 From: Mike Knepper Date: Mon, 5 Jan 2026 12:22:35 -0600 Subject: [PATCH 3/8] Rename stubs module --- src/data_designer/plugins/testing/{examples.py => stubs.py} | 0 tests/plugins/test_plugin.py | 2 +- tests/plugins/test_plugin_registry.py | 2 +- 3 files changed, 2 insertions(+), 2 deletions(-) rename src/data_designer/plugins/testing/{examples.py => stubs.py} (100%) diff --git a/src/data_designer/plugins/testing/examples.py b/src/data_designer/plugins/testing/stubs.py similarity index 100% rename from src/data_designer/plugins/testing/examples.py rename to src/data_designer/plugins/testing/stubs.py diff --git a/tests/plugins/test_plugin.py b/tests/plugins/test_plugin.py index 6a926a6d..6ddf365c 100644 --- a/tests/plugins/test_plugin.py +++ b/tests/plugins/test_plugin.py @@ -9,7 +9,7 @@ from data_designer.engine.configurable_task import ConfigurableTask from data_designer.plugins.errors import PluginLoadError from data_designer.plugins.plugin import Plugin, PluginType -from data_designer.plugins.testing.examples import MODULE_NAME, ValidTestConfig, ValidTestTask +from data_designer.plugins.testing.stubs import MODULE_NAME, ValidTestConfig, ValidTestTask from data_designer.plugins.testing.utils import assert_valid_plugin diff --git a/tests/plugins/test_plugin_registry.py b/tests/plugins/test_plugin_registry.py index 5f004d49..e51b648c 100644 --- a/tests/plugins/test_plugin_registry.py +++ b/tests/plugins/test_plugin_registry.py @@ -12,7 +12,7 @@ from data_designer.plugins.errors import PluginNotFoundError from data_designer.plugins.plugin import Plugin, PluginType from data_designer.plugins.registry import PluginRegistry -from data_designer.plugins.testing.examples import MODULE_NAME, StubPluginConfigA, StubPluginConfigB +from data_designer.plugins.testing.stubs import MODULE_NAME, StubPluginConfigA, StubPluginConfigB # ============================================================================= # Test Fixtures From 3c32d31ec79675b21eed1a404c7b733e90f89754 Mon Sep 17 00:00:00 2001 From: Mike Knepper Date: Mon, 5 Jan 2026 12:33:17 -0600 Subject: [PATCH 4/8] Plugin field descriptions --- src/data_designer/plugins/plugin.py | 33 +++++++++++++++-------------- 1 file changed, 17 insertions(+), 16 deletions(-) diff --git a/src/data_designer/plugins/plugin.py b/src/data_designer/plugins/plugin.py index c7bc6f40..cff08c00 100644 --- a/src/data_designer/plugins/plugin.py +++ b/src/data_designer/plugins/plugin.py @@ -7,9 +7,9 @@ import importlib.util from enum import Enum from functools import cached_property -from typing import TYPE_CHECKING, Annotated, Literal, get_origin +from typing import TYPE_CHECKING, Literal, get_origin -from pydantic import AfterValidator, BaseModel, model_validator +from pydantic import BaseModel, Field, field_validator, model_validator from typing_extensions import Self from data_designer.plugins.errors import PluginLoadError @@ -45,21 +45,11 @@ def _get_module_and_object_names(fully_qualified_object: str) -> tuple[str, str] return module_name, object_name -def _is_valid_module(value: str) -> str: - module_name, _ = _get_module_and_object_names(value) - try: - importlib.util.find_spec(module_name) - except: - raise PluginLoadError(f"Could not find module {module_name!r}.") - - return value - - class Plugin(BaseModel): - task_class_name: Annotated[str, AfterValidator(_is_valid_module)] - config_class_name: Annotated[str, AfterValidator(_is_valid_module)] - plugin_type: PluginType - emoji: str = "🔌" + task_class_name: str = Field(..., description="The fully-qualified import path to the task class object") + config_class_name: str = Field(..., description="The fully-qualified import path to the config class object") + plugin_type: PluginType = Field(..., description="The type of plugin") + emoji: str = Field(default="🔌", description="The emoji to use in logs related to the plugin") @property def config_type_as_class_name(self) -> str: @@ -77,6 +67,17 @@ def name(self) -> str: def discriminator_field(self) -> str: return self.plugin_type.discriminator_field + @field_validator("task_class_name", "config_class_name", mode="after") + @classmethod + def validate_class_name(cls, value: str) -> str: + module_name, _ = _get_module_and_object_names(value) + try: + importlib.util.find_spec(module_name) + except: + raise PluginLoadError(f"Could not find module {module_name!r}.") + + return value + @model_validator(mode="after") def validate_discriminator_field(self) -> Self: _, cfg = _get_module_and_object_names(self.config_class_name) From 6fe4011d7d9dcf141aaa5336d1017e0afec84bf7 Mon Sep 17 00:00:00 2001 From: Mike Knepper Date: Mon, 5 Jan 2026 13:31:23 -0600 Subject: [PATCH 5/8] Add ast parsing for more validation --- src/data_designer/plugins/plugin.py | 27 ++++++++++++++++++++++++--- tests/plugins/test_plugin.py | 7 +++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/src/data_designer/plugins/plugin.py b/src/data_designer/plugins/plugin.py index cff08c00..278faa49 100644 --- a/src/data_designer/plugins/plugin.py +++ b/src/data_designer/plugins/plugin.py @@ -3,6 +3,7 @@ from __future__ import annotations +import ast import importlib import importlib.util from enum import Enum @@ -45,6 +46,21 @@ def _get_module_and_object_names(fully_qualified_object: str) -> tuple[str, str] return module_name, object_name +def _check_class_exists_in_file(filepath: str, class_name: str) -> None: + try: + with open(filepath, "r") as file: + source = file.read() + except FileNotFoundError: + raise PluginLoadError(f"Could not read source code at {filepath!r}") + + tree = ast.parse(source) + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef) and node.name == class_name: + return None + + raise PluginLoadError(f"Could not find class named {class_name!r} in {filepath!r}") + + class Plugin(BaseModel): task_class_name: str = Field(..., description="The fully-qualified import path to the task class object") config_class_name: str = Field(..., description="The fully-qualified import path to the config class object") @@ -70,11 +86,16 @@ def discriminator_field(self) -> str: @field_validator("task_class_name", "config_class_name", mode="after") @classmethod def validate_class_name(cls, value: str) -> str: - module_name, _ = _get_module_and_object_names(value) + module_name, object_name = _get_module_and_object_names(value) try: - importlib.util.find_spec(module_name) + spec = importlib.util.find_spec(module_name) except: - raise PluginLoadError(f"Could not find module {module_name!r}.") + raise PluginLoadError(f"Could not find module {module_name!r}") + + if spec is None or spec.origin is None: + raise PluginLoadError(f"Error finding source for module {module_name!r}") + + _check_class_exists_in_file(spec.origin, object_name) return value diff --git a/tests/plugins/test_plugin.py b/tests/plugins/test_plugin.py index 6ddf365c..0fadf981 100644 --- a/tests/plugins/test_plugin.py +++ b/tests/plugins/test_plugin.py @@ -138,6 +138,13 @@ def test_validation_fails_with_invalid_modules() -> None: plugin_type=PluginType.COLUMN_GENERATOR, ) + with pytest.raises(PluginLoadError, match="Could not find class"): + Plugin( + task_class_name=f"{MODULE_NAME}.NotADefinedClass", + config_class_name=f"{MODULE_NAME}.ValidTestConfig", + plugin_type=PluginType.COLUMN_GENERATOR, + ) + def test_helper_utility_identifies_invalid_classes() -> None: """Test the helper utility provides deeper validation of config classes.""" From 3b1e96cf1ade76aa012cfad8f2208bc62441d278 Mon Sep 17 00:00:00 2001 From: Mike Knepper Date: Mon, 5 Jan 2026 17:24:45 -0600 Subject: [PATCH 6/8] Rename fields to _qualified_name --- src/data_designer/plugins/plugin.py | 17 +++++---- tests/plugins/test_plugin.py | 52 +++++++++++++-------------- tests/plugins/test_plugin_registry.py | 8 ++--- 3 files changed, 41 insertions(+), 36 deletions(-) diff --git a/src/data_designer/plugins/plugin.py b/src/data_designer/plugins/plugin.py index 278faa49..007f0ae4 100644 --- a/src/data_designer/plugins/plugin.py +++ b/src/data_designer/plugins/plugin.py @@ -62,8 +62,13 @@ def _check_class_exists_in_file(filepath: str, class_name: str) -> None: class Plugin(BaseModel): - task_class_name: str = Field(..., description="The fully-qualified import path to the task class object") - config_class_name: str = Field(..., description="The fully-qualified import path to the config class object") + task_qualified_name: str = Field( + ..., + description="The fully-qualified name of the task class object, e.g. 'my_plugin.generator.MyColumnGenerator'", + ) + config_qualified_name: str = Field( + ..., description="The fully-qualified name o the config class object, e.g. 'my_plugin.config.MyConfig'" + ) plugin_type: PluginType = Field(..., description="The type of plugin") emoji: str = Field(default="🔌", description="The emoji to use in logs related to the plugin") @@ -83,7 +88,7 @@ def name(self) -> str: def discriminator_field(self) -> str: return self.plugin_type.discriminator_field - @field_validator("task_class_name", "config_class_name", mode="after") + @field_validator("task_qualified_name", "config_qualified_name", mode="after") @classmethod def validate_class_name(cls, value: str) -> str: module_name, object_name = _get_module_and_object_names(value) @@ -101,7 +106,7 @@ def validate_class_name(cls, value: str) -> str: @model_validator(mode="after") def validate_discriminator_field(self) -> Self: - _, cfg = _get_module_and_object_names(self.config_class_name) + _, cfg = _get_module_and_object_names(self.config_qualified_name) field = self.plugin_type.discriminator_field if field not in self.config_cls.model_fields: raise ValueError(f"Discriminator field {field!r} not found in config class {cfg!r}") @@ -121,11 +126,11 @@ def validate_discriminator_field(self) -> Self: @cached_property def config_cls(self) -> type[ConfigBase]: - return self._load(self.config_class_name) + return self._load(self.config_qualified_name) @cached_property def task_cls(self) -> type[ConfigurableTask]: - return self._load(self.task_class_name) + return self._load(self.task_qualified_name) @staticmethod def _load(fully_qualified_object: str) -> type: diff --git a/tests/plugins/test_plugin.py b/tests/plugins/test_plugin.py index 0fadf981..afddcca2 100644 --- a/tests/plugins/test_plugin.py +++ b/tests/plugins/test_plugin.py @@ -17,8 +17,8 @@ def valid_plugin() -> Plugin: """Fixture providing a valid plugin instance for testing.""" return Plugin( - task_class_name=f"{MODULE_NAME}.ValidTestTask", - config_class_name=f"{MODULE_NAME}.ValidTestConfig", + task_qualified_name=f"{MODULE_NAME}.ValidTestTask", + config_qualified_name=f"{MODULE_NAME}.ValidTestConfig", plugin_type=PluginType.COLUMN_GENERATOR, ) @@ -72,8 +72,8 @@ def test_validation_fails_when_config_missing_discriminator_field() -> None: with pytest.raises(ValueError, match="Discriminator field 'column_type' not found in config class"): Plugin( - task_class_name=f"{MODULE_NAME}.ValidTestTask", - config_class_name=f"{MODULE_NAME}.ConfigWithoutDiscriminator", + task_qualified_name=f"{MODULE_NAME}.ValidTestTask", + config_qualified_name=f"{MODULE_NAME}.ConfigWithoutDiscriminator", plugin_type=PluginType.COLUMN_GENERATOR, ) @@ -82,8 +82,8 @@ def test_validation_fails_when_discriminator_field_not_literal_type() -> None: """Test validation fails when discriminator field is not a Literal type.""" with pytest.raises(ValueError, match="Field 'column_type' of .* must be a Literal type"): Plugin( - task_class_name=f"{MODULE_NAME}.ValidTestTask", - config_class_name=f"{MODULE_NAME}.ConfigWithStringField", + task_qualified_name=f"{MODULE_NAME}.ValidTestTask", + config_qualified_name=f"{MODULE_NAME}.ConfigWithStringField", plugin_type=PluginType.COLUMN_GENERATOR, ) @@ -92,8 +92,8 @@ def test_validation_fails_when_discriminator_default_not_string() -> None: """Test validation fails when discriminator field default is not a string.""" with pytest.raises(ValueError, match="The default of 'column_type' must be a string"): Plugin( - task_class_name=f"{MODULE_NAME}.ValidTestTask", - config_class_name=f"{MODULE_NAME}.ConfigWithNonStringDefault", + task_qualified_name=f"{MODULE_NAME}.ValidTestTask", + config_qualified_name=f"{MODULE_NAME}.ConfigWithNonStringDefault", plugin_type=PluginType.COLUMN_GENERATOR, ) @@ -102,8 +102,8 @@ def test_validation_fails_with_invalid_enum_key_conversion() -> None: """Test validation fails when default value cannot be converted to valid Python identifier.""" with pytest.raises(ValueError, match="cannot be converted to a valid enum key"): Plugin( - task_class_name=f"{MODULE_NAME}.ValidTestTask", - config_class_name=f"{MODULE_NAME}.ConfigWithInvalidKey", + task_qualified_name=f"{MODULE_NAME}.ValidTestTask", + config_qualified_name=f"{MODULE_NAME}.ConfigWithInvalidKey", plugin_type=PluginType.COLUMN_GENERATOR, ) @@ -112,36 +112,36 @@ def test_validation_fails_with_invalid_modules() -> None: """Test validation fails when task or config class modules are invalid.""" with pytest.raises(PluginLoadError, match="Could not find module"): Plugin( - task_class_name=f"{MODULE_NAME}.ValidTestTask", - config_class_name="invalid.module.ValidTestConfig", + task_qualified_name=f"{MODULE_NAME}.ValidTestTask", + config_qualified_name="invalid.module.ValidTestConfig", plugin_type=PluginType.COLUMN_GENERATOR, ) with pytest.raises(PluginLoadError, match="Could not find module"): Plugin( - task_class_name="invalid.module.ValidTestTask", - config_class_name=f"{MODULE_NAME}.ValidTestConfig", + task_qualified_name="invalid.module.ValidTestTask", + config_qualified_name=f"{MODULE_NAME}.ValidTestConfig", plugin_type=PluginType.COLUMN_GENERATOR, ) with pytest.raises(PluginLoadError, match="Expected a fully-qualified object name"): Plugin( - task_class_name="ValidTestTask", - config_class_name="ValidTestConfig", + task_qualified_name="ValidTestTask", + config_qualified_name="ValidTestConfig", plugin_type=PluginType.COLUMN_GENERATOR, ) with pytest.raises(PluginLoadError, match="Could not find class"): Plugin( - task_class_name=f"{MODULE_NAME}.ValidTestTask", - config_class_name=f"{MODULE_NAME}.NotADefinedClass", + task_qualified_name=f"{MODULE_NAME}.ValidTestTask", + config_qualified_name=f"{MODULE_NAME}.NotADefinedClass", plugin_type=PluginType.COLUMN_GENERATOR, ) with pytest.raises(PluginLoadError, match="Could not find class"): Plugin( - task_class_name=f"{MODULE_NAME}.NotADefinedClass", - config_class_name=f"{MODULE_NAME}.ValidTestConfig", + task_qualified_name=f"{MODULE_NAME}.NotADefinedClass", + config_qualified_name=f"{MODULE_NAME}.ValidTestConfig", plugin_type=PluginType.COLUMN_GENERATOR, ) @@ -149,15 +149,15 @@ def test_validation_fails_with_invalid_modules() -> None: def test_helper_utility_identifies_invalid_classes() -> None: """Test the helper utility provides deeper validation of config classes.""" valid_plugin = Plugin( - task_class_name=f"{MODULE_NAME}.ValidTestTask", - config_class_name=f"{MODULE_NAME}.ValidTestConfig", + task_qualified_name=f"{MODULE_NAME}.ValidTestTask", + config_qualified_name=f"{MODULE_NAME}.ValidTestConfig", plugin_type=PluginType.COLUMN_GENERATOR, ) assert_valid_plugin(valid_plugin) plugin_with_improper_task_class_type = Plugin( - task_class_name=f"{MODULE_NAME}.ValidTestConfig", - config_class_name=f"{MODULE_NAME}.ValidTestConfig", + task_qualified_name=f"{MODULE_NAME}.ValidTestConfig", + config_qualified_name=f"{MODULE_NAME}.ValidTestConfig", plugin_type=PluginType.COLUMN_GENERATOR, ) with pytest.raises(AssertionError): @@ -172,8 +172,8 @@ def test_helper_utility_identifies_invalid_classes() -> None: def test_plugin_works_with_real_sampler_column_generator() -> None: """Test that Plugin works with actual SamplerColumnGenerator from the codebase.""" plugin = Plugin( - task_class_name="data_designer.engine.column_generators.generators.samplers.SamplerColumnGenerator", - config_class_name="data_designer.config.column_configs.SamplerColumnConfig", + task_qualified_name="data_designer.engine.column_generators.generators.samplers.SamplerColumnGenerator", + config_qualified_name="data_designer.config.column_configs.SamplerColumnConfig", plugin_type=PluginType.COLUMN_GENERATOR, ) diff --git a/tests/plugins/test_plugin_registry.py b/tests/plugins/test_plugin_registry.py index e51b648c..215fe713 100644 --- a/tests/plugins/test_plugin_registry.py +++ b/tests/plugins/test_plugin_registry.py @@ -22,8 +22,8 @@ @pytest.fixture def plugin_a() -> Plugin: return Plugin( - task_class_name=f"{MODULE_NAME}.StubPluginTaskA", - config_class_name=f"{MODULE_NAME}.StubPluginConfigA", + task_qualified_name=f"{MODULE_NAME}.StubPluginTaskA", + config_qualified_name=f"{MODULE_NAME}.StubPluginConfigA", plugin_type=PluginType.COLUMN_GENERATOR, ) @@ -31,8 +31,8 @@ def plugin_a() -> Plugin: @pytest.fixture def plugin_b() -> Plugin: return Plugin( - task_class_name=f"{MODULE_NAME}.StubPluginTaskB", - config_class_name=f"{MODULE_NAME}.StubPluginConfigB", + task_qualified_name=f"{MODULE_NAME}.StubPluginTaskB", + config_qualified_name=f"{MODULE_NAME}.StubPluginConfigB", plugin_type=PluginType.COLUMN_GENERATOR, ) From bee17c36ab6443464d665db312e404fe7dc11637 Mon Sep 17 00:00:00 2001 From: Mike Knepper Date: Mon, 5 Jan 2026 17:49:15 -0600 Subject: [PATCH 7/8] Remove tmp notes --- _tmp_notes.md | 111 -------------------------------------------------- 1 file changed, 111 deletions(-) delete mode 100644 _tmp_notes.md diff --git a/_tmp_notes.md b/_tmp_notes.md deleted file mode 100644 index ed89adfc..00000000 --- a/_tmp_notes.md +++ /dev/null @@ -1,111 +0,0 @@ -# Plugin system updates - -## Requirements - -1. Plugins MUST support defining both a configuration object (a Pydantic model) and some `engine`-related implementation object (`ConfigurableTask`, `ColumnGenerator`, etc.). -1. The UX for making plugins discoverable MUST be simple. We should only require users define a single `Plugin` object that gets referenced in a single entry point. -1. The plugin system MUST NOT introduce a dependency chain that makes any `config` module depend on any `engine` module. - a. Breaks "slim install" support, because `engine` code may include third-party deps that a `config`-only slim install will not include. - b. Introduces a high risk of circular imports, because `engine` code depends on `config` modules. -1. A client using a slim-install of the library SHOULD be able to use plugins. - - -## Current state - -The current plugin system violates REQ 3 (and by extension REQ 4): - -``` -config.column_types -> data_designer.plugin_manager -> data_designer.plugins.plugin -> data_designer.engine.configurable_task -``` -(`->` means "imports" aka "depends on") - - -## Blessed engine modules? - -One idea that was floated is to refactor existing `engine` code so that base classes exist in some "blessed" module(s) that we would ensure do not create circular imports with `config` modules, -but this seems... -- hard to enforce/guarantee -- potentially restricting (what if a plugin author wants to use objects from other parts of `engine`) -- conceptually not ideal (it's just simpler to say "`engine` can import/depend on `config`, but not vice-versa" full stop instead of carving out exceptions) -- potentially complicated with respect to however we restructure packages to support slim installs - - -## Idea proposed in this branch - -Make the `Plugin` object "lazy" by defining the config and and task types as fully-qualified strings rather than objects. - -By using strings in the `Plugin` object fields, **if** the plugin is structured with multiple files (e.g. `config.py` and `task.py`)*, -then the core library's `config` code that uses plugins (to extend discriminated union types) can load the plugin and resolve **only** -the config class type; it would not need to resolve/load/import the plugin's task-related module where `engine` base classes are imported and subclassed. - -> *This multi-file setup wouldn't be **required** out of the box; see "Plugin development lifecycle" below. - -Example: -```python -# src/my_plugin/config.py -from data_designer.config.column_types import SingleColumnConfig - -class MyPluginConfig(SingleColumnConfig): - foo: str - - - -# src/my_plugin/generator.py -from data_designer.engine.column_generators.generators.base import ColumnGenerator -from my_plugin.config import MyPluginConfig - -class MyPluginGenerator(ColumnGenerator[MyPluginConfig]): - pass - - - -# src/my_plugin/plugin.py -from data_designer.plugins.plugin import Plugin, PluginType - -plugin = Plugin( - config_cls="my_plugin.config.MyPluginConfig", - task_cls="my_plugin.generator.MyPluginGenerator", - plugin_type=PluginType.COLUMN_GENERATOR, -) -``` - - -### Strings instead of concrete types? - -Yeah, a little sad, but seems a reasonable compromise given the benefits this unlocks. - -To mitigate against dumb stuff like typos, I suggest we ship a test helper function that we'd encourage plugin authors use in their unit tests: -```python -# my_plugin/tests/test_plugin.py -from data_designer.plugins.test import is_valid_plugin -from my_plugin.plugin import plugin - - -def test_plugin_validity(): - assert is_valid_plugin(plugin) -``` -(Similar to `pd.testing.assert_frame_equal`.) - -To start, that test helper would ensure two things: -1. The string class names resolve to concrete types that do exist -2. The resolved concrete types are subclasses of the expected base classes - -In the future, we could extend the helper to validate other things that are more complex than just Pydantic field type validations. - -Remember: we can't implement this validation as a Pydantic validator because it would break the laziness. -We **can** at least validate that the module exists (and this branch does so), but only the test helper -can go further and actually fully resolve the two fields. - - -### Plugin development lifecycle - -A plugin author _could_ continue defining everything in one Python file and things would still work in the library. -The limitation would be that a plugin defined that way would not support slim installs, and so clients like NMP would not be able to use it. -**This might be perfectly fine for many plugins**, especially in the early going. -A reasonable "plugin development lifecycle" might be: -1. Develop everything in one file and get it working with the library -2. Refactor the plugin to support slim installs (if ever desired) - -Plugin authors would only need to do step 2 if/when we want to make the plugin available in NMP. -That step 2 refactor would involve breaking the plugin implementation up into multiple files _and_ (if necessary) making sure any heavyweight, -task-only third party dependencies are included under an `engine` extra. From 54206bf707766faa17d5fa9dbce45fc89cab1e16 Mon Sep 17 00:00:00 2001 From: Mike Knepper Date: Mon, 5 Jan 2026 17:49:43 -0600 Subject: [PATCH 8/8] Update plugin example --- docs/plugins/example.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/plugins/example.md b/docs/plugins/example.md index 9c6929d1..b288456b 100644 --- a/docs/plugins/example.md +++ b/docs/plugins/example.md @@ -131,8 +131,8 @@ from data_designer.plugins import Plugin, PluginType # Plugin instance - this is what gets loaded via entry point plugin = Plugin( - task_cls=IndexMultiplierColumnGenerator, - config_cls=IndexMultiplierColumnConfig, + task_qualified_name="data_designer_index_multiplier.plugin.IndexMultiplierColumnGenerator", + config_qualified_name="data_designer_index_multiplier.plugin.IndexMultiplierColumnConfig", plugin_type=PluginType.COLUMN_GENERATOR, emoji="🔌", ) @@ -204,8 +204,8 @@ class IndexMultiplierColumnGenerator(ColumnGenerator[IndexMultiplierColumnConfig # Plugin instance - this is what gets loaded via entry point plugin = Plugin( - task_cls=IndexMultiplierColumnGenerator, - config_cls=IndexMultiplierColumnConfig, + task_qualified_name="data_designer_index_multiplier.plugin.IndexMultiplierColumnGenerator", + config_qualified_name="data_designer_index_multiplier.plugin.IndexMultiplierColumnConfig", plugin_type=PluginType.COLUMN_GENERATOR, emoji="🔌", )