plain 0.4.1__py3-none-any.whl → 0.11.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.
@@ -1,13 +1,5 @@
1
- """
2
- Settings and configuration for Plain.
3
-
4
- Read values from the module specified by the PLAIN_SETTINGS_MODULE environment
5
- variable, and then from plain.global_settings; see the global_settings.py
6
- for a list of all possible variables.
7
- """
8
1
  import importlib
9
2
  import json
10
- import logging
11
3
  import os
12
4
  import time
13
5
  import types
@@ -16,106 +8,256 @@ from pathlib import Path
16
8
 
17
9
  from plain.exceptions import ImproperlyConfigured
18
10
  from plain.packages import PackageConfig
19
- from plain.utils.functional import LazyObject, empty
20
11
 
21
12
  ENVIRONMENT_VARIABLE = "PLAIN_SETTINGS_MODULE"
22
13
  ENV_SETTINGS_PREFIX = "PLAIN_"
23
-
24
- logger = logging.getLogger("plain.runtime")
14
+ CUSTOM_SETTINGS_PREFIX = "APP_"
25
15
 
26
16
 
27
- class SettingsReference(str):
17
+ class Settings:
28
18
  """
29
- String subclass which references a current settings value. It's treated as
30
- the value in memory but serializes to a settings.NAME attribute reference.
19
+ Settings and configuration for Plain.
20
+
21
+ This class handles loading settings from the module specified by the
22
+ PLAIN_SETTINGS_MODULE environment variable, as well as from default settings,
23
+ environment variables, and explicit settings in the settings module.
24
+
25
+ Lazy initialization is implemented to defer loading until settings are first accessed.
31
26
  """
32
27
 
33
- def __new__(self, value, setting_name):
34
- return str.__new__(self, value)
28
+ def __init__(self, settings_module=None):
29
+ self._settings_module = settings_module
30
+ self._settings = {}
31
+ self._errors = [] # Collect configuration errors
32
+ self.configured = False
35
33
 
36
- def __init__(self, value, setting_name):
37
- self.setting_name = setting_name
34
+ def _setup(self):
35
+ if self.configured:
36
+ return
37
+ else:
38
+ self.configured = True
38
39
 
40
+ self._settings = {} # Maps setting names to SettingDefinition instances
39
41
 
40
- class LazySettings(LazyObject):
41
- """
42
- A lazy proxy for either global Plain settings or a custom settings object.
43
- The user can manually configure settings prior to using them. Otherwise,
44
- Plain uses the settings module pointed to by PLAIN_SETTINGS_MODULE.
45
- """
42
+ # Determine the settings module
43
+ if self._settings_module is None:
44
+ self._settings_module = os.environ.get(ENVIRONMENT_VARIABLE, "app.settings")
45
+
46
+ # First load the global settings from plain
47
+ self._load_module_settings(
48
+ importlib.import_module("plain.runtime.global_settings")
49
+ )
46
50
 
47
- def _setup(self, name=None):
48
- """
49
- Load the settings module pointed to by the environment variable. This
50
- is used the first time settings are needed, if the user hasn't
51
- configured settings manually.
52
- """
53
- settings_module = os.environ.get(ENVIRONMENT_VARIABLE, "settings")
54
- self._wrapped = Settings(settings_module)
51
+ # Import the user's settings module
52
+ try:
53
+ mod = importlib.import_module(self._settings_module)
54
+ except ImportError as e:
55
+ raise ImproperlyConfigured(
56
+ f"Could not import settings '{self._settings_module}': {e}"
57
+ )
55
58
 
56
- def __repr__(self):
57
- # Hardcode the class name as otherwise it yields 'Settings'.
58
- if self._wrapped is empty:
59
- return "<LazySettings [Unevaluated]>"
60
- return f'<LazySettings "{self._wrapped.SETTINGS_MODULE}">'
59
+ # Keep a reference to the settings.py module path
60
+ self.path = Path(mod.__file__).resolve()
61
+
62
+ # Load default settings from installed packages
63
+ self._load_default_settings(mod)
64
+ # Load environment settings
65
+ self._load_env_settings()
66
+ # Load explicit settings from the settings module
67
+ self._load_explicit_settings(mod)
68
+ # Check for any required settings that are missing
69
+ self._check_required_settings()
70
+ # Check for any collected errors
71
+ self._raise_errors_if_any()
72
+
73
+ def _load_module_settings(self, module):
74
+ annotations = getattr(module, "__annotations__", {})
75
+ settings = dir(module)
76
+
77
+ for setting in settings:
78
+ if setting.isupper():
79
+ if setting in self._settings:
80
+ self._errors.append(f"Duplicate setting '{setting}'.")
81
+ continue
82
+
83
+ setting_value = getattr(module, setting)
84
+ self._settings[setting] = SettingDefinition(
85
+ name=setting,
86
+ default_value=setting_value,
87
+ annotation=annotations.get(setting, None),
88
+ module=module,
89
+ )
90
+
91
+ # Store any annotations that didn't have a value (these are required settings)
92
+ for setting, annotation in annotations.items():
93
+ if setting not in self._settings:
94
+ self._settings[setting] = SettingDefinition(
95
+ name=setting,
96
+ default_value=None,
97
+ annotation=annotation,
98
+ module=module,
99
+ required=True,
100
+ )
101
+
102
+ def _load_default_settings(self, settings_module):
103
+ for entry in getattr(settings_module, "INSTALLED_PACKAGES", []):
104
+ try:
105
+ if isinstance(entry, PackageConfig):
106
+ app_settings = entry.module.default_settings
107
+ else:
108
+ app_settings = importlib.import_module(f"{entry}.default_settings")
109
+ except ModuleNotFoundError:
110
+ continue
111
+
112
+ self._load_module_settings(app_settings)
113
+
114
+ def _load_env_settings(self):
115
+ env_settings = {
116
+ k[len(ENV_SETTINGS_PREFIX) :]: v
117
+ for k, v in os.environ.items()
118
+ if k.startswith(ENV_SETTINGS_PREFIX) and k.isupper()
119
+ }
120
+ for setting, value in env_settings.items():
121
+ if setting in self._settings:
122
+ setting_def = self._settings[setting]
123
+ try:
124
+ parsed_value = _parse_env_value(value, setting_def.annotation)
125
+ setting_def.set_value(parsed_value, "env")
126
+ except ImproperlyConfigured as e:
127
+ self._errors.append(str(e))
128
+
129
+ def _load_explicit_settings(self, settings_module):
130
+ for setting in dir(settings_module):
131
+ if setting.isupper():
132
+ setting_value = getattr(settings_module, setting)
133
+
134
+ if setting in self._settings:
135
+ setting_def = self._settings[setting]
136
+ try:
137
+ setting_def.set_value(setting_value, "explicit")
138
+ except ImproperlyConfigured as e:
139
+ self._errors.append(str(e))
140
+ continue
141
+
142
+ elif setting.startswith(CUSTOM_SETTINGS_PREFIX):
143
+ # Accept custom settings prefixed with '{CUSTOM_SETTINGS_PREFIX}'
144
+ setting_def = SettingDefinition(
145
+ name=setting,
146
+ default_value=None,
147
+ annotation=None,
148
+ required=False,
149
+ )
150
+ try:
151
+ setting_def.set_value(setting_value, "explicit")
152
+ except ImproperlyConfigured as e:
153
+ self._errors.append(str(e))
154
+ continue
155
+ self._settings[setting] = setting_def
156
+ else:
157
+ # Collect unrecognized settings individually
158
+ self._errors.append(
159
+ f"Unknown setting '{setting}'. Custom settings must start with '{CUSTOM_SETTINGS_PREFIX}'."
160
+ )
161
+
162
+ if hasattr(time, "tzset") and self.TIME_ZONE:
163
+ zoneinfo_root = Path("/usr/share/zoneinfo")
164
+ zone_info_file = zoneinfo_root.joinpath(*self.TIME_ZONE.split("/"))
165
+ if zoneinfo_root.exists() and not zone_info_file.exists():
166
+ self._errors.append(
167
+ f"Invalid TIME_ZONE setting '{self.TIME_ZONE}'. Timezone file not found."
168
+ )
169
+ else:
170
+ os.environ["TZ"] = self.TIME_ZONE
171
+ time.tzset()
172
+
173
+ def _check_required_settings(self):
174
+ missing = [k for k, v in self._settings.items() if v.required and not v.is_set]
175
+ if missing:
176
+ self._errors.append(f"Missing required setting(s): {', '.join(missing)}.")
177
+
178
+ def _raise_errors_if_any(self):
179
+ if self._errors:
180
+ errors = ["- " + e for e in self._errors]
181
+ raise ImproperlyConfigured(
182
+ "Settings configuration errors:\n" + "\n".join(errors)
183
+ )
61
184
 
62
185
  def __getattr__(self, name):
63
- """Return the value of a setting and cache it in self.__dict__."""
64
- if (_wrapped := self._wrapped) is empty:
65
- self._setup(name)
66
- _wrapped = self._wrapped
67
- val = getattr(_wrapped, name)
186
+ # Avoid recursion by directly returning internal attributes
187
+ if not name.isupper():
188
+ return object.__getattribute__(self, name)
68
189
 
69
- # Special case some settings which require further modification.
70
- # This is done here for performance reasons so the modified value is cached.
71
- if name == "SECRET_KEY" and not val:
72
- raise ImproperlyConfigured("The SECRET_KEY setting must not be empty.")
190
+ self._setup()
73
191
 
74
- self.__dict__[name] = val
75
- return val
192
+ if name in self._settings:
193
+ return self._settings[name].value
194
+ else:
195
+ raise AttributeError(f"'Settings' object has no attribute '{name}'")
76
196
 
77
197
  def __setattr__(self, name, value):
78
- """
79
- Set the value of setting. Clear all cached values if _wrapped changes
80
- (@override_settings does this) or clear single values when set.
81
- """
82
- if name == "_wrapped":
83
- self.__dict__.clear()
198
+ # Handle internal attributes without recursion
199
+ if not name.isupper():
200
+ object.__setattr__(self, name, value)
84
201
  else:
85
- self.__dict__.pop(name, None)
86
- super().__setattr__(name, value)
87
-
88
- def __delattr__(self, name):
89
- """Delete a setting and clear it from cache if needed."""
90
- super().__delattr__(name)
91
- self.__dict__.pop(name, None)
202
+ if name in self._settings:
203
+ self._settings[name].set_value(value, "runtime")
204
+ self._raise_errors_if_any()
205
+ else:
206
+ object.__setattr__(self, name, value)
92
207
 
93
- @property
94
- def configured(self):
95
- """Return True if the settings have already been configured."""
96
- return self._wrapped is not empty
208
+ def __repr__(self):
209
+ if not self.configured:
210
+ return "<Settings [Unevaluated]>"
211
+ return f'<Settings "{self._settings_module}">'
212
+
213
+
214
+ def _parse_env_value(value, annotation):
215
+ if not annotation:
216
+ raise ImproperlyConfigured("Type hint required to set from environment.")
217
+
218
+ if annotation is bool:
219
+ # Special case for bools
220
+ return value.lower() in ("true", "1", "yes")
221
+ elif annotation is str:
222
+ return value
223
+ else:
224
+ # Parse other types using JSON
225
+ try:
226
+ return json.loads(value)
227
+ except json.JSONDecodeError as e:
228
+ raise ImproperlyConfigured(
229
+ f"Invalid JSON value for setting: {e.msg}"
230
+ ) from e
97
231
 
98
232
 
99
233
  class SettingDefinition:
100
- """Store some basic info about default settings and where they came from"""
234
+ """Store detailed information about settings."""
101
235
 
102
- def __init__(self, name, value, annotation, module, required=False):
236
+ def __init__(
237
+ self, name, default_value=None, annotation=None, module=None, required=False
238
+ ):
103
239
  self.name = name
104
- self.value = value
240
+ self.default_value = default_value
105
241
  self.annotation = annotation
106
242
  self.module = module
107
243
  self.required = required
244
+ self.value = default_value
245
+ self.source = "default" # 'default', 'env', 'explicit', or 'runtime'
246
+ self.is_set = False # Indicates if the value was set explicitly
108
247
 
109
- def __str__(self):
110
- return self.name
248
+ def set_value(self, value, source):
249
+ self.check_type(value)
250
+ self.value = value
251
+ self.source = source
252
+ self.is_set = True
111
253
 
112
254
  def check_type(self, obj):
113
255
  if not self.annotation:
114
256
  return
115
257
 
116
258
  if not SettingDefinition._is_instance_of_type(obj, self.annotation):
117
- raise ValueError(
118
- f"The {self.name} setting must be of type {self.annotation}"
259
+ raise ImproperlyConfigured(
260
+ f"'{self.name}': Expected type {self.annotation}, but got {type(obj)}."
119
261
  )
120
262
 
121
263
  @staticmethod
@@ -152,153 +294,20 @@ class SettingDefinition:
152
294
  for i, item in enumerate(value)
153
295
  )
154
296
 
155
- raise ValueError("Unsupported type hint: %s" % type_hint)
156
-
157
-
158
- class Settings:
159
- def __init__(self, settings_module):
160
- self._default_settings = {}
161
- self._explicit_settings = set()
162
-
163
- # First load the global settings from plain
164
- self._load_module_settings(
165
- importlib.import_module("plain.runtime.global_settings")
166
- )
167
-
168
- # store the settings module in case someone later cares
169
- self.SETTINGS_MODULE = settings_module
170
-
171
- mod = importlib.import_module(self.SETTINGS_MODULE)
172
-
173
- # Keep a reference to the settings.py module path
174
- # so we can find files next to it (assume it's at the app root)
175
- self.path = Path(mod.__file__).resolve()
176
-
177
- # First, get all the default_settings from the INSTALLED_PACKAGES and set those values
178
- self._load_default_settings(mod)
179
- # Second, look at the environment variables and overwrite with those
180
- self._load_env_settings()
181
- # Finally, load the explicit settings from the settings module
182
- self._load_explicit_settings(mod)
183
- # Check for any required settings that are missing
184
- self._check_required_settings()
185
-
186
- def _load_default_settings(self, settings_module):
187
- # Get INSTALLED_PACKAGES from mod,
188
- # then (without populating packages) do a check for default_settings in each
189
- # app and load those now too.
190
- for entry in getattr(settings_module, "INSTALLED_PACKAGES", []):
191
- try:
192
- if isinstance(entry, PackageConfig):
193
- app_settings = entry.module.default_settings
194
- else:
195
- app_settings = importlib.import_module(f"{entry}.default_settings")
196
- except ModuleNotFoundError:
197
- continue
198
-
199
- self._load_module_settings(app_settings)
200
-
201
- def _load_module_settings(self, module):
202
- annotations = getattr(module, "__annotations__", {})
203
- settings = dir(module)
204
-
205
- for setting in settings:
206
- if setting.isupper():
207
- if hasattr(self, setting):
208
- raise ImproperlyConfigured("The %s setting is duplicated" % setting)
297
+ raise ValueError(f"Unsupported type hint: {type_hint}")
209
298
 
210
- setting_value = getattr(module, setting)
211
-
212
- # Set a simple attr on the settings object
213
- setattr(self, setting, setting_value)
214
-
215
- # Store a more complex setting reference for more detail
216
- self._default_settings[setting] = SettingDefinition(
217
- name=setting,
218
- value=setting_value,
219
- annotation=annotations.get(setting, ""),
220
- module=module,
221
- )
222
-
223
- # Store any annotations that didn't have a value (these are required settings)
224
- for setting, annotation in annotations.items():
225
- if setting not in self._default_settings:
226
- self._default_settings[setting] = SettingDefinition(
227
- name=setting,
228
- value=None,
229
- annotation=annotation,
230
- module=module,
231
- required=True,
232
- )
233
-
234
- def _load_env_settings(self):
235
- env_settings = {
236
- k[len(ENV_SETTINGS_PREFIX) :]: v
237
- for k, v in os.environ.items()
238
- if k.startswith(ENV_SETTINGS_PREFIX)
239
- }
240
- logger.debug("Loading environment settings: %s", env_settings)
241
- for setting, value in env_settings.items():
242
- if setting not in self._default_settings:
243
- # Ignore anything not defined in the default settings
244
- continue
245
-
246
- default_setting = self._default_settings[setting]
247
- if not default_setting.annotation:
248
- raise ValueError(
249
- f"Setting {setting} needs a type hint to be set from the environment"
250
- )
251
-
252
- if default_setting.annotation is bool:
253
- # Special case for bools
254
- parsed_value = value.lower() in ("true", "1", "yes")
255
- elif default_setting.annotation is str:
256
- parsed_value = value
257
- else:
258
- # Anything besides a string will be parsed as JSON
259
- # (works for ints, lists, etc.)
260
- parsed_value = json.loads(value)
261
-
262
- default_setting.check_type(parsed_value)
263
-
264
- setattr(self, setting, parsed_value)
265
- self._explicit_settings.add(setting)
266
-
267
- def _load_explicit_settings(self, settings_module):
268
- for setting in dir(settings_module):
269
- if setting.isupper():
270
- setting_value = getattr(settings_module, setting)
271
-
272
- if setting in self._default_settings:
273
- self._default_settings[setting].check_type(setting_value)
274
-
275
- setattr(self, setting, setting_value)
276
- self._explicit_settings.add(setting)
299
+ def __str__(self):
300
+ return f"SettingDefinition(name={self.name}, value={self.value}, source={self.source})"
277
301
 
278
- if hasattr(time, "tzset") and self.TIME_ZONE:
279
- # When we can, attempt to validate the timezone. If we can't find
280
- # this file, no check happens and it's harmless.
281
- zoneinfo_root = Path("/usr/share/zoneinfo")
282
- zone_info_file = zoneinfo_root.joinpath(*self.TIME_ZONE.split("/"))
283
- if zoneinfo_root.exists() and not zone_info_file.exists():
284
- raise ValueError("Incorrect timezone setting: %s" % self.TIME_ZONE)
285
- # Move the time zone info into os.environ. See ticket #2315 for why
286
- # we don't do this unconditionally (breaks Windows).
287
- os.environ["TZ"] = self.TIME_ZONE
288
- time.tzset()
289
302
 
290
- def _check_required_settings(self):
291
- # Required settings have to be explicitly defined (there is no default)
292
- # so we can check whether they have been set by the user
293
- required_settings = {k for k, v in self._default_settings.items() if v.required}
294
- if missing := required_settings - self._explicit_settings:
295
- raise ImproperlyConfigured(
296
- "The following settings are required: %s" % ", ".join(missing)
297
- )
303
+ class SettingsReference(str):
304
+ """
305
+ String subclass which references a current settings value. It's treated as
306
+ the value in memory but serializes to a settings.NAME attribute reference.
307
+ """
298
308
 
299
- # Seems like this could almost be removed
300
- def is_overridden(self, setting):
301
- return setting in self._explicit_settings
309
+ def __new__(self, value, setting_name):
310
+ return str.__new__(self, value)
302
311
 
303
- def __repr__(self):
304
- return f'<{self.__class__.__name__} "{self.SETTINGS_MODULE}">'
312
+ def __init__(self, value, setting_name):
313
+ self.setting_name = setting_name
plain/signing.py CHANGED
@@ -37,12 +37,10 @@ import base64
37
37
  import datetime
38
38
  import json
39
39
  import time
40
- import warnings
41
40
  import zlib
42
41
 
43
42
  from plain.runtime import settings
44
43
  from plain.utils.crypto import constant_time_compare, salted_hmac
45
- from plain.utils.deprecation import RemovedInDjango51Warning
46
44
  from plain.utils.encoding import force_bytes
47
45
  from plain.utils.module_loading import import_string
48
46
  from plain.utils.regex_helper import _lazy_re_compile
@@ -109,7 +107,7 @@ def _cookie_signer_key(key):
109
107
 
110
108
 
111
109
  def get_cookie_signer(salt="plain.signing.get_cookie_signer"):
112
- Signer = import_string(settings.SIGNING_BACKEND)
110
+ Signer = import_string(settings.COOKIE_SIGNING_BACKEND)
113
111
  return Signer(
114
112
  key=_cookie_signer_key(settings.SECRET_KEY),
115
113
  fallback_keys=map(_cookie_signer_key, settings.SECRET_KEY_FALLBACKS),
@@ -177,17 +175,13 @@ def loads(
177
175
 
178
176
 
179
177
  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
178
  def __init__(
185
179
  self,
186
- *args,
180
+ *,
187
181
  key=None,
188
182
  sep=":",
189
183
  salt=None,
190
- algorithm=None,
184
+ algorithm="sha256",
191
185
  fallback_keys=None,
192
186
  ):
193
187
  self.key = key or settings.SECRET_KEY
@@ -198,20 +192,8 @@ class Signer:
198
192
  )
199
193
  self.sep = sep
200
194
  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)
195
+ self.algorithm = algorithm
196
+
215
197
  if _SEP_UNSAFE.match(self.sep):
216
198
  raise ValueError(
217
199
  "Unsafe Signer separator: %r (cannot be empty or consist of "