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 CHANGED
@@ -1 +1 @@
1
- 0.5.0
1
+ 0.7.0
runem/blocking_print.py CHANGED
@@ -13,7 +13,14 @@ def _reset_console() -> Console:
13
13
 
14
14
  RICH_CONSOLE = Console(
15
15
  log_path=False, # Do NOT print the source path.
16
- markup=False, # Do NOT print markup e.g. `[blink]Don't Panic![/blink]`.
16
+ # We allow markup here, BUT stdout/stderr from other procs should have
17
+ # `escape()` called on them so they don't error here.
18
+ # This means 'rich' effects/colors can be judiciously applied:
19
+ # e.g. `[blink]Don't Panic![/blink]`.
20
+ markup=True,
21
+ # `highlight` is what colourises string and number in print() calls.
22
+ # We do not want this to be auto-magic.
23
+ highlight=False,
17
24
  )
18
25
  return RICH_CONSOLE
19
26
 
@@ -31,7 +38,8 @@ def _reset_console_for_tests() -> None:
31
38
  RICH_CONSOLE = Console(
32
39
  log_path=False, # Do NOT print the source path.
33
40
  log_time=False, # Do not prefix with log time e.g. `[time] log message`.
34
- markup=False, # Do NOT print markup e.g. `[blink]Don't Panic![/blink]`.
41
+ markup=True, # Allow some markup e.g. `[blink]Don't Panic![/blink]`.
42
+ highlight=False,
35
43
  width=999, # A very wide width.
36
44
  )
37
45
 
@@ -13,7 +13,6 @@ def initialise_options(
13
13
 
14
14
  Returns the options dictionary
15
15
  """
16
-
17
16
  options: OptionsWritable = InformativeDict(
18
17
  {option["name"]: option["default"] for option in config_metadata.options_config}
19
18
  )
runem/command_line.py CHANGED
@@ -43,10 +43,8 @@ def _get_argparse_help_formatter() -> typing.Any:
43
43
 
44
44
  if use_fixed_width:
45
45
  # Use custom formatter with the width specified in the environment variable
46
- return (
47
- lambda prog: HelpFormatterFixedWidth( # pylint: disable=unnecessary-lambda
48
- prog
49
- )
46
+ return lambda prog: HelpFormatterFixedWidth( # pylint: disable=unnecessary-lambda
47
+ prog
50
48
  )
51
49
 
52
50
  # Use default formatter
@@ -294,12 +292,12 @@ def parse_args(
294
292
  error_on_log_logic(args.verbose, args.silent)
295
293
 
296
294
  if args.show_root_path_and_exit:
297
- log(str(config_metadata.cfg_filepath.parent), decorate=False)
295
+ log(str(config_metadata.cfg_filepath.parent), prefix=False)
298
296
  # cleanly exit
299
297
  sys.exit(0)
300
298
 
301
299
  if args.show_version_and_exit:
302
- log(str(get_runem_version()), decorate=False)
300
+ log(str(get_runem_version()), prefix=False)
303
301
  # cleanly exit
304
302
  sys.exit(0)
305
303
 
@@ -383,7 +381,6 @@ def initialise_options(
383
381
 
384
382
  Returns the options dictionary
385
383
  """
386
-
387
384
  options: OptionsWritable = InformativeDict(
388
385
  {option["name"]: option["default"] for option in config_metadata.options_config}
389
386
  )
runem/config.py CHANGED
@@ -2,9 +2,9 @@ import pathlib
2
2
  import sys
3
3
  import typing
4
4
 
5
- import yaml
6
5
  from packaging.version import Version
7
6
 
7
+ from runem.config_validate import validate_runem_file
8
8
  from runem.log import error, log
9
9
  from runem.runem_version import get_runem_version
10
10
  from runem.types.runem_config import (
@@ -13,6 +13,7 @@ from runem.types.runem_config import (
13
13
  GlobalSerialisedConfig,
14
14
  UserConfigMetadata,
15
15
  )
16
+ from runem.yaml_utils import load_yaml_object
16
17
 
17
18
  CFG_FILE_YAML = pathlib.Path(".runem.yml")
18
19
 
@@ -46,7 +47,7 @@ def _search_up_multiple_dirs_for_file(
46
47
 
47
48
 
48
49
  def _find_config_file(
49
- config_filename: typing.Union[str, pathlib.Path]
50
+ config_filename: typing.Union[str, pathlib.Path],
50
51
  ) -> typing.Tuple[typing.Optional[pathlib.Path], typing.Tuple[pathlib.Path, ...]]:
51
52
  """Searches up from the cwd for the given config file-name."""
52
53
  start_dirs = (pathlib.Path(".").absolute(),)
@@ -117,8 +118,11 @@ def _conform_global_config_types(
117
118
 
118
119
  def load_and_parse_config(cfg_filepath: pathlib.Path) -> Config:
119
120
  """For the given config file pass, project or user, load it & parse/conform it."""
120
- with cfg_filepath.open("r+", encoding="utf-8") as config_file_handle:
121
- all_config = yaml.full_load(config_file_handle)
121
+ all_config = load_yaml_object(cfg_filepath)
122
+ validate_runem_file(
123
+ cfg_filepath,
124
+ all_config,
125
+ )
122
126
 
123
127
  conformed_config: Config
124
128
  global_config: typing.Optional[GlobalConfig]
runem/config_parse.py CHANGED
@@ -162,7 +162,8 @@ def parse_job_config(
162
162
  ("cwd" in job["ctx"]) and (job["ctx"]["cwd"] is not None)
163
163
  )
164
164
  if (not have_ctw_cwd) or isinstance(
165
- job["ctx"]["cwd"], str # type: ignore # handled above
165
+ job["ctx"]["cwd"], # type: ignore # handled above
166
+ str,
166
167
  ):
167
168
  # if
168
169
  # - we don't have a cwd, ctx
@@ -0,0 +1,47 @@
1
+ import pathlib
2
+ import typing
3
+
4
+ from runem.log import error, log
5
+ from runem.types.errors import SystemExitBad
6
+ from runem.yaml_utils import load_yaml_object
7
+ from runem.yaml_validation import ValidationErrors, validate_yaml
8
+
9
+
10
+ def _load_runem_schema() -> typing.Any:
11
+ """Loads and returns the yaml schema for runem.
12
+
13
+ Returns:
14
+ Any: the Draft202012Validator conformant schema.
15
+ """
16
+ schema_path: pathlib.Path = pathlib.Path(__file__).with_name("schema.yml")
17
+ if not schema_path.exists():
18
+ error(
19
+ (
20
+ "runem schema file not found, cannot continue! "
21
+ f"Is the install corrupt? {schema_path}"
22
+ )
23
+ )
24
+ raise SystemExitBad(1)
25
+ schema: typing.Any = load_yaml_object(schema_path)
26
+ return schema
27
+
28
+
29
+ def validate_runem_file(
30
+ cfg_filepath: pathlib.Path,
31
+ all_config: typing.Any,
32
+ ) -> None:
33
+ """Validates the config Loader object against the runem schema.
34
+
35
+ Exits if the files does not validate.
36
+ """
37
+ schema: typing.Any = _load_runem_schema()
38
+ errors: ValidationErrors = validate_yaml(all_config, schema)
39
+ if not errors:
40
+ # aok
41
+ return
42
+
43
+ error(f"failed to validate runem config [yellow]{cfg_filepath}[/yellow]")
44
+ for err in errors:
45
+ path = ".".join(map(str, err.path)) or "<root>"
46
+ log(f" [yellow]{path}[/yellow]: {err.message}")
47
+ raise SystemExit("Config validation failed.")
runem/informative_dict.py CHANGED
@@ -9,8 +9,7 @@ class InformativeDict(typing.Dict[K, V], typing.Generic[K, V]):
9
9
  """A dictionary type that prints out the available keys."""
10
10
 
11
11
  def __getitem__(self, key: K) -> V:
12
- """Attempt to retrieve an item, raising a detailed exception if the key is not
13
- found."""
12
+ """Attempt to get item, raising a detailed exception if the key is not found."""
14
13
  try:
15
14
  return super().__getitem__(key)
16
15
  except KeyError:
@@ -24,19 +23,25 @@ class ReadOnlyInformativeDict(InformativeDict[K, V], typing.Generic[K, V]):
24
23
  """A read-only variant of the above."""
25
24
 
26
25
  def __setitem__(self, key: K, value: V) -> None:
26
+ """Readonly object, setitem disallowed."""
27
27
  raise NotImplementedError("This dictionary is read-only")
28
28
 
29
29
  def __delitem__(self, key: K) -> None:
30
+ """Readonly object, delitem disallowed."""
30
31
  raise NotImplementedError("This dictionary is read-only")
31
32
 
32
33
  def pop(self, *args: typing.Any, **kwargs: typing.Any) -> V:
34
+ """Readonly object, pop disallowed."""
33
35
  raise NotImplementedError("This dictionary is read-only")
34
36
 
35
37
  def popitem(self) -> typing.Tuple[K, V]:
38
+ """Readonly object, popitem disallowed."""
36
39
  raise NotImplementedError("This dictionary is read-only")
37
40
 
38
41
  def clear(self) -> None:
42
+ """Readonly object, clear disallowed."""
39
43
  raise NotImplementedError("This dictionary is read-only")
40
44
 
41
45
  def update(self, *args: typing.Any, **kwargs: typing.Any) -> None:
46
+ """Readonly object, update disallowed."""
42
47
  raise NotImplementedError("This dictionary is read-only")
runem/job.py CHANGED
@@ -71,7 +71,6 @@ class Job:
71
71
 
72
72
  TODO: make a non-static member function
73
73
  """
74
-
75
74
  # default to all file-tags
76
75
  tags_for_files: typing.Iterable[str] = file_lists.keys()
77
76
  use_default_tags: bool = job_tags is None
@@ -91,7 +90,6 @@ class Job:
91
90
 
92
91
  TODO: make a non-static member function
93
92
  """
94
-
95
93
  # First try one of the following keys.
96
94
  valid_name_keys = ("label", "command")
97
95
  for candidate in valid_name_keys:
@@ -101,6 +99,6 @@ class Job:
101
99
 
102
100
  # The try the python-wrapper address
103
101
  try:
104
- return f'{job["addr"]["file"]}.{job["addr"]["function"]}'
102
+ return f"{job['addr']['file']}.{job['addr']['function']}"
105
103
  except KeyError:
106
104
  raise NoJobName() # pylint: disable=raise-missing-from
runem/job_execute.py CHANGED
@@ -105,7 +105,7 @@ def job_execute_inner(
105
105
  reports = function(**all_k_args)
106
106
  except BaseException: # pylint: disable=broad-exception-caught
107
107
  # log that we hit an error on this job and re-raise
108
- log(decorate=False)
108
+ log(prefix=False)
109
109
  error(f"job: job '{Job.get_job_name(job_config)}' failed to complete!")
110
110
  # re-raise
111
111
  raise
@@ -132,12 +132,15 @@ def job_execute(
132
132
  """
133
133
  this_id: str = str(uuid.uuid4())
134
134
  running_jobs[this_id] = Job.get_job_name(job_config)
135
- results = job_execute_inner(
136
- job_config,
137
- config_metadata,
138
- file_lists,
139
- **kwargs,
140
- )
141
- completed_jobs[this_id] = running_jobs[this_id]
142
- del running_jobs[this_id]
135
+ try:
136
+ results = job_execute_inner(
137
+ job_config,
138
+ config_metadata,
139
+ file_lists,
140
+ **kwargs,
141
+ )
142
+ finally:
143
+ # Always tidy-up job statuses
144
+ completed_jobs[this_id] = running_jobs[this_id]
145
+ del running_jobs[this_id]
143
146
  return results
runem/job_filter.py CHANGED
@@ -101,21 +101,21 @@ def filter_jobs( # noqa: C901
101
101
  if tags_to_run:
102
102
  log(
103
103
  f"filtering for tags {printable_set(tags_to_run)}",
104
- decorate=True,
104
+ prefix=True,
105
105
  end="",
106
106
  )
107
107
  if tags_to_avoid:
108
108
  if tags_to_run:
109
- log(", ", decorate=False, end="")
109
+ log(", ", prefix=False, end="")
110
110
  else:
111
- log(decorate=True, end="")
111
+ log(prefix=True, end="")
112
112
  log(
113
113
  f"excluding jobs with tags {printable_set(tags_to_avoid)}",
114
- decorate=False,
114
+ prefix=False,
115
115
  end="",
116
116
  )
117
117
  if tags_to_run or tags_to_avoid:
118
- log(decorate=False)
118
+ log(prefix=False)
119
119
  filtered_jobs: PhaseGroupedJobs = defaultdict(list)
120
120
  for phase in config_metadata.phases:
121
121
  if phase not in phases_to_run:
@@ -15,7 +15,6 @@ def _load_python_function_from_module(
15
15
  function_to_load: str,
16
16
  ) -> JobFunction:
17
17
  """Given a job-description dynamically loads the test-function so we can call it."""
18
-
19
18
  # first locate the module relative to the config file
20
19
  abs_module_file_path: pathlib.Path = (
21
20
  cfg_filepath.parent / module_file_path
@@ -109,9 +108,9 @@ def get_job_wrapper_py_func(
109
108
  ) from err
110
109
 
111
110
  anchored_file_path = cfg_filepath.parent / module_file_path
112
- assert (
113
- anchored_file_path.exists()
114
- ), f"{module_file_path} not found at {anchored_file_path}!"
111
+ assert anchored_file_path.exists(), (
112
+ f"{module_file_path} not found at {anchored_file_path}!"
113
+ )
115
114
 
116
115
  module_name = module_file_path.stem.replace(" ", "_").replace("-", "_")
117
116
 
runem/log.py CHANGED
@@ -3,13 +3,35 @@ import typing
3
3
  from runem.blocking_print import blocking_print
4
4
 
5
5
 
6
- def log(msg: str = "", decorate: bool = True, end: typing.Optional[str] = None) -> None:
6
+ def log(
7
+ msg: str = "",
8
+ prefix: typing.Optional[bool] = None,
9
+ end: typing.Optional[str] = None,
10
+ ) -> None:
7
11
  """Thin wrapper around 'print', change the 'msg' & handles system-errors.
8
12
 
9
13
  One way we change it is to decorate the output with 'runem'
14
+
15
+ Parameters:
16
+ msg: str - the message to log out. Any `rich` markup will be escaped
17
+ and not applied.
18
+ decorate: str - whether to add runem-specific prefix text. We do this
19
+ to identify text that comes from the app vs text that
20
+ comes from hooks or other third-parties.
21
+ end: Optional[str] - same as the end option used by `print()` and
22
+ `rich`
23
+ Returns:
24
+ Nothing
10
25
  """
11
- if decorate:
12
- msg = f"runem: {msg}"
26
+ # Remove any markup as it will probably error, if unsanitised.
27
+ # msg = escape(msg)
28
+
29
+ if prefix is None:
30
+ prefix = True
31
+
32
+ if prefix:
33
+ # Make it clear that the message comes from `runem` internals.
34
+ msg = f"[light_slate_grey]runem[/light_slate_grey]: {msg}"
13
35
 
14
36
  # print in a blocking manner, waiting for system resources to free up if a
15
37
  # runem job is contending on stdout or similar.
@@ -17,8 +39,8 @@ def log(msg: str = "", decorate: bool = True, end: typing.Optional[str] = None)
17
39
 
18
40
 
19
41
  def warn(msg: str) -> None:
20
- log(f"WARNING: {msg}")
42
+ log(f"[yellow]WARNING[/yellow]: {msg}")
21
43
 
22
44
 
23
45
  def error(msg: str) -> None:
24
- log(f"ERROR: {msg}")
46
+ log(f"[red]ERROR[/red]: {msg}")
runem/report.py CHANGED
@@ -46,12 +46,13 @@ def replace_bar_graph_characters(text: str, end_str: str, replace_char: str) ->
46
46
  """Replaces block characters in lines containing `end_str` with give char.
47
47
 
48
48
  Args:
49
- text_lines (List[str]): A list of strings, each representing a line of text.
49
+ text (str): Text containing lines of bar-graphs (perhaps)
50
+ end_str (str): If contained by a line, the bar-graph shapes are replaced.
50
51
  replace_char (str): The character to replace all bocks with
51
52
 
52
53
  Returns:
53
- List[str]: The modified list of strings with block characters replaced
54
- on specified lines.
54
+ str: The modified `text` with block characters replaced on specific
55
+ lines.
55
56
  """
56
57
  # Define the block character and its light shade replacement
57
58
  block_chars = (
@@ -211,7 +212,10 @@ def _print_reports_by_phase(
211
212
  for job_report_url_info in report_urls:
212
213
  if not job_report_url_info:
213
214
  continue
214
- log(f"report: {str(job_report_url_info[0])}: {str(job_report_url_info[1])}")
215
+ log(
216
+ f"report: [blue]{str(job_report_url_info[0])}[/blue]: "
217
+ f"{str(job_report_url_info[1])}"
218
+ )
215
219
 
216
220
 
217
221
  def report_on_run(
runem/run_command.py CHANGED
@@ -7,17 +7,33 @@ from subprocess import STDOUT as SUBPROCESS_STDOUT
7
7
  from subprocess import Popen
8
8
  from timeit import default_timer as timer
9
9
 
10
+ from rich.markup import escape
11
+
10
12
  from runem.log import log
11
13
 
12
14
  TERMINAL_WIDTH = 86
13
15
 
14
16
 
15
- class RunCommandBadExitCode(RuntimeError):
16
- pass
17
+ class RunemJobError(RuntimeError):
18
+ """An exception type that stores the stdout/stderr.
19
+
20
+ Designed so that we do not print the full stdout via the exception stack, instead,
21
+ allows an opportunity to parse the markup in it.
22
+ """
23
+
24
+ def __init__(self, friendly_message: str, stdout: str):
25
+ self.stdout = stdout
26
+ super().__init__(friendly_message)
17
27
 
18
28
 
19
- class RunCommandUnhandledError(RuntimeError):
20
- pass
29
+ class RunCommandBadExitCode(RunemJobError):
30
+ def __init__(self, stdout: str):
31
+ super().__init__(friendly_message="Bad exit-code", stdout=stdout)
32
+
33
+
34
+ class RunCommandUnhandledError(RunemJobError):
35
+ def __init__(self, stdout: str):
36
+ super().__init__(friendly_message="Unhandled job error", stdout=stdout)
21
37
 
22
38
 
23
39
  # A function type for recording timing information.
@@ -25,27 +41,25 @@ RecordSubJobTimeType = typing.Callable[[str, timedelta], None]
25
41
 
26
42
 
27
43
  def parse_stdout(stdout: str, prefix: str) -> str:
28
- """Prefixes each line of the output with a given label, except trailing new
29
- lines."""
44
+ """Prefixes each line of output with a given label, except trailing new lines."""
30
45
  # Edge case: Return the prefix immediately for an empty string
31
46
  if not stdout:
32
47
  return prefix
33
48
 
34
- # Split stdout into lines, noting if it ends with a newline
35
- ends_with_newline = stdout.endswith("\n")
49
+ # Stop errors in `rich` by parsing out anything that might look like
50
+ # rich-markup.
51
+ stdout = escape(stdout)
52
+
53
+ # Split stdout into lines
36
54
  lines = stdout.split("\n")
37
55
 
38
56
  # 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]}"]
57
+ modified_lines = [f"{prefix}{escape(line)}" for line in lines[:-1]] + (
58
+ [f"{prefix}{escape(lines[-1])}"]
43
59
  )
44
60
 
45
61
  # 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"
62
+ modified_stdout: str = "\n".join(modified_lines)
49
63
 
50
64
  return modified_stdout
51
65
 
@@ -69,24 +83,34 @@ def _log_command_execution(
69
83
  label: str,
70
84
  env_overrides: typing.Optional[typing.Dict[str, str]],
71
85
  valid_exit_ids: typing.Optional[typing.Tuple[int, ...]],
86
+ decorate_logs: bool,
72
87
  verbose: bool,
73
88
  cwd: typing.Optional[pathlib.Path] = None,
74
89
  ) -> None:
75
90
  """Logs out useful debug information on '--verbose'."""
76
91
  if verbose:
77
- log(f"running: start: {label}: {cmd_string}")
92
+ log(
93
+ f"running: start: [blue]{label}[/blue]: [yellow]{cmd_string}[yellow]",
94
+ prefix=decorate_logs,
95
+ )
78
96
  if valid_exit_ids is not None:
79
97
  valid_exit_strs = ",".join(str(exit_code) for exit_code in valid_exit_ids)
80
- log(f"\tallowed return ids are: {valid_exit_strs}")
98
+ log(
99
+ f"\tallowed return ids are: [green]{valid_exit_strs}[/green]",
100
+ prefix=decorate_logs,
101
+ )
81
102
 
82
103
  if env_overrides:
83
104
  env_overrides_as_string = " ".join(
84
105
  [f"{key}='{value}'" for key, value in env_overrides.items()]
85
106
  )
86
- log(f"ENV OVERRIDES: {env_overrides_as_string} {cmd_string}")
107
+ log(
108
+ f"ENV OVERRIDES: [yellow]{env_overrides_as_string} {cmd_string}[/yellow]",
109
+ prefix=decorate_logs,
110
+ )
87
111
 
88
112
  if cwd:
89
- log(f"cwd: {str(cwd)}")
113
+ log(f"cwd: {str(cwd)}", prefix=decorate_logs)
90
114
 
91
115
 
92
116
  def run_command( # noqa: C901
@@ -98,6 +122,7 @@ def run_command( # noqa: C901
98
122
  valid_exit_ids: typing.Optional[typing.Tuple[int, ...]] = None,
99
123
  cwd: typing.Optional[pathlib.Path] = None,
100
124
  record_sub_job_time: typing.Optional[RecordSubJobTimeType] = None,
125
+ decorate_logs: bool = True,
101
126
  **kwargs: typing.Any,
102
127
  ) -> str:
103
128
  """Runs the given command, returning stdout or throwing on any error."""
@@ -115,6 +140,7 @@ def run_command( # noqa: C901
115
140
  label,
116
141
  env_overrides,
117
142
  valid_exit_ids,
143
+ decorate_logs,
118
144
  verbose,
119
145
  cwd,
120
146
  )
@@ -143,7 +169,12 @@ def run_command( # noqa: C901
143
169
  stdout += line
144
170
  if verbose:
145
171
  # print each line of output, assuming that each has a newline
146
- log(parse_stdout(line, prefix=f"{label}: "))
172
+ log(
173
+ parse_stdout(
174
+ line, prefix=f"[green]| [/green][blue]{label}[/blue]: "
175
+ ),
176
+ prefix=False,
177
+ )
147
178
 
148
179
  # Wait for the subprocess to finish and get the exit code
149
180
  process.wait()
@@ -154,15 +185,15 @@ def run_command( # noqa: C901
154
185
  )
155
186
  raise RunCommandBadExitCode(
156
187
  (
157
- f"non-zero exit {process.returncode} (allowed are "
158
- f"{valid_exit_strs}) from {cmd_string}"
188
+ f"non-zero exit [red]{process.returncode}[/red] (allowed are "
189
+ f"[green]{valid_exit_strs}[/green]) from {cmd_string}"
159
190
  )
160
191
  )
161
192
  except BaseException as err:
162
193
  if ignore_fails:
163
194
  return ""
164
195
  parsed_stdout: str = (
165
- parse_stdout(stdout, prefix=f"{label}: ERROR: ") if process else ""
196
+ parse_stdout(stdout, prefix="[red]| [/red]") if process else ""
166
197
  )
167
198
  env_overrides_as_string = ""
168
199
  if env_overrides:
@@ -171,11 +202,11 @@ def run_command( # noqa: C901
171
202
  )
172
203
  env_overrides_as_string = f"{env_overrides_as_string} "
173
204
  error_string = (
174
- f"runem: test: FATAL: command failed: {label}"
175
- f"\n\t{env_overrides_as_string}{cmd_string}"
176
- f"\nERROR"
205
+ f"runem: [red bold]FATAL[/red bold]: command failed: [blue]{label}[/blue]"
206
+ f"\n\t[yellow]{env_overrides_as_string}{cmd_string}[/yellow]"
207
+ f"\n[red underline]| ERROR[/red underline]: [blue]{label}[/blue]"
177
208
  f"\n{str(parsed_stdout)}"
178
- f"\nERROR END"
209
+ f"\n[red underline]| ERROR END[/red underline]"
179
210
  )
180
211
 
181
212
  if isinstance(err, RunCommandBadExitCode):
@@ -184,7 +215,10 @@ def run_command( # noqa: C901
184
215
  raise RunCommandUnhandledError(error_string) from err
185
216
 
186
217
  if verbose:
187
- log(f"running: done: {label}: {cmd_string}")
218
+ log(
219
+ f"running: done: [blue]{label}[/blue]: [yellow]{cmd_string}[/yellow]",
220
+ prefix=decorate_logs,
221
+ )
188
222
 
189
223
  if record_sub_job_time is not None:
190
224
  # Capture how long this run took