Module attrbox.env
Load configuration from environment files.
The file format is similar to a Bash file, but it is not as complete as python-dotenv.
Supported:
- unquoted and single-quoted key
- unquoted, single- and double-quoted value
- spaces before and after key, equal sign, and value are ignored
export
at the start of the line is ignored#
comment at the start of the line- value expansion for values in the environment
- value expansion for values only in the file (e.g., when
update_env=False
)
Unsupported:
- key without a value
- multiline value
#
comment after value- escape sequences in value
Examples
>>> loads('''
... # a comment
... normal=value
... ' quoted '=" space "
... export expanded="expanded-${normal}"
... ''')
{'normal': 'value', ' quoted ': ' space ', 'expanded': 'expanded-value'}
Expand source code
"""Load configuration from environment files.
The file format is similar to a Bash file, but it is not as complete as
[python-dotenv](https://github.com/theskumar/python-dotenv#file-format).
Supported:
- unquoted and single-quoted key
- unquoted, single- and double-quoted value
- spaces before and after key, equal sign, and value are ignored
- `export` at the start of the line is ignored
- `#` comment at the start of the line
- value expansion for values in the environment
- value expansion for values only in the file (e.g., when `update_env=False`)
Unsupported:
- key without a value
- multiline value
- `#` comment after value
- escape sequences in value
Examples:
>>> loads('''
... # a comment
... normal=value
... ' quoted '=" space "
... export expanded="expanded-${normal}"
... ''')
{'normal': 'value', ' quoted ': ' space ', 'expanded': 'expanded-value'}
"""
# native
from os import environ as ENV
from pathlib import Path
from typing import Dict
from typing import Optional
from typing import Mapping
from typing import Protocol
from typing import Union
from typing import Match
import re
# pkg
from .attrdict import AttrDict
PathStr = Union[Path, str]
"""Type representing a `Path` or a string to a path."""
_RE_EXPAND = re.compile(r"\$(\w+|\{[^}]*\})", re.ASCII)
"""Regex for finding variable expansions."""
class SupportsRead(Protocol): # pylint: disable=too-few-public-methods
"""Protocol for a class that implements a `.read()` method."""
def read(self) -> str:
"""Read the contents of the file-like object."""
return "" # pragma: no cover
def expand(
value: str,
store: Optional[Mapping[str, str]] = None,
*,
dotted_keys: bool = False,
) -> str:
"""Expand variables of the form `$var` and `${var}`.
A simplified form of `os.path.expandvars`.
Args:
value (str): value to expand
store (Mapping[str, str], optional): valid substitutions.
If `None`, `os.environ` is used. Defaults to `None`.
dotted_keys (bool): if `True` allow `${dotted.name}` to map
to nested values `{"dotted": {"name": "value"}}`.
Defaults to `False`.
Returns:
str: expanded value. Unknown variables are left unchanged.
Examples:
Regular expansion works as expected:
>>> expand("$a ${b}", {'a': 'hello', 'b': 'world'})
'hello world'
Unknown variables are left unchanged:
>>> expand("$a is $b", {'a': 'this'})
'this is $b'
>>> expand("no vars", {})
'no vars'
Values are passed to `str`:
>>> expand("$a", {'a': 5})
'5'
Dotted names are optionally possible:
>>> expand("${a.b}", {"a": {"b": "works"}}, dotted_keys=True)
'works'
"""
if "$" not in value:
return value
values = store or ENV
if dotted_keys and not isinstance(values, AttrDict):
values = AttrDict(values)
def _repl(match: Match[str]) -> str:
value = match.group(0)
name = match.group(1)
if name.startswith("{") and name.endswith("}"):
name = name[1:-1]
if dotted_keys:
name = name.split(".")
if name in values:
value = str(values[name]) # pyright: ignore
return value
return _RE_EXPAND.sub(_repl, value)
def load(
file: SupportsRead,
/,
*,
update_env: bool = True,
dotted_keys: bool = True,
) -> Dict[str, str]:
"""Load an environment `file`.
Args:
file (SupportsRead): file-like (has `.read()`).
update_env (bool, optional): If `True`, update the `os.path.environ` as
values are read in. Defaults to `True`.
dotted_keys (bool, optional): If `True`, split the key by `.` and use that
to create a nested `dict`. Defaults to `True`.
Returns:
Dict[str, str]: configuration values
Examples:
>>> root = Path(__file__).parent.parent.parent
>>> load((root / "test/config_3.env").open())
{'section': {'key': 'value3', 'env': 'loaded'}}
"""
return loads(file.read(), update_env=update_env, dotted_keys=dotted_keys)
def loads(
text: str,
/,
*,
update_env: bool = True,
dotted_keys: bool = True,
) -> Dict[str, str]:
"""Parse an environment file from a string.
Args:
text (str): text to parse.
update_env (bool, optional): If `True`, update the `os.path.environ` as
values are read in. Defaults to `True`.
dotted_keys (bool, optional): If `True`, split the key by `.` and use that
to create a nested `dict`. Defaults to `True`.
Returns:
Dict[str, str]: configuration values
Examples:
If you don't want to update the environment:
>>> 'fake' in ENV
False
>>> loads('''export 'fake'=ignored
... works=not $fake''', update_env=False)
{'fake': 'ignored', 'works': 'not ignored'}
>>> 'fake' in ENV
False
Keys with dots in them create nested dicts, but are optional:
>>> loads('section.key=value', update_env=False, dotted_keys=True)
{'section': {'key': 'value'}}
>>> loads('section.key=value', update_env=False, dotted_keys=False)
{'section.key': 'value'}
"""
result = AttrDict()
for line in text.replace("\r\n", "\n").split("\n"):
line = line.strip()
if not line or line.startswith("#"):
continue # skip blank lines and comments
if line.startswith("export "):
line = line[len("export ") :]
key, value = line.split("=", 1)
key = key.strip()
if len(key) >= 2 and key.startswith("'") and key.endswith("'"):
key = key[1:-1] # unquote key
# We expand the value with the current values which may have
# nested structure and then with the environment values (which do not).
value = expand(value, result, dotted_keys=dotted_keys)
value = expand(value)
value = value.strip()
if len(value) >= 2 and (
(value.startswith("'") and value.endswith("'"))
or (value.startswith('"') and value.endswith('"'))
):
value = value[1:-1] # unquote value
if update_env:
ENV[key] = value
if dotted_keys:
result[key.split(".")] = value
else:
result[key] = value
return result
def find_env(path: Optional[PathStr] = None, name: str = ".env") -> Optional[Path]:
"""Find the `.env` file in the ancestors of the current path.
Args:
path (PathLike, optional): A starting path to check. If `None`, starts with
the current working directory. Defaults to `None`.
name (str, optional): file name to search for. Defaults to `".env"`.
Returns:
Optional[Path]: path to environment file or `None` if it is not found.
Examples:
Search from the current working directory:
>>> str(find_env())
'.../.env'
Search from a specific directory:
>>> str(find_env("."))
'.../.env'
Pass a `Path` object:
>>> str(find_env(Path(__file__)))
'.../.env'
Point directly to the `.env` file:
>>> str(find_env(Path(__file__).parent.parent.parent / ".env"))
'.../.env'
"""
if not path:
path = Path.cwd()
elif isinstance(path, str):
path = Path(path).resolve()
if path.name == name and path.exists():
return path
for parent in [path] + list(path.parents):
path = parent / name
if path.exists():
return path
return None
def load_env(path: Optional[PathStr] = None) -> Dict[str, str]:
"""Load an environment file.
We recursively search for a `.env` file from the path given or the current
working directory, if omitted.
Args:
path (PathStr, optional): starting path. If `None`, start from the
current working directory. Defaults to `None`.
Raises:
FileNotFoundError: If not `.env` file is found.
Returns:
Dict[str, str]: configuration values
Examples:
>>> load_env() # our .env doesn't have any values
{}
If no `.env` can be found, a `FileNotFoundError` is raised:
>>> load_env("/")
Traceback (most recent call last):
...
FileNotFoundError: Cannot find .env file to load.
"""
path = find_env(path)
if not path or not path.exists():
raise FileNotFoundError("Cannot find .env file to load.")
return loads(path.read_text(encoding="utf-8"), update_env=True, dotted_keys=True)
Global variables
var PathStr
-
Type representing a
Path
or a string to a path.
Functions
def expand(value: str, store: Optional[Mapping[str, str]] = None, *, dotted_keys: bool = False) ‑> str
-
Expand variables of the form
$var
and${var}
.A simplified form of
os.path.expandvars
.Args
value
:str
- value to expand
store
:Mapping[str, str]
, optional- valid substitutions.
If
None
,os.environ
is used. Defaults toNone
. dotted_keys
:bool
- if
True
allow${dotted.name}
to map to nested values{"dotted": {"name": "value"}}
. Defaults toFalse
.
Returns
str
- expanded value. Unknown variables are left unchanged.
Examples
Regular expansion works as expected:
>>> expand("$a ${b}", {'a': 'hello', 'b': 'world'}) 'hello world'
Unknown variables are left unchanged:
>>> expand("$a is $b", {'a': 'this'}) 'this is $b' >>> expand("no vars", {}) 'no vars'
Values are passed to
str
:>>> expand("$a", {'a': 5}) '5'
Dotted names are optionally possible:
>>> expand("${a.b}", {"a": {"b": "works"}}, dotted_keys=True) 'works'
Expand source code
def expand( value: str, store: Optional[Mapping[str, str]] = None, *, dotted_keys: bool = False, ) -> str: """Expand variables of the form `$var` and `${var}`. A simplified form of `os.path.expandvars`. Args: value (str): value to expand store (Mapping[str, str], optional): valid substitutions. If `None`, `os.environ` is used. Defaults to `None`. dotted_keys (bool): if `True` allow `${dotted.name}` to map to nested values `{"dotted": {"name": "value"}}`. Defaults to `False`. Returns: str: expanded value. Unknown variables are left unchanged. Examples: Regular expansion works as expected: >>> expand("$a ${b}", {'a': 'hello', 'b': 'world'}) 'hello world' Unknown variables are left unchanged: >>> expand("$a is $b", {'a': 'this'}) 'this is $b' >>> expand("no vars", {}) 'no vars' Values are passed to `str`: >>> expand("$a", {'a': 5}) '5' Dotted names are optionally possible: >>> expand("${a.b}", {"a": {"b": "works"}}, dotted_keys=True) 'works' """ if "$" not in value: return value values = store or ENV if dotted_keys and not isinstance(values, AttrDict): values = AttrDict(values) def _repl(match: Match[str]) -> str: value = match.group(0) name = match.group(1) if name.startswith("{") and name.endswith("}"): name = name[1:-1] if dotted_keys: name = name.split(".") if name in values: value = str(values[name]) # pyright: ignore return value return _RE_EXPAND.sub(_repl, value)
def load(file: SupportsRead, /, *, update_env: bool = True, dotted_keys: bool = True) ‑> Dict[str, str]
-
Load an environment
file
.Args
file
:SupportsRead
- file-like (has
.read()
). update_env
:bool
, optional- If
True
, update theos.path.environ
as values are read in. Defaults toTrue
. dotted_keys
:bool
, optional- If
True
, split the key by.
and use that to create a nesteddict
. Defaults toTrue
.
Returns
Dict[str, str]
- configuration values
Examples
>>> root = Path(__file__).parent.parent.parent >>> load((root / "test/config_3.env").open()) {'section': {'key': 'value3', 'env': 'loaded'}}
Expand source code
def load( file: SupportsRead, /, *, update_env: bool = True, dotted_keys: bool = True, ) -> Dict[str, str]: """Load an environment `file`. Args: file (SupportsRead): file-like (has `.read()`). update_env (bool, optional): If `True`, update the `os.path.environ` as values are read in. Defaults to `True`. dotted_keys (bool, optional): If `True`, split the key by `.` and use that to create a nested `dict`. Defaults to `True`. Returns: Dict[str, str]: configuration values Examples: >>> root = Path(__file__).parent.parent.parent >>> load((root / "test/config_3.env").open()) {'section': {'key': 'value3', 'env': 'loaded'}} """ return loads(file.read(), update_env=update_env, dotted_keys=dotted_keys)
def loads(text: str, /, *, update_env: bool = True, dotted_keys: bool = True) ‑> Dict[str, str]
-
Parse an environment file from a string.
Args
text
:str
- text to parse.
update_env
:bool
, optional- If
True
, update theos.path.environ
as values are read in. Defaults toTrue
. dotted_keys
:bool
, optional- If
True
, split the key by.
and use that to create a nesteddict
. Defaults toTrue
.
Returns
Dict[str, str]
- configuration values
Examples
If you don't want to update the environment:
>>> 'fake' in ENV False >>> loads('''export 'fake'=ignored ... works=not $fake''', update_env=False) {'fake': 'ignored', 'works': 'not ignored'} >>> 'fake' in ENV False
Keys with dots in them create nested dicts, but are optional:
>>> loads('section.key=value', update_env=False, dotted_keys=True) {'section': {'key': 'value'}} >>> loads('section.key=value', update_env=False, dotted_keys=False) {'section.key': 'value'}
Expand source code
def loads( text: str, /, *, update_env: bool = True, dotted_keys: bool = True, ) -> Dict[str, str]: """Parse an environment file from a string. Args: text (str): text to parse. update_env (bool, optional): If `True`, update the `os.path.environ` as values are read in. Defaults to `True`. dotted_keys (bool, optional): If `True`, split the key by `.` and use that to create a nested `dict`. Defaults to `True`. Returns: Dict[str, str]: configuration values Examples: If you don't want to update the environment: >>> 'fake' in ENV False >>> loads('''export 'fake'=ignored ... works=not $fake''', update_env=False) {'fake': 'ignored', 'works': 'not ignored'} >>> 'fake' in ENV False Keys with dots in them create nested dicts, but are optional: >>> loads('section.key=value', update_env=False, dotted_keys=True) {'section': {'key': 'value'}} >>> loads('section.key=value', update_env=False, dotted_keys=False) {'section.key': 'value'} """ result = AttrDict() for line in text.replace("\r\n", "\n").split("\n"): line = line.strip() if not line or line.startswith("#"): continue # skip blank lines and comments if line.startswith("export "): line = line[len("export ") :] key, value = line.split("=", 1) key = key.strip() if len(key) >= 2 and key.startswith("'") and key.endswith("'"): key = key[1:-1] # unquote key # We expand the value with the current values which may have # nested structure and then with the environment values (which do not). value = expand(value, result, dotted_keys=dotted_keys) value = expand(value) value = value.strip() if len(value) >= 2 and ( (value.startswith("'") and value.endswith("'")) or (value.startswith('"') and value.endswith('"')) ): value = value[1:-1] # unquote value if update_env: ENV[key] = value if dotted_keys: result[key.split(".")] = value else: result[key] = value return result
def find_env(path: Union[pathlib.Path, str, ForwardRef(None)] = None, name: str = '.env') ‑> Optional[pathlib.Path]
-
Find the
.env
file in the ancestors of the current path.Args
path
:PathLike
, optional- A starting path to check. If
None
, starts with the current working directory. Defaults toNone
. name
:str
, optional- file name to search for. Defaults to
".env"
.
Returns
Optional[Path]
- path to environment file or
None
if it is not found.
Examples
Search from the current working directory:
>>> str(find_env()) '.../.env'
Search from a specific directory:
>>> str(find_env(".")) '.../.env'
Pass a
Path
object:>>> str(find_env(Path(__file__))) '.../.env'
Point directly to the
.env
file:>>> str(find_env(Path(__file__).parent.parent.parent / ".env")) '.../.env'
Expand source code
def find_env(path: Optional[PathStr] = None, name: str = ".env") -> Optional[Path]: """Find the `.env` file in the ancestors of the current path. Args: path (PathLike, optional): A starting path to check. If `None`, starts with the current working directory. Defaults to `None`. name (str, optional): file name to search for. Defaults to `".env"`. Returns: Optional[Path]: path to environment file or `None` if it is not found. Examples: Search from the current working directory: >>> str(find_env()) '.../.env' Search from a specific directory: >>> str(find_env(".")) '.../.env' Pass a `Path` object: >>> str(find_env(Path(__file__))) '.../.env' Point directly to the `.env` file: >>> str(find_env(Path(__file__).parent.parent.parent / ".env")) '.../.env' """ if not path: path = Path.cwd() elif isinstance(path, str): path = Path(path).resolve() if path.name == name and path.exists(): return path for parent in [path] + list(path.parents): path = parent / name if path.exists(): return path return None
def load_env(path: Union[pathlib.Path, str, ForwardRef(None)] = None) ‑> Dict[str, str]
-
Load an environment file.
We recursively search for a
.env
file from the path given or the current working directory, if omitted.Args
path
:PathStr
, optional- starting path. If
None
, start from the current working directory. Defaults toNone
.
Raises
FileNotFoundError
- If not
.env
file is found.
Returns
Dict[str, str]
- configuration values
Examples
>>> load_env() # our .env doesn't have any values {}
If no
.env
can be found, aFileNotFoundError
is raised:>>> load_env("/") Traceback (most recent call last): ... FileNotFoundError: Cannot find .env file to load.
Expand source code
def load_env(path: Optional[PathStr] = None) -> Dict[str, str]: """Load an environment file. We recursively search for a `.env` file from the path given or the current working directory, if omitted. Args: path (PathStr, optional): starting path. If `None`, start from the current working directory. Defaults to `None`. Raises: FileNotFoundError: If not `.env` file is found. Returns: Dict[str, str]: configuration values Examples: >>> load_env() # our .env doesn't have any values {} If no `.env` can be found, a `FileNotFoundError` is raised: >>> load_env("/") Traceback (most recent call last): ... FileNotFoundError: Cannot find .env file to load. """ path = find_env(path) if not path or not path.exists(): raise FileNotFoundError("Cannot find .env file to load.") return loads(path.read_text(encoding="utf-8"), update_env=True, dotted_keys=True)
Classes
class SupportsRead (*args, **kwargs)
-
Protocol for a class that implements a
.read()
method.Expand source code
class SupportsRead(Protocol): # pylint: disable=too-few-public-methods """Protocol for a class that implements a `.read()` method.""" def read(self) -> str: """Read the contents of the file-like object.""" return "" # pragma: no cover
Ancestors
- typing.Protocol
- typing.Generic
Methods
def read(self) ‑> str
-
Read the contents of the file-like object.
Expand source code
def read(self) -> str: """Read the contents of the file-like object.""" return "" # pragma: no cover