experimaestro 1.10.0__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 (46) hide show
  1. experimaestro/core/arguments.py +10 -1
  2. experimaestro/core/identifier.py +11 -6
  3. experimaestro/core/objects/config.py +127 -193
  4. experimaestro/core/objects/config_walk.py +4 -6
  5. experimaestro/core/objects.pyi +2 -6
  6. experimaestro/core/serializers.py +1 -8
  7. experimaestro/launcherfinder/specs.py +8 -1
  8. experimaestro/run.py +2 -0
  9. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  10. experimaestro/server/data/1815e00441357e01619e.ttf +0 -0
  11. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  12. experimaestro/server/data/2463b90d9a316e4e5294.woff2 +0 -0
  13. experimaestro/server/data/2582b0e4bcf85eceead0.ttf +0 -0
  14. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  15. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  16. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  17. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  18. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  19. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  20. experimaestro/server/data/89999bdf5d835c012025.woff2 +0 -0
  21. experimaestro/server/data/914997e1bdfc990d0897.ttf +0 -0
  22. experimaestro/server/data/c210719e60948b211a12.woff2 +0 -0
  23. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  24. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  25. experimaestro/server/data/favicon.ico +0 -0
  26. experimaestro/server/data/index.css +22963 -0
  27. experimaestro/server/data/index.css.map +1 -0
  28. experimaestro/server/data/index.html +27 -0
  29. experimaestro/server/data/index.js +101770 -0
  30. experimaestro/server/data/index.js.map +1 -0
  31. experimaestro/server/data/login.html +22 -0
  32. experimaestro/server/data/manifest.json +15 -0
  33. experimaestro/tests/tasks/all.py +7 -0
  34. experimaestro/tests/test_dependencies.py +0 -6
  35. experimaestro/tests/test_generators.py +93 -0
  36. experimaestro/tests/test_identifier.py +87 -76
  37. experimaestro/tests/test_instance.py +0 -12
  38. experimaestro/tests/test_serializers.py +0 -59
  39. experimaestro/tests/test_tasks.py +10 -23
  40. experimaestro/tests/test_types.py +2 -2
  41. experimaestro/utils/multiprocessing.py +44 -0
  42. {experimaestro-1.10.0.dist-info → experimaestro-1.15.2.dist-info}/METADATA +5 -4
  43. {experimaestro-1.10.0.dist-info → experimaestro-1.15.2.dist-info}/RECORD +46 -20
  44. {experimaestro-1.10.0.dist-info → experimaestro-1.15.2.dist-info}/WHEEL +1 -1
  45. {experimaestro-1.10.0.dist-info → experimaestro-1.15.2.dist-info}/entry_points.txt +0 -0
  46. {experimaestro-1.10.0.dist-info → experimaestro-1.15.2.dist-info/licenses}/LICENSE +0 -0
@@ -80,10 +80,12 @@ class Argument:
80
80
 
81
81
  self.generator = generator
82
82
  self.default = None
83
+ self.ignore_generated = False
83
84
 
84
85
  if default is not None:
85
86
  assert self.generator is None, "generator and default are exclusive options"
86
87
  if isinstance(default, field):
88
+ self.ignore_generated = default.ignore_generated
87
89
  if default.default is not None:
88
90
  self.default = default.default
89
91
  elif default.default_factory is not None:
@@ -184,13 +186,20 @@ DataPath = Annotated[Path, dataHint]
184
186
  class field:
185
187
  """Extra information for a given experimaestro field (param or meta)"""
186
188
 
187
- def __init__(self, *, default: Any = None, default_factory: Callable = None):
189
+ def __init__(
190
+ self,
191
+ *,
192
+ default: Any = None,
193
+ default_factory: Callable = None,
194
+ ignore_generated=False,
195
+ ):
188
196
  assert not (
189
197
  (default is not None) and (default_factory is not None)
190
198
  ), "default and default_factory are mutually exclusive options"
191
199
 
192
200
  self.default_factory = default_factory
193
201
  self.default = default
202
+ self.ignore_generated = ignore_generated
194
203
 
195
204
 
196
205
  class help(TypeAnnotation):
@@ -6,7 +6,7 @@ import logging
6
6
  import os
7
7
  import struct
8
8
  from typing import Optional
9
- from experimaestro.core.objects import Config
9
+ from experimaestro.core.objects import Config, ConfigMixin
10
10
 
11
11
 
12
12
  class ConfigPath:
@@ -116,7 +116,7 @@ class IdentifierComputer:
116
116
  CYCLE_REFERENCE = b"\x0b"
117
117
  INIT_TASKS = b"\x0c"
118
118
 
119
- def __init__(self, config: "Config", config_path: ConfigPath, *, version=None):
119
+ def __init__(self, config: "ConfigMixin", config_path: ConfigPath, *, version=None):
120
120
  # Hasher for parameters
121
121
  self._hasher = hashlib.sha256()
122
122
  self.config = config
@@ -170,7 +170,7 @@ class IdentifierComputer:
170
170
  self._hashupdate(IdentifierComputer.ENUM_ID)
171
171
  k = value.__class__
172
172
  self._hashupdate(
173
- f"{k.__module__}.{k.__qualname__ }:{value.name}".encode("utf-8"),
173
+ f"{k.__module__}.{k.__qualname__}:{value.name}".encode("utf-8"),
174
174
  )
175
175
  elif isinstance(value, dict):
176
176
  self._hashupdate(IdentifierComputer.DICT_ID)
@@ -183,7 +183,7 @@ class IdentifierComputer:
183
183
  self.update(value)
184
184
 
185
185
  # Handles configurations
186
- elif isinstance(value, Config):
186
+ elif isinstance(value, ConfigMixin):
187
187
  # Encodes the identifier
188
188
  self._hashupdate(IdentifierComputer.OBJECT_ID)
189
189
 
@@ -264,12 +264,17 @@ class IdentifierComputer:
264
264
  self._hashupdate(IdentifierComputer.NAME_ID)
265
265
  self.update(argvalue)
266
266
 
267
+ # Add init tasks
268
+ if value.__xpm__.init_tasks:
269
+ self._hashupdate(IdentifierComputer.INIT_TASKS)
270
+ for init_task in value.__xpm__.init_tasks:
271
+ self.update(init_task)
267
272
  else:
268
273
  raise NotImplementedError("Cannot compute hash of type %s" % type(value))
269
274
 
270
275
  @staticmethod
271
276
  def compute(
272
- config: "Config", config_path: ConfigPath | None = None, version=None
277
+ config: "ConfigMixin", config_path: ConfigPath | None = None, version=None
273
278
  ) -> Identifier:
274
279
  """Compute the identifier for a configuration
275
280
 
@@ -281,7 +286,7 @@ class IdentifierComputer:
281
286
  # Try to use the cached value first
282
287
  # (if there are no loops)
283
288
  if config.__xpm__._sealed:
284
- identifier = config.__xpm__._raw_identifier
289
+ identifier = config.__xpm__._identifier
285
290
  if identifier is not None and not identifier.has_loops:
286
291
  return identifier
287
292
 
@@ -9,7 +9,6 @@ from experimaestro import taskglobals
9
9
 
10
10
  from termcolor import cprint
11
11
  from pathlib import Path
12
- import hashlib
13
12
  import logging
14
13
  import io
15
14
  from enum import Enum
@@ -20,7 +19,6 @@ from typing import (
20
19
  Callable,
21
20
  ClassVar,
22
21
  Dict,
23
- Iterator,
24
22
  List,
25
23
  Optional,
26
24
  Set,
@@ -49,7 +47,6 @@ from .config_walk import ConfigWalk, ConfigWalkContext
49
47
  from .config_utils import (
50
48
  getqualattr,
51
49
  add_to_path,
52
- SealedError,
53
50
  TaggedValue,
54
51
  ObjectStore,
55
52
  classproperty,
@@ -122,11 +119,11 @@ class ConfigInformation:
122
119
  def __init__(self, pyobject: "ConfigMixin"):
123
120
  # The underlying pyobject and XPM type
124
121
  self.pyobject = pyobject
125
- self.xpmtype = pyobject.__xpmtype__ # type: ObjectType
122
+ self.xpmtype: "ObjectType" = pyobject.__xpmtype__
126
123
  self.values = {}
127
124
 
128
125
  # Meta-informations
129
- self._tags = {}
126
+ self._tags: dict[str, Any] = {}
130
127
  self._initinfo = ""
131
128
 
132
129
  self._taskoutput = None
@@ -142,16 +139,13 @@ class ConfigInformation:
142
139
  #: True when this configuration was loaded from disk
143
140
  self.loaded = False
144
141
 
145
- # Explicitely added dependencies
142
+ # Explicitly added dependencies
146
143
  self.dependencies = []
147
144
 
148
145
  # Concrete type variables resolutions
149
146
  # This is used to check typevars coherence
150
147
  self.concrete_typevars: Dict[TypeVar, type] = {}
151
148
 
152
- # Lightweight tasks
153
- self.pre_tasks: List["LightweightTask"] = []
154
-
155
149
  # Initialization tasks
156
150
  self.init_tasks: List["LightweightTask"] = []
157
151
 
@@ -160,16 +154,41 @@ class ConfigInformation:
160
154
 
161
155
  # Cached information
162
156
 
163
- self._full_identifier = None
164
- """The full identifier (with pre-tasks)"""
165
-
166
- self._raw_identifier = None
167
- """The identifier without taking into account pre-tasks"""
157
+ self._identifier = None
158
+ """The configuration identifier (cached when sealed)"""
168
159
 
169
160
  self._validated = False
170
161
  self._sealed = False
171
162
  self._meta = None
172
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
+
173
192
  def set_meta(self, value: Optional[bool]):
174
193
  """Sets the meta flag"""
175
194
  assert not self._sealed, "Configuration is sealed"
@@ -187,6 +206,31 @@ class ConfigInformation:
187
206
  # Not an argument, bypass
188
207
  return object.__getattribute__(self.pyobject, name)
189
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
+
190
234
  def set(self, k, v, bypass=False):
191
235
  from experimaestro.generators import Generator
192
236
 
@@ -198,9 +242,21 @@ class ConfigInformation:
198
242
  if self._sealed and not bypass:
199
243
  raise AttributeError(f"Object is read-only (trying to set {k})")
200
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
+
201
250
  try:
202
251
  argument = self.xpmtype.arguments.get(k, None)
203
252
  if argument:
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
+
204
260
  if not bypass and (
205
261
  (isinstance(argument.generator, Generator)) or argument.constant
206
262
  ):
@@ -302,10 +358,6 @@ class ConfigInformation:
302
358
  % (k, self.xpmtype, self._initinfo)
303
359
  )
304
360
 
305
- # Validate pre-tasks
306
- for pre_task in self.pre_tasks:
307
- pre_task.__xpm__.validate()
308
-
309
361
  # Validate init tasks
310
362
  for init_task in self.init_tasks:
311
363
  init_task.__xpm__.validate()
@@ -326,12 +378,21 @@ class ConfigInformation:
326
378
  Arguments:
327
379
  - context: the generation context
328
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
+ )
329
390
 
330
391
  class Sealer(ConfigWalk):
331
- def preprocess(self, config: Config):
392
+ def preprocess(self, config: ConfigMixin):
332
393
  return not config.__xpm__._sealed, config
333
394
 
334
- def postprocess(self, stub, config: Config, values):
395
+ def postprocess(self, stub, config: ConfigMixin, values):
335
396
  # Generate values
336
397
  from experimaestro.generators import Generator
337
398
 
@@ -344,22 +405,36 @@ class ConfigInformation:
344
405
  continue
345
406
  value = argument.generator()
346
407
  else:
408
+ # Generate a value
347
409
  sig = inspect.signature(argument.generator)
348
410
  if len(sig.parameters) == 0:
349
411
  value = argument.generator()
350
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)
351
416
  value = argument.generator(self.context, config)
352
417
  else:
353
418
  assert (
354
419
  False
355
420
  ), "generator has either two parameters (context and config), or none"
356
421
  config.__xpm__.set(k, value, bypass=True)
422
+ else:
423
+ value = config.__xpm__.values.get(k)
357
424
  except Exception:
358
425
  logger.error(
359
426
  "While setting %s of %s", argument.name, config.__xpmtype__
360
427
  )
361
428
  raise
362
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)
437
+
363
438
  config.__xpm__._sealed = True
364
439
 
365
440
  Sealer(context, recurse_task=True)(self.pyobject)
@@ -372,90 +447,29 @@ class ConfigInformation:
372
447
  context = ConfigWalkContext()
373
448
 
374
449
  class Unsealer(ConfigWalk):
375
- def preprocess(self, config: Config):
450
+ def preprocess(self, config: ConfigMixin):
376
451
  return config.__xpm__._sealed, config
377
452
 
378
- def postprocess(self, stub, config: Config, values):
453
+ def postprocess(self, stub, config: ConfigMixin, values):
379
454
  config.__xpm__._sealed = False
380
455
  config.__xpm__._identifier = None
381
456
 
382
457
  Unsealer(context, recurse_task=True)(self.pyobject)
383
458
 
384
- def collect_pre_tasks(self) -> Iterator["Config"]:
385
- context = ConfigWalkContext()
386
- pre_tasks: Dict[int, "Config"] = {}
387
-
388
- class PreTaskCollect(ConfigWalk):
389
- def preprocess(self, config: Config):
390
- # Do not cross tasks
391
- return not isinstance(config.__xpm__, Task), config
392
-
393
- def postprocess(self, stub, config: Config, values):
394
- pre_tasks.update(
395
- {id(pre_task): pre_task for pre_task in config.__xpm__.pre_tasks}
396
- )
397
-
398
- PreTaskCollect(context, recurse_task=True)(self.pyobject)
399
- return pre_tasks.values()
400
-
401
- def identifiers(self, only_raw: bool):
459
+ @property
460
+ def identifier(self):
402
461
  """Computes the unique identifier"""
403
- from ..identifier import IdentifierComputer, Identifier
404
-
405
- raw_identifier = self._raw_identifier
406
- full_identifier = self._full_identifier
462
+ from ..identifier import IdentifierComputer
407
463
 
408
464
  # Computes raw identifier if needed
409
- if raw_identifier is None or not self._sealed:
410
- # Get the main identifier
411
- raw_identifier = IdentifierComputer.compute(self.pyobject)
412
- if self._sealed:
413
- self._raw_identifier = raw_identifier
414
-
415
- if only_raw:
416
- return raw_identifier, full_identifier
417
-
418
- # OK, let's compute the full identifier
419
- if full_identifier is None or not self._sealed:
420
- # Compute the full identifier by including the pre-tasks
421
- hasher = hashlib.sha256()
422
- hasher.update(raw_identifier.all)
423
- pre_tasks_ids = [
424
- pre_task.__xpm__.raw_identifier.all
425
- for pre_task in self.collect_pre_tasks()
426
- ]
427
- for task_id in sorted(pre_tasks_ids):
428
- hasher.update(task_id)
429
-
430
- # Adds init tasks
431
- if self.init_tasks:
432
- hasher.update(IdentifierComputer.INIT_TASKS)
433
- for init_task in self.init_tasks:
434
- hasher.update(init_task.__xpm__.raw_identifier.all)
435
-
436
- full_identifier = Identifier(hasher.digest())
437
- full_identifier.has_loops = raw_identifier.has_loops
465
+ if self._identifier is not None:
466
+ return self._identifier
438
467
 
439
- # Only cache the identifier if sealed
440
- if self._sealed:
441
- self._full_identifier = full_identifier
442
-
443
- return raw_identifier, full_identifier
444
-
445
- @property
446
- def raw_identifier(self) -> "Identifier":
447
- """Computes the unique identifier (without task modifiers)"""
448
- raw_identifier, _ = self.identifiers(True)
449
- return raw_identifier
450
-
451
- @property
452
- def full_identifier(self) -> "Identifier":
453
- """Computes the unique identifier (with task modifiers)"""
454
- _, full_identifier = self.identifiers(False)
455
- return full_identifier
456
-
457
- identifier = full_identifier
458
- """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
459
473
 
460
474
  def dependency(self):
461
475
  """Returns a dependency"""
@@ -470,12 +484,6 @@ class ConfigInformation:
470
484
  path: List[str],
471
485
  taskids: Set[int],
472
486
  ):
473
- # Add pre-tasks
474
- for pre_task in self.pre_tasks:
475
- pre_task.__xpm__.updatedependencies(
476
- dependencies, path + ["__pre_tasks__"], taskids
477
- )
478
-
479
487
  # Add initialization tasks
480
488
  for init_task in self.init_tasks:
481
489
  init_task.__xpm__.updatedependencies(
@@ -618,10 +626,11 @@ class ConfigInformation:
618
626
  ) or RunMode.NORMAL
619
627
  if run_mode == RunMode.NORMAL:
620
628
  TaskEventListener.connect(experiment.CURRENT)
629
+ experiment.CURRENT.submit(self.job)
621
630
  other = experiment.CURRENT.submit(self.job)
622
631
  if other:
623
- # Just returns the other task
624
- return other.config.__xpm__._taskoutput
632
+ # Our job = previously submitted job
633
+ self.job = other
625
634
  else:
626
635
  # Show a warning
627
636
  if run_mode == RunMode.GENERATE_ONLY:
@@ -657,13 +666,6 @@ class ConfigInformation:
657
666
 
658
667
  print(file=sys.stderr) # noqa: T201
659
668
 
660
- # Handle an output configuration # FIXME: remove
661
- def mark_output(config: "Config"):
662
- """Sets a dependency on the job"""
663
- assert not isinstance(config, Task), "Cannot set a dependency on a task"
664
- config.__xpm__.task = self.pyobject
665
- return config
666
-
667
669
  # Mark this configuration also
668
670
  self.task = self.pyobject
669
671
 
@@ -752,9 +754,6 @@ class ConfigInformation:
752
754
  if self.task is not None and self.task is not self:
753
755
  ConfigInformation.__collect_objects__(self.task, objects, context)
754
756
 
755
- # Serialize pre-tasks
756
- ConfigInformation.__collect_objects__(self.pre_tasks, objects, context)
757
-
758
757
  # Serialize initialization tasks
759
758
  ConfigInformation.__collect_objects__(self.init_tasks, objects, context)
760
759
 
@@ -768,8 +767,6 @@ class ConfigInformation:
768
767
  }
769
768
 
770
769
  # Add pre/init tasks
771
- if self.pre_tasks:
772
- state_dict["pre-tasks"] = [id(pre_task) for pre_task in self.pre_tasks]
773
770
  if self.init_tasks:
774
771
  state_dict["init-tasks"] = [id(init_task) for init_task in self.init_tasks]
775
772
 
@@ -949,34 +946,31 @@ class ConfigInformation:
949
946
 
950
947
  @overload
951
948
  @staticmethod
952
- def fromParameters(
949
+ def fromParameters( # noqa: E704
953
950
  definitions: List[Dict],
954
951
  as_instance=True,
955
952
  save_directory: Optional[Path] = None,
956
953
  discard_id: bool = False,
957
- ) -> "ConfigMixin":
958
- ...
954
+ ) -> "ConfigMixin": ...
959
955
 
960
956
  @overload
961
957
  @staticmethod
962
- def fromParameters(
958
+ def fromParameters( # noqa: E704
963
959
  definitions: List[Dict],
964
960
  as_instance=False,
965
961
  return_tasks=True,
966
962
  save_directory: Optional[Path] = None,
967
963
  discard_id: bool = False,
968
- ) -> Tuple["Config", List["LightweightTask"]]:
969
- ...
964
+ ) -> Tuple["Config", List["LightweightTask"]]: ...
970
965
 
971
966
  @overload
972
967
  @staticmethod
973
- def fromParameters(
968
+ def fromParameters( # noqa: E704
974
969
  definitions: List[Dict],
975
970
  as_instance=False,
976
971
  save_directory: Optional[Path] = None,
977
972
  discard_id: bool = False,
978
- ) -> "Config":
979
- ...
973
+ ) -> "Config": ...
980
974
 
981
975
  @staticmethod
982
976
  def load_objects( # noqa: C901
@@ -1101,12 +1095,6 @@ class ConfigInformation:
1101
1095
  o.__post_init__()
1102
1096
 
1103
1097
  else:
1104
- # Sets pre-tasks
1105
- o.__xpm__.pre_tasks = [
1106
- objects[pre_task_id]
1107
- for pre_task_id in definition.get("pre-tasks", [])
1108
- ]
1109
-
1110
1098
  if task_id := definition.get("task", None):
1111
1099
  o.__xpm__.task = objects[task_id]
1112
1100
 
@@ -1140,15 +1128,6 @@ class ConfigInformation:
1140
1128
 
1141
1129
  # Run pre-task (or returns them)
1142
1130
  if as_instance or return_tasks:
1143
- # Collect pre-tasks (just once)
1144
- completed_pretasks = set()
1145
- pre_tasks = []
1146
- for definition in definitions:
1147
- for pre_task_id in definition.get("pre-tasks", []):
1148
- if pre_task_id not in completed_pretasks:
1149
- completed_pretasks.add(pre_task_id)
1150
- pre_tasks.append(objects[pre_task_id])
1151
-
1152
1131
  # Collect init tasks
1153
1132
  init_tasks = []
1154
1133
  for init_task_id in definitions[-1].get("init-tasks", []):
@@ -1156,14 +1135,11 @@ class ConfigInformation:
1156
1135
  init_tasks.append(init_task)
1157
1136
 
1158
1137
  if as_instance:
1159
- for pre_task in pre_tasks:
1160
- logger.info("Executing pre-task %s", type(pre_task))
1161
- pre_task.execute()
1162
1138
  for init_task in init_tasks:
1163
1139
  logger.info("Executing init task %s", type(init_task))
1164
1140
  init_task.execute()
1165
1141
  else:
1166
- return o, pre_tasks, pre_task + init_tasks
1142
+ return o, init_tasks
1167
1143
 
1168
1144
  return o
1169
1145
 
@@ -1171,7 +1147,6 @@ class ConfigInformation:
1171
1147
  def __init__(self, context: ConfigWalkContext, *, objects: ObjectStore = None):
1172
1148
  super().__init__(context)
1173
1149
  self.objects = ObjectStore() if objects is None else objects
1174
- self.pre_tasks = {}
1175
1150
 
1176
1151
  def preprocess(self, config: "Config"):
1177
1152
  if self.objects.is_constructed(id(config)):
@@ -1198,10 +1173,6 @@ class ConfigInformation:
1198
1173
  # Call __post_init__
1199
1174
  stub.__post_init__()
1200
1175
 
1201
- # Gather pre-tasks
1202
- for pre_task in config.__xpm__.pre_tasks:
1203
- self.pre_tasks[id(pre_task)] = self.stub(pre_task)
1204
-
1205
1176
  self.objects.set_constructed(id(config))
1206
1177
  return stub
1207
1178
 
@@ -1215,10 +1186,6 @@ class ConfigInformation:
1215
1186
  processor = ConfigInformation.FromPython(context, objects=objects)
1216
1187
  last_object = processor(self.pyobject)
1217
1188
 
1218
- # Execute pre-tasks
1219
- for pre_task in processor.pre_tasks.values():
1220
- pre_task.execute()
1221
-
1222
1189
  return last_object
1223
1190
 
1224
1191
  def add_dependencies(self, *dependencies):
@@ -1242,6 +1209,9 @@ def clone(v):
1242
1209
  if isinstance(v, Enum):
1243
1210
  return v
1244
1211
 
1212
+ if isinstance(v, tuple):
1213
+ return tuple(clone(x) for x in v)
1214
+
1245
1215
  if isinstance(v, Config):
1246
1216
  # Create a new instance
1247
1217
  kwargs = {
@@ -1260,6 +1230,11 @@ class ConfigMixin:
1260
1230
  """Class for configuration objects"""
1261
1231
 
1262
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"""
1263
1238
 
1264
1239
  def __init__(self, **kwargs):
1265
1240
  """Initialize the configuration with the given parameters"""
@@ -1396,29 +1371,7 @@ class ConfigMixin:
1396
1371
  attributes)"""
1397
1372
  return clone(self)
1398
1373
 
1399
- def add_pretasks(self, *tasks: "LightweightTask"):
1400
- assert all(
1401
- [isinstance(task, LightweightTask) for task in tasks]
1402
- ), "One of the pre-tasks are not lightweight tasks"
1403
- if self.__xpm__._sealed:
1404
- raise SealedError("Cannot add pre-tasks to a sealed configuration")
1405
- self.__xpm__.pre_tasks.extend(tasks)
1406
- return self
1407
-
1408
- def add_pretasks_from(self, *configs: "Config"):
1409
- assert all(
1410
- [isinstance(config, ConfigMixin) for config in configs]
1411
- ), "One of the parameters is not a configuration object"
1412
- for config in configs:
1413
- self.add_pretasks(*config.__xpm__.pre_tasks)
1414
- return self
1415
-
1416
- @property
1417
- def pre_tasks(self) -> List["LightweightTask"]:
1418
- """Access pre-tasks"""
1419
- return self.__xpm__.pre_tasks
1420
-
1421
- def copy_dependencies(self, other: "Config"):
1374
+ def copy_dependencies(self, other: "ConfigMixin"):
1422
1375
  """Add all the dependencies from other configuration"""
1423
1376
 
1424
1377
  # Add task dependency
@@ -1441,10 +1394,6 @@ class Config:
1441
1394
  """The object type holds all the information about a specific subclass
1442
1395
  experimaestro metadata"""
1443
1396
 
1444
- __xpm__: ConfigInformation
1445
- """The __xpm__ object contains all instance specific information about a
1446
- configuration/task"""
1447
-
1448
1397
  @classproperty
1449
1398
  def XPMConfig(cls):
1450
1399
  if issubclass(cls, ConfigMixin):
@@ -1557,17 +1506,7 @@ class Config:
1557
1506
  return self.__xpm__.__json__()
1558
1507
 
1559
1508
  def __identifier__(self) -> "Identifier":
1560
- return self.__xpm__.full_identifier
1561
-
1562
- def add_pretasks(self, *tasks: "LightweightTask"):
1563
- """Add pre-tasks"""
1564
- raise AssertionError("This method can only be used during configuration")
1565
-
1566
- def add_pretasks_from(self, *configs: "Config"):
1567
- """Add pre-tasks from the listed configurations"""
1568
- raise AssertionError(
1569
- "The 'add_pretasks_from' can only be used during configuration"
1570
- )
1509
+ return self.__xpm__.identifier
1571
1510
 
1572
1511
  def copy_dependencies(self, other: "Config"):
1573
1512
  """Add pre-tasks from the listed configurations"""
@@ -1575,11 +1514,6 @@ class Config:
1575
1514
  "The 'copy_dependencies' method can only be used during configuration"
1576
1515
  )
1577
1516
 
1578
- @property
1579
- def pre_tasks(self) -> List["LightweightTask"]:
1580
- """Access pre-tasks"""
1581
- raise AssertionError("Pre-tasks can be accessed only during configuration")
1582
-
1583
1517
  def register_task_output(self, method, *args, **kwargs):
1584
1518
  # Determine the path for this...
1585
1519
  path = taskglobals.Env.instance().xpm_path / "task-outputs.jsonl"