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

93 statements  

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

1""" 

2Helpers that work independently of core. 

3""" 

4 

5import datetime as dt 

6import fnmatch 

7import io 

8import types 

9import typing 

10from collections import ChainMap 

11from typing import Any 

12 

13from .types import AnyDict 

14 

15T = typing.TypeVar("T") 

16 

17 

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

19 """ 

20 Check if a type is some type of Union. 

21 

22 Args: 

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

24 

25 """ 

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

27 

28 

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

30 """ 

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

32 """ 

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

34 

35 

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

37 """ 

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

39 attributes defined in cls or inherited from superclasses. 

40 """ 

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

42 

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

44 

45 

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

47 """ 

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

49 """ 

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

51 

52 

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

54 """ 

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

56 

57 It also flattens the ChainMap to a regular dict. 

58 """ 

59 if _except is None: 

60 _except = set() 

61 

62 _all = _all_annotations(cls) 

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

64 

65 

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

67 """ 

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

69 

70 If it already is an instance, return it. 

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

72 

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

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

75 """ 

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

77 if not with_args: 

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

79 

80 args = typing.get_args(cls) 

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

82 

83 if isinstance(cls, type): 

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

85 

86 return cls 

87 

88 

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

90 """ 

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

92 

93 Example: 

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

95 """ 

96 return bool( 

97 typing.get_origin(obj) 

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

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

100 ) 

101 

102 

103def mktable( 

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

105) -> str: 

106 """ 

107 Display a table for 'data'. 

108 

109 See Also: 

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

111 """ 

112 # get max col width 

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

114 

115 # default numeric header if missing 

116 if not header: 

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

118 

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

120 

121 # correct column width if headers are longer 

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

123 

124 # create separator line 

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

126 

127 # create formating string 

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

129 

130 output = io.StringIO() 

131 # header 

132 print() 

133 print(line, file=output) 

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

135 print(line, file=output) 

136 

137 # data 

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

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

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

141 

142 # footer 

143 print(line, file=output) 

144 

145 return output.getvalue() 

146 

147 

148K = typing.TypeVar("K") 

149V = typing.TypeVar("V") 

150 

151 

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

153 """ 

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

155 

156 Examples: 

157 assert looks_like([], list) 

158 assert looks_like(list, list) 

159 assert looks_like(list[str], list) 

160 """ 

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

162 

163 

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

165 """ 

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

167 

168 Modifies mut_dict and returns everything of type _type. 

169 """ 

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

171 

172 

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

174 """ 

175 Get the inner type of a generic. 

176 

177 Example: 

178 list[list[str]] -> str 

179 """ 

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

181 _type = args[0] 

182 return _type 

183 

184 

185@typing.overload 

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

187 """ 

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

189 """ 

190 

191 

192@typing.overload 

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

194 """ 

195 None leads to None, False. 

196 """ 

197 

198 

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

200 """ 

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

202 """ 

203 if annotation is None: 

204 return None, False 

205 

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

207 args = typing.get_args(annotation) 

208 

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

210 # remove None: 

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

212 

213 return annotation, False 

214 

215 

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

217 """ 

218 Convert CamelCase to snake_case. 

219 

220 See Also: 

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

222 """ 

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

224 

225 

226class DummyQuery: 

227 """ 

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

229 """ 

230 

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

232 """ 

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

234 """ 

235 return other 

236 

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

238 """ 

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

240 """ 

241 return other 

242 

243 def __bool__(self) -> bool: 

244 """ 

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

246 """ 

247 return False 

248 

249 

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

251 """ 

252 Wrap value in a callable. 

253 """ 

254 return lambda *_, **__: value 

255 

256 

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

258 """ 

259 Glob but on a list of strings. 

260 """ 

261 if isinstance(patterns, str): 

262 patterns = [patterns] 

263 

264 matches = [] 

265 for pattern in patterns: 

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

267 

268 return matches 

269 

270 

271def utcnow() -> dt.datetime: 

272 """ 

273 Replacement of datetime.utcnow. 

274 """ 

275 # return dt.datetime.now(dt.UTC) 

276 return dt.datetime.now(dt.timezone.utc)