From 54452f8a267d4330ce4c8156cf158a349f81c697 Mon Sep 17 00:00:00 2001 From: Sofie Date: Tue, 9 Dec 2025 14:47:35 +0100 Subject: [PATCH 1/4] feat: RunInfo use subprocess.Popen over pid argument --- NEWS.rst | 4 +- src/dummynet/process_monitor.py | 6 +-- src/dummynet/run_info.py | 70 ++++++++++++++++++++------------- 3 files changed, 48 insertions(+), 32 deletions(-) diff --git a/NEWS.rst b/NEWS.rst index dbb7182..bd66add 100644 --- a/NEWS.rst +++ b/NEWS.rst @@ -5,7 +5,9 @@ every change, see the Git log. Latest ------ -* tbd +* Major: `RunInfo` now takes a `subprocess.Popen` object instead of a `pid`. + `RunInfo.pid` is still accessible and points to `RunInfo.popen.pid` for + backwards compatability. 10.0.0 ------ diff --git a/src/dummynet/process_monitor.py b/src/dummynet/process_monitor.py index 4017876..1ea0e93 100644 --- a/src/dummynet/process_monitor.py +++ b/src/dummynet/process_monitor.py @@ -210,16 +210,14 @@ def __init__( # Pipe possible sudo password to the process if sudo and (cached_sudo_password is not None): - assert cached_sudo_password.endswith( - "\n" - ) # Ensure the password ends with a newline as otherwise sudo will hang + assert cached_sudo_password.endswith("\n") self.popen.stdin.write(cached_sudo_password) self.popen.stdin.flush() self.info = run_info.RunInfo( cmd=cmd, cwd=cwd, - pid=self.popen.pid, + popen=self.popen, stdout="", stderr="", returncode=None, diff --git a/src/dummynet/run_info.py b/src/dummynet/run_info.py index da9e0c9..c903249 100644 --- a/src/dummynet/run_info.py +++ b/src/dummynet/run_info.py @@ -1,4 +1,5 @@ import fnmatch +import subprocess from typing import Callable, Optional from . import errors @@ -27,13 +28,22 @@ class RunInfo: """ def __init__( - self, cmd, cwd, pid, stdout, stderr, returncode, is_async, is_daemon, timeout + self, + cmd, + cwd, + popen, + stdout, + stderr, + returncode, + is_async, + is_daemon, + timeout, ): """Create a new object :param cmd: The command that was executed :param cwd: Current working directory i.e. path where the command was executed - :param pid: The process ID of the command + :param popen: The subprocess instance for the command :param stdout: The standard output stream generated by the command :param stderr: The standard error stream generated by the command :param returncode: The return code set after invoking the command @@ -44,7 +54,7 @@ def __init__( self.cmd: str | list[str] = cmd self.cwd: Optional[str] = cwd - self.pid: int = pid + self.popen: subprocess.Popen = popen self.stdout: str = stdout self.stderr: str = stderr self.returncode: Optional[int] = returncode @@ -54,6 +64,10 @@ def __init__( self.stderr_callback: Optional[Callable] = None self.timeout: Optional[int | float] = timeout + @property + def pid(self): + return self.popen.pid + def match(self, stdout=None, stderr=None): """Matches the lines in the output with the pattern. The match pattern can contain basic wildcards, see @@ -108,29 +122,31 @@ def _match(self, pattern, stream_name, output): pattern=pattern, stream_name=stream_name, output=output ) - def __str__(self): - """Print the RunInfo object as a string""" - run_string = ( - "RunInfo\n" - "command: {command}\n" - "cwd: {cwd}\n" - "pid: {pid}\n" - "returncode: {returncode}\n" - "stdout: \n{stdout}" - "stderr: \n{stderr}" - "is_async: {is_async}\n" - "is_daemon: {is_daemon}\n" - "timeout: {timeout}\n" + def __repr__(self): + """Return a detailed representation of the object for debugging""" + modes = [] + + if self.is_async: + modes.append("async") + if self.is_daemon: + modes.append("daemon") + + return ( + f"<{self.__class__.__name__}:" + + f" cmd: {self.cmd!r}" + + (f" cwd: {self.cwd!r}" if self.cwd is not None else "") + + ( + f" returncode: {self.returncode!r}" + if self.returncode is not None + else "" + ) + + (f" timeout: {self.timeout!r} " if self.timeout is not None else "") + + (f" stdout: {len(self.stdout)} chars" if self.stdout else "") + + (f" stderr: {len(self.stderr)} chars" if self.stderr else "") + + (f" modes: {modes!r}" if modes else "") + + ">" ) - return run_string.format( - command=self.cmd, - cwd=self.cwd, - pid=self.pid, - returncode=self.returncode, - stdout=self.stdout, - stderr=self.stderr, - is_async=self.is_async, - is_daemon=self.is_daemon, - timeout=self.timeout, - ) + def __str__(self): + """Print the RunInfo object as a string""" + return self.__repr__() From 3ab1aac9eebf3f54d95ed6d54ea9a0bda3ec1387 Mon Sep 17 00:00:00 2001 From: Sofie Date: Tue, 9 Dec 2025 15:15:21 +0100 Subject: [PATCH 2/4] feat: add rusage support --- .github/workflows/python-waf.yml | 2 +- pyproject.toml | 1 + src/dummynet/process_monitor.py | 3 ++- src/dummynet/run_info.py | 7 ++++++- test/test_dummynet.py | 17 +++++++++++++++++ 5 files changed, 27 insertions(+), 3 deletions(-) diff --git a/.github/workflows/python-waf.yml b/.github/workflows/python-waf.yml index ad0d85d..d57353b 100644 --- a/.github/workflows/python-waf.yml +++ b/.github/workflows/python-waf.yml @@ -28,7 +28,7 @@ jobs: --cgroupns host steps: - name: Install dependencies - run: apt update && apt install -y sudo iproute2 iputils-ping + run: apt update && apt install -y sudo iproute2 iputils-ping stress - name: Checkout uses: actions/checkout@v4 - name: Configure diff --git a/pyproject.toml b/pyproject.toml index 7c8a743..b83abb4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ classifiers = [ ] dependencies = [ "psutil==7.0.0", + "subprocess4==0.1.1", ] [project.urls] diff --git a/src/dummynet/process_monitor.py b/src/dummynet/process_monitor.py index 1ea0e93..37e3f5b 100644 --- a/src/dummynet/process_monitor.py +++ b/src/dummynet/process_monitor.py @@ -1,11 +1,12 @@ import select import logging import os -import subprocess import signal import getpass import time +import subprocess4 as subprocess + from functools import lru_cache from typing import Optional diff --git a/src/dummynet/run_info.py b/src/dummynet/run_info.py index c903249..ac85081 100644 --- a/src/dummynet/run_info.py +++ b/src/dummynet/run_info.py @@ -1,5 +1,6 @@ +import subprocess4 as subprocess + import fnmatch -import subprocess from typing import Callable, Optional from . import errors @@ -68,6 +69,10 @@ def __init__( def pid(self): return self.popen.pid + @property + def rusage(self): + return self.popen.rusage + def match(self, stdout=None, stderr=None): """Matches the lines in the output with the pattern. The match pattern can contain basic wildcards, see diff --git a/test/test_dummynet.py b/test/test_dummynet.py index e869e3b..148d3ad 100644 --- a/test/test_dummynet.py +++ b/test/test_dummynet.py @@ -697,3 +697,20 @@ def test_stop_process_async_kill(process_monitor: ProcessMonitor): daemon = process_monitor.run_process_async("sleep 10", sudo=False, daemon=False) process_monitor.stop_process_async(daemon) assert signal.Signals(-daemon.returncode).name == "SIGTERM" # type: ignore + + +def test_cpu_usage_statistics(process_monitor: ProcessMonitor): + def run_task_async(task, sudo, utime): + process = process_monitor.run_process_async(task, sudo=sudo) + + while process_monitor.keep_running(): + pass + + # Allow a 5% margin of the given utime value + margin = utime * 0.05 + assert (utime - margin) <= process.rusage.ru_utime <= (utime + margin) + + run_task_async("stress --cpu 1 --timeout 1", sudo=False, utime=1.0) + run_task_async(["stress", "--cpu", "2", "--timeout", "1"], sudo=False, utime=2.0) + run_task_async("stress --cpu 1 --timeout 2", sudo=True, utime=2.0) + run_task_async(["stress", "--cpu", "2", "--timeout", "2"], sudo=True, utime=4.0) From ad14c0565e40fcd369542e94ca38488c045e780b Mon Sep 17 00:00:00 2001 From: Sofie Date: Tue, 9 Dec 2025 15:22:59 +0100 Subject: [PATCH 3/4] fix: clean up nagging argument --- test/test_dummynet.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/test_dummynet.py b/test/test_dummynet.py index 148d3ad..2c6135f 100644 --- a/test/test_dummynet.py +++ b/test/test_dummynet.py @@ -708,7 +708,8 @@ def run_task_async(task, sudo, utime): # Allow a 5% margin of the given utime value margin = utime * 0.05 - assert (utime - margin) <= process.rusage.ru_utime <= (utime + margin) + actual_utime = process.rusage.ru_utime # type: ignore + assert (utime - margin) < actual_utime < (utime + margin) run_task_async("stress --cpu 1 --timeout 1", sudo=False, utime=1.0) run_task_async(["stress", "--cpu", "2", "--timeout", "1"], sudo=False, utime=2.0) From 30a72d2253597bdfbba7b36004396316595ff2e4 Mon Sep 17 00:00:00 2001 From: Sofie Date: Tue, 9 Dec 2025 15:40:16 +0100 Subject: [PATCH 4/4] fix: failing tests --- src/dummynet/process_monitor.py | 5 +++-- src/dummynet/run_info.py | 3 +-- test/test_dummynet.py | 11 ++++++++--- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/dummynet/process_monitor.py b/src/dummynet/process_monitor.py index 37e3f5b..3537d97 100644 --- a/src/dummynet/process_monitor.py +++ b/src/dummynet/process_monitor.py @@ -5,7 +5,8 @@ import getpass import time -import subprocess4 as subprocess +from subprocess4 import Popen as Popen4 +import subprocess from functools import lru_cache from typing import Optional @@ -193,7 +194,7 @@ def __init__( # Run inside wrapped /bin/sh environment when cmd is string. shell = isinstance(cmd, str) - self.popen = subprocess.Popen( + self.popen = Popen4( cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, diff --git a/src/dummynet/run_info.py b/src/dummynet/run_info.py index ac85081..2479342 100644 --- a/src/dummynet/run_info.py +++ b/src/dummynet/run_info.py @@ -1,6 +1,5 @@ -import subprocess4 as subprocess - import fnmatch +import subprocess from typing import Callable, Optional from . import errors diff --git a/test/test_dummynet.py b/test/test_dummynet.py index 2c6135f..08f5a38 100644 --- a/test/test_dummynet.py +++ b/test/test_dummynet.py @@ -647,12 +647,17 @@ def test_stop_process_async(process_monitor: ProcessMonitor): process_monitor.stop_process_async(process) assert process.returncode is not None + class FakePopen: + @property + def pid(self): + return None + fake_process = RunInfo( cmd="echo", cwd=None, - pid=None, - stdout=None, - stderr=None, + popen=FakePopen(), + stdout="", + stderr="", returncode=None, is_async=False, is_daemon=False,