plain 0.67.0__py3-none-any.whl → 0.68.1__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.
@@ -1,72 +1,79 @@
1
- from plain.utils.inspect import func_accepts_kwargs
2
- from plain.utils.itercompat import is_iterable
1
+ from plain.runtime import settings
3
2
 
4
3
 
5
4
  class CheckRegistry:
6
5
  def __init__(self):
7
- self.registered_checks = set()
8
- self.deployment_checks = set()
6
+ self.checks = {} # name -> (check_class, deploy)
9
7
 
10
- def register(self, check=None, deploy=False):
11
- """
12
- Can be used as a function or a decorator. Register given function
13
- `f`. The function should receive **kwargs
14
- and return list of Errors and Warnings.
15
-
16
- Example::
17
-
18
- registry = CheckRegistry()
19
- @registry.register('mytag', 'anothertag')
20
- def my_check(package_configs, **kwargs):
21
- # ... perform checks and collect `errors` ...
22
- return errors
23
- # or
24
- registry.register(my_check, 'mytag', 'anothertag')
25
- """
26
-
27
- def inner(check):
28
- if not func_accepts_kwargs(check):
29
- raise TypeError(
30
- "Check functions must accept keyword arguments (**kwargs)."
31
- )
32
- checks = self.deployment_checks if deploy else self.registered_checks
33
- checks.add(check)
34
- return check
35
-
36
- if callable(check):
37
- return inner(check)
38
- else:
39
- return inner
8
+ def register_check(self, check_class, name, deploy=False):
9
+ """Register a check class with a unique name."""
10
+ if name in self.checks:
11
+ raise ValueError(f"Check {name} already registered")
12
+ self.checks[name] = (check_class, deploy)
40
13
 
41
14
  def run_checks(
42
15
  self,
43
- package_configs=None,
44
- include_deployment_checks=False,
45
- database=False,
16
+ include_deploy_checks=False,
46
17
  ):
47
18
  """
48
- Run all registered checks and return list of Errors and Warnings.
19
+ Run all registered checks and yield (check_class, name, results) tuples.
49
20
  """
50
- errors = []
51
- checks = self.get_checks(include_deployment_checks)
52
-
53
- for check in checks:
54
- new_errors = check(package_configs=package_configs, database=database)
55
- if not is_iterable(new_errors):
56
- raise TypeError(
57
- f"The function {check!r} did not return a list. All functions "
58
- "registered with the checks registry must return a list.",
59
- )
60
- errors.extend(new_errors)
61
- return errors
62
-
63
- def get_checks(self, include_deployment_checks=False):
64
- checks = list(self.registered_checks)
65
- if include_deployment_checks:
66
- checks.extend(self.deployment_checks)
67
- return checks
21
+ # Validate silenced check names
22
+ silenced_checks = settings.PREFLIGHT_SILENCED_CHECKS
23
+ unknown_silenced = set(silenced_checks) - set(self.checks.keys())
24
+ if unknown_silenced:
25
+ unknown_names = ", ".join(sorted(unknown_silenced))
26
+ raise ValueError(
27
+ f"Unknown check names in PREFLIGHT_SILENCED_CHECKS: {unknown_names}. "
28
+ "Check for typos or remove outdated check names."
29
+ )
30
+
31
+ for name, (check_class, deploy) in sorted(self.checks.items()):
32
+ # Skip silenced checks
33
+ if name in silenced_checks:
34
+ continue
35
+
36
+ # Skip deployment checks if not requested
37
+ if deploy and not include_deploy_checks:
38
+ continue
39
+
40
+ # Instantiate and run check
41
+ check = check_class()
42
+ results = check.run()
43
+ yield check_class, name, results
44
+
45
+ def get_checks(self, include_deploy_checks=False):
46
+ """Get list of (check_class, name) tuples."""
47
+ result = []
48
+ for name, (check_class, deploy) in self.checks.items():
49
+ if deploy and not include_deploy_checks:
50
+ continue
51
+ result.append((check_class, name))
52
+ return result
68
53
 
69
54
 
70
55
  checks_registry = CheckRegistry()
71
- register_check = checks_registry.register
56
+
57
+
58
+ def register_check(name: str, *, deploy: bool = False):
59
+ """
60
+ Decorator to register a check class.
61
+
62
+ Usage:
63
+ @register_check("security.secret_key", deploy=True)
64
+ class CheckSecretKey(PreflightCheck):
65
+ pass
66
+
67
+ @register_check("files.upload_temp_dir")
68
+ class CheckUploadTempDir(PreflightCheck):
69
+ pass
70
+ """
71
+
72
+ def wrapper(cls):
73
+ checks_registry.register_check(cls, name=name, deploy=deploy)
74
+ return cls
75
+
76
+ return wrapper
77
+
78
+
72
79
  run_checks = checks_registry.run_checks
@@ -0,0 +1,29 @@
1
+ from plain.runtime import settings
2
+
3
+
4
+ class PreflightResult:
5
+ def __init__(self, *, fix: str, id: str, obj=None, warning: bool = False):
6
+ self.fix = fix
7
+ self.obj = obj
8
+ self.id = id
9
+ self.warning = warning
10
+
11
+ def __eq__(self, other):
12
+ return isinstance(other, self.__class__) and all(
13
+ getattr(self, attr) == getattr(other, attr)
14
+ for attr in ["fix", "obj", "id", "warning"]
15
+ )
16
+
17
+ def __str__(self):
18
+ if self.obj is None:
19
+ obj = ""
20
+ elif hasattr(self.obj, "_meta") and hasattr(self.obj._meta, "label"):
21
+ # Duck type for model objects - use their meta label
22
+ obj = self.obj._meta.label
23
+ else:
24
+ obj = str(self.obj)
25
+ id_part = f"({self.id}) " if self.id else ""
26
+ return f"{obj}: {id_part}{self.fix}"
27
+
28
+ def is_silenced(self):
29
+ return self.id and self.id in settings.PREFLIGHT_SILENCED_RESULTS
@@ -1,21 +1,12 @@
1
- from plain.exceptions import ImproperlyConfigured
2
1
  from plain.runtime import settings
3
2
 
4
- from .messages import Error, Warning
3
+ from .checks import PreflightCheck
5
4
  from .registry import register_check
5
+ from .results import PreflightResult
6
6
 
7
7
  SECRET_KEY_MIN_LENGTH = 50
8
8
  SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5
9
9
 
10
- SECRET_KEY_WARNING_MSG = (
11
- f"Your %s has less than {SECRET_KEY_MIN_LENGTH} characters or less than "
12
- f"{SECRET_KEY_MIN_UNIQUE_CHARACTERS} unique characters. Please generate "
13
- f"a long and random value, otherwise many of Plain's security-critical "
14
- f"features will be vulnerable to attack."
15
- )
16
-
17
- W025 = Warning(SECRET_KEY_WARNING_MSG, id="security.W025")
18
-
19
10
 
20
11
  def _check_secret_key(secret_key):
21
12
  return (
@@ -24,66 +15,67 @@ def _check_secret_key(secret_key):
24
15
  )
25
16
 
26
17
 
27
- @register_check(deploy=True)
28
- def check_secret_key(package_configs, **kwargs):
29
- try:
30
- secret_key = settings.SECRET_KEY
31
- except (ImproperlyConfigured, AttributeError):
32
- passed_check = False
33
- else:
34
- passed_check = _check_secret_key(secret_key)
35
- return (
36
- []
37
- if passed_check
38
- else [
39
- Warning(
40
- SECRET_KEY_WARNING_MSG % "SECRET_KEY",
41
- id="security.W009",
42
- )
43
- ]
44
- )
18
+ @register_check(name="security.secret_key", deploy=True)
19
+ class CheckSecretKey(PreflightCheck):
20
+ """Validates that SECRET_KEY is long and random enough for security."""
21
+
22
+ def run(self):
23
+ if not _check_secret_key(settings.SECRET_KEY):
24
+ return [
25
+ PreflightResult(
26
+ fix=f"SECRET_KEY is too weak (needs {SECRET_KEY_MIN_LENGTH}+ characters, "
27
+ f"{SECRET_KEY_MIN_UNIQUE_CHARACTERS}+ unique). Generate a new long random value or "
28
+ f"Plain's security features will be vulnerable to attack.",
29
+ id="security.secret_key_weak",
30
+ )
31
+ ]
32
+ return []
45
33
 
46
34
 
47
- @register_check(deploy=True)
48
- def check_secret_key_fallbacks(package_configs, **kwargs):
49
- warnings = []
50
- try:
51
- fallbacks = settings.SECRET_KEY_FALLBACKS
52
- except (ImproperlyConfigured, AttributeError):
53
- warnings.append(Warning(W025.msg % "SECRET_KEY_FALLBACKS", id=W025.id))
54
- else:
55
- for index, key in enumerate(fallbacks):
35
+ @register_check(name="security.secret_key_fallbacks", deploy=True)
36
+ class CheckSecretKeyFallbacks(PreflightCheck):
37
+ """Validates that SECRET_KEY_FALLBACKS are long and random enough for security."""
38
+
39
+ def run(self):
40
+ errors = []
41
+ for index, key in enumerate(settings.SECRET_KEY_FALLBACKS):
56
42
  if not _check_secret_key(key):
57
- warnings.append(
58
- Warning(W025.msg % f"SECRET_KEY_FALLBACKS[{index}]", id=W025.id)
43
+ errors.append(
44
+ PreflightResult(
45
+ fix=f"SECRET_KEY_FALLBACKS[{index}] is too weak (needs {SECRET_KEY_MIN_LENGTH}+ characters, "
46
+ f"{SECRET_KEY_MIN_UNIQUE_CHARACTERS}+ unique). Generate a new long random value or "
47
+ f"Plain's security features will be vulnerable to attack.",
48
+ id="security.secret_key_fallback_weak",
49
+ )
59
50
  )
60
- return warnings
51
+ return errors
61
52
 
62
53
 
63
- @register_check(deploy=True)
64
- def check_debug(package_configs, **kwargs):
65
- passed_check = not settings.DEBUG
66
- return (
67
- []
68
- if passed_check
69
- else [
70
- Warning(
71
- "You should not have DEBUG set to True in deployment.",
72
- id="security.W018",
73
- )
74
- ]
75
- )
54
+ @register_check(name="security.debug", deploy=True)
55
+ class CheckDebug(PreflightCheck):
56
+ """Ensures DEBUG is False in production deployment."""
76
57
 
58
+ def run(self):
59
+ if settings.DEBUG:
60
+ return [
61
+ PreflightResult(
62
+ fix="DEBUG is True in deployment. Set DEBUG=False to prevent exposing sensitive information.",
63
+ id="security.debug_enabled_in_production",
64
+ )
65
+ ]
66
+ return []
77
67
 
78
- @register_check(deploy=True)
79
- def check_allowed_hosts(package_configs, **kwargs):
80
- return (
81
- []
82
- if settings.ALLOWED_HOSTS
83
- else [
84
- Error(
85
- "ALLOWED_HOSTS must not be empty in deployment.",
86
- id="security.E020",
87
- )
88
- ]
89
- )
68
+
69
+ @register_check(name="security.allowed_hosts", deploy=True)
70
+ class CheckAllowedHosts(PreflightCheck):
71
+ """Ensures ALLOWED_HOSTS is not empty in production deployment."""
72
+
73
+ def run(self):
74
+ if not settings.ALLOWED_HOSTS:
75
+ return [
76
+ PreflightResult(
77
+ fix="ALLOWED_HOSTS is empty in deployment. Add your domain(s) to prevent host header attacks.",
78
+ id="security.allowed_hosts_empty",
79
+ )
80
+ ]
81
+ return []
plain/preflight/urls.py CHANGED
@@ -1,54 +1,13 @@
1
- from plain.runtime import settings
1
+ from .checks import PreflightCheck
2
+ from .registry import register_check
2
3
 
3
- from . import Error, register_check
4
4
 
5
+ @register_check("urls.config")
6
+ class CheckUrlConfig(PreflightCheck):
7
+ """Validates the URL configuration for common issues."""
5
8
 
6
- @register_check
7
- def check_url_config(package_configs, **kwargs):
8
- if getattr(settings, "URLS_ROUTER", None):
9
+ def run(self):
9
10
  from plain.urls import get_resolver
10
11
 
11
12
  resolver = get_resolver()
12
- return check_resolver(resolver)
13
-
14
- return []
15
-
16
-
17
- def check_resolver(resolver):
18
- """
19
- Recursively check the resolver.
20
- """
21
- check_method = getattr(resolver, "check", None)
22
- if check_method is not None:
23
- return check_method()
24
- elif not hasattr(resolver, "resolve"):
25
- return get_warning_for_invalid_pattern(resolver)
26
- else:
27
- return []
28
-
29
-
30
- def get_warning_for_invalid_pattern(pattern):
31
- """
32
- Return a list containing a warning that the pattern is invalid.
33
-
34
- describe_pattern() cannot be used here, because we cannot rely on the
35
- urlpattern having regex or name attributes.
36
- """
37
- if isinstance(pattern, str):
38
- hint = (
39
- f"Try removing the string '{pattern}'. The list of urlpatterns should not "
40
- "have a prefix string as the first element."
41
- )
42
- elif isinstance(pattern, tuple):
43
- hint = "Try using path() instead of a tuple."
44
- else:
45
- hint = None
46
-
47
- return [
48
- Error(
49
- f"Your URL pattern {pattern!r} is invalid. Ensure that urlpatterns is a list "
50
- "of path() and/or re_path() instances.",
51
- hint=hint,
52
- id="urls.E004",
53
- )
54
- ]
13
+ return resolver.preflight()
@@ -152,11 +152,11 @@ ASSETS_BASE_URL: str = ""
152
152
 
153
153
  # MARK: Preflight Checks
154
154
 
155
- # List of all issues generated by system checks that should be silenced. Light
156
- # issues like warnings, infos or debugs will not generate a message. Silencing
157
- # serious issues like errors and criticals does not result in hiding the
158
- # message, but Plain will not stop you from e.g. running server.
159
- PREFLIGHT_SILENCED_CHECKS = []
155
+ # Silence checks by name
156
+ PREFLIGHT_SILENCED_CHECKS: list[str] = []
157
+
158
+ # Silence specific check results by id
159
+ PREFLIGHT_SILENCED_RESULTS: list[str] = []
160
160
 
161
161
  # MARK: Templates
162
162
 
@@ -1,6 +1,3 @@
1
- import importlib.util
2
- from importlib import import_module
3
-
4
1
  from plain.packages import packages_registry
5
2
  from plain.runtime import settings
6
3
  from plain.utils.functional import LazyObject
@@ -10,10 +7,6 @@ from .environments import DefaultEnvironment, get_template_dirs
10
7
 
11
8
 
12
9
  class JinjaEnvironment(LazyObject):
13
- def __init__(self, *args, **kwargs):
14
- self.__dict__["_imported_modules"] = set()
15
- super().__init__(*args, **kwargs)
16
-
17
10
  def _setup(self):
18
11
  environment_setting = settings.TEMPLATES_JINJA_ENVIRONMENT
19
12
 
@@ -25,22 +18,8 @@ class JinjaEnvironment(LazyObject):
25
18
  # We have to set _wrapped before we trigger the autoloading of "register" commands
26
19
  self._wrapped = env
27
20
 
28
- for package_config in packages_registry.get_package_configs():
29
- # Autoload template helpers if the package provides a ``templates`` module
30
- import_name = f"{package_config.name}.templates"
31
- if import_name in self._imported_modules:
32
- continue
33
- if importlib.util.find_spec(import_name) is None:
34
- continue
35
- import_module(import_name)
36
- self._imported_modules.add(import_name)
37
-
38
- # Autoload template helpers from the local ``app`` package if present
39
- import_name = "app.templates"
40
- if import_name not in self._imported_modules:
41
- if importlib.util.find_spec(import_name) is not None:
42
- import_module(import_name)
43
- self._imported_modules.add(import_name)
21
+ # Autoload template helpers using the registry method
22
+ packages_registry.autodiscover_modules("templates", include_app=True)
44
23
 
45
24
 
46
25
  environment = JinjaEnvironment()
plain/urls/patterns.py CHANGED
@@ -3,7 +3,7 @@ import string
3
3
 
4
4
  from plain.exceptions import ImproperlyConfigured
5
5
  from plain.internal import internalcode
6
- from plain.preflight import Warning
6
+ from plain.preflight import PreflightResult
7
7
  from plain.runtime import settings
8
8
  from plain.utils.regex_helper import _lazy_re_compile
9
9
 
@@ -33,11 +33,10 @@ class CheckURLMixin:
33
33
  if regex_pattern.startswith(("/", "^/", "^\\/")) and not regex_pattern.endswith(
34
34
  "/"
35
35
  ):
36
- warning = Warning(
37
- f"Your URL pattern {self.describe()} has a route beginning with a '/'. Remove this "
38
- "slash as it is unnecessary. If this pattern is targeted in an "
39
- "include(), ensure the include() pattern has a trailing '/'.",
40
- id="urls.W002",
36
+ warning = PreflightResult(
37
+ fix=f"URL pattern {self.describe()} starts with unnecessary '/'. Remove the leading slash.",
38
+ warning=True,
39
+ id="urls.pattern_starts_with_slash",
41
40
  )
42
41
  return [warning]
43
42
  else:
@@ -68,7 +67,7 @@ class RegexPattern(CheckURLMixin):
68
67
  return path[match.end() :], args, kwargs
69
68
  return None
70
69
 
71
- def check(self):
70
+ def preflight(self):
72
71
  warnings = []
73
72
  warnings.extend(self._check_pattern_startswith_slash())
74
73
  if not self._is_endpoint:
@@ -79,11 +78,10 @@ class RegexPattern(CheckURLMixin):
79
78
  regex_pattern = self.regex.pattern
80
79
  if regex_pattern.endswith("$") and not regex_pattern.endswith(r"\$"):
81
80
  return [
82
- Warning(
83
- f"Your URL pattern {self.describe()} uses include with a route ending with a '$'. "
84
- "Remove the dollar from the route to avoid problems including "
85
- "URLs.",
86
- id="urls.W001",
81
+ PreflightResult(
82
+ fix=f"Include pattern {self.describe()} ends with '$' which prevents URL inclusion. Remove the dollar sign.",
83
+ warning=True,
84
+ id="urls.include_pattern_ends_with_dollar",
87
85
  )
88
86
  ]
89
87
  else:
@@ -174,16 +172,17 @@ class RoutePattern(CheckURLMixin):
174
172
  return path[match.end() :], (), kwargs
175
173
  return None
176
174
 
177
- def check(self):
175
+ def preflight(self):
178
176
  warnings = self._check_pattern_startswith_slash()
179
177
  route = self._route
180
178
  if "(?P<" in route or route.startswith("^") or route.endswith("$"):
181
179
  warnings.append(
182
- Warning(
183
- f"Your URL pattern {self.describe()} has a route that contains '(?P<', begins "
180
+ PreflightResult(
181
+ fix=f"Your URL pattern {self.describe()} has a route that contains '(?P<', begins "
184
182
  "with a '^', or ends with a '$'. This was likely an oversight "
185
183
  "when migrating to plain.urls.path().",
186
- id="2_0.W001",
184
+ warning=True,
185
+ id="urls.path_migration_warning",
187
186
  )
188
187
  )
189
188
  return warnings
@@ -204,9 +203,9 @@ class URLPattern:
204
203
  def __repr__(self):
205
204
  return f"<{self.__class__.__name__} {self.pattern.describe()}>"
206
205
 
207
- def check(self):
206
+ def preflight(self):
208
207
  warnings = self._check_pattern_name()
209
- warnings.extend(self.pattern.check())
208
+ warnings.extend(self.pattern.preflight())
210
209
  return warnings
211
210
 
212
211
  def _check_pattern_name(self):
@@ -214,10 +213,11 @@ class URLPattern:
214
213
  Check that the pattern name does not contain a colon.
215
214
  """
216
215
  if self.pattern.name is not None and ":" in self.pattern.name:
217
- warning = Warning(
218
- f"Your URL pattern {self.pattern.describe()} has a name including a ':'. Remove the colon, to "
216
+ warning = PreflightResult(
217
+ fix=f"Your URL pattern {self.pattern.describe()} has a name including a ':'. Remove the colon, to "
219
218
  "avoid ambiguous namespace references.",
220
- id="urls.W003",
219
+ warning=True,
220
+ id="urls.pattern_name_contains_colon",
221
221
  )
222
222
  return [warning]
223
223
  else:
plain/urls/resolvers.py CHANGED
@@ -11,7 +11,6 @@ import re
11
11
  from threading import local
12
12
  from urllib.parse import quote
13
13
 
14
- from plain.preflight.urls import check_resolver
15
14
  from plain.runtime import settings
16
15
  from plain.utils.datastructures import MultiValueDict
17
16
  from plain.utils.http import RFC3986_SUBDELIMS, escape_leading_slashes
@@ -114,11 +113,12 @@ class URLResolver:
114
113
  def __repr__(self):
115
114
  return f"<{self.__class__.__name__} {repr(self.router)} ({self.namespace}) {self.pattern.describe()}>"
116
115
 
117
- def check(self):
116
+ def preflight(self):
118
117
  messages = []
118
+ messages.extend(self.pattern.preflight())
119
119
  for pattern in self.url_patterns:
120
- messages.extend(check_resolver(pattern))
121
- return messages or self.pattern.check()
120
+ messages.extend(pattern.preflight())
121
+ return messages
122
122
 
123
123
  def _populate(self):
124
124
  # Short-circuit if called recursively in this thread to prevent
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: plain
3
- Version: 0.67.0
3
+ Version: 0.68.1
4
4
  Summary: A web framework for building products with Python.
5
5
  Author-email: Dave Gaeddert <dave.gaeddert@dropseed.dev>
6
6
  License-File: LICENSE