Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 5 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
matrix:
os:
# - macos-12
# - macos-latest
- macos-latest # arm64
# - windows-latest
- ubuntu-latest
python-version:
Expand Down Expand Up @@ -69,7 +69,10 @@ jobs:
run: |
python -m pip install --upgrade pip wheel
python -m pip install --upgrade --upgrade-strategy=eager tox
sudo apt-get install -y libjpeg-dev

- if: matrix.os == 'ubuntu-latest'
name: Install libjpeg on Ubuntu
run: sudo apt-get install -y libjpeg-dev

- name: Install uv
if: matrix.toxenv == 'oldestdeps'
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ coverage.*
build/
dist/
venvs/
.DS_Store

# Produced by versioningit
src/con_duct/_version.py
Expand Down
5 changes: 5 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,8 @@ build-backend = "setuptools.build_meta"

[tool.versioningit.write]
file = "src/con_duct/_version.py"

[tool.pytest.ini_options]
markers = [
"flaky: mark a test as being unreliable"
]
2 changes: 2 additions & 0 deletions setup.cfg
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ classifiers =
Intended Audience :: Science/Research
Intended Audience :: System Administrators
Topic :: System :: Systems Administration
Operating System :: Unix
Operating System :: MacOS

project_urls =
Source Code = https://github.com/con/duct/
Expand Down
189 changes: 154 additions & 35 deletions src/con_duct/duct_main.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
#!/usr/bin/env python3
from __future__ import annotations
from collections import Counter
from collections import Counter, deque
from collections.abc import Iterable, Iterator
from dataclasses import asdict, dataclass, field
from datetime import datetime
Expand All @@ -10,6 +10,7 @@
import logging
import math
import os
import platform
import re
import shutil
import signal
Expand All @@ -20,11 +21,40 @@
import threading
import time
from types import FrameType
from typing import IO, Any, Optional, TextIO
from typing import IO, Any, Callable, Optional, TextIO
import warnings

__version__ = version("con-duct")
__schema_version__ = "0.2.2"

_true_set = {"yes", "true", "t", "y", "1"}
_false_set = {"no", "false", "f", "n", "0"}


def _str2bool(value: str | bool | None) -> bool | None:
if value is None:
return False
if isinstance(value, bool):
return value

val_lower = value.lower()
if val_lower in _true_set:
return True
elif val_lower in _false_set:
return False
else:
raise ValueError(f"Cannot interpret '{value}' as boolean.")


is_mac_intel = sys.platform == "darwin" and os.uname().machine == "x86_64"
if is_mac_intel and not _str2bool(value=os.getenv("DUCT_IGNORE_INTEL_WARNING")):
message = (
"Detected system macOS running on intel architecture - "
"duct may experience issues with sampling and signal handling.\n\n"
"Set the environment variable `DUCT_IGNORE_INTEL_WARNING` to suppress this warning.\n"
)
warnings.warn(message=message, stacklevel=2)
SYSTEM = platform.system()

lgr = logging.getLogger("con-duct")
DEFAULT_LOG_LEVEL = os.environ.get("DUCT_LOG_LEVEL", "INFO").upper()
Expand Down Expand Up @@ -321,6 +351,126 @@ def for_json(self) -> dict[str, Any]:
return d


def _get_sample_linux(session_id: int) -> Sample:
sample = Sample()

ps_command = [
"ps",
"-w",
"-s",
str(session_id),
"-o",
"pid,pcpu,pmem,rss,vsz,etime,stat,cmd",
]
output = subprocess.check_output(ps_command, text=True)

for line in output.splitlines()[1:]:
if not line:
continue

pid, pcpu, pmem, rss_kib, vsz_kib, etime, stat, cmd = line.split(maxsplit=7)

sample.add_pid(
pid=int(pid),
stats=ProcessStats(
pcpu=float(pcpu),
pmem=float(pmem),
rss=int(rss_kib) * 1024,
vsz=int(vsz_kib) * 1024,
timestamp=datetime.now().astimezone().isoformat(),
etime=etime,
stat=Counter([stat]),
cmd=cmd,
),
)
sample.averages = Averages.from_sample(sample=sample)
return sample


def _try_to_get_sid(pid: int) -> int:
"""
It is possible that the `pid` returned by the top `ps` call no longer exists at time of `getsid` request.
"""
try:
return os.getsid(pid)
except ProcessLookupError as exc:
lgr.debug(f"Error fetching session ID for PID {pid}: {str(exc)}")
return -1


def _get_ps_lines_mac() -> list[str]:
ps_command = [
"ps",
"-ax",
"-o",
"pid,pcpu,pmem,rss,vsz,etime,stat,args",
Comment thread
asmacdo marked this conversation as resolved.
]
output = subprocess.check_output(ps_command, text=True)

lines = [line for line in output.splitlines()[1:] if line]
return lines


def _add_pid_to_sample_from_line_mac(
line: str, pid_to_matching_sid: dict[int, int], sample: Sample
) -> None:
pid, pcpu, pmem, rss_kb, vsz_kb, etime, stat, cmd = line.split(maxsplit=7)

if pid_to_matching_sid.get(int(pid)) is not None:
sample.add_pid(
pid=int(pid),
stats=ProcessStats(
pcpu=float(pcpu),
pmem=float(pmem),
rss=int(rss_kb) * 1024,
vsz=int(vsz_kb) * 1024,
timestamp=datetime.now().astimezone().isoformat(),
etime=etime,
stat=Counter([stat]),
cmd=cmd,
),
)


def _get_sample_mac(session_id: int) -> Optional[Sample]:
sample = Sample()

lines = _get_ps_lines_mac()
pid_to_matching_sid = {
pid: sid
for line in lines
if (sid := _try_to_get_sid(pid=(pid := int(line.split(maxsplit=1)[0]))))
== session_id
}

if not pid_to_matching_sid:
Comment thread
CodyCBakerPhD marked this conversation as resolved.
lgr.debug(f"No processes found for session ID {session_id}.")
return None

# collections.dequeue with maxlen=0 is used to approximate the
# performance of list comprehension (superior to basic for-loop)
# and also does not store `None` (or other) return values
deque(
Comment thread
CodyCBakerPhD marked this conversation as resolved.
Comment thread
CodyCBakerPhD marked this conversation as resolved.
(
_add_pid_to_sample_from_line_mac( # type: ignore[func-returns-value]
line=line, pid_to_matching_sid=pid_to_matching_sid, sample=sample
)
for line in lines
),
maxlen=0,
)

sample.averages = Averages.from_sample(sample=sample)
return sample


_get_sample_per_system = {
"Linux": _get_sample_linux,
"Darwin": _get_sample_mac,
}
_get_sample: Callable[[int], Optional[Sample]] = _get_sample_per_system[SYSTEM] # type: ignore[assignment]


class Report:
"""Top level report"""

Expand Down Expand Up @@ -431,44 +581,13 @@ def get_system_info(self) -> None:

def collect_sample(self) -> Optional[Sample]:
assert self.session_id is not None
sample = Sample()
try:
output = subprocess.check_output(
[
"ps",
"-w",
"-s",
str(self.session_id),
"-o",
"pid,pcpu,pmem,rss,vsz,etime,stat,cmd",
],
text=True,
)
for line in output.splitlines()[1:]:
if line:
pid, pcpu, pmem, rss_kib, vsz_kib, etime, stat, cmd = line.split(
maxsplit=7,
)
sample.add_pid(
int(pid),
ProcessStats(
pcpu=float(pcpu),
pmem=float(pmem),
rss=int(rss_kib) * 1024,
vsz=int(vsz_kib) * 1024,
timestamp=datetime.now().astimezone().isoformat(),
etime=etime,
stat=Counter([stat]),
cmd=cmd,
),
)
sample = _get_sample(self.session_id)
return sample
except subprocess.CalledProcessError as exc: # when session_id has no processes
lgr.debug("Error collecting sample: %s", str(exc))
return None

sample.averages = Averages.from_sample(sample)
return sample

def update_from_sample(self, sample: Sample) -> None:
self.full_run_stats = self.full_run_stats.aggregate(sample)
if self.current_sample is None:
Expand Down
2 changes: 2 additions & 0 deletions test/duct_main/test_e2e.py
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
from __future__ import annotations
import json
from pathlib import Path
import platform
import subprocess
import time
import pytest
from con_duct.duct_main import SUFFIXES

SYSTEM = platform.system()
TEST_SCRIPT_DIR = Path(__file__).parent.parent / "data"


Expand Down
7 changes: 6 additions & 1 deletion test/test_cli.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import argparse
import os
import platform
import re
import subprocess
from typing import Any
Expand All @@ -10,6 +11,8 @@
from con_duct import cli
from con_duct.cli import _create_run_parser

SYSTEM = platform.system()


class TestSuiteHelpers(unittest.TestCase):

Expand Down Expand Up @@ -97,6 +100,7 @@ def test_con_duct_version() -> None:
assert re.match(r"con-duct \d+\.\d+\.\d+", output_str)


@pytest.mark.skipif(SYSTEM != "Linux", reason="Test specific to Linux behavior")
def test_cmd_help() -> None:
out = subprocess.check_output(["duct", "ps", "--help"])
assert "ps [options]" in str(out)
Expand Down Expand Up @@ -131,7 +135,8 @@ def test_duct_missing_cmd() -> None:
assert "error: the following arguments are required: command" in str(e.stdout)


def test_abreviation_disabled() -> None:
@pytest.mark.skipif(SYSTEM != "Linux", reason="Test specific to Linux behavior")
def test_abbreviation_disabled() -> None:
"""
If abbreviation is enabled, options passed to command (not duct) are still
filtered through the argparse and causes problems.
Expand Down
Loading