experimaestro 1.6.1__py3-none-any.whl → 1.7.0__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.

Files changed (81) hide show
  1. experimaestro/__init__.py +3 -1
  2. experimaestro/annotations.py +13 -3
  3. experimaestro/cli/filter.py +3 -3
  4. experimaestro/cli/jobs.py +1 -1
  5. experimaestro/commandline.py +3 -7
  6. experimaestro/connectors/__init__.py +22 -10
  7. experimaestro/connectors/local.py +17 -8
  8. experimaestro/connectors/ssh.py +1 -1
  9. experimaestro/core/arguments.py +26 -3
  10. experimaestro/core/objects.py +90 -6
  11. experimaestro/core/objects.pyi +7 -1
  12. experimaestro/core/types.py +33 -2
  13. experimaestro/experiments/cli.py +21 -9
  14. experimaestro/generators.py +6 -1
  15. experimaestro/ipc.py +4 -1
  16. experimaestro/launcherfinder/registry.py +23 -5
  17. experimaestro/launchers/slurm/base.py +47 -9
  18. experimaestro/notifications.py +1 -1
  19. experimaestro/run.py +1 -1
  20. experimaestro/scheduler/base.py +102 -6
  21. experimaestro/scheduler/dynamic_outputs.py +184 -0
  22. experimaestro/scheduler/workspace.py +2 -1
  23. experimaestro/scriptbuilder.py +13 -2
  24. experimaestro/server/data/016b4a6cdced82ab3aa1.ttf +0 -0
  25. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  26. experimaestro/server/data/1815e00441357e01619e.ttf +0 -0
  27. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  28. experimaestro/server/data/2463b90d9a316e4e5294.woff2 +0 -0
  29. experimaestro/server/data/2582b0e4bcf85eceead0.ttf +0 -0
  30. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  31. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  32. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  33. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  34. experimaestro/server/data/50701fbb8177c2dde530.ttf +0 -0
  35. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  36. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  37. experimaestro/server/data/878f31251d960bd6266f.woff2 +0 -0
  38. experimaestro/server/data/89999bdf5d835c012025.woff2 +0 -0
  39. experimaestro/server/data/914997e1bdfc990d0897.ttf +0 -0
  40. experimaestro/server/data/b041b1fa4fe241b23445.woff2 +0 -0
  41. experimaestro/server/data/b6879d41b0852f01ed5b.woff2 +0 -0
  42. experimaestro/server/data/c210719e60948b211a12.woff2 +0 -0
  43. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  44. experimaestro/server/data/d75e3fd1eb12e9bd6655.ttf +0 -0
  45. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  46. experimaestro/server/data/favicon.ico +0 -0
  47. experimaestro/server/data/index.css +22963 -0
  48. experimaestro/server/data/index.css.map +1 -0
  49. experimaestro/server/data/index.html +27 -0
  50. experimaestro/server/data/index.js +101770 -0
  51. experimaestro/server/data/index.js.map +1 -0
  52. experimaestro/server/data/login.html +22 -0
  53. experimaestro/server/data/manifest.json +15 -0
  54. experimaestro/settings.py +2 -2
  55. experimaestro/sphinx/__init__.py +7 -17
  56. experimaestro/taskglobals.py +7 -2
  57. experimaestro/tests/definitions_types.py +5 -3
  58. experimaestro/tests/launchers/bin/sbatch +34 -7
  59. experimaestro/tests/launchers/bin/srun +5 -0
  60. experimaestro/tests/launchers/common.py +16 -4
  61. experimaestro/tests/restart.py +6 -3
  62. experimaestro/tests/tasks/all.py +16 -10
  63. experimaestro/tests/tasks/foreign.py +2 -4
  64. experimaestro/tests/test_forward.py +5 -5
  65. experimaestro/tests/test_identifier.py +61 -66
  66. experimaestro/tests/test_instance.py +3 -6
  67. experimaestro/tests/test_param.py +40 -22
  68. experimaestro/tests/test_tags.py +5 -11
  69. experimaestro/tests/test_tokens.py +3 -2
  70. experimaestro/tests/test_types.py +17 -14
  71. experimaestro/tests/test_validation.py +48 -91
  72. experimaestro/tokens.py +16 -5
  73. experimaestro/typingutils.py +7 -0
  74. experimaestro/utils/asyncio.py +6 -2
  75. experimaestro/utils/resources.py +7 -3
  76. {experimaestro-1.6.1.dist-info → experimaestro-1.7.0.dist-info}/METADATA +3 -4
  77. experimaestro-1.7.0.dist-info/RECORD +154 -0
  78. {experimaestro-1.6.1.dist-info → experimaestro-1.7.0.dist-info}/WHEEL +1 -1
  79. experimaestro-1.6.1.dist-info/RECORD +0 -122
  80. {experimaestro-1.6.1.dist-info → experimaestro-1.7.0.dist-info}/LICENSE +0 -0
  81. {experimaestro-1.6.1.dist-info → experimaestro-1.7.0.dist-info}/entry_points.txt +0 -0
experimaestro/__init__.py CHANGED
@@ -36,15 +36,17 @@ from .core.arguments import (
36
36
  DataPath,
37
37
  Annotated,
38
38
  Constant,
39
+ field,
39
40
  # Annotations helpers
40
41
  help,
41
42
  default,
42
43
  )
43
- from .generators import pathgenerator
44
+ from .generators import pathgenerator, PathGenerator
44
45
  from .core.objects import (
45
46
  Config,
46
47
  copyconfig,
47
48
  setmeta,
49
+ DependentMarker,
48
50
  Task,
49
51
  LightweightTask,
50
52
  ObjectStore,
@@ -8,7 +8,7 @@ import experimaestro.core.objects as objects
8
8
  import experimaestro.core.types as types
9
9
  from experimaestro.generators import PathGenerator
10
10
 
11
- from .core.arguments import Argument as CoreArgument
11
+ from .core.arguments import Argument as CoreArgument, field
12
12
  from .core.objects import Config
13
13
  from .core.types import Any, Identifier, TypeProxy, Type, ObjectType
14
14
  from .utils import logger
@@ -134,12 +134,22 @@ class param:
134
134
  self.type = Type.fromType(type) if type else None
135
135
  self.help = help
136
136
  self.ignored = ignored
137
- self.default = default
138
137
  self.required = required
139
- self.generator = None
140
138
  self.checker = checker
141
139
  self.constant = constant
142
140
 
141
+ self.generator = None
142
+ self.default = None
143
+
144
+ # Set default or generator
145
+ if isinstance(default, field):
146
+ if default.default is not None:
147
+ self.default = default
148
+ elif default.default_factory is not None:
149
+ self.generator = default.default_factory
150
+ else:
151
+ self.default = default
152
+
143
153
  def __call__(self, tp):
144
154
  # Don't annotate in task mode
145
155
  tp.__getxpmtype__().addAnnotation(self)
@@ -3,7 +3,7 @@ import pyparsing as pp
3
3
  from pathlib import Path
4
4
  import json
5
5
  from experimaestro.compat import cached_property
6
- import regex
6
+ import re
7
7
  from experimaestro.scheduler import JobState
8
8
 
9
9
 
@@ -87,7 +87,7 @@ class NotInExpr(BaseInExpr):
87
87
  class RegexExpr:
88
88
  def __init__(self, tokens):
89
89
  self.var, expr = tokens
90
- self.regex = regex.compile(expr)
90
+ self.regex = re.compile(expr)
91
91
 
92
92
  def __repr__(self):
93
93
  return f"""REGEX[{self.varname}, {self.value}]"""
@@ -103,7 +103,7 @@ class RegexExpr:
103
103
  if not value:
104
104
  return False
105
105
 
106
- return self.regex.match(value)
106
+ return self.re.match(value)
107
107
 
108
108
 
109
109
  class ConstantString:
experimaestro/cli/jobs.py CHANGED
@@ -69,7 +69,7 @@ def process(
69
69
  if (p / "jobs.bak").is_dir():
70
70
  cprint(f" Experiment {p.name} has not finished yet", "red")
71
71
  if (not perform) and (kill or clean):
72
- cprint(" Preventing kill/clean (use --force if you want to)", "yellow")
72
+ cprint(" Preventing kill/clean (use --perform if you want to)", "yellow")
73
73
  kill = False
74
74
  clean = False
75
75
 
@@ -276,12 +276,6 @@ class CommandLineJob(Job):
276
276
 
277
277
  scriptbuilder = self.launcher.scriptbuilder()
278
278
  self.path.mkdir(parents=True, exist_ok=True)
279
- donepath = self.donepath
280
-
281
- # Check again if done (now that we have locked)
282
- if not overwrite and donepath.is_file():
283
- logger.info("Job %s is already done", self)
284
- return JobState.DONE
285
279
 
286
280
  # Now we can write the script
287
281
  scriptbuilder.lockfiles.append(self.lockpath)
@@ -293,15 +287,17 @@ class CommandLineJob(Job):
293
287
  if self._process:
294
288
  return self._process
295
289
 
290
+ # Prepare the files to be run
296
291
  scriptPath = self.prepare()
297
292
 
293
+ # OK, now starts the process
298
294
  logger.info("Starting job %s", self.jobpath)
299
295
  processbuilder = self.launcher.processbuilder()
300
296
  processbuilder.environ = self.environ
301
297
  processbuilder.command.append(self.launcher.connector.resolve(scriptPath))
302
298
  processbuilder.stderr = Redirect.file(self.stderr)
303
299
  processbuilder.stdout = Redirect.file(self.stdout)
304
- self._process = processbuilder.start()
300
+ self._process = processbuilder.start(True)
305
301
 
306
302
  with self.pidpath.open("w") as fp:
307
303
  json.dump(self._process.tospec(), fp)
@@ -9,7 +9,8 @@ This module contains :
9
9
  """
10
10
 
11
11
  import enum
12
- from typing import Any, Dict, Mapping, Type, Union
12
+ import logging
13
+ from typing import Any, Dict, Mapping, Type, Union, Optional
13
14
  from pathlib import Path
14
15
  from experimaestro.utils import logger
15
16
  from experimaestro.locking import Lock
@@ -86,12 +87,12 @@ class Process:
86
87
  @staticmethod
87
88
  def fromDefinition(connector: "Connector", definition: Dict[str, Any]) -> "Process":
88
89
  """Retrieves a process from a serialized definition"""
89
- handler = Process.handler(definition["type"])
90
+ handler_type = definition["type"]
91
+ handler = Process.handler(handler_type)
92
+ assert handler is not None, f"No handler of type {handler_type}"
90
93
  try:
91
94
  return handler.fromspec(connector, definition)
92
95
  except Exception as e:
93
- import logging
94
-
95
96
  logging.exception("Could not retrieve job from specification")
96
97
  raise e
97
98
 
@@ -101,7 +102,11 @@ class Process:
101
102
  if Process.HANDLERS is None:
102
103
  Process.HANDLERS = {}
103
104
  for ep in pkg_resources.iter_entry_points(group="experimaestro.process"):
104
- Process.HANDLERS[ep.name] = ep.load()
105
+ logging.debug("Adding process handler for type %s", ep.name)
106
+ handler = ep.load()
107
+ Process.HANDLERS[ep.name] = handler
108
+ if handler is None:
109
+ logging.error("Handler of type %s is null", ep.name)
105
110
 
106
111
  return Process.HANDLERS.get(key, None)
107
112
 
@@ -117,10 +122,14 @@ class Process:
117
122
  """True is the process is truly running (I/O)"""
118
123
  return (await self.aio_state()).running
119
124
 
120
- async def aio_code(self):
121
- """Returns a future containing the returned code"""
125
+ async def aio_code(self) -> Optional[int]:
126
+ """Returns a future containing the returned code
127
+
128
+ Returns None if the process has already finished – and no information is
129
+ known about the process.
130
+ """
122
131
  code = await asyncThreadcheck("aio_code", self.wait)
123
- logger.debug("Got for return code %s: %s", self, code)
132
+ logger.debug("Got return code %s for %s", code, self)
124
133
  return code
125
134
 
126
135
  def kill(self):
@@ -145,8 +154,11 @@ class ProcessBuilder:
145
154
  self.environ: Mapping[str, str] = {}
146
155
  self.command = []
147
156
 
148
- def start(self) -> Process:
149
- """Start the process"""
157
+ def start(self, task_mode: bool = False) -> Process:
158
+ """Start the process
159
+
160
+ :param task_mode: True if the process is a job script
161
+ """
150
162
  raise NotImplementedError("Method not implemented in %s" % self.__class__)
151
163
 
152
164
 
@@ -1,7 +1,7 @@
1
- """All classes related to localhost management
2
- """
1
+ """All classes related to localhost management"""
3
2
 
4
3
  import subprocess
4
+ from typing import Optional
5
5
  from pathlib import Path, WindowsPath, PosixPath
6
6
  import os
7
7
  import threading
@@ -29,11 +29,13 @@ class PsutilProcess(Process):
29
29
  def __init__(self, pid: int):
30
30
  self._process = psutil.Process(pid)
31
31
 
32
- def wait(self) -> int:
32
+ def wait(self) -> Optional[int]:
33
33
  logger.debug("Waiting (psutil) for process with PID %s", self._process.pid)
34
34
  code = self._process.wait()
35
35
  logger.debug(
36
- "Finished to wait (psutil) for process with PID %s", self._process.pid
36
+ "Finished to wait (psutil) for process with PID %s: code %s",
37
+ self._process.pid,
38
+ code,
37
39
  )
38
40
  return code
39
41
 
@@ -57,7 +59,9 @@ class LocalProcess(Process):
57
59
  logger.debug("Waiting (python) for process with PID %s", self._process.pid)
58
60
  code = self._process.wait()
59
61
  logger.debug(
60
- "Finished to wait (python) for process with PID %s", self._process.pid
62
+ "Finished to wait (python) for process with PID %s: %s",
63
+ self._process.pid,
64
+ code,
61
65
  )
62
66
  return code
63
67
 
@@ -102,8 +106,11 @@ def getstream(redirect: Redirect, write: bool):
102
106
 
103
107
 
104
108
  class LocalProcessBuilder(ProcessBuilder):
105
- def start(self):
106
- """Start the process"""
109
+ def start(self, task_mode=False):
110
+ """Start the process
111
+
112
+ :param task_mode: just ignored
113
+ """
107
114
  stdin = getstream(self.stdin, False)
108
115
  stdout = getstream(self.stdout, True)
109
116
  stderr = getstream(self.stderr, True)
@@ -194,7 +201,9 @@ class LocalConnector(Connector):
194
201
  return LocalProcessBuilder()
195
202
 
196
203
  def resolve(self, path: Path, basepath: Path = None) -> str:
197
- assert isinstance(path, PosixPath) or isinstance(path, WindowsPath)
204
+ assert isinstance(path, PosixPath) or isinstance(
205
+ path, WindowsPath
206
+ ), f"Unrecognized path {type(path)}"
198
207
  if not basepath:
199
208
  return str(path.absolute())
200
209
  try:
@@ -200,7 +200,7 @@ class SshProcessBuilder(ProcessBuilder):
200
200
  super().__init__()
201
201
  self.connector = connector
202
202
 
203
- def start(self):
203
+ def start(self, task_mode: bool = False):
204
204
  """Start the process"""
205
205
 
206
206
  trans = str.maketrans({'"': r"\"", "$": r"\$"})
@@ -1,6 +1,6 @@
1
1
  """Management of the arguments (params, options, etc) associated with the XPM objects"""
2
2
 
3
- from typing import Optional, TypeVar, TYPE_CHECKING
3
+ from typing import Optional, TypeVar, TYPE_CHECKING, Callable, Any
4
4
  from experimaestro.typingutils import get_optional
5
5
  from pathlib import Path
6
6
  import sys
@@ -75,11 +75,22 @@ class Argument:
75
75
  self.constant = constant
76
76
  self.ignored = self.type.ignore if ignored is None else ignored
77
77
  self.required = required
78
- self.default = default
79
- self.generator = generator
80
78
  self.objecttype = None
81
79
  self.is_data = is_data
82
80
 
81
+ self.generator = generator
82
+ self.default = None
83
+
84
+ if default is not None:
85
+ assert self.generator is None, "generator and default are exclusive options"
86
+ if isinstance(default, field):
87
+ if default.default is not None:
88
+ self.default = default.default
89
+ elif default.default_factory is not None:
90
+ self.generator = default.default_factory
91
+ else:
92
+ self.default = default
93
+
83
94
  assert (
84
95
  not self.constant or self.default is not None
85
96
  ), "Cannot be constant without default"
@@ -170,6 +181,18 @@ DataPath = Annotated[Path, dataHint]
170
181
  """Annotates a path that should be kept to restore an object to its state"""
171
182
 
172
183
 
184
+ class field:
185
+ """Extra information for a given experimaestro field (param or meta)"""
186
+
187
+ def __init__(self, *, default: Any = None, default_factory: Callable = None):
188
+ assert not (
189
+ (default is not None) and (default_factory is not None)
190
+ ), "default and default_factory are mutually exclusive options"
191
+
192
+ self.default_factory = default_factory
193
+ self.default = default
194
+
195
+
173
196
  class help(TypeAnnotation):
174
197
  def __init__(self, text: str):
175
198
  self.text = text
@@ -3,6 +3,10 @@
3
3
  from functools import cached_property
4
4
  import json
5
5
 
6
+ from attr import define
7
+
8
+ from experimaestro import taskglobals
9
+
6
10
  try:
7
11
  from types import NoneType
8
12
  except Exception:
@@ -21,6 +25,7 @@ import inspect
21
25
  import importlib
22
26
  from typing import (
23
27
  Any,
28
+ Callable,
24
29
  ClassVar,
25
30
  Dict,
26
31
  Iterator,
@@ -136,6 +141,8 @@ class ConfigPath:
136
141
 
137
142
  hash_logger = logging.getLogger("xpm.hash")
138
143
 
144
+ DependentMarker = Callable[["Config"], None]
145
+
139
146
 
140
147
  class HashComputer:
141
148
  """This class is in charge of computing a config/task identifier"""
@@ -555,6 +562,24 @@ class ObjectStore:
555
562
  self.store[identifier] = stub
556
563
 
557
564
 
565
+ @define()
566
+ class WatchedOutput:
567
+ #: The enclosing job
568
+ job: "Job"
569
+
570
+ #: The configuration containing the watched output
571
+ config: "ConfigInformation"
572
+
573
+ #: The watched output (name)
574
+ method_name: str
575
+
576
+ #: The watched output method (called with the JSON event)
577
+ method: Callable
578
+
579
+ #: The callback to call (with the output of the previous method)
580
+ callback: Callable
581
+
582
+
558
583
  class ConfigInformation:
559
584
  """Holds experimaestro information for a config (or task) instance"""
560
585
 
@@ -595,6 +620,9 @@ class ConfigInformation:
595
620
  # Initialization tasks
596
621
  self.init_tasks: List["LightweightTask"] = []
597
622
 
623
+ # Watched outputs
624
+ self.watched_outputs: List[WatchedOutput] = []
625
+
598
626
  # Cached information
599
627
 
600
628
  self._full_identifier = None
@@ -724,9 +752,16 @@ class ConfigInformation:
724
752
  # Generate values
725
753
  for k, argument in config.__xpmtype__.arguments.items():
726
754
  if argument.generator:
727
- config.__xpm__.set(
728
- k, argument.generator(self.context, config), bypass=True
729
- )
755
+ sig = inspect.signature(argument.generator)
756
+ if len(sig.parameters) == 2:
757
+ value = argument.generator(self.context, config)
758
+ elif len(sig.parameters) == 0:
759
+ value = argument.generator()
760
+ else:
761
+ assert (
762
+ False
763
+ ), "generator has either two parameters (context and config), or none"
764
+ config.__xpm__.set(k, value, bypass=True)
730
765
 
731
766
  config.__xpm__._sealed = True
732
767
 
@@ -899,6 +934,19 @@ class ConfigInformation:
899
934
  # Now, seal the object
900
935
  self.seal(context)
901
936
 
937
+ def watch_output(self, method, callback):
938
+ """Watch the task output linked with a given method
939
+
940
+ :param method: The method to watch
941
+ :param callback: The callback
942
+ """
943
+ watched = WatchedOutput(
944
+ self, method.__self__, method.__name__, method, callback
945
+ )
946
+ self.watched_outputs.append(watched)
947
+ if self.job:
948
+ self.job.watch_output(watched)
949
+
902
950
  def submit(
903
951
  self,
904
952
  workspace: "Workspace",
@@ -1000,7 +1048,7 @@ class ConfigInformation:
1000
1048
 
1001
1049
  print(file=sys.stderr) # noqa: T201
1002
1050
 
1003
- # Handle an output configuration
1051
+ # Handle an output configuration # FIXME: remove
1004
1052
  def mark_output(config: "Config"):
1005
1053
  """Sets a dependency on the job"""
1006
1054
  assert not isinstance(config, Task), "Cannot set a dependency on a task"
@@ -1011,12 +1059,18 @@ class ConfigInformation:
1011
1059
  self.task = self.pyobject
1012
1060
 
1013
1061
  if hasattr(self.pyobject, "task_outputs"):
1014
- self._taskoutput = self.pyobject.task_outputs(mark_output)
1062
+ self._taskoutput = self.pyobject.task_outputs(self.mark_output)
1015
1063
  else:
1016
1064
  self._taskoutput = self.task = self.pyobject
1017
1065
 
1018
1066
  return self._taskoutput
1019
1067
 
1068
+ def mark_output(self, config: "Config"):
1069
+ """Sets a dependency on the job"""
1070
+ assert not isinstance(config, Task), "Cannot set a dependency on a task"
1071
+ config.__xpm__.task = self.pyobject
1072
+ return config
1073
+
1020
1074
  # --- Serialization
1021
1075
 
1022
1076
  @staticmethod
@@ -1345,7 +1399,9 @@ class ConfigInformation:
1345
1399
  mod = importlib.import_module(module_name)
1346
1400
  except ModuleNotFoundError:
1347
1401
  # More hints on the nature of the error
1348
- logging.warning("(1) Either the python path is wrong – %s", ":".join(sys.path))
1402
+ logging.warning(
1403
+ "(1) Either the python path is wrong – %s", ":".join(sys.path)
1404
+ )
1349
1405
  logging.warning("(2) There is not __init__.py in your module")
1350
1406
  raise
1351
1407
 
@@ -1790,6 +1846,10 @@ class classproperty(property):
1790
1846
  class Config:
1791
1847
  """Base type for all objects in python interface"""
1792
1848
 
1849
+ __xpmid__: ClassVar[Optional[str]]
1850
+ """Optional configuration ID, mostly useful when moving a class to another
1851
+ package to avoid changes in computed task identifiers"""
1852
+
1793
1853
  __xpmtype__: ClassVar[ObjectType]
1794
1854
  """The object type holds all the information about a specific subclass
1795
1855
  experimaestro metadata"""
@@ -1914,6 +1974,22 @@ class Config:
1914
1974
  """Access pre-tasks"""
1915
1975
  raise AssertionError("Pre-tasks can be accessed only during configuration")
1916
1976
 
1977
+ def register_task_output(self, method, *args, **kwargs):
1978
+ # Determine the path for this...
1979
+ path = taskglobals.Env.instance().xpm_path / "task-outputs.jsonl"
1980
+ path.parent.mkdir(parents=True, exist_ok=True)
1981
+
1982
+ data = json.dumps(
1983
+ {
1984
+ "key": f"{self.__xpmidentifier__}/{method.__name__}",
1985
+ "args": args,
1986
+ "kwargs": kwargs,
1987
+ }
1988
+ )
1989
+ with path.open("at") as fp:
1990
+ fp.writelines([data, "\n"])
1991
+ fp.flush()
1992
+
1917
1993
 
1918
1994
  class LightweightTask(Config):
1919
1995
  """A task that can be run before or after a real task to modify its behaviour"""
@@ -1931,6 +2007,14 @@ class Task(LightweightTask):
1931
2007
  def submit(self):
1932
2008
  raise AssertionError("This method can only be used during configuration")
1933
2009
 
2010
+ def watch_output(self, method, callback):
2011
+ """Sets up a callback
2012
+
2013
+ :param method: a method within a configuration
2014
+ :param callback: the callback
2015
+ """
2016
+ self.__xpm__.watch_output(method, callback)
2017
+
1934
2018
 
1935
2019
  # --- Utility functions
1936
2020
 
@@ -1,4 +1,5 @@
1
1
  from abc import ABC
2
+ from attrs import define
2
3
  import typing_extensions
3
4
 
4
5
  from experimaestro.core.types import ObjectType
@@ -108,6 +109,10 @@ class ConfigWalk(ConfigProcessing):
108
109
  def map(self, k: str): ...
109
110
 
110
111
  def getqualattr(module, qualname): ...
112
+ @define(frozen=True)
113
+ class WatchedOutput:
114
+ config: "Config"
115
+ method_name: str
111
116
 
112
117
  class ConfigInformation:
113
118
  LOADING: bool
@@ -116,6 +121,7 @@ class ConfigInformation:
116
121
  values: Dict[str, Any]
117
122
  job: Job
118
123
  dependencies: Incomplete
124
+ watched_outputs: List[WatchedOutput]
119
125
  def __init__(self, pyobject: TypeConfig) -> None: ...
120
126
  def set_meta(self, value: Optional[bool]): ...
121
127
  @property
@@ -231,7 +237,7 @@ class LightweightTask(Config):
231
237
  def execute(self) -> None: ...
232
238
 
233
239
  class Task(LightweightTask):
234
- __tags__: Dict[str, str]
240
+ # __tags__: Dict[str, str]
235
241
 
236
242
  def submit(
237
243
  self,
@@ -26,6 +26,7 @@ if typing.TYPE_CHECKING:
26
26
 
27
27
  class Identifier:
28
28
  def __init__(self, name: str):
29
+ assert isinstance(name, str)
29
30
  self.name = name
30
31
 
31
32
  def __hash__(self):
@@ -126,6 +127,9 @@ class Type:
126
127
  if t:
127
128
  return DictType(Type.fromType(t[0]), Type.fromType(t[1]))
128
129
 
130
+ if union_t := typingutils.get_union(key):
131
+ return UnionType([Type.fromType(t) for t in union_t])
132
+
129
133
  # Takes care of generics
130
134
  if get_origin(key):
131
135
  return GenericType(key)
@@ -223,7 +227,7 @@ class ObjectType(Type):
223
227
  __xpmid__ = getattr(tp, "__xpmid__")
224
228
  if isinstance(__xpmid__, Identifier):
225
229
  identifier = __xpmid__
226
- if inspect.ismethod(__xpmid__):
230
+ elif inspect.ismethod(__xpmid__):
227
231
  identifier = Identifier(__xpmid__())
228
232
  elif "__xpmid__" in tp.__dict__:
229
233
  identifier = Identifier(__xpmid__)
@@ -576,7 +580,7 @@ class PathType(Type):
576
580
  return Path(value.get("$value"))
577
581
 
578
582
  if not isinstance(value, (str, Path)):
579
- raise TypeError("value is not a pathlike value")
583
+ raise TypeError(f"value is not a pathlike value ({type(value)})")
580
584
  return Path(value)
581
585
 
582
586
  @property
@@ -631,6 +635,32 @@ class EnumType(Type):
631
635
  return f"Enum({self.type})"
632
636
 
633
637
 
638
+ class UnionType(Type):
639
+ def __init__(self, types: List[Type]):
640
+ self.types = types
641
+
642
+ def name(self):
643
+ return "Union[" + ", ".join(t.name() for t in self.types) + "]"
644
+
645
+ def __str__(self):
646
+ return "[" + " | ".join(t.name() for t in self.types) + " ]"
647
+
648
+ def __repr__(self):
649
+ return str(self)
650
+
651
+ def validate(self, value):
652
+ for subtype in self.types:
653
+ try:
654
+ return subtype.validate(value)
655
+ except ValueError:
656
+ pass
657
+ except TypeError:
658
+ pass
659
+
660
+ if not isinstance(value, dict):
661
+ raise ValueError(f"value is not within the types {self}")
662
+
663
+
634
664
  class DictType(Type):
635
665
  def __init__(self, keytype: Type, valuetype: Type):
636
666
  self.keytype = keytype
@@ -675,6 +705,7 @@ class GenericType(Type):
675
705
  (mro for mro in mros if (get_origin(mro) or mro) is self.origin), None
676
706
  )
677
707
  target = get_origin(self.type) or self.type
708
+
678
709
  if matching is None:
679
710
  raise ValueError(
680
711
  f"{type(value)} is not of type {target} ({type(value).__mro__})"