plain 0.1.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/README.md +33 -0
- plain/__main__.py +5 -0
- plain/assets/README.md +56 -0
- plain/assets/__init__.py +6 -0
- plain/assets/finders.py +233 -0
- plain/assets/preflight.py +14 -0
- plain/assets/storage.py +916 -0
- plain/assets/utils.py +52 -0
- plain/assets/whitenoise/__init__.py +5 -0
- plain/assets/whitenoise/base.py +259 -0
- plain/assets/whitenoise/compress.py +189 -0
- plain/assets/whitenoise/media_types.py +137 -0
- plain/assets/whitenoise/middleware.py +197 -0
- plain/assets/whitenoise/responders.py +286 -0
- plain/assets/whitenoise/storage.py +178 -0
- plain/assets/whitenoise/string_utils.py +13 -0
- plain/cli/README.md +123 -0
- plain/cli/__init__.py +3 -0
- plain/cli/cli.py +439 -0
- plain/cli/formatting.py +61 -0
- plain/cli/packages.py +73 -0
- plain/cli/print.py +9 -0
- plain/cli/startup.py +33 -0
- plain/csrf/README.md +3 -0
- plain/csrf/middleware.py +466 -0
- plain/csrf/views.py +10 -0
- plain/debug.py +23 -0
- plain/exceptions.py +242 -0
- plain/forms/README.md +14 -0
- plain/forms/__init__.py +8 -0
- plain/forms/boundfield.py +58 -0
- plain/forms/exceptions.py +11 -0
- plain/forms/fields.py +1030 -0
- plain/forms/forms.py +297 -0
- plain/http/README.md +1 -0
- plain/http/__init__.py +51 -0
- plain/http/cookie.py +20 -0
- plain/http/multipartparser.py +743 -0
- plain/http/request.py +754 -0
- plain/http/response.py +719 -0
- plain/internal/__init__.py +0 -0
- plain/internal/files/README.md +3 -0
- plain/internal/files/__init__.py +3 -0
- plain/internal/files/base.py +161 -0
- plain/internal/files/locks.py +127 -0
- plain/internal/files/move.py +102 -0
- plain/internal/files/temp.py +79 -0
- plain/internal/files/uploadedfile.py +150 -0
- plain/internal/files/uploadhandler.py +254 -0
- plain/internal/files/utils.py +78 -0
- plain/internal/handlers/__init__.py +0 -0
- plain/internal/handlers/base.py +133 -0
- plain/internal/handlers/exception.py +145 -0
- plain/internal/handlers/wsgi.py +216 -0
- plain/internal/legacy/__init__.py +0 -0
- plain/internal/legacy/__main__.py +12 -0
- plain/internal/legacy/management/__init__.py +414 -0
- plain/internal/legacy/management/base.py +692 -0
- plain/internal/legacy/management/color.py +113 -0
- plain/internal/legacy/management/commands/__init__.py +0 -0
- plain/internal/legacy/management/commands/collectstatic.py +297 -0
- plain/internal/legacy/management/sql.py +67 -0
- plain/internal/legacy/management/utils.py +175 -0
- plain/json.py +40 -0
- plain/logs/README.md +24 -0
- plain/logs/__init__.py +5 -0
- plain/logs/configure.py +39 -0
- plain/logs/loggers.py +74 -0
- plain/logs/utils.py +46 -0
- plain/middleware/README.md +3 -0
- plain/middleware/__init__.py +0 -0
- plain/middleware/clickjacking.py +52 -0
- plain/middleware/common.py +87 -0
- plain/middleware/gzip.py +64 -0
- plain/middleware/security.py +64 -0
- plain/packages/README.md +41 -0
- plain/packages/__init__.py +4 -0
- plain/packages/config.py +259 -0
- plain/packages/registry.py +438 -0
- plain/paginator.py +187 -0
- plain/preflight/README.md +3 -0
- plain/preflight/__init__.py +38 -0
- plain/preflight/compatibility/__init__.py +0 -0
- plain/preflight/compatibility/django_4_0.py +20 -0
- plain/preflight/files.py +19 -0
- plain/preflight/messages.py +88 -0
- plain/preflight/registry.py +72 -0
- plain/preflight/security/__init__.py +0 -0
- plain/preflight/security/base.py +268 -0
- plain/preflight/security/csrf.py +40 -0
- plain/preflight/urls.py +117 -0
- plain/runtime/README.md +75 -0
- plain/runtime/__init__.py +61 -0
- plain/runtime/global_settings.py +199 -0
- plain/runtime/user_settings.py +353 -0
- plain/signals/README.md +14 -0
- plain/signals/__init__.py +5 -0
- plain/signals/dispatch/__init__.py +9 -0
- plain/signals/dispatch/dispatcher.py +320 -0
- plain/signals/dispatch/license.txt +35 -0
- plain/signing.py +299 -0
- plain/templates/README.md +20 -0
- plain/templates/__init__.py +6 -0
- plain/templates/core.py +24 -0
- plain/templates/jinja/README.md +227 -0
- plain/templates/jinja/__init__.py +22 -0
- plain/templates/jinja/defaults.py +119 -0
- plain/templates/jinja/extensions.py +39 -0
- plain/templates/jinja/filters.py +28 -0
- plain/templates/jinja/globals.py +19 -0
- plain/test/README.md +3 -0
- plain/test/__init__.py +16 -0
- plain/test/client.py +985 -0
- plain/test/utils.py +255 -0
- plain/urls/README.md +3 -0
- plain/urls/__init__.py +40 -0
- plain/urls/base.py +118 -0
- plain/urls/conf.py +94 -0
- plain/urls/converters.py +66 -0
- plain/urls/exceptions.py +9 -0
- plain/urls/resolvers.py +731 -0
- plain/utils/README.md +3 -0
- plain/utils/__init__.py +0 -0
- plain/utils/_os.py +52 -0
- plain/utils/cache.py +327 -0
- plain/utils/connection.py +84 -0
- plain/utils/crypto.py +76 -0
- plain/utils/datastructures.py +345 -0
- plain/utils/dateformat.py +329 -0
- plain/utils/dateparse.py +154 -0
- plain/utils/dates.py +76 -0
- plain/utils/deconstruct.py +54 -0
- plain/utils/decorators.py +90 -0
- plain/utils/deprecation.py +6 -0
- plain/utils/duration.py +44 -0
- plain/utils/email.py +12 -0
- plain/utils/encoding.py +235 -0
- plain/utils/functional.py +456 -0
- plain/utils/hashable.py +26 -0
- plain/utils/html.py +401 -0
- plain/utils/http.py +374 -0
- plain/utils/inspect.py +73 -0
- plain/utils/ipv6.py +46 -0
- plain/utils/itercompat.py +8 -0
- plain/utils/module_loading.py +69 -0
- plain/utils/regex_helper.py +353 -0
- plain/utils/safestring.py +72 -0
- plain/utils/termcolors.py +221 -0
- plain/utils/text.py +518 -0
- plain/utils/timesince.py +138 -0
- plain/utils/timezone.py +244 -0
- plain/utils/tree.py +126 -0
- plain/validators.py +603 -0
- plain/views/README.md +268 -0
- plain/views/__init__.py +18 -0
- plain/views/base.py +107 -0
- plain/views/csrf.py +24 -0
- plain/views/errors.py +25 -0
- plain/views/exceptions.py +4 -0
- plain/views/forms.py +76 -0
- plain/views/objects.py +229 -0
- plain/views/redirect.py +72 -0
- plain/views/templates.py +66 -0
- plain/wsgi.py +11 -0
- plain-0.1.0.dist-info/LICENSE +85 -0
- plain-0.1.0.dist-info/METADATA +51 -0
- plain-0.1.0.dist-info/RECORD +169 -0
- plain-0.1.0.dist-info/WHEEL +4 -0
- plain-0.1.0.dist-info/entry_points.txt +3 -0
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
plain.signals.dispatch was originally forked from PyDispatcher.
|
|
2
|
+
|
|
3
|
+
PyDispatcher License:
|
|
4
|
+
|
|
5
|
+
Copyright (c) 2001-2003, Patrick K. O'Brien and Contributors
|
|
6
|
+
All rights reserved.
|
|
7
|
+
|
|
8
|
+
Redistribution and use in source and binary forms, with or without
|
|
9
|
+
modification, are permitted provided that the following conditions
|
|
10
|
+
are met:
|
|
11
|
+
|
|
12
|
+
Redistributions of source code must retain the above copyright
|
|
13
|
+
notice, this list of conditions and the following disclaimer.
|
|
14
|
+
|
|
15
|
+
Redistributions in binary form must reproduce the above
|
|
16
|
+
copyright notice, this list of conditions and the following
|
|
17
|
+
disclaimer in the documentation and/or other materials
|
|
18
|
+
provided with the distribution.
|
|
19
|
+
|
|
20
|
+
The name of Patrick K. O'Brien, or the name of any Contributor,
|
|
21
|
+
may not be used to endorse or promote products derived from this
|
|
22
|
+
software without specific prior written permission.
|
|
23
|
+
|
|
24
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
|
|
25
|
+
``AS IS'' AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
|
|
26
|
+
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
|
|
27
|
+
FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
|
|
28
|
+
COPYRIGHT HOLDERS AND CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
|
|
29
|
+
INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
30
|
+
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
31
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
|
|
32
|
+
HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT,
|
|
33
|
+
STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
|
34
|
+
ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
|
|
35
|
+
OF THE POSSIBILITY OF SUCH DAMAGE.
|
plain/signing.py
ADDED
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Functions for creating and restoring url-safe signed JSON objects.
|
|
3
|
+
|
|
4
|
+
The format used looks like this:
|
|
5
|
+
|
|
6
|
+
>>> signing.dumps("hello")
|
|
7
|
+
'ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk'
|
|
8
|
+
|
|
9
|
+
There are two components here, separated by a ':'. The first component is a
|
|
10
|
+
URLsafe base64 encoded JSON of the object passed to dumps(). The second
|
|
11
|
+
component is a base64 encoded hmac/SHA-256 hash of "$first_component:$secret"
|
|
12
|
+
|
|
13
|
+
signing.loads(s) checks the signature and returns the deserialized object.
|
|
14
|
+
If the signature fails, a BadSignature exception is raised.
|
|
15
|
+
|
|
16
|
+
>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv422nZA4sgmk")
|
|
17
|
+
'hello'
|
|
18
|
+
>>> signing.loads("ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified")
|
|
19
|
+
...
|
|
20
|
+
BadSignature: Signature "ImhlbGxvIg:1QaUZC:YIye-ze3TTx7gtSv42-modified" does not match
|
|
21
|
+
|
|
22
|
+
You can optionally compress the JSON prior to base64 encoding it to save
|
|
23
|
+
space, using the compress=True argument. This checks if compression actually
|
|
24
|
+
helps and only applies compression if the result is a shorter string:
|
|
25
|
+
|
|
26
|
+
>>> signing.dumps(list(range(1, 20)), compress=True)
|
|
27
|
+
'.eJwFwcERACAIwLCF-rCiILN47r-GyZVJsNgkxaFxoDgxcOHGxMKD_T7vhAml:1QaUaL:BA0thEZrp4FQVXIXuOvYJtLJSrQ'
|
|
28
|
+
|
|
29
|
+
The fact that the string is compressed is signalled by the prefixed '.' at the
|
|
30
|
+
start of the base64 JSON.
|
|
31
|
+
|
|
32
|
+
There are 65 url-safe characters: the 64 used by url-safe base64 and the ':'.
|
|
33
|
+
These functions make use of all of them.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
import base64
|
|
37
|
+
import datetime
|
|
38
|
+
import json
|
|
39
|
+
import time
|
|
40
|
+
import warnings
|
|
41
|
+
import zlib
|
|
42
|
+
|
|
43
|
+
from plain.runtime import settings
|
|
44
|
+
from plain.utils.crypto import constant_time_compare, salted_hmac
|
|
45
|
+
from plain.utils.deprecation import RemovedInDjango51Warning
|
|
46
|
+
from plain.utils.encoding import force_bytes
|
|
47
|
+
from plain.utils.module_loading import import_string
|
|
48
|
+
from plain.utils.regex_helper import _lazy_re_compile
|
|
49
|
+
|
|
50
|
+
_SEP_UNSAFE = _lazy_re_compile(r"^[A-z0-9-_=]*$")
|
|
51
|
+
BASE62_ALPHABET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class BadSignature(Exception):
|
|
55
|
+
"""Signature does not match."""
|
|
56
|
+
|
|
57
|
+
pass
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
class SignatureExpired(BadSignature):
|
|
61
|
+
"""Signature timestamp is older than required max_age."""
|
|
62
|
+
|
|
63
|
+
pass
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def b62_encode(s):
|
|
67
|
+
if s == 0:
|
|
68
|
+
return "0"
|
|
69
|
+
sign = "-" if s < 0 else ""
|
|
70
|
+
s = abs(s)
|
|
71
|
+
encoded = ""
|
|
72
|
+
while s > 0:
|
|
73
|
+
s, remainder = divmod(s, 62)
|
|
74
|
+
encoded = BASE62_ALPHABET[remainder] + encoded
|
|
75
|
+
return sign + encoded
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def b62_decode(s):
|
|
79
|
+
if s == "0":
|
|
80
|
+
return 0
|
|
81
|
+
sign = 1
|
|
82
|
+
if s[0] == "-":
|
|
83
|
+
s = s[1:]
|
|
84
|
+
sign = -1
|
|
85
|
+
decoded = 0
|
|
86
|
+
for digit in s:
|
|
87
|
+
decoded = decoded * 62 + BASE62_ALPHABET.index(digit)
|
|
88
|
+
return sign * decoded
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def b64_encode(s):
|
|
92
|
+
return base64.urlsafe_b64encode(s).strip(b"=")
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def b64_decode(s):
|
|
96
|
+
pad = b"=" * (-len(s) % 4)
|
|
97
|
+
return base64.urlsafe_b64decode(s + pad)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def base64_hmac(salt, value, key, algorithm="sha1"):
|
|
101
|
+
return b64_encode(
|
|
102
|
+
salted_hmac(salt, value, key, algorithm=algorithm).digest()
|
|
103
|
+
).decode()
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _cookie_signer_key(key):
|
|
107
|
+
# SECRET_KEYS items may be str or bytes.
|
|
108
|
+
return b"plain.http.cookies" + force_bytes(key)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def get_cookie_signer(salt="plain.signing.get_cookie_signer"):
|
|
112
|
+
Signer = import_string(settings.SIGNING_BACKEND)
|
|
113
|
+
return Signer(
|
|
114
|
+
key=_cookie_signer_key(settings.SECRET_KEY),
|
|
115
|
+
fallback_keys=map(_cookie_signer_key, settings.SECRET_KEY_FALLBACKS),
|
|
116
|
+
salt=salt,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
class JSONSerializer:
|
|
121
|
+
"""
|
|
122
|
+
Simple wrapper around json to be used in signing.dumps and
|
|
123
|
+
signing.loads.
|
|
124
|
+
"""
|
|
125
|
+
|
|
126
|
+
def dumps(self, obj):
|
|
127
|
+
return json.dumps(obj, separators=(",", ":")).encode("latin-1")
|
|
128
|
+
|
|
129
|
+
def loads(self, data):
|
|
130
|
+
return json.loads(data.decode("latin-1"))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def dumps(
|
|
134
|
+
obj, key=None, salt="plain.signing", serializer=JSONSerializer, compress=False
|
|
135
|
+
):
|
|
136
|
+
"""
|
|
137
|
+
Return URL-safe, hmac signed base64 compressed JSON string. If key is
|
|
138
|
+
None, use settings.SECRET_KEY instead. The hmac algorithm is the default
|
|
139
|
+
Signer algorithm.
|
|
140
|
+
|
|
141
|
+
If compress is True (not the default), check if compressing using zlib can
|
|
142
|
+
save some space. Prepend a '.' to signify compression. This is included
|
|
143
|
+
in the signature, to protect against zip bombs.
|
|
144
|
+
|
|
145
|
+
Salt can be used to namespace the hash, so that a signed string is
|
|
146
|
+
only valid for a given namespace. Leaving this at the default
|
|
147
|
+
value or re-using a salt value across different parts of your
|
|
148
|
+
application without good cause is a security risk.
|
|
149
|
+
|
|
150
|
+
The serializer is expected to return a bytestring.
|
|
151
|
+
"""
|
|
152
|
+
return TimestampSigner(key=key, salt=salt).sign_object(
|
|
153
|
+
obj, serializer=serializer, compress=compress
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def loads(
|
|
158
|
+
s,
|
|
159
|
+
key=None,
|
|
160
|
+
salt="plain.signing",
|
|
161
|
+
serializer=JSONSerializer,
|
|
162
|
+
max_age=None,
|
|
163
|
+
fallback_keys=None,
|
|
164
|
+
):
|
|
165
|
+
"""
|
|
166
|
+
Reverse of dumps(), raise BadSignature if signature fails.
|
|
167
|
+
|
|
168
|
+
The serializer is expected to accept a bytestring.
|
|
169
|
+
"""
|
|
170
|
+
return TimestampSigner(
|
|
171
|
+
key=key, salt=salt, fallback_keys=fallback_keys
|
|
172
|
+
).unsign_object(
|
|
173
|
+
s,
|
|
174
|
+
serializer=serializer,
|
|
175
|
+
max_age=max_age,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
class Signer:
|
|
180
|
+
# RemovedInDjango51Warning: When the deprecation ends, replace with:
|
|
181
|
+
# def __init__(
|
|
182
|
+
# self, *, key=None, sep=":", salt=None, algorithm=None, fallback_keys=None
|
|
183
|
+
# ):
|
|
184
|
+
def __init__(
|
|
185
|
+
self,
|
|
186
|
+
*args,
|
|
187
|
+
key=None,
|
|
188
|
+
sep=":",
|
|
189
|
+
salt=None,
|
|
190
|
+
algorithm=None,
|
|
191
|
+
fallback_keys=None,
|
|
192
|
+
):
|
|
193
|
+
self.key = key or settings.SECRET_KEY
|
|
194
|
+
self.fallback_keys = (
|
|
195
|
+
fallback_keys
|
|
196
|
+
if fallback_keys is not None
|
|
197
|
+
else settings.SECRET_KEY_FALLBACKS
|
|
198
|
+
)
|
|
199
|
+
self.sep = sep
|
|
200
|
+
self.salt = salt or f"{self.__class__.__module__}.{self.__class__.__name__}"
|
|
201
|
+
self.algorithm = algorithm or "sha256"
|
|
202
|
+
# RemovedInDjango51Warning.
|
|
203
|
+
if args:
|
|
204
|
+
warnings.warn(
|
|
205
|
+
f"Passing positional arguments to {self.__class__.__name__} is "
|
|
206
|
+
f"deprecated.",
|
|
207
|
+
RemovedInDjango51Warning,
|
|
208
|
+
stacklevel=2,
|
|
209
|
+
)
|
|
210
|
+
for arg, attr in zip(
|
|
211
|
+
args, ["key", "sep", "salt", "algorithm", "fallback_keys"]
|
|
212
|
+
):
|
|
213
|
+
if arg or attr == "sep":
|
|
214
|
+
setattr(self, attr, arg)
|
|
215
|
+
if _SEP_UNSAFE.match(self.sep):
|
|
216
|
+
raise ValueError(
|
|
217
|
+
"Unsafe Signer separator: %r (cannot be empty or consist of "
|
|
218
|
+
"only A-z0-9-_=)" % sep,
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
def signature(self, value, key=None):
|
|
222
|
+
key = key or self.key
|
|
223
|
+
return base64_hmac(self.salt + "signer", value, key, algorithm=self.algorithm)
|
|
224
|
+
|
|
225
|
+
def sign(self, value):
|
|
226
|
+
return f"{value}{self.sep}{self.signature(value)}"
|
|
227
|
+
|
|
228
|
+
def unsign(self, signed_value):
|
|
229
|
+
if self.sep not in signed_value:
|
|
230
|
+
raise BadSignature('No "%s" found in value' % self.sep)
|
|
231
|
+
value, sig = signed_value.rsplit(self.sep, 1)
|
|
232
|
+
for key in [self.key, *self.fallback_keys]:
|
|
233
|
+
if constant_time_compare(sig, self.signature(value, key)):
|
|
234
|
+
return value
|
|
235
|
+
raise BadSignature('Signature "%s" does not match' % sig)
|
|
236
|
+
|
|
237
|
+
def sign_object(self, obj, serializer=JSONSerializer, compress=False):
|
|
238
|
+
"""
|
|
239
|
+
Return URL-safe, hmac signed base64 compressed JSON string.
|
|
240
|
+
|
|
241
|
+
If compress is True (not the default), check if compressing using zlib
|
|
242
|
+
can save some space. Prepend a '.' to signify compression. This is
|
|
243
|
+
included in the signature, to protect against zip bombs.
|
|
244
|
+
|
|
245
|
+
The serializer is expected to return a bytestring.
|
|
246
|
+
"""
|
|
247
|
+
data = serializer().dumps(obj)
|
|
248
|
+
# Flag for if it's been compressed or not.
|
|
249
|
+
is_compressed = False
|
|
250
|
+
|
|
251
|
+
if compress:
|
|
252
|
+
# Avoid zlib dependency unless compress is being used.
|
|
253
|
+
compressed = zlib.compress(data)
|
|
254
|
+
if len(compressed) < (len(data) - 1):
|
|
255
|
+
data = compressed
|
|
256
|
+
is_compressed = True
|
|
257
|
+
base64d = b64_encode(data).decode()
|
|
258
|
+
if is_compressed:
|
|
259
|
+
base64d = "." + base64d
|
|
260
|
+
return self.sign(base64d)
|
|
261
|
+
|
|
262
|
+
def unsign_object(self, signed_obj, serializer=JSONSerializer, **kwargs):
|
|
263
|
+
# Signer.unsign() returns str but base64 and zlib compression operate
|
|
264
|
+
# on bytes.
|
|
265
|
+
base64d = self.unsign(signed_obj, **kwargs).encode()
|
|
266
|
+
decompress = base64d[:1] == b"."
|
|
267
|
+
if decompress:
|
|
268
|
+
# It's compressed; uncompress it first.
|
|
269
|
+
base64d = base64d[1:]
|
|
270
|
+
data = b64_decode(base64d)
|
|
271
|
+
if decompress:
|
|
272
|
+
data = zlib.decompress(data)
|
|
273
|
+
return serializer().loads(data)
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
class TimestampSigner(Signer):
|
|
277
|
+
def timestamp(self):
|
|
278
|
+
return b62_encode(int(time.time()))
|
|
279
|
+
|
|
280
|
+
def sign(self, value):
|
|
281
|
+
value = f"{value}{self.sep}{self.timestamp()}"
|
|
282
|
+
return super().sign(value)
|
|
283
|
+
|
|
284
|
+
def unsign(self, value, max_age=None):
|
|
285
|
+
"""
|
|
286
|
+
Retrieve original value and check it wasn't signed more
|
|
287
|
+
than max_age seconds ago.
|
|
288
|
+
"""
|
|
289
|
+
result = super().unsign(value)
|
|
290
|
+
value, timestamp = result.rsplit(self.sep, 1)
|
|
291
|
+
timestamp = b62_decode(timestamp)
|
|
292
|
+
if max_age is not None:
|
|
293
|
+
if isinstance(max_age, datetime.timedelta):
|
|
294
|
+
max_age = max_age.total_seconds()
|
|
295
|
+
# Check timestamp is not older than max_age
|
|
296
|
+
age = time.time() - timestamp
|
|
297
|
+
if age > max_age:
|
|
298
|
+
raise SignatureExpired(f"Signature age {age} > {max_age} seconds")
|
|
299
|
+
return value
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Templates
|
|
2
|
+
|
|
3
|
+
Render HTML templates using Jinja.
|
|
4
|
+
|
|
5
|
+
Templates are typically rendered in `TemplateViews`,
|
|
6
|
+
but you can also render them directly to strings for emails or other use cases.
|
|
7
|
+
|
|
8
|
+
```python
|
|
9
|
+
from plain.templates import Template
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
Template("comment.md").render({
|
|
13
|
+
"message": "Hello, world!",
|
|
14
|
+
})
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Template files can be located in either a root `app/templates`,
|
|
18
|
+
or the `templates` directory in any installed packages.
|
|
19
|
+
|
|
20
|
+
[Customizing Jinja](./jinja/README.md)
|
plain/templates/core.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import jinja2
|
|
2
|
+
|
|
3
|
+
from .jinja import environment
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class TemplateFileMissing(Exception):
|
|
7
|
+
def __str__(self) -> str:
|
|
8
|
+
if self.args:
|
|
9
|
+
return f"Template file {self.args[0]} not found"
|
|
10
|
+
else:
|
|
11
|
+
return "Template file not found"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Template:
|
|
15
|
+
def __init__(self, filename: str) -> None:
|
|
16
|
+
self.filename = filename
|
|
17
|
+
|
|
18
|
+
try:
|
|
19
|
+
self._jinja_template = environment.get_template(filename)
|
|
20
|
+
except jinja2.TemplateNotFound:
|
|
21
|
+
raise TemplateFileMissing(filename)
|
|
22
|
+
|
|
23
|
+
def render(self, context: dict) -> str:
|
|
24
|
+
return self._jinja_template.render(context)
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# Jinja
|
|
2
|
+
|
|
3
|
+
Templates can be stored inside `INSTALLED_PACKAGES` (ex. `app/<pkg>/templates`) or in the `templates` directory at the root of your project (ex. `app/templates`).
|
|
4
|
+
|
|
5
|
+
### App templates
|
|
6
|
+
|
|
7
|
+
You `app/templates` will typically have things like `base.html`,
|
|
8
|
+
which the whole app depends on.
|
|
9
|
+
|
|
10
|
+
### Package templates
|
|
11
|
+
|
|
12
|
+
Since all template directories are effectively "merged" together,
|
|
13
|
+
packages will typically namespace their own templates such as `app/users/templates/users/delete.html`.
|
|
14
|
+
|
|
15
|
+
## Jinja
|
|
16
|
+
|
|
17
|
+
There is a default set of globals, filters, and extensions.
|
|
18
|
+
|
|
19
|
+
## Default globals
|
|
20
|
+
|
|
21
|
+
- `static` - a function that returns the URL for a static file
|
|
22
|
+
- `url` - a function that returns the URL for a view
|
|
23
|
+
- `Paginator` - the Plain Paginator class
|
|
24
|
+
|
|
25
|
+
## Default filters
|
|
26
|
+
|
|
27
|
+
- `strftime` - `datetime.datetime.strftime`
|
|
28
|
+
- `strptime` - `datetime.datetime.strptime`
|
|
29
|
+
- `localtime` - convert a datetime to the activated time zone (or a given time zone)
|
|
30
|
+
- `timeuntil` - human readable time until a date
|
|
31
|
+
- `timesince` - human readable time since a date
|
|
32
|
+
- `json_script` - serialize a value as JSON and wrap it in a `<script>` tag
|
|
33
|
+
- `islice` - `itertools.islice` which is a slice for `dict.items()`
|
|
34
|
+
|
|
35
|
+
## Default extensions
|
|
36
|
+
|
|
37
|
+
- `jinja2.ext.debug`
|
|
38
|
+
- `jinja2.ext.loopcontrols`
|
|
39
|
+
|
|
40
|
+
## Default request context
|
|
41
|
+
|
|
42
|
+
Each request is rendered with a `context`.
|
|
43
|
+
This will include the [default globals](#default-globals),
|
|
44
|
+
any app or project globals,
|
|
45
|
+
as well as the `get_template_context()` from your view.
|
|
46
|
+
|
|
47
|
+
When a view is rendered,
|
|
48
|
+
the default context includes the `request` itself,
|
|
49
|
+
as well as a `csrf_input` (and `csrf_token`) to be used in forms.
|
|
50
|
+
|
|
51
|
+
## Extending with a root `jinja.py`
|
|
52
|
+
|
|
53
|
+
You can customize the Jinja environment by adding a `jinja.py` module to your app root.
|
|
54
|
+
This works just like [extending with packages](#extending-with-packages),
|
|
55
|
+
where you can define `filters`, `globals`, and `extensions`.
|
|
56
|
+
|
|
57
|
+
## Extending with Packages
|
|
58
|
+
|
|
59
|
+
Any of your `INSTALLED_PACKAGES` can customize Jinja by adding a `jinja.py` module.
|
|
60
|
+
|
|
61
|
+
To put it simply, the three things you can "export" from this module are:
|
|
62
|
+
- `filters` - functions that can be used in templates
|
|
63
|
+
- `globals` - variables that can be used in templates
|
|
64
|
+
- `extensions` - a list of custom Jinja extensions to install
|
|
65
|
+
|
|
66
|
+
All you need to do is define a variable with the correct name:
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
# <appname>/jinja.py
|
|
70
|
+
from jinja2 import Extension
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def add_exclamation(obj):
|
|
74
|
+
return obj + "!"
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
class MyExtension(Extension):
|
|
78
|
+
# ...
|
|
79
|
+
pass
|
|
80
|
+
|
|
81
|
+
# Filters are used on a variable with a pipe,
|
|
82
|
+
# like {{ variable|add_exclamation }}.
|
|
83
|
+
# You can use these to add new features to all variables.
|
|
84
|
+
filters = {
|
|
85
|
+
"add_exclamation": add_exclamation,
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
# Globals are essentially variables that are available in all templates.
|
|
89
|
+
# But you can also include functions here, which can be called in the template like {{ a_callable_global() }}.
|
|
90
|
+
globals = {
|
|
91
|
+
"my_global": "my global value",
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
# Extensions are classes that can add new features to the Jinja environment.
|
|
95
|
+
extensions = [
|
|
96
|
+
MyExtension,
|
|
97
|
+
]
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
TODO - when to use a global function vs a filter
|
|
101
|
+
|
|
102
|
+
## Time zone aware output
|
|
103
|
+
|
|
104
|
+
TODO - example middleware, `|localtime` filter
|
|
105
|
+
|
|
106
|
+
## Settings
|
|
107
|
+
|
|
108
|
+
Most Jinja customization happens in `jinja.py` at the root of your app or in `INSTALLED_PACKAGES`.
|
|
109
|
+
But if you need to further customize the environment,
|
|
110
|
+
you can define your own callable which will be used to create the Jinja environment.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
# app/settings.py
|
|
114
|
+
JINJA_ENVIRONMENT = "myjinja.create_environment"
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# app/myjinja.py
|
|
119
|
+
from jinja2 import Environment
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def create_environment():
|
|
123
|
+
return Environment(
|
|
124
|
+
# ...
|
|
125
|
+
)
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
## HTML Components
|
|
129
|
+
|
|
130
|
+
- `{% include %}` shorthand
|
|
131
|
+
- strictly html, no python classes to back them
|
|
132
|
+
- react-inspired syntax
|
|
133
|
+
- react components in the future? live-wire style? script tags?
|
|
134
|
+
|
|
135
|
+
## Background
|
|
136
|
+
|
|
137
|
+
Django has two options for template languages: the [Django template language (DTL)](https://docs.djangoproject.com/en/4.2/topics/templates/) and [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/).
|
|
138
|
+
|
|
139
|
+
I'm not an expert on the history here,
|
|
140
|
+
but my understanding is that the DTL inspired Jinja,
|
|
141
|
+
and my guess is that Jinja then became so popular that they added support for it back to Django.
|
|
142
|
+
And now there are two options.
|
|
143
|
+
|
|
144
|
+
The two are pretty similar,
|
|
145
|
+
but one of the biggest differences that I care about is the fact that in Django templates,
|
|
146
|
+
you don't use `()` to call functions.
|
|
147
|
+
Which means that you can't call any functions that take arguments.
|
|
148
|
+
|
|
149
|
+
Sometimes, this can lead you to create better, simpler templates.
|
|
150
|
+
But other times,
|
|
151
|
+
it just forces you to find silly workarounds like creating custom template tags/filters or additional model methods,
|
|
152
|
+
just because you can't pass an argument in your template.
|
|
153
|
+
|
|
154
|
+
```python
|
|
155
|
+
class MyModel(models.Model):
|
|
156
|
+
def foo(self, bar=None):
|
|
157
|
+
return f"foo {bar}"
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
```html
|
|
161
|
+
<!-- django-template.html -->
|
|
162
|
+
{{ my_model.foo }}
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
This is not a problem in Jinja.
|
|
166
|
+
|
|
167
|
+
```html
|
|
168
|
+
<!-- jinja-template.html -->
|
|
169
|
+
{{ my_model.foo(bar) }}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
It's also weird to explain to new users of Django/Python that you "use the same `foo()` method, but leave off the `()`, which would not do what you want in a Python shell or anywhere else..."
|
|
173
|
+
|
|
174
|
+
And even as someone who has understood how this works for a long time,
|
|
175
|
+
it's still really annoying when you want to search across your entire project for usage of a specific method,
|
|
176
|
+
but you can't simply search for `.foo(` because it won't pick it up in the template code.
|
|
177
|
+
|
|
178
|
+
Plain simply removes the Django Template Language (DTL) and only supports Jinja templates.
|
|
179
|
+
Nobody uses Django templates outside of Django, but people encounter Jinja in lots of other tools.
|
|
180
|
+
I think focusing on Jinja is a better move as an independent templating ecosystem.
|
|
181
|
+
This is also a change that I doubt Django would ever be able to make,
|
|
182
|
+
even if they wanted to.
|
|
183
|
+
|
|
184
|
+
https://github.com/mitsuhiko/minijinja
|
|
185
|
+
|
|
186
|
+
- request.user, not user
|
|
187
|
+
- jinja error if you try to render a callable?
|
|
188
|
+
- manager method example?
|
|
189
|
+
|
|
190
|
+
### `request.user` vs `user`
|
|
191
|
+
|
|
192
|
+
Django auth does this neat thing where it automatically [puts a `"user"` in your template context](https://github.com/django/django/blob/42b4f81e6efd5c4587e1207a2ae3dd0facb1436f/django/contrib/auth/context_processors.py#L65),
|
|
193
|
+
in addition to `request.user`.
|
|
194
|
+
|
|
195
|
+
I've seen this cause confusion/conflicts for templates that are trying to render a specific user...
|
|
196
|
+
Plain doesn't do this -- if you want the current logged in user, use `request.user`.
|
|
197
|
+
|
|
198
|
+
```html
|
|
199
|
+
{% if request.user.is_authenticated %}
|
|
200
|
+
|
|
201
|
+
{% endif %}
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
### StrictUndefined
|
|
205
|
+
|
|
206
|
+
Another feature of Django templates is that,
|
|
207
|
+
by default,
|
|
208
|
+
you don't get any kind of error if you try to render a variable that doesn't exist.
|
|
209
|
+
|
|
210
|
+
This can be handy for simple kinds of template logic,
|
|
211
|
+
but it can also be the source of some pretty big rendering bugs.
|
|
212
|
+
|
|
213
|
+
Plain runs Jinja with `strict_undefined=True` by default,
|
|
214
|
+
so you get a big, loud error if you try to render a variable that doesn't exist.
|
|
215
|
+
|
|
216
|
+
You can use the `|default()` filter to provide a default value if you want to allow a variable to be undefined,
|
|
217
|
+
or check `{% if variable is defined %}` if you want to conditionally render something.
|
|
218
|
+
|
|
219
|
+
```html
|
|
220
|
+
{{ variable|default('default value') }}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
```html
|
|
224
|
+
{% if variable is defined %}
|
|
225
|
+
{{ variable }}
|
|
226
|
+
{% endif %}
|
|
227
|
+
```
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from plain.runtime import settings
|
|
2
|
+
from plain.utils.functional import LazyObject
|
|
3
|
+
from plain.utils.module_loading import import_string
|
|
4
|
+
|
|
5
|
+
from .defaults import create_default_environment, get_template_dirs
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class JinjaEnvironment(LazyObject):
|
|
9
|
+
def _setup(self):
|
|
10
|
+
environment_setting = settings.JINJA_ENVIRONMENT
|
|
11
|
+
|
|
12
|
+
if isinstance(environment_setting, str):
|
|
13
|
+
environment = import_string(environment_setting)()
|
|
14
|
+
else:
|
|
15
|
+
environment = environment_setting()
|
|
16
|
+
|
|
17
|
+
self._wrapped = environment
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
environment = JinjaEnvironment()
|
|
21
|
+
|
|
22
|
+
__all__ = ["environment", "create_default_environment", "get_template_dirs"]
|