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.

Files changed (133) hide show
  1. experimaestro/__init__.py +10 -11
  2. experimaestro/annotations.py +167 -206
  3. experimaestro/cli/__init__.py +140 -16
  4. experimaestro/cli/filter.py +42 -74
  5. experimaestro/cli/jobs.py +157 -106
  6. experimaestro/cli/progress.py +269 -0
  7. experimaestro/cli/refactor.py +249 -0
  8. experimaestro/click.py +0 -1
  9. experimaestro/commandline.py +19 -3
  10. experimaestro/connectors/__init__.py +22 -3
  11. experimaestro/connectors/local.py +12 -0
  12. experimaestro/core/arguments.py +192 -37
  13. experimaestro/core/identifier.py +127 -12
  14. experimaestro/core/objects/__init__.py +6 -0
  15. experimaestro/core/objects/config.py +702 -285
  16. experimaestro/core/objects/config_walk.py +24 -6
  17. experimaestro/core/serialization.py +91 -34
  18. experimaestro/core/serializers.py +1 -8
  19. experimaestro/core/subparameters.py +164 -0
  20. experimaestro/core/types.py +198 -83
  21. experimaestro/exceptions.py +26 -0
  22. experimaestro/experiments/cli.py +107 -25
  23. experimaestro/generators.py +50 -9
  24. experimaestro/huggingface.py +3 -1
  25. experimaestro/launcherfinder/parser.py +29 -0
  26. experimaestro/launcherfinder/registry.py +3 -3
  27. experimaestro/launchers/__init__.py +26 -1
  28. experimaestro/launchers/direct.py +12 -0
  29. experimaestro/launchers/slurm/base.py +154 -2
  30. experimaestro/mkdocs/base.py +6 -8
  31. experimaestro/mkdocs/metaloader.py +0 -1
  32. experimaestro/mypy.py +452 -7
  33. experimaestro/notifications.py +75 -16
  34. experimaestro/progress.py +404 -0
  35. experimaestro/rpyc.py +0 -1
  36. experimaestro/run.py +19 -6
  37. experimaestro/scheduler/__init__.py +18 -1
  38. experimaestro/scheduler/base.py +504 -959
  39. experimaestro/scheduler/dependencies.py +43 -28
  40. experimaestro/scheduler/dynamic_outputs.py +259 -130
  41. experimaestro/scheduler/experiment.py +582 -0
  42. experimaestro/scheduler/interfaces.py +474 -0
  43. experimaestro/scheduler/jobs.py +485 -0
  44. experimaestro/scheduler/services.py +186 -12
  45. experimaestro/scheduler/signal_handler.py +32 -0
  46. experimaestro/scheduler/state.py +1 -1
  47. experimaestro/scheduler/state_db.py +388 -0
  48. experimaestro/scheduler/state_provider.py +2345 -0
  49. experimaestro/scheduler/state_sync.py +834 -0
  50. experimaestro/scheduler/workspace.py +52 -10
  51. experimaestro/scriptbuilder.py +7 -0
  52. experimaestro/server/__init__.py +153 -32
  53. experimaestro/server/data/index.css +0 -125
  54. experimaestro/server/data/index.css.map +1 -1
  55. experimaestro/server/data/index.js +194 -58
  56. experimaestro/server/data/index.js.map +1 -1
  57. experimaestro/settings.py +47 -6
  58. experimaestro/sphinx/__init__.py +3 -3
  59. experimaestro/taskglobals.py +20 -0
  60. experimaestro/tests/conftest.py +80 -0
  61. experimaestro/tests/core/test_generics.py +2 -2
  62. experimaestro/tests/identifier_stability.json +45 -0
  63. experimaestro/tests/launchers/bin/sacct +6 -2
  64. experimaestro/tests/launchers/bin/sbatch +4 -2
  65. experimaestro/tests/launchers/common.py +2 -2
  66. experimaestro/tests/launchers/test_slurm.py +80 -0
  67. experimaestro/tests/restart.py +1 -1
  68. experimaestro/tests/tasks/all.py +7 -0
  69. experimaestro/tests/tasks/test_dynamic.py +231 -0
  70. experimaestro/tests/test_checkers.py +2 -2
  71. experimaestro/tests/test_cli_jobs.py +615 -0
  72. experimaestro/tests/test_dependencies.py +11 -17
  73. experimaestro/tests/test_deprecated.py +630 -0
  74. experimaestro/tests/test_environment.py +200 -0
  75. experimaestro/tests/test_experiment.py +3 -3
  76. experimaestro/tests/test_file_progress.py +425 -0
  77. experimaestro/tests/test_file_progress_integration.py +477 -0
  78. experimaestro/tests/test_forward.py +3 -3
  79. experimaestro/tests/test_generators.py +93 -0
  80. experimaestro/tests/test_identifier.py +520 -169
  81. experimaestro/tests/test_identifier_stability.py +458 -0
  82. experimaestro/tests/test_instance.py +16 -21
  83. experimaestro/tests/test_multitoken.py +442 -0
  84. experimaestro/tests/test_mypy.py +433 -0
  85. experimaestro/tests/test_objects.py +314 -30
  86. experimaestro/tests/test_outputs.py +8 -8
  87. experimaestro/tests/test_param.py +22 -26
  88. experimaestro/tests/test_partial_paths.py +231 -0
  89. experimaestro/tests/test_progress.py +2 -50
  90. experimaestro/tests/test_resumable_task.py +480 -0
  91. experimaestro/tests/test_serializers.py +141 -60
  92. experimaestro/tests/test_state_db.py +434 -0
  93. experimaestro/tests/test_subparameters.py +160 -0
  94. experimaestro/tests/test_tags.py +151 -15
  95. experimaestro/tests/test_tasks.py +137 -160
  96. experimaestro/tests/test_token_locking.py +252 -0
  97. experimaestro/tests/test_tokens.py +25 -19
  98. experimaestro/tests/test_types.py +133 -11
  99. experimaestro/tests/test_validation.py +19 -19
  100. experimaestro/tests/test_workspace_triggers.py +158 -0
  101. experimaestro/tests/token_reschedule.py +5 -3
  102. experimaestro/tests/utils.py +2 -2
  103. experimaestro/tokens.py +154 -57
  104. experimaestro/tools/diff.py +8 -1
  105. experimaestro/tui/__init__.py +8 -0
  106. experimaestro/tui/app.py +2303 -0
  107. experimaestro/tui/app.tcss +353 -0
  108. experimaestro/tui/log_viewer.py +228 -0
  109. experimaestro/typingutils.py +11 -2
  110. experimaestro/utils/__init__.py +23 -0
  111. experimaestro/utils/environment.py +148 -0
  112. experimaestro/utils/git.py +129 -0
  113. experimaestro/utils/resources.py +1 -1
  114. experimaestro/version.py +34 -0
  115. {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info}/METADATA +70 -39
  116. experimaestro-2.0.0b4.dist-info/RECORD +181 -0
  117. {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info}/WHEEL +1 -1
  118. experimaestro-2.0.0b4.dist-info/entry_points.txt +16 -0
  119. experimaestro/compat.py +0 -6
  120. experimaestro/core/objects.pyi +0 -225
  121. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  122. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  123. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  124. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  125. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  126. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  127. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  128. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  129. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  130. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  131. experimaestro-1.11.1.dist-info/RECORD +0 -158
  132. experimaestro-1.11.1.dist-info/entry_points.txt +0 -17
  133. {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info/licenses}/LICENSE +0 -0
@@ -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 Set, TypeVar, Union, Dict, Iterator, List, get_args, get_origin
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
- if sys.version_info.major == 3 and sys.version_info.minor < 9:
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 objecttype: The python Type of the associated object
207
- :param configtype: The python Type of the configuration object that uses
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
- # Get the identifier
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
- if not issubclass(tp, Config):
254
- # Adds Config as a base class if not present
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__().configtype
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.basetype.__qualname__.split(".")
277
- self.configtype = type(
278
- f"{tp_name}.XPMConfig", __configbases__ + (self.basetype,), {}
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.configtype.__qualname__ = ".".join(tp_qual + [self.configtype.__name__])
281
- self.configtype.__module__ = tp.__module__
289
+ self.config_type.__qualname__ = ".".join(tp_qual + [self.config_type.__name__])
290
+ self.config_type.__module__ = tp.__module__
282
291
 
283
- # Return type is used by tasks to change the output
284
- if hasattr(self.basetype, "task_outputs") or False:
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.basetype, "task_outputs")
295
+ getattr(self.value_type, "task_outputs")
287
296
  ).get("return", typing.Any)
288
297
  else:
289
- self.returntype = self.basetype
298
+ self.returntype = self.value_type
290
299
 
291
- # Registers ourselves
292
- self.basetype.__xpmtype__ = self
293
- self.configtype.__xpmtype__ = 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._deprecated = False
308
+ self._deprecation: Optional[DeprecationInfo] = None
300
309
 
301
- @property
302
- def objecttype(self):
303
- """Returns the object type"""
304
- return self.basetype.XPMValue
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.basetype, Task):
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.basetype, "__annotations__"):
367
- typekeys = set(self.basetype.__dict__.get("__annotations__", {}).keys())
368
- hints = get_type_hints(self.basetype, include_extras=True)
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.objecttype, typehint.__args__[0]
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.objecttype,
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.basetype.__module__}.{self.basetype.__qualname__}"
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
- __doc__ = self.basetype.__dict__.get("__doc__", None)
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
- if len(self.basetype.__bases__) != 1:
434
- raise RuntimeError(
435
- "Deprecated configurations must have "
436
- "only one parent (the new configuration)"
437
- )
438
- assert not self._deprecated, "Already deprecated"
439
-
440
- # Uses the parent identifier (and saves the deprecated one for path updates)
441
- self._deprecated_identifier = self.identifier
442
- parent = self.basetype.__bases__[0].__getxpmtype__()
443
- self.identifier = parent.identifier
444
- self._deprecated = True
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._deprecated
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) -> Dict[str, Argument]:
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
- for tp in self.basetype.__bases__:
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.basetype
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.basetype.__module__}.{self.basetype.__qualname__}"
637
+ return f"{self.value_type.__module__}.{self.value_type.__qualname__}"
523
638
 
524
639
 
525
640
  class TypeProxy:
@@ -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)
@@ -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
- # Run the experiment
333
- helper.xp = xp
334
- helper.run(list(args), xp_configuration)
335
-
336
- # ... and wait
337
- xp.wait()
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()