Skip to content

Commit 7f707ec

Browse files
authored
Decouple process class from Model (#63)
* embed modified "dataclass" in __xsimlab_cls__ attribute * fix all existing tests
1 parent 5d2f66b commit 7f707ec

11 files changed

+228
-120
lines changed

doc/index.rst

+2
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ Documentation index
3535
* :doc:`create_model`
3636
* :doc:`inspect_model`
3737
* :doc:`run_model`
38+
* :doc:`testing`
3839

3940
.. toctree::
4041
:maxdepth: 1
@@ -45,6 +46,7 @@ Documentation index
4546
create_model
4647
inspect_model
4748
run_model
49+
testing
4850

4951
**Help & Reference**
5052

doc/testing.rst

+36
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
.. _testing:
2+
3+
Testing
4+
=======
5+
6+
Testing and/or debugging the logic implemented in process classes can
7+
be achieved easily just by instantiating them. The xarray-simlab
8+
framework is not invasive and process classes can be used like other,
9+
regular Python classes.
10+
11+
.. ipython:: python
12+
:suppress:
13+
14+
import sys
15+
sys.path.append('scripts')
16+
from advection_model import InitUGauss
17+
18+
Here is an example with one of the process classes created in section
19+
:doc:`create_model`:
20+
21+
.. ipython:: python
22+
23+
import numpy as np
24+
import matplotlib.pyplot as plt
25+
gauss = InitUGauss(loc=0.3, scale=0.1, x=np.arange(0, 1.5, 0.01))
26+
gauss.initialize()
27+
@savefig gauss.png width=50%
28+
plt.plot(gauss.x, gauss.u);
29+
30+
Like for any other process class, the parameters of
31+
``InitUGauss.__init__`` correspond to each of the variables declared
32+
in that class with either ``intent='in'`` or ``intent='inout'``. Those
33+
parameters are "keyword only" (see `PEP 3102`_), i.e., it is not
34+
possible to set these as positional arguments.
35+
36+
.. _`PEP 3102`: https://www.python.org/dev/peps/pep-3102/

doc/whats_new.rst

+3
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,9 @@ Enhancements
3737
to the (runtime) methods defined in process classes (:issue:`59`).
3838
- Better documentation with a minimal, yet illustrative example based
3939
on Game of Life (:issue:`61`).
40+
- A class decorated with ``process`` can now be instantiated
41+
independently of any Model object. This is very useful for testing
42+
and debugging (:issue:`63`).
4043

4144
Bug fixes
4245
~~~~~~~~~

xsimlab/model.py

+4-11
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
from inspect import isclass
33

44
from .variable import VarIntent, VarType
5-
from .process import (ensure_process_decorated, filter_variables,
5+
from .process import (filter_variables, get_process_cls,
66
get_target_variable, SimulationStage)
77
from .utils import AttrMapping, ContextMixin, has_method, variables_dict
88
from .formatting import repr_model
@@ -43,7 +43,7 @@ def __init__(self, processes_cls):
4343
self._processes_cls = processes_cls
4444
self._processes_obj = {k: cls() for k, cls in processes_cls.items()}
4545

46-
self._reverse_lookup = self._get_reverse_lookup(processes_cls)
46+
self._reverse_lookup = self._get_reverse_lookup(self._processes_cls)
4747

4848
self._input_vars = None
4949

@@ -391,20 +391,13 @@ def __init__(self, processes):
391391
392392
Raises
393393
------
394-
:exc:`TypeError`
395-
If values in ``processes`` are not classes.
396394
:exc:`NoteAProcessClassError`
397395
If values in ``processes`` are not classes decorated with
398396
:func:`process`.
399397
400398
"""
401-
for cls in processes.values():
402-
if not isclass(cls):
403-
raise TypeError("Dictionary values must be classes, "
404-
"found {}".format(cls))
405-
ensure_process_decorated(cls)
406-
407-
builder = _ModelBuilder(processes)
399+
builder = _ModelBuilder({k: get_process_cls(v)
400+
for k, v in processes.items()})
408401

409402
builder.bind_processes(self)
410403
builder.set_process_keys()

xsimlab/process.py

+93-75
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,17 @@ class NotAProcessClassError(ValueError):
2020
pass
2121

2222

23-
def ensure_process_decorated(cls):
24-
if not getattr(cls, "__xsimlab_process__", False):
25-
raise NotAProcessClassError("{cls!r} is not a "
26-
"process-decorated class.".format(cls=cls))
23+
def _get_embedded_process_cls(cls):
24+
if getattr(cls, "__xsimlab_process__", False):
25+
return cls
26+
27+
else:
28+
try:
29+
return cls.__xsimlab_cls__
30+
except AttributeError:
31+
raise NotAProcessClassError("{cls!r} is not a "
32+
"process-decorated class."
33+
.format(cls=cls))
2734

2835

2936
def get_process_cls(obj_or_cls):
@@ -32,22 +39,16 @@ def get_process_cls(obj_or_cls):
3239
else:
3340
cls = obj_or_cls
3441

35-
ensure_process_decorated(cls)
36-
37-
return cls
42+
return _get_embedded_process_cls(cls)
3843

3944

4045
def get_process_obj(obj_or_cls):
4146
if inspect.isclass(obj_or_cls):
4247
cls = obj_or_cls
43-
obj = cls()
4448
else:
4549
cls = type(obj_or_cls)
46-
obj = obj_or_cls
4750

48-
ensure_process_decorated(cls)
49-
50-
return obj
51+
return _get_embedded_process_cls(cls)()
5152

5253

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

139140

140-
def _attrify_class(cls):
141-
"""Return a `cls` after having passed through :func:`attr.attrs`.
142-
143-
This pulls out and converts `attr.ib` declared as class attributes
144-
into :class:`attr.Attribute` objects and it also adds
145-
dunder-methods such as `__init__`.
146-
147-
The following instance attributes are also defined with None or
148-
empty values (proper values will be set later at model creation):
149-
150-
__xsimlab_model__ : obj
151-
:class:`Model` instance to which the process instance is attached.
152-
__xsimlab_name__ : str
153-
Name given for this process in the model.
154-
__xsimlab_store__ : dict or object
155-
Simulation data store.
156-
__xsimlab_store_keys__ : dict
157-
Dictionary that maps variable names to their corresponding key
158-
(or list of keys for group variables) in the store.
159-
Such keys consist of pairs like `('foo', 'bar')` where
160-
'foo' is the name of any process in the same model and 'bar' is
161-
the name of a variable declared in that process.
162-
__xsimlab_od_keys__ : dict
163-
Dictionary that maps variable names to the location of their target
164-
on-demand variable (or a list of locations for group variables).
165-
Locations are tuples like store keys.
166-
167-
"""
168-
def init_process(self):
169-
self.__xsimlab_model__ = None
170-
self.__xsimlab_name__ = None
171-
self.__xsimlab_store__ = None
172-
self.__xsimlab_store_keys__ = {}
173-
self.__xsimlab_od_keys__ = {}
174-
175-
setattr(cls, '__attrs_post_init__', init_process)
176-
177-
return attr.attrs(cls)
178-
179-
180141
def _make_property_variable(var):
181142
"""Create a property for a variable or a foreign variable (after
182143
some sanity checks).
@@ -400,11 +361,42 @@ def execute(self, obj, stage, runtime_context):
400361
return executor.execute(obj, runtime_context)
401362

402363

364+
def _process_cls_init(obj):
365+
"""Set the following instance attributes with None or empty values
366+
(proper values will be set later at model creation):
367+
368+
__xsimlab_model__ : obj
369+
:class:`Model` instance to which the process instance is attached.
370+
__xsimlab_name__ : str
371+
Name given for this process in the model.
372+
__xsimlab_store__ : dict or object
373+
Simulation data store.
374+
__xsimlab_store_keys__ : dict
375+
Dictionary that maps variable names to their corresponding key
376+
(or list of keys for group variables) in the store.
377+
Such keys consist of pairs like `('foo', 'bar')` where
378+
'foo' is the name of any process in the same model and 'bar' is
379+
the name of a variable declared in that process.
380+
__xsimlab_od_keys__ : dict
381+
Dictionary that maps variable names to the location of their target
382+
on-demand variable (or a list of locations for group variables).
383+
Locations are tuples like store keys.
384+
385+
"""
386+
obj.__xsimlab_model__ = None
387+
obj.__xsimlab_name__ = None
388+
obj.__xsimlab_store__ = None
389+
obj.__xsimlab_store_keys__ = {}
390+
obj.__xsimlab_od_keys__ = {}
391+
392+
403393
class _ProcessBuilder:
404-
"""Used to iteratively create a new process class.
394+
"""Used to iteratively create a new process class from an existing
395+
"dataclass", i.e., a class decorated with ``attr.attrs``.
405396
406-
The original class must be already "attr-yfied", i.e., it must
407-
correspond to a class returned by `attr.attrs`.
397+
The process class is a direct child of the given dataclass, with
398+
attributes (fields) redefined and properties created so that it
399+
can be used within a model.
408400
409401
"""
410402
_make_prop_funcs = {
@@ -415,32 +407,59 @@ class _ProcessBuilder:
415407
}
416408

417409
def __init__(self, attr_cls):
418-
self._cls = attr_cls
419-
self._cls.__xsimlab_process__ = True
420-
self._cls.__xsimlab_executor__ = _ProcessExecutor(self._cls)
421-
self._cls_dict = {}
410+
self._base_cls = attr_cls
411+
self._p_cls_dict = {}
422412

423-
def add_properties(self, var_type):
424-
make_prop_func = self._make_prop_funcs[var_type]
413+
def _reset_attributes(self):
414+
new_attributes = OrderedDict()
425415

426-
for var_name, var in filter_variables(self._cls, var_type).items():
427-
self._cls_dict[var_name] = make_prop_func(var)
416+
for k, attrib in attr.fields_dict(self._base_cls).items():
417+
new_attributes[k] = attr.attrib(
418+
metadata=attrib.metadata,
419+
validator=attrib.validator,
420+
default=attr.NOTHING,
421+
init=False,
422+
cmp=False,
423+
repr=False
424+
)
428425

429-
def add_repr(self):
430-
self._cls_dict['__repr__'] = repr_process
426+
return new_attributes
427+
428+
def _make_process_subclass(self):
429+
p_cls = attr.make_class(self._base_cls.__name__,
430+
self._reset_attributes(),
431+
bases=(self._base_cls,),
432+
init=False,
433+
repr=False)
434+
435+
setattr(p_cls, '__init__', _process_cls_init)
436+
setattr(p_cls, '__repr__', repr_process)
437+
setattr(p_cls, '__xsimlab_process__', True)
438+
setattr(p_cls, '__xsimlab_executor__', _ProcessExecutor(p_cls))
439+
440+
return p_cls
441+
442+
def add_properties(self):
443+
for var_name, var in attr.fields_dict(self._base_cls).items():
444+
var_type = var.metadata.get('var_type')
445+
446+
if var_type is not None:
447+
make_prop_func = self._make_prop_funcs[var_type]
448+
449+
self._p_cls_dict[var_name] = make_prop_func(var)
431450

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

436455
def build_class(self):
437-
cls = self._cls
456+
p_cls = self._make_process_subclass()
438457

439458
# Attach properties (and docstrings)
440-
for name, value in self._cls_dict.items():
441-
setattr(cls, name, value)
459+
for name, value in self._p_cls_dict.items():
460+
setattr(p_cls, name, value)
442461

443-
return cls
462+
return p_cls
444463

445464

446465
def process(maybe_cls=None, autodoc=False):
@@ -475,19 +494,18 @@ def process(maybe_cls=None, autodoc=False):
475494
476495
"""
477496
def wrap(cls):
478-
attr_cls = _attrify_class(cls)
497+
attr_cls = attr.attrs(cls)
479498

480499
builder = _ProcessBuilder(attr_cls)
481500

482-
for var_type in VarType:
483-
builder.add_properties(var_type)
501+
builder.add_properties()
484502

485503
if autodoc:
486504
builder.render_docstrings()
487505

488-
builder.add_repr()
506+
setattr(attr_cls, '__xsimlab_cls__', builder.build_class())
489507

490-
return builder.build_class()
508+
return attr_cls
491509

492510
if maybe_cls is None:
493511
return wrap

xsimlab/tests/fixture_process.py

+3-2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import pytest
55

66
import xsimlab as xs
7+
from xsimlab.process import get_process_obj
78

89

910
@xs.process
@@ -49,7 +50,7 @@ def compute_od_var(self):
4950

5051
@pytest.fixture
5152
def example_process_obj():
52-
return ExampleProcess()
53+
return get_process_obj(ExampleProcess)
5354

5455

5556
@pytest.fixture(scope='session')
@@ -85,7 +86,7 @@ def in_var_details():
8586

8687

8788
def _init_process(p_cls, p_name, model, store, store_keys=None, od_keys=None):
88-
p_obj = p_cls()
89+
p_obj = get_process_obj(p_cls)
8990
p_obj.__xsimlab_name__ = p_name
9091
p_obj.__xsimlab_model__ = model
9192
p_obj.__xsimlab_store__ = store

xsimlab/tests/test_formatting.py

+2-1
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
from xsimlab.formatting import (maybe_truncate, pretty_print,
55
repr_process, repr_model,
66
var_details, wrap_indent)
7+
from xsimlab.process import get_process_obj
78

89

910
def test_maybe_truncate():
@@ -60,7 +61,7 @@ def run_step(self):
6061
run_step
6162
""")
6263

63-
assert repr_process(Dummy()) == expected
64+
assert repr_process(get_process_obj(Dummy)) == expected
6465

6566

6667
def test_model_repr(simple_model, simple_model_repr):

0 commit comments

Comments
 (0)