Files
timetracker/timetracker/config.py
T
Claude d8558eca89 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
2026-06-14 19:51:29 +00:00

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)