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

67 statements  

« prev     ^ index     » next       coverage.py v7.2.7, created at 2023-11-07 15:36 +0100

1""" 

2Contains stand-alone helper functions. 

3""" 

4import contextlib 

5import dataclasses as dc 

6import io 

7import math 

8import os 

9import types 

10import typing 

11from collections import ChainMap 

12from pathlib import Path 

13 

14import black.files 

15from typeguard import TypeCheckError 

16from typeguard import check_type as _check_type 

17 

18from .abs import T_typelike 

19 

20 

21def 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 

31def 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 

38Type = typing.Type[typing.Any] 

39 

40 

41def _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 

49def 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 

62def 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 

75def 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 

86def 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 

95def 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 

103def 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 

114def 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 

128def 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 

135def 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 

160def 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 

169def 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 

179def 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