Skip to content

Commit c486936

Browse files
Fix config precedence: environment variables now override config files
Following the 12-Factor App methodology, environment variables now take precedence over config file values. This is the standard DevOps practice for deployments where secrets and environment-specific settings should be injected via environment variables (Docker, Kubernetes, CI/CD). Priority order (highest to lowest): 1. Environment variables (DJ_*) 2. Secrets files (.secrets/) 3. Config file (datajoint.json) 4. Defaults Added ENV_VAR_MAPPING to track which settings have env var overrides. The _update_from_flat_dict() method now skips file values when the corresponding env var is set. Added test_env_var_overrides_config_file to verify the new behavior. Bump version to 2.0.0a3. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 0107d8d commit c486936

File tree

3 files changed

+68
-4
lines changed

3 files changed

+68
-4
lines changed

src/datajoint/settings.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828

2929
import json
3030
import logging
31+
import os
3132
import warnings
3233
from contextlib import contextmanager
3334
from copy import deepcopy
@@ -45,6 +46,18 @@
4546
SYSTEM_SECRETS_DIR = Path("/run/secrets/datajoint")
4647
DEFAULT_SUBFOLDING = (2, 2)
4748

49+
# Mapping of config keys to environment variables
50+
# Environment variables take precedence over config file values
51+
ENV_VAR_MAPPING = {
52+
"database.host": "DJ_HOST",
53+
"database.user": "DJ_USER",
54+
"database.password": "DJ_PASS",
55+
"database.port": "DJ_PORT",
56+
"external.aws_access_key_id": "DJ_AWS_ACCESS_KEY_ID",
57+
"external.aws_secret_access_key": "DJ_AWS_SECRET_ACCESS_KEY",
58+
"loglevel": "DJ_LOG_LEVEL",
59+
}
60+
4861
Role = Enum("Role", "manual lookup imported computed job")
4962
role_to_prefix = {
5063
Role.manual: "",
@@ -541,26 +554,47 @@ def load(self, filename: str | Path) -> None:
541554
self._config_path = filepath
542555

543556
def _update_from_flat_dict(self, data: dict[str, Any]) -> None:
544-
"""Update settings from a dict (flat dot-notation or nested)."""
557+
"""
558+
Update settings from a dict (flat dot-notation or nested).
559+
560+
Environment variables take precedence over config file values.
561+
If an env var is set for a setting, the file value is skipped.
562+
"""
545563
for key, value in data.items():
546564
# Handle nested dicts by recursively updating
547565
if isinstance(value, dict) and hasattr(self, key):
548566
group_obj = getattr(self, key)
549567
for nested_key, nested_value in value.items():
550568
if hasattr(group_obj, nested_key):
569+
# Check if env var is set for this nested key
570+
full_key = f"{key}.{nested_key}"
571+
env_var = ENV_VAR_MAPPING.get(full_key)
572+
if env_var and os.environ.get(env_var):
573+
logger.debug(f"Skipping {full_key} from file (env var {env_var} takes precedence)")
574+
continue
551575
setattr(group_obj, nested_key, nested_value)
552576
continue
553577

554578
# Handle flat dot-notation keys
555579
parts = key.split(".")
556580
if len(parts) == 1:
557581
if hasattr(self, key) and not key.startswith("_"):
582+
# Check if env var is set for this key
583+
env_var = ENV_VAR_MAPPING.get(key)
584+
if env_var and os.environ.get(env_var):
585+
logger.debug(f"Skipping {key} from file (env var {env_var} takes precedence)")
586+
continue
558587
setattr(self, key, value)
559588
elif len(parts) == 2:
560589
group, attr = parts
561590
if hasattr(self, group):
562591
group_obj = getattr(self, group)
563592
if hasattr(group_obj, attr):
593+
# Check if env var is set for this key
594+
env_var = ENV_VAR_MAPPING.get(key)
595+
if env_var and os.environ.get(env_var):
596+
logger.debug(f"Skipping {key} from file (env var {env_var} takes precedence)")
597+
continue
564598
setattr(group_obj, attr, value)
565599
elif len(parts) == 4:
566600
# Handle object_storage.stores.<name>.<attr> pattern

src/datajoint/version.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
11
# version bump auto managed by Github Actions:
22
# label_prs.yaml(prep), release.yaml(bump), post_release.yaml(edit)
33
# manually set this version will be eventually overwritten by the above actions
4-
__version__ = "2.0.0a2"
4+
__version__ = "2.0.0a3"

tests/test_settings.py

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -249,18 +249,48 @@ def test_override_restores_on_exception(self):
249249
class TestLoad:
250250
"""Test loading configuration."""
251251

252-
def test_load_config_file(self, tmp_path):
253-
"""Test loading configuration from file."""
252+
def test_load_config_file(self, tmp_path, monkeypatch):
253+
"""Test loading configuration from file.
254+
255+
Note: Environment variables take precedence over config file values.
256+
We need to clear DJ_HOST to test file loading.
257+
"""
254258
filename = tmp_path / "test_config.json"
255259
filename.write_text('{"database": {"host": "loaded_host"}}')
256260
original_host = dj.config.database.host
257261

262+
# Clear env var so file value takes effect
263+
monkeypatch.delenv("DJ_HOST", raising=False)
264+
258265
try:
259266
dj.config.load(filename)
260267
assert dj.config.database.host == "loaded_host"
261268
finally:
262269
dj.config.database.host = original_host
263270

271+
def test_env_var_overrides_config_file(self, tmp_path, monkeypatch):
272+
"""Test that environment variables take precedence over config file.
273+
274+
When DJ_HOST is set, loading a config file should NOT override the value.
275+
The env var value should be preserved.
276+
"""
277+
filename = tmp_path / "test_config.json"
278+
filename.write_text('{"database": {"host": "file_host"}}')
279+
original_host = dj.config.database.host
280+
281+
# Set env var - it should take precedence over file
282+
monkeypatch.setenv("DJ_HOST", "env_host")
283+
# Reset config to pick up new env var
284+
dj.config.database.host = "env_host"
285+
286+
try:
287+
dj.config.load(filename)
288+
# File value should be skipped because DJ_HOST is set
289+
# The env var value should be preserved
290+
assert dj.config.database.host == "env_host"
291+
finally:
292+
dj.config.database.host = original_host
293+
264294
def test_load_nonexistent_file(self):
265295
"""Test loading nonexistent file raises FileNotFoundError."""
266296
with pytest.raises(FileNotFoundError):

0 commit comments

Comments
 (0)