pyspy-mcp 0.1.0__tar.gz
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.
- pyspy_mcp-0.1.0/.gitignore +37 -0
- pyspy_mcp-0.1.0/LICENSE +21 -0
- pyspy_mcp-0.1.0/PKG-INFO +151 -0
- pyspy_mcp-0.1.0/README.md +119 -0
- pyspy_mcp-0.1.0/pyproject.toml +64 -0
- pyspy_mcp-0.1.0/src/pyspy_mcp/__init__.py +3 -0
- pyspy_mcp-0.1.0/src/pyspy_mcp/parser.py +150 -0
- pyspy_mcp-0.1.0/src/pyspy_mcp/process_util.py +64 -0
- pyspy_mcp-0.1.0/src/pyspy_mcp/py_spy_finder.py +75 -0
- pyspy_mcp-0.1.0/src/pyspy_mcp/server.py +168 -0
- pyspy_mcp-0.1.0/src/pyspy_mcp/tools.py +332 -0
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Rust
|
|
2
|
+
target/
|
|
3
|
+
Cargo.lock
|
|
4
|
+
|
|
5
|
+
# Python
|
|
6
|
+
__pycache__/
|
|
7
|
+
*.py[cod]
|
|
8
|
+
*.egg-info/
|
|
9
|
+
*.egg
|
|
10
|
+
.eggs/
|
|
11
|
+
dist/
|
|
12
|
+
build/
|
|
13
|
+
*.whl
|
|
14
|
+
*.tar.gz
|
|
15
|
+
.pytest_cache/
|
|
16
|
+
.mypy_cache/
|
|
17
|
+
.ruff_cache/
|
|
18
|
+
|
|
19
|
+
# Virtual environments
|
|
20
|
+
venv/
|
|
21
|
+
.env/
|
|
22
|
+
.venv/
|
|
23
|
+
|
|
24
|
+
# IDE
|
|
25
|
+
.vscode/
|
|
26
|
+
.idea/
|
|
27
|
+
*.swp
|
|
28
|
+
*.swo
|
|
29
|
+
|
|
30
|
+
# OS
|
|
31
|
+
.DS_Store
|
|
32
|
+
Thumbs.db
|
|
33
|
+
|
|
34
|
+
# Local overrides
|
|
35
|
+
.env
|
|
36
|
+
.pyspy-mcp-server.json
|
|
37
|
+
.zcode/
|
pyspy_mcp-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 pyspy-mcp-server contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
pyspy_mcp-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: pyspy-mcp
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: MCP server for Python performance testing with py-spy
|
|
5
|
+
Project-URL: Homepage, https://github.com/LBurny/pyspy-mcp
|
|
6
|
+
Project-URL: Repository, https://github.com/LBurny/pyspy-mcp
|
|
7
|
+
Project-URL: Issues, https://github.com/LBurny/pyspy-mcp/issues
|
|
8
|
+
Author: LBunny
|
|
9
|
+
License: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: mcp,performance,profiler,py-spy,python
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Topic :: Software Development :: Debuggers
|
|
21
|
+
Classifier: Topic :: System :: Monitoring
|
|
22
|
+
Requires-Python: >=3.10
|
|
23
|
+
Requires-Dist: fastmcp>=3.0
|
|
24
|
+
Requires-Dist: psutil>=5.9
|
|
25
|
+
Requires-Dist: py-spy>=0.4.2
|
|
26
|
+
Requires-Dist: pydantic>=2.0
|
|
27
|
+
Provides-Extra: dev
|
|
28
|
+
Requires-Dist: build>=1.0; extra == 'dev'
|
|
29
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
30
|
+
Requires-Dist: twine>=5.0; extra == 'dev'
|
|
31
|
+
Description-Content-Type: text/markdown
|
|
32
|
+
|
|
33
|
+
# py-spy MCP Server
|
|
34
|
+
|
|
35
|
+
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that exposes Python performance testing tools powered by [py-spy](https://github.com/benfred/py-spy).
|
|
36
|
+
|
|
37
|
+
[](LICENSE)
|
|
38
|
+

|
|
39
|
+

|
|
40
|
+

|
|
41
|
+
|
|
42
|
+
đ English | [įŽäŊ䏿](README.zh.md)
|
|
43
|
+
|
|
44
|
+
**Profile Python in context.** Sample live processes, generate flamegraphs, dump stacks, and compare runs â all through MCP.
|
|
45
|
+
|
|
46
|
+
## Features
|
|
47
|
+
|
|
48
|
+
- **Profile by PID or command** â sample a running Python process or launch a new one directly.
|
|
49
|
+
- **`record_profile`** â generate profiles in multiple formats:
|
|
50
|
+
- `speedscope` (interactive JSON)
|
|
51
|
+
- `flamegraph` (self-contained SVG)
|
|
52
|
+
- `raw` (stack-count text)
|
|
53
|
+
- `chrometrace` (Chrome DevTools timeline JSON)
|
|
54
|
+
- **`dump_stacks`** â capture the current Python call stacks of a process as JSON or human-readable text.
|
|
55
|
+
- **`list_python_processes`** â list running Python processes on the machine to pick a target.
|
|
56
|
+
- **`analyze_profile`** â parse an existing profile and return the hottest frames.
|
|
57
|
+
- **`compare_profiles`** â compare two speedscope profiles and show percentage changes.
|
|
58
|
+
- **`top_profile`** â run a short `py-spy top` session and return a summary.
|
|
59
|
+
- On Windows, `py-spy top` cannot be captured through a pipe, so this tool falls back to a short raw recording and returns the hottest frames.
|
|
60
|
+
- **Low-overhead sampling** â powered by [py-spy](https://github.com/benfred/py-spy); reads process memory without modifying or running inside the target process.
|
|
61
|
+
- **Cross-platform** â works on Linux, macOS, and Windows (subject to OS permissions).
|
|
62
|
+
- **Local-source friendly** â during development the server automatically prefers a `py-spy` binary built from the sibling Rust source (`src/pyspy/`).
|
|
63
|
+
- **Optional native/C extension profiling** â enable `--native` where the platform supports it.
|
|
64
|
+
- **GIL and idle filtering** â focus on active threads or GIL-holding threads.
|
|
65
|
+
|
|
66
|
+
## Installation (from PyPI)
|
|
67
|
+
|
|
68
|
+
Using `pip`:
|
|
69
|
+
|
|
70
|
+
```bash
|
|
71
|
+
pip install pyspy-mcp
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Using `uv`:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
uv pip install pyspy-mcp
|
|
78
|
+
# or install as a global tool
|
|
79
|
+
uv tool install pyspy-mcp
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
This will automatically install the compatible `py-spy` binary wheel for your platform.
|
|
83
|
+
|
|
84
|
+
## Running with Claude Desktop / Claude Code
|
|
85
|
+
|
|
86
|
+
Add the server as a **Local command** connector:
|
|
87
|
+
|
|
88
|
+
```json
|
|
89
|
+
{
|
|
90
|
+
"mcpServers": {
|
|
91
|
+
"pyspy": {
|
|
92
|
+
"command": "pyspy-mcp"
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
Or run directly:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
pyspy-mcp
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The server speaks MCP over stdio.
|
|
105
|
+
|
|
106
|
+
## Development (from source)
|
|
107
|
+
|
|
108
|
+
If you want to use the local py-spy Rust source instead of the PyPI package:
|
|
109
|
+
|
|
110
|
+
```bash
|
|
111
|
+
# Build py-spy from the local Rust source
|
|
112
|
+
cargo build --release
|
|
113
|
+
|
|
114
|
+
# The binary will be at:
|
|
115
|
+
# target/release/py-spy (Linux / macOS)
|
|
116
|
+
# target/release/py-spy.exe (Windows)
|
|
117
|
+
|
|
118
|
+
# Install the Python MCP package in editable mode
|
|
119
|
+
pip install -e ".[dev]"
|
|
120
|
+
|
|
121
|
+
# Or using uv
|
|
122
|
+
uv pip install -e ".[dev]"
|
|
123
|
+
|
|
124
|
+
# Run tests
|
|
125
|
+
python -m pytest tests/pyspy_mcp -v
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The server will automatically prefer a locally built binary at `target/release/py-spy[.exe]` over the `py-spy` installed from PyPI. You can also force a specific binary by setting the environment variable:
|
|
129
|
+
|
|
130
|
+
```bash
|
|
131
|
+
export PYSPY_MCP_BINARY=/path/to/py-spy
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
## Publishing to PyPI
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
python -m build
|
|
138
|
+
python -m twine upload dist/*
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
The published wheel is a pure-Python `py3-none-any` package and depends on the upstream `py-spy` PyPI package. If you modify the Rust source and want to ship those changes, you will need to build platform-specific wheels (or bundle the rebuilt `py-spy` binary as package data).
|
|
142
|
+
|
|
143
|
+
## Permissions
|
|
144
|
+
|
|
145
|
+
- On Linux, profiling an existing PID usually requires `ptrace` permissions (`sudo` or `cap_sys_ptrace`).
|
|
146
|
+
- On macOS, profiling often requires root due to System Integrity Protection (SIP).
|
|
147
|
+
- On Windows, running as Administrator may be needed for some processes.
|
|
148
|
+
|
|
149
|
+
## Configuration
|
|
150
|
+
|
|
151
|
+
Set `PYSPY_MCP_BINARY` to override the bundled/development py-spy binary location.
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# py-spy MCP Server
|
|
2
|
+
|
|
3
|
+
A [Model Context Protocol (MCP)](https://modelcontextprotocol.io) server that exposes Python performance testing tools powered by [py-spy](https://github.com/benfred/py-spy).
|
|
4
|
+
|
|
5
|
+
[](LICENSE)
|
|
6
|
+

|
|
7
|
+

|
|
8
|
+

|
|
9
|
+
|
|
10
|
+
đ English | [įŽäŊ䏿](README.zh.md)
|
|
11
|
+
|
|
12
|
+
**Profile Python in context.** Sample live processes, generate flamegraphs, dump stacks, and compare runs â all through MCP.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- **Profile by PID or command** â sample a running Python process or launch a new one directly.
|
|
17
|
+
- **`record_profile`** â generate profiles in multiple formats:
|
|
18
|
+
- `speedscope` (interactive JSON)
|
|
19
|
+
- `flamegraph` (self-contained SVG)
|
|
20
|
+
- `raw` (stack-count text)
|
|
21
|
+
- `chrometrace` (Chrome DevTools timeline JSON)
|
|
22
|
+
- **`dump_stacks`** â capture the current Python call stacks of a process as JSON or human-readable text.
|
|
23
|
+
- **`list_python_processes`** â list running Python processes on the machine to pick a target.
|
|
24
|
+
- **`analyze_profile`** â parse an existing profile and return the hottest frames.
|
|
25
|
+
- **`compare_profiles`** â compare two speedscope profiles and show percentage changes.
|
|
26
|
+
- **`top_profile`** â run a short `py-spy top` session and return a summary.
|
|
27
|
+
- On Windows, `py-spy top` cannot be captured through a pipe, so this tool falls back to a short raw recording and returns the hottest frames.
|
|
28
|
+
- **Low-overhead sampling** â powered by [py-spy](https://github.com/benfred/py-spy); reads process memory without modifying or running inside the target process.
|
|
29
|
+
- **Cross-platform** â works on Linux, macOS, and Windows (subject to OS permissions).
|
|
30
|
+
- **Local-source friendly** â during development the server automatically prefers a `py-spy` binary built from the sibling Rust source (`src/pyspy/`).
|
|
31
|
+
- **Optional native/C extension profiling** â enable `--native` where the platform supports it.
|
|
32
|
+
- **GIL and idle filtering** â focus on active threads or GIL-holding threads.
|
|
33
|
+
|
|
34
|
+
## Installation (from PyPI)
|
|
35
|
+
|
|
36
|
+
Using `pip`:
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
pip install pyspy-mcp
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
Using `uv`:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
uv pip install pyspy-mcp
|
|
46
|
+
# or install as a global tool
|
|
47
|
+
uv tool install pyspy-mcp
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
This will automatically install the compatible `py-spy` binary wheel for your platform.
|
|
51
|
+
|
|
52
|
+
## Running with Claude Desktop / Claude Code
|
|
53
|
+
|
|
54
|
+
Add the server as a **Local command** connector:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"pyspy": {
|
|
60
|
+
"command": "pyspy-mcp"
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Or run directly:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
pyspy-mcp
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
The server speaks MCP over stdio.
|
|
73
|
+
|
|
74
|
+
## Development (from source)
|
|
75
|
+
|
|
76
|
+
If you want to use the local py-spy Rust source instead of the PyPI package:
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
# Build py-spy from the local Rust source
|
|
80
|
+
cargo build --release
|
|
81
|
+
|
|
82
|
+
# The binary will be at:
|
|
83
|
+
# target/release/py-spy (Linux / macOS)
|
|
84
|
+
# target/release/py-spy.exe (Windows)
|
|
85
|
+
|
|
86
|
+
# Install the Python MCP package in editable mode
|
|
87
|
+
pip install -e ".[dev]"
|
|
88
|
+
|
|
89
|
+
# Or using uv
|
|
90
|
+
uv pip install -e ".[dev]"
|
|
91
|
+
|
|
92
|
+
# Run tests
|
|
93
|
+
python -m pytest tests/pyspy_mcp -v
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
The server will automatically prefer a locally built binary at `target/release/py-spy[.exe]` over the `py-spy` installed from PyPI. You can also force a specific binary by setting the environment variable:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
export PYSPY_MCP_BINARY=/path/to/py-spy
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
## Publishing to PyPI
|
|
103
|
+
|
|
104
|
+
```bash
|
|
105
|
+
python -m build
|
|
106
|
+
python -m twine upload dist/*
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
The published wheel is a pure-Python `py3-none-any` package and depends on the upstream `py-spy` PyPI package. If you modify the Rust source and want to ship those changes, you will need to build platform-specific wheels (or bundle the rebuilt `py-spy` binary as package data).
|
|
110
|
+
|
|
111
|
+
## Permissions
|
|
112
|
+
|
|
113
|
+
- On Linux, profiling an existing PID usually requires `ptrace` permissions (`sudo` or `cap_sys_ptrace`).
|
|
114
|
+
- On macOS, profiling often requires root due to System Integrity Protection (SIP).
|
|
115
|
+
- On Windows, running as Administrator may be needed for some processes.
|
|
116
|
+
|
|
117
|
+
## Configuration
|
|
118
|
+
|
|
119
|
+
Set `PYSPY_MCP_BINARY` to override the bundled/development py-spy binary location.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "pyspy-mcp"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "MCP server for Python performance testing with py-spy"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = {text = "MIT"}
|
|
7
|
+
requires-python = ">=3.10"
|
|
8
|
+
authors = [
|
|
9
|
+
{name = "LBunny"},
|
|
10
|
+
]
|
|
11
|
+
keywords = ["mcp", "py-spy", "profiler", "python", "performance"]
|
|
12
|
+
classifiers = [
|
|
13
|
+
"Development Status :: 3 - Alpha",
|
|
14
|
+
"Intended Audience :: Developers",
|
|
15
|
+
"License :: OSI Approved :: MIT License",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"Programming Language :: Python :: 3.10",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Topic :: Software Development :: Debuggers",
|
|
22
|
+
"Topic :: System :: Monitoring",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"fastmcp>=3.0",
|
|
26
|
+
"psutil>=5.9",
|
|
27
|
+
"pydantic>=2.0",
|
|
28
|
+
# PyPI-distributed py-spy command-line tool. The local source build is used
|
|
29
|
+
# automatically during development (see py_spy_finder.py).
|
|
30
|
+
"py-spy>=0.4.2",
|
|
31
|
+
]
|
|
32
|
+
|
|
33
|
+
[project.urls]
|
|
34
|
+
Homepage = "https://github.com/LBurny/pyspy-mcp"
|
|
35
|
+
Repository = "https://github.com/LBurny/pyspy-mcp"
|
|
36
|
+
Issues = "https://github.com/LBurny/pyspy-mcp/issues"
|
|
37
|
+
|
|
38
|
+
[project.optional-dependencies]
|
|
39
|
+
dev = [
|
|
40
|
+
"pytest>=7.0",
|
|
41
|
+
"build>=1.0",
|
|
42
|
+
"twine>=5.0",
|
|
43
|
+
]
|
|
44
|
+
|
|
45
|
+
[project.scripts]
|
|
46
|
+
pyspy-mcp = "pyspy_mcp.server:main"
|
|
47
|
+
|
|
48
|
+
[build-system]
|
|
49
|
+
requires = ["hatchling"]
|
|
50
|
+
build-backend = "hatchling.build"
|
|
51
|
+
|
|
52
|
+
[tool.hatch.build.targets.wheel]
|
|
53
|
+
packages = ["src/pyspy_mcp"]
|
|
54
|
+
# Include the bundled py-spy binary when present.
|
|
55
|
+
artifacts = ["src/pyspy_mcp/bin/*"]
|
|
56
|
+
|
|
57
|
+
[tool.hatch.build.targets.sdist]
|
|
58
|
+
only-packages = true
|
|
59
|
+
|
|
60
|
+
[tool.pytest.ini_options]
|
|
61
|
+
testpaths = ["tests/pyspy_mcp"]
|
|
62
|
+
filterwarnings = [
|
|
63
|
+
"ignore::DeprecationWarning",
|
|
64
|
+
]
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Parsers for py-spy output formats."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections import Counter
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Dict, List
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class HotFrame:
|
|
15
|
+
name: str
|
|
16
|
+
file: str | None
|
|
17
|
+
line: int | None
|
|
18
|
+
samples: int
|
|
19
|
+
percent: float
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def parse_speedscope(profile_path: str | Path) -> Dict:
|
|
23
|
+
"""Load a speedscope JSON file and return the raw dict."""
|
|
24
|
+
with open(profile_path, "r", encoding="utf-8") as f:
|
|
25
|
+
return json.load(f)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def analyze_speedscope(profile_path: str | Path, top_n: int = 10) -> List[HotFrame]:
|
|
29
|
+
"""Aggregate a speedscope profile into the most frequently sampled frames."""
|
|
30
|
+
data = parse_speedscope(profile_path)
|
|
31
|
+
frames = data.get("shared", {}).get("frames", [])
|
|
32
|
+
counts: Counter[int] = Counter()
|
|
33
|
+
total = 0
|
|
34
|
+
for profile in data.get("profiles", []):
|
|
35
|
+
for sample in profile.get("samples", []):
|
|
36
|
+
# Deduplicate frames within a single sample to avoid overweighting deep stacks.
|
|
37
|
+
seen = set()
|
|
38
|
+
for frame_idx in sample:
|
|
39
|
+
if frame_idx not in seen:
|
|
40
|
+
counts[frame_idx] += 1
|
|
41
|
+
seen.add(frame_idx)
|
|
42
|
+
total += len(sample)
|
|
43
|
+
|
|
44
|
+
if total == 0:
|
|
45
|
+
return []
|
|
46
|
+
|
|
47
|
+
hot = []
|
|
48
|
+
for idx, sample_count in counts.most_common(top_n):
|
|
49
|
+
if idx < 0 or idx >= len(frames):
|
|
50
|
+
continue
|
|
51
|
+
frame = frames[idx]
|
|
52
|
+
hot.append(
|
|
53
|
+
HotFrame(
|
|
54
|
+
name=frame.get("name", "<unknown>"),
|
|
55
|
+
file=frame.get("file"),
|
|
56
|
+
line=frame.get("line"),
|
|
57
|
+
samples=sample_count,
|
|
58
|
+
percent=round(100.0 * sample_count / total, 2),
|
|
59
|
+
)
|
|
60
|
+
)
|
|
61
|
+
return hot
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def parse_raw(profile_path: str | Path, top_n: int = 10) -> List[HotFrame]:
|
|
65
|
+
"""Parse py-spy raw output (semicolon-delimited stacks with counts)."""
|
|
66
|
+
counts: Counter[str] = Counter()
|
|
67
|
+
total = 0
|
|
68
|
+
with open(profile_path, "r", encoding="utf-8") as f:
|
|
69
|
+
for line in f:
|
|
70
|
+
line = line.strip()
|
|
71
|
+
if not line:
|
|
72
|
+
continue
|
|
73
|
+
if ";" not in line:
|
|
74
|
+
continue
|
|
75
|
+
# py-spy raw format: frame1 (file:line);frame2 (file:line) count
|
|
76
|
+
try:
|
|
77
|
+
stack_str, count_str = line.rsplit(" ", 1)
|
|
78
|
+
count = int(count_str)
|
|
79
|
+
except ValueError:
|
|
80
|
+
continue
|
|
81
|
+
counts[stack_str] += count
|
|
82
|
+
total += count
|
|
83
|
+
|
|
84
|
+
if total == 0:
|
|
85
|
+
return []
|
|
86
|
+
|
|
87
|
+
hot = []
|
|
88
|
+
for stack, sample_count in counts.most_common(top_n):
|
|
89
|
+
# Use the innermost (last) frame as the representative name.
|
|
90
|
+
top_frame = stack.split(";")[-1]
|
|
91
|
+
hot.append(
|
|
92
|
+
HotFrame(
|
|
93
|
+
name=top_frame,
|
|
94
|
+
file=None,
|
|
95
|
+
line=None,
|
|
96
|
+
samples=sample_count,
|
|
97
|
+
percent=round(100.0 * sample_count / total, 2),
|
|
98
|
+
)
|
|
99
|
+
)
|
|
100
|
+
return hot
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def parse_dump_json(dump_path: str | Path) -> List[Dict]:
|
|
104
|
+
"""Load the output of ``py-spy dump --json``."""
|
|
105
|
+
with open(dump_path, "r", encoding="utf-8") as f:
|
|
106
|
+
return json.load(f)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def format_hot_frames(frames: List[HotFrame]) -> str:
|
|
110
|
+
"""Render a list of hot frames as a Markdown table."""
|
|
111
|
+
lines = ["| Function | File | Line | Samples | % |", "|---|---|---|---|---|"]
|
|
112
|
+
for f in frames:
|
|
113
|
+
file_cell = f.file or "-"
|
|
114
|
+
line_cell = str(f.line) if f.line is not None else "-"
|
|
115
|
+
lines.append(f"| {f.name} | {file_cell} | {line_cell} | {f.samples} | {f.percent} |")
|
|
116
|
+
return "\n".join(lines)
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def _frame_signature(frame: Dict) -> str:
|
|
120
|
+
"""Canonical string for a frame used when comparing profiles."""
|
|
121
|
+
return f"{frame.get('name')}@{frame.get('filename')}:{frame.get('line', 0)}"
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def compare_profiles(
|
|
125
|
+
profile_a: str | Path,
|
|
126
|
+
profile_b: str | Path,
|
|
127
|
+
top_n: int = 10,
|
|
128
|
+
) -> str:
|
|
129
|
+
"""Compare two speedscope profiles and highlight changes."""
|
|
130
|
+
frames_a = {f.name: f for f in analyze_speedscope(profile_a, top_n=top_n * 2)}
|
|
131
|
+
frames_b = {f.name: f for f in analyze_speedscope(profile_b, top_n=top_n * 2)}
|
|
132
|
+
all_names = set(frames_a.keys()) | set(frames_b.keys())
|
|
133
|
+
|
|
134
|
+
rows = []
|
|
135
|
+
names = sorted(
|
|
136
|
+
all_names,
|
|
137
|
+
key=lambda n: -(frames_b.get(n, HotFrame(n, None, None, 0, 0.0)).percent),
|
|
138
|
+
)
|
|
139
|
+
for name in names[:top_n]:
|
|
140
|
+
a = frames_a.get(name)
|
|
141
|
+
b = frames_b.get(name)
|
|
142
|
+
pct_a = a.percent if a else 0.0
|
|
143
|
+
pct_b = b.percent if b else 0.0
|
|
144
|
+
delta = round(pct_b - pct_a, 2)
|
|
145
|
+
rows.append((name, pct_a, pct_b, delta))
|
|
146
|
+
|
|
147
|
+
lines = ["| Function | % in A | % in B | Î |", "|---|---|---|---|"]
|
|
148
|
+
for name, pct_a, pct_b, delta in rows:
|
|
149
|
+
lines.append(f"| {name} | {pct_a} | {pct_b} | {delta:+} |")
|
|
150
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Utilities for finding Python processes on the local machine."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from typing import List
|
|
7
|
+
|
|
8
|
+
import psutil
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass
|
|
12
|
+
class PythonProcess:
|
|
13
|
+
pid: int
|
|
14
|
+
cmdline: str
|
|
15
|
+
create_time: float
|
|
16
|
+
username: str | None
|
|
17
|
+
memory_mb: float
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def list_python_processes() -> List[PythonProcess]:
|
|
21
|
+
"""Return a list of running processes whose executable looks like Python."""
|
|
22
|
+
processes: List[PythonProcess] = []
|
|
23
|
+
for proc in psutil.process_iter(["pid", "name", "cmdline", "create_time", "username", "memory_info"]):
|
|
24
|
+
try:
|
|
25
|
+
name = proc.info["name"] or ""
|
|
26
|
+
cmdline = proc.info["cmdline"] or []
|
|
27
|
+
cmdline_str = " ".join(cmdline) if cmdline else name
|
|
28
|
+
if not _is_python_process(name, cmdline):
|
|
29
|
+
continue
|
|
30
|
+
mem = proc.info.get("memory_info")
|
|
31
|
+
processes.append(
|
|
32
|
+
PythonProcess(
|
|
33
|
+
pid=proc.info["pid"],
|
|
34
|
+
cmdline=cmdline_str,
|
|
35
|
+
create_time=proc.info["create_time"] or 0.0,
|
|
36
|
+
username=proc.info.get("username"),
|
|
37
|
+
memory_mb=(mem.rss / (1024 * 1024)) if mem else 0.0,
|
|
38
|
+
)
|
|
39
|
+
)
|
|
40
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
41
|
+
continue
|
|
42
|
+
return processes
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_python_process(name: str, cmdline: List[str]) -> bool:
|
|
46
|
+
"""Heuristic to decide whether a process is a Python interpreter."""
|
|
47
|
+
lowered = name.lower()
|
|
48
|
+
if "python" in lowered:
|
|
49
|
+
return True
|
|
50
|
+
if not cmdline:
|
|
51
|
+
return False
|
|
52
|
+
first = cmdline[0].lower()
|
|
53
|
+
# Handle cases like /usr/bin/python3.11 or python.exe
|
|
54
|
+
if "python" in first:
|
|
55
|
+
return True
|
|
56
|
+
return False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def format_process_list(processes: List[PythonProcess], max_count: int = 50) -> str:
|
|
60
|
+
"""Format a list of Python processes as a human-readable table."""
|
|
61
|
+
lines = ["PID\tMemory(MB)\tCommand"]
|
|
62
|
+
for p in processes[:max_count]:
|
|
63
|
+
lines.append(f"{p.pid}\t{p.memory_mb:.1f}\t\t{p.cmdline[:120]}")
|
|
64
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Locate the py-spy binary used by this MCP server."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import platform
|
|
7
|
+
import shutil
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _binary_name() -> str:
|
|
13
|
+
return "py-spy.exe" if platform.system() == "Windows" else "py-spy"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _development_binary() -> Path | None:
|
|
17
|
+
"""Look for a binary built from the sibling Rust workspace."""
|
|
18
|
+
# __file__ = I:/pyspy_mcp/src/pyspy_mcp/py_spy_finder.py
|
|
19
|
+
workspace_root = Path(__file__).resolve().parent.parent.parent
|
|
20
|
+
candidate = workspace_root / "target" / "release" / _binary_name()
|
|
21
|
+
if candidate.exists():
|
|
22
|
+
return candidate
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _bundled_binary() -> Path | None:
|
|
27
|
+
"""Look for a binary shipped inside this Python package."""
|
|
28
|
+
package_dir = Path(__file__).resolve().parent
|
|
29
|
+
candidate = package_dir / "bin" / _binary_name()
|
|
30
|
+
if candidate.exists():
|
|
31
|
+
return candidate
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def find_py_spy() -> str:
|
|
36
|
+
"""Return the path to the py-spy binary.
|
|
37
|
+
|
|
38
|
+
Resolution order:
|
|
39
|
+
1. ``PYSPY_MCP_BINARY`` environment variable.
|
|
40
|
+
2. A binary built in the sibling Rust workspace (``target/release``).
|
|
41
|
+
3. A binary bundled inside this package.
|
|
42
|
+
4. ``py-spy`` on ``PATH``.
|
|
43
|
+
|
|
44
|
+
Raises:
|
|
45
|
+
FileNotFoundError: if no py-spy binary can be found.
|
|
46
|
+
"""
|
|
47
|
+
env_binary = os.environ.get("PYSPY_MCP_BINARY")
|
|
48
|
+
if env_binary:
|
|
49
|
+
path = Path(env_binary)
|
|
50
|
+
if path.exists():
|
|
51
|
+
return str(path)
|
|
52
|
+
raise FileNotFoundError(
|
|
53
|
+
f"PYSPY_MCP_BINARY points to a missing file: {env_binary}"
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
dev = _development_binary()
|
|
57
|
+
if dev:
|
|
58
|
+
return str(dev)
|
|
59
|
+
|
|
60
|
+
bundled = _bundled_binary()
|
|
61
|
+
if bundled:
|
|
62
|
+
return str(bundled)
|
|
63
|
+
|
|
64
|
+
path_binary = shutil.which("py-spy")
|
|
65
|
+
if path_binary:
|
|
66
|
+
return path_binary
|
|
67
|
+
|
|
68
|
+
raise FileNotFoundError(
|
|
69
|
+
"Could not find a py-spy binary. Set PYSPY_MCP_BINARY, "
|
|
70
|
+
"install py-spy on PATH, or build the Rust workspace first."
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
if __name__ == "__main__":
|
|
75
|
+
print(find_py_spy())
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"""FastMCP server exposing py-spy based Python performance tools."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from typing import List, Optional
|
|
7
|
+
|
|
8
|
+
from fastmcp import FastMCP
|
|
9
|
+
|
|
10
|
+
from . import tools
|
|
11
|
+
|
|
12
|
+
mcp = FastMCP("pyspy-mcp")
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@mcp.tool()
|
|
16
|
+
def record_profile(
|
|
17
|
+
pid: Optional[int] = None,
|
|
18
|
+
command: Optional[List[str]] = None,
|
|
19
|
+
duration: int = 5,
|
|
20
|
+
output_format: str = "speedscope",
|
|
21
|
+
rate: int = 100,
|
|
22
|
+
native: bool = False,
|
|
23
|
+
idle: bool = False,
|
|
24
|
+
gil: bool = False,
|
|
25
|
+
subprocesses: bool = False,
|
|
26
|
+
) -> str:
|
|
27
|
+
"""Record a sampling profile of a Python process or command.
|
|
28
|
+
|
|
29
|
+
Args:
|
|
30
|
+
pid: Process ID to sample. Use this OR command, not both.
|
|
31
|
+
command: Command to run and sample, e.g. ["python", "script.py"]. Use this OR pid.
|
|
32
|
+
duration: How many seconds to sample (default 5).
|
|
33
|
+
output_format: One of "speedscope" (JSON), "flamegraph" (SVG), "raw", "chrometrace".
|
|
34
|
+
rate: Samples per second (default 100).
|
|
35
|
+
native: Include native/C extension frames if supported on this platform.
|
|
36
|
+
idle: Include idle threads.
|
|
37
|
+
gil: Only include traces holding the GIL.
|
|
38
|
+
subprocesses: Include child Python processes.
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The generated profile content (JSON, SVG, or raw text).
|
|
42
|
+
"""
|
|
43
|
+
return tools.record_profile(
|
|
44
|
+
pid=pid,
|
|
45
|
+
command=command,
|
|
46
|
+
duration=duration,
|
|
47
|
+
output_format=output_format,
|
|
48
|
+
rate=rate,
|
|
49
|
+
native=native,
|
|
50
|
+
idle=idle,
|
|
51
|
+
gil=gil,
|
|
52
|
+
subprocesses=subprocesses,
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
@mcp.tool()
|
|
57
|
+
def dump_stacks(
|
|
58
|
+
pid: int,
|
|
59
|
+
json_output: bool = True,
|
|
60
|
+
locals_level: int = 0,
|
|
61
|
+
subprocesses: bool = False,
|
|
62
|
+
native: bool = False,
|
|
63
|
+
) -> str:
|
|
64
|
+
"""Dump the current Python call stacks of a process.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
pid: Process ID to inspect.
|
|
68
|
+
json_output: Return structured JSON instead of human-readable text.
|
|
69
|
+
locals_level: Number of times to pass --locals (0-2). Each level shows more locals.
|
|
70
|
+
subprocesses: Include child Python processes.
|
|
71
|
+
native: Include native frames if supported.
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
The stack dump as JSON or text.
|
|
75
|
+
"""
|
|
76
|
+
return tools.dump_stacks(
|
|
77
|
+
pid=pid,
|
|
78
|
+
json_output=json_output,
|
|
79
|
+
locals_level=locals_level,
|
|
80
|
+
subprocesses=subprocesses,
|
|
81
|
+
native=native,
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
@mcp.tool()
|
|
86
|
+
def list_python_processes() -> str:
|
|
87
|
+
"""List running Python processes on this machine.
|
|
88
|
+
|
|
89
|
+
Returns:
|
|
90
|
+
A table of PID, memory usage, and command line.
|
|
91
|
+
"""
|
|
92
|
+
return tools.list_python_processes_tool()
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@mcp.tool()
|
|
96
|
+
def analyze_profile(profile_path: str, top_n: int = 10) -> str:
|
|
97
|
+
"""Analyze an existing py-spy profile and return the hottest frames.
|
|
98
|
+
|
|
99
|
+
Args:
|
|
100
|
+
profile_path: Path to a speedscope JSON (.json) or raw (.txt) profile.
|
|
101
|
+
top_n: Number of top frames to return (default 10).
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
A Markdown table of hot frames with sample counts and percentages.
|
|
105
|
+
"""
|
|
106
|
+
return tools.analyze_profile(profile_path, top_n=top_n)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@mcp.tool()
|
|
110
|
+
def compare_profiles(profile_a: str, profile_b: str, top_n: int = 10) -> str:
|
|
111
|
+
"""Compare two speedscope profiles and show percentage changes.
|
|
112
|
+
|
|
113
|
+
Args:
|
|
114
|
+
profile_a: Path to the first profile.
|
|
115
|
+
profile_b: Path to the second profile.
|
|
116
|
+
top_n: Number of frames to show (default 10).
|
|
117
|
+
|
|
118
|
+
Returns:
|
|
119
|
+
A Markdown table showing the percentage of time in each profile and the delta.
|
|
120
|
+
"""
|
|
121
|
+
return tools.compare_profiles(profile_a, profile_b, top_n=top_n)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@mcp.tool()
|
|
125
|
+
def top_profile(
|
|
126
|
+
pid: Optional[int] = None,
|
|
127
|
+
command: Optional[List[str]] = None,
|
|
128
|
+
duration: int = 5,
|
|
129
|
+
rate: int = 100,
|
|
130
|
+
native: bool = False,
|
|
131
|
+
idle: bool = False,
|
|
132
|
+
gil: bool = False,
|
|
133
|
+
subprocesses: bool = False,
|
|
134
|
+
) -> str:
|
|
135
|
+
"""Run a live ``py-spy top`` view for a few seconds and return the summary.
|
|
136
|
+
|
|
137
|
+
Args:
|
|
138
|
+
pid: Process ID. Use this OR command.
|
|
139
|
+
command: Command to run and monitor. Use this OR pid.
|
|
140
|
+
duration: How many seconds to run top (default 5).
|
|
141
|
+
rate: Samples per second (default 100).
|
|
142
|
+
native: Include native frames if supported.
|
|
143
|
+
idle: Include idle threads.
|
|
144
|
+
gil: Only show traces holding the GIL.
|
|
145
|
+
subprocesses: Include child processes.
|
|
146
|
+
|
|
147
|
+
Returns:
|
|
148
|
+
The final top summary lines.
|
|
149
|
+
"""
|
|
150
|
+
return tools.top_profile(
|
|
151
|
+
pid=pid,
|
|
152
|
+
command=command,
|
|
153
|
+
duration=duration,
|
|
154
|
+
rate=rate,
|
|
155
|
+
native=native,
|
|
156
|
+
idle=idle,
|
|
157
|
+
gil=gil,
|
|
158
|
+
subprocesses=subprocesses,
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def main() -> None:
|
|
163
|
+
"""Entry point used by the ``pyspy-mcp`` console script."""
|
|
164
|
+
mcp.run()
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
if __name__ == "__main__":
|
|
168
|
+
main()
|
|
@@ -0,0 +1,332 @@
|
|
|
1
|
+
"""Core tool implementations that drive py-spy."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import tempfile
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import List, Optional
|
|
11
|
+
|
|
12
|
+
from . import parser
|
|
13
|
+
from .process_util import PythonProcess, format_process_list, list_python_processes
|
|
14
|
+
from .py_spy_finder import find_py_spy
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
# Valid output formats supported by py-spy record.
|
|
18
|
+
VALID_FORMATS = {"flamegraph", "speedscope", "raw", "chrometrace"}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run_py_spy(args: List[str], timeout: Optional[int] = None) -> subprocess.CompletedProcess:
|
|
22
|
+
"""Run py-spy with the given arguments and return the completed process."""
|
|
23
|
+
binary = find_py_spy()
|
|
24
|
+
env = os.environ.copy()
|
|
25
|
+
env.setdefault("RUST_LOG", "warn")
|
|
26
|
+
env.setdefault("RUST_BACKTRACE", "1")
|
|
27
|
+
return subprocess.run(
|
|
28
|
+
[binary, *args],
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
encoding="utf-8",
|
|
32
|
+
errors="replace",
|
|
33
|
+
env=env,
|
|
34
|
+
timeout=timeout,
|
|
35
|
+
check=False,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
# Alias kept for backwards compatibility.
|
|
40
|
+
find_py_sky = find_py_spy
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def record_profile(
|
|
44
|
+
pid: Optional[int] = None,
|
|
45
|
+
command: Optional[List[str]] = None,
|
|
46
|
+
duration: int = 5,
|
|
47
|
+
output_format: str = "speedscope",
|
|
48
|
+
rate: int = 100,
|
|
49
|
+
native: bool = False,
|
|
50
|
+
idle: bool = False,
|
|
51
|
+
gil: bool = False,
|
|
52
|
+
subprocesses: bool = False,
|
|
53
|
+
output_path: Optional[str] = None,
|
|
54
|
+
) -> str:
|
|
55
|
+
"""Record a profile from a Python process or command.
|
|
56
|
+
|
|
57
|
+
Returns the contents of the generated profile file as a string.
|
|
58
|
+
"""
|
|
59
|
+
if output_format not in VALID_FORMATS:
|
|
60
|
+
raise ValueError(f"Unsupported format: {output_format}. Use one of {VALID_FORMATS}")
|
|
61
|
+
if not (pid or command):
|
|
62
|
+
raise ValueError("Either pid or command must be provided")
|
|
63
|
+
if pid and command:
|
|
64
|
+
raise ValueError("Provide either pid or command, not both")
|
|
65
|
+
if duration <= 0:
|
|
66
|
+
raise ValueError("duration must be positive")
|
|
67
|
+
|
|
68
|
+
if output_path:
|
|
69
|
+
out = Path(output_path)
|
|
70
|
+
else:
|
|
71
|
+
suffix = ".svg" if output_format == "flamegraph" else ".json" if output_format in ("speedscope", "chrometrace") else ".txt"
|
|
72
|
+
out = Path(tempfile.mkstemp(suffix=suffix, prefix="pyspy_")[1])
|
|
73
|
+
|
|
74
|
+
args = [
|
|
75
|
+
"record",
|
|
76
|
+
"-o", str(out),
|
|
77
|
+
"--format", output_format,
|
|
78
|
+
"-d", str(duration),
|
|
79
|
+
"-r", str(rate),
|
|
80
|
+
]
|
|
81
|
+
if native:
|
|
82
|
+
args.append("--native")
|
|
83
|
+
if idle:
|
|
84
|
+
args.append("--idle")
|
|
85
|
+
if gil:
|
|
86
|
+
args.append("--gil")
|
|
87
|
+
if subprocesses:
|
|
88
|
+
args.append("--subprocesses")
|
|
89
|
+
|
|
90
|
+
if pid:
|
|
91
|
+
args.extend(["--pid", str(pid)])
|
|
92
|
+
else:
|
|
93
|
+
args.append("--")
|
|
94
|
+
args.extend(command)
|
|
95
|
+
|
|
96
|
+
result = _run_py_spy(args, timeout=duration + 30)
|
|
97
|
+
if result.returncode != 0:
|
|
98
|
+
raise RuntimeError(
|
|
99
|
+
f"py-spy record failed (exit {result.returncode}):\n{result.stderr}"
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
with open(out, "r", encoding="utf-8", errors="replace") as f:
|
|
103
|
+
return f.read()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def dump_stacks(
|
|
107
|
+
pid: int,
|
|
108
|
+
json_output: bool = True,
|
|
109
|
+
locals_level: int = 0,
|
|
110
|
+
subprocesses: bool = False,
|
|
111
|
+
native: bool = False,
|
|
112
|
+
) -> str:
|
|
113
|
+
"""Dump the current Python call stacks for a process."""
|
|
114
|
+
args = ["dump", "--pid", str(pid)]
|
|
115
|
+
if json_output:
|
|
116
|
+
args.append("--json")
|
|
117
|
+
for _ in range(locals_level):
|
|
118
|
+
args.append("--locals")
|
|
119
|
+
if subprocesses:
|
|
120
|
+
args.append("--subprocesses")
|
|
121
|
+
if native:
|
|
122
|
+
args.append("--native")
|
|
123
|
+
|
|
124
|
+
result = _run_py_spy(args, timeout=30)
|
|
125
|
+
if result.returncode != 0:
|
|
126
|
+
raise RuntimeError(
|
|
127
|
+
f"py-spy dump failed (exit {result.returncode}):\n{result.stderr}"
|
|
128
|
+
)
|
|
129
|
+
return result.stdout
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def list_python_processes_tool() -> str:
|
|
133
|
+
"""List running Python processes on this machine."""
|
|
134
|
+
processes: List[PythonProcess] = list_python_processes()
|
|
135
|
+
if not processes:
|
|
136
|
+
return "No Python processes found."
|
|
137
|
+
return format_process_list(processes)
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def analyze_profile(profile_path: str, top_n: int = 10) -> str:
|
|
141
|
+
"""Analyze an existing profile file and return the hottest frames."""
|
|
142
|
+
path = Path(profile_path)
|
|
143
|
+
if not path.exists():
|
|
144
|
+
raise FileNotFoundError(f"Profile not found: {profile_path}")
|
|
145
|
+
|
|
146
|
+
suffix = path.suffix.lower()
|
|
147
|
+
if suffix == ".json":
|
|
148
|
+
frames = parser.analyze_speedscope(path, top_n=top_n)
|
|
149
|
+
elif suffix == ".txt" or suffix == ".raw":
|
|
150
|
+
frames = parser.parse_raw(path, top_n=top_n)
|
|
151
|
+
else:
|
|
152
|
+
# Best-effort: try speedscope first, then raw.
|
|
153
|
+
try:
|
|
154
|
+
frames = parser.analyze_speedscope(path, top_n=top_n)
|
|
155
|
+
except Exception:
|
|
156
|
+
frames = parser.parse_raw(path, top_n=top_n)
|
|
157
|
+
|
|
158
|
+
if not frames:
|
|
159
|
+
return "No samples found in profile."
|
|
160
|
+
return parser.format_hot_frames(frames)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def compare_profiles(profile_a: str, profile_b: str, top_n: int = 10) -> str:
|
|
164
|
+
"""Compare two profiles and return a table of changes."""
|
|
165
|
+
return parser.compare_profiles(profile_a, profile_b, top_n=top_n)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def top_profile(
|
|
169
|
+
pid: Optional[int] = None,
|
|
170
|
+
command: Optional[List[str]] = None,
|
|
171
|
+
duration: int = 5,
|
|
172
|
+
rate: int = 100,
|
|
173
|
+
native: bool = False,
|
|
174
|
+
idle: bool = False,
|
|
175
|
+
gil: bool = False,
|
|
176
|
+
subprocesses: bool = False,
|
|
177
|
+
tail_lines: int = 40,
|
|
178
|
+
) -> str:
|
|
179
|
+
"""Run ``py-spy top`` for a short time and capture the final output.
|
|
180
|
+
|
|
181
|
+
On Windows, ``py-spy top`` cannot be captured through a pipe, so we fall
|
|
182
|
+
back to a short ``record --format raw`` sample and return the hottest frames.
|
|
183
|
+
|
|
184
|
+
Args:
|
|
185
|
+
pid: Process ID. Use this OR command.
|
|
186
|
+
command: Command to run and monitor. Use this OR pid.
|
|
187
|
+
duration: How many seconds to run top (default 5).
|
|
188
|
+
rate: Samples per second (default 100).
|
|
189
|
+
native: Include native frames if supported.
|
|
190
|
+
idle: Include idle threads.
|
|
191
|
+
gil: Only show traces holding the GIL.
|
|
192
|
+
subprocesses: Include child processes.
|
|
193
|
+
tail_lines: Number of trailing lines to return (default 40).
|
|
194
|
+
|
|
195
|
+
Returns:
|
|
196
|
+
The final top summary lines, or a hot-frame table on Windows.
|
|
197
|
+
"""
|
|
198
|
+
if not (pid or command):
|
|
199
|
+
raise ValueError("Either pid or command must be provided")
|
|
200
|
+
if pid and command:
|
|
201
|
+
raise ValueError("Provide either pid or command, not both")
|
|
202
|
+
|
|
203
|
+
if sys.platform == "win32":
|
|
204
|
+
return _top_profile_windows(
|
|
205
|
+
pid=pid,
|
|
206
|
+
command=command,
|
|
207
|
+
duration=duration,
|
|
208
|
+
rate=rate,
|
|
209
|
+
native=native,
|
|
210
|
+
idle=idle,
|
|
211
|
+
gil=gil,
|
|
212
|
+
subprocesses=subprocesses,
|
|
213
|
+
top_n=tail_lines,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
args = [
|
|
217
|
+
"top",
|
|
218
|
+
"-r", str(rate),
|
|
219
|
+
]
|
|
220
|
+
if native:
|
|
221
|
+
args.append("--native")
|
|
222
|
+
if idle:
|
|
223
|
+
args.append("--idle")
|
|
224
|
+
if gil:
|
|
225
|
+
args.append("--gil")
|
|
226
|
+
if subprocesses:
|
|
227
|
+
args.append("--subprocesses")
|
|
228
|
+
|
|
229
|
+
if pid:
|
|
230
|
+
args.extend(["--pid", str(pid)])
|
|
231
|
+
else:
|
|
232
|
+
args.append("--")
|
|
233
|
+
args.extend(command)
|
|
234
|
+
|
|
235
|
+
# top runs indefinitely; collect output for the requested duration then terminate.
|
|
236
|
+
binary = find_py_spy()
|
|
237
|
+
env = os.environ.copy()
|
|
238
|
+
env.setdefault("RUST_LOG", "warn")
|
|
239
|
+
proc = subprocess.Popen(
|
|
240
|
+
[binary, *args],
|
|
241
|
+
stdout=subprocess.PIPE,
|
|
242
|
+
stderr=subprocess.PIPE,
|
|
243
|
+
text=True,
|
|
244
|
+
encoding="utf-8",
|
|
245
|
+
errors="replace",
|
|
246
|
+
env=env,
|
|
247
|
+
)
|
|
248
|
+
|
|
249
|
+
from collections import deque
|
|
250
|
+
import threading
|
|
251
|
+
import time
|
|
252
|
+
|
|
253
|
+
lines: deque[str] = deque(maxlen=tail_lines)
|
|
254
|
+
|
|
255
|
+
def reader() -> None:
|
|
256
|
+
try:
|
|
257
|
+
for line in proc.stdout:
|
|
258
|
+
lines.append(line.rstrip("\n"))
|
|
259
|
+
except Exception:
|
|
260
|
+
pass
|
|
261
|
+
|
|
262
|
+
reader_thread = threading.Thread(target=reader, daemon=True)
|
|
263
|
+
reader_thread.start()
|
|
264
|
+
|
|
265
|
+
time.sleep(duration)
|
|
266
|
+
proc.terminate()
|
|
267
|
+
try:
|
|
268
|
+
proc.wait(timeout=5)
|
|
269
|
+
except subprocess.TimeoutExpired:
|
|
270
|
+
proc.kill()
|
|
271
|
+
proc.wait(timeout=5)
|
|
272
|
+
|
|
273
|
+
reader_thread.join(timeout=5)
|
|
274
|
+
|
|
275
|
+
if not lines and proc.stderr:
|
|
276
|
+
return proc.stderr.read()
|
|
277
|
+
return "\n".join(lines)
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _top_profile_windows(
|
|
281
|
+
pid: Optional[int],
|
|
282
|
+
command: Optional[List[str]],
|
|
283
|
+
duration: int,
|
|
284
|
+
rate: int,
|
|
285
|
+
native: bool,
|
|
286
|
+
idle: bool,
|
|
287
|
+
gil: bool,
|
|
288
|
+
subprocesses: bool,
|
|
289
|
+
top_n: int,
|
|
290
|
+
) -> str:
|
|
291
|
+
"""Windows fallback for top_profile using a short raw recording."""
|
|
292
|
+
fd, path = tempfile.mkstemp(suffix=".txt", prefix="pyspy_top_")
|
|
293
|
+
os.close(fd)
|
|
294
|
+
try:
|
|
295
|
+
args = [
|
|
296
|
+
"record",
|
|
297
|
+
"-o", path,
|
|
298
|
+
"--format", "raw",
|
|
299
|
+
"-d", str(duration),
|
|
300
|
+
"-r", str(rate),
|
|
301
|
+
]
|
|
302
|
+
if native:
|
|
303
|
+
args.append("--native")
|
|
304
|
+
if idle:
|
|
305
|
+
args.append("--idle")
|
|
306
|
+
if gil:
|
|
307
|
+
args.append("--gil")
|
|
308
|
+
if subprocesses:
|
|
309
|
+
args.append("--subprocesses")
|
|
310
|
+
|
|
311
|
+
if pid:
|
|
312
|
+
args.extend(["--pid", str(pid)])
|
|
313
|
+
else:
|
|
314
|
+
args.append("--")
|
|
315
|
+
args.extend(command)
|
|
316
|
+
|
|
317
|
+
result = _run_py_spy(args, timeout=duration + 30)
|
|
318
|
+
if result.returncode != 0:
|
|
319
|
+
raise RuntimeError(
|
|
320
|
+
f"py-spy top (Windows fallback) failed (exit {result.returncode}):\n{result.stderr}"
|
|
321
|
+
)
|
|
322
|
+
|
|
323
|
+
frames = parser.parse_raw(path, top_n=top_n)
|
|
324
|
+
if not frames:
|
|
325
|
+
return "No samples collected."
|
|
326
|
+
return parser.format_hot_frames(frames)
|
|
327
|
+
finally:
|
|
328
|
+
try:
|
|
329
|
+
os.unlink(path)
|
|
330
|
+
except OSError:
|
|
331
|
+
pass
|
|
332
|
+
|