Skip to content

Path interpolation

Most apps run multiple stages (dev / staging / prod), tenants, or regions, and want one config class to cover all of them. vaultly's path interpolation makes this declarative:

class App(SecretModel):
    stage: str
    db_password: str = Secret("/{stage}/db/password")


prod = App(stage="prod", backend=...)  # reads /prod/db/password
dev  = App(stage="dev",  backend=...)  # reads /dev/db/password

How {var} resolves

Placeholders use str.format-style syntax. At fetch time, vaultly fills them with the model's non-secret scalar fields:

class App(SecretModel):
    stage: str          # used for {stage}
    region: str         # used for {region}
    db_password: str = Secret("/{region}/{stage}/db/password")

When constructing, vaultly walks every secret's path, extracts placeholder names, and verifies each one matches a non-secret field on the root model. A typo raises MissingContextVariableError immediately:

class Broken(SecretModel):
    stage: str
    db: str = Secret("/{stge}/db/password")   # {stge} typo


Broken(stage="prod", backend=...)
# > MissingContextVariableError: secret field Broken.db references {stge},
#   but no such field exists on the root model

Nested models share the root context

Nested SecretModel fields don't get their own context — they always resolve against the root. This is by design: it makes path templates predictable and avoids ambiguity when the same {var} could come from multiple levels.

class DbConfig(SecretModel):
    password: str = Secret("/{stage}/db/password")
    pool_size: int = Secret("/{stage}/db/pool_size")
    # {stage} resolves against the *parent's* `stage` field

class App(SecretModel):
    stage: str
    db: DbConfig

A DbConfig constructed standalone (without a parent) defers path validation — the model may be wrapped into a parent later. A standalone DbConfig that's never wrapped surfaces the unresolved {var} as MissingContextVariableError on the first fetch attempt.

Allowed placeholders

Form Supported? Notes
{name} yes The standard case.
{{literal}} yes Escaped braces — passes through as {literal}.
{0} (positional) no Surfaces as MissingContextVariableError.
{x.attr} no Same — we don't follow attribute paths.
{x[0]} no Same — we don't follow indexing.

The four edge cases above are caught at fetch time with a clear MissingContextVariableError, never as a generic KeyError / AttributeError.

When the value contains placeholders

The result of path.format(**context) is the resolved path used as the cache key. If two distinct fields produce the same resolved path, they share a single cache entry — and a single backend fetch.

This is sometimes useful (one secret used twice, one backend call) and sometimes surprising (Secret(...) and Secret(...) declared twice for the same path don't double-fetch). When in doubt, give each field a distinct path or rely on the Secret reference for the exact behavior.