django-launch-editor 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,176 @@
1
+ from __future__ import annotations
2
+
3
+ import itertools
4
+ import linecache
5
+ from pathlib import Path
6
+ from typing import Any
7
+
8
+ from django.conf import settings
9
+ from django.http import HttpRequest
10
+ from django.urls import NoReverseMatch, reverse
11
+ from django.utils.html import format_html, json_script
12
+ from django.utils.safestring import SafeString, mark_safe
13
+ from django.views.debug import ExceptionReporter
14
+
15
+ from django_launch_editor.tokens import make_token
16
+
17
+ SCRIPT = (Path(__file__).parent / "launch-editor.js").read_text(encoding="utf-8")
18
+
19
+ STYLE = """
20
+ button.django-launch-editor {
21
+ background: none; border: none; padding: 0; margin: 0;
22
+ font: inherit; color: inherit; text-align: inherit;
23
+ text-decoration: underline; cursor: pointer;
24
+ &:hover { text-decoration-thickness: 2px; }
25
+ & .fname { user-select: text; }
26
+ &:disabled { cursor: progress; }
27
+ }
28
+ .django-launch-editor-status {
29
+ color: #666;
30
+ &.error { color: #c00; }
31
+ }
32
+ dialog.django-launch-editor-dialog {
33
+ margin: auto; max-width: 40em; padding: 1.5em;
34
+ border: 1px solid #ccc; border-radius: 6px;
35
+ &::backdrop { background: rgba(0, 0, 0, 0.4); }
36
+ & h2 { margin: 0 0 0.5em; }
37
+ & p { margin: 0 0 0.5em; }
38
+ & code {
39
+ display: flow-root; padding: 0.5em; background: #f0f0f0;
40
+ overflow-wrap: anywhere; user-select: all;
41
+ &[hidden] { display: none; }
42
+ }
43
+ & .django-launch-editor-copy {
44
+ float: left; margin: 0 0.5em 0 0; padding: 0.4em;
45
+ line-height: 0; cursor: pointer;
46
+ }
47
+ & form {
48
+ clear: both; margin-top: 1em; text-align: right;
49
+ & button { padding: 0.4em 1.2em; font: inherit; cursor: pointer; }
50
+ }
51
+ }
52
+ """
53
+
54
+ BANNER_STYLE = """
55
+ #django-launch-editor-banner { padding: 10px 20px; background: #fec; border-bottom: 1px solid #ccc; }
56
+ #django-launch-editor-banner code { font-size: 100%; }
57
+ """
58
+
59
+
60
+ class LaunchEditorExceptionReporter(ExceptionReporter):
61
+ """
62
+ Exception reporter that makes file locations on the debug error page into
63
+ links that open them in the user’s editor.
64
+ """
65
+
66
+ def get_traceback_data(self) -> dict[str, Any]:
67
+ data = super().get_traceback_data()
68
+ # Keep the data for get_traceback_html(), rather than computing it
69
+ # twice.
70
+ self._launch_editor_data: dict[str, Any] = data
71
+ return data
72
+
73
+ def get_traceback_html(self) -> SafeString:
74
+ html = super().get_traceback_html()
75
+ return self.add_launch_editor(html, self._launch_editor_data)
76
+
77
+ def add_launch_editor(self, html: SafeString, data: dict[str, Any]) -> SafeString:
78
+ # Never add links to error emails, or when DEBUG is off, since the
79
+ # view will not work then.
80
+ if not settings.DEBUG or self.is_email or self.request is None:
81
+ return html
82
+
83
+ frames = [
84
+ make_token(frame["filename"], frame["lineno"], frame_column(frame))
85
+ if frame["tb"] is not None
86
+ else None
87
+ for frame in data["frames"]
88
+ ]
89
+
90
+ template_info = data["template_info"]
91
+ if template_info:
92
+ template = make_token(template_info["name"], template_info["line"])
93
+ else:
94
+ template = None
95
+
96
+ return add_launch_editor(
97
+ html, self.request, {"frames": frames, "template": template}
98
+ )
99
+
100
+
101
+ def frame_column(frame: dict[str, Any]) -> int | None:
102
+ """
103
+ Return the 1-based column where the frame’s current expression starts, if
104
+ it’s on the frame’s line.
105
+ """
106
+ tb = frame["tb"]
107
+ start_line, _, start_column, _ = next(
108
+ itertools.islice(tb.tb_frame.f_code.co_positions(), tb.tb_lasti // 2, None),
109
+ (None, None, None, None),
110
+ )
111
+ if start_line != frame["lineno"] or start_column is None:
112
+ return None
113
+
114
+ # Positions are UTF-8 byte offsets, but editors count characters.
115
+ source_line = linecache.getline(frame["filename"], frame["lineno"])
116
+ if not source_line:
117
+ return None
118
+ prefix = source_line.encode()[:start_column]
119
+ return len(prefix.decode(errors="replace")) + 1
120
+
121
+
122
+ def add_launch_editor(
123
+ html: SafeString, request: HttpRequest, config: dict[str, Any]
124
+ ) -> SafeString:
125
+ """
126
+ Add the script that makes file locations into links to a debug page, or a
127
+ banner explaining how to install the URLs if they’re missing.
128
+ """
129
+ try:
130
+ url = reverse("django_launch_editor:launch")
131
+ except NoReverseMatch:
132
+ return insert(html, "</header>", banner_html(request), before=False)
133
+
134
+ config = {"url": url, "frames": [], "template": None, **config}
135
+ return insert(
136
+ html,
137
+ "</body>",
138
+ f"<style>{STYLE}</style>"
139
+ + json_script(config, "django-launch-editor-data")
140
+ + f"<script>{SCRIPT}</script>",
141
+ before=True,
142
+ )
143
+
144
+
145
+ def banner_html(request: HttpRequest) -> str:
146
+ urlconf = getattr(request, "urlconf", None) or settings.ROOT_URLCONF
147
+ if not isinstance(urlconf, str):
148
+ urlconf = urlconf.__name__
149
+ return format_html(
150
+ "<style>{}</style>"
151
+ + '<div id="django-launch-editor-banner" role="note">'
152
+ + "<strong>django-launch-editor</strong> cannot make file locations "
153
+ + "into links, because its URLs are not installed. Add "
154
+ + '<code>path("__launch_editor__/", '
155
+ + 'include("django_launch_editor.urls"))</code> '
156
+ + "to <code>urlpatterns</code> in <code>{}</code>."
157
+ + "</div>",
158
+ mark_safe(BANNER_STYLE),
159
+ urlconf,
160
+ )
161
+
162
+
163
+ def insert(html: SafeString, marker: str, extra: str, *, before: bool) -> SafeString:
164
+ """
165
+ Insert extra HTML before the last occurrence of marker, or after the first
166
+ one, or return the HTML unchanged if marker is not found.
167
+ """
168
+ if before:
169
+ head, found, tail = html.rpartition(marker)
170
+ if found:
171
+ return mark_safe(head + extra + found + tail)
172
+ else:
173
+ head, found, tail = html.partition(marker)
174
+ if found:
175
+ return mark_safe(head + found + extra + tail)
176
+ return html
@@ -0,0 +1,201 @@
1
+ (function () {
2
+ 'use strict'
3
+
4
+ const data = JSON.parse(
5
+ document.getElementById('django-launch-editor-data').textContent
6
+ )
7
+
8
+ async function openInEditor (button, status, token) {
9
+ button.disabled = true
10
+ status.className = 'django-launch-editor-status'
11
+ status.textContent = 'Opening…'
12
+
13
+ const body = new FormData()
14
+ body.append('token', token)
15
+ let error = null
16
+ try {
17
+ const response = await fetch(data.url, {
18
+ method: 'POST',
19
+ body,
20
+ mode: 'same-origin',
21
+ credentials: 'same-origin'
22
+ })
23
+ const contentType = response.headers.get('Content-Type') ?? ''
24
+ const json = contentType.startsWith('application/json')
25
+ ? await response.json()
26
+ : {}
27
+ if (response.status === 200 && typeof json.location === 'string') {
28
+ // No editor was found, so the location to copy was sent instead.
29
+ showLocation(json)
30
+ } else if (response.status !== 204) {
31
+ if (typeof json.error === 'string') {
32
+ error = json.error
33
+ } else if (response.redirected) {
34
+ error = `Request was redirected to ${response.url}.`
35
+ } else {
36
+ error = `Request failed with status ${response.status}.`
37
+ }
38
+ }
39
+ } catch (exc) {
40
+ error = `Request failed: ${exc.message}`
41
+ }
42
+
43
+ button.disabled = false
44
+ if (error === null) {
45
+ status.textContent = ''
46
+ } else {
47
+ status.classList.add('error')
48
+ status.textContent = error
49
+ }
50
+ }
51
+
52
+ const COPY_ICON =
53
+ '<svg viewBox="0 0 24 24" width="16" height="16" fill="none" ' +
54
+ 'stroke="currentColor" stroke-width="2" stroke-linecap="round" ' +
55
+ 'stroke-linejoin="round" aria-hidden="true">' +
56
+ '<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/>' +
57
+ '<rect width="13" height="13" x="9" y="9" rx="2"/>' +
58
+ '</svg>'
59
+
60
+ let dialog = null
61
+
62
+ function getDialog () {
63
+ if (dialog === null) {
64
+ dialog = document.createElement('dialog')
65
+ dialog.className = 'django-launch-editor-dialog'
66
+ dialog.setAttribute('closedby', 'any')
67
+ dialog.setAttribute('aria-labelledby', 'django-launch-editor-title')
68
+
69
+ const title = document.createElement('h2')
70
+ title.id = 'django-launch-editor-title'
71
+ title.textContent = 'Running editor not detected'
72
+
73
+ const copyButton = document.createElement('button')
74
+ copyButton.type = 'button'
75
+ copyButton.className = 'django-launch-editor-copy'
76
+ copyButton.title = 'Copy to clipboard'
77
+ copyButton.setAttribute('aria-label', 'Copy to clipboard')
78
+ copyButton.innerHTML = COPY_ICON
79
+ copyButton.addEventListener('click', async () => {
80
+ const code = dialog.querySelector('code')
81
+ // Called directly from the click, so browsers like Safari allow it.
82
+ try {
83
+ await navigator.clipboard.writeText(code.textContent)
84
+ } catch {
85
+ window.getSelection().selectAllChildren(code)
86
+ }
87
+ })
88
+
89
+ const form = document.createElement('form')
90
+ form.method = 'dialog'
91
+ const button = document.createElement('button')
92
+ button.textContent = 'OK'
93
+ button.autofocus = true
94
+ form.append(button)
95
+
96
+ dialog.append(
97
+ title,
98
+ document.createElement('p'),
99
+ copyButton,
100
+ document.createElement('code'),
101
+ form
102
+ )
103
+ document.body.append(dialog)
104
+ }
105
+ return dialog
106
+ }
107
+
108
+ async function showLocation ({ location, relative }) {
109
+ let copied = false
110
+ try {
111
+ await navigator.clipboard.writeText(location)
112
+ copied = true
113
+ } catch {
114
+ // The clipboard is unavailable, for example on an insecure origin.
115
+ }
116
+
117
+ const description = relative ? 'Relative path' : 'Path'
118
+ const dialog = getDialog()
119
+ dialog.querySelector('p').textContent = copied
120
+ ? `${description} with line number copied to clipboard.`
121
+ : `Copy the ${description.toLowerCase()} with line number:`
122
+ const code = dialog.querySelector('code')
123
+ code.textContent = location
124
+ code.hidden = copied
125
+ dialog.querySelector('.django-launch-editor-copy').hidden = copied
126
+ dialog.showModal()
127
+ }
128
+
129
+ // Wrap the given nodes, such as the filename and line number, in a
130
+ // button. Show the status after any text that follows, such as “, in view”.
131
+ function addButton (nodes, token) {
132
+ if (token === null) {
133
+ return
134
+ }
135
+
136
+ const button = document.createElement('button')
137
+ button.type = 'button'
138
+ button.className = 'django-launch-editor'
139
+ button.title = 'Open in editor'
140
+ nodes[0].before(button)
141
+ button.append(...nodes)
142
+
143
+ const status = document.createElement('span')
144
+ status.className = 'django-launch-editor-status'
145
+ status.setAttribute('role', 'status')
146
+ const rest = button.nextSibling
147
+ if (rest?.nodeType === Node.TEXT_NODE) {
148
+ rest.textContent = rest.textContent.trimEnd()
149
+ rest.after(' ', status)
150
+ } else {
151
+ button.after(' ', status)
152
+ }
153
+
154
+ button.addEventListener('click', (event) => {
155
+ event.stopPropagation()
156
+ openInEditor(button, status, token)
157
+ })
158
+ }
159
+
160
+ // Return the filename element plus the following “, line 12” text.
161
+ function filenameAndLine (element) {
162
+ const text = element.nextSibling
163
+ if (text?.nodeType === Node.TEXT_NODE) {
164
+ const match = text.textContent.match(/^, line \d+/)
165
+ if (match !== null) {
166
+ text.splitText(match[0].length)
167
+ return [element, text]
168
+ }
169
+ }
170
+ return [element]
171
+ }
172
+
173
+ const frames = document.querySelectorAll('#browserTraceback li.frame')
174
+ // Frames are matched up by position, so give up if Django’s template has
175
+ // changed in a way that breaks that.
176
+ if (frames.length === data.frames.length) {
177
+ frames.forEach((frame, index) => {
178
+ const fname = frame.querySelector(':scope > code.fname')
179
+ if (fname !== null) {
180
+ addButton(filenameAndLine(fname), data.frames[index])
181
+ }
182
+ })
183
+ const fname = document.querySelector('#summary .fname')
184
+ if (fname !== null && data.frames.length > 0) {
185
+ addButton(filenameAndLine(fname), data.frames[data.frames.length - 1])
186
+ }
187
+ }
188
+
189
+ // Template errors read “In template <code>name</code>, error at line
190
+ // <strong>12</strong>”.
191
+ const templateName = document.querySelector('#template p > code')
192
+ if (templateName !== null) {
193
+ const nodes = [templateName]
194
+ const text = templateName.nextSibling
195
+ const line = text?.nextSibling
196
+ if (text?.nodeType === Node.TEXT_NODE && line?.nodeName === 'STRONG') {
197
+ nodes.push(text, line)
198
+ }
199
+ addButton(nodes, data.template)
200
+ }
201
+ })()
File without changes
@@ -0,0 +1,36 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+
5
+ from django.core import signing
6
+
7
+ SALT = "django_launch_editor"
8
+
9
+ # Tokens are only valid for this long, to limit how long a leaked one can be
10
+ # used for.
11
+ MAX_AGE = 60 * 60 * 24 # seconds
12
+
13
+
14
+ def make_token(filename: str, line: int, column: int | None = None) -> str | None:
15
+ """
16
+ Return a signed token that allows opening the given file at the given
17
+ line and column, or None if the file cannot be opened.
18
+ """
19
+ if not os.path.isfile(filename):
20
+ return None
21
+ return signing.dumps([filename, line, column], salt=SALT)
22
+
23
+
24
+ def load_token(token: str) -> tuple[str, int, int | None]:
25
+ """
26
+ Return the filename, line, and column from a token made by make_token().
27
+ Raises django.core.signing.BadSignature if the token is invalid or has
28
+ expired.
29
+ """
30
+ payload = signing.loads(token, salt=SALT, max_age=MAX_AGE)
31
+ try:
32
+ filename, line, column = payload
33
+ except (TypeError, ValueError) as exc:
34
+ # Such as a token from an older version, without a column.
35
+ raise signing.BadSignature("Invalid token payload.") from exc
36
+ return filename, line, column
@@ -0,0 +1,11 @@
1
+ from __future__ import annotations
2
+
3
+ from django.urls import path
4
+
5
+ from django_launch_editor import views
6
+
7
+ app_name = "django_launch_editor"
8
+
9
+ urlpatterns = [
10
+ path("launch/", views.launch, name="launch"),
11
+ ]
@@ -0,0 +1,99 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from http import HTTPStatus
5
+
6
+ from django.conf import settings
7
+ from django.contrib.auth.decorators import login_not_required
8
+ from django.core.signing import BadSignature
9
+ from django.http import Http404, HttpRequest, HttpResponse, JsonResponse
10
+ from django.views.decorators.csrf import csrf_exempt
11
+ from django.views.decorators.http import require_POST
12
+ from launch_editor import LaunchEditorError, guess_editor, launch_editor
13
+
14
+ from django_launch_editor.tokens import load_token
15
+
16
+
17
+ # Exempt from CsrfViewMiddleware, since its cookie may not have been set on
18
+ # the error page, such as for errors raised in middleware before it. Instead,
19
+ # cross-site requests are blocked with is_same_origin().
20
+ @csrf_exempt
21
+ @login_not_required
22
+ def launch(request: HttpRequest) -> HttpResponse:
23
+ # Check DEBUG first, so nothing else about the view is revealed when it’s
24
+ # off.
25
+ if not settings.DEBUG:
26
+ raise Http404()
27
+ return _launch(request)
28
+
29
+
30
+ @require_POST
31
+ def _launch(request: HttpRequest) -> HttpResponse:
32
+ if not is_same_origin(request):
33
+ return JsonResponse(
34
+ {"error": "Cross-origin requests are not allowed."},
35
+ status=HTTPStatus.FORBIDDEN,
36
+ )
37
+
38
+ # Only files and lines that were signed when rendering a debug page can
39
+ # be opened, rather than any file the client asks for.
40
+ try:
41
+ filename, line, column = load_token(request.POST.get("token", ""))
42
+ except BadSignature:
43
+ return JsonResponse(
44
+ {"error": "Invalid or expired token. Reload the page and try again."},
45
+ status=HTTPStatus.BAD_REQUEST,
46
+ )
47
+
48
+ # Skip terminal editors, which would take over the server’s terminal.
49
+ editor = guess_editor(terminal=False)
50
+ if not editor:
51
+ # Send the location back, for the page to copy to the clipboard.
52
+ location = relative_path(filename)
53
+ return JsonResponse(
54
+ {
55
+ "location": f"{location}:{line}",
56
+ "relative": not os.path.isabs(location),
57
+ }
58
+ )
59
+
60
+ try:
61
+ launch_editor(filename, line, column, editor=editor)
62
+ except LaunchEditorError as exc:
63
+ return JsonResponse(
64
+ {"error": str(exc)},
65
+ status=HTTPStatus.INTERNAL_SERVER_ERROR,
66
+ )
67
+
68
+ return HttpResponse(status=HTTPStatus.NO_CONTENT)
69
+
70
+
71
+ def relative_path(filename: str) -> str:
72
+ """
73
+ Return the filename relative to the current directory, which is normally
74
+ the project root, or unchanged if it’s outside it.
75
+ """
76
+ try:
77
+ relative = os.path.relpath(filename)
78
+ except ValueError: # On a different drive on Windows
79
+ return filename
80
+ if relative.startswith(os.pardir + os.sep):
81
+ return filename
82
+ return relative
83
+
84
+
85
+ def is_same_origin(request: HttpRequest) -> bool:
86
+ """
87
+ Check the request came from a page on the same origin, using headers that
88
+ browsers set and pages cannot forge. Browsers send Sec-Fetch-Site on all
89
+ requests, and Origin on all POST requests, so require one of them.
90
+ """
91
+ sec_fetch_site = request.headers.get("Sec-Fetch-Site")
92
+ if sec_fetch_site is not None:
93
+ return sec_fetch_site == "same-origin"
94
+
95
+ origin = request.headers.get("Origin")
96
+ if origin is not None:
97
+ return origin == f"{request.scheme}://{request.get_host()}"
98
+
99
+ return False
@@ -0,0 +1,165 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-launch-editor
3
+ Version: 1.0.0
4
+ Summary: Open files from Django’s debug error page in your editor.
5
+ Keywords: Django,editor,launch-editor,open-in-editor
6
+ Author: Adam Johnson
7
+ Author-email: Adam Johnson <me@adamj.eu>
8
+ License-Expression: MIT
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 5 - Production/Stable
11
+ Classifier: Framework :: Django :: 5.2
12
+ Classifier: Framework :: Django :: 6.0
13
+ Classifier: Framework :: Django :: 6.1
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Natural Language :: English
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3 :: Only
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Programming Language :: Python :: 3.14
22
+ Classifier: Programming Language :: Python :: 3.15
23
+ Classifier: Programming Language :: Python :: Implementation :: CPython
24
+ Classifier: Typing :: Typed
25
+ Requires-Dist: django>=5.2
26
+ Requires-Dist: launch-editor-py>=1
27
+ Requires-Python: >=3.11
28
+ Project-URL: Changelog, https://github.com/adamchainz/django-launch-editor/blob/main/CHANGELOG.rst
29
+ Project-URL: Funding, https://adamj.eu/books/
30
+ Project-URL: Repository, https://github.com/adamchainz/django-launch-editor
31
+ Description-Content-Type: text/x-rst
32
+
33
+ ====================
34
+ django-launch-editor
35
+ ====================
36
+
37
+ .. image:: https://img.shields.io/github/actions/workflow/status/adamchainz/django-launch-editor/main.yml.svg?branch=main&style=for-the-badge
38
+ :target: https://github.com/adamchainz/django-launch-editor/actions?workflow=CI
39
+
40
+ .. image:: https://img.shields.io/badge/Coverage-100%25-success?style=for-the-badge
41
+ :target: https://github.com/adamchainz/django-launch-editor/actions?workflow=CI
42
+
43
+ .. image:: https://img.shields.io/pypi/v/django-launch-editor.svg?style=for-the-badge
44
+ :target: https://pypi.org/project/django-launch-editor/
45
+
46
+ .. image:: https://img.shields.io/badge/code%20style-black-000000.svg?style=for-the-badge
47
+ :target: https://github.com/psf/black
48
+
49
+ .. image:: https://img.shields.io/badge/pre--commit-enabled-brightgreen?logo=pre-commit&logoColor=white&style=for-the-badge
50
+ :target: https://github.com/pre-commit/pre-commit
51
+ :alt: pre-commit
52
+
53
+ ----
54
+
55
+ Open files from Django’s debug error page in your editor.
56
+
57
+ Makes the file locations on Django’s debug error page, like “/path/to/views.py, line 8”, into links that open the file at that line in your editor, using `launch-editor-py <https://github.com/adamchainz/launch-editor-py>`__.
58
+
59
+
60
+ .. figure:: https://raw.githubusercontent.com/adamchainz/django-launch-editor/main/screenshot.png
61
+ :alt: django-launch-editor screenshot
62
+
63
+ ----
64
+
65
+ **Improve your Django and Git skills** with `my books <https://adamj.eu/books/>`__.
66
+
67
+ ----
68
+
69
+ Requirements
70
+ ------------
71
+
72
+ Python 3.11 to 3.15 supported.
73
+
74
+ Django 5.2 to 6.1 supported.
75
+
76
+ Installation
77
+ ------------
78
+
79
+ 1. Install with **pip**:
80
+
81
+ .. code-block:: sh
82
+
83
+ python -m pip install django-launch-editor
84
+
85
+ 2. Set Django’s exception reporter class, in your settings file:
86
+
87
+ .. code-block:: python
88
+
89
+ DEFAULT_EXCEPTION_REPORTER = "django_launch_editor.LaunchEditorExceptionReporter"
90
+
91
+ If you already use a custom exception reporter, make it subclass ``LaunchEditorExceptionReporter``.
92
+
93
+ 3. Include the app URLs in your root URLconf:
94
+
95
+ .. code-block:: python
96
+
97
+ from django.urls import include, path
98
+
99
+ urlpatterns = [
100
+ ...,
101
+ path("__launch_editor__/", include("django_launch_editor.urls")),
102
+ ]
103
+
104
+ You can use another prefix if required.
105
+
106
+ That’s it! 🎉
107
+
108
+ Trigger an error while you have ``DEBUG = True``, and click the file location of any traceback frame, or template error, to open it in your editor.
109
+ Where Python knows it, the editor opens at the column of the expression that failed, too.
110
+
111
+ Editor selection
112
+ ----------------
113
+
114
+ django-launch-editor attempts to detect a running non-terminal editor with `launch-editor-py <https://github.com/adamchainz/launch-editor-py>`__.
115
+ If none is detected, it falls back to showing a dialog for copying the clicked file path to your clipboard.
116
+
117
+ Because django-launch-editor runs within the Django server, it can only launch an editor when you run Django on your development machine, not in a container or on a remote server.
118
+ Similarly, it must skip terminal editors like Vim, since they would take over the terminal running Django.
119
+
120
+ For information on how the editor is picked, or how to override it, see `the launch-editor-py documentation <https://github.com/adamchainz/launch-editor-py>`__.
121
+
122
+ If no editor is found, django-launch-editor falls back to showing a dialog headed “Running editor not detected”, and copying the file location to your clipboard:
123
+
124
+
125
+ .. figure:: https://raw.githubusercontent.com/adamchainz/django-launch-editor/main/screenshot-dialog-copied.png
126
+ :alt: Screenshot showing dialog with copied file location
127
+
128
+ The location has the form ``path:line``, such as ``myapp/views.py:8``.
129
+
130
+ If the browser blocks the automatic copy, the dialog shows a copy icon button to the left of the location:
131
+
132
+ .. figure:: https://raw.githubusercontent.com/adamchainz/django-launch-editor/main/screenshot-dialog-manual.png
133
+ :alt: Screenshot showing dialog with manual fallback
134
+
135
+
136
+ Security
137
+ --------
138
+
139
+ Opening files runs a program on the server, so the package takes several precautions:
140
+
141
+ * The whole process only works when ``DEBUG`` is ``True``.
142
+
143
+ * Links are never added to error emails.
144
+
145
+ * The view only accepts ``POST`` requests from pages on the same origin, checked with the ``Sec-Fetch-Site`` or ``Origin`` headers that browsers send.
146
+ This blocks cross-site request forgery without relying on ``CsrfViewMiddleware``’s cookie, which may not be set on the error page.
147
+
148
+ * The view doesn’t accept arbitrary file paths.
149
+ Instead, each link carries a token, signed with your ``SECRET_KEY``, for a file and line that appeared on a debug page.
150
+ Tokens are only made for files that exist, and expire after 24 hours.
151
+
152
+ Example project
153
+ ---------------
154
+
155
+ To try django-launch-editor, there is an example project included in the repository.
156
+ Open |the example directory|__, follow the instructions in its README, and try it out.
157
+
158
+ .. |the example directory| replace:: the ``example/`` directory
159
+ __ https://github.com/adamchainz/django-launch-editor/tree/main/example
160
+
161
+ How it works
162
+ ------------
163
+
164
+ ``LaunchEditorExceptionReporter`` extends Django’s ``ExceptionReporter`` to add a small script to the HTML debug page, along with signed tokens for each traceback frame.
165
+ The script turns file locations into buttons, styled as links, that ``POST`` a token to the view, which checks it and calls ``launch_editor()``.
@@ -0,0 +1,10 @@
1
+ django_launch_editor/__init__.py,sha256=F87_13vscnk6C6NBlNdUqA_lMbBFk8ndIjyHB1efMDE,5920
2
+ django_launch_editor/launch-editor.js,sha256=suIQNARtH9TLImCBsGllIVawdAKbnckagpYDNUu4cMg,6574
3
+ django_launch_editor/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
4
+ django_launch_editor/tokens.py,sha256=GoKbGCLtlM9TcA42k7mAzs5lfAxwsiMJMXDMpzzx1BE,1156
5
+ django_launch_editor/urls.py,sha256=-ArRi3vpUuX1RBSQ9205FCKOtObG8a0k9IqBWp_Az90,209
6
+ django_launch_editor/views.py,sha256=-6kZUQrRPw28cX4GOKFa3e7DVdlSOnIoj6ujobOxId8,3373
7
+ django_launch_editor-1.0.0.dist-info/licenses/LICENSE,sha256=blVuFA7Qdp2_CcTyGXLxA4CsHsAjAquxVc4O6ODqye4,1069
8
+ django_launch_editor-1.0.0.dist-info/WHEEL,sha256=MWx7psoF7KNST9G72pGtiBYuaXRXQXBk9pw5R6FzYko,80
9
+ django_launch_editor-1.0.0.dist-info/METADATA,sha256=aledvjgXBQofgKhj7D5b3uNjM-DcrsiairnGxoOKkIA,6737
10
+ django_launch_editor-1.0.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.13.7
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Adam Johnson
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.