Skip to content

Hardening notes

pluginkit started as a teaching reimplementation. This page records what was done to move it past a toy, and why each item matters in a real plugin host.

Wrapper exception safety

The single most important fix. A wrapper holds control across its yield, so if an inner implementation raises, the wrapper must still be resumed - otherwise any try/finally or with it holds would leak.

The dispatch loop runs the inner implementations inside a try, then unwinds the started wrappers in reverse, throwing the exception into each one. A wrapper may clean up and re-raise, or swallow the exception by returning a value. Only after every wrapper has been resumed is the exception re-raised (if still pending). See HookCaller._teardown.

Covered by test_wrapper_cleanup_runs_when_inner_impl_raises and test_wrapper_can_suppress_inner_exception.

Two malformed-wrapper edges are handled the same way. A wrapper that returns before yielding raises the documented RuntimeError rather than leaking a bare StopIteration. A wrapper that yields twice is closed, and if its own cleanup raises, that error carries the contract violation as its cause and the remaining wrappers are still unwound explicitly instead of being left to garbage collection.

Fail-fast validation

Registration and typed caller resolution turn silent misbehaviour into loud errors:

  • unknown hook - an implementation whose name matches no spec raises PluginValidationError (unless marked optional).
  • unknown argument - an implementation that declares a parameter the spec does not have raises PluginValidationError. Without this, a typo like def greet(self, nam) would silently never receive its value, because the kwarg filter would simply drop it.
  • contradictory order - an implementation cannot be both tryfirst and trylast.
  • typed spec identity - caller(spec) accepts only the exact extension point registered with that manager, not a foreign same-named function.

Plugin lifecycle

A real host needs to remove and disable plugins, not just add them:

  • unregister(name) drops a plugin and all its implementations, including when a plugin does so from inside its own historic replay during registration;
  • set_blocked(name) removes it and refuses future registration (including via entry-point discovery);
  • is_registered, get_plugin, get_name, get_hookcallers, and plugin_names cover inspection.

Resilient discovery

load_entrypoints isolates each plugin load. A broken third-party plugin raises a PluginValidationError that names it, or is skipped entirely with ignore_errors=True, so one bad package cannot block all discovery. load_entrypoints_report provides resilient discovery with structured loaded, already-loaded, blocked, and failed outcomes while retaining original exceptions.

Thread-safe mutation

Registry mutations are guarded by a re-entrant lock, so plugins can be loaded from multiple threads. Hook calls are intentionally lock-free; see Lifecycle and validation for the rationale.

Spec-combination validation

A historic spec cannot also be firstresult (replay has no single winner); the manager rejects the combination when specs are added, rather than failing mysteriously at call time.

Packaging quality

  • zero runtime dependencies - the library is standard-library only;
  • ships a py.typed marker so downstreams get the types;
  • a curated public API in pluginkit/__init__.py with __version__;
  • strict ruff + mypy + pyright, and a test suite covering the happy paths, the error paths, and the examples.

What is deliberately still out of scope

See Differences from pluggy for the features pluginkit does not implement and when you should reach for pluggy instead.