"""
Helpers and wrappers for settings class backends.
Supported backends are:
- :mod:`dataclasses`
- `attrs <https://attrs.org>`_ (optional dependency)
- `pydantic <https://docs.pydantic.dev>`_ (optional dependency)
"""
import dataclasses
import functools
import inspect
from itertools import groupby
from typing import (
Any,
Callable,
Optional,
Protocol,
Union,
cast,
overload,
)
from . import constants, types
[docs]
class ClsHandler(Protocol):
"""
**Protocol** that class handlers must implement.
.. versionadded:: 23.1.0
"""
[docs]
@staticmethod
def check(cls: type) -> bool:
"""
Return a bool indicating whether *cls* belongs to the handler's class lib.
"""
[docs]
@staticmethod
def iter_fields(cls: type) -> types.OptionList:
"""
Recursively iterate the the fields of *cls* and return the
:class:`.types.OptionInfo` instances for them.
Fields of nested classes are only converted to :class:`.types.OptionInfo` if
they were created by the same class lib. For example, if the parent class is
an attrs class, the attributes of nested dataclasses are not added to the list
of options.
"""
[docs]
@staticmethod
def fields_to_parent_classes(cls: type) -> dict[str, type]:
"""
Map a class' attribute names to a "parent class".
This parent class is used to create CLI option groups. Thus, if a field's
type is another (nested) settings class, that class should be used. Else,
the class itself should be used.
"""
[docs]
@staticmethod
def asdict(inst: Any) -> types.SettingsDict:
"""
Return the instances attributes as dict, recurse into nested classes of the
same kind.
"""
[docs]
@staticmethod
def resolve_types(
cls: type[types.T],
globalns: Optional[dict[str, Any]] = None,
localns: Optional[dict[str, Any]] = None,
include_extras: bool = True,
) -> type[types.T]:
"""
Resolve any strings and forward annotations in type annotations.
With no arguments, names will be looked up in the module in which the class was
created. If this is not what you want, e.g. if the name only exists inside
a method, you may pass *globalns* or *localns* to specify other dictionaries in
which to look up these names. See the docs of :func:`typing.get_type_hints()`
for more details.
Args:
cls: Class to resolve.
globalns: Dictionary containing global variables.
localns: Dictionary containing local variables.
include_extras: Resolve more accurately, if possible.
Pass ``include_extras`` to ``typing.get_hints``, if supported by the
typing module. On supported Python versions (3.9+), this resolves the
types more accurately.
Return: *cls* so you can use this function also as a class decorator. Please
note that you have to apply it **after** :func:`attrs.define` or
:func:`dataclasses.dataclass`. That means the decorator has to come in the
line **before** :func:`attrs.define` or :func:`dataclasses.dataclass`.
"""
[docs]
class Attrs:
"""
Handler for "attrs" classes.
"""
@staticmethod
def check(cls: type) -> bool:
try:
import attrs
return attrs.has(cls)
except ImportError:
return False
@staticmethod
def iter_fields(cls: type) -> types.OptionList:
import attrs
result: list[types.OptionInfo] = []
def iter_attribs(r_cls: type, prefix: str) -> None:
# Resolve types, optionally using the globals we stored in the class in
# ".cls_attrs.combine()":
r_cls = attrs.resolve_types(
r_cls, globalns=getattr(r_cls, "__globals__", None)
)
for field in attrs.fields(r_cls): # type: ignore[misc]
if field.init is False:
continue
if field.type is not None and attrs.has(field.type):
iter_attribs(field.type, f"{prefix}{field.name}.")
else:
is_nothing = field.default is attrs.NOTHING
is_factory = isinstance(field.default, cast(type, attrs.Factory))
metadata = _get_metadata(field.metadata.get(constants.METADATA_KEY))
oinfo = types.OptionInfo(
parent_cls=r_cls,
path=f"{prefix}{field.name}",
cls=field.type,
is_secret=(
isinstance(field.repr, types.SecretRepr)
or (
isinstance(field.type, type)
and issubclass(field.type, types.SECRETS_TYPES)
)
),
default=field.default,
has_no_default=is_nothing,
default_is_factory=is_factory,
converter=field.converter,
metadata=metadata,
)
result.append(oinfo)
iter_attribs(cls, "")
return tuple(result)
@staticmethod
def fields_to_parent_classes(cls: type) -> dict[str, type]:
import attrs
return {
field.name: (field.type if attrs.has(field.type) else cls)
for field in attrs.fields(cls) # type: ignore[misc]
}
@staticmethod
def asdict(inst: Any) -> types.SettingsDict:
import attrs
return attrs.asdict(inst)
@staticmethod
def resolve_types(
cls: type[types.T],
globalns: Optional[dict[str, Any]] = None,
localns: Optional[dict[str, Any]] = None,
include_extras: bool = True,
) -> type[types.T]:
import attrs
return attrs.resolve_types( # type: ignore[type-var]
cls, globalns=globalns, localns=localns, include_extras=include_extras
)
[docs]
class Dataclasses:
"""
Handler for :mod:`dataclasses` classes.
"""
@staticmethod
def check(cls: type) -> bool:
return dataclasses.is_dataclass(cls)
@classmethod
def iter_fields(self, cls: type) -> types.OptionList:
result: list[types.OptionInfo] = []
def iter_attribs(r_cls: type, prefix: str) -> None:
r_cls = self.resolve_types(r_cls) # type: ignore[type-var]
for field in dataclasses.fields(r_cls):
if field.init is False:
continue
if field.type is not None and dataclasses.is_dataclass(field.type):
iter_attribs(field.type, f"{prefix}{field.name}.") # type: ignore[arg-type]
else:
is_nothing = field.default is dataclasses.MISSING
is_factory = (
is_nothing and field.default_factory is not dataclasses.MISSING
)
metadata = _get_metadata(field.metadata.get(constants.METADATA_KEY))
oinfo = types.OptionInfo(
parent_cls=r_cls,
path=f"{prefix}{field.name}",
cls=field.type, # type: ignore[arg-type]
is_secret=(
isinstance(field.repr, types.SecretRepr)
or (
isinstance(field.type, type)
and issubclass(field.type, types.SECRETS_TYPES)
)
),
default=field.default,
has_no_default=is_nothing and not is_factory,
default_is_factory=is_factory,
converter=None,
metadata=metadata,
)
result.append(oinfo)
iter_attribs(cls, "")
return tuple(result)
@staticmethod
def fields_to_parent_classes(cls: type) -> dict[str, type]:
return {
field.name: (field.type if dataclasses.is_dataclass(field.type) else cls) # type: ignore[misc]
for field in dataclasses.fields(cls)
}
@staticmethod
def asdict(inst: Any) -> types.SettingsDict:
return dataclasses.asdict(inst)
@staticmethod
def resolve_types(
cls: type[types.T],
globalns: Optional[dict[str, Any]] = None,
localns: Optional[dict[str, Any]] = None,
include_extras: bool = True,
) -> type[types.T]:
# Since calling get_type_hints is expensive we cache whether we've
# done it already.
if getattr(cls, "__dataclass_types_resolved__", None) != cls:
import typing
kwargs: dict[str, Any] = {
"globalns": globalns,
"localns": localns,
"include_extras": include_extras,
}
hints = typing.get_type_hints(cls, **kwargs)
for field in dataclasses.fields(cls): # type: ignore[arg-type]
if field.name in hints: # pragma: no cover
# Since fields have been frozen we must work around it.
object.__setattr__(field, "type", hints[field.name])
# We store the class we resolved so that subclasses know they haven't
# been resolved.
cls.__dataclass_types_resolved__ = cls # type: ignore[attr-defined]
# Return the class so you can use it as a decorator too.
return cls
[docs]
class Pydantic:
"""
Handler for "Pydantic" classes.
"""
@staticmethod
def check(cls: type) -> bool:
try:
import pydantic
return inspect.isclass(cls) and issubclass(cls, pydantic.BaseModel)
except ImportError:
return False
@staticmethod
def iter_fields(cls: type) -> types.OptionList:
import pydantic
result: list[types.OptionInfo] = []
def iter_attribs(r_cls: type, prefix: str) -> None:
for name, field in r_cls.model_fields.items(): # type: ignore[attr-defined]
if (
field.annotation is not None
and isinstance(field.annotation, type)
and issubclass(field.annotation, pydantic.BaseModel)
):
iter_attribs(field.annotation, f"{prefix}{name}.")
else:
json_schema_extra = field.json_schema_extra or {}
metadata_or_none = json_schema_extra.get(constants.METADATA_KEY, {})
metadata = _get_metadata(metadata_or_none, field.description)
oinfo = types.OptionInfo(
parent_cls=r_cls,
path=f"{prefix}{name}",
cls=field.annotation, # type: ignore[arg-type]
is_secret=(
isinstance(field.annotation, type)
and (
issubclass(
field.annotation,
(
pydantic.SecretBytes,
pydantic.SecretStr,
*types.SECRETS_TYPES,
),
)
)
),
default=field.default,
has_no_default=field.is_required(),
default_is_factory=False,
converter=None,
metadata=metadata,
)
result.append(oinfo)
iter_attribs(cls, "")
return tuple(result)
@staticmethod
def fields_to_parent_classes(cls: type) -> dict[str, type]:
import pydantic
return {
name: (
field.annotation
if isinstance(field.annotation, type)
and issubclass(field.annotation, pydantic.BaseModel)
else cls
)
for name, field in cls.model_fields.items() # type: ignore[attr-defined]
}
@staticmethod
def asdict(inst: Any) -> types.SettingsDict:
return inst.model_dump()
@staticmethod
def resolve_types(
cls: type[types.T],
globalns: Optional[dict[str, Any]] = None,
localns: Optional[dict[str, Any]] = None,
include_extras: bool = True,
) -> type[types.T]:
# Pydantic classes automatically resolve themselves.
return cls
CLASS_HANDLERS: list[type[ClsHandler]] = [
Attrs,
Dataclasses,
Pydantic,
]
[docs]
def handler_exists(cls: type) -> bool:
"""
Check if a class handler for *cls* exist.
Args:
cls: The settings class to check the existence of a handler for.
Return:
``True`` if there is a handler, otherwise ``False``.
"""
for cls_handler in CLASS_HANDLERS:
if cls_handler.check(cls):
return True
return False
[docs]
def find_handler(cls: type) -> type[ClsHandler]:
"""
Return the proper class handler for *cls*.
Args:
cls: The settings class to find a handler for.
Return:
A :class:`ClsHandler` that works with *cls*.
Raise:
TypeError: If no class handler can be found for *cls*.
"""
for cls_handler in CLASS_HANDLERS:
if cls_handler.check(cls):
return cls_handler
raise TypeError(f"Cannot handle type: {cls}")
[docs]
def deep_options(cls: type) -> types.OptionList:
"""
Recursively iterates *cls* and nested attrs classes and returns a flat
list of *(path, Attribute, type)* tuples.
Args:
cls: The class whose attributes will be listed.
Returns:
The flat list of attributes of *cls* and possibly nested attrs classes.
*path* is a dot (``.``) separted path to the attribute, e.g.
``"parent_attr.child_attr.grand_child_attr``.
Raises:
NameError: if the type annotations can not be resolved. This is, e.g., the
case when recursive classes are being used.
"""
cls_handler = find_handler(cls)
return cls_handler.iter_fields(cls)
[docs]
def group_options(
cls: type, options: types.OptionList
) -> list[tuple[type, types.OptionList]]:
"""
Group (nested) options by parent class.
If *cls* does not contain nested settings classes, return a single group for *cls*
with all its options.
If *cls* only contains nested subclasses, return one group per class containing all
of that classes (posibly nested) options.
If *cls* has multiple attributtes with the same nested settings class, create one
group per attribute.
If *cls* contains a mix of scalar options and nested options, return a mix of both.
Scalar options schould be grouped (on top or bottom) or else multiple groups for the
main settings class will be created.
See the tests for details.
Args:
cls: The settings class
options: The list of all options of the settings class.
Return:
A list of tuples matching a grouper class to all settings within that group.
"""
cls_handler = find_handler(cls)
fields_to_parents = cls_handler.fields_to_parent_classes(cls)
def keyfn(o: types.OptionInfo) -> tuple[str, type]:
"""
Group by prefix and also return the corresponding group class.
"""
basename, *remainder = o.path.split(".")
prefix = basename if remainder else ""
return prefix, fields_to_parents[basename]
grouper = groupby(options, key=keyfn)
grouped_options = [(g_cls[1], tuple(g_opts)) for g_cls, g_opts in grouper]
return grouped_options
@overload
def resolve_types(
cls: None = None,
*,
globalns: Optional[dict[str, Any]] = None,
localns: Optional[dict[str, Any]] = None,
include_extras: bool = True,
) -> Callable[[type[types.T]], type[types.T]]: ...
@overload
def resolve_types(
cls: type[types.T],
*,
globalns: Optional[dict[str, Any]] = None,
localns: Optional[dict[str, Any]] = None,
include_extras: bool = True,
) -> type[types.T]: ...
[docs]
def resolve_types(
cls: Optional[type[types.T]] = None,
*,
globalns: Optional[dict[str, Any]] = None,
localns: Optional[dict[str, Any]] = None,
include_extras: bool = True,
) -> Union[type[types.T], Callable[[type[types.T]], type[types.T]]]:
"""
Resolve any strings and forward annotations in type annotations.
This is only required if you need concrete types in fields' *type* field. In other
words, you don't need to resolve your types if you only use them for static type
checking.
With no arguments, names will be looked up in the module in which the class was
created. If this is not what you want, e.g. if the name only exists inside a method,
you may pass *globalns* or *localns* to specify other dictionaries in which to look
up these names. See the docs of `typing.get_type_hints` for more details.
Args:
cls: Class to resolve.
globalns: Dictionary containing global variables.
localns: Dictionary containing local variables.
include_extras: Resolve more accurately, if possible.
Pass ``include_extras`` to ``typing.get_hints``, if supported by the typing
module. On supported Python versions (3.9+), this resolves the types more
accurately.
Return:
*cls* so you can use this function also as a class decorator. Please note that
you have to apply it **after** `attrs.define`. That means the decorator has to
come in the line **before** `attrs.define`.
Examples:
::
>>> import typed_settings as ts
>>>
>>> @ts.settings
... class A:
... opt: "int"
...
>>> A = ts.resolve_types(A)
>>>
>>> @ts.resolve_types
... @ts.settings
... class B:
... opt: "int"
...
>>> @ts.resolve_types(globalns=globals(), localns=locals())
... @ts.settings
... class C:
... opt: "int"
...
.. versionadded:: 24.4.0
"""
if cls is None:
return functools.partial( # type: ignore[return-value]
resolve_types,
globalns=globalns,
localns=localns,
include_extras=include_extras,
)
cls_handler = find_handler(cls)
return cls_handler.resolve_types(
cls, globalns=globalns, localns=localns, include_extras=include_extras
)
def _get_metadata(metadata_or_none: Any, default_help: Optional[str] = None) -> dict:
metadata = metadata_or_none if isinstance(metadata_or_none, dict) else {}
cli_defaults: dict[str, Any] = {}
if default_help:
cli_defaults["help"] = default_help
if "help" in metadata:
cli_defaults["help"] = metadata["help"]
click_config = {
**cli_defaults,
**metadata.get(constants.CLICK_METADATA_KEY, {}),
}
argparse_config = {
**cli_defaults,
**metadata.get(constants.ARGPARSE_METADATA_KEY, {}),
}
if click_config:
metadata[constants.CLICK_METADATA_KEY] = click_config
if argparse_config:
metadata[constants.ARGPARSE_METADATA_KEY] = argparse_config
return metadata