Skip to content

symfonic.diagnostics.offline_cli

offline_cli

The offline half of symfonic doctor (T4.3.4).

Separate from :mod:symfonic.diagnostics.cli because the two halves have opposite dependencies: the live audit needs a constructed agent and a provider, and this one must not touch either. Keeping them in one module would have made --offline a flag that skips work inside a function that already imported the world.

The configuration file is read as data. JSON and TOML both parse to a mapping without executing anything, which a config.py would not -- and "run the adopter's Python to find out whether their configuration is safe" is the failure mode this whole surface exists to avoid.

ConfigFileError

Bases: ValueError

The configuration file could not be read as a mapping.

load_config_mapping

load_config_mapping(path: Path) -> Mapping[str, Any]

Load a raw configuration mapping from a JSON or TOML file.

Source code in src/symfonic/diagnostics/offline_cli.py
def load_config_mapping(path: Path) -> Mapping[str, Any]:
    """Load a raw configuration mapping from a JSON or TOML file."""
    if not path.is_file():
        raise ConfigFileError(f"configuration file not found: {path}")
    try:
        if path.suffix.lower() == ".toml":
            with path.open("rb") as handle:
                loaded = tomllib.load(handle)
        else:
            loaded = json.loads(path.read_text(encoding="utf-8"))
    except (OSError, ValueError) as exc:
        raise ConfigFileError(f"could not parse {path}: {exc}") from exc
    if not isinstance(loaded, dict):
        raise ConfigFileError(f"{path} does not contain a configuration mapping")
    return loaded

load_environ_mapping

load_environ_mapping(path: Path) -> Mapping[str, str]

Load the inspected deployment's environment from a .env-style file.

Deliberately not os.environ: the question is what the deployment runs with, and answering it from the inspecting machine reports on a laptop. The format is the smallest one an operator already has -- KEY=value a line, # comments, an optional export prefix, optional surrounding quotes. Nothing is expanded or executed; a value is the text as written.

Source code in src/symfonic/diagnostics/offline_cli.py
def load_environ_mapping(path: Path) -> Mapping[str, str]:
    """Load the *inspected deployment's* environment from a ``.env``-style file.

    Deliberately not ``os.environ``: the question is what the deployment runs
    with, and answering it from the inspecting machine reports on a laptop.
    The format is the smallest one an operator already has -- ``KEY=value`` a
    line, ``#`` comments, an optional ``export`` prefix, optional surrounding
    quotes. Nothing is expanded or executed; a value is the text as written.
    """
    if not path.is_file():
        raise ConfigFileError(f"environment file not found: {path}")
    try:
        text = path.read_text(encoding="utf-8")
    except (OSError, UnicodeDecodeError) as exc:
        raise ConfigFileError(f"could not read {path}: {exc}") from exc
    environ: dict[str, str] = {}
    for number, raw in enumerate(text.splitlines(), start=1):
        line = raw.strip()
        if not line or line.startswith("#"):
            continue
        line = line.removeprefix("export ").lstrip()
        key, separator, value = line.partition("=")
        if not separator or not key.strip():
            raise ConfigFileError(f"{path}:{number}: expected KEY=value, got {raw!r}")
        environ[key.strip()] = value.strip().strip("'\"")
    return environ

offline_json_payload

offline_json_payload(inspection: MigrationInspection, *, strict: bool = False) -> dict[str, Any]

The --offline --json document: the audit shape plus what was looked at.

checks/summary/exit_code are the shape doctor --json already documents, so one parser reads both modes. inspected/skipped are the part the audit shape cannot express: an absent category means "clean" for an inspected one and "nobody looked" for a skipped one, and a JSON consumer that cannot tell them apart is being invited to trust a green run that inspected nothing.

Source code in src/symfonic/diagnostics/offline_cli.py
def offline_json_payload(
    inspection: MigrationInspection, *, strict: bool = False
) -> dict[str, Any]:
    """The ``--offline --json`` document: the audit shape plus what was looked at.

    ``checks``/``summary``/``exit_code`` are the shape ``doctor --json`` already
    documents, so one parser reads both modes. ``inspected``/``skipped`` are the
    part the audit shape cannot express: an absent category means "clean" for an
    inspected one and "nobody looked" for a skipped one, and a JSON consumer
    that cannot tell them apart is being invited to trust a green run that
    inspected nothing.
    """
    payload = inspection.as_report().to_json_dict(strict=strict)
    payload["inspected"] = list(inspection.inspected)
    payload["skipped"] = [
        {"category": category, "reason": reason}
        for category, reason in inspection.skipped
    ]
    return payload

run_offline

run_offline(project_path: Path | None, *, config_path: Path | None = None, env_path: Path | None = None, migration_report: Path | None = None, package_version: str | None = None) -> tuple[MigrationInspection, str]

Inspect a project offline; return the inspection and its Markdown report.

Raises :class:ConfigFileError when a supplied configuration or environment file cannot be read -- the caller turns that into an exit code, because a diagnostic that silently inspects nothing is worse than one that refuses.

With no env_path the environment is empty and the platform-security category is recorded as not inspected. That is the honest answer: the process running this command is not the deployment being asked about.

Source code in src/symfonic/diagnostics/offline_cli.py
def run_offline(
    project_path: Path | None,
    *,
    config_path: Path | None = None,
    env_path: Path | None = None,
    migration_report: Path | None = None,
    package_version: str | None = None,
) -> tuple[MigrationInspection, str]:
    """Inspect a project offline; return the inspection and its Markdown report.

    Raises :class:`ConfigFileError` when a supplied configuration or environment
    file cannot be read -- the caller turns that into an exit code, because a
    diagnostic that silently inspects *nothing* is worse than one that refuses.

    With no ``env_path`` the environment is empty and the platform-security
    category is recorded as not inspected. That is the honest answer: the
    process running this command is not the deployment being asked about.
    """
    config = load_config_mapping(config_path) if config_path is not None else {}
    environ = load_environ_mapping(env_path) if env_path is not None else {}
    context = InspectionContext.for_target(
        project_root=project_path.resolve() if project_path is not None else None,
        config=config,
        environ=environ,
        package_version=package_version,
    )
    inspection = inspect_migration(context)
    rendered = render_migration_report(inspection, context)
    if migration_report is not None:
        migration_report.parent.mkdir(parents=True, exist_ok=True)
        migration_report.write_text(rendered, encoding="utf-8")
    return inspection, rendered