Coverage for src/configuraptor/helpers.py: 100%

1"""

2 Contains stand-alone helper functions.

3"""

4 import dataclasses as dc

5 import math

6 import os

7 import types

8 import typing

9 from collections import ChainMap

10

11 import black.files

12 from typeguard import TypeCheckError

13 from typeguard import check_type as _check_type

14

15 from .abs import T_typelike

16

17

18 def camel_to_snake(s: str) -> str:

19 """

20 Convert CamelCase to snake_case.

21

22 Source:

23 https://stackoverflow.com/questions/1175208/elegant-python-function-to-convert-camelcase-to-snake-case

24 """

25 return "".join([f"_{c.lower()}" if c.isupper() else c for c in s]).lstrip("_")

26

27

28 def find_pyproject_toml() -> typing.Optional[str]:

29 """

30 Find the project's config toml, looks up until it finds the project root (black's logic).

31 """

32 return black.files.find_pyproject_toml((os.getcwd(),))

33

34

35 Type = typing.Type[typing.Any]

36

37

38 def _all_annotations(cls: Type) -> ChainMap[str, Type]:

39 """

40 Returns a dictionary-like ChainMap that includes annotations for all \

41 attributes defined in cls or inherited from superclasses.

42 """

43 return ChainMap(*(c.__annotations__ for c in getattr(cls, "__mro__", []) if "__annotations__" in c.__dict__))

44

45

46 def all_annotations(cls: Type, _except: typing.Iterable[str] = None) -> dict[str, type[object]]:

47 """

48 Wrapper around `_all_annotations` that filters away any keys in _except.

49

50 It also flattens the ChainMap to a regular dict.

51 """

52 if _except is None:

53 _except = set()

54

55 _all = _all_annotations(cls)

56 return {k: v for k, v in _all.items() if k not in _except}

57

58

59 def check_type(value: typing.Any, expected_type: T_typelike) -> bool:

60 """

61 Given a variable, check if it matches 'expected_type' (which can be a Union, parameterized generic etc.).

62

63 Based on typeguard but this returns a boolean instead of returning the value or throwing a TypeCheckError

64 """

65 try:

66 _check_type(value, expected_type)

67 return True

68 except TypeCheckError:

69 return False

70

71

72 def is_builtin_type(_type: Type) -> bool:

73 """

74 Returns whether _type is one of the builtin types.

75 """

76 return _type.__module__ in ("__builtin__", "builtins")

77

78

79# def is_builtin_class_instance(obj: typing.Any) -> bool:

80# return is_builtin_type(obj.__class__)

81

82

83 def is_from_types_or_typing(_type: Type) -> bool:

84 """

85 Returns whether _type is one of the stlib typing/types types.

86

87 e.g. types.UnionType or typing.Union

88 """

89 return _type.__module__ in ("types", "typing")

90

91

92 def is_from_other_toml_supported_module(_type: Type) -> bool:

93 """

94 Besides builtins, toml also supports 'datetime' and 'math' types, \

95 so this returns whether _type is a type from these stdlib modules.

96 """

97 return _type.__module__ in ("datetime", "math")

98

99

100 def is_parameterized(_type: Type) -> bool:

101 """

102 Returns whether _type is a parameterized type.

103

104 Examples:

105 list[str] -> True

106 str -> False

107 """

108 return typing.get_origin(_type) is not None

109

110

111 def is_custom_class(_type: Type) -> bool:

112 """

113 Tries to guess if _type is a builtin or a custom (user-defined) class.

114

115 Other logic in this module depends on knowing that.

116 """

117 return (

118 type(_type) is type

119 and not is_builtin_type(_type)

120 and not is_from_other_toml_supported_module(_type)

121 and not is_from_types_or_typing(_type)

122 )

123

124

125 def instance_of_custom_class(var: typing.Any) -> bool:

126 """

127 Calls `is_custom_class` on an instance of a (possibly custom) class.

128 """

129 return is_custom_class(var.__class__)

130

131

132 def is_optional(_type: Type | typing.Any) -> bool:

133 """

134 Tries to guess if _type could be optional.

135

136 Examples:

137 None -> True

138 NoneType -> True

139 typing.Union[str, None] -> True

140 str | None -> True

141 list[str | None] -> False

142 list[str] -> False

143 """

144 if _type and (is_parameterized(_type) and typing.get_origin(_type) in (dict, list)) or (_type is math.nan):

145 # e.g. list[str]

146 # will crash issubclass to test it first here

147 return False

148

149 return (

150 _type is None

151 or types.NoneType in typing.get_args(_type) # union with Nonetype

152 or issubclass(types.NoneType, _type)

153 or issubclass(types.NoneType, type(_type)) # no type # Nonetype

154 )

155

156

157 def dataclass_field(cls: Type, key: str) -> typing.Optional[dc.Field[typing.Any]]:

158 """

159 Get Field info for a dataclass cls.

160 """

161 fields = getattr(cls, "__dataclass_fields__", {})

162 return fields.get(key)