Runtime
Leverage user-settings at runtime.
Settings
Single-file
All of your settings go in app/settings.py
.
That's how you do it!
The file itself is not much different than how Django does it, but the location, and a strong recommendation to only use the one file makes a big difference.
Environment variables
It seems pretty well-accepted these days that storing settings in env vars is a good idea (12factor.net).
Your settings file should be looking at the environment for secrets or other values that might change between environments. For example:
# app/settings.py
STRIPE_SECRET_KEY = environ["STRIPE_SECRET_KEY"]
Local development
In local development,
you should use .env
files to set these values.
The .env
should then be in your .gitignore
!
It would seem like .env.dev
would be a good idea,
but there's a chicken-and-egg problem with that.
You would then have to prefix most (or all) of your local commands with PLAIN_ENV=dev
or otherwise configure your environment to do that for you.
Generally speaking,
a production .env
shouldn't be committed in your repo anyway,
so using .env
for local development is ok.
The downside to this is that it's harder to share your local settings with others,
but these also often contain real secrets which shouldn't be committed to your repo either!
More advanced .env
sharing patterns are currently beyond the scope of Plain...
Production
TODO
Minimum required settings
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = environ["SECRET_KEY"]
# SECURITY WARNING: don't run with debug turned on in production!
DEBUG = environ.get("DEBUG", "false").lower() in ("true", "1", "yes")
MIDDLEWARE = [
"plain.middleware.security.SecurityMiddleware",
"plain.sessions.middleware.SessionMiddleware",
"plain.middleware.common.CommonMiddleware",
"plain.csrf.middleware.CsrfViewMiddleware",
"plain.auth.middleware.AuthenticationMiddleware",
]
if DEBUG:
INSTALLED_PACKAGES += [
"plain.dev",
]
MIDDLEWARE += [
"plain.dev.RequestsMiddleware",
]
TIME_ZONE = "America/Chicago"
1import importlib
2import json
3import os
4import time
5import types
6import typing
7from pathlib import Path
8
9from plain.exceptions import ImproperlyConfigured
10from plain.packages import PackageConfig
11
12ENVIRONMENT_VARIABLE = "PLAIN_SETTINGS_MODULE"
13ENV_SETTINGS_PREFIX = "PLAIN_"
14CUSTOM_SETTINGS_PREFIX = "APP_"
15
16
17class Settings:
18 """
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.
26 """
27
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
33
34 def _setup(self):
35 if self.configured:
36 return
37 else:
38 self.configured = True
39
40 self._settings = {} # Maps setting names to SettingDefinition instances
41
42 # Determine the settings module
43 if self._settings_module is None:
44 self._settings_module = os.environ.get(ENVIRONMENT_VARIABLE, "settings")
45
46 # First load the global settings from plain
47 self._load_module_settings(
48 importlib.import_module("plain.runtime.global_settings")
49 )
50
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 )
58
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 )
184
185 def __getattr__(self, name):
186 # Avoid recursion by directly returning internal attributes
187 if not name.isupper():
188 return object.__getattribute__(self, name)
189
190 self._setup()
191
192 if name in self._settings:
193 return self._settings[name].value
194 else:
195 raise AttributeError(f"'Settings' object has no attribute '{name}'")
196
197 def __setattr__(self, name, value):
198 # Handle internal attributes without recursion
199 if not name.isupper():
200 object.__setattr__(self, name, value)
201 else:
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)
207
208 def __repr__(self):
209 if not self.configured:
210 return "<Settings [Unevaluated]>"
211 return f'<Settings "{self._settings_module}">'
212
213
214def _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
231
232
233class SettingDefinition:
234 """Store detailed information about settings."""
235
236 def __init__(
237 self, name, default_value=None, annotation=None, module=None, required=False
238 ):
239 self.name = name
240 self.default_value = default_value
241 self.annotation = annotation
242 self.module = module
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
247
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
253
254 def check_type(self, obj):
255 if not self.annotation:
256 return
257
258 if not SettingDefinition._is_instance_of_type(obj, self.annotation):
259 raise ImproperlyConfigured(
260 f"'{self.name}': Expected type {self.annotation}, but got {type(obj)}."
261 )
262
263 @staticmethod
264 def _is_instance_of_type(value, type_hint) -> bool:
265 # Simple types
266 if isinstance(type_hint, type):
267 return isinstance(value, type_hint)
268
269 # Union types
270 if (
271 typing.get_origin(type_hint) is typing.Union
272 or typing.get_origin(type_hint) is types.UnionType
273 ):
274 return any(
275 SettingDefinition._is_instance_of_type(value, arg)
276 for arg in typing.get_args(type_hint)
277 )
278
279 # List types
280 if typing.get_origin(type_hint) is list:
281 return isinstance(value, list) and all(
282 SettingDefinition._is_instance_of_type(
283 item, typing.get_args(type_hint)[0]
284 )
285 for item in value
286 )
287
288 # Tuple types
289 if typing.get_origin(type_hint) is tuple:
290 return isinstance(value, tuple) and all(
291 SettingDefinition._is_instance_of_type(
292 item, typing.get_args(type_hint)[i]
293 )
294 for i, item in enumerate(value)
295 )
296
297 raise ValueError(f"Unsupported type hint: {type_hint}")
298
299 def __str__(self):
300 return f"SettingDefinition(name={self.name}, value={self.value}, source={self.source})"
301
302
303class 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 """
308
309 def __new__(self, value, setting_name):
310 return str.__new__(self, value)
311
312 def __init__(self, value, setting_name):
313 self.setting_name = setting_name