experimaestro 2.0.0b4__py3-none-any.whl → 2.0.0b17__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 (154) hide show
  1. experimaestro/__init__.py +12 -5
  2. experimaestro/cli/__init__.py +393 -134
  3. experimaestro/cli/filter.py +48 -23
  4. experimaestro/cli/jobs.py +253 -71
  5. experimaestro/cli/refactor.py +1 -2
  6. experimaestro/commandline.py +7 -4
  7. experimaestro/connectors/__init__.py +9 -1
  8. experimaestro/connectors/local.py +43 -3
  9. experimaestro/core/arguments.py +18 -18
  10. experimaestro/core/identifier.py +11 -11
  11. experimaestro/core/objects/config.py +96 -39
  12. experimaestro/core/objects/config_walk.py +3 -3
  13. experimaestro/core/{subparameters.py → partial.py} +16 -16
  14. experimaestro/core/partial_lock.py +394 -0
  15. experimaestro/core/types.py +12 -15
  16. experimaestro/dynamic.py +290 -0
  17. experimaestro/experiments/__init__.py +6 -2
  18. experimaestro/experiments/cli.py +223 -52
  19. experimaestro/experiments/configuration.py +24 -0
  20. experimaestro/generators.py +5 -5
  21. experimaestro/ipc.py +118 -1
  22. experimaestro/launcherfinder/__init__.py +2 -2
  23. experimaestro/launcherfinder/registry.py +6 -7
  24. experimaestro/launcherfinder/specs.py +2 -9
  25. experimaestro/launchers/slurm/__init__.py +2 -2
  26. experimaestro/launchers/slurm/base.py +62 -0
  27. experimaestro/locking.py +957 -1
  28. experimaestro/notifications.py +89 -201
  29. experimaestro/progress.py +63 -366
  30. experimaestro/rpyc.py +0 -2
  31. experimaestro/run.py +29 -2
  32. experimaestro/scheduler/__init__.py +8 -1
  33. experimaestro/scheduler/base.py +650 -53
  34. experimaestro/scheduler/dependencies.py +20 -16
  35. experimaestro/scheduler/experiment.py +764 -169
  36. experimaestro/scheduler/interfaces.py +338 -96
  37. experimaestro/scheduler/jobs.py +58 -20
  38. experimaestro/scheduler/remote/__init__.py +31 -0
  39. experimaestro/scheduler/remote/adaptive_sync.py +265 -0
  40. experimaestro/scheduler/remote/client.py +928 -0
  41. experimaestro/scheduler/remote/protocol.py +282 -0
  42. experimaestro/scheduler/remote/server.py +447 -0
  43. experimaestro/scheduler/remote/sync.py +144 -0
  44. experimaestro/scheduler/services.py +186 -35
  45. experimaestro/scheduler/state_provider.py +811 -2157
  46. experimaestro/scheduler/state_status.py +1247 -0
  47. experimaestro/scheduler/transient.py +31 -0
  48. experimaestro/scheduler/workspace.py +1 -1
  49. experimaestro/scheduler/workspace_state_provider.py +1273 -0
  50. experimaestro/scriptbuilder.py +4 -4
  51. experimaestro/settings.py +36 -0
  52. experimaestro/tests/conftest.py +33 -5
  53. experimaestro/tests/connectors/bin/executable.py +1 -1
  54. experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
  55. experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
  56. experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
  57. experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
  58. experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
  59. experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
  60. experimaestro/tests/launchers/bin/test.py +1 -0
  61. experimaestro/tests/launchers/test_slurm.py +9 -9
  62. experimaestro/tests/partial_reschedule.py +46 -0
  63. experimaestro/tests/restart.py +3 -3
  64. experimaestro/tests/restart_main.py +1 -0
  65. experimaestro/tests/scripts/notifyandwait.py +1 -0
  66. experimaestro/tests/task_partial.py +38 -0
  67. experimaestro/tests/task_tokens.py +2 -2
  68. experimaestro/tests/tasks/test_dynamic.py +6 -6
  69. experimaestro/tests/test_dependencies.py +3 -3
  70. experimaestro/tests/test_deprecated.py +15 -15
  71. experimaestro/tests/test_dynamic_locking.py +317 -0
  72. experimaestro/tests/test_environment.py +24 -14
  73. experimaestro/tests/test_experiment.py +171 -36
  74. experimaestro/tests/test_identifier.py +25 -25
  75. experimaestro/tests/test_identifier_stability.py +3 -5
  76. experimaestro/tests/test_multitoken.py +2 -4
  77. experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
  78. experimaestro/tests/test_partial_paths.py +81 -138
  79. experimaestro/tests/test_pre_experiment.py +219 -0
  80. experimaestro/tests/test_progress.py +2 -8
  81. experimaestro/tests/test_remote_state.py +1132 -0
  82. experimaestro/tests/test_stray_jobs.py +261 -0
  83. experimaestro/tests/test_tasks.py +1 -2
  84. experimaestro/tests/test_token_locking.py +52 -67
  85. experimaestro/tests/test_tokens.py +5 -6
  86. experimaestro/tests/test_transient.py +225 -0
  87. experimaestro/tests/test_workspace_state_provider.py +768 -0
  88. experimaestro/tests/token_reschedule.py +1 -3
  89. experimaestro/tests/utils.py +2 -7
  90. experimaestro/tokens.py +227 -372
  91. experimaestro/tools/diff.py +1 -0
  92. experimaestro/tools/documentation.py +4 -5
  93. experimaestro/tools/jobs.py +1 -2
  94. experimaestro/tui/app.py +459 -1895
  95. experimaestro/tui/app.tcss +162 -0
  96. experimaestro/tui/dialogs.py +172 -0
  97. experimaestro/tui/log_viewer.py +253 -3
  98. experimaestro/tui/messages.py +137 -0
  99. experimaestro/tui/utils.py +54 -0
  100. experimaestro/tui/widgets/__init__.py +23 -0
  101. experimaestro/tui/widgets/experiments.py +468 -0
  102. experimaestro/tui/widgets/global_services.py +238 -0
  103. experimaestro/tui/widgets/jobs.py +972 -0
  104. experimaestro/tui/widgets/log.py +156 -0
  105. experimaestro/tui/widgets/orphans.py +363 -0
  106. experimaestro/tui/widgets/runs.py +185 -0
  107. experimaestro/tui/widgets/services.py +314 -0
  108. experimaestro/tui/widgets/stray_jobs.py +528 -0
  109. experimaestro/utils/__init__.py +1 -1
  110. experimaestro/utils/environment.py +105 -22
  111. experimaestro/utils/fswatcher.py +124 -0
  112. experimaestro/utils/jobs.py +1 -2
  113. experimaestro/utils/jupyter.py +1 -2
  114. experimaestro/utils/logging.py +72 -0
  115. experimaestro/version.py +2 -2
  116. experimaestro/webui/__init__.py +9 -0
  117. experimaestro/webui/app.py +117 -0
  118. experimaestro/{server → webui}/data/index.css +66 -11
  119. experimaestro/webui/data/index.css.map +1 -0
  120. experimaestro/{server → webui}/data/index.js +82763 -87217
  121. experimaestro/webui/data/index.js.map +1 -0
  122. experimaestro/webui/routes/__init__.py +5 -0
  123. experimaestro/webui/routes/auth.py +53 -0
  124. experimaestro/webui/routes/proxy.py +117 -0
  125. experimaestro/webui/server.py +200 -0
  126. experimaestro/webui/state_bridge.py +152 -0
  127. experimaestro/webui/websocket.py +413 -0
  128. {experimaestro-2.0.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +8 -9
  129. experimaestro-2.0.0b17.dist-info/RECORD +219 -0
  130. experimaestro/cli/progress.py +0 -269
  131. experimaestro/scheduler/state.py +0 -75
  132. experimaestro/scheduler/state_db.py +0 -388
  133. experimaestro/scheduler/state_sync.py +0 -834
  134. experimaestro/server/__init__.py +0 -467
  135. experimaestro/server/data/index.css.map +0 -1
  136. experimaestro/server/data/index.js.map +0 -1
  137. experimaestro/tests/test_cli_jobs.py +0 -615
  138. experimaestro/tests/test_file_progress.py +0 -425
  139. experimaestro/tests/test_file_progress_integration.py +0 -477
  140. experimaestro/tests/test_state_db.py +0 -434
  141. experimaestro-2.0.0b4.dist-info/RECORD +0 -181
  142. /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
  143. /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
  144. /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
  145. /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
  146. /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
  147. /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
  148. /experimaestro/{server → webui}/data/favicon.ico +0 -0
  149. /experimaestro/{server → webui}/data/index.html +0 -0
  150. /experimaestro/{server → webui}/data/login.html +0 -0
  151. /experimaestro/{server → webui}/data/manifest.json +0 -0
  152. {experimaestro-2.0.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
  153. {experimaestro-2.0.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
  154. {experimaestro-2.0.0b4.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
@@ -1,9 +1,14 @@
1
1
  import abc
2
2
  from enum import Enum
3
- import functools
4
3
  import logging
5
4
  import threading
6
- from typing import Set
5
+ from pathlib import Path
6
+ from typing import Callable, Optional, Set, TYPE_CHECKING
7
+
8
+ from experimaestro.scheduler.interfaces import BaseService
9
+
10
+ if TYPE_CHECKING:
11
+ from experimaestro.scheduler.experiment import Experiment
7
12
 
8
13
  logger = logging.getLogger(__name__)
9
14
 
@@ -11,7 +16,7 @@ logger = logging.getLogger(__name__)
11
16
  class ServiceListener:
12
17
  """A service listener"""
13
18
 
14
- def service_state_changed(service):
19
+ def service_state_changed(self, service):
15
20
  pass
16
21
 
17
22
 
@@ -28,7 +33,7 @@ class ServiceState(Enum):
28
33
  STOPPING = 3
29
34
 
30
35
 
31
- class Service:
36
+ class Service(BaseService):
32
37
  """An experiment service
33
38
 
34
39
  Services can be associated with an experiment. They send
@@ -46,42 +51,138 @@ class Service:
46
51
  self._listeners: Set[ServiceListener] = set()
47
52
  self._listeners_lock = threading.Lock()
48
53
 
54
+ def set_experiment(self, xp: "Experiment") -> None:
55
+ """Called when the service is added to an experiment.
56
+
57
+ Override this method to access the experiment context (e.g., workdir).
58
+ The default implementation does nothing.
59
+
60
+ Args:
61
+ xp: The experiment this service is being added to.
62
+ """
63
+ pass
64
+
49
65
  def state_dict(self) -> dict:
50
- """Return a dictionary representation for serialization.
66
+ """Return parameters needed to recreate this service.
51
67
 
52
- Subclasses should override this to include any parameters needed
53
- to recreate the service. The base implementation returns the
54
- class module and name.
68
+ Subclasses should override this to return constructor arguments.
69
+ Path values are automatically serialized and restored (with
70
+ translation for remote monitoring).
71
+
72
+ Example::
73
+
74
+ def state_dict(self):
75
+ return {
76
+ "log_dir": self.log_dir, # Path is auto-handled
77
+ "name": self.name,
78
+ }
55
79
 
56
80
  Returns:
57
- Dict with '__class__' key and any additional kwargs.
81
+ Dict with constructor kwargs.
82
+ """
83
+ return {}
84
+
85
+ def full_state_dict(self) -> dict:
86
+ """Serialize service to dictionary for JSON serialization.
87
+
88
+ Overrides BaseService.full_state_dict() to properly serialize Path objects.
58
89
  """
59
90
  return {
60
- "__class__": f"{self.__class__.__module__}.{self.__class__.__name__}",
91
+ "service_id": self.id,
92
+ "description": self.description(),
93
+ "class": f"{self.__class__.__module__}.{self.__class__.__name__}",
94
+ "state_dict": self.serialize_state_dict(self.state_dict()),
61
95
  }
62
96
 
63
97
  @staticmethod
64
- def from_state_dict(data: dict) -> "Service":
98
+ def serialize_state_dict(data: dict) -> dict:
99
+ """Serialize a state_dict, converting Path objects to serializable format.
100
+
101
+ This is called automatically when storing services. Path values are
102
+ converted to {"__path__": "/path/string"} format.
103
+
104
+ Args:
105
+ data: Raw state_dict from service (should include __class__)
106
+
107
+ Returns:
108
+ Serializable dictionary with paths converted
109
+ """
110
+
111
+ def serialize_value(v):
112
+ if isinstance(v, Path):
113
+ return {"__path__": str(v)}
114
+ elif isinstance(v, dict):
115
+ return {k: serialize_value(val) for k, val in v.items()}
116
+ elif isinstance(v, (list, tuple)):
117
+ return [serialize_value(item) for item in v]
118
+ else:
119
+ return v
120
+
121
+ return {k: serialize_value(v) for k, v in data.items()}
122
+
123
+ @staticmethod
124
+ def from_state_dict(
125
+ service_class: str,
126
+ data: dict,
127
+ path_translator: Optional[Callable[[str], Path]] = None,
128
+ ) -> "Service":
65
129
  """Recreate a service from a state dictionary.
66
130
 
67
131
  Args:
68
- data: Dictionary from :meth:`state_dict`
132
+ service_class: Fully qualified class name (e.g., "module.ClassName")
133
+ data: Dictionary from :meth:`state_dict` (may be serialized)
134
+ path_translator: Optional function to translate remote paths to local.
135
+ Used by remote clients to map paths to local cache.
69
136
 
70
137
  Returns:
71
138
  A new Service instance, or raises if the class cannot be loaded.
139
+
140
+ Raises:
141
+ ValueError: If __unserializable__ is True or class cannot be loaded
72
142
  """
73
143
  import importlib
74
144
 
75
- class_path = data.get("__class__")
76
- if not class_path:
77
- raise ValueError("Missing '__class__' in service state_dict")
145
+ # Check if service is marked as unserializable
146
+ if data.get("__unserializable__"):
147
+ raise ValueError(
148
+ f"Service cannot be recreated: {data.get('__reason__', 'unknown reason')}"
149
+ )
150
+
151
+ if not service_class:
152
+ raise ValueError("Missing service_class")
78
153
 
79
- module_name, class_name = class_path.rsplit(".", 1)
154
+ module_name, class_name = service_class.rsplit(".", 1)
80
155
  module = importlib.import_module(module_name)
81
156
  cls = getattr(module, class_name)
82
157
 
83
- # Remove __class__ and pass remaining as kwargs
84
- kwargs = {k: v for k, v in data.items() if k != "__class__"}
158
+ # Build kwargs, detecting and translating paths automatically (handles nested)
159
+ def deserialize_value(v):
160
+ if isinstance(v, dict):
161
+ if "__path__" in v:
162
+ # Serialized path - deserialize with optional translation
163
+ path_str = v["__path__"]
164
+ if path_translator:
165
+ return path_translator(path_str)
166
+ else:
167
+ return Path(path_str)
168
+ else:
169
+ return {
170
+ k: deserialize_value(val)
171
+ for k, val in v.items()
172
+ if not k.startswith("__")
173
+ }
174
+ elif isinstance(v, list):
175
+ return [deserialize_value(item) for item in v]
176
+ else:
177
+ return v
178
+
179
+ kwargs = {}
180
+ for k, v in data.items():
181
+ if k.startswith("__"):
182
+ continue # Skip special keys
183
+ kwargs[k] = deserialize_value(v)
184
+
185
+ logger.debug("Creating %s with kwargs: %s", cls.__name__, kwargs)
85
186
  return cls(**kwargs)
86
187
 
87
188
  def add_listener(self, listener: ServiceListener):
@@ -158,6 +259,8 @@ class WebService(Service):
158
259
  self.url = None
159
260
  self.thread = None
160
261
  self._stop_event = threading.Event()
262
+ self._start_lock = threading.Lock()
263
+ self._running_event: Optional[threading.Event] = None
161
264
 
162
265
  def should_stop(self) -> bool:
163
266
  """Check if the service should stop.
@@ -173,21 +276,46 @@ class WebService(Service):
173
276
  """Get the URL of this web service, starting it if needed.
174
277
 
175
278
  If the service is not running, this method will start it and
176
- block until the URL is available.
279
+ block until the URL is available. If the service is already
280
+ starting or running, returns the existing URL.
177
281
 
178
282
  :return: The URL where this service can be accessed
283
+ :raises RuntimeError: If called while service is stopping
179
284
  """
180
- if self.state == ServiceState.STOPPED:
181
- self._stop_event.clear()
182
- self.state = ServiceState.STARTING
183
- self.running = threading.Event()
184
- self.serve()
285
+ with self._start_lock:
286
+ if self.state == ServiceState.STOPPING:
287
+ raise RuntimeError("Cannot start service while it is stopping")
288
+
289
+ if self.state == ServiceState.RUNNING:
290
+ logger.debug("Service already running, returning existing URL")
291
+ return self.url
292
+
293
+ if self.state == ServiceState.STOPPED:
294
+ logger.info(
295
+ "Starting service %s (id=%s)", self.__class__.__name__, id(self)
296
+ )
297
+ self._stop_event.clear()
298
+ self.state = ServiceState.STARTING
299
+ self._running_event = threading.Event()
300
+ self.serve()
301
+ else:
302
+ logger.info(
303
+ "Service %s (id=%s) already starting, waiting for it",
304
+ self.__class__.__name__,
305
+ id(self),
306
+ )
307
+
308
+ # State is STARTING - wait for it to be ready
309
+ running_event = self._running_event
310
+
311
+ # Wait outside the lock to avoid blocking other callers
312
+ if running_event:
313
+ running_event.wait()
314
+ # Set state to RUNNING (this will notify listeners)
315
+ with self._start_lock:
316
+ if self.state == ServiceState.STARTING:
317
+ self.state = ServiceState.RUNNING
185
318
 
186
- # Wait until the server is ready
187
- self.running.wait()
188
- self.state = ServiceState.RUNNING
189
-
190
- # Returns the URL
191
319
  return self.url
192
320
 
193
321
  def stop(self, timeout: float = 2.0):
@@ -199,10 +327,21 @@ class WebService(Service):
199
327
 
200
328
  :param timeout: Seconds to wait for graceful shutdown before forcing
201
329
  """
202
- if self.state == ServiceState.STOPPED:
203
- return
330
+ with self._start_lock:
331
+ if self.state == ServiceState.STOPPED:
332
+ return
204
333
 
205
- self.state = ServiceState.STOPPING
334
+ if self.state == ServiceState.STARTING:
335
+ # Wait for service to finish starting before stopping
336
+ running_event = self._running_event
337
+ else:
338
+ running_event = None
339
+
340
+ self.state = ServiceState.STOPPING
341
+
342
+ # Wait for starting to complete if needed (outside lock to avoid deadlock)
343
+ if running_event is not None:
344
+ running_event.wait()
206
345
 
207
346
  # Signal the service to stop
208
347
  self._stop_event.set()
@@ -215,8 +354,10 @@ class WebService(Service):
215
354
  if self.thread.is_alive():
216
355
  self._force_stop_thread()
217
356
 
218
- self.url = None
219
- self.state = ServiceState.STOPPED
357
+ with self._start_lock:
358
+ self.url = None
359
+ self._running_event = None
360
+ self.state = ServiceState.STOPPED
220
361
 
221
362
  def _force_stop_thread(self):
222
363
  """Attempt to forcefully stop the service thread.
@@ -254,12 +395,22 @@ class WebService(Service):
254
395
  This method creates a daemon thread that calls :meth:`_serve`.
255
396
  """
256
397
  self.thread = threading.Thread(
257
- target=functools.partial(self._serve, self.running),
398
+ target=self._serve_wrapper,
258
399
  name=f"service[{self.id}]",
259
400
  )
260
401
  self.thread.daemon = True
261
402
  self.thread.start()
262
403
 
404
+ def _serve_wrapper(self):
405
+ """Wrapper for _serve that handles state transitions."""
406
+ running_event = self._running_event
407
+ try:
408
+ self._serve(running_event)
409
+ finally:
410
+ # Ensure the event is set even if _serve fails
411
+ if running_event and not running_event.is_set():
412
+ running_event.set()
413
+
263
414
  @abc.abstractmethod
264
415
  def _serve(self, running: threading.Event):
265
416
  """Start the web server (implement in subclasses).