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
327else:
328
329 @_checks_drafts(
330 draft7="iri",
331 draft201909="iri",
332 draft202012="iri",
333 raises=ValueError,
334 )
335 def is_iri(instance: object) -> bool:
336 if not isinstance(instance, str):
337 return True
338 return rfc3987.parse(instance, rule="IRI")
339
340 @_checks_drafts(
341 draft7="iri-reference",
342 draft201909="iri-reference",
343 draft202012="iri-reference",
344 raises=ValueError,
345 )
346 def is_iri_reference(instance: object) -> bool:
347 if not isinstance(instance, str):
348 return True
349 return rfc3987.parse(instance, rule="IRI_reference")
350
351 @_checks_drafts(name="uri", raises=ValueError)
352 def is_uri(instance: object) -> bool:
353 if not isinstance(instance, str):
354 return True
355 return rfc3987.parse(instance, rule="URI")
356
357 @_checks_drafts(
358 draft6="uri-reference",
359 draft7="uri-reference",
360 draft201909="uri-reference",
361 draft202012="uri-reference",
362 raises=ValueError,
363 )
364 def is_uri_reference(instance: object) -> bool:
365 if not isinstance(instance, str):
366 return True
367 return rfc3987.parse(instance, rule="URI_reference")
368
369
370with suppress(ImportError):
371 from rfc3339_validator import validate_rfc3339
372
373 @_checks_drafts(name="date-time")
374 def is_datetime(instance: object) -> bool:
375 if not isinstance(instance, str):
376 return True
377 return validate_rfc3339(instance.upper())
378
379 @_checks_drafts(
380 draft7="time",
381 draft201909="time",
382 draft202012="time",
383 )
384 def is_time(instance: object) -> bool:
385 if not isinstance(instance, str):
386 return True
387 return is_datetime("1970-01-01T" + instance)
388
389
390@_checks_drafts(name="regex", raises=re.error)
391def is_regex(instance: object) -> bool:
392 if not isinstance(instance, str):
393 return True
394 return bool(re.compile(instance))
395
396
397@_checks_drafts(
398 draft3="date",
399 draft7="date",
400 draft201909="date",
401 draft202012="date",
402 raises=ValueError,
403)
404def is_date(instance: object) -> bool:
405 if not isinstance(instance, str):
406 return True
407 return bool(_RE_DATE.fullmatch(instance) and date.fromisoformat(instance))
408
409
410@_checks_drafts(draft3="time", raises=ValueError)
411def is_draft3_time(instance: object) -> bool:
412 if not isinstance(instance, str):
413 return True
414 return bool(datetime.strptime(instance, "%H:%M:%S")) # noqa: DTZ007
415
416
417with suppress(ImportError):
418 import webcolors
419
420 @_checks_drafts(draft3="color", raises=(ValueError, TypeError))
421 def is_css21_color(instance: object) -> bool:
422 if isinstance(instance, str):
423 try:
424 webcolors.name_to_hex(instance)
425 except ValueError:
426 webcolors.normalize_hex(instance.lower())
427 return True
428
429
430with suppress(ImportError):
431 import jsonpointer
432
433 @_checks_drafts(
434 draft6="json-pointer",
435 draft7="json-pointer",
436 draft201909="json-pointer",
437 draft202012="json-pointer",
438 raises=jsonpointer.JsonPointerException,
439 )
440 def is_json_pointer(instance: object) -> bool:
441 if not isinstance(instance, str):
442 return True
443 return bool(jsonpointer.JsonPointer(instance))
444
445 # TODO: I don't want to maintain this, so it
446 # needs to go either into jsonpointer (pending
447 # https://github.com/stefankoegl/python-json-pointer/issues/34) or
448 # into a new external library.
449 @_checks_drafts(
450 draft7="relative-json-pointer",
451 draft201909="relative-json-pointer",
452 draft202012="relative-json-pointer",
453 raises=jsonpointer.JsonPointerException,
454 )
455 def is_relative_json_pointer(instance: object) -> bool:
456 # Definition taken from:
457 # https://tools.ietf.org/html/draft-handrews-relative-json-pointer-01#section-3
458 if not isinstance(instance, str):
459 return True
460 if not instance:
461 return False
462
463 non_negative_integer, rest = [], ""
464 for i, character in enumerate(instance):
465 if character.isdigit():
466 # digits with a leading "0" are not allowed
467 if i > 0 and int(instance[i - 1]) == 0:
468 return False
469
470 non_negative_integer.append(character)
471 continue
472
473 if not non_negative_integer:
474 return False
475
476 rest = instance[i:]
477 break
478 return (rest == "#") or bool(jsonpointer.JsonPointer(rest))
479
480
481with suppress(ImportError):
482 import uri_template
483
484 @_checks_drafts(
485 draft6="uri-template",
486 draft7="uri-template",
487 draft201909="uri-template",
488 draft202012="uri-template",
489 )
490 def is_uri_template(instance: object) -> bool:
491 if not isinstance(instance, str):
492 return True
493 return uri_template.validate(instance)
494
495
496with suppress(ImportError):
497 import isoduration
498
499 @_checks_drafts(
500 draft201909="duration",
501 draft202012="duration",
502 raises=isoduration.DurationParsingException,
503 )
504 def is_duration(instance: object) -> bool:
505 if not isinstance(instance, str):
506 return True
507 isoduration.parse_duration(instance)
508 # FIXME: See bolsote/isoduration#25 and bolsote/isoduration#21
509 return instance.endswith(tuple("DMYWHMS"))
510
511
512@_checks_drafts(
513 draft201909="uuid",
514 draft202012="uuid",
515 raises=ValueError,
516)
517def is_uuid(instance: object) -> bool:
518 if not isinstance(instance, str):
519 return True
520 UUID(instance)
521 return all(instance[position] == "-" for position in (8, 13, 18, 23))