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.
@@ -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,2 @@
1
+ # Marker file for PEP 561
2
+ # This empty file indicates that the django_env_loader package supports type checking
@@ -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,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: poetry-core 2.2.1
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -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.