Coverage for an_website / utils / data_parsing.py: 60.432%
139 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 17:35 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-04-24 17:35 +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."""
16import contextlib
17import functools
18import inspect
19import typing
20from collections.abc import Callable, Iterable, Mapping
21from inspect import Parameter
22from types import UnionType
23from typing import Any, Literal, TypeVar, get_origin
25from tornado.web import HTTPError, RequestHandler
27from .utils import str_to_bool
29T = TypeVar("T")
32def parse( # noqa: C901 # pylint: disable=too-many-branches
33 type_: type[T],
34 data: Iterable[Mapping[str, Any]] | Mapping[str, Any] | Any,
35 *,
36 strict: bool = False,
37) -> T | None:
38 """Parse a data."""
39 # pylint: disable=too-many-return-statements, too-complex
40 if data is None and type_ is None:
41 return None
43 simple_type: type = get_origin(type_) or type_
45 if (
46 simple_type is type_
47 and not isinstance(type_, str) # type: ignore[redundant-expr]
48 and isinstance(data, type_)
49 ):
50 return data
52 if simple_type is Literal: # type: ignore[comparison-overlap]
53 possible = tuple(typing.get_args(type_)) # type: ignore[unreachable]
54 for pos in possible:
55 if pos == data:
56 return typing.cast(T, pos)
57 if isinstance(data, str):
58 data = data.strip()
59 for pos in possible:
60 if str(pos) == data:
61 return typing.cast(T, pos)
62 raise ValueError(f"Unable to parse {data!r} into {type_}")
64 if simple_type == UnionType:
65 possible = tuple(typing.get_args(type_))
66 for pos in possible:
67 with contextlib.suppress(ValueError):
68 return parse(pos, data, strict=strict)
69 raise ValueError(f"Unable to parse {data!r} into {type_}")
71 if simple_type in {list, "list"} and isinstance(data, list):
72 return _parse_list(type_, data, strict=strict)
73 if type_ in {bool, "bool"}:
74 return typing.cast(T, _parse_bool(data, strict=strict))
75 if type_ in {str, "str"}:
76 return typing.cast(T, _parse_str(data, strict=strict))
77 if type_ in {int, "int"}:
78 return typing.cast(T, _parse_int(data, strict=strict))
79 if type_ in {float, "float"}:
80 return typing.cast(T, _parse_float(data, strict=strict))
81 if hasattr(type_, "__init__") and isinstance(data, dict):
82 return _parse_class(type_, data, strict=strict)
84 raise ValueError(f"Unable to parse {data!r} into {type_}")
87def _parse_str(data: Any, *, strict: bool) -> str:
88 """Parse data into str."""
89 if isinstance(data, str):
90 return data
91 if strict:
92 raise ValueError(f"{data!r} is not str.")
93 return str(data)
96def _parse_bool(data: Any, *, strict: bool) -> bool:
97 """Parse data into bool."""
98 if isinstance(data, bool):
99 return data
100 if strict:
101 raise ValueError(f"{data!r} is not bool.")
102 if data in {0, 1}:
103 return bool(data)
104 if isinstance(data, str):
105 return str_to_bool(data)
106 raise ValueError(f"Cannot parse {data!r} into bool.")
109def _parse_int(data: Any, *, strict: bool) -> int:
110 """Parse data into int."""
111 if isinstance(data, int):
112 return data
113 if isinstance(data, float) and int(data) == data:
114 return int(data)
115 if strict:
116 raise ValueError(f"{data!r} is not a number.")
117 if isinstance(data, str):
118 return int(data, base=0)
119 if isinstance(data, bool):
120 return int(data)
121 raise ValueError(f"Cannot parse {data!r} into int.")
124def _parse_float(data: Any, *, strict: bool) -> float:
125 """Parse data into float."""
126 if isinstance(data, float):
127 return data
128 if isinstance(data, int):
129 return float(data)
130 if strict:
131 raise ValueError(f"{data!r} is not a number.")
132 if isinstance(data, str):
133 return int(data)
134 if isinstance(data, bool):
135 return int(data)
136 raise ValueError(f"Cannot parse {data!r} into int.")
139def _parse_list(
140 type_: type[T], data: Iterable[Mapping[str, Any]], *, strict: bool
141) -> T:
142 """Parse a list of data."""
143 args = typing.get_args(type_)
144 if len(args) != 1:
145 raise ValueError(f"{type_=} should be list[...]")
146 return typing.cast(
147 T, [parse(args[0], spam, strict=strict) for spam in data]
148 )
151def _parse_class(type_: type[T], data: Mapping[str, Any], *, strict: bool) -> T:
152 """Parse data into a class."""
153 signature = inspect.signature(type_.__init__, eval_str=True)
154 args: list[Any] = []
155 kwargs: dict[str, Any] = {}
156 in_positional = False
158 def add(_name: str, _value: Any) -> None:
159 if in_positional:
160 args.append(_value)
161 else:
162 kwargs[_name] = _value
164 first = True
165 for arg_name, param in signature.parameters.items():
166 if first:
167 first = False
168 continue
169 if param.kind in {Parameter.VAR_KEYWORD, Parameter.KEYWORD_ONLY}:
170 in_positional = True
171 if arg_name not in data:
172 if param.default == Parameter.empty:
173 raise ValueError(f"Missing required argument {arg_name!r}")
174 add(arg_name, param.default)
175 continue
176 value = data[arg_name]
177 if param.annotation == Parameter.empty:
178 if strict:
179 raise ValueError(f"Missing type annotation for {arg_name!r}")
180 add(arg_name, value)
181 continue
182 add(arg_name, parse(param.annotation, value, strict=strict))
184 return type_(*args, **kwargs)
187def parse_args(
188 *, type_: Any, name: str = "args", validation_method: str | None = None
189) -> Callable[[Callable[..., T]], Callable[..., T]]:
190 """Insert kwarg with name and type into function."""
192 def _inner(func: Callable[..., T]) -> Callable[..., T]:
193 @functools.wraps(func)
194 def new_func(self: RequestHandler, *args: Any, **kwargs: Any) -> T:
195 arguments: dict[str, str] = {}
196 for key, values in self.request.arguments.items():
197 if len(values) == 1:
198 arguments[key] = values[0].decode("UTF-8", "replace")
199 else:
200 raise HTTPError( # we don't want to guess
201 400, reason=f"Given multiple values for {key!r}"
202 )
203 try:
204 _data = parse(type_, arguments, strict=False)
205 except ValueError as err:
206 raise HTTPError(400, reason=err.args[0]) from err
207 if validation_method:
208 getattr(_data, validation_method)()
209 return func(self, *args, **kwargs, **{name: _data})
211 return new_func
213 return _inner