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.
@@ -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"")
@@ -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
@@ -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
+ }