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.
- django_launch_editor/__init__.py +176 -0
- django_launch_editor/launch-editor.js +201 -0
- django_launch_editor/py.typed +0 -0
- django_launch_editor/tokens.py +36 -0
- django_launch_editor/urls.py +11 -0
- django_launch_editor/views.py +99 -0
- django_launch_editor-1.0.0.dist-info/METADATA +165 -0
- django_launch_editor-1.0.0.dist-info/RECORD +10 -0
- django_launch_editor-1.0.0.dist-info/WHEEL +4 -0
- django_launch_editor-1.0.0.dist-info/licenses/LICENSE +21 -0
|
@@ -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,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,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.
|