p-e-w/heretic · error · ImportError

Could not load plugin '{name}' (invalid module spec)

Error message

Could not load plugin '{name}' (invalid module spec)

What it means

importlib.util.spec_from_file_location() returned no spec or no loader, so the plugin file cannot be turned into a module. load_plugin() raises this ImportError early rather than crashing with an opaque AttributeError later.

Source

Thrown at src/heretic/plugin.py:115

        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)
        if module is None:
            spec = importlib.util.spec_from_file_location(module_name, plugin_path)
            if spec is None or spec.loader is None:
                raise ImportError(
                    f"Could not load plugin '{name}' (invalid module spec)"
                )

            module = importlib.util.module_from_spec(spec)

            # Cache before executing to match normal import semantics and allow
            # circular imports. If execution fails, remove the entry.
            sys.modules[module_name] = module
            try:
                spec.loader.exec_module(module)
            except Exception:
                sys.modules.pop(module_name, None)
                raise

        plugin_cls = validate_class(module, class_name)
    # Fully-qualified import path, e.g "heretic.scorers.keyword_rate.KeywordRate".
    else:
        if "." not in name:

View on GitHub (pinned to bedb94ef11)

Solutions

  1. Confirm plugin_path.is_file() is True right before loading (race conditions are the usual cause).
  2. Verify the Python runtime supports SourceFileLoader for this path (standard CPython does for .py).
  3. Re-check that the path contains no invalid characters for the loader and retry with a plain, local .py file.

Example fix

# before (blind call)
plugin_cls = load_plugin(args.scorer, Scorer)
# after
target = Path(args.scorer.split(':',1)[0]).resolve()
if not target.is_file():
    raise FileNotFoundError(f"missing plugin file: {target}")
plugin_cls = load_plugin(args.scorer, Scorer)
Defensive patterns

Strategy: validation

Validate before calling

from pathlib import Path
def assert_loadable(name: str) -> None:
    target = Path(name.rsplit(":", 1)[0]).resolve()
    if not target.is_file():
        raise FileNotFoundError(target)

Try / catch

try:
    cls = load_plugin(name, Scorer)
except ImportError as e:
    logging.error("plugin load failed: %s", e)
    raise

Prevention

When it happens

Trigger: spec_from_file_location returning None for the resolved plugin_path — typically a nonexistent file slipping through, an unsupported extension (non-.py already filtered, but e.g. broken symlinks or special files), or a loader mismatch on unusual filesystem entries.

Common situations: Files added to a cache/overlay that disappeared before import; permission or filesystem oddities in containers; Python builds lacking the source-file loader for the given path type.

Related errors


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