Skip to content

Commit 0b1aca6

Browse files
feat(config): DJ_STORES env var + DJ_IGNORE_CONFIG_FILE flag — new in 2.3
Adds env-var configuration for object stores so the DataJoint platform — and any env-var-only deployment — can configure plugin-registered storage adapters (Databricks Unity Catalog Volumes, custom HTTP stores, lab archive systems) without files on disk. - DJ_STORES (JSON-encoded) carries the entire `stores` dict in the same shape used in `datajoint.json`. Replaces the file's `stores` block when set. - DJ_IGNORE_CONFIG_FILE (default false) skips `datajoint.json`, the project `.secrets/`, and `/run/secrets/datajoint/` entirely. Hard guarantee that no file on disk leaks into config. Implementation notes: - New `Config.ignore_config_file` field (validation_alias DJ_IGNORE_CONFIG_FILE) auto-bound by pydantic-settings. - The `stores` field receives a `validation_alias` placeholder so pydantic-settings does NOT auto-bind DJ_STORES at Config() construction. Otherwise its built-in JSON parser intercepts before precedence logic runs and reports SettingsError instead of a clean ValueError. - New `Config._apply_stores_env()` parses DJ_STORES JSON, replaces self.stores wholesale, raises ValueError on bad JSON or non-object payloads. - `_create_config()` restructured to: skip file + secrets when ignore_config_file is set; apply DJ_STORES between file load and secrets fill so env wins over file and secrets only fill gaps. Functional precedence (high to low): programmatic > DJ_STORES > config file > `.secrets/stores.<name>.<attr>` (fills missing attrs only). Tests: new TestStoreEnv class with 8 tests; existing TestStoreSecrets and test_storage_adapter tests still pass. Companion docs: datajoint/datajoint-docs#172
1 parent 0b8e7f6 commit 0b1aca6

2 files changed

Lines changed: 166 additions & 19 deletions

File tree

src/datajoint/settings.py

Lines changed: 65 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -335,17 +335,32 @@ class Config(BaseSettings):
335335
jobs: JobsSettings = Field(default_factory=JobsSettings)
336336

337337
# Unified stores configuration (replaces external and object_storage)
338+
# ``validation_alias`` redirects pydantic-settings' env source away from the
339+
# natural ``DJ_STORES`` so it doesn't auto-parse on Config() construction.
340+
# ``DJ_STORES`` is handled by ``_apply_stores_env`` after the config file
341+
# load so env-var precedence is honored. *New in 2.3.*
338342
stores: dict[str, Any] = Field(
339343
default_factory=dict,
344+
validation_alias="_DJ_STORES_PYDANTIC_DISABLED",
340345
description="Unified object storage configuration. "
341346
"Use stores.default to designate default store. "
342-
"Configure named stores as stores.<name>.protocol, stores.<name>.location, etc.",
347+
"Configure named stores as stores.<name>.protocol, stores.<name>.location, etc. "
348+
"Set via DJ_STORES (JSON object) or in datajoint.json. *New in 2.3* for "
349+
"DJ_STORES env-var support.",
343350
)
344351

345352
# Top-level settings
346353
loglevel: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = Field(default="INFO", validation_alias="DJ_LOG_LEVEL")
347354
safemode: bool = True
348355

356+
ignore_config_file: bool = Field(
357+
default=False,
358+
validation_alias="DJ_IGNORE_CONFIG_FILE",
359+
description="If True, skip loading datajoint.json and the secrets directory. "
360+
"Intended for env-var-only deployments (e.g. the DataJoint platform). "
361+
"*New in 2.3.*",
362+
)
363+
349364
# Cache path for query results
350365
query_cache: Path | None = None
351366

@@ -713,6 +728,29 @@ def _load_secrets(self, secrets_dir: Path) -> None:
713728
self.stores[store_name][attr] = value
714729
logger.debug(f"Loaded stores.{store_name}.{attr} from {secrets_dir}")
715730

731+
def _apply_stores_env(self) -> None:
732+
"""Replace ``self.stores`` from the ``DJ_STORES`` env var if set.
733+
734+
``DJ_STORES`` holds a JSON object in the same shape as the ``stores``
735+
block of ``datajoint.json``. This lets env-var-only deployments
736+
configure plugin-registered storage adapters with arbitrary attr
737+
names (e.g. a Bearer ``token`` field) without negotiating an env-var
738+
naming scheme per attr.
739+
740+
*New in 2.3.*
741+
"""
742+
raw = os.environ.get("DJ_STORES")
743+
if not raw:
744+
return
745+
try:
746+
data = json.loads(raw)
747+
except json.JSONDecodeError as e:
748+
raise ValueError(f"DJ_STORES contains invalid JSON: {e}") from e
749+
if not isinstance(data, dict):
750+
raise ValueError(f"DJ_STORES must be a JSON object, got {type(data).__name__}")
751+
self.stores = data
752+
logger.debug("Loaded stores from DJ_STORES env var")
753+
716754
@contextmanager
717755
def override(self, **kwargs: Any) -> Iterator["Config"]:
718756
"""
@@ -786,9 +824,13 @@ def save_template(
786824
787825
Credentials should NOT be stored in datajoint.json. Instead, use either:
788826
789-
- Environment variables (``DJ_USER``, ``DJ_PASS``, ``DJ_HOST``, etc.)
827+
- Environment variables (``DJ_USER``, ``DJ_PASS``, ``DJ_HOST``,
828+
``DJ_STORES`` for JSON-encoded store configs, etc.)
790829
- The ``.secrets/`` directory (created alongside datajoint.json)
791830
831+
Set ``DJ_IGNORE_CONFIG_FILE=true`` to skip both ``datajoint.json`` and
832+
the secrets directory entirely (env-var-only configuration).
833+
792834
Parameters
793835
----------
794836
path : str or Path, optional
@@ -963,25 +1005,29 @@ def _create_config() -> Config:
9631005
"""Create and initialize the global config instance."""
9641006
cfg = Config()
9651007

966-
# Find config file (recursive parent search)
967-
config_path = find_config_file()
1008+
config_path: Path | None = None
1009+
if not cfg.ignore_config_file:
1010+
config_path = find_config_file()
1011+
if config_path is not None:
1012+
try:
1013+
cfg.load(config_path)
1014+
except Exception as e:
1015+
warnings.warn(f"Failed to load config from {config_path}: {e}")
1016+
else:
1017+
warnings.warn(
1018+
f"No {CONFIG_FILENAME} found. Using defaults and environment variables. "
1019+
f"Run `dj.config.save_template()` to create a template configuration.",
1020+
stacklevel=2,
1021+
)
9681022

969-
if config_path is not None:
970-
try:
971-
cfg.load(config_path)
972-
except Exception as e:
973-
warnings.warn(f"Failed to load config from {config_path}: {e}")
974-
else:
975-
warnings.warn(
976-
f"No {CONFIG_FILENAME} found. Using defaults and environment variables. "
977-
f"Run `dj.config.save_template()` to create a template configuration.",
978-
stacklevel=2,
979-
)
1023+
# DJ_STORES (if set) overrides the stores dict from the config file
1024+
cfg._apply_stores_env()
9801025

981-
# Find and load secrets
982-
secrets_dir = find_secrets_dir(config_path)
983-
if secrets_dir is not None:
984-
cfg._load_secrets(secrets_dir)
1026+
# Secrets fill missing attrs in whatever ended up in self.stores
1027+
if not cfg.ignore_config_file:
1028+
secrets_dir = find_secrets_dir(config_path)
1029+
if secrets_dir is not None:
1030+
cfg._load_secrets(secrets_dir)
9851031

9861032
# Set initial log level
9871033
logger.setLevel(cfg.loglevel)

tests/unit/test_settings.py

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -506,6 +506,107 @@ def test_load_store_arbitrary_attr(self, tmp_path):
506506
cfg.stores = original_stores
507507

508508

509+
class TestStoreEnv:
510+
"""Test DJ_STORES env var and DJ_IGNORE_CONFIG_FILE flag."""
511+
512+
def _isolate_filesystem(self, monkeypatch, tmp_path):
513+
"""chdir into a tmp_path with a .git sentinel so find_config_file stops there."""
514+
(tmp_path / ".git").mkdir()
515+
monkeypatch.chdir(tmp_path)
516+
# Defend against a /run/secrets/datajoint/ on the host
517+
monkeypatch.setattr(settings, "SYSTEM_SECRETS_DIR", tmp_path / "nonexistent-system-secrets")
518+
519+
def test_dj_stores_sets_stores_dict(self, monkeypatch, tmp_path):
520+
self._isolate_filesystem(monkeypatch, tmp_path)
521+
monkeypatch.setenv(
522+
"DJ_STORES",
523+
'{"uc":{"protocol":"http","token":"dapibd","workspace_url":"https://x"}}',
524+
)
525+
526+
with pytest.warns(UserWarning): # "No datajoint.json found"
527+
cfg = settings._create_config()
528+
529+
assert cfg.stores["uc"]["protocol"] == "http"
530+
assert cfg.stores["uc"]["token"] == "dapibd"
531+
assert cfg.stores["uc"]["workspace_url"] == "https://x"
532+
533+
def test_dj_stores_overrides_config_file(self, monkeypatch, tmp_path):
534+
self._isolate_filesystem(monkeypatch, tmp_path)
535+
(tmp_path / CONFIG_FILENAME).write_text('{"stores": {"main": {"protocol": "s3", "location": "from-file"}}}')
536+
monkeypatch.setenv(
537+
"DJ_STORES",
538+
'{"main": {"protocol": "http", "location": "from-env"}}',
539+
)
540+
541+
cfg = settings._create_config()
542+
543+
assert cfg.stores["main"]["protocol"] == "http"
544+
assert cfg.stores["main"]["location"] == "from-env"
545+
546+
def test_dj_stores_invalid_json_raises(self, monkeypatch, tmp_path):
547+
self._isolate_filesystem(monkeypatch, tmp_path)
548+
monkeypatch.setenv("DJ_STORES", "{not json")
549+
with pytest.raises(ValueError, match="DJ_STORES.*invalid JSON"):
550+
settings._create_config()
551+
552+
def test_dj_stores_non_object_raises(self, monkeypatch, tmp_path):
553+
self._isolate_filesystem(monkeypatch, tmp_path)
554+
monkeypatch.setenv("DJ_STORES", '["a", "b"]')
555+
with pytest.raises(ValueError, match="DJ_STORES must be a JSON object"):
556+
settings._create_config()
557+
558+
def test_dj_stores_plus_secrets_dir(self, monkeypatch, tmp_path):
559+
"""Secrets dir fills attrs that DJ_STORES omits."""
560+
self._isolate_filesystem(monkeypatch, tmp_path)
561+
# config file lets find_secrets_dir locate .secrets/ next to it
562+
(tmp_path / CONFIG_FILENAME).write_text("{}")
563+
secrets_dir = tmp_path / SECRETS_DIRNAME
564+
secrets_dir.mkdir()
565+
(secrets_dir / "stores.uc.token").write_text("from-secrets")
566+
monkeypatch.setenv("DJ_STORES", '{"uc": {"protocol": "http"}}')
567+
568+
cfg = settings._create_config()
569+
570+
assert cfg.stores["uc"]["protocol"] == "http"
571+
assert cfg.stores["uc"]["token"] == "from-secrets"
572+
573+
def test_ignore_config_file_skips_json(self, monkeypatch, tmp_path):
574+
self._isolate_filesystem(monkeypatch, tmp_path)
575+
(tmp_path / CONFIG_FILENAME).write_text('{"database": {"host": "should-not-load"}}')
576+
monkeypatch.setenv("DJ_IGNORE_CONFIG_FILE", "true")
577+
578+
cfg = settings._create_config()
579+
580+
assert cfg.database.host == "localhost"
581+
582+
def test_ignore_config_file_skips_secrets(self, monkeypatch, tmp_path):
583+
self._isolate_filesystem(monkeypatch, tmp_path)
584+
# Place secrets where find_secrets_dir would find them if not ignored
585+
monkeypatch.setattr(settings, "SYSTEM_SECRETS_DIR", tmp_path / SECRETS_DIRNAME)
586+
secrets_dir = tmp_path / SECRETS_DIRNAME
587+
secrets_dir.mkdir()
588+
(secrets_dir / "database.password").write_text("should-not-load")
589+
monkeypatch.setenv("DJ_IGNORE_CONFIG_FILE", "true")
590+
591+
cfg = settings._create_config()
592+
593+
assert cfg.database.password is None
594+
595+
def test_ignore_config_file_default_loads_both(self, monkeypatch, tmp_path):
596+
"""Default (env unset) preserves today's behavior."""
597+
self._isolate_filesystem(monkeypatch, tmp_path)
598+
(tmp_path / CONFIG_FILENAME).write_text('{"database": {"host": "from-file"}}')
599+
secrets_dir = tmp_path / SECRETS_DIRNAME
600+
secrets_dir.mkdir()
601+
(secrets_dir / "database.user").write_text("dbuser")
602+
monkeypatch.delenv("DJ_IGNORE_CONFIG_FILE", raising=False)
603+
604+
cfg = settings._create_config()
605+
606+
assert cfg.database.host == "from-file"
607+
assert cfg.database.user == "dbuser"
608+
609+
509610
class TestDisplaySettings:
510611
"""Test display-related settings."""
511612

0 commit comments

Comments
 (0)