Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
137 changes: 69 additions & 68 deletions Cargo.lock

Large diffs are not rendered by default.

20 changes: 10 additions & 10 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,21 @@ authors = ["Cevat Batuhan Tolon <cevatbatuhan.tolon@gmail.com>"]
# The engine. `default-features = false` because each crate names what it
# needs: the base wheel carries every feature a Python caller can reach,
# the remote wheel only what a document needs to travel.
dynamic-config = { version = "0.9", default-features = false }
dynamic-config = { version = "0.10", default-features = false }
# The store crates' shared internals: the redaction that keeps a password
# out of a store's description, by the same rule the store crates use
# rather than by a second copy of it.
dynamic-config-store-core = "0.9"
dynamic-config-store-core = "0.10"
# The eight stores. Caret, because a wheel built today should be able to
# pick up a store's patch release without this repository being re-cut.
dynamic-config-consul = "0.9"
dynamic-config-etcd = { version = "0.9", features = ["tls"] }
dynamic-config-firestore = "0.9"
dynamic-config-git = "0.9"
dynamic-config-nats = "0.9"
dynamic-config-redis = { version = "0.9", features = ["tls"] }
dynamic-config-s3 = "0.9"
dynamic-config-vault = "0.9"
dynamic-config-consul = "0.10"
dynamic-config-etcd = { version = "0.10", features = ["tls"] }
dynamic-config-firestore = "0.10"
dynamic-config-git = "0.10"
dynamic-config-nats = "0.10"
dynamic-config-redis = { version = "0.10", features = ["tls"] }
dynamic-config-s3 = "0.10"
dynamic-config-vault = "0.10"

# A wheel is downloaded, not compiled by whoever installs it, so the
# release profile is the one that matters.
Expand Down
1 change: 1 addition & 0 deletions book/src/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -543,6 +543,7 @@ instance carries `kind`, `path`, `origin_kind` and `origin`.
| `InvalidError` | The configuration as a whole was rejected — Pydantic's report is on `.errors`, scrubbed of input values, and `[]` for a schema that raises a message rather than a report (a dataclass, a `msgspec.Struct`) |
| `RemoteError` | A remote store could not be read — unreachable, refusing, malformed |
| `AuthError` | A credential was rejected, or could not be obtained. Distinct from `RemoteError` on purpose: waiting fixes one and not the other |
| `AbsentError` | The store answered, and the document is not there — a key deleted, a path that never existed. Distinct from `RemoteError`, which is an outage waiting cures, and from `MissingError`, which is a key absent from a document that *was* read |
| `DecryptError` | An encrypted source could not be decrypted |
| `BackendError` | The engine refused — a source added after loading, for instance |
| `NotInitialisedError` | `current()` before the first successful load |
2 changes: 1 addition & 1 deletion book/src/telemetry.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ does not fix its sources.

| Field | Type | Means |
|---|---|---|
| `kind` | `str` | The category: `io`, `parse`, `missing`, `type`, `env`, `invalid`, `remote`, `auth`, `decrypt`, `backend` — the names the [exception classes](reference.md#exceptions) carry |
| `kind` | `str` | The category: `io`, `parse`, `missing`, `type`, `env`, `invalid`, `remote`, `auth`, `absent`, `decrypt`, `backend` — the names the [exception classes](reference.md#exceptions) carry |
| `path` | `str` | The dotted key path, empty when the failure belongs to the load as a whole |
| `seconds_ago` | `float` | How long before the status was taken it was recorded |

Expand Down
2 changes: 2 additions & 0 deletions dynamic-config-python-remote/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,8 @@ is the door into this one.

## [Unreleased]

## 0.3.3 — 2026-08-23

## 0.3.2 — 2026-08-21

### Changed
Expand Down
2 changes: 1 addition & 1 deletion dynamic-config-python-remote/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ name = "dynamic-config-python-remote"
# release that moves the base wheel and not this one is fine (the extra keeps
# working); the reverse is not, which is why the compatibility floor is
# declared in `pyproject.toml` rather than assumed.
version = "0.3.2"
version = "0.3.3"
edition.workspace = true
# The organisation's one 1.88 floor — originally forced here anyway by
# `aws-sdk-sts`, `async-nats` and `redis`, measured rather than declared.
Expand Down
2 changes: 1 addition & 1 deletion dynamic-config-python-remote/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ classifiers = [
# remote path at all. It is not `==` for the reason the Rust crates are: a
# wheel that pinned its sibling exactly would make every base-wheel patch a
# forced upgrade of this one.
dependencies = ["dynamic-config-py>=0.3.2"]
dependencies = ["dynamic-config-py>=0.3.3"]
dynamic = ["version"]

[project.urls]
Expand Down
25 changes: 25 additions & 0 deletions dynamic-config-python/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,31 @@ breaking.

## [Unreleased]

## 0.3.3 — 2026-08-23

### Added

- **`AbsentError`.** A store that answers "that path holds nothing" raises
its own class rather than the base one. Distinct from `RemoteError`, which
is an outage waiting cures, and from `MissingError`, which is a key absent
from a document that *was* read — the three deserve different answers, and
a program that cannot tell them apart serves a deleted secret forever.
- **`fingerprint()`** — a stable digest of the configuration installed, or
`None` before the first load. `sha256:…`, over the resolved tree rather
than any rendering of it, so two processes agree whether their files were
written as TOML or as YAML.

**Safe to log**: every field named to `secrets=[..]` is masked by position
before hashing, so it moves when a secret appears or disappears and stays
put when one merely rotates — a digest that moved on rotation would be an
oracle for the value that moved it. It answers what
`status().generation` cannot: two processes on generation 4 have nothing
in particular in common.

### Changed

- The engine and the nine store crates move to **0.10**.

## 0.3.2 — 2026-08-21

### Changed
Expand Down
2 changes: 1 addition & 1 deletion dynamic-config-python/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ name = "dynamic-config-python"
# So this moves when *this* changes — a new Python API, a behaviour
# change, or an engine bump worth shipping — and `maturin upload
# --skip-existing` makes a release that changed nothing here a no-op.
version = "0.3.2"
version = "0.3.3"
edition.workspace = true
# Its own floor, not the workspace's 1.71: PyO3 needs more than the core does.
rust-version = "1.88"
Expand Down
2 changes: 2 additions & 0 deletions dynamic-config-python/python/dynamic_config/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ class Database:
from . import _core
from ._config import DynamicConfig
from ._core import (
AbsentError,
AuthError,
BackendError,
DecryptError,
Expand Down Expand Up @@ -108,6 +109,7 @@ class Database:
__engine_version__: str = _core.__engine_version__

__all__ = [
"AbsentError",
"AsyncRemoteSource",
"AuthError",
"BackendError",
Expand Down
19 changes: 19 additions & 0 deletions dynamic-config-python/python/dynamic_config/_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1552,6 +1552,25 @@ def snapshot(self) -> Snapshot:
"""The resolved section, without deserializing it into the model."""
return Snapshot(self._core.snapshot())

def fingerprint(self) -> str | None:
"""A stable digest of the configuration installed, or None.

``sha256:…``, computed over the resolved tree rather than any
rendering of it, so two processes agree whether their files were
written as TOML or as YAML.

**Safe to log.** Every field named to ``secrets=[..]`` is masked by
position before hashing, so the digest moves when a secret appears
or disappears and stays put when one merely rotates — a digest that
moved on rotation would be an oracle for the value that moved it.

What it is for is comparing two processes without comparing two
documents. ``status().generation`` counts *this* process's installs
and cannot answer that: two pods on generation 4 have nothing in
particular in common.
"""
return self._core.fingerprint()

# ── Telemetry ──────────────────────────────────────────────────────

def status(self) -> ConfigStatus:
Expand Down
2 changes: 2 additions & 0 deletions dynamic-config-python/python/dynamic_config/_core.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ class Config:
def explain(self, path: str) -> dict[str, Any]: ...
def check(self) -> dict[str, Any]: ...
def snapshot(self) -> Snapshot: ...
def fingerprint(self) -> str | None: ...
def release(self) -> None: ...
# Telemetry
def status(self) -> dict[str, Any]: ...
Expand Down Expand Up @@ -131,6 +132,7 @@ class InvalidError(DynamicConfigError):

class RemoteError(DynamicConfigError): ...
class AuthError(DynamicConfigError): ...
class AbsentError(DynamicConfigError): ...
class DecryptError(DynamicConfigError): ...
class BackendError(DynamicConfigError): ...

Expand Down
24 changes: 24 additions & 0 deletions dynamic-config-python/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1249,6 +1249,30 @@ impl Config {
.unbind())
}

/// A stable digest of the configuration installed, or `None` before
/// the first load. No parameters.
///
/// `sha256:…`, over the resolved tree rather than any rendering of it,
/// so two processes agree whether their files were written as TOML or
/// as YAML. **Safe to log**: every field named to `secrets=[..]` is
/// masked by position before hashing, so the digest moves when a secret
/// appears or disappears and stays put when one merely rotates — a
/// digest that moved on rotation would be an oracle for the value that
/// moved it.
///
/// What it is for is comparing two processes without comparing two
/// documents. `status().generation` counts *this* process's installs
/// and cannot answer that: two pods on generation 4 have nothing in
/// particular in common.
fn fingerprint(&self, py: Python<'_>) -> PyResult<Option<String>> {
let inner = Arc::clone(&self.inner);
let dynamic = self.inner.dynamic(py, &inner)?;

Ok(dynamic
.fingerprint()
.map(|fingerprint| (*fingerprint).clone()))
}

/// The resolved section as data, without the model. No parameters.
fn snapshot(&self, py: Python<'_>) -> PyResult<Snapshot> {
let inner = Arc::clone(&self.inner);
Expand Down
12 changes: 12 additions & 0 deletions dynamic-config-python/src/errors.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,16 @@ create_exception!(
Distinct from `RemoteError`, which is the store being unreachable: \
waiting will not fix this one."
);
create_exception!(
_core,
AbsentError,
DynamicConfigError,
"The store answered, and the document is not there.\n\n\
Distinct from `RemoteError`, which is the store being unreachable, and \
from `MissingError`, which is a key absent from a document that was \
read. Waiting fixes an unreachable store; waiting does not bring back a \
deleted secret."
);
create_exception!(
_core,
DecryptError,
Expand All @@ -98,6 +108,7 @@ fn class_for(py: Python<'_>, kind: ErrorKind) -> Bound<'_, PyType> {
ErrorKind::Invalid => py.get_type::<InvalidError>(),
ErrorKind::Remote => py.get_type::<RemoteError>(),
ErrorKind::Auth => py.get_type::<AuthError>(),
ErrorKind::Absent => py.get_type::<AbsentError>(),
ErrorKind::Decrypt => py.get_type::<DecryptError>(),
ErrorKind::Backend => py.get_type::<BackendError>(),
// `ErrorKind` is `#[non_exhaustive]`: a kind this binding predates
Expand Down Expand Up @@ -182,6 +193,7 @@ pub(crate) fn register(module: &Bound<'_, PyModule>) -> PyResult<()> {
module.add("InvalidError", module.py().get_type::<InvalidError>())?;
module.add("RemoteError", module.py().get_type::<RemoteError>())?;
module.add("AuthError", module.py().get_type::<AuthError>())?;
module.add("AbsentError", module.py().get_type::<AbsentError>())?;
module.add("DecryptError", module.py().get_type::<DecryptError>())?;
module.add("BackendError", module.py().get_type::<BackendError>())?;

Expand Down
122 changes: 122 additions & 0 deletions dynamic-config-python/tests/test_fingerprint.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
"""`fingerprint()`, and the two properties that make it printable.

The Rust suite's `tests/fingerprint.rs` pins these on that side. Asked
again here because the binding is where a fleet actually reads it: a pod
prints a string, another pod prints a string, and either they match or
one of them is running a configuration nobody meant to ship.
"""

from __future__ import annotations

import json
from pathlib import Path

import pytest

from dynamic_config import DynamicConfig, Values

SECRET = "hunter2-planted-secret"


def built(path: Path) -> DynamicConfig[Values]:
config = DynamicConfig(Values, "svc", secrets=["password"])
config.file(str(path))
config.init()

return config


@pytest.fixture
def written(tmp_path: Path):
def write(name: str, body: str) -> Path:
path = tmp_path / name
path.write_text(body, encoding="utf-8")

return path

return write


def test_the_same_configuration_fingerprints_the_same_in_any_format(written) -> None:
"""Two hosts, one configuration, two formats.

A digest that disagreed here would report drift between hosts that are
configured identically and merely chose different formats.
"""
as_json = written(
"config.json",
json.dumps({"svc": {"host": "db.internal", "port": 5432, "password": SECRET}}),
)
# Different syntax, different key order, the same configuration.
as_toml = written(
"config.toml",
f'[svc]\nport = 5432\npassword = "{SECRET}"\nhost = "db.internal"\n',
)

assert built(as_json).fingerprint() == built(as_toml).fingerprint()


def test_rotating_a_secret_does_not_move_it(written) -> None:
"""The property that makes it printable.

A digest that changed when a password changed would let anyone holding a
candidate password confirm it against a logged fingerprint.
"""
before = written(
"before.json",
json.dumps({"svc": {"host": "db.internal", "password": SECRET}}),
)
after = written(
"after.json",
json.dumps({"svc": {"host": "db.internal", "password": "correct-horse"}}),
)

assert built(before).fingerprint() == built(after).fingerprint()


def test_changing_anything_else_moves_it(written) -> None:
"""And the property that makes it useful."""
before = written(
"before.json",
json.dumps({"svc": {"host": "db.internal", "port": 5432, "password": SECRET}}),
)
after = written(
"after.json",
json.dumps({"svc": {"host": "db.internal", "port": 6432, "password": SECRET}}),
)

assert built(before).fingerprint() != built(after).fingerprint()


def test_it_names_its_algorithm_and_carries_no_value(written) -> None:
path = written(
"config.json",
json.dumps({"svc": {"host": "db.internal", "password": SECRET}}),
)

fingerprint = built(path).fingerprint()

assert fingerprint is not None
digest = fingerprint.removeprefix("sha256:")

assert digest != fingerprint, "a fingerprint names its algorithm"
assert len(digest) == 64
assert SECRET not in fingerprint


def test_there_is_no_fingerprint_before_the_first_load(written) -> None:
"""Absence rather than a digest of nothing.

The digest of an empty document would compare equal across every process
that had not started yet.
"""
path = written("config.json", json.dumps({"svc": {"host": "db.internal"}}))

config = DynamicConfig(Values, "svc", secrets=["password"])
config.file(str(path))

assert config.fingerprint() is None

config.init()

assert config.fingerprint() is not None
12 changes: 11 additions & 1 deletion scripts/conformance.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,9 @@ def run_case(case: pathlib.Path) -> str | None:
os.environ[key] = value

try:
config = DynamicConfig(Values, key=args["key"]).file(str(case / "config.toml"))
config = DynamicConfig(
Values, key=args["key"], secrets=args.get("secrets", ())
).file(str(case / "config.toml"))

if prefix := args.get("env_prefix"):
config = config.env(prefix)
Expand All @@ -63,13 +65,21 @@ def run_case(case: pathlib.Path) -> str | None:
config.alias(old, new)

resolved = plain(config.init_and_current())
# Only where a case pins one: every other case is a document
# comparison and asking for a digest it does not check would make
# this runner test the binding rather than the suite.
pinned = args.get("expected_fingerprint")
fingerprint = config.fingerprint() if pinned else None
finally:
for key in env:
del os.environ[key]

if resolved != expected:
return f"resolved {json.dumps(resolved, sort_keys=True)} but expected {json.dumps(expected, sort_keys=True)}"

if pinned is not None and fingerprint != pinned:
return f"fingerprinted {fingerprint} but expected {pinned}"

return None


Expand Down
Loading