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

1"""

2 Helpers that work independently of core.

3"""

4

5 import datetime as dt

6 import fnmatch

7 import io

8 import types

9 import typing

10 from collections import ChainMap

11 from typing import Any

12

13 from .types import AnyDict

14

15 T = typing.TypeVar("T")

16

17

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

19 """

20 Check if a type is some type of Union.

21

22 Args:

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

24

25 """

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

27

28

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

30 """

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

32 """

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

34

35

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

37 """

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

39 attributes defined in cls or inherited from superclasses.

40 """

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

42

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

44

45

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

47 """

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

49 """

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

51

52

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

54 """

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

56

57 It also flattens the ChainMap to a regular dict.

58 """

59 if _except is None:

60 _except = set()

61

62 _all = _all_annotations(cls)

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

64

65

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

67 """

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

69

70 If it already is an instance, return it.

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

72

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

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

75 """

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

77 if not with_args:

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

79

80 args = typing.get_args(cls)

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

82

83 if isinstance(cls, type):

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

85

86 return cls

87

88

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

90 """

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

92

93 Example:

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

95 """

96 return bool(

97 typing.get_origin(obj)

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

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

100 )

101

102

103 def mktable(

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

105) -> str:

106 """

107 Display a table for 'data'.

108

109 See Also:

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

111 """

112 # get max col width

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

114

115 # default numeric header if missing

116 if not header:

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

118

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

120

121 # correct column width if headers are longer

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

123

124 # create separator line

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

126

127 # create formating string

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

129

130 output = io.StringIO()

131 # header

132 print()

133 print(line, file=output)

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

135 print(line, file=output)

136

137 # data

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

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

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

141

142 # footer

143 print(line, file=output)

144

145 return output.getvalue()

146

147

148 K = typing.TypeVar("K")

149 V = typing.TypeVar("V")

150

151

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

153 """

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

155

156 Examples:

157 assert looks_like([], list)

158 assert looks_like(list, list)

159 assert looks_like(list[str], list)

160 """

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

162

163

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

165 """

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

167

168 Modifies mut_dict and returns everything of type _type.

169 """

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

171

172

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

174 """

175 Get the inner type of a generic.

176

177 Example:

178 list[list[str]] -> str

179 """

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

181 _type = args[0]

182 return _type

183

184

185@typing.overload

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

187 """

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

189 """

190

191

192@typing.overload

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

194 """

195 None leads to None, False.

196 """

197

198

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

200 """

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

202 """

203 if annotation is None:

204 return None, False

205

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

207 args = typing.get_args(annotation)

208

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

210 # remove None:

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

212

213 return annotation, False

214

215

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

217 """

218 Convert CamelCase to snake_case.

219

220 See Also:

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

222 """

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

224

225

226 class DummyQuery:

227 """

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

229 """

230

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

232 """

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

234 """

235 return other

236

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

238 """

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

240 """

241 return other

242

243 def __bool__(self) -> bool:

244 """

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

246 """

247 return False

248

249

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

251 """

252 Wrap value in a callable.

253 """

254 return lambda *_, **__: value

255

256

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

258 """

259 Glob but on a list of strings.

260 """

261 if isinstance(patterns, str):

262 patterns = [patterns]

263

264 matches = []

265 for pattern in patterns:

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

267

268 return matches

269

270

271 def utcnow() -> dt.datetime:

272 """

273 Replacement of datetime.utcnow.

274 """

275 # return dt.datetime.now(dt.UTC)

276 return dt.datetime.now(dt.timezone.utc)