Add unified config system (issue #24)
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
This commit is contained in:
@@ -0,0 +1,195 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user