diff --git a/dataclass_wizard/bases.py b/dataclass_wizard/bases.py index 273f364b..542cf9fd 100644 --- a/dataclass_wizard/bases.py +++ b/dataclass_wizard/bases.py @@ -1,5 +1,6 @@ from abc import ABCMeta, abstractmethod -from typing import Callable, Type, Dict, Optional, ClassVar, Union, TypeVar +from collections.abc import Sequence +from typing import Callable, Type, Dict, Optional, ClassVar, Union, TypeVar, Sequence from .constants import TAG from .decorators import cached_class_property @@ -306,6 +307,15 @@ class AbstractEnvMeta(metaclass=ABCOrAndMeta): # env_file = '.env', '.env.last' env_file: ClassVar[EnvFileType] = None + # Prefix for all environment variables. Defaults to `None`. + env_prefix: ClassVar[str] = None + + # secrets_dir: The secret files directory or a sequence of directories. Defaults to `None`. + secrets_dir: ClassVar['EnvFileType | Sequence[EnvFileType]'] = None + + # The nested env values delimiter. Defaults to `None`. + # env_nested_delimiter: ClassVar[str] = None + # A customized mapping of field in the `EnvWizard` subclass to its # corresponding environment variable to search for. # diff --git a/dataclass_wizard/bases_meta.py b/dataclass_wizard/bases_meta.py index cdbb2a2a..18924a92 100644 --- a/dataclass_wizard/bases_meta.py +++ b/dataclass_wizard/bases_meta.py @@ -6,7 +6,7 @@ """ import logging from datetime import datetime, date -from typing import Type, Optional, Dict, Union +from typing import Type, Optional, Dict, Union, Sequence from .abstractions import AbstractJSONWizard from .bases import AbstractMeta, META, AbstractEnvMeta @@ -351,6 +351,8 @@ def DumpMeta(*, debug_enabled: 'bool | int | str' = False, # noinspection PyPep8Naming def EnvMeta(*, debug_enabled: 'bool | int | str' = False, env_file: EnvFileType = None, + env_prefix: str = '', + secrets_dir: 'EnvFileType | Sequence[EnvFileType]' = None, field_to_env_var: dict[str, str] = None, key_lookup_with_load: Union[LetterCasePriority, str] = LetterCasePriority.SCREAMING_SNAKE, key_transform_with_dump: Union[LetterCase, str] = LetterCase.SNAKE, @@ -378,6 +380,8 @@ def EnvMeta(*, debug_enabled: 'bool | int | str' = False, '__slots__': (), 'debug_enabled': debug_enabled, 'env_file': env_file, + 'env_prefix': env_prefix, + 'secrets_dir': secrets_dir, 'field_to_env_var': field_to_env_var, 'key_lookup_with_load': key_lookup_with_load, 'key_transform_with_dump': key_transform_with_dump, diff --git a/dataclass_wizard/environ/lookups.py b/dataclass_wizard/environ/lookups.py index 6d2f1f38..52825cd3 100644 --- a/dataclass_wizard/environ/lookups.py +++ b/dataclass_wizard/environ/lookups.py @@ -1,6 +1,5 @@ import os from dataclasses import MISSING -from os import environ, name from ..decorators import cached_class_property from ..lazy_imports import dotenv @@ -10,6 +9,9 @@ # Type of `os.environ` or `DotEnv` dict Environ = dict[str, 'str | None'] +# noinspection PyTypeChecker +environ: Environ = None + # noinspection PyMethodParameters class Env: @@ -18,6 +20,25 @@ class Env: _accessed_cleaned_to_env = False + @classmethod + def load_environ(cls, force_reload=False): + """ + Load :attr:`environ` from ``os.environ``. + + If `force_reload` is true, start fresh + and re-copy `os.environ`. + """ + global environ + + if (_env_not_setup := environ is None) or force_reload: + # Copy `os.environ`, so as not to mutate it + environ = os.environ.copy() + + if not _env_not_setup: + # Refresh `var_names`, in case env variables + # were removed (deleted) from `os.environ` + cls.var_names = set(environ) + @cached_class_property def var_names(cls): """ @@ -27,9 +48,14 @@ def var_names(cls): return set(environ) @classmethod - def reload(cls, env=environ): + def reload(cls, env=None): """Refresh cached environment variable names.""" env_vars = cls.var_names + + if env is None: + cls.load_environ(force_reload=True) + env = environ + new_vars = set(env) - env_vars # update names of environment variables @@ -72,7 +98,7 @@ def update_with_dotenv(cls, files='.env', dotenv_values=None): # reload cached mapping of environment variables cls.reload(dotenv_values) - # update `os.environ` with new environment variables + # update `environ` with new environment variables environ.update(dotenv_values) # noinspection PyDunderSlots,PyUnresolvedReferences,PyClassVar @@ -104,7 +130,7 @@ def try_cleaned(key): return MISSING -if name == 'nt': +if os.name == 'nt': # Where Env Var Names Must Be UPPERCASE def lookup_exact(var): """ diff --git a/dataclass_wizard/environ/lookups.pyi b/dataclass_wizard/environ/lookups.pyi index b80b5808..8792ba14 100644 --- a/dataclass_wizard/environ/lookups.pyi +++ b/dataclass_wizard/environ/lookups.pyi @@ -26,6 +26,9 @@ class Env: var_names: EnvVars + @classmethod + def load_environ(cls, force_reload=False) -> None: ... + @classmethod def reload(cls, env: dict = environ): ... diff --git a/dataclass_wizard/environ/wizard.py b/dataclass_wizard/environ/wizard.py index 81ba1d7c..8bf07ab8 100644 --- a/dataclass_wizard/environ/wizard.py +++ b/dataclass_wizard/environ/wizard.py @@ -1,5 +1,6 @@ import json import logging +from collections.abc import Sequence from dataclasses import MISSING, dataclass, fields from typing import Callable @@ -77,6 +78,8 @@ def to_json(self, *, def __init_subclass__(cls, *, reload_env=False, debug=False, key_transform=LetterCase.NONE): + Env.load_environ() + if reload_env: # reload cached var names from `os.environ` as needed. Env.reload() @@ -132,7 +135,6 @@ def _create_methods(cls): _meta_env_file = meta.env_file _locals = {'Env': Env, - 'EnvFileType': EnvFileType, 'MISSING': MISSING, 'ParseError': ParseError, 'field_names': field_names, @@ -143,12 +145,18 @@ def _create_methods(cls): 'add': _add_missing_var, 'cls': cls, 'fields_ordered': cls_fields.keys(), - 'handle_err': _handle_parse_error} + 'handle_err': _handle_parse_error, + 'EnvFileType': EnvFileType, + 'Sequence': Sequence, + } # parameters to the `__init__()` method. init_params = ['self', '_env_file:EnvFileType=None', - '_reload:bool=False'] + '_reload:bool=False', + f'_env_prefix:str={meta.env_prefix!r}', + '_secrets_dir:EnvFileType|Sequence[EnvFileType]=None', + ] fn_gen = FunctionBuilder() @@ -171,42 +179,49 @@ def _create_methods(cls): # each one. fn_gen.add_line('missing_vars = []') - for name, f in cls_fields.items(): - type_field = f'_tp_{name}' - tp = _globals[type_field] = f.type - - init_params.append(f'{name}:{type_field}=MISSING') - - # retrieve value (if it exists) for the environment variable - - env_var = field_to_var.get(name) - if env_var: - part = f'({name} := lookup_exact({env_var!r}))' - else: - part = f'({name} := get_env({name!r}))' - - with fn_gen.if_(f'{name} is not MISSING or {part} is not MISSING'): - parser_name = f'_parser_{name}' - _globals[parser_name] = getattr(p := cls_loader.get_parser_for_annotation( - tp, cls, extras), '__call__', p) - with fn_gen.try_(): - fn_gen.add_line(f'self.{name} = {parser_name}({name})') - with fn_gen.except_(ParseError, 'e'): - fn_gen.add_line(f'handle_err(e, cls, {name!r}, {env_var!r})') - # this `else` block means that a value was not received for the - # field, either via keyword arguments or Environment. - with fn_gen.else_(): - # check if the field defines a `default` or `default_factory` - # value; note this is similar to how `dataclasses` does it. - default_name = f'_dflt_{name}' - if f.default is not MISSING: - _globals[default_name] = f.default - fn_gen.add_line(f'self.{name} = {default_name}') - elif f.default_factory is not MISSING: - _globals[default_name] = f.default_factory - fn_gen.add_line(f'self.{name} = {default_name}()') - else: - fn_gen.add_line(f'add(missing_vars, {name!r}, {type_field})') + if field_names: + + with fn_gen.try_(): + + for name, f in cls_fields.items(): + type_field = f'_tp_{name}' + tp = _globals[type_field] = f.type + + init_params.append(f'{name}:{type_field}=MISSING') + + # retrieve value (if it exists) for the environment variable + + env_var = var_name = field_to_var.get(name) + if env_var: + part = f'({name} := lookup_exact(var_name))' + else: + var_name = name + part = f'({name} := get_env(var_name))' + + fn_gen.add_line(f'name={name!r}; env_var={env_var!r}; var_name=f"{{_env_prefix}}{var_name}" if _env_prefix else {var_name!r}') + + with fn_gen.if_(f'{name} is not MISSING or {part} is not MISSING'): + parser_name = f'_parser_{name}' + _globals[parser_name] = getattr(p := cls_loader.get_parser_for_annotation( + tp, cls, extras), '__call__', p) + fn_gen.add_line(f'self.{name} = {parser_name}({name})') + # this `else` block means that a value was not received for the + # field, either via keyword arguments or Environment. + with fn_gen.else_(): + # check if the field defines a `default` or `default_factory` + # value; note this is similar to how `dataclasses` does it. + default_name = f'_dflt_{name}' + if f.default is not MISSING: + _globals[default_name] = f.default + fn_gen.add_line(f'self.{name} = {default_name}') + elif f.default_factory is not MISSING: + _globals[default_name] = f.default_factory + fn_gen.add_line(f'self.{name} = {default_name}()') + else: + fn_gen.add_line(f'add(missing_vars, name, {type_field})') + + with fn_gen.except_(ParseError, 'e'): + fn_gen.add_line('handle_err(e, cls, name, env_var)') # check for any required fields with missing values with fn_gen.if_('missing_vars'): diff --git a/tests/unit/environ/.env.prefix b/tests/unit/environ/.env.prefix new file mode 100644 index 00000000..d78d816f --- /dev/null +++ b/tests/unit/environ/.env.prefix @@ -0,0 +1,4 @@ +MY_PREFIX_STR='my prefix value' +MY_PREFIX_BOOL=t +MY_PREFIX_INT='123.0' + diff --git a/tests/unit/environ/test_wizard.py b/tests/unit/environ/test_wizard.py index d3c0d1fb..8267d8de 100644 --- a/tests/unit/environ/test_wizard.py +++ b/tests/unit/environ/test_wizard.py @@ -444,3 +444,96 @@ class _(EnvWizard.Meta): # reset global flag for other tests that # rely on `debug_enabled` functionality dataclass_wizard.bases_meta._debug_was_enabled = False + + +def test_load_with_tuple_of_dotenv_and_env_prefix_param_to_init(): + """ + Test when `env_file` is specified as a tuple of dotenv files, and + the `_env_file` parameter is also passed in to the constructor + or __init__() method. Additionally, test prefixing environment + variables using `Meta.env_prefix` and `_env_prefix` in __init__(). + """ + + os.environ.update( + PREFIXED_MY_STR='prefixed string', + PREFIXED_MY_VALUE='12.34', + PREFIXED_OTHER_KEY='10', + MY_STR='default from env', + MY_VALUE='3322.11', + OTHER_KEY='5', + ) + + class MyClass(EnvWizard): + class _(EnvWizard.Meta): + env_file = '.env', here / '.env.test' + env_prefix = 'PREFIXED_' # Static prefix + key_lookup_with_load = 'PASCAL' + + my_value: float + my_str: str + other_key: int = 3 + + # Test without prefix + c = MyClass(_env_file=False, _reload=True, + _env_prefix=None) + + assert c.dict() == {'my_str': 'default from env', + 'my_value': 3322.11, + 'other_key': 5} + + # Test with Meta.env_prefix applied + c = MyClass(other_key=7) + + assert c.dict() == {'my_str': 'prefixed string', + 'my_value': 12.34, + 'other_key': 7} + + # Override prefix dynamically with _env_prefix + c = MyClass(_env_file=False, _env_prefix='', _reload=True) + + assert c.dict() == {'my_str': 'default from env', + 'my_value': 3322.11, + 'other_key': 5} + + # Dynamically set a new prefix via _env_prefix + c = MyClass(_env_prefix='PREFIXED_') + + assert c.dict() == {'my_str': 'prefixed string', + 'my_value': 12.34, + 'other_key': 10} + + # Otherwise, this would take priority, as it's named `My_Value` in `.env.prod` + del os.environ['MY_VALUE'] + + # Load from `_env_file` argument, ignoring prefixes + c = MyClass(_reload=True, _env_file=here / '.env.prod', _env_prefix='') + + assert c.dict() == {'my_str': 'hello world!', + 'my_value': 3.21, + 'other_key': 5} + + +def test_env_prefix_with_env_file(): + """ + Test `env_prefix` with `env_file` where file has prefixed env variables. + + Contents of `.env.prefix`: + MY_PREFIX_STR='my prefix value' + MY_PREFIX_BOOL=t + MY_PREFIX_INT='123.0' + + """ + class MyPrefixTest(EnvWizard): + class _(EnvWizard.Meta): + env_prefix = 'MY_PREFIX_' + env_file = here / '.env.prefix' + + str: str + bool: bool + int: int + + expected = MyPrefixTest(str='my prefix value', + bool=True, + int=123) + + assert MyPrefixTest() == expected