p-e-w/heretic · error · ValueError

File-based plugin must use the form 'path/to/plugin.py:Class

Error message

File-based plugin must use the form 'path/to/plugin.py:ClassName'

What it means

A name containing ':' was given, but either the part before ':' does not end in '.py' or the class part after ':' is empty. load_plugin() throws this to enforce the strict 'path/to/plugin.py:ClassName' syntax for file-based plugins.

Source

Thrown at src/heretic/plugin.py:93

        """
        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'"
            )

        plugin_path = Path(file_path)
        if not plugin_path.is_absolute():
            plugin_path = Path.cwd() / plugin_path
        plugin_path = plugin_path.resolve()

        if not plugin_path.is_file():
            raise ImportError(f"Plugin file '{plugin_path}' does not exist")

        # We're writing directly to the sys.modules dict,
        # so the typical restrictions on module names
        # (no dots, slashes, etc.) don't apply.
        module_name = f"heretic_plugin_{plugin_path}"

        # Reuse already-loaded modules to avoid re-executing the plugin on repeated loads.
        module = sys.modules.get(module_name)

View on GitHub (pinned to bedb94ef11)

Solutions

  1. Make sure the path segment ends with '.py'.
  2. Ensure a non-empty class name follows the ':'.
  3. Verify with Path(file).is_file() that the target exists before running.

Example fix

# before
scorer = "plugins/my_scorer.py:"
# after
scorer = "plugins/my_scorer.py:MyScorer"
Defensive patterns

Strategy: validation

Validate before calling

import re
FILE_PLUGIN_RE = re.compile(r"^.+\.py:.+$")
def valid_file_plugin(name: str) -> bool:
    return ":" in name and bool(FILE_PLUGIN_RE.match(name))

Try / catch

try:
    cls = load_plugin(name, Scorer)
except ValueError:
    sys.exit("use path/to/plugin.py:ClassName (non-empty .py path and class name)")

Prevention

When it happens

Trigger: load_plugin('plugins/my_scorer.txt:MyScorer', ...) with a non-.py extension; load_plugin('plugins/my_scorer.py:', ...) with an empty class name; Windows-style drive letters only in the segment (rare, handled by rsplit(':',1) but a truncated path like 'C:plugin.py:Foo' can slip through).

Common situations: Pointing the config at a compiled/other file type; leaving a trailing ':' after deleting the class name; typos like '..py' or '.pyy'.

Related errors


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