Entry points reference
data-product-forge discovers external functionality through Python entry-points. Each line in your pyproject.toml registers one plugin under one group. This table lists, for CLI 0.18.1 and data-product-forge-custom-scaffold 0.4.1, which code walks each group:
| Group | Walked by |
|---|---|
fluid_build.commands | cli/bootstrap.py (lazily: a plugin is imported when its command runs) |
fluid_build.extension_validators | cli/validate.py |
fluid_build.extension_schemas | fluid_build/extension_schemas.py (the fluid forge copilot) |
fluid_build.apply_hooks | cli/apply.py |
fluid_build.custom_scaffolds | Not walked by the CLI. The data-product-forge-custom-scaffold engine looks a plugin up by entry-point name when fluid custom-scaffold runs. fluid plugins labels the group NOT DISPATCHED for that reason. |
fluid_build.providers | providers/__init__.py (provider discovery for fluid apply and --provider) |
fluid_build.validators | cli/validate.py, through plugin_manager.collect_validator_findings |
fluid_build.catalog_adapters | cli/publish.py, through plugin_manager.dispatch_catalog_adapters |
fluid_build.iac_providers | iac/registry.py (the IaC emitter) |
fluid plugins also lists fluid_build.modeling_techniques, fluid_build.source_adapters and fluid_build.llm_providers, which feed the fluid forge copilot (modeling techniques, catalog source adapters, and third-party LLM providers for --llm-provider). They are governed by the same allow/block policy as the groups above.
The CLI-level groups
These hook into specific CLI subcommands and take functions.
| Group | Hooks into | Plugin shape | Failure mode |
|---|---|---|---|
fluid_build.commands | cli/bootstrap.py::register_core_commands (CLI startup) | register(subparsers) -> None | Plugin load or register() exception → WARN log, CLI continues |
fluid_build.extension_validators | cli/validate.py::_run_extension_validators (during fluid validate) | validate(extensions_block: dict, errors: list[str]) -> None | Plugin exception → folded into ValidationResult.errors, validate continues |
fluid_build.extension_schemas | fluid_build/extension_schemas.py::iter_extension_schemas (during fluid forge generate + pre-emit validate) | get_extension_schema(fluid_version: str | None = None) -> dict | Plugin exception → skipped (logged by type, redacted); discovery fails open to {} |
fluid_build.apply_hooks | cli/apply.py::_run_apply_hooks (during fluid apply) | hook(contract_dir: Path, contract: dict, errors: list[str]) -> None | Plugin exception → recorded as error, apply aborts unless --force-pattern-drift |
fluid_build.commands — add CLI subcommands
For when your plugin needs its own fluid <name> top-level command.
Signature
import argparse
def register(subparsers: argparse._SubParsersAction) -> None:
"""Called at CLI bootstrap. Add your subparser(s) to the subparsers group."""
p = subparsers.add_parser("my-command", help="What it does")
p.add_argument("--option", help="...")
p.set_defaults(func=_run_my_command)
def _run_my_command(args, logger):
"""Your command's body."""
logger.info("running my-command")
return 0 # exit code
Registration
[project.entry-points."fluid_build.commands"]
my-command = "my_pkg.cli:register"
The value is module:callable — pointing at the register function, not at a class.
Discovery + failure
- Discovered at CLI startup by
register_core_commands()incli/bootstrap.py. - If
ep.load()raises (broken import, missing dependency) → logged at WARNING (prefix:"Failed to load CLI plugin <name>: ..."), CLI continues. - If
register(subparsers)raises → same WARN-and-continue. - Plugin exception text is redacted with the global secret-scanner before being logged.
Source
fluid_build/cli/bootstrap.py — search for fluid_build.commands to find the loop.
fluid_build.extension_validators — validate contract.extensions.*
For when you've defined a custom sub-key of contract.extensions (e.g. contract.extensions.customScaffold) and want to validate it as part of fluid validate.
This is different from the fluid_build.validators group (see below) — extension-validators run on a sub-key of contract.extensions, validators run on the whole contract.
Signature
from typing import Any, Dict, List
def validate(extensions: Dict[str, Any], errors: List[str]) -> None:
"""Called during `fluid validate`. `extensions` is contract.extensions.
Inspect your own sub-key, append error strings to `errors`. Other plugins'
sub-keys are ignored. The CLI namespaces your errors as
`extensions.<ep-name>: <message>` automatically.
"""
my_block = extensions.get("myKey")
if my_block is None:
return # not opted in to this extension — pass through
# ... your validation logic
if missing_field:
errors.append("required field 'foo' missing")
Registration
[project.entry-points."fluid_build.extension_validators"]
myKey = "my_pkg.validation:validate"
The entry-point name is the sub-key your validator claims. The error namespace in CLI output is extensions.<name>: ....
Discovery + failure
- Discovered at the start of
fluid validateby_run_extension_validators()incli/validate.py. - Short-circuits if
contract.extensionsis absent or empty — your validator is never called. - If
ep.load()orvalidate()raises → captured, redacted, recorded as a single error in the ValidationResult (extensions: validator <name> raised: <message>).fluid validatecontinues with other validators. - Plugin-supplied error messages are pre-redacted before reaching
ValidationResult.errors.
Example
The data-product-forge-custom-scaffold package uses this group to validate the contract.extensions.customScaffold block — see its pyproject.toml.
fluid_build.extension_schemas — make contract.extensions.* AI-native
The companion to extension_validators (new in CLI 0.8.9). Where the validator checks a contract.extensions.<key> block, the schema provider describes it: it returns the JSON-Schema for the block so the fluid forge copilot can generate a valid block and pre-validate it before writing the contract.
Without this, the core contract schema treats extensions as additionalProperties: true — the copilot has no idea what shape your sub-key takes, so AI-authored contracts never include it. Register a schema provider and your extension is handled like a first-class contract field, with zero per-extension CLI changes — the entry-point group is the entire contract.
Signature
from typing import Any, Optional
def get_extension_schema(fluid_version: Optional[str] = None) -> dict[str, Any]:
"""Return the draft-07 JSON-Schema for your contract.extensions.<key> block.
`fluid_version` is the target contract version (e.g. "0.7.4") so a provider
can return a version-specific schema; ignore it if your schema is stable.
The dict describes the data *under* the extension key.
"""
return {
"$schema": "http://json-schema.org/draft-07/schema#",
"type": "object",
"required": ["..."],
"properties": {"...": {"type": "string"}},
}
A zero-argument provider (get_extension_schema()) is also accepted.
Registration
[project.entry-points."fluid_build.extension_schemas"]
myKey = "my_pkg.validation:get_extension_schema"
The entry-point name is the contract.extensions sub-key the schema describes — match the name you registered under fluid_build.extension_validators so the same block is both generated and validated.
Discovery + failure
- Enumerated by
iter_extension_schemas()influid_build/extension_schemas.py. Thefluid forgecopilot injects every discovered schema into the modeler prompt (so the LLM can propose a valid block undersource_summary.proposed_extensions), keeps only proposals that validate against the schema, and runs the matchingextension_validatorson the emitted block before the contract is written — so a malformed block is repaired, not shipped. - Per-plugin isolation: a provider that fails to load, raises, or returns a non-
dictis skipped — logged by exception type only (so a secret-bearing message can't leak) — and never drops the other providers. Discovery fails open to{}. - A no-op when no provider is installed: the copilot prompt is byte-identical to before, so contracts that don't use extensions are unaffected.
Example
data-product-forge-custom-scaffold advertises the schema for contract.extensions.customScaffold from the same module that validates it — see its validation.py. The SDK ships a reference iter_extension_schemas() helper (from fluid_sdk import iter_extension_schemas) for plugin authors and conformance tests.
fluid_build.apply_hooks — runtime invariant checks at fluid apply
For checks that fire during apply, not during validate. State that depends on the runtime environment (env vars, filesystem, lockfile presence) goes here.
Signature
from pathlib import Path
from typing import Any, Dict, List
def hook(contract_dir: Path, contract: Dict[str, Any], errors: List[str]) -> None:
"""Called during `fluid apply`, after contract load, before any provider.
`contract` is a deep copy — mutations here do NOT affect the rest of apply.
Append messages to `errors` to fail the apply; leave it empty to pass.
Errors are pre-redacted before logging.
"""
# ... your logic
if violation_detected:
errors.append("my-hook: explain what's wrong and how to fix it")
Registration
[project.entry-points."fluid_build.apply_hooks"]
my-hook = "my_pkg.hook:hook"
The entry-point name surfaces in the error namespace (apply hook 'my-hook' raised: ...) and in any tooling that reads importlib.metadata.entry_points(group='fluid_build.apply_hooks').
Discovery + failure
- Discovered at the start of
fluid applyby_run_apply_hooks()incli/apply.py. - Each hook receives a fresh
copy.deepcopy(contract). A buggy or malicious hook cannot corrupt the contract for the rest of apply or for other hooks. - If
ep.load()or your hook function raises → captured asapply hook '<name>' raised: <exception>and added to the errors list. Apply continues evaluating other hooks before deciding to abort. - Plugin exception text is pre-redacted before reaching logs or errors.
- After all hooks run, if any errors were appended:
- Without
--force-pattern-drift→fluid applyaborts with exit code 1. - With
--force-pattern-drift→ errors are downgraded to WARNINGs and apply continues. Audit-friendly: the WARN line appears in stdout/log.
- Without
What hooks know about the target environment
Since CLI 0.11.0: apply hooks receive the resolved --env value via backward-compatible signature dispatch (pluggy's argnames opt-in model). A legacy 3-parameter hook (contract_dir, contract, errors) is called unchanged; a hook declaring a keyword-compatible env parameter or **kwargs receives env=<value>; a hook with a 4th positional slot or *args receives it positionally. A callable whose signature cannot be introspected falls back to the legacy 3-argument call. The contract remains post-overlay (env values baked in).
import os
def hook(contract_dir, contract, errors, env=None):
# env == "prod" when `fluid apply --env prod` ran; None when --env was omitted.
if env == "prod" and not os.environ.get("PROD_DEPLOY_KEY"):
errors.append("my-hook: PROD_DEPLOY_KEY is not set")
The value is the argparse-validated --env string, or None when the flag was omitted, so an env-aware hook must handle None.
Two other signals are still available, and are what a hook that has to keep the legacy 3-parameter signature reaches for:
- Runner-set convention env var. Have your CI runner / deploy script
export DEPLOY_ENV=...(or your team's chosen name) before invokingfluid apply. The hook reads that env var. This is the pattern used in the apply-hook example and journey. Note the failure mode theenvparameter avoids: if CI forgets to export the var, the guard silently passes. - Branch on post-overlay contract values. If your contract carries an env-distinguishing field (e.g.
metadata.deploy_targetset differently per env in the overlay), the hook can read it. Brittle — couples to contract content.
Example
The apply-hook-prod-key-guard example is a fully-runnable hook. The apply-hook journey is the full walkthrough.
The role-level groups (for plugin classes)
These register plugin classes so the runtime knows which subclass corresponds to which user-facing name.
| Group | Plugin class | Discovered by |
|---|---|---|
fluid_build.custom_scaffolds | CustomScaffold subclass | The data-product-forge-custom-scaffold entry-point resolver, when fluid custom-scaffold runs |
fluid_build.providers | InfraProvider subclass | The provider registry (providers/__init__.py) |
fluid_build.validators | Validator subclass | fluid validate |
fluid_build.catalog_adapters | CatalogAdapter subclass | fluid publish |
fluid_build.iac_providers | IaC provider | The IaC emitter |
Registration shape
All four follow the same pattern — the value is module:ClassName (pointing at the class, not an instance, not a function):
[project.entry-points."fluid_build.custom_scaffolds"]
hello = "hello_scaffold.scaffold:HelloScaffold"
[project.entry-points."fluid_build.validators"]
steward-required = "my_validators.steward:StewardRequired"
Multiple registrations per group are fine — both steward-required and cost-center-required in the same package register independently.
When to use which
| You want to… | Use |
|---|---|
Register a CustomScaffold plugin discovered via source.kind: entrypoint in a contract | fluid_build.custom_scaffolds |
Register a Validator plugin that runs at fluid validate | fluid_build.validators |
Register an InfraProvider for fluid apply to dispatch to | fluid_build.providers |
Register a CatalogAdapter that fluid publish runs | fluid_build.catalog_adapters |
Several groups from one package
# pyproject.toml — one package registering against eight groups
# CLI-level extension points (functions)
[project.entry-points."fluid_build.commands"]
my-cmd = "my_pkg.cli:register"
[project.entry-points."fluid_build.extension_validators"]
myKey = "my_pkg.ext_validate:validate"
[project.entry-points."fluid_build.extension_schemas"]
myKey = "my_pkg.ext_validate:get_extension_schema"
[project.entry-points."fluid_build.apply_hooks"]
my-hook = "my_pkg.hook:check"
# Role-level plugin registration (classes)
[project.entry-points."fluid_build.custom_scaffolds"]
my-scaffold = "my_pkg.scaffold:MyScaffold"
[project.entry-points."fluid_build.validators"]
my-rule = "my_pkg.validator:MyValidator"
[project.entry-points."fluid_build.providers"]
my-cloud = "my_pkg.provider:MyProvider"
[project.entry-points."fluid_build.catalog_adapters"]
my-catalog = "my_pkg.catalog:MyCatalogAdapter"
Each line is independent: register only the groups your plugin needs. A package can register against multiple groups (e.g. a scaffold + its associated validator both ship from the same plugin).
Inspecting what's registered
fluid plugins lists installed plugins per group with their allow/block status. It is the primary way to confirm a plugin registered:
fluid plugins # human table, grouped by role
fluid plugins list --role provider
fluid plugins list --detailed # loads ALLOWED plugins to show declared metadata
fluid plugins list --json # machine-readable, every group keyed
🔌 Installed FLUID plugins (by role):
command (1)
• generate-custom-scaffold allowed
from=data-product-forge-custom-scaffold 0.4.1
custom_scaffold (1) — NOT DISPATCHED
• hello allowed
from=hello-scaffold 0.1.0
provider (4)
• aws allowed
from=data-product-forge 0.18.1
...
custom_scaffold: declared and governed, but this build has no dispatch site —
plugins registered under it are never invoked.
(Output from CLI 0.18.1 with the scaffold engine and the quickstart plugin installed, trimmed.) The --json form emits an object keyed by group (apply_hook, catalog, command, custom_scaffold, extension_schema, extension_validator, iac_provider, llm_provider, modeling_technique, provider, source_adapter, validator), each value a list of {name, group, allowed, dispatched, distribution}. dispatched is false for custom_scaffold: the CLI itself never walks that group, and the engine behind fluid custom-scaffold does, so the label does not mean the plugin is inert.
If you'd rather not shell out, the importlib.metadata one-liner is an equivalent fallback:
python -c "
from importlib.metadata import entry_points
for group in ('fluid_build.commands', 'fluid_build.custom_scaffolds',
'fluid_build.validators', 'fluid_build.apply_hooks',
'fluid_build.extension_validators', 'fluid_build.extension_schemas',
'fluid_build.providers', 'fluid_build.catalog_adapters',
'fluid_build.iac_providers'):
eps = list(entry_points(group=group))
if eps:
print(f'{group}:')
for ep in eps:
print(f' {ep.name} ({ep.value})')
"
Either is your sanity check after pip install — if a plugin doesn't show up, the entry-point didn't register (most often: forgot pip install -e . after editing pyproject.toml).
Plugin governance
The CLI gates the code-executing entry-point groups before load with two operator env vars (the scaffold engine applies the same policy to fluid_build.custom_scaffolds itself):
FLUID_PLUGINS_ALLOWLIST— comma-separated entry-point names; if set, only these load.FLUID_PLUGINS_BLOCKLIST— comma-separated entry-point names; these never load.
A blocked plugin's code never executes. Governed groups: providers, validators, catalog_adapters, commands, apply_hooks, extension_schemas, extension_validators, modeling_techniques, source_adapters, iac_providers, llm_providers, and custom_scaffolds (enforced by the engine). fluid plugins surfaces each plugin's allow/block status.
An opt-in compat gate, FLUID_PLUGIN_STRICT_COMPAT=1, makes the CLI refuse to register a provider plugin whose declared requires_cli (a PEP 440 specifier from the SDK's PluginMetadata) the running CLI version does not satisfy. Default (unset) is warn-only. fluid plugins marks such a plugin INCOMPATIBLE (requires_cli). See the trust model for the full operator story.
Trust model
Plugins are uncontained Python loaded into the CLI process. The CLI defends against three failure modes automatically:
- Crashes — plugin loads and invocations are wrapped in
try/except. - Contract mutation (apply hooks only) — each hook receives
copy.deepcopy(contract). - Credential leak in error messages — plugin exception text is pre-scrubbed with
redact_secret_textbefore reaching logs.
What it does not defend against: arbitrary os.system calls inside plugin code, infinite loops (no per-plugin timeout), resource exhaustion. Trust = pip trust. Full statement: Trust model.
Source
- Bootstrap loop:
fluid_build/cli/bootstrap.py::register_core_commands - Extension validators loop:
fluid_build/cli/validate.py::_run_extension_validators - Extension schema discovery:
fluid_build/extension_schemas.py::iter_extension_schemas - Apply hooks loop:
fluid_build/cli/apply.py::_run_apply_hooks - Tests pinning all three:
tests/test_cli_plugin_hooks.py