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>
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user