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

79 statements  

« prev     ^ index     » next       coverage.py v7.3.2, created at 2023-10-26 14:30 +0200

1""" 

2Helpers that work independently of core. 

3""" 

4import io 

5import types 

6import typing 

7from collections import ChainMap 

8from typing import Any 

9 

10T = typing.TypeVar("T") 

11 

12 

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

14 """ 

15 Check if a type is some type of Union. 

16 

17 Args: 

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

19 

20 """ 

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

22 

23 

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

25 """ 

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

27 attributes defined in cls or inherited from superclasses. 

28 """ 

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

30 

31 

32def all_dict(cls: type) -> dict[str, Any]: 

33 """ 

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

35 """ 

36 return dict(ChainMap(*(c.__dict__ for c in getattr(cls, "__mro__", [])))) 

37 

38 

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

40 """ 

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

42 

43 It also flattens the ChainMap to a regular dict. 

44 """ 

45 if _except is None: 

46 _except = set() 

47 

48 _all = _all_annotations(cls) 

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

50 

51 

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

53 """ 

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

55 

56 If it already is an instance, return it. 

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

58 

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

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

61 """ 

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

63 if not with_args: 

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

65 

66 args = typing.get_args(cls) 

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

68 

69 if isinstance(cls, type): 

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

71 

72 return cls 

73 

74 

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

76 """ 

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

78 

79 Example: 

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

81 """ 

82 return bool( 

83 typing.get_origin(obj) 

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

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

86 ) 

87 

88 

89def mktable( 

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

91) -> str: 

92 """ 

93 Display a table for 'data'. 

94 

95 See Also: 

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

97 """ 

98 # get max col width 

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

100 

101 # default numeric header if missing 

102 if not header: 

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

104 

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

106 

107 # correct column width if headers are longer 

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

109 

110 # create separator line 

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

112 

113 # create formating string 

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

115 

116 output = io.StringIO() 

117 # header 

118 print() 

119 print(line, file=output) 

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

121 print(line, file=output) 

122 

123 # data 

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

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

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

127 

128 # footer 

129 print(line, file=output) 

130 

131 return output.getvalue() 

132 

133 

134K = typing.TypeVar("K") 

135V = typing.TypeVar("V") 

136 

137 

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

139 """ 

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

141 

142 Examples: 

143 assert looks_like([], list) 

144 assert looks_like(list, list) 

145 assert looks_like(list[str], list) 

146 """ 

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

148 

149 

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

151 """ 

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

153 

154 Modifies mut_dict and returns everything of type _type. 

155 """ 

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

157 

158 

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

160 """ 

161 Get the inner type of a generic. 

162 

163 Example: 

164 list[list[str]] -> str 

165 """ 

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

167 _type = args[0] 

168 return _type 

169 

170 

171@typing.overload 

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

173 """ 

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

175 """ 

176 

177 

178@typing.overload 

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

180 """ 

181 None leads to None, False. 

182 """ 

183 

184 

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

186 """ 

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

188 """ 

189 if annotation is None: 

190 return None, False 

191 

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

193 args = typing.get_args(annotation) 

194 

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

196 # remove None: 

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

198 

199 return annotation, False 

200 

201 

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

203 """ 

204 Convert CamelCase to snake_case. 

205 

206 See Also: 

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

208 """ 

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

210 

211 

212class DummyQuery: 

213 """ 

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

215 """ 

216 

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

218 """ 

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

220 """ 

221 return other 

222 

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

224 """ 

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

226 """ 

227 return other 

228 

229 def __bool__(self) -> bool: 

230 """ 

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

232 """ 

233 return False 

234 

235 

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

237 """ 

238 Wrap value in a callable. 

239 """ 

240 return lambda *_, **__: value