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.
Files changed (169) hide show
  1. plain/README.md +33 -0
  2. plain/__main__.py +5 -0
  3. plain/assets/README.md +56 -0
  4. plain/assets/__init__.py +6 -0
  5. plain/assets/finders.py +233 -0
  6. plain/assets/preflight.py +14 -0
  7. plain/assets/storage.py +916 -0
  8. plain/assets/utils.py +52 -0
  9. plain/assets/whitenoise/__init__.py +5 -0
  10. plain/assets/whitenoise/base.py +259 -0
  11. plain/assets/whitenoise/compress.py +189 -0
  12. plain/assets/whitenoise/media_types.py +137 -0
  13. plain/assets/whitenoise/middleware.py +197 -0
  14. plain/assets/whitenoise/responders.py +286 -0
  15. plain/assets/whitenoise/storage.py +178 -0
  16. plain/assets/whitenoise/string_utils.py +13 -0
  17. plain/cli/README.md +123 -0
  18. plain/cli/__init__.py +3 -0
  19. plain/cli/cli.py +439 -0
  20. plain/cli/formatting.py +61 -0
  21. plain/cli/packages.py +73 -0
  22. plain/cli/print.py +9 -0
  23. plain/cli/startup.py +33 -0
  24. plain/csrf/README.md +3 -0
  25. plain/csrf/middleware.py +466 -0
  26. plain/csrf/views.py +10 -0
  27. plain/debug.py +23 -0
  28. plain/exceptions.py +242 -0
  29. plain/forms/README.md +14 -0
  30. plain/forms/__init__.py +8 -0
  31. plain/forms/boundfield.py +58 -0
  32. plain/forms/exceptions.py +11 -0
  33. plain/forms/fields.py +1030 -0
  34. plain/forms/forms.py +297 -0
  35. plain/http/README.md +1 -0
  36. plain/http/__init__.py +51 -0
  37. plain/http/cookie.py +20 -0
  38. plain/http/multipartparser.py +743 -0
  39. plain/http/request.py +754 -0
  40. plain/http/response.py +719 -0
  41. plain/internal/__init__.py +0 -0
  42. plain/internal/files/README.md +3 -0
  43. plain/internal/files/__init__.py +3 -0
  44. plain/internal/files/base.py +161 -0
  45. plain/internal/files/locks.py +127 -0
  46. plain/internal/files/move.py +102 -0
  47. plain/internal/files/temp.py +79 -0
  48. plain/internal/files/uploadedfile.py +150 -0
  49. plain/internal/files/uploadhandler.py +254 -0
  50. plain/internal/files/utils.py +78 -0
  51. plain/internal/handlers/__init__.py +0 -0
  52. plain/internal/handlers/base.py +133 -0
  53. plain/internal/handlers/exception.py +145 -0
  54. plain/internal/handlers/wsgi.py +216 -0
  55. plain/internal/legacy/__init__.py +0 -0
  56. plain/internal/legacy/__main__.py +12 -0
  57. plain/internal/legacy/management/__init__.py +414 -0
  58. plain/internal/legacy/management/base.py +692 -0
  59. plain/internal/legacy/management/color.py +113 -0
  60. plain/internal/legacy/management/commands/__init__.py +0 -0
  61. plain/internal/legacy/management/commands/collectstatic.py +297 -0
  62. plain/internal/legacy/management/sql.py +67 -0
  63. plain/internal/legacy/management/utils.py +175 -0
  64. plain/json.py +40 -0
  65. plain/logs/README.md +24 -0
  66. plain/logs/__init__.py +5 -0
  67. plain/logs/configure.py +39 -0
  68. plain/logs/loggers.py +74 -0
  69. plain/logs/utils.py +46 -0
  70. plain/middleware/README.md +3 -0
  71. plain/middleware/__init__.py +0 -0
  72. plain/middleware/clickjacking.py +52 -0
  73. plain/middleware/common.py +87 -0
  74. plain/middleware/gzip.py +64 -0
  75. plain/middleware/security.py +64 -0
  76. plain/packages/README.md +41 -0
  77. plain/packages/__init__.py +4 -0
  78. plain/packages/config.py +259 -0
  79. plain/packages/registry.py +438 -0
  80. plain/paginator.py +187 -0
  81. plain/preflight/README.md +3 -0
  82. plain/preflight/__init__.py +38 -0
  83. plain/preflight/compatibility/__init__.py +0 -0
  84. plain/preflight/compatibility/django_4_0.py +20 -0
  85. plain/preflight/files.py +19 -0
  86. plain/preflight/messages.py +88 -0
  87. plain/preflight/registry.py +72 -0
  88. plain/preflight/security/__init__.py +0 -0
  89. plain/preflight/security/base.py +268 -0
  90. plain/preflight/security/csrf.py +40 -0
  91. plain/preflight/urls.py +117 -0
  92. plain/runtime/README.md +75 -0
  93. plain/runtime/__init__.py +61 -0
  94. plain/runtime/global_settings.py +199 -0
  95. plain/runtime/user_settings.py +353 -0
  96. plain/signals/README.md +14 -0
  97. plain/signals/__init__.py +5 -0
  98. plain/signals/dispatch/__init__.py +9 -0
  99. plain/signals/dispatch/dispatcher.py +320 -0
  100. plain/signals/dispatch/license.txt +35 -0
  101. plain/signing.py +299 -0
  102. plain/templates/README.md +20 -0
  103. plain/templates/__init__.py +6 -0
  104. plain/templates/core.py +24 -0
  105. plain/templates/jinja/README.md +227 -0
  106. plain/templates/jinja/__init__.py +22 -0
  107. plain/templates/jinja/defaults.py +119 -0
  108. plain/templates/jinja/extensions.py +39 -0
  109. plain/templates/jinja/filters.py +28 -0
  110. plain/templates/jinja/globals.py +19 -0
  111. plain/test/README.md +3 -0
  112. plain/test/__init__.py +16 -0
  113. plain/test/client.py +985 -0
  114. plain/test/utils.py +255 -0
  115. plain/urls/README.md +3 -0
  116. plain/urls/__init__.py +40 -0
  117. plain/urls/base.py +118 -0
  118. plain/urls/conf.py +94 -0
  119. plain/urls/converters.py +66 -0
  120. plain/urls/exceptions.py +9 -0
  121. plain/urls/resolvers.py +731 -0
  122. plain/utils/README.md +3 -0
  123. plain/utils/__init__.py +0 -0
  124. plain/utils/_os.py +52 -0
  125. plain/utils/cache.py +327 -0
  126. plain/utils/connection.py +84 -0
  127. plain/utils/crypto.py +76 -0
  128. plain/utils/datastructures.py +345 -0
  129. plain/utils/dateformat.py +329 -0
  130. plain/utils/dateparse.py +154 -0
  131. plain/utils/dates.py +76 -0
  132. plain/utils/deconstruct.py +54 -0
  133. plain/utils/decorators.py +90 -0
  134. plain/utils/deprecation.py +6 -0
  135. plain/utils/duration.py +44 -0
  136. plain/utils/email.py +12 -0
  137. plain/utils/encoding.py +235 -0
  138. plain/utils/functional.py +456 -0
  139. plain/utils/hashable.py +26 -0
  140. plain/utils/html.py +401 -0
  141. plain/utils/http.py +374 -0
  142. plain/utils/inspect.py +73 -0
  143. plain/utils/ipv6.py +46 -0
  144. plain/utils/itercompat.py +8 -0
  145. plain/utils/module_loading.py +69 -0
  146. plain/utils/regex_helper.py +353 -0
  147. plain/utils/safestring.py +72 -0
  148. plain/utils/termcolors.py +221 -0
  149. plain/utils/text.py +518 -0
  150. plain/utils/timesince.py +138 -0
  151. plain/utils/timezone.py +244 -0
  152. plain/utils/tree.py +126 -0
  153. plain/validators.py +603 -0
  154. plain/views/README.md +268 -0
  155. plain/views/__init__.py +18 -0
  156. plain/views/base.py +107 -0
  157. plain/views/csrf.py +24 -0
  158. plain/views/errors.py +25 -0
  159. plain/views/exceptions.py +4 -0
  160. plain/views/forms.py +76 -0
  161. plain/views/objects.py +229 -0
  162. plain/views/redirect.py +72 -0
  163. plain/views/templates.py +66 -0
  164. plain/wsgi.py +11 -0
  165. plain-0.1.0.dist-info/LICENSE +85 -0
  166. plain-0.1.0.dist-info/METADATA +51 -0
  167. plain-0.1.0.dist-info/RECORD +169 -0
  168. plain-0.1.0.dist-info/WHEEL +4 -0
  169. 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)
@@ -0,0 +1,6 @@
1
+ from .core import Template, TemplateFileMissing
2
+
3
+ __all__ = [
4
+ "Template",
5
+ "TemplateFileMissing",
6
+ ]
@@ -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"]