From dc86e843be4f572e2af7925ff7bd9ce2d1b55c46 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 21 Oct 2026 03:34:07 -0700 Subject: [PATCH 1/3] generate reference fields from the DFNs' model-name keys A component's columns in its row in the simulation's name file keyed (fk) to the simulation's models become its reference fields, in row order: an exchange's exgmnamea/exgmnameb, a solution's slnmnames (kept as `models`). The Exchange and Solution bases keep the semantics, via a _reference_problem hook replacing reference()'s check. Binding and ingress read and set reference fields in order instead of per type. The generated modules were produced with sim-nam annotated as devtools' sim-nam-model-keys branch will annotate it. Co-Authored-By: Claude Opus 5.5 --- flopy4/mf6/component.py | 15 ++++-- flopy4/mf6/converter/binding.py | 15 ++++-- flopy4/mf6/converter/ingress/structure.py | 20 +++---- flopy4/mf6/ems.py | 4 ++ flopy4/mf6/exchange.py | 43 ++++++++-------- flopy4/mf6/exg/chfgwf.py | 4 ++ flopy4/mf6/exg/gwegwe.py | 4 ++ flopy4/mf6/exg/gwfgwe.py | 5 ++ flopy4/mf6/exg/gwfgwf.py | 4 ++ flopy4/mf6/exg/gwfgwt.py | 5 ++ flopy4/mf6/exg/gwfprt.py | 5 ++ flopy4/mf6/exg/gwtgwt.py | 4 ++ flopy4/mf6/exg/olfgwf.py | 4 ++ flopy4/mf6/ims.py | 3 ++ flopy4/mf6/solution.py | 19 ++++--- flopy4/mf6/utils/codegen/make.py | 63 +++++++++++++++++++++++ flopy4/spec.py | 20 +++---- test/mf6/test_mf6_codegen.py | 44 ++++++++++++++++ 18 files changed, 222 insertions(+), 59 deletions(-) diff --git a/flopy4/mf6/component.py b/flopy4/mf6/component.py index f4eb3e5c2..412c0154e 100644 --- a/flopy4/mf6/component.py +++ b/flopy4/mf6/component.py @@ -7,7 +7,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Optional import attrs -from attrs import fields +from attrs import Attribute, fields from flopy4.dimensions import DimensionResolverMixin from flopy4.mf6.constants import MF6 @@ -575,17 +575,22 @@ def _retarget_references(self) -> None: if any(a is not b for a, b in zip(referenced(new), referenced(value))): setattr(self, f.name, new) + def _reference_problem(self, field: Attribute, model: "Component") -> str | None: + """What's wrong with reference field `field` referring to `model` + (one of the simulation's models), or None. Subclasses say what they + accept (e.g. the model types an exchange connects).""" + return None + def _reference_errors(self) -> list[str]: """What's wrong with this component's references: a required one - missing, one not among the simulation's models, or one its field's - check finds a problem with (see `flopy4.spec.reference`).""" + missing, one not among the simulation's models, or one + `_reference_problem` finds a problem with.""" errors = [] models = getattr(self._parent, "models", []) for f in self._reference_fields(): value = self._resolved(f.name) if value is None and f.metadata["required"]: errors.append(f"{self.name} has no {f.name}") # type: ignore[attr-defined] - check = f.metadata["check"] for v in value if isinstance(value, list) else [value]: if v is None: continue @@ -594,7 +599,7 @@ def _reference_errors(self) -> list[str]: f"{self.name} refers to model {reference_names(v)!r} in {f.name}, " # type: ignore[attr-defined] "which isn't in the simulation" ) - elif check is not None and (problem := check(self, v)): + elif problem := self._reference_problem(f, v): errors.append(f"{self.name} {problem}") # type: ignore[attr-defined] return errors diff --git a/flopy4/mf6/converter/binding.py b/flopy4/mf6/converter/binding.py index f8e24a7a2..0039fe11e 100644 --- a/flopy4/mf6/converter/binding.py +++ b/flopy4/mf6/converter/binding.py @@ -20,6 +20,11 @@ def component_ftype(cls: type) -> str: return f"{cls_name.upper()}6" +def _flat(values) -> list: + """Reference fields' values as a row's terms, lists spread out.""" + return [v for value in values for v in (value if isinstance(value, list) else [value])] + + @define class Binding: """A serializable representation of a component.""" @@ -37,11 +42,13 @@ def to_tuple(self) -> tuple[str, ...]: @classmethod def from_component(cls, component: Component) -> "Binding": def _get_binding_terms(component: Component) -> tuple[str, ...] | None: - if isinstance(component, Exchange): - return tuple(reference_names([component.exgmnamea, component.exgmnameb])) - elif isinstance(component, Solution): + if isinstance(component, Solution): + # a lone solution listing none solves them all return tuple(reference_names(component._solved_models())) - elif isinstance(component, (Model, Package)): + if refs := component._reference_fields(): + # the reference fields are the row's terms, in order + return tuple(_flat(reference_names(getattr(component, f.name)) for f in refs)) + if isinstance(component, (Model, Package)): return (component.name,) # type: ignore return None diff --git a/flopy4/mf6/converter/ingress/structure.py b/flopy4/mf6/converter/ingress/structure.py index ad375ae3b..aad812289 100644 --- a/flopy4/mf6/converter/ingress/structure.py +++ b/flopy4/mf6/converter/ingress/structure.py @@ -568,19 +568,19 @@ def _parse_readarray_period_block( def _apply_binding_terms(child: Any, terms: list, models: list) -> None: - from flopy4.mf6.exchange import Exchange - from flopy4.mf6.solution import Solution - + """Set the child's reference fields from the rest of its row, in + order: a single one takes a term, a list the rest.""" # the models loaded so far, so a loaded exchange or solution holds # them as a built one does; unknown names stay found = resolve([str(t) for t in terms], models) - if isinstance(child, Exchange): - if len(found) > 0: - child.exgmnamea = found[0] - if len(found) > 1: - child.exgmnameb = found[1] - elif isinstance(child, Solution) and found: - child.models = found + for f in child._reference_fields(): + if not found: + break + if f.metadata["many"]: + value, found = found, [] + else: + value, found = found[0], found[1:] + setattr(child, f.name, value) def _disambiguate_ga_variant(candidates: "list[type[Component]]", path: Path) -> "type[Component]": diff --git a/flopy4/mf6/ems.py b/flopy4/mf6/ems.py index ce44218bd..2f4c757e9 100644 --- a/flopy4/mf6/ems.py +++ b/flopy4/mf6/ems.py @@ -3,7 +3,9 @@ import attrs +from flopy4.mf6.model import Model from flopy4.mf6.solution import Solution +from flopy4.spec import reference @attrs.define(kw_only=True, slots=False) @@ -11,3 +13,5 @@ class Ems(Solution): dfn_name: ClassVar[str] = "sln-ems" slntype: ClassVar[str] = "ems" + + models: list[Model | str] = reference() diff --git a/flopy4/mf6/exchange.py b/flopy4/mf6/exchange.py index 1a7225936..ac5a40d02 100644 --- a/flopy4/mf6/exchange.py +++ b/flopy4/mf6/exchange.py @@ -3,39 +3,26 @@ from typing import TYPE_CHECKING, Optional import attrs +from attrs import Attribute from flopy4.mf6.package import Package -from flopy4.spec import reference if TYPE_CHECKING: from flopy4.mf6.model import Model -def _connects(side: int): - """A check that a model on `side` (0 for a, 1 for b) is of the type the - exchange connects there (see `Exchange.model_types`).""" - - def check(exg: "Exchange", model: "Model") -> "str | None": - mtype = exg.model_types[side] - if type(model).dfn_name.partition("-")[0] == mtype: # type: ignore[attr-defined] - return None - return ( - f"({type(exg).__name__}) needs a {mtype.upper()} model {'ab'[side]}, " - f"not {model.name!r} ({type(model).__name__})" # type: ignore[attr-defined] - ) - - return check - - @attrs.define(kw_only=True, slots=False) class Exchange(Package, ABC): + """An exchange. Its `exgmnamea` and `exgmnameb` (generated, from its + row in the simulation's name file) are the models it connects, as + objects or names, of the types in `model_types`. The simulation owns + its models; an exchange only refers to them.""" + exgtype: Optional[type] = attrs.field(default=None) # type: ignore exgfile: Optional[Path] = attrs.field(default=None) # type: ignore - exgmnamea: "Model | str | None" = reference(many=False, check=_connects(0), required=True) - """The first model the exchange connects, as an object or a name. The - simulation owns its models; an exchange only refers to them.""" - exgmnameb: "Model | str | None" = reference(many=False, check=_connects(1), required=True) - """The second model the exchange connects, as an object or a name.""" + if TYPE_CHECKING: + exgmnamea: "Model | str | None" + exgmnameb: "Model | str | None" @property def model_types(self) -> tuple[str, str]: @@ -44,3 +31,15 @@ def model_types(self) -> tuple[str, str]: letters.)""" pair = type(self).dfn_name.partition("-")[2] # type: ignore[attr-defined] return pair[:3], pair[3:] + + def _reference_problem(self, field: Attribute, model) -> "str | None": + """A problem if the model isn't of the type the exchange connects on + the field's side (its references are a's, then b's, in row order).""" + side = [f.name for f in self._reference_fields()].index(field.name) + mtype = self.model_types[side] + if type(model).dfn_name.partition("-")[0] == mtype: + return None + return ( + f"({type(self).__name__}) needs a {mtype.upper()} model {'ab'[side]}, " + f"not {model.name!r} ({type(model).__name__})" + ) diff --git a/flopy4/mf6/exg/chfgwf.py b/flopy4/mf6/exg/chfgwf.py index e001e6a67..8beac65c2 100644 --- a/flopy4/mf6/exg/chfgwf.py +++ b/flopy4/mf6/exg/chfgwf.py @@ -5,8 +5,10 @@ from flopy4.mf6.exchange import Exchange from flopy4.mf6.item import Item +from flopy4.mf6.model import Model from flopy4.mf6.spec import cellid_, child, double, integer, keyword, list_ from flopy4.mf6.utl.obs import Obs +from flopy4.spec import reference @attrs.define(kw_only=True, slots=False) @@ -22,6 +24,8 @@ class Exchangedata(Item): bedleak: float = double() cfact: float = double() + exgmnamea: Model | str | None = reference(many=False, required=True) + exgmnameb: Model | str | None = reference(many=False, required=True) print_input: bool = keyword( default=False, block="options", diff --git a/flopy4/mf6/exg/gwegwe.py b/flopy4/mf6/exg/gwegwe.py index 7e2150345..72ab9b7ab 100644 --- a/flopy4/mf6/exg/gwegwe.py +++ b/flopy4/mf6/exg/gwegwe.py @@ -9,8 +9,10 @@ from flopy4.mf6.exchange import Exchange from flopy4.mf6.gwe.mve import Mve from flopy4.mf6.item import Item +from flopy4.mf6.model import Model from flopy4.mf6.spec import array, cellid_, child, double, integer, keyword, list_, string from flopy4.mf6.utl.obs import Obs +from flopy4.spec import reference @attrs.define(kw_only=True, slots=False) @@ -36,6 +38,8 @@ class Exchangedata(Item): default=None, empty_is_none=True, optional=True, when="boundnames" ) + exgmnamea: Model | str | None = reference(many=False, required=True) + exgmnameb: Model | str | None = reference(many=False, required=True) gwfmodelname1: str = string( default=None, block="options", diff --git a/flopy4/mf6/exg/gwfgwe.py b/flopy4/mf6/exg/gwfgwe.py index ff2707531..9075632a0 100644 --- a/flopy4/mf6/exg/gwfgwe.py +++ b/flopy4/mf6/exg/gwfgwe.py @@ -4,8 +4,13 @@ import attrs from flopy4.mf6.exchange import Exchange +from flopy4.mf6.model import Model +from flopy4.spec import reference @attrs.define(kw_only=True, slots=False) class Gwfgwe(Exchange): dfn_name: ClassVar[str] = "exg-gwfgwe" + + exgmnamea: Model | str | None = reference(many=False, required=True) + exgmnameb: Model | str | None = reference(many=False, required=True) diff --git a/flopy4/mf6/exg/gwfgwf.py b/flopy4/mf6/exg/gwfgwf.py index a73f35854..495929047 100644 --- a/flopy4/mf6/exg/gwfgwf.py +++ b/flopy4/mf6/exg/gwfgwf.py @@ -10,9 +10,11 @@ from flopy4.mf6.gwf.gnc import Gnc from flopy4.mf6.gwf.mvr import Mvr from flopy4.mf6.item import Item +from flopy4.mf6.model import Model from flopy4.mf6.record import Record from flopy4.mf6.spec import array, cellid_, child, double, integer, keyword, list_, record, string from flopy4.mf6.utl.obs import Obs +from flopy4.spec import reference @attrs.define(kw_only=True, slots=False) @@ -43,6 +45,8 @@ class Exchangedata(Item): default=None, empty_is_none=True, optional=True, when="boundnames" ) + exgmnamea: Model | str | None = reference(many=False, required=True) + exgmnameb: Model | str | None = reference(many=False, required=True) auxiliary: Optional[NDArray[np.str_]] = array( default=None, converter=attrs.converters.optional(to_array(np.str_)), diff --git a/flopy4/mf6/exg/gwfgwt.py b/flopy4/mf6/exg/gwfgwt.py index d6ea4485a..84723cca5 100644 --- a/flopy4/mf6/exg/gwfgwt.py +++ b/flopy4/mf6/exg/gwfgwt.py @@ -4,8 +4,13 @@ import attrs from flopy4.mf6.exchange import Exchange +from flopy4.mf6.model import Model +from flopy4.spec import reference @attrs.define(kw_only=True, slots=False) class Gwfgwt(Exchange): dfn_name: ClassVar[str] = "exg-gwfgwt" + + exgmnamea: Model | str | None = reference(many=False, required=True) + exgmnameb: Model | str | None = reference(many=False, required=True) diff --git a/flopy4/mf6/exg/gwfprt.py b/flopy4/mf6/exg/gwfprt.py index c3cf13c1d..3e773ab3c 100644 --- a/flopy4/mf6/exg/gwfprt.py +++ b/flopy4/mf6/exg/gwfprt.py @@ -4,8 +4,13 @@ import attrs from flopy4.mf6.exchange import Exchange +from flopy4.mf6.model import Model +from flopy4.spec import reference @attrs.define(kw_only=True, slots=False) class Gwfprt(Exchange): dfn_name: ClassVar[str] = "exg-gwfprt" + + exgmnamea: Model | str | None = reference(many=False, required=True) + exgmnameb: Model | str | None = reference(many=False, required=True) diff --git a/flopy4/mf6/exg/gwtgwt.py b/flopy4/mf6/exg/gwtgwt.py index fa0325cb5..69d83df1f 100644 --- a/flopy4/mf6/exg/gwtgwt.py +++ b/flopy4/mf6/exg/gwtgwt.py @@ -9,8 +9,10 @@ from flopy4.mf6.exchange import Exchange from flopy4.mf6.gwt.mvt import Mvt from flopy4.mf6.item import Item +from flopy4.mf6.model import Model from flopy4.mf6.spec import array, cellid_, child, double, integer, keyword, list_, string from flopy4.mf6.utl.obs import Obs +from flopy4.spec import reference @attrs.define(kw_only=True, slots=False) @@ -36,6 +38,8 @@ class Exchangedata(Item): default=None, empty_is_none=True, optional=True, when="boundnames" ) + exgmnamea: Model | str | None = reference(many=False, required=True) + exgmnameb: Model | str | None = reference(many=False, required=True) gwfmodelname1: str = string( default=None, block="options", diff --git a/flopy4/mf6/exg/olfgwf.py b/flopy4/mf6/exg/olfgwf.py index 2b0e89b6b..41dd949cd 100644 --- a/flopy4/mf6/exg/olfgwf.py +++ b/flopy4/mf6/exg/olfgwf.py @@ -5,8 +5,10 @@ from flopy4.mf6.exchange import Exchange from flopy4.mf6.item import Item +from flopy4.mf6.model import Model from flopy4.mf6.spec import cellid_, child, double, integer, keyword, list_ from flopy4.mf6.utl.obs import Obs +from flopy4.spec import reference @attrs.define(kw_only=True, slots=False) @@ -22,6 +24,8 @@ class Exchangedata(Item): bedleak: float = double() cfact: float = double() + exgmnamea: Model | str | None = reference(many=False, required=True) + exgmnameb: Model | str | None = reference(many=False, required=True) print_input: bool = keyword( default=False, block="options", diff --git a/flopy4/mf6/ims.py b/flopy4/mf6/ims.py index 0aef60710..8f61d29be 100644 --- a/flopy4/mf6/ims.py +++ b/flopy4/mf6/ims.py @@ -4,9 +4,11 @@ import attrs +from flopy4.mf6.model import Model from flopy4.mf6.record import Record from flopy4.mf6.solution import Solution from flopy4.mf6.spec import double, file, integer, record, string +from flopy4.spec import reference @attrs.define(kw_only=True, slots=False) @@ -26,6 +28,7 @@ class Rclose(Record): inner_rclose: float = double() rclose_option: Optional[str] = string(default=None, optional=True) + models: list[Model | str] = reference() print_option: Optional[str] = string( default=None, block="options", diff --git a/flopy4/mf6/solution.py b/flopy4/mf6/solution.py index 331f41dd4..1be3a76c0 100644 --- a/flopy4/mf6/solution.py +++ b/flopy4/mf6/solution.py @@ -2,9 +2,9 @@ from typing import TYPE_CHECKING, ClassVar import attrs +from attrs import Attribute from flopy4.mf6.package import Package -from flopy4.spec import reference if TYPE_CHECKING: from flopy4.mf6.model import Model @@ -12,12 +12,16 @@ @attrs.define(kw_only=True, slots=False) class Solution(Package, ABC): + """A solution. Its `models` (generated, from its row in the + simulation's name file) are the models it solves, as objects or names. + The simulation owns its models; a solution only refers to them. If a + simulation has one solution and it lists no models, it solves them + all. A solution given no parent joins its models' simulation, if they + have one.""" + slntype: ClassVar[str] = "sln" - models: "list[Model | str]" = reference(check=lambda sln, m: sln._cant_solve(m)) - """The models this solution solves, as objects or names. The simulation - owns its models; a solution only refers to them. If a simulation has - one solution and it lists no models, it solves them all. A solution - given no parent joins its models' simulation, if they have one.""" + if TYPE_CHECKING: + models: "list[Model | str]" def solves(self, model: "Model") -> bool: """Whether this kind of solution can solve the model's type: EMS @@ -25,6 +29,9 @@ def solves(self, model: "Model") -> bool: is_prt = type(model).dfn_name.startswith("prt") # type: ignore[attr-defined] return is_prt == (self.slntype == "ems") + def _reference_problem(self, field: Attribute, model) -> "str | None": + return self._cant_solve(model) + def _cant_solve(self, model: "Model") -> "str | None": """Why this solution can't solve the model, or None if it can.""" if self.solves(model): diff --git a/flopy4/mf6/utils/codegen/make.py b/flopy4/mf6/utils/codegen/make.py index 1a0fd2bb8..2ee2dad39 100644 --- a/flopy4/mf6/utils/codegen/make.py +++ b/flopy4/mf6/utils/codegen/make.py @@ -1303,6 +1303,65 @@ def _child_field_spec( ), [f"from {_component_module(target)} import {cls}"] +# Reference fields' Python names, where they aren't the DFN's: a solution's +# models kept the name they had before the DFNs keyed them. +_REFERENCE_NAMES = {"slnmnames": "models"} + + +def _binding_row(component: Component, dfns: Mapping[str, Component]) -> Record | None: + """The row naming the component in its parent's name file (an + exchange's ``exgtype exgfile exgmnamea exgmnameb`` in the simulation's + exchanges block), or None. Such a row's file column picks its + component by a ``component_ftype`` column.""" + for parent in dfns.values(): + if not _is_child_of(component, parent): + continue + for _, f in filters.flat_fields(parent): + if not isinstance(f, ListField) or not isinstance(f.item, Record): + continue + found = _find_link(f) + if found is None or found[1].component_ftype is None: + continue + if component.name in resolve_link(parent, found[0], found[1], dfns): + return f.item + return None + + +def _reference_specs( + component: Component, dfns: Mapping[str, Component] | None +) -> tuple[list[FieldSpec], list[str]]: + """The component's reference fields (see `flopy4.spec.reference`) and + their imports: the columns of its row in its parent's name file keyed + (``fk``) to the parent's models, in row order. An exchange's + ``exgmnamea`` and ``exgmnameb``, a solution's ``slnmnames``.""" + row = _binding_row(component, dfns) if dfns is not None else None + if row is None: + return [], [] + specs = [] + for name, m in (row.fields or {}).items(): + if not (fk := getattr(m, "fk", None)): + continue + if fk.partition(".")[0] != "models": + raise NotImplementedError(f"{component.name}.{name}: a reference to {fk}") + if isinstance(m, Array): + annotation, call = "list[Model | str]", "reference()" + else: + annotation = "Model | str | None" + call = "reference(many=False)" if m.optional else "reference(many=False, required=True)" + specs.append( + FieldSpec( + dfn_name=name, + py_name=_REFERENCE_NAMES.get(name, filters.safe_name(name)), + type_annotation=annotation, + spec_call=call, + generatable=True, + ) + ) + if not specs: + return [], [] + return specs, ["from flopy4.mf6.model import Model", "from flopy4.spec import reference"] + + # Package types sharing one model field: a model has one discretization. _SLOTS = {"DIS6": "dis", "DISV6": "dis", "DISU6": "dis"} @@ -1466,6 +1525,10 @@ def build_component_spec( _linked_dims = {bp.dim_attr for bp in block_properties if bp.dim_is_dfn_declared} extra_imports: list[str] = [] # child fields' imports + # reference fields first, in row order + _reference_fields, _reference_imports = _reference_specs(component, dfns) + prefix_specs.extend(_reference_fields) + extra_imports.extend(_reference_imports) _period_item: str | None = None # element type of the fill-forward block's list _readarray_period_fields: list[FieldV3] = [] # READARRAY period fields (CHDG, DRNG …) _dynamically_named_period_fields: list[tuple] = [] # (list, array, fk): RCHA's aux diff --git a/flopy4/spec.py b/flopy4/spec.py index d2e60adf9..1191c8fa6 100644 --- a/flopy4/spec.py +++ b/flopy4/spec.py @@ -2,9 +2,6 @@ Wrap `attrs` specification utilities. """ -from collections.abc import Callable -from typing import Any - import attrs from attrs import Attribute from attrs import fields_dict as attrs_fields_dict @@ -78,11 +75,7 @@ def _check_reference(instance, attribute, value) -> None: raise TypeError(f"{attribute.name} takes a component or a name, not {type(value).__name__}") -def reference( - many: bool = True, - check: "Callable[[Any, Any], str | None] | None" = None, - required: bool = False, -): +def reference(many: bool = True, required: bool = False): """Models this component refers to but doesn't own (e.g. the models a solution solves), given as objects or names: a list of them, or with `many=False` one (or None). The simulation owns its models; a @@ -92,9 +85,12 @@ def reference( parent joins its references' simulation (see `Component.__attrs_post_init__`). - When written, a reference must be one of the simulation's models, and - `check(component, model)`, if given, must return no problem with it. A - single one must also be given if `required`.""" + Generated from the DFNs: a column of the component's row in its + parent's name file keyed to the parent's models (``fk``) is a + reference, in row order. When written, a reference must be one of the + simulation's models, and the component's `_reference_problem` must + find no problem with it. A single one must also be given if + `required`.""" shape: dict = ( {"factory": list, "converter": _to_reference_list} if many @@ -102,7 +98,7 @@ def reference( ) return attrs.field( **shape, - metadata={"reference": True, "check": check, "required": required}, + metadata={"reference": True, "many": many, "required": required}, repr=lambda v: repr(reference_names(v)), eq=reference_names, ) diff --git a/test/mf6/test_mf6_codegen.py b/test/mf6/test_mf6_codegen.py index 1c2413905..bb4ad3df3 100644 --- a/test/mf6/test_mf6_codegen.py +++ b/test/mf6/test_mf6_codegen.py @@ -416,6 +416,50 @@ def test_simulation_spec(all_dfns): assert {"continue_", "nocheck", "maxerrors", "mxiter"} <= set(types) +def _with_model_keys(dfns): + """The DFNs with sim-nam's model-name columns keyed to its models, as + devtools marks them (drop once the DFNs flopy4 syncs from have them).""" + sim = dfns["sim-nam"] + blocks = dict(sim.blocks) + for block, column in [ + ("exchanges", "exgmnamea"), + ("exchanges", "exgmnameb"), + ("solutiongroup", "slnmnames"), + ]: + b = blocks[block] + rows = b.fields[block] + item = rows.item + keyed = item.fields[column].model_copy(update={"fk": "models.mname"}) + columns = {**item.fields, column: keyed} + rows = rows.model_copy(update={"item": item.model_copy(update={"fields": columns})}) + blocks[block] = b.model_copy(update={"fields": {**b.fields, block: rows}}) + return {**dfns, "sim-nam": sim.model_copy(update={"blocks": blocks})} + + +@pytest.mark.parametrize( + "name, expected", + [ + ( + "exg-gwfgwt", + [ + ("exgmnamea", "Model | str | None", "reference(many=False, required=True)"), + ("exgmnameb", "Model | str | None", "reference(many=False, required=True)"), + ], + ), + ("sln-ims", [("models", "list[Model | str]", "reference()")]), + ("gwf-chd", []), + ], +) +def test_reference_fields_from_keys(all_dfns, name, expected): + """A component's columns in its row in the simulation's name file that + are keyed to its models become reference fields, first, in row order.""" + dfns = _with_model_keys(all_dfns) + spec = build_component_spec(dfns[name], root=Path("/fake/mf6"), dfns=dfns) + refs = [f for f in spec.fields if f.spec_call.startswith("reference(")] + assert [(f.py_name, f.type_annotation, f.spec_call) for f in refs] == expected + assert spec.fields[: len(refs)] == refs + + def test_model_spec(all_dfns): root = Path("/fake/mf6") spec = build_component_spec(all_dfns["gwt-nam"], root=root, dfns=all_dfns) From 8468515d4a285f4be6ab21417a9ed8b703924e54 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 21 Oct 2026 08:52:20 -0700 Subject: [PATCH 2/3] use devtools' sim-nam model keys Pin modflow-devtools to b7c43566, which marks sim-nam's model-name columns pk/fk, and test reference field generation on the real DFNs instead of patching the keys in. Synced at the contract commit, the generated modules are unchanged. The mixin gap check now looks for an attribute a base class lacks on its leaf subclasses, since Solution.models is now generated on IMS/EMS rather than declared on Solution. Co-Authored-By: Claude Opus 5.5 --- flopy4/mf6/_mixin_fields.py | 35 +++++++++++++++--------- pixi.lock | 50 +++++++++++++++++------------------ test/mf6/test_mf6_codegen.py | 23 +--------------- test/mf6/test_mixin_fields.py | 30 +++++++++++++++++++++ 4 files changed, 79 insertions(+), 59 deletions(-) diff --git a/flopy4/mf6/_mixin_fields.py b/flopy4/mf6/_mixin_fields.py index 3a8d328e0..ca387545c 100644 --- a/flopy4/mf6/_mixin_fields.py +++ b/flopy4/mf6/_mixin_fields.py @@ -225,27 +225,38 @@ def _field_types(cls: type) -> dict[str, Any]: return types +def _leaves(cls: type) -> list[type]: + """A class's subclasses that have none of their own, or the class if + it has none.""" + subs = cls.__subclasses__() + return [leaf for s in subs for leaf in _leaves(s)] if subs else [cls] + + def _missing(cls: type, chain: list[str]) -> list[str]: """Where a chain read from ``cls`` hits an attribute that isn't there, as ``Class.attr``. Past a field that can hold something else too (see - `_open`), an attribute only some of the classes have isn't missing.""" + `_open`), an attribute only some of the classes have isn't missing. + An attribute a base class lacks is looked for on its leaf subclasses, + which the DFNs may give it (e.g. ``Solution.models``).""" missing = [] at = {cls} unchecked = False for attr in chain: following: set[type] = set() following_unchecked = False - for c in at: - fields = _field_types(c) if attrs.has(c) else {} - if attr in fields: - following |= _classes(fields[attr]) - following_unchecked |= _open(fields[attr]) - elif hasattr(c, attr): - value = getattr(c, attr) - if isinstance(value, type) and attrs.has(value): - following.add(value) - elif not unchecked: - missing.append(f"{c.__qualname__}.{attr}") + for base in at: + on_base = hasattr(base, attr) or (attrs.has(base) and attr in _field_types(base)) + for c in [base] if on_base else _leaves(base): + fields = _field_types(c) if attrs.has(c) else {} + if attr in fields: + following |= _classes(fields[attr]) + following_unchecked |= _open(fields[attr]) + elif hasattr(c, attr): + value = getattr(c, attr) + if isinstance(value, type) and attrs.has(value): + following.add(value) + elif not unchecked: + missing.append(f"{c.__qualname__}.{attr}") at, unchecked = following, following_unchecked return missing diff --git a/pixi.lock b/pixi.lock index 8e1864561..8eb10b255 100644 --- a/pixi.lock +++ b/pixi.lock @@ -106,7 +106,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-9_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/70/746ab7140121dcf04bade9ab7d2e65b6d5eb5b23bb7dc04b7ad22742ca0f/xugrid-0.15.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/09/4e/23e0009b6d5d415c223ea38fcf1e38dc9b0fc6d3ff15b5c5090dcc1f6d88/sparse-0.19.2-py2.py3-none-any.whl @@ -255,7 +255,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl - pypi: https://files.pythonhosted.org/packages/07/70/746ab7140121dcf04bade9ab7d2e65b6d5eb5b23bb7dc04b7ad22742ca0f/xugrid-0.15.3-py3-none-any.whl @@ -404,7 +404,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/70/746ab7140121dcf04bade9ab7d2e65b6d5eb5b23bb7dc04b7ad22742ca0f/xugrid-0.15.3-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/09/4e/23e0009b6d5d415c223ea38fcf1e38dc9b0fc6d3ff15b5c5090dcc1f6d88/sparse-0.19.2-py2.py3-none-any.whl @@ -547,7 +547,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/07/70/746ab7140121dcf04bade9ab7d2e65b6d5eb5b23bb7dc04b7ad22742ca0f/xugrid-0.15.3-py3-none-any.whl @@ -709,7 +709,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-9_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -992,7 +992,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -1273,7 +1273,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -1548,7 +1548,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/08/9c41fb51ab5b43eb21674aff13df270e8ba6c4b29c8624e328dc7a9482af/distlib-0.4.3-py2.py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/36/ecc85bc96c273dc8a11273ed4782272975e6338d4a3e9228621175edf0e3/jaraco_functools-4.6.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl @@ -1838,7 +1838,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-9_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl @@ -2105,7 +2105,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl @@ -2373,7 +2373,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl @@ -2634,7 +2634,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/01/f9/575c8d760eae1fc99651b7cc5efd96ad5379ca4d6b53750b0fb4fe983f34/imagesize-2.0.1-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl @@ -2914,7 +2914,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.11-9_cp311.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -3165,7 +3165,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -3417,7 +3417,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/90/67bd7260b4ea9b8b20b4f58afef6c223ecb3abf368eb4ec5bc2cdef81b49/pyproj-3.7.2.tar.gz @@ -3663,7 +3663,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -3922,7 +3922,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.12-9_cp312.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl @@ -4170,7 +4170,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -4419,7 +4419,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/05/4a/78c6285577c375e7cf27277ea8ee6961224327f1e1a0c44af5f17f23635c/scipy-1.18.1-cp312-cp312-macosx_12_0_arm64.whl @@ -4662,7 +4662,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl @@ -4917,7 +4917,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/noarch/python_abi-3.13-9_cp313.conda - conda: https://conda.anaconda.org/conda-forge/noarch/tzdata-2026c-h151e31d_0.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -5167,7 +5167,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-64/tk-8.6.13-ha6c374e_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-64/zstd-1.5.7-hbc1a06c_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl @@ -5418,7 +5418,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/osx-arm64/tk-8.6.13-hbeba79b_4.conda - conda: https://conda.anaconda.org/conda-forge/osx-arm64/zstd-1.5.7-hf451053_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl @@ -5663,7 +5663,7 @@ environments: - conda: https://conda.anaconda.org/conda-forge/win-64/vcomp14-14.51.36247-habf1de7_41.conda - conda: https://conda.anaconda.org/conda-forge/win-64/zstd-1.5.7-h534d264_7.conda - pypi: ./ - - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f + - pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 - pypi: https://files.pythonhosted.org/packages/02/8b/b83f7780dad22e0878e4c7bd9158ebd24ed12bc3d5e3a471cd0576f77ded/pyzmq-27.2.0-cp312-abi3-win_amd64.whl - pypi: https://files.pythonhosted.org/packages/02/be/5d2d47b1fb58943194fb59dcf222f7c4e35122ec0ffe8c36e18b5d728f0b/tblib-3.2.2-py3-none-any.whl - pypi: https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl @@ -10927,7 +10927,7 @@ packages: - build ; extra == 'build' - twine ; extra == 'build' requires_python: '>=3.11,<3.14' -- pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#9a1662ef87c006346b5769f856f730bad35f218f +- pypi: git+https://github.com/MODFLOW-ORG/modflow-devtools.git#b7c43566bb2e950a82c34636aefbc3fda701ca08 name: modflow-devtools version: 1.10.0.dev1 requires_dist: diff --git a/test/mf6/test_mf6_codegen.py b/test/mf6/test_mf6_codegen.py index bb4ad3df3..224a863ac 100644 --- a/test/mf6/test_mf6_codegen.py +++ b/test/mf6/test_mf6_codegen.py @@ -416,26 +416,6 @@ def test_simulation_spec(all_dfns): assert {"continue_", "nocheck", "maxerrors", "mxiter"} <= set(types) -def _with_model_keys(dfns): - """The DFNs with sim-nam's model-name columns keyed to its models, as - devtools marks them (drop once the DFNs flopy4 syncs from have them).""" - sim = dfns["sim-nam"] - blocks = dict(sim.blocks) - for block, column in [ - ("exchanges", "exgmnamea"), - ("exchanges", "exgmnameb"), - ("solutiongroup", "slnmnames"), - ]: - b = blocks[block] - rows = b.fields[block] - item = rows.item - keyed = item.fields[column].model_copy(update={"fk": "models.mname"}) - columns = {**item.fields, column: keyed} - rows = rows.model_copy(update={"item": item.model_copy(update={"fields": columns})}) - blocks[block] = b.model_copy(update={"fields": {**b.fields, block: rows}}) - return {**dfns, "sim-nam": sim.model_copy(update={"blocks": blocks})} - - @pytest.mark.parametrize( "name, expected", [ @@ -453,8 +433,7 @@ def _with_model_keys(dfns): def test_reference_fields_from_keys(all_dfns, name, expected): """A component's columns in its row in the simulation's name file that are keyed to its models become reference fields, first, in row order.""" - dfns = _with_model_keys(all_dfns) - spec = build_component_spec(dfns[name], root=Path("/fake/mf6"), dfns=dfns) + spec = build_component_spec(all_dfns[name], root=Path("/fake/mf6"), dfns=all_dfns) refs = [f for f in spec.fields if f.spec_call.startswith("reference(")] assert [(f.py_name, f.type_annotation, f.spec_call) for f in refs] == expected assert spec.fields[: len(refs)] == refs diff --git a/test/mf6/test_mixin_fields.py b/test/mf6/test_mixin_fields.py index b4ff709ff..fe0e25442 100644 --- a/test/mf6/test_mixin_fields.py +++ b/test/mf6/test_mixin_fields.py @@ -119,6 +119,36 @@ class _RefsLacking(_Comprehensions): refs: list[_Child] = attrs.Factory(list) +@attrs.define +class _Base: + pass + + +@attrs.define +class _Full(_Base): + name: str = "" + + +@attrs.define +class _Bare(_Base): + pass + + +class _ReadsBase: + def names(self): + return [b.name for b in self.bases] + + +@attrs.define +class _HasBases(_ReadsBase): + bases: list[_Base] = attrs.Factory(list) + + +def test_mixin_gaps_through_base_class(): + # the base lacks `name`; of its subclasses, only _Bare does too + assert mixin_gaps(_HasBases, _ReadsBase) == ["_ReadsBase.names: no _Bare.name"] + + def test_mixin_gaps_through_union_with_plain_type(): # an item might be a str, which has `lower` assert "_Child.lower" not in str(mixin_gaps(_Refs, _Comprehensions)) From 0f19cd247e85045f1b78660df5041767d645a2d7 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 21 Oct 2026 09:29:04 -0700 Subject: [PATCH 3/3] mark the grammar test's auxname dynamic, as devtools now requires Co-Authored-By: Claude Opus 5.5 --- test/mf6/test_mf6_grammar_gen.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/mf6/test_mf6_grammar_gen.py b/test/mf6/test_mf6_grammar_gen.py index 4517e9ecd..71fbaf2ea 100644 --- a/test/mf6/test_mf6_grammar_gen.py +++ b/test/mf6/test_mf6_grammar_gen.py @@ -381,7 +381,7 @@ def test_make_grammar_with_dynamically_named_arrays(tmp_path): item = Record( name="aux", fields={ - "auxname": String(name="auxname", tagged=False, fk="options.auxiliary"), + "auxname": String(name="auxname", tagged=False, fk="options.auxiliary", dynamic=True), "aux": Array(name="aux", dtype="double", shape=["ncpl"], tagged=False), }, )