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
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,38 @@
# Licensed under the MIT License.

from ._load_configuration import load_configuration_from_env
from .configuration import Configuration, _Configurable
from ._configuration_protocol import _ConfigurationProtocol, _DictConfigurable
from ._resolve import _resolve
from ._utils import (
_MissingType,
_MISSING,
_get,
_is_missing,
_path as path,
_xpath as xpath,
_set,
_try_set,
)
from ._coercion import coerce_bool, coerce_int, coerce_float, _get_str

__all__ = [
"load_configuration_from_env",
"Configuration",
"_ConfigurationProtocol",
"_MissingType",
"_MISSING",
"_is_missing",
"xpath",
"path",
"_get",
"_set",
"_try_set",
"_resolve",
"_Configurable",
"_DictConfigurable",
"coerce_bool",
"coerce_int",
"coerce_float",
"_get_str",
]
Original file line number Diff line number Diff line change
Expand Up @@ -13,87 +13,269 @@

from __future__ import annotations

from typing import Any
from typing import (
Any,
Mapping,
overload,
)

from ._utils import (
_MISSING,
_MissingType,
_get,
_is_missing,
)

_TRUTHY = ("1", "true")
_FALSY = ("0", "false")


def coerce_bool(value: Any, default: bool | None = None, name: str = "value") -> bool:
@overload
def _handle_bool(
value: Any, default: None = None, name: str = "value"
) -> bool | None: ...
@overload
def _handle_bool(
value: Any, default: bool | _MissingType = _MISSING, name: str = "value"
) -> bool: ...
def _handle_bool(
value: Any, default: bool | None | _MissingType = None, name: str = "value"
) -> bool | None:
"""Coerce a config value (possibly an env string) to a bool.

Environment variables always arrive as strings, so ``bool("false")`` would
be ``True``. Recognized truthy spellings are ``1/true`` and falsy spellings
are ``0/false`` (case-insensitive).

An empty/whitespace-only string or ``None`` is treated as *unset* (not as an
explicit ``false``). For unset and unrecognized values: if ``default`` is
provided it is returned, otherwise a ``ValueError`` is raised naming the
offending setting -- mirroring :func:`coerce_int` and :func:`coerce_float`.
explicit ``false``). Unset values return the default, or raise if no default
was supplied. Unrecognized nonempty values always raise ``ValueError``.
Errors identify the setting without including its input value.
"""
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
if value is not None:
if value is not None and not _is_missing(value):
text = str(value).strip().lower()
if text in _TRUTHY:
return True
if text in _FALSY:
return False
if text:
if default is not None:
return default
raise ValueError(f"Invalid boolean for {name}: {value!r}")
# Unset (None or empty/whitespace-only string).
if default is not None:
return default
raise ValueError(f"Invalid boolean for {name}: {value!r}")
raise ValueError(f"Invalid boolean for {name}")
if _is_missing(default):
raise ValueError(f"Missing boolean for {name}")
return default


def coerce_bool(value: Any, default: bool | None = None, name: str = "value") -> bool:
"""Coerce a config value (possibly an env string) to a bool.

Environment variables always arrive as strings, so ``bool("false")`` would
be ``True``. Recognized truthy spellings are ``1/true`` and falsy spellings
are ``0/false`` (case-insensitive).

An empty/whitespace-only string or ``None`` is treated as *unset* (not as an
explicit ``false``). Unset and unrecognized values return the default, or
raise ``ValueError`` if no non-None default was supplied.
Errors identify the setting without including its input value.
"""
new_default = default if default is not None else _MISSING
try:
return _handle_bool(value, new_default, name)
except ValueError:
if default is not None:
return default
raise


def coerce_int(value: Any, default: int, name: str = "value") -> int:
@overload
def _handle_int(
value: Any, default: int | _MissingType = _MISSING, name: str = "value"
) -> int: ...
@overload
def _handle_int(
value: Any, default: None = None, name: str = "value"
) -> int | None: ...
def _handle_int(
value: Any, default: int | None | _MissingType = None, name: str = "value"
) -> int | None:
"""Coerce a config value (possibly an env string) to an int.

Empty/``None`` values fall back to ``default``. Bools are treated as unset
(a stray ``True``/``False`` is not a meaningful count). Numeric strings such
as ``"5"`` or ``"5.0"`` are accepted; anything else raises ``ValueError``
naming the offending setting.
"""
if value is None:
if value is None or _is_missing(value):
if _is_missing(default):
raise ValueError(f"Missing integer for {name}")
return default
if isinstance(value, bool):
return default
if _is_missing(default):
raise ValueError(f"Missing integer for {name}")
return default # TODO -> should this raise?
if isinstance(value, int):
return value
text = str(value).strip()
if not text:
if _is_missing(default):
raise ValueError(f"Missing integer for {name}")
return default
try:
return int(text, 10)
except ValueError:
try:
return int(float(text))
except ValueError as ex:
raise ValueError(f"Invalid integer for {name}: {value!r}") from ex
except ValueError:
raise ValueError(f"Invalid integer for {name}") from None


def coerce_int(value: Any, default: int | None = None, name: str = "value") -> int:
"""Coerce a config value (possibly an env string) to an int.

Empty/``None`` values fall back to ``default``. Bools are treated as unset
(a stray ``True``/``False`` is not a meaningful count). Numeric strings such
as ``"5"`` or ``"5.0"`` are accepted; anything else raises ``ValueError``
naming the offending setting.
"""
new_default = default if default is not None else _MISSING
return _handle_int(value, new_default, name)


def coerce_float(value: Any, default: float, name: str = "value") -> float:
@overload
def _handle_float(
value: Any, default: float | _MissingType, name: str = "value"
) -> float: ...
@overload
def _handle_float(
value: Any, default: None = None, name: str = "value"
) -> float | None: ...
def _handle_float(
value: Any, default: float | None | _MissingType = None, name: str = "value"
) -> float | None:
"""Coerce a config value (possibly an env string) to a float.

Empty/``None`` values fall back to ``default``. Bools are treated as unset.
Anything that is not parseable as a float raises ``ValueError`` naming the
offending setting.
"""
if value is None:
if value is None or _is_missing(value):
if _is_missing(default):
raise ValueError(f"Missing number for {name}")
return default
if isinstance(value, bool):
return default
if _is_missing(default):
raise ValueError(f"Missing number for {name}")
return default # TODO -> should this also raise?
if isinstance(value, (int, float)):
return float(value)
text = str(value).strip()
if not text:
if _is_missing(default):
raise ValueError(f"Missing number for {name}")
return default
try:
return float(text)
except ValueError as ex:
raise ValueError(f"Invalid number for {name}: {value!r}") from ex
except ValueError:
raise ValueError(f"Invalid number for {name}") from None


def coerce_float(
value: Any, default: float | None = None, name: str = "value"
) -> float:
"""Coerce a config value (possibly an env string) to a float.

Empty/``None`` values fall back to ``default``. Bools are treated as unset.
Anything that is not parseable as a float raises ``ValueError`` naming the
offending setting.
"""
new_default = default if default is not None else _MISSING
return _handle_float(value, new_default, name)


def _check_key_error(value: Any, default: Any, key: str) -> None:
if _is_missing(value) and _is_missing(default):
raise KeyError(key)


@overload
def _handle_str(
value: Any, default: None = None, name: str = "value"
) -> str | None: ...
@overload
def _handle_str(
value: Any, default: str | _MissingType = _MISSING, name: str = "value"
) -> str: ...
def _handle_str(
value: Any, default: str | None | _MissingType = None, name: str = "value"
) -> str | None:
if value is None or _is_missing(value):
if _is_missing(default):
raise ValueError(f"Missing string for {name}")
return default
if isinstance(value, str):
return value
return str(value)


def _handle_str_list(
value: Any, default: list[str] | None = None, name: str = "value"
) -> list[str]:
if not isinstance(value, list):
raise ValueError(f"{name} must be a list.")
values = [_handle_str(val) for val in value]
return [val for val in values if val is not None]


@overload
def _get_bool(mapping: Mapping, key: str, default: bool | _MissingType) -> bool: ...
@overload
def _get_bool(mapping: Mapping, key: str, default: None) -> bool | None: ...
def _get_bool(
mapping: Mapping, key: str, default: bool | None | _MissingType = _MISSING
) -> bool | None:
val = _get(mapping, key, default=_MISSING)
_check_key_error(val, default, key)
return _handle_bool(val, default, key)


@overload
def _get_int(mapping: Mapping, key: str, default: int | _MissingType) -> int: ...
@overload
def _get_int(mapping: Mapping, key: str, default: None) -> int | None: ...
def _get_int(
mapping: Mapping, key: str, default: int | None | _MissingType = _MISSING
) -> int | None:
val = _get(mapping, key, default=_MISSING)
_check_key_error(val, default, key)
return _handle_int(val, default, key)


@overload
def _get_float(mapping: Mapping, *key: str, default: float | _MissingType) -> float: ...
@overload
def _get_float(mapping: Mapping, *key: str, default: None) -> float | None: ...
def _get_float(
mapping: Mapping, *key: str, default: float | None | _MissingType = _MISSING
) -> float | None:
if not key:
raise ValueError("At least one key must be provided.")
val = _get(mapping, *key, default=_MISSING)
_check_key_error(val, default, key[0])
return _handle_float(val, default, key[0])


@overload
def _get_str(mapping: Mapping, *key: str, default: str | _MissingType) -> str: ...
@overload
def _get_str(mapping: Mapping, *key: str, default: None) -> str | None: ...
def _get_str(
mapping: Mapping, *key: str, default: str | None | _MissingType = _MISSING
) -> str | None:
if not key:
raise ValueError("At least one key must be provided.")
val = _get(mapping, *key, default=_MISSING)
_check_key_error(val, default, key[0])
return _handle_str(val, default, key[0])
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Copyright (c) Microsoft Corporation. All rights reserved.
# Licensed under the MIT License.

from abc import ABC, abstractmethod
from typing import (
Mapping,
Protocol,
runtime_checkable,
)
from typing_extensions import Self


@runtime_checkable
class _ConfigurationProtocol(Protocol):

@classmethod
def from_mapping(cls, _mapping: Mapping) -> Self: ...


class _DictConfigurableProtocol(_ConfigurationProtocol, Protocol):

def __init__(self, data: dict, *args, **kwargs) -> None: ...

@classmethod
def from_mapping(cls, mapping: Mapping) -> Self: ...


class _DictConfigurable(ABC):

@abstractmethod
def __init__(self, data: dict, *args, **kwargs) -> None: ...

@classmethod
def from_mapping(cls, mapping: Mapping) -> Self:
return cls(dict(mapping))
Loading
Loading