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

90 statements  

« prev     ^ index     » next       coverage.py v7.5.1, created at 2024-05-08 16:34 +0200

1""" 

2Helpers that work independently of core. 

3""" 

4 

5import fnmatch 

6import io 

7import types 

8import typing 

9from collections import ChainMap 

10from typing import Any 

11 

12from .types import AnyDict 

13 

14T = typing.TypeVar("T") 

15 

16 

17def is_union(some_type: type | types.UnionType) -> bool: 

18 """ 

19 Check if a type is some type of Union. 

20 

21 Args: 

22 some_type: types.UnionType = type(int | str); typing.Union = typing.Union[int, str] 

23 

24 """ 

25 return typing.get_origin(some_type) in (types.UnionType, typing.Union) 

26 

27 

28def reversed_mro(cls: type) -> typing.Iterable[type]: 

29 """ 

30 Get the Method Resolution Order (mro) for a class, in reverse order to be used with ChainMap. 

31 """ 

32 return reversed(getattr(cls, "__mro__", [])) 

33 

34 

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

36 """ 

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

38 attributes defined in cls or inherited from superclasses. 

39 """ 

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

41 

42 return ChainMap(*(c.__annotations__ for c in reversed_mro(cls) if "__annotations__" in c.__dict__)) 

43 

44 

45def all_dict(cls: type) -> AnyDict: 

46 """ 

47 Get the internal data of a class and all it's parents. 

48 """ 

49 return dict(ChainMap(*(c.__dict__ for c in reversed_mro(cls)))) # type: ignore 

50 

51 

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

53 """ 

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

55 

56 It also flattens the ChainMap to a regular dict. 

57 """ 

58 if _except is None: 

59 _except = set() 

60 

61 _all = _all_annotations(cls) 

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

63 

64 

65def instanciate(cls: typing.Type[T] | T, with_args: bool = False) -> T: 

66 """ 

67 Create an instance of T (if it is a class). 

68 

69 If it already is an instance, return it. 

70 If it is a generic (list[int)) create an instance of the 'origin' (-> list()). 

71 

72 If with_args: spread the generic args into the class creation 

73 (needed for e.g. TypedField(str), but not for list[str]) 

74 """ 

75 if inner_cls := typing.get_origin(cls): 

76 if not with_args: 

77 return typing.cast(T, inner_cls()) 

78 

79 args = typing.get_args(cls) 

80 return typing.cast(T, inner_cls(*args)) 

81 

82 if isinstance(cls, type): 

83 return typing.cast(T, cls()) 

84 

85 return cls 

86 

87 

88def origin_is_subclass(obj: Any, _type: type) -> bool: 

89 """ 

90 Check if the origin of a generic is a subclass of _type. 

91 

92 Example: 

93 origin_is_subclass(list[str], list) -> True 

94 """ 

95 return bool( 

96 typing.get_origin(obj) 

97 and isinstance(typing.get_origin(obj), type) 

98 and issubclass(typing.get_origin(obj), _type) 

99 ) 

100 

101 

102def mktable( 

103 data: dict[Any, Any], header: typing.Optional[typing.Iterable[str] | range] = None, skip_first: bool = True 

104) -> str: 

105 """ 

106 Display a table for 'data'. 

107 

108 See Also: 

109 https://stackoverflow.com/questions/70937491/python-flexible-way-to-format-string-output-into-a-table-without-using-a-non-st 

110 """ 

111 # get max col width 

112 col_widths: list[int] = list(map(max, zip(*(map(lambda x: len(str(x)), (k, *v)) for k, v in data.items())))) 

113 

114 # default numeric header if missing 

115 if not header: 

116 header = range(1, len(col_widths) + 1) 

117 

118 header_widths = map(lambda x: len(str(x)), header) 

119 

120 # correct column width if headers are longer 

121 col_widths = [max(c, h) for c, h in zip(col_widths, header_widths)] 

122 

123 # create separator line 

124 line = f"+{'+'.join('-' * (w + 2) for w in col_widths)}+" 

125 

126 # create formating string 

127 fmt_str = "| %s |" % " | ".join(f"{ :<{i}} " for i in col_widths) 

128 

129 output = io.StringIO() 

130 # header 

131 print() 

132 print(line, file=output) 

133 print(fmt_str.format(*header), file=output) 

134 print(line, file=output) 

135 

136 # data 

137 for k, v in data.items(): 

138 values = list(v.values())[1:] if skip_first else v.values() 

139 print(fmt_str.format(k, *values), file=output) 

140 

141 # footer 

142 print(line, file=output) 

143 

144 return output.getvalue() 

145 

146 

147K = typing.TypeVar("K") 

148V = typing.TypeVar("V") 

149 

150 

151def looks_like(v: Any, _type: type[Any]) -> bool: 

152 """ 

153 Returns true if v or v's class is of type _type, including if it is a generic. 

154 

155 Examples: 

156 assert looks_like([], list) 

157 assert looks_like(list, list) 

158 assert looks_like(list[str], list) 

159 """ 

160 return isinstance(v, _type) or (isinstance(v, type) and issubclass(v, _type)) or origin_is_subclass(v, _type) 

161 

162 

163def filter_out(mut_dict: dict[K, V], _type: type[T]) -> dict[K, type[T]]: 

164 """ 

165 Split a dictionary into things matching _type and the rest. 

166 

167 Modifies mut_dict and returns everything of type _type. 

168 """ 

169 return {k: mut_dict.pop(k) for k, v in list(mut_dict.items()) if looks_like(v, _type)} 

170 

171 

172def unwrap_type(_type: type) -> type: 

173 """ 

174 Get the inner type of a generic. 

175 

176 Example: 

177 list[list[str]] -> str 

178 """ 

179 while args := typing.get_args(_type): 

180 _type = args[0] 

181 return _type 

182 

183 

184@typing.overload 

185def extract_type_optional(annotation: T) -> tuple[T, bool]: 

186 """ 

187 T -> T is not exactly right because you'll get the inner type, but mypy seems happy with this. 

188 """ 

189 

190 

191@typing.overload 

192def extract_type_optional(annotation: None) -> tuple[None, bool]: 

193 """ 

194 None leads to None, False. 

195 """ 

196 

197 

198def extract_type_optional(annotation: T | None) -> tuple[T | None, bool]: 

199 """ 

200 Given an annotation, extract the actual type and whether it is optional. 

201 """ 

202 if annotation is None: 

203 return None, False 

204 

205 if origin := typing.get_origin(annotation): 

206 args = typing.get_args(annotation) 

207 

208 if origin in (typing.Union, types.UnionType, typing.Optional) and args: 

209 # remove None: 

210 return next(_ for _ in args if _ and _ != types.NoneType and not isinstance(_, types.NoneType)), True 

211 

212 return annotation, False 

213 

214 

215def to_snake(camel: str) -> str: 

216 """ 

217 Convert CamelCase to snake_case. 

218 

219 See Also: 

220 https://stackoverflow.com/a/44969381 

221 """ 

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

223 

224 

225class DummyQuery: 

226 """ 

227 Placeholder to &= and |= actual query parts. 

228 """ 

229 

230 def __or__(self, other: T) -> T: 

231 """ 

232 For 'or': DummyQuery | Other == Other. 

233 """ 

234 return other 

235 

236 def __and__(self, other: T) -> T: 

237 """ 

238 For 'and': DummyQuery & Other == Other. 

239 """ 

240 return other 

241 

242 def __bool__(self) -> bool: 

243 """ 

244 A dummy query is falsey, since it can't actually be used! 

245 """ 

246 return False 

247 

248 

249def as_lambda(value: T) -> typing.Callable[..., T]: 

250 """ 

251 Wrap value in a callable. 

252 """ 

253 return lambda *_, **__: value 

254 

255 

256def match_strings(patterns: list[str] | str, string_list: list[str]) -> list[str]: 

257 """ 

258 Glob but on a list of strings. 

259 """ 

260 if isinstance(patterns, str): 

261 patterns = [patterns] 

262 

263 matches = [] 

264 for pattern in patterns: 

265 matches.extend([s for s in string_list if fnmatch.fnmatch(s, pattern)]) 

266 

267 return matches