Skip to content

Commit da4cd0f

Browse files
committed
chore: add definition resolver
1 parent cbe4ad0 commit da4cd0f

2 files changed

Lines changed: 271 additions & 0 deletions

File tree

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
"""
2+
Generic definition resolver for YAML models.
3+
4+
Walks a pydantic BaseModel recursively and replaces DefinitionReference strings
5+
with the actual definition objects from a provided definitions registry.
6+
"""
7+
8+
from __future__ import annotations
9+
10+
import types
11+
from collections.abc import Mapping, Sequence, Set
12+
from typing import Any, Union, get_args, get_origin
13+
14+
from pydantic import BaseModel
15+
16+
from libecalc.presentation.yaml.yaml_types.process.yaml_process_references import DefinitionReference
17+
18+
19+
def resolve_definitions(model: BaseModel, definitions: dict[str, Any]) -> BaseModel:
20+
"""Resolve all DefinitionReference strings in a pydantic model tree.
21+
22+
Recursively walks the model and for any field typed as `T | DefinitionReference`,
23+
if the value is a string, looks it up in the definitions dict and replaces it
24+
with the corresponding definition object.
25+
26+
Args:
27+
model: The root pydantic model to resolve references in.
28+
definitions: A flat mapping of {reference_name: definition_object}.
29+
30+
Returns:
31+
A new model instance with all DefinitionReference strings replaced by their definition objects.
32+
33+
Raises:
34+
KeyError: If a reference string cannot be found in the definitions registry.
35+
"""
36+
updates = {}
37+
for field_name, field_info in type(model).model_fields.items():
38+
value = getattr(model, field_name)
39+
if value is None:
40+
continue
41+
42+
annotation = field_info.annotation
43+
resolved = _resolve_value(value, annotation, definitions)
44+
if resolved is not value:
45+
updates[field_name] = resolved
46+
47+
if updates:
48+
return model.model_copy(update=updates)
49+
return model
50+
51+
52+
def _get_item_type(annotation: Any) -> Any:
53+
"""Extract the element type from a generic container annotation.
54+
55+
Handles list[X], set[X], frozenset[X], tuple[X, ...], dict[K, V] (returns V), etc.
56+
"""
57+
args = get_args(annotation)
58+
if not args:
59+
return Any
60+
61+
origin = get_origin(annotation)
62+
if origin is dict or (isinstance(origin, type) and issubclass(origin, Mapping)):
63+
return args[1] if len(args) >= 2 else Any
64+
65+
# tuple[X, ...] → X; tuple[A, B, C] → we can't resolve per-position generically, use Any
66+
if origin is tuple:
67+
if len(args) == 2 and args[1] is Ellipsis:
68+
return args[0]
69+
return Any
70+
71+
# list, set, frozenset, etc.
72+
return args[0]
73+
74+
75+
def _resolve_value(value: Any, annotation: Any, definitions: dict[str, Any]) -> Any:
76+
"""Resolve a single value based on its type annotation."""
77+
78+
# Check if this field is a union containing DefinitionReference
79+
if _is_definition_reference_union(annotation) and isinstance(value, str):
80+
return _lookup_definition(value, definitions)
81+
82+
# Recurse into BaseModel instances
83+
if isinstance(value, BaseModel):
84+
return resolve_definitions(value, definitions)
85+
86+
# Recurse into mappings (dict, OrderedDict, etc.)
87+
if isinstance(value, Mapping):
88+
item_type = _get_item_type(annotation)
89+
return type(value)({k: _resolve_value(v, item_type, definitions) for k, v in value.items()}) # type: ignore[call-arg]
90+
91+
# Recurse into sequences and sets (list, tuple, set, frozenset, etc.) but not strings
92+
if isinstance(value, (Sequence, Set)) and not isinstance(value, (str, bytes)):
93+
item_type = _get_item_type(annotation)
94+
return type(value)(_resolve_value(item, item_type, definitions) for item in value) # type: ignore[call-arg]
95+
96+
return value
97+
98+
99+
def _is_definition_reference_union(annotation: Any) -> bool:
100+
"""Check if the annotation is a Union containing DefinitionReference."""
101+
origin = get_origin(annotation)
102+
if origin is Union or origin is types.UnionType:
103+
args = get_args(annotation)
104+
return any(a is DefinitionReference for a in args)
105+
return False
106+
107+
108+
def _lookup_definition(reference: str, definitions: dict[str, Any]) -> Any:
109+
"""Look up a reference string in the definitions registry."""
110+
if reference not in definitions:
111+
raise KeyError(
112+
f"Definition reference '{reference}' not found. Available definitions: {list(definitions.keys())}"
113+
)
114+
return definitions[reference]
Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import pytest
2+
from pydantic import Field
3+
4+
from libecalc.presentation.yaml.definition_resolver import resolve_definitions
5+
from libecalc.presentation.yaml.yaml_types import YamlBase
6+
from libecalc.presentation.yaml.yaml_types.process.yaml_process_pipeline import (
7+
YamlProcessPipeline,
8+
)
9+
from libecalc.presentation.yaml.yaml_types.process.yaml_process_references import DefinitionReference
10+
from libecalc.presentation.yaml.yaml_types.process.yaml_process_units import (
11+
YamlLiquidRemoverDefinition,
12+
YamlPressureDropperDefinition,
13+
)
14+
15+
16+
class TestResolveDefinitions:
17+
def test_resolves_string_reference(self):
18+
pipeline = YamlProcessPipeline.model_validate(
19+
{
20+
"NAME": "p",
21+
"PROCESS_UNITS": [{"TARGET": "my_dropper"}],
22+
}
23+
)
24+
defs = {
25+
"my_dropper": YamlPressureDropperDefinition.model_validate(
26+
{"TYPE": "PRESSURE_DROPPER", "PRESSURE_DROP": "5"}
27+
)
28+
}
29+
30+
resolved = resolve_definitions(pipeline, defs)
31+
32+
assert isinstance(resolved.process_units[0].target, YamlPressureDropperDefinition)
33+
assert resolved.process_units[0].target.pressure_drop == "5"
34+
35+
def test_preserves_inline_definition(self):
36+
pipeline = YamlProcessPipeline.model_validate(
37+
{
38+
"NAME": "p",
39+
"PROCESS_UNITS": [
40+
{"TARGET": {"TYPE": "LIQUID_REMOVER"}},
41+
],
42+
}
43+
)
44+
45+
resolved = resolve_definitions(pipeline, {})
46+
47+
assert isinstance(resolved.process_units[0].target, YamlLiquidRemoverDefinition)
48+
49+
def test_missing_reference_raises_key_error(self):
50+
pipeline = YamlProcessPipeline.model_validate(
51+
{
52+
"NAME": "p",
53+
"PROCESS_UNITS": [{"TARGET": "nonexistent"}],
54+
}
55+
)
56+
57+
with pytest.raises(KeyError, match="nonexistent"):
58+
resolve_definitions(pipeline, {})
59+
60+
def test_resolves_multiple_references(self):
61+
pipeline = YamlProcessPipeline.model_validate(
62+
{
63+
"NAME": "p",
64+
"PROCESS_UNITS": [
65+
{"TARGET": "dropper"},
66+
{"TARGET": "remover"},
67+
],
68+
}
69+
)
70+
defs = {
71+
"dropper": YamlPressureDropperDefinition.model_validate({"TYPE": "PRESSURE_DROPPER", "PRESSURE_DROP": "3"}),
72+
"remover": YamlLiquidRemoverDefinition.model_validate({"TYPE": "LIQUID_REMOVER"}),
73+
}
74+
75+
resolved = resolve_definitions(pipeline, defs)
76+
77+
assert isinstance(resolved.process_units[0].target, YamlPressureDropperDefinition)
78+
assert isinstance(resolved.process_units[1].target, YamlLiquidRemoverDefinition)
79+
80+
def test_mixed_inline_and_reference(self):
81+
pipeline = YamlProcessPipeline.model_validate(
82+
{
83+
"NAME": "p",
84+
"PROCESS_UNITS": [
85+
{"TARGET": "dropper"},
86+
{"TARGET": {"TYPE": "LIQUID_REMOVER"}},
87+
],
88+
}
89+
)
90+
defs = {
91+
"dropper": YamlPressureDropperDefinition.model_validate({"TYPE": "PRESSURE_DROPPER", "PRESSURE_DROP": "1"}),
92+
}
93+
94+
resolved = resolve_definitions(pipeline, defs)
95+
96+
assert isinstance(resolved.process_units[0].target, YamlPressureDropperDefinition)
97+
assert isinstance(resolved.process_units[1].target, YamlLiquidRemoverDefinition)
98+
99+
def test_no_references_returns_equal_model(self):
100+
pipeline = YamlProcessPipeline.model_validate(
101+
{
102+
"NAME": "p",
103+
"PROCESS_UNITS": [
104+
{"TARGET": {"TYPE": "LIQUID_REMOVER"}},
105+
],
106+
}
107+
)
108+
109+
resolved = resolve_definitions(pipeline, {})
110+
111+
assert resolved == pipeline
112+
113+
def test_preserves_instance_name(self):
114+
pipeline = YamlProcessPipeline.model_validate(
115+
{
116+
"NAME": "p",
117+
"PROCESS_UNITS": [{"TARGET": "dropper", "NAME": "stage1"}],
118+
}
119+
)
120+
defs = {
121+
"dropper": YamlPressureDropperDefinition.model_validate({"TYPE": "PRESSURE_DROPPER", "PRESSURE_DROP": "5"}),
122+
}
123+
124+
resolved = resolve_definitions(pipeline, defs)
125+
126+
assert resolved.process_units[0].name == "stage1"
127+
128+
def test_non_definition_strings_are_not_resolved(self):
129+
"""Fields typed as plain str (not DefinitionReference unions) should be left alone."""
130+
pipeline = YamlProcessPipeline.model_validate(
131+
{
132+
"NAME": "my_pipeline_name",
133+
"PROCESS_UNITS": [{"TARGET": {"TYPE": "LIQUID_REMOVER"}}],
134+
}
135+
)
136+
137+
resolved = resolve_definitions(pipeline, {"my_pipeline_name": "should_not_replace"})
138+
139+
assert resolved.name == "my_pipeline_name"
140+
141+
def test_resolves_references_in_dict_values(self):
142+
"""DefinitionReference inside dict values should be resolved."""
143+
144+
class Inner(YamlBase):
145+
target: YamlPressureDropperDefinition | DefinitionReference
146+
147+
class Outer(YamlBase):
148+
items: dict[str, Inner] = Field(default_factory=dict)
149+
150+
outer = Outer.model_validate({"ITEMS": {"a": {"TARGET": "dropper"}}})
151+
defs = {
152+
"dropper": YamlPressureDropperDefinition.model_validate({"TYPE": "PRESSURE_DROPPER", "PRESSURE_DROP": "2"}),
153+
}
154+
155+
resolved = resolve_definitions(outer, defs)
156+
157+
assert isinstance(resolved.items["a"].target, YamlPressureDropperDefinition)

0 commit comments

Comments
 (0)