DEV Community

Cover image for Case Study: The Missing Admission Layer in Python Plugin Systems
Luca
Luca

Posted on

Case Study: The Missing Admission Layer in Python Plugin Systems

How two years of work on ImportSpy turned an import-validation problem into a runtime-contract model for modular Python systems.

Plugin architectures are interesting again.

They never really disappeared, of course. But today the same architectural idea shows up everywhere: extensible backends, developer platforms, automation systems, AI tools, agent capabilities, embedded Python runtimes, and applications that load functionality dynamically.

The implementation often starts with something deceptively simple:

import importlib

plugin = importlib.import_module("my_plugin")
Enter fullscreen mode Exit fullscreen mode

If the import succeeds, the plugin exists.

But does that mean the host should actually allow it to participate in the system?

After working on this problem for roughly two years while developing ImportSpy, I no longer think that is primarily an import problem.

I think it is an admission problem.

And that distinction changed the architecture of the project.


The starting point

Imagine a Python application that supports independently developed plugins.

At the beginning, the contract between the host and a plugin may be mostly implicit. The host expects something like:

class Plugin:
    def initialize(self, config: dict) -> None:
        ...

    def run(self) -> None:
        ...
Enter fullscreen mode Exit fullscreen mode

A loader can import the module, inspect it, and start using it.

module = importlib.import_module(plugin_name)
plugin_class = getattr(module, "Plugin")
plugin = plugin_class()
plugin.initialize(config)
plugin.run()
Enter fullscreen mode Exit fullscreen mode

This works until the system starts evolving.

A plugin developed six months later may expose a different signature. Another may depend on Python 3.12 while one deployment still runs Python 3.11. Another expects an environment variable to exist. Another relies on a dependency the host intentionally forbids. Another assumes Linux-specific behavior. Another exposes the right methods but expects capabilities that only exist in a different deployment.

All of these modules may still be perfectly valid Python modules.

Some may even import successfully.

That is where the first important distinction appears:

importable != compatible
Enter fullscreen mode Exit fullscreen mode

Python answers one question:

Can this module be imported?

The host application needs an answer to a different question:

Is this module admissible in this runtime?

Two Python modules can both be importable while only one matches the runtime contract expected by the host.


Why existing mechanisms only solve part of the problem

Python already gives us several good mechanisms for expressing software contracts.

An abstract base class can describe a required interface:

from abc import ABC, abstractmethod


class Plugin(ABC):
    @abstractmethod
    def initialize(self, config: dict) -> None:
        ...

    @abstractmethod
    def run(self) -> None:
        ...
Enter fullscreen mode Exit fullscreen mode

A Protocol can make the relationship more flexible:

from typing import Protocol


class PluginProtocol(Protocol):
    def initialize(self, config: dict) -> None:
        ...

    def run(self) -> None:
        ...
Enter fullscreen mode Exit fullscreen mode

Static typing is useful too.

But these mechanisms mostly describe structural compatibility.

The systems I was thinking about had assumptions that extended beyond Python interfaces:

Python >= 3.11
OS = Linux
API_TOKEN must exist
Plugin class must exist
run() must have the expected signature
Dependency X must be available
Dependency Y must not be imported
Deployment must expose capability Z
Enter fullscreen mode Exit fullscreen mode

At this point the compatibility problem spans several layers:

  • Python structure
  • signatures and annotations
  • environment
  • dependencies
  • interpreter characteristics
  • operating system
  • deployment assumptions
  • runtime invariants

No single Python interface mechanism naturally represents all of them.


The first design mistake: thinking about imports

The original idea behind ImportSpy was much closer to:

Validate a Python module when it is imported.

That framing is intuitive. If the module is incompatible, reject it as early as possible.

Failing early is attractive because it keeps invalid components away from deeper execution paths and improves diagnostics: the error appears near the moment the incompatible component attempts to enter the system.

But the more I worked on the project, the more I realized that importing was only one possible enforcement point.

The real concept was somewhere else.


The architectural shift: admission instead of import validation

The model eventually became:

module -> contract -> validation -> admission / rejection
Enter fullscreen mode Exit fullscreen mode

The important operation is no longer simply:

import module
Enter fullscreen mode Exit fullscreen mode

It becomes:

admit module into this runtime
Enter fullscreen mode Exit fullscreen mode

That sounds like a small semantic change. It is not.

It changes the responsibility of the system.

The module is no longer considered acceptable merely because Python knows how to load it. Instead, the host establishes an explicit architectural boundary. Before a component crosses that boundary, the system can verify whether the assumptions on both sides are compatible.

That model became the foundation of what ImportSpy is today.

ImportSpy admission flow showing a module evaluated against a declarative contract, validated by the host, then admitted or rejected.


Making architectural assumptions executable

Keeping requirements only in documentation does not solve much.

You can write:

This plugin requires Python 3.11 and Linux.
Enter fullscreen mode Exit fullscreen mode

But nothing guarantees that those assumptions are actually respected.

You can scatter checks throughout application code:

if sys.version_info < (3, 11):
    raise RuntimeError("Python 3.11+ required")

if platform.system() != "Linux":
    raise RuntimeError("Linux required")

if "API_TOKEN" not in os.environ:
    raise RuntimeError("API_TOKEN is missing")
Enter fullscreen mode Exit fullscreen mode

That works technically.

Architecturally, though, the contract becomes distributed across the implementation.

ImportSpy takes a different approach: describe the assumptions declaratively and compile them into an internal model that can be validated by the runtime.

A simplified contract might express requirements such as:

filename: plugin.py
version: "1.2.3"

functions:
  - name: initialize
    arguments:
      - name: config
        annotation: dict

classes:
  - name: Plugin
    methods:
      - name: run

runtime:
  python: ">=3.11"
  os: linux

required_environment:
  - API_TOKEN
Enter fullscreen mode Exit fullscreen mode

The exact syntax is less important than the architectural property it gives us.

The assumptions become:

  • explicit
  • inspectable
  • versionable
  • testable
  • enforceable

Most importantly, they are no longer trapped inside someone's understanding of the system.

ImportSpy's actual contracts are YAML-based and are compiled into a SpyModel, which represents the minimum acceptable contract for a module.


Compatibility should describe the minimum, not the whole runtime

One design decision turned out to be particularly important.

A contract should generally describe the minimum acceptable context, not an exact copy of the runtime.

Suppose a module requires capabilities A, B, and C, while the host provides A, B, C, D, and E.

The host should normally be compatible.

Conceptually:

required capabilities ⊆ available capabilities
Enter fullscreen mode Exit fullscreen mode

ImportSpy uses this kind of subset compatibility strategy.

The contract establishes a baseline. The runtime is allowed to provide more.

This matters in long-lived systems because exact equality creates unnecessarily fragile contracts. A new deployment capability should not invalidate every existing module. The important question is whether the assumptions required by the component remain satisfied.


Keep policy separate from implementation

Another requirement emerged while thinking about real systems.

I did not want every module to contain host-governance logic just so another system could determine whether that module was compatible.

The cleaner model is:

implementation != admission policy
Enter fullscreen mode Exit fullscreen mode

The module remains ordinary Python code.

The contract describes expectations about it.

The host decides whether those expectations are satisfied.

This separation makes the mechanism easier to reason about and also matters for legacy code and externally developed extensions: the component should not need to be rewritten simply to participate in a compatibility check.


Where should enforcement happen?

Once I stopped thinking exclusively about imports, another question appeared.

There is no single correct enforcement point.

1. Import-time validation

The advantage is obvious: failure happens extremely early.

An incompatible module is rejected before it can propagate deeper into the application lifecycle.

The trade-off is that Python imports are easier to reason about when they remain predictable and free of unnecessary behavior.

2. Controlled initialization

Another architecture creates an explicit lifecycle:

discover -> load -> validate -> initialize -> activate
Enter fullscreen mode Exit fullscreen mode

This gives the host much more control over when a component becomes active and often makes diagnostics easier to handle.

The trade-off is that Python code may already have executed during import, depending on how the module is loaded.

3. Plugin manager as admission controller

For extensible applications, this is often the cleanest conceptual model.

The plugin manager stops being only a discovery mechanism and becomes a governance layer. It can inspect the component, evaluate the contract, produce diagnostics, and decide whether the component is allowed to activate.

ImportSpy supports validation at import time as well as controlled initialization, which lets the architecture choose the boundary that fits the system rather than forcing one lifecycle on every application.

Comparison of three enforcement points for Python runtime contracts: import-time validation, controlled initialization, and plugin-manager admission control.


A concrete failure mode

Consider a host expecting this plugin interface:

class Processor:
    def process(self, payload: bytes) -> bytes:
        ...
Enter fullscreen mode Exit fullscreen mode

A new version of a plugin exposes:

class Processor:
    def process(self, payload: str) -> str:
        ...
Enter fullscreen mode Exit fullscreen mode

The module is valid Python.

The import can succeed.

The class exists.

The method exists.

A simplistic loader may accept it.

Without an admission check, the incompatibility appears later, somewhere inside an execution path that may be far removed from plugin loading.

With a contract-aware boundary, the system changes the failure path:

plugin discovered
      |
      v
contract checked
      |
      v
signature mismatch
      |
      v
REJECT
Enter fullscreen mode Exit fullscreen mode

The contract system does not magically eliminate the error.

It changes where the error is allowed to exist.

A late integration failure becomes an early compatibility failure with structured diagnostics.


Why this matters in long-lived systems

Small systems can survive on implicit knowledge surprisingly well.

Someone on the team knows:

Don't upgrade that plugin beyond version 2.

Someone else remembers:

That component requires this environment variable.

Another developer knows:

That implementation only works on the Linux deployment.

The architecture exists partly in code and partly in people's heads.

Then time passes.

People leave. Infrastructure changes. New deployments appear. Dependencies evolve. Python versions move forward.

The hidden contract is still there.

The people who understood it may not be.

This is where architectural drift becomes expensive.

The problem is not necessarily bad code. The problem is that assumptions have become invisible.

A runtime contract moves some of that knowledge out of people's heads and into something that can be inspected, versioned, tested, and enforced.

That is the part of this idea that interests me most.

Not merely catching errors.

Making architectural assumptions visible.


This became ImportSpy

This design process is what eventually shaped ImportSpy.

I have been working on it for roughly two years.

Today I describe it as a runtime contract enforcement engine for Python modules rather than simply an import validator.

ImportSpy allows developers to declare and verify structural, contextual, and execution constraints on modules. Contracts are authored in a YAML-based DSL, compiled into an internal SpyModel, and can be validated at import time or during controlled initialization.

The current model can express requirements around:

  • module structure
  • required classes and functions
  • signatures and annotations
  • runtime invariants
  • Python and operating-system constraints
  • environment requirements
  • dependency restrictions
  • deployment compatibility

The project is open source:

GitHub: https://github.com/atellaluca/ImportSpy

Documentation: https://importspy.atellaluca.com/

One thing I explicitly do not claim is sandboxing.

ImportSpy is an architectural governance tool, not a hard-security boundary. Validating a contract does not make arbitrary Python code safe to execute. Process isolation, containers, permissions, and OS-level controls still belong to the security layer.


An unexpected connection with modern AI tool systems

One reason I think this problem is becoming more relevant is that we are recreating plugin architectures in new forms.

Consider a modern system exposing tools or capabilities to an AI agent:

Host / Agent
     |
     v
Capability registry
     |
     v
Tool / Extension
Enter fullscreen mode Exit fullscreen mode

The terminology changed.

The architectural question did not.

Before the host allows an extension to participate, it still needs to understand things such as:

  • What interface does it expose?
  • What does it depend on?
  • What environment does it expect?
  • Which capabilities does it require?
  • Is this implementation compatible with this runtime?

A schema can describe the callable interface.

It does not necessarily describe the complete execution contract.

That is why I think runtime admission deserves to be considered separately from discovery.

Loading components is easy.

Deciding whether they belong in the current runtime is the harder problem.

A host or AI agent discovers tools, validates interface, environment, dependencies, capabilities, and runtime requirements, then admits or rejects the extension.


The most important lesson from the project

The biggest change in ImportSpy was not a new validator or another feature.

It was changing the question.

I originally asked:

How can I validate an import?

Today I ask:

What conditions must be true before this component is allowed to participate in the system?

That produces a much more useful architecture.

It moves the discussion from Python mechanics to system boundaries.

Once the problem is modeled that way, imports become only one possible place where policy can be enforced.


Where I would take this next

There are several directions I find interesting.

One is contract evolution. If modules and runtimes evolve independently, contracts eventually need compatibility semantics of their own.

Another is diagnostics. Rejecting a component is useful. Explaining precisely why it was rejected is much more useful.

And perhaps the most interesting direction is treating contracts as architectural artifacts that participate across the whole lifecycle:

development -> CI -> deployment -> runtime
Enter fullscreen mode Exit fullscreen mode

The same assumptions could then be checked at several stages instead of being discovered only after deployment.


How are you handling this boundary?

I suspect many mature Python systems already implement some version of this idea without calling it a runtime contract.

Maybe it lives inside a plugin manager.

Maybe it is encoded in initialization logic.

Maybe the system relies mostly on packaging metadata and Protocols.

Maybe compatibility is negotiated between components.

Or maybe the architecture deliberately avoids dynamic admission altogether.

If you maintain a large Python application, plugin platform, extensible backend, embedded runtime, or tool system, I would be interested in hearing where you draw this boundary.

Because after two years of working on ImportSpy, the part I find most interesting is no longer how to intercept an import.

It is deciding when a piece of code has earned the right to become part of the running system.

Top comments (0)