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
@@ -0,0 +1,249 @@
1
+ """Refactoring commands for experimaestro codebase patterns"""
2
+
3
+ import ast
4
+ import re
5
+ import sys
6
+ from pathlib import Path
7
+ from typing import Iterator
8
+
9
+ import click
10
+ from termcolor import cprint
11
+
12
+
13
+ class DefaultValueFinder(ast.NodeVisitor):
14
+ """AST visitor to find class definitions with Param/Meta/Option annotations"""
15
+
16
+ def __init__(self, source_lines: list[str]):
17
+ self.source_lines = source_lines
18
+ self.findings: list[dict] = []
19
+ self.current_class: str | None = None
20
+
21
+ def visit_ClassDef(self, node: ast.ClassDef):
22
+ old_class = self.current_class
23
+ self.current_class = node.name
24
+
25
+ # Check if this class might be a Config/Task (has annotations)
26
+ for item in node.body:
27
+ if isinstance(item, ast.AnnAssign):
28
+ self._check_annotation(item, node)
29
+
30
+ self.generic_visit(node)
31
+ self.current_class = old_class
32
+
33
+ def _check_annotation(self, node: ast.AnnAssign, class_node: ast.ClassDef):
34
+ """Check if an annotated assignment uses Param/Meta/Option with bare default"""
35
+ if not isinstance(node.target, ast.Name):
36
+ return
37
+
38
+ param_name = node.target.id
39
+
40
+ # Check if annotation is Param[...], Meta[...], or Option[...]
41
+ annotation = node.annotation
42
+ is_param_type = False
43
+
44
+ if isinstance(annotation, ast.Subscript):
45
+ if isinstance(annotation.value, ast.Name):
46
+ if annotation.value.id in ("Param", "Meta", "Option"):
47
+ is_param_type = True
48
+
49
+ if not is_param_type:
50
+ return
51
+
52
+ # Check if there's a default value
53
+ if node.value is None:
54
+ return
55
+
56
+ # Check if the default is already wrapped in field()
57
+ if isinstance(node.value, ast.Call):
58
+ if isinstance(node.value.func, ast.Name):
59
+ if node.value.func.id == "field":
60
+ return # Already using field()
61
+
62
+ # Found a bare default value
63
+ self.findings.append(
64
+ {
65
+ "class_name": self.current_class,
66
+ "param_name": param_name,
67
+ "line": node.lineno,
68
+ "col_offset": node.col_offset,
69
+ "end_line": node.end_lineno,
70
+ "end_col_offset": node.end_col_offset,
71
+ "value_line": node.value.lineno,
72
+ "value_col": node.value.col_offset,
73
+ "value_end_line": node.value.end_lineno,
74
+ "value_end_col": node.value.end_col_offset,
75
+ }
76
+ )
77
+
78
+
79
+ def find_bare_defaults(file_path: Path) -> list[dict]:
80
+ """Find all bare default values in a Python file"""
81
+ try:
82
+ source = file_path.read_text()
83
+ tree = ast.parse(source)
84
+ except (SyntaxError, UnicodeDecodeError):
85
+ return []
86
+
87
+ source_lines = source.splitlines()
88
+ finder = DefaultValueFinder(source_lines)
89
+ finder.visit(tree)
90
+ return finder.findings
91
+
92
+
93
+ def refactor_file(file_path: Path, perform: bool) -> int:
94
+ """Refactor a single file, returns number of changes made/found"""
95
+ findings = find_bare_defaults(file_path)
96
+ if not findings:
97
+ return 0
98
+
99
+ source = file_path.read_text()
100
+ source_lines = source.splitlines(keepends=True)
101
+
102
+ # Sort findings by line number in reverse order (to not mess up offsets)
103
+ findings.sort(key=lambda f: (f["line"], f["col_offset"]), reverse=True)
104
+
105
+ changes_made = 0
106
+ for finding in findings:
107
+ class_name = finding["class_name"]
108
+ param_name = finding["param_name"]
109
+ line_num = finding["line"]
110
+
111
+ # Get the line content
112
+ line_idx = line_num - 1
113
+ if line_idx >= len(source_lines):
114
+ continue
115
+
116
+ line = source_lines[line_idx]
117
+
118
+ # Try to find and replace the pattern on this line
119
+ # Pattern: `param_name: Param[...] = value` -> `param_name: Param[...] = field(ignore_default=value)`
120
+ # We need to be careful with multi-line values
121
+
122
+ # Simple case: value is on the same line
123
+ if finding["value_line"] == finding["value_end_line"] == line_num:
124
+ # Extract the value part
125
+ value_start = finding["value_col"]
126
+ value_end = finding["value_end_col"]
127
+
128
+ # Get the original value string
129
+ original_value = line[value_start:value_end]
130
+
131
+ # Create the replacement
132
+ new_value = f"field(ignore_default={original_value})"
133
+
134
+ # Replace in the line
135
+ new_line = line[:value_start] + new_value + line[value_end:]
136
+ source_lines[line_idx] = new_line
137
+
138
+ if perform:
139
+ cprint(
140
+ f" {file_path}:{line_num}: {class_name}.{param_name} = {original_value} "
141
+ f"-> field(ignore_default={original_value})",
142
+ "green",
143
+ )
144
+ else:
145
+ cprint(
146
+ f" {file_path}:{line_num}: {class_name}.{param_name} = {original_value} "
147
+ f"-> field(ignore_default={original_value})",
148
+ "yellow",
149
+ )
150
+
151
+ changes_made += 1
152
+ else:
153
+ # Multi-line value - more complex handling needed
154
+ # For now, just report it
155
+ cprint(
156
+ f" {file_path}:{line_num}: {class_name}.{param_name} has multi-line default "
157
+ f"(manual fix required)",
158
+ "red",
159
+ )
160
+ changes_made += 1
161
+
162
+ if perform and changes_made > 0:
163
+ # Check if we need to add 'field' import
164
+ new_source = "".join(source_lines)
165
+
166
+ # Simple check for field import
167
+ if "from experimaestro" in new_source or "import experimaestro" in new_source:
168
+ # Check if field is already imported
169
+ if not re.search(
170
+ r"from\s+experimaestro[^\n]*\bfield\b", new_source
171
+ ) and not re.search(
172
+ r"from\s+experimaestro\.core\.arguments[^\n]*\bfield\b", new_source
173
+ ):
174
+ # Try to add field to existing import
175
+ new_source = re.sub(
176
+ r"(from\s+experimaestro\s+import\s+)([^\n]+)",
177
+ r"\1field, \2",
178
+ new_source,
179
+ count=1,
180
+ )
181
+
182
+ file_path.write_text(new_source)
183
+
184
+ return changes_made
185
+
186
+
187
+ def find_python_files(path: Path) -> Iterator[Path]:
188
+ """Find all Python files in a directory"""
189
+ if path.is_file():
190
+ if path.suffix == ".py":
191
+ yield path
192
+ else:
193
+ for py_file in path.rglob("*.py"):
194
+ # Skip common directories
195
+ parts = py_file.parts
196
+ if any(
197
+ p in parts
198
+ for p in ("__pycache__", ".git", ".venv", "venv", "node_modules")
199
+ ):
200
+ continue
201
+ yield py_file
202
+
203
+
204
+ @click.group()
205
+ def refactor():
206
+ """Refactor codebase patterns"""
207
+ pass
208
+
209
+
210
+ @refactor.command(name="default-values")
211
+ @click.option(
212
+ "--perform",
213
+ is_flag=True,
214
+ help="Perform the refactoring (default is dry-run)",
215
+ )
216
+ @click.argument("path", type=click.Path(exists=True, path_type=Path), default=".")
217
+ def default_values(path: Path, perform: bool):
218
+ """Fix ambiguous default values in configuration files.
219
+
220
+ Converts `x: Param[int] = 23` to `x: Param[int] = field(ignore_default=23)`
221
+ to make the behavior explicit.
222
+
223
+ By default runs in dry-run mode. Use --perform to apply changes.
224
+ """
225
+ if not perform:
226
+ cprint("DRY RUN MODE: No changes will be written", "yellow")
227
+ cprint("Use --perform to apply changes\n", "yellow")
228
+
229
+ total_changes = 0
230
+ files_with_changes = 0
231
+
232
+ for py_file in find_python_files(path):
233
+ changes = refactor_file(py_file, perform)
234
+ if changes > 0:
235
+ total_changes += changes
236
+ files_with_changes += 1
237
+
238
+ if total_changes == 0:
239
+ cprint("\nNo bare default values found.", "green")
240
+ else:
241
+ action = "Fixed" if perform else "Found"
242
+ cprint(
243
+ f"\n{action} {total_changes} bare default value(s) in {files_with_changes} file(s).",
244
+ "green" if perform else "yellow",
245
+ )
246
+ if not perform:
247
+ cprint("Run with --perform to apply changes.", "yellow")
248
+
249
+ sys.exit(0 if perform or total_changes == 0 else 1)
experimaestro/click.py CHANGED
@@ -37,7 +37,6 @@ class forwardoption(metaclass=forwardoptionMetaclass):
37
37
  name = "--%s" % (option_name or argument.name.replace("_", "-"))
38
38
  default = kwargs["default"] if "default" in kwargs else argument.default
39
39
 
40
- # TODO: set the type of the option when not a simple type
41
40
  return click.option(name, help=argument.help or "", default=default)
42
41
 
43
42
  def __getattr__(self, key):
@@ -232,9 +232,14 @@ class CommandLineJob(Job):
232
232
  workspace: Optional[Workspace] = None,
233
233
  launcher=None,
234
234
  run_mode: RunMode = None,
235
+ max_retries=None,
235
236
  ):
236
237
  super().__init__(
237
- parameters, workspace=workspace, launcher=launcher, run_mode=run_mode
238
+ parameters,
239
+ workspace=workspace,
240
+ launcher=launcher,
241
+ run_mode=run_mode,
242
+ max_retries=max_retries,
238
243
  )
239
244
  self.commandline = commandline
240
245
 
@@ -300,7 +305,11 @@ class CommandLineJob(Job):
300
305
  self._process = processbuilder.start(True)
301
306
 
302
307
  with self.pidpath.open("w") as fp:
303
- json.dump(self._process.tospec(), fp)
308
+ process_spec = self._process.tospec()
309
+ json.dump(process_spec, fp)
310
+
311
+ # Write process spec to metadata (contains launcher type, job ID, etc.)
312
+ self.write_metadata(process=process_spec)
304
313
 
305
314
  self.state = JobState.RUNNING
306
315
  logger.info("Process started (%s)", self._process)
@@ -312,7 +321,13 @@ class CommandLineTask:
312
321
  self.commandline = commandline
313
322
 
314
323
  def __call__(
315
- self, pyobject, *, launcher=None, workspace=None, run_mode=None
324
+ self,
325
+ pyobject,
326
+ *,
327
+ launcher=None,
328
+ workspace=None,
329
+ run_mode=None,
330
+ max_retries=None,
316
331
  ) -> Job:
317
332
  return CommandLineJob(
318
333
  self.commandline,
@@ -320,4 +335,5 @@ class CommandLineTask:
320
335
  launcher=launcher,
321
336
  workspace=workspace,
322
337
  run_mode=run_mode,
338
+ max_retries=max_retries,
323
339
  )
@@ -10,7 +10,7 @@ This module contains :
10
10
 
11
11
  import enum
12
12
  import logging
13
- from typing import Any, Dict, Mapping, Type, Union, Optional
13
+ from typing import Any, Dict, Mapping, Type, Union, Optional, TYPE_CHECKING
14
14
  from pathlib import Path
15
15
  from experimaestro.utils import logger
16
16
  from experimaestro.locking import Lock
@@ -18,6 +18,9 @@ from experimaestro.tokens import Token
18
18
  from experimaestro.utils.asyncio import asyncThreadcheck
19
19
  from importlib.metadata import entry_points
20
20
 
21
+ if TYPE_CHECKING:
22
+ from experimaestro.scheduler.jobs import JobState
23
+
21
24
 
22
25
  class RedirectType(enum.Enum):
23
26
  INHERIT = 0
@@ -135,6 +138,22 @@ class Process:
135
138
  logger.debug("Got return code %s for %s", code, self)
136
139
  return code
137
140
 
141
+ def get_job_state(self, code: int) -> "JobState":
142
+ """Convert a process exit code to a JobState
143
+
144
+ This method allows process implementations to provide additional
145
+ information about failures (e.g., timeout detection for SLURM jobs).
146
+
147
+ Args:
148
+ code: The process exit code
149
+
150
+ Returns:
151
+ JobState instance (JobState.DONE for success, JobStateError for failures)
152
+ """
153
+ from experimaestro.scheduler.jobs import JobState
154
+
155
+ return JobState.DONE if code == 0 else JobState.ERROR
156
+
138
157
  def kill(self):
139
158
  raise NotImplementedError(f"Not implemented: {self.__class__}.kill")
140
159
 
@@ -163,6 +163,18 @@ class InterProcessLock(fasteners.InterProcessLock, Lock):
163
163
 
164
164
 
165
165
  class LocalConnector(Connector):
166
+ """Connector for executing tasks on the local machine.
167
+
168
+ This connector handles local file system operations and process execution.
169
+ It is the default connector used when no remote execution is needed.
170
+
171
+ Use :meth:`instance` to get a singleton instance of the local connector.
172
+
173
+ :param localpath: Base path for experimaestro data. Defaults to
174
+ ``~/.local/share/experimaestro`` or the value of ``XPM_WORKDIR``
175
+ environment variable.
176
+ """
177
+
166
178
  INSTANCE: Connector = None
167
179
 
168
180
  @staticmethod