experimaestro 1.11.1__py3-none-any.whl → 2.0.0b4__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.
- experimaestro/__init__.py +10 -11
- experimaestro/annotations.py +167 -206
- experimaestro/cli/__init__.py +140 -16
- experimaestro/cli/filter.py +42 -74
- experimaestro/cli/jobs.py +157 -106
- experimaestro/cli/progress.py +269 -0
- experimaestro/cli/refactor.py +249 -0
- experimaestro/click.py +0 -1
- experimaestro/commandline.py +19 -3
- experimaestro/connectors/__init__.py +22 -3
- experimaestro/connectors/local.py +12 -0
- experimaestro/core/arguments.py +192 -37
- experimaestro/core/identifier.py +127 -12
- experimaestro/core/objects/__init__.py +6 -0
- experimaestro/core/objects/config.py +702 -285
- experimaestro/core/objects/config_walk.py +24 -6
- experimaestro/core/serialization.py +91 -34
- experimaestro/core/serializers.py +1 -8
- experimaestro/core/subparameters.py +164 -0
- experimaestro/core/types.py +198 -83
- experimaestro/exceptions.py +26 -0
- experimaestro/experiments/cli.py +107 -25
- experimaestro/generators.py +50 -9
- experimaestro/huggingface.py +3 -1
- experimaestro/launcherfinder/parser.py +29 -0
- experimaestro/launcherfinder/registry.py +3 -3
- experimaestro/launchers/__init__.py +26 -1
- experimaestro/launchers/direct.py +12 -0
- experimaestro/launchers/slurm/base.py +154 -2
- experimaestro/mkdocs/base.py +6 -8
- experimaestro/mkdocs/metaloader.py +0 -1
- experimaestro/mypy.py +452 -7
- experimaestro/notifications.py +75 -16
- experimaestro/progress.py +404 -0
- experimaestro/rpyc.py +0 -1
- experimaestro/run.py +19 -6
- experimaestro/scheduler/__init__.py +18 -1
- experimaestro/scheduler/base.py +504 -959
- experimaestro/scheduler/dependencies.py +43 -28
- experimaestro/scheduler/dynamic_outputs.py +259 -130
- experimaestro/scheduler/experiment.py +582 -0
- experimaestro/scheduler/interfaces.py +474 -0
- experimaestro/scheduler/jobs.py +485 -0
- experimaestro/scheduler/services.py +186 -12
- experimaestro/scheduler/signal_handler.py +32 -0
- experimaestro/scheduler/state.py +1 -1
- experimaestro/scheduler/state_db.py +388 -0
- experimaestro/scheduler/state_provider.py +2345 -0
- experimaestro/scheduler/state_sync.py +834 -0
- experimaestro/scheduler/workspace.py +52 -10
- experimaestro/scriptbuilder.py +7 -0
- experimaestro/server/__init__.py +153 -32
- experimaestro/server/data/index.css +0 -125
- experimaestro/server/data/index.css.map +1 -1
- experimaestro/server/data/index.js +194 -58
- experimaestro/server/data/index.js.map +1 -1
- experimaestro/settings.py +47 -6
- experimaestro/sphinx/__init__.py +3 -3
- experimaestro/taskglobals.py +20 -0
- experimaestro/tests/conftest.py +80 -0
- experimaestro/tests/core/test_generics.py +2 -2
- experimaestro/tests/identifier_stability.json +45 -0
- experimaestro/tests/launchers/bin/sacct +6 -2
- experimaestro/tests/launchers/bin/sbatch +4 -2
- experimaestro/tests/launchers/common.py +2 -2
- experimaestro/tests/launchers/test_slurm.py +80 -0
- experimaestro/tests/restart.py +1 -1
- experimaestro/tests/tasks/all.py +7 -0
- experimaestro/tests/tasks/test_dynamic.py +231 -0
- experimaestro/tests/test_checkers.py +2 -2
- experimaestro/tests/test_cli_jobs.py +615 -0
- experimaestro/tests/test_dependencies.py +11 -17
- experimaestro/tests/test_deprecated.py +630 -0
- experimaestro/tests/test_environment.py +200 -0
- experimaestro/tests/test_experiment.py +3 -3
- experimaestro/tests/test_file_progress.py +425 -0
- experimaestro/tests/test_file_progress_integration.py +477 -0
- experimaestro/tests/test_forward.py +3 -3
- experimaestro/tests/test_generators.py +93 -0
- experimaestro/tests/test_identifier.py +520 -169
- experimaestro/tests/test_identifier_stability.py +458 -0
- experimaestro/tests/test_instance.py +16 -21
- experimaestro/tests/test_multitoken.py +442 -0
- experimaestro/tests/test_mypy.py +433 -0
- experimaestro/tests/test_objects.py +314 -30
- experimaestro/tests/test_outputs.py +8 -8
- experimaestro/tests/test_param.py +22 -26
- experimaestro/tests/test_partial_paths.py +231 -0
- experimaestro/tests/test_progress.py +2 -50
- experimaestro/tests/test_resumable_task.py +480 -0
- experimaestro/tests/test_serializers.py +141 -60
- experimaestro/tests/test_state_db.py +434 -0
- experimaestro/tests/test_subparameters.py +160 -0
- experimaestro/tests/test_tags.py +151 -15
- experimaestro/tests/test_tasks.py +137 -160
- experimaestro/tests/test_token_locking.py +252 -0
- experimaestro/tests/test_tokens.py +25 -19
- experimaestro/tests/test_types.py +133 -11
- experimaestro/tests/test_validation.py +19 -19
- experimaestro/tests/test_workspace_triggers.py +158 -0
- experimaestro/tests/token_reschedule.py +5 -3
- experimaestro/tests/utils.py +2 -2
- experimaestro/tokens.py +154 -57
- experimaestro/tools/diff.py +8 -1
- experimaestro/tui/__init__.py +8 -0
- experimaestro/tui/app.py +2303 -0
- experimaestro/tui/app.tcss +353 -0
- experimaestro/tui/log_viewer.py +228 -0
- experimaestro/typingutils.py +11 -2
- experimaestro/utils/__init__.py +23 -0
- experimaestro/utils/environment.py +148 -0
- experimaestro/utils/git.py +129 -0
- experimaestro/utils/resources.py +1 -1
- experimaestro/version.py +34 -0
- {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info}/METADATA +70 -39
- experimaestro-2.0.0b4.dist-info/RECORD +181 -0
- {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info}/WHEEL +1 -1
- experimaestro-2.0.0b4.dist-info/entry_points.txt +16 -0
- experimaestro/compat.py +0 -6
- experimaestro/core/objects.pyi +0 -225
- experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
- experimaestro/server/data/219aa9140e099e6c72ed.woff2 +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/c380809fd3677d7d6903.woff2 +0 -0
- experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
- experimaestro-1.11.1.dist-info/RECORD +0 -158
- experimaestro-1.11.1.dist-info/entry_points.txt +0 -17
- {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info/licenses}/LICENSE +0 -0
experimaestro/core/types.py
CHANGED
|
@@ -1,7 +1,18 @@
|
|
|
1
1
|
from abc import ABC, abstractmethod
|
|
2
|
+
from dataclasses import dataclass
|
|
2
3
|
import inspect
|
|
3
4
|
import sys
|
|
4
|
-
from typing import
|
|
5
|
+
from typing import (
|
|
6
|
+
Set,
|
|
7
|
+
TypeVar,
|
|
8
|
+
Union,
|
|
9
|
+
Dict,
|
|
10
|
+
Iterator,
|
|
11
|
+
List,
|
|
12
|
+
Optional,
|
|
13
|
+
get_args,
|
|
14
|
+
get_origin,
|
|
15
|
+
)
|
|
5
16
|
from collections import ChainMap
|
|
6
17
|
from pathlib import Path
|
|
7
18
|
import typing
|
|
@@ -13,15 +24,27 @@ from enum import Enum
|
|
|
13
24
|
import ast
|
|
14
25
|
import textwrap
|
|
15
26
|
|
|
16
|
-
|
|
17
|
-
from typing_extensions import _AnnotatedAlias, get_type_hints
|
|
18
|
-
else:
|
|
19
|
-
from typing import _AnnotatedAlias, get_type_hints
|
|
27
|
+
from typing import _AnnotatedAlias, get_type_hints
|
|
20
28
|
|
|
21
29
|
if typing.TYPE_CHECKING:
|
|
22
30
|
from experimaestro.scheduler.base import Job
|
|
23
31
|
from experimaestro.launchers import Launcher
|
|
24
32
|
from experimaestro.core.objects import Config
|
|
33
|
+
from experimaestro.core.subparameters import Subparameters
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class DeprecationInfo:
|
|
38
|
+
"""Information about a deprecated configuration type."""
|
|
39
|
+
|
|
40
|
+
#: The original identifier before deprecation
|
|
41
|
+
original_identifier: "Identifier"
|
|
42
|
+
|
|
43
|
+
#: The target configuration class to convert to
|
|
44
|
+
target: type
|
|
45
|
+
|
|
46
|
+
#: If True, creating an instance immediately converts to the target type
|
|
47
|
+
replace: bool = False
|
|
25
48
|
|
|
26
49
|
|
|
27
50
|
class Identifier:
|
|
@@ -203,18 +226,14 @@ class ObjectType(Type):
|
|
|
203
226
|
"""ObjectType contains class-level information about
|
|
204
227
|
experimaestro configurations and tasks
|
|
205
228
|
|
|
206
|
-
:param
|
|
207
|
-
:param
|
|
208
|
-
property for arguments
|
|
229
|
+
:param value_type: The Python type of the associated object
|
|
230
|
+
:param config_type: The Python type of the configuration object
|
|
209
231
|
"""
|
|
210
232
|
|
|
211
|
-
# Those entries should not be copied in the __dict__
|
|
212
|
-
FORBIDDEN_KEYS = set(("__dict__", "__weakref__"))
|
|
213
|
-
|
|
214
233
|
def __init__(
|
|
215
234
|
self,
|
|
216
235
|
tp: type,
|
|
217
|
-
identifier: Union[str, Identifier] = None,
|
|
236
|
+
identifier: Union[str, Identifier, None] = None,
|
|
218
237
|
):
|
|
219
238
|
"""Creates a type"""
|
|
220
239
|
from .objects import Config, ConfigMixin
|
|
@@ -225,7 +244,10 @@ class ObjectType(Type):
|
|
|
225
244
|
self._title = None
|
|
226
245
|
self.submit_hooks = set()
|
|
227
246
|
|
|
228
|
-
#
|
|
247
|
+
# Warning flag for non-resumable task directory cleanup
|
|
248
|
+
self.warned_clean_not_resumable = False
|
|
249
|
+
|
|
250
|
+
# --- Get the identifier
|
|
229
251
|
if identifier is None and hasattr(tp, "__xpmid__"):
|
|
230
252
|
__xpmid__ = getattr(tp, "__xpmid__")
|
|
231
253
|
if isinstance(__xpmid__, Identifier):
|
|
@@ -250,58 +272,53 @@ class ObjectType(Type):
|
|
|
250
272
|
# --- Creates the config type and not config type
|
|
251
273
|
|
|
252
274
|
self.originaltype = tp
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
__bases__ = () if tp.__bases__ == (object,) else tp.__bases__
|
|
256
|
-
__dict__ = dict(tp.__dict__)
|
|
257
|
-
|
|
258
|
-
__dict__ = {
|
|
259
|
-
key: value
|
|
260
|
-
for key, value in tp.__dict__.items()
|
|
261
|
-
if key not in ObjectType.FORBIDDEN_KEYS
|
|
262
|
-
}
|
|
263
|
-
self.basetype = type(tp.__name__, (Config,) + __bases__, __dict__)
|
|
264
|
-
self.basetype.__module__ = tp.__module__
|
|
265
|
-
self.basetype.__qualname__ = tp.__qualname__
|
|
266
|
-
else:
|
|
267
|
-
self.basetype = tp
|
|
275
|
+
assert issubclass(tp, Config)
|
|
276
|
+
self.value_type = tp
|
|
268
277
|
|
|
269
278
|
# --- Create the type-specific configuration class (XPMConfig)
|
|
270
279
|
__configbases__ = tuple(
|
|
271
|
-
s.__getxpmtype__().
|
|
280
|
+
s.__getxpmtype__().config_type
|
|
272
281
|
for s in tp.__bases__
|
|
273
282
|
if issubclass(s, Config) and (s is not Config)
|
|
274
283
|
) or (ConfigMixin,)
|
|
275
284
|
|
|
276
|
-
*tp_qual, tp_name = self.
|
|
277
|
-
self.
|
|
278
|
-
f"{tp_name}.XPMConfig", __configbases__ + (self.
|
|
285
|
+
*tp_qual, tp_name = self.value_type.__qualname__.split(".")
|
|
286
|
+
self.config_type = type(
|
|
287
|
+
f"{tp_name}.XPMConfig", __configbases__ + (self.value_type,), {}
|
|
279
288
|
)
|
|
280
|
-
self.
|
|
281
|
-
self.
|
|
289
|
+
self.config_type.__qualname__ = ".".join(tp_qual + [self.config_type.__name__])
|
|
290
|
+
self.config_type.__module__ = tp.__module__
|
|
282
291
|
|
|
283
|
-
#
|
|
284
|
-
if hasattr(self.
|
|
292
|
+
# --- Get the return type
|
|
293
|
+
if hasattr(self.value_type, "task_outputs") or False:
|
|
285
294
|
self.returntype = get_type_hints(
|
|
286
|
-
getattr(self.
|
|
295
|
+
getattr(self.value_type, "task_outputs")
|
|
287
296
|
).get("return", typing.Any)
|
|
288
297
|
else:
|
|
289
|
-
self.returntype = self.
|
|
298
|
+
self.returntype = self.value_type
|
|
290
299
|
|
|
291
|
-
# Registers ourselves
|
|
292
|
-
self.
|
|
293
|
-
self.
|
|
300
|
+
# --- Registers ourselves
|
|
301
|
+
self.value_type.__xpmtype__ = self
|
|
302
|
+
self.config_type.__xpmtype__ = self
|
|
294
303
|
|
|
295
|
-
# Other initializations
|
|
304
|
+
# --- Other initializations
|
|
296
305
|
self.__initialized__ = False
|
|
297
306
|
self._runtype = None
|
|
298
307
|
self.annotations = []
|
|
299
|
-
self.
|
|
308
|
+
self._deprecation: Optional[DeprecationInfo] = None
|
|
300
309
|
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
310
|
+
# --- Value class (for external value types, e.g., nn.Module subclasses)
|
|
311
|
+
self._original_type: type = tp # Keep reference to original config class
|
|
312
|
+
|
|
313
|
+
# --- Subparameters for partial identifier computation
|
|
314
|
+
self._subparameters: Dict[str, "Subparameters"] = {}
|
|
315
|
+
|
|
316
|
+
def set_value_type(self, value_class: type) -> None:
|
|
317
|
+
"""Register an explicit value class for this configuration.
|
|
318
|
+
|
|
319
|
+
The value class will be used when creating instances via .instance().
|
|
320
|
+
"""
|
|
321
|
+
self.value_type = value_class
|
|
305
322
|
|
|
306
323
|
def addAnnotation(self, annotation):
|
|
307
324
|
assert not self.__initialized__
|
|
@@ -357,15 +374,18 @@ class ObjectType(Type):
|
|
|
357
374
|
# Add task
|
|
358
375
|
if self.taskcommandfactory is not None:
|
|
359
376
|
self.task = self.taskcommandfactory(self)
|
|
360
|
-
elif issubclass(self.
|
|
377
|
+
elif issubclass(self._original_type, Task):
|
|
361
378
|
self.task = self.getpythontaskcommand()
|
|
362
379
|
|
|
363
380
|
# Add arguments from type hints
|
|
381
|
+
# Use _original_type since value_type may have been overridden by set_value_type
|
|
364
382
|
from .arguments import TypeAnnotation
|
|
365
383
|
|
|
366
|
-
if hasattr(self.
|
|
367
|
-
typekeys = set(
|
|
368
|
-
|
|
384
|
+
if hasattr(self._original_type, "__annotations__"):
|
|
385
|
+
typekeys = set(
|
|
386
|
+
self._original_type.__dict__.get("__annotations__", {}).keys()
|
|
387
|
+
)
|
|
388
|
+
hints = get_type_hints(self._original_type, include_extras=True)
|
|
369
389
|
for key, typehint in hints.items():
|
|
370
390
|
# Filter out hints from parent classes
|
|
371
391
|
if key in typekeys:
|
|
@@ -378,19 +398,29 @@ class ObjectType(Type):
|
|
|
378
398
|
try:
|
|
379
399
|
self.addArgument(
|
|
380
400
|
options.create(
|
|
381
|
-
key, self.
|
|
401
|
+
key, self._original_type, typehint.__args__[0]
|
|
382
402
|
)
|
|
383
403
|
)
|
|
384
404
|
except Exception:
|
|
385
405
|
logger.error(
|
|
386
406
|
"while adding argument %s of %s",
|
|
387
407
|
key,
|
|
388
|
-
self.
|
|
408
|
+
self._original_type,
|
|
389
409
|
)
|
|
390
410
|
raise
|
|
391
411
|
|
|
412
|
+
# Collect subparameters from class attributes
|
|
413
|
+
from .subparameters import Subparameters as SubparametersClass
|
|
414
|
+
|
|
415
|
+
for name, value in self._original_type.__dict__.items():
|
|
416
|
+
if isinstance(value, SubparametersClass):
|
|
417
|
+
# Auto-set name from attribute name if not already set
|
|
418
|
+
if value.name is None:
|
|
419
|
+
value.name = name
|
|
420
|
+
self._subparameters[name] = value
|
|
421
|
+
|
|
392
422
|
def name(self):
|
|
393
|
-
return f"{self.
|
|
423
|
+
return f"{self.value_type.__module__}.{self.value_type.__qualname__}"
|
|
394
424
|
|
|
395
425
|
def __parsedoc__(self):
|
|
396
426
|
"""Parse the documentation"""
|
|
@@ -400,7 +430,8 @@ class ObjectType(Type):
|
|
|
400
430
|
self.__initialize__()
|
|
401
431
|
|
|
402
432
|
# Get description from documentation
|
|
403
|
-
|
|
433
|
+
# Use _original_type since value_type may have been overridden
|
|
434
|
+
__doc__ = self._original_type.__dict__.get("__doc__", None)
|
|
404
435
|
if __doc__:
|
|
405
436
|
parseddoc = parse(__doc__)
|
|
406
437
|
self._title = parseddoc.short_description
|
|
@@ -429,24 +460,56 @@ class ObjectType(Type):
|
|
|
429
460
|
|
|
430
461
|
argname = None
|
|
431
462
|
|
|
432
|
-
def deprecate(self):
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
463
|
+
def deprecate(self, target=None, replace: bool = False):
|
|
464
|
+
"""Mark this configuration type as deprecated.
|
|
465
|
+
|
|
466
|
+
Args:
|
|
467
|
+
target: Optional target configuration class. If provided, uses
|
|
468
|
+
target's identifier. If None, uses parent class's identifier
|
|
469
|
+
(legacy behavior requiring single inheritance).
|
|
470
|
+
replace: If True, creating an instance of this class immediately
|
|
471
|
+
returns a converted instance of the target class.
|
|
472
|
+
|
|
473
|
+
When a target is specified, the deprecated class should define a
|
|
474
|
+
__convert__ method that returns an equivalent target configuration.
|
|
475
|
+
The identifier is computed from the converted configuration.
|
|
476
|
+
"""
|
|
477
|
+
assert self._deprecation is None, "Already deprecated"
|
|
478
|
+
|
|
479
|
+
# Save the deprecated identifier for migration tools (fix_deprecated)
|
|
480
|
+
original_identifier = self.identifier
|
|
481
|
+
|
|
482
|
+
if target is not None:
|
|
483
|
+
# New mechanism: explicit target class
|
|
484
|
+
target_xpmtype = target.__getxpmtype__()
|
|
485
|
+
self.identifier = target_xpmtype.identifier
|
|
486
|
+
deprecation_target = target
|
|
487
|
+
else:
|
|
488
|
+
# Legacy mechanism: parent class is the target
|
|
489
|
+
if len(self.value_type.__bases__) != 1:
|
|
490
|
+
raise RuntimeError(
|
|
491
|
+
"Deprecated configurations must have "
|
|
492
|
+
"only one parent (the new configuration)"
|
|
493
|
+
)
|
|
494
|
+
parent = self.value_type.__bases__[0].__getxpmtype__()
|
|
495
|
+
self.identifier = parent.identifier
|
|
496
|
+
deprecation_target = self.value_type.__bases__[0]
|
|
497
|
+
|
|
498
|
+
self._deprecation = DeprecationInfo(
|
|
499
|
+
original_identifier=original_identifier,
|
|
500
|
+
target=deprecation_target,
|
|
501
|
+
replace=replace,
|
|
502
|
+
)
|
|
445
503
|
|
|
446
504
|
@property
|
|
447
505
|
def deprecated(self) -> bool:
|
|
448
506
|
"""Returns true if this type is deprecated"""
|
|
449
|
-
return self.
|
|
507
|
+
return self._deprecation is not None
|
|
508
|
+
|
|
509
|
+
@property
|
|
510
|
+
def _deprecated_identifier(self) -> Optional["Identifier"]:
|
|
511
|
+
"""Returns the original identifier before deprecation (for backwards compatibility)"""
|
|
512
|
+
return self._deprecation.original_identifier if self._deprecation else None
|
|
450
513
|
|
|
451
514
|
@property
|
|
452
515
|
def description(self) -> str:
|
|
@@ -454,7 +517,7 @@ class ObjectType(Type):
|
|
|
454
517
|
return self._description
|
|
455
518
|
|
|
456
519
|
@property
|
|
457
|
-
def title(self) ->
|
|
520
|
+
def title(self) -> str:
|
|
458
521
|
self.__parsedoc__()
|
|
459
522
|
return self._title or str(self.identifier)
|
|
460
523
|
|
|
@@ -464,23 +527,72 @@ class ObjectType(Type):
|
|
|
464
527
|
return self._arguments
|
|
465
528
|
|
|
466
529
|
def addArgument(self, argument: Argument):
|
|
530
|
+
# Check if this argument overrides a parent argument
|
|
531
|
+
# _arguments is a ChainMap where maps[0] is current class, maps[1:] are parents
|
|
532
|
+
parent_argument = None
|
|
533
|
+
for parent_map in self._arguments.maps[1:]:
|
|
534
|
+
if argument.name in parent_map:
|
|
535
|
+
parent_argument = parent_map[argument.name]
|
|
536
|
+
break
|
|
537
|
+
|
|
538
|
+
if parent_argument is not None:
|
|
539
|
+
# Check type compatibility (child type should be subtype of parent type)
|
|
540
|
+
self._check_override_type_compatibility(argument, parent_argument)
|
|
541
|
+
|
|
542
|
+
# Warn if overrides flag is not set
|
|
543
|
+
if not argument.overrides:
|
|
544
|
+
logger.warning(
|
|
545
|
+
"Parameter '%s' in %s overrides parent parameter from %s. "
|
|
546
|
+
"Use field(overrides=True) to suppress this warning.",
|
|
547
|
+
argument.name,
|
|
548
|
+
self._original_type.__qualname__,
|
|
549
|
+
(
|
|
550
|
+
parent_argument.objecttype._original_type.__qualname__
|
|
551
|
+
if parent_argument.objecttype
|
|
552
|
+
else "unknown"
|
|
553
|
+
),
|
|
554
|
+
)
|
|
555
|
+
|
|
467
556
|
self._arguments[argument.name] = argument
|
|
468
557
|
argument.objecttype = self
|
|
469
558
|
|
|
470
|
-
# The the attribute for the config type
|
|
471
|
-
setattr(
|
|
472
|
-
self.configtype,
|
|
473
|
-
argument.name,
|
|
474
|
-
property(
|
|
475
|
-
lambda _self: _self.__xpm__.get(argument.name),
|
|
476
|
-
lambda _self, value: _self.__xpm__.set(argument.name, value),
|
|
477
|
-
),
|
|
478
|
-
)
|
|
479
|
-
|
|
480
559
|
# Check default value
|
|
481
560
|
if argument.default is not None:
|
|
482
561
|
argument.type.validate(argument.default)
|
|
483
562
|
|
|
563
|
+
def _check_override_type_compatibility(
|
|
564
|
+
self, child_arg: Argument, parent_arg: Argument
|
|
565
|
+
):
|
|
566
|
+
"""Check that the child argument type is compatible with the parent type.
|
|
567
|
+
|
|
568
|
+
For Config types, the child type should be a subtype of the parent type
|
|
569
|
+
(covariant). For other types, we check for exact match.
|
|
570
|
+
"""
|
|
571
|
+
child_type = child_arg.type
|
|
572
|
+
parent_type = parent_arg.type
|
|
573
|
+
|
|
574
|
+
# Check if both are ObjectType (Config types)
|
|
575
|
+
if isinstance(child_type, ObjectType) and isinstance(parent_type, ObjectType):
|
|
576
|
+
child_pytype = child_type.value_type
|
|
577
|
+
parent_pytype = parent_type.value_type
|
|
578
|
+
|
|
579
|
+
# Check if child is a subtype of parent
|
|
580
|
+
if not issubclass(child_pytype, parent_pytype):
|
|
581
|
+
raise TypeError(
|
|
582
|
+
f"Parameter '{child_arg.name}' type {child_pytype.__qualname__} "
|
|
583
|
+
f"is not a subtype of parent type {parent_pytype.__qualname__}. "
|
|
584
|
+
f"Override types must be subtypes of the parent type."
|
|
585
|
+
)
|
|
586
|
+
elif type(child_type) is not type(parent_type):
|
|
587
|
+
# For non-Config types, check for exact type match
|
|
588
|
+
# Different type classes (e.g., IntType vs StrType) are incompatible
|
|
589
|
+
raise TypeError(
|
|
590
|
+
f"Parameter '{child_arg.name}' type {type(child_type).__name__} "
|
|
591
|
+
f"is not compatible with parent type {type(parent_type).__name__}. "
|
|
592
|
+
f"Override types must be the same type or a subtype."
|
|
593
|
+
)
|
|
594
|
+
# Same type class is allowed (e.g., both are IntType)
|
|
595
|
+
|
|
484
596
|
def getArgument(self, key: str) -> Argument:
|
|
485
597
|
self.__initialize__()
|
|
486
598
|
return self._arguments[key]
|
|
@@ -488,7 +600,10 @@ class ObjectType(Type):
|
|
|
488
600
|
def parents(self) -> Iterator["ObjectType"]:
|
|
489
601
|
from .objects import Config, Task
|
|
490
602
|
|
|
491
|
-
|
|
603
|
+
# Use _original_type to avoid issues when value_type has been
|
|
604
|
+
# overridden by set_value_type (the value class would create
|
|
605
|
+
# circular references since it inherits from the config class)
|
|
606
|
+
for tp in self._original_type.__bases__:
|
|
492
607
|
if issubclass(tp, Config) and tp not in [Config, Task]:
|
|
493
608
|
yield tp.__xpmtype__
|
|
494
609
|
|
|
@@ -504,7 +619,7 @@ class ObjectType(Type):
|
|
|
504
619
|
if not isinstance(value, Config):
|
|
505
620
|
raise ValueError(f"{value} is not an experimaestro type or task")
|
|
506
621
|
|
|
507
|
-
types = self.
|
|
622
|
+
types = self.value_type
|
|
508
623
|
|
|
509
624
|
if not isinstance(value, types):
|
|
510
625
|
raise ValueError(
|
|
@@ -519,7 +634,7 @@ class ObjectType(Type):
|
|
|
519
634
|
|
|
520
635
|
def fullyqualifiedname(self) -> str:
|
|
521
636
|
"""Returns the fully qualified (Python) name"""
|
|
522
|
-
return f"{self.
|
|
637
|
+
return f"{self.value_type.__module__}.{self.value_type.__qualname__}"
|
|
523
638
|
|
|
524
639
|
|
|
525
640
|
class TypeProxy:
|
experimaestro/exceptions.py
CHANGED
|
@@ -1,2 +1,28 @@
|
|
|
1
1
|
class HandledException(Exception):
|
|
2
2
|
pass
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class GracefulTimeout(Exception):
|
|
6
|
+
"""Exception raised to signal a graceful timeout in resumable tasks.
|
|
7
|
+
|
|
8
|
+
Raise this exception when a task needs to checkpoint and exit before
|
|
9
|
+
a time limit (e.g., SLURM walltime). The task will be marked for retry
|
|
10
|
+
rather than as failed.
|
|
11
|
+
|
|
12
|
+
Example::
|
|
13
|
+
|
|
14
|
+
```python
|
|
15
|
+
class LongTraining(ResumableTask):
|
|
16
|
+
def execute(self):
|
|
17
|
+
for epoch in range(self.epochs):
|
|
18
|
+
remaining = self.remaining_time()
|
|
19
|
+
if remaining is not None and remaining < 300:
|
|
20
|
+
save_checkpoint(self.checkpoint, epoch)
|
|
21
|
+
raise GracefulTimeout("Not enough time for another epoch")
|
|
22
|
+
train_one_epoch()
|
|
23
|
+
```
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, message: str = "Task stopped gracefully before timeout"):
|
|
27
|
+
self.message = message
|
|
28
|
+
super().__init__(message)
|
experimaestro/experiments/cli.py
CHANGED
|
@@ -52,8 +52,7 @@ class ExperimentHelper:
|
|
|
52
52
|
class ExperimentCallable(Protocol):
|
|
53
53
|
"""Protocol for the run function"""
|
|
54
54
|
|
|
55
|
-
def __call__(self, helper: ExperimentHelper, configuration: Any):
|
|
56
|
-
...
|
|
55
|
+
def __call__(self, helper: ExperimentHelper, configuration: Any): ... # noqa: E704
|
|
57
56
|
|
|
58
57
|
|
|
59
58
|
class ConfigurationLoader:
|
|
@@ -126,6 +125,11 @@ class ConfigurationLoader:
|
|
|
126
125
|
default=None,
|
|
127
126
|
help="Port for monitoring (can be defined in the settings.yaml file)",
|
|
128
127
|
)
|
|
128
|
+
@click.option(
|
|
129
|
+
"--console",
|
|
130
|
+
is_flag=True,
|
|
131
|
+
help="Launch Textual console UI for monitoring with logs",
|
|
132
|
+
)
|
|
129
133
|
@click.option(
|
|
130
134
|
"--file",
|
|
131
135
|
"xp_file",
|
|
@@ -162,6 +166,7 @@ def experiments_cli( # noqa: C901
|
|
|
162
166
|
xp_file: str,
|
|
163
167
|
host: str,
|
|
164
168
|
port: int,
|
|
169
|
+
console: bool,
|
|
165
170
|
xpm_config_dir: Path,
|
|
166
171
|
workdir: Optional[Path],
|
|
167
172
|
workspace: Optional[str],
|
|
@@ -298,43 +303,120 @@ def experiments_cli( # noqa: C901
|
|
|
298
303
|
configuration, structured_config_mode=SCMode.INSTANTIATE
|
|
299
304
|
)
|
|
300
305
|
|
|
301
|
-
# Define the workspace
|
|
302
|
-
ws_env = find_workspace(workdir=workdir, workspace=workspace)
|
|
303
|
-
|
|
304
|
-
workdir = ws_env.path
|
|
305
|
-
|
|
306
306
|
# --- Sets up the experiment ID
|
|
307
|
-
|
|
308
|
-
# --- Runs the experiment
|
|
309
307
|
if xp_configuration.add_timestamp:
|
|
310
308
|
timestamp = datetime.datetime.now().strftime("%Y%m%d-%H%M")
|
|
311
309
|
experiment_id = f"""{xp_configuration.id}-{timestamp}"""
|
|
312
310
|
else:
|
|
313
311
|
experiment_id = xp_configuration.id
|
|
314
312
|
|
|
313
|
+
# Define the workspace (may auto-select based on experiment_id triggers)
|
|
314
|
+
ws_env = find_workspace(
|
|
315
|
+
workdir=workdir, workspace=workspace, experiment_id=experiment_id
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
workdir = ws_env.path
|
|
319
|
+
|
|
315
320
|
logging.info(
|
|
316
321
|
"Running experiment %s working directory %s",
|
|
317
322
|
experiment_id,
|
|
318
323
|
str(workdir.resolve()),
|
|
319
324
|
)
|
|
320
|
-
with experiment(
|
|
321
|
-
ws_env, experiment_id, host=host, port=port, run_mode=run_mode
|
|
322
|
-
) as xp:
|
|
323
|
-
# Set up the environment
|
|
324
|
-
# (1) global settings (2) workspace settings and (3) command line settings
|
|
325
|
-
for key, value in env:
|
|
326
|
-
xp.setenv(key, value)
|
|
327
|
-
|
|
328
|
-
# Sets the python path
|
|
329
|
-
xp.workspace.python_path.extend(python_path)
|
|
330
325
|
|
|
326
|
+
# Define the experiment execution function
|
|
327
|
+
def run_experiment_code(xp_holder=None, xp_ready_event=None, register_signals=True):
|
|
328
|
+
"""Run the experiment code - optionally storing xp in xp_holder"""
|
|
331
329
|
try:
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
330
|
+
with experiment(
|
|
331
|
+
ws_env,
|
|
332
|
+
experiment_id,
|
|
333
|
+
host=host,
|
|
334
|
+
port=port,
|
|
335
|
+
run_mode=run_mode,
|
|
336
|
+
register_signals=register_signals,
|
|
337
|
+
) as xp:
|
|
338
|
+
if xp_holder is not None:
|
|
339
|
+
xp_holder["xp"] = xp
|
|
340
|
+
if xp_ready_event is not None:
|
|
341
|
+
xp_ready_event.set() # Signal that xp is ready
|
|
342
|
+
|
|
343
|
+
# Test logging from experiment thread
|
|
344
|
+
logging.info("Experiment started in background thread")
|
|
345
|
+
|
|
346
|
+
# Set up the environment
|
|
347
|
+
for key, value in env:
|
|
348
|
+
xp.setenv(key, value)
|
|
349
|
+
|
|
350
|
+
# Sets the python path
|
|
351
|
+
xp.workspace.python_path.extend(python_path)
|
|
352
|
+
|
|
353
|
+
# Run the experiment
|
|
354
|
+
helper.xp = xp
|
|
355
|
+
helper.run(list(args), xp_configuration)
|
|
356
|
+
|
|
357
|
+
# ... and wait
|
|
358
|
+
xp.wait()
|
|
338
359
|
|
|
339
360
|
except HandledException:
|
|
340
361
|
sys.exit(1)
|
|
362
|
+
|
|
363
|
+
if console:
|
|
364
|
+
# Run experiment in background thread, console UI in main thread
|
|
365
|
+
import threading
|
|
366
|
+
from experimaestro.tui import ExperimentTUI
|
|
367
|
+
|
|
368
|
+
xp_holder = {"xp": None}
|
|
369
|
+
exception_holder = {"exception": None}
|
|
370
|
+
xp_ready = threading.Event()
|
|
371
|
+
|
|
372
|
+
def run_in_thread():
|
|
373
|
+
try:
|
|
374
|
+
# Don't register signals in background thread
|
|
375
|
+
run_experiment_code(xp_holder, xp_ready, register_signals=False)
|
|
376
|
+
# Add a test message after experiment completes
|
|
377
|
+
logging.info("Experiment thread completed")
|
|
378
|
+
print("Experiment thread print test")
|
|
379
|
+
except Exception as e:
|
|
380
|
+
exception_holder["exception"] = e
|
|
381
|
+
xp_ready.set() # Signal even on error
|
|
382
|
+
|
|
383
|
+
# Start experiment in background thread
|
|
384
|
+
exp_thread = threading.Thread(target=run_in_thread, daemon=True)
|
|
385
|
+
exp_thread.start()
|
|
386
|
+
|
|
387
|
+
# Wait for experiment to start (up to 30 seconds)
|
|
388
|
+
if not xp_ready.wait(timeout=30.0):
|
|
389
|
+
cprint("Timeout waiting for experiment to start", "red", file=sys.stderr)
|
|
390
|
+
sys.exit(1)
|
|
391
|
+
|
|
392
|
+
if xp_holder["xp"] is None:
|
|
393
|
+
cprint("Failed to start experiment", "red", file=sys.stderr)
|
|
394
|
+
if exception_holder["exception"]:
|
|
395
|
+
raise exception_holder["exception"]
|
|
396
|
+
sys.exit(1)
|
|
397
|
+
|
|
398
|
+
# Run TUI in main thread (handles signals via Textual)
|
|
399
|
+
tui_app = ExperimentTUI(
|
|
400
|
+
workdir=workdir,
|
|
401
|
+
state_provider=xp_holder["xp"].state_provider,
|
|
402
|
+
show_logs=True,
|
|
403
|
+
)
|
|
404
|
+
|
|
405
|
+
try:
|
|
406
|
+
# Textual automatically captures stdout/stderr via Print events
|
|
407
|
+
tui_app.run()
|
|
408
|
+
finally:
|
|
409
|
+
# TUI exited (user pressed q or Ctrl+C) - stop the experiment
|
|
410
|
+
if xp_holder["xp"]:
|
|
411
|
+
xp_holder["xp"].stop()
|
|
412
|
+
|
|
413
|
+
# Wait for experiment thread to finish
|
|
414
|
+
exp_thread.join(timeout=5.0)
|
|
415
|
+
|
|
416
|
+
# Handle exceptions
|
|
417
|
+
if exception_holder["exception"]:
|
|
418
|
+
raise exception_holder["exception"]
|
|
419
|
+
|
|
420
|
+
else:
|
|
421
|
+
# Normal mode without TUI - run directly
|
|
422
|
+
run_experiment_code()
|