From 7bff566265cb156c623f0d26f6ea43fa14c4622a Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 23 Sep 2026 08:25:49 -0400 Subject: [PATCH 1/6] Migrate the MF6 object model from attrs to pydantic dataclasses Squash of the pydantic-plan branch (backup: pydantic-plan-backup): - docs: revised attrs->pydantic object-model plan and prototypes (Dis, list-heavy package, Record/Item, keystring-union-arm coercion) - Stage 1: Record/Item on pydantic.dataclasses.dataclass - Stages 2-3.5: Component, Package, and remaining hand-written Component-tree classes - Stage 4: codegen emits pydantic dataclasses - Stage 5: regenerate the 61 codegen-generated package files - Stages 6-7: consumer surface and fixes from real-corpus and full test-suite runs - codegen: drop maxbound from G-variant packages (replaces the interim auto_from restore), with docs recording the fix Co-Authored-By: Claude Opus 5.5 --- docs/dev/prototypes/pydantic_chd_prototype.py | 379 ++++++++ docs/dev/prototypes/pydantic_dis_prototype.py | 401 ++++++++ .../prototypes/pydantic_record_prototype.py | 366 +++++++ .../pydantic_union_arm_prototype.py | 381 ++++++++ docs/dev/pydantic-object-model-plan.md | 899 ++++++++++++++++++ docs/examples/frenchman-flat.py | 6 +- flopy4/attrs_xarray.py | 177 ++-- flopy4/dimensions.py | 59 +- flopy4/mf6/_types.py | 13 +- flopy4/mf6/adapters.py | 17 +- flopy4/mf6/codec/writer/filters.py | 4 +- flopy4/mf6/component.py | 257 +++-- flopy4/mf6/context.py | 62 +- flopy4/mf6/converter/binding.py | 8 +- flopy4/mf6/converter/egress/unstructure.py | 70 +- flopy4/mf6/converter/ingress/structure.py | 209 ++-- flopy4/mf6/ems.py | 6 +- flopy4/mf6/exchange.py | 10 +- flopy4/mf6/exg/gwfgwe.py | 6 +- flopy4/mf6/exg/gwfgwt.py | 6 +- flopy4/mf6/exg/gwfprt.py | 6 +- flopy4/mf6/gwe/__init__.py | 12 +- flopy4/mf6/gwe/adv.py | 6 +- flopy4/mf6/gwe/cnd.py | 6 +- flopy4/mf6/gwe/ctp.py | 11 +- flopy4/mf6/gwe/dis.py | 21 +- flopy4/mf6/gwe/disv.py | 57 +- flopy4/mf6/gwe/esl.py | 11 +- flopy4/mf6/gwe/est.py | 6 +- flopy4/mf6/gwe/fmi.py | 11 +- flopy4/mf6/gwe/ic.py | 6 +- flopy4/mf6/gwe/lke.py | 27 +- flopy4/mf6/gwe/mve.py | 6 +- flopy4/mf6/gwe/oc.py | 37 +- flopy4/mf6/gwe/ssm.py | 15 +- flopy4/mf6/gwf/__init__.py | 44 +- flopy4/mf6/gwf/api.py | 7 +- flopy4/mf6/gwf/buy.py | 11 +- flopy4/mf6/gwf/chd.py | 11 +- flopy4/mf6/gwf/chdg.py | 7 +- flopy4/mf6/gwf/csub.py | 15 +- flopy4/mf6/gwf/dis.py | 21 +- flopy4/mf6/gwf/disbase.py | 54 +- flopy4/mf6/gwf/disv.py | 57 +- flopy4/mf6/gwf/drn.py | 11 +- flopy4/mf6/gwf/drng.py | 7 +- flopy4/mf6/gwf/evt.py | 11 +- flopy4/mf6/gwf/evta.py | 6 +- flopy4/mf6/gwf/ghb.py | 11 +- flopy4/mf6/gwf/ghbg.py | 7 +- flopy4/mf6/gwf/ic.py | 6 +- flopy4/mf6/gwf/lak.py | 51 +- flopy4/mf6/gwf/mvr.py | 15 +- flopy4/mf6/gwf/npf.py | 23 +- flopy4/mf6/gwf/oc.py | 37 +- flopy4/mf6/gwf/rch.py | 11 +- flopy4/mf6/gwf/rcha.py | 6 +- flopy4/mf6/gwf/riv.py | 11 +- flopy4/mf6/gwf/rivg.py | 7 +- flopy4/mf6/gwf/sto.py | 11 +- flopy4/mf6/gwf/vsc.py | 11 +- flopy4/mf6/gwf/wel.py | 11 +- flopy4/mf6/gwf/welg.py | 7 +- flopy4/mf6/gwt/__init__.py | 12 +- flopy4/mf6/gwt/adv.py | 6 +- flopy4/mf6/gwt/api.py | 7 +- flopy4/mf6/gwt/cnc.py | 11 +- flopy4/mf6/gwt/dis.py | 21 +- flopy4/mf6/gwt/disv.py | 57 +- flopy4/mf6/gwt/dsp.py | 6 +- flopy4/mf6/gwt/fmi.py | 11 +- flopy4/mf6/gwt/ic.py | 6 +- flopy4/mf6/gwt/ist.py | 21 +- flopy4/mf6/gwt/lkt.py | 27 +- flopy4/mf6/gwt/mst.py | 6 +- flopy4/mf6/gwt/mvt.py | 6 +- flopy4/mf6/gwt/oc.py | 37 +- flopy4/mf6/gwt/src.py | 11 +- flopy4/mf6/gwt/ssm.py | 15 +- flopy4/mf6/ims.py | 17 +- flopy4/mf6/item.py | 225 +++-- flopy4/mf6/model.py | 6 +- flopy4/mf6/netcdf.py | 40 +- flopy4/mf6/package.py | 225 +++-- flopy4/mf6/prt/__init__.py | 8 +- flopy4/mf6/prt/dis.py | 18 +- flopy4/mf6/prt/disv.py | 55 +- flopy4/mf6/prt/fmi.py | 6 +- flopy4/mf6/prt/mip.py | 6 +- flopy4/mf6/prt/oc.py | 27 +- flopy4/mf6/prt/prp.py | 29 +- flopy4/mf6/pts.py | 11 +- flopy4/mf6/record.py | 193 ++-- flopy4/mf6/simulation.py | 31 +- flopy4/mf6/solution.py | 9 +- flopy4/mf6/spec.py | 98 +- flopy4/mf6/tdis.py | 81 +- flopy4/mf6/utils/cbc_reader.py | 9 +- flopy4/mf6/utils/codegen/filters.py | 25 +- flopy4/mf6/utils/codegen/make.py | 36 +- .../utils/codegen/templates/package.py.jinja | 16 +- flopy4/mf6/utl/ats.py | 11 +- flopy4/mf6/utl/hpc.py | 11 +- flopy4/mf6/utl/laktab.py | 11 +- flopy4/mf6/utl/ncf.py | 6 +- flopy4/mf6/utl/ncf_base.py | 6 +- flopy4/mf6/utl/sfrtab.py | 11 +- flopy4/mf6/utl/spca.py | 6 +- flopy4/mf6/utl/tas.py | 19 +- flopy4/mf6/write_context.py | 19 +- flopy4/spec.py | 21 +- test/mf6/test_mf6_adapters.py | 27 +- test/mf6/test_mf6_codec.py | 30 +- test/mf6/test_mf6_codegen.py | 9 +- test/mf6/test_mf6_component.py | 6 +- test/mf6/test_mf6_spec.py | 4 +- test/test_attrs_xarray.py | 19 +- test/test_dimensions.py | 17 +- 118 files changed, 4371 insertions(+), 1354 deletions(-) create mode 100644 docs/dev/prototypes/pydantic_chd_prototype.py create mode 100644 docs/dev/prototypes/pydantic_dis_prototype.py create mode 100644 docs/dev/prototypes/pydantic_record_prototype.py create mode 100644 docs/dev/prototypes/pydantic_union_arm_prototype.py create mode 100644 docs/dev/pydantic-object-model-plan.md diff --git a/docs/dev/prototypes/pydantic_chd_prototype.py b/docs/dev/prototypes/pydantic_chd_prototype.py new file mode 100644 index 00000000..f699985c --- /dev/null +++ b/docs/dev/prototypes/pydantic_chd_prototype.py @@ -0,0 +1,379 @@ +""" +Pydantic prototype: the two mechanics `pydantic_dis_prototype.py` explicitly +left out of scope -- (1) `Component`'s full `MutableMapping` interface for a +*list*-kind child field (many packages of the same slot, e.g. a model's +`chd: list[Chd]`), not just the "only" (single-child, `Dis.ncf`) case that +prototype covered, and (2) `Package`'s Item-list coercion (raw +tuple/dict/instance stress-period-data -> `Item` instances), modeled on +`flopy4/mf6/gwf/chd.py`'s real shape. + +Mined from: +- flopy4/mf6/component.py -- `__getitem__`/`__setitem__`/`__delitem__`/ + `__iter__`/`__len__`, `_find_child_field`, `_resolve_child_name`, + `_attach_to_parent_field` (the "list" kind branches specifically). +- flopy4/mf6/package.py -- `_init_item_lists`, `_coerce_item_list`. +- flopy4/mf6/gwf/chd.py -- the real `Chd`/`Chd.StressPeriodData` shape being + modeled (`_stress_period_data`, `alias=`, `block="period"`, + `fill_forward=True`). +- flopy4/mf6/item.py -- `item_list_type()` (the field-type -> Item-class + introspection this prototype's `_item_list_type()` adapts for pydantic's + `SkipValidation`-wrapped annotation shape -- see the finding below). + +Reuses `ComponentBase`/`PackageBase`/`_CFG`/`_DTYPE_MAP` from +`pydantic_dis_prototype.py` rather than redefining them. + +KEY FINDING (the reason `SkipValidation` is needed at all): attrs applies +*zero* validation to `Chd._stress_period_data` at construction (no +validator/converter declared on that field) -- raw tuples/dicts pass +through attrs' `__init__` untouched, and `Package.__attrs_post_init__` +coerces them into real `Item` instances afterward. Pydantic does NOT default +to this behavior: a plain `Optional[dict[int, list[Row]]]`-typed field is +*eagerly, strictly* validated against that annotation at construction -- +confirmed empirically that `Pkg(rows={0: [(1, 2.0)]})` raises +`ValidationError: Input should be an instance of Row` for a raw tuple, +before any post-init coercion hook ever runs. Wrapping the annotation in +`pydantic.SkipValidation[...]` fixes this (confirmed: identical raw input is +accepted, coercion runs in `__post_init__` exactly like the attrs version), +but costs two things pydantic_dis_prototype.py's array fields didn't need: + 1. `item_list_type()`'s `get_origin`/`get_args` walk must unwrap one extra + `Annotated[..., SkipValidation()]` layer before it reaches + `dict[int, list[Row]]` -- confirmed the real function's current logic + does NOT do this and needs a small, mechanical addition (see + `_item_list_type()` below). + 2. `SkipValidation` also skips `validate_assignment` re-validation on + this field specifically -- confirmed `pkg.stress_period_data = "junk"` + is silently accepted with `SkipValidation`, same as it would be on + attrs today (no validator declared there either) -- a wash, not a + regression, but worth naming since it's a per-field opt-out, not a + global one. + +Also confirms the `Item`/`Record` subsystem (`flopy4/mf6/item.py`, +`flopy4/mf6/record.py`) does NOT need to migrate to pydantic at all: with +`SkipValidation`, pydantic never inspects `Row`'s own fields, so `Row` +here is a genuine, unmodified `attrs.define` class -- exactly like the real +`Chd.StressPeriodData`. Mixed attrs/pydantic is fine for this boundary. + +Run directly: `python docs/dev/prototypes/pydantic_chd_prototype.py` +""" + +from __future__ import annotations + +from collections.abc import MutableMapping +from typing import Annotated, Any, ClassVar, Optional, get_args, get_origin + +import attrs +from pydantic import Field, SkipValidation +from pydantic.dataclasses import dataclass +from pydantic_dis_prototype import _CFG, ComponentBase, PackageBase + +# ============================================================================ +# Row type: stays a plain, unmodified attrs class -- see module docstring's +# "KEY FINDING" for why pydantic never needs to see inside it. +# ============================================================================ + + +@attrs.define +class ChdRowProto: + """Models `Chd.StressPeriodData` (flopy4/mf6/gwf/chd.py) -- a real + attrs `Item` subclass in the actual codebase; simplified here to just + the fields this prototype's coercion path exercises (the full + `Item`/`Record` token round-trip machinery is out of scope -- see + module docstring).""" + + cellid: tuple + head: float + boundname: Optional[str] = None + + +# ============================================================================ +# item_list_type(): adapted from flopy4/mf6/item.py for pydantic's +# SkipValidation-wrapped annotation shape (see module docstring finding #1). +# ============================================================================ + + +def _unwrap_skip_validation(t: Any) -> Any: + """Strip one `Annotated[X, SkipValidation()]` layer, if present. + + NEW code this migration would need -- the real `item_list_type()` has + no such step today because attrs field types are never wrapped this + way. Confirmed necessary: `get_origin()` on the raw (unstripped) + annotation returns `Annotated`, not `dict`, so the existing + dict/list-unwrapping logic below would silently fail to find `Row` + without this. + """ + if get_origin(t) is Annotated: + return get_args(t)[0] + return t + + +def _item_list_type(field_type: Any) -> "type | None": + """Adapted from `flopy4.mf6.item.item_list_type()`: for + `Optional[SkipValidation[dict[int, list[C]]]]`, return `C`.""" + args = get_args(field_type) + inner = next((a for a in args if a is not type(None)), None) + if inner is None: + return None + inner = _unwrap_skip_validation(inner) + origin = get_origin(inner) + if origin is dict: + _, val = get_args(inner) + val = _unwrap_skip_validation(val) + if get_origin(val) is list: + (item_cls,) = get_args(val) + return item_cls + return None + + +# ============================================================================ +# ListPackageBase: Package._init_item_lists/_coerce_item_list, adapted. +# ============================================================================ + + +@dataclass(config=_CFG, kw_only=True) +class ListPackageBase(PackageBase): + """Adds Item-list coercion on top of `PackageBase`'s griddata handling. + A real generated package would get both by inheriting one shared base + -- split here into two classes only so this file can import + `PackageBase` from the Dis prototype unmodified.""" + + def _init_item_lists(self) -> None: + """Adapted from `Package._init_item_lists`/`_coerce_item_list` + (non-union path only -- the keystring-union-arm case, e.g. LAK/SFR + period settings, is a separate, still-unmeasured surface; see the + plan doc).""" + for fname, finfo in type(self)._pydantic_fields().items(): + meta = finfo.json_schema_extra or {} + if not (isinstance(meta, dict) and meta.get("block")): + continue + item_cls = _item_list_type(finfo.annotation) + if item_cls is None: + continue + raw = self.__dict__.get(fname) + if raw is None: + continue + if meta.get("block") == "period" or meta.get("fill_forward"): + coerced = { + kper: self._coerce_item_list(rows, item_cls) for kper, rows in raw.items() + } + object.__setattr__(self, fname, coerced) + else: + object.__setattr__(self, fname, self._coerce_item_list(raw, item_cls)) + + @staticmethod + def _coerce_item_list(data, item_cls: type) -> list: + items = [] + for row in data: + if isinstance(row, item_cls): + items.append(row) + elif isinstance(row, dict): + items.append(item_cls(**row)) + elif isinstance(row, (list, tuple)): + items.append(item_cls(*row)) + else: + raise TypeError(f"Can't coerce {row!r} to {item_cls.__name__}") + return items + + def __post_init__(self) -> None: + self._init_item_lists() + super().__post_init__() + + +# ============================================================================ +# ChdProto: models the real Chd (flopy4/mf6/gwf/chd.py) +# ============================================================================ + + +@dataclass(config=_CFG, kw_only=True) +class ChdProto(ListPackageBase): + dfn_name: ClassVar[str] = "gwf-chd" + + boundnames: bool = Field(default=False, json_schema_extra={"block": "options"}) + print_input: bool = Field(default=False, json_schema_extra={"block": "options"}) + + # The field this whole prototype exists to test: SkipValidation is + # what lets raw tuple/dict input (see demo() below) reach + # ListPackageBase._init_item_lists() at all -- without it, pydantic's + # own eager validation rejects the raw input before __post_init__ ever + # runs (module docstring's "KEY FINDING"). + stress_period_data: Optional[SkipValidation[dict[int, list[ChdRowProto]]]] = Field( + default=None, json_schema_extra={"block": "period", "fill_forward": True} + ) + + +# ============================================================================ +# ModelBase: Component's full MutableMapping interface for a *list*-kind +# child field -- pydantic_dis_prototype.py's ComponentBase only exercised +# the "only" (single-child) case via Dis.ncf. This is the other half. +# ============================================================================ + + +@dataclass(config=_CFG, kw_only=True) +class ModelBase(ComponentBase, MutableMapping): + """Adapted from `Component`'s `MutableMapping` mixing + `_children`/ + `__getitem__`/`__setitem__`/`__delitem__`/`__iter__`/`__len__` -- the + "list" kind only (a model holding several packages of the same slot, + e.g. `chd: list[ChdProto]`); the "dict"-kind branch real `Component` + also has isn't exercised here (out of scope, same as before).""" + + chd: list[ChdProto] = Field(default_factory=list, exclude=True) + + def _list_child_fields(self) -> list[str]: + """`list[ComponentBase]`-typed fields -- the part `ComponentBase. + _child_fields()` (Dis prototype) doesn't cover, since it only + matches a field whose annotation IS (or directly wraps) a + `ComponentBase` subclass, not `list[ComponentBase subclass]`.""" + names = [] + for fname, finfo in type(self)._pydantic_fields().items(): + ann = finfo.annotation + if get_origin(ann) is list: + (elem,) = get_args(ann) + if isinstance(elem, type) and issubclass(elem, ComponentBase): + names.append(fname) + return names + + @property + def _children(self) -> dict[str, ComponentBase]: + result: dict[str, ComponentBase] = {} + for fname in self._list_child_fields(): + for child in getattr(self, fname): + result[child.name] = child + return result + + def _set_child_parents(self) -> None: + """Adapted from `Component._set_child_parents`'s "list" branch. + + NOTE the same "is this still a default name" check real + `Component._is_default_child_name()` needs: by the time a child + reaches here, its own `__post_init__` (`ComponentBase.__post_init__`) + has *already* defaulted `.name` to its lowercased class name -- + `child.name` is never actually `None` at this point. Checking + `child.name is None` (this prototype's first cut) silently failed + to rename same-class siblings, since only the first one collided + (the rest stayed at the shared class-name default, `not in used` + yet). Confirmed by running this file and seeing `['chdproto', + 'chd1']` instead of `['chd0', 'chd1']` -- fixed below. + """ + super()._set_child_parents() + for fname in self._list_child_fields(): + used: set[str] = set() + for i, child in enumerate(getattr(self, fname)): + object.__setattr__(child, "parent", self) + is_default = child.name == type(child).__name__.lower() + if is_default or child.name in used: + object.__setattr__(child, "name", f"{fname}{i}") + used.add(child.name) + + def __getitem__(self, key): + return self._children[key] + + def __setitem__(self, key, value): + if not isinstance(value, ComponentBase): + raise TypeError(f"Expected a ComponentBase, got {type(value).__name__}") + for fname in self._list_child_fields(): + current = getattr(self, fname) + for i, child in enumerate(current): + if child.name == key: + object.__setattr__(value, "name", key) + object.__setattr__(value, "parent", self) + current[i] = value + return + # No existing child named `key` -- attach fresh to the (only, for + # this prototype) list field matching value's type. + for fname in self._list_child_fields(): + finfo = type(self)._pydantic_fields()[fname] + (elem,) = get_args(finfo.annotation) + if isinstance(value, elem): + object.__setattr__(value, "name", key) + object.__setattr__(value, "parent", self) + getattr(self, fname).append(value) + return + raise TypeError(f"No field on {type(self).__name__} accepts a {type(value).__name__}") + + def __delitem__(self, key): + for fname in self._list_child_fields(): + current = getattr(self, fname) + for i, child in enumerate(current): + if child.name == key: + del current[i] + return + raise KeyError(key) + + def __iter__(self): + return iter(self._children) + + def __len__(self): + return len(self._children) + + +# ============================================================================ +# Demonstration / smoke test +# ============================================================================ + + +def _assert_plain_typed_field_rejects_raw_tuple() -> None: + """Control case for the module docstring's "KEY FINDING": the identical + field, WITHOUT `SkipValidation`, on the same raw input `ChdProto` + above accepts -- confirms the rejection is really pydantic's default + eager validation, not some other mistake in this prototype.""" + from pydantic import ConfigDict + from pydantic.dataclasses import dataclass as _dc + + @_dc(config=ConfigDict(arbitrary_types_allowed=True, extra="forbid"), kw_only=True) + class _NoSkip: + rows: Optional[dict[int, list[ChdRowProto]]] = None + + try: + _NoSkip(rows={0: [((0, 0, 0), 1.0)]}) + raise AssertionError("expected a validation error") + except AssertionError: + raise + except Exception as e: + print(f" plain-typed field rejects the same raw tuple -> {type(e).__name__} as expected") + + +def demo() -> None: + print("=" * 70) + print("Pydantic Chd/MutableMapping prototype") + print("=" * 70) + + # -- Item-list coercion: raw tuple, raw dict, and real-instance forms, + # matching Package._coerce_item_list's three main input shapes. + chd = ChdProto( + stress_period_data={ + 0: [((0, 0, 0), 1.0), {"cellid": (0, 0, 1), "head": 2.0, "boundname": "b1"}], + 1: [ChdRowProto(cellid=(0, 0, 2), head=3.0)], + } + ) + print(f"\nstress_period_data: {chd.stress_period_data}") + assert isinstance(chd.stress_period_data[0][0], ChdRowProto) + assert chd.stress_period_data[0][0].cellid == (0, 0, 0) + assert chd.stress_period_data[0][1].boundname == "b1" + assert chd.stress_period_data[1][0].head == 3.0 + + print("\nEager pydantic validation without SkipValidation (confirms the finding):") + _assert_plain_typed_field_rejects_raw_tuple() + + # -- MutableMapping over a list-kind child field. + model = ModelBase(chd=[ChdProto(), ChdProto()]) + print(f"\nlen(model): {len(model)}") + assert len(model) == 2 + names = list(model) + print(f"child names (auto-assigned, field-name + index): {names}") + assert names == ["chd0", "chd1"] + assert model["chd0"].parent is model + + extra = ChdProto() + model["chd2"] = extra + assert model["chd2"] is extra and extra.name == "chd2" and extra.parent is model + print(f"after __setitem__('chd2', ...): {list(model)}") + + del model["chd1"] + print(f"after __delitem__('chd1'): {list(model)}") + assert "chd1" not in model + assert len(model) == 2 + + print("\nAll assertions passed.") + + +if __name__ == "__main__": + demo() diff --git a/docs/dev/prototypes/pydantic_dis_prototype.py b/docs/dev/prototypes/pydantic_dis_prototype.py new file mode 100644 index 00000000..63b00971 --- /dev/null +++ b/docs/dev/prototypes/pydantic_dis_prototype.py @@ -0,0 +1,401 @@ +""" +Pydantic prototype v3: ports `Dis` (flopy4/mf6/gwf/dis.py, via `DisBase` -> +`Package` -> `Component`) to pydantic, in its *current* (post-xattree, +post-Row-unification) shape. + +v3 change from v2: built on `pydantic.dataclasses.dataclass`, not +`pydantic.BaseModel`. v2 (BaseModel) found no working analog to attrs' +`field(init=False)` (DisBase's derived nlay/nrow/ncol/ncpl/nvert/nodes) -- +`Field(init=False)` on a `BaseModel` is accepted but has NO runtime effect +(confirmed: `M(nodes=999)` silently succeeds and sets `nodes=999`, even +under `extra="forbid"` -- it's type-checker-only metadata for BaseModel). +On a pydantic dataclass, the identical `Field(init=False)` DOES work at +runtime -- confirmed: with `extra="forbid"` in config, `M(nodes=999)` +raises `ValidationError: Unexpected keyword argument`, matching attrs' +own `TypeError: unexpected keyword argument` for the same case exactly. +See DisBaseProto below and the plan doc's "BaseModel vs. pydantic +dataclasses" section for the full comparison. + +This exists to satisfy docs/dev/pydantic-object-model-plan.md's "next +steps when picked up": port one real, current-shape package end to end, +port its __attrs_post_init__/DimensionResolverMixin chain, and measure +ergonomics against the actual current codebase. + +Mined from: +- flopy4/mf6/component.py, flopy4/mf6/package.py, flopy4/dimensions.py, + flopy4/mf6/gwf/{dis,disbase}.py -- the mechanics being ported. +- origin/plan-codegen's pydantic_prototype.py (2026-01-23) -- the + pydantic-side mechanics (Annotated NDArray hints, field_validator + array-structuring pattern). Reused near-verbatim where still applicable. + +Not wired into flopy4's real registry (FNAMES/FTYPES), codegen, or the +xarray/write/load machinery -- this is a standalone measurement of the +object-model layer only, not a drop-in replacement. `Ncf` is stubbed +(`NcfProto`) rather than importing the real attrs-based `Ncf`, since +mixing attrs and pydantic components isn't the point of this exercise. +`Component`'s full `MutableMapping` interface is intentionally out of +scope here too (see the plan doc) -- this only exercises the single-child +("only") case via `ncf`. + +Run directly: `python docs/dev/prototypes/pydantic_dis_prototype.py` +""" + +from __future__ import annotations + +from abc import ABC +from typing import Annotated, Any, ClassVar, Optional + +import numpy as np +from numpy.typing import NDArray +from pydantic import ConfigDict, Field, ValidationInfo, field_validator +from pydantic.dataclasses import dataclass + +# Applied to every class in the hierarchy explicitly (dataclass config +# isn't inherited across `@dataclass`-decorated subclasses the way +# BaseModel's `model_config` is) -- one constant, repeated per class, same +# shape codegen already has today (`@attrs.define(kw_only=True, +# slots=False)` emitted on every generated class). +_CFG = ConfigDict( + arbitrary_types_allowed=True, # for np.ndarray / NDArray fields + validate_assignment=True, + extra="forbid", # required for Field(init=False) to actually bite -- see module docstring +) + +_DTYPE_MAP = {"integer": np.int64, "double": np.float64} + + +# ============================================================================ +# Component: parent/child wiring + dimension resolution +# +# FRICTION POINT #1: attrs' private-attribute convention (`_parent` field, +# `parent=` constructor kwarg, via leading-underscore name mangling) +# doesn't exist in pydantic -- and doesn't need to. A field just named +# `parent` works directly. `exclude=True` is inert at runtime here (a +# dataclass has no built-in dump method to honor it -- see the plan doc's +# BaseModel-vs-dataclass section) but documents intent for if/when a +# `TypeAdapter(cls).dump_python(...)` call is ever added; flopy4 already +# does its own field-filtering in `to_dict()` regardless (its +# `attrs.asdict(..., filter=...)` call excludes "parent"/"_parent" by +# name today), so this isn't a functional gap. +# ============================================================================ + + +@dataclass(config=_CFG, kw_only=True) +class ComponentBase(ABC): + filename: Optional[str] = None + name: Optional[str] = None + parent: Optional["ComponentBase"] = Field(default=None, exclude=True, repr=False) + dims: dict = Field(default_factory=dict, exclude=True) + + # attrs' `attrs.Factory(lambda self: ..., takes_self=True)` + # (Component.name's real default: the lowercased *runtime* class name) + # has no direct per-field equivalent here either -- `default_factory` + # callables take no arguments in pydantic, same as in stdlib + # dataclasses. Filled in here, in `__post_init__` -- pydantic + # dataclasses use the same single post-construction hook stdlib + # dataclasses do (not BaseModel's two-hook `model_validator(mode= + # "after")` + `model_post_init` split), which turns out to be a + # *closer* match to attrs' single `__attrs_post_init__` than v2's + # BaseModel-based version was. + def __post_init__(self) -> None: + if self.name is None: + self.name = type(self).__name__.lower() + self._set_child_parents() + + def default_filename(self) -> str: + return f"{self.name}.{type(self).__name__.lower()}" + + # FRICTION POINT #3 (see plan doc): `attrs.fields(cls)` -> a pydantic + # dataclass's own `__pydantic_fields__` (or, more portably, + # `dataclasses.fields(cls)` combined with each field's pydantic + # `Field(...)` extras) -- same shape as BaseModel's `model_fields`, + # just a different attribute name. `.metadata` dict -> + # `Field(json_schema_extra={...})`, unchanged from v2. + @classmethod + def _pydantic_fields(cls) -> dict[str, Any]: + return cls.__pydantic_fields__ # type: ignore[attr-defined] + + @classmethod + def _child_fields(cls) -> list[str]: + """Fields whose annotation is (or wraps) another ComponentBase -- + replaces attrs_xarray.child_field_candidates() for the "only" case + this prototype needs (Dis -> Ncf).""" + names = [] + for fname, finfo in cls._pydantic_fields().items(): + ann = finfo.annotation + args = getattr(ann, "__args__", ()) + candidates = (ann, *args) + if any(isinstance(a, type) and issubclass(a, ComponentBase) for a in candidates): + names.append(fname) + return names + + def _set_child_parents(self) -> None: + for fname in self._child_fields(): + child = getattr(self, fname, None) + if isinstance(child, ComponentBase): + object.__setattr__(child, "parent", self) + if child.name is None: + object.__setattr__(child, "name", fname) + + # -- DimensionResolverMixin equivalent -------------------------------- + def get_dims(self) -> dict[str, int]: + return {} + + def resolve_dims(self, *dims: str) -> dict[str, int]: + if "_dimension_cache" not in self.__dict__: + self.__dict__["_dimension_cache"] = {} + cache = self.__dict__["_dimension_cache"] + + all_dims: dict[str, int] = {} + if self.parent is not None: + all_dims.update(self.parent.resolve_dims()) + all_dims.update(self.get_dims()) + for fname in self._child_fields(): + child = getattr(self, fname, None) + if isinstance(child, ComponentBase): + all_dims.update(child.get_dims()) + cache.update(all_dims) + + if not dims: + return all_dims + return {d: all_dims[d] for d in dims if d in all_dims} + + +# ============================================================================ +# Package: griddata broadcasting via a single, generic field_validator +# ============================================================================ + + +@dataclass(config=_CFG, kw_only=True) +class PackageBase(ComponentBase, ABC): + # FRICTION POINT #5: a griddata field's *declared* type is + # `NDArray[np.float64]`, but its *default value* in the current attrs + # code is a bare scalar (`default=1.0`) that only becomes a real array + # once dims are known. attrs never type-checks this mismatch. Pydantic + # DOES enforce it (confirmed by running this prototype): constructing + # `DisProto(delr=100.0, ...)` raises `ValidationError: Input should be + # an instance of ndarray` without a coercion step. + # + # This does NOT need to be written once per field, or once per + # generated class -- a single `field_validator("*", mode="before")`, + # defined ONE time on this shared base, driven by each field's own + # `json_schema_extra["shape"]` metadata (the metadata `spec.py`'s + # `field()` helper already emits today), covers every array field on + # every subclass, including under `validate_assignment=True` + # (confirmed: assigning `d.delr = 5.0` after construction still + # coerces). Codegen's array-field template doesn't need to emit a + # validator at all -- just the `shape=` metadata it already writes. + @field_validator("*", mode="before") + @classmethod + def _coerce_arrays(cls, v: Any, info: ValidationInfo) -> Any: + finfo = cls._pydantic_fields().get(info.field_name) + if finfo is None or v is None: + return v + meta = finfo.json_schema_extra or {} + if not (isinstance(meta, dict) and meta.get("block") == "griddata" and meta.get("shape")): + return v + if isinstance(v, np.ndarray): + return v + dtype = _DTYPE_MAP.get(meta.get("dfn_type", "double"), np.float64) + return np.asarray(v, dtype=dtype) + + # Deliberately a plain method, not a validator -- called explicitly + # from `__post_init__` (see DisProto below), the same way the real + # `Package._broadcast_griddata`/`DisBase._coerce_griddata` are plain + # methods called explicitly from `__attrs_post_init__`. + def _broadcast_griddata(self) -> None: + dims = self.resolve_dims() + if not dims: + return + for fname, finfo in type(self)._pydantic_fields().items(): + meta = finfo.json_schema_extra or {} + if meta.get("block") != "griddata": + continue + shape_dims = meta.get("shape") + if not shape_dims: + continue + val = getattr(self, fname, None) + if val is None: + continue + try: + shape = tuple(dims[d] for d in shape_dims) + except KeyError: + continue + dtype = _DTYPE_MAP.get(meta.get("dfn_type", "double"), np.float64) + if not isinstance(val, np.ndarray) or val.shape == shape: + continue + if val.size == 1: + # scalar (post-_coerce_arrays, a 0-d ndarray) -> broadcast + object.__setattr__(self, fname, np.full(shape, val.item(), dtype=dtype)) + elif meta.get("layered") and val.size == dims.get("nlay", 1): + object.__setattr__( + self, fname, np.repeat(val, np.prod(shape) // val.size).astype(dtype) + ) + else: + try: + object.__setattr__(self, fname, val.reshape(shape)) + except ValueError: + pass + + def __post_init__(self) -> None: + self._broadcast_griddata() + super().__post_init__() + + +# ============================================================================ +# NcfProto: minimal child-component stub (real Ncf is attrs-based and out +# of scope -- this exists only to exercise Component's child-wiring path) +# ============================================================================ + + +@dataclass(config=_CFG, kw_only=True) +class NcfProto(ComponentBase): + dfn_name: ClassVar[str] = "utl-ncf" + latitude: Optional[str] = None + longitude: Optional[str] = None + + +# ============================================================================ +# DisBase / Dis +# ============================================================================ + + +@dataclass(config=_CFG, kw_only=True) +class DisBaseProto(PackageBase, ABC): + # FRICTION POINT #2, RESOLVED by using a pydantic dataclass instead of + # BaseModel: these were `attrs.field(init=False)` in the real code -- + # excluded from the constructor entirely, always computed, with attrs + # raising `TypeError: unexpected keyword argument` if a caller passes + # one anyway. `Field(init=False)` on a `BaseModel` is silently inert + # at runtime (confirmed by testing: `M(nodes=999)` just sets + # `nodes=999`, no error, even under `extra="forbid"`). The identical + # `Field(init=False)`, on a pydantic dataclass, with `extra="forbid"` + # in config (see `_CFG` above), DOES work: `DisProto(nodes=999)` + # raises `ValidationError: Unexpected keyword argument` -- see the + # demo below. This was the single largest unresolved gap v2 of this + # prototype (BaseModel-based) found; switching the base to + # `pydantic.dataclasses.dataclass` closes it entirely, at zero extra + # code cost (same `Field(init=False)` call either way). + nlay: Optional[int] = Field(default=None, init=False) + nrow: Optional[int] = Field(default=None, init=False) + ncol: Optional[int] = Field(default=None, init=False) + ncpl: Optional[int] = Field(default=None, init=False) + nvert: Optional[int] = Field(default=None, init=False) + nodes: Optional[int] = Field(default=None, init=False) + + +@dataclass(config=_CFG, kw_only=True) +class DisProto(DisBaseProto): + dfn_name: ClassVar[str] = "gwf-dis" + + length_units: Optional[str] = Field(default=None, json_schema_extra={"block": "options"}) + nogrb: bool = Field(default=False, json_schema_extra={"block": "options"}) + xorigin: float = Field(default=0.0, json_schema_extra={"block": "options"}) + yorigin: float = Field(default=0.0, json_schema_extra={"block": "options"}) + export_array_netcdf: bool = Field(default=False, json_schema_extra={"block": "options"}) + ncf: Optional[NcfProto] = None + + # Redeclares DisBaseProto's init=False nlay/nrow/ncol as real (init=True) + # fields, matching the real attrs Dis exactly: DisBase declares all six + # derived fields init=False, but Dis's own field() redeclaration of + # nlay/nrow/ncol (real constructor args, `block="dimensions"`) shadows + # DisBase's -- only ncpl/nvert/nodes stay init=False, computed purely + # from these three. Pydantic dataclass subclassing honors the same + # override-by-redeclaration rule attrs does. + nlay: int = Field(default=1, json_schema_extra={"block": "dimensions"}) # type: ignore[assignment] + ncol: int = Field(default=2, json_schema_extra={"block": "dimensions"}) # type: ignore[assignment] + nrow: int = Field(default=2, json_schema_extra={"block": "dimensions"}) # type: ignore[assignment] + + delr: Annotated[ + NDArray[np.float64], + Field(json_schema_extra={"block": "griddata", "shape": ("ncol",), "netcdf": True}), + ] = 1.0 # type: ignore[assignment] + delc: Annotated[ + NDArray[np.float64], + Field(json_schema_extra={"block": "griddata", "shape": ("nrow",), "netcdf": True}), + ] = 1.0 # type: ignore[assignment] + top: Annotated[ + NDArray[np.float64], + Field(json_schema_extra={"block": "griddata", "shape": ("ncpl",), "netcdf": True}), + ] = 1.0 # type: ignore[assignment] + botm: Annotated[ + NDArray[np.float64], + Field( + json_schema_extra={ + "block": "griddata", + "shape": ("nodes",), + "layered": True, + "netcdf": True, + } + ), + ] = 0.0 # type: ignore[assignment] + + # No per-field array-coercion validator needed here -- see + # PackageBase._coerce_arrays above. + + def get_dims(self) -> dict[str, int]: + return { + "nlay": self.nlay, + "nrow": self.nrow, + "ncol": self.ncol, + "nodes": self.nlay * self.nrow * self.ncol, + "ncpl": self.nrow * self.ncol, + } + + # `nodes`/`ncpl`/`nvert` must exist before `resolve_dims()` (called + # inside `_broadcast_griddata`) can see them -- same ordering + # constraint the real `Dis.__attrs_post_init__` documents (compute + # derived dims, *then* chain to super()). + def __post_init__(self) -> None: + object.__setattr__(self, "nodes", self.ncol * self.nrow * self.nlay) + object.__setattr__(self, "ncpl", self.ncol * self.nrow) + object.__setattr__(self, "nvert", (self.ncol + 1) * (self.nrow + 1)) + self._broadcast_griddata() + # ComponentBase's own hook (child-wiring/default-name) -- deliberately + # skips PackageBase.__post_init__ to avoid a second broadcast pass. + ComponentBase.__post_init__(self) + + +# ============================================================================ +# Demonstration / smoke test +# ============================================================================ + + +def demo() -> None: + print("=" * 70) + print("Pydantic Dis prototype v3 (pydantic.dataclasses, current codebase shape)") + print("=" * 70) + + dis = DisProto(nlay=3, nrow=10, ncol=10, delr=100.0, delc=100.0, top=1.0, botm=0.0) + print(f"\nget_dims(): {dis.get_dims()}") + assert dis.nodes == 300 and dis.ncpl == 100 and dis.nvert == 121 + print(f"delr: shape={dis.delr.shape}, dtype={dis.delr.dtype}") + assert dis.delr.shape == (10,) + print(f"botm: shape={dis.botm.shape}") + assert dis.botm.shape == (300,) + + ncf = NcfProto(latitude="lat", longitude="lon") + dis2 = DisProto(ncf=ncf) + assert dis2.ncf is not None and dis2.ncf.parent is dis2 + print(f"\nchild wiring: dis2.ncf.parent is dis2 -> {dis2.ncf.parent is dis2}") + print(f"child wiring: dis2.ncf.name -> {dis2.ncf.name!r}") + + print("\nvalidate_assignment=True in effect:") + try: + dis.xorigin = "not a float" + raise AssertionError("expected a validation error") + except Exception as e: + print(f" dis.xorigin = 'not a float' -> raised {type(e).__name__} as expected") + + print("\nField(init=False) now correctly rejects an explicit kwarg (v2/BaseModel didn't):") + try: + DisProto(nodes=999) + raise AssertionError("expected a validation error") + except Exception as e: + print(f" DisProto(nodes=999) -> raised {type(e).__name__} as expected") + + print("\nAll assertions passed.") + + +if __name__ == "__main__": + demo() diff --git a/docs/dev/prototypes/pydantic_record_prototype.py b/docs/dev/prototypes/pydantic_record_prototype.py new file mode 100644 index 00000000..ec7ae668 --- /dev/null +++ b/docs/dev/prototypes/pydantic_record_prototype.py @@ -0,0 +1,366 @@ +""" +Pydantic prototype: does `flopy4/mf6/record.py`/`item.py` (the `Record`/ +`Item` row-type subsystem `Chd.StressPeriodData`, `Oc.Headprint`, etc. are +generated from) need to migrate off attrs at all, and if it does, can it +target a pydantic dataclass -- the same target +`pydantic_dis_prototype.py`/`pydantic_chd_prototype.py` chose for +`Component`/`Package` -- or does it need something else (plain stdlib +`dataclasses.dataclass`)? + +Answers, both confirmed by running this file: + +1. Record/Item's own mechanics (`to_tokens`/`from_tokens`, a metadata- + driven field walk) use NO attrs-specific validation/conversion feature + -- no `attrs.field(validator=...)` or `converter=` is ever declared on + a Record/Item field in the real codebase (grepped: `converter=` only + appears on `Component`/`Package`-level fields, e.g. `Gwf.dis`'s + `convert_grid`). `_coerce()` does its own manual, explicit coercion, + called directly from `from_tokens()` -- not wired through attrs at all. + So nothing here *requires* attrs specifically. + +2. BUT one real behavior IS load-bearing and attrs-specific: `_nested_class()` + (record.py) exists because a composed/nested record field (e.g. + `Oc.Headprint.fmt: "Oc.Format"`) is declared with a STRING type + annotation naming a SIBLING class inside the same enclosing class -- + unresolvable via any module-global lookup at class-body-execution time + (Python class bodies can't see sibling names in an enclosing class's + scope), so attrs' default behavior of leaving `f.type` as the literal, + unevaluated string is exactly what makes this possible: resolution is + deferred to `_nested_class()`, called lazily, well after the whole + module (and all its sibling classes) has finished loading. + + Confirmed here: this is NOT an attrs-specific behavior -- plain stdlib + `dataclasses.dataclass` does the exact same thing (`dataclasses.fields + (cls)[i].type` is *also* just the raw, unevaluated string) since it's a + property of how Python stores string-literal annotations, not of + attrs. A straight, mechanical port of `Record`'s current shape to a + stdlib dataclass needs zero changes to `_nested_class()`. + + Pydantic dataclasses behave differently, but not in the way a first + guess would suggest ("eager resolution breaks forward refs"): pydantic + defers schema-building for an unresolvable annotation + (`cls.__pydantic_complete__` is `False` right after decoration) and + resolves it LAZILY -- confirmed: constructing an instance with no + explicit fixup call at all just works, self-healing on first use, via + the same effective mechanism `typing.get_type_hints()` uses (`eval()` + against the defining module's globals plus qualified attribute access, + e.g. `eval("Oc.Format", sys.modules[cls.__module__].__dict__)` -- + which succeeds once `Oc.Format` exists as a real attribute of `Oc`, + regardless of whether `Headprint` or `Format` was defined first in the + source). The ONE real gap: something that inspects `cls.__pydantic_ + fields__` BEFORE any instance is ever constructed -- exactly what + `from_tokens()` does, since it calls `record_fields(cls)` before + building the returned instance -- sees the annotation still as an + unresolved `ForwardRef`, not the real class. Fixed with one guarded + `pydantic.dataclasses.rebuild_dataclass(cls)` call inside + `record_fields()` itself (see below) -- small, centralized, + confirmed working even with zero instances of the target class ever + constructed first. + +3. Genuine WIN for pydantic over stdlib dataclasses here, confirmed by + testing: once resolved (lazily, or via the `rebuild_dataclass()` guard + below), a pydantic dataclass's `FieldInfo.annotation` is the REAL + `Oc.Format` class object, not a string -- so `_nested_class()`'s entire + ~15-line custom qualname-walking resolver becomes UNNECESSARY code, not + just working code: `isinstance(finfo.annotation, type) and issubclass + (finfo.annotation, Record)` (after unwrapping `Optional`) replaces it + outright. A stdlib-dataclass port would need to KEEP `_nested_class()` + unchanged (its `.type` never resolves, same as attrs today). + +4. `attrs.NOTHING` (required-field sentinel, used in `from_tokens()`'s + `required_tagged` computation) -> `FieldInfo.is_required()` on a + pydantic dataclass -- direct swap, confirmed. + +5. `attrs.asdict(row)` (`Package.to_dataframe()`) -> `dataclasses.asdict + (row)` works UNCHANGED on a pydantic dataclass instance, confirmed -- + pydantic dataclasses ARE real stdlib dataclasses under the hood. + +6. Positional construction (`construct_item`'s `item_cls(*values)`, + `cls(*before, tuple_vals)`) needs `kw_only` left at its default + (`False`) -- unlike `Component`/`Package`'s `kw_only=True` -- confirmed + working the same as attrs' current non-`kw_only` `Item`/`Record` + classes. + +7. `.metadata` (dict, read via `f.metadata.get(...)` throughout record.py/ + item.py) -> `Field(json_schema_extra={...})`, same convention already + chosen for `Component`/`Package` fields (`pydantic_dis_prototype.py`) -- + confirmed a plain stdlib-style `Field(metadata={...})` kwarg is + deprecated/unsupported on pydantic's `Field()`, so this is the only + viable convention, which is also the *consistent* one across the + codebase (one metadata convention, not two). + +CONCLUSION: Record/Item does NOT need to keep attrs, and a straight port +to `pydantic.dataclasses.dataclass` (not `BaseModel` -- positional +construction, see #6) is not just possible but strictly simplifies one +piece of real code (`_nested_class()` goes away). Nothing here requires +falling back to plain stdlib `dataclasses.dataclass` instead -- pydantic +is the better target, matching `Component`/`Package`'s choice, keeping +ONE field-metadata idiom (`json_schema_extra`) across the whole object +model rather than two. + +Run directly: `python docs/dev/prototypes/pydantic_record_prototype.py` +""" + +from __future__ import annotations + +import types +from pathlib import Path +from typing import Any, ClassVar, Optional, Union, get_args, get_origin + +from pydantic import ConfigDict, Field +from pydantic.dataclasses import dataclass, rebuild_dataclass + +_CFG = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, extra="forbid") + + +# ============================================================================ +# record_fields(): attrs.fields() -> __pydantic_fields__, with the one new +# piece of support code this migration needs -- see finding #2 above. +# ============================================================================ + + +def record_fields(cls: type) -> dict[str, Any]: + """Non-private fields of a Record (or Item) class, in declaration + order. NEW vs. the real attrs-based version: the guarded + `rebuild_dataclass()` call -- confirmed necessary (and sufficient) so + that a nested/composed field's annotation is the real class, not a + stale `ForwardRef`, even when called before any instance of `cls` has + ever been constructed (exactly what `from_tokens()` does).""" + if not cls.__pydantic_complete__: # type: ignore[attr-defined] + rebuild_dataclass(cls, force=True, _parent_namespace_depth=4) # type: ignore[arg-type] + return {n: f for n, f in cls.__pydantic_fields__.items() if not n.startswith("_")} # type: ignore[attr-defined] + + +def keyword_of(cls: type) -> str: + return vars(cls).get("_keyword", "") + + +def _nested_class(cls: type, annotation: Any) -> "type[RecordBase] | None": + """Replaces attrs-based `_nested_class()`'s custom qualname-walking + string resolver entirely (finding #3): once `record_fields()` above + has resolved the field, `annotation` (a pydantic `FieldInfo. + annotation`) IS the real class object already -- no sys.modules/ + qualname lookup needed.""" + args = get_args(annotation) + candidate = next((a for a in args if a is not type(None)), annotation) + return candidate if isinstance(candidate, type) and issubclass(candidate, RecordBase) else None + + +def _is_bool_field(finfo: Any) -> bool: + t = finfo.annotation + origin = get_origin(t) + if origin is types.UnionType or origin is Union: + t = next((a for a in get_args(t) if a is not type(None)), t) + return t is bool + + +def _coerce(token: Any, finfo: Any) -> Any: + meta = finfo.json_schema_extra or {} + if isinstance(meta, dict) and meta.get("time_series"): + try: + return float(token) + except (ValueError, TypeError): + return str(token) + t = finfo.annotation + origin = get_origin(t) + if origin is types.UnionType or origin is Union: + args = [a for a in get_args(t) if a is not type(None)] + if len(args) != 1: + return token + t = args[0] + if t is int: + return int(float(str(token))) + if t is float: + return float(token) + if t is Path: + return Path(token) + return token + + +def _is_tagged(finfo: Any) -> bool: + return bool((finfo.json_schema_extra or {}).get("tagged")) + + +def _tagged_tokens(name: str, v: Any) -> list: + if isinstance(v, bool): + return [name.upper()] if v else [] + return [name.upper(), v] + + +def _consume_tagged(tokens: list, i: int, name: str, finfo: Any) -> "tuple[Any, int] | None": + if str(tokens[i]).upper() != name.upper(): + return None + if _is_bool_field(finfo): + return True, 1 + if i + 1 >= len(tokens): + return None + return _coerce(tokens[i + 1], finfo), 2 + + +class RecordBase: + """Mixin for generated Record/Item types -- ported from + `flopy4.mf6.record.Record`. Provides symmetric + `to_tokens`/`from_tokens`, same shape as the attrs original, just + reading `__pydantic_fields__`/`json_schema_extra` instead of + `attrs.fields()`/`.metadata` (finding #7).""" + + def to_tokens(self) -> tuple: + inner_cls = type(self) + keyword = keyword_of(inner_cls) + tokens: list = [keyword.upper()] if keyword else [] + for tok in vars(inner_cls).get("_extra_tokens", ()): + tokens.append(tok) + all_fields = record_fields(inner_cls) + tagged = [(n, f) for n, f in all_fields.items() if _is_tagged(f)] + untagged = [(n, f) for n, f in all_fields.items() if not _is_tagged(f)] + for name, finfo in tagged + untagged: + v = getattr(self, name) + if v is None: + continue + if isinstance(v, RecordBase): + tokens.extend(v.to_tokens()) + elif (finfo.json_schema_extra or {}).get("tagged"): + tokens.extend(_tagged_tokens(name, v)) + elif isinstance(v, bool): + if v: + tokens.append(name.upper()) + else: + tokens.append(v) + return tuple(tokens) + + @classmethod + def from_tokens(cls, tokens: "str | list[str]") -> "RecordBase": + if isinstance(tokens, str): + tokens = tokens.split() + + skip: list[str] = [] + if kw := keyword_of(cls): + skip.append(kw.upper()) + skip.extend(t.upper() for t in vars(cls).get("_extra_tokens", ())) + if [t.upper() for t in tokens[: len(skip)]] == skip: + tokens = tokens[len(skip) :] + + all_fields = record_fields(cls) + + nested_fields = [(n, f, _nested_class(cls, f.annotation)) for n, f in all_fields.items()] + nested_fields = [(n, f, nc) for n, f, nc in nested_fields if nc is not None] + if nested_fields: + assert len(nested_fields) == 1 and len(nested_fields) == len(all_fields), ( + f"{cls.__name__}: exactly one nested record field, with no plain " + "fields of its own, is the only shape supported so far" + ) + n, _f, nested_cls = nested_fields[0] + return cls(**{n: nested_cls.from_tokens(tokens)}) # type: ignore[call-arg] + + tagged = {n.upper(): (n, f) for n, f in all_fields.items() if _is_tagged(f)} + untagged = [(n, f) for n, f in all_fields.items() if not _is_tagged(f)] + + kwargs: dict = {} + consumed: set[int] = set() + i = 0 + while i < len(tokens): + entry = tagged.get(str(tokens[i]).upper()) + result = None if entry is None else _consume_tagged(tokens, i, entry[0], entry[1]) + if entry is None or result is None: + i += 1 + continue + name, _finfo = entry + val, width = result + kwargs[name] = val + for j in range(width): + consumed.add(i + j) + i += width + + required_tagged = [ + (n, f) + for n, f in all_fields.items() + if _is_tagged(f) and f.is_required() and n not in kwargs + ] + positional_queue = required_tagged + untagged + remaining = [t for j, t in enumerate(tokens) if j not in consumed] + for (name, finfo), tok in zip(positional_queue, remaining): + kwargs[name] = _coerce(tok, finfo) + + return cls(**kwargs) # type: ignore[call-arg] + + +# ============================================================================ +# Demonstration: Oc.Headprint / Oc.Format, the exact nested-sibling-class +# shape _nested_class() exists for -- ported to pydantic dataclasses. +# ============================================================================ + + +class Oc: + @dataclass(config=_CFG) + class Format(RecordBase): + _keyword: ClassVar[str] = "PRINT_FORMAT" + columns: int = Field(default=10, json_schema_extra={"tagged": True}) + width: int = Field(default=12, json_schema_extra={"tagged": True}) + digits: int = Field(default=6, json_schema_extra={"tagged": True}) + + @dataclass(config=_CFG) + class Headprint(RecordBase): + """References its SIBLING `Format` -- via a literal string + annotation, fully qualified exactly like real codegen emits + (`package.py.jinja` line 38/40: `"Optional[{{ spec.class_name }}. + {{ f.type_annotation }}]"`), and declared BEFORE `Format` in + source order (the harder of the two orderings -- see finding #2). + """ + + _keyword: ClassVar[str] = "HEAD" + fmt: "Optional[Oc.Format]" = Field(default=None) + + +# ============================================================================ +# A plain (non-nested) tagged/positional record, for the required-field +# (attrs.NOTHING -> is_required()) and to_tokens/from_tokens round-trip. +# ============================================================================ + + +@dataclass(config=_CFG) +class Save(RecordBase): + _keyword: ClassVar[str] = "SAVE" + frequency: int = Field(json_schema_extra={"tagged": True}) # required -- no default + print_input: bool = Field(default=False, json_schema_extra={"tagged": True}) + + +def demo() -> None: + print("=" * 70) + print("Pydantic Record/Item prototype") + print("=" * 70) + + complete = Oc.Headprint.__pydantic_complete__ + print(f"\nOc.Headprint.__pydantic_complete__ before any use: {complete}") + + hp = Oc.Headprint(fmt=Oc.Format(columns=5, width=10, digits=3)) + print(f"constructed via nested composition: {hp}") + print(f"__pydantic_complete__ after construction: {Oc.Headprint.__pydantic_complete__}") + + tokens = hp.to_tokens() + print(f"to_tokens(): {tokens}") + assert tokens == ("HEAD", "PRINT_FORMAT", "COLUMNS", 5, "WIDTH", 10, "DIGITS", 3) + + round_tripped = Oc.Headprint.from_tokens(list(tokens)) + print(f"from_tokens() round-trip: {round_tripped}") + assert round_tripped == hp + + print("\nrecord_fields() resolves the nested field to a REAL class object") + print("(not a string -- _nested_class() needs no sys.modules/qualname walk):") + fields = record_fields(Oc.Headprint) + resolved = _nested_class(Oc.Headprint, fields["fmt"].annotation) + print(f" fields['fmt'].annotation -> {fields['fmt'].annotation}") + print(f" _nested_class() -> {resolved}") + assert resolved is Oc.Format + + print("\nrequired-field detection (attrs.NOTHING -> FieldInfo.is_required()):") + save = Save.from_tokens("SAVE FREQUENCY 5") + print(f" Save.from_tokens('SAVE FREQUENCY 5') -> {save}") + assert save.frequency == 5 and save.print_input is False + print(f" to_tokens() round-trip: {save.to_tokens()}") + assert save.to_tokens() == ("SAVE", "FREQUENCY", 5) + + print("\nAll assertions passed.") + + +if __name__ == "__main__": + demo() diff --git a/docs/dev/prototypes/pydantic_union_arm_prototype.py b/docs/dev/prototypes/pydantic_union_arm_prototype.py new file mode 100644 index 00000000..96892d00 --- /dev/null +++ b/docs/dev/prototypes/pydantic_union_arm_prototype.py @@ -0,0 +1,381 @@ +""" +Pydantic prototype: the keystring-union-arm coercion path -- the one item +`pydantic_chd_prototype.py` explicitly left unmeasured ("the keystring-union +-arm case, e.g. LAK/SFR period settings, is a separate, still-unmeasured +surface"). Modeled on the real `flopy4/mf6/gwf/oc.py` `Oc` package, NOT a +synthetic example: `Oc` is the only in-repo package that exercises BOTH +layers of this machinery at once -- + + 1. Top-level union-arm dispatch: `Oc._stress_period_data` holds a + `Save | Print` Item per row, dispatched by its leading keyword token + (SAVE/PRINT) -- flopy4/mf6/item.py's `construct_union_item()`/ + `dispatch_union_item()`, called from `Package._coerce_item_list()`'s + `isinstance(item_cls, tuple)` branch. + 2. Nested union-arm dispatch: `Save`/`Print`'s own `ocsetting` field is + ITSELF a `All | First | Last | Frequency | Steps` Item, dispatched the + same way, one level down -- flopy4/mf6/item.py's `construct_item()`, + which detects a nested-union field via `_nested_union_classes()` and + recurses into `construct_union_item()` for it. + +Scope: only the raw-Python-value construction path (`Package. +_coerce_item_list` -> `construct_item`/`construct_union_item`), which is +what `Oc(stress_period_data={0: [(...)]})` goes through (confirmed against +the real package below). NOT `from_tokens`/`to_tokens` (the file-parsing +round trip) -- that machinery's pydantic-portability was already measured +independently in `pydantic_record_prototype.py` (a single nested class, not +a nested UNION of classes) and isn't repeated here; `ItemBase` below is +therefore deliberately thinner than the real `Item` mixin (no aux/boundname/ +array-of-cellid handling either -- `Oc.Save`/`Oc.Print` don't have those, +and adding them wouldn't exercise anything this spike is about). + +Baseline (real code, attrs) this reproduces byte-for-byte -- confirmed by +running against flopy4/mf6/gwf/oc.py directly before writing a line of this +file: + + >>> from flopy4.mf6.gwf import Oc + >>> oc = Oc(stress_period_data={0: [ + ... ("SAVE", "HEAD", "ALL"), + ... ("SAVE", "BUDGET", "STEPS", 1, 3, 5), + ... ("PRINT", "HEAD", "ALL"), + ... ]}) + >>> [(type(r).__name__, type(r.ocsetting).__name__, getattr(r.ocsetting, "steps", None)) + ... for r in oc.stress_period_data[0]] + [('Save', 'All', None), ('Save', 'Steps', (1, 3, 5)), ('Print', 'All', None)] + +Also matches test/mf6/test_mf6_adapters.py::test_oc_ocsetting_construct_item_positional +(`Oc(stress_period_data={0: [("SAVE", "HEAD", "ALL")]})` -> row is `Oc.Save`, +`row.ocsetting` is `Oc.All`) and the construction half of test/mf6/ +test_mf6_codec.py::test_oc_ocsetting_typed_dispatch (the dump/load/ +structure_component roundtrip in that test goes through a different code +path -- cattrs-based converters, out of scope here). + +KEY FINDING: the whole thing ports with NO new mechanism beyond what the +two earlier prototypes already established -- it's a straight composition +of `pydantic_chd_prototype.py`'s `SkipValidation`-wrapped Item-list field +(now `Optional[SkipValidation[dict[int, list[Save | Print]]]]` instead of +`Optional[SkipValidation[dict[int, list[Row]]]]`) and +`pydantic_record_prototype.py`'s lazy forward-ref resolution (now a +`"OcProto.All | OcProto.First | ..."` STRING forward ref naming FIVE +sibling classes instead of one -- confirmed pydantic's lazy resolver +handles a multi-name `|`-joined forward ref exactly the same way it handles +a single-name one, no special-casing needed). The one genuinely new piece +of code is `_is_item_union()` below -- the pydantic-side replacement for +item.py's `_nested_union_classes()`, and (same win record_prototype.py +found for `_nested_class()`) it is SIMPLER than the attrs original: no +qualname-walking string parse, just `get_origin`/`get_args` on the already- +resolved `FieldInfo.annotation`, because by the time `record_fields()`'s +guarded `rebuild_dataclass()` has run, that annotation IS the real +`OcProto.All | OcProto.First | ...` union object, not a string. + +Confirms the two remaining "Next steps" items from the plan doc's list are +no longer BOTH open -- this was the harder of the two (a real package using +double-nested keystring-union dispatch); the codegen-side change (emitting +`Field(json_schema_extra=...)` from make.py/filters.py) is still unmeasured, +but it's mechanical template work, not a new coercion mechanism -- nothing +found while writing this file suggests it would behave differently than the +by-hand `Field(...)` calls below. + +Run directly: `python docs/dev/prototypes/pydantic_union_arm_prototype.py` +""" + +from __future__ import annotations + +import types +from typing import Annotated, Any, ClassVar, Optional, Union, get_args, get_origin + +from pydantic import Field, SkipValidation +from pydantic.dataclasses import dataclass +from pydantic_dis_prototype import _CFG, PackageBase +from pydantic_record_prototype import keyword_of, record_fields + +# ============================================================================ +# ItemBase: thin port of flopy4/mf6/item.py's `Item` mixin -- just enough +# for construct_item()/construct_union_item() (see module docstring for what +# is deliberately NOT ported: to_tokens/from_tokens, aux/boundname/cellid). +# ============================================================================ + + +class ItemBase: + pass + + +# ============================================================================ +# _is_item_union(): replaces item.py's `_nested_union_classes()` -- see +# module docstring's KEY FINDING for why this is simpler than the original. +# ============================================================================ + + +def _is_item_union(annotation: Any) -> "tuple[type[ItemBase], ...] | None": + origin = get_origin(annotation) + if origin is Union or origin is types.UnionType: + arms = tuple( + a for a in get_args(annotation) if isinstance(a, type) and issubclass(a, ItemBase) + ) + return arms or None + return None + + +def dispatch_union_item( + values: list, arm_classes: "tuple[type[ItemBase], ...]" +) -> "type[ItemBase] | None": + """Port of item.py's `dispatch_union_item()`, unchanged in shape.""" + kw_map = {keyword_of(c).upper(): c for c in arm_classes if keyword_of(c)} + for v in values: + arm_cls = kw_map.get(str(v).upper()) + if arm_cls is not None: + return arm_cls + return None + + +def _collects_tail(finfo: Any) -> bool: + """A field that swallows every remaining positional value -- either a + nested Union[Item, ...] field (Oc's `ocsetting`) or an `array=True` + field (Oc.Steps' `steps`). Port of construct_item()'s `tuple_idx` + search condition, minus the aux/cellid arms real Item has that Oc's + classes don't use (see module docstring).""" + meta = finfo.json_schema_extra or {} + if isinstance(meta, dict) and meta.get("array"): + return True + return _is_item_union(finfo.annotation) is not None + + +def construct_item(item_cls: type, values) -> "ItemBase": + """Port of item.py's `construct_item()`.""" + fields = record_fields(item_cls) + items = list(fields.items()) + tuple_idx = next((i for i, (_, f) in enumerate(items) if _collects_tail(f)), None) + values = list(values) + if tuple_idx is None: + return item_cls(*values) + _, finfo = items[tuple_idx] + arm_classes = _is_item_union(finfo.annotation) + before = values[:tuple_idx] + trailing = values[tuple_idx:] + tail_val = ( + construct_union_item(trailing, arm_classes) if arm_classes is not None else tuple(trailing) + ) + return item_cls(*before, tail_val) + + +def construct_union_item(values, arm_classes: "tuple[type[ItemBase], ...]") -> "ItemBase | None": + """Port of item.py's `construct_union_item()`, unchanged in shape.""" + values = list(values) + arm_cls = dispatch_union_item(values, arm_classes) + if arm_cls is None: + return None + kw = keyword_of(arm_cls).upper() + kw_idx = next((i for i, v in enumerate(values) if str(v).upper() == kw), None) + if kw_idx is not None: + values = values[:kw_idx] + values[kw_idx + 1 :] + return construct_item(arm_cls, values) + + +# ============================================================================ +# _item_list_type(): pydantic_chd_prototype.py's version, extended (same as +# the real item_list_type()) to also return a tuple of arm classes. +# ============================================================================ + + +def _unwrap_skip_validation(t: Any) -> Any: + if get_origin(t) is Annotated: + return get_args(t)[0] + return t + + +def _unwrap_item(t: Any) -> "type[ItemBase] | tuple[type[ItemBase], ...] | None": + if isinstance(t, type) and issubclass(t, ItemBase): + return t + return _is_item_union(t) + + +def _item_list_type(field_type: Any) -> "type[ItemBase] | tuple[type[ItemBase], ...] | None": + args = get_args(field_type) + inner = next((a for a in args if a is not type(None)), None) + if inner is None: + return None + inner = _unwrap_skip_validation(inner) + if get_origin(inner) is dict: + _, val = get_args(inner) + val = _unwrap_skip_validation(val) + if get_origin(val) is list: + return _unwrap_item(get_args(val)[0]) + return None + + +# ============================================================================ +# UnionListPackageBase: Package._coerce_item_list, extended for the +# `isinstance(item_cls, tuple)` (keystring-union) branch +# pydantic_chd_prototype.py's ListPackageBase explicitly left out. +# ============================================================================ + + +@dataclass(config=_CFG, kw_only=True) +class UnionListPackageBase(PackageBase): + def _init_item_lists(self) -> None: + for fname, finfo in type(self)._pydantic_fields().items(): + meta = finfo.json_schema_extra or {} + if not (isinstance(meta, dict) and meta.get("block")): + continue + item_cls = _item_list_type(finfo.annotation) + if item_cls is None: + continue + raw = self.__dict__.get(fname) + if raw is None: + continue + if meta.get("block") == "period" or meta.get("fill_forward"): + coerced = { + kper: self._coerce_item_list(rows, item_cls) for kper, rows in raw.items() + } + object.__setattr__(self, fname, coerced) + else: + object.__setattr__(self, fname, self._coerce_item_list(raw, item_cls)) + + @staticmethod + def _coerce_item_list(data, item_cls) -> list: + """Port of `Package._coerce_item_list()`, both branches (this + prototype's whole reason for existing is the first one).""" + if isinstance(item_cls, tuple): + items = [] + for row in data: + if isinstance(row, item_cls): + items.append(row) + elif isinstance(row, dict): + kw = str(row.get("keyword", "")).upper() + arm = next((c for c in item_cls if keyword_of(c).upper() == kw), None) + if arm is not None: + items.append(arm(**{k: v for k, v in row.items() if k != "keyword"})) + else: + item = construct_union_item(row, item_cls) + if item is not None: + items.append(item) + return items + items = [] + for row in data: + if isinstance(row, item_cls): + items.append(row) + elif isinstance(row, dict): + items.append(item_cls(**row)) + elif isinstance(row, (list, tuple)): + items.append(item_cls(*row)) + return items + + def __post_init__(self) -> None: + self._init_item_lists() + super().__post_init__() + + +# ============================================================================ +# OcProto: models the real Oc (flopy4/mf6/gwf/oc.py), just the +# stress_period_data slice this spike is about. +# ============================================================================ + + +@dataclass(config=_CFG, kw_only=True) +class OcProto(UnionListPackageBase): + dfn_name: ClassVar[str] = "gwf-oc" + + @dataclass(config=_CFG) + class All(ItemBase): + _keyword: ClassVar[str] = "all" + + @dataclass(config=_CFG) + class First(ItemBase): + _keyword: ClassVar[str] = "first" + + @dataclass(config=_CFG) + class Last(ItemBase): + _keyword: ClassVar[str] = "last" + + @dataclass(config=_CFG) + class Frequency(ItemBase): + _keyword: ClassVar[str] = "frequency" + frequency: int = Field() + + @dataclass(config=_CFG) + class Steps(ItemBase): + _keyword: ClassVar[str] = "steps" + steps: tuple = Field(default=(), json_schema_extra={"array": True}) + + @dataclass(config=_CFG) + class Save(ItemBase): + _keyword: ClassVar[str] = "save" + rtype: Union[float, str] = Field() + ocsetting: ( + "OcProto.All | OcProto.First | OcProto.Last | OcProto.Frequency | " + "OcProto.Steps" + ) = Field() + + @dataclass(config=_CFG) + class Print(ItemBase): + _keyword: ClassVar[str] = "print" + rtype: Union[float, str] = Field() + ocsetting: ( + "OcProto.All | OcProto.First | OcProto.Last | OcProto.Frequency | " + "OcProto.Steps" + ) = Field() + + _StressPeriodDataItem = Save | Print + + stress_period_data: Optional[SkipValidation[dict[int, list[_StressPeriodDataItem]]]] = Field( + default=None, json_schema_extra={"block": "period", "fill_forward": True} + ) + + +# ============================================================================ +# Demonstration / smoke test +# ============================================================================ + + +def demo() -> None: + print("=" * 70) + print("Pydantic keystring-union-arm prototype (real Oc shape)") + print("=" * 70) + + complete = OcProto.Save.__pydantic_complete__ + print(f"\nSave.__pydantic_complete__ before any construction: {complete}") + + oc = OcProto( + stress_period_data={ + 0: [ + ("SAVE", "HEAD", "ALL"), + ("SAVE", "BUDGET", "STEPS", 1, 3, 5), + ("PRINT", "HEAD", "ALL"), + ] + } + ) + print(f"Save.__pydantic_complete__ after construction: {OcProto.Save.__pydantic_complete__}") + + rows = oc.stress_period_data[0] + print("\nstress_period_data[0]:") + for r in rows: + print(f" {type(r).__name__}(rtype={r.rtype!r}, ocsetting={r.ocsetting!r})") + + assert isinstance(rows[0], OcProto.Save) + assert isinstance(rows[0].ocsetting, OcProto.All) + assert isinstance(rows[1], OcProto.Save) + assert isinstance(rows[1].ocsetting, OcProto.Steps) + assert rows[1].ocsetting.steps == (1, 3, 5) + assert isinstance(rows[2], OcProto.Print) + assert isinstance(rows[2].ocsetting, OcProto.All) + + # -- dict-form input, dispatched by an explicit "keyword" key (the + # Package._coerce_item_list branch a raw tuple can't reach: a + # column-oriented / already-typed-arm dict input). + oc2 = OcProto( + stress_period_data={0: [{"keyword": "print", "rtype": "HEAD", "ocsetting": OcProto.All()}]} + ) + row2 = oc2.stress_period_data[0][0] + assert isinstance(row2, OcProto.Print) + assert isinstance(row2.ocsetting, OcProto.All) + print(f"\ndict-form (explicit keyword) input: {row2}") + + # -- a real OcProto.Save/Print instance passed straight through. + oc3 = OcProto(stress_period_data={0: [OcProto.Print(rtype="HEAD", ocsetting=OcProto.All())]}) + assert isinstance(oc3.stress_period_data[0][0], OcProto.Print) + print(f"instance-form input passes through: {oc3.stress_period_data[0][0]}") + + print("\nAll assertions passed.") + + +if __name__ == "__main__": + demo() diff --git a/docs/dev/pydantic-object-model-plan.md b/docs/dev/pydantic-object-model-plan.md new file mode 100644 index 00000000..9033f7a6 --- /dev/null +++ b/docs/dev/pydantic-object-model-plan.md @@ -0,0 +1,899 @@ +# Object model: attrs vs. pydantic (revised) + +## Status + +**The full migration is complete, on this branch (`pydantic-plan`), as of +2026-09-17.** Every class in the MF6 object model — `Component`, `Package`, +`Record`/`Item` (including — a scope expansion over this doc's earlier +"they don't need to migrate" finding below — the decision was made to +migrate them too, for a fully consistent codebase, once every mechanism +they needed was already independently confirmed working), all 8 hand-written +Dis/Disv pairs, `Ncf`, all 63 codegen-generated package files, and the full +~50-call-site consumer surface (`netcdf.py`, `converter/*`, `codec/*`, +`adapters.py`, `attrs_xarray.py`) — now targets +`pydantic.dataclasses.dataclass`. The complete test suite passes: **910 +passed, 0 failed, 11 skipped (pre-existing/unrelated), 4 xfailed +(pre-existing/unrelated)**, and a full-tree `ruff check .` is clean. See +"Full migration results (2026-09-17)" below for what was found doing the +real thing, as opposed to what the prototypes predicted. + +**tl;dr of how it got here:** The original "don't do this now" +recommendation (see "Revised recommendation" / "Re-assessed recommendation" +below) held through four rounds of prototyping, which is exactly why a full +migration then went ahead on this branch (see the 2026-09-17 update under +"Next steps") — every mechanism the object model needs was independently +de-risked first, cheaply, rather than discovered mid-migration. Four +runnable prototypes +(`docs/dev/prototypes/pydantic_{dis,chd,record,union_arm}_prototype.py`) +port `Dis`, a list-heavy package (`Chd`), the `Record`/`Item` row-type +subsystem, and the keystring-union-arm coercion path (`Oc`) to +`pydantic.dataclasses.dataclass` against the *current* (post-xattree, +post-`Row`) codebase shape, measuring real cost/ergonomics rather than +trusting the stale January prototype. Headline findings: target +`pydantic.dataclasses.dataclass`, never `BaseModel`; array-field, +Item-list, and keystring-union-arm coercion all collapse to one reusable +mechanism each, not per-field cost; `Component`/`Package`'s own mechanics +(parent/child wiring, `MutableMapping`, Item-list coercion, union-arm +dispatch) port cleanly; `item.py`/`record.py` don't strictly need to +migrate, but do so cleanly and delete real code +(`_nested_class()`'s custom resolver) when they do. The cost never fully +spiked in isolation — the codegen templates and the ~50-call-site consumer +surface (`netcdf.py`, `converter/*`, `codec/*`) — was then measured +directly by doing the real migration, per the 2026-09-17 decision, and is +now known rather than estimated (see below). + +Supersedes the prototype on `origin/plan-codegen` (`a9b77e8`, "planning", +2026-01-23) — six files (`pydantic_prototype.py`, +`pydantic_prototype_summary.md`, `codegen_comparison.md`, +`codegen_recommendation.md`, `codegen_architecture.py`, +`model_rebuild_explained.md`), never merged. That branch isn't deleted and +its code is still worth mining when this is picked back up (see "What's +still true," below) — but its headline recommendation is stale as of +2026-09. + +## Background + +Issue #282 ("Consider switching attrs -> pydantic") gives two motivations: +free JSON Schema, and easier construction/validation mechanics (notably, +validation that can run *after* assignment, which sidesteps friction in +load-time dimension resolution that attrs' init/convert/validate ordering +causes today). The January prototype demonstrated both are technically +achievable and recommended switching flopy4's object model +(`Package`/`Component`) to pydantic during the then-upcoming refactor. + +Discussion while writing `docs/dev/netcdf-spec-plan.md` (same architectural +question, applied to the NetCDF I/O object model) produced a sharper +version of the schema argument: JSON Schema is valuable on artifacts meant +for external/cross-tool interop — a file format, a spec other tools +consume — not on an in-memory object model whose only job is ergonomic +construction of a live simulation. That reasoning applies here too, and it +changes the calculus. + +## Why the January prototype is stale + +### Codebase drift + +- **xattree removed** (`67d0922`, 2026-09). The prototype spent real effort + proving compatibility with the xattree-adjacent parent/dimension-wiring + design it was written against. That constraint no longer exists — net + simplification, but it means the prototype's compatibility analysis + answers a question that no longer applies. +- **`Column`/`Schema` deleted, `Row` unified with `pk`/`fk` metadata** + (`78c506b`/`6cdfb2a`, 2026-08-19, per `mf6-object-model-plan.md` Phase + 0.6). The prototype's worked examples (DIS/NPF field declarations) are + ported against the pre-`Row` shape. +- **DFN spec parsing already flipped to pydantic** — `modflow_devtools.dfns` + dev3, consumed by flopy4's codegen (same `6cdfb2a`, "Phase 0.6a"). The + prototype predates this and had no visibility into it. +- **Every phase landed since January was built uniformly on + `attrs.fields()`** as the one field-introspection idiom, by explicit + design (`mf6-object-model-plan.md` wants exactly one idiom active at a + time — it says so directly, having just paid down a two-idiom problem for + `Column`/`Schema` vs. `Row`). Each phase that lands on attrs before a + pydantic swap happens is more surface area that swap eventually has to + re-touch. The prototype's "marginal cost, you're already refactoring" + framing assumed less of this had been built yet. + +### Value-conclusion drift + +The prototype's headline argument — schema-first design, JSON Schema "for +free" — is now satisfied independently, at the DFN-spec layer in devtools +(Phase 0.6a), without touching `Package`/`Component` at all. Same +conclusion as `netcdf-spec-plan.md`: the schema value lives with the spec +artifact meant for external interop, not with flopy4's in-memory +construction ergonomics. + +What's left standing on its own, once the schema argument is subtracted: +`validate_assignment=True` / `model_validator(mode="after")` replacing the +`__attrs_post_init__` super()-chain across `DimensionResolverMixin` → +`Component` → `Package` (`flopy4/dimensions.py`, `flopy4/mf6/component.py`, +`flopy4/mf6/package.py`). Real, still-open friction — but a narrower, +ergonomics-only case, not the two-pronged case the prototype made. + +## What's still true from the prototype (worth keeping) + +- `Annotated[NDArray[np.float64], ...]` field type hints work fine, dtype-precise. +- `model_validator` + `validate_assignment` does give implicit + post-assignment validation, which directly addresses the dimension- + resolution post-init chaining pain. +- Centralizing validation logic in a base-class `model_validator` so + generated classes reduce to field declarations is still sound in + principle — though flopy4's codegen has independently converged on thin + generated classes already via `flopy4/mf6/spec.py`'s `field()` wrapper, + so this is less of a differentiator than it was in January. +- pydantic is already a proven, unpinned, friction-free dependency in this + codebase (DFN spec parsing, `flopy4/mf6/netcdf.py`) — the version-pinning + risk mwtoews flagged on the issue hasn't materialized in practice. + +## Revised recommendation + +Don't do this now, in parallel with `mf6-object-model-plan.md`'s open +phases. Wait for one of: + +1. `mf6-object-model-plan.md` Phase 1 (generalized structuring) lands, so + there's exactly one field-introspection idiom to migrate off of, not two + competing ones mid-flight. +2. The `__attrs_post_init__`/`DimensionResolverMixin` chaining becomes an + active blocker on some other piece of real work (not hypothetical) — + that would justify pulling this forward ahead of (1). + +## Purpose of this branch + +Staging ground for updated prototyping, so that *whenever* one of the +above triggers is met, the go/no-go decision is made from real, current +measurements instead of the stale January prototype. The prototyping +itself (below) was done ahead of either trigger firing — deliberately: the +point was to de-risk the *cost estimate* now, cheaply, on a throwaway +branch, not to jump the queue on doing the actual migration. The "wait for +a trigger" recommendation is unchanged by any of it (see "Status" above). + +## Prototype results (2026-09-16) + +`docs/dev/prototypes/pydantic_dis_prototype.py` ports `Dis` (via +`DisBase`/`Package`/`Component`/`DimensionResolverMixin`, +`flopy4/mf6/gwf/{dis,disbase}.py`, `flopy4/mf6/{package,component}.py`, +`flopy4/dimensions.py`) to pydantic in its current, post-`Row` shape, and +runs (`pixi run -e dev python docs/dev/prototypes/pydantic_dis_prototype.py`) +against real assertions: derived dims (`nodes`/`ncpl`/`nvert`) compute +correctly, griddata scalar defaults broadcast to full arrays, a child +component (`ncf`, stubbed) gets parent-wired, `validate_assignment=True` +catches a bad post-construction assignment, and (v3, below) an explicit +`nodes=` kwarg is correctly rejected. It is a scoped-down measurement, not +a drop-in replacement — see "Explicitly out of scope" below. + +v3 of the prototype is built on `pydantic.dataclasses.dataclass`, not +`pydantic.BaseModel` — see "BaseModel vs. pydantic dataclasses" below for +why that switch happened and what it fixed. + +**What ported cleanly, lower cost than expected:** + +- Parent/child wiring is *simpler* in pydantic than attrs, not just + equivalent: attrs needs a private-attribute naming convention (`_parent` + field, `parent=` constructor kwarg, via leading-underscore mangling) to + get a public-looking accessor; pydantic just names the field `parent` + directly. No trick needed. +- `attrs.fields(cls)` → a pydantic dataclass's `__pydantic_fields__` + (or `cls.model_fields` if targeting `BaseModel`), `.metadata` dict → + `Field(json_schema_extra={...})`: a direct, mechanical swap, field by + field. Every place `spec.py`'s `field()` helper writes to `metadata[...]` + has an equally-simple pydantic equivalent. +- The single post-construction hook (`__post_init__` on a pydantic + dataclass) not auto-chaining across the MRO (each override must call + `super().__post_init__()` itself) turned out to be a wash, not a new + cost — attrs' `__attrs_post_init__` already required the same explicit + `super()` chaining discipline, and it's the same *single*-hook shape + attrs has (unlike BaseModel's two-hook `model_validator(mode="after")` + + `model_post_init` split — one more reason v3 prefers dataclasses). +- `validate_assignment=True` delivers the concrete ergonomics win issue + #282 actually asked for: a later bad assignment (`dis.xorigin = "not a + float"`) is now caught automatically. Confirmed working in the demo. +- Array-field coercion (below) also turned out to be a one-time cost, not a + per-field one — see the correction under "What's real." + +**What's real (a genuine, newly-surfaced correctness gap, but a one-time +fix, not a per-field one):** + +- Pydantic strictly validates `NDArray`-typed fields: constructing + `DisProto(delr=100.0, ...)` — the exact call shape `Dis(delr=100.0, ...)` + uses today, a bare scalar against an array-typed field — raised + `ValidationError: Input should be an instance of ndarray`. attrs never + validates this (no validator attached to the field by default), so the + scalar-default-for-an-array-typed-field pattern (used throughout + DIS/DISV/NPF/IC/STO/... griddata fields) just works there today. + An earlier revision of this prototype fixed this with a `field_validator` + declared per array field on `DisProto` and described it as an unavoidable + per-generated-field cost. **That was wrong, and worth flagging as a + correction rather than quietly fixing:** a single `field_validator("*", + mode="before")`, defined once on the shared `PackageBase`, driven by each + field's own `json_schema_extra["shape"]` metadata (the same metadata + `flopy4/mf6/spec.py`'s `field()` helper already emits today), covers + every array field on every subclass — present and future — including + under `validate_assignment=True` (`d.delr = 5.0` after construction still + coerces correctly, confirmed with a standalone test). Codegen doesn't + need to emit anything new for this; the `shape=` metadata it already + writes is sufficient. +- `attrs.field(init=False)` (`DisBase`'s derived `nlay`/`nrow`/`ncol`/ + `ncpl`/`nvert`/`nodes` — computed, never user-supplied) has **no working + `BaseModel` equivalent** — this was v2 of the prototype's biggest + unresolved gap, and it's resolved in v3 by targeting + `pydantic.dataclasses.dataclass` instead. See "BaseModel vs. pydantic + dataclasses" below. +- `attrs.Factory(lambda self: ..., takes_self=True)` (`Component.name`'s + default: the lowercased *runtime* class name) has no direct + `default_factory=` equivalent (those callables take no arguments) in + either BaseModel or a pydantic dataclass — filled in inside the single + post-construction hook instead (`__post_init__`, on the dataclass; a + `model_validator(mode="after")` on the now-abandoned BaseModel version). + One extra method either way, where attrs needed a one-line `Factory` — + the only place this measurement found pydantic costing a genuinely + unavoidable few extra lines versus attrs. + +**Explicitly out of scope for this measurement (deferred, not glossed +over)** — each of these is real remaining migration surface, not yet +priced: + +- Every consumer that reads `attrs.fields()`/`.metadata` off a live + `Package` today. Measured directly (not estimated): ~50 call sites across + 12 files (`component.py`, `package.py`, `dimensions.py`, `spec.py`, + `item.py`, `record.py`, `adapters.py`, `attrs_xarray.py`, `netcdf.py`, + `converter/ingress/structure.py`, `converter/egress/unstructure.py`, + `gwf/disbase.py`), concentrated most heavily in + `converter/ingress/structure.py` and `netcdf.py`. Each individual call + site is the same mechanical swap this prototype already demonstrates + (`attrs.fields(cls)` → `cls.model_fields`, `attr.metadata.get(...)` → + `finfo.json_schema_extra.get(...)`) — no single one is hard. The cost is + the count: ~50 independent edit sites is real surface area to touch and + re-test, and dominates total migration size far more than porting any + individual leaf package's field declarations does. Not measured here: + whether `attrs.Attribute.type` and pydantic's `FieldInfo.annotation` + ever disagree on a case this prototype didn't exercise (e.g. how each + represents `Optional`/`Union` for a field type) — worth checking against + the two largest files above before trusting the swap is mechanical + everywhere. + +## Prototype results: list-heavy package (2026-09-16) + +`docs/dev/prototypes/pydantic_chd_prototype.py` prices out the two things +the `Dis` prototype explicitly deferred: `Component`'s full `MutableMapping` +interface for a *list*-kind child field (several packages in one slot, not +just `Dis.ncf`'s single-child case), and `Package`'s Item-list period-data +coercion, modeled on the real `Chd`/`Chd.StressPeriodData` +(`flopy4/mf6/gwf/chd.py`). Run via +`pixi run -e dev python docs/dev/prototypes/pydantic_chd_prototype.py`. + +**The MutableMapping half ports cleanly**, once one attrs convention is +matched exactly: `Component._is_default_child_name()`'s check — is this +child's `.name` still at its class-name default, not just `is None` — has +to be replicated verbatim. Pydantic's dataclass `__post_init__` already +defaults every child's `.name` before the parent ever sees it (same as +`ComponentBase.__post_init__` in the `Dis` prototype), so by the time a +list-kind parent's `_set_child_parents()` runs, `child.name` is never +actually `None` — a naive `is None` check silently fails to disambiguate +same-class siblings (confirmed by running this prototype with that bug in +place: two fresh `ChdProto()` children both landed on `"chd1"` for their +would-be `"chd0"`/`"chd1"` names, since only the second matched `used`). +Fixed by porting the real `_is_default_child_name` equality check instead. +Once that's right, `__getitem__`/`__setitem__`/`__delitem__`/`__iter__`/ +`__len__`, auto-naming (`f"{field}{i}"`), and parent-stamping on +`__setitem__` all behave identically to `Component`'s real semantics +(demo asserts construction-time naming, explicit-key replacement, and +deletion all work). + +**The Item-list half surfaces one real, new problem pydantic-specific to +this field shape, not the array-field one already solved:** attrs applies +zero validation to a field like `Chd._stress_period_data` (no +validator/converter declared) — raw tuples/dicts pass through attrs' +`__init__` untouched, and `Package.__attrs_post_init__` coerces them +afterward. A plain pydantic-typed equivalent +(`Optional[dict[int, list[Row]]]`) does NOT behave this way: it's eagerly, +strictly validated at construction, so the exact raw-tuple/raw-dict input +`Chd(stress_period_data=...)` accepts today raises +`ValidationError: Input should be an instance of Row` before any +post-init coercion hook runs — confirmed directly, including a control +case proving the identical field without the fix does reject the identical +input the fixed version accepts. `pydantic.SkipValidation[...]` fixes it +(confirmed: same raw input accepted, coercion still runs in +`__post_init__` exactly like the attrs version), at two small, genuinely +new costs: + +- `flopy4/mf6/item.py`'s `item_list_type()` needs one extra unwrap step + (`Annotated[X, SkipValidation()]` → `X`) before its existing + `get_origin`/`get_args` walk reaches `dict[int, list[Row]]` — confirmed + the real function's current logic doesn't do this and would silently + return `None` (no item type found) without it. Small, mechanical, + one-time addition to one function. +- `SkipValidation` also opts the field out of `validate_assignment` + re-validation, confirmed directly (`pkg.stress_period_data = "junk"` is + silently accepted). Not a regression versus attrs (no validator is + declared on this field today either), but it is a per-field, explicit + opt-out rather than something that falls out of the shared config the + way array-field coercion does. + +**Corollary, worth stating plainly: `flopy4/mf6/item.py`/`record.py` (the +`Item`/`Record` row-type subsystem — token round-tripping, +`construct_item`/`construct_union_item`, cellid/aux/boundname handling) do +not need to migrate to pydantic at all.** With `SkipValidation`, pydantic +never inspects what's inside the list — `ChdRowProto` in this prototype is +a genuine, unmodified `attrs.define` class, exactly like the real +`Chd.StressPeriodData`. A real migration can leave `item.py`/`record.py` +attrs-based indefinitely and only port `Component`/`Package` (and +generated leaf classes) to pydantic — a materially smaller migration +surface than porting the whole object model in one pass would suggest. + +**Still out of scope after this measurement:** the keystring-union-arm +case (`construct_union_item` — LAK/SFR/MAW/UZF period settings, several +`Item` subclasses sharing one field via a `Union`) — this prototype only +covers the plain (non-union) coercion path `Chd`/`Wel`/`Drn`-style packages +use. `Component`'s "dict"-kind child collection (as opposed to "list") is +also still unexercised, though nothing found here suggests it would behave +differently from the "list" case's `_children`/naming logic. + +## Item/Record: does it need attrs? (2026-09-16) + +The previous section's corollary said `item.py`/`record.py` don't *need* +to migrate — true, `SkipValidation` means a `Package` that's moved to +pydantic can leave them alone indefinitely. Direct question asked +separately: if they *did* move, would they need to stay on attrs, could +they target plain stdlib `dataclasses.dataclass`, or does pydantic work +here too? `docs/dev/prototypes/pydantic_record_prototype.py` ports +`Record`'s core mechanics (`to_tokens`/`from_tokens`, the metadata-driven +field walk `record_fields()` does) to a pydantic dataclass and answers +this directly — run via +`pixi run -e dev python docs/dev/prototypes/pydantic_record_prototype.py`. + +**Nothing here requires attrs specifically.** Grepped the real codebase +first: no Record/Item field anywhere declares `validator=`/`converter=` +(those only appear on `Component`/`Package`-level fields, e.g. `Gwf.dis`'s +`convert_grid`) — `_coerce()` does its own manual, explicit coercion, +called directly from `from_tokens()`, never wired through attrs machinery. + +**One real attrs-specific behavior IS load-bearing, and it's not the one +the array-field/Item-list prototypes hit:** `_nested_class()` exists +because a composed record field (e.g. `Oc.Headprint.fmt: "Oc.Format"`, +generated by `package.py.jinja` lines 38/40 as a literal, fully-qualified +string) names a **sibling class inside the same enclosing class** — +unresolvable via any module-global lookup at class-body-execution time, +since Python class bodies can't see sibling names in an enclosing class's +scope. attrs' default behavior of leaving `f.type` as the literal, +unevaluated string is exactly what makes `_nested_class()`'s lazy, +custom qualname-walking resolution possible. + +- **Confirmed this is NOT attrs-specific**: plain stdlib + `dataclasses.dataclass` does the identical thing — `dataclasses.fields + (cls)[i].type` is *also* just the raw, unevaluated string, since it's a + property of how Python stores a string-literal annotation, not of + attrs. A straight port to stdlib dataclasses needs zero changes to + `_nested_class()`. +- **Pydantic dataclasses behave differently, but not how a first guess + ("eager resolution breaks forward refs") would suggest.** Confirmed by + testing the *exact* problem shape — `Format` referencing `Headprint` + declared in the *harder* order (referencing class first, referenced + class second, source-order matching what real codegen's + `spec.inner_classes` loop isn't guaranteed to avoid): pydantic defers + schema-building for an unresolvable annotation + (`cls.__pydantic_complete__` is `False` right after decoration) and + resolves it **lazily** — constructing an instance with **no explicit + fixup call at all** just works, self-healing on first use (the same + effective mechanism `typing.get_type_hints()` uses: `eval()` against the + defining module's globals plus qualified attribute access, succeeding + once `Oc.Format` exists as a real attribute of `Oc`, regardless of + which class was defined first in source). The one real gap: something + that inspects `__pydantic_fields__` **before any instance is ever + constructed** — exactly what `from_tokens()` does (calls + `record_fields(cls)` before building the instance it returns) — sees + the annotation still as an unresolved `ForwardRef`. Fixed with one + guarded `pydantic.dataclasses.rebuild_dataclass(cls)` call inside + `record_fields()` itself — small, centralized, confirmed working even + with zero prior instances of the target class. +- **Genuine win for pydantic over stdlib dataclasses here, not just + parity:** once resolved, a pydantic dataclass's `FieldInfo.annotation` + **is the real class object**, not a string — confirmed + `fields["fmt"].annotation` is identical (`is`) to `Optional[Oc.Format]` + built directly. That means `_nested_class()`'s entire ~15-line custom + qualname-walking resolver (`sys.modules` lookup, `__qualname__` + splitting, `getattr` walk) becomes **unnecessary code**, replaced by + `isinstance(annotation, type) and issubclass(annotation, RecordBase)` + (after unwrapping `Optional`) — confirmed working in the prototype's + `_nested_class()`. A stdlib-dataclass port would have to *keep* + `_nested_class()` unchanged, same as attrs today. + +**Everything else is the same mechanical swap already established +elsewhere in this doc, confirmed again here:** + +- `attrs.NOTHING` (the `required_tagged` sentinel in `from_tokens()`) → + `FieldInfo.is_required()`. Direct swap. +- `attrs.asdict(row)` (`Package.to_dataframe()`) → `dataclasses.asdict + (row)` works **unchanged** on a pydantic dataclass instance, confirmed + — pydantic dataclasses are real stdlib dataclasses underneath. +- Positional construction (`construct_item`'s `item_cls(*values)`, + `cls(*before, tuple_vals)`) needs `kw_only` left at its default + (`False`), unlike `Component`/`Package`'s `kw_only=True` — confirmed + working, matching attrs' current non-`kw_only` `Item`/`Record` classes. +- `.metadata` → `Field(json_schema_extra={...})`, the same convention + already chosen for `Component`/`Package` (not a stdlib-style + `Field(metadata={...})` kwarg — confirmed that's deprecated/unsupported + on pydantic's `Field()`). One metadata idiom across the whole object + model, not two. + +**Consequence:** if/when `item.py`/`record.py` ever do migrate, target +`pydantic.dataclasses.dataclass`, not plain stdlib `dataclasses.dataclass` +— it's not just equally viable, it deletes real code +(`_nested_class()`'s custom resolver). But per the corollary in the +previous section, migrating them is still not *required* by a +`Component`/`Package` migration — `SkipValidation` decouples the two, so +this can be sequenced independently, or skipped entirely, without +blocking anything else in this plan. + +## Prototype results: keystring-union-arm coercion (2026-09-17) + +The one item "Prototype results: list-heavy package" explicitly left +unmeasured: a package whose period data is a *union* of Item types +dispatched by leading keyword token (LAK/SFR/MAW/UZF-style period +settings), not a single Item type like `Chd`. `docs/dev/prototypes/ +pydantic_union_arm_prototype.py` ports this against the real `flopy4/mf6/ +gwf/oc.py` `Oc` package — the only in-repo package currently exercising +this machinery at all (grepped: no `list[Union[...]]`-typed top-level +period-data field exists yet in `gwf/`/`gwt/`/`gwe/`/`prt/` — LAK/SFR/MAW/ +UZF's own period-data fields turn out to be plain Item types, not unions; +`Oc.stress_period_data` is the real thing to model, and it's actually a +harder case than any of those four, since it exercises the coercion path +at BOTH levels at once (see below)). + +`Oc` exercises two nested layers of the same mechanism: + +1. Top-level: `Oc._stress_period_data` holds `Save | Print` (a `tuple` of + arm classes, item.py's `construct_union_item()`/`dispatch_union_item()`, + called from `Package._coerce_item_list()`'s `isinstance(item_cls, + tuple)` branch — the branch `pydantic_chd_prototype.py`'s version of + this method didn't implement). +2. Nested: `Save`/`Print`'s own `ocsetting` field is *itself* a + `All | First | Last | Frequency | Steps` union, dispatched the same way, + one level down (item.py's `construct_item()` detecting a nested-union + field via `_nested_union_classes()`). + +Confirmed against the real `Oc` package before writing any prototype code +(`Oc(stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", +"STEPS", 1, 3, 5), ("PRINT", "HEAD", "ALL")]})`) and reproduced +byte-for-byte by the prototype, including the untyped `steps=(1, 3, 5)` +int tuple (not floats — `construct_item()`'s array-field branch does no +numeric coercion; that only happens in the separate `from_tokens()` path, +out of scope here and already measured independently in +`pydantic_record_prototype.py`). + +**Result: no new mechanism needed.** This is a straight composition of +findings already on record — `SkipValidation` (the list-heavy-package +result) wrapping a field now typed `dict[int, list[Save | Print]]` instead +of `dict[int, list[Row]]`, plus lazy forward-ref resolution (the Item/ +Record result) now resolving a `"OcProto.All | OcProto.First | ..."` +string naming FIVE sibling classes instead of one — confirmed pydantic's +lazy resolver handles a multi-name `|`-joined forward ref exactly like a +single-name one. The one new piece of code, `_is_item_union()`, replaces +item.py's `_nested_union_classes()` and is *simpler* than the original for +the same reason `_nested_class()` was in the Item/Record section above: no +qualname-walking string parse, just `get_origin`/`get_args` on the +already-resolved `FieldInfo.annotation`. + +This was the harder of the two items "Next steps" listed as still open — +see the updated list below. + +## Full migration results (2026-09-17) + +Staged as 7 commits on `pydantic-plan` +(`249ffe9`, `75af85c`, `b6c2019`, `f5dc069`, `fbcbb3a`, `ab22c9f`, +`f8a5359`, `b29dfc4`), each independently runnable/testable, in the order: +`Record`/`Item` → `Component`+`DimensionResolverMixin` → `Package`+ +`disbase.py` → codegen pipeline → regenerate (63 files) → consumer surface +→ full-suite fixup. Every mechanism the prototypes measured ported exactly +as predicted; everything below is what the prototypes *couldn't* measure +(the codegen templates and consumer surface were explicitly out of scope +for isolated spiking, per the 2026-09-17 decision below) or got newly +surfaced by running the real ~250-fixture MF6 corpus test and the full +`test/` suite, not just hand-picked demo assertions. + +**Confirmed exactly as the prototypes predicted, at full scale:** + +- `pydantic.dataclasses.dataclass` (never `BaseModel`) with a shared + `ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, + extra="forbid")`, applied per-class via `config=CFG` since pydantic + doesn't inherit class-level config the way attrs does. +- One shared `field_validator("*", mode="before")` on `Package` + (`_coerce_arrays`), driven by each field's own `shape=`/`block=` + metadata, handles array coercion for all ~50 generated packages — no + per-field or per-package validator needed, exactly as measured. +- `SkipValidation[...]` + `item_list_type()`'s one-line unwrap fix handles + every Item-list field, plain and keystring-union-arm alike (`Oc` + directly, plus `Lak`/`Lke`/`Lkt`/`Prp` transitively via the same + mechanism) — no new mechanism needed beyond what the union-arm prototype + already found. +- `Record`/`Item` migrated cleanly to pydantic dataclasses and + `_nested_class()`'s custom qualname-walking resolver was deleted, as + predicted, replaced by a direct `isinstance` check on the now-real + `FieldInfo.annotation`. +- The ~50-call-site consumer surface (`netcdf.py`, `converter/ingress/ + structure.py`, `converter/egress/unstructure.py`, `adapters.py`, + `attrs_xarray.py`, `spec.py`) was indeed mechanical field-by-field + (`attrs.fields()`/`.metadata` → `__pydantic_fields__`/ + `json_schema_extra`), confirming the prototype's estimate that the cost + here is the *count* of sites, not the difficulty of any one of them. + +**New findings, only visible at real scale (not predictable from the +prototypes' scoped-down demos):** + +- Two real, pre-existing bugs it took the actual ~250-fixture corpus test + and full `test_quickstart_grid` mf6-binary integration test to surface — + neither reproducible from a hand-picked demo package: + - A Python `for` loop shadowing bug in + `converter/ingress/structure.py`: several loops used `name` as the + loop variable while `structure_component()` itself takes a `name` + keyword parameter (`for` loops don't scope in Python, unlike + comprehensions) — silently correct under attrs (which never validated + the resulting garbage against a real field type) but wrong under + pydantic's real validation, causing roughly 300 of the migration's + ~400 initial test failures. One rename (`name` → `fname`) fixed it. + - `chdg.py`/`drng.py`/`ghbg.py`/`rivg.py`/`welg.py` losing a + hand-maintained `auto_from="stress_period_data"` value on `maxbound` + when Stage 5 regenerated them — confirmed via a controlled before/ + after run of `test_quickstart_grid` against the *unmigrated* `develop` + branch (passes there, since those 5 files' on-disk value there was + still a stale-but-present hand-patch, untouched since it was last + regenerated) that this wasn't caused by the attrs→pydantic swap + itself. **Follow-up correction (`df6db25`, same day): this was + mischaracterized just above as an old, permanent codegen limitation — + it is not.** Git archaeology (not guesswork) found `filters.py` had a + working, general `has_maxbound`-gated mechanism for this as recently + as `3db0e36` (2026-09-02), which `67d0922` ("drop xattree (#356)", + 2026-09-16 — the same refactor this branch is based on) silently + dropped while reworking `maxbound` into a computed `@property` for + list-variant packages, with no explanation in that commit for why the + G-variant/`auto_from` half of the change went with it. Confirmed via + a scratch checkout of `origin/develop` (unmodified) that running + `pixi run -e dev generate-classes` there *today* silently strips + `auto_from` from the same 5 files — this is a **live, currently + unfixed regression on `develop` itself**, not a stable pre-existing + gap, and it also already-silently affects `gwf/api.py`/`gwt/api.py` + (no stale value there to mask it, just missed by every test so far). + Fixed at the actual root cause in `filters.py`'s `field_metadata()`: + since `build_component_spec` already `continue`s past every case + where `maxbound` becomes a computed property before + `field_metadata()` ever runs, every `maxbound` field that mechanism + still sees is unconditionally the "real, MF6-auto-inferred field" + case (confirmed against every historical plain-`maxbound` field at + `3db0e36` — zero counterexamples) — no `has_maxbound` parameter + needed, unlike the mechanism that regressed. Verified by regenerating + the full corpus: `chdg`/`drng`/`ghbg`/`rivg`/`welg` reproduce their + existing (till now hand-patched) content exactly, and `gwf/api.py`/ + `gwt/api.py` gain the field too. This branch's copy is now fixed at + the codegen level; `develop`'s is not yet — worth reporting upstream. + - **Second follow-up (`812a906`, 2026-09-17): went further and asked + whether `auto_from` is needed for these 5 packages at all, rather than + treating it as settled.** Checked the real MF6 Fortran source + (`src/Model/ModelUtilities/BoundaryPackageExt.f90`, + `BndExtType%source_dimensions`, via `gh api` against + `MODFLOW-ORG/modflow6`): for any `READARRAYGRID` ("G-variant") package, + the entire branch that reads a user-supplied `MAXBOUND` from the input + file is skipped unconditionally — `this%maxbound = this%dis%get_ncpl()` + always runs instead. A `MAXBOUND` line in a real `.chdg` (etc.) file's + DIMENSIONS block parses without error but has zero effect: never read, + never logged, never validated — genuinely dead input, not just an + inconvenient one, confirmed in the implementation rather than inferred + from the schema alone. This is real, not the same story as the ordinary + list-input variant, where `gwf-chd.dfn`'s `stress_period_data` field + declares `shape (maxbound)` — MF6 genuinely needs that value there to + size the read. + + Fixed at the actual source — the DFN schema itself, not flopy4's + codegen — on a new branch, `fix-gvariant-maxbound`, pushed to + `wpbonelli/modflow-devtools` (not yet merged upstream): the dev3 + migration (`migrate_to_v2_0_0_dev3.py`) no longer emits a `maxbound` + DIMENSIONS field (or the matching `dims.maxbound` entry) for any + package with a `readarraygrid` options-block keyword. Verified against + `autotest/dfns/` there (204 passed) with reviewed, hand-checked + snapshot diffs (only the 5 expected packages changed, only the + `maxbound` entries removed). + + That same verification — regenerating flopy4 against the new devtools + branch to confirm it actually worked end-to-end, not just trusting the + schema-level test — surfaced a second, unrelated, independently + real bug: `Dfns.load()` (what `RemoteDfnRegistry.spec()` actually calls + for a live sync, i.e. flopy4's own `generate-classes` path) silently + stopped its migration at schema_version `"2.0.0.dev2"` rather than + advancing to `"2.0.0.dev3"` (`CURRENT_SCHEMA_VERSION`), with no error — + meaning the maxbound fix above would never have reached flopy4's real + generated output at all without also fixing this. Confirmed directly: + the first version of the devtools fix alone produced zero change when + flopy4 was regenerated against it. Also fixed on the same branch, + together with a second devtools-side bug this uncovered (`migrate.py`'s + own dev2-CLI-output path was depending on `Dfns.load()`'s buggy + stop-at-dev2 behavior, so simply "fixing" `Dfns.load()` alone broke + that caller — given its own direct per-file loop instead, mirroring the + already-correct dev3 branch). + + flopy4 itself was NOT repointed at the unmerged devtools branch + permanently (would have meant depending on someone else's unreviewed + fork/branch, and empirically caused a `~/.cache/modflow-devtools` + resource-cleanliness surprise — the corpus-loading test's + "model registry" cache is siblings-in-the-same-cache-tree with the DFN + cache, so clearing one while testing this cleared the other too, purely + an artifact of local testing, not of the fix). Verified for real by + temporarily repointing `pyproject.toml` at the fork branch, running + `pixi run generate-classes` + the full suite, then reverting the + dependency and keeping only the resulting, hand-confirmed-correct + 5-file diff — `chdg.py`/`drng.py`/`ghbg.py`/`rivg.py`/`welg.py` no + longer declare a `maxbound` field at all. `filters.py`'s + `field_metadata()` keeps its `auto_from` fallback, now reached only by + the separate, unverified `gwf-api`/`gwt-api` case. +- Real MF6 test-fixture data-quality issues, tolerated silently by attrs + (zero field validation) and correctly rejected by pydantic's real + types, found only by running actual DFN-driven test/example files: IMS + stale legacy extra tokens on scalar fields ("OUTER_MAXIMUM 100 500"), + Gwf "NEWTON UNDER_RELAXATION" (bool/keyword field with a trailing + modifier token), a single-name `auxiliary` option missing `shape=` + metadata, `Tdis.start_date_time` splitting an ISO datetime into + multiple tokens or a bare-int year, pandas `NaN` for a missing optional + Item-list column, and `Npf.rewet`'s field name colliding with its own + inner Record's `_keyword`, causing wrong dispatch priority. All fixed + at the point where the real data meets the new (correct) validation, + not by loosening validation. +- A distinction the array-coercion prototype didn't need to make because + its demo never exercised it: an `xr.DataArray` satisfies the same duck + typing as a real dask array (`hasattr(dtype)`/`hasattr(shape)`), but + only a dask array's laziness needs preserving — an `xr.DataArray` still + needs materializing via `np.asarray()` to satisfy a stricter bare + `NDArray[...]`-typed field (`Disv.top`/`botm` and similar). Fixed by + narrowing `_coerce_arrays`'s passthrough condition to + `isinstance(v, np.ndarray) or _is_dask_array(v)` specifically. +- `Disv.iv`/`xv`/`yv` (all 4 model families) are commonly constructed from + a plain list (`from_grid()`) but carry no `shape=`/`block="griddata"` + metadata, so the shared `Package._coerce_arrays` validator never reaches + them — needed small, field-scoped `mode="before"` validators declared + locally on each `Disv` class, the one place per-field validators + (rather than the shared one) were actually necessary. +- `Dis`/`Disv`'s `top`/`botm` fields (all 4 model families) were declared + as required `NDArray[...]` but given a real `default=None` (and + constructed with an explicit `None` on some grid-conversion paths) — a + type/default mismatch attrs never checked. Fixed by making them + `Optional[NDArray[...]]`, matching `idomain`'s already-correct pattern. +- Field-redeclaration ordering differs: when a subclass redeclares a field + its base class already declared (`DisBase`'s `nlay`/`nrow`/`ncol`/... + vs. `Dis`'s own `nlay`/`ncol`/`nrow`), attrs moves the field to the + subclass's redeclaration position; pydantic (like plain stdlib + dataclasses) keeps it at the base class's original position. A real, + permanent behavioral difference, not a bug — confirmed via + `Dis.__pydantic_fields__` directly and reflected in updated test + expectations, not worked around. +- `init=False` fields are fundamentally incompatible with `extra="allow"` + in pydantic — it refuses the combination outright + (`PydanticUserError`), confirmed empirically. Since `DisBase`'s derived + dimensions need `init=False` + `extra="forbid"` to work at all, this + closes off the specific kind of freeform post-construction attribute- + bolting attrs' `slots=False` tolerated; one flopy3-compat test needed + the same `object.__setattr__()` escape hatch the real source already + uses internally for the same reason. +- Missing-required-field construction raises pydantic's own + `ValidationError` where attrs raised `TypeError` — a real, permanent + exception-type difference for any code that catches construction + errors narrowly. +- A codegen bug newly introduced by the migration itself (not + pre-existing): `_generated_imports()` in `make.py` unconditionally + imported `Field` even for files with no `inner_classes` (the only place + a bare `Field()` call is ever emitted — everything else routes through + `spec.py`'s `field()`/`path()` wrappers), producing 49 unused-import + lint errors across the regenerated corpus. Fixed at the root cause + (scoped to `has_inner_classes`), plus a one-time `ruff --fix` sweep. + +**Not needed, contrary to what might have been assumed going in:** no +change to `to_field_type()`/`get_field_type()`/`child_field_candidates()` +beyond the mechanical `Attribute`→`FieldInfo` swap already measured; no +change to how computed `@property` fields (e.g. `maxbound`) stay invisible +to field introspection — `unstructure.py`'s existing +`isinstance(..., property)` special-case needed no changes. + +## BaseModel vs. pydantic dataclasses + +Every pydantic-based sketch this codebase has produced so far — the +January prototype, `flopy4/mf6/netcdf.py`, `modflow_devtools.dfns` — is +built on `pydantic.BaseModel`. v2 of this prototype followed that default +without examining it. It shouldn't have: `pydantic.dataclasses.dataclass` +is the closer match to what the object model actually needs, confirmed +directly (not assumed) by testing both: + +- **`Field(init=False)`.** On `BaseModel`, it's accepted by the field + constructor but has **no runtime effect at all** — confirmed: + `M(nodes=999)` on a `BaseModel` with an `init=False` field silently + succeeds and sets `nodes=999`, even under `extra="forbid"`. It's + type-checker-only metadata there (part of `@dataclass_transform` + support), not an enforced constraint. On a pydantic dataclass, the + *identical* `Field(init=False)`, combined with `extra="forbid"` in + config, works exactly like attrs: `DisProto(nodes=999)` raises + `ValidationError: Unexpected keyword argument` — confirmed in the demo. + This was v2's single biggest unresolved gap; v3 closes it for free, no + extra code beyond the `Field(init=False)` call attrs' equivalent already + needed. +- **Everything else composes cleanly, confirmed with standalone tests + before committing to the rewrite:** a pydantic dataclass subclassing + `ABC` and mixing in `collections.abc.MutableMapping` works + (`isinstance(d, MutableMapping)` is `True`); `kw_only=True` is a direct + decorator argument, matching `@attrs.define(kw_only=True)` exactly; + `field_validator`/`validate_assignment=True` work identically to the + `BaseModel` case; direct `self.__dict__[...]` writes (the + `_dimension_cache` lazy-init pattern `flopy4/dimensions.py` uses today) + and `object.__setattr__` bypass-writes (used throughout + `Package`/`DisBase` to update a field without re-triggering validation) + both still work on a dataclass instance; a nested pydantic-dataclass-typed + child field (`ncf: Optional[NcfProto]`) constructs and wires up the same + as under `BaseModel`. +- **What a dataclass gives up:** `BaseModel`'s self-methods + (`.model_dump()`, `.model_validate()`, `.model_json_schema()`) aren't + available directly on an instance — the equivalent is an external + `pydantic.TypeAdapter(cls)` call. In practice this costs nothing here: + flopy4 doesn't lean on those methods today either. `Component.to_dict()` + already wraps `attrs.asdict(self, recurse=True, filter=...)` — an + external function, not a self-method — and would wrap + `TypeAdapter(type(self)).dump_python(self, ...)` the same way. JSON + Schema (already concluded, in the Background section above, to belong + at the devtools/DFN layer rather than here) would still be reachable via + `TypeAdapter(cls).json_schema()` if ever wanted. + +**Consequence:** any future prototyping or real migration should target +`pydantic.dataclasses.dataclass`, not `BaseModel`. It's a closer structural +match to attrs (single post-construction hook, real `init=False`, +`kw_only` as a decorator arg) and gives up nothing flopy4's object model +actually uses from `BaseModel`. + +## Supporting-code complexity vs. the current implementation + +The previous section (and the ~50-call-site count above) covers *how many* +places need to change. This is about the code *those places rely on* — +`spec.py`'s `field()`/`fields_dict()`/`to_field_type()`/`get_field_type()` +and `attrs_xarray.py`'s `child_field_candidates()` — and whether its +replacement is more, less, or equally complex. + +- **The bulk of it is a wash.** `to_field_type()`/`get_field_type()` + (~100 lines) and `child_field_candidates()` (~40 lines) are `match` + statements over `get_origin()`/`get_args()` of a raw type annotation, + bridging Python's type system to MF6's own DFN type vocabulary + (`keyword`/`integer`/`double`/`record`/`list`/...). That complexity comes + from interpreting `typing` module generics, not from attrs vs. pydantic — + confirmed directly: a side-by-side test showed `attrs.Attribute.type` and + pydantic's `FieldInfo.annotation` expose `Optional[int]`-style + annotations identically (`typing.Optional[int]`, + `typing._UnionGenericAlias`, both cases). Neither library's native + validation/schema machinery reduces this bridge layer — MF6's type + vocabulary doesn't map onto either library's own type system, so a + hand-written translation is required either way. `spec.py`'s `field()` + metadata wrapper is the same story: `Field(json_schema_extra={...})` in + place of `attrs.field(metadata={...})`, same kwargs, same size. +- **One place pydantic's introspection is more robust, not just + equivalent** — found by testing, not assumed: `attrs.fields(cls).type` + only resolves to a real type object when attrs can eagerly evaluate the + annotation. A string/forward-ref annotation (e.g. under `from __future__ + import annotations`, which the codebase doesn't use today but easily + could add) silently degrades `attrs.Attribute.type` to an unresolved + `str` unless `attrs.resolve_types()` is called explicitly — and there + are zero such calls anywhere in flopy4 today, so `to_field_type()`/ + `child_field_candidates()` are quietly relying on a convention (no + future-annotations import) rather than a guarantee. Confirmed directly: + `b: "int | None"` under attrs stayed a bare `str`; the identical + annotation under pydantic resolved to a real `types.UnionType` + automatically, no extra call needed. +- **One place pydantic dataclasses need genuinely new support code:** the + `init=False` replacement (see "BaseModel vs. pydantic dataclasses" above) + is resolved for the object model itself, but confirms this is a + systemic pattern, not a `Dis`-only quirk — `init=False` appears at 13 + real sites across 7 files, including `flopy4/mf6/utils/codegen/ + filters.py` (codegen emits it, not just hand-written `DisBase`). A real + migration's codegen templates need `extra="forbid"` in the shared + dataclass config (one line, project-wide) for this to keep working — + cheap, but worth naming as a required config decision, not an implicit + default. + +## Re-assessed recommendation + +The measurement doesn't change the "wait for a trigger" recommendation +above. It does relocate where the real cost lives: not in per-field +boilerplate (the array-coercion validator collapses to one reusable +definition, not one per field or per package), not in translating type +annotations to MF6's DFN vocabulary (a wash — see "Supporting-code +complexity" above), and not in `Component`/`Package`'s own object-model +mechanics (`MutableMapping`, Item-list coercion, parent/child wiring, and +now the keystring-union-arm coercion path — all measured and ported +cleanly, at the cost of one `SkipValidation` opt-out per Item-list field +and one small `item_list_type()` fix), but in the ~50-call-site consumer +surface outside the object model itself (`netcdf.py`, `converter/*`, +`codec/*`) and the codegen templates that would need to emit the pydantic +shape. That's a one-time, codebase-wide cost rather than one that scales +with how many packages get migrated. Target `pydantic.dataclasses.dataclass`, +not `BaseModel`, for both — and leave `flopy4/mf6/item.py`/`record.py` +attrs-based; they don't need to migrate (see the Item-list corollary +above). + +## Next steps + +All items originally listed here are done — see "Full migration results +(2026-09-17)" above for what running them for real found beyond what the +prototypes predicted. Kept for history: + +1. ~~Prototype the codegen-side change~~ — done for real, not spiked; see + Stage 4 in "Full migration results" above. +2. ~~Prototype migrating one real consumer~~ — done for real, all ~50 call + sites across the full consumer surface, not just `netcdf.py`; see + Stage 6 above. +3. ~~Measure the keystring-union-arm coercion path~~ — done, see + "Prototype results: keystring-union-arm coercion" above. +4. ~~Re-decide go/no-go~~ — superseded by the 2026-09-17 decision below to + go straight to a full migration rather than spike further. + +**2026-09-17 decision-to-migrate note:** the decision was made to skip +further isolated spiking and go straight to a full migration on this +branch instead — a real, working migration is a better basis for team +comparison than more prototyping, and every mechanism it would exercise +(array/griddata coercion, `MutableMapping`, Item-list coercion including +the keystring-union-arm case, parent/child wiring) was already +independently confirmed to work with no open unknowns. The codegen +template work and the `netcdf.py`/`converter/*`/`codec/*` consumer surface +were done as part of that migration directly, not spiked separately +first. **The migration is now complete** — see "Status" and "Full +migration results (2026-09-17)" above. + +**What's genuinely left, if this is merged:** + +- The real go/no-go/merge decision itself — this doc and branch provide + the complete, working comparison basis issue #282 asked for, but merging + `pydantic-plan` into `develop` is a separate decision this doc doesn't + make. +- ~~The `maxbound`/`auto_from="stress_period_data"` codegen gap~~ — fully + resolved, in two steps (see "Full migration results" above): first + restored at the codegen-logic level on this branch (`df6db25`), after + discovering it wasn't the pre-existing/permanent limitation first + assumed but a live regression on `develop` itself, from `67d0922` + ("drop xattree (#356)"); then removed entirely (`812a906`) once the + Fortran source confirmed `MAXBOUND` is genuinely dead input for these 5 + packages, with the real fix landing in a DFN schema branch pushed to + `wpbonelli/modflow-devtools` (`fix-gvariant-maxbound`, not yet merged + upstream — flopy4 was NOT repointed at it permanently; the resulting + 5-file diff was verified against it and kept by hand). **`develop` + (flopy4) still has the codegen-level `auto_from` regression from + `67d0922`** (moot for `pydantic-plan` now, but still real there), **and + `MODFLOW-ORG/modflow-devtools` still has both the dead-`MAXBOUND`-field + DFN issue and the unrelated `Dfns.load()` dev2/dev3 bug the fix's own + verification surfaced** — three separate, independent things worth + upstreaming/PRing, none of them pydantic-specific, none blocking any + `pydantic-plan` merge decision. +- Sequencing against `mf6-object-model-plan.md` Phase 1 (see "Related" + below) — this migration did not wait for it, per the 2026-09-17 + decision; whether that causes any rebasing friction if Phase 1 lands + first is unknown. + +## Related + +- `docs/dev/prototypes/pydantic_dis_prototype.py` — the working prototype + behind "Prototype results" above, built on `pydantic.dataclasses.dataclass` + (v3 — see "BaseModel vs. pydantic dataclasses"). Runnable standalone; not + wired into flopy4's real registry/codegen/write/load path. +- `docs/dev/prototypes/pydantic_chd_prototype.py` — the list-heavy-package + prototype behind "Prototype results: list-heavy package" above (imports + `ComponentBase`/`PackageBase` from `pydantic_dis_prototype.py`). Runnable + standalone. +- `docs/dev/prototypes/pydantic_record_prototype.py` — the `Record`/`Item` + prototype behind "Item/Record: does it need attrs?" above. Self-contained + (doesn't import from the other two prototypes). Runnable standalone. +- `docs/dev/prototypes/pydantic_union_arm_prototype.py` — the + keystring-union-arm prototype behind "Prototype results: + keystring-union-arm coercion" above (imports `PackageBase` from + `pydantic_dis_prototype.py`, `record_fields`/`keyword_of` from + `pydantic_record_prototype.py`). Models the real `flopy4/mf6/gwf/oc.py` + `Oc` package. Runnable standalone. +- `docs/dev/netcdf-spec-plan.md` — same schema-value-layering conclusion, + applied to the NetCDF I/O object model. +- `mf6-object-model-plan.md` — the in-flight refactor this should sequence + after. **Not a file in this repo** — confirmed via `git log --all` it has + never been committed, on any branch; it's a local/uncommitted planning + note in someone's working tree. A fresh session won't have it — ask + wpbonelli for current status on `mf6-object-model-plan.md` Phase 1 + (generalized structuring) rather than expecting to find or `git show` it. +- Issue #282. +- `origin/plan-codegen` (`a9b77e8`) — original prototype code/docs; mined + for `pydantic_prototype.py`'s array-structuring pattern + (`structure_array_from_value` → this prototype's `PackageBase._coerce_arrays` + `field_validator`) when writing `pydantic_dis_prototype.py`. Its + recommendation section is still not current; this doc supersedes it. diff --git a/docs/examples/frenchman-flat.py b/docs/examples/frenchman-flat.py index 2cda6958..fee30657 100644 --- a/docs/examples/frenchman-flat.py +++ b/docs/examples/frenchman-flat.py @@ -703,7 +703,7 @@ def plot_head_ugrid(head, cbc, grid, workspace): nc_fpth = workspace / "frenchman-flat.input.nc" gwf.netcdf_input_file = nc_fpth dis.ncf = Ncf.from_grid(grid, NetCDFFormat.LAYERED_MESH) -dis.ncf.filename = workspace / "ff.dis.ncf" +dis.ncf.filename = str(workspace / "ff.dis.ncf") # Here, grid and time info is passed to the `NetCDFModel' constructor # so that coordinate and mesh data is written to the NetCDF file. @@ -822,7 +822,7 @@ def plot_head_ugrid(head, cbc, grid, workspace): gwf.netcdf_mesh2d_file = Path("frenchman-flat.nc") gwf.netcdf_input_file = Path("frenchman-flat.input.nc") dis.ncf = Ncf.from_grid(grid, NetCDFFormat.LAYERED_MESH) -dis.ncf.filename = workspace / "ff.dis.ncf" +dis.ncf.filename = str(workspace / "ff.dis.ncf") # Again, with grid and time info nc_model = flopy4.mf6.netcdf.NetCDFModel.from_model( @@ -871,7 +871,7 @@ def plot_head_ugrid(head, cbc, grid, workspace): gwf.netcdf_input_file = nc_fpth gwf.netcdf_mesh2d_file = None dis.ncf = Ncf.from_grid(grid, NetCDFFormat.STRUCTURED) -dis.ncf.filename = workspace / "ff.dis.ncf" +dis.ncf.filename = str(workspace / "ff.dis.ncf") # Again, with grid and time info nc_model = flopy4.mf6.netcdf.NetCDFModel.from_model(gwf, grid=grid, time=time) diff --git a/flopy4/attrs_xarray.py b/flopy4/attrs_xarray.py index 3e8fd754..23d9a65a 100644 --- a/flopy4/attrs_xarray.py +++ b/flopy4/attrs_xarray.py @@ -1,16 +1,16 @@ """ -Generic conversion between `attrs`-decorated classes and `xarray` -`Dataset`/`DataTree` objects. +Generic conversion between `pydantic.dataclasses.dataclass`-decorated +classes and `xarray` `Dataset`/`DataTree` objects. Reads only two things per field: its own instance value (via `getattr`) and, for array values, the field's own `shape` metadata (a tuple of dimension-name strings -- the same convention `flopy4.mf6.spec.field()`/ `array()` already tag leaf DFN packages with). -Child detection (which fields hold nested attrs instances, as opposed to -plain scalar/array leaf values) is done by inspecting instance *values* -with `attrs.has()` when building a tree (mirrors the `attrs.fields()` + -`isinstance` pattern `flopy4.dimensions` already established), and by +Child detection (which fields hold nested dataclass instances, as opposed +to plain scalar/array leaf values) is done by inspecting instance *values* +with `is_dataclass_instance()` when building a tree (mirrors the field-walk ++ `isinstance` pattern `flopy4.dimensions` already established), and by inspecting field *type annotations* when reconstructing one from a tree (a bare `DataTree` node carries no back-pointer to the field it came from, so reconstruction has no values to inspect yet). @@ -27,22 +27,18 @@ field name, unlike "list"-kind's positional `f"{field_name}{index}"` convention. Namefile binding rows carry this information separately; reconstruction should use that rather than guessing here. Likewise, a -"list"-kind field whose element type is itself a `Union` of attrs -classes (e.g. `list[Union[Chd, Chdg]]`) isn't resolved to a concrete -arm. +"list"-kind field whose element type is itself a `Union` of dataclasses +(e.g. `list[Union[Chd, Chdg]]`) isn't resolved to a concrete arm. """ import types from typing import Any, Union, get_args, get_origin -import attrs import numpy as np import xarray as xr +from pydantic.dataclasses import is_pydantic_dataclass - -def _is_attrs_instance(value: Any) -> bool: - return attrs.has(type(value)) - +from flopy4.spec import is_dataclass_instance # Field names to always skip, regardless of what they hold. `dims` # (Component.dims) is a plain dict of already-resolved dimension sizes, @@ -58,37 +54,48 @@ def _is_attrs_instance(value: Any) -> bool: def _leaf_fields_and_children( obj, ) -> "tuple[dict[str, tuple], dict[str, Any], dict[str, dict | list]]": - """Split `obj`'s attrs fields by instance value into: - - leaf fields: ``{name: (attrs.Attribute, value)}`` + """Split `obj`'s pydantic fields by instance value into: + - leaf fields: ``{name: (FieldInfo, value)}`` - single-child fields: ``{name: child_obj}`` - collection-child fields: ``{name: {key: child_obj}}`` or ``{name: [child_obj, ...]}`` """ leaves: "dict[str, tuple]" = {} single_children: "dict[str, Any]" = {} collection_children: "dict[str, dict | list]" = {} - for field in attrs.fields(type(obj)): - if field.name in _RESERVED_FIELD_NAMES: + for name, finfo in type(obj).__pydantic_fields__.items(): + if name in _RESERVED_FIELD_NAMES: continue - value = getattr(obj, field.name, None) + # A private field (leading underscore) exposed under an alias -- + # e.g. Context._workspace/alias="workspace" -- is keyed by that + # alias here, not its real (private) name, matching the public API + # the field is actually meant to be read/written through (same + # convention structure.py/unstructure.py already use). + exposed = finfo.alias if (finfo.alias and name.startswith("_")) else name + value = getattr(obj, name, None) if value is None: continue - if _is_attrs_instance(value): - single_children[field.name] = value + if is_dataclass_instance(value): + single_children[exposed] = value elif ( - isinstance(value, dict) and value and all(_is_attrs_instance(v) for v in value.values()) + isinstance(value, dict) + and value + and all(is_dataclass_instance(v) for v in value.values()) ): - collection_children[field.name] = value + collection_children[exposed] = value elif ( - isinstance(value, (list, tuple)) and value and all(_is_attrs_instance(v) for v in value) + isinstance(value, (list, tuple)) + and value + and all(is_dataclass_instance(v) for v in value) ): - collection_children[field.name] = list(value) + collection_children[exposed] = list(value) else: - leaves[field.name] = (field, value) + leaves[exposed] = (finfo, value) return leaves, single_children, collection_children -def _array_dims(field: attrs.Attribute, name: str, ndim: int) -> tuple: - shape_meta = field.metadata.get("shape") +def _array_dims(finfo: Any, name: str, ndim: int) -> tuple: + meta = finfo.json_schema_extra or {} + shape_meta = meta.get("shape") if isinstance(meta, dict) else None if shape_meta and len(shape_meta) == ndim: return tuple(shape_meta) return tuple(f"{name}_dim{i}" for i in range(ndim)) @@ -97,29 +104,31 @@ def _array_dims(field: attrs.Attribute, name: str, ndim: int) -> tuple: def attrs_to_dataset(obj) -> xr.Dataset: """Flatten `obj`'s own scalar/array fields into a flat `xr.Dataset`. - Attrs-typed child fields are skipped here -- see `attrs_to_datatree()` - for those. A `numpy.ndarray`-valued field becomes a data variable, - with dims named from its `shape` metadata when present (falling back - to generic per-axis names otherwise); everything else becomes a - dataset-level attr. + Dataclass-typed child fields are skipped here -- see + `attrs_to_datatree()` for those. A `numpy.ndarray`-valued field becomes + a data variable, with dims named from its `shape` metadata when + present (falling back to generic per-axis names otherwise); everything + else becomes a dataset-level attr. """ leaves, _, _ = _leaf_fields_and_children(obj) data_vars = {} ds_attrs = {} - for name, (field, value) in leaves.items(): + for name, (finfo, value) in leaves.items(): + meta = finfo.json_schema_extra or {} + has_shape = isinstance(meta, dict) and meta.get("shape") if isinstance(value, xr.DataArray): data_vars[name] = value elif isinstance(value, np.ndarray): - data_vars[name] = xr.DataArray(value, dims=_array_dims(field, name, value.ndim)) - elif field.metadata.get("shape") and isinstance(value, (list, tuple)): + data_vars[name] = xr.DataArray(value, dims=_array_dims(finfo, name, value.ndim)) + elif has_shape and isinstance(value, (list, tuple)): # A shape-tagged field whose value hasn't (yet) been coerced to # a real ndarray -- e.g. assigned directly post-construction, - # bypassing whatever coercion __attrs_post_init__ normally does. - # The field's own metadata says it's array-shaped regardless of - # the value's current runtime type, so honor that rather than + # bypassing whatever coercion __post_init__ normally does. The + # field's own metadata says it's array-shaped regardless of the + # value's current runtime type, so honor that rather than # silently dropping it to a dataset-level attr. arr = np.asarray(value) - data_vars[name] = xr.DataArray(arr, dims=_array_dims(field, name, arr.ndim)) + data_vars[name] = xr.DataArray(arr, dims=_array_dims(finfo, name, arr.ndim)) else: ds_attrs[name] = value return xr.Dataset(data_vars, attrs=ds_attrs) @@ -127,9 +136,18 @@ def attrs_to_dataset(obj) -> xr.Dataset: def _init_field_names(cls: type) -> set: # init=False fields (e.g. Dis's derived nodes/ncpl/nvert) have no - # __init__ parameter -- they're recomputed by __attrs_post_init__, not - # round-tripped through the constructor. - return {f.name for f in attrs.fields(cls) if f.init is not False} + # __init__ parameter -- they're recomputed by __post_init__, not + # round-tripped through the constructor. Exposed under each field's + # alias when it has one and is itself private (leading underscore), + # matching _leaf_fields_and_children's own exposed-name convention -- + # a dataset produced from Context.workspace (backed by the private + # _workspace/alias="workspace" field) is keyed "workspace", so lookups + # here must match that key, not the private real name. + return { + (f.alias if (f.alias and name.startswith("_")) else name) + for name, f in cls.__pydantic_fields__.items() + if f.init is not False + } def _leaf_kwargs_from_dataset(cls: type, dataset: xr.Dataset) -> dict: @@ -158,9 +176,9 @@ def attrs_to_datatree(obj, _ancestors: frozenset = frozenset()) -> xr.DataTree: """Recursively convert `obj` into an `xr.DataTree`. `obj`'s own leaf fields become the root dataset (`attrs_to_dataset()`). - Each attrs-typed child field becomes a named child node (recursively - converted the same way); a dict- or list-of-children field expands to - one child node per entry, named by its dict key or + Each dataclass-typed child field becomes a named child node + (recursively converted the same way); a dict- or list-of-children + field expands to one child node per entry, named by its dict key or ``f"{field_name}{index}"`` respectively. A back-reference field pointing back up the tree (e.g. `Component`'s @@ -195,63 +213,66 @@ def _unwrap_optional(tp): return tp -def _child_field_spec(field: attrs.Attribute) -> "tuple[str, type] | None": - """If `field`'s declared type holds attrs-typed child/children, return - ``(kind, element_type)`` where `kind` is ``"one"``, ``"list"``, or - ``"dict"``. Returns `None` for plain scalar/array fields, an - unresolvable (string/forward-ref) annotation, or a collection whose - element type isn't a plain attrs class (e.g. a `Union` of arms -- - see module docstring). +def _child_field_spec(finfo: Any) -> "tuple[str, type] | None": + """If `finfo`'s declared type holds dataclass-typed child/children, + return ``(kind, element_type)`` where `kind` is ``"one"``, ``"list"``, + or ``"dict"``. Returns `None` for plain scalar/array fields, an + unresolvable annotation, or a collection whose element type isn't a + plain dataclass (e.g. a `Union` of arms -- see module docstring). """ - tp = field.type - if tp is None or isinstance(tp, str): + tp = finfo.annotation + if tp is None: return None tp = _unwrap_optional(tp) origin = get_origin(tp) if origin is None: - return ("one", tp) if attrs.has(tp) else None + return ("one", tp) if isinstance(tp, type) and _is_pydantic_type(tp) else None args = get_args(tp) - if origin in (list, tuple) and len(args) >= 1 and attrs.has(args[0]): + if origin in (list, tuple) and len(args) >= 1 and _is_pydantic_type(args[0]): return ("list", args[0]) - if origin is dict and len(args) == 2 and attrs.has(args[1]): + if origin is dict and len(args) == 2 and _is_pydantic_type(args[1]): return ("dict", args[1]) return None -def child_field_candidates(field: attrs.Attribute) -> "tuple[str, tuple[type, ...]] | None": +def _is_pydantic_type(tp: Any) -> bool: + return isinstance(tp, type) and is_pydantic_dataclass(tp) + + +def child_field_candidates(finfo: Any) -> "tuple[str, tuple[type, ...]] | None": """Like `_child_field_spec`, but resolves *every* concrete - attrs-decorated candidate class for the field, including each arm of - a `Union` of attrs classes in the collection-element (or bare "only") + dataclass-decorated candidate class for the field, including each arm + of a `Union` of dataclasses in the collection-element (or bare "only") position -- e.g. `list[Union[Chd, Chdg]]`, needed to disambiguate an MF6 base/grid-array package pair sharing one namefile ftype (see `converter/binding.py`'s `component_ftype()`). Returns `None` under the same conditions as `_child_field_spec`: an unresolvable annotation, or a type/collection-element that resolves to no - attrs-decorated candidate at all. + dataclass-decorated candidate at all. Kind is `"only"`, `"list"`, or `"dict"`, matching the child-collection vocabulary used throughout `flopy4/mf6/converter/`. """ - tp = field.type - if tp is None or isinstance(tp, str): + tp = finfo.annotation + if tp is None: return None tp = _unwrap_optional(tp) origin = get_origin(tp) if origin in (Union, types.UnionType): # Optional[Union[A, B]] -- _unwrap_optional only collapses a # single non-None arm, so a genuine multi-arm Union survives here. - candidates = tuple(a for a in get_args(tp) if a is not type(None) and attrs.has(a)) + candidates = tuple(a for a in get_args(tp) if a is not type(None) and _is_pydantic_type(a)) return ("only", candidates) if candidates else None if origin is None: - return ("only", (tp,)) if attrs.has(tp) else None + return ("only", (tp,)) if _is_pydantic_type(tp) else None args = get_args(tp) if origin in (list, tuple) and len(args) >= 1: elem = args[0] if get_origin(elem) in (Union, types.UnionType): - candidates = tuple(a for a in get_args(elem) if attrs.has(a)) + candidates = tuple(a for a in get_args(elem) if _is_pydantic_type(a)) return ("list", candidates) if candidates else None - return ("list", (elem,)) if attrs.has(elem) else None - if origin is dict and len(args) == 2 and attrs.has(args[1]): + return ("list", (elem,)) if _is_pydantic_type(elem) else None + if origin is dict and len(args) == 2 and _is_pydantic_type(args[1]): return ("dict", (args[1],)) return None @@ -261,30 +282,30 @@ def datatree_to_attrs(cls: type, tree: xr.DataTree): `attrs_to_datatree()`. The root dataset supplies `cls`'s own scalar/array field kwargs (see - `dataset_to_attrs()`). Each attrs-typed child field is matched to + `dataset_to_attrs()`). Each dataclass-typed child field is matched to child node(s) by name and recursively reconstructed against the field's own declared element type. See the module docstring for the "dict"-kind and `Union`-element limitations. """ kwargs = _leaf_kwargs_from_dataset(cls, tree.dataset) - for field in attrs.fields(cls): - if field.init is False: + for name, finfo in cls.__pydantic_fields__.items(): + if finfo.init is False: continue - spec = _child_field_spec(field) + spec = _child_field_spec(finfo) if spec is None: continue kind, elem_type = spec if kind == "one": - if field.name in tree.children: - kwargs[field.name] = datatree_to_attrs(elem_type, tree.children[field.name]) + if name in tree.children: + kwargs[name] = datatree_to_attrs(elem_type, tree.children[name]) elif kind == "list": items = [] i = 0 - while f"{field.name}{i}" in tree.children: - items.append(datatree_to_attrs(elem_type, tree.children[f"{field.name}{i}"])) + while f"{name}{i}" in tree.children: + items.append(datatree_to_attrs(elem_type, tree.children[f"{name}{i}"])) i += 1 if items: - kwargs[field.name] = items + kwargs[name] = items # "dict"-kind: not reconstructable from node name alone -- see # module docstring. Left unset; a caller with namefile binding # rows can fill it in separately. diff --git a/flopy4/dimensions.py b/flopy4/dimensions.py index c055d6b4..d0c468b9 100644 --- a/flopy4/dimensions.py +++ b/flopy4/dimensions.py @@ -2,7 +2,7 @@ from typing import Protocol, runtime_checkable -import attrs +from pydantic.dataclasses import is_pydantic_dataclass @runtime_checkable @@ -109,9 +109,9 @@ def _dimension_cache(self) -> dict: self.__dict__["_dimension_cache"] = {} return self.__dict__["_dimension_cache"] - def __attrs_post_init__(self) -> None: - if hasattr(super(), "__attrs_post_init__"): - super().__attrs_post_init__() # type: ignore[misc] + def __post_init__(self) -> None: + if hasattr(super(), "__post_init__"): + super().__post_init__() # type: ignore[misc] def resolve_dims(self, *dims: str) -> dict[str, int]: """ @@ -191,19 +191,19 @@ def _find_dimension_in_children(self, dim_name: str) -> int | None: def _walk_providers(self): """Yield (source_label, dims_dict) for each DimensionProvider in child fields.""" - for field_obj in attrs.fields(type(self)): # type: ignore[arg-type] - if (value := getattr(self, field_obj.name, None)) is None: + for name in type(self).__pydantic_fields__: # type: ignore[attr-defined] + if (value := getattr(self, name, None)) is None: continue if isinstance(value, DimensionProvider): - yield field_obj.name, value.get_dims() + yield name, value.get_dims() elif isinstance(value, dict): for child_key, child in value.items(): if isinstance(child, DimensionProvider): - yield f"{field_obj.name}[{child_key}]", child.get_dims() + yield f"{name}[{child_key}]", child.get_dims() elif isinstance(value, list): for idx, child in enumerate(value): if isinstance(child, DimensionProvider): - yield f"{field_obj.name}[{idx}]", child.get_dims() + yield f"{name}[{idx}]", child.get_dims() def _get_all_dimensions(self) -> dict[str, int]: """Get all dimensions from children and parent. Children take precedence.""" @@ -271,10 +271,11 @@ def validate_dimension_resolution(component) -> list[str]: errors = [] # Check all array fields on this component - for field in attrs.fields(type(component)): + for name, finfo in type(component).__pydantic_fields__.items(): # Check if field has dimension metadata - if hasattr(field, "metadata") and field.metadata and "dims" in field.metadata: - dims_needed = field.metadata["dims"] + meta = finfo.json_schema_extra or {} + if isinstance(meta, dict) and "dims" in meta: + dims_needed = meta["dims"] # Check if this component has a parent and can resolve dimensions if hasattr(component, "_parent") and component._parent: if hasattr(component._parent, "resolve_dims"): @@ -282,40 +283,26 @@ def validate_dimension_resolution(component) -> list[str]: result = component._parent.resolve_dims(dim) if dim not in result: errors.append( - f"{type(component).__name__}.{field.name} needs dimension '{dim}' " + f"{type(component).__name__}.{name} needs dimension '{dim}' " f"but it's not available in parent hierarchy" ) # Recursively validate children - for field in attrs.fields(type(component)): - value = getattr(component, field.name, None) + for name in type(component).__pydantic_fields__: + value = getattr(component, name, None) if value is None: continue - # Check if child is a component with attrs fields - if hasattr(value, "__class__") and hasattr(attrs, "fields"): - try: - attrs.fields(type(value)) - # It's an attrs class, validate it - errors.extend(validate_dimension_resolution(value)) - except Exception: - # Not an attrs class, skip - pass + # Check if child is a pydantic dataclass instance + if is_pydantic_dataclass(type(value)): + errors.extend(validate_dimension_resolution(value)) elif isinstance(value, dict): for child in value.values(): - if hasattr(child, "__class__") and hasattr(attrs, "fields"): - try: - attrs.fields(type(child)) - errors.extend(validate_dimension_resolution(child)) - except Exception: - pass + if is_pydantic_dataclass(type(child)): + errors.extend(validate_dimension_resolution(child)) elif isinstance(value, list): for child in value: - if hasattr(child, "__class__") and hasattr(attrs, "fields"): - try: - attrs.fields(type(child)) - errors.extend(validate_dimension_resolution(child)) - except Exception: - pass + if is_pydantic_dataclass(type(child)): + errors.extend(validate_dimension_resolution(child)) return errors diff --git a/flopy4/mf6/_types.py b/flopy4/mf6/_types.py index 8eb97884..8d328654 100644 --- a/flopy4/mf6/_types.py +++ b/flopy4/mf6/_types.py @@ -2,7 +2,7 @@ from datetime import datetime from pathlib import Path -from typing import Protocol, TypeAlias, TypeVar +from typing import Protocol, TypeAlias, TypeVar, runtime_checkable import numpy as np @@ -15,6 +15,7 @@ unions support this natively).""" +@runtime_checkable class _ArrayLike(Protocol[_DT]): """Structural stand-in for "ndarray or duck array of this dtype". @@ -24,6 +25,16 @@ class _ArrayLike(Protocol[_DT]): Used for griddata and READARRAY period fields, which may be dask-backed (see `codec/writer/filters.py`'s `array2chunks`, which streams dask-backed arrays without materializing them). + + `@runtime_checkable` is required for pydantic (not needed under attrs, + which never validated this annotation at all): under + `arbitrary_types_allowed=True`, pydantic builds an `isinstance()`-based + validator for any type it doesn't otherwise understand, which requires + the protocol to support `isinstance()` at all -- confirmed empirically + that schema-building itself fails with a `SchemaError` (not even a + runtime `ValidationError`) without this decorator, even though the + generic type parameter (`_DT`) is itself ignored by the resulting + isinstance check either way, same as plain Python `Protocol` semantics. """ @property diff --git a/flopy4/mf6/adapters.py b/flopy4/mf6/adapters.py index 3a3bc0af..bf530a9f 100644 --- a/flopy4/mf6/adapters.py +++ b/flopy4/mf6/adapters.py @@ -3,7 +3,6 @@ from typing import Optional, Union, get_args, get_origin from warnings import warn -import attrs import numpy as np from flopy.datbase import DataInterface, DataListInterface, DataType from flopy.discretization.grid import Grid @@ -12,6 +11,7 @@ from flopy.mbase import ModelInterface from flopy.pakbase import PackageInterface from flopy.plot.plotutil import PlotUtilities +from pydantic.dataclasses import is_pydantic_dataclass from flopy4.attrs_xarray import attrs_to_dataset from flopy4.mf6.model import Model @@ -217,13 +217,13 @@ def __init__( self._time = modeltime self._dlist = list() - field_by_name = {f.name: f for f in attrs.fields(type(package))} + field_by_name = dict(type(package).__pydantic_fields__) for a, value in self._dataset.attrs.items(): field = field_by_name.get(a) if field is None or value is None: continue - leaf_type = _resolve_leaf_type(field.type) + leaf_type = _resolve_leaf_type(field.annotation) if leaf_type is None: continue d_fp3 = Flopy3Data( @@ -291,14 +291,13 @@ def has_stress_period_data(self): # Any other fill-forward (period) field (covers OC's own # _stress_period_data too, redundantly with the check above -- kept # as a generic fallback for any period field shape). - try: - for f in attrs.fields(type(self._package)): - if f.metadata.get("fill_forward"): - attr_name = f.alias if (f.alias and f.name.startswith("_")) else f.name + if is_pydantic_dataclass(type(self._package)): + for name, f in type(self._package).__pydantic_fields__.items(): + meta = f.json_schema_extra or {} + if isinstance(meta, dict) and meta.get("fill_forward"): + attr_name = f.alias if (f.alias and name.startswith("_")) else name if getattr(self._package, attr_name, None) is not None: return True - except attrs.exceptions.NotAnAttrsClassError: - pass return "nper" in self._dataset.dims def check(self, f=None, verbose=True, level=1, checktype=None): diff --git a/flopy4/mf6/codec/writer/filters.py b/flopy4/mf6/codec/writer/filters.py index 3a0f545f..e5cd6d11 100644 --- a/flopy4/mf6/codec/writer/filters.py +++ b/flopy4/mf6/codec/writer/filters.py @@ -1,8 +1,8 @@ +import dataclasses from collections.abc import Hashable, Mapping from io import StringIO from typing import Any, Literal -import attrs import numpy as np import xarray as xr from numpy.typing import NDArray @@ -267,7 +267,7 @@ def dataset2list(value: xr.Dataset): if name == "perioddata": val = value[name] val = val.item() if val.shape == () else val - yield attrs.astuple(val, recurse=True) # type: ignore + yield dataclasses.astuple(val) # type: ignore continue val = value[name] val = val.item() if val.shape == () else val diff --git a/flopy4/mf6/component.py b/flopy4/mf6/component.py index 53409fa3..94f49752 100644 --- a/flopy4/mf6/component.py +++ b/flopy4/mf6/component.py @@ -1,18 +1,30 @@ +import dataclasses from abc import ABC from collections.abc import MutableMapping from os import PathLike from pathlib import Path from typing import Any, ClassVar, Optional -import attrs -from attrs import fields +from pydantic import ConfigDict, Field, field_validator +from pydantic.dataclasses import dataclass, is_pydantic_dataclass from flopy4.dimensions import DimensionResolverMixin from flopy4.mf6.constants import MF6 -from flopy4.mf6.spec import field, fields_dict +from flopy4.mf6.spec import fields_dict from flopy4.mf6.write_context import WriteContext from flopy4.uio import IO, Loader, Writer +# Shared config for every Component/Package (sub)class -- one constant, +# repeated per class at its own `@dataclass(config=CFG, ...)` decoration +# site (config isn't inherited the way attrs' class-level settings are), +# same shape codegen already emits today (`@attrs.define(kw_only=True, +# slots=False)` on every generated class). +CFG = ConfigDict( + arbitrary_types_allowed=True, + validate_assignment=True, + extra="forbid", +) + FNAMES: "dict[str, type[Component]]" = {} """MF6 component name (e.g. 'gwf-dis') -> component class.""" @@ -106,33 +118,34 @@ def _find_child_field(parent_cls: type, child_cls: type) -> "tuple[Any, str] | N """Find the single field on `parent_cls` that accepts `child_cls` as a child, by type annotation (`child_field_candidates()`). - Returns `(field, kind)`, or `None` if no field matches. Raises + Returns `(finfo, kind)`, or `None` if no field matches. Raises `TypeError` if more than one field matches (ambiguous). """ from flopy4.attrs_xarray import child_field_candidates matches = [] - for f in fields(parent_cls): # type: ignore[arg-type] - spec = child_field_candidates(f) + for name, finfo in parent_cls.__pydantic_fields__.items(): + spec = child_field_candidates(finfo) if spec is None: continue kind, candidates = spec if any(issubclass(child_cls, c) for c in candidates): - matches.append((f, kind)) + matches.append((name, finfo, kind)) if not matches: return None if len(matches) > 1: - names = ", ".join(f.name for f, _ in matches) + names = ", ".join(name for name, _, _ in matches) raise TypeError( f"Class '{parent_cls.__name__}' has multiple fields of type " f"'{child_cls.__name__}' ({names}); can't bind." ) - return matches[0] + name, _finfo, kind = matches[0] + return name, kind # kw_only=True necessary so we can define optional fields here -# and required fields in subclasses. attrs complains otherwise -@attrs.define(kw_only=True, slots=False) +# and required fields in subclasses. +@dataclass(config=CFG, kw_only=True) class Component(DimensionResolverMixin, ABC, MutableMapping): """ Base class for MF6 components. @@ -150,41 +163,83 @@ class Component(DimensionResolverMixin, ABC, MutableMapping): _load = IO(Loader) # type: ignore _write = IO(Writer) # type: ignore - filename: str | None = field(default=None) + filename: Optional[str] = Field(default=None) """The name of the component's input file.""" - name: str = field( - default=attrs.Factory(lambda self: type(self).__name__.lower(), takes_self=True) - ) - """The component's own identity/tag name. Computed per-instance from - the *actual* runtime class (`takes_self=True`), not whichever class in - the hierarchy happens to declare this field -- so a `Package` leaf - (e.g. `Ic`, never separately subclassed for this field) still gets - "ic", not "package". Overridden explicitly by `_resolve_child_name()`/ - `_attach_to_parent_field()` when a component is attached as a named - child; otherwise this default stands.""" - - _parent: Any = field(default=None, repr=False, eq=False) + name: Optional[str] = Field(default=None) + """The component's own identity/tag name. `None` until `__post_init__` + defaults it to the *actual* runtime class's lowercased name -- not + whichever class in the hierarchy happens to declare this field -- so a + `Package` leaf (e.g. `Ic`, never separately subclassed for this field) + still gets "ic", not "package". (Pydantic's `default_factory` takes no + arguments, unlike attrs' `takes_self=True`, so this can't be a + declarative field default the way it was under attrs -- filled in + explicitly in `__post_init__` instead.) Overridden explicitly by + `_resolve_child_name()`/`_attach_to_parent_field()` when a component is + attached as a named child; otherwise this default stands.""" + + _parent: Any = Field(default=None, alias="parent", repr=False) """Parent back-reference -- source of truth for "who is this component's parent", top-down (`Gwf(dis=Dis(...))`) and bottom-up - (`Dis(parent=gwf)`) alike. Leading underscore triggers attrs' private- - attribute convention, so the constructor keyword stays `parent=` even - though the field is `_parent`. Typed `Any` so `child_field_candidates()` - (type-annotation based) doesn't mistake it for a real child field. + (`Dis(parent=gwf)`) alike. Leading underscore + `alias="parent"` + mirrors attrs' private-attribute convention: the constructor keyword + stays `parent=` even though the field is `_parent`. Typed `Any` so + `child_field_candidates()` (type-annotation based) doesn't mistake it + for a real child field, and excluded from `__eq__` below (a live + `.parent` would otherwise recurse: comparing a component's parent + compares the parent's own children, including this component again). Populated by `_set_child_parents()` (top-down) and `_attach_to_parent_field()` (bottom-up), and kept current by - `parent`'s setter below. Excluded by name from `to_dict()`'s - `attrs.asdict()` recursion (alongside the unrelated `Output.parent`) - since a live `.parent` would otherwise be a reference cycle. + `parent`'s setter below. Excluded by name from `to_dict()`'s recursion + (alongside the unrelated `Output.parent`) since a live `.parent` would + otherwise be a reference cycle. """ - dims: dict = field(default=attrs.Factory(dict), repr=False, eq=False) + dims: dict = Field(default_factory=dict, repr=False) """Accepts `dims=` at construction (e.g. `Ic(dims={"nodes": 900})`) for API-compatibility with existing call sites. Read directly via - `self.__dict__.get("dims")` by `Package.__attrs_post_init__` for - griddata broadcasting -- not resolved/consumed by anything at the - `Component` level itself.""" + `self.__dict__.get("dims")` by `Package.__post_init__` for griddata + broadcasting -- not resolved/consumed by anything at the `Component` + level itself. Excluded from `__eq__` below for the same reason `attrs` + excluded it: two components differing only in already-resolved + dimension bookkeeping should still compare equal.""" + + @field_validator("*", mode="before") + @classmethod + def _apply_converter(cls, v: Any, info) -> Any: + """The one shared hook every field-level `converter=` (attrs + original) funnels through -- `flopy4.mf6.spec.field()`/`path()` + stash the callable in `json_schema_extra["converter"]` instead of + a per-field attrs `converter=`, and this single validator (not one + per field, not one per generated class) applies it uniformly.""" + finfo = cls.__pydantic_fields__.get(info.field_name) + if finfo is None or v is None: + return v + meta = finfo.json_schema_extra or {} + conv = meta.get("converter") if isinstance(meta, dict) else None + return conv(v) if conv is not None else v + + def __eq__(self, other: object) -> bool: + """Hand-written, replacing the dataclass-generated `__eq__` + (disabled below via `eq=False`... note: NOT disabled -- see + below): compares every field except `_parent`/`dims` (attrs' + `eq=False` on those two fields, ported -- see their own + docstrings for why). Defined once here, inherited by every + subclass unmodified: reads `dataclasses.fields(self)` + dynamically, so it naturally covers each subclass's own + additional fields too, without needing to be redeclared or + special-cased per generated class. + """ + if type(self) is not type(other): + return NotImplemented + skip = {"_parent", "dims"} + for f in dataclasses.fields(self): + if f.name in skip: + continue + if getattr(self, f.name) != getattr(other, f.name): + return False + return True @property def parent(self) -> "Component | None": @@ -249,11 +304,11 @@ def _children(self) -> "dict[str, Component]": self._set_child_parents() result: "dict[str, Component]" = {} - for f in fields(type(self)): - spec = child_field_candidates(f) + for name, finfo in type(self).__pydantic_fields__.items(): + spec = child_field_candidates(finfo) if spec is None: continue - value = getattr(self, f.name, None) + value = getattr(self, name, None) if value is None: continue kind, _ = spec @@ -283,11 +338,11 @@ def _set_child_parents(self) -> None: from flopy4.attrs_xarray import child_field_candidates used: "set[str]" = set() - for f in fields(type(self)): - spec = child_field_candidates(f) + for name, finfo in type(self).__pydantic_fields__.items(): + spec = child_field_candidates(finfo) if spec is None: continue - value = getattr(self, f.name, None) + value = getattr(self, name, None) if value is None: continue kind, _ = spec @@ -295,13 +350,13 @@ def _set_child_parents(self) -> None: if kind == "only": if isinstance(value, Component): value.__dict__["_parent"] = self - value.name = _resolve_child_name(used, kind, f.name, value) # type: ignore[attr-defined] + value.name = _resolve_child_name(used, kind, name, value) # type: ignore[attr-defined] used.add(value.name) # type: ignore[attr-defined] elif kind == "list": for child in value: if isinstance(child, Component): child.__dict__["_parent"] = self - child.name = _resolve_child_name(used, kind, f.name, child) # type: ignore[attr-defined] + child.name = _resolve_child_name(used, kind, name, child) # type: ignore[attr-defined] used.add(child.name) # type: ignore[attr-defined] elif kind == "dict": for key, child in value.items(): @@ -334,22 +389,25 @@ def default_filename(self) -> str: cls_name = self.__class__.__name__.lower() return f"{name}.{cls_name}" - def __attrs_post_init__(self): + def __post_init__(self): """ Post-initialization hook for all components. Chains to parent class post-init hooks (including DimensionRegistryMixin). - Also runs the two `_parent`-tracking hooks (see `_parent`'s - docstring): stamps `_parent` on this component's own already- - populated children (top-down construction), and -- if this - component's own `_parent` was given directly as `parent=`, i.e. - bottom-up construction -- attaches `self` into the matching field - on it and resolves its `.name` -- see `_attach_to_parent_field()`'s - docstring. + Defaults `.name` from the runtime class (attrs' `takes_self=True` + factory, ported -- see the field's own docstring), then runs the + two `_parent`-tracking hooks (see `_parent`'s docstring): stamps + `_parent` on this component's own already-populated children + (top-down construction), and -- if this component's own `_parent` + was given directly as `parent=`, i.e. bottom-up construction -- + attaches `self` into the matching field on it and resolves its + `.name` -- see `_attach_to_parent_field()`'s docstring. """ # Chain to parent classes (including DimensionRegistryMixin) - if hasattr(super(), "__attrs_post_init__"): - super().__attrs_post_init__() # type: ignore[misc] + if hasattr(super(), "__post_init__"): + super().__post_init__() # type: ignore[misc] + if self.name is None: + self.name = type(self).__name__.lower() if self._parent is not None: self._attach_to_parent_field(self._parent) self._set_child_parents() @@ -369,14 +427,14 @@ def _attach_to_parent_field(self, parent: "Component") -> None: match = _find_child_field(type(parent), type(self)) if match is None: return - target_field, kind = match + target_name, kind = match used = {c.name for c in parent._children.values()} # type: ignore[attr-defined] if kind == "only": - self.name = _resolve_child_name(used, kind, target_field.name, self) # type: ignore[attr-defined] - setattr(parent, target_field.name, self) + self.name = _resolve_child_name(used, kind, target_name, self) # type: ignore[attr-defined] + setattr(parent, target_name, self) elif kind == "list": - self.name = _resolve_child_name(used, kind, target_field.name, self) # type: ignore[attr-defined] - getattr(parent, target_field.name).append(self) + self.name = _resolve_child_name(used, kind, target_name, self) # type: ignore[attr-defined] + getattr(parent, target_name).append(self) elif kind == "dict": # No positional auto-key to fall back on for an unnamed child, # unlike "only"/"list" -- see `_set_child_parents`'s "dict" @@ -387,10 +445,10 @@ def _attach_to_parent_field(self, parent: "Component") -> None: raise ValueError( f"Child name '{key}' collides with an existing child on the same parent." ) - getattr(parent, target_field.name)[key] = self + getattr(parent, target_name)[key] = self - @classmethod - def __attrs_init_subclass__(cls): + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) # Only register classes that declare their own `dfn_name`. # Abstract bases (Package, Context, Model, Exchange, Solution, # DisBase, ...) have no `dfn_name` of their own and are silently @@ -418,17 +476,17 @@ def __setitem__(self, key, value): from flopy4.attrs_xarray import child_field_candidates - for f in fields(type(self)): - spec = child_field_candidates(f) + for name, finfo in type(self).__pydantic_fields__.items(): + spec = child_field_candidates(finfo) if spec is None: continue kind, _ = spec - current = getattr(self, f.name, None) + current = getattr(self, name, None) if kind == "only": if isinstance(current, Component) and current.name == key: # type: ignore[attr-defined] value.name = key # type: ignore[attr-defined] value.__dict__["_parent"] = self - setattr(self, f.name, value) + setattr(self, name, value) return elif kind == "list": for i, child in enumerate(current or []): @@ -447,30 +505,30 @@ def __setitem__(self, key, value): match = _find_child_field(type(self), type(value)) if match is None: raise TypeError(f"No field on {type(self).__name__} accepts a {type(value).__name__}") - target_field, kind = match + target_name, kind = match value.__dict__["_parent"] = self value.name = key # type: ignore[attr-defined] if kind == "only": - setattr(self, target_field.name, value) + setattr(self, target_name, value) elif kind == "list": - getattr(self, target_field.name).append(value) + getattr(self, target_name).append(value) elif kind == "dict": - getattr(self, target_field.name)[key] = value + getattr(self, target_name)[key] = value def __delitem__(self, key): """Detach the child named `key`, from whatever field/slot currently holds it.""" from flopy4.attrs_xarray import child_field_candidates - for f in fields(type(self)): - spec = child_field_candidates(f) + for name, finfo in type(self).__pydantic_fields__.items(): + spec = child_field_candidates(finfo) if spec is None: continue kind, _ = spec - value = getattr(self, f.name, None) + value = getattr(self, name, None) if kind == "only": if isinstance(value, Component) and value.name == key: # type: ignore[attr-defined] - setattr(self, f.name, None) + setattr(self, name, None) return elif kind == "list": for i, child in enumerate(value or []): @@ -527,6 +585,32 @@ def write(self, format: str = MF6, context: Optional[WriteContext] = None) -> No for child in self._children.values(): child.write(format=format, context=context) + def _asdict_filtered(self) -> dict[str, Any]: + """Recursive `dataclasses.asdict()`, excluding any field literally + named "parent" or "_parent" at every recursion level, not just + this component's own: e.g. `Gwf.Output.parent` is a genuine + back-reference to the owning `Gwf`, unrelated to `Component. + _parent`, but recursing into it the same way would infinitely + loop (output -> parent -> output -> ...). Ported from the attrs + original's `attrs.asdict(self, recurse=True, filter=...)` -- + pydantic/stdlib dataclasses' own `dataclasses.asdict()` has no + filter hook, so this walks by hand instead.""" + + def _convert(value: Any) -> Any: + if is_pydantic_dataclass(type(value)): + return { + name: _convert(getattr(value, name)) + for name in type(value).__pydantic_fields__ + if name not in ("parent", "_parent") + } + if isinstance(value, dict): + return {k: _convert(v) for k, v in value.items()} + if isinstance(value, (list, tuple)): + return type(value)(_convert(v) for v in value) + return value + + return _convert(self) + def to_dict(self, blocks: bool = False, strict: bool = False) -> dict[str, Any]: """ Convert the component to a dictionary representation. @@ -545,14 +629,7 @@ def to_dict(self, blocks: bool = False, strict: bool = False) -> dict[str, Any]: Dictionary containing component data, either in terms of fields (flat) or blocks (nested). """ - # Exclude any field literally named "parent" or "_parent" at every - # recursion level, not just this component's own: e.g. - # Gwf.Output.parent is a genuine back-reference to the owning Gwf, - # unrelated to Component._parent, but recursing into it the same - # way would infinitely loop (output -> parent -> output -> ...). - data = attrs.asdict( - self, recurse=True, filter=lambda attr, value: attr.name not in ("parent", "_parent") - ) + data = self._asdict_filtered() spec = fields_dict(self.__class__) if strict: @@ -561,9 +638,10 @@ def to_dict(self, blocks: bool = False, strict: bool = False) -> dict[str, Any]: if blocks: blocks_ = {} # type: ignore - for field_name, field_attr in spec.items(): + for field_name, finfo in spec.items(): field_value = data[field_name] - block_name = field_attr.metadata.get("block") + meta = finfo.json_schema_extra or {} + block_name = meta.get("block") if isinstance(meta, dict) else None if strict and block_name is None: continue if block_name not in blocks_: @@ -573,8 +651,8 @@ def to_dict(self, blocks: bool = False, strict: bool = False) -> dict[str, Any]: else: return { field_name: data[field_name] - for field_name, field_attr in spec.items() - if field_attr.metadata.get("block") or not strict + for field_name, finfo in spec.items() + if (finfo.json_schema_extra or {}).get("block") or not strict } def to_xarray(self): @@ -604,11 +682,12 @@ def _collect_child_griddata_datasets(self) -> dict: result: dict = {} try: for name, child in self._children.items(): - try: - _fields = attrs.fields(type(child)) - except attrs.exceptions.NotAnAttrsClassError: + if not is_pydantic_dataclass(type(child)): continue - if not any(f.metadata.get("block") == "griddata" for f in _fields): + _fields = type(child).__pydantic_fields__ + if not any( + (f.json_schema_extra or {}).get("block") == "griddata" for f in _fields.values() + ): continue try: ds = child.to_xarray() diff --git a/flopy4/mf6/context.py b/flopy4/mf6/context.py index d085dcb4..44d23f4b 100644 --- a/flopy4/mf6/context.py +++ b/flopy4/mf6/context.py @@ -1,45 +1,45 @@ from abc import ABC from pathlib import Path +from typing import Any, Optional -import attrs from modflow_devtools.misc import cd +from pydantic import Field +from pydantic.dataclasses import dataclass -from flopy4.mf6.component import Component +from flopy4.mf6.component import CFG, Component from flopy4.mf6.constants import MF6 -from flopy4.mf6.spec import field from flopy4.utils import to_path -def update_child_attr(instance, attribute, new_value): - """ - Generalized function to update child attribute (e.g. workspace). - - Args: - instance: The model instance - attribute: The attribute being set (from attrs on_setattr) - new_value: The new value being set - - Returns: - The new_value (unchanged) - """ - - for child in instance._children.values(): - if hasattr(child, attribute.name): - setattr(child, attribute.name, new_value) - - return new_value - - -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Context(Component, ABC): - workspace: Path = field(default=None, converter=to_path, on_setattr=update_child_attr) + # `_workspace`/`workspace` mirrors `Component._parent`/`.parent`'s + # private-field-plus-property pattern: pydantic has no per-field + # `on_setattr=` hook (attrs' `update_child_attr`, ported into the + # setter below), so the propagate-to-children side effect needs an + # explicit property instead of a declarative field option. + _workspace: Any = Field(default=None, alias="workspace", repr=False) - def __attrs_post_init__(self): - super().__attrs_post_init__() - # By the time this runs, `super().__attrs_post_init__()` - # (Component's) has already resolved `_parent`/`.parent` for both - # top-down and bottom-up construction (see `Component._parent`'s - # docstring). + @property + def workspace(self) -> Optional[Path]: + return self._workspace + + @workspace.setter + def workspace(self, value) -> None: + """Coerce `value` to a `Path` (attrs' `converter=to_path`, ported), + then propagate it to every child that has its own `workspace` + attribute (attrs' `on_setattr=update_child_attr`, ported).""" + value = to_path(value) + self._workspace = value + for child in self._children.values(): + if hasattr(child, "workspace"): + child.workspace = value + + def __post_init__(self): + super().__post_init__() + # By the time this runs, `super().__post_init__()` (Component's) + # has already resolved `_parent`/`.parent` for both top-down and + # bottom-up construction (see `Component._parent`'s docstring). if self.workspace is None: self.workspace = ( self._parent.workspace diff --git a/flopy4/mf6/converter/binding.py b/flopy4/mf6/converter/binding.py index ab67408e..9ec18cee 100644 --- a/flopy4/mf6/converter/binding.py +++ b/flopy4/mf6/converter/binding.py @@ -1,4 +1,5 @@ -from attrs import define +from pydantic import ConfigDict +from pydantic.dataclasses import dataclass from flopy4.mf6.component import Component from flopy4.mf6.exchange import Exchange @@ -19,7 +20,10 @@ def component_ftype(cls: type) -> str: return f"{cls_name.upper()}6" -@define +_CFG = ConfigDict(arbitrary_types_allowed=True, extra="forbid") + + +@dataclass(config=_CFG) class Binding: """A serializable representation of a component.""" diff --git a/flopy4/mf6/converter/egress/unstructure.py b/flopy4/mf6/converter/egress/unstructure.py index 7657b067..1a747142 100644 --- a/flopy4/mf6/converter/egress/unstructure.py +++ b/flopy4/mf6/converter/egress/unstructure.py @@ -1,11 +1,11 @@ from collections.abc import Iterable, Mapping from datetime import datetime from pathlib import Path -from typing import Any +from typing import Any, cast -import attrs import numpy as np import xarray as xr +from pydantic.fields import FieldInfo from flopy4.attrs_xarray import child_field_candidates from flopy4.mf6.component import Component @@ -18,14 +18,16 @@ from flopy4.mf6.spec import FileDirection, block_sort_key, blocks_dict, to_field_type -def _path_to_tuple(field: attrs.Attribute, value: Path) -> tuple[str, ...]: +def _path_to_tuple(name: str, field: FieldInfo, value: Path) -> tuple[str, ...]: """A block-level file record's ``KEYWORD FILEIN|FILEOUT `` row. The keyword is the field's own ``_keyword`` metadata (see spec.path).""" - keyword = field.metadata.get("_keyword") + meta = field.json_schema_extra or {} + assert isinstance(meta, dict) + keyword = meta.get("_keyword") if not keyword: - raise ValueError(f"file field {field.name!r} has no _keyword metadata") - direction: FileDirection | None = field.metadata.get("direction") - t = [keyword.upper()] + raise ValueError(f"file field {name!r} has no _keyword metadata") + direction = cast(FileDirection | None, meta.get("direction")) + t = [str(keyword).upper()] if direction: t.append("FILEOUT" if direction == "out" else "FILEIN") t.append(str(value)) @@ -38,13 +40,13 @@ def _make_binding_blocks(value: Component) -> dict[str, dict[str, list[tuple[str blocks = {} # type: ignore - for f in attrs.fields(type(value)): # type: ignore[arg-type] + for child_name, f in type(value).__pydantic_fields__.items(): if child_field_candidates(f) is None: continue - child_name = f.name if (child := getattr(value, child_name, None)) is None: continue - block_name = f.metadata.get("block") + meta = f.json_schema_extra or {} + block_name = meta.get("block") if isinstance(meta, dict) else None if block_name is None: continue if block_name not in blocks: @@ -129,9 +131,9 @@ def _unstructure_package(value: Package) -> dict[str, Any]: except ImportError: _DaskArray = type(None) # type: ignore[misc,assignment] - for f in attrs.fields(cls): # type: ignore[arg-type] - meta = f.metadata - block_name = meta.get("block") + for name, f in cls.__pydantic_fields__.items(): + meta = f.json_schema_extra or {} + block_name = meta.get("block") if isinstance(meta, dict) else None if not block_name: continue @@ -139,12 +141,12 @@ def _unstructure_package(value: Package) -> dict[str, Any]: write_if_empty_set.add(block_name) blocks.setdefault(block_name, {}) - attr_name = f.alias if (f.alias and f.name.startswith("_")) else f.name + attr_name = f.alias if (f.alias and name.startswith("_")) else name field_value = getattr(value, attr_name, None) if field_value is None: continue - dfn_type = to_field_type(f.type) + dfn_type = to_field_type(f.annotation) # fill-forward block if meta.get("fill_forward"): @@ -154,7 +156,7 @@ def _unstructure_package(value: Package) -> dict[str, Any]: is_layered = meta.get("layered", False) nper = field_value.shape[0] # aux field: shape (nper, ncpl, naux) - if f.name == "aux" and field_value.ndim == 3: + if name == "aux" and field_value.ndim == 3: aux_names: list[str] = list(getattr(value, "auxiliary", None) or []) naux = field_value.shape[2] for kper in range(nper): @@ -170,7 +172,7 @@ def _unstructure_package(value: Package) -> dict[str, Any]: da = xr.DataArray(layer_slice, dims=("nlay",) + extra_dims) else: da = xr.DataArray(layer_slice) - readarray_period.setdefault(kper, {})[f.name] = da + readarray_period.setdefault(kper, {})[name] = da continue # list: dict[int, list[Item]] if not isinstance(field_value, dict): @@ -188,7 +190,7 @@ def _unstructure_package(value: Package) -> dict[str, Any]: continue if isinstance(field_value, dict): - array_key = f.name if meta.get("tagged") else "" + array_key = name if meta.get("tagged") else "" for tval, arr in field_value.items(): blocks[f"{block_name} {tval}"] = {array_key: _wrap_array(arr)} continue @@ -199,17 +201,17 @@ def _unstructure_package(value: Package) -> dict[str, Any]: if dfn_type == "keyword": if field_value: - blocks[block_name][f.name] = field_value + blocks[block_name][name] = field_value elif meta.get("direction") and isinstance(field_value, Path): - t = _path_to_tuple(f, field_value) + t = _path_to_tuple(name, f, field_value) blocks[block_name][t[0].lower()] = t elif isinstance(field_value, list) and field_value and isinstance(field_value[0], Item): - blocks[block_name][f.name] = _rows_to_tuples(field_value) + blocks[block_name][name] = _rows_to_tuples(field_value) elif isinstance(field_value, list) and field_value and isinstance(field_value[0], tuple): - blocks[block_name][f.name] = field_value + blocks[block_name][name] = field_value elif meta.get("shape") and not isinstance(field_value, bool): if meta["shape"]: @@ -225,26 +227,26 @@ def _unstructure_package(value: Package) -> dict[str, Any]: _nlay = _dims_d.get("nlay", 0) _ncpl = _dims_d.get("ncpl", 0) if _nlay > 1 and _ncpl > 0 and field_value.size == _nlay * _ncpl: - blocks[block_name][f.name] = xr.DataArray( + blocks[block_name][name] = xr.DataArray( field_value.reshape(_nlay, _ncpl), dims=("nlay", "ncpl"), ) continue - blocks[block_name][f.name] = _wrap_array(field_value) + blocks[block_name][name] = _wrap_array(field_value) - elif f.name == "auxiliary" and isinstance(field_value, list): - blocks[block_name][f.name] = ("AUXILIARY",) + tuple(field_value) + elif name == "auxiliary" and isinstance(field_value, list): + blocks[block_name][name] = ("AUXILIARY",) + tuple(field_value) elif isinstance(field_value, Record): - blocks[block_name][f.name] = field_value.to_tokens() + blocks[block_name][name] = field_value.to_tokens() elif dfn_type in ("integer", "double", "double precision"): if field_value == 0 and meta.get("auto_from"): continue - blocks[block_name][f.name] = field_value + blocks[block_name][name] = field_value elif dfn_type == "string" and field_value: - blocks[block_name][f.name] = field_value + blocks[block_name][name] = field_value # `maxbound` is a computed property on some classes, not a real field if isinstance(getattr(cls, "maxbound", None), property): @@ -283,10 +285,10 @@ def unstructure_component(value: Component) -> dict[str, Any]: def _unstructure_component(value: Component) -> dict[str, Any]: """Unstructure an internal-node component (Gwf, Simulation, etc.) with - attrs-typed child fields, including its child binding blocks.""" + pydantic-typed child fields, including its child binding blocks.""" blockspec = blocks_dict(type(value)) blocks: dict[str, dict[str, Any]] = {} - fields_by_name = {f.name: f for f in attrs.fields(type(value))} # type: ignore[arg-type] + fields_by_name = dict(type(value).__pydantic_fields__) # create child component binding blocks blocks.update(_make_binding_blocks(value)) @@ -298,11 +300,13 @@ def _unstructure_component(value: Component) -> dict[str, Any]: for field_name in block.keys(): # Skip child components already processed as bindings field = fields_by_name.get(field_name) + field_meta = (field.json_schema_extra or {}) if field is not None else {} if ( isinstance(value, Context) and field is not None and child_field_candidates(field) is not None - and field.metadata.get("block") == block_name + and isinstance(field_meta, dict) + and field_meta.get("block") == block_name ): continue @@ -322,7 +326,7 @@ def _unstructure_component(value: Component) -> dict[str, Any]: blocks[block_name][field_name] = field_value case Path(): assert field is not None # field_name comes from blocks_dict(type(value)) - t = _path_to_tuple(field, field_value) + t = _path_to_tuple(field_name, field, field_value) blocks[block_name][t[0]] = t case datetime(): blocks[block_name][field_name] = field_value.isoformat() diff --git a/flopy4/mf6/converter/ingress/structure.py b/flopy4/mf6/converter/ingress/structure.py index 93bc1807..f19ad09d 100644 --- a/flopy4/mf6/converter/ingress/structure.py +++ b/flopy4/mf6/converter/ingress/structure.py @@ -3,7 +3,6 @@ from pathlib import Path from typing import Any, get_args -import attrs import numpy as np from flopy4.dimensions import DimensionProvider @@ -16,7 +15,7 @@ def _inner_class_type(field_type) -> type[Record] | None: - """If field_type is Optional[C] where C is an attrs inner-record class, return C.""" + """If field_type is Optional[C] where C is a pydantic inner-record class, return C.""" args = get_args(field_type) if not args: return None @@ -213,7 +212,7 @@ def _griddata_flat_length(f, dims: dict, default: int) -> int: back to `default` (the grid's total node count) when the field's declared shape dimension isn't resolvable from `dims`. """ - shape_meta = f.metadata.get("shape") + shape_meta = (f.json_schema_extra or {}).get("shape") if shape_meta: dim_name = shape_meta[-1] if isinstance(shape_meta, (tuple, list)) else shape_meta if dim_name in dims: @@ -245,11 +244,12 @@ def _parse_griddata_block( continue key = str(row[0]).lower() f = fields_by_name.get(key) - if f is None or f.metadata.get("block") != "griddata": + meta = (f.json_schema_extra or {}) if f is not None else {} + if f is None or meta.get("block") != "griddata": i += 1 continue - dtype = np.int64 if to_field_type(f.type) == "integer" else np.float64 + dtype = np.int64 if to_field_type(f.annotation) == "integer" else np.float64 layered = any(str(t).upper() == "LAYERED" for t in row[1:]) i += 1 @@ -287,13 +287,13 @@ def _parse_griddata_block( stacked = np.concatenate(layers).astype(dtype) if stacked.size < target and stacked.size and target % stacked.size == 0: stacked = np.tile(stacked, target // stacked.size) - result[f.name] = stacked + result[key] = stacked else: if i >= len(rows): break length = _griddata_flat_length(f, dims, nodes) value, i = _read_control_record(rows, i, workspace, dtype, length) - result[f.name] = value + result[key] = value return result @@ -366,10 +366,9 @@ def _parse_readarray_period_block( _, i = _read_control_record(rows, i, workspace, np.float64, ncpl) continue - is_int = to_field_type(f.type) == "integer" - is_layered = f.metadata.get("layered", False) or any( - str(t).upper() == "LAYERED" for t in row[1:] - ) + is_int = to_field_type(f.annotation) == "integer" + meta = f.json_schema_extra or {} + is_layered = meta.get("layered", False) or any(str(t).upper() == "LAYERED" for t in row[1:]) dtype = np.int64 if is_int else np.float64 if is_layered: @@ -383,12 +382,12 @@ def _parse_readarray_period_block( if layers: if len(layers) < nlay: layers = (layers * nlay)[:nlay] - result[f.name] = np.stack(layers) # (nlay, ncpl) + result[key] = np.stack(layers) # (nlay, ncpl) else: if i >= len(rows): break value, i = _read_control_record(rows, i, workspace, dtype, ncpl) - result[f.name] = value + result[key] = value return result @@ -454,15 +453,16 @@ def _resolve_bindings(cls: type, raw_lower: dict, workspace: Path) -> dict[str, model_prefix = cls.__name__.lower() if issubclass(cls, Model) else None fields_by_block: dict[str, list] = {} - for f in attrs.fields(cls): # type: ignore[arg-type] + for name, f in cls.__pydantic_fields__.items(): spec = child_field_candidates(f) if spec is None: continue - block_name = f.metadata.get("block") + meta = f.json_schema_extra or {} + block_name = meta.get("block") if isinstance(meta, dict) else None if block_name is None: continue kind, candidates = spec - fields_by_block.setdefault(block_name, []).append((f.name, kind, candidates)) + fields_by_block.setdefault(block_name, []).append((name, kind, candidates)) if not fields_by_block: return {} @@ -625,56 +625,74 @@ def structure_component( # so a block keyword can't shadow them (e.g. utl-tas's "NAME ..." row # is for time_series_name, not Package.name). all_fields = { - f.name: f for f in attrs.fields(cls) if f.init is not False and "block" in f.metadata + fname: f + for fname, f in cls.__pydantic_fields__.items() + if f.init is not False and "block" in (f.json_schema_extra or {}) } alias_map: dict[str, str] = {} # alias → name - for f in attrs.fields(cls): - if f.alias and f.alias != f.name: - alias_map[f.alias] = f.name + for fname, f in cls.__pydantic_fields__.items(): + if f.alias and f.alias != fname: + alias_map[f.alias] = fname # Block-level file records ("TS6 FILEIN ") are keyed by their # trigger keyword, the path field's _keyword metadata (see spec.path), # not by the field's py name (ts_file). - file_fields: dict[str, Any] = { - f.metadata["_keyword"]: f for f in all_fields.values() if f.metadata.get("_keyword") + file_fields: dict[str, tuple[str, Any]] = { + kw: (fname, f) + for fname, f in all_fields.items() + if (kw := (f.json_schema_extra or {}).get("_keyword")) } # Index Optional[InnerClass] fields by the inner class's _keyword (lowercase). # Covers options-block compound records like Npf.Cvoptions, Ims.Rclose, etc. + # + # NOTE: every field-iteration loop below in this function deliberately + # uses `fname`, never `name` -- this function's own `name` KEYWORD + # PARAMETER (the explicit override name, used at the very bottom) would + # otherwise be silently shadowed by the loop variable (a plain `for` + # doesn't get its own scope in Python, unlike a comprehension). Confirmed + # as a real bug by running the real test suite: every load-a-real-model + # test failed, with kwargs["name"] ending up as the *last field name + # iterated* (e.g. "output") instead of the caller's actual override. inner_class_fields: dict[str, tuple] = {} - for f in attrs.fields(cls): + for fname, f in cls.__pydantic_fields__.items(): if f.init is False: continue - inner_cls = _inner_class_type(f.type) + inner_cls = _inner_class_type(f.annotation) if inner_cls is None: continue kw = vars(inner_cls).get("_keyword", "") if kw: - inner_class_fields[kw.lower()] = (f, inner_cls) + inner_class_fields[kw.lower()] = (fname, f, inner_cls) # Identify Item-list fields (packagedata, connectiondata, partitions …) -- # the field's own type annotation (Optional[list[ItemClass]] or # Optional[dict[int, list[ItemClass]]]) is the schema. - block_item_fields: dict[str, tuple] = {} # block_name → (field, item_cls) + block_item_fields: dict[str, tuple] = {} # block_name → (name, field, item_cls) period_field = None # field for the period Item-list + period_field_name: "str | None" = None period_item_cls: "type[Item] | tuple[type[Item], ...] | None" = None # Fill-forward repeating blocks (period), per the fields' own # fill_forward metadata (from the DFN's BlockHeader.fill_forward). fill_forward_blocks = { - f.metadata["block"] for f in attrs.fields(cls) if f.metadata.get("fill_forward") + (f.json_schema_extra or {})["block"] + for f in cls.__pydantic_fields__.values() + if (f.json_schema_extra or {}).get("fill_forward") } - for f in attrs.fields(cls): - block = f.metadata.get("block", "") - item_cls = item_list_type(f.type) + for fname, f in cls.__pydantic_fields__.items(): + meta = f.json_schema_extra or {} + block = meta.get("block", "") if isinstance(meta, dict) else "" + item_cls = item_list_type(f.annotation) if item_cls is None: continue - if f.metadata.get("fill_forward"): + if meta.get("fill_forward"): period_field = f + period_field_name = fname period_item_cls = item_cls else: - block_item_fields[block] = (f, item_cls) + block_item_fields[block] = (fname, f, item_cls) # Array fields whose own block repeats per header value -- e.g. # utl-tas's tas_array, dict[float, ndarray]. Detected structurally from @@ -682,11 +700,13 @@ def structure_component( # way block_item_fields above is detected via item_list_type -- not via # a metadata flag. Mirrors egress/unstructure.py's write path. repeating_array_fields = { - f.name: f - for f in attrs.fields(cls) - if repeating_array_key_type(f.type) is not None and f.init is not False + fname: f + for fname, f in cls.__pydantic_fields__.items() + if repeating_array_key_type(f.annotation) is not None and f.init is not False + } + repeating_array_block_prefixes = { + (f.json_schema_extra or {})["block"] for f in repeating_array_fields.values() } - repeating_array_block_prefixes = {f.metadata["block"] for f in repeating_array_fields.values()} # ── Pass 1: scalar blocks (options, dimensions, etc.) ──────────────────── kwargs: dict[str, Any] = {} @@ -707,36 +727,89 @@ def structure_component( if (ff := file_fields.get(key)) is not None: # KEYWORD FILEIN|FILEOUT : the path follows the # keyword and the direction token. + ff_name, ff_f = ff tokens = row[1:] if tokens and str(tokens[0]).upper() in ("FILEIN", "FILEOUT"): tokens = tokens[1:] if tokens: - kwargs[ff.alias or ff.name] = Path(_strip_quotes(str(tokens[0]))) + kwargs[ff_f.alias or ff_name] = Path(_strip_quotes(str(tokens[0]))) continue - f = all_fields.get(key) or all_fields.get(alias_map.get(key, "")) + found_name = key if key in all_fields else alias_map.get(key) + f = all_fields.get(found_name) if found_name else None if f is None or f.init is False: if key in inner_class_fields: - cand_f, inner_cls = inner_class_fields[key] - cand_init = cand_f.alias if cand_f.alias else cand_f.name + cand_name, cand_f, inner_cls = inner_class_fields[key] + cand_init = cand_f.alias if cand_f.alias else cand_name kwargs[cand_init] = inner_cls.from_tokens(row) continue - init_key = f.alias if f.alias else f.name - # A Record-typed field must go through from_tokens(), even when - # the matched token is the field's own name rather than the - # record's separate trigger keyword (e.g. sfacrecord's outer - # field is itself named "sfac"). - inner_cls = _inner_class_type(f.type) - if inner_cls is not None: - kwargs[init_key] = inner_cls.from_tokens(row) + # A field whose OWN name happens to equal its inner Record's + # _keyword (e.g. Npf.rewet: Optional[Rewet], Rewet._keyword == + # "rewet") matches `all_fields` above before `inner_class_fields` + # (a fallback keyed by the row's *keyword*, not the field's own + # name) is ever consulted -- surfaced as a real bug by pydantic's + # real Optional[Rewet] validation (attrs applied none, so this + # field silently held a raw token list instead of a real Rewet + # instance): route it through the same from_tokens() path here + # too, rather than falling into the generic scalar/list branch + # below, which is wrong for any Record-typed field. + direct_inner_cls = _inner_class_type(f.annotation) + if direct_inner_cls is not None: + init_key = f.alias if f.alias else found_name + kwargs[init_key] = direct_inner_cls.from_tokens(row) continue - if len(row) == 1: + init_key = f.alias if f.alias else found_name + if len(row) == 1 or to_field_type(f.annotation) == "keyword": + # A bare bool/keyword flag -- true just from the keyword's + # presence on the row, regardless of trailing tokens. A + # compound record this keyword is also meant to trigger + # (e.g. Gwf's `newton`/`newtonoptions` pair) isn't resolved + # here if it isn't reachable via inner_class_fields (that + # needs the inner class's own _keyword ClassVar, a + # pre-existing gap unrelated to this migration) -- under + # attrs a trailing modifier token (e.g. "NEWTON + # UNDER_RELAXATION") silently overwrote this bool field with + # a raw string, unvalidated and never actually used; + # pydantic's real bool validation correctly rejects that, so + # this takes the keyword's own presence as the field's real + # (and only sound) signal instead. kwargs[init_key] = True else: # List-valued options (auxiliary, etc.) have shape metadata; - # always keep them as a list so __attrs_post_init__ can use len(). - is_list_opt = isinstance(f.metadata.get("shape"), tuple) + # always keep them as a list so __post_init__ can use len(). + # `auxiliary` itself doesn't actually carry shape= metadata + # in the real DFN corpus (confirmed empirically) despite + # being declared Optional[list[str]], so it's also matched + # by name directly -- otherwise a row naming exactly one + # aux variable (e.g. "AUXILIARY MULT", row length 2) fell + # through to the plain-scalar branch below and stored a + # bare string, which attrs tolerated (no validation on this + # field either) but pydantic's real list[str] check + # correctly rejects. + _f_meta = f.json_schema_extra or {} + is_list_opt = ( + isinstance(_f_meta, dict) and isinstance(_f_meta.get("shape"), tuple) + ) or found_name == "auxiliary" if is_list_opt: kwargs[init_key] = list(row[1:]) + elif to_field_type(f.annotation) in ( + "integer", + "double", + "double precision", + "string", + ): + # A genuine scalar field (int/float/str, not + # shape-tagged) with extra trailing tokens on its row -- + # seen in some older, migrated fixtures (e.g. IMS + # "OUTER_MAXIMUM 100 500" or "REORDERING_METHOD NONE + # RKM"), the second value belonging to a field the + # current DFN schema no longer declares. Under attrs + # this silently stored the whole raw list into a + # scalar-typed field, unvalidated and never actually + # used; pydantic's real validation correctly rejects a + # list there, so take just the first (real, + # current-schema) value instead of keeping stale extra + # tokens no longer part of the spec. + kwargs[init_key] = row[1] else: kwargs[init_key] = list(row[1:]) if len(row) > 2 else row[1] @@ -753,7 +826,7 @@ def structure_component( effective_dims = dims or _self_dims_from_kwargs(kwargs) # ── Pass 2: block Item-list fields (packagedata, partitions …) ────────── - for block_name, (f, item_cls) in block_item_fields.items(): + for block_name, (name, f, item_cls) in block_item_fields.items(): rows = raw_lower.get(block_name, []) if not rows: continue @@ -762,7 +835,7 @@ def structure_component( rows, item_cls, naux=naux, boundnames=boundnames, dims=effective_dims ) if row_list is not None: - init_key = f.alias if (f.alias and not f.alias.startswith("_")) else f.name + init_key = f.alias if (f.alias and not f.alias.startswith("_")) else name kwargs[init_key] = row_list # ── Pass 3: period blocks ──────────────────────────────────────────────── @@ -789,7 +862,7 @@ def structure_component( spd[kper] = row_list if spd: # Use the alias (stress_period_data) as the init kwarg - init_key = period_field.alias if period_field.alias else period_field.name + init_key = period_field.alias if period_field.alias else period_field_name kwargs[init_key] = spd else: @@ -800,11 +873,12 @@ def structure_component( # set above) and not dict-wrapped (else it'd be a # repeating_array_field instead, see below). ra_fields = { - f.name: f - for f in attrs.fields(cls) - if f.metadata.get("fill_forward") - and f.name not in repeating_array_fields - and to_field_type(f.type) in ("integer", "double") + name: f + for name, f in cls.__pydantic_fields__.items() + if isinstance(f.json_schema_extra, dict) + and f.json_schema_extra.get("fill_forward") + and name not in repeating_array_fields + and to_field_type(f.annotation) in ("integer", "double") and f.init is not False } if ra_fields and dims: @@ -816,7 +890,8 @@ def structure_component( # fill-forward semantics (egress skips all-FILL_DNODATA periods). accum: dict[str, np.ndarray] = {} for fname, f in ra_fields.items(): - shape = (nper, nlay, ncpl) if f.metadata.get("layered", False) else (nper, ncpl) + _meta = f.json_schema_extra or {} + shape = (nper, nlay, ncpl) if _meta.get("layered", False) else (nper, ncpl) accum[fname] = np.full(shape, FILL_DNODATA) for kper, rows in sorted(kper_rows.items()): if not rows: @@ -844,12 +919,12 @@ def structure_component( nlay = effective_dims.get("nlay", 1) ncpl = nodes // nlay if nlay > 1 else nodes for fname, f in repeating_array_fields.items(): - block = f.metadata["block"] - key_type = repeating_array_key_type(f.type) + block = (f.json_schema_extra or {})["block"] + key_type = repeating_array_key_type(f.annotation) series_rows = _group_repeating_rows(raw_lower, {block}, key_type).get(block) if not series_rows: continue - dtype = np.int64 if to_field_type(f.type) == "integer" else np.float64 + dtype = np.int64 if to_field_type(f.annotation) == "integer" else np.float64 length = _griddata_flat_length(f, effective_dims, ncpl) series: dict[Any, np.ndarray] = {} for header, rows in sorted(series_rows.items()): @@ -873,9 +948,11 @@ def structure_component( effective_dims = dims or _self_dims_from_kwargs(kwargs) if effective_dims: gd_fields = { - f.name: f - for f in attrs.fields(cls) - if f.metadata.get("block") == "griddata" and f.init is not False + name: f + for name, f in cls.__pydantic_fields__.items() + if isinstance(f.json_schema_extra, dict) + and f.json_schema_extra.get("block") == "griddata" + and f.init is not False } parsed = _parse_griddata_block(griddata_rows, gd_fields, effective_dims, workspace) kwargs.update(parsed) diff --git a/flopy4/mf6/ems.py b/flopy4/mf6/ems.py index ce44218b..5844473b 100644 --- a/flopy4/mf6/ems.py +++ b/flopy4/mf6/ems.py @@ -1,12 +1,12 @@ # autogenerated file, do not modify from typing import ClassVar -import attrs +from pydantic.dataclasses import dataclass -from flopy4.mf6.solution import Solution +from flopy4.mf6.solution import CFG, Solution -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ems(Solution): dfn_name: ClassVar[str] = "sln-ems" diff --git a/flopy4/mf6/exchange.py b/flopy4/mf6/exchange.py index 8c96554d..69efe04d 100644 --- a/flopy4/mf6/exchange.py +++ b/flopy4/mf6/exchange.py @@ -2,13 +2,13 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Exchange(Package, ABC): exgtype: Optional[type] = field(default=None) # type: ignore exgfile: Optional[Path] = field(default=None) # type: ignore @@ -19,14 +19,14 @@ def default_filename(self) -> str: return f"{self.name}.exg" # type: ignore -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class GwfGwt(Exchange): """GWF-GWT flow-transport exchange (declares coupling in mfsim.nam).""" dfn_name: ClassVar[str] = "exg-gwfgwt" -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class GwfGwe(Exchange): """GWF-GWE flow-energy exchange (declares coupling in mfsim.nam).""" diff --git a/flopy4/mf6/exg/gwfgwe.py b/flopy4/mf6/exg/gwfgwe.py index f1415b11..bb318939 100644 --- a/flopy4/mf6/exg/gwfgwe.py +++ b/flopy4/mf6/exg/gwfgwe.py @@ -1,11 +1,11 @@ # autogenerated file, do not modify from typing import ClassVar -import attrs +from pydantic.dataclasses import dataclass -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Gwfgwe(Package): dfn_name: ClassVar[str] = "exg-gwfgwe" diff --git a/flopy4/mf6/exg/gwfgwt.py b/flopy4/mf6/exg/gwfgwt.py index a4156ac6..7b8538b6 100644 --- a/flopy4/mf6/exg/gwfgwt.py +++ b/flopy4/mf6/exg/gwfgwt.py @@ -1,11 +1,11 @@ # autogenerated file, do not modify from typing import ClassVar -import attrs +from pydantic.dataclasses import dataclass -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Gwfgwt(Package): dfn_name: ClassVar[str] = "exg-gwfgwt" diff --git a/flopy4/mf6/exg/gwfprt.py b/flopy4/mf6/exg/gwfprt.py index d2de62e5..2b88f0d8 100644 --- a/flopy4/mf6/exg/gwfprt.py +++ b/flopy4/mf6/exg/gwfprt.py @@ -1,11 +1,11 @@ # autogenerated file, do not modify from typing import ClassVar -import attrs +from pydantic.dataclasses import dataclass -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Gwfprt(Package): dfn_name: ClassVar[str] = "exg-gwfprt" diff --git a/flopy4/mf6/gwe/__init__.py b/flopy4/mf6/gwe/__init__.py index 7f0ad7a6..99c9f54d 100644 --- a/flopy4/mf6/gwe/__init__.py +++ b/flopy4/mf6/gwe/__init__.py @@ -1,9 +1,9 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs from flopy.discretization.structuredgrid import StructuredGrid from flopy.discretization.vertexgrid import VertexGrid +from pydantic.dataclasses import dataclass from flopy4.mf6.gwe.adv import Adv from flopy4.mf6.gwe.cnd import Cnd @@ -18,7 +18,7 @@ from flopy4.mf6.gwe.oc import Oc from flopy4.mf6.gwe.ssm import Ssm from flopy4.mf6.gwf.disbase import DisBase -from flopy4.mf6.model import Model +from flopy4.mf6.model import CFG, Model from flopy4.mf6.spec import field, path from flopy4.utils import to_path @@ -50,7 +50,7 @@ def convert_grid(value): ] -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Gwe(Model): dfn_name: ClassVar[str] = "gwe-nam" @@ -86,9 +86,9 @@ class Gwe(Model): adv: Adv | None = field(block="packages", default=None) cnd: Cnd | None = field(block="packages", default=None) est: Est | None = field(block="packages", default=None) - ctp: list[Ctp] = field(block="packages", default=attrs.Factory(list)) - esl: list[Esl] = field(block="packages", default=attrs.Factory(list)) - lke: list[Lke] = field(block="packages", default=attrs.Factory(list)) + ctp: list[Ctp] = field(block="packages", default_factory=list) + esl: list[Esl] = field(block="packages", default_factory=list) + lke: list[Lke] = field(block="packages", default_factory=list) ssm: Ssm | None = field(block="packages", default=None) mve: Mve | None = field(block="packages", default=None) diff --git a/flopy4/mf6/gwe/adv.py b/flopy4/mf6/gwe/adv.py index 1b9ae233..ed74f2b8 100644 --- a/flopy4/mf6/gwe/adv.py +++ b/flopy4/mf6/gwe/adv.py @@ -1,13 +1,13 @@ # autogenerated file, do not modify from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Adv(Package): dfn_name: ClassVar[str] = "gwe-adv" diff --git a/flopy4/mf6/gwe/cnd.py b/flopy4/mf6/gwe/cnd.py index e5a04a1f..f4c8f1d9 100644 --- a/flopy4/mf6/gwe/cnd.py +++ b/flopy4/mf6/gwe/cnd.py @@ -1,14 +1,14 @@ # autogenerated file, do not modify from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Cnd(Package): dfn_name: ClassVar[str] = "gwe-cnd" diff --git a/flopy4/mf6/gwe/ctp.py b/flopy4/mf6/gwe/ctp.py index 97f1be9e..a4386466 100644 --- a/flopy4/mf6/gwe/ctp.py +++ b/flopy4/mf6/gwe/ctp.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ctp(Package): dfn_name: ClassVar[str] = "gwe-ctp" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) temp: Union[float, str] = field(time_series=True) @@ -74,7 +75,7 @@ class StressPeriodData(Item): direction="in", keyword="obs6", ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwe/dis.py b/flopy4/mf6/gwe/dis.py index 03146527..9660a3a4 100644 --- a/flopy4/mf6/gwe/dis.py +++ b/flopy4/mf6/gwe/dis.py @@ -1,18 +1,19 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs import numpy as np from numpy.typing import NDArray +from pydantic import Field +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path -from flopy4.mf6.gwf.disbase import DisBase +from flopy4.mf6.gwf.disbase import CFG, DisBase from flopy4.mf6.spec import field, path from flopy4.mf6.utils.grid import StructuredGrid from flopy4.mf6.utl.ncf import Ncf -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Dis(DisBase): dfn_name: ClassVar[str] = "gwe-dis" @@ -31,11 +32,11 @@ class Dis(DisBase): direction="in", keyword="ncf6", ) - ncf: Optional[Ncf] = attrs.field(default=None) + ncf: Optional[Ncf] = Field(default=None) nlay: int = field(default=1, block="dimensions") ncol: int = field(default=2, block="dimensions") nrow: int = field(default=2, block="dimensions") - delr: NDArray[np.float64] = field( + delr: Optional[NDArray[np.float64]] = field( default=1.0, longname="spacing along a row", block="griddata", @@ -43,7 +44,7 @@ class Dis(DisBase): layered=False, netcdf=True, ) - delc: NDArray[np.float64] = field( + delc: Optional[NDArray[np.float64]] = field( default=1.0, longname="spacing along a column", block="griddata", @@ -51,7 +52,7 @@ class Dis(DisBase): layered=False, netcdf=True, ) - top: NDArray[np.float64] = field( + top: Optional[NDArray[np.float64]] = field( default=1.0, longname="cell top elevation", block="griddata", @@ -59,7 +60,7 @@ class Dis(DisBase): layered=False, netcdf=True, ) - botm: NDArray[np.float64] = field( + botm: Optional[NDArray[np.float64]] = field( default=0.0, longname="cell bottom elevation", block="griddata", @@ -76,12 +77,12 @@ class Dis(DisBase): longname="idomain existence array", ) - def __attrs_post_init__(self): + def __post_init__(self): self.nodes = self.ncol * self.nrow * self.nlay self.ncpl = self.ncol * self.nrow self.nvert = (self.ncol + 1) * (self.nrow + 1) self._coerce_griddata() - super().__attrs_post_init__() + super().__post_init__() def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/gwe/disv.py b/flopy4/mf6/gwe/disv.py index 3fc3845c..a92fbfe0 100644 --- a/flopy4/mf6/gwe/disv.py +++ b/flopy4/mf6/gwe/disv.py @@ -1,31 +1,32 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs import numpy as np from numpy.typing import NDArray +from pydantic import Field, field_validator +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path -from flopy4.mf6.gwf.disbase import DisBase +from flopy4.mf6.gwf.disbase import CFG, DisBase from flopy4.mf6.item import Item from flopy4.mf6.spec import field, path from flopy4.mf6.utils.grid import VertexGrid from flopy4.mf6.utl.ncf import Ncf -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Disv(DisBase): dfn_name: ClassVar[str] = "gwe-disv" - @attrs.define(slots=False) + @dataclass(config=CFG) class Cell2dRecord: - icell2d: int = attrs.field() - xc: float = attrs.field() - yc: float = attrs.field() - ncvert: int = attrs.field() - icvert: tuple[int, ...] = attrs.field() + icell2d: int = Field() + xc: float = Field() + yc: float = Field() + ncvert: int = Field() + icvert: tuple[int, ...] = Field() - @attrs.define + @dataclass(config=CFG) class Vertices(Item): iv: int xv: float @@ -46,11 +47,11 @@ class Vertices(Item): direction="in", keyword="ncf6", ) - ncf: Optional[Ncf] = attrs.field(default=None) + ncf: Optional[Ncf] = Field(default=None) nlay: int = field(default=0, block="dimensions") ncpl: int = field(default=0, block="dimensions") nvert: int = field(default=0, block="dimensions") - top: NDArray[np.float64] = field( + top: Optional[NDArray[np.float64]] = field( default=None, longname="model top elevation", block="griddata", @@ -58,7 +59,7 @@ class Vertices(Item): layered=False, netcdf=True, ) - botm: NDArray[np.float64] = field( + botm: Optional[NDArray[np.float64]] = field( default=None, longname="model bottom elevation", block="griddata", @@ -74,14 +75,32 @@ class Vertices(Item): layered=True, netcdf=True, ) - iv: Optional[NDArray[np.int64]] = attrs.field(default=None) - xv: Optional[NDArray[np.float64]] = attrs.field(default=None) - yv: Optional[NDArray[np.float64]] = attrs.field(default=None) + iv: Optional[NDArray[np.int64]] = Field(default=None) + xv: Optional[NDArray[np.float64]] = Field(default=None) + yv: Optional[NDArray[np.float64]] = Field(default=None) + + # iv/xv/yv are declared NDArray-typed but commonly constructed from a + # plain list/tuple (see from_grid() below) -- unlike Package's own + # griddata fields, these carry no block="griddata"/shape= metadata, so + # Package._coerce_arrays' shape-driven check doesn't reach them. Same + # underlying gap as Tdis.perlen/nstp/tsmult: attrs never validated the + # declared NDArray type against an actual list default/override at + # all; pydantic does, so this needs its own small mode="before" fix. + @field_validator("iv", mode="before") + @classmethod + def _coerce_iv(cls, v): + return v if v is None or isinstance(v, np.ndarray) else np.asarray(v, dtype=np.int64) + + @field_validator("xv", "yv", mode="before") + @classmethod + def _coerce_xv_yv(cls, v): + return v if v is None or isinstance(v, np.ndarray) else np.asarray(v, dtype=np.float64) + vertices: Optional[list[Vertices]] = field(default=None, block="vertices") - cell2ddata: Optional[list] = attrs.field(default=None) + cell2ddata: Optional[list] = Field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") - def __attrs_post_init__(self): + def __post_init__(self): if self.iv is not None and (not isinstance(self.iv, np.ndarray)): object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) if self.xv is not None and (not isinstance(self.xv, np.ndarray)): @@ -106,7 +125,7 @@ def __attrs_post_init__(self): self.nrow = 0 self.ncol = 0 self._coerce_griddata() - super().__attrs_post_init__() + super().__post_init__() def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/gwe/esl.py b/flopy4/mf6/gwe/esl.py index f9132764..b7a115a3 100644 --- a/flopy4/mf6/gwe/esl.py +++ b/flopy4/mf6/gwe/esl.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Esl(Package): dfn_name: ClassVar[str] = "gwe-esl" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) senerrate: Union[float, str] = field(time_series=True) @@ -74,7 +75,7 @@ class StressPeriodData(Item): direction="in", keyword="obs6", ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwe/est.py b/flopy4/mf6/gwe/est.py index a8189674..2293cdbf 100644 --- a/flopy4/mf6/gwe/est.py +++ b/flopy4/mf6/gwe/est.py @@ -1,14 +1,14 @@ # autogenerated file, do not modify from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Est(Package): dfn_name: ClassVar[str] = "gwe-est" diff --git a/flopy4/mf6/gwe/fmi.py b/flopy4/mf6/gwe/fmi.py index e08198c5..e25766a0 100644 --- a/flopy4/mf6/gwe/fmi.py +++ b/flopy4/mf6/gwe/fmi.py @@ -2,18 +2,19 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Fmi(Package): dfn_name: ClassVar[str] = "gwe-fmi" - @attrs.define + @dataclass(config=CFG) class Packagedata(Item): flowtype: Union[float, str] = field() fname: Path = path(converter=Path, direction="in") @@ -31,7 +32,7 @@ class Packagedata(Item): optional=True, longname="correct for flow imbalance", ) - packagedata: Optional[list[Packagedata]] = field( + packagedata: Optional[SkipValidation[list[Packagedata]]] = field( default=None, block="packagedata", ) diff --git a/flopy4/mf6/gwe/ic.py b/flopy4/mf6/gwe/ic.py index 2e67c8c4..c0a5b475 100644 --- a/flopy4/mf6/gwe/ic.py +++ b/flopy4/mf6/gwe/ic.py @@ -1,14 +1,14 @@ # autogenerated file, do not modify from typing import ClassVar -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ic(Package): dfn_name: ClassVar[str] = "gwe-ic" diff --git a/flopy4/mf6/gwe/lke.py b/flopy4/mf6/gwe/lke.py index 1d155a15..ff852e31 100644 --- a/flopy4/mf6/gwe/lke.py +++ b/flopy4/mf6/gwe/lke.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Lke(Package): dfn_name: ClassVar[str] = "gwe-lke" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class Packagedata(Item): lakeno: int = field(index=True, pk=True) strt: float = field() @@ -25,43 +26,43 @@ class Packagedata(Item): aux: tuple = () boundname: Optional[str] = field(default=None, optional=True) - @attrs.define + @dataclass(config=CFG) class Status(Item): _keyword: ClassVar[str] = "status" lakeno: int = field(index=True, fk="packagedata.lakeno") status: Union[float, str] = field() - @attrs.define + @dataclass(config=CFG) class Temperature(Item): _keyword: ClassVar[str] = "temperature" lakeno: int = field(index=True, fk="packagedata.lakeno") temperature: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Rainfall(Item): _keyword: ClassVar[str] = "rainfall" lakeno: int = field(index=True, fk="packagedata.lakeno") rainfall: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Evaporation(Item): _keyword: ClassVar[str] = "evaporation" lakeno: int = field(index=True, fk="packagedata.lakeno") evaporation: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Runoff(Item): _keyword: ClassVar[str] = "runoff" lakeno: int = field(index=True, fk="packagedata.lakeno") runoff: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class ExtInflow(Item): _keyword: ClassVar[str] = "ext-inflow" lakeno: int = field(index=True, fk="packagedata.lakeno") ext_inflow: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Auxiliary(Item): _keyword: ClassVar[str] = "auxiliary" lakeno: int = field(index=True, fk="packagedata.lakeno") @@ -159,12 +160,12 @@ class Auxiliary(Item): direction="in", keyword="obs6", ) - packagedata: Optional[list[Packagedata]] = field( + packagedata: Optional[SkipValidation[list[Packagedata]]] = field( default=None, block="packagedata", write_if_empty=True, ) - _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[_StressPeriodDataItem]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwe/mve.py b/flopy4/mf6/gwe/mve.py index 6ce9f6eb..3f7aa7d6 100644 --- a/flopy4/mf6/gwe/mve.py +++ b/flopy4/mf6/gwe/mve.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Mve(Package): dfn_name: ClassVar[str] = "gwe-mve" diff --git a/flopy4/mf6/gwe/oc.py b/flopy4/mf6/gwe/oc.py index 4f09e060..718404e5 100644 --- a/flopy4/mf6/gwe/oc.py +++ b/flopy4/mf6/gwe/oc.py @@ -2,62 +2,63 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import Field, SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.record import Record from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Oc(Package): dfn_name: ClassVar[str] = "gwe-oc" - @attrs.define + @dataclass(config=CFG) class Format(Record): _keyword: ClassVar[str] = "" - format_: str = attrs.field() - columns: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) - width: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) - digits: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) + format_: str = Field() + columns: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) + width: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) + digits: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) - @attrs.define + @dataclass(config=CFG) class Temperatureprint(Record): _keyword: ClassVar[str] = "temperature" _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) - formatrecord: "Oc.Format" = attrs.field() + formatrecord: "Oc.Format" = Field() - @attrs.define + @dataclass(config=CFG) class All(Item): _keyword: ClassVar[str] = "all" - @attrs.define + @dataclass(config=CFG) class First(Item): _keyword: ClassVar[str] = "first" - @attrs.define + @dataclass(config=CFG) class Last(Item): _keyword: ClassVar[str] = "last" - @attrs.define + @dataclass(config=CFG) class Frequency(Item): _keyword: ClassVar[str] = "frequency" frequency: int = field() - @attrs.define + @dataclass(config=CFG) class Steps(Item): _keyword: ClassVar[str] = "steps" steps: tuple = field(default=(), array=True) - @attrs.define + @dataclass(config=CFG) class Save(Item): _keyword: ClassVar[str] = "save" rtype: Union[float, str] = field() ocsetting: "Oc.All | Oc.First | Oc.Last | Oc.Frequency | Oc.Steps" = field() - @attrs.define + @dataclass(config=CFG) class Print(Item): _keyword: ClassVar[str] = "print" rtype: Union[float, str] = field() @@ -93,7 +94,7 @@ class Print(Item): default=None, block="options", ) - _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[_StressPeriodDataItem]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwe/ssm.py b/flopy4/mf6/gwe/ssm.py index 68d5d51f..9a3543fa 100644 --- a/flopy4/mf6/gwe/ssm.py +++ b/flopy4/mf6/gwe/ssm.py @@ -2,24 +2,25 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ssm(Package): dfn_name: ClassVar[str] = "gwe-ssm" - @attrs.define + @dataclass(config=CFG) class Sources(Item): pname: Union[float, str] = field() srctype: Union[float, str] = field() auxname: Union[float, str] = field() - @attrs.define + @dataclass(config=CFG) class Fileinput(Item): pname: Union[float, str] = field() spc6_filename: Path = path(converter=Path, direction="in", keyword="spc6") @@ -37,12 +38,12 @@ class Fileinput(Item): optional=True, longname="save calculated flows to budget file", ) - sources: Optional[list[Sources]] = field( + sources: Optional[SkipValidation[list[Sources]]] = field( default=None, block="sources", write_if_empty=True, ) - fileinput: Optional[list[Fileinput]] = field( + fileinput: Optional[SkipValidation[list[Fileinput]]] = field( default=None, block="fileinput", ) diff --git a/flopy4/mf6/gwf/__init__.py b/flopy4/mf6/gwf/__init__.py index 037ad392..6c29b13d 100644 --- a/flopy4/mf6/gwf/__init__.py +++ b/flopy4/mf6/gwf/__init__.py @@ -1,13 +1,13 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs import xarray as xr import xugrid as xu -from attrs import define from flopy.discretization.grid import Grid from flopy.discretization.structuredgrid import StructuredGrid from flopy.discretization.vertexgrid import VertexGrid +from pydantic import Field +from pydantic.dataclasses import dataclass from flopy4.mf6.gwf.buy import Buy from flopy4.mf6.gwf.chd import Chd @@ -35,7 +35,7 @@ from flopy4.mf6.gwf.vsc import Vsc from flopy4.mf6.gwf.wel import Wel from flopy4.mf6.gwf.welg import Welg -from flopy4.mf6.model import Model +from flopy4.mf6.model import CFG, Model from flopy4.mf6.spec import field, path from flopy4.mf6.utils import open_cbc, open_hds from flopy4.utils import to_path @@ -85,18 +85,18 @@ def convert_grid(value): raise TypeError(f"Expected Grid or Dis/Disv, got {type(value)}") -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Gwf(Model): dfn_name: ClassVar[str] = "gwf-nam" - @define + @dataclass(config=CFG) class NewtonOptions: newton: bool = field() under_relaxation: bool = field() - @define + @dataclass(config=CFG) class Output: - parent: "Gwf" = attrs.field(repr=False) + parent: "Gwf" = Field(repr=False) @property def head(self) -> xr.DataArray | xu.UgridDataArray: @@ -179,20 +179,26 @@ def budget(self) -> xr.Dataset | xu.UgridDataset: npf: Npf | None = field(block="packages", default=None) sto: Sto | None = field(block="packages", default=None) buy: Buy | None = field(block="packages", default=None) - chd: list[Union[Chd, Chdg]] = field(block="packages", default=attrs.Factory(list)) - drn: list[Union[Drn, Drng]] = field(block="packages", default=attrs.Factory(list)) - evt: list[Union[Evt, Evta]] = field(block="packages", default=attrs.Factory(list)) - ghb: list[Union[Ghb, Ghbg]] = field(block="packages", default=attrs.Factory(list)) - rch: list[Union[Rch, Rcha]] = field(block="packages", default=attrs.Factory(list)) - riv: list[Union[Riv, Rivg]] = field(block="packages", default=attrs.Factory(list)) - csub: list[Csub] = field(block="packages", default=attrs.Factory(list)) - lak: list[Lak] = field(block="packages", default=attrs.Factory(list)) + chd: list[Union[Chd, Chdg]] = field(block="packages", default_factory=list) + drn: list[Union[Drn, Drng]] = field(block="packages", default_factory=list) + evt: list[Union[Evt, Evta]] = field(block="packages", default_factory=list) + ghb: list[Union[Ghb, Ghbg]] = field(block="packages", default_factory=list) + rch: list[Union[Rch, Rcha]] = field(block="packages", default_factory=list) + riv: list[Union[Riv, Rivg]] = field(block="packages", default_factory=list) + csub: list[Csub] = field(block="packages", default_factory=list) + lak: list[Lak] = field(block="packages", default_factory=list) mvr: Mvr | None = field(block="packages", default=None) vsc: Vsc | None = field(block="packages", default=None) - wel: list[Union[Wel, Welg]] = field(block="packages", default=attrs.Factory(list)) - output: Output = attrs.field( - default=attrs.Factory(lambda self: Gwf.Output(self), takes_self=True) - ) + wel: list[Union[Wel, Welg]] = field(block="packages", default_factory=list) + # attrs' takes_self=True factory, ported: pydantic default_factory + # callables take no arguments, so this is Optional + filled in by + # __post_init__ below instead (same pattern as Component.name). + output: Optional[Output] = Field(default=None, repr=False) + + def __post_init__(self): + super().__post_init__() + if self.output is None: + self.output = Gwf.Output(self) @property def grid(self) -> Grid: diff --git a/flopy4/mf6/gwf/api.py b/flopy4/mf6/gwf/api.py index 4eaadeca..f71fa063 100644 --- a/flopy4/mf6/gwf/api.py +++ b/flopy4/mf6/gwf/api.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Api(Package): dfn_name: ClassVar[str] = "gwf-api" @@ -54,5 +54,6 @@ class Api(Package): maxbound: Optional[int] = field( default=0, block="dimensions", + auto_from="stress_period_data", longname="maximum number of user-defined api boundaries", ) diff --git a/flopy4/mf6/gwf/buy.py b/flopy4/mf6/gwf/buy.py index 17555763..f91d6c60 100644 --- a/flopy4/mf6/gwf/buy.py +++ b/flopy4/mf6/gwf/buy.py @@ -2,19 +2,20 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Buy(Package): dfn_name: ClassVar[str] = "gwf-buy" - @attrs.define + @dataclass(config=CFG) class Packagedata(Item): irhospec: int = field(index=True, pk=True) drhodc: float = field() @@ -48,7 +49,7 @@ class Packagedata(Item): block="dimensions", longname="number of species used in density equation of state", ) - packagedata: Optional[list[Packagedata]] = field( + packagedata: Optional[SkipValidation[list[Packagedata]]] = field( default=None, block="packagedata", auto_from="packagedata", diff --git a/flopy4/mf6/gwf/chd.py b/flopy4/mf6/gwf/chd.py index 4090d6ab..83cf4ffb 100644 --- a/flopy4/mf6/gwf/chd.py +++ b/flopy4/mf6/gwf/chd.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Chd(Package): dfn_name: ClassVar[str] = "gwf-chd" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) head: Union[float, str] = field(time_series=True) @@ -74,7 +75,7 @@ class StressPeriodData(Item): direction="in", keyword="obs6", ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/chdg.py b/flopy4/mf6/gwf/chdg.py index 4ecb8975..83241688 100644 --- a/flopy4/mf6/gwf/chdg.py +++ b/flopy4/mf6/gwf/chdg.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Chdg(Package): dfn_name: ClassVar[str] = "gwf-chdg" @@ -68,6 +68,7 @@ class Chdg(Package): default=None, block="dimensions", optional=True, + auto_from="stress_period_data", longname="maximum number of constant head cells in any stress period", ) head: Optional[FloatArrayLike] = field( diff --git a/flopy4/mf6/gwf/csub.py b/flopy4/mf6/gwf/csub.py index f8ac854f..2aa6b79b 100644 --- a/flopy4/mf6/gwf/csub.py +++ b/flopy4/mf6/gwf/csub.py @@ -2,19 +2,20 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Csub(Package): dfn_name: ClassVar[str] = "gwf-csub" - @attrs.define + @dataclass(config=CFG) class Packagedata(Item): icsubno: int = field(index=True, pk=True) cellid: tuple = field(cellid=True) @@ -30,7 +31,7 @@ class Packagedata(Item): aux: tuple = () boundname: Optional[str] = field(default=None, optional=True) - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) sig0: Union[float, str] = field(time_series=True) @@ -242,7 +243,7 @@ class StressPeriodData(Item): optional=True, longname="maximum number of stress offset cells", ) - packagedata: Optional[list[Packagedata]] = field( + packagedata: Optional[SkipValidation[list[Packagedata]]] = field( default=None, block="packagedata", auto_from="packagedata", @@ -277,7 +278,7 @@ class StressPeriodData(Item): optional=True, longname="specific gravity of saturated sediments", ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/dis.py b/flopy4/mf6/gwf/dis.py index feb7ce7e..b46b1ee5 100644 --- a/flopy4/mf6/gwf/dis.py +++ b/flopy4/mf6/gwf/dis.py @@ -1,18 +1,19 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs import numpy as np from numpy.typing import NDArray +from pydantic import Field +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path -from flopy4.mf6.gwf.disbase import DisBase +from flopy4.mf6.gwf.disbase import CFG, DisBase from flopy4.mf6.spec import field, path from flopy4.mf6.utils.grid import StructuredGrid from flopy4.mf6.utl.ncf import Ncf -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Dis(DisBase): dfn_name: ClassVar[str] = "gwf-dis" @@ -31,11 +32,11 @@ class Dis(DisBase): direction="in", keyword="ncf6", ) - ncf: Optional[Ncf] = attrs.field(default=None) + ncf: Optional[Ncf] = Field(default=None) nlay: int = field(default=1, block="dimensions") ncol: int = field(default=2, block="dimensions") nrow: int = field(default=2, block="dimensions") - delr: NDArray[np.float64] = field( + delr: Optional[NDArray[np.float64]] = field( default=1.0, longname="spacing along a row", block="griddata", @@ -43,7 +44,7 @@ class Dis(DisBase): layered=False, netcdf=True, ) - delc: NDArray[np.float64] = field( + delc: Optional[NDArray[np.float64]] = field( default=1.0, longname="spacing along a column", block="griddata", @@ -51,7 +52,7 @@ class Dis(DisBase): layered=False, netcdf=True, ) - top: NDArray[np.float64] = field( + top: Optional[NDArray[np.float64]] = field( default=1.0, longname="cell top elevation", block="griddata", @@ -59,7 +60,7 @@ class Dis(DisBase): layered=False, netcdf=True, ) - botm: NDArray[np.float64] = field( + botm: Optional[NDArray[np.float64]] = field( default=0.0, longname="cell bottom elevation", block="griddata", @@ -76,12 +77,12 @@ class Dis(DisBase): longname="idomain existence array", ) - def __attrs_post_init__(self): + def __post_init__(self): self.nodes = self.ncol * self.nrow * self.nlay self.ncpl = self.ncol * self.nrow self.nvert = (self.ncol + 1) * (self.nrow + 1) self._coerce_griddata() - super().__attrs_post_init__() + super().__post_init__() def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/gwf/disbase.py b/flopy4/mf6/gwf/disbase.py index 00352e89..5c187f2b 100644 --- a/flopy4/mf6/gwf/disbase.py +++ b/flopy4/mf6/gwf/disbase.py @@ -1,56 +1,64 @@ from pathlib import Path from typing import Optional -import attrs import numpy as np from flopy.discretization.grid import Grid as LegacyGrid +from pydantic import Field +from pydantic.dataclasses import dataclass from flopy4.mf6.constants import MF6 from flopy4.mf6.package import _DTYPE_MAP as _PKG_DTYPE_MAP -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.write_context import WriteContext -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class DisBase(Package): # Derived dimensions — not read/written by the codec, set by subclass post_init. - nlay: Optional[int] = attrs.field(default=None, init=False) - nrow: Optional[int] = attrs.field(default=None, init=False) - ncol: Optional[int] = attrs.field(default=None, init=False) - ncpl: Optional[int] = attrs.field(default=None, init=False) - nvert: Optional[int] = attrs.field(default=None, init=False) - nodes: Optional[int] = attrs.field(default=None, init=False) + nlay: Optional[int] = Field(default=None, init=False) + nrow: Optional[int] = Field(default=None, init=False) + ncol: Optional[int] = Field(default=None, init=False) + ncpl: Optional[int] = Field(default=None, init=False) + nvert: Optional[int] = Field(default=None, init=False) + nodes: Optional[int] = Field(default=None, init=False) - def __attrs_post_init__(self): - super().__attrs_post_init__() + def __post_init__(self): + super().__post_init__() def _coerce_griddata(self) -> None: """Coerce griddata fields: list→ndarray, per-layer expansion, flatten. Must be called after derived dimensions (nodes, ncpl) are set and - before _broadcast_griddata / super().__attrs_post_init__(). + before _broadcast_griddata / super().__post_init__(). By this + point, any scalar/list griddata value has already passed through + Package._coerce_arrays (inherited -- a field_validator("*", + mode="before") runs during construction, ahead of __post_init__), + so it already arrives here as a real ndarray of the right dtype; + the list/tuple branch below is effectively a no-op today but kept + for parity with the attrs original (harmless if a future caller + ever bypasses construction-time validation via a direct __dict__ + write, as several places in this codebase do). """ - import attrs as _attrs - - fields = _attrs.fields(type(self)) + fields = type(self).__pydantic_fields__ dims = self.get_dims() ncpl = dims.get("ncpl", 0) nlay = dims.get("nlay", 1) - for f in fields: - if f.metadata.get("block") != "griddata": + for name, f in fields.items(): + meta = f.json_schema_extra or {} + if meta.get("block") != "griddata": continue - val = self.__dict__.get(f.name) + val = self.__dict__.get(name) if val is None: continue - dtype = _PKG_DTYPE_MAP.get(f.metadata.get("dfn_type", "double"), np.float64) + dtype = _PKG_DTYPE_MAP.get(meta.get("dfn_type", "double"), np.float64) if isinstance(val, (list, tuple)): val = np.asarray(val, dtype=dtype) - self.__dict__[f.name] = val + self.__dict__[name] = val if isinstance(val, np.ndarray): - if f.metadata.get("layered") and val.size == nlay and nlay > 0 and ncpl > 0: - self.__dict__[f.name] = np.repeat(val, ncpl).astype(dtype) + if meta.get("layered") and val.size == nlay and nlay > 0 and ncpl > 0: + self.__dict__[name] = np.repeat(val, ncpl).astype(dtype) elif val.ndim > 1: - self.__dict__[f.name] = val.ravel() + self.__dict__[name] = val.ravel() self._broadcast_griddata(fields, dims) def write(self, format: str = MF6, context: Optional[WriteContext] = None) -> None: diff --git a/flopy4/mf6/gwf/disv.py b/flopy4/mf6/gwf/disv.py index 3cbdc025..ef5479fe 100644 --- a/flopy4/mf6/gwf/disv.py +++ b/flopy4/mf6/gwf/disv.py @@ -1,31 +1,32 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs import numpy as np from numpy.typing import NDArray +from pydantic import Field, field_validator +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path -from flopy4.mf6.gwf.disbase import DisBase +from flopy4.mf6.gwf.disbase import CFG, DisBase from flopy4.mf6.item import Item from flopy4.mf6.spec import field, path from flopy4.mf6.utils.grid import VertexGrid from flopy4.mf6.utl.ncf import Ncf -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Disv(DisBase): dfn_name: ClassVar[str] = "gwf-disv" - @attrs.define(slots=False) + @dataclass(config=CFG) class Cell2dRecord: - icell2d: int = attrs.field() - xc: float = attrs.field() - yc: float = attrs.field() - ncvert: int = attrs.field() - icvert: tuple[int, ...] = attrs.field() + icell2d: int = Field() + xc: float = Field() + yc: float = Field() + ncvert: int = Field() + icvert: tuple[int, ...] = Field() - @attrs.define + @dataclass(config=CFG) class Vertices(Item): iv: int xv: float @@ -46,11 +47,11 @@ class Vertices(Item): direction="in", keyword="ncf6", ) - ncf: Optional[Ncf] = attrs.field(default=None) + ncf: Optional[Ncf] = Field(default=None) nlay: int = field(default=0, block="dimensions") ncpl: int = field(default=0, block="dimensions") nvert: int = field(default=0, block="dimensions") - top: NDArray[np.float64] = field( + top: Optional[NDArray[np.float64]] = field( default=None, longname="model top elevation", block="griddata", @@ -58,7 +59,7 @@ class Vertices(Item): layered=False, netcdf=True, ) - botm: NDArray[np.float64] = field( + botm: Optional[NDArray[np.float64]] = field( default=None, longname="model bottom elevation", block="griddata", @@ -74,14 +75,32 @@ class Vertices(Item): layered=True, netcdf=True, ) - iv: Optional[NDArray[np.int64]] = attrs.field(default=None) - xv: Optional[NDArray[np.float64]] = attrs.field(default=None) - yv: Optional[NDArray[np.float64]] = attrs.field(default=None) + iv: Optional[NDArray[np.int64]] = Field(default=None) + xv: Optional[NDArray[np.float64]] = Field(default=None) + yv: Optional[NDArray[np.float64]] = Field(default=None) + + # iv/xv/yv are declared NDArray-typed but commonly constructed from a + # plain list/tuple (see from_grid() below) -- unlike Package's own + # griddata fields, these carry no block="griddata"/shape= metadata, so + # Package._coerce_arrays' shape-driven check doesn't reach them. Same + # underlying gap as Tdis.perlen/nstp/tsmult: attrs never validated the + # declared NDArray type against an actual list default/override at + # all; pydantic does, so this needs its own small mode="before" fix. + @field_validator("iv", mode="before") + @classmethod + def _coerce_iv(cls, v): + return v if v is None or isinstance(v, np.ndarray) else np.asarray(v, dtype=np.int64) + + @field_validator("xv", "yv", mode="before") + @classmethod + def _coerce_xv_yv(cls, v): + return v if v is None or isinstance(v, np.ndarray) else np.asarray(v, dtype=np.float64) + vertices: Optional[list[Vertices]] = field(default=None, block="vertices") - cell2ddata: Optional[list] = attrs.field(default=None) + cell2ddata: Optional[list] = Field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") - def __attrs_post_init__(self): + def __post_init__(self): if self.iv is not None and (not isinstance(self.iv, np.ndarray)): object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) if self.xv is not None and (not isinstance(self.xv, np.ndarray)): @@ -106,7 +125,7 @@ def __attrs_post_init__(self): self.nrow = 0 self.ncol = 0 self._coerce_griddata() - super().__attrs_post_init__() + super().__post_init__() def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/gwf/drn.py b/flopy4/mf6/gwf/drn.py index 3847a511..23725547 100644 --- a/flopy4/mf6/gwf/drn.py +++ b/flopy4/mf6/gwf/drn.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Drn(Package): dfn_name: ClassVar[str] = "gwf-drn" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) elev: Union[float, str] = field(time_series=True) @@ -86,7 +87,7 @@ class StressPeriodData(Item): block="options", optional=True, ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/drng.py b/flopy4/mf6/gwf/drng.py index c50de963..173e88d9 100644 --- a/flopy4/mf6/gwf/drng.py +++ b/flopy4/mf6/gwf/drng.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Drng(Package): dfn_name: ClassVar[str] = "gwf-drng" @@ -79,6 +79,7 @@ class Drng(Package): default=None, block="dimensions", optional=True, + auto_from="stress_period_data", longname="maximum number of drain cells in any stress period", ) elev: Optional[FloatArrayLike] = field( diff --git a/flopy4/mf6/gwf/evt.py b/flopy4/mf6/gwf/evt.py index dadcd33e..7b3e4164 100644 --- a/flopy4/mf6/gwf/evt.py +++ b/flopy4/mf6/gwf/evt.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Evt(Package): dfn_name: ClassVar[str] = "gwf-evt" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) surface: Union[float, str] = field(time_series=True) @@ -96,7 +97,7 @@ class StressPeriodData(Item): block="dimensions", longname="number of ET segments", ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/evta.py b/flopy4/mf6/gwf/evta.py index bf3f5d19..dabb4085 100644 --- a/flopy4/mf6/gwf/evta.py +++ b/flopy4/mf6/gwf/evta.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, IntArrayLike, _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Evta(Package): dfn_name: ClassVar[str] = "gwf-evta" diff --git a/flopy4/mf6/gwf/ghb.py b/flopy4/mf6/gwf/ghb.py index c76c741f..019698c3 100644 --- a/flopy4/mf6/gwf/ghb.py +++ b/flopy4/mf6/gwf/ghb.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ghb(Package): dfn_name: ClassVar[str] = "gwf-ghb" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) bhead: Union[float, str] = field(time_series=True) @@ -80,7 +81,7 @@ class StressPeriodData(Item): block="options", optional=True, ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/ghbg.py b/flopy4/mf6/gwf/ghbg.py index 8a0714ff..83f4ad93 100644 --- a/flopy4/mf6/gwf/ghbg.py +++ b/flopy4/mf6/gwf/ghbg.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ghbg(Package): dfn_name: ClassVar[str] = "gwf-ghbg" @@ -73,6 +73,7 @@ class Ghbg(Package): default=None, block="dimensions", optional=True, + auto_from="stress_period_data", longname="maximum number of general-head boundaries in any stress period", ) bhead: Optional[FloatArrayLike] = field( diff --git a/flopy4/mf6/gwf/ic.py b/flopy4/mf6/gwf/ic.py index f0d9d779..4665459f 100644 --- a/flopy4/mf6/gwf/ic.py +++ b/flopy4/mf6/gwf/ic.py @@ -1,14 +1,14 @@ # autogenerated file, do not modify from typing import ClassVar -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ic(Package): dfn_name: ClassVar[str] = "gwf-ic" diff --git a/flopy4/mf6/gwf/lak.py b/flopy4/mf6/gwf/lak.py index e8ab7424..ec53f592 100644 --- a/flopy4/mf6/gwf/lak.py +++ b/flopy4/mf6/gwf/lak.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Lak(Package): dfn_name: ClassVar[str] = "gwf-lak" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class Packagedata(Item): ifno: int = field(index=True, pk=True) strt: float = field() @@ -24,7 +25,7 @@ class Packagedata(Item): aux: tuple = () boundname: Optional[str] = field(default=None, optional=True) - @attrs.define + @dataclass(config=CFG) class Connectiondata(Item): ifno: int = field(index=True, fk="packagedata.ifno") iconn: int = field(index=True) @@ -36,12 +37,12 @@ class Connectiondata(Item): connlen: float = field() connwidth: float = field() - @attrs.define + @dataclass(config=CFG) class Tables(Item): ifno: int = field(index=True, fk="packagedata.ifno") tab6_filename: Path = path(converter=Path, direction="in", keyword="tab6") - @attrs.define + @dataclass(config=CFG) class Outlets(Item): outletno: int = field(index=True, pk=True) lakein: int = field(index=True, fk="packagedata.ifno") @@ -52,79 +53,79 @@ class Outlets(Item): rough: Union[float, str] = field(time_series=True) slope: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Status(Item): _keyword: ClassVar[str] = "status" lakeno: int = field(index=True, fk="packagedata.ifno") status: Union[float, str] = field() - @attrs.define + @dataclass(config=CFG) class Stage(Item): _keyword: ClassVar[str] = "stage" lakeno: int = field(index=True, fk="packagedata.ifno") stage: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Rainfall(Item): _keyword: ClassVar[str] = "rainfall" lakeno: int = field(index=True, fk="packagedata.ifno") rainfall: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Evaporation(Item): _keyword: ClassVar[str] = "evaporation" lakeno: int = field(index=True, fk="packagedata.ifno") evaporation: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Runoff(Item): _keyword: ClassVar[str] = "runoff" lakeno: int = field(index=True, fk="packagedata.ifno") runoff: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Inflow(Item): _keyword: ClassVar[str] = "inflow" lakeno: int = field(index=True, fk="packagedata.ifno") inflow: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Withdrawal(Item): _keyword: ClassVar[str] = "withdrawal" lakeno: int = field(index=True, fk="packagedata.ifno") withdrawal: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Rate(Item): _keyword: ClassVar[str] = "rate" outletno: int = field(index=True, fk="outlets.outletno") rate: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Invert(Item): _keyword: ClassVar[str] = "invert" outletno: int = field(index=True, fk="outlets.outletno") invert: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Width(Item): _keyword: ClassVar[str] = "width" outletno: int = field(index=True, fk="outlets.outletno") width: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Slope(Item): _keyword: ClassVar[str] = "slope" outletno: int = field(index=True, fk="outlets.outletno") slope: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Rough(Item): _keyword: ClassVar[str] = "rough" outletno: int = field(index=True, fk="outlets.outletno") rough: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Auxiliary(Item): _keyword: ClassVar[str] = "auxiliary" lakeno: int = field(index=True, fk="packagedata.ifno") @@ -286,27 +287,27 @@ class Auxiliary(Item): block="dimensions", longname="number of tables", ) - packagedata: Optional[list[Packagedata]] = field( + packagedata: Optional[SkipValidation[list[Packagedata]]] = field( default=None, block="packagedata", auto_from="packagedata", ) - connectiondata: Optional[list[Connectiondata]] = field( + connectiondata: Optional[SkipValidation[list[Connectiondata]]] = field( default=None, block="connectiondata", write_if_empty=True, ) - tables: Optional[list[Tables]] = field( + tables: Optional[SkipValidation[list[Tables]]] = field( default=None, block="tables", auto_from="tables", ) - outlets: Optional[list[Outlets]] = field( + outlets: Optional[SkipValidation[list[Outlets]]] = field( default=None, block="outlets", auto_from="outlets", ) - _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[_StressPeriodDataItem]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/mvr.py b/flopy4/mf6/gwf/mvr.py index 65d4a195..e4cd2a72 100644 --- a/flopy4/mf6/gwf/mvr.py +++ b/flopy4/mf6/gwf/mvr.py @@ -2,24 +2,25 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Mvr(Package): dfn_name: ClassVar[str] = "gwf-mvr" - @attrs.define + @dataclass(config=CFG) class Packages(Item): pname: Union[float, str] = field() mname: Optional[Union[float, str]] = field(default=None, optional=True) - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): pname1: Union[float, str] = field() id1: int = field(index=True) @@ -75,12 +76,12 @@ class StressPeriodData(Item): block="dimensions", longname="number of packages to be used with the mover", ) - packages: Optional[list[Packages]] = field( + packages: Optional[SkipValidation[list[Packages]]] = field( default=None, block="packages", auto_from="packages", ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/npf.py b/flopy4/mf6/gwf/npf.py index 493d5c33..29805daf 100644 --- a/flopy4/mf6/gwf/npf.py +++ b/flopy4/mf6/gwf/npf.py @@ -2,34 +2,35 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic import Field +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, IntArrayLike, _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.record import Record from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Npf(Package): dfn_name: ClassVar[str] = "gwf-npf" - @attrs.define + @dataclass(config=CFG) class Cvoptions(Record): _keyword: ClassVar[str] = "variablecv" - dewatered: Optional[bool] = attrs.field(default=None, metadata={"tagged": True}) + dewatered: Optional[bool] = Field(default=None, json_schema_extra={"tagged": True}) - @attrs.define + @dataclass(config=CFG) class Rewet(Record): _keyword: ClassVar[str] = "rewet" - wetfct: float = attrs.field(metadata={"tagged": True}) - iwetit: int = attrs.field(metadata={"tagged": True}) - ihdwet: int = attrs.field(metadata={"tagged": True}) + wetfct: float = Field(json_schema_extra={"tagged": True}) + iwetit: int = Field(json_schema_extra={"tagged": True}) + ihdwet: int = Field(json_schema_extra={"tagged": True}) - @attrs.define + @dataclass(config=CFG) class Xt3doptions(Record): _keyword: ClassVar[str] = "xt3d" - rhs: Optional[bool] = attrs.field(default=None, metadata={"tagged": True}) + rhs: Optional[bool] = Field(default=None, json_schema_extra={"tagged": True}) save_flows: bool = field( default=False, diff --git a/flopy4/mf6/gwf/oc.py b/flopy4/mf6/gwf/oc.py index bb74d9b7..3ad47903 100644 --- a/flopy4/mf6/gwf/oc.py +++ b/flopy4/mf6/gwf/oc.py @@ -2,62 +2,63 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import Field, SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.record import Record from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Oc(Package): dfn_name: ClassVar[str] = "gwf-oc" - @attrs.define + @dataclass(config=CFG) class Format(Record): _keyword: ClassVar[str] = "" - format_: str = attrs.field() - columns: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) - width: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) - digits: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) + format_: str = Field() + columns: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) + width: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) + digits: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) - @attrs.define + @dataclass(config=CFG) class Headprint(Record): _keyword: ClassVar[str] = "head" _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) - formatrecord: "Oc.Format" = attrs.field() + formatrecord: "Oc.Format" = Field() - @attrs.define + @dataclass(config=CFG) class All(Item): _keyword: ClassVar[str] = "all" - @attrs.define + @dataclass(config=CFG) class First(Item): _keyword: ClassVar[str] = "first" - @attrs.define + @dataclass(config=CFG) class Last(Item): _keyword: ClassVar[str] = "last" - @attrs.define + @dataclass(config=CFG) class Frequency(Item): _keyword: ClassVar[str] = "frequency" frequency: int = field() - @attrs.define + @dataclass(config=CFG) class Steps(Item): _keyword: ClassVar[str] = "steps" steps: tuple = field(default=(), array=True) - @attrs.define + @dataclass(config=CFG) class Save(Item): _keyword: ClassVar[str] = "save" rtype: Union[float, str] = field() ocsetting: "Oc.All | Oc.First | Oc.Last | Oc.Frequency | Oc.Steps" = field() - @attrs.define + @dataclass(config=CFG) class Print(Item): _keyword: ClassVar[str] = "print" rtype: Union[float, str] = field() @@ -93,7 +94,7 @@ class Print(Item): default=None, block="options", ) - _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[_StressPeriodDataItem]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/rch.py b/flopy4/mf6/gwf/rch.py index 9b4aa851..9ad7445e 100644 --- a/flopy4/mf6/gwf/rch.py +++ b/flopy4/mf6/gwf/rch.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Rch(Package): dfn_name: ClassVar[str] = "gwf-rch" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) recharge: Union[float, str] = field(time_series=True) @@ -80,7 +81,7 @@ class StressPeriodData(Item): direction="in", keyword="obs6", ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/rcha.py b/flopy4/mf6/gwf/rcha.py index 85ce1baa..b4044fd8 100644 --- a/flopy4/mf6/gwf/rcha.py +++ b/flopy4/mf6/gwf/rcha.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, IntArrayLike, _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Rcha(Package): dfn_name: ClassVar[str] = "gwf-rcha" diff --git a/flopy4/mf6/gwf/riv.py b/flopy4/mf6/gwf/riv.py index e7e32a17..3c06295e 100644 --- a/flopy4/mf6/gwf/riv.py +++ b/flopy4/mf6/gwf/riv.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Riv(Package): dfn_name: ClassVar[str] = "gwf-riv" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) stage: Union[float, str] = field(time_series=True) @@ -81,7 +82,7 @@ class StressPeriodData(Item): block="options", optional=True, ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/rivg.py b/flopy4/mf6/gwf/rivg.py index 91531256..11465544 100644 --- a/flopy4/mf6/gwf/rivg.py +++ b/flopy4/mf6/gwf/rivg.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Rivg(Package): dfn_name: ClassVar[str] = "gwf-rivg" @@ -73,6 +73,7 @@ class Rivg(Package): default=None, block="dimensions", optional=True, + auto_from="stress_period_data", longname="maximum number of river cells in any stress period", ) stage: Optional[FloatArrayLike] = field( diff --git a/flopy4/mf6/gwf/sto.py b/flopy4/mf6/gwf/sto.py index c837579c..018ebbc9 100644 --- a/flopy4/mf6/gwf/sto.py +++ b/flopy4/mf6/gwf/sto.py @@ -2,19 +2,20 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, IntArrayLike, _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Sto(Package): dfn_name: ClassVar[str] = "gwf-sto" - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): storage: str = field() @@ -77,7 +78,7 @@ class StressPeriodData(Item): netcdf=True, longname="specific yield", ) # type: ignore[assignment] - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/vsc.py b/flopy4/mf6/gwf/vsc.py index e76ffaa0..ada93fc6 100644 --- a/flopy4/mf6/gwf/vsc.py +++ b/flopy4/mf6/gwf/vsc.py @@ -2,19 +2,20 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Vsc(Package): dfn_name: ClassVar[str] = "gwf-vsc" - @attrs.define + @dataclass(config=CFG) class Packagedata(Item): iviscspec: int = field(index=True, pk=True) dviscdc: float = field() @@ -72,7 +73,7 @@ class Packagedata(Item): block="dimensions", longname="number of species used in viscosity equation of state", ) - packagedata: Optional[list[Packagedata]] = field( + packagedata: Optional[SkipValidation[list[Packagedata]]] = field( default=None, block="packagedata", auto_from="packagedata", diff --git a/flopy4/mf6/gwf/wel.py b/flopy4/mf6/gwf/wel.py index 5803aaaf..4123a8bc 100644 --- a/flopy4/mf6/gwf/wel.py +++ b/flopy4/mf6/gwf/wel.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Wel(Package): dfn_name: ClassVar[str] = "gwf-wel" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) q: Union[float, str] = field(time_series=True) @@ -105,7 +106,7 @@ class StressPeriodData(Item): block="options", optional=True, ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwf/welg.py b/flopy4/mf6/gwf/welg.py index 86c51423..20eb5fd1 100644 --- a/flopy4/mf6/gwf/welg.py +++ b/flopy4/mf6/gwf/welg.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Welg(Package): dfn_name: ClassVar[str] = "gwf-welg" @@ -93,6 +93,7 @@ class Welg(Package): default=None, block="dimensions", optional=True, + auto_from="stress_period_data", longname="maximum number of wells in any stress period", ) q: Optional[FloatArrayLike] = field( diff --git a/flopy4/mf6/gwt/__init__.py b/flopy4/mf6/gwt/__init__.py index d329104b..8b4cdda3 100644 --- a/flopy4/mf6/gwt/__init__.py +++ b/flopy4/mf6/gwt/__init__.py @@ -1,9 +1,9 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs from flopy.discretization.structuredgrid import StructuredGrid from flopy.discretization.vertexgrid import VertexGrid +from pydantic.dataclasses import dataclass from flopy4.mf6.gwf.disbase import DisBase from flopy4.mf6.gwt.adv import Adv @@ -19,7 +19,7 @@ from flopy4.mf6.gwt.oc import Oc from flopy4.mf6.gwt.src import Src from flopy4.mf6.gwt.ssm import Ssm -from flopy4.mf6.model import Model +from flopy4.mf6.model import CFG, Model from flopy4.mf6.spec import field, path from flopy4.utils import to_path @@ -52,7 +52,7 @@ def convert_grid(value): ] -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Gwt(Model): dfn_name: ClassVar[str] = "gwt-nam" @@ -88,9 +88,9 @@ class Gwt(Model): adv: Adv | None = field(block="packages", default=None) dsp: Dsp | None = field(block="packages", default=None) mst: Mst | None = field(block="packages", default=None) - cnc: list[Cnc] = field(block="packages", default=attrs.Factory(list)) - src: list[Src] = field(block="packages", default=attrs.Factory(list)) - lkt: list[Lkt] = field(block="packages", default=attrs.Factory(list)) + cnc: list[Cnc] = field(block="packages", default_factory=list) + src: list[Src] = field(block="packages", default_factory=list) + lkt: list[Lkt] = field(block="packages", default_factory=list) ssm: Ssm | None = field(block="packages", default=None) mvt: Mvt | None = field(block="packages", default=None) api: Api | None = field(block="packages", default=None) diff --git a/flopy4/mf6/gwt/adv.py b/flopy4/mf6/gwt/adv.py index 3fbeb8fc..54953143 100644 --- a/flopy4/mf6/gwt/adv.py +++ b/flopy4/mf6/gwt/adv.py @@ -1,13 +1,13 @@ # autogenerated file, do not modify from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Adv(Package): dfn_name: ClassVar[str] = "gwt-adv" diff --git a/flopy4/mf6/gwt/api.py b/flopy4/mf6/gwt/api.py index 6bbfc5c5..ca75047d 100644 --- a/flopy4/mf6/gwt/api.py +++ b/flopy4/mf6/gwt/api.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Api(Package): dfn_name: ClassVar[str] = "gwt-api" @@ -54,5 +54,6 @@ class Api(Package): maxbound: Optional[int] = field( default=0, block="dimensions", + auto_from="stress_period_data", longname="maximum number of user-defined api boundaries", ) diff --git a/flopy4/mf6/gwt/cnc.py b/flopy4/mf6/gwt/cnc.py index c0f45f8e..9d301b64 100644 --- a/flopy4/mf6/gwt/cnc.py +++ b/flopy4/mf6/gwt/cnc.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Cnc(Package): dfn_name: ClassVar[str] = "gwt-cnc" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) conc: Union[float, str] = field(time_series=True) @@ -74,7 +75,7 @@ class StressPeriodData(Item): direction="in", keyword="obs6", ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwt/dis.py b/flopy4/mf6/gwt/dis.py index e46e5f62..fe279e23 100644 --- a/flopy4/mf6/gwt/dis.py +++ b/flopy4/mf6/gwt/dis.py @@ -1,18 +1,19 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs import numpy as np from numpy.typing import NDArray +from pydantic import Field +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path -from flopy4.mf6.gwf.disbase import DisBase +from flopy4.mf6.gwf.disbase import CFG, DisBase from flopy4.mf6.spec import field, path from flopy4.mf6.utils.grid import StructuredGrid from flopy4.mf6.utl.ncf import Ncf -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Dis(DisBase): dfn_name: ClassVar[str] = "gwt-dis" @@ -31,11 +32,11 @@ class Dis(DisBase): direction="in", keyword="ncf6", ) - ncf: Optional[Ncf] = attrs.field(default=None) + ncf: Optional[Ncf] = Field(default=None) nlay: int = field(default=1, block="dimensions") ncol: int = field(default=2, block="dimensions") nrow: int = field(default=2, block="dimensions") - delr: NDArray[np.float64] = field( + delr: Optional[NDArray[np.float64]] = field( default=1.0, longname="spacing along a row", block="griddata", @@ -43,7 +44,7 @@ class Dis(DisBase): layered=False, netcdf=True, ) - delc: NDArray[np.float64] = field( + delc: Optional[NDArray[np.float64]] = field( default=1.0, longname="spacing along a column", block="griddata", @@ -51,7 +52,7 @@ class Dis(DisBase): layered=False, netcdf=True, ) - top: NDArray[np.float64] = field( + top: Optional[NDArray[np.float64]] = field( default=1.0, longname="cell top elevation", block="griddata", @@ -59,7 +60,7 @@ class Dis(DisBase): layered=False, netcdf=True, ) - botm: NDArray[np.float64] = field( + botm: Optional[NDArray[np.float64]] = field( default=0.0, longname="cell bottom elevation", block="griddata", @@ -76,12 +77,12 @@ class Dis(DisBase): longname="idomain existence array", ) - def __attrs_post_init__(self): + def __post_init__(self): self.nodes = self.ncol * self.nrow * self.nlay self.ncpl = self.ncol * self.nrow self.nvert = (self.ncol + 1) * (self.nrow + 1) self._coerce_griddata() - super().__attrs_post_init__() + super().__post_init__() def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/gwt/disv.py b/flopy4/mf6/gwt/disv.py index f231c710..885b73dc 100644 --- a/flopy4/mf6/gwt/disv.py +++ b/flopy4/mf6/gwt/disv.py @@ -1,31 +1,32 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs import numpy as np from numpy.typing import NDArray +from pydantic import Field, field_validator +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path -from flopy4.mf6.gwf.disbase import DisBase +from flopy4.mf6.gwf.disbase import CFG, DisBase from flopy4.mf6.item import Item from flopy4.mf6.spec import field, path from flopy4.mf6.utils.grid import VertexGrid from flopy4.mf6.utl.ncf import Ncf -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Disv(DisBase): dfn_name: ClassVar[str] = "gwt-disv" - @attrs.define(slots=False) + @dataclass(config=CFG) class Cell2dRecord: - icell2d: int = attrs.field() - xc: float = attrs.field() - yc: float = attrs.field() - ncvert: int = attrs.field() - icvert: tuple[int, ...] = attrs.field() + icell2d: int = Field() + xc: float = Field() + yc: float = Field() + ncvert: int = Field() + icvert: tuple[int, ...] = Field() - @attrs.define + @dataclass(config=CFG) class Vertices(Item): iv: int xv: float @@ -46,11 +47,11 @@ class Vertices(Item): direction="in", keyword="ncf6", ) - ncf: Optional[Ncf] = attrs.field(default=None) + ncf: Optional[Ncf] = Field(default=None) nlay: int = field(default=0, block="dimensions") ncpl: int = field(default=0, block="dimensions") nvert: int = field(default=0, block="dimensions") - top: NDArray[np.float64] = field( + top: Optional[NDArray[np.float64]] = field( default=None, longname="model top elevation", block="griddata", @@ -58,7 +59,7 @@ class Vertices(Item): layered=False, netcdf=True, ) - botm: NDArray[np.float64] = field( + botm: Optional[NDArray[np.float64]] = field( default=None, longname="model bottom elevation", block="griddata", @@ -74,14 +75,32 @@ class Vertices(Item): layered=True, netcdf=True, ) - iv: Optional[NDArray[np.int64]] = attrs.field(default=None) - xv: Optional[NDArray[np.float64]] = attrs.field(default=None) - yv: Optional[NDArray[np.float64]] = attrs.field(default=None) + iv: Optional[NDArray[np.int64]] = Field(default=None) + xv: Optional[NDArray[np.float64]] = Field(default=None) + yv: Optional[NDArray[np.float64]] = Field(default=None) + + # iv/xv/yv are declared NDArray-typed but commonly constructed from a + # plain list/tuple (see from_grid() below) -- unlike Package's own + # griddata fields, these carry no block="griddata"/shape= metadata, so + # Package._coerce_arrays' shape-driven check doesn't reach them. Same + # underlying gap as Tdis.perlen/nstp/tsmult: attrs never validated the + # declared NDArray type against an actual list default/override at + # all; pydantic does, so this needs its own small mode="before" fix. + @field_validator("iv", mode="before") + @classmethod + def _coerce_iv(cls, v): + return v if v is None or isinstance(v, np.ndarray) else np.asarray(v, dtype=np.int64) + + @field_validator("xv", "yv", mode="before") + @classmethod + def _coerce_xv_yv(cls, v): + return v if v is None or isinstance(v, np.ndarray) else np.asarray(v, dtype=np.float64) + vertices: Optional[list[Vertices]] = field(default=None, block="vertices") - cell2ddata: Optional[list] = attrs.field(default=None) + cell2ddata: Optional[list] = Field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") - def __attrs_post_init__(self): + def __post_init__(self): if self.iv is not None and (not isinstance(self.iv, np.ndarray)): object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) if self.xv is not None and (not isinstance(self.xv, np.ndarray)): @@ -106,7 +125,7 @@ def __attrs_post_init__(self): self.nrow = 0 self.ncol = 0 self._coerce_griddata() - super().__attrs_post_init__() + super().__post_init__() def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/gwt/dsp.py b/flopy4/mf6/gwt/dsp.py index 09a99e61..bc118bb7 100644 --- a/flopy4/mf6/gwt/dsp.py +++ b/flopy4/mf6/gwt/dsp.py @@ -1,14 +1,14 @@ # autogenerated file, do not modify from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Dsp(Package): dfn_name: ClassVar[str] = "gwt-dsp" diff --git a/flopy4/mf6/gwt/fmi.py b/flopy4/mf6/gwt/fmi.py index 8165de69..dc3ce4db 100644 --- a/flopy4/mf6/gwt/fmi.py +++ b/flopy4/mf6/gwt/fmi.py @@ -2,18 +2,19 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Fmi(Package): dfn_name: ClassVar[str] = "gwt-fmi" - @attrs.define + @dataclass(config=CFG) class Packagedata(Item): flowtype: Union[float, str] = field() fname: Path = path(converter=Path, direction="in") @@ -31,7 +32,7 @@ class Packagedata(Item): optional=True, longname="correct for flow imbalance", ) - packagedata: Optional[list[Packagedata]] = field( + packagedata: Optional[SkipValidation[list[Packagedata]]] = field( default=None, block="packagedata", ) diff --git a/flopy4/mf6/gwt/ic.py b/flopy4/mf6/gwt/ic.py index 0908aa1b..e327069a 100644 --- a/flopy4/mf6/gwt/ic.py +++ b/flopy4/mf6/gwt/ic.py @@ -1,14 +1,14 @@ # autogenerated file, do not modify from typing import ClassVar -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ic(Package): dfn_name: ClassVar[str] = "gwt-ic" diff --git a/flopy4/mf6/gwt/ist.py b/flopy4/mf6/gwt/ist.py index a363ce08..95799194 100644 --- a/flopy4/mf6/gwt/ist.py +++ b/flopy4/mf6/gwt/ist.py @@ -2,33 +2,34 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic import Field +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.record import Record from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ist(Package): dfn_name: ClassVar[str] = "gwt-ist" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class Format(Record): _keyword: ClassVar[str] = "" - format_: str = attrs.field() - columns: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) - width: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) - digits: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) + format_: str = Field() + columns: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) + width: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) + digits: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) - @attrs.define + @dataclass(config=CFG) class Cimprint(Record): _keyword: ClassVar[str] = "cim" _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) - formatrecord: "Ist.Format" = attrs.field() + formatrecord: "Ist.Format" = Field() save_flows: bool = field( default=False, diff --git a/flopy4/mf6/gwt/lkt.py b/flopy4/mf6/gwt/lkt.py index 066e9628..20500074 100644 --- a/flopy4/mf6/gwt/lkt.py +++ b/flopy4/mf6/gwt/lkt.py @@ -2,64 +2,65 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Lkt(Package): dfn_name: ClassVar[str] = "gwt-lkt" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class Packagedata(Item): ifno: int = field(index=True, pk=True) strt: float = field() aux: tuple = () boundname: Optional[str] = field(default=None, optional=True) - @attrs.define + @dataclass(config=CFG) class Status(Item): _keyword: ClassVar[str] = "status" ifno: int = field(index=True, fk="packagedata.ifno") status: Union[float, str] = field() - @attrs.define + @dataclass(config=CFG) class Concentration(Item): _keyword: ClassVar[str] = "concentration" ifno: int = field(index=True, fk="packagedata.ifno") concentration: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Rainfall(Item): _keyword: ClassVar[str] = "rainfall" ifno: int = field(index=True, fk="packagedata.ifno") rainfall: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Evaporation(Item): _keyword: ClassVar[str] = "evaporation" ifno: int = field(index=True, fk="packagedata.ifno") evaporation: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Runoff(Item): _keyword: ClassVar[str] = "runoff" ifno: int = field(index=True, fk="packagedata.ifno") runoff: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class ExtInflow(Item): _keyword: ClassVar[str] = "ext-inflow" ifno: int = field(index=True, fk="packagedata.ifno") ext_inflow: Union[float, str] = field(time_series=True) - @attrs.define + @dataclass(config=CFG) class Auxiliary(Item): _keyword: ClassVar[str] = "auxiliary" ifno: int = field(index=True, fk="packagedata.ifno") @@ -157,12 +158,12 @@ class Auxiliary(Item): direction="in", keyword="obs6", ) - packagedata: Optional[list[Packagedata]] = field( + packagedata: Optional[SkipValidation[list[Packagedata]]] = field( default=None, block="packagedata", write_if_empty=True, ) - _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[_StressPeriodDataItem]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwt/mst.py b/flopy4/mf6/gwt/mst.py index 46ce85f5..7e4541ce 100644 --- a/flopy4/mf6/gwt/mst.py +++ b/flopy4/mf6/gwt/mst.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Mst(Package): dfn_name: ClassVar[str] = "gwt-mst" diff --git a/flopy4/mf6/gwt/mvt.py b/flopy4/mf6/gwt/mvt.py index f5ff622a..d323239a 100644 --- a/flopy4/mf6/gwt/mvt.py +++ b/flopy4/mf6/gwt/mvt.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Mvt(Package): dfn_name: ClassVar[str] = "gwt-mvt" diff --git a/flopy4/mf6/gwt/oc.py b/flopy4/mf6/gwt/oc.py index 24ad976a..e3560079 100644 --- a/flopy4/mf6/gwt/oc.py +++ b/flopy4/mf6/gwt/oc.py @@ -2,62 +2,63 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import Field, SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.record import Record from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Oc(Package): dfn_name: ClassVar[str] = "gwt-oc" - @attrs.define + @dataclass(config=CFG) class Format(Record): _keyword: ClassVar[str] = "" - format_: str = attrs.field() - columns: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) - width: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) - digits: Optional[int] = attrs.field(default=None, metadata={"tagged": True}) + format_: str = Field() + columns: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) + width: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) + digits: Optional[int] = Field(default=None, json_schema_extra={"tagged": True}) - @attrs.define + @dataclass(config=CFG) class Concentrationprint(Record): _keyword: ClassVar[str] = "concentration" _extra_tokens: ClassVar[tuple[str, ...]] = ("PRINT_FORMAT",) - formatrecord: "Oc.Format" = attrs.field() + formatrecord: "Oc.Format" = Field() - @attrs.define + @dataclass(config=CFG) class All(Item): _keyword: ClassVar[str] = "all" - @attrs.define + @dataclass(config=CFG) class First(Item): _keyword: ClassVar[str] = "first" - @attrs.define + @dataclass(config=CFG) class Last(Item): _keyword: ClassVar[str] = "last" - @attrs.define + @dataclass(config=CFG) class Frequency(Item): _keyword: ClassVar[str] = "frequency" frequency: int = field() - @attrs.define + @dataclass(config=CFG) class Steps(Item): _keyword: ClassVar[str] = "steps" steps: tuple = field(default=(), array=True) - @attrs.define + @dataclass(config=CFG) class Save(Item): _keyword: ClassVar[str] = "save" rtype: Union[float, str] = field() ocsetting: "Oc.All | Oc.First | Oc.Last | Oc.Frequency | Oc.Steps" = field() - @attrs.define + @dataclass(config=CFG) class Print(Item): _keyword: ClassVar[str] = "print" rtype: Union[float, str] = field() @@ -93,7 +94,7 @@ class Print(Item): default=None, block="options", ) - _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[_StressPeriodDataItem]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwt/src.py b/flopy4/mf6/gwt/src.py index af6e5aa8..562622d1 100644 --- a/flopy4/mf6/gwt/src.py +++ b/flopy4/mf6/gwt/src.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Src(Package): dfn_name: ClassVar[str] = "gwt-src" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class StressPeriodData(Item): cellid: tuple = field(cellid=True) smassrate: Union[float, str] = field(time_series=True) @@ -80,7 +81,7 @@ class StressPeriodData(Item): optional=True, longname="apply source to highest saturated cell", ) - _stress_period_data: Optional[dict[int, list[StressPeriodData]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[StressPeriodData]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/gwt/ssm.py b/flopy4/mf6/gwt/ssm.py index 17ba9bea..c5c8fe0d 100644 --- a/flopy4/mf6/gwt/ssm.py +++ b/flopy4/mf6/gwt/ssm.py @@ -2,24 +2,25 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ssm(Package): dfn_name: ClassVar[str] = "gwt-ssm" - @attrs.define + @dataclass(config=CFG) class Sources(Item): pname: Union[float, str] = field() srctype: Union[float, str] = field() auxname: Union[float, str] = field() - @attrs.define + @dataclass(config=CFG) class Fileinput(Item): pname: Union[float, str] = field() spc6_filename: Path = path(converter=Path, direction="in", keyword="spc6") @@ -37,12 +38,12 @@ class Fileinput(Item): optional=True, longname="save calculated flows to budget file", ) - sources: Optional[list[Sources]] = field( + sources: Optional[SkipValidation[list[Sources]]] = field( default=None, block="sources", write_if_empty=True, ) - fileinput: Optional[list[Fileinput]] = field( + fileinput: Optional[SkipValidation[list[Fileinput]]] = field( default=None, block="fileinput", ) diff --git a/flopy4/mf6/ims.py b/flopy4/mf6/ims.py index 318e4a99..a996ea6b 100644 --- a/flopy4/mf6/ims.py +++ b/flopy4/mf6/ims.py @@ -2,30 +2,31 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic import Field +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.record import Record -from flopy4.mf6.solution import Solution +from flopy4.mf6.solution import CFG, Solution from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ims(Solution): dfn_name: ClassVar[str] = "sln-ims" slntype: ClassVar[str] = "ims" - @attrs.define + @dataclass(config=CFG) class NoPtc(Record): _keyword: ClassVar[str] = "no_ptc" - no_ptc_option: Optional[str] = attrs.field(default=None) + no_ptc_option: Optional[str] = Field(default=None) - @attrs.define + @dataclass(config=CFG) class Rclose(Record): _keyword: ClassVar[str] = "" - inner_rclose: float = attrs.field(metadata={"tagged": True}) - rclose_option: Optional[str] = attrs.field(default=None) + inner_rclose: float = Field(json_schema_extra={"tagged": True}) + rclose_option: Optional[str] = Field(default=None) print_option: Optional[str] = field( default=None, diff --git a/flopy4/mf6/item.py b/flopy4/mf6/item.py index 1f491f44..e3e4c62d 100644 --- a/flopy4/mf6/item.py +++ b/flopy4/mf6/item.py @@ -13,14 +13,13 @@ by its own leading keyword token (STATUS/STAGE/RATE/...). """ +from __future__ import annotations + import re -from functools import lru_cache from pathlib import Path -from typing import Any, Union, cast, get_args, get_origin - -import attrs +from typing import Annotated, Any, Union, cast, get_args, get_origin -from flopy4.mf6.record import Record, _coerce, _resolve_sibling_class +from flopy4.mf6.record import Record, _coerce _AUX_KEY_RE = re.compile(r"^aux(\d+)$") @@ -41,44 +40,41 @@ def normalize_aux_keys(item: dict) -> dict: return rest -def _cellid_field(cls: type) -> attrs.Attribute | None: - return next((f for f in cast(type[Record], cls).fields() if f.metadata.get("cellid")), None) +def _cellid_field(cls: type) -> Any | None: + return next( + ( + f + for f in cast(type[Record], cls).fields().values() + if (f.json_schema_extra or {}).get("cellid") + ), + None, + ) def _has_aux_field(cls: type) -> bool: - return any(f.name == "aux" for f in cast(type[Record], cls).fields()) + return "aux" in cast(type[Record], cls).fields() def _has_boundname_field(cls: type) -> bool: - return any(f.name == "boundname" for f in cast(type[Record], cls).fields()) + return "boundname" in cast(type[Record], cls).fields() -@lru_cache(maxsize=None) -def _nested_union_classes(cls: type, type_str: str) -> "tuple[type[Item], ...] | None": - """If a field's raw type annotation is a `` | ``-joined forward - reference to sibling Item classes (e.g. ``"Oc.All | Oc.First | ..."``, - see make.py's _build_arm_specs_from_union), return the tuple of - resolved classes; else None. Resolvability is itself the signal, same - as record.py's _nested_class. +def _is_item_union(annotation: Any) -> "tuple[type[Item], ...] | None": + """If a field's (already-resolved) annotation is a Union of sibling + Item classes (e.g. OC's ``All | First | Last | Frequency | Steps``), + return the tuple of arm classes; else None. - Cached since to_tokens/from_tokens call this per field, often - repeatedly while parsing many rows. + Replaces the attrs original's `_nested_union_classes()` -- a + qualname-walking parse of the field's raw, unresolved forward-ref + string. Not needed here: by the time `Record.fields()` has run, + `annotation` (a pydantic `FieldInfo.annotation`) IS the real union of + resolved classes already. """ - names = [part.strip().rsplit(".", 1)[-1] for part in type_str.split(" | ")] - if len(names) < 2: - return None - resolved = [_resolve_sibling_class(cls, name) for name in names] - if any(not (isinstance(r, type) and issubclass(r, Item)) for r in resolved): - return None - return tuple(cast("list[type[Item]]", resolved)) - - -def _field_type_str(f: attrs.Attribute) -> "str | None": - """attrs stubs type `Attribute.type` as `type | None`, but attrs - actually stores the raw annotation there -- a string for a forward - reference (every nested-sibling-union field). None otherwise.""" - t: Any = f.type - return t if isinstance(t, str) else None + origin = get_origin(annotation) + if origin is Union or origin is type(int | str): + arms = tuple(a for a in get_args(annotation) if isinstance(a, type) and issubclass(a, Item)) + return arms or None + return None def construct_item(item_cls: type, values) -> "Item": @@ -89,34 +85,26 @@ def construct_item(item_cls: type, values) -> "Item": trailing string when the class also has boundname (always declared last) -- a string there unambiguously isn't a numeric aux value. """ - fields = cast(type[Record], item_cls).fields() + fields = list(cast(type[Record], item_cls).fields().items()) tuple_idx = next( ( i - for i, f in enumerate(fields) - if f.name == "aux" - or f.metadata.get("array") - or ( - (t := _field_type_str(f)) is not None - and _nested_union_classes(item_cls, t) is not None - ) + for i, (name, f) in enumerate(fields) + if name == "aux" + or (f.json_schema_extra or {}).get("array") + or _is_item_union(f.annotation) is not None ), None, ) values = list(values) if tuple_idx is None: return cast("Item", item_cls(*values)) - nested_field = fields[tuple_idx] - nested_field_type = _field_type_str(nested_field) - arm_classes = ( - _nested_union_classes(item_cls, nested_field_type) - if nested_field_type is not None - else None - ) + _, nested_finfo = fields[tuple_idx] + arm_classes = _is_item_union(nested_finfo.annotation) boundname_val = None if ( fields - and fields[-1].name == "boundname" + and fields[-1][0] == "boundname" and len(values) > tuple_idx and isinstance(values[-1], str) and arm_classes is None @@ -138,13 +126,14 @@ def _n_fixed_tokens(cls: type) -> int: infer a variable-width cellid's element count from total token length.""" cls = cast(type[Record], cls) n = 1 if cls.keyword() else 0 - for f in cls.fields(): - if f.metadata.get("cellid") or f.name in ("aux", "boundname"): + for name, f in cls.fields().items(): + meta = f.json_schema_extra or {} + if meta.get("cellid") or name in ("aux", "boundname"): continue - if f.metadata.get("optional"): + if meta.get("optional"): continue - n += 1 + (1 if f.metadata.get("_keyword") else 0) - if f.metadata.get("direction"): + n += 1 + (1 if meta.get("_keyword") else 0) + if meta.get("direction"): n += 1 return n @@ -216,27 +205,28 @@ def to_tokens(self) -> tuple: keyword = cls.keyword() row: list[Any] = [] keyword_emitted = not keyword - for f in fields: - if f.name in ("aux", "boundname"): + for name, f in fields.items(): + if name in ("aux", "boundname"): continue - val = getattr(self, f.name) + val = getattr(self, name) if val is None: continue - if f.metadata.get("cellid"): + meta = f.json_schema_extra or {} + if meta.get("cellid"): row.extend(int(c) + 1 for c in val) - elif f.metadata.get("index"): + elif meta.get("index"): row.append(int(val) + 1) - elif f.metadata.get("array"): + elif meta.get("array"): if not keyword_emitted: row.append(keyword.upper()) keyword_emitted = True row.extend(val) - elif f.metadata.get("tagged"): + elif meta.get("tagged"): if not keyword_emitted: row.append(keyword.upper()) keyword_emitted = True if val: - row.append(f.name.upper()) + row.append(name.upper()) elif isinstance(val, Record): # Nested keystring-union field (OC's ocsetting) -- val is # already the resolved arm instance and knows how to @@ -249,9 +239,9 @@ def to_tokens(self) -> tuple: if not keyword_emitted: row.append(keyword.upper()) keyword_emitted = True - if file_kw := f.metadata.get("_keyword"): + if file_kw := meta.get("_keyword"): row.append(file_kw.upper()) - if direction := f.metadata.get("direction"): + if direction := meta.get("direction"): row.append("FILEOUT" if direction == "out" else "FILEIN") row.append(str(val) if isinstance(val, Path) else val) if not keyword_emitted: @@ -284,51 +274,55 @@ def from_tokens( # type: ignore[override] tok_idx = 0 n = len(tokens) - def consume(f: attrs.Attribute) -> None: + def consume(name: str, f: Any) -> None: nonlocal tok_idx, keyword_skipped - if f.metadata.get("cellid"): + meta = f.json_schema_extra or {} + if meta.get("cellid"): cellid = tuple(int(tokens[tok_idx + j]) - 1 for j in range(ncelldim)) - kwargs[f.name] = cellid + kwargs[name] = cellid tok_idx += ncelldim return - if f.metadata.get("index"): - kwargs[f.name] = int(float(str(tokens[tok_idx]))) - 1 + if meta.get("index"): + kwargs[name] = int(float(str(tokens[tok_idx]))) - 1 tok_idx += 1 return if not keyword_skipped: tok_idx += 1 keyword_skipped = True - if f.metadata.get("_keyword"): + if meta.get("_keyword"): tok_idx += 1 - if f.metadata.get("direction"): + if meta.get("direction"): tok_idx += 1 if tok_idx >= n: return - kwargs[f.name] = _coerce(tokens[tok_idx], f) + kwargs[name] = _coerce(tokens[tok_idx], f) tok_idx += 1 - def width(f: attrs.Attribute) -> int: - w = 1 + (1 if f.metadata.get("_keyword") else 0) - if f.metadata.get("direction"): + def width(f: Any) -> int: + meta = f.json_schema_extra or {} + w = 1 + (1 if meta.get("_keyword") else 0) + if meta.get("direction"): w += 1 return w - main_fields = [f for f in fields if f.name not in ("aux", "boundname")] + main_fields = [(name, f) for name, f in fields.items() if name not in ("aux", "boundname")] nested_union_fields = [ - f - for f in main_fields - if (t := _field_type_str(f)) is not None - # mypy false positive: type[Item] vs. Hashable (lru_cache arg) - and _nested_union_classes(cls, t) is not None # type: ignore[arg-type] + (name, f) for name, f in main_fields if _is_item_union(f.annotation) is not None + ] + main_fields = [item for item in main_fields if item not in nested_union_fields] + array_fields = [ + (name, f) for name, f in main_fields if (f.json_schema_extra or {}).get("array") + ] + main_fields = [item for item in main_fields if item not in array_fields] + required_fields = [ + (name, f) for name, f in main_fields if not (f.json_schema_extra or {}).get("optional") + ] + optional_fields = [ + (name, f) for name, f in main_fields if (f.json_schema_extra or {}).get("optional") ] - main_fields = [f for f in main_fields if f not in nested_union_fields] - array_fields = [f for f in main_fields if f.metadata.get("array")] - main_fields = [f for f in main_fields if not f.metadata.get("array")] - required_fields = [f for f in main_fields if not f.metadata.get("optional")] - optional_fields = [f for f in main_fields if f.metadata.get("optional")] - for f in required_fields: - consume(f) + for name, f in required_fields: + consume(name, f) has_bn_token = False if has_boundname and n > tok_idx: @@ -336,10 +330,14 @@ def width(f: attrs.Attribute) -> int: has_bn_token = isinstance(last, str) and not _token_fits(last, float) remaining = n - tok_idx - (1 if has_bn_token else 0) - (naux if has_aux else 0) - budget_fields = [f for f in optional_fields if not f.metadata.get("tagged")] + budget_fields = [ + (name, f) + for name, f in optional_fields + if not (f.json_schema_extra or {}).get("tagged") + ] n_opt_present = 0 used = 0 - for f in budget_fields: + for name, f in budget_fields: w = width(f) if used + w > remaining: break @@ -347,20 +345,21 @@ def width(f: attrs.Attribute) -> int: n_opt_present += 1 budget_idx = 0 - for f in optional_fields: - if f.metadata.get("tagged"): + for name, f in optional_fields: + meta = f.json_schema_extra or {} + if meta.get("tagged"): if not keyword_skipped: tok_idx += 1 keyword_skipped = True - kw = f.name.upper() + kw = name.upper() if tok_idx < n and str(tokens[tok_idx]).upper() == kw: - kwargs[f.name] = str(tokens[tok_idx]) + kwargs[name] = str(tokens[tok_idx]) tok_idx += 1 continue present = budget_idx < n_opt_present budget_idx += 1 if present: - consume(f) + consume(name, f) if array_fields: # Consumes everything left up to aux/boundname's own reserved @@ -371,7 +370,7 @@ def width(f: attrs.Attribute) -> int: if not keyword_skipped: tok_idx += 1 keyword_skipped = True - f = array_fields[0] + name, _f = array_fields[0] end = n - (1 if has_bn_token else 0) - (naux if has_aux else 0) vals = [] while tok_idx < end: @@ -381,7 +380,7 @@ def width(f: attrs.Attribute) -> int: except (ValueError, TypeError): vals.append(tok) tok_idx += 1 - kwargs[f.name] = tuple(vals) + kwargs[name] = tuple(vals) elif nested_union_fields: # Nested keystring-union field (OC's ocsetting) -- same span # logic as array_fields above, but dispatches a typed arm @@ -389,20 +388,18 @@ def width(f: attrs.Attribute) -> int: if not keyword_skipped: tok_idx += 1 keyword_skipped = True - f = nested_union_fields[0] - nested_field_type = _field_type_str(f) - assert nested_field_type is not None - arm_classes = _nested_union_classes(cls, nested_field_type) # type: ignore[arg-type] + name, f = nested_union_fields[0] + arm_classes = _is_item_union(f.annotation) assert arm_classes is not None end = n - (1 if has_bn_token else 0) - (naux if has_aux else 0) nested_tokens = list(tokens[tok_idx:end]) arm_cls = dispatch_union_item(nested_tokens, arm_classes) if arm_cls is None: raise ValueError( - f"{cls.__name__}.{f.name}: no matching arm in {arm_classes} " + f"{cls.__name__}.{name}: no matching arm in {arm_classes} " f"for tokens {nested_tokens}" ) - kwargs[f.name] = arm_cls.from_tokens(nested_tokens) + kwargs[name] = arm_cls.from_tokens(nested_tokens) tok_idx = end elif not keyword_skipped: tok_idx += 1 @@ -437,18 +434,36 @@ def _unwrap_item(item) -> "type[Item] | tuple[type[Item], ...] | None": return None +def _unwrap_skip_validation(t: Any) -> Any: + """Strip one `Annotated[X, SkipValidation()]` layer, if present. + + Item-list fields are pydantic.SkipValidation-wrapped (codegen emits + this -- see Package._init_item_lists' own docstring for why: pydantic + validates an Item-list field's raw tuple/dict input eagerly by + default, unlike attrs, which applies no validation there at all). + get_origin() on the raw annotation returns Annotated, not dict/list, + so the unwrapping below needs this extra step attrs never did. + """ + if get_origin(t) is Annotated: + return get_args(t)[0] + return t + + def item_list_type(field_type) -> "type[Item] | tuple[type[Item], ...] | None": - """For Optional[list[C]] or Optional[dict[int, list[C]]], return C (or - the tuple of arm classes for a Union item type).""" + """For Optional[list[C]] or Optional[dict[int, list[C]]] (each + optionally SkipValidation-wrapped), return C (or the tuple of arm + classes for a Union item type).""" args = get_args(field_type) inner = next((a for a in args if a is not type(None)), None) if inner is None: return None + inner = _unwrap_skip_validation(inner) origin = get_origin(inner) if origin is list: return _unwrap_item(get_args(inner)[0]) if origin is dict: _, val = get_args(inner) + val = _unwrap_skip_validation(val) if get_origin(val) is list: return _unwrap_item(get_args(val)[0]) return None diff --git a/flopy4/mf6/model.py b/flopy4/mf6/model.py index 40d1091a..6ee069c8 100644 --- a/flopy4/mf6/model.py +++ b/flopy4/mf6/model.py @@ -1,11 +1,11 @@ from abc import ABC -import attrs +from pydantic.dataclasses import dataclass -from flopy4.mf6.context import Context +from flopy4.mf6.context import CFG, Context -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Model(Context, ABC): def default_filename(self) -> str: return f"{self.name}.nam" # type: ignore diff --git a/flopy4/mf6/netcdf.py b/flopy4/mf6/netcdf.py index 026b0bed..64822b53 100644 --- a/flopy4/mf6/netcdf.py +++ b/flopy4/mf6/netcdf.py @@ -84,14 +84,13 @@ class _PackageSpec: _DTYPE_MAP = _PKG_DTYPE_MAP def __init__(self, cls): - import attrs as _attrs - class _ArrayInfo: - def __init__(self, f): + def __init__(self, name, f): + meta = f.json_schema_extra or {} # A fill-forward (period) field's value has a leading nper axis. - fill_forward = bool(f.metadata.get("fill_forward")) - is_layered = f.metadata.get("layered", True) - raw_shape = f.metadata.get("shape") or ("nodes",) + fill_forward = bool(meta.get("fill_forward")) + is_layered = meta.get("layered", True) + raw_shape = meta.get("shape") or ("nodes",) # normalize ncpl → nodes for layered fields only if is_layered: normalized = tuple("nodes" if d == "ncpl" else d for d in raw_shape) @@ -101,18 +100,20 @@ def __init__(self, f): normalized = ("nper",) + normalized self.dtype = np.dtype( - _PackageSpec._DTYPE_MAP.get(to_field_type(f.type), np.float64) + _PackageSpec._DTYPE_MAP.get(to_field_type(f.annotation), np.float64) ) self.dims = normalized self.metadata = { - "longname": f.metadata.get("longname", f.name), - "block": f.metadata.get("block", ""), + "longname": meta.get("longname", name), + "block": meta.get("block", ""), "fill_forward": fill_forward, "netcdf": True, } self.arrays = { - f.name: _ArrayInfo(f) for f in _attrs.fields(cls) if f.metadata.get("netcdf") + name: _ArrayInfo(name, f) + for name, f in cls.__pydantic_fields__.items() + if (f.json_schema_extra or {}).get("netcdf") } @@ -220,8 +221,6 @@ def from_model( "params": [], } - import attrs as _attrs - # compute total nodes for broadcasting scalars to full grid _dis = getattr(model, "dis", None) d = _dis.get_dims() if _dis is not None else {} @@ -233,24 +232,25 @@ def from_model( else: _nodes = d.get("nodes", _nlay) - for f in _attrs.fields(type(package)): - if not f.metadata.get("netcdf"): + for name, f in type(package).__pydantic_fields__.items(): + meta = f.json_schema_extra or {} + if not meta.get("netcdf"): continue - if f.metadata.get("block") == "griddata": - val = getattr(package, f.name) + if meta.get("block") == "griddata": + val = getattr(package, name) if val is None: continue arr = np.asarray(val, dtype=np.float64) # Only broadcast scalars to full grid for nodes-shaped fields - shape_meta = f.metadata.get("shape", ()) + shape_meta = meta.get("shape", ()) if "nodes" in shape_meta and arr.size < _nodes: arr = np.full(_nodes, float(arr.ravel()[0])) - p["params"].append({"name": f.name, "data": arr}) + p["params"].append({"name": name, "data": arr}) else: - val = getattr(package, f.name) + val = getattr(package, name) if val is None: continue - p["params"].append({"name": f.name, "data": np.asarray(val, dtype=np.float64)}) + p["params"].append({"name": name, "data": np.asarray(val, dtype=np.float64)}) if len(p["params"]) > 0: packages.append(p) diff --git a/flopy4/mf6/package.py b/flopy4/mf6/package.py index 6b3e75b5..4815eb4d 100644 --- a/flopy4/mf6/package.py +++ b/flopy4/mf6/package.py @@ -1,12 +1,14 @@ from abc import ABC from pathlib import Path +from typing import Any -import attrs import numpy as np import pandas as pd import xarray as xr +from pydantic import field_validator +from pydantic.dataclasses import dataclass -from flopy4.mf6.component import Component +from flopy4.mf6.component import CFG, Component from flopy4.mf6.item import ( Item, construct_item, @@ -27,9 +29,75 @@ } -@attrs.define(kw_only=True, slots=False) +def _is_dask_array(v: Any) -> bool: + try: + from dask.array import Array as _DaskArray + except ImportError: + return False + return isinstance(v, _DaskArray) + + +@dataclass(config=CFG, kw_only=True) class Package(Component, ABC): - def __attrs_post_init__(self) -> None: + # A griddata field's *declared* type is an array type (NDArray[...]/ + # FloatArrayLike/IntArrayLike), but its *default value* in the real DFN + # corpus is often a bare scalar (e.g. `strt: FloatArrayLike = + # field(default=1.0, ...)`) that only becomes a real array once dims + # are known -- attrs never type-checks this mismatch (no per-field + # validator declared), so a scalar sails through construction + # untouched until __post_init__'s _broadcast_griddata expands it. + # Pydantic DOES enforce it: confirmed empirically that + # `IcLike(strt=1.0, dims=...)` raises `ValidationError: Input should be + # an instance of _ArrayLike` without this coercion step -- the + # unvalidated *default* (not explicitly passed) doesn't hit this, + # since pydantic doesn't validate field defaults unless + # validate_default=True (not set here), but any *explicit* scalar + # override does. One shared `field_validator("*", mode="before")`, + # driven by each field's own `json_schema_extra["shape"]` (which + # `spec.field()` already emits today), covers every griddata field on + # every subclass -- not one per field, not one per generated class. + @field_validator("*", mode="before") + @classmethod + def _coerce_arrays(cls, v: Any, info) -> Any: + finfo = cls.__pydantic_fields__.get(info.field_name) + if finfo is None or v is None: + return v + meta = finfo.json_schema_extra or {} + if not (isinstance(meta, dict) and meta.get("block") == "griddata" and meta.get("shape")): + return v + if isinstance(v, np.ndarray) or _is_dask_array(v): + # Already a real ndarray, or specifically a dask.array.Array + # (the one duck array actually exercised here -- see + # codec/writer/filters.py's array2chunks). np.asarray() below + # would materialize a dask array into a real ndarray, losing + # its laziness (confirmed by running the real dask-array + # griddata test), so it's passed through untouched. Anything + # ELSE duck-array-shaped (an xr.DataArray, notably -- confirmed + # by running Disv.from_grid() with a DataArray-backed grid) is + # NOT preserved as-is: some hand-written fields (Dis/Disv's + # own top/botm/delr/delc/iv/xv/yv) declare the stricter + # NDArray[...] rather than the _ArrayLike Protocol most + # generated griddata fields use, and only real np.ndarray + # satisfies that -- so it still needs materializing below. + return v + dtype = _DTYPE_MAP.get(to_field_type(finfo.annotation), np.float64) + if isinstance(v, dict): + # An empty-dict griddata value (e.g. Chd(dims={}) with no + # explicit scalar override) is _broadcast_griddata's own + # "use the field's own scalar default" signal -- attrs let it + # reach __post_init__ as a raw {} unchanged; pydantic's + # NDArray/_ArrayLike type check has no such carve-out (and + # np.asarray({}, ...) itself raises, confirmed by running the + # real empty-dict-griddata test). Pre-resolve it into the same + # 0-d default-valued array a bare scalar default produces here + # -- _broadcast_griddata's existing size==1 branch (added for + # that scalar case) picks it up and broadcasts it exactly the + # same way. + default = finfo.default if isinstance(finfo.default, (int, float)) else 0 + return np.asarray(default, dtype=dtype) + return np.asarray(v, dtype=dtype) + + def __post_init__(self) -> None: """Post-init for Package subclasses. Handles three concerns in order: @@ -37,32 +105,30 @@ def __attrs_post_init__(self) -> None: auto-set ns. 2. Broadcast scalar griddata values to their DFN shape when dims is supplied (e.g. IC(strt=1.0, dims={"nodes": 900})). - 3. Chain to Component.__attrs_post_init__() via super() -- LAST, - after 1-2, in every exit path (including the two early - returns below). Several of a package's own fields (e.g. - griddata arrays default to a bare scalar/dict until step 2 - broadcasts them) aren't in their final shape until steps 1-2 - finish, and Component.__attrs_post_init__() (via - DimensionResolverMixin's chain and _set_child_parents(), - which walks every attrs field) reads them -- so chaining - before they're finalized breaks griddata broadcasting and - dims resolution. Matches the ordering DisBase/Dis already use - for their own __attrs_post_init__ chaining (super() called - last, after their own field setup). + 3. Chain to Component.__post_init__() via super() -- LAST, after + 1-2, in every exit path (including the early return below). + Several of a package's own fields (e.g. griddata arrays + default to a bare scalar/dict until step 2 broadcasts them) + aren't in their final shape until steps 1-2 finish, and + Component.__post_init__() (via DimensionResolverMixin's chain + and _set_child_parents(), which walks every field) reads them + -- so chaining before they're finalized breaks griddata + broadcasting and dims resolution. Matches the ordering + DisBase/Dis already use for their own __post_init__ chaining + (super() called last, after their own field setup). """ - import attrs as _attrs - - # Detect schema-driven fields by presence of 'block' in field metadata. - # Package subclasses with no fields of their own (e.g. the - # Gwfgwe/Gwfgwt/Gwfprt exchange leaves in flopy4/mf6/exg/ -- just - # dfn_name, no declared fields) no-op through the rest of this method. - try: - fields = _attrs.fields(type(self)) # type: ignore[arg-type] - except _attrs.exceptions.NotAnAttrsClassError: - super().__attrs_post_init__() - return - if not any(f.metadata.get("block") is not None for f in fields): - super().__attrs_post_init__() + # Detect schema-driven fields by presence of 'block' in field + # json_schema_extra. Package subclasses with no fields of their + # own (e.g. the Gwfgwe/Gwfgwt/Gwfprt exchange leaves in + # flopy4/mf6/exg/ -- just dfn_name, no declared fields) still + # inherit Component's own fields (filename, name, ...), none of + # which carry block metadata, so this simply no-ops through the + # rest of this method for them -- no NotAnAttrsClassError-style + # guard needed (every Package subclass is a pydantic dataclass, + # unconditionally, unlike attrs' optional per-class opt-in). + fields = type(self).__pydantic_fields__ + if not any((f.json_schema_extra or {}).get("block") is not None for f in fields.values()): + super().__post_init__() return # 1. Item-list coercion. @@ -75,37 +141,38 @@ def __attrs_post_init__(self) -> None: # 3. Chain to Component's own post-init -- see docstring above for # why this must run last, not first. - super().__attrs_post_init__() + super().__post_init__() def _init_item_lists(self, fields) -> None: """Coerce raw list/dict block+period data into Item-list fields; auto-set ns from the resulting list lengths. `maxbound` (where applicable) is a computed property instead, not set here. - Reads/writes the field's real attribute name (f.name) always -- - aliases (e.g. _stress_period_data's "stress_period_data") only name - the __init__ parameter; the instance attribute (and __dict__ key - object.__setattr__ writes to) is still the real name. + Reads/writes the field's real attribute name (the dict key) + always -- aliases (e.g. _stress_period_data's "stress_period_data") + only name the __init__ parameter; the instance attribute (and + __dict__ key object.__setattr__ writes to) is still the real name. """ - for f in fields: - block = f.metadata.get("block") + for name, f in fields.items(): + meta = f.json_schema_extra or {} + block = meta.get("block") if not block: continue - item_cls = item_list_type(f.type) + item_cls = item_list_type(f.annotation) if item_cls is None: continue - raw = self.__dict__.get(f.name) + raw = self.__dict__.get(name) if raw is None: continue - if f.metadata.get("fill_forward"): + if meta.get("fill_forward"): coerced = { kper: self._coerce_item_list(rows, item_cls) for kper, rows in raw.items() } - object.__setattr__(self, f.name, coerced) + object.__setattr__(self, name, coerced) else: coerced_list = self._coerce_item_list(raw, item_cls) - object.__setattr__(self, f.name, coerced_list) + object.__setattr__(self, name, coerced_list) if getattr(self, f"n{block}s", 0) == 0: object.__setattr__(self, f"n{block}s", len(coerced_list)) @@ -173,16 +240,17 @@ def _broadcast_griddata(self, fields, dims: dict) -> None: and _par_data.dims.get("nrow", 0) == 0 ) or ("ncpl" in dims and "nrow" not in dims) - for f in fields: - if f.metadata.get("block") != "griddata": + for name, f in fields.items(): + meta = f.json_schema_extra or {} + if meta.get("block") != "griddata": continue - shape_meta = f.metadata.get("shape") + shape_meta = meta.get("shape") if not shape_meta: continue - val = self.__dict__.get(f.name) + val = self.__dict__.get(name) if val is None: continue - _gd_dtype = _DTYPE_MAP.get(to_field_type(f.type), np.float64) + _gd_dtype = _DTYPE_MAP.get(to_field_type(f.annotation), np.float64) try: resolved = [] for d in shape_meta: @@ -194,15 +262,23 @@ def _broadcast_griddata(self, fields, dims: dict) -> None: except KeyError: continue if isinstance(val, (int, float)): - self.__dict__[f.name] = np.full(shape, val, dtype=_gd_dtype) + self.__dict__[name] = np.full(shape, val, dtype=_gd_dtype) + elif isinstance(val, np.ndarray) and val.size == 1 and val.shape != shape: + # A scalar that already passed through _coerce_arrays' + # mode="before" validator (needed so pydantic's own type + # check on an _ArrayLike-typed field accepts it at all -- + # see that validator's docstring) arrives here as a 0-d + # ndarray, not a bare int/float, so the branch above + # doesn't match. Same broadcast, just unwrapped first. + self.__dict__[name] = np.full(shape, val.item(), dtype=_gd_dtype) elif isinstance(val, np.ndarray) and val.shape != shape: try: - self.__dict__[f.name] = val.reshape(shape) + self.__dict__[name] = val.reshape(shape) except ValueError: pass elif isinstance(val, dict) and not val: default = f.default if isinstance(f.default, (int, float)) else 0 - self.__dict__[f.name] = np.full(shape, default, dtype=_gd_dtype) + self.__dict__[name] = np.full(shape, default, dtype=_gd_dtype) @classmethod def load( # type: ignore[override] @@ -255,27 +331,23 @@ def to_dict(self, blocks: bool = False, strict: bool = False) -> dict: strict : bool If True, only include fields with ``block`` metadata. """ - import attrs as _attrs - - try: - all_fields = _attrs.fields(type(self)) # type: ignore[arg-type] - except _attrs.exceptions.NotAnAttrsClassError: - return super().to_dict(blocks=blocks, strict=strict) + all_fields = type(self).__pydantic_fields__ # Fall back for a Package subclass with no schema-driven fields of # its own (e.g. the exchange leaves in flopy4/mf6/exg/). - if not any(f.metadata.get("block") for f in all_fields): + if not any((f.json_schema_extra or {}).get("block") for f in all_fields.values()): return super().to_dict(blocks=blocks, strict=strict) _exclude = {"name", "parent", "_parent", "dims", "filename", "workspace", "strict"} result: dict = {} - for f in all_fields: - if f.name in _exclude or f.init is False: + for name, f in all_fields.items(): + if name in _exclude or f.init is False: continue - block = f.metadata.get("block") + meta = f.json_schema_extra or {} + block = meta.get("block") if not block: continue - key = f.alias if (f.alias and f.name.startswith("_")) else f.name + key = f.alias if (f.alias and name.startswith("_")) else name val = getattr(self, key, None) if blocks: result.setdefault(block, {})[key] = val @@ -285,12 +357,14 @@ def to_dict(self, blocks: bool = False, strict: bool = False) -> dict: def to_dataframe(self) -> pd.DataFrame: """Return stress period data as a tidy DataFrame. Zero cost if not called.""" + import dataclasses as _dc + _spd = self.__dict__.get("_stress_period_data") if not _spd: return pd.DataFrame() frames = [] for kper in sorted(_spd): - df = pd.DataFrame([attrs.asdict(row) for row in _spd[kper]]) + df = pd.DataFrame([_dc.asdict(row) for row in _spd[kper]]) df.insert(0, "kper", kper) frames.append(df) return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame() @@ -315,13 +389,23 @@ def from_dataframe(self, df: pd.DataFrame) -> None: spd: dict[int, list] = {} for kper, group in df.groupby("kper"): group = group.drop(columns=["kper"]) - spd[int(kper)] = [item_cls(**row) for row in group.to_dict("records")] + rows = [] + for row in group.to_dict("records"): + # A missing optional column (e.g. boundname) round-trips + # through pandas as NaN, not absent -- drop it so the + # Item class's own default applies instead of failing + # pydantic's real type validation (unlike attrs, which + # applied none here and silently accepted a stray float + # in a str-typed field). + rows.append(item_cls(**{k: v for k, v in row.items() if not pd.isna(v)})) + spd[int(kper)] = rows self.__dict__["_stress_period_data"] = spd def _period_item_cls(self) -> "type[Item] | tuple[type[Item], ...]": - for f in attrs.fields(type(self)): # type: ignore[arg-type] - if f.metadata.get("fill_forward"): - item_cls = item_list_type(f.type) + for f in type(self).__pydantic_fields__.values(): + meta = f.json_schema_extra or {} + if meta.get("fill_forward"): + item_cls = item_list_type(f.annotation) if item_cls is not None: return item_cls raise ValueError(f"{type(self).__name__} has no period Item-list field") @@ -365,14 +449,13 @@ def to_xarray(self) -> "xr.Dataset": # type: ignore[override] ``attrs_to_dataset()`` finds -- empty for a package with no griddata fields of its own. """ - import attrs as _attrs - - fields = _attrs.fields(type(self)) # type: ignore[arg-type] + fields = type(self).__pydantic_fields__ for _block in ("griddata", "period"): data_vars = { - a.name: self.to_dataarray(a.name) - for a in fields - if a.metadata.get("block") == _block and getattr(self, a.name) is not None + name: self.to_dataarray(name) + for name, f in fields.items() + if (f.json_schema_extra or {}).get("block") == _block + and getattr(self, name) is not None } if data_vars: return xr.Dataset(data_vars) diff --git a/flopy4/mf6/prt/__init__.py b/flopy4/mf6/prt/__init__.py index 589ae7ad..e3fc66be 100644 --- a/flopy4/mf6/prt/__init__.py +++ b/flopy4/mf6/prt/__init__.py @@ -1,11 +1,11 @@ from typing import ClassVar, Optional -import attrs from flopy.discretization.structuredgrid import StructuredGrid from flopy.discretization.vertexgrid import VertexGrid +from pydantic.dataclasses import dataclass from flopy4.mf6.gwf.disbase import DisBase -from flopy4.mf6.model import Model +from flopy4.mf6.model import CFG, Model from flopy4.mf6.prt.dis import Dis from flopy4.mf6.prt.disv import Disv from flopy4.mf6.prt.fmi import Fmi @@ -36,7 +36,7 @@ def convert_grid(value): ] -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Prt(Model): dfn_name: ClassVar[str] = "prt-nam" @@ -48,7 +48,7 @@ class Prt(Model): fmi: Fmi | None = field(block="packages", default=None) mip: Mip | None = field(block="packages", default=None) oc: Oc | None = field(block="packages", default=None) - prp: list[Prp] = field(block="packages", default=attrs.Factory(list)) + prp: list[Prp] = field(block="packages", default_factory=list) @property def grid(self): diff --git a/flopy4/mf6/prt/dis.py b/flopy4/mf6/prt/dis.py index 95174895..e49c593f 100644 --- a/flopy4/mf6/prt/dis.py +++ b/flopy4/mf6/prt/dis.py @@ -1,15 +1,15 @@ from typing import ClassVar, Optional -import attrs import numpy as np from numpy.typing import NDArray +from pydantic.dataclasses import dataclass -from flopy4.mf6.gwf.disbase import DisBase +from flopy4.mf6.gwf.disbase import CFG, DisBase from flopy4.mf6.spec import field from flopy4.mf6.utils.grid import StructuredGrid -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Dis(DisBase): dfn_name: ClassVar[str] = "prt-dis" @@ -23,7 +23,7 @@ class Dis(DisBase): nlay: int = field(default=1, block="dimensions") ncol: int = field(default=2, block="dimensions") nrow: int = field(default=2, block="dimensions") - delr: NDArray[np.float64] = field( + delr: Optional[NDArray[np.float64]] = field( default=1.0, longname="spacing along a row", block="griddata", @@ -31,7 +31,7 @@ class Dis(DisBase): layered=False, netcdf=False, ) - delc: NDArray[np.float64] = field( + delc: Optional[NDArray[np.float64]] = field( default=1.0, longname="spacing along a column", block="griddata", @@ -39,7 +39,7 @@ class Dis(DisBase): layered=False, netcdf=False, ) - top: NDArray[np.float64] = field( + top: Optional[NDArray[np.float64]] = field( default=1.0, longname="cell top elevation", block="griddata", @@ -47,7 +47,7 @@ class Dis(DisBase): layered=False, netcdf=False, ) - botm: NDArray[np.float64] = field( + botm: Optional[NDArray[np.float64]] = field( default=0.0, longname="cell bottom elevation", block="griddata", @@ -64,12 +64,12 @@ class Dis(DisBase): netcdf=False, ) - def __attrs_post_init__(self): + def __post_init__(self): self.nodes = self.ncol * self.nrow * self.nlay self.ncpl = self.ncol * self.nrow self.nvert = (self.ncol + 1) * (self.nrow + 1) self._coerce_griddata() - super().__attrs_post_init__() + super().__post_init__() def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/prt/disv.py b/flopy4/mf6/prt/disv.py index 2cda2753..f0091a60 100644 --- a/flopy4/mf6/prt/disv.py +++ b/flopy4/mf6/prt/disv.py @@ -1,28 +1,29 @@ from typing import ClassVar, Optional -import attrs import numpy as np from numpy.typing import NDArray +from pydantic import Field, field_validator +from pydantic.dataclasses import dataclass -from flopy4.mf6.gwf.disbase import DisBase +from flopy4.mf6.gwf.disbase import CFG, DisBase from flopy4.mf6.item import Item from flopy4.mf6.spec import field from flopy4.mf6.utils.grid import VertexGrid -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Disv(DisBase): dfn_name: ClassVar[str] = "prt-disv" - @attrs.define(slots=False) + @dataclass(config=CFG) class Cell2dRecord: - icell2d: int = attrs.field() - xc: float = attrs.field() - yc: float = attrs.field() - ncvert: int = attrs.field() - icvert: tuple[int, ...] = attrs.field() + icell2d: int = Field() + xc: float = Field() + yc: float = Field() + ncvert: int = Field() + icvert: tuple[int, ...] = Field() - @attrs.define + @dataclass(config=CFG) class Vertices(Item): iv: int xv: float @@ -38,7 +39,7 @@ class Vertices(Item): nlay: int = field(default=0, block="dimensions") ncpl: int = field(default=0, block="dimensions") nvert: int = field(default=0, block="dimensions") - top: NDArray[np.float64] = field( + top: Optional[NDArray[np.float64]] = field( default=None, longname="model top elevation", block="griddata", @@ -46,7 +47,7 @@ class Vertices(Item): layered=False, netcdf=False, ) - botm: NDArray[np.float64] = field( + botm: Optional[NDArray[np.float64]] = field( default=None, longname="model bottom elevation", block="griddata", @@ -62,14 +63,32 @@ class Vertices(Item): layered=True, netcdf=False, ) - iv: Optional[NDArray[np.int64]] = attrs.field(default=None) - xv: Optional[NDArray[np.float64]] = attrs.field(default=None) - yv: Optional[NDArray[np.float64]] = attrs.field(default=None) + iv: Optional[NDArray[np.int64]] = Field(default=None) + xv: Optional[NDArray[np.float64]] = Field(default=None) + yv: Optional[NDArray[np.float64]] = Field(default=None) + + # iv/xv/yv are declared NDArray-typed but commonly constructed from a + # plain list/tuple (see from_grid() below) -- unlike Package's own + # griddata fields, these carry no block="griddata"/shape= metadata, so + # Package._coerce_arrays' shape-driven check doesn't reach them. Same + # underlying gap as Tdis.perlen/nstp/tsmult: attrs never validated the + # declared NDArray type against an actual list default/override at + # all; pydantic does, so this needs its own small mode="before" fix. + @field_validator("iv", mode="before") + @classmethod + def _coerce_iv(cls, v): + return v if v is None or isinstance(v, np.ndarray) else np.asarray(v, dtype=np.int64) + + @field_validator("xv", "yv", mode="before") + @classmethod + def _coerce_xv_yv(cls, v): + return v if v is None or isinstance(v, np.ndarray) else np.asarray(v, dtype=np.float64) + vertices: Optional[list[Vertices]] = field(default=None, block="vertices") - cell2ddata: Optional[list] = attrs.field(default=None) + cell2ddata: Optional[list] = Field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") - def __attrs_post_init__(self): + def __post_init__(self): if self.iv is not None and (not isinstance(self.iv, np.ndarray)): object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) if self.xv is not None and (not isinstance(self.xv, np.ndarray)): @@ -94,7 +113,7 @@ def __attrs_post_init__(self): self.nrow = 0 self.ncol = 0 self._coerce_griddata() - super().__attrs_post_init__() + super().__post_init__() def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/prt/fmi.py b/flopy4/mf6/prt/fmi.py index 449be91d..2758c363 100644 --- a/flopy4/mf6/prt/fmi.py +++ b/flopy4/mf6/prt/fmi.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Fmi(Package): dfn_name: ClassVar[str] = "prt-fmi" diff --git a/flopy4/mf6/prt/mip.py b/flopy4/mf6/prt/mip.py index 83566432..00289481 100644 --- a/flopy4/mf6/prt/mip.py +++ b/flopy4/mf6/prt/mip.py @@ -1,14 +1,14 @@ # autogenerated file, do not modify from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, IntArrayLike -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Mip(Package): dfn_name: ClassVar[str] = "prt-mip" diff --git a/flopy4/mf6/prt/oc.py b/flopy4/mf6/prt/oc.py index 32c4ceeb..bbb57afe 100644 --- a/flopy4/mf6/prt/oc.py +++ b/flopy4/mf6/prt/oc.py @@ -2,51 +2,52 @@ from pathlib import Path from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Oc(Package): dfn_name: ClassVar[str] = "prt-oc" - @attrs.define + @dataclass(config=CFG) class Tracktimes(Item): time: float = field() - @attrs.define + @dataclass(config=CFG) class All(Item): _keyword: ClassVar[str] = "all" - @attrs.define + @dataclass(config=CFG) class First(Item): _keyword: ClassVar[str] = "first" - @attrs.define + @dataclass(config=CFG) class Last(Item): _keyword: ClassVar[str] = "last" - @attrs.define + @dataclass(config=CFG) class Frequency(Item): _keyword: ClassVar[str] = "frequency" frequency: int = field() - @attrs.define + @dataclass(config=CFG) class Steps(Item): _keyword: ClassVar[str] = "steps" steps: tuple = field(default=(), array=True) - @attrs.define + @dataclass(config=CFG) class Save(Item): _keyword: ClassVar[str] = "save" rtype: Union[float, str] = field() ocsetting: "Oc.All | Oc.First | Oc.Last | Oc.Frequency | Oc.Steps" = field() - @attrs.define + @dataclass(config=CFG) class Print(Item): _keyword: ClassVar[str] = "print" rtype: Union[float, str] = field() @@ -146,12 +147,12 @@ class Print(Item): optional=True, longname="number of particle tracking times", ) - tracktimes: Optional[list[Tracktimes]] = field( + tracktimes: Optional[SkipValidation[list[Tracktimes]]] = field( default=None, block="tracktimes", auto_from="tracktimes", ) - _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[_StressPeriodDataItem]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/prt/prp.py b/flopy4/mf6/prt/prp.py index 4400cb22..b9f01b02 100644 --- a/flopy4/mf6/prt/prp.py +++ b/flopy4/mf6/prt/prp.py @@ -2,21 +2,22 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Prp(Package): dfn_name: ClassVar[str] = "prt-prp" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class Packagedata(Item): irptno: int = field(index=True, pk=True) cellid: tuple = field(cellid=True) @@ -26,33 +27,33 @@ class Packagedata(Item): aux: tuple = () boundname: Optional[str] = field(default=None, optional=True) - @attrs.define + @dataclass(config=CFG) class Releasetimes(Item): time: float = field() - @attrs.define + @dataclass(config=CFG) class All(Item): _keyword: ClassVar[str] = "all" - @attrs.define + @dataclass(config=CFG) class First(Item): _keyword: ClassVar[str] = "first" - @attrs.define + @dataclass(config=CFG) class Last(Item): _keyword: ClassVar[str] = "last" - @attrs.define + @dataclass(config=CFG) class Frequency(Item): _keyword: ClassVar[str] = "frequency" frequency: int = field() - @attrs.define + @dataclass(config=CFG) class Steps(Item): _keyword: ClassVar[str] = "steps" steps: tuple = field(default=(), array=True) - @attrs.define + @dataclass(config=CFG) class Fraction(Item): _keyword: ClassVar[str] = "fraction" fraction: tuple = field(default=(), array=True) @@ -168,17 +169,17 @@ class Fraction(Item): block="dimensions", longname="number of particle release times", ) - packagedata: Optional[list[Packagedata]] = field( + packagedata: Optional[SkipValidation[list[Packagedata]]] = field( default=None, block="packagedata", auto_from="packagedata", ) - releasetimes: Optional[list[Releasetimes]] = field( + releasetimes: Optional[SkipValidation[list[Releasetimes]]] = field( default=None, block="releasetimes", auto_from="releasetimes", ) - _stress_period_data: Optional[dict[int, list[_StressPeriodDataItem]]] = field( + _stress_period_data: Optional[SkipValidation[dict[int, list[_StressPeriodDataItem]]]] = field( alias="stress_period_data", default=None, repr=False, diff --git a/flopy4/mf6/pts.py b/flopy4/mf6/pts.py index dc3a0b29..d4808b87 100644 --- a/flopy4/mf6/pts.py +++ b/flopy4/mf6/pts.py @@ -2,24 +2,25 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic import Field +from pydantic.dataclasses import dataclass from flopy4.mf6._types import _optional_path from flopy4.mf6.record import Record -from flopy4.mf6.solution import Solution +from flopy4.mf6.solution import CFG, Solution from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Pts(Solution): dfn_name: ClassVar[str] = "sln-pts" slntype: ClassVar[str] = "pts" - @attrs.define + @dataclass(config=CFG) class NoPtc(Record): _keyword: ClassVar[str] = "no_ptc" - no_ptc_option: Optional[str] = attrs.field(default=None) + no_ptc_option: Optional[str] = Field(default=None) print_option: Optional[str] = field( default=None, diff --git a/flopy4/mf6/record.py b/flopy4/mf6/record.py index bc41bc86..e0295418 100644 --- a/flopy4/mf6/record.py +++ b/flopy4/mf6/record.py @@ -9,90 +9,93 @@ another) rather than flattening the nested one's fields into itself -- see _nested_class, inferred from the field's own type annotation rather than a declared flag, and make.py's _build_record_class_specs. + +Generated Record/Item subclasses are `pydantic.dataclasses.dataclass`, not +`attrs.define` -- `Record.fields()`/`_nested_class()` below read +`__pydantic_fields__`/`FieldInfo.json_schema_extra` accordingly. A nested/ +composed field's annotation (e.g. `Headprint.formatrecord: "Oc.Format"`) is +a forward-reference string naming a SIBLING class inside the same enclosing +package class -- unresolvable via any module-global lookup at class-body- +execution time (Python class bodies can't see sibling names in an enclosing +class's scope). Unlike attrs (which leaves this unresolved forever, forcing +a qualname-walking string resolver), pydantic resolves it lazily and +self-heals on first construction -- `Record.fields()`'s guarded +`rebuild_dataclass()` call handles the one case that doesn't self-heal on +its own: something (like `from_tokens()`) inspecting a class's fields +before any instance of it has ever been built. """ -import sys +from __future__ import annotations + import types -from functools import lru_cache from pathlib import Path -from typing import Any, Union, cast, get_args, get_origin - -import attrs +from typing import Any, Union, get_args, get_origin +from pydantic import ConfigDict +from pydantic.dataclasses import rebuild_dataclass -def _resolve_sibling_class(cls: type, name: str) -> Any | None: - """Resolve a bare sibling class name one level up from `cls` in - `__qualname__`, where every generated flat-sibling class (composed - Records, keystring-union arms) lives regardless of DFN nesting depth. - No type check here -- callers (`_nested_class`, item.py's - `_nested_union_classes`) apply their own, against different base - classes. - - Must resolve at runtime: a class body can't see sibling names from an - enclosing scope, which is why the qualified string form - (``"Oc.Format"``) exists at all -- purely for mypy. - """ - obj = sys.modules[cls.__module__] - for part in cls.__qualname__.split(".")[:-1]: - obj = getattr(obj, part) - return getattr(obj, name, None) +CFG = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, extra="forbid") -@lru_cache(maxsize=None) -def _nested_class(cls: type, type_str: str) -> "type[Record] | None": - """If a field's raw type annotation (e.g. ``"Format"`` or - ``"Optional[Oc.Format]"``) names a Record subclass, return it; else - None. Resolvability against a real Record subclass is itself the - signal -- no declared "is this nested" flag needed. +def _nested_class(cls: type, annotation: Any) -> "type[Record] | None": + """If a field's (already-resolved) annotation is -- or wraps, via + `Optional[...]` -- a Record subclass, return it; else None. + Resolvability against a real Record subclass is itself the signal, no + declared "is this nested" flag needed. - Cached since to_tokens/from_tokens call this per field, often - repeatedly while parsing many rows. + Replaces the attrs original's qualname-walking string resolver + entirely: by the time `Record.fields()` has run, `annotation` + (a pydantic `FieldInfo.annotation`) IS the real class object already, + not a string -- no `sys.modules`/qualname lookup needed. """ - name = type_str - if name.startswith("Optional[") and name.endswith("]"): - name = name[len("Optional[") : -1] - name = name.rsplit(".", 1)[-1] - resolved = _resolve_sibling_class(cls, name) - return resolved if isinstance(resolved, type) and issubclass(resolved, Record) else None + args = get_args(annotation) + candidate = next((a for a in args if a is not type(None)), annotation) + return candidate if isinstance(candidate, type) and issubclass(candidate, Record) else None -def _is_bool_field(f: attrs.Attribute) -> bool: - t = f.type +def _is_bool_field(finfo: Any) -> bool: + t = finfo.annotation origin = get_origin(t) if origin is types.UnionType or origin is Union: t = next((a for a in get_args(t) if a is not type(None)), t) - return t in (bool, "bool") + return t is bool -def _coerce(token: Any, f: attrs.Attribute) -> Any: +def _coerce(token: Any, finfo: Any) -> Any: """Cast a raw token to a field's declared type (time_series falls back to the raw string if it isn't a float). Only Optional[X] (a single non-None union arm) is unwrapped -- a genuine multi-type union like Union[float, str] is deliberately ambiguous and left as the raw token.""" - if f.metadata.get("time_series"): + meta = finfo.json_schema_extra or {} + if isinstance(meta, dict) and meta.get("time_series"): try: return float(token) except (ValueError, TypeError): return str(token) - t = f.type + t = finfo.annotation origin = get_origin(t) if origin is types.UnionType or origin is Union: args = [a for a in get_args(t) if a is not type(None)] if len(args) != 1: return token t = args[0] - if t in (int, "int"): + if t is int: return int(float(str(token))) - if t in (float, "float"): + if t is float: return float(token) - if t in (Path, "Path"): + if t is Path: return Path(token) return token -def _is_list_field(f: attrs.Attribute) -> bool: +def _is_tagged(finfo: Any) -> bool: + meta = finfo.json_schema_extra or {} + return bool(isinstance(meta, dict) and meta.get("tagged")) + + +def _is_list_field(finfo: Any) -> bool: """True for ``list[X]`` or ``Optional[list[X]]``""" - t = f.type + t = finfo.annotation origin = get_origin(t) if origin is types.UnionType or origin is Union: t = next((a for a in get_args(t) if a is not type(None)), t) @@ -100,9 +103,9 @@ def _is_list_field(f: attrs.Attribute) -> bool: return origin is list -def _list_elem_coerce(token: Any, f: attrs.Attribute) -> Any: +def _list_elem_coerce(token: Any, finfo: Any) -> Any: """Coerce one token to a list field's declared element type.""" - t = f.type + t = finfo.annotation origin = get_origin(t) if origin is types.UnionType or origin is Union: t = next((a for a in get_args(t) if a is not type(None)), t) @@ -122,29 +125,38 @@ def _tokens(name: str, value: Any) -> list: return [name.upper(), value] -def _consume_tagged(tokens: list, i: int, f: attrs.Attribute) -> "tuple[Any, int] | None": - """Match field f's tagged keyword at tokens[i]; a bool field needs no - value token, anything else does. None if unmatched or value missing.""" - if str(tokens[i]).upper() != f.name.upper(): +def _consume_tagged(tokens: list, i: int, name: str, finfo: Any) -> "tuple[Any, int] | None": + """Match field `name`'s tagged keyword at tokens[i]; a bool field needs + no value token, anything else does. None if unmatched or value + missing.""" + if str(tokens[i]).upper() != name.upper(): return None - if _is_bool_field(f): + if _is_bool_field(finfo): return True, 1 if i + 1 >= len(tokens): return None - return _coerce(tokens[i + 1], f), 2 + return _coerce(tokens[i + 1], finfo), 2 class Record: """Mixin for record types.""" @classmethod - def fields(cls: type["Record"]) -> list[attrs.Attribute]: - """Record (or Item) class' fields, in declaration order.""" - fields = attrs.fields(cast(type[attrs.AttrsInstance], cls)) - return [f for f in fields if not f.name.startswith("_")] + def fields(cls) -> dict[str, Any]: + """Record (or Item) class' non-private fields, in declaration + order, keyed by name -- each value a pydantic `FieldInfo`. + + Guards with a `rebuild_dataclass()` call so a nested/composed + field's annotation is the real class, not a stale `ForwardRef`, + even when called before any instance of `cls` has ever been + constructed (exactly what `from_tokens()` does). + """ + if not cls.__pydantic_complete__: # type: ignore[attr-defined] + rebuild_dataclass(cls, force=True, _parent_namespace_depth=4) # type: ignore[arg-type] + return {n: f for n, f in cls.__pydantic_fields__.items() if not n.startswith("_")} # type: ignore[attr-defined] @classmethod - def keyword(cls: type["Record"]) -> str: + def keyword(cls) -> str: return vars(cls).get("_keyword", "") def to_tokens(self) -> tuple: @@ -154,19 +166,19 @@ def to_tokens(self) -> tuple: for tok in vars(cls).get("_extra_tokens", ()): tokens.append(tok) fields = cls.fields() - tagged = [a for a in fields if a.metadata.get("tagged")] - untagged = [a for a in fields if not a.metadata.get("tagged")] - for a in tagged + untagged: - v = getattr(self, a.name) + tagged = [(n, f) for n, f in fields.items() if _is_tagged(f)] + untagged = [(n, f) for n, f in fields.items() if not _is_tagged(f)] + for name, finfo in tagged + untagged: + v = getattr(self, name) if v is None: continue if isinstance(v, Record): tokens.extend(v.to_tokens()) - elif a.metadata.get("tagged"): - tokens.extend(_tokens(a.name, v)) + elif _is_tagged(finfo): + tokens.extend(_tokens(name, v)) elif isinstance(v, bool): if v: - tokens.append(a.name.upper()) + tokens.append(name.upper()) elif isinstance(v, (list, tuple)): tokens.extend(v) else: @@ -174,7 +186,7 @@ def to_tokens(self) -> tuple: return tuple(tokens) @classmethod - def from_tokens(cls, tokens: str | list[str]) -> "Record": + def from_tokens(cls, tokens: "str | list[str]") -> "Record": """Parse a token string/list back into an instance. Tagged fields are matched by keyword wherever it appears; whatever's @@ -196,10 +208,9 @@ def from_tokens(cls, tokens: str | list[str]) -> "Record": fields = cls.fields() - def _nested(f: attrs.Attribute) -> "type[Record] | None": - return _nested_class(cast(type, cls), f.type) if isinstance(f.type, str) else None - - nested_fields = [f for f in fields if _nested(f) is not None] + nested_fields = [ + (n, f) for n, f in fields.items() if _nested_class(cls, f.annotation) is not None + ] if nested_fields: # A record composed of nested record(s) has, in the current # corpus, no other fields of its own once _keyword/_extra_tokens @@ -208,53 +219,53 @@ def _nested(f: attrs.Attribute) -> "type[Record] | None": f"{cls.__name__}: exactly one nested record field, with no plain " "fields of its own, is the only shape supported so far" ) - nf = nested_fields[0] - nested_cls = _nested(nf) + nf_name, nf_finfo = nested_fields[0] + nested_cls = _nested_class(cls, nf_finfo.annotation) assert nested_cls is not None - return cls(**{nf.name: nested_cls.from_tokens(tokens)}) + return cls(**{nf_name: nested_cls.from_tokens(tokens)}) - tagged = {f.name.upper(): f for f in fields if f.metadata.get("tagged")} - untagged = [f for f in fields if not f.metadata.get("tagged")] + tagged = {n.upper(): (n, f) for n, f in fields.items() if _is_tagged(f)} + untagged = [(n, f) for n, f in fields.items() if not _is_tagged(f)] kwargs: dict = {} consumed: set[int] = set() i = 0 while i < len(tokens): - f = tagged.get(str(tokens[i]).upper()) - result = None if f is None else _consume_tagged(tokens, i, f) - if f is None or result is None: + entry = tagged.get(str(tokens[i]).upper()) + result = None if entry is None else _consume_tagged(tokens, i, entry[0], entry[1]) + if entry is None or result is None: i += 1 continue val, width = result - kwargs[f.name] = val + kwargs[entry[0]] = val for j in range(width): consumed.add(i + j) i += width required_tagged = [ - f - for f in fields - if f.metadata.get("tagged") and f.default is attrs.NOTHING and f.name not in kwargs + (n, f) + for n, f in fields.items() + if _is_tagged(f) and f.is_required() and n not in kwargs ] positional_queue = required_tagged + untagged remaining = [t for j, t in enumerate(tokens) if j not in consumed] - list_field = next((f for f in positional_queue if _is_list_field(f)), None) + list_field = next((nf for nf in positional_queue if _is_list_field(nf[1])), None) if list_field is not None: idx = positional_queue.index(list_field) assert idx == len(positional_queue) - 1, ( - f"{cls.__name__}.{list_field.name}: a list-typed record field must be " + f"{cls.__name__}.{list_field[0]}: a list-typed record field must be " "the last positional field -- it consumes all remaining tokens" ) scalar_fields = positional_queue[:idx] - for f, tok in zip(scalar_fields, remaining): - kwargs[f.name] = _coerce(tok, f) - kwargs[list_field.name] = [ - _list_elem_coerce(tok, list_field) for tok in remaining[len(scalar_fields) :] + for (name, finfo), tok in zip(scalar_fields, remaining): + kwargs[name] = _coerce(tok, finfo) + kwargs[list_field[0]] = [ + _list_elem_coerce(tok, list_field[1]) for tok in remaining[len(scalar_fields) :] ] else: - for f, tok in zip(positional_queue, remaining): - kwargs[f.name] = _coerce(tok, f) + for (name, finfo), tok in zip(positional_queue, remaining): + kwargs[name] = _coerce(tok, finfo) return cls(**kwargs) diff --git a/flopy4/mf6/simulation.py b/flopy4/mf6/simulation.py index 3e5da181..b5acdba0 100644 --- a/flopy4/mf6/simulation.py +++ b/flopy4/mf6/simulation.py @@ -2,10 +2,10 @@ from typing import ClassVar from warnings import warn -import attrs from modflow_devtools.misc import cd, run_cmd +from pydantic.dataclasses import dataclass -from flopy4.mf6.context import Context, update_child_attr +from flopy4.mf6.context import CFG, Context from flopy4.mf6.exchange import Exchange from flopy4.mf6.model import Model from flopy4.mf6.solution import Solution @@ -22,22 +22,20 @@ def convert_time(value): raise TypeError(f"Expected Time or Tdis, got {type(value)}") -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Simulation(Context): dfn_name: ClassVar[str] = "sim-nam" - tdis: Tdis = field(block="timing", converter=convert_time, default=attrs.Factory(Tdis)) - models: dict[str, Model] = field(block="models", default=attrs.Factory(dict)) - exchanges: dict[str, Exchange] = field(block="exchanges", default=attrs.Factory(dict)) - solutions: dict[str, Solution] = field(block="solutiongroup", default=attrs.Factory(dict)) + tdis: Tdis = field(block="timing", converter=convert_time, default_factory=Tdis) + models: dict[str, Model] = field(block="models", default_factory=dict) + exchanges: dict[str, Exchange] = field(block="exchanges", default_factory=dict) + solutions: dict[str, Solution] = field(block="solutiongroup", default_factory=dict) def default_filename(self) -> str: return "mfsim.nam" - def __attrs_post_init__(self): - from attrs import fields_dict - - super().__attrs_post_init__() + def __post_init__(self): + super().__post_init__() if self.filename != "mfsim.nam": if self.filename is not None: warn( @@ -45,9 +43,14 @@ def __attrs_post_init__(self): UserWarning, ) self.filename = "mfsim.nam" - fields = fields_dict(type(self)) - field = fields["workspace"] - update_child_attr(self, field, self.workspace) + # Re-propagate workspace to Simulation's own children (models/ + # exchanges/solutions/tdis) -- Context.__post_init__ (already run, + # via super() above) only saw whatever was attached at ITS point + # in the post-init chain; re-assigning through the property setter + # (attrs' on_setattr=update_child_attr, ported -- see Context. + # workspace) re-runs the propagation now that every field on this + # concrete Simulation instance is attached. + self.workspace = self.workspace @property def time(self) -> Time: diff --git a/flopy4/mf6/solution.py b/flopy4/mf6/solution.py index 34bee4fe..b68b868c 100644 --- a/flopy4/mf6/solution.py +++ b/flopy4/mf6/solution.py @@ -1,15 +1,16 @@ from abc import ABC from typing import ClassVar -import attrs +from pydantic import Field +from pydantic.dataclasses import dataclass -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Solution(Package, ABC): slntype: ClassVar[str] = "sln" - models: list[str] = attrs.field(default=attrs.Factory(list)) + models: list[str] = Field(default_factory=list) def default_filename(self) -> str: return f"solution.{self.slntype.lower()}" diff --git a/flopy4/mf6/spec.py b/flopy4/mf6/spec.py index 60910ec0..9682c570 100644 --- a/flopy4/mf6/spec.py +++ b/flopy4/mf6/spec.py @@ -1,5 +1,5 @@ """ -Wrap `attrs` specification utilities for MF6. +Wrap `pydantic` specification utilities for MF6. These include field decorators and introspection functions. """ @@ -7,27 +7,31 @@ import types from datetime import datetime from pathlib import Path -from typing import Literal, Union, get_args, get_origin +from typing import Any, Literal, Union, get_args, get_origin -import attrs import numpy as np -from attrs import NOTHING, Attribute +from pydantic import Field +from pydantic.fields import FieldInfo from flopy4.mf6._types import FloatArrayLike, IntArrayLike from flopy4.spec import fields_dict as flopy_fields_dict FieldType = Literal["keyword", "integer", "double", "string", "list", "record"] +# Sentinel distinguishing "no default given" (a required field) from a +# real default of `None` -- pydantic.Field()'s own "no default" state is +# just not passing `default=` at all, so this marks that case through +# field()/path()'s own default=... parameter (mirrors attrs.NOTHING's role). +_UNSET = object() + def field( - default=NOTHING, - validator=None, + default=_UNSET, + default_factory=None, converter=None, repr=True, - eq=True, init=True, metadata=None, - on_setattr=None, alias: str | None = None, block: str | None = None, longname: str | None = None, @@ -47,8 +51,18 @@ def field( tagged: bool = False, array: bool = False, ): - """Define a field: always a plain ``attrs.field()``.""" + """Define a field: always a plain ``pydantic.Field()``. + + ``converter``: stashed into the field's own `json_schema_extra` + (`metadata["converter"]`) rather than passed to `Field()` directly -- + pydantic has no per-field `converter=` hook the way attrs does. + Applied uniformly by one shared `field_validator("*", mode="before")` + on `Component` (see `flopy4.mf6.component.Component._apply_converter`) + instead of one per field. + """ metadata = metadata or {} + if converter is not None: + metadata["converter"] = converter if block: metadata["block"] = block if longname: @@ -85,38 +99,32 @@ def field( metadata["tagged"] = True if array: metadata["array"] = True - return attrs.field( - default=default, - validator=validator, - converter=converter, - repr=repr, - eq=eq, - init=init, - on_setattr=on_setattr, - metadata=metadata, - alias=alias, - ) + kwargs: dict[str, Any] = dict(repr=repr, init=init, json_schema_extra=metadata) + if alias is not None: + kwargs["alias"] = alias + if default_factory is not None: + kwargs["default_factory"] = default_factory + elif default is not _UNSET: + kwargs["default"] = default + return Field(**kwargs) FileDirection = Literal[None, "in", "out"] def path( - default=NOTHING, - validator=None, + default=_UNSET, converter=None, repr=True, - eq=True, init=True, metadata=None, - on_setattr=None, block: str | None = None, direction: FileDirection | None = None, longname: str | None = None, optional: bool = False, keyword: str | None = None, ): - """Define a path field: always a plain ``attrs.field()``. + """Define a path field: always a plain ``pydantic.Field()``. ``keyword``: the file record's trigger keyword, stored as ``_keyword`` metadata (lowercase, same convention as a Record class's own @@ -124,8 +132,14 @@ def path( in ``TS6 FILEIN `` (block level, where it's also the row's key on ingress -- not derivable from the py name, ts_filerecord → ts_file) or ``tab6`` in a LAK tables row (read by Item.to_tokens()/from_tokens()). + + ``converter``: see `field()`'s own docstring -- stashed in metadata, + applied by the shared `Component`-level validator (Item/Record-level + path fields go through `Record`'s own equivalent validator instead). """ metadata = metadata or {} + if converter is not None: + metadata["converter"] = converter if keyword: metadata["_keyword"] = keyword.lower() if block: @@ -136,22 +150,16 @@ def path( metadata["longname"] = longname if optional: metadata["optional"] = True - return attrs.field( - default=default, - validator=validator, - converter=converter, - repr=repr, - eq=eq, - init=init, - on_setattr=on_setattr, - metadata=metadata, - ) + kwargs: dict[str, Any] = dict(repr=repr, init=init, json_schema_extra=metadata) + if default is not _UNSET: + kwargs["default"] = default + return Field(**kwargs) -Block = dict[str, Attribute] +Block = dict[str, FieldInfo] -def blocks(cls) -> list[list[Attribute]]: +def blocks(cls) -> list[list[FieldInfo]]: """Return an ordered list of blocks for a component class.""" return [list(v.values()) for v in blocks_dict(cls).values()] @@ -160,30 +168,34 @@ def blocks_dict(cls) -> dict[str, Block]: """ Return an ordered dictionary of blocks for a component class, whose keys are block names. Each block is a map from variable - (field) name to `attrs.Attribute`. + (field) name to `FieldInfo`. """ fields = fields_dict(cls) blocks: dict[str, Block] = {} for k, v in fields.items(): - block = v.metadata["block"] + block = v.json_schema_extra["block"] # type: ignore[index] if block not in blocks: blocks[block] = {} blocks[block][k] = v return dict(sorted(blocks.items(), key=block_sort_key)) -def fields(cls) -> list[Attribute]: +def fields(cls) -> list[FieldInfo]: """Return an ordered list of fields for a component class.""" return list(fields_dict(cls).values()) -def fields_dict(cls) -> dict[str, Attribute]: +def fields_dict(cls) -> dict[str, FieldInfo]: """ Return an ordered dictionary of fields for a component class, - whose keys are field names. Each field is an `attrs.Attribute`. + whose keys are field names. Each field is a `FieldInfo`. """ fields = flopy_fields_dict(cls) - return {k: v for k, v in fields.items() if "block" in v.metadata} + return { + k: v + for k, v in fields.items() + if isinstance(v.json_schema_extra, dict) and "block" in v.json_schema_extra + } def _ndarray_field_type(t) -> FieldType | None: diff --git a/flopy4/mf6/tdis.py b/flopy4/mf6/tdis.py index 2107c79f..44206bbb 100644 --- a/flopy4/mf6/tdis.py +++ b/flopy4/mf6/tdis.py @@ -1,21 +1,22 @@ from datetime import datetime -from typing import ClassVar, Optional +from typing import Any, ClassVar, Optional -import attrs import numpy as np from numpy.typing import ArrayLike, NDArray +from pydantic import field_validator +from pydantic.dataclasses import dataclass from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field from flopy4.mf6.utils.time import Time -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Tdis(Package): dfn_name: ClassVar[str] = "sim-tdis" - @attrs.define + @dataclass(config=CFG) class PeriodData(Item): perlen: float nstp: int @@ -24,17 +25,48 @@ class PeriodData(Item): time_units: Optional[str] = field(default=None, block="options", optional=True) start_date_time: Optional[str] = field( default=None, - converter=lambda v: v.isoformat() if isinstance(v, datetime) else v, + # A raw ISO datetime like "1970-01-01T00:00:00" tokenizes into TWO + # file tokens (e.g. [1970, "-01-01T00:00:00"]) -- the reader's + # numeric-token grammar splits at the leading digits, before the + # first embedded dash -- and a bare year like "1997" (no rest of an + # ISO string on the row at all) tokenizes as a plain int, not a + # str. Under attrs both passed through unvalidated (no type check + # on this field at all); pydantic's real Optional[str] validation + # correctly rejects a list or a bare int, so the converter (already + # needed for the datetime -> isoformat direction) also normalizes + # both back into one string here. + converter=lambda v: ( + v.isoformat() + if isinstance(v, datetime) + else "".join(str(t) for t in v) + if isinstance(v, list) + else str(v) + if isinstance(v, (int, float)) + else v + ), block="options", optional=True, ) nper: int = field(default=1, block="dimensions") - perlen: NDArray[np.float64] = attrs.field(default=1.0) - nstp: NDArray[np.int64] = attrs.field(default=1) - tsmult: NDArray[np.float64] = attrs.field(default=1.0) + perlen: NDArray[np.float64] = field(default=1.0) + nstp: NDArray[np.int64] = field(default=1) + tsmult: NDArray[np.float64] = field(default=1.0) perioddata: Optional[list[PeriodData]] = field(default=None, block="perioddata") - def __attrs_post_init__(self): + # perlen/nstp/tsmult aren't Package "griddata" block fields (no + # block="griddata" metadata), so Package._coerce_arrays' shape-driven + # check doesn't touch them -- same underlying problem though (a + # declared array type with a bare-scalar default/override), so this + # needs its own mode="before" coercion, scoped to just these 3 fields + # by name rather than metadata. + @field_validator("perlen", "nstp", "tsmult", mode="before") + @classmethod + def _coerce_to_array(cls, v: Any) -> Any: + if isinstance(v, np.ndarray): + return v + return np.asarray(v) + + def __post_init__(self): if self.perioddata: rows = [ row @@ -48,27 +80,40 @@ def __attrs_post_init__(self): object.__setattr__(self, "perlen", np.array([r.perlen for r in rows], dtype=np.float64)) object.__setattr__(self, "nstp", np.array([r.nstp for r in rows], dtype=np.int64)) object.__setattr__(self, "tsmult", np.array([r.tsmult for r in rows], dtype=np.float64)) - super().__attrs_post_init__() + super().__post_init__() return nper = self.nper + # _coerce_to_array only runs on an EXPLICITLY passed value + # (pydantic doesn't validate an unused field default unless + # validate_default=True, not set here) -- so an untouched default + # (Tdis() with no perlen=/nstp=/tsmult= at all) still arrives here + # as attrs would have always left it, a bare int/float; an + # explicit scalar/list override arrives already coerced to a 0-d/ + # plain ndarray by that validator. Both are handled below. if isinstance(self.perlen, (int, float)): object.__setattr__(self, "perlen", np.full(nper, self.perlen, dtype=np.float64)) - elif not isinstance(self.perlen, np.ndarray): - object.__setattr__(self, "perlen", np.asarray(self.perlen, dtype=np.float64)) + elif self.perlen.size == 1: + object.__setattr__(self, "perlen", np.full(nper, self.perlen.item(), dtype=np.float64)) + elif self.perlen.dtype != np.float64: + object.__setattr__(self, "perlen", self.perlen.astype(np.float64)) if isinstance(self.nstp, (int, float)): object.__setattr__(self, "nstp", np.full(nper, int(self.nstp), dtype=np.int64)) - elif not isinstance(self.nstp, np.ndarray): - object.__setattr__(self, "nstp", np.asarray(self.nstp, dtype=np.int64)) + elif self.nstp.size == 1: + object.__setattr__(self, "nstp", np.full(nper, int(self.nstp.item()), dtype=np.int64)) + elif self.nstp.dtype != np.int64: + object.__setattr__(self, "nstp", self.nstp.astype(np.int64)) if isinstance(self.tsmult, (int, float)): object.__setattr__(self, "tsmult", np.full(nper, self.tsmult, dtype=np.float64)) - elif not isinstance(self.tsmult, np.ndarray): - object.__setattr__(self, "tsmult", np.asarray(self.tsmult, dtype=np.float64)) + elif self.tsmult.size == 1: + object.__setattr__(self, "tsmult", np.full(nper, self.tsmult.item(), dtype=np.float64)) + elif self.tsmult.dtype != np.float64: + object.__setattr__(self, "tsmult", self.tsmult.astype(np.float64)) rows = [ Tdis.PeriodData(perlen=float(p), nstp=int(n), tsmult=float(t)) for p, n, t in zip(self.perlen, self.nstp, self.tsmult) ] object.__setattr__(self, "perioddata", rows) - super().__attrs_post_init__() + super().__post_init__() def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/utils/cbc_reader.py b/flopy4/mf6/utils/cbc_reader.py index 18a054cf..68666865 100644 --- a/flopy4/mf6/utils/cbc_reader.py +++ b/flopy4/mf6/utils/cbc_reader.py @@ -9,15 +9,18 @@ import numpy as np import xarray as xr import xugrid as xu -from attrs import define from flopy.discretization import StructuredGrid +from pydantic import ConfigDict +from pydantic.dataclasses import dataclass from flopy4.adapters import read_binary_grid_file from flopy4.mf6.utils.grid import get_coords from flopy4.mf6.utils.time import assign_datetime_coords +_CFG = ConfigDict(arbitrary_types_allowed=True, extra="forbid") -@define + +@dataclass(config=_CFG) class Imeth1Header: kstp: int kper: int @@ -32,7 +35,7 @@ class Imeth1Header: pos: int -@define +@dataclass(config=_CFG) class Imeth6Header: kstp: int kper: int diff --git a/flopy4/mf6/utils/codegen/filters.py b/flopy4/mf6/utils/codegen/filters.py index d4df64d2..558b22c6 100644 --- a/flopy4/mf6/utils/codegen/filters.py +++ b/flopy4/mf6/utils/codegen/filters.py @@ -461,6 +461,25 @@ def field_metadata(f: FieldV3, block_name: str) -> dict: kw["time_series"] = True if f.optional: kw["optional"] = True + if block_name == "dimensions" and f.name == "maxbound": + # Reached only for a maxbound field that build_component_spec did NOT + # skip via its _maxbound_is_computed `continue` -- i.e. any package + # whose maxbound stays a real, user-writable field rather than + # becoming a computed @property. Confirmed (via + # src/Model/ModelUtilities/BoundaryPackageExt.f90's + # BndExtType%source_dimensions) that MF6 never reads a user-supplied + # MAXBOUND at all for a READARRAYGRID ("G-variant") package -- it's + # dead input there, always overwritten with NCPL. Fixed at the real + # root cause upstream (modflow-devtools DFN migration no longer + # declares the field for those packages at all -- see + # MODFLOW-ORG/modflow-devtools issue/PR for + # gwf-chdg/drng/ghbg/rivg/welg), so this `auto_from` fallback no + # longer applies to them; it's reached today only by the Api family + # (gwf-api/gwt-api), whose maxbound has NOT been confirmed dead the + # same way -- MF6 infers it itself when left at 0/unwritten, so it + # must never be written out as a literal 0 -- see unstructure.py's + # auto_from handling. + kw["auto_from"] = "stress_period_data" if is_file_record(f): child = file_child(f) assert child is not None # is_file_record() already confirmed a File child exists @@ -619,7 +638,7 @@ def item_class( ``role="nested_union"`` column -- it qualifies that field's forward-reference union annotation (``"Oc.All | Oc.First | ..."``). - Produces a 4-space-indented ``@attrs.define`` class whose fields carry + Produces a 4-space-indented ``@dataclass(config=CFG)`` class whose fields carry real metadata (``index=``/``pk=``/``fk=``/``cellid=``/``time_series=``/ ``prefix=``/``tagged=``, via ``field()``) -- the class itself is the schema. @@ -766,7 +785,7 @@ def _field_line(col: dict, *, optional: bool) -> str: return f" {col['name']}: {py_type} = field({margs})" # A bare annotation here is equivalent to field() at runtime (both # mean "no default") -- but mypy's attrs plugin doesn't recognize - # field() (a flopy4.mf6.spec wrapper, not attrs.field itself) as a + # field() (a flopy4.mf6.spec wrapper, not pydantic.Field itself) as a # field specifier, so it can't tell field()-declared columns above # (e.g. pk=/cellid=) don't actually have a default either. Left # bare, that misreading makes mypy treat *this* column as a @@ -796,7 +815,7 @@ def _field_line(col: dict, *, optional: bool) -> str: optional_non_boundname = [col for col in optional if col["role"] != "boundname"] boundname_cols = [col for col in optional if col["role"] == "boundname"] - lines = [" @attrs.define"] + lines = [" @dataclass(config=CFG)"] lines.append(f" class {class_name}(Item):") if keyword: lines.append(f' _keyword: ClassVar[str] = "{keyword}"') diff --git a/flopy4/mf6/utils/codegen/make.py b/flopy4/mf6/utils/codegen/make.py index c42ab5ea..f7a61547 100644 --- a/flopy4/mf6/utils/codegen/make.py +++ b/flopy4/mf6/utils/codegen/make.py @@ -783,15 +783,35 @@ def _generated_imports( if typing_parts: stdlib.append(f"from typing import {', '.join(sorted(typing_parts))}") - third_party: list[str] = ["import attrs"] + # Bare pydantic Field() (as opposed to the flopy4.mf6.spec field()/ + # path() wrappers, imported separately below via _spec_parts) is only + # ever emitted by the template for spec.inner_classes -- composed + # Record fields (e.g. Oc.Headprint.formatrecord). Every top-level + # package field and every item_class()-rendered Item/Record field + # routes through field()/path() instead. Confirmed empirically: a + # generated file with no inner_classes but an unconditional Field + # import left 49 F401 (unused import) errors across the regenerated + # corpus before this was scoped to has_inner_classes. + _pydantic_parts = ["Field"] if has_inner_classes else [] + if has_period_schema: + # has_period_schema is already the OR of period_schema/block_schemas/ + # period_arms (see the call site) -- SkipValidation is needed + # whenever any Item-list field is generated (see item.py's + # item_list_type()/Package._init_item_lists() for why: pydantic + # validates a raw tuple/dict input eagerly where attrs applied none). + _pydantic_parts.append("SkipValidation") + third_party: list[str] = [] + if _pydantic_parts: + third_party.append(f"from pydantic import {', '.join(sorted(_pydantic_parts))}") + third_party.append("from pydantic.dataclasses import dataclass") if has_array: third_party.append("import numpy as np") third_party.append("from numpy.typing import NDArray") _base_imports = { - "Package": "from flopy4.mf6.package import Package", - "Solution": "from flopy4.mf6.solution import Solution", - "Context": "from flopy4.mf6.context import Context", + "Package": "from flopy4.mf6.package import CFG, Package", + "Solution": "from flopy4.mf6.solution import CFG, Solution", + "Context": "from flopy4.mf6.context import CFG, Context", } flopy4: list[str] = [_base_imports.get(base_class, _base_imports["Package"])] if has_inner_classes: @@ -1053,7 +1073,7 @@ def build_component_spec( FieldSpec( dfn_name=bp.block_name, py_name=bp.block_name, - type_annotation=f"Optional[list[{_item_cls_name}]]", + type_annotation=f"Optional[SkipValidation[list[{_item_cls_name}]]]", spec_call=_ml_field(metadata=_meta), generatable=True, ) @@ -1073,7 +1093,9 @@ def build_component_spec( FieldSpec( dfn_name="_stress_period_data", py_name="_stress_period_data", - type_annotation="Optional[dict[int, list[_StressPeriodDataItem]]]", + type_annotation=( + "Optional[SkipValidation[dict[int, list[_StressPeriodDataItem]]]]" + ), spec_call=_ml_field(alias="stress_period_data", repr_=False, metadata=_spd_meta), generatable=True, ) @@ -1084,7 +1106,7 @@ def build_component_spec( FieldSpec( dfn_name="_stress_period_data", py_name="_stress_period_data", - type_annotation="Optional[dict[int, list[StressPeriodData]]]", + type_annotation="Optional[SkipValidation[dict[int, list[StressPeriodData]]]]", spec_call=_ml_field(alias="stress_period_data", repr_=False, metadata=_spd_meta), generatable=True, ) diff --git a/flopy4/mf6/utils/codegen/templates/package.py.jinja b/flopy4/mf6/utils/codegen/templates/package.py.jinja index 6941294e..1dc41cb9 100644 --- a/flopy4/mf6/utils/codegen/templates/package.py.jinja +++ b/flopy4/mf6/utils/codegen/templates/package.py.jinja @@ -14,7 +14,7 @@ {% endfor %} -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class {{ spec.class_name }}({{ spec.base_class }}): dfn_name: ClassVar[str] = "{{ spec.dfn_name }}" @@ -27,7 +27,7 @@ class {{ spec.class_name }}({{ spec.base_class }}): {% endif %} {% for rec in spec.inner_classes %} - @attrs.define + @dataclass(config=CFG) class {{ rec.class_name }}(Record): _keyword: ClassVar[str] = "{{ rec.keyword }}" {% if rec.extra_tokens_repr %} @@ -35,17 +35,17 @@ class {{ spec.class_name }}({{ spec.base_class }}): {% endif %} {% for f in rec.fields %} {% if f.nested and f.optional %} - {{ f.py_name }}: "Optional[{{ spec.class_name }}.{{ f.type_annotation }}]" = attrs.field(default=None) + {{ f.py_name }}: "Optional[{{ spec.class_name }}.{{ f.type_annotation }}]" = Field(default=None) {% elif f.nested %} - {{ f.py_name }}: "{{ spec.class_name }}.{{ f.type_annotation }}" = attrs.field() + {{ f.py_name }}: "{{ spec.class_name }}.{{ f.type_annotation }}" = Field() {% elif f.tagged and f.optional %} - {{ f.py_name }}: {{ f.type_annotation }} = attrs.field(default=None, metadata={"tagged": True}) + {{ f.py_name }}: {{ f.type_annotation }} = Field(default=None, json_schema_extra={"tagged": True}) {% elif f.tagged %} - {{ f.py_name }}: {{ f.type_annotation }} = attrs.field(metadata={"tagged": True}) + {{ f.py_name }}: {{ f.type_annotation }} = Field(json_schema_extra={"tagged": True}) {% elif f.optional %} - {{ f.py_name }}: {{ f.type_annotation }} = attrs.field(default=None) + {{ f.py_name }}: {{ f.type_annotation }} = Field(default=None) {% else %} - {{ f.py_name }}: {{ f.type_annotation }} = attrs.field() + {{ f.py_name }}: {{ f.type_annotation }} = Field() {% endif %} {% endfor %} diff --git a/flopy4/mf6/utl/ats.py b/flopy4/mf6/utl/ats.py index 6c660332..eb368691 100644 --- a/flopy4/mf6/utl/ats.py +++ b/flopy4/mf6/utl/ats.py @@ -1,18 +1,19 @@ # autogenerated file, do not modify from typing import ClassVar, Optional -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ats(Package): dfn_name: ClassVar[str] = "utl-ats" - @attrs.define + @dataclass(config=CFG) class Perioddata(Item): iperats: int = field(index=True, pk=True) dt0: float = field() @@ -26,7 +27,7 @@ class Perioddata(Item): block="dimensions", longname="number of ATS periods", ) - perioddata: Optional[list[Perioddata]] = field( + perioddata: Optional[SkipValidation[list[Perioddata]]] = field( default=None, block="perioddata", auto_from="perioddata", diff --git a/flopy4/mf6/utl/hpc.py b/flopy4/mf6/utl/hpc.py index 0b1d0912..5eb2ac35 100644 --- a/flopy4/mf6/utl/hpc.py +++ b/flopy4/mf6/utl/hpc.py @@ -1,18 +1,19 @@ # autogenerated file, do not modify from typing import ClassVar, Optional, Union -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Hpc(Package): dfn_name: ClassVar[str] = "utl-hpc" - @attrs.define + @dataclass(config=CFG) class Partitions(Item): mname: Union[float, str] = field() mrank: int = field() @@ -23,7 +24,7 @@ class Partitions(Item): optional=True, longname="model print table to listing file", ) - partitions: Optional[list[Partitions]] = field( + partitions: Optional[SkipValidation[list[Partitions]]] = field( default=None, block="partitions", ) diff --git a/flopy4/mf6/utl/laktab.py b/flopy4/mf6/utl/laktab.py index 2b57235b..f9e3a3ee 100644 --- a/flopy4/mf6/utl/laktab.py +++ b/flopy4/mf6/utl/laktab.py @@ -1,20 +1,21 @@ # autogenerated file, do not modify from typing import ClassVar, Optional -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Laktab(Package): dfn_name: ClassVar[str] = "utl-laktab" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class Table(Item): stage: float = field() volume: float = field() @@ -31,7 +32,7 @@ class Table(Item): block="dimensions", longname="number of table columns", ) - table: Optional[list[Table]] = field( + table: Optional[SkipValidation[list[Table]]] = field( default=None, block="table", auto_from="table", diff --git a/flopy4/mf6/utl/ncf.py b/flopy4/mf6/utl/ncf.py index 60f8fa91..2d8f9196 100644 --- a/flopy4/mf6/utl/ncf.py +++ b/flopy4/mf6/utl/ncf.py @@ -1,16 +1,16 @@ from typing import ClassVar, Optional from warnings import warn -import attrs import numpy as np from numpy.typing import NDArray +from pydantic.dataclasses import dataclass from flopy4.mf6.enums import NetCDFFormat from flopy4.mf6.spec import field -from flopy4.mf6.utl.ncf_base import NcfBase +from flopy4.mf6.utl.ncf_base import CFG, NcfBase -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Ncf(NcfBase): dfn_name: ClassVar[str] = "utl-ncf" diff --git a/flopy4/mf6/utl/ncf_base.py b/flopy4/mf6/utl/ncf_base.py index 7e32d1c4..b4c1e02d 100644 --- a/flopy4/mf6/utl/ncf_base.py +++ b/flopy4/mf6/utl/ncf_base.py @@ -1,12 +1,12 @@ from typing import Optional -import attrs +from pydantic.dataclasses import dataclass -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class NcfBase(Package): """Field-type overrides for the utl-ncf CRS options; see Ncf for the rest.""" diff --git a/flopy4/mf6/utl/sfrtab.py b/flopy4/mf6/utl/sfrtab.py index 716e2613..c45332e0 100644 --- a/flopy4/mf6/utl/sfrtab.py +++ b/flopy4/mf6/utl/sfrtab.py @@ -1,20 +1,21 @@ # autogenerated file, do not modify from typing import ClassVar, Optional -import attrs +from pydantic import SkipValidation +from pydantic.dataclasses import dataclass from flopy4.mf6.item import Item -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Sfrtab(Package): dfn_name: ClassVar[str] = "utl-sfrtab" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class Table(Item): xfraction: float = field() height: float = field() @@ -30,7 +31,7 @@ class Table(Item): block="dimensions", longname="number of table columns", ) - table: Optional[list[Table]] = field( + table: Optional[SkipValidation[list[Table]]] = field( default=None, block="table", auto_from="table", diff --git a/flopy4/mf6/utl/spca.py b/flopy4/mf6/utl/spca.py index ba4dd999..3b1673a2 100644 --- a/flopy4/mf6/utl/spca.py +++ b/flopy4/mf6/utl/spca.py @@ -2,14 +2,14 @@ from pathlib import Path from typing import ClassVar, Optional -import attrs +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike, _optional_path -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.spec import field, path -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Spca(Package): dfn_name: ClassVar[str] = "utl-spca" diff --git a/flopy4/mf6/utl/tas.py b/flopy4/mf6/utl/tas.py index 5d1b36e6..5cf290fd 100644 --- a/flopy4/mf6/utl/tas.py +++ b/flopy4/mf6/utl/tas.py @@ -1,34 +1,35 @@ # autogenerated file, do not modify from typing import ClassVar, Optional -import attrs +from pydantic import Field +from pydantic.dataclasses import dataclass from flopy4.mf6._types import FloatArrayLike -from flopy4.mf6.package import Package +from flopy4.mf6.package import CFG, Package from flopy4.mf6.record import Record from flopy4.mf6.spec import field -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class Tas(Package): dfn_name: ClassVar[str] = "utl-tas" multi_package: ClassVar[bool] = True - @attrs.define + @dataclass(config=CFG) class TimeSeriesName(Record): _keyword: ClassVar[str] = "name" - time_series_name: list[str] = attrs.field() + time_series_name: list[str] = Field() - @attrs.define + @dataclass(config=CFG) class InterpolationMethod(Record): _keyword: ClassVar[str] = "method" - interpolation_method: str = attrs.field() + interpolation_method: str = Field() - @attrs.define + @dataclass(config=CFG) class Sfac(Record): _keyword: ClassVar[str] = "sfac" - sfacval: list[float] = attrs.field() + sfacval: list[float] = Field() time_series_name: Optional[TimeSeriesName] = field( default=None, diff --git a/flopy4/mf6/write_context.py b/flopy4/mf6/write_context.py index eda6138a..de04faf5 100644 --- a/flopy4/mf6/write_context.py +++ b/flopy4/mf6/write_context.py @@ -3,15 +3,18 @@ import threading from typing import TYPE_CHECKING, ClassVar, Literal, Optional -from attrs import define, field +from pydantic import ConfigDict, Field +from pydantic.dataclasses import dataclass if TYPE_CHECKING: from threading import local ArrayFormat = Literal["internal", "constant", "open/close"] +_CFG = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, extra="forbid") -@define + +@dataclass(config=_CFG) class WriteContext: """ Configuration context for writing MODFLOW 6 input files. @@ -51,12 +54,12 @@ class WriteContext: ... sim.write() """ - use_binary: bool = field(default=False) - use_netcdf: bool = field(default=False) - binary_threshold: Optional[int] = field(default=None) - float_precision: int = field(default=8) - use_relative_paths: bool = field(default=True) - array_format: Optional[ArrayFormat] = field(default=None) + use_binary: bool = Field(default=False) + use_netcdf: bool = Field(default=False) + binary_threshold: Optional[int] = Field(default=None) + float_precision: int = Field(default=8) + use_relative_paths: bool = Field(default=True) + array_format: Optional[ArrayFormat] = Field(default=None) # Class-level thread-local storage for context stack _global_context_stack: ClassVar["local"] diff --git a/flopy4/spec.py b/flopy4/spec.py index 74963cc7..54f364bc 100644 --- a/flopy4/spec.py +++ b/flopy4/spec.py @@ -1,14 +1,23 @@ """ -Wrap `attrs` specification utilities. +Generic pydantic-dataclass specification utilities. """ -from attrs import Attribute -from attrs import fields_dict as attrs_fields_dict +from typing import Any +from pydantic.dataclasses import is_pydantic_dataclass -def fields_dict(cls) -> dict[str, Attribute]: + +def is_dataclass_instance(value: Any) -> bool: + """True if `value` is an instance of a pydantic dataclass -- the + pydantic-dataclass replacement for `attrs.has(type(value))`, used + wherever generic tree-walking code needs to tell a nested + dataclass-typed value apart from a plain scalar/array leaf value.""" + return is_pydantic_dataclass(type(value)) + + +def fields_dict(cls) -> dict[str, Any]: """ Return an ordered dictionary of fields for a component class, - whose keys are field names. Each field is an `attrs.Attribute`. + whose keys are field names. Each field is a pydantic `FieldInfo`. """ - return dict(attrs_fields_dict(cls)) + return dict(getattr(cls, "__pydantic_fields__", {})) diff --git a/test/mf6/test_mf6_adapters.py b/test/mf6/test_mf6_adapters.py index 39ad671d..dd8805fe 100644 --- a/test/mf6/test_mf6_adapters.py +++ b/test/mf6/test_mf6_adapters.py @@ -71,8 +71,16 @@ def test_flopy3_model(tmp_path): inner_dvclose=1e-6, linear_acceleration="cg", ) - ims.inner_hclose = 1e-6 - ims.inner_rclose = 0.1000000 + # Legacy (pre-current-DFN) attribute names, not real Ims fields -- attrs + # (slots=False) tolerated bolting these on as plain extra instance + # attributes; pydantic's validate_assignment+extra="forbid" (needed + # elsewhere for init=False derived fields like DisBase's nlay/nrow/... -- + # pydantic itself refuses to combine init=False with extra="allow" at + # all) rejects an ordinary `ims.inner_hclose = ...` now, so this uses + # the same object.__setattr__ escape hatch the real source itself uses + # internally (e.g. Package._broadcast_griddata) to bypass validation. + object.__setattr__(ims, "inner_hclose", 1e-6) + object.__setattr__(ims, "inner_rclose", 0.1000000) ic = Ic(dims=dims) oc = Oc(dims=dims, stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", "ALL")]}) npf = Npf(dims=dims) @@ -227,8 +235,20 @@ def test_flopy3_package(tmp_path): # order), then every array field (.data_vars, in declaration order) -- # not a curated flopy3-only subset. See attrs_to_dataset()'s own # docstring (flopy4/attrs_xarray.py) for the scalar/array split rule. + # + # nlay/nrow/ncol/ncpl/nvert/nodes' relative order here differs from + # attrs: DisBase declares them all together (as init=False derived + # fields); Dis then redeclares nlay/ncol/nrow as its own real, + # required fields. attrs moves a redeclared field to its subclass + # redeclaration position; pydantic (like plain stdlib dataclasses) + # keeps it at the base class's original position instead -- confirmed + # via Dis.__pydantic_fields__ directly. Not a bug to work around, a + # real, documented library difference this list now reflects. data_list = [ "name", + "nlay", + "nrow", + "ncol", "ncpl", "nvert", "nodes", @@ -236,9 +256,6 @@ def test_flopy3_package(tmp_path): "xorigin", "yorigin", "export_array_netcdf", - "nlay", - "ncol", - "nrow", "delr", "delc", "top", diff --git a/test/mf6/test_mf6_codec.py b/test/mf6/test_mf6_codec.py index ceae13fc..6fae158d 100644 --- a/test/mf6/test_mf6_codec.py +++ b/test/mf6/test_mf6_codec.py @@ -170,7 +170,14 @@ def test_dumps_dis_with_constant_arrays(dis_with_constant_arrays): pprint(loaded) assert ["LENGTH_UNITS", "feet"] in loaded["OPTIONS"] - assert loaded["DIMENSIONS"] == [["NLAY", 2], ["NCOL", 10], ["NROW", 10]] + # NROW/NCOL order (not NCOL/NROW) here is a real, documented library + # difference, not a bug: DisBase declares nlay/nrow/ncol/... together; + # Dis redeclares nlay/ncol/nrow as its own real fields. attrs moves a + # redeclared field to its subclass redeclaration position; pydantic + # (like plain stdlib dataclasses) keeps it at the base class's + # original position instead -- see test_flopy3_package's own note on + # the identical difference for Dis's nlay/nrow/ncol/ncpl/nvert/nodes. + assert loaded["DIMENSIONS"] == [["NLAY", 2], ["NROW", 10], ["NCOL", 10]] assert ["DELR"] in loaded["GRIDDATA"] assert ["DELC"] in loaded["GRIDDATA"] @@ -1460,7 +1467,16 @@ def test_ssm_fileinput_row_format(): fileinput={ "pname": np.array(["rch-1", "wel-1"]), "spc6_filename": np.array(["rch.spc6", "wel.spc6"]), - "mixed": np.array([True, False]), + # `mixed` is a bare-presence-flag tagged field (see item.py's + # module docstring) but declared Optional[str] (item.py's + # codegen types every "inline_keyword"-role tagged field str, + # regardless of how it's actually used) -- to_tokens() only + # ever checks its truthiness (`if val:`), so a real string, + # not a bool, is the field's actual contract; attrs never + # validated this (a raw np.True_/np.False_ passed through + # unchecked), pydantic's real Optional[str] check correctly + # rejects it. + "mixed": np.array(["MIXED", ""], dtype=object), }, ) text = dumps(unstructure_component(ssm)) @@ -1620,10 +1636,16 @@ def test_rclose_from_tokens_with_option(): def test_from_tokens_missing_required_raises(): - """attrs raises TypeError when a required field has no value.""" + """A required field with no value raises -- attrs raised TypeError + (Python's own missing-positional-argument error); pydantic dataclasses + raise their own ValidationError instead (a real, expected difference + in exception *type*, not a behavior regression -- both signal the same + "missing required field" condition at construction).""" + from pydantic import ValidationError + from flopy4.mf6.gwf.oc import Oc - with pytest.raises(TypeError): + with pytest.raises(ValidationError): Oc.Headprint.from_tokens("") diff --git a/test/mf6/test_mf6_codegen.py b/test/mf6/test_mf6_codegen.py index 61e690ac..7acec381 100644 --- a/test/mf6/test_mf6_codegen.py +++ b/test/mf6/test_mf6_codegen.py @@ -254,7 +254,7 @@ def test_item_class_static_block_no_aux(self): {"name": "boundname", "role": "boundname", "dfn_type": "string"}, ] result = item_class(schema, "Packagedata") - assert "@attrs.define" in result + assert "@dataclass(config=CFG)" in result assert "class Packagedata(Item):" in result assert "ifno: int = field(index=True, pk=True)" in result assert "strt: float" in result @@ -370,7 +370,7 @@ def test_imports_include_base(self, dfn_name, expected_class, expected_base, all + spec.imports.get("third_party", []) + spec.imports.get("flopy4", []) ) - assert "attrs" in all_imports + assert "pydantic" in all_imports assert "Package" in all_imports def test_outpath(self, dfn_name, expected_class, expected_base, all_dfns): @@ -424,7 +424,7 @@ def test_imports_include_solution( + spec.imports.get("third_party", []) + spec.imports.get("flopy4", []) ) - assert "attrs" in all_imports + assert "pydantic" in all_imports assert "Solution" in all_imports assert "ClassVar" in all_imports @@ -458,7 +458,8 @@ def test_mvr_list_fields_expanded_and_optional(all_dfns): assert spec.period_schema, "MVR should have a period_schema" assert "_stress_period_data" in field_map spd_field = field_map["_stress_period_data"] - assert spd_field.type_annotation == "Optional[dict[int, list[StressPeriodData]]]" + expected_type = "Optional[SkipValidation[dict[int, list[StressPeriodData]]]]" + assert spd_field.type_annotation == expected_type # Packages block → single recarray field assert "packages" in field_map or "packages" in spec.block_schemas diff --git a/test/mf6/test_mf6_component.py b/test/mf6/test_mf6_component.py index 6b655f6a..09caf1e5 100644 --- a/test/mf6/test_mf6_component.py +++ b/test/mf6/test_mf6_component.py @@ -1598,15 +1598,13 @@ def test_disv_class_identity(): def test_prt_dis_no_ncf(): """prt.Dis and prt.Disv must not expose NCF fields.""" - import attrs - from flopy4.mf6 import prt - dis_field_names = {f.name for f in attrs.fields(prt.Dis)} + dis_field_names = set(prt.Dis.__pydantic_fields__) assert "ncf6_filerecord" not in dis_field_names assert "ncf" not in dis_field_names - disv_field_names = {f.name for f in attrs.fields(prt.Disv)} + disv_field_names = set(prt.Disv.__pydantic_fields__) assert "ncf6_filerecord" not in disv_field_names assert "ncf" not in disv_field_names diff --git a/test/mf6/test_mf6_spec.py b/test/mf6/test_mf6_spec.py index 21ec9bfa..ac9f7aaf 100644 --- a/test/mf6/test_mf6_spec.py +++ b/test/mf6/test_mf6_spec.py @@ -5,7 +5,9 @@ def test_blocks(): block_spec = blocks(Gwf) options = block_spec[0] - assert options[-1].name == "netcdf_input_file" + options_by_name = blocks_dict(Gwf)["options"] + assert next(reversed(options_by_name)) == "netcdf_input_file" + assert len(options) == len(options_by_name) def test_blocks_dict(): diff --git a/test/test_attrs_xarray.py b/test/test_attrs_xarray.py index 24bc25d0..4307d575 100644 --- a/test/test_attrs_xarray.py +++ b/test/test_attrs_xarray.py @@ -10,7 +10,8 @@ import numpy as np import xarray as xr -from attrs import define, field +from pydantic import ConfigDict, Field +from pydantic.dataclasses import dataclass from flopy4.attrs_xarray import ( attrs_to_dataset, @@ -23,8 +24,10 @@ from flopy4.mixins import DatasetConvertibleMixin, DataTreeConvertibleMixin from flopy4.protocols import DatasetConvertible, DataTreeConvertible +_CFG = ConfigDict(arbitrary_types_allowed=True, extra="forbid") -@define + +@dataclass(config=_CFG) class Leaf(DatasetConvertibleMixin): """A leaf class with only scalar/array fields, no children.""" @@ -36,29 +39,29 @@ class Leaf(DatasetConvertibleMixin): values: Optional[np.ndarray] = mf6_field(default=None, shape=("nlay",)) -@define +@dataclass(config=_CFG) class OnlyChild(DatasetConvertibleMixin): label: str = "child" -@define +@dataclass(config=_CFG) class ListChild(DatasetConvertibleMixin): idx: int = 0 -@define +@dataclass(config=_CFG) class DictChild(DatasetConvertibleMixin): key: str = "k" -@define +@dataclass(config=_CFG) class Node(DataTreeConvertibleMixin): """An internal-node class with all three child-field kinds.""" title: str = "node" only: Optional[OnlyChild] = None - items: list[ListChild] = field(factory=list) - mapping: dict[str, DictChild] = field(factory=dict) + items: list[ListChild] = Field(default_factory=list) + mapping: dict[str, DictChild] = Field(default_factory=dict) def test_leaf_scalar_and_array_round_trip(): diff --git a/test/test_dimensions.py b/test/test_dimensions.py index d039fd92..7af18b30 100644 --- a/test/test_dimensions.py +++ b/test/test_dimensions.py @@ -2,12 +2,15 @@ from typing import Optional -from attrs import define, field +from pydantic import ConfigDict, Field +from pydantic.dataclasses import dataclass from flopy4.dimensions import DimensionResolverMixin +_CFG = ConfigDict(arbitrary_types_allowed=True, extra="forbid") -@define + +@dataclass(config=_CFG) class MockDimensionProvider: """Mock component that provides dimensions.""" @@ -26,25 +29,25 @@ def get_dims(self) -> dict[str, int]: } -@define +@dataclass(config=_CFG) class MockContainer(DimensionResolverMixin): """Mock container that uses the dimension registry mixin.""" provider: Optional[MockDimensionProvider] = None -@define +@dataclass(config=_CFG) class MockContainerWithDict(DimensionResolverMixin): """Mock container with dict of providers.""" - providers: dict[str, MockDimensionProvider] = field(factory=dict) + providers: dict[str, MockDimensionProvider] = Field(default_factory=dict) -@define +@dataclass(config=_CFG) class MockContainerWithList(DimensionResolverMixin): """Mock container with list of providers.""" - providers: list[MockDimensionProvider] = field(factory=list) + providers: list[MockDimensionProvider] = Field(default_factory=list) def test_resolve_dimension_from_direct_child(): From 2629f37a6b029c90c9223532b28a47f48de18fbb Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 23 Sep 2026 09:17:46 -0400 Subject: [PATCH 2/6] Clean up after the pydantic migration - Untrack the pydantic planning doc and prototypes (kept locally) - Drop the hardcoded maxbound -> auto_from fallback in codegen; G-variant maxbound is a plain optional field again (devtools #366), and generated packages now match develop apart from the dataclass decorator - Remove leftover attrs: drop attrs/cattrs deps, rename attrs_xarray to dataclass_xarray, port test_uio, rewrite attrs-history comments, update the cattrs references in sdd.md - Fix equality: subclass-generated __eq__ overrode Component's, so _parent and dims were compared; use compare=False on _parent instead - Make Component.dims a construction-only InitVar threaded through the __post_init__ chain, rather than a stored field Co-Authored-By: Claude Opus 5.5 --- docs/dev/prototypes/pydantic_chd_prototype.py | 379 -------- docs/dev/prototypes/pydantic_dis_prototype.py | 401 -------- .../prototypes/pydantic_record_prototype.py | 366 ------- .../pydantic_union_arm_prototype.py | 381 -------- docs/dev/pydantic-object-model-plan.md | 899 ------------------ docs/dev/sdd.md | 10 +- .../{attrs_xarray.py => dataclass_xarray.py} | 40 +- flopy4/dimensions.py | 8 +- flopy4/mf6/_types.py | 5 +- flopy4/mf6/adapters.py | 6 +- flopy4/mf6/component.py | 110 +-- flopy4/mf6/context.py | 22 +- flopy4/mf6/converter/egress/unstructure.py | 2 +- flopy4/mf6/converter/ingress/structure.py | 35 +- flopy4/mf6/gwe/dis.py | 4 +- flopy4/mf6/gwe/disv.py | 11 +- flopy4/mf6/gwf/__init__.py | 10 +- flopy4/mf6/gwf/api.py | 1 - flopy4/mf6/gwf/chdg.py | 1 - flopy4/mf6/gwf/dis.py | 4 +- flopy4/mf6/gwf/disbase.py | 11 +- flopy4/mf6/gwf/disv.py | 11 +- flopy4/mf6/gwf/drng.py | 1 - flopy4/mf6/gwf/ghbg.py | 1 - flopy4/mf6/gwf/rivg.py | 1 - flopy4/mf6/gwf/welg.py | 1 - flopy4/mf6/gwt/api.py | 1 - flopy4/mf6/gwt/dis.py | 4 +- flopy4/mf6/gwt/disv.py | 11 +- flopy4/mf6/item.py | 15 +- flopy4/mf6/netcdf.py | 2 +- flopy4/mf6/package.py | 49 +- flopy4/mf6/prt/dis.py | 4 +- flopy4/mf6/prt/disv.py | 11 +- flopy4/mf6/record.py | 15 +- flopy4/mf6/simulation.py | 11 +- flopy4/mf6/spec.py | 4 +- flopy4/mf6/tdis.py | 16 +- flopy4/mf6/utils/codegen/dfn2py.py | 2 +- flopy4/mf6/utils/codegen/filters.py | 29 +- flopy4/mf6/utils/codegen/make.py | 20 +- flopy4/mixins.py | 28 +- flopy4/protocols.py | 8 +- flopy4/spec.py | 3 +- pixi.lock | 2 - pyproject.toml | 2 - test/mf6/test_mf6_adapters.py | 29 +- test/mf6/test_mf6_codec.py | 22 +- test/mf6/test_mf6_codegen.py | 2 +- test/mf6/test_mf6_component.py | 16 +- ...trs_xarray.py => test_dataclass_xarray.py} | 44 +- test/test_uio.py | 8 +- 52 files changed, 273 insertions(+), 2806 deletions(-) delete mode 100644 docs/dev/prototypes/pydantic_chd_prototype.py delete mode 100644 docs/dev/prototypes/pydantic_dis_prototype.py delete mode 100644 docs/dev/prototypes/pydantic_record_prototype.py delete mode 100644 docs/dev/prototypes/pydantic_union_arm_prototype.py delete mode 100644 docs/dev/pydantic-object-model-plan.md rename flopy4/{attrs_xarray.py => dataclass_xarray.py} (90%) rename test/{test_attrs_xarray.py => test_dataclass_xarray.py} (81%) diff --git a/docs/dev/prototypes/pydantic_chd_prototype.py b/docs/dev/prototypes/pydantic_chd_prototype.py deleted file mode 100644 index f699985c..00000000 --- a/docs/dev/prototypes/pydantic_chd_prototype.py +++ /dev/null @@ -1,379 +0,0 @@ -""" -Pydantic prototype: the two mechanics `pydantic_dis_prototype.py` explicitly -left out of scope -- (1) `Component`'s full `MutableMapping` interface for a -*list*-kind child field (many packages of the same slot, e.g. a model's -`chd: list[Chd]`), not just the "only" (single-child, `Dis.ncf`) case that -prototype covered, and (2) `Package`'s Item-list coercion (raw -tuple/dict/instance stress-period-data -> `Item` instances), modeled on -`flopy4/mf6/gwf/chd.py`'s real shape. - -Mined from: -- flopy4/mf6/component.py -- `__getitem__`/`__setitem__`/`__delitem__`/ - `__iter__`/`__len__`, `_find_child_field`, `_resolve_child_name`, - `_attach_to_parent_field` (the "list" kind branches specifically). -- flopy4/mf6/package.py -- `_init_item_lists`, `_coerce_item_list`. -- flopy4/mf6/gwf/chd.py -- the real `Chd`/`Chd.StressPeriodData` shape being - modeled (`_stress_period_data`, `alias=`, `block="period"`, - `fill_forward=True`). -- flopy4/mf6/item.py -- `item_list_type()` (the field-type -> Item-class - introspection this prototype's `_item_list_type()` adapts for pydantic's - `SkipValidation`-wrapped annotation shape -- see the finding below). - -Reuses `ComponentBase`/`PackageBase`/`_CFG`/`_DTYPE_MAP` from -`pydantic_dis_prototype.py` rather than redefining them. - -KEY FINDING (the reason `SkipValidation` is needed at all): attrs applies -*zero* validation to `Chd._stress_period_data` at construction (no -validator/converter declared on that field) -- raw tuples/dicts pass -through attrs' `__init__` untouched, and `Package.__attrs_post_init__` -coerces them into real `Item` instances afterward. Pydantic does NOT default -to this behavior: a plain `Optional[dict[int, list[Row]]]`-typed field is -*eagerly, strictly* validated against that annotation at construction -- -confirmed empirically that `Pkg(rows={0: [(1, 2.0)]})` raises -`ValidationError: Input should be an instance of Row` for a raw tuple, -before any post-init coercion hook ever runs. Wrapping the annotation in -`pydantic.SkipValidation[...]` fixes this (confirmed: identical raw input is -accepted, coercion runs in `__post_init__` exactly like the attrs version), -but costs two things pydantic_dis_prototype.py's array fields didn't need: - 1. `item_list_type()`'s `get_origin`/`get_args` walk must unwrap one extra - `Annotated[..., SkipValidation()]` layer before it reaches - `dict[int, list[Row]]` -- confirmed the real function's current logic - does NOT do this and needs a small, mechanical addition (see - `_item_list_type()` below). - 2. `SkipValidation` also skips `validate_assignment` re-validation on - this field specifically -- confirmed `pkg.stress_period_data = "junk"` - is silently accepted with `SkipValidation`, same as it would be on - attrs today (no validator declared there either) -- a wash, not a - regression, but worth naming since it's a per-field opt-out, not a - global one. - -Also confirms the `Item`/`Record` subsystem (`flopy4/mf6/item.py`, -`flopy4/mf6/record.py`) does NOT need to migrate to pydantic at all: with -`SkipValidation`, pydantic never inspects `Row`'s own fields, so `Row` -here is a genuine, unmodified `attrs.define` class -- exactly like the real -`Chd.StressPeriodData`. Mixed attrs/pydantic is fine for this boundary. - -Run directly: `python docs/dev/prototypes/pydantic_chd_prototype.py` -""" - -from __future__ import annotations - -from collections.abc import MutableMapping -from typing import Annotated, Any, ClassVar, Optional, get_args, get_origin - -import attrs -from pydantic import Field, SkipValidation -from pydantic.dataclasses import dataclass -from pydantic_dis_prototype import _CFG, ComponentBase, PackageBase - -# ============================================================================ -# Row type: stays a plain, unmodified attrs class -- see module docstring's -# "KEY FINDING" for why pydantic never needs to see inside it. -# ============================================================================ - - -@attrs.define -class ChdRowProto: - """Models `Chd.StressPeriodData` (flopy4/mf6/gwf/chd.py) -- a real - attrs `Item` subclass in the actual codebase; simplified here to just - the fields this prototype's coercion path exercises (the full - `Item`/`Record` token round-trip machinery is out of scope -- see - module docstring).""" - - cellid: tuple - head: float - boundname: Optional[str] = None - - -# ============================================================================ -# item_list_type(): adapted from flopy4/mf6/item.py for pydantic's -# SkipValidation-wrapped annotation shape (see module docstring finding #1). -# ============================================================================ - - -def _unwrap_skip_validation(t: Any) -> Any: - """Strip one `Annotated[X, SkipValidation()]` layer, if present. - - NEW code this migration would need -- the real `item_list_type()` has - no such step today because attrs field types are never wrapped this - way. Confirmed necessary: `get_origin()` on the raw (unstripped) - annotation returns `Annotated`, not `dict`, so the existing - dict/list-unwrapping logic below would silently fail to find `Row` - without this. - """ - if get_origin(t) is Annotated: - return get_args(t)[0] - return t - - -def _item_list_type(field_type: Any) -> "type | None": - """Adapted from `flopy4.mf6.item.item_list_type()`: for - `Optional[SkipValidation[dict[int, list[C]]]]`, return `C`.""" - args = get_args(field_type) - inner = next((a for a in args if a is not type(None)), None) - if inner is None: - return None - inner = _unwrap_skip_validation(inner) - origin = get_origin(inner) - if origin is dict: - _, val = get_args(inner) - val = _unwrap_skip_validation(val) - if get_origin(val) is list: - (item_cls,) = get_args(val) - return item_cls - return None - - -# ============================================================================ -# ListPackageBase: Package._init_item_lists/_coerce_item_list, adapted. -# ============================================================================ - - -@dataclass(config=_CFG, kw_only=True) -class ListPackageBase(PackageBase): - """Adds Item-list coercion on top of `PackageBase`'s griddata handling. - A real generated package would get both by inheriting one shared base - -- split here into two classes only so this file can import - `PackageBase` from the Dis prototype unmodified.""" - - def _init_item_lists(self) -> None: - """Adapted from `Package._init_item_lists`/`_coerce_item_list` - (non-union path only -- the keystring-union-arm case, e.g. LAK/SFR - period settings, is a separate, still-unmeasured surface; see the - plan doc).""" - for fname, finfo in type(self)._pydantic_fields().items(): - meta = finfo.json_schema_extra or {} - if not (isinstance(meta, dict) and meta.get("block")): - continue - item_cls = _item_list_type(finfo.annotation) - if item_cls is None: - continue - raw = self.__dict__.get(fname) - if raw is None: - continue - if meta.get("block") == "period" or meta.get("fill_forward"): - coerced = { - kper: self._coerce_item_list(rows, item_cls) for kper, rows in raw.items() - } - object.__setattr__(self, fname, coerced) - else: - object.__setattr__(self, fname, self._coerce_item_list(raw, item_cls)) - - @staticmethod - def _coerce_item_list(data, item_cls: type) -> list: - items = [] - for row in data: - if isinstance(row, item_cls): - items.append(row) - elif isinstance(row, dict): - items.append(item_cls(**row)) - elif isinstance(row, (list, tuple)): - items.append(item_cls(*row)) - else: - raise TypeError(f"Can't coerce {row!r} to {item_cls.__name__}") - return items - - def __post_init__(self) -> None: - self._init_item_lists() - super().__post_init__() - - -# ============================================================================ -# ChdProto: models the real Chd (flopy4/mf6/gwf/chd.py) -# ============================================================================ - - -@dataclass(config=_CFG, kw_only=True) -class ChdProto(ListPackageBase): - dfn_name: ClassVar[str] = "gwf-chd" - - boundnames: bool = Field(default=False, json_schema_extra={"block": "options"}) - print_input: bool = Field(default=False, json_schema_extra={"block": "options"}) - - # The field this whole prototype exists to test: SkipValidation is - # what lets raw tuple/dict input (see demo() below) reach - # ListPackageBase._init_item_lists() at all -- without it, pydantic's - # own eager validation rejects the raw input before __post_init__ ever - # runs (module docstring's "KEY FINDING"). - stress_period_data: Optional[SkipValidation[dict[int, list[ChdRowProto]]]] = Field( - default=None, json_schema_extra={"block": "period", "fill_forward": True} - ) - - -# ============================================================================ -# ModelBase: Component's full MutableMapping interface for a *list*-kind -# child field -- pydantic_dis_prototype.py's ComponentBase only exercised -# the "only" (single-child) case via Dis.ncf. This is the other half. -# ============================================================================ - - -@dataclass(config=_CFG, kw_only=True) -class ModelBase(ComponentBase, MutableMapping): - """Adapted from `Component`'s `MutableMapping` mixing + `_children`/ - `__getitem__`/`__setitem__`/`__delitem__`/`__iter__`/`__len__` -- the - "list" kind only (a model holding several packages of the same slot, - e.g. `chd: list[ChdProto]`); the "dict"-kind branch real `Component` - also has isn't exercised here (out of scope, same as before).""" - - chd: list[ChdProto] = Field(default_factory=list, exclude=True) - - def _list_child_fields(self) -> list[str]: - """`list[ComponentBase]`-typed fields -- the part `ComponentBase. - _child_fields()` (Dis prototype) doesn't cover, since it only - matches a field whose annotation IS (or directly wraps) a - `ComponentBase` subclass, not `list[ComponentBase subclass]`.""" - names = [] - for fname, finfo in type(self)._pydantic_fields().items(): - ann = finfo.annotation - if get_origin(ann) is list: - (elem,) = get_args(ann) - if isinstance(elem, type) and issubclass(elem, ComponentBase): - names.append(fname) - return names - - @property - def _children(self) -> dict[str, ComponentBase]: - result: dict[str, ComponentBase] = {} - for fname in self._list_child_fields(): - for child in getattr(self, fname): - result[child.name] = child - return result - - def _set_child_parents(self) -> None: - """Adapted from `Component._set_child_parents`'s "list" branch. - - NOTE the same "is this still a default name" check real - `Component._is_default_child_name()` needs: by the time a child - reaches here, its own `__post_init__` (`ComponentBase.__post_init__`) - has *already* defaulted `.name` to its lowercased class name -- - `child.name` is never actually `None` at this point. Checking - `child.name is None` (this prototype's first cut) silently failed - to rename same-class siblings, since only the first one collided - (the rest stayed at the shared class-name default, `not in used` - yet). Confirmed by running this file and seeing `['chdproto', - 'chd1']` instead of `['chd0', 'chd1']` -- fixed below. - """ - super()._set_child_parents() - for fname in self._list_child_fields(): - used: set[str] = set() - for i, child in enumerate(getattr(self, fname)): - object.__setattr__(child, "parent", self) - is_default = child.name == type(child).__name__.lower() - if is_default or child.name in used: - object.__setattr__(child, "name", f"{fname}{i}") - used.add(child.name) - - def __getitem__(self, key): - return self._children[key] - - def __setitem__(self, key, value): - if not isinstance(value, ComponentBase): - raise TypeError(f"Expected a ComponentBase, got {type(value).__name__}") - for fname in self._list_child_fields(): - current = getattr(self, fname) - for i, child in enumerate(current): - if child.name == key: - object.__setattr__(value, "name", key) - object.__setattr__(value, "parent", self) - current[i] = value - return - # No existing child named `key` -- attach fresh to the (only, for - # this prototype) list field matching value's type. - for fname in self._list_child_fields(): - finfo = type(self)._pydantic_fields()[fname] - (elem,) = get_args(finfo.annotation) - if isinstance(value, elem): - object.__setattr__(value, "name", key) - object.__setattr__(value, "parent", self) - getattr(self, fname).append(value) - return - raise TypeError(f"No field on {type(self).__name__} accepts a {type(value).__name__}") - - def __delitem__(self, key): - for fname in self._list_child_fields(): - current = getattr(self, fname) - for i, child in enumerate(current): - if child.name == key: - del current[i] - return - raise KeyError(key) - - def __iter__(self): - return iter(self._children) - - def __len__(self): - return len(self._children) - - -# ============================================================================ -# Demonstration / smoke test -# ============================================================================ - - -def _assert_plain_typed_field_rejects_raw_tuple() -> None: - """Control case for the module docstring's "KEY FINDING": the identical - field, WITHOUT `SkipValidation`, on the same raw input `ChdProto` - above accepts -- confirms the rejection is really pydantic's default - eager validation, not some other mistake in this prototype.""" - from pydantic import ConfigDict - from pydantic.dataclasses import dataclass as _dc - - @_dc(config=ConfigDict(arbitrary_types_allowed=True, extra="forbid"), kw_only=True) - class _NoSkip: - rows: Optional[dict[int, list[ChdRowProto]]] = None - - try: - _NoSkip(rows={0: [((0, 0, 0), 1.0)]}) - raise AssertionError("expected a validation error") - except AssertionError: - raise - except Exception as e: - print(f" plain-typed field rejects the same raw tuple -> {type(e).__name__} as expected") - - -def demo() -> None: - print("=" * 70) - print("Pydantic Chd/MutableMapping prototype") - print("=" * 70) - - # -- Item-list coercion: raw tuple, raw dict, and real-instance forms, - # matching Package._coerce_item_list's three main input shapes. - chd = ChdProto( - stress_period_data={ - 0: [((0, 0, 0), 1.0), {"cellid": (0, 0, 1), "head": 2.0, "boundname": "b1"}], - 1: [ChdRowProto(cellid=(0, 0, 2), head=3.0)], - } - ) - print(f"\nstress_period_data: {chd.stress_period_data}") - assert isinstance(chd.stress_period_data[0][0], ChdRowProto) - assert chd.stress_period_data[0][0].cellid == (0, 0, 0) - assert chd.stress_period_data[0][1].boundname == "b1" - assert chd.stress_period_data[1][0].head == 3.0 - - print("\nEager pydantic validation without SkipValidation (confirms the finding):") - _assert_plain_typed_field_rejects_raw_tuple() - - # -- MutableMapping over a list-kind child field. - model = ModelBase(chd=[ChdProto(), ChdProto()]) - print(f"\nlen(model): {len(model)}") - assert len(model) == 2 - names = list(model) - print(f"child names (auto-assigned, field-name + index): {names}") - assert names == ["chd0", "chd1"] - assert model["chd0"].parent is model - - extra = ChdProto() - model["chd2"] = extra - assert model["chd2"] is extra and extra.name == "chd2" and extra.parent is model - print(f"after __setitem__('chd2', ...): {list(model)}") - - del model["chd1"] - print(f"after __delitem__('chd1'): {list(model)}") - assert "chd1" not in model - assert len(model) == 2 - - print("\nAll assertions passed.") - - -if __name__ == "__main__": - demo() diff --git a/docs/dev/prototypes/pydantic_dis_prototype.py b/docs/dev/prototypes/pydantic_dis_prototype.py deleted file mode 100644 index 63b00971..00000000 --- a/docs/dev/prototypes/pydantic_dis_prototype.py +++ /dev/null @@ -1,401 +0,0 @@ -""" -Pydantic prototype v3: ports `Dis` (flopy4/mf6/gwf/dis.py, via `DisBase` -> -`Package` -> `Component`) to pydantic, in its *current* (post-xattree, -post-Row-unification) shape. - -v3 change from v2: built on `pydantic.dataclasses.dataclass`, not -`pydantic.BaseModel`. v2 (BaseModel) found no working analog to attrs' -`field(init=False)` (DisBase's derived nlay/nrow/ncol/ncpl/nvert/nodes) -- -`Field(init=False)` on a `BaseModel` is accepted but has NO runtime effect -(confirmed: `M(nodes=999)` silently succeeds and sets `nodes=999`, even -under `extra="forbid"` -- it's type-checker-only metadata for BaseModel). -On a pydantic dataclass, the identical `Field(init=False)` DOES work at -runtime -- confirmed: with `extra="forbid"` in config, `M(nodes=999)` -raises `ValidationError: Unexpected keyword argument`, matching attrs' -own `TypeError: unexpected keyword argument` for the same case exactly. -See DisBaseProto below and the plan doc's "BaseModel vs. pydantic -dataclasses" section for the full comparison. - -This exists to satisfy docs/dev/pydantic-object-model-plan.md's "next -steps when picked up": port one real, current-shape package end to end, -port its __attrs_post_init__/DimensionResolverMixin chain, and measure -ergonomics against the actual current codebase. - -Mined from: -- flopy4/mf6/component.py, flopy4/mf6/package.py, flopy4/dimensions.py, - flopy4/mf6/gwf/{dis,disbase}.py -- the mechanics being ported. -- origin/plan-codegen's pydantic_prototype.py (2026-01-23) -- the - pydantic-side mechanics (Annotated NDArray hints, field_validator - array-structuring pattern). Reused near-verbatim where still applicable. - -Not wired into flopy4's real registry (FNAMES/FTYPES), codegen, or the -xarray/write/load machinery -- this is a standalone measurement of the -object-model layer only, not a drop-in replacement. `Ncf` is stubbed -(`NcfProto`) rather than importing the real attrs-based `Ncf`, since -mixing attrs and pydantic components isn't the point of this exercise. -`Component`'s full `MutableMapping` interface is intentionally out of -scope here too (see the plan doc) -- this only exercises the single-child -("only") case via `ncf`. - -Run directly: `python docs/dev/prototypes/pydantic_dis_prototype.py` -""" - -from __future__ import annotations - -from abc import ABC -from typing import Annotated, Any, ClassVar, Optional - -import numpy as np -from numpy.typing import NDArray -from pydantic import ConfigDict, Field, ValidationInfo, field_validator -from pydantic.dataclasses import dataclass - -# Applied to every class in the hierarchy explicitly (dataclass config -# isn't inherited across `@dataclass`-decorated subclasses the way -# BaseModel's `model_config` is) -- one constant, repeated per class, same -# shape codegen already has today (`@attrs.define(kw_only=True, -# slots=False)` emitted on every generated class). -_CFG = ConfigDict( - arbitrary_types_allowed=True, # for np.ndarray / NDArray fields - validate_assignment=True, - extra="forbid", # required for Field(init=False) to actually bite -- see module docstring -) - -_DTYPE_MAP = {"integer": np.int64, "double": np.float64} - - -# ============================================================================ -# Component: parent/child wiring + dimension resolution -# -# FRICTION POINT #1: attrs' private-attribute convention (`_parent` field, -# `parent=` constructor kwarg, via leading-underscore name mangling) -# doesn't exist in pydantic -- and doesn't need to. A field just named -# `parent` works directly. `exclude=True` is inert at runtime here (a -# dataclass has no built-in dump method to honor it -- see the plan doc's -# BaseModel-vs-dataclass section) but documents intent for if/when a -# `TypeAdapter(cls).dump_python(...)` call is ever added; flopy4 already -# does its own field-filtering in `to_dict()` regardless (its -# `attrs.asdict(..., filter=...)` call excludes "parent"/"_parent" by -# name today), so this isn't a functional gap. -# ============================================================================ - - -@dataclass(config=_CFG, kw_only=True) -class ComponentBase(ABC): - filename: Optional[str] = None - name: Optional[str] = None - parent: Optional["ComponentBase"] = Field(default=None, exclude=True, repr=False) - dims: dict = Field(default_factory=dict, exclude=True) - - # attrs' `attrs.Factory(lambda self: ..., takes_self=True)` - # (Component.name's real default: the lowercased *runtime* class name) - # has no direct per-field equivalent here either -- `default_factory` - # callables take no arguments in pydantic, same as in stdlib - # dataclasses. Filled in here, in `__post_init__` -- pydantic - # dataclasses use the same single post-construction hook stdlib - # dataclasses do (not BaseModel's two-hook `model_validator(mode= - # "after")` + `model_post_init` split), which turns out to be a - # *closer* match to attrs' single `__attrs_post_init__` than v2's - # BaseModel-based version was. - def __post_init__(self) -> None: - if self.name is None: - self.name = type(self).__name__.lower() - self._set_child_parents() - - def default_filename(self) -> str: - return f"{self.name}.{type(self).__name__.lower()}" - - # FRICTION POINT #3 (see plan doc): `attrs.fields(cls)` -> a pydantic - # dataclass's own `__pydantic_fields__` (or, more portably, - # `dataclasses.fields(cls)` combined with each field's pydantic - # `Field(...)` extras) -- same shape as BaseModel's `model_fields`, - # just a different attribute name. `.metadata` dict -> - # `Field(json_schema_extra={...})`, unchanged from v2. - @classmethod - def _pydantic_fields(cls) -> dict[str, Any]: - return cls.__pydantic_fields__ # type: ignore[attr-defined] - - @classmethod - def _child_fields(cls) -> list[str]: - """Fields whose annotation is (or wraps) another ComponentBase -- - replaces attrs_xarray.child_field_candidates() for the "only" case - this prototype needs (Dis -> Ncf).""" - names = [] - for fname, finfo in cls._pydantic_fields().items(): - ann = finfo.annotation - args = getattr(ann, "__args__", ()) - candidates = (ann, *args) - if any(isinstance(a, type) and issubclass(a, ComponentBase) for a in candidates): - names.append(fname) - return names - - def _set_child_parents(self) -> None: - for fname in self._child_fields(): - child = getattr(self, fname, None) - if isinstance(child, ComponentBase): - object.__setattr__(child, "parent", self) - if child.name is None: - object.__setattr__(child, "name", fname) - - # -- DimensionResolverMixin equivalent -------------------------------- - def get_dims(self) -> dict[str, int]: - return {} - - def resolve_dims(self, *dims: str) -> dict[str, int]: - if "_dimension_cache" not in self.__dict__: - self.__dict__["_dimension_cache"] = {} - cache = self.__dict__["_dimension_cache"] - - all_dims: dict[str, int] = {} - if self.parent is not None: - all_dims.update(self.parent.resolve_dims()) - all_dims.update(self.get_dims()) - for fname in self._child_fields(): - child = getattr(self, fname, None) - if isinstance(child, ComponentBase): - all_dims.update(child.get_dims()) - cache.update(all_dims) - - if not dims: - return all_dims - return {d: all_dims[d] for d in dims if d in all_dims} - - -# ============================================================================ -# Package: griddata broadcasting via a single, generic field_validator -# ============================================================================ - - -@dataclass(config=_CFG, kw_only=True) -class PackageBase(ComponentBase, ABC): - # FRICTION POINT #5: a griddata field's *declared* type is - # `NDArray[np.float64]`, but its *default value* in the current attrs - # code is a bare scalar (`default=1.0`) that only becomes a real array - # once dims are known. attrs never type-checks this mismatch. Pydantic - # DOES enforce it (confirmed by running this prototype): constructing - # `DisProto(delr=100.0, ...)` raises `ValidationError: Input should be - # an instance of ndarray` without a coercion step. - # - # This does NOT need to be written once per field, or once per - # generated class -- a single `field_validator("*", mode="before")`, - # defined ONE time on this shared base, driven by each field's own - # `json_schema_extra["shape"]` metadata (the metadata `spec.py`'s - # `field()` helper already emits today), covers every array field on - # every subclass, including under `validate_assignment=True` - # (confirmed: assigning `d.delr = 5.0` after construction still - # coerces). Codegen's array-field template doesn't need to emit a - # validator at all -- just the `shape=` metadata it already writes. - @field_validator("*", mode="before") - @classmethod - def _coerce_arrays(cls, v: Any, info: ValidationInfo) -> Any: - finfo = cls._pydantic_fields().get(info.field_name) - if finfo is None or v is None: - return v - meta = finfo.json_schema_extra or {} - if not (isinstance(meta, dict) and meta.get("block") == "griddata" and meta.get("shape")): - return v - if isinstance(v, np.ndarray): - return v - dtype = _DTYPE_MAP.get(meta.get("dfn_type", "double"), np.float64) - return np.asarray(v, dtype=dtype) - - # Deliberately a plain method, not a validator -- called explicitly - # from `__post_init__` (see DisProto below), the same way the real - # `Package._broadcast_griddata`/`DisBase._coerce_griddata` are plain - # methods called explicitly from `__attrs_post_init__`. - def _broadcast_griddata(self) -> None: - dims = self.resolve_dims() - if not dims: - return - for fname, finfo in type(self)._pydantic_fields().items(): - meta = finfo.json_schema_extra or {} - if meta.get("block") != "griddata": - continue - shape_dims = meta.get("shape") - if not shape_dims: - continue - val = getattr(self, fname, None) - if val is None: - continue - try: - shape = tuple(dims[d] for d in shape_dims) - except KeyError: - continue - dtype = _DTYPE_MAP.get(meta.get("dfn_type", "double"), np.float64) - if not isinstance(val, np.ndarray) or val.shape == shape: - continue - if val.size == 1: - # scalar (post-_coerce_arrays, a 0-d ndarray) -> broadcast - object.__setattr__(self, fname, np.full(shape, val.item(), dtype=dtype)) - elif meta.get("layered") and val.size == dims.get("nlay", 1): - object.__setattr__( - self, fname, np.repeat(val, np.prod(shape) // val.size).astype(dtype) - ) - else: - try: - object.__setattr__(self, fname, val.reshape(shape)) - except ValueError: - pass - - def __post_init__(self) -> None: - self._broadcast_griddata() - super().__post_init__() - - -# ============================================================================ -# NcfProto: minimal child-component stub (real Ncf is attrs-based and out -# of scope -- this exists only to exercise Component's child-wiring path) -# ============================================================================ - - -@dataclass(config=_CFG, kw_only=True) -class NcfProto(ComponentBase): - dfn_name: ClassVar[str] = "utl-ncf" - latitude: Optional[str] = None - longitude: Optional[str] = None - - -# ============================================================================ -# DisBase / Dis -# ============================================================================ - - -@dataclass(config=_CFG, kw_only=True) -class DisBaseProto(PackageBase, ABC): - # FRICTION POINT #2, RESOLVED by using a pydantic dataclass instead of - # BaseModel: these were `attrs.field(init=False)` in the real code -- - # excluded from the constructor entirely, always computed, with attrs - # raising `TypeError: unexpected keyword argument` if a caller passes - # one anyway. `Field(init=False)` on a `BaseModel` is silently inert - # at runtime (confirmed by testing: `M(nodes=999)` just sets - # `nodes=999`, no error, even under `extra="forbid"`). The identical - # `Field(init=False)`, on a pydantic dataclass, with `extra="forbid"` - # in config (see `_CFG` above), DOES work: `DisProto(nodes=999)` - # raises `ValidationError: Unexpected keyword argument` -- see the - # demo below. This was the single largest unresolved gap v2 of this - # prototype (BaseModel-based) found; switching the base to - # `pydantic.dataclasses.dataclass` closes it entirely, at zero extra - # code cost (same `Field(init=False)` call either way). - nlay: Optional[int] = Field(default=None, init=False) - nrow: Optional[int] = Field(default=None, init=False) - ncol: Optional[int] = Field(default=None, init=False) - ncpl: Optional[int] = Field(default=None, init=False) - nvert: Optional[int] = Field(default=None, init=False) - nodes: Optional[int] = Field(default=None, init=False) - - -@dataclass(config=_CFG, kw_only=True) -class DisProto(DisBaseProto): - dfn_name: ClassVar[str] = "gwf-dis" - - length_units: Optional[str] = Field(default=None, json_schema_extra={"block": "options"}) - nogrb: bool = Field(default=False, json_schema_extra={"block": "options"}) - xorigin: float = Field(default=0.0, json_schema_extra={"block": "options"}) - yorigin: float = Field(default=0.0, json_schema_extra={"block": "options"}) - export_array_netcdf: bool = Field(default=False, json_schema_extra={"block": "options"}) - ncf: Optional[NcfProto] = None - - # Redeclares DisBaseProto's init=False nlay/nrow/ncol as real (init=True) - # fields, matching the real attrs Dis exactly: DisBase declares all six - # derived fields init=False, but Dis's own field() redeclaration of - # nlay/nrow/ncol (real constructor args, `block="dimensions"`) shadows - # DisBase's -- only ncpl/nvert/nodes stay init=False, computed purely - # from these three. Pydantic dataclass subclassing honors the same - # override-by-redeclaration rule attrs does. - nlay: int = Field(default=1, json_schema_extra={"block": "dimensions"}) # type: ignore[assignment] - ncol: int = Field(default=2, json_schema_extra={"block": "dimensions"}) # type: ignore[assignment] - nrow: int = Field(default=2, json_schema_extra={"block": "dimensions"}) # type: ignore[assignment] - - delr: Annotated[ - NDArray[np.float64], - Field(json_schema_extra={"block": "griddata", "shape": ("ncol",), "netcdf": True}), - ] = 1.0 # type: ignore[assignment] - delc: Annotated[ - NDArray[np.float64], - Field(json_schema_extra={"block": "griddata", "shape": ("nrow",), "netcdf": True}), - ] = 1.0 # type: ignore[assignment] - top: Annotated[ - NDArray[np.float64], - Field(json_schema_extra={"block": "griddata", "shape": ("ncpl",), "netcdf": True}), - ] = 1.0 # type: ignore[assignment] - botm: Annotated[ - NDArray[np.float64], - Field( - json_schema_extra={ - "block": "griddata", - "shape": ("nodes",), - "layered": True, - "netcdf": True, - } - ), - ] = 0.0 # type: ignore[assignment] - - # No per-field array-coercion validator needed here -- see - # PackageBase._coerce_arrays above. - - def get_dims(self) -> dict[str, int]: - return { - "nlay": self.nlay, - "nrow": self.nrow, - "ncol": self.ncol, - "nodes": self.nlay * self.nrow * self.ncol, - "ncpl": self.nrow * self.ncol, - } - - # `nodes`/`ncpl`/`nvert` must exist before `resolve_dims()` (called - # inside `_broadcast_griddata`) can see them -- same ordering - # constraint the real `Dis.__attrs_post_init__` documents (compute - # derived dims, *then* chain to super()). - def __post_init__(self) -> None: - object.__setattr__(self, "nodes", self.ncol * self.nrow * self.nlay) - object.__setattr__(self, "ncpl", self.ncol * self.nrow) - object.__setattr__(self, "nvert", (self.ncol + 1) * (self.nrow + 1)) - self._broadcast_griddata() - # ComponentBase's own hook (child-wiring/default-name) -- deliberately - # skips PackageBase.__post_init__ to avoid a second broadcast pass. - ComponentBase.__post_init__(self) - - -# ============================================================================ -# Demonstration / smoke test -# ============================================================================ - - -def demo() -> None: - print("=" * 70) - print("Pydantic Dis prototype v3 (pydantic.dataclasses, current codebase shape)") - print("=" * 70) - - dis = DisProto(nlay=3, nrow=10, ncol=10, delr=100.0, delc=100.0, top=1.0, botm=0.0) - print(f"\nget_dims(): {dis.get_dims()}") - assert dis.nodes == 300 and dis.ncpl == 100 and dis.nvert == 121 - print(f"delr: shape={dis.delr.shape}, dtype={dis.delr.dtype}") - assert dis.delr.shape == (10,) - print(f"botm: shape={dis.botm.shape}") - assert dis.botm.shape == (300,) - - ncf = NcfProto(latitude="lat", longitude="lon") - dis2 = DisProto(ncf=ncf) - assert dis2.ncf is not None and dis2.ncf.parent is dis2 - print(f"\nchild wiring: dis2.ncf.parent is dis2 -> {dis2.ncf.parent is dis2}") - print(f"child wiring: dis2.ncf.name -> {dis2.ncf.name!r}") - - print("\nvalidate_assignment=True in effect:") - try: - dis.xorigin = "not a float" - raise AssertionError("expected a validation error") - except Exception as e: - print(f" dis.xorigin = 'not a float' -> raised {type(e).__name__} as expected") - - print("\nField(init=False) now correctly rejects an explicit kwarg (v2/BaseModel didn't):") - try: - DisProto(nodes=999) - raise AssertionError("expected a validation error") - except Exception as e: - print(f" DisProto(nodes=999) -> raised {type(e).__name__} as expected") - - print("\nAll assertions passed.") - - -if __name__ == "__main__": - demo() diff --git a/docs/dev/prototypes/pydantic_record_prototype.py b/docs/dev/prototypes/pydantic_record_prototype.py deleted file mode 100644 index ec7ae668..00000000 --- a/docs/dev/prototypes/pydantic_record_prototype.py +++ /dev/null @@ -1,366 +0,0 @@ -""" -Pydantic prototype: does `flopy4/mf6/record.py`/`item.py` (the `Record`/ -`Item` row-type subsystem `Chd.StressPeriodData`, `Oc.Headprint`, etc. are -generated from) need to migrate off attrs at all, and if it does, can it -target a pydantic dataclass -- the same target -`pydantic_dis_prototype.py`/`pydantic_chd_prototype.py` chose for -`Component`/`Package` -- or does it need something else (plain stdlib -`dataclasses.dataclass`)? - -Answers, both confirmed by running this file: - -1. Record/Item's own mechanics (`to_tokens`/`from_tokens`, a metadata- - driven field walk) use NO attrs-specific validation/conversion feature - -- no `attrs.field(validator=...)` or `converter=` is ever declared on - a Record/Item field in the real codebase (grepped: `converter=` only - appears on `Component`/`Package`-level fields, e.g. `Gwf.dis`'s - `convert_grid`). `_coerce()` does its own manual, explicit coercion, - called directly from `from_tokens()` -- not wired through attrs at all. - So nothing here *requires* attrs specifically. - -2. BUT one real behavior IS load-bearing and attrs-specific: `_nested_class()` - (record.py) exists because a composed/nested record field (e.g. - `Oc.Headprint.fmt: "Oc.Format"`) is declared with a STRING type - annotation naming a SIBLING class inside the same enclosing class -- - unresolvable via any module-global lookup at class-body-execution time - (Python class bodies can't see sibling names in an enclosing class's - scope), so attrs' default behavior of leaving `f.type` as the literal, - unevaluated string is exactly what makes this possible: resolution is - deferred to `_nested_class()`, called lazily, well after the whole - module (and all its sibling classes) has finished loading. - - Confirmed here: this is NOT an attrs-specific behavior -- plain stdlib - `dataclasses.dataclass` does the exact same thing (`dataclasses.fields - (cls)[i].type` is *also* just the raw, unevaluated string) since it's a - property of how Python stores string-literal annotations, not of - attrs. A straight, mechanical port of `Record`'s current shape to a - stdlib dataclass needs zero changes to `_nested_class()`. - - Pydantic dataclasses behave differently, but not in the way a first - guess would suggest ("eager resolution breaks forward refs"): pydantic - defers schema-building for an unresolvable annotation - (`cls.__pydantic_complete__` is `False` right after decoration) and - resolves it LAZILY -- confirmed: constructing an instance with no - explicit fixup call at all just works, self-healing on first use, via - the same effective mechanism `typing.get_type_hints()` uses (`eval()` - against the defining module's globals plus qualified attribute access, - e.g. `eval("Oc.Format", sys.modules[cls.__module__].__dict__)` -- - which succeeds once `Oc.Format` exists as a real attribute of `Oc`, - regardless of whether `Headprint` or `Format` was defined first in the - source). The ONE real gap: something that inspects `cls.__pydantic_ - fields__` BEFORE any instance is ever constructed -- exactly what - `from_tokens()` does, since it calls `record_fields(cls)` before - building the returned instance -- sees the annotation still as an - unresolved `ForwardRef`, not the real class. Fixed with one guarded - `pydantic.dataclasses.rebuild_dataclass(cls)` call inside - `record_fields()` itself (see below) -- small, centralized, - confirmed working even with zero instances of the target class ever - constructed first. - -3. Genuine WIN for pydantic over stdlib dataclasses here, confirmed by - testing: once resolved (lazily, or via the `rebuild_dataclass()` guard - below), a pydantic dataclass's `FieldInfo.annotation` is the REAL - `Oc.Format` class object, not a string -- so `_nested_class()`'s entire - ~15-line custom qualname-walking resolver becomes UNNECESSARY code, not - just working code: `isinstance(finfo.annotation, type) and issubclass - (finfo.annotation, Record)` (after unwrapping `Optional`) replaces it - outright. A stdlib-dataclass port would need to KEEP `_nested_class()` - unchanged (its `.type` never resolves, same as attrs today). - -4. `attrs.NOTHING` (required-field sentinel, used in `from_tokens()`'s - `required_tagged` computation) -> `FieldInfo.is_required()` on a - pydantic dataclass -- direct swap, confirmed. - -5. `attrs.asdict(row)` (`Package.to_dataframe()`) -> `dataclasses.asdict - (row)` works UNCHANGED on a pydantic dataclass instance, confirmed -- - pydantic dataclasses ARE real stdlib dataclasses under the hood. - -6. Positional construction (`construct_item`'s `item_cls(*values)`, - `cls(*before, tuple_vals)`) needs `kw_only` left at its default - (`False`) -- unlike `Component`/`Package`'s `kw_only=True` -- confirmed - working the same as attrs' current non-`kw_only` `Item`/`Record` - classes. - -7. `.metadata` (dict, read via `f.metadata.get(...)` throughout record.py/ - item.py) -> `Field(json_schema_extra={...})`, same convention already - chosen for `Component`/`Package` fields (`pydantic_dis_prototype.py`) -- - confirmed a plain stdlib-style `Field(metadata={...})` kwarg is - deprecated/unsupported on pydantic's `Field()`, so this is the only - viable convention, which is also the *consistent* one across the - codebase (one metadata convention, not two). - -CONCLUSION: Record/Item does NOT need to keep attrs, and a straight port -to `pydantic.dataclasses.dataclass` (not `BaseModel` -- positional -construction, see #6) is not just possible but strictly simplifies one -piece of real code (`_nested_class()` goes away). Nothing here requires -falling back to plain stdlib `dataclasses.dataclass` instead -- pydantic -is the better target, matching `Component`/`Package`'s choice, keeping -ONE field-metadata idiom (`json_schema_extra`) across the whole object -model rather than two. - -Run directly: `python docs/dev/prototypes/pydantic_record_prototype.py` -""" - -from __future__ import annotations - -import types -from pathlib import Path -from typing import Any, ClassVar, Optional, Union, get_args, get_origin - -from pydantic import ConfigDict, Field -from pydantic.dataclasses import dataclass, rebuild_dataclass - -_CFG = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, extra="forbid") - - -# ============================================================================ -# record_fields(): attrs.fields() -> __pydantic_fields__, with the one new -# piece of support code this migration needs -- see finding #2 above. -# ============================================================================ - - -def record_fields(cls: type) -> dict[str, Any]: - """Non-private fields of a Record (or Item) class, in declaration - order. NEW vs. the real attrs-based version: the guarded - `rebuild_dataclass()` call -- confirmed necessary (and sufficient) so - that a nested/composed field's annotation is the real class, not a - stale `ForwardRef`, even when called before any instance of `cls` has - ever been constructed (exactly what `from_tokens()` does).""" - if not cls.__pydantic_complete__: # type: ignore[attr-defined] - rebuild_dataclass(cls, force=True, _parent_namespace_depth=4) # type: ignore[arg-type] - return {n: f for n, f in cls.__pydantic_fields__.items() if not n.startswith("_")} # type: ignore[attr-defined] - - -def keyword_of(cls: type) -> str: - return vars(cls).get("_keyword", "") - - -def _nested_class(cls: type, annotation: Any) -> "type[RecordBase] | None": - """Replaces attrs-based `_nested_class()`'s custom qualname-walking - string resolver entirely (finding #3): once `record_fields()` above - has resolved the field, `annotation` (a pydantic `FieldInfo. - annotation`) IS the real class object already -- no sys.modules/ - qualname lookup needed.""" - args = get_args(annotation) - candidate = next((a for a in args if a is not type(None)), annotation) - return candidate if isinstance(candidate, type) and issubclass(candidate, RecordBase) else None - - -def _is_bool_field(finfo: Any) -> bool: - t = finfo.annotation - origin = get_origin(t) - if origin is types.UnionType or origin is Union: - t = next((a for a in get_args(t) if a is not type(None)), t) - return t is bool - - -def _coerce(token: Any, finfo: Any) -> Any: - meta = finfo.json_schema_extra or {} - if isinstance(meta, dict) and meta.get("time_series"): - try: - return float(token) - except (ValueError, TypeError): - return str(token) - t = finfo.annotation - origin = get_origin(t) - if origin is types.UnionType or origin is Union: - args = [a for a in get_args(t) if a is not type(None)] - if len(args) != 1: - return token - t = args[0] - if t is int: - return int(float(str(token))) - if t is float: - return float(token) - if t is Path: - return Path(token) - return token - - -def _is_tagged(finfo: Any) -> bool: - return bool((finfo.json_schema_extra or {}).get("tagged")) - - -def _tagged_tokens(name: str, v: Any) -> list: - if isinstance(v, bool): - return [name.upper()] if v else [] - return [name.upper(), v] - - -def _consume_tagged(tokens: list, i: int, name: str, finfo: Any) -> "tuple[Any, int] | None": - if str(tokens[i]).upper() != name.upper(): - return None - if _is_bool_field(finfo): - return True, 1 - if i + 1 >= len(tokens): - return None - return _coerce(tokens[i + 1], finfo), 2 - - -class RecordBase: - """Mixin for generated Record/Item types -- ported from - `flopy4.mf6.record.Record`. Provides symmetric - `to_tokens`/`from_tokens`, same shape as the attrs original, just - reading `__pydantic_fields__`/`json_schema_extra` instead of - `attrs.fields()`/`.metadata` (finding #7).""" - - def to_tokens(self) -> tuple: - inner_cls = type(self) - keyword = keyword_of(inner_cls) - tokens: list = [keyword.upper()] if keyword else [] - for tok in vars(inner_cls).get("_extra_tokens", ()): - tokens.append(tok) - all_fields = record_fields(inner_cls) - tagged = [(n, f) for n, f in all_fields.items() if _is_tagged(f)] - untagged = [(n, f) for n, f in all_fields.items() if not _is_tagged(f)] - for name, finfo in tagged + untagged: - v = getattr(self, name) - if v is None: - continue - if isinstance(v, RecordBase): - tokens.extend(v.to_tokens()) - elif (finfo.json_schema_extra or {}).get("tagged"): - tokens.extend(_tagged_tokens(name, v)) - elif isinstance(v, bool): - if v: - tokens.append(name.upper()) - else: - tokens.append(v) - return tuple(tokens) - - @classmethod - def from_tokens(cls, tokens: "str | list[str]") -> "RecordBase": - if isinstance(tokens, str): - tokens = tokens.split() - - skip: list[str] = [] - if kw := keyword_of(cls): - skip.append(kw.upper()) - skip.extend(t.upper() for t in vars(cls).get("_extra_tokens", ())) - if [t.upper() for t in tokens[: len(skip)]] == skip: - tokens = tokens[len(skip) :] - - all_fields = record_fields(cls) - - nested_fields = [(n, f, _nested_class(cls, f.annotation)) for n, f in all_fields.items()] - nested_fields = [(n, f, nc) for n, f, nc in nested_fields if nc is not None] - if nested_fields: - assert len(nested_fields) == 1 and len(nested_fields) == len(all_fields), ( - f"{cls.__name__}: exactly one nested record field, with no plain " - "fields of its own, is the only shape supported so far" - ) - n, _f, nested_cls = nested_fields[0] - return cls(**{n: nested_cls.from_tokens(tokens)}) # type: ignore[call-arg] - - tagged = {n.upper(): (n, f) for n, f in all_fields.items() if _is_tagged(f)} - untagged = [(n, f) for n, f in all_fields.items() if not _is_tagged(f)] - - kwargs: dict = {} - consumed: set[int] = set() - i = 0 - while i < len(tokens): - entry = tagged.get(str(tokens[i]).upper()) - result = None if entry is None else _consume_tagged(tokens, i, entry[0], entry[1]) - if entry is None or result is None: - i += 1 - continue - name, _finfo = entry - val, width = result - kwargs[name] = val - for j in range(width): - consumed.add(i + j) - i += width - - required_tagged = [ - (n, f) - for n, f in all_fields.items() - if _is_tagged(f) and f.is_required() and n not in kwargs - ] - positional_queue = required_tagged + untagged - remaining = [t for j, t in enumerate(tokens) if j not in consumed] - for (name, finfo), tok in zip(positional_queue, remaining): - kwargs[name] = _coerce(tok, finfo) - - return cls(**kwargs) # type: ignore[call-arg] - - -# ============================================================================ -# Demonstration: Oc.Headprint / Oc.Format, the exact nested-sibling-class -# shape _nested_class() exists for -- ported to pydantic dataclasses. -# ============================================================================ - - -class Oc: - @dataclass(config=_CFG) - class Format(RecordBase): - _keyword: ClassVar[str] = "PRINT_FORMAT" - columns: int = Field(default=10, json_schema_extra={"tagged": True}) - width: int = Field(default=12, json_schema_extra={"tagged": True}) - digits: int = Field(default=6, json_schema_extra={"tagged": True}) - - @dataclass(config=_CFG) - class Headprint(RecordBase): - """References its SIBLING `Format` -- via a literal string - annotation, fully qualified exactly like real codegen emits - (`package.py.jinja` line 38/40: `"Optional[{{ spec.class_name }}. - {{ f.type_annotation }}]"`), and declared BEFORE `Format` in - source order (the harder of the two orderings -- see finding #2). - """ - - _keyword: ClassVar[str] = "HEAD" - fmt: "Optional[Oc.Format]" = Field(default=None) - - -# ============================================================================ -# A plain (non-nested) tagged/positional record, for the required-field -# (attrs.NOTHING -> is_required()) and to_tokens/from_tokens round-trip. -# ============================================================================ - - -@dataclass(config=_CFG) -class Save(RecordBase): - _keyword: ClassVar[str] = "SAVE" - frequency: int = Field(json_schema_extra={"tagged": True}) # required -- no default - print_input: bool = Field(default=False, json_schema_extra={"tagged": True}) - - -def demo() -> None: - print("=" * 70) - print("Pydantic Record/Item prototype") - print("=" * 70) - - complete = Oc.Headprint.__pydantic_complete__ - print(f"\nOc.Headprint.__pydantic_complete__ before any use: {complete}") - - hp = Oc.Headprint(fmt=Oc.Format(columns=5, width=10, digits=3)) - print(f"constructed via nested composition: {hp}") - print(f"__pydantic_complete__ after construction: {Oc.Headprint.__pydantic_complete__}") - - tokens = hp.to_tokens() - print(f"to_tokens(): {tokens}") - assert tokens == ("HEAD", "PRINT_FORMAT", "COLUMNS", 5, "WIDTH", 10, "DIGITS", 3) - - round_tripped = Oc.Headprint.from_tokens(list(tokens)) - print(f"from_tokens() round-trip: {round_tripped}") - assert round_tripped == hp - - print("\nrecord_fields() resolves the nested field to a REAL class object") - print("(not a string -- _nested_class() needs no sys.modules/qualname walk):") - fields = record_fields(Oc.Headprint) - resolved = _nested_class(Oc.Headprint, fields["fmt"].annotation) - print(f" fields['fmt'].annotation -> {fields['fmt'].annotation}") - print(f" _nested_class() -> {resolved}") - assert resolved is Oc.Format - - print("\nrequired-field detection (attrs.NOTHING -> FieldInfo.is_required()):") - save = Save.from_tokens("SAVE FREQUENCY 5") - print(f" Save.from_tokens('SAVE FREQUENCY 5') -> {save}") - assert save.frequency == 5 and save.print_input is False - print(f" to_tokens() round-trip: {save.to_tokens()}") - assert save.to_tokens() == ("SAVE", "FREQUENCY", 5) - - print("\nAll assertions passed.") - - -if __name__ == "__main__": - demo() diff --git a/docs/dev/prototypes/pydantic_union_arm_prototype.py b/docs/dev/prototypes/pydantic_union_arm_prototype.py deleted file mode 100644 index 96892d00..00000000 --- a/docs/dev/prototypes/pydantic_union_arm_prototype.py +++ /dev/null @@ -1,381 +0,0 @@ -""" -Pydantic prototype: the keystring-union-arm coercion path -- the one item -`pydantic_chd_prototype.py` explicitly left unmeasured ("the keystring-union --arm case, e.g. LAK/SFR period settings, is a separate, still-unmeasured -surface"). Modeled on the real `flopy4/mf6/gwf/oc.py` `Oc` package, NOT a -synthetic example: `Oc` is the only in-repo package that exercises BOTH -layers of this machinery at once -- - - 1. Top-level union-arm dispatch: `Oc._stress_period_data` holds a - `Save | Print` Item per row, dispatched by its leading keyword token - (SAVE/PRINT) -- flopy4/mf6/item.py's `construct_union_item()`/ - `dispatch_union_item()`, called from `Package._coerce_item_list()`'s - `isinstance(item_cls, tuple)` branch. - 2. Nested union-arm dispatch: `Save`/`Print`'s own `ocsetting` field is - ITSELF a `All | First | Last | Frequency | Steps` Item, dispatched the - same way, one level down -- flopy4/mf6/item.py's `construct_item()`, - which detects a nested-union field via `_nested_union_classes()` and - recurses into `construct_union_item()` for it. - -Scope: only the raw-Python-value construction path (`Package. -_coerce_item_list` -> `construct_item`/`construct_union_item`), which is -what `Oc(stress_period_data={0: [(...)]})` goes through (confirmed against -the real package below). NOT `from_tokens`/`to_tokens` (the file-parsing -round trip) -- that machinery's pydantic-portability was already measured -independently in `pydantic_record_prototype.py` (a single nested class, not -a nested UNION of classes) and isn't repeated here; `ItemBase` below is -therefore deliberately thinner than the real `Item` mixin (no aux/boundname/ -array-of-cellid handling either -- `Oc.Save`/`Oc.Print` don't have those, -and adding them wouldn't exercise anything this spike is about). - -Baseline (real code, attrs) this reproduces byte-for-byte -- confirmed by -running against flopy4/mf6/gwf/oc.py directly before writing a line of this -file: - - >>> from flopy4.mf6.gwf import Oc - >>> oc = Oc(stress_period_data={0: [ - ... ("SAVE", "HEAD", "ALL"), - ... ("SAVE", "BUDGET", "STEPS", 1, 3, 5), - ... ("PRINT", "HEAD", "ALL"), - ... ]}) - >>> [(type(r).__name__, type(r.ocsetting).__name__, getattr(r.ocsetting, "steps", None)) - ... for r in oc.stress_period_data[0]] - [('Save', 'All', None), ('Save', 'Steps', (1, 3, 5)), ('Print', 'All', None)] - -Also matches test/mf6/test_mf6_adapters.py::test_oc_ocsetting_construct_item_positional -(`Oc(stress_period_data={0: [("SAVE", "HEAD", "ALL")]})` -> row is `Oc.Save`, -`row.ocsetting` is `Oc.All`) and the construction half of test/mf6/ -test_mf6_codec.py::test_oc_ocsetting_typed_dispatch (the dump/load/ -structure_component roundtrip in that test goes through a different code -path -- cattrs-based converters, out of scope here). - -KEY FINDING: the whole thing ports with NO new mechanism beyond what the -two earlier prototypes already established -- it's a straight composition -of `pydantic_chd_prototype.py`'s `SkipValidation`-wrapped Item-list field -(now `Optional[SkipValidation[dict[int, list[Save | Print]]]]` instead of -`Optional[SkipValidation[dict[int, list[Row]]]]`) and -`pydantic_record_prototype.py`'s lazy forward-ref resolution (now a -`"OcProto.All | OcProto.First | ..."` STRING forward ref naming FIVE -sibling classes instead of one -- confirmed pydantic's lazy resolver -handles a multi-name `|`-joined forward ref exactly the same way it handles -a single-name one, no special-casing needed). The one genuinely new piece -of code is `_is_item_union()` below -- the pydantic-side replacement for -item.py's `_nested_union_classes()`, and (same win record_prototype.py -found for `_nested_class()`) it is SIMPLER than the attrs original: no -qualname-walking string parse, just `get_origin`/`get_args` on the already- -resolved `FieldInfo.annotation`, because by the time `record_fields()`'s -guarded `rebuild_dataclass()` has run, that annotation IS the real -`OcProto.All | OcProto.First | ...` union object, not a string. - -Confirms the two remaining "Next steps" items from the plan doc's list are -no longer BOTH open -- this was the harder of the two (a real package using -double-nested keystring-union dispatch); the codegen-side change (emitting -`Field(json_schema_extra=...)` from make.py/filters.py) is still unmeasured, -but it's mechanical template work, not a new coercion mechanism -- nothing -found while writing this file suggests it would behave differently than the -by-hand `Field(...)` calls below. - -Run directly: `python docs/dev/prototypes/pydantic_union_arm_prototype.py` -""" - -from __future__ import annotations - -import types -from typing import Annotated, Any, ClassVar, Optional, Union, get_args, get_origin - -from pydantic import Field, SkipValidation -from pydantic.dataclasses import dataclass -from pydantic_dis_prototype import _CFG, PackageBase -from pydantic_record_prototype import keyword_of, record_fields - -# ============================================================================ -# ItemBase: thin port of flopy4/mf6/item.py's `Item` mixin -- just enough -# for construct_item()/construct_union_item() (see module docstring for what -# is deliberately NOT ported: to_tokens/from_tokens, aux/boundname/cellid). -# ============================================================================ - - -class ItemBase: - pass - - -# ============================================================================ -# _is_item_union(): replaces item.py's `_nested_union_classes()` -- see -# module docstring's KEY FINDING for why this is simpler than the original. -# ============================================================================ - - -def _is_item_union(annotation: Any) -> "tuple[type[ItemBase], ...] | None": - origin = get_origin(annotation) - if origin is Union or origin is types.UnionType: - arms = tuple( - a for a in get_args(annotation) if isinstance(a, type) and issubclass(a, ItemBase) - ) - return arms or None - return None - - -def dispatch_union_item( - values: list, arm_classes: "tuple[type[ItemBase], ...]" -) -> "type[ItemBase] | None": - """Port of item.py's `dispatch_union_item()`, unchanged in shape.""" - kw_map = {keyword_of(c).upper(): c for c in arm_classes if keyword_of(c)} - for v in values: - arm_cls = kw_map.get(str(v).upper()) - if arm_cls is not None: - return arm_cls - return None - - -def _collects_tail(finfo: Any) -> bool: - """A field that swallows every remaining positional value -- either a - nested Union[Item, ...] field (Oc's `ocsetting`) or an `array=True` - field (Oc.Steps' `steps`). Port of construct_item()'s `tuple_idx` - search condition, minus the aux/cellid arms real Item has that Oc's - classes don't use (see module docstring).""" - meta = finfo.json_schema_extra or {} - if isinstance(meta, dict) and meta.get("array"): - return True - return _is_item_union(finfo.annotation) is not None - - -def construct_item(item_cls: type, values) -> "ItemBase": - """Port of item.py's `construct_item()`.""" - fields = record_fields(item_cls) - items = list(fields.items()) - tuple_idx = next((i for i, (_, f) in enumerate(items) if _collects_tail(f)), None) - values = list(values) - if tuple_idx is None: - return item_cls(*values) - _, finfo = items[tuple_idx] - arm_classes = _is_item_union(finfo.annotation) - before = values[:tuple_idx] - trailing = values[tuple_idx:] - tail_val = ( - construct_union_item(trailing, arm_classes) if arm_classes is not None else tuple(trailing) - ) - return item_cls(*before, tail_val) - - -def construct_union_item(values, arm_classes: "tuple[type[ItemBase], ...]") -> "ItemBase | None": - """Port of item.py's `construct_union_item()`, unchanged in shape.""" - values = list(values) - arm_cls = dispatch_union_item(values, arm_classes) - if arm_cls is None: - return None - kw = keyword_of(arm_cls).upper() - kw_idx = next((i for i, v in enumerate(values) if str(v).upper() == kw), None) - if kw_idx is not None: - values = values[:kw_idx] + values[kw_idx + 1 :] - return construct_item(arm_cls, values) - - -# ============================================================================ -# _item_list_type(): pydantic_chd_prototype.py's version, extended (same as -# the real item_list_type()) to also return a tuple of arm classes. -# ============================================================================ - - -def _unwrap_skip_validation(t: Any) -> Any: - if get_origin(t) is Annotated: - return get_args(t)[0] - return t - - -def _unwrap_item(t: Any) -> "type[ItemBase] | tuple[type[ItemBase], ...] | None": - if isinstance(t, type) and issubclass(t, ItemBase): - return t - return _is_item_union(t) - - -def _item_list_type(field_type: Any) -> "type[ItemBase] | tuple[type[ItemBase], ...] | None": - args = get_args(field_type) - inner = next((a for a in args if a is not type(None)), None) - if inner is None: - return None - inner = _unwrap_skip_validation(inner) - if get_origin(inner) is dict: - _, val = get_args(inner) - val = _unwrap_skip_validation(val) - if get_origin(val) is list: - return _unwrap_item(get_args(val)[0]) - return None - - -# ============================================================================ -# UnionListPackageBase: Package._coerce_item_list, extended for the -# `isinstance(item_cls, tuple)` (keystring-union) branch -# pydantic_chd_prototype.py's ListPackageBase explicitly left out. -# ============================================================================ - - -@dataclass(config=_CFG, kw_only=True) -class UnionListPackageBase(PackageBase): - def _init_item_lists(self) -> None: - for fname, finfo in type(self)._pydantic_fields().items(): - meta = finfo.json_schema_extra or {} - if not (isinstance(meta, dict) and meta.get("block")): - continue - item_cls = _item_list_type(finfo.annotation) - if item_cls is None: - continue - raw = self.__dict__.get(fname) - if raw is None: - continue - if meta.get("block") == "period" or meta.get("fill_forward"): - coerced = { - kper: self._coerce_item_list(rows, item_cls) for kper, rows in raw.items() - } - object.__setattr__(self, fname, coerced) - else: - object.__setattr__(self, fname, self._coerce_item_list(raw, item_cls)) - - @staticmethod - def _coerce_item_list(data, item_cls) -> list: - """Port of `Package._coerce_item_list()`, both branches (this - prototype's whole reason for existing is the first one).""" - if isinstance(item_cls, tuple): - items = [] - for row in data: - if isinstance(row, item_cls): - items.append(row) - elif isinstance(row, dict): - kw = str(row.get("keyword", "")).upper() - arm = next((c for c in item_cls if keyword_of(c).upper() == kw), None) - if arm is not None: - items.append(arm(**{k: v for k, v in row.items() if k != "keyword"})) - else: - item = construct_union_item(row, item_cls) - if item is not None: - items.append(item) - return items - items = [] - for row in data: - if isinstance(row, item_cls): - items.append(row) - elif isinstance(row, dict): - items.append(item_cls(**row)) - elif isinstance(row, (list, tuple)): - items.append(item_cls(*row)) - return items - - def __post_init__(self) -> None: - self._init_item_lists() - super().__post_init__() - - -# ============================================================================ -# OcProto: models the real Oc (flopy4/mf6/gwf/oc.py), just the -# stress_period_data slice this spike is about. -# ============================================================================ - - -@dataclass(config=_CFG, kw_only=True) -class OcProto(UnionListPackageBase): - dfn_name: ClassVar[str] = "gwf-oc" - - @dataclass(config=_CFG) - class All(ItemBase): - _keyword: ClassVar[str] = "all" - - @dataclass(config=_CFG) - class First(ItemBase): - _keyword: ClassVar[str] = "first" - - @dataclass(config=_CFG) - class Last(ItemBase): - _keyword: ClassVar[str] = "last" - - @dataclass(config=_CFG) - class Frequency(ItemBase): - _keyword: ClassVar[str] = "frequency" - frequency: int = Field() - - @dataclass(config=_CFG) - class Steps(ItemBase): - _keyword: ClassVar[str] = "steps" - steps: tuple = Field(default=(), json_schema_extra={"array": True}) - - @dataclass(config=_CFG) - class Save(ItemBase): - _keyword: ClassVar[str] = "save" - rtype: Union[float, str] = Field() - ocsetting: ( - "OcProto.All | OcProto.First | OcProto.Last | OcProto.Frequency | " - "OcProto.Steps" - ) = Field() - - @dataclass(config=_CFG) - class Print(ItemBase): - _keyword: ClassVar[str] = "print" - rtype: Union[float, str] = Field() - ocsetting: ( - "OcProto.All | OcProto.First | OcProto.Last | OcProto.Frequency | " - "OcProto.Steps" - ) = Field() - - _StressPeriodDataItem = Save | Print - - stress_period_data: Optional[SkipValidation[dict[int, list[_StressPeriodDataItem]]]] = Field( - default=None, json_schema_extra={"block": "period", "fill_forward": True} - ) - - -# ============================================================================ -# Demonstration / smoke test -# ============================================================================ - - -def demo() -> None: - print("=" * 70) - print("Pydantic keystring-union-arm prototype (real Oc shape)") - print("=" * 70) - - complete = OcProto.Save.__pydantic_complete__ - print(f"\nSave.__pydantic_complete__ before any construction: {complete}") - - oc = OcProto( - stress_period_data={ - 0: [ - ("SAVE", "HEAD", "ALL"), - ("SAVE", "BUDGET", "STEPS", 1, 3, 5), - ("PRINT", "HEAD", "ALL"), - ] - } - ) - print(f"Save.__pydantic_complete__ after construction: {OcProto.Save.__pydantic_complete__}") - - rows = oc.stress_period_data[0] - print("\nstress_period_data[0]:") - for r in rows: - print(f" {type(r).__name__}(rtype={r.rtype!r}, ocsetting={r.ocsetting!r})") - - assert isinstance(rows[0], OcProto.Save) - assert isinstance(rows[0].ocsetting, OcProto.All) - assert isinstance(rows[1], OcProto.Save) - assert isinstance(rows[1].ocsetting, OcProto.Steps) - assert rows[1].ocsetting.steps == (1, 3, 5) - assert isinstance(rows[2], OcProto.Print) - assert isinstance(rows[2].ocsetting, OcProto.All) - - # -- dict-form input, dispatched by an explicit "keyword" key (the - # Package._coerce_item_list branch a raw tuple can't reach: a - # column-oriented / already-typed-arm dict input). - oc2 = OcProto( - stress_period_data={0: [{"keyword": "print", "rtype": "HEAD", "ocsetting": OcProto.All()}]} - ) - row2 = oc2.stress_period_data[0][0] - assert isinstance(row2, OcProto.Print) - assert isinstance(row2.ocsetting, OcProto.All) - print(f"\ndict-form (explicit keyword) input: {row2}") - - # -- a real OcProto.Save/Print instance passed straight through. - oc3 = OcProto(stress_period_data={0: [OcProto.Print(rtype="HEAD", ocsetting=OcProto.All())]}) - assert isinstance(oc3.stress_period_data[0][0], OcProto.Print) - print(f"instance-form input passes through: {oc3.stress_period_data[0][0]}") - - print("\nAll assertions passed.") - - -if __name__ == "__main__": - demo() diff --git a/docs/dev/pydantic-object-model-plan.md b/docs/dev/pydantic-object-model-plan.md deleted file mode 100644 index 9033f7a6..00000000 --- a/docs/dev/pydantic-object-model-plan.md +++ /dev/null @@ -1,899 +0,0 @@ -# Object model: attrs vs. pydantic (revised) - -## Status - -**The full migration is complete, on this branch (`pydantic-plan`), as of -2026-09-17.** Every class in the MF6 object model — `Component`, `Package`, -`Record`/`Item` (including — a scope expansion over this doc's earlier -"they don't need to migrate" finding below — the decision was made to -migrate them too, for a fully consistent codebase, once every mechanism -they needed was already independently confirmed working), all 8 hand-written -Dis/Disv pairs, `Ncf`, all 63 codegen-generated package files, and the full -~50-call-site consumer surface (`netcdf.py`, `converter/*`, `codec/*`, -`adapters.py`, `attrs_xarray.py`) — now targets -`pydantic.dataclasses.dataclass`. The complete test suite passes: **910 -passed, 0 failed, 11 skipped (pre-existing/unrelated), 4 xfailed -(pre-existing/unrelated)**, and a full-tree `ruff check .` is clean. See -"Full migration results (2026-09-17)" below for what was found doing the -real thing, as opposed to what the prototypes predicted. - -**tl;dr of how it got here:** The original "don't do this now" -recommendation (see "Revised recommendation" / "Re-assessed recommendation" -below) held through four rounds of prototyping, which is exactly why a full -migration then went ahead on this branch (see the 2026-09-17 update under -"Next steps") — every mechanism the object model needs was independently -de-risked first, cheaply, rather than discovered mid-migration. Four -runnable prototypes -(`docs/dev/prototypes/pydantic_{dis,chd,record,union_arm}_prototype.py`) -port `Dis`, a list-heavy package (`Chd`), the `Record`/`Item` row-type -subsystem, and the keystring-union-arm coercion path (`Oc`) to -`pydantic.dataclasses.dataclass` against the *current* (post-xattree, -post-`Row`) codebase shape, measuring real cost/ergonomics rather than -trusting the stale January prototype. Headline findings: target -`pydantic.dataclasses.dataclass`, never `BaseModel`; array-field, -Item-list, and keystring-union-arm coercion all collapse to one reusable -mechanism each, not per-field cost; `Component`/`Package`'s own mechanics -(parent/child wiring, `MutableMapping`, Item-list coercion, union-arm -dispatch) port cleanly; `item.py`/`record.py` don't strictly need to -migrate, but do so cleanly and delete real code -(`_nested_class()`'s custom resolver) when they do. The cost never fully -spiked in isolation — the codegen templates and the ~50-call-site consumer -surface (`netcdf.py`, `converter/*`, `codec/*`) — was then measured -directly by doing the real migration, per the 2026-09-17 decision, and is -now known rather than estimated (see below). - -Supersedes the prototype on `origin/plan-codegen` (`a9b77e8`, "planning", -2026-01-23) — six files (`pydantic_prototype.py`, -`pydantic_prototype_summary.md`, `codegen_comparison.md`, -`codegen_recommendation.md`, `codegen_architecture.py`, -`model_rebuild_explained.md`), never merged. That branch isn't deleted and -its code is still worth mining when this is picked back up (see "What's -still true," below) — but its headline recommendation is stale as of -2026-09. - -## Background - -Issue #282 ("Consider switching attrs -> pydantic") gives two motivations: -free JSON Schema, and easier construction/validation mechanics (notably, -validation that can run *after* assignment, which sidesteps friction in -load-time dimension resolution that attrs' init/convert/validate ordering -causes today). The January prototype demonstrated both are technically -achievable and recommended switching flopy4's object model -(`Package`/`Component`) to pydantic during the then-upcoming refactor. - -Discussion while writing `docs/dev/netcdf-spec-plan.md` (same architectural -question, applied to the NetCDF I/O object model) produced a sharper -version of the schema argument: JSON Schema is valuable on artifacts meant -for external/cross-tool interop — a file format, a spec other tools -consume — not on an in-memory object model whose only job is ergonomic -construction of a live simulation. That reasoning applies here too, and it -changes the calculus. - -## Why the January prototype is stale - -### Codebase drift - -- **xattree removed** (`67d0922`, 2026-09). The prototype spent real effort - proving compatibility with the xattree-adjacent parent/dimension-wiring - design it was written against. That constraint no longer exists — net - simplification, but it means the prototype's compatibility analysis - answers a question that no longer applies. -- **`Column`/`Schema` deleted, `Row` unified with `pk`/`fk` metadata** - (`78c506b`/`6cdfb2a`, 2026-08-19, per `mf6-object-model-plan.md` Phase - 0.6). The prototype's worked examples (DIS/NPF field declarations) are - ported against the pre-`Row` shape. -- **DFN spec parsing already flipped to pydantic** — `modflow_devtools.dfns` - dev3, consumed by flopy4's codegen (same `6cdfb2a`, "Phase 0.6a"). The - prototype predates this and had no visibility into it. -- **Every phase landed since January was built uniformly on - `attrs.fields()`** as the one field-introspection idiom, by explicit - design (`mf6-object-model-plan.md` wants exactly one idiom active at a - time — it says so directly, having just paid down a two-idiom problem for - `Column`/`Schema` vs. `Row`). Each phase that lands on attrs before a - pydantic swap happens is more surface area that swap eventually has to - re-touch. The prototype's "marginal cost, you're already refactoring" - framing assumed less of this had been built yet. - -### Value-conclusion drift - -The prototype's headline argument — schema-first design, JSON Schema "for -free" — is now satisfied independently, at the DFN-spec layer in devtools -(Phase 0.6a), without touching `Package`/`Component` at all. Same -conclusion as `netcdf-spec-plan.md`: the schema value lives with the spec -artifact meant for external interop, not with flopy4's in-memory -construction ergonomics. - -What's left standing on its own, once the schema argument is subtracted: -`validate_assignment=True` / `model_validator(mode="after")` replacing the -`__attrs_post_init__` super()-chain across `DimensionResolverMixin` → -`Component` → `Package` (`flopy4/dimensions.py`, `flopy4/mf6/component.py`, -`flopy4/mf6/package.py`). Real, still-open friction — but a narrower, -ergonomics-only case, not the two-pronged case the prototype made. - -## What's still true from the prototype (worth keeping) - -- `Annotated[NDArray[np.float64], ...]` field type hints work fine, dtype-precise. -- `model_validator` + `validate_assignment` does give implicit - post-assignment validation, which directly addresses the dimension- - resolution post-init chaining pain. -- Centralizing validation logic in a base-class `model_validator` so - generated classes reduce to field declarations is still sound in - principle — though flopy4's codegen has independently converged on thin - generated classes already via `flopy4/mf6/spec.py`'s `field()` wrapper, - so this is less of a differentiator than it was in January. -- pydantic is already a proven, unpinned, friction-free dependency in this - codebase (DFN spec parsing, `flopy4/mf6/netcdf.py`) — the version-pinning - risk mwtoews flagged on the issue hasn't materialized in practice. - -## Revised recommendation - -Don't do this now, in parallel with `mf6-object-model-plan.md`'s open -phases. Wait for one of: - -1. `mf6-object-model-plan.md` Phase 1 (generalized structuring) lands, so - there's exactly one field-introspection idiom to migrate off of, not two - competing ones mid-flight. -2. The `__attrs_post_init__`/`DimensionResolverMixin` chaining becomes an - active blocker on some other piece of real work (not hypothetical) — - that would justify pulling this forward ahead of (1). - -## Purpose of this branch - -Staging ground for updated prototyping, so that *whenever* one of the -above triggers is met, the go/no-go decision is made from real, current -measurements instead of the stale January prototype. The prototyping -itself (below) was done ahead of either trigger firing — deliberately: the -point was to de-risk the *cost estimate* now, cheaply, on a throwaway -branch, not to jump the queue on doing the actual migration. The "wait for -a trigger" recommendation is unchanged by any of it (see "Status" above). - -## Prototype results (2026-09-16) - -`docs/dev/prototypes/pydantic_dis_prototype.py` ports `Dis` (via -`DisBase`/`Package`/`Component`/`DimensionResolverMixin`, -`flopy4/mf6/gwf/{dis,disbase}.py`, `flopy4/mf6/{package,component}.py`, -`flopy4/dimensions.py`) to pydantic in its current, post-`Row` shape, and -runs (`pixi run -e dev python docs/dev/prototypes/pydantic_dis_prototype.py`) -against real assertions: derived dims (`nodes`/`ncpl`/`nvert`) compute -correctly, griddata scalar defaults broadcast to full arrays, a child -component (`ncf`, stubbed) gets parent-wired, `validate_assignment=True` -catches a bad post-construction assignment, and (v3, below) an explicit -`nodes=` kwarg is correctly rejected. It is a scoped-down measurement, not -a drop-in replacement — see "Explicitly out of scope" below. - -v3 of the prototype is built on `pydantic.dataclasses.dataclass`, not -`pydantic.BaseModel` — see "BaseModel vs. pydantic dataclasses" below for -why that switch happened and what it fixed. - -**What ported cleanly, lower cost than expected:** - -- Parent/child wiring is *simpler* in pydantic than attrs, not just - equivalent: attrs needs a private-attribute naming convention (`_parent` - field, `parent=` constructor kwarg, via leading-underscore mangling) to - get a public-looking accessor; pydantic just names the field `parent` - directly. No trick needed. -- `attrs.fields(cls)` → a pydantic dataclass's `__pydantic_fields__` - (or `cls.model_fields` if targeting `BaseModel`), `.metadata` dict → - `Field(json_schema_extra={...})`: a direct, mechanical swap, field by - field. Every place `spec.py`'s `field()` helper writes to `metadata[...]` - has an equally-simple pydantic equivalent. -- The single post-construction hook (`__post_init__` on a pydantic - dataclass) not auto-chaining across the MRO (each override must call - `super().__post_init__()` itself) turned out to be a wash, not a new - cost — attrs' `__attrs_post_init__` already required the same explicit - `super()` chaining discipline, and it's the same *single*-hook shape - attrs has (unlike BaseModel's two-hook `model_validator(mode="after")` + - `model_post_init` split — one more reason v3 prefers dataclasses). -- `validate_assignment=True` delivers the concrete ergonomics win issue - #282 actually asked for: a later bad assignment (`dis.xorigin = "not a - float"`) is now caught automatically. Confirmed working in the demo. -- Array-field coercion (below) also turned out to be a one-time cost, not a - per-field one — see the correction under "What's real." - -**What's real (a genuine, newly-surfaced correctness gap, but a one-time -fix, not a per-field one):** - -- Pydantic strictly validates `NDArray`-typed fields: constructing - `DisProto(delr=100.0, ...)` — the exact call shape `Dis(delr=100.0, ...)` - uses today, a bare scalar against an array-typed field — raised - `ValidationError: Input should be an instance of ndarray`. attrs never - validates this (no validator attached to the field by default), so the - scalar-default-for-an-array-typed-field pattern (used throughout - DIS/DISV/NPF/IC/STO/... griddata fields) just works there today. - An earlier revision of this prototype fixed this with a `field_validator` - declared per array field on `DisProto` and described it as an unavoidable - per-generated-field cost. **That was wrong, and worth flagging as a - correction rather than quietly fixing:** a single `field_validator("*", - mode="before")`, defined once on the shared `PackageBase`, driven by each - field's own `json_schema_extra["shape"]` metadata (the same metadata - `flopy4/mf6/spec.py`'s `field()` helper already emits today), covers - every array field on every subclass — present and future — including - under `validate_assignment=True` (`d.delr = 5.0` after construction still - coerces correctly, confirmed with a standalone test). Codegen doesn't - need to emit anything new for this; the `shape=` metadata it already - writes is sufficient. -- `attrs.field(init=False)` (`DisBase`'s derived `nlay`/`nrow`/`ncol`/ - `ncpl`/`nvert`/`nodes` — computed, never user-supplied) has **no working - `BaseModel` equivalent** — this was v2 of the prototype's biggest - unresolved gap, and it's resolved in v3 by targeting - `pydantic.dataclasses.dataclass` instead. See "BaseModel vs. pydantic - dataclasses" below. -- `attrs.Factory(lambda self: ..., takes_self=True)` (`Component.name`'s - default: the lowercased *runtime* class name) has no direct - `default_factory=` equivalent (those callables take no arguments) in - either BaseModel or a pydantic dataclass — filled in inside the single - post-construction hook instead (`__post_init__`, on the dataclass; a - `model_validator(mode="after")` on the now-abandoned BaseModel version). - One extra method either way, where attrs needed a one-line `Factory` — - the only place this measurement found pydantic costing a genuinely - unavoidable few extra lines versus attrs. - -**Explicitly out of scope for this measurement (deferred, not glossed -over)** — each of these is real remaining migration surface, not yet -priced: - -- Every consumer that reads `attrs.fields()`/`.metadata` off a live - `Package` today. Measured directly (not estimated): ~50 call sites across - 12 files (`component.py`, `package.py`, `dimensions.py`, `spec.py`, - `item.py`, `record.py`, `adapters.py`, `attrs_xarray.py`, `netcdf.py`, - `converter/ingress/structure.py`, `converter/egress/unstructure.py`, - `gwf/disbase.py`), concentrated most heavily in - `converter/ingress/structure.py` and `netcdf.py`. Each individual call - site is the same mechanical swap this prototype already demonstrates - (`attrs.fields(cls)` → `cls.model_fields`, `attr.metadata.get(...)` → - `finfo.json_schema_extra.get(...)`) — no single one is hard. The cost is - the count: ~50 independent edit sites is real surface area to touch and - re-test, and dominates total migration size far more than porting any - individual leaf package's field declarations does. Not measured here: - whether `attrs.Attribute.type` and pydantic's `FieldInfo.annotation` - ever disagree on a case this prototype didn't exercise (e.g. how each - represents `Optional`/`Union` for a field type) — worth checking against - the two largest files above before trusting the swap is mechanical - everywhere. - -## Prototype results: list-heavy package (2026-09-16) - -`docs/dev/prototypes/pydantic_chd_prototype.py` prices out the two things -the `Dis` prototype explicitly deferred: `Component`'s full `MutableMapping` -interface for a *list*-kind child field (several packages in one slot, not -just `Dis.ncf`'s single-child case), and `Package`'s Item-list period-data -coercion, modeled on the real `Chd`/`Chd.StressPeriodData` -(`flopy4/mf6/gwf/chd.py`). Run via -`pixi run -e dev python docs/dev/prototypes/pydantic_chd_prototype.py`. - -**The MutableMapping half ports cleanly**, once one attrs convention is -matched exactly: `Component._is_default_child_name()`'s check — is this -child's `.name` still at its class-name default, not just `is None` — has -to be replicated verbatim. Pydantic's dataclass `__post_init__` already -defaults every child's `.name` before the parent ever sees it (same as -`ComponentBase.__post_init__` in the `Dis` prototype), so by the time a -list-kind parent's `_set_child_parents()` runs, `child.name` is never -actually `None` — a naive `is None` check silently fails to disambiguate -same-class siblings (confirmed by running this prototype with that bug in -place: two fresh `ChdProto()` children both landed on `"chd1"` for their -would-be `"chd0"`/`"chd1"` names, since only the second matched `used`). -Fixed by porting the real `_is_default_child_name` equality check instead. -Once that's right, `__getitem__`/`__setitem__`/`__delitem__`/`__iter__`/ -`__len__`, auto-naming (`f"{field}{i}"`), and parent-stamping on -`__setitem__` all behave identically to `Component`'s real semantics -(demo asserts construction-time naming, explicit-key replacement, and -deletion all work). - -**The Item-list half surfaces one real, new problem pydantic-specific to -this field shape, not the array-field one already solved:** attrs applies -zero validation to a field like `Chd._stress_period_data` (no -validator/converter declared) — raw tuples/dicts pass through attrs' -`__init__` untouched, and `Package.__attrs_post_init__` coerces them -afterward. A plain pydantic-typed equivalent -(`Optional[dict[int, list[Row]]]`) does NOT behave this way: it's eagerly, -strictly validated at construction, so the exact raw-tuple/raw-dict input -`Chd(stress_period_data=...)` accepts today raises -`ValidationError: Input should be an instance of Row` before any -post-init coercion hook runs — confirmed directly, including a control -case proving the identical field without the fix does reject the identical -input the fixed version accepts. `pydantic.SkipValidation[...]` fixes it -(confirmed: same raw input accepted, coercion still runs in -`__post_init__` exactly like the attrs version), at two small, genuinely -new costs: - -- `flopy4/mf6/item.py`'s `item_list_type()` needs one extra unwrap step - (`Annotated[X, SkipValidation()]` → `X`) before its existing - `get_origin`/`get_args` walk reaches `dict[int, list[Row]]` — confirmed - the real function's current logic doesn't do this and would silently - return `None` (no item type found) without it. Small, mechanical, - one-time addition to one function. -- `SkipValidation` also opts the field out of `validate_assignment` - re-validation, confirmed directly (`pkg.stress_period_data = "junk"` is - silently accepted). Not a regression versus attrs (no validator is - declared on this field today either), but it is a per-field, explicit - opt-out rather than something that falls out of the shared config the - way array-field coercion does. - -**Corollary, worth stating plainly: `flopy4/mf6/item.py`/`record.py` (the -`Item`/`Record` row-type subsystem — token round-tripping, -`construct_item`/`construct_union_item`, cellid/aux/boundname handling) do -not need to migrate to pydantic at all.** With `SkipValidation`, pydantic -never inspects what's inside the list — `ChdRowProto` in this prototype is -a genuine, unmodified `attrs.define` class, exactly like the real -`Chd.StressPeriodData`. A real migration can leave `item.py`/`record.py` -attrs-based indefinitely and only port `Component`/`Package` (and -generated leaf classes) to pydantic — a materially smaller migration -surface than porting the whole object model in one pass would suggest. - -**Still out of scope after this measurement:** the keystring-union-arm -case (`construct_union_item` — LAK/SFR/MAW/UZF period settings, several -`Item` subclasses sharing one field via a `Union`) — this prototype only -covers the plain (non-union) coercion path `Chd`/`Wel`/`Drn`-style packages -use. `Component`'s "dict"-kind child collection (as opposed to "list") is -also still unexercised, though nothing found here suggests it would behave -differently from the "list" case's `_children`/naming logic. - -## Item/Record: does it need attrs? (2026-09-16) - -The previous section's corollary said `item.py`/`record.py` don't *need* -to migrate — true, `SkipValidation` means a `Package` that's moved to -pydantic can leave them alone indefinitely. Direct question asked -separately: if they *did* move, would they need to stay on attrs, could -they target plain stdlib `dataclasses.dataclass`, or does pydantic work -here too? `docs/dev/prototypes/pydantic_record_prototype.py` ports -`Record`'s core mechanics (`to_tokens`/`from_tokens`, the metadata-driven -field walk `record_fields()` does) to a pydantic dataclass and answers -this directly — run via -`pixi run -e dev python docs/dev/prototypes/pydantic_record_prototype.py`. - -**Nothing here requires attrs specifically.** Grepped the real codebase -first: no Record/Item field anywhere declares `validator=`/`converter=` -(those only appear on `Component`/`Package`-level fields, e.g. `Gwf.dis`'s -`convert_grid`) — `_coerce()` does its own manual, explicit coercion, -called directly from `from_tokens()`, never wired through attrs machinery. - -**One real attrs-specific behavior IS load-bearing, and it's not the one -the array-field/Item-list prototypes hit:** `_nested_class()` exists -because a composed record field (e.g. `Oc.Headprint.fmt: "Oc.Format"`, -generated by `package.py.jinja` lines 38/40 as a literal, fully-qualified -string) names a **sibling class inside the same enclosing class** — -unresolvable via any module-global lookup at class-body-execution time, -since Python class bodies can't see sibling names in an enclosing class's -scope. attrs' default behavior of leaving `f.type` as the literal, -unevaluated string is exactly what makes `_nested_class()`'s lazy, -custom qualname-walking resolution possible. - -- **Confirmed this is NOT attrs-specific**: plain stdlib - `dataclasses.dataclass` does the identical thing — `dataclasses.fields - (cls)[i].type` is *also* just the raw, unevaluated string, since it's a - property of how Python stores a string-literal annotation, not of - attrs. A straight port to stdlib dataclasses needs zero changes to - `_nested_class()`. -- **Pydantic dataclasses behave differently, but not how a first guess - ("eager resolution breaks forward refs") would suggest.** Confirmed by - testing the *exact* problem shape — `Format` referencing `Headprint` - declared in the *harder* order (referencing class first, referenced - class second, source-order matching what real codegen's - `spec.inner_classes` loop isn't guaranteed to avoid): pydantic defers - schema-building for an unresolvable annotation - (`cls.__pydantic_complete__` is `False` right after decoration) and - resolves it **lazily** — constructing an instance with **no explicit - fixup call at all** just works, self-healing on first use (the same - effective mechanism `typing.get_type_hints()` uses: `eval()` against the - defining module's globals plus qualified attribute access, succeeding - once `Oc.Format` exists as a real attribute of `Oc`, regardless of - which class was defined first in source). The one real gap: something - that inspects `__pydantic_fields__` **before any instance is ever - constructed** — exactly what `from_tokens()` does (calls - `record_fields(cls)` before building the instance it returns) — sees - the annotation still as an unresolved `ForwardRef`. Fixed with one - guarded `pydantic.dataclasses.rebuild_dataclass(cls)` call inside - `record_fields()` itself — small, centralized, confirmed working even - with zero prior instances of the target class. -- **Genuine win for pydantic over stdlib dataclasses here, not just - parity:** once resolved, a pydantic dataclass's `FieldInfo.annotation` - **is the real class object**, not a string — confirmed - `fields["fmt"].annotation` is identical (`is`) to `Optional[Oc.Format]` - built directly. That means `_nested_class()`'s entire ~15-line custom - qualname-walking resolver (`sys.modules` lookup, `__qualname__` - splitting, `getattr` walk) becomes **unnecessary code**, replaced by - `isinstance(annotation, type) and issubclass(annotation, RecordBase)` - (after unwrapping `Optional`) — confirmed working in the prototype's - `_nested_class()`. A stdlib-dataclass port would have to *keep* - `_nested_class()` unchanged, same as attrs today. - -**Everything else is the same mechanical swap already established -elsewhere in this doc, confirmed again here:** - -- `attrs.NOTHING` (the `required_tagged` sentinel in `from_tokens()`) → - `FieldInfo.is_required()`. Direct swap. -- `attrs.asdict(row)` (`Package.to_dataframe()`) → `dataclasses.asdict - (row)` works **unchanged** on a pydantic dataclass instance, confirmed - — pydantic dataclasses are real stdlib dataclasses underneath. -- Positional construction (`construct_item`'s `item_cls(*values)`, - `cls(*before, tuple_vals)`) needs `kw_only` left at its default - (`False`), unlike `Component`/`Package`'s `kw_only=True` — confirmed - working, matching attrs' current non-`kw_only` `Item`/`Record` classes. -- `.metadata` → `Field(json_schema_extra={...})`, the same convention - already chosen for `Component`/`Package` (not a stdlib-style - `Field(metadata={...})` kwarg — confirmed that's deprecated/unsupported - on pydantic's `Field()`). One metadata idiom across the whole object - model, not two. - -**Consequence:** if/when `item.py`/`record.py` ever do migrate, target -`pydantic.dataclasses.dataclass`, not plain stdlib `dataclasses.dataclass` -— it's not just equally viable, it deletes real code -(`_nested_class()`'s custom resolver). But per the corollary in the -previous section, migrating them is still not *required* by a -`Component`/`Package` migration — `SkipValidation` decouples the two, so -this can be sequenced independently, or skipped entirely, without -blocking anything else in this plan. - -## Prototype results: keystring-union-arm coercion (2026-09-17) - -The one item "Prototype results: list-heavy package" explicitly left -unmeasured: a package whose period data is a *union* of Item types -dispatched by leading keyword token (LAK/SFR/MAW/UZF-style period -settings), not a single Item type like `Chd`. `docs/dev/prototypes/ -pydantic_union_arm_prototype.py` ports this against the real `flopy4/mf6/ -gwf/oc.py` `Oc` package — the only in-repo package currently exercising -this machinery at all (grepped: no `list[Union[...]]`-typed top-level -period-data field exists yet in `gwf/`/`gwt/`/`gwe/`/`prt/` — LAK/SFR/MAW/ -UZF's own period-data fields turn out to be plain Item types, not unions; -`Oc.stress_period_data` is the real thing to model, and it's actually a -harder case than any of those four, since it exercises the coercion path -at BOTH levels at once (see below)). - -`Oc` exercises two nested layers of the same mechanism: - -1. Top-level: `Oc._stress_period_data` holds `Save | Print` (a `tuple` of - arm classes, item.py's `construct_union_item()`/`dispatch_union_item()`, - called from `Package._coerce_item_list()`'s `isinstance(item_cls, - tuple)` branch — the branch `pydantic_chd_prototype.py`'s version of - this method didn't implement). -2. Nested: `Save`/`Print`'s own `ocsetting` field is *itself* a - `All | First | Last | Frequency | Steps` union, dispatched the same way, - one level down (item.py's `construct_item()` detecting a nested-union - field via `_nested_union_classes()`). - -Confirmed against the real `Oc` package before writing any prototype code -(`Oc(stress_period_data={0: [("SAVE", "HEAD", "ALL"), ("SAVE", "BUDGET", -"STEPS", 1, 3, 5), ("PRINT", "HEAD", "ALL")]})`) and reproduced -byte-for-byte by the prototype, including the untyped `steps=(1, 3, 5)` -int tuple (not floats — `construct_item()`'s array-field branch does no -numeric coercion; that only happens in the separate `from_tokens()` path, -out of scope here and already measured independently in -`pydantic_record_prototype.py`). - -**Result: no new mechanism needed.** This is a straight composition of -findings already on record — `SkipValidation` (the list-heavy-package -result) wrapping a field now typed `dict[int, list[Save | Print]]` instead -of `dict[int, list[Row]]`, plus lazy forward-ref resolution (the Item/ -Record result) now resolving a `"OcProto.All | OcProto.First | ..."` -string naming FIVE sibling classes instead of one — confirmed pydantic's -lazy resolver handles a multi-name `|`-joined forward ref exactly like a -single-name one. The one new piece of code, `_is_item_union()`, replaces -item.py's `_nested_union_classes()` and is *simpler* than the original for -the same reason `_nested_class()` was in the Item/Record section above: no -qualname-walking string parse, just `get_origin`/`get_args` on the -already-resolved `FieldInfo.annotation`. - -This was the harder of the two items "Next steps" listed as still open — -see the updated list below. - -## Full migration results (2026-09-17) - -Staged as 7 commits on `pydantic-plan` -(`249ffe9`, `75af85c`, `b6c2019`, `f5dc069`, `fbcbb3a`, `ab22c9f`, -`f8a5359`, `b29dfc4`), each independently runnable/testable, in the order: -`Record`/`Item` → `Component`+`DimensionResolverMixin` → `Package`+ -`disbase.py` → codegen pipeline → regenerate (63 files) → consumer surface -→ full-suite fixup. Every mechanism the prototypes measured ported exactly -as predicted; everything below is what the prototypes *couldn't* measure -(the codegen templates and consumer surface were explicitly out of scope -for isolated spiking, per the 2026-09-17 decision below) or got newly -surfaced by running the real ~250-fixture MF6 corpus test and the full -`test/` suite, not just hand-picked demo assertions. - -**Confirmed exactly as the prototypes predicted, at full scale:** - -- `pydantic.dataclasses.dataclass` (never `BaseModel`) with a shared - `ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, - extra="forbid")`, applied per-class via `config=CFG` since pydantic - doesn't inherit class-level config the way attrs does. -- One shared `field_validator("*", mode="before")` on `Package` - (`_coerce_arrays`), driven by each field's own `shape=`/`block=` - metadata, handles array coercion for all ~50 generated packages — no - per-field or per-package validator needed, exactly as measured. -- `SkipValidation[...]` + `item_list_type()`'s one-line unwrap fix handles - every Item-list field, plain and keystring-union-arm alike (`Oc` - directly, plus `Lak`/`Lke`/`Lkt`/`Prp` transitively via the same - mechanism) — no new mechanism needed beyond what the union-arm prototype - already found. -- `Record`/`Item` migrated cleanly to pydantic dataclasses and - `_nested_class()`'s custom qualname-walking resolver was deleted, as - predicted, replaced by a direct `isinstance` check on the now-real - `FieldInfo.annotation`. -- The ~50-call-site consumer surface (`netcdf.py`, `converter/ingress/ - structure.py`, `converter/egress/unstructure.py`, `adapters.py`, - `attrs_xarray.py`, `spec.py`) was indeed mechanical field-by-field - (`attrs.fields()`/`.metadata` → `__pydantic_fields__`/ - `json_schema_extra`), confirming the prototype's estimate that the cost - here is the *count* of sites, not the difficulty of any one of them. - -**New findings, only visible at real scale (not predictable from the -prototypes' scoped-down demos):** - -- Two real, pre-existing bugs it took the actual ~250-fixture corpus test - and full `test_quickstart_grid` mf6-binary integration test to surface — - neither reproducible from a hand-picked demo package: - - A Python `for` loop shadowing bug in - `converter/ingress/structure.py`: several loops used `name` as the - loop variable while `structure_component()` itself takes a `name` - keyword parameter (`for` loops don't scope in Python, unlike - comprehensions) — silently correct under attrs (which never validated - the resulting garbage against a real field type) but wrong under - pydantic's real validation, causing roughly 300 of the migration's - ~400 initial test failures. One rename (`name` → `fname`) fixed it. - - `chdg.py`/`drng.py`/`ghbg.py`/`rivg.py`/`welg.py` losing a - hand-maintained `auto_from="stress_period_data"` value on `maxbound` - when Stage 5 regenerated them — confirmed via a controlled before/ - after run of `test_quickstart_grid` against the *unmigrated* `develop` - branch (passes there, since those 5 files' on-disk value there was - still a stale-but-present hand-patch, untouched since it was last - regenerated) that this wasn't caused by the attrs→pydantic swap - itself. **Follow-up correction (`df6db25`, same day): this was - mischaracterized just above as an old, permanent codegen limitation — - it is not.** Git archaeology (not guesswork) found `filters.py` had a - working, general `has_maxbound`-gated mechanism for this as recently - as `3db0e36` (2026-09-02), which `67d0922` ("drop xattree (#356)", - 2026-09-16 — the same refactor this branch is based on) silently - dropped while reworking `maxbound` into a computed `@property` for - list-variant packages, with no explanation in that commit for why the - G-variant/`auto_from` half of the change went with it. Confirmed via - a scratch checkout of `origin/develop` (unmodified) that running - `pixi run -e dev generate-classes` there *today* silently strips - `auto_from` from the same 5 files — this is a **live, currently - unfixed regression on `develop` itself**, not a stable pre-existing - gap, and it also already-silently affects `gwf/api.py`/`gwt/api.py` - (no stale value there to mask it, just missed by every test so far). - Fixed at the actual root cause in `filters.py`'s `field_metadata()`: - since `build_component_spec` already `continue`s past every case - where `maxbound` becomes a computed property before - `field_metadata()` ever runs, every `maxbound` field that mechanism - still sees is unconditionally the "real, MF6-auto-inferred field" - case (confirmed against every historical plain-`maxbound` field at - `3db0e36` — zero counterexamples) — no `has_maxbound` parameter - needed, unlike the mechanism that regressed. Verified by regenerating - the full corpus: `chdg`/`drng`/`ghbg`/`rivg`/`welg` reproduce their - existing (till now hand-patched) content exactly, and `gwf/api.py`/ - `gwt/api.py` gain the field too. This branch's copy is now fixed at - the codegen level; `develop`'s is not yet — worth reporting upstream. - - **Second follow-up (`812a906`, 2026-09-17): went further and asked - whether `auto_from` is needed for these 5 packages at all, rather than - treating it as settled.** Checked the real MF6 Fortran source - (`src/Model/ModelUtilities/BoundaryPackageExt.f90`, - `BndExtType%source_dimensions`, via `gh api` against - `MODFLOW-ORG/modflow6`): for any `READARRAYGRID` ("G-variant") package, - the entire branch that reads a user-supplied `MAXBOUND` from the input - file is skipped unconditionally — `this%maxbound = this%dis%get_ncpl()` - always runs instead. A `MAXBOUND` line in a real `.chdg` (etc.) file's - DIMENSIONS block parses without error but has zero effect: never read, - never logged, never validated — genuinely dead input, not just an - inconvenient one, confirmed in the implementation rather than inferred - from the schema alone. This is real, not the same story as the ordinary - list-input variant, where `gwf-chd.dfn`'s `stress_period_data` field - declares `shape (maxbound)` — MF6 genuinely needs that value there to - size the read. - - Fixed at the actual source — the DFN schema itself, not flopy4's - codegen — on a new branch, `fix-gvariant-maxbound`, pushed to - `wpbonelli/modflow-devtools` (not yet merged upstream): the dev3 - migration (`migrate_to_v2_0_0_dev3.py`) no longer emits a `maxbound` - DIMENSIONS field (or the matching `dims.maxbound` entry) for any - package with a `readarraygrid` options-block keyword. Verified against - `autotest/dfns/` there (204 passed) with reviewed, hand-checked - snapshot diffs (only the 5 expected packages changed, only the - `maxbound` entries removed). - - That same verification — regenerating flopy4 against the new devtools - branch to confirm it actually worked end-to-end, not just trusting the - schema-level test — surfaced a second, unrelated, independently - real bug: `Dfns.load()` (what `RemoteDfnRegistry.spec()` actually calls - for a live sync, i.e. flopy4's own `generate-classes` path) silently - stopped its migration at schema_version `"2.0.0.dev2"` rather than - advancing to `"2.0.0.dev3"` (`CURRENT_SCHEMA_VERSION`), with no error — - meaning the maxbound fix above would never have reached flopy4's real - generated output at all without also fixing this. Confirmed directly: - the first version of the devtools fix alone produced zero change when - flopy4 was regenerated against it. Also fixed on the same branch, - together with a second devtools-side bug this uncovered (`migrate.py`'s - own dev2-CLI-output path was depending on `Dfns.load()`'s buggy - stop-at-dev2 behavior, so simply "fixing" `Dfns.load()` alone broke - that caller — given its own direct per-file loop instead, mirroring the - already-correct dev3 branch). - - flopy4 itself was NOT repointed at the unmerged devtools branch - permanently (would have meant depending on someone else's unreviewed - fork/branch, and empirically caused a `~/.cache/modflow-devtools` - resource-cleanliness surprise — the corpus-loading test's - "model registry" cache is siblings-in-the-same-cache-tree with the DFN - cache, so clearing one while testing this cleared the other too, purely - an artifact of local testing, not of the fix). Verified for real by - temporarily repointing `pyproject.toml` at the fork branch, running - `pixi run generate-classes` + the full suite, then reverting the - dependency and keeping only the resulting, hand-confirmed-correct - 5-file diff — `chdg.py`/`drng.py`/`ghbg.py`/`rivg.py`/`welg.py` no - longer declare a `maxbound` field at all. `filters.py`'s - `field_metadata()` keeps its `auto_from` fallback, now reached only by - the separate, unverified `gwf-api`/`gwt-api` case. -- Real MF6 test-fixture data-quality issues, tolerated silently by attrs - (zero field validation) and correctly rejected by pydantic's real - types, found only by running actual DFN-driven test/example files: IMS - stale legacy extra tokens on scalar fields ("OUTER_MAXIMUM 100 500"), - Gwf "NEWTON UNDER_RELAXATION" (bool/keyword field with a trailing - modifier token), a single-name `auxiliary` option missing `shape=` - metadata, `Tdis.start_date_time` splitting an ISO datetime into - multiple tokens or a bare-int year, pandas `NaN` for a missing optional - Item-list column, and `Npf.rewet`'s field name colliding with its own - inner Record's `_keyword`, causing wrong dispatch priority. All fixed - at the point where the real data meets the new (correct) validation, - not by loosening validation. -- A distinction the array-coercion prototype didn't need to make because - its demo never exercised it: an `xr.DataArray` satisfies the same duck - typing as a real dask array (`hasattr(dtype)`/`hasattr(shape)`), but - only a dask array's laziness needs preserving — an `xr.DataArray` still - needs materializing via `np.asarray()` to satisfy a stricter bare - `NDArray[...]`-typed field (`Disv.top`/`botm` and similar). Fixed by - narrowing `_coerce_arrays`'s passthrough condition to - `isinstance(v, np.ndarray) or _is_dask_array(v)` specifically. -- `Disv.iv`/`xv`/`yv` (all 4 model families) are commonly constructed from - a plain list (`from_grid()`) but carry no `shape=`/`block="griddata"` - metadata, so the shared `Package._coerce_arrays` validator never reaches - them — needed small, field-scoped `mode="before"` validators declared - locally on each `Disv` class, the one place per-field validators - (rather than the shared one) were actually necessary. -- `Dis`/`Disv`'s `top`/`botm` fields (all 4 model families) were declared - as required `NDArray[...]` but given a real `default=None` (and - constructed with an explicit `None` on some grid-conversion paths) — a - type/default mismatch attrs never checked. Fixed by making them - `Optional[NDArray[...]]`, matching `idomain`'s already-correct pattern. -- Field-redeclaration ordering differs: when a subclass redeclares a field - its base class already declared (`DisBase`'s `nlay`/`nrow`/`ncol`/... - vs. `Dis`'s own `nlay`/`ncol`/`nrow`), attrs moves the field to the - subclass's redeclaration position; pydantic (like plain stdlib - dataclasses) keeps it at the base class's original position. A real, - permanent behavioral difference, not a bug — confirmed via - `Dis.__pydantic_fields__` directly and reflected in updated test - expectations, not worked around. -- `init=False` fields are fundamentally incompatible with `extra="allow"` - in pydantic — it refuses the combination outright - (`PydanticUserError`), confirmed empirically. Since `DisBase`'s derived - dimensions need `init=False` + `extra="forbid"` to work at all, this - closes off the specific kind of freeform post-construction attribute- - bolting attrs' `slots=False` tolerated; one flopy3-compat test needed - the same `object.__setattr__()` escape hatch the real source already - uses internally for the same reason. -- Missing-required-field construction raises pydantic's own - `ValidationError` where attrs raised `TypeError` — a real, permanent - exception-type difference for any code that catches construction - errors narrowly. -- A codegen bug newly introduced by the migration itself (not - pre-existing): `_generated_imports()` in `make.py` unconditionally - imported `Field` even for files with no `inner_classes` (the only place - a bare `Field()` call is ever emitted — everything else routes through - `spec.py`'s `field()`/`path()` wrappers), producing 49 unused-import - lint errors across the regenerated corpus. Fixed at the root cause - (scoped to `has_inner_classes`), plus a one-time `ruff --fix` sweep. - -**Not needed, contrary to what might have been assumed going in:** no -change to `to_field_type()`/`get_field_type()`/`child_field_candidates()` -beyond the mechanical `Attribute`→`FieldInfo` swap already measured; no -change to how computed `@property` fields (e.g. `maxbound`) stay invisible -to field introspection — `unstructure.py`'s existing -`isinstance(..., property)` special-case needed no changes. - -## BaseModel vs. pydantic dataclasses - -Every pydantic-based sketch this codebase has produced so far — the -January prototype, `flopy4/mf6/netcdf.py`, `modflow_devtools.dfns` — is -built on `pydantic.BaseModel`. v2 of this prototype followed that default -without examining it. It shouldn't have: `pydantic.dataclasses.dataclass` -is the closer match to what the object model actually needs, confirmed -directly (not assumed) by testing both: - -- **`Field(init=False)`.** On `BaseModel`, it's accepted by the field - constructor but has **no runtime effect at all** — confirmed: - `M(nodes=999)` on a `BaseModel` with an `init=False` field silently - succeeds and sets `nodes=999`, even under `extra="forbid"`. It's - type-checker-only metadata there (part of `@dataclass_transform` - support), not an enforced constraint. On a pydantic dataclass, the - *identical* `Field(init=False)`, combined with `extra="forbid"` in - config, works exactly like attrs: `DisProto(nodes=999)` raises - `ValidationError: Unexpected keyword argument` — confirmed in the demo. - This was v2's single biggest unresolved gap; v3 closes it for free, no - extra code beyond the `Field(init=False)` call attrs' equivalent already - needed. -- **Everything else composes cleanly, confirmed with standalone tests - before committing to the rewrite:** a pydantic dataclass subclassing - `ABC` and mixing in `collections.abc.MutableMapping` works - (`isinstance(d, MutableMapping)` is `True`); `kw_only=True` is a direct - decorator argument, matching `@attrs.define(kw_only=True)` exactly; - `field_validator`/`validate_assignment=True` work identically to the - `BaseModel` case; direct `self.__dict__[...]` writes (the - `_dimension_cache` lazy-init pattern `flopy4/dimensions.py` uses today) - and `object.__setattr__` bypass-writes (used throughout - `Package`/`DisBase` to update a field without re-triggering validation) - both still work on a dataclass instance; a nested pydantic-dataclass-typed - child field (`ncf: Optional[NcfProto]`) constructs and wires up the same - as under `BaseModel`. -- **What a dataclass gives up:** `BaseModel`'s self-methods - (`.model_dump()`, `.model_validate()`, `.model_json_schema()`) aren't - available directly on an instance — the equivalent is an external - `pydantic.TypeAdapter(cls)` call. In practice this costs nothing here: - flopy4 doesn't lean on those methods today either. `Component.to_dict()` - already wraps `attrs.asdict(self, recurse=True, filter=...)` — an - external function, not a self-method — and would wrap - `TypeAdapter(type(self)).dump_python(self, ...)` the same way. JSON - Schema (already concluded, in the Background section above, to belong - at the devtools/DFN layer rather than here) would still be reachable via - `TypeAdapter(cls).json_schema()` if ever wanted. - -**Consequence:** any future prototyping or real migration should target -`pydantic.dataclasses.dataclass`, not `BaseModel`. It's a closer structural -match to attrs (single post-construction hook, real `init=False`, -`kw_only` as a decorator arg) and gives up nothing flopy4's object model -actually uses from `BaseModel`. - -## Supporting-code complexity vs. the current implementation - -The previous section (and the ~50-call-site count above) covers *how many* -places need to change. This is about the code *those places rely on* — -`spec.py`'s `field()`/`fields_dict()`/`to_field_type()`/`get_field_type()` -and `attrs_xarray.py`'s `child_field_candidates()` — and whether its -replacement is more, less, or equally complex. - -- **The bulk of it is a wash.** `to_field_type()`/`get_field_type()` - (~100 lines) and `child_field_candidates()` (~40 lines) are `match` - statements over `get_origin()`/`get_args()` of a raw type annotation, - bridging Python's type system to MF6's own DFN type vocabulary - (`keyword`/`integer`/`double`/`record`/`list`/...). That complexity comes - from interpreting `typing` module generics, not from attrs vs. pydantic — - confirmed directly: a side-by-side test showed `attrs.Attribute.type` and - pydantic's `FieldInfo.annotation` expose `Optional[int]`-style - annotations identically (`typing.Optional[int]`, - `typing._UnionGenericAlias`, both cases). Neither library's native - validation/schema machinery reduces this bridge layer — MF6's type - vocabulary doesn't map onto either library's own type system, so a - hand-written translation is required either way. `spec.py`'s `field()` - metadata wrapper is the same story: `Field(json_schema_extra={...})` in - place of `attrs.field(metadata={...})`, same kwargs, same size. -- **One place pydantic's introspection is more robust, not just - equivalent** — found by testing, not assumed: `attrs.fields(cls).type` - only resolves to a real type object when attrs can eagerly evaluate the - annotation. A string/forward-ref annotation (e.g. under `from __future__ - import annotations`, which the codebase doesn't use today but easily - could add) silently degrades `attrs.Attribute.type` to an unresolved - `str` unless `attrs.resolve_types()` is called explicitly — and there - are zero such calls anywhere in flopy4 today, so `to_field_type()`/ - `child_field_candidates()` are quietly relying on a convention (no - future-annotations import) rather than a guarantee. Confirmed directly: - `b: "int | None"` under attrs stayed a bare `str`; the identical - annotation under pydantic resolved to a real `types.UnionType` - automatically, no extra call needed. -- **One place pydantic dataclasses need genuinely new support code:** the - `init=False` replacement (see "BaseModel vs. pydantic dataclasses" above) - is resolved for the object model itself, but confirms this is a - systemic pattern, not a `Dis`-only quirk — `init=False` appears at 13 - real sites across 7 files, including `flopy4/mf6/utils/codegen/ - filters.py` (codegen emits it, not just hand-written `DisBase`). A real - migration's codegen templates need `extra="forbid"` in the shared - dataclass config (one line, project-wide) for this to keep working — - cheap, but worth naming as a required config decision, not an implicit - default. - -## Re-assessed recommendation - -The measurement doesn't change the "wait for a trigger" recommendation -above. It does relocate where the real cost lives: not in per-field -boilerplate (the array-coercion validator collapses to one reusable -definition, not one per field or per package), not in translating type -annotations to MF6's DFN vocabulary (a wash — see "Supporting-code -complexity" above), and not in `Component`/`Package`'s own object-model -mechanics (`MutableMapping`, Item-list coercion, parent/child wiring, and -now the keystring-union-arm coercion path — all measured and ported -cleanly, at the cost of one `SkipValidation` opt-out per Item-list field -and one small `item_list_type()` fix), but in the ~50-call-site consumer -surface outside the object model itself (`netcdf.py`, `converter/*`, -`codec/*`) and the codegen templates that would need to emit the pydantic -shape. That's a one-time, codebase-wide cost rather than one that scales -with how many packages get migrated. Target `pydantic.dataclasses.dataclass`, -not `BaseModel`, for both — and leave `flopy4/mf6/item.py`/`record.py` -attrs-based; they don't need to migrate (see the Item-list corollary -above). - -## Next steps - -All items originally listed here are done — see "Full migration results -(2026-09-17)" above for what running them for real found beyond what the -prototypes predicted. Kept for history: - -1. ~~Prototype the codegen-side change~~ — done for real, not spiked; see - Stage 4 in "Full migration results" above. -2. ~~Prototype migrating one real consumer~~ — done for real, all ~50 call - sites across the full consumer surface, not just `netcdf.py`; see - Stage 6 above. -3. ~~Measure the keystring-union-arm coercion path~~ — done, see - "Prototype results: keystring-union-arm coercion" above. -4. ~~Re-decide go/no-go~~ — superseded by the 2026-09-17 decision below to - go straight to a full migration rather than spike further. - -**2026-09-17 decision-to-migrate note:** the decision was made to skip -further isolated spiking and go straight to a full migration on this -branch instead — a real, working migration is a better basis for team -comparison than more prototyping, and every mechanism it would exercise -(array/griddata coercion, `MutableMapping`, Item-list coercion including -the keystring-union-arm case, parent/child wiring) was already -independently confirmed to work with no open unknowns. The codegen -template work and the `netcdf.py`/`converter/*`/`codec/*` consumer surface -were done as part of that migration directly, not spiked separately -first. **The migration is now complete** — see "Status" and "Full -migration results (2026-09-17)" above. - -**What's genuinely left, if this is merged:** - -- The real go/no-go/merge decision itself — this doc and branch provide - the complete, working comparison basis issue #282 asked for, but merging - `pydantic-plan` into `develop` is a separate decision this doc doesn't - make. -- ~~The `maxbound`/`auto_from="stress_period_data"` codegen gap~~ — fully - resolved, in two steps (see "Full migration results" above): first - restored at the codegen-logic level on this branch (`df6db25`), after - discovering it wasn't the pre-existing/permanent limitation first - assumed but a live regression on `develop` itself, from `67d0922` - ("drop xattree (#356)"); then removed entirely (`812a906`) once the - Fortran source confirmed `MAXBOUND` is genuinely dead input for these 5 - packages, with the real fix landing in a DFN schema branch pushed to - `wpbonelli/modflow-devtools` (`fix-gvariant-maxbound`, not yet merged - upstream — flopy4 was NOT repointed at it permanently; the resulting - 5-file diff was verified against it and kept by hand). **`develop` - (flopy4) still has the codegen-level `auto_from` regression from - `67d0922`** (moot for `pydantic-plan` now, but still real there), **and - `MODFLOW-ORG/modflow-devtools` still has both the dead-`MAXBOUND`-field - DFN issue and the unrelated `Dfns.load()` dev2/dev3 bug the fix's own - verification surfaced** — three separate, independent things worth - upstreaming/PRing, none of them pydantic-specific, none blocking any - `pydantic-plan` merge decision. -- Sequencing against `mf6-object-model-plan.md` Phase 1 (see "Related" - below) — this migration did not wait for it, per the 2026-09-17 - decision; whether that causes any rebasing friction if Phase 1 lands - first is unknown. - -## Related - -- `docs/dev/prototypes/pydantic_dis_prototype.py` — the working prototype - behind "Prototype results" above, built on `pydantic.dataclasses.dataclass` - (v3 — see "BaseModel vs. pydantic dataclasses"). Runnable standalone; not - wired into flopy4's real registry/codegen/write/load path. -- `docs/dev/prototypes/pydantic_chd_prototype.py` — the list-heavy-package - prototype behind "Prototype results: list-heavy package" above (imports - `ComponentBase`/`PackageBase` from `pydantic_dis_prototype.py`). Runnable - standalone. -- `docs/dev/prototypes/pydantic_record_prototype.py` — the `Record`/`Item` - prototype behind "Item/Record: does it need attrs?" above. Self-contained - (doesn't import from the other two prototypes). Runnable standalone. -- `docs/dev/prototypes/pydantic_union_arm_prototype.py` — the - keystring-union-arm prototype behind "Prototype results: - keystring-union-arm coercion" above (imports `PackageBase` from - `pydantic_dis_prototype.py`, `record_fields`/`keyword_of` from - `pydantic_record_prototype.py`). Models the real `flopy4/mf6/gwf/oc.py` - `Oc` package. Runnable standalone. -- `docs/dev/netcdf-spec-plan.md` — same schema-value-layering conclusion, - applied to the NetCDF I/O object model. -- `mf6-object-model-plan.md` — the in-flight refactor this should sequence - after. **Not a file in this repo** — confirmed via `git log --all` it has - never been committed, on any branch; it's a local/uncommitted planning - note in someone's working tree. A fresh session won't have it — ask - wpbonelli for current status on `mf6-object-model-plan.md` Phase 1 - (generalized structuring) rather than expecting to find or `git show` it. -- Issue #282. -- `origin/plan-codegen` (`a9b77e8`) — original prototype code/docs; mined - for `pydantic_prototype.py`'s array-structuring pattern - (`structure_array_from_value` → this prototype's `PackageBase._coerce_arrays` - `field_validator`) when writing `pydantic_dis_prototype.py`. Its - recommendation section is still not current; this doc supersedes it. diff --git a/docs/dev/sdd.md b/docs/dev/sdd.md index cba712e0..a2ef5d11 100644 --- a/docs/dev/sdd.md +++ b/docs/dev/sdd.md @@ -143,7 +143,7 @@ IO is at the product's boundary. Details of any input or output format should no Input file IO is implemented in three layers: 1. **Unified IO layer**: Registry and descriptors implementing `load` and `write` methods on the base `Component` class -2. **Conversion layer**: Uses `cattrs` to map the object model to/from Python primitives and containers (i.e. un/structuring) +2. **Conversion layer**: Maps the object model to/from Python primitives and containers (i.e. un/structuring) 3. **Serialization layer**: Format-specific encoders/decoders translating primitives and containers to/from strings or binary data #### Unified IO @@ -156,9 +156,9 @@ Loaders and writers can be registered for any component class and format. The re The sparse input format used by MODFLOW 6 is in tension with an object model where tables are disaggregated into a separate array variable for each column — this requires a nontrivial conversion at load and write time. -The conversion layer uses `cattrs` to transform between the product's `xarray`/`attrs`-based object model and plain Python data structures suitable for serialization. This layer is format-agnostic and handles structural transformations common across formats. +The conversion layer (`flopy4.mf6.converter`) transforms between the product's object model (pydantic dataclasses holding `xarray`/`numpy` data) and plain Python data structures suitable for serialization. This layer is format-agnostic and handles structural transformations common across formats. -**Unstructuring (write time)**: A `cattrs` converter with appropriate unstructuring hooks converts components to a form suitable for serialization, handling transformations like: +**Unstructuring (write time)**: `converter.egress.unstructure` converts components to a form suitable for serialization, handling transformations like: - Grouping fields into blocks according to their `block` metadata from DFNs - Converting child components to binding tables for parent component name files @@ -167,7 +167,7 @@ The conversion layer uses `cattrs` to transform between the product's `xarray`/` The unstructuring phase aims to avoid a) unnecessary copies and b) materializing data in memory. -**Structuring (load time)**: A `cattrs` converter with appropriate structuring hooks converts dictionaries of primitives into component instances, including: +**Structuring (load time)**: `converter.ingress.structure` converts dictionaries of primitives into component instances, including: - Instantiating child components from bindings - Converting sparse list input data representations to arrays @@ -204,7 +204,7 @@ The reader in `flopy4.mf6.codec.reader` uses [Lark](https://lark-parser.readthed **Parsing**: A minimal *basic* grammar recognizes only block structure — blocks delimited by `BEGIN ` / `END `, each containing lines of whitespace-separated tokens (words and numbers). `BasicTransformer` yields a `{BLOCK_NAME: [token_row, ...]}` mapping. This grammar is component-agnostic, so one parser handles every input file. -**Structuring**: `converter.ingress.structure` reconstructs a component from the parsed block mapping, using the component class's `attrs` field metadata as the specification: +**Structuring**: `converter.ingress.structure` reconstructs a component from the parsed block mapping, using the component class's field metadata as the specification: - Blocks map to fields by name; field metadata identifies each field's kind (scalar, keyword, array, record, list). - List and record blocks are parsed into typed `Item` / `Record` objects (one class per row shape), resolving cellid width, `AUXILIARY` columns, and boundnames. diff --git a/flopy4/attrs_xarray.py b/flopy4/dataclass_xarray.py similarity index 90% rename from flopy4/attrs_xarray.py rename to flopy4/dataclass_xarray.py index 23d9a65a..e9b8d7c8 100644 --- a/flopy4/attrs_xarray.py +++ b/flopy4/dataclass_xarray.py @@ -15,7 +15,9 @@ (a bare `DataTree` node carries no back-pointer to the field it came from, so reconstruction has no values to inspect yet). -Known limitation: a field literally named `dims`, `parent`, or `_parent` +InitVars (e.g. `Component.dims`) aren't stored, so they're skipped. + +Known limitation: a field literally named `parent` or `_parent` is always excluded (see `_RESERVED_FIELD_NAMES` for why each one is there). No real DFN field uses any of these names today; if one ever did, it would need a different name or a second, explicit exclusion @@ -40,15 +42,13 @@ from flopy4.spec import is_dataclass_instance -# Field names to always skip, regardless of what they hold. `dims` -# (Component.dims) is a plain dict of already-resolved dimension sizes, -# not xarray-representable leaf/child data. `parent` and `_parent` +# Field names to always skip, regardless of what they hold. `parent` and `_parent` # (Output.parent, Component._parent -- see their own docstrings) are # back-reference fields whose runtime values would otherwise look # structurally like real leaf/child data and cause infinite recursion # (parent -> child -> same parent) if walked, so they're excluded by # name rather than by (unreliable) type-checking. -_RESERVED_FIELD_NAMES = frozenset({"dims", "parent", "_parent"}) +_RESERVED_FIELD_NAMES = frozenset({"parent", "_parent"}) def _leaf_fields_and_children( @@ -63,7 +63,7 @@ def _leaf_fields_and_children( single_children: "dict[str, Any]" = {} collection_children: "dict[str, dict | list]" = {} for name, finfo in type(obj).__pydantic_fields__.items(): - if name in _RESERVED_FIELD_NAMES: + if name in _RESERVED_FIELD_NAMES or finfo.init_var: continue # A private field (leading underscore) exposed under an alias -- # e.g. Context._workspace/alias="workspace" -- is keyed by that @@ -101,11 +101,11 @@ def _array_dims(finfo: Any, name: str, ndim: int) -> tuple: return tuple(f"{name}_dim{i}" for i in range(ndim)) -def attrs_to_dataset(obj) -> xr.Dataset: +def dataclass_to_dataset(obj) -> xr.Dataset: """Flatten `obj`'s own scalar/array fields into a flat `xr.Dataset`. Dataclass-typed child fields are skipped here -- see - `attrs_to_datatree()` for those. A `numpy.ndarray`-valued field becomes + `dataclass_to_datatree()` for those. A `numpy.ndarray`-valued field becomes a data variable, with dims named from its `shape` metadata when present (falling back to generic per-axis names otherwise); everything else becomes a dataset-level attr. @@ -162,9 +162,9 @@ def _leaf_kwargs_from_dataset(cls: type, dataset: xr.Dataset) -> dict: return kwargs -def dataset_to_attrs(cls: type, dataset: xr.Dataset): +def dataset_to_dataclass(cls: type, dataset: xr.Dataset): """Construct a `cls` instance from an `xr.Dataset` shaped like - `attrs_to_dataset()`'s output: data variables and dataset-level attrs + `dataclass_to_dataset()`'s output: data variables and dataset-level attrs are matched to `cls`'s fields by name and passed as constructor kwargs. Unmatched dataset keys are ignored; fields with no matching key fall back to `cls`'s own default. @@ -172,10 +172,10 @@ def dataset_to_attrs(cls: type, dataset: xr.Dataset): return cls(**_leaf_kwargs_from_dataset(cls, dataset)) -def attrs_to_datatree(obj, _ancestors: frozenset = frozenset()) -> xr.DataTree: +def dataclass_to_datatree(obj, _ancestors: frozenset = frozenset()) -> xr.DataTree: """Recursively convert `obj` into an `xr.DataTree`. - `obj`'s own leaf fields become the root dataset (`attrs_to_dataset()`). + `obj`'s own leaf fields become the root dataset (`dataclass_to_dataset()`). Each dataclass-typed child field becomes a named child node (recursively converted the same way); a dict- or list-of-children field expands to one child node per entry, named by its dict key or @@ -194,15 +194,15 @@ def attrs_to_datatree(obj, _ancestors: frozenset = frozenset()) -> xr.DataTree: for name, child in single_children.items(): if id(child) in ancestors: continue - children[name] = attrs_to_datatree(child, ancestors) + children[name] = dataclass_to_datatree(child, ancestors) for field_name, collection in collection_children.items(): items = collection.items() if isinstance(collection, dict) else enumerate(collection) for key, child in items: if id(child) in ancestors: continue node_name = str(key) if isinstance(collection, dict) else f"{field_name}{key}" - children[node_name] = attrs_to_datatree(child, ancestors) - return xr.DataTree(dataset=attrs_to_dataset(obj), children=children) + children[node_name] = dataclass_to_datatree(child, ancestors) + return xr.DataTree(dataset=dataclass_to_dataset(obj), children=children) def _unwrap_optional(tp): @@ -277,12 +277,12 @@ def child_field_candidates(finfo: Any) -> "tuple[str, tuple[type, ...]] | None": return None -def datatree_to_attrs(cls: type, tree: xr.DataTree): +def datatree_to_dataclass(cls: type, tree: xr.DataTree): """Construct a `cls` instance from an `xr.DataTree` produced by - `attrs_to_datatree()`. + `dataclass_to_datatree()`. The root dataset supplies `cls`'s own scalar/array field kwargs (see - `dataset_to_attrs()`). Each dataclass-typed child field is matched to + `dataset_to_dataclass()`). Each dataclass-typed child field is matched to child node(s) by name and recursively reconstructed against the field's own declared element type. See the module docstring for the "dict"-kind and `Union`-element limitations. @@ -297,12 +297,12 @@ def datatree_to_attrs(cls: type, tree: xr.DataTree): kind, elem_type = spec if kind == "one": if name in tree.children: - kwargs[name] = datatree_to_attrs(elem_type, tree.children[name]) + kwargs[name] = datatree_to_dataclass(elem_type, tree.children[name]) elif kind == "list": items = [] i = 0 while f"{name}{i}" in tree.children: - items.append(datatree_to_attrs(elem_type, tree.children[f"{name}{i}"])) + items.append(datatree_to_dataclass(elem_type, tree.children[f"{name}{i}"])) i += 1 if items: kwargs[name] = items diff --git a/flopy4/dimensions.py b/flopy4/dimensions.py index d0c468b9..b3ccb315 100644 --- a/flopy4/dimensions.py +++ b/flopy4/dimensions.py @@ -97,14 +97,14 @@ class DimensionResolverMixin: Attributes ---------- _dimension_cache : dict[str, int] - Cache of resolved dimensions (stored as instance variable, not attrs field) + Cache of resolved dimensions (stored as instance variable, not a dataclass field) """ @property def _dimension_cache(self) -> dict: - # Lazily initialize in __dict__ directly rather than as a real attrs - # field: avoids needing a mutable-default Factory, and doesn't - # depend on __attrs_post_init__ chaining order across mixins. + # Lazily initialize in __dict__ directly rather than as a real + # dataclass field: avoids a mutable default, and doesn't depend on + # __post_init__ chaining order across mixins. if "_dimension_cache" not in self.__dict__: self.__dict__["_dimension_cache"] = {} return self.__dict__["_dimension_cache"] diff --git a/flopy4/mf6/_types.py b/flopy4/mf6/_types.py index 8d328654..f47ceaf2 100644 --- a/flopy4/mf6/_types.py +++ b/flopy4/mf6/_types.py @@ -26,8 +26,7 @@ class _ArrayLike(Protocol[_DT]): (see `codec/writer/filters.py`'s `array2chunks`, which streams dask-backed arrays without materializing them). - `@runtime_checkable` is required for pydantic (not needed under attrs, - which never validated this annotation at all): under + `@runtime_checkable` is required: under `arbitrary_types_allowed=True`, pydantic builds an `isinstance()`-based validator for any type it doesn't otherwise understand, which requires the protocol to support `isinstance()` at all -- confirmed empirically @@ -48,7 +47,7 @@ def shape(self) -> tuple[int, ...]: ... def _optional_path(v): - """Converter for Optional[Path] attrs fields. + """Converter for Optional[Path] fields. Accepts None, str, or Path; returns None or Path. """ diff --git a/flopy4/mf6/adapters.py b/flopy4/mf6/adapters.py index bf530a9f..9220b3eb 100644 --- a/flopy4/mf6/adapters.py +++ b/flopy4/mf6/adapters.py @@ -13,7 +13,7 @@ from flopy.plot.plotutil import PlotUtilities from pydantic.dataclasses import is_pydantic_dataclass -from flopy4.attrs_xarray import attrs_to_dataset +from flopy4.dataclass_xarray import dataclass_to_dataset from flopy4.mf6.model import Model from flopy4.mf6.package import Package @@ -35,7 +35,7 @@ def _resolve_leaf_type(annotation) -> "type | None": `NDArray[np.float64]`) down to the concrete runtime type `Flopy3Data` dispatches on (`bool`/`int`/`float`/`str`/`np.ndarray`). - Returns `None` for anything else (a nested attrs/Component type, + Returns `None` for anything else (a nested Component type, `Path`, `datetime`, `Record`, a bare `dict`/`list` period field, ...) -- those aren't representable as a single flopy3 `Data` leaf. """ @@ -207,7 +207,7 @@ def __init__( ): self._model = model self._package = package - self._dataset = attrs_to_dataset(package) + self._dataset = dataclass_to_dataset(package) if modelgrid: self._grid = modelgrid elif model: diff --git a/flopy4/mf6/component.py b/flopy4/mf6/component.py index 94f49752..33857b1a 100644 --- a/flopy4/mf6/component.py +++ b/flopy4/mf6/component.py @@ -14,11 +14,9 @@ from flopy4.mf6.write_context import WriteContext from flopy4.uio import IO, Loader, Writer -# Shared config for every Component/Package (sub)class -- one constant, -# repeated per class at its own `@dataclass(config=CFG, ...)` decoration -# site (config isn't inherited the way attrs' class-level settings are), -# same shape codegen already emits today (`@attrs.define(kw_only=True, -# slots=False)` on every generated class). +# Shared config for every Component/Package (sub)class. Pydantic doesn't +# inherit dataclass config, so each class passes it explicitly at its own +# `@dataclass(config=CFG, ...)` decoration site, as codegen does. CFG = ConfigDict( arbitrary_types_allowed=True, validate_assignment=True, @@ -121,7 +119,7 @@ def _find_child_field(parent_cls: type, child_cls: type) -> "tuple[Any, str] | N Returns `(finfo, kind)`, or `None` if no field matches. Raises `TypeError` if more than one field matches (ambiguous). """ - from flopy4.attrs_xarray import child_field_candidates + from flopy4.dataclass_xarray import child_field_candidates matches = [] for name, finfo in parent_cls.__pydantic_fields__.items(): @@ -171,23 +169,23 @@ class Component(DimensionResolverMixin, ABC, MutableMapping): defaults it to the *actual* runtime class's lowercased name -- not whichever class in the hierarchy happens to declare this field -- so a `Package` leaf (e.g. `Ic`, never separately subclassed for this field) - still gets "ic", not "package". (Pydantic's `default_factory` takes no - arguments, unlike attrs' `takes_self=True`, so this can't be a - declarative field default the way it was under attrs -- filled in - explicitly in `__post_init__` instead.) Overridden explicitly by + still gets "ic", not "package". (Pydantic's `default_factory` can't see + the instance, so this is filled in by `__post_init__` rather than a + declarative default.) Overridden explicitly by `_resolve_child_name()`/`_attach_to_parent_field()` when a component is attached as a named child; otherwise this default stands.""" - _parent: Any = Field(default=None, alias="parent", repr=False) + _parent: Any = dataclasses.field( + default=Field(default=None, alias="parent", repr=False), compare=False + ) """Parent back-reference -- source of truth for "who is this component's parent", top-down (`Gwf(dis=Dis(...))`) and bottom-up - (`Dis(parent=gwf)`) alike. Leading underscore + `alias="parent"` - mirrors attrs' private-attribute convention: the constructor keyword - stays `parent=` even though the field is `_parent`. Typed `Any` so + (`Dis(parent=gwf)`) alike. The alias keeps the constructor keyword + `parent=` even though the field is `_parent`. Typed `Any` so `child_field_candidates()` (type-annotation based) doesn't mistake it - for a real child field, and excluded from `__eq__` below (a live - `.parent` would otherwise recurse: comparing a component's parent - compares the parent's own children, including this component again). + for a real child field, and `compare=False` because comparing a live + `.parent` would recurse: comparing a component's parent compares the + parent's own children, including this component again. Populated by `_set_child_parents()` (top-down) and `_attach_to_parent_field()` (bottom-up), and kept current by @@ -196,23 +194,19 @@ class Component(DimensionResolverMixin, ABC, MutableMapping): otherwise be a reference cycle. """ - dims: dict = Field(default_factory=dict, repr=False) - """Accepts `dims=` at construction (e.g. `Ic(dims={"nodes": 900})`) - for API-compatibility with existing call sites. Read directly via - `self.__dict__.get("dims")` by `Package.__post_init__` for griddata - broadcasting -- not resolved/consumed by anything at the `Component` - level itself. Excluded from `__eq__` below for the same reason `attrs` - excluded it: two components differing only in already-resolved - dimension bookkeeping should still compare equal.""" + dims: dataclasses.InitVar[Optional[dict]] = None + """Dimension sizes to size griddata with at construction (e.g. + `Ic(dims={"nodes": 900})`). Construction-only: passed through the + `__post_init__` chain to `Package.__post_init__`, which broadcasts + scalar griddata to full shape, and not stored on the instance.""" @field_validator("*", mode="before") @classmethod def _apply_converter(cls, v: Any, info) -> Any: - """The one shared hook every field-level `converter=` (attrs - original) funnels through -- `flopy4.mf6.spec.field()`/`path()` - stash the callable in `json_schema_extra["converter"]` instead of - a per-field attrs `converter=`, and this single validator (not one - per field, not one per generated class) applies it uniformly.""" + """Apply each field's `converter=`, if any. Pydantic has no + per-field converter hook, so `flopy4.mf6.spec.field()`/`path()` + stash the callable in `json_schema_extra["converter"]` and this + single validator applies it for every field of every class.""" finfo = cls.__pydantic_fields__.get(info.field_name) if finfo is None or v is None: return v @@ -220,27 +214,6 @@ def _apply_converter(cls, v: Any, info) -> Any: conv = meta.get("converter") if isinstance(meta, dict) else None return conv(v) if conv is not None else v - def __eq__(self, other: object) -> bool: - """Hand-written, replacing the dataclass-generated `__eq__` - (disabled below via `eq=False`... note: NOT disabled -- see - below): compares every field except `_parent`/`dims` (attrs' - `eq=False` on those two fields, ported -- see their own - docstrings for why). Defined once here, inherited by every - subclass unmodified: reads `dataclasses.fields(self)` - dynamically, so it naturally covers each subclass's own - additional fields too, without needing to be redeclared or - special-cased per generated class. - """ - if type(self) is not type(other): - return NotImplemented - skip = {"_parent", "dims"} - for f in dataclasses.fields(self): - if f.name in skip: - continue - if getattr(self, f.name) != getattr(other, f.name): - return False - return True - @property def parent(self) -> "Component | None": """This component's parent, if attached (`None` otherwise). @@ -297,9 +270,9 @@ def _children(self) -> "dict[str, Component]": (`write()`, `NetCDFModel.from_model()`, ...), so nothing can read a stale name depending on call order. - Detects child fields via `flopy4.attrs_xarray.child_field_candidates()`. + Detects child fields via `flopy4.dataclass_xarray.child_field_candidates()`. """ - from flopy4.attrs_xarray import child_field_candidates + from flopy4.dataclass_xarray import child_field_candidates self._set_child_parents() @@ -335,7 +308,7 @@ def _set_child_parents(self) -> None: Detects child fields via `child_field_candidates()` (type- annotation based). """ - from flopy4.attrs_xarray import child_field_candidates + from flopy4.dataclass_xarray import child_field_candidates used: "set[str]" = set() for name, finfo in type(self).__pydantic_fields__.items(): @@ -389,14 +362,17 @@ def default_filename(self) -> str: cls_name = self.__class__.__name__.lower() return f"{name}.{cls_name}" - def __post_init__(self): + def __post_init__(self, dims: Optional[dict] = None): """ Post-initialization hook for all components. Chains to parent class post-init hooks (including DimensionRegistryMixin). - Defaults `.name` from the runtime class (attrs' `takes_self=True` - factory, ported -- see the field's own docstring), then runs the - two `_parent`-tracking hooks (see `_parent`'s docstring): stamps + `dims` is the `dims` InitVar; only `Package` uses it, so it isn't + passed further up the chain. + + Defaults `.name` from the runtime class (see the field's own + docstring), then runs the two `_parent`-tracking hooks (see + `_parent`'s docstring): stamps `_parent` on this component's own already-populated children (top-down construction), and -- if this component's own `_parent` was given directly as `parent=`, i.e. bottom-up construction -- @@ -474,7 +450,7 @@ def __setitem__(self, key, value): if not isinstance(value, Component): raise TypeError(f"Expected a Component, got {type(value).__name__}") - from flopy4.attrs_xarray import child_field_candidates + from flopy4.dataclass_xarray import child_field_candidates for name, finfo in type(self).__pydantic_fields__.items(): spec = child_field_candidates(finfo) @@ -518,7 +494,7 @@ def __setitem__(self, key, value): def __delitem__(self, key): """Detach the child named `key`, from whatever field/slot currently holds it.""" - from flopy4.attrs_xarray import child_field_candidates + from flopy4.dataclass_xarray import child_field_candidates for name, finfo in type(self).__pydantic_fields__.items(): spec = child_field_candidates(finfo) @@ -591,10 +567,8 @@ def _asdict_filtered(self) -> dict[str, Any]: this component's own: e.g. `Gwf.Output.parent` is a genuine back-reference to the owning `Gwf`, unrelated to `Component. _parent`, but recursing into it the same way would infinitely - loop (output -> parent -> output -> ...). Ported from the attrs - original's `attrs.asdict(self, recurse=True, filter=...)` -- - pydantic/stdlib dataclasses' own `dataclasses.asdict()` has no - filter hook, so this walks by hand instead.""" + loop (output -> parent -> output -> ...). `dataclasses.asdict()` + has no filter hook, so this walks by hand instead.""" def _convert(value: Any) -> Any: if is_pydantic_dataclass(type(value)): @@ -659,14 +633,14 @@ def to_xarray(self): """Flat xr.Dataset of this component's own scalar/array fields, merged with any child packages that have griddata fields. - Built directly from live attribute values via flopy4.attrs_xarray's - attrs_to_dataset. + Built directly from live attribute values via flopy4.dataclass_xarray's + dataclass_to_dataset. """ import xarray as _xr - from flopy4.attrs_xarray import attrs_to_dataset + from flopy4.dataclass_xarray import dataclass_to_dataset - base = attrs_to_dataset(self) + base = dataclass_to_dataset(self) extra = list(self._collect_child_griddata_datasets().values()) if not extra: return base diff --git a/flopy4/mf6/context.py b/flopy4/mf6/context.py index 44d23f4b..0ba4e486 100644 --- a/flopy4/mf6/context.py +++ b/flopy4/mf6/context.py @@ -15,9 +15,8 @@ class Context(Component, ABC): # `_workspace`/`workspace` mirrors `Component._parent`/`.parent`'s # private-field-plus-property pattern: pydantic has no per-field - # `on_setattr=` hook (attrs' `update_child_attr`, ported into the - # setter below), so the propagate-to-children side effect needs an - # explicit property instead of a declarative field option. + # on-setattr hook, so propagating to children happens in the property + # setter below. _workspace: Any = Field(default=None, alias="workspace", repr=False) @property @@ -26,17 +25,16 @@ def workspace(self) -> Optional[Path]: @workspace.setter def workspace(self, value) -> None: - """Coerce `value` to a `Path` (attrs' `converter=to_path`, ported), - then propagate it to every child that has its own `workspace` - attribute (attrs' `on_setattr=update_child_attr`, ported).""" + """Coerce `value` to a `Path`, then propagate it to every child + that has its own `workspace` attribute.""" value = to_path(value) self._workspace = value for child in self._children.values(): if hasattr(child, "workspace"): child.workspace = value - def __post_init__(self): - super().__post_init__() + def __post_init__(self, dims: Optional[dict] = None): + super().__post_init__(dims) # By the time this runs, `super().__post_init__()` (Component's) # has already resolved `_parent`/`.parent` for both top-down and # bottom-up construction (see `Component._parent`'s docstring). @@ -69,10 +67,10 @@ def write(self, format=MF6, context=None): def to_xarray(self): """DataTree for this context and its full child hierarchy. - Built directly from live attribute values via flopy4.attrs_xarray's - attrs_to_datatree. Each child node, leaf packages included, is + Built directly from live attribute values via flopy4.dataclass_xarray's + dataclass_to_datatree. Each child node, leaf packages included, is built from its own fields directly, so griddata appears natively. """ - from flopy4.attrs_xarray import attrs_to_datatree + from flopy4.dataclass_xarray import dataclass_to_datatree - return attrs_to_datatree(self) + return dataclass_to_datatree(self) diff --git a/flopy4/mf6/converter/egress/unstructure.py b/flopy4/mf6/converter/egress/unstructure.py index 1a747142..55770947 100644 --- a/flopy4/mf6/converter/egress/unstructure.py +++ b/flopy4/mf6/converter/egress/unstructure.py @@ -7,7 +7,7 @@ import xarray as xr from pydantic.fields import FieldInfo -from flopy4.attrs_xarray import child_field_candidates +from flopy4.dataclass_xarray import child_field_candidates from flopy4.mf6.component import Component from flopy4.mf6.constants import FILL_DNODATA from flopy4.mf6.context import Context diff --git a/flopy4/mf6/converter/ingress/structure.py b/flopy4/mf6/converter/ingress/structure.py index f19ad09d..8705e8e7 100644 --- a/flopy4/mf6/converter/ingress/structure.py +++ b/flopy4/mf6/converter/ingress/structure.py @@ -436,7 +436,7 @@ def _resolve_bindings(cls: type, raw_lower: dict, workspace: Path) -> dict[str, dims=dims)` calls -- `dimensions.py`'s object-graph walk only helps once a child is already attached, not while its siblings are still loading. """ - from flopy4.attrs_xarray import child_field_candidates + from flopy4.dataclass_xarray import child_field_candidates from flopy4.mf6.converter.binding import component_ftype from flopy4.mf6.exchange import Exchange from flopy4.mf6.model import Model @@ -746,10 +746,9 @@ def structure_component( # _keyword (e.g. Npf.rewet: Optional[Rewet], Rewet._keyword == # "rewet") matches `all_fields` above before `inner_class_fields` # (a fallback keyed by the row's *keyword*, not the field's own - # name) is ever consulted -- surfaced as a real bug by pydantic's - # real Optional[Rewet] validation (attrs applied none, so this - # field silently held a raw token list instead of a real Rewet - # instance): route it through the same from_tokens() path here + # name) is ever consulted, so without this it would get a raw + # token list instead of a Rewet instance. Route it through the + # same from_tokens() path here # too, rather than falling into the generic scalar/list branch # below, which is wrong for any Record-typed field. direct_inner_cls = _inner_class_type(f.annotation) @@ -764,14 +763,10 @@ def structure_component( # compound record this keyword is also meant to trigger # (e.g. Gwf's `newton`/`newtonoptions` pair) isn't resolved # here if it isn't reachable via inner_class_fields (that - # needs the inner class's own _keyword ClassVar, a - # pre-existing gap unrelated to this migration) -- under - # attrs a trailing modifier token (e.g. "NEWTON - # UNDER_RELAXATION") silently overwrote this bool field with - # a raw string, unvalidated and never actually used; - # pydantic's real bool validation correctly rejects that, so - # this takes the keyword's own presence as the field's real - # (and only sound) signal instead. + # needs the inner class's own _keyword ClassVar). A + # trailing modifier token (e.g. "NEWTON UNDER_RELAXATION") + # isn't a valid bool, so the keyword's presence is the + # field's value. kwargs[init_key] = True else: # List-valued options (auxiliary, etc.) have shape metadata; @@ -782,9 +777,7 @@ def structure_component( # by name directly -- otherwise a row naming exactly one # aux variable (e.g. "AUXILIARY MULT", row length 2) fell # through to the plain-scalar branch below and stored a - # bare string, which attrs tolerated (no validation on this - # field either) but pydantic's real list[str] check - # correctly rejects. + # bare string, which fails list[str] validation. _f_meta = f.json_schema_extra or {} is_list_opt = ( isinstance(_f_meta, dict) and isinstance(_f_meta.get("shape"), tuple) @@ -802,13 +795,9 @@ def structure_component( # seen in some older, migrated fixtures (e.g. IMS # "OUTER_MAXIMUM 100 500" or "REORDERING_METHOD NONE # RKM"), the second value belonging to a field the - # current DFN schema no longer declares. Under attrs - # this silently stored the whole raw list into a - # scalar-typed field, unvalidated and never actually - # used; pydantic's real validation correctly rejects a - # list there, so take just the first (real, - # current-schema) value instead of keeping stale extra - # tokens no longer part of the spec. + # current DFN schema no longer declares. A list fails + # scalar validation, so take just the first value and + # drop the stale extra tokens. kwargs[init_key] = row[1] else: kwargs[init_key] = list(row[1:]) if len(row) > 2 else row[1] diff --git a/flopy4/mf6/gwe/dis.py b/flopy4/mf6/gwe/dis.py index 9660a3a4..013efe77 100644 --- a/flopy4/mf6/gwe/dis.py +++ b/flopy4/mf6/gwe/dis.py @@ -77,12 +77,12 @@ class Dis(DisBase): longname="idomain existence array", ) - def __post_init__(self): + def __post_init__(self, dims: Optional[dict] = None): self.nodes = self.ncol * self.nrow * self.nlay self.ncpl = self.ncol * self.nrow self.nvert = (self.ncol + 1) * (self.nrow + 1) self._coerce_griddata() - super().__post_init__() + super().__post_init__(dims) def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/gwe/disv.py b/flopy4/mf6/gwe/disv.py index a92fbfe0..cca49c73 100644 --- a/flopy4/mf6/gwe/disv.py +++ b/flopy4/mf6/gwe/disv.py @@ -82,10 +82,9 @@ class Vertices(Item): # iv/xv/yv are declared NDArray-typed but commonly constructed from a # plain list/tuple (see from_grid() below) -- unlike Package's own # griddata fields, these carry no block="griddata"/shape= metadata, so - # Package._coerce_arrays' shape-driven check doesn't reach them. Same - # underlying gap as Tdis.perlen/nstp/tsmult: attrs never validated the - # declared NDArray type against an actual list default/override at - # all; pydantic does, so this needs its own small mode="before" fix. + # Package._coerce_arrays' shape-driven check doesn't reach them, so + # they get their own mode="before" coercion (as do Tdis.perlen/nstp/ + # tsmult). @field_validator("iv", mode="before") @classmethod def _coerce_iv(cls, v): @@ -100,7 +99,7 @@ def _coerce_xv_yv(cls, v): cell2ddata: Optional[list] = Field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") - def __post_init__(self): + def __post_init__(self, dims: Optional[dict] = None): if self.iv is not None and (not isinstance(self.iv, np.ndarray)): object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) if self.xv is not None and (not isinstance(self.xv, np.ndarray)): @@ -125,7 +124,7 @@ def __post_init__(self): self.nrow = 0 self.ncol = 0 self._coerce_griddata() - super().__post_init__() + super().__post_init__(dims) def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/gwf/__init__.py b/flopy4/mf6/gwf/__init__.py index 6c29b13d..a9786caa 100644 --- a/flopy4/mf6/gwf/__init__.py +++ b/flopy4/mf6/gwf/__init__.py @@ -190,13 +190,13 @@ def budget(self) -> xr.Dataset | xu.UgridDataset: mvr: Mvr | None = field(block="packages", default=None) vsc: Vsc | None = field(block="packages", default=None) wel: list[Union[Wel, Welg]] = field(block="packages", default_factory=list) - # attrs' takes_self=True factory, ported: pydantic default_factory - # callables take no arguments, so this is Optional + filled in by - # __post_init__ below instead (same pattern as Component.name). + # Needs the instance to build, which default_factory can't see, so + # it's Optional and filled in by __post_init__ below (same pattern as + # Component.name). output: Optional[Output] = Field(default=None, repr=False) - def __post_init__(self): - super().__post_init__() + def __post_init__(self, dims: Optional[dict] = None): + super().__post_init__(dims) if self.output is None: self.output = Gwf.Output(self) diff --git a/flopy4/mf6/gwf/api.py b/flopy4/mf6/gwf/api.py index f71fa063..80e9e397 100644 --- a/flopy4/mf6/gwf/api.py +++ b/flopy4/mf6/gwf/api.py @@ -54,6 +54,5 @@ class Api(Package): maxbound: Optional[int] = field( default=0, block="dimensions", - auto_from="stress_period_data", longname="maximum number of user-defined api boundaries", ) diff --git a/flopy4/mf6/gwf/chdg.py b/flopy4/mf6/gwf/chdg.py index 83241688..393eb0ac 100644 --- a/flopy4/mf6/gwf/chdg.py +++ b/flopy4/mf6/gwf/chdg.py @@ -68,7 +68,6 @@ class Chdg(Package): default=None, block="dimensions", optional=True, - auto_from="stress_period_data", longname="maximum number of constant head cells in any stress period", ) head: Optional[FloatArrayLike] = field( diff --git a/flopy4/mf6/gwf/dis.py b/flopy4/mf6/gwf/dis.py index b46b1ee5..a1c61db1 100644 --- a/flopy4/mf6/gwf/dis.py +++ b/flopy4/mf6/gwf/dis.py @@ -77,12 +77,12 @@ class Dis(DisBase): longname="idomain existence array", ) - def __post_init__(self): + def __post_init__(self, dims: Optional[dict] = None): self.nodes = self.ncol * self.nrow * self.nlay self.ncpl = self.ncol * self.nrow self.nvert = (self.ncol + 1) * (self.nrow + 1) self._coerce_griddata() - super().__post_init__() + super().__post_init__(dims) def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/gwf/disbase.py b/flopy4/mf6/gwf/disbase.py index 5c187f2b..c2f53652 100644 --- a/flopy4/mf6/gwf/disbase.py +++ b/flopy4/mf6/gwf/disbase.py @@ -22,8 +22,8 @@ class DisBase(Package): nvert: Optional[int] = Field(default=None, init=False) nodes: Optional[int] = Field(default=None, init=False) - def __post_init__(self): - super().__post_init__() + def __post_init__(self, dims: Optional[dict] = None): + super().__post_init__(dims) def _coerce_griddata(self) -> None: """Coerce griddata fields: list→ndarray, per-layer expansion, flatten. @@ -34,10 +34,9 @@ def _coerce_griddata(self) -> None: Package._coerce_arrays (inherited -- a field_validator("*", mode="before") runs during construction, ahead of __post_init__), so it already arrives here as a real ndarray of the right dtype; - the list/tuple branch below is effectively a no-op today but kept - for parity with the attrs original (harmless if a future caller - ever bypasses construction-time validation via a direct __dict__ - write, as several places in this codebase do). + the list/tuple branch below only matters for a caller that + bypasses construction-time validation via a direct __dict__ write, + as several places in this codebase do. """ fields = type(self).__pydantic_fields__ dims = self.get_dims() diff --git a/flopy4/mf6/gwf/disv.py b/flopy4/mf6/gwf/disv.py index ef5479fe..344d6f1a 100644 --- a/flopy4/mf6/gwf/disv.py +++ b/flopy4/mf6/gwf/disv.py @@ -82,10 +82,9 @@ class Vertices(Item): # iv/xv/yv are declared NDArray-typed but commonly constructed from a # plain list/tuple (see from_grid() below) -- unlike Package's own # griddata fields, these carry no block="griddata"/shape= metadata, so - # Package._coerce_arrays' shape-driven check doesn't reach them. Same - # underlying gap as Tdis.perlen/nstp/tsmult: attrs never validated the - # declared NDArray type against an actual list default/override at - # all; pydantic does, so this needs its own small mode="before" fix. + # Package._coerce_arrays' shape-driven check doesn't reach them, so + # they get their own mode="before" coercion (as do Tdis.perlen/nstp/ + # tsmult). @field_validator("iv", mode="before") @classmethod def _coerce_iv(cls, v): @@ -100,7 +99,7 @@ def _coerce_xv_yv(cls, v): cell2ddata: Optional[list] = Field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") - def __post_init__(self): + def __post_init__(self, dims: Optional[dict] = None): if self.iv is not None and (not isinstance(self.iv, np.ndarray)): object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) if self.xv is not None and (not isinstance(self.xv, np.ndarray)): @@ -125,7 +124,7 @@ def __post_init__(self): self.nrow = 0 self.ncol = 0 self._coerce_griddata() - super().__post_init__() + super().__post_init__(dims) def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/gwf/drng.py b/flopy4/mf6/gwf/drng.py index 173e88d9..98e6720e 100644 --- a/flopy4/mf6/gwf/drng.py +++ b/flopy4/mf6/gwf/drng.py @@ -79,7 +79,6 @@ class Drng(Package): default=None, block="dimensions", optional=True, - auto_from="stress_period_data", longname="maximum number of drain cells in any stress period", ) elev: Optional[FloatArrayLike] = field( diff --git a/flopy4/mf6/gwf/ghbg.py b/flopy4/mf6/gwf/ghbg.py index 83f4ad93..9e623f9c 100644 --- a/flopy4/mf6/gwf/ghbg.py +++ b/flopy4/mf6/gwf/ghbg.py @@ -73,7 +73,6 @@ class Ghbg(Package): default=None, block="dimensions", optional=True, - auto_from="stress_period_data", longname="maximum number of general-head boundaries in any stress period", ) bhead: Optional[FloatArrayLike] = field( diff --git a/flopy4/mf6/gwf/rivg.py b/flopy4/mf6/gwf/rivg.py index 11465544..b13b6935 100644 --- a/flopy4/mf6/gwf/rivg.py +++ b/flopy4/mf6/gwf/rivg.py @@ -73,7 +73,6 @@ class Rivg(Package): default=None, block="dimensions", optional=True, - auto_from="stress_period_data", longname="maximum number of river cells in any stress period", ) stage: Optional[FloatArrayLike] = field( diff --git a/flopy4/mf6/gwf/welg.py b/flopy4/mf6/gwf/welg.py index 20eb5fd1..05c040b9 100644 --- a/flopy4/mf6/gwf/welg.py +++ b/flopy4/mf6/gwf/welg.py @@ -93,7 +93,6 @@ class Welg(Package): default=None, block="dimensions", optional=True, - auto_from="stress_period_data", longname="maximum number of wells in any stress period", ) q: Optional[FloatArrayLike] = field( diff --git a/flopy4/mf6/gwt/api.py b/flopy4/mf6/gwt/api.py index ca75047d..e2b360ca 100644 --- a/flopy4/mf6/gwt/api.py +++ b/flopy4/mf6/gwt/api.py @@ -54,6 +54,5 @@ class Api(Package): maxbound: Optional[int] = field( default=0, block="dimensions", - auto_from="stress_period_data", longname="maximum number of user-defined api boundaries", ) diff --git a/flopy4/mf6/gwt/dis.py b/flopy4/mf6/gwt/dis.py index fe279e23..9bf9c720 100644 --- a/flopy4/mf6/gwt/dis.py +++ b/flopy4/mf6/gwt/dis.py @@ -77,12 +77,12 @@ class Dis(DisBase): longname="idomain existence array", ) - def __post_init__(self): + def __post_init__(self, dims: Optional[dict] = None): self.nodes = self.ncol * self.nrow * self.nlay self.ncpl = self.ncol * self.nrow self.nvert = (self.ncol + 1) * (self.nrow + 1) self._coerce_griddata() - super().__post_init__() + super().__post_init__(dims) def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/gwt/disv.py b/flopy4/mf6/gwt/disv.py index 885b73dc..8a1c8bc7 100644 --- a/flopy4/mf6/gwt/disv.py +++ b/flopy4/mf6/gwt/disv.py @@ -82,10 +82,9 @@ class Vertices(Item): # iv/xv/yv are declared NDArray-typed but commonly constructed from a # plain list/tuple (see from_grid() below) -- unlike Package's own # griddata fields, these carry no block="griddata"/shape= metadata, so - # Package._coerce_arrays' shape-driven check doesn't reach them. Same - # underlying gap as Tdis.perlen/nstp/tsmult: attrs never validated the - # declared NDArray type against an actual list default/override at - # all; pydantic does, so this needs its own small mode="before" fix. + # Package._coerce_arrays' shape-driven check doesn't reach them, so + # they get their own mode="before" coercion (as do Tdis.perlen/nstp/ + # tsmult). @field_validator("iv", mode="before") @classmethod def _coerce_iv(cls, v): @@ -100,7 +99,7 @@ def _coerce_xv_yv(cls, v): cell2ddata: Optional[list] = Field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") - def __post_init__(self): + def __post_init__(self, dims: Optional[dict] = None): if self.iv is not None and (not isinstance(self.iv, np.ndarray)): object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) if self.xv is not None and (not isinstance(self.xv, np.ndarray)): @@ -125,7 +124,7 @@ def __post_init__(self): self.nrow = 0 self.ncol = 0 self._coerce_griddata() - super().__post_init__() + super().__post_init__(dims) def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/item.py b/flopy4/mf6/item.py index e3e4c62d..11b10d05 100644 --- a/flopy4/mf6/item.py +++ b/flopy4/mf6/item.py @@ -64,11 +64,9 @@ def _is_item_union(annotation: Any) -> "tuple[type[Item], ...] | None": Item classes (e.g. OC's ``All | First | Last | Frequency | Steps``), return the tuple of arm classes; else None. - Replaces the attrs original's `_nested_union_classes()` -- a - qualname-walking parse of the field's raw, unresolved forward-ref - string. Not needed here: by the time `Record.fields()` has run, - `annotation` (a pydantic `FieldInfo.annotation`) IS the real union of - resolved classes already. + By the time `Record.fields()` has run, `annotation` (a pydantic + `FieldInfo.annotation`) is already the resolved union of classes, not + a forward-ref string. """ origin = get_origin(annotation) if origin is Union or origin is type(int | str): @@ -439,10 +437,9 @@ def _unwrap_skip_validation(t: Any) -> Any: Item-list fields are pydantic.SkipValidation-wrapped (codegen emits this -- see Package._init_item_lists' own docstring for why: pydantic - validates an Item-list field's raw tuple/dict input eagerly by - default, unlike attrs, which applies no validation there at all). - get_origin() on the raw annotation returns Annotated, not dict/list, - so the unwrapping below needs this extra step attrs never did. + would otherwise validate an Item-list field's raw tuple/dict input + eagerly). get_origin() on the raw annotation returns Annotated, not + dict/list, so the unwrapping below needs this extra step. """ if get_origin(t) is Annotated: return get_args(t)[0] diff --git a/flopy4/mf6/netcdf.py b/flopy4/mf6/netcdf.py index 64822b53..dcbb650d 100644 --- a/flopy4/mf6/netcdf.py +++ b/flopy4/mf6/netcdf.py @@ -79,7 +79,7 @@ def get_spec(package_name: str): class _PackageSpec: """Summarizes a package's netcdf-relevant array fields (dtype, dims, - metadata) from its attrs field metadata (block, shape, layered, ...).""" + metadata) from its field metadata (block, shape, layered, ...).""" _DTYPE_MAP = _PKG_DTYPE_MAP diff --git a/flopy4/mf6/package.py b/flopy4/mf6/package.py index 4815eb4d..a95a0af2 100644 --- a/flopy4/mf6/package.py +++ b/flopy4/mf6/package.py @@ -1,6 +1,6 @@ from abc import ABC from pathlib import Path -from typing import Any +from typing import Any, Optional import numpy as np import pandas as pd @@ -40,19 +40,13 @@ def _is_dask_array(v: Any) -> bool: @dataclass(config=CFG, kw_only=True) class Package(Component, ABC): # A griddata field's *declared* type is an array type (NDArray[...]/ - # FloatArrayLike/IntArrayLike), but its *default value* in the real DFN - # corpus is often a bare scalar (e.g. `strt: FloatArrayLike = - # field(default=1.0, ...)`) that only becomes a real array once dims - # are known -- attrs never type-checks this mismatch (no per-field - # validator declared), so a scalar sails through construction - # untouched until __post_init__'s _broadcast_griddata expands it. - # Pydantic DOES enforce it: confirmed empirically that - # `IcLike(strt=1.0, dims=...)` raises `ValidationError: Input should be - # an instance of _ArrayLike` without this coercion step -- the - # unvalidated *default* (not explicitly passed) doesn't hit this, - # since pydantic doesn't validate field defaults unless - # validate_default=True (not set here), but any *explicit* scalar - # override does. One shared `field_validator("*", mode="before")`, + # FloatArrayLike/IntArrayLike), but its value is often a bare scalar + # (e.g. `strt: FloatArrayLike = field(default=1.0, ...)`) that only + # becomes a real array once dims are known, in __post_init__'s + # _broadcast_griddata. Pydantic doesn't validate defaults (no + # validate_default=True), but an explicit scalar override would fail + # the array type check without this coercion. One shared + # `field_validator("*", mode="before")`, # driven by each field's own `json_schema_extra["shape"]` (which # `spec.field()` already emits today), covers every griddata field on # every subclass -- not one per field, not one per generated class. @@ -84,11 +78,9 @@ def _coerce_arrays(cls, v: Any, info) -> Any: if isinstance(v, dict): # An empty-dict griddata value (e.g. Chd(dims={}) with no # explicit scalar override) is _broadcast_griddata's own - # "use the field's own scalar default" signal -- attrs let it - # reach __post_init__ as a raw {} unchanged; pydantic's - # NDArray/_ArrayLike type check has no such carve-out (and - # np.asarray({}, ...) itself raises, confirmed by running the - # real empty-dict-griddata test). Pre-resolve it into the same + # "use the field's own scalar default" signal, but it can't + # pass the NDArray/_ArrayLike type check (and np.asarray({}) + # raises). Pre-resolve it into the same # 0-d default-valued array a bare scalar default produces here # -- _broadcast_griddata's existing size==1 branch (added for # that scalar case) picks it up and broadcasts it exactly the @@ -97,7 +89,7 @@ def _coerce_arrays(cls, v: Any, info) -> Any: return np.asarray(default, dtype=dtype) return np.asarray(v, dtype=dtype) - def __post_init__(self) -> None: + def __post_init__(self, dims: Optional[dict] = None) -> None: """Post-init for Package subclasses. Handles three concerns in order: @@ -123,25 +115,22 @@ def __post_init__(self) -> None: # flopy4/mf6/exg/ -- just dfn_name, no declared fields) still # inherit Component's own fields (filename, name, ...), none of # which carry block metadata, so this simply no-ops through the - # rest of this method for them -- no NotAnAttrsClassError-style - # guard needed (every Package subclass is a pydantic dataclass, - # unconditionally, unlike attrs' optional per-class opt-in). + # rest of this method for them. fields = type(self).__pydantic_fields__ if not any((f.json_schema_extra or {}).get("block") is not None for f in fields.values()): - super().__post_init__() + super().__post_init__(dims) return # 1. Item-list coercion. self._init_item_lists(fields) # 2. Griddata broadcasting. - dims: dict = self.__dict__.get("dims") or {} if dims: self._broadcast_griddata(fields, dims) # 3. Chain to Component's own post-init -- see docstring above for # why this must run last, not first. - super().__post_init__() + super().__post_init__(dims) def _init_item_lists(self, fields) -> None: """Coerce raw list/dict block+period data into Item-list fields; @@ -338,7 +327,7 @@ def to_dict(self, blocks: bool = False, strict: bool = False) -> dict: if not any((f.json_schema_extra or {}).get("block") for f in all_fields.values()): return super().to_dict(blocks=blocks, strict=strict) - _exclude = {"name", "parent", "_parent", "dims", "filename", "workspace", "strict"} + _exclude = {"name", "parent", "_parent", "filename", "workspace", "strict"} result: dict = {} for name, f in all_fields.items(): if name in _exclude or f.init is False: @@ -394,9 +383,7 @@ def from_dataframe(self, df: pd.DataFrame) -> None: # A missing optional column (e.g. boundname) round-trips # through pandas as NaN, not absent -- drop it so the # Item class's own default applies instead of failing - # pydantic's real type validation (unlike attrs, which - # applied none here and silently accepted a stray float - # in a str-typed field). + # type validation (a float in a str-typed field). rows.append(item_cls(**{k: v for k, v in row.items() if not pd.isna(v)})) spd[int(kper)] = rows self.__dict__["_stress_period_data"] = spd @@ -446,7 +433,7 @@ def to_xarray(self) -> "xr.Dataset": # type: ignore[override] Stays lazy if dask-backed. For packages with no array fields this falls through to ``Component.to_xarray()``, which returns whatever - ``attrs_to_dataset()`` finds -- empty for a package with no + ``dataclass_to_dataset()`` finds -- empty for a package with no griddata fields of its own. """ fields = type(self).__pydantic_fields__ diff --git a/flopy4/mf6/prt/dis.py b/flopy4/mf6/prt/dis.py index e49c593f..cf0e389f 100644 --- a/flopy4/mf6/prt/dis.py +++ b/flopy4/mf6/prt/dis.py @@ -64,12 +64,12 @@ class Dis(DisBase): netcdf=False, ) - def __post_init__(self): + def __post_init__(self, dims: Optional[dict] = None): self.nodes = self.ncol * self.nrow * self.nlay self.ncpl = self.ncol * self.nrow self.nvert = (self.ncol + 1) * (self.nrow + 1) self._coerce_griddata() - super().__post_init__() + super().__post_init__(dims) def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/prt/disv.py b/flopy4/mf6/prt/disv.py index f0091a60..84e60c4c 100644 --- a/flopy4/mf6/prt/disv.py +++ b/flopy4/mf6/prt/disv.py @@ -70,10 +70,9 @@ class Vertices(Item): # iv/xv/yv are declared NDArray-typed but commonly constructed from a # plain list/tuple (see from_grid() below) -- unlike Package's own # griddata fields, these carry no block="griddata"/shape= metadata, so - # Package._coerce_arrays' shape-driven check doesn't reach them. Same - # underlying gap as Tdis.perlen/nstp/tsmult: attrs never validated the - # declared NDArray type against an actual list default/override at - # all; pydantic does, so this needs its own small mode="before" fix. + # Package._coerce_arrays' shape-driven check doesn't reach them, so + # they get their own mode="before" coercion (as do Tdis.perlen/nstp/ + # tsmult). @field_validator("iv", mode="before") @classmethod def _coerce_iv(cls, v): @@ -88,7 +87,7 @@ def _coerce_xv_yv(cls, v): cell2ddata: Optional[list] = Field(default=None) cell2d: Optional[list] = field(default=None, init=False, block="cell2d") - def __post_init__(self): + def __post_init__(self, dims: Optional[dict] = None): if self.iv is not None and (not isinstance(self.iv, np.ndarray)): object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) if self.xv is not None and (not isinstance(self.xv, np.ndarray)): @@ -113,7 +112,7 @@ def __post_init__(self): self.nrow = 0 self.ncol = 0 self._coerce_griddata() - super().__post_init__() + super().__post_init__(dims) def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/record.py b/flopy4/mf6/record.py index e0295418..94ef15ad 100644 --- a/flopy4/mf6/record.py +++ b/flopy4/mf6/record.py @@ -10,16 +10,14 @@ see _nested_class, inferred from the field's own type annotation rather than a declared flag, and make.py's _build_record_class_specs. -Generated Record/Item subclasses are `pydantic.dataclasses.dataclass`, not -`attrs.define` -- `Record.fields()`/`_nested_class()` below read +Generated Record/Item subclasses are `pydantic.dataclasses.dataclass` -- +`Record.fields()`/`_nested_class()` below read `__pydantic_fields__`/`FieldInfo.json_schema_extra` accordingly. A nested/ composed field's annotation (e.g. `Headprint.formatrecord: "Oc.Format"`) is a forward-reference string naming a SIBLING class inside the same enclosing package class -- unresolvable via any module-global lookup at class-body- execution time (Python class bodies can't see sibling names in an enclosing -class's scope). Unlike attrs (which leaves this unresolved forever, forcing -a qualname-walking string resolver), pydantic resolves it lazily and -self-heals on first construction -- `Record.fields()`'s guarded +class's scope). Pydantic resolves it lazily on first construction -- `Record.fields()`'s guarded `rebuild_dataclass()` call handles the one case that doesn't self-heal on its own: something (like `from_tokens()`) inspecting a class's fields before any instance of it has ever been built. @@ -43,10 +41,9 @@ def _nested_class(cls: type, annotation: Any) -> "type[Record] | None": Resolvability against a real Record subclass is itself the signal, no declared "is this nested" flag needed. - Replaces the attrs original's qualname-walking string resolver - entirely: by the time `Record.fields()` has run, `annotation` - (a pydantic `FieldInfo.annotation`) IS the real class object already, - not a string -- no `sys.modules`/qualname lookup needed. + By the time `Record.fields()` has run, `annotation` (a pydantic + `FieldInfo.annotation`) is already the real class object, not a + string -- no `sys.modules`/qualname lookup needed. """ args = get_args(annotation) candidate = next((a for a in args if a is not type(None)), annotation) diff --git a/flopy4/mf6/simulation.py b/flopy4/mf6/simulation.py index b5acdba0..7070d9a2 100644 --- a/flopy4/mf6/simulation.py +++ b/flopy4/mf6/simulation.py @@ -1,5 +1,5 @@ from os import PathLike -from typing import ClassVar +from typing import ClassVar, Optional from warnings import warn from modflow_devtools.misc import cd, run_cmd @@ -34,8 +34,8 @@ class Simulation(Context): def default_filename(self) -> str: return "mfsim.nam" - def __post_init__(self): - super().__post_init__() + def __post_init__(self, dims: Optional[dict] = None): + super().__post_init__(dims) if self.filename != "mfsim.nam": if self.filename is not None: warn( @@ -47,9 +47,8 @@ def __post_init__(self): # exchanges/solutions/tdis) -- Context.__post_init__ (already run, # via super() above) only saw whatever was attached at ITS point # in the post-init chain; re-assigning through the property setter - # (attrs' on_setattr=update_child_attr, ported -- see Context. - # workspace) re-runs the propagation now that every field on this - # concrete Simulation instance is attached. + # (see Context.workspace) re-runs the propagation now that every + # field on this concrete Simulation instance is attached. self.workspace = self.workspace @property diff --git a/flopy4/mf6/spec.py b/flopy4/mf6/spec.py index 9682c570..df31d678 100644 --- a/flopy4/mf6/spec.py +++ b/flopy4/mf6/spec.py @@ -21,7 +21,7 @@ # Sentinel distinguishing "no default given" (a required field) from a # real default of `None` -- pydantic.Field()'s own "no default" state is # just not passing `default=` at all, so this marks that case through -# field()/path()'s own default=... parameter (mirrors attrs.NOTHING's role). +# field()/path()'s own default=... parameter. _UNSET = object() @@ -55,7 +55,7 @@ def field( ``converter``: stashed into the field's own `json_schema_extra` (`metadata["converter"]`) rather than passed to `Field()` directly -- - pydantic has no per-field `converter=` hook the way attrs does. + pydantic has no per-field `converter=` hook. Applied uniformly by one shared `field_validator("*", mode="before")` on `Component` (see `flopy4.mf6.component.Component._apply_converter`) instead of one per field. diff --git a/flopy4/mf6/tdis.py b/flopy4/mf6/tdis.py index 44206bbb..6e40d0bb 100644 --- a/flopy4/mf6/tdis.py +++ b/flopy4/mf6/tdis.py @@ -30,11 +30,9 @@ class PeriodData(Item): # numeric-token grammar splits at the leading digits, before the # first embedded dash -- and a bare year like "1997" (no rest of an # ISO string on the row at all) tokenizes as a plain int, not a - # str. Under attrs both passed through unvalidated (no type check - # on this field at all); pydantic's real Optional[str] validation - # correctly rejects a list or a bare int, so the converter (already - # needed for the datetime -> isoformat direction) also normalizes - # both back into one string here. + # str. Neither passes Optional[str] validation, so the converter + # (already needed for the datetime -> isoformat direction) also + # normalizes both back into one string here. converter=lambda v: ( v.isoformat() if isinstance(v, datetime) @@ -66,7 +64,7 @@ def _coerce_to_array(cls, v: Any) -> Any: return v return np.asarray(v) - def __post_init__(self): + def __post_init__(self, dims: Optional[dict] = None): if self.perioddata: rows = [ row @@ -80,14 +78,14 @@ def __post_init__(self): object.__setattr__(self, "perlen", np.array([r.perlen for r in rows], dtype=np.float64)) object.__setattr__(self, "nstp", np.array([r.nstp for r in rows], dtype=np.int64)) object.__setattr__(self, "tsmult", np.array([r.tsmult for r in rows], dtype=np.float64)) - super().__post_init__() + super().__post_init__(dims) return nper = self.nper # _coerce_to_array only runs on an EXPLICITLY passed value # (pydantic doesn't validate an unused field default unless # validate_default=True, not set here) -- so an untouched default # (Tdis() with no perlen=/nstp=/tsmult= at all) still arrives here - # as attrs would have always left it, a bare int/float; an + # as a bare int/float; an # explicit scalar/list override arrives already coerced to a 0-d/ # plain ndarray by that validator. Both are handled below. if isinstance(self.perlen, (int, float)): @@ -113,7 +111,7 @@ def __post_init__(self): for p, n, t in zip(self.perlen, self.nstp, self.tsmult) ] object.__setattr__(self, "perioddata", rows) - super().__post_init__() + super().__post_init__(dims) def get_dims(self) -> dict[str, int]: """Get all dimensions.""" diff --git a/flopy4/mf6/utils/codegen/dfn2py.py b/flopy4/mf6/utils/codegen/dfn2py.py index bf2b3726..2e381d74 100644 --- a/flopy4/mf6/utils/codegen/dfn2py.py +++ b/flopy4/mf6/utils/codegen/dfn2py.py @@ -19,7 +19,7 @@ # *g / *a variants: gridded/array package variants, deferred. # # TODO (subpackage tier): detect `# flopy subpackage` DFN annotations and emit -# a typed child attrs field (e.g. ncf: Optional[Ncf]) alongside the existing path +# a typed child field (e.g. ncf: Optional[Ncf]) alongside the existing path # field; DisBase.write() already establishes the write pattern for NCF. # utl-ts also needs period values referencing timeseries by name written as strings. _SKIP = { diff --git a/flopy4/mf6/utils/codegen/filters.py b/flopy4/mf6/utils/codegen/filters.py index 558b22c6..0b2efe2b 100644 --- a/flopy4/mf6/utils/codegen/filters.py +++ b/flopy4/mf6/utils/codegen/filters.py @@ -273,11 +273,11 @@ def _record_child_supported(c: FieldV3) -> bool: def can_generate_record_class(f: FieldV3) -> bool: - """True when a compound record should be rendered as an inner attrs class. + """True when a compound record should be rendered as an inner dataclass. All non-file records whose children are entirely scalars, keywords, nested inline arrays, and/or nested records (see - _record_child_supported) become inner attrs classes. The first keyword + _record_child_supported) become inner dataclasses. The first keyword child (if any) is the trigger token (``_keyword``); remaining keyword children become ``Optional[bool]`` fields. A child that is itself a Record becomes its own composed class rather than being flattened in. @@ -461,25 +461,6 @@ def field_metadata(f: FieldV3, block_name: str) -> dict: kw["time_series"] = True if f.optional: kw["optional"] = True - if block_name == "dimensions" and f.name == "maxbound": - # Reached only for a maxbound field that build_component_spec did NOT - # skip via its _maxbound_is_computed `continue` -- i.e. any package - # whose maxbound stays a real, user-writable field rather than - # becoming a computed @property. Confirmed (via - # src/Model/ModelUtilities/BoundaryPackageExt.f90's - # BndExtType%source_dimensions) that MF6 never reads a user-supplied - # MAXBOUND at all for a READARRAYGRID ("G-variant") package -- it's - # dead input there, always overwritten with NCPL. Fixed at the real - # root cause upstream (modflow-devtools DFN migration no longer - # declares the field for those packages at all -- see - # MODFLOW-ORG/modflow-devtools issue/PR for - # gwf-chdg/drng/ghbg/rivg/welg), so this `auto_from` fallback no - # longer applies to them; it's reached today only by the Api family - # (gwf-api/gwt-api), whose maxbound has NOT been confirmed dead the - # same way -- MF6 infers it itself when left at 0/unwritten, so it - # must never be written out as a literal 0 -- see unstructure.py's - # auto_from handling. - kw["auto_from"] = "stress_period_data" if is_file_record(f): child = file_child(f) assert child is not None # is_file_record() already confirmed a File child exists @@ -643,7 +624,7 @@ def item_class( ``prefix=``/``tagged=``, via ``field()``) -- the class itself is the schema. Required fields (no default) are declared before optional fields to - satisfy attrs ordering constraints. + satisfy dataclass field-ordering constraints. ``is_period=True`` injects ``aux: tuple = ()`` between required value columns and optional columns, for packages that accept positional AUXILIARY @@ -784,7 +765,7 @@ def _field_line(col: dict, *, optional: bool) -> str: if meta: return f" {col['name']}: {py_type} = field({margs})" # A bare annotation here is equivalent to field() at runtime (both - # mean "no default") -- but mypy's attrs plugin doesn't recognize + # mean "no default") -- but mypy's dataclass_transform support doesn't recognize # field() (a flopy4.mf6.spec wrapper, not pydantic.Field itself) as a # field specifier, so it can't tell field()-declared columns above # (e.g. pk=/cellid=) don't actually have a default either. Left @@ -988,7 +969,7 @@ def collision_names( A name is a collision when it appears in more than one static list block, OR when it appears in any block AND is reserved by a period field. The - latter ensures that static block attrs never shadow bare period field names. + latter ensures that static block fields never shadow bare period field names. Prefix columns are excluded since they produce no attr. """ names = [col.name for cols in block_schemas.values() for col in cols if not col.is_prefix] diff --git a/flopy4/mf6/utils/codegen/make.py b/flopy4/mf6/utils/codegen/make.py index f7a61547..6f911bdd 100644 --- a/flopy4/mf6/utils/codegen/make.py +++ b/flopy4/mf6/utils/codegen/make.py @@ -53,7 +53,7 @@ class FieldSpec: @dataclass class InnerClassFieldSpec: - """Pre-computed context for one field of an inner attrs class.""" + """Pre-computed context for one field of an inner dataclass.""" py_name: str type_annotation: str @@ -64,7 +64,7 @@ class InnerClassFieldSpec: @dataclass class InnerClassSpec: - """Pre-computed context for a generated inner attrs class.""" + """Pre-computed context for a generated inner dataclass.""" class_name: str keyword: str @@ -109,7 +109,7 @@ class PeriodArmSpec: @dataclass class ComputedFieldSpec: """Pre-computed context for a read-only computed property, replacing a - stored attrs field entirely -- e.g. ``maxbound``, derived live from + stored field entirely -- e.g. ``maxbound``, derived live from ``stress_period_data``'s row counts rather than stored and kept in sync by hand (see ``build_component_spec``'s ``_maxbound_is_computed`` for when this applies).""" @@ -159,7 +159,7 @@ def _schema_dict_from_columns( accumulated and attached as a 'prefix' key on the next value column so the codec can emit the fixed token(s) before the value. is_row_keyword columns (optional keywords, e.g. MIXED) get role 'inline_keyword'. aux columns are - excluded -- appended dynamically in __attrs_post_init__. + excluded -- appended dynamically in __post_init__. ``nested_arm_classes``, when given, maps a column name to sibling arm class names already built for it (see ``_build_arm_specs_from_union``) @@ -325,8 +325,8 @@ def _ml_field( Produces continuation lines pre-indented at 8 spaces (args) and 4 spaces (closing paren) so the Jinja template can render it verbatim after `` {name}: {type} = ``. ``metadata`` here is the set of ``field()``/ - ``path()`` kwargs (block, schema, fill_forward, ...), not a raw attrs - metadata dict -- generated fields are plain attrs fields, so they go + ``path()`` kwargs (block, schema, fill_forward, ...), not a raw + metadata dict -- generated fields are plain dataclass fields, so they go through the same passive-metadata constructors hand-written classes use for their scalar fields. """ @@ -580,7 +580,7 @@ def _process_child(child: FieldV3) -> None: for child in data_children: _process_child(child) - # attrs requires fields with defaults to follow fields without defaults. + # Dataclass fields with defaults must follow fields without defaults. inner_fields.sort(key=lambda field: str(field.optional)) words = _strip_record_words(f.name) @@ -798,7 +798,7 @@ def _generated_imports( # period_arms (see the call site) -- SkipValidation is needed # whenever any Item-list field is generated (see item.py's # item_list_type()/Package._init_item_lists() for why: pydantic - # validates a raw tuple/dict input eagerly where attrs applied none). + # would otherwise validate a raw tuple/dict input eagerly). _pydantic_parts.append("SkipValidation") third_party: list[str] = [] if _pydantic_parts: @@ -930,7 +930,7 @@ def build_component_spec( for block_name, f in all_fields: if filters.is_list_field(f) and block_name in _bp_block_names: - continue # covered by BlockPropertySpec; column attrs generated below + continue # covered by BlockPropertySpec; column fields generated below if block_name in _fill_forward_blocks and filters.is_list_field(f): union = filters.find_keystring_union(f) @@ -1112,7 +1112,7 @@ def build_component_spec( ) ) - # READARRAY period fields → individual Optional[Int|FloatArrayLike] attrs + # READARRAY period fields → individual Optional[Int|FloatArrayLike] # fields. G-variant packages (CHDG, DRNG, WELG, RCHA …) declare each period # array separately. Each field is a full-grid array passed directly by # the user; the egress side (unstructure.py's _unstructure_package) diff --git a/flopy4/mixins.py b/flopy4/mixins.py index 4414f05c..512a9b31 100644 --- a/flopy4/mixins.py +++ b/flopy4/mixins.py @@ -1,37 +1,37 @@ """ Mixins implementing the `DatasetConvertible`/`DataTreeConvertible` -protocols (`flopy4/protocols.py`) on top of the generic attrs<->xarray -conversion functions (`flopy4/attrs_xarray.py`). +protocols (`flopy4/protocols.py`) on top of the generic dataclass<->xarray +conversion functions (`flopy4/dataclass_xarray.py`). """ import xarray as xr -from flopy4.attrs_xarray import ( - attrs_to_dataset, - attrs_to_datatree, - dataset_to_attrs, - datatree_to_attrs, +from flopy4.dataclass_xarray import ( + dataclass_to_dataset, + dataclass_to_datatree, + dataset_to_dataclass, + datatree_to_dataclass, ) class DatasetConvertibleMixin: - """Mixin for leaf attrs classes with no attrs-typed child fields.""" + """Mixin for leaf dataclasses with no dataclass-typed child fields.""" def to_xarray(self) -> xr.Dataset: - return attrs_to_dataset(self) + return dataclass_to_dataset(self) @classmethod def from_dataset(cls, dataset: xr.Dataset): - return dataset_to_attrs(cls, dataset) + return dataset_to_dataclass(cls, dataset) class DataTreeConvertibleMixin: - """Mixin for internal-node attrs classes with one or more attrs-typed - child fields.""" + """Mixin for internal-node dataclasses with one or more + dataclass-typed child fields.""" def to_xarray(self) -> xr.DataTree: - return attrs_to_datatree(self) + return dataclass_to_datatree(self) @classmethod def from_datatree(cls, tree: xr.DataTree): - return datatree_to_attrs(cls, tree) + return datatree_to_dataclass(cls, tree) diff --git a/flopy4/protocols.py b/flopy4/protocols.py index 88d31978..231185ca 100644 --- a/flopy4/protocols.py +++ b/flopy4/protocols.py @@ -1,6 +1,6 @@ """ -Runtime-checkable protocols for the explicit attrs<->xarray conversion -layer (`flopy4/attrs_xarray.py`, `flopy4/mixins.py`). +Runtime-checkable protocols for the explicit dataclass<->xarray conversion +layer (`flopy4/dataclass_xarray.py`, `flopy4/mixins.py`). """ from typing import Protocol, runtime_checkable @@ -12,7 +12,7 @@ class DatasetConvertible(Protocol): """A leaf object: convertible to/from a flat `xr.Dataset`. - For attrs classes with no attrs-typed child fields (e.g. a DFN leaf + For dataclasses with no dataclass-typed child fields (e.g. a DFN leaf package with only scalar/array fields). """ @@ -27,7 +27,7 @@ class DataTreeConvertible(Protocol): """An internal-node object: convertible to/from a hierarchical `xr.DataTree`. - For attrs classes with one or more attrs-typed child fields (single, + For dataclasses with one or more dataclass-typed child fields (single, list, or dict), whose own scalar/array fields form the tree's root dataset and whose children form named child nodes. """ diff --git a/flopy4/spec.py b/flopy4/spec.py index 54f364bc..1a0b458b 100644 --- a/flopy4/spec.py +++ b/flopy4/spec.py @@ -8,8 +8,7 @@ def is_dataclass_instance(value: Any) -> bool: - """True if `value` is an instance of a pydantic dataclass -- the - pydantic-dataclass replacement for `attrs.has(type(value))`, used + """True if `value` is an instance of a pydantic dataclass. Used wherever generic tree-walking code needs to tell a nested dataclass-typed value apart from a plain scalar/array leaf value.""" return is_pydantic_dataclass(type(value)) diff --git a/pixi.lock b/pixi.lock index 1878f69f..e6eeb4e9 100644 --- a/pixi.lock +++ b/pixi.lock @@ -10817,8 +10817,6 @@ packages: name: flopy4 requires_dist: - modflow-devtools[ecosystem] @ git+https://github.com/MODFLOW-ORG/modflow-devtools.git - - attrs>=25.4.0,<26 - - cattrs>=25.3.0,<26 - flopy>=3.9.5,<4 - jinja2>=3.1.6,<4 - lark>=1.3.1,<2 diff --git a/pyproject.toml b/pyproject.toml index 046e59dc..b34d2e5e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -35,8 +35,6 @@ classifiers = [ requires-python = ">=3.11,<3.14" dependencies = [ "modflow-devtools[ecosystem] @ git+https://github.com/MODFLOW-ORG/modflow-devtools.git", - "attrs>=25.4.0,<26", - "cattrs>=25.3.0,<26", "flopy>=3.9.5,<4", "jinja2>=3.1.6,<4", "lark>=1.3.1,<2", diff --git a/test/mf6/test_mf6_adapters.py b/test/mf6/test_mf6_adapters.py index dd8805fe..eaf97aa0 100644 --- a/test/mf6/test_mf6_adapters.py +++ b/test/mf6/test_mf6_adapters.py @@ -71,13 +71,10 @@ def test_flopy3_model(tmp_path): inner_dvclose=1e-6, linear_acceleration="cg", ) - # Legacy (pre-current-DFN) attribute names, not real Ims fields -- attrs - # (slots=False) tolerated bolting these on as plain extra instance - # attributes; pydantic's validate_assignment+extra="forbid" (needed - # elsewhere for init=False derived fields like DisBase's nlay/nrow/... -- - # pydantic itself refuses to combine init=False with extra="allow" at - # all) rejects an ordinary `ims.inner_hclose = ...` now, so this uses - # the same object.__setattr__ escape hatch the real source itself uses + # Legacy (pre-current-DFN) attribute names, not real Ims fields. + # extra="forbid" (needed for init=False derived fields like DisBase's + # nlay/nrow/...) rejects an ordinary `ims.inner_hclose = ...`, so this + # uses the same object.__setattr__ escape hatch the source uses # internally (e.g. Package._broadcast_griddata) to bypass validation. object.__setattr__(ims, "inner_hclose", 1e-6) object.__setattr__(ims, "inner_rclose", 0.1000000) @@ -230,20 +227,16 @@ def test_flopy3_package(tmp_path): assert not dis3.has_stress_period_data # package data - # Flopy3Package.data_list is built from attrs_to_dataset(dis): every + # Flopy3Package.data_list is built from dataclass_to_dataset(dis): every # non-None scalar field first (dataset-level .attrs, in declaration # order), then every array field (.data_vars, in declaration order) -- - # not a curated flopy3-only subset. See attrs_to_dataset()'s own - # docstring (flopy4/attrs_xarray.py) for the scalar/array split rule. + # not a curated flopy3-only subset. See dataclass_to_dataset()'s own + # docstring (flopy4/dataclass_xarray.py) for the scalar/array split rule. # - # nlay/nrow/ncol/ncpl/nvert/nodes' relative order here differs from - # attrs: DisBase declares them all together (as init=False derived - # fields); Dis then redeclares nlay/ncol/nrow as its own real, - # required fields. attrs moves a redeclared field to its subclass - # redeclaration position; pydantic (like plain stdlib dataclasses) - # keeps it at the base class's original position instead -- confirmed - # via Dis.__pydantic_fields__ directly. Not a bug to work around, a - # real, documented library difference this list now reflects. + # nlay/nrow/ncol/ncpl/nvert/nodes' relative order: DisBase declares + # them all together (as init=False derived fields); Dis then redeclares + # nlay/ncol/nrow as its own required fields. A redeclared dataclass + # field keeps the base class's original position. data_list = [ "name", "nlay", diff --git a/test/mf6/test_mf6_codec.py b/test/mf6/test_mf6_codec.py index 6fae158d..906c8fb2 100644 --- a/test/mf6/test_mf6_codec.py +++ b/test/mf6/test_mf6_codec.py @@ -170,13 +170,9 @@ def test_dumps_dis_with_constant_arrays(dis_with_constant_arrays): pprint(loaded) assert ["LENGTH_UNITS", "feet"] in loaded["OPTIONS"] - # NROW/NCOL order (not NCOL/NROW) here is a real, documented library - # difference, not a bug: DisBase declares nlay/nrow/ncol/... together; - # Dis redeclares nlay/ncol/nrow as its own real fields. attrs moves a - # redeclared field to its subclass redeclaration position; pydantic - # (like plain stdlib dataclasses) keeps it at the base class's - # original position instead -- see test_flopy3_package's own note on - # the identical difference for Dis's nlay/nrow/ncol/ncpl/nvert/nodes. + # NROW/NCOL order (not NCOL/NROW): DisBase declares nlay/nrow/ncol/... + # together; Dis redeclares nlay/ncol/nrow as its own fields, and a + # redeclared dataclass field keeps the base class's original position. assert loaded["DIMENSIONS"] == [["NLAY", 2], ["NROW", 10], ["NCOL", 10]] assert ["DELR"] in loaded["GRIDDATA"] assert ["DELC"] in loaded["GRIDDATA"] @@ -1472,10 +1468,8 @@ def test_ssm_fileinput_row_format(): # codegen types every "inline_keyword"-role tagged field str, # regardless of how it's actually used) -- to_tokens() only # ever checks its truthiness (`if val:`), so a real string, - # not a bool, is the field's actual contract; attrs never - # validated this (a raw np.True_/np.False_ passed through - # unchecked), pydantic's real Optional[str] check correctly - # rejects it. + # not a bool, is the field's actual contract (a raw + # np.True_/np.False_ fails Optional[str] validation). "mixed": np.array(["MIXED", ""], dtype=object), }, ) @@ -1636,11 +1630,7 @@ def test_rclose_from_tokens_with_option(): def test_from_tokens_missing_required_raises(): - """A required field with no value raises -- attrs raised TypeError - (Python's own missing-positional-argument error); pydantic dataclasses - raise their own ValidationError instead (a real, expected difference - in exception *type*, not a behavior regression -- both signal the same - "missing required field" condition at construction).""" + """A required field with no value raises pydantic's ValidationError.""" from pydantic import ValidationError from flopy4.mf6.gwf.oc import Oc diff --git a/test/mf6/test_mf6_codegen.py b/test/mf6/test_mf6_codegen.py index 7acec381..cbaa91d3 100644 --- a/test/mf6/test_mf6_codegen.py +++ b/test/mf6/test_mf6_codegen.py @@ -502,7 +502,7 @@ def test_lak_outlets_dim_declared(self, lak_spec): assert bp.dim_is_dfn_declared is True def test_lak_ifno_collision_prefixed(self, lak_spec): - # ifno appears in packagedata, connectiondata, and tables — all get block-prefixed attrs + # ifno appears in packagedata, connectiondata, and tables — all get block-prefixed fields for block in ("packagedata", "connectiondata", "tables"): bp = next(b for b in lak_spec.block_properties if b.block_name == block) assert bp.attr_name_map.get("ifno") == f"{block}_ifno", ( diff --git a/test/mf6/test_mf6_component.py b/test/mf6/test_mf6_component.py index 09caf1e5..4d51516d 100644 --- a/test/mf6/test_mf6_component.py +++ b/test/mf6/test_mf6_component.py @@ -294,7 +294,7 @@ def test_init_sim_explicit_dims(): assert gwf.oc is oc assert gwf.npf is npf assert gwf.chd[0] is chd - # k is stored as a plain attrs field; use to_xarray() for Dataset access + # k is stored as a plain field; use to_xarray() for Dataset access assert np.array_equal(sim.models["gwf"].npf.k, np.ones(100)) assert np.array_equal(sim.models["gwf"].npf.to_xarray()["k"].values, np.ones((1, 10, 10))) @@ -1719,7 +1719,7 @@ def test_explicit_parent_top_down(): def test_explicit_parent_bottom_up_non_package(): """The bottom-up (`parent=`) half of _parent tracking works for a non-Package component (Model/Context/Component's own chain), whose - own __attrs_post_init__ chains to Component's.""" + own __post_init__ chains to Component's.""" sim = Simulation() gwf = Gwf(parent=sim, name="gwf") assert gwf._parent is sim @@ -1727,8 +1727,8 @@ def test_explicit_parent_bottom_up_non_package(): def test_explicit_parent_bottom_up_package(): """The bottom-up (`parent=`) half of `_parent` tracking also works - for a Package subclass, which requires Package.__attrs_post_init__ - to chain to Component.__attrs_post_init__ via super() -- see that + for a Package subclass, which requires Package.__post_init__ + to chain to Component.__post_init__ via super() -- see that method's own docstring for why the chaining order matters. """ gwf = Gwf() @@ -1766,3 +1766,11 @@ def test_parent_setter_detach(): assert ic.parent is None assert ic._parent is None assert gwf.ic is None + + +def test_eq_ignores_dims_and_parent(): + """Equality ignores `dims` and `_parent`, on subclasses too (each + generated dataclass gets its own `__eq__`).""" + assert Ims(dims={"nodes": 3}) == Ims(dims={"nodes": 4}) + assert Ims(parent=Simulation()) == Ims() + assert Ims(inner_maximum=10) != Ims(inner_maximum=20) diff --git a/test/test_attrs_xarray.py b/test/test_dataclass_xarray.py similarity index 81% rename from test/test_attrs_xarray.py rename to test/test_dataclass_xarray.py index 4307d575..4587eedc 100644 --- a/test/test_attrs_xarray.py +++ b/test/test_dataclass_xarray.py @@ -1,7 +1,7 @@ -"""Tests for the generic attrs<->xarray conversion layer -(flopy4/attrs_xarray.py, flopy4/protocols.py, flopy4/mixins.py). +"""Tests for the generic dataclass<->xarray conversion layer +(flopy4/dataclass_xarray.py, flopy4/protocols.py, flopy4/mixins.py). -Uses synthetic attrs classes to validate the conversion functions in +Uses synthetic dataclasses to validate the conversion functions in isolation, plus one round-trip against a real leaf MF6 package (Dis) to confirm it also works unmodified on an actual generated class. """ @@ -13,11 +13,11 @@ from pydantic import ConfigDict, Field from pydantic.dataclasses import dataclass -from flopy4.attrs_xarray import ( - attrs_to_dataset, - attrs_to_datatree, - dataset_to_attrs, - datatree_to_attrs, +from flopy4.dataclass_xarray import ( + dataclass_to_dataset, + dataclass_to_datatree, + dataset_to_dataclass, + datatree_to_dataclass, ) from flopy4.mf6.gwf.dis import Dis from flopy4.mf6.spec import field as mf6_field @@ -33,7 +33,7 @@ class Leaf(DatasetConvertibleMixin): name: str = "leaf" value: float = 1.0 - # Deliberately not named "dims"/"parent"/"_parent" -- flopy4.attrs_xarray + # Deliberately not named "dims"/"parent"/"_parent" -- flopy4.dataclass_xarray # always excludes those (see its module docstring); no real DFN field # uses them either. values: Optional[np.ndarray] = mf6_field(default=None, shape=("nlay",)) @@ -66,14 +66,14 @@ class Node(DataTreeConvertibleMixin): def test_leaf_scalar_and_array_round_trip(): leaf = Leaf(name="a", value=2.5, values=np.array([1.0, 2.0, 3.0])) - ds = attrs_to_dataset(leaf) + ds = dataclass_to_dataset(leaf) assert isinstance(ds, xr.Dataset) assert ds.attrs["name"] == "a" assert ds.attrs["value"] == 2.5 assert list(ds["values"].dims) == ["nlay"] np.testing.assert_array_equal(ds["values"].values, [1.0, 2.0, 3.0]) - rebuilt = dataset_to_attrs(Leaf, ds) + rebuilt = dataset_to_dataclass(Leaf, ds) assert rebuilt.name == "a" assert rebuilt.value == 2.5 np.testing.assert_array_equal(rebuilt.values, [1.0, 2.0, 3.0]) @@ -90,12 +90,12 @@ def test_leaf_mixin_to_xarray_and_from_dataset(): def test_node_single_child_round_trip(): node = Node(title="root", only=OnlyChild(label="x")) - tree = attrs_to_datatree(node) + tree = dataclass_to_datatree(node) assert isinstance(tree, xr.DataTree) assert "only" in tree.children assert tree.dataset.attrs["title"] == "root" - rebuilt = datatree_to_attrs(Node, tree) + rebuilt = datatree_to_dataclass(Node, tree) assert rebuilt.title == "root" assert isinstance(rebuilt.only, OnlyChild) assert rebuilt.only.label == "x" @@ -103,26 +103,26 @@ def test_node_single_child_round_trip(): def test_node_list_children_round_trip(): node = Node(items=[ListChild(idx=0), ListChild(idx=1), ListChild(idx=2)]) - tree = attrs_to_datatree(node) + tree = dataclass_to_datatree(node) assert "items0" in tree.children assert "items1" in tree.children assert "items2" in tree.children - rebuilt = datatree_to_attrs(Node, tree) + rebuilt = datatree_to_dataclass(Node, tree) assert [c.idx for c in rebuilt.items] == [0, 1, 2] def test_node_dict_children_not_reconstructed(): """Documented limitation: dict-kind children round-trip into the tree (by their real dict key) but can't be recovered back into the dict - field from the tree alone -- see flopy4/attrs_xarray.py's module docstring. + field from the tree alone -- see flopy4/dataclass_xarray.py's module docstring. """ node = Node(mapping={"foo": DictChild(key="foo"), "bar": DictChild(key="bar")}) - tree = attrs_to_datatree(node) + tree = dataclass_to_datatree(node) assert "foo" in tree.children assert "bar" in tree.children - rebuilt = datatree_to_attrs(Node, tree) + rebuilt = datatree_to_dataclass(Node, tree) assert rebuilt.mapping == {} @@ -137,22 +137,22 @@ def test_node_mixin_to_xarray_and_from_datatree(): def test_empty_node_has_no_children(): node = Node() - tree = attrs_to_datatree(node) + tree = dataclass_to_datatree(node) assert dict(tree.children) == {} def test_dis_leaf_round_trip_real_mf6_class(): - """Dis has no attrs-typed children when ncf is unset, so it's a real + """Dis has no dataclass-typed children when ncf is unset, so it's a real (not synthetic) example of a DatasetConvertible-shaped leaf class.""" dis = Dis(nlay=2, nrow=3, ncol=4) - ds = attrs_to_dataset(dis) + ds = dataclass_to_dataset(dis) assert ds.attrs["nlay"] == 2 assert ds.attrs["nrow"] == 3 assert ds.attrs["ncol"] == 4 assert dict(ds["delr"].sizes) == {"ncol": 4} assert dict(ds["botm"].sizes) == {"nodes": 24} - rebuilt = dataset_to_attrs(Dis, ds) + rebuilt = dataset_to_dataclass(Dis, ds) assert rebuilt.nlay == 2 assert rebuilt.nrow == 3 assert rebuilt.ncol == 4 diff --git a/test/test_uio.py b/test/test_uio.py index 54783aa5..6feac819 100644 --- a/test/test_uio.py +++ b/test/test_uio.py @@ -2,14 +2,14 @@ from pathlib import Path -import attrs +from pydantic.dataclasses import dataclass -from flopy4.mf6.component import Component +from flopy4.mf6.component import CFG, Component from flopy4.mf6.constants import MF6 from flopy4.uio import DEFAULT_REGISTRY, IO, Loader, Registry, Writer -@attrs.define(kw_only=True, slots=False) +@dataclass(config=CFG, kw_only=True) class MockComponent(Component): """Minimal test component for IO testing.""" @@ -100,7 +100,7 @@ def test_loader_registry_subclass_lookup(): """Test that registry correctly finds loaders for subclasses.""" test_registry = Registry() - @attrs.define(kw_only=True, slots=False) + @dataclass(config=CFG, kw_only=True) class SubComponent(MockComponent): """Subclass of MockComponent.""" From 742279e460f627d9a4a552705b1561da150e9d64 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 23 Sep 2026 10:22:18 -0400 Subject: [PATCH 3/6] Type-check pydantic field introspection with mypy - Add pydantic_fields() and field_meta() helpers; pydantic doesn't declare __pydantic_fields__ on decorated classes, and types json_schema_extra as a JSON dict or callable, so direct access fails type checking - Route all field and metadata reads through them - Drop dead array-coercion branches in the Disv __post_init__s (the field validators already coerce) and give Tdis perlen/nstp/tsmult array defaults so their declared types hold - Smaller narrowing fixes surfaced once field types became visible Co-Authored-By: Claude Opus 5.5 --- flopy4/dataclass_xarray.py | 12 ++--- flopy4/dimensions.py | 10 ++-- flopy4/mf6/adapters.py | 9 ++-- flopy4/mf6/component.py | 27 +++++----- flopy4/mf6/converter/egress/unstructure.py | 19 +++---- flopy4/mf6/converter/ingress/structure.py | 63 +++++++++++----------- flopy4/mf6/gwe/disv.py | 12 ++--- flopy4/mf6/gwf/disbase.py | 5 +- flopy4/mf6/gwf/disv.py | 12 ++--- flopy4/mf6/gwt/disv.py | 12 ++--- flopy4/mf6/item.py | 33 +++++------- flopy4/mf6/netcdf.py | 11 ++-- flopy4/mf6/package.py | 28 +++++----- flopy4/mf6/prt/disv.py | 12 ++--- flopy4/mf6/record.py | 17 +++--- flopy4/mf6/spec.py | 11 ++-- flopy4/mf6/tdis.py | 28 ++++------ flopy4/spec.py | 27 +++++++++- 18 files changed, 167 insertions(+), 181 deletions(-) diff --git a/flopy4/dataclass_xarray.py b/flopy4/dataclass_xarray.py index e9b8d7c8..7f374d26 100644 --- a/flopy4/dataclass_xarray.py +++ b/flopy4/dataclass_xarray.py @@ -40,7 +40,7 @@ import xarray as xr from pydantic.dataclasses import is_pydantic_dataclass -from flopy4.spec import is_dataclass_instance +from flopy4.spec import field_meta, is_dataclass_instance, pydantic_fields # Field names to always skip, regardless of what they hold. `parent` and `_parent` # (Output.parent, Component._parent -- see their own docstrings) are @@ -62,7 +62,7 @@ def _leaf_fields_and_children( leaves: "dict[str, tuple]" = {} single_children: "dict[str, Any]" = {} collection_children: "dict[str, dict | list]" = {} - for name, finfo in type(obj).__pydantic_fields__.items(): + for name, finfo in pydantic_fields(type(obj)).items(): if name in _RESERVED_FIELD_NAMES or finfo.init_var: continue # A private field (leading underscore) exposed under an alias -- @@ -94,7 +94,7 @@ def _leaf_fields_and_children( def _array_dims(finfo: Any, name: str, ndim: int) -> tuple: - meta = finfo.json_schema_extra or {} + meta = field_meta(finfo) shape_meta = meta.get("shape") if isinstance(meta, dict) else None if shape_meta and len(shape_meta) == ndim: return tuple(shape_meta) @@ -114,7 +114,7 @@ def dataclass_to_dataset(obj) -> xr.Dataset: data_vars = {} ds_attrs = {} for name, (finfo, value) in leaves.items(): - meta = finfo.json_schema_extra or {} + meta = field_meta(finfo) has_shape = isinstance(meta, dict) and meta.get("shape") if isinstance(value, xr.DataArray): data_vars[name] = value @@ -145,7 +145,7 @@ def _init_field_names(cls: type) -> set: # here must match that key, not the private real name. return { (f.alias if (f.alias and name.startswith("_")) else name) - for name, f in cls.__pydantic_fields__.items() + for name, f in pydantic_fields(cls).items() if f.init is not False } @@ -288,7 +288,7 @@ def datatree_to_dataclass(cls: type, tree: xr.DataTree): "dict"-kind and `Union`-element limitations. """ kwargs = _leaf_kwargs_from_dataset(cls, tree.dataset) - for name, finfo in cls.__pydantic_fields__.items(): + for name, finfo in pydantic_fields(cls).items(): if finfo.init is False: continue spec = _child_field_spec(finfo) diff --git a/flopy4/dimensions.py b/flopy4/dimensions.py index b3ccb315..be40f1e3 100644 --- a/flopy4/dimensions.py +++ b/flopy4/dimensions.py @@ -4,6 +4,8 @@ from pydantic.dataclasses import is_pydantic_dataclass +from flopy4.spec import field_meta, pydantic_fields + @runtime_checkable class DimensionProvider(Protocol): @@ -191,7 +193,7 @@ def _find_dimension_in_children(self, dim_name: str) -> int | None: def _walk_providers(self): """Yield (source_label, dims_dict) for each DimensionProvider in child fields.""" - for name in type(self).__pydantic_fields__: # type: ignore[attr-defined] + for name in pydantic_fields(type(self)): if (value := getattr(self, name, None)) is None: continue if isinstance(value, DimensionProvider): @@ -271,9 +273,9 @@ def validate_dimension_resolution(component) -> list[str]: errors = [] # Check all array fields on this component - for name, finfo in type(component).__pydantic_fields__.items(): + for name, finfo in pydantic_fields(type(component)).items(): # Check if field has dimension metadata - meta = finfo.json_schema_extra or {} + meta = field_meta(finfo) if isinstance(meta, dict) and "dims" in meta: dims_needed = meta["dims"] # Check if this component has a parent and can resolve dimensions @@ -288,7 +290,7 @@ def validate_dimension_resolution(component) -> list[str]: ) # Recursively validate children - for name in type(component).__pydantic_fields__: + for name in pydantic_fields(type(component)): value = getattr(component, name, None) if value is None: continue diff --git a/flopy4/mf6/adapters.py b/flopy4/mf6/adapters.py index 9220b3eb..7aa9ad71 100644 --- a/flopy4/mf6/adapters.py +++ b/flopy4/mf6/adapters.py @@ -16,6 +16,7 @@ from flopy4.dataclass_xarray import dataclass_to_dataset from flopy4.mf6.model import Model from flopy4.mf6.package import Package +from flopy4.spec import field_meta, pydantic_fields def _to_numpy(val): @@ -217,7 +218,7 @@ def __init__( self._time = modeltime self._dlist = list() - field_by_name = dict(type(package).__pydantic_fields__) + field_by_name = dict(pydantic_fields(type(package))) for a, value in self._dataset.attrs.items(): field = field_by_name.get(a) @@ -238,7 +239,7 @@ def __init__( self._dlist.append(d_fp3) for v, data_array in self._dataset.data_vars.items(): - field = field_by_name.get(v) + field = field_by_name.get(str(v)) if field is None: continue d_fp3 = Flopy3Data( @@ -292,8 +293,8 @@ def has_stress_period_data(self): # _stress_period_data too, redundantly with the check above -- kept # as a generic fallback for any period field shape). if is_pydantic_dataclass(type(self._package)): - for name, f in type(self._package).__pydantic_fields__.items(): - meta = f.json_schema_extra or {} + for name, f in pydantic_fields(type(self._package)).items(): + meta = field_meta(f) if isinstance(meta, dict) and meta.get("fill_forward"): attr_name = f.alias if (f.alias and name.startswith("_")) else name if getattr(self._package, attr_name, None) is not None: diff --git a/flopy4/mf6/component.py b/flopy4/mf6/component.py index 33857b1a..e25d16c9 100644 --- a/flopy4/mf6/component.py +++ b/flopy4/mf6/component.py @@ -12,6 +12,7 @@ from flopy4.mf6.constants import MF6 from flopy4.mf6.spec import fields_dict from flopy4.mf6.write_context import WriteContext +from flopy4.spec import field_meta, pydantic_fields from flopy4.uio import IO, Loader, Writer # Shared config for every Component/Package (sub)class. Pydantic doesn't @@ -122,7 +123,7 @@ def _find_child_field(parent_cls: type, child_cls: type) -> "tuple[Any, str] | N from flopy4.dataclass_xarray import child_field_candidates matches = [] - for name, finfo in parent_cls.__pydantic_fields__.items(): + for name, finfo in pydantic_fields(parent_cls).items(): spec = child_field_candidates(finfo) if spec is None: continue @@ -207,10 +208,10 @@ def _apply_converter(cls, v: Any, info) -> Any: per-field converter hook, so `flopy4.mf6.spec.field()`/`path()` stash the callable in `json_schema_extra["converter"]` and this single validator applies it for every field of every class.""" - finfo = cls.__pydantic_fields__.get(info.field_name) + finfo = pydantic_fields(cls).get(info.field_name) if finfo is None or v is None: return v - meta = finfo.json_schema_extra or {} + meta = field_meta(finfo) conv = meta.get("converter") if isinstance(meta, dict) else None return conv(v) if conv is not None else v @@ -277,7 +278,7 @@ def _children(self) -> "dict[str, Component]": self._set_child_parents() result: "dict[str, Component]" = {} - for name, finfo in type(self).__pydantic_fields__.items(): + for name, finfo in pydantic_fields(type(self)).items(): spec = child_field_candidates(finfo) if spec is None: continue @@ -311,7 +312,7 @@ def _set_child_parents(self) -> None: from flopy4.dataclass_xarray import child_field_candidates used: "set[str]" = set() - for name, finfo in type(self).__pydantic_fields__.items(): + for name, finfo in pydantic_fields(type(self)).items(): spec = child_field_candidates(finfo) if spec is None: continue @@ -452,7 +453,7 @@ def __setitem__(self, key, value): from flopy4.dataclass_xarray import child_field_candidates - for name, finfo in type(self).__pydantic_fields__.items(): + for name, finfo in pydantic_fields(type(self)).items(): spec = child_field_candidates(finfo) if spec is None: continue @@ -496,7 +497,7 @@ def __delitem__(self, key): currently holds it.""" from flopy4.dataclass_xarray import child_field_candidates - for name, finfo in type(self).__pydantic_fields__.items(): + for name, finfo in pydantic_fields(type(self)).items(): spec = child_field_candidates(finfo) if spec is None: continue @@ -574,7 +575,7 @@ def _convert(value: Any) -> Any: if is_pydantic_dataclass(type(value)): return { name: _convert(getattr(value, name)) - for name in type(value).__pydantic_fields__ + for name in pydantic_fields(type(value)) if name not in ("parent", "_parent") } if isinstance(value, dict): @@ -614,7 +615,7 @@ def to_dict(self, blocks: bool = False, strict: bool = False) -> dict[str, Any]: blocks_ = {} # type: ignore for field_name, finfo in spec.items(): field_value = data[field_name] - meta = finfo.json_schema_extra or {} + meta = field_meta(finfo) block_name = meta.get("block") if isinstance(meta, dict) else None if strict and block_name is None: continue @@ -626,7 +627,7 @@ def to_dict(self, blocks: bool = False, strict: bool = False) -> dict[str, Any]: return { field_name: data[field_name] for field_name, finfo in spec.items() - if (finfo.json_schema_extra or {}).get("block") or not strict + if field_meta(finfo).get("block") or not strict } def to_xarray(self): @@ -658,10 +659,8 @@ def _collect_child_griddata_datasets(self) -> dict: for name, child in self._children.items(): if not is_pydantic_dataclass(type(child)): continue - _fields = type(child).__pydantic_fields__ - if not any( - (f.json_schema_extra or {}).get("block") == "griddata" for f in _fields.values() - ): + _fields = pydantic_fields(type(child)) + if not any(field_meta(f).get("block") == "griddata" for f in _fields.values()): continue try: ds = child.to_xarray() diff --git a/flopy4/mf6/converter/egress/unstructure.py b/flopy4/mf6/converter/egress/unstructure.py index 55770947..90c3d51e 100644 --- a/flopy4/mf6/converter/egress/unstructure.py +++ b/flopy4/mf6/converter/egress/unstructure.py @@ -16,12 +16,13 @@ from flopy4.mf6.package import Package from flopy4.mf6.record import Record from flopy4.mf6.spec import FileDirection, block_sort_key, blocks_dict, to_field_type +from flopy4.spec import field_meta, pydantic_fields def _path_to_tuple(name: str, field: FieldInfo, value: Path) -> tuple[str, ...]: """A block-level file record's ``KEYWORD FILEIN|FILEOUT `` row. The keyword is the field's own ``_keyword`` metadata (see spec.path).""" - meta = field.json_schema_extra or {} + meta = field_meta(field) assert isinstance(meta, dict) keyword = meta.get("_keyword") if not keyword: @@ -40,12 +41,12 @@ def _make_binding_blocks(value: Component) -> dict[str, dict[str, list[tuple[str blocks = {} # type: ignore - for child_name, f in type(value).__pydantic_fields__.items(): + for child_name, f in pydantic_fields(type(value)).items(): if child_field_candidates(f) is None: continue if (child := getattr(value, child_name, None)) is None: continue - meta = f.json_schema_extra or {} + meta = field_meta(f) block_name = meta.get("block") if isinstance(meta, dict) else None if block_name is None: continue @@ -131,8 +132,8 @@ def _unstructure_package(value: Package) -> dict[str, Any]: except ImportError: _DaskArray = type(None) # type: ignore[misc,assignment] - for name, f in cls.__pydantic_fields__.items(): - meta = f.json_schema_extra or {} + for name, f in pydantic_fields(cls).items(): + meta = field_meta(f) block_name = meta.get("block") if isinstance(meta, dict) else None if not block_name: continue @@ -288,7 +289,7 @@ def _unstructure_component(value: Component) -> dict[str, Any]: pydantic-typed child fields, including its child binding blocks.""" blockspec = blocks_dict(type(value)) blocks: dict[str, dict[str, Any]] = {} - fields_by_name = dict(type(value).__pydantic_fields__) + fields_by_name = dict(pydantic_fields(type(value))) # create child component binding blocks blocks.update(_make_binding_blocks(value)) @@ -300,13 +301,13 @@ def _unstructure_component(value: Component) -> dict[str, Any]: for field_name in block.keys(): # Skip child components already processed as bindings field = fields_by_name.get(field_name) - field_meta = (field.json_schema_extra or {}) if field is not None else {} + fmeta = field_meta(field) if field is not None else {} if ( isinstance(value, Context) and field is not None and child_field_candidates(field) is not None - and isinstance(field_meta, dict) - and field_meta.get("block") == block_name + and isinstance(fmeta, dict) + and fmeta.get("block") == block_name ): continue diff --git a/flopy4/mf6/converter/ingress/structure.py b/flopy4/mf6/converter/ingress/structure.py index 8705e8e7..3f3d8bbf 100644 --- a/flopy4/mf6/converter/ingress/structure.py +++ b/flopy4/mf6/converter/ingress/structure.py @@ -12,6 +12,7 @@ from flopy4.mf6.package import Package from flopy4.mf6.record import Record from flopy4.mf6.spec import repeating_array_key_type, to_field_type +from flopy4.spec import field_meta, pydantic_fields def _inner_class_type(field_type) -> type[Record] | None: @@ -212,7 +213,7 @@ def _griddata_flat_length(f, dims: dict, default: int) -> int: back to `default` (the grid's total node count) when the field's declared shape dimension isn't resolvable from `dims`. """ - shape_meta = (f.json_schema_extra or {}).get("shape") + shape_meta = field_meta(f).get("shape") if shape_meta: dim_name = shape_meta[-1] if isinstance(shape_meta, (tuple, list)) else shape_meta if dim_name in dims: @@ -244,7 +245,7 @@ def _parse_griddata_block( continue key = str(row[0]).lower() f = fields_by_name.get(key) - meta = (f.json_schema_extra or {}) if f is not None else {} + meta = field_meta(f) if f is not None else {} if f is None or meta.get("block") != "griddata": i += 1 continue @@ -367,7 +368,7 @@ def _parse_readarray_period_block( continue is_int = to_field_type(f.annotation) == "integer" - meta = f.json_schema_extra or {} + meta = field_meta(f) is_layered = meta.get("layered", False) or any(str(t).upper() == "LAYERED" for t in row[1:]) dtype = np.int64 if is_int else np.float64 @@ -453,11 +454,11 @@ def _resolve_bindings(cls: type, raw_lower: dict, workspace: Path) -> dict[str, model_prefix = cls.__name__.lower() if issubclass(cls, Model) else None fields_by_block: dict[str, list] = {} - for name, f in cls.__pydantic_fields__.items(): + for name, f in pydantic_fields(cls).items(): spec = child_field_candidates(f) if spec is None: continue - meta = f.json_schema_extra or {} + meta = field_meta(f) block_name = meta.get("block") if isinstance(meta, dict) else None if block_name is None: continue @@ -626,11 +627,11 @@ def structure_component( # is for time_series_name, not Package.name). all_fields = { fname: f - for fname, f in cls.__pydantic_fields__.items() - if f.init is not False and "block" in (f.json_schema_extra or {}) + for fname, f in pydantic_fields(cls).items() + if f.init is not False and "block" in field_meta(f) } alias_map: dict[str, str] = {} # alias → name - for fname, f in cls.__pydantic_fields__.items(): + for fname, f in pydantic_fields(cls).items(): if f.alias and f.alias != fname: alias_map[f.alias] = fname @@ -638,9 +639,7 @@ def structure_component( # trigger keyword, the path field's _keyword metadata (see spec.path), # not by the field's py name (ts_file). file_fields: dict[str, tuple[str, Any]] = { - kw: (fname, f) - for fname, f in all_fields.items() - if (kw := (f.json_schema_extra or {}).get("_keyword")) + kw: (fname, f) for fname, f in all_fields.items() if (kw := field_meta(f).get("_keyword")) } # Index Optional[InnerClass] fields by the inner class's _keyword (lowercase). @@ -655,7 +654,7 @@ def structure_component( # test failed, with kwargs["name"] ending up as the *last field name # iterated* (e.g. "output") instead of the caller's actual override. inner_class_fields: dict[str, tuple] = {} - for fname, f in cls.__pydantic_fields__.items(): + for fname, f in pydantic_fields(cls).items(): if f.init is False: continue inner_cls = _inner_class_type(f.annotation) @@ -676,13 +675,13 @@ def structure_component( # Fill-forward repeating blocks (period), per the fields' own # fill_forward metadata (from the DFN's BlockHeader.fill_forward). fill_forward_blocks = { - (f.json_schema_extra or {})["block"] - for f in cls.__pydantic_fields__.values() - if (f.json_schema_extra or {}).get("fill_forward") + field_meta(f)["block"] + for f in pydantic_fields(cls).values() + if field_meta(f).get("fill_forward") } - for fname, f in cls.__pydantic_fields__.items(): - meta = f.json_schema_extra or {} + for fname, f in pydantic_fields(cls).items(): + meta = field_meta(f) block = meta.get("block", "") if isinstance(meta, dict) else "" item_cls = item_list_type(f.annotation) if item_cls is None: @@ -701,11 +700,11 @@ def structure_component( # a metadata flag. Mirrors egress/unstructure.py's write path. repeating_array_fields = { fname: f - for fname, f in cls.__pydantic_fields__.items() + for fname, f in pydantic_fields(cls).items() if repeating_array_key_type(f.annotation) is not None and f.init is not False } repeating_array_block_prefixes = { - (f.json_schema_extra or {})["block"] for f in repeating_array_fields.values() + field_meta(f)["block"] for f in repeating_array_fields.values() } # ── Pass 1: scalar blocks (options, dimensions, etc.) ──────────────────── @@ -735,13 +734,14 @@ def structure_component( kwargs[ff_f.alias or ff_name] = Path(_strip_quotes(str(tokens[0]))) continue found_name = key if key in all_fields else alias_map.get(key) - f = all_fields.get(found_name) if found_name else None - if f is None or f.init is False: + found = all_fields.get(found_name) if found_name is not None else None + if found_name is None or found is None or found.init is False: if key in inner_class_fields: cand_name, cand_f, inner_cls = inner_class_fields[key] cand_init = cand_f.alias if cand_f.alias else cand_name kwargs[cand_init] = inner_cls.from_tokens(row) continue + f = found # A field whose OWN name happens to equal its inner Record's # _keyword (e.g. Npf.rewet: Optional[Rewet], Rewet._keyword == # "rewet") matches `all_fields` above before `inner_class_fields` @@ -778,7 +778,7 @@ def structure_component( # aux variable (e.g. "AUXILIARY MULT", row length 2) fell # through to the plain-scalar branch below and stored a # bare string, which fails list[str] validation. - _f_meta = f.json_schema_extra or {} + _f_meta = field_meta(f) is_list_opt = ( isinstance(_f_meta, dict) and isinstance(_f_meta.get("shape"), tuple) ) or found_name == "auxiliary" @@ -838,7 +838,9 @@ def structure_component( if kper_rows: if period_field is not None: - assert period_item_cls is not None # set together with period_field above + assert ( + period_item_cls is not None and period_field_name is not None + ) # set with period_field spd: dict[int, list] = {} for kper, rows in sorted(kper_rows.items()): if not rows: @@ -863,9 +865,8 @@ def structure_component( # repeating_array_field instead, see below). ra_fields = { name: f - for name, f in cls.__pydantic_fields__.items() - if isinstance(f.json_schema_extra, dict) - and f.json_schema_extra.get("fill_forward") + for name, f in pydantic_fields(cls).items() + if field_meta(f).get("fill_forward") and name not in repeating_array_fields and to_field_type(f.annotation) in ("integer", "double") and f.init is not False @@ -879,7 +880,7 @@ def structure_component( # fill-forward semantics (egress skips all-FILL_DNODATA periods). accum: dict[str, np.ndarray] = {} for fname, f in ra_fields.items(): - _meta = f.json_schema_extra or {} + _meta = field_meta(f) shape = (nper, nlay, ncpl) if _meta.get("layered", False) else (nper, ncpl) accum[fname] = np.full(shape, FILL_DNODATA) for kper, rows in sorted(kper_rows.items()): @@ -908,7 +909,7 @@ def structure_component( nlay = effective_dims.get("nlay", 1) ncpl = nodes // nlay if nlay > 1 else nodes for fname, f in repeating_array_fields.items(): - block = (f.json_schema_extra or {})["block"] + block = field_meta(f)["block"] key_type = repeating_array_key_type(f.annotation) series_rows = _group_repeating_rows(raw_lower, {block}, key_type).get(block) if not series_rows: @@ -938,10 +939,8 @@ def structure_component( if effective_dims: gd_fields = { name: f - for name, f in cls.__pydantic_fields__.items() - if isinstance(f.json_schema_extra, dict) - and f.json_schema_extra.get("block") == "griddata" - and f.init is not False + for name, f in pydantic_fields(cls).items() + if field_meta(f).get("block") == "griddata" and f.init is not False } parsed = _parse_griddata_block(griddata_rows, gd_fields, effective_dims, workspace) kwargs.update(parsed) diff --git a/flopy4/mf6/gwe/disv.py b/flopy4/mf6/gwe/disv.py index cca49c73..b10ec6de 100644 --- a/flopy4/mf6/gwe/disv.py +++ b/flopy4/mf6/gwe/disv.py @@ -100,12 +100,6 @@ def _coerce_xv_yv(cls, v): cell2d: Optional[list] = field(default=None, init=False, block="cell2d") def __post_init__(self, dims: Optional[dict] = None): - if self.iv is not None and (not isinstance(self.iv, np.ndarray)): - object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) - if self.xv is not None and (not isinstance(self.xv, np.ndarray)): - object.__setattr__(self, "xv", np.asarray(self.xv, dtype=np.float64)) - if self.yv is not None and (not isinstance(self.yv, np.ndarray)): - object.__setattr__(self, "yv", np.asarray(self.yv, dtype=np.float64)) if self.iv is not None and self.xv is not None and (self.yv is not None): rows = [ self.Vertices(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) @@ -113,13 +107,13 @@ def __post_init__(self, dims: Optional[dict] = None): ] object.__setattr__(self, "vertices", rows) if self.cell2ddata is not None: - rows = [] + cell_rows = [] for rec in self.cell2ddata: row = (rec.icell2d + 1, rec.xc, rec.yc, rec.ncvert) + tuple( (v + 1 for v in rec.icvert) ) - rows.append(row) - object.__setattr__(self, "cell2d", rows) + cell_rows.append(row) + object.__setattr__(self, "cell2d", cell_rows) self.nodes = self.ncpl * self.nlay self.nrow = 0 self.ncol = 0 diff --git a/flopy4/mf6/gwf/disbase.py b/flopy4/mf6/gwf/disbase.py index c2f53652..5eecc4c1 100644 --- a/flopy4/mf6/gwf/disbase.py +++ b/flopy4/mf6/gwf/disbase.py @@ -10,6 +10,7 @@ from flopy4.mf6.package import _DTYPE_MAP as _PKG_DTYPE_MAP from flopy4.mf6.package import CFG, Package from flopy4.mf6.write_context import WriteContext +from flopy4.spec import field_meta, pydantic_fields @dataclass(config=CFG, kw_only=True) @@ -38,12 +39,12 @@ def _coerce_griddata(self) -> None: bypasses construction-time validation via a direct __dict__ write, as several places in this codebase do. """ - fields = type(self).__pydantic_fields__ + fields = pydantic_fields(type(self)) dims = self.get_dims() ncpl = dims.get("ncpl", 0) nlay = dims.get("nlay", 1) for name, f in fields.items(): - meta = f.json_schema_extra or {} + meta = field_meta(f) if meta.get("block") != "griddata": continue val = self.__dict__.get(name) diff --git a/flopy4/mf6/gwf/disv.py b/flopy4/mf6/gwf/disv.py index 344d6f1a..f565b3f3 100644 --- a/flopy4/mf6/gwf/disv.py +++ b/flopy4/mf6/gwf/disv.py @@ -100,12 +100,6 @@ def _coerce_xv_yv(cls, v): cell2d: Optional[list] = field(default=None, init=False, block="cell2d") def __post_init__(self, dims: Optional[dict] = None): - if self.iv is not None and (not isinstance(self.iv, np.ndarray)): - object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) - if self.xv is not None and (not isinstance(self.xv, np.ndarray)): - object.__setattr__(self, "xv", np.asarray(self.xv, dtype=np.float64)) - if self.yv is not None and (not isinstance(self.yv, np.ndarray)): - object.__setattr__(self, "yv", np.asarray(self.yv, dtype=np.float64)) if self.iv is not None and self.xv is not None and (self.yv is not None): rows = [ self.Vertices(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) @@ -113,13 +107,13 @@ def __post_init__(self, dims: Optional[dict] = None): ] object.__setattr__(self, "vertices", rows) if self.cell2ddata is not None: - rows = [] + cell_rows = [] for rec in self.cell2ddata: row = (rec.icell2d + 1, rec.xc, rec.yc, rec.ncvert) + tuple( (v + 1 for v in rec.icvert) ) - rows.append(row) - object.__setattr__(self, "cell2d", rows) + cell_rows.append(row) + object.__setattr__(self, "cell2d", cell_rows) self.nodes = self.ncpl * self.nlay self.nrow = 0 self.ncol = 0 diff --git a/flopy4/mf6/gwt/disv.py b/flopy4/mf6/gwt/disv.py index 8a1c8bc7..c0d7d823 100644 --- a/flopy4/mf6/gwt/disv.py +++ b/flopy4/mf6/gwt/disv.py @@ -100,12 +100,6 @@ def _coerce_xv_yv(cls, v): cell2d: Optional[list] = field(default=None, init=False, block="cell2d") def __post_init__(self, dims: Optional[dict] = None): - if self.iv is not None and (not isinstance(self.iv, np.ndarray)): - object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) - if self.xv is not None and (not isinstance(self.xv, np.ndarray)): - object.__setattr__(self, "xv", np.asarray(self.xv, dtype=np.float64)) - if self.yv is not None and (not isinstance(self.yv, np.ndarray)): - object.__setattr__(self, "yv", np.asarray(self.yv, dtype=np.float64)) if self.iv is not None and self.xv is not None and (self.yv is not None): rows = [ self.Vertices(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) @@ -113,13 +107,13 @@ def __post_init__(self, dims: Optional[dict] = None): ] object.__setattr__(self, "vertices", rows) if self.cell2ddata is not None: - rows = [] + cell_rows = [] for rec in self.cell2ddata: row = (rec.icell2d + 1, rec.xc, rec.yc, rec.ncvert) + tuple( (v + 1 for v in rec.icvert) ) - rows.append(row) - object.__setattr__(self, "cell2d", rows) + cell_rows.append(row) + object.__setattr__(self, "cell2d", cell_rows) self.nodes = self.ncpl * self.nlay self.nrow = 0 self.ncol = 0 diff --git a/flopy4/mf6/item.py b/flopy4/mf6/item.py index 11b10d05..45ac4151 100644 --- a/flopy4/mf6/item.py +++ b/flopy4/mf6/item.py @@ -20,6 +20,7 @@ from typing import Annotated, Any, Union, cast, get_args, get_origin from flopy4.mf6.record import Record, _coerce +from flopy4.spec import field_meta _AUX_KEY_RE = re.compile(r"^aux(\d+)$") @@ -42,11 +43,7 @@ def normalize_aux_keys(item: dict) -> dict: def _cellid_field(cls: type) -> Any | None: return next( - ( - f - for f in cast(type[Record], cls).fields().values() - if (f.json_schema_extra or {}).get("cellid") - ), + (f for f in cast(type[Record], cls).fields().values() if field_meta(f).get("cellid")), None, ) @@ -89,7 +86,7 @@ def construct_item(item_cls: type, values) -> "Item": i for i, (name, f) in enumerate(fields) if name == "aux" - or (f.json_schema_extra or {}).get("array") + or field_meta(f).get("array") or _is_item_union(f.annotation) is not None ), None, @@ -125,7 +122,7 @@ def _n_fixed_tokens(cls: type) -> int: cls = cast(type[Record], cls) n = 1 if cls.keyword() else 0 for name, f in cls.fields().items(): - meta = f.json_schema_extra or {} + meta = field_meta(f) if meta.get("cellid") or name in ("aux", "boundname"): continue if meta.get("optional"): @@ -209,7 +206,7 @@ def to_tokens(self) -> tuple: val = getattr(self, name) if val is None: continue - meta = f.json_schema_extra or {} + meta = field_meta(f) if meta.get("cellid"): row.extend(int(c) + 1 for c in val) elif meta.get("index"): @@ -274,7 +271,7 @@ def from_tokens( # type: ignore[override] def consume(name: str, f: Any) -> None: nonlocal tok_idx, keyword_skipped - meta = f.json_schema_extra or {} + meta = field_meta(f) if meta.get("cellid"): cellid = tuple(int(tokens[tok_idx + j]) - 1 for j in range(ncelldim)) kwargs[name] = cellid @@ -297,7 +294,7 @@ def consume(name: str, f: Any) -> None: tok_idx += 1 def width(f: Any) -> int: - meta = f.json_schema_extra or {} + meta = field_meta(f) w = 1 + (1 if meta.get("_keyword") else 0) if meta.get("direction"): w += 1 @@ -308,16 +305,12 @@ def width(f: Any) -> int: (name, f) for name, f in main_fields if _is_item_union(f.annotation) is not None ] main_fields = [item for item in main_fields if item not in nested_union_fields] - array_fields = [ - (name, f) for name, f in main_fields if (f.json_schema_extra or {}).get("array") - ] + array_fields = [(name, f) for name, f in main_fields if field_meta(f).get("array")] main_fields = [item for item in main_fields if item not in array_fields] required_fields = [ - (name, f) for name, f in main_fields if not (f.json_schema_extra or {}).get("optional") - ] - optional_fields = [ - (name, f) for name, f in main_fields if (f.json_schema_extra or {}).get("optional") + (name, f) for name, f in main_fields if not field_meta(f).get("optional") ] + optional_fields = [(name, f) for name, f in main_fields if field_meta(f).get("optional")] for name, f in required_fields: consume(name, f) @@ -329,9 +322,7 @@ def width(f: Any) -> int: remaining = n - tok_idx - (1 if has_bn_token else 0) - (naux if has_aux else 0) budget_fields = [ - (name, f) - for name, f in optional_fields - if not (f.json_schema_extra or {}).get("tagged") + (name, f) for name, f in optional_fields if not field_meta(f).get("tagged") ] n_opt_present = 0 used = 0 @@ -344,7 +335,7 @@ def width(f: Any) -> int: budget_idx = 0 for name, f in optional_fields: - meta = f.json_schema_extra or {} + meta = field_meta(f) if meta.get("tagged"): if not keyword_skipped: tok_idx += 1 diff --git a/flopy4/mf6/netcdf.py b/flopy4/mf6/netcdf.py index dcbb650d..2232531f 100644 --- a/flopy4/mf6/netcdf.py +++ b/flopy4/mf6/netcdf.py @@ -20,6 +20,7 @@ from flopy4.mf6.spec import to_field_type from flopy4.mf6.utils.grid import StructuredGrid, VertexGrid from flopy4.mf6.utils.time import Time +from flopy4.spec import field_meta, pydantic_fields from flopy4.version import __version__ @@ -86,7 +87,7 @@ class _PackageSpec: def __init__(self, cls): class _ArrayInfo: def __init__(self, name, f): - meta = f.json_schema_extra or {} + meta = field_meta(f) # A fill-forward (period) field's value has a leading nper axis. fill_forward = bool(meta.get("fill_forward")) is_layered = meta.get("layered", True) @@ -112,8 +113,8 @@ def __init__(self, name, f): self.arrays = { name: _ArrayInfo(name, f) - for name, f in cls.__pydantic_fields__.items() - if (f.json_schema_extra or {}).get("netcdf") + for name, f in pydantic_fields(cls).items() + if field_meta(f).get("netcdf") } @@ -232,8 +233,8 @@ def from_model( else: _nodes = d.get("nodes", _nlay) - for name, f in type(package).__pydantic_fields__.items(): - meta = f.json_schema_extra or {} + for name, f in pydantic_fields(type(package)).items(): + meta = field_meta(f) if not meta.get("netcdf"): continue if meta.get("block") == "griddata": diff --git a/flopy4/mf6/package.py b/flopy4/mf6/package.py index a95a0af2..dfc7db1f 100644 --- a/flopy4/mf6/package.py +++ b/flopy4/mf6/package.py @@ -17,6 +17,7 @@ normalize_aux_keys, ) from flopy4.mf6.spec import to_field_type +from flopy4.spec import field_meta, pydantic_fields # DFN type -> numpy dtype, for broadcasting a scalar griddata default to a # full array. @@ -53,10 +54,10 @@ class Package(Component, ABC): @field_validator("*", mode="before") @classmethod def _coerce_arrays(cls, v: Any, info) -> Any: - finfo = cls.__pydantic_fields__.get(info.field_name) + finfo = pydantic_fields(cls).get(info.field_name) if finfo is None or v is None: return v - meta = finfo.json_schema_extra or {} + meta = field_meta(finfo) if not (isinstance(meta, dict) and meta.get("block") == "griddata" and meta.get("shape")): return v if isinstance(v, np.ndarray) or _is_dask_array(v): @@ -116,8 +117,8 @@ def __post_init__(self, dims: Optional[dict] = None) -> None: # inherit Component's own fields (filename, name, ...), none of # which carry block metadata, so this simply no-ops through the # rest of this method for them. - fields = type(self).__pydantic_fields__ - if not any((f.json_schema_extra or {}).get("block") is not None for f in fields.values()): + fields = pydantic_fields(type(self)) + if not any(field_meta(f).get("block") is not None for f in fields.values()): super().__post_init__(dims) return @@ -143,7 +144,7 @@ def _init_item_lists(self, fields) -> None: __dict__ key object.__setattr__ writes to) is still the real name. """ for name, f in fields.items(): - meta = f.json_schema_extra or {} + meta = field_meta(f) block = meta.get("block") if not block: continue @@ -230,7 +231,7 @@ def _broadcast_griddata(self, fields, dims: dict) -> None: ) or ("ncpl" in dims and "nrow" not in dims) for name, f in fields.items(): - meta = f.json_schema_extra or {} + meta = field_meta(f) if meta.get("block") != "griddata": continue shape_meta = meta.get("shape") @@ -320,11 +321,11 @@ def to_dict(self, blocks: bool = False, strict: bool = False) -> dict: strict : bool If True, only include fields with ``block`` metadata. """ - all_fields = type(self).__pydantic_fields__ + all_fields = pydantic_fields(type(self)) # Fall back for a Package subclass with no schema-driven fields of # its own (e.g. the exchange leaves in flopy4/mf6/exg/). - if not any((f.json_schema_extra or {}).get("block") for f in all_fields.values()): + if not any(field_meta(f).get("block") for f in all_fields.values()): return super().to_dict(blocks=blocks, strict=strict) _exclude = {"name", "parent", "_parent", "filename", "workspace", "strict"} @@ -332,7 +333,7 @@ def to_dict(self, blocks: bool = False, strict: bool = False) -> dict: for name, f in all_fields.items(): if name in _exclude or f.init is False: continue - meta = f.json_schema_extra or {} + meta = field_meta(f) block = meta.get("block") if not block: continue @@ -389,8 +390,8 @@ def from_dataframe(self, df: pd.DataFrame) -> None: self.__dict__["_stress_period_data"] = spd def _period_item_cls(self) -> "type[Item] | tuple[type[Item], ...]": - for f in type(self).__pydantic_fields__.values(): - meta = f.json_schema_extra or {} + for f in pydantic_fields(type(self)).values(): + meta = field_meta(f) if meta.get("fill_forward"): item_cls = item_list_type(f.annotation) if item_cls is not None: @@ -436,13 +437,12 @@ def to_xarray(self) -> "xr.Dataset": # type: ignore[override] ``dataclass_to_dataset()`` finds -- empty for a package with no griddata fields of its own. """ - fields = type(self).__pydantic_fields__ + fields = pydantic_fields(type(self)) for _block in ("griddata", "period"): data_vars = { name: self.to_dataarray(name) for name, f in fields.items() - if (f.json_schema_extra or {}).get("block") == _block - and getattr(self, name) is not None + if field_meta(f).get("block") == _block and getattr(self, name) is not None } if data_vars: return xr.Dataset(data_vars) diff --git a/flopy4/mf6/prt/disv.py b/flopy4/mf6/prt/disv.py index 84e60c4c..50563f58 100644 --- a/flopy4/mf6/prt/disv.py +++ b/flopy4/mf6/prt/disv.py @@ -88,12 +88,6 @@ def _coerce_xv_yv(cls, v): cell2d: Optional[list] = field(default=None, init=False, block="cell2d") def __post_init__(self, dims: Optional[dict] = None): - if self.iv is not None and (not isinstance(self.iv, np.ndarray)): - object.__setattr__(self, "iv", np.asarray(self.iv, dtype=np.int64)) - if self.xv is not None and (not isinstance(self.xv, np.ndarray)): - object.__setattr__(self, "xv", np.asarray(self.xv, dtype=np.float64)) - if self.yv is not None and (not isinstance(self.yv, np.ndarray)): - object.__setattr__(self, "yv", np.asarray(self.yv, dtype=np.float64)) if self.iv is not None and self.xv is not None and (self.yv is not None): rows = [ self.Vertices(iv=int(iv) + 1, xv=float(xv), yv=float(yv)) @@ -101,13 +95,13 @@ def __post_init__(self, dims: Optional[dict] = None): ] object.__setattr__(self, "vertices", rows) if self.cell2ddata is not None: - rows = [] + cell_rows = [] for rec in self.cell2ddata: row = (rec.icell2d + 1, rec.xc, rec.yc, rec.ncvert) + tuple( (v + 1 for v in rec.icvert) ) - rows.append(row) - object.__setattr__(self, "cell2d", rows) + cell_rows.append(row) + object.__setattr__(self, "cell2d", cell_rows) self.nodes = self.ncpl * self.nlay self.nrow = 0 self.ncol = 0 diff --git a/flopy4/mf6/record.py b/flopy4/mf6/record.py index 94ef15ad..75df7cae 100644 --- a/flopy4/mf6/record.py +++ b/flopy4/mf6/record.py @@ -12,7 +12,7 @@ Generated Record/Item subclasses are `pydantic.dataclasses.dataclass` -- `Record.fields()`/`_nested_class()` below read -`__pydantic_fields__`/`FieldInfo.json_schema_extra` accordingly. A nested/ +`pydantic_fields()`/`FieldInfo.json_schema_extra` accordingly. A nested/ composed field's annotation (e.g. `Headprint.formatrecord: "Oc.Format"`) is a forward-reference string naming a SIBLING class inside the same enclosing package class -- unresolvable via any module-global lookup at class-body- @@ -30,7 +30,9 @@ from typing import Any, Union, get_args, get_origin from pydantic import ConfigDict -from pydantic.dataclasses import rebuild_dataclass +from pydantic.dataclasses import is_pydantic_dataclass, rebuild_dataclass + +from flopy4.spec import field_meta, pydantic_fields CFG = ConfigDict(arbitrary_types_allowed=True, validate_assignment=True, extra="forbid") @@ -63,7 +65,7 @@ def _coerce(token: Any, finfo: Any) -> Any: to the raw string if it isn't a float). Only Optional[X] (a single non-None union arm) is unwrapped -- a genuine multi-type union like Union[float, str] is deliberately ambiguous and left as the raw token.""" - meta = finfo.json_schema_extra or {} + meta = field_meta(finfo) if isinstance(meta, dict) and meta.get("time_series"): try: return float(token) @@ -86,7 +88,7 @@ def _coerce(token: Any, finfo: Any) -> Any: def _is_tagged(finfo: Any) -> bool: - meta = finfo.json_schema_extra or {} + meta = field_meta(finfo) return bool(isinstance(meta, dict) and meta.get("tagged")) @@ -148,9 +150,10 @@ def fields(cls) -> dict[str, Any]: even when called before any instance of `cls` has ever been constructed (exactly what `from_tokens()` does). """ - if not cls.__pydantic_complete__: # type: ignore[attr-defined] - rebuild_dataclass(cls, force=True, _parent_namespace_depth=4) # type: ignore[arg-type] - return {n: f for n, f in cls.__pydantic_fields__.items() if not n.startswith("_")} # type: ignore[attr-defined] + assert is_pydantic_dataclass(cls) + if not cls.__pydantic_complete__: + rebuild_dataclass(cls, force=True, _parent_namespace_depth=4) + return {n: f for n, f in pydantic_fields(cls).items() if not n.startswith("_")} @classmethod def keyword(cls) -> str: diff --git a/flopy4/mf6/spec.py b/flopy4/mf6/spec.py index df31d678..c4c75656 100644 --- a/flopy4/mf6/spec.py +++ b/flopy4/mf6/spec.py @@ -14,6 +14,7 @@ from pydantic.fields import FieldInfo from flopy4.mf6._types import FloatArrayLike, IntArrayLike +from flopy4.spec import field_meta from flopy4.spec import fields_dict as flopy_fields_dict FieldType = Literal["keyword", "integer", "double", "string", "list", "record"] @@ -173,7 +174,7 @@ def blocks_dict(cls) -> dict[str, Block]: fields = fields_dict(cls) blocks: dict[str, Block] = {} for k, v in fields.items(): - block = v.json_schema_extra["block"] # type: ignore[index] + block = field_meta(v)["block"] if block not in blocks: blocks[block] = {} blocks[block][k] = v @@ -191,11 +192,7 @@ def fields_dict(cls) -> dict[str, FieldInfo]: whose keys are field names. Each field is a `FieldInfo`. """ fields = flopy_fields_dict(cls) - return { - k: v - for k, v in fields.items() - if isinstance(v.json_schema_extra, dict) and "block" in v.json_schema_extra - } + return {k: v for k, v in fields.items() if "block" in field_meta(v)} def _ndarray_field_type(t) -> FieldType | None: @@ -246,7 +243,7 @@ def repeating_array_key_type(field_type) -> type | None: return key if val in (IntArrayLike, FloatArrayLike) else None -def to_field_type(t: type) -> FieldType: +def to_field_type(t: Any) -> FieldType: if (result := _ndarray_field_type(t)) is not None: return result match t: diff --git a/flopy4/mf6/tdis.py b/flopy4/mf6/tdis.py index 6e40d0bb..4d3b2c53 100644 --- a/flopy4/mf6/tdis.py +++ b/flopy4/mf6/tdis.py @@ -46,9 +46,9 @@ class PeriodData(Item): optional=True, ) nper: int = field(default=1, block="dimensions") - perlen: NDArray[np.float64] = field(default=1.0) - nstp: NDArray[np.int64] = field(default=1) - tsmult: NDArray[np.float64] = field(default=1.0) + perlen: NDArray[np.float64] = field(default_factory=lambda: np.array(1.0)) + nstp: NDArray[np.int64] = field(default_factory=lambda: np.array(1, dtype=np.int64)) + tsmult: NDArray[np.float64] = field(default_factory=lambda: np.array(1.0)) perioddata: Optional[list[PeriodData]] = field(default=None, block="perioddata") # perlen/nstp/tsmult aren't Package "griddata" block fields (no @@ -81,28 +81,18 @@ def __post_init__(self, dims: Optional[dict] = None): super().__post_init__(dims) return nper = self.nper - # _coerce_to_array only runs on an EXPLICITLY passed value - # (pydantic doesn't validate an unused field default unless - # validate_default=True, not set here) -- so an untouched default - # (Tdis() with no perlen=/nstp=/tsmult= at all) still arrives here - # as a bare int/float; an - # explicit scalar/list override arrives already coerced to a 0-d/ - # plain ndarray by that validator. Both are handled below. - if isinstance(self.perlen, (int, float)): - object.__setattr__(self, "perlen", np.full(nper, self.perlen, dtype=np.float64)) - elif self.perlen.size == 1: + # Defaults are 0-d arrays, and _coerce_to_array turns an explicit + # scalar/list into an array, so each is an array here; broadcast a + # single value to nper. + if self.perlen.size == 1: object.__setattr__(self, "perlen", np.full(nper, self.perlen.item(), dtype=np.float64)) elif self.perlen.dtype != np.float64: object.__setattr__(self, "perlen", self.perlen.astype(np.float64)) - if isinstance(self.nstp, (int, float)): - object.__setattr__(self, "nstp", np.full(nper, int(self.nstp), dtype=np.int64)) - elif self.nstp.size == 1: + if self.nstp.size == 1: object.__setattr__(self, "nstp", np.full(nper, int(self.nstp.item()), dtype=np.int64)) elif self.nstp.dtype != np.int64: object.__setattr__(self, "nstp", self.nstp.astype(np.int64)) - if isinstance(self.tsmult, (int, float)): - object.__setattr__(self, "tsmult", np.full(nper, self.tsmult, dtype=np.float64)) - elif self.tsmult.size == 1: + if self.tsmult.size == 1: object.__setattr__(self, "tsmult", np.full(nper, self.tsmult.item(), dtype=np.float64)) elif self.tsmult.dtype != np.float64: object.__setattr__(self, "tsmult", self.tsmult.astype(np.float64)) diff --git a/flopy4/spec.py b/flopy4/spec.py index 1a0b458b..3f61ddad 100644 --- a/flopy4/spec.py +++ b/flopy4/spec.py @@ -5,6 +5,7 @@ from typing import Any from pydantic.dataclasses import is_pydantic_dataclass +from pydantic.fields import FieldInfo def is_dataclass_instance(value: Any) -> bool: @@ -14,9 +15,33 @@ def is_dataclass_instance(value: Any) -> bool: return is_pydantic_dataclass(type(value)) +def pydantic_fields(cls: type) -> dict[str, FieldInfo]: + """A pydantic dataclass's fields, by name. + + Use this rather than `cls.__pydantic_fields__` directly: pydantic sets + that attribute at runtime but doesn't declare it on the decorated + class's type, so mypy only sees it after `is_pydantic_dataclass()` + narrows `cls`. + """ + if not is_pydantic_dataclass(cls): + raise TypeError(f"{cls!r} is not a pydantic dataclass") + return cls.__pydantic_fields__ + + +def field_meta(finfo: FieldInfo) -> dict[str, Any]: + """A field's flopy4 metadata (`block`, `shape`, ...), or `{}`. + + Stored in `json_schema_extra`, which pydantic types as a JSON dict or + a callable; flopy4 only ever stores a dict there. + """ + extra = finfo.json_schema_extra + return extra if isinstance(extra, dict) else {} + + def fields_dict(cls) -> dict[str, Any]: """ Return an ordered dictionary of fields for a component class, whose keys are field names. Each field is a pydantic `FieldInfo`. + Empty for anything that isn't a pydantic dataclass. """ - return dict(getattr(cls, "__pydantic_fields__", {})) + return dict(pydantic_fields(cls)) if is_pydantic_dataclass(cls) else {} From 56a57764a1cbeec53852087d43cfe14b091d520a Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 23 Sep 2026 10:25:07 -0400 Subject: [PATCH 4/6] Make Component.name and Context.workspace non-optional - name: str with a subclass-aware default via a field validator (validate_default=True), replacing the post-init fill-in; drops the now-unneeded attr-defined ignores on .name - workspace property returns Path and raises if read before it's resolved; Context checks use isinstance instead of hasattr mypy flopy4 is now clean. Co-Authored-By: Claude Opus 5.5 --- flopy4/mf6/component.py | 77 +++++++++++++++++++++-------------------- flopy4/mf6/context.py | 17 +++++---- 2 files changed, 49 insertions(+), 45 deletions(-) diff --git a/flopy4/mf6/component.py b/flopy4/mf6/component.py index e25d16c9..2dcdac78 100644 --- a/flopy4/mf6/component.py +++ b/flopy4/mf6/component.py @@ -78,7 +78,7 @@ def _is_default_child_name(child: "Component") -> bool: """Whether `child`'s current `.name` is still at its class-name default (see `Component.name`'s own field docstring), i.e. no explicit name was ever given.""" - return child.name == type(child).__name__.lower() # type: ignore[attr-defined] + return child.name == type(child).__name__.lower() def _resolve_child_name(used: "set[str]", kind: str, field_name: str, child: "Component") -> str: @@ -99,12 +99,11 @@ def _resolve_child_name(used: "set[str]", kind: str, field_name: str, child: "Co if kind not in ("only", "list"): raise TypeError(f"Bad child collection kind '{kind}'") if not _is_default_child_name(child): - if child.name in used: # type: ignore[attr-defined] + if child.name in used: raise ValueError( - f"Child name '{child.name}' collides with an existing child " # type: ignore[attr-defined] - "on the same parent." + f"Child name '{child.name}' collides with an existing child on the same parent." ) - return child.name # type: ignore[attr-defined] + return child.name if kind == "only": return field_name i = 0 @@ -165,14 +164,12 @@ class Component(DimensionResolverMixin, ABC, MutableMapping): filename: Optional[str] = Field(default=None) """The name of the component's input file.""" - name: Optional[str] = Field(default=None) - """The component's own identity/tag name. `None` until `__post_init__` - defaults it to the *actual* runtime class's lowercased name -- not - whichever class in the hierarchy happens to declare this field -- so a - `Package` leaf (e.g. `Ic`, never separately subclassed for this field) - still gets "ic", not "package". (Pydantic's `default_factory` can't see - the instance, so this is filled in by `__post_init__` rather than a - declarative default.) Overridden explicitly by + name: str = Field(default="", validate_default=True) + """The component's own identity/tag name. Defaults to the *actual* + runtime class's lowercased name -- not whichever class in the hierarchy + happens to declare this field -- so a `Package` leaf (e.g. `Ic`, never + separately subclassed for this field) still gets "ic", not "package". + See `_default_name`. Overridden explicitly by `_resolve_child_name()`/`_attach_to_parent_field()` when a component is attached as a named child; otherwise this default stands.""" @@ -201,6 +198,14 @@ class Component(DimensionResolverMixin, ABC, MutableMapping): `__post_init__` chain to `Package.__post_init__`, which broadcasts scalar griddata to full shape, and not stored on the instance.""" + @field_validator("name", mode="before") + @classmethod + def _default_name(cls, v: Any) -> str: + """Default `name` to the runtime class's lowercased name. `cls` is + the class actually being constructed, so this is subclass-aware; + `validate_default=True` makes it run when `name` isn't given.""" + return v or cls.__name__.lower() + @field_validator("*", mode="before") @classmethod def _apply_converter(cls, v: Any, info) -> Any: @@ -245,7 +250,7 @@ def parent(self, value: "Component | None") -> None: if old is value: return if old is not None: - del old[self.name] # type: ignore[attr-defined] + del old[self.name] self._parent = None if value is not None: self._parent = value @@ -324,14 +329,14 @@ def _set_child_parents(self) -> None: if kind == "only": if isinstance(value, Component): value.__dict__["_parent"] = self - value.name = _resolve_child_name(used, kind, name, value) # type: ignore[attr-defined] - used.add(value.name) # type: ignore[attr-defined] + value.name = _resolve_child_name(used, kind, name, value) + used.add(value.name) elif kind == "list": for child in value: if isinstance(child, Component): child.__dict__["_parent"] = self - child.name = _resolve_child_name(used, kind, name, child) # type: ignore[attr-defined] - used.add(child.name) # type: ignore[attr-defined] + child.name = _resolve_child_name(used, kind, name, child) + used.add(child.name) elif kind == "dict": for key, child in value.items(): if isinstance(child, Component): @@ -341,8 +346,8 @@ def _set_child_parents(self) -> None: f"Child name '{key}' collides with an " "existing child on the same parent." ) - child.name = key # type: ignore[attr-defined] - used.add(child.name) # type: ignore[attr-defined] + child.name = key + used.add(child.name) @property def path(self) -> Path: @@ -371,10 +376,8 @@ class post-init hooks (including DimensionRegistryMixin). `dims` is the `dims` InitVar; only `Package` uses it, so it isn't passed further up the chain. - Defaults `.name` from the runtime class (see the field's own - docstring), then runs the two `_parent`-tracking hooks (see - `_parent`'s docstring): stamps - `_parent` on this component's own already-populated children + Runs the two `_parent`-tracking hooks (see `_parent`'s docstring): + stamps `_parent` on this component's own already-populated children (top-down construction), and -- if this component's own `_parent` was given directly as `parent=`, i.e. bottom-up construction -- attaches `self` into the matching field on it and resolves its @@ -383,8 +386,6 @@ class post-init hooks (including DimensionRegistryMixin). # Chain to parent classes (including DimensionRegistryMixin) if hasattr(super(), "__post_init__"): super().__post_init__() # type: ignore[misc] - if self.name is None: - self.name = type(self).__name__.lower() if self._parent is not None: self._attach_to_parent_field(self._parent) self._set_child_parents() @@ -405,19 +406,19 @@ def _attach_to_parent_field(self, parent: "Component") -> None: if match is None: return target_name, kind = match - used = {c.name for c in parent._children.values()} # type: ignore[attr-defined] + used = {c.name for c in parent._children.values()} if kind == "only": - self.name = _resolve_child_name(used, kind, target_name, self) # type: ignore[attr-defined] + self.name = _resolve_child_name(used, kind, target_name, self) setattr(parent, target_name, self) elif kind == "list": - self.name = _resolve_child_name(used, kind, target_name, self) # type: ignore[attr-defined] + self.name = _resolve_child_name(used, kind, target_name, self) getattr(parent, target_name).append(self) elif kind == "dict": # No positional auto-key to fall back on for an unnamed child, # unlike "only"/"list" -- see `_set_child_parents`'s "dict" # branch: the child's own `.name` (explicit, or its # class-name default) is the key. - key = self.name # type: ignore[attr-defined] + key = self.name if key in used: raise ValueError( f"Child name '{key}' collides with an existing child on the same parent." @@ -460,21 +461,21 @@ def __setitem__(self, key, value): kind, _ = spec current = getattr(self, name, None) if kind == "only": - if isinstance(current, Component) and current.name == key: # type: ignore[attr-defined] - value.name = key # type: ignore[attr-defined] + if isinstance(current, Component) and current.name == key: + value.name = key value.__dict__["_parent"] = self setattr(self, name, value) return elif kind == "list": for i, child in enumerate(current or []): - if isinstance(child, Component) and child.name == key: # type: ignore[attr-defined] - value.name = key # type: ignore[attr-defined] + if isinstance(child, Component) and child.name == key: + value.name = key value.__dict__["_parent"] = self current[i] = value return elif kind == "dict": if current and key in current: - value.name = key # type: ignore[attr-defined] + value.name = key value.__dict__["_parent"] = self current[key] = value return @@ -484,7 +485,7 @@ def __setitem__(self, key, value): raise TypeError(f"No field on {type(self).__name__} accepts a {type(value).__name__}") target_name, kind = match value.__dict__["_parent"] = self - value.name = key # type: ignore[attr-defined] + value.name = key if kind == "only": setattr(self, target_name, value) elif kind == "list": @@ -504,12 +505,12 @@ def __delitem__(self, key): kind, _ = spec value = getattr(self, name, None) if kind == "only": - if isinstance(value, Component) and value.name == key: # type: ignore[attr-defined] + if isinstance(value, Component) and value.name == key: setattr(self, name, None) return elif kind == "list": for i, child in enumerate(value or []): - if isinstance(child, Component) and child.name == key: # type: ignore[attr-defined] + if isinstance(child, Component) and child.name == key: del value[i] return elif kind == "dict": diff --git a/flopy4/mf6/context.py b/flopy4/mf6/context.py index 0ba4e486..9fbcd61e 100644 --- a/flopy4/mf6/context.py +++ b/flopy4/mf6/context.py @@ -20,17 +20,22 @@ class Context(Component, ABC): _workspace: Any = Field(default=None, alias="workspace", repr=False) @property - def workspace(self) -> Optional[Path]: + def workspace(self) -> Path: + """The directory this context's files live in. Given at + construction, or resolved by `__post_init__` from the parent's + workspace or the current directory.""" + if self._workspace is None: + raise RuntimeError(f"{type(self).__name__}.workspace is not resolved yet") return self._workspace @workspace.setter def workspace(self, value) -> None: """Coerce `value` to a `Path`, then propagate it to every child - that has its own `workspace` attribute.""" + `Context`.""" value = to_path(value) self._workspace = value for child in self._children.values(): - if hasattr(child, "workspace"): + if isinstance(child, Context): child.workspace = value def __post_init__(self, dims: Optional[dict] = None): @@ -38,11 +43,9 @@ def __post_init__(self, dims: Optional[dict] = None): # By the time this runs, `super().__post_init__()` (Component's) # has already resolved `_parent`/`.parent` for both top-down and # bottom-up construction (see `Component._parent`'s docstring). - if self.workspace is None: + if self._workspace is None: self.workspace = ( - self._parent.workspace - if self._parent and hasattr(self._parent, "workspace") - else Path.cwd() + self._parent.workspace if isinstance(self._parent, Context) else Path.cwd() ) @property From c900c60afcd8cbd33b20569f44d82cc0770469a5 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 23 Sep 2026 13:54:42 -0400 Subject: [PATCH 5/6] Make Component.filename a Path and write POSIX paths - filename is Optional[Path] (str still accepted): it's a workspace-relative path, and name-file entries can include subdirectories - Every path written into an input file uses as_posix() so files are portable across platforms: name-file bindings, DFN file records, list rows, records, scalar options, and OPEN/CLOSE array references - Drop the str() workarounds from the frenchman-flat example Co-Authored-By: Claude Opus 5.5 --- docs/examples/frenchman-flat.py | 6 ++-- flopy4/mf6/__init__.py | 2 +- flopy4/mf6/codec/writer/filters.py | 8 +++++- .../mf6/codec/writer/templates/macros.jinja | 2 +- flopy4/mf6/component.py | 9 +++--- flopy4/mf6/context.py | 2 +- flopy4/mf6/converter/__init__.py | 2 +- flopy4/mf6/converter/binding.py | 4 ++- flopy4/mf6/converter/egress/unstructure.py | 2 +- flopy4/mf6/converter/ingress/structure.py | 2 +- flopy4/mf6/gwf/disbase.py | 2 +- flopy4/mf6/item.py | 2 +- flopy4/mf6/record.py | 4 ++- flopy4/mf6/simulation.py | 5 ++-- test/mf6/test_mf6_component.py | 28 ++++++++++++++++++- 15 files changed, 59 insertions(+), 21 deletions(-) diff --git a/docs/examples/frenchman-flat.py b/docs/examples/frenchman-flat.py index fee30657..2cda6958 100644 --- a/docs/examples/frenchman-flat.py +++ b/docs/examples/frenchman-flat.py @@ -703,7 +703,7 @@ def plot_head_ugrid(head, cbc, grid, workspace): nc_fpth = workspace / "frenchman-flat.input.nc" gwf.netcdf_input_file = nc_fpth dis.ncf = Ncf.from_grid(grid, NetCDFFormat.LAYERED_MESH) -dis.ncf.filename = str(workspace / "ff.dis.ncf") +dis.ncf.filename = workspace / "ff.dis.ncf" # Here, grid and time info is passed to the `NetCDFModel' constructor # so that coordinate and mesh data is written to the NetCDF file. @@ -822,7 +822,7 @@ def plot_head_ugrid(head, cbc, grid, workspace): gwf.netcdf_mesh2d_file = Path("frenchman-flat.nc") gwf.netcdf_input_file = Path("frenchman-flat.input.nc") dis.ncf = Ncf.from_grid(grid, NetCDFFormat.LAYERED_MESH) -dis.ncf.filename = str(workspace / "ff.dis.ncf") +dis.ncf.filename = workspace / "ff.dis.ncf" # Again, with grid and time info nc_model = flopy4.mf6.netcdf.NetCDFModel.from_model( @@ -871,7 +871,7 @@ def plot_head_ugrid(head, cbc, grid, workspace): gwf.netcdf_input_file = nc_fpth gwf.netcdf_mesh2d_file = None dis.ncf = Ncf.from_grid(grid, NetCDFFormat.STRUCTURED) -dis.ncf.filename = str(workspace / "ff.dis.ncf") +dis.ncf.filename = workspace / "ff.dis.ncf" # Again, with grid and time info nc_model = flopy4.mf6.netcdf.NetCDFModel.from_model(gwf, grid=grid, time=time) diff --git a/flopy4/mf6/__init__.py b/flopy4/mf6/__init__.py index e008756d..b9300d9a 100644 --- a/flopy4/mf6/__init__.py +++ b/flopy4/mf6/__init__.py @@ -63,7 +63,7 @@ def _load_mf6(cls, path: Path, name: "str | None" = None) -> Component: instance = structure_component(raw, cls, workspace=path.parent, name=name) if isinstance(instance, Context): instance.workspace = path.parent - instance.filename = path.name + instance.filename = Path(path.name) return instance diff --git a/flopy4/mf6/codec/writer/filters.py b/flopy4/mf6/codec/writer/filters.py index e5cd6d11..7f832a54 100644 --- a/flopy4/mf6/codec/writer/filters.py +++ b/flopy4/mf6/codec/writer/filters.py @@ -1,6 +1,7 @@ import dataclasses from collections.abc import Hashable, Mapping from io import StringIO +from pathlib import PurePath from typing import Any, Literal import numpy as np @@ -150,7 +151,7 @@ def array2string(value: NDArray, precision: int = 9) -> str: return buffer.getvalue().strip() -def quote_if_needed(value: str) -> str: +def quote_if_needed(value: Any) -> str: """ Wrap a string in single quotes if it contains double-quotes. @@ -158,7 +159,12 @@ def quote_if_needed(value: str) -> str: contain double-quotes and must be single-quoted for MF6 to parse them. MF6 keyword sequences like 'STEPS 1 5' or 'all' are left as-is even if they contain spaces, because they are not string literals. + + Paths are written with POSIX separators so input files are portable + across platforms. """ + if isinstance(value, PurePath): + return value.as_posix() if isinstance(value, str) and '"' in value: return f"'{value}'" return str(value) diff --git a/flopy4/mf6/codec/writer/templates/macros.jinja b/flopy4/mf6/codec/writer/templates/macros.jinja index d4fe8e78..1dcfb0e1 100644 --- a/flopy4/mf6/codec/writer/templates/macros.jinja +++ b/flopy4/mf6/codec/writer/templates/macros.jinja @@ -53,7 +53,7 @@ {{ (2 * inset) ~ chunk|array2string(context.float_precision) }} {%- endfor %} {% elif how == "external" %} -OPEN/CLOSE {{ value }} +OPEN/CLOSE {{ value|quote_if_needed }} {% endif %} {% endif %} {% endmacro %} diff --git a/flopy4/mf6/component.py b/flopy4/mf6/component.py index 2dcdac78..d4709a45 100644 --- a/flopy4/mf6/component.py +++ b/flopy4/mf6/component.py @@ -161,8 +161,9 @@ class Component(DimensionResolverMixin, ABC, MutableMapping): _load = IO(Loader) # type: ignore _write = IO(Writer) # type: ignore - filename: Optional[str] = Field(default=None) - """The name of the component's input file.""" + filename: Optional[Path] = Field(default=None) + """The component's input file, relative to the workspace (a `str` is + accepted and converted). Written to name files with POSIX separators.""" name: str = Field(default="", validate_default=True) """The component's own identity/tag name. Defaults to the *actual* @@ -352,7 +353,7 @@ def _set_child_parents(self) -> None: @property def path(self) -> Path: """The path to the component's input file.""" - self.filename = self.filename or self.default_filename() + self.filename = self.filename or Path(self.default_filename()) return Path.cwd() / self.filename def default_filename(self) -> str: @@ -554,7 +555,7 @@ def write(self, format: str = MF6, context: Optional[WriteContext] = None) -> No # name as this component's filename stem, if it has one. an # actual solution is to auto-set the filename when children # are attached to parents. - self.filename = self.filename or self.default_filename() + self.filename = self.filename or Path(self.default_filename()) # Determine active context: provided > current > default active_context = context or WriteContext.current() diff --git a/flopy4/mf6/context.py b/flopy4/mf6/context.py index 9fbcd61e..5a4111d7 100644 --- a/flopy4/mf6/context.py +++ b/flopy4/mf6/context.py @@ -50,7 +50,7 @@ def __post_init__(self, dims: Optional[dict] = None): @property def path(self) -> Path: - self.filename = self.filename or self.default_filename() + self.filename = self.filename or Path(self.default_filename()) return self.workspace / self.filename @classmethod diff --git a/flopy4/mf6/converter/__init__.py b/flopy4/mf6/converter/__init__.py index 9c0803bf..8f0b6b6d 100644 --- a/flopy4/mf6/converter/__init__.py +++ b/flopy4/mf6/converter/__init__.py @@ -34,7 +34,7 @@ def structure(data: dict[str, Any], path: Path) -> Component: component = COMPONENT_CONVERTER.structure(data, Component) if isinstance(component, Context): component.workspace = path.parent - component.filename = path.name + component.filename = Path(path.name) return component diff --git a/flopy4/mf6/converter/binding.py b/flopy4/mf6/converter/binding.py index 9ec18cee..c350276e 100644 --- a/flopy4/mf6/converter/binding.py +++ b/flopy4/mf6/converter/binding.py @@ -1,3 +1,5 @@ +from pathlib import Path + from pydantic import ConfigDict from pydantic.dataclasses import dataclass @@ -50,6 +52,6 @@ def _get_binding_terms(component: Component) -> tuple[str, ...] | None: return cls( type=component_ftype(type(component)), - fname=component.filename or component.default_filename(), + fname=Path(component.filename or component.default_filename()).as_posix(), terms=_get_binding_terms(component), ) diff --git a/flopy4/mf6/converter/egress/unstructure.py b/flopy4/mf6/converter/egress/unstructure.py index 90c3d51e..f7b02838 100644 --- a/flopy4/mf6/converter/egress/unstructure.py +++ b/flopy4/mf6/converter/egress/unstructure.py @@ -31,7 +31,7 @@ def _path_to_tuple(name: str, field: FieldInfo, value: Path) -> tuple[str, ...]: t = [str(keyword).upper()] if direction: t.append("FILEOUT" if direction == "out" else "FILEIN") - t.append(str(value)) + t.append(value.as_posix()) return tuple(t) diff --git a/flopy4/mf6/converter/ingress/structure.py b/flopy4/mf6/converter/ingress/structure.py index 3f3d8bbf..12135712 100644 --- a/flopy4/mf6/converter/ingress/structure.py +++ b/flopy4/mf6/converter/ingress/structure.py @@ -539,7 +539,7 @@ def _resolve_bindings(cls: type, raw_lower: dict, workspace: Path) -> dict[str, if issubclass(target_cls, Package) else target_cls.load(workspace / fname, name=pname) ) - child.filename = fname + child.filename = Path(fname) _apply_binding_terms(child, row[2:]) if isinstance(child, DimensionProvider): dims = {**dims, **child.get_dims()} diff --git a/flopy4/mf6/gwf/disbase.py b/flopy4/mf6/gwf/disbase.py index 5eecc4c1..607c3546 100644 --- a/flopy4/mf6/gwf/disbase.py +++ b/flopy4/mf6/gwf/disbase.py @@ -67,7 +67,7 @@ def write(self, format: str = MF6, context: Optional[WriteContext] = None) -> No ncf = getattr(self, "ncf", None) if ncf is not None: if getattr(self, "ncf6_filerecord", None) is None and ncf.filename is not None: - setattr(self, "ncf6_filerecord", Path(Path(ncf.filename).name)) + setattr(self, "ncf6_filerecord", Path(ncf.filename.name)) super().write(format=format, context=context) if ncf is not None: # NCF lat/lon coordinate arrays require full float64 precision. diff --git a/flopy4/mf6/item.py b/flopy4/mf6/item.py index 45ac4151..1f1e69a5 100644 --- a/flopy4/mf6/item.py +++ b/flopy4/mf6/item.py @@ -238,7 +238,7 @@ def to_tokens(self) -> tuple: row.append(file_kw.upper()) if direction := meta.get("direction"): row.append("FILEOUT" if direction == "out" else "FILEIN") - row.append(str(val) if isinstance(val, Path) else val) + row.append(val.as_posix() if isinstance(val, Path) else val) if not keyword_emitted: row.append(keyword.upper()) aux = getattr(self, "aux", None) diff --git a/flopy4/mf6/record.py b/flopy4/mf6/record.py index 75df7cae..bcb8a2a8 100644 --- a/flopy4/mf6/record.py +++ b/flopy4/mf6/record.py @@ -26,7 +26,7 @@ from __future__ import annotations import types -from pathlib import Path +from pathlib import Path, PurePath from typing import Any, Union, get_args, get_origin from pydantic import ConfigDict @@ -181,6 +181,8 @@ def to_tokens(self) -> tuple: tokens.append(name.upper()) elif isinstance(v, (list, tuple)): tokens.extend(v) + elif isinstance(v, PurePath): + tokens.append(v.as_posix()) else: tokens.append(v) return tuple(tokens) diff --git a/flopy4/mf6/simulation.py b/flopy4/mf6/simulation.py index 7070d9a2..8cb49596 100644 --- a/flopy4/mf6/simulation.py +++ b/flopy4/mf6/simulation.py @@ -1,4 +1,5 @@ from os import PathLike +from pathlib import Path from typing import ClassVar, Optional from warnings import warn @@ -36,13 +37,13 @@ def default_filename(self) -> str: def __post_init__(self, dims: Optional[dict] = None): super().__post_init__(dims) - if self.filename != "mfsim.nam": + if self.filename != Path("mfsim.nam"): if self.filename is not None: warn( "Simulation filename must be 'mfsim.nam'.", UserWarning, ) - self.filename = "mfsim.nam" + self.filename = Path("mfsim.nam") # Re-propagate workspace to Simulation's own children (models/ # exchanges/solutions/tdis) -- Context.__post_init__ (already run, # via super() above) only saw whatever was attached at ITS point diff --git a/test/mf6/test_mf6_component.py b/test/mf6/test_mf6_component.py index 4d51516d..275bfc74 100644 --- a/test/mf6/test_mf6_component.py +++ b/test/mf6/test_mf6_component.py @@ -536,7 +536,7 @@ def test_to_xarray_on_context(function_tmpdir): assert isinstance(dt, xr.DataTree) assert isinstance(dt.kper, xr.DataArray) assert np.array_equal(dt.kper, [0]) - assert dt.attrs["filename"] == "mfsim.nam" + assert dt.attrs["filename"] == Path("mfsim.nam") assert dt.attrs["workspace"] == Path(function_tmpdir) @@ -1774,3 +1774,29 @@ def test_eq_ignores_dims_and_parent(): assert Ims(dims={"nodes": 3}) == Ims(dims={"nodes": 4}) assert Ims(parent=Simulation()) == Ims() assert Ims(inner_maximum=10) != Ims(inner_maximum=20) + + +def test_filename_is_path(): + """`filename` accepts a str or Path, is stored as a Path, and goes into + the name file with POSIX separators.""" + from pathlib import PureWindowsPath + + from flopy4.mf6.codec.writer.filters import quote_if_needed + from flopy4.mf6.converter.binding import Binding + + ic = Ic(filename="gwf.ic") + assert ic.filename == Path("gwf.ic") + ic.filename = Path("sub") / "gwf.ic" + assert Binding.from_component(ic).fname == "sub/gwf.ic" + assert quote_if_needed(PureWindowsPath("sub\\gwf.ic")) == "sub/gwf.ic" + + +def test_external_array_path_is_posix(): + """An external array's OPEN/CLOSE path is written with POSIX separators.""" + from pathlib import PureWindowsPath + + from flopy4.mf6.codec.writer import _JINJA_ENV + + macros = _JINJA_ENV.get_template("macros.jinja").module + out = str(macros.array("top", PureWindowsPath("data\\top.dat"), how="external")) # type: ignore[attr-defined] + assert "OPEN/CLOSE data/top.dat" in out From c4178c8012e1eccf0d11ec8825d70d9e582ab98c Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Thu, 24 Sep 2026 08:01:40 -0400 Subject: [PATCH 6/6] Keep integer dtype for layered griddata and fix netcdf path tests _coerce_griddata looked up a "dfn_type" metadata key that nothing sets, so layered griddata was always cast to float64. A scalar idomain was then written as a float CONSTANT, which the macOS mf6 build rejects. Resolve the dtype from the field annotation, as Package does. The netcdf integration tests compared against str(path), which fails on Windows now that written paths use POSIX separators. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01JhSQ3nPJL9HXGAS6LmbMVw --- flopy4/mf6/gwf/disbase.py | 3 ++- test/mf6/test_mf6_component.py | 10 ++++++++++ test/mf6/test_mf6_integration.py | 4 ++-- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/flopy4/mf6/gwf/disbase.py b/flopy4/mf6/gwf/disbase.py index 607c3546..6503b8cd 100644 --- a/flopy4/mf6/gwf/disbase.py +++ b/flopy4/mf6/gwf/disbase.py @@ -9,6 +9,7 @@ from flopy4.mf6.constants import MF6 from flopy4.mf6.package import _DTYPE_MAP as _PKG_DTYPE_MAP from flopy4.mf6.package import CFG, Package +from flopy4.mf6.spec import to_field_type from flopy4.mf6.write_context import WriteContext from flopy4.spec import field_meta, pydantic_fields @@ -50,7 +51,7 @@ def _coerce_griddata(self) -> None: val = self.__dict__.get(name) if val is None: continue - dtype = _PKG_DTYPE_MAP.get(meta.get("dfn_type", "double"), np.float64) + dtype = _PKG_DTYPE_MAP.get(to_field_type(f.annotation), np.float64) if isinstance(val, (list, tuple)): val = np.asarray(val, dtype=dtype) self.__dict__[name] = val diff --git a/test/mf6/test_mf6_component.py b/test/mf6/test_mf6_component.py index 275bfc74..08f32050 100644 --- a/test/mf6/test_mf6_component.py +++ b/test/mf6/test_mf6_component.py @@ -1800,3 +1800,13 @@ def test_external_array_path_is_posix(): macros = _JINJA_ENV.get_template("macros.jinja").module out = str(macros.array("top", PureWindowsPath("data\\top.dat"), how="external")) # type: ignore[attr-defined] assert "OPEN/CLOSE data/top.dat" in out + + +def test_layered_int_griddata_keeps_int_dtype(): + # A scalar layered integer array (idomain=1) is repeated per layer in + # _coerce_griddata; it must stay integer, or it's written as a float + # CONSTANT, which some MF6 builds reject for an integer array. + from flopy4.mf6.gwf import Dis + + dis = Dis(nlay=2, nrow=1, ncol=3, top=1.0, botm=[0.0, -1.0], idomain=1) + assert dis.idomain.dtype == np.int64 diff --git a/test/mf6/test_mf6_integration.py b/test/mf6/test_mf6_integration.py index 0af8bb0d..2f367cef 100644 --- a/test/mf6/test_mf6_integration.py +++ b/test/mf6/test_mf6_integration.py @@ -764,7 +764,7 @@ def test_quickstart_netcdf(function_tmpdir): with open(function_tmpdir / f"{gwf_name}.nam", "r") as fh: lines = fh.readlines() nc_fpth = function_tmpdir / f"{gwf_name}.input.nc" - assert f" NETCDF FILEIN {nc_fpth}\n" in lines + assert f" NETCDF FILEIN {nc_fpth.as_posix()}\n" in lines with open(function_tmpdir / f"{gwf_name}.dis", "r") as fh: lines = fh.readlines() assert " DELR NETCDF\n" in lines @@ -876,7 +876,7 @@ def test_quickstart_netcdf_mesh(function_tmpdir): with open(function_tmpdir / f"{gwf_name}.nam", "r") as fh: lines = fh.readlines() nc_fpth = function_tmpdir / f"{gwf_name}.input.nc" - assert f" NETCDF FILEIN {nc_fpth}\n" in lines + assert f" NETCDF FILEIN {nc_fpth.as_posix()}\n" in lines with open(function_tmpdir / f"{gwf_name}.dis", "r") as fh: lines = fh.readlines() assert " DELR NETCDF\n" in lines