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.
Files changed (48) hide show
  1. gcmon/__init__.py +19 -0
  2. gcmon/__main__.py +6 -0
  3. gcmon/_env.py +223 -0
  4. gcmon/child_process_runner.py +237 -0
  5. gcmon/cli.py +127 -0
  6. gcmon/commands/__init__.py +9 -0
  7. gcmon/commands/convert_cmd.py +111 -0
  8. gcmon/commands/monitor_cmd.py +57 -0
  9. gcmon/commands/monitoring_base.py +76 -0
  10. gcmon/commands/monitoring_options.py +176 -0
  11. gcmon/commands/parser_factory.py +6 -0
  12. gcmon/commands/run_cmd.py +92 -0
  13. gcmon/control/__init__.py +0 -0
  14. gcmon/control/control_client.py +116 -0
  15. gcmon/control/control_server.py +286 -0
  16. gcmon/data.py +60 -0
  17. gcmon/exporters/__init__.py +27 -0
  18. gcmon/exporters/chrome_trace_exporter.py +131 -0
  19. gcmon/exporters/chrome_trace_format.py +400 -0
  20. gcmon/exporters/chrome_trace_io.py +236 -0
  21. gcmon/exporters/exporter.py +32 -0
  22. gcmon/exporters/exporter_factory.py +27 -0
  23. gcmon/exporters/jsonl_exporter.py +103 -0
  24. gcmon/exporters/perfetto_exporter.py +136 -0
  25. gcmon/exporters/perfetto_format.py +582 -0
  26. gcmon/exporters/protobuf_encoder.py +70 -0
  27. gcmon/exporters/stdout_exporter.py +39 -0
  28. gcmon/monitor.py +125 -0
  29. gcmon/monitor_loop.py +60 -0
  30. gcmon/monitor_thread.py +133 -0
  31. gcmon/poll_status.py +9 -0
  32. gcmon/protocol.py +190 -0
  33. gcmon/pyperf/__init__.py +11 -0
  34. gcmon/pyperf/hook.py +295 -0
  35. gcmon/run_policy.py +36 -0
  36. gcmon/stats.py +280 -0
  37. gcmon/stats_output.py +113 -0
  38. gcmon/target_process.py +41 -0
  39. gcmon/utils/__init__.py +10 -0
  40. gcmon/utils/process_terminator.py +200 -0
  41. gcmon/utils/replace_signals.py +20 -0
  42. gcmon/utils/set_on_exit.py +13 -0
  43. gcmon/wait_policy.py +55 -0
  44. gcmon-0.2.0.dist-info/METADATA +393 -0
  45. gcmon-0.2.0.dist-info/RECORD +48 -0
  46. gcmon-0.2.0.dist-info/WHEEL +4 -0
  47. gcmon-0.2.0.dist-info/entry_points.txt +6 -0
  48. 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
@@ -0,0 +1,6 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ if __name__ == "__main__":
6
+ sys.exit(main())
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())
@@ -0,0 +1,9 @@
1
+ from .convert_cmd import add_parser as add_combine_parser
2
+ from .monitor_cmd import add_parser as add_monitor_parser
3
+ from .run_cmd import add_parser as add_run_parser
4
+
5
+ __all__ = [
6
+ "add_combine_parser",
7
+ "add_monitor_parser",
8
+ "add_run_parser",
9
+ ]