from __future__ import annotations
import copy
from collections.abc import Iterable
from functools import lru_cache
from typing import Any
from ._data import load_schema
from .models import ConfigFix, Diagnostic, FixResult
class KnownFuncs:
__slots__ = ("funcs_by_stage", "stage_by_func", "all_funcs", "schema_by_func")
def __init__(self) -> None:
self.funcs_by_stage: dict[str, set[str]] = {}
self.stage_by_func: dict[str, str] = {}
self.all_funcs: set[str] = set()
#: The resolved model definition for each func, so a pipeline step can
#: be validated against the one branch its func names instead of the
#: whole anyOf.
self.schema_by_func: dict[str, dict[str, Any]] = {}
def add(
self, stage: str, func: str | None, model_schema: dict[str, Any] | None = None
) -> None:
if not func:
return
self.funcs_by_stage.setdefault(stage, set()).add(func)
self.stage_by_func.setdefault(func, stage)
self.all_funcs.add(func)
if model_schema is not None:
self.schema_by_func.setdefault(func, model_schema)
def _resolve_ref(ref: object, schema: dict[str, Any]) -> dict[str, Any] | None:
if not isinstance(ref, str) or not ref.startswith("#/"):
return None
node: object = schema
for part in ref[2:].split("/"):
if not isinstance(node, dict):
return None
node = node.get(part)
return node if isinstance(node, dict) else None
def _func_const_of(model_schema: dict[str, Any] | None) -> str | None:
if not isinstance(model_schema, dict):
return None
func = (model_schema.get("properties") or {}).get("func")
if not isinstance(func, dict):
return None
const = func.get("const")
if isinstance(const, str):
return const
enum = func.get("enum")
if isinstance(enum, list) and len(enum) == 1 and isinstance(enum[0], str):
return str(enum[0])
return None
def _branches_of(item_schema: dict[str, Any] | None) -> list[dict[str, Any]]:
if not isinstance(item_schema, dict):
return []
for combiner in ("anyOf", "oneOf"):
branches = item_schema.get(combiner)
if isinstance(branches, list):
return branches
return [item_schema]
@lru_cache(maxsize=1)
def known_funcs() -> KnownFuncs:
schema = load_schema()
known = KnownFuncs()
for definition in (schema.get("definitions") or {}).values():
props = definition.get("properties") if isinstance(definition, dict) else None
if not isinstance(props, dict):
continue
for stage, prop_schema in props.items():
items = prop_schema.get("items") if isinstance(prop_schema, dict) else None
if not items:
continue
for branch in _branches_of(items):
model = _resolve_ref(branch.get("$ref"), schema) or branch
known.add(stage, _func_const_of(model), model)
return known
def _leaf(func: object) -> str:
return str(func).split(".")[-1]
def levenshtein(a: str = "", b: str = "") -> int:
a, b = str(a), str(b)
m, n = len(a), len(b)
if m == 0:
return n
if n == 0:
return m
prev = list(range(n + 1))
curr = [0] * (n + 1)
for i in range(1, m + 1):
curr[0] = i
for j in range(1, n + 1):
cost = 0 if a[i - 1] == b[j - 1] else 1
curr[j] = min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost)
prev, curr = curr, prev
return prev[n]
[docs]
def suggest_func(bad_func: str, candidates: Iterable[str]) -> str | None:
"""Suggest the valid model a mistyped ``func`` most likely meant.
Names are compared by edit distance, both on the last part of the dotted
path and on the whole path. A candidate is only suggested when it is
within two edits, or two in five characters of that last part for a
longer name, so a name that is nothing like any model gets no suggestion.
Parameters
----------
bad_func : str
The ``func`` value as written in the config.
candidates : iterable of str
The valid ``func`` paths to choose from.
Returns
-------
str or None
The closest candidate, or None when none is close enough.
"""
options = list(candidates or [])
if not options:
return None
bad_leaf = _leaf(bad_func)
best: str | None = None
best_score = float("inf")
for cand in options:
score = min(levenshtein(bad_leaf, _leaf(cand)), levenshtein(bad_func, cand))
if score < best_score:
best_score, best = score, cand
tolerance = max(2, -(-len(bad_leaf) * 2 // 5)) # ceil(len * 0.4)
return best if best_score <= tolerance else None
class Finding:
__slots__ = (
"stage",
"index",
"func",
"path",
"reason",
"suggestion",
"owning_stage",
"valid_funcs",
)
def __init__(
self,
stage: str,
index: int,
func: str,
reason: str,
suggestion: str | None,
owning_stage: str | None,
valid_funcs: list[str],
) -> None:
self.stage = stage
self.index = index
self.func = func
self.path = f"/pipeline/{stage}/{index}/func"
self.reason = reason
self.suggestion = suggestion
self.owning_stage = owning_stage
self.valid_funcs = valid_funcs
@property
def base(self) -> str:
return f"/pipeline/{self.stage}/{self.index}"
#: Every model Pyxel ships lives under this namespace.
PYXEL_NAMESPACE = "pyxel."
def is_user_model(func: str) -> bool:
"""A model of the user's own, outside Pyxel.
Pyxel's schema accepts any func outside ``pyxel.models.`` through its
generic ModelFunction branch, and Pyxel imports it at run time, so it is
neither unknown nor something to rename into a built-in. A func that does
sit under ``pyxel.`` but is not a real model is still reported: Pyxel has
no models anywhere else, so it is a typo.
"""
return "." in func and not func.startswith(PYXEL_NAMESPACE)
[docs]
def find_unknown_funcs(config: object, known: KnownFuncs | None = None) -> list[Finding]:
"""Find the pipeline steps whose ``func`` is not a model of their stage.
A model of the user's own, outside the ``pyxel.`` namespace, is left
alone: Pyxel imports it at run time.
Parameters
----------
config : object
A parsed config. Anything without a ``pipeline`` mapping has no
findings.
known : KnownFuncs, optional
The models to check against. By default, the ones the bundled schema
defines.
Returns
-------
list of Finding
One per step, each with its ``reason`` (``"unknown"`` for a name no
stage has, ``"wrong-stage"`` for a model placed in another stage), the
``owning_stage`` of a misplaced model, a ``suggestion`` for a likely
typo, and the ``valid_funcs`` of the step's stage.
"""
if not isinstance(config, dict):
return []
pipeline = config.get("pipeline")
if not isinstance(pipeline, dict):
return []
known = known or known_funcs()
findings: list[Finding] = []
for stage, steps in pipeline.items():
if not isinstance(steps, list):
continue
valid = known.funcs_by_stage.get(stage, set())
for index, step in enumerate(steps):
if not isinstance(step, dict):
continue
func = step.get("func")
if not isinstance(func, str):
continue
if func in valid:
continue
if is_user_model(func):
continue
owning_stage = known.stage_by_func.get(func)
reason = "wrong-stage" if owning_stage else "unknown"
candidates = valid or known.all_funcs
findings.append(
Finding(
stage=stage,
index=index,
func=func,
reason=reason,
suggestion=None if reason == "wrong-stage" else suggest_func(func, candidates),
owning_stage=owning_stage,
valid_funcs=sorted(valid),
)
)
return findings
def _describe(finding: Finding) -> str:
if finding.reason == "wrong-stage":
return (
f"Model '{_leaf(finding.func)}' is valid, but not in stage "
f"'{finding.stage}'. It belongs to '{finding.owning_stage}'."
)
msg = f"Unknown model '{_leaf(finding.func)}' in stage '{finding.stage}'."
if finding.suggestion:
msg += f" Did you mean '{_leaf(finding.suggestion)}'?"
return msg
def _hint(finding: Finding) -> str:
if finding.reason == "wrong-stage":
return f"Move this step under pipeline -> {finding.owning_stage}."
return "Check the function name against the valid models for this stage."
def next_step_for(finding: Finding) -> str:
"""The one instruction explain_config attaches to a func finding."""
if finding.reason == "wrong-stage" and finding.owning_stage:
return f"Move this step to pipeline.{finding.owning_stage}."
if finding.suggestion:
return f"Rename func to '{finding.suggestion}'."
if finding.valid_funcs:
return f"Pick one of the {len(finding.valid_funcs)} models valid in '{finding.stage}'."
return f"'{finding.stage}' is not a pipeline stage; move its steps under a real stage."
def _diagnostic(finding: Finding, *, with_next_step: bool) -> Diagnostic:
shown = [_leaf(f) for f in finding.valid_funcs[:8]]
return Diagnostic(
severity="error",
path=finding.path,
message=_describe(finding),
hint=_hint(finding),
suggestion=_leaf(finding.suggestion) if finding.suggestion else None,
valid_options=shown or None,
next_step=next_step_for(finding) if with_next_step else None,
category="func",
)
def diagnostics_for(
config: object, known: KnownFuncs | None = None, *, with_next_step: bool = False
) -> list[Diagnostic]:
return [
_diagnostic(f, with_next_step=with_next_step) for f in find_unknown_funcs(config, known)
]
def offending_bases(config: object, known: KnownFuncs | None = None) -> set[str]:
return {f.base for f in find_unknown_funcs(config, known)}
[docs]
def autofix(config: dict[str, Any] | str) -> FixResult:
"""Repair mistyped and misplaced model names in a config.
A mistyped ``func`` is renamed to its closest valid model, and a valid
model in the wrong stage is moved to the stage it belongs to. Only
``func`` names change: required arguments are never invented, so any
that are still missing appear in ``remaining_diagnostics``. The config
that is passed in is never modified.
Parameters
----------
config : dict or str
The config as a mapping, or the raw YAML text of a ``.yaml`` file.
Returns
-------
FixResult
The corrected config, each change made, what could not be repaired,
and the full validation findings for the corrected config. Text that
cannot be read as a config comes back unresolved, with an empty
config.
"""
if isinstance(config, str):
from .validation import _as_config
from .yaml_read import ConfigReadError, read_problem
try:
config = _as_config(config)
except ConfigReadError as exc:
problem = read_problem(exc)
return FixResult(
changed=False,
changes=[],
unresolved=[problem],
remaining_diagnostics=[problem],
config={},
)
fixed = copy.deepcopy(config)
known = known_funcs()
changes: list[ConfigFix] = []
unresolved: list[Diagnostic] = []
for pipeline, prefix, label in _pipelines(fixed):
found_changes, found_unresolved = _fix_pipeline(pipeline, known)
changes += [
change.model_copy(update={"path": prefix + change.path}) for change in found_changes
]
unresolved += [
d.model_copy(update={"path": prefix + d.path, "message": label + d.message})
for d in found_unresolved
]
from .validation import validate_config
# Always the full verdict, so a config with nothing this can repair still
# reports what is wrong with it.
remaining = validate_config(fixed).diagnostics
return FixResult(
changed=bool(changes),
changes=changes,
unresolved=unresolved,
remaining_diagnostics=remaining,
config=fixed,
)
def _pipelines(config: dict[str, Any]) -> list[tuple[dict[str, Any], str, str]]:
"""(pipeline, path prefix, message prefix) for every pipeline in a config.
A flat config has one, at the root. A focal plane has one per tile, at
focal_plane_array/detectors/<id>/pipeline.
"""
if "focal_plane_array" in config:
plane = config["focal_plane_array"]
detectors = plane.get("detectors") if isinstance(plane, dict) else None
if not isinstance(detectors, dict):
return []
return [
(tile["pipeline"], f"/focal_plane_array/detectors/{tile_id}", f"Tile {tile_id}: ")
for tile_id, tile in detectors.items()
if isinstance(tile, dict) and isinstance(tile.get("pipeline"), dict)
]
pipeline = config.get("pipeline")
return [(pipeline, "", "")] if isinstance(pipeline, dict) else []
def _fix_pipeline(
pipeline: dict[str, Any], known: KnownFuncs
) -> tuple[list[ConfigFix], list[Diagnostic]]:
"""Rename and move the steps of one pipeline in place, with paths from its root."""
findings = find_unknown_funcs({"pipeline": pipeline}, known)
changes: list[ConfigFix] = []
unresolved: list[Diagnostic] = []
pending_moves: list[tuple[str, int, str]] = []
for finding in findings:
if finding.reason == "unknown" and finding.suggestion:
pipeline[finding.stage][finding.index]["func"] = finding.suggestion
changes.append(
ConfigFix(
path=finding.path,
action="rename_func",
before=finding.func,
after=finding.suggestion,
detail=(
f"Renamed unknown model to nearest valid match in stage "
f"'{finding.stage}'."
),
)
)
elif finding.reason == "wrong-stage" and finding.owning_stage:
pending_moves.append((finding.stage, finding.index, finding.owning_stage))
changes.append(
ConfigFix(
path=finding.path,
action="move_stage",
before=finding.stage,
after=finding.owning_stage,
detail=(
f"Moved model '{_leaf(finding.func)}' to its owning stage "
f"'{finding.owning_stage}'."
),
)
)
else:
unresolved.append(_diagnostic(finding, with_next_step=False))
if pending_moves:
removals: dict[str, list[int]] = {}
additions: dict[str, list[Any]] = {}
for source, index, destination in pending_moves:
removals.setdefault(source, []).append(index)
additions.setdefault(destination, []).append(pipeline[source][index])
for source, indices in removals.items():
for index in sorted(indices, reverse=True):
pipeline[source].pop(index)
# A misspelled stage key ("charge_generaton") is how every step
# under it came to be "in the wrong stage". Once emptied it is only
# an unknown property the schema will still reject, so it goes too.
# A real stage left empty is valid and is kept.
if not pipeline[source] and source not in known.funcs_by_stage:
del pipeline[source]
for destination, steps in additions.items():
if not isinstance(pipeline.get(destination), list):
pipeline[destination] = []
pipeline[destination].extend(steps)
return changes, unresolved