experimaestro 2.0.0a8__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 (116) hide show
  1. experimaestro/__init__.py +10 -11
  2. experimaestro/annotations.py +167 -206
  3. experimaestro/cli/__init__.py +130 -5
  4. experimaestro/cli/filter.py +42 -74
  5. experimaestro/cli/jobs.py +157 -106
  6. experimaestro/cli/refactor.py +249 -0
  7. experimaestro/click.py +0 -1
  8. experimaestro/commandline.py +19 -3
  9. experimaestro/connectors/__init__.py +20 -1
  10. experimaestro/connectors/local.py +12 -0
  11. experimaestro/core/arguments.py +182 -46
  12. experimaestro/core/identifier.py +107 -6
  13. experimaestro/core/objects/__init__.py +6 -0
  14. experimaestro/core/objects/config.py +542 -25
  15. experimaestro/core/objects/config_walk.py +20 -0
  16. experimaestro/core/serialization.py +91 -34
  17. experimaestro/core/subparameters.py +164 -0
  18. experimaestro/core/types.py +175 -38
  19. experimaestro/exceptions.py +26 -0
  20. experimaestro/experiments/cli.py +107 -25
  21. experimaestro/generators.py +50 -9
  22. experimaestro/huggingface.py +3 -1
  23. experimaestro/launcherfinder/parser.py +29 -0
  24. experimaestro/launchers/__init__.py +26 -1
  25. experimaestro/launchers/direct.py +12 -0
  26. experimaestro/launchers/slurm/base.py +154 -2
  27. experimaestro/mkdocs/metaloader.py +0 -1
  28. experimaestro/mypy.py +452 -7
  29. experimaestro/notifications.py +63 -13
  30. experimaestro/progress.py +0 -2
  31. experimaestro/rpyc.py +0 -1
  32. experimaestro/run.py +19 -6
  33. experimaestro/scheduler/base.py +489 -125
  34. experimaestro/scheduler/dependencies.py +43 -28
  35. experimaestro/scheduler/dynamic_outputs.py +259 -130
  36. experimaestro/scheduler/experiment.py +225 -30
  37. experimaestro/scheduler/interfaces.py +474 -0
  38. experimaestro/scheduler/jobs.py +216 -206
  39. experimaestro/scheduler/services.py +186 -12
  40. experimaestro/scheduler/state_db.py +388 -0
  41. experimaestro/scheduler/state_provider.py +2345 -0
  42. experimaestro/scheduler/state_sync.py +834 -0
  43. experimaestro/scheduler/workspace.py +52 -10
  44. experimaestro/scriptbuilder.py +7 -0
  45. experimaestro/server/__init__.py +147 -57
  46. experimaestro/server/data/index.css +0 -125
  47. experimaestro/server/data/index.css.map +1 -1
  48. experimaestro/server/data/index.js +194 -58
  49. experimaestro/server/data/index.js.map +1 -1
  50. experimaestro/settings.py +44 -5
  51. experimaestro/sphinx/__init__.py +3 -3
  52. experimaestro/taskglobals.py +20 -0
  53. experimaestro/tests/conftest.py +80 -0
  54. experimaestro/tests/core/test_generics.py +2 -2
  55. experimaestro/tests/identifier_stability.json +45 -0
  56. experimaestro/tests/launchers/bin/sacct +6 -2
  57. experimaestro/tests/launchers/bin/sbatch +4 -2
  58. experimaestro/tests/launchers/test_slurm.py +80 -0
  59. experimaestro/tests/tasks/test_dynamic.py +231 -0
  60. experimaestro/tests/test_cli_jobs.py +615 -0
  61. experimaestro/tests/test_deprecated.py +630 -0
  62. experimaestro/tests/test_environment.py +200 -0
  63. experimaestro/tests/test_file_progress_integration.py +1 -1
  64. experimaestro/tests/test_forward.py +3 -3
  65. experimaestro/tests/test_identifier.py +372 -41
  66. experimaestro/tests/test_identifier_stability.py +458 -0
  67. experimaestro/tests/test_instance.py +3 -3
  68. experimaestro/tests/test_multitoken.py +442 -0
  69. experimaestro/tests/test_mypy.py +433 -0
  70. experimaestro/tests/test_objects.py +312 -5
  71. experimaestro/tests/test_outputs.py +2 -2
  72. experimaestro/tests/test_param.py +8 -12
  73. experimaestro/tests/test_partial_paths.py +231 -0
  74. experimaestro/tests/test_progress.py +0 -48
  75. experimaestro/tests/test_resumable_task.py +480 -0
  76. experimaestro/tests/test_serializers.py +141 -1
  77. experimaestro/tests/test_state_db.py +434 -0
  78. experimaestro/tests/test_subparameters.py +160 -0
  79. experimaestro/tests/test_tags.py +136 -0
  80. experimaestro/tests/test_tasks.py +107 -121
  81. experimaestro/tests/test_token_locking.py +252 -0
  82. experimaestro/tests/test_tokens.py +17 -13
  83. experimaestro/tests/test_types.py +123 -1
  84. experimaestro/tests/test_workspace_triggers.py +158 -0
  85. experimaestro/tests/token_reschedule.py +4 -2
  86. experimaestro/tests/utils.py +2 -2
  87. experimaestro/tokens.py +154 -57
  88. experimaestro/tools/diff.py +1 -1
  89. experimaestro/tui/__init__.py +8 -0
  90. experimaestro/tui/app.py +2303 -0
  91. experimaestro/tui/app.tcss +353 -0
  92. experimaestro/tui/log_viewer.py +228 -0
  93. experimaestro/utils/__init__.py +23 -0
  94. experimaestro/utils/environment.py +148 -0
  95. experimaestro/utils/git.py +129 -0
  96. experimaestro/utils/resources.py +1 -1
  97. experimaestro/version.py +34 -0
  98. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b4.dist-info}/METADATA +68 -38
  99. experimaestro-2.0.0b4.dist-info/RECORD +181 -0
  100. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b4.dist-info}/WHEEL +1 -1
  101. experimaestro-2.0.0b4.dist-info/entry_points.txt +16 -0
  102. experimaestro/compat.py +0 -6
  103. experimaestro/core/objects.pyi +0 -221
  104. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  105. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  106. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  107. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  108. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  109. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  110. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  111. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  112. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  113. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  114. experimaestro-2.0.0a8.dist-info/RECORD +0 -166
  115. experimaestro-2.0.0a8.dist-info/entry_points.txt +0 -17
  116. {experimaestro-2.0.0a8.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,22 +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
95
  self.ignore_generated = False
84
-
85
- if default is not None:
86
- assert self.generator is None, "generator and default are exclusive options"
87
- if isinstance(default, field):
88
- self.ignore_generated = default.ignore_generated
89
-
90
- if default.default is not None:
91
- self.default = default.default
92
- elif default.default_factory is not None:
93
- self.generator = default.default_factory
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
94
123
  else:
95
- self.default = default
124
+ # Bare default: backwards compatible, ignore in identifier
125
+ self.default = field_or_default
126
+ self.ignore_default_in_identifier = True
96
127
 
97
128
  assert (
98
129
  not self.constant or self.default is not None
@@ -137,12 +168,44 @@ class ArgumentOptions:
137
168
  optionaltype = get_optional(typehint)
138
169
  type = Type.fromType(optionaltype or typehint)
139
170
 
140
- 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
+ ):
141
175
  defaultvalue = getattr(originaltype, name, None)
142
- 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
+ )
143
206
 
144
207
  self.kwargs["required"] = (optionaltype is None) and (
145
- self.kwargs["default"] is None
208
+ self.kwargs["field_or_default"] is None
146
209
  )
147
210
 
148
211
  return Argument(name, type, **self.kwargs)
@@ -174,42 +237,116 @@ T = TypeVar("T")
174
237
 
175
238
  paramHint = _Param()
176
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
+ """
177
252
 
178
253
  optionHint = _Param(ignored=True)
179
254
  Option = Annotated[T, optionHint]
255
+ """Deprecated alias for Meta. Use Meta instead."""
256
+
180
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
+ """
181
272
 
182
273
  dataHint = _Param(ignored=True, is_data=True)
183
274
  DataPath = Annotated[Path, dataHint]
184
- """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
+ """
185
286
 
186
287
 
187
288
  class field:
188
- """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)
301
+
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
+ """
189
308
 
190
309
  def __init__(
191
310
  self,
192
311
  *,
193
312
  default: Any = None,
194
313
  default_factory: Callable = None,
314
+ ignore_default: Any = None,
195
315
  ignore_generated=False,
316
+ overrides=False,
317
+ groups: list["ParameterGroup"] = None,
196
318
  ):
197
- """Gives some extra per-field information
198
-
199
- :param default: a default value, defaults to None
200
- :param default_factory: a default factory for values, defaults to None
201
- :param ignore_generated: True if the value is hidden it won't be accessible in
202
- tasks, defaults to False. The interest of hidden is to add a
203
- configuration field that changes the identifier, but will not be
204
- used.
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.
205
331
  """
206
332
  assert not (
207
333
  (default is not None) and (default_factory is not None)
208
334
  ), "default and default_factory are mutually exclusive options"
209
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
+
210
344
  self.default_factory = default_factory
211
345
  self.default = default
346
+ self.ignore_default = ignore_default
212
347
  self.ignore_generated = ignore_generated
348
+ self.overrides = overrides
349
+ self.groups = set(groups) if groups else set()
213
350
 
214
351
 
215
352
  class help(TypeAnnotation):
@@ -220,17 +357,6 @@ class help(TypeAnnotation):
220
357
  options.kwargs["help"] = self.text
221
358
 
222
359
 
223
- class default(TypeAnnotation):
224
- """Adds a default value (useful when we have problems with setattr and class
225
- properties)"""
226
-
227
- def __init__(self, value):
228
- self.value = value
229
-
230
- def annotate(self, options: ArgumentOptions):
231
- options.kwargs["default"] = self.value
232
-
233
-
234
360
  class ConstantHint(TypeAnnotation):
235
361
  def annotate(self, options: ArgumentOptions):
236
362
  options.kwargs["constant"] = True
@@ -238,3 +364,13 @@ class ConstantHint(TypeAnnotation):
238
364
 
239
365
  constantHint = ConstantHint()
240
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,9 +5,12 @@ import hashlib
5
5
  import logging
6
6
  import os
7
7
  import struct
8
- from typing import Optional
8
+ from typing import Optional, TYPE_CHECKING
9
9
  from experimaestro.core.objects import Config, ConfigMixin
10
10
 
11
+ if TYPE_CHECKING:
12
+ from experimaestro.core.subparameters import Subparameters
13
+
11
14
 
12
15
  class ConfigPath:
13
16
  """Used to keep track of cycles when computing a hash"""
@@ -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: "ConfigMixin", 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
@@ -184,6 +207,19 @@ class IdentifierComputer:
184
207
 
185
208
  # Handles configurations
186
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,7 +302,8 @@ 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
305
+ # - the argument value is equal to the default value AND
306
+ # ignore_default_in_identifier is True
238
307
  try:
239
308
  argvalue = getattr(value, argument.name, None)
240
309
  except KeyError:
@@ -252,7 +321,8 @@ class IdentifierComputer:
252
321
  and argvalue is None
253
322
  )
254
323
  or (
255
- argument.default is not None
324
+ argument.ignore_default_in_identifier
325
+ and argument.default is not None
256
326
  and argument.default == remove_meta(argvalue)
257
327
  )
258
328
  ):
@@ -308,3 +378,34 @@ class IdentifierComputer:
308
378
  identifier.has_loops = config_path.has_loop()
309
379
 
310
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",