"""Environment variable parser."""
from __future__ import annotations
from functools import cached_property
from os import PathLike, environ, fspath, path
from re import Match, compile as regex
from secrets import token_urlsafe
from shlex import shlex
from shutil import move
from tempfile import mkstemp
from typing import Iterator, Literal, overload
from . import db, email, utils
[docs]
class EnvError(Exception):
"""Exception class representing a dotenv error."""
[docs]
class EnvVar:
"""
Class that represents an environment variable.
Attributes
----------
key : str
The key of the variable.
value : str
The value of the variable.
"""
_interpolate: bool
key: str
value: str
[docs]
def __new__(cls, line: str) -> EnvVar | None: # type: ignore
"""
Parse a line and return a new instance or ``None``.
Parameters
----------
line : str
The line to be parsed.
Returns
-------
EnvVar | None
A new ``EnvVar`` if all went well, or ``None`` if
the line doesn't contain a variable declaration.
Raises
------
EnvError
If the line cannot be parsed.
Examples
--------
>>> print(repr(EnvVar('example=???')))
EnvVar('example', '???')
>>> print(repr(EnvVar('# comment')))
None
"""
lex = shlex(line)
key = lex.read_token()
if not key:
return None
# invalid key
if (
not all(c in lex.wordchars for c in key)
or lex.get_token() != '='
or key == '_' or key[0] in '0123456789'
):
raise EnvError(f'Invalid key in line: {line}')
lex.whitespace_split = True
try:
value = lex.read_token()
except ValueError as e:
raise EnvError(f'Mismatched quotes in line: {line}') from e
# surplus token after value
if lex.read_token():
raise EnvError(f'Surplus token in line: {line}')
instance = super(EnvVar, cls).__new__(cls)
instance.key = key
# blank value
if not value:
instance.value = ''
instance._interpolate = False
return instance
# double-quoted value
if value[0] == '"' or value[-1] == '"':
if not value[0] == value[-1]:
raise EnvError(f'Mismatched quotes in line: {line}')
instance.value = value[1:-1]
instance._interpolate = True
return instance
# single-quoted value
if value[0] == "'" or value[-1] == "'":
if not value[0] == value[-1]:
raise EnvError(f'Mismatched quotes in line: {line}')
instance.value = value[1:-1]
instance._interpolate = False
return instance
instance.value = value
instance._interpolate = True
return instance
[docs]
def __bool__(self) -> bool:
"""
Return whether the variable can be interpolated or not.
Returns
-------
bool
``True`` unless the value is blank or enclosed in single quotes.
"""
return self._interpolate
[docs]
def __iter__(self) -> Iterator[str]:
"""
Iterate through the tokens of the variable.
Returns
-------
Iterator[str]
An iterator containing the key and value.
"""
yield self.key
yield self.value
[docs]
def __len__(self) -> Literal[0, 1, 2]:
"""
Return a number which represents the state of the ``EnvVar``.
Returns
-------
Literal[0, 1, 2]
``0`` if the ``EnvVar`` is ``None``, ``1``
if the value is blank, or ``2`` otherwise.
"""
if self is None:
return 0
if not self.value:
return 1
return 2
[docs]
def __str__(self) -> str: # pragma: no cover
"""
Return the variable as a string.
Returns
-------
str
The value of the variable.
"""
return self.value
[docs]
def __repr__(self) -> str: # pragma: no cover
"""
Return a string representing the object.
Returns
-------
str
A string that shows the key and value of the variable.
"""
return f"EnvVar('{self.key}', '{self.value}')"
[docs]
class Env(PathLike):
"""
Class used to parse dotenv files and access their variables.
Attributes
----------
ENV : os._Environ
A reference to :os:`environ`.
envfile : str | :os:`PathLike`
The dotenv file of the object.
Parameters
----------
envfile : str | :os:`PathLike`
The path to a dotenv file.
Examples
--------
>>> print(open('.env').read())
STR_VAR=value
LIST_VAR=item1:item2
SECRET_KEY=notsosecret
>>> env = Env('.env')
"""
def __init__(self, envfile: str | PathLike) -> None:
if not path.isfile(envfile):
raise EnvError(f"File '{envfile}' does not exist")
self.envfile = envfile
self.ENV = environ
[docs]
def __getitem__(self, key: str) -> str:
"""
Return an environment variable that cannot be missing.
Parameters
----------
key : str
The name of the variable.
Returns
-------
str
The value of the variable as ``str``.
Raises
------
EnvError
If the environment variable is missing.
"""
value = self.ENV.get(key, self.vars.get(key))
if value is None:
raise EnvError(f"Missing environment variable: '{key}'")
return value
[docs]
def __setitem__(self, key: str, value: str) -> None:
"""
Set an environment variable.
Parameters
----------
key : str
The name of the variable.
value : str
The value of the variable as ``str``.
"""
self.vars[key] = value
self._replace(key, value)
[docs]
def __delitem__(self, key: str) -> None:
"""
Unset an environment variable.
Parameters
----------
key : str
The name of the variable.
Raises
------
EnvError
If the variable is not set.
"""
try:
del self.vars[key]
except KeyError:
raise EnvError(f"Missing environment variable: '{key}'")
else:
self._replace(key, None)
[docs]
def __iter__(self) -> Iterator[tuple[str, str]]:
"""
Iterate through the entries in the dotenv file.
Returns
-------
Iterator[tuple[str, str]]
An iterator of key-value pairs.
"""
yield from self.vars.items()
[docs]
def __contains__(self, item: str) -> bool:
"""
Check whether a variable is defined in the dotenv file.
Parameters
----------
item : str
The name of the variable.
Returns
-------
bool
``True`` if the variable is defined.
"""
return item in self.vars
[docs]
def __len__(self) -> int:
"""
Return the number of environment variables.
Returns
-------
int
The number of variables defined in the dotenv file.
"""
return len(self.vars)
[docs]
def __fspath__(self) -> str:
"""
Return the file system representation of the object.
This method is used by :os:`fspath`.
Returns
-------
str
The path of the dotenv file.
"""
return fspath(self.envfile)
[docs]
def __str__(self) -> str: # pragma: no cover
"""
Return a string representing the environment variables.
Returns
-------
str
The key-value pairs defined in the dotenv file as lines.
"""
return '\n'.join(f'{k}="{v}"' for k, v in self)
[docs]
def __repr__(self) -> str: # pragma: no cover
"""
Return a string representing the object.
Returns
-------
str
A string that shows the path of the dotenv file.
"""
return f"Env('{self.envfile}')"
@cached_property
def vars(self) -> dict[str, str]:
"""`dict[str, str]` : Get the environment variables as a ``dict``."""
def _sub_callback(match: Match) -> str:
return (self.ENV | result).get(match.group(1), '')
with open(self.envfile, 'r') as f:
envvars = list(filter(EnvVar.__len__, map(EnvVar, f.readlines())))
result = dict(envvars) # type: ignore
# substitute variables that can be interpolated
posix = regex(r'\$\{([^}].*)?\}')
for var in filter(bool, envvars):
result[var.key] = posix.sub(_sub_callback, var.value)
return result
[docs]
def setenv(self) -> None:
"""Add the variables defined in the dotenv file to :os:`environ`."""
self.ENV |= self.vars
@overload
def get(self, key: str, default: str) -> str: ...
@overload
def get(self, key: str, default: None = None) -> str | None: ...
[docs]
def get(self, key: str, default: str | None = None) -> str | None:
"""
Return an environment variable or a default value.
Parameters
----------
key : str
The name of the variable.
default : str | None
The default value.
Returns
-------
str | None
The value of the variable or the ``default`` value.
Examples
--------
>>> env.get('STR_VAR', 'default')
'value'
"""
return self.ENV.get(key, self.vars.get(key) or default)
@overload
def bool(self, key: str, default: bool) -> bool: ...
@overload
def bool(self, key: str, default: None = None) -> bool | None: ...
[docs]
def bool(self, key: str, default: bool | None = None) -> bool | None:
"""
Return an environment variable as a ``bool``, or a default value.
Parameters
----------
key : str
The name of the variable.
default : bool | None
The default value.
Returns
-------
bool | None
The ``bool`` value of the variable or the ``default`` value.
Raises
------
EnvError
If the variable cannot be cast to ``bool``.
Examples
--------
>>> env.bool('BOOL_VAR', False)
False
"""
value = self.get(key)
if value is None:
return default
if utils.is_truthy(value):
return True
if utils.is_falsy(value):
return False
raise EnvError(f"Invalid boolean value: '{value}'")
@overload
def int(self, key: str, default: int) -> int: ...
@overload
def int(self, key: str, default: None = None) -> int | None: ...
[docs]
def int(self, key: str, default: int | None = None) -> int | None:
"""
Return an environment variable as an ``int``, or a default value.
Parameters
----------
key : str
The name of the variable.
default : int | None
The default value.
Returns
-------
int | None
The ``int`` value of the variable or the ``default`` value.
Raises
------
EnvError
If the variable cannot be cast to ``int``.
Examples
--------
>>> env.int('INT_VAR', 10)
10
"""
value = self.get(key)
if value is None:
return default
try:
return int(value)
except ValueError:
raise EnvError(f"Invalid integer value: '{value}'")
@overload
def float(self, key: str, default: float) -> float: ...
@overload
def float(self, key: str, default: None = None) -> float | None: ...
[docs]
def float(self, key: str, default: float | None = None) -> float | None:
"""
Return an environment variable as a ``float``, or a default value.
Parameters
----------
key : str
The name of the variable.
default : float | None
The default value.
Returns
-------
float | None
The ``float`` value of the variable or the ``default`` value.
Raises
------
EnvError
If the variable cannot be cast to ``float``.
Examples
--------
>>> env.float('FLOAT_VAR', 0.3)
0.3
"""
value = self.get(key)
if value is None:
return default
try:
return float(value)
except ValueError:
raise EnvError(f"Invalid numerical value: '{value}'")
@overload
def list(self, key: str, default: list, separator: str = ...) -> list: ...
@overload
def list(self, key: str, default: None = None,
separator: str = ...) -> list | None: ...
[docs]
def list(self, key: str, default: list | None = None,
separator: str = ',') -> list | None:
"""
Return an environment variable as a ``list``, or a default value.
Parameters
----------
key : str
The name of the variable.
default : list | None
The default value.
separator : str
The separator to use when splitting the list.
Returns
-------
list | None
The ``list`` value of the variable or the ``default`` value.
Examples
--------
>>> env.list('LIST_VAR', separator=':')
['item1', 'item2']
"""
value = self.get(key)
if value is None:
return default
return value.split(separator)
@overload
def db(self, key: str, default: str) -> db.DBConfig: ...
@overload
def db(self, key: str, default: None = None) -> db.DBConfig | None: ...
[docs]
def db(self, key: str, default: str | None = None) -> db.DBConfig | None:
"""
Return a dictionary that can be used for Django's database settings.
Parameters
----------
key : str
The name of the variable.
default : str | None
The default (unparsed) value.
Returns
-------
db.DBConfig | None
A database config object for Django.
Raises
------
EnvError
If the variable cannot be parsed.
See Also
--------
:meth:`yaenv.db.parse` : Database URL parser.
"""
value = self.get(key, default)
if value is None:
return None
try:
return db.parse(value)
except Exception as e:
raise EnvError(f"Invalid database URL: '{value}'") from e
@overload
def email(self, key: str, default: str) -> email.EmailConfig: ...
@overload
def email(self, key: str, default: None = None
) -> email.EmailConfig | None: ...
[docs]
def email(self, key: str, default: str | None
= None) -> email.EmailConfig | None:
"""
Return a dictionary that can be used for Django's e-mail settings.
Parameters
----------
key : str
The name of the variable.
default : str | None
The default (unparsed) value.
Returns
-------
email.EmailConfig | None
An e-mail config object for Django.
Raises
------
EnvError
If the variable cannot be parsed.
See Also
--------
:meth:`yaenv.email.parse` : E-mail URL parser.
"""
value = self.get(key, default)
if value is None:
return None
try:
return email.parse(value)
except Exception as e:
raise EnvError(f"Invalid e-mail URL: '{value}'") from e
[docs]
def secret(self, key: str = 'SECRET_KEY') -> str:
"""
Return a cryptographically secure secret key.
If the key is empty, it is generated and saved.
Parameters
----------
key : str
The name of the key.
Returns
-------
str
The value of the key or a random string.
"""
value = self.get(key)
if value is None:
value = token_urlsafe(37)
self[key] = value
return value
def _replace(self, key: str, value: str | None) -> None:
target = mkstemp(prefix='yaenv')[-1]
pattern = regex(fr'^\s*{key}\s*=')
replaced = value is None # can't replace if there's no value
if value is not None:
value = value.replace('"', '\\"') \
.replace('\n', '\\n').replace('\t', '\\t')
newline = f'{key}="{value}"\n'
with open(target, 'w') as tf, open(self.envfile, 'r') as sf:
for line in sf:
if not pattern.match(line):
tf.write(line)
elif value is not None:
tf.write(newline)
replaced = True
if not replaced:
if not line[-1] == '\n':
tf.write('\n') # ensure new line
tf.write(newline)
move(target, self.envfile)
__all__ = ['Env', 'EnvError', 'EnvVar']