profiling-explorer 1.2.0__tar.gz → 1.3.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.
Files changed (32) hide show
  1. {profiling_explorer-1.2.0/src/profiling_explorer.egg-info → profiling_explorer-1.3.0}/PKG-INFO +32 -13
  2. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/README.rst +21 -3
  3. profiling_explorer-1.3.0/pyproject.toml +124 -0
  4. profiling_explorer-1.2.0/pyproject.toml → profiling_explorer-1.3.0/pyproject.toml.orig +15 -14
  5. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/static/script.js +38 -1
  6. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/static/styles.css +17 -0
  7. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/templates/_row.html +2 -1
  8. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/templates/callers_callees.html +1 -1
  9. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/urls.py +1 -0
  10. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/views.py +63 -16
  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.0}/LICENSE +0 -0
  21. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/__init__.py +0 -0
  22. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/__main__.py +0 -0
  23. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/main.py +0 -0
  24. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/management/__init__.py +0 -0
  25. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/management/commands/__init__.py +0 -0
  26. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/management/commands/runserver.py +0 -0
  27. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/py.typed +0 -0
  28. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/settings.py +0 -0
  29. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/templates/base.html +0 -0
  30. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/templates/index.html +0 -0
  31. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/src/profiling_explorer/templatetags/__init__.py +0 -0
  32. {profiling_explorer-1.2.0 → profiling_explorer-1.3.0}/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.0
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.0"
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.0"
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)
@@ -91,7 +102,7 @@ def _row_id(full_filename: str, lineno: int, funcname: str) -> str:
91
102
 
92
103
  def _row_id_from_pstats_key(key: tuple[str, int, str]) -> str:
93
104
  filename, lineno, funcname = key
94
- full_filename = "" if filename == "~" else filename
105
+ full_filename, _, funcname = _shorten_filename_function(filename, funcname)
95
106
  return _row_id(full_filename, lineno, funcname)
96
107
 
97
108
 
@@ -103,13 +114,18 @@ def build_profile(s: pstats.Stats, path: str) -> Profile:
103
114
  rows_by_id = {}
104
115
  callers_map: defaultdict[str, dict[str, RowStats]] = defaultdict(dict)
105
116
  callees_map: defaultdict[str, dict[str, RowStats]] = defaultdict(dict)
117
+ is_file_cache: dict[str, bool] = {}
106
118
  for key in s.fcn_list: # type: ignore[attr-defined]
107
119
  filename, lineno, funcname = key
108
120
  _, calls, tottime, cumtime, callers = s.stats[key] # type: ignore[attr-defined]
109
121
  full_filename, short_filename, funcname = _shorten_filename_function(
110
122
  filename, funcname
111
123
  )
112
- row_id = _row_id(full_filename, lineno, funcname)
124
+ row_id = _row_id_from_pstats_key(key)
125
+ if full_filename not in is_file_cache:
126
+ is_file_cache[full_filename] = bool(full_filename) and os.path.isfile(
127
+ full_filename
128
+ )
113
129
  cumulative_ms = round(cumtime * 1_000)
114
130
  row = Row(
115
131
  id=row_id,
@@ -120,23 +136,17 @@ def build_profile(s: pstats.Stats, path: str) -> Profile:
120
136
  cumulative_ms_pct=min(100.0, cumulative_ms / total_time_ms * 100)
121
137
  if total_time_ms
122
138
  else 0.0,
139
+ per_call_ms=round(cumtime * 1_000 / calls) if calls else 0,
123
140
  filename=short_filename,
124
141
  full_filename=full_filename,
125
142
  lineno=lineno,
126
143
  funcname=funcname,
144
+ is_file=is_file_cache[full_filename],
127
145
  )
128
146
  rows.append(row)
129
147
  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)
148
+ for caller_key, (callee_calls, _, __, callee_cumtime) in callers.items():
149
+ caller_id = _row_id_from_pstats_key(caller_key)
140
150
  callee_cumulative_ms = round(callee_cumtime * 1_000)
141
151
  edge = RowStats(
142
152
  calls=callee_calls,
@@ -150,6 +160,9 @@ def build_profile(s: pstats.Stats, path: str) -> Profile:
150
160
  if total_time_ms
151
161
  else 0.0
152
162
  ),
163
+ per_call_ms=(
164
+ round(callee_cumtime * 1_000 / callee_calls) if callee_calls else 0
165
+ ),
153
166
  )
154
167
  callers_map[row_id][caller_id] = edge
155
168
  callees_map[caller_id][row_id] = edge
@@ -179,7 +192,7 @@ def _build_edge_rows(
179
192
 
180
193
  PAGE_SIZE = 200
181
194
 
182
- _VALID_SORT_COLS = {"calls", "internal_ms", "cumulative_ms"}
195
+ _VALID_SORT_COLS = {"calls", "internal_ms", "cumulative_ms", "per_call_ms"}
183
196
 
184
197
 
185
198
  def _render_table(
@@ -225,7 +238,10 @@ def _render_table(
225
238
  reverse=sort_desc,
226
239
  )
227
240
 
228
- offset = int(request.GET.get("offset", 0))
241
+ try:
242
+ offset = max(0, int(request.GET.get("offset", 0)))
243
+ except ValueError:
244
+ offset = 0
229
245
  page_rows = sorted_rows[offset : offset + PAGE_SIZE]
230
246
 
231
247
  next_url = None
@@ -249,12 +265,14 @@ def _render_table(
249
265
  context = {
250
266
  "profile": profile,
251
267
  "rows": page_rows,
268
+ "first_page": offset == 0,
252
269
  "next_url": next_url,
253
270
  "q": q,
254
271
  "columns": [
255
272
  col_config("calls", "calls"),
256
273
  col_config("internal_ms", "internal ms"),
257
274
  col_config("cumulative_ms", "cumulative ms"),
275
+ col_config("per_call_ms", "ms/call"),
258
276
  ],
259
277
  **extra_context,
260
278
  }
@@ -324,6 +342,35 @@ def callees_view(request: HttpRequest, row_id: str) -> HttpResponse:
324
342
  )
325
343
 
326
344
 
345
+ _LOCAL_HOSTS = {"127.0.0.1", "localhost", "[::1]"}
346
+
347
+
348
+ @require_POST
349
+ def open_in_editor(request: HttpRequest, row_id: str) -> HttpResponse:
350
+ # This view runs a program, so only accept requests from our own pages.
351
+ # Websites can send cross-origin POSTs to localhost, and DNS rebinding can
352
+ # make another hostname resolve here.
353
+ host = request.get_host().rsplit(":", 1)[0]
354
+ if host not in _LOCAL_HOSTS:
355
+ return HttpResponseForbidden("Invalid host.")
356
+ if request.headers.get("Sec-Fetch-Site", "same-origin") != "same-origin":
357
+ return HttpResponseForbidden("Cross-site request.")
358
+ origin = request.headers.get("Origin")
359
+ if origin is not None and origin != f"{request.scheme}://{request.get_host()}":
360
+ return HttpResponseForbidden("Cross-origin request.")
361
+
362
+ row = profile.rows_by_id.get(row_id)
363
+ if row is None or not row.is_file:
364
+ raise Http404()
365
+
366
+ try:
367
+ launch_editor(row.full_filename, row.lineno)
368
+ except LaunchEditorError as exc:
369
+ return HttpResponseBadRequest(str(exc))
370
+
371
+ return HttpResponse(status=204)
372
+
373
+
327
374
  @require_GET
328
375
  def file(request: HttpRequest, *, filename: str) -> FileResponse:
329
376
  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