Skip to content

Commit

Permalink
Decouple process class from Model (#63)
Browse files Browse the repository at this point in the history
* embed modified "dataclass" in __xsimlab_cls__ attribute

* fix all existing tests
  • Loading branch information
benbovy committed Sep 30, 2019
1 parent 5d2f66b commit 7f707ec
Show file tree
Hide file tree
Showing 11 changed files with 228 additions and 120 deletions.
2 changes: 2 additions & 0 deletions doc/index.rst
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ Documentation index
* :doc:`create_model`
* :doc:`inspect_model`
* :doc:`run_model`
* :doc:`testing`

.. toctree::
:maxdepth: 1
Expand All @@ -45,6 +46,7 @@ Documentation index
create_model
inspect_model
run_model
testing

**Help & Reference**

Expand Down
36 changes: 36 additions & 0 deletions doc/testing.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
.. _testing:

Testing
=======

Testing and/or debugging the logic implemented in process classes can
be achieved easily just by instantiating them. The xarray-simlab
framework is not invasive and process classes can be used like other,
regular Python classes.

.. ipython:: python
:suppress:
import sys
sys.path.append('scripts')
from advection_model import InitUGauss
Here is an example with one of the process classes created in section
:doc:`create_model`:

.. ipython:: python
import numpy as np
import matplotlib.pyplot as plt
gauss = InitUGauss(loc=0.3, scale=0.1, x=np.arange(0, 1.5, 0.01))
gauss.initialize()
@savefig gauss.png width=50%
plt.plot(gauss.x, gauss.u);
Like for any other process class, the parameters of
``InitUGauss.__init__`` correspond to each of the variables declared
in that class with either ``intent='in'`` or ``intent='inout'``. Those
parameters are "keyword only" (see `PEP 3102`_), i.e., it is not
possible to set these as positional arguments.

.. _`PEP 3102`: https://www.python.org/dev/peps/pep-3102/
3 changes: 3 additions & 0 deletions doc/whats_new.rst
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ Enhancements
to the (runtime) methods defined in process classes (:issue:`59`).
- Better documentation with a minimal, yet illustrative example based
on Game of Life (:issue:`61`).
- A class decorated with ``process`` can now be instantiated
independently of any Model object. This is very useful for testing
and debugging (:issue:`63`).

Bug fixes
~~~~~~~~~
Expand Down
15 changes: 4 additions & 11 deletions xsimlab/model.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
from inspect import isclass

from .variable import VarIntent, VarType
from .process import (ensure_process_decorated, filter_variables,
from .process import (filter_variables, get_process_cls,
get_target_variable, SimulationStage)
from .utils import AttrMapping, ContextMixin, has_method, variables_dict
from .formatting import repr_model
Expand Down Expand Up @@ -43,7 +43,7 @@ def __init__(self, processes_cls):
self._processes_cls = processes_cls
self._processes_obj = {k: cls() for k, cls in processes_cls.items()}

self._reverse_lookup = self._get_reverse_lookup(processes_cls)
self._reverse_lookup = self._get_reverse_lookup(self._processes_cls)

self._input_vars = None

Expand Down Expand Up @@ -391,20 +391,13 @@ def __init__(self, processes):
Raises
------
:exc:`TypeError`
If values in ``processes`` are not classes.
:exc:`NoteAProcessClassError`
If values in ``processes`` are not classes decorated with
:func:`process`.
"""
for cls in processes.values():
if not isclass(cls):
raise TypeError("Dictionary values must be classes, "
"found {}".format(cls))
ensure_process_decorated(cls)

builder = _ModelBuilder(processes)
builder = _ModelBuilder({k: get_process_cls(v)
for k, v in processes.items()})

builder.bind_processes(self)
builder.set_process_keys()
Expand Down
168 changes: 93 additions & 75 deletions xsimlab/process.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,17 @@ class NotAProcessClassError(ValueError):
pass


def ensure_process_decorated(cls):
if not getattr(cls, "__xsimlab_process__", False):
raise NotAProcessClassError("{cls!r} is not a "
"process-decorated class.".format(cls=cls))
def _get_embedded_process_cls(cls):
if getattr(cls, "__xsimlab_process__", False):
return cls

else:
try:
return cls.__xsimlab_cls__
except AttributeError:
raise NotAProcessClassError("{cls!r} is not a "
"process-decorated class."
.format(cls=cls))


def get_process_cls(obj_or_cls):
Expand All @@ -32,22 +39,16 @@ def get_process_cls(obj_or_cls):
else:
cls = obj_or_cls

ensure_process_decorated(cls)

return cls
return _get_embedded_process_cls(cls)


def get_process_obj(obj_or_cls):
if inspect.isclass(obj_or_cls):
cls = obj_or_cls
obj = cls()
else:
cls = type(obj_or_cls)
obj = obj_or_cls

ensure_process_decorated(cls)

return obj
return _get_embedded_process_cls(cls)()


def filter_variables(process, var_type=None, intent=None, group=None,
Expand Down Expand Up @@ -137,46 +138,6 @@ def get_target_variable(var):
return target_process_cls, target_var


def _attrify_class(cls):
"""Return a `cls` after having passed through :func:`attr.attrs`.
This pulls out and converts `attr.ib` declared as class attributes
into :class:`attr.Attribute` objects and it also adds
dunder-methods such as `__init__`.
The following instance attributes are also defined with None or
empty values (proper values will be set later at model creation):
__xsimlab_model__ : obj
:class:`Model` instance to which the process instance is attached.
__xsimlab_name__ : str
Name given for this process in the model.
__xsimlab_store__ : dict or object
Simulation data store.
__xsimlab_store_keys__ : dict
Dictionary that maps variable names to their corresponding key
(or list of keys for group variables) in the store.
Such keys consist of pairs like `('foo', 'bar')` where
'foo' is the name of any process in the same model and 'bar' is
the name of a variable declared in that process.
__xsimlab_od_keys__ : dict
Dictionary that maps variable names to the location of their target
on-demand variable (or a list of locations for group variables).
Locations are tuples like store keys.
"""
def init_process(self):
self.__xsimlab_model__ = None
self.__xsimlab_name__ = None
self.__xsimlab_store__ = None
self.__xsimlab_store_keys__ = {}
self.__xsimlab_od_keys__ = {}

setattr(cls, '__attrs_post_init__', init_process)

return attr.attrs(cls)


def _make_property_variable(var):
"""Create a property for a variable or a foreign variable (after
some sanity checks).
Expand Down Expand Up @@ -400,11 +361,42 @@ def execute(self, obj, stage, runtime_context):
return executor.execute(obj, runtime_context)


def _process_cls_init(obj):
"""Set the following instance attributes with None or empty values
(proper values will be set later at model creation):
__xsimlab_model__ : obj
:class:`Model` instance to which the process instance is attached.
__xsimlab_name__ : str
Name given for this process in the model.
__xsimlab_store__ : dict or object
Simulation data store.
__xsimlab_store_keys__ : dict
Dictionary that maps variable names to their corresponding key
(or list of keys for group variables) in the store.
Such keys consist of pairs like `('foo', 'bar')` where
'foo' is the name of any process in the same model and 'bar' is
the name of a variable declared in that process.
__xsimlab_od_keys__ : dict
Dictionary that maps variable names to the location of their target
on-demand variable (or a list of locations for group variables).
Locations are tuples like store keys.
"""
obj.__xsimlab_model__ = None
obj.__xsimlab_name__ = None
obj.__xsimlab_store__ = None
obj.__xsimlab_store_keys__ = {}
obj.__xsimlab_od_keys__ = {}


class _ProcessBuilder:
"""Used to iteratively create a new process class.
"""Used to iteratively create a new process class from an existing
"dataclass", i.e., a class decorated with ``attr.attrs``.
The original class must be already "attr-yfied", i.e., it must
correspond to a class returned by `attr.attrs`.
The process class is a direct child of the given dataclass, with
attributes (fields) redefined and properties created so that it
can be used within a model.
"""
_make_prop_funcs = {
Expand All @@ -415,32 +407,59 @@ class _ProcessBuilder:
}

def __init__(self, attr_cls):
self._cls = attr_cls
self._cls.__xsimlab_process__ = True
self._cls.__xsimlab_executor__ = _ProcessExecutor(self._cls)
self._cls_dict = {}
self._base_cls = attr_cls
self._p_cls_dict = {}

def add_properties(self, var_type):
make_prop_func = self._make_prop_funcs[var_type]
def _reset_attributes(self):
new_attributes = OrderedDict()

for var_name, var in filter_variables(self._cls, var_type).items():
self._cls_dict[var_name] = make_prop_func(var)
for k, attrib in attr.fields_dict(self._base_cls).items():
new_attributes[k] = attr.attrib(
metadata=attrib.metadata,
validator=attrib.validator,
default=attr.NOTHING,
init=False,
cmp=False,
repr=False
)

def add_repr(self):
self._cls_dict['__repr__'] = repr_process
return new_attributes

def _make_process_subclass(self):
p_cls = attr.make_class(self._base_cls.__name__,
self._reset_attributes(),
bases=(self._base_cls,),
init=False,
repr=False)

setattr(p_cls, '__init__', _process_cls_init)
setattr(p_cls, '__repr__', repr_process)
setattr(p_cls, '__xsimlab_process__', True)
setattr(p_cls, '__xsimlab_executor__', _ProcessExecutor(p_cls))

return p_cls

def add_properties(self):
for var_name, var in attr.fields_dict(self._base_cls).items():
var_type = var.metadata.get('var_type')

if var_type is not None:
make_prop_func = self._make_prop_funcs[var_type]

self._p_cls_dict[var_name] = make_prop_func(var)

def render_docstrings(self):
# self._cls_dict['__doc__'] = "Process-ified class."
# self._p_cls_dict['__doc__'] = "Process-ified class."
raise NotImplementedError("autodoc is not yet implemented.")

def build_class(self):
cls = self._cls
p_cls = self._make_process_subclass()

# Attach properties (and docstrings)
for name, value in self._cls_dict.items():
setattr(cls, name, value)
for name, value in self._p_cls_dict.items():
setattr(p_cls, name, value)

return cls
return p_cls


def process(maybe_cls=None, autodoc=False):
Expand Down Expand Up @@ -475,19 +494,18 @@ def process(maybe_cls=None, autodoc=False):
"""
def wrap(cls):
attr_cls = _attrify_class(cls)
attr_cls = attr.attrs(cls)

builder = _ProcessBuilder(attr_cls)

for var_type in VarType:
builder.add_properties(var_type)
builder.add_properties()

if autodoc:
builder.render_docstrings()

builder.add_repr()
setattr(attr_cls, '__xsimlab_cls__', builder.build_class())

return builder.build_class()
return attr_cls

if maybe_cls is None:
return wrap
Expand Down
5 changes: 3 additions & 2 deletions xsimlab/tests/fixture_process.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import pytest

import xsimlab as xs
from xsimlab.process import get_process_obj


@xs.process
Expand Down Expand Up @@ -49,7 +50,7 @@ def compute_od_var(self):

@pytest.fixture
def example_process_obj():
return ExampleProcess()
return get_process_obj(ExampleProcess)


@pytest.fixture(scope='session')
Expand Down Expand Up @@ -85,7 +86,7 @@ def in_var_details():


def _init_process(p_cls, p_name, model, store, store_keys=None, od_keys=None):
p_obj = p_cls()
p_obj = get_process_obj(p_cls)
p_obj.__xsimlab_name__ = p_name
p_obj.__xsimlab_model__ = model
p_obj.__xsimlab_store__ = store
Expand Down
3 changes: 2 additions & 1 deletion xsimlab/tests/test_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
from xsimlab.formatting import (maybe_truncate, pretty_print,
repr_process, repr_model,
var_details, wrap_indent)
from xsimlab.process import get_process_obj


def test_maybe_truncate():
Expand Down Expand Up @@ -60,7 +61,7 @@ def run_step(self):
run_step
""")

assert repr_process(Dummy()) == expected
assert repr_process(get_process_obj(Dummy)) == expected


def test_model_repr(simple_model, simple_model_repr):
Expand Down

0 comments on commit 7f707ec

Please sign in to comment.