Skip to content

Caching#

In decorator mode, caching is enabled by default:

"""Caching — decorator mode with cache enabled."""

import os
from dataclasses import dataclass

import dature

os.environ["CACHE_HOST"] = "localhost"
os.environ["CACHE_PORT"] = "6379"


@dature.load(dature.EnvSource(prefix="CACHE_"), cache=True)
@dataclass
class CachedConfig:
    host: str
    port: int


config1 = CachedConfig()
os.environ["CACHE_PORT"] = "9999"
config2 = CachedConfig()
assert config1.port == 6379
assert config2.port == 6379
"""Caching — decorator mode with cache disabled."""

import os
from dataclasses import dataclass

import dature

os.environ["NOCACHE_HOST"] = "localhost"
os.environ["NOCACHE_PORT"] = "6379"


@dature.load(dature.EnvSource(prefix="NOCACHE_"), cache=False)
@dataclass
class UncachedConfig:
    host: str
    port: int


config3 = UncachedConfig()
os.environ["NOCACHE_PORT"] = "9999"
config4 = UncachedConfig()
assert config3.port == 6379
assert config4.port == 9999
"""Caching — TTL via timedelta (decorator mode)."""

import os
import time
from dataclasses import dataclass
from datetime import timedelta

import dature

os.environ["TTL_HOST"] = "localhost"
os.environ["TTL_PORT"] = "6379"


@dature.load(dature.EnvSource(prefix="TTL_"), cache=timedelta(seconds=30))
@dataclass
class TtlConfig:
    host: str
    port: int


config1 = TtlConfig()
os.environ["TTL_PORT"] = "9999"

config2 = TtlConfig()
assert config1.port == 6379
assert config2.port == 6379

# Simulate TTL expiration by monkey-patching the monotonic clock used internally
real_monotonic = time.monotonic
time.monotonic = lambda: real_monotonic() + 60.0
config3 = TtlConfig()
time.monotonic = real_monotonic
assert config3.port == 9999

Caching can also be configured globally via configure().

TTL caching#

cache accepts a datetime.timedelta in addition to bool:

  • cache=True — cache forever
  • cache=False — never cache
  • cache=timedelta(seconds=N) — cache for up to N seconds, then reload on the next access
  • cache=timedelta(0) — equivalent to "always miss" (reload on every access)

TTL is measured via time.monotonic(), so it is immune to system clock changes. A negative timedelta raises ValueError.

Bucket-aligned invalidation#

TTL is bucket-aligned (cron-style): the stored timestamp snaps down to the nearest monotonic % period == 0 boundary. The practical effect is that every class loaded inside the same TTL window invalidates at the same instant, regardless of when each individual load() happened.

Example with cache=timedelta(minutes=15):

Moment Action Effect
T=0 Class A is first loaded both A and B will invalidate at T=15
T=5 Class B is first loaded shares A's bucket → invalidates at T=15
T=15 TTL boundary crossed A and B go stale together
T=16 Class A reloaded both refresh into the next bucket, expiring at T=30

The first load in a window has an effectively shortened TTL (up to one period less than the full duration). This is the standard cron-style trade-off and matches the intuitive "invalidate every N minutes" mental model.

Function-mode caching: Loader#

dature.load(src, schema=Cls) is a thin shortcut that constructs a throwaway Loader and calls .load() once. Repeated load(...) calls do not share a cache — each call is a fresh load.

To cache across calls in function mode, construct a Loader explicitly and keep the instance around:

"""Function-mode caching via an explicit ``Loader``.

``dature.load(...)`` is a thin shortcut that creates a throwaway ``Loader`` and
calls ``.load()`` once — repeated calls do NOT share a cache. To make caching
useful in function mode, keep the ``Loader`` instance around and call
``.load()`` multiple times.
"""

import os
from dataclasses import dataclass
from datetime import timedelta

import dature

os.environ["FN_HOST"] = "localhost"
os.environ["FN_PORT"] = "6379"


@dataclass
class FunctionConfig:
    host: str
    port: int


loader = dature.Loader(
    dature.EnvSource(prefix="FN_"),
    schema=FunctionConfig,
    cache=timedelta(seconds=30),
)

first = loader.load()
os.environ["FN_PORT"] = "9999"
second = loader.load()

assert first.port == 6379
assert second.port == 6379  # cache still fresh — same Loader instance

The Loader carries all the load-time parameters and the cache state. Identity of the Loader instance fully captures the call configuration — there is no implicit fingerprinting of debug/type_loaders/strategy/etc. Different parameters → different Loader instances → independent cache slots.

Loader API#

Method Effect
Loader.load() -> T Return cached result if fresh, else reload and cache.
Loader.invalidate() Drop the cached result so the next .load() reloads from sources.

Loader supports the same constructor parameters as dature.load(...) for function mode.