plain 0.1.2__py3-none-any.whl → 0.2.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 (38) hide show
  1. plain/assets/README.md +18 -37
  2. plain/assets/__init__.py +0 -6
  3. plain/assets/compile.py +111 -0
  4. plain/assets/finders.py +26 -218
  5. plain/assets/fingerprints.py +38 -0
  6. plain/assets/urls.py +31 -0
  7. plain/assets/views.py +263 -0
  8. plain/cli/cli.py +68 -5
  9. plain/packages/config.py +5 -5
  10. plain/packages/registry.py +1 -7
  11. plain/preflight/urls.py +0 -10
  12. plain/runtime/README.md +0 -1
  13. plain/runtime/global_settings.py +7 -14
  14. plain/runtime/user_settings.py +0 -49
  15. plain/templates/jinja/globals.py +1 -1
  16. plain/test/__init__.py +0 -8
  17. plain/test/client.py +36 -16
  18. plain/views/base.py +5 -3
  19. plain/views/errors.py +7 -0
  20. {plain-0.1.2.dist-info → plain-0.2.0.dist-info}/LICENSE +0 -24
  21. {plain-0.1.2.dist-info → plain-0.2.0.dist-info}/METADATA +1 -1
  22. {plain-0.1.2.dist-info → plain-0.2.0.dist-info}/RECORD +24 -34
  23. plain/assets/preflight.py +0 -14
  24. plain/assets/storage.py +0 -916
  25. plain/assets/utils.py +0 -52
  26. plain/assets/whitenoise/__init__.py +0 -5
  27. plain/assets/whitenoise/base.py +0 -259
  28. plain/assets/whitenoise/compress.py +0 -189
  29. plain/assets/whitenoise/media_types.py +0 -137
  30. plain/assets/whitenoise/middleware.py +0 -197
  31. plain/assets/whitenoise/responders.py +0 -286
  32. plain/assets/whitenoise/storage.py +0 -178
  33. plain/assets/whitenoise/string_utils.py +0 -13
  34. plain/internal/legacy/management/commands/__init__.py +0 -0
  35. plain/internal/legacy/management/commands/collectstatic.py +0 -297
  36. plain/test/utils.py +0 -255
  37. {plain-0.1.2.dist-info → plain-0.2.0.dist-info}/WHEEL +0 -0
  38. {plain-0.1.2.dist-info → plain-0.2.0.dist-info}/entry_points.txt +0 -0
plain/assets/views.py ADDED
@@ -0,0 +1,263 @@
1
+ import functools
2
+ import os
3
+ from email.utils import formatdate, parsedate
4
+ from io import BytesIO
5
+
6
+ from plain.http import (
7
+ FileResponse,
8
+ Http404,
9
+ Response,
10
+ ResponseNotModified,
11
+ ResponseRedirect,
12
+ StreamingResponse,
13
+ )
14
+ from plain.runtime import settings
15
+ from plain.urls import reverse
16
+ from plain.views import View
17
+
18
+ from .compile import FINGERPRINT_LENGTH
19
+ from .finders import find_assets
20
+ from .fingerprints import get_fingerprinted_url_path
21
+
22
+
23
+ class AssetView(View):
24
+ """
25
+ Serve an asset file directly.
26
+
27
+ This class could be subclassed to further tweak the responses or behavior.
28
+ """
29
+
30
+ def get(self):
31
+ url_path = self.url_kwargs["path"]
32
+
33
+ # Make a trailing slash work, but we don't expect it
34
+ url_path = url_path.rstrip("/")
35
+
36
+ if settings.DEBUG and False:
37
+ absolute_path = self.get_debug_asset_path(url_path)
38
+ else:
39
+ absolute_path = self.get_asset_path(url_path)
40
+
41
+ if settings.ASSETS_REDIRECT_ORIGINAL:
42
+ if redirect_response := self.get_redirect_response(url_path):
43
+ return redirect_response
44
+
45
+ self.check_asset_path(absolute_path)
46
+
47
+ if encoded_path := self.get_encoded_path(absolute_path):
48
+ absolute_path = encoded_path
49
+
50
+ if range_response := self.get_range_response(absolute_path):
51
+ return range_response
52
+
53
+ if not_modified_response := self.get_conditional_response(absolute_path):
54
+ return not_modified_response
55
+
56
+ response = FileResponse(
57
+ open(absolute_path, "rb"),
58
+ filename=os.path.basename(absolute_path), # Used for Content-Type
59
+ )
60
+ response.headers = self.update_headers(response.headers, absolute_path)
61
+ return response
62
+
63
+ def get_asset_path(self, path):
64
+ """Get the path to the compiled asset"""
65
+ compiled_path = os.path.abspath(settings.ASSETS_COMPILED_PATH)
66
+ asset_path = os.path.join(compiled_path, path)
67
+
68
+ # Make sure we don't try to escape the compiled assests path
69
+ if not os.path.commonpath([compiled_path, asset_path]) == compiled_path:
70
+ raise Http404("Asset not found")
71
+
72
+ return asset_path
73
+
74
+ def get_debug_asset_path(self, path):
75
+ """Make a "live" check to find the uncompiled asset in the filesystem"""
76
+ if asset := find_assets().get(path):
77
+ return asset.absolute_path
78
+
79
+ def check_asset_path(self, path):
80
+ if not path:
81
+ raise Http404("Asset not found")
82
+
83
+ if not os.path.exists(path):
84
+ raise Http404("Asset not found")
85
+
86
+ if os.path.isdir(path):
87
+ raise Http404("Asset is a directory")
88
+
89
+ @functools.cache
90
+ def get_last_modified(self, path):
91
+ try:
92
+ mtime = os.path.getmtime(path)
93
+ except OSError:
94
+ mtime = None
95
+
96
+ if mtime:
97
+ return formatdate(mtime, usegmt=True)
98
+
99
+ @functools.cache
100
+ def get_etag(self, path):
101
+ try:
102
+ mtime = os.path.getmtime(path)
103
+ except OSError:
104
+ mtime = None
105
+
106
+ timestamp = int(mtime)
107
+ size = self.get_size(path)
108
+ return f'"{timestamp:x}-{size:x}"'
109
+
110
+ @functools.cache
111
+ def get_size(self, path):
112
+ return os.path.getsize(path)
113
+
114
+ def update_headers(self, headers, path):
115
+ headers.setdefault("Access-Control-Allow-Origin", "*")
116
+
117
+ # Always vary on Accept-Encoding
118
+ vary = headers.get("Vary")
119
+ if not vary:
120
+ headers["Vary"] = "Accept-Encoding"
121
+ elif vary == "*":
122
+ pass
123
+ elif "Accept-Encoding" not in vary:
124
+ headers["Vary"] = vary + ", Accept-Encoding"
125
+
126
+ # If the file is compressed, tell the browser
127
+ if path.endswith(".gz"):
128
+ headers.setdefault("Content-Encoding", "gzip")
129
+ elif path.endswith(".br"):
130
+ headers.setdefault("Content-Encoding", "br")
131
+
132
+ is_immutable = self.is_immutable(path)
133
+
134
+ if is_immutable:
135
+ max_age = 10 * 365 * 24 * 60 * 60 # 10 years
136
+ headers.setdefault("Cache-Control", f"max-age={max_age}, immutable")
137
+ elif settings.DEBUG:
138
+ # In development, cache for 1 second to avoid re-fetching the same file
139
+ headers.setdefault("Cache-Control", "max-age=0")
140
+ else:
141
+ # Tell the browser to cache the file for 60 seconds if nothing else
142
+ headers.setdefault("Cache-Control", "max-age=60")
143
+
144
+ if not is_immutable:
145
+ if last_modified := self.get_last_modified(path):
146
+ headers.setdefault("Last-Modified", last_modified)
147
+ if etag := self.get_etag(path):
148
+ headers.setdefault("ETag", etag)
149
+
150
+ return headers
151
+
152
+ def is_immutable(self, path):
153
+ """
154
+ Determine whether an asset looks like it is immutable.
155
+
156
+ Pattern matching based on fingerprinted filenames:
157
+ - main.{fingerprint}.css
158
+ - main.{fingerprint}.css.gz
159
+ """
160
+ base = os.path.basename(path)
161
+ extension = None
162
+ while extension != "":
163
+ base, extension = os.path.splitext(base)
164
+ if len(extension) == FINGERPRINT_LENGTH + 1 and extension[1:].isalnum():
165
+ return True
166
+
167
+ return False
168
+
169
+ def get_encoded_path(self, path):
170
+ """
171
+ If the client supports compression, return the path to the compressed file.
172
+ Otherwise, return the original path.
173
+ """
174
+ accept_encoding = self.request.headers.get("Accept-Encoding")
175
+ if not accept_encoding:
176
+ return
177
+
178
+ if "br" in accept_encoding:
179
+ br_path = path + ".br"
180
+ if os.path.exists(br_path):
181
+ return br_path
182
+
183
+ if "gzip" in accept_encoding:
184
+ gzip_path = path + ".gz"
185
+ if os.path.exists(gzip_path):
186
+ return gzip_path
187
+
188
+ def get_redirect_response(self, path):
189
+ """If the asset is not found, try to redirect to the fingerprinted path"""
190
+ fingerprinted_url_path = get_fingerprinted_url_path(path)
191
+
192
+ if not fingerprinted_url_path or fingerprinted_url_path == path:
193
+ # Don't need to redirect if there is no fingerprinted path,
194
+ # or we're already looking at it.
195
+ return
196
+
197
+ from .urls import default_namespace
198
+
199
+ return ResponseRedirect(
200
+ redirect_to=reverse(
201
+ f"{default_namespace}:asset", args=[fingerprinted_url_path]
202
+ ),
203
+ headers={
204
+ "Cache-Control": "max-age=60", # Can cache this for a short time, but the fingerprinted path can change
205
+ },
206
+ )
207
+
208
+ def get_conditional_response(self, path):
209
+ """
210
+ Support conditional requests (HTTP 304 response) based on ETag and Last-Modified headers.
211
+ """
212
+ if self.request.headers.get("If-None-Match") == self.get_etag(path):
213
+ response = ResponseNotModified()
214
+ response.headers = self.update_headers(response.headers, path)
215
+ return response
216
+
217
+ if "If-Modified-Since" in self.request.headers:
218
+ if_modified_since = parsedate(self.request.headers["If-Modified-Since"])
219
+ last_modified = parsedate(self.get_last_modified(path))
220
+ if (
221
+ if_modified_since
222
+ and last_modified
223
+ and if_modified_since >= last_modified
224
+ ):
225
+ response = ResponseNotModified()
226
+ response.headers = self.update_headers(response.headers, path)
227
+ return response
228
+
229
+ def get_range_response(self, path):
230
+ """
231
+ Support range requests (HTTP 206 response).
232
+ """
233
+ range_header = self.request.headers.get("HTTP_RANGE")
234
+ if not range_header:
235
+ return None
236
+
237
+ file_size = self.get_size(path)
238
+
239
+ if not range_header.startswith("bytes="):
240
+ return Response(
241
+ status=416, headers=[("Content-Range", f"bytes */{file_size}")]
242
+ )
243
+
244
+ range_values = range_header.split("=")[1].split("-")
245
+ start = int(range_values[0]) if range_values[0] else 0
246
+ end = int(range_values[1]) if range_values[1] else float("inf")
247
+
248
+ if start >= file_size:
249
+ return Response(
250
+ status=416, headers=[("Content-Range", f"bytes */{file_size}")]
251
+ )
252
+
253
+ end = min(end, file_size - 1)
254
+
255
+ with open(path, "rb") as f:
256
+ f.seek(start)
257
+ content = f.read(end - start + 1)
258
+
259
+ response = StreamingResponse(BytesIO(content), status=206)
260
+ response.headers = self.update_headers(response.headers, path)
261
+ response.headers["Content-Range"] = f"bytes {start}-{end}/{file_size}"
262
+ response.headers["Content-Length"] = str(end - start + 1)
263
+ return response
plain/cli/cli.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import importlib
2
2
  import json
3
3
  import os
4
+ import shutil
4
5
  import subprocess
5
6
  import sys
6
7
  import traceback
@@ -12,7 +13,9 @@ from click.core import Command, Context
12
13
 
13
14
  import plain.runtime
14
15
  from plain import preflight
16
+ from plain.assets.compile import compile_assets
15
17
  from plain.packages import packages
18
+ from plain.runtime import settings
16
19
 
17
20
  from .formatting import PlainContext
18
21
  from .packages import EntryPointGroup, InstalledPackagesGroup
@@ -270,16 +273,44 @@ def preflight_checks(package_label, deploy, fail_level, databases):
270
273
 
271
274
 
272
275
  @plain_cli.command()
273
- @click.pass_context
274
- def compile(ctx):
276
+ @click.option(
277
+ "--keep-original/--no-keep-original",
278
+ "keep_original",
279
+ is_flag=True,
280
+ default=False,
281
+ help="Keep the original assets",
282
+ )
283
+ @click.option(
284
+ "--fingerprint/--no-fingerprint",
285
+ "fingerprint",
286
+ is_flag=True,
287
+ default=True,
288
+ help="Fingerprint the assets",
289
+ )
290
+ @click.option(
291
+ "--compress/--no-compress",
292
+ "compress",
293
+ is_flag=True,
294
+ default=True,
295
+ help="Compress the assets",
296
+ )
297
+ def compile(keep_original, fingerprint, compress):
275
298
  """Compile static assets"""
276
299
 
277
- # TODO preflight for assets only?
300
+ if not keep_original and not fingerprint:
301
+ click.secho(
302
+ "You must either keep the original assets or fingerprint them.",
303
+ fg="red",
304
+ err=True,
305
+ )
306
+ sys.exit(1)
278
307
 
279
308
  # TODO make this an entrypoint instead
280
309
  # Compile our Tailwind CSS (including templates in plain itself)
281
310
  if find_spec("plain.tailwind") is not None:
311
+ click.secho("Compiling Tailwind CSS", bold=True)
282
312
  result = subprocess.run(["plain", "tailwind", "compile", "--minify"])
313
+ print()
283
314
  if result.returncode:
284
315
  click.secho(
285
316
  f"Error compiling Tailwind CSS (exit {result.returncode})", fg="red"
@@ -295,15 +326,47 @@ def compile(ctx):
295
326
  package = json.load(f)
296
327
 
297
328
  if package.get("scripts", {}).get("compile"):
329
+ click.secho("Running `npm run compile`", bold=True)
298
330
  result = subprocess.run(["npm", "run", "compile"])
331
+ print()
299
332
  if result.returncode:
300
333
  click.secho(
301
334
  f"Error in `npm run compile` (exit {result.returncode})", fg="red"
302
335
  )
303
336
  sys.exit(result.returncode)
304
337
 
305
- # Run the regular collectstatic
306
- ctx.invoke(legacy_alias, legacy_args=["collectstatic", "--noinput"])
338
+ # Compile our assets
339
+ compiled_target_dir = settings.ASSETS_COMPILED_PATH
340
+ click.secho(f"Compiling assets to {compiled_target_dir}", bold=True)
341
+ if compiled_target_dir.exists():
342
+ click.secho("(clearing previously compiled assets)")
343
+ shutil.rmtree(compiled_target_dir)
344
+ compiled_target_dir.mkdir(parents=True, exist_ok=True)
345
+
346
+ total_files = 0
347
+ total_compiled = 0
348
+
349
+ for url_path, resolved_url_path, compiled_paths in compile_assets(
350
+ target_dir=compiled_target_dir,
351
+ keep_original=keep_original,
352
+ fingerprint=fingerprint,
353
+ compress=compress,
354
+ ):
355
+ if url_path == resolved_url_path:
356
+ click.secho(url_path, bold=True)
357
+ else:
358
+ click.secho(url_path, bold=True, nl=False)
359
+ click.secho(" → ", fg="yellow", nl=False)
360
+ click.echo(resolved_url_path)
361
+
362
+ print("\n".join(f" {Path(p).relative_to(Path.cwd())}" for p in compiled_paths))
363
+
364
+ total_files += 1
365
+ total_compiled += len(compiled_paths)
366
+
367
+ click.secho(
368
+ f"Compiled {total_files} assets into {total_compiled} files", fg="green"
369
+ )
307
370
 
308
371
 
309
372
  @plain_cli.command()
plain/packages/config.py CHANGED
@@ -15,11 +15,11 @@ class PackageConfig:
15
15
  migrations_module = "migrations"
16
16
 
17
17
  def __init__(self, package_name, package_module):
18
- # Full Python path to the application e.g. 'plain.staff.admin.admin'.
18
+ # Full Python path to the application e.g. 'plain.staff.admin'.
19
19
  self.name = package_name
20
20
 
21
- # Root module for the application e.g. <module 'plain.staff.admin.admin'
22
- # from 'admin/__init__.py'>.
21
+ # Root module for the application e.g. <module 'plain.staff.admin'
22
+ # from 'staff/__init__.py'>.
23
23
  self.module = package_module
24
24
 
25
25
  # Reference to the Packages registry that holds this PackageConfig. Set by the
@@ -43,8 +43,8 @@ class PackageConfig:
43
43
  if not hasattr(self, "path"):
44
44
  self.path = self._path_from_module(package_module)
45
45
 
46
- # Module containing models e.g. <module 'plain.staff.admin.models'
47
- # from 'admin/models.py'>. Set by import_models().
46
+ # Module containing models e.g. <module 'plain.staff.models'
47
+ # from 'staff/models.py'>. Set by import_models().
48
48
  # None if the application doesn't have a models module.
49
49
  self.models_module = None
50
50
 
@@ -243,7 +243,7 @@ class Packages:
243
243
  """
244
244
  Check whether an application with this name exists in the registry.
245
245
 
246
- package_name is the full name of the app e.g. 'plain.staff.admin'.
246
+ package_name is the full name of the app e.g. 'plain.staff'.
247
247
  """
248
248
  self.check_packages_ready()
249
249
  return any(ac.name == package_name for ac in self.package_configs.values())
@@ -363,12 +363,6 @@ class Packages:
363
363
  self.clear_cache()
364
364
  self.populate(installed)
365
365
 
366
- def unset_installed_packages(self):
367
- """Cancel a previous call to set_installed_packages()."""
368
- self.package_configs = self.stored_package_configs.pop()
369
- self.packages_ready = self.models_ready = self.ready = True
370
- self.clear_cache()
371
-
372
366
  def clear_cache(self):
373
367
  """
374
368
  Clear all internal caches, for methods that alter the app registry.
plain/preflight/urls.py CHANGED
@@ -100,16 +100,6 @@ def get_warning_for_invalid_pattern(pattern):
100
100
  ]
101
101
 
102
102
 
103
- @register
104
- def check_url_settings(package_configs, **kwargs):
105
- errors = []
106
- for name in ("ASSETS_URL",):
107
- value = getattr(settings, name)
108
- if value and not value.endswith("/"):
109
- errors.append(E006(name))
110
- return errors
111
-
112
-
113
103
  def E006(name):
114
104
  return Error(
115
105
  f"The {name} setting must end with a slash.",
plain/runtime/README.md CHANGED
@@ -55,7 +55,6 @@ DEBUG = environ.get("DEBUG", "false").lower() in ("true", "1", "yes")
55
55
 
56
56
  MIDDLEWARE = [
57
57
  "plain.middleware.security.SecurityMiddleware",
58
- "plain.assets.whitenoise.middleware.WhiteNoiseMiddleware",
59
58
  "plain.sessions.middleware.SessionMiddleware",
60
59
  "plain.middleware.common.CommonMiddleware",
61
60
  "plain.csrf.middleware.CsrfViewMiddleware",
@@ -112,7 +112,6 @@ SECURE_PROXY_SSL_HEADER = None
112
112
  # phase the middleware will be applied in reverse order.
113
113
  MIDDLEWARE = [
114
114
  "plain.middleware.security.SecurityMiddleware",
115
- "plain.assets.whitenoise.middleware.WhiteNoiseMiddleware",
116
115
  "plain.middleware.common.CommonMiddleware",
117
116
  "plain.csrf.middleware.CsrfViewMiddleware",
118
117
  "plain.middleware.clickjacking.XFrameOptionsMiddleware",
@@ -151,22 +150,16 @@ LOGGING = {}
151
150
  # ASSETS #
152
151
  ###############
153
152
 
154
- ASSETS_BACKEND = "plain.assets.whitenoise.storage.CompressedManifestStaticFilesStorage"
155
-
156
- # List of finder classes that know how to find assets files in
157
- # various locations.
158
- ASSETS_FINDERS = [
159
- "plain.assets.finders.FileSystemFinder",
160
- "plain.assets.finders.PackageDirectoriesFinder",
161
- ]
162
-
163
153
  # Absolute path to the directory assets files should be collected to.
164
154
  # Example: "/var/www/example.com/assets/"
165
- ASSETS_ROOT = PLAIN_TEMP_PATH / "assets_collected"
155
+ ASSETS_COMPILED_PATH = PLAIN_TEMP_PATH / "assets" / "compiled"
156
+
157
+ # Whether to redirect the original asset path to the fingerprinted path.
158
+ ASSETS_REDIRECT_ORIGINAL = True
166
159
 
167
- # URL that handles the assets files served from ASSETS_ROOT.
168
- # Example: "http://example.com/assets/", "http://assets.example.com/"
169
- ASSETS_URL = "/assets/"
160
+ # If assets are served by a CDN, use this URL to prefix asset paths.
161
+ # Ex. "https://cdn.example.com/assets/"
162
+ ASSETS_BASE_URL: str = ""
170
163
 
171
164
  ####################
172
165
  # PREFLIGHT CHECKS #
@@ -302,52 +302,3 @@ class Settings:
302
302
 
303
303
  def __repr__(self):
304
304
  return f'<{self.__class__.__name__} "{self.SETTINGS_MODULE}">'
305
-
306
-
307
- # Currently used for test settings override... nothing else
308
- class UserSettingsHolder:
309
- """Holder for user configured settings."""
310
-
311
- # SETTINGS_MODULE doesn't make much sense in the manually configured
312
- # (standalone) case.
313
- SETTINGS_MODULE = None
314
-
315
- def __init__(self, default_settings):
316
- """
317
- Requests for configuration variables not in this class are satisfied
318
- from the module specified in default_settings (if possible).
319
- """
320
- self.__dict__["_deleted"] = set()
321
- self.default_settings = default_settings
322
-
323
- def __getattr__(self, name):
324
- if not name.isupper() or name in self._deleted:
325
- raise AttributeError
326
- return getattr(self.default_settings, name)
327
-
328
- def __setattr__(self, name, value):
329
- self._deleted.discard(name)
330
- super().__setattr__(name, value)
331
-
332
- def __delattr__(self, name):
333
- self._deleted.add(name)
334
- if hasattr(self, name):
335
- super().__delattr__(name)
336
-
337
- def __dir__(self):
338
- return sorted(
339
- s
340
- for s in [*self.__dict__, *dir(self.default_settings)]
341
- if s not in self._deleted
342
- )
343
-
344
- def is_overridden(self, setting):
345
- deleted = setting in self._deleted
346
- set_locally = setting in self.__dict__
347
- set_on_default = getattr(
348
- self.default_settings, "is_overridden", lambda s: False
349
- )(setting)
350
- return deleted or set_locally or set_on_default
351
-
352
- def __repr__(self):
353
- return f"<{self.__class__.__name__}>"
@@ -1,4 +1,4 @@
1
- from plain.assets import get_asset_url
1
+ from plain.assets.urls import get_asset_url
2
2
  from plain.paginator import Paginator
3
3
  from plain.utils import timezone
4
4
 
plain/test/__init__.py CHANGED
@@ -1,16 +1,8 @@
1
1
  """Plain Unit Test framework."""
2
2
 
3
3
  from plain.test.client import Client, RequestFactory
4
- from plain.test.utils import (
5
- ignore_warnings,
6
- modify_settings,
7
- override_settings,
8
- )
9
4
 
10
5
  __all__ = [
11
6
  "Client",
12
7
  "RequestFactory",
13
- "ignore_warnings",
14
- "modify_settings",
15
- "override_settings",
16
8
  ]
plain/test/client.py CHANGED
@@ -8,6 +8,7 @@ from http import HTTPStatus
8
8
  from http.cookies import SimpleCookie
9
9
  from importlib import import_module
10
10
  from io import BytesIO, IOBase
11
+ from itertools import chain
11
12
  from urllib.parse import unquote_to_bytes, urljoin, urlparse, urlsplit
12
13
 
13
14
  from plain.http import HttpHeaders, HttpRequest, QueryDict
@@ -16,7 +17,6 @@ from plain.internal.handlers.wsgi import WSGIRequest
16
17
  from plain.json import PlainJSONEncoder
17
18
  from plain.runtime import settings
18
19
  from plain.signals import got_request_exception, request_started
19
- from plain.test.utils import ContextList
20
20
  from plain.urls import resolve
21
21
  from plain.utils.encoding import force_bytes
22
22
  from plain.utils.functional import SimpleLazyObject
@@ -40,6 +40,41 @@ CONTENT_TYPE_RE = _lazy_re_compile(r".*; charset=([\w-]+);?")
40
40
  JSON_CONTENT_TYPE_RE = _lazy_re_compile(r"^application\/(.+\+)?json")
41
41
 
42
42
 
43
+ class ContextList(list):
44
+ """
45
+ A wrapper that provides direct key access to context items contained
46
+ in a list of context objects.
47
+ """
48
+
49
+ def __getitem__(self, key):
50
+ if isinstance(key, str):
51
+ for subcontext in self:
52
+ if key in subcontext:
53
+ return subcontext[key]
54
+ raise KeyError(key)
55
+ else:
56
+ return super().__getitem__(key)
57
+
58
+ def get(self, key, default=None):
59
+ try:
60
+ return self.__getitem__(key)
61
+ except KeyError:
62
+ return default
63
+
64
+ def __contains__(self, key):
65
+ try:
66
+ self[key]
67
+ except KeyError:
68
+ return False
69
+ return True
70
+
71
+ def keys(self):
72
+ """
73
+ Flattened keys of subcontexts.
74
+ """
75
+ return set(chain.from_iterable(d for subcontext in self for d in subcontext))
76
+
77
+
43
78
  class RedirectCycleError(Exception):
44
79
  """The test client has been asked to follow a redirect loop."""
45
80
 
@@ -550,21 +585,6 @@ class ClientMixin:
550
585
  self.cookies[settings.SESSION_COOKIE_NAME] = session.session_key
551
586
  return session
552
587
 
553
- def login(self, **credentials):
554
- """
555
- Set the Factory to appear as if it has successfully logged into a site.
556
-
557
- Return True if login is possible or False if the provided credentials
558
- are incorrect.
559
- """
560
- from plain.auth import authenticate
561
-
562
- user = authenticate(**credentials)
563
- if user:
564
- self._login(user)
565
- return True
566
- return False
567
-
568
588
  def force_login(self, user):
569
589
  self._login(user)
570
590
 
plain/views/base.py CHANGED
@@ -72,9 +72,6 @@ class View:
72
72
  if isinstance(result, ResponseBase):
73
73
  return result
74
74
 
75
- # Allow return of an int (status code)
76
- # or tuple (status code, content)?
77
-
78
75
  if isinstance(result, str):
79
76
  return Response(result)
80
77
 
@@ -84,6 +81,11 @@ class View:
84
81
  if isinstance(result, dict):
85
82
  return JsonResponse(result)
86
83
 
84
+ if isinstance(result, int):
85
+ return Response(status=result)
86
+
87
+ # Allow tuple for (status_code, content)?
88
+
87
89
  raise ValueError(f"Unexpected view return type: {type(result)}")
88
90
 
89
91
  def options(self) -> Response:
plain/views/errors.py CHANGED
@@ -1,4 +1,5 @@
1
1
  from plain.http import ResponseBase
2
+ from plain.templates import TemplateFileMissing
2
3
 
3
4
  from .templates import TemplateView
4
5
 
@@ -23,3 +24,9 @@ class ErrorView(TemplateView):
23
24
  # Set the status code we want
24
25
  response.status_code = self.status_code
25
26
  return response
27
+
28
+ def get(self):
29
+ try:
30
+ return super().get()
31
+ except TemplateFileMissing:
32
+ return self.status_code