Skip to content

symfonic.diagnostics.inspection.probe

probe

Read the adopter's raw configuration once, keeping what normalisation loses.

The T2.2.3 adapter already answers most of what a migration needs to know -- which legacy key won, which native target it landed on, which values were chosen rather than defaulted. What it does not do is survive a bad configuration: it raises, because a framework that normalises a contradictory configuration into a working one has lied to somebody. A diagnostic has the opposite obligation, so this module runs the adapter behind a boundary that turns its exceptions and warnings back into data.

The pass is deliberately permissive about the environment: extras are granted, the filesystem effect is granted, a parent scope is assumed. Those three facts are properties of a deployment, not of a configuration, and answering "is this configuration coherent?" must not depend on which extras happen to be installed on the machine running the inspection. The environment questions are asked separately, against the normalised result, by the missing-extra and lifecycle inspectors.

ConfigProbe dataclass

ConfigProbe(supplied: bool, raw_keys: tuple[str, ...] = (), normalized: NormalizedConfig | None = None, diagnostics: tuple[CompatibilityDiagnostic, ...] = (), comparisons: Mapping[str, FieldComparison] = dict(), errors: tuple[tuple[str, str], ...] = (), advisories: tuple[tuple[str, str], ...] = (), rejected: str | None = None)

What one adapter pass established about a raw configuration.

legacy_keys property

legacy_keys: tuple[tuple[str, str], ...]

The supplied keys that are legacy spellings, with their targets.

family

family(family_id: str) -> Any | None

Return a normalised capability or service family, or None.

Source code in src/symfonic/diagnostics/inspection/probe.py
def family(self, family_id: str) -> Any | None:
    """Return a normalised capability or service family, or ``None``."""
    if self.normalized is None:
        return None
    source = (
        self.normalized.capabilities
        if family_id.startswith("cap.")
        else self.normalized.services
    )
    return source.get(family_id)

flatten_keys

flatten_keys(values: Mapping[str, Any], prefix: str = '') -> tuple[str, ...]

Every dotted path present in a raw configuration mapping.

Container paths are kept alongside leaves because the legacy surface has keys at both levels (agent.model is a key; so is agent.model.max_tokens), and dropping either would under-report.

Source code in src/symfonic/diagnostics/inspection/probe.py
def flatten_keys(values: Mapping[str, Any], prefix: str = "") -> tuple[str, ...]:
    """Every dotted path present in a raw configuration mapping.

    Container paths are kept alongside leaves because the legacy surface has
    keys at both levels (``agent.model`` is a key; so is
    ``agent.model.max_tokens``), and dropping either would under-report.
    """
    paths: list[str] = []
    for key, value in values.items():
        path = f"{prefix}.{key}" if prefix else str(key)
        paths.append(path)
        if isinstance(value, Mapping):
            paths.extend(flatten_keys(value, path))
    return tuple(sorted(paths))

probe_config

probe_config(config: Mapping[str, Any]) -> ConfigProbe

Run the legacy adapter over config without letting it raise.

.. warning::

Not thread-safe. Capturing the adapter's advisories needs :func:warnings.catch_warnings, which swaps the process-wide filter for the duration of the call: a concurrent thread can lose a warning it would otherwise have seen, or see one it had filtered out. Harmless for the CLI, which is single-threaded and does this once; a served process calling :func:~symfonic.diagnostics.inspection.inspect_migration from a worker pool should serialise the calls.

Source code in src/symfonic/diagnostics/inspection/probe.py
def probe_config(config: Mapping[str, Any]) -> ConfigProbe:
    """Run the legacy adapter over *config* without letting it raise.

    .. warning::

       Not thread-safe. Capturing the adapter's advisories needs
       :func:`warnings.catch_warnings`, which swaps the **process-wide** filter
       for the duration of the call: a concurrent thread can lose a warning it
       would otherwise have seen, or see one it had filtered out. Harmless for
       the CLI, which is single-threaded and does this once; a served process
       calling :func:`~symfonic.diagnostics.inspection.inspect_migration` from a
       worker pool should serialise the calls.
    """
    if not config:
        return ConfigProbe(supplied=False)

    raw_keys = flatten_keys(config)
    with warnings.catch_warnings(record=True) as caught:
        warnings.simplefilter("always")
        try:
            result = FrameworkConfigAdapter.from_mapping(dict(config))
        except ConfigurationError as exc:
            errors = _parse_rules(str(exc))
            return ConfigProbe(
                supplied=True,
                raw_keys=raw_keys,
                errors=errors or (("XV-00", str(exc)),),
            )
        except Exception as exc:  # noqa: BLE001 -- any rejection is a finding
            return ConfigProbe(
                supplied=True,
                raw_keys=raw_keys,
                rejected=f"{type(exc).__name__}: {exc}",
            )
        advisories = tuple(
            rule
            for entry in caught
            if issubclass(entry.category, UserWarning)
            for rule in _parse_rules(str(entry.message))
        )

    return ConfigProbe(
        supplied=True,
        raw_keys=raw_keys,
        normalized=result.normalized,
        diagnostics=result.diagnostics,
        comparisons=dict(result.comparisons),
        advisories=tuple(sorted(set(advisories))),
    )