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
@@ -0,0 +1,1132 @@
1
+ """Tests for SSH-based remote state provider
2
+
3
+ Tests cover:
4
+ - Protocol serialization/deserialization
5
+ - Server request handling
6
+ - Client-server communication (using pipes instead of SSH)
7
+ - File synchronization logic
8
+ """
9
+
10
+ import io
11
+ import json
12
+ from datetime import datetime
13
+ from pathlib import Path
14
+ from unittest.mock import MagicMock, patch
15
+
16
+ import pytest
17
+
18
+ from experimaestro.scheduler.remote.protocol import (
19
+ JSONRPC_VERSION,
20
+ RPCMethod,
21
+ NotificationMethod,
22
+ RPCRequest,
23
+ RPCResponse,
24
+ RPCNotification,
25
+ RPCError,
26
+ parse_message,
27
+ create_request,
28
+ create_success_response,
29
+ create_error_response,
30
+ create_notification,
31
+ serialize_datetime,
32
+ deserialize_datetime,
33
+ )
34
+ from experimaestro.scheduler.state_provider import (
35
+ MockJob,
36
+ MockExperiment,
37
+ MockService,
38
+ )
39
+ from experimaestro.notifications import LevelInformation
40
+
41
+
42
+ # =============================================================================
43
+ # Protocol Tests
44
+ # =============================================================================
45
+
46
+
47
+ class TestProtocolMessages:
48
+ """Test JSON-RPC message creation and parsing"""
49
+
50
+ def test_request_creation(self):
51
+ """Test creating a JSON-RPC request"""
52
+ req_json = create_request(RPCMethod.GET_EXPERIMENTS, {"since": None}, 1)
53
+ data = json.loads(req_json)
54
+
55
+ assert data["jsonrpc"] == JSONRPC_VERSION
56
+ assert data["method"] == "get_experiments"
57
+ assert data["params"] == {"since": None}
58
+ assert data["id"] == 1
59
+
60
+ def test_request_parsing(self):
61
+ """Test parsing a JSON-RPC request"""
62
+ req_json = '{"jsonrpc": "2.0", "method": "get_jobs", "params": {"experiment_id": "exp1"}, "id": 42}'
63
+ msg = parse_message(req_json)
64
+
65
+ assert isinstance(msg, RPCRequest)
66
+ assert msg.method == "get_jobs"
67
+ assert msg.params == {"experiment_id": "exp1"}
68
+ assert msg.id == 42
69
+
70
+ def test_response_creation(self):
71
+ """Test creating a JSON-RPC response"""
72
+ resp_json = create_success_response(1, [{"id": "test"}])
73
+ data = json.loads(resp_json)
74
+
75
+ assert data["jsonrpc"] == JSONRPC_VERSION
76
+ assert data["id"] == 1
77
+ assert data["result"] == [{"id": "test"}]
78
+ assert "error" not in data
79
+
80
+ def test_error_response_creation(self):
81
+ """Test creating a JSON-RPC error response"""
82
+ resp_json = create_error_response(
83
+ 1, -32600, "Invalid request", {"detail": "missing method"}
84
+ )
85
+ data = json.loads(resp_json)
86
+
87
+ assert data["jsonrpc"] == JSONRPC_VERSION
88
+ assert data["id"] == 1
89
+ assert data["error"]["code"] == -32600
90
+ assert data["error"]["message"] == "Invalid request"
91
+ assert data["error"]["data"] == {"detail": "missing method"}
92
+
93
+ def test_response_parsing(self):
94
+ """Test parsing a JSON-RPC response"""
95
+ resp_json = '{"jsonrpc": "2.0", "id": 1, "result": {"success": true}}'
96
+ msg = parse_message(resp_json)
97
+
98
+ assert isinstance(msg, RPCResponse)
99
+ assert msg.id == 1
100
+ assert msg.result == {"success": True}
101
+ assert msg.error is None
102
+
103
+ def test_error_response_parsing(self):
104
+ """Test parsing a JSON-RPC error response"""
105
+ resp_json = '{"jsonrpc": "2.0", "id": 1, "error": {"code": -32601, "message": "Method not found"}}'
106
+ msg = parse_message(resp_json)
107
+
108
+ assert isinstance(msg, RPCResponse)
109
+ assert msg.id == 1
110
+ assert msg.result is None
111
+ assert msg.error.code == -32601
112
+ assert msg.error.message == "Method not found"
113
+
114
+ def test_notification_creation(self):
115
+ """Test creating a JSON-RPC notification"""
116
+ notif_json = create_notification(
117
+ NotificationMethod.STATE_EVENT,
118
+ {"event_type": "JobStateChangedEvent", "data": {"job_id": "job1"}},
119
+ )
120
+ data = json.loads(notif_json)
121
+
122
+ assert data["jsonrpc"] == JSONRPC_VERSION
123
+ assert data["method"] == "notification.state_event"
124
+ assert data["params"] == {
125
+ "event_type": "JobStateChangedEvent",
126
+ "data": {"job_id": "job1"},
127
+ }
128
+ assert "id" not in data
129
+
130
+ def test_notification_parsing(self):
131
+ """Test parsing a JSON-RPC notification"""
132
+ notif_json = '{"jsonrpc": "2.0", "method": "notification.shutdown", "params": {"reason": "test"}}'
133
+ msg = parse_message(notif_json)
134
+
135
+ assert isinstance(msg, RPCNotification)
136
+ assert msg.method == "notification.shutdown"
137
+ assert msg.params == {"reason": "test"}
138
+
139
+ def test_parse_invalid_json(self):
140
+ """Test parsing invalid JSON raises ValueError"""
141
+ with pytest.raises(ValueError, match="Invalid JSON"):
142
+ parse_message("not valid json")
143
+
144
+ def test_parse_missing_version(self):
145
+ """Test parsing message without jsonrpc version raises ValueError"""
146
+ with pytest.raises(ValueError, match="Invalid or missing jsonrpc version"):
147
+ parse_message('{"method": "test", "id": 1}')
148
+
149
+ def test_parse_wrong_version(self):
150
+ """Test parsing message with wrong jsonrpc version raises ValueError"""
151
+ with pytest.raises(ValueError, match="Invalid or missing jsonrpc version"):
152
+ parse_message('{"jsonrpc": "1.0", "method": "test", "id": 1}')
153
+
154
+
155
+ class TestDatetimeSerialization:
156
+ """Test datetime serialization helpers"""
157
+
158
+ def test_serialize_none(self):
159
+ """Test serializing None"""
160
+ assert serialize_datetime(None) is None
161
+
162
+ def test_serialize_datetime(self):
163
+ """Test serializing datetime object"""
164
+ dt = datetime(2024, 1, 15, 10, 30, 0)
165
+ result = serialize_datetime(dt)
166
+ assert result == "2024-01-15T10:30:00"
167
+
168
+ def test_serialize_timestamp(self):
169
+ """Test serializing Unix timestamp"""
170
+ # 2024-01-01 00:00:00 UTC (adjusted for local timezone)
171
+ result = serialize_datetime(1704067200.0)
172
+ assert "2024-01-01" in result
173
+
174
+ def test_serialize_string_passthrough(self):
175
+ """Test that strings pass through unchanged"""
176
+ result = serialize_datetime("2024-01-15T10:30:00")
177
+ assert result == "2024-01-15T10:30:00"
178
+
179
+ def test_deserialize_none(self):
180
+ """Test deserializing None"""
181
+ assert deserialize_datetime(None) is None
182
+
183
+ def test_deserialize_iso_string(self):
184
+ """Test deserializing ISO format string"""
185
+ result = deserialize_datetime("2024-01-15T10:30:00")
186
+ assert result == datetime(2024, 1, 15, 10, 30, 0)
187
+
188
+ def test_roundtrip(self):
189
+ """Test datetime serialization roundtrip"""
190
+ original = datetime(2024, 6, 15, 14, 30, 45)
191
+ serialized = serialize_datetime(original)
192
+ deserialized = deserialize_datetime(serialized)
193
+ assert deserialized == original
194
+
195
+
196
+ class TestJobSerialization:
197
+ """Test job serialization using state_dict"""
198
+
199
+ def test_serialize_mock_job(self):
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
203
+ job = MockJob(
204
+ identifier="job123",
205
+ task_id="task.MyTask",
206
+ path=Path("/tmp/jobs/job123"),
207
+ state="running",
208
+ submittime=1704067200.0,
209
+ starttime=1704067300.0,
210
+ endtime=None,
211
+ progress=[],
212
+ updated_at="2024-01-01T00:00:00",
213
+ )
214
+
215
+ result = job.state_dict()
216
+
217
+ assert result["job_id"] == "job123"
218
+ assert result["task_id"] == "task.MyTask"
219
+ assert result["path"] == "/tmp/jobs/job123"
220
+ # State is serialized from JobState enum - case may vary
221
+ assert result["state"].upper() == "RUNNING"
222
+
223
+
224
+ class TestExperimentSerialization:
225
+ """Test experiment serialization using state_dict"""
226
+
227
+ def test_serialize_mock_experiment(self):
228
+ """Test serializing a MockExperiment using state_dict()"""
229
+ from experimaestro.scheduler.interfaces import ExperimentStatus
230
+
231
+ # New layout: experiments/{experiment_id}/{run_id}
232
+ exp = MockExperiment(
233
+ workdir=Path("/tmp/experiments/myexp/run_20240101"),
234
+ run_id="run_20240101",
235
+ status=ExperimentStatus.RUNNING,
236
+ started_at=1704067200.0,
237
+ ended_at=None,
238
+ hostname="server1",
239
+ )
240
+
241
+ result = exp.state_dict()
242
+
243
+ assert result["experiment_id"] == "myexp"
244
+ assert result["run_id"] == "run_20240101"
245
+ assert result["status"] == "running"
246
+
247
+
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()
422
+
423
+
424
+ # =============================================================================
425
+ # Server Tests
426
+ # =============================================================================
427
+
428
+
429
+ class TestServerRequestHandling:
430
+ """Test server request handling with mocked state provider"""
431
+
432
+ @pytest.fixture
433
+ def mock_state_provider(self):
434
+ """Create a mock state provider"""
435
+ provider = MagicMock()
436
+ provider.workspace_path = Path("/tmp/workspace")
437
+ provider.get_experiments.return_value = []
438
+ provider.get_experiment.return_value = None
439
+ provider.get_experiment_runs.return_value = []
440
+ provider.get_jobs.return_value = []
441
+ provider.get_job.return_value = None
442
+ provider.get_all_jobs.return_value = []
443
+ provider.get_services.return_value = []
444
+ provider.get_last_sync_time.return_value = None
445
+ return provider
446
+
447
+ @pytest.fixture
448
+ def server_with_mock(self, mock_state_provider, tmp_path):
449
+ """Create a server with mocked state provider"""
450
+ from experimaestro.scheduler.remote.server import SSHStateProviderServer
451
+
452
+ # Create workspace directory
453
+ workspace = tmp_path / "workspace"
454
+ workspace.mkdir()
455
+ (workspace / ".experimaestro").mkdir()
456
+
457
+ server = SSHStateProviderServer(workspace)
458
+ server._state_provider = mock_state_provider
459
+ return server
460
+
461
+ def test_handle_get_experiments(self, server_with_mock, mock_state_provider):
462
+ """Test handling get_experiments request"""
463
+ from experimaestro.scheduler.interfaces import ExperimentStatus
464
+
465
+ # New layout: experiments/{exp-id}/{run-id}
466
+ mock_exp = MockExperiment(
467
+ workdir=Path("/tmp/experiments/exp1/run1"),
468
+ run_id="run1",
469
+ status=ExperimentStatus.RUNNING,
470
+ )
471
+ mock_state_provider.get_experiments.return_value = [mock_exp]
472
+
473
+ result = server_with_mock._handle_get_experiments({"since": None})
474
+
475
+ assert len(result) == 1
476
+ assert result[0]["experiment_id"] == "exp1"
477
+ mock_state_provider.get_experiments.assert_called_once_with(since=None)
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
+
529
+ def test_handle_get_jobs(self, server_with_mock, mock_state_provider):
530
+ """Test handling get_jobs request"""
531
+ mock_job = MockJob(
532
+ identifier="job1",
533
+ task_id="task.Test",
534
+ path=Path("/tmp/jobs/job1"),
535
+ state="done",
536
+ submittime=None,
537
+ starttime=None,
538
+ endtime=None,
539
+ progress=[],
540
+ updated_at="",
541
+ )
542
+ mock_state_provider.get_jobs.return_value = [mock_job]
543
+
544
+ result = server_with_mock._handle_get_jobs(
545
+ {
546
+ "experiment_id": "exp1",
547
+ "run_id": "run1",
548
+ }
549
+ )
550
+
551
+ assert len(result) == 1
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
742
+
743
+ def test_handle_get_sync_info(self, server_with_mock):
744
+ """Test handling get_sync_info request"""
745
+ result = server_with_mock._handle_get_sync_info({})
746
+
747
+ assert "workspace_path" in result
748
+ assert "last_sync_time" in result
749
+
750
+ def test_handle_unknown_method(self, server_with_mock):
751
+ """Test that unknown methods are not in handlers"""
752
+ assert "unknown_method" not in server_with_mock._handlers
753
+
754
+
755
+ # =============================================================================
756
+ # Client-Server Integration Tests (using pipes)
757
+ # =============================================================================
758
+
759
+
760
+ class TestClientServerIntegration:
761
+ """Integration tests using pipes instead of SSH"""
762
+
763
+ @pytest.fixture
764
+ def pipe_pair(self):
765
+ """Create a pair of pipes for client-server communication"""
766
+ # Server reads from client_to_server, writes to server_to_client
767
+ # Client reads from server_to_client, writes to client_to_server
768
+ client_to_server_r, client_to_server_w = io.BytesIO(), io.BytesIO()
769
+ server_to_client_r, server_to_client_w = io.BytesIO(), io.BytesIO()
770
+
771
+ return {
772
+ "server_stdin": client_to_server_r,
773
+ "server_stdout": server_to_client_w,
774
+ "client_stdin": client_to_server_w,
775
+ "client_stdout": server_to_client_r,
776
+ }
777
+
778
+ def test_request_response_cycle(self):
779
+ """Test a complete request-response cycle"""
780
+ # Simulate server response
781
+ request = create_request(RPCMethod.GET_EXPERIMENTS, {"since": None}, 1)
782
+ response = create_success_response(1, [])
783
+
784
+ # Parse request
785
+ req_msg = parse_message(request)
786
+ assert isinstance(req_msg, RPCRequest)
787
+ assert req_msg.method == "get_experiments"
788
+
789
+ # Parse response
790
+ resp_msg = parse_message(response)
791
+ assert isinstance(resp_msg, RPCResponse)
792
+ assert resp_msg.result == []
793
+
794
+ def test_notification_job_updated(self):
795
+ """Test job_updated notification message handling via STATE_EVENT"""
796
+ notification = create_notification(
797
+ NotificationMethod.STATE_EVENT,
798
+ {
799
+ "event_type": "JobStateChangedEvent",
800
+ "data": {
801
+ "job_id": "job1",
802
+ "experiment_id": "exp1",
803
+ "run_id": "run1",
804
+ },
805
+ },
806
+ )
807
+
808
+ msg = parse_message(notification)
809
+ assert isinstance(msg, RPCNotification)
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"
900
+
901
+
902
+ # =============================================================================
903
+ # Client Tests
904
+ # =============================================================================
905
+
906
+
907
+ class TestClientDataConversion:
908
+ """Test client data conversion methods"""
909
+
910
+ @pytest.fixture
911
+ def client(self, tmp_path):
912
+ """Create a client instance with mocked temp directory"""
913
+ from experimaestro.scheduler.remote.client import SSHStateProviderClient
914
+
915
+ client = SSHStateProviderClient(
916
+ host="testhost",
917
+ remote_workspace="/remote/workspace",
918
+ )
919
+ # Manually set up temp directory for testing (normally done in connect())
920
+ client._temp_dir = str(tmp_path)
921
+ client.local_cache_dir = tmp_path
922
+ client.workspace_path = tmp_path
923
+
924
+ return client
925
+
926
+ def test_dict_to_job(self, client, tmp_path):
927
+ """Test converting dictionary to MockJob"""
928
+ # Note: tags, experiment_id, run_id are not part of MockJob
929
+ # as they are experiment-specific
930
+ job_dict = {
931
+ "job_id": "job123",
932
+ "task_id": "task.MyTask",
933
+ "path": "/remote/workspace/jobs/job123",
934
+ "state": "running",
935
+ "submitted_time": "2024-01-01T10:00:00",
936
+ "started_time": "2024-01-01T10:01:00",
937
+ "ended_time": None,
938
+ "progress": [],
939
+ }
940
+
941
+ job = client._dict_to_job(job_dict)
942
+
943
+ assert job.identifier == "job123"
944
+ assert job.task_id == "task.MyTask"
945
+ # Path should be mapped to local cache
946
+ assert job.path == tmp_path / "jobs/job123"
947
+
948
+ def test_dict_to_experiment(self, client, tmp_path):
949
+ """Test converting dictionary to MockExperiment"""
950
+ # New layout: experiments/{experiment_id}/{run_id}
951
+ exp_dict = {
952
+ "experiment_id": "myexp",
953
+ "workdir": "/remote/workspace/experiments/myexp/run1",
954
+ "run_id": "run1",
955
+ "status": "running",
956
+ "hostname": "server1",
957
+ }
958
+
959
+ exp = client._dict_to_experiment(exp_dict)
960
+
961
+ assert exp.experiment_id == "myexp"
962
+ # Path should be mapped to local cache
963
+ assert exp.workdir == tmp_path / "experiments/myexp/run1"
964
+ assert exp.hostname == "server1"
965
+
966
+ def test_path_mapping_outside_workspace(self, client):
967
+ """Test path mapping for paths outside remote workspace"""
968
+ job_dict = {
969
+ "job_id": "job123",
970
+ "task_id": "task.MyTask",
971
+ "path": "/other/path/job123", # Not under remote_workspace
972
+ "state": "done",
973
+ "progress": [],
974
+ }
975
+
976
+ job = client._dict_to_job(job_dict)
977
+
978
+ # Path outside workspace should be kept as-is
979
+ assert job.path == Path("/other/path/job123")
980
+
981
+
982
+ # =============================================================================
983
+ # Synchronizer Tests
984
+ # =============================================================================
985
+
986
+
987
+ class TestRemoteFileSynchronizer:
988
+ """Test file synchronization logic"""
989
+
990
+ @pytest.fixture
991
+ def synchronizer(self, tmp_path):
992
+ """Create a synchronizer instance"""
993
+ from experimaestro.scheduler.remote.sync import RemoteFileSynchronizer
994
+
995
+ local_cache = tmp_path / "cache"
996
+ local_cache.mkdir()
997
+
998
+ return RemoteFileSynchronizer(
999
+ host="testhost",
1000
+ remote_workspace=Path("/remote/workspace"),
1001
+ local_cache=local_cache,
1002
+ )
1003
+
1004
+ def test_get_local_path(self, synchronizer):
1005
+ """Test mapping remote path to local path"""
1006
+ remote_path = "/remote/workspace/xp/exp1/jobs.jsonl"
1007
+ local_path = synchronizer.get_local_path(remote_path)
1008
+
1009
+ assert local_path == synchronizer.local_cache / "xp/exp1/jobs.jsonl"
1010
+
1011
+ def test_get_local_path_outside_workspace(self, synchronizer):
1012
+ """Test mapping path outside workspace"""
1013
+ remote_path = "/other/path/file.txt"
1014
+ local_path = synchronizer.get_local_path(remote_path)
1015
+
1016
+ # Should return the original path
1017
+ assert local_path == Path("/other/path/file.txt")
1018
+
1019
+ @patch("subprocess.run")
1020
+ def test_rsync_command_construction(self, mock_run, synchronizer):
1021
+ """Test that rsync command is constructed correctly"""
1022
+ mock_run.return_value = MagicMock(returncode=0)
1023
+
1024
+ synchronizer._rsync(
1025
+ "testhost:/remote/workspace/logs/",
1026
+ str(synchronizer.local_cache / "logs") + "/",
1027
+ )
1028
+
1029
+ mock_run.assert_called_once()
1030
+ cmd = mock_run.call_args[0][0]
1031
+
1032
+ assert "rsync" in cmd
1033
+ assert "--inplace" in cmd
1034
+ assert "--delete" in cmd
1035
+ assert "-L" in cmd
1036
+ assert "-a" in cmd
1037
+ assert "-z" in cmd
1038
+ assert "-v" in cmd
1039
+ assert "testhost:/remote/workspace/logs/" in cmd
1040
+
1041
+
1042
+ # =============================================================================
1043
+ # Version and Temp Directory Tests
1044
+ # =============================================================================
1045
+
1046
+
1047
+ class TestVersionStripping:
1048
+ """Test version string manipulation"""
1049
+
1050
+ def test_strip_dev_version(self):
1051
+ """Test stripping .devN suffix from versions"""
1052
+ from experimaestro.scheduler.remote.client import _strip_dev_version
1053
+
1054
+ assert _strip_dev_version("2.0.0b3.dev8") == "2.0.0b3"
1055
+ assert _strip_dev_version("1.2.3.dev1") == "1.2.3"
1056
+ assert _strip_dev_version("1.2.3") == "1.2.3"
1057
+ assert _strip_dev_version("2.0.0a1.dev100") == "2.0.0a1"
1058
+ assert _strip_dev_version("0.1.0.dev0") == "0.1.0"
1059
+
1060
+ def test_strip_dev_preserves_prerelease(self):
1061
+ """Test that pre-release tags are preserved"""
1062
+ from experimaestro.scheduler.remote.client import _strip_dev_version
1063
+
1064
+ assert _strip_dev_version("1.0.0a1.dev5") == "1.0.0a1"
1065
+ assert _strip_dev_version("1.0.0b2.dev3") == "1.0.0b2"
1066
+ assert _strip_dev_version("1.0.0rc1.dev1") == "1.0.0rc1"
1067
+
1068
+
1069
+ class TestTempDirectory:
1070
+ """Test temporary directory handling for client cache"""
1071
+
1072
+ def test_client_temp_dir_not_created_until_connect(self):
1073
+ """Test that temp directory is not created until connect() is called"""
1074
+ from experimaestro.scheduler.remote.client import SSHStateProviderClient
1075
+
1076
+ client = SSHStateProviderClient(
1077
+ host="testhost",
1078
+ remote_workspace="/remote",
1079
+ )
1080
+
1081
+ # Before connect, temp_dir should be None
1082
+ assert client._temp_dir is None
1083
+ assert client.local_cache_dir is None
1084
+
1085
+
1086
+ # =============================================================================
1087
+ # Error Handling Tests
1088
+ # =============================================================================
1089
+
1090
+
1091
+ class TestErrorHandling:
1092
+ """Test error handling in protocol and server"""
1093
+
1094
+ def test_rpc_error_creation(self):
1095
+ """Test creating RPC error objects"""
1096
+ error = RPCError(
1097
+ code=-32600, message="Invalid request", data={"field": "method"}
1098
+ )
1099
+
1100
+ assert error.code == -32600
1101
+ assert error.message == "Invalid request"
1102
+ assert error.data == {"field": "method"}
1103
+
1104
+ error_dict = error.to_dict()
1105
+ assert error_dict["code"] == -32600
1106
+ assert error_dict["message"] == "Invalid request"
1107
+ assert error_dict["data"] == {"field": "method"}
1108
+
1109
+ def test_rpc_error_from_dict(self):
1110
+ """Test creating RPC error from dictionary"""
1111
+ error = RPCError.from_dict(
1112
+ {
1113
+ "code": -32601,
1114
+ "message": "Method not found",
1115
+ }
1116
+ )
1117
+
1118
+ assert error.code == -32601
1119
+ assert error.message == "Method not found"
1120
+ assert error.data is None
1121
+
1122
+ def test_response_with_error(self):
1123
+ """Test response with error is parsed correctly"""
1124
+ resp = RPCResponse(
1125
+ id=1,
1126
+ error=RPCError(code=-32600, message="Invalid request"),
1127
+ )
1128
+
1129
+ resp_dict = resp.to_dict()
1130
+ assert "error" in resp_dict
1131
+ assert resp_dict["error"]["code"] == -32600
1132
+ assert "result" not in resp_dict