Edit on GitHub

envclasses

envclasses is a library to map fields on dataclass object to environment variables.

Declare a class with dataclass and envclass decorators. If you are new to dataclass, I recommend to read dataclasses documentation first.

>>> from dataclasses import dataclass
>>>
>>> @envclass
... @dataclass
... class Foo:
...     v: int

Create an instance of Foo, now foo.v is 10.

>>> foo = Foo(v=10)

Set environment variable FOO_V 100.

>>> import os
>>> os.environ['FOO_V'] = '100'

Calling load_env will change the value from 10 to 100 on foo.v.

>>> load_env(foo, prefix='foo')
>>> foo.v
100

Supported types

  • Primitives (int, float, str, bool)
  • Containers (List, Tuple, Dict)
>>> from typing import List, Dict
>>> from dataclasses import dataclass, field
>>>
>>> @envclass
... @dataclass
... class Foo:
...     lst: List[int] = field(default_factory=list)
...     dct: Dict[str, float] = field(default_factory=dict)
...
>>> foo = Foo()
>>> os.environ['FOO_LST'] = '[1, 2]'
>>> os.environ['FOO_DCT'] = '{key: 100.0}'
>>> load_env(foo, prefix='FOO')
>>> foo
Foo(lst=[1, 2], dct={'key': 100.0})
  • Nested envclass
>>> @envclass
... @dataclass
... class Bar:
...     v: int
>>>
>>> @envclass
... @dataclass
... class Foo:
...     bar: Bar
>>> foo = Foo(Bar(v=10))
>>> os.environ['FOO_BAR_V'] = '100'
>>> load_env(foo, prefix='foo')
>>> foo.bar.v
100

Debugging

If you want to see how envclasses looks up environment variables, you may want to see the logs produced in the module. envclasses uses the logger of standard logging module. In order to see DEBUG logs, you can get the logger and configure the log level.

import logging

logging.getLogger("envclasses").setLevel(logging.DEBUG)
  1"""
  2`envclasses` is a library to map fields on dataclass object to environment variables.
  3
  4Declare a class with `dataclass` and `envclass` decorators. If you are new to dataclass,
  5I recommend to read [dataclasses documentation](https://docs.python.org/3/library/dataclasses.html)
  6first.
  7
  8>>> from dataclasses import dataclass
  9>>>
 10>>> @envclass
 11... @dataclass
 12... class Foo:
 13...     v: int
 14
 15Create an instance of `Foo`, now `foo.v` is 10.
 16
 17>>> foo = Foo(v=10)
 18
 19Set environment variable `FOO_V` `100`.
 20
 21>>> import os
 22>>> os.environ['FOO_V'] = '100'
 23
 24Calling `load_env` will change the value from `10` to `100` on `foo.v`.
 25
 26>>> load_env(foo, prefix='foo')
 27>>> foo.v
 28100
 29
 30### Supported types
 31
 32* Primitives (int, float, str, bool)
 33* Containers (List, Tuple, Dict)
 34
 35>>> from typing import List, Dict
 36>>> from dataclasses import dataclass, field
 37>>>
 38>>> @envclass
 39... @dataclass
 40... class Foo:
 41...     lst: List[int] = field(default_factory=list)
 42...     dct: Dict[str, float] = field(default_factory=dict)
 43...
 44>>> foo = Foo()
 45>>> os.environ['FOO_LST'] = '[1, 2]'
 46>>> os.environ['FOO_DCT'] = '{key: 100.0}'
 47>>> load_env(foo, prefix='FOO')
 48>>> foo
 49Foo(lst=[1, 2], dct={'key': 100.0})
 50
 51* Nested envclass
 52
 53>>> @envclass
 54... @dataclass
 55... class Bar:
 56...     v: int
 57>>>
 58>>> @envclass
 59... @dataclass
 60... class Foo:
 61...     bar: Bar
 62
 63>>> foo = Foo(Bar(v=10))
 64>>> os.environ['FOO_BAR_V'] = '100'
 65>>> load_env(foo, prefix='foo')
 66>>> foo.bar.v
 67100
 68
 69### Debugging
 70
 71If you want to see how `envclasses` looks up environment variables, you may want to see the logs
 72produced in the module. `envclasses` uses the logger of standard `logging` module. In order to
 73see DEBUG logs, you can get the logger and configure the log level.
 74
 75```python
 76import logging
 77
 78logging.getLogger("envclasses").setLevel(logging.DEBUG)
 79```
 80"""
 81import enum
 82import functools
 83import logging
 84import os
 85from dataclasses import Field, fields
 86from typing import Any, Dict, List, Optional, Type, TypeVar, Union, cast
 87
 88import yaml
 89from typing_inspect import get_args, get_origin, is_optional_type
 90
 91__all__ = [
 92    'envclass',
 93    'is_envclass',
 94    'load_env',
 95    'EnvclassError',
 96    'LoadEnvError',
 97]
 98
 99__version__ = '0.2.7'
100""" Version of envclass. """
101
102logger = logging.getLogger('envclasses')
103
104ENVCLASS_DUNDER_FUNC_NAME = '__envclasses_load_env__'
105""" Name of the generated dunder function to be called by `load_env`. """
106
107ENVCLASS_PREFIX = 'env'
108""" Default prefix used for environment variables. """
109
110T = TypeVar('T')
111
112JsonValue = TypeVar('JsonValue', str, int, float, bool, Dict, List)
113
114
115class EnvclassError(TypeError):
116    """
117    Exception used in `envclass`. This exception is only raised when there is an
118    error in the class declaration.
119    """
120
121
122class LoadEnvError(Exception):
123    """
124    Exception raised in `load_env`.
125    """
126
127
128class InvalidNumberOfElement(LoadEnvError):
129    """
130    Raised if the number of element is imcompatible with
131    the number of elemtn of type.
132    """
133
134
135def _coalesce(typ: Type) -> Type:
136    if not is_optional_type(typ):
137        return typ
138    else:
139        return cast(
140            Type,
141            Union[tuple(tt for tt in get_args(typ, evaluate=True) if not is_optional_type(tt))])
142
143
144def envclass(_cls: Type[T]) -> Type[T]:
145    """
146    `envclass` decorator generates methods to loads field values from environment variables.
147
148    """
149
150    @functools.wraps(_cls)
151    def wrap(cls):
152
153        def load_env(self, _prefix: str = None) -> None:
154            """
155            Load attributes from environment variables.
156            """
157            for f in fields(cls):
158                # If no prefix specified, use the default PREFIX.
159                prefix = _prefix if _prefix is not None else ENVCLASS_PREFIX
160                prefix += '_' if prefix and not prefix.endswith('_') else ''
161                logger.debug(f'prefix={prefix}, type={f.type}')
162
163                f_type = _coalesce(f.type)
164                if is_envclass(f_type):
165                    _load_dataclass(self, f, prefix, f_type)
166                elif is_list(f_type):
167                    _load_list(self, f, prefix, f_type)
168                elif is_tuple(f_type):
169                    _load_tuple(self, f, prefix, f_type)
170                elif is_dict(f_type):
171                    _load_dict(self, f, prefix, f_type)
172                elif is_enum(f_type):
173                    _load_enum(self, f, prefix, f_type)
174                elif is_str(f_type):
175                    _load_str(self, f, prefix, f_type)
176                else:
177                    _load_other(self, f, prefix, f_type)
178
179        setattr(cls, ENVCLASS_DUNDER_FUNC_NAME, load_env)
180        return cls
181
182    return wrap(_cls)
183
184
185def _load_dataclass(obj, f: Field, prefix: str, f_type: Optional[Type] = None) -> None:
186    """
187    Override exisiting dataclass object by environment variables.
188    """
189    inner_prefix = f'{prefix}{f.name}'
190    typ = f_type or f.type
191    if getattr(obj, f.name, None) is None:
192        setattr(obj, f.name, typ())
193    o = getattr(obj, f.name)
194    try:
195        o.__envclasses_load_env__(inner_prefix)
196    except KeyError:
197        pass
198
199
200def _load_list(obj, f: Field, prefix: str, f_type: Optional[Type] = None) -> None:
201    """
202    Override list values by environment variables.
203    """
204    typ = f_type or f.type
205    element_type = typ.__args__[0]
206    name = f'{prefix.upper()}{f.name.upper()}'
207    try:
208        s: str = os.environ[name].strip()
209    except KeyError:
210        return
211
212    yml = yaml.safe_load(s)
213    lst = [element_type(e) for e in yml]
214    setattr(obj, f.name, lst)
215
216
217def _load_tuple(obj, f: Field, prefix: str, f_type: Optional[Type] = None) -> None:
218    """
219    Override tuple values by environment variables.
220    """
221    typ = f_type or f.type
222    name = f'{prefix.upper()}{f.name.upper()}'
223    element_types = typ.__args__
224    try:
225        s: str = os.environ[name].strip()
226    except KeyError:
227        return
228
229    lst = yaml.safe_load(s)
230    if len(lst) != len(element_types):
231        raise InvalidNumberOfElement(f'expected={len(element_types)} '
232                                     f'actual={len(lst)}')
233    tpl = tuple(element_type(e) for e, element_type in zip(lst, element_types))
234    setattr(obj, f.name, tpl)
235
236
237def _load_dict(obj, f: Field, prefix: str, f_type: Optional[Type] = None) -> None:
238    """
239    Override dict values by environment variables.
240    """
241    typ = f_type or f.type
242    name = f'{prefix.upper()}{f.name.upper()}'
243    key_type = typ.__args__[0]
244    value_type = typ.__args__[1]
245    try:
246        s = os.environ[name].strip()
247    except KeyError:
248        return
249
250    dct = {_to_value(k, key_type): _to_value(v, value_type) for k, v in yaml.safe_load(s).items()}
251    setattr(obj, f.name, dct)
252
253
254def _to_value(v: JsonValue, typ: Type) -> Any:
255    if isinstance(v, (List, Dict)):
256        return v
257    else:
258        return typ(v)
259
260
261def _load_enum(obj, f: Field, prefix: str, f_type: Optional[Type] = None) -> None:
262    name = f'{prefix.upper()}{f.name.upper()}'
263    typ = f_type or f.type
264    for enum_item in list(typ):
265        try:
266            setattr(obj, f.name, typ(type(enum_item.value)(os.environ[name])))
267            return
268        except (KeyError, ValueError):
269            continue
270
271
272def _load_str(obj, f: Field, prefix: str, f_type: Optional[Type] = None) -> None:
273    """
274    Override str values by environment variables.
275    """
276    name = f'{prefix.upper()}{f.name.upper()}'
277    typ = f_type or f.type
278    try:
279        value = os.environ[name]
280        setattr(obj, f.name, _to_value(value, typ))
281    except KeyError:
282        pass
283
284
285def _load_other(obj, f: Field, prefix: str, f_type: Optional[Type] = None) -> None:
286    """
287    Override values by environment variables.
288    """
289    name = f'{prefix.upper()}{f.name.upper()}'
290    typ = f_type or f.type
291    try:
292        yml = yaml.safe_load(os.environ[name])
293        setattr(obj, f.name, _to_value(yml, typ))
294    except KeyError:
295        pass
296
297
298def is_enum(typ: Type) -> bool:
299    """
300    Test if class is Enum class.
301    """
302    try:
303        return issubclass(typ, enum.Enum)
304    except TypeError:
305        return isinstance(typ, enum.Enum)
306
307
308def is_list(typ: Type) -> bool:
309    """
310    Test if the type is `typing.List`.
311    """
312    try:
313        return issubclass(get_origin(typ), list)
314    except TypeError:
315        return isinstance(typ, list)
316
317
318def is_tuple(typ: Type) -> bool:
319    """
320    Test if the type is `typing.Tuple`.
321    """
322    try:
323        return issubclass(get_origin(typ), tuple)
324    except TypeError:
325        return isinstance(typ, tuple)
326
327
328def is_dict(typ: Type) -> bool:
329    """
330    Test if the type is `typing.Dict`.
331    """
332    try:
333        return issubclass(get_origin(typ), dict)
334    except TypeError:
335        return isinstance(typ, dict)
336
337
338def is_str(typ: Type) -> bool:
339    """
340    Test if the type is `str`.
341    """
342    try:
343        return issubclass(typ, str)
344    except TypeError:
345        return False
346
347
348def is_envclass(instance_or_class: Any) -> bool:
349    """
350    Test if instance_or_class is envclass.
351
352    >>> from dataclasses import dataclass
353    >>>
354    >>> @envclass
355    ... @dataclass
356    ... class Foo:
357    ...     pass
358    >>> is_envclass(Foo)
359    True
360    """
361    return hasattr(instance_or_class, ENVCLASS_DUNDER_FUNC_NAME)
362
363
364def load_env(inst, prefix: str = None) -> None:
365    """
366    Load field values from environment variables.
367
368    `inst` is an instance of envclass. If `inst` is not an envclass,
369    `LoadEnvError` exception is raised.
370
371    `prefix` specifies the prefix of environment variables that envclass looks up.
372    for example `prefix="foo"`, envclass will load value from environment variable
373    `FOO_<FIELD_NAME>` and update the field value on the instance. If `prefix` is
374    omitted, the default prefix `env` is used.
375
376    >>> from dataclasses import dataclass
377    >>>
378    >>> @envclass
379    ... @dataclass
380    ... class Foo:
381    ...     v: int
382    >>> foo = Foo(v=10)
383    >>> os.environ['ENV_V'] = '100'
384    >>> load_env(foo)
385    >>> foo.v
386    100
387
388    If an empty string is set in `prefix`, no prefix will be expected.
389
390    >>> os.environ['V'] = '100'
391    >>> load_env(foo, '')
392    >>> foo.v
393    100
394    """
395    inst.__envclasses_load_env__(prefix)
def envclass(_cls: Type[~T]) -> Type[~T]:
145def envclass(_cls: Type[T]) -> Type[T]:
146    """
147    `envclass` decorator generates methods to loads field values from environment variables.
148
149    """
150
151    @functools.wraps(_cls)
152    def wrap(cls):
153
154        def load_env(self, _prefix: str = None) -> None:
155            """
156            Load attributes from environment variables.
157            """
158            for f in fields(cls):
159                # If no prefix specified, use the default PREFIX.
160                prefix = _prefix if _prefix is not None else ENVCLASS_PREFIX
161                prefix += '_' if prefix and not prefix.endswith('_') else ''
162                logger.debug(f'prefix={prefix}, type={f.type}')
163
164                f_type = _coalesce(f.type)
165                if is_envclass(f_type):
166                    _load_dataclass(self, f, prefix, f_type)
167                elif is_list(f_type):
168                    _load_list(self, f, prefix, f_type)
169                elif is_tuple(f_type):
170                    _load_tuple(self, f, prefix, f_type)
171                elif is_dict(f_type):
172                    _load_dict(self, f, prefix, f_type)
173                elif is_enum(f_type):
174                    _load_enum(self, f, prefix, f_type)
175                elif is_str(f_type):
176                    _load_str(self, f, prefix, f_type)
177                else:
178                    _load_other(self, f, prefix, f_type)
179
180        setattr(cls, ENVCLASS_DUNDER_FUNC_NAME, load_env)
181        return cls
182
183    return wrap(_cls)

envclass decorator generates methods to loads field values from environment variables.

def is_envclass(instance_or_class: Any) -> bool:
349def is_envclass(instance_or_class: Any) -> bool:
350    """
351    Test if instance_or_class is envclass.
352
353    >>> from dataclasses import dataclass
354    >>>
355    >>> @envclass
356    ... @dataclass
357    ... class Foo:
358    ...     pass
359    >>> is_envclass(Foo)
360    True
361    """
362    return hasattr(instance_or_class, ENVCLASS_DUNDER_FUNC_NAME)

Test if instance_or_class is envclass.

>>> from dataclasses import dataclass
>>>
>>> @envclass
... @dataclass
... class Foo:
...     pass
>>> is_envclass(Foo)
True
def load_env(inst, prefix: str = None) -> None:
365def load_env(inst, prefix: str = None) -> None:
366    """
367    Load field values from environment variables.
368
369    `inst` is an instance of envclass. If `inst` is not an envclass,
370    `LoadEnvError` exception is raised.
371
372    `prefix` specifies the prefix of environment variables that envclass looks up.
373    for example `prefix="foo"`, envclass will load value from environment variable
374    `FOO_<FIELD_NAME>` and update the field value on the instance. If `prefix` is
375    omitted, the default prefix `env` is used.
376
377    >>> from dataclasses import dataclass
378    >>>
379    >>> @envclass
380    ... @dataclass
381    ... class Foo:
382    ...     v: int
383    >>> foo = Foo(v=10)
384    >>> os.environ['ENV_V'] = '100'
385    >>> load_env(foo)
386    >>> foo.v
387    100
388
389    If an empty string is set in `prefix`, no prefix will be expected.
390
391    >>> os.environ['V'] = '100'
392    >>> load_env(foo, '')
393    >>> foo.v
394    100
395    """
396    inst.__envclasses_load_env__(prefix)

Load field values from environment variables.

inst is an instance of envclass. If inst is not an envclass, LoadEnvError exception is raised.

prefix specifies the prefix of environment variables that envclass looks up. for example prefix="foo", envclass will load value from environment variable FOO_<FIELD_NAME> and update the field value on the instance. If prefix is omitted, the default prefix env is used.

>>> from dataclasses import dataclass
>>>
>>> @envclass
... @dataclass
... class Foo:
...     v: int
>>> foo = Foo(v=10)
>>> os.environ['ENV_V'] = '100'
>>> load_env(foo)
>>> foo.v
100

If an empty string is set in prefix, no prefix will be expected.

>>> os.environ['V'] = '100'
>>> load_env(foo, '')
>>> foo.v
100
class EnvclassError(builtins.TypeError):
116class EnvclassError(TypeError):
117    """
118    Exception used in `envclass`. This exception is only raised when there is an
119    error in the class declaration.
120    """

Exception used in envclass. This exception is only raised when there is an error in the class declaration.

Inherited Members
builtins.TypeError
TypeError
builtins.BaseException
with_traceback
class LoadEnvError(builtins.Exception):
123class LoadEnvError(Exception):
124    """
125    Exception raised in `load_env`.
126    """

Exception raised in load_env.

Inherited Members
builtins.Exception
Exception
builtins.BaseException
with_traceback