plain.dev 0.30.1__py3-none-any.whl → 0.32.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.
- plain/dev/CHANGELOG.md +14 -0
- plain/dev/README.md +1 -0
- plain/dev/__init__.py +1 -2
- plain/dev/cli.py +38 -4
- plain/dev/contribute/cli.py +20 -5
- plain/dev/dev_pid.py +36 -0
- {plain_dev-0.30.1.dist-info → plain_dev-0.32.0.dist-info}/METADATA +2 -1
- {plain_dev-0.30.1.dist-info → plain_dev-0.32.0.dist-info}/RECORD +11 -13
- plain/dev/requests.py +0 -224
- plain/dev/templates/dev/requests.html +0 -134
- plain/dev/urls.py +0 -10
- plain/dev/views.py +0 -39
- {plain_dev-0.30.1.dist-info → plain_dev-0.32.0.dist-info}/WHEEL +0 -0
- {plain_dev-0.30.1.dist-info → plain_dev-0.32.0.dist-info}/entry_points.txt +0 -0
- {plain_dev-0.30.1.dist-info → plain_dev-0.32.0.dist-info}/licenses/LICENSE +0 -0
plain/dev/CHANGELOG.md
ADDED
@@ -0,0 +1,14 @@
|
|
1
|
+
# plain-dev changelog
|
2
|
+
|
3
|
+
## [0.32.0](https://github.com/dropseed/plain/releases/plain-dev@0.32.0) (2025-06-23)
|
4
|
+
|
5
|
+
### What's changed
|
6
|
+
|
7
|
+
- `plain dev` now writes a PID file and will refuse to start if it detects that another `plain dev` instance is already running in the same project ([75b7a50](https://github.com/dropseed/plain/commit/75b7a505ae3c60675099ffd440f35cf8f30665da)).
|
8
|
+
- When no `--port` is provided, `plain dev` now checks if port 8443 is available and, if not, automatically selects the next free port. Supplying `--port` will error if that port is already in use ([3f5141f](https://github.com/dropseed/plain/commit/3f5141f54a65455f5784ed3f97be2d153ed10a23)).
|
9
|
+
- The development request-log UI has been removed for now, along with its related endpoints and templates ([8ac6f71](https://github.com/dropseed/plain/commit/8ac6f7170efa72e6069bae3cc91809b5fe0f8a7d)).
|
10
|
+
- `plain contrib --all` skips any installed `plainx-*` packages instead of erroring when it can’t locate their repository ([3a26aee](https://github.com/dropseed/plain/commit/3a26aee25e586a66e02a348aa24ee6e048ea0b71)).
|
11
|
+
|
12
|
+
### Upgrade instructions
|
13
|
+
|
14
|
+
- No changes required.
|
plain/dev/README.md
CHANGED
@@ -20,6 +20,7 @@ The `plain dev` command does several things:
|
|
20
20
|
- Runs `plain preflight` to check for any issues
|
21
21
|
- Executes any pending model migrations
|
22
22
|
- Starts `gunicorn` with `--reload`
|
23
|
+
- Serves HTTPS on port 8443 by default (uses the next free port if 8443 is taken and no port is specified)
|
23
24
|
- Runs `plain tailwind build --watch`, if `plain.tailwind` is installed
|
24
25
|
- Any custom process defined in `pyproject.toml` at `tool.plain.dev.run`
|
25
26
|
- Necessary services (ex. Postgres) defined in `pyproject.toml` at `tool.plain.dev.services`
|
plain/dev/__init__.py
CHANGED
plain/dev/cli.py
CHANGED
@@ -4,6 +4,7 @@ import multiprocessing
|
|
4
4
|
import os
|
5
5
|
import platform
|
6
6
|
import signal
|
7
|
+
import socket
|
7
8
|
import subprocess
|
8
9
|
import sys
|
9
10
|
import time
|
@@ -20,6 +21,7 @@ from rich.text import Text
|
|
20
21
|
from plain.cli import register_cli
|
21
22
|
from plain.runtime import APP_PATH, PLAIN_TEMP_PATH
|
22
23
|
|
24
|
+
from .dev_pid import DevPid
|
23
25
|
from .mkcert import MkcertManager
|
24
26
|
from .poncho.manager import Manager as PonchoManager
|
25
27
|
from .poncho.printer import Printer
|
@@ -35,9 +37,12 @@ ENTRYPOINT_GROUP = "plain.dev"
|
|
35
37
|
@click.option(
|
36
38
|
"--port",
|
37
39
|
"-p",
|
38
|
-
default=
|
39
|
-
type=
|
40
|
-
help=
|
40
|
+
default="",
|
41
|
+
type=str,
|
42
|
+
help=(
|
43
|
+
"Port to run the web server on. If omitted, tries 8443 and "
|
44
|
+
"picks the next free port"
|
45
|
+
),
|
41
46
|
)
|
42
47
|
@click.option(
|
43
48
|
"--hostname",
|
@@ -59,6 +64,10 @@ def cli(ctx, port, hostname, log_level):
|
|
59
64
|
if ctx.invoked_subcommand:
|
60
65
|
return
|
61
66
|
|
67
|
+
if DevPid().exists():
|
68
|
+
click.secho("`plain dev` already running", fg="yellow")
|
69
|
+
sys.exit(1)
|
70
|
+
|
62
71
|
if not hostname:
|
63
72
|
project_name = os.path.basename(
|
64
73
|
os.getcwd()
|
@@ -132,10 +141,21 @@ def entrypoint(show_list, entrypoint):
|
|
132
141
|
|
133
142
|
class Dev:
|
134
143
|
def __init__(self, *, port, hostname, log_level):
|
135
|
-
self.port = port
|
136
144
|
self.hostname = hostname
|
137
145
|
self.log_level = log_level
|
138
146
|
|
147
|
+
self.pid = DevPid()
|
148
|
+
|
149
|
+
if port:
|
150
|
+
self.port = int(port)
|
151
|
+
if not self._port_available(self.port):
|
152
|
+
click.secho(f"Port {self.port} in use", fg="red")
|
153
|
+
raise SystemExit(1)
|
154
|
+
else:
|
155
|
+
self.port = self._find_open_port(8443)
|
156
|
+
if self.port != 8443:
|
157
|
+
click.secho(f"Port 8443 in use, using {self.port}", fg="yellow")
|
158
|
+
|
139
159
|
self.ssl_key_path = None
|
140
160
|
self.ssl_cert_path = None
|
141
161
|
|
@@ -191,7 +211,20 @@ class Dev:
|
|
191
211
|
|
192
212
|
self.poncho = PonchoManager(printer=Printer(lambda s: self.console.out(s)))
|
193
213
|
|
214
|
+
def _find_open_port(self, start_port):
|
215
|
+
port = start_port
|
216
|
+
while not self._port_available(port):
|
217
|
+
port += 1
|
218
|
+
return port
|
219
|
+
|
220
|
+
def _port_available(self, port):
|
221
|
+
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
222
|
+
sock.settimeout(0.5)
|
223
|
+
result = sock.connect_ex(("127.0.0.1", port))
|
224
|
+
return result != 0
|
225
|
+
|
194
226
|
def run(self):
|
227
|
+
self.pid.write()
|
195
228
|
mkcert_manager = MkcertManager()
|
196
229
|
mkcert_manager.setup_mkcert(install_path=Path.home() / ".plain" / "dev")
|
197
230
|
self.ssl_cert_path, self.ssl_key_path = mkcert_manager.generate_certs(
|
@@ -253,6 +286,7 @@ class Dev:
|
|
253
286
|
# Remove the status bar
|
254
287
|
self.console_status.stop()
|
255
288
|
finally:
|
289
|
+
self.pid.rm()
|
256
290
|
# Make sure the services pid gets removed if we set it
|
257
291
|
if services_pid:
|
258
292
|
services_pid.rm()
|
plain/dev/contribute/cli.py
CHANGED
@@ -35,6 +35,8 @@ def cli(packages, repo, reset, all_packages):
|
|
35
35
|
|
36
36
|
return
|
37
37
|
|
38
|
+
packages = list(packages)
|
39
|
+
|
38
40
|
repo = Path(repo)
|
39
41
|
if not repo.exists():
|
40
42
|
click.secho(f"Repo not found at {repo}", fg="red")
|
@@ -57,6 +59,7 @@ def cli(packages, repo, reset, all_packages):
|
|
57
59
|
|
58
60
|
plain_packages = []
|
59
61
|
plainx_packages = []
|
62
|
+
skipped_plainx_packages = []
|
60
63
|
|
61
64
|
if all_packages:
|
62
65
|
# get all installed plain packages
|
@@ -67,11 +70,23 @@ def cli(packages, repo, reset, all_packages):
|
|
67
70
|
click.secho("No installed packages found", fg="red")
|
68
71
|
sys.exit(1)
|
69
72
|
|
70
|
-
packages = [
|
71
|
-
|
72
|
-
|
73
|
-
|
74
|
-
|
73
|
+
packages = []
|
74
|
+
for line in installed_packages.splitlines():
|
75
|
+
if not line.startswith("plain"):
|
76
|
+
continue
|
77
|
+
package = line.split("==")[0]
|
78
|
+
if package.startswith("plainx-"):
|
79
|
+
skipped_plainx_packages.append(package)
|
80
|
+
else:
|
81
|
+
packages.append(package)
|
82
|
+
|
83
|
+
if skipped_plainx_packages:
|
84
|
+
click.secho(
|
85
|
+
"Skipping plainx packages: "
|
86
|
+
+ ", ".join(sorted(skipped_plainx_packages))
|
87
|
+
+ " (unknown repo)",
|
88
|
+
fg="yellow",
|
89
|
+
)
|
75
90
|
|
76
91
|
for package in packages:
|
77
92
|
package = package.replace(".", "-")
|
plain/dev/dev_pid.py
ADDED
@@ -0,0 +1,36 @@
|
|
1
|
+
import os
|
2
|
+
|
3
|
+
from plain.runtime import PLAIN_TEMP_PATH
|
4
|
+
|
5
|
+
|
6
|
+
class DevPid:
|
7
|
+
"""Manage a pidfile for the running ``plain dev`` command."""
|
8
|
+
|
9
|
+
def __init__(self):
|
10
|
+
self.pidfile = PLAIN_TEMP_PATH / "dev" / "dev.pid"
|
11
|
+
|
12
|
+
def write(self):
|
13
|
+
pid = os.getpid()
|
14
|
+
self.pidfile.parent.mkdir(parents=True, exist_ok=True)
|
15
|
+
with self.pidfile.open("w+") as f:
|
16
|
+
f.write(str(pid))
|
17
|
+
|
18
|
+
def rm(self):
|
19
|
+
if self.pidfile.exists():
|
20
|
+
self.pidfile.unlink()
|
21
|
+
|
22
|
+
def exists(self):
|
23
|
+
if not self.pidfile.exists():
|
24
|
+
return False
|
25
|
+
try:
|
26
|
+
pid = int(self.pidfile.read_text())
|
27
|
+
except (ValueError, OSError):
|
28
|
+
self.rm()
|
29
|
+
return False
|
30
|
+
try:
|
31
|
+
os.kill(pid, 0)
|
32
|
+
except OSError:
|
33
|
+
# Stale pidfile
|
34
|
+
self.rm()
|
35
|
+
return False
|
36
|
+
return True
|
@@ -1,6 +1,6 @@
|
|
1
1
|
Metadata-Version: 2.4
|
2
2
|
Name: plain.dev
|
3
|
-
Version: 0.
|
3
|
+
Version: 0.32.0
|
4
4
|
Summary: Local development tools for Plain.
|
5
5
|
Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
|
6
6
|
License-Expression: BSD-3-Clause
|
@@ -38,6 +38,7 @@ The `plain dev` command does several things:
|
|
38
38
|
- Runs `plain preflight` to check for any issues
|
39
39
|
- Executes any pending model migrations
|
40
40
|
- Starts `gunicorn` with `--reload`
|
41
|
+
- Serves HTTPS on port 8443 by default (uses the next free port if 8443 is taken and no port is specified)
|
41
42
|
- Runs `plain tailwind build --watch`, if `plain.tailwind` is installed
|
42
43
|
- Any custom process defined in `pyproject.toml` at `tool.plain.dev.run`
|
43
44
|
- Necessary services (ex. Postgres) defined in `pyproject.toml` at `tool.plain.dev.services`
|
@@ -1,20 +1,19 @@
|
|
1
|
-
plain/dev/
|
2
|
-
plain/dev/
|
3
|
-
plain/dev/
|
1
|
+
plain/dev/CHANGELOG.md,sha256=jr6wXfXbC0M7Ay3K3-D9rQisUD3nGvEcG4dtGeyZkeM,1139
|
2
|
+
plain/dev/README.md,sha256=F0tkPLqQQLqqJFi5-r7VE6GB8bS1m84QUrGx8xNW9HE,3765
|
3
|
+
plain/dev/__init__.py,sha256=9ByBOIdM8DebChjNz-RH2atdz4vWe8somlwNEsbhwh4,40
|
4
|
+
plain/dev/cli.py,sha256=gITJpDAZb0lfuv7hNB8Oinb2HAaBhnz-XiS_R1_esQA,16617
|
4
5
|
plain/dev/debug.py,sha256=Ka84K8zUdF0kMYNyqiLYDrdzU1jU8LSOkts3hcw_Gok,1005
|
5
6
|
plain/dev/default_settings.py,sha256=uXWYORWP_aRDwXIFXdu5kHyiBFUZzARIJdhPeFaX35c,75
|
7
|
+
plain/dev/dev_pid.py,sha256=wV8avKNY-zyd-FVQYjtjg5y4zOE7sgiVOhIjj9OmWNg,882
|
6
8
|
plain/dev/entrypoints.py,sha256=diqNwA6eydUMtoO7p_rH-DtSYsw5-GBmjFe1Z5bHagc,579
|
7
9
|
plain/dev/gunicorn_logging.json,sha256=3H6b6bQf_KdY61gzypvI0ly_Jduy-Uhqs2ra9ttzGJQ,867
|
8
10
|
plain/dev/mkcert.py,sha256=fm1U_UTGPREso6ZaP79WqEvd9uvA4lYWFo6fKhNglMM,3911
|
9
11
|
plain/dev/pdb.py,sha256=4ru3rlIIyuYVXteyI7v42i4MmdBIjpJP0IJemBpf83A,3742
|
10
|
-
plain/dev/requests.py,sha256=OLpauU0FoaJA2IG6YD_KS323jKM5l6RX0HiXQvqpH-E,6865
|
11
12
|
plain/dev/services.py,sha256=0MfPLoffItypUg_1PkX-juigR-abSS42nFHFZ5Eag-k,2843
|
12
|
-
plain/dev/urls.py,sha256=6fyl-DvxDgKjUjpyhuLN8m6GisB8L-eH4rhYH_eRmGA,188
|
13
13
|
plain/dev/utils.py,sha256=4wMzpvj1Is_c0QxhsTu34_P9wAYlzw4glNPfVtZr_0A,123
|
14
|
-
plain/dev/views.py,sha256=yCZ_YmkJGCb3YYgElhzAPrA9V0X3lkiLD1-Ht1HjV7A,1131
|
15
14
|
plain/dev/contribute/README.md,sha256=v9Ympugu2wvDEe_045WJnF1dmC4ZH7v_Bnxkpfaf_rM,329
|
16
15
|
plain/dev/contribute/__init__.py,sha256=9ByBOIdM8DebChjNz-RH2atdz4vWe8somlwNEsbhwh4,40
|
17
|
-
plain/dev/contribute/cli.py,sha256=
|
16
|
+
plain/dev/contribute/cli.py,sha256=7oO7L_cRaLXaxgixxqhRFDUwFxzPIySYaFeif0NTFIo,3598
|
18
17
|
plain/dev/poncho/__init__.py,sha256=MDOk2rhhoR3V-I-rg6tMHFeX60vTGJuQ14RI-_N6tQY,97
|
19
18
|
plain/dev/poncho/color.py,sha256=Dk77inPR9qNc9vCaZOGk8W9skXfRgoUlxp_E6mhPNns,610
|
20
19
|
plain/dev/poncho/compat.py,sha256=l66WZLR7kRpO8P8DI5-aUsbNlohPaXEurQ5xXESQYDs,1276
|
@@ -23,9 +22,8 @@ plain/dev/poncho/printer.py,sha256=wt1ioaGcPnVyrPy-UjvdsR9zfcr4DTTycmapW1MIdSU,1
|
|
23
22
|
plain/dev/poncho/process.py,sha256=JJOKy-C6vMCg7-6JMCtu6C649h7HmOBSJqDP_hnX49I,2637
|
24
23
|
plain/dev/precommit/__init__.py,sha256=9ByBOIdM8DebChjNz-RH2atdz4vWe8somlwNEsbhwh4,40
|
25
24
|
plain/dev/precommit/cli.py,sha256=KVHcG3Y_JZJNu3_MLIrO5s6yMYQfAArIU5L0hNWZUjg,3441
|
26
|
-
|
27
|
-
plain_dev-0.
|
28
|
-
plain_dev-0.
|
29
|
-
plain_dev-0.
|
30
|
-
plain_dev-0.
|
31
|
-
plain_dev-0.30.1.dist-info/RECORD,,
|
25
|
+
plain_dev-0.32.0.dist-info/METADATA,sha256=MfND655Xq3SgypdmVD1YxkwGEDYAu3W1UvyQzphB9XY,4270
|
26
|
+
plain_dev-0.32.0.dist-info/WHEEL,sha256=qtCwoSJWgHk21S1Kb4ihdzI2rlJ1ZKaIurTj_ngOhyQ,87
|
27
|
+
plain_dev-0.32.0.dist-info/entry_points.txt,sha256=zrcTOiFk_MLKsnYVlwVP7aMm1XLEqq7w4EBkJ-3ge-g,114
|
28
|
+
plain_dev-0.32.0.dist-info/licenses/LICENSE,sha256=Cx4Dq9yR2fLHthf8Ke36B8QJvE1bZFXVzDIGE8wGzsY,4132
|
29
|
+
plain_dev-0.32.0.dist-info/RECORD,,
|
plain/dev/requests.py
DELETED
@@ -1,224 +0,0 @@
|
|
1
|
-
import datetime
|
2
|
-
import json
|
3
|
-
import os
|
4
|
-
import sys
|
5
|
-
import traceback
|
6
|
-
|
7
|
-
import requests
|
8
|
-
|
9
|
-
from plain.runtime import PLAIN_TEMP_PATH, settings
|
10
|
-
from plain.signals import got_request_exception
|
11
|
-
|
12
|
-
|
13
|
-
class RequestLog:
|
14
|
-
def __init__(self, *, request, response, exception=None):
|
15
|
-
self.request = request
|
16
|
-
self.response = response
|
17
|
-
self.exception = exception
|
18
|
-
|
19
|
-
@staticmethod
|
20
|
-
def storage_path():
|
21
|
-
return str(PLAIN_TEMP_PATH / "dev" / "requestlog")
|
22
|
-
|
23
|
-
@classmethod
|
24
|
-
def replay_request(cls, name):
|
25
|
-
path = os.path.join(cls.storage_path(), f"{name}.json")
|
26
|
-
with open(path) as f:
|
27
|
-
data = json.load(f)
|
28
|
-
|
29
|
-
method = data["request"]["method"]
|
30
|
-
|
31
|
-
if method == "GET":
|
32
|
-
# Params are in absolute uri
|
33
|
-
request_data = data["request"]["body"].encode("utf-8")
|
34
|
-
elif method in ("POST", "PUT", "PATCH"):
|
35
|
-
if data["request"]["querydict"]:
|
36
|
-
request_data = data["request"]["querydict"]
|
37
|
-
else:
|
38
|
-
request_data = data["request"]["body"].encode("utf-8")
|
39
|
-
|
40
|
-
# Cookies need to be passed as a dict, so that
|
41
|
-
# they are passed through redirects
|
42
|
-
data["request"]["headers"].pop("Cookie", None)
|
43
|
-
|
44
|
-
# TODO???
|
45
|
-
if data["request"]["headers"].get("X-Forwarded-Proto", "") == "https,https":
|
46
|
-
data["request"]["headers"]["X-Forwarded-Proto"] = "https"
|
47
|
-
|
48
|
-
response = requests.request(
|
49
|
-
method,
|
50
|
-
data["request"]["absolute_uri"],
|
51
|
-
headers=data["request"]["headers"],
|
52
|
-
cookies=data["request"]["cookies"],
|
53
|
-
data=request_data,
|
54
|
-
timeout=5,
|
55
|
-
)
|
56
|
-
print("Replayed request", response)
|
57
|
-
|
58
|
-
@staticmethod
|
59
|
-
def load_json_logs():
|
60
|
-
storage_path = RequestLog.storage_path()
|
61
|
-
if not os.path.exists(storage_path):
|
62
|
-
return []
|
63
|
-
|
64
|
-
logs = []
|
65
|
-
filenames = os.listdir(storage_path)
|
66
|
-
sorted_filenames = sorted(filenames, reverse=True)
|
67
|
-
for filename in sorted_filenames:
|
68
|
-
path = os.path.join(storage_path, filename)
|
69
|
-
with open(path) as f:
|
70
|
-
log = json.load(f)
|
71
|
-
log["name"] = os.path.splitext(filename)[0]
|
72
|
-
# Convert timestamp back to datetime
|
73
|
-
log["timestamp"] = datetime.datetime.fromtimestamp(log["timestamp"])
|
74
|
-
try:
|
75
|
-
log["request"]["body_json"] = json.dumps(
|
76
|
-
json.loads(log["request"]["body"]), indent=2
|
77
|
-
)
|
78
|
-
except json.JSONDecodeError:
|
79
|
-
pass
|
80
|
-
logs.append(log)
|
81
|
-
|
82
|
-
return logs
|
83
|
-
|
84
|
-
@staticmethod
|
85
|
-
def delete_old_logs():
|
86
|
-
storage_path = RequestLog.storage_path()
|
87
|
-
if not os.path.exists(storage_path):
|
88
|
-
return
|
89
|
-
|
90
|
-
filenames = os.listdir(storage_path)
|
91
|
-
sorted_filenames = sorted(filenames, reverse=True)
|
92
|
-
for filename in sorted_filenames[settings.DEV_REQUESTS_MAX :]:
|
93
|
-
path = os.path.join(storage_path, filename)
|
94
|
-
try:
|
95
|
-
os.remove(path)
|
96
|
-
except FileNotFoundError:
|
97
|
-
pass
|
98
|
-
|
99
|
-
@staticmethod
|
100
|
-
def clear():
|
101
|
-
storage_path = RequestLog.storage_path()
|
102
|
-
if not os.path.exists(storage_path):
|
103
|
-
return
|
104
|
-
|
105
|
-
filenames = os.listdir(storage_path)
|
106
|
-
for filename in filenames:
|
107
|
-
path = os.path.join(storage_path, filename)
|
108
|
-
try:
|
109
|
-
os.remove(path)
|
110
|
-
except FileNotFoundError:
|
111
|
-
pass
|
112
|
-
|
113
|
-
def save(self):
|
114
|
-
storage_path = self.storage_path()
|
115
|
-
if not os.path.exists(storage_path):
|
116
|
-
os.makedirs(storage_path)
|
117
|
-
|
118
|
-
timestamp = datetime.datetime.now().timestamp()
|
119
|
-
filename = f"{timestamp}.json"
|
120
|
-
path = os.path.join(storage_path, filename)
|
121
|
-
with open(path, "w+") as f:
|
122
|
-
json.dump(self.as_dict(), f, indent=2)
|
123
|
-
|
124
|
-
self.delete_old_logs()
|
125
|
-
|
126
|
-
def as_dict(self):
|
127
|
-
return {
|
128
|
-
"timestamp": datetime.datetime.now().timestamp(),
|
129
|
-
"request": self.request_as_dict(self.request),
|
130
|
-
"response": self.response_as_dict(self.response),
|
131
|
-
"exception": self.exception_as_dict(self.exception),
|
132
|
-
}
|
133
|
-
|
134
|
-
@staticmethod
|
135
|
-
def request_as_dict(request):
|
136
|
-
return {
|
137
|
-
"method": request.method,
|
138
|
-
"path": request.path,
|
139
|
-
"full_path": request.get_full_path(),
|
140
|
-
"querydict": request.data.dict()
|
141
|
-
if request.method == "POST"
|
142
|
-
else request.query_params.dict(),
|
143
|
-
"cookies": request.cookies,
|
144
|
-
# files?
|
145
|
-
"absolute_uri": request.build_absolute_uri(),
|
146
|
-
"body": request.body.decode("utf-8"),
|
147
|
-
"headers": dict(request.headers),
|
148
|
-
}
|
149
|
-
|
150
|
-
@staticmethod
|
151
|
-
def response_as_dict(response):
|
152
|
-
try:
|
153
|
-
content = response.content.decode("utf-8")
|
154
|
-
except AttributeError:
|
155
|
-
content = "<streaming_content>"
|
156
|
-
|
157
|
-
return {
|
158
|
-
"status_code": response.status_code,
|
159
|
-
"headers": dict(response.headers),
|
160
|
-
"content": content,
|
161
|
-
}
|
162
|
-
|
163
|
-
@staticmethod
|
164
|
-
def exception_as_dict(exception):
|
165
|
-
if not exception:
|
166
|
-
return None
|
167
|
-
|
168
|
-
tb_string = "".join(traceback.format_tb(exception.__traceback__))
|
169
|
-
|
170
|
-
try:
|
171
|
-
args = json.dumps(exception.args)
|
172
|
-
except TypeError:
|
173
|
-
args = str(exception.args)
|
174
|
-
|
175
|
-
return {
|
176
|
-
"type": type(exception).__name__,
|
177
|
-
"str": str(exception),
|
178
|
-
"args": args,
|
179
|
-
"traceback": tb_string,
|
180
|
-
}
|
181
|
-
|
182
|
-
|
183
|
-
def should_capture_request(request):
|
184
|
-
if not settings.DEBUG:
|
185
|
-
return False
|
186
|
-
|
187
|
-
if request.resolver_match and request.resolver_match.namespace == "dev":
|
188
|
-
return False
|
189
|
-
|
190
|
-
if request.path in settings.DEV_REQUESTS_IGNORE_PATHS:
|
191
|
-
return False
|
192
|
-
|
193
|
-
# This could be an attribute set on request or response
|
194
|
-
# or something more dynamic
|
195
|
-
if "querystats" in request.query_params:
|
196
|
-
return False
|
197
|
-
|
198
|
-
return True
|
199
|
-
|
200
|
-
|
201
|
-
class RequestsMiddleware:
|
202
|
-
def __init__(self, get_response):
|
203
|
-
self.get_response = get_response
|
204
|
-
self.exception = None # If an exception occurs, we want to remember it
|
205
|
-
|
206
|
-
got_request_exception.connect(self.store_exception)
|
207
|
-
|
208
|
-
def __call__(self, request):
|
209
|
-
# Process it first, so we know the resolver_match
|
210
|
-
response = self.get_response(request)
|
211
|
-
|
212
|
-
if should_capture_request(request):
|
213
|
-
RequestLog(
|
214
|
-
request=request, response=response, exception=self.exception
|
215
|
-
).save()
|
216
|
-
|
217
|
-
return response
|
218
|
-
|
219
|
-
def store_exception(self, **kwargs):
|
220
|
-
"""
|
221
|
-
The signal calls this at the right time,
|
222
|
-
so we can use sys.exxception to capture.
|
223
|
-
"""
|
224
|
-
self.exception = sys.exception()
|
@@ -1,134 +0,0 @@
|
|
1
|
-
<!DOCTYPE html>
|
2
|
-
<html lang="en">
|
3
|
-
<head>
|
4
|
-
<meta charset="UTF-8">
|
5
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
6
|
-
<title>Requestlog</title>
|
7
|
-
{% tailwind_css %}
|
8
|
-
</head>
|
9
|
-
<body class="bg-zinc-900 text-zinc-200">
|
10
|
-
|
11
|
-
<div class="flex">
|
12
|
-
<div class="overflow-auto border-r sm:w-1/2 md:2/5 border-zinc-700">
|
13
|
-
<table>
|
14
|
-
<tbody>
|
15
|
-
{% for log in requestlogs %}
|
16
|
-
<tr class="hover:bg-zinc-800 cursor-pointer text-zinc-400 {% if log.name == requestlog.name %}bg-zinc-700{% endif %}">
|
17
|
-
<td class="whitespace-nowrap px-2 py-2 border-b border-zinc-700 border-l-4 {% if log.name == requestlog.name %}border-l-orange-600{% else %}border-l-transparent{% endif %}">
|
18
|
-
{% if log.response.status_code >= 400 %}
|
19
|
-
<span class="px-1.5 py-0.5 text-xs text-white bg-red-600 rounded-sm">{{ log.response.status_code }}</span>
|
20
|
-
{% elif log.response.status_code >= 300 %}
|
21
|
-
<span class="px-1.5 py-0.5 text-xs text-white bg-zinc-600 rounded-sm">{{ log.response.status_code }}</span>
|
22
|
-
{% else %}
|
23
|
-
<span class="px-1.5 py-0.5 text-xs text-zinc-700 bg-zinc-300 rounded-sm">{{ log.response.status_code }}</span>
|
24
|
-
{% endif %}
|
25
|
-
</td>
|
26
|
-
<td class="px-1 py-2 border-b whitespace-nowrap border-zinc-700">
|
27
|
-
<span class="font-mono text-sm text-zinc-400">{{ log.request.method }}</span>
|
28
|
-
</td>
|
29
|
-
<td class="w-full px-2 py-2 overflow-hidden border-b whitespace-nowrap border-zinc-700 text-ellipsis" style="max-width: 1px;">
|
30
|
-
<a href="?log={{ log.name }}">
|
31
|
-
<span class="font-mono text-sm text-zinc-300">{{ log.request.full_path }}</span>
|
32
|
-
</a>
|
33
|
-
</td>
|
34
|
-
<td class="px-2 py-2 text-right border-b whitespace-nowrap border-zinc-700">
|
35
|
-
<span title="{{ log.timestamp }}" class="text-xs tabular-nums whitespace-nowrap">{{ log.timestamp|strftime("%H:%M:%S") }}</span>
|
36
|
-
</td>
|
37
|
-
</tr>
|
38
|
-
{% else %}
|
39
|
-
<tr>
|
40
|
-
<td class="px-4 py-2 text-center">
|
41
|
-
<span class="text-zinc-500">No logs yet!</span>
|
42
|
-
</td>
|
43
|
-
</tr>
|
44
|
-
{% endfor %}
|
45
|
-
{% if requestlogs %}
|
46
|
-
<tr>
|
47
|
-
<td colspan="4" class="px-1 py-3">
|
48
|
-
<form method="post">
|
49
|
-
{{ csrf_input }}
|
50
|
-
<input type="hidden" name="action" value="clear">
|
51
|
-
<button type="submit" class="flex items-center mx-auto text-xs text-red-500/75 hover:text-red-500">
|
52
|
-
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="w-4 h-4 mr-2 bi bi-trash3" viewBox="0 0 16 16">
|
53
|
-
<path d="M6.5 1h3a.5.5 0 0 1 .5.5v1H6v-1a.5.5 0 0 1 .5-.5ZM11 2.5v-1A1.5 1.5 0 0 0 9.5 0h-3A1.5 1.5 0 0 0 5 1.5v1H2.506a.58.58 0 0 0-.01 0H1.5a.5.5 0 0 0 0 1h.538l.853 10.66A2 2 0 0 0 4.885 16h6.23a2 2 0 0 0 1.994-1.84l.853-10.66h.538a.5.5 0 0 0 0-1h-.995a.59.59 0 0 0-.01 0H11Zm1.958 1-.846 10.58a1 1 0 0 1-.997.92h-6.23a1 1 0 0 1-.997-.92L3.042 3.5h9.916Zm-7.487 1a.5.5 0 0 1 .528.47l.5 8.5a.5.5 0 0 1-.998.06L5 5.03a.5.5 0 0 1 .47-.53Zm5.058 0a.5.5 0 0 1 .47.53l-.5 8.5a.5.5 0 1 1-.998-.06l.5-8.5a.5.5 0 0 1 .528-.47ZM8 4.5a.5.5 0 0 1 .5.5v8.5a.5.5 0 0 1-1 0V5a.5.5 0 0 1 .5-.5Z"/>
|
54
|
-
</svg>
|
55
|
-
Clear all
|
56
|
-
</button>
|
57
|
-
</form>
|
58
|
-
</td>
|
59
|
-
</tr>
|
60
|
-
{% endif %}
|
61
|
-
</tbody>
|
62
|
-
</table>
|
63
|
-
</div>
|
64
|
-
|
65
|
-
{% if requestlog %}
|
66
|
-
<div class="px-8 py-6 overflow-auto sm:w-1/2 md:w-3/5">
|
67
|
-
{% with request=requestlog.request %}
|
68
|
-
<div class="flex items-center justify-between">
|
69
|
-
<h3 class="text-lg font-medium">
|
70
|
-
{{ request.method }} <a href="{{ request.absolute_uri }}" class="font-mono hover:underline">{{ request.full_path }}</a>
|
71
|
-
</h3>
|
72
|
-
<form method="POST">
|
73
|
-
{{ csrf_input }}
|
74
|
-
<input type="hidden" name="log" value="{{ requestlog.name }}">
|
75
|
-
<button type="submit" class="flex items-center px-3 py-2 ml-4 text-sm rounded-full bg-zinc-600 hover:bg-zinc-500">
|
76
|
-
<svg xmlns="http://www.w3.org/2000/svg" class="w-4 h-4 mr-2" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-refresh-cw"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
|
77
|
-
replay
|
78
|
-
</button>
|
79
|
-
</form>
|
80
|
-
</div>
|
81
|
-
|
82
|
-
{% if requestlog.exception %}
|
83
|
-
<div class="p-2 mt-4 bg-red-600 rounded text-zinc-100">
|
84
|
-
<h3 class="text-sm font-medium">{{ requestlog.exception.type }} Exception</h3>
|
85
|
-
<pre class="mt-2 overflow-auto text-xs rounded bg-zinc-800 text-zinc-200 max-h-96"><code>
|
86
|
-
{{- requestlog.exception.str -}}
|
87
|
-
</code></pre>
|
88
|
-
<pre class="mt-2 overflow-auto text-xs rounded bg-zinc-800 text-zinc-200 max-h-96"><code>
|
89
|
-
{{- requestlog.exception.args -}}
|
90
|
-
</code></pre>
|
91
|
-
<pre class="mt-2 overflow-auto text-xs rounded bg-zinc-800 text-zinc-200"><code>
|
92
|
-
{{- requestlog.exception.traceback -}}
|
93
|
-
</code></pre>
|
94
|
-
</div>
|
95
|
-
{% endif %}
|
96
|
-
|
97
|
-
{% if request.querydict %}
|
98
|
-
<dl class="px-2 pt-1 pb-2 mt-4 rounded bg-zinc-800">
|
99
|
-
{% for key, value in request.querydict.items() %}
|
100
|
-
<dt class="mt-2 text-xs text-zinc-400">{{ key }}</dt>
|
101
|
-
<dd class="text-xs break-all text-zinc-300"><code>{{ value or "(Empty)" }}</code></dd>
|
102
|
-
{% endfor %}
|
103
|
-
</dl>
|
104
|
-
{% endif %}
|
105
|
-
|
106
|
-
<dl class="mt-4">
|
107
|
-
{% for key, value in request.headers.items() %}
|
108
|
-
<dt class="mt-2 text-xs text-zinc-400">{{ key }}</dt>
|
109
|
-
<dd class="text-xs break-all text-zinc-300"><code>{{ value or "(Empty)" }}</code></dd>
|
110
|
-
{% endfor %}
|
111
|
-
</dl>
|
112
|
-
{% if "body_json" in request %}
|
113
|
-
<pre class="p-2 mt-4 overflow-y-auto text-xs rounded bg-zinc-800 text-zinc-200"><code>{{ request.body_json }}</code></pre>
|
114
|
-
{% else %}
|
115
|
-
<pre class="p-2 mt-4 overflow-y-auto text-xs rounded bg-zinc-800 text-zinc-200 max-h-96"><code>{{ request.body or "(Empty)" }}</code></pre>
|
116
|
-
{% endif %}
|
117
|
-
{% endwith %}
|
118
|
-
|
119
|
-
{% with response=requestlog.response %}
|
120
|
-
<h3 class="mt-10 text-lg font-medium">HTTP <span class="font-mono">{{ response.status_code }}</span></h3>
|
121
|
-
<dl class="mt-4">
|
122
|
-
{% for key, value in response.headers.items() %}
|
123
|
-
<dt class="mt-2 text-xs text-zinc-400">{{ key }}</dt>
|
124
|
-
<dd class="text-xs break-all text-zinc-300"><code>{{ value or "(Empty)" }}</code></dd>
|
125
|
-
{% endfor %}
|
126
|
-
</dl>
|
127
|
-
<pre class="p-2 mt-4 overflow-y-auto text-xs rounded bg-zinc-800 text-zinc-200 max-h-96"><code>{{ response.content or "(Empty)" }}</code></pre>
|
128
|
-
{% endwith %}
|
129
|
-
</div>
|
130
|
-
{% endif %}
|
131
|
-
</div>
|
132
|
-
|
133
|
-
</body>
|
134
|
-
</html>
|
plain/dev/urls.py
DELETED
plain/dev/views.py
DELETED
@@ -1,39 +0,0 @@
|
|
1
|
-
from plain.http import ResponseRedirect
|
2
|
-
from plain.views import TemplateView
|
3
|
-
|
4
|
-
from .requests import RequestLog
|
5
|
-
|
6
|
-
|
7
|
-
class RequestsView(TemplateView):
|
8
|
-
template_name = "dev/requests.html"
|
9
|
-
|
10
|
-
def get_template_context(self):
|
11
|
-
ctx = super().get_template_context()
|
12
|
-
requestlogs = RequestLog.load_json_logs()
|
13
|
-
|
14
|
-
if self.request.query_params.get("log"):
|
15
|
-
try:
|
16
|
-
requestlog = [
|
17
|
-
x
|
18
|
-
for x in requestlogs
|
19
|
-
if x.get("name") == self.request.query_params["log"]
|
20
|
-
][0]
|
21
|
-
except IndexError:
|
22
|
-
requestlog = None
|
23
|
-
elif requestlogs:
|
24
|
-
requestlog = requestlogs[0]
|
25
|
-
else:
|
26
|
-
requestlog = None
|
27
|
-
|
28
|
-
ctx["requestlogs"] = requestlogs
|
29
|
-
ctx["requestlog"] = requestlog
|
30
|
-
|
31
|
-
return ctx
|
32
|
-
|
33
|
-
def post(self):
|
34
|
-
if self.request.data.get("action") == "clear":
|
35
|
-
RequestLog.clear()
|
36
|
-
return ResponseRedirect(self.request.path)
|
37
|
-
else:
|
38
|
-
RequestLog.replay_request(self.request.data["log"])
|
39
|
-
return ResponseRedirect(".")
|
File without changes
|
File without changes
|
File without changes
|