Plugins¶
multicz ships with a small plugin system that lets external code
participate in the release pipeline without forking the core. Plugins
can:
- gate a
multicz bump(block the bump on policy violations), - enrich the rendered changelog and release notes with sections of their own,
- surface advice in
multicz status/multicz planso the user knows about a pending gate before it fires.
A plugin is a Python package that registers a class under the
multicz.plugins entry-point group.
There is no privileged loader path: built-in plugins use the exact
same mechanism as third-party ones.
Activation¶
Discovery alone is not activation. Even a freshly-installed plugin
stays dormant until the project's multicz.toml declares it:
The empty section is the minimum opt-in — it means "run with all defaults". Three states are possible per plugin:
| state | meaning |
|---|---|
active |
[plugins.<name>] is declared and enabled is not false. The runner invokes every hook. |
disabled |
section declared, but enabled = false. Opt-out without deleting the section. |
inactive |
plugin discovered (entry point registered) but no [plugins.<name>] section. Hooks are not called. |
multicz plugins lists every discovered plugin with its current
state:
┃ Plugin ┃ Status ┃ Module ┃ Config section ┃
│ deprecation │ inactive │ multicz.plugins.builtin.… │ (not in multicz.toml — add [plugins.deprecation] to activate) │
│ newsy │ active │ newsy │ [plugins.newsy] directory='changes.d', … │
Same command with --output json for CI consumption — each row
carries status, configured, and enabled fields plus the
plugin's resolved module / class / config dict.
Tip
A plugin you don't recognize in multicz plugins came in via a
transitive dependency. Until you add [plugins.<name>], it
can't affect your bumps — so the safe default for an unfamiliar
plugin is to leave it inactive.
Hooks¶
A plugin is a class implementing three hooks. The runner calls them in this order during a bump:
sequenceDiagram
participant CLI as multicz bump
participant R as Plugin runner
participant P as Plugin
CLI->>R: plan = build_plan()
R->>P: status_lines(ctx)
P-->>R: ["3 due for removal, …"]
R->>P: post_plan(ctx)
P-->>R: [Violation(error, …)]
Note over R: aborts if any Severity.error
R->>P: enrich_changelog(ctx, component)
P-->>R: [ChangelogEntry(section="Removed", …)]
CLI->>CLI: render changelog + release notes
Every hook receives the same PluginContext:
| field | content |
|---|---|
ctx.config |
the parsed multicz config (whole file — read other sections if you need them) |
ctx.repo |
absolute Path to the repository root |
ctx.plan |
the computed Plan; iterate ctx.plan or look up ctx.plan.bumps[component] |
ctx.plugin_config |
only the [plugins.<name>] slice of the user's config, defaulted to {} if absent |
post_plan¶
Called once after the plan is computed and before any file is written.
Return a list of Violation objects:
Severity.erroraborts the bump (exit code 1).Severity.warningprints but lets the bump proceed.Severity.infois purely informational.
A plugin that raises is caught by the runner, logged as a
RuntimeWarning, and treated as if it returned []. Other plugins
still run.
enrich_changelog¶
Called per component during changelog / release-notes rendering.
Return ChangelogEntry objects; each one becomes a
section in the rendered markdown. Sections returned with the same
title (section="Removed", etc.) merge with whatever the
conventional-commit renderer produced — no duplicate H3.
status_lines¶
Called by multicz status and multicz plan. Each returned string is
printed verbatim under the bump table, prefixed with a magenta arrow.
Rich markup ([bold], [red], …) is supported — escape literal
brackets with \[ if you mean them literally.
Data types¶
Violation¶
@dataclass(frozen=True, slots=True)
class Violation:
severity: Severity # "error" | "warning" | "info"
message: str
plugin: str
file: Path | None = None
line: int | None = None
component: str | None = None
ChangelogEntry¶
@dataclass(frozen=True, slots=True)
class ChangelogEntry:
section: str # e.g. "Removed", "Deprecated"
component: str
lines: tuple[str, ...] = () # one rendered bullet per line
Built-in plugins¶
deprecation¶
Enforces a removal policy on @deprecated(since=..., remove_in=...)
markers (and # DEPRECATED since=.. remove_in=.. comments). Behaviour:
post_plan— every marker whoseremove_in ≤ next_versionraises a violation. By defaultSeverity.error; flip to a warning withmode = "warning"during initial rollout.enrich_changelog— emits aDeprecatedsection for markers newly added in this release window and aRemovedsection for markers whose deadline matches the planned version.status_lines— one summary line per component:deprecation[api 1.0.0 → 2.0.0]: 1 added, 1 due for removal, 1 upcoming.
Config keys:
[plugins.deprecation]
# Refuse the bump on past-due markers ("error", default) or just warn ("warning").
mode = "error"
# Override the scan globs. When omitted, falls back to each component's `paths`.
scan = ["src/**/*.py"]
# Different globs per component, when a single project mixes scan needs.
[plugins.deprecation.scan_per_component]
api = ["src/api/**/*.py"]
worker = ["src/worker/**/*.py"]
Runnable example: examples/deprecation-plugin/.
Writing a plugin¶
The fastest path is to subclass BasePlugin, which provides no-op
defaults so you only implement the hooks you care about:
from multicz.plugins import (
BasePlugin,
ChangelogEntry,
Severity,
Violation,
)
class NewsyPlugin(BasePlugin):
name = "newsy" # (1)!
def post_plan(self, ctx) -> list[Violation]:
return [] if self._fragments(ctx) else [
Violation(
severity=Severity.error,
message="no changelog fragments — add one under changes.d/",
plugin=self.name,
)
]
def enrich_changelog(self, ctx, component) -> list[ChangelogEntry]:
return [
ChangelogEntry(section=section, component=component, lines=lines)
for section, lines in self._render(ctx).items()
]
def status_lines(self, ctx) -> list[str]:
return [f"newsy: {len(self._fragments(ctx))} fragment(s)"]
namemust match the entry-point key inpyproject.tomland the[plugins.<name>]section a consumer writes in theirmulticz.toml. Pick something short, kebab-case, namespace-y if collisions are likely.
Register it via the entry-point group — this is what makes it discoverable to any multicz install:
[project]
name = "newsy"
dependencies = ["multicz"]
[project.entry-points."multicz.plugins"]
newsy = "newsy:NewsyPlugin"
That's the whole API surface. Multicz handles discovery, config slicing, hook ordering, and exception isolation; the plugin only has to implement the hooks it cares about.
Runnable example, full code + README: examples/custom-plugin/.
Reference¶
- Plugin protocol + dataclasses:
multicz.plugins.protocol - Runner + entry-point discovery:
multicz.plugins.runner,multicz.plugins.registry - Built-in implementations:
multicz.plugins.builtin.* - CLI integration points:
multicz plugins(this listing),multicz status/plan/bump(call sites for the three hooks).