Source code for cfx.types.config_field

"""Base descriptor class for self-documenting configuration fields.

This module defines `ConfigField`, which implements the Python descriptor
protocol to provide validated, documented configuration fields that live
directly on `Config` class definitions. Subclass `ConfigField` and override
`ConfigField.validate` to add type or value constraints.
"""

import os
from typing import Generic, TypeVar, overload

try:
    import click as _click
except ImportError:
    _click = None

from ..refs import FieldRef
from ..utils import _CLI_UNSET

__all__ = ["ConfigField"]

T = TypeVar("T")


[docs] class ConfigField(Generic[T]): """Base descriptor for a single configuration field. Implements ``__get__``, ``__set__``, and ``__set_name__`` so that instances assigned as class attributes behave as managed, validated attributes on `Config` subclasses. The default value may be a plain object or a callable that accepts the owning instance as its sole argument (a lazy factory). When a lazy factory is provided, the computed value is returned on every access until the user explicitly sets the attribute, after which the stored value is used instead. Parameters ---------- default_value : `object` or `callable` Default value for the field. If callable, it is treated as a lazy factory: ``default_value(instance)`` is called on each ``__get__`` invocation until an explicit value is stored on the instance. doc : `str` Human-readable description shown in ``__str__`` tables and Jupyter ``_repr_html_`` output. static : `bool`, optional If `True` the value is frozen to ``default_value`` at class-definition time and cannot be changed on instances. Any attempted ``__set__`` raises `AttributeError`. Default is `False`. env : `str` or `None`, optional Name of an environment variable to consult when no explicit value has been stored on the instance. When set, the priority chain is: explicit assignment > environment variable > default. The raw string from ``os.environ`` is coerced by `_from_env_str` and then validated. The env value is not stored on the instance - it is re-read on every access, matching the behaviour of callable defaults. ``None`` means no environment variable is consulted. Default is `None`. Raises ------ TypeError If ``default_value`` (when not callable) fails `validate`. ValueError If ``default_value`` (when not callable) fails `validate`. Examples -------- >>> from cfx import Config >>> from cfx.types import ConfigField >>> class MyConfig(Config): ... name = ConfigField("default", "A simple untyped field") >>> cfg = MyConfig() >>> cfg.name 'default' >>> cfg.name = "something else" >>> cfg.name 'something else' """ def __init__( self, default_value, doc, static=False, env=None, transient=None, ): self.doc = doc self.static = static self.env = env self._transient = transient if not callable(default_value): normalized = self.normalize(default_value) self.validate(normalized) self.defaultval = normalized else: self.defaultval = default_value @property def transient(self): """True when the field is skipped on serialization if no value stored.""" # noqa: E501 if self._transient is None: return callable(self.defaultval) return self._transient ########################################################################### # The may-need-to-be-reimplemented methods # Ideally, I would have made these methods a abc.abstracmethod, but they # have very reasonable default implementations so it's not obvious I should ###########################################################################
[docs] def normalize(self, value): """Transform value to canonical form before validation and storage. Called before `validate` in both ``__init__`` (for non-callable defaults) and ``__set__``. Must be idempotent: applying normalize twice yields the same result as applying it once. Override in subclasses to coerce common input types to the field's canonical type. Examples: ``str -> pathlib.Path``, ``list -> tuple``, angular wrap-around to ``[0, 360)``. The default implementation returns the value unchanged. Parameters ---------- value : object The raw value to normalize. Returns ------- object The normalized value, ready to pass to `validate`. """ return value
[docs] def validate(self, value): """Validate a value against this field's constraints. Called after `normalize`. Override in subclasses to enforce correct type or range of values. Raise `TypeError` for wrong type, and `ValueError` for out-of-range error. The base implementation accepts any value. Parameters ---------- value : `object` The normalized value to validate. Raises ------ TypeError If the value has the wrong type. ValueError If the value is outside the allowed range or set. """ return True
[docs] def from_string(self, s): """Parse a raw string into this field's type. Override in subclasses to coerce the raw string to the appropriate type for a particular `ConfigField`. The default implementation returns the unchanged string. Called by ``__get__`` when ``env`` is set and the variable is present in the environment but the instance has no explicitly stored value, and by the CLI integration as the argparse ``type=`` callable. The result is then passed to `validate`. Parameters ---------- s : `str` Raw string value. Returns ------- value : `object` The coerced value, ready to pass to `validate`. """ return s
[docs] def to_string(self, value): """Format a field value as a human-readable string for display. Called by the display layer when rendering the config table. Override in subclasses to produce cleaner output than Python's default ``str()``. The default implementation returns ``str(value)``. Parameters ---------- value : `object` The current field value. Returns ------- s : `str` Display string for the value. """ return str(value)
[docs] def serialize(self, value): """Serialize value to a JSON/YAML-safe form for ``to_dict()``. Override in subclasses for types that need a different on-disk representation (e.g. ``pathlib.Path -> str``, ``set -> sorted list``, ``datetime.time -> ISO string``). The default returns the value unchanged. Parameters ---------- value : object The current field value (already normalized/canonical). Returns ------- object A JSON/YAML-safe value. """ return value
[docs] def deserialize(self, value): """Deserialize a value from dict form back to the field's type. Logical inverse of `serialize`. Called by ``from_dict()`` before ``setattr``; the result is then passed through `normalize` and `validate` via ``__set__``. The default returns the value unchanged. Parameters ---------- value : object The value from the dict. Returns ------- object The deserialized value, ready to be set on the instance. """ return value
[docs] def is_set(self, obj): """Return ``True`` if an explicit value is stored on *obj*. Returns ``False`` when the field is still using its default, env var, or callable factory. Parameters ---------- obj : Config Instance to check. """ return self.private_name in obj.__dict__
[docs] def unset(self, obj): """Remove the stored value, reverting to default/env/callable. After calling this, ``__get__`` will re-evaluate the default, environment variable, or callable factory on the next access. Parameters ---------- obj : Config Instance to modify. """ obj.__dict__.pop(self.private_name, None)
[docs] def reset(self, obj, value=None): """Reset to default or set a new value with full normalize/validate. If *value* is ``None``, equivalent to `unset`. Otherwise, sets a new value going through the full normalize -> validate pipeline. Parameters ---------- obj : Config Instance to modify. value : object, optional New value. If ``None``, reverts to default. """ if value is None: self.unset(obj) else: setattr(obj, self.public_name, value)
[docs] def to_argparse_kwargs(self, name, prefix=""): """Return kwargs dict for ``parser.add_argument()``. The returned dict always contains a ``"flag"`` key with the full option string (e.g. ``"--search.n-sigma"``). Pull it out before calling argparse:: kwargs = descriptor.to_argparse_kwargs(name, prefix) flag = kwargs.pop("flag") parser.add_argument(flag, **kwargs) Override in subclasses to customize the argparse representation (add ``"choices"``, ``"nargs"``, ``"action"``, ``"metavar"``). Parameters ---------- name : str Field attribute name. prefix : str, optional Dot-separated prefix for nested configs (e.g. ``"search"``). Returns ------- dict """ hyphenated = name.replace("_", "-") flag = f"--{prefix}.{hyphenated}" if prefix else f"--{hyphenated}" return { "flag": flag, "dest": f"{prefix}.{name}" if prefix else name, "type": self.from_string, "default": _CLI_UNSET, "help": self.doc, }
[docs] def to_click_option(self, name, prefix=""): """Return a ``click.option()`` decorator for this field. Override in subclasses to customize the click representation. Parameters ---------- name : str Field attribute name. prefix : str, optional Dot-separated prefix for nested configs. Returns ------- decorator A ``click.option(...)`` decorator ready to stack on a command. Raises ------ ImportError If ``click`` is not installed. """ if _click is None: raise ImportError( "click is required for click_options(). " "Install it with: pip install click" ) hyphenated = name.replace("_", "-") flag = f"--{prefix}.{hyphenated}" if prefix else f"--{hyphenated}" param_name = f"{prefix.replace('.', '__')}__{name}" if prefix else name return _click.option( flag, param_name, type=self.from_string, default=None, help=self.doc, show_default=False, )
########################################################################### # Dunder methods ###########################################################################
[docs] def __repr__(self): env_part = f", env={self.env!r}" if self.env is not None else "" return ( f"{self.__class__.__name__}(" f"name={self.public_name!r}, " f"default={self.defaultval!r}, " f"doc={self.doc!r}" f"{env_part})" )
[docs] def __str__(self): env_part = f", env={self.env!r}" if self.env is not None else "" return ( f"{self.__class__.__name__}(" f"name={self.public_name!r}, " f"default={self.defaultval!r}, " f"doc={self.doc!r}" f"{env_part})" )
########################################################################### # Descriptor protocol dunders ###########################################################################
[docs] def __set_name__(self, owner, name): """Record the attribute name assigned by the owning class. Called automatically by the metaclass machinery when the descriptor is assigned as a class attribute. Sets `public_name` (the name as written in the class body) and `private_name` (the name used to store the per-instance value in ``instance.__dict__``). Parameters ---------- owner : `type` The class that owns this descriptor. name : `str` The attribute name under which this descriptor was assigned. """ self.public_name = name self.private_name = "_" + name
# Overloads satisfy static type checkers: class-level access returns the # descriptor itself (ConfigField[T]) while instance-level access returns T. # At runtime only the implementation below is used. @overload def __get__(self, obj: None, objtype: type) -> "ConfigField[T]": ... @overload def __get__(self, obj: object, objtype: type) -> T: ...
[docs] def __get__(self, obj, objtype=None): """Return the field value for the owning instance. If accessed on the class (``obj`` is `None`) returns a `FieldRef` path proxy, enabling IDE-refactorable paths for use in `Alias` and `Mirror` declarations. For static fields, always returns `defaultval`. For non-static fields with no explicitly stored value, the lookup order is: - explicit assignment (stored on the instance) - environment variable, when `env` is set and the variable is present in ``os.environ`` - ``defaultval``, called if callable, returned directly otherwise Parameters ---------- obj : `Config` or `None` The owning instance, or `None` when accessed on the class. objtype : `type`, optional The owning class. Returns ------- value : `object` The field value, or a `FieldRef` path proxy when ``obj`` is `None`. """ if obj is None: return FieldRef(self.public_name, None) if self.static: return self.defaultval sentinel = object() stored = getattr(obj, self.private_name, sentinel) if stored is sentinel: if self.env is not None: raw = os.environ.get(self.env) if raw is not None: value = self.from_string(raw) self.validate(value) return value if callable(self.defaultval): return self.defaultval(obj) return self.defaultval return stored
[docs] def __set__(self, obj, value): """Set the field value on the owning instance. Full pipeline: normalize -> validate -> store. Parameters ---------- obj : `Config` The owning instance. value : `object` The new value. Passed through `normalize` then `validate` before being stored. Raises ------ AttributeError If the field is declared ``static=True``. TypeError If the value fails `validate`. ValueError If the value fails `validate`. """ if self.static: raise AttributeError("Cannot set a static config field.") normalized = self.normalize(value) self.validate(normalized) setattr(obj, self.private_name, normalized)