p-e-w/heretic · error · ValueError

Plugin '{name}' does not export a class named '{class_name}'

Error message

Plugin '{name}' does not export a class named '{class_name}'

What it means

load_plugin() resolved the plugin module but getattr(module, class_name) returned nothing class-like. The library throws this because the plugin name promises a class (after ':' for file plugins or after the last '.' for import plugins) that the module does not actually define. It guards against silently returning a function, variable, or None as a 'plugin class'.

Source

Thrown at src/heretic/plugin.py:78

) -> type[T]:
    """
    Load a plugin class from either a filesystem `.py` file or a fully-qualified Python import path.
    Also checks that the class exists in the module and that it
    subclasses the correct Plugin subclass (e.g Scorer).

    Accepted forms:
    - `path/to/plugin.py:MyPluginClass` (relative or absolute): load `MyPluginClass`
      from that file.
    - `fully.qualified.module.MyPluginClass`: import the module and load the class.
    """

    def validate_class(module: ModuleType, class_name: str) -> type[Any]:
        """
        Checks that the module actually exports the class as claimed and returns the class.
        """
        obj = getattr(module, class_name, None)
        if not inspect.isclass(obj):
            raise ValueError(
                f"Plugin '{name}' does not export a class named '{class_name}'"
            )
        return obj

    # Common user trap with filepath imports.
    if name.endswith(".py"):
        raise ValueError(
            "You must append the plugin class name to the filepath like this: path/to/plugin.py:ClassName"
        )

    # File path with explicit class name, e.g. "C:\\path\\plugin.py:MyPlugin".
    if ":" in name:
        file_path, class_name = name.rsplit(":", 1)
        if not file_path.endswith(".py") or not class_name:
            raise ValueError(
                "File-based plugin must use the form 'path/to/plugin.py:ClassName'"
            )

View on GitHub (pinned to bedb94ef11)

Solutions

  1. Open the plugin file/module and confirm a class with the exact name (after ':' or the last '.') exists.
  2. Fix the class name in your config/CLI argument to match the definition, including case.
  3. If the class is defined in another module, import it into the plugin module or point load_plugin at the module that truly defines it.
  4. If you exported a function/constant instead, wrap it in a proper subclass of the expected base class.

Example fix

# before
heretic --scorer my_scorers.py:KeywrodRate
# after
heretic --scorer my_scorers.py:KeywordRate
Defensive patterns

Strategy: validation

Validate before calling

import importlib, inspect
def plugin_class_exists(dotted: str) -> bool:
    mod_name, cls = dotted.rsplit(".", 1)
    obj = getattr(importlib.import_module(mod_name), cls, None)
    return inspect.isclass(obj)

Type guard

def is_plugin_class(obj: object) -> bool:
    return inspect.isclass(obj)

Try / catch

try:
    cls = load_plugin(name, Scorer)
except ValueError as e:
    print(f"bad plugin name '{name}': {e}")

Prevention

When it happens

Trigger: Calling load_plugin('path/to/plugin.py:MyScorer', Scorer) where myscorer.py defines no MyScorer; a typo in the class name segment; the class is imported under a different name (e.g. `from x import Scorer as Foo`); or an import-path plugin like 'pkg.module.Scorer' where module only exports a function or dict named Scorer.

Common situations: Renaming a plugin class after it was referenced in a TOML config; copy-pasting an example plugin name; the plugin file exporting the class conditionally (e.g. behind `if TYPE_CHECKING` or re-exported without __all__); case-sensitivity typos (myscorer vs MyScorer).

Related errors


AI-assisted analysis of p-e-w/heretic@bedb94ef11 (2026-08-29). Data as JSON: /api/errors/a23718980fb8d7f2. Report an issue: GitHub.