Coverage for src/typedal/helpers.py: 100%
90 statements
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-08 16:34 +0200
« prev ^ index » next coverage.py v7.5.1, created at 2024-05-08 16:34 +0200
1"""
2Helpers that work independently of core.
3"""
5import fnmatch
6import io
7import types
8import typing
9from collections import ChainMap
10from typing import Any
12from .types import AnyDict
14T = typing.TypeVar("T")
17def is_union(some_type: type | types.UnionType) -> bool:
18 """
19 Check if a type is some type of Union.
21 Args:
22 some_type: types.UnionType = type(int | str); typing.Union = typing.Union[int, str]
24 """
25 return typing.get_origin(some_type) in (types.UnionType, typing.Union)
28def 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__", []))
35def _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:
42 return ChainMap(*(c.__annotations__ for c in reversed_mro(cls) if "__annotations__" in c.__dict__))
45def 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
52def 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.
56 It also flattens the ChainMap to a regular dict.
57 """
58 if _except is None:
59 _except = set()
61 _all = _all_annotations(cls)
62 return {k: v for k, v in _all.items() if k not in _except}
65def instanciate(cls: typing.Type[T] | T, with_args: bool = False) -> T:
66 """
67 Create an instance of T (if it is a class).
69 If it already is an instance, return it.
70 If it is a generic (list[int)) create an instance of the 'origin' (-> list()).
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())
79 args = typing.get_args(cls)
80 return typing.cast(T, inner_cls(*args))
82 if isinstance(cls, type):
83 return typing.cast(T, cls())
85 return cls
88def origin_is_subclass(obj: Any, _type: type) -> bool:
89 """
90 Check if the origin of a generic is a subclass of _type.
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 )
102def 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'.
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()))))
114 # default numeric header if missing
115 if not header:
116 header = range(1, len(col_widths) + 1)
118 header_widths = map(lambda x: len(str(x)), header)
120 # correct column width if headers are longer
121 col_widths = [max(c, h) for c, h in zip(col_widths, header_widths)]
123 # create separator line
124 line = f"+{'+'.join('-' * (w + 2) for w in col_widths)}+"
126 # create formating string
127 fmt_str = "| %s |" % " | ".join(f"{ :<{i}} " for i in col_widths)
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)
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)
141 # footer
142 print(line, file=output)
144 return output.getvalue()
147K = typing.TypeVar("K")
148V = typing.TypeVar("V")
151def 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.
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)
163def 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.
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)}
172def unwrap_type(_type: type) -> type:
173 """
174 Get the inner type of a generic.
176 Example:
177 list[list[str]] -> str
178 """
179 while args := typing.get_args(_type):
180 _type = args[0]
181 return _type
184@typing.overload
185def 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 """
191@typing.overload
192def extract_type_optional(annotation: None) -> tuple[None, bool]:
193 """
194 None leads to None, False.
195 """
198def 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
205 if origin := typing.get_origin(annotation):
206 args = typing.get_args(annotation)
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
212 return annotation, False
215def to_snake(camel: str) -> str:
216 """
217 Convert CamelCase to snake_case.
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("_")
225class DummyQuery:
226 """
227 Placeholder to &= and |= actual query parts.
228 """
230 def __or__(self, other: T) -> T:
231 """
232 For 'or': DummyQuery | Other == Other.
233 """
234 return other
236 def __and__(self, other: T) -> T:
237 """
238 For 'and': DummyQuery & Other == Other.
239 """
240 return other
242 def __bool__(self) -> bool:
243 """
244 A dummy query is falsey, since it can't actually be used!
245 """
246 return False
249def as_lambda(value: T) -> typing.Callable[..., T]:
250 """
251 Wrap value in a callable.
252 """
253 return lambda *_, **__: value
256def 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]
263 matches = []
264 for pattern in patterns:
265 matches.extend([s for s in string_list if fnmatch.fnmatch(s, pattern)])
267 return matches