"""config_from_env — popula dataclasses desde variables de entorno usando field metadata.""" import os from dataclasses import fields as dc_fields def config_from_env(target_class: type) -> object: """Crea una instancia de un dataclass poblada desde variables de entorno. Cada campo del dataclass puede tener metadata que guia el mapeo: - ``env``: nombre de la variable de entorno (obligatorio para usar este sistema) - ``required``: bool, si True y la var no esta seteada lanza ValueError - ``default``: valor string usado cuando la var no esta seteada (opcional) - ``secret``: bool, documentacion solamente, sin efecto en runtime La conversion de tipo es automatica segun el tipo anotado del campo: - ``str``: sin conversion - ``int``: int(value) - ``float``: float(value) - ``bool``: True para "1", "true", "yes" (case-insensitive); False para el resto - ``list[str]`` o ``list``: split por coma, stripping espacios Args: target_class: clase dataclass con campo metadata en sus fields. Returns: instancia de target_class con todos los campos poblados. Raises: ValueError: si un campo requerido no esta seteado, o si la conversion de tipo falla. TypeError: si target_class no es un dataclass. """ try: all_fields = dc_fields(target_class) # raises TypeError if not dataclass except TypeError: raise TypeError(f"config_from_env: {target_class.__name__} is not a dataclass") kwargs: dict = {} errors: list[str] = [] for f in all_fields: meta = f.metadata env_key: str | None = meta.get("env") if env_key is None: # no env tag → leave at dataclass default (will be provided via default_factory) continue required: bool = meta.get("required", False) default_str: str | None = meta.get("default") raw = os.environ.get(env_key, "") if not raw: if default_str is not None: raw = default_str elif required: errors.append(f"field '{f.name}': env var '{env_key}' is required but not set") continue else: continue # optional, unset — use dataclass default # type conversion try: kwargs[f.name] = _coerce(raw, f.type, f.name) except (ValueError, TypeError) as exc: errors.append(str(exc)) if errors: raise ValueError("config_from_env: " + "; ".join(errors)) return target_class(**kwargs) def _coerce(raw: str, annotation: type | str, field_name: str) -> object: """Coerce raw string to the annotated type.""" # handle string annotations (from __future__ import annotations or forward refs) if isinstance(annotation, str): ann_str = annotation.lower().strip() else: # Get the type name for comparison ann_str = getattr(annotation, "__name__", str(annotation)).lower() if ann_str in ("str", "string"): return raw if ann_str == "int": try: return int(raw) except ValueError: raise ValueError(f"field '{field_name}': cannot convert {raw!r} to int") if ann_str == "float": try: return float(raw) except ValueError: raise ValueError(f"field '{field_name}': cannot convert {raw!r} to float") if ann_str == "bool": return raw.strip().lower() in ("1", "true", "yes") # list / list[str] if "list" in ann_str: parts = [p.strip() for p in raw.split(",") if p.strip()] return parts # fallback: return as string return raw