Source code for yaenv.core

"""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']