Source code for loom.core.config.resolver
"""Pluggable config resolver protocol.
Resolvers extend the YAML loader with custom ``${prefix:key}`` interpolation,
enabling secret values to be fetched from external stores (e.g. AWS SSM,
Azure Key Vault) at parse time without ever writing them to disk.
Register resolvers via :func:`~loom.core.config.loader.load_config`::
cfg = load_config("s3://bucket/prod.yaml", resolvers=[MyResolver()])
YAML placeholders::
storage:
catalogs:
main:
token: ${myresolver:prod/databricks/token}
The resolver name becomes the placeholder prefix. Values are resolved
when OmegaConf materialises the config — i.e. at job startup, so secret
rotation takes effect on the next run without redeployment.
Built-in resolvers
------------------
:class:`~loom.core.config.ssm.SsmResolver` is the bundled implementation
for AWS SSM Parameter Store. Install ``loom-kernel[config-ssm]`` to use it::
from loom.core.config import load_config, SsmResolver
cfg = load_config("config/prod.yaml", resolvers=[SsmResolver()])
"""
from __future__ import annotations
import logging
from collections.abc import Sequence
from typing import Protocol, runtime_checkable
logger = logging.getLogger(__name__)
[docs]
@runtime_checkable
class ConfigResolver(Protocol):
"""Protocol for pluggable config value resolvers.
Implementors provide a *name* (used as the OmegaConf placeholder prefix)
and a *resolve* callable that fetches the actual value at parse time.
See :class:`~loom.core.config.ssm.SsmResolver` for the bundled AWS SSM
implementation. Custom resolvers only need to satisfy this two-member
protocol::
class VaultResolver:
@property
def name(self) -> str:
return "vault"
def resolve(self, key: str) -> object:
return vault_client.read_secret(key)
cfg = load_config("config/prod.yaml", resolvers=[VaultResolver()])
"""
@property
def name(self) -> str:
"""OmegaConf resolver prefix.
Used as the placeholder prefix in YAML: ``${<name>:key}``.
Must be unique across all registered resolvers.
Returns:
Resolver name string (e.g. ``"ssm"``, ``"keyvault"``).
"""
...
[docs]
def resolve(self, key: str) -> object:
"""Resolve *key* to its string value.
Called by OmegaConf when materialising ``${<name>:key}``
placeholders. Runs at config parse time (job startup), so the
returned value reflects the current state of the backing store.
Args:
key: Key portion of the placeholder after the prefix separator
(e.g. ``"/prod/token"`` for ``${ssm:/prod/token}``).
Returns:
Resolved value. Typically a string, but may be a structured type
when the resolver supports JSON navigation.
"""
...
[docs]
def default_resolvers() -> tuple[ConfigResolver, ...]:
"""Return loom's built-in resolvers: ``secrets`` and ``ssm``.
Both use the AWS SDK's default region and credential chain and create
their client lazily on the first resolution.
Returns:
A :class:`~loom.core.config.secrets.SecretsManagerResolver` followed
by a :class:`~loom.core.config.ssm.SsmResolver`, both freshly built.
"""
# Local imports keep this protocol module free of the AWS implementations.
from loom.core.config.secrets import SecretsManagerResolver
from loom.core.config.ssm import SsmResolver
return (SecretsManagerResolver(), SsmResolver())
[docs]
def merge_resolvers(
explicit: Sequence[ConfigResolver], defaults: Sequence[ConfigResolver]
) -> tuple[ConfigResolver, ...]:
"""Return *explicit* followed by the *defaults* whose names are still free.
A default is dropped when an explicit resolver takes its name or when a
resolver with that name is already registered in OmegaConf; the latter
is logged at DEBUG level.
Args:
explicit: Resolvers that keep their position and always win their
name.
defaults: Candidate resolvers appended after ``explicit`` when their
name is not taken.
Returns:
``explicit`` in order, followed by the kept ``defaults`` in order.
"""
# Local import keeps omegaconf out of the import of ``loom.core.config``.
from omegaconf import OmegaConf
taken = {resolver.name for resolver in explicit}
kept: list[ConfigResolver] = []
for resolver in defaults:
if resolver.name in taken:
continue
if OmegaConf.has_resolver(resolver.name):
logger.debug(
"config resolver %r already registered; loom default skipped", resolver.name
)
continue
kept.append(resolver)
return (*explicit, *kept)
[docs]
def with_default_resolvers(explicit: Sequence[ConfigResolver] = ()) -> tuple[ConfigResolver, ...]:
"""Return *explicit* followed by loom's built-in resolvers whose names are free.
Equivalent to ``merge_resolvers(explicit, default_resolvers())``; the
factories use it to register ``secrets`` and ``ssm`` behind user resolvers.
Args:
explicit: User resolvers that keep their position and always win
their name.
Returns:
``explicit`` in order, followed by the built-in resolvers whose
name is neither taken by ``explicit`` nor already registered.
"""
return merge_resolvers(explicit, default_resolvers())
__all__ = ["ConfigResolver", "default_resolvers", "merge_resolvers", "with_default_resolvers"]