Source code for ursa.store.factory
"""Config loading + `get_store()` factory.
Search order for the YAML config:
1. Explicit `path` argument
2. `URSA_CONFIG` env var
3. `./ursa.yaml` in the current working directory
4. Packaged `ursa.default.yaml`
The packaged config is profile-agnostic; bucket and credential routing
based on `CONSTELLATION_PROFILE` happens inside constellation-utils.
"""
from __future__ import annotations
import os
from importlib import resources
from pathlib import Path
from typing import Any
import yaml
from ursa.store.backends.local import build_local_store
from ursa.store.backends.r2 import build_r2_store
from ursa.store.base import ObjectStore
from ursa.store.config import (
LocalStoreConfig,
R2StoreConfig,
UrsaConfig,
)
__all__ = [
"load_config",
"get_store",
"ConfigNotFoundError",
]
_DEFAULT_ROLE = "default"
_CONFIG_ENV_VAR = "URSA_CONFIG"
_CWD_FILENAME = "ursa.yaml"
_PACKAGED_CONFIG = "ursa.default.yaml"
[docs]
class ConfigNotFoundError(FileNotFoundError):
"""Raised when an explicit config path argument or `URSA_CONFIG` env
points at a file that doesn't exist. (The packaged-default fallback
is always available, so this only fires when the caller explicitly
asked for a path that wasn't found.)"""
[docs]
def _resolve_config_source(path: Path | None) -> tuple[str, Any]:
"""Resolve the config source.
Returns ``(label, file_obj_or_path)`` where the file contents will
be parsed as YAML. ``label`` is used in error messages.
"""
if path is not None:
if not path.is_file():
raise ConfigNotFoundError(f"explicit config path not found: {path}")
return (str(path), path)
env = os.environ.get(_CONFIG_ENV_VAR)
if env:
env_path = Path(env)
if not env_path.is_file():
raise ConfigNotFoundError(
f"${_CONFIG_ENV_VAR} points at {env_path} which does not exist"
)
return (str(env_path), env_path)
cwd_path = Path.cwd() / _CWD_FILENAME
if cwd_path.is_file():
return (str(cwd_path), cwd_path)
return (
f"<packaged {_PACKAGED_CONFIG}>",
resources.files("ursa.config") / _PACKAGED_CONFIG,
)
[docs]
def load_config(path: Path | None = None) -> UrsaConfig:
"""Load and validate the Ursa config from a YAML file."""
label, source = _resolve_config_source(path)
if isinstance(source, Path):
with source.open("r") as f:
raw: Any = yaml.safe_load(f)
else:
# importlib.resources.abc.Traversable — supports .open
with source.open("r") as f:
raw = yaml.safe_load(f)
if not isinstance(raw, dict):
raise ValueError(f"{label} did not parse to a mapping (got {type(raw).__name__})")
return UrsaConfig.model_validate(raw)
[docs]
def get_store(
role: str = _DEFAULT_ROLE,
*,
config: UrsaConfig | None = None,
config_path: Path | None = None,
) -> ObjectStore:
"""Return the `ObjectStore` bound to `role` in the active config.
Pass an already-loaded `config` to avoid re-reading the YAML, or
`config_path` to override the search order. Without either, the
YAML is resolved through the search-order described in the module
docstring.
Raises `KeyError` (with the available role list) if `role` is not
defined in the active config.
"""
cfg = config if config is not None else load_config(config_path)
try:
store_cfg = cfg.stores[role]
except KeyError:
available = sorted(cfg.stores.keys())
raise KeyError(
f"no store configured for role {role!r}; available roles: {available}"
) from None
return _build(store_cfg, role=role)
[docs]
def _build(
cfg: R2StoreConfig | LocalStoreConfig,
*,
role: str,
) -> ObjectStore:
if isinstance(cfg, R2StoreConfig):
return build_r2_store(cfg, role=role)
if isinstance(cfg, LocalStoreConfig):
return build_local_store(cfg, role=role)
# Pydantic's discriminated union should make this unreachable;
# guard anyway so future additions don't silently fall through.
raise NotImplementedError(f"unhandled store backend config: {type(cfg).__name__}")