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.
Files changed (27) hide show
  1. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/PKG-INFO +1 -1
  2. profiler_runit-0.1.4.post1.dev9/assets/runit-demo.gif +0 -0
  3. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/PKG-INFO +1 -1
  4. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/cli.py +13 -1
  5. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/monitor.py +78 -24
  6. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/plotting.py +2 -1
  7. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/test.py +39 -11
  8. profiler_runit-0.1.3.post1.dev1/assets/runit-demo.gif +0 -0
  9. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/.github/workflows/ci.yml +0 -0
  10. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/.github/workflows/release.yml +0 -0
  11. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/.github/workflows/testpypi.yml +0 -0
  12. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/.gitignore +0 -0
  13. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/LICENSE +0 -0
  14. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/README.md +0 -0
  15. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/benchmark.py +0 -0
  16. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/SOURCES.txt +0 -0
  17. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/dependency_links.txt +0 -0
  18. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/entry_points.txt +0 -0
  19. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/requires.txt +0 -0
  20. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/profiler_runit.egg-info/top_level.txt +0 -0
  21. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/pyproject.toml +0 -0
  22. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/requirements.txt +0 -0
  23. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/__init__.py +0 -0
  24. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/logging_config.py +0 -0
  25. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/report.py +0 -0
  26. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/runit/utils.py +0 -0
  27. {profiler_runit-0.1.3.post1.dev1 → profiler_runit-0.1.4.post1.dev9}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: profiler-runit
3
- Version: 0.1.3.post1.dev1
3
+ Version: 0.1.4.post1.dev9
4
4
  Summary: Execute and profile cli commands
5
5
  Author-email: NotesOfCliff <notesofcliff@gmail.com>
6
6
  License-Expression: GPL-3.0-only
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: profiler-runit
3
- Version: 0.1.3.post1.dev1
3
+ Version: 0.1.4.post1.dev9
4
4
  Summary: Execute and profile cli commands
5
5
  Author-email: NotesOfCliff <notesofcliff@gmail.com>
6
6
  License-Expression: GPL-3.0-only
@@ -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
- log = logging.getLogger(__name__)
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
- stats['pid'] = ps_proc.pid
34
- try:
35
- while ps_proc.is_running() and not ps_proc.status() == psutil.STATUS_ZOMBIE:
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
- stats['stdout'] = proc.stdout.read().decode()
52
- stats['stderr'] = proc.stderr.read().decode()
53
-
54
- except Exception as e:
55
- log.warning("Exception during stat collection: %s", e)
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
- class TestRunitCLI(unittest.TestCase):
8
- """Integration tests for the runit CLI command."""
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."""