Custom Fields¶
When the built-in types don’t fit your domain — non-linear ranges, custom unit
normalization, domain-specific coercion — extend ConfigField
directly. This capability is intentionally preserved alongside
annotation-native syntax: not every field can be expressed via annotations
alone.
Defining your own field¶
Import the base class from cfx.types:
from cfx import Config, Field
from cfx.types import ConfigField
class Angle(ConfigField):
"""An angle in degrees, stored normalized to [0, 360)."""
def normalize(self, value):
"""Coerce to float and wrap to [0, 360)."""
return float(value) % 360.0
def validate(self, value):
if not isinstance(value, float):
raise TypeError(
f"Angle requires a float, got {type(value).__name__!r}"
)
class SurveyConfig(Config):
heading = Angle(0.0, "Survey heading in degrees")
bearing = Angle(45.0, "Bearing to target in degrees")
radius: float = Field(10.0, "Search radius in meters", minval=0.0)
cfg = SurveyConfig()
cfg.heading = 370.0
cfg.heading # 10.0 - normalized to [0, 360)
cfg.heading = -30.0
cfg.heading # 330.0 - wrapped around
Explicit field types and Field()-declared fields can coexist freely on
the same class.
Normalize and validate¶
Every assignment (and the initial seeding of the default value) runs through the same two-step pipeline:
normalize(value)— transform the raw input to its canonical form. Must be idempotent:normalize(normalize(x)) == normalize(x)(see Normalization and the descriptor’s defaultval for when relaxing this is acceptable).validate(value)— check the normalized value against constraints. RaiseTypeErrororValueErroron failure.
Both methods are called in ConfigField.__init__ on the default, so
defaultval is always stored in its canonical (normalized) form:
class SurveyConfig(Config):
heading = Angle(370.0, "Heading")
SurveyConfig().heading # 10.0 — normalized at access
type(SurveyConfig()).heading.defaultval # 10.0 — also normalized
Override only the methods you need. The base implementations are no-ops —
normalize returns the value unchanged, validate accepts anything.
Extra constructor arguments¶
Custom __init__ parameters must be set before calling
super().__init__ because the parent calls validate on the default:
import math
class LogScale(ConfigField):
"""Value constrained to a log10-space interval."""
def __init__(self, default, doc, log_min, log_max, **kwargs):
self.log_min = log_min
self.log_max = log_max
super().__init__(default, doc, **kwargs)
def validate(self, value):
log_val = math.log10(value)
if not (self.log_min <= log_val <= self.log_max):
raise ValueError(
f"log10({value}) = {log_val:.2f} outside "
f"[{self.log_min}, {self.log_max}]"
)
Serialization hooks¶
Override serialize and deserialize to control how a field value is
stored in a dict or YAML file. By default both are identity functions.
The Path field, for example, converts to a plain string for serialization
and back to pathlib.Path on load:
import pathlib
class MyPath(ConfigField):
def normalize(self, value):
if isinstance(value, str):
return pathlib.Path(value)
return value
def serialize(self, value):
return str(value)
def deserialize(self, value):
return pathlib.Path(value)
serialize is called by to_dict() (and therefore
to_yaml). deserialize is called by from_dict()
before the value is passed through the normalize → validate pipeline.
CLI hooks¶
Override to_argparse_kwargs (or to_click_option) to control how a
field is exposed on the command line. The base implementation returns a
type=from_string callable and an appropriate metavar; override only what
differs from that default:
class Angle(ConfigField):
...
def to_argparse_kwargs(self, name, prefix=""):
kwargs = super().to_argparse_kwargs(name, prefix)
kwargs["metavar"] = "DEG"
kwargs["help"] = f"{self.doc} (any value, wrapped to [0, 360))"
return kwargs