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