From c4f22465978f4cd902e7caab8ce2fd0ebdaeab18 Mon Sep 17 00:00:00 2001 From: wpbonelli Date: Wed, 4 Sep 2024 00:02:36 -0400 Subject: [PATCH] from/to dict via @context, extra utils, tidying --- docs/dev/sdd.md | 26 +++--- flopy4/attrs.py | 125 +++++++++++++++++------------ flopy4/utils.py | 8 ++ test/test_attrs.py | 133 ++++++++++--------------------- test/test_gwfoc.py | 195 +++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 332 insertions(+), 155 deletions(-) create mode 100644 test/test_gwfoc.py diff --git a/docs/dev/sdd.md b/docs/dev/sdd.md index 7c21936..d11cbc4 100644 --- a/docs/dev/sdd.md +++ b/docs/dev/sdd.md @@ -250,13 +250,14 @@ A parameter is a primitive value or a **composite** of such. Primitive parameters are **scalar** (int, float, bool, -string, path), **array-like**, or **tabular**. +string, path) or **array-like**. > [!NOTE] > Ideally a data model would be dependency-agnostic, -but we view NumPy and Pandas as de facto standard -library and accept them as array/table primitives. -If there is ever need to provide arrays/tables +but we view NumPy as de facto standard library and +accept its array primitives — especially as +they have made recent advancements in type hinting. +If there is ever need to define array abstractions of our own, we could take inspiration from [astropy](https://github.com/astropy/astropy). @@ -265,16 +266,11 @@ Composite parameters are **record** and **union** **lists** of primitives or records. A record is a named and ordered tuple of primitives. -A record's parameters must all be scalars, except -for its last parameter, which may be a sequence of -scalars (such a record could be called *variadic*; -it is a value constructor with unspecified arity). - > [!NOTE] -> A record is a `Dict` for practical purposes. It -needs implementing as an `attrs`-based class so -its parameter spec is discoverable upon import, -though. +> Records are shown as `Dict` for demonstration, +but need implementing as an `attrs`-based class +so the parameter specification is discoverable +upon import. A list may constrain its elements to parameters of a single scalar or record type, or may hold unions @@ -283,7 +279,9 @@ of such. > [!NOTE] > On this view an MF6 keystring is a `typing.Union` of records and a period block is a list of `Union`s -of records. +of records. Most packages' `packagedata` block, on +the other hand, have a regular shape, and can thus +be considered tabular. A context is a map of parameters. So is a record; the operative difference is that composites cannot diff --git a/flopy4/attrs.py b/flopy4/attrs.py index d6d4aca..43b2e99 100644 --- a/flopy4/attrs.py +++ b/flopy4/attrs.py @@ -1,26 +1,32 @@ from pathlib import Path from typing import ( - Dict, - Iterable, - List, Optional, TypeVar, Union, ) -from attrs import NOTHING, Attribute, define, field, fields +import attr +from attrs import NOTHING, define, field, fields +from cattrs import structure, unstructure from numpy.typing import ArrayLike from pandas import DataFrame -# Core input data model. This enumerates the -# types FloPy accepts in input data contexts. +# Enumerate the primitive types to support. Scalar = Union[bool, int, float, str, Path] -Record = Dict[str, Union[Scalar, List[Scalar]]] -List = List[Union[Scalar, Record]] +"""A scalar input parameter.""" + + Array = ArrayLike +"""An array input parameter""" + + Table = DataFrame -Param = Union[Scalar, Record, List, Array, Table] +"""A table input parameter.""" + + +Param = Union[Scalar, Array, Table] +"""An input parameter.""" # Wrap `attrs.field()` for input parameters. @@ -32,6 +38,7 @@ def param( deprecated: bool = False, optional: bool = False, default=NOTHING, + alias=None, metadata=None, validator=None, converter=None, @@ -53,6 +60,7 @@ def param( order=False, hash=False, init=True, + alias=alias, metadata=metadata, converter=converter, ) @@ -66,7 +74,9 @@ def params(cls): Notes ----- Wraps `attrs.fields()`. A parameter can be a value - itself or another nested context of parameters. + itself or another nested context of parameters. We + eschew the traditional `get_...()` naming in favor + of `params()` in the spirit of `attrs.fields()`. """ return {field.name: field for field in fields(cls)} @@ -80,30 +90,35 @@ def params(cls): def context( maybe_cls: Optional[type[T]] = None, *, + auto_attribs: bool = True, frozen: bool = False, - multi: bool = False, ): """ Wrap `attrs.define()` for more opinionated input contexts. Notes ----- - Contexts are parameter containers and can be nested to an - arbitrary depth. + Input contexts may be nested to an arbitrary depth. + + Contexts can be made immutable with `frozen=True`. """ - def add_index(fields): - return [ - Attribute.from_counting_attr(name="index", ca=field(), type=int), - *fields, - ] + def from_dict(cls, d: dict): + """Convert the dictionary to a context.""" + return structure(d, cls) + + def to_dict(self): + """Convert the context to a dictionary.""" + return unstructure(self) def wrap(cls): - transformer = (lambda _, fields: add_index(fields)) if multi else None + setattr(cls, "from_dict", classmethod(from_dict)) + setattr(cls, "to_dict", to_dict) return define( cls, - field_transformer=transformer, + auto_attribs=auto_attribs, frozen=frozen, + slots=False, weakref_slot=True, ) @@ -113,42 +128,52 @@ def wrap(cls): return wrap(maybe_cls) -def record(maybe_cls: Optional[type[T]] = None, *, frozen: bool = True): - """ - Wrap `attrs.define()` for immutable records (tuples of parameters). - - Notes - ----- - - Records are frozen by default. - - A variadic record ends with a list. A `variadic` flag is attached - to record classes via introspection at import time. - """ - - def add_variadic(cls, fields): - last = fields[-1] - variadic = False - try: - variadic = issubclass(last.type, Iterable) - except: - variadic = ( - hasattr(last.type, "__origin__") - and last.type.__origin__ is list - ) - setattr(cls, "variadic", variadic) - return fields - +def choice( + maybe_cls: Optional[type[T]] = None, + *, + frozen: bool = False, +): def wrap(cls): - return define( + return context( cls, - auto_attribs=True, - field_transformer=add_variadic, frozen=frozen, - weakref_slot=True, ) if maybe_cls is None: return wrap return wrap(maybe_cls) + + +# Utilities + + +def is_attrs(cls: type) -> bool: + """Determines whether the given class is `attrs`-based.""" + + return hasattr(cls, "__attrs_attrs__") + + +def is_frozen(cls: type) -> bool: + """ + Determines whether the `attrs`-based class is frozen (i.e. immutable). + + Notes + ----- + The class *must* be `attrs`-based, otherwise `TypeError` is raised. + + The way to check this may change in the future. See: + - https://github.com/python-attrs/attrs/issues/853 + - https://github.com/python-attrs/attrs/issues/602 + """ + + return cls.__setattr__ == attr._make._frozen_setattrs + + +def to_path(val) -> Optional[Path]: + if val is None: + return None + try: + return Path(val).expanduser() + except: + raise ValueError(f"Cannot convert value to Path: {val}") diff --git a/flopy4/utils.py b/flopy4/utils.py index c1cb167..b796db3 100644 --- a/flopy4/utils.py +++ b/flopy4/utils.py @@ -4,6 +4,14 @@ def find_upper(s): yield i +def flatten(d): + if isinstance(d, (tuple, list)): + for x in d: + yield from flatten(x) + else: + yield d + + def strip(line): """ Remove comments and replace commas from input text diff --git a/test/test_attrs.py b/test/test_attrs.py index 4855fbc..f4349d9 100644 --- a/test/test_attrs.py +++ b/test/test_attrs.py @@ -1,36 +1,41 @@ import math from pathlib import Path -from typing import List, Union +from typing import Dict, Optional +import numpy as np import pytest +from attrs import asdict, astuple +from numpy.typing import NDArray -from flopy4.attrs import Array, context, param, params, record +from flopy4.attrs import ( + context, + is_frozen, + param, + params, +) # Records are product types: named, ordered tuples of scalars. # Records are immutable: they can't be changed, only evolved. -@record +@context(frozen=True) class Record: - rk: bool = param(description="keyword in record") + rb: bool = param(description="bool in record") ri: int = param(description="int in record") - rd: float = param(description="double in record") - - -@record -class VariadicRecord: - vrk: bool = param(description="keyword in record") - vrl: List[int] = param(description="list in record") + rf: float = param(description="float in record") + rs: Optional[str] = param( + description="optional str in record", default=None + ) @context class Block: - k: bool = param(description="keyword") + b: bool = param(description="bool") i: int = param(description="int") - d: float = param(description="double") - s: str = param(description="string", optional=False) - f: Path = param(description="filename", optional=False) - a: Array = param(description="array") + f: float = param(description="float") + s: str = param(description="str", optional=False) + p: Path = param(description="path", optional=False) + a: NDArray[np.int_] = param(description="array") r: Record = param( description="record", optional=False, @@ -40,104 +45,50 @@ class Block: # Keystrings are sum types: discriminated unions of records. -@record -class All: - all: bool = param( - description="keyword to indicate save for all time steps in period." - ) - - -@record -class First: - first: bool = param( - description="keyword to indicate save for first step in period." - ) - - -@record -class Last: - last: bool = param( - description="keyword to indicate save for last step in period." - ) - - -@record -class Frequency: - frequency: int = param( - description="save at the specified time step frequency." - ) - - -@record -class Steps: - steps: List[int] = param(description="save for each step specified.") - - -OCSetting = Union[All, First, Last, Frequency, Steps] - - -@context(multi=True) -class Period: - ocsetting: OCSetting = param( - description="keystring", - optional=False, - ) - - def test_spec(): spec = params(Record) - assert len(spec) == 3 - assert not Record.variadic - - spec = params(VariadicRecord) - assert len(spec) == 2 - assert VariadicRecord.variadic + assert len(spec) == 4 + assert isinstance(spec, Dict) + assert is_frozen(Record) spec = params(Block) - print(spec) - assert len(spec) == 7 + assert isinstance(spec, Dict) + assert not is_frozen(Block) - k = spec["k"] - assert k.type is bool - assert k.metadata["description"] == "keyword" + b = spec["b"] + assert b.type is bool + assert b.metadata["description"] == "bool" i = spec["i"] assert i.type is int assert i.metadata["description"] == "int" - d = spec["d"] - assert d.type is float - assert d.metadata["description"] == "double" + f = spec["f"] + assert f.type is float + assert f.metadata["description"] == "float" s = spec["s"] assert s.type is str - assert s.metadata["description"] == "string" + assert s.metadata["description"] == "str" - f = spec["f"] - assert f.type is Path - assert f.metadata["description"] == "filename" + p = spec["p"] + assert p.type is Path + assert p.metadata["description"] == "path" a = spec["a"] - assert a.type is Array + assert a.type == NDArray[np.int_] assert a.metadata["description"] == "array" r = spec["r"] assert r.type is Record assert r.metadata["description"] == "record" - spec = params(Period) - assert len(spec) == 2 - - index = spec["index"] - assert index.type is int - - ocsetting = spec["ocsetting"] - assert ocsetting.type is OCSetting - def test_usage(): - r = Record(rk=True, ri=42, rd=math.pi) - assert r.ri == 42 + r = Record(rb=True, ri=42, rf=math.pi) + assert astuple(r) == (True, 42, math.pi, None) + assert asdict(r) == {"rb": True, "ri": 42, "rf": math.pi, "rs": None} with pytest.raises(TypeError): - Record(rk=None) + # non-optional members are required + Record(rb=True) diff --git a/test/test_gwfoc.py b/test/test_gwfoc.py new file mode 100644 index 0000000..b74d1b7 --- /dev/null +++ b/test/test_gwfoc.py @@ -0,0 +1,195 @@ +from pathlib import Path +from typing import Dict, List, Literal, Optional, Union + +from cattrs import unstructure + +from flopy4.attrs import context, is_frozen, param, params, to_path + +# Define the package input specification. +# Some of this will be generic, and come +# from elsewhere, eventually. + +ArrayFormat = Literal["exponential", "fixed", "general", "scientific"] + + +@context +class PrintFormat: + columns: int = param( + description=""" +number of columns for writing data""" + ) + width: int = param( + description=""" +width for writing each number""" + ) + digits: int = param( + description=""" +number of digits to use for writing a number""" + ) + array_format: ArrayFormat = param( + description=""" +write format can be EXPONENTIAL, FIXED, GENERAL, or SCIENTIFIC""" + ) + + +@context +class All: + all: bool = param( + description=""" +keyword to indicate save for all time steps in period.""" + ) + + +@context +class First: + first: bool = param( + description=""" +keyword to indicate save for first step in period.""" + ) + + +@context +class Last: + last: bool = param( + description=""" +keyword to indicate save for last step in period""" + ) + + +@context +class Steps: + steps: List[int] = param( + description=""" +save for each step specified.""" + ) + + +@context +class Frequency: + frequency: int = param( + description=""" +save at the specified time step frequency.""" + ) + + +# It's awkward to have single-parameter contexts, but +# it's the only way I got `cattrs` to distinguish the +# choices in the union. + + +StepSelection = Union[All, First, Last, Steps, Frequency] +OutputAction = Literal["print", "save"] +OutputVariable = Literal["budget", "head"] + + +@context +class OutputControlData: + action: OutputAction = param() + variable: OutputVariable = param() + ocsetting: StepSelection = param() + + +@context +class Options: + budget_file: Optional[Path] = param( + description=""" +name of the output file to write budget information""", + converter=to_path, + default=None, + ) + budget_csv_file: Optional[Path] = param( + description=""" +name of the comma-separated value (CSV) output +file to write budget summary information. +A budget summary record will be written to this +file for each time step of the simulation.""", + converter=to_path, + default=None, + ) + head_file: Optional[Path] = param( + description=""" +name of the output file to write head information.""", + converter=to_path, + default=None, + ) + print_format: Optional[PrintFormat] = param( + description=""" +specify format for printing to the listing file""", + default=None, + ) + + +Period = List[OutputControlData] +Periods = List[Period] + + +@context +class GwfOc: + options: Options = param( + description=""" +options block""" + ) + periods: Periods = param( + description=""" +period blocks""" + ) + + +# Tests + + +def test_spec(): + spec = params(OutputControlData) + assert len(spec) == 3 + assert isinstance(spec, Dict) + assert not is_frozen(OutputControlData) + + ocsetting = spec["ocsetting"] + assert ocsetting.type is StepSelection + + +def test_options(): + options = Options( + budget_file="some/file/path.cbc", + ) + assert isinstance(options.budget_file, Path) + assert len(unstructure(options)) == 4 + + +def test_gwfoc_structure(): + gwfoc = GwfOc.from_dict( + { + "options": { + "budget_file": "some/file/path.cbc", + "head_file": "some/file/path.hds", + "print_format": { + "columns": 1, + "width": 10, + "digits": 8, + "array_format": "scientific", + }, + }, + "periods": [ + [ + { + "action": "print", + "variable": "budget", + "ocsetting": {"steps": [1, 3, 5]}, + }, + { + "action": "save", + "variable": "head", + "ocsetting": {"frequency": 2}, + }, + ] + ], + } + ) + assert gwfoc.options.budget_file == Path("some/file/path.cbc") + assert gwfoc.options.print_format.width == 10 + assert gwfoc.options.print_format.array_format == "scientific" + period = gwfoc.periods[0] + assert len(period) == 2 + assert period[0] == OutputControlData( + action="print", variable="budget", ocsetting=Steps([1, 3, 5]) + )