Skip to content

Commit 79e78ad

Browse files
committed
almost there
1 parent 5c268b6 commit 79e78ad

8 files changed

Lines changed: 100 additions & 97 deletions

File tree

autotest/test_dfn.py

Lines changed: 41 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,15 @@
22

33
import pytest
44

5-
from modflow_devtools.dfn import Dfn, load
5+
from modflow_devtools.dfn import _load_common, load, load_all, load_tree
66
from modflow_devtools.dfn.fetch import fetch_dfns
77
from modflow_devtools.dfn2toml import convert
88
from modflow_devtools.markers import requires_pkg
99

1010
PROJ_ROOT = Path(__file__).parents[1]
1111
DFN_DIR = PROJ_ROOT / "autotest" / "temp" / "dfn"
1212
TOML_DIR = DFN_DIR / "toml"
13-
VERSIONS = {1: DFN_DIR, 2: TOML_DIR}
13+
SPEC_DIRS = {1: DFN_DIR, 2: TOML_DIR}
1414
MF6_OWNER = "MODFLOW-ORG"
1515
MF6_REPO = "modflow6"
1616
MF6_REF = "develop"
@@ -30,12 +30,13 @@ def pytest_generate_tests(metafunc):
3030
if "toml_name" in metafunc.fixturenames:
3131
convert(DFN_DIR, TOML_DIR)
3232
dfn_paths = list(DFN_DIR.glob("*.dfn"))
33-
assert all(
34-
(TOML_DIR / f"{dfn.stem.replace('-nam', '')}.toml").is_file()
33+
expected_toml_paths = [
34+
TOML_DIR / f"{dfn.stem.replace('-nam', '')}.toml"
3535
for dfn in dfn_paths
3636
if "common" not in dfn.stem
37-
)
37+
]
3838
toml_names = [toml.stem for toml in TOML_DIR.glob("*.toml")]
39+
assert all(toml_path.exists() for toml_path in expected_toml_paths)
3940
metafunc.parametrize("toml_name", toml_names, ids=toml_names)
4041

4142

@@ -45,23 +46,24 @@ def test_load_v1(dfn_name):
4546
(DFN_DIR / "common.dfn").open() as common_file,
4647
(DFN_DIR / f"{dfn_name}.dfn").open() as dfn_file,
4748
):
48-
common, _ = load(common_file)
49+
common, _ = _load_common(common_file)
4950
dfn = load(dfn_file, name=dfn_name, common=common)
50-
assert any(dfn)
51+
assert any(dfn.fields)
5152

5253

5354
@requires_pkg("boltons")
5455
def test_load_v2(toml_name):
5556
with (TOML_DIR / f"{toml_name}.toml").open(mode="rb") as toml_file:
56-
toml = Dfn.load(toml_file, name=toml_name, version=2)
57-
assert any(toml)
57+
dfn = load(toml_file, name=toml_name, format="toml")
58+
assert any(dfn.fields)
5859

5960

6061
@requires_pkg("boltons")
61-
@pytest.mark.parametrize("version", list(VERSIONS.keys()))
62-
def test_load_all(version):
63-
dfns = Dfn.load_all(VERSIONS[version], version=version)
64-
assert any(dfns)
62+
@pytest.mark.parametrize("schema_version", list(SPEC_DIRS.keys()))
63+
def test_load_all(schema_version):
64+
path = SPEC_DIRS[schema_version]
65+
dfns = load_all(path)
66+
assert all(any(dfn.fields) for dfn in dfns.values())
6567

6668

6769
@requires_pkg("boltons")
@@ -90,37 +92,34 @@ def test_load_tree():
9092
assert gwf_data["name"] == "gwf"
9193
assert gwf_data["parent"] == "sim"
9294

93-
# Test hierarchy enforcement and completeness
94-
dfns = Dfn.load_all(tmp_path, version=2)
95-
roots = [name for name, dfn in dfns.items() if not dfn.get("parent")]
96-
assert len(roots) == 1
97-
assert roots[0] == "sim"
98-
95+
dfns = load_all(tmp_path)
96+
root = load_tree(tmp_path)
97+
roots = []
9998
for dfn in dfns.values():
100-
parent = dfn.get("parent")
101-
if parent:
102-
assert parent in dfns
103-
104-
# Test tree building and navigation
105-
tree = Dfn.load_tree(tmp_path, version=2)
106-
assert "sim" in tree
107-
assert tree["sim"]["name"] == "sim"
108-
109-
for model_type in ["gwf", "gwt", "gwe"]:
110-
if model_type in tree["sim"]:
111-
assert tree["sim"][model_type]["name"] == model_type
112-
assert tree["sim"][model_type]["parent"] == "sim"
113-
114-
if "gwf" in tree["sim"]:
99+
if dfn.parent:
100+
assert dfn.parent in dfns
101+
else:
102+
roots.append(dfn.name)
103+
assert len(roots) == 1
104+
assert root.name == "sim"
105+
assert root == roots[0]
106+
107+
model_types = ["gwf", "gwt", "gwe"]
108+
models = root.children or {}
109+
for model_type in model_types:
110+
if model_type in models:
111+
assert models[model_type].name == model_type
112+
assert models[model_type].parent == "sim"
113+
114+
if "gwf" in models:
115+
pkgs = models["gwf"].children or {}
115116
gwf_packages = [
116-
k
117-
for k in tree["sim"]["gwf"].keys()
118-
if k.startswith("gwf-") and isinstance(tree["sim"]["gwf"][k], dict)
117+
k for k in pkgs if k.startswith("gwf-") and isinstance(pkgs[k], dict)
119118
]
120119
assert len(gwf_packages) > 0
121120

122-
if "gwf-dis" in tree["sim"]["gwf"]:
123-
dis = tree["sim"]["gwf"]["gwf-dis"]
124-
assert dis["name"] == "gwf-dis"
125-
assert dis["parent"] == "gwf"
126-
assert "options" in dis or "dimensions" in dis
121+
if dis := pkgs.get("gwf-dis", None):
122+
assert dis.name == "gwf-dis"
123+
assert dis.parent == "gwf"
124+
assert "options" in (dis.blocks or {})
125+
assert "dimensions" in (dis.blocks or {})

modflow_devtools/dfn/__init__.py

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ def _record_fields() -> Fields:
257257
"block": block,
258258
"description": description,
259259
"default": default,
260-
**_field,
260+
**field_dict,
261261
}
262262
)
263263

@@ -337,55 +337,52 @@ def map(
337337
raise ValueError(f"Unsupported schema version: {schema_version}. Expected 1 or 2.")
338338

339339

340-
def load(f: str | PathLike, **kwargs) -> Dfn:
340+
def load(f, name: str, format: str = "dfn", **kwargs) -> Dfn:
341341
"""Load a MODFLOW 6 definition file."""
342-
path = Path(f).expanduser().resolve()
343-
if path.suffix == ".dfn":
344-
fields, meta = parse_dfn(path, **kwargs)
345-
blocks = (
346-
{}
347-
if "common" in path.stem
348-
else {
349-
block_name: {field["name"]: FieldV1.from_dict(field) for field in block}
350-
for block_name, block in groupby(
351-
fields.values(), lambda field: field["block"]
352-
)
353-
}
354-
)
342+
if format == "dfn":
343+
fields, meta = parse_dfn(f, **kwargs)
344+
blocks = {
345+
block_name: {field["name"]: FieldV1.from_dict(field) for field in block}
346+
for block_name, block in groupby(
347+
fields.values(), lambda field: field["block"]
348+
)
349+
}
355350
return Dfn(
356-
name=path.stem,
351+
name=name,
357352
schema_version=Version("1"),
358353
parent=try_parse_parent(meta),
359354
advanced=is_advanced_package(meta),
360355
multi=is_multi_package(meta),
361356
blocks=blocks,
362357
)
363-
elif path.suffix == ".toml":
364-
with path.open("rb") as file:
365-
return Dfn(**tomli.load(file))
366-
raise ValueError(f"Unsupported file type: {path.suffix}. Expected .dfn or .toml.")
358+
elif format == "toml":
359+
return Dfn(name=name, **tomli.load(f))
360+
raise ValueError(f"Unsupported format: {format}. Expected 'dfn' or 'toml'.")
367361

368362

369-
def _load_common(path: str | PathLike) -> Fields:
370-
common_path = Path(path).expanduser().resolve()
371-
if not common_path.is_file():
372-
return {}
373-
common, _ = parse_dfn(common_path)
363+
def _load_common(f) -> Fields:
364+
common, _ = parse_dfn(f)
374365
return common
375366

376367

377-
def load_all(dfndir: str | PathLike) -> Dfns:
368+
def load_all(path: str | PathLike) -> Dfns:
378369
"""Load a MODFLOW 6 specification from definition files in a directory."""
379370
exclude = ["common", "flopy"]
380-
dfndir = Path(dfndir).expanduser().resolve()
381-
dfns = {p.stem: p for p in dfndir.glob("*.dfn") if p.stem not in exclude}
382-
tomls = {p.stem: p for p in dfndir.glob("*.toml") if p.stem not in exclude}
383-
if dfns:
384-
common = _load_common(dfndir / "common.dfn")
385-
return {path.stem: load(path, common=common) for path in dfns.values()}
386-
if tomls:
387-
return {path.stem: load(path) for path in tomls.values()}
388-
return {} # TODO: raise instead?
371+
path = Path(path).expanduser().resolve()
372+
dfn_paths = {p.stem: p for p in path.glob("*.dfn") if p.stem not in exclude}
373+
toml_paths = {p.stem: p for p in path.glob("*.toml") if p.stem not in exclude}
374+
dfns = {}
375+
if dfn_paths:
376+
with (path / "common.dfn").open() as f:
377+
common = _load_common(f)
378+
for dfn_name, dfn_path in dfn_paths.items():
379+
with dfn_path.open() as f:
380+
dfns[dfn_name] = load(f, name=dfn_name, common=common, format="dfn")
381+
if toml_paths:
382+
for toml_name, toml_path in toml_paths.items():
383+
with toml_path.open() as f:
384+
dfns[toml_name] = load(f, name=toml_name, format="toml")
385+
return dfns
389386

390387

391388
def infer_tree(dfns: Dfns) -> Dfn:

modflow_devtools/dfn/parse.py

Lines changed: 4 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,4 @@
11
from ast import literal_eval
2-
from os import PathLike
3-
from pathlib import Path
42
from typing import Any
53
from warnings import warn
64

@@ -83,17 +81,14 @@ def is_multi_package(meta: list[str]) -> bool:
8381
return any("multi-package" in m for m in meta)
8482

8583

86-
def parse_dfn(
87-
path: str | PathLike,
88-
common: dict | None = None,
89-
) -> tuple[OMD, list[str]]:
84+
def parse_dfn(f, common: dict | None = None) -> tuple[OMD, list[str]]:
9085
"""
9186
Parse a DFN file into an ordered dict of fields and a list of metadata.
9287
9388
Parameters
9489
----------
95-
path : str | PathLike
96-
Path to the DFN file to parse.
90+
f : readable file-like
91+
A file-like object to read the DFN file from.
9792
common : dict, optional
9893
A dictionary of common variable definitions to use for
9994
description substitutions, by default None.
@@ -123,7 +118,7 @@ def parse_dfn(
123118
fields: list = []
124119
metadata: list = []
125120

126-
for line in Path(path).expanduser().resolve().open():
121+
for line in f:
127122
# parse metadata line
128123
if (line := line.strip()).startswith("#"):
129124
_, sep, tail = line.partition("flopy")

modflow_devtools/dfn/schema/v1.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ class FieldV1(Field):
1919
@classmethod
2020
def from_dict(cls, d: dict) -> "FieldV1":
2121
"""Create a FieldV1 instance from a dictionary."""
22-
return cls(**{k: v for k, v in d.items() if k in cls.__annotations__.keys()})
22+
keys = list(cls.__annotations__.keys()) + list(Field.__annotations__.keys())
23+
return cls(**{k: v for k, v in d.items() if k in keys})

modflow_devtools/dfn/schema/v2.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,5 @@ class FieldV2(Field):
1515
@classmethod
1616
def from_dict(cls, d: dict) -> "FieldV2":
1717
"""Create a FieldV2 instance from a dictionary."""
18-
return cls(**{k: v for k, v in d.items() if k in cls.__annotations__.keys()})
18+
keys = list(cls.__annotations__.keys()) + list(Field.__annotations__.keys())
19+
return cls(**{k: v for k, v in d.items() if k in keys})

modflow_devtools/dfn2toml.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@
66
from pathlib import Path
77

88
import tomli_w as tomli
9+
from boltons.iterutils import remap
910

1011
from modflow_devtools.dfn import load_all, map
12+
from modflow_devtools.misc import drop_none_or_empty
1113

1214
# mypy: ignore-errors
1315

@@ -22,7 +24,11 @@ def convert(indir: PathLike, outdir: PathLike, schema_version: str = "2") -> Non
2224
}
2325
for dfn_name, dfn in dfns.items():
2426
with Path.open(outdir / f"{dfn_name}.toml", "wb") as f:
25-
tomli.dump(asdict(dfn), f)
27+
dfn_dict = asdict(dfn)
28+
# TODO if we start using c/attrs, swap
29+
# this for a custom unstructuring hook
30+
dfn_dict["schema_version"] = str(dfn_dict["schema_version"])
31+
tomli.dump(remap(dfn_dict, visit=drop_none_or_empty), f)
2632

2733

2834
if __name__ == "__main__":

modflow_devtools/misc.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -581,3 +581,13 @@ def try_literal_eval(value: str) -> Any:
581581
return literal_eval(value)
582582
except (SyntaxError, ValueError):
583583
return value
584+
585+
586+
def drop_none_or_empty(path, key, value):
587+
"""
588+
Drop dictionary items with None or empty string values.
589+
For use with `boltons.iterutils.remap`.
590+
"""
591+
if value is None or value == "":
592+
return False
593+
return True

modflow_devtools/models.py

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -21,13 +21,7 @@
2121
from pooch import Pooch
2222

2323
import modflow_devtools
24-
from modflow_devtools.misc import get_model_paths
25-
26-
27-
def _drop_none_or_empty(path, key, value):
28-
if value is None or value == "":
29-
return False
30-
return True
24+
from modflow_devtools.misc import drop_none_or_empty, get_model_paths
3125

3226

3327
def _model_sort_key(k) -> int:
@@ -421,7 +415,7 @@ def index(
421415

422416
with self._registry_file_path.open("ab+") as registry_file:
423417
tomli_w.dump(
424-
remap(dict(sorted(files.items())), visit=_drop_none_or_empty),
418+
remap(dict(sorted(files.items())), visit=drop_none_or_empty),
425419
registry_file,
426420
)
427421

0 commit comments

Comments
 (0)