profiler-runit 0.1.4.post1.dev9__tar.gz → 0.2.0.post1.dev5__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 (33) hide show
  1. profiler_runit-0.2.0.post1.dev5/CHANGES.md +10 -0
  2. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/PKG-INFO +28 -7
  3. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/README.md +23 -5
  4. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/profiler_runit.egg-info/PKG-INFO +28 -7
  5. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/profiler_runit.egg-info/SOURCES.txt +3 -0
  6. profiler_runit-0.2.0.post1.dev5/profiler_runit.egg-info/requires.txt +2 -0
  7. profiler_runit-0.2.0.post1.dev5/profiler_runit.egg-info/scm_file_list.json +23 -0
  8. profiler_runit-0.2.0.post1.dev5/profiler_runit.egg-info/scm_version.json +8 -0
  9. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/pyproject.toml +6 -1
  10. profiler_runit-0.2.0.post1.dev5/requirements.txt +2 -0
  11. profiler_runit-0.2.0.post1.dev5/runit/cli.py +132 -0
  12. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/runit/monitor.py +44 -3
  13. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/runit/plotting.py +7 -0
  14. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/runit/report.py +2 -1
  15. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/runit/utils.py +24 -0
  16. profiler_runit-0.2.0.post1.dev5/test.py +446 -0
  17. profiler_runit-0.1.4.post1.dev9/profiler_runit.egg-info/requires.txt +0 -2
  18. profiler_runit-0.1.4.post1.dev9/requirements.txt +0 -2
  19. profiler_runit-0.1.4.post1.dev9/runit/cli.py +0 -89
  20. profiler_runit-0.1.4.post1.dev9/test.py +0 -230
  21. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/.github/workflows/ci.yml +0 -0
  22. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/.github/workflows/release.yml +0 -0
  23. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/.github/workflows/testpypi.yml +0 -0
  24. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/.gitignore +0 -0
  25. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/LICENSE +0 -0
  26. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/assets/runit-demo.gif +0 -0
  27. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/benchmark.py +0 -0
  28. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/profiler_runit.egg-info/dependency_links.txt +0 -0
  29. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/profiler_runit.egg-info/entry_points.txt +0 -0
  30. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/profiler_runit.egg-info/top_level.txt +0 -0
  31. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/runit/__init__.py +0 -0
  32. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/runit/logging_config.py +0 -0
  33. {profiler_runit-0.1.4.post1.dev9 → profiler_runit-0.2.0.post1.dev5}/setup.cfg +0 -0
@@ -0,0 +1,10 @@
1
+ # Changes
2
+
3
+ ## 0.2.0 (unreleased)
4
+
5
+ - Fixed: `--out-file` no longer crashes the output thread or kills the command with a broken pipe. All output of the command is shown and captured.
6
+ - Fixed: output larger than the pipe buffer (about 64 KB) no longer makes runit hang. This was fixed on main by streaming stdout and stderr (PR #1). The 0.1.4 release still has the hang.
7
+ - Changed (new in 0.2.0): runit exits with the exit code of the command (128 + N for signal N, 127 when the command is not found, 126 when it cannot be executed, also for a script without a `#!` line). Before, it exited with 0; use `runit ... || true` if a script relies on that. The report's `Exit Code` shows the same value, with the signal name for a signal (for example `143 (signal 15, SIGTERM)`).
8
+ - Fixed: an `--out-file` that cannot be written, or a full stdout, now gives one error line instead of a traceback. For a full stdout, runit keeps the command's exit code. For an unwritable `--out-file`, a non-zero exit code of the command wins; otherwise runit exits 1. `--out-file` is written before the report goes to the terminal. When the stdout reader goes away (`runit yes | head`), runit stops quietly.
9
+ - Changed: the plotext dependency is pinned to `plotext>=5.3,<6`. plotext 6 replaced the API that `--plot` uses, so charts were silently skipped, and plotext 5.2 draws empty charts. runit now logs a clear warning if an unsupported plotext is installed.
10
+ - Tests: added regression tests that run the installed `runit` command for these cases.
@@ -1,14 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: profiler-runit
3
- Version: 0.1.4.post1.dev9
3
+ Version: 0.2.0.post1.dev5
4
4
  Summary: Execute and profile cli commands
5
5
  Author-email: NotesOfCliff <notesofcliff@gmail.com>
6
6
  License-Expression: GPL-3.0-only
7
+ Project-URL: Homepage, https://github.com/McIndi/runit
8
+ Project-URL: Repository, https://github.com/McIndi/runit
9
+ Project-URL: Issues, https://github.com/McIndi/runit/issues
7
10
  Requires-Python: >=3.9
8
11
  Description-Content-Type: text/markdown
9
12
  License-File: LICENSE
10
13
  Requires-Dist: psutil
11
- Requires-Dist: plotext
14
+ Requires-Dist: plotext<6,>=5.3
12
15
  Dynamic: license-file
13
16
 
14
17
  # runit
@@ -39,14 +42,14 @@ See `runit` in action:
39
42
  ## Requirements
40
43
  * Python 3.9+
41
44
  * psutil
42
- * plotext
45
+ * plotext 5.x (`plotext>=5.3,<6`). plotext 6 replaced the plotting API that runit uses, so it is not supported yet.
43
46
 
44
47
  ## Installation
45
48
 
46
49
  Clone this repository and install with pip:
47
50
 
48
51
  ```sh
49
- git clone https://github.com/notesofcliff/runit.git
52
+ git clone https://github.com/McIndi/runit.git
50
53
  cd runit
51
54
  python -m venv .venv
52
55
  source .venv/bin/activate # Or (for Windows) .venv/Scripts/activate
@@ -59,7 +62,7 @@ python test.py
59
62
  Or, install directly from GitHub:
60
63
 
61
64
  ```sh
62
- pip install git+https://github.com/notesofcliff/runit.git
65
+ pip install git+https://github.com/McIndi/runit.git
63
66
  ```
64
67
 
65
68
  After installation, use `runit` to run and analyze any command:
@@ -91,6 +94,7 @@ PID: 12345
91
94
  Start Time: 2025-07-31 12:00:00.000000
92
95
  End Time: 2025-07-31 12:00:01.000000
93
96
  Duration: 0:00:01
97
+ Exit Code: 0
94
98
  Max RSS (bytes): 12345678
95
99
  Max Threads: 4
96
100
  Max Children: 0
@@ -110,12 +114,15 @@ runit python benchmark.py
110
114
  ## CLI Options
111
115
 
112
116
  - `--log-level LEVEL`: Set the logging level (default: INFO)
113
- - `--out-file FILE`: Write the report and charts to a file (output is also shown in the terminal)
117
+ - `--out-file FILE`: Also write the report and charts to a file (they are still shown in the terminal)
114
118
  - `--strip-ansi`: Remove ANSI escape codes from output file (for plain text files)
115
119
  - `--plot`: If specified, attempt to plot CPU and memory usage
120
+ - `--plot-width N`, `--plot-height N`: Set the chart size (default: 80 x 20)
116
121
  - `command`: The command to run and monitor
117
122
 
118
- When using `--out-file`, the output is both displayed in the terminal and written to the file. For plain text (no colors or terminal formatting), simply add `--strip-ansi`.
123
+ The output of the command goes to the terminal while it runs (stdout to stdout, stderr to stderr), with or without `--out-file`. When the command ends, runit shows the report, which also contains the captured stdout and stderr.
124
+
125
+ When using `--out-file`, the report and charts are both displayed in the terminal and written to the file. For plain text (no colors or terminal formatting), simply add `--strip-ansi`.
119
126
 
120
127
  ```sh
121
128
  runit --out-file out.txt --strip-ansi python benchmark.py
@@ -123,6 +130,20 @@ runit --out-file out.txt --strip-ansi python benchmark.py
123
130
 
124
131
  You'll see the report and charts in your terminal, and a plain text version in `out.txt`.
125
132
 
133
+ ## Exit Status
134
+
135
+ runit exits with the exit code of the command, so you can use it in scripts and CI:
136
+
137
+ - The exit code of the command (for example, `runit false` exits with 1).
138
+ - 128 + N when the command is stopped by signal N (for example, 143 for SIGTERM).
139
+ - 127 when the command is not found, and 126 when it cannot be executed (for example, no permission, or a script without a `#!` line).
140
+
141
+ The `Exit Code:` line of the report shows the same value that runit exits with. For a signal, it also shows the signal, for example `Exit Code: 143 (signal 15, SIGTERM)`.
142
+
143
+ If runit cannot write the `--out-file` file, it logs one error line. If the command failed, runit exits with the exit code of the command; if the command exited with 0, runit exits with 1. If runit cannot write the report to stdout (for example, a full disk), it logs one error line and exits with the exit code of the command. If the reader of stdout goes away (for example, `runit yes | head`), runit stops quietly. The `--out-file` file is written before the report goes to the terminal.
144
+
145
+ This behavior is new in 0.2.0. Releases up to 0.1.4 always exited with 0. If a script relies on that, use `runit ... || true`.
146
+
126
147
  ## Development & Extending
127
148
 
128
149
  ### Project Structure
@@ -26,14 +26,14 @@ See `runit` in action:
26
26
  ## Requirements
27
27
  * Python 3.9+
28
28
  * psutil
29
- * plotext
29
+ * plotext 5.x (`plotext>=5.3,<6`). plotext 6 replaced the plotting API that runit uses, so it is not supported yet.
30
30
 
31
31
  ## Installation
32
32
 
33
33
  Clone this repository and install with pip:
34
34
 
35
35
  ```sh
36
- git clone https://github.com/notesofcliff/runit.git
36
+ git clone https://github.com/McIndi/runit.git
37
37
  cd runit
38
38
  python -m venv .venv
39
39
  source .venv/bin/activate # Or (for Windows) .venv/Scripts/activate
@@ -46,7 +46,7 @@ python test.py
46
46
  Or, install directly from GitHub:
47
47
 
48
48
  ```sh
49
- pip install git+https://github.com/notesofcliff/runit.git
49
+ pip install git+https://github.com/McIndi/runit.git
50
50
  ```
51
51
 
52
52
  After installation, use `runit` to run and analyze any command:
@@ -78,6 +78,7 @@ PID: 12345
78
78
  Start Time: 2025-07-31 12:00:00.000000
79
79
  End Time: 2025-07-31 12:00:01.000000
80
80
  Duration: 0:00:01
81
+ Exit Code: 0
81
82
  Max RSS (bytes): 12345678
82
83
  Max Threads: 4
83
84
  Max Children: 0
@@ -97,12 +98,15 @@ runit python benchmark.py
97
98
  ## CLI Options
98
99
 
99
100
  - `--log-level LEVEL`: Set the logging level (default: INFO)
100
- - `--out-file FILE`: Write the report and charts to a file (output is also shown in the terminal)
101
+ - `--out-file FILE`: Also write the report and charts to a file (they are still shown in the terminal)
101
102
  - `--strip-ansi`: Remove ANSI escape codes from output file (for plain text files)
102
103
  - `--plot`: If specified, attempt to plot CPU and memory usage
104
+ - `--plot-width N`, `--plot-height N`: Set the chart size (default: 80 x 20)
103
105
  - `command`: The command to run and monitor
104
106
 
105
- When using `--out-file`, the output is both displayed in the terminal and written to the file. For plain text (no colors or terminal formatting), simply add `--strip-ansi`.
107
+ The output of the command goes to the terminal while it runs (stdout to stdout, stderr to stderr), with or without `--out-file`. When the command ends, runit shows the report, which also contains the captured stdout and stderr.
108
+
109
+ When using `--out-file`, the report and charts are both displayed in the terminal and written to the file. For plain text (no colors or terminal formatting), simply add `--strip-ansi`.
106
110
 
107
111
  ```sh
108
112
  runit --out-file out.txt --strip-ansi python benchmark.py
@@ -110,6 +114,20 @@ runit --out-file out.txt --strip-ansi python benchmark.py
110
114
 
111
115
  You'll see the report and charts in your terminal, and a plain text version in `out.txt`.
112
116
 
117
+ ## Exit Status
118
+
119
+ runit exits with the exit code of the command, so you can use it in scripts and CI:
120
+
121
+ - The exit code of the command (for example, `runit false` exits with 1).
122
+ - 128 + N when the command is stopped by signal N (for example, 143 for SIGTERM).
123
+ - 127 when the command is not found, and 126 when it cannot be executed (for example, no permission, or a script without a `#!` line).
124
+
125
+ The `Exit Code:` line of the report shows the same value that runit exits with. For a signal, it also shows the signal, for example `Exit Code: 143 (signal 15, SIGTERM)`.
126
+
127
+ If runit cannot write the `--out-file` file, it logs one error line. If the command failed, runit exits with the exit code of the command; if the command exited with 0, runit exits with 1. If runit cannot write the report to stdout (for example, a full disk), it logs one error line and exits with the exit code of the command. If the reader of stdout goes away (for example, `runit yes | head`), runit stops quietly. The `--out-file` file is written before the report goes to the terminal.
128
+
129
+ This behavior is new in 0.2.0. Releases up to 0.1.4 always exited with 0. If a script relies on that, use `runit ... || true`.
130
+
113
131
  ## Development & Extending
114
132
 
115
133
  ### Project Structure
@@ -1,14 +1,17 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: profiler-runit
3
- Version: 0.1.4.post1.dev9
3
+ Version: 0.2.0.post1.dev5
4
4
  Summary: Execute and profile cli commands
5
5
  Author-email: NotesOfCliff <notesofcliff@gmail.com>
6
6
  License-Expression: GPL-3.0-only
7
+ Project-URL: Homepage, https://github.com/McIndi/runit
8
+ Project-URL: Repository, https://github.com/McIndi/runit
9
+ Project-URL: Issues, https://github.com/McIndi/runit/issues
7
10
  Requires-Python: >=3.9
8
11
  Description-Content-Type: text/markdown
9
12
  License-File: LICENSE
10
13
  Requires-Dist: psutil
11
- Requires-Dist: plotext
14
+ Requires-Dist: plotext<6,>=5.3
12
15
  Dynamic: license-file
13
16
 
14
17
  # runit
@@ -39,14 +42,14 @@ See `runit` in action:
39
42
  ## Requirements
40
43
  * Python 3.9+
41
44
  * psutil
42
- * plotext
45
+ * plotext 5.x (`plotext>=5.3,<6`). plotext 6 replaced the plotting API that runit uses, so it is not supported yet.
43
46
 
44
47
  ## Installation
45
48
 
46
49
  Clone this repository and install with pip:
47
50
 
48
51
  ```sh
49
- git clone https://github.com/notesofcliff/runit.git
52
+ git clone https://github.com/McIndi/runit.git
50
53
  cd runit
51
54
  python -m venv .venv
52
55
  source .venv/bin/activate # Or (for Windows) .venv/Scripts/activate
@@ -59,7 +62,7 @@ python test.py
59
62
  Or, install directly from GitHub:
60
63
 
61
64
  ```sh
62
- pip install git+https://github.com/notesofcliff/runit.git
65
+ pip install git+https://github.com/McIndi/runit.git
63
66
  ```
64
67
 
65
68
  After installation, use `runit` to run and analyze any command:
@@ -91,6 +94,7 @@ PID: 12345
91
94
  Start Time: 2025-07-31 12:00:00.000000
92
95
  End Time: 2025-07-31 12:00:01.000000
93
96
  Duration: 0:00:01
97
+ Exit Code: 0
94
98
  Max RSS (bytes): 12345678
95
99
  Max Threads: 4
96
100
  Max Children: 0
@@ -110,12 +114,15 @@ runit python benchmark.py
110
114
  ## CLI Options
111
115
 
112
116
  - `--log-level LEVEL`: Set the logging level (default: INFO)
113
- - `--out-file FILE`: Write the report and charts to a file (output is also shown in the terminal)
117
+ - `--out-file FILE`: Also write the report and charts to a file (they are still shown in the terminal)
114
118
  - `--strip-ansi`: Remove ANSI escape codes from output file (for plain text files)
115
119
  - `--plot`: If specified, attempt to plot CPU and memory usage
120
+ - `--plot-width N`, `--plot-height N`: Set the chart size (default: 80 x 20)
116
121
  - `command`: The command to run and monitor
117
122
 
118
- When using `--out-file`, the output is both displayed in the terminal and written to the file. For plain text (no colors or terminal formatting), simply add `--strip-ansi`.
123
+ The output of the command goes to the terminal while it runs (stdout to stdout, stderr to stderr), with or without `--out-file`. When the command ends, runit shows the report, which also contains the captured stdout and stderr.
124
+
125
+ When using `--out-file`, the report and charts are both displayed in the terminal and written to the file. For plain text (no colors or terminal formatting), simply add `--strip-ansi`.
119
126
 
120
127
  ```sh
121
128
  runit --out-file out.txt --strip-ansi python benchmark.py
@@ -123,6 +130,20 @@ runit --out-file out.txt --strip-ansi python benchmark.py
123
130
 
124
131
  You'll see the report and charts in your terminal, and a plain text version in `out.txt`.
125
132
 
133
+ ## Exit Status
134
+
135
+ runit exits with the exit code of the command, so you can use it in scripts and CI:
136
+
137
+ - The exit code of the command (for example, `runit false` exits with 1).
138
+ - 128 + N when the command is stopped by signal N (for example, 143 for SIGTERM).
139
+ - 127 when the command is not found, and 126 when it cannot be executed (for example, no permission, or a script without a `#!` line).
140
+
141
+ The `Exit Code:` line of the report shows the same value that runit exits with. For a signal, it also shows the signal, for example `Exit Code: 143 (signal 15, SIGTERM)`.
142
+
143
+ If runit cannot write the `--out-file` file, it logs one error line. If the command failed, runit exits with the exit code of the command; if the command exited with 0, runit exits with 1. If runit cannot write the report to stdout (for example, a full disk), it logs one error line and exits with the exit code of the command. If the reader of stdout goes away (for example, `runit yes | head`), runit stops quietly. The `--out-file` file is written before the report goes to the terminal.
144
+
145
+ This behavior is new in 0.2.0. Releases up to 0.1.4 always exited with 0. If a script relies on that, use `runit ... || true`.
146
+
126
147
  ## Development & Extending
127
148
 
128
149
  ### Project Structure
@@ -1,4 +1,5 @@
1
1
  .gitignore
2
+ CHANGES.md
2
3
  LICENSE
3
4
  README.md
4
5
  benchmark.py
@@ -14,6 +15,8 @@ profiler_runit.egg-info/SOURCES.txt
14
15
  profiler_runit.egg-info/dependency_links.txt
15
16
  profiler_runit.egg-info/entry_points.txt
16
17
  profiler_runit.egg-info/requires.txt
18
+ profiler_runit.egg-info/scm_file_list.json
19
+ profiler_runit.egg-info/scm_version.json
17
20
  profiler_runit.egg-info/top_level.txt
18
21
  runit/__init__.py
19
22
  runit/cli.py
@@ -0,0 +1,2 @@
1
+ psutil
2
+ plotext<6,>=5.3
@@ -0,0 +1,23 @@
1
+ {
2
+ "files": [
3
+ ".github/workflows/ci.yml",
4
+ ".github/workflows/release.yml",
5
+ ".github/workflows/testpypi.yml",
6
+ ".gitignore",
7
+ "CHANGES.md",
8
+ "LICENSE",
9
+ "README.md",
10
+ "assets/runit-demo.gif",
11
+ "benchmark.py",
12
+ "pyproject.toml",
13
+ "requirements.txt",
14
+ "runit/__init__.py",
15
+ "runit/cli.py",
16
+ "runit/logging_config.py",
17
+ "runit/monitor.py",
18
+ "runit/plotting.py",
19
+ "runit/report.py",
20
+ "runit/utils.py",
21
+ "test.py"
22
+ ]
23
+ }
@@ -0,0 +1,8 @@
1
+ {
2
+ "tag": "0.2.0",
3
+ "distance": 5,
4
+ "node": "g30d2fa7621d9ddbc2c773cbf2796d6d9a60ddb44",
5
+ "dirty": false,
6
+ "branch": "main",
7
+ "node_date": "2026-11-11"
8
+ }
@@ -12,9 +12,14 @@ authors = [{ name = "NotesOfCliff", email = "notesofcliff@gmail.com" }]
12
12
  license = "GPL-3.0-only"
13
13
  dependencies = [
14
14
  "psutil",
15
- "plotext",
15
+ "plotext>=5.3,<6",
16
16
  ]
17
17
 
18
+ [project.urls]
19
+ Homepage = "https://github.com/McIndi/runit"
20
+ Repository = "https://github.com/McIndi/runit"
21
+ Issues = "https://github.com/McIndi/runit/issues"
22
+
18
23
  [tool.setuptools_scm]
19
24
  version_scheme = "no-guess-dev"
20
25
  local_scheme = "no-local-version"
@@ -0,0 +1,2 @@
1
+ psutil
2
+ plotext>=5.3,<6
@@ -0,0 +1,132 @@
1
+ import sys
2
+ from .monitor import monitor_process
3
+ from .report import format_report
4
+ from .plotting import plot_charts
5
+ from .utils import strip_ansi, exit_status
6
+ from .logging_config import setup_logging
7
+ import argparse
8
+ import logging
9
+ import os
10
+ from contextlib import redirect_stdout
11
+ from io import StringIO
12
+
13
+
14
+ def _write_stdout(text, log):
15
+ """Write the report to stdout. Return False if stdout is closed or broken.
16
+
17
+ When the reader has gone away (for example `runit cmd | head`), stay quiet
18
+ like other command line tools. For other errors (for example a full disk),
19
+ log one line. In both cases stdout is pointed at os.devnull, so that the
20
+ flush at interpreter exit does not raise again.
21
+ """
22
+ try:
23
+ sys.stdout.write(text)
24
+ sys.stdout.flush()
25
+ return True
26
+ except (OSError, ValueError) as e:
27
+ if isinstance(e, BrokenPipeError):
28
+ log.info("Output reader closed the pipe; report not shown.")
29
+ else:
30
+ log.error("Cannot write the report to stdout: %s", e)
31
+ try:
32
+ devnull = os.open(os.devnull, os.O_WRONLY)
33
+ os.dup2(devnull, sys.stdout.fileno())
34
+ os.close(devnull)
35
+ except (OSError, ValueError):
36
+ pass
37
+ return False
38
+
39
+
40
+ def run_cli():
41
+ parser = create_parser()
42
+ args = parser.parse_args()
43
+ setup_logging(args.log_level)
44
+ log = logging.getLogger(__name__)
45
+ log.info("Starting runit CLI")
46
+ if not args.command:
47
+ parser.print_help()
48
+ log.info("No command provided. Exiting.")
49
+ return 0
50
+ try:
51
+ # The program's stdout and stderr go to the terminal as it runs.
52
+ stats = monitor_process(args.command)
53
+ except FileNotFoundError as e:
54
+ log.error("Command not found: %s (%s)", args.command[0], e)
55
+ return 127
56
+ except OSError as e:
57
+ # For example EACCES, or ENOEXEC for a script without a shebang.
58
+ log.error("Command cannot be executed: %s (%s)", args.command[0], e)
59
+ return 126
60
+ status = exit_status(stats.get('returncode'))
61
+ report = format_report(stats)
62
+ # Capture the report and charts, write --out-file first, then the terminal.
63
+ buf = StringIO()
64
+ with redirect_stdout(buf):
65
+ if args.plot:
66
+ plot_charts(stats, args.plot_width, args.plot_height)
67
+ print(report)
68
+ output = buf.getvalue()
69
+ if args.out_file:
70
+ file_output = output
71
+ if getattr(args, 'strip_ansi', False):
72
+ log.info("Stripping ANSI codes for output file.")
73
+ file_output = strip_ansi(output)
74
+ try:
75
+ with open(args.out_file, 'w', encoding='utf-8') as f:
76
+ f.write(file_output)
77
+ log.info("Wrote output to file: %s", args.out_file)
78
+ except OSError as e:
79
+ # runit's own job failed: report it in one line. A non-zero
80
+ # exit code of the command wins; otherwise runit exits 1.
81
+ log.error("Cannot write --out-file %s: %s", args.out_file, e)
82
+ if status == 0:
83
+ status = 1
84
+ _write_stdout(output, log)
85
+ log.info("runit CLI finished.")
86
+ return status
87
+
88
+ def create_parser():
89
+ parser = argparse.ArgumentParser(
90
+ description="Run a command and report on its execution (time, resources, etc)."
91
+ )
92
+ parser.add_argument(
93
+ '--log-level',
94
+ default='INFO',
95
+ choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'],
96
+ help='Set the logging level (default: INFO)'
97
+ )
98
+ parser.add_argument(
99
+ '--out-file',
100
+ type=str,
101
+ default=None,
102
+ help='Also write the report (and charts) to this file. '
103
+ 'The program output and the report are still shown in the terminal.'
104
+ )
105
+ parser.add_argument(
106
+ '--strip-ansi',
107
+ action='store_true',
108
+ help='Remove ANSI escape codes from output (for plain text files).'
109
+ )
110
+ parser.add_argument(
111
+ '--plot',
112
+ action='store_true',
113
+ help='Show resource usage charts (requires plotext).'
114
+ )
115
+ parser.add_argument(
116
+ '--plot-width',
117
+ type=int,
118
+ default=80,
119
+ help='Set the plot width (default: 80)'
120
+ )
121
+ parser.add_argument(
122
+ '--plot-height',
123
+ type=int,
124
+ default=20,
125
+ help='Set the plot height (default: 20)'
126
+ )
127
+ parser.add_argument(
128
+ 'command',
129
+ nargs=argparse.REMAINDER,
130
+ help='The command and arguments to run.'
131
+ )
132
+ return parser
@@ -9,6 +9,24 @@ import sys
9
9
  log = logging.getLogger(__name__)
10
10
  _STREAM_CHUNK_SIZE = 4096
11
11
  _THREAD_JOIN_TIMEOUT_SECONDS = 5
12
+
13
+ def _binary_writer(target):
14
+ """Return a function that writes bytes to ``target`` and flushes it.
15
+
16
+ ``target`` is normally ``sys.stdout`` or ``sys.stderr``. A text stream
17
+ without a binary ``buffer`` (for example ``io.StringIO``) gets the bytes
18
+ decoded, so that forwarding never fails because of the stream type.
19
+ """
20
+ buffer = getattr(target, 'buffer', None)
21
+ if buffer is not None:
22
+ def write(chunk):
23
+ buffer.write(chunk)
24
+ buffer.flush()
25
+ else:
26
+ def write(chunk):
27
+ target.write(chunk.decode(errors='backslashreplace'))
28
+ target.flush()
29
+ return write
12
30
 
13
31
  def _start_proc(command: list):
14
32
  log.info("Starting process: %s", command)
@@ -29,6 +47,9 @@ def monitor_process(command):
29
47
  'memory_info': [],
30
48
  'children': [],
31
49
  'threads': [],
50
+ 'stdout': '',
51
+ 'stderr': '',
52
+ 'returncode': None,
32
53
  }
33
54
  proc = _start_proc(command)
34
55
  ps_proc = psutil.Process(proc.pid)
@@ -48,14 +69,32 @@ def monitor_process(command):
48
69
  if reader is None:
49
70
  # Fallback for file-like streams that do not implement read1().
50
71
  reader = stream.read
72
+ write = _binary_writer(target)
51
73
  try:
52
74
  while True:
53
75
  chunk = reader(_STREAM_CHUNK_SIZE)
54
76
  if not chunk:
55
77
  break
56
78
  chunks.append(chunk)
57
- target.buffer.write(chunk)
58
- target.flush()
79
+ if write is None:
80
+ continue
81
+ try:
82
+ write(chunk)
83
+ except (OSError, ValueError) as e:
84
+ # The destination is closed or full (for example EPIPE
85
+ # from `runit cmd | head`). Stop and close the pipe so
86
+ # the program sees a closed pipe, as without runit.
87
+ if isinstance(e, BrokenPipeError):
88
+ log.info("Output reader closed the pipe; stopped forwarding.")
89
+ else:
90
+ log.warning("Stream forwarding stopped due to write error: %s", e)
91
+ break
92
+ except Exception as e:
93
+ # Any other failure must not stop the reader: a program
94
+ # that cannot write blocks on a full pipe or is killed by
95
+ # a closed one. Keep capturing and stop forwarding.
96
+ log.warning("Stream forwarding stopped due to write error: %s", e)
97
+ write = None
59
98
  except (OSError, ValueError) as e:
60
99
  log.warning("Stream forwarding stopped due to read error: %s", e)
61
100
  finally:
@@ -92,7 +131,7 @@ def monitor_process(command):
92
131
  log.warning("Process ended or became inaccessible during monitoring.")
93
132
  break
94
133
  time.sleep(0.1)
95
- proc.wait()
134
+ stats['returncode'] = proc.wait()
96
135
  stdout_thread.join(timeout=_THREAD_JOIN_TIMEOUT_SECONDS)
97
136
  stderr_thread.join(timeout=_THREAD_JOIN_TIMEOUT_SECONDS)
98
137
  if stdout_thread.is_alive() or stderr_thread.is_alive():
@@ -107,6 +146,8 @@ def monitor_process(command):
107
146
 
108
147
  except Exception as e:
109
148
  log.warning("Exception during stat collection: %s", e)
149
+ if stats['returncode'] is None:
150
+ stats['returncode'] = proc.wait()
110
151
  stats['end_time'] = datetime.now()
111
152
  stats['duration'] = stats['end_time'] - stats['start_time']
112
153
  log.info("Process monitoring complete. Duration: %s", stats['duration'])
@@ -10,6 +10,13 @@ def plot_charts(stats, width=80, height=20):
10
10
  if plt is None:
11
11
  log.warning("plotext is not installed. Skipping plotting.")
12
12
  return
13
+ if not hasattr(plt, 'plot_size'):
14
+ # plotext 6 replaced the module-level API that runit uses.
15
+ log.warning(
16
+ "plotext %s is not supported (runit needs plotext>=5.3,<6). Skipping plotting.",
17
+ getattr(plt, '__version__', 'unknown'),
18
+ )
19
+ return
13
20
  if stats['check_times'] and stats['cpu_percent']:
14
21
  try:
15
22
  log.info("Plotting charts.")
@@ -1,4 +1,4 @@
1
- from .utils import extract_memory_rss, extract_num_threads, extract_num_children
1
+ from .utils import extract_memory_rss, extract_num_threads, extract_num_children, describe_exit
2
2
  import logging
3
3
 
4
4
  log = logging.getLogger(__name__)
@@ -14,6 +14,7 @@ def format_report(stats):
14
14
  "Start Time: {}".format(stats['start_time']),
15
15
  "End Time: {}".format(stats['end_time']),
16
16
  "Duration: {}".format(stats['duration']),
17
+ "Exit Code: {}".format(describe_exit(stats.get('returncode'))),
17
18
  "Max RSS (bytes): {}".format(max(rss_values) if rss_values else 'N/A'),
18
19
  "Max Threads: {}".format(max(num_threads) if num_threads else 'N/A'),
19
20
  "Max Children: {}".format(max(num_children) if num_children else 'N/A'),
@@ -1,5 +1,6 @@
1
1
  import re
2
2
  import logging
3
+ import signal
3
4
  from ast import literal_eval
4
5
 
5
6
  log = logging.getLogger(__name__)
@@ -9,6 +10,29 @@ def strip_ansi(text):
9
10
  ansi_escape = re.compile(r'\x1B\[[0-?]*[ -/]*[@-~]')
10
11
  return ansi_escape.sub('', text)
11
12
 
13
+ def exit_status(returncode):
14
+ """Map a subprocess return code to the exit status runit uses.
15
+
16
+ A program killed by signal N has a negative return code; shells report
17
+ that as 128 + N, so runit does the same.
18
+ """
19
+ if returncode is None:
20
+ return 1
21
+ if returncode < 0:
22
+ return 128 - returncode
23
+ return returncode
24
+
25
+ def describe_exit(returncode):
26
+ """Text for the report: runit's exit status, plus the signal if there was one."""
27
+ status = exit_status(returncode)
28
+ if returncode is None or returncode >= 0:
29
+ return str(status)
30
+ try:
31
+ name = signal.Signals(-returncode).name
32
+ except ValueError:
33
+ return "{} (signal {})".format(status, -returncode)
34
+ return "{} (signal {}, {})".format(status, -returncode, name)
35
+
12
36
  def extract_memory_rss(memory_info_list):
13
37
  log.debug("Extracting memory RSS values.")
14
38
  rss_values = []