Skip to content

Decouple process class from Model #63

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 8 commits into from
Sep 30, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading