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

1"""

2 Helpers that work independently of core.

3"""

4

5 import io

6 import types

7 import typing

8 from collections import ChainMap

9 from typing import Any

10

11 from .types import AnyDict

12

13 T = typing.TypeVar("T")

14

15

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

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

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

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

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

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

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

137 K = typing.TypeVar("K")

138 V = typing.TypeVar("V")

139

140

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

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

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

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

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

183 """

184 None leads to None, False.

185 """

186

187

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

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

215 class 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

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

240 """

241 Wrap value in a callable.

242 """

243 return lambda *_, **__: value