plain 0.74.0__py3-none-any.whl → 0.76.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/CHANGELOG.md +29 -0
- plain/README.md +1 -1
- plain/chores/README.md +1 -1
- plain/cli/core.py +35 -17
- plain/cli/runtime.py +28 -0
- plain/cli/server.py +143 -0
- plain/server/LICENSE +35 -0
- plain/server/README.md +75 -0
- plain/server/__init__.py +9 -0
- plain/server/app.py +52 -0
- plain/server/arbiter.py +555 -0
- plain/server/config.py +118 -0
- plain/server/errors.py +31 -0
- plain/server/glogging.py +292 -0
- plain/server/http/__init__.py +12 -0
- plain/server/http/body.py +283 -0
- plain/server/http/errors.py +150 -0
- plain/server/http/message.py +399 -0
- plain/server/http/parser.py +69 -0
- plain/server/http/unreader.py +88 -0
- plain/server/http/wsgi.py +421 -0
- plain/server/pidfile.py +91 -0
- plain/server/reloader.py +158 -0
- plain/server/sock.py +219 -0
- plain/server/util.py +380 -0
- plain/server/workers/__init__.py +12 -0
- plain/server/workers/base.py +305 -0
- plain/server/workers/gthread.py +393 -0
- plain/server/workers/sync.py +210 -0
- plain/server/workers/workertmp.py +50 -0
- {plain-0.74.0.dist-info → plain-0.76.0.dist-info}/METADATA +2 -2
- {plain-0.74.0.dist-info → plain-0.76.0.dist-info}/RECORD +35 -9
- {plain-0.74.0.dist-info → plain-0.76.0.dist-info}/WHEEL +0 -0
- {plain-0.74.0.dist-info → plain-0.76.0.dist-info}/entry_points.txt +0 -0
- {plain-0.74.0.dist-info → plain-0.76.0.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,421 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
#
|
4
|
+
#
|
5
|
+
# This file is part of gunicorn released under the MIT license.
|
6
|
+
# See the LICENSE for more information.
|
7
|
+
#
|
8
|
+
# Vendored and modified for Plain.
|
9
|
+
import io
|
10
|
+
import logging
|
11
|
+
import os
|
12
|
+
import re
|
13
|
+
import socket
|
14
|
+
import sys
|
15
|
+
from collections.abc import Callable, Iterator
|
16
|
+
from typing import TYPE_CHECKING, Any, cast
|
17
|
+
|
18
|
+
import plain.runtime
|
19
|
+
|
20
|
+
from .. import util
|
21
|
+
from .errors import ConfigurationProblem, InvalidHeader, InvalidHeaderName
|
22
|
+
from .message import TOKEN_RE
|
23
|
+
|
24
|
+
if TYPE_CHECKING:
|
25
|
+
from ..config import Config
|
26
|
+
from .message import Request
|
27
|
+
|
28
|
+
# Send files in at most 1GB blocks as some operating systems can have problems
|
29
|
+
# with sending files in blocks over 2GB.
|
30
|
+
BLKSIZE = 0x3FFFFFFF
|
31
|
+
|
32
|
+
# RFC9110 5.5: field-vchar = VCHAR / obs-text
|
33
|
+
# RFC4234 B.1: VCHAR = 0x21-x07E = printable ASCII
|
34
|
+
HEADER_VALUE_RE = re.compile(r"[ \t\x21-\x7e\x80-\xff]*")
|
35
|
+
|
36
|
+
log = logging.getLogger(__name__)
|
37
|
+
|
38
|
+
|
39
|
+
class FileWrapper:
|
40
|
+
def __init__(self, filelike: Any, blksize: int = 8192) -> None:
|
41
|
+
self.filelike = filelike
|
42
|
+
self.blksize = blksize
|
43
|
+
if hasattr(filelike, "close"):
|
44
|
+
self.close = filelike.close
|
45
|
+
|
46
|
+
def __getitem__(self, key: int) -> bytes:
|
47
|
+
data = self.filelike.read(self.blksize)
|
48
|
+
if data:
|
49
|
+
return data
|
50
|
+
raise IndexError
|
51
|
+
|
52
|
+
|
53
|
+
class WSGIErrorsWrapper(io.RawIOBase):
|
54
|
+
def __init__(self, cfg: Config) -> None:
|
55
|
+
# There is no public __init__ method for RawIOBase so
|
56
|
+
# we don't need to call super() in the __init__ method.
|
57
|
+
# pylint: disable=super-init-not-called
|
58
|
+
errorlog = logging.getLogger("plain.server.error")
|
59
|
+
handlers = errorlog.handlers
|
60
|
+
self.streams: list[Any] = []
|
61
|
+
|
62
|
+
if cfg.errorlog == "-":
|
63
|
+
self.streams.append(sys.stderr)
|
64
|
+
handlers = handlers[1:]
|
65
|
+
|
66
|
+
for h in handlers:
|
67
|
+
if hasattr(h, "stream"):
|
68
|
+
self.streams.append(h.stream)
|
69
|
+
|
70
|
+
def write(self, data: str | bytes) -> None:
|
71
|
+
for stream in self.streams:
|
72
|
+
try:
|
73
|
+
stream.write(data)
|
74
|
+
except UnicodeError:
|
75
|
+
if isinstance(data, str):
|
76
|
+
stream.write(data.encode("UTF-8"))
|
77
|
+
else:
|
78
|
+
stream.write(data)
|
79
|
+
stream.flush()
|
80
|
+
|
81
|
+
|
82
|
+
def base_environ(cfg: Config) -> dict[str, Any]:
|
83
|
+
return {
|
84
|
+
"wsgi.errors": WSGIErrorsWrapper(cfg),
|
85
|
+
"wsgi.version": (1, 0),
|
86
|
+
"wsgi.multithread": False,
|
87
|
+
"wsgi.multiprocess": (cfg.workers > 1),
|
88
|
+
"wsgi.run_once": False,
|
89
|
+
"wsgi.file_wrapper": FileWrapper,
|
90
|
+
"wsgi.input_terminated": True,
|
91
|
+
"SERVER_SOFTWARE": f"plain/{plain.runtime.__version__}",
|
92
|
+
}
|
93
|
+
|
94
|
+
|
95
|
+
def default_environ(req: Request, sock: socket.socket, cfg: Config) -> dict[str, Any]:
|
96
|
+
env = base_environ(cfg)
|
97
|
+
env.update(
|
98
|
+
{
|
99
|
+
"wsgi.input": req.body,
|
100
|
+
"plain.server.socket": sock,
|
101
|
+
"REQUEST_METHOD": req.method,
|
102
|
+
"QUERY_STRING": req.query,
|
103
|
+
"RAW_URI": req.uri,
|
104
|
+
"SERVER_PROTOCOL": "HTTP/{}".format(
|
105
|
+
".".join([str(v) for v in req.version])
|
106
|
+
),
|
107
|
+
}
|
108
|
+
)
|
109
|
+
return env
|
110
|
+
|
111
|
+
|
112
|
+
def create(
|
113
|
+
req: Request,
|
114
|
+
sock: socket.socket,
|
115
|
+
client: str | bytes | tuple[str, int],
|
116
|
+
server: str | tuple[str, int],
|
117
|
+
cfg: Config,
|
118
|
+
) -> tuple[Response, dict[str, Any]]:
|
119
|
+
resp = Response(req, sock, cfg)
|
120
|
+
|
121
|
+
# set initial environ
|
122
|
+
environ = default_environ(req, sock, cfg)
|
123
|
+
|
124
|
+
# default variables
|
125
|
+
host = None
|
126
|
+
script_name = os.environ.get("SCRIPT_NAME", "")
|
127
|
+
|
128
|
+
# add the headers to the environ
|
129
|
+
for hdr_name, hdr_value in req.headers:
|
130
|
+
if hdr_name == "EXPECT":
|
131
|
+
# handle expect
|
132
|
+
if hdr_value.lower() == "100-continue":
|
133
|
+
sock.send(b"HTTP/1.1 100 Continue\r\n\r\n")
|
134
|
+
elif hdr_name == "HOST":
|
135
|
+
host = hdr_value
|
136
|
+
elif hdr_name == "SCRIPT_NAME":
|
137
|
+
script_name = hdr_value
|
138
|
+
elif hdr_name == "CONTENT-TYPE":
|
139
|
+
environ["CONTENT_TYPE"] = hdr_value
|
140
|
+
continue
|
141
|
+
elif hdr_name == "CONTENT-LENGTH":
|
142
|
+
environ["CONTENT_LENGTH"] = hdr_value
|
143
|
+
continue
|
144
|
+
|
145
|
+
# do not change lightly, this is a common source of security problems
|
146
|
+
# RFC9110 Section 17.10 discourages ambiguous or incomplete mappings
|
147
|
+
key = "HTTP_" + hdr_name.replace("-", "_")
|
148
|
+
if key in environ:
|
149
|
+
hdr_value = f"{environ[key]},{hdr_value}"
|
150
|
+
environ[key] = hdr_value
|
151
|
+
|
152
|
+
# set the url scheme
|
153
|
+
environ["wsgi.url_scheme"] = req.scheme
|
154
|
+
|
155
|
+
# set the REMOTE_* keys in environ
|
156
|
+
# authors should be aware that REMOTE_HOST and REMOTE_ADDR
|
157
|
+
# may not qualify the remote addr:
|
158
|
+
# http://www.ietf.org/rfc/rfc3875
|
159
|
+
if isinstance(client, str):
|
160
|
+
environ["REMOTE_ADDR"] = client
|
161
|
+
elif isinstance(client, bytes):
|
162
|
+
environ["REMOTE_ADDR"] = client.decode()
|
163
|
+
else:
|
164
|
+
environ["REMOTE_ADDR"] = client[0]
|
165
|
+
environ["REMOTE_PORT"] = str(client[1])
|
166
|
+
|
167
|
+
# handle the SERVER_*
|
168
|
+
# Normally only the application should use the Host header but since the
|
169
|
+
# WSGI spec doesn't support unix sockets, we are using it to create
|
170
|
+
# viable SERVER_* if possible.
|
171
|
+
server_parts: list[str | int]
|
172
|
+
if isinstance(server, str):
|
173
|
+
server_parts = cast(list[str | int], server.split(":"))
|
174
|
+
if len(server_parts) == 1:
|
175
|
+
# unix socket
|
176
|
+
if host:
|
177
|
+
server_parts = cast(list[str | int], host.split(":"))
|
178
|
+
if len(server_parts) == 1:
|
179
|
+
if req.scheme == "http":
|
180
|
+
server_parts.append(80)
|
181
|
+
elif req.scheme == "https":
|
182
|
+
server_parts.append(443)
|
183
|
+
else:
|
184
|
+
server_parts.append("")
|
185
|
+
else:
|
186
|
+
# no host header given which means that we are not behind a
|
187
|
+
# proxy, so append an empty port.
|
188
|
+
server_parts.append("")
|
189
|
+
else:
|
190
|
+
server_parts = list(server)
|
191
|
+
environ["SERVER_NAME"] = str(server_parts[0])
|
192
|
+
environ["SERVER_PORT"] = str(server_parts[1])
|
193
|
+
|
194
|
+
# set the path and script name
|
195
|
+
path_info: str = req.path or ""
|
196
|
+
if script_name:
|
197
|
+
if not path_info.startswith(script_name):
|
198
|
+
raise ConfigurationProblem(
|
199
|
+
f"Request path {path_info!r} does not start with SCRIPT_NAME {script_name!r}"
|
200
|
+
)
|
201
|
+
path_info = path_info[len(script_name) :]
|
202
|
+
environ["PATH_INFO"] = util.unquote_to_wsgi_str(path_info)
|
203
|
+
environ["SCRIPT_NAME"] = script_name
|
204
|
+
|
205
|
+
return resp, environ
|
206
|
+
|
207
|
+
|
208
|
+
class Response:
|
209
|
+
def __init__(self, req: Request, sock: socket.socket, cfg: Config) -> None:
|
210
|
+
self.req = req
|
211
|
+
self.sock = sock
|
212
|
+
self.version = "plain"
|
213
|
+
self.status: str | None = None
|
214
|
+
self.chunked = False
|
215
|
+
self.must_close = False
|
216
|
+
self.headers: list[tuple[str, str]] = []
|
217
|
+
self.headers_sent = False
|
218
|
+
self.response_length: int | None = None
|
219
|
+
self.sent = 0
|
220
|
+
self.upgrade = False
|
221
|
+
self.cfg = cfg
|
222
|
+
self.status_code: int | None = None
|
223
|
+
|
224
|
+
def force_close(self) -> None:
|
225
|
+
self.must_close = True
|
226
|
+
|
227
|
+
def should_close(self) -> bool:
|
228
|
+
if self.must_close or self.req.should_close():
|
229
|
+
return True
|
230
|
+
if self.response_length is not None or self.chunked:
|
231
|
+
return False
|
232
|
+
if self.req.method == "HEAD":
|
233
|
+
return False
|
234
|
+
if self.status_code is not None and (
|
235
|
+
self.status_code < 200 or self.status_code in (204, 304)
|
236
|
+
):
|
237
|
+
return False
|
238
|
+
return True
|
239
|
+
|
240
|
+
def start_response(
|
241
|
+
self,
|
242
|
+
status: str,
|
243
|
+
headers: list[tuple[str, str]],
|
244
|
+
exc_info: tuple[type[BaseException], BaseException, Any] | None = None,
|
245
|
+
) -> Callable[[bytes], None]:
|
246
|
+
if exc_info:
|
247
|
+
try:
|
248
|
+
if self.status and self.headers_sent:
|
249
|
+
util.reraise(exc_info[0], exc_info[1], exc_info[2])
|
250
|
+
finally:
|
251
|
+
exc_info = None
|
252
|
+
elif self.status is not None:
|
253
|
+
raise AssertionError("Response headers already set!")
|
254
|
+
|
255
|
+
self.status = status
|
256
|
+
|
257
|
+
# get the status code from the response here so we can use it to check
|
258
|
+
# the need for the connection header later without parsing the string
|
259
|
+
# each time.
|
260
|
+
try:
|
261
|
+
self.status_code = int(self.status.split()[0])
|
262
|
+
except ValueError:
|
263
|
+
self.status_code = None
|
264
|
+
|
265
|
+
self.process_headers(headers)
|
266
|
+
self.chunked = self.is_chunked()
|
267
|
+
return self.write
|
268
|
+
|
269
|
+
def process_headers(self, headers: list[tuple[str, str]]) -> None:
|
270
|
+
for name, value in headers:
|
271
|
+
if not isinstance(name, str):
|
272
|
+
raise TypeError(f"{name!r} is not a string")
|
273
|
+
|
274
|
+
if not TOKEN_RE.fullmatch(name):
|
275
|
+
raise InvalidHeaderName(f"{name!r}")
|
276
|
+
|
277
|
+
if not isinstance(value, str):
|
278
|
+
raise TypeError(f"{value!r} is not a string")
|
279
|
+
|
280
|
+
if not HEADER_VALUE_RE.fullmatch(value):
|
281
|
+
raise InvalidHeader(f"{value!r}")
|
282
|
+
|
283
|
+
# RFC9110 5.5
|
284
|
+
value = value.strip(" \t")
|
285
|
+
lname = name.lower()
|
286
|
+
if lname == "content-length":
|
287
|
+
self.response_length = int(value)
|
288
|
+
elif util.is_hoppish(name):
|
289
|
+
if lname == "connection":
|
290
|
+
# handle websocket
|
291
|
+
if value.lower() == "upgrade":
|
292
|
+
self.upgrade = True
|
293
|
+
elif lname == "upgrade":
|
294
|
+
if value.lower() == "websocket":
|
295
|
+
self.headers.append((name, value))
|
296
|
+
|
297
|
+
# ignore hopbyhop headers
|
298
|
+
continue
|
299
|
+
self.headers.append((name, value))
|
300
|
+
|
301
|
+
def is_chunked(self) -> bool:
|
302
|
+
# Only use chunked responses when the client is
|
303
|
+
# speaking HTTP/1.1 or newer and there was
|
304
|
+
# no Content-Length header set.
|
305
|
+
if self.response_length is not None:
|
306
|
+
return False
|
307
|
+
elif self.req.version <= (1, 0):
|
308
|
+
return False
|
309
|
+
elif self.req.method == "HEAD":
|
310
|
+
# Responses to a HEAD request MUST NOT contain a response body.
|
311
|
+
return False
|
312
|
+
elif self.status_code is not None and self.status_code in (204, 304):
|
313
|
+
# Do not use chunked responses when the response is guaranteed to
|
314
|
+
# not have a response body.
|
315
|
+
return False
|
316
|
+
return True
|
317
|
+
|
318
|
+
def default_headers(self) -> list[str]:
|
319
|
+
# set the connection header
|
320
|
+
if self.upgrade:
|
321
|
+
connection = "upgrade"
|
322
|
+
elif self.should_close():
|
323
|
+
connection = "close"
|
324
|
+
else:
|
325
|
+
connection = "keep-alive"
|
326
|
+
|
327
|
+
headers = [
|
328
|
+
f"HTTP/{self.req.version[0]}.{self.req.version[1]} {self.status}\r\n",
|
329
|
+
f"Server: {self.version}\r\n",
|
330
|
+
f"Date: {util.http_date()}\r\n",
|
331
|
+
f"Connection: {connection}\r\n",
|
332
|
+
]
|
333
|
+
if self.chunked:
|
334
|
+
headers.append("Transfer-Encoding: chunked\r\n")
|
335
|
+
return headers
|
336
|
+
|
337
|
+
def send_headers(self) -> None:
|
338
|
+
if self.headers_sent:
|
339
|
+
return None
|
340
|
+
tosend = self.default_headers()
|
341
|
+
tosend.extend([f"{k}: {v}\r\n" for k, v in self.headers])
|
342
|
+
|
343
|
+
header_str = "{}\r\n".format("".join(tosend))
|
344
|
+
util.write(self.sock, util.to_bytestring(header_str, "latin-1"))
|
345
|
+
self.headers_sent = True
|
346
|
+
return None
|
347
|
+
|
348
|
+
def write(self, arg: bytes) -> None:
|
349
|
+
self.send_headers()
|
350
|
+
if not isinstance(arg, bytes):
|
351
|
+
raise TypeError(f"{arg!r} is not a byte")
|
352
|
+
arglen = len(arg)
|
353
|
+
tosend = arglen
|
354
|
+
if self.response_length is not None:
|
355
|
+
if self.sent >= self.response_length:
|
356
|
+
# Never write more than self.response_length bytes
|
357
|
+
return None
|
358
|
+
|
359
|
+
tosend = min(self.response_length - self.sent, tosend)
|
360
|
+
if tosend < arglen:
|
361
|
+
arg = arg[:tosend]
|
362
|
+
|
363
|
+
# Sending an empty chunk signals the end of the
|
364
|
+
# response and prematurely closes the response
|
365
|
+
if self.chunked and tosend == 0:
|
366
|
+
return None
|
367
|
+
|
368
|
+
self.sent += tosend
|
369
|
+
util.write(self.sock, arg, self.chunked)
|
370
|
+
return None
|
371
|
+
|
372
|
+
def can_sendfile(self) -> bool:
|
373
|
+
return self.cfg.sendfile is not False
|
374
|
+
|
375
|
+
def sendfile(self, respiter: FileWrapper) -> bool:
|
376
|
+
if self.cfg.is_ssl or not self.can_sendfile():
|
377
|
+
return False
|
378
|
+
|
379
|
+
if not util.has_fileno(respiter.filelike):
|
380
|
+
return False
|
381
|
+
|
382
|
+
fileno = respiter.filelike.fileno()
|
383
|
+
try:
|
384
|
+
offset = os.lseek(fileno, 0, os.SEEK_CUR)
|
385
|
+
if self.response_length is None:
|
386
|
+
filesize = os.fstat(fileno).st_size
|
387
|
+
nbytes = filesize - offset
|
388
|
+
else:
|
389
|
+
nbytes = self.response_length
|
390
|
+
except (OSError, io.UnsupportedOperation):
|
391
|
+
return False
|
392
|
+
|
393
|
+
self.send_headers()
|
394
|
+
|
395
|
+
if self.is_chunked():
|
396
|
+
chunk_size = f"{nbytes:X}\r\n"
|
397
|
+
self.sock.sendall(chunk_size.encode("utf-8"))
|
398
|
+
if nbytes > 0:
|
399
|
+
self.sock.sendfile(respiter.filelike, offset=offset, count=nbytes)
|
400
|
+
|
401
|
+
if self.is_chunked():
|
402
|
+
self.sock.sendall(b"\r\n")
|
403
|
+
|
404
|
+
os.lseek(fileno, offset, os.SEEK_SET)
|
405
|
+
|
406
|
+
return True
|
407
|
+
|
408
|
+
def write_file(self, respiter: FileWrapper | Iterator[bytes]) -> None:
|
409
|
+
if isinstance(respiter, FileWrapper):
|
410
|
+
if not self.sendfile(respiter):
|
411
|
+
for item in respiter:
|
412
|
+
self.write(item)
|
413
|
+
else:
|
414
|
+
for item in respiter:
|
415
|
+
self.write(item)
|
416
|
+
|
417
|
+
def close(self) -> None:
|
418
|
+
if not self.headers_sent:
|
419
|
+
self.send_headers()
|
420
|
+
if self.chunked:
|
421
|
+
util.write_chunk(self.sock, b"")
|
plain/server/pidfile.py
ADDED
@@ -0,0 +1,91 @@
|
|
1
|
+
#
|
2
|
+
#
|
3
|
+
# This file is part of gunicorn released under the MIT license.
|
4
|
+
# See the LICENSE for more information.
|
5
|
+
#
|
6
|
+
# Vendored and modified for Plain.
|
7
|
+
|
8
|
+
import errno
|
9
|
+
import os
|
10
|
+
import tempfile
|
11
|
+
|
12
|
+
|
13
|
+
class Pidfile:
|
14
|
+
"""\
|
15
|
+
Manage a PID file. If a specific name is provided
|
16
|
+
it and '"%s.oldpid" % name' will be used. Otherwise
|
17
|
+
we create a temp file using os.mkstemp.
|
18
|
+
"""
|
19
|
+
|
20
|
+
def __init__(self, fname: str) -> None:
|
21
|
+
self.fname = fname
|
22
|
+
self.pid: int | None = None
|
23
|
+
|
24
|
+
def create(self, pid: int) -> None:
|
25
|
+
oldpid = self.validate()
|
26
|
+
if oldpid:
|
27
|
+
if oldpid == os.getpid():
|
28
|
+
return None
|
29
|
+
msg = "Already running on PID %s (or pid file '%s' is stale)"
|
30
|
+
raise RuntimeError(msg % (oldpid, self.fname))
|
31
|
+
|
32
|
+
self.pid = pid
|
33
|
+
|
34
|
+
# Write pidfile
|
35
|
+
fdir = os.path.dirname(self.fname)
|
36
|
+
if fdir and not os.path.isdir(fdir):
|
37
|
+
raise RuntimeError(f"{fdir} doesn't exist. Can't create pidfile.")
|
38
|
+
fd, fname = tempfile.mkstemp(dir=fdir)
|
39
|
+
os.write(fd, (f"{self.pid}\n").encode())
|
40
|
+
if self.fname:
|
41
|
+
os.rename(fname, self.fname)
|
42
|
+
else:
|
43
|
+
self.fname = fname
|
44
|
+
os.close(fd)
|
45
|
+
|
46
|
+
# set permissions to -rw-r--r--
|
47
|
+
os.chmod(self.fname, 420)
|
48
|
+
return None
|
49
|
+
|
50
|
+
def rename(self, path: str) -> None:
|
51
|
+
self.unlink()
|
52
|
+
self.fname = path
|
53
|
+
self.create(self.pid) # type: ignore[arg-type]
|
54
|
+
return None
|
55
|
+
|
56
|
+
def unlink(self) -> None:
|
57
|
+
"""delete pidfile"""
|
58
|
+
try:
|
59
|
+
with open(self.fname) as f:
|
60
|
+
pid1 = int(f.read() or 0)
|
61
|
+
|
62
|
+
if pid1 == self.pid:
|
63
|
+
os.unlink(self.fname)
|
64
|
+
except Exception:
|
65
|
+
pass
|
66
|
+
return None
|
67
|
+
|
68
|
+
def validate(self) -> int | None:
|
69
|
+
"""Validate pidfile and make it stale if needed"""
|
70
|
+
if not self.fname:
|
71
|
+
return None
|
72
|
+
try:
|
73
|
+
with open(self.fname) as f:
|
74
|
+
try:
|
75
|
+
wpid = int(f.read())
|
76
|
+
except ValueError:
|
77
|
+
return None
|
78
|
+
|
79
|
+
try:
|
80
|
+
os.kill(wpid, 0)
|
81
|
+
return wpid
|
82
|
+
except OSError as e:
|
83
|
+
if e.args[0] == errno.EPERM:
|
84
|
+
return wpid
|
85
|
+
if e.args[0] == errno.ESRCH:
|
86
|
+
return None
|
87
|
+
raise
|
88
|
+
except OSError as e:
|
89
|
+
if e.args[0] == errno.ENOENT:
|
90
|
+
return None
|
91
|
+
raise
|
plain/server/reloader.py
ADDED
@@ -0,0 +1,158 @@
|
|
1
|
+
from __future__ import annotations
|
2
|
+
|
3
|
+
#
|
4
|
+
#
|
5
|
+
# This file is part of gunicorn released under the MIT license.
|
6
|
+
# See the LICENSE for more information.
|
7
|
+
#
|
8
|
+
# Vendored and modified for Plain.
|
9
|
+
import os
|
10
|
+
import os.path
|
11
|
+
import re
|
12
|
+
import sys
|
13
|
+
import threading
|
14
|
+
import time
|
15
|
+
from collections.abc import Callable, Iterable
|
16
|
+
|
17
|
+
COMPILED_EXT_RE = re.compile(r"py[co]$")
|
18
|
+
|
19
|
+
|
20
|
+
class Reloader(threading.Thread):
|
21
|
+
def __init__(
|
22
|
+
self,
|
23
|
+
extra_files: Iterable[str] | None = None,
|
24
|
+
interval: int = 1,
|
25
|
+
callback: Callable[[str], None] | None = None,
|
26
|
+
) -> None:
|
27
|
+
super().__init__()
|
28
|
+
self.daemon = True
|
29
|
+
self._extra_files: set[str] = set(extra_files or ())
|
30
|
+
self._interval = interval
|
31
|
+
self._callback = callback
|
32
|
+
|
33
|
+
def add_extra_file(self, filename: str) -> None:
|
34
|
+
self._extra_files.add(filename)
|
35
|
+
|
36
|
+
def get_files(self) -> list[str]:
|
37
|
+
fnames = [
|
38
|
+
COMPILED_EXT_RE.sub("py", module.__file__) # type: ignore[arg-type]
|
39
|
+
for module in tuple(sys.modules.values())
|
40
|
+
if getattr(module, "__file__", None)
|
41
|
+
]
|
42
|
+
|
43
|
+
fnames.extend(self._extra_files)
|
44
|
+
|
45
|
+
return fnames
|
46
|
+
|
47
|
+
def run(self) -> None:
|
48
|
+
mtimes: dict[str, float] = {}
|
49
|
+
while True:
|
50
|
+
for filename in self.get_files():
|
51
|
+
try:
|
52
|
+
mtime = os.stat(filename).st_mtime
|
53
|
+
except OSError:
|
54
|
+
continue
|
55
|
+
old_time = mtimes.get(filename)
|
56
|
+
if old_time is None:
|
57
|
+
mtimes[filename] = mtime
|
58
|
+
continue
|
59
|
+
elif mtime > old_time:
|
60
|
+
if self._callback:
|
61
|
+
self._callback(filename)
|
62
|
+
time.sleep(self._interval)
|
63
|
+
|
64
|
+
|
65
|
+
has_inotify = False
|
66
|
+
if sys.platform.startswith("linux"):
|
67
|
+
try:
|
68
|
+
import inotify.constants
|
69
|
+
from inotify.adapters import Inotify
|
70
|
+
|
71
|
+
has_inotify = True
|
72
|
+
except ImportError:
|
73
|
+
pass
|
74
|
+
|
75
|
+
|
76
|
+
if has_inotify:
|
77
|
+
|
78
|
+
class InotifyReloader(threading.Thread):
|
79
|
+
event_mask = (
|
80
|
+
inotify.constants.IN_CREATE
|
81
|
+
| inotify.constants.IN_DELETE
|
82
|
+
| inotify.constants.IN_DELETE_SELF
|
83
|
+
| inotify.constants.IN_MODIFY
|
84
|
+
| inotify.constants.IN_MOVE_SELF
|
85
|
+
| inotify.constants.IN_MOVED_FROM
|
86
|
+
| inotify.constants.IN_MOVED_TO
|
87
|
+
)
|
88
|
+
|
89
|
+
def __init__(
|
90
|
+
self,
|
91
|
+
extra_files: Iterable[str] | None = None,
|
92
|
+
callback: Callable[[str], None] | None = None,
|
93
|
+
) -> None:
|
94
|
+
super().__init__()
|
95
|
+
self.daemon = True
|
96
|
+
self._callback = callback
|
97
|
+
self._dirs: set[str] = set()
|
98
|
+
self._watcher = Inotify()
|
99
|
+
|
100
|
+
if extra_files:
|
101
|
+
for extra_file in extra_files:
|
102
|
+
self.add_extra_file(extra_file)
|
103
|
+
|
104
|
+
def add_extra_file(self, filename: str) -> None:
|
105
|
+
dirname = os.path.dirname(filename)
|
106
|
+
|
107
|
+
if dirname in self._dirs:
|
108
|
+
return None
|
109
|
+
|
110
|
+
self._watcher.add_watch(dirname, mask=self.event_mask)
|
111
|
+
self._dirs.add(dirname)
|
112
|
+
|
113
|
+
def get_dirs(self) -> set[str]:
|
114
|
+
fnames = [
|
115
|
+
os.path.dirname(
|
116
|
+
os.path.abspath(COMPILED_EXT_RE.sub("py", module.__file__)) # type: ignore[arg-type]
|
117
|
+
)
|
118
|
+
for module in tuple(sys.modules.values())
|
119
|
+
if getattr(module, "__file__", None)
|
120
|
+
]
|
121
|
+
|
122
|
+
return set(fnames)
|
123
|
+
|
124
|
+
def run(self) -> None:
|
125
|
+
self._dirs = self.get_dirs()
|
126
|
+
|
127
|
+
for dirname in self._dirs:
|
128
|
+
if os.path.isdir(dirname):
|
129
|
+
self._watcher.add_watch(dirname, mask=self.event_mask)
|
130
|
+
|
131
|
+
for event in self._watcher.event_gen(): # type: ignore[attr-defined]
|
132
|
+
if event is None:
|
133
|
+
continue
|
134
|
+
|
135
|
+
filename = event[3] # type: ignore[index]
|
136
|
+
|
137
|
+
self._callback(filename) # type: ignore[misc]
|
138
|
+
|
139
|
+
else:
|
140
|
+
|
141
|
+
class InotifyReloader:
|
142
|
+
def __init__(
|
143
|
+
self,
|
144
|
+
extra_files: Iterable[str] | None = None,
|
145
|
+
callback: Callable[[str], None] | None = None,
|
146
|
+
) -> None:
|
147
|
+
raise ImportError(
|
148
|
+
"You must have the inotify module installed to use the inotify reloader"
|
149
|
+
)
|
150
|
+
|
151
|
+
|
152
|
+
preferred_reloader = InotifyReloader if has_inotify else Reloader
|
153
|
+
|
154
|
+
reloader_engines = {
|
155
|
+
"auto": preferred_reloader,
|
156
|
+
"poll": Reloader,
|
157
|
+
"inotify": InotifyReloader,
|
158
|
+
}
|