Files
egutierrez c7998de8bb feat(infra): funciones Python config_from_env y dotenv_load con tests
- 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>
2026-04-13 02:01:56 +02:00

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