#!/usr/bin/env python3
#
# utils.py
"""
Utility functions.
"""
#
# Copyright © 2020 Dominic Davis-Foster <dominic@davis-foster.co.uk>
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
# EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
# MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
# OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE
# OR OTHER DEALINGS IN THE SOFTWARE.
#
# gat_args based on CPython.
# Licensed under the Python Software Foundation License Version 2.
# Copyright © 2001-2020 Python Software Foundation. All rights reserved.
# Copyright © 2000 BeOpen.com . All rights reserved.
# Copyright © 1995-2000 Corporation for National Research Initiatives . All rights reserved.
# Copyright © 1991-1995 Stichting Mathematisch Centrum . All rights reserved.
#
# stdlib
import copy
import sys
import typing
from enum import EnumMeta
from types import MappingProxyType
from typing import TYPE_CHECKING, Any, Callable, Dict, List, Type, TypeVar, Union
# 3rd party
from typing_extensions import Literal
from typing_inspect import is_literal_type # type: ignore[import]
if TYPE_CHECKING:
# this package
from configconfig.metaclass import ConfigVarMeta
if sys.version_info >= (3, 8): # pragma: no cover (<py38)
# stdlib
from typing import get_args, get_origin
else: # pragma: no cover (>=py38)
# stdlib
import collections.abc
# 3rd party
from typing_inspect import get_origin
def get_args(tp): # noqa: MAN001,MAN002
"""Get type arguments with all substitutions performed.
For unions, basic simplifications used by Union constructor are performed.
Examples::
get_args(Dict[str, int]) == (str, int)
get_args(int) == ()
get_args(Union[int, Union[T, int], str][int]) == (int, str)
get_args(Union[int, Tuple[T, int]][str]) == (int, Tuple[str, int])
get_args(Callable[[], T][int]) == ([], int)
"""
if hasattr(tp, "__args__"):
res = tp.__args__
if get_origin(tp) is collections.abc.Callable and res[0] is not Ellipsis:
res = (list(res[:-1]), res[-1])
return res
return ()
__all__ = [
"get_literal_values",
"basic_schema",
"optional_getter",
"get_yaml_type",
"make_schema",
"check_union",
"get_json_type",
"RawConfigVarsType",
]
#: A literal ``TAB`` (``\t``) character for use in f-strings.
tab = '\t'
# TODO: latest mypy no longer happy
# if sys.version_info < (3, 7):
UnionType = Any
GenericAliasType = Any
# else:
# UnionType = type(Union)
# GenericAliasType = type(List)
[docs]def optional_getter(raw_config_vars: Dict[str, Any], cls: "ConfigVarMeta", required: bool) -> Any:
"""
Returns either the configuration value, the default,
or raises an error if the value is required but wasn't supplied.
:param raw_config_vars:
:param cls:
:param required:
""" # noqa: D400
if required:
try:
return raw_config_vars[cls.__name__]
except KeyError:
raise ValueError(f"A value for '{cls.__name__}' is required.") from None
else:
if cls.__name__ in raw_config_vars:
return raw_config_vars[cls.__name__]
else:
if isinstance(cls.default, Callable): # type: ignore[arg-type]
return copy.deepcopy(cls.default(raw_config_vars))
else:
return copy.deepcopy(cls.default)
#: Mapping of Python types to their YAML equivalents.
yaml_type_lookup = {
str: "String",
int: "Integer",
float: "Float",
bool: "Boolean",
list: "Sequence",
dict: "Mapping",
Any: "anything",
}
def check_type(left: Type, *right: Type) -> bool:
return left in right or get_origin(left) in right
[docs]def get_yaml_type(type_: Type) -> str:
r"""
Get the YAML type that corresponds to the given Python type.
:param type\_:
"""
if type_ in yaml_type_lookup:
return yaml_type_lookup[type_]
elif get_origin(type_) is Union:
dtype = " or ".join(yaml_type_lookup[x] for x in type_.__args__)
return dtype
elif check_type(type_, list, List):
args = get_args(type_)
inner_types: typing.Iterable[str]
if args:
inner_types = (get_yaml_type(x) for x in args if not isinstance(x, TypeVar))
else:
inner_types = ()
dtype = " or ".join(inner_types)
if dtype:
return f"Sequence of {dtype}"
else:
return "Sequence"
elif check_type(type_, dict, Dict):
args = get_args(type_)
if not args or any(isinstance(t, TypeVar) for t in args):
return "Mapping"
else:
dtype = " to ".join(get_yaml_type(x) for x in get_args(type_))
return f"Mapping of {dtype}"
elif is_literal_type(type_):
types = [y for y in get_literal_values(type_)]
return " or ".join(repr(x) for x in types)
elif isinstance(type_, EnumMeta):
return " or ".join([repr(x._value_) for x in type_])
else:
return str(type_)
basic_schema = MappingProxyType({
"$schema": "http://json-schema.org/draft-07/schema",
"type": "object",
})
[docs]def make_schema(*configuration_variables: "ConfigVarMeta") -> Dict[str, Any]:
"""
Create a ``JSON`` schema from a list of :class:`~configconfig.configvar.ConfigVar` classes.
:param configuration_variables:
:return: Dictionary representation of the ``JSON`` schema.
"""
schema = {
**basic_schema,
"properties": {},
"required": [],
"additionalProperties": False,
}
for var in configuration_variables:
schema = var.get_schema_entry(schema)
return schema
[docs]def check_union(obj: Any, dtype: Union["GenericAliasType", "UnionType"]) -> bool:
r"""
Check if the type of ``obj`` is one of the types in a :class:`typing.Union`, :class:`typing.List` etc.
:param obj:
:param dtype:
:type dtype: :py:obj:`~typing.Union`\, :class:`~typing.List`\, etc.
"""
args = dtype.__args__
if float in args and int not in args:
args = (*dtype.__args__, int)
return isinstance(obj, args)
[docs]def get_json_type(type_: Type) -> Dict[str, Union[str, List, Dict]]:
r"""
Get the type for the JSON schema that corresponds to the given Python type.
:param type\_:
"""
if type_ in json_type_lookup:
return {"type": json_type_lookup[type_]}
elif get_origin(type_) is Union:
return {"type": [get_json_type(t)["type"] for t in type_.__args__]}
elif check_type(type_, list, List):
args = get_args(type_)
if args:
items = get_json_type(args[0])
if items is NotImplemented:
return {"type": "array"}
elif "type" in items:
return {"type": "array", "items": items}
elif "enum" in items:
return {"type": "array", "items": items}
else:
return {"type": "array"}
return {"type": "array"}
elif check_type(type_, dict, Dict):
return {"type": "object"}
elif check_type(type_, Literal) or is_literal_type(type_): # type: ignore[arg-type]
return {"enum": [x for x in get_literal_values(type_)]}
elif isinstance(type_, EnumMeta):
return {"enum": [x._value_ for x in type_]}
elif type_ is bool:
return {"type": ["boolean", "string"]}
else:
return NotImplemented
#: Mapping of Python types to their JSON equivalents.
json_type_lookup = {
str: "string",
int: "number",
float: "number",
dict: "object",
list: "array",
}
[docs]def get_literal_values(literal: Literal) -> typing.Tuple[Any]: # type: ignore[misc]
"""
Returns a tuple of permitted values for a :class:`typing.Literal`.
.. versionadded:: 0.3.0
:param literal:
"""
if sys.version_info < (3, 7):
return literal.__values__
else:
return literal.__args__
RawConfigVarsType = Dict[str, Any]