d8558eca89
Introduce timetracker/config.py with a single config() helper that resolves settings from a fixed priority chain: NAME__FILE (opt-in secret) -> env var -> .env -> settings.ini -> in-code default. Supports type casting (bool/list/int/Path), file-based secrets with .strip(), and required_in_prod validation. Migrate settings.py off the previous ad-hoc idioms: - DEBUG via config() (PROD kept as deprecated alias) - SECRET_KEY required in prod, supports SECRET_KEY__FILE - APP_URL derives ALLOWED_HOSTS and CSRF_TRUSTED_ORIGINS (kept separate, each independently overridable); ALLOWED_HOSTS is now configurable - TZ and DATA_DIR via config() Fix DATA_DIR inconsistency: entrypoint.sh now reads DATA_DIR (was hardcoded) so the bash bootstrap and Django agree on the database directory. Document the container/entrypoint-only flags (PUID/PGID/ CREATE_DEFAULT_SUPERUSER/STAGING/LOAD_SAMPLE_DATA) as bash concerns. Update deployment configs to set APP_URL (and DEBUG), add docs/configuration.md, settings.ini.example, regrouped .env.example, CLAUDE.md, and tests. https://claude.ai/code/session_01FFn8BiGrQpEJarC8xGse8s
196 lines
6.7 KiB
Python
196 lines
6.7 KiB
Python
"""
|
|
Centralized configuration reading for timetracker.
|
|
|
|
Every configurable Django setting is resolved through :func:`config`, which
|
|
consults several sources in a fixed priority order (highest first):
|
|
|
|
1. ``NAME__FILE`` — path to a file whose *stripped* contents are the value.
|
|
Only consulted when the setting opts in with
|
|
``allow_file=True``. Intended for Docker/Kubernetes
|
|
secrets, which are mounted as files rather than env vars.
|
|
2. ``NAME`` — a real process environment variable.
|
|
3. ``.env`` file — ``KEY=value`` lines (see the supported syntax below).
|
|
4. ``settings.ini`` — the ``[timetracker]`` section, parsed with
|
|
:mod:`configparser`.
|
|
5. ``default`` — the in-code fallback passed to :func:`config`.
|
|
|
|
If no source supplies a value and no ``default`` is given, an
|
|
:class:`~django.core.exceptions.ImproperlyConfigured` error is raised.
|
|
|
|
``.env`` syntax supported:
|
|
|
|
- ``KEY=value`` and ``export KEY=value``
|
|
- blank lines and ``#`` full-line comments
|
|
- single- or double-quoted values (the surrounding quotes are stripped); a
|
|
``#`` inside quotes is treated literally
|
|
- an inline ``# comment`` after an *unquoted* value
|
|
|
|
Deliberately NOT supported (documented limits, not bugs):
|
|
|
|
- variable interpolation (``${OTHER}``)
|
|
- multiline values
|
|
|
|
File locations default to ``.env`` and ``settings.ini`` next to the project
|
|
root and can be overridden with the ``ENV_FILE`` / ``INI_FILE`` environment
|
|
variables. Missing files are silently ignored so env-only deployments are
|
|
unaffected.
|
|
"""
|
|
|
|
import os
|
|
from configparser import ConfigParser
|
|
from pathlib import Path
|
|
from typing import Any, Callable
|
|
|
|
from django.core.exceptions import ImproperlyConfigured
|
|
|
|
BASE_DIR = Path(__file__).resolve().parent.parent
|
|
|
|
# Sentinel distinguishing "no default supplied" from an explicit ``None``.
|
|
NOT_SET: Any = object()
|
|
|
|
INI_SECTION = "timetracker"
|
|
|
|
_env_file_cache: dict[str, str] | None = None
|
|
_ini_file_cache: dict[str, str] | None = None
|
|
|
|
|
|
def _unquote(value: str) -> str:
|
|
"""Strip surrounding quotes, or an inline comment from an unquoted value."""
|
|
if not value:
|
|
return value
|
|
quote = value[0]
|
|
if quote in "\"'":
|
|
closing = value.find(quote, 1)
|
|
if closing != -1:
|
|
return value[1:closing]
|
|
# Opening quote with no match: drop it and keep the rest verbatim.
|
|
return value[1:]
|
|
comment_index = value.find("#")
|
|
if comment_index != -1:
|
|
value = value[:comment_index]
|
|
return value.strip()
|
|
|
|
|
|
def _parse_env_file(path: Path) -> dict[str, str]:
|
|
values: dict[str, str] = {}
|
|
for raw_line in path.read_text().splitlines():
|
|
line = raw_line.strip()
|
|
if not line or line.startswith("#"):
|
|
continue
|
|
if line.startswith("export "):
|
|
line = line[len("export ") :].lstrip()
|
|
if "=" not in line:
|
|
continue
|
|
name, _, value = line.partition("=")
|
|
name = name.strip()
|
|
if not name:
|
|
continue
|
|
values[name] = _unquote(value.strip())
|
|
return values
|
|
|
|
|
|
def _load_env_file() -> dict[str, str]:
|
|
global _env_file_cache
|
|
if _env_file_cache is None:
|
|
path = Path(os.environ.get("ENV_FILE", BASE_DIR / ".env"))
|
|
_env_file_cache = _parse_env_file(path) if path.is_file() else {}
|
|
return _env_file_cache
|
|
|
|
|
|
def _load_ini_file() -> dict[str, str]:
|
|
global _ini_file_cache
|
|
if _ini_file_cache is None:
|
|
path = Path(os.environ.get("INI_FILE", BASE_DIR / "settings.ini"))
|
|
if path.is_file():
|
|
parser = ConfigParser()
|
|
# Preserve key case; ConfigParser lowercases option names by default.
|
|
parser.optionxform = str # type: ignore[assignment, method-assign]
|
|
parser.read(path)
|
|
_ini_file_cache = (
|
|
dict(parser[INI_SECTION]) if parser.has_section(INI_SECTION) else {}
|
|
)
|
|
else:
|
|
_ini_file_cache = {}
|
|
return _ini_file_cache
|
|
|
|
|
|
def reset_caches() -> None:
|
|
"""Clear parsed-file caches. Intended for use in tests."""
|
|
global _env_file_cache, _ini_file_cache
|
|
_env_file_cache = None
|
|
_ini_file_cache = None
|
|
|
|
|
|
def _cast_value(value: str, cast: Callable[[str], Any] | None) -> Any:
|
|
if cast is None:
|
|
return value
|
|
if cast is bool:
|
|
return value.strip().lower() in {"true", "1", "yes", "on"}
|
|
if cast is list:
|
|
return [item.strip() for item in value.split(",") if item.strip()]
|
|
return cast(value)
|
|
|
|
|
|
def _resolve_raw(name: str, allow_file: bool) -> str | None:
|
|
"""Return the first raw string from the source chain, or ``None``."""
|
|
if allow_file:
|
|
file_pointer = os.environ.get(f"{name}__FILE")
|
|
if file_pointer:
|
|
return Path(file_pointer).read_text().strip()
|
|
if name in os.environ:
|
|
return os.environ[name]
|
|
env_file = _load_env_file()
|
|
if name in env_file:
|
|
return env_file[name]
|
|
ini_file = _load_ini_file()
|
|
if name in ini_file:
|
|
return ini_file[name]
|
|
return None
|
|
|
|
|
|
def _debug_enabled() -> bool:
|
|
"""Whether the app runs in DEBUG mode, mirroring ``settings.DEBUG``.
|
|
|
|
Defaults to on for local development; turned off by ``DEBUG=false`` or the
|
|
deprecated ``PROD`` env var. Used to decide whether ``required_in_prod``
|
|
settings may fall back to a development default.
|
|
"""
|
|
raw = _resolve_raw("DEBUG", allow_file=False)
|
|
if raw is not None:
|
|
return _cast_value(raw, bool)
|
|
return not bool(os.environ.get("PROD"))
|
|
|
|
|
|
def config(
|
|
name: str,
|
|
*,
|
|
default: Any = NOT_SET,
|
|
cast: Callable[[str], Any] | None = None,
|
|
allow_file: bool = False,
|
|
required_in_prod: bool = False,
|
|
) -> Any:
|
|
"""Resolve a configuration value from the source chain.
|
|
|
|
Args:
|
|
name: The setting / environment variable name.
|
|
default: Fallback when no source provides a value. If omitted, a
|
|
missing value raises ``ImproperlyConfigured``.
|
|
cast: Coercion applied to string values — ``bool``, ``list``, ``int``,
|
|
``Path``, or any callable taking a string. Defaults are returned
|
|
untouched.
|
|
allow_file: Whether to honor a ``NAME__FILE`` secret pointer.
|
|
required_in_prod: When ``True``, a missing value raises in production
|
|
(DEBUG off) even if a ``default`` is given, so insecure development
|
|
defaults never leak into a deployment.
|
|
"""
|
|
raw = _resolve_raw(name, allow_file=allow_file)
|
|
if raw is None:
|
|
if required_in_prod and not _debug_enabled():
|
|
raise ImproperlyConfigured(
|
|
f"{name} must be set in production (DEBUG is off)."
|
|
)
|
|
if default is NOT_SET:
|
|
raise ImproperlyConfigured(f"Required setting {name} is not configured.")
|
|
return default
|
|
return _cast_value(raw, cast)
|