Skip to content

Commit 2760b16

Browse files
Merge pull request #1452 from datajoint/fix/store-secrets-arbitrary-attr
feat(config): DJ_STORES env var + DJ_IGNORE_CONFIG_FILE flag + arbitrary .secrets/ attrs — 2.3
2 parents ad4f257 + 0b1aca6 commit 2760b16

3 files changed

Lines changed: 200 additions & 41 deletions

File tree

src/datajoint/settings.py

Lines changed: 78 additions & 31 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

@@ -691,26 +706,50 @@ def _load_secrets(self, secrets_dir: Path) -> None:
691706
self.database.password = db_password
692707
logger.debug(f"Loaded database.password from {secrets_dir}")
693708

694-
# Load per-store secrets (stores.<name>.access_key, stores.<name>.secret_key)
695-
# Iterate through all files in secrets directory
709+
# Load per-store secrets from any stores.<name>.<attr> file.
710+
# The attr name is recorded as-is on stores.<name>; this lets
711+
# plugin-registered adapters define their own secret fields
712+
# (e.g. a Bearer ``token`` for HTTP-based protocols) without
713+
# forcing AWS-style ``access_key`` / ``secret_key`` naming.
696714
if secrets_dir.is_dir():
697715
for secret_file in secrets_dir.iterdir():
698716
if not secret_file.is_file() or secret_file.name.startswith("."):
699717
continue
700718

701719
parts = secret_file.name.split(".")
702-
# Check for stores.<name>.access_key or stores.<name>.secret_key pattern
703720
if len(parts) == 3 and parts[0] == "stores":
704721
store_name, attr = parts[1], parts[2]
705-
if attr in ("access_key", "secret_key"):
706-
value = secret_file.read_text().strip()
707-
# Initialize store dict if needed
708-
if store_name not in self.stores:
709-
self.stores[store_name] = {}
710-
# Only set if not already present
711-
if attr not in self.stores[store_name]:
712-
self.stores[store_name][attr] = value
713-
logger.debug(f"Loaded stores.{store_name}.{attr} from {secrets_dir}")
722+
value = secret_file.read_text().strip()
723+
# Initialize store dict if needed
724+
if store_name not in self.stores:
725+
self.stores[store_name] = {}
726+
# Only set if not already present (config / env vars win)
727+
if attr not in self.stores[store_name]:
728+
self.stores[store_name][attr] = value
729+
logger.debug(f"Loaded stores.{store_name}.{attr} from {secrets_dir}")
730+
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")
714753

715754
@contextmanager
716755
def override(self, **kwargs: Any) -> Iterator["Config"]:
@@ -785,9 +824,13 @@ def save_template(
785824
786825
Credentials should NOT be stored in datajoint.json. Instead, use either:
787826
788-
- 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.)
789829
- The ``.secrets/`` directory (created alongside datajoint.json)
790830
831+
Set ``DJ_IGNORE_CONFIG_FILE=true`` to skip both ``datajoint.json`` and
832+
the secrets directory entirely (env-var-only configuration).
833+
791834
Parameters
792835
----------
793836
path : str or Path, optional
@@ -962,25 +1005,29 @@ def _create_config() -> Config:
9621005
"""Create and initialize the global config instance."""
9631006
cfg = Config()
9641007

965-
# Find config file (recursive parent search)
966-
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+
)
9671022

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

980-
# Find and load secrets
981-
secrets_dir = find_secrets_dir(config_path)
982-
if secrets_dir is not None:
983-
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)
9841031

9851032
# Set initial log level
9861033
logger.setLevel(cfg.loglevel)

src/datajoint/storage_adapter.py

Lines changed: 3 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -86,16 +86,9 @@ def get_storage_adapter(protocol: str) -> StorageAdapter | None:
8686

8787
def _discover_adapters() -> None:
8888
"""Load storage adapters from datajoint.storage entry points."""
89-
try:
90-
from importlib.metadata import entry_points
91-
except ImportError:
92-
logger.debug("importlib.metadata not available, skipping adapter discovery")
93-
return
94-
95-
try:
96-
eps = entry_points(group="datajoint.storage")
97-
except TypeError:
98-
eps = entry_points().get("datajoint.storage", [])
89+
from importlib.metadata import entry_points
90+
91+
eps = entry_points(group="datajoint.storage")
9992

10093
for ep in eps:
10194
if ep.name in _adapter_registry:

tests/unit/test_settings.py

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,125 @@ def test_secrets_do_not_override_existing(self, tmp_path):
487487
finally:
488488
cfg.stores = original_stores
489489

490+
def test_load_store_arbitrary_attr(self, tmp_path):
491+
"""Plugin-registered adapters can use arbitrary secret-field names."""
492+
# e.g. an HTTP-based protocol that authenticates with a Bearer token
493+
secrets_dir = tmp_path / SECRETS_DIRNAME
494+
secrets_dir.mkdir()
495+
(secrets_dir / "stores.bearer_store.token").write_text("dapibdfXXXX")
496+
(secrets_dir / "stores.bearer_store.api_key").write_text("ak_yyy")
497+
498+
cfg = settings.Config()
499+
original_stores = cfg.stores.copy()
500+
try:
501+
cfg._load_secrets(secrets_dir)
502+
503+
assert cfg.stores["bearer_store"]["token"] == "dapibdfXXXX"
504+
assert cfg.stores["bearer_store"]["api_key"] == "ak_yyy"
505+
finally:
506+
cfg.stores = original_stores
507+
508+
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+
490609

491610
class TestDisplaySettings:
492611
"""Test display-related settings."""

0 commit comments

Comments
 (0)