Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CHANGES.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ Changes
development (main)
------------------

-
- Rename the 'empty' fallback value to `NOT_CONFIGURED` (old name `NotConfigured` is retained for backwards compatibility).

0.18 (2026-07-13)
-----------------
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ function(**configuration.foo)
value = configuration.foo.bar
# they're even safe when values might be missing
value = configuration.foo.whoopsie
if value is NotConfigured:
if value is NOT_CONFIGURED:
value = 42
# or, similar
value = configuration.foo.whoopsie or 42
Expand Down
3 changes: 2 additions & 1 deletion confidence/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from confidence.exceptions import ConfigurationError, ConfiguredReferenceError, MergeConflictError, NotConfiguredError
from confidence.formats import JSON, TOML, YAML, Format
from confidence.io import DEFAULT_LOAD_ORDER, Locality, dump, dumpf, dumps, load, load_name, loaders, loadf, loads
from confidence.models import Configuration, Missing, NotConfigured, merge, unwrap
from confidence.models import NOT_CONFIGURED, Configuration, Missing, NotConfigured, merge, unwrap


__all__: Sequence[str] = sorted(
Expand All @@ -18,6 +18,7 @@
'Locality',
'MergeConflictError',
'Missing',
'NOT_CONFIGURED',
'NotConfigured',
'NotConfiguredError',
'TOML',
Expand Down
13 changes: 8 additions & 5 deletions confidence/formats.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import json
import typing
from abc import ABC, abstractmethod
from collections.abc import Sequence
from dataclasses import dataclass, replace
from os import PathLike
from pathlib import Path
Expand Down Expand Up @@ -111,9 +112,11 @@ def dumps(self, value: typing.Any) -> str:
YAML: Format = _YAMLFormat(suffix='.yaml', encoding='utf-8')


__all__ = (
'Format',
'JSON',
'TOML',
'YAML',
__all__: Sequence[str] = sorted(
{
'Format',
'JSON',
'TOML',
'YAML',
}
)
32 changes: 16 additions & 16 deletions confidence/io.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
from typing import Any, TextIO

from confidence.formats import YAML, Format
from confidence.models import Configuration, Missing, NoDefault, NotConfigured
from confidence.models import NO_DEFAULT, NOT_CONFIGURED, Configuration, Missing


LOG = logging.getLogger(__name__)
Expand All @@ -33,11 +33,11 @@ def read_xdg_config_dirs(name: str, format: Format = YAML) -> Configuration:
# PATH-like env vars operate in decreasing precedence, reverse this path set to mimic the end result
config_dirs = reversed(config_dirs.split(pathsep))

# load a file from all config dirs, default to NotConfigured
# load a file from all config dirs, default to NOT_CONFIGURED
return loadf(
*(Path(config_dir) / f'{name}{format.suffix}' for config_dir in config_dirs),
format=format,
default=NotConfigured,
default=NOT_CONFIGURED,
)


Expand All @@ -49,7 +49,7 @@ def read_xdg_config_home(name: str, format: Format = YAML) -> Configuration:

:param name: application or configuration set name
:param format: configuration (file) format to use
:returns: a `Configuration` instance, possibly `NotConfigured`
:returns: a `Configuration` instance, possibly `NOT_CONFIGURED`
"""
# find optional value of ${XDG_CONFIG_HOME}
# XDG spec: "If $XDG_CONFIG_HOME is either not set or empty, a default equal to $HOME/.config should be used."
Expand All @@ -58,7 +58,7 @@ def read_xdg_config_home(name: str, format: Format = YAML) -> Configuration:
config_home = environ.get('XDG_CONFIG_HOME')
config_home = Path(config_home) if config_home else Path(f'{home}/.config')
# expand to full path to configuration file in XDG config path
return loadf(config_home / f'{name}{format.suffix}', format=format, default=NotConfigured)
return loadf(config_home / f'{name}{format.suffix}', format=format, default=NOT_CONFIGURED)


def read_envvars(name: str, format: Format = YAML) -> Configuration:
Expand All @@ -78,7 +78,7 @@ def read_envvars(name: str, format: Format = YAML) -> Configuration:

:param name: environment variable prefix to look for (without the ``_``)
:param format: configuration (file) format to use
:returns: a `Configuration` instance, possibly `NotConfigured`
:returns: a `Configuration` instance, possibly `NOT_CONFIGURED`
"""
prefix = f'{name}_'
prefix_len = len(prefix)
Expand All @@ -90,7 +90,7 @@ def read_envvars(name: str, format: Format = YAML) -> Configuration:
if var.lower().startswith(prefix) and var.lower() != envvar_file
}
if not values:
return NotConfigured
return NOT_CONFIGURED

def dotted(name: str) -> str:
# replace 'regular' underscores (those between alphanumeric characters) with dots first
Expand All @@ -113,37 +113,37 @@ def read_envvar_file(name: str, format: Format = YAML) -> Configuration:
:param name: environment variable prefix to look for (without the
``_CONFIG_FILE``)
:param format: configuration (file) format to use
:returns: a `Configuration`, possibly `NotConfigured`
:returns: a `Configuration`, possibly `NOT_CONFIGURED`
"""
envvar_file = environ.get(f'{name}_config_file'.upper())
if envvar_file:
# envvar set, load value as file
return loadf(envvar_file, format=format)
else:
# envvar not set, return an empty source
return NotConfigured
return NOT_CONFIGURED


def read_envvar_dir(envvar: str, name: str, format: Format = YAML) -> Configuration:
"""
Read values from a file located in a directory specified by a particular
environment file. ``read_envvar_dir('HOME', 'example', format=YAML)`` would
look for a file at ``/home/user/example.yaml``. When the environment
variable isn't set or the file does not exist, `NotConfigured` will be
variable isn't set or the file does not exist, `NOT_CONFIGURED` will be
returned.

:param envvar: the environment variable to interpret as a directory
:param name: application or configuration set name
:param format: configuration (file) format to use
:returns: a `Configuration`, possibly `NotConfigured`
:returns: a `Configuration`, possibly `NOT_CONFIGURED`
"""
config_dir = environ.get(envvar)
if not config_dir:
return NotConfigured
return NOT_CONFIGURED

# envvar is set, construct full file path, expanding user to allow the envvar containing a value like ~/config
config_path = Path(config_dir).expanduser() / f'{name}{format.suffix}'
return loadf(config_path, format=format, default=NotConfigured)
return loadf(config_path, format=format, default=NOT_CONFIGURED)


class Locality(IntEnum):
Expand Down Expand Up @@ -275,7 +275,7 @@ def load(*fps: TextIO, format: Format = YAML, missing: Any = Missing.SILENT) ->
def loadf(
*fnames: str | PathLike,
format: Format = YAML,
default: Any = NoDefault,
default: Any = NO_DEFAULT,
missing: Any = Missing.SILENT,
) -> Configuration:
"""
Expand All @@ -294,7 +294,7 @@ def readf(fpath: Path) -> Mapping[str, Any]:
try:
return format.loadf(fpath)
except FileNotFoundError:
if default is NoDefault:
if default is NO_DEFAULT:
# no explicit default provided, continue original error
raise
else:
Expand Down Expand Up @@ -363,7 +363,7 @@ def generate_sources() -> Iterable[Mapping[str, Any]]:
yield source(name, format)
else:
source = _format_source(source, name, format)
yield loadf(source, format=format, default=NotConfigured)
yield loadf(source, format=format, default=NOT_CONFIGURED)

return Configuration(*generate_sources(), missing=missing)

Expand Down
98 changes: 60 additions & 38 deletions confidence/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,26 +4,23 @@
from itertools import chain
from typing import Any

from typing_extensions import Self, sentinel

from confidence.exceptions import ConfiguredReferenceError, NotConfiguredError
from confidence.utils import Conflict, merge_into, split_keys


class Missing(Enum):
SILENT = 'silent' #: return `NotConfigured` for unconfigured keys, avoiding errors
SILENT = 'silent' #: return `NOT_CONFIGURED` for unconfigured keys, avoiding errors
ERROR = 'error' #: raise an `AttributeError` for unconfigured keys


# define a sentinel value to indicate there is no default value specified (None would be a valid default value)
# as this is used as an argument default to indicate that an error should be raised when a value is not found, make
# sure that the repr-value of NoDefault shows up as '(raise)' in documentation
NoDefault = type(
'NoDefault',
(object,),
{
'__repr__': lambda self: '(raise)',
'__str__': lambda self: '(raise)',
},
)() # create instance of that new type to assign to NoDefault
# sure that the repr-value of NO_DEFAULT shows up as '(raise)' in documentation
NO_DEFAULT = sentinel('NO_DEFAULT', repr='(raise)')
# retain old name for backwards compatibility
NoDefault = NO_DEFAULT


def unwrap(source: Any) -> Any:
Expand Down Expand Up @@ -96,8 +93,8 @@ def __init__(self, *sources: Mapping[str, Any], missing: Any = Missing.SILENT):

if isinstance(self._missing, Missing):
self._missing = {
Missing.SILENT: NotConfigured,
Missing.ERROR: NoDefault,
Missing.SILENT: NOT_CONFIGURED,
Missing.ERROR: NO_DEFAULT,
}[missing]

self._source: MutableMapping[str, Any] = {}
Expand Down Expand Up @@ -130,7 +127,7 @@ def _resolve(self, value: str) -> Any:
if path in references:
raise ConfiguredReferenceError(f'cannot resolve recursive reference {path}', key=path)

reference = self._root.get(path, default=NoDefault, resolve_references=False)
reference = self._root.get(path, default=NO_DEFAULT, resolve_references=False)

if match.span(0) != (0, len(value)):
# matched a reference inside of another value (template)
Expand Down Expand Up @@ -168,7 +165,7 @@ def get(
:param path: the configuration key to fetch a value for, steps
separated by a dot (``.``)
:param default: a value to return if no value is found for the
supplied path (defaults to ``None``, use ``NoDefault`` to trigger a
supplied path (defaults to ``None``, use ``NO_DEFAULT`` to trigger a
``KeyError`` for a non-existing)
:param as_type: an optional callable to apply to the value found for
the supplied path (possibly raising exceptions of its own if the
Expand All @@ -177,7 +174,7 @@ def get(
:returns: the value associated with the supplied configuration key, if
available, or a supplied default value if the key was not found
:raises NotConfiguredError: when no value was found for *path* and
*default* was ``NoDefault``
*default* was ``NO_DEFAULT``
:raises ConfiguredReferenceError: when a reference could not be resolved
"""
value = self._source
Expand Down Expand Up @@ -209,7 +206,7 @@ def get(
# also a KeyError, but this one should bubble to caller
raise
except KeyError as e:
if default is not NoDefault:
if default is not NO_DEFAULT:
return default
else:
missing_key = '.'.join(steps_taken)
Expand Down Expand Up @@ -252,9 +249,9 @@ def __len__(self) -> int:
return len(self._source)

def __getitem__(self, item: str) -> Any:
# emulate the way dict would handle this: explicitly pass NoDefault to trigger a KeyError when item is not
# emulate the way dict would handle this: explicitly pass NO_DEFAULT to trigger a KeyError when item is not
# available
return self.get(item, default=NoDefault)
return self.get(item, default=NO_DEFAULT)

def __iter__(self) -> Iterator[str]:
return iter(self._source)
Expand Down Expand Up @@ -287,9 +284,9 @@ def __getstate__(self) -> dict[str, Any]:

# NB: both 'magic missing values' are required to be the same specific instances at runtime, encode them as
# their corresponding Missing instances for pickling (but leave them as-is otherwise)
if state['_missing'] is NotConfigured:
if state['_missing'] is NOT_CONFIGURED:
state['_missing'] = Missing.SILENT
elif state['_missing'] is NoDefault:
elif state['_missing'] is NO_DEFAULT:
state['_missing'] = Missing.ERROR

return state
Expand All @@ -299,26 +296,38 @@ def __setstate__(self, state: dict[str, Any]) -> None:

if isinstance(self._missing, Missing):
# reverse the Missing encoding done in __getstate__
self._missing = {Missing.SILENT: NotConfigured, Missing.ERROR: NoDefault}[self._missing]
self._missing = {Missing.SILENT: NOT_CONFIGURED, Missing.ERROR: NO_DEFAULT}[self._missing]


# define NotConfigured as a class first (using type() to keep the type checker happy)
NotConfigured = type(
'NotConfigured',
(Configuration,),
{
'__bool__': lambda self: False,
'__repr__': lambda self: '(not configured)',
'__str__': lambda self: '(not configured)',
'__doc__': 'Sentinel value to signal there is no value for a requested key.',
'__hash__': lambda self: hash((type(self), None)),
},
)
# overwrite the NotConfigured type as an instance of itself, serving as a sentinel value that some requested key was
# not configured, while still acting like a Configuration object
NotConfigured = NotConfigured()
# NotConfigured._missing refers to the NotConfigured *type* at this point, overwrite it with the sentinel value
NotConfigured._missing = NotConfigured # type: ignore
class _NotConfigured(Configuration):
_instance: Self | None = None

def __new__(cls) -> Self:
if cls._instance is None:
cls._instance = super().__new__(cls)

return cls._instance

def __init__(self) -> None:
super().__init__(missing=self)

def __bool__(self) -> bool:
return False

def __str__(self) -> str:
return '(not configured)'

def __repr__(self) -> str:
return '(not configured)'

def __hash__(self) -> int:
return hash((self.__class__, None))


# set NOT_CONFIGURED as the singleton instance of _NotConfigured
NOT_CONFIGURED = _NotConfigured()
# retain old name for backwards compatibility
NotConfigured = NOT_CONFIGURED


# collect the names of all defined members of a Configuration instance to be used to warn for configured keys that
Expand Down Expand Up @@ -408,3 +417,16 @@ def _repr_value(value: Any) -> str:
case _:
# fall back to builtin repr
return repr(value)


__all__: Sequence[str] = sorted(
{
'Configuration',
'ConfigurationSequence',
'Missing',
'NO_DEFAULT',
'NOT_CONFIGURED',
'merge',
'unwrap',
}
)
Loading
Loading