Module attrbox.config
Configuration loading and parsing.
Expand source code
"""Configuration loading and parsing."""
# native
from inspect import cleandoc
from pathlib import Path
from typing import Any
from typing import Callable
from typing import Dict
from typing import List
from typing import LiteralString
from typing import Mapping
from typing import Optional
from typing import Sequence
from typing import Union
import json
# lib
from docopt import docopt
import tomli as tomllib # TODO 2026-10-04 [3.10 EOL]: switch to native tomllib
# pkg
from .attrdict import AttrDict
from . import env
PYTHON_KEYWORDS: List[
LiteralString
] = """\
False await else import pass
None break except in raise
True class finally is return
and continue for lambda try
as def from nonlocal while
assert del global not with
async elif if or yield
""".lower().split()
"""[All Python keywords](https://docs.python.org/3/reference/lexical_analysis.html#keywords)."""
LoaderFunc = Callable[[str], Any]
"""Function signature to load configuration from a string."""
LOADERS: Dict[str, LoaderFunc] = {}
"""Mapping of file extensions to configuration loaders."""
def set_loader(suffix: str, loader: LoaderFunc) -> None:
"""Register a configuration `loader` for a given file `suffix`.
NOTE: This will overwrite any previously registered loader for `suffix`.
Args:
suffix (str): file suffix with the leading period (e.g., `".json"`)
loader (LoaderFunc): function that takes a string and returns
an object, typically a `Dict[str, Any]` of key/value pairs.
"""
LOADERS[suffix] = loader
set_loader(".json", json.loads)
set_loader(".toml", tomllib.loads)
set_loader(".env", env.loads)
# loaders registered
def load_config(
path: Path,
/,
*,
load_imports: bool = True,
loaders: Optional[Mapping[str, LoaderFunc]] = None,
done: Optional[List[Path]] = None,
) -> Dict[str, Any]:
"""Load a configuration file from `path` using configuration `loaders`.
Args:
path (Path): file to load.
load_imports (bool, optional): If `True`, recursively load any files
located at the `imports` key. Defaults to `True`.
loaders (Mapping[str, LoaderFunc], optional): mapping of file suffixes
to to loader functions. If `None`, uses the global `LOADERS`.
Defaults to `None`.
done (List[Path], optional): If provided, a list of paths to ignore when
doing recursive loading. Defaults to `None`.
Returns:
Dict[str, Any]: keys/values from the configuration file
Examples:
>>> root = Path(__file__).parent.parent.parent
>>> expected = {'section': {'key': 'value1', "env": "loaded",
... "json": "loaded", "toml": "loaded"}}
>>> load_config(root / "test/config_1.toml") == expected
True
"""
result = AttrDict()
path = path.resolve()
done = done or []
loader = (loaders or LOADERS)[path.suffix]
data = loader(path.read_text())
if load_imports and "imports" in data:
imports = [(path.parent / p).resolve() for p in data.pop("imports")]
for file in imports:
if file in done:
continue
result <<= load_config(
file,
load_imports=True,
loaders=loaders,
done=done + imports,
)
result <<= data
return result
def optvar(
name: str,
/,
*,
shadow_keywords: bool = False,
shadow_builtins: bool = False,
) -> str:
"""Return a valid python variable name from a docopt flag.
Args:
name (str): docopt option name.
shadow_keywords (bool, optional): If `True`, allow `name` to be a
python keyword. Otherwise, add an underscore. Defaults to `False`.
shadow_builtins (bool, optional): If `True` allow `name` to be a name
of a python `builtins` (globally available names). Otherwise, add an
underscore. Defaults to `False`.
Returns:
str: option name converted to a valid python variable name
Examples:
Special cases:
>>> optvar("-") == "stdin"
True
>>> optvar("--") == "__"
True
Leading dashes removed:
>>> optvar("--example") == "example"
True
>>> optvar("-v") == "v"
True
Hyphens become underscores:
>>> optvar("--two-words") == "two_words"
True
Angle brackets removed:
>>> optvar("<file>") == "file"
True
By default, we don't shadow keywords or builtins:
>>> optvar("--continue") == "continue_"
True
>>> optvar("--help") == "help_"
True
Shadow builtins if you want:
>>> optvar("--list", shadow_builtins=True) == "list"
True
Shadow keywords at your own risk:
>>> optvar("--continue", shadow_keywords=True) == "continue"
True
"""
result = name.lower()
special = {"-": "stdin", "--": "__"}
if result in special:
return special[result]
# special cases handled
result = result.replace("--", "")
if result[0] == "-":
result = result[1:]
# leading hyphens removed
trans: Dict[str, Union[str, int, None]] = {"-": "_", "<": "", ">": ""}
result = result.translate(str.maketrans(trans))
# hyphens become underscores; angle brackets removed
if not shadow_keywords and result in PYTHON_KEYWORDS:
result += "_"
# don't shadow keywords
if not shadow_builtins:
built_in = [s.lower() for s in globals()["__builtins__"]]
if result in built_in:
result += "_"
# don't shadow builtins
return result
def parse_docopt(
doc: str,
/,
argv: Optional[Sequence[str]] = None,
*,
version: str = "1.0.0",
options_first: bool = False,
read_config: bool = True,
) -> AttrDict:
"""Parse docopt args and load config.
Args:
doc (str): docstring with description of command
argv (Sequence[str], optional): arguments to parse against the
doc. If `None`, will default to `sys.argv[1:]`. Defaults to `None`.
version (str, optional): program version. Defaults to `"1.0.0"`.
options_first (bool): If `True`, options must come before positional
arguments. Defaults to `False`.
read_config (bool): If `True`, a `<config>` argument or `--config` option
will be automatically loaded before args are parsed. Defaults to `True`.
Returns:
AttrDict[str, Any]: mapping of options to values
Examples:
>>> usage = "Usage: test.py [--debug]"
>>> parse_docopt(usage, argv=["--debug"])
{'debug': True}
>>> root = Path(__file__).parent.parent.parent
>>> path = str(root / "test/config_1.toml")
>>> usage = "Usage: test.py <config> [--section.key=VAL]"
>>> argv = [path, "--section.key=overwrite"]
>>> expected = {"section": {
... "key": "overwrite", # overwritten by argv
... "env": "loaded", "json": "loaded", "toml": "loaded"
... }, "config": path}
>>> parse_docopt(usage, argv=argv) == expected
True
"""
result = AttrDict()
args = {
optvar(k, shadow_builtins=True): v
for k, v in docopt(
cleandoc(doc),
argv=argv,
help=True,
version=version,
options_first=options_first,
).items()
}
if read_config and "config" in args:
result <<= load_config(Path(args["config"]))
for key, val in args.items():
key = optvar(key, shadow_builtins=True)
result[key.split(".")] = val
return result
Global variables
var PYTHON_KEYWORDS : List[LiteralString]
var LoaderFunc
-
Function signature to load configuration from a string.
var LOADERS : Dict[str, Callable[[str], Any]]
-
Mapping of file extensions to configuration loaders.
Functions
def set_loader(suffix: str, loader: Callable[[str], Any]) ‑> None
-
Register a configuration
loader
for a given filesuffix
.NOTE: This will overwrite any previously registered loader for
suffix
.Args
suffix
:str
- file suffix with the leading period (e.g.,
".json"
) loader
:LoaderFunc
- function that takes a string and returns
an object, typically a
Dict[str, Any]
of key/value pairs.
Expand source code
def set_loader(suffix: str, loader: LoaderFunc) -> None: """Register a configuration `loader` for a given file `suffix`. NOTE: This will overwrite any previously registered loader for `suffix`. Args: suffix (str): file suffix with the leading period (e.g., `".json"`) loader (LoaderFunc): function that takes a string and returns an object, typically a `Dict[str, Any]` of key/value pairs. """ LOADERS[suffix] = loader
def load_config(path: pathlib.Path, /, *, load_imports: bool = True, loaders: Optional[Mapping[str, Callable[[str], Any]]] = None, done: Optional[List[pathlib.Path]] = None) ‑> Dict[str, Any]
-
Load a configuration file from
path
using configurationloaders
.Args
path
:Path
- file to load.
load_imports
:bool
, optional- If
True
, recursively load any files located at theimports
key. Defaults toTrue
. loaders
:Mapping[str, LoaderFunc]
, optional- mapping of file suffixes
to to loader functions. If
None
, uses the globalLOADERS
. Defaults toNone
. done
:List[Path]
, optional- If provided, a list of paths to ignore when
doing recursive loading. Defaults to
None
.
Returns
Dict[str, Any]
- keys/values from the configuration file
Examples
>>> root = Path(__file__).parent.parent.parent >>> expected = {'section': {'key': 'value1', "env": "loaded", ... "json": "loaded", "toml": "loaded"}} >>> load_config(root / "test/config_1.toml") == expected True
Expand source code
def load_config( path: Path, /, *, load_imports: bool = True, loaders: Optional[Mapping[str, LoaderFunc]] = None, done: Optional[List[Path]] = None, ) -> Dict[str, Any]: """Load a configuration file from `path` using configuration `loaders`. Args: path (Path): file to load. load_imports (bool, optional): If `True`, recursively load any files located at the `imports` key. Defaults to `True`. loaders (Mapping[str, LoaderFunc], optional): mapping of file suffixes to to loader functions. If `None`, uses the global `LOADERS`. Defaults to `None`. done (List[Path], optional): If provided, a list of paths to ignore when doing recursive loading. Defaults to `None`. Returns: Dict[str, Any]: keys/values from the configuration file Examples: >>> root = Path(__file__).parent.parent.parent >>> expected = {'section': {'key': 'value1', "env": "loaded", ... "json": "loaded", "toml": "loaded"}} >>> load_config(root / "test/config_1.toml") == expected True """ result = AttrDict() path = path.resolve() done = done or [] loader = (loaders or LOADERS)[path.suffix] data = loader(path.read_text()) if load_imports and "imports" in data: imports = [(path.parent / p).resolve() for p in data.pop("imports")] for file in imports: if file in done: continue result <<= load_config( file, load_imports=True, loaders=loaders, done=done + imports, ) result <<= data return result
def optvar(name: str, /, *, shadow_keywords: bool = False, shadow_builtins: bool = False) ‑> str
-
Return a valid python variable name from a docopt flag.
Args
name
:str
- docopt option name.
shadow_keywords
:bool
, optional- If
True
, allowname
to be a python keyword. Otherwise, add an underscore. Defaults toFalse
. shadow_builtins
:bool
, optional- If
True
allowname
to be a name of a pythonbuiltins
(globally available names). Otherwise, add an underscore. Defaults toFalse
.
Returns
str
- option name converted to a valid python variable name
Examples
Special cases:
>>> optvar("-") == "stdin" True >>> optvar("--") == "__" True
Leading dashes removed:
>>> optvar("--example") == "example" True >>> optvar("-v") == "v" True
Hyphens become underscores:
>>> optvar("--two-words") == "two_words" True
Angle brackets removed:
>>> optvar("<file>") == "file" True
By default, we don't shadow keywords or builtins:
>>> optvar("--continue") == "continue_" True >>> optvar("--help") == "help_" True
Shadow builtins if you want:
>>> optvar("--list", shadow_builtins=True) == "list" True
Shadow keywords at your own risk:
>>> optvar("--continue", shadow_keywords=True) == "continue" True
Expand source code
def optvar( name: str, /, *, shadow_keywords: bool = False, shadow_builtins: bool = False, ) -> str: """Return a valid python variable name from a docopt flag. Args: name (str): docopt option name. shadow_keywords (bool, optional): If `True`, allow `name` to be a python keyword. Otherwise, add an underscore. Defaults to `False`. shadow_builtins (bool, optional): If `True` allow `name` to be a name of a python `builtins` (globally available names). Otherwise, add an underscore. Defaults to `False`. Returns: str: option name converted to a valid python variable name Examples: Special cases: >>> optvar("-") == "stdin" True >>> optvar("--") == "__" True Leading dashes removed: >>> optvar("--example") == "example" True >>> optvar("-v") == "v" True Hyphens become underscores: >>> optvar("--two-words") == "two_words" True Angle brackets removed: >>> optvar("<file>") == "file" True By default, we don't shadow keywords or builtins: >>> optvar("--continue") == "continue_" True >>> optvar("--help") == "help_" True Shadow builtins if you want: >>> optvar("--list", shadow_builtins=True) == "list" True Shadow keywords at your own risk: >>> optvar("--continue", shadow_keywords=True) == "continue" True """ result = name.lower() special = {"-": "stdin", "--": "__"} if result in special: return special[result] # special cases handled result = result.replace("--", "") if result[0] == "-": result = result[1:] # leading hyphens removed trans: Dict[str, Union[str, int, None]] = {"-": "_", "<": "", ">": ""} result = result.translate(str.maketrans(trans)) # hyphens become underscores; angle brackets removed if not shadow_keywords and result in PYTHON_KEYWORDS: result += "_" # don't shadow keywords if not shadow_builtins: built_in = [s.lower() for s in globals()["__builtins__"]] if result in built_in: result += "_" # don't shadow builtins return result
def parse_docopt(doc: str, /, argv: Optional[Sequence[str]] = None, *, version: str = '1.0.0', options_first: bool = False, read_config: bool = True) ‑> AttrDict
-
Parse docopt args and load config.
Args
doc
:str
- docstring with description of command
argv
:Sequence[str]
, optional- arguments to parse against the
doc. If
None
, will default tosys.argv[1:]
. Defaults toNone
. version
:str
, optional- program version. Defaults to
"1.0.0"
. options_first
:bool
- If
True
, options must come before positional arguments. Defaults toFalse
. read_config
:bool
- If
True
, a<config>
argument or--config
option will be automatically loaded before args are parsed. Defaults toTrue
.
Returns
AttrDict[str, Any]
- mapping of options to values
Examples
>>> usage = "Usage: test.py [--debug]" >>> parse_docopt(usage, argv=["--debug"]) {'debug': True}
>>> root = Path(__file__).parent.parent.parent >>> path = str(root / "test/config_1.toml") >>> usage = "Usage: test.py <config> [--section.key=VAL]" >>> argv = [path, "--section.key=overwrite"] >>> expected = {"section": { ... "key": "overwrite", # overwritten by argv ... "env": "loaded", "json": "loaded", "toml": "loaded" ... }, "config": path} >>> parse_docopt(usage, argv=argv) == expected True
Expand source code
def parse_docopt( doc: str, /, argv: Optional[Sequence[str]] = None, *, version: str = "1.0.0", options_first: bool = False, read_config: bool = True, ) -> AttrDict: """Parse docopt args and load config. Args: doc (str): docstring with description of command argv (Sequence[str], optional): arguments to parse against the doc. If `None`, will default to `sys.argv[1:]`. Defaults to `None`. version (str, optional): program version. Defaults to `"1.0.0"`. options_first (bool): If `True`, options must come before positional arguments. Defaults to `False`. read_config (bool): If `True`, a `<config>` argument or `--config` option will be automatically loaded before args are parsed. Defaults to `True`. Returns: AttrDict[str, Any]: mapping of options to values Examples: >>> usage = "Usage: test.py [--debug]" >>> parse_docopt(usage, argv=["--debug"]) {'debug': True} >>> root = Path(__file__).parent.parent.parent >>> path = str(root / "test/config_1.toml") >>> usage = "Usage: test.py <config> [--section.key=VAL]" >>> argv = [path, "--section.key=overwrite"] >>> expected = {"section": { ... "key": "overwrite", # overwritten by argv ... "env": "loaded", "json": "loaded", "toml": "loaded" ... }, "config": path} >>> parse_docopt(usage, argv=argv) == expected True """ result = AttrDict() args = { optvar(k, shadow_builtins=True): v for k, v in docopt( cleandoc(doc), argv=argv, help=True, version=version, options_first=options_first, ).items() } if read_config and "config" in args: result <<= load_config(Path(args["config"])) for key, val in args.items(): key = optvar(key, shadow_builtins=True) result[key.split(".")] = val return result