Coverage for an_website/utils/data_parsing.py: 65.116%
129 statements
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-16 19:56 +0000
« prev ^ index » next coverage.py v7.6.4, created at 2024-11-16 19:56 +0000
1# This program is free software: you can redistribute it and/or modify
2# it under the terms of the GNU Affero General Public License as
3# published by the Free Software Foundation, either version 3 of the
4# License, or (at your option) any later version.
5#
6# This program is distributed in the hope that it will be useful,
7# but WITHOUT ANY WARRANTY; without even the implied warranty of
8# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
9# GNU Affero General Public License for more details.
10#
11# You should have received a copy of the GNU Affero General Public License
12# along with this program. If not, see <https://www.gnu.org/licenses/>.
14"""Parse data into classes."""
16from __future__ import annotations
18import contextlib
19import functools
20import inspect
21import typing
22from collections.abc import Callable, Iterable, Mapping
23from inspect import Parameter
24from types import UnionType
25from typing import Any, TypeVar, get_origin
27from tornado.web import HTTPError, RequestHandler
29from .utils import str_to_bool
31T = TypeVar("T")
34def parse(
35 type_: type[T],
36 data: Iterable[Mapping[str, Any]] | Mapping[str, Any] | Any,
37 *,
38 strict: bool = False,
39) -> T:
40 """Parse a data."""
41 # pylint: disable=too-many-return-statements, too-complex
42 if data is None and type_ is None:
43 return None # type: ignore[unreachable]
45 simple_type: type = get_origin(type_) or type_
47 if (
48 simple_type is type_
49 and not isinstance(type_, str) # type: ignore[redundant-expr]
50 and isinstance(data, type_)
51 ):
52 return data
54 if simple_type == UnionType:
55 possible = list(typing.get_args(type_))
56 for pos in possible:
57 with contextlib.suppress(ValueError):
58 return typing.cast(T, parse(pos, data, strict=strict))
59 raise ValueError(f"Unable to parse {data!r} into {type_}")
61 if simple_type in {list, "list"} and isinstance(data, list):
62 return _parse_list(type_, data, strict=strict)
63 if type_ in {bool, "bool"}:
64 return typing.cast(T, _parse_bool(data, strict=strict))
65 if type_ in {str, "str"}:
66 return typing.cast(T, _parse_str(data, strict=strict))
67 if type_ in {int, "int"}:
68 return typing.cast(T, _parse_int(data, strict=strict))
69 if type_ in {float, "float"}:
70 return typing.cast(T, _parse_float(data, strict=strict))
71 if hasattr(type_, "__init__") and isinstance(data, dict):
72 return _parse_class(type_, data, strict=strict)
74 raise ValueError(f"Unable to parse {data!r} into {type_}")
77def _parse_str(data: Any, *, strict: bool) -> str:
78 """Parse data into str."""
79 if isinstance(data, str):
80 return data
81 if strict:
82 raise ValueError(f"{data!r} is not str.")
83 return str(data)
86def _parse_bool(data: Any, *, strict: bool) -> bool:
87 """Parse data into bool."""
88 if isinstance(data, bool):
89 return data
90 if strict:
91 raise ValueError(f"{data!r} is not bool.")
92 if data in {0, 1}:
93 return bool(data)
94 if isinstance(data, str):
95 return str_to_bool(data)
96 raise ValueError(f"Cannot parse {data!r} into bool.")
99def _parse_int(data: Any, *, strict: bool) -> int:
100 """Parse data into int."""
101 if isinstance(data, int):
102 return data
103 if isinstance(data, float) and int(data) == data:
104 return int(data)
105 if strict:
106 raise ValueError(f"{data!r} is not a number.")
107 if isinstance(data, str):
108 return int(data, base=0)
109 if isinstance(data, bool):
110 return int(data)
111 raise ValueError(f"Cannot parse {data!r} into int.")
114def _parse_float(data: Any, *, strict: bool) -> float:
115 """Parse data into float."""
116 if isinstance(data, float):
117 return data
118 if isinstance(data, int):
119 return float(data)
120 if strict:
121 raise ValueError(f"{data!r} is not a number.")
122 if isinstance(data, str):
123 return int(data)
124 if isinstance(data, bool):
125 return int(data)
126 raise ValueError(f"Cannot parse {data!r} into int.")
129def _parse_list(
130 type_: type[T], data: Iterable[Mapping[str, Any]], *, strict: bool
131) -> T:
132 """Parse a list of data."""
133 args = typing.get_args(type_)
134 if len(args) != 1:
135 raise ValueError(f"{type_=} should be list[...]")
136 return typing.cast(
137 T, [parse(args[0], spam, strict=strict) for spam in data]
138 )
141def _parse_class(type_: type[T], data: Mapping[str, Any], *, strict: bool) -> T:
142 """Parse data into a class."""
143 signature = inspect.signature(type_.__init__, eval_str=True)
144 args: list[Any] = []
145 kwargs: dict[str, Any] = {}
146 in_positional = False
148 def add(_name: str, _value: Any) -> None:
149 if in_positional:
150 args.append(_value)
151 else:
152 kwargs[_name] = _value
154 first = True
155 for arg_name, param in signature.parameters.items():
156 if first:
157 first = False
158 continue
159 if param.kind in {Parameter.VAR_KEYWORD, Parameter.KEYWORD_ONLY}:
160 in_positional = True
161 if arg_name not in data:
162 if param.default == Parameter.empty:
163 raise ValueError(f"Missing required argument {arg_name!r}")
164 add(arg_name, param.default)
165 continue
166 value = data[arg_name]
167 if param.annotation == Parameter.empty:
168 if strict:
169 raise ValueError(f"Missing type annotation for {arg_name!r}")
170 add(arg_name, value)
171 continue
172 add(arg_name, parse(param.annotation, value, strict=strict))
174 return type_(*args, **kwargs)
177def parse_args(
178 *, type_: Any, name: str = "args", validation_method: str | None = None
179) -> Callable[[Callable[..., T]], Callable[..., T]]:
180 """Insert kwarg with name and type into function."""
182 def _inner(func: Callable[..., T]) -> Callable[..., T]:
183 @functools.wraps(func)
184 def new_func(self: RequestHandler, *args: Any, **kwargs: Any) -> T:
185 arguments: dict[str, str] = {}
186 for key, values in self.request.arguments.items():
187 if len(values) == 1:
188 arguments[key] = values[0].decode("UTF-8", "replace")
189 else:
190 raise HTTPError( # we don't want to guess
191 400, reason=f"Given multiple values for {key!r}"
192 )
193 try:
194 _data = parse(type_, arguments, strict=False)
195 except ValueError as err:
196 raise HTTPError(400, reason=err.args[0]) from err
197 if validation_method:
198 getattr(_data, validation_method)()
199 return func(self, *args, **kwargs, **{name: _data})
201 return new_func
203 return _inner