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.
Files changed (45) hide show
  1. immlib/__init__.py +131 -0
  2. immlib/_init.py +108 -0
  3. immlib/_version.py +235 -0
  4. immlib/doc/__init__.py +38 -0
  5. immlib/doc/_core.py +311 -0
  6. immlib/iolib/__init__.py +29 -0
  7. immlib/iolib/_core.py +720 -0
  8. immlib/pathlib/__init__.py +69 -0
  9. immlib/pathlib/_cache.py +152 -0
  10. immlib/pathlib/_core.py +869 -0
  11. immlib/pathlib/_osf.py +538 -0
  12. immlib/test/__init__.py +16 -0
  13. immlib/test/__main__.py +10 -0
  14. immlib/test/doc/__init__.py +6 -0
  15. immlib/test/doc/test_core.py +91 -0
  16. immlib/test/iolib/__init__.py +7 -0
  17. immlib/test/iolib/test_core.py +81 -0
  18. immlib/test/pathlib/__init__.py +11 -0
  19. immlib/test/pathlib/test_core.py +146 -0
  20. immlib/test/pathlib/test_osf.py +54 -0
  21. immlib/test/types/__init__.py +5 -0
  22. immlib/test/types/test_core.py +110 -0
  23. immlib/test/util/__init__.py +11 -0
  24. immlib/test/util/test_core.py +681 -0
  25. immlib/test/util/test_numeric.py +1374 -0
  26. immlib/test/util/test_quantity.py +218 -0
  27. immlib/test/util/test_url.py +51 -0
  28. immlib/test/workflow/__init__.py +9 -0
  29. immlib/test/workflow/test_core.py +418 -0
  30. immlib/test/workflow/test_plantype.py +248 -0
  31. immlib/types/__init__.py +29 -0
  32. immlib/types/_core.py +333 -0
  33. immlib/util/__init__.py +283 -0
  34. immlib/util/_core.py +2524 -0
  35. immlib/util/_numeric.py +2651 -0
  36. immlib/util/_quantity.py +523 -0
  37. immlib/util/_url.py +114 -0
  38. immlib/workflow/__init__.py +48 -0
  39. immlib/workflow/_core.py +1635 -0
  40. immlib/workflow/_plantype.py +334 -0
  41. immlib-1.0.0.dev2.dist-info/METADATA +76 -0
  42. immlib-1.0.0.dev2.dist-info/RECORD +45 -0
  43. immlib-1.0.0.dev2.dist-info/WHEEL +5 -0
  44. immlib-1.0.0.dev2.dist-info/licenses/LICENSE +21 -0
  45. immlib-1.0.0.dev2.dist-info/top_level.txt +1 -0
@@ -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)