Skip to content

Commit b4c6fc0

Browse files
committed
feat(dfns): specify and reimplement v2 dfn schema
1 parent f9abb6a commit b4c6fc0

14 files changed

Lines changed: 3218 additions & 811 deletions

File tree

autotest/test_dfns.py

Lines changed: 83 additions & 85 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
from dataclasses import asdict
21
from pathlib import Path
32

43
import pytest
@@ -8,7 +7,17 @@
87
from modflow_devtools.dfns.dfn2toml import convert, is_valid
98
from modflow_devtools.dfns.fetch import fetch_dfns
109
from modflow_devtools.dfns.schema.v1 import FieldV1
11-
from modflow_devtools.dfns.schema.v2 import FieldV2
10+
from modflow_devtools.dfns.schema.v2 import (
11+
Array,
12+
Double,
13+
FieldBase,
14+
FieldV2,
15+
Integer,
16+
Keyword,
17+
Record,
18+
String,
19+
Union,
20+
)
1221
from modflow_devtools.markers import requires_pkg
1322

1423
PROJ_ROOT = Path(__file__).parents[1]
@@ -31,13 +40,11 @@ def pytest_generate_tests(metafunc):
3140
metafunc.parametrize("dfn_name", dfn_names, ids=dfn_names)
3241

3342
if "toml_name" in metafunc.fixturenames:
34-
# Only convert if TOML files don't exist yet (avoid repeated conversions)
3543
dfn_paths = [p for p in DFN_DIR.glob("*.dfn") if p.stem not in ["common", "flopy"]]
3644
if not TOML_DIR.exists() or not all(
3745
(TOML_DIR / f"{dfn.stem}.toml").is_file() for dfn in dfn_paths
3846
):
3947
convert(DFN_DIR, TOML_DIR)
40-
# Verify all expected TOML files were created
4148
assert all((TOML_DIR / f"{dfn.stem}.toml").is_file() for dfn in dfn_paths)
4249
toml_names = [toml.stem for toml in TOML_DIR.glob("*.toml")]
4350
metafunc.parametrize("toml_name", toml_names, ids=toml_names)
@@ -108,11 +115,11 @@ def test_convert(function_tmpdir):
108115

109116
if gwf := models.get("gwf-nam", None):
110117
pkgs = gwf.children or {}
111-
pkgs = {k: v for k, v in pkgs.items() if k.startswith("gwf-") and isinstance(v, dict)}
118+
pkgs = {k: v for k, v in pkgs.items() if k.startswith("gwf-")}
112119
assert len(pkgs) > 0
113120
if dis := pkgs.get("gwf-dis", None):
114121
assert dis.name == "gwf-dis"
115-
assert dis.parent == "gwf"
122+
assert dis.parent == "gwf-nam"
116123
assert "options" in (dis.blocks or {})
117124
assert "dimensions" in (dis.blocks or {})
118125

@@ -166,7 +173,7 @@ def test_dfn_from_dict_roundtrip():
166173
multi=True,
167174
blocks={"options": {}},
168175
)
169-
d = asdict(original)
176+
d = original.model_dump()
170177
reconstructed = Dfn.from_dict(d)
171178
assert reconstructed.name == original.name
172179
assert reconstructed.schema_version == original.schema_version
@@ -183,9 +190,9 @@ def test_fieldv1_from_dict_ignores_extra_keys():
183190
"extra_key": "should be allowed",
184191
"another_extra": 123,
185192
}
186-
field = FieldV1.from_dict(d)
187-
assert field.name == "test_field"
188-
assert field.type == "keyword"
193+
f = FieldV1.from_dict(d)
194+
assert f.name == "test_field"
195+
assert f.type == "keyword"
189196

190197

191198
def test_fieldv1_from_dict_strict_mode():
@@ -199,6 +206,8 @@ def test_fieldv1_from_dict_strict_mode():
199206

200207

201208
def test_fieldv1_from_dict_roundtrip():
209+
from dataclasses import asdict
210+
202211
original = FieldV1(
203212
name="maxbound",
204213
type="integer",
@@ -222,9 +231,10 @@ def test_fieldv2_from_dict_ignores_extra_keys():
222231
"extra_key": "should be allowed",
223232
"another_extra": 123,
224233
}
225-
field = FieldV2.from_dict(d)
226-
assert field.name == "test_field"
227-
assert field.type == "keyword"
234+
f = FieldBase.from_dict(d)
235+
assert f.name == "test_field"
236+
assert f.type == "keyword"
237+
assert isinstance(f, Keyword)
228238

229239

230240
def test_fieldv2_from_dict_strict_mode():
@@ -234,22 +244,20 @@ def test_fieldv2_from_dict_strict_mode():
234244
"extra_key": "should cause error",
235245
}
236246
with pytest.raises(ValueError, match="Unrecognized keys in field data"):
237-
FieldV2.from_dict(d, strict=True)
247+
FieldBase.from_dict(d, strict=True)
238248

239249

240250
def test_fieldv2_from_dict_roundtrip():
241-
original = FieldV2(
251+
original = Integer(
242252
name="nper",
243-
type="integer",
244-
block="dimensions",
245253
description="number of stress periods",
246254
optional=False,
247255
)
248-
d = asdict(original)
249-
reconstructed = FieldV2.from_dict(d)
256+
d = original.model_dump()
257+
reconstructed = FieldBase.from_dict(d)
258+
assert isinstance(reconstructed, Integer)
250259
assert reconstructed.name == original.name
251260
assert reconstructed.type == original.type
252-
assert reconstructed.block == original.block
253261
assert reconstructed.description == original.description
254262
assert reconstructed.optional == original.optional
255263

@@ -276,12 +284,12 @@ def test_dfn_from_dict_with_v1_field_dicts():
276284
assert "options" in dfn.blocks
277285
assert "save_flows" in dfn.blocks["options"]
278286

279-
field = dfn.blocks["options"]["save_flows"]
280-
assert isinstance(field, FieldV1)
281-
assert field.name == "save_flows"
282-
assert field.type == "keyword"
283-
assert field.tagged is True
284-
assert field.in_record is False
287+
f = dfn.blocks["options"]["save_flows"]
288+
assert isinstance(f, FieldV1)
289+
assert f.name == "save_flows"
290+
assert f.type == "keyword"
291+
assert f.tagged is True
292+
assert f.in_record is False
285293

286294

287295
def test_dfn_from_dict_with_v2_field_dicts():
@@ -305,11 +313,11 @@ def test_dfn_from_dict_with_v2_field_dicts():
305313
assert "dimensions" in dfn.blocks
306314
assert "nper" in dfn.blocks["dimensions"]
307315

308-
field = dfn.blocks["dimensions"]["nper"]
309-
assert isinstance(field, FieldV2)
310-
assert field.name == "nper"
311-
assert field.type == "integer"
312-
assert field.optional is False
316+
f = dfn.blocks["dimensions"]["nper"]
317+
assert isinstance(f, Integer)
318+
assert f.name == "nper"
319+
assert f.type == "integer"
320+
assert f.optional is False
313321

314322

315323
def test_dfn_from_dict_defaults_to_v2_fields():
@@ -326,25 +334,26 @@ def test_dfn_from_dict_defaults_to_v2_fields():
326334
}
327335
dfn = Dfn.from_dict(d)
328336
assert dfn.blocks is not None
329-
field = dfn.blocks["options"]["some_field"]
330-
assert isinstance(field, FieldV2)
337+
f = dfn.blocks["options"]["some_field"]
338+
assert isinstance(f, Keyword)
339+
assert isinstance(f, FieldBase)
331340
assert dfn.schema_version == Version("2")
332341

333342

334343
def test_dfn_from_dict_with_already_deserialized_fields():
335-
field = FieldV2(name="test", type="keyword")
344+
kw = Keyword(name="test")
336345
d = {
337346
"schema_version": Version("2"),
338347
"name": "test-dfn",
339348
"blocks": {
340349
"options": {
341-
"test": field,
350+
"test": kw,
342351
},
343352
},
344353
}
345354
dfn = Dfn.from_dict(d)
346355
assert dfn.blocks is not None
347-
assert dfn.blocks["options"]["test"] is field
356+
assert dfn.blocks["options"]["test"] is kw
348357

349358

350359
@requires_pkg("boltons")
@@ -383,7 +392,7 @@ def test_validate_nonexistent_file(function_tmpdir):
383392

384393

385394
def test_fieldv1_to_fieldv2_conversion():
386-
"""Test that FieldV1 instances are properly converted to FieldV2."""
395+
"""Test that FieldV1 instances are properly converted to typed v2 instances."""
387396
from modflow_devtools.dfns import map
388397

389398
dfn_v1 = Dfn(
@@ -417,68 +426,57 @@ def test_fieldv1_to_fieldv2_conversion():
417426
assert "save_flows" in dfn_v2.blocks["options"]
418427

419428
save_flows = dfn_v2.blocks["options"]["save_flows"]
420-
assert isinstance(save_flows, FieldV2)
429+
assert isinstance(save_flows, Keyword)
430+
assert isinstance(save_flows, FieldBase)
421431
assert save_flows.name == "save_flows"
422432
assert save_flows.type == "keyword"
423-
assert save_flows.block == "options"
424433
assert save_flows.description == "save calculated flows"
425-
assert hasattr(save_flows, "tagged")
426434
assert not hasattr(save_flows, "in_record")
427435
assert not hasattr(save_flows, "reader")
428436

429437
some_float = dfn_v2.blocks["options"]["some_float"]
430-
assert isinstance(some_float, FieldV2)
438+
assert isinstance(some_float, Double)
431439
assert some_float.name == "some_float"
432440
assert some_float.type == "double"
433-
assert some_float.block == "options"
434441
assert some_float.description == "a floating point value"
435442

436443

437444
def test_fieldv1_to_fieldv2_conversion_with_children():
438-
"""Test that FieldV1 with nested children are properly converted to FieldV2."""
445+
"""Test that FieldV1 with nested children are properly converted to typed v2 instances."""
439446
from modflow_devtools.dfns import map
440447

441-
# Create nested fields for a record
442-
child_field_v1 = FieldV1(
443-
name="cellid",
444-
type="integer",
445-
block="period",
446-
description="cell identifier",
447-
in_record=True,
448-
tagged=False,
449-
)
450-
451-
parent_field_v1 = FieldV1(
452-
name="stress_period_data",
453-
type="recarray cellid",
454-
block="period",
455-
description="stress period data",
456-
in_record=False,
457-
)
458-
459448
dfn_v1 = Dfn(
460449
schema_version=Version("1"),
461450
name="test-dfn",
462451
blocks={
463452
"period": {
464-
"stress_period_data": parent_field_v1,
465-
"cellid": child_field_v1,
453+
"stress_period_data": FieldV1(
454+
name="stress_period_data",
455+
type="recarray cellid",
456+
block="period",
457+
description="stress period data",
458+
in_record=False,
459+
),
460+
"cellid": FieldV1(
461+
name="cellid",
462+
type="integer",
463+
block="period",
464+
description="cell identifier",
465+
in_record=True,
466+
tagged=False,
467+
),
466468
}
467469
},
468470
)
469471

470-
# Convert to v2
471472
dfn_v2 = map(dfn_v1, schema_version="2")
472-
473-
# Check that all fields are FieldV2 instances
474473
assert dfn_v2.blocks is not None
475-
for block_name, block_fields in dfn_v2.blocks.items():
476-
for field_name, field in block_fields.items():
477-
assert isinstance(field, FieldV2)
478-
# Check nested children too
479-
if field.children:
480-
for child_name, child_field in field.children.items():
481-
assert isinstance(child_field, FieldV2)
474+
for block_fields in dfn_v2.blocks.values():
475+
for f in block_fields.values():
476+
assert isinstance(f, FieldBase)
477+
if f.children:
478+
for child in f.children.values():
479+
assert isinstance(child, FieldBase)
482480

483481

484482
def test_period_block_conversion():
@@ -517,13 +515,13 @@ def test_period_block_conversion():
517515
dfn_v2 = map(dfn_v1, schema_version="2")
518516

519517
period_block = dfn_v2.blocks["period"]
520-
assert "cellid" not in period_block # cellid removed
518+
assert "cellid" not in period_block
521519
assert "q" in period_block
522-
assert isinstance(period_block["q"], FieldV2)
523-
# Shape should be transformed: maxbound removed, nper and nnodes added
524-
assert "nper" in period_block["q"].shape
525-
assert "nnodes" in period_block["q"].shape
526-
assert "maxbound" not in period_block["q"].shape
520+
q = period_block["q"]
521+
assert isinstance(q, Array)
522+
assert "nper" in q.shape
523+
assert "nodes" in q.shape
524+
assert "maxbound" not in q.shape
527525

528526

529527
def test_record_type_conversion():
@@ -560,17 +558,17 @@ def test_record_type_conversion():
560558
dfn_v2 = map(dfn_v1, schema_version="2")
561559

562560
auxrecord = dfn_v2.blocks["options"]["auxrecord"]
563-
assert isinstance(auxrecord, FieldV2)
561+
assert isinstance(auxrecord, Record)
564562
assert auxrecord.type == "record"
565563
assert auxrecord.children is not None
566564
assert "auxiliary" in auxrecord.children
567565
assert "auxname" in auxrecord.children
568-
assert isinstance(auxrecord.children["auxiliary"], FieldV2)
569-
assert isinstance(auxrecord.children["auxname"], FieldV2)
566+
assert isinstance(auxrecord.children["auxiliary"], Keyword)
567+
assert isinstance(auxrecord.children["auxname"], String)
570568

571569

572570
def test_keystring_type_conversion():
573-
"""Test keystring type conversion."""
571+
"""Test keystring (union) type conversion."""
574572
from modflow_devtools.dfns import map
575573

576574
dfn_v1 = Dfn(
@@ -610,7 +608,7 @@ def test_keystring_type_conversion():
610608
dfn_v2 = map(dfn_v1, schema_version="2")
611609

612610
obs_rec = dfn_v2.blocks["options"]["obs_filerecord"]
613-
assert isinstance(obs_rec, FieldV2)
611+
assert isinstance(obs_rec, Record)
614612
assert obs_rec.type == "record"
615613
assert obs_rec.children is not None
616-
assert all(isinstance(child, FieldV2) for child in obs_rec.children.values())
614+
assert all(isinstance(child, FieldBase) for child in obs_rec.children.values())

autotest/test_dfns_registry.py

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -131,14 +131,12 @@ def test_hierarchical_access(self, dfn_dir):
131131
# Root should be sim-nam
132132
assert spec.root.name == "sim-nam"
133133

134-
# Root should have children
135-
assert spec.root.children is not None
136-
assert "gwf-nam" in spec.root.children
137-
138-
# gwf-nam should have its own children
139-
gwf_nam = spec.root.children["gwf-nam"]
140-
assert gwf_nam.children is not None
141-
assert "gwf-chd" in gwf_nam.children
134+
# children_of is the query API; components carry parent, not children
135+
root_children = spec.children_of("sim-nam")
136+
assert "gwf-nam" in root_children
137+
138+
gwf_nam_children = spec.children_of("gwf-nam")
139+
assert "gwf-chd" in gwf_nam_children
142140

143141
def test_load_empty_directory_raises(self, tmp_path):
144142
"""Test that loading from empty directory raises ValueError."""

0 commit comments

Comments
 (0)