experimaestro 2.0.0a8__py3-none-any.whl → 2.0.0b8__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 (122) hide show
  1. experimaestro/__init__.py +10 -11
  2. experimaestro/annotations.py +167 -206
  3. experimaestro/cli/__init__.py +278 -7
  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 +111 -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 +510 -125
  34. experimaestro/scheduler/dependencies.py +43 -28
  35. experimaestro/scheduler/dynamic_outputs.py +259 -130
  36. experimaestro/scheduler/experiment.py +256 -31
  37. experimaestro/scheduler/interfaces.py +501 -0
  38. experimaestro/scheduler/jobs.py +216 -206
  39. experimaestro/scheduler/remote/__init__.py +31 -0
  40. experimaestro/scheduler/remote/client.py +874 -0
  41. experimaestro/scheduler/remote/protocol.py +467 -0
  42. experimaestro/scheduler/remote/server.py +423 -0
  43. experimaestro/scheduler/remote/sync.py +144 -0
  44. experimaestro/scheduler/services.py +323 -23
  45. experimaestro/scheduler/state_db.py +437 -0
  46. experimaestro/scheduler/state_provider.py +2766 -0
  47. experimaestro/scheduler/state_sync.py +891 -0
  48. experimaestro/scheduler/workspace.py +52 -10
  49. experimaestro/scriptbuilder.py +7 -0
  50. experimaestro/server/__init__.py +147 -57
  51. experimaestro/server/data/index.css +0 -125
  52. experimaestro/server/data/index.css.map +1 -1
  53. experimaestro/server/data/index.js +194 -58
  54. experimaestro/server/data/index.js.map +1 -1
  55. experimaestro/settings.py +44 -5
  56. experimaestro/sphinx/__init__.py +3 -3
  57. experimaestro/taskglobals.py +20 -0
  58. experimaestro/tests/conftest.py +80 -0
  59. experimaestro/tests/core/test_generics.py +2 -2
  60. experimaestro/tests/identifier_stability.json +45 -0
  61. experimaestro/tests/launchers/bin/sacct +6 -2
  62. experimaestro/tests/launchers/bin/sbatch +4 -2
  63. experimaestro/tests/launchers/test_slurm.py +80 -0
  64. experimaestro/tests/tasks/test_dynamic.py +231 -0
  65. experimaestro/tests/test_cli_jobs.py +615 -0
  66. experimaestro/tests/test_deprecated.py +630 -0
  67. experimaestro/tests/test_environment.py +200 -0
  68. experimaestro/tests/test_file_progress_integration.py +1 -1
  69. experimaestro/tests/test_forward.py +3 -3
  70. experimaestro/tests/test_identifier.py +372 -41
  71. experimaestro/tests/test_identifier_stability.py +458 -0
  72. experimaestro/tests/test_instance.py +3 -3
  73. experimaestro/tests/test_multitoken.py +442 -0
  74. experimaestro/tests/test_mypy.py +433 -0
  75. experimaestro/tests/test_objects.py +312 -5
  76. experimaestro/tests/test_outputs.py +2 -2
  77. experimaestro/tests/test_param.py +8 -12
  78. experimaestro/tests/test_partial_paths.py +231 -0
  79. experimaestro/tests/test_progress.py +0 -48
  80. experimaestro/tests/test_remote_state.py +671 -0
  81. experimaestro/tests/test_resumable_task.py +480 -0
  82. experimaestro/tests/test_serializers.py +141 -1
  83. experimaestro/tests/test_state_db.py +434 -0
  84. experimaestro/tests/test_subparameters.py +160 -0
  85. experimaestro/tests/test_tags.py +136 -0
  86. experimaestro/tests/test_tasks.py +107 -121
  87. experimaestro/tests/test_token_locking.py +252 -0
  88. experimaestro/tests/test_tokens.py +17 -13
  89. experimaestro/tests/test_types.py +123 -1
  90. experimaestro/tests/test_workspace_triggers.py +158 -0
  91. experimaestro/tests/token_reschedule.py +4 -2
  92. experimaestro/tests/utils.py +2 -2
  93. experimaestro/tokens.py +154 -57
  94. experimaestro/tools/diff.py +1 -1
  95. experimaestro/tui/__init__.py +8 -0
  96. experimaestro/tui/app.py +2395 -0
  97. experimaestro/tui/app.tcss +353 -0
  98. experimaestro/tui/log_viewer.py +228 -0
  99. experimaestro/utils/__init__.py +23 -0
  100. experimaestro/utils/environment.py +148 -0
  101. experimaestro/utils/git.py +129 -0
  102. experimaestro/utils/resources.py +1 -1
  103. experimaestro/version.py +34 -0
  104. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b8.dist-info}/METADATA +68 -38
  105. experimaestro-2.0.0b8.dist-info/RECORD +187 -0
  106. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b8.dist-info}/WHEEL +1 -1
  107. experimaestro-2.0.0b8.dist-info/entry_points.txt +16 -0
  108. experimaestro/compat.py +0 -6
  109. experimaestro/core/objects.pyi +0 -221
  110. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  111. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  112. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  113. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  114. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  115. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  116. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  117. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  118. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  119. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  120. experimaestro-2.0.0a8.dist-info/RECORD +0 -166
  121. experimaestro-2.0.0a8.dist-info/entry_points.txt +0 -17
  122. {experimaestro-2.0.0a8.dist-info → experimaestro-2.0.0b8.dist-info}/licenses/LICENSE +0 -0
experimaestro/cli/jobs.py CHANGED
@@ -1,8 +1,6 @@
1
1
  # flake8: noqa: T201
2
- import asyncio
3
2
  import subprocess
4
3
  from typing import Optional
5
- from shutil import rmtree
6
4
  import click
7
5
  from pathlib import Path
8
6
  from termcolor import colored, cprint
@@ -34,6 +32,9 @@ def jobs(
34
32
  selects jobs where the tag model is "bm25", the tag mode is either
35
33
  "a" or "b", and the state is running.
36
34
 
35
+ Note: Jobs are read from the workspace database. If jobs are missing,
36
+ run 'experimaestro experiments sync' to synchronize the database
37
+ with the filesystem.
37
38
  """
38
39
  ws = ctx.obj.workspace = find_workspace(workdir=workdir, workspace=workspace)
39
40
  check_xp_path(ctx, None, ws.path)
@@ -44,117 +45,117 @@ def process(
44
45
  *,
45
46
  experiment="",
46
47
  tags="",
47
- ready=False,
48
48
  clean=False,
49
49
  kill=False,
50
50
  filter="",
51
51
  perform=False,
52
52
  fullpath=False,
53
- check=False,
53
+ count=0,
54
54
  ):
55
- from .filter import createFilter, JobInformation
55
+ """Process jobs from the workspace database
56
+
57
+ Args:
58
+ workspace: Workspace settings
59
+ experiment: Filter by experiment ID
60
+ tags: Show tags in output
61
+ clean: Clean finished jobs
62
+ kill: Kill running jobs
63
+ filter: Filter expression
64
+ perform: Actually perform kill/clean (dry run if False)
65
+ fullpath: Show full paths instead of short names
66
+ count: Limit output to N most recent jobs (0 = no limit)
67
+ """
68
+ from .filter import createFilter
69
+ from experimaestro.scheduler.state_provider import WorkspaceStateProvider
56
70
  from experimaestro.scheduler import JobState
57
71
 
58
- _filter = createFilter(filter) if filter else lambda x: True
59
-
60
- # Get all jobs from experiments
61
- job2xp = {}
62
-
63
- path = workspace.path
64
- for p in (path / "xp").glob("*"):
65
- for job in p.glob("jobs/*/*"):
66
- job_path = job.resolve()
67
- if job_path.is_dir():
68
- job2xp.setdefault(job_path.name, set()).add(p.name)
69
-
70
- if (p / "jobs.bak").is_dir():
71
- cprint(f" Experiment {p.name} has not finished yet", "red")
72
- if (not perform) and (kill or clean):
73
- cprint(
74
- " Preventing kill/clean (use --perform if you want to)", "yellow"
75
- )
76
- kill = False
77
- clean = False
78
-
79
- # Now, process jobs
80
- for job in path.glob("jobs/*/*"):
81
- info = None
82
- p = job.resolve()
83
- if p.is_dir():
84
- *_, scriptname = p.parent.name.rsplit(".", 1)
85
- xps = job2xp.get(job.name, set())
86
- if experiment and experiment not in xps:
87
- continue
88
-
89
- info = JobInformation(p, scriptname, check=check)
90
- job_str = (
91
- (str(job.resolve()) if fullpath else f"{job.parent.name}/{job.name}")
92
- + " "
93
- + ",".join(xps)
94
- )
95
-
96
- if filter:
97
- if not _filter(info):
98
- continue
99
-
100
- if info.state is None:
101
- print(colored(f"NODIR {job_str}", "red"), end="")
102
- elif info.state.running():
72
+ _filter = createFilter(filter) if filter else None
73
+
74
+ # Get state provider (write mode for kill/clean operations)
75
+ read_only = not (kill or clean)
76
+ provider = WorkspaceStateProvider.get_instance(workspace.path, read_only=read_only)
77
+
78
+ try:
79
+ # Get all jobs from the database
80
+ all_jobs = provider.get_all_jobs()
81
+
82
+ # Filter by experiment if specified
83
+ if experiment:
84
+ all_jobs = [j for j in all_jobs if j.experiment_id == experiment]
85
+
86
+ # Apply filter expression
87
+ if _filter:
88
+ all_jobs = [j for j in all_jobs if _filter(j)]
89
+
90
+ # Sort by submission time (most recent first)
91
+ # Jobs without submittime go to the end
92
+ all_jobs.sort(key=lambda j: j.submittime or 0, reverse=True)
93
+
94
+ # Limit to N most recent jobs if count is specified
95
+ if count > 0:
96
+ all_jobs = all_jobs[:count]
97
+
98
+ if not all_jobs:
99
+ cprint("No jobs found.", "yellow")
100
+ return
101
+
102
+ # Process each job
103
+ for job in all_jobs:
104
+ job_str = str(job.path) if fullpath else f"{job.task_id}/{job.identifier}"
105
+
106
+ # Add experiment info
107
+ if job.experiment_id:
108
+ job_str += f" [{job.experiment_id}]"
109
+
110
+ if job.state is None or job.state == JobState.UNSCHEDULED:
111
+ print(colored(f"UNSCHED {job_str}", "red"), end="")
112
+ elif job.state.running():
103
113
  if kill:
104
114
  if perform:
105
- process = info.getprocess()
106
- if process is None:
107
- cprint(
108
- "internal error – no process could be retrieved",
109
- "red",
110
- )
115
+ if provider.kill_job(job, perform=True):
116
+ cprint(f"KILLED {job_str}", "light_red")
111
117
  else:
112
- cprint(f"KILLING {process}", "light_red")
113
- process.kill()
118
+ cprint(f"KILL FAILED {job_str}", "red")
114
119
  else:
115
- print("KILLING (not performing)", process)
116
- print(
117
- colored(f"{info.state.name:8}{job_str}", "yellow"),
118
- end="",
119
- )
120
- elif info.state == JobState.DONE:
121
- print(
122
- colored(f"DONE {job_str}", "green"),
123
- end="",
124
- )
125
- elif info.state == JobState.ERROR:
120
+ cprint(f"KILLING {job_str} (dry run)", "yellow")
121
+ else:
122
+ print(colored(f"{job.state.name:8}{job_str}", "yellow"), end="")
123
+ elif job.state == JobState.DONE:
124
+ print(colored(f"DONE {job_str}", "green"), end="")
125
+ elif job.state == JobState.ERROR:
126
126
  print(colored(f"FAIL {job_str}", "red"), end="")
127
127
  else:
128
- print(
129
- colored(f"{info.state.name:8}{job_str}", "red"),
130
- end="",
131
- )
128
+ print(colored(f"{job.state.name:8}{job_str}", "red"), end="")
132
129
 
133
- else:
134
- if not ready:
135
- continue
136
- print(colored(f"READY {job_path}", "yellow"), end="")
130
+ # Show tags if requested
131
+ if tags and job.tags:
132
+ print(f""" {" ".join(f"{k}={v}" for k, v in job.tags.items())}""")
133
+ elif not (kill and perform):
134
+ print()
137
135
 
138
- if tags:
139
- print(f""" {" ".join(f"{k}={v}" for k, v in info.tags.items())}""")
140
- else:
141
- print()
136
+ # Clean finished jobs
137
+ if clean and job.state and job.state.finished():
138
+ if perform:
139
+ if provider.clean_job(job, perform=True):
140
+ cprint(" Cleaned", "red")
141
+ else:
142
+ cprint(" Clean failed", "red")
143
+ else:
144
+ cprint(" Would clean (dry run)", "yellow")
142
145
 
143
- if clean and info.state and info.state.finished():
144
- if perform:
145
- cprint("Cleaning...", "red")
146
- rmtree(p)
147
- else:
148
- cprint("Cleaning... (not performed)", "red")
149
- print()
146
+ print()
147
+
148
+ finally:
149
+ # Close provider if we created it for write mode
150
+ if not read_only:
151
+ provider.close()
150
152
 
151
153
 
152
154
  @click.option("--experiment", default=None, help="Restrict to this experiment")
153
155
  @click.option("--tags", is_flag=True, help="Show tags")
154
- @click.option("--ready", is_flag=True, help="Include tasks which are not yet scheduled")
155
156
  @click.option("--filter", default="", help="Filter expression")
156
157
  @click.option("--fullpath", is_flag=True, help="Prints full paths")
157
- @click.option("--no-check", is_flag=True, help="Check that running jobs")
158
+ @click.option("--count", "-c", default=0, type=int, help="Limit to N most recent jobs")
158
159
  @jobs.command()
159
160
  @click.pass_context
160
161
  def list(
@@ -162,24 +163,22 @@ def list(
162
163
  experiment: str,
163
164
  filter: str,
164
165
  tags: bool,
165
- ready: bool,
166
166
  fullpath: bool,
167
- no_check: bool,
167
+ count: int,
168
168
  ):
169
+ """List all jobs in the workspace (sorted by submission date, most recent first)"""
169
170
  process(
170
171
  ctx.obj.workspace,
171
172
  experiment=experiment,
172
173
  filter=filter,
173
174
  tags=tags,
174
- ready=ready,
175
175
  fullpath=fullpath,
176
- check=not no_check,
176
+ count=count,
177
177
  )
178
178
 
179
179
 
180
180
  @click.option("--experiment", default=None, help="Restrict to this experiment")
181
181
  @click.option("--tags", is_flag=True, help="Show tags")
182
- @click.option("--ready", is_flag=True, help="Include tasks which are not yet scheduled")
183
182
  @click.option("--filter", default="", help="Filter expression")
184
183
  @click.option("--perform", is_flag=True, help="Really perform the killing")
185
184
  @click.option("--fullpath", is_flag=True, help="Prints full paths")
@@ -190,17 +189,15 @@ def kill(
190
189
  experiment: str,
191
190
  filter: str,
192
191
  tags: bool,
193
- ready: bool,
194
192
  fullpath: bool,
195
193
  perform: bool,
196
- check: bool,
197
194
  ):
195
+ """Kill running jobs"""
198
196
  process(
199
197
  ctx.obj.workspace,
200
198
  experiment=experiment,
201
199
  filter=filter,
202
200
  tags=tags,
203
- ready=ready,
204
201
  kill=True,
205
202
  perform=perform,
206
203
  fullpath=fullpath,
@@ -209,7 +206,6 @@ def kill(
209
206
 
210
207
  @click.option("--experiment", default=None, help="Restrict to this experiment")
211
208
  @click.option("--tags", is_flag=True, help="Show tags")
212
- @click.option("--ready", is_flag=True, help="Include tasks which are not yet scheduled")
213
209
  @click.option("--filter", default="", help="Filter expression")
214
210
  @click.option("--perform", is_flag=True, help="Really perform the cleaning")
215
211
  @click.option("--fullpath", is_flag=True, help="Prints full paths")
@@ -220,16 +216,15 @@ def clean(
220
216
  experiment: str,
221
217
  filter: str,
222
218
  tags: bool,
223
- ready: bool,
224
219
  fullpath: bool,
225
220
  perform: bool,
226
221
  ):
222
+ """Clean finished jobs (delete directories and DB entries)"""
227
223
  process(
228
224
  ctx.obj.workspace,
229
225
  experiment=experiment,
230
226
  filter=filter,
231
227
  tags=tags,
232
- ready=ready,
233
228
  clean=True,
234
229
  perform=perform,
235
230
  fullpath=fullpath,
@@ -244,25 +239,81 @@ def clean(
244
239
  @jobs.command()
245
240
  @click.pass_context
246
241
  def log(ctx, jobid: str, follow: bool, std: bool):
242
+ """View job log (stderr by default, stdout with --std)
243
+
244
+ JOBID format: task.name/hash (e.g., mymodule.MyTask/abc123)
245
+ """
247
246
  task_name, task_hash = jobid.split("/")
248
247
  _, name = task_name.rsplit(".", 1)
249
- path = (
248
+ log_path = (
250
249
  ctx.obj.workspace.path
251
250
  / "jobs"
252
251
  / task_name
253
252
  / task_hash
254
253
  / f"""{name}.{'out' if std else 'err'}"""
255
254
  )
255
+ if not log_path.exists():
256
+ cprint(f"Log file not found: {log_path}", "red")
257
+ return
256
258
  if follow:
257
- subprocess.run(["tail", "-f", path])
259
+ subprocess.run(["tail", "-f", log_path])
258
260
  else:
259
- subprocess.run(["less", "-r", path])
261
+ subprocess.run(["less", "-r", log_path])
260
262
 
261
263
 
262
264
  @click.argument("jobid", type=str)
263
265
  @jobs.command()
264
266
  @click.pass_context
265
267
  def path(ctx, jobid: str):
268
+ """Print the path to a job directory
269
+
270
+ JOBID format: task.name/hash (e.g., mymodule.MyTask/abc123)
271
+ """
266
272
  task_name, task_hash = jobid.split("/")
267
- path = ctx.obj.workspace.path / "jobs" / task_name / task_hash
268
- print(path)
273
+ job_path = ctx.obj.workspace.path / "jobs" / task_name / task_hash
274
+ if not job_path.exists():
275
+ cprint(f"Job directory not found: {job_path}", "red")
276
+ return
277
+ print(job_path)
278
+
279
+
280
+ @click.option("--perform", is_flag=True, help="Actually delete orphan partials")
281
+ @jobs.command("cleanup-partials")
282
+ @click.pass_context
283
+ def cleanup_partials(ctx, perform: bool):
284
+ """Clean up orphan partial directories
285
+
286
+ Partial directories are shared checkpoint locations created by
287
+ subparameters. When all jobs using a partial are deleted, the
288
+ partial becomes orphaned and can be cleaned up.
289
+
290
+ This command finds all orphan partials and deletes them (or shows
291
+ what would be deleted in dry-run mode).
292
+ """
293
+ from experimaestro.scheduler.state_provider import WorkspaceStateProvider
294
+
295
+ provider = WorkspaceStateProvider.get_instance(
296
+ ctx.obj.workspace.path, read_only=not perform
297
+ )
298
+
299
+ try:
300
+ orphan_paths = provider.cleanup_orphan_partials(perform=perform)
301
+
302
+ if not orphan_paths:
303
+ cprint("No orphan partials found.", "green")
304
+ return
305
+
306
+ if perform:
307
+ cprint(f"Cleaned {len(orphan_paths)} orphan partial(s):", "green")
308
+ else:
309
+ cprint(f"Found {len(orphan_paths)} orphan partial(s) (dry run):", "yellow")
310
+
311
+ for path in orphan_paths:
312
+ if perform:
313
+ print(colored(f" Deleted: {path}", "red"))
314
+ else:
315
+ print(colored(f" Would delete: {path}", "yellow"))
316
+
317
+ finally:
318
+ if perform:
319
+ provider.close()
@@ -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):