diff --git a/autotest/test_dfn.py b/autotest/test_dfn.py index 70883551..e5158c8e 100644 --- a/autotest/test_dfn.py +++ b/autotest/test_dfn.py @@ -30,7 +30,7 @@ def pytest_generate_tests(metafunc): convert(DFN_DIR, TOML_DIR) dfn_paths = list(DFN_DIR.glob("*.dfn")) assert all( - (TOML_DIR / f"{dfn.stem}.toml").is_file() + (TOML_DIR / f"{dfn.stem.replace('-nam', '')}.toml").is_file() for dfn in dfn_paths if "common" not in dfn.stem ) @@ -61,3 +61,65 @@ def test_load_v2(toml_name): def test_load_all(version): dfns = Dfn.load_all(VERSIONS[version], version=version) assert any(dfns) + + +@requires_pkg("boltons") +def test_load_tree(): + import tempfile + + import tomli + + with tempfile.TemporaryDirectory() as tmp_dir: + tmp_path = Path(tmp_dir) + convert(DFN_DIR, tmp_path) + + # Test file conversion and naming + assert (tmp_path / "sim.toml").exists() + assert (tmp_path / "gwf.toml").exists() + assert not (tmp_path / "sim-nam.toml").exists() + + # Test parent relationships in files + with (tmp_path / "sim.toml").open("rb") as f: + sim_data = tomli.load(f) + assert sim_data["name"] == "sim" + assert "parent" not in sim_data + + with (tmp_path / "gwf.toml").open("rb") as f: + gwf_data = tomli.load(f) + assert gwf_data["name"] == "gwf" + assert gwf_data["parent"] == "sim" + + # Test hierarchy enforcement and completeness + dfns = Dfn.load_all(tmp_path, version=2) + roots = [name for name, dfn in dfns.items() if not dfn.get("parent")] + assert len(roots) == 1 + assert roots[0] == "sim" + + for dfn in dfns.values(): + parent = dfn.get("parent") + if parent: + assert parent in dfns + + # Test tree building and navigation + tree = Dfn.load_tree(tmp_path, version=2) + assert "sim" in tree + assert tree["sim"]["name"] == "sim" + + for model_type in ["gwf", "gwt", "gwe"]: + if model_type in tree["sim"]: + assert tree["sim"][model_type]["name"] == model_type + assert tree["sim"][model_type]["parent"] == "sim" + + if "gwf" in tree["sim"]: + gwf_packages = [ + k + for k in tree["sim"]["gwf"].keys() + if k.startswith("gwf-") and isinstance(tree["sim"]["gwf"][k], dict) + ] + assert len(gwf_packages) > 0 + + if "gwf-dis" in tree["sim"]["gwf"]: + dis = tree["sim"]["gwf"]["gwf-dis"] + assert dis["name"] == "gwf-dis" + assert dis["parent"] == "gwf" + assert "options" in dis or "dimensions" in dis diff --git a/modflow_devtools/dfn.py b/modflow_devtools/dfn.py index 74e59682..4d8c70a0 100644 --- a/modflow_devtools/dfn.py +++ b/modflow_devtools/dfn.py @@ -190,6 +190,7 @@ class Dfn(TypedDict): name: str advanced: bool = False multi: bool = False + parent: str | None = None ref: Ref | None = None sln: Sln | None = None fkeys: Dfns | None = None @@ -639,6 +640,41 @@ def load_all(dfndir: PathLike, version: FormatVersion = 1) -> Dfns: else: raise ValueError(f"Unsupported version, expected one of {version.__args__}") + @staticmethod + def load_tree(dfndir: PathLike, version: FormatVersion = 2) -> dict: + """Load all definitions and return as hierarchical tree.""" + dfns = Dfn.load_all(dfndir, version) + return infer_tree(dfns) + + +def infer_tree(dfns: dict[str, Dfn]) -> dict: + """Infer the component hierarchy from definitions. + + Enforces single root requirement - must be exactly one component + with no parent, and it must be named 'sim'. + """ + roots = [name for name, dfn in dfns.items() if not dfn.get("parent")] + + if len(roots) != 1: + raise ValueError( + f"Expected exactly one root component, found {len(roots)}: {roots}" + ) + + root_name = roots[0] + if root_name != "sim": + raise ValueError(f"Root component must be named 'sim', found '{root_name}'") + + def add_children(node_name: str) -> dict: + node = dfns[node_name].copy() + children = [ + name for name, dfn in dfns.items() if dfn.get("parent") == node_name + ] + for child in children: + node[child] = add_children(child) + return node + + return {root_name: add_children(root_name)} + def get_dfns( owner: str, repo: str, ref: str, outdir: str | PathLike, verbose: bool = False diff --git a/modflow_devtools/dfn2toml.py b/modflow_devtools/dfn2toml.py index 96a68661..7d346e6d 100644 --- a/modflow_devtools/dfn2toml.py +++ b/modflow_devtools/dfn2toml.py @@ -17,7 +17,47 @@ def convert(indir: PathLike, outdir: PathLike): outdir = Path(outdir).expanduser().absolute() outdir.mkdir(exist_ok=True, parents=True) for dfn in Dfn.load_all(indir).values(): - with Path.open(outdir / f"{dfn['name']}.toml", "wb") as f: + dfn_name = dfn["name"] + + # Determine new filename and parent relationship + if dfn_name == "sim-nam": + filename = "sim.toml" + dfn = dfn.copy() + dfn["name"] = "sim" + # No parent - this is root + elif dfn_name.endswith("-nam"): + # Model name files: gwf-nam -> gwf.toml, parent = "sim" + model_type = dfn_name[:-4] # Remove "-nam" + filename = f"{model_type}.toml" + dfn = dfn.copy() + dfn["name"] = model_type + dfn["parent"] = "sim" + elif dfn_name.startswith("exg-"): + # Exchanges: parent = "sim" + filename = f"{dfn_name}.toml" + dfn = dfn.copy() + dfn["parent"] = "sim" + elif dfn_name.startswith("sln-"): + # Solutions: parent = "sim" + filename = f"{dfn_name}.toml" + dfn = dfn.copy() + dfn["parent"] = "sim" + elif dfn_name.startswith("utl-"): + # Utilities: parent = "sim" + filename = f"{dfn_name}.toml" + dfn = dfn.copy() + dfn["parent"] = "sim" + elif "-" in dfn_name: + # Packages: gwf-dis -> parent = "gwf" + model_type = dfn_name.split("-")[0] + filename = f"{dfn_name}.toml" + dfn = dfn.copy() + dfn["parent"] = model_type + else: + # Default case + filename = f"{dfn_name}.toml" + + with Path.open(outdir / filename, "wb") as f: def drop_none_or_empty(path, key, value): if value is None or value == "" or value == [] or value == {}: