diff --git a/docs/plugins.md b/docs/plugins.md index 0c10eb92..72bd546a 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -45,7 +45,8 @@ 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. +(registration raises); duplicate plugin names (within one kind) are last-wins +with a warning logged on the `freshdata.plugins` logger. A comparator is any named callable; an exporter needs `name` + `export(report) -> str | dict`: @@ -85,8 +86,9 @@ 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`. +instantiates on first use. A broken entry point (one that fails to import, +instantiate, or register) is logged and skipped — it can never break +`import freshdata`, and the other entry points still register. Introspect what's registered: @@ -103,7 +105,10 @@ fd.registered_plugins("expert") # just experts - 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. +output type so a mistake in a plugin can't corrupt the report. Proposals for a +column the plugin was not given (an expert's own column, or a backend's frame), +and malformed proposals (e.g. a non-mapping `provenance`), are dropped the same +way. ## 3. Required metadata @@ -125,9 +130,12 @@ Two of these are **enforced**, not advisory: - **`uses_network=True` disables the plugin by default.** A network-using plugin is registered but *inactive* until you opt in with `fd.register_expert(plugin, allow_network=True)` or - `FRESHDATA_ALLOW_NETWORK_PLUGINS=1`. FreshData's own runtime never calls the - network; a network plugin is your explicit choice, disclosed in - `fd.registered_plugins()`. + `FRESHDATA_ALLOW_NETWORK_PLUGINS=1`. The environment variable is checked + each time the registry is consulted (e.g. at `fd.clean` time), so setting it + after registration takes effect — and unsetting it disables the plugin again + unless it was registered with `allow_network=True`. FreshData's own runtime + never calls the network; a network plugin is your explicit choice, disclosed + in `fd.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. diff --git a/src/freshdata/plugins.py b/src/freshdata/plugins.py index 830f78aa..40d52175 100644 --- a/src/freshdata/plugins.py +++ b/src/freshdata/plugins.py @@ -47,6 +47,8 @@ from typing import TYPE_CHECKING, Any if TYPE_CHECKING: # pragma: no cover - typing only + from collections.abc import Callable + import pandas as pd from .findings import QualityFinding @@ -107,7 +109,9 @@ def _requires(obj: object) -> tuple[str, ...]: def _max_risk(obj: object) -> str: raw = getattr(obj, "max_risk", "high") - return raw if raw in _RISK_RANK else "high" + # A non-string (possibly unhashable) value gets the same treatment as an + # unknown string: the documented default ceiling, "high". + return raw if isinstance(raw, str) and raw in _RISK_RANK else "high" def _semantic_types(obj: object) -> tuple[str, ...]: @@ -156,6 +160,12 @@ class RegisteredPlugin: max_risk: str = "high" semantic_types: tuple[str, ...] = () source: str = "explicit" # "explicit" | "entry_point" + #: True for a network-using plugin registered without ``allow_network=True`` + #: (and otherwise eligible). Its ``active`` flag then follows + #: ``FRESHDATA_ALLOW_NETWORK_PLUGINS`` as read each time the registry is + #: consulted, not just at registration. + network_blocked: bool = False + _warned: set[str] = field(default_factory=set, repr=False, compare=False) def describe(self) -> dict[str, Any]: return { @@ -187,6 +197,11 @@ def bucket(self, kind: str) -> dict[str, RegisteredPlugin]: _REGISTRY = _Registry() +_NETWORK_DISABLED_REASON = ( + "network-using plugin disabled by default; pass allow_network=True " + "or set FRESHDATA_ALLOW_NETWORK_PLUGINS=1 to enable" +) + def _register(kind: str, obj: object, *, allow_network: bool, source: str) -> RegisteredPlugin: name = _plugin_name(obj, fallback=type(obj).__name__) @@ -196,6 +211,7 @@ def _register(kind: str, obj: object, *, allow_network: bool, source: str) -> Re active = True reason: str | None = None + network_blocked = False missing = _missing_requirements(obj) if kind == "comparator" and name in _RESERVED_COMPARATOR_NAMES: active = False @@ -203,23 +219,59 @@ def _register(kind: str, obj: object, *, allow_network: bool, source: str) -> Re elif missing: active = False reason = f"requires missing dependency: {', '.join(missing)}" - elif uses_network and not _network_allowed(allow_network): - active = False - reason = ( - "network-using plugin disabled by default; pass allow_network=True " - "or set FRESHDATA_ALLOW_NETWORK_PLUGINS=1 to enable" - ) + elif uses_network and not allow_network: + # Gated on the environment variable, re-read on every registry access + # (see _refresh_network_gates) so a later opt-in takes effect. + network_blocked = True + active = _network_allowed(False) + reason = None if active else _NETWORK_DISABLED_REASON record = RegisteredPlugin( - name=name, kind=kind, obj=obj, active=active, inactive_reason=reason, - uses_network=uses_network, max_risk=max_risk, semantic_types=semantic_types, + name=name, + kind=kind, + obj=obj, + active=active, + inactive_reason=reason, + uses_network=uses_network, + max_risk=max_risk, + semantic_types=semantic_types, source=source, + network_blocked=network_blocked, ) - _REGISTRY.bucket(kind)[name] = record + bucket = _REGISTRY.bucket(kind) + previous = bucket.get(name) + if previous is not None and previous.obj is not obj: + log.warning( + "%s plugin name %r is already registered (%s, source=%s); replacing it " + "with %s (source=%s): the last registration wins", + kind, + name, + type(previous.obj).__name__, + previous.source, + type(obj).__name__, + source, + ) + bucket[name] = record log.debug("registered %s plugin %r (active=%s)", kind, name, active) return record +def _refresh_network_gates() -> None: + """Re-evaluate env-gated network plugins against the current environment.""" + allowed = _network_allowed(False) + for kind in _ENTRY_POINT_GROUPS: + for rec in _REGISTRY.bucket(kind).values(): + if rec.network_blocked: + rec.active = allowed + rec.inactive_reason = None if allowed else _NETWORK_DISABLED_REASON + + +def _sync_registry() -> None: + """Discover entry points once, then apply the current network opt-in.""" + _ensure_entry_points() + _refresh_network_gates() + + # --------------------------------------------------------------------------- # # public registration API (re-exported as fd.register_*) # # --------------------------------------------------------------------------- # @@ -291,7 +343,7 @@ def clear_plugins(kind: str | None = None) -> None: def registered_plugins(kind: str | None = None) -> list[dict[str, Any]]: """Introspect every registered plugin (active and inactive).""" - _ensure_entry_points() + _sync_registry() buckets = [kind] if kind else ["expert", "backend", "validator", "comparator", "exporter"] out: list[dict[str, Any]] = [] for k in buckets: @@ -322,13 +374,18 @@ def _ensure_entry_points() -> None: _REGISTRY._entry_points_loaded = True # set first: never retry a bad group for kind, group in _ENTRY_POINT_GROUPS.items(): for ep in _entry_points_for(group): + # Load *and* register inside the guard: malformed metadata on one + # entry point must not stop the rest of the group from registering. try: factory = ep.load() obj = factory() if callable(factory) else factory + _register(kind, obj, allow_network=False, source="entry_point") except Exception as exc: # noqa: BLE0001 - a broken plugin must not break import - log.warning("freshdata plugin entry point %r failed to load: %s", ep.name, exc) - continue - _register(kind, obj, allow_network=False, source="entry_point") + log.warning( + "freshdata plugin entry point %r failed to load: %s", + getattr(ep, "name", ep), + exc, + ) # --------------------------------------------------------------------------- # @@ -336,15 +393,32 @@ def _ensure_entry_points() -> None: # --------------------------------------------------------------------------- # +def _column_allowed(column: object, allowed: Callable[[object], bool]) -> bool: + try: + return bool(allowed(column)) + except Exception: # noqa: BLE001 - e.g. an unhashable column label + return False + + def _cap_and_stamp( - proposals: object, name: str, kind: str, max_risk: str, + proposals: object, + record: RegisteredPlugin, + kind: str, + *, + allowed_column: Callable[[object], bool], ) -> list[SemanticProposal]: - """Validate plugin output, drop over-risk proposals, and stamp provenance.""" + """Validate plugin output, drop over-risk, off-target or malformed + proposals, and stamp provenance.""" from .semantic.types import SemanticProposal # noqa: PLC0415 + name, max_risk = record.name, record.max_risk if not isinstance(proposals, (list, tuple)): - log.warning("plugin %s %r returned %s, not a list of proposals; ignored", - kind, name, type(proposals).__name__) + log.warning( + "plugin %s %r returned %s, not a list of proposals; ignored", + kind, + name, + type(proposals).__name__, + ) return [] cap = _RISK_RANK[max_risk] out: list[SemanticProposal] = [] @@ -352,13 +426,41 @@ def _cap_and_stamp( if not isinstance(p, SemanticProposal): log.warning("plugin %s %r emitted a non-proposal %r; dropped", kind, name, type(p)) continue - if _RISK_RANK.get(p.risk, 2) > cap: - log.warning("plugin %s %r emitted risk=%s above its declared max_risk=%s; dropped", - kind, name, p.risk, max_risk) + if not _column_allowed(p.column, allowed_column): + # A plugin may only propose for the column/frame it was handed; + # anything else would reach the executor as an unknown column. + # Log once per plugin: a confused plugin repeats this per column. + if "column" not in record._warned: + record._warned.add("column") + log.warning( + "plugin %s %r proposed for column %r it was not given; " + "such proposals are dropped (logged once per plugin)", + kind, + name, + p.column, + ) + continue + risk_rank = _RISK_RANK.get(p.risk, 2) if isinstance(p.risk, str) else 2 + if risk_rank > cap: + log.warning( + "plugin %s %r emitted risk=%s above its declared max_risk=%s; dropped", + kind, + name, + p.risk, + max_risk, + ) + continue + try: + provenance = { + **(dict(p.provenance) if p.provenance else {}), + "plugin": name, + "plugin_kind": kind, + } + stamped = dataclasses.replace(p, backend=f"plugin:{name}", provenance=provenance) + except Exception as exc: # noqa: BLE001 - malformed proposal must not break a clean + log.warning("plugin %s %r emitted a malformed proposal (%s); dropped", kind, name, exc) continue - provenance = {**(dict(p.provenance) if p.provenance else {}), - "plugin": name, "plugin_kind": kind} - out.append(dataclasses.replace(p, backend=f"plugin:{name}", provenance=provenance)) + out.append(stamped) return out @@ -383,7 +485,8 @@ def propose(self, series: pd.Series, info: SemanticColumnInfo) -> list[SemanticP except Exception as exc: # noqa: BLE0001 - isolate plugin failure log.warning("expert plugin %r.propose() failed: %s", self.name, exc) return [] - return _cap_and_stamp(raw, self.name, "expert", self._record.max_risk) + column = info.name + return _cap_and_stamp(raw, self._record, "expert", allowed_column=lambda c: c == column) class _SafeBackend: @@ -413,7 +516,8 @@ def propose(self, df: pd.DataFrame, ctx: SemanticContext, budget: object) -> lis except Exception as exc: # noqa: BLE0001 - isolate plugin failure log.warning("backend plugin %r.propose() failed: %s", self.name, exc) return [] - return _cap_and_stamp(raw, self.name, "backend", self._record.max_risk) + columns = df.columns + return _cap_and_stamp(raw, self._record, "backend", allowed_column=lambda c: c in columns) class _SafeValidator: @@ -488,53 +592,53 @@ def export(self, report: object) -> str | dict: def active_experts() -> tuple[_SafeExpert, ...]: """Active plugin experts, wrapped for isolation (built-ins are separate).""" - _ensure_entry_points() + _sync_registry() return tuple(_SafeExpert(rec) for rec in _REGISTRY.experts.values() if rec.active) def active_backend_names() -> tuple[str, ...]: - _ensure_entry_points() + _sync_registry() return tuple(name for name, rec in _REGISTRY.backends.items() if rec.active) def get_active_backend(name: str) -> _SafeBackend | None: - _ensure_entry_points() + _sync_registry() rec = _REGISTRY.backends.get(name) return _SafeBackend(rec) if rec is not None and rec.active else None def known_backend_names() -> tuple[str, ...]: """Registered backend names (active or not) — for the ``strict`` name check.""" - _ensure_entry_points() + _sync_registry() return tuple(_REGISTRY.backends) def active_validators() -> tuple[_SafeValidator, ...]: - _ensure_entry_points() + _sync_registry() return tuple(_SafeValidator(rec) for rec in _REGISTRY.validators.values() if rec.active) def get_active_comparator(name: str) -> _SafeComparator | None: """The comparator registered under *name*, or ``None`` (used by ER scoring).""" - _ensure_entry_points() + _sync_registry() rec = _REGISTRY.comparators.get(name) return _SafeComparator(rec) if rec is not None and rec.active else None def known_comparator_names() -> tuple[str, ...]: """Registered comparator names (active or not) — for config validation.""" - _ensure_entry_points() + _sync_registry() return tuple(_REGISTRY.comparators) def get_active_exporter(name: str) -> _SafeExporter | None: - _ensure_entry_points() + _sync_registry() rec = _REGISTRY.exporters.get(name) return _SafeExporter(rec) if rec is not None and rec.active else None def active_exporter_names() -> tuple[str, ...]: - _ensure_entry_points() + _sync_registry() return tuple(name for name, rec in _REGISTRY.exporters.items() if rec.active) diff --git a/tests/test_plugin_isolation.py b/tests/test_plugin_isolation.py new file mode 100644 index 00000000..b308475f --- /dev/null +++ b/tests/test_plugin_isolation.py @@ -0,0 +1,401 @@ +"""Plugin isolation: malformed plugins/proposals, duplicate names, late network opt-in. + +Regression tests for #297 (plugin failures escaping isolation), #298 (duplicate +names replaced silently) and #299 (``FRESHDATA_ALLOW_NETWORK_PLUGINS`` set after +registration never activating the plugin). +""" + +from __future__ import annotations + +import dataclasses +import importlib.metadata as md +import logging + +import pandas as pd +import pytest + +import freshdata as fd +from freshdata.plugins import ( + active_backend_names, + active_experts, + clear_plugins, + get_active_backend, + registered_plugins, +) +from freshdata.semantic.scoring import make_proposal + +_ENV = "FRESHDATA_ALLOW_NETWORK_PLUGINS" + + +@pytest.fixture(autouse=True) +def _clean_registry(monkeypatch): + monkeypatch.delenv(_ENV, raising=False) + clear_plugins() + yield + clear_plugins() + + +def _prop(column: str, raw: str = "x", proposed: str = "y"): + return make_proposal( + column=column, + raw_value=raw, + proposed_value=proposed, + issue_type="email_format", + expert="p", + base_confidence=0.99, + evidence=(), + count=1, + rationale="r", + ) + + +def _frame() -> pd.DataFrame: + return pd.DataFrame({"a": ["x", "q", "y", "z"]}) + + +def _plugin_warnings(caplog) -> list[str]: + return [ + r.getMessage() + for r in caplog.records + if r.name == "freshdata.plugins" and r.levelno >= logging.WARNING + ] + + +def _install_fake_entry_points(monkeypatch, factories): + class _EP: + def __init__(self, name, factory): + self.name = name + self._factory = factory + + def load(self): + return self._factory + + def fake_entry_points(*, group=None, **_kw): + return [_EP(n, f) for n, f in factories.get(group, [])] + + monkeypatch.setattr(md, "entry_points", fake_entry_points) + + +# --------------------------------------------------------------------------- # +# #297 - failures degrade safely +# --------------------------------------------------------------------------- # + + +class WrongColumnExpert: + name = "wrong_column" + issue_type = "email_format" + + def applies(self, info): + return True + + def propose(self, series, info): + return [_prop("no_such_column")] + + +class BadProvenanceExpert(WrongColumnExpert): + name = "bad_provenance" + + def propose(self, series, info): + return [dataclasses.replace(_prop(info.name), provenance="oops")] + + +class RaisingExpert(WrongColumnExpert): + name = "raising" + + def propose(self, series, info): + raise RuntimeError("plugin bug") + + +class TestProposalIsolation: + def test_wrong_column_proposal_is_dropped_and_clean_completes(self, caplog): + fd.register_expert(WrongColumnExpert()) + df = _frame() + with caplog.at_level(logging.WARNING, logger="freshdata.plugins"): + out = fd.clean(df, semantic_mode="auto", verbose=False) + assert list(out["a"]) == list(df["a"]) + messages = [m for m in _plugin_warnings(caplog) if "not given" in m] + assert len(messages) == 1 # logged once per plugin, not per column + assert "wrong_column" in messages[0] + + def test_wrong_column_logged_once_across_columns(self, caplog): + fd.register_expert(WrongColumnExpert()) + df = pd.DataFrame({"a": ["x", "q", "y", "z"], "b": ["x", "q", "y", "z"]}) + with caplog.at_level(logging.WARNING, logger="freshdata.plugins"): + fd.clean(df, semantic_mode="auto", verbose=False) + fd.clean(df, semantic_mode="auto", verbose=False) + assert len([m for m in _plugin_warnings(caplog) if "not given" in m]) == 1 + + def test_expert_proposal_for_its_own_column_is_kept(self): + fd.register_expert(WrongColumnExpert()) + (expert,) = active_experts() + + class _Info: + name = "a" + + expert._record.obj.propose = lambda s, i: [_prop(i.name)] + kept = expert.propose(pd.Series(["x"], name="a"), _Info()) + assert [p.column for p in kept] == ["a"] + assert kept[0].backend == "plugin:wrong_column" + assert kept[0].provenance["plugin"] == "wrong_column" + + def test_malformed_provenance_is_dropped_and_clean_completes(self, caplog): + fd.register_expert(BadProvenanceExpert()) + df = _frame() + with caplog.at_level(logging.WARNING, logger="freshdata.plugins"): + out = fd.clean(df, semantic_mode="auto", verbose=False) + assert list(out["a"]) == list(df["a"]) + assert any( + "malformed proposal" in m and "bad_provenance" in m for m in _plugin_warnings(caplog) + ) + + def test_raising_propose_is_still_isolated(self, caplog): + fd.register_expert(RaisingExpert()) + df = _frame() + with caplog.at_level(logging.WARNING, logger="freshdata.plugins"): + out = fd.clean(df, semantic_mode="auto", verbose=False) + assert list(out["a"]) == list(df["a"]) + assert any("propose() failed" in m for m in _plugin_warnings(caplog)) + + def test_backend_proposal_for_unknown_column_is_dropped(self, caplog): + class OffFrameBackend: + name = "offframe" + max_risk = "high" + + def propose(self, df, ctx, budget): + return [_prop("no_such_column")] + + fd.register_backend(OffFrameBackend()) + df = _frame() + with caplog.at_level(logging.WARNING, logger="freshdata.plugins"): + out = fd.clean( + df, semantic_mode="auto", semantic_backends=("offframe",), verbose=False + ) + assert list(out["a"]) == list(df["a"]) + assert any("not given" in m and "offframe" in m for m in _plugin_warnings(caplog)) + + +class TestMalformedMetadata: + def test_unhashable_max_risk_falls_back_to_default(self): + class Unhashable: + name = "unhashable" + max_risk = ["low"] + + def applies(self, info): + return True + + def propose(self, series, info): + return [] + + fd.register_expert(Unhashable()) + (rec,) = registered_plugins("expert") + assert rec["max_risk"] == "high" + assert rec["active"] is True + + def test_malformed_entry_point_is_skipped_and_rest_register(self, monkeypatch, caplog): + class Bad: + name = "bad" + max_risk = "low" + + @property + def uses_network(self): + raise RuntimeError("broken metadata") + + def applies(self, info): + return True + + def propose(self, series, info): + return [] + + class Good: + name = "good" + max_risk = "low" + + def applies(self, info): + return True + + def propose(self, series, info): + return [] + + _install_fake_entry_points( + monkeypatch, {"freshdata.experts": [("bad", Bad), ("good", Good)]} + ) + with caplog.at_level(logging.WARNING, logger="freshdata.plugins"): + first = [p["name"] for p in registered_plugins()] + assert first == ["good"] + assert [p["name"] for p in registered_plugins()] == ["good"] + assert any("'bad'" in m and "failed to load" in m for m in _plugin_warnings(caplog)) + + def test_issue_repro_unhashable_entry_point_metadata(self, monkeypatch): + class Bad: + name = "bad" + max_risk = ["low"] + + def applies(self, i): + return True + + def propose(self, s, i): + return [] + + class Good(Bad): + name = "good" + max_risk = "low" + + _install_fake_entry_points( + monkeypatch, {"freshdata.experts": [("bad", Bad), ("good", Good)]} + ) + names = [p["name"] for p in registered_plugins()] + assert "good" in names + + +# --------------------------------------------------------------------------- # +# #298 - duplicate names warn +# --------------------------------------------------------------------------- # + + +class _DupA: + name = "dup" + + def applies(self, i): + return True + + def propose(self, s, i): + return [] + + +class _DupB(_DupA): + pass + + +class TestDuplicateNames: + def test_duplicate_name_warns_and_last_wins(self, caplog): + second = _DupB() + with caplog.at_level(logging.WARNING, logger="freshdata.plugins"): + fd.register_expert(_DupA()) + assert _plugin_warnings(caplog) == [] + fd.register_expert(second) + messages = _plugin_warnings(caplog) + assert len(messages) == 1 + assert "'dup'" in messages[0] + assert "_DupA" in messages[0] and "_DupB" in messages[0] + (expert,) = active_experts() + assert expert._record.obj is second + + def test_reregistering_same_object_does_not_warn(self, caplog): + plugin = _DupA() + with caplog.at_level(logging.WARNING, logger="freshdata.plugins"): + fd.register_expert(plugin) + fd.register_expert(plugin) + assert _plugin_warnings(caplog) == [] + + def test_same_name_in_different_kinds_does_not_warn(self, caplog): + class Validator: + name = "dup" + + def validate(self, df, policy, ctx): + return [] + + with caplog.at_level(logging.WARNING, logger="freshdata.plugins"): + fd.register_expert(_DupA()) + fd.register_validator(Validator()) + assert _plugin_warnings(caplog) == [] + + +# --------------------------------------------------------------------------- # +# #299 - network opt-in honoured at use time +# --------------------------------------------------------------------------- # + + +class _NetExpert: + name = "net" + uses_network = True + max_risk = "low" + + def __init__(self): + self.calls: list[str] = [] + + def applies(self, info): + self.calls.append(info.name) + return False + + def propose(self, series, info): + return [] + + +class _NetBackend: + name = "netbackend" + uses_network = True + max_risk = "low" + + def propose(self, df, ctx, budget): + return [] + + +class TestLateNetworkOptIn: + def test_env_set_after_registration_activates_plugin(self, monkeypatch): + plugin = _NetExpert() + fd.register_expert(plugin) + (rec,) = registered_plugins("expert") + assert rec["active"] is False + assert _ENV in rec["inactive_reason"] + + monkeypatch.setenv(_ENV, "1") + fd.clean(_frame(), semantic_mode="auto", verbose=False) + (rec,) = registered_plugins("expert") + assert rec["active"] is True + assert rec["inactive_reason"] is None + assert "a" in plugin.calls + + def test_network_plugin_stays_inactive_without_env(self): + plugin = _NetExpert() + fd.register_expert(plugin) + fd.clean(_frame(), semantic_mode="auto", verbose=False) + assert active_experts() == () + (rec,) = registered_plugins("expert") + assert rec["active"] is False + assert plugin.calls == [] + + @pytest.mark.parametrize("value", ["", "0", "true", "yes"]) + def test_only_env_value_one_opts_in(self, monkeypatch, value): + fd.register_expert(_NetExpert()) + monkeypatch.setenv(_ENV, value) + assert active_experts() == () + + def test_unsetting_env_deactivates_env_gated_plugin(self, monkeypatch): + monkeypatch.setenv(_ENV, "1") + fd.register_expert(_NetExpert()) + assert len(active_experts()) == 1 + monkeypatch.delenv(_ENV) + assert active_experts() == () + (rec,) = registered_plugins("expert") + assert rec["active"] is False + + def test_explicit_allow_network_is_not_env_dependent(self, monkeypatch): + fd.register_expert(_NetExpert(), allow_network=True) + assert len(active_experts()) == 1 + monkeypatch.setenv(_ENV, "0") + assert len(active_experts()) == 1 + + def test_backend_accessors_honour_late_env(self, monkeypatch): + fd.register_backend(_NetBackend()) + assert get_active_backend("netbackend") is None + assert active_backend_names() == () + monkeypatch.setenv(_ENV, "1") + assert get_active_backend("netbackend") is not None + assert active_backend_names() == ("netbackend",) + + def test_env_does_not_override_missing_dependency(self, monkeypatch): + class NetNeedsThing(_NetExpert): + name = "netneeds" + requires = ("a_package_that_does_not_exist_xyz",) + + monkeypatch.setenv(_ENV, "1") + fd.register_expert(NetNeedsThing()) + assert active_experts() == () + (rec,) = registered_plugins("expert") + assert "missing dependency" in rec["inactive_reason"] + + def test_entry_point_network_plugin_honours_late_env(self, monkeypatch): + _install_fake_entry_points(monkeypatch, {"freshdata.experts": [("net", _NetExpert)]}) + assert active_experts() == () + monkeypatch.setenv(_ENV, "1") + assert len(active_experts()) == 1