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

1"""

2 Helpers that work independently of core.

3"""

4

5 import fnmatch

6 import io

7 import types

8 import typing

9 from collections import ChainMap

10 from typing import Any

11

12 from .types import AnyDict

13

14 T = typing.TypeVar("T")

15

16

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

18 """

19 Check if a type is some type of Union.

20

21 Args:

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

23

24 """

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

26

27

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

29 """

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

31 """

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

33

34

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

36 """

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

38 attributes defined in cls or inherited from superclasses.

39 """

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

41

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

43

44

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

46 """

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

48 """

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

50

51

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

53 """

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

55

56 It also flattens the ChainMap to a regular dict.

57 """

58 if _except is None:

59 _except = set()

60

61 _all = _all_annotations(cls)

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

63

64

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

66 """

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

68

69 If it already is an instance, return it.

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

71

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

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

74 """

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

76 if not with_args:

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

78

79 args = typing.get_args(cls)

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

81

82 if isinstance(cls, type):

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

84

85 return cls

86

87

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

89 """

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

91

92 Example:

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

94 """

95 return bool(

96 typing.get_origin(obj)

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

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

99 )

100

101

102 def mktable(

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

104) -> str:

105 """

106 Display a table for 'data'.

107

108 See Also:

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

110 """

111 # get max col width

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

113

114 # default numeric header if missing

115 if not header:

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

117

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

119

120 # correct column width if headers are longer

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

122

123 # create separator line

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

125

126 # create formating string

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

128

129 output = io.StringIO()

130 # header

131 print()

132 print(line, file=output)

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

134 print(line, file=output)

135

136 # data

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

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

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

140

141 # footer

142 print(line, file=output)

143

144 return output.getvalue()

145

146

147 K = typing.TypeVar("K")

148 V = typing.TypeVar("V")

149

150

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

152 """

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

154

155 Examples:

156 assert looks_like([], list)

157 assert looks_like(list, list)

158 assert looks_like(list[str], list)

159 """

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

161

162

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

164 """

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

166

167 Modifies mut_dict and returns everything of type _type.

168 """

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

170

171

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

173 """

174 Get the inner type of a generic.

175

176 Example:

177 list[list[str]] -> str

178 """

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

180 _type = args[0]

181 return _type

182

183

184@typing.overload

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

186 """

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

188 """

189

190

191@typing.overload

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

193 """

194 None leads to None, False.

195 """

196

197

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

199 """

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

201 """

202 if annotation is None:

203 return None, False

204

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

206 args = typing.get_args(annotation)

207

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

209 # remove None:

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

211

212 return annotation, False

213

214

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

216 """

217 Convert CamelCase to snake_case.

218

219 See Also:

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

221 """

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

223

224

225 class DummyQuery:

226 """

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

228 """

229

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

231 """

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

233 """

234 return other

235

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

237 """

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

239 """

240 return other

241

242 def __bool__(self) -> bool:

243 """

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

245 """

246 return False

247

248

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

250 """

251 Wrap value in a callable.

252 """

253 return lambda *_, **__: value

254

255

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

257 """

258 Glob but on a list of strings.

259 """

260 if isinstance(patterns, str):

261 patterns = [patterns]

262

263 matches = []

264 for pattern in patterns:

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

266

267 return matches