Skip to content

Commit 244eaa4

Browse files
authored
fix: separated raw and validated spec config loading
2 parents bb7d2c0 + ff79a45 commit 244eaa4

3 files changed

Lines changed: 156 additions & 7 deletions

File tree

netengine/config/spec_config.py

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,21 @@
11
"""Infrastructure specification configuration and loading."""
22

33
from pathlib import Path
4-
from typing import Any, Optional
4+
from typing import TYPE_CHECKING, Any, Optional
55

66
from omegaconf import OmegaConf
77

88
from netengine.config.loader import ConfigLoader
99

10+
if TYPE_CHECKING:
11+
from netengine.spec.models import NetEngineSpec
12+
1013

1114
class SpecConfig:
1215
"""Load and manage infrastructure specifications with composition support."""
1316

1417
@staticmethod
15-
def load(
18+
def load_raw(
1619
spec_path: Path | str,
1720
base_path: Optional[Path | str] = None,
1821
overrides: Optional[dict[str, Any]] = None,
@@ -25,7 +28,7 @@ def load(
2528
overrides: Optional overrides dictionary
2629
2730
Returns:
28-
Loaded and merged specification as dictionary
31+
Loaded and merged raw specification as dictionary
2932
"""
3033
spec_path = Path(spec_path)
3134

@@ -42,7 +45,34 @@ def load(
4245
return spec_dict
4346

4447
@staticmethod
45-
def load_environment_variants(
48+
def load(
49+
spec_path: Path | str,
50+
base_path: Optional[Path | str] = None,
51+
overrides: Optional[dict[str, Any]] = None,
52+
) -> dict[str, Any]:
53+
"""Load spec as a raw dictionary.
54+
55+
This backwards-compatible alias preserves the historical ``SpecConfig.load``
56+
behavior. Prefer ``load_raw`` for new raw merge callers or
57+
``load_validated`` when a validated ``NetEngineSpec`` is required.
58+
"""
59+
return SpecConfig.load_raw(spec_path, base_path=base_path, overrides=overrides)
60+
61+
@staticmethod
62+
def load_validated(
63+
spec_path: Path | str,
64+
base_path: Optional[Path | str] = None,
65+
overrides: Optional[dict[str, Any]] = None,
66+
validate_feature_states: bool = True,
67+
) -> "NetEngineSpec":
68+
"""Load, compose, and validate a spec into a ``NetEngineSpec``."""
69+
from netengine.spec.loader import validate_spec_data
70+
71+
spec_dict = SpecConfig.load_raw(spec_path, base_path=base_path, overrides=overrides)
72+
return validate_spec_data(spec_dict, validate_feature_states=validate_feature_states)
73+
74+
@staticmethod
75+
def load_environment_variants_raw(
4676
base_spec: Path | str,
4777
environment: str = "dev",
4878
overrides: Optional[dict[str, Any]] = None,
@@ -59,7 +89,7 @@ def load_environment_variants(
5989
overrides: Optional additional overrides
6090
6191
Returns:
62-
Merged specification
92+
Merged raw specification dictionary
6393
"""
6494
base_path = Path(base_spec)
6595
base_dir = base_path.parent
@@ -78,6 +108,39 @@ def load_environment_variants(
78108

79109
return spec_dict
80110

111+
@staticmethod
112+
def load_environment_variants(
113+
base_spec: Path | str,
114+
environment: str = "dev",
115+
overrides: Optional[dict[str, Any]] = None,
116+
) -> dict[str, Any]:
117+
"""Load environment variants as a raw dictionary.
118+
119+
This backwards-compatible alias preserves the historical
120+
``SpecConfig.load_environment_variants`` behavior. Prefer
121+
``load_environment_variants_raw`` for new raw merge callers or
122+
``load_environment_variant_validated`` when a validated ``NetEngineSpec``
123+
is required.
124+
"""
125+
return SpecConfig.load_environment_variants_raw(
126+
base_spec, environment=environment, overrides=overrides
127+
)
128+
129+
@staticmethod
130+
def load_environment_variant_validated(
131+
base_spec: Path | str,
132+
environment: str = "dev",
133+
overrides: Optional[dict[str, Any]] = None,
134+
validate_feature_states: bool = True,
135+
) -> "NetEngineSpec":
136+
"""Load an environment variant and validate it into a ``NetEngineSpec``."""
137+
from netengine.spec.loader import validate_spec_data
138+
139+
spec_dict = SpecConfig.load_environment_variants_raw(
140+
base_spec, environment=environment, overrides=overrides
141+
)
142+
return validate_spec_data(spec_dict, validate_feature_states=validate_feature_states)
143+
81144
@staticmethod
82145
def to_dict(spec_obj: Any) -> dict[str, Any]:
83146
"""Convert OmegaConf spec object to dictionary.

netengine/spec/loader.py

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
"""YAML spec loading and validation with OmegaConf composition support."""
22

33
import ipaddress
4-
import netengine.logs as logs
54
from collections.abc import Iterator
65
from pathlib import Path
76
from typing import Any, Optional
87

98
import yaml
109
from pydantic import ValidationError
1110

11+
import netengine.logs as logs
1212
from netengine.config.loader import ConfigLoader
1313
from netengine.spec.models import SUPPORTED_SPEC_SCHEMA_VERSIONS, NetEngineSpec
1414
from netengine.spec.types import GatewayRealInternetMode
@@ -218,6 +218,35 @@ def _validate_spec_data(
218218
return spec
219219

220220

221+
def load_spec(yaml_path: str | Path, *, validate_feature_states: bool = True) -> NetEngineSpec:
222+
"""Load and validate a NetEngine YAML specification.
223+
224+
Args:
225+
data: Composed raw specification dictionary
226+
validate_feature_states: Whether to enforce feature-state metadata
227+
228+
Returns:
229+
Validated, immutable NetEngineSpec object
230+
231+
Raises:
232+
SpecLoadError: If the composed spec data is invalid
233+
"""
234+
yaml_path = Path(yaml_path)
235+
236+
if not yaml_path.exists():
237+
raise SpecLoadError(f"Spec file not found: {yaml_path}")
238+
239+
try:
240+
with open(yaml_path) as f:
241+
data = yaml.safe_load(f)
242+
except yaml.YAMLError as e:
243+
raise SpecLoadError(f"Failed to parse YAML: {e}")
244+
except IOError as e:
245+
raise SpecLoadError(f"Failed to read file: {e}")
246+
247+
return _validate_spec_data(data, validate_feature_states=validate_feature_states)
248+
249+
221250
def load_spec(yaml_path: str | Path, *, validate_feature_states: bool = True) -> NetEngineSpec:
222251
"""Load and validate a NetEngine YAML specification.
223252
@@ -243,7 +272,7 @@ def load_spec(yaml_path: str | Path, *, validate_feature_states: bool = True) ->
243272
except IOError as e:
244273
raise SpecLoadError(f"Failed to read file: {e}")
245274

246-
return _validate_spec_data(data, validate_feature_states=validate_feature_states)
275+
return validate_spec_data(data, validate_feature_states=validate_feature_states)
247276

248277

249278
def load_spec_with_composition(

tests/test_config.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
load_spec_with_composition,
1515
load_spec_with_environment,
1616
)
17+
from netengine.spec.models import NetEngineSpec
1718

1819

1920
@pytest.fixture
@@ -260,6 +261,34 @@ def test_load_spec(self, base_spec_file: Path) -> None:
260261
spec = SpecConfig.load(base_spec_file)
261262
assert spec["metadata"]["name"] == "test-network"
262263

264+
def test_load_raw_returns_dict(self, base_spec_file: Path) -> None:
265+
"""Test explicit raw spec loading returns a dictionary."""
266+
spec = SpecConfig.load_raw(base_spec_file)
267+
268+
assert isinstance(spec, dict)
269+
assert spec["metadata"]["name"] == "test-network"
270+
271+
def test_load_validated_returns_netengine_spec(self, base_spec_file: Path) -> None:
272+
"""Test validated spec loading returns a NetEngineSpec."""
273+
spec = SpecConfig.load_validated(base_spec_file)
274+
275+
assert isinstance(spec, NetEngineSpec)
276+
assert spec.metadata.name == "test-network"
277+
278+
def test_load_validated_rejects_invalid_specs(self, base_spec_file: Path) -> None:
279+
"""Test validated spec loading rejects invalid composed specs."""
280+
overrides = {
281+
"substrate": {
282+
"networks": {
283+
"platform": {"type": "bridge", "subnet": "10.0.0.0/8"},
284+
"core": {"type": "bridge", "subnet": "10.1.0.0/16"},
285+
}
286+
}
287+
}
288+
289+
with pytest.raises(SpecLoadError, match="subnet overlap"):
290+
SpecConfig.load_validated(base_spec_file, overrides=overrides)
291+
263292
def test_load_with_base(self, base_spec_file: Path, prod_spec_file: Path) -> None:
264293
"""Test loading spec with base composition."""
265294
spec = SpecConfig.load(prod_spec_file, base_path=base_spec_file)
@@ -281,6 +310,34 @@ def test_load_environment_variants_dev(self, base_spec_file: Path, dev_spec_file
281310
# Dev spec sets environment field
282311
assert spec["metadata"].get("environment") == "dev"
283312

313+
def test_load_environment_variants_raw_returns_dict(
314+
self, base_spec_file: Path, dev_spec_file: Path
315+
) -> None:
316+
"""Test explicit raw environment loading returns a dictionary."""
317+
spec = SpecConfig.load_environment_variants_raw(base_spec_file, environment="dev")
318+
319+
assert isinstance(spec, dict)
320+
assert spec["metadata"].get("environment") == "dev"
321+
322+
def test_load_environment_variant_validated_returns_netengine_spec(
323+
self, base_spec_file: Path, dev_spec_file: Path
324+
) -> None:
325+
"""Test validated environment loading returns a NetEngineSpec."""
326+
spec = SpecConfig.load_environment_variant_validated(base_spec_file, environment="dev")
327+
328+
assert isinstance(spec, NetEngineSpec)
329+
assert spec.metadata.name == "test-network"
330+
assert spec.metadata.environment == "dev"
331+
332+
def test_load_environment_variant_validated_rejects_invalid_specs(
333+
self, base_spec_file: Path
334+
) -> None:
335+
"""Test validated environment loading rejects invalid composed specs."""
336+
overrides = {"substrate": {"networks": {"bad": {"type": "bridge", "subnet": "bad"}}}}
337+
338+
with pytest.raises(SpecLoadError, match="invalid CIDR"):
339+
SpecConfig.load_environment_variant_validated(base_spec_file, overrides=overrides)
340+
284341
def test_load_environment_variants_prod(
285342
self, base_spec_file: Path, prod_spec_file: Path
286343
) -> None:

0 commit comments

Comments
 (0)