runem 0.0.28__tar.gz → 0.0.29__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (65) hide show
  1. {runem-0.0.28 → runem-0.0.29}/HISTORY.md +107 -0
  2. {runem-0.0.28 → runem-0.0.29}/PKG-INFO +8 -7
  3. {runem-0.0.28 → runem-0.0.29}/README.md +6 -6
  4. runem-0.0.29/runem/VERSION +1 -0
  5. {runem-0.0.28 → runem-0.0.29}/runem/job_execute.py +28 -6
  6. runem-0.0.29/runem/report.py +252 -0
  7. {runem-0.0.28 → runem-0.0.29}/runem/run_command.py +18 -0
  8. {runem-0.0.28 → runem-0.0.29}/runem/runem.py +16 -6
  9. {runem-0.0.28 → runem-0.0.29}/runem/types.py +15 -1
  10. {runem-0.0.28 → runem-0.0.29}/runem.egg-info/PKG-INFO +8 -7
  11. {runem-0.0.28 → runem-0.0.29}/runem.egg-info/SOURCES.txt +2 -1
  12. {runem-0.0.28 → runem-0.0.29}/runem.egg-info/requires.txt +1 -0
  13. runem-0.0.29/tests/data/help_output.3.11.txt +71 -0
  14. {runem-0.0.28 → runem-0.0.29}/tests/test_job_execute.py +74 -0
  15. {runem-0.0.28 → runem-0.0.29}/tests/test_report.py +72 -29
  16. {runem-0.0.28 → runem-0.0.29}/tests/test_run_command.py +12 -1
  17. {runem-0.0.28 → runem-0.0.29}/tests/test_runem.py +41 -11
  18. runem-0.0.28/runem/VERSION +0 -1
  19. runem-0.0.28/runem/report.py +0 -145
  20. {runem-0.0.28 → runem-0.0.29}/Containerfile +0 -0
  21. {runem-0.0.28 → runem-0.0.29}/LICENSE +0 -0
  22. {runem-0.0.28 → runem-0.0.29}/MANIFEST.in +0 -0
  23. {runem-0.0.28 → runem-0.0.29}/runem/__init__.py +0 -0
  24. {runem-0.0.28 → runem-0.0.29}/runem/__main__.py +0 -0
  25. {runem-0.0.28 → runem-0.0.29}/runem/base.py +0 -0
  26. {runem-0.0.28 → runem-0.0.29}/runem/blocking_print.py +0 -0
  27. {runem-0.0.28 → runem-0.0.29}/runem/cli/initialise_options.py +0 -0
  28. {runem-0.0.28 → runem-0.0.29}/runem/cli.py +0 -0
  29. {runem-0.0.28 → runem-0.0.29}/runem/command_line.py +0 -0
  30. {runem-0.0.28 → runem-0.0.29}/runem/config.py +0 -0
  31. {runem-0.0.28 → runem-0.0.29}/runem/config_metadata.py +0 -0
  32. {runem-0.0.28 → runem-0.0.29}/runem/config_parse.py +0 -0
  33. {runem-0.0.28 → runem-0.0.29}/runem/files.py +0 -0
  34. {runem-0.0.28 → runem-0.0.29}/runem/informative_dict.py +0 -0
  35. {runem-0.0.28 → runem-0.0.29}/runem/job.py +0 -0
  36. {runem-0.0.28 → runem-0.0.29}/runem/job_filter.py +0 -0
  37. {runem-0.0.28 → runem-0.0.29}/runem/job_runner_simple_command.py +0 -0
  38. {runem-0.0.28 → runem-0.0.29}/runem/job_wrapper.py +0 -0
  39. {runem-0.0.28 → runem-0.0.29}/runem/job_wrapper_python.py +0 -0
  40. {runem-0.0.28 → runem-0.0.29}/runem/log.py +0 -0
  41. {runem-0.0.28 → runem-0.0.29}/runem/py.typed +0 -0
  42. {runem-0.0.28 → runem-0.0.29}/runem/runem_version.py +0 -0
  43. {runem-0.0.28 → runem-0.0.29}/runem/utils.py +0 -0
  44. {runem-0.0.28 → runem-0.0.29}/runem.egg-info/dependency_links.txt +0 -0
  45. {runem-0.0.28 → runem-0.0.29}/runem.egg-info/entry_points.txt +0 -0
  46. {runem-0.0.28 → runem-0.0.29}/runem.egg-info/top_level.txt +0 -0
  47. {runem-0.0.28 → runem-0.0.29}/setup.cfg +0 -0
  48. {runem-0.0.28 → runem-0.0.29}/setup.py +0 -0
  49. {runem-0.0.28 → runem-0.0.29}/tests/__init__.py +0 -0
  50. {runem-0.0.28 → runem-0.0.29}/tests/cli/test_initialise_options.py +0 -0
  51. {runem-0.0.28 → runem-0.0.29}/tests/conftest.py +0 -0
  52. /runem-0.0.28/tests/data/help_output.txt → /runem-0.0.29/tests/data/help_output.3.10.txt +0 -0
  53. {runem-0.0.28 → runem-0.0.29}/tests/intentional_test_error.py +0 -0
  54. {runem-0.0.28 → runem-0.0.29}/tests/test_base.py +0 -0
  55. {runem-0.0.28 → runem-0.0.29}/tests/test_blocking_print.py +0 -0
  56. {runem-0.0.28 → runem-0.0.29}/tests/test_cli.py +0 -0
  57. {runem-0.0.28 → runem-0.0.29}/tests/test_config.py +0 -0
  58. {runem-0.0.28 → runem-0.0.29}/tests/test_config_parse.py +0 -0
  59. {runem-0.0.28 → runem-0.0.29}/tests/test_files.py +0 -0
  60. {runem-0.0.28 → runem-0.0.29}/tests/test_informative_dict.py +0 -0
  61. {runem-0.0.28 → runem-0.0.29}/tests/test_job.py +0 -0
  62. {runem-0.0.28 → runem-0.0.29}/tests/test_job_filter.py +0 -0
  63. {runem-0.0.28 → runem-0.0.29}/tests/test_job_runner_simple_command.py +0 -0
  64. {runem-0.0.28 → runem-0.0.29}/tests/test_job_wrapper.py +0 -0
  65. {runem-0.0.28 → runem-0.0.29}/tests/test_job_wrapper_python.py +0 -0
@@ -4,6 +4,113 @@ Changelog
4
4
 
5
5
  (unreleased)
6
6
  ------------
7
+ - Merge pull request #40 from lursight/fix/time_saved. [Frank Harrison]
8
+
9
+ Fix/time saved
10
+ - Feat(prettier-bars): clarifies that total is user-space time. [Frank
11
+ Harrison]
12
+
13
+ ... not wall-clock or system-time
14
+ - Feat(prettier-bars): distiguishes the wall-clock bars. [Frank
15
+ Harrison]
16
+
17
+ ... from the total/sum and sub-job bars, so that it's slightly easier to
18
+ see where the time is being really spent.
19
+ - Fix(time-saved): clarifies which measurement is the wall-clock time
20
+ for the entire run. [Frank Harrison]
21
+ - Fix(time-saved): add message about how long we _would_ have waited
22
+ without runem. [Frank Harrison]
23
+ - Fix(time-saved): renames all variable associated with timing reports.
24
+ [Frank Harrison]
25
+
26
+ This just makes someting which can become intractable/confusing a lot
27
+ easier to follow.
28
+ - Fix(time-saved): check that time-saved is reported correctly. [Frank
29
+ Harrison]
30
+
31
+ Here we add a test first and then fix the missing math to calculate the
32
+ time-saved by using runem. We broke this in the previous feature for
33
+ rendering the tree slightly more elegantly.
34
+ - Feat(hide-single-leafs): only show the job when it has a single child.
35
+ [Frank Harrison]
36
+
37
+ We would get duplicated information for jobs which had single
38
+ run_command invocations. This only shows sub-tasks/jobs if there are
39
+ more than one sub-tasks meaning the output looks a lot nicer & clearer.
40
+ - Chore(deps): adds setuptools as a explicit dep. [Frank Harrison]
41
+
42
+ ... otherwise we get the following error (more often in python 3.12),
43
+ perhaps due to setuptools being removed from distros?:
44
+
45
+ ```text
46
+ Traceback (most recent call last):
47
+ File "/var/www/mydir/virtualenvs/dev/bin/pip", line 5, in <module>
48
+ from pkg_resources import load_entry_point
49
+ ImportError: No module named pkg_resources
50
+ ```
51
+ - Merge pull request #39 from lursight/feat/time_all_run_command_calls.
52
+ [Frank Harrison]
53
+
54
+ Feat/time all run command calls
55
+ - Feat(pretty-tree): refactors out the phase-job report generator.
56
+ [Frank Harrison]
57
+
58
+ This is just to make pylint happy.
59
+ - Feat(pretty-tree): makes the report tree neater. [Frank Harrison]
60
+ - Feat(time-all-sub-tasks): re-raise errors for context in ci/cd. [Frank
61
+ Harrison]
62
+
63
+ In github ci/cd we were hitting the asserts but had no context of where
64
+ they're raised from or why. This should fix that if they still occur.
65
+ - Feat(time-all-sub-tasks): adds a test to test the time-recording
66
+ functions. [Frank Harrison]
67
+ - Feat(time-all-sub-tasks): adds all run_command times to report output.
68
+ [Frank Harrison]
69
+ - Chore(type): uses a type-alias instead of manual type. [Frank
70
+ Harrison]
71
+ - Merge pull request #37 from
72
+ lursight/dependabot/github_actions/actions/upload-artifact-4. [Frank
73
+ Harrison]
74
+
75
+ chore(deps): bump actions/upload-artifact from 3 to 4
76
+ - Chore(deps): bump actions/upload-artifact from 3 to 4.
77
+ [dependabot[bot]]
78
+
79
+ Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
80
+ - [Release notes](https://github.com/actions/upload-artifact/releases)
81
+ - [Commits](https://github.com/actions/upload-artifact/compare/v3...v4)
82
+
83
+ ---
84
+ updated-dependencies:
85
+ - dependency-name: actions/upload-artifact
86
+ dependency-type: direct:production
87
+ update-type: version-update:semver-major
88
+ ...
89
+ - Merge pull request #36 from lursight/chore/more_python_versions_in_ci.
90
+ [Frank Harrison]
91
+
92
+ Chore/more python versions in ci
93
+ - Chore(github-ci): workaround for python 3.12 setuptools issue. [Frank
94
+ Harrison]
95
+
96
+ This fies what looks like an issue with pytest hooks running ci/cd (and
97
+ on local machine) where we get:
98
+ ModuleNotFoundError: No module named 'pkg_resources'
99
+ - Chore(github-ci): updates the python `--help` tests for 3.11 (and
100
+ later) [Frank Harrison]
101
+ - Chore(github-ci): test python 3.9, 3.11 and 3.12 in ci. [Frank
102
+ Harrison]
103
+
104
+ We don't bother with 3.10 because we test 3.9 and boundaries for
105
+ features are on the 3.11 version.
106
+
107
+ We don't bother with earlier than 3.9, even though 3.8 is the earliest
108
+ officially supported version.
109
+
110
+
111
+ 0.0.28 (2024-03-01)
112
+ -------------------
113
+ - Release: version 0.0.28 🚀 [Frank Harrison]
7
114
  - Merge pull request #35 from lursight/fix/aliases_not_setting_options.
8
115
  [Frank Harrison]
9
116
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: runem
3
- Version: 0.0.28
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] ▍
@@ -367,12 +367,12 @@ runem: reports:
367
367
  runem: runem: 8.820488s
368
368
  runem: ├runem.pre-build: 0.019031s
369
369
  runem: ├runem.run-phases: 8.801317s
370
- runem: ├pre-run (total): 0.00498s
370
+ runem: ├pre-run (user-time): 0.00498s
371
371
  runem: │├pre-run.install python requirements: 2.6e-05s
372
372
  runem: │├pre-run.ls -alh runem: 0.004954s
373
- runem: ├edit (total): 0.557559s
373
+ runem: ├edit (user-time): 0.557559s
374
374
  runem: │├edit.reformat py: 0.557559s
375
- runem: ├analysis (total): 21.526145s
375
+ runem: ├analysis (user-time): 21.526145s
376
376
  runem: │├analysis.pylint py: 7.457029s
377
377
  runem: │├analysis.flake8 py: 0.693754s
378
378
  runem: │├analysis.mypy py: 1.071956s
@@ -397,12 +397,12 @@ runem: reports:
397
397
  runem [14.174612] ███████████████▋
398
398
  ├runem.pre-build [ 0.025858]
399
399
  ├runem.run-phases [14.148587] ███████████████▋
400
- ├pre-run (total) [ 0.005825]
400
+ ├pre-run (user-time) [ 0.005825]
401
401
  │├pre-run.install python requirements [ 0.000028]
402
402
  │├pre-run.ls -alh runem [ 0.005797]
403
- ├edit (total) [ 0.579153] ▋
403
+ ├edit (user-time) [ 0.579153] ▋
404
404
  │├edit.reformat py [ 0.579153] ▋
405
- ├analysis (total) [36.231034] ████████████████████████████████████████
405
+ ├analysis (user-time) [36.231034] ████████████████████████████████████████
406
406
  │├analysis.pylint py [12.738303] ██████████████▏
407
407
  │├analysis.flake8 py [ 0.798575] ▉
408
408
  │├analysis.mypy py [ 0.335984] ▍
@@ -0,0 +1 @@
1
+ 0.0.29
@@ -11,14 +11,23 @@ from runem.informative_dict import ReadOnlyInformativeDict
11
11
  from runem.job import Job
12
12
  from runem.job_wrapper import get_job_wrapper
13
13
  from runem.log import log
14
- 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
+ )
15
24
 
16
25
 
17
26
  def job_execute_inner(
18
27
  job_config: JobConfig,
19
28
  config_metadata: ConfigMetadata,
20
29
  file_lists: FilePathListLookup,
21
- ) -> typing.Tuple[typing.Tuple[str, timedelta], JobReturn]:
30
+ ) -> typing.Tuple[JobTiming, JobReturn]:
22
31
  """Wrapper for running a job inside a sub-process.
23
32
 
24
33
  Returns the time information and any reports the job generated
@@ -38,7 +47,19 @@ def job_execute_inner(
38
47
  if not file_list:
39
48
  # no files to work on
40
49
  log(f"WARNING: skipping job '{label}', no files for job")
41
- 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))
42
63
 
43
64
  if (
44
65
  "ctx" in job_config
@@ -71,6 +92,7 @@ def job_execute_inner(
71
92
  # unpack useful data points from the job_config
72
93
  label=Job.get_job_name(job_config),
73
94
  job=job_config,
95
+ record_sub_job_time=_record_sub_job_time,
74
96
  )
75
97
  except BaseException: # pylint: disable=broad-exception-caught
76
98
  # log that we hit an error on this job and re-raise
@@ -83,8 +105,8 @@ def job_execute_inner(
83
105
  time_taken: timedelta = timedelta(seconds=end - start)
84
106
  if config_metadata.args.verbose:
85
107
  log(f"job: DONE: '{label}': {time_taken}")
86
- timing_data = (label, time_taken)
87
- return (timing_data, reports)
108
+ this_job_timing_data: TimingEntry = (label, time_taken)
109
+ return ({"job": this_job_timing_data, "commands": sub_command_timings}, reports)
88
110
 
89
111
 
90
112
  def job_execute(
@@ -92,7 +114,7 @@ def job_execute(
92
114
  running_jobs: typing.Dict[str, str],
93
115
  config_metadata: ConfigMetadata,
94
116
  file_lists: FilePathListLookup,
95
- ) -> typing.Tuple[typing.Tuple[str, timedelta], JobReturn]:
117
+ ) -> typing.Tuple[JobTiming, JobReturn]:
96
118
  """Thin-wrapper around job_execute_inner needed for mocking in tests.
97
119
 
98
120
  Needed for faster tests.
@@ -0,0 +1,252 @@
1
+ import re
2
+ import typing
3
+ from collections import defaultdict
4
+ from datetime import timedelta
5
+
6
+ from runem.log import log
7
+ from runem.types import (
8
+ JobReturn,
9
+ JobRunMetadatasByPhase,
10
+ JobRunReportByPhase,
11
+ JobRunTimesByPhase,
12
+ JobTiming,
13
+ OrderedPhases,
14
+ PhaseName,
15
+ ReportUrlInfo,
16
+ ReportUrls,
17
+ TimingEntries,
18
+ )
19
+
20
+ try:
21
+ import termplotlib
22
+ except ImportError: # pragma: FIXME: add code coverage
23
+ termplotlib = None
24
+
25
+
26
+ def _align_bar_graphs_workaround(original_text: str) -> str:
27
+ """Module termplotlib doesn't align floats, this fixes that.
28
+
29
+ This makes it so we can align the point in the floating point string, without it,
30
+ larger numbers push their bars right, instead of at the same place.
31
+ """
32
+ # Find the maximum width between '[' and '.' characters
33
+ max_width = max(
34
+ int(match.end() - match.start() - 2)
35
+ for match in re.finditer(r"\[.*?(\d+)\.", original_text)
36
+ )
37
+
38
+ # Replace each line with aligned numbers
39
+ formatted_text = re.sub(
40
+ r"\[.*?(\d+)\.", lambda m: f"[{m.group(1):>{max_width}}.", original_text
41
+ )
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)
83
+
84
+
85
+ def _plot_times(
86
+ wall_clock_for_runem_main: timedelta,
87
+ phase_run_oder: OrderedPhases,
88
+ timing_data: JobRunTimesByPhase,
89
+ ) -> typing.Tuple[timedelta, timedelta]:
90
+ """Prints a report to terminal on how well we performed.
91
+
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)
96
+ """
97
+ labels: typing.List[str] = []
98
+ times: typing.List[float] = []
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 " "
108
+ # log(f"Phase '{phase}' jobs took:")
109
+ phase_start_idx = len(labels)
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())
130
+ if termplotlib:
131
+ fig = termplotlib.figure()
132
+ # cspell:disable-next-line
133
+ fig.barh(
134
+ times,
135
+ labels,
136
+ force_ascii=False,
137
+ )
138
+ shaded_bar_graph: str = _semi_shade_phase_totals(fig.get_string())
139
+ dotted_bar_graph: str = _dot_jobs(shaded_bar_graph)
140
+
141
+ # ensure the graphs get aligned nicely.
142
+ final_bar_graph: str = _align_bar_graphs_workaround(dotted_bar_graph)
143
+ print(final_bar_graph)
144
+ else: # pragma: FIXME: add code coverage
145
+ for job_label, time in zip(labels, times):
146
+ log(f"{job_label}: {time}s")
147
+
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
199
+
200
+
201
+ def _print_reports_by_phase(
202
+ phase_run_oder: OrderedPhases, report_data: JobRunReportByPhase
203
+ ) -> None:
204
+ """Logs out the reports by grouped by phase."""
205
+ for phase in phase_run_oder:
206
+ report_urls: ReportUrls = report_data[phase]
207
+ job_report_url_info: ReportUrlInfo
208
+ for job_report_url_info in report_urls:
209
+ if not job_report_url_info:
210
+ continue
211
+ log(f"report: {str(job_report_url_info[0])}: {str(job_report_url_info[1])}")
212
+
213
+
214
+ def report_on_run(
215
+ phase_run_oder: OrderedPhases,
216
+ job_run_metadatas: JobRunMetadatasByPhase,
217
+ wall_clock_for_runem_main: timedelta,
218
+ ) -> typing.Tuple[timedelta, timedelta]:
219
+ """Generate high-level reports AND prints out any reports returned by jobs.
220
+
221
+ IMPORTANT: returns the wall-clock time saved to the user.
222
+ """
223
+ log("reports:")
224
+
225
+ # First, collate all data, timing and reports
226
+ timing_data: JobRunTimesByPhase = defaultdict(list)
227
+ report_data: JobRunReportByPhase = defaultdict(list)
228
+ phase: PhaseName
229
+ for phase in job_run_metadatas:
230
+ timing: JobTiming
231
+ reports: JobReturn
232
+ for timing, reports in job_run_metadatas[phase]:
233
+ timing_data[phase].append(timing)
234
+ if reports:
235
+ # the job returned some report urls, record them against the
236
+ # job's phase
237
+ report_data[phase].extend(reports["reportUrls"])
238
+
239
+ # Now plot the times on the terminal to give a visual report of the timing.
240
+ time_metrics: typing.Tuple[timedelta, timedelta] = _plot_times(
241
+ wall_clock_for_runem_main=wall_clock_for_runem_main,
242
+ phase_run_oder=phase_run_oder,
243
+ timing_data=timing_data,
244
+ )
245
+
246
+ # Penultimate-ly print out the available reports grouped by run-phase.
247
+ _print_reports_by_phase(phase_run_oder, report_data)
248
+
249
+ # Return the key metrics for runem, the system vs wall-clock time saved to
250
+ # the user
251
+ # TODO: write this to disk
252
+ return time_metrics
@@ -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,6 +20,10 @@ 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
28
  """Prefixes each line of the output with a given label, except trailing new
23
29
  lines."""
@@ -91,11 +97,16 @@ def run_command( # noqa: C901
91
97
  ignore_fails: bool = False,
92
98
  valid_exit_ids: typing.Optional[typing.Tuple[int, ...]] = None,
93
99
  cwd: typing.Optional[pathlib.Path] = None,
100
+ record_sub_job_time: typing.Optional[RecordSubJobTimeType] = None,
94
101
  **kwargs: typing.Any,
95
102
  ) -> str:
96
103
  """Runs the given command, returning stdout or throwing on any error."""
97
104
  cmd_string = " ".join(cmd)
98
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
+
99
110
  run_env: typing.Dict[str, str] = _prepare_environment(
100
111
  env_overrides,
101
112
  )
@@ -174,4 +185,11 @@ def run_command( # noqa: C901
174
185
 
175
186
  if verbose:
176
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
+
177
195
  return stdout
@@ -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,14 +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
+ )
337
346
  message: str = "DONE: runem took"
338
347
  if failure_exception:
339
348
  message = "FAILED: your jobs failed after"
340
349
  log(
341
350
  (
342
351
  f"{message}: {time_taken.total_seconds()}s, "
343
- f"saving you {time_saved.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"
344
354
  )
345
355
  )
346
356
 
@@ -32,7 +32,21 @@ class JobReturnData(typing.TypedDict, total=False):
32
32
  reportUrls: ReportUrls # urls containing reports for the user
33
33
 
34
34
 
35
- 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
+
36
50
  JobReturn = typing.Optional[JobReturnData]
37
51
  JobRunMetadata = typing.Tuple[JobTiming, JobReturn]
38
52
  JobRunTimesByPhase = typing.Dict[PhaseName, typing.List[JobTiming]]