codekingpro/portable-devtools
114k
1from __future__ import annotations2 3from contextlib import suppress4from datetime import date, datetime5from uuid import UUID6import ipaddress7import re8import typing9import warnings10 11from jsonschema.exceptions import FormatError12 13_FormatCheckCallable = typing.Callable[[object], bool]14#: A format checker callable.15_F = typing.TypeVar("_F", bound=_FormatCheckCallable)16_RaisesType = 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 any26 validation. If validation is desired however, instances of this class can27 be hooked into validators to enable format validation.28 29 `FormatChecker` objects always return ``True`` when asked about30 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 to40 limit which formats will be used during validation.41 42 """43 44 checkers: dict[45 str,46 tuple[_FormatCheckCallable, _RaisesType],47 ] = {} # noqa: RUF01248 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 an72 invalid instance is found.73 74 The exception object will be accessible as the75 `jsonschema.exceptions.ValidationError.cause` attribute of the76 resulting validation error.77 78 """79 80 def _checks(func: _F) -> _F:81 self.checkers[format] = (func, raises)82 return func83 84 return _checks85 86 @classmethod87 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 @classmethod102 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 func108 109 return _checks110 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 check120 121 format:122 123 The format that instance should conform to124 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 return134 135 func, raises = self.checkers[format]136 result, cause = None, None137 try:138 result = func(instance)139 except raises as e:140 cause = e141 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 check153 154 format:155 156 The format that instance should conform to157 158 Returns:159 160 bool: whether it conformed161 162 """163 try:164 self.check(instance, format)165 except FormatError:166 return False167 else:168 return True169 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 name199 draft4 = draft4 or name200 draft6 = draft6 or name201 draft7 = draft7 or name202 draft201909 = draft201909 or name203 draft202012 = draft202012 or name204 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, until224 # deprecation. See #519 and test_format_checkers_come_with_defaults225 FormatChecker._cls_checks(226 draft202012 or draft201909 or draft7 or draft6 or draft4 or draft3,227 raises,228 )(func)229 return func230 231 return wrap232 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 True239 return "@" in instance240 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 True254 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 True261 address = ipaddress.IPv6Address(instance)262 return not getattr(address, "scope_id", "")263 264 265with suppress(ImportError):266 from fqdn import FQDN267 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 bug276 # however, it's not clear when or if that will be fixed, so catch it277 # here for now278 raises=ValueError,279 )280 def is_host_name(instance: object) -> bool:281 if not isinstance(instance, str):282 return True283 return FQDN(instance, min_labels=1).is_valid284 285 286with suppress(ImportError):287 # The built-in `idna` codec only implements RFC 3890, so we go elsewhere.288 import idna289 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 True299 idna.encode(instance)300 return True301 302 303try:304 import rfc3987305except ImportError:306 with suppress(ImportError):307 from rfc3986_validator import validate_rfc3986308 309 @_checks_drafts(name="uri")310 def is_uri(instance: object) -> bool:311 if not isinstance(instance, str):312 return True313 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 True325 return validate_rfc3986(instance, rule="URI_reference")326 327 with suppress(ImportError):328 from rfc3987_syntax import is_valid_syntax as _rfc3987_is_valid_syntax329 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 True339 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 True350 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 True363 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 True374 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 True380 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 True392 return rfc3987.parse(instance, rule="URI_reference")393 394 395with suppress(ImportError):396 from rfc3339_validator import validate_rfc3339397 398 @_checks_drafts(name="date-time")399 def is_datetime(instance: object) -> bool:400 if not isinstance(instance, str):401 return True402 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 True412 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 True419 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 True432 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 True439 return bool(datetime.strptime(instance, "%H:%M:%S")) # noqa: DTZ007440 441 442with suppress(ImportError):443 import webcolors444 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 True453 454 455with suppress(ImportError):456 import jsonpointer457 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 True468 return bool(jsonpointer.JsonPointer(instance))469 470 # TODO: I don't want to maintain this, so it471 # needs to go either into jsonpointer (pending472 # https://github.com/stefankoegl/python-json-pointer/issues/34) or473 # 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-3483 if not isinstance(instance, str):484 return True485 if not instance:486 return False487 488 non_negative_integer, rest = [], ""489 for i, character in enumerate(instance):490 if character.isdigit():491 # digits with a leading "0" are not allowed492 if i > 0 and int(instance[i - 1]) == 0:493 return False494 495 non_negative_integer.append(character)496 continue497 498 if not non_negative_integer:499 return False500 501 rest = instance[i:]502 break503 return (rest == "#") or bool(jsonpointer.JsonPointer(rest))504 505 506with suppress(ImportError):507 import uri_template508 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 True518 return uri_template.validate(instance)519 520 521with suppress(ImportError):522 import isoduration523 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 True532 isoduration.parse_duration(instance)533 # FIXME: See bolsote/isoduration#25 and bolsote/isoduration#21534 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 True545 UUID(instance)546 return all(instance[position] == "-" for position in (8, 13, 18, 23))547 