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

80 statements  

« prev     ^ index     » next       coverage.py v7.4.1, created at 2024-01-29 16:15 +0100

1""" 

2Helpers that work independently of core. 

3""" 

4 

5import io 

6import types 

7import typing 

8from collections import ChainMap 

9from typing import Any 

10 

11from .types import AnyDict 

12 

13T = typing.TypeVar("T") 

14 

15 

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

17 """ 

18 Check if a type is some type of Union. 

19 

20 Args: 

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

22 

23 """ 

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

25 

26 

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

28 """ 

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

30 attributes defined in cls or inherited from superclasses. 

31 """ 

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

33 

34 

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

36 """ 

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

38 """ 

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

40 

41 

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

43 """ 

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

45 

46 It also flattens the ChainMap to a regular dict. 

47 """ 

48 if _except is None: 

49 _except = set() 

50 

51 _all = _all_annotations(cls) 

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

53 

54 

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

56 """ 

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

58 

59 If it already is an instance, return it. 

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

61 

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

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

64 """ 

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

66 if not with_args: 

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

68 

69 args = typing.get_args(cls) 

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

71 

72 if isinstance(cls, type): 

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

74 

75 return cls 

76 

77 

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

79 """ 

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

81 

82 Example: 

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

84 """ 

85 return bool( 

86 typing.get_origin(obj) 

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

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

89 ) 

90 

91 

92def mktable( 

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

94) -> str: 

95 """ 

96 Display a table for 'data'. 

97 

98 See Also: 

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

100 """ 

101 # get max col width 

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

103 

104 # default numeric header if missing 

105 if not header: 

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

107 

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

109 

110 # correct column width if headers are longer 

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

112 

113 # create separator line 

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

115 

116 # create formating string 

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

118 

119 output = io.StringIO() 

120 # header 

121 print() 

122 print(line, file=output) 

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

124 print(line, file=output) 

125 

126 # data 

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

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

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

130 

131 # footer 

132 print(line, file=output) 

133 

134 return output.getvalue() 

135 

136 

137K = typing.TypeVar("K") 

138V = typing.TypeVar("V") 

139 

140 

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

142 """ 

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

144 

145 Examples: 

146 assert looks_like([], list) 

147 assert looks_like(list, list) 

148 assert looks_like(list[str], list) 

149 """ 

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

151 

152 

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

154 """ 

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

156 

157 Modifies mut_dict and returns everything of type _type. 

158 """ 

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

160 

161 

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

163 """ 

164 Get the inner type of a generic. 

165 

166 Example: 

167 list[list[str]] -> str 

168 """ 

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

170 _type = args[0] 

171 return _type 

172 

173 

174@typing.overload 

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

176 """ 

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

178 """ 

179 

180 

181@typing.overload 

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

183 """ 

184 None leads to None, False. 

185 """ 

186 

187 

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

189 """ 

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

191 """ 

192 if annotation is None: 

193 return None, False 

194 

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

196 args = typing.get_args(annotation) 

197 

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

199 # remove None: 

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

201 

202 return annotation, False 

203 

204 

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

206 """ 

207 Convert CamelCase to snake_case. 

208 

209 See Also: 

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

211 """ 

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

213 

214 

215class DummyQuery: 

216 """ 

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

218 """ 

219 

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

221 """ 

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

223 """ 

224 return other 

225 

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

227 """ 

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

229 """ 

230 return other 

231 

232 def __bool__(self) -> bool: 

233 """ 

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

235 """ 

236 return False 

237 

238 

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

240 """ 

241 Wrap value in a callable. 

242 """ 

243 return lambda *_, **__: value