Writing plugins¶
FreshData has one plugin mechanism for five extension points:
| Kind | What it does | Runs in |
|---|---|---|
| expert | proposes value repairs for one column (distinct value → repair) | fd.clean(..., semantic_mode=...) |
| backend | proposes over the whole frame; opted into by name | fd.clean(..., semantic_backends=(...)) |
| validator | read-only checks that append findings | fd.validate(...) |
| comparator | a named similarity (a, b) -> [0, 1] for entity resolution |
ComparisonLevel(kind=<name>) |
| exporter | renders a report into another format | fd.export(report, format=<name>) |
Custom blocking rules need no plugin interface: BlockingRule(sql=...)
already accepts arbitrary DuckDB predicates — SQL is the extension point.
The hard rule: a plugin can only propose or validate. It never touches the
DataFrame. Expert/backend proposals flow through the same policy gate
(freshdata.semantic.policy.decide) and the executor's byte-identity guard as
the built-ins, so a plugin can never change a protected (id / target /
preserve) column, and never force an auto-apply. Validators are read-only.
1. Registering a plugin¶
Two ways, same registry.
Explicit (scripts, notebooks):
import freshdata as fd
from my_pkg import MyExpert
fd.testing.expert_contract(MyExpert()) # verify the contract first
fd.register_expert(MyExpert())
fd.register_backend(...), fd.register_validator(...),
fd.register_comparator(...), and fd.register_exporter(...) work the same
way. Comparator names may not shadow the built-in comparison kinds
(registration raises); duplicate plugin names are last-wins with a warning.
A comparator is any named callable; an exporter needs name +
export(report) -> str | dict:
class InitialsComparator:
name = "initials"
def __call__(self, a: str, b: str) -> float: ...
fd.testing.comparator_contract(InitialsComparator())
fd.register_comparator(InitialsComparator())
level = ComparisonLevel("name", kind="initials", weight=1.0)
Safety: a comparator that raises skips the field (never distorts the score);
output is clamped to [0, 1]. An exporter that returns the wrong type raises
at the fd.export call site.
Entry points (installed packages — discovered automatically):
# your package's pyproject.toml
[project.entry-points."freshdata.experts"]
my_expert = "my_pkg:MyExpert"
[project.entry-points."freshdata.backends"]
my_backend = "my_pkg:MyBackend"
[project.entry-points."freshdata.validators"]
my_validator = "my_pkg:MyValidator"
[project.entry-points."freshdata.comparators"]
my_comparator = "my_pkg:MyComparator"
[project.entry-points."freshdata.exporters"]
my_exporter = "my_pkg:MyExporter"
The value points at a class (or any zero-arg factory) that FreshData
instantiates on first use. A broken entry point is logged and skipped — it can
never break import freshdata.
Introspect what's registered:
fd.registered_plugins() # every plugin, active and inactive
fd.registered_plugins("expert") # just experts
2. The proposal / finding schema¶
- Experts and backends build proposals with
freshdata.semantic.scoring.make_proposal(...), which returns aSemanticProposal(scores confidence and derives risk for you). - Validators build
freshdata.QualityFinding.create(...)records.
Anything else you return is dropped with a warning — the registry validates the output type so a mistake in a plugin can't corrupt the report.
3. Required metadata¶
Every plugin declares:
| Attribute | Meaning | Default if omitted |
|---|---|---|
name |
unique registry name (also the report attribution) | class name |
issue_type (experts) |
scoring/risk profile to reuse | — |
semantic_types |
column kinds the plugin claims (declaration) | () |
max_risk |
ceiling: "low" / "medium" / "high" |
"high" |
uses_network |
does it make network calls? | False |
requires |
optional dependency module names | () |
Two of these are enforced, not advisory:
max_riskis a hard ceiling. Any proposal scored above it is dropped before the gate sees it. Declare the truth.uses_network=Truedisables the plugin by default. A network-using plugin is registered but inactive until you opt in withfd.register_expert(plugin, allow_network=True)orFRESHDATA_ALLOW_NETWORK_PLUGINS=1. FreshData's own runtime never calls the network; a network plugin is your explicit choice, disclosed infd.registered_plugins().
If requires names a package that isn't installed, the plugin is registered but
inactive, with the reason recorded — no ImportError at clean time.
4. Lifecycle & safety¶
- Failures degrade safely. Every call into a plugin (
applies,propose,validate,warm_up) is wrapped: an exception disables that plugin for the run and is logged — the clean still completes. - Provenance is automatic. An applied plugin proposal shows up in the report
with
model_id="semantic:<issue>:plugin:<name>"andmetadata["plugin"]set, so a plugin repair is never anonymous in the audit trail. - No mutation.
propose/validatemust not modify the series/frame they are handed. The contract tests check this.
5. Contract tests¶
Run the matching contract test before you register — it raises AssertionError
with an actionable message on the first violation:
fd.testing.expert_contract(MyExpert())
fd.testing.semantic_backend_contract(MyBackend())
fd.testing.validator_contract(MyValidator())
They verify the protocol methods and return types, well-formed metadata
(max_risk in range, uses_network a bool, iterable requires/semantic_types),
that the plugin does not mutate its input, that experts/backends only propose for
the column/frame they were given, and that nothing exceeds the declared
max_risk.
6. Full worked examples¶
Runnable, contract-passing examples live in the repo:
examples/plugins/custom_expert/acronym_expert.py— expand acronyms in categorical columnsexamples/plugins/custom_backend/keyword_backend.py— strip a shared prefix frame-wide, budget-awareexamples/plugins/custom_validator/min_rows_validator.py— flag too-few-rows and constant columnsexamples/plugins/custom_comparator/initials_comparator.py— initials-agreement ER comparatorexamples/plugins/custom_exporter/markdown_exporter.py— Markdown clean-report exporter
7. Security & privacy expectations¶
- Keep plugins deterministic and offline unless you truly need otherwise; network plugins are off by default for a reason.
- A plugin sees your data. Vet third-party plugins the same way you'd vet any dependency — they run in-process.
- Plugins cannot bypass the gate, the protected-column guard, the risk ceiling, or the no-mutation rule. If you find a way to, that's a bug — please report it.