profiling-explorer 1.2.0__tar.gz → 1.3.1__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.
Files changed (32) hide show
  1. {profiling_explorer-1.2.0/src/profiling_explorer.egg-info → profiling_explorer-1.3.1}/PKG-INFO +32 -13
  2. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/README.rst +21 -3
  3. profiling_explorer-1.3.1/pyproject.toml +124 -0
  4. profiling_explorer-1.2.0/pyproject.toml → profiling_explorer-1.3.1/pyproject.toml.orig +15 -14
  5. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/static/script.js +38 -1
  6. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/static/styles.css +17 -0
  7. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/templates/_row.html +2 -1
  8. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/templates/callers_callees.html +1 -1
  9. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/urls.py +1 -0
  10. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/views.py +68 -17
  11. profiling_explorer-1.2.0/CHANGELOG.rst +0 -50
  12. profiling_explorer-1.2.0/MANIFEST.in +0 -8
  13. profiling_explorer-1.2.0/PKG-INFO +0 -155
  14. profiling_explorer-1.2.0/setup.cfg +0 -4
  15. profiling_explorer-1.2.0/src/profiling_explorer.egg-info/SOURCES.txt +0 -29
  16. profiling_explorer-1.2.0/src/profiling_explorer.egg-info/dependency_links.txt +0 -1
  17. profiling_explorer-1.2.0/src/profiling_explorer.egg-info/entry_points.txt +0 -2
  18. profiling_explorer-1.2.0/src/profiling_explorer.egg-info/requires.txt +0 -1
  19. profiling_explorer-1.2.0/src/profiling_explorer.egg-info/top_level.txt +0 -1
  20. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/LICENSE +0 -0
  21. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/__init__.py +0 -0
  22. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/__main__.py +0 -0
  23. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/main.py +0 -0
  24. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/management/__init__.py +0 -0
  25. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/management/commands/__init__.py +0 -0
  26. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/management/commands/runserver.py +0 -0
  27. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/py.typed +0 -0
  28. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/settings.py +0 -0
  29. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/templates/base.html +0 -0
  30. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/templates/index.html +0 -0
  31. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/templatetags/__init__.py +0 -0
  32. {profiling_explorer-1.2.0 → profiling_explorer-1.3.1}/src/profiling_explorer/templatetags/profiling_explorer_tags.py +0 -0
@@ -1,29 +1,30 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: profiling-explorer
3
- Version: 1.2.0
3
+ Version: 1.3.1
4
4
  Summary: Table-based exploration tool for Python profiling data (pstats files).
5
+ Keywords: profiler,profiling,pstats
6
+ Author: Adam Johnson
5
7
  Author-email: Adam Johnson <me@adamj.eu>
6
8
  License-Expression: MIT
7
- Project-URL: Changelog, https://github.com/adamchainz/profiling-explorer/blob/main/CHANGELOG.rst
8
- Project-URL: Funding, https://adamj.eu/books/
9
- Project-URL: Repository, https://github.com/adamchainz/profiling-explorer
10
- Keywords: profiler,profiling,pstats
9
+ License-File: LICENSE
11
10
  Classifier: Development Status :: 5 - Production/Stable
12
11
  Classifier: Intended Audience :: Developers
13
12
  Classifier: Natural Language :: English
14
13
  Classifier: Operating System :: OS Independent
15
14
  Classifier: Programming Language :: Python :: 3 :: Only
16
- Classifier: Programming Language :: Python :: 3.10
17
15
  Classifier: Programming Language :: Python :: 3.11
18
16
  Classifier: Programming Language :: Python :: 3.12
19
17
  Classifier: Programming Language :: Python :: 3.13
20
18
  Classifier: Programming Language :: Python :: 3.14
19
+ Classifier: Programming Language :: Python :: 3.15
21
20
  Classifier: Typing :: Typed
22
- Requires-Python: >=3.10
23
- Description-Content-Type: text/x-rst
24
- License-File: LICENSE
25
21
  Requires-Dist: django>=5.2
26
- Dynamic: license-file
22
+ Requires-Dist: launch-editor-py>=1
23
+ Requires-Python: >=3.11
24
+ Project-URL: Changelog, https://github.com/adamchainz/profiling-explorer/blob/main/CHANGELOG.rst
25
+ Project-URL: Funding, https://adamj.eu/books/
26
+ Project-URL: Repository, https://github.com/adamchainz/profiling-explorer
27
+ Description-Content-Type: text/x-rst
27
28
 
28
29
  ==================
29
30
  profiling-explorer
@@ -69,7 +70,7 @@ Read more in `the introductory blog post <https://adamj.eu/tech/2026/04/03/pytho
69
70
  Requirements
70
71
  ------------
71
72
 
72
- Python 3.10 to 3.14 supported.
73
+ Python 3.11 to 3.15 supported.
73
74
 
74
75
  Installation
75
76
  ------------
@@ -110,15 +111,33 @@ Then run ``profiling-explorer`` with the generated file:
110
111
  The report will open in your web browser, and you can explore the profile data with the interactive interface.
111
112
  Features:
112
113
 
113
- * Click the **calls**, **internal ms**, or **cumulative ms** column headers to sort by that column.
114
+ * Click the **calls**, **internal ms**, **cumulative ms**, or **ms/call** column headers to sort by that column.
115
+ **ms/call** is the cumulative time divided by the number of calls, useful for finding functions that are slow each time they run, rather than those called many times.
114
116
  * Use the search box to filter by filename or function name.
115
117
  Separate multiple search terms by spaces to match records that contain all terms.
116
118
  * Press a keyboard shortcut to focus the search box: ``/``, ``Control+K``, or ``Command+K``.
117
119
  Select some text first, like a filename, to pre-fill the search box with that text.
118
120
  * Hover by a filename + line number pair to reveal the copy button, which copies the location to your clipboard for faster opening.
121
+ * Click a filename + line number pair to open that file at that line in your editor.
122
+ See `Opening files in your editor`_ below.
119
123
  * Click the **callers** or **callees** links on the right of a row to see the callers or callees of that function.
124
+ The **← back** link returns to the main table with your previous search and sort order.
125
+
126
+ Opening files in your editor
127
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
128
+
129
+ When you click a filename, profiling-explorer runs your editor to open the file at that line, using `launch-editor-py <https://pypi.org/project/launch-editor-py/>`__.
130
+ It picks the editor from one of these options, in this order:
131
+
132
+ 1. The ``LAUNCH_EDITOR`` environment variable, for override.s
133
+ 2. A supported editor that’s currently running, such as VS Code, Sublime Text, or Zed.
134
+ 3. The ``VISUAL`` environment variable.
135
+ 4. The ``EDITOR`` environment variable.
136
+
137
+ Terminal editors like Vim open in the terminal running profiling-explorer.
120
138
 
121
- Full help:
139
+ Full help
140
+ ~~~~~~~~~
122
141
 
123
142
  .. [[[cog
124
143
  .. import cog
@@ -42,7 +42,7 @@ Read more in `the introductory blog post <https://adamj.eu/tech/2026/04/03/pytho
42
42
  Requirements
43
43
  ------------
44
44
 
45
- Python 3.10 to 3.14 supported.
45
+ Python 3.11 to 3.15 supported.
46
46
 
47
47
  Installation
48
48
  ------------
@@ -83,15 +83,33 @@ Then run ``profiling-explorer`` with the generated file:
83
83
  The report will open in your web browser, and you can explore the profile data with the interactive interface.
84
84
  Features:
85
85
 
86
- * Click the **calls**, **internal ms**, or **cumulative ms** column headers to sort by that column.
86
+ * Click the **calls**, **internal ms**, **cumulative ms**, or **ms/call** column headers to sort by that column.
87
+ **ms/call** is the cumulative time divided by the number of calls, useful for finding functions that are slow each time they run, rather than those called many times.
87
88
  * Use the search box to filter by filename or function name.
88
89
  Separate multiple search terms by spaces to match records that contain all terms.
89
90
  * Press a keyboard shortcut to focus the search box: ``/``, ``Control+K``, or ``Command+K``.
90
91
  Select some text first, like a filename, to pre-fill the search box with that text.
91
92
  * Hover by a filename + line number pair to reveal the copy button, which copies the location to your clipboard for faster opening.
93
+ * Click a filename + line number pair to open that file at that line in your editor.
94
+ See `Opening files in your editor`_ below.
92
95
  * Click the **callers** or **callees** links on the right of a row to see the callers or callees of that function.
96
+ The **← back** link returns to the main table with your previous search and sort order.
93
97
 
94
- Full help:
98
+ Opening files in your editor
99
+ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~
100
+
101
+ When you click a filename, profiling-explorer runs your editor to open the file at that line, using `launch-editor-py <https://pypi.org/project/launch-editor-py/>`__.
102
+ It picks the editor from one of these options, in this order:
103
+
104
+ 1. The ``LAUNCH_EDITOR`` environment variable, for override.s
105
+ 2. A supported editor that’s currently running, such as VS Code, Sublime Text, or Zed.
106
+ 3. The ``VISUAL`` environment variable.
107
+ 4. The ``EDITOR`` environment variable.
108
+
109
+ Terminal editors like Vim open in the terminal running profiling-explorer.
110
+
111
+ Full help
112
+ ~~~~~~~~~
95
113
 
96
114
  .. [[[cog
97
115
  .. import cog
@@ -0,0 +1,124 @@
1
+ [build-system]
2
+ build-backend = "uv_build"
3
+ requires = ["uv-build>=0.12.1,<0.13"]
4
+
5
+ [project]
6
+ name = "profiling-explorer"
7
+ version = "1.3.1"
8
+ description = "Table-based exploration tool for Python profiling data (pstats files)."
9
+ readme = "README.rst"
10
+ keywords = [
11
+ "profiler",
12
+ "profiling",
13
+ "pstats",
14
+ ]
15
+ license = "MIT"
16
+ license-files = ["LICENSE"]
17
+ requires-python = ">=3.11"
18
+ classifiers = [
19
+ "Development Status :: 5 - Production/Stable",
20
+ "Intended Audience :: Developers",
21
+ "Natural Language :: English",
22
+ "Operating System :: OS Independent",
23
+ "Programming Language :: Python :: 3 :: Only",
24
+ "Programming Language :: Python :: 3.11",
25
+ "Programming Language :: Python :: 3.12",
26
+ "Programming Language :: Python :: 3.13",
27
+ "Programming Language :: Python :: 3.14",
28
+ "Programming Language :: Python :: 3.15",
29
+ "Typing :: Typed",
30
+ ]
31
+ dependencies = [
32
+ "django>=5.2",
33
+ "launch-editor-py>=1",
34
+ ]
35
+
36
+ [[project.authors]]
37
+ name = "Adam Johnson"
38
+ email = "me@adamj.eu"
39
+
40
+ [project.urls]
41
+ Changelog = "https://github.com/adamchainz/profiling-explorer/blob/main/CHANGELOG.rst"
42
+ Funding = "https://adamj.eu/books/"
43
+ Repository = "https://github.com/adamchainz/profiling-explorer"
44
+
45
+ [project.scripts]
46
+ profiling-explorer = "profiling_explorer.main:main"
47
+
48
+ [dependency-groups]
49
+ test = [
50
+ "cogapp>=3.6",
51
+ "coverage[toml]>=7.10",
52
+ "pytest>=9",
53
+ "pytest-django",
54
+ "pytest-randomly",
55
+ ]
56
+
57
+ [tool.ruff.lint]
58
+ select = [
59
+ "B",
60
+ "C4",
61
+ "E",
62
+ "F",
63
+ "I",
64
+ "SIM",
65
+ "TID",
66
+ "UP",
67
+ "W",
68
+ ]
69
+ ignore = [
70
+ "B9",
71
+ "E501",
72
+ "SIM105",
73
+ "SIM108",
74
+ ]
75
+ extend-safe-fixes = ["UP006"]
76
+
77
+ [tool.ruff.lint.isort]
78
+ required-imports = ["from __future__ import annotations"]
79
+
80
+ [tool.pyproject-fmt]
81
+ max_supported_python = "3.15"
82
+
83
+ [tool.mypy]
84
+ mypy_path = "src/"
85
+ namespace_packages = false
86
+ warn_unreachable = true
87
+ enable_error_code = [
88
+ "ignore-without-code",
89
+ "redundant-expr",
90
+ "truthy-bool",
91
+ ]
92
+ strict = true
93
+
94
+ [[tool.mypy.overrides]]
95
+ module = "tests.*"
96
+ allow_untyped_defs = true
97
+
98
+ [tool.pytest]
99
+ django_find_project = false
100
+ DJANGO_SETTINGS_MODULE = "profiling_explorer.settings"
101
+ strict = true
102
+
103
+ [tool.coverage.run]
104
+ source = [
105
+ "profiling_explorer",
106
+ "tests",
107
+ ]
108
+ branch = true
109
+ data_file = ".coverage/cov"
110
+ parallel = true
111
+
112
+ [tool.coverage.paths]
113
+ source = [
114
+ "src",
115
+ ".tox/**/site-packages",
116
+ ]
117
+
118
+ [tool.coverage.report]
119
+ skip_covered = true
120
+ skip_empty = true
121
+ show_missing = true
122
+
123
+ [tool.rstcheck]
124
+ report_level = "ERROR"
@@ -1,12 +1,12 @@
1
1
  [build-system]
2
- build-backend = "setuptools.build_meta"
2
+ build-backend = "uv_build"
3
3
  requires = [
4
- "setuptools>=77",
4
+ "uv-build>=0.12.1,<0.13",
5
5
  ]
6
6
 
7
7
  [project]
8
8
  name = "profiling-explorer"
9
- version = "1.2.0"
9
+ version = "1.3.1"
10
10
  description = "Table-based exploration tool for Python profiling data (pstats files)."
11
11
  readme = "README.rst"
12
12
  keywords = [
@@ -19,22 +19,23 @@ license-files = [ "LICENSE" ]
19
19
  authors = [
20
20
  { name = "Adam Johnson", email = "me@adamj.eu" },
21
21
  ]
22
- requires-python = ">=3.10"
22
+ requires-python = ">=3.11"
23
23
  classifiers = [
24
24
  "Development Status :: 5 - Production/Stable",
25
25
  "Intended Audience :: Developers",
26
26
  "Natural Language :: English",
27
27
  "Operating System :: OS Independent",
28
28
  "Programming Language :: Python :: 3 :: Only",
29
- "Programming Language :: Python :: 3.10",
30
29
  "Programming Language :: Python :: 3.11",
31
30
  "Programming Language :: Python :: 3.12",
32
31
  "Programming Language :: Python :: 3.13",
33
32
  "Programming Language :: Python :: 3.14",
33
+ "Programming Language :: Python :: 3.15",
34
34
  "Typing :: Typed",
35
35
  ]
36
36
  dependencies = [
37
37
  "django>=5.2",
38
+ "launch-editor-py>=1",
38
39
  ]
39
40
  urls.Changelog = "https://github.com/adamchainz/profiling-explorer/blob/main/CHANGELOG.rst"
40
41
  urls.Funding = "https://adamj.eu/books/"
@@ -88,40 +89,40 @@ lint.extend-safe-fixes = [
88
89
  lint.isort.required-imports = [ "from __future__ import annotations" ]
89
90
 
90
91
  [tool.pyproject-fmt]
91
- max_supported_python = "3.14"
92
+ max_supported_python = "3.15"
92
93
 
93
94
  [tool.mypy]
95
+ mypy_path = "src/"
96
+ namespace_packages = false
97
+ warn_unreachable = true
94
98
  enable_error_code = [
95
99
  "ignore-without-code",
96
100
  "redundant-expr",
97
101
  "truthy-bool",
98
102
  ]
99
- mypy_path = "src/"
100
- namespace_packages = false
101
103
  strict = true
102
- warn_unreachable = true
103
104
  overrides = [ { module = "tests.*", allow_untyped_defs = true } ]
104
105
 
105
106
  [tool.pytest]
106
- strict = true
107
107
  django_find_project = false
108
108
  DJANGO_SETTINGS_MODULE = "profiling_explorer.settings"
109
+ strict = true
109
110
 
110
111
  [tool.coverage]
111
- run.branch = true
112
- run.data_file = ".coverage/cov"
113
- run.parallel = true
114
112
  run.source = [
115
113
  "profiling_explorer",
116
114
  "tests",
117
115
  ]
116
+ run.branch = true
117
+ run.data_file = ".coverage/cov"
118
+ run.parallel = true
118
119
  paths.source = [
119
120
  "src",
120
121
  ".tox/**/site-packages",
121
122
  ]
122
- report.show_missing = true
123
123
  report.skip_covered = true
124
124
  report.skip_empty = true
125
+ report.show_missing = true
125
126
 
126
127
  [tool.rstcheck]
127
128
  report_level = "ERROR"
@@ -7,7 +7,7 @@ async function fetchDoc(url, options) {
7
7
  // Filter box
8
8
  const searchInput = document.getElementById('pe-search');
9
9
 
10
- document.addEventListener('keydown', (e) => {
10
+ document.addEventListener('keydown', (event) => {
11
11
  const target = event.composedPath()?.[0] || event.target;
12
12
  const isTextField =
13
13
  target instanceof HTMLElement &&
@@ -43,6 +43,26 @@ searchInput.addEventListener('input', () => {
43
43
 
44
44
  let searchAbort = null;
45
45
 
46
+ // Remember the index page's URL, with its search and sort, for back links
47
+ const INDEX_URL_KEY = 'pe-index-url';
48
+
49
+ function rememberIndexUrl() {
50
+ if (window.location.pathname !== '/') return;
51
+ try {
52
+ sessionStorage.setItem(INDEX_URL_KEY, window.location.search);
53
+ } catch {}
54
+ }
55
+
56
+ rememberIndexUrl();
57
+
58
+ const backLink = document.querySelector('a.back-link');
59
+ if (backLink) {
60
+ try {
61
+ const search = sessionStorage.getItem(INDEX_URL_KEY);
62
+ if (search) backLink.href = '/' + search;
63
+ } catch {}
64
+ }
65
+
46
66
  async function doSearch() {
47
67
  const q = searchInput.value.trim();
48
68
  const url = new URL(window.location.href);
@@ -52,6 +72,7 @@ async function doSearch() {
52
72
  url.searchParams.delete('q');
53
73
  }
54
74
  history.replaceState(null, '', url);
75
+ rememberIndexUrl();
55
76
 
56
77
  searchAbort?.abort();
57
78
  searchAbort = new AbortController();
@@ -75,6 +96,19 @@ document.addEventListener('click', (e) => {
75
96
  });
76
97
  });
77
98
 
99
+ // Open-in-editor buttons
100
+ document.addEventListener('click', async (e) => {
101
+ const btn = e.target.closest('button.open-btn');
102
+ if (!btn) return;
103
+
104
+ const response = await fetch(btn.dataset.url, { method: 'POST' });
105
+ if (!response.ok) {
106
+ btn.classList.add('failed');
107
+ setTimeout(() => btn.classList.remove('failed'), 1500);
108
+ alert(await response.text());
109
+ }
110
+ });
111
+
78
112
 
79
113
  // Pagination
80
114
  class PePaginationMarker extends HTMLElement {
@@ -98,6 +132,9 @@ class PePaginationMarker extends HTMLElement {
98
132
  async _load() {
99
133
  const doc = await fetchDoc(this.dataset.url);
100
134
 
135
+ // The table may have been replaced by a search while fetching.
136
+ if (!this.isConnected) return;
137
+
101
138
  const tbody = document.querySelector('tbody');
102
139
  for (const row of doc.querySelectorAll('tbody tr')) {
103
140
  tbody.appendChild(document.adoptNode(row));
@@ -198,6 +198,23 @@ pe-funcname {
198
198
  color: var(--funcname);
199
199
  }
200
200
 
201
+ button.open-btn {
202
+ background: none;
203
+ border: none;
204
+ padding: 0;
205
+ font: inherit;
206
+ color: inherit;
207
+ cursor: pointer;
208
+
209
+ &:hover {
210
+ text-decoration: underline;
211
+ }
212
+
213
+ &.failed {
214
+ color: red;
215
+ }
216
+ }
217
+
201
218
  button.copy-btn {
202
219
  display: inline-flex;
203
220
  align-items: center;
@@ -5,5 +5,6 @@
5
5
  <td>{% if stats.internal_ms != None %}{{ stats.internal_ms|intcomma }}{% endif %}
6
6
  <td>{{ stats.cumulative_ms|intcomma }}
7
7
  <td class="{{ stats.cumulative_ms_pct|pct_class }}">{{ stats.cumulative_ms_pct|pct }}
8
- <td class=filename title="{{ row.full_filename }}"><button {% if not row.full_filename %}disabled{% endif %} class=copy-btn data-copy="{{ row.full_filename }}:{{ row.lineno }}" aria-label="Copy"><svg aria-hidden="true" width="18" height="18"><use href="#icon-copy"></use></svg></button>{% if row.filename %}{{ row.filename }}:{{ row.lineno }} {% endif %}<pe-funcname>{{ row.funcname }}</pe-funcname>
8
+ <td>{{ stats.per_call_ms|intcomma }}
9
+ <td class=filename title="{{ row.full_filename }}"><button {% if not row.full_filename %}disabled{% endif %} class=copy-btn data-copy="{{ row.full_filename }}:{{ row.lineno }}" aria-label="Copy"><svg aria-hidden="true" width="18" height="18"><use href="#icon-copy"></use></svg></button>{% if row.filename %}{% if row.is_file %}<button class=open-btn data-url="/open/{{ row.id }}/" title="Open in editor">{{ row.filename }}:{{ row.lineno }}</button>{% else %}{{ row.filename }}:{{ row.lineno }}{% endif %} {% endif %}<pe-funcname>{{ row.funcname }}</pe-funcname>
9
10
  <td>{% if row.id %}<a class=callexs href="/callers/{{ row.id }}/"><svg aria-hidden="true" width="18" height="18"><use href="#icon-tree" transform="rotate(180 9 9)"></use></svg> callers</a> <a class=callexs href="/callees/{{ row.id }}/"><svg aria-hidden="true" width="18" height="18"><use href="#icon-tree"></use></svg> callees</a>{% endif %}
@@ -18,7 +18,7 @@
18
18
  <th>
19
19
  </thead>
20
20
  <tbody>
21
- {% include "_row.html" with row=focal_row stats=focal_row focal=True only %}
21
+ {% if first_page %}{% include "_row.html" with row=focal_row stats=focal_row focal=True only %}{% endif %}
22
22
  {% for row, edge in rows_with_edges %}
23
23
  {% include "_row.html" with row=row stats=edge focal=False only %}
24
24
  {% endfor %}
@@ -8,6 +8,7 @@ urlpatterns = [
8
8
  path("", views.index, name="index"),
9
9
  path("callers/<str:row_id>/", views.callers_view, name="callers"),
10
10
  path("callees/<str:row_id>/", views.callees_view, name="callees"),
11
+ path("open/<str:row_id>/", views.open_in_editor, name="open"),
11
12
  path("favicon.ico", views.favicon),
12
13
  path("styles.css", views.file, {"filename": "styles.css"}),
13
14
  path("script.js", views.file, {"filename": "script.js"}),
@@ -10,9 +10,17 @@ from importlib.resources import files as resource_files
10
10
  from typing import Any
11
11
  from urllib.parse import urlencode
12
12
 
13
- from django.http import FileResponse, Http404, HttpRequest, HttpResponse
13
+ from django.http import (
14
+ FileResponse,
15
+ Http404,
16
+ HttpRequest,
17
+ HttpResponse,
18
+ HttpResponseBadRequest,
19
+ HttpResponseForbidden,
20
+ )
14
21
  from django.shortcuts import render
15
- from django.views.decorators.http import require_GET
22
+ from django.views.decorators.http import require_GET, require_POST
23
+ from launch_editor import LaunchEditorError, launch_editor
16
24
 
17
25
 
18
26
  @dataclass(slots=True)
@@ -22,6 +30,7 @@ class RowStats:
22
30
  internal_ms: int | None
23
31
  cumulative_ms: int
24
32
  cumulative_ms_pct: float
33
+ per_call_ms: int
25
34
 
26
35
 
27
36
  @dataclass(slots=True)
@@ -31,6 +40,8 @@ class Row(RowStats):
31
40
  full_filename: str
32
41
  lineno: int
33
42
  funcname: str
43
+ # Whether the file exists, so can be opened in an editor
44
+ is_file: bool
34
45
 
35
46
 
36
47
  @dataclass(slots=True)
@@ -78,7 +89,11 @@ def _shorten_filename_function(filename: str, funcname: str) -> tuple[str, str,
78
89
  if match:
79
90
  short_filename = filename[match.end() :]
80
91
  else:
81
- short_filename = os.path.relpath(filename)
92
+ try:
93
+ short_filename = os.path.relpath(filename)
94
+ except ValueError:
95
+ # On Windows, paths on a different drive can't be relative.
96
+ short_filename = filename
82
97
  full_filename = filename
83
98
  return full_filename, short_filename, funcname
84
99
 
@@ -91,7 +106,7 @@ def _row_id(full_filename: str, lineno: int, funcname: str) -> str:
91
106
 
92
107
  def _row_id_from_pstats_key(key: tuple[str, int, str]) -> str:
93
108
  filename, lineno, funcname = key
94
- full_filename = "" if filename == "~" else filename
109
+ full_filename, _, funcname = _shorten_filename_function(filename, funcname)
95
110
  return _row_id(full_filename, lineno, funcname)
96
111
 
97
112
 
@@ -103,13 +118,18 @@ def build_profile(s: pstats.Stats, path: str) -> Profile:
103
118
  rows_by_id = {}
104
119
  callers_map: defaultdict[str, dict[str, RowStats]] = defaultdict(dict)
105
120
  callees_map: defaultdict[str, dict[str, RowStats]] = defaultdict(dict)
121
+ is_file_cache: dict[str, bool] = {}
106
122
  for key in s.fcn_list: # type: ignore[attr-defined]
107
123
  filename, lineno, funcname = key
108
124
  _, calls, tottime, cumtime, callers = s.stats[key] # type: ignore[attr-defined]
109
125
  full_filename, short_filename, funcname = _shorten_filename_function(
110
126
  filename, funcname
111
127
  )
112
- row_id = _row_id(full_filename, lineno, funcname)
128
+ row_id = _row_id_from_pstats_key(key)
129
+ if full_filename not in is_file_cache:
130
+ is_file_cache[full_filename] = bool(full_filename) and os.path.isfile(
131
+ full_filename
132
+ )
113
133
  cumulative_ms = round(cumtime * 1_000)
114
134
  row = Row(
115
135
  id=row_id,
@@ -120,23 +140,17 @@ def build_profile(s: pstats.Stats, path: str) -> Profile:
120
140
  cumulative_ms_pct=min(100.0, cumulative_ms / total_time_ms * 100)
121
141
  if total_time_ms
122
142
  else 0.0,
143
+ per_call_ms=round(cumtime * 1_000 / calls) if calls else 0,
123
144
  filename=short_filename,
124
145
  full_filename=full_filename,
125
146
  lineno=lineno,
126
147
  funcname=funcname,
148
+ is_file=is_file_cache[full_filename],
127
149
  )
128
150
  rows.append(row)
129
151
  rows_by_id[row_id] = row
130
- for (caller_filename, caller_lineno, caller_funcname), (
131
- callee_calls,
132
- _,
133
- __,
134
- callee_cumtime,
135
- ) in callers.items():
136
- caller_filename, _, caller_funcname = _shorten_filename_function(
137
- caller_filename, caller_funcname
138
- )
139
- caller_id = _row_id(caller_filename, caller_lineno, caller_funcname)
152
+ for caller_key, (callee_calls, _, __, callee_cumtime) in callers.items():
153
+ caller_id = _row_id_from_pstats_key(caller_key)
140
154
  callee_cumulative_ms = round(callee_cumtime * 1_000)
141
155
  edge = RowStats(
142
156
  calls=callee_calls,
@@ -150,6 +164,9 @@ def build_profile(s: pstats.Stats, path: str) -> Profile:
150
164
  if total_time_ms
151
165
  else 0.0
152
166
  ),
167
+ per_call_ms=(
168
+ round(callee_cumtime * 1_000 / callee_calls) if callee_calls else 0
169
+ ),
153
170
  )
154
171
  callers_map[row_id][caller_id] = edge
155
172
  callees_map[caller_id][row_id] = edge
@@ -179,7 +196,7 @@ def _build_edge_rows(
179
196
 
180
197
  PAGE_SIZE = 200
181
198
 
182
- _VALID_SORT_COLS = {"calls", "internal_ms", "cumulative_ms"}
199
+ _VALID_SORT_COLS = {"calls", "internal_ms", "cumulative_ms", "per_call_ms"}
183
200
 
184
201
 
185
202
  def _render_table(
@@ -225,7 +242,10 @@ def _render_table(
225
242
  reverse=sort_desc,
226
243
  )
227
244
 
228
- offset = int(request.GET.get("offset", 0))
245
+ try:
246
+ offset = max(0, int(request.GET.get("offset", 0)))
247
+ except ValueError:
248
+ offset = 0
229
249
  page_rows = sorted_rows[offset : offset + PAGE_SIZE]
230
250
 
231
251
  next_url = None
@@ -249,12 +269,14 @@ def _render_table(
249
269
  context = {
250
270
  "profile": profile,
251
271
  "rows": page_rows,
272
+ "first_page": offset == 0,
252
273
  "next_url": next_url,
253
274
  "q": q,
254
275
  "columns": [
255
276
  col_config("calls", "calls"),
256
277
  col_config("internal_ms", "internal ms"),
257
278
  col_config("cumulative_ms", "cumulative ms"),
279
+ col_config("per_call_ms", "ms/call"),
258
280
  ],
259
281
  **extra_context,
260
282
  }
@@ -324,6 +346,35 @@ def callees_view(request: HttpRequest, row_id: str) -> HttpResponse:
324
346
  )
325
347
 
326
348
 
349
+ _LOCAL_HOSTS = {"127.0.0.1", "localhost", "[::1]"}
350
+
351
+
352
+ @require_POST
353
+ def open_in_editor(request: HttpRequest, row_id: str) -> HttpResponse:
354
+ # This view runs a program, so only accept requests from our own pages.
355
+ # Websites can send cross-origin POSTs to localhost, and DNS rebinding can
356
+ # make another hostname resolve here.
357
+ host = request.get_host().rsplit(":", 1)[0]
358
+ if host not in _LOCAL_HOSTS:
359
+ return HttpResponseForbidden("Invalid host.")
360
+ if request.headers.get("Sec-Fetch-Site", "same-origin") != "same-origin":
361
+ return HttpResponseForbidden("Cross-site request.")
362
+ origin = request.headers.get("Origin")
363
+ if origin is not None and origin != f"{request.scheme}://{request.get_host()}":
364
+ return HttpResponseForbidden("Cross-origin request.")
365
+
366
+ row = profile.rows_by_id.get(row_id)
367
+ if row is None or not row.is_file:
368
+ raise Http404()
369
+
370
+ try:
371
+ launch_editor(row.full_filename, row.lineno)
372
+ except LaunchEditorError as exc:
373
+ return HttpResponseBadRequest(str(exc))
374
+
375
+ return HttpResponse(status=204)
376
+
377
+
327
378
  @require_GET
328
379
  def file(request: HttpRequest, *, filename: str) -> FileResponse:
329
380
  return FileResponse(
@@ -1,50 +0,0 @@
1
- =========
2
- Changelog
3
- =========
4
-
5
- 1.2.0 (2026-05-18)
6
- ------------------
7
-
8
- * Enable changing the sort order in callees and callers views.
9
-
10
- `PR #43 <https://github.com/adamchainz/profiling-explorer/pull/43>`__.
11
-
12
- 1.1.2 (2026-04-14)
13
- ------------------
14
-
15
- * Fix caller percentage calculation.
16
-
17
- `PR #37 <https://github.com/adamchainz/profiling-explorer/pull/37>`__.
18
-
19
- 1.1.1 (2026-04-13)
20
- ------------------
21
-
22
- * Fix filename simplification.
23
-
24
- `PR #36 <https://github.com/adamchainz/profiling-explorer/pull/36>`__.
25
-
26
- 1.1.0 (2026-04-13)
27
- ------------------
28
-
29
- * Extend search to support multiple terms.
30
-
31
- `PR #33 <https://github.com/adamchainz/profiling-explorer/pull/33>`__.
32
-
33
- * Add keyboard shortcuts to focus the search box.
34
-
35
- `PR #35 <https://github.com/adamchainz/profiling-explorer/pull/35>`__.
36
-
37
- * Fix callers/callees views for built-in functions.
38
-
39
- `PR #34 <https://github.com/adamchainz/profiling-explorer/pull/34>`__.
40
- Thanks to David Szotten for the report in `Issue #32 <https://github.com/adamchainz/profiling-explorer/issues/32>`__.
41
-
42
- 1.0.1 (2026-04-02)
43
- ------------------
44
-
45
- * Fix crash when a record has an empty filename.
46
-
47
- 1.0.0 (2026-04-02)
48
- ------------------
49
-
50
- * Initial release.
@@ -1,8 +0,0 @@
1
- prune tests
2
- include CHANGELOG.rst
3
- include LICENSE
4
- include pyproject.toml
5
- include README.rst
6
- include src/*/py.typed
7
- graft src/profiling_explorer/static/
8
- graft src/profiling_explorer/templates/
@@ -1,155 +0,0 @@
1
- Metadata-Version: 2.4
2
- Name: profiling-explorer
3
- Version: 1.2.0
4
- Summary: Table-based exploration tool for Python profiling data (pstats files).
5
- Author-email: Adam Johnson <me@adamj.eu>
6
- License-Expression: MIT
7
- Project-URL: Changelog, https://github.com/adamchainz/profiling-explorer/blob/main/CHANGELOG.rst
8
- Project-URL: Funding, https://adamj.eu/books/
9
- Project-URL: Repository, https://github.com/adamchainz/profiling-explorer
10
- Keywords: profiler,profiling,pstats
11
- Classifier: Development Status :: 5 - Production/Stable
12
- Classifier: Intended Audience :: Developers
13
- Classifier: Natural Language :: English
14
- Classifier: Operating System :: OS Independent
15
- Classifier: Programming Language :: Python :: 3 :: Only
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: Programming Language :: Python :: 3.14
21
- Classifier: Typing :: Typed
22
- Requires-Python: >=3.10
23
- Description-Content-Type: text/x-rst
24
- License-File: LICENSE
25
- Requires-Dist: django>=5.2
26
- Dynamic: license-file
27
-
28
- ==================
29
- profiling-explorer
30
- ==================
31
-
32
- .. image:: https://img.shields.io/github/actions/workflow/status/adamchainz/profiling-explorer/main.yml.svg?branch=main&style=for-the-badge
33
- :target: https://github.com/adamchainz/profiling-explorer/actions?workflow=CI
34
-
35
- .. image:: https://img.shields.io/badge/Coverage-70%25-success?style=for-the-badge
36
- :target: https://github.com/adamchainz/profiling-explorer/actions?workflow=CI
37
-
38
- .. image:: https://img.shields.io/pypi/v/profiling-explorer.svg?style=for-the-badge
39
- :target: https://pypi.org/project/profiling-explorer/
40
-
41
- .. image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge
42
- :target: https://github.com/psf/black
43
-
44
- .. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=for-the-badge
45
- :target: https://github.com/pre-commit/pre-commit
46
- :alt: pre-commit
47
-
48
- ----
49
-
50
- Table-based exploration tool for Python profiling data (pstats files).
51
-
52
- .. Generated with:
53
- .. uvx --with django python -m cProfile -o django_utils_html.pstats -m django.utils.html
54
- .. uvr profiling-explorer django_utils_html.pstats
55
- .. …then in another tab:
56
- .. uvx shot-scraper --width 1280 --height 720 --retina --wait 1000 http://127.0.0.1:8099/ -o screenshot.png
57
-
58
- .. figure:: https://raw.githubusercontent.com/adamchainz/profiling-explorer/main/screenshot.png
59
- :alt: profiling-explorer screenshot
60
-
61
- Read more in `the introductory blog post <https://adamj.eu/tech/2026/04/03/python-introducing-profiling-explorer/>`__.
62
-
63
- ----
64
-
65
- **Get better at command line Git** with my book `Boost Your Git DX <https://adamchainz.gumroad.com/l/bygdx>`__.
66
-
67
- ----
68
-
69
- Requirements
70
- ------------
71
-
72
- Python 3.10 to 3.14 supported.
73
-
74
- Installation
75
- ------------
76
-
77
- 1. Install with **pip**:
78
-
79
- .. code-block:: sh
80
-
81
- python -m pip install profiling-explorer
82
-
83
- Usage
84
- -----
85
-
86
- ``profiling-explorer`` reads |pstats|__ files as generated by Python’s profilers: |profiling.tracing|__ (called ``cProfile`` on Python < 3.15) and |profiling.sampling|__ (new in Python 3.15).
87
- To use it, first generate a profile file, for example by running your program under cProfile:
88
-
89
- .. |pstats| replace:: ``pstats``
90
- __ https://docs.python.org/3.15/library/pstats.html
91
-
92
- .. |profiling.tracing| replace:: ``profiling.tracing``
93
- __ https://docs.python.org/3.15/library/profiling.tracing.html
94
-
95
- .. |profiling.sampling| replace:: ``profiling.sampling``
96
- __ https://docs.python.org/3.15/library/profiling.sampling.html
97
-
98
- .. code-block:: console
99
-
100
- $ python -m cProfile -o example.pstats example.py
101
-
102
- (Also runnable as ``python -m profiling.tracing`` instead on Python 3.15+.)
103
-
104
- Then run ``profiling-explorer`` with the generated file:
105
-
106
- .. code-block:: console
107
-
108
- $ profiling-explorer example.pstats
109
-
110
- The report will open in your web browser, and you can explore the profile data with the interactive interface.
111
- Features:
112
-
113
- * Click the **calls**, **internal ms**, or **cumulative ms** column headers to sort by that column.
114
- * Use the search box to filter by filename or function name.
115
- Separate multiple search terms by spaces to match records that contain all terms.
116
- * Press a keyboard shortcut to focus the search box: ``/``, ``Control+K``, or ``Command+K``.
117
- Select some text first, like a filename, to pre-fill the search box with that text.
118
- * Hover by a filename + line number pair to reveal the copy button, which copies the location to your clipboard for faster opening.
119
- * Click the **callers** or **callees** links on the right of a row to see the callers or callees of that function.
120
-
121
- Full help:
122
-
123
- .. [[[cog
124
- .. import cog
125
- .. import subprocess
126
- .. import sys
127
- .. result = subprocess.run(
128
- .. [sys.executable, "-m", "profiling_explorer", "--help"],
129
- .. capture_output=True,
130
- .. text=True,
131
- .. )
132
- .. cog.outl("")
133
- .. cog.outl(".. code-block:: console")
134
- .. cog.outl("")
135
- .. for line in result.stdout.splitlines():
136
- .. if line.strip() == "":
137
- .. cog.outl("")
138
- .. else:
139
- .. cog.outl(" " + line.rstrip())
140
- .. cog.outl("")
141
- .. ]]]
142
-
143
- .. code-block:: console
144
-
145
- usage: profiling-explorer [-h] [--port PORT] [--dev] FILE
146
-
147
- positional arguments:
148
- FILE The pstats data file to explore.
149
-
150
- options:
151
- -h, --help show this help message and exit
152
- --port PORT Port for the local web server (default: 8099).
153
- --dev Run in development mode (enables server reload and debug mode).
154
-
155
- .. [[[end]]]
@@ -1,4 +0,0 @@
1
- [egg_info]
2
- tag_build =
3
- tag_date = 0
4
-
@@ -1,29 +0,0 @@
1
- CHANGELOG.rst
2
- LICENSE
3
- MANIFEST.in
4
- README.rst
5
- pyproject.toml
6
- src/profiling_explorer/__init__.py
7
- src/profiling_explorer/__main__.py
8
- src/profiling_explorer/main.py
9
- src/profiling_explorer/py.typed
10
- src/profiling_explorer/settings.py
11
- src/profiling_explorer/urls.py
12
- src/profiling_explorer/views.py
13
- src/profiling_explorer.egg-info/PKG-INFO
14
- src/profiling_explorer.egg-info/SOURCES.txt
15
- src/profiling_explorer.egg-info/dependency_links.txt
16
- src/profiling_explorer.egg-info/entry_points.txt
17
- src/profiling_explorer.egg-info/requires.txt
18
- src/profiling_explorer.egg-info/top_level.txt
19
- src/profiling_explorer/management/__init__.py
20
- src/profiling_explorer/management/commands/__init__.py
21
- src/profiling_explorer/management/commands/runserver.py
22
- src/profiling_explorer/static/script.js
23
- src/profiling_explorer/static/styles.css
24
- src/profiling_explorer/templates/_row.html
25
- src/profiling_explorer/templates/base.html
26
- src/profiling_explorer/templates/callers_callees.html
27
- src/profiling_explorer/templates/index.html
28
- src/profiling_explorer/templatetags/__init__.py
29
- src/profiling_explorer/templatetags/profiling_explorer_tags.py
@@ -1,2 +0,0 @@
1
- [console_scripts]
2
- profiling-explorer = profiling_explorer.main:main
@@ -1 +0,0 @@
1
- profiling_explorer