plain.dev 0.30.1__tar.gz → 0.32.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. {plain_dev-0.30.1 → plain_dev-0.32.1}/PKG-INFO +2 -1
  2. plain_dev-0.32.1/plain/dev/CHANGELOG.md +24 -0
  3. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/README.md +1 -0
  4. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/cli.py +38 -4
  5. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/contribute/cli.py +20 -5
  6. plain_dev-0.32.1/plain/dev/dev_pid.py +36 -0
  7. plain_dev-0.32.1/plain/dev/precommit/__init__.py +3 -0
  8. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/precommit/cli.py +0 -1
  9. {plain_dev-0.30.1 → plain_dev-0.32.1}/pyproject.toml +1 -1
  10. plain_dev-0.30.1/plain/dev/__init__.py +0 -4
  11. plain_dev-0.30.1/plain/dev/requests.py +0 -224
  12. plain_dev-0.30.1/plain/dev/templates/dev/requests.html +0 -134
  13. plain_dev-0.30.1/plain/dev/urls.py +0 -10
  14. plain_dev-0.30.1/plain/dev/views.py +0 -39
  15. {plain_dev-0.30.1 → plain_dev-0.32.1}/.gitignore +0 -0
  16. {plain_dev-0.30.1 → plain_dev-0.32.1}/LICENSE +0 -0
  17. {plain_dev-0.30.1 → plain_dev-0.32.1}/README.md +0 -0
  18. {plain_dev-0.30.1/plain/dev/contribute → plain_dev-0.32.1/plain/dev}/__init__.py +0 -0
  19. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/contribute/README.md +0 -0
  20. {plain_dev-0.30.1/plain/dev/precommit → plain_dev-0.32.1/plain/dev/contribute}/__init__.py +0 -0
  21. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/debug.py +0 -0
  22. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/default_settings.py +0 -0
  23. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/entrypoints.py +0 -0
  24. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/gunicorn_logging.json +0 -0
  25. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/mkcert.py +0 -0
  26. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/pdb.py +0 -0
  27. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/poncho/__init__.py +0 -0
  28. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/poncho/color.py +0 -0
  29. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/poncho/compat.py +0 -0
  30. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/poncho/manager.py +0 -0
  31. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/poncho/printer.py +0 -0
  32. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/poncho/process.py +0 -0
  33. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/services.py +0 -0
  34. {plain_dev-0.30.1 → plain_dev-0.32.1}/plain/dev/utils.py +0 -0
  35. {plain_dev-0.30.1 → plain_dev-0.32.1}/tests/settings.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain.dev
3
- Version: 0.30.1
3
+ Version: 0.32.1
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`
@@ -0,0 +1,24 @@
1
+ # plain-dev changelog
2
+
3
+ ## [0.32.1](https://github.com/dropseed/plain/releases/plain-dev@0.32.1) (2025-06-27)
4
+
5
+ ### What's changed
6
+
7
+ - Fixed an error when running `plain dev precommit` (or the `plain precommit` helper) that passed an extra `default` argument to `plain preflight --database`. The flag now correctly aligns with the current `plain preflight` CLI ([db65930](https://github.com/dropseed/plain/commit/db659304129a453676c0dcc20c13b606254ce1c2)).
8
+
9
+ ### Upgrade instructions
10
+
11
+ - No changes required.
12
+
13
+ ## [0.32.0](https://github.com/dropseed/plain/releases/plain-dev@0.32.0) (2025-06-23)
14
+
15
+ ### What's changed
16
+
17
+ - `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)).
18
+ - 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)).
19
+ - 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)).
20
+ - `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)).
21
+
22
+ ### Upgrade instructions
23
+
24
+ - No changes required.
@@ -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`
@@ -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=8443,
39
- type=int,
40
- help="Port to run the web server on",
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()
@@ -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
- line.split("==")[0]
72
- for line in installed_packages.splitlines()
73
- if line.startswith("plain")
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(".", "-")
@@ -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
@@ -0,0 +1,3 @@
1
+ from .cli import cli
2
+
3
+ __all__ = ["cli"]
@@ -67,7 +67,6 @@ def cli(install):
67
67
  "plain",
68
68
  "preflight",
69
69
  "--database",
70
- "default",
71
70
  )
72
71
  check_short("Checking Plain migrations", "plain", "migrate", "--check")
73
72
  check_short(
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "plain.dev"
3
- version = "0.30.1"
3
+ version = "0.32.1"
4
4
  description = "Local development tools for Plain."
5
5
  authors = [{name = "Dave Gaeddert", email = "dave.gaeddert@dropseed.dev"}]
6
6
  license = "BSD-3-Clause"
@@ -1,4 +0,0 @@
1
- from .cli import cli
2
- from .requests import RequestsMiddleware
3
-
4
- __all__ = ["cli", "RequestsMiddleware"]
@@ -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>
@@ -1,10 +0,0 @@
1
- from plain.urls import Router, path
2
-
3
- from . import views
4
-
5
-
6
- class DevRequestsRouter(Router):
7
- namespace = "dev"
8
- urls = [
9
- path("", views.RequestsView, name="requests"),
10
- ]
@@ -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
File without changes
File without changes