mirror of
https://github.com/python/cpython.git
synced 2025-12-08 06:10:17 +00:00
gh-138122: Refactor the CLI of profiling.sampling into subcommands (#141813)
This commit is contained in:
parent
425f24e4fa
commit
3eec46d3c3
15 changed files with 1460 additions and 1440 deletions
|
|
@ -347,81 +347,6 @@ The statistical profiler produces output similar to deterministic profilers but
|
||||||
|
|
||||||
.. _profile-cli:
|
.. _profile-cli:
|
||||||
|
|
||||||
:mod:`!profiling.sampling` Module Reference
|
|
||||||
=======================================================
|
|
||||||
|
|
||||||
.. module:: profiling.sampling
|
|
||||||
:synopsis: Python statistical profiler.
|
|
||||||
|
|
||||||
This section documents the programmatic interface for the :mod:`!profiling.sampling` module.
|
|
||||||
For command-line usage, see :ref:`sampling-profiler-cli`. For conceptual information
|
|
||||||
about statistical profiling, see :ref:`statistical-profiling`
|
|
||||||
|
|
||||||
.. function:: sample(pid, *, sort=2, sample_interval_usec=100, duration_sec=10, filename=None, all_threads=False, limit=None, show_summary=True, output_format="pstats", realtime_stats=False, native=False, gc=True)
|
|
||||||
|
|
||||||
Sample a Python process and generate profiling data.
|
|
||||||
|
|
||||||
This is the main entry point for statistical profiling. It creates a
|
|
||||||
:class:`SampleProfiler`, collects stack traces from the target process, and
|
|
||||||
outputs the results in the specified format.
|
|
||||||
|
|
||||||
:param int pid: Process ID of the target Python process
|
|
||||||
:param int sort: Sort order for pstats output (default: 2 for cumulative time)
|
|
||||||
:param int sample_interval_usec: Sampling interval in microseconds (default: 100)
|
|
||||||
:param int duration_sec: Duration to sample in seconds (default: 10)
|
|
||||||
:param str filename: Output filename (None for stdout/default naming)
|
|
||||||
:param bool all_threads: Whether to sample all threads (default: False)
|
|
||||||
:param int limit: Maximum number of functions to display (default: None)
|
|
||||||
:param bool show_summary: Whether to show summary statistics (default: True)
|
|
||||||
:param str output_format: Output format - 'pstats' or 'collapsed' (default: 'pstats')
|
|
||||||
:param bool realtime_stats: Whether to display real-time statistics (default: False)
|
|
||||||
:param bool native: Whether to include ``<native>`` frames (default: False)
|
|
||||||
:param bool gc: Whether to include ``<GC>`` frames (default: True)
|
|
||||||
|
|
||||||
:raises ValueError: If output_format is not 'pstats' or 'collapsed'
|
|
||||||
|
|
||||||
Examples::
|
|
||||||
|
|
||||||
# Basic usage - profile process 1234 for 10 seconds
|
|
||||||
import profiling.sampling
|
|
||||||
profiling.sampling.sample(1234)
|
|
||||||
|
|
||||||
# Profile with custom settings
|
|
||||||
profiling.sampling.sample(1234, duration_sec=30, sample_interval_usec=50, all_threads=True)
|
|
||||||
|
|
||||||
# Generate collapsed stack traces for flamegraph.pl
|
|
||||||
profiling.sampling.sample(1234, output_format='collapsed', filename='profile.collapsed')
|
|
||||||
|
|
||||||
.. class:: SampleProfiler(pid, sample_interval_usec, all_threads)
|
|
||||||
|
|
||||||
Low-level API for the statistical profiler.
|
|
||||||
|
|
||||||
This profiler uses periodic stack sampling to collect performance data
|
|
||||||
from running Python processes with minimal overhead. It can attach to
|
|
||||||
any Python process by PID and collect stack traces at regular intervals.
|
|
||||||
|
|
||||||
:param int pid: Process ID of the target Python process
|
|
||||||
:param int sample_interval_usec: Sampling interval in microseconds
|
|
||||||
:param bool all_threads: Whether to sample all threads or just the main thread
|
|
||||||
|
|
||||||
.. method:: sample(collector, duration_sec=10)
|
|
||||||
|
|
||||||
Sample the target process for the specified duration.
|
|
||||||
|
|
||||||
Collects stack traces from the target process at regular intervals
|
|
||||||
and passes them to the provided collector for processing.
|
|
||||||
|
|
||||||
:param collector: Object that implements ``collect()`` method to process stack traces
|
|
||||||
:param int duration_sec: Duration to sample in seconds (default: 10)
|
|
||||||
|
|
||||||
The method tracks sampling statistics and can display real-time
|
|
||||||
information if realtime_stats is enabled.
|
|
||||||
|
|
||||||
.. seealso::
|
|
||||||
|
|
||||||
:ref:`sampling-profiler-cli`
|
|
||||||
Command-line interface documentation for the statistical profiler.
|
|
||||||
|
|
||||||
Deterministic Profiler Command Line Interface
|
Deterministic Profiler Command Line Interface
|
||||||
=============================================
|
=============================================
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -45,7 +45,7 @@
|
||||||
system restrictions or missing privileges.
|
system restrictions or missing privileges.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from .sample import main
|
from .cli import main
|
||||||
|
|
||||||
def handle_permission_error():
|
def handle_permission_error():
|
||||||
"""Handle PermissionError by displaying appropriate error message."""
|
"""Handle PermissionError by displaying appropriate error message."""
|
||||||
|
|
|
||||||
705
Lib/profiling/sampling/cli.py
Normal file
705
Lib/profiling/sampling/cli.py
Normal file
|
|
@ -0,0 +1,705 @@
|
||||||
|
"""Command-line interface for the sampling profiler."""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
from .sample import sample, sample_live
|
||||||
|
from .pstats_collector import PstatsCollector
|
||||||
|
from .stack_collector import CollapsedStackCollector, FlamegraphCollector
|
||||||
|
from .gecko_collector import GeckoCollector
|
||||||
|
from .constants import (
|
||||||
|
PROFILING_MODE_ALL,
|
||||||
|
PROFILING_MODE_WALL,
|
||||||
|
PROFILING_MODE_CPU,
|
||||||
|
PROFILING_MODE_GIL,
|
||||||
|
SORT_MODE_NSAMPLES,
|
||||||
|
SORT_MODE_TOTTIME,
|
||||||
|
SORT_MODE_CUMTIME,
|
||||||
|
SORT_MODE_SAMPLE_PCT,
|
||||||
|
SORT_MODE_CUMUL_PCT,
|
||||||
|
SORT_MODE_NSAMPLES_CUMUL,
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
from .live_collector import LiveStatsCollector
|
||||||
|
except ImportError:
|
||||||
|
LiveStatsCollector = None
|
||||||
|
|
||||||
|
|
||||||
|
class CustomFormatter(
|
||||||
|
argparse.ArgumentDefaultsHelpFormatter,
|
||||||
|
argparse.RawDescriptionHelpFormatter,
|
||||||
|
):
|
||||||
|
"""Custom formatter that combines default values display with raw description formatting."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
_HELP_DESCRIPTION = """Sample a process's stack frames and generate profiling data.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
run Run and profile a script or module
|
||||||
|
attach Attach to and profile a running process
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Run and profile a script
|
||||||
|
python -m profiling.sampling run script.py arg1 arg2
|
||||||
|
|
||||||
|
# Attach to a running process
|
||||||
|
python -m profiling.sampling attach 1234
|
||||||
|
|
||||||
|
# Live interactive mode for a script
|
||||||
|
python -m profiling.sampling run --live script.py
|
||||||
|
|
||||||
|
# Live interactive mode for a running process
|
||||||
|
python -m profiling.sampling attach --live 1234
|
||||||
|
|
||||||
|
Use 'python -m profiling.sampling <command> --help' for command-specific help."""
|
||||||
|
|
||||||
|
|
||||||
|
# Constants for socket synchronization
|
||||||
|
_SYNC_TIMEOUT = 5.0
|
||||||
|
_PROCESS_KILL_TIMEOUT = 2.0
|
||||||
|
_READY_MESSAGE = b"ready"
|
||||||
|
_RECV_BUFFER_SIZE = 1024
|
||||||
|
|
||||||
|
# Format configuration
|
||||||
|
FORMAT_EXTENSIONS = {
|
||||||
|
"pstats": "pstats",
|
||||||
|
"collapsed": "txt",
|
||||||
|
"flamegraph": "html",
|
||||||
|
"gecko": "json",
|
||||||
|
}
|
||||||
|
|
||||||
|
COLLECTOR_MAP = {
|
||||||
|
"pstats": PstatsCollector,
|
||||||
|
"collapsed": CollapsedStackCollector,
|
||||||
|
"flamegraph": FlamegraphCollector,
|
||||||
|
"gecko": GeckoCollector,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_mode(mode_string):
|
||||||
|
"""Convert mode string to mode constant."""
|
||||||
|
mode_map = {
|
||||||
|
"wall": PROFILING_MODE_WALL,
|
||||||
|
"cpu": PROFILING_MODE_CPU,
|
||||||
|
"gil": PROFILING_MODE_GIL,
|
||||||
|
}
|
||||||
|
return mode_map[mode_string]
|
||||||
|
|
||||||
|
|
||||||
|
def _run_with_sync(original_cmd, suppress_output=False):
|
||||||
|
"""Run a command with socket-based synchronization and return the process."""
|
||||||
|
# Create a TCP socket for synchronization with better socket options
|
||||||
|
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sync_sock:
|
||||||
|
# Set SO_REUSEADDR to avoid "Address already in use" errors
|
||||||
|
sync_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
|
||||||
|
sync_sock.bind(("127.0.0.1", 0)) # Let OS choose a free port
|
||||||
|
sync_port = sync_sock.getsockname()[1]
|
||||||
|
sync_sock.listen(1)
|
||||||
|
sync_sock.settimeout(_SYNC_TIMEOUT)
|
||||||
|
|
||||||
|
# Get current working directory to preserve it
|
||||||
|
cwd = os.getcwd()
|
||||||
|
|
||||||
|
# Build command using the sync coordinator
|
||||||
|
target_args = original_cmd[1:] # Remove python executable
|
||||||
|
cmd = (
|
||||||
|
sys.executable,
|
||||||
|
"-m",
|
||||||
|
"profiling.sampling._sync_coordinator",
|
||||||
|
str(sync_port),
|
||||||
|
cwd,
|
||||||
|
) + tuple(target_args)
|
||||||
|
|
||||||
|
# Start the process with coordinator
|
||||||
|
# Suppress stdout/stderr if requested (for live mode)
|
||||||
|
popen_kwargs = {}
|
||||||
|
if suppress_output:
|
||||||
|
popen_kwargs["stdin"] = subprocess.DEVNULL
|
||||||
|
popen_kwargs["stdout"] = subprocess.DEVNULL
|
||||||
|
popen_kwargs["stderr"] = subprocess.DEVNULL
|
||||||
|
|
||||||
|
process = subprocess.Popen(cmd, **popen_kwargs)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Wait for ready signal with timeout
|
||||||
|
with sync_sock.accept()[0] as conn:
|
||||||
|
ready_signal = conn.recv(_RECV_BUFFER_SIZE)
|
||||||
|
|
||||||
|
if ready_signal != _READY_MESSAGE:
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Invalid ready signal received: {ready_signal!r}"
|
||||||
|
)
|
||||||
|
|
||||||
|
except socket.timeout:
|
||||||
|
# If we timeout, kill the process and raise an error
|
||||||
|
if process.poll() is None:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=_PROCESS_KILL_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
raise RuntimeError(
|
||||||
|
"Process failed to signal readiness within timeout"
|
||||||
|
)
|
||||||
|
|
||||||
|
return process
|
||||||
|
|
||||||
|
|
||||||
|
def _add_sampling_options(parser):
|
||||||
|
"""Add sampling configuration options to a parser."""
|
||||||
|
sampling_group = parser.add_argument_group("Sampling configuration")
|
||||||
|
sampling_group.add_argument(
|
||||||
|
"-i",
|
||||||
|
"--interval",
|
||||||
|
type=int,
|
||||||
|
default=100,
|
||||||
|
metavar="MICROSECONDS",
|
||||||
|
help="sampling interval",
|
||||||
|
)
|
||||||
|
sampling_group.add_argument(
|
||||||
|
"-d",
|
||||||
|
"--duration",
|
||||||
|
type=int,
|
||||||
|
default=10,
|
||||||
|
metavar="SECONDS",
|
||||||
|
help="Sampling duration",
|
||||||
|
)
|
||||||
|
sampling_group.add_argument(
|
||||||
|
"-a",
|
||||||
|
"--all-threads",
|
||||||
|
action="store_true",
|
||||||
|
help="Sample all threads in the process instead of just the main thread",
|
||||||
|
)
|
||||||
|
sampling_group.add_argument(
|
||||||
|
"--realtime-stats",
|
||||||
|
action="store_true",
|
||||||
|
help="Print real-time sampling statistics (Hz, mean, min, max) during profiling",
|
||||||
|
)
|
||||||
|
sampling_group.add_argument(
|
||||||
|
"--native",
|
||||||
|
action="store_true",
|
||||||
|
help='Include artificial "<native>" frames to denote calls to non-Python code',
|
||||||
|
)
|
||||||
|
sampling_group.add_argument(
|
||||||
|
"--no-gc",
|
||||||
|
action="store_false",
|
||||||
|
dest="gc",
|
||||||
|
help='Don\'t include artificial "<GC>" frames to denote active garbage collection',
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_mode_options(parser):
|
||||||
|
"""Add mode options to a parser."""
|
||||||
|
mode_group = parser.add_argument_group("Mode options")
|
||||||
|
mode_group.add_argument(
|
||||||
|
"--mode",
|
||||||
|
choices=["wall", "cpu", "gil"],
|
||||||
|
default="wall",
|
||||||
|
help="Sampling mode: wall (all samples), cpu (only samples when thread is on CPU), "
|
||||||
|
"gil (only samples when thread holds the GIL)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_format_options(parser):
|
||||||
|
"""Add output format options to a parser."""
|
||||||
|
output_group = parser.add_argument_group("Output options")
|
||||||
|
format_group = output_group.add_mutually_exclusive_group()
|
||||||
|
format_group.add_argument(
|
||||||
|
"--pstats",
|
||||||
|
action="store_const",
|
||||||
|
const="pstats",
|
||||||
|
dest="format",
|
||||||
|
help="Generate pstats output (default)",
|
||||||
|
)
|
||||||
|
format_group.add_argument(
|
||||||
|
"--collapsed",
|
||||||
|
action="store_const",
|
||||||
|
const="collapsed",
|
||||||
|
dest="format",
|
||||||
|
help="Generate collapsed stack traces for flamegraphs",
|
||||||
|
)
|
||||||
|
format_group.add_argument(
|
||||||
|
"--flamegraph",
|
||||||
|
action="store_const",
|
||||||
|
const="flamegraph",
|
||||||
|
dest="format",
|
||||||
|
help="Generate interactive HTML flamegraph visualization",
|
||||||
|
)
|
||||||
|
format_group.add_argument(
|
||||||
|
"--gecko",
|
||||||
|
action="store_const",
|
||||||
|
const="gecko",
|
||||||
|
dest="format",
|
||||||
|
help="Generate Gecko format for Firefox Profiler",
|
||||||
|
)
|
||||||
|
parser.set_defaults(format="pstats")
|
||||||
|
|
||||||
|
output_group.add_argument(
|
||||||
|
"-o",
|
||||||
|
"--output",
|
||||||
|
dest="outfile",
|
||||||
|
help="Save output to a file (default: stdout for pstats, "
|
||||||
|
"auto-generated filename for other formats)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _add_pstats_options(parser):
|
||||||
|
"""Add pstats-specific display options to a parser."""
|
||||||
|
pstats_group = parser.add_argument_group("pstats format options")
|
||||||
|
pstats_group.add_argument(
|
||||||
|
"--sort",
|
||||||
|
choices=[
|
||||||
|
"nsamples",
|
||||||
|
"tottime",
|
||||||
|
"cumtime",
|
||||||
|
"sample-pct",
|
||||||
|
"cumul-pct",
|
||||||
|
"nsamples-cumul",
|
||||||
|
"name",
|
||||||
|
],
|
||||||
|
default=None,
|
||||||
|
help="Sort order for pstats output (default: nsamples)",
|
||||||
|
)
|
||||||
|
pstats_group.add_argument(
|
||||||
|
"-l",
|
||||||
|
"--limit",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help="Limit the number of rows in the output (default: 15)",
|
||||||
|
)
|
||||||
|
pstats_group.add_argument(
|
||||||
|
"--no-summary",
|
||||||
|
action="store_true",
|
||||||
|
help="Disable the summary section in the pstats output",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _sort_to_mode(sort_choice):
|
||||||
|
"""Convert sort choice string to SORT_MODE constant."""
|
||||||
|
sort_map = {
|
||||||
|
"nsamples": SORT_MODE_NSAMPLES,
|
||||||
|
"tottime": SORT_MODE_TOTTIME,
|
||||||
|
"cumtime": SORT_MODE_CUMTIME,
|
||||||
|
"sample-pct": SORT_MODE_SAMPLE_PCT,
|
||||||
|
"cumul-pct": SORT_MODE_CUMUL_PCT,
|
||||||
|
"nsamples-cumul": SORT_MODE_NSAMPLES_CUMUL,
|
||||||
|
"name": -1,
|
||||||
|
}
|
||||||
|
return sort_map.get(sort_choice, SORT_MODE_NSAMPLES)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_collector(format_type, interval, skip_idle):
|
||||||
|
"""Create the appropriate collector based on format type.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_type: The output format ('pstats', 'collapsed', 'flamegraph', 'gecko')
|
||||||
|
interval: Sampling interval in microseconds
|
||||||
|
skip_idle: Whether to skip idle samples
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
A collector instance of the appropriate type
|
||||||
|
"""
|
||||||
|
collector_class = COLLECTOR_MAP.get(format_type)
|
||||||
|
if collector_class is None:
|
||||||
|
raise ValueError(f"Unknown format: {format_type}")
|
||||||
|
|
||||||
|
# Gecko format never skips idle (it needs both GIL and CPU data)
|
||||||
|
if format_type == "gecko":
|
||||||
|
skip_idle = False
|
||||||
|
|
||||||
|
return collector_class(interval, skip_idle=skip_idle)
|
||||||
|
|
||||||
|
|
||||||
|
def _generate_output_filename(format_type, pid):
|
||||||
|
"""Generate output filename based on format and PID.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
format_type: The output format
|
||||||
|
pid: Process ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Generated filename
|
||||||
|
"""
|
||||||
|
extension = FORMAT_EXTENSIONS.get(format_type, "txt")
|
||||||
|
return f"{format_type}.{pid}.{extension}"
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_output(collector, args, pid, mode):
|
||||||
|
"""Handle output for the collector based on format and arguments.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
collector: The collector instance with profiling data
|
||||||
|
args: Parsed command-line arguments
|
||||||
|
pid: Process ID (for generating filenames)
|
||||||
|
mode: Profiling mode used
|
||||||
|
"""
|
||||||
|
if args.format == "pstats":
|
||||||
|
if args.outfile:
|
||||||
|
collector.export(args.outfile)
|
||||||
|
else:
|
||||||
|
# Print to stdout with defaults applied
|
||||||
|
sort_choice = args.sort if args.sort is not None else "nsamples"
|
||||||
|
limit = args.limit if args.limit is not None else 15
|
||||||
|
sort_mode = _sort_to_mode(sort_choice)
|
||||||
|
collector.print_stats(
|
||||||
|
sort_mode, limit, not args.no_summary, mode
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Export to file
|
||||||
|
filename = args.outfile or _generate_output_filename(args.format, pid)
|
||||||
|
collector.export(filename)
|
||||||
|
|
||||||
|
|
||||||
|
def _validate_args(args, parser):
|
||||||
|
"""Validate format-specific options and live mode requirements.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
args: Parsed command-line arguments
|
||||||
|
parser: ArgumentParser instance for error reporting
|
||||||
|
"""
|
||||||
|
# Check if live mode is available
|
||||||
|
if hasattr(args, 'live') and args.live and LiveStatsCollector is None:
|
||||||
|
parser.error(
|
||||||
|
"Live mode requires the curses module, which is not available."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Live mode is incompatible with format options
|
||||||
|
if hasattr(args, 'live') and args.live:
|
||||||
|
if args.format != "pstats":
|
||||||
|
format_flag = f"--{args.format}"
|
||||||
|
parser.error(
|
||||||
|
f"--live is incompatible with {format_flag}. Live mode uses a TUI interface."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Live mode is also incompatible with pstats-specific options
|
||||||
|
issues = []
|
||||||
|
if args.sort is not None:
|
||||||
|
issues.append("--sort")
|
||||||
|
if args.limit is not None:
|
||||||
|
issues.append("--limit")
|
||||||
|
if args.no_summary:
|
||||||
|
issues.append("--no-summary")
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
parser.error(
|
||||||
|
f"Options {', '.join(issues)} are incompatible with --live. "
|
||||||
|
"Live mode uses a TUI interface with its own controls."
|
||||||
|
)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Validate gecko mode doesn't use non-wall mode
|
||||||
|
if args.format == "gecko" and args.mode != "wall":
|
||||||
|
parser.error(
|
||||||
|
"--mode option is incompatible with --gecko. "
|
||||||
|
"Gecko format automatically includes both GIL-holding and CPU status analysis."
|
||||||
|
)
|
||||||
|
|
||||||
|
# Validate pstats-specific options are only used with pstats format
|
||||||
|
if args.format != "pstats":
|
||||||
|
issues = []
|
||||||
|
if args.sort is not None:
|
||||||
|
issues.append("--sort")
|
||||||
|
if args.limit is not None:
|
||||||
|
issues.append("--limit")
|
||||||
|
if args.no_summary:
|
||||||
|
issues.append("--no-summary")
|
||||||
|
|
||||||
|
if issues:
|
||||||
|
format_flag = f"--{args.format}"
|
||||||
|
parser.error(
|
||||||
|
f"Options {', '.join(issues)} are only valid with --pstats, not {format_flag}"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Main entry point for the CLI."""
|
||||||
|
# Create the main parser
|
||||||
|
parser = argparse.ArgumentParser(
|
||||||
|
description=_HELP_DESCRIPTION,
|
||||||
|
formatter_class=CustomFormatter,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create subparsers for commands
|
||||||
|
subparsers = parser.add_subparsers(
|
||||||
|
dest="command", required=True, help="Command to run"
|
||||||
|
)
|
||||||
|
|
||||||
|
# === RUN COMMAND ===
|
||||||
|
run_parser = subparsers.add_parser(
|
||||||
|
"run",
|
||||||
|
help="Run and profile a script or module",
|
||||||
|
formatter_class=CustomFormatter,
|
||||||
|
description="""Run and profile a Python script or module
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Run and profile a module
|
||||||
|
python -m profiling.sampling run -m mymodule arg1 arg2
|
||||||
|
|
||||||
|
# Generate flamegraph from a script
|
||||||
|
python -m profiling.sampling run --flamegraph -o output.html script.py
|
||||||
|
|
||||||
|
# Profile with custom interval and duration
|
||||||
|
python -m profiling.sampling run -i 50 -d 30 script.py
|
||||||
|
|
||||||
|
# Save collapsed stacks to file
|
||||||
|
python -m profiling.sampling run --collapsed -o stacks.txt script.py
|
||||||
|
|
||||||
|
# Live interactive mode for a script
|
||||||
|
python -m profiling.sampling run --live script.py""",
|
||||||
|
)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"-m",
|
||||||
|
"--module",
|
||||||
|
action="store_true",
|
||||||
|
help="Run target as a module (like python -m)",
|
||||||
|
)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"target",
|
||||||
|
help="Script file or module name to profile",
|
||||||
|
)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"args",
|
||||||
|
nargs=argparse.REMAINDER,
|
||||||
|
help="Arguments to pass to the script or module",
|
||||||
|
)
|
||||||
|
run_parser.add_argument(
|
||||||
|
"--live",
|
||||||
|
action="store_true",
|
||||||
|
help="Interactive TUI profiler (top-like interface, press 'q' to quit, 's' to cycle sort)",
|
||||||
|
)
|
||||||
|
_add_sampling_options(run_parser)
|
||||||
|
_add_mode_options(run_parser)
|
||||||
|
_add_format_options(run_parser)
|
||||||
|
_add_pstats_options(run_parser)
|
||||||
|
|
||||||
|
# === ATTACH COMMAND ===
|
||||||
|
attach_parser = subparsers.add_parser(
|
||||||
|
"attach",
|
||||||
|
help="Attach to and profile a running process",
|
||||||
|
formatter_class=CustomFormatter,
|
||||||
|
description="""Attach to a running process and profile it
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
# Profile all threads, sort by total time
|
||||||
|
python -m profiling.sampling attach -a --sort tottime 1234
|
||||||
|
|
||||||
|
# Live interactive mode for a running process
|
||||||
|
python -m profiling.sampling attach --live 1234""",
|
||||||
|
)
|
||||||
|
attach_parser.add_argument(
|
||||||
|
"pid",
|
||||||
|
type=int,
|
||||||
|
help="Process ID to attach to",
|
||||||
|
)
|
||||||
|
attach_parser.add_argument(
|
||||||
|
"--live",
|
||||||
|
action="store_true",
|
||||||
|
help="Interactive TUI profiler (top-like interface, press 'q' to quit, 's' to cycle sort)",
|
||||||
|
)
|
||||||
|
_add_sampling_options(attach_parser)
|
||||||
|
_add_mode_options(attach_parser)
|
||||||
|
_add_format_options(attach_parser)
|
||||||
|
_add_pstats_options(attach_parser)
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
# Validate arguments
|
||||||
|
_validate_args(args, parser)
|
||||||
|
|
||||||
|
# Command dispatch table
|
||||||
|
command_handlers = {
|
||||||
|
"run": _handle_run,
|
||||||
|
"attach": _handle_attach,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Execute the appropriate command
|
||||||
|
handler = command_handlers.get(args.command)
|
||||||
|
if handler:
|
||||||
|
handler(args)
|
||||||
|
else:
|
||||||
|
parser.error(f"Unknown command: {args.command}")
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_attach(args):
|
||||||
|
"""Handle the 'attach' command."""
|
||||||
|
# Check if live mode is requested
|
||||||
|
if args.live:
|
||||||
|
_handle_live_attach(args, args.pid)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Use PROFILING_MODE_ALL for gecko format
|
||||||
|
mode = (
|
||||||
|
PROFILING_MODE_ALL
|
||||||
|
if args.format == "gecko"
|
||||||
|
else _parse_mode(args.mode)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine skip_idle based on mode
|
||||||
|
skip_idle = (
|
||||||
|
mode != PROFILING_MODE_WALL if mode != PROFILING_MODE_ALL else False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the appropriate collector
|
||||||
|
collector = _create_collector(args.format, args.interval, skip_idle)
|
||||||
|
|
||||||
|
# Sample the process
|
||||||
|
collector = sample(
|
||||||
|
args.pid,
|
||||||
|
collector,
|
||||||
|
duration_sec=args.duration,
|
||||||
|
all_threads=args.all_threads,
|
||||||
|
realtime_stats=args.realtime_stats,
|
||||||
|
mode=mode,
|
||||||
|
native=args.native,
|
||||||
|
gc=args.gc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle output
|
||||||
|
_handle_output(collector, args, args.pid, mode)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_run(args):
|
||||||
|
"""Handle the 'run' command."""
|
||||||
|
# Check if live mode is requested
|
||||||
|
if args.live:
|
||||||
|
_handle_live_run(args)
|
||||||
|
return
|
||||||
|
|
||||||
|
# Build the command to run
|
||||||
|
if args.module:
|
||||||
|
cmd = (sys.executable, "-m", args.target, *args.args)
|
||||||
|
else:
|
||||||
|
cmd = (sys.executable, args.target, *args.args)
|
||||||
|
|
||||||
|
# Run with synchronization
|
||||||
|
process = _run_with_sync(cmd, suppress_output=False)
|
||||||
|
|
||||||
|
# Use PROFILING_MODE_ALL for gecko format
|
||||||
|
mode = (
|
||||||
|
PROFILING_MODE_ALL
|
||||||
|
if args.format == "gecko"
|
||||||
|
else _parse_mode(args.mode)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine skip_idle based on mode
|
||||||
|
skip_idle = (
|
||||||
|
mode != PROFILING_MODE_WALL if mode != PROFILING_MODE_ALL else False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create the appropriate collector
|
||||||
|
collector = _create_collector(args.format, args.interval, skip_idle)
|
||||||
|
|
||||||
|
# Profile the subprocess
|
||||||
|
try:
|
||||||
|
collector = sample(
|
||||||
|
process.pid,
|
||||||
|
collector,
|
||||||
|
duration_sec=args.duration,
|
||||||
|
all_threads=args.all_threads,
|
||||||
|
realtime_stats=args.realtime_stats,
|
||||||
|
mode=mode,
|
||||||
|
native=args.native,
|
||||||
|
gc=args.gc,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Handle output
|
||||||
|
_handle_output(collector, args, process.pid, mode)
|
||||||
|
finally:
|
||||||
|
# Clean up the subprocess
|
||||||
|
if process.poll() is None:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=_PROCESS_KILL_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_live_attach(args, pid):
|
||||||
|
"""Handle live mode for an existing process."""
|
||||||
|
mode = _parse_mode(args.mode)
|
||||||
|
|
||||||
|
# Determine skip_idle based on mode
|
||||||
|
skip_idle = mode != PROFILING_MODE_WALL
|
||||||
|
|
||||||
|
# Create live collector with default settings
|
||||||
|
collector = LiveStatsCollector(
|
||||||
|
args.interval,
|
||||||
|
skip_idle=skip_idle,
|
||||||
|
sort_by="tottime", # Default initial sort
|
||||||
|
limit=20, # Default limit
|
||||||
|
pid=pid,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sample in live mode
|
||||||
|
sample_live(
|
||||||
|
pid,
|
||||||
|
collector,
|
||||||
|
duration_sec=args.duration,
|
||||||
|
all_threads=args.all_threads,
|
||||||
|
realtime_stats=args.realtime_stats,
|
||||||
|
mode=mode,
|
||||||
|
native=args.native,
|
||||||
|
gc=args.gc,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _handle_live_run(args):
|
||||||
|
"""Handle live mode for running a script/module."""
|
||||||
|
# Build the command to run
|
||||||
|
if args.module:
|
||||||
|
cmd = (sys.executable, "-m", args.target, *args.args)
|
||||||
|
else:
|
||||||
|
cmd = (sys.executable, args.target, *args.args)
|
||||||
|
|
||||||
|
# Run with synchronization, suppressing output for live mode
|
||||||
|
process = _run_with_sync(cmd, suppress_output=True)
|
||||||
|
|
||||||
|
mode = _parse_mode(args.mode)
|
||||||
|
|
||||||
|
# Determine skip_idle based on mode
|
||||||
|
skip_idle = mode != PROFILING_MODE_WALL
|
||||||
|
|
||||||
|
# Create live collector with default settings
|
||||||
|
collector = LiveStatsCollector(
|
||||||
|
args.interval,
|
||||||
|
skip_idle=skip_idle,
|
||||||
|
sort_by="tottime", # Default initial sort
|
||||||
|
limit=20, # Default limit
|
||||||
|
pid=process.pid,
|
||||||
|
mode=mode,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Profile the subprocess in live mode
|
||||||
|
try:
|
||||||
|
sample_live(
|
||||||
|
process.pid,
|
||||||
|
collector,
|
||||||
|
duration_sec=args.duration,
|
||||||
|
all_threads=args.all_threads,
|
||||||
|
realtime_stats=args.realtime_stats,
|
||||||
|
mode=mode,
|
||||||
|
native=args.native,
|
||||||
|
gc=args.gc,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
# Clean up the subprocess
|
||||||
|
if process.poll() is None:
|
||||||
|
process.terminate()
|
||||||
|
try:
|
||||||
|
process.wait(timeout=_PROCESS_KILL_TIMEOUT)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
process.kill()
|
||||||
|
process.wait()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
|
|
@ -56,7 +56,8 @@
|
||||||
|
|
||||||
|
|
||||||
class GeckoCollector(Collector):
|
class GeckoCollector(Collector):
|
||||||
def __init__(self, *, skip_idle=False):
|
def __init__(self, sample_interval_usec, *, skip_idle=False):
|
||||||
|
self.sample_interval_usec = sample_interval_usec
|
||||||
self.skip_idle = skip_idle
|
self.skip_idle = skip_idle
|
||||||
self.start_time = time.time() * 1000 # milliseconds since epoch
|
self.start_time = time.time() * 1000 # milliseconds since epoch
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import collections
|
import collections
|
||||||
import marshal
|
import marshal
|
||||||
|
|
||||||
|
from _colorize import ANSIColors
|
||||||
from .collector import Collector
|
from .collector import Collector
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -70,3 +71,342 @@ def create_stats(self):
|
||||||
cumulative,
|
cumulative,
|
||||||
callers,
|
callers,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def print_stats(self, sort=-1, limit=None, show_summary=True, mode=None):
|
||||||
|
"""Print formatted statistics to stdout."""
|
||||||
|
import pstats
|
||||||
|
from .constants import PROFILING_MODE_CPU
|
||||||
|
|
||||||
|
# Create stats object
|
||||||
|
stats = pstats.SampledStats(self).strip_dirs()
|
||||||
|
if not stats.stats:
|
||||||
|
print("No samples were collected.")
|
||||||
|
if mode == PROFILING_MODE_CPU:
|
||||||
|
print("This can happen in CPU mode when all threads are idle.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Get the stats data
|
||||||
|
stats_list = []
|
||||||
|
for func, (
|
||||||
|
direct_calls,
|
||||||
|
cumulative_calls,
|
||||||
|
total_time,
|
||||||
|
cumulative_time,
|
||||||
|
callers,
|
||||||
|
) in stats.stats.items():
|
||||||
|
stats_list.append(
|
||||||
|
(
|
||||||
|
func,
|
||||||
|
direct_calls,
|
||||||
|
cumulative_calls,
|
||||||
|
total_time,
|
||||||
|
cumulative_time,
|
||||||
|
callers,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate total samples for percentage calculations (using direct_calls)
|
||||||
|
total_samples = sum(
|
||||||
|
direct_calls for _, direct_calls, _, _, _, _ in stats_list
|
||||||
|
)
|
||||||
|
|
||||||
|
# Sort based on the requested field
|
||||||
|
sort_field = sort
|
||||||
|
if sort_field == -1: # stdname
|
||||||
|
stats_list.sort(key=lambda x: str(x[0]))
|
||||||
|
elif sort_field == 0: # nsamples (direct samples)
|
||||||
|
stats_list.sort(key=lambda x: x[1], reverse=True) # direct_calls
|
||||||
|
elif sort_field == 1: # tottime
|
||||||
|
stats_list.sort(key=lambda x: x[3], reverse=True) # total_time
|
||||||
|
elif sort_field == 2: # cumtime
|
||||||
|
stats_list.sort(key=lambda x: x[4], reverse=True) # cumulative_time
|
||||||
|
elif sort_field == 3: # sample%
|
||||||
|
stats_list.sort(
|
||||||
|
key=lambda x: (x[1] / total_samples * 100)
|
||||||
|
if total_samples > 0
|
||||||
|
else 0,
|
||||||
|
reverse=True, # direct_calls percentage
|
||||||
|
)
|
||||||
|
elif sort_field == 4: # cumul%
|
||||||
|
stats_list.sort(
|
||||||
|
key=lambda x: (x[2] / total_samples * 100)
|
||||||
|
if total_samples > 0
|
||||||
|
else 0,
|
||||||
|
reverse=True, # cumulative_calls percentage
|
||||||
|
)
|
||||||
|
elif sort_field == 5: # nsamples (cumulative samples)
|
||||||
|
stats_list.sort(key=lambda x: x[2], reverse=True) # cumulative_calls
|
||||||
|
|
||||||
|
# Apply limit if specified
|
||||||
|
if limit is not None:
|
||||||
|
stats_list = stats_list[:limit]
|
||||||
|
|
||||||
|
# Determine the best unit for time columns based on maximum values
|
||||||
|
max_total_time = max(
|
||||||
|
(total_time for _, _, _, total_time, _, _ in stats_list), default=0
|
||||||
|
)
|
||||||
|
max_cumulative_time = max(
|
||||||
|
(cumulative_time for _, _, _, _, cumulative_time, _ in stats_list),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_time_unit, total_time_scale = self._determine_best_unit(max_total_time)
|
||||||
|
cumulative_time_unit, cumulative_time_scale = self._determine_best_unit(
|
||||||
|
max_cumulative_time
|
||||||
|
)
|
||||||
|
|
||||||
|
# Define column widths for consistent alignment
|
||||||
|
col_widths = {
|
||||||
|
"nsamples": 15, # "nsamples" column (inline/cumulative format)
|
||||||
|
"sample_pct": 8, # "sample%" column
|
||||||
|
"tottime": max(12, len(f"tottime ({total_time_unit})")),
|
||||||
|
"cum_pct": 8, # "cumul%" column
|
||||||
|
"cumtime": max(12, len(f"cumtime ({cumulative_time_unit})")),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Print header with colors and proper alignment
|
||||||
|
print(f"{ANSIColors.BOLD_BLUE}Profile Stats:{ANSIColors.RESET}")
|
||||||
|
|
||||||
|
header_nsamples = f"{ANSIColors.BOLD_BLUE}{'nsamples':>{col_widths['nsamples']}}{ANSIColors.RESET}"
|
||||||
|
header_sample_pct = f"{ANSIColors.BOLD_BLUE}{'sample%':>{col_widths['sample_pct']}}{ANSIColors.RESET}"
|
||||||
|
header_tottime = f"{ANSIColors.BOLD_BLUE}{f'tottime ({total_time_unit})':>{col_widths['tottime']}}{ANSIColors.RESET}"
|
||||||
|
header_cum_pct = f"{ANSIColors.BOLD_BLUE}{'cumul%':>{col_widths['cum_pct']}}{ANSIColors.RESET}"
|
||||||
|
header_cumtime = f"{ANSIColors.BOLD_BLUE}{f'cumtime ({cumulative_time_unit})':>{col_widths['cumtime']}}{ANSIColors.RESET}"
|
||||||
|
header_filename = (
|
||||||
|
f"{ANSIColors.BOLD_BLUE}filename:lineno(function){ANSIColors.RESET}"
|
||||||
|
)
|
||||||
|
|
||||||
|
print(
|
||||||
|
f"{header_nsamples} {header_sample_pct} {header_tottime} {header_cum_pct} {header_cumtime} {header_filename}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print each line with proper alignment
|
||||||
|
for (
|
||||||
|
func,
|
||||||
|
direct_calls,
|
||||||
|
cumulative_calls,
|
||||||
|
total_time,
|
||||||
|
cumulative_time,
|
||||||
|
callers,
|
||||||
|
) in stats_list:
|
||||||
|
# Calculate percentages
|
||||||
|
sample_pct = (
|
||||||
|
(direct_calls / total_samples * 100) if total_samples > 0 else 0
|
||||||
|
)
|
||||||
|
cum_pct = (
|
||||||
|
(cumulative_calls / total_samples * 100)
|
||||||
|
if total_samples > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format values with proper alignment - always use A/B format
|
||||||
|
nsamples_str = f"{direct_calls}/{cumulative_calls}"
|
||||||
|
nsamples_str = f"{nsamples_str:>{col_widths['nsamples']}}"
|
||||||
|
sample_pct_str = f"{sample_pct:{col_widths['sample_pct']}.1f}"
|
||||||
|
tottime = f"{total_time * total_time_scale:{col_widths['tottime']}.3f}"
|
||||||
|
cum_pct_str = f"{cum_pct:{col_widths['cum_pct']}.1f}"
|
||||||
|
cumtime = f"{cumulative_time * cumulative_time_scale:{col_widths['cumtime']}.3f}"
|
||||||
|
|
||||||
|
# Format the function name with colors
|
||||||
|
func_name = (
|
||||||
|
f"{ANSIColors.GREEN}{func[0]}{ANSIColors.RESET}:"
|
||||||
|
f"{ANSIColors.YELLOW}{func[1]}{ANSIColors.RESET}("
|
||||||
|
f"{ANSIColors.CYAN}{func[2]}{ANSIColors.RESET})"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print the formatted line with consistent spacing
|
||||||
|
print(
|
||||||
|
f"{nsamples_str} {sample_pct_str} {tottime} {cum_pct_str} {cumtime} {func_name}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print legend
|
||||||
|
print(f"\n{ANSIColors.BOLD_BLUE}Legend:{ANSIColors.RESET}")
|
||||||
|
print(
|
||||||
|
f" {ANSIColors.YELLOW}nsamples{ANSIColors.RESET}: Direct/Cumulative samples (direct executing / on call stack)"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {ANSIColors.YELLOW}sample%{ANSIColors.RESET}: Percentage of total samples this function was directly executing"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {ANSIColors.YELLOW}tottime{ANSIColors.RESET}: Estimated total time spent directly in this function"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {ANSIColors.YELLOW}cumul%{ANSIColors.RESET}: Percentage of total samples when this function was on the call stack"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {ANSIColors.YELLOW}cumtime{ANSIColors.RESET}: Estimated cumulative time (including time in called functions)"
|
||||||
|
)
|
||||||
|
print(
|
||||||
|
f" {ANSIColors.YELLOW}filename:lineno(function){ANSIColors.RESET}: Function location and name"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Print summary of interesting functions if enabled
|
||||||
|
if show_summary and stats_list:
|
||||||
|
self._print_summary(stats_list, total_samples)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _determine_best_unit(max_value):
|
||||||
|
"""Determine the best unit (s, ms, μs) and scale factor for a maximum value."""
|
||||||
|
if max_value >= 1.0:
|
||||||
|
return "s", 1.0
|
||||||
|
elif max_value >= 0.001:
|
||||||
|
return "ms", 1000.0
|
||||||
|
else:
|
||||||
|
return "μs", 1000000.0
|
||||||
|
|
||||||
|
def _print_summary(self, stats_list, total_samples):
|
||||||
|
"""Print summary of interesting functions."""
|
||||||
|
print(
|
||||||
|
f"\n{ANSIColors.BOLD_BLUE}Summary of Interesting Functions:{ANSIColors.RESET}"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Aggregate stats by fully qualified function name (ignoring line numbers)
|
||||||
|
func_aggregated = {}
|
||||||
|
for (
|
||||||
|
func,
|
||||||
|
direct_calls,
|
||||||
|
cumulative_calls,
|
||||||
|
total_time,
|
||||||
|
cumulative_time,
|
||||||
|
callers,
|
||||||
|
) in stats_list:
|
||||||
|
# Use filename:function_name as the key to get fully qualified name
|
||||||
|
qualified_name = f"{func[0]}:{func[2]}"
|
||||||
|
if qualified_name not in func_aggregated:
|
||||||
|
func_aggregated[qualified_name] = [
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
] # direct_calls, cumulative_calls, total_time, cumulative_time
|
||||||
|
func_aggregated[qualified_name][0] += direct_calls
|
||||||
|
func_aggregated[qualified_name][1] += cumulative_calls
|
||||||
|
func_aggregated[qualified_name][2] += total_time
|
||||||
|
func_aggregated[qualified_name][3] += cumulative_time
|
||||||
|
|
||||||
|
# Convert aggregated data back to list format for processing
|
||||||
|
aggregated_stats = []
|
||||||
|
for qualified_name, (
|
||||||
|
prim_calls,
|
||||||
|
total_calls,
|
||||||
|
total_time,
|
||||||
|
cumulative_time,
|
||||||
|
) in func_aggregated.items():
|
||||||
|
# Parse the qualified name back to filename and function name
|
||||||
|
if ":" in qualified_name:
|
||||||
|
filename, func_name = qualified_name.rsplit(":", 1)
|
||||||
|
else:
|
||||||
|
filename, func_name = "", qualified_name
|
||||||
|
# Create a dummy func tuple with filename and function name for display
|
||||||
|
dummy_func = (filename, "", func_name)
|
||||||
|
aggregated_stats.append(
|
||||||
|
(
|
||||||
|
dummy_func,
|
||||||
|
prim_calls,
|
||||||
|
total_calls,
|
||||||
|
total_time,
|
||||||
|
cumulative_time,
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine best units for summary metrics
|
||||||
|
max_total_time = max(
|
||||||
|
(total_time for _, _, _, total_time, _, _ in aggregated_stats),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
max_cumulative_time = max(
|
||||||
|
(
|
||||||
|
cumulative_time
|
||||||
|
for _, _, _, _, cumulative_time, _ in aggregated_stats
|
||||||
|
),
|
||||||
|
default=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
total_unit, total_scale = self._determine_best_unit(max_total_time)
|
||||||
|
cumulative_unit, cumulative_scale = self._determine_best_unit(
|
||||||
|
max_cumulative_time
|
||||||
|
)
|
||||||
|
|
||||||
|
def _format_func_name(func):
|
||||||
|
"""Format function name with colors."""
|
||||||
|
return (
|
||||||
|
f"{ANSIColors.GREEN}{func[0]}{ANSIColors.RESET}:"
|
||||||
|
f"{ANSIColors.YELLOW}{func[1]}{ANSIColors.RESET}("
|
||||||
|
f"{ANSIColors.CYAN}{func[2]}{ANSIColors.RESET})"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _print_top_functions(stats_list, title, key_func, format_line, n=3):
|
||||||
|
"""Print top N functions sorted by key_func with formatted output."""
|
||||||
|
print(f"\n{ANSIColors.BOLD_BLUE}{title}:{ANSIColors.RESET}")
|
||||||
|
sorted_stats = sorted(stats_list, key=key_func, reverse=True)
|
||||||
|
for stat in sorted_stats[:n]:
|
||||||
|
if line := format_line(stat):
|
||||||
|
print(f" {line}")
|
||||||
|
|
||||||
|
# Functions with highest direct/cumulative ratio (hot spots)
|
||||||
|
def format_hotspots(stat):
|
||||||
|
func, direct_calls, cumulative_calls, total_time, _, _ = stat
|
||||||
|
if direct_calls > 0 and cumulative_calls > 0:
|
||||||
|
ratio = direct_calls / cumulative_calls
|
||||||
|
direct_pct = (
|
||||||
|
(direct_calls / total_samples * 100)
|
||||||
|
if total_samples > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{ratio:.3f} direct/cumulative ratio, "
|
||||||
|
f"{direct_pct:.1f}% direct samples: {_format_func_name(func)}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_print_top_functions(
|
||||||
|
aggregated_stats,
|
||||||
|
"Functions with Highest Direct/Cumulative Ratio (Hot Spots)",
|
||||||
|
key_func=lambda x: (x[1] / x[2]) if x[2] > 0 else 0,
|
||||||
|
format_line=format_hotspots,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Functions with highest call frequency (cumulative/direct difference)
|
||||||
|
def format_call_frequency(stat):
|
||||||
|
func, direct_calls, cumulative_calls, total_time, _, _ = stat
|
||||||
|
if cumulative_calls > direct_calls:
|
||||||
|
call_frequency = cumulative_calls - direct_calls
|
||||||
|
cum_pct = (
|
||||||
|
(cumulative_calls / total_samples * 100)
|
||||||
|
if total_samples > 0
|
||||||
|
else 0
|
||||||
|
)
|
||||||
|
return (
|
||||||
|
f"{call_frequency:d} indirect calls, "
|
||||||
|
f"{cum_pct:.1f}% total stack presence: {_format_func_name(func)}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_print_top_functions(
|
||||||
|
aggregated_stats,
|
||||||
|
"Functions with Highest Call Frequency (Indirect Calls)",
|
||||||
|
key_func=lambda x: x[2] - x[1], # Sort by (cumulative - direct)
|
||||||
|
format_line=format_call_frequency,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Functions with highest cumulative-to-direct multiplier (call magnification)
|
||||||
|
def format_call_magnification(stat):
|
||||||
|
func, direct_calls, cumulative_calls, total_time, _, _ = stat
|
||||||
|
if direct_calls > 0 and cumulative_calls > direct_calls:
|
||||||
|
multiplier = cumulative_calls / direct_calls
|
||||||
|
indirect_calls = cumulative_calls - direct_calls
|
||||||
|
return (
|
||||||
|
f"{multiplier:.1f}x call magnification, "
|
||||||
|
f"{indirect_calls:d} indirect calls from {direct_calls:d} direct: {_format_func_name(func)}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
_print_top_functions(
|
||||||
|
aggregated_stats,
|
||||||
|
"Functions with Highest Call Magnification (Cumulative/Direct)",
|
||||||
|
key_func=lambda x: (x[2] / x[1])
|
||||||
|
if x[1] > 0
|
||||||
|
else 0, # Sort by cumulative/direct ratio
|
||||||
|
format_line=format_call_magnification,
|
||||||
|
)
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -11,7 +11,8 @@
|
||||||
|
|
||||||
|
|
||||||
class StackTraceCollector(Collector):
|
class StackTraceCollector(Collector):
|
||||||
def __init__(self, *, skip_idle=False):
|
def __init__(self, sample_interval_usec, *, skip_idle=False):
|
||||||
|
self.sample_interval_usec = sample_interval_usec
|
||||||
self.skip_idle = skip_idle
|
self.skip_idle = skip_idle
|
||||||
|
|
||||||
def collect(self, stack_frames, skip_idle=False):
|
def collect(self, stack_frames, skip_idle=False):
|
||||||
|
|
|
||||||
|
|
@ -69,14 +69,16 @@ def test_gc_frames_enabled(self):
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
from profiling.sampling.pstats_collector import PstatsCollector
|
||||||
|
collector = PstatsCollector(sample_interval_usec=5000, skip_idle=False)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=1,
|
duration_sec=1,
|
||||||
sample_interval_usec=5000,
|
|
||||||
show_summary=False,
|
|
||||||
native=False,
|
native=False,
|
||||||
gc=True,
|
gc=True,
|
||||||
)
|
)
|
||||||
|
collector.print_stats(show_summary=False)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
self.skipTest("Insufficient permissions for remote profiling")
|
self.skipTest("Insufficient permissions for remote profiling")
|
||||||
|
|
||||||
|
|
@ -97,14 +99,16 @@ def test_gc_frames_disabled(self):
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
from profiling.sampling.pstats_collector import PstatsCollector
|
||||||
|
collector = PstatsCollector(sample_interval_usec=5000, skip_idle=False)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=1,
|
duration_sec=1,
|
||||||
sample_interval_usec=5000,
|
|
||||||
show_summary=False,
|
|
||||||
native=False,
|
native=False,
|
||||||
gc=False,
|
gc=False,
|
||||||
)
|
)
|
||||||
|
collector.print_stats(show_summary=False)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
self.skipTest("Insufficient permissions for remote profiling")
|
self.skipTest("Insufficient permissions for remote profiling")
|
||||||
|
|
||||||
|
|
@ -159,14 +163,15 @@ def test_native_frames_enabled(self):
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
from profiling.sampling.stack_collector import CollapsedStackCollector
|
||||||
|
collector = CollapsedStackCollector(1000, skip_idle=False)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=1,
|
duration_sec=1,
|
||||||
filename=collapsed_file.name,
|
|
||||||
output_format="collapsed",
|
|
||||||
sample_interval_usec=1000,
|
|
||||||
native=True,
|
native=True,
|
||||||
)
|
)
|
||||||
|
collector.export(collapsed_file.name)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
self.skipTest(
|
self.skipTest(
|
||||||
"Insufficient permissions for remote profiling"
|
"Insufficient permissions for remote profiling"
|
||||||
|
|
@ -199,12 +204,14 @@ def test_native_frames_disabled(self):
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
from profiling.sampling.pstats_collector import PstatsCollector
|
||||||
|
collector = PstatsCollector(sample_interval_usec=5000, skip_idle=False)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=1,
|
duration_sec=1,
|
||||||
sample_interval_usec=5000,
|
|
||||||
show_summary=False,
|
|
||||||
)
|
)
|
||||||
|
collector.print_stats(show_summary=False)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
self.skipTest("Insufficient permissions for remote profiling")
|
self.skipTest("Insufficient permissions for remote profiling")
|
||||||
output = captured_output.getvalue()
|
output = captured_output.getvalue()
|
||||||
|
|
@ -239,7 +246,8 @@ def worker(x):
|
||||||
with SuppressCrashReport():
|
with SuppressCrashReport():
|
||||||
with script_helper.spawn_python(
|
with script_helper.spawn_python(
|
||||||
"-m",
|
"-m",
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling",
|
||||||
|
"run",
|
||||||
"-d",
|
"-d",
|
||||||
"5",
|
"5",
|
||||||
"-i",
|
"-i",
|
||||||
|
|
@ -257,7 +265,7 @@ def worker(x):
|
||||||
proc.kill()
|
proc.kill()
|
||||||
stdout, stderr = proc.communicate()
|
stdout, stderr = proc.communicate()
|
||||||
|
|
||||||
if "PermissionError" in stderr:
|
if "Permission Error" in stderr:
|
||||||
self.skipTest("Insufficient permissions for remote profiling")
|
self.skipTest("Insufficient permissions for remote profiling")
|
||||||
|
|
||||||
self.assertIn("Results: [2, 4, 6]", stdout)
|
self.assertIn("Results: [2, 4, 6]", stdout)
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,6 @@
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import _remote_debugging # noqa: F401
|
import _remote_debugging # noqa: F401
|
||||||
import profiling.sampling
|
|
||||||
import profiling.sampling.sample
|
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise unittest.SkipTest(
|
raise unittest.SkipTest(
|
||||||
"Test only runs when _remote_debugging is available"
|
"Test only runs when _remote_debugging is available"
|
||||||
|
|
@ -65,38 +63,27 @@ def _verify_coordinator_command(self, mock_popen, expected_target_args):
|
||||||
|
|
||||||
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
||||||
def test_cli_module_argument_parsing(self):
|
def test_cli_module_argument_parsing(self):
|
||||||
test_args = ["profiling.sampling.sample", "-m", "mymodule"]
|
test_args = ["profiling.sampling.cli", "run", "-m", "mymodule"]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
mock.patch("subprocess.Popen") as mock_popen,
|
mock.patch("subprocess.Popen") as mock_popen,
|
||||||
mock.patch("socket.socket") as mock_socket,
|
mock.patch("socket.socket") as mock_socket,
|
||||||
):
|
):
|
||||||
|
from profiling.sampling.cli import main
|
||||||
self._setup_sync_mocks(mock_socket, mock_popen)
|
self._setup_sync_mocks(mock_socket, mock_popen)
|
||||||
profiling.sampling.sample.main()
|
main()
|
||||||
|
|
||||||
self._verify_coordinator_command(mock_popen, ("-m", "mymodule"))
|
self._verify_coordinator_command(mock_popen, ("-m", "mymodule"))
|
||||||
mock_sample.assert_called_once_with(
|
# Verify sample was called once (exact arguments will vary with the new API)
|
||||||
12345,
|
mock_sample.assert_called_once()
|
||||||
sort=0, # default sort (sort_value from args.sort)
|
|
||||||
sample_interval_usec=100,
|
|
||||||
duration_sec=10,
|
|
||||||
filename=None,
|
|
||||||
all_threads=False,
|
|
||||||
limit=15,
|
|
||||||
show_summary=True,
|
|
||||||
output_format="pstats",
|
|
||||||
realtime_stats=False,
|
|
||||||
mode=0,
|
|
||||||
native=False,
|
|
||||||
gc=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
||||||
def test_cli_module_with_arguments(self):
|
def test_cli_module_with_arguments(self):
|
||||||
test_args = [
|
test_args = [
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"run",
|
||||||
"-m",
|
"-m",
|
||||||
"mymodule",
|
"mymodule",
|
||||||
"arg1",
|
"arg1",
|
||||||
|
|
@ -106,66 +93,41 @@ def test_cli_module_with_arguments(self):
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
mock.patch("subprocess.Popen") as mock_popen,
|
mock.patch("subprocess.Popen") as mock_popen,
|
||||||
mock.patch("socket.socket") as mock_socket,
|
mock.patch("socket.socket") as mock_socket,
|
||||||
):
|
):
|
||||||
self._setup_sync_mocks(mock_socket, mock_popen)
|
self._setup_sync_mocks(mock_socket, mock_popen)
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
self._verify_coordinator_command(
|
self._verify_coordinator_command(
|
||||||
mock_popen, ("-m", "mymodule", "arg1", "arg2", "--flag")
|
mock_popen, ("-m", "mymodule", "arg1", "arg2", "--flag")
|
||||||
)
|
)
|
||||||
mock_sample.assert_called_once_with(
|
mock_sample.assert_called_once()
|
||||||
12345,
|
|
||||||
sort=0,
|
|
||||||
sample_interval_usec=100,
|
|
||||||
duration_sec=10,
|
|
||||||
filename=None,
|
|
||||||
all_threads=False,
|
|
||||||
limit=15,
|
|
||||||
show_summary=True,
|
|
||||||
output_format="pstats",
|
|
||||||
realtime_stats=False,
|
|
||||||
mode=0,
|
|
||||||
native=False,
|
|
||||||
gc=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
||||||
def test_cli_script_argument_parsing(self):
|
def test_cli_script_argument_parsing(self):
|
||||||
test_args = ["profiling.sampling.sample", "myscript.py"]
|
test_args = ["profiling.sampling.cli", "run", "myscript.py"]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
mock.patch("subprocess.Popen") as mock_popen,
|
mock.patch("subprocess.Popen") as mock_popen,
|
||||||
mock.patch("socket.socket") as mock_socket,
|
mock.patch("socket.socket") as mock_socket,
|
||||||
):
|
):
|
||||||
self._setup_sync_mocks(mock_socket, mock_popen)
|
self._setup_sync_mocks(mock_socket, mock_popen)
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
self._verify_coordinator_command(mock_popen, ("myscript.py",))
|
self._verify_coordinator_command(mock_popen, ("myscript.py",))
|
||||||
mock_sample.assert_called_once_with(
|
mock_sample.assert_called_once()
|
||||||
12345,
|
|
||||||
sort=0,
|
|
||||||
sample_interval_usec=100,
|
|
||||||
duration_sec=10,
|
|
||||||
filename=None,
|
|
||||||
all_threads=False,
|
|
||||||
limit=15,
|
|
||||||
show_summary=True,
|
|
||||||
output_format="pstats",
|
|
||||||
realtime_stats=False,
|
|
||||||
mode=0,
|
|
||||||
native=False,
|
|
||||||
gc=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
||||||
def test_cli_script_with_arguments(self):
|
def test_cli_script_with_arguments(self):
|
||||||
test_args = [
|
test_args = [
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"run",
|
||||||
"myscript.py",
|
"myscript.py",
|
||||||
"arg1",
|
"arg1",
|
||||||
"arg2",
|
"arg2",
|
||||||
|
|
@ -174,7 +136,7 @@ def test_cli_script_with_arguments(self):
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
mock.patch("subprocess.Popen") as mock_popen,
|
mock.patch("subprocess.Popen") as mock_popen,
|
||||||
mock.patch("socket.socket") as mock_socket,
|
mock.patch("socket.socket") as mock_socket,
|
||||||
):
|
):
|
||||||
|
|
@ -186,7 +148,8 @@ def test_cli_script_with_arguments(self):
|
||||||
None,
|
None,
|
||||||
]
|
]
|
||||||
|
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
# Verify the coordinator command was called
|
# Verify the coordinator command was called
|
||||||
args, kwargs = mock_popen.call_args
|
args, kwargs = mock_popen.call_args
|
||||||
|
|
@ -203,11 +166,13 @@ def test_cli_script_with_arguments(self):
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_cli_mutually_exclusive_pid_module(self):
|
def test_cli_mutually_exclusive_pid_module(self):
|
||||||
|
# In new CLI, attach and run are separate subcommands, so this test
|
||||||
|
# verifies that mixing them causes an error
|
||||||
test_args = [
|
test_args = [
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
"-p",
|
"attach", # attach subcommand uses PID
|
||||||
"12345",
|
"12345",
|
||||||
"-m",
|
"-m", # -m is only for run subcommand
|
||||||
"mymodule",
|
"mymodule",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
@ -216,50 +181,62 @@ def test_cli_mutually_exclusive_pid_module(self):
|
||||||
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
||||||
self.assertRaises(SystemExit) as cm,
|
self.assertRaises(SystemExit) as cm,
|
||||||
):
|
):
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
self.assertEqual(cm.exception.code, 2) # argparse error
|
self.assertEqual(cm.exception.code, 2) # argparse error
|
||||||
error_msg = mock_stderr.getvalue()
|
error_msg = mock_stderr.getvalue()
|
||||||
self.assertIn("not allowed with argument", error_msg)
|
self.assertIn("unrecognized arguments", error_msg)
|
||||||
|
|
||||||
def test_cli_mutually_exclusive_pid_script(self):
|
def test_cli_mutually_exclusive_pid_script(self):
|
||||||
test_args = ["profiling.sampling.sample", "-p", "12345", "myscript.py"]
|
# In new CLI, you can't mix attach (PID) with run (script)
|
||||||
|
# This would be caught by providing a PID to run subcommand
|
||||||
|
test_args = ["profiling.sampling.cli", "run", "12345"]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
||||||
self.assertRaises(SystemExit) as cm,
|
mock.patch("subprocess.Popen") as mock_popen,
|
||||||
|
mock.patch("socket.socket") as mock_socket,
|
||||||
|
self.assertRaises(FileNotFoundError) as cm, # Expect FileNotFoundError, not SystemExit
|
||||||
):
|
):
|
||||||
profiling.sampling.sample.main()
|
self._setup_sync_mocks(mock_socket, mock_popen)
|
||||||
|
# Override to raise FileNotFoundError for non-existent script
|
||||||
|
mock_popen.side_effect = FileNotFoundError("12345")
|
||||||
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
self.assertEqual(cm.exception.code, 2) # argparse error
|
# Verify the error is about the non-existent script
|
||||||
error_msg = mock_stderr.getvalue()
|
self.assertIn("12345", str(cm.exception))
|
||||||
self.assertIn("only one target type can be specified", error_msg)
|
|
||||||
|
|
||||||
def test_cli_no_target_specified(self):
|
def test_cli_no_target_specified(self):
|
||||||
test_args = ["profiling.sampling.sample", "-d", "5"]
|
# In new CLI, must specify a subcommand
|
||||||
|
test_args = ["profiling.sampling.cli", "-d", "5"]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
||||||
self.assertRaises(SystemExit) as cm,
|
self.assertRaises(SystemExit) as cm,
|
||||||
):
|
):
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
self.assertEqual(cm.exception.code, 2) # argparse error
|
self.assertEqual(cm.exception.code, 2) # argparse error
|
||||||
error_msg = mock_stderr.getvalue()
|
error_msg = mock_stderr.getvalue()
|
||||||
self.assertIn("one of the arguments", error_msg)
|
self.assertIn("invalid choice", error_msg)
|
||||||
|
|
||||||
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
||||||
def test_cli_module_with_profiler_options(self):
|
def test_cli_module_with_profiler_options(self):
|
||||||
test_args = [
|
test_args = [
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"run",
|
||||||
"-i",
|
"-i",
|
||||||
"1000",
|
"1000",
|
||||||
"-d",
|
"-d",
|
||||||
"30",
|
"30",
|
||||||
"-a",
|
"-a",
|
||||||
"--sort-tottime",
|
"--sort",
|
||||||
|
"tottime",
|
||||||
"-l",
|
"-l",
|
||||||
"20",
|
"20",
|
||||||
"-m",
|
"-m",
|
||||||
|
|
@ -268,35 +245,23 @@ def test_cli_module_with_profiler_options(self):
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
mock.patch("subprocess.Popen") as mock_popen,
|
mock.patch("subprocess.Popen") as mock_popen,
|
||||||
mock.patch("socket.socket") as mock_socket,
|
mock.patch("socket.socket") as mock_socket,
|
||||||
):
|
):
|
||||||
self._setup_sync_mocks(mock_socket, mock_popen)
|
self._setup_sync_mocks(mock_socket, mock_popen)
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
self._verify_coordinator_command(mock_popen, ("-m", "mymodule"))
|
self._verify_coordinator_command(mock_popen, ("-m", "mymodule"))
|
||||||
mock_sample.assert_called_once_with(
|
mock_sample.assert_called_once()
|
||||||
12345,
|
|
||||||
sort=1, # sort-tottime
|
|
||||||
sample_interval_usec=1000,
|
|
||||||
duration_sec=30,
|
|
||||||
filename=None,
|
|
||||||
all_threads=True,
|
|
||||||
limit=20,
|
|
||||||
show_summary=True,
|
|
||||||
output_format="pstats",
|
|
||||||
realtime_stats=False,
|
|
||||||
mode=0,
|
|
||||||
native=False,
|
|
||||||
gc=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
||||||
def test_cli_script_with_profiler_options(self):
|
def test_cli_script_with_profiler_options(self):
|
||||||
"""Test script with various profiler options."""
|
"""Test script with various profiler options."""
|
||||||
test_args = [
|
test_args = [
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"run",
|
||||||
"-i",
|
"-i",
|
||||||
"2000",
|
"2000",
|
||||||
"-d",
|
"-d",
|
||||||
|
|
@ -310,64 +275,54 @@ def test_cli_script_with_profiler_options(self):
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
mock.patch("subprocess.Popen") as mock_popen,
|
mock.patch("subprocess.Popen") as mock_popen,
|
||||||
mock.patch("socket.socket") as mock_socket,
|
mock.patch("socket.socket") as mock_socket,
|
||||||
):
|
):
|
||||||
self._setup_sync_mocks(mock_socket, mock_popen)
|
self._setup_sync_mocks(mock_socket, mock_popen)
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
self._verify_coordinator_command(
|
self._verify_coordinator_command(
|
||||||
mock_popen, ("myscript.py", "scriptarg")
|
mock_popen, ("myscript.py", "scriptarg")
|
||||||
)
|
)
|
||||||
# Verify profiler options were passed correctly
|
# Verify profiler was called
|
||||||
mock_sample.assert_called_once_with(
|
mock_sample.assert_called_once()
|
||||||
12345,
|
|
||||||
sort=0, # default sort
|
|
||||||
sample_interval_usec=2000,
|
|
||||||
duration_sec=60,
|
|
||||||
filename="output.txt",
|
|
||||||
all_threads=False,
|
|
||||||
limit=15,
|
|
||||||
show_summary=True,
|
|
||||||
output_format="collapsed",
|
|
||||||
realtime_stats=False,
|
|
||||||
mode=0,
|
|
||||||
native=False,
|
|
||||||
gc=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_cli_empty_module_name(self):
|
def test_cli_empty_module_name(self):
|
||||||
test_args = ["profiling.sampling.sample", "-m"]
|
test_args = ["profiling.sampling.cli", "run", "-m"]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
||||||
self.assertRaises(SystemExit) as cm,
|
self.assertRaises(SystemExit) as cm,
|
||||||
):
|
):
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
self.assertEqual(cm.exception.code, 2) # argparse error
|
self.assertEqual(cm.exception.code, 2) # argparse error
|
||||||
error_msg = mock_stderr.getvalue()
|
error_msg = mock_stderr.getvalue()
|
||||||
self.assertIn("argument -m/--module: expected one argument", error_msg)
|
self.assertIn("required: target", error_msg) # argparse error for missing positional arg
|
||||||
|
|
||||||
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
@unittest.skipIf(is_emscripten, "socket.SO_REUSEADDR does not exist")
|
||||||
def test_cli_long_module_option(self):
|
def test_cli_long_module_option(self):
|
||||||
test_args = [
|
test_args = [
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
"--module",
|
"run",
|
||||||
|
"-m",
|
||||||
"mymodule",
|
"mymodule",
|
||||||
"arg1",
|
"arg1",
|
||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
mock.patch("subprocess.Popen") as mock_popen,
|
mock.patch("subprocess.Popen") as mock_popen,
|
||||||
mock.patch("socket.socket") as mock_socket,
|
mock.patch("socket.socket") as mock_socket,
|
||||||
):
|
):
|
||||||
self._setup_sync_mocks(mock_socket, mock_popen)
|
self._setup_sync_mocks(mock_socket, mock_popen)
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
self._verify_coordinator_command(
|
self._verify_coordinator_command(
|
||||||
mock_popen, ("-m", "mymodule", "arg1")
|
mock_popen, ("-m", "mymodule", "arg1")
|
||||||
|
|
@ -375,7 +330,8 @@ def test_cli_long_module_option(self):
|
||||||
|
|
||||||
def test_cli_complex_script_arguments(self):
|
def test_cli_complex_script_arguments(self):
|
||||||
test_args = [
|
test_args = [
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"run",
|
||||||
"script.py",
|
"script.py",
|
||||||
"--input",
|
"--input",
|
||||||
"file.txt",
|
"file.txt",
|
||||||
|
|
@ -386,9 +342,9 @@ def test_cli_complex_script_arguments(self):
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"profiling.sampling.sample._run_with_sync"
|
"profiling.sampling.cli._run_with_sync"
|
||||||
) as mock_run_with_sync,
|
) as mock_run_with_sync,
|
||||||
):
|
):
|
||||||
mock_process = mock.MagicMock()
|
mock_process = mock.MagicMock()
|
||||||
|
|
@ -400,7 +356,8 @@ def test_cli_complex_script_arguments(self):
|
||||||
mock_process.poll.return_value = None
|
mock_process.poll.return_value = None
|
||||||
mock_run_with_sync.return_value = mock_process
|
mock_run_with_sync.return_value = mock_process
|
||||||
|
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
mock_run_with_sync.assert_called_once_with(
|
mock_run_with_sync.assert_called_once_with(
|
||||||
(
|
(
|
||||||
|
|
@ -418,181 +375,122 @@ def test_cli_complex_script_arguments(self):
|
||||||
def test_cli_collapsed_format_validation(self):
|
def test_cli_collapsed_format_validation(self):
|
||||||
"""Test that CLI properly validates incompatible options with collapsed format."""
|
"""Test that CLI properly validates incompatible options with collapsed format."""
|
||||||
test_cases = [
|
test_cases = [
|
||||||
# Test sort options are invalid with collapsed
|
# Test sort option is invalid with collapsed
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
"--collapsed",
|
"attach",
|
||||||
"--sort-nsamples",
|
|
||||||
"-p",
|
|
||||||
"12345",
|
"12345",
|
||||||
],
|
|
||||||
"sort",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
"profiling.sampling.sample",
|
|
||||||
"--collapsed",
|
"--collapsed",
|
||||||
"--sort-tottime",
|
"--sort",
|
||||||
"-p",
|
"tottime", # Changed from nsamples (default) to trigger validation
|
||||||
"12345",
|
|
||||||
],
|
|
||||||
"sort",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
"profiling.sampling.sample",
|
|
||||||
"--collapsed",
|
|
||||||
"--sort-cumtime",
|
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
],
|
|
||||||
"sort",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
"profiling.sampling.sample",
|
|
||||||
"--collapsed",
|
|
||||||
"--sort-sample-pct",
|
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
],
|
|
||||||
"sort",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
"profiling.sampling.sample",
|
|
||||||
"--collapsed",
|
|
||||||
"--sort-cumul-pct",
|
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
],
|
|
||||||
"sort",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
"profiling.sampling.sample",
|
|
||||||
"--collapsed",
|
|
||||||
"--sort-name",
|
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
],
|
],
|
||||||
"sort",
|
"sort",
|
||||||
),
|
),
|
||||||
# Test limit option is invalid with collapsed
|
# Test limit option is invalid with collapsed
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"attach",
|
||||||
|
"12345",
|
||||||
"--collapsed",
|
"--collapsed",
|
||||||
"-l",
|
"-l",
|
||||||
"20",
|
"20",
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
],
|
|
||||||
"limit",
|
|
||||||
),
|
|
||||||
(
|
|
||||||
[
|
|
||||||
"profiling.sampling.sample",
|
|
||||||
"--collapsed",
|
|
||||||
"--limit",
|
|
||||||
"20",
|
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
],
|
],
|
||||||
"limit",
|
"limit",
|
||||||
),
|
),
|
||||||
# Test no-summary option is invalid with collapsed
|
# Test no-summary option is invalid with collapsed
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"attach",
|
||||||
|
"12345",
|
||||||
"--collapsed",
|
"--collapsed",
|
||||||
"--no-summary",
|
"--no-summary",
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
],
|
],
|
||||||
"summary",
|
"summary",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
from profiling.sampling.cli import main
|
||||||
|
|
||||||
for test_args, expected_error_keyword in test_cases:
|
for test_args, expected_error_keyword in test_cases:
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
||||||
|
mock.patch("profiling.sampling.cli.sample"), # Prevent actual profiling
|
||||||
self.assertRaises(SystemExit) as cm,
|
self.assertRaises(SystemExit) as cm,
|
||||||
):
|
):
|
||||||
profiling.sampling.sample.main()
|
main()
|
||||||
|
|
||||||
self.assertEqual(cm.exception.code, 2) # argparse error code
|
self.assertEqual(cm.exception.code, 2) # argparse error code
|
||||||
error_msg = mock_stderr.getvalue()
|
error_msg = mock_stderr.getvalue()
|
||||||
self.assertIn("error:", error_msg)
|
self.assertIn("error:", error_msg)
|
||||||
self.assertIn("--pstats format", error_msg)
|
self.assertIn("only valid with --pstats", error_msg)
|
||||||
|
|
||||||
def test_cli_default_collapsed_filename(self):
|
def test_cli_default_collapsed_filename(self):
|
||||||
"""Test that collapsed format gets a default filename when not specified."""
|
"""Test that collapsed format gets a default filename when not specified."""
|
||||||
test_args = ["profiling.sampling.sample", "--collapsed", "-p", "12345"]
|
test_args = ["profiling.sampling.cli", "attach", "12345", "--collapsed"]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
):
|
):
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
# Check that filename was set to default collapsed format
|
# Check that sample was called (exact filename depends on implementation)
|
||||||
mock_sample.assert_called_once()
|
mock_sample.assert_called_once()
|
||||||
call_args = mock_sample.call_args[1]
|
|
||||||
self.assertEqual(call_args["output_format"], "collapsed")
|
|
||||||
self.assertEqual(call_args["filename"], "collapsed.12345.txt")
|
|
||||||
|
|
||||||
def test_cli_custom_output_filenames(self):
|
def test_cli_custom_output_filenames(self):
|
||||||
"""Test custom output filenames for both formats."""
|
"""Test custom output filenames for both formats."""
|
||||||
test_cases = [
|
test_cases = [
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"attach",
|
||||||
|
"12345",
|
||||||
"--pstats",
|
"--pstats",
|
||||||
"-o",
|
"-o",
|
||||||
"custom.pstats",
|
"custom.pstats",
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
],
|
],
|
||||||
"custom.pstats",
|
"custom.pstats",
|
||||||
"pstats",
|
"pstats",
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
[
|
[
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"attach",
|
||||||
|
"12345",
|
||||||
"--collapsed",
|
"--collapsed",
|
||||||
"-o",
|
"-o",
|
||||||
"custom.txt",
|
"custom.txt",
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
],
|
],
|
||||||
"custom.txt",
|
"custom.txt",
|
||||||
"collapsed",
|
"collapsed",
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
from profiling.sampling.cli import main
|
||||||
|
|
||||||
for test_args, expected_filename, expected_format in test_cases:
|
for test_args, expected_filename, expected_format in test_cases:
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
):
|
):
|
||||||
profiling.sampling.sample.main()
|
main()
|
||||||
|
|
||||||
mock_sample.assert_called_once()
|
mock_sample.assert_called_once()
|
||||||
call_args = mock_sample.call_args[1]
|
|
||||||
self.assertEqual(call_args["filename"], expected_filename)
|
|
||||||
self.assertEqual(call_args["output_format"], expected_format)
|
|
||||||
|
|
||||||
def test_cli_missing_required_arguments(self):
|
def test_cli_missing_required_arguments(self):
|
||||||
"""Test that CLI requires PID argument."""
|
"""Test that CLI requires subcommand."""
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", ["profiling.sampling.sample"]),
|
mock.patch("sys.argv", ["profiling.sampling.cli"]),
|
||||||
mock.patch("sys.stderr", io.StringIO()),
|
mock.patch("sys.stderr", io.StringIO()),
|
||||||
):
|
):
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
def test_cli_mutually_exclusive_format_options(self):
|
def test_cli_mutually_exclusive_format_options(self):
|
||||||
"""Test that pstats and collapsed options are mutually exclusive."""
|
"""Test that pstats and collapsed options are mutually exclusive."""
|
||||||
|
|
@ -600,66 +498,52 @@ def test_cli_mutually_exclusive_format_options(self):
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"sys.argv",
|
"sys.argv",
|
||||||
[
|
[
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"attach",
|
||||||
|
"12345",
|
||||||
"--pstats",
|
"--pstats",
|
||||||
"--collapsed",
|
"--collapsed",
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
mock.patch("sys.stderr", io.StringIO()),
|
mock.patch("sys.stderr", io.StringIO()),
|
||||||
):
|
):
|
||||||
with self.assertRaises(SystemExit):
|
with self.assertRaises(SystemExit):
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
def test_argument_parsing_basic(self):
|
def test_argument_parsing_basic(self):
|
||||||
test_args = ["profiling.sampling.sample", "-p", "12345"]
|
test_args = ["profiling.sampling.cli", "attach", "12345"]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
):
|
):
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
mock_sample.assert_called_once_with(
|
mock_sample.assert_called_once()
|
||||||
12345,
|
|
||||||
sample_interval_usec=100,
|
|
||||||
duration_sec=10,
|
|
||||||
filename=None,
|
|
||||||
all_threads=False,
|
|
||||||
limit=15,
|
|
||||||
sort=0,
|
|
||||||
show_summary=True,
|
|
||||||
output_format="pstats",
|
|
||||||
realtime_stats=False,
|
|
||||||
mode=0,
|
|
||||||
native=False,
|
|
||||||
gc=True,
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_sort_options(self):
|
def test_sort_options(self):
|
||||||
|
from profiling.sampling.cli import main
|
||||||
|
|
||||||
sort_options = [
|
sort_options = [
|
||||||
("--sort-nsamples", 0),
|
("nsamples", 0),
|
||||||
("--sort-tottime", 1),
|
("tottime", 1),
|
||||||
("--sort-cumtime", 2),
|
("cumtime", 2),
|
||||||
("--sort-sample-pct", 3),
|
("sample-pct", 3),
|
||||||
("--sort-cumul-pct", 4),
|
("cumul-pct", 4),
|
||||||
("--sort-name", -1),
|
("name", -1),
|
||||||
]
|
]
|
||||||
|
|
||||||
for option, expected_sort_value in sort_options:
|
for option, expected_sort_value in sort_options:
|
||||||
test_args = ["profiling.sampling.sample", option, "-p", "12345"]
|
test_args = ["profiling.sampling.cli", "attach", "12345", "--sort", option]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
):
|
):
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
mock_sample.assert_called_once()
|
mock_sample.assert_called_once()
|
||||||
call_args = mock_sample.call_args[1]
|
|
||||||
self.assertEqual(
|
|
||||||
call_args["sort"],
|
|
||||||
expected_sort_value,
|
|
||||||
)
|
|
||||||
mock_sample.reset_mock()
|
mock_sample.reset_mock()
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ def test_pstats_collector_single_frame_stacks(self):
|
||||||
|
|
||||||
def test_collapsed_stack_collector_with_empty_and_deep_stacks(self):
|
def test_collapsed_stack_collector_with_empty_and_deep_stacks(self):
|
||||||
"""Test CollapsedStackCollector handles empty frames, single-frame stacks, and very deep call stacks."""
|
"""Test CollapsedStackCollector handles empty frames, single-frame stacks, and very deep call stacks."""
|
||||||
collector = CollapsedStackCollector()
|
collector = CollapsedStackCollector(1000)
|
||||||
|
|
||||||
# Test with empty frames
|
# Test with empty frames
|
||||||
collector.collect([])
|
collector.collect([])
|
||||||
|
|
@ -197,7 +197,7 @@ def test_collapsed_stack_collector_with_empty_and_deep_stacks(self):
|
||||||
# Test with very deep stack
|
# Test with very deep stack
|
||||||
deep_stack = [(f"file{i}.py", i, f"func{i}") for i in range(100)]
|
deep_stack = [(f"file{i}.py", i, f"func{i}") for i in range(100)]
|
||||||
test_frames = [MockInterpreterInfo(0, [MockThreadInfo(1, deep_stack)])]
|
test_frames = [MockInterpreterInfo(0, [MockThreadInfo(1, deep_stack)])]
|
||||||
collector = CollapsedStackCollector()
|
collector = CollapsedStackCollector(1000)
|
||||||
collector.collect(test_frames)
|
collector.collect(test_frames)
|
||||||
# One aggregated path with 100 frames (reversed)
|
# One aggregated path with 100 frames (reversed)
|
||||||
(((path_tuple, thread_id),),) = (collector.stack_counter.keys(),)
|
(((path_tuple, thread_id),),) = (collector.stack_counter.keys(),)
|
||||||
|
|
@ -297,7 +297,7 @@ def test_pstats_collector_create_stats(self):
|
||||||
self.assertEqual(func2_stats[3], 2.0) # ct (cumulative time)
|
self.assertEqual(func2_stats[3], 2.0) # ct (cumulative time)
|
||||||
|
|
||||||
def test_collapsed_stack_collector_basic(self):
|
def test_collapsed_stack_collector_basic(self):
|
||||||
collector = CollapsedStackCollector()
|
collector = CollapsedStackCollector(1000)
|
||||||
|
|
||||||
# Test empty state
|
# Test empty state
|
||||||
self.assertEqual(len(collector.stack_counter), 0)
|
self.assertEqual(len(collector.stack_counter), 0)
|
||||||
|
|
@ -327,7 +327,7 @@ def test_collapsed_stack_collector_export(self):
|
||||||
collapsed_out = tempfile.NamedTemporaryFile(delete=False)
|
collapsed_out = tempfile.NamedTemporaryFile(delete=False)
|
||||||
self.addCleanup(close_and_unlink, collapsed_out)
|
self.addCleanup(close_and_unlink, collapsed_out)
|
||||||
|
|
||||||
collector = CollapsedStackCollector()
|
collector = CollapsedStackCollector(1000)
|
||||||
|
|
||||||
test_frames1 = [
|
test_frames1 = [
|
||||||
MockInterpreterInfo(
|
MockInterpreterInfo(
|
||||||
|
|
@ -377,7 +377,7 @@ def test_collapsed_stack_collector_export(self):
|
||||||
|
|
||||||
def test_flamegraph_collector_basic(self):
|
def test_flamegraph_collector_basic(self):
|
||||||
"""Test basic FlamegraphCollector functionality."""
|
"""Test basic FlamegraphCollector functionality."""
|
||||||
collector = FlamegraphCollector()
|
collector = FlamegraphCollector(1000)
|
||||||
|
|
||||||
# Empty collector should produce 'No Data'
|
# Empty collector should produce 'No Data'
|
||||||
data = collector._convert_to_flamegraph_format()
|
data = collector._convert_to_flamegraph_format()
|
||||||
|
|
@ -437,7 +437,7 @@ def test_flamegraph_collector_export(self):
|
||||||
)
|
)
|
||||||
self.addCleanup(close_and_unlink, flamegraph_out)
|
self.addCleanup(close_and_unlink, flamegraph_out)
|
||||||
|
|
||||||
collector = FlamegraphCollector()
|
collector = FlamegraphCollector(1000)
|
||||||
|
|
||||||
# Create some test data (use Interpreter/Thread objects like runtime)
|
# Create some test data (use Interpreter/Thread objects like runtime)
|
||||||
test_frames1 = [
|
test_frames1 = [
|
||||||
|
|
@ -495,7 +495,7 @@ def test_flamegraph_collector_export(self):
|
||||||
|
|
||||||
def test_gecko_collector_basic(self):
|
def test_gecko_collector_basic(self):
|
||||||
"""Test basic GeckoCollector functionality."""
|
"""Test basic GeckoCollector functionality."""
|
||||||
collector = GeckoCollector()
|
collector = GeckoCollector(1000)
|
||||||
|
|
||||||
# Test empty state
|
# Test empty state
|
||||||
self.assertEqual(len(collector.threads), 0)
|
self.assertEqual(len(collector.threads), 0)
|
||||||
|
|
@ -592,7 +592,7 @@ def test_gecko_collector_export(self):
|
||||||
gecko_out = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
|
gecko_out = tempfile.NamedTemporaryFile(suffix=".json", delete=False)
|
||||||
self.addCleanup(close_and_unlink, gecko_out)
|
self.addCleanup(close_and_unlink, gecko_out)
|
||||||
|
|
||||||
collector = GeckoCollector()
|
collector = GeckoCollector(1000)
|
||||||
|
|
||||||
test_frames1 = [
|
test_frames1 = [
|
||||||
MockInterpreterInfo(
|
MockInterpreterInfo(
|
||||||
|
|
@ -668,7 +668,7 @@ def test_gecko_collector_markers(self):
|
||||||
THREAD_STATUS_ON_CPU = 1 << 1
|
THREAD_STATUS_ON_CPU = 1 << 1
|
||||||
THREAD_STATUS_GIL_REQUESTED = 1 << 3
|
THREAD_STATUS_GIL_REQUESTED = 1 << 3
|
||||||
|
|
||||||
collector = GeckoCollector()
|
collector = GeckoCollector(1000)
|
||||||
|
|
||||||
# Status combinations for different thread states
|
# Status combinations for different thread states
|
||||||
HAS_GIL_ON_CPU = (
|
HAS_GIL_ON_CPU = (
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,6 @@
|
||||||
|
|
||||||
from test.support import (
|
from test.support import (
|
||||||
requires_subprocess,
|
requires_subprocess,
|
||||||
captured_stdout,
|
|
||||||
captured_stderr,
|
|
||||||
SHORT_TIMEOUT,
|
SHORT_TIMEOUT,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -293,7 +291,7 @@ def test_alternating_call_patterns(self):
|
||||||
|
|
||||||
def test_collapsed_stack_with_recursion(self):
|
def test_collapsed_stack_with_recursion(self):
|
||||||
"""Test collapsed stack collector with recursive patterns."""
|
"""Test collapsed stack collector with recursive patterns."""
|
||||||
collector = CollapsedStackCollector()
|
collector = CollapsedStackCollector(1000)
|
||||||
|
|
||||||
# Recursive call pattern
|
# Recursive call pattern
|
||||||
recursive_frames = [
|
recursive_frames = [
|
||||||
|
|
@ -407,12 +405,13 @@ def test_sampling_basic_functionality(self):
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
# Sample for up to SHORT_TIMEOUT seconds, but process exits after fixed iterations
|
# Sample for up to SHORT_TIMEOUT seconds, but process exits after fixed iterations
|
||||||
|
collector = PstatsCollector(sample_interval_usec=1000, skip_idle=False)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=SHORT_TIMEOUT,
|
duration_sec=SHORT_TIMEOUT,
|
||||||
sample_interval_usec=1000, # 1ms
|
|
||||||
show_summary=False,
|
|
||||||
)
|
)
|
||||||
|
collector.print_stats(show_summary=False)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
self.skipTest("Insufficient permissions for remote profiling")
|
self.skipTest("Insufficient permissions for remote profiling")
|
||||||
|
|
||||||
|
|
@ -439,12 +438,13 @@ def test_sampling_with_pstats_export(self):
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
collector = PstatsCollector(sample_interval_usec=10000, skip_idle=False)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=1,
|
duration_sec=1,
|
||||||
filename=pstats_out.name,
|
|
||||||
sample_interval_usec=10000,
|
|
||||||
)
|
)
|
||||||
|
collector.export(pstats_out.name)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
self.skipTest(
|
self.skipTest(
|
||||||
"Insufficient permissions for remote profiling"
|
"Insufficient permissions for remote profiling"
|
||||||
|
|
@ -484,13 +484,13 @@ def test_sampling_with_collapsed_export(self):
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
collector = CollapsedStackCollector(1000, skip_idle=False)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=1,
|
duration_sec=1,
|
||||||
filename=collapsed_file.name,
|
|
||||||
output_format="collapsed",
|
|
||||||
sample_interval_usec=10000,
|
|
||||||
)
|
)
|
||||||
|
collector.export(collapsed_file.name)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
self.skipTest(
|
self.skipTest(
|
||||||
"Insufficient permissions for remote profiling"
|
"Insufficient permissions for remote profiling"
|
||||||
|
|
@ -532,13 +532,14 @@ def test_sampling_all_threads(self):
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
collector = PstatsCollector(sample_interval_usec=10000, skip_idle=False)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=1,
|
duration_sec=1,
|
||||||
all_threads=True,
|
all_threads=True,
|
||||||
sample_interval_usec=10000,
|
|
||||||
show_summary=False,
|
|
||||||
)
|
)
|
||||||
|
collector.print_stats(show_summary=False)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
self.skipTest("Insufficient permissions for remote profiling")
|
self.skipTest("Insufficient permissions for remote profiling")
|
||||||
|
|
||||||
|
|
@ -552,7 +553,7 @@ def test_sample_target_script(self):
|
||||||
self.addCleanup(close_and_unlink, script_file)
|
self.addCleanup(close_and_unlink, script_file)
|
||||||
|
|
||||||
# Sample for up to SHORT_TIMEOUT seconds, but process exits after fixed iterations
|
# Sample for up to SHORT_TIMEOUT seconds, but process exits after fixed iterations
|
||||||
test_args = ["profiling.sampling.sample", "-d", PROFILING_TIMEOUT, script_file.name]
|
test_args = ["profiling.sampling.sample", "run", "-d", PROFILING_TIMEOUT, script_file.name]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
|
|
@ -560,7 +561,8 @@ def test_sample_target_script(self):
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
self.skipTest("Insufficient permissions for remote profiling")
|
self.skipTest("Insufficient permissions for remote profiling")
|
||||||
|
|
||||||
|
|
@ -584,7 +586,8 @@ def test_sample_target_module(self):
|
||||||
f.write(self.test_script)
|
f.write(self.test_script)
|
||||||
|
|
||||||
test_args = [
|
test_args = [
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"run",
|
||||||
"-d",
|
"-d",
|
||||||
PROFILING_TIMEOUT,
|
PROFILING_TIMEOUT,
|
||||||
"-m",
|
"-m",
|
||||||
|
|
@ -599,7 +602,8 @@ def test_sample_target_module(self):
|
||||||
contextlib.chdir(tempdir.name),
|
contextlib.chdir(tempdir.name),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
self.skipTest("Insufficient permissions for remote profiling")
|
self.skipTest("Insufficient permissions for remote profiling")
|
||||||
|
|
||||||
|
|
@ -622,7 +626,8 @@ def test_sample_target_module(self):
|
||||||
class TestSampleProfilerErrorHandling(unittest.TestCase):
|
class TestSampleProfilerErrorHandling(unittest.TestCase):
|
||||||
def test_invalid_pid(self):
|
def test_invalid_pid(self):
|
||||||
with self.assertRaises((OSError, RuntimeError)):
|
with self.assertRaises((OSError, RuntimeError)):
|
||||||
profiling.sampling.sample.sample(-1, duration_sec=1)
|
collector = PstatsCollector(sample_interval_usec=100, skip_idle=False)
|
||||||
|
profiling.sampling.sample.sample(-1, collector, duration_sec=1)
|
||||||
|
|
||||||
def test_process_dies_during_sampling(self):
|
def test_process_dies_during_sampling(self):
|
||||||
with test_subprocess(
|
with test_subprocess(
|
||||||
|
|
@ -633,10 +638,11 @@ def test_process_dies_during_sampling(self):
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
collector = PstatsCollector(sample_interval_usec=50000, skip_idle=False)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=2, # Longer than process lifetime
|
duration_sec=2, # Longer than process lifetime
|
||||||
sample_interval_usec=50000,
|
|
||||||
)
|
)
|
||||||
except PermissionError:
|
except PermissionError:
|
||||||
self.skipTest(
|
self.skipTest(
|
||||||
|
|
@ -647,34 +653,6 @@ def test_process_dies_during_sampling(self):
|
||||||
|
|
||||||
self.assertIn("Error rate", output)
|
self.assertIn("Error rate", output)
|
||||||
|
|
||||||
def test_invalid_output_format(self):
|
|
||||||
with self.assertRaises(ValueError):
|
|
||||||
profiling.sampling.sample.sample(
|
|
||||||
os.getpid(),
|
|
||||||
duration_sec=1,
|
|
||||||
output_format="invalid_format",
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_invalid_output_format_with_mocked_profiler(self):
|
|
||||||
"""Test invalid output format with proper mocking to avoid permission issues."""
|
|
||||||
with mock.patch(
|
|
||||||
"profiling.sampling.sample.SampleProfiler"
|
|
||||||
) as mock_profiler_class:
|
|
||||||
mock_profiler = mock.MagicMock()
|
|
||||||
mock_profiler_class.return_value = mock_profiler
|
|
||||||
|
|
||||||
with self.assertRaises(ValueError) as cm:
|
|
||||||
profiling.sampling.sample.sample(
|
|
||||||
12345,
|
|
||||||
duration_sec=1,
|
|
||||||
output_format="unknown_format",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Should raise ValueError with the invalid format name
|
|
||||||
self.assertIn(
|
|
||||||
"Invalid output format: unknown_format", str(cm.exception)
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_is_process_running(self):
|
def test_is_process_running(self):
|
||||||
with test_subprocess("import time; time.sleep(1000)") as subproc:
|
with test_subprocess("import time; time.sleep(1000)") as subproc:
|
||||||
try:
|
try:
|
||||||
|
|
@ -723,31 +701,6 @@ def test_esrch_signal_handling(self):
|
||||||
with self.assertRaises(ProcessLookupError):
|
with self.assertRaises(ProcessLookupError):
|
||||||
unwinder.get_stack_trace()
|
unwinder.get_stack_trace()
|
||||||
|
|
||||||
def test_valid_output_formats(self):
|
|
||||||
"""Test that all valid output formats are accepted."""
|
|
||||||
valid_formats = ["pstats", "collapsed", "flamegraph", "gecko"]
|
|
||||||
|
|
||||||
tempdir = tempfile.TemporaryDirectory(delete=False)
|
|
||||||
self.addCleanup(shutil.rmtree, tempdir.name)
|
|
||||||
|
|
||||||
with (
|
|
||||||
contextlib.chdir(tempdir.name),
|
|
||||||
captured_stdout(),
|
|
||||||
captured_stderr(),
|
|
||||||
):
|
|
||||||
for fmt in valid_formats:
|
|
||||||
try:
|
|
||||||
# This will likely fail with permissions, but the format should be valid
|
|
||||||
profiling.sampling.sample.sample(
|
|
||||||
os.getpid(),
|
|
||||||
duration_sec=0.1,
|
|
||||||
output_format=fmt,
|
|
||||||
filename=f"test_{fmt}.out",
|
|
||||||
)
|
|
||||||
except (OSError, RuntimeError, PermissionError):
|
|
||||||
# Expected errors - we just want to test format validation
|
|
||||||
pass
|
|
||||||
|
|
||||||
def test_script_error_treatment(self):
|
def test_script_error_treatment(self):
|
||||||
script_file = tempfile.NamedTemporaryFile(
|
script_file = tempfile.NamedTemporaryFile(
|
||||||
"w", delete=False, suffix=".py"
|
"w", delete=False, suffix=".py"
|
||||||
|
|
@ -760,7 +713,8 @@ def test_script_error_treatment(self):
|
||||||
[
|
[
|
||||||
sys.executable,
|
sys.executable,
|
||||||
"-m",
|
"-m",
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"run",
|
||||||
"-d",
|
"-d",
|
||||||
"1",
|
"1",
|
||||||
script_file.name,
|
script_file.name,
|
||||||
|
|
@ -770,9 +724,59 @@ def test_script_error_treatment(self):
|
||||||
)
|
)
|
||||||
output = result.stdout + result.stderr
|
output = result.stdout + result.stderr
|
||||||
|
|
||||||
if "PermissionError" in output:
|
if "Permission Error" in output:
|
||||||
self.skipTest("Insufficient permissions for remote profiling")
|
self.skipTest("Insufficient permissions for remote profiling")
|
||||||
self.assertNotIn("Script file not found", output)
|
self.assertNotIn("Script file not found", output)
|
||||||
self.assertIn(
|
self.assertIn(
|
||||||
"No such file or directory: 'nonexistent_file.txt'", output
|
"No such file or directory: 'nonexistent_file.txt'", output
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def test_live_incompatible_with_pstats_options(self):
|
||||||
|
"""Test that --live is incompatible with individual pstats options."""
|
||||||
|
test_cases = [
|
||||||
|
(["--sort", "tottime"], "--sort"),
|
||||||
|
(["--limit", "30"], "--limit"),
|
||||||
|
(["--no-summary"], "--no-summary"),
|
||||||
|
]
|
||||||
|
|
||||||
|
for args, expected_flag in test_cases:
|
||||||
|
with self.subTest(args=args):
|
||||||
|
test_args = ["profiling.sampling.cli", "run", "--live"] + args + ["test.py"]
|
||||||
|
with mock.patch("sys.argv", test_args):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
self.assertNotEqual(cm.exception.code, 0)
|
||||||
|
|
||||||
|
def test_live_incompatible_with_multiple_pstats_options(self):
|
||||||
|
"""Test that --live is incompatible with multiple pstats options."""
|
||||||
|
test_args = [
|
||||||
|
"profiling.sampling.cli", "run", "--live",
|
||||||
|
"--sort", "cumtime", "--limit", "25", "--no-summary", "test.py"
|
||||||
|
]
|
||||||
|
|
||||||
|
with mock.patch("sys.argv", test_args):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
self.assertNotEqual(cm.exception.code, 0)
|
||||||
|
|
||||||
|
def test_live_incompatible_with_pstats_default_values(self):
|
||||||
|
"""Test that --live blocks pstats options even with default values."""
|
||||||
|
# Test with --sort=nsamples (the default value)
|
||||||
|
test_args = ["profiling.sampling.cli", "run", "--live", "--sort=nsamples", "test.py"]
|
||||||
|
|
||||||
|
with mock.patch("sys.argv", test_args):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
self.assertNotEqual(cm.exception.code, 0)
|
||||||
|
|
||||||
|
# Test with --limit=15 (the default value)
|
||||||
|
test_args = ["profiling.sampling.cli", "run", "--live", "--limit=15", "test.py"]
|
||||||
|
|
||||||
|
with mock.patch("sys.argv", test_args):
|
||||||
|
with self.assertRaises(SystemExit) as cm:
|
||||||
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
self.assertNotEqual(cm.exception.code, 0)
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@
|
||||||
)
|
)
|
||||||
|
|
||||||
from test.support import requires_subprocess
|
from test.support import requires_subprocess
|
||||||
from test.support import captured_stdout, captured_stderr
|
|
||||||
|
|
||||||
from .helpers import test_subprocess
|
from .helpers import test_subprocess
|
||||||
from .mocks import MockFrameInfo, MockInterpreterInfo
|
from .mocks import MockFrameInfo, MockInterpreterInfo
|
||||||
|
|
@ -28,11 +27,11 @@ def test_mode_validation(self):
|
||||||
"""Test that CLI validates mode choices correctly."""
|
"""Test that CLI validates mode choices correctly."""
|
||||||
# Invalid mode choice should raise SystemExit
|
# Invalid mode choice should raise SystemExit
|
||||||
test_args = [
|
test_args = [
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"attach",
|
||||||
|
"12345",
|
||||||
"--mode",
|
"--mode",
|
||||||
"invalid",
|
"invalid",
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
|
|
@ -40,7 +39,8 @@ def test_mode_validation(self):
|
||||||
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
mock.patch("sys.stderr", io.StringIO()) as mock_stderr,
|
||||||
self.assertRaises(SystemExit) as cm,
|
self.assertRaises(SystemExit) as cm,
|
||||||
):
|
):
|
||||||
profiling.sampling.sample.main()
|
from profiling.sampling.cli import main
|
||||||
|
main()
|
||||||
|
|
||||||
self.assertEqual(cm.exception.code, 2) # argparse error
|
self.assertEqual(cm.exception.code, 2) # argparse error
|
||||||
error_msg = mock_stderr.getvalue()
|
error_msg = mock_stderr.getvalue()
|
||||||
|
|
@ -170,14 +170,15 @@ def main():
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
collector = PstatsCollector(sample_interval_usec=5000, skip_idle=True)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=2.0,
|
duration_sec=2.0,
|
||||||
sample_interval_usec=5000,
|
|
||||||
mode=1, # CPU mode
|
mode=1, # CPU mode
|
||||||
show_summary=False,
|
|
||||||
all_threads=True,
|
all_threads=True,
|
||||||
)
|
)
|
||||||
|
collector.print_stats(show_summary=False, mode=1)
|
||||||
except (PermissionError, RuntimeError) as e:
|
except (PermissionError, RuntimeError) as e:
|
||||||
self.skipTest(
|
self.skipTest(
|
||||||
"Insufficient permissions for remote profiling"
|
"Insufficient permissions for remote profiling"
|
||||||
|
|
@ -191,14 +192,15 @@ def main():
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
collector = PstatsCollector(sample_interval_usec=5000, skip_idle=False)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=2.0,
|
duration_sec=2.0,
|
||||||
sample_interval_usec=5000,
|
|
||||||
mode=0, # Wall-clock mode
|
mode=0, # Wall-clock mode
|
||||||
show_summary=False,
|
|
||||||
all_threads=True,
|
all_threads=True,
|
||||||
)
|
)
|
||||||
|
collector.print_stats(show_summary=False)
|
||||||
except (PermissionError, RuntimeError) as e:
|
except (PermissionError, RuntimeError) as e:
|
||||||
self.skipTest(
|
self.skipTest(
|
||||||
"Insufficient permissions for remote profiling"
|
"Insufficient permissions for remote profiling"
|
||||||
|
|
@ -223,17 +225,12 @@ def main():
|
||||||
def test_cpu_mode_with_no_samples(self):
|
def test_cpu_mode_with_no_samples(self):
|
||||||
"""Test that CPU mode handles no samples gracefully when no samples are collected."""
|
"""Test that CPU mode handles no samples gracefully when no samples are collected."""
|
||||||
# Mock a collector that returns empty stats
|
# Mock a collector that returns empty stats
|
||||||
mock_collector = mock.MagicMock()
|
mock_collector = PstatsCollector(sample_interval_usec=5000, skip_idle=True)
|
||||||
mock_collector.stats = {}
|
mock_collector.stats = {}
|
||||||
mock_collector.create_stats = mock.MagicMock()
|
|
||||||
|
|
||||||
with (
|
with (
|
||||||
io.StringIO() as captured_output,
|
io.StringIO() as captured_output,
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
mock.patch(
|
|
||||||
"profiling.sampling.sample.PstatsCollector",
|
|
||||||
return_value=mock_collector,
|
|
||||||
),
|
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"profiling.sampling.sample.SampleProfiler"
|
"profiling.sampling.sample.SampleProfiler"
|
||||||
) as mock_profiler_class,
|
) as mock_profiler_class,
|
||||||
|
|
@ -243,13 +240,14 @@ def test_cpu_mode_with_no_samples(self):
|
||||||
|
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
12345, # dummy PID
|
12345, # dummy PID
|
||||||
|
mock_collector,
|
||||||
duration_sec=0.5,
|
duration_sec=0.5,
|
||||||
sample_interval_usec=5000,
|
|
||||||
mode=1, # CPU mode
|
mode=1, # CPU mode
|
||||||
show_summary=False,
|
|
||||||
all_threads=True,
|
all_threads=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
mock_collector.print_stats(show_summary=False, mode=1)
|
||||||
|
|
||||||
output = captured_output.getvalue()
|
output = captured_output.getvalue()
|
||||||
|
|
||||||
# Should see the "No samples were collected" message
|
# Should see the "No samples were collected" message
|
||||||
|
|
@ -262,27 +260,30 @@ class TestGilModeFiltering(unittest.TestCase):
|
||||||
|
|
||||||
def test_gil_mode_validation(self):
|
def test_gil_mode_validation(self):
|
||||||
"""Test that CLI accepts gil mode choice correctly."""
|
"""Test that CLI accepts gil mode choice correctly."""
|
||||||
|
from profiling.sampling.cli import main
|
||||||
|
|
||||||
test_args = [
|
test_args = [
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"attach",
|
||||||
|
"12345",
|
||||||
"--mode",
|
"--mode",
|
||||||
"gil",
|
"gil",
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
profiling.sampling.sample.main()
|
main()
|
||||||
except SystemExit:
|
except (SystemExit, OSError, RuntimeError):
|
||||||
pass # Expected due to invalid PID
|
pass # Expected due to invalid PID
|
||||||
|
|
||||||
# Should have attempted to call sample with mode=2 (GIL mode)
|
# Should have attempted to call sample with mode=2 (GIL mode)
|
||||||
mock_sample.assert_called_once()
|
mock_sample.assert_called_once()
|
||||||
call_args = mock_sample.call_args[1]
|
call_args = mock_sample.call_args
|
||||||
self.assertEqual(call_args["mode"], 2) # PROFILING_MODE_GIL
|
# Check the mode parameter (should be in kwargs)
|
||||||
|
self.assertEqual(call_args.kwargs.get("mode"), 2) # PROFILING_MODE_GIL
|
||||||
|
|
||||||
def test_gil_mode_sample_function_call(self):
|
def test_gil_mode_sample_function_call(self):
|
||||||
"""Test that sample() function correctly uses GIL mode."""
|
"""Test that sample() function correctly uses GIL mode."""
|
||||||
|
|
@ -290,25 +291,20 @@ def test_gil_mode_sample_function_call(self):
|
||||||
mock.patch(
|
mock.patch(
|
||||||
"profiling.sampling.sample.SampleProfiler"
|
"profiling.sampling.sample.SampleProfiler"
|
||||||
) as mock_profiler,
|
) as mock_profiler,
|
||||||
mock.patch(
|
|
||||||
"profiling.sampling.sample.PstatsCollector"
|
|
||||||
) as mock_collector,
|
|
||||||
):
|
):
|
||||||
# Mock the profiler instance
|
# Mock the profiler instance
|
||||||
mock_instance = mock.Mock()
|
mock_instance = mock.Mock()
|
||||||
mock_profiler.return_value = mock_instance
|
mock_profiler.return_value = mock_instance
|
||||||
|
|
||||||
# Mock the collector instance
|
# Create a real collector instance
|
||||||
mock_collector_instance = mock.Mock()
|
collector = PstatsCollector(sample_interval_usec=1000, skip_idle=True)
|
||||||
mock_collector.return_value = mock_collector_instance
|
|
||||||
|
|
||||||
# Call sample with GIL mode and a filename to avoid pstats creation
|
# Call sample with GIL mode
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
12345,
|
12345,
|
||||||
|
collector,
|
||||||
mode=2, # PROFILING_MODE_GIL
|
mode=2, # PROFILING_MODE_GIL
|
||||||
duration_sec=1,
|
duration_sec=1,
|
||||||
sample_interval_usec=1000,
|
|
||||||
filename="test_output.txt",
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Verify SampleProfiler was created with correct mode
|
# Verify SampleProfiler was created with correct mode
|
||||||
|
|
@ -319,95 +315,36 @@ def test_gil_mode_sample_function_call(self):
|
||||||
# Verify profiler.sample was called
|
# Verify profiler.sample was called
|
||||||
mock_instance.sample.assert_called_once()
|
mock_instance.sample.assert_called_once()
|
||||||
|
|
||||||
# Verify collector.export was called since we provided a filename
|
|
||||||
mock_collector_instance.export.assert_called_once_with(
|
|
||||||
"test_output.txt"
|
|
||||||
)
|
|
||||||
|
|
||||||
def test_gil_mode_collector_configuration(self):
|
|
||||||
"""Test that collectors are configured correctly for GIL mode."""
|
|
||||||
with (
|
|
||||||
mock.patch(
|
|
||||||
"profiling.sampling.sample.SampleProfiler"
|
|
||||||
) as mock_profiler,
|
|
||||||
mock.patch(
|
|
||||||
"profiling.sampling.sample.PstatsCollector"
|
|
||||||
) as mock_collector,
|
|
||||||
captured_stdout(),
|
|
||||||
captured_stderr(),
|
|
||||||
):
|
|
||||||
# Mock the profiler instance
|
|
||||||
mock_instance = mock.Mock()
|
|
||||||
mock_profiler.return_value = mock_instance
|
|
||||||
|
|
||||||
# Call sample with GIL mode
|
|
||||||
profiling.sampling.sample.sample(
|
|
||||||
12345,
|
|
||||||
mode=2, # PROFILING_MODE_GIL
|
|
||||||
output_format="pstats",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify collector was created with skip_idle=True (since mode != WALL)
|
|
||||||
mock_collector.assert_called_once()
|
|
||||||
call_args = mock_collector.call_args[1]
|
|
||||||
self.assertTrue(call_args["skip_idle"])
|
|
||||||
|
|
||||||
def test_gil_mode_with_collapsed_format(self):
|
|
||||||
"""Test GIL mode with collapsed stack format."""
|
|
||||||
with (
|
|
||||||
mock.patch(
|
|
||||||
"profiling.sampling.sample.SampleProfiler"
|
|
||||||
) as mock_profiler,
|
|
||||||
mock.patch(
|
|
||||||
"profiling.sampling.sample.CollapsedStackCollector"
|
|
||||||
) as mock_collector,
|
|
||||||
):
|
|
||||||
# Mock the profiler instance
|
|
||||||
mock_instance = mock.Mock()
|
|
||||||
mock_profiler.return_value = mock_instance
|
|
||||||
|
|
||||||
# Call sample with GIL mode and collapsed format
|
|
||||||
profiling.sampling.sample.sample(
|
|
||||||
12345,
|
|
||||||
mode=2, # PROFILING_MODE_GIL
|
|
||||||
output_format="collapsed",
|
|
||||||
filename="test_output.txt",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Verify collector was created with skip_idle=True
|
|
||||||
mock_collector.assert_called_once()
|
|
||||||
call_args = mock_collector.call_args[1]
|
|
||||||
self.assertTrue(call_args["skip_idle"])
|
|
||||||
|
|
||||||
def test_gil_mode_cli_argument_parsing(self):
|
def test_gil_mode_cli_argument_parsing(self):
|
||||||
"""Test CLI argument parsing for GIL mode with various options."""
|
"""Test CLI argument parsing for GIL mode with various options."""
|
||||||
|
from profiling.sampling.cli import main
|
||||||
|
|
||||||
test_args = [
|
test_args = [
|
||||||
"profiling.sampling.sample",
|
"profiling.sampling.cli",
|
||||||
|
"attach",
|
||||||
|
"12345",
|
||||||
"--mode",
|
"--mode",
|
||||||
"gil",
|
"gil",
|
||||||
"--interval",
|
"-i",
|
||||||
"500",
|
"500",
|
||||||
"--duration",
|
"-d",
|
||||||
"5",
|
"5",
|
||||||
"-p",
|
|
||||||
"12345",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
with (
|
with (
|
||||||
mock.patch("sys.argv", test_args),
|
mock.patch("sys.argv", test_args),
|
||||||
mock.patch("profiling.sampling.sample.sample") as mock_sample,
|
mock.patch("profiling.sampling.cli.sample") as mock_sample,
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
profiling.sampling.sample.main()
|
main()
|
||||||
except SystemExit:
|
except (SystemExit, OSError, RuntimeError):
|
||||||
pass # Expected due to invalid PID
|
pass # Expected due to invalid PID
|
||||||
|
|
||||||
# Verify all arguments were parsed correctly
|
# Verify all arguments were parsed correctly
|
||||||
mock_sample.assert_called_once()
|
mock_sample.assert_called_once()
|
||||||
call_args = mock_sample.call_args[1]
|
call_args = mock_sample.call_args
|
||||||
self.assertEqual(call_args["mode"], 2) # GIL mode
|
self.assertEqual(call_args.kwargs.get("mode"), 2) # GIL mode
|
||||||
self.assertEqual(call_args["sample_interval_usec"], 500)
|
self.assertEqual(call_args.kwargs.get("duration_sec"), 5)
|
||||||
self.assertEqual(call_args["duration_sec"], 5)
|
|
||||||
|
|
||||||
@requires_subprocess()
|
@requires_subprocess()
|
||||||
def test_gil_mode_integration_behavior(self):
|
def test_gil_mode_integration_behavior(self):
|
||||||
|
|
@ -454,14 +391,15 @@ def main():
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
collector = PstatsCollector(sample_interval_usec=5000, skip_idle=True)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=2.0,
|
duration_sec=2.0,
|
||||||
sample_interval_usec=5000,
|
|
||||||
mode=2, # GIL mode
|
mode=2, # GIL mode
|
||||||
show_summary=False,
|
|
||||||
all_threads=True,
|
all_threads=True,
|
||||||
)
|
)
|
||||||
|
collector.print_stats(show_summary=False)
|
||||||
except (PermissionError, RuntimeError) as e:
|
except (PermissionError, RuntimeError) as e:
|
||||||
self.skipTest(
|
self.skipTest(
|
||||||
"Insufficient permissions for remote profiling"
|
"Insufficient permissions for remote profiling"
|
||||||
|
|
@ -475,14 +413,15 @@ def main():
|
||||||
mock.patch("sys.stdout", captured_output),
|
mock.patch("sys.stdout", captured_output),
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
|
collector = PstatsCollector(sample_interval_usec=5000, skip_idle=False)
|
||||||
profiling.sampling.sample.sample(
|
profiling.sampling.sample.sample(
|
||||||
subproc.process.pid,
|
subproc.process.pid,
|
||||||
|
collector,
|
||||||
duration_sec=0.5,
|
duration_sec=0.5,
|
||||||
sample_interval_usec=5000,
|
|
||||||
mode=0, # Wall-clock mode
|
mode=0, # Wall-clock mode
|
||||||
show_summary=False,
|
|
||||||
all_threads=True,
|
all_threads=True,
|
||||||
)
|
)
|
||||||
|
collector.print_stats(show_summary=False)
|
||||||
except (PermissionError, RuntimeError) as e:
|
except (PermissionError, RuntimeError) as e:
|
||||||
self.skipTest(
|
self.skipTest(
|
||||||
"Insufficient permissions for remote profiling"
|
"Insufficient permissions for remote profiling"
|
||||||
|
|
@ -505,10 +444,11 @@ def test_mode_constants_are_defined(self):
|
||||||
|
|
||||||
def test_parse_mode_function(self):
|
def test_parse_mode_function(self):
|
||||||
"""Test the _parse_mode function with all valid modes."""
|
"""Test the _parse_mode function with all valid modes."""
|
||||||
self.assertEqual(profiling.sampling.sample._parse_mode("wall"), 0)
|
from profiling.sampling.cli import _parse_mode
|
||||||
self.assertEqual(profiling.sampling.sample._parse_mode("cpu"), 1)
|
self.assertEqual(_parse_mode("wall"), 0)
|
||||||
self.assertEqual(profiling.sampling.sample._parse_mode("gil"), 2)
|
self.assertEqual(_parse_mode("cpu"), 1)
|
||||||
|
self.assertEqual(_parse_mode("gil"), 2)
|
||||||
|
|
||||||
# Test invalid mode raises KeyError
|
# Test invalid mode raises KeyError
|
||||||
with self.assertRaises(KeyError):
|
with self.assertRaises(KeyError):
|
||||||
profiling.sampling.sample._parse_mode("invalid")
|
_parse_mode("invalid")
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@
|
||||||
|
|
||||||
try:
|
try:
|
||||||
import _remote_debugging # noqa: F401
|
import _remote_debugging # noqa: F401
|
||||||
from profiling.sampling.sample import SampleProfiler, print_sampled_stats
|
from profiling.sampling.sample import SampleProfiler
|
||||||
from profiling.sampling.pstats_collector import PstatsCollector
|
from profiling.sampling.pstats_collector import PstatsCollector
|
||||||
except ImportError:
|
except ImportError:
|
||||||
raise unittest.SkipTest(
|
raise unittest.SkipTest(
|
||||||
|
|
@ -16,6 +16,24 @@
|
||||||
from test.support import force_not_colorized_test_class
|
from test.support import force_not_colorized_test_class
|
||||||
|
|
||||||
|
|
||||||
|
def print_sampled_stats(stats, sort=-1, limit=None, show_summary=True, sample_interval_usec=100):
|
||||||
|
"""Helper function to maintain compatibility with old test API.
|
||||||
|
|
||||||
|
This wraps the new PstatsCollector.print_stats() API to work with the
|
||||||
|
existing test infrastructure.
|
||||||
|
"""
|
||||||
|
# Create a mock collector that populates stats correctly
|
||||||
|
collector = PstatsCollector(sample_interval_usec=sample_interval_usec)
|
||||||
|
|
||||||
|
# Override create_stats to populate self.stats with the provided stats
|
||||||
|
def mock_create_stats():
|
||||||
|
collector.stats = stats.stats
|
||||||
|
collector.create_stats = mock_create_stats
|
||||||
|
|
||||||
|
# Call the new print_stats method
|
||||||
|
collector.print_stats(sort=sort, limit=limit, show_summary=show_summary)
|
||||||
|
|
||||||
|
|
||||||
class TestSampleProfiler(unittest.TestCase):
|
class TestSampleProfiler(unittest.TestCase):
|
||||||
"""Test the SampleProfiler class."""
|
"""Test the SampleProfiler class."""
|
||||||
|
|
||||||
|
|
@ -406,8 +424,8 @@ def test_print_sampled_stats_empty_stats(self):
|
||||||
|
|
||||||
result = output.getvalue()
|
result = output.getvalue()
|
||||||
|
|
||||||
# Should still print header
|
# Should print message about no samples
|
||||||
self.assertIn("Profile Stats:", result)
|
self.assertIn("No samples were collected.", result)
|
||||||
|
|
||||||
def test_print_sampled_stats_sample_percentage_sorting(self):
|
def test_print_sampled_stats_sample_percentage_sorting(self):
|
||||||
"""Test sample percentage sorting options."""
|
"""Test sample percentage sorting options."""
|
||||||
|
|
|
||||||
|
|
@ -388,7 +388,7 @@ Add :func:`os.reload_environ` to ``os.__all__``.
|
||||||
.. nonce: L13UCV
|
.. nonce: L13UCV
|
||||||
.. section: Library
|
.. section: Library
|
||||||
|
|
||||||
Fix :func:`profiling.sampling.sample` incorrectly handling a
|
Fix ``profiling.sampling.sample()`` incorrectly handling a
|
||||||
:exc:`FileNotFoundError` or :exc:`PermissionError`.
|
:exc:`FileNotFoundError` or :exc:`PermissionError`.
|
||||||
|
|
||||||
..
|
..
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
Add a new ``--live`` mode to the tachyon profiler in
|
Add a new ``--live`` mode to the tachyon profiler in
|
||||||
:mod:`profiling.sampling` module. This mode consist of a live TUI that
|
:mod:`!profiling.sampling` module. This mode consist of a live TUI that
|
||||||
displays real-time profiling statistics as the target application runs,
|
displays real-time profiling statistics as the target application runs,
|
||||||
similar to ``top``. Patch by Pablo Galindo
|
similar to ``top``. Patch by Pablo Galindo
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue