Skip to content

Commit

Permalink
Merge pull request #27 from wpbonelli/typing
Browse files Browse the repository at this point in the history
from/to dict via @context, extra utils, tidying
  • Loading branch information
wpbonelli authored Sep 4, 2024
2 parents d086f1b + c4f2246 commit 5d390dc
Show file tree
Hide file tree
Showing 5 changed files with 332 additions and 155 deletions.
26 changes: 12 additions & 14 deletions docs/dev/sdd.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand All @@ -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
Expand All @@ -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
Expand Down
125 changes: 75 additions & 50 deletions flopy4/attrs.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -32,6 +38,7 @@ def param(
deprecated: bool = False,
optional: bool = False,
default=NOTHING,
alias=None,
metadata=None,
validator=None,
converter=None,
Expand All @@ -53,6 +60,7 @@ def param(
order=False,
hash=False,
init=True,
alias=alias,
metadata=metadata,
converter=converter,
)
Expand All @@ -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)}

Expand All @@ -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,
)

Expand All @@ -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}")
8 changes: 8 additions & 0 deletions flopy4/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading

0 comments on commit 5d390dc

Please sign in to comment.