The plugin model¶
Why clickwork's plugin system is entry-point based, how discovery works conceptually, and how the local-wins rule plays out.
The shape¶
Plugins are regular Python packages. They contribute commands by
declaring an entry point in the clickwork.commands group of their
pyproject.toml.
When create_cli() runs, clickwork:
- Iterates
importlib.metadata.entry_points(group="clickwork.commands")and wraps each in aLazyEntryPointCommandkeyed by the entry-point name (the LHS —deployabove). - Reads the local
commands_dir(when it exists and auto mode is active) and registers every file that exposes acliattribute, keyed by the Click command's.nameattribute (with fallback to the filename stem only if.nameis unset). - Overlays directory commands on top of entry-point commands, so local files win any name collision. clickwork emits an INFO log when a local file shadows an installed command — visibility depends on the host's logging setup though (see the caveat below).
Caveat on visibility of these log messages. Discovery runs
during create_cli() — often at module import time, before the
host application has configured logging. clickwork attaches a
NullHandler on its own logger at import time, which suppresses
the "no handlers" complaint AND also disables Python's
logging.lastResort stderr fallback (because callHandlers walks
the logger chain and counts the NullHandler as a handler). The
practical effect: discovery-time records of any severity go to
the NullHandler (a no-op) and propagate to root, where they reach
whatever the host has installed — if anything has been installed
yet. In default / not-yet-configured setups, they're effectively
invisible.
Reliable options, in order of preference:
- Pass
strict=Truetocreate_cli()— discovery failures (duplicate names, broken imports, missingcliattr) become aClickworkDiscoveryErrorraised at CLI construction time, completely independent of logging config. - Configure logging before
create_cli()— callsetup_logging()or attach a root handler at module import time, so records emitted during discovery have somewhere to go.
Why entry points¶
Three alternatives got rejected in the 0.x cycle:
- A central registry config file (e.g.
plugins.tomllisting which packages contribute). Rejected because it's an extra thing to keep in sync on every plugin install, and it turns plugin discovery into "did someone update the config" instead of "did someone install the package." - Directory scanning (look at
site-packages/*/plugin.json). Rejected because it's coupled to filesystem layout — breaks on editable installs, zipped installs, and namespace packages. - Manual registration (
@cli.register_plugin(X)at runtime). Rejected because it requires the main CLI to know about every plugin, defeating the purpose.
Entry points are the Python ecosystem's native plugin mechanism.
pip install registers them; pip uninstall unregisters them; the
tooling already knows how to introspect them.
To see the entry points a distribution declares, read the
distribution's *.dist-info/entry_points.txt directly, or use the
programmatic API:
import importlib.metadata
for ep in importlib.metadata.entry_points(group="clickwork.commands"):
print(ep.name, "->", ep.value, "from", ep.dist.name)
(pip show -f <package> lists installed files but does not parse
entry-point metadata, so it's not the right tool for this question.)
No per-CLI scoping today¶
The entry-point group is clickwork.commands — a single global group
for every clickwork-built CLI. Every CLI running in the same Python
environment sees every plugin published under this group. There is
no .<cli-name> suffix or other per-CLI scoping in the current
implementation.
The practical consequences:
- When authoring a plugin, choose command names that are unlikely to collide with other plugins or with local command-directory files in target CLIs.
- Within-mechanism duplicates — two plugins both registering the
same entry-point name, or two directory files producing the same
Click command name — are a bug. clickwork keeps the first-loaded
one (directory scan sorted alphabetically; entry points in
importlib.metadataiteration order), emits a warning, and strict mode promotes the duplicate to a hard error viaClickworkDiscoveryError. - Cross-mechanism "collisions" — a local
commands/foo.pywith the same name as an installed-plugin entry point — are the intentional shadowing feature: the local command wins, clickwork logs at INFO (not WARNING) so you can tell the shadowing happened, and strict mode explicitly does NOT treat this as a failure. See the "Why local wins on collision" section below. - If you're planning to ship an ecosystem of distinct CLIs in the
same venv, be deliberate about namespacing command names at
design time (
projectctl-deploy,dataops-deploy, not justdeploy,deploy).
Per-CLI scoping is a credible future feature (it'd scope a group
name like clickwork.commands.projectctl), but it's not shipped.
Don't publish plugins under a scoped group name today — clickwork
won't read them.
Why local wins on collision¶
Scenario: a plugin you installed six months ago exposes a command
named deploy. You later write a local commands/deploy.py because
your project's deploy story diverged. clickwork picks the local file.
Rationale:
- Local code is what the project maintainer is actively editing. A plugin winning would silently shadow work-in-progress.
- Plugins are easy to replace; hand-written code is not. If a
plugin's
deployno longer fits,pip uninstall+rmis one command; rewriting a local command to match a plugin's shape is weeks. - The override is visible.
projectctl deploy --helpshows the local file's docstring, not the plugin's. You can tell by reading the help which one ran.
See plugins reference for the exact discovery algorithm, including strict mode behaviour and diagnostic hooks.