diff --git a/poetry.lock b/poetry.lock index 9518beb..ecfebc0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry and should not be changed by hand. +# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand. [[package]] name = "appnope" @@ -576,6 +576,18 @@ pytest = ">=4.6" [package.extras] testing = ["fields", "hunter", "process-tests", "pytest-xdist", "six", "virtualenv"] +[[package]] +name = "rfc3987" +version = "1.3.8" +description = "Parsing and validation of URIs (RFC 3986) and IRIs (RFC 3987)" +category = "main" +optional = true +python-versions = "*" +files = [ + {file = "rfc3987-1.3.8-py2.py3-none-any.whl", hash = "sha256:10702b1e51e5658843460b189b185c0366d2cf4cff716f13111b0ea9fd2dce53"}, + {file = "rfc3987-1.3.8.tar.gz", hash = "sha256:d3c4d257a560d544e9826b38bc81db676890c79ab9d7ac92b39c7a253d5ca733"}, +] + [[package]] name = "six" version = "1.16.0" @@ -691,7 +703,10 @@ files = [ docs = ["furo", "jaraco.packaging (>=9)", "jaraco.tidelift (>=1.4)", "rst.linker (>=1.9)", "sphinx (>=3.5)"] testing = ["flake8 (<5)", "func-timeout", "jaraco.functools", "jaraco.itertools", "more-itertools", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=1.3)", "pytest-flake8", "pytest-mypy (>=0.9.1)"] +[extras] +strings = ["rfc3987"] + [metadata] lock-version = "2.0" python-versions = "^3.8" -content-hash = "279f6d4505c101b6c88e545b44b6d48411759907d983d564813db59f0624b859" +content-hash = "3b310229c33fe0386ce0e20f1d87ea7449f90bc149dce128b9612122699eacd3" diff --git a/pyproject.toml b/pyproject.toml index d8a2967..88f855e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,6 +8,8 @@ readme = "README.md" [tool.poetry.dependencies] python = "^3.8" +rfc3987 = { version = "^1.3.8", optional = true } + [tool.poetry.dev-dependencies] pytest = "^7.2.0" @@ -17,6 +19,10 @@ vulture = "^2.6" flake8 = { version = "^6.0.0", python = ">=3.8.1" } pytest-cov = "^4.0.0" + +[tool.poetry.extras] +strings = ["rfc3987"] + [build-system] requires = ["poetry-core>=1.0.0"] build-backend = "poetry.core.masonry.api" diff --git a/schemamodels/__init__.py b/schemamodels/__init__.py index 8037b77..35203e3 100644 --- a/schemamodels/__init__.py +++ b/schemamodels/__init__.py @@ -1,14 +1,19 @@ import sys from dataclasses import make_dataclass, field, fields as fs from re import sub +import re import importlib +import ipaddress from operator import gt, ge, lt, le, mod, xor, not_ from typing import Callable +from urllib.parse import urlparse from functools import partial, reduce from schemamodels import exceptions as e, bases +from datetime import datetime + JSON_TYPE_MAP = { 'string': lambda d: isinstance(d, str), @@ -21,6 +26,38 @@ PORCELINE_KEYWORDS = ['value', 'default', 'anyOf', 'allOf', 'oneOf', 'not'] +# Source: https://uibakery.io/regex-library/email-regex-python +EMAIL_REGEX = re.compile(r"^[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*@(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") + +HOSTNAME_REGEX = re.compile(r'^(?P(?:\w+(?:\+\w+)?:)//)?//(?P.*)') + + +def hostname_process(e): + results = HOSTNAME_REGEX.match(e) + if results: + if all(results.group('scheme'), results.group('remainder')): + return urlparse(e).hostname is not None + return False + + +def ifraises(func, value, exception_class=ValueError): + try: + func(value) + except exception_class: + return False + return True + + +STRING_FORMATS = { + 'email': lambda e: EMAIL_REGEX.match(e) is not None, + 'date-time': lambda e: sys.version_info >= (3, 11) and datetime.fromisoformat(e), + 'date': lambda e: sys.version_info >= (3, 11) and datetime.fromisoformat(e), + 'time': lambda e: sys.version_info >= (3, 11) and datetime.fromisoformat(e), + 'ipv4': lambda e: ifraises(ipaddress.IPv4Address, e, exception_class=ipaddress.AddressValueError), + 'ipv6': lambda e: ifraises(ipaddress.IPv6Address, e, exception_class=ipaddress.AddressValueError), + 'hostname': lambda e: hostname_process(e), +} + COMPARISONS = { 'type': lambda d: JSON_TYPE_MAP[d], 'anyOf': lambda d: partial(lambda struct: generate_functors(struct), d), @@ -28,6 +65,7 @@ 'oneOf': lambda d: partial(lambda struct: generate_functors(struct), d), 'not': lambda d: not_(d), 'string': lambda d: isinstance(d, str), + 'format': lambda d: partial(lambda bound, v: STRING_FORMATS[bound](v), d), 'integer': lambda d: isinstance(d, int), 'number': lambda d: isinstance(d, (float, int)), 'null': lambda d: d is None, @@ -107,6 +145,11 @@ def constraints(dataclass_instance): raise e.SubSchemaFailureViolation("at least one subschema failed") if len([n for n in nodes if not n.get('type', True)]) > 0: raise e.ValueTypeViolation("incorrect type assigned to JSON property") + if len([n for n in nodes if not n.get('format', True)]) > 0: + if sys.version_info >= (3, 11): + raise e.StringFormatViolation("violates string format constraint") + else: + raise e.StringFormatViolation("Python versions < 3.11 does not support native ISO8601 datetime parsing") if len([n for n in nodes if not n.get('maximum', True)]) > 0: raise e.RangeConstraintViolation("violates range contraint") if len([n for n in nodes if not n.get('exclusiveMaximum', True)]) > 0: diff --git a/schemamodels/exceptions.py b/schemamodels/exceptions.py index 6ef3688..f3c3f51 100644 --- a/schemamodels/exceptions.py +++ b/schemamodels/exceptions.py @@ -14,3 +14,6 @@ class ValueTypeViolation(SchemaViolation): pass class SubSchemaFailureViolation(SchemaViolation): pass + + +class StringFormatViolation(SchemaViolation): pass diff --git a/tests.py b/tests.py index 019f463..1262dee 100644 --- a/tests.py +++ b/tests.py @@ -1,3 +1,4 @@ +import sys from jsonschema import validators import json import importlib @@ -523,3 +524,115 @@ def test_functor_generator(): assert next(iter(fn.values()))(1) assert any(list(next(iter(fn.values()))(1.0))) assert all(list(next(iter(fn.values()))("e"))) + + +@pytest.mark.string +@pytest.mark.format +def test_string_email_format_support(): + schemadoc = ''' + { + "title": "email-format", + "description": "Blue Blah", + "type": "object", + "properties": { + "user_email": { + "type": "string", + "format": "email" + } + } + } + ''' + stringformat = json.loads(schemadoc) + sm = SchemaModelFactory() + sm.register(stringformat) + + from schemamodels.dynamic import EmailFormat + + fs = EmailFormat(user_email="test@examplemail.co") + with pytest.raises(exceptions.StringFormatViolation): + fs = EmailFormat(user_email="abcdefgh") + + +@pytest.mark.string +@pytest.mark.format +def test_string_datetimes_format_support(): + schemadoc = ''' + { + "title": "date-format", + "description": "Blue Blah", + "type": "object", + "properties": { + "event_time": { + "type": "string", + "format": "date-time" + } + } + } + ''' + stringformat = json.loads(schemadoc) + sm = SchemaModelFactory() + sm.register(stringformat) + + from schemamodels.dynamic import DateFormat + + if sys.version_info >= (3, 11): + fs = DateFormat(event_time="2018-11-13T20:20:39+00:00") + with pytest.raises(exceptions.StringFormatViolation): + fs = DateFormat(event_time="abcdefgh") + else: + with pytest.raises(exceptions.StringFormatViolation): + fs = DateFormat(event_time="2018-11-13T20:20:39+00:00") + + +@pytest.mark.string +@pytest.mark.format +def test_string_ipaddress_format_support(): + schemadoc = ''' + { + "title": "internet-format", + "description": "Blue Blah", + "type": "object", + "properties": { + "event_location": { + "type": "string", + "format": "ipv4" + } + } + } + ''' + stringformat = json.loads(schemadoc) + sm = SchemaModelFactory() + sm.register(stringformat) + + from schemamodels.dynamic import InternetFormat + + fs = InternetFormat(event_location='127.0.0.1') + with pytest.raises(exceptions.StringFormatViolation): + fs = InternetFormat(event_location='welpwelp') + + +@pytest.mark.string +@pytest.mark.format +def test_string_hostname_format_support(): + schemadoc = ''' + { + "title": "internet-format", + "description": "Blue Blah", + "type": "object", + "properties": { + "webhost": { + "type": "string", + "format": "hostname" + } + } + } + ''' + stringformat = json.loads(schemadoc) + sm = SchemaModelFactory() + sm.register(stringformat) + + from schemamodels.dynamic import InternetFormat + + fs = InternetFormat(webhost='127.0.0.1') + with pytest.raises(exceptions.StringFormatViolation): + fs = InternetFormat(webhost='qwdqwd//qwdqwdwd.com')