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

1"""

2 Contains stand-alone helper functions.

3"""

4 import contextlib

5 import dataclasses as dc

6 import io

7 import math

8 import os

9 import types

10 import typing

11 from collections import ChainMap

12 from pathlib import Path

13

14 import black.files

15 from typeguard import TypeCheckError

16 from typeguard import check_type as _check_type

17

18 from .abs import T_typelike

19

20

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

22 """

23 Convert CamelCase to snake_case.

24

25 Source:

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

27 """

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

29

30

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

32 """

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

34 """

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

36

37

38 Type = typing.Type[typing.Any]

39

40

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

42 """

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

44 attributes defined in cls or inherited from superclasses.

45 """

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

47

48

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

50 """

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

52

53 It also flattens the ChainMap to a regular dict.

54 """

55 if _except is None:

56 _except = set()

57

58 _all = _all_annotations(cls)

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

60

61

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

63 """

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

65

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

67 """

68 try:

69 _check_type(value, expected_type)

70 return True

71 except TypeCheckError:

72 return False

73

74

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

76 """

77 Returns whether _type is one of the builtin types.

78 """

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

80

81

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

83# return is_builtin_type(obj.__class__)

84

85

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

87 """

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

89

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

91 """

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

93

94

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

96 """

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

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

99 """

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

101

102

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

104 """

105 Returns whether _type is a parameterized type.

106

107 Examples:

108 list[str] -> True

109 str -> False

110 """

111 return typing.get_origin(_type) is not None

112

113

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

115 """

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

117

118 Other logic in this module depends on knowing that.

119 """

120 return (

121 type(_type) is type

122 and not is_builtin_type(_type)

123 and not is_from_other_toml_supported_module(_type)

124 and not is_from_types_or_typing(_type)

125 )

126

127

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

129 """

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

131 """

132 return is_custom_class(var.__class__)

133

134

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

136 """

137 Tries to guess if _type could be optional.

138

139 Examples:

140 None -> True

141 NoneType -> True

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

143 str | None -> True

144 list[str | None] -> False

145 list[str] -> False

146 """

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

148 # e.g. list[str]

149 # will crash issubclass to test it first here

150 return False

151

152 return (

153 _type is None

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

155 or issubclass(types.NoneType, _type)

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

157 )

158

159

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

161 """

162 Get Field info for a dataclass cls.

163 """

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

165 return fields.get(key)

166

167

168@contextlib.contextmanager

169 def uncloseable(fd: typing.BinaryIO) -> typing.Generator[typing.BinaryIO, typing.Any, None]:

170 """

171 Context manager which turns the fd's close operation to no-op for the duration of the context.

172 """

173 close = fd.close

174 fd.close = lambda: None # type: ignore

175 yield fd

176 fd.close = close # type: ignore

177

178

179 def as_binaryio(file: str | Path | typing.BinaryIO | None, mode: typing.Literal["rb", "wb"] = "rb") -> typing.BinaryIO:

180 """

181 Convert a number of possible 'file' descriptions into a single BinaryIO interface.

182 """

183 if isinstance(file, str):

184 file = Path(file)

185 if isinstance(file, Path):

186 file = file.open(mode)

187 if file is None:

188 file = io.BytesIO()

189 if isinstance(file, io.BytesIO):

190 # so .read() works after .write():

191 file.seek(0)

192 # so the with-statement doesn't close the in-memory file:

193 file = uncloseable(file) # type: ignore

194

195 return file