experimaestro 2.0.0a8__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 (116) hide show
  1. experimaestro/__init__.py +10 -11
  2. experimaestro/annotations.py +167 -206
  3. experimaestro/cli/__init__.py +130 -5
  4. experimaestro/cli/filter.py +42 -74
  5. experimaestro/cli/jobs.py +157 -106
  6. experimaestro/cli/refactor.py +249 -0
  7. experimaestro/click.py +0 -1
  8. experimaestro/commandline.py +19 -3
  9. experimaestro/connectors/__init__.py +20 -1
  10. experimaestro/connectors/local.py +12 -0
  11. experimaestro/core/arguments.py +182 -46
  12. experimaestro/core/identifier.py +107 -6
  13. experimaestro/core/objects/__init__.py +6 -0
  14. experimaestro/core/objects/config.py +542 -25
  15. experimaestro/core/objects/config_walk.py +20 -0
  16. experimaestro/core/serialization.py +91 -34
  17. experimaestro/core/subparameters.py +164 -0
  18. experimaestro/core/types.py +175 -38
  19. experimaestro/exceptions.py +26 -0
  20. experimaestro/experiments/cli.py +107 -25
  21. experimaestro/generators.py +50 -9
  22. experimaestro/huggingface.py +3 -1
  23. experimaestro/launcherfinder/parser.py +29 -0
  24. experimaestro/launchers/__init__.py +26 -1
  25. experimaestro/launchers/direct.py +12 -0
  26. experimaestro/launchers/slurm/base.py +154 -2
  27. experimaestro/mkdocs/metaloader.py +0 -1
  28. experimaestro/mypy.py +452 -7
  29. experimaestro/notifications.py +63 -13
  30. experimaestro/progress.py +0 -2
  31. experimaestro/rpyc.py +0 -1
  32. experimaestro/run.py +19 -6
  33. experimaestro/scheduler/base.py +489 -125
  34. experimaestro/scheduler/dependencies.py +43 -28
  35. experimaestro/scheduler/dynamic_outputs.py +259 -130
  36. experimaestro/scheduler/experiment.py +225 -30
  37. experimaestro/scheduler/interfaces.py +474 -0
  38. experimaestro/scheduler/jobs.py +216 -206
  39. experimaestro/scheduler/services.py +186 -12
  40. experimaestro/scheduler/state_db.py +388 -0
  41. experimaestro/scheduler/state_provider.py +2345 -0
  42. experimaestro/scheduler/state_sync.py +834 -0
  43. experimaestro/scheduler/workspace.py +52 -10
  44. experimaestro/scriptbuilder.py +7 -0
  45. experimaestro/server/__init__.py +147 -57
  46. experimaestro/server/data/index.css +0 -125
  47. experimaestro/server/data/index.css.map +1 -1
  48. experimaestro/server/data/index.js +194 -58
  49. experimaestro/server/data/index.js.map +1 -1
  50. experimaestro/settings.py +44 -5
  51. experimaestro/sphinx/__init__.py +3 -3
  52. experimaestro/taskglobals.py +20 -0
  53. experimaestro/tests/conftest.py +80 -0
  54. experimaestro/tests/core/test_generics.py +2 -2
  55. experimaestro/tests/identifier_stability.json +45 -0
  56. experimaestro/tests/launchers/bin/sacct +6 -2
  57. experimaestro/tests/launchers/bin/sbatch +4 -2
  58. experimaestro/tests/launchers/test_slurm.py +80 -0
  59. experimaestro/tests/tasks/test_dynamic.py +231 -0
  60. experimaestro/tests/test_cli_jobs.py +615 -0
  61. experimaestro/tests/test_deprecated.py +630 -0
  62. experimaestro/tests/test_environment.py +200 -0
  63. experimaestro/tests/test_file_progress_integration.py +1 -1
  64. experimaestro/tests/test_forward.py +3 -3
  65. experimaestro/tests/test_identifier.py +372 -41
  66. experimaestro/tests/test_identifier_stability.py +458 -0
  67. experimaestro/tests/test_instance.py +3 -3
  68. experimaestro/tests/test_multitoken.py +442 -0
  69. experimaestro/tests/test_mypy.py +433 -0
  70. experimaestro/tests/test_objects.py +312 -5
  71. experimaestro/tests/test_outputs.py +2 -2
  72. experimaestro/tests/test_param.py +8 -12
  73. experimaestro/tests/test_partial_paths.py +231 -0
  74. experimaestro/tests/test_progress.py +0 -48
  75. experimaestro/tests/test_resumable_task.py +480 -0
  76. experimaestro/tests/test_serializers.py +141 -1
  77. experimaestro/tests/test_state_db.py +434 -0
  78. experimaestro/tests/test_subparameters.py +160 -0
  79. experimaestro/tests/test_tags.py +136 -0
  80. experimaestro/tests/test_tasks.py +107 -121
  81. experimaestro/tests/test_token_locking.py +252 -0
  82. experimaestro/tests/test_tokens.py +17 -13
  83. experimaestro/tests/test_types.py +123 -1
  84. experimaestro/tests/test_workspace_triggers.py +158 -0
  85. experimaestro/tests/token_reschedule.py +4 -2
  86. experimaestro/tests/utils.py +2 -2
  87. experimaestro/tokens.py +154 -57
  88. experimaestro/tools/diff.py +1 -1
  89. experimaestro/tui/__init__.py +8 -0
  90. experimaestro/tui/app.py +2303 -0
  91. experimaestro/tui/app.tcss +353 -0
  92. experimaestro/tui/log_viewer.py +228 -0
  93. experimaestro/utils/__init__.py +23 -0
  94. experimaestro/utils/environment.py +148 -0
  95. experimaestro/utils/git.py +129 -0
  96. experimaestro/utils/resources.py +1 -1
  97. experimaestro/version.py +34 -0
  98. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b4.dist-info}/METADATA +68 -38
  99. experimaestro-2.0.0b4.dist-info/RECORD +181 -0
  100. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b4.dist-info}/WHEEL +1 -1
  101. experimaestro-2.0.0b4.dist-info/entry_points.txt +16 -0
  102. experimaestro/compat.py +0 -6
  103. experimaestro/core/objects.pyi +0 -221
  104. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  105. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  106. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  107. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  108. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  109. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  110. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  111. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  112. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  113. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  114. experimaestro-2.0.0a8.dist-info/RECORD +0 -166
  115. experimaestro-2.0.0a8.dist-info/entry_points.txt +0 -17
  116. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b4.dist-info}/licenses/LICENSE +0 -0
experimaestro/__init__.py CHANGED
@@ -4,15 +4,7 @@ from pathlib import Path
4
4
 
5
5
  # Annotations
6
6
  from .annotations import (
7
- config,
8
- task,
9
- param,
10
- ConstantParam,
11
- constant,
12
- option,
13
- pathoption,
14
7
  cache,
15
- Identifier,
16
8
  Array,
17
9
  TagDict,
18
10
  tag,
@@ -21,12 +13,11 @@ from .annotations import (
21
13
  STDOUT,
22
14
  STDERR,
23
15
  deprecate,
24
- # deprecated
25
- argument,
26
16
  initializer,
27
17
  # Method
28
18
  config_only,
29
19
  )
20
+ from .core.types import Identifier
30
21
  from .core.serialization import (
31
22
  load,
32
23
  save,
@@ -47,15 +38,22 @@ from .core.arguments import (
47
38
  field,
48
39
  # Annotations helpers
49
40
  help,
50
- default,
51
41
  )
52
42
  from .generators import pathgenerator, PathGenerator
43
+ from .core.subparameters import (
44
+ subparameters,
45
+ param_group,
46
+ ParameterGroup,
47
+ Subparameters,
48
+ )
53
49
  from .core.objects import (
54
50
  Config,
51
+ InstanceConfig,
55
52
  copyconfig,
56
53
  setmeta,
57
54
  DependentMarker,
58
55
  Task,
56
+ ResumableTask,
59
57
  LightweightTask,
60
58
  ObjectStore,
61
59
  )
@@ -64,6 +62,7 @@ from .core.serializers import SerializationLWTask, PathSerializationLWTask
64
62
  from .core.types import Any, SubmitHook
65
63
  from .launchers import Launcher
66
64
  from .scheduler import Scheduler, experiment, FailedExperiment
65
+ from .exceptions import GracefulTimeout
67
66
  from .scheduler.workspace import Workspace, RunMode
68
67
  from .scheduler.state import get_experiment
69
68
  from .notifications import progress, tqdm
@@ -1,18 +1,14 @@
1
1
  # Import Python modules
2
2
 
3
3
  import inspect
4
- from pathlib import Path
5
- from typing import Callable, Type as TypingType, Optional, TypeVar, Union
4
+ from typing import Callable, Type as TypingType, TypeVar, Union
6
5
  from sortedcontainers import SortedDict
7
6
  import experimaestro.core.objects as objects
8
7
  import experimaestro.core.types as types
9
- from experimaestro.generators import PathGenerator
10
8
 
11
- from .core.arguments import Argument as CoreArgument, field
12
- from .core.objects import Config
13
- from .core.types import Any, Identifier, TypeProxy, Type, ObjectType
9
+ from .core.objects import Config, Task
10
+ from .core.types import Any, TypeProxy, Type
14
11
  from .utils import logger
15
- from .checkers import Checker
16
12
 
17
13
  # --- Annotations to define tasks and types
18
14
 
@@ -24,56 +20,6 @@ def configmethod(method):
24
20
  return method
25
21
 
26
22
 
27
- class config:
28
- """Annotations for experimaestro types"""
29
-
30
- def __init__(self, identifier=None, description=None):
31
- """[summary]
32
-
33
- Keyword Arguments:
34
- identifier {Identifier, str} -- Unique identifier of the type,
35
- generate by default (None)
36
-
37
- description {str} -- (deprecated, use
38
- comments) Description of the config/task, use comments with
39
- (default) None
40
-
41
- register {bool} -- False if the type should not be
42
- registered (debug only)
43
-
44
- The identifier, if not specified, will be set to `X.CLASSNAME`(by order
45
- of priority), where X is:
46
- - the parent identifier
47
- - the module qualified name
48
- """
49
- super().__init__()
50
- self.identifier = identifier
51
- if isinstance(self.identifier, str):
52
- self.identifier = Identifier(self.identifier)
53
-
54
- self.description = description
55
-
56
- def __call__(self, tp: T) -> T:
57
- """Annotate the class
58
-
59
- Depending on whether we are running or configuring,
60
- the behavior is different:
61
-
62
- - when configuring, we return a proxy class
63
- - when running, we return the same class
64
-
65
- Arguments:
66
- tp {type} -- The type
67
-
68
- Keyword Arguments:
69
- basetype {type} -- The base type of the class (Task or Config)
70
- """
71
- assert inspect.isclass(tp), f"{tp} is not a class"
72
-
73
- # Adds to xpminfo for on demand creation of information
74
- return ObjectType(tp, identifier=self.identifier).valuetype
75
-
76
-
77
23
  class Array(TypeProxy):
78
24
  """Array of object"""
79
25
 
@@ -94,121 +40,12 @@ class Choice(TypeProxy):
94
40
  return types.StringType
95
41
 
96
42
 
97
- class task(config):
98
- """Register a task"""
99
-
100
- def __init__(self, identifier=None, pythonpath=None, description=None):
101
- super().__init__(identifier, description)
102
- self.pythonpath = pythonpath
103
-
104
- def __call__(self, tp) -> type:
105
- # Register the type
106
- tp = super().__call__(tp)
107
-
108
- def factory(xpmtype):
109
- return xpmtype.getpythontaskcommand(self.pythonpath)
110
-
111
- tp.__getxpmtype__().taskcommandfactory = factory
112
- return tp
113
-
114
-
115
- # --- argument related annotations
116
-
117
-
118
- class param:
119
- """Defines an argument for an experimaestro type"""
120
-
121
- def __init__(
122
- self,
123
- name,
124
- type=None,
125
- default=None,
126
- required: bool = None,
127
- ignored: Optional[bool] = None,
128
- help: Optional[str] = None,
129
- checker: Optional[Checker] = None,
130
- constant: bool = False,
131
- ):
132
- # Determine if required
133
- self.name = name
134
- self.type = Type.fromType(type) if type else None
135
- self.help = help
136
- self.ignored = ignored
137
- self.required = required
138
- self.checker = checker
139
- self.constant = constant
140
-
141
- self.generator = None
142
- self.default = None
143
-
144
- # Set default or generator
145
- if isinstance(default, field):
146
- if default.default is not None:
147
- self.default = default
148
- elif default.default_factory is not None:
149
- self.generator = default.default_factory
150
- else:
151
- self.default = default
152
-
153
- def __call__(self, tp):
154
- # Don't annotate in task mode
155
- tp.__getxpmtype__().addAnnotation(self)
156
- return tp
157
-
158
- def process(self, xpmtype):
159
- # Get type from default if needed
160
- if self.type is None:
161
- if self.default is not None:
162
- self.type = Type.fromType(type(self.default))
163
-
164
- # Type = any if no type
165
- if self.type is None:
166
- self.type = types.Any
167
-
168
- argument = CoreArgument(
169
- self.name,
170
- self.type,
171
- help=self.help,
172
- required=self.required,
173
- ignored=self.ignored,
174
- generator=self.generator,
175
- default=self.default,
176
- checker=self.checker,
177
- constant=self.constant,
178
- )
179
- xpmtype.addArgument(argument)
180
-
181
-
182
- # Just a rebind (back-compatibility)
183
- argument = param
184
-
185
-
186
- class option(param):
187
- """An argument which is ignored
188
-
189
- See argument
190
- """
191
-
192
- def __init__(self, *args, **kwargs):
193
- kwargs["ignored"] = True
194
- super().__init__(*args, **kwargs)
195
-
196
-
197
- class pathoption(param):
198
- """Defines a an argument that will be a relative path (automatically
199
- set by experimaestro)"""
200
-
201
- def __init__(self, name: str, path=None, help=""):
202
- """
203
- :param name: The name of argument (in python)
204
- :param path: The relative path or a function
205
- """
206
- super().__init__(name, type=Path, help=help)
43
+ def config_only(method):
44
+ """Marks a configuration-only method"""
45
+ assert inspect.ismethod(method)
207
46
 
208
- if path is None:
209
- path = name
210
47
 
211
- self.generator = PathGenerator(path)
48
+ # --- Path generators for task stdout/stderr
212
49
 
213
50
 
214
51
  def STDERR(jobcontext, config):
@@ -219,28 +56,31 @@ def STDOUT(jobcontext, config):
219
56
  return "%s.out" % jobcontext.name
220
57
 
221
58
 
222
- class constant(param):
223
- """
224
- A constant argument (useful for versionning tasks)
225
- """
226
-
227
- def __init__(self, name: str, value, type=None, help=""):
228
- super().__init__(name, default=value, constant=True, type=type, help=help)
229
-
59
+ # --- Cache
230
60
 
231
- ConstantParam = constant
232
61
 
62
+ def cache(name: str):
63
+ """Decorator for caching method results to disk.
233
64
 
234
- def config_only(method):
235
- """Marks a configuration-only method"""
236
- assert inspect.ismethod(method)
65
+ The cache is stored in the workspace's config directory, keyed by the
66
+ configuration's identifier.
237
67
 
68
+ Example::
238
69
 
239
- # --- Cache
70
+ class MyConfig(Config):
71
+ data_path: Param[Path]
240
72
 
73
+ @cache("processed.pkl")
74
+ def process(self, cache_path: Path):
75
+ if cache_path.exists():
76
+ return pickle.load(cache_path.open("rb"))
77
+ result = expensive_computation(self.data_path)
78
+ pickle.dump(result, cache_path.open("wb"))
79
+ return result
241
80
 
242
- def cache(name: str):
243
- """Use a cache path for a given config"""
81
+ :param name: Filename for the cache file
82
+ :return: A decorator that wraps the method with caching logic
83
+ """
244
84
 
245
85
  def annotate(method):
246
86
  return objects.cache(method, name)
@@ -252,7 +92,22 @@ def cache(name: str):
252
92
 
253
93
 
254
94
  def tag(value):
255
- """Tag a value"""
95
+ """Tag a parameter value for tracking in experiments.
96
+
97
+ Tagged values appear in experiment logs and can be used for filtering
98
+ and organizing results. Tags are included in the task's ``__tags__``
99
+ dictionary.
100
+
101
+ Example::
102
+
103
+ task = MyTask.C(
104
+ learning_rate=tag(0.001), # Will appear in task tags
105
+ batch_size=32,
106
+ ).submit()
107
+
108
+ :param value: The value to tag (str, int, float, or bool)
109
+ :return: A tagged value wrapper that preserves the original value
110
+ """
256
111
  return objects.TaggedValue(value)
257
112
 
258
113
 
@@ -267,7 +122,19 @@ class TagDict(SortedDict):
267
122
 
268
123
 
269
124
  def tags(value) -> TagDict:
270
- """Return the tags associated with a value"""
125
+ """Return the tags associated with a configuration.
126
+
127
+ Returns a dictionary of all tagged parameter values from this configuration
128
+ and its nested configurations.
129
+
130
+ Example::
131
+
132
+ config = MyTask.C(learning_rate=tag(0.001), epochs=tag(100))
133
+ task_tags = tags(config) # {"learning_rate": 0.001, "epochs": 100}
134
+
135
+ :param value: A configuration object
136
+ :return: A TagDict with tag names as keys and tagged values as values
137
+ """
271
138
  return TagDict(value.__xpm__.tags())
272
139
 
273
140
 
@@ -277,8 +144,20 @@ def _normalizepathcomponent(v: Any):
277
144
  return v
278
145
 
279
146
 
280
- def tagspath(value: Config):
281
- """Return a unique path made of tags and their values"""
147
+ def tagspath(value: Config) -> str:
148
+ """Generate a unique path string from a configuration's tags.
149
+
150
+ Useful for creating tag-based directory structures. Tags are sorted
151
+ alphabetically and joined with underscores.
152
+
153
+ Example::
154
+
155
+ config = MyTask.C(learning_rate=tag(0.001), epochs=tag(100))
156
+ path = tagspath(config) # "epochs=100_learning_rate=0.001"
157
+
158
+ :param value: A configuration object
159
+ :return: A string with sorted tags in ``key=value`` format, joined by ``_``
160
+ """
282
161
  return "_".join(
283
162
  f"""{_normalizepathcomponent(key)}={_normalizepathcomponent(value)}"""
284
163
  for key, value in tags(value).items()
@@ -288,33 +167,101 @@ def tagspath(value: Config):
288
167
  # --- Deprecated
289
168
 
290
169
 
291
- def deprecate(config: Union[TypingType[Config], Callable]):
292
- """Deprecate a configuration / task or
293
- an attribute (via a method)
170
+ def deprecate(
171
+ config_or_target: Union[TypingType[Config], Callable, None] = None,
172
+ *,
173
+ replace: bool = False,
174
+ ):
175
+ """Deprecate a configuration/task class or a parameter.
176
+
177
+ Deprecated configurations maintain backwards compatibility while allowing
178
+ migration to new structures. The identifier is computed from the converted
179
+ configuration, ensuring consistency.
180
+
181
+ **Usage patterns:**
294
182
 
295
- Usage:
183
+ 1. Simple deprecation (class inherits from new class)::
296
184
 
297
185
  @deprecate
298
186
  class OldConfig(NewConfig):
299
187
  pass
300
188
 
301
- # Or only a parameter
302
- class MyConfig():
189
+ 2. Deprecation with conversion::
190
+
191
+ @deprecate(NewConfig)
192
+ class OldConfig(Config):
193
+ value: Param[int]
194
+
195
+ def __convert__(self):
196
+ return NewConfig.C(values=[self.value])
197
+
198
+ 3. Immediate replacement::
199
+
200
+ @deprecate(NewConfig, replace=True)
201
+ class OldConfig(Config):
202
+ value: Param[int]
203
+
204
+ def __convert__(self):
205
+ return NewConfig.C(values=[self.value])
206
+
207
+ 4. Deprecate a parameter::
208
+
209
+ class MyConfig(Config):
210
+ new_param: Param[list[int]]
211
+
303
212
  @deprecate
304
- def oldattribute(self, value):
305
- # Do something with the value
306
- pass
307
- """
308
- if inspect.isclass(config):
309
- config.__getxpmtype__().deprecate()
310
- return config
213
+ def old_param(self, value: int):
214
+ self.new_param = [value]
311
215
 
312
- if inspect.isfunction(config):
216
+ :param config_or_target: Target class for conversion, or the deprecated
217
+ class/method when used as a simple decorator
218
+ :param replace: If True, creating the deprecated class immediately returns
219
+ the converted instance
220
+ """
221
+ # Case 1: @deprecate on a function (deprecated attribute)
222
+ if inspect.isfunction(config_or_target):
313
223
  from experimaestro.core.types import DeprecatedAttribute
314
224
 
315
- return DeprecatedAttribute(config)
225
+ return DeprecatedAttribute(config_or_target)
226
+
227
+ # Case 2: @deprecate (no parens) on a class - legacy pattern
228
+ # The class inherits from its target (NewConfig), not directly from Config
229
+ if config_or_target is not None and inspect.isclass(config_or_target):
230
+ # Check if this looks like a deprecated class (legacy pattern)
231
+ # Legacy pattern: @deprecate class OldConfig(NewConfig) where NewConfig is a Config subclass
232
+ # The deprecated class inherits from exactly one Config subclass (the target)
233
+ # We exclude Config and Task as base classes since those indicate the new pattern
234
+ base_classes_for_new_pattern = (Config, Task)
235
+ if (
236
+ not replace
237
+ and len(config_or_target.__bases__) == 1
238
+ and config_or_target.__bases__[0] not in base_classes_for_new_pattern
239
+ and issubclass(config_or_target.__bases__[0], Config)
240
+ ):
241
+ # This is the legacy pattern: @deprecate on a class
242
+ deprecated_class = config_or_target
243
+ deprecated_class.__getxpmtype__().deprecate()
244
+ return deprecated_class
245
+
246
+ # Otherwise, this is the new pattern: @deprecate(TargetConfig)
247
+ target = config_or_target
316
248
 
317
- raise NotImplementedError("Cannot deprecate %s", config)
249
+ def decorator(deprecated_class: TypingType[Config]):
250
+ deprecated_class.__getxpmtype__().deprecate(target=target, replace=replace)
251
+ return deprecated_class
252
+
253
+ return decorator
254
+
255
+ # Case 3: @deprecate() with parentheses but no arguments (legacy, uses parent class)
256
+ if config_or_target is None:
257
+
258
+ def decorator(deprecated_class: TypingType[Config]):
259
+ deprecated_class.__getxpmtype__().deprecate()
260
+ return deprecated_class
261
+
262
+ return decorator
263
+
264
+ raise NotImplementedError("Cannot deprecate %s" % config_or_target)
318
265
 
319
266
 
320
267
  def deprecateClass(klass):
@@ -337,7 +284,21 @@ def deprecateClass(klass):
337
284
 
338
285
 
339
286
  def initializer(method):
340
- """Defines a method as an initializer that can only be called once"""
287
+ """Decorator for methods that should only execute once.
288
+
289
+ After the first call, subsequent calls return the cached result.
290
+ This is useful for lazy initialization of expensive resources.
291
+
292
+ Example::
293
+
294
+ class MyConfig(Config):
295
+ @initializer
296
+ def model(self):
297
+ return load_expensive_model()
298
+
299
+ :param method: The method to wrap
300
+ :return: A wrapper that caches the result after first execution
301
+ """
341
302
 
342
303
  def wrapper(self, *args, **kwargs):
343
304
  value = method(self, *args, **kwargs)
@@ -1,7 +1,6 @@
1
1
  # flake8: noqa: T201
2
2
  import sys
3
3
  from typing import Set, Optional
4
- import pkg_resources
5
4
  from itertools import chain
6
5
  from shutil import rmtree
7
6
  import click
@@ -10,11 +9,12 @@ from functools import cached_property, update_wrapper
10
9
  from pathlib import Path
11
10
  import subprocess
12
11
  from termcolor import cprint
12
+ from importlib.metadata import entry_points
13
13
 
14
14
  import experimaestro
15
15
  from experimaestro.experiments.cli import experiments_cli
16
16
  import experimaestro.launcherfinder.registry as launcher_registry
17
- from experimaestro.settings import find_workspace
17
+ from experimaestro.settings import ServerSettings, find_workspace
18
18
 
19
19
  # --- Command line main options
20
20
  logging.basicConfig(level=logging.INFO)
@@ -257,13 +257,13 @@ def find_launchers(config: Optional[Path], spec: str):
257
257
  print(launcher_registry.find_launcher(spec))
258
258
 
259
259
 
260
- class Launchers(click.MultiCommand):
261
- """Connectors commands"""
260
+ class Launchers(click.Group):
261
+ """Dynamic command group for entry point discovery"""
262
262
 
263
263
  @cached_property
264
264
  def commands(self):
265
265
  map = {}
266
- for ep in pkg_resources.iter_entry_points(f"experimaestro.{self.name}"):
266
+ for ep in entry_points(group=f"experimaestro.{self.name}"):
267
267
  if get_cli := getattr(ep.load(), "get_cli", None):
268
268
  map[ep.name] = get_cli()
269
269
  return map
@@ -289,6 +289,11 @@ from .jobs import jobs as jobs_cli
289
289
 
290
290
  cli.add_command(jobs_cli)
291
291
 
292
+ # Import and add refactor commands
293
+ from .refactor import refactor as refactor_cli
294
+
295
+ cli.add_command(refactor_cli)
296
+
292
297
 
293
298
  @cli.group()
294
299
  @click.option("--workdir", type=Path, default=None)
@@ -309,3 +314,123 @@ def list(workdir: Path):
309
314
  cprint(f"[unfinished] {p.name}", "yellow")
310
315
  else:
311
316
  cprint(p.name, "cyan")
317
+
318
+
319
+ @experiments.command()
320
+ @click.option("--console", is_flag=True, help="Use console TUI instead of web UI")
321
+ @click.option(
322
+ "--port", type=int, default=12345, help="Port for web server (default: 12345)"
323
+ )
324
+ @click.option(
325
+ "--sync", is_flag=True, help="Force sync from disk before starting monitor"
326
+ )
327
+ @pass_cfg
328
+ def monitor(workdir: Path, console: bool, port: int, sync: bool):
329
+ """Monitor experiments with web UI or console TUI"""
330
+ # Force sync from disk if requested
331
+ if sync:
332
+ from experimaestro.scheduler.state_sync import sync_workspace_from_disk
333
+
334
+ cprint("Syncing workspace from disk...", "yellow")
335
+ sync_workspace_from_disk(workdir, write_mode=True, force=True)
336
+ cprint("Sync complete", "green")
337
+
338
+ if console:
339
+ # Use Textual TUI
340
+ from experimaestro.tui import ExperimentTUI
341
+
342
+ app = ExperimentTUI(workdir, watch=True)
343
+ app.run()
344
+ else:
345
+ # Use React web server
346
+ from experimaestro.scheduler.state_provider import WorkspaceStateProvider
347
+ from experimaestro.server import Server
348
+
349
+ cprint(f"Starting experiment monitor on http://localhost:{port}", "green")
350
+ cprint("Press Ctrl+C to stop", "yellow")
351
+
352
+ state_provider = WorkspaceStateProvider.get_instance(
353
+ workdir,
354
+ sync_on_start=not sync, # Skip auto-sync if we just did a forced one
355
+ )
356
+ settings = ServerSettings()
357
+ settings.port = port
358
+ server = Server.instance(settings, state_provider=state_provider)
359
+ server.start()
360
+
361
+ try:
362
+ import time
363
+
364
+ while True:
365
+ time.sleep(1)
366
+ except KeyboardInterrupt:
367
+ cprint("\nShutting down...", "yellow")
368
+ state_provider.close()
369
+
370
+
371
+ @experiments.command()
372
+ @click.option(
373
+ "--dry-run",
374
+ is_flag=True,
375
+ help="Don't write to database, only show what would be synced",
376
+ )
377
+ @click.option(
378
+ "--force",
379
+ is_flag=True,
380
+ help="Force sync even if recently synced (bypasses time throttling)",
381
+ )
382
+ @click.option(
383
+ "--no-wait",
384
+ is_flag=True,
385
+ help="Don't wait for lock, fail immediately if unavailable",
386
+ )
387
+ @pass_cfg
388
+ def sync(workdir: Path, dry_run: bool, force: bool, no_wait: bool):
389
+ """Synchronize workspace database from disk state
390
+
391
+ Scans experiment directories and job marker files to update the workspace
392
+ database. Uses exclusive locking to prevent conflicts with running experiments.
393
+ """
394
+ from experimaestro.scheduler.state_sync import sync_workspace_from_disk
395
+ from experimaestro.scheduler.workspace import Workspace
396
+ from experimaestro.settings import Settings
397
+
398
+ # Get settings and workspace settings
399
+ settings = Settings.instance()
400
+ ws_settings = find_workspace(workdir=workdir)
401
+
402
+ # Create workspace instance (manages database lifecycle)
403
+ workspace = Workspace(
404
+ settings=settings,
405
+ workspace_settings=ws_settings,
406
+ sync_on_init=False, # Don't sync on init since we're explicitly syncing
407
+ )
408
+
409
+ try:
410
+ # Enter workspace context to initialize database
411
+ with workspace:
412
+ cprint(f"Syncing workspace: {workspace.path}", "cyan")
413
+ if dry_run:
414
+ cprint("DRY RUN MODE: No changes will be written", "yellow")
415
+ if force:
416
+ cprint("FORCE MODE: Bypassing time throttling", "yellow")
417
+
418
+ # Run sync
419
+ sync_workspace_from_disk(
420
+ workspace=workspace,
421
+ write_mode=not dry_run,
422
+ force=force,
423
+ blocking=not no_wait,
424
+ )
425
+
426
+ cprint("Sync completed successfully", "green")
427
+
428
+ except RuntimeError as e:
429
+ cprint(f"Sync failed: {e}", "red")
430
+ sys.exit(1)
431
+ except Exception as e:
432
+ cprint(f"Unexpected error during sync: {e}", "red")
433
+ import traceback
434
+
435
+ traceback.print_exc()
436
+ sys.exit(1)