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

1"""

2 Contains stand-alone helper functions.

3"""

4

5 import contextlib

6 import dataclasses as dc

7 import io

8 import math

9 import types

10 import typing

11 from collections import ChainMap

12 from pathlib import Path

13

14 from typeguard import TypeCheckError

15 from typeguard import check_type as _check_type

16

17 try:

18 import annotationlib

19 except ImportError: # pragma: no cover

20 annotationlib = None

21

22

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

24 """

25 Convert CamelCase to snake_case.

26

27 Source:

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

29 """

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

31

32

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

34# """

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

36# """

37# return black.files.find_pyproject_toml((os.getcwd(),))

38

39

40 def find_pyproject_toml(start_dir: typing.Optional[Path | str] = None) -> Path | None:

41 """

42 Search for pyproject.toml starting from the current working directory \

43 and moving upwards in the directory tree.

44

45 Args:

46 start_dir: Starting directory to begin the search.

47 If not provided, uses the current working directory.

48

49 Returns:

50 Path or None: Path object to the found pyproject.toml file, or None if not found.

51 """

52 start_dir = Path.cwd() if start_dir is None else Path(start_dir).resolve()

53

54 current_dir = start_dir

55

56 while str(current_dir) != str(current_dir.root):

57 pyproject_toml = current_dir / "pyproject.toml"

58 if pyproject_toml.is_file():

59 return pyproject_toml

60 current_dir = current_dir.parent

61

62 # If not found anywhere

63 return None

64

65

66 Type = typing.Type[typing.Any]

67

68

69 def _cls_annotations(c: type) -> dict[str, type]: # pragma: no cover

70 """

71 Functions to get the annotations of a class (excl inherited, use _all_annotations for that).

72

73 Uses `annotationlib` if available (since 3.14) and if so, resolves forward references immediately.

74 """

75 if annotationlib:

76 return typing.cast(

77 dict[str, type], annotationlib.get_annotations(c, format=annotationlib.Format.VALUE, eval_str=True)

78 )

79 else:

80 return getattr(c, "__annotations__", {})

81

82

83 def _all_annotations(cls: type) -> ChainMap[str, type]:

84 """

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

86 attributes defined in cls or inherited from superclasses.

87 """

88 # chainmap reverses the iterable, so reverse again beforehand to keep order normally:

89

90 return ChainMap(*(_cls_annotations(c) for c in getattr(cls, "__mro__", [])))

91

92

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

94 """

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

96

97 It also flattens the ChainMap to a regular dict.

98 """

99 if _except is None:

100 _except = set()

101

102 _all = _all_annotations(cls)

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

104

105

106 T = typing.TypeVar("T")

107

108

109 def check_type(value: typing.Any, expected_type: typing.Type[T]) -> typing.TypeGuard[T]:

110 """

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

112

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

114 """

115 try:

116 _check_type(value, expected_type)

117 return True

118 except TypeCheckError:

119 return False

120

121

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

123 """

124 Returns whether _type is one of the builtin types.

125 """

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

127

128

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

130# return is_builtin_type(obj.__class__)

131

132

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

134 """

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

136

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

138 """

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

140

141

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

143 """

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

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

146 """

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

148

149

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

151 """

152 Returns whether _type is a parameterized type.

153

154 Examples:

155 list[str] -> True

156 str -> False

157 """

158 return typing.get_origin(_type) is not None

159

160

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

162 """

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

164

165 Other logic in this module depends on knowing that.

166 """

167 return (

168 type(_type) is type

169 and not is_builtin_type(_type)

170 and not is_from_other_toml_supported_module(_type)

171 and not is_from_types_or_typing(_type)

172 )

173

174

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

176 """

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

178 """

179 return is_custom_class(var.__class__)

180

181

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

183 """

184 Tries to guess if _type could be optional.

185

186 Examples:

187 None -> True

188 NoneType -> True

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

190 str | None -> True

191 list[str | None] -> False

192 list[str] -> False

193 """

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

195 # e.g. list[str]

196 # will crash issubclass to test it first here

197 return False

198

199 try:

200 return (

201 _type is None

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

203 or issubclass(types.NoneType, _type)

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

205 )

206 except TypeError:

207 # probably some weird input that's not a type

208 return False

209

210

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

212 """

213 Get Field info for a dataclass cls.

214 """

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

216 return fields.get(key)

217

218

219@contextlib.contextmanager

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

221 """

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

223 """

224 close = fd.close

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

226 yield fd

227 fd.close = close # type: ignore

228

229

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

231 """

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

233 """

234 if isinstance(file, str):

235 file = Path(file)

236 if isinstance(file, Path):

237 file = file.open(mode)

238 if file is None:

239 file = io.BytesIO()

240 if isinstance(file, io.BytesIO):

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

242 file.seek(0)

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

244 file = uncloseable(file) # type: ignore

245

246 return file