Coverage for python/univention/config_registry/validation.py: 93%
216 statements
« prev ^ index » next coverage.py v7.6.0, created at 2026-09-11 14:11 +0000
« prev ^ index » next coverage.py v7.6.0, created at 2026-09-11 14:11 +0000
1# SPDX-FileCopyrightText: 2022-2026 Univention GmbH
2# SPDX-License-Identifier: AGPL-3.0-only
4"""
5|UCR| type validation classes.
7Checks validity of type definitions and type compatibility of values to be set.
8"""
10from __future__ import annotations
12import ipaddress
13import json
14import re
15from re import Pattern
16from typing import TYPE_CHECKING, Any, cast
17from urllib.parse import urlsplit
19import univention.config_registry_info as cri
20from univention.config_registry.backend import BooleanConfigRegistry
23if TYPE_CHECKING: 23 ↛ 24line 23 didn't jump to line 24 because the condition on line 23 was never true
24 from collections.abc import Container, Iterator
27class BaseValidator:
28 """Base class for |UCR| type validators."""
30 NAME = ""
32 def __init__(self, attrs: dict[str, str]) -> None:
33 pass
35 def is_valid(self, value: str) -> bool:
36 """
37 Check if value is valid.
39 :returns: ``True`` if valid, ``False`` otherwise.
40 """
41 try:
42 return bool(self.validate(value))
43 except Exception:
44 return False
46 def validate(self, value: str) -> Any:
47 """
48 Check is value is valid.
50 :returns: something that can be evaulated with ``bool()``.
51 :raises Exception: on errors.
52 """
53 raise NotImplementedError()
55 def __str__(self) -> str:
56 return self.NAME
58 @classmethod
59 def _recurse_subclasses(cls) -> Iterator[type[BaseValidator]]:
60 for clazz in cls.__subclasses__():
61 if clazz.NAME: 61 ↛ 64line 61 didn't jump to line 64 because the condition on line 61 was always true
62 yield clazz
64 yield from clazz._recurse_subclasses()
67class String(BaseValidator):
68 """
69 Validator for |UCR| type "str".
71 Supports a Python compatible regular expression defined in the optional `regex` constraints
72 """
74 NAME = "str"
75 REGEX: Pattern | None = None
77 def __init__(self, attrs: dict[str, str]) -> None:
78 self.regex = attrs.get('regex', self.REGEX) # type: ignore
80 @property
81 def regex(self) -> str | None:
82 return self._rxc.pattern if self._rxc else None
84 @regex.setter
85 def regex(self, regex: str | Pattern | None) -> None:
86 rxc = None
87 if regex is not None:
88 try:
89 rxc = re.compile(regex)
90 except (ValueError, TypeError, re.error):
91 raise ValueError('error compiling regex: %s' % regex)
92 self._rxc = rxc
94 def validate(self, value: str) -> Any:
95 if self._rxc:
96 return self._rxc.match(value)
97 else:
98 return isinstance(value, str)
100 def __str__(self) -> str:
101 return "%s(regex=%r)" % (self.NAME, self.regex) if self.regex else self.NAME
104class URLHttp(BaseValidator):
105 """Validator for |UCR| type "url_http"."""
107 NAME = "url_http"
109 def validate(self, value: str) -> bool:
110 o = urlsplit(value)
111 o.port # may raise ValueError # noqa: B018
112 return o.scheme in {"http", "https"}
115class URLProxy(BaseValidator):
116 """Validator for |UCR| type "url_proxy"."""
118 NAME = "url_proxy"
120 def validate(self, value: str) -> bool:
121 o = urlsplit(value)
122 o.port # may raise ValueError # noqa: B018
123 return o.scheme in {"http", "https"} and not o.path and not o.query and not o.fragment
126class IPv4Address(BaseValidator):
127 """Validator for |UCR| type "ipv4address"."""
129 NAME = "ipv4address"
131 def validate(self, value: str) -> Any:
132 return ipaddress.IPv4Address(value)
135class IPv6Address(BaseValidator):
136 """Validator for |UCR| type "ipv6address"."""
138 NAME = "ipv6address"
140 def validate(self, value: str) -> Any:
141 return ipaddress.IPv6Address(value)
144class IPAddress(BaseValidator):
145 """Validator for |UCR| type "ipaddress"."""
147 NAME = "ipaddress"
149 def validate(self, value: str) -> Any:
150 return ipaddress.ip_address(value)
153class Integer(BaseValidator):
154 """
155 Validator for |UCR| type "int".
157 Supports optional 'min' and 'max' constraints
158 """
160 NAME = "int"
162 MIN: int | None = None
163 MAX: int | None = None
165 def __init__(self, attrs: dict[str, str]) -> None:
166 self._min: int | None = None
167 self._max: int | None = None
168 self.min = cast(int | None, attrs.get('min', self.MIN))
169 self.max = cast(int | None, attrs.get('max', self.MAX))
171 @property
172 def min(self) -> int | None:
173 return self._min
175 @min.setter
176 def min(self, value: str | None) -> None:
177 if value is None:
178 self._min = None
179 return
181 val = int(value)
182 if self._max is not None and val > self._max: 182 ↛ 183line 182 didn't jump to line 183 because the condition on line 182 was never true
183 raise ValueError('min %d > max %d' % (val, self._max))
185 self._min = val
187 @property
188 def max(self) -> int | None:
189 return self._max
191 @max.setter
192 def max(self, value: str | None) -> None:
193 if value is None:
194 self._max = None
195 return
197 val = int(value)
198 if self._min is not None and self._min > val:
199 raise ValueError('min %d > max %d' % (self._min, val))
201 self._max = val
203 def validate(self, value: str) -> bool:
204 val = int(value)
205 if self._min is not None and val < self._min:
206 return False
207 return not (self._max is not None and val > self._max)
209 def __str__(self) -> str:
210 return '%s(min=%r, max=%r)' % (self.NAME, self._min, self._max) if self._min or self._max else self.NAME
213class UnsignedNumber(Integer):
214 """Validator for |UCR| type "uint"."""
216 NAME = "uint"
217 MIN = 0
219 def __str__(self) -> str:
220 return '%s(max=%r)' % (self.NAME, self._max) if self._max else self.NAME
223class PositiveNumber(Integer):
224 """Validator for |UCR| type "pint"."""
226 NAME = "pint"
227 MIN = 1
229 def __str__(self) -> str:
230 return '%s(max=%r)' % (self.NAME, self._max) if self._max else self.NAME
233class PortNumber(Integer):
234 """Validator for |UCR| type "portnumber"."""
236 NAME = "portnumber"
237 MIN = 0
238 MAX = 65535
240 def __str__(self) -> str:
241 return self.NAME
244class Bool(BaseValidator):
245 """Validator for |UCR| type "bool"."""
247 NAME = "bool"
248 _BCR = BooleanConfigRegistry()
250 def validate(self, value: str) -> bool:
251 return self._BCR.is_true(value=value) or self._BCR.is_false(value=value)
254class Json(BaseValidator):
255 """Validator for |UCR| type "json"."""
257 NAME = "json"
259 def validate(self, value: str) -> Any:
260 return json.loads(value) or True
263class List(BaseValidator):
264 """Validator for |UCR| type "list"."""
266 NAME = "list"
267 DEFAULT_SEPARATOR = ','
269 def __init__(self, attrs: dict[str, str]) -> None:
270 self.element_type = attrs.get('elementtype', "str")
271 regex = attrs.get('separator', self.DEFAULT_SEPARATOR)
272 try:
273 self.separator = re.compile(regex)
274 except re.error:
275 raise ValueError('error compiling regex: %s' % regex)
276 typ = Type.TYPE_CLASSES.get(self.element_type, String)
277 self.checker = typ(attrs)
279 def validate(self, value: str) -> bool:
280 if self.element_type is None: 280 ↛ 281line 280 didn't jump to line 281 because the condition on line 280 was never true
281 return False
282 vinfo = cri.Variable()
283 vinfo['type'] = self.element_type
284 val = Type(vinfo)
285 return all(val.check(element.strip()) for element in self.separator.split(value))
287 def __str__(self) -> str:
288 return '%s[%s%s]' % (self.NAME, self.element_type, self.separator.pattern)
291class Cron(BaseValidator):
292 """Validator for |UCR| type "cron"."""
294 NAME = "cron"
295 PREDEFINED = frozenset(["@annually", "@yearly", "@monthly", "@weekly", "@daily", "@hourly", "@reboot"])
296 MONTHS = frozenset(["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"])
297 DAYS = frozenset(["sun", "mon", "tue", "wed", "thu", "fri", "sat"])
299 def validate(self, value: str) -> bool:
300 if value.startswith("#"):
301 pass
302 elif value in self.PREDEFINED:
303 pass
304 else:
305 minutes, hours, days_month, months, days_week = value.split()
306 self._check(minutes, 0, 59)
307 self._check(hours, 0, 23)
308 self._check(days_month, 1, 31)
309 self._check(months, 1, 12, self.MONTHS)
310 self._check(days_week, 0, 7, self.DAYS)
311 return True
313 @staticmethod
314 def _check(text: str, low: int, high: int, extra: Container[str] = {}) -> None:
315 if text.lower() in extra:
316 return
318 for value in text.split(","):
319 base, _, step = value.partition("/")
320 start, _, end = base.partition("-")
322 if start == "*":
323 pass
324 elif low <= int(start) <= high:
325 pass
326 else:
327 raise ValueError("start={start!r} not in range [{low}-{high}]".format(**locals()))
329 if not end:
330 pass
331 elif start == "*":
332 raise ValueError("end={end!r} not compatible with '*'".format(**locals()))
333 elif low <= int(start) <= int(end) <= high:
334 pass
335 else:
336 raise ValueError("end={end!r} not in range [{low}-{start}-{high}]".format(**locals()))
338 if not step:
339 pass
340 elif start != "*" and not end:
341 raise ValueError("step={step!r} requires range".format(**locals()))
342 elif (low or 1) <= int(step) <= high:
343 pass
344 else:
345 raise ValueError("step={step!r} not in range [{low_}-{high}]".format(low_=low or 1, **locals()))
348class Type:
349 """
350 Basic |UCR| type validation class.
352 Usage::
354 try:
355 validator = Type(vinfo)
356 except (TypeError, ValueError):
357 # invalid type
358 else:
359 if validator.check(value_to_be_set):
360 # check ok: set value
361 else:
362 # value is not compatible with type definition
363 """
365 TYPE_CLASSES: dict[str | None, type[BaseValidator]] = {
366 clazz.NAME: clazz
367 for clazz in BaseValidator._recurse_subclasses()
368 }
370 def __init__(self, vinfo: cri.Variable) -> None:
371 self.vinfo = vinfo
372 self.vtype: str | None = self.vinfo.get('type')
373 typ = self.TYPE_CLASSES.get(self.vtype, String)
374 self.checker = typ(self.vinfo)
376 def check(self, value: str) -> bool:
377 return self.checker.is_valid(value)
379 def __str__(self) -> str:
380 return str(self.checker)