af9ad48c9b
- config_from_env_py_infra: dataclass + field metadata, tipos str/int/float/bool/list - dotenv_load_py_infra: parser .env con semantica de no-sobreescritura - 15 tests unitarios Python, todos PASS - registry.db actualizado con fn index (685 funciones, 109 tipos) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
107 lines
3.6 KiB
Python
107 lines
3.6 KiB
Python
"""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
|