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 CHANGED
@@ -1 +1 @@
1
- 0.0.27
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, Options
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: Options = initialise_options(config_metadata, args)
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
- ) -> Options:
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: 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
- Options,
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: Options # the final configured options to pass to jobs
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 = {} # will be defined after cli argument parsing
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: 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 FilePathListLookup, JobConfig, JobFunction, JobReturn, JobTags
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[typing.Tuple[str, timedelta], JobReturn]:
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 (f"{label}: no files!", timedelta(0)), None
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
- if "args" in func_signature.parameters:
59
- reports = function( # type: ignore # FIXME: which function do we have?
60
- config_metadata.args, config_metadata.options, file_list
61
- )
62
- else:
63
- reports = function(
64
- options=config_metadata.options, # type: ignore
65
- file_list=file_list,
66
- procs=config_metadata.args.procs,
67
- root_path=root_path,
68
- verbose=config_metadata.args.verbose,
69
- # unpack useful data points from the job_config
70
- label=Job.get_job_name(job_config),
71
- job=job_config,
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
- timing_data = (label, time_taken)
78
- return (timing_data, reports)
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[typing.Tuple[str, timedelta], JobReturn]:
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
- if verbose:
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
- if verbose:
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
- if verbose:
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) -> None:
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
- print(formatted_text)
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
- overall_run_time: timedelta,
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
- job_time_sum: timedelta = timedelta() # init to 0
57
- for phase in phase_run_oder:
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
- for label, job_time in timing_data[phase]:
62
- if job_time.total_seconds() == 0:
63
- continue
64
- labels.append(f"│├{phase}.{label}")
65
- times.append(job_time.total_seconds())
66
- job_time_sum += job_time
67
- phase_total_time += job_time.total_seconds()
68
- labels.insert(phase_start_idx, f"├{phase} (total)")
69
- times.insert(phase_start_idx, phase_total_time)
70
-
71
- for label, job_time in reversed(timing_data["_app"]):
72
- labels.insert(0, f"├runem.{label}")
73
- times.insert(0, job_time.total_seconds())
74
- labels.insert(0, "runem")
75
- times.insert(0, overall_run_time.total_seconds())
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(fig.get_string())
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 label, time in zip(labels, times):
88
- log(f"{label}: {time}s")
145
+ for job_label, time in zip(labels, times):
146
+ log(f"{job_label}: {time}s")
89
147
 
90
- time_saved: timedelta = job_time_sum - overall_run_time
91
- return time_saved
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
- overall_runtime: timedelta,
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
- # Also, calculate the time saved by runem, a key selling-point metric
134
- time_saved: timedelta = _plot_times(
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 metric for runem, the wall-clock time saved to the user
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 time_saved
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
- stdout = prefix + stdout.replace("\n", f"\n{prefix}")
24
- return stdout
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
- job_run_metadatas["_app"].append(
301
- (("pre-build", (timedelta(seconds=end - start))), None)
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 = ("run-phases", timedelta(seconds=end - start))
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
- time_saved = report_on_run(phase_run_oder, job_run_metadatas, time_taken)
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"DONE: runem took: {time_taken.total_seconds()}s, "
340
- f"saving you {time_saved.total_seconds()}s"
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
- JobTiming = typing.Tuple[str, timedelta]
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
- Options = typing.Dict[OptionName, OptionValue]
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, Options, FilePathList], None],
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.27
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 (total): 0.00498s
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 (total): 0.557559s
407
+ runem: ├edit (user-time): 0.557559s
407
408
  runem: │├edit.reformat py: 0.557559s
408
- runem: ├analysis (total): 21.526145s
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 (total) [ 0.005825]
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 (total) [ 0.579153] ▋
437
+ ├edit (user-time) [ 0.579153] ▋
437
438
  │├edit.reformat py [ 0.579153] ▋
438
- ├analysis (total) [36.231034] ████████████████████████████████████████
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=4RaaVpocPRZzAO4OueuhfnfUYNX8-rNoVGqAXr5Yij4,7
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=AOZ9hH2MGSoyOWHUM68rZ6E5GzwjNKFpDyTFVcTFvcI,10279
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=EjCEqx9-2mtMrFf3lJJ8HFhfmScioZKeY_c9Rzajne8,2836
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=QRGaG0S996I_bkHGvIBZolfgX2bqzosQ6RHK3X0_Fow,3245
14
- runem/job_filter.py,sha256=MvGuEqROgdAn7f4yZivJUS9ZuByQgrEDY_q4_D6Y0uQ,4493
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=GdTavjbkaOX-cwXE0KFzTIYMN58vCQRDD2esaTqB6H0,4808
21
- runem/run_command.py,sha256=xKNXS2YAw67i9unNDSEud2kfcE35dig59ce7opU_ZVk,5051
22
- runem/runem.py,sha256=d7BZWNqADcg1QCeAVtFic8MS-1OnyVS4AemDz_fBrts,11991
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=V5f-HQxgEXdrNy0AuqozzDfWOoPeyRI3hRbACbiiHN4,5804
25
+ runem/types.py,sha256=recvwKLWY4KB043jyGGdFU812O45d9KkN5yF8sZX2ug,6368
25
26
  runem/utils.py,sha256=3N_kel9LsriiMq7kOjT14XhfxUOgz4hdDg97wlLKm3U,221
26
- runem-0.0.27.dist-info/LICENSE,sha256=awOCsWJ58m_2kBQwBUGWejVqZm6wuRtCL2hi9rfa0X4,1211
27
- runem-0.0.27.dist-info/METADATA,sha256=XbQPBGyg6CiyCDkqXRihNWNh-_DAC9OmsKpu92zZ3wk,30525
28
- runem-0.0.27.dist-info/WHEEL,sha256=oiQVh_5PnQM0E3gPdiz09WCNmwiHDMaGer_elqB3coM,92
29
- runem-0.0.27.dist-info/entry_points.txt,sha256=nu0g_vBeuPihYtimbtlNusxWovylMppvJ8UxdJlJfvM,46
30
- runem-0.0.27.dist-info/top_level.txt,sha256=gK6iqh9OfHDDpErioCC9ul_zx2Q5zWTALtcuGU7Vil4,6
31
- runem-0.0.27.dist-info/RECORD,,
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,,
@@ -1,5 +1,5 @@
1
1
  Wheel-Version: 1.0
2
- Generator: bdist_wheel (0.42.0)
2
+ Generator: bdist_wheel (0.43.0)
3
3
  Root-Is-Purelib: true
4
4
  Tag: py3-none-any
5
5