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.

Files changed (118) hide show
  1. experimaestro/__init__.py +14 -4
  2. experimaestro/__main__.py +3 -423
  3. experimaestro/annotations.py +14 -4
  4. experimaestro/cli/__init__.py +311 -0
  5. experimaestro/{filter.py → cli/filter.py} +23 -9
  6. experimaestro/cli/jobs.py +268 -0
  7. experimaestro/cli/progress.py +269 -0
  8. experimaestro/click.py +0 -35
  9. experimaestro/commandline.py +3 -7
  10. experimaestro/connectors/__init__.py +29 -14
  11. experimaestro/connectors/local.py +19 -10
  12. experimaestro/connectors/ssh.py +27 -8
  13. experimaestro/core/arguments.py +45 -3
  14. experimaestro/core/callbacks.py +52 -0
  15. experimaestro/core/context.py +8 -9
  16. experimaestro/core/identifier.py +310 -0
  17. experimaestro/core/objects/__init__.py +44 -0
  18. experimaestro/core/{objects.py → objects/config.py} +399 -772
  19. experimaestro/core/objects/config_utils.py +58 -0
  20. experimaestro/core/objects/config_walk.py +151 -0
  21. experimaestro/core/objects.pyi +15 -45
  22. experimaestro/core/serialization.py +63 -9
  23. experimaestro/core/serializers.py +1 -8
  24. experimaestro/core/types.py +104 -66
  25. experimaestro/experiments/cli.py +154 -72
  26. experimaestro/experiments/configuration.py +10 -1
  27. experimaestro/generators.py +6 -1
  28. experimaestro/ipc.py +4 -1
  29. experimaestro/launcherfinder/__init__.py +1 -1
  30. experimaestro/launcherfinder/base.py +2 -18
  31. experimaestro/launcherfinder/parser.py +8 -3
  32. experimaestro/launcherfinder/registry.py +52 -140
  33. experimaestro/launcherfinder/specs.py +49 -10
  34. experimaestro/launchers/direct.py +0 -47
  35. experimaestro/launchers/slurm/base.py +54 -14
  36. experimaestro/mkdocs/__init__.py +1 -1
  37. experimaestro/mkdocs/base.py +6 -8
  38. experimaestro/notifications.py +38 -12
  39. experimaestro/progress.py +406 -0
  40. experimaestro/run.py +24 -3
  41. experimaestro/scheduler/__init__.py +18 -1
  42. experimaestro/scheduler/base.py +108 -808
  43. experimaestro/scheduler/dynamic_outputs.py +184 -0
  44. experimaestro/scheduler/experiment.py +387 -0
  45. experimaestro/scheduler/jobs.py +475 -0
  46. experimaestro/scheduler/signal_handler.py +32 -0
  47. experimaestro/scheduler/state.py +75 -0
  48. experimaestro/scheduler/workspace.py +27 -8
  49. experimaestro/scriptbuilder.py +18 -3
  50. experimaestro/server/__init__.py +36 -5
  51. experimaestro/server/data/1815e00441357e01619e.ttf +0 -0
  52. experimaestro/server/data/2463b90d9a316e4e5294.woff2 +0 -0
  53. experimaestro/server/data/2582b0e4bcf85eceead0.ttf +0 -0
  54. experimaestro/server/data/89999bdf5d835c012025.woff2 +0 -0
  55. experimaestro/server/data/914997e1bdfc990d0897.ttf +0 -0
  56. experimaestro/server/data/c210719e60948b211a12.woff2 +0 -0
  57. experimaestro/server/data/index.css +5187 -5068
  58. experimaestro/server/data/index.css.map +1 -1
  59. experimaestro/server/data/index.js +68887 -68064
  60. experimaestro/server/data/index.js.map +1 -1
  61. experimaestro/settings.py +45 -5
  62. experimaestro/sphinx/__init__.py +7 -17
  63. experimaestro/taskglobals.py +7 -2
  64. experimaestro/tests/core/__init__.py +0 -0
  65. experimaestro/tests/core/test_generics.py +206 -0
  66. experimaestro/tests/definitions_types.py +5 -3
  67. experimaestro/tests/launchers/bin/sbatch +34 -7
  68. experimaestro/tests/launchers/bin/srun +5 -0
  69. experimaestro/tests/launchers/common.py +17 -5
  70. experimaestro/tests/launchers/config_slurm/launchers.py +25 -0
  71. experimaestro/tests/restart.py +10 -5
  72. experimaestro/tests/tasks/all.py +23 -10
  73. experimaestro/tests/tasks/foreign.py +2 -4
  74. experimaestro/tests/test_checkers.py +2 -2
  75. experimaestro/tests/test_dependencies.py +11 -17
  76. experimaestro/tests/test_experiment.py +73 -0
  77. experimaestro/tests/test_file_progress.py +425 -0
  78. experimaestro/tests/test_file_progress_integration.py +477 -0
  79. experimaestro/tests/test_findlauncher.py +12 -5
  80. experimaestro/tests/test_forward.py +5 -5
  81. experimaestro/tests/test_generators.py +93 -0
  82. experimaestro/tests/test_identifier.py +182 -158
  83. experimaestro/tests/test_instance.py +19 -27
  84. experimaestro/tests/test_objects.py +13 -20
  85. experimaestro/tests/test_outputs.py +6 -6
  86. experimaestro/tests/test_param.py +68 -30
  87. experimaestro/tests/test_progress.py +4 -4
  88. experimaestro/tests/test_serializers.py +24 -64
  89. experimaestro/tests/test_ssh.py +7 -0
  90. experimaestro/tests/test_tags.py +50 -21
  91. experimaestro/tests/test_tasks.py +42 -51
  92. experimaestro/tests/test_tokens.py +11 -8
  93. experimaestro/tests/test_types.py +24 -21
  94. experimaestro/tests/test_validation.py +67 -110
  95. experimaestro/tests/token_reschedule.py +1 -1
  96. experimaestro/tokens.py +24 -13
  97. experimaestro/tools/diff.py +8 -1
  98. experimaestro/typingutils.py +20 -11
  99. experimaestro/utils/asyncio.py +6 -2
  100. experimaestro/utils/multiprocessing.py +44 -0
  101. experimaestro/utils/resources.py +11 -3
  102. {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/METADATA +28 -36
  103. experimaestro-2.0.0a8.dist-info/RECORD +166 -0
  104. {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/WHEEL +1 -1
  105. {experimaestro-1.5.1.dist-info → experimaestro-2.0.0a8.dist-info}/entry_points.txt +0 -4
  106. experimaestro/launchers/slurm/cli.py +0 -29
  107. experimaestro/launchers/slurm/configuration.py +0 -597
  108. experimaestro/scheduler/environment.py +0 -94
  109. experimaestro/server/data/016b4a6cdced82ab3aa1.ttf +0 -0
  110. experimaestro/server/data/50701fbb8177c2dde530.ttf +0 -0
  111. experimaestro/server/data/878f31251d960bd6266f.woff2 +0 -0
  112. experimaestro/server/data/b041b1fa4fe241b23445.woff2 +0 -0
  113. experimaestro/server/data/b6879d41b0852f01ed5b.woff2 +0 -0
  114. experimaestro/server/data/d75e3fd1eb12e9bd6655.ttf +0 -0
  115. experimaestro/tests/launchers/config_slurm/launchers.yaml +0 -134
  116. experimaestro/utils/yaml.py +0 -202
  117. experimaestro-1.5.1.dist-info/RECORD +0 -148
  118. {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
@@ -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
- from typing import Any, Dict, Mapping, Type, Union
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 pkg_resources
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
- handler = Process.handler(definition["type"])
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 pkg_resources.iter_entry_points(group="experimaestro.process"):
104
- Process.HANDLERS[ep.name] = ep.load()
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 for return code %s: %s", self, code)
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", self._process.pid
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", self._process.pid
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(path, WindowsPath)
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:
@@ -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, YAMLDataClass
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(YAMLDataClass):
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 invoke.exceptions.Failure:
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"\$"})
@@ -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