profiler-runit 0.1.3.post1.dev1__tar.gz → 0.1.4.post1.dev9__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.
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/PKG-INFO +1 -1
- profiler_runit-0.1.4.post1.dev9/assets/runit-demo.gif +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/PKG-INFO +1 -1
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/cli.py +13 -1
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/monitor.py +78 -24
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/plotting.py +2 -1
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/test.py +39 -11
- profiler_runit-0.1.3.post1.dev1/assets/runit-demo.gif +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/.github/workflows/ci.yml +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/.github/workflows/release.yml +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/.github/workflows/testpypi.yml +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/.gitignore +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/LICENSE +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/README.md +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/benchmark.py +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/SOURCES.txt +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/dependency_links.txt +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/entry_points.txt +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/requires.txt +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/top_level.txt +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/pyproject.toml +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/requirements.txt +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/__init__.py +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/logging_config.py +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/report.py +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/utils.py +0 -0
- {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/setup.cfg +0 -0
|
Binary file
|
|
@@ -28,7 +28,7 @@ def run_cli():
|
|
|
28
28
|
stats = monitor_process(args.command)
|
|
29
29
|
report = format_report(stats)
|
|
30
30
|
if args.plot:
|
|
31
|
-
plot_charts(stats)
|
|
31
|
+
plot_charts(stats, args.plot_width, args.plot_height)
|
|
32
32
|
print(report)
|
|
33
33
|
if args.out_file:
|
|
34
34
|
sys.stdout = orig_stdout
|
|
@@ -69,6 +69,18 @@ def create_parser():
|
|
|
69
69
|
action='store_true',
|
|
70
70
|
help='Show resource usage charts (requires plotext).'
|
|
71
71
|
)
|
|
72
|
+
parser.add_argument(
|
|
73
|
+
'--plot-width',
|
|
74
|
+
type=int,
|
|
75
|
+
default=80,
|
|
76
|
+
help='Set the plot width (default: 80)'
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
'--plot-height',
|
|
80
|
+
type=int,
|
|
81
|
+
default=20,
|
|
82
|
+
help='Set the plot height (default: 20)'
|
|
83
|
+
)
|
|
72
84
|
parser.add_argument(
|
|
73
85
|
'command',
|
|
74
86
|
nargs=argparse.REMAINDER,
|
|
@@ -1,10 +1,14 @@
|
|
|
1
|
-
import subprocess
|
|
2
|
-
import psutil
|
|
3
|
-
from datetime import datetime
|
|
4
|
-
import time
|
|
5
|
-
import logging
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
import subprocess
|
|
2
|
+
import psutil
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
import time
|
|
5
|
+
import logging
|
|
6
|
+
import threading
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
log = logging.getLogger(__name__)
|
|
10
|
+
_STREAM_CHUNK_SIZE = 4096
|
|
11
|
+
_THREAD_JOIN_TIMEOUT_SECONDS = 5
|
|
8
12
|
|
|
9
13
|
def _start_proc(command: list):
|
|
10
14
|
log.info("Starting process: %s", command)
|
|
@@ -15,7 +19,7 @@ def _start_proc(command: list):
|
|
|
15
19
|
)
|
|
16
20
|
return proc
|
|
17
21
|
|
|
18
|
-
def monitor_process(command):
|
|
22
|
+
def monitor_process(command):
|
|
19
23
|
log.info("Monitoring process: %s", command)
|
|
20
24
|
stats = {
|
|
21
25
|
'start_time': datetime.now(),
|
|
@@ -26,13 +30,53 @@ def monitor_process(command):
|
|
|
26
30
|
'children': [],
|
|
27
31
|
'threads': [],
|
|
28
32
|
}
|
|
29
|
-
proc = _start_proc(command)
|
|
30
|
-
ps_proc = psutil.Process(proc.pid)
|
|
31
|
-
log.debug("Subprocess started with PID %s", ps_proc.pid)
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
33
|
+
proc = _start_proc(command)
|
|
34
|
+
ps_proc = psutil.Process(proc.pid)
|
|
35
|
+
log.debug("Subprocess started with PID %s", ps_proc.pid)
|
|
36
|
+
|
|
37
|
+
stdout_chunks = []
|
|
38
|
+
stderr_chunks = []
|
|
39
|
+
|
|
40
|
+
def _safe_close(stream):
|
|
41
|
+
try:
|
|
42
|
+
stream.close()
|
|
43
|
+
except (OSError, ValueError):
|
|
44
|
+
pass
|
|
45
|
+
|
|
46
|
+
def _forward_and_capture(stream, target, chunks):
|
|
47
|
+
reader = getattr(stream, 'read1', None)
|
|
48
|
+
if reader is None:
|
|
49
|
+
# Fallback for file-like streams that do not implement read1().
|
|
50
|
+
reader = stream.read
|
|
51
|
+
try:
|
|
52
|
+
while True:
|
|
53
|
+
chunk = reader(_STREAM_CHUNK_SIZE)
|
|
54
|
+
if not chunk:
|
|
55
|
+
break
|
|
56
|
+
chunks.append(chunk)
|
|
57
|
+
target.buffer.write(chunk)
|
|
58
|
+
target.flush()
|
|
59
|
+
except (OSError, ValueError) as e:
|
|
60
|
+
log.warning("Stream forwarding stopped due to read error: %s", e)
|
|
61
|
+
finally:
|
|
62
|
+
_safe_close(stream)
|
|
63
|
+
|
|
64
|
+
stdout_thread = threading.Thread(
|
|
65
|
+
target=_forward_and_capture,
|
|
66
|
+
args=(proc.stdout, sys.stdout, stdout_chunks),
|
|
67
|
+
daemon=True,
|
|
68
|
+
)
|
|
69
|
+
stderr_thread = threading.Thread(
|
|
70
|
+
target=_forward_and_capture,
|
|
71
|
+
args=(proc.stderr, sys.stderr, stderr_chunks),
|
|
72
|
+
daemon=True,
|
|
73
|
+
)
|
|
74
|
+
stdout_thread.start()
|
|
75
|
+
stderr_thread.start()
|
|
76
|
+
|
|
77
|
+
stats['pid'] = ps_proc.pid
|
|
78
|
+
try:
|
|
79
|
+
while ps_proc.is_running() and not ps_proc.status() == psutil.STATUS_ZOMBIE:
|
|
36
80
|
try:
|
|
37
81
|
cpu_percent = ps_proc.cpu_percent(interval=0.1)
|
|
38
82
|
memory_info = ps_proc.memory_info()
|
|
@@ -44,15 +88,25 @@ def monitor_process(command):
|
|
|
44
88
|
stats['threads'].append(threads)
|
|
45
89
|
stats['check_times'].append(datetime.now())
|
|
46
90
|
log.debug("Sampled stats at %s, CPU: %s%%, Memory: %s bytes, Children: %s, Threads: %s", stats['check_times'][-1], cpu_percent, memory_info.rss, len(children), len(threads))
|
|
47
|
-
except (psutil.NoSuchProcess, psutil.ZombieProcess, psutil.AccessDenied):
|
|
48
|
-
log.warning("Process ended or became inaccessible during monitoring.")
|
|
49
|
-
break
|
|
50
|
-
time.sleep(0.1)
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
91
|
+
except (psutil.NoSuchProcess, psutil.ZombieProcess, psutil.AccessDenied):
|
|
92
|
+
log.warning("Process ended or became inaccessible during monitoring.")
|
|
93
|
+
break
|
|
94
|
+
time.sleep(0.1)
|
|
95
|
+
proc.wait()
|
|
96
|
+
stdout_thread.join(timeout=_THREAD_JOIN_TIMEOUT_SECONDS)
|
|
97
|
+
stderr_thread.join(timeout=_THREAD_JOIN_TIMEOUT_SECONDS)
|
|
98
|
+
if stdout_thread.is_alive() or stderr_thread.is_alive():
|
|
99
|
+
log.warning("Timed out waiting for stream forwarding threads to finish.")
|
|
100
|
+
for stream in (proc.stdout, proc.stderr):
|
|
101
|
+
_safe_close(stream)
|
|
102
|
+
stdout_thread.join(timeout=1)
|
|
103
|
+
stderr_thread.join(timeout=1)
|
|
104
|
+
# Preserve undecodable bytes in a visible form without dropping content.
|
|
105
|
+
stats['stdout'] = b''.join(stdout_chunks).decode(errors='backslashreplace')
|
|
106
|
+
stats['stderr'] = b''.join(stderr_chunks).decode(errors='backslashreplace')
|
|
107
|
+
|
|
108
|
+
except Exception as e:
|
|
109
|
+
log.warning("Exception during stat collection: %s", e)
|
|
56
110
|
stats['end_time'] = datetime.now()
|
|
57
111
|
stats['duration'] = stats['end_time'] - stats['start_time']
|
|
58
112
|
log.info("Process monitoring complete. Duration: %s", stats['duration'])
|
|
@@ -6,7 +6,7 @@ try:
|
|
|
6
6
|
except ImportError:
|
|
7
7
|
plt = None
|
|
8
8
|
|
|
9
|
-
def plot_charts(stats):
|
|
9
|
+
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
|
|
@@ -19,6 +19,7 @@ def plot_charts(stats):
|
|
|
19
19
|
from .utils import extract_memory_rss
|
|
20
20
|
rss = extract_memory_rss(stats['memory_info'])
|
|
21
21
|
plt.clear_figure()
|
|
22
|
+
plt.plot_size(width=width, height=height)
|
|
22
23
|
plt.subplots(2, 1)
|
|
23
24
|
plt.subplot(1)
|
|
24
25
|
plt.title("CPU % over time")
|
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
import subprocess
|
|
2
2
|
import unittest
|
|
3
|
-
import tempfile
|
|
4
|
-
import os
|
|
5
|
-
import re
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
import tempfile
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
|
|
8
|
+
class TestRunitCLI(unittest.TestCase):
|
|
9
|
+
"""Integration tests for the runit CLI command."""
|
|
9
10
|
|
|
10
11
|
def test_nonexistent_command(self):
|
|
11
12
|
"""Fails gracefully when a nonexistent command is run."""
|
|
@@ -54,9 +55,9 @@ class TestRunitCLI(unittest.TestCase):
|
|
|
54
55
|
self.assertEqual(result.returncode, 0)
|
|
55
56
|
self.assertTrue('usage:' in result.stdout.lower() or 'usage:' in result.stderr.lower())
|
|
56
57
|
|
|
57
|
-
def test_command_runs_and_reports(self):
|
|
58
|
-
"""Runs a command and checks for key report fields."""
|
|
59
|
-
result = subprocess.run(['runit', 'python', '-c', 'print(123)'], capture_output=True, text=True)
|
|
58
|
+
def test_command_runs_and_reports(self):
|
|
59
|
+
"""Runs a command and checks for key report fields."""
|
|
60
|
+
result = subprocess.run(['runit', 'python', '-c', 'print(123)'], capture_output=True, text=True)
|
|
60
61
|
out = result.stdout
|
|
61
62
|
self.assertEqual(result.returncode, 0)
|
|
62
63
|
self.assertIn('Command:', out)
|
|
@@ -64,8 +65,35 @@ class TestRunitCLI(unittest.TestCase):
|
|
|
64
65
|
self.assertIn('End Time:', out)
|
|
65
66
|
self.assertIn('Max RSS (bytes):', out)
|
|
66
67
|
self.assertIn('Max Threads:', out)
|
|
67
|
-
self.assertIn('Max Children:', out)
|
|
68
|
-
self.assertIn('Samples:', out)
|
|
68
|
+
self.assertIn('Max Children:', out)
|
|
69
|
+
self.assertIn('Samples:', out)
|
|
70
|
+
|
|
71
|
+
def test_streams_output_in_real_time(self):
|
|
72
|
+
"""Streams child output before command completion."""
|
|
73
|
+
max_first_output_delay_seconds = 1.5
|
|
74
|
+
code = 'import time; print("stream-now", flush=True); time.sleep(2); print("done", flush=True)'
|
|
75
|
+
proc = subprocess.Popen(
|
|
76
|
+
['runit', 'python', '-c', code],
|
|
77
|
+
stdout=subprocess.PIPE,
|
|
78
|
+
stderr=subprocess.PIPE,
|
|
79
|
+
text=True,
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
start = time.monotonic()
|
|
83
|
+
first_line = proc.stdout.readline().strip()
|
|
84
|
+
elapsed = time.monotonic() - start
|
|
85
|
+
|
|
86
|
+
self.assertEqual(first_line, 'stream-now')
|
|
87
|
+
self.assertLess(elapsed, max_first_output_delay_seconds)
|
|
88
|
+
|
|
89
|
+
stdout, stderr = proc.communicate(timeout=10)
|
|
90
|
+
self.assertEqual(proc.returncode, 0, msg=stderr)
|
|
91
|
+
self.assertIn('Command:', stdout)
|
|
92
|
+
self.assertIn('End Time:', stdout)
|
|
93
|
+
finally:
|
|
94
|
+
if proc.poll() is None:
|
|
95
|
+
proc.terminate()
|
|
96
|
+
proc.wait(timeout=5)
|
|
69
97
|
|
|
70
98
|
def test_out_file_and_strip_ansi(self):
|
|
71
99
|
"""Checks --out-file and --strip-ansi produce plain text output."""
|
|
Binary file
|
{profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/.github/workflows/ci.yml
RENAMED
|
File without changes
|
{profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/.github/workflows/release.yml
RENAMED
|
File without changes
|
{profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/.github/workflows/testpypi.yml
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|