runem 0.0.27__py3-none-any.whl → 0.0.29__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/command_line.py +7 -6
- runem/config_metadata.py +5 -4
- runem/informative_dict.py +42 -0
- runem/job_execute.py +55 -24
- runem/job_filter.py +16 -3
- runem/report.py +141 -34
- runem/run_command.py +42 -4
- runem/runem.py +20 -7
- runem/types.py +21 -3
- {runem-0.0.27.dist-info → runem-0.0.29.dist-info}/METADATA +8 -7
- {runem-0.0.27.dist-info → runem-0.0.29.dist-info}/RECORD +16 -15
- {runem-0.0.27.dist-info → runem-0.0.29.dist-info}/WHEEL +1 -1
- {runem-0.0.27.dist-info → runem-0.0.29.dist-info}/LICENSE +0 -0
- {runem-0.0.27.dist-info → runem-0.0.29.dist-info}/entry_points.txt +0 -0
- {runem-0.0.27.dist-info → runem-0.0.29.dist-info}/top_level.txt +0 -0
runem/VERSION
CHANGED
@@ -1 +1 @@
|
|
1
|
-
0.0.
|
1
|
+
0.0.29
|
runem/command_line.py
CHANGED
@@ -5,9 +5,10 @@ import sys
|
|
5
5
|
import typing
|
6
6
|
|
7
7
|
from runem.config_metadata import ConfigMetadata
|
8
|
+
from runem.informative_dict import InformativeDict
|
8
9
|
from runem.log import log
|
9
10
|
from runem.runem_version import get_runem_version
|
10
|
-
from runem.types import JobNames, OptionConfig,
|
11
|
+
from runem.types import JobNames, OptionConfig, OptionsWritable
|
11
12
|
from runem.utils import printable_set
|
12
13
|
|
13
14
|
|
@@ -175,7 +176,7 @@ def parse_args(
|
|
175
176
|
# cleanly exit
|
176
177
|
sys.exit(0)
|
177
178
|
|
178
|
-
options:
|
179
|
+
options: OptionsWritable = initialise_options(config_metadata, args)
|
179
180
|
|
180
181
|
if not _validate_filters(config_metadata, args):
|
181
182
|
sys.exit(1)
|
@@ -245,15 +246,15 @@ def _validate_filters(
|
|
245
246
|
def initialise_options(
|
246
247
|
config_metadata: ConfigMetadata,
|
247
248
|
args: argparse.Namespace,
|
248
|
-
) ->
|
249
|
+
) -> OptionsWritable:
|
249
250
|
"""Initialises and returns the set of options to use for this run.
|
250
251
|
|
251
252
|
Returns the options dictionary
|
252
253
|
"""
|
253
254
|
|
254
|
-
options:
|
255
|
-
option["name"]: option["default"] for option in config_metadata.options_config
|
256
|
-
|
255
|
+
options: OptionsWritable = InformativeDict(
|
256
|
+
{option["name"]: option["default"] for option in config_metadata.options_config}
|
257
|
+
)
|
257
258
|
if config_metadata.options_config and args.overrides_on: # pragma: no branch
|
258
259
|
for option_name in args.overrides_on: # pragma: no branch
|
259
260
|
options[option_name] = True
|
runem/config_metadata.py
CHANGED
@@ -1,12 +1,13 @@
|
|
1
1
|
import argparse
|
2
2
|
import pathlib
|
3
3
|
|
4
|
+
from runem.informative_dict import InformativeDict
|
4
5
|
from runem.types import (
|
5
6
|
JobNames,
|
6
7
|
JobPhases,
|
7
8
|
JobTags,
|
8
9
|
OptionConfigs,
|
9
|
-
|
10
|
+
OptionsWritable,
|
10
11
|
OrderedPhases,
|
11
12
|
PhaseGroupedJobs,
|
12
13
|
TagFileFilters,
|
@@ -24,7 +25,7 @@ class ConfigMetadata:
|
|
24
25
|
all_job_phases: JobPhases # the set of job-phases (should be subset of 'phases')
|
25
26
|
all_job_tags: JobTags # the set of job-tags (used for filtering)
|
26
27
|
|
27
|
-
options:
|
28
|
+
options: OptionsWritable # the final configured options to pass to jobs
|
28
29
|
|
29
30
|
args: argparse.Namespace # the raw cli args, probably missing information
|
30
31
|
jobs_to_run: JobNames # superset of job-name candidates to run, from cli+config
|
@@ -52,7 +53,7 @@ class ConfigMetadata:
|
|
52
53
|
self.all_job_phases = all_job_phases
|
53
54
|
self.all_job_tags = all_job_tags
|
54
55
|
|
55
|
-
self.options =
|
56
|
+
self.options = InformativeDict() # shows useful errors on bad-option lookups
|
56
57
|
|
57
58
|
self.args = (
|
58
59
|
argparse.Namespace()
|
@@ -69,7 +70,7 @@ class ConfigMetadata:
|
|
69
70
|
phases_to_run: JobPhases,
|
70
71
|
tags_to_run: JobTags,
|
71
72
|
tags_to_avoid: JobTags,
|
72
|
-
options:
|
73
|
+
options: OptionsWritable,
|
73
74
|
) -> None:
|
74
75
|
self.options = options
|
75
76
|
self.args = args
|
@@ -0,0 +1,42 @@
|
|
1
|
+
import typing
|
2
|
+
|
3
|
+
# Define type variables for key and value to be used in the custom dictionary
|
4
|
+
K = typing.TypeVar("K")
|
5
|
+
V = typing.TypeVar("V")
|
6
|
+
|
7
|
+
|
8
|
+
class InformativeDict(typing.Dict[K, V], typing.Generic[K, V]):
|
9
|
+
"""A dictionary type that prints out the available keys."""
|
10
|
+
|
11
|
+
def __getitem__(self, key: K) -> V:
|
12
|
+
"""Attempt to retrieve an item, raising a detailed exception if the key is not
|
13
|
+
found."""
|
14
|
+
try:
|
15
|
+
return super().__getitem__(key)
|
16
|
+
except KeyError:
|
17
|
+
available_keys: typing.Iterable[str] = (str(k) for k in self.keys())
|
18
|
+
raise KeyError(
|
19
|
+
f"Key '{key}' not found. Available keys: {', '.join(available_keys)}"
|
20
|
+
) from None
|
21
|
+
|
22
|
+
|
23
|
+
class ReadOnlyInformativeDict(InformativeDict[K, V], typing.Generic[K, V]):
|
24
|
+
"""A read-only variant of the above."""
|
25
|
+
|
26
|
+
def __setitem__(self, key: K, value: V) -> None:
|
27
|
+
raise NotImplementedError("This dictionary is read-only")
|
28
|
+
|
29
|
+
def __delitem__(self, key: K) -> None:
|
30
|
+
raise NotImplementedError("This dictionary is read-only")
|
31
|
+
|
32
|
+
def pop(self, *args: typing.Any, **kwargs: typing.Any) -> V:
|
33
|
+
raise NotImplementedError("This dictionary is read-only")
|
34
|
+
|
35
|
+
def popitem(self) -> typing.Tuple[K, V]:
|
36
|
+
raise NotImplementedError("This dictionary is read-only")
|
37
|
+
|
38
|
+
def clear(self) -> None:
|
39
|
+
raise NotImplementedError("This dictionary is read-only")
|
40
|
+
|
41
|
+
def update(self, *args: typing.Any, **kwargs: typing.Any) -> None:
|
42
|
+
raise NotImplementedError("This dictionary is read-only")
|
runem/job_execute.py
CHANGED
@@ -7,24 +7,34 @@ from datetime import timedelta
|
|
7
7
|
from timeit import default_timer as timer
|
8
8
|
|
9
9
|
from runem.config_metadata import ConfigMetadata
|
10
|
+
from runem.informative_dict import ReadOnlyInformativeDict
|
10
11
|
from runem.job import Job
|
11
12
|
from runem.job_wrapper import get_job_wrapper
|
12
13
|
from runem.log import log
|
13
|
-
from runem.types import
|
14
|
+
from runem.types import (
|
15
|
+
FilePathListLookup,
|
16
|
+
JobConfig,
|
17
|
+
JobFunction,
|
18
|
+
JobReturn,
|
19
|
+
JobTags,
|
20
|
+
JobTiming,
|
21
|
+
TimingEntries,
|
22
|
+
TimingEntry,
|
23
|
+
)
|
14
24
|
|
15
25
|
|
16
26
|
def job_execute_inner(
|
17
27
|
job_config: JobConfig,
|
18
28
|
config_metadata: ConfigMetadata,
|
19
29
|
file_lists: FilePathListLookup,
|
20
|
-
) -> typing.Tuple[
|
30
|
+
) -> typing.Tuple[JobTiming, JobReturn]:
|
21
31
|
"""Wrapper for running a job inside a sub-process.
|
22
32
|
|
23
33
|
Returns the time information and any reports the job generated
|
24
34
|
"""
|
25
35
|
label = Job.get_job_name(job_config)
|
26
36
|
if config_metadata.args.verbose:
|
27
|
-
log(f"START: {label}")
|
37
|
+
log(f"START: '{label}'")
|
28
38
|
root_path: pathlib.Path = config_metadata.cfg_filepath.parent
|
29
39
|
function: JobFunction
|
30
40
|
job_tags: typing.Optional[JobTags] = Job.get_job_tags(job_config)
|
@@ -37,7 +47,19 @@ def job_execute_inner(
|
|
37
47
|
if not file_list:
|
38
48
|
# no files to work on
|
39
49
|
log(f"WARNING: skipping job '{label}', no files for job")
|
40
|
-
return
|
50
|
+
return {
|
51
|
+
"job": (f"{label}: no files!", timedelta(0)),
|
52
|
+
"commands": [],
|
53
|
+
}, None
|
54
|
+
|
55
|
+
sub_command_timings: TimingEntries = []
|
56
|
+
|
57
|
+
def _record_sub_job_time(label: str, timing: timedelta) -> None:
|
58
|
+
"""Record timing information for sub-commands/tasks, atomically.
|
59
|
+
|
60
|
+
For example inside of run_command() calls
|
61
|
+
"""
|
62
|
+
sub_command_timings.append((label, timing))
|
41
63
|
|
42
64
|
if (
|
43
65
|
"ctx" in job_config
|
@@ -53,29 +75,38 @@ def job_execute_inner(
|
|
53
75
|
start = timer()
|
54
76
|
func_signature = inspect.signature(function)
|
55
77
|
if config_metadata.args.verbose:
|
56
|
-
log(f"job: running {Job.get_job_name(job_config)}")
|
78
|
+
log(f"job: running: '{Job.get_job_name(job_config)}'")
|
57
79
|
reports: JobReturn
|
58
|
-
|
59
|
-
|
60
|
-
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
|
65
|
-
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
80
|
+
try:
|
81
|
+
if "args" in func_signature.parameters:
|
82
|
+
reports = function( # type: ignore # FIXME: which function do we have?
|
83
|
+
config_metadata.args, config_metadata.options, file_list
|
84
|
+
)
|
85
|
+
else:
|
86
|
+
reports = function(
|
87
|
+
options=ReadOnlyInformativeDict(config_metadata.options), # type: ignore
|
88
|
+
file_list=file_list,
|
89
|
+
procs=config_metadata.args.procs,
|
90
|
+
root_path=root_path,
|
91
|
+
verbose=config_metadata.args.verbose,
|
92
|
+
# unpack useful data points from the job_config
|
93
|
+
label=Job.get_job_name(job_config),
|
94
|
+
job=job_config,
|
95
|
+
record_sub_job_time=_record_sub_job_time,
|
96
|
+
)
|
97
|
+
except BaseException: # pylint: disable=broad-exception-caught
|
98
|
+
# log that we hit an error on this job and re-raise
|
99
|
+
log(decorate=False)
|
100
|
+
log(f"job: ERROR: job '{Job.get_job_name(job_config)}' failed to complete!")
|
101
|
+
# re-raise
|
102
|
+
raise
|
103
|
+
|
73
104
|
end = timer()
|
74
105
|
time_taken: timedelta = timedelta(seconds=end - start)
|
75
106
|
if config_metadata.args.verbose:
|
76
|
-
log(f"DONE: {label}: {time_taken}")
|
77
|
-
|
78
|
-
return (
|
107
|
+
log(f"job: DONE: '{label}': {time_taken}")
|
108
|
+
this_job_timing_data: TimingEntry = (label, time_taken)
|
109
|
+
return ({"job": this_job_timing_data, "commands": sub_command_timings}, reports)
|
79
110
|
|
80
111
|
|
81
112
|
def job_execute(
|
@@ -83,7 +114,7 @@ def job_execute(
|
|
83
114
|
running_jobs: typing.Dict[str, str],
|
84
115
|
config_metadata: ConfigMetadata,
|
85
116
|
file_lists: FilePathListLookup,
|
86
|
-
) -> typing.Tuple[
|
117
|
+
) -> typing.Tuple[JobTiming, JobReturn]:
|
87
118
|
"""Thin-wrapper around job_execute_inner needed for mocking in tests.
|
88
119
|
|
89
120
|
Needed for faster tests.
|
runem/job_filter.py
CHANGED
@@ -65,6 +65,10 @@ def _get_jobs_matching(
|
|
65
65
|
filtered_jobs: PhaseGroupedJobs,
|
66
66
|
verbose: bool,
|
67
67
|
) -> None:
|
68
|
+
"""Via filtered_jobs, filters 'jobs' that match the given phase and and tags.
|
69
|
+
|
70
|
+
Warns if the job-name isn't found in list of valid job-names.
|
71
|
+
"""
|
68
72
|
phase_jobs: typing.List[JobConfig] = jobs[phase]
|
69
73
|
|
70
74
|
job: JobConfig
|
@@ -74,7 +78,10 @@ def _get_jobs_matching(
|
|
74
78
|
|
75
79
|
job_name: str = Job.get_job_name(job)
|
76
80
|
if job_name not in job_names:
|
77
|
-
|
81
|
+
# test test_get_jobs_matching_when_job_not_in_valid_job_names should
|
82
|
+
# cover the follow in Ci but does not for some reason I don't have
|
83
|
+
# time to look in to. /FH
|
84
|
+
if verbose: # pragma: FIXME: add code coverage
|
78
85
|
log(
|
79
86
|
(
|
80
87
|
f"not running job '{job_name}' because it isn't in the "
|
@@ -118,7 +125,10 @@ def filter_jobs( # noqa: C901
|
|
118
125
|
filtered_jobs: PhaseGroupedJobs = defaultdict(list)
|
119
126
|
for phase in config_metadata.phases:
|
120
127
|
if phase not in phases_to_run:
|
121
|
-
|
128
|
+
# test test_get_jobs_matching_when_job_not_in_valid_job_names should
|
129
|
+
# cover the follow in Ci but does not for some reason I don't have
|
130
|
+
# time to look in to. /FH
|
131
|
+
if verbose: # pragma: FIXME: add code coverage
|
122
132
|
log(f"skipping phase '{phase}'")
|
123
133
|
continue
|
124
134
|
_get_jobs_matching(
|
@@ -131,7 +141,10 @@ def filter_jobs( # noqa: C901
|
|
131
141
|
verbose=verbose,
|
132
142
|
)
|
133
143
|
if len(filtered_jobs[phase]) == 0:
|
134
|
-
|
144
|
+
# test test_get_jobs_matching_when_job_not_in_valid_job_names should
|
145
|
+
# cover the follow in Ci but does not for some reason I don't have
|
146
|
+
# time to look in to. /FH
|
147
|
+
if verbose: # pragma: FIXME: add code coverage
|
135
148
|
log(f"No jobs for phase '{phase}' tags {printable_set(tags_to_run)}")
|
136
149
|
continue
|
137
150
|
|
runem/report.py
CHANGED
@@ -14,6 +14,7 @@ from runem.types import (
|
|
14
14
|
PhaseName,
|
15
15
|
ReportUrlInfo,
|
16
16
|
ReportUrls,
|
17
|
+
TimingEntries,
|
17
18
|
)
|
18
19
|
|
19
20
|
try:
|
@@ -22,7 +23,7 @@ except ImportError: # pragma: FIXME: add code coverage
|
|
22
23
|
termplotlib = None
|
23
24
|
|
24
25
|
|
25
|
-
def _align_bar_graphs_workaround(original_text: str) ->
|
26
|
+
def _align_bar_graphs_workaround(original_text: str) -> str:
|
26
27
|
"""Module termplotlib doesn't align floats, this fixes that.
|
27
28
|
|
28
29
|
This makes it so we can align the point in the floating point string, without it,
|
@@ -39,40 +40,93 @@ def _align_bar_graphs_workaround(original_text: str) -> None:
|
|
39
40
|
r"\[.*?(\d+)\.", lambda m: f"[{m.group(1):>{max_width}}.", original_text
|
40
41
|
)
|
41
42
|
|
42
|
-
|
43
|
+
return formatted_text
|
44
|
+
|
45
|
+
|
46
|
+
def _replace_bar_characters(text: str, end_str: str, replace_char: str) -> str:
|
47
|
+
"""Replaces block characters in lines containing `end_str` with give char.
|
48
|
+
|
49
|
+
Args:
|
50
|
+
text_lines (List[str]): A list of strings, each representing a line of text.
|
51
|
+
replace_char (str): The character to replace all bocks with
|
52
|
+
|
53
|
+
Returns:
|
54
|
+
List[str]: The modified list of strings with block characters replaced
|
55
|
+
on specified lines.
|
56
|
+
"""
|
57
|
+
# Define the block character and its light shade replacement
|
58
|
+
block_chars = (
|
59
|
+
"▏▎▋▊█▌▐▄▀─" # Extend this string with any additional block characters you use
|
60
|
+
)
|
61
|
+
|
62
|
+
text_lines: typing.List[str] = text.split("\n")
|
63
|
+
|
64
|
+
# Process each line, replacing block characters if `end_str` is present
|
65
|
+
modified_lines = [
|
66
|
+
line.translate(str.maketrans(block_chars, replace_char * len(block_chars)))
|
67
|
+
if end_str in line
|
68
|
+
else line
|
69
|
+
for line in text_lines
|
70
|
+
]
|
71
|
+
|
72
|
+
return "\n".join(modified_lines)
|
73
|
+
|
74
|
+
|
75
|
+
def _semi_shade_phase_totals(text: str) -> str:
|
76
|
+
light_shade_char = "░"
|
77
|
+
return _replace_bar_characters(text, "(user-time)", light_shade_char)
|
78
|
+
|
79
|
+
|
80
|
+
def _dot_jobs(text: str) -> str:
|
81
|
+
dot_char = "·"
|
82
|
+
return _replace_bar_characters(text, "(+)", dot_char)
|
43
83
|
|
44
84
|
|
45
85
|
def _plot_times(
|
46
|
-
|
86
|
+
wall_clock_for_runem_main: timedelta,
|
47
87
|
phase_run_oder: OrderedPhases,
|
48
88
|
timing_data: JobRunTimesByPhase,
|
49
|
-
) -> timedelta:
|
89
|
+
) -> typing.Tuple[timedelta, timedelta]:
|
50
90
|
"""Prints a report to terminal on how well we performed.
|
51
91
|
|
52
92
|
Also calculates the wall-clock time-saved for the user.
|
93
|
+
|
94
|
+
Returns the total system time spent and the time-saved. (system-time-spent,
|
95
|
+
wall-clock-time-saved)
|
53
96
|
"""
|
54
97
|
labels: typing.List[str] = []
|
55
98
|
times: typing.List[float] = []
|
56
|
-
|
57
|
-
for
|
99
|
+
|
100
|
+
# Track active processing time for jobs, distinct from wall-clock time (the
|
101
|
+
# time the user experiences).
|
102
|
+
system_time_spent: timedelta = timedelta() # init to 0
|
103
|
+
|
104
|
+
for idx, phase in enumerate(phase_run_oder):
|
105
|
+
not_last_phase: bool = idx < len(phase_run_oder) - 1
|
106
|
+
utf8_phase = "├" if not_last_phase else "└"
|
107
|
+
utf8_phase_group = "│" if not_last_phase else " "
|
58
108
|
# log(f"Phase '{phase}' jobs took:")
|
59
|
-
phase_total_time: float = 0.0
|
60
109
|
phase_start_idx = len(labels)
|
61
|
-
|
62
|
-
|
63
|
-
|
64
|
-
labels
|
65
|
-
times
|
66
|
-
|
67
|
-
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
75
|
-
|
110
|
+
|
111
|
+
phase_job_times: timedelta = _gen_jobs_report(
|
112
|
+
phase,
|
113
|
+
labels,
|
114
|
+
times,
|
115
|
+
utf8_phase_group,
|
116
|
+
timing_data[phase],
|
117
|
+
)
|
118
|
+
labels.insert(phase_start_idx, f"{utf8_phase}{phase} (user-time)")
|
119
|
+
times.insert(phase_start_idx, phase_job_times.total_seconds())
|
120
|
+
system_time_spent += phase_job_times
|
121
|
+
|
122
|
+
runem_app_timing: typing.List[JobTiming] = timing_data["_app"]
|
123
|
+
job_metadata: JobTiming
|
124
|
+
for job_metadata in reversed(runem_app_timing):
|
125
|
+
job_label, job_time_total = job_metadata["job"]
|
126
|
+
labels.insert(0, f"├runem.{job_label}")
|
127
|
+
times.insert(0, job_time_total.total_seconds())
|
128
|
+
labels.insert(0, "runem (total wall-clock)")
|
129
|
+
times.insert(0, wall_clock_for_runem_main.total_seconds())
|
76
130
|
if termplotlib:
|
77
131
|
fig = termplotlib.figure()
|
78
132
|
# cspell:disable-next-line
|
@@ -81,14 +135,67 @@ def _plot_times(
|
|
81
135
|
labels,
|
82
136
|
force_ascii=False,
|
83
137
|
)
|
138
|
+
shaded_bar_graph: str = _semi_shade_phase_totals(fig.get_string())
|
139
|
+
dotted_bar_graph: str = _dot_jobs(shaded_bar_graph)
|
140
|
+
|
84
141
|
# ensure the graphs get aligned nicely.
|
85
|
-
_align_bar_graphs_workaround(
|
142
|
+
final_bar_graph: str = _align_bar_graphs_workaround(dotted_bar_graph)
|
143
|
+
print(final_bar_graph)
|
86
144
|
else: # pragma: FIXME: add code coverage
|
87
|
-
for
|
88
|
-
log(f"{
|
145
|
+
for job_label, time in zip(labels, times):
|
146
|
+
log(f"{job_label}: {time}s")
|
89
147
|
|
90
|
-
|
91
|
-
return
|
148
|
+
wall_clock_time_saved: timedelta = system_time_spent - wall_clock_for_runem_main
|
149
|
+
return system_time_spent, wall_clock_time_saved
|
150
|
+
|
151
|
+
|
152
|
+
def _gen_jobs_report(
|
153
|
+
phase: PhaseName,
|
154
|
+
labels: typing.List[str],
|
155
|
+
times: typing.List[float],
|
156
|
+
utf8_phase_group: str,
|
157
|
+
job_timings: typing.List[JobTiming],
|
158
|
+
) -> timedelta:
|
159
|
+
"""Gathers the reports for sub-jobs.
|
160
|
+
|
161
|
+
Split out from _plot_times as the code was getting complex
|
162
|
+
"""
|
163
|
+
job_timing: JobTiming
|
164
|
+
|
165
|
+
# Filter out JobTiming instances with non-zero total_seconds
|
166
|
+
non_zero_timing_data: typing.List[JobTiming] = [
|
167
|
+
job_timing
|
168
|
+
for job_timing in job_timings
|
169
|
+
if job_timing["job"][1].total_seconds() != 0
|
170
|
+
]
|
171
|
+
|
172
|
+
job_time_sum: timedelta = timedelta() # init to 0
|
173
|
+
for idx, job_timing in enumerate(non_zero_timing_data):
|
174
|
+
not_last: bool = idx < len(non_zero_timing_data) - 1
|
175
|
+
utf8_job = "├" if not_last else "└"
|
176
|
+
utf8_sub_jobs = "│" if not_last else " "
|
177
|
+
job_label, job_time_total = job_timing["job"]
|
178
|
+
job_bar_label: str = f"{phase}.{job_label}"
|
179
|
+
labels.append(f"{utf8_phase_group}{utf8_job}{job_bar_label}")
|
180
|
+
times.append(job_time_total.total_seconds())
|
181
|
+
job_time_sum += job_time_total
|
182
|
+
sub_command_times: TimingEntries = job_timing["commands"]
|
183
|
+
|
184
|
+
if len(sub_command_times) <= 1:
|
185
|
+
# we only have one or fewer sub-commands, just show the job-time
|
186
|
+
continue
|
187
|
+
|
188
|
+
# also print the sub-components of the job as we have more than one
|
189
|
+
for idx, (sub_job_label, sub_job_time) in enumerate(sub_command_times):
|
190
|
+
sub_utf8 = "├"
|
191
|
+
if idx == len(sub_command_times) - 1:
|
192
|
+
sub_utf8 = "└"
|
193
|
+
labels.append(
|
194
|
+
f"{utf8_phase_group}{utf8_sub_jobs}{sub_utf8}{job_bar_label}"
|
195
|
+
f".{sub_job_label} (+)"
|
196
|
+
)
|
197
|
+
times.append(sub_job_time.total_seconds())
|
198
|
+
return job_time_sum
|
92
199
|
|
93
200
|
|
94
201
|
def _print_reports_by_phase(
|
@@ -107,8 +214,8 @@ def _print_reports_by_phase(
|
|
107
214
|
def report_on_run(
|
108
215
|
phase_run_oder: OrderedPhases,
|
109
216
|
job_run_metadatas: JobRunMetadatasByPhase,
|
110
|
-
|
111
|
-
) -> timedelta:
|
217
|
+
wall_clock_for_runem_main: timedelta,
|
218
|
+
) -> typing.Tuple[timedelta, timedelta]:
|
112
219
|
"""Generate high-level reports AND prints out any reports returned by jobs.
|
113
220
|
|
114
221
|
IMPORTANT: returns the wall-clock time saved to the user.
|
@@ -130,9 +237,8 @@ def report_on_run(
|
|
130
237
|
report_data[phase].extend(reports["reportUrls"])
|
131
238
|
|
132
239
|
# Now plot the times on the terminal to give a visual report of the timing.
|
133
|
-
|
134
|
-
|
135
|
-
overall_run_time=overall_runtime,
|
240
|
+
time_metrics: typing.Tuple[timedelta, timedelta] = _plot_times(
|
241
|
+
wall_clock_for_runem_main=wall_clock_for_runem_main,
|
136
242
|
phase_run_oder=phase_run_oder,
|
137
243
|
timing_data=timing_data,
|
138
244
|
)
|
@@ -140,6 +246,7 @@ def report_on_run(
|
|
140
246
|
# Penultimate-ly print out the available reports grouped by run-phase.
|
141
247
|
_print_reports_by_phase(phase_run_oder, report_data)
|
142
248
|
|
143
|
-
# Return the key
|
249
|
+
# Return the key metrics for runem, the system vs wall-clock time saved to
|
250
|
+
# the user
|
144
251
|
# TODO: write this to disk
|
145
|
-
return
|
252
|
+
return time_metrics
|
runem/run_command.py
CHANGED
@@ -1,9 +1,11 @@
|
|
1
1
|
import os
|
2
2
|
import pathlib
|
3
3
|
import typing
|
4
|
+
from datetime import timedelta
|
4
5
|
from subprocess import PIPE as SUBPROCESS_PIPE
|
5
6
|
from subprocess import STDOUT as SUBPROCESS_STDOUT
|
6
7
|
from subprocess import Popen
|
8
|
+
from timeit import default_timer as timer
|
7
9
|
|
8
10
|
from runem.log import log
|
9
11
|
|
@@ -18,10 +20,34 @@ class RunCommandUnhandledError(RuntimeError):
|
|
18
20
|
pass
|
19
21
|
|
20
22
|
|
23
|
+
# A function type for recording timing information.
|
24
|
+
RecordSubJobTimeType = typing.Callable[[str, timedelta], None]
|
25
|
+
|
26
|
+
|
21
27
|
def parse_stdout(stdout: str, prefix: str) -> str:
|
22
|
-
"""Prefixes each line of the output with a label
|
23
|
-
|
24
|
-
|
28
|
+
"""Prefixes each line of the output with a given label, except trailing new
|
29
|
+
lines."""
|
30
|
+
# Edge case: Return the prefix immediately for an empty string
|
31
|
+
if not stdout:
|
32
|
+
return prefix
|
33
|
+
|
34
|
+
# Split stdout into lines, noting if it ends with a newline
|
35
|
+
ends_with_newline = stdout.endswith("\n")
|
36
|
+
lines = stdout.split("\n")
|
37
|
+
|
38
|
+
# Apply prefix to all lines except the last if it's empty (due to a trailing newline)
|
39
|
+
modified_lines = [f"{prefix}{line}" for line in lines[:-1]] + (
|
40
|
+
[lines[-1]]
|
41
|
+
if lines[-1] == "" and ends_with_newline
|
42
|
+
else [f"{prefix}{lines[-1]}"]
|
43
|
+
)
|
44
|
+
|
45
|
+
# Join the lines back together, appropriately handling the final newline
|
46
|
+
modified_stdout = "\n".join(modified_lines)
|
47
|
+
# if ends_with_newline:
|
48
|
+
# modified_stdout += "\n"
|
49
|
+
|
50
|
+
return modified_stdout
|
25
51
|
|
26
52
|
|
27
53
|
def _prepare_environment(
|
@@ -71,11 +97,16 @@ def run_command( # noqa: C901
|
|
71
97
|
ignore_fails: bool = False,
|
72
98
|
valid_exit_ids: typing.Optional[typing.Tuple[int, ...]] = None,
|
73
99
|
cwd: typing.Optional[pathlib.Path] = None,
|
100
|
+
record_sub_job_time: typing.Optional[RecordSubJobTimeType] = None,
|
74
101
|
**kwargs: typing.Any,
|
75
102
|
) -> str:
|
76
103
|
"""Runs the given command, returning stdout or throwing on any error."""
|
77
104
|
cmd_string = " ".join(cmd)
|
78
105
|
|
106
|
+
if record_sub_job_time is not None:
|
107
|
+
# start the capture of how long this sub-task takes.
|
108
|
+
start = timer()
|
109
|
+
|
79
110
|
run_env: typing.Dict[str, str] = _prepare_environment(
|
80
111
|
env_overrides,
|
81
112
|
)
|
@@ -111,7 +142,7 @@ def run_command( # noqa: C901
|
|
111
142
|
for line in process.stdout:
|
112
143
|
stdout += line
|
113
144
|
if verbose:
|
114
|
-
# print each line of output
|
145
|
+
# print each line of output, assuming that each has a newline
|
115
146
|
log(parse_stdout(line, prefix=f"{label}: "))
|
116
147
|
|
117
148
|
# Wait for the subprocess to finish and get the exit code
|
@@ -154,4 +185,11 @@ def run_command( # noqa: C901
|
|
154
185
|
|
155
186
|
if verbose:
|
156
187
|
log(f"running: done: {label}: {cmd_string}")
|
188
|
+
|
189
|
+
if record_sub_job_time is not None:
|
190
|
+
# Capture how long this run took
|
191
|
+
end = timer()
|
192
|
+
time_taken: timedelta = timedelta(seconds=end - start)
|
193
|
+
record_sub_job_time(label, time_taken)
|
194
|
+
|
157
195
|
return stdout
|
runem/runem.py
CHANGED
@@ -297,9 +297,11 @@ def _main(
|
|
297
297
|
end = timer()
|
298
298
|
|
299
299
|
job_run_metadatas: JobRunMetadatasByPhase = defaultdict(list)
|
300
|
-
|
301
|
-
(
|
302
|
-
|
300
|
+
pre_build_time: JobTiming = {
|
301
|
+
"job": ("pre-build", (timedelta(seconds=end - start))),
|
302
|
+
"commands": [],
|
303
|
+
}
|
304
|
+
job_run_metadatas["_app"].append((pre_build_time, None))
|
303
305
|
|
304
306
|
start = timer()
|
305
307
|
|
@@ -313,7 +315,10 @@ def _main(
|
|
313
315
|
|
314
316
|
end = timer()
|
315
317
|
|
316
|
-
phase_run_timing: JobTiming =
|
318
|
+
phase_run_timing: JobTiming = {
|
319
|
+
"job": ("run-phases", timedelta(seconds=end - start)),
|
320
|
+
"commands": [],
|
321
|
+
}
|
317
322
|
phase_run_report: JobReturn = None
|
318
323
|
phase_run_metadata: JobRunMetadata = (phase_run_timing, phase_run_report)
|
319
324
|
job_run_metadatas["_app"].append(phase_run_metadata)
|
@@ -333,11 +338,19 @@ def timed_main(argv: typing.List[str]) -> None:
|
|
333
338
|
phase_run_oder, job_run_metadatas, failure_exception = _main(argv)
|
334
339
|
end = timer()
|
335
340
|
time_taken: timedelta = timedelta(seconds=end - start)
|
336
|
-
|
341
|
+
wall_clock_time_saved: timedelta
|
342
|
+
system_time_spent: timedelta
|
343
|
+
system_time_spent, wall_clock_time_saved = report_on_run(
|
344
|
+
phase_run_oder, job_run_metadatas, time_taken
|
345
|
+
)
|
346
|
+
message: str = "DONE: runem took"
|
347
|
+
if failure_exception:
|
348
|
+
message = "FAILED: your jobs failed after"
|
337
349
|
log(
|
338
350
|
(
|
339
|
-
f"
|
340
|
-
f"saving you {
|
351
|
+
f"{message}: {time_taken.total_seconds()}s, "
|
352
|
+
f"saving you {wall_clock_time_saved.total_seconds()}s, "
|
353
|
+
f"without runem you would have waited {system_time_spent.total_seconds()}s"
|
341
354
|
)
|
342
355
|
)
|
343
356
|
|
runem/types.py
CHANGED
@@ -3,6 +3,8 @@ import pathlib
|
|
3
3
|
import typing
|
4
4
|
from datetime import timedelta
|
5
5
|
|
6
|
+
from runem.informative_dict import InformativeDict, ReadOnlyInformativeDict
|
7
|
+
|
6
8
|
|
7
9
|
class FunctionNotFound(ValueError):
|
8
10
|
"""Thrown when the test-function cannot be found."""
|
@@ -30,7 +32,21 @@ class JobReturnData(typing.TypedDict, total=False):
|
|
30
32
|
reportUrls: ReportUrls # urls containing reports for the user
|
31
33
|
|
32
34
|
|
33
|
-
|
35
|
+
TimingEntry = typing.Tuple[str, timedelta]
|
36
|
+
TimingEntries = typing.List[TimingEntry]
|
37
|
+
|
38
|
+
|
39
|
+
class JobTiming(typing.TypedDict, total=True):
|
40
|
+
"""A hierarchy of timing info. Job->JobCommands.
|
41
|
+
|
42
|
+
The overall time for a job is in 'job', the child calls to run_command are in
|
43
|
+
'commands'
|
44
|
+
"""
|
45
|
+
|
46
|
+
job: TimingEntry # the overall time for a job
|
47
|
+
commands: TimingEntries # timing for each call to `run_command`
|
48
|
+
|
49
|
+
|
34
50
|
JobReturn = typing.Optional[JobReturnData]
|
35
51
|
JobRunMetadata = typing.Tuple[JobTiming, JobReturn]
|
36
52
|
JobRunTimesByPhase = typing.Dict[PhaseName, typing.List[JobTiming]]
|
@@ -53,7 +69,9 @@ OptionName = str
|
|
53
69
|
OptionValue = bool
|
54
70
|
|
55
71
|
OptionConfigs = typing.Tuple[OptionConfig, ...]
|
56
|
-
|
72
|
+
OptionsWritable = InformativeDict[OptionName, OptionValue]
|
73
|
+
OptionsReadOnly = ReadOnlyInformativeDict[OptionName, OptionValue]
|
74
|
+
Options = OptionsReadOnly
|
57
75
|
|
58
76
|
# P1: bool for verbose, P2: list of file paths to work on
|
59
77
|
|
@@ -70,7 +88,7 @@ FilePathListLookup = typing.DefaultDict[JobTag, FilePathList]
|
|
70
88
|
|
71
89
|
# FIXME: this type is no-longer the actual spec of the test-functions
|
72
90
|
JobFunction = typing.Union[
|
73
|
-
typing.Callable[[argparse.Namespace,
|
91
|
+
typing.Callable[[argparse.Namespace, OptionsWritable, FilePathList], None],
|
74
92
|
typing.Callable[[typing.Any], None],
|
75
93
|
]
|
76
94
|
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.1
|
2
2
|
Name: runem
|
3
|
-
Version: 0.0.
|
3
|
+
Version: 0.0.29
|
4
4
|
Summary: Awesome runem created by lursight
|
5
5
|
Home-page: https://github.com/lursight/runem/
|
6
6
|
Author: lursight
|
@@ -26,6 +26,7 @@ Requires-Dist: pytest-cov ==4.1.0 ; extra == 'test'
|
|
26
26
|
Requires-Dist: pytest-profiling ==1.7.0 ; extra == 'test'
|
27
27
|
Requires-Dist: pytest-xdist ==3.3.1 ; extra == 'test'
|
28
28
|
Requires-Dist: pytest ==7.4.3 ; extra == 'test'
|
29
|
+
Requires-Dist: setuptools ; extra == 'test'
|
29
30
|
Requires-Dist: termplotlib ==0.3.9 ; extra == 'test'
|
30
31
|
Requires-Dist: types-PyYAML ==6.0.12.12 ; extra == 'test'
|
31
32
|
Requires-Dist: requests-mock ==1.10.0 ; extra == 'test'
|
@@ -400,12 +401,12 @@ runem: reports:
|
|
400
401
|
runem: runem: 8.820488s
|
401
402
|
runem: ├runem.pre-build: 0.019031s
|
402
403
|
runem: ├runem.run-phases: 8.801317s
|
403
|
-
runem: ├pre-run (
|
404
|
+
runem: ├pre-run (user-time): 0.00498s
|
404
405
|
runem: │├pre-run.install python requirements: 2.6e-05s
|
405
406
|
runem: │├pre-run.ls -alh runem: 0.004954s
|
406
|
-
runem: ├edit (
|
407
|
+
runem: ├edit (user-time): 0.557559s
|
407
408
|
runem: │├edit.reformat py: 0.557559s
|
408
|
-
runem: ├analysis (
|
409
|
+
runem: ├analysis (user-time): 21.526145s
|
409
410
|
runem: │├analysis.pylint py: 7.457029s
|
410
411
|
runem: │├analysis.flake8 py: 0.693754s
|
411
412
|
runem: │├analysis.mypy py: 1.071956s
|
@@ -430,12 +431,12 @@ runem: reports:
|
|
430
431
|
runem [14.174612] ███████████████▋
|
431
432
|
├runem.pre-build [ 0.025858]
|
432
433
|
├runem.run-phases [14.148587] ███████████████▋
|
433
|
-
├pre-run (
|
434
|
+
├pre-run (user-time) [ 0.005825]
|
434
435
|
│├pre-run.install python requirements [ 0.000028]
|
435
436
|
│├pre-run.ls -alh runem [ 0.005797]
|
436
|
-
├edit (
|
437
|
+
├edit (user-time) [ 0.579153] ▋
|
437
438
|
│├edit.reformat py [ 0.579153] ▋
|
438
|
-
├analysis (
|
439
|
+
├analysis (user-time) [36.231034] ████████████████████████████████████████
|
439
440
|
│├analysis.pylint py [12.738303] ██████████████▏
|
440
441
|
│├analysis.flake8 py [ 0.798575] ▉
|
441
442
|
│├analysis.mypy py [ 0.335984] ▍
|
@@ -1,31 +1,32 @@
|
|
1
|
-
runem/VERSION,sha256=
|
1
|
+
runem/VERSION,sha256=jph1-M7GbnVztMqqYOGloa6ZJHFW9vEnWp0NTfilvfk,7
|
2
2
|
runem/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
3
3
|
runem/__main__.py,sha256=dsOiVZegpfK9JOs5n7UmbX5iwwbj7iFkEbLoVeEgAn4,136
|
4
4
|
runem/base.py,sha256=EZfR7FIlwEdU9Vfe47Wk2DOO8GQqpKxxLNKp6YHueZ4,316
|
5
5
|
runem/blocking_print.py,sha256=S9dtgAeuTzc2-ht-vk9Wl6l-0PwS2tYbHDHDQQitrlA,841
|
6
6
|
runem/cli.py,sha256=LTkxwALR67TM8GlkwrfISoYxcBZ848d9Mp75lQtDTAE,133
|
7
|
-
runem/command_line.py,sha256=
|
7
|
+
runem/command_line.py,sha256=gRiT_fdRt4_xh_kFVoEY9GyTCcg-LmoKSs4y7HVtHqY,10379
|
8
8
|
runem/config.py,sha256=dBQks5ERVTwdxw0PlOm0ioDrsxfgTCfGlBTuE2RFtn8,3866
|
9
|
-
runem/config_metadata.py,sha256=
|
9
|
+
runem/config_metadata.py,sha256=FaMLZOUNKpg77yowyAKSDH9rmKbZfIwAmkDVbZMX4xU,2925
|
10
10
|
runem/config_parse.py,sha256=B_hQPYSMIxzy5XUVWFuQ--0WZnCuDqgP3bQUYHwKc1A,8389
|
11
11
|
runem/files.py,sha256=vAI17m-Y1tRGot6_vDpTuBTkKm8xdByGoh7HCfNkMFU,1900
|
12
|
+
runem/informative_dict.py,sha256=U7p9z78UwOT4TAfng1iDXCEyeYz6C-XZlx9Z1pWNVrI,1548
|
12
13
|
runem/job.py,sha256=QVXvzz67fJk__-h0womFQsB80-w41E3XRcHpxmRnv3o,2912
|
13
|
-
runem/job_execute.py,sha256=
|
14
|
-
runem/job_filter.py,sha256
|
14
|
+
runem/job_execute.py,sha256=FLZ6KCcsI2mywBW-qMqrdEUhHURADxvUBXuOxJVSkss,4217
|
15
|
+
runem/job_filter.py,sha256=-qinE3cmFsk6Q9N2Hxp_vm0olIzvRgrWczmvegcd7yc,5352
|
15
16
|
runem/job_runner_simple_command.py,sha256=H4LuAgzLAiy_T2juRO0qL7ULH9OpWhcXmeuAALi8JaI,836
|
16
17
|
runem/job_wrapper.py,sha256=c2im3YTBmrNlULbQs9kKWlIy45B6PRuogDZcfitmX7A,752
|
17
18
|
runem/job_wrapper_python.py,sha256=sjKRUTnjiAR0JKmdzoKBPM5YHIjv2q86IUUyJmV7f0s,4263
|
18
19
|
runem/log.py,sha256=YddguLhhLrdbEuHxJlolNdIg_LMTEN09nqiqEWWjDzU,517
|
19
20
|
runem/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
20
|
-
runem/report.py,sha256=
|
21
|
-
runem/run_command.py,sha256=
|
22
|
-
runem/runem.py,sha256=
|
21
|
+
runem/report.py,sha256=2JD6b515FF-t040V2C6eKOp1PIS0w8AEWmpA-5WwSao,8758
|
22
|
+
runem/run_command.py,sha256=Egl_j4bJ9mwi2JEFCsl0W6WH2IRgIdpMN7qdj8voClQ,6386
|
23
|
+
runem/runem.py,sha256=Et3Jfg_qO5BBet43FI3LmmP2opuIZBKsraQJnAktPps,12443
|
23
24
|
runem/runem_version.py,sha256=MbETwZO2Tb1Y3hX_OYZjKepEMKA1cjNvr-7Cqhz6e3s,271
|
24
|
-
runem/types.py,sha256=
|
25
|
+
runem/types.py,sha256=recvwKLWY4KB043jyGGdFU812O45d9KkN5yF8sZX2ug,6368
|
25
26
|
runem/utils.py,sha256=3N_kel9LsriiMq7kOjT14XhfxUOgz4hdDg97wlLKm3U,221
|
26
|
-
runem-0.0.
|
27
|
-
runem-0.0.
|
28
|
-
runem-0.0.
|
29
|
-
runem-0.0.
|
30
|
-
runem-0.0.
|
31
|
-
runem-0.0.
|
27
|
+
runem-0.0.29.dist-info/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
|
28
|
+
runem-0.0.29.dist-info/METADATA,sha256=ftCbQO7zAiHhPhkpZuB9UBYmWi68u_sw59gkZ_DVwcE,30581
|
29
|
+
runem-0.0.29.dist-info/WHEEL,sha256=GJ7t_kWBFywbagK5eo9IoUwLW6oyOeTKmQ-9iHFVNxQ,92
|
30
|
+
runem-0.0.29.dist-info/entry_points.txt,sha256=nu0g_vBeuPihYtimbtlNusxWovylMppvJ8UxdJlJfvM,46
|
31
|
+
runem-0.0.29.dist-info/top_level.txt,sha256=gK6iqh9OfHDDpErioCC9ul_zx2Q5zWTALtcuGU7Vil4,6
|
32
|
+
runem-0.0.29.dist-info/RECORD,,
|
File without changes
|
File without changes
|
File without changes
|