experimaestro 1.6.1__py3-none-any.whl → 1.15.2__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 (98) hide show
  1. experimaestro/__init__.py +14 -3
  2. experimaestro/annotations.py +13 -3
  3. experimaestro/cli/filter.py +19 -5
  4. experimaestro/cli/jobs.py +12 -5
  5. experimaestro/commandline.py +3 -7
  6. experimaestro/connectors/__init__.py +27 -12
  7. experimaestro/connectors/local.py +19 -10
  8. experimaestro/connectors/ssh.py +1 -1
  9. experimaestro/core/arguments.py +35 -3
  10. experimaestro/core/callbacks.py +52 -0
  11. experimaestro/core/context.py +8 -9
  12. experimaestro/core/identifier.py +301 -0
  13. experimaestro/core/objects/__init__.py +44 -0
  14. experimaestro/core/{objects.py → objects/config.py} +364 -716
  15. experimaestro/core/objects/config_utils.py +58 -0
  16. experimaestro/core/objects/config_walk.py +151 -0
  17. experimaestro/core/objects.pyi +15 -45
  18. experimaestro/core/serialization.py +63 -9
  19. experimaestro/core/serializers.py +1 -8
  20. experimaestro/core/types.py +61 -6
  21. experimaestro/experiments/cli.py +79 -29
  22. experimaestro/experiments/configuration.py +3 -0
  23. experimaestro/generators.py +6 -1
  24. experimaestro/ipc.py +4 -1
  25. experimaestro/launcherfinder/parser.py +8 -3
  26. experimaestro/launcherfinder/registry.py +29 -10
  27. experimaestro/launcherfinder/specs.py +49 -10
  28. experimaestro/launchers/slurm/base.py +51 -13
  29. experimaestro/mkdocs/__init__.py +1 -1
  30. experimaestro/notifications.py +2 -1
  31. experimaestro/run.py +3 -1
  32. experimaestro/scheduler/base.py +114 -6
  33. experimaestro/scheduler/dynamic_outputs.py +184 -0
  34. experimaestro/scheduler/state.py +75 -0
  35. experimaestro/scheduler/workspace.py +2 -1
  36. experimaestro/scriptbuilder.py +13 -2
  37. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  38. experimaestro/server/data/1815e00441357e01619e.ttf +0 -0
  39. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  40. experimaestro/server/data/2463b90d9a316e4e5294.woff2 +0 -0
  41. experimaestro/server/data/2582b0e4bcf85eceead0.ttf +0 -0
  42. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  43. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  44. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  45. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  46. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  47. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  48. experimaestro/server/data/89999bdf5d835c012025.woff2 +0 -0
  49. experimaestro/server/data/914997e1bdfc990d0897.ttf +0 -0
  50. experimaestro/server/data/c210719e60948b211a12.woff2 +0 -0
  51. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  52. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  53. experimaestro/server/data/favicon.ico +0 -0
  54. experimaestro/server/data/index.css +22963 -0
  55. experimaestro/server/data/index.css.map +1 -0
  56. experimaestro/server/data/index.html +27 -0
  57. experimaestro/server/data/index.js +101770 -0
  58. experimaestro/server/data/index.js.map +1 -0
  59. experimaestro/server/data/login.html +22 -0
  60. experimaestro/server/data/manifest.json +15 -0
  61. experimaestro/settings.py +2 -2
  62. experimaestro/sphinx/__init__.py +7 -17
  63. experimaestro/taskglobals.py +7 -2
  64. experimaestro/tests/core/__init__.py +0 -0
  65. experimaestro/tests/core/test_generics.py +206 -0
  66. experimaestro/tests/definitions_types.py +5 -3
  67. experimaestro/tests/launchers/bin/sbatch +34 -7
  68. experimaestro/tests/launchers/bin/srun +5 -0
  69. experimaestro/tests/launchers/common.py +16 -4
  70. experimaestro/tests/restart.py +9 -4
  71. experimaestro/tests/tasks/all.py +23 -10
  72. experimaestro/tests/tasks/foreign.py +2 -4
  73. experimaestro/tests/test_dependencies.py +0 -6
  74. experimaestro/tests/test_experiment.py +73 -0
  75. experimaestro/tests/test_findlauncher.py +11 -4
  76. experimaestro/tests/test_forward.py +5 -5
  77. experimaestro/tests/test_generators.py +93 -0
  78. experimaestro/tests/test_identifier.py +114 -99
  79. experimaestro/tests/test_instance.py +6 -21
  80. experimaestro/tests/test_objects.py +20 -4
  81. experimaestro/tests/test_param.py +60 -22
  82. experimaestro/tests/test_serializers.py +24 -64
  83. experimaestro/tests/test_tags.py +5 -11
  84. experimaestro/tests/test_tasks.py +10 -23
  85. experimaestro/tests/test_tokens.py +3 -2
  86. experimaestro/tests/test_types.py +20 -17
  87. experimaestro/tests/test_validation.py +48 -91
  88. experimaestro/tokens.py +16 -5
  89. experimaestro/typingutils.py +8 -8
  90. experimaestro/utils/asyncio.py +6 -2
  91. experimaestro/utils/multiprocessing.py +44 -0
  92. experimaestro/utils/resources.py +7 -3
  93. {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info}/METADATA +27 -34
  94. experimaestro-1.15.2.dist-info/RECORD +159 -0
  95. {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info}/WHEEL +1 -1
  96. experimaestro-1.6.1.dist-info/RECORD +0 -122
  97. {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info}/entry_points.txt +0 -0
  98. {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info/licenses}/LICENSE +0 -0
@@ -1,29 +1,24 @@
1
1
  """Configuration and tasks"""
2
2
 
3
- from functools import cached_property
4
3
  import json
5
4
 
6
- try:
7
- from types import NoneType
8
- except Exception:
9
- # compatibility: python-3.8
10
- NoneType = type(None)
5
+ from attr import define
6
+ import fasteners
7
+
8
+ from experimaestro import taskglobals
9
+
11
10
  from termcolor import cprint
12
- import os
13
11
  from pathlib import Path
14
- import hashlib
15
12
  import logging
16
- import struct
17
13
  import io
18
- import fasteners
19
14
  from enum import Enum
20
15
  import inspect
21
16
  import importlib
22
17
  from typing import (
23
18
  Any,
19
+ Callable,
24
20
  ClassVar,
25
21
  Dict,
26
- Iterator,
27
22
  List,
28
23
  Optional,
29
24
  Set,
@@ -37,301 +32,30 @@ from typing import (
37
32
  import sys
38
33
  import experimaestro
39
34
  from experimaestro.utils import logger
40
- from contextlib import contextmanager
41
- from experimaestro.core.types import DeprecatedAttribute, ObjectType
42
- from .context import SerializationContext, SerializedPath, SerializedPathLoader
35
+ from experimaestro.core.types import DeprecatedAttribute, ObjectType, TypeVarType
36
+ from ..context import SerializationContext, SerializedPath, SerializedPathLoader
43
37
 
44
38
  if TYPE_CHECKING:
39
+ from ..callbacks import TaskEventListener
40
+ from ..identifier import Identifier
45
41
  from experimaestro.scheduler.base import Job
46
42
  from experimaestro.scheduler.workspace import RunMode
47
43
  from experimaestro.launchers import Launcher
48
44
  from experimaestro.scheduler import Workspace
49
45
 
50
- T = TypeVar("T", bound="Config")
51
-
52
-
53
- class Identifier:
54
- def __init__(self, main: bytes):
55
- self.main = main
56
- self.has_loops = False
57
-
58
- @cached_property
59
- def all(self):
60
- """Returns the overall identifier"""
61
- return self.main
62
-
63
- def __hash__(self) -> int:
64
- return hash(self.main)
65
-
66
- def state_dict(self):
67
- return self.main.hex()
68
-
69
- def __eq__(self, other: "Identifier"):
70
- return self.main == other.main
71
-
72
- @staticmethod
73
- def from_state_dict(data: Union[Dict[str, str], str]):
74
- if isinstance(data, str):
75
- return Identifier(bytes.fromhex(data))
76
-
77
- return Identifier(bytes.fromhex(data["main"]))
78
-
79
- def __repr__(self):
80
- return self.main.hex()
81
-
82
-
83
- def is_ignored(value):
84
- """Returns True if the value should be ignored by itself"""
85
- return value is not None and isinstance(value, Config) and (value.__xpm__.meta)
86
-
87
-
88
- def remove_meta(value):
89
- """Cleanup a dict/list by removing ignored values"""
90
- if isinstance(value, list):
91
- return [el for el in value if not is_ignored(el)]
92
- if isinstance(value, dict):
93
- return {key: value for key, value in value.items() if not is_ignored(value)}
94
- return value
95
-
96
-
97
- class ConfigPath:
98
- """Used to keep track of cycles when computing a hash"""
99
-
100
- def __init__(self):
101
- self.loops: List[bool] = []
102
- """Indicates whether a loop was detected up to this node"""
103
-
104
- self.config2index = {}
105
- """Associate an index in the list with a configuration"""
106
-
107
- def detect_loop(self, config) -> Optional[int]:
108
- """If there is a loop, return the relative index and update the path"""
109
- index = self.config2index.get(id(config), None)
110
- if index is not None:
111
- for i in range(index, self.depth):
112
- self.loops[i] = True
113
- return self.depth - index
114
-
115
- def has_loop(self):
116
- return self.loops[-1]
117
-
118
- @property
119
- def depth(self):
120
- return len(self.loops)
121
-
122
- @contextmanager
123
- def push(self, config):
124
- config_id = id(config)
125
- assert config_id not in self.config2index
126
-
127
- self.config2index[config_id] = self.depth
128
- self.loops.append(False)
129
-
130
- try:
131
- yield
132
- finally:
133
- self.loops.pop()
134
- del self.config2index[config_id]
135
-
136
-
137
- hash_logger = logging.getLogger("xpm.hash")
138
-
139
-
140
- class HashComputer:
141
- """This class is in charge of computing a config/task identifier"""
142
-
143
- OBJECT_ID = b"\x00"
144
- INT_ID = b"\x01"
145
- FLOAT_ID = b"\x02"
146
- STR_ID = b"\x03"
147
- PATH_ID = b"\x04"
148
- NAME_ID = b"\x05"
149
- NONE_ID = b"\x06"
150
- LIST_ID = b"\x07"
151
- TASK_ID = b"\x08"
152
- DICT_ID = b"\x09"
153
- ENUM_ID = b"\x0a"
154
- CYCLE_REFERENCE = b"\x0b"
155
- INIT_TASKS = b"\x0c"
156
-
157
- def __init__(self, config: "Config", config_path: ConfigPath, *, version=None):
158
- # Hasher for parameters
159
- self._hasher = hashlib.sha256()
160
- self.config = config
161
- self.config_path = config_path
162
- self.version = version or int(os.environ.get("XPM_HASH_COMPUTER", 2))
163
- if hash_logger.isEnabledFor(logging.DEBUG):
164
- hash_logger.debug(
165
- "starting hash (%s): %s", hash(str(self.config)), self.config
166
- )
167
-
168
- def identifier(self) -> Identifier:
169
- main = self._hasher.digest()
170
- if hash_logger.isEnabledFor(logging.DEBUG):
171
- hash_logger.debug("hash (%s): %s", hash(str(self.config)), str(main))
172
- return Identifier(main)
173
-
174
- def _hashupdate(self, bytes: bytes):
175
- """Update the hash computers with some bytes"""
176
- if hash_logger.isEnabledFor(logging.DEBUG):
177
- hash_logger.debug(
178
- "updating hash (%s): %s", hash(str(self.config)), str(bytes)
179
- )
180
- self._hasher.update(bytes)
181
-
182
- def update(self, value, *, myself=False): # noqa: C901
183
- """Update the hash
184
-
185
- :param value: The value to add to the hash
186
- :param myself: True if the value is the configuration for which we wish
187
- to compute the identifier, defaults to False
188
- :raises NotImplementedError: If the value cannot be processed
189
- """
190
- if value is None:
191
- self._hashupdate(HashComputer.NONE_ID)
192
- elif isinstance(value, float):
193
- self._hashupdate(HashComputer.FLOAT_ID)
194
- self._hashupdate(struct.pack("!d", value))
195
- elif isinstance(value, int):
196
- self._hashupdate(HashComputer.INT_ID)
197
- self._hashupdate(struct.pack("!q", value))
198
- elif isinstance(value, str):
199
- self._hashupdate(HashComputer.STR_ID)
200
- self._hashupdate(value.encode("utf-8"))
201
- elif isinstance(value, list):
202
- values = [el for el in value if not is_ignored(el)]
203
- self._hashupdate(HashComputer.LIST_ID)
204
- self._hashupdate(struct.pack("!d", len(values)))
205
- for x in values:
206
- self.update(x)
207
- elif isinstance(value, Enum):
208
- self._hashupdate(HashComputer.ENUM_ID)
209
- k = value.__class__
210
- self._hashupdate(
211
- f"{k.__module__}.{k.__qualname__ }:{value.name}".encode("utf-8"),
212
- )
213
- elif isinstance(value, dict):
214
- self._hashupdate(HashComputer.DICT_ID)
215
- items = [
216
- (key, value) for key, value in value.items() if not is_ignored(value)
217
- ]
218
- items.sort(key=lambda x: x[0])
219
- for key, value in items:
220
- self.update(key)
221
- self.update(value)
222
-
223
- # Handles configurations
224
- elif isinstance(value, Config):
225
- # Encodes the identifier
226
- self._hashupdate(HashComputer.OBJECT_ID)
227
-
228
- # If we encode another config, then
229
- if not myself:
230
- if loop_ix := self.config_path.detect_loop(value):
231
- # Loop detected: use cycle reference
232
- self._hashupdate(HashComputer.CYCLE_REFERENCE)
233
- self._hashupdate(struct.pack("!q", loop_ix))
234
-
235
- else:
236
- # Just use the object identifier
237
- value_id = HashComputer.compute(
238
- value, version=self.version, config_path=self.config_path
239
- )
240
- self._hashupdate(value_id.all)
241
-
242
- # And that's it!
243
- return
244
-
245
- # Process tasks
246
- if value.__xpm__.task is not None and (value.__xpm__.task is not value):
247
- hash_logger.debug("Computing hash for task %s", value.__xpm__.task)
248
- self._hashupdate(HashComputer.TASK_ID)
249
- self.update(value.__xpm__.task)
250
-
251
- xpmtype = value.__xpmtype__
252
- self._hashupdate(xpmtype.identifier.name.encode("utf-8"))
253
-
254
- # Process arguments (sort by name to ensure uniqueness)
255
- arguments = sorted(xpmtype.arguments.values(), key=lambda a: a.name)
256
- for argument in arguments:
257
- # Ignored argument
258
- if argument.ignored:
259
- argvalue = value.__xpm__.values.get(argument.name, None)
260
-
261
- # ... unless meta is set to false
262
- if (
263
- argvalue is None
264
- or not isinstance(argvalue, Config)
265
- or (argvalue.__xpm__.meta is not False)
266
- ):
267
- continue
268
-
269
- if argument.generator:
270
- continue
271
-
272
- # Argument value
273
- # Skip if the argument is not a constant, and
274
- # - optional argument: both value and default are None
275
- # - the argument value is equal to the default value
276
- argvalue = getattr(value, argument.name, None)
277
- if not argument.constant and (
278
- (
279
- not argument.required
280
- and argument.default is None
281
- and argvalue is None
282
- )
283
- or (
284
- argument.default is not None
285
- and argument.default == remove_meta(argvalue)
286
- )
287
- ):
288
- # No update if same value (and not constant)
289
- continue
290
-
291
- if (
292
- argvalue is not None
293
- and isinstance(argvalue, Config)
294
- and argvalue.__xpm__.meta
295
- ):
296
- continue
297
-
298
- # Hash name
299
- self.update(argument.name)
300
-
301
- # Hash value
302
- self._hashupdate(HashComputer.NAME_ID)
303
- self.update(argvalue)
304
-
305
- else:
306
- raise NotImplementedError("Cannot compute hash of type %s" % type(value))
307
-
308
- @staticmethod
309
- def compute(
310
- config: "Config", config_path: ConfigPath = None, version=None
311
- ) -> Identifier:
312
- """Compute the identifier for a configuration
313
-
314
- :param config: the configuration for which we compute the identifier
315
- :param config_path: used to track down cycles between configurations
316
- :param version: version for the hash computation (None for the last one)
317
- """
318
-
319
- # Try to use the cached value first
320
- # (if there are no loops)
321
- if config.__xpm__._sealed:
322
- identifier = config.__xpm__._raw_identifier
323
- if identifier is not None and not identifier.has_loops:
324
- return identifier
46
+ from .config_walk import ConfigWalk, ConfigWalkContext
47
+ from .config_utils import (
48
+ getqualattr,
49
+ add_to_path,
50
+ TaggedValue,
51
+ ObjectStore,
52
+ classproperty,
53
+ )
325
54
 
326
- config_path = config_path or ConfigPath()
55
+ T = TypeVar("T", bound="Config")
327
56
 
328
- with config_path.push(config):
329
- self = HashComputer(config, config_path, version=version)
330
- self.update(config, myself=True)
331
- identifier = self.identifier()
332
- identifier.has_loop = config_path.has_loop()
333
57
 
334
- return identifier
58
+ DependentMarker = Callable[["Config"], None]
335
59
 
336
60
 
337
61
  def updatedependencies(
@@ -362,197 +86,25 @@ def updatedependencies(
362
86
  raise NotImplementedError("update dependencies for type %s" % type(value))
363
87
 
364
88
 
365
- class SealedError(Exception):
366
- """Exception when trying to modify a sealed configuration"""
367
-
368
- pass
369
-
370
-
371
- class TaggedValue:
372
- def __init__(self, value):
373
- self.value = value
374
-
375
-
376
- @contextmanager
377
- def add_to_path(p):
378
- """Temporarily add a path to sys.path"""
379
- import sys
380
-
381
- old_path = sys.path
382
- sys.path = sys.path[:]
383
- sys.path.insert(0, p)
384
- try:
385
- yield
386
- finally:
387
- sys.path = old_path
388
-
389
-
390
- class ConfigWalkContext:
391
- """Context when generating values in configurations"""
392
-
393
- @property
394
- def path(self):
395
- """Returns the path of the job directory"""
396
- raise NotImplementedError()
397
-
398
- def __init__(self):
399
- self._configpath = None
400
-
401
- @property
402
- def task(self):
403
- return None
404
-
405
- def currentpath(self) -> Path:
406
- """Returns the configuration folder"""
407
- if self._configpath:
408
- return self.path / self._configpath
409
- return self.path
410
-
411
- @contextmanager
412
- def push(self, key: str):
413
- """Push a new key to contextualize paths"""
414
- p = self._configpath
415
- try:
416
- self._configpath = (Path("out") if p is None else p) / key
417
- yield key
418
- finally:
419
- self._configpath = p
420
-
421
-
422
89
  NOT_SET = object()
423
90
 
424
91
 
425
- class ConfigWalk:
426
- """Allows to perform an operation on all nested configurations"""
427
-
428
- def __init__(self, context: ConfigWalkContext = None, recurse_task=False):
429
- """
430
-
431
- :param recurse_task: Recurse into linked tasks
432
- :param context: The context, by default only tracks the position in the
433
- config tree
434
- """
435
- self.recurse_task = recurse_task
436
- self.context = ConfigWalkContext() if context is None else context
437
-
438
- # Stores already visited nodes
439
- self.visited = {}
440
-
441
- def preprocess(self, config: "Config") -> Tuple[bool, Any]:
442
- """Returns a tuple boolean/value
443
-
444
- The boolean value is used to stop the processing if False.
445
- The value is returned
446
- """
447
- return True, None
448
-
449
- def postprocess(self, stub, config: "Config", values: Dict[str, Any]):
450
- return stub
451
-
452
- def list(self, i: int):
453
- return self.context.push(str(i))
454
-
455
- def map(self, k: str):
456
- return self.context.push(k)
457
-
458
- def stub(self, config: "Config"):
459
- return config
460
-
461
- def __call__(self, x):
462
- if isinstance(x, Config):
463
- info = x.__xpm__ # type: ConfigInformation
464
-
465
- # Avoid loops
466
- xid = id(x)
467
- if xid in self.visited:
468
- return self.visited[xid]
469
-
470
- # Get a stub
471
- stub = self.stub(x)
472
- self.visited[xid] = stub
473
-
474
- # Pre-process
475
- flag, value = self.preprocess(x)
476
-
477
- if not flag:
478
- # Stop processing and returns value
479
- return value
480
-
481
- # Process all the arguments
482
- result = {}
483
- for arg, v in info.xpmvalues():
484
- if v is not None:
485
- with self.map(arg.name):
486
- result[arg.name] = self(v)
487
- else:
488
- result[arg.name] = None
489
-
490
- # Deals with pre-tasks
491
- if info.pre_tasks:
492
- with self.map("__pre_tasks__"):
493
- self(info.pre_tasks)
92
+ @define()
93
+ class WatchedOutput:
94
+ #: The enclosing job
95
+ job: "Job"
494
96
 
495
- if info.init_tasks:
496
- with self.map("__init_tasks__"):
497
- self(info.init_tasks)
97
+ #: The configuration containing the watched output
98
+ config: "ConfigInformation"
498
99
 
499
- # Process task if different
500
- if (
501
- x.__xpm__.task is not None
502
- and self.recurse_task
503
- and x.__xpm__.task is not x
504
- ):
505
- self(x.__xpm__.task)
100
+ #: The watched output (name)
101
+ method_name: str
506
102
 
507
- processed = self.postprocess(stub, x, result)
508
- self.visited[xid] = processed
509
- return processed
103
+ #: The watched output method (called with the JSON event)
104
+ method: Callable
510
105
 
511
- if isinstance(x, list):
512
- result = []
513
- for i, sv in enumerate(x):
514
- with self.list(i):
515
- result.append(self(sv))
516
- return result
517
-
518
- if isinstance(x, dict):
519
- result = {}
520
- for key, value in x.items():
521
- assert isinstance(key, (str, float, int))
522
- with self.map(key):
523
- result[key] = self(value)
524
- return result
525
-
526
- if isinstance(x, (float, int, str, Path, Enum)):
527
- return x
528
-
529
- raise NotImplementedError(f"Cannot handle a value of type {type(x)}")
530
-
531
-
532
- def getqualattr(module, qualname):
533
- """Get a qualified attributed value"""
534
- cls = module
535
- for part in qualname.split("."):
536
- cls = getattr(cls, part)
537
- return cls
538
-
539
-
540
- class ObjectStore:
541
- def __init__(self):
542
- self.store: Dict[int, Any] = {}
543
- self.constructed: Set[int] = set()
544
-
545
- def set_constructed(self, identifier: int):
546
- self.constructed.add(identifier)
547
-
548
- def is_constructed(self, identifier: int):
549
- return identifier in self.constructed
550
-
551
- def retrieve(self, identifier: int):
552
- return self.store.get(identifier, None)
553
-
554
- def add_stub(self, identifier: int, stub: Any):
555
- self.store[identifier] = stub
106
+ #: The callback to call (with the output of the previous method)
107
+ callback: Callable
556
108
 
557
109
 
558
110
  class ConfigInformation:
@@ -564,14 +116,14 @@ class ConfigInformation:
564
116
  # Set to true when loading from JSON
565
117
  LOADING: ClassVar[bool] = False
566
118
 
567
- def __init__(self, pyobject: "TypeConfig"):
119
+ def __init__(self, pyobject: "ConfigMixin"):
568
120
  # The underlying pyobject and XPM type
569
121
  self.pyobject = pyobject
570
- self.xpmtype = pyobject.__xpmtype__ # type: ObjectType
122
+ self.xpmtype: "ObjectType" = pyobject.__xpmtype__
571
123
  self.values = {}
572
124
 
573
125
  # Meta-informations
574
- self._tags = {}
126
+ self._tags: dict[str, Any] = {}
575
127
  self._initinfo = ""
576
128
 
577
129
  self._taskoutput = None
@@ -582,31 +134,61 @@ class ConfigInformation:
582
134
 
583
135
  # State information
584
136
  self.job = None
137
+ self._job_listener: "TaskEventListener" | None = None
585
138
 
586
139
  #: True when this configuration was loaded from disk
587
140
  self.loaded = False
588
141
 
589
- # Explicitely added dependencies
142
+ # Explicitly added dependencies
590
143
  self.dependencies = []
591
144
 
592
- # Lightweight tasks
593
- self.pre_tasks: List["LightweightTask"] = []
145
+ # Concrete type variables resolutions
146
+ # This is used to check typevars coherence
147
+ self.concrete_typevars: Dict[TypeVar, type] = {}
594
148
 
595
149
  # Initialization tasks
596
150
  self.init_tasks: List["LightweightTask"] = []
597
151
 
598
- # Cached information
152
+ # Watched outputs
153
+ self.watched_outputs: List[WatchedOutput] = []
599
154
 
600
- self._full_identifier = None
601
- """The full identifier (with pre-tasks)"""
155
+ # Cached information
602
156
 
603
- self._raw_identifier = None
604
- """The identifier without taking into account pre-tasks"""
157
+ self._identifier = None
158
+ """The configuration identifier (cached when sealed)"""
605
159
 
606
160
  self._validated = False
607
161
  self._sealed = False
608
162
  self._meta = None
609
163
 
164
+ # This contains the list of generated values (using context) in this
165
+ # configuration or any sub-configuration, is generated. This prevents
166
+ # problem when a configuration with generated values is re-used.
167
+ self._generated_values = []
168
+
169
+ def get_generated_paths(
170
+ self, path: list[str] = None, paths: list[str] = None
171
+ ) -> list[str]:
172
+ """Get the list of generated paths, useful to track down those
173
+
174
+ :param path: The current path
175
+ :param paths: The list of generated paths so far, defaults to None
176
+ :return: The full list of generated paths
177
+ """
178
+ paths = [] if paths is None else paths
179
+ path = [] if path is None else path
180
+
181
+ for key in self._generated_values:
182
+ value = self.values[key]
183
+ if isinstance(value, ConfigMixin) and value.__xpm__._generated_values:
184
+ path.append(key)
185
+ value.__xpm__.get_generated_paths(path, paths)
186
+ path.pop()
187
+ else:
188
+ paths.append(".".join(path + [key]))
189
+
190
+ return paths
191
+
610
192
  def set_meta(self, value: Optional[bool]):
611
193
  """Sets the meta flag"""
612
194
  assert not self._sealed, "Configuration is sealed"
@@ -624,7 +206,34 @@ class ConfigInformation:
624
206
  # Not an argument, bypass
625
207
  return object.__getattribute__(self.pyobject, name)
626
208
 
209
+ @staticmethod
210
+ def is_generated_value(argument, value):
211
+ if argument.ignore_generated:
212
+ return False
213
+
214
+ if value is None:
215
+ return False
216
+
217
+ if isinstance(value, (int, str, float, bool, Path)):
218
+ return False
219
+
220
+ if isinstance(value, ConfigMixin):
221
+ return value.__xpm__._generated_values and value.__xpm__.task is None
222
+
223
+ if isinstance(value, list):
224
+ return any(ConfigInformation.is_generated_value(argument, x) for x in value)
225
+
226
+ if isinstance(value, dict):
227
+ return any(
228
+ ConfigInformation.is_generated_value(argument, x)
229
+ for x in value.values()
230
+ )
231
+
232
+ return False
233
+
627
234
  def set(self, k, v, bypass=False):
235
+ from experimaestro.generators import Generator
236
+
628
237
  # Not an argument, bypass
629
238
  if k not in self.xpmtype.arguments:
630
239
  setattr(self.pyobject, k, v)
@@ -633,13 +242,34 @@ class ConfigInformation:
633
242
  if self._sealed and not bypass:
634
243
  raise AttributeError(f"Object is read-only (trying to set {k})")
635
244
 
245
+ if not isinstance(v, ConfigMixin) and isinstance(v, Config):
246
+ raise AttributeError(
247
+ "Configuration (and not objects) should be used. Consider using .C(...)"
248
+ )
249
+
636
250
  try:
637
251
  argument = self.xpmtype.arguments.get(k, None)
638
252
  if argument:
639
- if not bypass and (argument.generator or argument.constant):
253
+ if ConfigInformation.is_generated_value(argument, v):
254
+ raise AttributeError(
255
+ f"Cannot set {k} to a configuration with generated values. "
256
+ "Here is the list of paths to help you: "
257
+ f"""{', '.join(v.__xpm__.get_generated_paths([k]))}"""
258
+ )
259
+
260
+ if not bypass and (
261
+ (isinstance(argument.generator, Generator)) or argument.constant
262
+ ):
640
263
  raise AttributeError("Property %s is read-only" % (k))
641
264
  if v is not None:
642
265
  self.values[k] = argument.validate(v)
266
+ # Check for type variables
267
+ if type(argument.type) is TypeVarType:
268
+ self.check_typevar(argument.type.typevar, type(v))
269
+ if isinstance(v, Config):
270
+ # If the value is a Config, fuse type variables
271
+ v.__xpm__.fuse_concrete_typevars(self.concrete_typevars)
272
+ self.fuse_concrete_typevars(v.__xpm__.concrete_typevars)
643
273
  elif argument.required:
644
274
  raise AttributeError("Cannot set required attribute to None")
645
275
  else:
@@ -652,6 +282,43 @@ class ConfigInformation:
652
282
  logger.error("Error while setting value %s in %s", k, self.xpmtype)
653
283
  raise
654
284
 
285
+ def fuse_concrete_typevars(self, typevars: Dict[TypeVar, type]):
286
+ """Fuses concrete type variables with the current ones"""
287
+ for typevar, v in typevars.items():
288
+ self.check_typevar(typevar, v)
289
+
290
+ def check_typevar(self, typevar: TypeVar, v: type):
291
+ """Check if a type variable is coherent with the current typevars bindings,
292
+ updates the bindings if necessary"""
293
+ if typevar not in self.concrete_typevars:
294
+ self.concrete_typevars[typevar] = v
295
+ return
296
+
297
+ concrete_typevar = self.concrete_typevars[typevar]
298
+ bound = typevar.__bound__
299
+ # Check that v is a subclass of the typevar OR that typevar is a subclass of v
300
+ # Then set the concrete type variable to the most generic type
301
+
302
+ # First, limiting to the specified bound
303
+ if bound is not None:
304
+ if not issubclass(v, bound):
305
+ raise TypeError(
306
+ f"Type variable {typevar} is bound to {bound}, but tried to set it to {v}"
307
+ )
308
+
309
+ if issubclass(v, concrete_typevar):
310
+ # v is a subclass of the typevar, keep the typevar
311
+ return
312
+ if issubclass(concrete_typevar, v):
313
+ # typevar is a subclass of v, keep v
314
+ self.concrete_typevars[typevar] = v
315
+ return
316
+ raise TypeError(
317
+ f"Type variable {typevar} is already set to {self.concrete_typevars[typevar]}, "
318
+ f"but tried to set it to {v}"
319
+ f" (current typevars bindings: {self.concrete_typevars})"
320
+ )
321
+
655
322
  def addtag(self, name, value):
656
323
  self._tags[name] = value
657
324
 
@@ -691,10 +358,6 @@ class ConfigInformation:
691
358
  % (k, self.xpmtype, self._initinfo)
692
359
  )
693
360
 
694
- # Validate pre-tasks
695
- for pre_task in self.pre_tasks:
696
- pre_task.__xpm__.validate()
697
-
698
361
  # Validate init tasks
699
362
  for init_task in self.init_tasks:
700
363
  init_task.__xpm__.validate()
@@ -715,18 +378,62 @@ class ConfigInformation:
715
378
  Arguments:
716
379
  - context: the generation context
717
380
  """
381
+ if generated_keys := [
382
+ k
383
+ for k, v in self.values.items()
384
+ if ConfigInformation.is_generated_value(self.xpmtype.arguments[k], v)
385
+ ]:
386
+ raise AttributeError(
387
+ "Cannot seal a configuration with generated values:"
388
+ f"""{",".join(generated_keys)} in {context.currentpath}"""
389
+ )
718
390
 
719
391
  class Sealer(ConfigWalk):
720
- def preprocess(self, config: Config):
392
+ def preprocess(self, config: ConfigMixin):
721
393
  return not config.__xpm__._sealed, config
722
394
 
723
- def postprocess(self, stub, config: Config, values):
395
+ def postprocess(self, stub, config: ConfigMixin, values):
724
396
  # Generate values
397
+ from experimaestro.generators import Generator
398
+
725
399
  for k, argument in config.__xpmtype__.arguments.items():
726
- if argument.generator:
727
- config.__xpm__.set(
728
- k, argument.generator(self.context, config), bypass=True
400
+ try:
401
+ if argument.generator:
402
+ if not isinstance(argument.generator, Generator):
403
+ # Don't set if already set
404
+ if config.__xpm__.values.get(k) is not None:
405
+ continue
406
+ value = argument.generator()
407
+ else:
408
+ # Generate a value
409
+ sig = inspect.signature(argument.generator)
410
+ if len(sig.parameters) == 0:
411
+ value = argument.generator()
412
+ elif len(sig.parameters) == 2:
413
+ # Only in that case do we need to flag this configuration
414
+ # as containing generated values
415
+ config.__xpm__._generated_values.append(k)
416
+ value = argument.generator(self.context, config)
417
+ else:
418
+ assert (
419
+ False
420
+ ), "generator has either two parameters (context and config), or none"
421
+ config.__xpm__.set(k, value, bypass=True)
422
+ else:
423
+ value = config.__xpm__.values.get(k)
424
+ except Exception:
425
+ logger.error(
426
+ "While setting %s of %s", argument.name, config.__xpmtype__
729
427
  )
428
+ raise
429
+
430
+ # Propagate the generated value flag
431
+ if (
432
+ (value is not None)
433
+ and isinstance(value, ConfigMixin)
434
+ and value.__xpm__._generated_values
435
+ ):
436
+ config.__xpm__._generated_values.append(k)
730
437
 
731
438
  config.__xpm__._sealed = True
732
439
 
@@ -740,89 +447,29 @@ class ConfigInformation:
740
447
  context = ConfigWalkContext()
741
448
 
742
449
  class Unsealer(ConfigWalk):
743
- def preprocess(self, config: Config):
450
+ def preprocess(self, config: ConfigMixin):
744
451
  return config.__xpm__._sealed, config
745
452
 
746
- def postprocess(self, stub, config: Config, values):
453
+ def postprocess(self, stub, config: ConfigMixin, values):
747
454
  config.__xpm__._sealed = False
748
455
  config.__xpm__._identifier = None
749
456
 
750
457
  Unsealer(context, recurse_task=True)(self.pyobject)
751
458
 
752
- def collect_pre_tasks(self) -> Iterator["Config"]:
753
- context = ConfigWalkContext()
754
- pre_tasks: Dict[int, "Config"] = {}
755
-
756
- class PreTaskCollect(ConfigWalk):
757
- def preprocess(self, config: Config):
758
- # Do not cross tasks
759
- return not isinstance(config.__xpm__, Task), config
760
-
761
- def postprocess(self, stub, config: Config, values):
762
- pre_tasks.update(
763
- {id(pre_task): pre_task for pre_task in config.__xpm__.pre_tasks}
764
- )
765
-
766
- PreTaskCollect(context, recurse_task=True)(self.pyobject)
767
- return pre_tasks.values()
768
-
769
- def identifiers(self, only_raw: bool):
459
+ @property
460
+ def identifier(self):
770
461
  """Computes the unique identifier"""
771
-
772
- raw_identifier = self._raw_identifier
773
- full_identifier = self._full_identifier
462
+ from ..identifier import IdentifierComputer
774
463
 
775
464
  # Computes raw identifier if needed
776
- if raw_identifier is None or not self._sealed:
777
- # Get the main identifier
778
- raw_identifier = HashComputer.compute(self.pyobject)
779
- if self._sealed:
780
- self._raw_identifier = raw_identifier
781
-
782
- if only_raw:
783
- return raw_identifier, full_identifier
784
-
785
- # OK, let's compute the full identifier
786
- if full_identifier is None or not self._sealed:
787
- # Compute the full identifier by including the pre-tasks
788
- hasher = hashlib.sha256()
789
- hasher.update(raw_identifier.all)
790
- pre_tasks_ids = [
791
- pre_task.__xpm__.raw_identifier.all
792
- for pre_task in self.collect_pre_tasks()
793
- ]
794
- for task_id in sorted(pre_tasks_ids):
795
- hasher.update(task_id)
796
-
797
- # Adds init tasks
798
- if self.init_tasks:
799
- hasher.update(HashComputer.INIT_TASKS)
800
- for init_task in self.init_tasks:
801
- hasher.update(init_task.__xpm__.raw_identifier.all)
802
-
803
- full_identifier = Identifier(hasher.digest())
804
- full_identifier.has_loops = raw_identifier.has_loops
805
-
806
- # Only cache the identifier if sealed
807
- if self._sealed:
808
- self._full_identifier = full_identifier
809
-
810
- return raw_identifier, full_identifier
811
-
812
- @property
813
- def raw_identifier(self) -> Identifier:
814
- """Computes the unique identifier (without task modifiers)"""
815
- raw_identifier, _ = self.identifiers(True)
816
- return raw_identifier
817
-
818
- @property
819
- def full_identifier(self) -> Identifier:
820
- """Computes the unique identifier (with task modifiers)"""
821
- _, full_identifier = self.identifiers(False)
822
- return full_identifier
465
+ if self._identifier is not None:
466
+ return self._identifier
823
467
 
824
- identifier = full_identifier
825
- """Deprecated: use full_identifier"""
468
+ # Get the main identifier
469
+ identifier = IdentifierComputer.compute(self.pyobject)
470
+ if self._sealed:
471
+ self._identifier = identifier
472
+ return identifier
826
473
 
827
474
  def dependency(self):
828
475
  """Returns a dependency"""
@@ -837,12 +484,6 @@ class ConfigInformation:
837
484
  path: List[str],
838
485
  taskids: Set[int],
839
486
  ):
840
- # Add pre-tasks
841
- for pre_task in self.pre_tasks:
842
- pre_task.__xpm__.updatedependencies(
843
- dependencies, path + ["__pre_tasks__"], taskids
844
- )
845
-
846
487
  # Add initialization tasks
847
488
  for init_task in self.init_tasks:
848
489
  init_task.__xpm__.updatedependencies(
@@ -899,6 +540,28 @@ class ConfigInformation:
899
540
  # Now, seal the object
900
541
  self.seal(context)
901
542
 
543
+ def watch_output(self, method, callback):
544
+ """Watch the task output linked with a given method
545
+
546
+ :param method: The method to watch
547
+ :param callback: The callback
548
+ """
549
+ watched = WatchedOutput(
550
+ self, method.__self__, method.__name__, method, callback
551
+ )
552
+ self.watched_outputs.append(watched)
553
+ if self.job:
554
+ self.job.watch_output(watched)
555
+
556
+ def on_completed(self, callback: Callable[[], None]):
557
+ """Call a method when the task is completed successfully
558
+
559
+ :param callback: _description_
560
+ """
561
+ from ..callbacks import TaskEventListener
562
+
563
+ TaskEventListener.on_completed(self, callback)
564
+
902
565
  def submit(
903
566
  self,
904
567
  workspace: "Workspace",
@@ -909,6 +572,7 @@ class ConfigInformation:
909
572
  ):
910
573
  from experimaestro.scheduler import experiment, JobContext
911
574
  from experimaestro.scheduler.workspace import RunMode
575
+ from ..callbacks import TaskEventListener
912
576
 
913
577
  # --- Prepare the object
914
578
 
@@ -961,10 +625,12 @@ class ConfigInformation:
961
625
  workspace.run_mode if run_mode is None else run_mode
962
626
  ) or RunMode.NORMAL
963
627
  if run_mode == RunMode.NORMAL:
628
+ TaskEventListener.connect(experiment.CURRENT)
629
+ experiment.CURRENT.submit(self.job)
964
630
  other = experiment.CURRENT.submit(self.job)
965
631
  if other:
966
- # Just returns the other task
967
- return other.config.__xpm__._taskoutput
632
+ # Our job = previously submitted job
633
+ self.job = other
968
634
  else:
969
635
  # Show a warning
970
636
  if run_mode == RunMode.GENERATE_ONLY:
@@ -1000,23 +666,22 @@ class ConfigInformation:
1000
666
 
1001
667
  print(file=sys.stderr) # noqa: T201
1002
668
 
1003
- # Handle an output configuration
1004
- def mark_output(config: "Config"):
1005
- """Sets a dependency on the job"""
1006
- assert not isinstance(config, Task), "Cannot set a dependency on a task"
1007
- config.__xpm__.task = self.pyobject
1008
- return config
1009
-
1010
669
  # Mark this configuration also
1011
670
  self.task = self.pyobject
1012
671
 
1013
672
  if hasattr(self.pyobject, "task_outputs"):
1014
- self._taskoutput = self.pyobject.task_outputs(mark_output)
673
+ self._taskoutput = self.pyobject.task_outputs(self.mark_output)
1015
674
  else:
1016
675
  self._taskoutput = self.task = self.pyobject
1017
676
 
1018
677
  return self._taskoutput
1019
678
 
679
+ def mark_output(self, config: "Config"):
680
+ """Sets a dependency on the job"""
681
+ assert not isinstance(config, Task), "Cannot set a dependency on a task"
682
+ config.__xpm__.task = self.pyobject
683
+ return config
684
+
1020
685
  # --- Serialization
1021
686
 
1022
687
  @staticmethod
@@ -1025,7 +690,7 @@ class ConfigInformation:
1025
690
  if value is None:
1026
691
  return None
1027
692
 
1028
- elif isinstance(value, list):
693
+ elif isinstance(value, (list, tuple)):
1029
694
  return [ConfigInformation._outputjsonvalue(el, context) for el in value]
1030
695
 
1031
696
  elif isinstance(value, dict):
@@ -1089,9 +754,6 @@ class ConfigInformation:
1089
754
  if self.task is not None and self.task is not self:
1090
755
  ConfigInformation.__collect_objects__(self.task, objects, context)
1091
756
 
1092
- # Serialize pre-tasks
1093
- ConfigInformation.__collect_objects__(self.pre_tasks, objects, context)
1094
-
1095
757
  # Serialize initialization tasks
1096
758
  ConfigInformation.__collect_objects__(self.init_tasks, objects, context)
1097
759
 
@@ -1105,8 +767,6 @@ class ConfigInformation:
1105
767
  }
1106
768
 
1107
769
  # Add pre/init tasks
1108
- if self.pre_tasks:
1109
- state_dict["pre-tasks"] = [id(pre_task) for pre_task in self.pre_tasks]
1110
770
  if self.init_tasks:
1111
771
  state_dict["init-tasks"] = [id(init_task) for init_task in self.init_tasks]
1112
772
 
@@ -1140,9 +800,12 @@ class ConfigInformation:
1140
800
  def __collect_objects__(value, objects: List[Dict], context: SerializationContext):
1141
801
  """Serialize all needed configuration objects, looking at sub
1142
802
  configurations if necessary"""
803
+ if value is None:
804
+ return
805
+
1143
806
  if isinstance(value, Config):
1144
807
  value.__xpm__.__get_objects__(objects, context)
1145
- elif isinstance(value, list):
808
+ elif isinstance(value, (list, tuple)):
1146
809
  for el in value:
1147
810
  ConfigInformation.__collect_objects__(el, objects, context)
1148
811
  elif isinstance(value, dict):
@@ -1283,34 +946,31 @@ class ConfigInformation:
1283
946
 
1284
947
  @overload
1285
948
  @staticmethod
1286
- def fromParameters(
949
+ def fromParameters( # noqa: E704
1287
950
  definitions: List[Dict],
1288
951
  as_instance=True,
1289
952
  save_directory: Optional[Path] = None,
1290
953
  discard_id: bool = False,
1291
- ) -> "TypeConfig":
1292
- ...
954
+ ) -> "ConfigMixin": ...
1293
955
 
1294
956
  @overload
1295
957
  @staticmethod
1296
- def fromParameters(
958
+ def fromParameters( # noqa: E704
1297
959
  definitions: List[Dict],
1298
960
  as_instance=False,
1299
961
  return_tasks=True,
1300
962
  save_directory: Optional[Path] = None,
1301
963
  discard_id: bool = False,
1302
- ) -> Tuple["Config", List["LightweightTask"]]:
1303
- ...
964
+ ) -> Tuple["Config", List["LightweightTask"]]: ...
1304
965
 
1305
966
  @overload
1306
967
  @staticmethod
1307
- def fromParameters(
968
+ def fromParameters( # noqa: E704
1308
969
  definitions: List[Dict],
1309
970
  as_instance=False,
1310
971
  save_directory: Optional[Path] = None,
1311
972
  discard_id: bool = False,
1312
- ) -> "Config":
1313
- ...
973
+ ) -> "Config": ...
1314
974
 
1315
975
  @staticmethod
1316
976
  def load_objects( # noqa: C901
@@ -1323,6 +983,7 @@ class ConfigInformation:
1323
983
  o = None
1324
984
  objects = {}
1325
985
  import experimaestro.taskglobals as taskglobals
986
+ from ..identifier import Identifier
1326
987
 
1327
988
  # Loop over all the definitions and create objects
1328
989
  for definition in definitions:
@@ -1345,7 +1006,9 @@ class ConfigInformation:
1345
1006
  mod = importlib.import_module(module_name)
1346
1007
  except ModuleNotFoundError:
1347
1008
  # More hints on the nature of the error
1348
- logging.warning("(1) Either the python path is wrong – %s", ":".join(sys.path))
1009
+ logging.warning(
1010
+ "(1) Either the python path is wrong – %s", ":".join(sys.path)
1011
+ )
1349
1012
  logging.warning("(2) There is not __init__.py in your module")
1350
1013
  raise
1351
1014
 
@@ -1432,12 +1095,6 @@ class ConfigInformation:
1432
1095
  o.__post_init__()
1433
1096
 
1434
1097
  else:
1435
- # Sets pre-tasks
1436
- o.__xpm__.pre_tasks = [
1437
- objects[pre_task_id]
1438
- for pre_task_id in definition.get("pre-tasks", [])
1439
- ]
1440
-
1441
1098
  if task_id := definition.get("task", None):
1442
1099
  o.__xpm__.task = objects[task_id]
1443
1100
 
@@ -1471,15 +1128,6 @@ class ConfigInformation:
1471
1128
 
1472
1129
  # Run pre-task (or returns them)
1473
1130
  if as_instance or return_tasks:
1474
- # Collect pre-tasks (just once)
1475
- completed_pretasks = set()
1476
- pre_tasks = []
1477
- for definition in definitions:
1478
- for pre_task_id in definition.get("pre-tasks", []):
1479
- if pre_task_id not in completed_pretasks:
1480
- completed_pretasks.add(pre_task_id)
1481
- pre_tasks.append(objects[pre_task_id])
1482
-
1483
1131
  # Collect init tasks
1484
1132
  init_tasks = []
1485
1133
  for init_task_id in definitions[-1].get("init-tasks", []):
@@ -1487,14 +1135,11 @@ class ConfigInformation:
1487
1135
  init_tasks.append(init_task)
1488
1136
 
1489
1137
  if as_instance:
1490
- for pre_task in pre_tasks:
1491
- logger.info("Executing pre-task %s", type(pre_task))
1492
- pre_task.execute()
1493
1138
  for init_task in init_tasks:
1494
1139
  logger.info("Executing init task %s", type(init_task))
1495
1140
  init_task.execute()
1496
1141
  else:
1497
- return o, pre_tasks, pre_task + init_tasks
1142
+ return o, init_tasks
1498
1143
 
1499
1144
  return o
1500
1145
 
@@ -1502,7 +1147,6 @@ class ConfigInformation:
1502
1147
  def __init__(self, context: ConfigWalkContext, *, objects: ObjectStore = None):
1503
1148
  super().__init__(context)
1504
1149
  self.objects = ObjectStore() if objects is None else objects
1505
- self.pre_tasks = {}
1506
1150
 
1507
1151
  def preprocess(self, config: "Config"):
1508
1152
  if self.objects.is_constructed(id(config)):
@@ -1529,10 +1173,6 @@ class ConfigInformation:
1529
1173
  # Call __post_init__
1530
1174
  stub.__post_init__()
1531
1175
 
1532
- # Gather pre-tasks
1533
- for pre_task in config.__xpm__.pre_tasks:
1534
- self.pre_tasks[id(pre_task)] = self.stub(pre_task)
1535
-
1536
1176
  self.objects.set_constructed(id(config))
1537
1177
  return stub
1538
1178
 
@@ -1546,10 +1186,6 @@ class ConfigInformation:
1546
1186
  processor = ConfigInformation.FromPython(context, objects=objects)
1547
1187
  last_object = processor(self.pyobject)
1548
1188
 
1549
- # Execute pre-tasks
1550
- for pre_task in processor.pre_tasks.values():
1551
- pre_task.execute()
1552
-
1553
1189
  return last_object
1554
1190
 
1555
1191
  def add_dependencies(self, *dependencies):
@@ -1573,6 +1209,9 @@ def clone(v):
1573
1209
  if isinstance(v, Enum):
1574
1210
  return v
1575
1211
 
1212
+ if isinstance(v, tuple):
1213
+ return tuple(clone(x) for x in v)
1214
+
1576
1215
  if isinstance(v, Config):
1577
1216
  # Create a new instance
1578
1217
  kwargs = {
@@ -1587,31 +1226,15 @@ def clone(v):
1587
1226
  raise NotImplementedError("Clone not implemented for type %s" % type(v))
1588
1227
 
1589
1228
 
1590
- def cache(fn, name: str):
1591
- def __call__(config, *args, **kwargs):
1592
- import experimaestro.taskglobals as taskglobals
1593
-
1594
- # Get path and create directory if needed
1595
- hexid = config.__xpmidentifier__ # type: Identifier
1596
- typename = config.__xpmtypename__ # type: str
1597
- dir = taskglobals.Env.instance().wspath / "config" / typename / hexid.all.hex()
1598
-
1599
- if not dir.exists():
1600
- dir.mkdir(parents=True, exist_ok=True)
1601
-
1602
- path = dir / name
1603
- ipc_lock = fasteners.InterProcessLock(path.with_suffix(path.suffix + ".lock"))
1604
- with ipc_lock:
1605
- r = fn(config, path, *args, **kwargs)
1606
- return r
1607
-
1608
- return __call__
1609
-
1610
-
1611
- class TypeConfig:
1229
+ class ConfigMixin:
1612
1230
  """Class for configuration objects"""
1613
1231
 
1614
1232
  __xpmtype__: ObjectType
1233
+ """The associated XPM type"""
1234
+
1235
+ __xpm__: ConfigInformation
1236
+ """The __xpm__ object contains all instance specific information about a
1237
+ configuration/task"""
1615
1238
 
1616
1239
  def __init__(self, **kwargs):
1617
1240
  """Initialize the configuration with the given parameters"""
@@ -1748,29 +1371,7 @@ class TypeConfig:
1748
1371
  attributes)"""
1749
1372
  return clone(self)
1750
1373
 
1751
- def add_pretasks(self, *tasks: "LightweightTask"):
1752
- assert all(
1753
- [isinstance(task, LightweightTask) for task in tasks]
1754
- ), "One of the pre-tasks are not lightweight tasks"
1755
- if self.__xpm__._sealed:
1756
- raise SealedError("Cannot add pre-tasks to a sealed configuration")
1757
- self.__xpm__.pre_tasks.extend(tasks)
1758
- return self
1759
-
1760
- def add_pretasks_from(self, *configs: "Config"):
1761
- assert all(
1762
- [isinstance(config, TypeConfig) for config in configs]
1763
- ), "One of the parameters is not a configuration object"
1764
- for config in configs:
1765
- self.add_pretasks(*config.__xpm__.pre_tasks)
1766
- return self
1767
-
1768
- @property
1769
- def pre_tasks(self) -> List["LightweightTask"]:
1770
- """Access pre-tasks"""
1771
- return self.__xpm__.pre_tasks
1772
-
1773
- def copy_dependencies(self, other: "Config"):
1374
+ def copy_dependencies(self, other: "ConfigMixin"):
1774
1375
  """Add all the dependencies from other configuration"""
1775
1376
 
1776
1377
  # Add task dependency
@@ -1782,42 +1383,33 @@ class TypeConfig:
1782
1383
  self.__xpm__.add_dependencies(*other.__xpm__.dependencies)
1783
1384
 
1784
1385
 
1785
- class classproperty(property):
1786
- def __get__(self, owner_self, owner_cls):
1787
- return self.fget(owner_cls)
1788
-
1789
-
1790
1386
  class Config:
1791
1387
  """Base type for all objects in python interface"""
1792
1388
 
1389
+ __xpmid__: ClassVar[Optional[str]]
1390
+ """Optional configuration ID, mostly useful when moving a class to another
1391
+ package to avoid changes in computed task identifiers"""
1392
+
1793
1393
  __xpmtype__: ClassVar[ObjectType]
1794
1394
  """The object type holds all the information about a specific subclass
1795
1395
  experimaestro metadata"""
1796
1396
 
1797
- __xpm__: ConfigInformation
1798
- """The __xpm__ object contains all instance specific information about a
1799
- configuration/task"""
1800
-
1801
1397
  @classproperty
1802
1398
  def XPMConfig(cls):
1803
- if issubclass(cls, TypeConfig):
1399
+ if issubclass(cls, ConfigMixin):
1804
1400
  return cls
1805
1401
  return cls.__getxpmtype__().configtype
1806
1402
 
1807
- @classproperty
1808
- def C(cls):
1809
- return cls.XPMConfig
1810
-
1811
1403
  @classproperty
1812
1404
  def XPMValue(cls):
1813
1405
  """Returns the value object for this configuration"""
1814
- if issubclass(cls, TypeConfig):
1406
+ if issubclass(cls, ConfigMixin):
1815
1407
  return cls.__xpmtype__.objecttype
1816
1408
 
1817
1409
  if value_cls := cls.__dict__.get("__XPMValue__", None):
1818
1410
  pass
1819
1411
  else:
1820
- from .types import XPMValue
1412
+ from ..types import XPMValue
1821
1413
 
1822
1414
  __objectbases__ = tuple(
1823
1415
  s.XPMValue
@@ -1834,9 +1426,20 @@ class Config:
1834
1426
 
1835
1427
  return value_cls
1836
1428
 
1429
+ @classproperty
1430
+ def C(cls):
1431
+ """Alias for XPMConfig"""
1432
+ return cls.XPMConfig
1433
+
1434
+ @classproperty
1435
+ def V(cls):
1436
+ """Alias for XPMValue"""
1437
+ return cls.XPMValue
1438
+
1837
1439
  @classmethod
1838
1440
  def __getxpmtype__(cls) -> "ObjectType":
1839
- """Get (and create if necessary) the Object type of this"""
1441
+ """Get (and create if necessary) the Object type associated
1442
+ with thie Config object"""
1840
1443
  xpmtype = cls.__dict__.get("__xpmtype__", None)
1841
1444
  if xpmtype is None:
1842
1445
  from experimaestro.core.types import ObjectType
@@ -1850,9 +1453,12 @@ class Config:
1850
1453
  return xpmtype
1851
1454
 
1852
1455
  def __new__(cls: Type[T], *args, **kwargs) -> T:
1853
- """Returns an instance of a TypeConfig (for compatibility, use XPMConfig
1854
- or C if possible)"""
1456
+ """Returns an instance of a ConfigMixin (for compatibility, use XPMConfig
1457
+ or C if possible)
1855
1458
 
1459
+ :deprecated: Use Config.C or Config.XPMConfig to construct a new
1460
+ configuration, and Config.V (or Config.XPMValue) for a new value
1461
+ """
1856
1462
  # If this is an XPMValue, just return a new instance
1857
1463
  from experimaestro.core.types import XPMValue
1858
1464
 
@@ -1861,15 +1467,24 @@ class Config:
1861
1467
 
1862
1468
  # If this is the XPMConfig, just return a new instance
1863
1469
  # __init__ will be called
1864
- if issubclass(cls, TypeConfig):
1470
+ if issubclass(cls, ConfigMixin):
1865
1471
  return object.__new__(cls)
1866
1472
 
1473
+ # Log a deprecation warning for this way of creating a configuration
1474
+ caller = inspect.getframeinfo(inspect.stack()[1][0])
1475
+ logger.warning(
1476
+ "Creating a configuration using Config.__new__ is deprecated, and will be removed in a future version. "
1477
+ "Use Config.C or Config.XPMConfig to create a new configuration. "
1478
+ "Issue created at %s:%s",
1479
+ str(Path(caller.filename).absolute()),
1480
+ caller.lineno,
1481
+ )
1482
+
1867
1483
  # otherwise, we use the configuration type
1868
- o: TypeConfig = object.__new__(cls.__getxpmtype__().configtype)
1484
+ o: ConfigMixin = object.__new__(cls.__getxpmtype__().configtype)
1869
1485
  try:
1870
1486
  o.__init__(*args, **kwargs)
1871
1487
  except Exception:
1872
- caller = inspect.getframeinfo(inspect.stack()[1][0])
1873
1488
  logger.error(
1874
1489
  "Init error in %s:%s"
1875
1490
  % (str(Path(caller.filename).absolute()), caller.lineno)
@@ -1890,29 +1505,30 @@ class Config:
1890
1505
  """Returns a JSON version of the object (if possible)"""
1891
1506
  return self.__xpm__.__json__()
1892
1507
 
1893
- def __identifier__(self) -> Identifier:
1508
+ def __identifier__(self) -> "Identifier":
1894
1509
  return self.__xpm__.identifier
1895
1510
 
1896
- def add_pretasks(self, *tasks: "LightweightTask"):
1897
- """Add pre-tasks"""
1898
- raise AssertionError("This method can only be used during configuration")
1899
-
1900
- def add_pretasks_from(self, *configs: "Config"):
1901
- """Add pre-tasks from the listed configurations"""
1902
- raise AssertionError(
1903
- "The 'add_pretasks_from' can only be used during configuration"
1904
- )
1905
-
1906
1511
  def copy_dependencies(self, other: "Config"):
1907
1512
  """Add pre-tasks from the listed configurations"""
1908
1513
  raise AssertionError(
1909
1514
  "The 'copy_dependencies' method can only be used during configuration"
1910
1515
  )
1911
1516
 
1912
- @property
1913
- def pre_tasks(self) -> List["LightweightTask"]:
1914
- """Access pre-tasks"""
1915
- raise AssertionError("Pre-tasks can be accessed only during configuration")
1517
+ def register_task_output(self, method, *args, **kwargs):
1518
+ # Determine the path for this...
1519
+ path = taskglobals.Env.instance().xpm_path / "task-outputs.jsonl"
1520
+ path.parent.mkdir(parents=True, exist_ok=True)
1521
+
1522
+ data = json.dumps(
1523
+ {
1524
+ "key": f"{self.__xpmidentifier__}/{method.__name__}",
1525
+ "args": args,
1526
+ "kwargs": kwargs,
1527
+ }
1528
+ )
1529
+ with path.open("at") as fp:
1530
+ fp.writelines([data, "\n"])
1531
+ fp.flush()
1916
1532
 
1917
1533
 
1918
1534
  class LightweightTask(Config):
@@ -1931,6 +1547,17 @@ class Task(LightweightTask):
1931
1547
  def submit(self):
1932
1548
  raise AssertionError("This method can only be used during configuration")
1933
1549
 
1550
+ def watch_output(self, method, callback):
1551
+ """Sets up a callback
1552
+
1553
+ :param method: a method within a configuration
1554
+ :param callback: the callback
1555
+ """
1556
+ self.__xpm__.watch_output(method, callback)
1557
+
1558
+ def on_completed(self, callback: Callable[[], None]):
1559
+ self.__xpm__.on_completed(callback)
1560
+
1934
1561
 
1935
1562
  # --- Utility functions
1936
1563
 
@@ -1969,3 +1596,24 @@ def setmeta(config: Config, flag: bool):
1969
1596
  """Flags the configuration as a meta-parameter"""
1970
1597
  config.__xpm__.set_meta(flag)
1971
1598
  return config
1599
+
1600
+
1601
+ def cache(fn, name: str):
1602
+ def __call__(config, *args, **kwargs):
1603
+ import experimaestro.taskglobals as taskglobals
1604
+
1605
+ # Get path and create directory if needed
1606
+ hexid = config.__xpmidentifier__ # type: Identifier
1607
+ typename = config.__xpmtypename__ # type: str
1608
+ dir = taskglobals.Env.instance().wspath / "config" / typename / hexid.all.hex()
1609
+
1610
+ if not dir.exists():
1611
+ dir.mkdir(parents=True, exist_ok=True)
1612
+
1613
+ path = dir / name
1614
+ ipc_lock = fasteners.InterProcessLock(path.with_suffix(path.suffix + ".lock"))
1615
+ with ipc_lock:
1616
+ r = fn(config, path, *args, **kwargs)
1617
+ return r
1618
+
1619
+ return __call__