1from __future__ import annotations
2
3from contextlib import suppress
4from datetime import date, datetime
5from uuid import UUID
6import ipaddress
7import re
8import typing
9import warnings
10
11from jsonschema.exceptions import FormatError
12
13_FormatCheckCallable = typing.Callable[[object], bool]
14#: A format checker callable.
15_F = typing.TypeVar("_F", bound=_FormatCheckCallable)
16_RaisesType = typing.Union[
17 typing.Type[Exception], typing.Tuple[typing.Type[Exception], ...],
18]
19
20_RE_DATE = re.compile(r"^\d{4}-\d{2}-\d{2}$", re.ASCII)
21
22
23class FormatChecker:
24 """
25 A ``format`` property checker.
26
27 JSON Schema does not mandate that the ``format`` property actually do any
28 validation. If validation is desired however, instances of this class can
29 be hooked into validators to enable format validation.
30
31 `FormatChecker` objects always return ``True`` when asked about
32 formats that they do not know how to validate.
33
34 To add a check for a custom format use the `FormatChecker.checks`
35 decorator.
36
37 Arguments:
38
39 formats:
40
41 The known formats to validate. This argument can be used to
42 limit which formats will be used during validation.
43
44 """
45
46 checkers: dict[
47 str,
48 tuple[_FormatCheckCallable, _RaisesType],
49 ] = {} # noqa: RUF012
50
51 def __init__(self, formats: typing.Iterable[str] | None = None):
52 if formats is None:
53 formats = self.checkers.keys()
54 self.checkers = {k: self.checkers[k] for k in formats}
55
56 def __repr__(self):
57 return f"<FormatChecker checkers={sorted(self.checkers)}>"
58
59 def checks(
60 self, format: str, raises: _RaisesType = (),
61 ) -> typing.Callable[[_F], _F]:
62 """
63 Register a decorated function as validating a new format.
64
65 Arguments:
66
67 format:
68
69 The format that the decorated function will check.
70
71 raises:
72
73 The exception(s) raised by the decorated function when an
74 invalid instance is found.
75
76 The exception object will be accessible as the
77 `jsonschema.exceptions.ValidationError.cause` attribute of the
78 resulting validation error.
79
80 """
81
82 def _checks(func: _F) -> _F:
83 self.checkers[format] = (func, raises)
84 return func
85
86 return _checks
87
88 @classmethod
89 def cls_checks(
90 cls, format: str, raises: _RaisesType = (),
91 ) -> typing.Callable[[_F], _F]:
92 warnings.warn(
93 (
94 "FormatChecker.cls_checks is deprecated. Call "
95 "FormatChecker.checks on a specific FormatChecker instance "
96 "instead."
97 ),
98 DeprecationWarning,
99 stacklevel=2,
100 )
101 return cls._cls_checks(format=format, raises=raises)
102
103 @classmethod
104 def _cls_checks(
105 cls, format: str, raises: _RaisesType = (),
106 ) -> typing.Callable[[_F], _F]:
107 def _checks(func: _F) -> _F:
108 cls.checkers[format] = (func, raises)
109 return func
110
111 return _checks
112
113 def check(self, instance: object, format: str) -> None:
114 """
115 Check whether the instance conforms to the given format.
116
117 Arguments:
118
119 instance (*any primitive type*, i.e. str, number, bool):
120
121 The instance to check
122
123 format:
124
125 The format that instance should conform to
126
127 Raises:
128
129 FormatError:
130
131 if the instance does not conform to ``format``
132
133 """
134 if format not in self.checkers:
135 return
136
137 func, raises = self.checkers[format]
138 result, cause = None, None
139 try:
140 result = func(instance)
141 except raises as e:
142 cause = e
143 if not result:
144 raise FormatError(f"{instance!r} is not a {format!r}", cause=cause)
145
146 def conforms(self, instance: object, format: str) -> bool:
147 """
148 Check whether the instance conforms to the given format.
149
150 Arguments:
151
152 instance (*any primitive type*, i.e. str, number, bool):
153
154 The instance to check
155
156 format:
157
158 The format that instance should conform to
159
160 Returns:
161
162 bool: whether it conformed
163
164 """
165 try:
166 self.check(instance, format)
167 except FormatError:
168 return False
169 else:
170 return True
171
172
173draft3_format_checker = FormatChecker()
174draft4_format_checker = FormatChecker()
175draft6_format_checker = FormatChecker()
176draft7_format_checker = FormatChecker()
177draft201909_format_checker = FormatChecker()
178draft202012_format_checker = FormatChecker()
179
180_draft_checkers: dict[str, FormatChecker] = dict(
181 draft3=draft3_format_checker,
182 draft4=draft4_format_checker,
183 draft6=draft6_format_checker,
184 draft7=draft7_format_checker,
185 draft201909=draft201909_format_checker,
186 draft202012=draft202012_format_checker,
187)
188
189
190def _checks_drafts(
191 name=None,
192 draft3=None,
193 draft4=None,
194 draft6=None,
195 draft7=None,
196 draft201909=None,
197 draft202012=None,
198 raises=(),
199) -> typing.Callable[[_F], _F]:
200 draft3 = draft3 or name
201 draft4 = draft4 or name
202 draft6 = draft6 or name
203 draft7 = draft7 or name
204 draft201909 = draft201909 or name
205 draft202012 = draft202012 or name
206
207 def wrap(func: _F) -> _F:
208 if draft3:
209 func = _draft_checkers["draft3"].checks(draft3, raises)(func)
210 if draft4:
211 func = _draft_checkers["draft4"].checks(draft4, raises)(func)
212 if draft6:
213 func = _draft_checkers["draft6"].checks(draft6, raises)(func)
214 if draft7:
215 func = _draft_checkers["draft7"].checks(draft7, raises)(func)
216 if draft201909:
217 func = _draft_checkers["draft201909"].checks(draft201909, raises)(
218 func,
219 )
220 if draft202012:
221 func = _draft_checkers["draft202012"].checks(draft202012, raises)(
222 func,
223 )
224
225 # Oy. This is bad global state, but relied upon for now, until
226 # deprecation. See #519 and test_format_checkers_come_with_defaults
227 FormatChecker._cls_checks(
228 draft202012 or draft201909 or draft7 or draft6 or draft4 or draft3,
229 raises,
230 )(func)
231 return func
232
233 return wrap
234
235
236@_checks_drafts(name="idn-email")
237@_checks_drafts(name="email")
238def is_email(instance: object) -> bool:
239 if not isinstance(instance, str):
240 return True
241 return "@" in instance
242
243
244@_checks_drafts(
245 draft3="ip-address",
246 draft4="ipv4",
247 draft6="ipv4",
248 draft7="ipv4",
249 draft201909="ipv4",
250 draft202012="ipv4",
251 raises=ipaddress.AddressValueError,
252)
253def is_ipv4(instance: object) -> bool:
254 if not isinstance(instance, str):
255 return True
256 return bool(ipaddress.IPv4Address(instance))
257
258
259@_checks_drafts(name="ipv6", raises=ipaddress.AddressValueError)
260def is_ipv6(instance: object) -> bool:
261 if not isinstance(instance, str):
262 return True
263 address = ipaddress.IPv6Address(instance)
264 return not getattr(address, "scope_id", "")
265
266
267with suppress(ImportError):
268 from fqdn import FQDN
269
270 @_checks_drafts(
271 draft3="host-name",
272 draft4="hostname",
273 draft6="hostname",
274 draft7="hostname",
275 draft201909="hostname",
276 draft202012="hostname",
277 )
278 def is_host_name(instance: object) -> bool:
279 if not isinstance(instance, str):
280 return True
281 return FQDN(instance, min_labels=1).is_valid
282
283
284with suppress(ImportError):
285 # The built-in `idna` codec only implements RFC 3890, so we go elsewhere.
286 import idna
287
288 @_checks_drafts(
289 draft7="idn-hostname",
290 draft201909="idn-hostname",
291 draft202012="idn-hostname",
292 raises=(idna.IDNAError, UnicodeError),
293 )
294 def is_idn_host_name(instance: object) -> bool:
295 if not isinstance(instance, str):
296 return True
297 idna.encode(instance)
298 return True
299
300
301try:
302 import rfc3987
303except ImportError:
304 with suppress(ImportError):
305 from rfc3986_validator import validate_rfc3986
306
307 @_checks_drafts(name="uri")
308 def is_uri(instance: object) -> bool:
309 if not isinstance(instance, str):
310 return True
311 return validate_rfc3986(instance, rule="URI")
312
313 @_checks_drafts(
314 draft6="uri-reference",
315 draft7="uri-reference",
316 draft201909="uri-reference",
317 draft202012="uri-reference",
318 raises=ValueError,
319 )
320 def is_uri_reference(instance: object) -> bool:
321 if not isinstance(instance, str):
322 return True
323 return validate_rfc3986(instance, rule="URI_reference")
324
325else:
326
327 @_checks_drafts(
328 draft7="iri",
329 draft201909="iri",
330 draft202012="iri",
331 raises=ValueError,
332 )
333 def is_iri(instance: object) -> bool:
334 if not isinstance(instance, str):
335 return True
336 return rfc3987.parse(instance, rule="IRI")
337
338 @_checks_drafts(
339 draft7="iri-reference",
340 draft201909="iri-reference",
341 draft202012="iri-reference",
342 raises=ValueError,
343 )
344 def is_iri_reference(instance: object) -> bool:
345 if not isinstance(instance, str):
346 return True
347 return rfc3987.parse(instance, rule="IRI_reference")
348
349 @_checks_drafts(name="uri", raises=ValueError)
350 def is_uri(instance: object) -> bool:
351 if not isinstance(instance, str):
352 return True
353 return rfc3987.parse(instance, rule="URI")
354
355 @_checks_drafts(
356 draft6="uri-reference",
357 draft7="uri-reference",
358 draft201909="uri-reference",
359 draft202012="uri-reference",
360 raises=ValueError,
361 )
362 def is_uri_reference(instance: object) -> bool:
363 if not isinstance(instance, str):
364 return True
365 return rfc3987.parse(instance, rule="URI_reference")
366
367
368with suppress(ImportError):
369 from rfc3339_validator import validate_rfc3339
370
371 @_checks_drafts(name="date-time")
372 def is_datetime(instance: object) -> bool:
373 if not isinstance(instance, str):
374 return True
375 return validate_rfc3339(instance.upper())
376
377 @_checks_drafts(
378 draft7="time",
379 draft201909="time",
380 draft202012="time",
381 )
382 def is_time(instance: object) -> bool:
383 if not isinstance(instance, str):
384 return True
385 return is_datetime("1970-01-01T" + instance)
386
387
388@_checks_drafts(name="regex", raises=re.error)
389def is_regex(instance: object) -> bool:
390 if not isinstance(instance, str):
391 return True
392 return bool(re.compile(instance))
393
394
395@_checks_drafts(
396 draft3="date",
397 draft7="date",
398 draft201909="date",
399 draft202012="date",
400 raises=ValueError,
401)
402def is_date(instance: object) -> bool:
403 if not isinstance(instance, str):
404 return True
405 return bool(_RE_DATE.fullmatch(instance) and date.fromisoformat(instance))
406
407
408@_checks_drafts(draft3="time", raises=ValueError)
409def is_draft3_time(instance: object) -> bool:
410 if not isinstance(instance, str):
411 return True
412 return bool(datetime.strptime(instance, "%H:%M:%S")) # noqa: DTZ007
413
414
415with suppress(ImportError):
416 import webcolors
417
418 @_checks_drafts(draft3="color", raises=(ValueError, TypeError))
419 def is_css21_color(instance: object) -> bool:
420 if isinstance(instance, str):
421 try:
422 webcolors.name_to_hex(instance)
423 except ValueError:
424 webcolors.normalize_hex(instance.lower())
425 return True
426
427
428with suppress(ImportError):
429 import jsonpointer
430
431 @_checks_drafts(
432 draft6="json-pointer",
433 draft7="json-pointer",
434 draft201909="json-pointer",
435 draft202012="json-pointer",
436 raises=jsonpointer.JsonPointerException,
437 )
438 def is_json_pointer(instance: object) -> bool:
439 if not isinstance(instance, str):
440 return True
441 return bool(jsonpointer.JsonPointer(instance))
442
443 # TODO: I don't want to maintain this, so it
444 # needs to go either into jsonpointer (pending
445 # https://github.com/stefankoegl/python-json-pointer/issues/34) or
446 # into a new external library.
447 @_checks_drafts(
448 draft7="relative-json-pointer",
449 draft201909="relative-json-pointer",
450 draft202012="relative-json-pointer",
451 raises=jsonpointer.JsonPointerException,
452 )
453 def is_relative_json_pointer(instance: object) -> bool:
454 # Definition taken from:
455 # https://tools.ietf.org/html/draft-handrews-relative-json-pointer-01#section-3
456 if not isinstance(instance, str):
457 return True
458 if not instance:
459 return False
460
461 non_negative_integer, rest = [], ""
462 for i, character in enumerate(instance):
463 if character.isdigit():
464 # digits with a leading "0" are not allowed
465 if i > 0 and int(instance[i - 1]) == 0:
466 return False
467
468 non_negative_integer.append(character)
469 continue
470
471 if not non_negative_integer:
472 return False
473
474 rest = instance[i:]
475 break
476 return (rest == "#") or bool(jsonpointer.JsonPointer(rest))
477
478
479with suppress(ImportError):
480 import uri_template
481
482 @_checks_drafts(
483 draft6="uri-template",
484 draft7="uri-template",
485 draft201909="uri-template",
486 draft202012="uri-template",
487 )
488 def is_uri_template(instance: object) -> bool:
489 if not isinstance(instance, str):
490 return True
491 return uri_template.validate(instance)
492
493
494with suppress(ImportError):
495 import isoduration
496
497 @_checks_drafts(
498 draft201909="duration",
499 draft202012="duration",
500 raises=isoduration.DurationParsingException,
501 )
502 def is_duration(instance: object) -> bool:
503 if not isinstance(instance, str):
504 return True
505 isoduration.parse_duration(instance)
506 # FIXME: See bolsote/isoduration#25 and bolsote/isoduration#21
507 return instance.endswith(tuple("DMYWHMS"))
508
509
510@_checks_drafts(
511 draft201909="uuid",
512 draft202012="uuid",
513 raises=ValueError,
514)
515def is_uuid(instance: object) -> bool:
516 if not isinstance(instance, str):
517 return True
518 UUID(instance)
519 return all(instance[position] == "-" for position in (8, 13, 18, 23))