Plain is headed towards 1.0! Subscribe for development updates →

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