diff --git a/flopy4/attrs.py b/flopy4/attrs.py index 43b2e99..1dffb1b 100644 --- a/flopy4/attrs.py +++ b/flopy4/attrs.py @@ -1,17 +1,20 @@ from pathlib import Path from typing import ( + Any, Optional, TypeVar, Union, ) import attr -from attrs import NOTHING, define, field, fields -from cattrs import structure, unstructure +from attrs import NOTHING, asdict, define, field, fields +from cattrs import structure from numpy.typing import ArrayLike from pandas import DataFrame # Enumerate the primitive types to support. +# This is just for reference, not meant to +# be definitive, exclusive, or exhaustive. Scalar = Union[bool, int, float, str, Path] """A scalar input parameter.""" @@ -109,7 +112,7 @@ def from_dict(cls, d: dict): def to_dict(self): """Convert the context to a dictionary.""" - return unstructure(self) + return asdict(self, recurse=True) def wrap(cls): setattr(cls, "from_dict", classmethod(from_dict)) @@ -128,23 +131,6 @@ def wrap(cls): return wrap(maybe_cls) -def choice( - maybe_cls: Optional[type[T]] = None, - *, - frozen: bool = False, -): - def wrap(cls): - return context( - cls, - frozen=frozen, - ) - - if maybe_cls is None: - return wrap - - return wrap(maybe_cls) - - # Utilities @@ -170,10 +156,11 @@ def is_frozen(cls: type) -> bool: return cls.__setattr__ == attr._make._frozen_setattrs -def to_path(val) -> Optional[Path]: - if val is None: +def to_path(value: Any) -> Optional[Path]: + """Try to convert the value to a `Path`.""" + if value is None: return None try: - return Path(val).expanduser() + return Path(value).expanduser() except: - raise ValueError(f"Cannot convert value to Path: {val}") + raise ValueError(f"Can't convert value to Path: {value}") diff --git a/flopy4/converter.py b/flopy4/converter.py new file mode 100644 index 0000000..e69de29 diff --git a/flopy4/lark/__init__.py b/flopy4/lark/__init__.py new file mode 100644 index 0000000..8f81a2c --- /dev/null +++ b/flopy4/lark/__init__.py @@ -0,0 +1,68 @@ +from lark import Lark + +MF6_GRAMMAR = r""" +?start: _NL* _item* +_item: (block | COMMENT) _NL+ + +// block +block: _begin _NL params _end +_begin: _BEGIN name [index] +_end: _END name +name: WORD +index: INT +_BEGIN: "begin"i +_END: "end"i + +// parameter +params: (param _NL)* +param: _key [_value] +_key: KEYS +_value: NUMBER | path | string | array | list + +// string +string: WORD+ + +// file path +path: INOUT PATH +PATH: [_PATHSEP] (NON_SEPARATOR_STRING [_PATHSEP]) [NON_SEPARATOR_STRING] +_PATHSEP: "/" +INOUT: "filein"i|"fileout"i + +// array +array: constantarray | internalarray | externalarray +constantarray: "CONSTANT" NUMBER +internalarray: "INTERNAL" [factor] [iprn] (NUMBER* [_NL])* +externalarray: "OPEN/CLOSE" WORD [factor] ["binary"] [iprn] +factor: "FACTOR" NUMBER +iprn: "IPRN" INT + +// list (adapted from https://github.com/lark-parser/lark/blob/master/examples/composition/csv.lark) +list: header _NL row* +header: "#" " "? (WORD _SEPARATOR?)+ +row: (_anything _SEPARATOR?)+ _NL +_anything: INT | WORD | NON_SEPARATOR_STRING | FLOAT | SIGNED_FLOAT +NON_SEPARATOR_STRING: /[a-zA-z.;\\\/]+/ +_SEPARATOR: /[ ]+/ + | "\t" + | "," + +// newline +_NL: /(\r?\n[\t ]*)+/ + +// parameter keys file can be generated +// with the rest of the plugin interface +// and maybe placed in a separate file +KEYS: "K"|"I"|"D"|"S"|"F"|"A" + +%import common.SH_COMMENT -> COMMENT +%import common.SIGNED_NUMBER -> NUMBER +%import common.SIGNED_FLOAT +%import common.INT +%import common.FLOAT +%import common.WORD +%import common.WS_INLINE + +%ignore WS_INLINE +""" + +MF6_PARSER = Lark(MF6_GRAMMAR, start="start") diff --git a/flopy4/param.py b/flopy4/param.py index 64a1dcb..23bfba7 100644 --- a/flopy4/param.py +++ b/flopy4/param.py @@ -29,8 +29,6 @@ class MFParamSpec: repeating: bool = False tagged: bool = True reader: MFReader = MFReader.urword - # todo change to variadic tuple of str and resolve - # actual shape at load time from simulation context shape: Optional[Tuple[int]] = None default_value: Optional[Any] = None diff --git a/pyproject.toml b/pyproject.toml index bfda02d..16c1602 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,10 +36,11 @@ classifiers = [ ] requires-python = ">=3.9" dependencies = [ - "attrs", # todo: lower bound? - "cattrs", # todo: lower bound? - "Jinja2>=3.0", + "attrs", # todo: bounds? + "cattrs", # todo: bounds? "flopy>=3.7.0", + "Jinja2>=3.0", + "lark", # todo: bounds? "numpy>=1.20.3", "pandas>=2.0.0", "toml>=0.10", @@ -54,6 +55,7 @@ test = [ "flopy4[lint]", "coverage", "GitPython", + "interegular", "jupyter", "jupytext", "modflow-devtools", diff --git a/test/test_attrs.py b/test/test_attrs.py index f4349d9..80c8649 100644 --- a/test/test_attrs.py +++ b/test/test_attrs.py @@ -14,9 +14,6 @@ params, ) -# Records are product types: named, ordered tuples of scalars. -# Records are immutable: they can't be changed, only evolved. - @context(frozen=True) class Record: @@ -42,9 +39,6 @@ class Block: ) -# Keystrings are sum types: discriminated unions of records. - - def test_spec(): spec = params(Record) assert len(spec) == 4 @@ -90,5 +84,4 @@ def test_usage(): assert astuple(r) == (True, 42, math.pi, None) assert asdict(r) == {"rb": True, "ri": 42, "rf": math.pi, "rs": None} with pytest.raises(TypeError): - # non-optional members are required Record(rb=True) diff --git a/test/test_gwfoc.py b/test/test_gwfoc.py index b74d1b7..4d86f7c 100644 --- a/test/test_gwfoc.py +++ b/test/test_gwfoc.py @@ -1,14 +1,10 @@ from pathlib import Path from typing import Dict, List, Literal, Optional, Union -from cattrs import unstructure +import pytest 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"] @@ -32,6 +28,36 @@ class PrintFormat: ) +@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, + ) + + @context class All: all: bool = param( @@ -74,7 +100,7 @@ class 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. +# choices in the union. There is likely a better way. StepSelection = Union[All, First, Last, Steps, Frequency] @@ -89,36 +115,6 @@ class OutputControlData: 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] @@ -148,15 +144,36 @@ def test_spec(): assert ocsetting.type is StepSelection -def test_options(): +def test_options_to_dict(): options = Options( budget_file="some/file/path.cbc", ) assert isinstance(options.budget_file, Path) - assert len(unstructure(options)) == 4 + assert len(options.to_dict()) == 4 + + +def test_output_control_data_from_dict(): + # from dict + ocdata = OutputControlData.from_dict( + { + "action": "print", + "variable": "budget", + "ocsetting": {"steps": [1, 3, 5]}, + } + ) + assert ocdata.action == "print" + + +@pytest.mark.xfail(reason="todo") +def test_output_control_data_from_tuple(): + ocdata = OutputControlData.from_tuple( + ("print", "budget", "steps", 1, 3, 5) + ) + assert ocdata.action == "print" + assert ocdata.variable == "budget" -def test_gwfoc_structure(): +def test_gwfoc_from_dict(): gwfoc = GwfOc.from_dict( { "options": { diff --git a/test/test_lark.py b/test/test_lark.py new file mode 100644 index 0000000..2ac5b52 --- /dev/null +++ b/test/test_lark.py @@ -0,0 +1,42 @@ +from pprint import pprint + +import pytest +from lark import Transformer + +from flopy4.lark import MF6_PARSER + +TEST_PKG = """ +BEGIN OPTIONS + K + I 1 + D 1.0 + S hello world + F FILEIN some/path +END OPTIONS + +BEGIN PACKAGEDATA 1 + A INTERNAL 1.0 2.0 3.0 +END PACKAGEDATA +""" + + +def test_parse_mf6(): + tree = MF6_PARSER.parse(TEST_PKG) + # this is working, check it with: + # pytest test/test_lark.py::test_parse_mf6 -s + print(tree.pretty()) + + +class MF6Transformer(Transformer): + # TODO + pass + + +MF6_TRANSFORMER = MF6Transformer() + + +@pytest.mark.xfail +def test_transform_mf6(): + tree = MF6_PARSER.parse(TEST_PKG) + data = MF6_TRANSFORMER.transform(tree) + pprint(data)