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,198 @@
|
||||
"""Tests for the configuration reader in ``timetracker/config.py``."""
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import ImproperlyConfigured
|
||||
|
||||
from timetracker import config as config_module
|
||||
from timetracker.config import config
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_caches():
|
||||
"""Each test sees freshly parsed files."""
|
||||
config_module.reset_caches()
|
||||
yield
|
||||
config_module.reset_caches()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def env_file(tmp_path, monkeypatch):
|
||||
def _write(contents: str):
|
||||
path = tmp_path / ".env"
|
||||
path.write_text(contents)
|
||||
monkeypatch.setenv("ENV_FILE", str(path))
|
||||
config_module.reset_caches()
|
||||
return path
|
||||
|
||||
return _write
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def ini_file(tmp_path, monkeypatch):
|
||||
def _write(contents: str):
|
||||
path = tmp_path / "settings.ini"
|
||||
path.write_text(contents)
|
||||
monkeypatch.setenv("INI_FILE", str(path))
|
||||
config_module.reset_caches()
|
||||
return path
|
||||
|
||||
return _write
|
||||
|
||||
|
||||
def test_default_returned_when_unset():
|
||||
assert config("TOTALLY_UNSET_VALUE", default="fallback") == "fallback"
|
||||
|
||||
|
||||
def test_missing_without_default_raises():
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
config("TOTALLY_UNSET_VALUE")
|
||||
|
||||
|
||||
def test_env_var_overrides_default(monkeypatch):
|
||||
monkeypatch.setenv("SOME_SETTING", "from-env")
|
||||
assert config("SOME_SETTING", default="fallback") == "from-env"
|
||||
|
||||
|
||||
def test_priority_env_beats_files(monkeypatch, env_file, ini_file):
|
||||
ini_file("[timetracker]\nVALUE = from-ini\n")
|
||||
env_file("VALUE=from-dotenv\n")
|
||||
monkeypatch.setenv("VALUE", "from-env")
|
||||
assert config("VALUE") == "from-env"
|
||||
|
||||
|
||||
def test_priority_dotenv_beats_ini(env_file, ini_file):
|
||||
ini_file("[timetracker]\nVALUE = from-ini\n")
|
||||
env_file("VALUE=from-dotenv\n")
|
||||
assert config("VALUE") == "from-dotenv"
|
||||
|
||||
|
||||
def test_priority_ini_beats_default(ini_file):
|
||||
ini_file("[timetracker]\nVALUE = from-ini\n")
|
||||
assert config("VALUE", default="fallback") == "from-ini"
|
||||
|
||||
|
||||
def test_ini_preserves_key_case(ini_file):
|
||||
ini_file("[timetracker]\nSECRET_KEY = abc\n")
|
||||
assert config("SECRET_KEY") == "abc"
|
||||
|
||||
|
||||
# --- __FILE secret pointer -------------------------------------------------
|
||||
|
||||
|
||||
def test_file_pointer_read_and_stripped(tmp_path, monkeypatch):
|
||||
secret = tmp_path / "secret"
|
||||
secret.write_text("super-secret-value\n") # trailing newline must be stripped
|
||||
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
|
||||
assert config("SECRET_KEY", allow_file=True) == "super-secret-value"
|
||||
|
||||
|
||||
def test_file_pointer_ignored_without_allow_file(tmp_path, monkeypatch):
|
||||
secret = tmp_path / "secret"
|
||||
secret.write_text("ignored")
|
||||
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
|
||||
assert config("SECRET_KEY", default="fallback") == "fallback"
|
||||
|
||||
|
||||
def test_file_pointer_beats_env(tmp_path, monkeypatch):
|
||||
secret = tmp_path / "secret"
|
||||
secret.write_text("from-file")
|
||||
monkeypatch.setenv("SECRET_KEY__FILE", str(secret))
|
||||
monkeypatch.setenv("SECRET_KEY", "from-env")
|
||||
assert config("SECRET_KEY", allow_file=True) == "from-file"
|
||||
|
||||
|
||||
# --- casting ---------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw,expected",
|
||||
[
|
||||
("true", True),
|
||||
("True", True),
|
||||
("1", True),
|
||||
("yes", True),
|
||||
("on", True),
|
||||
("false", False),
|
||||
("0", False),
|
||||
("no", False),
|
||||
("", False),
|
||||
],
|
||||
)
|
||||
def test_cast_bool(monkeypatch, raw, expected):
|
||||
monkeypatch.setenv("FLAG", raw)
|
||||
assert config("FLAG", cast=bool) is expected
|
||||
|
||||
|
||||
def test_cast_list(monkeypatch):
|
||||
monkeypatch.setenv("HOSTS", "a.example, b.example , ,c.example")
|
||||
assert config("HOSTS", cast=list) == ["a.example", "b.example", "c.example"]
|
||||
|
||||
|
||||
def test_cast_int(monkeypatch):
|
||||
monkeypatch.setenv("COUNT", "42")
|
||||
assert config("COUNT", cast=int) == 42
|
||||
|
||||
|
||||
def test_cast_not_applied_to_default():
|
||||
# A None default passes through untouched even with a cast set.
|
||||
assert config("UNSET", default=None, cast=list) is None
|
||||
|
||||
|
||||
# --- required_in_prod ------------------------------------------------------
|
||||
|
||||
|
||||
def test_required_in_prod_raises_when_prod(monkeypatch):
|
||||
monkeypatch.setenv("DEBUG", "false")
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
config("SECRET_KEY", default="dev-default", required_in_prod=True)
|
||||
|
||||
|
||||
def test_required_in_prod_uses_default_in_debug(monkeypatch):
|
||||
monkeypatch.setenv("DEBUG", "true")
|
||||
assert config("SECRET_KEY", default="dev-default", required_in_prod=True) == (
|
||||
"dev-default"
|
||||
)
|
||||
|
||||
|
||||
def test_deprecated_prod_var_implies_production(monkeypatch):
|
||||
monkeypatch.delenv("DEBUG", raising=False)
|
||||
monkeypatch.setenv("PROD", "1")
|
||||
with pytest.raises(ImproperlyConfigured):
|
||||
config("SECRET_KEY", default="dev-default", required_in_prod=True)
|
||||
|
||||
|
||||
# --- .env parser edge cases ------------------------------------------------
|
||||
|
||||
|
||||
def test_env_parser_quotes_comments_and_export(env_file):
|
||||
env_file(
|
||||
"\n".join(
|
||||
[
|
||||
"# a comment line",
|
||||
"PLAIN=value",
|
||||
"export EXPORTED=exported-value",
|
||||
'DOUBLE="quoted value"',
|
||||
"SINGLE='single quoted'",
|
||||
"INLINE=value # trailing comment",
|
||||
'HASH_IN_QUOTES="a # b"',
|
||||
"EMPTY=",
|
||||
'QUOTED_THEN_COMMENT="keep" # drop',
|
||||
]
|
||||
)
|
||||
+ "\n"
|
||||
)
|
||||
assert config("PLAIN") == "value"
|
||||
assert config("EXPORTED") == "exported-value"
|
||||
assert config("DOUBLE") == "quoted value"
|
||||
assert config("SINGLE") == "single quoted"
|
||||
assert config("INLINE") == "value"
|
||||
assert config("HASH_IN_QUOTES") == "a # b"
|
||||
assert config("EMPTY", default="x") == ""
|
||||
assert config("QUOTED_THEN_COMMENT") == "keep"
|
||||
|
||||
|
||||
def test_missing_files_are_ignored(monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("ENV_FILE", str(tmp_path / "does-not-exist.env"))
|
||||
monkeypatch.setenv("INI_FILE", str(tmp_path / "does-not-exist.ini"))
|
||||
config_module.reset_caches()
|
||||
assert config("ANYTHING", default="fallback") == "fallback"
|
||||
Reference in New Issue
Block a user