experimaestro 1.5.1__py3-none-any.whl → 2.0.0a8__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.
- experimaestro/__init__.py +14 -4
- experimaestro/__main__.py +3 -423
- experimaestro/annotations.py +14 -4
- experimaestro/cli/__init__.py +311 -0
- experimaestro/{filter.py → cli/filter.py} +23 -9
- experimaestro/cli/jobs.py +268 -0
- experimaestro/cli/progress.py +269 -0
- experimaestro/click.py +0 -35
- experimaestro/commandline.py +3 -7
- experimaestro/connectors/__init__.py +29 -14
- experimaestro/connectors/local.py +19 -10
- experimaestro/connectors/ssh.py +27 -8
- experimaestro/core/arguments.py +45 -3
- experimaestro/core/callbacks.py +52 -0
- experimaestro/core/context.py +8 -9
- experimaestro/core/identifier.py +310 -0
- experimaestro/core/objects/__init__.py +44 -0
- experimaestro/core/{objects.py → objects/config.py} +399 -772
- experimaestro/core/objects/config_utils.py +58 -0
- experimaestro/core/objects/config_walk.py +151 -0
- experimaestro/core/objects.pyi +15 -45
- experimaestro/core/serialization.py +63 -9
- experimaestro/core/serializers.py +1 -8
- experimaestro/core/types.py +104 -66
- experimaestro/experiments/cli.py +154 -72
- experimaestro/experiments/configuration.py +10 -1
- experimaestro/generators.py +6 -1
- experimaestro/ipc.py +4 -1
- experimaestro/launcherfinder/__init__.py +1 -1
- experimaestro/launcherfinder/base.py +2 -18
- experimaestro/launcherfinder/parser.py +8 -3
- experimaestro/launcherfinder/registry.py +52 -140
- experimaestro/launcherfinder/specs.py +49 -10
- experimaestro/launchers/direct.py +0 -47
- experimaestro/launchers/slurm/base.py +54 -14
- experimaestro/mkdocs/__init__.py +1 -1
- experimaestro/mkdocs/base.py +6 -8
- experimaestro/notifications.py +38 -12
- experimaestro/progress.py +406 -0
- experimaestro/run.py +24 -3
- experimaestro/scheduler/__init__.py +18 -1
- experimaestro/scheduler/base.py +108 -808
- experimaestro/scheduler/dynamic_outputs.py +184 -0
- experimaestro/scheduler/experiment.py +387 -0
- experimaestro/scheduler/jobs.py +475 -0
- experimaestro/scheduler/signal_handler.py +32 -0
- experimaestro/scheduler/state.py +75 -0
- experimaestro/scheduler/workspace.py +27 -8
- experimaestro/scriptbuilder.py +18 -3
- experimaestro/server/__init__.py +36 -5
- experimaestro/server/data/1815e00441357e01619e.ttf +0 -0
- experimaestro/server/data/2463b90d9a316e4e5294.woff2 +0 -0
- experimaestro/server/data/2582b0e4bcf85eceead0.ttf +0 -0
- experimaestro/server/data/89999bdf5d835c012025.woff2 +0 -0
- experimaestro/server/data/914997e1bdfc990d0897.ttf +0 -0
- experimaestro/server/data/c210719e60948b211a12.woff2 +0 -0
- experimaestro/server/data/index.css +5187 -5068
- experimaestro/server/data/index.css.map +1 -1
- experimaestro/server/data/index.js +68887 -68064
- experimaestro/server/data/index.js.map +1 -1
- experimaestro/settings.py +45 -5
- experimaestro/sphinx/__init__.py +7 -17
- experimaestro/taskglobals.py +7 -2
- experimaestro/tests/core/__init__.py +0 -0
- experimaestro/tests/core/test_generics.py +206 -0
- experimaestro/tests/definitions_types.py +5 -3
- experimaestro/tests/launchers/bin/sbatch +34 -7
- experimaestro/tests/launchers/bin/srun +5 -0
- experimaestro/tests/launchers/common.py +17 -5
- experimaestro/tests/launchers/config_slurm/launchers.py +25 -0
- experimaestro/tests/restart.py +10 -5
- experimaestro/tests/tasks/all.py +23 -10
- experimaestro/tests/tasks/foreign.py +2 -4
- experimaestro/tests/test_checkers.py +2 -2
- experimaestro/tests/test_dependencies.py +11 -17
- experimaestro/tests/test_experiment.py +73 -0
- experimaestro/tests/test_file_progress.py +425 -0
- experimaestro/tests/test_file_progress_integration.py +477 -0
- experimaestro/tests/test_findlauncher.py +12 -5
- experimaestro/tests/test_forward.py +5 -5
- experimaestro/tests/test_generators.py +93 -0
- experimaestro/tests/test_identifier.py +182 -158
- experimaestro/tests/test_instance.py +19 -27
- experimaestro/tests/test_objects.py +13 -20
- experimaestro/tests/test_outputs.py +6 -6
- experimaestro/tests/test_param.py +68 -30
- experimaestro/tests/test_progress.py +4 -4
- experimaestro/tests/test_serializers.py +24 -64
- experimaestro/tests/test_ssh.py +7 -0
- experimaestro/tests/test_tags.py +50 -21
- experimaestro/tests/test_tasks.py +42 -51
- experimaestro/tests/test_tokens.py +11 -8
- experimaestro/tests/test_types.py +24 -21
- experimaestro/tests/test_validation.py +67 -110
- experimaestro/tests/token_reschedule.py +1 -1
- experimaestro/tokens.py +24 -13
- experimaestro/tools/diff.py +8 -1
- experimaestro/typingutils.py +20 -11
- experimaestro/utils/asyncio.py +6 -2
- experimaestro/utils/multiprocessing.py +44 -0
- experimaestro/utils/resources.py +11 -3
- {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/METADATA +28 -36
- experimaestro-2.0.0a8.dist-info/RECORD +166 -0
- {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/WHEEL +1 -1
- {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/entry_points.txt +0 -4
- experimaestro/launchers/slurm/cli.py +0 -29
- experimaestro/launchers/slurm/configuration.py +0 -597
- experimaestro/scheduler/environment.py +0 -94
- experimaestro/server/data/016b4a6cdced82ab3aa1.ttf +0 -0
- experimaestro/server/data/50701fbb8177c2dde530.ttf +0 -0
- experimaestro/server/data/878f31251d960bd6266f.woff2 +0 -0
- experimaestro/server/data/b041b1fa4fe241b23445.woff2 +0 -0
- experimaestro/server/data/b6879d41b0852f01ed5b.woff2 +0 -0
- experimaestro/server/data/d75e3fd1eb12e9bd6655.ttf +0 -0
- experimaestro/tests/launchers/config_slurm/launchers.yaml +0 -134
- experimaestro/utils/yaml.py +0 -202
- experimaestro-1.5.1.dist-info/RECORD +0 -148
- {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info/licenses}/LICENSE +0 -0
|
@@ -0,0 +1,269 @@
|
|
|
1
|
+
"""Simplified CLI commands for managing and viewing progress files"""
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from datetime import datetime
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional, Dict
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from termcolor import colored
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
from tqdm import tqdm
|
|
13
|
+
|
|
14
|
+
TQDM_AVAILABLE = True
|
|
15
|
+
except ImportError:
|
|
16
|
+
TQDM_AVAILABLE = False
|
|
17
|
+
|
|
18
|
+
from experimaestro.progress import ProgressEntry, ProgressFileReader
|
|
19
|
+
from experimaestro.settings import find_workspace
|
|
20
|
+
from . import cli
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@click.option("--workspace", default="", help="Experimaestro workspace")
|
|
24
|
+
@click.option("--workdir", type=Path, default=None)
|
|
25
|
+
@cli.group()
|
|
26
|
+
@click.pass_context
|
|
27
|
+
def progress(
|
|
28
|
+
ctx,
|
|
29
|
+
workdir: Optional[Path],
|
|
30
|
+
workspace: Optional[str],
|
|
31
|
+
):
|
|
32
|
+
"""Progress tracking commands"""
|
|
33
|
+
ctx.obj.workspace = find_workspace(workdir=workdir, workspace=workspace)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def format_timestamp(timestamp: float) -> str:
|
|
37
|
+
"""Format timestamp for display"""
|
|
38
|
+
dt = datetime.fromtimestamp(timestamp)
|
|
39
|
+
return dt.strftime("%Y-%m-%d %H:%M:%S")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@click.argument("jobid", type=str)
|
|
43
|
+
@progress.command()
|
|
44
|
+
@click.pass_context
|
|
45
|
+
def show(ctx, jobid: str):
|
|
46
|
+
"""Show current progress state (default command)
|
|
47
|
+
|
|
48
|
+
JOBID format: task_name/task_hash
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
task_name, task_hash = jobid.split("/")
|
|
52
|
+
except ValueError:
|
|
53
|
+
raise click.ClickException("JOBID must be in format task_name/task_hash")
|
|
54
|
+
|
|
55
|
+
workspace = ctx.obj.workspace
|
|
56
|
+
task_path = workspace.path / "jobs" / task_name / task_hash
|
|
57
|
+
|
|
58
|
+
if not task_path.exists():
|
|
59
|
+
raise click.ClickException(f"Job directory not found: {task_path}")
|
|
60
|
+
|
|
61
|
+
reader = ProgressFileReader(task_path)
|
|
62
|
+
current_progress = reader.get_current_progress()
|
|
63
|
+
|
|
64
|
+
if not current_progress:
|
|
65
|
+
click.echo("No progress information available")
|
|
66
|
+
return
|
|
67
|
+
|
|
68
|
+
# Filter out EOJ markers
|
|
69
|
+
current_progress = {k: v for k, v in current_progress.items() if k != -1}
|
|
70
|
+
|
|
71
|
+
if not current_progress:
|
|
72
|
+
click.echo("No progress information available")
|
|
73
|
+
return
|
|
74
|
+
|
|
75
|
+
click.echo(f"Progress for job {jobid}")
|
|
76
|
+
click.echo("=" * 80)
|
|
77
|
+
|
|
78
|
+
# Show simple text-based progress for each level
|
|
79
|
+
for level in sorted(current_progress.keys()):
|
|
80
|
+
entry = current_progress[level]
|
|
81
|
+
indent = " " * level
|
|
82
|
+
progress_pct = f"{entry.progress * 100:5.1f}%"
|
|
83
|
+
desc = entry.desc or f"Level {level}"
|
|
84
|
+
timestamp = format_timestamp(entry.timestamp)
|
|
85
|
+
|
|
86
|
+
color = "green" if entry.progress >= 1.0 else "yellow"
|
|
87
|
+
click.echo(colored(f"{indent}L{level}: {progress_pct} - {desc}", color))
|
|
88
|
+
click.echo(colored(f"{indent} Last updated: {timestamp}", "cyan"))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def create_progress_bar(
|
|
92
|
+
level: int,
|
|
93
|
+
desc: str,
|
|
94
|
+
progress: float = 0.0,
|
|
95
|
+
) -> tqdm:
|
|
96
|
+
"""Create a properly aligned progress bar like dashboard style"""
|
|
97
|
+
if level > 0:
|
|
98
|
+
indent = " " * (level - 1) + "└─ "
|
|
99
|
+
else:
|
|
100
|
+
indent = ""
|
|
101
|
+
label = f"{indent}L{level}"
|
|
102
|
+
|
|
103
|
+
colors = ["blue", "yellow", "magenta", "cyan", "white"]
|
|
104
|
+
bar_color = colors[level % len(colors)]
|
|
105
|
+
|
|
106
|
+
unit = desc[:50] if desc else f"Level {level}"
|
|
107
|
+
ncols = 100
|
|
108
|
+
wbar = 50
|
|
109
|
+
|
|
110
|
+
return tqdm(
|
|
111
|
+
total=100,
|
|
112
|
+
desc=label,
|
|
113
|
+
position=level,
|
|
114
|
+
leave=True,
|
|
115
|
+
bar_format=f"{{desc}}: {{percentage:3.0f}}%|{{bar:{wbar - len(indent)}}}| {{unit}}", # noqa: F541
|
|
116
|
+
ncols=ncols, # Adjust width based on level
|
|
117
|
+
unit=unit,
|
|
118
|
+
colour=bar_color,
|
|
119
|
+
initial=progress * 100,
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def _update_progress_display(
|
|
124
|
+
reader: ProgressFileReader, progress_bars: Dict[int, tqdm]
|
|
125
|
+
) -> bool:
|
|
126
|
+
"""Update the tqdm progress bars in dashboard style"""
|
|
127
|
+
current_state: Dict[int, ProgressEntry] = {
|
|
128
|
+
k: v for k, v in reader.get_current_state().items() if k != -1
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if not current_state:
|
|
132
|
+
click.echo("No progress information available yet...")
|
|
133
|
+
return False
|
|
134
|
+
|
|
135
|
+
# Update existing bars and create new ones
|
|
136
|
+
for _level, entry in current_state.items():
|
|
137
|
+
progress_val = entry.progress * 100
|
|
138
|
+
desc = entry.desc or f"Level {entry.level}"
|
|
139
|
+
|
|
140
|
+
if entry.level not in progress_bars:
|
|
141
|
+
progress_bars[entry.level] = create_progress_bar(
|
|
142
|
+
entry.level, desc, progress_val
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
bar = progress_bars[entry.level]
|
|
146
|
+
bar.unit = desc[:50]
|
|
147
|
+
bar.n = progress_val
|
|
148
|
+
|
|
149
|
+
bar.refresh()
|
|
150
|
+
|
|
151
|
+
# Remove bars for levels that no longer exist
|
|
152
|
+
levels_to_remove = set(progress_bars.keys()) - set(current_state.keys())
|
|
153
|
+
for level in levels_to_remove:
|
|
154
|
+
progress_bars[level].close()
|
|
155
|
+
del progress_bars[level]
|
|
156
|
+
|
|
157
|
+
return True
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@click.argument("jobid", type=str)
|
|
161
|
+
@click.option("--refresh-rate", "-r", default=0.5, help="Refresh rate in seconds")
|
|
162
|
+
@progress.command()
|
|
163
|
+
@click.pass_context
|
|
164
|
+
def live(ctx, jobid: str, refresh_rate: float):
|
|
165
|
+
"""Show live progress with tqdm-style bars
|
|
166
|
+
|
|
167
|
+
JOBID format: task_name/task_hash
|
|
168
|
+
"""
|
|
169
|
+
if not TQDM_AVAILABLE:
|
|
170
|
+
click.echo("tqdm is not available. Install with: pip install tqdm")
|
|
171
|
+
click.echo("Falling back to basic display...")
|
|
172
|
+
ctx.invoke(show, jobid=jobid)
|
|
173
|
+
return
|
|
174
|
+
|
|
175
|
+
try:
|
|
176
|
+
task_name, task_hash = jobid.split("/")
|
|
177
|
+
except ValueError:
|
|
178
|
+
raise click.ClickException("JOBID must be in format task_name/task_hash")
|
|
179
|
+
|
|
180
|
+
workspace = ctx.obj.workspace
|
|
181
|
+
task_path = workspace.path / "jobs" / task_name / task_hash
|
|
182
|
+
|
|
183
|
+
if not task_path.exists():
|
|
184
|
+
raise click.ClickException(f"Job directory not found: {task_path}")
|
|
185
|
+
|
|
186
|
+
reader = ProgressFileReader(task_path)
|
|
187
|
+
progress_bars: Dict[int, tqdm] = {}
|
|
188
|
+
|
|
189
|
+
def cleanup_bars():
|
|
190
|
+
"""Clean up all progress bars"""
|
|
191
|
+
for bar in progress_bars.values():
|
|
192
|
+
bar.close()
|
|
193
|
+
progress_bars.clear()
|
|
194
|
+
|
|
195
|
+
click.echo(f"Live progress for job {jobid}")
|
|
196
|
+
click.echo("Press Ctrl+C to stop")
|
|
197
|
+
click.echo("=" * 80)
|
|
198
|
+
|
|
199
|
+
try:
|
|
200
|
+
if not _update_progress_display(reader, progress_bars):
|
|
201
|
+
click.echo("No progress information available yet...")
|
|
202
|
+
|
|
203
|
+
while True:
|
|
204
|
+
time.sleep(refresh_rate)
|
|
205
|
+
|
|
206
|
+
if not _update_progress_display(reader, progress_bars):
|
|
207
|
+
# Check if job is complete
|
|
208
|
+
if reader.is_done():
|
|
209
|
+
click.echo("\nJob completed!")
|
|
210
|
+
break
|
|
211
|
+
|
|
212
|
+
# Check if all progress bars are at 100%
|
|
213
|
+
if progress_bars and all(bar.n >= 100 for bar in progress_bars.values()):
|
|
214
|
+
cleanup_bars()
|
|
215
|
+
click.echo("\nAll progress completed!")
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
except KeyboardInterrupt:
|
|
219
|
+
click.echo("\nStopped monitoring progress")
|
|
220
|
+
finally:
|
|
221
|
+
cleanup_bars()
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@progress.command(name="list")
|
|
225
|
+
@click.pass_context
|
|
226
|
+
def list_jobs(ctx):
|
|
227
|
+
"""List all jobs with progress information"""
|
|
228
|
+
ws = ctx.obj.workspace
|
|
229
|
+
jobs_path = ws.path / "jobs"
|
|
230
|
+
|
|
231
|
+
if not jobs_path.exists():
|
|
232
|
+
click.echo("No jobs directory found")
|
|
233
|
+
return
|
|
234
|
+
|
|
235
|
+
for task_dir in jobs_path.iterdir():
|
|
236
|
+
if not task_dir.is_dir():
|
|
237
|
+
continue
|
|
238
|
+
|
|
239
|
+
for job_dir in task_dir.iterdir():
|
|
240
|
+
if not job_dir.is_dir():
|
|
241
|
+
continue
|
|
242
|
+
|
|
243
|
+
progress_dir = job_dir / ".experimaestro"
|
|
244
|
+
if not progress_dir.exists():
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
# Check if there are progress files
|
|
248
|
+
progress_files = list(progress_dir.glob("progress-*.jsonl"))
|
|
249
|
+
if not progress_files:
|
|
250
|
+
continue
|
|
251
|
+
|
|
252
|
+
job_id = f"{task_dir.name}/{job_dir.name}"
|
|
253
|
+
reader = ProgressFileReader(job_dir)
|
|
254
|
+
current_state = reader.get_current_state()
|
|
255
|
+
|
|
256
|
+
# if current_progress:
|
|
257
|
+
if current_state:
|
|
258
|
+
# Get overall progress (level 0)
|
|
259
|
+
level_0 = current_state.get(0)
|
|
260
|
+
if level_0:
|
|
261
|
+
color = "green" if level_0.progress >= 1.0 else "yellow"
|
|
262
|
+
desc = f"{level_0.desc}" if level_0.desc else ""
|
|
263
|
+
progress_pct = f"{level_0.progress * 100:5.1f}%"
|
|
264
|
+
click.echo(colored(f"{job_id:50} - {progress_pct} - {desc}", color))
|
|
265
|
+
|
|
266
|
+
else:
|
|
267
|
+
click.echo(f"{job_id:50} No level 0 progress")
|
|
268
|
+
else:
|
|
269
|
+
click.echo(f"{job_id:50} No progress data")
|
experimaestro/click.py
CHANGED
|
@@ -1,6 +1,4 @@
|
|
|
1
1
|
import click
|
|
2
|
-
from experimaestro import Environment
|
|
3
|
-
from experimaestro.run import parse_commandline
|
|
4
2
|
|
|
5
3
|
"""Defines the task command line argument prefix for experimaestro-handled command lines"""
|
|
6
4
|
|
|
@@ -45,36 +43,3 @@ class forwardoption(metaclass=forwardoptionMetaclass):
|
|
|
45
43
|
def __getattr__(self, key):
|
|
46
44
|
"""Access to a class field"""
|
|
47
45
|
return forwardoption([key])
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def environment(name: str):
|
|
51
|
-
def annotate(f):
|
|
52
|
-
def callback_env(ctx, name, value):
|
|
53
|
-
if value:
|
|
54
|
-
assert name not in ctx.params, "Environment has already been set"
|
|
55
|
-
else:
|
|
56
|
-
return ctx.params.get(name, None)
|
|
57
|
-
return Environment.get(value)
|
|
58
|
-
|
|
59
|
-
def callback(ctx, param, value):
|
|
60
|
-
if value:
|
|
61
|
-
if name not in ctx.params:
|
|
62
|
-
ctx.params[name] = Environment()
|
|
63
|
-
ctx.params[name].workdir = value
|
|
64
|
-
|
|
65
|
-
f = click.option(
|
|
66
|
-
f"--{name}-workdir",
|
|
67
|
-
type=str,
|
|
68
|
-
callback=callback,
|
|
69
|
-
expose_value=False,
|
|
70
|
-
help="Experimaestro environment",
|
|
71
|
-
)(f)
|
|
72
|
-
f = click.option(
|
|
73
|
-
f"--{name}",
|
|
74
|
-
type=str,
|
|
75
|
-
callback=callback_env,
|
|
76
|
-
help="Experimaestro environment",
|
|
77
|
-
)(f)
|
|
78
|
-
return f
|
|
79
|
-
|
|
80
|
-
return annotate
|
experimaestro/commandline.py
CHANGED
|
@@ -276,12 +276,6 @@ class CommandLineJob(Job):
|
|
|
276
276
|
|
|
277
277
|
scriptbuilder = self.launcher.scriptbuilder()
|
|
278
278
|
self.path.mkdir(parents=True, exist_ok=True)
|
|
279
|
-
donepath = self.donepath
|
|
280
|
-
|
|
281
|
-
# Check again if done (now that we have locked)
|
|
282
|
-
if not overwrite and donepath.is_file():
|
|
283
|
-
logger.info("Job %s is already done", self)
|
|
284
|
-
return JobState.DONE
|
|
285
279
|
|
|
286
280
|
# Now we can write the script
|
|
287
281
|
scriptbuilder.lockfiles.append(self.lockpath)
|
|
@@ -293,15 +287,17 @@ class CommandLineJob(Job):
|
|
|
293
287
|
if self._process:
|
|
294
288
|
return self._process
|
|
295
289
|
|
|
290
|
+
# Prepare the files to be run
|
|
296
291
|
scriptPath = self.prepare()
|
|
297
292
|
|
|
293
|
+
# OK, now starts the process
|
|
298
294
|
logger.info("Starting job %s", self.jobpath)
|
|
299
295
|
processbuilder = self.launcher.processbuilder()
|
|
300
296
|
processbuilder.environ = self.environ
|
|
301
297
|
processbuilder.command.append(self.launcher.connector.resolve(scriptPath))
|
|
302
298
|
processbuilder.stderr = Redirect.file(self.stderr)
|
|
303
299
|
processbuilder.stdout = Redirect.file(self.stdout)
|
|
304
|
-
self._process = processbuilder.start()
|
|
300
|
+
self._process = processbuilder.start(True)
|
|
305
301
|
|
|
306
302
|
with self.pidpath.open("w") as fp:
|
|
307
303
|
json.dump(self._process.tospec(), fp)
|
|
@@ -9,13 +9,14 @@ This module contains :
|
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
11
|
import enum
|
|
12
|
-
|
|
12
|
+
import logging
|
|
13
|
+
from typing import Any, Dict, Mapping, Type, Union, Optional
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
from experimaestro.utils import logger
|
|
15
16
|
from experimaestro.locking import Lock
|
|
16
17
|
from experimaestro.tokens import Token
|
|
17
18
|
from experimaestro.utils.asyncio import asyncThreadcheck
|
|
18
|
-
import
|
|
19
|
+
from importlib.metadata import entry_points
|
|
19
20
|
|
|
20
21
|
|
|
21
22
|
class RedirectType(enum.Enum):
|
|
@@ -86,12 +87,12 @@ class Process:
|
|
|
86
87
|
@staticmethod
|
|
87
88
|
def fromDefinition(connector: "Connector", definition: Dict[str, Any]) -> "Process":
|
|
88
89
|
"""Retrieves a process from a serialized definition"""
|
|
89
|
-
|
|
90
|
+
handler_type = definition["type"]
|
|
91
|
+
handler = Process.handler(handler_type)
|
|
92
|
+
assert handler is not None, f"No handler of type {handler_type}"
|
|
90
93
|
try:
|
|
91
94
|
return handler.fromspec(connector, definition)
|
|
92
95
|
except Exception as e:
|
|
93
|
-
import logging
|
|
94
|
-
|
|
95
96
|
logging.exception("Could not retrieve job from specification")
|
|
96
97
|
raise e
|
|
97
98
|
|
|
@@ -100,8 +101,12 @@ class Process:
|
|
|
100
101
|
"""Get a handler"""
|
|
101
102
|
if Process.HANDLERS is None:
|
|
102
103
|
Process.HANDLERS = {}
|
|
103
|
-
for ep in
|
|
104
|
-
|
|
104
|
+
for ep in entry_points(group="experimaestro.process"):
|
|
105
|
+
logging.debug("Adding process handler for type %s", ep.name)
|
|
106
|
+
handler = ep.load()
|
|
107
|
+
Process.HANDLERS[ep.name] = handler
|
|
108
|
+
if handler is None:
|
|
109
|
+
logging.error("Handler of type %s is null", ep.name)
|
|
105
110
|
|
|
106
111
|
return Process.HANDLERS.get(key, None)
|
|
107
112
|
|
|
@@ -109,18 +114,25 @@ class Process:
|
|
|
109
114
|
"""Wait until the process finishes and returns the error code"""
|
|
110
115
|
raise NotImplementedError(f"Not implemented: {self.__class__}.wait")
|
|
111
116
|
|
|
112
|
-
async def aio_state(self) -> ProcessState:
|
|
113
|
-
"""Returns the job state
|
|
117
|
+
async def aio_state(self, timeout: float | None = None) -> ProcessState:
|
|
118
|
+
"""Returns the job state
|
|
119
|
+
|
|
120
|
+
:param timeout: maximum waiting time for a refresh
|
|
121
|
+
"""
|
|
114
122
|
raise NotImplementedError(f"Not implemented: {self.__class__}.aio_state")
|
|
115
123
|
|
|
116
124
|
async def aio_isrunning(self):
|
|
117
125
|
"""True is the process is truly running (I/O)"""
|
|
118
126
|
return (await self.aio_state()).running
|
|
119
127
|
|
|
120
|
-
async def aio_code(self):
|
|
121
|
-
"""Returns a future containing the returned code
|
|
128
|
+
async def aio_code(self) -> Optional[int]:
|
|
129
|
+
"""Returns a future containing the returned code
|
|
130
|
+
|
|
131
|
+
Returns None if the process has already finished – and no information is
|
|
132
|
+
known about the process.
|
|
133
|
+
"""
|
|
122
134
|
code = await asyncThreadcheck("aio_code", self.wait)
|
|
123
|
-
logger.debug("Got
|
|
135
|
+
logger.debug("Got return code %s for %s", code, self)
|
|
124
136
|
return code
|
|
125
137
|
|
|
126
138
|
def kill(self):
|
|
@@ -145,8 +157,11 @@ class ProcessBuilder:
|
|
|
145
157
|
self.environ: Mapping[str, str] = {}
|
|
146
158
|
self.command = []
|
|
147
159
|
|
|
148
|
-
def start(self) -> Process:
|
|
149
|
-
"""Start the process
|
|
160
|
+
def start(self, task_mode: bool = False) -> Process:
|
|
161
|
+
"""Start the process
|
|
162
|
+
|
|
163
|
+
:param task_mode: True if the process is a job script
|
|
164
|
+
"""
|
|
150
165
|
raise NotImplementedError("Method not implemented in %s" % self.__class__)
|
|
151
166
|
|
|
152
167
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
"""All classes related to localhost management
|
|
2
|
-
"""
|
|
1
|
+
"""All classes related to localhost management"""
|
|
3
2
|
|
|
4
3
|
import subprocess
|
|
4
|
+
from typing import Optional
|
|
5
5
|
from pathlib import Path, WindowsPath, PosixPath
|
|
6
6
|
import os
|
|
7
7
|
import threading
|
|
@@ -29,15 +29,17 @@ class PsutilProcess(Process):
|
|
|
29
29
|
def __init__(self, pid: int):
|
|
30
30
|
self._process = psutil.Process(pid)
|
|
31
31
|
|
|
32
|
-
def wait(self) -> int:
|
|
32
|
+
def wait(self) -> Optional[int]:
|
|
33
33
|
logger.debug("Waiting (psutil) for process with PID %s", self._process.pid)
|
|
34
34
|
code = self._process.wait()
|
|
35
35
|
logger.debug(
|
|
36
|
-
"Finished to wait (psutil) for process with PID %s",
|
|
36
|
+
"Finished to wait (psutil) for process with PID %s: code %s",
|
|
37
|
+
self._process.pid,
|
|
38
|
+
code,
|
|
37
39
|
)
|
|
38
40
|
return code
|
|
39
41
|
|
|
40
|
-
async def aio_state(self):
|
|
42
|
+
async def aio_state(self, timeout: float | None = None) -> ProcessState:
|
|
41
43
|
if self._process.is_running():
|
|
42
44
|
return ProcessState.RUNNING
|
|
43
45
|
return ProcessState.FINISHED
|
|
@@ -57,11 +59,13 @@ class LocalProcess(Process):
|
|
|
57
59
|
logger.debug("Waiting (python) for process with PID %s", self._process.pid)
|
|
58
60
|
code = self._process.wait()
|
|
59
61
|
logger.debug(
|
|
60
|
-
"Finished to wait (python) for process with PID %s",
|
|
62
|
+
"Finished to wait (python) for process with PID %s: %s",
|
|
63
|
+
self._process.pid,
|
|
64
|
+
code,
|
|
61
65
|
)
|
|
62
66
|
return code
|
|
63
67
|
|
|
64
|
-
async def aio_state(self):
|
|
68
|
+
async def aio_state(self, timeout: float | None = None) -> ProcessState:
|
|
65
69
|
code = self._process.poll()
|
|
66
70
|
if code is None:
|
|
67
71
|
return ProcessState.RUNNING
|
|
@@ -102,8 +106,11 @@ def getstream(redirect: Redirect, write: bool):
|
|
|
102
106
|
|
|
103
107
|
|
|
104
108
|
class LocalProcessBuilder(ProcessBuilder):
|
|
105
|
-
def start(self):
|
|
106
|
-
"""Start the process
|
|
109
|
+
def start(self, task_mode=False):
|
|
110
|
+
"""Start the process
|
|
111
|
+
|
|
112
|
+
:param task_mode: just ignored
|
|
113
|
+
"""
|
|
107
114
|
stdin = getstream(self.stdin, False)
|
|
108
115
|
stdout = getstream(self.stdout, True)
|
|
109
116
|
stderr = getstream(self.stderr, True)
|
|
@@ -194,7 +201,9 @@ class LocalConnector(Connector):
|
|
|
194
201
|
return LocalProcessBuilder()
|
|
195
202
|
|
|
196
203
|
def resolve(self, path: Path, basepath: Path = None) -> str:
|
|
197
|
-
assert isinstance(path, PosixPath) or isinstance(
|
|
204
|
+
assert isinstance(path, PosixPath) or isinstance(
|
|
205
|
+
path, WindowsPath
|
|
206
|
+
), f"Unrecognized path {type(path)}"
|
|
198
207
|
if not basepath:
|
|
199
208
|
return str(path.absolute())
|
|
200
209
|
try:
|
experimaestro/connectors/ssh.py
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
|
+
try:
|
|
2
|
+
from pathlib import Path, _posix_flavour
|
|
3
|
+
except ImportError:
|
|
4
|
+
# Avoids problem with python 3.12 where this module does not work
|
|
5
|
+
# anyways
|
|
6
|
+
_posix_flavour = None
|
|
7
|
+
|
|
1
8
|
from dataclasses import dataclass
|
|
2
|
-
from pathlib import Path, _posix_flavour
|
|
3
9
|
import io
|
|
4
10
|
import os
|
|
5
11
|
import re
|
|
6
|
-
from experimaestro.launcherfinder import LauncherRegistry
|
|
7
|
-
from fabric import Connection
|
|
8
|
-
from invoke import Promise
|
|
9
|
-
import invoke.exceptions
|
|
12
|
+
from experimaestro.launcherfinder import LauncherRegistry
|
|
10
13
|
from urllib.parse import urlparse
|
|
11
14
|
from itertools import chain
|
|
12
15
|
from . import Connector
|
|
@@ -19,6 +22,22 @@ from . import (
|
|
|
19
22
|
from experimaestro.locking import Lock
|
|
20
23
|
from experimaestro.tokens import Token
|
|
21
24
|
|
|
25
|
+
try:
|
|
26
|
+
from fabric import Connection
|
|
27
|
+
from invoke import Promise
|
|
28
|
+
from invoke.exceptions import Failure
|
|
29
|
+
except Exception:
|
|
30
|
+
# Just define placeholders
|
|
31
|
+
class Connection:
|
|
32
|
+
pass
|
|
33
|
+
|
|
34
|
+
class Promise:
|
|
35
|
+
pass
|
|
36
|
+
|
|
37
|
+
class Failure(Exception):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
22
41
|
# Might be wise to switch to https://github.com/marian-code/ssh-utilities
|
|
23
42
|
|
|
24
43
|
|
|
@@ -132,7 +151,7 @@ class SshPath(Path):
|
|
|
132
151
|
|
|
133
152
|
|
|
134
153
|
@dataclass
|
|
135
|
-
class SshConfiguration
|
|
154
|
+
class SshConfiguration:
|
|
136
155
|
hostname: str
|
|
137
156
|
|
|
138
157
|
def create(self, registry: LauncherRegistry):
|
|
@@ -172,7 +191,7 @@ class SshProcess(Process):
|
|
|
172
191
|
def wait(self) -> int:
|
|
173
192
|
try:
|
|
174
193
|
self.promise.join()
|
|
175
|
-
except
|
|
194
|
+
except Failure:
|
|
176
195
|
raise
|
|
177
196
|
|
|
178
197
|
|
|
@@ -181,7 +200,7 @@ class SshProcessBuilder(ProcessBuilder):
|
|
|
181
200
|
super().__init__()
|
|
182
201
|
self.connector = connector
|
|
183
202
|
|
|
184
|
-
def start(self):
|
|
203
|
+
def start(self, task_mode: bool = False):
|
|
185
204
|
"""Start the process"""
|
|
186
205
|
|
|
187
206
|
trans = str.maketrans({'"': r"\"", "$": r"\$"})
|
experimaestro/core/arguments.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"""Management of the arguments (params, options, etc) associated with the XPM objects"""
|
|
2
2
|
|
|
3
|
-
from typing import Optional, TypeVar, TYPE_CHECKING
|
|
3
|
+
from typing import Optional, TypeVar, TYPE_CHECKING, Callable, Any
|
|
4
4
|
from experimaestro.typingutils import get_optional
|
|
5
5
|
from pathlib import Path
|
|
6
6
|
import sys
|
|
@@ -75,11 +75,25 @@ class Argument:
|
|
|
75
75
|
self.constant = constant
|
|
76
76
|
self.ignored = self.type.ignore if ignored is None else ignored
|
|
77
77
|
self.required = required
|
|
78
|
-
self.default = default
|
|
79
|
-
self.generator = generator
|
|
80
78
|
self.objecttype = None
|
|
81
79
|
self.is_data = is_data
|
|
82
80
|
|
|
81
|
+
self.generator = generator
|
|
82
|
+
self.default = None
|
|
83
|
+
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
|
|
94
|
+
else:
|
|
95
|
+
self.default = default
|
|
96
|
+
|
|
83
97
|
assert (
|
|
84
98
|
not self.constant or self.default is not None
|
|
85
99
|
), "Cannot be constant without default"
|
|
@@ -170,6 +184,34 @@ DataPath = Annotated[Path, dataHint]
|
|
|
170
184
|
"""Annotates a path that should be kept to restore an object to its state"""
|
|
171
185
|
|
|
172
186
|
|
|
187
|
+
class field:
|
|
188
|
+
"""Extra information for a given experimaestro field (param or meta)"""
|
|
189
|
+
|
|
190
|
+
def __init__(
|
|
191
|
+
self,
|
|
192
|
+
*,
|
|
193
|
+
default: Any = None,
|
|
194
|
+
default_factory: Callable = None,
|
|
195
|
+
ignore_generated=False,
|
|
196
|
+
):
|
|
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.
|
|
205
|
+
"""
|
|
206
|
+
assert not (
|
|
207
|
+
(default is not None) and (default_factory is not None)
|
|
208
|
+
), "default and default_factory are mutually exclusive options"
|
|
209
|
+
|
|
210
|
+
self.default_factory = default_factory
|
|
211
|
+
self.default = default
|
|
212
|
+
self.ignore_generated = ignore_generated
|
|
213
|
+
|
|
214
|
+
|
|
173
215
|
class help(TypeAnnotation):
|
|
174
216
|
def __init__(self, text: str):
|
|
175
217
|
self.text = text
|