django-env-loader 1.0.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- django_env_loader/__init__.py +19 -0
- django_env_loader/exceptions.py +29 -0
- django_env_loader/loader.py +517 -0
- django_env_loader/py.typed +2 -0
- django_env_loader-1.0.3.dist-info/METADATA +655 -0
- django_env_loader-1.0.3.dist-info/RECORD +8 -0
- django_env_loader-1.0.3.dist-info/WHEEL +4 -0
- django_env_loader-1.0.3.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Django Env Loader - Gerenciamento robusto de variáveis de ambiente."""
|
|
2
|
+
|
|
3
|
+
from django_env_loader.exceptions import EnvLoaderError, SecretNotFoundError, ValidationError
|
|
4
|
+
from django_env_loader.loader import DjangoEnvLoader, EnvConfig, EnvLoader
|
|
5
|
+
|
|
6
|
+
__version__ = "1.0.0"
|
|
7
|
+
__all__ = [
|
|
8
|
+
"EnvLoader",
|
|
9
|
+
"DjangoEnvLoader",
|
|
10
|
+
"EnvConfig",
|
|
11
|
+
"EnvLoaderError",
|
|
12
|
+
"SecretNotFoundError",
|
|
13
|
+
"ValidationError",
|
|
14
|
+
"env_loader",
|
|
15
|
+
]
|
|
16
|
+
|
|
17
|
+
# Instância singleton padrão para importação direta
|
|
18
|
+
# Uso: from django_env_loader import env_loader
|
|
19
|
+
env_loader = EnvLoader()
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
|
|
3
|
+
# ============================================================================
|
|
4
|
+
# Exceções customizadas
|
|
5
|
+
# ============================================================================
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class EnvLoaderError(Exception):
|
|
9
|
+
"""Classe base para exceções do EnvLoader."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class SecretNotFoundError(EnvLoaderError):
|
|
13
|
+
"""Exceção levantada quando um secret obrigatório não é encontrado."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, key: str, /, searched_locations: list[str] | None = None):
|
|
16
|
+
self.key = key
|
|
17
|
+
self.searched_locations = searched_locations or []
|
|
18
|
+
locations = ", ".join(self.searched_locations) if self.searched_locations else "padrão"
|
|
19
|
+
super().__init__(f"Secret '{key}' não encontrado. Locais buscados: {locations}")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ValidationError(EnvLoaderError):
|
|
23
|
+
"""Exceção levantada quando a validação de uma variável falha."""
|
|
24
|
+
|
|
25
|
+
def __init__(self, key: str, value: Any, reason: str):
|
|
26
|
+
self.key = key
|
|
27
|
+
self.value = value
|
|
28
|
+
self.reason = reason
|
|
29
|
+
super().__init__(f"Validação falhou para '{key}': {reason} (valor: {value!r})")
|
|
@@ -0,0 +1,517 @@
|
|
|
1
|
+
"""Pacote para gerenciamento de variáveis de ambiente e secrets.
|
|
2
|
+
|
|
3
|
+
Este módulo fornece uma interface type-safe e extensível para carregar e validar
|
|
4
|
+
variáveis de ambiente, com suporte especial para Docker secrets e integração Django.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import logging
|
|
10
|
+
import os
|
|
11
|
+
import warnings
|
|
12
|
+
|
|
13
|
+
from collections.abc import Callable
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import TypeVar, overload
|
|
17
|
+
|
|
18
|
+
from dotenv import load_dotenv
|
|
19
|
+
|
|
20
|
+
from django_env_loader.exceptions import SecretNotFoundError, ValidationError
|
|
21
|
+
|
|
22
|
+
__version__ = "1.0.0"
|
|
23
|
+
__all__ = ["EnvLoader", "EnvConfig", SecretNotFoundError, ValidationError]
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger(__name__)
|
|
26
|
+
|
|
27
|
+
T = TypeVar("T")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
# ============================================================================
|
|
31
|
+
# Configuração
|
|
32
|
+
# ============================================================================
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@dataclass
|
|
36
|
+
class EnvConfig:
|
|
37
|
+
"""Configuração para o EnvLoader.
|
|
38
|
+
|
|
39
|
+
Attributes:
|
|
40
|
+
env_file: Caminho para arquivo .env (None = auto-detect)
|
|
41
|
+
secrets_dir: Diretório base para Docker secrets (padrão: /run/secrets)
|
|
42
|
+
encoding: Encoding para leitura de arquivos
|
|
43
|
+
prefix: Prefixo para filtrar variáveis (ex: 'DJANGO_')
|
|
44
|
+
override_existing: Se deve sobrescrever variáveis já definidas
|
|
45
|
+
auto_cast: Se deve tentar conversão automática de tipos
|
|
46
|
+
cache_secrets: Se deve cachear secrets lidos de arquivos
|
|
47
|
+
strict_mode: Se deve levantar exceções em vez de warnings
|
|
48
|
+
warn_on_missing: Se deve emitir warnings para variáveis não encontradas
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
env_file: Path | str | None = None
|
|
52
|
+
secrets_dir: Path = field(default_factory=lambda: Path("/run/secrets"))
|
|
53
|
+
encoding: str = "utf-8"
|
|
54
|
+
prefix: str = ""
|
|
55
|
+
override_existing: bool = False
|
|
56
|
+
auto_cast: bool = True
|
|
57
|
+
cache_secrets: bool = True
|
|
58
|
+
strict_mode: bool = False
|
|
59
|
+
warn_on_missing: bool = True
|
|
60
|
+
|
|
61
|
+
def __post_init__(self) -> None:
|
|
62
|
+
"""Valida e normaliza a configuração."""
|
|
63
|
+
if self.env_file is not None:
|
|
64
|
+
self.env_file = Path(self.env_file)
|
|
65
|
+
if isinstance(self.secrets_dir, str):
|
|
66
|
+
self.secrets_dir = Path(self.secrets_dir)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# ============================================================================
|
|
70
|
+
# Conversores de tipo
|
|
71
|
+
# ============================================================================
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class TypeConverter:
|
|
75
|
+
"""Conversor type-safe de valores de ambiente."""
|
|
76
|
+
|
|
77
|
+
@staticmethod
|
|
78
|
+
def to_bool(value: str | bool) -> bool:
|
|
79
|
+
"""Converte string para boolean de forma segura."""
|
|
80
|
+
if isinstance(value, bool):
|
|
81
|
+
return value
|
|
82
|
+
|
|
83
|
+
if not isinstance(value, str):
|
|
84
|
+
raise ValidationError("boolean", value, "Tipo inválido, esperado str ou bool")
|
|
85
|
+
|
|
86
|
+
normalized = value.lower().strip()
|
|
87
|
+
|
|
88
|
+
if normalized in {"true", "1", "yes", "y", "on", "t", "sim", "s"}:
|
|
89
|
+
return True
|
|
90
|
+
if normalized in {"false", "0", "no", "n", "off", "f", "não", "nao", ""}:
|
|
91
|
+
return False
|
|
92
|
+
|
|
93
|
+
raise ValidationError("boolean", value, f"Valor inválido para bool: '{value}'")
|
|
94
|
+
|
|
95
|
+
@staticmethod
|
|
96
|
+
def to_int(value: str | int) -> int:
|
|
97
|
+
"""Converte string para inteiro."""
|
|
98
|
+
if isinstance(value, int):
|
|
99
|
+
return value
|
|
100
|
+
try:
|
|
101
|
+
return int(str(value).strip())
|
|
102
|
+
except (ValueError, TypeError) as e:
|
|
103
|
+
raise ValidationError("int", value, str(e)) from e
|
|
104
|
+
|
|
105
|
+
@staticmethod
|
|
106
|
+
def to_float(value: str | float) -> float:
|
|
107
|
+
"""Converte string para float."""
|
|
108
|
+
if isinstance(value, float):
|
|
109
|
+
return value
|
|
110
|
+
try:
|
|
111
|
+
return float(str(value).strip())
|
|
112
|
+
except (ValueError, TypeError) as e:
|
|
113
|
+
raise ValidationError("float", value, str(e)) from e
|
|
114
|
+
|
|
115
|
+
@staticmethod
|
|
116
|
+
def to_list(value: str | list[str], delimiter: str = ",") -> list[str]:
|
|
117
|
+
"""Converte string delimitada em lista."""
|
|
118
|
+
if isinstance(value, list):
|
|
119
|
+
return value
|
|
120
|
+
if not value:
|
|
121
|
+
return []
|
|
122
|
+
return [item.strip() for item in str(value).split(delimiter) if item.strip()]
|
|
123
|
+
|
|
124
|
+
@staticmethod
|
|
125
|
+
def to_dict(value: str | dict[str, str], delimiter: str = ",") -> dict[str, str]:
|
|
126
|
+
"""Converte string 'key=value,key2=value2' em dicionário."""
|
|
127
|
+
if isinstance(value, dict):
|
|
128
|
+
return value
|
|
129
|
+
if not value:
|
|
130
|
+
return {}
|
|
131
|
+
|
|
132
|
+
result: dict[str, str] = {}
|
|
133
|
+
for item in str(value).split(delimiter):
|
|
134
|
+
item = item.strip()
|
|
135
|
+
if "=" in item:
|
|
136
|
+
k, v = item.split("=", 1)
|
|
137
|
+
result[k.strip()] = v.strip()
|
|
138
|
+
elif item:
|
|
139
|
+
result[item] = ""
|
|
140
|
+
return result
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
# ============================================================================
|
|
144
|
+
# EnvLoader Principal
|
|
145
|
+
# ============================================================================
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class EnvLoader:
|
|
149
|
+
"""Gerenciador robusto de variáveis de ambiente e secrets.
|
|
150
|
+
|
|
151
|
+
Fornece interface type-safe para carregar, validar e converter variáveis
|
|
152
|
+
de ambiente e Docker secrets com suporte a cache e validação customizada.
|
|
153
|
+
|
|
154
|
+
Exemplo:
|
|
155
|
+
>>> loader = EnvLoader()
|
|
156
|
+
>>> db_host = loader.get("DATABASE_URL", required=True)
|
|
157
|
+
>>> debug = loader.get_bool("DEBUG", default=False)
|
|
158
|
+
>>> allowed_hosts = loader.get_list("ALLOWED_HOSTS", default=[])
|
|
159
|
+
"""
|
|
160
|
+
|
|
161
|
+
_instance: EnvLoader | None = None
|
|
162
|
+
_initialized: bool = False
|
|
163
|
+
|
|
164
|
+
def __new__(cls, config: EnvConfig | None = None) -> EnvLoader:
|
|
165
|
+
"""Implementa padrão Singleton (opcional)."""
|
|
166
|
+
if cls._instance is None:
|
|
167
|
+
cls._instance = super().__new__(cls)
|
|
168
|
+
return cls._instance
|
|
169
|
+
|
|
170
|
+
def __init__(self, config: EnvConfig | None = None) -> None:
|
|
171
|
+
"""Inicializa o loader com configuração opcional.
|
|
172
|
+
|
|
173
|
+
Args:
|
|
174
|
+
config: Configuração customizada (None = configuração padrão)
|
|
175
|
+
"""
|
|
176
|
+
# Evita reinicialização no padrão Singleton
|
|
177
|
+
if self._initialized:
|
|
178
|
+
return
|
|
179
|
+
|
|
180
|
+
self.config = config or EnvConfig()
|
|
181
|
+
self._secrets_cache: dict[str, str] = {}
|
|
182
|
+
self._load_env_file()
|
|
183
|
+
self._initialized = True
|
|
184
|
+
|
|
185
|
+
def _load_env_file(self) -> None:
|
|
186
|
+
"""Carrega arquivo .env se especificado."""
|
|
187
|
+
if self.config.env_file:
|
|
188
|
+
env_path = Path(self.config.env_file)
|
|
189
|
+
if env_path.is_file():
|
|
190
|
+
load_dotenv(
|
|
191
|
+
env_path,
|
|
192
|
+
override=self.config.override_existing,
|
|
193
|
+
encoding=self.config.encoding,
|
|
194
|
+
)
|
|
195
|
+
logger.debug(f"Arquivo .env carregado: {env_path}")
|
|
196
|
+
else:
|
|
197
|
+
msg = f"Arquivo .env não encontrado: {env_path}"
|
|
198
|
+
if self.config.strict_mode:
|
|
199
|
+
raise FileNotFoundError(msg)
|
|
200
|
+
logger.warning(msg)
|
|
201
|
+
else:
|
|
202
|
+
load_dotenv(override=self.config.override_existing, encoding=self.config.encoding)
|
|
203
|
+
|
|
204
|
+
def _get_prefixed_key(self, key: str) -> str:
|
|
205
|
+
"""Retorna a chave com prefixo aplicado."""
|
|
206
|
+
return (
|
|
207
|
+
f"{self.config.prefix}{key}"
|
|
208
|
+
if self.config.prefix and not key.startswith(self.config.prefix)
|
|
209
|
+
else key
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
def _read_secret_file(self, secret_path: Path) -> str | None:
|
|
213
|
+
"""Lê conteúdo de arquivo secret com tratamento de erros."""
|
|
214
|
+
try:
|
|
215
|
+
if not secret_path.exists():
|
|
216
|
+
return None
|
|
217
|
+
|
|
218
|
+
content = secret_path.read_text(encoding=self.config.encoding).strip()
|
|
219
|
+
path_str = str(secret_path)
|
|
220
|
+
|
|
221
|
+
if self.config.cache_secrets:
|
|
222
|
+
self._secrets_cache[path_str] = content # ← Armazena pelo caminho
|
|
223
|
+
|
|
224
|
+
logger.debug(f"Secret lido: {secret_path}")
|
|
225
|
+
return content
|
|
226
|
+
|
|
227
|
+
except (OSError, PermissionError) as e:
|
|
228
|
+
logger.error(f"Erro ao ler secret {secret_path}: {e}")
|
|
229
|
+
if self.config.strict_mode:
|
|
230
|
+
raise
|
|
231
|
+
return None
|
|
232
|
+
|
|
233
|
+
def _get_from_secret(self, key: str) -> str | None:
|
|
234
|
+
"""Tenta obter valor de Docker secret."""
|
|
235
|
+
secret_path = self.config.secrets_dir / key
|
|
236
|
+
path_str = str(secret_path)
|
|
237
|
+
|
|
238
|
+
# Verifica cache primeiro (pelo caminho do arquivo)
|
|
239
|
+
if self.config.cache_secrets and path_str in self._secrets_cache:
|
|
240
|
+
return self._secrets_cache[path_str]
|
|
241
|
+
|
|
242
|
+
# Tenta ler do arquivo secret
|
|
243
|
+
return self._read_secret_file(secret_path)
|
|
244
|
+
|
|
245
|
+
def _get_from_env(self, key: str) -> str | None:
|
|
246
|
+
"""Obtém valor de variável de ambiente."""
|
|
247
|
+
prefixed_key = self._get_prefixed_key(key)
|
|
248
|
+
return os.environ.get(prefixed_key)
|
|
249
|
+
|
|
250
|
+
@overload
|
|
251
|
+
def get(self, key: str, *, default: T, required: bool = False) -> str | T: ...
|
|
252
|
+
|
|
253
|
+
@overload
|
|
254
|
+
def get(self, key: str, *, default: None = None, required: bool = True) -> str: ...
|
|
255
|
+
|
|
256
|
+
def get(
|
|
257
|
+
self,
|
|
258
|
+
key: str,
|
|
259
|
+
*,
|
|
260
|
+
default: T | None = None,
|
|
261
|
+
required: bool = False,
|
|
262
|
+
use_secrets: bool = True,
|
|
263
|
+
) -> str | T:
|
|
264
|
+
"""Obtém variável de ambiente ou secret.
|
|
265
|
+
|
|
266
|
+
Args:
|
|
267
|
+
key: Nome da variável (sem prefixo)
|
|
268
|
+
default: Valor padrão se não encontrado
|
|
269
|
+
required: Se True, levanta SecretNotFoundError se não encontrado
|
|
270
|
+
use_secrets: Se deve buscar em Docker secrets
|
|
271
|
+
|
|
272
|
+
Returns:
|
|
273
|
+
Valor da variável ou default
|
|
274
|
+
|
|
275
|
+
Raises:
|
|
276
|
+
SecretNotFoundError: Se required=True e variável não encontrada
|
|
277
|
+
"""
|
|
278
|
+
value: str | None = None
|
|
279
|
+
searched: list[str] = []
|
|
280
|
+
|
|
281
|
+
# Busca em secrets primeiro
|
|
282
|
+
if use_secrets:
|
|
283
|
+
value = self._get_from_secret(key)
|
|
284
|
+
searched.append(f"secret:{self.config.secrets_dir / key}")
|
|
285
|
+
|
|
286
|
+
# Fallback para variável de ambiente
|
|
287
|
+
if value is None:
|
|
288
|
+
value = self._get_from_env(key)
|
|
289
|
+
searched.append(f"env:{self._get_prefixed_key(key)}")
|
|
290
|
+
|
|
291
|
+
# Validação
|
|
292
|
+
if value is None or not value.strip():
|
|
293
|
+
if required:
|
|
294
|
+
raise SecretNotFoundError(key, searched)
|
|
295
|
+
|
|
296
|
+
if self.config.warn_on_missing and default is None:
|
|
297
|
+
warnings.warn(f"Variável '{key}' não encontrada", UserWarning, stacklevel=2)
|
|
298
|
+
|
|
299
|
+
return default if default is not None else ""
|
|
300
|
+
|
|
301
|
+
return value
|
|
302
|
+
|
|
303
|
+
def get_bool(
|
|
304
|
+
self,
|
|
305
|
+
key: str,
|
|
306
|
+
*,
|
|
307
|
+
default: bool = False,
|
|
308
|
+
required: bool = False,
|
|
309
|
+
use_secrets: bool = True,
|
|
310
|
+
) -> bool:
|
|
311
|
+
"""Obtém variável como boolean."""
|
|
312
|
+
value = self.get(key, default=str(default), required=required, use_secrets=use_secrets)
|
|
313
|
+
try:
|
|
314
|
+
return TypeConverter.to_bool(value)
|
|
315
|
+
except ValidationError as e:
|
|
316
|
+
if self.config.strict_mode:
|
|
317
|
+
raise
|
|
318
|
+
logger.warning(f"Erro ao converter '{key}' para bool: {e}. Usando default: {default}")
|
|
319
|
+
return default
|
|
320
|
+
|
|
321
|
+
def get_int(
|
|
322
|
+
self,
|
|
323
|
+
key: str,
|
|
324
|
+
*,
|
|
325
|
+
default: int = 0,
|
|
326
|
+
required: bool = False,
|
|
327
|
+
use_secrets: bool = True,
|
|
328
|
+
) -> int:
|
|
329
|
+
"""Obtém variável como inteiro."""
|
|
330
|
+
value = self.get(key, default=str(default), required=required, use_secrets=use_secrets)
|
|
331
|
+
try:
|
|
332
|
+
return TypeConverter.to_int(value)
|
|
333
|
+
except ValidationError as e:
|
|
334
|
+
if self.config.strict_mode:
|
|
335
|
+
raise
|
|
336
|
+
logger.warning(f"Erro ao converter '{key}' para int: {e}. Usando default: {default}")
|
|
337
|
+
return default
|
|
338
|
+
|
|
339
|
+
def get_float(
|
|
340
|
+
self,
|
|
341
|
+
key: str,
|
|
342
|
+
*,
|
|
343
|
+
default: float = 0.0,
|
|
344
|
+
required: bool = False,
|
|
345
|
+
use_secrets: bool = True,
|
|
346
|
+
) -> float:
|
|
347
|
+
"""Obtém variável como float."""
|
|
348
|
+
value = self.get(key, default=str(default), required=required, use_secrets=use_secrets)
|
|
349
|
+
try:
|
|
350
|
+
return TypeConverter.to_float(value)
|
|
351
|
+
except ValidationError as e:
|
|
352
|
+
if self.config.strict_mode:
|
|
353
|
+
raise
|
|
354
|
+
logger.warning(f"Erro ao converter '{key}' para float: {e}. Usando default: {default}")
|
|
355
|
+
return default
|
|
356
|
+
|
|
357
|
+
def get_list(
|
|
358
|
+
self,
|
|
359
|
+
key: str,
|
|
360
|
+
*,
|
|
361
|
+
default: list[str] | None = None,
|
|
362
|
+
delimiter: str = ",",
|
|
363
|
+
required: bool = False,
|
|
364
|
+
use_secrets: bool = True,
|
|
365
|
+
) -> list[str]:
|
|
366
|
+
"""Obtém variável como lista."""
|
|
367
|
+
if default is None:
|
|
368
|
+
default = []
|
|
369
|
+
|
|
370
|
+
# Tenta obter a variável sem valor padrão
|
|
371
|
+
value = self.get(key, default=None, required=False, use_secrets=use_secrets)
|
|
372
|
+
|
|
373
|
+
if value is None or value == "":
|
|
374
|
+
# Variável não existe ou é string vazia
|
|
375
|
+
if required:
|
|
376
|
+
raise SecretNotFoundError(key, [])
|
|
377
|
+
return default
|
|
378
|
+
|
|
379
|
+
# Variável existe, converte para lista
|
|
380
|
+
return TypeConverter.to_list(value, delimiter)
|
|
381
|
+
|
|
382
|
+
def get_dict(
|
|
383
|
+
self,
|
|
384
|
+
key: str,
|
|
385
|
+
*,
|
|
386
|
+
default: dict[str, str] | None = None,
|
|
387
|
+
delimiter: str = ",",
|
|
388
|
+
required: bool = False,
|
|
389
|
+
use_secrets: bool = True,
|
|
390
|
+
) -> dict[str, str]:
|
|
391
|
+
"""Obtém variável como dicionário."""
|
|
392
|
+
if default is None:
|
|
393
|
+
default = {}
|
|
394
|
+
|
|
395
|
+
# Tenta obter a variável sem valor padrão
|
|
396
|
+
value = self.get(key, default=None, required=False, use_secrets=use_secrets)
|
|
397
|
+
|
|
398
|
+
if value is None or value == "":
|
|
399
|
+
# Variável não existe ou é string vazia
|
|
400
|
+
if required:
|
|
401
|
+
raise SecretNotFoundError(key, [])
|
|
402
|
+
return default
|
|
403
|
+
|
|
404
|
+
# Variável existe, converte para dicionário
|
|
405
|
+
return TypeConverter.to_dict(value, delimiter)
|
|
406
|
+
|
|
407
|
+
def get_with_validator(
|
|
408
|
+
self,
|
|
409
|
+
key: str,
|
|
410
|
+
validator: Callable[[str], T],
|
|
411
|
+
*,
|
|
412
|
+
default: T | None = None,
|
|
413
|
+
required: bool = False,
|
|
414
|
+
use_secrets: bool = True,
|
|
415
|
+
) -> T | None:
|
|
416
|
+
"""Obtém variável com validação customizada.
|
|
417
|
+
|
|
418
|
+
Args:
|
|
419
|
+
key: Nome da variável
|
|
420
|
+
validator: Função que valida/converte o valor
|
|
421
|
+
default: Valor padrão
|
|
422
|
+
required: Se é obrigatória
|
|
423
|
+
use_secrets: Se deve buscar em secrets
|
|
424
|
+
|
|
425
|
+
Returns:
|
|
426
|
+
Valor validado ou default
|
|
427
|
+
"""
|
|
428
|
+
value = self.get(key, default=None, required=required, use_secrets=use_secrets)
|
|
429
|
+
if value is None:
|
|
430
|
+
return default
|
|
431
|
+
|
|
432
|
+
try:
|
|
433
|
+
return validator(value)
|
|
434
|
+
except Exception as e:
|
|
435
|
+
if self.config.strict_mode:
|
|
436
|
+
raise ValidationError(key, value, str(e)) from e
|
|
437
|
+
logger.warning(f"Validação falhou para '{key}': {e}. Usando default")
|
|
438
|
+
return default
|
|
439
|
+
|
|
440
|
+
def is_set(self, key: str, *, use_secrets: bool = True) -> bool:
|
|
441
|
+
"""Verifica se variável está definida e não vazia."""
|
|
442
|
+
try:
|
|
443
|
+
value = self.get(key, required=False, use_secrets=use_secrets)
|
|
444
|
+
return bool(value and str(value).strip())
|
|
445
|
+
except Exception:
|
|
446
|
+
return False
|
|
447
|
+
|
|
448
|
+
def get_all(self, *, include_secrets: bool = False) -> dict[str, str]:
|
|
449
|
+
"""Retorna todas as variáveis carregadas.
|
|
450
|
+
|
|
451
|
+
Args:
|
|
452
|
+
include_secrets: Se deve incluir secrets em cache
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
Dicionário com todas as variáveis
|
|
456
|
+
"""
|
|
457
|
+
result = dict(os.environ)
|
|
458
|
+
|
|
459
|
+
if include_secrets and self.config.cache_secrets:
|
|
460
|
+
result.update(self._secrets_cache)
|
|
461
|
+
|
|
462
|
+
# Filtra por prefixo se configurado
|
|
463
|
+
if self.config.prefix:
|
|
464
|
+
result = {k: v for k, v in result.items() if k.startswith(self.config.prefix)}
|
|
465
|
+
|
|
466
|
+
return result
|
|
467
|
+
|
|
468
|
+
def clear_cache(self) -> None:
|
|
469
|
+
"""Limpa o cache de secrets."""
|
|
470
|
+
self._secrets_cache.clear()
|
|
471
|
+
logger.debug("Cache de secrets limpo")
|
|
472
|
+
|
|
473
|
+
@classmethod
|
|
474
|
+
def reset_singleton(cls) -> None:
|
|
475
|
+
"""Reset do singleton (útil para testes)."""
|
|
476
|
+
cls._instance = None
|
|
477
|
+
cls._initialized = False
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
# ============================================================================
|
|
481
|
+
# Utilitários para Django
|
|
482
|
+
# ============================================================================
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
class DjangoEnvLoader(EnvLoader):
|
|
486
|
+
"""Loader especializado para projetos Django.
|
|
487
|
+
|
|
488
|
+
Fornece helpers específicos para configurações Django comuns.
|
|
489
|
+
"""
|
|
490
|
+
|
|
491
|
+
def __init__(self, config: EnvConfig | None = None) -> None:
|
|
492
|
+
"""Inicializa com defaults para Django."""
|
|
493
|
+
if config is None:
|
|
494
|
+
config = EnvConfig(prefix="DJANGO_", auto_cast=True)
|
|
495
|
+
super().__init__(config)
|
|
496
|
+
|
|
497
|
+
def get_database_url(self, default: str | None = None) -> str:
|
|
498
|
+
"""Obtém DATABASE_URL com validação básica."""
|
|
499
|
+
url = self.get("DATABASE_URL", default=default, required=default is None)
|
|
500
|
+
# Validação básica de formato
|
|
501
|
+
if url and "://" not in url:
|
|
502
|
+
raise ValidationError(
|
|
503
|
+
"DATABASE_URL", url, "URL inválida (formato esperado: scheme://...)"
|
|
504
|
+
)
|
|
505
|
+
return url
|
|
506
|
+
|
|
507
|
+
def get_allowed_hosts(self) -> list[str]:
|
|
508
|
+
"""Obtém ALLOWED_HOSTS como lista."""
|
|
509
|
+
return self.get_list("ALLOWED_HOSTS", default=["localhost", "127.0.0.1"])
|
|
510
|
+
|
|
511
|
+
def get_debug(self, default: bool = False) -> bool:
|
|
512
|
+
"""Obtém DEBUG com segurança."""
|
|
513
|
+
return self.get_bool("DEBUG", default=default)
|
|
514
|
+
|
|
515
|
+
def get_secret_key(self) -> str:
|
|
516
|
+
"""Obtém SECRET_KEY (sempre obrigatória)."""
|
|
517
|
+
return self.get("SECRET_KEY", required=True, use_secrets=True)
|
|
@@ -0,0 +1,655 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-env-loader
|
|
3
|
+
Version: 1.0.3
|
|
4
|
+
Summary: Gerenciador robusto de variáveis de ambiente e secrets para Django
|
|
5
|
+
License: MIT
|
|
6
|
+
License-File: LICENSE
|
|
7
|
+
Keywords: django,env,environment,secrets,docker
|
|
8
|
+
Author: Felipe Abreu
|
|
9
|
+
Author-email: felipeabreu.rj@gmail.com
|
|
10
|
+
Requires-Python: >=3.10,<4.0
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Framework :: Django
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
23
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
24
|
+
Requires-Dist: python-dotenv (>=1.0.0,<2.0.0)
|
|
25
|
+
Project-URL: Homepage, https://github.com/felipeabreu86/django-env-loader
|
|
26
|
+
Project-URL: Repository, https://github.com/felipeabreu86/django-env-loader
|
|
27
|
+
Description-Content-Type: text/markdown
|
|
28
|
+
|
|
29
|
+
# Django Env Loader
|
|
30
|
+
|
|
31
|
+
**Gerenciador robusto e type-safe de variáveis de ambiente para projetos Python e Django.**
|
|
32
|
+
|
|
33
|
+
`django-env-loader` é uma biblioteca moderna que simplifica o gerenciamento de variáveis de ambiente e Docker secrets, oferecendo validação rigorosa, conversão automática de tipos e integração nativa com Django.
|
|
34
|
+
|
|
35
|
+
---
|
|
36
|
+
|
|
37
|
+
## ✨ Características
|
|
38
|
+
|
|
39
|
+
- 🔒 **Type-safe**: Suporte completo a type hints e validação de tipos
|
|
40
|
+
- 🐳 **Docker Secrets**: Suporte nativo a Docker secrets com fallback automático
|
|
41
|
+
- ⚡ **Performance**: Sistema de cache inteligente para secrets
|
|
42
|
+
- 🎯 **Django-ready**: Helpers especializados para configurações Django
|
|
43
|
+
- 🛡️ **Validação robusta**: Conversores seguros com tratamento de erros
|
|
44
|
+
- 🔧 **Configurável**: Prefixos, encoding, strict mode e muito mais
|
|
45
|
+
- 📝 **Bem documentado**: Type hints completos e docstrings detalhadas
|
|
46
|
+
- 🧪 **Testado**: Cobertura de testes > 95%
|
|
47
|
+
|
|
48
|
+
---
|
|
49
|
+
|
|
50
|
+
## 📦 Instalação
|
|
51
|
+
|
|
52
|
+
```bash
|
|
53
|
+
pip install django-env-loader
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Para uso com Django (instala dependências adicionais):
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
pip install django-env-loader[django]
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## 🚀 Quick Start
|
|
65
|
+
|
|
66
|
+
### Uso básico
|
|
67
|
+
|
|
68
|
+
```python
|
|
69
|
+
# Forma mais simples - usa instância singleton pré-configurada
|
|
70
|
+
from django_env_loader import env_loader
|
|
71
|
+
|
|
72
|
+
# Obtém variáveis simples
|
|
73
|
+
api_key = env_loader.get("API_KEY", required=True)
|
|
74
|
+
debug = env_loader.get_bool("DEBUG", default=False)
|
|
75
|
+
port = env_loader.get_int("PORT", default=8000)
|
|
76
|
+
|
|
77
|
+
# Listas e dicionários
|
|
78
|
+
allowed_hosts = env_loader.get_list("ALLOWED_HOSTS", default=["localhost"])
|
|
79
|
+
database_options = env_loader.get_dict("DB_OPTIONS") # FORMAT: key1=val1,key2=val2
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
**Ou crie uma instância customizada:**
|
|
83
|
+
|
|
84
|
+
```python
|
|
85
|
+
from django_env_loader import EnvLoader, EnvConfig
|
|
86
|
+
from pathlib import Path
|
|
87
|
+
|
|
88
|
+
config = EnvConfig(env_file=Path(".env.production"), prefix="MYAPP_")
|
|
89
|
+
loader = EnvLoader(config)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
### Uso com Django
|
|
93
|
+
|
|
94
|
+
```python
|
|
95
|
+
# settings.py
|
|
96
|
+
# Opção 1: Singleton simples
|
|
97
|
+
from django_env_loader import env_loader
|
|
98
|
+
|
|
99
|
+
SECRET_KEY = env_loader.get("SECRET_KEY", required=True)
|
|
100
|
+
DEBUG = env_loader.get_bool("DEBUG", default=False)
|
|
101
|
+
ALLOWED_HOSTS = env_loader.get_list("ALLOWED_HOSTS", default=["localhost"])
|
|
102
|
+
|
|
103
|
+
# Opção 2: DjangoEnvLoader com helpers especializados
|
|
104
|
+
from django_env_loader import DjangoEnvLoader
|
|
105
|
+
|
|
106
|
+
env = DjangoEnvLoader()
|
|
107
|
+
|
|
108
|
+
# Configurações Django com validação automática
|
|
109
|
+
SECRET_KEY = env.get_secret_key() # Obrigatória, busca em secrets primeiro
|
|
110
|
+
DEBUG = env.get_debug(default=False)
|
|
111
|
+
ALLOWED_HOSTS = env.get_allowed_hosts()
|
|
112
|
+
DATABASE_URL = env.get_database_url()
|
|
113
|
+
|
|
114
|
+
# Outras configurações
|
|
115
|
+
DATABASES = {
|
|
116
|
+
'default': dj_database_url.parse(DATABASE_URL)
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
EMAIL_HOST = env.get("EMAIL_HOST", default="localhost")
|
|
120
|
+
EMAIL_PORT = env.get_int("EMAIL_PORT", default=587)
|
|
121
|
+
EMAIL_USE_TLS = env.get_bool("EMAIL_USE_TLS", default=True)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
---
|
|
125
|
+
|
|
126
|
+
## 📚 Documentação Completa
|
|
127
|
+
|
|
128
|
+
### Configuração Avançada
|
|
129
|
+
|
|
130
|
+
O `EnvLoader` aceita uma configuração personalizada através da classe `EnvConfig`:
|
|
131
|
+
|
|
132
|
+
```python
|
|
133
|
+
from django_env_loader import EnvLoader, EnvConfig
|
|
134
|
+
from pathlib import Path
|
|
135
|
+
|
|
136
|
+
config = EnvConfig(
|
|
137
|
+
env_file=Path(".env.production"), # Arquivo .env customizado
|
|
138
|
+
secrets_dir=Path("/run/secrets"), # Diretório de Docker secrets
|
|
139
|
+
encoding="utf-8", # Encoding dos arquivos
|
|
140
|
+
prefix="MYAPP_", # Prefixo para todas as variáveis
|
|
141
|
+
override_existing=False, # Não sobrescreve vars já definidas
|
|
142
|
+
auto_cast=True, # Conversão automática de tipos
|
|
143
|
+
cache_secrets=True, # Cache de secrets
|
|
144
|
+
strict_mode=False, # False = warnings, True = exceções
|
|
145
|
+
warn_on_missing=True, # Avisa sobre variáveis não encontradas
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
loader = EnvLoader(config)
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
### Métodos de Obtenção de Variáveis
|
|
152
|
+
|
|
153
|
+
#### `get()` - Método base
|
|
154
|
+
|
|
155
|
+
```python
|
|
156
|
+
# Obter string simples
|
|
157
|
+
value = loader.get("MY_VAR", default="default_value")
|
|
158
|
+
|
|
159
|
+
# Obrigatória (levanta SecretNotFoundError se não encontrada)
|
|
160
|
+
value = loader.get("REQUIRED_VAR", required=True)
|
|
161
|
+
|
|
162
|
+
# Sem buscar em Docker secrets
|
|
163
|
+
value = loader.get("ENV_ONLY", use_secrets=False)
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
#### `get_bool()` - Booleanos
|
|
167
|
+
|
|
168
|
+
Aceita múltiplos formatos: `true/false`, `1/0`, `yes/no`, `on/off`, `sim/não`
|
|
169
|
+
|
|
170
|
+
```python
|
|
171
|
+
debug = loader.get_bool("DEBUG", default=False)
|
|
172
|
+
maintenance = loader.get_bool("MAINTENANCE_MODE", required=True)
|
|
173
|
+
|
|
174
|
+
# Valores aceitos para True
|
|
175
|
+
# "true", "1", "yes", "y", "on", "t", "sim", "s"
|
|
176
|
+
|
|
177
|
+
# Valores aceitos para False
|
|
178
|
+
# "false", "0", "no", "n", "off", "f", "não", "nao", ""
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
#### `get_int()` - Números inteiros
|
|
182
|
+
|
|
183
|
+
```python
|
|
184
|
+
port = loader.get_int("PORT", default=8000)
|
|
185
|
+
workers = loader.get_int("WORKERS", required=True)
|
|
186
|
+
timeout = loader.get_int("TIMEOUT", default=30)
|
|
187
|
+
```
|
|
188
|
+
|
|
189
|
+
#### `get_float()` - Números decimais
|
|
190
|
+
|
|
191
|
+
```python
|
|
192
|
+
tax_rate = loader.get_float("TAX_RATE", default=0.15)
|
|
193
|
+
temperature = loader.get_float("MAX_TEMP", default=98.6)
|
|
194
|
+
```
|
|
195
|
+
|
|
196
|
+
#### `get_list()` - Listas
|
|
197
|
+
|
|
198
|
+
```python
|
|
199
|
+
# Formato: item1,item2,item3
|
|
200
|
+
hosts = loader.get_list("ALLOWED_HOSTS", default=["localhost"])
|
|
201
|
+
|
|
202
|
+
# Delimitador customizado
|
|
203
|
+
emails = loader.get_list("ADMIN_EMAILS", delimiter=";")
|
|
204
|
+
|
|
205
|
+
# De variável de ambiente:
|
|
206
|
+
# ALLOWED_HOSTS=example.com,api.example.com,www.example.com
|
|
207
|
+
# Resultado: ["example.com", "api.example.com", "www.example.com"]
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
#### `get_dict()` - Dicionários
|
|
211
|
+
|
|
212
|
+
```python
|
|
213
|
+
# Formato: key1=value1,key2=value2
|
|
214
|
+
options = loader.get_dict("DB_OPTIONS")
|
|
215
|
+
|
|
216
|
+
# Delimitador customizado
|
|
217
|
+
config = loader.get_dict("FEATURE_FLAGS", delimiter=";")
|
|
218
|
+
|
|
219
|
+
# De variável de ambiente:
|
|
220
|
+
# DB_OPTIONS=timeout=30,pool_size=10,ssl=true
|
|
221
|
+
# Resultado: {"timeout": "30", "pool_size": "10", "ssl": "true"}
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
#### `get_with_validator()` - Validação customizada
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from typing import Callable
|
|
228
|
+
from datetime import datetime
|
|
229
|
+
|
|
230
|
+
# Validador de email
|
|
231
|
+
def validate_email(value: str) -> str:
|
|
232
|
+
if "@" not in value:
|
|
233
|
+
raise ValueError(f"Email inválido: {value}")
|
|
234
|
+
return value.lower()
|
|
235
|
+
|
|
236
|
+
admin_email = loader.get_with_validator(
|
|
237
|
+
"ADMIN_EMAIL",
|
|
238
|
+
validator=validate_email,
|
|
239
|
+
required=True
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
# Validador de data
|
|
243
|
+
def parse_date(value: str) -> datetime:
|
|
244
|
+
return datetime.fromisoformat(value)
|
|
245
|
+
|
|
246
|
+
launch_date = loader.get_with_validator(
|
|
247
|
+
"LAUNCH_DATE",
|
|
248
|
+
validator=parse_date,
|
|
249
|
+
default=datetime.now()
|
|
250
|
+
)
|
|
251
|
+
|
|
252
|
+
# Validador de URL
|
|
253
|
+
def validate_url(value: str) -> str:
|
|
254
|
+
if not value.startswith(("http://", "https://")):
|
|
255
|
+
raise ValueError(f"URL deve começar com http:// ou https://")
|
|
256
|
+
return value.rstrip("/")
|
|
257
|
+
|
|
258
|
+
api_url = loader.get_with_validator(
|
|
259
|
+
"API_URL",
|
|
260
|
+
validator=validate_url,
|
|
261
|
+
required=True
|
|
262
|
+
)
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
### Docker Secrets
|
|
266
|
+
|
|
267
|
+
O loader busca automaticamente em Docker secrets antes de tentar variáveis de ambiente:
|
|
268
|
+
|
|
269
|
+
```python
|
|
270
|
+
# Ordem de busca:
|
|
271
|
+
# 1. /run/secrets/DATABASE_PASSWORD (Docker secret)
|
|
272
|
+
# 2. DATABASE_PASSWORD (variável de ambiente)
|
|
273
|
+
|
|
274
|
+
db_password = loader.get("DATABASE_PASSWORD", required=True, use_secrets=True)
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
**Exemplo com Docker Compose:**
|
|
278
|
+
|
|
279
|
+
```yaml
|
|
280
|
+
# docker-compose.yml
|
|
281
|
+
version: '3.8'
|
|
282
|
+
|
|
283
|
+
services:
|
|
284
|
+
web:
|
|
285
|
+
image: myapp:latest
|
|
286
|
+
secrets:
|
|
287
|
+
- db_password
|
|
288
|
+
- secret_key
|
|
289
|
+
environment:
|
|
290
|
+
- DEBUG=false
|
|
291
|
+
- DATABASE_HOST=db
|
|
292
|
+
|
|
293
|
+
secrets:
|
|
294
|
+
db_password:
|
|
295
|
+
file: ./secrets/db_password.txt
|
|
296
|
+
secret_key:
|
|
297
|
+
file: ./secrets/secret_key.txt
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
```python
|
|
301
|
+
# settings.py
|
|
302
|
+
from django_env_loader import DjangoEnvLoader
|
|
303
|
+
|
|
304
|
+
env = DjangoEnvLoader()
|
|
305
|
+
|
|
306
|
+
# Busca automaticamente em /run/secrets/SECRET_KEY
|
|
307
|
+
SECRET_KEY = env.get_secret_key()
|
|
308
|
+
|
|
309
|
+
# Busca em /run/secrets/DATABASE_PASSWORD
|
|
310
|
+
db_password = env.get("DATABASE_PASSWORD", required=True)
|
|
311
|
+
```
|
|
312
|
+
|
|
313
|
+
### Verificação de Variáveis
|
|
314
|
+
|
|
315
|
+
```python
|
|
316
|
+
# Verifica se variável existe e não está vazia
|
|
317
|
+
if loader.is_set("FEATURE_FLAG"):
|
|
318
|
+
enable_feature()
|
|
319
|
+
|
|
320
|
+
# Obtém todas as variáveis carregadas
|
|
321
|
+
all_vars = loader.get_all(include_secrets=False)
|
|
322
|
+
print(all_vars)
|
|
323
|
+
```
|
|
324
|
+
|
|
325
|
+
### Gerenciamento de Cache
|
|
326
|
+
|
|
327
|
+
```python
|
|
328
|
+
# Limpar cache de secrets (útil para recarregar valores)
|
|
329
|
+
loader.clear_cache()
|
|
330
|
+
|
|
331
|
+
# Reset completo do singleton (útil em testes)
|
|
332
|
+
from django_env_loader import EnvLoader
|
|
333
|
+
EnvLoader.reset_singleton()
|
|
334
|
+
```
|
|
335
|
+
|
|
336
|
+
---
|
|
337
|
+
|
|
338
|
+
## 🎯 Casos de Uso Comuns
|
|
339
|
+
|
|
340
|
+
### 1. Configuração Multi-ambiente
|
|
341
|
+
|
|
342
|
+
```python
|
|
343
|
+
# .env.development
|
|
344
|
+
DEBUG=true
|
|
345
|
+
DATABASE_URL=sqlite:///db.sqlite3
|
|
346
|
+
ALLOWED_HOSTS=localhost,127.0.0.1
|
|
347
|
+
|
|
348
|
+
# .env.production
|
|
349
|
+
DEBUG=false
|
|
350
|
+
DATABASE_URL=postgresql://user:pass@db:5432/mydb
|
|
351
|
+
ALLOWED_HOSTS=myapp.com,www.myapp.com
|
|
352
|
+
```
|
|
353
|
+
|
|
354
|
+
```python
|
|
355
|
+
# settings.py
|
|
356
|
+
import os
|
|
357
|
+
from pathlib import Path
|
|
358
|
+
from django_env_loader import env_loader, EnvLoader, EnvConfig
|
|
359
|
+
|
|
360
|
+
# Opção 1: Usando singleton com config customizada
|
|
361
|
+
ENVIRONMENT = os.getenv("ENVIRONMENT", "development")
|
|
362
|
+
env_file = Path(f".env.{ENVIRONMENT}")
|
|
363
|
+
|
|
364
|
+
# Reconfigura o singleton (faça isso antes de qualquer uso)
|
|
365
|
+
EnvLoader.reset_singleton()
|
|
366
|
+
config = EnvConfig(env_file=env_file, strict_mode=True)
|
|
367
|
+
env = EnvLoader(config)
|
|
368
|
+
|
|
369
|
+
# Opção 2: Criar nova instância (não recomendado se usar singleton em outros lugares)
|
|
370
|
+
# config = EnvConfig(env_file=env_file, strict_mode=True)
|
|
371
|
+
# env = EnvLoader(config)
|
|
372
|
+
|
|
373
|
+
DEBUG = env.get_bool("DEBUG")
|
|
374
|
+
DATABASE_URL = env.get("DATABASE_URL", required=True)
|
|
375
|
+
ALLOWED_HOSTS = env.get_list("ALLOWED_HOSTS")
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
### 2. Validação de Configurações Obrigatórias
|
|
379
|
+
|
|
380
|
+
```python
|
|
381
|
+
from django_env_loader import env_loader, SecretNotFoundError
|
|
382
|
+
|
|
383
|
+
try:
|
|
384
|
+
# Garante que variáveis críticas existem
|
|
385
|
+
api_key = env_loader.get("API_KEY", required=True)
|
|
386
|
+
secret_key = env_loader.get("SECRET_KEY", required=True)
|
|
387
|
+
database_url = env_loader.get("DATABASE_URL", required=True)
|
|
388
|
+
|
|
389
|
+
except SecretNotFoundError as e:
|
|
390
|
+
print(f"❌ Configuração faltando: {e}")
|
|
391
|
+
print(f" Locais buscados: {e.searched_locations}")
|
|
392
|
+
import sys
|
|
393
|
+
sys.exit(1)
|
|
394
|
+
```
|
|
395
|
+
|
|
396
|
+
### 3. Feature Flags
|
|
397
|
+
|
|
398
|
+
```python
|
|
399
|
+
# .env
|
|
400
|
+
FEATURE_FLAGS=new_ui=true,beta_api=false,analytics=true
|
|
401
|
+
|
|
402
|
+
# app.py
|
|
403
|
+
from django_env_loader import env_loader
|
|
404
|
+
|
|
405
|
+
features = env_loader.get_dict("FEATURE_FLAGS")
|
|
406
|
+
|
|
407
|
+
if features.get("new_ui") == "true":
|
|
408
|
+
enable_new_ui()
|
|
409
|
+
|
|
410
|
+
if features.get("beta_api") == "true":
|
|
411
|
+
enable_beta_api()
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### 4. Configuração de Múltiplos Serviços
|
|
415
|
+
|
|
416
|
+
```python
|
|
417
|
+
from django_env_loader import EnvLoader, EnvConfig
|
|
418
|
+
|
|
419
|
+
# Serviço de email
|
|
420
|
+
email_config = EnvConfig(prefix="EMAIL_")
|
|
421
|
+
email_loader = EnvLoader(email_config)
|
|
422
|
+
|
|
423
|
+
EMAIL_CONFIG = {
|
|
424
|
+
"host": email_loader.get("HOST", default="localhost"),
|
|
425
|
+
"port": email_loader.get_int("PORT", default=587),
|
|
426
|
+
"username": email_loader.get("USERNAME"),
|
|
427
|
+
"password": email_loader.get("PASSWORD", use_secrets=True),
|
|
428
|
+
"use_tls": email_loader.get_bool("USE_TLS", default=True),
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
# Serviço de cache
|
|
432
|
+
cache_config = EnvConfig(prefix="REDIS_")
|
|
433
|
+
cache_loader = EnvLoader(cache_config)
|
|
434
|
+
|
|
435
|
+
REDIS_CONFIG = {
|
|
436
|
+
"host": cache_loader.get("HOST", default="localhost"),
|
|
437
|
+
"port": cache_loader.get_int("PORT", default=6379),
|
|
438
|
+
"db": cache_loader.get_int("DB", default=0),
|
|
439
|
+
"password": cache_loader.get("PASSWORD", use_secrets=True),
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
### 5. Integração com Pydantic Settings
|
|
444
|
+
|
|
445
|
+
```python
|
|
446
|
+
from pydantic_settings import BaseSettings
|
|
447
|
+
from django_env_loader import env_loader
|
|
448
|
+
|
|
449
|
+
class Settings(BaseSettings):
|
|
450
|
+
app_name: str = env_loader.get("APP_NAME", default="MyApp")
|
|
451
|
+
debug: bool = env_loader.get_bool("DEBUG", default=False)
|
|
452
|
+
database_url: str = env_loader.get("DATABASE_URL", required=True)
|
|
453
|
+
secret_key: str = env_loader.get("SECRET_KEY", required=True)
|
|
454
|
+
allowed_hosts: list[str] = env_loader.get_list("ALLOWED_HOSTS")
|
|
455
|
+
|
|
456
|
+
class Config:
|
|
457
|
+
case_sensitive = False
|
|
458
|
+
|
|
459
|
+
settings = Settings()
|
|
460
|
+
```
|
|
461
|
+
|
|
462
|
+
---
|
|
463
|
+
|
|
464
|
+
## 🔒 Segurança
|
|
465
|
+
|
|
466
|
+
### Boas Práticas
|
|
467
|
+
|
|
468
|
+
1. **Nunca commite arquivos `.env`** com dados sensíveis
|
|
469
|
+
```bash
|
|
470
|
+
# .gitignore
|
|
471
|
+
.env
|
|
472
|
+
.env.*
|
|
473
|
+
!.env.example
|
|
474
|
+
```
|
|
475
|
+
|
|
476
|
+
2. **Use Docker secrets** para dados sensíveis em produção
|
|
477
|
+
```python
|
|
478
|
+
# Prioriza secrets sobre env vars
|
|
479
|
+
secret_key = loader.get("SECRET_KEY", use_secrets=True, required=True)
|
|
480
|
+
```
|
|
481
|
+
|
|
482
|
+
3. **Valide configurações** no startup
|
|
483
|
+
```python
|
|
484
|
+
# manage.py ou app startup
|
|
485
|
+
from django_env_loader import DjangoEnvLoader, SecretNotFoundError
|
|
486
|
+
|
|
487
|
+
env = DjangoEnvLoader()
|
|
488
|
+
|
|
489
|
+
try:
|
|
490
|
+
env.get_secret_key()
|
|
491
|
+
env.get_database_url()
|
|
492
|
+
except SecretNotFoundError as e:
|
|
493
|
+
logger.critical(f"Configuração crítica faltando: {e}")
|
|
494
|
+
sys.exit(1)
|
|
495
|
+
```
|
|
496
|
+
|
|
497
|
+
4. **Use strict_mode** em produção
|
|
498
|
+
```python
|
|
499
|
+
config = EnvConfig(strict_mode=True)
|
|
500
|
+
loader = EnvLoader(config)
|
|
501
|
+
# Agora erros de validação levantam exceções em vez de warnings
|
|
502
|
+
```
|
|
503
|
+
|
|
504
|
+
---
|
|
505
|
+
|
|
506
|
+
## 🧪 Testes
|
|
507
|
+
|
|
508
|
+
### Testando código que usa EnvLoader
|
|
509
|
+
|
|
510
|
+
```python
|
|
511
|
+
import pytest
|
|
512
|
+
from django_env_loader import EnvLoader, EnvConfig
|
|
513
|
+
from pathlib import Path
|
|
514
|
+
|
|
515
|
+
@pytest.fixture
|
|
516
|
+
def loader():
|
|
517
|
+
"""Fixture que cria loader limpo para cada teste."""
|
|
518
|
+
EnvLoader.reset_singleton()
|
|
519
|
+
config = EnvConfig(env_file=Path("tests/.env.test"))
|
|
520
|
+
return EnvLoader(config)
|
|
521
|
+
|
|
522
|
+
def test_get_database_url(loader, monkeypatch):
|
|
523
|
+
"""Testa obtenção de DATABASE_URL."""
|
|
524
|
+
monkeypatch.setenv("DATABASE_URL", "postgresql://localhost/testdb")
|
|
525
|
+
|
|
526
|
+
url = loader.get("DATABASE_URL", required=True)
|
|
527
|
+
assert url == "postgresql://localhost/testdb"
|
|
528
|
+
|
|
529
|
+
def test_missing_required_variable(loader):
|
|
530
|
+
"""Testa erro ao buscar variável obrigatória inexistente."""
|
|
531
|
+
from django_env_loader import SecretNotFoundError
|
|
532
|
+
|
|
533
|
+
with pytest.raises(SecretNotFoundError) as exc_info:
|
|
534
|
+
loader.get("NONEXISTENT", required=True)
|
|
535
|
+
|
|
536
|
+
assert "NONEXISTENT" in str(exc_info.value)
|
|
537
|
+
|
|
538
|
+
def test_bool_conversion(loader, monkeypatch):
|
|
539
|
+
"""Testa conversão de valores booleanos."""
|
|
540
|
+
monkeypatch.setenv("FEATURE_ENABLED", "true")
|
|
541
|
+
assert loader.get_bool("FEATURE_ENABLED") is True
|
|
542
|
+
|
|
543
|
+
monkeypatch.setenv("FEATURE_DISABLED", "0")
|
|
544
|
+
assert loader.get_bool("FEATURE_DISABLED") is False
|
|
545
|
+
```
|
|
546
|
+
|
|
547
|
+
---
|
|
548
|
+
|
|
549
|
+
## 📖 API Reference
|
|
550
|
+
|
|
551
|
+
### Classes Principais
|
|
552
|
+
|
|
553
|
+
#### `EnvLoader`
|
|
554
|
+
Gerenciador principal de variáveis de ambiente.
|
|
555
|
+
|
|
556
|
+
**Métodos:**
|
|
557
|
+
- `get(key, *, default, required, use_secrets)` → `str | T`
|
|
558
|
+
- `get_bool(key, *, default, required, use_secrets)` → `bool`
|
|
559
|
+
- `get_int(key, *, default, required, use_secrets)` → `int`
|
|
560
|
+
- `get_float(key, *, default, required, use_secrets)` → `float`
|
|
561
|
+
- `get_list(key, *, default, delimiter, required, use_secrets)` → `list[str]`
|
|
562
|
+
- `get_dict(key, *, default, delimiter, required, use_secrets)` → `dict[str, str]`
|
|
563
|
+
- `get_with_validator(key, validator, *, default, required, use_secrets)` → `T | None`
|
|
564
|
+
- `is_set(key, *, use_secrets)` → `bool`
|
|
565
|
+
- `get_all(*, include_secrets)` → `dict[str, str]`
|
|
566
|
+
- `clear_cache()` → `None`
|
|
567
|
+
- `reset_singleton()` → `None` (class method)
|
|
568
|
+
|
|
569
|
+
#### `DjangoEnvLoader`
|
|
570
|
+
Subclasse especializada para Django.
|
|
571
|
+
|
|
572
|
+
**Métodos adicionais:**
|
|
573
|
+
- `get_database_url(default)` → `str`
|
|
574
|
+
- `get_allowed_hosts()` → `list[str]`
|
|
575
|
+
- `get_debug(default)` → `bool`
|
|
576
|
+
- `get_secret_key()` → `str`
|
|
577
|
+
|
|
578
|
+
#### `EnvConfig`
|
|
579
|
+
Configuração do EnvLoader.
|
|
580
|
+
|
|
581
|
+
**Atributos:**
|
|
582
|
+
- `env_file: Path | str | None`
|
|
583
|
+
- `secrets_dir: Path`
|
|
584
|
+
- `encoding: str`
|
|
585
|
+
- `prefix: str`
|
|
586
|
+
- `override_existing: bool`
|
|
587
|
+
- `auto_cast: bool`
|
|
588
|
+
- `cache_secrets: bool`
|
|
589
|
+
- `strict_mode: bool`
|
|
590
|
+
- `warn_on_missing: bool`
|
|
591
|
+
|
|
592
|
+
### Exceções
|
|
593
|
+
|
|
594
|
+
#### `SecretNotFoundError`
|
|
595
|
+
Levantada quando um secret obrigatório não é encontrado.
|
|
596
|
+
|
|
597
|
+
**Atributos:**
|
|
598
|
+
- `key: str` - Nome da variável
|
|
599
|
+
- `searched_locations: list[str]` - Locais onde foi buscada
|
|
600
|
+
|
|
601
|
+
#### `ValidationError`
|
|
602
|
+
Levantada quando a validação de uma variável falha.
|
|
603
|
+
|
|
604
|
+
**Atributos:**
|
|
605
|
+
- `key: str` - Nome da variável
|
|
606
|
+
- `value: Any` - Valor que falhou na validação
|
|
607
|
+
- `reason: str` - Motivo da falha
|
|
608
|
+
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
## 🤝 Contribuindo
|
|
612
|
+
|
|
613
|
+
Contribuições são bem-vindas! Por favor:
|
|
614
|
+
|
|
615
|
+
1. Faça um fork do projeto
|
|
616
|
+
2. Crie uma branch para sua feature (`git checkout -b feature/AmazingFeature`)
|
|
617
|
+
3. Commit suas mudanças (`git commit -m 'Add some AmazingFeature'`)
|
|
618
|
+
4. Push para a branch (`git push origin feature/AmazingFeature`)
|
|
619
|
+
5. Abra um Pull Request
|
|
620
|
+
|
|
621
|
+
### Diretrizes de Desenvolvimento
|
|
622
|
+
|
|
623
|
+
- Escreva testes para novas funcionalidades
|
|
624
|
+
- Mantenha cobertura de testes > 95%
|
|
625
|
+
- Use type hints em todo o código
|
|
626
|
+
- Siga o guia de estilo PEP 8
|
|
627
|
+
- Atualize a documentação
|
|
628
|
+
|
|
629
|
+
---
|
|
630
|
+
|
|
631
|
+
## 📝 Changelog
|
|
632
|
+
|
|
633
|
+
Veja o arquivo [CHANGELOG](CHANGELOG.md) para histórico de mudanças.
|
|
634
|
+
|
|
635
|
+
---
|
|
636
|
+
|
|
637
|
+
## 📄 Licença
|
|
638
|
+
|
|
639
|
+
Este projeto está licenciado sob a Licença MIT - veja o arquivo [LICENSE](LICENSE) para detalhes.
|
|
640
|
+
|
|
641
|
+
---
|
|
642
|
+
|
|
643
|
+
## 📬 Suporte
|
|
644
|
+
|
|
645
|
+
- **Issues**: [GitHub Issues](https://github.com/felipeabreu86/django-env-loader/issues)
|
|
646
|
+
- **Discussões**: [GitHub Discussions](https://github.com/felipeabreu86/django-env-loader/discussions)
|
|
647
|
+
|
|
648
|
+
---
|
|
649
|
+
|
|
650
|
+
## 🔗 Links Úteis
|
|
651
|
+
|
|
652
|
+
- [PyPI](https://pypi.org/project/django-env-loader/)
|
|
653
|
+
- [Código fonte](https://github.com/felipeabreu86/django-env-loader)
|
|
654
|
+
- [Exemplos](https://github.com/felipeabreu86/django-env-loader/tree/main/examples)
|
|
655
|
+
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
django_env_loader/__init__.py,sha256=F6T6o86XwoJmrokaiBxul2howm0iIUvmLqxFFbqPE3U,578
|
|
2
|
+
django_env_loader/exceptions.py,sha256=ewHeViHT7dKR4fe7MXx6pytsJyOFSY4WyhLrDUECybI,1117
|
|
3
|
+
django_env_loader/loader.py,sha256=SPkCbLL-yDn5oQ08vyvGsWgMv9S2d1KeF7YzS-3wA9o,17517
|
|
4
|
+
django_env_loader/py.typed,sha256=-VPOUwqsDZdR7p-C4JfqXoX-f-VI-cz8NL4iSwj0FQ4,111
|
|
5
|
+
django_env_loader-1.0.3.dist-info/licenses/LICENSE,sha256=jD4BZSgz5L_C_XAhg2Ny7GlWVv8D7__c1qSQxq12eeo,1068
|
|
6
|
+
django_env_loader-1.0.3.dist-info/METADATA,sha256=PULvqQxh_7iaOh-Uvw1yEeQYk0OcAoUvd9bwBzOyP5w,17711
|
|
7
|
+
django_env_loader-1.0.3.dist-info/WHEEL,sha256=zp0Cn7JsFoX2ATtOhtaFYIiE2rmFAD4OcMhtUki8W3U,88
|
|
8
|
+
django_env_loader-1.0.3.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2024 Felipe Abreu
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|