immlib 1.0.0.dev2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- immlib/__init__.py +131 -0
- immlib/_init.py +108 -0
- immlib/_version.py +235 -0
- immlib/doc/__init__.py +38 -0
- immlib/doc/_core.py +311 -0
- immlib/iolib/__init__.py +29 -0
- immlib/iolib/_core.py +720 -0
- immlib/pathlib/__init__.py +69 -0
- immlib/pathlib/_cache.py +152 -0
- immlib/pathlib/_core.py +869 -0
- immlib/pathlib/_osf.py +538 -0
- immlib/test/__init__.py +16 -0
- immlib/test/__main__.py +10 -0
- immlib/test/doc/__init__.py +6 -0
- immlib/test/doc/test_core.py +91 -0
- immlib/test/iolib/__init__.py +7 -0
- immlib/test/iolib/test_core.py +81 -0
- immlib/test/pathlib/__init__.py +11 -0
- immlib/test/pathlib/test_core.py +146 -0
- immlib/test/pathlib/test_osf.py +54 -0
- immlib/test/types/__init__.py +5 -0
- immlib/test/types/test_core.py +110 -0
- immlib/test/util/__init__.py +11 -0
- immlib/test/util/test_core.py +681 -0
- immlib/test/util/test_numeric.py +1374 -0
- immlib/test/util/test_quantity.py +218 -0
- immlib/test/util/test_url.py +51 -0
- immlib/test/workflow/__init__.py +9 -0
- immlib/test/workflow/test_core.py +418 -0
- immlib/test/workflow/test_plantype.py +248 -0
- immlib/types/__init__.py +29 -0
- immlib/types/_core.py +333 -0
- immlib/util/__init__.py +283 -0
- immlib/util/_core.py +2524 -0
- immlib/util/_numeric.py +2651 -0
- immlib/util/_quantity.py +523 -0
- immlib/util/_url.py +114 -0
- immlib/workflow/__init__.py +48 -0
- immlib/workflow/_core.py +1635 -0
- immlib/workflow/_plantype.py +334 -0
- immlib-1.0.0.dev2.dist-info/METADATA +76 -0
- immlib-1.0.0.dev2.dist-info/RECORD +45 -0
- immlib-1.0.0.dev2.dist-info/WHEEL +5 -0
- immlib-1.0.0.dev2.dist-info/licenses/LICENSE +21 -0
- immlib-1.0.0.dev2.dist-info/top_level.txt +1 -0
immlib/workflow/_core.py
ADDED
|
@@ -0,0 +1,1635 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
###############################################################################
|
|
3
|
+
# immlib/workflow/_core.py
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
# Dependencies ################################################################
|
|
7
|
+
|
|
8
|
+
import copy, textwrap
|
|
9
|
+
from collections.abc import (Callable, Mapping)
|
|
10
|
+
from collections import (defaultdict, namedtuple)
|
|
11
|
+
from functools import (reduce, wraps, partial, update_wrapper)
|
|
12
|
+
from inspect import (signature, Parameter)
|
|
13
|
+
from joblib import Memory
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from pcollections import (
|
|
18
|
+
pdict, tdict, ldict, tldict,
|
|
19
|
+
lazy, holdlazy,
|
|
20
|
+
pset, tset,
|
|
21
|
+
plist)
|
|
22
|
+
|
|
23
|
+
from ..doc import (docwrap, make_docproc, reindent, detect_indentation)
|
|
24
|
+
from ..util import (
|
|
25
|
+
is_pdict, is_str, is_number, is_tuple, is_dict, is_ldict,
|
|
26
|
+
is_array, is_integer, strisvar, is_amap, is_pcoll, to_pcoll,
|
|
27
|
+
to_pathcache, to_lrucache, identfn,
|
|
28
|
+
merge, rmerge, valmap)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# calc ########################################################################
|
|
32
|
+
|
|
33
|
+
class calc:
|
|
34
|
+
'''Decorator type that represents a single calculation in a calc-plan.
|
|
35
|
+
|
|
36
|
+
The ``calc`` class encapsulates data regarding the calculation of a single
|
|
37
|
+
set of output values from a separate set of input values: a calculation
|
|
38
|
+
component that can be fit together with other such components to make a
|
|
39
|
+
calculation plan.
|
|
40
|
+
|
|
41
|
+
``@calc`` by itself can be used as a decorator to indicate that the
|
|
42
|
+
function that follows is a calculation component; calculation components
|
|
43
|
+
can be combined to form ``plan`` objects, which can encapsulate a flexible
|
|
44
|
+
workflow of Python computations. When ``@calc`` is used as a decorator by
|
|
45
|
+
itself, then the calc is considered to have a single output value whose
|
|
46
|
+
name is the same as that of the function it decorates.
|
|
47
|
+
|
|
48
|
+
``@calc(names...)`` accepts a string or strings that name the output values
|
|
49
|
+
of the calc function. In this case, the decorated function must return
|
|
50
|
+
either a tuple of thes values in the order they are given or a dictionary
|
|
51
|
+
in which the keys are the same as the given names.
|
|
52
|
+
|
|
53
|
+
``@calc(None)`` is a special instance which indicates that the lazy
|
|
54
|
+
argument is to be ignored (it is forced to be ``False``), no output values
|
|
55
|
+
are to be produced by the function, and the calculation must always run
|
|
56
|
+
when the input parameters are updated.
|
|
57
|
+
|
|
58
|
+
The ``calc`` class parses its inputs and outputs through the
|
|
59
|
+
``immlib.docwrap`` function in order to collect documentation (see the
|
|
60
|
+
``input_docs`` and ``output_docs`` attributes, below). The ``'Inputs'`` and
|
|
61
|
+
``'Outputs'`` sections are tracked as the documentation of the parameters,
|
|
62
|
+
and are required to be formatted using [NumPy's documentation
|
|
63
|
+
style](https://numpydoc.readthedocs.io/en/latest/format.html) in order for
|
|
64
|
+
the parameter documentation to be properly extracted. Users of calculation
|
|
65
|
+
objects should decorate their functions using ``docwrap`` manually
|
|
66
|
+
themselves, however (if desired), because decorating a function with
|
|
67
|
+
``calc`` alone does not cause the function's documentation to be available
|
|
68
|
+
to other functions that use ``@docwrap`` to format their docstrings.
|
|
69
|
+
|
|
70
|
+
Caching for calculations requires some care. First, the ``calc``- and
|
|
71
|
+
``plan``-based workflow system in ``immlib`` is designed to work best with
|
|
72
|
+
``calc`` objects that are pure functions. A function ``f(*args, **kw)`` is
|
|
73
|
+
pure if it has no side-effects and if ``f(*args1, **kw1) == f(*args2,
|
|
74
|
+
**kw2)`` is true whenever ``args1 == args2 and kw1 == kw2``. That is, ``f``
|
|
75
|
+
always produces the same outputs when given the same inputs. Plans that
|
|
76
|
+
contain unpure functions can work fine in many contexts, but unpure
|
|
77
|
+
``calc`` objects will break caching because the return value of a cached
|
|
78
|
+
unpure calculation will always be the same value. (In other words, the
|
|
79
|
+
value that is calculated and cached by the function the first time it is
|
|
80
|
+
called.)
|
|
81
|
+
|
|
82
|
+
Second, the ``calc`` type has an option, ``pathcache``, which can be set to
|
|
83
|
+
an explicit path to which all calculations run by the created ``calc``
|
|
84
|
+
object will be cached and later uncached if re-requested. This is
|
|
85
|
+
occasionally appropriate for a particular compute environment, but a better
|
|
86
|
+
approach is typically to grant control of caching and cache paths to the
|
|
87
|
+
user who creates the ``plandict`` object downstream of the creation of the
|
|
88
|
+
``calc`` objects. To enable this behavior, one should instead use the
|
|
89
|
+
option ``pathcache=True``, which enables caching of calculations to a
|
|
90
|
+
specific cache path when provided by the user during the creation of the
|
|
91
|
+
``plandict`` (the default is ``False``, which disables path caching for the
|
|
92
|
+
calculation).
|
|
93
|
+
|
|
94
|
+
Parameters
|
|
95
|
+
----------
|
|
96
|
+
outputs : strings
|
|
97
|
+
The positional arguments to ``@calc()`` provide the names of the output
|
|
98
|
+
variables. The names must all be valid variable names (see
|
|
99
|
+
``immlib.strisvar``).
|
|
100
|
+
name : None or str, optional
|
|
101
|
+
The name of the function. The default, ``None``, uses ``fn.__name__``.
|
|
102
|
+
lazy : bool, optional
|
|
103
|
+
Whether the calculation unit should be calculated lazily (``True``) or
|
|
104
|
+
eagerly (``False``) when a plandict is created. The default is
|
|
105
|
+
``True``.
|
|
106
|
+
lrucache : int, optional
|
|
107
|
+
The number of recently calculated results to cache. If this value is 0,
|
|
108
|
+
then no memoization is done (the default). If ``lrucache`` is an
|
|
109
|
+
integer greater than 0, then an LRU cache is used with a maximum size
|
|
110
|
+
of `lrucache`. If ``lrucache`` is ``inf``, then all values are cached
|
|
111
|
+
indefinitely. Note that this cache is performed at the level of the
|
|
112
|
+
calculation using Python's ``functools`` caching decorators.
|
|
113
|
+
pathcache : None, bool, or path-like, optional
|
|
114
|
+
If ``pathcache`` is a path-like object (typically a ``pathlib.Path``
|
|
115
|
+
orstring) that references a directory, then the results are cached in
|
|
116
|
+
files in the given directory whenever possible. The ``pathcache``
|
|
117
|
+
option may also a 2-tuple containing a path followed by options to the
|
|
118
|
+
``joblib.Memory`` constructor; see ``immlib.util.to_pathcache`` for
|
|
119
|
+
more information.
|
|
120
|
+
indent : int or None, optional
|
|
121
|
+
The indentation level of the function's docstring. The default is
|
|
122
|
+
``None``, which indicates that the indentation level should be deduced
|
|
123
|
+
from the docstring itself.
|
|
124
|
+
|
|
125
|
+
Attributes
|
|
126
|
+
----------
|
|
127
|
+
name : str
|
|
128
|
+
The name of the calculation function.
|
|
129
|
+
base_function : callable
|
|
130
|
+
The original function, prior to decoration for caching.
|
|
131
|
+
lrucache : None or lrucache-like
|
|
132
|
+
The in-memory cache being used. If this value is ``None``then no
|
|
133
|
+
in-memory cache is being used. If it is an integer, this indicates the
|
|
134
|
+
number of least recently used objects being stored in the
|
|
135
|
+
cache. Otherwise, ``lrucache`` will be a function used to wrap the
|
|
136
|
+
``base_function`` of the calculation for caching. The ``lrucache``
|
|
137
|
+
parameter is filtered by the ``immlib.util.to_lrucache`` function in
|
|
138
|
+
order to convert it into a valid ``functools.lru_cache`` object.
|
|
139
|
+
pathcache : None or pathcache-like
|
|
140
|
+
The file-system-based cache being used. If this value is ``None`` or
|
|
141
|
+
``False``, then no filesystem cache is being used by the calculation
|
|
142
|
+
directly. If this value is a path object, then that path is the
|
|
143
|
+
directory in which cache files are saved/loaded. If ``pathcache`` is a
|
|
144
|
+
``joblib.Memory`` object, then this object handles the caching for the
|
|
145
|
+
calculation. Otherwise, the value will be ``True``, indicating that
|
|
146
|
+
caching should be performed automatically using the ``cache_path``
|
|
147
|
+
input to the calc. If ``cache_path`` was not already one of the inputs,
|
|
148
|
+
it is added as an input with the default value ``None``. When automatic
|
|
149
|
+
caching is performed, the ``cache_path`` is automatically converted
|
|
150
|
+
into a ``joblib.Memory`` object using the ``immlib.util.to_pathcache``
|
|
151
|
+
function.
|
|
152
|
+
function : callable
|
|
153
|
+
The function itself.
|
|
154
|
+
signature : inspect.Signature
|
|
155
|
+
The signature of ``fn``, as returned from ``inspect.signature(fn)``.
|
|
156
|
+
inputs : pcollections.pset of str
|
|
157
|
+
The names of the input parameters for the calculation.
|
|
158
|
+
outputs : tuple of str
|
|
159
|
+
The names of the output values of the calculation.
|
|
160
|
+
defaults : pcollections.pdict
|
|
161
|
+
A persistent dictionary whose keys are input parameter names and whose
|
|
162
|
+
values are the default values for the associated parameters.
|
|
163
|
+
lazy : bool
|
|
164
|
+
Whether the calculation is intended as a lazy (``True``) or eager
|
|
165
|
+
(``False``) calculation.
|
|
166
|
+
input_docs : pcollections.pdict
|
|
167
|
+
A ``pdict`` object whose keys are input names and whose values are the
|
|
168
|
+
documentation for the associated input parameters.
|
|
169
|
+
output_docs : pcollections.pdict
|
|
170
|
+
A ``pdict`` object whose keys are output names and whose values are
|
|
171
|
+
the documentation for the associated output values.
|
|
172
|
+
'''
|
|
173
|
+
__slots__ = (
|
|
174
|
+
'name', 'base_function', 'lrucache', 'pathcache',
|
|
175
|
+
'function', 'signature', 'inputs', 'outputs', 'defaults', 'lazy',
|
|
176
|
+
'input_docs', 'output_docs')
|
|
177
|
+
@staticmethod
|
|
178
|
+
def _dict_persist(arg):
|
|
179
|
+
return None if arg is None else pdict(arg)
|
|
180
|
+
@classmethod
|
|
181
|
+
def _interpret_pathcache(cls, pathcache):
|
|
182
|
+
if pathcache is None or pathcache is False:
|
|
183
|
+
return None
|
|
184
|
+
elif pathcache is True:
|
|
185
|
+
return True
|
|
186
|
+
else:
|
|
187
|
+
return to_pathcache(pathcache)
|
|
188
|
+
@staticmethod
|
|
189
|
+
def _pathcache_woutsig(base_fn, *args, **kw):
|
|
190
|
+
if 'cache_path' in kw:
|
|
191
|
+
cache_path = kw.pop('cache_path')
|
|
192
|
+
elif len(args) > 0:
|
|
193
|
+
cache_path = args[-1]
|
|
194
|
+
args = args[:-1]
|
|
195
|
+
else:
|
|
196
|
+
cache_path = None
|
|
197
|
+
if cache_path is None or cache_path is False:
|
|
198
|
+
return base_fn(*args, **kw)
|
|
199
|
+
cp = to_pathcache(cache_path)
|
|
200
|
+
cache_fn = cp.cache(base_fn)
|
|
201
|
+
return cache_fn(*args, **kw)
|
|
202
|
+
@staticmethod
|
|
203
|
+
def _pathcache_withsig(base_fn, sig, *args, **kw):
|
|
204
|
+
ba = sig.bind(*args, **kw)
|
|
205
|
+
ba.apply_defaults()
|
|
206
|
+
cp = ba.arguments['cache_path']
|
|
207
|
+
if cp is None or cp is False:
|
|
208
|
+
return base_fn(*args, **kw)
|
|
209
|
+
cp = to_pathcache(cp)
|
|
210
|
+
cache_fn = cp.cache(base_fn)
|
|
211
|
+
return cache_fn(*args, **kw)
|
|
212
|
+
@staticmethod
|
|
213
|
+
def _apply_caching(base_fn, sig, lrucache, pathcache):
|
|
214
|
+
# We assume that cache and pathcache have already been appropriately
|
|
215
|
+
# filtered by the to_lrucache and to_pathcache functions.
|
|
216
|
+
newsig = None
|
|
217
|
+
if pathcache is None or pathcache is False:
|
|
218
|
+
# No caching requested, either for plandicts or globally.
|
|
219
|
+
fn = base_fn
|
|
220
|
+
elif pathcache is True:
|
|
221
|
+
# This means we are caching into the cache_path input, which may be
|
|
222
|
+
# implicitly given. We make a special function if we need to ignore
|
|
223
|
+
# (not pass along) the cache_path argument.
|
|
224
|
+
if 'cache_path' in sig.parameters:
|
|
225
|
+
fn = partial(calc._pathcache_withsig, base_fn, sig)
|
|
226
|
+
else:
|
|
227
|
+
fn = partial(calc._pathcache_woutsig, base_fn)
|
|
228
|
+
params = list(sig.parameters.values())
|
|
229
|
+
params.append(
|
|
230
|
+
Parameter(
|
|
231
|
+
'cache_path',
|
|
232
|
+
Parameter.KEYWORD_ONLY,
|
|
233
|
+
default=None))
|
|
234
|
+
newsig = sig.replace(parameters=params)
|
|
235
|
+
elif isinstance(pathcache, Memory):
|
|
236
|
+
fn = pathcache.cache(base_fn)
|
|
237
|
+
else:
|
|
238
|
+
fn = Memory(pathcache).cache(base_fn)
|
|
239
|
+
if lrucache is not None:
|
|
240
|
+
fn = lrucache(fn)
|
|
241
|
+
# We want to wrap base_fn but use the signature sig.
|
|
242
|
+
wrapfn = fn if fn is base_fn else wraps(base_fn)(fn)
|
|
243
|
+
if newsig is not None:
|
|
244
|
+
wrapfn.__signature__ = newsig
|
|
245
|
+
return wrapfn
|
|
246
|
+
@classmethod
|
|
247
|
+
def _new(cls, fn, outputs,
|
|
248
|
+
name=None, lazy=True, indent=None,
|
|
249
|
+
lrucache=0, pathcache=None):
|
|
250
|
+
# Check the name.
|
|
251
|
+
if name is None:
|
|
252
|
+
name = fn.__module__ + '.' + fn.__name__
|
|
253
|
+
# Okay, let's run the fn through docwrap to get the input and output
|
|
254
|
+
# documentation.
|
|
255
|
+
if (hasattr(fn, '__doc__') and
|
|
256
|
+
fn.__doc__ is not None and fn.__doc__.strip() != '' and
|
|
257
|
+
name is not None):
|
|
258
|
+
fndoc = fn.__doc__
|
|
259
|
+
dp = make_docproc()
|
|
260
|
+
fn = docwrap('fn', indent=indent, proc=dp)(fn)
|
|
261
|
+
input_docs = tdict()
|
|
262
|
+
output_docs = tdict()
|
|
263
|
+
for (k,doc) in dp.params.items():
|
|
264
|
+
if k.startswith('fn.inputs.'):
|
|
265
|
+
input_docs[k[10:]] = doc
|
|
266
|
+
elif k.startswith('fn.parameters.'):
|
|
267
|
+
input_docs[k[14:]] = doc
|
|
268
|
+
elif k.startswith('fn.outputs.'):
|
|
269
|
+
output_docs[k[11:]] = doc
|
|
270
|
+
input_docs = pdict(input_docs)
|
|
271
|
+
output_docs = pdict(output_docs)
|
|
272
|
+
else:
|
|
273
|
+
input_docs = pdict()
|
|
274
|
+
output_docs = pdict()
|
|
275
|
+
fndoc = None
|
|
276
|
+
# Go ahead and allocate the object we're creating.
|
|
277
|
+
self = object.__new__(cls)
|
|
278
|
+
# Setting function to Ellipsis is a signal to the setattr method that
|
|
279
|
+
# the object is being initialized; until we set function to something
|
|
280
|
+
# else at the end of this function, setattr is allowed (i.e., the calc
|
|
281
|
+
# becomes immutable once this function returns.)
|
|
282
|
+
object.__setattr__(self, 'function', Ellipsis)
|
|
283
|
+
# Set some attributes.
|
|
284
|
+
self.name = name
|
|
285
|
+
# Save the base_function before we do anything to it.
|
|
286
|
+
self.base_function = fn
|
|
287
|
+
# If there's a caching strategy here, use it.
|
|
288
|
+
lrucache = to_lrucache(lrucache)
|
|
289
|
+
self.lrucache = lrucache
|
|
290
|
+
# If there's a cache path, note it.
|
|
291
|
+
pathcache = self._interpret_pathcache(pathcache)
|
|
292
|
+
self.pathcache = pathcache
|
|
293
|
+
# Get the argspec for the calculation function.
|
|
294
|
+
sig = signature(fn)
|
|
295
|
+
for p in sig.parameters.values():
|
|
296
|
+
if p.kind == p.VAR_POSITIONAL:
|
|
297
|
+
raise ValueError("calculations do not support varargs")
|
|
298
|
+
elif p.kind == p.VAR_KEYWORD:
|
|
299
|
+
raise ValueError("calculations do not support varkw")
|
|
300
|
+
# Figure out the inputs from the argspec; we set them below, after we
|
|
301
|
+
# have checked the pathcache.
|
|
302
|
+
inputs = pset(sig.parameters.keys())
|
|
303
|
+
# Check that the outputs are okay.
|
|
304
|
+
outputs = tuple(outputs)
|
|
305
|
+
for out in outputs:
|
|
306
|
+
if not strisvar(out):
|
|
307
|
+
raise ValueError(f"calc output '{out}' is not a valid varname")
|
|
308
|
+
self.outputs = outputs
|
|
309
|
+
# We need to grab the defaults also.
|
|
310
|
+
dflts = {}
|
|
311
|
+
for p in sig.parameters.values():
|
|
312
|
+
if p.default is not p.empty:
|
|
313
|
+
dflts[p.name] = p.default
|
|
314
|
+
# If pathcache is True, then cache_path is an implicit argument if not
|
|
315
|
+
# already included; add that here if necessary. This won't screw up the
|
|
316
|
+
# arguments when the eager_call is eventually made because the
|
|
317
|
+
# _apply_caching function handles this.
|
|
318
|
+
if pathcache is True and 'cache_path' not in inputs:
|
|
319
|
+
inputs = inputs.add('cache_path')
|
|
320
|
+
dflts['cache_path'] = None
|
|
321
|
+
self.inputs = inputs
|
|
322
|
+
self.defaults = pdict(dflts)
|
|
323
|
+
# Save the laziness status and the documentations.
|
|
324
|
+
self.lazy = bool(lazy)
|
|
325
|
+
self.input_docs = input_docs
|
|
326
|
+
self.output_docs = output_docs
|
|
327
|
+
# Last thing is to set the function, which signals that construction is
|
|
328
|
+
# done and the calc is now immutable.
|
|
329
|
+
cache_fn = self._apply_caching(fn, sig, lrucache, pathcache)
|
|
330
|
+
# At this point we get the signature for cache_fn because it's possible
|
|
331
|
+
# that cache_fn added a cache_path parameter.
|
|
332
|
+
self.signature = signature(cache_fn)
|
|
333
|
+
self.function = cache_fn
|
|
334
|
+
# That is all for the constructor. However, what we actually return
|
|
335
|
+
# from a calc decorator/call is a function with a
|
|
336
|
+
# `calc` field.
|
|
337
|
+
try:
|
|
338
|
+
fn.calc = self
|
|
339
|
+
except Exception:
|
|
340
|
+
# If the above fails, it's probably because fn isn't a normal
|
|
341
|
+
# function and doesn't allow a field to be set. We can hack that.
|
|
342
|
+
func = fn
|
|
343
|
+
@wraps(fn)
|
|
344
|
+
def fn_wrapper(*args, **kwargs):
|
|
345
|
+
return func(*args, **kwargs)
|
|
346
|
+
fn_wrapper.calc = self
|
|
347
|
+
fn = fn_wrapper
|
|
348
|
+
return fn
|
|
349
|
+
def __new__(cls, *args,
|
|
350
|
+
name=None, lazy=True,
|
|
351
|
+
lrucache=0, pathcache=None, indent=None):
|
|
352
|
+
kw = dict(name=name, lazy=lazy, lrucache=lrucache,
|
|
353
|
+
pathcache=pathcache, indent=indent)
|
|
354
|
+
if len(args) == 0:
|
|
355
|
+
# @calc(k1=v1...) :: calc(k1=v1...)(fn)
|
|
356
|
+
# Special case where we are getting the output name from the
|
|
357
|
+
# function's name directly.
|
|
358
|
+
def calc_noarg(f):
|
|
359
|
+
return cls._new(f, (f.__name__,), **kw)
|
|
360
|
+
return calc_noarg
|
|
361
|
+
elif len(args) == 1 and not is_str(args[0]):
|
|
362
|
+
if args[0] is None:
|
|
363
|
+
# @calc(None, k1=v1...) :: calc(None, k1=v1...)(fn)
|
|
364
|
+
# Call to @calc(None), which forces a no-outputs version.
|
|
365
|
+
def calc_none(f):
|
|
366
|
+
return cls._new(f, None, **kw)
|
|
367
|
+
return cls_none
|
|
368
|
+
else:
|
|
369
|
+
# @calc :: calc(fn) or calc(fn, k1=v1...)
|
|
370
|
+
# Call to @calc without arguments: use the function name.
|
|
371
|
+
f = args[0]
|
|
372
|
+
return cls._new(f, (f.__name__,), **kw)
|
|
373
|
+
else:
|
|
374
|
+
# @calc(out1..., k1=v1...) :: calc(out1..., k1=v1...)(fn)
|
|
375
|
+
# We have been given a list of output variable names.
|
|
376
|
+
def calc_outputs(f):
|
|
377
|
+
return cls._new(f, args, **kw)
|
|
378
|
+
return calc_outputs
|
|
379
|
+
def update_function(self, fn):
|
|
380
|
+
"""Updates the function and its calc object.
|
|
381
|
+
|
|
382
|
+
On occasion, a function decorated with ``@calc`` is later decorated
|
|
383
|
+
with another feature, such as a decorator that causes its inputs to be
|
|
384
|
+
promoted. Such a decorator, when it comes after the ``@calc`` decorator
|
|
385
|
+
(i.e., on a line prior to the ``@calc``), will not update the
|
|
386
|
+
calculation object and thus the calculation object, when invoked, will
|
|
387
|
+
not call the fully decorated version of its function. To fix this, any
|
|
388
|
+
``calc`` object whose ``base_function`` member variable is identical to
|
|
389
|
+
the function given to a ``plan`` object (i.e., ``f is not
|
|
390
|
+
to_calc(f).base_function``), then this method is called to return a
|
|
391
|
+
``calc`` object whose ``base_function`` has been updated. If possible,
|
|
392
|
+
it also updates the `fn` argument to use the new ``calc`` object.
|
|
393
|
+
|
|
394
|
+
In general, this function should not be called directly by the user;
|
|
395
|
+
rather, it gets run automatically when a calc is added to a new
|
|
396
|
+
``plan`` or ``planobject``.
|
|
397
|
+
"""
|
|
398
|
+
# First, make sure the right calc was passed this function.
|
|
399
|
+
if to_calc(fn, update=False) is not self:
|
|
400
|
+
raise ValueError(
|
|
401
|
+
"calcobj.update_function(f) called, but to_calc(f) is not"
|
|
402
|
+
" calcobj")
|
|
403
|
+
# Next make sure we aren't already up-to-date.
|
|
404
|
+
if self.base_function is fn:
|
|
405
|
+
return self
|
|
406
|
+
# If fn.__wrapped__ doesn't exist or isn't the self.function, then the
|
|
407
|
+
# function was either poorly wrapped or it wasn't made from
|
|
408
|
+
# base_function and we need to raise an error.
|
|
409
|
+
wrapped = fn
|
|
410
|
+
wset = set([fn])
|
|
411
|
+
while wrapped is not None:
|
|
412
|
+
wrapped = getattr(fn, '__wrapped__', None)
|
|
413
|
+
if wrapped is self.base_function:
|
|
414
|
+
break
|
|
415
|
+
wid = id(wrapped)
|
|
416
|
+
if wid in wset:
|
|
417
|
+
raise ValueError("loop in __wrapped__ attributes")
|
|
418
|
+
wset.add(wid)
|
|
419
|
+
if wrapped is None:
|
|
420
|
+
raise ValueError(
|
|
421
|
+
"calcobj.update_function(f) called, but f is not made from"
|
|
422
|
+
" calcobj.base_function")
|
|
423
|
+
# At this point, we have verified that this is an appropriate update,
|
|
424
|
+
# so we can go ahead and make a duplicate calc with the new function.
|
|
425
|
+
new_fn = calc._new(
|
|
426
|
+
fn, self.outputs,
|
|
427
|
+
name=self.name, lazy=self.lazy,
|
|
428
|
+
lrucache=self.lrucache,
|
|
429
|
+
pathcache=self.pathcache)
|
|
430
|
+
return new_fn.calc
|
|
431
|
+
def eager_call(self, *args, **kwargs):
|
|
432
|
+
"""Eagerly calls the given calculation using the arguments.
|
|
433
|
+
|
|
434
|
+
``c.eager_call(...)`` returns the result of calling the calculation
|
|
435
|
+
``c(...)`` directly. Using the ``eager_call`` method is different from
|
|
436
|
+
calling the ``__call__`` method only in that the ``eager_call`` method
|
|
437
|
+
ignores the ``lazy`` member and always returns the direct results of
|
|
438
|
+
calling the calculation; using the ``__call__`` method will result in
|
|
439
|
+
``eager_call`` being run if the calculation is not lazy and in
|
|
440
|
+
``lazy_call`` being run if the calculation is lazy.
|
|
441
|
+
|
|
442
|
+
See Also
|
|
443
|
+
--------
|
|
444
|
+
calc.eager_mapcall, calc.lazy_call, calc.lazy_mapcall
|
|
445
|
+
"""
|
|
446
|
+
# Now we just pass these arguments along (the function itself has been
|
|
447
|
+
# given the caching code via decorators already).
|
|
448
|
+
res = self.function(*args, **kwargs)
|
|
449
|
+
# Now interpret the result.
|
|
450
|
+
outs = self.outputs
|
|
451
|
+
if not outs:
|
|
452
|
+
# We ignore the output and just return an empty lazydict in this
|
|
453
|
+
# case.
|
|
454
|
+
return ldict({})
|
|
455
|
+
n = len(outs)
|
|
456
|
+
if is_amap(res) and len(res) == n and all(k in res for k in outs):
|
|
457
|
+
pass
|
|
458
|
+
elif is_tuple(res) and len(res) == n:
|
|
459
|
+
res = {k:v for (k,v) in zip(outs, res)}
|
|
460
|
+
elif len(self.outputs) == 1:
|
|
461
|
+
res = {outs[0]: res}
|
|
462
|
+
elif not self.outputs and not res:
|
|
463
|
+
res = {}
|
|
464
|
+
else:
|
|
465
|
+
raise ValueError(f'return value from function call ({self.name}):'
|
|
466
|
+
' did not match efferents')
|
|
467
|
+
# We always convert lazys into values by returning a lazydict.
|
|
468
|
+
return ldict(res)
|
|
469
|
+
def lazy_call(self, *args, **kwargs):
|
|
470
|
+
"""Returns a lazy-dict of the results of calling the calculation.
|
|
471
|
+
|
|
472
|
+
``calc.lazy_call(...)`` is equivalent to ``calc(...)`` except that the
|
|
473
|
+
``lazydict`` that it returns encapsulates the running of the
|
|
474
|
+
calculation itself, so that ``calc(...)`` is not run until one of the
|
|
475
|
+
lazy values is requested.
|
|
476
|
+
|
|
477
|
+
See Also
|
|
478
|
+
--------
|
|
479
|
+
calc.mapcall, calc.lazy_mapcall
|
|
480
|
+
"""
|
|
481
|
+
# First, create a lazy for the actual call:
|
|
482
|
+
lazycall = lazy(self.eager_call, *args, **kwargs)
|
|
483
|
+
# Then make a lazy map of all the outputs, each of which pulls from
|
|
484
|
+
# this lazy object to get its values.
|
|
485
|
+
return ldict(
|
|
486
|
+
{k: lazy(lambda k: lazycall()[k], k)
|
|
487
|
+
for k in self.outputs})
|
|
488
|
+
def __call__(self, *args, **kwargs):
|
|
489
|
+
if self.lazy:
|
|
490
|
+
return self.lazy_call(*args, **kwargs)
|
|
491
|
+
else:
|
|
492
|
+
return self.eager_call(*args, **kwargs)
|
|
493
|
+
def call(self, *args, **kwargs):
|
|
494
|
+
"""Calls the calculation and returns the results dictionary.
|
|
495
|
+
|
|
496
|
+
``c.call(...)`` is an alias for ``c(...)``.
|
|
497
|
+
|
|
498
|
+
See also ``calc.mapcall``, ``calc.eager_call``, and ``calc.lazy_call``.
|
|
499
|
+
"""
|
|
500
|
+
if self.lazy:
|
|
501
|
+
return self.lazy_call(*args, **kwargs)
|
|
502
|
+
else:
|
|
503
|
+
return self.eager_call(*args, **kwargs)
|
|
504
|
+
def _maps_to_args(self, args, kwargs):
|
|
505
|
+
opts = merge(self.defaults, *args, **kwargs)
|
|
506
|
+
args = []
|
|
507
|
+
kwargs = {}
|
|
508
|
+
for (name,p) in self.signature.parameters.items():
|
|
509
|
+
if name not in opts:
|
|
510
|
+
raise ValueError(f"required argument {name} not found")
|
|
511
|
+
if p.kind == p.POSITIONAL_ONLY:
|
|
512
|
+
args.append(opts[name])
|
|
513
|
+
else:
|
|
514
|
+
kwargs[name] = opts[name]
|
|
515
|
+
return (args, kwargs)
|
|
516
|
+
def eager_mapcall(self, *args, **kwargs):
|
|
517
|
+
"""Calls the given calculation using the parameters in mappings.
|
|
518
|
+
|
|
519
|
+
``c.eager_mapcall(map1, map2..., key1=val1, key2=val2...)`` returns the
|
|
520
|
+
result of calling the calculation ``c(...)`` using the parameters found
|
|
521
|
+
in the provided mappings and key-value pairs. All arguments of
|
|
522
|
+
``mapcall`` are merged left-to-right using ``immlib.merge`` then passed
|
|
523
|
+
to ``c.function`` as required by it.
|
|
524
|
+
"""
|
|
525
|
+
(args, kwargs) = self._maps_to_args(args, kwargs)
|
|
526
|
+
return self.eager_call(*args, **kwargs)
|
|
527
|
+
def lazy_mapcall(self, *args, **kwargs):
|
|
528
|
+
"""Calls the given calculation lazily using the parameters in mappings.
|
|
529
|
+
|
|
530
|
+
``c.lazy_mapcall(map1, map2..., key1=val1, key2=val2...)`` returns the
|
|
531
|
+
result of calling the calculation ``c(...)`` using the parameters found
|
|
532
|
+
in the provided mappings and key-value pairs. All arguments of
|
|
533
|
+
``mapcall`` are merged left-to-right using ``immlib.merge`` then passed
|
|
534
|
+
to ``c.function`` as required by it.
|
|
535
|
+
|
|
536
|
+
The only difference between ``calc.mapcall`` and ``calc.lazy_mapcall``
|
|
537
|
+
is that the lazydict returned by the latter method encapsulates the
|
|
538
|
+
calling of the calculation itself, so no call to the calculation is
|
|
539
|
+
made until one of the values of the lazydict is requested.
|
|
540
|
+
|
|
541
|
+
See Also
|
|
542
|
+
--------
|
|
543
|
+
calc.eager_mapcall, calc.lazy_call, calc.eager_call
|
|
544
|
+
"""
|
|
545
|
+
# Note that all the args must be dictionaries, so we make copies of
|
|
546
|
+
# them if they're not persistent dictionaries. This prevents later
|
|
547
|
+
# modifications from affecting the results downstream.
|
|
548
|
+
args = [d if is_pdict(d) else dict(d) for d in args]
|
|
549
|
+
# First, create a lazy for the actual call:
|
|
550
|
+
calldel = lazy(self.eager_mapcall, *args, **kwargs)
|
|
551
|
+
# Then make a lazy map of all the outputs, each of which pulls from
|
|
552
|
+
# this lazy object to get its values.
|
|
553
|
+
fn = lambda k: calldel()[k]
|
|
554
|
+
return ldict({k: lazy(fn, k) for k in self.outputs})
|
|
555
|
+
def mapcall(self, *args, **kwargs):
|
|
556
|
+
"""Calls the calculation and returns the results dictionary.
|
|
557
|
+
|
|
558
|
+
``c.mapcall(map1, map2..., key1=val1, key2=val2...)`` returns the
|
|
559
|
+
result of calling the calculation ``c(...)`` using the parameters found
|
|
560
|
+
in the provided mappings and key-value pairs. All arguments of
|
|
561
|
+
``mapcall`` are merged left-to-right using ``immlib.merge`` then passed
|
|
562
|
+
to ``c.function`` as required by it.
|
|
563
|
+
|
|
564
|
+
See Also
|
|
565
|
+
--------
|
|
566
|
+
calc.lazy_mapcall, calc.eager_mapcall, calc.call
|
|
567
|
+
"""
|
|
568
|
+
if self.lazy: return self.lazy_mapcall(*args, **kwargs)
|
|
569
|
+
else: return self.eager_mapcall(*args, **kwargs)
|
|
570
|
+
def __setattr__(self, k, v):
|
|
571
|
+
if self.function is Ellipsis:
|
|
572
|
+
# We're still initializing, so setattr is allowed.
|
|
573
|
+
return object.__setattr__(self, k, v)
|
|
574
|
+
else:
|
|
575
|
+
raise TypeError('calc objects are immutable')
|
|
576
|
+
def __delattr__(self, k):
|
|
577
|
+
raise TypeError('calc objects are immutable')
|
|
578
|
+
@staticmethod
|
|
579
|
+
def _tr_map(tr, m, is_input):
|
|
580
|
+
if m is None:
|
|
581
|
+
return None
|
|
582
|
+
tup_ii = int(not is_input)
|
|
583
|
+
is_ld = is_ldict(m)
|
|
584
|
+
it = holdlazy(m).items()
|
|
585
|
+
d = tdict()
|
|
586
|
+
for (k,v) in it:
|
|
587
|
+
kk = tr.get(k,k)
|
|
588
|
+
if isinstance(kk, tuple):
|
|
589
|
+
kk = kk[tup_ii]
|
|
590
|
+
d[kk] = v
|
|
591
|
+
return ldict(d) if is_ld else pdict(d)
|
|
592
|
+
@staticmethod
|
|
593
|
+
def _tr_tup(tr, t, is_input):
|
|
594
|
+
if t is None:
|
|
595
|
+
return None
|
|
596
|
+
tup_ii = int(not is_input)
|
|
597
|
+
res = []
|
|
598
|
+
for k in t:
|
|
599
|
+
k = tr.get(k,k)
|
|
600
|
+
if isinstance(k, tuple):
|
|
601
|
+
k = k[tup_ii]
|
|
602
|
+
res.append(k)
|
|
603
|
+
return tuple(res)
|
|
604
|
+
@staticmethod
|
|
605
|
+
def _tr_set(tr, t, is_input):
|
|
606
|
+
if t is None:
|
|
607
|
+
return None
|
|
608
|
+
tup_ii = int(not is_input)
|
|
609
|
+
res = tset()
|
|
610
|
+
for k in t:
|
|
611
|
+
k = tr.get(k, k)
|
|
612
|
+
if isinstance(k, tuple):
|
|
613
|
+
k = k[tup_ii]
|
|
614
|
+
res.add(k)
|
|
615
|
+
return res.persistent()
|
|
616
|
+
def rename_keys(self, *args, **kwargs):
|
|
617
|
+
"""Returns a copy of the calculation with inputs and outputs renamed.
|
|
618
|
+
|
|
619
|
+
``calc.rename_keys(...)`` returns a copy of ``calc`` in which the input
|
|
620
|
+
and output values of the function have been translated. The translation
|
|
621
|
+
is found from merging the list of 0 or more dict-like arguments given
|
|
622
|
+
left-to-right followed by the keyword arguments into a single
|
|
623
|
+
dictionary. The keys of this dictionary are translated into their
|
|
624
|
+
associated values in the returned dictionary.
|
|
625
|
+
|
|
626
|
+
If any of the values of the merged dictionary are 2-tuples, then they
|
|
627
|
+
are interpreted as ``(input_tr, output_tr)``. In this case, then the
|
|
628
|
+
key must be associated with a name that appears in both the
|
|
629
|
+
calculation's input list and its output list, and the two names are
|
|
630
|
+
translated differently.
|
|
631
|
+
"""
|
|
632
|
+
d = merge(*args, **kwargs)
|
|
633
|
+
# Make a copy.
|
|
634
|
+
tr = object.__new__(calc)
|
|
635
|
+
# Simple changes first.
|
|
636
|
+
trhash = np.fromiter(map(hash, d.items()), dtype=np.intp)
|
|
637
|
+
trhash = np.sum(trhash.astype(np.uintp))
|
|
638
|
+
object.__setattr__(tr, 'name', self.name + f'.rename{hex(trhash)}')
|
|
639
|
+
object.__setattr__(tr, 'base_function', self.base_function)
|
|
640
|
+
object.__setattr__(tr, 'lrucache', self.lrucache)
|
|
641
|
+
object.__setattr__(tr, 'pathcache', self.pathcache)
|
|
642
|
+
object.__setattr__(tr, 'lazy', self.lazy)
|
|
643
|
+
object.__setattr__(tr, 'inputs', calc._tr_set(d, self.inputs, True))
|
|
644
|
+
object.__setattr__(tr, 'outputs', calc._tr_tup(d, self.outputs, False))
|
|
645
|
+
object.__setattr__(
|
|
646
|
+
tr, 'defaults', calc._tr_map(d, self.defaults, True))
|
|
647
|
+
object.__setattr__(
|
|
648
|
+
tr, 'input_docs', calc._tr_map(d, self.input_docs, True))
|
|
649
|
+
object.__setattr__(
|
|
650
|
+
tr, 'output_docs', calc._tr_map(d, self.output_docs, False))
|
|
651
|
+
# Translate the argspec.
|
|
652
|
+
params = []
|
|
653
|
+
for (k,v) in self.signature.parameters.items():
|
|
654
|
+
name = d.get(k, k)
|
|
655
|
+
if name != k:
|
|
656
|
+
name = name if isinstance(name, str) else name[0]
|
|
657
|
+
v = v.replace(name=name)
|
|
658
|
+
params.append(v)
|
|
659
|
+
newsig = self.signature.replace(parameters=params)
|
|
660
|
+
object.__setattr__(tr, 'signature', newsig)
|
|
661
|
+
# The reversed version of d (for inputs).
|
|
662
|
+
r = {v:(k if isinstance(k, str) else k[0]) for (k,v) in d.items()}
|
|
663
|
+
fn = self.function
|
|
664
|
+
def _tr_fn_wrapper(*args, **kwargs):
|
|
665
|
+
# We may need to untranslate some of the keys.
|
|
666
|
+
kwargs = {r.get(k,k):v for (k,v) in kwargs.items()}
|
|
667
|
+
res = fn(*args, **kwargs)
|
|
668
|
+
if is_amap(res):
|
|
669
|
+
return calc._tr_map(d, res, False)
|
|
670
|
+
else:
|
|
671
|
+
return res
|
|
672
|
+
wrapfn = wraps(self.function)(_tr_fn_wrapper)
|
|
673
|
+
object.__setattr__(tr, 'function', wrapfn)
|
|
674
|
+
return tr
|
|
675
|
+
def with_lrucache(self, new_cache):
|
|
676
|
+
"Returns a copy of a calc with a different in-memory cache strategy."
|
|
677
|
+
new_cache = to_lrucache(new_cache)
|
|
678
|
+
if new_cache is self.lrucache:
|
|
679
|
+
return self
|
|
680
|
+
new_calc = copy.copy(self)
|
|
681
|
+
object.__setattr__(new_calc, 'lrucache', new_cache)
|
|
682
|
+
fn = self.base_function
|
|
683
|
+
new_fn = calc._apply_caching(fn, new_cache, self.pathcache)
|
|
684
|
+
if fn is not new_fn:
|
|
685
|
+
object.__setattr__(new_calc, 'function', new_fn)
|
|
686
|
+
return new_calc
|
|
687
|
+
def with_pathcache(self, new_path):
|
|
688
|
+
"""Returns a copy of a calc with a different cache directory."""
|
|
689
|
+
new_path = self._interpret_pathcache(new_path)
|
|
690
|
+
if new_cache is self.pathcache:
|
|
691
|
+
return self
|
|
692
|
+
new_calc = copy.copy(self)
|
|
693
|
+
object.__setattr__(new_calc, 'pathcache', new_cache)
|
|
694
|
+
fn = self.base_function
|
|
695
|
+
new_fn = calc._apply_caching(fn, self.lrucache, new_cache)
|
|
696
|
+
if fn is not new_fn:
|
|
697
|
+
object.__setattr__(new_calc, 'function', new_fn)
|
|
698
|
+
return new_calc
|
|
699
|
+
@docwrap('immlib.workflow.is_calc')
|
|
700
|
+
def is_calc(obj, /):
|
|
701
|
+
"""Determines if an object is a ``calc`` instance.
|
|
702
|
+
|
|
703
|
+
``is_calc(obj)`` returns ``True`` if `obj` is a ``calc`` object.
|
|
704
|
+
|
|
705
|
+
.. Warning:: ``is_calc(obj)`` returns ``False`` if `obj` is a function that
|
|
706
|
+
was decorated with the ``@calc`` decorator. This is because ``calc``
|
|
707
|
+
does not turn its decorated functions into ``calc`` objects; rather it
|
|
708
|
+
attaches a field ``calc`` to the decorated function. To see
|
|
709
|
+
whether a function is was decorated by ``calc``, use ``is_calcfn``.
|
|
710
|
+
|
|
711
|
+
See Also
|
|
712
|
+
--------
|
|
713
|
+
calc, to_calc, is_calcfn
|
|
714
|
+
"""
|
|
715
|
+
return isinstance(obj, calc)
|
|
716
|
+
@docwrap('immlib.is_calcfn')
|
|
717
|
+
def is_calcfn(obj, /):
|
|
718
|
+
"""Determines if an object is function that was decorated by ``@calc``.
|
|
719
|
+
|
|
720
|
+
``is_calcfn(obj)`` returns ``True`` if `obj` is a function that was
|
|
721
|
+
decorated with an ``@calc`` decorator or if `obj` is a ``calc`` object, and
|
|
722
|
+
it returns ``False`` otherwise.
|
|
723
|
+
|
|
724
|
+
Functions decorated with ``@calc`` are not changed but rather are given
|
|
725
|
+
some metadata, which is stored in the member field ``calc``. For
|
|
726
|
+
such functions, this field contains an object of type ``calc``.
|
|
727
|
+
|
|
728
|
+
See Also
|
|
729
|
+
--------
|
|
730
|
+
calc, to_calc, is_calc
|
|
731
|
+
"""
|
|
732
|
+
return isinstance(getattr(obj, 'calc', None), calc)
|
|
733
|
+
@docwrap('immlib.workflow.to_calc')
|
|
734
|
+
def to_calc(obj, /, update=True):
|
|
735
|
+
"""Converts an object into a ``calc`` object or raises a ``TypeError``.
|
|
736
|
+
|
|
737
|
+
``to_calc(obj)`` returns `obj` if `obj` is already a ``calc``
|
|
738
|
+
object. Otherwise, if `obj` has the attribute ``calc``, then that attribute
|
|
739
|
+
is returned.
|
|
740
|
+
|
|
741
|
+
.. Note:: When a function is decorated by ``@calc``, the calculation data
|
|
742
|
+
is stored in a ``calc`` object that is saved to the ``calc``
|
|
743
|
+
field of the function, which is why the above works.
|
|
744
|
+
"""
|
|
745
|
+
if isinstance(obj, calc):
|
|
746
|
+
return obj
|
|
747
|
+
c = getattr(obj, 'calc', None)
|
|
748
|
+
if isinstance(c, calc):
|
|
749
|
+
if update:
|
|
750
|
+
c = c.update_function(obj)
|
|
751
|
+
return c
|
|
752
|
+
raise TypeError(f"to_calc received non-calc object of type {type(obj)}")
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
# plan ########################################################################
|
|
756
|
+
|
|
757
|
+
class plan(pdict):
|
|
758
|
+
'''Represents a directed acyclic graph of calculations.
|
|
759
|
+
|
|
760
|
+
The ``plan`` class encapsulates individual functions that require
|
|
761
|
+
parameters as inputs and produce outputs in the form of named values. Plan
|
|
762
|
+
objects can be called as functions with a dictionary and/or a keyword
|
|
763
|
+
arguments providing the plan's parameters; they always return a type of
|
|
764
|
+
lazy dictionary called a ``plandict`` of the values they calculate, even if
|
|
765
|
+
they calculate only a single value.
|
|
766
|
+
|
|
767
|
+
Superficially, a ``plan`` is a ``pdict`` object whose values must all be
|
|
768
|
+
``calc`` objects. However, under the hood, every ``plan`` object maintains
|
|
769
|
+
a directed acyclic graph of dependencies of the inputs and outputs of the
|
|
770
|
+
calculation objects such that it can create ``plandict`` objects that reify
|
|
771
|
+
the outputs of the various calculations lazily.
|
|
772
|
+
|
|
773
|
+
The keys that are used in a plan must be strings but are not otherwise
|
|
774
|
+
restricted.
|
|
775
|
+
|
|
776
|
+
For a plan ``p = plan(calc_key1=calc1, calc_key2=calc2, ...)``, a
|
|
777
|
+
``plandict`` can be instantiated using the following syntax::
|
|
778
|
+
|
|
779
|
+
pd = p(param1=val1, param2=val2, ...)
|
|
780
|
+
|
|
781
|
+
This ``plandict`` is an enhanced ``ldict`` that evaluates components of the
|
|
782
|
+
plan as requested based on laziness requirements of the calculations in the
|
|
783
|
+
plan and on dictionary lookups of plan outputs. (``ldict`` is the lazy
|
|
784
|
+
dictionary type from the ``pcollections`` library.)
|
|
785
|
+
|
|
786
|
+
All plans implicitly contains the parameter ``'cache_path'`` with the
|
|
787
|
+
default value of ``None``. This parameter is used by the plan's
|
|
788
|
+
``plandict`` objects, to cache the outputs of calculations that were
|
|
789
|
+
constructed with the option ``pathcache=True``.
|
|
790
|
+
|
|
791
|
+
Attributes
|
|
792
|
+
----------
|
|
793
|
+
inputs : pset of strs
|
|
794
|
+
A pset of the input parameter names, as defined by the plan's
|
|
795
|
+
calculations. Note that the union of the inputs and the outputs is
|
|
796
|
+
equivalent to the keys in any plan-dictionary.
|
|
797
|
+
outputs : pset of strs
|
|
798
|
+
A pset of the output parameter names, as defined by the plan's
|
|
799
|
+
calculations. Note that the union of the inputs and the outputs is
|
|
800
|
+
equivalent to the keys in any plan-dictionary.
|
|
801
|
+
defaults : pdict
|
|
802
|
+
A dictionary whose keys consist of a subset of the inputs to the plan
|
|
803
|
+
and whose values are the default values those parameters should take if
|
|
804
|
+
they are not provided explicitly to the plan.
|
|
805
|
+
calcs : pdict
|
|
806
|
+
A persistent dictionary whose keys are the names of the various
|
|
807
|
+
calculations in the plan and whose values are the calculation objects
|
|
808
|
+
themselves.
|
|
809
|
+
input_docs : pdict
|
|
810
|
+
A dictionary whose keys are input parameter names and whose values are
|
|
811
|
+
the combined documentation for the associated parameter across all
|
|
812
|
+
calculations in the plan.
|
|
813
|
+
output_docs : pdict
|
|
814
|
+
A dictionary whose keys are output value names and whose values are the
|
|
815
|
+
combined documentation for the associated outputs across all
|
|
816
|
+
calculations in the plan.
|
|
817
|
+
requirements : pset
|
|
818
|
+
A ``pset`` of the names of the required calculations of the plan (i.e.,
|
|
819
|
+
those with option ``lazy=False``).
|
|
820
|
+
__doc__ : str
|
|
821
|
+
Every ``plan`` object is given a set of documentation which includes
|
|
822
|
+
sections for the inputs and outputs as well as a listing of all the
|
|
823
|
+
calculation steps.
|
|
824
|
+
'''
|
|
825
|
+
# Subclasses --------------------------------------------------------------
|
|
826
|
+
CalcData = namedtuple(
|
|
827
|
+
'CalcData',
|
|
828
|
+
('names', 'calcs', 'args', 'sources', 'index'))
|
|
829
|
+
DepData = namedtuple(
|
|
830
|
+
'DepData',
|
|
831
|
+
('inputs', 'calcs'))
|
|
832
|
+
# Static Methods ----------------------------------------------------------
|
|
833
|
+
@staticmethod
|
|
834
|
+
def _filter_sort(kv):
|
|
835
|
+
return len(kv[1].inputs)
|
|
836
|
+
@staticmethod
|
|
837
|
+
def _find_trname(valnames, k, suffix=None):
|
|
838
|
+
"Returns a new unique value name appropriate for internal translation."
|
|
839
|
+
if suffix is not None:
|
|
840
|
+
k = f'{k}__{suffix}'
|
|
841
|
+
if k not in valnames:
|
|
842
|
+
return k
|
|
843
|
+
k0 = k + '_'
|
|
844
|
+
ii = 1
|
|
845
|
+
k = f'{k0}1'
|
|
846
|
+
while k in valnames:
|
|
847
|
+
ii += 1
|
|
848
|
+
k = f'{k0}{ii}'
|
|
849
|
+
return k
|
|
850
|
+
@staticmethod
|
|
851
|
+
def _source_lookup(inputtup, calctup, src):
|
|
852
|
+
if isinstance(src, tuple):
|
|
853
|
+
(cidx, oidx) = src
|
|
854
|
+
lazycalc = calctup[cidx]
|
|
855
|
+
val = lazycalc()[oidx]
|
|
856
|
+
else:
|
|
857
|
+
val = inputtup[src]()
|
|
858
|
+
return val
|
|
859
|
+
@staticmethod
|
|
860
|
+
def _lookup(calcdata, inputtup, calctup, key):
|
|
861
|
+
return plan._source_lookup(inputtup, calctup, calcdata.sources[key])
|
|
862
|
+
@staticmethod
|
|
863
|
+
def _call_calc(inputtup, calctup, c, args):
|
|
864
|
+
argvals = map(partial(plan._source_lookup, inputtup, calctup), args)
|
|
865
|
+
args = []
|
|
866
|
+
kwargs = {}
|
|
867
|
+
c = to_calc(c)
|
|
868
|
+
for (p,arg) in zip(c.signature.parameters.values(), argvals):
|
|
869
|
+
if p.kind == p.POSITIONAL_ONLY:
|
|
870
|
+
args.append[arg]
|
|
871
|
+
else:
|
|
872
|
+
kwargs[p.name] = arg
|
|
873
|
+
r = c.eager_call(*args, **kwargs)
|
|
874
|
+
if is_amap(r):
|
|
875
|
+
return tuple(map(r.__getitem__, c.outputs))
|
|
876
|
+
else:
|
|
877
|
+
return tuple(r)
|
|
878
|
+
@staticmethod
|
|
879
|
+
def _make_calctup(calcdata, inputtup):
|
|
880
|
+
f = plan._call_calc
|
|
881
|
+
# We take advantage of Python's weak closures here:
|
|
882
|
+
calctup = ()
|
|
883
|
+
calctup = tuple(
|
|
884
|
+
lazy(lambda c,args: f(inputtup, calctup, c, args), c, args)
|
|
885
|
+
for (c,args) in zip(calcdata.calcs, calcdata.args))
|
|
886
|
+
return calctup
|
|
887
|
+
@staticmethod
|
|
888
|
+
def _update_calctup(calcdata, inputtup, calctup, cidx):
|
|
889
|
+
calctup[cidx] = lazy(
|
|
890
|
+
plan._call_calc,
|
|
891
|
+
inputtup, calctup,
|
|
892
|
+
calcdata.calcs[cidx],
|
|
893
|
+
calcdata.args[cidx])
|
|
894
|
+
return calctup
|
|
895
|
+
def _update_dictdata(self, inputtup, calctup, updates):
|
|
896
|
+
calcdata = self.calcdata
|
|
897
|
+
sources = calcdata.sources
|
|
898
|
+
dependants = self.dependants
|
|
899
|
+
valsources = self.valsources
|
|
900
|
+
calc_updates = set()
|
|
901
|
+
# We're outputting/building-up new_inputtup and new_calctup from the
|
|
902
|
+
# inputtup and calctup values.
|
|
903
|
+
# We use some sleight of hand in this function:
|
|
904
|
+
# The a = lazy(f, arg) construct makes a closure over the value of arg.
|
|
905
|
+
# The b = lambda: f(arg) construct makes a closure over the symbol arg.
|
|
906
|
+
# This means that if arg is updated after both of these lines, then
|
|
907
|
+
# a() will return fn(original_value) and b() will return fn(new_value).
|
|
908
|
+
# We can use this to change calctup as we go.
|
|
909
|
+
new_inputtup = list(inputtup)
|
|
910
|
+
new_calctup = list(calctup)
|
|
911
|
+
items = tldict.empty()
|
|
912
|
+
srcget = plan._source_lookup
|
|
913
|
+
updates = holdlazy(updates)
|
|
914
|
+
for (k,v) in updates.items():
|
|
915
|
+
iidx = sources[k]
|
|
916
|
+
lv = v if isinstance(v, lazy) else lazy(identfn, v)
|
|
917
|
+
new_inputtup[iidx] = lv
|
|
918
|
+
calc_updates.update(dependants[k].calcs)
|
|
919
|
+
# Create a new plandict item for the new input value.
|
|
920
|
+
src = valsources[k]
|
|
921
|
+
if isinstance(src, tuple):
|
|
922
|
+
# This input gets filtered, so we need a lazy lookup using a
|
|
923
|
+
# lambda that makes a closure over the new_calctup symbol,
|
|
924
|
+
# which will get updated as we go.
|
|
925
|
+
lv = lazy(
|
|
926
|
+
lambda src: srcget(new_inputtup, new_calctup, src),
|
|
927
|
+
src)
|
|
928
|
+
items[k] = lv
|
|
929
|
+
new_inputtup = tuple(new_inputtup)
|
|
930
|
+
output_updates = set()
|
|
931
|
+
for cidx in calc_updates:
|
|
932
|
+
calc = calcdata.calcs[cidx]
|
|
933
|
+
output_updates.update(calc.outputs)
|
|
934
|
+
new_calctup[cidx] = lazy(
|
|
935
|
+
lambda c,a: plan._call_calc(new_inputtup, new_calctup, c, a),
|
|
936
|
+
calc,
|
|
937
|
+
calcdata.args[cidx])
|
|
938
|
+
new_calctup = tuple(new_calctup)
|
|
939
|
+
outputs = self.outputs
|
|
940
|
+
for k in output_updates:
|
|
941
|
+
if k not in outputs: # Skip the internal/translated outputs.
|
|
942
|
+
continue
|
|
943
|
+
src = self.valsources[k]
|
|
944
|
+
items[k] = lazy(srcget, new_inputtup, new_calctup, src)
|
|
945
|
+
return (new_inputtup, new_calctup, items.persistent())
|
|
946
|
+
@staticmethod
|
|
947
|
+
def _make_srcs_args(names, calcs, params):
|
|
948
|
+
args = []
|
|
949
|
+
srcs = {k:ii for (ii,k) in enumerate(params)}
|
|
950
|
+
for (cidx,(nm,c)) in enumerate(zip(names, calcs)):
|
|
951
|
+
c = to_calc(c)
|
|
952
|
+
# Wire up the inputs/arguments:
|
|
953
|
+
a = []
|
|
954
|
+
for k in c.inputs:
|
|
955
|
+
a.append(srcs[k])
|
|
956
|
+
args.append(tuple(a))
|
|
957
|
+
# And the outputs/sources.
|
|
958
|
+
for (oidx, oo) in enumerate(c.outputs):
|
|
959
|
+
assert oo not in srcs, "filter detected in flattened plan"
|
|
960
|
+
srcs[oo] = (cidx, oidx)
|
|
961
|
+
# At this point...
|
|
962
|
+
# args is the list (in calc order) of where to find the inputs of
|
|
963
|
+
# each calc.
|
|
964
|
+
args = tuple(args)
|
|
965
|
+
# srcs is the dictionary whose keys are plan value names and whose
|
|
966
|
+
# values are the indices telling us where to find that value.
|
|
967
|
+
srcs = pdict(srcs)
|
|
968
|
+
# That's all.
|
|
969
|
+
return (srcs, args)
|
|
970
|
+
@staticmethod
|
|
971
|
+
def _transitive_closure(edges):
|
|
972
|
+
clos = set(edges)
|
|
973
|
+
while True:
|
|
974
|
+
s = set(
|
|
975
|
+
(u1,v2)
|
|
976
|
+
for (u1,v1) in clos
|
|
977
|
+
for (u2,v2) in clos
|
|
978
|
+
if u2 == v1
|
|
979
|
+
if u1 != v2)
|
|
980
|
+
if clos.issuperset(s):
|
|
981
|
+
break
|
|
982
|
+
clos |= s
|
|
983
|
+
res = defaultdict(lambda:set())
|
|
984
|
+
for (u,v) in clos:
|
|
985
|
+
res[u].add(v)
|
|
986
|
+
return res
|
|
987
|
+
# Construction ------------------------------------------------------------
|
|
988
|
+
__slots__ = (
|
|
989
|
+
'inputs', 'outputs', 'defaults', 'requirements',
|
|
990
|
+
'input_docs', 'output_docs', 'docstr',
|
|
991
|
+
'calcdata', 'valsources', 'dependants')
|
|
992
|
+
def __new__(cls, *args, **kwargs):
|
|
993
|
+
# We overload new just to parse the input arguments and convert any
|
|
994
|
+
# values into calc objects. We then pass these down to pdict.
|
|
995
|
+
calcs = {}
|
|
996
|
+
nargs = len(args)
|
|
997
|
+
if nargs == 1:
|
|
998
|
+
inplan = args[0]
|
|
999
|
+
if isinstance(inplan, Mapping): # (plan is a pdict/map of calcs)
|
|
1000
|
+
calcs.update(inplan)
|
|
1001
|
+
else:
|
|
1002
|
+
raise TypeError(
|
|
1003
|
+
f"x in plan(x) must be a Mapping; found {type(inplan)}")
|
|
1004
|
+
elif nargs > 1:
|
|
1005
|
+
raise ValueError(
|
|
1006
|
+
f"plan expects 0 or 1 positional arguments; found {nargs}")
|
|
1007
|
+
calcs.update(kwargs)
|
|
1008
|
+
for (k,v) in calcs.items():
|
|
1009
|
+
u = to_calc(v)
|
|
1010
|
+
calcs[k] = u
|
|
1011
|
+
return pdict.__new__(cls, calcs)
|
|
1012
|
+
def __init__(self, *args, **kwargs):
|
|
1013
|
+
# We ignore the arguments because they are handled by __new__.
|
|
1014
|
+
# We can start by gathering up the calcs that deal with each of the
|
|
1015
|
+
# plan's values. The val2calc dict maps each value name in the plan to
|
|
1016
|
+
# a tuple of (output, filter, input) calcs that process the value. The
|
|
1017
|
+
# output calcs are those that produce the value as an output but don't
|
|
1018
|
+
# requie it as an input; the filter calcs are those that require the
|
|
1019
|
+
# value as an input and that produce the value as an output; the input
|
|
1020
|
+
# calcs are those that require the calc as an input but don't produce
|
|
1021
|
+
# it as output.
|
|
1022
|
+
val2calc = defaultdict(lambda:([],[],[]))
|
|
1023
|
+
filters = set()
|
|
1024
|
+
params = []
|
|
1025
|
+
reqs = tset()
|
|
1026
|
+
for (nm,c) in self.items():
|
|
1027
|
+
c = to_calc(c)
|
|
1028
|
+
# Examine the inputs and outputs:
|
|
1029
|
+
for oo in c.outputs:
|
|
1030
|
+
if oo not in c.inputs:
|
|
1031
|
+
val2calc[oo][0].append(nm)
|
|
1032
|
+
else:
|
|
1033
|
+
filters.add(nm)
|
|
1034
|
+
val2calc[oo][1].append(nm)
|
|
1035
|
+
for ii in c.inputs:
|
|
1036
|
+
if ii not in c.outputs:
|
|
1037
|
+
val2calc[ii][2].append(nm)
|
|
1038
|
+
# If it's not a lazy calculation, it goes on the requirements list:
|
|
1039
|
+
if not c.lazy:
|
|
1040
|
+
reqs.add(nm)
|
|
1041
|
+
nval = len(val2calc)
|
|
1042
|
+
for (k,(outs,filts,ins)) in val2calc.items():
|
|
1043
|
+
nouts = len(outs)
|
|
1044
|
+
if nouts == 0:
|
|
1045
|
+
# If it's an output of zero calcs, then it's a parameter of the
|
|
1046
|
+
# overall plan.
|
|
1047
|
+
params.append(k)
|
|
1048
|
+
elif nouts > 1:
|
|
1049
|
+
# If any of the values are produced by more than 1 output, then
|
|
1050
|
+
# that is a violation of the graph rules.
|
|
1051
|
+
raise ValueError(
|
|
1052
|
+
f"value {k} is an output of {nouts} calcs: {outs}")
|
|
1053
|
+
# Now that we have a list of the params for the plan, we can start
|
|
1054
|
+
# putting the calcs in a calculation order. The order must guarantee
|
|
1055
|
+
# that any calc at position p has inputs that are drawn only from plan
|
|
1056
|
+
# parameters and the outputs of calcs whose position is less than p. If
|
|
1057
|
+
# we can make such an ordering, then we can make a DAG.
|
|
1058
|
+
params = pset(params)
|
|
1059
|
+
inputs = params.transient()
|
|
1060
|
+
calcs = set(self.keys())
|
|
1061
|
+
calcorder = []
|
|
1062
|
+
input_docs = defaultdict(lambda:[])
|
|
1063
|
+
output_docs = defaultdict(lambda:[])
|
|
1064
|
+
# We're going to be selecting filters and we want to do so
|
|
1065
|
+
# preferentially based on the number of inputs they require.
|
|
1066
|
+
filts = tset(sorted(
|
|
1067
|
+
filters, key=lambda
|
|
1068
|
+
f:-len(to_calc(self[f]).inputs)))
|
|
1069
|
+
filters = pset(filts)
|
|
1070
|
+
is_ready = lambda f: to_calc(self[f]).inputs <= inputs
|
|
1071
|
+
while len(calcs) > 0:
|
|
1072
|
+
# We start by greedily selecting filters.
|
|
1073
|
+
if len(filts) > 0:
|
|
1074
|
+
nextcalc = next(filter(is_ready, filts), None)
|
|
1075
|
+
else:
|
|
1076
|
+
nextcalc = None
|
|
1077
|
+
if nextcalc is None:
|
|
1078
|
+
# If we get here, we didn't find a filter, so we look for any
|
|
1079
|
+
# other calc we can run!
|
|
1080
|
+
nextcalc = next(filter(is_ready, calcs), None)
|
|
1081
|
+
if nextcalc is None:
|
|
1082
|
+
raise ValueError(
|
|
1083
|
+
f"unreachable calcs: {tuple(calcs)}; this is likely"
|
|
1084
|
+
f" due to a circular dependency")
|
|
1085
|
+
else:
|
|
1086
|
+
filts.discard(nextcalc)
|
|
1087
|
+
# We have a next calculation in the order, so we add it.
|
|
1088
|
+
calcorder.append(nextcalc)
|
|
1089
|
+
c = to_calc(self[nextcalc])
|
|
1090
|
+
inputs.addall(c.outputs)
|
|
1091
|
+
calcs.discard(nextcalc)
|
|
1092
|
+
# While we're going through the calcs in order, we process docs:
|
|
1093
|
+
for (inp,doc) in c.input_docs.items():
|
|
1094
|
+
if not doc:
|
|
1095
|
+
continue
|
|
1096
|
+
lns = doc.split('\n')
|
|
1097
|
+
nameln = lns[0]
|
|
1098
|
+
if ':' in nameln:
|
|
1099
|
+
tag = ' :' + ':'.join(nameln.split(':')[1:])
|
|
1100
|
+
else:
|
|
1101
|
+
tag = ''
|
|
1102
|
+
doc = reindent(
|
|
1103
|
+
'\n'.join(lns[1:]), 4,
|
|
1104
|
+
skip_first=False, final_endline=False)
|
|
1105
|
+
doc = f" **``{nextcalc}``** input: ``{inp}``{tag} \n{doc}"
|
|
1106
|
+
if inp in params:
|
|
1107
|
+
input_docs[inp].append(doc)
|
|
1108
|
+
else:
|
|
1109
|
+
output_docs[inp].append(doc)
|
|
1110
|
+
for (out,doc) in c.output_docs.items():
|
|
1111
|
+
if not doc:
|
|
1112
|
+
continue
|
|
1113
|
+
lns = doc.split('\n')
|
|
1114
|
+
nameln = lns[0]
|
|
1115
|
+
if ':' in nameln:
|
|
1116
|
+
tag = ' :' + ':'.join(nameln.split(':')[1:])
|
|
1117
|
+
else:
|
|
1118
|
+
tag = ''
|
|
1119
|
+
doc = reindent(
|
|
1120
|
+
'\n'.join(lns[1:]), 4,
|
|
1121
|
+
skip_first=False, final_endline=False)
|
|
1122
|
+
doc = f" **``{nextcalc}``** output: ``{out}``{tag} \n{doc}"
|
|
1123
|
+
output_docs[out].append(doc)
|
|
1124
|
+
doc_connectfn = lambda v: '\n\n'.join(v)
|
|
1125
|
+
input_docs = pdict(valmap(doc_connectfn, input_docs))
|
|
1126
|
+
output_docs = pdict(valmap(doc_connectfn, output_docs))
|
|
1127
|
+
ncalcs = len(calcorder)
|
|
1128
|
+
calcidx = pdict(zip(calcorder, range(ncalcs)))
|
|
1129
|
+
outputs = inputs
|
|
1130
|
+
outputs -= params
|
|
1131
|
+
outputs = outputs.persistent()
|
|
1132
|
+
inputs = params
|
|
1133
|
+
# Before we move on to the calculation graph, let's make the docstring.
|
|
1134
|
+
# the 12 here must match the indentation of the docstring that
|
|
1135
|
+
# follows.
|
|
1136
|
+
code_indent = 12
|
|
1137
|
+
code_head = ' ' * code_indent
|
|
1138
|
+
sep = '\n' + code_head
|
|
1139
|
+
# The calculation substring is the most complex part; we make it
|
|
1140
|
+
# out of the list of calculations and their inputs/outputs.
|
|
1141
|
+
calcstr = []
|
|
1142
|
+
for (name,c) in self.items():
|
|
1143
|
+
cname = c.base_function if c.name is None else c.name
|
|
1144
|
+
calcstr.append(f"* ``{name}``: ``{cname}`` ")
|
|
1145
|
+
if len(inputs) == 0:
|
|
1146
|
+
inps = "None"
|
|
1147
|
+
else:
|
|
1148
|
+
inps = textwrap.wrap('``'+'``, ``'.join(c.inputs)+'``', 68)
|
|
1149
|
+
inps = (' '*11).join(inps)
|
|
1150
|
+
calcstr.append(f" Inputs: {inps} ")
|
|
1151
|
+
if len(outputs) == 0:
|
|
1152
|
+
outs = "None"
|
|
1153
|
+
else:
|
|
1154
|
+
outs = textwrap.wrap('``'+'``, ``'.join(c.outputs)+'``', 68)
|
|
1155
|
+
outs = (' '*11).join(outs)
|
|
1156
|
+
calcstr.append(f" Outputs: {outs} ")
|
|
1157
|
+
calcstr = '\n'.join(calcstr)
|
|
1158
|
+
calcstr = reindent(
|
|
1159
|
+
calcstr, code_indent + 1,
|
|
1160
|
+
skip_first=False,
|
|
1161
|
+
final_endline=False)
|
|
1162
|
+
calcstr = calcstr[code_indent + 1:]
|
|
1163
|
+
# Make up the input and output strings too.
|
|
1164
|
+
inputstr = '\n'.join(
|
|
1165
|
+
[reindent(
|
|
1166
|
+
k + '\n' + s, code_indent,
|
|
1167
|
+
skip_first=False, final_endline=False)
|
|
1168
|
+
for (k,s) in input_docs.items()])
|
|
1169
|
+
outputstr = '\n'.join(
|
|
1170
|
+
[reindent(
|
|
1171
|
+
k + '\n' + s, code_indent,
|
|
1172
|
+
skip_first=False, final_endline=False)
|
|
1173
|
+
for (k,s) in output_docs.items()])
|
|
1174
|
+
if len(inputstr) > 0:
|
|
1175
|
+
inputstr = (
|
|
1176
|
+
f"{sep}Inputs{sep}"
|
|
1177
|
+
f"------\n"
|
|
1178
|
+
f"{inputstr}{sep}")
|
|
1179
|
+
if len(outputstr) > 0:
|
|
1180
|
+
outputstr = (
|
|
1181
|
+
f"{sep}Outputs{sep}"
|
|
1182
|
+
f"-------\n"
|
|
1183
|
+
f"{outputstr}{sep}")
|
|
1184
|
+
docstr = f"""An ``immlib.plan`` object for a set of calculations.
|
|
1185
|
+
|
|
1186
|
+
This documentation was generated automatically from the docstrings
|
|
1187
|
+
of the individual ``immlib.calc`` objects that make up this plan.
|
|
1188
|
+
|
|
1189
|
+
This plan contains the following calculations:
|
|
1190
|
+
{calcstr}
|
|
1191
|
+
{inputstr}{outputstr}"""
|
|
1192
|
+
docstr = reindent(docstr, 0, final_endline=False)
|
|
1193
|
+
object.__setattr__(self, 'docstr', docstr)
|
|
1194
|
+
object.__setattr__(self, '__doc__', docstr)
|
|
1195
|
+
# We now have a calculation ordering that we can use to turn the plan's
|
|
1196
|
+
# filtered values into sequential values. For example, if the variable
|
|
1197
|
+
# 'x' is filtered through calculations 'f', 'g', and 'h', in that
|
|
1198
|
+
# order, then we update the inputs/outputs of the functions to force an
|
|
1199
|
+
# ordering. First, the initial parameter will be renamed to 'x.', then
|
|
1200
|
+
# f is changed to take 'x.' as an input in place of 'x' and to produce
|
|
1201
|
+
# 'x.f'. Then g is changed so that it takes 'x.f' instead of x and
|
|
1202
|
+
# produces 'x.g'. Then h is changed so that it takes 'x.h' as instead
|
|
1203
|
+
# of 'x' and produces the output 'x'. The actual internal names don't
|
|
1204
|
+
# use periods (they stay as valid variable names that are potentially
|
|
1205
|
+
# randomly chosen using the _find_trname staticmethod).
|
|
1206
|
+
names = tuple(calcorder)
|
|
1207
|
+
calcs = tuple(self[k] for k in calcorder)
|
|
1208
|
+
if len(filters) == 0:
|
|
1209
|
+
# We don't actually have any filters to put in order, so we have
|
|
1210
|
+
# the straightforward job of wiring things up as-is. We already
|
|
1211
|
+
# know that there aren't any cycles in the graph (they would have
|
|
1212
|
+
# appeared earlier).
|
|
1213
|
+
(srcs, args) = plan._make_srcs_args(names, calcs, params)
|
|
1214
|
+
calcdata = plan.CalcData(names, calcs, args, srcs, calcidx)
|
|
1215
|
+
valsources = srcs
|
|
1216
|
+
tr = {}
|
|
1217
|
+
itr = {}
|
|
1218
|
+
else:
|
|
1219
|
+
# We need to do two things: (1) make a plan out of the unfiltered
|
|
1220
|
+
# calcs (i.e., separate inputs and outputs of each filter calc into
|
|
1221
|
+
# different variables and link them up across calculations), and
|
|
1222
|
+
# (2) make a translation for the output values.
|
|
1223
|
+
tr = {}
|
|
1224
|
+
itr = {}
|
|
1225
|
+
valnames = set(val2calc.keys())
|
|
1226
|
+
new_calcs = []
|
|
1227
|
+
for (nm,c) in zip(names, calcs):
|
|
1228
|
+
c = to_calc(c)
|
|
1229
|
+
filts = c.inputs & set(c.outputs)
|
|
1230
|
+
calctr = {}
|
|
1231
|
+
for f in filts:
|
|
1232
|
+
k = tr.get(f, f)
|
|
1233
|
+
new_k = plan._find_trname(valnames, k, nm)
|
|
1234
|
+
valnames.add(new_k)
|
|
1235
|
+
tr[f] = new_k
|
|
1236
|
+
itr[new_k] = f
|
|
1237
|
+
calctr[f] = (k, new_k)
|
|
1238
|
+
new_calcs.append(c.rename_keys(tr, calctr))
|
|
1239
|
+
(srcs, args) = plan._make_srcs_args(names, new_calcs, params)
|
|
1240
|
+
new_calcs = tuple(new_calcs)
|
|
1241
|
+
calcdata = plan.CalcData(names, new_calcs, args, srcs, calcidx)
|
|
1242
|
+
valsrcs = tdict()
|
|
1243
|
+
for k in valnames:
|
|
1244
|
+
valsrcs[k] = srcs[tr.get(k, k)]
|
|
1245
|
+
valsources = valsrcs.persistent()
|
|
1246
|
+
# Go through the calc ordering and find the earliest default value for
|
|
1247
|
+
# each of the keys.
|
|
1248
|
+
defaults = rmerge(
|
|
1249
|
+
*(c.defaults for c in map(to_calc, calcs) if c.defaults))
|
|
1250
|
+
# Note the requirements.
|
|
1251
|
+
requirements = pset(reqs)
|
|
1252
|
+
# One final thing we need to do is to make the dependants graph; this
|
|
1253
|
+
# is basically the graph of calculations and outputs that need to be
|
|
1254
|
+
# updated / reset any time a parameter is changed.
|
|
1255
|
+
depset = set()
|
|
1256
|
+
for (cidx,c) in enumerate(calcdata.calcs):
|
|
1257
|
+
c = to_calc(c)
|
|
1258
|
+
for k in c.inputs:
|
|
1259
|
+
depset.add((k, cidx))
|
|
1260
|
+
for k in c.outputs:
|
|
1261
|
+
depset.add((cidx, k))
|
|
1262
|
+
depgraph = plan._transitive_closure(depset)
|
|
1263
|
+
deps = tdict()
|
|
1264
|
+
for k in inputs:
|
|
1265
|
+
odeps = []
|
|
1266
|
+
cdeps = []
|
|
1267
|
+
for d in depgraph[k]:
|
|
1268
|
+
if isinstance(d, str):
|
|
1269
|
+
odeps.append(d)
|
|
1270
|
+
else:
|
|
1271
|
+
cdeps.append(d)
|
|
1272
|
+
# Translate to original key name (not dep key)
|
|
1273
|
+
k = itr.get(k, k)
|
|
1274
|
+
deps[k] = plan.DepData(tuple(odeps), tuple(cdeps))
|
|
1275
|
+
dependants = deps.persistent()
|
|
1276
|
+
# Now set all the variables, and we're done!
|
|
1277
|
+
object.__setattr__(self, 'inputs', inputs)
|
|
1278
|
+
object.__setattr__(self, 'outputs', outputs)
|
|
1279
|
+
object.__setattr__(self, 'defaults', defaults)
|
|
1280
|
+
object.__setattr__(self, 'requirements', requirements)
|
|
1281
|
+
object.__setattr__(self, 'input_docs', input_docs)
|
|
1282
|
+
object.__setattr__(self, 'output_docs', output_docs)
|
|
1283
|
+
object.__setattr__(self, 'calcdata', calcdata)
|
|
1284
|
+
object.__setattr__(self, 'valsources', valsources)
|
|
1285
|
+
object.__setattr__(self, 'dependants', dependants)
|
|
1286
|
+
# Methods -----------------------------------------------------------------
|
|
1287
|
+
def filtercall(self, *args, **kwargs):
|
|
1288
|
+
"""Calls the plan object, but filters out args that aren't in the plan.
|
|
1289
|
+
|
|
1290
|
+
``plan_obj.filtercall(dict1, dict2, ..., k1=v1, k2=v2, ...)`` is
|
|
1291
|
+
equivalent to ``plan_obj(dict1, dict2, ..., k1=v1, k2=v2, ...)`` except
|
|
1292
|
+
that any keys in the argument list to ``filtercall`` that aren't in the
|
|
1293
|
+
parameter list of ``plan_obj`` are automatically filtered out.
|
|
1294
|
+
"""
|
|
1295
|
+
params = merge(*args, **kwargs)
|
|
1296
|
+
for k in params.keys():
|
|
1297
|
+
if k not in self.inputs:
|
|
1298
|
+
params = params.delete(k)
|
|
1299
|
+
return self.__call__(params)
|
|
1300
|
+
def __call__(self, *args, **kwargs):
|
|
1301
|
+
# Make and return a plandict with these parameters.
|
|
1302
|
+
return plandict(self, *args, **kwargs)
|
|
1303
|
+
def __str__(self):
|
|
1304
|
+
n = len(self.calcdata.calcs)
|
|
1305
|
+
m = len(self.inputs)
|
|
1306
|
+
return f"plan(<{n} calcs>, <{m} params>)"
|
|
1307
|
+
def __repr__(self):
|
|
1308
|
+
n = len(self.calcdata.calcs)
|
|
1309
|
+
m = len(self.inputs)
|
|
1310
|
+
return f"plan(<{n} calcs>, <{m} params>)"
|
|
1311
|
+
@docwrap
|
|
1312
|
+
def is_plan(arg):
|
|
1313
|
+
"""Determines if an object is a ``plan`` instance.
|
|
1314
|
+
|
|
1315
|
+
``is_plan(x)`` returns ``True`` if ``x`` is a calculation ``plan`` and
|
|
1316
|
+
``False`` otherwise.
|
|
1317
|
+
"""
|
|
1318
|
+
return isinstance(arg, plan)
|
|
1319
|
+
|
|
1320
|
+
|
|
1321
|
+
# plandict ####################################################################
|
|
1322
|
+
|
|
1323
|
+
class plandict(ldict):
|
|
1324
|
+
"""A persistent dict type that manages the outputs of executing a plan.
|
|
1325
|
+
|
|
1326
|
+
``plandict(plan, params)`` instantiates a plan object with the given
|
|
1327
|
+
dict-like object of parameters, ``params``.
|
|
1328
|
+
|
|
1329
|
+
``plandict(plan, params, k1=v1, k2=v2, ...)`` additional merges all keyword
|
|
1330
|
+
arguments into parameters.
|
|
1331
|
+
|
|
1332
|
+
``plandict(plan, k1=v1, k2=v2, ...)`` uses only the keyword arguments as
|
|
1333
|
+
the plan parameters.
|
|
1334
|
+
|
|
1335
|
+
Note that ``plandict(plan, args...)`` is equivalent to ``plan(args...)``.
|
|
1336
|
+
|
|
1337
|
+
``plandict`` is a subclass of ``lazydict``, but it has some unique
|
|
1338
|
+
behavior, primarily in that only the parameters of a ``plandict`` may be
|
|
1339
|
+
updated; the rest of the items are consequences of the plan and parameter.
|
|
1340
|
+
|
|
1341
|
+
Parameters
|
|
1342
|
+
----------
|
|
1343
|
+
plan : plan
|
|
1344
|
+
The ``plan`` object that is to be instantiated.
|
|
1345
|
+
*params : dict-like, optional
|
|
1346
|
+
The dict-like object of the parameters of the ``plan``. All and only
|
|
1347
|
+
``plan`` parameters must be provided, after the ``params`` argument is
|
|
1348
|
+
merged with the ``kwargs`` options. This may be a ``lazydict``, and
|
|
1349
|
+
this dict's laziness is respected as much as possible.
|
|
1350
|
+
**kwargs : optional keywords
|
|
1351
|
+
Optional keywords that are merged into ``params`` to form the set of
|
|
1352
|
+
parameters for the plan.
|
|
1353
|
+
|
|
1354
|
+
Attributes
|
|
1355
|
+
----------
|
|
1356
|
+
plan : immlib.plan
|
|
1357
|
+
The plan object on which this plandict is based or alternatively a
|
|
1358
|
+
``plandict`` or object to copy.
|
|
1359
|
+
inputs : pdict
|
|
1360
|
+
The parameters that fulfill the plan. Note that these are the only keys
|
|
1361
|
+
in the ``plandict`` that can be updated using methods like ``set`` and
|
|
1362
|
+
``setdefault``.
|
|
1363
|
+
"""
|
|
1364
|
+
__slots__ = ('plan', 'inputs', '_calcdata', '_inputdata')
|
|
1365
|
+
def __new__(cls, *args, **kwargs):
|
|
1366
|
+
# There are two valid ways to call plandict(): plandict(planobj,
|
|
1367
|
+
# params) and plandict(plandictobj, new_params). We call different
|
|
1368
|
+
# classmethods for each version.
|
|
1369
|
+
if len(args) == 0:
|
|
1370
|
+
raise TypeError(
|
|
1371
|
+
"plandict() requires 1 argument that is a plan or plandict")
|
|
1372
|
+
(obj, args) = (args[0], args[1:])
|
|
1373
|
+
if is_plan(obj):
|
|
1374
|
+
pd = cls._new_from_plan(obj, *args, **kwargs)
|
|
1375
|
+
elif isinstance(obj, (plandict, tplandict)):
|
|
1376
|
+
pd = cls._new_from_plandict(obj, *args, **kwargs)
|
|
1377
|
+
else:
|
|
1378
|
+
raise TypeError(
|
|
1379
|
+
"plandict(obj, ...) requires that obj be a plan or plandict")
|
|
1380
|
+
return pd
|
|
1381
|
+
@classmethod
|
|
1382
|
+
def _new_plandict(cls, items, plan, params, calctup, inputtup):
|
|
1383
|
+
self = ldict.__new__(cls, items)
|
|
1384
|
+
# params needs to be a pdict (not a tdict)
|
|
1385
|
+
if isinstance(params, tdict):
|
|
1386
|
+
params = params.persistent()
|
|
1387
|
+
elif not isinstance(params, pdict):
|
|
1388
|
+
raise ValueError("plandict received non-pdict inputs")
|
|
1389
|
+
# And set our special member-values.
|
|
1390
|
+
object.__setattr__(self, 'plan', plan)
|
|
1391
|
+
object.__setattr__(self, 'inputs', params)
|
|
1392
|
+
object.__setattr__(self, '_calcdata', calctup)
|
|
1393
|
+
object.__setattr__(self, '_inputdata', inputtup)
|
|
1394
|
+
# At this point, the object should be entirely initialized, so we can
|
|
1395
|
+
# go ahead and run its required calculations.
|
|
1396
|
+
calcdata = plan.calcdata
|
|
1397
|
+
for r in plan.requirements:
|
|
1398
|
+
cidx = calcdata.index[r]
|
|
1399
|
+
calctup[cidx]()
|
|
1400
|
+
# That's all; just return the object.
|
|
1401
|
+
return self
|
|
1402
|
+
@classmethod
|
|
1403
|
+
def _new_from_plan(cls, plan, *args, **kwargs):
|
|
1404
|
+
# First, merge from left-to-right, respecting laziness. Then, run them
|
|
1405
|
+
# (lazily) through the filters.
|
|
1406
|
+
params = merge(plan.defaults, *args, **kwargs)
|
|
1407
|
+
given_params = set(params.keys())
|
|
1408
|
+
# Are we missing any parameters?
|
|
1409
|
+
missing_params = plan.inputs - given_params
|
|
1410
|
+
if len(missing_params) > 0:
|
|
1411
|
+
raise ValueError(f"missing inputs: {tuple(missing_params)}")
|
|
1412
|
+
# Do we have extra inputs?
|
|
1413
|
+
extra_params = given_params - plan.inputs
|
|
1414
|
+
if len(extra_params) > 0:
|
|
1415
|
+
raise ValueError(f"extra inputs: {tuple(extra_params)}")
|
|
1416
|
+
# Okay, we have the correct parameters. We can make the input tuple.
|
|
1417
|
+
inp = []
|
|
1418
|
+
pparams = holdlazy(params)
|
|
1419
|
+
for k in plan.inputs:
|
|
1420
|
+
v = pparams[k]
|
|
1421
|
+
if isinstance(v, lazy):
|
|
1422
|
+
inp.append(v)
|
|
1423
|
+
else:
|
|
1424
|
+
inp.append(lazy(identfn, v))
|
|
1425
|
+
inputtup = tuple(inp)
|
|
1426
|
+
# We can also make a lazy object per calc for the calctup.
|
|
1427
|
+
calctup = plan._make_calctup(plan.calcdata, inputtup)
|
|
1428
|
+
# Go ahead and do the initialization.
|
|
1429
|
+
items = ldict.empty.transient()
|
|
1430
|
+
for k in plan.inputs:
|
|
1431
|
+
src = plan.valsources[k]
|
|
1432
|
+
# If this input gets filtered, we need a lazy lookup:
|
|
1433
|
+
if isinstance(src, tuple):
|
|
1434
|
+
v = lazy(plan._source_lookup, inputtup, calctup, src)
|
|
1435
|
+
else:
|
|
1436
|
+
v = pparams[k]
|
|
1437
|
+
items[k] = v
|
|
1438
|
+
for k in plan.outputs:
|
|
1439
|
+
src = plan.valsources[k]
|
|
1440
|
+
items[k] = lazy(plan._source_lookup, inputtup, calctup, src)
|
|
1441
|
+
# We can now make and return the object (this also runs requirements).
|
|
1442
|
+
return cls._new_plandict(items, plan, params, calctup, inputtup)
|
|
1443
|
+
@classmethod
|
|
1444
|
+
def _new_from_plandict(cls, pd, *args, **kwargs):
|
|
1445
|
+
plan = pd.plan
|
|
1446
|
+
calcdata = plan.calcdata
|
|
1447
|
+
if len(args) == 0 and len(kwargs) == 0:
|
|
1448
|
+
if isinstance(pd, plandict):
|
|
1449
|
+
return pd
|
|
1450
|
+
else:
|
|
1451
|
+
return cls._new_plandict(
|
|
1452
|
+
pd, plan, pd.inputs, pd._calcdata, pd._inputdata)
|
|
1453
|
+
# First, merge from left-to-right, respecting laziness.
|
|
1454
|
+
param_updates = merge(*args, **kwargs)
|
|
1455
|
+
# There must only be parameters here.
|
|
1456
|
+
planins = plan.inputs
|
|
1457
|
+
if any(k not in planins for k in param_updates.keys()):
|
|
1458
|
+
extras = set(params_updates.keys()) - plan.inputs
|
|
1459
|
+
raise ValueError(f"unrecognized inputs: {tuple(extras)}")
|
|
1460
|
+
# Make a new inputtup, calctup, and updates to the items dict.
|
|
1461
|
+
if len(param_updates) == 0:
|
|
1462
|
+
items = pd
|
|
1463
|
+
inputtup = pd._inputdata
|
|
1464
|
+
calctup = pd._calcdata
|
|
1465
|
+
else:
|
|
1466
|
+
(inputtup, calctup, items) = plan._update_dictdata(
|
|
1467
|
+
pd._inputdata,
|
|
1468
|
+
pd._calcdata,
|
|
1469
|
+
param_updates)
|
|
1470
|
+
items = merge(pd, items)
|
|
1471
|
+
inputs = merge(pd.inputs, param_updates)
|
|
1472
|
+
return cls._new_plandict(items, plan, inputs, calctup, inputtup)
|
|
1473
|
+
def set(self, k, v):
|
|
1474
|
+
return plandict(self, {k:v})
|
|
1475
|
+
def setdefault(self, k, v=None):
|
|
1476
|
+
# All possible keys to set are already set in a plandict, so just pass
|
|
1477
|
+
# this through to set.
|
|
1478
|
+
return self.set(k, v)
|
|
1479
|
+
def delete(self, k):
|
|
1480
|
+
raise TypeError("cannot delete from a plandict")
|
|
1481
|
+
def transient(self):
|
|
1482
|
+
return tplandict(self)
|
|
1483
|
+
def __hash__(self):
|
|
1484
|
+
return hash(self.inputs) + hash(self.plan)
|
|
1485
|
+
|
|
1486
|
+
class tplandict(tldict):
|
|
1487
|
+
"""A transient dict type that follows an ``immlib`` plan.
|
|
1488
|
+
|
|
1489
|
+
See ``immlib.plandict`` and ``immlib.plan`` for more information about
|
|
1490
|
+
``immlib`` plans and workflows. The ``tplandict`` type is a transient
|
|
1491
|
+
counterpart to the persistent ``plandict`` type. A ``tplandict`` object can
|
|
1492
|
+
be created from a ``plandict`` object ``pd`` using either the syntax ``td =
|
|
1493
|
+
tplandict(pd)`` or ``td = pd.transient()``. The keys corresponding to the
|
|
1494
|
+
inputs of the plan can be changed in the resulting ``tplandict`` object,
|
|
1495
|
+
and the downstream outputs of the plan will be automatically updated as
|
|
1496
|
+
these are changed. A ``plandict`` can be recreated using either the syntax
|
|
1497
|
+
``plandict(td)`` or ``td.transient()``.
|
|
1498
|
+
|
|
1499
|
+
Parameters
|
|
1500
|
+
----------
|
|
1501
|
+
plan : plan
|
|
1502
|
+
The ``plan`` object that is to be instantiated or alternatively a
|
|
1503
|
+
``plandict`` or ``tplandict`` object to copy.
|
|
1504
|
+
*params : dict-like, optional
|
|
1505
|
+
The dict-like object of the parameters of the ``plan``. All and only
|
|
1506
|
+
``plan`` parameters must be provided, after the ``params`` argument is
|
|
1507
|
+
merged with the ``kwargs`` options. This may be a ``lazydict``, and
|
|
1508
|
+
this dict's laziness is respected as much as possible.
|
|
1509
|
+
**kwargs : optional keywords
|
|
1510
|
+
Optional keywords that are merged into ``params`` to form the set of
|
|
1511
|
+
parameters for the plan.
|
|
1512
|
+
|
|
1513
|
+
Attributes
|
|
1514
|
+
----------
|
|
1515
|
+
plan : plan
|
|
1516
|
+
The plan object on which this tplandict is based.
|
|
1517
|
+
inputs : pdict
|
|
1518
|
+
The parameters that fulfill the plan. Note that these are the only keys
|
|
1519
|
+
in the ``tplandict`` that can be updated directly.
|
|
1520
|
+
|
|
1521
|
+
"""
|
|
1522
|
+
__slots__ = ('plan', 'inputs', '_calcdata', '_inputdata')
|
|
1523
|
+
def __new__(cls, *args, **kwargs):
|
|
1524
|
+
# There are two valid ways to call plandict(): plandict(planobj,
|
|
1525
|
+
# params) and plandict(plandictobj, new_params). We call different
|
|
1526
|
+
# classmethods for each version.
|
|
1527
|
+
if len(args) == 0:
|
|
1528
|
+
raise TypeError(
|
|
1529
|
+
"tplandict() requires 1 argument that is a plan or plandict")
|
|
1530
|
+
(obj, args) = (args[0], args[1:])
|
|
1531
|
+
if is_plan(obj):
|
|
1532
|
+
pd = plandict(obj, *args, **kwargs)
|
|
1533
|
+
elif isinstance(obj, tplandict):
|
|
1534
|
+
pd = obj.persistent()
|
|
1535
|
+
else:
|
|
1536
|
+
pd = obj
|
|
1537
|
+
if not isinstance(obj, plandict):
|
|
1538
|
+
raise TypeError(
|
|
1539
|
+
"tplandict(obj, ...) requires that obj be a plan or plandict")
|
|
1540
|
+
plan = pd.plan
|
|
1541
|
+
calcdata = plan.calcdata
|
|
1542
|
+
# First, merge from left-to-right, respecting laziness.
|
|
1543
|
+
param_updates = merge(*args, **kwargs)
|
|
1544
|
+
# There must only be parameters here.
|
|
1545
|
+
planins = plan.inputs
|
|
1546
|
+
if any(k not in planins for k in param_updates.keys()):
|
|
1547
|
+
extras = set(params_updates.keys()) - plan.inputs
|
|
1548
|
+
raise ValueError(f"unrecognized inputs: {tuple(extras)}")
|
|
1549
|
+
# Make a new inputtup, calctup, and update the items dict.
|
|
1550
|
+
(inputtup, calctup, items) = plan._update_dictdata(
|
|
1551
|
+
pd._inputdata,
|
|
1552
|
+
pd._calcdata,
|
|
1553
|
+
param_updates)
|
|
1554
|
+
inputs = pd.inputs
|
|
1555
|
+
# Inputs stays a pdict because we don't want to allow the user to
|
|
1556
|
+
# update them directly.
|
|
1557
|
+
inputs = inputs.update(param_updates)
|
|
1558
|
+
items = merge(pd, items)
|
|
1559
|
+
return cls._new_tplandict(items, plan, inputs, calctup, inputtup)
|
|
1560
|
+
@classmethod
|
|
1561
|
+
def _new_tplandict(cls, items, plan, params, calctup, inputtup):
|
|
1562
|
+
# We make a new tplandict, but we don't initialize items here--we do
|
|
1563
|
+
# that below.
|
|
1564
|
+
self = tldict.__new__(cls)
|
|
1565
|
+
# params needs to be a tdict (not a pdict)
|
|
1566
|
+
if isinstance(params, tdict):
|
|
1567
|
+
params = params.persistent()
|
|
1568
|
+
elif not isinstance(params, pdict):
|
|
1569
|
+
raise ValueError("tplandict received non-pdict inputs")
|
|
1570
|
+
# And set our special member-values.
|
|
1571
|
+
object.__setattr__(self, 'plan', plan)
|
|
1572
|
+
object.__setattr__(self, 'inputs', params)
|
|
1573
|
+
object.__setattr__(self, '_calcdata', calctup)
|
|
1574
|
+
object.__setattr__(self, '_inputdata', inputtup)
|
|
1575
|
+
# Now we add items.
|
|
1576
|
+
for (k,v) in holdlazy(items).items():
|
|
1577
|
+
tldict.__setitem__(self, k, v)
|
|
1578
|
+
# At this point, the object should be entirely initialized, so we can
|
|
1579
|
+
# go ahead and run its required calculations.
|
|
1580
|
+
calcdata = plan.calcdata
|
|
1581
|
+
for r in plan.requirements:
|
|
1582
|
+
cidx = calcdata.index[r]
|
|
1583
|
+
calctup[cidx]()
|
|
1584
|
+
# That's all; just return the object.
|
|
1585
|
+
return self
|
|
1586
|
+
def __setitem__(self, k, v):
|
|
1587
|
+
vv = holdlazy(self.inputs).get(k, self)
|
|
1588
|
+
if vv is self:
|
|
1589
|
+
raise ValueError(f"cannot set non-input key in tplandict: {k}")
|
|
1590
|
+
elif vv is v:
|
|
1591
|
+
# No change; just return.
|
|
1592
|
+
return
|
|
1593
|
+
plan = self.plan
|
|
1594
|
+
# First, we reset any calculation that relies on this value and update
|
|
1595
|
+
# the calctup / inputtup and items.
|
|
1596
|
+
(inputtup, calctup, items) = plan._update_dictdata(
|
|
1597
|
+
self._inputdata,
|
|
1598
|
+
self._calcdata,
|
|
1599
|
+
{k:v})
|
|
1600
|
+
# Next, we next need to reset the downstream items.
|
|
1601
|
+
for kk in items.keys():
|
|
1602
|
+
tldict.__setitem__(self, kk, items.getlazy(kk))
|
|
1603
|
+
# Before we return, we change the object's values.
|
|
1604
|
+
object.__setattr__(self, 'inputs', self.inputs.set(k, v))
|
|
1605
|
+
object.__setattr__(self, '_inputdata', inputtup)
|
|
1606
|
+
object.__setattr__(self, '_calcdata', calctup)
|
|
1607
|
+
# Finally, we need to rerun any requirements that were changed.
|
|
1608
|
+
calcdataidx = plan.calcdata.index
|
|
1609
|
+
for r in plan.requirements:
|
|
1610
|
+
cidx = calcdataidx[r]
|
|
1611
|
+
calctup[cidx]()
|
|
1612
|
+
def __delitem__(self, k):
|
|
1613
|
+
raise TypeError("cannot delete items from tplandict objects")
|
|
1614
|
+
def setdefault(self, k, default=None, /):
|
|
1615
|
+
# All possible keys to set are already set in a plandict, so just
|
|
1616
|
+
# return the current value of key k
|
|
1617
|
+
return self[k]
|
|
1618
|
+
def persistent(self):
|
|
1619
|
+
return plandict(self)
|
|
1620
|
+
@docwrap
|
|
1621
|
+
def is_plandict(arg):
|
|
1622
|
+
"""Determines if an object is a ``plandict`` instance.
|
|
1623
|
+
|
|
1624
|
+
``is_plandict(x)`` returns ``True`` if ``x`` is a ``plandict`` object and
|
|
1625
|
+
``False`` otherwise.
|
|
1626
|
+
"""
|
|
1627
|
+
return isinstance(arg, plandict)
|
|
1628
|
+
@docwrap
|
|
1629
|
+
def is_tplandict(arg):
|
|
1630
|
+
"""Determines if an object is a ``tplandict`` instance.
|
|
1631
|
+
|
|
1632
|
+
``is_tplandict(x)`` returns ``True`` if ``x`` is a ``tplandict`` object and
|
|
1633
|
+
``False`` otherwise.
|
|
1634
|
+
"""
|
|
1635
|
+
return isinstance(arg, tplandict)
|