experimaestro 1.6.1__py3-none-any.whl → 1.15.2__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- experimaestro/__init__.py +14 -3
- experimaestro/annotations.py +13 -3
- experimaestro/cli/filter.py +19 -5
- experimaestro/cli/jobs.py +12 -5
- experimaestro/commandline.py +3 -7
- experimaestro/connectors/__init__.py +27 -12
- experimaestro/connectors/local.py +19 -10
- experimaestro/connectors/ssh.py +1 -1
- experimaestro/core/arguments.py +35 -3
- experimaestro/core/callbacks.py +52 -0
- experimaestro/core/context.py +8 -9
- experimaestro/core/identifier.py +301 -0
- experimaestro/core/objects/__init__.py +44 -0
- experimaestro/core/{objects.py → objects/config.py} +364 -716
- experimaestro/core/objects/config_utils.py +58 -0
- experimaestro/core/objects/config_walk.py +151 -0
- experimaestro/core/objects.pyi +15 -45
- experimaestro/core/serialization.py +63 -9
- experimaestro/core/serializers.py +1 -8
- experimaestro/core/types.py +61 -6
- experimaestro/experiments/cli.py +79 -29
- experimaestro/experiments/configuration.py +3 -0
- experimaestro/generators.py +6 -1
- experimaestro/ipc.py +4 -1
- experimaestro/launcherfinder/parser.py +8 -3
- experimaestro/launcherfinder/registry.py +29 -10
- experimaestro/launcherfinder/specs.py +49 -10
- experimaestro/launchers/slurm/base.py +51 -13
- experimaestro/mkdocs/__init__.py +1 -1
- experimaestro/notifications.py +2 -1
- experimaestro/run.py +3 -1
- experimaestro/scheduler/base.py +114 -6
- experimaestro/scheduler/dynamic_outputs.py +184 -0
- experimaestro/scheduler/state.py +75 -0
- experimaestro/scheduler/workspace.py +2 -1
- experimaestro/scriptbuilder.py +13 -2
- experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
- experimaestro/server/data/1815e00441357e01619e.ttf +0 -0
- experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
- experimaestro/server/data/2463b90d9a316e4e5294.woff2 +0 -0
- experimaestro/server/data/2582b0e4bcf85eceead0.ttf +0 -0
- experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
- experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
- experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
- experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
- experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
- experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
- experimaestro/server/data/89999bdf5d835c012025.woff2 +0 -0
- experimaestro/server/data/914997e1bdfc990d0897.ttf +0 -0
- experimaestro/server/data/c210719e60948b211a12.woff2 +0 -0
- experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
- experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
- experimaestro/server/data/favicon.ico +0 -0
- experimaestro/server/data/index.css +22963 -0
- experimaestro/server/data/index.css.map +1 -0
- experimaestro/server/data/index.html +27 -0
- experimaestro/server/data/index.js +101770 -0
- experimaestro/server/data/index.js.map +1 -0
- experimaestro/server/data/login.html +22 -0
- experimaestro/server/data/manifest.json +15 -0
- experimaestro/settings.py +2 -2
- experimaestro/sphinx/__init__.py +7 -17
- experimaestro/taskglobals.py +7 -2
- experimaestro/tests/core/__init__.py +0 -0
- experimaestro/tests/core/test_generics.py +206 -0
- experimaestro/tests/definitions_types.py +5 -3
- experimaestro/tests/launchers/bin/sbatch +34 -7
- experimaestro/tests/launchers/bin/srun +5 -0
- experimaestro/tests/launchers/common.py +16 -4
- experimaestro/tests/restart.py +9 -4
- experimaestro/tests/tasks/all.py +23 -10
- experimaestro/tests/tasks/foreign.py +2 -4
- experimaestro/tests/test_dependencies.py +0 -6
- experimaestro/tests/test_experiment.py +73 -0
- experimaestro/tests/test_findlauncher.py +11 -4
- experimaestro/tests/test_forward.py +5 -5
- experimaestro/tests/test_generators.py +93 -0
- experimaestro/tests/test_identifier.py +114 -99
- experimaestro/tests/test_instance.py +6 -21
- experimaestro/tests/test_objects.py +20 -4
- experimaestro/tests/test_param.py +60 -22
- experimaestro/tests/test_serializers.py +24 -64
- experimaestro/tests/test_tags.py +5 -11
- experimaestro/tests/test_tasks.py +10 -23
- experimaestro/tests/test_tokens.py +3 -2
- experimaestro/tests/test_types.py +20 -17
- experimaestro/tests/test_validation.py +48 -91
- experimaestro/tokens.py +16 -5
- experimaestro/typingutils.py +8 -8
- experimaestro/utils/asyncio.py +6 -2
- experimaestro/utils/multiprocessing.py +44 -0
- experimaestro/utils/resources.py +7 -3
- {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info}/METADATA +27 -34
- experimaestro-1.15.2.dist-info/RECORD +159 -0
- {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info}/WHEEL +1 -1
- experimaestro-1.6.1.dist-info/RECORD +0 -122
- {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info}/entry_points.txt +0 -0
- {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,301 @@
|
|
|
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
|
+
argvalue = getattr(value, argument.name, None)
|
|
239
|
+
if not argument.constant and (
|
|
240
|
+
(
|
|
241
|
+
not argument.required
|
|
242
|
+
and argument.default is None
|
|
243
|
+
and argvalue is None
|
|
244
|
+
)
|
|
245
|
+
or (
|
|
246
|
+
argument.default is not None
|
|
247
|
+
and argument.default == remove_meta(argvalue)
|
|
248
|
+
)
|
|
249
|
+
):
|
|
250
|
+
# No update if same value (and not constant)
|
|
251
|
+
continue
|
|
252
|
+
|
|
253
|
+
if (
|
|
254
|
+
argvalue is not None
|
|
255
|
+
and isinstance(argvalue, Config)
|
|
256
|
+
and argvalue.__xpm__.meta
|
|
257
|
+
):
|
|
258
|
+
continue
|
|
259
|
+
|
|
260
|
+
# Hash name
|
|
261
|
+
self.update(argument.name)
|
|
262
|
+
|
|
263
|
+
# Hash value
|
|
264
|
+
self._hashupdate(IdentifierComputer.NAME_ID)
|
|
265
|
+
self.update(argvalue)
|
|
266
|
+
|
|
267
|
+
# Add init tasks
|
|
268
|
+
if value.__xpm__.init_tasks:
|
|
269
|
+
self._hashupdate(IdentifierComputer.INIT_TASKS)
|
|
270
|
+
for init_task in value.__xpm__.init_tasks:
|
|
271
|
+
self.update(init_task)
|
|
272
|
+
else:
|
|
273
|
+
raise NotImplementedError("Cannot compute hash of type %s" % type(value))
|
|
274
|
+
|
|
275
|
+
@staticmethod
|
|
276
|
+
def compute(
|
|
277
|
+
config: "ConfigMixin", config_path: ConfigPath | None = None, version=None
|
|
278
|
+
) -> Identifier:
|
|
279
|
+
"""Compute the identifier for a configuration
|
|
280
|
+
|
|
281
|
+
:param config: the configuration for which we compute the identifier
|
|
282
|
+
:param config_path: used to track down cycles between configurations
|
|
283
|
+
:param version: version for the hash computation (None for the last one)
|
|
284
|
+
"""
|
|
285
|
+
|
|
286
|
+
# Try to use the cached value first
|
|
287
|
+
# (if there are no loops)
|
|
288
|
+
if config.__xpm__._sealed:
|
|
289
|
+
identifier = config.__xpm__._identifier
|
|
290
|
+
if identifier is not None and not identifier.has_loops:
|
|
291
|
+
return identifier
|
|
292
|
+
|
|
293
|
+
config_path = config_path or ConfigPath()
|
|
294
|
+
|
|
295
|
+
with config_path.push(config):
|
|
296
|
+
self = IdentifierComputer(config, config_path, version=version)
|
|
297
|
+
self.update(config, myself=True)
|
|
298
|
+
identifier = self.identifier()
|
|
299
|
+
identifier.has_loops = config_path.has_loop()
|
|
300
|
+
|
|
301
|
+
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
|
+
]
|