Skip to content

Commit

Permalink
add logic for env_prefix
Browse files Browse the repository at this point in the history
  • Loading branch information
rnag committed Nov 29, 2024
1 parent 90f54f3 commit 80613b6
Show file tree
Hide file tree
Showing 7 changed files with 200 additions and 45 deletions.
12 changes: 11 additions & 1 deletion dataclass_wizard/bases.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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.
#
Expand Down
6 changes: 5 additions & 1 deletion dataclass_wizard/bases_meta.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 30 additions & 4 deletions dataclass_wizard/environ/lookups.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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:
Expand All @@ -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):
"""
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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):
"""
Expand Down
3 changes: 3 additions & 0 deletions dataclass_wizard/environ/lookups.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -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): ...

Expand Down
93 changes: 54 additions & 39 deletions dataclass_wizard/environ/wizard.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import logging
from collections.abc import Sequence
from dataclasses import MISSING, dataclass, fields
from typing import Callable

Expand Down Expand Up @@ -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()

Expand Down Expand Up @@ -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,
Expand All @@ -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()

Expand All @@ -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'):
Expand Down
4 changes: 4 additions & 0 deletions tests/unit/environ/.env.prefix
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
MY_PREFIX_STR='my prefix value'
MY_PREFIX_BOOL=t
MY_PREFIX_INT='123.0'

93 changes: 93 additions & 0 deletions tests/unit/environ/test_wizard.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

0 comments on commit 80613b6

Please sign in to comment.