| description | Writing a plugin for api-verity-lab: six versioned entry-point groups, discovered by any installation that pip-installs your distribution. |
|---|
This project has six versioned entry-point groups. A distribution declaring one is discovered by every installation that pip-installs it — there is no registry to register with, because Python already has a distribution channel and inventing a second index would mean operating one.
| Group | What it adds | Discovered by |
|---|---|---|
apiverity.specs |
A contract format the loader can recognise | apiverity.plugins.registry |
apiverity.rules |
A governance rule pack | apiverity.rules.packs_registry |
apiverity.checks |
A check the security engine runs | apiverity.plugins.registry |
apiverity.generators |
A fuzz-case generator | apiverity.plugins.registry |
apiverity.exporters |
A result exporter | apiverity.plugins.registry |
apiverity.transports |
A client for a protocol the tool can speak | apiverity.plugins.registry |
apiverity plugins lists what an installation has. apiverity rules --packs
lists rule packs specifically, with the distribution each came from.
examples/plugins/apiverity-house-rules
is a complete rule pack as its own package: pyproject.toml, the rules, and its
own tests. It is installed into a throwaway virtualenv by
tests/unit/test_example_plugin.py, which then runs the CLI there and asserts
the rules actually fire — so the example cannot rot into one that no longer
loads.
pip install -e examples/plugins/apiverity-house-rules
apiverity rules --packs
apiverity validate openapi.yaml # HOUSE-* findings appear alongside the built-ins# pyproject.toml
[project]
name = "apiverity-house-rules"
dependencies = ["api-verity-lab>=0.2,<1.0"]
[project.entry-points."apiverity.rules"]
house-rules = "house_rules:pack"# house_rules/__init__.py
from apiverity.core.model import Finding, Protocol, Service, Severity
from apiverity.rules.policy import RuleDefinition, RulePack
def no_camel_case_paths(service: Service) -> list[Finding]:
return [
Finding(
rule_id="HOUSE-PATH-CASE",
severity=Severity.WARN,
message=f"path '{op.path}' is not kebab-case",
operation_key=op.key,
hint="rename the segment, or drop this rule if your house style differs",
)
for op in service.operations
if any(c.isupper() for c in (op.path or ""))
]
PACK = RulePack(
name="house-rules",
version="1.0.0",
description="House style rules the engine does not ship.",
rules=(
RuleDefinition(
rule_id="HOUSE-PATH-CASE",
severity=Severity.WARN,
rationale="Mixed path casing makes an API read as several APIs stitched together.",
remediation="Rename the segment to kebab-case.",
protocols=frozenset({Protocol.OPENAPI}),
check=no_camel_case_paths,
),
),
)
def pack() -> RulePack:
return PACKThe entry point may be the pack or a callable returning one; discover() calls
it if it is callable. A factory is the better habit, because it defers the work
until something asks.
The catalogue holds built-in rules to this, and a pack that skips it teaches the wrong shape.
A rationale and a remediation. A rule that says only "no" is a rule that
gets switched off, and it takes its neighbours with it. rationale says why
this matters; remediation says what to do instead.
A protocol set it can actually be satisfied in. A path-casing rule run
against a GraphQL schema fires on nothing or on everything, and neither is
information. Declare protocols narrowly.
Both directions, tested. A check that fires on everything and a check that fires on nothing are equally useless, and only running them against a passing contract and a failing one tells the two apart. The example's tests do exactly that, and so does the test in this repository that holds the example to it.
A rule id nobody else owns. PolicyEngine refuses to run two rules with one
id — it cannot explain both — so prefix yours. apiverity rules --packs
reports conflicts as data before the engine refuses them, naming which packs
clash.
A pack that raises on import is named, not skipped. apiverity rules --packs --json lists it under failed, with the exception. A team whose pack
raises should find that out rather than believe it is running.
A pack whose entry point returns something that is not a RulePack is
ignored. That is deliberate: apiverity.rules is also how this project
publishes its own breaking-change catalogue, which is a dict, and reporting
"not a RulePack" about the project's own entry point would be a false alarm on
every installation.
The plugin API is versioned. apiverity.plugins.PLUGIN_API_VERSION is the
v1 contract; apiverity.plugins.v2 adds a PluginManifest with declared
capabilities. A v2 plugin's manifest states which host versions it supports, and
the loader refuses one that does not support the host rather than importing it
and finding out.
For v2 plugins, apiverity.plugins.conformance checks a plugin object against
the contract before anybody installs it: a valid manifest, capabilities that map
to methods that exist, and safe failure modes.
from apiverity.plugins.conformance import conformance_report
report = conformance_report(MyPlugin())
assert report["conforms"], report["failures"]apiverity plugins scaffold my-plugin --out ./my-pluginwrites a conforming v2 layout — manifest, implementation and a conformance test — so a first plugin starts from a working shape rather than from this page.
There is nothing to publish to. Push the package to PyPI, or to a private
index, or install it from a path; discovery is importlib.metadata either way.
Provenance in the listing is the distribution name and version, which is what a
reader needs to answer "where did this rule come from" — and exactly the field a
hand-rolled registry would have had to invent.