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

1"""

2 Helpers that work independently of core.

3"""

4 import io

5 import types

6 import typing

7 from collections import ChainMap

8 from typing import Any

9

10 T = typing.TypeVar("T")

11

12

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

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

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

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

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

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

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

134 K = typing.TypeVar("K")

135 V = typing.TypeVar("V")

136

137

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

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

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

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

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

180 """

181 None leads to None, False.

182 """

183

184

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

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

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

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

237 """

238 Wrap value in a callable.

239 """

240 return lambda *_, **__: value