experimaestro 1.5.1__py3-none-any.whl → 2.0.0a8__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 (118) hide show
  1. experimaestro/__init__.py +14 -4
  2. experimaestro/__main__.py +3 -423
  3. experimaestro/annotations.py +14 -4
  4. experimaestro/cli/__init__.py +311 -0
  5. experimaestro/{filter.py → cli/filter.py} +23 -9
  6. experimaestro/cli/jobs.py +268 -0
  7. experimaestro/cli/progress.py +269 -0
  8. experimaestro/click.py +0 -35
  9. experimaestro/commandline.py +3 -7
  10. experimaestro/connectors/__init__.py +29 -14
  11. experimaestro/connectors/local.py +19 -10
  12. experimaestro/connectors/ssh.py +27 -8
  13. experimaestro/core/arguments.py +45 -3
  14. experimaestro/core/callbacks.py +52 -0
  15. experimaestro/core/context.py +8 -9
  16. experimaestro/core/identifier.py +310 -0
  17. experimaestro/core/objects/__init__.py +44 -0
  18. experimaestro/core/{objects.py → objects/config.py} +399 -772
  19. experimaestro/core/objects/config_utils.py +58 -0
  20. experimaestro/core/objects/config_walk.py +151 -0
  21. experimaestro/core/objects.pyi +15 -45
  22. experimaestro/core/serialization.py +63 -9
  23. experimaestro/core/serializers.py +1 -8
  24. experimaestro/core/types.py +104 -66
  25. experimaestro/experiments/cli.py +154 -72
  26. experimaestro/experiments/configuration.py +10 -1
  27. experimaestro/generators.py +6 -1
  28. experimaestro/ipc.py +4 -1
  29. experimaestro/launcherfinder/__init__.py +1 -1
  30. experimaestro/launcherfinder/base.py +2 -18
  31. experimaestro/launcherfinder/parser.py +8 -3
  32. experimaestro/launcherfinder/registry.py +52 -140
  33. experimaestro/launcherfinder/specs.py +49 -10
  34. experimaestro/launchers/direct.py +0 -47
  35. experimaestro/launchers/slurm/base.py +54 -14
  36. experimaestro/mkdocs/__init__.py +1 -1
  37. experimaestro/mkdocs/base.py +6 -8
  38. experimaestro/notifications.py +38 -12
  39. experimaestro/progress.py +406 -0
  40. experimaestro/run.py +24 -3
  41. experimaestro/scheduler/__init__.py +18 -1
  42. experimaestro/scheduler/base.py +108 -808
  43. experimaestro/scheduler/dynamic_outputs.py +184 -0
  44. experimaestro/scheduler/experiment.py +387 -0
  45. experimaestro/scheduler/jobs.py +475 -0
  46. experimaestro/scheduler/signal_handler.py +32 -0
  47. experimaestro/scheduler/state.py +75 -0
  48. experimaestro/scheduler/workspace.py +27 -8
  49. experimaestro/scriptbuilder.py +18 -3
  50. experimaestro/server/__init__.py +36 -5
  51. experimaestro/server/data/1815e00441357e01619e.ttf +0 -0
  52. experimaestro/server/data/2463b90d9a316e4e5294.woff2 +0 -0
  53. experimaestro/server/data/2582b0e4bcf85eceead0.ttf +0 -0
  54. experimaestro/server/data/89999bdf5d835c012025.woff2 +0 -0
  55. experimaestro/server/data/914997e1bdfc990d0897.ttf +0 -0
  56. experimaestro/server/data/c210719e60948b211a12.woff2 +0 -0
  57. experimaestro/server/data/index.css +5187 -5068
  58. experimaestro/server/data/index.css.map +1 -1
  59. experimaestro/server/data/index.js +68887 -68064
  60. experimaestro/server/data/index.js.map +1 -1
  61. experimaestro/settings.py +45 -5
  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 +17 -5
  70. experimaestro/tests/launchers/config_slurm/launchers.py +25 -0
  71. experimaestro/tests/restart.py +10 -5
  72. experimaestro/tests/tasks/all.py +23 -10
  73. experimaestro/tests/tasks/foreign.py +2 -4
  74. experimaestro/tests/test_checkers.py +2 -2
  75. experimaestro/tests/test_dependencies.py +11 -17
  76. experimaestro/tests/test_experiment.py +73 -0
  77. experimaestro/tests/test_file_progress.py +425 -0
  78. experimaestro/tests/test_file_progress_integration.py +477 -0
  79. experimaestro/tests/test_findlauncher.py +12 -5
  80. experimaestro/tests/test_forward.py +5 -5
  81. experimaestro/tests/test_generators.py +93 -0
  82. experimaestro/tests/test_identifier.py +182 -158
  83. experimaestro/tests/test_instance.py +19 -27
  84. experimaestro/tests/test_objects.py +13 -20
  85. experimaestro/tests/test_outputs.py +6 -6
  86. experimaestro/tests/test_param.py +68 -30
  87. experimaestro/tests/test_progress.py +4 -4
  88. experimaestro/tests/test_serializers.py +24 -64
  89. experimaestro/tests/test_ssh.py +7 -0
  90. experimaestro/tests/test_tags.py +50 -21
  91. experimaestro/tests/test_tasks.py +42 -51
  92. experimaestro/tests/test_tokens.py +11 -8
  93. experimaestro/tests/test_types.py +24 -21
  94. experimaestro/tests/test_validation.py +67 -110
  95. experimaestro/tests/token_reschedule.py +1 -1
  96. experimaestro/tokens.py +24 -13
  97. experimaestro/tools/diff.py +8 -1
  98. experimaestro/typingutils.py +20 -11
  99. experimaestro/utils/asyncio.py +6 -2
  100. experimaestro/utils/multiprocessing.py +44 -0
  101. experimaestro/utils/resources.py +11 -3
  102. {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/METADATA +28 -36
  103. experimaestro-2.0.0a8.dist-info/RECORD +166 -0
  104. {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/WHEEL +1 -1
  105. {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/entry_points.txt +0 -4
  106. experimaestro/launchers/slurm/cli.py +0 -29
  107. experimaestro/launchers/slurm/configuration.py +0 -597
  108. experimaestro/scheduler/environment.py +0 -94
  109. experimaestro/server/data/016b4a6cdced82ab3aa1.ttf +0 -0
  110. experimaestro/server/data/50701fbb8177c2dde530.ttf +0 -0
  111. experimaestro/server/data/878f31251d960bd6266f.woff2 +0 -0
  112. experimaestro/server/data/b041b1fa4fe241b23445.woff2 +0 -0
  113. experimaestro/server/data/b6879d41b0852f01ed5b.woff2 +0 -0
  114. experimaestro/server/data/d75e3fd1eb12e9bd6655.ttf +0 -0
  115. experimaestro/tests/launchers/config_slurm/launchers.yaml +0 -134
  116. experimaestro/utils/yaml.py +0 -202
  117. experimaestro-1.5.1.dist-info/RECORD +0 -148
  118. {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info/licenses}/LICENSE +0 -0
@@ -1,34 +1,28 @@
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,
30
25
  Tuple,
31
- Type,
32
26
  TypeVar,
33
27
  Union,
34
28
  overload,
@@ -37,301 +31,30 @@ from typing import (
37
31
  import sys
38
32
  import experimaestro
39
33
  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
34
+ from experimaestro.core.types import DeprecatedAttribute, ObjectType, TypeVarType
35
+ from ..context import SerializationContext, SerializedPath, SerializedPathLoader
43
36
 
44
37
  if TYPE_CHECKING:
38
+ from ..callbacks import TaskEventListener
39
+ from ..identifier import Identifier
45
40
  from experimaestro.scheduler.base import Job
46
41
  from experimaestro.scheduler.workspace import RunMode
47
42
  from experimaestro.launchers import Launcher
48
43
  from experimaestro.scheduler import Workspace
49
44
 
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
45
+ from .config_walk import ConfigWalk, ConfigWalkContext
46
+ from .config_utils import (
47
+ getqualattr,
48
+ add_to_path,
49
+ TaggedValue,
50
+ ObjectStore,
51
+ classproperty,
52
+ )
325
53
 
326
- config_path = config_path or ConfigPath()
54
+ T = TypeVar("T", bound="Config")
327
55
 
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
56
 
334
- return identifier
57
+ DependentMarker = Callable[["Config"], None]
335
58
 
336
59
 
337
60
  def updatedependencies(
@@ -362,197 +85,61 @@ def updatedependencies(
362
85
  raise NotImplementedError("update dependencies for type %s" % type(value))
363
86
 
364
87
 
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
88
  NOT_SET = object()
423
89
 
424
90
 
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)
494
-
495
- if info.init_tasks:
496
- with self.map("__init_tasks__"):
497
- self(info.init_tasks)
498
-
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)
506
-
507
- processed = self.postprocess(stub, x, result)
508
- self.visited[xid] = processed
509
- return processed
510
-
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
91
+ @define()
92
+ class WatchedOutput:
93
+ #: The enclosing job
94
+ job: "Job"
528
95
 
529
- raise NotImplementedError(f"Cannot handle a value of type {type(x)}")
96
+ #: The configuration containing the watched output
97
+ config: "ConfigInformation"
530
98
 
99
+ #: The watched output (name)
100
+ method_name: str
531
101
 
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
102
+ #: The watched output method (called with the JSON event)
103
+ method: Callable
538
104
 
105
+ #: The callback to call (with the output of the previous method)
106
+ callback: Callable
539
107
 
540
- class ObjectStore:
541
- def __init__(self):
542
- self.store: Dict[int, Any] = {}
543
- self.constructed: Set[int] = set()
544
108
 
545
- def set_constructed(self, identifier: int):
546
- self.constructed.add(identifier)
109
+ def get_generated_paths(
110
+ v: Union["ConfigMixin", list, dict],
111
+ path: list[str] | None = None,
112
+ paths: list[str] | None = None,
113
+ ) -> list[str]:
114
+ """Get the list of generated paths, useful to track down those
547
115
 
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)
116
+ :param path: The current path
117
+ :param paths: The list of generated paths so far, defaults to None
118
+ :return: The full list of generated paths
119
+ """
120
+ paths = [] if paths is None else paths
121
+ path = [] if path is None else path
553
122
 
554
- def add_stub(self, identifier: int, stub: Any):
555
- self.store[identifier] = stub
123
+ if isinstance(v, list):
124
+ for ix, element in enumerate(v):
125
+ get_generated_paths(element, path + [f"[{ix}]"], paths)
126
+
127
+ elif isinstance(v, dict):
128
+ for key, element in v.items():
129
+ get_generated_paths(element, path + [f"[{key}]"], paths)
130
+
131
+ elif isinstance(v, ConfigMixin):
132
+ for key in v.__xpm__._generated_values:
133
+ value = v.__xpm__.values[key]
134
+ if isinstance(value, ConfigMixin) and value.__xpm__._generated_values:
135
+ path.append(key)
136
+ get_generated_paths(value, path, paths)
137
+ path.pop()
138
+ else:
139
+ paths.append(".".join(path + [key]))
140
+ else:
141
+ raise ValueError(f"Cannot handle type {type(v)}")
142
+ return paths
556
143
 
557
144
 
558
145
  class ConfigInformation:
@@ -564,14 +151,14 @@ class ConfigInformation:
564
151
  # Set to true when loading from JSON
565
152
  LOADING: ClassVar[bool] = False
566
153
 
567
- def __init__(self, pyobject: "TypeConfig"):
154
+ def __init__(self, pyobject: "ConfigMixin"):
568
155
  # The underlying pyobject and XPM type
569
156
  self.pyobject = pyobject
570
- self.xpmtype = pyobject.__xpmtype__ # type: ObjectType
157
+ self.xpmtype: "ObjectType" = pyobject.__xpmtype__
571
158
  self.values = {}
572
159
 
573
160
  # Meta-informations
574
- self._tags = {}
161
+ self._tags: dict[str, Any] = {}
575
162
  self._initinfo = ""
576
163
 
577
164
  self._taskoutput = None
@@ -582,28 +169,38 @@ class ConfigInformation:
582
169
 
583
170
  # State information
584
171
  self.job = None
172
+ self._job_listener: "TaskEventListener" | None = None
585
173
 
586
- # Explicitely added dependencies
174
+ #: True when this configuration was loaded from disk
175
+ self.loaded = False
176
+
177
+ # Explicitly added dependencies
587
178
  self.dependencies = []
588
179
 
589
- # Lightweight tasks
590
- self.pre_tasks: List["LightweightTask"] = []
180
+ # Concrete type variables resolutions
181
+ # This is used to check typevars coherence
182
+ self.concrete_typevars: Dict[TypeVar, type] = {}
591
183
 
592
184
  # Initialization tasks
593
185
  self.init_tasks: List["LightweightTask"] = []
594
186
 
595
- # Cached information
187
+ # Watched outputs
188
+ self.watched_outputs: List[WatchedOutput] = []
596
189
 
597
- self._full_identifier = None
598
- """The full identifier (with pre-tasks)"""
190
+ # Cached information
599
191
 
600
- self._raw_identifier = None
601
- """The identifier without taking into account pre-tasks"""
192
+ self._identifier = None
193
+ """The configuration identifier (cached when sealed)"""
602
194
 
603
195
  self._validated = False
604
196
  self._sealed = False
605
197
  self._meta = None
606
198
 
199
+ # This contains the list of generated values (using context) in this
200
+ # configuration or any sub-configuration, is generated. This prevents
201
+ # problem when a configuration with generated values is re-used.
202
+ self._generated_values = []
203
+
607
204
  def set_meta(self, value: Optional[bool]):
608
205
  """Sets the meta flag"""
609
206
  assert not self._sealed, "Configuration is sealed"
@@ -621,7 +218,34 @@ class ConfigInformation:
621
218
  # Not an argument, bypass
622
219
  return object.__getattribute__(self.pyobject, name)
623
220
 
221
+ @staticmethod
222
+ def is_generated_value(argument, value):
223
+ if argument.ignore_generated:
224
+ return False
225
+
226
+ if value is None:
227
+ return False
228
+
229
+ if isinstance(value, (int, str, float, bool, Enum, Path)):
230
+ return False
231
+
232
+ if isinstance(value, ConfigMixin):
233
+ return value.__xpm__._generated_values and value.__xpm__.task is None
234
+
235
+ if isinstance(value, list):
236
+ return any(ConfigInformation.is_generated_value(argument, x) for x in value)
237
+
238
+ if isinstance(value, dict):
239
+ return any(
240
+ ConfigInformation.is_generated_value(argument, x)
241
+ for x in value.values()
242
+ )
243
+
244
+ return False
245
+
624
246
  def set(self, k, v, bypass=False):
247
+ from experimaestro.generators import Generator
248
+
625
249
  # Not an argument, bypass
626
250
  if k not in self.xpmtype.arguments:
627
251
  setattr(self.pyobject, k, v)
@@ -630,13 +254,34 @@ class ConfigInformation:
630
254
  if self._sealed and not bypass:
631
255
  raise AttributeError(f"Object is read-only (trying to set {k})")
632
256
 
257
+ if not isinstance(v, ConfigMixin) and isinstance(v, Config):
258
+ raise AttributeError(
259
+ "Configuration (and not objects) should be used. Consider using .C(...)"
260
+ )
261
+
633
262
  try:
634
263
  argument = self.xpmtype.arguments.get(k, None)
635
264
  if argument:
636
- if not bypass and (argument.generator or argument.constant):
265
+ if ConfigInformation.is_generated_value(argument, v):
266
+ raise AttributeError(
267
+ f"Cannot set {k} to a configuration with generated values. "
268
+ "Here is the list of paths to help you: "
269
+ f"""{', '.join(get_generated_paths(v, [k]))}"""
270
+ )
271
+
272
+ if not bypass and (
273
+ (isinstance(argument.generator, Generator)) or argument.constant
274
+ ):
637
275
  raise AttributeError("Property %s is read-only" % (k))
638
276
  if v is not None:
639
277
  self.values[k] = argument.validate(v)
278
+ # Check for type variables
279
+ if type(argument.type) is TypeVarType:
280
+ self.check_typevar(argument.type.typevar, type(v))
281
+ if isinstance(v, Config):
282
+ # If the value is a Config, fuse type variables
283
+ v.__xpm__.fuse_concrete_typevars(self.concrete_typevars)
284
+ self.fuse_concrete_typevars(v.__xpm__.concrete_typevars)
640
285
  elif argument.required:
641
286
  raise AttributeError("Cannot set required attribute to None")
642
287
  else:
@@ -649,6 +294,43 @@ class ConfigInformation:
649
294
  logger.error("Error while setting value %s in %s", k, self.xpmtype)
650
295
  raise
651
296
 
297
+ def fuse_concrete_typevars(self, typevars: Dict[TypeVar, type]):
298
+ """Fuses concrete type variables with the current ones"""
299
+ for typevar, v in typevars.items():
300
+ self.check_typevar(typevar, v)
301
+
302
+ def check_typevar(self, typevar: TypeVar, v: type):
303
+ """Check if a type variable is coherent with the current typevars bindings,
304
+ updates the bindings if necessary"""
305
+ if typevar not in self.concrete_typevars:
306
+ self.concrete_typevars[typevar] = v
307
+ return
308
+
309
+ concrete_typevar = self.concrete_typevars[typevar]
310
+ bound = typevar.__bound__
311
+ # Check that v is a subclass of the typevar OR that typevar is a subclass of v
312
+ # Then set the concrete type variable to the most generic type
313
+
314
+ # First, limiting to the specified bound
315
+ if bound is not None:
316
+ if not issubclass(v, bound):
317
+ raise TypeError(
318
+ f"Type variable {typevar} is bound to {bound}, but tried to set it to {v}"
319
+ )
320
+
321
+ if issubclass(v, concrete_typevar):
322
+ # v is a subclass of the typevar, keep the typevar
323
+ return
324
+ if issubclass(concrete_typevar, v):
325
+ # typevar is a subclass of v, keep v
326
+ self.concrete_typevars[typevar] = v
327
+ return
328
+ raise TypeError(
329
+ f"Type variable {typevar} is already set to {self.concrete_typevars[typevar]}, "
330
+ f"but tried to set it to {v}"
331
+ f" (current typevars bindings: {self.concrete_typevars})"
332
+ )
333
+
652
334
  def addtag(self, name, value):
653
335
  self._tags[name] = value
654
336
 
@@ -688,10 +370,6 @@ class ConfigInformation:
688
370
  % (k, self.xpmtype, self._initinfo)
689
371
  )
690
372
 
691
- # Validate pre-tasks
692
- for pre_task in self.pre_tasks:
693
- pre_task.__xpm__.validate()
694
-
695
373
  # Validate init tasks
696
374
  for init_task in self.init_tasks:
697
375
  init_task.__xpm__.validate()
@@ -712,18 +390,68 @@ class ConfigInformation:
712
390
  Arguments:
713
391
  - context: the generation context
714
392
  """
393
+ if generated_keys := [
394
+ k
395
+ for k, v in self.values.items()
396
+ if ConfigInformation.is_generated_value(self.xpmtype.arguments[k], v)
397
+ ]:
398
+ raise AttributeError(
399
+ "Cannot seal a configuration with generated values:"
400
+ f"""{",".join(generated_keys)} in {context.currentpath}"""
401
+ )
715
402
 
716
403
  class Sealer(ConfigWalk):
717
- def preprocess(self, config: Config):
404
+ def preprocess(self, config: ConfigMixin):
718
405
  return not config.__xpm__._sealed, config
719
406
 
720
- def postprocess(self, stub, config: Config, values):
407
+ def postprocess(self, stub, config: ConfigMixin, values):
721
408
  # Generate values
409
+ from experimaestro.generators import Generator
410
+
722
411
  for k, argument in config.__xpmtype__.arguments.items():
723
- if argument.generator:
724
- config.__xpm__.set(
725
- k, argument.generator(self.context, config), bypass=True
412
+ try:
413
+ if argument.generator:
414
+ if not isinstance(argument.generator, Generator):
415
+ # Don't set if already set
416
+ if config.__xpm__.values.get(k) is not None:
417
+ continue
418
+ value = argument.generator()
419
+ else:
420
+ # Generate a value
421
+ sig = inspect.signature(argument.generator)
422
+ if len(sig.parameters) == 0:
423
+ value = argument.generator()
424
+ elif len(sig.parameters) == 2:
425
+ # Only in that case do we need to flag this configuration
426
+ # as containing generated values
427
+ if not argument.ignore_generated:
428
+ config.__xpm__._generated_values.append(k)
429
+ else:
430
+ logging.warning("Ignoring %s", k)
431
+ value = argument.generator(self.context, config)
432
+ else:
433
+ assert (
434
+ False
435
+ ), "generator has either two parameters (context and config), or none"
436
+ config.__xpm__.set(k, value, bypass=True)
437
+ else:
438
+ value = config.__xpm__.values.get(k)
439
+ except Exception:
440
+ logger.error(
441
+ "While setting %s of %s", argument.name, config.__xpmtype__
726
442
  )
443
+ raise
444
+
445
+ # Propagate the generated value flag
446
+ if (
447
+ value is not None
448
+ and isinstance(value, ConfigMixin)
449
+ and value.__xpm__._generated_values
450
+ ):
451
+ if not argument.ignore_generated:
452
+ config.__xpm__._generated_values.append(k)
453
+ else:
454
+ logging.warning("Ignoring %s", k)
727
455
 
728
456
  config.__xpm__._sealed = True
729
457
 
@@ -737,89 +465,29 @@ class ConfigInformation:
737
465
  context = ConfigWalkContext()
738
466
 
739
467
  class Unsealer(ConfigWalk):
740
- def preprocess(self, config: Config):
468
+ def preprocess(self, config: ConfigMixin):
741
469
  return config.__xpm__._sealed, config
742
470
 
743
- def postprocess(self, stub, config: Config, values):
471
+ def postprocess(self, stub, config: ConfigMixin, values):
744
472
  config.__xpm__._sealed = False
745
473
  config.__xpm__._identifier = None
746
474
 
747
475
  Unsealer(context, recurse_task=True)(self.pyobject)
748
476
 
749
- def collect_pre_tasks(self) -> Iterator["Config"]:
750
- context = ConfigWalkContext()
751
- pre_tasks: Dict[int, "Config"] = {}
752
-
753
- class PreTaskCollect(ConfigWalk):
754
- def preprocess(self, config: Config):
755
- # Do not cross tasks
756
- return not isinstance(config.__xpm__, Task), config
757
-
758
- def postprocess(self, stub, config: Config, values):
759
- pre_tasks.update(
760
- {id(pre_task): pre_task for pre_task in config.__xpm__.pre_tasks}
761
- )
762
-
763
- PreTaskCollect(context, recurse_task=True)(self.pyobject)
764
- return pre_tasks.values()
765
-
766
- def identifiers(self, only_raw: bool):
477
+ @property
478
+ def identifier(self):
767
479
  """Computes the unique identifier"""
768
-
769
- raw_identifier = self._raw_identifier
770
- full_identifier = self._full_identifier
480
+ from ..identifier import IdentifierComputer
771
481
 
772
482
  # Computes raw identifier if needed
773
- if raw_identifier is None or not self._sealed:
774
- # Get the main identifier
775
- raw_identifier = HashComputer.compute(self.pyobject)
776
- if self._sealed:
777
- self._raw_identifier = raw_identifier
778
-
779
- if only_raw:
780
- return raw_identifier, full_identifier
781
-
782
- # OK, let's compute the full identifier
783
- if full_identifier is None or not self._sealed:
784
- # Compute the full identifier by including the pre-tasks
785
- hasher = hashlib.sha256()
786
- hasher.update(raw_identifier.all)
787
- pre_tasks_ids = [
788
- pre_task.__xpm__.raw_identifier.all
789
- for pre_task in self.collect_pre_tasks()
790
- ]
791
- for task_id in sorted(pre_tasks_ids):
792
- hasher.update(task_id)
793
-
794
- # Adds init tasks
795
- if self.init_tasks:
796
- hasher.update(HashComputer.INIT_TASKS)
797
- for init_task in self.init_tasks:
798
- hasher.update(init_task.__xpm__.raw_identifier.all)
483
+ if self._identifier is not None:
484
+ return self._identifier
799
485
 
800
- full_identifier = Identifier(hasher.digest())
801
- full_identifier.has_loops = raw_identifier.has_loops
802
-
803
- # Only cache the identifier if sealed
804
- if self._sealed:
805
- self._full_identifier = full_identifier
806
-
807
- return raw_identifier, full_identifier
808
-
809
- @property
810
- def raw_identifier(self) -> Identifier:
811
- """Computes the unique identifier (without task modifiers)"""
812
- raw_identifier, _ = self.identifiers(True)
813
- return raw_identifier
814
-
815
- @property
816
- def full_identifier(self) -> Identifier:
817
- """Computes the unique identifier (with task modifiers)"""
818
- _, full_identifier = self.identifiers(False)
819
- return full_identifier
820
-
821
- identifier = full_identifier
822
- """Deprecated: use full_identifier"""
486
+ # Get the main identifier
487
+ identifier = IdentifierComputer.compute(self.pyobject)
488
+ if self._sealed:
489
+ self._identifier = identifier
490
+ return identifier
823
491
 
824
492
  def dependency(self):
825
493
  """Returns a dependency"""
@@ -834,20 +502,14 @@ class ConfigInformation:
834
502
  path: List[str],
835
503
  taskids: Set[int],
836
504
  ):
837
- # Add pre-tasks
838
- for pre_task in self.pre_tasks:
839
- pre_task.__xpm__.updatedependencies(
840
- dependencies, path + ["__pre_tasks__"], taskids
841
- )
842
-
843
505
  # Add initialization tasks
844
506
  for init_task in self.init_tasks:
845
507
  init_task.__xpm__.updatedependencies(
846
508
  dependencies, path + ["__init_tasks__"], taskids
847
509
  )
848
510
 
849
- # Check for an associated task
850
- if self.task:
511
+ # Check for an associated task (and not loaded)
512
+ if self.task and not self.loaded:
851
513
  if id(self.task) not in taskids:
852
514
  taskids.add(id(self.task))
853
515
  dependencies.add(self.task.__xpm__.dependency())
@@ -896,6 +558,28 @@ class ConfigInformation:
896
558
  # Now, seal the object
897
559
  self.seal(context)
898
560
 
561
+ def watch_output(self, method, callback):
562
+ """Watch the task output linked with a given method
563
+
564
+ :param method: The method to watch
565
+ :param callback: The callback
566
+ """
567
+ watched = WatchedOutput(
568
+ self, method.__self__, method.__name__, method, callback
569
+ )
570
+ self.watched_outputs.append(watched)
571
+ if self.job:
572
+ self.job.watch_output(watched)
573
+
574
+ def on_completed(self, callback: Callable[[], None]):
575
+ """Call a method when the task is completed successfully
576
+
577
+ :param callback: _description_
578
+ """
579
+ from ..callbacks import TaskEventListener
580
+
581
+ TaskEventListener.on_completed(self, callback)
582
+
899
583
  def submit(
900
584
  self,
901
585
  workspace: "Workspace",
@@ -906,6 +590,7 @@ class ConfigInformation:
906
590
  ):
907
591
  from experimaestro.scheduler import experiment, JobContext
908
592
  from experimaestro.scheduler.workspace import RunMode
593
+ from ..callbacks import TaskEventListener
909
594
 
910
595
  # --- Prepare the object
911
596
 
@@ -916,14 +601,14 @@ class ConfigInformation:
916
601
 
917
602
  # --- Submit the job
918
603
 
604
+ # Sets the init tasks
605
+ self.init_tasks = init_tasks
606
+
919
607
  # Creates a new job
920
608
  self.job = self.xpmtype.task(
921
609
  self.pyobject, launcher=launcher, workspace=workspace, run_mode=run_mode
922
610
  )
923
611
 
924
- # Sets the init tasks
925
- self.init_tasks = init_tasks
926
-
927
612
  # Validate the object
928
613
  job_context = JobContext(self.job)
929
614
  self.validate_and_seal(job_context)
@@ -958,10 +643,12 @@ class ConfigInformation:
958
643
  workspace.run_mode if run_mode is None else run_mode
959
644
  ) or RunMode.NORMAL
960
645
  if run_mode == RunMode.NORMAL:
646
+ TaskEventListener.connect(experiment.CURRENT)
647
+ experiment.CURRENT.submit(self.job)
961
648
  other = experiment.CURRENT.submit(self.job)
962
649
  if other:
963
- # Just returns the other task
964
- return other.config.__xpm__._taskoutput
650
+ # Our job = previously submitted job
651
+ self.job = other
965
652
  else:
966
653
  # Show a warning
967
654
  if run_mode == RunMode.GENERATE_ONLY:
@@ -979,6 +666,9 @@ class ConfigInformation:
979
666
  elif self.job.failedpath.is_file():
980
667
  color = "light_red"
981
668
  cprint(f"[failed] {s}", color, file=sys.stderr)
669
+ elif self.job.pidpath.is_file():
670
+ color = "blue"
671
+ cprint(f"[running] {s}", color, file=sys.stderr)
982
672
  else:
983
673
  color = "light_blue"
984
674
  cprint(f"[not run] {s}", color, file=sys.stderr)
@@ -994,23 +684,25 @@ class ConfigInformation:
994
684
 
995
685
  print(file=sys.stderr) # noqa: T201
996
686
 
997
- # Handle an output configuration
998
- def mark_output(config: "Config"):
999
- """Sets a dependency on the job"""
1000
- assert not isinstance(config, Task), "Cannot set a dependency on a task"
1001
- config.__xpm__.task = self.pyobject
1002
- return config
1003
-
1004
687
  # Mark this configuration also
1005
688
  self.task = self.pyobject
1006
689
 
1007
690
  if hasattr(self.pyobject, "task_outputs"):
1008
- self._taskoutput = self.pyobject.task_outputs(mark_output)
691
+ self._taskoutput = self.pyobject.task_outputs(self.mark_output)
1009
692
  else:
1010
693
  self._taskoutput = self.task = self.pyobject
1011
694
 
1012
695
  return self._taskoutput
1013
696
 
697
+ def mark_output(self, config: "Config"):
698
+ """Sets a dependency on the job"""
699
+ assert not isinstance(config, Task), "Cannot set a dependency on a task"
700
+ assert isinstance(
701
+ config, ConfigMixin
702
+ ), "Only configurations can be marked as dependent on a task"
703
+ config.__xpm__.task = self.pyobject
704
+ return config
705
+
1014
706
  # --- Serialization
1015
707
 
1016
708
  @staticmethod
@@ -1019,7 +711,7 @@ class ConfigInformation:
1019
711
  if value is None:
1020
712
  return None
1021
713
 
1022
- elif isinstance(value, list):
714
+ elif isinstance(value, (list, tuple)):
1023
715
  return [ConfigInformation._outputjsonvalue(el, context) for el in value]
1024
716
 
1025
717
  elif isinstance(value, dict):
@@ -1083,9 +775,6 @@ class ConfigInformation:
1083
775
  if self.task is not None and self.task is not self:
1084
776
  ConfigInformation.__collect_objects__(self.task, objects, context)
1085
777
 
1086
- # Serialize pre-tasks
1087
- ConfigInformation.__collect_objects__(self.pre_tasks, objects, context)
1088
-
1089
778
  # Serialize initialization tasks
1090
779
  ConfigInformation.__collect_objects__(self.init_tasks, objects, context)
1091
780
 
@@ -1093,14 +782,12 @@ class ConfigInformation:
1093
782
  state_dict = {
1094
783
  "id": id(self.pyobject),
1095
784
  "module": self.xpmtype._module,
1096
- "type": self.xpmtype.basetype.__qualname__,
785
+ "type": self.xpmtype.value_type.__qualname__,
1097
786
  "typename": self.xpmtype.name(),
1098
787
  "identifier": self.identifier.state_dict(),
1099
788
  }
1100
789
 
1101
790
  # Add pre/init tasks
1102
- if self.pre_tasks:
1103
- state_dict["pre-tasks"] = [id(pre_task) for pre_task in self.pre_tasks]
1104
791
  if self.init_tasks:
1105
792
  state_dict["init-tasks"] = [id(init_task) for init_task in self.init_tasks]
1106
793
 
@@ -1134,9 +821,12 @@ class ConfigInformation:
1134
821
  def __collect_objects__(value, objects: List[Dict], context: SerializationContext):
1135
822
  """Serialize all needed configuration objects, looking at sub
1136
823
  configurations if necessary"""
824
+ if value is None:
825
+ return
826
+
1137
827
  if isinstance(value, Config):
1138
828
  value.__xpm__.__get_objects__(objects, context)
1139
- elif isinstance(value, list):
829
+ elif isinstance(value, (list, tuple)):
1140
830
  for el in value:
1141
831
  ConfigInformation.__collect_objects__(el, objects, context)
1142
832
  elif isinstance(value, dict):
@@ -1183,6 +873,7 @@ class ConfigInformation:
1183
873
  "workspace": str(context.workspace.path.absolute()),
1184
874
  "tags": {key: value for key, value in self.tags().items()},
1185
875
  "version": 2,
876
+ "experimaestro": experimaestro.__version__,
1186
877
  "objects": self.__get_objects__([], context),
1187
878
  },
1188
879
  out,
@@ -1277,34 +968,31 @@ class ConfigInformation:
1277
968
 
1278
969
  @overload
1279
970
  @staticmethod
1280
- def fromParameters(
971
+ def fromParameters( # noqa: E704
1281
972
  definitions: List[Dict],
1282
973
  as_instance=True,
1283
974
  save_directory: Optional[Path] = None,
1284
975
  discard_id: bool = False,
1285
- ) -> "TypeConfig":
1286
- ...
976
+ ) -> "ConfigMixin": ...
1287
977
 
1288
978
  @overload
1289
979
  @staticmethod
1290
- def fromParameters(
980
+ def fromParameters( # noqa: E704
1291
981
  definitions: List[Dict],
1292
982
  as_instance=False,
1293
983
  return_tasks=True,
1294
984
  save_directory: Optional[Path] = None,
1295
985
  discard_id: bool = False,
1296
- ) -> Tuple["Config", List["LightweightTask"]]:
1297
- ...
986
+ ) -> Tuple["Config", List["LightweightTask"]]: ...
1298
987
 
1299
988
  @overload
1300
989
  @staticmethod
1301
- def fromParameters(
990
+ def fromParameters( # noqa: E704
1302
991
  definitions: List[Dict],
1303
992
  as_instance=False,
1304
993
  save_directory: Optional[Path] = None,
1305
994
  discard_id: bool = False,
1306
- ) -> "Config":
1307
- ...
995
+ ) -> "Config": ...
1308
996
 
1309
997
  @staticmethod
1310
998
  def load_objects( # noqa: C901
@@ -1317,6 +1005,7 @@ class ConfigInformation:
1317
1005
  o = None
1318
1006
  objects = {}
1319
1007
  import experimaestro.taskglobals as taskglobals
1008
+ from ..identifier import Identifier
1320
1009
 
1321
1010
  # Loop over all the definitions and create objects
1322
1011
  for definition in definitions:
@@ -1334,14 +1023,22 @@ class ConfigInformation:
1334
1023
  sys.modules[module_name] = mod
1335
1024
  spec.loader.exec_module(mod)
1336
1025
  else:
1337
- logger.debug("Importing module %s", definition["module"])
1338
- mod = importlib.import_module(module_name)
1026
+ try:
1027
+ logger.debug("Importing module %s", definition["module"])
1028
+ mod = importlib.import_module(module_name)
1029
+ except ModuleNotFoundError:
1030
+ # More hints on the nature of the error
1031
+ logging.warning(
1032
+ "(1) Either the python path is wrong – %s", ":".join(sys.path)
1033
+ )
1034
+ logging.warning("(2) There is not __init__.py in your module")
1035
+ raise
1339
1036
 
1340
1037
  cls = getqualattr(mod, definition["type"])
1341
1038
 
1342
1039
  # Creates an object (or a config)
1343
1040
  if as_instance:
1344
- o = cls.XPMValue.__new__(cls.XPMValue)
1041
+ o = cls.__new__(cls)
1345
1042
  else:
1346
1043
  o = cls.XPMConfig.__new__(cls.XPMConfig)
1347
1044
  assert definition["id"] not in objects, "Duplicate id %s" % definition["id"]
@@ -1382,12 +1079,13 @@ class ConfigInformation:
1382
1079
  else:
1383
1080
  o.__init__()
1384
1081
  xpminfo = o.__xpm__ # type: ConfigInformation
1082
+ xpminfo.loaded = True
1385
1083
 
1386
1084
  meta = definition.get("meta", None)
1387
1085
  if meta:
1388
1086
  xpminfo._meta = meta
1389
1087
  if xpminfo.xpmtype.task is not None:
1390
- o.__xpm__.job = object()
1088
+ xpminfo.job = object()
1391
1089
 
1392
1090
  # Set the fields
1393
1091
  for name, value in definition["fields"].items():
@@ -1419,12 +1117,6 @@ class ConfigInformation:
1419
1117
  o.__post_init__()
1420
1118
 
1421
1119
  else:
1422
- # Sets pre-tasks
1423
- o.__xpm__.pre_tasks = [
1424
- objects[pre_task_id]
1425
- for pre_task_id in definition.get("pre-tasks", [])
1426
- ]
1427
-
1428
1120
  if task_id := definition.get("task", None):
1429
1121
  o.__xpm__.task = objects[task_id]
1430
1122
 
@@ -1458,15 +1150,6 @@ class ConfigInformation:
1458
1150
 
1459
1151
  # Run pre-task (or returns them)
1460
1152
  if as_instance or return_tasks:
1461
- # Collect pre-tasks (just once)
1462
- completed_pretasks = set()
1463
- pre_tasks = []
1464
- for definition in definitions:
1465
- for pre_task_id in definition.get("pre-tasks", []):
1466
- if pre_task_id not in completed_pretasks:
1467
- completed_pretasks.add(pre_task_id)
1468
- pre_tasks.append(objects[pre_task_id])
1469
-
1470
1153
  # Collect init tasks
1471
1154
  init_tasks = []
1472
1155
  for init_task_id in definitions[-1].get("init-tasks", []):
@@ -1474,14 +1157,11 @@ class ConfigInformation:
1474
1157
  init_tasks.append(init_task)
1475
1158
 
1476
1159
  if as_instance:
1477
- for pre_task in pre_tasks:
1478
- logger.info("Executing pre-task %s", type(pre_task))
1479
- pre_task.execute()
1480
1160
  for init_task in init_tasks:
1481
1161
  logger.info("Executing init task %s", type(init_task))
1482
1162
  init_task.execute()
1483
1163
  else:
1484
- return o, pre_tasks, pre_task + init_tasks
1164
+ return o, init_tasks
1485
1165
 
1486
1166
  return o
1487
1167
 
@@ -1489,7 +1169,6 @@ class ConfigInformation:
1489
1169
  def __init__(self, context: ConfigWalkContext, *, objects: ObjectStore = None):
1490
1170
  super().__init__(context)
1491
1171
  self.objects = ObjectStore() if objects is None else objects
1492
- self.pre_tasks = {}
1493
1172
 
1494
1173
  def preprocess(self, config: "Config"):
1495
1174
  if self.objects.is_constructed(id(config)):
@@ -1501,7 +1180,7 @@ class ConfigInformation:
1501
1180
 
1502
1181
  if o is None:
1503
1182
  # Creates an object (and not a config)
1504
- o = config.XPMValue()
1183
+ o = config.__xpmtype__.value_type()
1505
1184
 
1506
1185
  # Store in cache
1507
1186
  self.objects.add_stub(id(config), o)
@@ -1516,10 +1195,6 @@ class ConfigInformation:
1516
1195
  # Call __post_init__
1517
1196
  stub.__post_init__()
1518
1197
 
1519
- # Gather pre-tasks
1520
- for pre_task in config.__xpm__.pre_tasks:
1521
- self.pre_tasks[id(pre_task)] = self.stub(pre_task)
1522
-
1523
1198
  self.objects.set_constructed(id(config))
1524
1199
  return stub
1525
1200
 
@@ -1533,10 +1208,6 @@ class ConfigInformation:
1533
1208
  processor = ConfigInformation.FromPython(context, objects=objects)
1534
1209
  last_object = processor(self.pyobject)
1535
1210
 
1536
- # Execute pre-tasks
1537
- for pre_task in processor.pre_tasks.values():
1538
- pre_task.execute()
1539
-
1540
1211
  return last_object
1541
1212
 
1542
1213
  def add_dependencies(self, *dependencies):
@@ -1560,6 +1231,9 @@ def clone(v):
1560
1231
  if isinstance(v, Enum):
1561
1232
  return v
1562
1233
 
1234
+ if isinstance(v, tuple):
1235
+ return tuple(clone(x) for x in v)
1236
+
1563
1237
  if isinstance(v, Config):
1564
1238
  # Create a new instance
1565
1239
  kwargs = {
@@ -1574,31 +1248,15 @@ def clone(v):
1574
1248
  raise NotImplementedError("Clone not implemented for type %s" % type(v))
1575
1249
 
1576
1250
 
1577
- def cache(fn, name: str):
1578
- def __call__(config, *args, **kwargs):
1579
- import experimaestro.taskglobals as taskglobals
1580
-
1581
- # Get path and create directory if needed
1582
- hexid = config.__xpmidentifier__ # type: Identifier
1583
- typename = config.__xpmtypename__ # type: str
1584
- dir = taskglobals.Env.instance().wspath / "config" / typename / hexid.all.hex()
1585
-
1586
- if not dir.exists():
1587
- dir.mkdir(parents=True, exist_ok=True)
1588
-
1589
- path = dir / name
1590
- ipc_lock = fasteners.InterProcessLock(path.with_suffix(path.suffix + ".lock"))
1591
- with ipc_lock:
1592
- r = fn(config, path, *args, **kwargs)
1593
- return r
1594
-
1595
- return __call__
1596
-
1597
-
1598
- class TypeConfig:
1251
+ class ConfigMixin:
1599
1252
  """Class for configuration objects"""
1600
1253
 
1601
1254
  __xpmtype__: ObjectType
1255
+ """The associated XPM type"""
1256
+
1257
+ __xpm__: ConfigInformation
1258
+ """The __xpm__ object contains all instance specific information about a
1259
+ configuration/task"""
1602
1260
 
1603
1261
  def __init__(self, **kwargs):
1604
1262
  """Initialize the configuration with the given parameters"""
@@ -1649,8 +1307,8 @@ class TypeConfig:
1649
1307
  [f"{key}={value}" for key, value in self.__xpm__.values.items()]
1650
1308
  )
1651
1309
  return (
1652
- f"{self.__xpmtype__.basetype.__module__}."
1653
- f"{self.__xpmtype__.basetype.__qualname__}({params})"
1310
+ f"{self.__xpmtype__.value_type.__module__}."
1311
+ f"{self.__xpmtype__.value_type.__qualname__}({params})"
1654
1312
  )
1655
1313
 
1656
1314
  def tag(self, name, value):
@@ -1679,9 +1337,20 @@ class TypeConfig:
1679
1337
  return self
1680
1338
 
1681
1339
  def instance(
1682
- self, context: ConfigWalkContext = None, *, objects: ObjectStore = None
1340
+ self,
1341
+ context: ConfigWalkContext = None,
1342
+ *,
1343
+ objects: ObjectStore = None,
1344
+ keep: bool = True,
1683
1345
  ) -> T:
1684
- """Return an instance with the current values"""
1346
+ """Return an instance with the current values
1347
+
1348
+ :param context: The context when computing the instance
1349
+ :param objects: The previously built objects (so that we avoid
1350
+ re-creating instances of past configurations)
1351
+ :param keep: register a configuration in the __config__ field of the
1352
+ instance
1353
+ """
1685
1354
  if context is None:
1686
1355
  from experimaestro.xpmutils import EmptyContext
1687
1356
 
@@ -1690,7 +1359,11 @@ class TypeConfig:
1690
1359
  assert isinstance(
1691
1360
  context, ConfigWalkContext
1692
1361
  ), f"{context.__class__} is not an instance of ConfigWalkContext"
1693
- return self.__xpm__.fromConfig(context, objects=objects) # type: ignore
1362
+
1363
+ instance = self.__xpm__.fromConfig(context, objects=objects) # type: ignore
1364
+ if keep:
1365
+ object.__setattr__(instance, "__config__", self)
1366
+ return instance
1694
1367
 
1695
1368
  def submit(
1696
1369
  self,
@@ -1735,29 +1408,7 @@ class TypeConfig:
1735
1408
  attributes)"""
1736
1409
  return clone(self)
1737
1410
 
1738
- def add_pretasks(self, *tasks: "LightweightTask"):
1739
- assert all(
1740
- [isinstance(task, LightweightTask) for task in tasks]
1741
- ), "One of the pre-tasks are not lightweight tasks"
1742
- if self.__xpm__._sealed:
1743
- raise SealedError("Cannot add pre-tasks to a sealed configuration")
1744
- self.__xpm__.pre_tasks.extend(tasks)
1745
- return self
1746
-
1747
- def add_pretasks_from(self, *configs: "Config"):
1748
- assert all(
1749
- [isinstance(config, TypeConfig) for config in configs]
1750
- ), "One of the parameters is not a configuration object"
1751
- for config in configs:
1752
- self.add_pretasks(*config.__xpm__.pre_tasks)
1753
- return self
1754
-
1755
- @property
1756
- def pre_tasks(self) -> List["LightweightTask"]:
1757
- """Access pre-tasks"""
1758
- return self.__xpm__.pre_tasks
1759
-
1760
- def copy_dependencies(self, other: "Config"):
1411
+ def copy_dependencies(self, other: "ConfigMixin"):
1761
1412
  """Add all the dependencies from other configuration"""
1762
1413
 
1763
1414
  # Add task dependency
@@ -1769,61 +1420,32 @@ class TypeConfig:
1769
1420
  self.__xpm__.add_dependencies(*other.__xpm__.dependencies)
1770
1421
 
1771
1422
 
1772
- class classproperty(property):
1773
- def __get__(self, owner_self, owner_cls):
1774
- return self.fget(owner_cls)
1775
-
1776
-
1777
1423
  class Config:
1778
1424
  """Base type for all objects in python interface"""
1779
1425
 
1426
+ __xpmid__: ClassVar[Optional[str]]
1427
+ """Optional configuration ID, mostly useful when moving a class to another
1428
+ package to avoid changes in computed task identifiers"""
1429
+
1780
1430
  __xpmtype__: ClassVar[ObjectType]
1781
1431
  """The object type holds all the information about a specific subclass
1782
1432
  experimaestro metadata"""
1783
1433
 
1784
- __xpm__: ConfigInformation
1785
- """The __xpm__ object contains all instance specific information about a
1786
- configuration/task"""
1787
-
1788
1434
  @classproperty
1789
1435
  def XPMConfig(cls):
1790
- if issubclass(cls, TypeConfig):
1436
+ if issubclass(cls, ConfigMixin):
1791
1437
  return cls
1792
- return cls.__getxpmtype__().configtype
1438
+ return cls.__getxpmtype__().config_type
1793
1439
 
1794
1440
  @classproperty
1795
1441
  def C(cls):
1442
+ """Alias for XPMConfig"""
1796
1443
  return cls.XPMConfig
1797
1444
 
1798
- @classproperty
1799
- def XPMValue(cls):
1800
- """Returns the value object for this configuration"""
1801
- if issubclass(cls, TypeConfig):
1802
- return cls.__xpmtype__.objecttype
1803
-
1804
- if value_cls := cls.__dict__.get("__XPMValue__", None):
1805
- pass
1806
- else:
1807
- from .types import XPMValue
1808
-
1809
- __objectbases__ = tuple(
1810
- s.XPMValue
1811
- for s in cls.__bases__
1812
- if issubclass(s, Config) and (s is not Config)
1813
- ) or (XPMValue,)
1814
-
1815
- *tp_qual, tp_name = cls.__qualname__.split(".")
1816
- value_cls = type(f"{tp_name}.XPMValue", (cls,) + __objectbases__, {})
1817
- value_cls.__qualname__ = ".".join(tp_qual + [value_cls.__name__])
1818
- value_cls.__module__ = cls.__module__
1819
-
1820
- setattr(cls, "__XPMValue__", value_cls)
1821
-
1822
- return value_cls
1823
-
1824
1445
  @classmethod
1825
1446
  def __getxpmtype__(cls) -> "ObjectType":
1826
- """Get (and create if necessary) the Object type of this"""
1447
+ """Get (and create if necessary) the Object type associated
1448
+ with thie Config object"""
1827
1449
  xpmtype = cls.__dict__.get("__xpmtype__", None)
1828
1450
  if xpmtype is None:
1829
1451
  from experimaestro.core.types import ObjectType
@@ -1836,34 +1458,6 @@ class Config:
1836
1458
  raise
1837
1459
  return xpmtype
1838
1460
 
1839
- def __new__(cls: Type[T], *args, **kwargs) -> T:
1840
- """Returns an instance of a TypeConfig (for compatibility, use XPMConfig
1841
- or C if possible)"""
1842
-
1843
- # If this is an XPMValue, just return a new instance
1844
- from experimaestro.core.types import XPMValue
1845
-
1846
- if issubclass(cls, XPMValue):
1847
- return object.__new__(cls)
1848
-
1849
- # If this is the XPMConfig, just return a new instance
1850
- # __init__ will be called
1851
- if issubclass(cls, TypeConfig):
1852
- return object.__new__(cls)
1853
-
1854
- # otherwise, we use the configuration type
1855
- o: TypeConfig = object.__new__(cls.__getxpmtype__().configtype)
1856
- try:
1857
- o.__init__(*args, **kwargs)
1858
- except Exception:
1859
- caller = inspect.getframeinfo(inspect.stack()[1][0])
1860
- logger.error(
1861
- "Init error in %s:%s"
1862
- % (str(Path(caller.filename).absolute()), caller.lineno)
1863
- )
1864
- raise
1865
- return o
1866
-
1867
1461
  def __validate__(self):
1868
1462
  """Validate the values"""
1869
1463
  pass
@@ -1877,29 +1471,30 @@ class Config:
1877
1471
  """Returns a JSON version of the object (if possible)"""
1878
1472
  return self.__xpm__.__json__()
1879
1473
 
1880
- def __identifier__(self) -> Identifier:
1474
+ def __identifier__(self) -> "Identifier":
1881
1475
  return self.__xpm__.identifier
1882
1476
 
1883
- def add_pretasks(self, *tasks: "LightweightTask"):
1884
- """Add pre-tasks"""
1885
- raise AssertionError("This method can only be used during configuration")
1886
-
1887
- def add_pretasks_from(self, *configs: "Config"):
1888
- """Add pre-tasks from the listed configurations"""
1889
- raise AssertionError(
1890
- "The 'add_pretasks_from' can only be used during configuration"
1891
- )
1892
-
1893
1477
  def copy_dependencies(self, other: "Config"):
1894
1478
  """Add pre-tasks from the listed configurations"""
1895
1479
  raise AssertionError(
1896
1480
  "The 'copy_dependencies' method can only be used during configuration"
1897
1481
  )
1898
1482
 
1899
- @property
1900
- def pre_tasks(self) -> List["LightweightTask"]:
1901
- """Access pre-tasks"""
1902
- raise AssertionError("Pre-tasks can be accessed only during configuration")
1483
+ def register_task_output(self, method, *args, **kwargs):
1484
+ # Determine the path for this...
1485
+ path = taskglobals.Env.instance().xpm_path / "task-outputs.jsonl"
1486
+ path.parent.mkdir(parents=True, exist_ok=True)
1487
+
1488
+ data = json.dumps(
1489
+ {
1490
+ "key": f"{self.__xpmidentifier__}/{method.__name__}",
1491
+ "args": args,
1492
+ "kwargs": kwargs,
1493
+ }
1494
+ )
1495
+ with path.open("at") as fp:
1496
+ fp.writelines([data, "\n"])
1497
+ fp.flush()
1903
1498
 
1904
1499
 
1905
1500
  class LightweightTask(Config):
@@ -1918,6 +1513,17 @@ class Task(LightweightTask):
1918
1513
  def submit(self):
1919
1514
  raise AssertionError("This method can only be used during configuration")
1920
1515
 
1516
+ def watch_output(self, method, callback):
1517
+ """Sets up a callback
1518
+
1519
+ :param method: a method within a configuration
1520
+ :param callback: the callback
1521
+ """
1522
+ self.__xpm__.watch_output(method, callback)
1523
+
1524
+ def on_completed(self, callback: Callable[[], None]):
1525
+ self.__xpm__.on_completed(callback)
1526
+
1921
1527
 
1922
1528
  # --- Utility functions
1923
1529
 
@@ -1956,3 +1562,24 @@ def setmeta(config: Config, flag: bool):
1956
1562
  """Flags the configuration as a meta-parameter"""
1957
1563
  config.__xpm__.set_meta(flag)
1958
1564
  return config
1565
+
1566
+
1567
+ def cache(fn, name: str):
1568
+ def __call__(config, *args, **kwargs):
1569
+ import experimaestro.taskglobals as taskglobals
1570
+
1571
+ # Get path and create directory if needed
1572
+ hexid = config.__xpmidentifier__ # type: Identifier
1573
+ typename = config.__xpmtypename__ # type: str
1574
+ dir = taskglobals.Env.instance().wspath / "config" / typename / hexid.all.hex()
1575
+
1576
+ if not dir.exists():
1577
+ dir.mkdir(parents=True, exist_ok=True)
1578
+
1579
+ path = dir / name
1580
+ ipc_lock = fasteners.InterProcessLock(path.with_suffix(path.suffix + ".lock"))
1581
+ with ipc_lock:
1582
+ r = fn(config, path, *args, **kwargs)
1583
+ return r
1584
+
1585
+ return __call__