runem 0.5.0__py3-none-any.whl → 0.7.0__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.
- runem/VERSION +1 -1
- runem/blocking_print.py +10 -2
- runem/cli/initialise_options.py +0 -1
- runem/command_line.py +4 -7
- runem/config.py +8 -4
- runem/config_parse.py +2 -1
- runem/config_validate.py +47 -0
- runem/informative_dict.py +7 -2
- runem/job.py +1 -3
- runem/job_execute.py +12 -9
- runem/job_filter.py +5 -5
- runem/job_wrapper_python.py +3 -4
- runem/log.py +27 -5
- runem/report.py +8 -4
- runem/run_command.py +62 -28
- runem/runem.py +46 -64
- runem/schema.yml +137 -0
- runem/types/__init__.py +2 -1
- runem/types/errors.py +10 -0
- runem/types/hooks.py +1 -1
- runem/types/types_jobs.py +21 -24
- runem/utils.py +12 -0
- runem/yaml_utils.py +19 -0
- runem/yaml_validation.py +28 -0
- runem-0.7.0.dist-info/METADATA +162 -0
- runem-0.7.0.dist-info/RECORD +56 -0
- {runem-0.5.0.dist-info → runem-0.7.0.dist-info}/WHEEL +1 -1
- scripts/test_hooks/py.py +63 -1
- runem-0.5.0.dist-info/METADATA +0 -164
- runem-0.5.0.dist-info/RECORD +0 -52
- {runem-0.5.0.dist-info → runem-0.7.0.dist-info}/entry_points.txt +0 -0
- {runem-0.5.0.dist-info → runem-0.7.0.dist-info/licenses}/LICENSE +0 -0
- {runem-0.5.0.dist-info → runem-0.7.0.dist-info}/top_level.txt +0 -0
runem/runem.py
CHANGED
@@ -19,6 +19,8 @@ We do:
|
|
19
19
|
- time tests and tell you what used the most time, and how much time run-tests saved
|
20
20
|
you
|
21
21
|
"""
|
22
|
+
|
23
|
+
import contextlib
|
22
24
|
import multiprocessing
|
23
25
|
import os
|
24
26
|
import pathlib
|
@@ -30,10 +32,9 @@ from datetime import timedelta
|
|
30
32
|
from itertools import repeat
|
31
33
|
from multiprocessing.managers import DictProxy, ValueProxy
|
32
34
|
from timeit import default_timer as timer
|
33
|
-
from types import TracebackType
|
34
35
|
|
35
|
-
from rich.console import Console, ConsoleOptions, ConsoleRenderable, RenderResult
|
36
36
|
from rich.spinner import Spinner
|
37
|
+
from rich.status import Status
|
37
38
|
from rich.text import Text
|
38
39
|
|
39
40
|
from runem.blocking_print import RICH_CONSOLE
|
@@ -46,7 +47,9 @@ from runem.job_execute import job_execute
|
|
46
47
|
from runem.job_filter import filter_jobs
|
47
48
|
from runem.log import error, log, warn
|
48
49
|
from runem.report import report_on_run
|
50
|
+
from runem.run_command import RunemJobError
|
49
51
|
from runem.types.common import OrderedPhases, PhaseName
|
52
|
+
from runem.types.errors import SystemExitBad
|
50
53
|
from runem.types.filters import FilePathListLookup
|
51
54
|
from runem.types.hooks import HookName
|
52
55
|
from runem.types.runem_config import Config, Jobs, PhaseGroupedJobs
|
@@ -56,7 +59,7 @@ from runem.types.types_jobs import (
|
|
56
59
|
JobRunMetadatasByPhase,
|
57
60
|
JobTiming,
|
58
61
|
)
|
59
|
-
from runem.utils import
|
62
|
+
from runem.utils import printable_set_coloured
|
60
63
|
|
61
64
|
|
62
65
|
def _determine_run_parameters(argv: typing.List[str]) -> ConfigMetadata:
|
@@ -67,7 +70,6 @@ def _determine_run_parameters(argv: typing.List[str]) -> ConfigMetadata:
|
|
67
70
|
|
68
71
|
Return a ConfigMetadata object with all the required information.
|
69
72
|
"""
|
70
|
-
|
71
73
|
# Because we want to be able to show logging whilst parsing .runem.yml config, we
|
72
74
|
# need to check the state of the logging-verbosity switches here, manually, as well.
|
73
75
|
verbose = "--verbose" in argv
|
@@ -92,38 +94,8 @@ def _determine_run_parameters(argv: typing.List[str]) -> ConfigMetadata:
|
|
92
94
|
return config_metadata
|
93
95
|
|
94
96
|
|
95
|
-
class DummySpinner(ConsoleRenderable): # pragma: no cover
|
96
|
-
"""A dummy spinner for when spinners are disabled."""
|
97
|
-
|
98
|
-
def __init__(self) -> None:
|
99
|
-
self.text = ""
|
100
|
-
|
101
|
-
def __rich__(self) -> Text:
|
102
|
-
"""Return a rich Text object for rendering."""
|
103
|
-
return Text(self.text)
|
104
|
-
|
105
|
-
def __rich_console__(
|
106
|
-
self, console: Console, options: ConsoleOptions
|
107
|
-
) -> RenderResult:
|
108
|
-
"""Yield an empty string or placeholder text."""
|
109
|
-
yield Text(self.text)
|
110
|
-
|
111
|
-
def __enter__(self) -> None:
|
112
|
-
"""Support for context manager."""
|
113
|
-
pass
|
114
|
-
|
115
|
-
def __exit__(
|
116
|
-
self,
|
117
|
-
exc_type: typing.Optional[typing.Type[BaseException]],
|
118
|
-
exc_value: typing.Optional[BaseException],
|
119
|
-
traceback: typing.Optional[TracebackType],
|
120
|
-
) -> None:
|
121
|
-
"""Support for context manager."""
|
122
|
-
pass
|
123
|
-
|
124
|
-
|
125
97
|
def _update_progress(
|
126
|
-
|
98
|
+
phase: str,
|
127
99
|
running_jobs: typing.Dict[str, str],
|
128
100
|
completed_jobs: typing.Dict[str, str],
|
129
101
|
all_jobs: Jobs,
|
@@ -134,36 +106,44 @@ def _update_progress(
|
|
134
106
|
"""Updates progress report periodically for running tasks.
|
135
107
|
|
136
108
|
Args:
|
137
|
-
|
109
|
+
phase (str): The currently running phase.
|
138
110
|
running_jobs (Dict[str, str]): The currently running jobs.
|
111
|
+
completed_jobs (Dict[str, str]): The jobs that have finished work.
|
139
112
|
all_jobs (Jobs): All jobs, encompassing both completed and running jobs.
|
140
113
|
is_running (ValueProxy[bool]): Flag indicating if jobs are still running.
|
141
114
|
num_workers (int): Indicates the number of workers performing the jobs.
|
115
|
+
show_spinner (bool): Whether to show the animated spinner or not.
|
142
116
|
"""
|
143
|
-
# Using the `rich` module to show a loading spinner on console
|
144
|
-
spinner: typing.Union[Spinner, DummySpinner]
|
145
|
-
if show_spinner:
|
146
|
-
spinner = Spinner("dots", text="Starting tasks...")
|
147
|
-
else:
|
148
|
-
spinner = DummySpinner()
|
149
|
-
|
150
117
|
last_running_jobs_set: typing.Set[str] = set()
|
151
118
|
|
152
|
-
|
119
|
+
# Using the `rich` module to show a loading spinner on console
|
120
|
+
spinner_ctx: typing.Union[Status, typing.ContextManager[None]] = (
|
121
|
+
RICH_CONSOLE.status(Spinner("dots", text="Starting tasks..."))
|
122
|
+
if show_spinner
|
123
|
+
else contextlib.nullcontext()
|
124
|
+
)
|
125
|
+
|
126
|
+
with spinner_ctx:
|
153
127
|
while is_running.value:
|
154
128
|
running_jobs_set: typing.Set[str] = set(running_jobs.values())
|
155
129
|
|
156
130
|
# Progress report
|
157
131
|
progress: str = f"{len(completed_jobs)}/{len(all_jobs)}"
|
158
|
-
running_jobs_list =
|
159
|
-
running_jobs_set
|
132
|
+
running_jobs_list = printable_set_coloured(
|
133
|
+
running_jobs_set,
|
134
|
+
"blue",
|
160
135
|
) # Reflect current running jobs accurately
|
161
|
-
report: str =
|
136
|
+
report: str = (
|
137
|
+
f"[green]{phase}[/green]: {progress}({num_workers}): "
|
138
|
+
f"{running_jobs_list}"
|
139
|
+
)
|
162
140
|
if show_spinner:
|
163
|
-
|
141
|
+
assert isinstance(spinner_ctx, Status)
|
142
|
+
spinner_ctx.update(Text.from_markup(report))
|
164
143
|
else:
|
165
144
|
if last_running_jobs_set != running_jobs_set:
|
166
145
|
RICH_CONSOLE.log(report)
|
146
|
+
last_running_jobs_set = running_jobs_set
|
167
147
|
|
168
148
|
# Sleep for reduced CPU usage
|
169
149
|
time.sleep(0.1)
|
@@ -176,7 +156,7 @@ def _process_jobs(
|
|
176
156
|
phase: PhaseName,
|
177
157
|
jobs: Jobs,
|
178
158
|
show_spinner: bool,
|
179
|
-
) -> typing.Optional[
|
159
|
+
) -> typing.Optional[RunemJobError]:
|
180
160
|
"""Execute each given job asynchronously.
|
181
161
|
|
182
162
|
This is where the major real-world time savings happen, and it could be
|
@@ -196,12 +176,12 @@ def _process_jobs(
|
|
196
176
|
num_concurrent_procs: int = min(max_num_concurrent_procs, len(jobs))
|
197
177
|
log(
|
198
178
|
(
|
199
|
-
f"Running '{phase}' with {num_concurrent_procs} workers (of "
|
179
|
+
f"Running '[green]{phase}[/green]' with {num_concurrent_procs} workers (of "
|
200
180
|
f"{max_num_concurrent_procs} max) processing {len(jobs)} jobs"
|
201
181
|
)
|
202
182
|
)
|
203
183
|
|
204
|
-
subprocess_error: typing.Optional[
|
184
|
+
subprocess_error: typing.Optional[RunemJobError] = None
|
205
185
|
|
206
186
|
with multiprocessing.Manager() as manager:
|
207
187
|
running_jobs: DictProxy[typing.Any, typing.Any] = manager.dict()
|
@@ -235,7 +215,7 @@ def _process_jobs(
|
|
235
215
|
repeat(file_lists),
|
236
216
|
),
|
237
217
|
)
|
238
|
-
except
|
218
|
+
except RunemJobError as err: # pylint: disable=broad-exception-caught
|
239
219
|
subprocess_error = err
|
240
220
|
finally:
|
241
221
|
# Signal the terminal_writer process to exit
|
@@ -251,7 +231,7 @@ def _process_jobs_by_phase(
|
|
251
231
|
filtered_jobs_by_phase: PhaseGroupedJobs,
|
252
232
|
in_out_job_run_metadatas: JobRunMetadatasByPhase,
|
253
233
|
show_spinner: bool,
|
254
|
-
) -> typing.Optional[
|
234
|
+
) -> typing.Optional[RunemJobError]:
|
255
235
|
"""Execute each job asynchronously, grouped by phase.
|
256
236
|
|
257
237
|
Whilst it is conceptually useful to group jobs by 'phase', Phases are
|
@@ -275,7 +255,7 @@ def _process_jobs_by_phase(
|
|
275
255
|
if config_metadata.args.verbose:
|
276
256
|
log(f"Running Phase {phase}")
|
277
257
|
|
278
|
-
failure_exception: typing.Optional[
|
258
|
+
failure_exception: typing.Optional[RunemJobError] = _process_jobs(
|
279
259
|
config_metadata,
|
280
260
|
file_lists,
|
281
261
|
in_out_job_run_metadatas,
|
@@ -293,7 +273,7 @@ def _process_jobs_by_phase(
|
|
293
273
|
|
294
274
|
|
295
275
|
MainReturnType = typing.Tuple[
|
296
|
-
ConfigMetadata, JobRunMetadatasByPhase, typing.Optional[
|
276
|
+
ConfigMetadata, JobRunMetadatasByPhase, typing.Optional[RunemJobError]
|
297
277
|
]
|
298
278
|
|
299
279
|
|
@@ -316,8 +296,8 @@ def _main(
|
|
316
296
|
log(f"found {len(file_lists)} batches, ", end="")
|
317
297
|
for tag in sorted(file_lists.keys()):
|
318
298
|
file_list = file_lists[tag]
|
319
|
-
log(f"{len(file_list)} '{tag}' files, ",
|
320
|
-
log(
|
299
|
+
log(f"{len(file_list)} '{tag}' files, ", prefix=False, end="")
|
300
|
+
log(prefix=False) # new line
|
321
301
|
|
322
302
|
filtered_jobs_by_phase: PhaseGroupedJobs = filter_jobs(
|
323
303
|
config_metadata=config_metadata,
|
@@ -333,7 +313,7 @@ def _main(
|
|
333
313
|
|
334
314
|
start = timer()
|
335
315
|
|
336
|
-
failure_exception: typing.Optional[
|
316
|
+
failure_exception: typing.Optional[RunemJobError] = _process_jobs_by_phase(
|
337
317
|
config_metadata,
|
338
318
|
file_lists,
|
339
319
|
filtered_jobs_by_phase,
|
@@ -362,7 +342,7 @@ def timed_main(argv: typing.List[str]) -> None:
|
|
362
342
|
start = timer()
|
363
343
|
config_metadata: ConfigMetadata
|
364
344
|
job_run_metadatas: JobRunMetadatasByPhase
|
365
|
-
failure_exception: typing.Optional[
|
345
|
+
failure_exception: typing.Optional[RunemJobError]
|
366
346
|
config_metadata, job_run_metadatas, failure_exception = _main(argv)
|
367
347
|
phase_run_oder: OrderedPhases = config_metadata.phases
|
368
348
|
end = timer()
|
@@ -372,14 +352,15 @@ def timed_main(argv: typing.List[str]) -> None:
|
|
372
352
|
system_time_spent, wall_clock_time_saved = report_on_run(
|
373
353
|
phase_run_oder, job_run_metadatas, time_taken
|
374
354
|
)
|
375
|
-
message: str = "DONE: runem took"
|
355
|
+
message: str = "[green bold]DONE[/green bold]: runem took"
|
376
356
|
if failure_exception:
|
377
|
-
message = "FAILED: your jobs failed after"
|
357
|
+
message = "[red bold]FAILED[/red bold]: your jobs failed after"
|
378
358
|
log(
|
379
359
|
(
|
380
360
|
f"{message}: {time_taken.total_seconds()}s, "
|
381
|
-
f"saving you {wall_clock_time_saved.total_seconds()}s, "
|
382
|
-
|
361
|
+
f"saving you [green]{wall_clock_time_saved.total_seconds()}s[/green], "
|
362
|
+
"without runem you would have waited "
|
363
|
+
f"[red]{system_time_spent.total_seconds()}s[/red]"
|
383
364
|
)
|
384
365
|
)
|
385
366
|
|
@@ -392,7 +373,8 @@ def timed_main(argv: typing.List[str]) -> None:
|
|
392
373
|
if failure_exception is not None:
|
393
374
|
# we got a failure somewhere, now that we've reported the timings we
|
394
375
|
# re-raise.
|
395
|
-
|
376
|
+
error(failure_exception.stdout)
|
377
|
+
raise SystemExitBad(1) from failure_exception
|
396
378
|
|
397
379
|
|
398
380
|
if __name__ == "__main__":
|
runem/schema.yml
ADDED
@@ -0,0 +1,137 @@
|
|
1
|
+
#%RAML 1.0 (← just a comment so VS Code picks up YAML)
|
2
|
+
$schema: "https://json-schema.org/draft/2020-12/schema"
|
3
|
+
$title: Runem pipeline definition
|
4
|
+
$defs:
|
5
|
+
# ----- common pieces -------------------------------------------------------
|
6
|
+
phase:
|
7
|
+
type: string
|
8
|
+
|
9
|
+
addr:
|
10
|
+
type: object
|
11
|
+
required: [file, function]
|
12
|
+
additionalProperties: false
|
13
|
+
properties:
|
14
|
+
file: { type: string, minLength: 1 }
|
15
|
+
function: { type: string, minLength: 1 }
|
16
|
+
|
17
|
+
ctx:
|
18
|
+
type: object
|
19
|
+
additionalProperties: false
|
20
|
+
properties:
|
21
|
+
cwd:
|
22
|
+
oneOf:
|
23
|
+
- type: string
|
24
|
+
- type: array
|
25
|
+
minItems: 1
|
26
|
+
items: { type: string, minLength: 1 }
|
27
|
+
params:
|
28
|
+
type: object # free‑form kv‑pairs for hooks
|
29
|
+
additionalProperties: true
|
30
|
+
|
31
|
+
when:
|
32
|
+
type: object
|
33
|
+
required: [phase]
|
34
|
+
additionalProperties: false
|
35
|
+
properties:
|
36
|
+
phase: { $ref: "#/$defs/phase" }
|
37
|
+
tags:
|
38
|
+
type: array
|
39
|
+
items: { type: string, minLength: 1 }
|
40
|
+
uniqueItems: true
|
41
|
+
|
42
|
+
# ----- top‑level entity types ---------------------------------------------
|
43
|
+
config:
|
44
|
+
type: object
|
45
|
+
required: []
|
46
|
+
additionalProperties: false
|
47
|
+
properties:
|
48
|
+
min_version:
|
49
|
+
type: string
|
50
|
+
|
51
|
+
phases:
|
52
|
+
type: array
|
53
|
+
minItems: 1
|
54
|
+
items: { $ref: "#/$defs/phase" }
|
55
|
+
uniqueItems: true
|
56
|
+
|
57
|
+
files:
|
58
|
+
type: [array, 'null']
|
59
|
+
minItems: 0
|
60
|
+
items:
|
61
|
+
type: object
|
62
|
+
required: [filter]
|
63
|
+
additionalProperties: false
|
64
|
+
properties:
|
65
|
+
filter:
|
66
|
+
type: object
|
67
|
+
required: [tag, regex]
|
68
|
+
additionalProperties: false
|
69
|
+
properties:
|
70
|
+
tag: { type: string, minLength: 1 }
|
71
|
+
regex: { type: string, minLength: 1 } # leave pattern‑checking to the engine
|
72
|
+
|
73
|
+
options:
|
74
|
+
type: [array, 'null']
|
75
|
+
minItems: 0
|
76
|
+
items:
|
77
|
+
type: object
|
78
|
+
required: [option]
|
79
|
+
additionalProperties: false
|
80
|
+
properties:
|
81
|
+
option:
|
82
|
+
type: object
|
83
|
+
required: [name, type, default, desc]
|
84
|
+
additionalProperties: false
|
85
|
+
properties:
|
86
|
+
name: { type: string, minLength: 1 }
|
87
|
+
alias: { type: string, minLength: 1 }
|
88
|
+
desc: { type: string, minLength: 1 }
|
89
|
+
type:
|
90
|
+
const: bool # always "bool" per sample
|
91
|
+
default: { type: boolean }
|
92
|
+
|
93
|
+
hook:
|
94
|
+
type: object
|
95
|
+
required: [hook_name]
|
96
|
+
oneOf:
|
97
|
+
- required: [command]
|
98
|
+
- required: [addr]
|
99
|
+
additionalProperties: false
|
100
|
+
properties:
|
101
|
+
hook_name: { type: string, minLength: 1 }
|
102
|
+
addr: { $ref: "#/$defs/addr" }
|
103
|
+
command: { type: string, minLength: 1 }
|
104
|
+
|
105
|
+
job:
|
106
|
+
type: object
|
107
|
+
oneOf:
|
108
|
+
- required: [command]
|
109
|
+
- required: [addr]
|
110
|
+
additionalProperties: false
|
111
|
+
properties:
|
112
|
+
label: { type: string, minLength: 1 }
|
113
|
+
addr: { $ref: "#/$defs/addr" }
|
114
|
+
command: { type: string, minLength: 1 }
|
115
|
+
ctx: { $ref: "#/$defs/ctx" }
|
116
|
+
when: { $ref: "#/$defs/when" }
|
117
|
+
oneOf:
|
118
|
+
- required: [addr] # either addr
|
119
|
+
- required: [command] # or command, but not both
|
120
|
+
not:
|
121
|
+
anyOf:
|
122
|
+
- required: [addr, command] # forbid both together
|
123
|
+
|
124
|
+
# ---------- ROOT -------------------------------------------------------------
|
125
|
+
type: array
|
126
|
+
minItems: 1
|
127
|
+
items:
|
128
|
+
type: object
|
129
|
+
additionalProperties: false
|
130
|
+
oneOf:
|
131
|
+
- required: [config]
|
132
|
+
- required: [hook]
|
133
|
+
- required: [job]
|
134
|
+
properties:
|
135
|
+
config: { $ref: "#/$defs/config" }
|
136
|
+
hook: { $ref: "#/$defs/hook" }
|
137
|
+
job: { $ref: "#/$defs/job" }
|
runem/types/__init__.py
CHANGED
@@ -1,11 +1,12 @@
|
|
1
1
|
from runem.types.common import FilePathList, JobName
|
2
2
|
from runem.types.options import Options
|
3
|
-
from runem.types.types_jobs import HookKwargs, JobKwargs, JobReturnData
|
3
|
+
from runem.types.types_jobs import HookKwargs, JobKwargs, JobReturn, JobReturnData
|
4
4
|
|
5
5
|
__all__ = [
|
6
6
|
"FilePathList",
|
7
7
|
"HookKwargs",
|
8
8
|
"JobName",
|
9
|
+
"JobReturn",
|
9
10
|
"JobReturnData",
|
10
11
|
"Options",
|
11
12
|
"JobKwargs",
|
runem/types/errors.py
CHANGED
@@ -1,4 +1,14 @@
|
|
1
|
+
from typing import Optional
|
2
|
+
|
3
|
+
|
1
4
|
class FunctionNotFound(ValueError):
|
2
5
|
"""Thrown when the test-function cannot be found."""
|
3
6
|
|
4
7
|
pass
|
8
|
+
|
9
|
+
|
10
|
+
class SystemExitBad(SystemExit):
|
11
|
+
def __init__(self, code: Optional[int] = None) -> None:
|
12
|
+
super().__init__()
|
13
|
+
self.code = 1 if code is None else code # non-zero bad exit code
|
14
|
+
assert self.code > 0, "A bad exit code should be non-zero and >0"
|
runem/types/hooks.py
CHANGED
runem/types/types_jobs.py
CHANGED
@@ -1,28 +1,25 @@
|
|
1
|
-
"""
|
1
|
+
"""Job‑typing helpers.
|
2
|
+
|
3
|
+
Cross‑version advice
|
4
|
+
--------------------
|
5
|
+
* Type variadic keyword arguments as **kwargs: Unpack[KwArgsT] for clarity.
|
6
|
+
* Always import Unpack from ``typing_extensions``.
|
7
|
+
- Std‑lib Unpack appears only in Py 3.12+.
|
8
|
+
- ``typing_extensions`` works on 3.9‑3.12, so one import path keeps
|
9
|
+
mypy/pyright happy without conditional logic.
|
10
|
+
|
11
|
+
Example:
|
12
|
+
~~~~~~~
|
13
|
+
from typing_extensions import TypedDict, Unpack
|
14
|
+
|
15
|
+
|
16
|
+
class SaveKwArgs(TypedDict):
|
17
|
+
path: str
|
18
|
+
overwrite: bool
|
19
|
+
|
2
20
|
|
3
|
-
|
4
|
-
|
5
|
-
We have tried several ways to define a Generic type that encapsulates
|
6
|
-
`**kwargs: SingleType`
|
7
|
-
... but none of the solutions worked with python 3.9 -> 3.12 and mypy 1.9.0,
|
8
|
-
so we have to recommend instead using:
|
9
|
-
`**kwargs: Unpack[KwArgsType]`
|
10
|
-
|
11
|
-
For this to work across versions of python where support for Unpack changes;
|
12
|
-
for example `Unpack` is a python 3.12 feature, but available in the
|
13
|
-
`typing_extensions` module.
|
14
|
-
|
15
|
-
So, for now, it looks like we get away with importing `Unpack` from the
|
16
|
-
`typing_extensions` module, even in python 3.12, so we will use, and
|
17
|
-
recommend using, the `typing_extensions` of `Unpack`, until it becomes
|
18
|
-
obsolete.
|
19
|
-
|
20
|
-
Alternatively, we can use the following, but it's unnecessarily verbose.
|
21
|
-
|
22
|
-
if sys.version_info >= (3, 12): # pragma: no coverage
|
23
|
-
from typing import Unpack
|
24
|
-
else: # pragma: no coverage
|
25
|
-
from typing_extensions import Unpack
|
21
|
+
def save_job(**kwargs: Unpack[SaveKwArgs]) -> None:
|
22
|
+
...
|
26
23
|
"""
|
27
24
|
|
28
25
|
import pathlib
|
runem/utils.py
CHANGED
@@ -4,3 +4,15 @@ import typing
|
|
4
4
|
def printable_set(some_set: typing.Set[typing.Any]) -> str:
|
5
5
|
"""Get a printable, deterministic string version of a set."""
|
6
6
|
return ", ".join([f"'{set_item}'" for set_item in sorted(list(some_set))])
|
7
|
+
|
8
|
+
|
9
|
+
def printable_set_coloured(some_set: typing.Set[typing.Any], colour: str) -> str:
|
10
|
+
"""`printable_set` but elements are surrounded with colour mark-up.
|
11
|
+
|
12
|
+
Parameters:
|
13
|
+
some_set: a set of anything
|
14
|
+
colour: a `rich` Console supported colour
|
15
|
+
"""
|
16
|
+
return ", ".join(
|
17
|
+
[f"'[{colour}]{set_item}[/{colour}]'" for set_item in sorted(list(some_set))]
|
18
|
+
)
|
runem/yaml_utils.py
ADDED
@@ -0,0 +1,19 @@
|
|
1
|
+
import pathlib
|
2
|
+
import typing
|
3
|
+
|
4
|
+
import yaml
|
5
|
+
|
6
|
+
|
7
|
+
def load_yaml_object(yaml_file: pathlib.Path) -> typing.Any:
|
8
|
+
"""Loads using full_load, a yaml file.
|
9
|
+
|
10
|
+
This is likely to have safety concerns in non-trusted projects.
|
11
|
+
|
12
|
+
Returns:
|
13
|
+
YAML Loader object: the full PyYAML loader object.
|
14
|
+
"""
|
15
|
+
# Do a full, untrusted load of the runem config
|
16
|
+
# TODO: work out safety concerns of this
|
17
|
+
with yaml_file.open("r+", encoding="utf-8") as file_handle:
|
18
|
+
full_yaml_object: typing.Any = yaml.full_load(file_handle)
|
19
|
+
return full_yaml_object
|
runem/yaml_validation.py
ADDED
@@ -0,0 +1,28 @@
|
|
1
|
+
from typing import Any, List
|
2
|
+
|
3
|
+
from jsonschema import Draft202012Validator, ValidationError
|
4
|
+
|
5
|
+
# For now just return the raw ValidationErrors as a list
|
6
|
+
ValidationErrors = List[ValidationError]
|
7
|
+
|
8
|
+
|
9
|
+
def validate_yaml(yaml_data: Any, schema: Any) -> ValidationErrors:
|
10
|
+
"""Validates the give yaml data against the given schema, returning any errors.
|
11
|
+
|
12
|
+
We use more future-looking validation so that we can have richer and more
|
13
|
+
descriptive schema.
|
14
|
+
|
15
|
+
Params:
|
16
|
+
instance: JSON data loaded via `load_json` or similar
|
17
|
+
schema: schema object compatible with a Draft202012Validator
|
18
|
+
|
19
|
+
Returns:
|
20
|
+
ValidationErrors: a sorted list of errors in the file, empty if none found
|
21
|
+
"""
|
22
|
+
validator = Draft202012Validator(schema)
|
23
|
+
errors: ValidationErrors = sorted(
|
24
|
+
validator.iter_errors(yaml_data),
|
25
|
+
key=lambda e: e.path,
|
26
|
+
)
|
27
|
+
|
28
|
+
return errors
|