"""
Framework agnostic utilities for generating CLI options from Typed Settings
options.
"""
from collections.abc import (
Mapping,
MutableMapping,
MutableSequence,
MutableSet,
Sequence,
)
from typing import Protocol, Union, get_args, get_origin
from ._compat import PY_310
if PY_310:
from types import UnionType
else:
from typing import Union as UnionType # type: ignore
from collections.abc import Collection
from typing import Any, Optional
from . import _core, converters, types
__all__ = [
"NO_DEFAULT",
"NoDefaultType",
"TypeArgsMaker",
"TypeHandler",
"TypeHandlerFunc",
"check_if_optional",
"get_default",
]
[docs]
class NoDefaultType:
"""
Sentinel class to indicate the lack of a default value for an option when ``None``
is ambiguous.
``NoDefaultType`` is a singleton. There is only ever one of it.
"""
_singleton = None
def __new__(cls) -> "NoDefaultType":
if NoDefaultType._singleton is None:
NoDefaultType._singleton = super().__new__(cls)
return NoDefaultType._singleton
def __repr__(self) -> str:
return "NO_DEFAULT"
NO_DEFAULT = NoDefaultType()
"""
Sentinel that indicates the lack of a default value for an option.
"""
class DefaultFactorySentinel:
def __repr__(self) -> str:
return "(dynamic)"
def __call__(self) -> object:
return None
Default = Union[Any, None, NoDefaultType]
NoneType = type(None)
StrDict = dict[str, Any]
[docs]
class TypeHandlerFunc(Protocol):
"""
**Protocol:** A function that returns keyword arguments for a CLI option
for a specific type.
"""
[docs]
def __call__(self, type: type, default: Default, is_optional: bool) -> StrDict:
"""
Return keyword arguments for creating an option for *type*.
Args:
type: The type to create the option for.
default: The default value for the option. May be ``None`` or
:data:`NO_DEFAULT`.
is_optional: Whether the original type was an
:class:`~typing.Optional`.
"""
...
[docs]
class TypeHandler(Protocol):
"""
**Protocol:** Callbacks for the :class:`TypeArgsMaker` that provide
framework specific arguments for various classes of CLI options.
.. versionadded:: 2.0.0
"""
[docs]
def get_scalar_handlers(self) -> dict[type, TypeHandlerFunc]:
"""
Return a dict that maps specialized handlers for certain types (e.g.,
enums or datetimes.
Such a handler can look like this:
.. code-block:: python
def handle_mytype(
type: type,
default: Default,
is_optional: bool,
) -> Dict[str, Any]:
kwargs = {
"type": MyCliType(...)
}
if default not in (None, NO_DEFAULT):
kwargs["default"] = default.stringify()
elif is_optional:
kwargs["default"] = None
return kwargs
Return:
A dict mapping types to the corresponding type handler function.
"""
...
[docs]
def handle_scalar(
self, type: Optional[type], default: Default, is_optional: bool
) -> StrDict:
"""
Handle all scalars for which :func:`get_scalar_handlers()` does not
provide a specific handler.
Args:
type: The type to create an option for. Can be none if the option
is untyped.
default: The default value for the option. My be ``None`` or
:data:`NO_DEFAULT`.
is_optional: Whether or not the option type was marked as option
or not.
Return:
A dictionary with keyword arguments for creating an option for the
given type.
"""
...
[docs]
def handle_tuple(
self,
type_args_maker: "TypeArgsMaker",
types: tuple[Any, ...],
default: Optional[tuple],
is_optional: bool,
) -> StrDict:
"""
Handle options for structured tuples (i.e., not list-like tuples).
Args:
type_args_maker: The :class:`TypeArgsMaker` that called this
function.
types: The types of all tuple items.
default: Either a tuple of default values or ``None``.
is_optional: Whether or not the option type was marked as option
or not.
Return:
A dictionary with keyword arguments for creating an option for the
tuple.
"""
...
[docs]
def handle_collection(
self,
type_args_maker: "TypeArgsMaker",
types: tuple[Any, ...],
default: Optional[list[Any]],
is_optional: bool,
) -> StrDict:
"""
Handle collections, add options to allow multiple values and to
collect them in a list/collection.
Args:
type_args_maker: The :class:`TypeArgsMaker` that called this
function.
types: The types of the list items.
default: Either a collection of default values or ``None``.
is_optional: Whether or not the option type was marked as option
or not.
Return:
A dictionary with keyword arguments for creating an option for the
list type.
"""
...
[docs]
def handle_mapping(
self,
type_args_maker: "TypeArgsMaker",
types: tuple[Any, ...],
default: Default,
is_optional: bool,
) -> StrDict:
"""
Handle dictionaries.
Args:
type_args_maker: The :class:`TypeArgsMaker` that called this
function.
types: The types of keys and values.
default: Either a mapping of default values, ``None`` or :data:`NO_DEFAULT`.
is_optional: Whether or not the option type was marked as option
or not.
Return:
A dictionary with keyword arguments for creating an option for the
tuple.
"""
...
[docs]
class TypeArgsMaker:
"""
This class derives type information (in the form of keyword arguments)
for CLI options from attributes of a settings class.
For example, it could return a dict ``{"type": int, "default": 3}`` for
an option ``val: int = 3``.
It is agnostic of the CLI framework being used. The specifics for each
framework are implemented in a :class:`TypeHandler` that is passed to this
class.
The TypeArgsMaker differentitates between scalar and collection types
(e.g., :samp:`int` vs. :samp:`list[int]`. It inspects each option (field)
of a settings class and calls the appropriate method of the
:class:`TypeHandler`:
- If a type is in the dict returned by
:meth:`TypeHandler.get_scalar_handlers()`, call the corresponding
handler.
- For other scalar types, call :meth:`TypeHandler.handle_scalar()`.
- For structured tuples, call :meth:`TypeHandler.handle_tuple()`.
- For collections (e.g., lists, sets, and list-like tuples), call
:meth:`TypeHandler.handle_collection()`.
- For mappings (e.g., dicts), call :meth:`TypeHandler.handle_mapping()`.
.. versionchanged:: 2.0.0
Complete refactoring and renamed from *TypeHandler* to *TypeArgsMaker*.
"""
def __init__(
self,
type_handler: TypeHandler,
) -> None:
self.type_handler = type_handler
self.list_types = (
list,
Sequence,
MutableSequence,
set,
frozenset,
MutableSet,
)
self.tuple_types = (tuple,)
self.mapping_types = (
dict,
Mapping,
MutableMapping,
)
[docs]
def get_kwargs(self, otype: Any, default: Default) -> StrDict:
"""
Analyse the option type and return keyword arguments for creating a
CLI option for it.
Args:
otype: The option's type. It can be None if the user uses an
untyped class.
default: The default value for the option. It can be anything, but the
values ``None`` (possible default for optional types) and
:data:`NO_DEFAULT` (no default set) should be handled explicitly.
Return:
A dictionary with keyword arguments for creating a CLI option in
for a given framework.
Raise:
TypeError: If the *otype* has an unsupported type (e.g., a union
type).
"""
origin = get_origin(otype)
args = get_args(otype)
otype, default, origin, args, is_optional = check_if_optional(
otype, default, origin, args
)
if otype is None:
return self._handle_scalar(otype, default, is_optional)
elif origin is None:
scalar_handlers = self.type_handler.get_scalar_handlers()
for target_type, get_kwargs in scalar_handlers.items():
if types.is_new_type(otype):
otype = otype.__supertype__
if issubclass(otype, target_type):
return get_kwargs(otype, default, is_optional)
return self._handle_scalar(otype, default, is_optional)
else:
if origin in self.list_types:
return self._handle_collection(otype, args, default, is_optional)
elif origin in self.tuple_types:
return self._handle_tuple(otype, args, default, is_optional)
elif origin in self.mapping_types:
return self._handle_mapping(otype, args, default, is_optional)
raise TypeError(f"Cannot create CLI option for: {otype}")
def _handle_scalar(
self,
type: Optional[type],
default: Default,
is_optional: bool,
) -> StrDict:
"""
Get kwargs for scalar types.
"""
return self.type_handler.handle_scalar(type, default, is_optional)
def _handle_tuple(
self,
type: type,
args: tuple[Any, ...],
default: Default,
is_optional: bool,
) -> StrDict:
"""
Get kwargs for tuples.
Call :meth:`_handle_collection()` for list like tuples.
"""
if len(args) == 2 and args[1] == ...:
# "Immutable list" variant of tuple
return self._handle_collection(type, args, default, is_optional)
# "struct" variant of tuple
default_val: Optional[tuple]
if isinstance(default, tuple):
if not len(default) == len(args):
raise TypeError(
f"Default value must be of len {len(args)}: {len(default)}"
)
kwargs = {"strict": True} if PY_310 else {}
default_val = tuple(
self.get_kwargs(a, d)["default"]
for a, d in zip(args, default, **kwargs)
)
else:
default_val = None
kwargs = self.type_handler.handle_tuple(self, args, default_val, is_optional)
return kwargs
def _handle_collection(
self,
type: type,
args: tuple[Any, ...],
default: Default,
is_optional: bool,
) -> StrDict:
"""
Get kwargs for collections (e.g., lists or list-like tuples) of the
same type.
"""
if isinstance(default, Collection):
# Call get_kwargs() to get proper default value formatting
default = [self.get_kwargs(args[0], d)["default"] for d in default]
else:
default = None
kwargs = self.type_handler.handle_collection(self, args, default, is_optional)
return kwargs
def _handle_mapping(
self,
type: type,
args: tuple[Any, ...],
default: Default,
is_optional: bool,
) -> StrDict:
"""
Get kwargs for mapping types (e.g, dicts).
"""
kwargs = self.type_handler.handle_mapping(self, args, default, is_optional)
return kwargs
[docs]
def get_default(
option_info: types.OptionInfo,
settings: types.MergedSettings,
converter: converters.Converter,
) -> Any:
"""
Return the proper default value for an attribute.
If possible, the default is taken from loaded settings. Else, use the
field's default value.
Args:
option_info: The option description for the attribute.
settings: A (nested) dict with the loaded settings.
converter: The converter to be used.
Return:
The default value to be used for the option. This can also be ``None``
or a "nothing" value (e.g., :data:`attrs.NOTHING`).
"""
default: Any
if option_info.path in settings:
value, meta = settings[option_info.path]
try:
default = _core.convert_value(option_info, value, meta, converter)
except Exception as e:
raise ValueError(
f"Invalid default {value!r} for option {option_info.path!r} with "
f"type {option_info.cls}: {e!r}"
) from e
elif option_info.default_is_factory:
# Use a fake factory function to indicate a dynamic default value, that is only
# computed when the CLI is invoked (and not when the options are generated).
default = DefaultFactorySentinel()
elif option_info.has_no_default:
default = NO_DEFAULT
else:
default = option_info.default
return default
[docs]
def check_if_optional(
otype: Optional[type],
default: Default,
origin: Any,
args: tuple[Any, ...],
) -> tuple[Optional[type], Any, Any, tuple[Any, ...], bool]:
"""
Check if *otype* is an optional (``Optional[...]`` or ``Union[None, ...]``) and
return the actual type for it and a flag indicating the optionality.
If it is optional and a default value is not set, use ``None`` as new default.
Args:
otype: The Python type of the option.
default: The option's default value.
origin: The generic origin as returned by :func:`typing.get_origin()`.
args: The generic args as returned by :func:`typing.get_args()`.
Return:
A tuple *(otype, default, origin, args, is_optional)*:
*otype:*
is either the original or the unwrapped optional type.
*default:*
is the possibly updated default value.
*origin:*
is the possibly updated *origin* for the unwrapped *otype*.
*args:*
are the possibly updated *args* for the unwrapped *otype*.
*is_optional:*
indicates whether *otype* was an optional or not.
"""
is_optional = origin in (Union, UnionType) and len(args) == 2 and NoneType in args
if is_optional:
if default is NO_DEFAULT:
default = None
# "idx" is the index of the not-NoneType:
idx = (args.index(NoneType) + 1) % 2
otype = args[idx]
origin = get_origin(otype)
args = get_args(otype)
return otype, default, origin, args, is_optional