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
|