Coverage for src/typedal/helpers.py: 100%
93 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-08-05 19:10 +0200
« prev ^ index » next coverage.py v7.5.1, created at 2024-08-05 19:10 +0200
1"""
2Helpers that work independently of core.
3"""
5import datetime as dt
6import fnmatch
7import io
8import types
9import typing
10from collections import ChainMap
11from typing import Any
13from .types import AnyDict
15T = typing.TypeVar("T")
18def is_union(some_type: type | types.UnionType) -> bool:
19 """
20 Check if a type is some type of Union.
22 Args:
23 some_type: types.UnionType = type(int | str); typing.Union = typing.Union[int, str]
25 """
26 return typing.get_origin(some_type) in (types.UnionType, typing.Union)
29def 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__", []))
36def _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:
43 return ChainMap(*(c.__annotations__ for c in reversed_mro(cls) if "__annotations__" in c.__dict__))
46def 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
53def 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.
57 It also flattens the ChainMap to a regular dict.
58 """
59 if _except is None:
60 _except = set()
62 _all = _all_annotations(cls)
63 return {k: v for k, v in _all.items() if k not in _except}
66def instanciate(cls: typing.Type[T] | T, with_args: bool = False) -> T:
67 """
68 Create an instance of T (if it is a class).
70 If it already is an instance, return it.
71 If it is a generic (list[int)) create an instance of the 'origin' (-> list()).
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())
80 args = typing.get_args(cls)
81 return typing.cast(T, inner_cls(*args))
83 if isinstance(cls, type):
84 return typing.cast(T, cls())
86 return cls
89def origin_is_subclass(obj: Any, _type: type) -> bool:
90 """
91 Check if the origin of a generic is a subclass of _type.
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 )
103def 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'.
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()))))
115 # default numeric header if missing
116 if not header:
117 header = range(1, len(col_widths) + 1)
119 header_widths = map(lambda x: len(str(x)), header)
121 # correct column width if headers are longer
122 col_widths = [max(c, h) for c, h in zip(col_widths, header_widths)]
124 # create separator line
125 line = f"+{'+'.join('-' * (w + 2) for w in col_widths)}+"
127 # create formating string
128 fmt_str = "| %s |" % " | ".join(f"{{:<{i}}}" for i in col_widths)
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)
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)
142 # footer
143 print(line, file=output)
145 return output.getvalue()
148K = typing.TypeVar("K")
149V = typing.TypeVar("V")
152def 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.
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)
164def 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.
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)}
173def unwrap_type(_type: type) -> type:
174 """
175 Get the inner type of a generic.
177 Example:
178 list[list[str]] -> str
179 """
180 while args := typing.get_args(_type):
181 _type = args[0]
182 return _type
185@typing.overload
186def 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 """
192@typing.overload
193def extract_type_optional(annotation: None) -> tuple[None, bool]:
194 """
195 None leads to None, False.
196 """
199def 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
206 if origin := typing.get_origin(annotation):
207 args = typing.get_args(annotation)
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
213 return annotation, False
216def to_snake(camel: str) -> str:
217 """
218 Convert CamelCase to snake_case.
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("_")
226class DummyQuery:
227 """
228 Placeholder to &= and |= actual query parts.
229 """
231 def __or__(self, other: T) -> T:
232 """
233 For 'or': DummyQuery | Other == Other.
234 """
235 return other
237 def __and__(self, other: T) -> T:
238 """
239 For 'and': DummyQuery & Other == Other.
240 """
241 return other
243 def __bool__(self) -> bool:
244 """
245 A dummy query is falsey, since it can't actually be used!
246 """
247 return False
250def as_lambda(value: T) -> typing.Callable[..., T]:
251 """
252 Wrap value in a callable.
253 """
254 return lambda *_, **__: value
257def 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]
264 matches = []
265 for pattern in patterns:
266 matches.extend([s for s in string_list if fnmatch.fnmatch(s, pattern)])
268 return matches
271def 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)