experimaestro 2.0.0b8__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 (152) hide show
  1. experimaestro/__init__.py +12 -5
  2. experimaestro/cli/__init__.py +239 -126
  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 +217 -50
  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 +629 -53
  34. experimaestro/scheduler/dependencies.py +20 -16
  35. experimaestro/scheduler/experiment.py +732 -167
  36. experimaestro/scheduler/interfaces.py +316 -101
  37. experimaestro/scheduler/jobs.py +58 -20
  38. experimaestro/scheduler/remote/adaptive_sync.py +265 -0
  39. experimaestro/scheduler/remote/client.py +171 -117
  40. experimaestro/scheduler/remote/protocol.py +8 -193
  41. experimaestro/scheduler/remote/server.py +95 -71
  42. experimaestro/scheduler/services.py +53 -28
  43. experimaestro/scheduler/state_provider.py +663 -2430
  44. experimaestro/scheduler/state_status.py +1247 -0
  45. experimaestro/scheduler/transient.py +31 -0
  46. experimaestro/scheduler/workspace.py +1 -1
  47. experimaestro/scheduler/workspace_state_provider.py +1273 -0
  48. experimaestro/scriptbuilder.py +4 -4
  49. experimaestro/settings.py +36 -0
  50. experimaestro/tests/conftest.py +33 -5
  51. experimaestro/tests/connectors/bin/executable.py +1 -1
  52. experimaestro/tests/fixtures/pre_experiment/experiment_check_env.py +16 -0
  53. experimaestro/tests/fixtures/pre_experiment/experiment_check_mock.py +14 -0
  54. experimaestro/tests/fixtures/pre_experiment/experiment_simple.py +12 -0
  55. experimaestro/tests/fixtures/pre_experiment/pre_setup_env.py +5 -0
  56. experimaestro/tests/fixtures/pre_experiment/pre_setup_error.py +3 -0
  57. experimaestro/tests/fixtures/pre_experiment/pre_setup_mock.py +8 -0
  58. experimaestro/tests/launchers/bin/test.py +1 -0
  59. experimaestro/tests/launchers/test_slurm.py +9 -9
  60. experimaestro/tests/partial_reschedule.py +46 -0
  61. experimaestro/tests/restart.py +3 -3
  62. experimaestro/tests/restart_main.py +1 -0
  63. experimaestro/tests/scripts/notifyandwait.py +1 -0
  64. experimaestro/tests/task_partial.py +38 -0
  65. experimaestro/tests/task_tokens.py +2 -2
  66. experimaestro/tests/tasks/test_dynamic.py +6 -6
  67. experimaestro/tests/test_dependencies.py +3 -3
  68. experimaestro/tests/test_deprecated.py +15 -15
  69. experimaestro/tests/test_dynamic_locking.py +317 -0
  70. experimaestro/tests/test_environment.py +24 -14
  71. experimaestro/tests/test_experiment.py +171 -36
  72. experimaestro/tests/test_identifier.py +25 -25
  73. experimaestro/tests/test_identifier_stability.py +3 -5
  74. experimaestro/tests/test_multitoken.py +2 -4
  75. experimaestro/tests/{test_subparameters.py → test_partial.py} +25 -25
  76. experimaestro/tests/test_partial_paths.py +81 -138
  77. experimaestro/tests/test_pre_experiment.py +219 -0
  78. experimaestro/tests/test_progress.py +2 -8
  79. experimaestro/tests/test_remote_state.py +560 -99
  80. experimaestro/tests/test_stray_jobs.py +261 -0
  81. experimaestro/tests/test_tasks.py +1 -2
  82. experimaestro/tests/test_token_locking.py +52 -67
  83. experimaestro/tests/test_tokens.py +5 -6
  84. experimaestro/tests/test_transient.py +225 -0
  85. experimaestro/tests/test_workspace_state_provider.py +768 -0
  86. experimaestro/tests/token_reschedule.py +1 -3
  87. experimaestro/tests/utils.py +2 -7
  88. experimaestro/tokens.py +227 -372
  89. experimaestro/tools/diff.py +1 -0
  90. experimaestro/tools/documentation.py +4 -5
  91. experimaestro/tools/jobs.py +1 -2
  92. experimaestro/tui/app.py +438 -1966
  93. experimaestro/tui/app.tcss +162 -0
  94. experimaestro/tui/dialogs.py +172 -0
  95. experimaestro/tui/log_viewer.py +253 -3
  96. experimaestro/tui/messages.py +137 -0
  97. experimaestro/tui/utils.py +54 -0
  98. experimaestro/tui/widgets/__init__.py +23 -0
  99. experimaestro/tui/widgets/experiments.py +468 -0
  100. experimaestro/tui/widgets/global_services.py +238 -0
  101. experimaestro/tui/widgets/jobs.py +972 -0
  102. experimaestro/tui/widgets/log.py +156 -0
  103. experimaestro/tui/widgets/orphans.py +363 -0
  104. experimaestro/tui/widgets/runs.py +185 -0
  105. experimaestro/tui/widgets/services.py +314 -0
  106. experimaestro/tui/widgets/stray_jobs.py +528 -0
  107. experimaestro/utils/__init__.py +1 -1
  108. experimaestro/utils/environment.py +105 -22
  109. experimaestro/utils/fswatcher.py +124 -0
  110. experimaestro/utils/jobs.py +1 -2
  111. experimaestro/utils/jupyter.py +1 -2
  112. experimaestro/utils/logging.py +72 -0
  113. experimaestro/version.py +2 -2
  114. experimaestro/webui/__init__.py +9 -0
  115. experimaestro/webui/app.py +117 -0
  116. experimaestro/{server → webui}/data/index.css +66 -11
  117. experimaestro/webui/data/index.css.map +1 -0
  118. experimaestro/{server → webui}/data/index.js +82763 -87217
  119. experimaestro/webui/data/index.js.map +1 -0
  120. experimaestro/webui/routes/__init__.py +5 -0
  121. experimaestro/webui/routes/auth.py +53 -0
  122. experimaestro/webui/routes/proxy.py +117 -0
  123. experimaestro/webui/server.py +200 -0
  124. experimaestro/webui/state_bridge.py +152 -0
  125. experimaestro/webui/websocket.py +413 -0
  126. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/METADATA +5 -6
  127. experimaestro-2.0.0b17.dist-info/RECORD +219 -0
  128. experimaestro/cli/progress.py +0 -269
  129. experimaestro/scheduler/state.py +0 -75
  130. experimaestro/scheduler/state_db.py +0 -437
  131. experimaestro/scheduler/state_sync.py +0 -891
  132. experimaestro/server/__init__.py +0 -467
  133. experimaestro/server/data/index.css.map +0 -1
  134. experimaestro/server/data/index.js.map +0 -1
  135. experimaestro/tests/test_cli_jobs.py +0 -615
  136. experimaestro/tests/test_file_progress.py +0 -425
  137. experimaestro/tests/test_file_progress_integration.py +0 -477
  138. experimaestro/tests/test_state_db.py +0 -434
  139. experimaestro-2.0.0b8.dist-info/RECORD +0 -187
  140. /experimaestro/{server → webui}/data/1815e00441357e01619e.ttf +0 -0
  141. /experimaestro/{server → webui}/data/2463b90d9a316e4e5294.woff2 +0 -0
  142. /experimaestro/{server → webui}/data/2582b0e4bcf85eceead0.ttf +0 -0
  143. /experimaestro/{server → webui}/data/89999bdf5d835c012025.woff2 +0 -0
  144. /experimaestro/{server → webui}/data/914997e1bdfc990d0897.ttf +0 -0
  145. /experimaestro/{server → webui}/data/c210719e60948b211a12.woff2 +0 -0
  146. /experimaestro/{server → webui}/data/favicon.ico +0 -0
  147. /experimaestro/{server → webui}/data/index.html +0 -0
  148. /experimaestro/{server → webui}/data/login.html +0 -0
  149. /experimaestro/{server → webui}/data/manifest.json +0 -0
  150. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/WHEEL +0 -0
  151. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/entry_points.txt +0 -0
  152. {experimaestro-2.0.0b8.dist-info → experimaestro-2.0.0b17.dist-info}/licenses/LICENSE +0 -0
@@ -30,10 +30,13 @@ from experimaestro.scheduler.remote.protocol import (
30
30
  create_notification,
31
31
  serialize_datetime,
32
32
  deserialize_datetime,
33
- serialize_job,
34
- serialize_experiment,
35
- serialize_run,
36
33
  )
34
+ from experimaestro.scheduler.state_provider import (
35
+ MockJob,
36
+ MockExperiment,
37
+ MockService,
38
+ )
39
+ from experimaestro.notifications import LevelInformation
37
40
 
38
41
 
39
42
  # =============================================================================
@@ -111,13 +114,17 @@ class TestProtocolMessages:
111
114
  def test_notification_creation(self):
112
115
  """Test creating a JSON-RPC notification"""
113
116
  notif_json = create_notification(
114
- NotificationMethod.JOB_UPDATED, {"job_id": "job1", "state": "running"}
117
+ NotificationMethod.STATE_EVENT,
118
+ {"event_type": "JobStateChangedEvent", "data": {"job_id": "job1"}},
115
119
  )
116
120
  data = json.loads(notif_json)
117
121
 
118
122
  assert data["jsonrpc"] == JSONRPC_VERSION
119
- assert data["method"] == "notification.job_updated"
120
- assert data["params"] == {"job_id": "job1", "state": "running"}
123
+ assert data["method"] == "notification.state_event"
124
+ assert data["params"] == {
125
+ "event_type": "JobStateChangedEvent",
126
+ "data": {"job_id": "job1"},
127
+ }
121
128
  assert "id" not in data
122
129
 
123
130
  def test_notification_parsing(self):
@@ -187,90 +194,231 @@ class TestDatetimeSerialization:
187
194
 
188
195
 
189
196
  class TestJobSerialization:
190
- """Test job serialization"""
197
+ """Test job serialization using state_dict"""
191
198
 
192
199
  def test_serialize_mock_job(self):
193
- """Test serializing a MockJob-like object"""
194
- from experimaestro.scheduler.state_provider import MockJob
195
-
200
+ """Test serializing a MockJob using state_dict()"""
201
+ # Note: tags, experiment_id, run_id are not part of MockJob
202
+ # as they are experiment-specific
196
203
  job = MockJob(
197
204
  identifier="job123",
198
205
  task_id="task.MyTask",
199
- locator="job123",
200
206
  path=Path("/tmp/jobs/job123"),
201
207
  state="running",
202
208
  submittime=1704067200.0,
203
209
  starttime=1704067300.0,
204
210
  endtime=None,
205
211
  progress=[],
206
- tags={"tag1": "value1"},
207
- experiment_id="exp1",
208
- run_id="run1",
209
212
  updated_at="2024-01-01T00:00:00",
210
213
  )
211
214
 
212
- result = serialize_job(job)
215
+ result = job.state_dict()
213
216
 
214
- assert result["identifier"] == "job123"
217
+ assert result["job_id"] == "job123"
215
218
  assert result["task_id"] == "task.MyTask"
216
219
  assert result["path"] == "/tmp/jobs/job123"
217
220
  # State is serialized from JobState enum - case may vary
218
221
  assert result["state"].upper() == "RUNNING"
219
- assert result["tags"] == {"tag1": "value1"}
220
- assert result["experiment_id"] == "exp1"
221
- assert result["run_id"] == "run1"
222
222
 
223
223
 
224
224
  class TestExperimentSerialization:
225
- """Test experiment serialization"""
225
+ """Test experiment serialization using state_dict"""
226
226
 
227
227
  def test_serialize_mock_experiment(self):
228
- """Test serializing a MockExperiment-like object"""
229
- from experimaestro.scheduler.state_provider import MockExperiment
228
+ """Test serializing a MockExperiment using state_dict()"""
229
+ from experimaestro.scheduler.interfaces import ExperimentStatus
230
230
 
231
+ # New layout: experiments/{experiment_id}/{run_id}
231
232
  exp = MockExperiment(
232
- workdir=Path("/tmp/xp/myexp"),
233
- current_run_id="run_20240101",
234
- total_jobs=10,
235
- finished_jobs=5,
236
- failed_jobs=1,
237
- updated_at="2024-01-01T12:00:00",
233
+ workdir=Path("/tmp/experiments/myexp/run_20240101"),
234
+ run_id="run_20240101",
235
+ status=ExperimentStatus.RUNNING,
238
236
  started_at=1704067200.0,
239
237
  ended_at=None,
240
238
  hostname="server1",
241
239
  )
242
240
 
243
- result = serialize_experiment(exp)
241
+ result = exp.state_dict()
244
242
 
245
243
  assert result["experiment_id"] == "myexp"
246
- assert result["workdir"] == "/tmp/xp/myexp"
247
- assert result["current_run_id"] == "run_20240101"
248
- assert result["total_jobs"] == 10
249
- assert result["finished_jobs"] == 5
250
- assert result["failed_jobs"] == 1
251
- assert result["hostname"] == "server1"
252
-
253
-
254
- class TestRunSerialization:
255
- """Test run serialization"""
256
-
257
- def test_serialize_run_dict(self):
258
- """Test serializing a run dictionary"""
259
- run_dict = {
260
- "run_id": "run_20240101",
261
- "experiment_id": "exp1",
262
- "hostname": "server1",
263
- "started_at": "2024-01-01T10:00:00",
264
- "ended_at": None,
265
- "status": "active",
266
- }
244
+ assert result["run_id"] == "run_20240101"
245
+ assert result["status"] == "running"
267
246
 
268
- result = serialize_run(run_dict)
269
247
 
270
- assert result["run_id"] == "run_20240101"
271
- assert result["experiment_id"] == "exp1"
272
- assert result["hostname"] == "server1"
273
- assert result["status"] == "active"
248
+ class TestServiceSerialization:
249
+ """Test service serialization using state_dict"""
250
+
251
+ def test_serialize_mock_service(self):
252
+ """Test serializing a MockService using full_state_dict()
253
+
254
+ MockService.full_state_dict() preserves the original service class name
255
+ (not MockService's class name) to enable proper round-trip serialization.
256
+ """
257
+ service = MockService(
258
+ service_id="svc123",
259
+ description_text="Test service",
260
+ state_dict_data={"port": 8080},
261
+ service_class="mymodule.MyService",
262
+ experiment_id="exp1",
263
+ run_id="run1",
264
+ url="http://localhost:8080",
265
+ )
266
+
267
+ result = service.full_state_dict()
268
+
269
+ assert result["service_id"] == "svc123"
270
+ assert result["description"] == "Test service"
271
+ # Preserves original service class, not MockService's class name
272
+ assert result["class"] == "mymodule.MyService"
273
+ assert result["state_dict"] == {"port": 8080}
274
+
275
+ def test_serialize_mock_service_no_class(self):
276
+ """Test serializing a MockService with service_class=None"""
277
+ service = MockService(
278
+ service_id="svc123",
279
+ description_text="Test service",
280
+ state_dict_data={"port": 8080},
281
+ service_class=None,
282
+ )
283
+
284
+ result = service.full_state_dict()
285
+
286
+ # class is always present, even when None
287
+ assert result["class"] is None
288
+ assert result["service_id"] == "svc123"
289
+ assert result["description"] == "Test service"
290
+ assert result["state_dict"] == {"port": 8080}
291
+
292
+
293
+ # =============================================================================
294
+ # SSH Round-Trip Tests (Mock → full_state_dict → client → Mock)
295
+ # =============================================================================
296
+
297
+
298
+ class MockSSHClient:
299
+ """Minimal mock of SSHStateProviderClient for testing deserialization"""
300
+
301
+ def __init__(self, remote_workspace: str, local_cache_dir: Path):
302
+ self.remote_workspace = remote_workspace
303
+ self.local_cache_dir = local_cache_dir
304
+
305
+ def _parse_datetime_to_timestamp(self, value) -> float | None:
306
+ """Convert datetime value to Unix timestamp"""
307
+ if value is None:
308
+ return None
309
+ if isinstance(value, (int, float)):
310
+ return float(value)
311
+ if isinstance(value, str):
312
+ try:
313
+ dt = datetime.fromisoformat(value)
314
+ return dt.timestamp()
315
+ except ValueError:
316
+ return None
317
+ if isinstance(value, datetime):
318
+ return value.timestamp()
319
+ return None
320
+
321
+
322
+ class TestSSHRoundTrip:
323
+ """Test SSH round-trip serialization: Mock → state_dict → from_state_dict"""
324
+
325
+ def test_mockjob_ssh_roundtrip(self, tmp_path: Path):
326
+ """Test MockJob round-trip through SSH serialization path"""
327
+ from experimaestro.scheduler.remote.client import SSHStateProviderClient
328
+
329
+ workspace_path = tmp_path / "workspace"
330
+ workspace_path.mkdir()
331
+ job_path = workspace_path / "jobs" / "my.Task" / "abc123"
332
+
333
+ # Create original MockJob
334
+ # Note: tags, experiment_id, run_id are not part of MockJob
335
+ original = MockJob(
336
+ identifier="abc123",
337
+ task_id="my.Task",
338
+ path=job_path,
339
+ state="running",
340
+ submittime=1234567890.0,
341
+ starttime=1234567891.0,
342
+ endtime=None,
343
+ progress=[LevelInformation(level=0, progress=0.5, desc="halfway")],
344
+ updated_at="2024-01-01T00:00:00",
345
+ exit_code=None,
346
+ retry_count=2,
347
+ )
348
+
349
+ # Server-side: serialize using state_dict
350
+ serialized = original.state_dict()
351
+
352
+ # Client-side: deserialize using _dict_to_job (which uses from_state_dict)
353
+ mock_client = MockSSHClient(
354
+ remote_workspace=str(workspace_path),
355
+ local_cache_dir=workspace_path,
356
+ )
357
+ restored = SSHStateProviderClient._dict_to_job(mock_client, serialized)
358
+
359
+ # Verify equality by comparing state_dict outputs
360
+ assert original.state_dict() == restored.state_dict()
361
+
362
+ def test_mockexperiment_ssh_roundtrip(self, tmp_path: Path):
363
+ """Test MockExperiment round-trip through SSH serialization path"""
364
+ from experimaestro.scheduler.interfaces import ExperimentStatus
365
+ from experimaestro.scheduler.remote.client import SSHStateProviderClient
366
+
367
+ workspace_path = tmp_path / "workspace"
368
+ workspace_path.mkdir()
369
+ # New layout: experiments/{exp-id}/{run-id}/
370
+ (workspace_path / "experiments" / "test_exp" / "run_001").mkdir(parents=True)
371
+
372
+ # Create original MockExperiment
373
+ original = MockExperiment(
374
+ workdir=workspace_path / "experiments" / "test_exp" / "run_001",
375
+ run_id="run_001",
376
+ status=ExperimentStatus.RUNNING,
377
+ started_at=1234567890.0,
378
+ ended_at=None,
379
+ hostname="testhost",
380
+ )
381
+
382
+ # Server-side: serialize using state_dict
383
+ serialized = original.state_dict()
384
+
385
+ # Client-side: deserialize using _dict_to_experiment
386
+ mock_client = MockSSHClient(
387
+ remote_workspace=str(workspace_path),
388
+ local_cache_dir=workspace_path,
389
+ )
390
+ restored = SSHStateProviderClient._dict_to_experiment(mock_client, serialized)
391
+
392
+ # Verify equality by comparing state_dict outputs
393
+ assert original.state_dict() == restored.state_dict()
394
+
395
+ def test_mockservice_ssh_roundtrip(self, tmp_path: Path):
396
+ """Test MockService round-trip through SSH serialization path"""
397
+ from experimaestro.scheduler.remote.client import SSHStateProviderClient
398
+
399
+ # Create original MockService (no service_class to avoid Service recreation)
400
+ original = MockService(
401
+ service_id="svc_123",
402
+ description_text="Test service description",
403
+ state_dict_data={"port": 8080, "host": "localhost"},
404
+ service_class=None,
405
+ experiment_id="exp1",
406
+ run_id="run1",
407
+ url="http://localhost:8080",
408
+ )
409
+
410
+ # Server-side: serialize using full_state_dict
411
+ serialized = original.full_state_dict()
412
+
413
+ # Client-side: deserialize using _dict_to_service
414
+ mock_client = MockSSHClient(
415
+ remote_workspace="/tmp/workspace",
416
+ local_cache_dir=tmp_path,
417
+ )
418
+ restored = SSHStateProviderClient._dict_to_service(mock_client, serialized)
419
+
420
+ # Verify equality by comparing full_state_dict outputs
421
+ assert original.full_state_dict() == restored.full_state_dict()
274
422
 
275
423
 
276
424
  # =============================================================================
@@ -312,15 +460,13 @@ class TestServerRequestHandling:
312
460
 
313
461
  def test_handle_get_experiments(self, server_with_mock, mock_state_provider):
314
462
  """Test handling get_experiments request"""
315
- from experimaestro.scheduler.state_provider import MockExperiment
463
+ from experimaestro.scheduler.interfaces import ExperimentStatus
316
464
 
465
+ # New layout: experiments/{exp-id}/{run-id}
317
466
  mock_exp = MockExperiment(
318
- workdir=Path("/tmp/xp/exp1"),
319
- current_run_id="run1",
320
- total_jobs=5,
321
- finished_jobs=3,
322
- failed_jobs=0,
323
- updated_at="2024-01-01T00:00:00",
467
+ workdir=Path("/tmp/experiments/exp1/run1"),
468
+ run_id="run1",
469
+ status=ExperimentStatus.RUNNING,
324
470
  )
325
471
  mock_state_provider.get_experiments.return_value = [mock_exp]
326
472
 
@@ -330,23 +476,67 @@ class TestServerRequestHandling:
330
476
  assert result[0]["experiment_id"] == "exp1"
331
477
  mock_state_provider.get_experiments.assert_called_once_with(since=None)
332
478
 
479
+ def test_handle_get_experiment(self, server_with_mock, mock_state_provider):
480
+ """Test handling get_experiment request"""
481
+ from experimaestro.scheduler.interfaces import ExperimentStatus
482
+
483
+ # New layout: experiments/{exp-id}/{run-id}
484
+ mock_exp = MockExperiment(
485
+ workdir=Path("/tmp/experiments/exp1/run1"),
486
+ run_id="run1",
487
+ status=ExperimentStatus.RUNNING,
488
+ )
489
+ mock_state_provider.get_experiment.return_value = mock_exp
490
+
491
+ result = server_with_mock._handle_get_experiment({"experiment_id": "exp1"})
492
+
493
+ assert result["experiment_id"] == "exp1"
494
+ assert result["run_id"] == "run1"
495
+ mock_state_provider.get_experiment.assert_called_once_with("exp1")
496
+
497
+ def test_handle_get_experiment_not_found(
498
+ self, server_with_mock, mock_state_provider
499
+ ):
500
+ """Test handling get_experiment when experiment not found"""
501
+ mock_state_provider.get_experiment.return_value = None
502
+
503
+ result = server_with_mock._handle_get_experiment(
504
+ {"experiment_id": "nonexistent"}
505
+ )
506
+
507
+ assert result is None
508
+
509
+ def test_handle_get_experiment_runs(self, server_with_mock, mock_state_provider):
510
+ """Test handling get_experiment_runs request"""
511
+ from experimaestro.scheduler.interfaces import ExperimentStatus
512
+
513
+ mock_run = MockExperiment(
514
+ workdir=Path("/tmp/experiments/exp1/run1"),
515
+ run_id="run1",
516
+ status=ExperimentStatus.DONE,
517
+ hostname="server1",
518
+ started_at=1704067200.0,
519
+ ended_at=1704070800.0,
520
+ )
521
+ mock_state_provider.get_experiment_runs.return_value = [mock_run]
522
+
523
+ result = server_with_mock._handle_get_experiment_runs({"experiment_id": "exp1"})
524
+
525
+ assert len(result) == 1
526
+ assert result[0]["run_id"] == "run1"
527
+ assert result[0]["status"] == "done"
528
+
333
529
  def test_handle_get_jobs(self, server_with_mock, mock_state_provider):
334
530
  """Test handling get_jobs request"""
335
- from experimaestro.scheduler.state_provider import MockJob
336
-
337
531
  mock_job = MockJob(
338
532
  identifier="job1",
339
533
  task_id="task.Test",
340
- locator="job1",
341
534
  path=Path("/tmp/jobs/job1"),
342
535
  state="done",
343
536
  submittime=None,
344
537
  starttime=None,
345
538
  endtime=None,
346
539
  progress=[],
347
- tags={},
348
- experiment_id="exp1",
349
- run_id="run1",
350
540
  updated_at="",
351
541
  )
352
542
  mock_state_provider.get_jobs.return_value = [mock_job]
@@ -359,7 +549,196 @@ class TestServerRequestHandling:
359
549
  )
360
550
 
361
551
  assert len(result) == 1
362
- assert result[0]["identifier"] == "job1"
552
+ assert result[0]["job_id"] == "job1"
553
+
554
+ def test_handle_get_job(self, server_with_mock, mock_state_provider):
555
+ """Test handling get_job request"""
556
+ mock_job = MockJob(
557
+ identifier="job1",
558
+ task_id="task.Test",
559
+ path=Path("/tmp/jobs/job1"),
560
+ state="running",
561
+ submittime=1704067200.0,
562
+ starttime=1704067300.0,
563
+ endtime=None,
564
+ progress=[],
565
+ updated_at="2024-01-01T00:00:00",
566
+ )
567
+ mock_state_provider.get_job.return_value = mock_job
568
+
569
+ result = server_with_mock._handle_get_job(
570
+ {"job_id": "job1", "experiment_id": "exp1", "run_id": "run1"}
571
+ )
572
+
573
+ assert result["job_id"] == "job1"
574
+ assert result["task_id"] == "task.Test"
575
+
576
+ def test_handle_get_job_not_found(self, server_with_mock, mock_state_provider):
577
+ """Test handling get_job when job not found"""
578
+ mock_state_provider.get_job.return_value = None
579
+
580
+ result = server_with_mock._handle_get_job(
581
+ {"job_id": "nonexistent", "experiment_id": "exp1", "run_id": "run1"}
582
+ )
583
+
584
+ assert result is None
585
+
586
+ def test_handle_get_all_jobs(self, server_with_mock, mock_state_provider):
587
+ """Test handling get_all_jobs request"""
588
+ mock_job1 = MockJob(
589
+ identifier="job1",
590
+ task_id="task.Test1",
591
+ path=Path("/tmp/jobs/job1"),
592
+ state="done",
593
+ submittime=None,
594
+ starttime=None,
595
+ endtime=None,
596
+ progress=[],
597
+ updated_at="",
598
+ )
599
+ mock_job2 = MockJob(
600
+ identifier="job2",
601
+ task_id="task.Test2",
602
+ path=Path("/tmp/jobs/job2"),
603
+ state="running",
604
+ submittime=None,
605
+ starttime=None,
606
+ endtime=None,
607
+ progress=[],
608
+ updated_at="",
609
+ )
610
+ mock_state_provider.get_all_jobs.return_value = [mock_job1, mock_job2]
611
+
612
+ result = server_with_mock._handle_get_all_jobs({"state": None, "tags": None})
613
+
614
+ assert len(result) == 2
615
+ assert result[0]["job_id"] == "job1"
616
+ assert result[1]["job_id"] == "job2"
617
+
618
+ def test_handle_get_services(self, server_with_mock, mock_state_provider):
619
+ """Test handling get_services request"""
620
+ mock_service = MockService(
621
+ service_id="svc1",
622
+ description_text="Test service",
623
+ state_dict_data={"port": 8080},
624
+ service_class="mymodule.MyService",
625
+ experiment_id="exp1",
626
+ run_id="run1",
627
+ url="http://localhost:8080",
628
+ )
629
+ mock_state_provider.get_services.return_value = [mock_service]
630
+
631
+ result = server_with_mock._handle_get_services(
632
+ {"experiment_id": "exp1", "run_id": "run1"}
633
+ )
634
+
635
+ assert len(result) == 1
636
+ assert result[0]["service_id"] == "svc1"
637
+ assert result[0]["description"] == "Test service"
638
+
639
+ def test_handle_get_tags_map(self, server_with_mock, mock_state_provider):
640
+ """Test handling get_tags_map request"""
641
+ mock_state_provider.get_tags_map.return_value = {
642
+ "job1": {"model": "bert", "dataset": "squad"},
643
+ "job2": {"model": "gpt"},
644
+ }
645
+
646
+ result = server_with_mock._handle_get_tags_map(
647
+ {"experiment_id": "exp1", "run_id": "run1"}
648
+ )
649
+
650
+ assert result["job1"]["model"] == "bert"
651
+ assert result["job2"]["model"] == "gpt"
652
+
653
+ def test_handle_get_tags_map_missing_experiment(self, server_with_mock):
654
+ """Test get_tags_map raises TypeError when experiment_id missing"""
655
+ with pytest.raises(TypeError, match="experiment_id is required"):
656
+ server_with_mock._handle_get_tags_map({"run_id": "run1"})
657
+
658
+ def test_handle_get_dependencies_map(self, server_with_mock, mock_state_provider):
659
+ """Test handling get_dependencies_map request"""
660
+ mock_state_provider.get_dependencies_map.return_value = {
661
+ "job2": ["job1"],
662
+ "job3": ["job1", "job2"],
663
+ }
664
+
665
+ result = server_with_mock._handle_get_dependencies_map(
666
+ {"experiment_id": "exp1", "run_id": "run1"}
667
+ )
668
+
669
+ assert result["job2"] == ["job1"]
670
+ assert result["job3"] == ["job1", "job2"]
671
+
672
+ def test_handle_get_dependencies_map_missing_experiment(self, server_with_mock):
673
+ """Test get_dependencies_map raises TypeError when experiment_id missing"""
674
+ with pytest.raises(TypeError, match="experiment_id is required"):
675
+ server_with_mock._handle_get_dependencies_map({"run_id": "run1"})
676
+
677
+ def test_handle_kill_job(self, server_with_mock, mock_state_provider):
678
+ """Test handling kill_job request"""
679
+ mock_job = MockJob(
680
+ identifier="job1",
681
+ task_id="task.Test",
682
+ path=Path("/tmp/jobs/job1"),
683
+ state="running",
684
+ submittime=None,
685
+ starttime=None,
686
+ endtime=None,
687
+ progress=[],
688
+ updated_at="",
689
+ )
690
+ mock_state_provider.get_job.return_value = mock_job
691
+ mock_state_provider.kill_job.return_value = True
692
+
693
+ result = server_with_mock._handle_kill_job(
694
+ {"job_id": "job1", "experiment_id": "exp1", "run_id": "run1"}
695
+ )
696
+
697
+ assert result["success"] is True
698
+
699
+ def test_handle_kill_job_not_found(self, server_with_mock, mock_state_provider):
700
+ """Test handling kill_job when job not found"""
701
+ mock_state_provider.get_job.return_value = None
702
+
703
+ result = server_with_mock._handle_kill_job(
704
+ {"job_id": "nonexistent", "experiment_id": "exp1", "run_id": "run1"}
705
+ )
706
+
707
+ assert result["success"] is False
708
+ assert "error" in result
709
+
710
+ def test_handle_clean_job(self, server_with_mock, mock_state_provider):
711
+ """Test handling clean_job request"""
712
+ mock_job = MockJob(
713
+ identifier="job1",
714
+ task_id="task.Test",
715
+ path=Path("/tmp/jobs/job1"),
716
+ state="done",
717
+ submittime=None,
718
+ starttime=None,
719
+ endtime=None,
720
+ progress=[],
721
+ updated_at="",
722
+ )
723
+ mock_state_provider.get_job.return_value = mock_job
724
+ mock_state_provider.clean_job.return_value = True
725
+
726
+ result = server_with_mock._handle_clean_job(
727
+ {"job_id": "job1", "experiment_id": "exp1", "run_id": "run1"}
728
+ )
729
+
730
+ assert result["success"] is True
731
+
732
+ def test_handle_clean_job_not_found(self, server_with_mock, mock_state_provider):
733
+ """Test handling clean_job when job not found"""
734
+ mock_state_provider.get_job.return_value = None
735
+
736
+ result = server_with_mock._handle_clean_job(
737
+ {"job_id": "nonexistent", "experiment_id": "exp1", "run_id": "run1"}
738
+ )
739
+
740
+ assert result["success"] is False
741
+ assert "error" in result
363
742
 
364
743
  def test_handle_get_sync_info(self, server_with_mock):
365
744
  """Test handling get_sync_info request"""
@@ -412,22 +791,112 @@ class TestClientServerIntegration:
412
791
  assert isinstance(resp_msg, RPCResponse)
413
792
  assert resp_msg.result == []
414
793
 
415
- def test_notification_handling(self):
416
- """Test notification message handling"""
794
+ def test_notification_job_updated(self):
795
+ """Test job_updated notification message handling via STATE_EVENT"""
417
796
  notification = create_notification(
418
- NotificationMethod.JOB_UPDATED,
797
+ NotificationMethod.STATE_EVENT,
419
798
  {
420
- "job_id": "job1",
421
- "experiment_id": "exp1",
422
- "run_id": "run1",
423
- "state": "running",
799
+ "event_type": "JobStateChangedEvent",
800
+ "data": {
801
+ "job_id": "job1",
802
+ "experiment_id": "exp1",
803
+ "run_id": "run1",
804
+ },
424
805
  },
425
806
  )
426
807
 
427
808
  msg = parse_message(notification)
428
809
  assert isinstance(msg, RPCNotification)
429
- assert msg.method == "notification.job_updated"
430
- assert msg.params["job_id"] == "job1"
810
+ assert msg.method == "notification.state_event"
811
+ assert msg.params["event_type"] == "JobStateChangedEvent"
812
+ assert msg.params["data"]["job_id"] == "job1"
813
+
814
+ def test_notification_experiment_updated(self):
815
+ """Test experiment_updated notification message handling via STATE_EVENT"""
816
+ notification = create_notification(
817
+ NotificationMethod.STATE_EVENT,
818
+ {
819
+ "event_type": "ExperimentUpdatedEvent",
820
+ "data": {
821
+ "experiment_id": "exp1",
822
+ },
823
+ },
824
+ )
825
+
826
+ msg = parse_message(notification)
827
+ assert isinstance(msg, RPCNotification)
828
+ assert msg.method == "notification.state_event"
829
+ assert msg.params["event_type"] == "ExperimentUpdatedEvent"
830
+ assert msg.params["data"]["experiment_id"] == "exp1"
831
+
832
+ def test_notification_run_updated(self):
833
+ """Test run_updated notification message handling via STATE_EVENT"""
834
+ notification = create_notification(
835
+ NotificationMethod.STATE_EVENT,
836
+ {
837
+ "event_type": "RunUpdatedEvent",
838
+ "data": {
839
+ "experiment_id": "exp1",
840
+ "run_id": "run1",
841
+ },
842
+ },
843
+ )
844
+
845
+ msg = parse_message(notification)
846
+ assert isinstance(msg, RPCNotification)
847
+ assert msg.method == "notification.state_event"
848
+ assert msg.params["event_type"] == "RunUpdatedEvent"
849
+ assert msg.params["data"]["run_id"] == "run1"
850
+
851
+ def test_notification_service_updated(self):
852
+ """Test service_updated notification message handling via STATE_EVENT"""
853
+ notification = create_notification(
854
+ NotificationMethod.STATE_EVENT,
855
+ {
856
+ "event_type": "ServiceAddedEvent",
857
+ "data": {
858
+ "experiment_id": "exp1",
859
+ "run_id": "run1",
860
+ "service_id": "svc1",
861
+ },
862
+ },
863
+ )
864
+
865
+ msg = parse_message(notification)
866
+ assert isinstance(msg, RPCNotification)
867
+ assert msg.method == "notification.state_event"
868
+ assert msg.params["event_type"] == "ServiceAddedEvent"
869
+ assert msg.params["data"]["service_id"] == "svc1"
870
+
871
+ def test_notification_file_changed(self):
872
+ """Test file_changed notification message handling"""
873
+ notification = create_notification(
874
+ NotificationMethod.FILE_CHANGED,
875
+ {
876
+ "path": "/workspace/logs/job.log",
877
+ "event_type": "modified",
878
+ },
879
+ )
880
+
881
+ msg = parse_message(notification)
882
+ assert isinstance(msg, RPCNotification)
883
+ assert msg.method == "notification.file_changed"
884
+ assert msg.params["path"] == "/workspace/logs/job.log"
885
+
886
+ def test_notification_shutdown(self):
887
+ """Test shutdown notification message handling"""
888
+ notification = create_notification(
889
+ NotificationMethod.SHUTDOWN,
890
+ {
891
+ "reason": "server_stop",
892
+ "code": 0,
893
+ },
894
+ )
895
+
896
+ msg = parse_message(notification)
897
+ assert isinstance(msg, RPCNotification)
898
+ assert msg.method == "notification.shutdown"
899
+ assert msg.params["reason"] == "server_stop"
431
900
 
432
901
 
433
902
  # =============================================================================
@@ -456,19 +925,17 @@ class TestClientDataConversion:
456
925
 
457
926
  def test_dict_to_job(self, client, tmp_path):
458
927
  """Test converting dictionary to MockJob"""
928
+ # Note: tags, experiment_id, run_id are not part of MockJob
929
+ # as they are experiment-specific
459
930
  job_dict = {
460
- "identifier": "job123",
931
+ "job_id": "job123",
461
932
  "task_id": "task.MyTask",
462
- "locator": "job123",
463
933
  "path": "/remote/workspace/jobs/job123",
464
934
  "state": "running",
465
- "submittime": "2024-01-01T10:00:00",
466
- "starttime": "2024-01-01T10:01:00",
467
- "endtime": None,
935
+ "submitted_time": "2024-01-01T10:00:00",
936
+ "started_time": "2024-01-01T10:01:00",
937
+ "ended_time": None,
468
938
  "progress": [],
469
- "tags": {"key": "value"},
470
- "experiment_id": "exp1",
471
- "run_id": "run1",
472
939
  }
473
940
 
474
941
  job = client._dict_to_job(job_dict)
@@ -477,18 +944,15 @@ class TestClientDataConversion:
477
944
  assert job.task_id == "task.MyTask"
478
945
  # Path should be mapped to local cache
479
946
  assert job.path == tmp_path / "jobs/job123"
480
- assert job.tags == {"key": "value"}
481
947
 
482
948
  def test_dict_to_experiment(self, client, tmp_path):
483
949
  """Test converting dictionary to MockExperiment"""
950
+ # New layout: experiments/{experiment_id}/{run_id}
484
951
  exp_dict = {
485
952
  "experiment_id": "myexp",
486
- "workdir": "/remote/workspace/xp/myexp",
487
- "current_run_id": "run1",
488
- "total_jobs": 10,
489
- "finished_jobs": 5,
490
- "failed_jobs": 1,
491
- "updated_at": "2024-01-01T12:00:00",
953
+ "workdir": "/remote/workspace/experiments/myexp/run1",
954
+ "run_id": "run1",
955
+ "status": "running",
492
956
  "hostname": "server1",
493
957
  }
494
958
 
@@ -496,20 +960,17 @@ class TestClientDataConversion:
496
960
 
497
961
  assert exp.experiment_id == "myexp"
498
962
  # Path should be mapped to local cache
499
- assert exp.workdir == tmp_path / "xp/myexp"
500
- assert exp.total_jobs == 10
963
+ assert exp.workdir == tmp_path / "experiments/myexp/run1"
501
964
  assert exp.hostname == "server1"
502
965
 
503
- def test_path_mapping_outside_workspace(self, client, tmp_path):
966
+ def test_path_mapping_outside_workspace(self, client):
504
967
  """Test path mapping for paths outside remote workspace"""
505
968
  job_dict = {
506
- "identifier": "job123",
969
+ "job_id": "job123",
507
970
  "task_id": "task.MyTask",
508
- "locator": "job123",
509
971
  "path": "/other/path/job123", # Not under remote_workspace
510
972
  "state": "done",
511
973
  "progress": [],
512
- "tags": {},
513
974
  }
514
975
 
515
976
  job = client._dict_to_job(job_dict)