experimaestro 0.22.0__py2.py3-none-any.whl → 0.24.0__py2.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.

Potentially problematic release.


This version of experimaestro might be problematic. Click here for more details.

@@ -2,6 +2,12 @@
2
2
 
3
3
  from functools import cached_property
4
4
  import json
5
+
6
+ try:
7
+ from types import NoneType
8
+ except Exception:
9
+ # compatibility: python-3.8
10
+ NoneType = type(None)
5
11
  from termcolor import cprint
6
12
  import os
7
13
  from pathlib import Path
@@ -14,12 +20,12 @@ import inspect
14
20
  import importlib
15
21
  from typing import (
16
22
  Any,
17
- Callable,
18
23
  ClassVar,
19
24
  Dict,
20
25
  List,
21
26
  Optional,
22
27
  Set,
28
+ Tuple,
23
29
  Type,
24
30
  TypeVar,
25
31
  Union,
@@ -35,6 +41,7 @@ from experimaestro.core.types import DeprecatedAttribute, ObjectType
35
41
  from .context import SerializationContext, SerializedPath, SerializedPathLoader
36
42
 
37
43
  if TYPE_CHECKING:
44
+ from experimaestro.scheduler.base import Job
38
45
  from experimaestro.scheduler.workspace import RunMode
39
46
  from experimaestro.launchers import Launcher
40
47
  from experimaestro.scheduler import Workspace
@@ -167,7 +174,7 @@ class HashComputer:
167
174
  for key, value in items:
168
175
  self.update(key, subparam=subparam)
169
176
  self.update(value, subparam=subparam)
170
- elif isinstance(value, TaskOutput):
177
+ elif isinstance(value, ConfigWrapper):
171
178
  # Add the task ID...
172
179
  self.update(value.__xpm__.task, subparam=subparam)
173
180
 
@@ -276,8 +283,14 @@ def updatedependencies(
276
283
  elif isinstance(value, (list, set)):
277
284
  for el in value:
278
285
  updatedependencies(dependencies, el, path, taskids)
279
- elif isinstance(value, TaskOutput):
280
- dependencies.add(value.__xpm__.task.__xpm__.dependency())
286
+ elif isinstance(value, ConfigWrapper):
287
+ # Add the base value (if any)
288
+ if value.__xpm__.base is not None:
289
+ value.__xpm__.base.__xpm__.updatedependencies(dependencies, path, taskids)
290
+
291
+ # Add the task (if any)
292
+ if value.__xpm__.task is not None:
293
+ dependencies.add(value.__xpm__.task.__xpm__.dependency())
281
294
  elif isinstance(value, (dict,)):
282
295
  for key, val in value.items():
283
296
  updatedependencies(dependencies, key, path, taskids)
@@ -307,7 +320,7 @@ def add_to_path(p):
307
320
  sys.path = old_path
308
321
 
309
322
 
310
- class GenerationContext:
323
+ class ConfigWalkContext:
311
324
  """Context when generating values in configurations"""
312
325
 
313
326
  @property
@@ -342,33 +355,38 @@ class GenerationContext:
342
355
  NOT_SET = object()
343
356
 
344
357
 
345
- class ConfigProcessing:
358
+ class ConfigWalk:
346
359
  """Allows to perform an operation on all nested configurations"""
347
360
 
348
- def __init__(self, recurse_task=False):
361
+ def __init__(self, context: ConfigWalkContext = None, recurse_task=False):
349
362
  """
350
363
 
351
- Parameters:
352
- recurse_task: Recurse into linked tasks
364
+ :param recurse_task: Recurse into linked tasks
365
+ :param context: The context, by default only tracks the position in the
366
+ config tree
353
367
  """
354
368
  self.recurse_task = recurse_task
369
+ self.context = ConfigWalkContext() if context is None else context
355
370
 
356
371
  # Stores already visited nodes
357
372
  self.visited = {}
358
373
 
359
- def preprocess(self, config: "Config"):
374
+ def preprocess(self, config: "Config") -> Tuple[bool, Any]:
375
+ """Returns a tuple boolean/value
376
+
377
+ The boolean value is used to stop the processing if False.
378
+ The value is returned
379
+ """
360
380
  return True, None
361
381
 
362
382
  def postprocess(self, config: "Config", values: Dict[str, Any]):
363
383
  return config
364
384
 
365
- @contextmanager
366
385
  def list(self, i: int):
367
- yield i
386
+ return self.context.push(str(i))
368
387
 
369
- @contextmanager
370
388
  def map(self, k: str):
371
- yield k
389
+ return self.context.push(k)
372
390
 
373
391
  def __call__(self, x):
374
392
  if isinstance(x, Config):
@@ -412,9 +430,13 @@ class ConfigProcessing:
412
430
  result[key] = self(value)
413
431
  return result
414
432
 
415
- if isinstance(x, TaskOutput):
433
+ if isinstance(x, ConfigWrapper):
416
434
  # Process task if different
417
- if self.recurse_task and x.__xpm__.task is not x.__unwrap__():
435
+ if (
436
+ x.__xpm__.task is not None
437
+ and self.recurse_task
438
+ and x.__xpm__.task is not x.__unwrap__()
439
+ ):
418
440
  self(x.__xpm__.task)
419
441
 
420
442
  # Processed the wrapped config
@@ -423,19 +445,10 @@ class ConfigProcessing:
423
445
  if isinstance(x, (float, int, str, Path, Enum)):
424
446
  return x
425
447
 
426
- raise NotImplementedError(f"Cannot handle a value of type {type(x)}")
427
-
428
-
429
- class GenerationConfigProcessing(ConfigProcessing):
430
- def __init__(self, context: GenerationContext, recurse_task=False):
431
- super().__init__(recurse_task=recurse_task)
432
- self.context = context
433
-
434
- def list(self, i: int):
435
- return self.context.push(str(i))
448
+ if isinstance(x, Proxy):
449
+ return self(x.__unwrap__())
436
450
 
437
- def map(self, k: str):
438
- return self.context.push(k)
451
+ raise NotImplementedError(f"Cannot handle a value of type {type(x)}")
439
452
 
440
453
 
441
454
  def getqualattr(module, qualname):
@@ -453,7 +466,7 @@ class ConfigInformation:
453
466
  """Forces this configuration to be a meta-parameter"""
454
467
 
455
468
  # Set to true when loading from JSON
456
- LOADING = False
469
+ LOADING: ClassVar[bool] = False
457
470
 
458
471
  def __init__(self, pyobject: "TypeConfig"):
459
472
  # The underlying pyobject and XPM type
@@ -464,6 +477,7 @@ class ConfigInformation:
464
477
  # Meta-informations
465
478
  self._tags = {}
466
479
  self._initinfo = ""
480
+ self.submit_hooks = set()
467
481
 
468
482
  # Generated task
469
483
  self._taskoutput = None
@@ -481,6 +495,7 @@ class ConfigInformation:
481
495
  self._meta = None
482
496
 
483
497
  def set_meta(self, value: Optional[bool]):
498
+ """Sets the meta flag"""
484
499
  assert not self._sealed, "Configuration is sealed"
485
500
  self._meta = value
486
501
 
@@ -534,7 +549,7 @@ class ConfigInformation:
534
549
  yield argument, self.values[argument.name]
535
550
 
536
551
  def tags(self):
537
- class TagFinder(ConfigProcessing):
552
+ class TagFinder(ConfigWalk):
538
553
  def __init__(self):
539
554
  super().__init__(recurse_task=True)
540
555
  self.tags = {}
@@ -578,7 +593,7 @@ class ConfigInformation:
578
593
  )
579
594
  raise
580
595
 
581
- def seal(self, context: GenerationContext):
596
+ def seal(self, context: ConfigWalkContext):
582
597
  """Seal the object, generating values when needed,
583
598
  before scheduling the associated job(s)
584
599
 
@@ -586,7 +601,7 @@ class ConfigInformation:
586
601
  - context: the generation context
587
602
  """
588
603
 
589
- class Sealer(GenerationConfigProcessing):
604
+ class Sealer(ConfigWalk):
590
605
  def preprocess(self, config: Config):
591
606
  return not config.__xpm__._sealed, config
592
607
 
@@ -607,9 +622,9 @@ class ConfigInformation:
607
622
 
608
623
  Internal API - do not use
609
624
  """
610
- context = GenerationContext()
625
+ context = ConfigWalkContext()
611
626
 
612
- class Unsealer(GenerationConfigProcessing):
627
+ class Unsealer(ConfigWalk):
613
628
  def preprocess(self, config: Config):
614
629
  return config.__xpm__._sealed, config
615
630
 
@@ -658,13 +673,35 @@ class ConfigInformation:
658
673
  logger.error("While setting %s", path + [argument.name])
659
674
  raise
660
675
 
676
+ def apply_submit_hooks(self, job: "Job"):
677
+ """Apply configuration hooks"""
678
+ context = ConfigWalkContext()
679
+
680
+ class HookGatherer(ConfigWalk):
681
+ def __init__(self, *args, **kwargs):
682
+ super().__init__(*args, **kwargs)
683
+ self.hooks = set()
684
+
685
+ def postprocess(self, config: "Config", values: Dict[str, Any]):
686
+ self.hooks.update(config.__xpm__.submit_hooks)
687
+
688
+ gatherer = HookGatherer(context, recurse_task=False)
689
+ gatherer(self.pyobject)
690
+ for hook in gatherer.hooks:
691
+ hook(job)
692
+
661
693
  def submit(
662
- self, workspace: "Workspace", launcher: "Launcher", run_mode=None
663
- ) -> "TaskOutput":
694
+ self, workspace: "Workspace", launcher: "Launcher", *, run_mode=None, pre=None
695
+ ) -> "ConfigWrapper":
664
696
  from experimaestro.scheduler import experiment, JobContext
665
697
  from experimaestro.scheduler.workspace import RunMode
666
698
 
699
+ # --- Handle default values
700
+
701
+ pre = pre or []
702
+
667
703
  # --- Prepare the object
704
+
668
705
  if self.job:
669
706
  raise Exception("task %s was already submitted" % self)
670
707
  if not self.xpmtype.task:
@@ -672,6 +709,7 @@ class ConfigInformation:
672
709
 
673
710
  # --- Submit the job
674
711
 
712
+ # Creates a new job
675
713
  self.job = self.xpmtype.task(
676
714
  self.pyobject, launcher=launcher, workspace=workspace, run_mode=run_mode
677
715
  )
@@ -696,7 +734,7 @@ class ConfigInformation:
696
734
  experiment.CURRENT.workspace if experiment.CURRENT else None
697
735
  )
698
736
 
699
- # Call onSubmit
737
+ # Call onSubmit hooks
700
738
  launcher = (
701
739
  launcher
702
740
  or (workspace and workspace.launcher)
@@ -705,6 +743,9 @@ class ConfigInformation:
705
743
  if launcher:
706
744
  launcher.onSubmit(self.job)
707
745
 
746
+ # Apply submit hooks
747
+ self.apply_submit_hooks(self.job)
748
+
708
749
  # Add job dependencies
709
750
  self.updatedependencies(self.job.dependencies, [], set([id(self.pyobject)]))
710
751
 
@@ -743,16 +784,20 @@ class ConfigInformation:
743
784
  hints = get_type_hints(self.pyobject.config)
744
785
  config = hints["return"](**config)
745
786
  config.__xpm__.validate()
746
- self._taskoutput = TaskOutput(config, self.pyobject)
787
+ self._taskoutput = ConfigWrapper.__create_taskoutput__(
788
+ config, self.pyobject
789
+ )
747
790
 
748
791
  # New way to handle outputs
749
792
  elif hasattr(self.pyobject, "taskoutputs"):
750
793
  value = self.pyobject.taskoutputs()
751
- self._taskoutput = TaskOutput(value, self.pyobject)
794
+ self._taskoutput = ConfigWrapper.__create_taskoutput__(value, self.pyobject)
752
795
 
753
796
  # Otherwise, the output is just the config
754
797
  else:
755
- self._taskoutput = TaskOutput(self.pyobject, self.pyobject)
798
+ self._taskoutput = ConfigWrapper.__create_taskoutput__(
799
+ self.pyobject, self.pyobject
800
+ )
756
801
 
757
802
  return self._taskoutput
758
803
 
@@ -794,20 +839,13 @@ class ConfigInformation:
794
839
  "value": value.name,
795
840
  }
796
841
 
797
- elif isinstance(value, SerializedTaskOutput):
798
- # Reference to a serialized object
799
- return {
800
- "type": "serialized",
801
- "value": id(value.__xpm__.serialized.loader),
802
- "path": [c.toJSON() for c in value.__xpm__.path],
803
- }
804
-
805
- elif isinstance(value, TaskOutput):
842
+ elif isinstance(value, ConfigWrapper):
806
843
  return {
807
844
  "type": "python",
808
845
  "value": id(value.__unwrap__()),
809
846
  # We add the task for identifier computation
810
847
  "task": id(value.__xpm__.task),
848
+ "path": [c.toJSON() for c in value.__xpm__.path or []],
811
849
  }
812
850
 
813
851
  elif isinstance(value, Config):
@@ -876,25 +914,18 @@ class ConfigInformation:
876
914
  def __collect_objects__(value, objects: List[Dict], context: SerializationContext):
877
915
  """Serialize all needed configuration objects, looking at sub
878
916
  configurations if necessary"""
879
- # objects
880
- if isinstance(value, SerializedTaskOutput):
881
- loader = value.__xpm__.serialized.loader
882
- if id(loader) not in context.serialized:
883
- objects.append(
884
- {
885
- "id": id(loader),
886
- "serialized": True,
887
- "module": loader.__class__.__module__,
888
- "type": loader.__class__.__qualname__,
889
- "value": loader.toJSON(),
890
- }
891
- )
892
- return
893
-
894
917
  # Unwrap if needed
895
- if isinstance(value, TaskOutput):
918
+ if isinstance(value, ConfigWrapper):
896
919
  # We will need to output the task configuration objects
897
- ConfigInformation.__collect_objects__(value.__xpm__.task, objects, context)
920
+ if value.__xpm__.task is not None:
921
+ ConfigInformation.__collect_objects__(
922
+ value.__xpm__.task, objects, context
923
+ )
924
+
925
+ if value.__xpm__.base is not None:
926
+ ConfigInformation.__collect_objects__(
927
+ value.__xpm__.base, objects, context
928
+ )
898
929
 
899
930
  # Unwrap the value to output it
900
931
  value = value.__unwrap__()
@@ -1024,7 +1055,7 @@ class ConfigInformation:
1024
1055
  if not as_instance:
1025
1056
  if task_id := value.get("task", None):
1026
1057
  task = objects[task_id]
1027
- return TaskOutput(obj, task)
1058
+ return ConfigWrapper.__create_taskoutput__(obj, task)
1028
1059
  return obj
1029
1060
 
1030
1061
  if value["type"] == "serialized":
@@ -1086,6 +1117,7 @@ class ConfigInformation:
1086
1117
  o = None
1087
1118
  objects = {}
1088
1119
  import experimaestro.taskglobals as taskglobals
1120
+ from .serializers import SerializedConfig
1089
1121
 
1090
1122
  for definition in definitions:
1091
1123
  module_name = definition["module"]
@@ -1168,7 +1200,10 @@ class ConfigInformation:
1168
1200
  assert isinstance(v, Path), "Excepted Path, got {type(v)}"
1169
1201
 
1170
1202
  if as_instance:
1203
+ # Unwrap the value if needed
1204
+ v = unwrap(v)
1171
1205
  setattr(o, name, v)
1206
+
1172
1207
  assert (
1173
1208
  getattr(o, name) is v
1174
1209
  ), f"Problem with deserialization {name} of {o.__class__}"
@@ -1178,6 +1213,8 @@ class ConfigInformation:
1178
1213
  if as_instance:
1179
1214
  # Calls post-init
1180
1215
  o.__post_init__()
1216
+ if isinstance(o, SerializedConfig):
1217
+ o.initialize()
1181
1218
  else:
1182
1219
  # Seal and set the identifier
1183
1220
  if not discard_id:
@@ -1194,8 +1231,8 @@ class ConfigInformation:
1194
1231
 
1195
1232
  return o
1196
1233
 
1197
- class FromPython(GenerationConfigProcessing):
1198
- def __init__(self, context: GenerationContext):
1234
+ class FromPython(ConfigWalk):
1235
+ def __init__(self, context: ConfigWalkContext):
1199
1236
  super().__init__(context)
1200
1237
  self.objects = {}
1201
1238
 
@@ -1231,9 +1268,16 @@ class ConfigInformation:
1231
1268
  # Call __post_init__
1232
1269
  o.__post_init__()
1233
1270
 
1271
+ # Process a serialized configuration
1272
+ from .serializers import SerializedConfig
1273
+
1274
+ if isinstance(o, SerializedConfig):
1275
+ o.initialize()
1276
+ o = o.__unwrap__()
1277
+
1234
1278
  return o
1235
1279
 
1236
- def fromConfig(self, context: GenerationContext):
1280
+ def fromConfig(self, context: ConfigWalkContext):
1237
1281
  """Generate an instance given the current configuration"""
1238
1282
  self.validate()
1239
1283
  processor = ConfigInformation.FromPython(context)
@@ -1378,7 +1422,7 @@ class TypeConfig:
1378
1422
  self.__xpm__.add_dependencies(*dependencies)
1379
1423
  return self
1380
1424
 
1381
- def instance(self, context: GenerationContext = None) -> T:
1425
+ def instance(self, context: ConfigWalkContext = None) -> T:
1382
1426
  """Return an instance with the current values"""
1383
1427
  if context is None:
1384
1428
  from experimaestro.xpmutils import EmptyContext
@@ -1386,19 +1430,22 @@ class TypeConfig:
1386
1430
  context = EmptyContext()
1387
1431
  else:
1388
1432
  assert isinstance(
1389
- context, GenerationContext
1390
- ), f"{context.__class__} is not an instance of GenerationContext"
1433
+ context, ConfigWalkContext
1434
+ ), f"{context.__class__} is not an instance of ConfigWalkContext"
1391
1435
  return self.__xpm__.fromConfig(context) # type: ignore
1392
1436
 
1393
- def submit(self, *, workspace=None, launcher=None, run_mode: "RunMode" = None):
1437
+ def submit(
1438
+ self, *, workspace=None, launcher=None, run_mode: "RunMode" = None, pre=None
1439
+ ):
1394
1440
  """Submit this task
1395
1441
 
1396
1442
  :param workspace: the workspace, defaults to None
1397
1443
  :param launcher: The launcher, defaults to None
1398
1444
  :param run_mode: Run mode (if None, uses the workspace default)
1399
- :return: a :py:class:TaskOutput object
1445
+ :param pre: Pre-tasks to execute before
1446
+ :return: a :py:class:ConfigWrapper object
1400
1447
  """
1401
- return self.__xpm__.submit(workspace, launcher, run_mode=run_mode)
1448
+ return self.__xpm__.submit(workspace, launcher, run_mode=run_mode, pre=pre)
1402
1449
 
1403
1450
  def stdout(self):
1404
1451
  return self.__xpm__.job.stdout
@@ -1434,7 +1481,7 @@ class Config:
1434
1481
  configuration/task"""
1435
1482
 
1436
1483
  @classmethod
1437
- def __getxpmtype__(cls):
1484
+ def __getxpmtype__(cls) -> "ObjectType":
1438
1485
  """Get (and create if necessary) the Object type of this"""
1439
1486
  xpmtype = cls.__dict__.get("__xpmtype__", None)
1440
1487
  if xpmtype is None:
@@ -1499,9 +1546,6 @@ class Task(Config):
1499
1546
  raise NotImplementedError()
1500
1547
 
1501
1548
 
1502
- # --- Output proxy
1503
-
1504
-
1505
1549
  class Proxy:
1506
1550
  """A proxy for a value"""
1507
1551
 
@@ -1509,7 +1553,29 @@ class Proxy:
1509
1553
  raise NotImplementedError()
1510
1554
 
1511
1555
 
1556
+ def unwrap(v: Any):
1557
+ """Unwrap all proxies"""
1558
+ while isinstance(v, Proxy):
1559
+ v = v.__unwrap__()
1560
+ return v
1561
+
1562
+
1563
+ class AttrAccessor:
1564
+ """Access an attribute"""
1565
+
1566
+ def __init__(self, key: str):
1567
+ self.key = key
1568
+
1569
+ def get(self, value):
1570
+ return getattr(value, self.key)
1571
+
1572
+ def toJSON(self):
1573
+ return {"type": "attr", "name": self.key}
1574
+
1575
+
1512
1576
  class ItemAccessor:
1577
+ """Access an array item"""
1578
+
1513
1579
  def __init__(self, key: Any):
1514
1580
  self.key = key
1515
1581
 
@@ -1520,49 +1586,65 @@ class ItemAccessor:
1520
1586
  return value.__getitem__(self.key)
1521
1587
 
1522
1588
 
1523
- class AttrAccessor:
1524
- def __init__(self, key: Any, default: Any):
1525
- self.key = key
1526
- self.default = default
1527
-
1528
- def get(self, value):
1529
- return getattr(value, self.key, self.default)
1530
-
1531
- def toJSON(self):
1532
- return {"type": "attr", "name": self.key}
1589
+ class ConfigWrapperInfo:
1590
+ """Global information about the Configuration wrapper"""
1533
1591
 
1592
+ def __init__(
1593
+ self,
1594
+ value: Any,
1595
+ *,
1596
+ task: Optional[Task] = None,
1597
+ parent: Optional["ConfigWrapperInfo"] = None,
1598
+ base: Optional[Config] = None,
1599
+ path: Optional[List[Path]] = None,
1600
+ ):
1601
+ # Current value
1602
+ self.value = value
1603
+ self.parent = parent
1534
1604
 
1535
- class Serialized:
1536
- """Simple serialization object"""
1605
+ # The task
1606
+ if isinstance(value, Task):
1607
+ self.task = value
1608
+ else:
1609
+ self.task = task
1537
1610
 
1538
- def __init__(self, value):
1539
- self.value = value
1611
+ # Holds serialized config information
1612
+ from .serializers import SerializedConfig
1540
1613
 
1541
- def toJSON(self):
1542
- return self.value
1614
+ if isinstance(value, SerializedConfig):
1615
+ self.base = value
1616
+ self.value = value.config
1617
+ self.path = [AttrAccessor("config")]
1618
+ else:
1619
+ self.base = base
1620
+ self.path = path
1543
1621
 
1622
+ def __state_dict__(self):
1623
+ return {"task": self.task, "base": self.base, "path": self.path}
1544
1624
 
1545
- class SerializedConfig:
1546
- """A serializable configuration
1625
+ def __getitem__(self, key: Any):
1626
+ kwargs = self.__state_dict__()
1627
+ value = self.value.__getitem__(key)
1547
1628
 
1548
- This can be used to define a loading mechanism when instanciating the
1549
- configuration
1550
- """
1629
+ if self.path:
1630
+ kwargs["path"].append(ItemAccessor(key))
1551
1631
 
1552
- pyobject: Config
1553
- """The configuration that will be serialized"""
1632
+ return ConfigWrapperInfo(value, **kwargs)
1554
1633
 
1555
- def __init__(self, pyobject: Config, loader: Callable[[Path], Config]):
1556
- self.pyobject = pyobject
1557
- self.loader = loader
1634
+ def __getattr__(self, key: str, *default) -> Any:
1635
+ kwargs = self.__state_dict__()
1636
+ value = getattr(self.value, key, *default)
1637
+ if self.path:
1638
+ kwargs["path"].append(AttrAccessor(key, *default))
1639
+ return ConfigWrapperInfo(value, parent=self, **kwargs)
1558
1640
 
1641
+ def wrap(self):
1642
+ """Wrap a value if needed"""
1643
+ if isinstance(self.value, (str, int, float, Path, bool, NoneType)):
1644
+ return self.value
1559
1645
 
1560
- class TaskOutputInfo:
1561
- def __init__(self, task: Task):
1562
- self.task = task
1563
- self.value = None
1564
- self.path = None
1565
- self.serialized = None
1646
+ # Returns a new config wrapper
1647
+ return ConfigWrapper(self)
1566
1648
 
1567
1649
  @property
1568
1650
  def identifier(self):
@@ -1573,92 +1655,64 @@ class TaskOutputInfo:
1573
1655
  return self.task.__xpm__.job
1574
1656
 
1575
1657
  def tags(self):
1658
+ """Returns the tags of the task"""
1576
1659
  tags = self.task.__xpm__.tags()
1577
1660
  return tags
1578
1661
 
1579
1662
  def stdout(self):
1663
+ """Returns the standard output of the associated task"""
1580
1664
  return self.task.__xpm__.job.stdout
1581
1665
 
1582
1666
  def stderr(self):
1667
+ """Returns the standard error of the associated task"""
1583
1668
  return self.task.__xpm__.job.stderr
1584
1669
 
1585
1670
  def wait(self):
1671
+ """Wait for the task to end
1672
+
1673
+ :return: True if the task completed without error
1674
+ """
1586
1675
  from experimaestro.scheduler import JobState
1587
1676
 
1588
1677
  return self.task.__xpm__.job.wait() == JobState.DONE
1589
1678
 
1590
1679
 
1591
- class TaskOutput(Proxy):
1680
+ # Cleanup into just one
1681
+ class ConfigWrapper(Proxy):
1592
1682
  """Task proxy
1593
1683
 
1594
1684
  This is used when accessing properties *after* having submitted a task,
1595
- to keep track of the dependencies
1685
+ to keep track of the dependencies, and/or as an accessor when dealing with
1686
+ a serialized config
1596
1687
  """
1597
1688
 
1598
- def __init__(self, value: Any, task: Union[Task, TaskOutputInfo]):
1599
- self.__xpm__ = (
1600
- task if isinstance(task, TaskOutputInfo) else TaskOutputInfo(task)
1601
- )
1602
- self.__xpm__.value = value
1603
-
1604
- def _wrap(self, value):
1605
- if isinstance(value, SerializedConfig):
1606
- return SerializedTaskOutput(value.pyobject, value, self.__xpm__.task, [])
1607
-
1608
- if isinstance(value, (str, int, float, Path, bool)):
1609
- # No need to wrap if direct
1610
- return value
1689
+ def __init__(self, info: ConfigWrapperInfo):
1690
+ self.__xpm__ = info
1611
1691
 
1612
- return TaskOutput(value, self.__xpm__.task)
1692
+ @staticmethod
1693
+ def __create_taskoutput__(value: Any, task: Task):
1694
+ return ConfigWrapperInfo(value, task=task).wrap()
1613
1695
 
1614
1696
  def __getitem__(self, key: Any):
1615
- return self._wrap(self.__xpm__.value.__getitem__(key))
1697
+ return self.__xpm__[key].wrap()
1616
1698
 
1617
- def __getattr__(self, key: str, default=None) -> Any:
1618
- return self._wrap(getattr(self.__xpm__.value, key, default))
1699
+ def __getattr__(self, key: str, *default) -> Any:
1700
+ return self.__xpm__.__getattr__(key, *default).wrap()
1619
1701
 
1620
1702
  def __unwrap__(self):
1621
1703
  return self.__xpm__.value
1622
1704
 
1623
1705
  def __call__(self, *args, **kwargs):
1624
1706
  assert callable(self.__xpm__.value), "Attribute is not a function"
1625
- __self__ = TaskOutput(self.__xpm__.value.__self__, self.__xpm__.task)
1626
- return self.__xpm__.value.__func__(__self__, *args, **kwargs)
1627
1707
 
1628
-
1629
- class SerializedTaskOutput(TaskOutput):
1630
- """Used when serializing a configuration
1631
-
1632
- Here, we need to keep track of the path to the value we need
1633
- """
1634
-
1635
- def __init__(
1636
- self, value, serialized: SerializedConfig, task: Task, path: List[Any]
1637
- ):
1638
- super().__init__(value, task)
1639
- self.__xpm__.serialized = serialized
1640
- self.__xpm__.path = path
1641
-
1642
- def __getitem__(self, key: Any):
1643
- value = self.__xpm__.value.__getitem__(key)
1644
- return SerializedTaskOutput(
1645
- value, self.serialized, self.__xpm__.task, self.path + [ItemAccessor(key)]
1646
- )
1647
-
1648
- def __getattr__(self, key: str, default=None) -> Any:
1649
- value = getattr(self.__xpm__.value, key, default)
1650
- return SerializedTaskOutput(
1651
- value,
1652
- self.__xpm__.serialized,
1653
- self.__xpm__.task,
1654
- self.__xpm__.path + [AttrAccessor(key, default)],
1655
- )
1708
+ __self__ = self.__xpm__.parent.wrap()
1709
+ return self.__xpm__.value.__func__(__self__, *args, **kwargs)
1656
1710
 
1657
1711
 
1658
1712
  # --- Utility functions
1659
1713
 
1660
1714
 
1661
- def copyconfig(config_or_output: Union[Config, TaskOutput], **kwargs):
1715
+ def copyconfig(config_or_output: Union[Config, ConfigWrapper], **kwargs):
1662
1716
  """Copy a configuration or task output
1663
1717
 
1664
1718
  Useful to modify a configuration that can be potentially
@@ -1666,7 +1720,7 @@ def copyconfig(config_or_output: Union[Config, TaskOutput], **kwargs):
1666
1720
  a task output).
1667
1721
  """
1668
1722
 
1669
- if isinstance(config_or_output, TaskOutput):
1723
+ if isinstance(config_or_output, ConfigWrapper):
1670
1724
  output = config_or_output
1671
1725
  config = config_or_output.__unwrap__()
1672
1726
  assert isinstance(config, Config)
@@ -1686,7 +1740,7 @@ def copyconfig(config_or_output: Union[Config, TaskOutput], **kwargs):
1686
1740
  return copy
1687
1741
 
1688
1742
  # wrap in Task output
1689
- return TaskOutput(copy, output.__xpm__)
1743
+ return ConfigWrapper(copy, output.__xpm__)
1690
1744
 
1691
1745
 
1692
1746
  def setmeta(config: Config, flag: bool):