"""FairDM configuration setup entry point.
This module provides ``setup()``, the single call a portal's settings module
makes to obtain a complete Django configuration.
**Resolved environment.** Taken literally from the ``DJANGO_ENV`` environment
variable, defaulting to ``production`` when unset. Not validated against an
allowlist: any name is valid, including one nothing ships an override for.
**Environment files**, read in this order, later files not overriding a
variable already set in the process environment except where noted:
1. ``stack.env``, beside the portal's ``base_dir``, if present.
2. ``stack.<environment>.env``, beside ``base_dir``, if present.
3. The explicit ``env_file=`` argument, if given — this one *does* overwrite
variables already set, including by the two files above.
**Layers**, applied in this order, each later layer overriding the same
setting in an earlier one:
1. The production baseline — every module under ``fairdm/conf/settings/``.
2. FairDM's own override module for the resolved environment, if it ships
one. Only ``development.py`` today.
3. Settings contributed by addons named in the ``addons=`` argument.
4. The portal's own override module for the resolved environment, resolved
beside its settings module regardless of directory name.
5. Assignments the portal's settings module makes after ``setup()`` returns —
the only supported way to override a FairDM-owned setting.
Layers 2 and 4 are both selected by existence, not from a fixed list of
permitted names: if no module named after the resolved environment exists,
that layer is skipped without error.
"""
import copy
import inspect
import logging
import os
from pathlib import Path
import environ
from django.core.exceptions import ImproperlyConfigured
from split_settings.tools import include
from . import record
from .addons import load_addons
logger = logging.getLogger(__name__)
#: Value types a layer can alter without rebinding the name. Deep-copied when
#: a snapshot is taken, so the change is still visible afterwards.
_MUTABLE_CONTAINERS = (list, dict, set)
def _uppercase_scope(scope: dict) -> dict:
"""Return every uppercase key in ``scope`` and its current value.
Uppercase is Django's settings convention, also used for the bookkeeping keys
``setup()`` injects itself.
Args:
scope: The namespace to read.
Returns:
The uppercase entries of ``scope``.
"""
return {key: value for key, value in scope.items() if key.isupper()}
def _snapshot_scope(scope: dict) -> dict:
"""Return ``_uppercase_scope`` with mutable containers copied out of harm's way.
A layer does not have to rebind a name to change a setting: ``development.py`` writes
``INSTALLED_APPS += [...]``, which mutates the baseline's own list in place. A snapshot
holding a reference to that list sees the mutation on both sides of the diff and credits
the baseline with a value it did not produce.
Args:
scope: The namespace to snapshot.
Returns:
The uppercase entries of ``scope``, with lists, dicts and sets deep-copied.
"""
snapshot = {}
for key, value in scope.items():
if not key.isupper():
continue
if isinstance(value, _MUTABLE_CONTAINERS):
try:
value = copy.deepcopy(value)
except Exception: # pragma: no cover — defensive
logger.debug(
f"Could not snapshot {key} for provenance; "
"falling back to identity comparison"
)
snapshot[key] = value
return snapshot
def _scratch_scope(scope: dict) -> dict:
"""Return a private copy of ``scope`` an addon's setup module can execute against.
``include()`` execs a module directly against whatever dict it is given, so a shallow copy
would still share the object a ``+=`` mutates in place. A discarded scratch copy that shared
the baseline's ``INSTALLED_APPS`` would corrupt the real scope before raising.
Only settings are copied. Django reads uppercase names and nothing else, so lowercase names
(the portal's imports and helpers, and ``__builtins__``) stay in the scratch scope by
reference. Copying those too would rebind a container the portal shares with another module
when the copies merge back.
Args:
scope: The namespace to copy.
Returns:
A scope whose uppercase mutable containers are deep-copied.
"""
scratch = {}
for key, value in scope.items():
if key.isupper() and isinstance(value, _MUTABLE_CONTAINERS):
try:
value = copy.deepcopy(value)
except Exception: # pragma: no cover — defensive
logger.debug(
f"Could not snapshot {key} for an addon's scratch scope; "
"falling back to a shared reference"
)
scratch[key] = value
return scratch
def _differs(before_value, after_value) -> bool:
"""Return whether a layer changed a container, comparing by value.
Args:
before_value: The container before the layer ran.
after_value: The container after the layer ran.
Returns:
``True`` when the values differ, or when they cannot be compared and are not identical.
"""
try:
return bool(before_value != after_value)
except Exception: # pragma: no cover — defensive
return before_value is not after_value
def _written_keys(before: dict, after: dict) -> list[str]:
"""Return the uppercase keys a layer wrote.
That is the names it introduced, the names it rebound, and the containers it mutated in place.
Args:
before: The snapshot taken before the layer ran.
after: The scope after the layer ran.
Returns:
The written setting names, sorted.
"""
written = []
for key, value in after.items():
if key not in before:
written.append(key)
elif isinstance(value, _MUTABLE_CONTAINERS):
if _differs(before[key], value):
written.append(key)
elif before[key] is not value:
written.append(key)
return sorted(written)
[docs]
def setup(
apps: list[str] | None = None,
addons: list[str] | None = None,
base_dir: Path | None = None,
env_file: str | None = None,
) -> None:
"""Initialize FairDM configuration with environment-specific settings.
The main entry point for portal configuration. See the module docstring for the resolved
environment, the environment files and the five layers this composes into the caller's
global namespace.
Args:
apps: List of portal-specific Django apps to include in INSTALLED_APPS.
addons: List of FairDM addon packages to enable.
base_dir: Project base directory (auto-detected if not provided).
env_file: Optional path to .env file to load.
Raises:
ImproperlyConfigured: An addon's setup module fails while applying in production.
Example:
>>> import fairdm
>>> fairdm.setup(
... apps=["my_portal_app"],
... addons=["fairdm_discussions"],
... )
"""
apps = apps or []
addons = addons or []
env_profile = os.environ.get("DJANGO_ENV", "production")
logger.info(f"🚀 FairDM Configuration: {env_profile} environment")
caller_globals = inspect.stack()[1][0].f_globals
# Captured before `__file__` is overwritten below for split_settings. A settings module
# with no usable `__file__` (generated, or imported from an archive) cannot be anchored,
# so the portal override lookup is skipped.
portal_settings_dir: Path | None = None
caller_file = caller_globals.get("__file__")
if caller_file:
try:
portal_settings_dir = Path(caller_file).resolve(strict=True).parent
except OSError:
portal_settings_dir = None
if portal_settings_dir is None:
logger.warning(
"Could not determine the portal's settings module directory; "
"its environment override module (if any) will not be looked up."
)
if not base_dir:
base_dir = Path(caller_globals["__file__"]).resolve(strict=True).parent.parent
from .environment import env
env_files_to_load = []
if (base_dir / "stack.env").exists():
env_files_to_load.append(str(base_dir / "stack.env"))
env_specific_file = base_dir / f"stack.{env_profile}.env"
if env_specific_file.exists():
env_files_to_load.append(str(env_specific_file))
if env_file and Path(env_file).exists():
env_files_to_load.append(env_file)
for env_path in env_files_to_load:
# Only the explicit file overwrites variables already set.
is_custom_file = env_path == env_file
environ.Env.read_env(env_path, overwrite=is_custom_file)
logger.debug(
f"Loaded environment file: {env_path} (overwrite={is_custom_file})"
)
caller_globals.update(
{
"env": env,
"BASE_DIR": base_dir,
"FAIRDM_APPS": apps,
"DJANGO_ENV": env_profile,
"__file__": os.path.realpath(__file__),
}
)
# Each layer's contribution is the delta of the scope's uppercase keys around its
# `include()` call.
record.reset()
logger.info("Loading production baseline settings...")
# Order matters: later modules read names the earlier ones define.
settings_modules = [
"settings/apps.py",
"settings/security.py",
"settings/database.py",
"settings/cache.py",
"settings/static_media.py",
"settings/celery.py",
"settings/auth.py",
"settings/logging.py",
"settings/email.py",
"settings/addons.py",
"settings/api.py",
]
before = _snapshot_scope(caller_globals)
include(*settings_modules, scope=caller_globals)
after = _uppercase_scope(caller_globals)
record.add_layer(
"baseline",
str(Path(__file__).parent / "settings"),
True,
_written_keys(before, after),
)
# Layer 2. Selected by existence: any environment name is looked up the same way.
fairdm_override = Path(__file__).parent / f"{env_profile}.py"
fairdm_override_found = fairdm_override.exists()
before = _snapshot_scope(caller_globals)
if fairdm_override_found:
logger.info(
f"Applying FairDM {env_profile} overrides from {fairdm_override.name}"
)
include(fairdm_override.name, scope=caller_globals)
after = _uppercase_scope(caller_globals)
record.add_layer(
"fairdm override",
str(fairdm_override),
fairdm_override_found,
_written_keys(before, after),
)
# Layer 3. Each addon applies to a scratch scope and merges only on success, so a
# module that raises partway leaves no partial writes. A failure fails fast in
# production and warns and skips elsewhere, like an addon that cannot be found.
applied_addon_modules: list[str] = []
before = _snapshot_scope(caller_globals)
if addons:
for addon_name, module_path in load_addons(addons, env_profile):
scratch = _scratch_scope(caller_globals)
try:
include(module_path, scope=scratch)
except Exception as exc:
message = (
f"Addon '{addon_name}' setup module raised while applying "
f"its settings: {exc}"
)
if env_profile == "production":
raise ImproperlyConfigured(message) from exc
logger.warning(message)
continue
caller_globals.update(scratch)
applied_addon_modules.append(module_path)
after = _uppercase_scope(caller_globals)
record.add_layer(
"addons",
", ".join(applied_addon_modules) if applied_addon_modules else None,
bool(applied_addon_modules),
_written_keys(before, after),
)
# Layer 4. Selected by existence, and skipped without error if absent.
portal_override: Path | None = None
portal_override_found = False
before = _snapshot_scope(caller_globals)
if portal_settings_dir is not None:
portal_override = portal_settings_dir / f"{env_profile}.py"
portal_override_found = portal_override.exists()
if portal_override_found:
logger.info(
f"Applying portal {env_profile} overrides from {portal_override}"
)
include(str(portal_override), scope=caller_globals)
after = _uppercase_scope(caller_globals)
record.add_layer(
"portal override",
str(portal_override) if portal_override else None,
portal_override_found,
_written_keys(before, after),
)
# Validation belongs to Django's check framework (`fairdm/conf/checks.py`), except this
# rule: django-parler enforces it when any parler-model app imports, before checks run,
# and this is the only point ahead of every such app.
from django.conf import global_settings
from fairdm.conf.checks import raise_on_parler_languages_mismatch
raise_on_parler_languages_mismatch(
caller_globals.get("LANGUAGES", global_settings.LANGUAGES),
caller_globals.get("PARLER_LANGUAGES", {}),
caller_globals.get("PARLER_DEFAULT_LANGUAGE_CODE"),
)
logger.info("✅ Configuration complete")
from .addons import addon_urls # noqa: F401