experimaestro 1.6.1__py3-none-any.whl → 1.15.2__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.
Files changed (98) hide show
  1. experimaestro/__init__.py +14 -3
  2. experimaestro/annotations.py +13 -3
  3. experimaestro/cli/filter.py +19 -5
  4. experimaestro/cli/jobs.py +12 -5
  5. experimaestro/commandline.py +3 -7
  6. experimaestro/connectors/__init__.py +27 -12
  7. experimaestro/connectors/local.py +19 -10
  8. experimaestro/connectors/ssh.py +1 -1
  9. experimaestro/core/arguments.py +35 -3
  10. experimaestro/core/callbacks.py +52 -0
  11. experimaestro/core/context.py +8 -9
  12. experimaestro/core/identifier.py +301 -0
  13. experimaestro/core/objects/__init__.py +44 -0
  14. experimaestro/core/{objects.py → objects/config.py} +364 -716
  15. experimaestro/core/objects/config_utils.py +58 -0
  16. experimaestro/core/objects/config_walk.py +151 -0
  17. experimaestro/core/objects.pyi +15 -45
  18. experimaestro/core/serialization.py +63 -9
  19. experimaestro/core/serializers.py +1 -8
  20. experimaestro/core/types.py +61 -6
  21. experimaestro/experiments/cli.py +79 -29
  22. experimaestro/experiments/configuration.py +3 -0
  23. experimaestro/generators.py +6 -1
  24. experimaestro/ipc.py +4 -1
  25. experimaestro/launcherfinder/parser.py +8 -3
  26. experimaestro/launcherfinder/registry.py +29 -10
  27. experimaestro/launcherfinder/specs.py +49 -10
  28. experimaestro/launchers/slurm/base.py +51 -13
  29. experimaestro/mkdocs/__init__.py +1 -1
  30. experimaestro/notifications.py +2 -1
  31. experimaestro/run.py +3 -1
  32. experimaestro/scheduler/base.py +114 -6
  33. experimaestro/scheduler/dynamic_outputs.py +184 -0
  34. experimaestro/scheduler/state.py +75 -0
  35. experimaestro/scheduler/workspace.py +2 -1
  36. experimaestro/scriptbuilder.py +13 -2
  37. experimaestro/server/data/0c35d18bf06992036b69.woff2 +0 -0
  38. experimaestro/server/data/1815e00441357e01619e.ttf +0 -0
  39. experimaestro/server/data/219aa9140e099e6c72ed.woff2 +0 -0
  40. experimaestro/server/data/2463b90d9a316e4e5294.woff2 +0 -0
  41. experimaestro/server/data/2582b0e4bcf85eceead0.ttf +0 -0
  42. experimaestro/server/data/3a4004a46a653d4b2166.woff +0 -0
  43. experimaestro/server/data/3baa5b8f3469222b822d.woff +0 -0
  44. experimaestro/server/data/4d73cb90e394b34b7670.woff +0 -0
  45. experimaestro/server/data/4ef4218c522f1eb6b5b1.woff2 +0 -0
  46. experimaestro/server/data/5d681e2edae8c60630db.woff +0 -0
  47. experimaestro/server/data/6f420cf17cc0d7676fad.woff2 +0 -0
  48. experimaestro/server/data/89999bdf5d835c012025.woff2 +0 -0
  49. experimaestro/server/data/914997e1bdfc990d0897.ttf +0 -0
  50. experimaestro/server/data/c210719e60948b211a12.woff2 +0 -0
  51. experimaestro/server/data/c380809fd3677d7d6903.woff2 +0 -0
  52. experimaestro/server/data/f882956fd323fd322f31.woff +0 -0
  53. experimaestro/server/data/favicon.ico +0 -0
  54. experimaestro/server/data/index.css +22963 -0
  55. experimaestro/server/data/index.css.map +1 -0
  56. experimaestro/server/data/index.html +27 -0
  57. experimaestro/server/data/index.js +101770 -0
  58. experimaestro/server/data/index.js.map +1 -0
  59. experimaestro/server/data/login.html +22 -0
  60. experimaestro/server/data/manifest.json +15 -0
  61. experimaestro/settings.py +2 -2
  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 +16 -4
  70. experimaestro/tests/restart.py +9 -4
  71. experimaestro/tests/tasks/all.py +23 -10
  72. experimaestro/tests/tasks/foreign.py +2 -4
  73. experimaestro/tests/test_dependencies.py +0 -6
  74. experimaestro/tests/test_experiment.py +73 -0
  75. experimaestro/tests/test_findlauncher.py +11 -4
  76. experimaestro/tests/test_forward.py +5 -5
  77. experimaestro/tests/test_generators.py +93 -0
  78. experimaestro/tests/test_identifier.py +114 -99
  79. experimaestro/tests/test_instance.py +6 -21
  80. experimaestro/tests/test_objects.py +20 -4
  81. experimaestro/tests/test_param.py +60 -22
  82. experimaestro/tests/test_serializers.py +24 -64
  83. experimaestro/tests/test_tags.py +5 -11
  84. experimaestro/tests/test_tasks.py +10 -23
  85. experimaestro/tests/test_tokens.py +3 -2
  86. experimaestro/tests/test_types.py +20 -17
  87. experimaestro/tests/test_validation.py +48 -91
  88. experimaestro/tokens.py +16 -5
  89. experimaestro/typingutils.py +8 -8
  90. experimaestro/utils/asyncio.py +6 -2
  91. experimaestro/utils/multiprocessing.py +44 -0
  92. experimaestro/utils/resources.py +7 -3
  93. {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info}/METADATA +27 -34
  94. experimaestro-1.15.2.dist-info/RECORD +159 -0
  95. {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info}/WHEEL +1 -1
  96. experimaestro-1.6.1.dist-info/RECORD +0 -122
  97. {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info}/entry_points.txt +0 -0
  98. {experimaestro-1.6.1.dist-info → experimaestro-1.15.2.dist-info/licenses}/LICENSE +0 -0
experimaestro/__init__.py CHANGED
@@ -27,7 +27,15 @@ from .annotations import (
27
27
  # Method
28
28
  config_only,
29
29
  )
30
- from .core.serialization import load, save, state_dict, from_state_dict, from_task_dir
30
+ from .core.serialization import (
31
+ load,
32
+ save,
33
+ state_dict,
34
+ from_state_dict,
35
+ from_task_dir,
36
+ serialize,
37
+ deserialize,
38
+ )
31
39
  from .core.arguments import (
32
40
  # Types
33
41
  Param,
@@ -36,15 +44,17 @@ from .core.arguments import (
36
44
  DataPath,
37
45
  Annotated,
38
46
  Constant,
47
+ field,
39
48
  # Annotations helpers
40
49
  help,
41
50
  default,
42
51
  )
43
- from .generators import pathgenerator
52
+ from .generators import pathgenerator, PathGenerator
44
53
  from .core.objects import (
45
54
  Config,
46
55
  copyconfig,
47
56
  setmeta,
57
+ DependentMarker,
48
58
  Task,
49
59
  LightweightTask,
50
60
  ObjectStore,
@@ -53,8 +63,9 @@ from .core.context import SerializationContext
53
63
  from .core.serializers import SerializationLWTask, PathSerializationLWTask
54
64
  from .core.types import Any, SubmitHook
55
65
  from .launchers import Launcher
56
- from .scheduler.workspace import Workspace, RunMode
57
66
  from .scheduler import Scheduler, experiment, FailedExperiment
67
+ from .scheduler.workspace import Workspace, RunMode
68
+ from .scheduler.state import get_experiment
58
69
  from .notifications import progress, tqdm
59
70
  from .checkers import Choices
60
71
  from .xpmutils import DirectoryContext
@@ -8,7 +8,7 @@ import experimaestro.core.objects as objects
8
8
  import experimaestro.core.types as types
9
9
  from experimaestro.generators import PathGenerator
10
10
 
11
- from .core.arguments import Argument as CoreArgument
11
+ from .core.arguments import Argument as CoreArgument, field
12
12
  from .core.objects import Config
13
13
  from .core.types import Any, Identifier, TypeProxy, Type, ObjectType
14
14
  from .utils import logger
@@ -134,12 +134,22 @@ class param:
134
134
  self.type = Type.fromType(type) if type else None
135
135
  self.help = help
136
136
  self.ignored = ignored
137
- self.default = default
138
137
  self.required = required
139
- self.generator = None
140
138
  self.checker = checker
141
139
  self.constant = constant
142
140
 
141
+ self.generator = None
142
+ self.default = None
143
+
144
+ # Set default or generator
145
+ if isinstance(default, field):
146
+ if default.default is not None:
147
+ self.default = default
148
+ elif default.default_factory is not None:
149
+ self.generator = default.default_factory
150
+ else:
151
+ self.default = default
152
+
143
153
  def __call__(self, tp):
144
154
  # Don't annotate in task mode
145
155
  tp.__getxpmtype__().addAnnotation(self)
@@ -1,20 +1,27 @@
1
+ import asyncio
2
+ import logging
1
3
  from typing import Any, Callable, Dict, List, Optional
2
4
  import pyparsing as pp
3
5
  from pathlib import Path
4
6
  import json
5
7
  from experimaestro.compat import cached_property
6
- import regex
8
+ import re
7
9
  from experimaestro.scheduler import JobState
8
10
 
9
11
 
10
12
  class JobInformation:
11
- def __init__(self, path: Path, scriptname: str):
13
+ def __init__(self, path: Path, scriptname: str, check: bool = False):
12
14
  self.path = path
13
15
  self.scriptname = scriptname
16
+ self.check = check
14
17
 
15
18
  @cached_property
16
19
  def params(self):
17
- return json.loads((self.path / "params.json").read_text())
20
+ try:
21
+ return json.loads((self.path / "params.json").read_text())
22
+ except Exception:
23
+ logging.warning("Could not load params.json in %s", self.path)
24
+ return {"tags": {}}
18
25
 
19
26
  @cached_property
20
27
  def tags(self) -> List[str]:
@@ -27,6 +34,13 @@ class JobInformation:
27
34
  if (self.path / f"{self.scriptname}.failed").is_file():
28
35
  return JobState.ERROR
29
36
  if (self.path / f"{self.scriptname}.pid").is_file():
37
+ if self.check:
38
+ if process := self.getprocess():
39
+ state = asyncio.run(process.aio_state(0))
40
+ if state is None or state.finished:
41
+ return JobState.ERROR
42
+ else:
43
+ return JobState.ERROR
30
44
  return JobState.RUNNING
31
45
  else:
32
46
  return None
@@ -87,7 +101,7 @@ class NotInExpr(BaseInExpr):
87
101
  class RegexExpr:
88
102
  def __init__(self, tokens):
89
103
  self.var, expr = tokens
90
- self.regex = regex.compile(expr)
104
+ self.regex = re.compile(expr)
91
105
 
92
106
  def __repr__(self):
93
107
  return f"""REGEX[{self.varname}, {self.value}]"""
@@ -103,7 +117,7 @@ class RegexExpr:
103
117
  if not value:
104
118
  return False
105
119
 
106
- return self.regex.match(value)
120
+ return self.re.match(value)
107
121
 
108
122
 
109
123
  class ConstantString:
experimaestro/cli/jobs.py CHANGED
@@ -1,4 +1,5 @@
1
1
  # flake8: noqa: T201
2
+ import asyncio
2
3
  import subprocess
3
4
  from typing import Optional
4
5
  from shutil import rmtree
@@ -49,6 +50,7 @@ def process(
49
50
  filter="",
50
51
  perform=False,
51
52
  fullpath=False,
53
+ check=False,
52
54
  ):
53
55
  from .filter import createFilter, JobInformation
54
56
  from experimaestro.scheduler import JobState
@@ -63,13 +65,14 @@ def process(
63
65
  for job in p.glob("jobs/*/*"):
64
66
  job_path = job.resolve()
65
67
  if job_path.is_dir():
66
- *_, scriptname = job_path.parent.name.rsplit(".", 1)
67
- job2xp.setdefault(scriptname, set()).add(p.name)
68
+ job2xp.setdefault(job_path.name, set()).add(p.name)
68
69
 
69
70
  if (p / "jobs.bak").is_dir():
70
71
  cprint(f" Experiment {p.name} has not finished yet", "red")
71
72
  if (not perform) and (kill or clean):
72
- cprint(" Preventing kill/clean (use --force if you want to)", "yellow")
73
+ cprint(
74
+ " Preventing kill/clean (use --perform if you want to)", "yellow"
75
+ )
73
76
  kill = False
74
77
  clean = False
75
78
 
@@ -79,11 +82,11 @@ def process(
79
82
  p = job.resolve()
80
83
  if p.is_dir():
81
84
  *_, scriptname = p.parent.name.rsplit(".", 1)
82
- xps = job2xp.get(scriptname, set())
85
+ xps = job2xp.get(job.name, set())
83
86
  if experiment and experiment not in xps:
84
87
  continue
85
88
 
86
- info = JobInformation(p, scriptname)
89
+ info = JobInformation(p, scriptname, check=check)
87
90
  job_str = (
88
91
  (str(job.resolve()) if fullpath else f"{job.parent.name}/{job.name}")
89
92
  + " "
@@ -151,6 +154,7 @@ def process(
151
154
  @click.option("--ready", is_flag=True, help="Include tasks which are not yet scheduled")
152
155
  @click.option("--filter", default="", help="Filter expression")
153
156
  @click.option("--fullpath", is_flag=True, help="Prints full paths")
157
+ @click.option("--no-check", is_flag=True, help="Check that running jobs")
154
158
  @jobs.command()
155
159
  @click.pass_context
156
160
  def list(
@@ -160,6 +164,7 @@ def list(
160
164
  tags: bool,
161
165
  ready: bool,
162
166
  fullpath: bool,
167
+ no_check: bool,
163
168
  ):
164
169
  process(
165
170
  ctx.obj.workspace,
@@ -168,6 +173,7 @@ def list(
168
173
  tags=tags,
169
174
  ready=ready,
170
175
  fullpath=fullpath,
176
+ check=not no_check,
171
177
  )
172
178
 
173
179
 
@@ -187,6 +193,7 @@ def kill(
187
193
  ready: bool,
188
194
  fullpath: bool,
189
195
  perform: bool,
196
+ check: bool,
190
197
  ):
191
198
  process(
192
199
  ctx.obj.workspace,
@@ -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,7 +9,8 @@ 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
@@ -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
 
@@ -101,7 +102,11 @@ class Process:
101
102
  if Process.HANDLERS is None:
102
103
  Process.HANDLERS = {}
103
104
  for ep in pkg_resources.iter_entry_points(group="experimaestro.process"):
104
- Process.HANDLERS[ep.name] = ep.load()
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:
@@ -200,7 +200,7 @@ class SshProcessBuilder(ProcessBuilder):
200
200
  super().__init__()
201
201
  self.connector = connector
202
202
 
203
- def start(self):
203
+ def start(self, task_mode: bool = False):
204
204
  """Start the process"""
205
205
 
206
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,24 @@ 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
+ if default.default is not None:
90
+ self.default = default.default
91
+ elif default.default_factory is not None:
92
+ self.generator = default.default_factory
93
+ else:
94
+ self.default = default
95
+
83
96
  assert (
84
97
  not self.constant or self.default is not None
85
98
  ), "Cannot be constant without default"
@@ -170,6 +183,25 @@ DataPath = Annotated[Path, dataHint]
170
183
  """Annotates a path that should be kept to restore an object to its state"""
171
184
 
172
185
 
186
+ class field:
187
+ """Extra information for a given experimaestro field (param or meta)"""
188
+
189
+ def __init__(
190
+ self,
191
+ *,
192
+ default: Any = None,
193
+ default_factory: Callable = None,
194
+ ignore_generated=False,
195
+ ):
196
+ assert not (
197
+ (default is not None) and (default_factory is not None)
198
+ ), "default and default_factory are mutually exclusive options"
199
+
200
+ self.default_factory = default_factory
201
+ self.default = default
202
+ self.ignore_generated = ignore_generated
203
+
204
+
173
205
  class help(TypeAnnotation):
174
206
  def __init__(self, text: str):
175
207
  self.text = text
@@ -0,0 +1,52 @@
1
+ from collections import defaultdict
2
+ import threading
3
+ from typing import Callable, ClassVar, Optional
4
+ from experimaestro.core.objects import ConfigInformation
5
+ from experimaestro.scheduler import Listener, Job, JobState, experiment
6
+
7
+
8
+ class TaskEventListener(Listener):
9
+ INSTANCE: ClassVar[Optional["TaskEventListener"]] = None
10
+ """The general instance"""
11
+
12
+ def __init__(self):
13
+ self.lock = threading.Lock()
14
+ self.experiments: set[int] = set()
15
+
16
+ self._on_completed: defaultdict[int, Callable] = defaultdict(list)
17
+
18
+ @staticmethod
19
+ def connect(xp: experiment):
20
+ _self = TaskEventListener.instance()
21
+ with _self.lock:
22
+ if id(xp) not in _self.experiments:
23
+ _self.experiments.add(id(xp))
24
+ xp.scheduler.addlistener(_self)
25
+
26
+ @staticmethod
27
+ def instance():
28
+ if TaskEventListener.INSTANCE is None:
29
+ TaskEventListener.INSTANCE = TaskEventListener()
30
+
31
+ return TaskEventListener.INSTANCE
32
+
33
+ def job_state(self, job: Job):
34
+ if job.state == JobState.DONE:
35
+ with self.lock:
36
+ for callback in self._on_completed.get(id(job.config.__xpm__), []):
37
+ callback()
38
+
39
+ @staticmethod
40
+ def on_completed(
41
+ config_information: ConfigInformation, callback: Callable[[], None]
42
+ ):
43
+ instance = TaskEventListener.instance()
44
+
45
+ with instance.lock:
46
+ instance._on_completed[id(config_information)].append(callback)
47
+
48
+ if (
49
+ config_information.job is not None
50
+ and config_information.job == JobState.DONE
51
+ ):
52
+ callback()
@@ -1,22 +1,21 @@
1
1
  from contextlib import contextmanager
2
2
  from pathlib import Path
3
+
4
+ try:
5
+ from pathlib import UnsupportedOperation
6
+ except ImportError:
7
+ UnsupportedOperation = OSError
3
8
  import shutil
4
9
  from typing import List, Optional, Protocol, Set, Union
5
10
  import os
6
- import sys
7
-
8
- has_hardlink_to = sys.version_info.major == 3 and sys.version_info.minor >= 10
9
11
 
10
12
 
11
13
  def shallow_copy(src_path: Path, dest_path: Path):
12
14
  """Copy a directory or file, trying to use hard links if possible"""
13
15
  if src_path.is_file():
14
16
  try:
15
- if has_hardlink_to:
16
- dest_path.hardlink_to(src_path)
17
- else:
18
- dest_path.link_to(src_path)
19
- except OSError:
17
+ dest_path.hardlink_to(src_path)
18
+ except (NotImplementedError, UnsupportedOperation, OSError):
20
19
  shutil.copy(src_path, dest_path)
21
20
  else:
22
21
  if dest_path.exists():
@@ -74,7 +73,7 @@ class SerializationContext:
74
73
 
75
74
 
76
75
  class SerializedPathLoader(Protocol):
77
- def __call__(path: Union[Path, str, SerializedPath]) -> Path:
76
+ def __call__(self, path: Union[Path, str, SerializedPath]) -> Path:
78
77
  """Get a filesystem path from a relative path
79
78
 
80
79
  :param path: The relative path