experimaestro 1.11.1__py3-none-any.whl → 2.0.0b4__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of experimaestro might be problematic. Click here for more details.

Files changed (133) hide show
  1. experimaestro/__init__.py +10 -11
  2. experimaestro/annotations.py +167 -206
  3. experimaestro/cli/__init__.py +140 -16
  4. experimaestro/cli/filter.py +42 -74
  5. experimaestro/cli/jobs.py +157 -106
  6. experimaestro/cli/progress.py +269 -0
  7. experimaestro/cli/refactor.py +249 -0
  8. experimaestro/click.py +0 -1
  9. experimaestro/commandline.py +19 -3
  10. experimaestro/connectors/__init__.py +22 -3
  11. experimaestro/connectors/local.py +12 -0
  12. experimaestro/core/arguments.py +192 -37
  13. experimaestro/core/identifier.py +127 -12
  14. experimaestro/core/objects/__init__.py +6 -0
  15. experimaestro/core/objects/config.py +702 -285
  16. experimaestro/core/objects/config_walk.py +24 -6
  17. experimaestro/core/serialization.py +91 -34
  18. experimaestro/core/serializers.py +1 -8
  19. experimaestro/core/subparameters.py +164 -0
  20. experimaestro/core/types.py +198 -83
  21. experimaestro/exceptions.py +26 -0
  22. experimaestro/experiments/cli.py +107 -25
  23. experimaestro/generators.py +50 -9
  24. experimaestro/huggingface.py +3 -1
  25. experimaestro/launcherfinder/parser.py +29 -0
  26. experimaestro/launcherfinder/registry.py +3 -3
  27. experimaestro/launchers/__init__.py +26 -1
  28. experimaestro/launchers/direct.py +12 -0
  29. experimaestro/launchers/slurm/base.py +154 -2
  30. experimaestro/mkdocs/base.py +6 -8
  31. experimaestro/mkdocs/metaloader.py +0 -1
  32. experimaestro/mypy.py +452 -7
  33. experimaestro/notifications.py +75 -16
  34. experimaestro/progress.py +404 -0
  35. experimaestro/rpyc.py +0 -1
  36. experimaestro/run.py +19 -6
  37. experimaestro/scheduler/__init__.py +18 -1
  38. experimaestro/scheduler/base.py +504 -959
  39. experimaestro/scheduler/dependencies.py +43 -28
  40. experimaestro/scheduler/dynamic_outputs.py +259 -130
  41. experimaestro/scheduler/experiment.py +582 -0
  42. experimaestro/scheduler/interfaces.py +474 -0
  43. experimaestro/scheduler/jobs.py +485 -0
  44. experimaestro/scheduler/services.py +186 -12
  45. experimaestro/scheduler/signal_handler.py +32 -0
  46. experimaestro/scheduler/state.py +1 -1
  47. experimaestro/scheduler/state_db.py +388 -0
  48. experimaestro/scheduler/state_provider.py +2345 -0
  49. experimaestro/scheduler/state_sync.py +834 -0
  50. experimaestro/scheduler/workspace.py +52 -10
  51. experimaestro/scriptbuilder.py +7 -0
  52. experimaestro/server/__init__.py +153 -32
  53. experimaestro/server/data/index.css +0 -125
  54. experimaestro/server/data/index.css.map +1 -1
  55. experimaestro/server/data/index.js +194 -58
  56. experimaestro/server/data/index.js.map +1 -1
  57. experimaestro/settings.py +47 -6
  58. experimaestro/sphinx/__init__.py +3 -3
  59. experimaestro/taskglobals.py +20 -0
  60. experimaestro/tests/conftest.py +80 -0
  61. experimaestro/tests/core/test_generics.py +2 -2
  62. experimaestro/tests/identifier_stability.json +45 -0
  63. experimaestro/tests/launchers/bin/sacct +6 -2
  64. experimaestro/tests/launchers/bin/sbatch +4 -2
  65. experimaestro/tests/launchers/common.py +2 -2
  66. experimaestro/tests/launchers/test_slurm.py +80 -0
  67. experimaestro/tests/restart.py +1 -1
  68. experimaestro/tests/tasks/all.py +7 -0
  69. experimaestro/tests/tasks/test_dynamic.py +231 -0
  70. experimaestro/tests/test_checkers.py +2 -2
  71. experimaestro/tests/test_cli_jobs.py +615 -0
  72. experimaestro/tests/test_dependencies.py +11 -17
  73. experimaestro/tests/test_deprecated.py +630 -0
  74. experimaestro/tests/test_environment.py +200 -0
  75. experimaestro/tests/test_experiment.py +3 -3
  76. experimaestro/tests/test_file_progress.py +425 -0
  77. experimaestro/tests/test_file_progress_integration.py +477 -0
  78. experimaestro/tests/test_forward.py +3 -3
  79. experimaestro/tests/test_generators.py +93 -0
  80. experimaestro/tests/test_identifier.py +520 -169
  81. experimaestro/tests/test_identifier_stability.py +458 -0
  82. experimaestro/tests/test_instance.py +16 -21
  83. experimaestro/tests/test_multitoken.py +442 -0
  84. experimaestro/tests/test_mypy.py +433 -0
  85. experimaestro/tests/test_objects.py +314 -30
  86. experimaestro/tests/test_outputs.py +8 -8
  87. experimaestro/tests/test_param.py +22 -26
  88. experimaestro/tests/test_partial_paths.py +231 -0
  89. experimaestro/tests/test_progress.py +2 -50
  90. experimaestro/tests/test_resumable_task.py +480 -0
  91. experimaestro/tests/test_serializers.py +141 -60
  92. experimaestro/tests/test_state_db.py +434 -0
  93. experimaestro/tests/test_subparameters.py +160 -0
  94. experimaestro/tests/test_tags.py +151 -15
  95. experimaestro/tests/test_tasks.py +137 -160
  96. experimaestro/tests/test_token_locking.py +252 -0
  97. experimaestro/tests/test_tokens.py +25 -19
  98. experimaestro/tests/test_types.py +133 -11
  99. experimaestro/tests/test_validation.py +19 -19
  100. experimaestro/tests/test_workspace_triggers.py +158 -0
  101. experimaestro/tests/token_reschedule.py +5 -3
  102. experimaestro/tests/utils.py +2 -2
  103. experimaestro/tokens.py +154 -57
  104. experimaestro/tools/diff.py +8 -1
  105. experimaestro/tui/__init__.py +8 -0
  106. experimaestro/tui/app.py +2303 -0
  107. experimaestro/tui/app.tcss +353 -0
  108. experimaestro/tui/log_viewer.py +228 -0
  109. experimaestro/typingutils.py +11 -2
  110. experimaestro/utils/__init__.py +23 -0
  111. experimaestro/utils/environment.py +148 -0
  112. experimaestro/utils/git.py +129 -0
  113. experimaestro/utils/resources.py +1 -1
  114. experimaestro/version.py +34 -0
  115. {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info}/METADATA +70 -39
  116. experimaestro-2.0.0b4.dist-info/RECORD +181 -0
  117. {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info}/WHEEL +1 -1
  118. experimaestro-2.0.0b4.dist-info/entry_points.txt +16 -0
  119. experimaestro/compat.py +0 -6
  120. experimaestro/core/objects.pyi +0 -225
  121. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  122. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  123. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  124. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  125. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  126. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  127. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  128. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  129. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  130. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  131. experimaestro-1.11.1.dist-info/RECORD +0 -158
  132. experimaestro-1.11.1.dist-info/entry_points.txt +0 -17
  133. {experimaestro-1.11.1.dist-info → experimaestro-2.0.0b4.dist-info/licenses}/LICENSE +0 -0
@@ -0,0 +1,630 @@
1
+ """Tests for deprecation mechanism.
2
+
3
+ Tests cover:
4
+ - Legacy @deprecate pattern (deprecated class inherits from new class)
5
+ - New @deprecate(Target) pattern with explicit target
6
+ - @deprecate(Target, replace=True) for immediate conversion
7
+ - Deprecated attributes
8
+ - fix_deprecated CLI for migrating job directories
9
+ - Task deprecation with fix_deprecated symlink creation
10
+ """
11
+
12
+ import logging
13
+ from typing import List
14
+
15
+ from experimaestro import field, Config, Param, Task, deprecate
16
+ from experimaestro.core.identifier import IdentifierComputer
17
+ from experimaestro.scheduler.workspace import RunMode
18
+ from experimaestro.tools.jobs import fix_deprecated
19
+
20
+ from .utils import TemporaryExperiment
21
+
22
+
23
+ def assert_equal(a, b, message=""):
24
+ """Assert two configs have the same identifier."""
25
+ a_id = IdentifierComputer.compute(a)
26
+ b_id = IdentifierComputer.compute(b)
27
+ assert a_id == b_id, f"{message}: {a_id} != {b_id}"
28
+
29
+
30
+ def assert_notequal(a, b, message=""):
31
+ """Assert two configs have different identifiers."""
32
+ a_id = IdentifierComputer.compute(a)
33
+ b_id = IdentifierComputer.compute(b)
34
+ assert a_id != b_id, f"{message}: {a_id} == {b_id}"
35
+
36
+
37
+ # --- Legacy deprecation tests ---
38
+
39
+
40
+ def test_deprecated_class_legacy():
41
+ """Test legacy @deprecate pattern where deprecated class inherits from new class."""
42
+
43
+ class NewConfig(Config):
44
+ __xpmid__ = "test.deprecated.legacy.new"
45
+
46
+ @deprecate
47
+ class OldConfig(NewConfig):
48
+ __xpmid__ = "test.deprecated.legacy.old"
49
+
50
+ class DerivedConfig(NewConfig):
51
+ __xpmid__ = "test.deprecated.legacy.derived"
52
+
53
+ assert_notequal(
54
+ NewConfig.C(), DerivedConfig.C(), "A derived configuration has another ID"
55
+ )
56
+ assert_equal(
57
+ NewConfig.C(),
58
+ OldConfig.C(),
59
+ "Deprecated and new configuration have the same ID",
60
+ )
61
+
62
+
63
+ def test_deprecated_attribute():
64
+ """Test deprecating a parameter with a conversion method."""
65
+
66
+ class Values(Config):
67
+ __xpmid__ = "test.deprecated.attribute"
68
+ values: Param[List[int]] = field(ignore_default=[])
69
+
70
+ @deprecate
71
+ def value(self, x):
72
+ self.values = [x]
73
+
74
+ assert_equal(Values.C(values=[1]), Values.C(value=1))
75
+
76
+
77
+ # --- New @deprecate(Target) pattern tests ---
78
+
79
+
80
+ def test_deprecate_with_explicit_target():
81
+ """Test @deprecate(TargetConfig) with explicit target class."""
82
+
83
+ class NewConfig(Config):
84
+ __xpmid__ = "test.deprecate.explicit.new"
85
+ value: Param[int]
86
+
87
+ @deprecate(NewConfig)
88
+ class OldConfig(Config):
89
+ __xpmid__ = "test.deprecate.explicit.old"
90
+ value: Param[int]
91
+
92
+ def __convert__(self):
93
+ return NewConfig.C(value=self.value)
94
+
95
+ # Creating OldConfig returns OldConfig (not NewConfig)
96
+ old = OldConfig.C(value=42)
97
+ assert type(old).__name__ == "OldConfig.XPMConfig"
98
+
99
+ # But identifiers should match because __convert__ is used for ID computation
100
+ new = NewConfig.C(value=42)
101
+ assert_equal(old, new)
102
+
103
+
104
+ def test_deprecate_with_parameter_transformation():
105
+ """Test __convert__ that transforms parameters."""
106
+
107
+ class NewConfig(Config):
108
+ __xpmid__ = "test.deprecate.transform.new"
109
+ values: Param[List[int]]
110
+
111
+ @deprecate(NewConfig)
112
+ class OldConfig(Config):
113
+ __xpmid__ = "test.deprecate.transform.old"
114
+ value: Param[int]
115
+
116
+ def __convert__(self):
117
+ # Convert single value to list
118
+ return NewConfig.C(values=[self.value])
119
+
120
+ # Create old config - stays as OldConfig
121
+ old = OldConfig.C(value=42)
122
+ assert type(old).__name__ == "OldConfig.XPMConfig"
123
+ assert old.value == 42
124
+
125
+ # Identifier should match the equivalent NewConfig
126
+ new = NewConfig.C(values=[42])
127
+ assert_equal(old, new)
128
+
129
+
130
+ def test_deprecate_chained_versions():
131
+ """Test chained deprecation: A_v0 -> A_v1 -> A
132
+
133
+ Each deprecated version computes its identifier via __convert__ chain.
134
+ """
135
+
136
+ class A(Config):
137
+ __xpmid__ = "test.deprecate.chained.a"
138
+ data: Param[List[str]]
139
+
140
+ @deprecate(A)
141
+ class A_v1(Config):
142
+ __xpmid__ = "test.deprecate.chained.a_v1"
143
+ items: Param[List[str]]
144
+
145
+ def __convert__(self):
146
+ return A.C(data=self.items)
147
+
148
+ @deprecate(A_v1)
149
+ class A_v0(Config):
150
+ __xpmid__ = "test.deprecate.chained.a_v0"
151
+ item: Param[str]
152
+
153
+ def __convert__(self):
154
+ return A_v1.C(items=[self.item])
155
+
156
+ # Create configs - each stays as its own type
157
+ v0 = A_v0.C(item="hello")
158
+ v1 = A_v1.C(items=["hello"])
159
+ current = A.C(data=["hello"])
160
+
161
+ assert type(v0).__name__ == "A_v0.XPMConfig"
162
+ assert type(v1).__name__ == "A_v1.XPMConfig"
163
+ assert type(current).__name__ == "A.XPMConfig"
164
+
165
+ # All should have the same identifier (computed via conversion chain)
166
+ assert_equal(v0, v1)
167
+ assert_equal(v1, current)
168
+ assert_equal(v0, current)
169
+
170
+
171
+ def test_deprecate_preserves_original_identifier():
172
+ """Test that deprecated identifier is preserved for migration (fix_deprecated)."""
173
+
174
+ class NewStyle(Config):
175
+ __xpmid__ = "test.deprecate.preserve.new"
176
+ x: Param[int]
177
+
178
+ @deprecate(NewStyle)
179
+ class OldStyle(Config):
180
+ __xpmid__ = "test.deprecate.preserve.old"
181
+ x: Param[int]
182
+
183
+ def __convert__(self):
184
+ return NewStyle.C(x=self.x)
185
+
186
+ xpmtype = OldStyle.__getxpmtype__()
187
+
188
+ # The deprecated identifier should be preserved for migration
189
+ assert xpmtype._deprecated_identifier is not None
190
+ assert xpmtype._deprecated_identifier.name == "test.deprecate.preserve.old"
191
+
192
+ # The target identifier should be accessible
193
+ assert xpmtype.identifier.name == "test.deprecate.preserve.new"
194
+
195
+
196
+ # --- @deprecate(Target, replace=True) tests ---
197
+
198
+
199
+ def test_deprecate_replace_basic():
200
+ """Test @deprecate(Target, replace=True) immediately converts to new config."""
201
+
202
+ class NewConfig(Config):
203
+ __xpmid__ = "test.deprecate.replace.basic.new"
204
+ values: Param[list[int]]
205
+
206
+ @deprecate(NewConfig, replace=True)
207
+ class OldConfig(Config):
208
+ __xpmid__ = "test.deprecate.replace.basic.old"
209
+ value: Param[int]
210
+
211
+ def __convert__(self):
212
+ return NewConfig.C(values=[self.value])
213
+
214
+ # Creating OldConfig should return a NewConfig instance
215
+ result = OldConfig.C(value=42)
216
+
217
+ # The result should be the new config type
218
+ assert type(result).__name__ == "NewConfig.XPMConfig"
219
+
220
+ # The values should be converted
221
+ assert result.values == [42]
222
+
223
+
224
+ def test_deprecate_replace_identifier():
225
+ """Test that replaced configs have the same identifier as direct creation."""
226
+
227
+ class NewConfig(Config):
228
+ __xpmid__ = "test.deprecate.replace.id.new"
229
+ values: Param[list[int]]
230
+
231
+ @deprecate(NewConfig, replace=True)
232
+ class OldConfig(Config):
233
+ __xpmid__ = "test.deprecate.replace.id.old"
234
+ value: Param[int]
235
+
236
+ def __convert__(self):
237
+ return NewConfig.C(values=[self.value])
238
+
239
+ # Create via old and new ways
240
+ via_old = OldConfig.C(value=42)
241
+ via_new = NewConfig.C(values=[42])
242
+
243
+ # Both should be NewConfig instances with same identifier
244
+ assert type(via_old).__name__ == "NewConfig.XPMConfig"
245
+ assert type(via_new).__name__ == "NewConfig.XPMConfig"
246
+ assert_equal(via_old, via_new)
247
+
248
+
249
+ def test_deprecate_replace_preserves_deprecated_id():
250
+ """Test that deprecated identifier is preserved for fix_deprecated even with replace=True."""
251
+
252
+ class NewConfig(Config):
253
+ __xpmid__ = "test.deprecate.replace.preserve.new"
254
+ values: Param[list[int]]
255
+
256
+ @deprecate(NewConfig, replace=True)
257
+ class OldConfig(Config):
258
+ __xpmid__ = "test.deprecate.replace.preserve.old"
259
+ value: Param[int]
260
+
261
+ def __convert__(self):
262
+ return NewConfig.C(values=[self.value])
263
+
264
+ xpmtype = OldConfig.__getxpmtype__()
265
+
266
+ # The deprecated identifier should be preserved
267
+ assert xpmtype._deprecated_identifier is not None
268
+ assert xpmtype._deprecated_identifier.name == "test.deprecate.replace.preserve.old"
269
+
270
+ # The current identifier should point to new config
271
+ assert xpmtype.identifier.name == "test.deprecate.replace.preserve.new"
272
+
273
+
274
+ def test_deprecate_replace_deprecated_from_preserved():
275
+ """Test that converted config preserves reference to original deprecated config."""
276
+
277
+ class NewConfig(Config):
278
+ __xpmid__ = "test.deprecate.replace.from.new"
279
+ values: Param[list[int]]
280
+
281
+ @deprecate(NewConfig, replace=True)
282
+ class OldConfig(Config):
283
+ __xpmid__ = "test.deprecate.replace.from.old"
284
+ value: Param[int]
285
+
286
+ def __convert__(self):
287
+ return NewConfig.C(values=[self.value])
288
+
289
+ result = OldConfig.C(value=42)
290
+
291
+ # The converted config should have reference to original
292
+ assert result._deprecated_from is not None
293
+ assert type(result._deprecated_from).__name__ == "OldConfig.XPMConfig"
294
+ assert result._deprecated_from.value == 42
295
+
296
+
297
+ # --- fix_deprecated tests ---
298
+
299
+
300
+ def test_deprecation_info_available_for_fix_deprecated():
301
+ """Test that deprecation info is available for fix_deprecated tool.
302
+
303
+ The fix_deprecated tool needs to know:
304
+ 1. The original (deprecated) identifier
305
+ 2. The new (target) identifier
306
+ This test verifies this information is available via _deprecation.
307
+ """
308
+
309
+ class NewConfig(Config):
310
+ __xpmid__ = "test.fixdeprecated.info.new"
311
+ values: Param[list[int]]
312
+
313
+ @deprecate(NewConfig)
314
+ class OldConfig(Config):
315
+ __xpmid__ = "test.fixdeprecated.info.old"
316
+ value: Param[int]
317
+
318
+ def __convert__(self):
319
+ return NewConfig.C(values=[self.value])
320
+
321
+ xpmtype = OldConfig.__getxpmtype__()
322
+
323
+ # Verify deprecation info is accessible
324
+ assert xpmtype._deprecation is not None
325
+ assert (
326
+ xpmtype._deprecation.original_identifier.name == "test.fixdeprecated.info.old"
327
+ )
328
+ assert xpmtype._deprecation.target == NewConfig
329
+ assert xpmtype._deprecation.replace is False
330
+
331
+ # The identifier should point to new config
332
+ assert xpmtype.identifier.name == "test.fixdeprecated.info.new"
333
+
334
+
335
+ def test_deprecation_info_with_replace():
336
+ """Test that deprecation info includes replace flag."""
337
+
338
+ class NewConfig(Config):
339
+ __xpmid__ = "test.fixdeprecated.replace.new"
340
+ values: Param[list[int]]
341
+
342
+ @deprecate(NewConfig, replace=True)
343
+ class OldConfig(Config):
344
+ __xpmid__ = "test.fixdeprecated.replace.old"
345
+ value: Param[int]
346
+
347
+ def __convert__(self):
348
+ return NewConfig.C(values=[self.value])
349
+
350
+ xpmtype = OldConfig.__getxpmtype__()
351
+
352
+ # Verify replace flag is set
353
+ assert xpmtype._deprecation is not None
354
+ assert xpmtype._deprecation.replace is True
355
+ assert (
356
+ xpmtype._deprecation.original_identifier.name
357
+ == "test.fixdeprecated.replace.old"
358
+ )
359
+
360
+
361
+ # =============================================================================
362
+ # Task deprecation tests (with actual experiment context and fix_deprecated)
363
+ # =============================================================================
364
+
365
+
366
+ class NewConfigForTask(Config):
367
+ """New configuration used in task deprecation tests."""
368
+
369
+ __xpmid__ = "test.deprecated.task.config.new"
370
+
371
+
372
+ @deprecate
373
+ class DeprecatedConfigForTask(NewConfigForTask):
374
+ """Deprecated configuration (legacy pattern)."""
375
+
376
+ __xpmid__ = "test.deprecated.task.config.deprecated"
377
+
378
+
379
+ class OldConfigForTask(NewConfigForTask):
380
+ """Old configuration without deprecate flag (for comparison)."""
381
+
382
+ __xpmid__ = "test.deprecated.task.config.deprecated"
383
+
384
+
385
+ class TaskWithDeprecatedConfig(Task):
386
+ """Task that uses a deprecated config as parameter."""
387
+
388
+ __xpmid__ = "test.deprecated.task.with.config"
389
+ p: Param[NewConfigForTask]
390
+
391
+ def execute(self):
392
+ pass
393
+
394
+
395
+ def _check_symlink_paths(task_new, task_old_path):
396
+ """Helper to verify symlink was created correctly."""
397
+ task_new_path = task_new.__xpm__.job.path # type: Path
398
+
399
+ assert task_new_path.exists(), f"New path {task_new_path} should exist"
400
+ assert task_new_path.is_symlink(), f"New path {task_new_path} should be a symlink"
401
+ assert task_new_path.resolve() == task_old_path
402
+
403
+
404
+ def test_task_deprecated_config_identifier():
405
+ """Test that tasks using deprecated configs have correct identifiers."""
406
+ with TemporaryExperiment("deprecated_config", maxwait=0):
407
+ # Create tasks with new, old, and deprecated configs
408
+ task_new = TaskWithDeprecatedConfig.C(p=NewConfigForTask.C()).submit(
409
+ run_mode=RunMode.DRY_RUN
410
+ )
411
+ task_old = TaskWithDeprecatedConfig.C(p=OldConfigForTask.C()).submit(
412
+ run_mode=RunMode.DRY_RUN
413
+ )
414
+ task_deprecated = TaskWithDeprecatedConfig.C(
415
+ p=DeprecatedConfigForTask.C()
416
+ ).submit(run_mode=RunMode.DRY_RUN)
417
+
418
+ logging.debug("New task ID: %s", task_new.__xpm__.identifier.all.hex())
419
+ logging.debug("Old task ID: %s", task_old.__xpm__.identifier.all.hex())
420
+ logging.debug(
421
+ "Deprecated task ID: %s", task_deprecated.__xpm__.identifier.all.hex()
422
+ )
423
+
424
+ # Old (non-deprecated) and new should have different paths
425
+ assert (
426
+ task_new.stdout() != task_old.stdout()
427
+ ), "Old and new path should be different"
428
+
429
+ # Deprecated should have same path as new (identifier matches)
430
+ assert (
431
+ task_new.stdout() == task_deprecated.stdout()
432
+ ), "Deprecated path should be the same as non deprecated"
433
+
434
+
435
+ def test_task_deprecated_config_fix_deprecated():
436
+ """Test fix_deprecated creates symlinks for tasks with deprecated configs."""
437
+ with TemporaryExperiment("deprecated_config_fix", maxwait=0) as xp:
438
+ task_new = TaskWithDeprecatedConfig.C(p=NewConfigForTask.C()).submit(
439
+ run_mode=RunMode.DRY_RUN
440
+ )
441
+
442
+ # Run old task (before deprecation)
443
+ task_old = TaskWithDeprecatedConfig.C(p=OldConfigForTask.C()).submit()
444
+ task_old.wait()
445
+ task_old_path = task_old.stdout().parent
446
+
447
+ # Now deprecate the old config
448
+ OldConfigForTask.__xpmtype__.deprecate()
449
+
450
+ # Run fix_deprecated
451
+ fix_deprecated(xp.workspace.path, fix=True, cleanup=False)
452
+
453
+ # Verify symlink was created
454
+ _check_symlink_paths(task_new, task_old_path)
455
+
456
+
457
+ class NewTask(Task):
458
+ """New task for deprecation tests."""
459
+
460
+ __xpmid__ = "test.deprecated.task.new"
461
+ x: Param[int]
462
+
463
+ def execute(self):
464
+ pass
465
+
466
+
467
+ class OldTask(NewTask):
468
+ """Old task without deprecate flag (for comparison)."""
469
+
470
+ __xpmid__ = "test.deprecated.task.deprecated"
471
+
472
+
473
+ @deprecate
474
+ class DeprecatedTask(NewTask):
475
+ """Deprecated task (legacy pattern)."""
476
+
477
+ __xpmid__ = "test.deprecated.task.deprecated"
478
+
479
+
480
+ def test_task_deprecated_identifier():
481
+ """Test that deprecated tasks have correct identifiers."""
482
+ with TemporaryExperiment("deprecated_task", maxwait=20):
483
+ task_new = NewTask.C(x=1).submit(run_mode=RunMode.DRY_RUN)
484
+ task_old = OldTask.C(x=1).submit(run_mode=RunMode.DRY_RUN)
485
+ task_deprecated = DeprecatedTask.C(x=1).submit(run_mode=RunMode.DRY_RUN)
486
+
487
+ # Old and new should have different paths
488
+ assert (
489
+ task_new.stdout() != task_old.stdout()
490
+ ), "Old and new path should be different"
491
+
492
+ # Deprecated should have same path as new
493
+ assert (
494
+ task_new.stdout() == task_deprecated.stdout()
495
+ ), "Deprecated path should be the same as non deprecated"
496
+
497
+
498
+ def test_task_deprecated_fix_deprecated():
499
+ """Test fix_deprecated creates symlinks for deprecated tasks."""
500
+ with TemporaryExperiment("deprecated_task_fix", maxwait=20) as xp:
501
+ task_new = NewTask.C(x=1).submit(run_mode=RunMode.DRY_RUN)
502
+
503
+ # Run old task (before deprecation)
504
+ task_old = OldTask.C(x=1).submit()
505
+ task_old.wait()
506
+ task_old_path = task_old.stdout().parent
507
+
508
+ # Deprecate and fix
509
+ OldTask.__xpmtype__.deprecate()
510
+ fix_deprecated(xp.workspace.path, fix=True, cleanup=False)
511
+
512
+ # Verify symlink
513
+ _check_symlink_paths(task_new, task_old_path)
514
+
515
+
516
+ # =============================================================================
517
+ # Extended tests for new deprecation mechanism with tasks
518
+ # =============================================================================
519
+
520
+
521
+ class NewTaskWithConvert(Task):
522
+ """New task with different parameter structure."""
523
+
524
+ __xpmid__ = "test.deprecated.task.convert.new"
525
+ values: Param[List[int]]
526
+
527
+ def execute(self):
528
+ pass
529
+
530
+
531
+ @deprecate(NewTaskWithConvert)
532
+ class OldTaskWithConvert(Task):
533
+ """Old task with single value, deprecated to new task with list."""
534
+
535
+ __xpmid__ = "test.deprecated.task.convert.old"
536
+ value: Param[int]
537
+
538
+ def __convert__(self):
539
+ return NewTaskWithConvert.C(values=[self.value])
540
+
541
+ def execute(self):
542
+ pass
543
+
544
+
545
+ def test_task_deprecated_with_convert_identifier():
546
+ """Test deprecated task with __convert__ has correct identifier."""
547
+ with TemporaryExperiment("deprecated_task_convert", maxwait=0):
548
+ # Old task should compute identifier via __convert__
549
+ task_old = OldTaskWithConvert.C(value=42).submit(run_mode=RunMode.DRY_RUN)
550
+ task_new = NewTaskWithConvert.C(values=[42]).submit(run_mode=RunMode.DRY_RUN)
551
+
552
+ # Identifiers should match (computed via __convert__)
553
+ assert (
554
+ task_old.stdout() == task_new.stdout()
555
+ ), "Deprecated task should have same path as equivalent new task"
556
+
557
+
558
+ @deprecate(NewTaskWithConvert, replace=True)
559
+ class ReplacedTaskWithConvert(Task):
560
+ """Old task that gets immediately replaced with new task."""
561
+
562
+ __xpmid__ = "test.deprecated.task.replace.old"
563
+ value: Param[int]
564
+
565
+ def __convert__(self):
566
+ return NewTaskWithConvert.C(values=[self.value])
567
+
568
+ def execute(self):
569
+ pass
570
+
571
+
572
+ def test_task_deprecated_replace_returns_new_type():
573
+ """Test deprecated task with replace=True returns new task type."""
574
+ with TemporaryExperiment("deprecated_task_replace", maxwait=0):
575
+ # Creating old task should return new task
576
+ result = ReplacedTaskWithConvert.C(value=42)
577
+
578
+ # Should be NewTaskWithConvert type
579
+ assert type(result).__name__ == "NewTaskWithConvert.XPMConfig"
580
+ assert result.values == [42]
581
+
582
+ # Submit should work with the new task
583
+ task = result.submit(run_mode=RunMode.DRY_RUN)
584
+
585
+ # Create equivalent new task directly
586
+ task_new = NewTaskWithConvert.C(values=[42]).submit(run_mode=RunMode.DRY_RUN)
587
+
588
+ # Paths should match
589
+ assert task.stdout() == task_new.stdout()
590
+
591
+
592
+ # =============================================================================
593
+ # Attribute warning tests for replaced configs
594
+ # =============================================================================
595
+
596
+
597
+ def test_deprecate_replace_warns_on_old_attribute(caplog):
598
+ """Test that setting attributes that existed on deprecated config warns."""
599
+
600
+ class NewConfigForWarn(Config):
601
+ __xpmid__ = "test.deprecate.replace.warn.new"
602
+ values: Param[list[int]]
603
+
604
+ @deprecate(NewConfigForWarn, replace=True)
605
+ class OldConfigForWarn(Config):
606
+ __xpmid__ = "test.deprecate.replace.warn.old"
607
+ value: Param[int]
608
+ extra: Param[str] = field(
609
+ ignore_default="default"
610
+ ) # This doesn't exist in NewConfigForWarn
611
+
612
+ def __convert__(self):
613
+ return NewConfigForWarn.C(values=[self.value])
614
+
615
+ # Creating OldConfig returns NewConfig
616
+ result = OldConfigForWarn.C(value=42)
617
+ assert type(result).__name__ == "NewConfigForWarn.XPMConfig"
618
+
619
+ # Setting an attribute that only exists on OldConfigForWarn should warn
620
+ import logging
621
+
622
+ with caplog.at_level(logging.WARNING):
623
+ result.extra = "new_value"
624
+
625
+ # Check that warning was logged
626
+ assert any("extra" in record.message for record in caplog.records)
627
+ assert any("deprecated" in record.message.lower() for record in caplog.records)
628
+
629
+ # The attribute should NOT be set on the new config
630
+ assert not hasattr(result, "extra") or getattr(result, "extra", None) is None