Skip to content

symfonic.diagnostics.inspection

inspection

Offline migration inspection (T4.3.4, REQ-S4.3).

symfonic doctor audits a running configuration: it constructs an agent and asks the process about itself. This package answers the questions a migration asks instead, and answers them from text alone -- raw configuration, a directory of source, and a mapping of environment variables.

Nine questions, one pass:

============================ ================================================= legacy-configuration which supplied keys are legacy spellings, and the native target each one feeds legacy-pin whether a legacy generation override is declared, and whether this release honors or rejects it capability-conflict which cross-capability rules the configuration breaks -- reported, not raised missing-extra which optional distributions the configuration commits to that this install lacks lifecycle-risk which state a restart, a replica, or a swept directory will lose implicit-default which consequential values arrived by default rather than by decision deprecated-import which imported symbols have a documented new owner platform-security where the production auth gate will not fire architecture-violation which imports reach past what the package declares ============================ =================================================

Usage::

from symfonic.diagnostics.inspection import (
    InspectionContext, inspect_migration, render_migration_report,
)

context = InspectionContext(config=raw, project_root=Path("."))
inspection = inspect_migration(context)
print(render_migration_report(inspection, context))

or, from a shell, symfonic doctor --offline ..

InspectionContext dataclass

InspectionContext(config: Mapping[str, Any] = dict(), package_version: str | None = None, project_root: Path | None = None, environ: Mapping[str, str] = dict(), available_roots: frozenset[str] | None = None, framework_root: Path | None = None, max_source_files: int = 5000)

The inputs one offline inspection may read.

Every field is injectable so a caller -- a test, a CI job, a reviewer inspecting somebody else's checkout -- gets the same answer the adopter gets, without depending on the inspecting process's own environment.

for_target classmethod

for_target(*, project_root: Path | None = None, config: Mapping[str, Any] | None = None, environ: Mapping[str, str] | None = None, package_version: str | None = None) -> InspectionContext

Build a context describing the inspected deployment.

environ defaults to empty rather than to os.environ. The replaced from_process did the latter, which quietly turned every platform-security answer into a statement about the machine running the inspection -- and the caller has no way to notice, because a laptop with no production marker set produces exactly the silence a clean production deployment would.

Source code in src/symfonic/diagnostics/inspection/context.py
@classmethod
def for_target(
    cls,
    *,
    project_root: Path | None = None,
    config: Mapping[str, Any] | None = None,
    environ: Mapping[str, str] | None = None,
    package_version: str | None = None,
) -> InspectionContext:
    """Build a context describing the *inspected* deployment.

    ``environ`` defaults to empty rather than to ``os.environ``. The
    replaced ``from_process`` did the latter, which quietly turned every
    platform-security answer into a statement about the machine running the
    inspection -- and the caller has no way to notice, because a laptop with
    no production marker set produces exactly the silence a clean production
    deployment would.
    """
    return cls(
        config=dict(config or {}),
        project_root=project_root,
        environ=dict(environ or {}),
        package_version=package_version,
    )

resolved_framework_root

resolved_framework_root() -> Path

The directory containing the symfonic package.

src/ in a checkout, site-packages/ in an installed environment; only the symfonic subtree under it is ever scanned.

Source code in src/symfonic/diagnostics/inspection/context.py
def resolved_framework_root(self) -> Path:
    """The directory *containing* the ``symfonic`` package.

    ``src/`` in a checkout, ``site-packages/`` in an installed environment;
    only the ``symfonic`` subtree under it is ever scanned.
    """
    if self.framework_root is not None:
        return self.framework_root
    import symfonic

    return Path(symfonic.__file__).resolve().parent.parent

resolved_roots

resolved_roots() -> frozenset[str]

Installed optional import roots, probed once if not supplied.

Source code in src/symfonic/diagnostics/inspection/context.py
def resolved_roots(self) -> frozenset[str]:
    """Installed optional import roots, probed once if not supplied."""
    if self.available_roots is not None:
        return self.available_roots
    return detect_available_roots()

sources

sources() -> tuple[tuple[str, str], ...]

Every readable *.py under the project root, as (path, text).

Paths are project-relative POSIX strings because they end up in a report somebody reads; absolute paths from the inspecting machine would be noise in a document that outlives the run.

Source code in src/symfonic/diagnostics/inspection/context.py
def sources(self) -> tuple[tuple[str, str], ...]:
    """Every readable ``*.py`` under the project root, as (path, text).

    Paths are project-relative POSIX strings because they end up in a
    report somebody reads; absolute paths from the inspecting machine
    would be noise in a document that outlives the run.
    """
    root = self.project_root
    if root is None:
        return ()
    collected: list[tuple[str, str]] = []
    for path in self._candidate_paths():
        try:
            text = path.read_text(encoding="utf-8")
        except (OSError, UnicodeDecodeError):
            continue
        collected.append((path.relative_to(root).as_posix(), text))
        if len(collected) >= self.max_source_files:
            break
    return tuple(collected)

InspectionFinding dataclass

InspectionFinding(code: str, category: str, severity: Severity, subject: str, detail: str, migration_action: str, location: str | None = None)

One offline observation about an adopter's project.

sort_key property

sort_key: tuple[int, int, str, str, str]

Group by category, then most severe first, then stably by name.

as_check_result

as_check_result() -> CheckResult

Render as a :class:CheckResult so doctor treats it like any check.

Source code in src/symfonic/diagnostics/inspection/model.py
def as_check_result(self) -> CheckResult:
    """Render as a :class:`CheckResult` so ``doctor`` treats it like any check."""
    where = f" ({self.location})" if self.location else ""
    return CheckResult(
        name=f"{self.category}.{self.code}",
        severity=self.severity,
        message=f"{self.subject}: {self.detail}{where}",
        fix_hint=self.migration_action or None,
    )

MigrationInspection dataclass

MigrationInspection(findings: tuple[InspectionFinding, ...] = (), inspected: tuple[str, ...] = (), skipped: tuple[tuple[str, str], ...] = ())

Everything one offline pass established.

inspected and skipped together cover every category, always. An empty findings list is ambiguous on its own -- it means "clean" for an inspected category and "never looked" for a skipped one, and conflating those two is how a migration tool earns undeserved trust.

as_report

as_report() -> AuditReport

Fold into the shipped audit report, exit codes and all.

Source code in src/symfonic/diagnostics/inspection/model.py
def as_report(self) -> AuditReport:
    """Fold into the shipped audit report, exit codes and all."""
    return AuditReport(results=[f.as_check_result() for f in self.findings])

counts

counts() -> dict[str, int]

Findings per category, omitting categories with none.

Source code in src/symfonic/diagnostics/inspection/model.py
def counts(self) -> dict[str, int]:
    """Findings per category, omitting categories with none."""
    return dict(Counter(finding.category for finding in self.findings))

inspect_migration

inspect_migration(context: InspectionContext) -> MigrationInspection

Answer all migration questions, reading nothing but text.

Source code in src/symfonic/diagnostics/inspection/runner.py
def inspect_migration(context: InspectionContext) -> MigrationInspection:
    """Answer all migration questions, reading nothing but text."""
    probe = probe_config(context.config)
    findings, skipped = _config_findings(context, probe)

    if context.project_root is None:
        skipped["deprecated-import"] = _NO_SOURCES
        skipped["architecture-violation"] = _NO_SOURCES
        refs = ()
    else:
        refs = project_imports(context)
        findings += deprecated_imports(refs)
        findings += architecture_violations(context, refs)

    # An empty environment and one that names no deployment are the same answer:
    # every platform check below is silent for both, so reporting either as
    # "inspected" hands back a green tick for a gate nobody looked at.
    if not describes_a_deployment(context.environ):
        skipped["platform-security"] = _NO_DEPLOYMENT
    else:
        findings += platform_security(context.environ, refs)

    inspected = tuple(name for name in CATEGORIES if name not in skipped)
    return MigrationInspection(
        findings=tuple(sorted(findings, key=lambda f: f.sort_key)),
        inspected=inspected,
        skipped=tuple(
            (name, skipped[name]) for name in CATEGORIES if name in skipped
        ),
    )

render_migration_report

render_migration_report(inspection: MigrationInspection, context: InspectionContext) -> str

Return the Markdown migration inspection report for inspection.

Source code in src/symfonic/diagnostics/inspection/report.py
def render_migration_report(
    inspection: MigrationInspection, context: InspectionContext
) -> str:
    """Return the Markdown migration inspection report for *inspection*."""
    lines = _preamble(inspection, context)
    lines += _summary(inspection)
    lines += _sections(inspection)
    lines += _closing(inspection)
    return "\n".join(lines).rstrip() + "\n"