Sharp edges

This page documents subtle behaviors you may encounter with advanced features. Most users won’t hit these, but they’re important to understand when using callable defaults or CLI string parsing.

Callable fields and copy()

copy() distinguishes between fields that have an explicitly stored value and fields whose value comes from a callable default. Only the stored values are copied - callable-default fields are left unset on the new instance so they recompute lazily from the copy’s own field values:

class RetryConfig(Config):
    base = Float(1.0, "Base interval")
    retry = Float(lambda self: self.base * 3, "3* base")

cfg = RetryConfig()   # retry NOT stored (callable default)
cfg2 = cfg.copy()
cfg2.base = 10.0
cfg2.retry            # 30.0 - recomputed from cfg2.base

Once a callable-default field is explicitly set, copy() carries that stored value:

cfg.retry = 99.0      # now stored
cfg3 = cfg.copy()
cfg3.base = 10.0
cfg3.retry            # 99.0 - the stored value is preserved, formula gone

Serialization and callable defaults

Callable-default fields are skipped during serialization when no explicit value has been stored. On load, the callable is still present in the class definition and reconstructs the value naturally:

class RetryConfig(Config):
    base = Float(1.0, "Base interval")
    retry = Float(lambda self: self.base * 3, "3* base")

cfg = RetryConfig()
d = cfg.to_dict()     # d == {'base': 1.0}  -- retry omitted

cfg2 = RetryConfig.from_dict(d)
cfg2.base = 10.0
cfg2.retry            # 30.0 - recomputed from callable

This works reliably when the callable is a pure function of other Config fields. If the callable depends on external state (global variables, memoized values, I/O), the reconstructed value after loading may differ from the original. In that case set transient=False on the field to preserve the snapshot behavior:

retry = Float(lambda self: self.base * 3, "3* base", transient=False)
# now serialized as a plain value and reloaded as-is

Circular dependencies

There is no cycle detection. If field A’s callable default reads field B and field B’s callable reads field A, you get infinite recursion:

class BadConfig(Config):
    a = Float(lambda self: self.b + 1, "Reads b")
    b = Float(lambda self: self.a + 1, "Reads a")  # RecursionError on access

Keep the dependency graph acyclic: derived fields should only read earlier-declared fields that have plain defaults.

List.from_string falls back silently on bad JSON

When a List field reads its value from an environment variable or a CLI argument, it first tries to parse the raw string as JSON. If that fails (e.g. a malformed array), it silently falls back to splitting on commas - so "[1, 2, 3" becomes ["[1", " 2", " 3"] rather than an error.

If precise element types matter, set element_type on the field and pass well-formed JSON arrays from the environment.

Normalization and the descriptor’s defaultval

ConfigField.__init__ runs the default through normalize() before storing it, so defaultval is always in canonical form. Instances and the descriptor’s defaultval agree:

class SurveyConfig(Config):
    heading = Angle(370.0, "Heading in degrees")

SurveyConfig().heading                       # 10.0 — normalized
type(SurveyConfig()).heading.defaultval      # 10.0 — also normalized

This relies on normalize being idempotent. If your normalize depends on instance state (rare), the class-level default may not match what an instance would compute — keep normalize a pure function of value to avoid surprises.