Skip to content

Config

duho.config

Read and write configuration documents in JSON, TOML, YAML and INI.

A format is a :class:ConfigBackend; the module functions pick one by name or by file suffix. Importing this package loads no parser: each backend imports its library when it is used.

__all__ = ['ConfigBackend', 'ConfigDependencyError', 'ConfigError', 'INIBackend', 'JSONBackend', 'TOMLBackend', 'UnsupportedFormatError', 'YAMLBackend', 'backend_for', 'backend_names', 'dump', 'dumps', 'get_backend', 'load', 'loads', 'user_path'] module-attribute

ConfigBackend(*, name=None, aliases=None, suffixes=None)

One configuration format: reads and writes a document as a plain value.

Subclass it, set name in the class body and implement loads and dumps: the class is then registered and every duho.config function and every config file duho reads can use it. replace=True in the class statement takes over the names and suffixes of backends already registered (the replaced class leaves the registry whole). A subclass that sets no name of its own is not registered.

An instance may carry other name, aliases and suffixes than its class: YAMLBackend(suffixes=(".conf",)). Names, aliases and suffixes are lowercase and a suffix carries its dot; the suffix "" matches any file name. extra names the pip extra a backend's library comes with.

Source code in src/duho/config/_backend.py
79
80
81
82
83
84
85
86
87
88
89
90
91
def __init__(
    self,
    *,
    name: _ty.Optional[str] = None,
    aliases: _ty.Optional[_ty.Iterable[str]] = None,
    suffixes: _ty.Optional[_ty.Iterable[str]] = None,
) -> None:
    if name is not None:
        self.name = name
    if aliases is not None:
        self.aliases = tuple(aliases)
    if suffixes is not None:
        self.suffixes = tuple(suffixes)

aliases = () class-attribute instance-attribute

extra = None class-attribute instance-attribute

name instance-attribute

suffixes = () class-attribute instance-attribute

__init_subclass__(*, replace=False, **kwargs)

Source code in src/duho/config/_backend.py
74
75
76
77
def __init_subclass__(cls, *, replace: bool = False, **kwargs: _ty.Any) -> None:
    super().__init_subclass__(**kwargs)
    if "name" in cls.__dict__:
        _register(cls, replace)

__repr__()

Source code in src/duho/config/_backend.py
93
94
def __repr__(self) -> str:
    return f"{type(self).__name__}(name={self.name!r})"

dump(data, target)

Write data to a path (~ expanded) or a text file object.

The document is built first, so a failure leaves an existing file as it was.

Source code in src/duho/config/_backend.py
142
143
144
145
146
147
148
149
150
151
152
153
def dump(self, data: _ty.Any, target: _ty.Any) -> None:
    """Write ``data`` to a path (``~`` expanded) or a text file object.

    The document is built first, so a failure leaves an existing file as it was.
    """
    text = self.dumps(data)
    if hasattr(target, "write"):
        target.write(text)
        return
    path = _pathlib.Path(target).expanduser()
    with path.open("w", encoding="utf-8", newline="") as handle:
        handle.write(text)

dumps(data)

data as the text of a document.

Source code in src/duho/config/_backend.py
100
101
102
def dumps(self, data: _ty.Any) -> str:
    """``data`` as the text of a document."""
    raise NotImplementedError(f"{type(self).__name__} does not implement dumps()")

load(source)

Read a document from a path (~ expanded) or a file object.

The bytes are strict UTF-8 and one leading byte-order mark is dropped. A parse failure is a ConfigError that names the format and the file.

Source code in src/duho/config/_backend.py
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
def load(self, source: _ty.Any) -> _ty.Any:
    """Read a document from a path (``~`` expanded) or a file object.

    The bytes are strict UTF-8 and one leading byte-order mark is dropped.
    A parse failure is a ``ConfigError`` that names the format and the file.
    """
    if hasattr(source, "read"):
        shown = getattr(source, "name", None)
        shown = shown if isinstance(shown, str) else "<stream>"
        raw = source.read()
    else:
        path = _pathlib.Path(source).expanduser()
        shown = _os.fspath(path)
        with path.open("rb") as handle:
            raw = handle.read()
    label = self.name.upper()
    if isinstance(raw, (bytes, bytearray)):
        try:
            raw = bytes(raw).decode("utf-8")
        except UnicodeDecodeError as exc:
            raise ConfigError(
                f"duho: invalid {label} in config file {shown}: "
                f"not valid UTF-8 (byte offset {exc.start})",
                path=shown,
            ) from None
    text = raw[1:] if raw.startswith("\ufeff") else raw
    try:
        return self.loads(text)
    except ConfigDependencyError:
        raise
    except ConfigError as exc:
        raise ConfigError(
            f"duho: invalid {label} in config file {shown}: {exc.message}",
            path=shown,
            lineno=exc.lineno,
            colno=exc.colno,
        ) from None

loads(text)

Parse text; a bad document raises ConfigError holding none of its values.

Source code in src/duho/config/_backend.py
96
97
98
def loads(self, text: str) -> _ty.Any:
    """Parse ``text``; a bad document raises ``ConfigError`` holding none of its values."""
    raise NotImplementedError(f"{type(self).__name__} does not implement loads()")

ConfigDependencyError(message='', extra=None, path=None)

Bases: ConfigError

A backend needs a library that is not installed; extra names the pip extra.

Source code in src/duho/exceptions.py
71
72
73
74
75
76
77
78
def __init__(
    self,
    message: str = "",
    extra: _ty.Optional[str] = None,
    path: _ty.Optional[str] = None,
) -> None:
    super().__init__(message, path)
    self.extra = extra

extra = extra instance-attribute

ConfigError(message='', path=None, lineno=None, colno=None)

Bases: ValueError

A configuration document could not be read or written.

str(exc) is the message. It never holds a value of the document; it may name a key, a section, or a YAML alias or tag, cut to a bounded length. path, lineno and colno locate the problem when known.

Source code in src/duho/exceptions.py
47
48
49
50
51
52
53
54
55
56
57
58
def __init__(
    self,
    message: str = "",
    path: _ty.Optional[str] = None,
    lineno: _ty.Optional[int] = None,
    colno: _ty.Optional[int] = None,
) -> None:
    super().__init__(message)
    self.message = message
    self.path = path
    self.lineno = lineno
    self.colno = colno

colno = colno instance-attribute

lineno = lineno instance-attribute

message = message instance-attribute

path = path instance-attribute

__str__()

Source code in src/duho/exceptions.py
60
61
def __str__(self) -> str:
    return self.message

INIBackend(*, name=None, aliases=None, suffixes=None)

Bases: ConfigBackend

INI through configparser, read as plain text with no interpolation.

The keys of [DEFAULT] are the top level, every other section is a table one level deep, sections do not inherit [DEFAULT], key case is kept and % is text. Every value is a string.

Source code in src/duho/config/_backend.py
79
80
81
82
83
84
85
86
87
88
89
90
91
def __init__(
    self,
    *,
    name: _ty.Optional[str] = None,
    aliases: _ty.Optional[_ty.Iterable[str]] = None,
    suffixes: _ty.Optional[_ty.Iterable[str]] = None,
) -> None:
    if name is not None:
        self.name = name
    if aliases is not None:
        self.aliases = tuple(aliases)
    if suffixes is not None:
        self.suffixes = tuple(suffixes)

name = 'ini' class-attribute instance-attribute

suffixes = ('.ini', '.cfg') class-attribute instance-attribute

dumps(data)

Source code in src/duho/config/_ini.py
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
def dumps(self, data: _ty.Any) -> str:
    import configparser
    import io

    if not isinstance(data, _ty.Mapping):
        raise ConfigError("cannot write as INI: the top level must be a table")
    parser = configparser.ConfigParser(interpolation=None)
    parser.optionxform = str  # type: ignore[assignment,method-assign]
    for key, value in data.items():
        if isinstance(value, _ty.Mapping):
            if str(key) == _TOP:
                raise ConfigError(f"cannot write as INI: a table named {_TOP}")
            parser.add_section(str(key))
            for sub, item in value.items():
                parser.set(str(key), str(sub), _scalar(f"{key}.{sub}", item))
        else:
            parser.set(_TOP, str(key), _scalar(str(key), value))
    out = io.StringIO()
    parser.write(out)
    return out.getvalue()

loads(text)

Source code in src/duho/config/_ini.py
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
def loads(self, text: str) -> _ty.Any:
    import configparser

    parser = configparser.ConfigParser(
        interpolation=None, default_section=_NO_DEFAULTS
    )
    parser.optionxform = str  # type: ignore[assignment,method-assign]
    try:
        parser.read_string(text)
    except configparser.MissingSectionHeaderError as exc:
        raise _at("no section header before the first key", exc.lineno) from None
    except configparser.ParsingError as exc:
        errors = getattr(exc, "errors", None) or []
        raise _at("not a key and value", errors[0][0] if errors else None) from None
    except configparser.DuplicateSectionError as exc:
        raise _at(
            f"section [{_plain(exc.section)}] is defined twice", exc.lineno
        ) from None
    except configparser.DuplicateOptionError as exc:
        raise _at(
            f"key {_plain(exc.option)!r} is defined twice in "
            f"[{_plain(exc.section)}]",
            exc.lineno,
        ) from None
    except configparser.Error:
        raise ConfigError("not valid INI") from None
    result: "dict[str, _ty.Any]" = {}
    if parser.has_section(_TOP):
        result.update(parser.items(_TOP))
    for section in parser.sections():
        if section == _TOP:
            continue
        if section in result:
            raise ConfigError(
                f"{_plain(section)!r} is both a key of [{_TOP}] and a section"
            )
        result[section] = dict(parser.items(section))
    return result

JSONBackend(*, name=None, aliases=None, suffixes=None)

Bases: ConfigBackend

JSON through the standard library.

Source code in src/duho/config/_backend.py
79
80
81
82
83
84
85
86
87
88
89
90
91
def __init__(
    self,
    *,
    name: _ty.Optional[str] = None,
    aliases: _ty.Optional[_ty.Iterable[str]] = None,
    suffixes: _ty.Optional[_ty.Iterable[str]] = None,
) -> None:
    if name is not None:
        self.name = name
    if aliases is not None:
        self.aliases = tuple(aliases)
    if suffixes is not None:
        self.suffixes = tuple(suffixes)

name = 'json' class-attribute instance-attribute

suffixes = ('.json',) class-attribute instance-attribute

dumps(data)

Source code in src/duho/config/_json.py
27
28
29
30
31
32
33
def dumps(self, data: _ty.Any) -> str:
    import json

    try:
        return json.dumps(data, indent=2, ensure_ascii=False) + "\n"
    except (TypeError, ValueError) as exc:
        raise ConfigError(f"cannot write as JSON: {exc}") from None

loads(text)

Source code in src/duho/config/_json.py
17
18
19
20
21
22
23
24
25
def loads(self, text: str) -> _ty.Any:
    import json

    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raise ConfigError(str(exc), lineno=exc.lineno, colno=exc.colno) from None
    except RecursionError:
        raise ConfigError("nested too deeply") from None

TOMLBackend(*, name=None, aliases=None, suffixes=None)

Bases: ConfigBackend

TOML: tomllib (or tomli) reads, tomli_w writes.

Source code in src/duho/config/_backend.py
79
80
81
82
83
84
85
86
87
88
89
90
91
def __init__(
    self,
    *,
    name: _ty.Optional[str] = None,
    aliases: _ty.Optional[_ty.Iterable[str]] = None,
    suffixes: _ty.Optional[_ty.Iterable[str]] = None,
) -> None:
    if name is not None:
        self.name = name
    if aliases is not None:
        self.aliases = tuple(aliases)
    if suffixes is not None:
        self.suffixes = tuple(suffixes)

extra = 'config' class-attribute instance-attribute

name = 'toml' class-attribute instance-attribute

suffixes = ('.toml',) class-attribute instance-attribute

dumps(data)

Source code in src/duho/config/_toml.py
57
58
59
60
61
62
63
64
65
66
67
68
69
def dumps(self, data: _ty.Any) -> str:
    try:
        import tomli_w
    except ImportError:
        raise ConfigDependencyError(
            "duho: writing TOML requires the optional 'tomli-w' package "
            "(e.g. `pip install tomli-w` or `pip install duho[config]`).",
            extra="config",
        ) from None
    try:
        return tomli_w.dumps(data)
    except (TypeError, ValueError) as exc:
        raise ConfigError(f"cannot write as TOML: {exc}") from None

loads(text)

Source code in src/duho/config/_toml.py
42
43
44
45
46
47
48
49
50
51
52
53
54
55
def loads(self, text: str) -> _ty.Any:
    reader = _reader()
    try:
        return reader.loads(text)
    except reader.TOMLDecodeError as exc:
        message = str(exc)
        lineno = getattr(exc, "lineno", None)
        colno = getattr(exc, "colno", None)
        found = _POSITION.search(message)
        if lineno is None and found is not None:
            lineno, colno = int(found.group(1)), int(found.group(2))
        raise ConfigError(message, lineno=lineno, colno=colno) from None
    except RecursionError:
        raise ConfigError("nested too deeply") from None

UnsupportedFormatError(message='', path=None, lineno=None, colno=None)

Bases: ConfigError

No backend matches the format name or the file name.

Source code in src/duho/exceptions.py
47
48
49
50
51
52
53
54
55
56
57
58
def __init__(
    self,
    message: str = "",
    path: _ty.Optional[str] = None,
    lineno: _ty.Optional[int] = None,
    colno: _ty.Optional[int] = None,
) -> None:
    super().__init__(message)
    self.message = message
    self.path = path
    self.lineno = lineno
    self.colno = colno

YAMLBackend(*, name=None, aliases=None, suffixes=None)

Bases: ConfigBackend

YAML through PyYAML's safe loader and dumper.

Source code in src/duho/config/_backend.py
79
80
81
82
83
84
85
86
87
88
89
90
91
def __init__(
    self,
    *,
    name: _ty.Optional[str] = None,
    aliases: _ty.Optional[_ty.Iterable[str]] = None,
    suffixes: _ty.Optional[_ty.Iterable[str]] = None,
) -> None:
    if name is not None:
        self.name = name
    if aliases is not None:
        self.aliases = tuple(aliases)
    if suffixes is not None:
        self.suffixes = tuple(suffixes)

aliases = ('yml',) class-attribute instance-attribute

extra = 'yaml' class-attribute instance-attribute

name = 'yaml' class-attribute instance-attribute

suffixes = ('.yaml', '.yml') class-attribute instance-attribute

dumps(data)

Source code in src/duho/config/_yaml.py
49
50
51
52
53
54
55
56
57
def dumps(self, data: _ty.Any) -> str:
    yaml = _module()
    try:
        return yaml.safe_dump(data, sort_keys=False, allow_unicode=True)
    except yaml.YAMLError:
        # PyYAML's own text quotes the value it could not represent.
        raise ConfigError(
            "cannot write as YAML: a value is not a plain YAML type"
        ) from None

loads(text)

Source code in src/duho/config/_yaml.py
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
def loads(self, text: str) -> _ty.Any:
    yaml = _module()
    try:
        return yaml.safe_load(text)
    except yaml.YAMLError as exc:
        mark = getattr(exc, "problem_mark", None)
        problem = getattr(exc, "problem", None)
        lineno = None if mark is None else mark.line + 1
        colno = None if mark is None else mark.column + 1
        detail = _plain(problem) if problem else "not valid YAML"
        if lineno is not None:
            detail += f" (at line {lineno}, column {colno})"
        raise ConfigError(detail, lineno=lineno, colno=colno) from None
    except RecursionError:
        raise ConfigError("nested too deeply") from None
    except (ValueError, TypeError, OverflowError):
        raise ConfigError("a value that is not valid YAML") from None

backend_for(path, format=None, *, default=None, backends=None)

The backend for a file: format if given, else the longest suffix its name ends with (case ignored), else default.

No match and no default is an UnsupportedFormatError listing the accepted suffixes.

Source code in src/duho/config/_backend.py
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
def backend_for(
    path: "_ty.Union[str, _os.PathLike[str]]",
    format: _ty.Optional[str] = None,
    *,
    default: _ty.Optional[str] = None,
    backends: _Backends = None,
) -> ConfigBackend:
    """The backend for a file: ``format`` if given, else the longest suffix its
    name ends with (case ignored), else ``default``.

    No match and no ``default`` is an ``UnsupportedFormatError`` listing the
    accepted suffixes.
    """
    if format is not None:
        return get_backend(format, backends=backends)
    _, by_suffix, _ = _index(backends)
    file_name = _pathlib.PurePath(_os.fspath(path)).name.lower()
    matches = [s for s in by_suffix if file_name.endswith(s)]
    if matches:
        return by_suffix[max(matches, key=len)]
    if default is not None:
        return get_backend(default, backends=backends)
    accepted = ", ".join(sorted(s for s in by_suffix if s)) or "none"
    raise UnsupportedFormatError(
        f"duho: cannot tell the config format of {_os.fspath(path)!r}; "
        f"accepted suffixes: {accepted}"
    )

backend_names(*, backends=None)

The primary names of the backends in the set, sorted.

Source code in src/duho/config/_backend.py
210
211
212
213
def backend_names(*, backends: _Backends = None) -> "tuple[str, ...]":
    """The primary names of the backends in the set, sorted."""
    _, _, items = _index(backends)
    return tuple(sorted({item.name.lower() for item in items}))

dump(data, target, format=None, *, backends=None)

Write data to a file (a path or a text file object).

Source code in src/duho/config/_backend.py
284
285
286
287
288
289
290
291
292
293
def dump(
    data: _ty.Any,
    target: _ty.Any,
    format: _ty.Optional[str] = None,
    *,
    backends: _Backends = None,
) -> None:
    """Write ``data`` to a file (a path or a text file object)."""
    where = _name_of(target) if hasattr(target, "write") and not format else target
    backend_for(where, format, backends=backends).dump(data, target)

dumps(data, format, *, backends=None)

data as a document of the named format.

Source code in src/duho/config/_backend.py
279
280
281
def dumps(data: _ty.Any, format: str, *, backends: _Backends = None) -> str:
    """``data`` as a document of the named format."""
    return get_backend(format, backends=backends).dumps(data)

get_backend(name, *, backends=None)

The backend answering to name or an alias, in any case.

Source code in src/duho/config/_backend.py
216
217
218
219
220
221
222
223
224
225
def get_backend(name: str, *, backends: _Backends = None) -> ConfigBackend:
    """The backend answering to ``name`` or an alias, in any case."""
    by_name, _, items = _index(backends)
    found = by_name.get(str(name).lower())
    if found is None:
        raise UnsupportedFormatError(
            f"duho: no config format named {name!r}; use one of "
            f"{', '.join(sorted({i.name.lower() for i in items}))}"
        )
    return found

load(source, format=None, *, backends=None)

Read a file (a path or a file object); the format is format or the file name's.

Source code in src/duho/config/_backend.py
271
272
273
274
275
276
def load(
    source: _ty.Any, format: _ty.Optional[str] = None, *, backends: _Backends = None
) -> _ty.Any:
    """Read a file (a path or a file object); the format is ``format`` or the file name's."""
    where = _name_of(source) if hasattr(source, "read") and not format else source
    return backend_for(where, format, backends=backends).load(source)

loads(text, format, *, backends=None)

Parse text as the named format.

Source code in src/duho/config/_backend.py
266
267
268
def loads(text: str, format: str, *, backends: _Backends = None) -> _ty.Any:
    """Parse ``text`` as the named format."""
    return get_backend(format, backends=backends).loads(text)

user_path(app, *names)

The per-user configuration path <config dir>/<app>/<names...>.

The result resolves each time it is used as a path, so it is safe as a class attribute (_config_ = user_path("tool", "config.toml")): the environment is read when the file is looked up, not when the class is defined. The configuration directory is APPDATA (when absolute, else ~/AppData/Roaming) on Windows and XDG_CONFIG_HOME (when absolute, else ~/.config) elsewhere. str(path) is the resolved path; repr shows the call.

Raises ValueError when app is empty.

Source code in src/duho/config/_paths.py
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
def user_path(app: str, *names: str) -> "_os.PathLike[str]":
    """The per-user configuration path ``<config dir>/<app>/<names...>``.

    The result resolves each time it is used as a path, so it is safe as a class
    attribute (``_config_ = user_path("tool", "config.toml")``): the environment
    is read when the file is looked up, not when the class is defined. The
    configuration directory is ``APPDATA`` (when absolute, else
    ``~/AppData/Roaming``) on Windows and ``XDG_CONFIG_HOME`` (when absolute,
    else ``~/.config``) elsewhere.
    ``str(path)`` is the resolved path; ``repr`` shows the call.

    Raises ``ValueError`` when ``app`` is empty.
    """
    if not app:
        raise ValueError("user_path: app must be a non-empty name")
    return _UserPath((app, *names))