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
@@ -1,18 +1,18 @@
1
1
  """Management of the arguments (params, options, etc) associated with the XPM objects"""
2
2
 
3
+ import warnings
3
4
  from typing import Optional, TypeVar, TYPE_CHECKING, Callable, Any
4
5
  from experimaestro.typingutils import get_optional
5
6
  from pathlib import Path
6
- import sys
7
+ from typing import Annotated
7
8
 
8
9
  if TYPE_CHECKING:
9
- from typing_extensions import Annotated
10
10
  import experimaestro.core.types
11
- else:
12
- if sys.version_info.major == 3 and sys.version_info.minor < 9:
13
- from typing_extensions import Annotated
14
- else:
15
- from typing import Annotated
11
+ from experimaestro.core.subparameters import ParameterGroup
12
+
13
+ # Track deprecation warnings per module (max 10 per module)
14
+ _deprecation_warning_counts: dict[str, int] = {}
15
+ _MAX_WARNINGS_PER_MODULE = 10
16
16
 
17
17
 
18
18
  class Argument:
@@ -29,10 +29,12 @@ class Argument:
29
29
  help=None,
30
30
  generator=None,
31
31
  ignored=None,
32
- default=None,
32
+ field_or_default=None,
33
33
  checker=None,
34
34
  constant=False,
35
35
  is_data=False,
36
+ overrides=False,
37
+ groups: set["ParameterGroup"] = None,
36
38
  ):
37
39
  """Creates a new argument
38
40
 
@@ -52,7 +54,8 @@ class Argument:
52
54
  ignored (bool, optional): True if ignored (if None, computed
53
55
  automatically). Defaults to None.
54
56
 
55
- default (any, optional): . Defaults to None.
57
+ field_or_default (any | field, optional): Default value or field
58
+ object containing default information. Defaults to None.
56
59
 
57
60
  checker (any, optional): Value checker. Defaults to None.
58
61
 
@@ -61,9 +64,17 @@ class Argument:
61
64
 
62
65
  is_data (bool, optional): Flag for paths that are data path (to be
63
66
  serialized). Defaults to False.
67
+
68
+ overrides (bool, optional): If True, this argument intentionally
69
+ overrides a parent argument. Suppresses the warning that would
70
+ otherwise be issued. Defaults to False.
71
+
72
+ groups (set[ParameterGroup], optional): Set of groups this parameter
73
+ belongs to. Used with subparameters to compute partial identifiers.
74
+ Defaults to None (empty set).
64
75
  """
65
- required = (default is None) if required is None else required
66
- if default is not None and required is not None and required:
76
+ required = (field_or_default is None) if required is None else required
77
+ if field_or_default is not None and required is not None and required:
67
78
  raise Exception(
68
79
  "argument '%s' is required but default value is given" % name
69
80
  )
@@ -77,19 +88,42 @@ class Argument:
77
88
  self.required = required
78
89
  self.objecttype = None
79
90
  self.is_data = is_data
91
+ self.overrides = overrides
80
92
 
81
93
  self.generator = generator
82
94
  self.default = None
83
-
84
- if default is not None:
85
- assert self.generator is None, "generator and default are exclusive options"
86
- if isinstance(default, field):
87
- if default.default is not None:
88
- self.default = default.default
89
- elif default.default_factory is not None:
90
- self.generator = default.default_factory
95
+ self.ignore_generated = False
96
+ self.ignore_default_in_identifier = False
97
+ self.groups = groups if groups else set()
98
+
99
+ if field_or_default is not None:
100
+ assert (
101
+ self.generator is None
102
+ ), "generator and field_or_default are exclusive options"
103
+ if isinstance(field_or_default, field):
104
+ self.ignore_generated = field_or_default.ignore_generated
105
+ # Allow field to override the overrides flag
106
+ if field_or_default.overrides:
107
+ self.overrides = True
108
+
109
+ # Process groups from field
110
+ if field_or_default.groups:
111
+ self.groups = field_or_default.groups
112
+
113
+ if field_or_default.ignore_default is not None:
114
+ # ignore_default: value is default AND ignored in identifier
115
+ self.default = field_or_default.ignore_default
116
+ self.ignore_default_in_identifier = True
117
+ elif field_or_default.default is not None:
118
+ # default: value is default but NOT ignored in identifier
119
+ self.default = field_or_default.default
120
+ self.ignore_default_in_identifier = False
121
+ elif field_or_default.default_factory is not None:
122
+ self.generator = field_or_default.default_factory
91
123
  else:
92
- self.default = default
124
+ # Bare default: backwards compatible, ignore in identifier
125
+ self.default = field_or_default
126
+ self.ignore_default_in_identifier = True
93
127
 
94
128
  assert (
95
129
  not self.constant or self.default is not None
@@ -134,12 +168,44 @@ class ArgumentOptions:
134
168
  optionaltype = get_optional(typehint)
135
169
  type = Type.fromType(optionaltype or typehint)
136
170
 
137
- if "default" not in self.kwargs or self.kwargs["default"] is None:
171
+ if (
172
+ "field_or_default" not in self.kwargs
173
+ or self.kwargs["field_or_default"] is None
174
+ ):
138
175
  defaultvalue = getattr(originaltype, name, None)
139
- self.kwargs["default"] = defaultvalue
176
+ self.kwargs["field_or_default"] = defaultvalue
177
+
178
+ # Emit deprecation warning for bare default values (not wrapped in field)
179
+ # Skip warning for Constant parameters - they are inherently constant, not defaults
180
+ if (
181
+ defaultvalue is not None
182
+ and not isinstance(defaultvalue, field)
183
+ and not self.kwargs.get("constant")
184
+ ):
185
+ module = originaltype.__module__
186
+ count = _deprecation_warning_counts.get(module, 0)
187
+ if count < _MAX_WARNINGS_PER_MODULE:
188
+ _deprecation_warning_counts[module] = count + 1
189
+ class_name = originaltype.__qualname__
190
+ remaining = _MAX_WARNINGS_PER_MODULE - count - 1
191
+ limit_msg = (
192
+ f" ({remaining} more warnings for this module)"
193
+ if remaining > 0
194
+ else " (no more warnings for this module)"
195
+ )
196
+ warnings.warn(
197
+ f"Deprecated: parameter `{name}` in {module}.{class_name} "
198
+ f"has an ambiguous default value. Use `field(ignore_default=...)` "
199
+ f"to keep current behavior (default ignored in identifier) or "
200
+ f"`field(default=...)` to include default in identifier. "
201
+ f"Run `experimaestro refactor default-values` to fix automatically."
202
+ f"{limit_msg}",
203
+ DeprecationWarning,
204
+ stacklevel=6,
205
+ )
140
206
 
141
207
  self.kwargs["required"] = (optionaltype is None) and (
142
- self.kwargs["default"] is None
208
+ self.kwargs["field_or_default"] is None
143
209
  )
144
210
 
145
211
  return Argument(name, type, **self.kwargs)
@@ -171,26 +237,116 @@ T = TypeVar("T")
171
237
 
172
238
  paramHint = _Param()
173
239
  Param = Annotated[T, paramHint]
240
+ """Type annotation for configuration parameters.
241
+
242
+ Parameters annotated with ``Param[T]`` are included in the configuration
243
+ identifier computation and must be set before the configuration is sealed.
244
+
245
+ Example::
246
+
247
+ class MyConfig(Config):
248
+ name: Param[str]
249
+ count: Param[int] = field(default=10)
250
+ threshold: Param[float] = field(ignore_default=0.5)
251
+ """
174
252
 
175
253
  optionHint = _Param(ignored=True)
176
254
  Option = Annotated[T, optionHint]
255
+ """Deprecated alias for Meta. Use Meta instead."""
256
+
177
257
  Meta = Annotated[T, optionHint]
258
+ """Type annotation for meta-parameters (ignored in identifier computation).
259
+
260
+ Use ``Meta[T]`` for parameters that should not affect the task identity,
261
+ such as output paths or runtime configuration.
262
+
263
+ Example::
264
+
265
+ class MyTask(Task):
266
+ # This affects the task identity
267
+ learning_rate: Param[float]
268
+
269
+ # This does not affect the identity
270
+ checkpoint_path: Meta[Path] = field(default_factory=PathGenerator("model.pt"))
271
+ """
178
272
 
179
273
  dataHint = _Param(ignored=True, is_data=True)
180
274
  DataPath = Annotated[Path, dataHint]
181
- """Annotates a path that should be kept to restore an object to its state"""
275
+ """Type annotation for data paths that should be serialized.
276
+
277
+ Use ``DataPath`` for paths that point to data files that should be
278
+ preserved when serializing/deserializing a configuration. The path
279
+ is copied during serialization.
280
+
281
+ Example::
282
+
283
+ class MyConfig(Config):
284
+ model_weights: DataPath
285
+ """
182
286
 
183
287
 
184
288
  class field:
185
- """Extra information for a given experimaestro field (param or meta)"""
289
+ """Specify additional properties for a configuration parameter.
290
+
291
+ Use ``field()`` to control default value behavior and parameter grouping.
292
+
293
+ Example::
294
+
295
+ class MyConfig(Config):
296
+ # Default included in identifier
297
+ count: Param[int] = field(default=10)
298
+
299
+ # Default ignored in identifier (backwards compatible)
300
+ threshold: Param[float] = field(ignore_default=0.5)
186
301
 
187
- def __init__(self, *, default: Any = None, default_factory: Callable = None):
302
+ # Generated path
303
+ output: Meta[Path] = field(default_factory=PathGenerator("out.txt"))
304
+
305
+ # Parameter in a group (for partial identifiers)
306
+ lr: Param[float] = field(groups=[training_group])
307
+ """
308
+
309
+ def __init__(
310
+ self,
311
+ *,
312
+ default: Any = None,
313
+ default_factory: Callable = None,
314
+ ignore_default: Any = None,
315
+ ignore_generated=False,
316
+ overrides=False,
317
+ groups: list["ParameterGroup"] = None,
318
+ ):
319
+ """Create a field specification.
320
+
321
+ :param default: Default value (included in identifier computation)
322
+ :param default_factory: Callable that generates the default value
323
+ :param ignore_default: Default value that is ignored in identifier computation
324
+ when the actual value equals this default. Use for backwards-compatible
325
+ behavior with bare default values.
326
+ :param ignore_generated: If True, the generated value is hidden from tasks.
327
+ Useful for adding a field that changes the identifier but won't be used.
328
+ :param overrides: If True, suppress warning when overriding parent parameter
329
+ :param groups: List of ParameterGroup objects for partial identifiers.
330
+ Used with subparameters to compute identifiers that exclude certain groups.
331
+ """
188
332
  assert not (
189
333
  (default is not None) and (default_factory is not None)
190
334
  ), "default and default_factory are mutually exclusive options"
191
335
 
336
+ assert not (
337
+ (default is not None) and (ignore_default is not None)
338
+ ), "default and ignore_default are mutually exclusive options"
339
+
340
+ assert not (
341
+ (ignore_default is not None) and (default_factory is not None)
342
+ ), "ignore_default and default_factory are mutually exclusive options"
343
+
192
344
  self.default_factory = default_factory
193
345
  self.default = default
346
+ self.ignore_default = ignore_default
347
+ self.ignore_generated = ignore_generated
348
+ self.overrides = overrides
349
+ self.groups = set(groups) if groups else set()
194
350
 
195
351
 
196
352
  class help(TypeAnnotation):
@@ -201,17 +357,6 @@ class help(TypeAnnotation):
201
357
  options.kwargs["help"] = self.text
202
358
 
203
359
 
204
- class default(TypeAnnotation):
205
- """Adds a default value (useful when we have problems with setattr and class
206
- properties)"""
207
-
208
- def __init__(self, value):
209
- self.value = value
210
-
211
- def annotate(self, options: ArgumentOptions):
212
- options.kwargs["default"] = self.value
213
-
214
-
215
360
  class ConstantHint(TypeAnnotation):
216
361
  def annotate(self, options: ArgumentOptions):
217
362
  options.kwargs["constant"] = True
@@ -219,3 +364,13 @@ class ConstantHint(TypeAnnotation):
219
364
 
220
365
  constantHint = ConstantHint()
221
366
  Constant = Annotated[T, constantHint]
367
+ """Type annotation for constant (read-only) parameters.
368
+
369
+ Constants must have a default value and cannot be modified after creation.
370
+ They are included in the identifier computation.
371
+
372
+ Example::
373
+
374
+ class MyConfig(Config):
375
+ version: Constant[str] = "1.0"
376
+ """
@@ -5,8 +5,11 @@ import hashlib
5
5
  import logging
6
6
  import os
7
7
  import struct
8
- from typing import Optional
9
- from experimaestro.core.objects import Config
8
+ from typing import Optional, TYPE_CHECKING
9
+ from experimaestro.core.objects import Config, ConfigMixin
10
+
11
+ if TYPE_CHECKING:
12
+ from experimaestro.core.subparameters import Subparameters
10
13
 
11
14
 
12
15
  class ConfigPath:
@@ -19,6 +22,9 @@ class ConfigPath:
19
22
  self.config2index = {}
20
23
  """Associate an index in the list with a configuration"""
21
24
 
25
+ self.instance_order: dict[bytes, list[int]] = {}
26
+ """Maps InstanceConfig base identifier to list of object ids in encounter order"""
27
+
22
28
  def detect_loop(self, config) -> Optional[int]:
23
29
  """If there is a loop, return the relative index and update the path"""
24
30
  index = self.config2index.get(id(config), None)
@@ -100,7 +106,15 @@ class Identifier:
100
106
 
101
107
 
102
108
  class IdentifierComputer:
103
- """This class is in charge of computing a config/task identifier"""
109
+ """This class is in charge of computing a config/task identifier
110
+
111
+ Args:
112
+ config: The configuration to compute the identifier for
113
+ config_path: Used to track cycles when computing identifiers
114
+ version: Hash computation version (defaults to XPM_HASH_COMPUTER env var or 2)
115
+ subparameters: If provided, only include parameters that are not excluded
116
+ by this Subparameters instance (for partial identifier computation)
117
+ """
104
118
 
105
119
  OBJECT_ID = b"\x00"
106
120
  INT_ID = b"\x01"
@@ -115,13 +129,22 @@ class IdentifierComputer:
115
129
  ENUM_ID = b"\x0a"
116
130
  CYCLE_REFERENCE = b"\x0b"
117
131
  INIT_TASKS = b"\x0c"
118
-
119
- def __init__(self, config: "Config", config_path: ConfigPath, *, version=None):
132
+ INSTANCE_ID = b"\x0d"
133
+
134
+ def __init__(
135
+ self,
136
+ config: "ConfigMixin",
137
+ config_path: ConfigPath,
138
+ *,
139
+ version=None,
140
+ subparameters: "Subparameters" = None,
141
+ ):
120
142
  # Hasher for parameters
121
143
  self._hasher = hashlib.sha256()
122
144
  self.config = config
123
145
  self.config_path = config_path
124
146
  self.version = version or int(os.environ.get("XPM_HASH_COMPUTER", 2))
147
+ self.subparameters = subparameters
125
148
  if hash_logger.isEnabledFor(logging.DEBUG):
126
149
  hash_logger.debug(
127
150
  "starting hash (%s): %s", hash(str(self.config)), self.config
@@ -170,7 +193,7 @@ class IdentifierComputer:
170
193
  self._hashupdate(IdentifierComputer.ENUM_ID)
171
194
  k = value.__class__
172
195
  self._hashupdate(
173
- f"{k.__module__}.{k.__qualname__ }:{value.name}".encode("utf-8"),
196
+ f"{k.__module__}.{k.__qualname__}:{value.name}".encode("utf-8"),
174
197
  )
175
198
  elif isinstance(value, dict):
176
199
  self._hashupdate(IdentifierComputer.DICT_ID)
@@ -183,7 +206,20 @@ class IdentifierComputer:
183
206
  self.update(value)
184
207
 
185
208
  # Handles configurations
186
- elif isinstance(value, Config):
209
+ elif isinstance(value, ConfigMixin):
210
+ # For deprecated configs with __convert__, compute identifier from converted config
211
+ # This must happen BEFORE hashing OBJECT_ID to ensure identical identifiers
212
+ if myself:
213
+ xpmtype = value.__xpmtype__
214
+ if xpmtype.deprecated and hasattr(value, "__convert__"):
215
+ hash_logger.debug(
216
+ "Computing hash via __convert__ for deprecated %s", xpmtype
217
+ )
218
+ converted = value.__convert__()
219
+ # Delegate entirely to the converted config
220
+ self.update(converted, myself=True)
221
+ return
222
+
187
223
  # Encodes the identifier
188
224
  self._hashupdate(IdentifierComputer.OBJECT_ID)
189
225
 
@@ -201,6 +237,33 @@ class IdentifierComputer:
201
237
  )
202
238
  self._hashupdate(value_id.all)
203
239
 
240
+ # Handle InstanceConfig: add instance order only if duplicate detected
241
+ from experimaestro.core.objects import InstanceConfig
242
+
243
+ if isinstance(value, InstanceConfig):
244
+ # Use the base identifier to track instances
245
+ base_id = value_id.all
246
+ obj_id = id(value)
247
+
248
+ # Track instances with this base identifier
249
+ if base_id not in self.config_path.instance_order:
250
+ # First occurrence - just record it (NO-OP for backwards compatibility)
251
+ self.config_path.instance_order[base_id] = [obj_id]
252
+ else:
253
+ # Check if this specific object was already seen
254
+ instances = self.config_path.instance_order[base_id]
255
+ if obj_id not in instances:
256
+ # New instance with same params - add to list
257
+ instances.append(obj_id)
258
+
259
+ # Get position in the list
260
+ position = instances.index(obj_id)
261
+
262
+ # Only add instance order if not the first (position > 0)
263
+ if position > 0:
264
+ self._hashupdate(IdentifierComputer.INSTANCE_ID)
265
+ self._hashupdate(struct.pack("!q", position))
266
+
204
267
  # And that's it!
205
268
  return
206
269
 
@@ -216,6 +279,11 @@ class IdentifierComputer:
216
279
  # Process arguments (sort by name to ensure uniqueness)
217
280
  arguments = sorted(xpmtype.arguments.values(), key=lambda a: a.name)
218
281
  for argument in arguments:
282
+ # Skip arguments excluded by subparameters (for partial identifiers)
283
+ if self.subparameters is not None:
284
+ if self.subparameters.is_excluded(argument.groups):
285
+ continue
286
+
219
287
  # Ignored argument
220
288
  if argument.ignored:
221
289
  argvalue = value.__xpm__.values.get(argument.name, None)
@@ -234,8 +302,18 @@ class IdentifierComputer:
234
302
  # Argument value
235
303
  # Skip if the argument is not a constant, and
236
304
  # - optional argument: both value and default are None
237
- # - the argument value is equal to the default value
238
- argvalue = getattr(value, argument.name, None)
305
+ # - the argument value is equal to the default value AND
306
+ # ignore_default_in_identifier is True
307
+ try:
308
+ argvalue = getattr(value, argument.name, None)
309
+ except KeyError:
310
+ logging.warning(
311
+ "Parameter %s has not been set in %s created at %s",
312
+ argument.name,
313
+ value,
314
+ value.__xpm__._initinfo,
315
+ )
316
+ raise
239
317
  if not argument.constant and (
240
318
  (
241
319
  not argument.required
@@ -243,7 +321,8 @@ class IdentifierComputer:
243
321
  and argvalue is None
244
322
  )
245
323
  or (
246
- argument.default is not None
324
+ argument.ignore_default_in_identifier
325
+ and argument.default is not None
247
326
  and argument.default == remove_meta(argvalue)
248
327
  )
249
328
  ):
@@ -264,12 +343,17 @@ class IdentifierComputer:
264
343
  self._hashupdate(IdentifierComputer.NAME_ID)
265
344
  self.update(argvalue)
266
345
 
346
+ # Add init tasks
347
+ if value.__xpm__.init_tasks:
348
+ self._hashupdate(IdentifierComputer.INIT_TASKS)
349
+ for init_task in value.__xpm__.init_tasks:
350
+ self.update(init_task)
267
351
  else:
268
352
  raise NotImplementedError("Cannot compute hash of type %s" % type(value))
269
353
 
270
354
  @staticmethod
271
355
  def compute(
272
- config: "Config", config_path: ConfigPath | None = None, version=None
356
+ config: "ConfigMixin", config_path: ConfigPath | None = None, version=None
273
357
  ) -> Identifier:
274
358
  """Compute the identifier for a configuration
275
359
 
@@ -281,7 +365,7 @@ class IdentifierComputer:
281
365
  # Try to use the cached value first
282
366
  # (if there are no loops)
283
367
  if config.__xpm__._sealed:
284
- identifier = config.__xpm__._raw_identifier
368
+ identifier = config.__xpm__._identifier
285
369
  if identifier is not None and not identifier.has_loops:
286
370
  return identifier
287
371
 
@@ -294,3 +378,34 @@ class IdentifierComputer:
294
378
  identifier.has_loops = config_path.has_loop()
295
379
 
296
380
  return identifier
381
+
382
+ @staticmethod
383
+ def compute_partial(
384
+ config: "ConfigMixin",
385
+ subparameters: "Subparameters",
386
+ config_path: ConfigPath | None = None,
387
+ version=None,
388
+ ) -> Identifier:
389
+ """Compute a partial identifier for a configuration
390
+
391
+ A partial identifier excludes certain parameter groups, allowing
392
+ configurations that differ only in those groups to share the same
393
+ partial identifier (and thus the same partial directory).
394
+
395
+ :param config: the configuration for which we compute the identifier
396
+ :param subparameters: the Subparameters instance defining which groups
397
+ to include/exclude
398
+ :param config_path: used to track down cycles between configurations
399
+ :param version: version for the hash computation (None for the last one)
400
+ """
401
+ config_path = config_path or ConfigPath()
402
+
403
+ with config_path.push(config):
404
+ computer = IdentifierComputer(
405
+ config, config_path, version=version, subparameters=subparameters
406
+ )
407
+ computer.update(config, myself=True)
408
+ identifier = computer.identifier()
409
+ identifier.has_loops = config_path.has_loop()
410
+
411
+ return identifier
@@ -2,8 +2,11 @@ from .config_walk import ConfigWalkContext, ConfigWalk
2
2
  from .config import (
3
3
  ConfigMixin,
4
4
  Config,
5
+ InstanceConfig,
5
6
  ConfigInformation,
6
7
  Task,
8
+ TaskStub,
9
+ ResumableTask,
7
10
  LightweightTask,
8
11
  WatchedOutput,
9
12
  DependentMarker,
@@ -25,10 +28,13 @@ from .config_utils import (
25
28
  __all__ = [
26
29
  "ConfigMixin",
27
30
  "Config",
31
+ "InstanceConfig",
28
32
  "ConfigInformation",
29
33
  "ConfigWalkContext",
30
34
  "ConfigWalk",
31
35
  "Task",
36
+ "TaskStub",
37
+ "ResumableTask",
32
38
  "LightweightTask",
33
39
  "ObjectStore",
34
40
  "WatchedOutput",