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
@@ -0,0 +1,52 @@
1
+ from collections import defaultdict
2
+ import threading
3
+ from typing import Callable, ClassVar, Optional
4
+ from experimaestro.core.objects import ConfigInformation
5
+ from experimaestro.scheduler import Listener, Job, JobState, experiment
6
+
7
+
8
+ class TaskEventListener(Listener):
9
+ INSTANCE: ClassVar[Optional["TaskEventListener"]] = None
10
+ """The general instance"""
11
+
12
+ def __init__(self):
13
+ self.lock = threading.Lock()
14
+ self.experiments: set[int] = set()
15
+
16
+ self._on_completed: defaultdict[int, Callable] = defaultdict(list)
17
+
18
+ @staticmethod
19
+ def connect(xp: experiment):
20
+ _self = TaskEventListener.instance()
21
+ with _self.lock:
22
+ if id(xp) not in _self.experiments:
23
+ _self.experiments.add(id(xp))
24
+ xp.scheduler.addlistener(_self)
25
+
26
+ @staticmethod
27
+ def instance():
28
+ if TaskEventListener.INSTANCE is None:
29
+ TaskEventListener.INSTANCE = TaskEventListener()
30
+
31
+ return TaskEventListener.INSTANCE
32
+
33
+ def job_state(self, job: Job):
34
+ if job.state == JobState.DONE:
35
+ with self.lock:
36
+ for callback in self._on_completed.get(id(job.config.__xpm__), []):
37
+ callback()
38
+
39
+ @staticmethod
40
+ def on_completed(
41
+ config_information: ConfigInformation, callback: Callable[[], None]
42
+ ):
43
+ instance = TaskEventListener.instance()
44
+
45
+ with instance.lock:
46
+ instance._on_completed[id(config_information)].append(callback)
47
+
48
+ if (
49
+ config_information.job is not None
50
+ and config_information.job == JobState.DONE
51
+ ):
52
+ callback()
@@ -1,22 +1,21 @@
1
1
  from contextlib import contextmanager
2
2
  from pathlib import Path
3
+
4
+ try:
5
+ from pathlib import UnsupportedOperation
6
+ except ImportError:
7
+ UnsupportedOperation = OSError
3
8
  import shutil
4
9
  from typing import List, Optional, Protocol, Set, Union
5
10
  import os
6
- import sys
7
-
8
- has_hardlink_to = sys.version_info.major == 3 and sys.version_info.minor >= 10
9
11
 
10
12
 
11
13
  def shallow_copy(src_path: Path, dest_path: Path):
12
14
  """Copy a directory or file, trying to use hard links if possible"""
13
15
  if src_path.is_file():
14
16
  try:
15
- if has_hardlink_to:
16
- dest_path.hardlink_to(src_path)
17
- else:
18
- dest_path.link_to(src_path)
19
- except OSError:
17
+ dest_path.hardlink_to(src_path)
18
+ except (NotImplementedError, UnsupportedOperation, OSError):
20
19
  shutil.copy(src_path, dest_path)
21
20
  else:
22
21
  if dest_path.exists():
@@ -74,7 +73,7 @@ class SerializationContext:
74
73
 
75
74
 
76
75
  class SerializedPathLoader(Protocol):
77
- def __call__(path: Union[Path, str, SerializedPath]) -> Path:
76
+ def __call__(self, path: Union[Path, str, SerializedPath]) -> Path:
78
77
  """Get a filesystem path from a relative path
79
78
 
80
79
  :param path: The relative path
@@ -0,0 +1,310 @@
1
+ from contextlib import contextmanager
2
+ from enum import Enum
3
+ from functools import cached_property
4
+ import hashlib
5
+ import logging
6
+ import os
7
+ import struct
8
+ from typing import Optional
9
+ from experimaestro.core.objects import Config, ConfigMixin
10
+
11
+
12
+ class ConfigPath:
13
+ """Used to keep track of cycles when computing a hash"""
14
+
15
+ def __init__(self):
16
+ self.loops: list[bool] = []
17
+ """Indicates whether a loop was detected up to this node"""
18
+
19
+ self.config2index = {}
20
+ """Associate an index in the list with a configuration"""
21
+
22
+ def detect_loop(self, config) -> Optional[int]:
23
+ """If there is a loop, return the relative index and update the path"""
24
+ index = self.config2index.get(id(config), None)
25
+ if index is not None:
26
+ for i in range(index, self.depth):
27
+ self.loops[i] = True
28
+ return self.depth - index
29
+ return None
30
+
31
+ def has_loop(self):
32
+ return self.loops[-1]
33
+
34
+ @property
35
+ def depth(self):
36
+ return len(self.loops)
37
+
38
+ @contextmanager
39
+ def push(self, config):
40
+ config_id = id(config)
41
+ assert config_id not in self.config2index
42
+
43
+ self.config2index[config_id] = self.depth
44
+ self.loops.append(False)
45
+
46
+ try:
47
+ yield
48
+ finally:
49
+ self.loops.pop()
50
+ del self.config2index[config_id]
51
+
52
+
53
+ hash_logger = logging.getLogger("xpm.hash")
54
+
55
+
56
+ def is_ignored(value):
57
+ """Returns True if the value should be ignored by itself"""
58
+ return value is not None and isinstance(value, Config) and (value.__xpm__.meta)
59
+
60
+
61
+ def remove_meta(value):
62
+ """Cleanup a dict/list by removing ignored values"""
63
+ if isinstance(value, list):
64
+ return [el for el in value if not is_ignored(el)]
65
+ if isinstance(value, dict):
66
+ return {key: value for key, value in value.items() if not is_ignored(value)}
67
+ return value
68
+
69
+
70
+ class Identifier:
71
+ def __init__(self, main: bytes):
72
+ self.main = main
73
+ self.has_loops = False
74
+
75
+ @cached_property
76
+ def all(self):
77
+ """Returns the overall identifier"""
78
+ return self.main
79
+
80
+ def __hash__(self) -> int:
81
+ return hash(self.main)
82
+
83
+ def state_dict(self):
84
+ return self.main.hex()
85
+
86
+ def __eq__(self, other: object):
87
+ if not isinstance(other, Identifier):
88
+ return False
89
+ return self.main == other.main
90
+
91
+ @staticmethod
92
+ def from_state_dict(data: dict[str, str] | str):
93
+ if isinstance(data, str):
94
+ return Identifier(bytes.fromhex(data))
95
+
96
+ return Identifier(bytes.fromhex(data["main"]))
97
+
98
+ def __repr__(self):
99
+ return self.main.hex()
100
+
101
+
102
+ class IdentifierComputer:
103
+ """This class is in charge of computing a config/task identifier"""
104
+
105
+ OBJECT_ID = b"\x00"
106
+ INT_ID = b"\x01"
107
+ FLOAT_ID = b"\x02"
108
+ STR_ID = b"\x03"
109
+ PATH_ID = b"\x04"
110
+ NAME_ID = b"\x05"
111
+ NONE_ID = b"\x06"
112
+ LIST_ID = b"\x07"
113
+ TASK_ID = b"\x08"
114
+ DICT_ID = b"\x09"
115
+ ENUM_ID = b"\x0a"
116
+ CYCLE_REFERENCE = b"\x0b"
117
+ INIT_TASKS = b"\x0c"
118
+
119
+ def __init__(self, config: "ConfigMixin", config_path: ConfigPath, *, version=None):
120
+ # Hasher for parameters
121
+ self._hasher = hashlib.sha256()
122
+ self.config = config
123
+ self.config_path = config_path
124
+ self.version = version or int(os.environ.get("XPM_HASH_COMPUTER", 2))
125
+ if hash_logger.isEnabledFor(logging.DEBUG):
126
+ hash_logger.debug(
127
+ "starting hash (%s): %s", hash(str(self.config)), self.config
128
+ )
129
+
130
+ def identifier(self) -> Identifier:
131
+ main = self._hasher.digest()
132
+ if hash_logger.isEnabledFor(logging.DEBUG):
133
+ hash_logger.debug("hash (%s): %s", hash(str(self.config)), str(main))
134
+ return Identifier(main)
135
+
136
+ def _hashupdate(self, bytes: bytes):
137
+ """Update the hash computers with some bytes"""
138
+ if hash_logger.isEnabledFor(logging.DEBUG):
139
+ hash_logger.debug(
140
+ "updating hash (%s): %s", hash(str(self.config)), str(bytes)
141
+ )
142
+ self._hasher.update(bytes)
143
+
144
+ def update(self, value, *, myself=False): # noqa: C901
145
+ """Update the hash
146
+
147
+ :param value: The value to add to the hash
148
+ :param myself: True if the value is the configuration for which we wish
149
+ to compute the identifier, defaults to False
150
+ :raises NotImplementedError: If the value cannot be processed
151
+ """
152
+ if value is None:
153
+ self._hashupdate(IdentifierComputer.NONE_ID)
154
+ elif isinstance(value, float):
155
+ self._hashupdate(IdentifierComputer.FLOAT_ID)
156
+ self._hashupdate(struct.pack("!d", value))
157
+ elif isinstance(value, int):
158
+ self._hashupdate(IdentifierComputer.INT_ID)
159
+ self._hashupdate(struct.pack("!q", value))
160
+ elif isinstance(value, str):
161
+ self._hashupdate(IdentifierComputer.STR_ID)
162
+ self._hashupdate(value.encode("utf-8"))
163
+ elif isinstance(value, list):
164
+ values = [el for el in value if not is_ignored(el)]
165
+ self._hashupdate(IdentifierComputer.LIST_ID)
166
+ self._hashupdate(struct.pack("!d", len(values)))
167
+ for x in values:
168
+ self.update(x)
169
+ elif isinstance(value, Enum):
170
+ self._hashupdate(IdentifierComputer.ENUM_ID)
171
+ k = value.__class__
172
+ self._hashupdate(
173
+ f"{k.__module__}.{k.__qualname__}:{value.name}".encode("utf-8"),
174
+ )
175
+ elif isinstance(value, dict):
176
+ self._hashupdate(IdentifierComputer.DICT_ID)
177
+ items = [
178
+ (key, value) for key, value in value.items() if not is_ignored(value)
179
+ ]
180
+ items.sort(key=lambda x: x[0])
181
+ for key, value in items:
182
+ self.update(key)
183
+ self.update(value)
184
+
185
+ # Handles configurations
186
+ elif isinstance(value, ConfigMixin):
187
+ # Encodes the identifier
188
+ self._hashupdate(IdentifierComputer.OBJECT_ID)
189
+
190
+ # If we encode another config, then
191
+ if not myself:
192
+ if loop_ix := self.config_path.detect_loop(value):
193
+ # Loop detected: use cycle reference
194
+ self._hashupdate(IdentifierComputer.CYCLE_REFERENCE)
195
+ self._hashupdate(struct.pack("!q", loop_ix))
196
+
197
+ else:
198
+ # Just use the object identifier
199
+ value_id = IdentifierComputer.compute(
200
+ value, version=self.version, config_path=self.config_path
201
+ )
202
+ self._hashupdate(value_id.all)
203
+
204
+ # And that's it!
205
+ return
206
+
207
+ # Process tasks
208
+ if value.__xpm__.task is not None and (value.__xpm__.task is not value):
209
+ hash_logger.debug("Computing hash for task %s", value.__xpm__.task)
210
+ self._hashupdate(IdentifierComputer.TASK_ID)
211
+ self.update(value.__xpm__.task)
212
+
213
+ xpmtype = value.__xpmtype__
214
+ self._hashupdate(xpmtype.identifier.name.encode("utf-8"))
215
+
216
+ # Process arguments (sort by name to ensure uniqueness)
217
+ arguments = sorted(xpmtype.arguments.values(), key=lambda a: a.name)
218
+ for argument in arguments:
219
+ # Ignored argument
220
+ if argument.ignored:
221
+ argvalue = value.__xpm__.values.get(argument.name, None)
222
+
223
+ # ... unless meta is set to false
224
+ if (
225
+ argvalue is None
226
+ or not isinstance(argvalue, Config)
227
+ or (argvalue.__xpm__.meta is not False)
228
+ ):
229
+ continue
230
+
231
+ if argument.generator:
232
+ continue
233
+
234
+ # Argument value
235
+ # Skip if the argument is not a constant, and
236
+ # - optional argument: both value and default are None
237
+ # - the argument value is equal to the default value
238
+ try:
239
+ argvalue = getattr(value, argument.name, None)
240
+ except KeyError:
241
+ logging.warning(
242
+ "Parameter %s has not been set in %s created at %s",
243
+ argument.name,
244
+ value,
245
+ value.__xpm__._initinfo,
246
+ )
247
+ raise
248
+ if not argument.constant and (
249
+ (
250
+ not argument.required
251
+ and argument.default is None
252
+ and argvalue is None
253
+ )
254
+ or (
255
+ argument.default is not None
256
+ and argument.default == remove_meta(argvalue)
257
+ )
258
+ ):
259
+ # No update if same value (and not constant)
260
+ continue
261
+
262
+ if (
263
+ argvalue is not None
264
+ and isinstance(argvalue, Config)
265
+ and argvalue.__xpm__.meta
266
+ ):
267
+ continue
268
+
269
+ # Hash name
270
+ self.update(argument.name)
271
+
272
+ # Hash value
273
+ self._hashupdate(IdentifierComputer.NAME_ID)
274
+ self.update(argvalue)
275
+
276
+ # Add init tasks
277
+ if value.__xpm__.init_tasks:
278
+ self._hashupdate(IdentifierComputer.INIT_TASKS)
279
+ for init_task in value.__xpm__.init_tasks:
280
+ self.update(init_task)
281
+ else:
282
+ raise NotImplementedError("Cannot compute hash of type %s" % type(value))
283
+
284
+ @staticmethod
285
+ def compute(
286
+ config: "ConfigMixin", config_path: ConfigPath | None = None, version=None
287
+ ) -> Identifier:
288
+ """Compute the identifier for a configuration
289
+
290
+ :param config: the configuration for which we compute the identifier
291
+ :param config_path: used to track down cycles between configurations
292
+ :param version: version for the hash computation (None for the last one)
293
+ """
294
+
295
+ # Try to use the cached value first
296
+ # (if there are no loops)
297
+ if config.__xpm__._sealed:
298
+ identifier = config.__xpm__._identifier
299
+ if identifier is not None and not identifier.has_loops:
300
+ return identifier
301
+
302
+ config_path = config_path or ConfigPath()
303
+
304
+ with config_path.push(config):
305
+ self = IdentifierComputer(config, config_path, version=version)
306
+ self.update(config, myself=True)
307
+ identifier = self.identifier()
308
+ identifier.has_loops = config_path.has_loop()
309
+
310
+ return identifier
@@ -0,0 +1,44 @@
1
+ from .config_walk import ConfigWalkContext, ConfigWalk
2
+ from .config import (
3
+ ConfigMixin,
4
+ Config,
5
+ ConfigInformation,
6
+ Task,
7
+ LightweightTask,
8
+ WatchedOutput,
9
+ DependentMarker,
10
+ copyconfig,
11
+ setmeta,
12
+ cache,
13
+ logger,
14
+ )
15
+
16
+ from .config_utils import (
17
+ getqualattr,
18
+ add_to_path,
19
+ ObjectStore,
20
+ SealedError,
21
+ TaggedValue,
22
+ )
23
+
24
+
25
+ __all__ = [
26
+ "ConfigMixin",
27
+ "Config",
28
+ "ConfigInformation",
29
+ "ConfigWalkContext",
30
+ "ConfigWalk",
31
+ "Task",
32
+ "LightweightTask",
33
+ "ObjectStore",
34
+ "WatchedOutput",
35
+ "SealedError",
36
+ "DependentMarker",
37
+ "TaggedValue",
38
+ "getqualattr",
39
+ "copyconfig",
40
+ "setmeta",
41
+ "cache",
42
+ "add_to_path",
43
+ "logger",
44
+ ]