Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
163 changes: 139 additions & 24 deletions cli/overlay.py
Original file line number Diff line number Diff line change
Expand Up @@ -216,6 +216,52 @@ def _compose_extends(
if doc is None:
return None, {}, diagnostics

profile_dir = profile_file.parent.resolve()
new_stack = (*stack, resolved_file)
merged, provenance, merge_diagnostics = _resolve_and_merge_extends(
doc, profile_dir, str(profile_file), new_stack
)
diagnostics += merge_diagnostics
return merged, provenance, diagnostics


def _compose_extends_from_doc(
doc: dict[str, Any],
profile_dir: Path,
source_label: str,
) -> tuple[dict[str, Any] | None, dict[str, str], list[Diagnostic]]:
"""
In-memory counterpart to _compose_extends(): resolves `doc`'s own
`extends` chain the same way, but for an already-loaded document that
has no file of its own on disk (e.g. a profile assembled at runtime and
planned/validated without being written to disk first, per issue #349).
`profile_dir` anchors relative extends refs (and derives the profiles
root) the same way a real profile file's parent directory would;
`source_label` stands in for the file path in diagnostics and merge
provenance. `doc` has no file identity of its own to cycle back to, so
cycle detection (E113) only applies to on-disk *parent* extends chains,
which still recurse through _compose_extends() normally.
"""
return _resolve_and_merge_extends(doc, profile_dir, source_label, ())


def _resolve_and_merge_extends(
doc: dict[str, Any],
profile_dir: Path,
source_label: str,
stack: tuple[Path, ...],
) -> tuple[dict[str, Any] | None, dict[str, str], list[Diagnostic]]:
"""
Shared tail of _compose_extends()/_compose_extends_from_doc(): resolves
`doc`'s own `extends` list (if any) into merged parent(s) plus `doc`
itself, given the directory `doc` should be treated as living in
(`profile_dir`) and a label to use in diagnostics/provenance in place of
a real file path (`source_label`). `stack` is only used for cycle
detection against on-disk parent recursion; callers with no file
identity of their own to cycle back to (i.e. _compose_extends_from_doc)
pass ().
"""
diagnostics: list[Diagnostic] = []
extends = doc.get("extends")
if extends is None:
return doc, {}, diagnostics
Expand All @@ -233,24 +279,21 @@ def _compose_extends(
)
return None, {}, diagnostics

profile_dir = profile_file.parent.resolve()
profiles_root = _derive_profiles_root(profile_dir)
if profiles_root is None:
diagnostics.append(
Diagnostic(
level="error",
code="E111",
message=(
f'Cannot resolve "extends" for {profile_file}: its directory does not '
f'Cannot resolve "extends" for {source_label}: its directory does not '
'reside under a "profiles/" root.'
),
path="extends",
)
)
return None, {}, diagnostics

new_stack = (*stack, resolved_file)

merged: dict[str, Any] | None = None
merged_source: str | None = None
provenance: dict[str, str] = {}
Expand Down Expand Up @@ -280,7 +323,7 @@ def _compose_extends(
)
return None, {}, diagnostics

parent_doc, parent_provenance, parent_diagnostics = _compose_extends(parent_path, new_stack)
parent_doc, parent_provenance, parent_diagnostics = _compose_extends(parent_path, stack)
diagnostics += parent_diagnostics
if parent_doc is None:
return None, {}, diagnostics
Expand All @@ -300,7 +343,7 @@ def _compose_extends(

child_without_extends = {k: v for k, v in doc.items() if k != "extends"}
merged, merge_diagnostics = _merge_profile_docs(
merged, child_without_extends, merged_source, str(profile_file), provenance
merged, child_without_extends, merged_source, source_label, provenance
)
diagnostics += merge_diagnostics
if merge_diagnostics:
Expand Down Expand Up @@ -353,6 +396,23 @@ def resolve_extends(
return _compose_extends(Path(profile_path), ())


def resolve_extends_from_profile(
profile: dict[str, Any],
profile_dir: Path,
source_label: str = "<in-memory profile>",
) -> tuple[dict[str, Any] | None, dict[str, str], list[Diagnostic]]:
"""
In-memory counterpart to resolve_extends() (issue #349): resolves only
`profile`'s own `extends` chain against on-disk parent profiles, without
requiring `profile` itself to be loaded from (or written to) disk
first. `profile_dir` only needs to exist and reside under a
"profiles/" root if `profile` actually declares `extends`; it does not
need to contain a profile.yaml of its own. `source_label` stands in
for a real file path in diagnostics and merge provenance.
"""
return _compose_extends_from_doc(profile, profile_dir, source_label)


def resolve_profile(
profile_path: str,
environment: str | None = None,
Expand All @@ -375,13 +435,71 @@ def resolve_profile(
if base is None:
return None, {}, diagnostics

resolved, out_provenance = _resolve_environment_overlay_and_validate(
base, provenance, profile_file.parent, str(profile_file), environment, profile_file, diagnostics
)
return resolved, out_provenance, diagnostics


def resolve_profile_from_profile(
profile: dict[str, Any],
profile_dir: Path,
environment: str | None = None,
source_label: str = "<in-memory profile>",
) -> tuple[dict[str, Any] | None, dict[str, str], list[Diagnostic]]:
"""
In-memory counterpart to resolve_profile() (issue #349): resolves
`profile`'s own `extends` chain and, if `environment` is set, merges
profile_dir/environments/<environment>.yaml over the result, then runs
the same validate_loaded_profile() pass -- all without requiring
`profile` to be loaded from (or written to) disk first. `profile_dir`
only needs to exist and reside under a "profiles/" root if `extends`
or `environment` are actually used; it does not need to contain a
profile.yaml of its own. `source_label` stands in for a real file path
in diagnostics and merge provenance.
"""
base, provenance, diagnostics = _compose_extends_from_doc(profile, profile_dir, source_label)
if base is None:
return None, {}, diagnostics

anchor_file = profile_dir / "profile.yaml"
resolved, out_provenance = _resolve_environment_overlay_and_validate(
base, provenance, profile_dir, source_label, environment, anchor_file, diagnostics
)
return resolved, out_provenance, diagnostics


def _resolve_environment_overlay_and_validate(
base: dict[str, Any],
provenance: dict[str, str],
profile_dir: Path,
source_label: str,
environment: str | None,
anchor_file: Path,
diagnostics: list[Diagnostic],
) -> tuple[dict[str, Any] | None, dict[str, str]]:
"""
Shared tail of resolve_profile()/resolve_profile_from_profile(): applies
an optional environments/<environment>.yaml overlay over `base` (merged
the same way `extends` parents are, via _merge_profile_docs) and runs
validate_loaded_profile() against the result (or against `base` directly
when environment is None), appending all diagnostics to `diagnostics` in
place. `anchor_file` is passed through to validate_loaded_profile()
purely to anchor module `source:` resolution via its .parent -- unlike
a resolve_profile() caller's real profile file, it does not need to
exist for resolve_profile_from_profile()'s in-memory case.

Returns (resolved_profile_or_None, provenance_to_report); the reported
provenance intentionally differs between failure branches to preserve
resolve_profile()'s pre-existing behavior (empty on overlay-resolution
failures, the extends-derived provenance on validation failures).
"""
if environment is None:
diagnostics += validate_loaded_profile(base, profile_file)
return (base, provenance, diagnostics) if not any(
d.level == "error" for d in diagnostics
) else (None, provenance, diagnostics)
diagnostics.extend(validate_loaded_profile(base, anchor_file))
if any(d.level == "error" for d in diagnostics):
return None, provenance
return base, provenance

profile_dir = profile_file.parent
environments_dir = profile_dir / "environments"
overlay_file = environments_dir / f"{environment}.yaml"

Expand All @@ -394,7 +512,7 @@ def resolve_profile(
path="environment",
)
)
return None, {}, diagnostics
return None, {}

if not overlay_file.is_file():
diagnostics.append(
Expand All @@ -405,23 +523,20 @@ def resolve_profile(
path="environment",
)
)
return None, {}, diagnostics
return None, {}

overlay, overlay_diags = load_yaml_file(overlay_file)
diagnostics += overlay_diags
diagnostics.extend(overlay_diags)
if overlay is None:
return None, {}, diagnostics

base_source = str(profile_file)
overlay_source = str(overlay_file)
return None, {}

merged, merge_diagnostics = _merge_profile_docs(base, overlay, base_source, overlay_source, provenance)
diagnostics += merge_diagnostics
merged, merge_diagnostics = _merge_profile_docs(base, overlay, source_label, str(overlay_file), provenance)
diagnostics.extend(merge_diagnostics)
if merge_diagnostics:
return None, {}, diagnostics
return None, {}

diagnostics += validate_loaded_profile(merged, profile_file)
diagnostics.extend(validate_loaded_profile(merged, anchor_file))
if any(d.level == "error" for d in diagnostics):
return None, provenance, diagnostics
return None, provenance

return merged, provenance, diagnostics
return merged, provenance
Loading