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

1# SPDX-FileCopyrightText: 2022-2026 Univention GmbH 

2# SPDX-License-Identifier: AGPL-3.0-only 

3 

4""" 

5|UCR| type validation classes. 

6 

7Checks validity of type definitions and type compatibility of values to be set. 

8""" 

9 

10from __future__ import annotations 

11 

12import ipaddress 

13import json 

14import re 

15from re import Pattern 

16from typing import TYPE_CHECKING, Any, cast 

17from urllib.parse import urlsplit 

18 

19import univention.config_registry_info as cri 

20from univention.config_registry.backend import BooleanConfigRegistry 

21 

22 

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 

25 

26 

27class BaseValidator: 

28 """Base class for |UCR| type validators.""" 

29 

30 NAME = "" 

31 

32 def __init__(self, attrs: dict[str, str]) -> None: 

33 pass 

34 

35 def is_valid(self, value: str) -> bool: 

36 """ 

37 Check if value is valid. 

38 

39 :returns: ``True`` if valid, ``False`` otherwise. 

40 """ 

41 try: 

42 return bool(self.validate(value)) 

43 except Exception: 

44 return False 

45 

46 def validate(self, value: str) -> Any: 

47 """ 

48 Check is value is valid. 

49 

50 :returns: something that can be evaulated with ``bool()``. 

51 :raises Exception: on errors. 

52 """ 

53 raise NotImplementedError() 

54 

55 def __str__(self) -> str: 

56 return self.NAME 

57 

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 

63 

64 yield from clazz._recurse_subclasses() 

65 

66 

67class String(BaseValidator): 

68 """ 

69 Validator for |UCR| type "str". 

70 

71 Supports a Python compatible regular expression defined in the optional `regex` constraints 

72 """ 

73 

74 NAME = "str" 

75 REGEX: Pattern | None = None 

76 

77 def __init__(self, attrs: dict[str, str]) -> None: 

78 self.regex = attrs.get('regex', self.REGEX) # type: ignore 

79 

80 @property 

81 def regex(self) -> str | None: 

82 return self._rxc.pattern if self._rxc else None 

83 

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 

93 

94 def validate(self, value: str) -> Any: 

95 if self._rxc: 

96 return self._rxc.match(value) 

97 else: 

98 return isinstance(value, str) 

99 

100 def __str__(self) -> str: 

101 return "%s(regex=%r)" % (self.NAME, self.regex) if self.regex else self.NAME 

102 

103 

104class URLHttp(BaseValidator): 

105 """Validator for |UCR| type "url_http".""" 

106 

107 NAME = "url_http" 

108 

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"} 

113 

114 

115class URLProxy(BaseValidator): 

116 """Validator for |UCR| type "url_proxy".""" 

117 

118 NAME = "url_proxy" 

119 

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 

124 

125 

126class IPv4Address(BaseValidator): 

127 """Validator for |UCR| type "ipv4address".""" 

128 

129 NAME = "ipv4address" 

130 

131 def validate(self, value: str) -> Any: 

132 return ipaddress.IPv4Address(value) 

133 

134 

135class IPv6Address(BaseValidator): 

136 """Validator for |UCR| type "ipv6address".""" 

137 

138 NAME = "ipv6address" 

139 

140 def validate(self, value: str) -> Any: 

141 return ipaddress.IPv6Address(value) 

142 

143 

144class IPAddress(BaseValidator): 

145 """Validator for |UCR| type "ipaddress".""" 

146 

147 NAME = "ipaddress" 

148 

149 def validate(self, value: str) -> Any: 

150 return ipaddress.ip_address(value) 

151 

152 

153class Integer(BaseValidator): 

154 """ 

155 Validator for |UCR| type "int". 

156 

157 Supports optional 'min' and 'max' constraints 

158 """ 

159 

160 NAME = "int" 

161 

162 MIN: int | None = None 

163 MAX: int | None = None 

164 

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)) 

170 

171 @property 

172 def min(self) -> int | None: 

173 return self._min 

174 

175 @min.setter 

176 def min(self, value: str | None) -> None: 

177 if value is None: 

178 self._min = None 

179 return 

180 

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)) 

184 

185 self._min = val 

186 

187 @property 

188 def max(self) -> int | None: 

189 return self._max 

190 

191 @max.setter 

192 def max(self, value: str | None) -> None: 

193 if value is None: 

194 self._max = None 

195 return 

196 

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)) 

200 

201 self._max = val 

202 

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) 

208 

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 

211 

212 

213class UnsignedNumber(Integer): 

214 """Validator for |UCR| type "uint".""" 

215 

216 NAME = "uint" 

217 MIN = 0 

218 

219 def __str__(self) -> str: 

220 return '%s(max=%r)' % (self.NAME, self._max) if self._max else self.NAME 

221 

222 

223class PositiveNumber(Integer): 

224 """Validator for |UCR| type "pint".""" 

225 

226 NAME = "pint" 

227 MIN = 1 

228 

229 def __str__(self) -> str: 

230 return '%s(max=%r)' % (self.NAME, self._max) if self._max else self.NAME 

231 

232 

233class PortNumber(Integer): 

234 """Validator for |UCR| type "portnumber".""" 

235 

236 NAME = "portnumber" 

237 MIN = 0 

238 MAX = 65535 

239 

240 def __str__(self) -> str: 

241 return self.NAME 

242 

243 

244class Bool(BaseValidator): 

245 """Validator for |UCR| type "bool".""" 

246 

247 NAME = "bool" 

248 _BCR = BooleanConfigRegistry() 

249 

250 def validate(self, value: str) -> bool: 

251 return self._BCR.is_true(value=value) or self._BCR.is_false(value=value) 

252 

253 

254class Json(BaseValidator): 

255 """Validator for |UCR| type "json".""" 

256 

257 NAME = "json" 

258 

259 def validate(self, value: str) -> Any: 

260 return json.loads(value) or True 

261 

262 

263class List(BaseValidator): 

264 """Validator for |UCR| type "list".""" 

265 

266 NAME = "list" 

267 DEFAULT_SEPARATOR = ',' 

268 

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) 

278 

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)) 

286 

287 def __str__(self) -> str: 

288 return '%s[%s%s]' % (self.NAME, self.element_type, self.separator.pattern) 

289 

290 

291class Cron(BaseValidator): 

292 """Validator for |UCR| type "cron".""" 

293 

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"]) 

298 

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 

312 

313 @staticmethod 

314 def _check(text: str, low: int, high: int, extra: Container[str] = {}) -> None: 

315 if text.lower() in extra: 

316 return 

317 

318 for value in text.split(","): 

319 base, _, step = value.partition("/") 

320 start, _, end = base.partition("-") 

321 

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())) 

328 

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())) 

337 

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())) 

346 

347 

348class Type: 

349 """ 

350 Basic |UCR| type validation class. 

351 

352 Usage:: 

353 

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 """ 

364 

365 TYPE_CLASSES: dict[str | None, type[BaseValidator]] = { 

366 clazz.NAME: clazz 

367 for clazz in BaseValidator._recurse_subclasses() 

368 } 

369 

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) 

375 

376 def check(self, value: str) -> bool: 

377 return self.checker.is_valid(value) 

378 

379 def __str__(self) -> str: 

380 return str(self.checker)