gcmon 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gcmon/__init__.py +19 -0
- gcmon/__main__.py +6 -0
- gcmon/_env.py +223 -0
- gcmon/child_process_runner.py +237 -0
- gcmon/cli.py +127 -0
- gcmon/commands/__init__.py +9 -0
- gcmon/commands/convert_cmd.py +111 -0
- gcmon/commands/monitor_cmd.py +57 -0
- gcmon/commands/monitoring_base.py +76 -0
- gcmon/commands/monitoring_options.py +176 -0
- gcmon/commands/parser_factory.py +6 -0
- gcmon/commands/run_cmd.py +92 -0
- gcmon/control/__init__.py +0 -0
- gcmon/control/control_client.py +116 -0
- gcmon/control/control_server.py +286 -0
- gcmon/data.py +60 -0
- gcmon/exporters/__init__.py +27 -0
- gcmon/exporters/chrome_trace_exporter.py +131 -0
- gcmon/exporters/chrome_trace_format.py +400 -0
- gcmon/exporters/chrome_trace_io.py +236 -0
- gcmon/exporters/exporter.py +32 -0
- gcmon/exporters/exporter_factory.py +27 -0
- gcmon/exporters/jsonl_exporter.py +103 -0
- gcmon/exporters/perfetto_exporter.py +136 -0
- gcmon/exporters/perfetto_format.py +582 -0
- gcmon/exporters/protobuf_encoder.py +70 -0
- gcmon/exporters/stdout_exporter.py +39 -0
- gcmon/monitor.py +125 -0
- gcmon/monitor_loop.py +60 -0
- gcmon/monitor_thread.py +133 -0
- gcmon/poll_status.py +9 -0
- gcmon/protocol.py +190 -0
- gcmon/pyperf/__init__.py +11 -0
- gcmon/pyperf/hook.py +295 -0
- gcmon/run_policy.py +36 -0
- gcmon/stats.py +280 -0
- gcmon/stats_output.py +113 -0
- gcmon/target_process.py +41 -0
- gcmon/utils/__init__.py +10 -0
- gcmon/utils/process_terminator.py +200 -0
- gcmon/utils/replace_signals.py +20 -0
- gcmon/utils/set_on_exit.py +13 -0
- gcmon/wait_policy.py +55 -0
- gcmon-0.2.0.dist-info/METADATA +393 -0
- gcmon-0.2.0.dist-info/RECORD +48 -0
- gcmon-0.2.0.dist-info/WHEEL +4 -0
- gcmon-0.2.0.dist-info/entry_points.txt +6 -0
- gcmon-0.2.0.dist-info/licenses/LICENSE +21 -0
gcmon/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
__version__ = "0.1.0"
|
|
2
|
+
|
|
3
|
+
from .child_process_runner import ChildProcess, ChildProcessRunner
|
|
4
|
+
from .exporters import EventsExporter, JsonlExporter, StdoutExporter, TraceExporter
|
|
5
|
+
from .monitor import EventsMonitor, create_monitor
|
|
6
|
+
from .monitor_thread import MonitorThread
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"ChildProcess",
|
|
10
|
+
"ChildProcessRunner",
|
|
11
|
+
"EventsExporter",
|
|
12
|
+
"EventsMonitor",
|
|
13
|
+
"JsonlExporter",
|
|
14
|
+
"MonitorThread",
|
|
15
|
+
"StdoutExporter",
|
|
16
|
+
"TraceExporter",
|
|
17
|
+
"__version__",
|
|
18
|
+
"create_monitor",
|
|
19
|
+
]
|
gcmon/__main__.py
ADDED
gcmon/_env.py
ADDED
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
"""Environment variable helpers for CLI defaults."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .stats_output import TableFormat
|
|
7
|
+
|
|
8
|
+
# Environment variable names for CLI options
|
|
9
|
+
ENV_PREFIX = "GCMON"
|
|
10
|
+
ENV_OUTPUT = f"{ENV_PREFIX}_OUTPUT"
|
|
11
|
+
ENV_RATE = f"{ENV_PREFIX}_RATE"
|
|
12
|
+
ENV_DURATION = f"{ENV_PREFIX}_DURATION"
|
|
13
|
+
ENV_VERBOSE = f"{ENV_PREFIX}_VERBOSE"
|
|
14
|
+
ENV_FORMAT = f"{ENV_PREFIX}_FORMAT"
|
|
15
|
+
ENV_THREAD_ID = f"{ENV_PREFIX}_THREAD_ID"
|
|
16
|
+
ENV_FLUSH_THRESHOLD = f"{ENV_PREFIX}_FLUSH_THRESHOLD"
|
|
17
|
+
ENV_SERVER_HOST = f"{ENV_PREFIX}_SERVER_HOST"
|
|
18
|
+
ENV_SERVER_PORT = f"{ENV_PREFIX}_SERVER_PORT"
|
|
19
|
+
ENV_STATS = f"{ENV_PREFIX}_STATS"
|
|
20
|
+
ENV_TABLE_FORMAT = f"{ENV_PREFIX}_TABLE_FORMAT"
|
|
21
|
+
ENV_CONTROL_NAME = f"{ENV_PREFIX}_CONTROL_NAME"
|
|
22
|
+
|
|
23
|
+
__all__ = [
|
|
24
|
+
"ENV_CONTROL_NAME",
|
|
25
|
+
"ENV_DURATION",
|
|
26
|
+
"ENV_FLUSH_THRESHOLD",
|
|
27
|
+
"ENV_FORMAT",
|
|
28
|
+
"ENV_OUTPUT",
|
|
29
|
+
"ENV_PREFIX",
|
|
30
|
+
"ENV_RATE",
|
|
31
|
+
"ENV_SERVER_HOST",
|
|
32
|
+
"ENV_SERVER_PORT",
|
|
33
|
+
"ENV_STATS",
|
|
34
|
+
"ENV_TABLE_FORMAT",
|
|
35
|
+
"ENV_THREAD_ID",
|
|
36
|
+
"ENV_VERBOSE",
|
|
37
|
+
"get_env_control_name",
|
|
38
|
+
"get_env_duration",
|
|
39
|
+
"get_env_flush_threshold",
|
|
40
|
+
"get_env_format",
|
|
41
|
+
"get_env_output",
|
|
42
|
+
"get_env_rate",
|
|
43
|
+
"get_env_server_host",
|
|
44
|
+
"get_env_server_port",
|
|
45
|
+
"get_env_stats",
|
|
46
|
+
"get_env_table_format",
|
|
47
|
+
"get_env_thread_id",
|
|
48
|
+
"get_env_verbose",
|
|
49
|
+
]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def get_env_output() -> Path:
|
|
53
|
+
"""Get output path from environment variable.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Path from GCMON_OUTPUT env var, or default Path("gcmon.json").
|
|
57
|
+
"""
|
|
58
|
+
output_str = os.environ.get(ENV_OUTPUT)
|
|
59
|
+
if output_str:
|
|
60
|
+
return Path(output_str)
|
|
61
|
+
# Check format for default filename
|
|
62
|
+
format_str = os.environ.get(ENV_FORMAT)
|
|
63
|
+
if format_str and format_str.lower() == "jsonl":
|
|
64
|
+
return Path("gcmon.jsonl")
|
|
65
|
+
return Path("gcmon.json")
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def get_env_rate() -> float:
|
|
69
|
+
"""Get polling rate from environment variable.
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Rate from GCMON_RATE env var, or default 0.1.
|
|
73
|
+
"""
|
|
74
|
+
rate_str = os.environ.get(ENV_RATE)
|
|
75
|
+
if rate_str:
|
|
76
|
+
try:
|
|
77
|
+
return float(rate_str)
|
|
78
|
+
except ValueError:
|
|
79
|
+
pass
|
|
80
|
+
return 0.1
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_env_duration() -> float | None:
|
|
84
|
+
"""Get monitoring duration from environment variable.
|
|
85
|
+
|
|
86
|
+
Returns:
|
|
87
|
+
Duration from GCMON_DURATION env var, or None (run until interrupted).
|
|
88
|
+
"""
|
|
89
|
+
duration_str = os.environ.get(ENV_DURATION)
|
|
90
|
+
if duration_str:
|
|
91
|
+
try:
|
|
92
|
+
return float(duration_str)
|
|
93
|
+
except ValueError:
|
|
94
|
+
pass
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get_env_verbose() -> int:
|
|
99
|
+
"""Get verbose count from environment variable.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
Verbose count: 0 for no verbose, 1 for INFO, 2+ for DEBUG.
|
|
103
|
+
GCMON_VERBOSE can be set to a number (e.g., "2") or
|
|
104
|
+
truthy value ("1", "true", "yes", "on" -> 1).
|
|
105
|
+
"""
|
|
106
|
+
verbose_str = os.environ.get(ENV_VERBOSE, "").lower()
|
|
107
|
+
if not verbose_str:
|
|
108
|
+
return 0
|
|
109
|
+
# Try to parse as integer first
|
|
110
|
+
try:
|
|
111
|
+
return int(verbose_str)
|
|
112
|
+
except ValueError:
|
|
113
|
+
pass
|
|
114
|
+
# Fall back to boolean interpretation
|
|
115
|
+
if verbose_str in ("1", "true", "yes", "on"):
|
|
116
|
+
return 1
|
|
117
|
+
return 0
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def get_env_format() -> str:
|
|
121
|
+
"""Get output format from environment variable.
|
|
122
|
+
|
|
123
|
+
Returns:
|
|
124
|
+
Format from GCMON_FORMAT env var, or default "chrome".
|
|
125
|
+
"""
|
|
126
|
+
format_str = os.environ.get(ENV_FORMAT)
|
|
127
|
+
if format_str:
|
|
128
|
+
format_lower = format_str.lower()
|
|
129
|
+
if format_lower in ("chrome", "stdout", "jsonl"):
|
|
130
|
+
return format_lower
|
|
131
|
+
return "chrome"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def get_env_thread_id() -> int:
|
|
135
|
+
"""Get thread ID from environment variable.
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
Thread ID from GCMON_THREAD_ID env var, or default 0.
|
|
139
|
+
"""
|
|
140
|
+
thread_id_str = os.environ.get(ENV_THREAD_ID)
|
|
141
|
+
if thread_id_str:
|
|
142
|
+
try:
|
|
143
|
+
return int(thread_id_str)
|
|
144
|
+
except ValueError:
|
|
145
|
+
pass
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def get_env_flush_threshold() -> int:
|
|
150
|
+
"""Get flush threshold from environment variable.
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
Flush threshold from GCMON_FLUSH_THRESHOLD env var, or default 100.
|
|
154
|
+
"""
|
|
155
|
+
threshold_str = os.environ.get(ENV_FLUSH_THRESHOLD)
|
|
156
|
+
if threshold_str:
|
|
157
|
+
try:
|
|
158
|
+
return int(threshold_str)
|
|
159
|
+
except ValueError:
|
|
160
|
+
pass
|
|
161
|
+
return 100
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def get_env_server_host() -> str:
|
|
165
|
+
"""Get server host from environment variable.
|
|
166
|
+
|
|
167
|
+
Returns:
|
|
168
|
+
Host from GCMON_SERVER_HOST env var, or default "localhost".
|
|
169
|
+
"""
|
|
170
|
+
host_str = os.environ.get(ENV_SERVER_HOST)
|
|
171
|
+
if host_str:
|
|
172
|
+
return host_str
|
|
173
|
+
return "localhost"
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def get_env_server_port() -> int:
|
|
177
|
+
"""Get server port from environment variable.
|
|
178
|
+
|
|
179
|
+
Returns:
|
|
180
|
+
Port from GCMON_SERVER_PORT env var, or default 9999.
|
|
181
|
+
"""
|
|
182
|
+
port_str = os.environ.get(ENV_SERVER_PORT)
|
|
183
|
+
if port_str:
|
|
184
|
+
try:
|
|
185
|
+
return int(port_str)
|
|
186
|
+
except ValueError:
|
|
187
|
+
pass
|
|
188
|
+
return 9999
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def get_env_stats() -> bool:
|
|
192
|
+
"""Get stats flag from environment variable.
|
|
193
|
+
|
|
194
|
+
Returns:
|
|
195
|
+
True if GCMON_STATS is set to a truthy value ("1", "true", "yes", "on").
|
|
196
|
+
"""
|
|
197
|
+
stats_str = os.environ.get(ENV_STATS, "").lower()
|
|
198
|
+
if not stats_str:
|
|
199
|
+
return False
|
|
200
|
+
return stats_str in ("1", "true", "yes", "on")
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def get_env_control_name() -> str | None:
|
|
204
|
+
"""Get control plane name from environment variable.
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
Name from GCMON_CONTROL_NAME env var, or None.
|
|
208
|
+
"""
|
|
209
|
+
return os.environ.get(ENV_CONTROL_NAME) or None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def get_env_table_format() -> TableFormat:
|
|
213
|
+
"""Get table format from environment variable.
|
|
214
|
+
|
|
215
|
+
Returns:
|
|
216
|
+
TableFormat from GCMON_TABLE_FORMAT env var, or TableFormat.PLAIN.
|
|
217
|
+
"""
|
|
218
|
+
val = os.environ.get(ENV_TABLE_FORMAT)
|
|
219
|
+
if val:
|
|
220
|
+
val = val.lower()
|
|
221
|
+
if val == "md" or val == "markdown":
|
|
222
|
+
return TableFormat.MARKDOWN
|
|
223
|
+
return TableFormat.PLAIN
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import os
|
|
3
|
+
import subprocess
|
|
4
|
+
import sys
|
|
5
|
+
import threading
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Self, override
|
|
8
|
+
|
|
9
|
+
from .control.control_server import set_control_env
|
|
10
|
+
from .target_process import TargetProcess
|
|
11
|
+
from .utils.process_terminator import log_process_output, terminate_process
|
|
12
|
+
|
|
13
|
+
__all__ = ["ChildProcess", "ChildProcessRunner"]
|
|
14
|
+
|
|
15
|
+
logger = logging.getLogger("gcmon")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ChildProcess(TargetProcess):
|
|
19
|
+
def __init__(self, pid: int):
|
|
20
|
+
self._pid = pid
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
@override
|
|
24
|
+
def pid(self) -> int:
|
|
25
|
+
return self._pid
|
|
26
|
+
|
|
27
|
+
class ChildProcessRunner:
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
target: str,
|
|
31
|
+
is_module: bool = False,
|
|
32
|
+
passthrough_args: list[str] | None = None,
|
|
33
|
+
env: dict[str, str] | None = None,
|
|
34
|
+
control_address: str | None = None,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._target = target
|
|
37
|
+
self._is_module = is_module
|
|
38
|
+
self._passthrough_args = passthrough_args or []
|
|
39
|
+
self._env = env
|
|
40
|
+
self._control_address = control_address
|
|
41
|
+
self._process: subprocess.Popen[bytes] | None = None
|
|
42
|
+
self._stdout_thread: ProcessStdoutReader | None = None
|
|
43
|
+
|
|
44
|
+
def _validate_target(self) -> None:
|
|
45
|
+
if self._is_module:
|
|
46
|
+
# Module mode: validate module name is not empty
|
|
47
|
+
if not self._target.strip():
|
|
48
|
+
raise ValueError("Module name cannot be empty")
|
|
49
|
+
else:
|
|
50
|
+
# Script mode: validate file exists and is readable
|
|
51
|
+
script_path = Path(self._target)
|
|
52
|
+
if not script_path.exists():
|
|
53
|
+
raise FileNotFoundError(f"Script not found: {self._target}")
|
|
54
|
+
if not script_path.is_file():
|
|
55
|
+
raise ValueError(f"Target is not a file: {self._target}")
|
|
56
|
+
|
|
57
|
+
def _build_command(self) -> list[str]:
|
|
58
|
+
cmd = [sys.executable, "-u"]
|
|
59
|
+
|
|
60
|
+
if self._is_module:
|
|
61
|
+
# Module mode: python -m module_name [args...]
|
|
62
|
+
cmd.append("-m")
|
|
63
|
+
cmd.append(self._target)
|
|
64
|
+
else:
|
|
65
|
+
# Script mode: python script_path [args...]
|
|
66
|
+
# Resolve to absolute path to ensure correct execution
|
|
67
|
+
script_path = str(Path(self._target).resolve())
|
|
68
|
+
cmd.append(script_path)
|
|
69
|
+
|
|
70
|
+
# Add passthrough arguments
|
|
71
|
+
cmd.extend(self._passthrough_args)
|
|
72
|
+
|
|
73
|
+
return cmd
|
|
74
|
+
|
|
75
|
+
def _build_env(self) -> dict[str, str]:
|
|
76
|
+
"""Build the environment for the subprocess.
|
|
77
|
+
|
|
78
|
+
Returns:
|
|
79
|
+
Environment dictionary for subprocess
|
|
80
|
+
"""
|
|
81
|
+
# Start with current environment
|
|
82
|
+
env = os.environ.copy()
|
|
83
|
+
|
|
84
|
+
# Merge custom environment variables
|
|
85
|
+
if self._env:
|
|
86
|
+
env.update(self._env)
|
|
87
|
+
|
|
88
|
+
# Inject control plane address for child processes
|
|
89
|
+
if self._control_address is not None:
|
|
90
|
+
set_control_env(env, self._control_address)
|
|
91
|
+
|
|
92
|
+
return env
|
|
93
|
+
|
|
94
|
+
def start(self) -> ChildProcess:
|
|
95
|
+
"""Spawn the subprocess and return its PID.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
Process ID of spawned subprocess
|
|
99
|
+
|
|
100
|
+
Raises:
|
|
101
|
+
FileNotFoundError: If target script doesn't exist (script mode)
|
|
102
|
+
ValueError: If target is invalid
|
|
103
|
+
RuntimeError: If subprocess fails to start
|
|
104
|
+
"""
|
|
105
|
+
# Validate target before spawning
|
|
106
|
+
self._validate_target()
|
|
107
|
+
|
|
108
|
+
# Build command and environment
|
|
109
|
+
cmd = self._build_command()
|
|
110
|
+
env = self._build_env()
|
|
111
|
+
|
|
112
|
+
logger.debug("Subprocess cmd: %s", " ".join(cmd))
|
|
113
|
+
|
|
114
|
+
# Configure subprocess creation flags for cross-platform compatibility
|
|
115
|
+
creationflags = 0
|
|
116
|
+
if sys.platform == "win32":
|
|
117
|
+
# Windows: Create new process group for proper signal handling
|
|
118
|
+
creationflags = subprocess.CREATE_NEW_PROCESS_GROUP
|
|
119
|
+
|
|
120
|
+
try:
|
|
121
|
+
self._process = subprocess.Popen(
|
|
122
|
+
cmd,
|
|
123
|
+
stdout=subprocess.PIPE,
|
|
124
|
+
stderr=subprocess.STDOUT,
|
|
125
|
+
creationflags=creationflags,
|
|
126
|
+
env=env,
|
|
127
|
+
)
|
|
128
|
+
except OSError as e:
|
|
129
|
+
raise RuntimeError(f"Failed to start subprocess: {e}") from e
|
|
130
|
+
|
|
131
|
+
if self._process.poll() is not None:
|
|
132
|
+
stdout_data, _ = self._process.communicate()
|
|
133
|
+
stdout_str = stdout_data.decode("utf-8", errors="replace").strip()
|
|
134
|
+
|
|
135
|
+
logger.debug("Subprocess exited immediately: %s", stdout_str)
|
|
136
|
+
raise RuntimeError("Subprocess exited immediately.")
|
|
137
|
+
|
|
138
|
+
self._stdout_thread = ProcessStdoutReader(self._process)
|
|
139
|
+
self._stdout_thread.start()
|
|
140
|
+
return ChildProcess(self._process.pid)
|
|
141
|
+
|
|
142
|
+
@property
|
|
143
|
+
def process(self) -> subprocess.Popen[bytes] | None:
|
|
144
|
+
"""Return the subprocess handle, or None if not started."""
|
|
145
|
+
return self._process
|
|
146
|
+
|
|
147
|
+
@property
|
|
148
|
+
def pid(self) -> int | None:
|
|
149
|
+
"""Return the process ID, or None if not started."""
|
|
150
|
+
return self._process.pid if self._process is not None else None
|
|
151
|
+
|
|
152
|
+
@property
|
|
153
|
+
def is_running(self) -> bool:
|
|
154
|
+
"""Check if the subprocess is still running."""
|
|
155
|
+
if self._process is not None:
|
|
156
|
+
return self._process.poll() is None
|
|
157
|
+
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
@property
|
|
161
|
+
def returncode(self) -> int | None:
|
|
162
|
+
"""Return the subprocess exit code, or None if still running."""
|
|
163
|
+
if self._process is not None:
|
|
164
|
+
return self._process.poll()
|
|
165
|
+
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
def terminate(
|
|
169
|
+
self,
|
|
170
|
+
graceful_timeout: float = 5.0,
|
|
171
|
+
force_timeout: float = 2.0,
|
|
172
|
+
) -> bytes:
|
|
173
|
+
"""Terminate the subprocess gracefully.
|
|
174
|
+
|
|
175
|
+
Uses escalating signals for graceful shutdown:
|
|
176
|
+
- SIGINT → SIGTERM → SIGKILL
|
|
177
|
+
"""
|
|
178
|
+
if self._stdout_thread is not None:
|
|
179
|
+
self._stdout_thread.stop()
|
|
180
|
+
self._stdout_thread = None
|
|
181
|
+
|
|
182
|
+
if self._process is None:
|
|
183
|
+
return b""
|
|
184
|
+
|
|
185
|
+
# Use the shared terminate_process utility
|
|
186
|
+
stdout_data, _ = terminate_process(
|
|
187
|
+
process=self._process,
|
|
188
|
+
graceful_timeout=graceful_timeout,
|
|
189
|
+
force_timeout=force_timeout,
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
# Log process output
|
|
193
|
+
log_process_output(
|
|
194
|
+
process=self._process,
|
|
195
|
+
stdout_data=stdout_data,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
return stdout_data
|
|
199
|
+
|
|
200
|
+
def close(self) -> None:
|
|
201
|
+
self.terminate()
|
|
202
|
+
|
|
203
|
+
def __enter__(self) -> Self:
|
|
204
|
+
return self
|
|
205
|
+
|
|
206
|
+
def __exit__(self, *args: object) -> None:
|
|
207
|
+
self.terminate()
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
class ProcessStdoutReader:
|
|
211
|
+
def __init__(self, process: subprocess.Popen[bytes]):
|
|
212
|
+
self._process = process
|
|
213
|
+
self._stop_event = threading.Event()
|
|
214
|
+
self._thread = threading.Thread(
|
|
215
|
+
target=self._run,
|
|
216
|
+
name=type(self).__qualname__,
|
|
217
|
+
daemon=True,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
def start(self) -> None:
|
|
221
|
+
self._stop_event.clear()
|
|
222
|
+
self._thread.start()
|
|
223
|
+
|
|
224
|
+
def stop(self, timeout: float = 1.0) -> None:
|
|
225
|
+
self._stop_event.set()
|
|
226
|
+
self._thread.join(timeout=timeout)
|
|
227
|
+
|
|
228
|
+
def _run(self) -> None:
|
|
229
|
+
pipe = self._process.stdout
|
|
230
|
+
if pipe is None:
|
|
231
|
+
return
|
|
232
|
+
|
|
233
|
+
for line in iter(pipe.readline, b""):
|
|
234
|
+
if line:
|
|
235
|
+
print(line.decode("utf-8", errors="replace"), end="", flush=True)
|
|
236
|
+
if self._stop_event.is_set():
|
|
237
|
+
break
|
gcmon/cli.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
"""Command-line interface for gcmon."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import logging
|
|
5
|
+
import sys
|
|
6
|
+
|
|
7
|
+
from .commands import (
|
|
8
|
+
add_combine_parser,
|
|
9
|
+
add_monitor_parser,
|
|
10
|
+
add_run_parser,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
logger = logging.getLogger("gcmon")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _create_parser() -> argparse.ArgumentParser:
|
|
17
|
+
"""Create the argument parser with subcommands."""
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="gcmon",
|
|
20
|
+
description="Monitor Python's garbage collector and export statistics.",
|
|
21
|
+
)
|
|
22
|
+
subparsers = parser.add_subparsers(dest="command", help="Available commands")
|
|
23
|
+
|
|
24
|
+
add_monitor_parser(subparsers.add_parser)
|
|
25
|
+
add_combine_parser(subparsers.add_parser)
|
|
26
|
+
add_run_parser(subparsers.add_parser)
|
|
27
|
+
|
|
28
|
+
return parser
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _setup_logging(verbose_count: int) -> None:
|
|
32
|
+
"""Configure logging for the CLI.
|
|
33
|
+
|
|
34
|
+
Args:
|
|
35
|
+
verbose_count: Verbose level count:
|
|
36
|
+
0 = WARNING (default)
|
|
37
|
+
1 = INFO (-v)
|
|
38
|
+
2+ = DEBUG (-vv or more)
|
|
39
|
+
"""
|
|
40
|
+
if verbose_count >= 2:
|
|
41
|
+
level = logging.DEBUG
|
|
42
|
+
elif verbose_count == 1:
|
|
43
|
+
level = logging.INFO
|
|
44
|
+
else:
|
|
45
|
+
level = logging.WARNING
|
|
46
|
+
logger = logging.getLogger("gcmon")
|
|
47
|
+
logger.setLevel(level)
|
|
48
|
+
|
|
49
|
+
# Only add handler if none exists
|
|
50
|
+
if not logger.handlers:
|
|
51
|
+
handler = logging.StreamHandler()
|
|
52
|
+
handler.setLevel(level)
|
|
53
|
+
formatter = logging.Formatter("[%(name)s] %(levelname)s: %(message)s")
|
|
54
|
+
handler.setFormatter(formatter)
|
|
55
|
+
logger.addHandler(handler)
|
|
56
|
+
else:
|
|
57
|
+
# Update existing handlers
|
|
58
|
+
for handler in logger.handlers: # type: ignore[assignment]
|
|
59
|
+
handler.setLevel(level)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _split_run_args(argv: list[str]) -> tuple[list[str], list[str]]:
|
|
63
|
+
"""Split run command args at the first target option (-m/-s/--module/--script).
|
|
64
|
+
|
|
65
|
+
Everything up to and including the target option + value goes to gcmon.
|
|
66
|
+
Everything after is passed verbatim to the script.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
argv: Command-line arguments starting with "run"
|
|
70
|
+
|
|
71
|
+
Returns:
|
|
72
|
+
Tuple of (gcmon args, script args)
|
|
73
|
+
"""
|
|
74
|
+
target_options = {"-m", "--module", "-s", "--script"}
|
|
75
|
+
for i, arg in enumerate(argv):
|
|
76
|
+
if arg in target_options:
|
|
77
|
+
# -m value or -s value → split after the value
|
|
78
|
+
return argv[: i + 2], argv[i + 2 :]
|
|
79
|
+
if arg.startswith("--module=") or arg.startswith("--script="):
|
|
80
|
+
# --module=value or --script=value → split after this arg
|
|
81
|
+
return argv[: i + 1], argv[i + 1 :]
|
|
82
|
+
# No target option found — all args go to gcmon
|
|
83
|
+
return argv, []
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def main(argv: list[str] | None = None) -> int:
|
|
87
|
+
"""Main entry point for the CLI.
|
|
88
|
+
|
|
89
|
+
Args:
|
|
90
|
+
argv: Command-line arguments (defaults to sys.argv[1:])
|
|
91
|
+
|
|
92
|
+
Returns:
|
|
93
|
+
Exit code (0 for success, non-zero for failure)
|
|
94
|
+
"""
|
|
95
|
+
parser = _create_parser()
|
|
96
|
+
|
|
97
|
+
# Check if "run" command is being used - need special handling for script args
|
|
98
|
+
if argv is None:
|
|
99
|
+
argv = sys.argv[1:]
|
|
100
|
+
|
|
101
|
+
# For run command, split args at the first target option (-m/-s/--module/--script)
|
|
102
|
+
# Everything before goes to gcmon, everything after goes to the script
|
|
103
|
+
if argv and argv[0] == "run":
|
|
104
|
+
gc_args, script_args = _split_run_args(argv)
|
|
105
|
+
args = parser.parse_args(gc_args)
|
|
106
|
+
args.script_args = script_args
|
|
107
|
+
else:
|
|
108
|
+
args = parser.parse_args(argv)
|
|
109
|
+
|
|
110
|
+
# Setup logging before any logging calls
|
|
111
|
+
_setup_logging(args.verbose)
|
|
112
|
+
|
|
113
|
+
# Dispatch via args.func (set by each subparser's set_defaults)
|
|
114
|
+
if hasattr(args, "func"):
|
|
115
|
+
return int(args.func(args))
|
|
116
|
+
|
|
117
|
+
# No command specified — default to monitor
|
|
118
|
+
if args.command is None:
|
|
119
|
+
return main(["monitor", *argv])
|
|
120
|
+
|
|
121
|
+
# Unknown command (should not happen due to argparse)
|
|
122
|
+
logger.error("Unknown command: %s", args.command)
|
|
123
|
+
return 1
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
if __name__ == "__main__":
|
|
127
|
+
sys.exit(main())
|