From 7a3266e4ccaef7ebce2bb5bf4a0778465121ce4d Mon Sep 17 00:00:00 2001 From: Pu Chen Date: Tue, 6 May 2025 21:54:02 +0800 Subject: [PATCH 1/2] Install email-validator --- pyproject.toml | 1 + uv.lock | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 0bf5bc9..9f12218 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,6 +23,7 @@ readme = "README.md" # Project Dependencies dependencies = [ + "email-validator>=2.2.0", "jsonschema>=4.23.0", "pydantic>=2.10.6", ] diff --git a/uv.lock b/uv.lock index 4721a45..b184157 100644 --- a/uv.lock +++ b/uv.lock @@ -112,6 +112,28 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/91/a1/cf2472db20f7ce4a6be1253a81cfdf85ad9c7885ffbed7047fb72c24cf87/distlib-0.3.9-py2.py3-none-any.whl", hash = "sha256:47f8c22fd27c27e25a65601af709b38e4f0a45ea4fc2e710f65755fa8caaaf87", size = 468973 }, ] +[[package]] +name = "dnspython" +version = "2.7.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/4a/263763cb2ba3816dd94b08ad3a33d5fdae34ecb856678773cc40a3605829/dnspython-2.7.0.tar.gz", hash = "sha256:ce9c432eda0dc91cf618a5cedf1a4e142651196bbcd2c80e89ed5a907e5cfaf1", size = 345197 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/68/1b/e0a87d256e40e8c888847551b20a017a6b98139178505dc7ffb96f04e954/dnspython-2.7.0-py3-none-any.whl", hash = "sha256:b4c34b7d10b51bcc3a5071e7b8dee77939f1e878477eeecc965e9835f63c6c86", size = 313632 }, +] + +[[package]] +name = "email-validator" +version = "2.2.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "dnspython" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/48/ce/13508a1ec3f8bb981ae4ca79ea40384becc868bfae97fd1c942bb3a001b1/email_validator-2.2.0.tar.gz", hash = "sha256:cb690f344c617a714f22e66ae771445a1ceb46821152df8e165c5f9a364582b7", size = 48967 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d7/ee/bf0adb559ad3c786f12bcbc9296b3f5675f529199bef03e2df281fa1fadb/email_validator-2.2.0-py3-none-any.whl", hash = "sha256:561977c2d73ce3611850a06fa56b414621e0c8faa9d66f2611407d87465da631", size = 33521 }, +] + [[package]] name = "exceptiongroup" version = "1.2.2" @@ -139,6 +161,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/07/ce/0845144ed1f0e25db5e7a79c2354c1da4b5ce392b8966449d5db8dca18f1/identify-2.6.9-py2.py3-none-any.whl", hash = "sha256:c98b4322da415a8e5a70ff6e51fbc2d2932c015532d77e9f8537b4ba7813b150", size = 99101 }, ] +[[package]] +name = "idna" +version = "3.10" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/70/7703c29685631f5a7590aa73f1f1d3fa9a380e654b86af429e0934a32f7d/idna-3.10.tar.gz", hash = "sha256:12f65c9b470abda6dc35cf8e63cc574b1c52b11df2c86030af0ac09b01b13ea9", size = 190490 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/c6/c88e154df9c4e1a2a66ccf0005a88dfb2650c1dffb6f5ce603dfbd452ce3/idna-3.10-py3-none-any.whl", hash = "sha256:946d195a0d259cbba61165e88e65941f16e9b36ea6ddb97f00452bae8b1287d3", size = 70442 }, +] + [[package]] name = "iniconfig" version = "2.1.0" @@ -152,6 +183,7 @@ wheels = [ name = "jambo" source = { editable = "." } dependencies = [ + { name = "email-validator" }, { name = "jsonschema" }, { name = "pydantic" }, ] @@ -168,6 +200,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "email-validator", specifier = ">=2.2.0" }, { name = "jsonschema", specifier = ">=4.23.0" }, { name = "pydantic", specifier = ">=2.10.6" }, ] From b52997633c4d178d397d8ee95c307277fcacb6d0 Mon Sep 17 00:00:00 2001 From: Pu Chen Date: Tue, 6 May 2025 21:51:06 +0800 Subject: [PATCH 2/2] Support string format --- jambo/parser/string_type_parser.py | 37 +++++++- tests/parser/test_string_type_parser.py | 112 ++++++++++++++++++++++++ tests/test_schema_converter.py | 99 ++++++++++++++++++++- 3 files changed, 245 insertions(+), 3 deletions(-) diff --git a/jambo/parser/string_type_parser.py b/jambo/parser/string_type_parser.py index 89e8b7e..cf4c41c 100644 --- a/jambo/parser/string_type_parser.py +++ b/jambo/parser/string_type_parser.py @@ -1,5 +1,9 @@ from jambo.parser._type_parser import GenericTypeParser +from pydantic import EmailStr, HttpUrl, IPvAnyAddress + +from datetime import date, datetime, time + class StringTypeParser(GenericTypeParser): mapped_type = str @@ -10,13 +14,42 @@ class StringTypeParser(GenericTypeParser): "maxLength": "max_length", "minLength": "min_length", "pattern": "pattern", + "format": "format", + } + + format_type_mapping = { + "email": EmailStr, + "uri": HttpUrl, + "ipv4": IPvAnyAddress, + "ipv6": IPvAnyAddress, + "hostname": str, + "date": date, + "time": time, + "date-time": datetime, + } + + format_pattern_mapping = { + "hostname": r"^[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?)*$", } def from_properties(self, name, properties, required=False): mapped_properties = self.mappings_properties_builder(properties, required) + format_type = properties.get("format") + if format_type: + if format_type in self.format_type_mapping: + mapped_type = self.format_type_mapping[format_type] + if format_type in self.format_pattern_mapping: + mapped_properties["pattern"] = self.format_pattern_mapping[ + format_type + ] + else: + raise ValueError(f"Unsupported string format: {format_type}") + else: + mapped_type = str + default_value = properties.get("default") if default_value is not None: - self.validate_default(str, mapped_properties, default_value) + self.validate_default(mapped_type, mapped_properties, default_value) - return str, mapped_properties + return mapped_type, mapped_properties diff --git a/tests/parser/test_string_type_parser.py b/tests/parser/test_string_type_parser.py index 92161d0..f4dc3d6 100644 --- a/tests/parser/test_string_type_parser.py +++ b/tests/parser/test_string_type_parser.py @@ -1,5 +1,8 @@ from jambo.parser import StringTypeParser +from pydantic import EmailStr, HttpUrl, IPvAnyAddress + +from datetime import date, datetime, time from unittest import TestCase @@ -85,3 +88,112 @@ class TestStringTypeParser(TestCase): with self.assertRaises(ValueError): parser.from_properties("placeholder", properties) + + def test_string_parser_with_email_format(self): + parser = StringTypeParser() + + properties = { + "type": "string", + "format": "email", + } + + type_parsing, type_validator = parser.from_properties("placeholder", properties) + + self.assertEqual(type_parsing, EmailStr) + + def test_string_parser_with_uri_format(self): + parser = StringTypeParser() + + properties = { + "type": "string", + "format": "uri", + } + + type_parsing, type_validator = parser.from_properties("placeholder", properties) + + self.assertEqual(type_parsing, HttpUrl) + + def test_string_parser_with_ip_formats(self): + parser = StringTypeParser() + + for ip_format in ["ipv4", "ipv6"]: + properties = { + "type": "string", + "format": ip_format, + } + + type_parsing, type_validator = parser.from_properties( + "placeholder", properties + ) + + self.assertEqual(type_parsing, IPvAnyAddress) + + def test_string_parser_with_time_format(self): + parser = StringTypeParser() + + properties = { + "type": "string", + "format": "time", + } + + type_parsing, type_validator = parser.from_properties("placeholder", properties) + + self.assertEqual(type_parsing, time) + + def test_string_parser_with_pattern_based_formats(self): + parser = StringTypeParser() + + for format_type in ["hostname"]: + properties = { + "type": "string", + "format": format_type, + } + + type_parsing, type_validator = parser.from_properties( + "placeholder", properties + ) + + self.assertEqual(type_parsing, str) + self.assertIn("pattern", type_validator) + self.assertEqual( + type_validator["pattern"], parser.format_pattern_mapping[format_type] + ) + + def test_string_parser_with_unsupported_format(self): + parser = StringTypeParser() + + properties = { + "type": "string", + "format": "unsupported-format", + } + + with self.assertRaises(ValueError) as context: + parser.from_properties("placeholder", properties) + + self.assertEqual( + str(context.exception), "Unsupported string format: unsupported-format" + ) + + def test_string_parser_with_date_format(self): + parser = StringTypeParser() + + properties = { + "type": "string", + "format": "date", + } + + type_parsing, type_validator = parser.from_properties("placeholder", properties) + + self.assertEqual(type_parsing, date) + + def test_string_parser_with_datetime_format(self): + parser = StringTypeParser() + + properties = { + "type": "string", + "format": "date-time", + } + + type_parsing, type_validator = parser.from_properties("placeholder", properties) + + self.assertEqual(type_parsing, datetime) diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index 7f85d3f..c2f5395 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -1,7 +1,8 @@ from jambo import SchemaConverter -from pydantic import BaseModel +from pydantic import BaseModel, HttpUrl +from ipaddress import IPv4Address, IPv6Address from unittest import TestCase @@ -397,3 +398,99 @@ class TestSchemaConverter(TestCase): with self.assertRaises(ValueError): Model(id=11) + + def test_string_format_email(self): + schema = { + "title": "EmailTest", + "type": "object", + "properties": {"email": {"type": "string", "format": "email"}}, + } + model = SchemaConverter.build(schema) + self.assertEqual(model(email="test@example.com").email, "test@example.com") + with self.assertRaises(ValueError): + model(email="invalid-email") + + def test_string_format_uri(self): + schema = { + "title": "UriTest", + "type": "object", + "properties": {"website": {"type": "string", "format": "uri"}}, + } + model = SchemaConverter.build(schema) + self.assertEqual( + model(website="https://example.com").website, HttpUrl("https://example.com") + ) + with self.assertRaises(ValueError): + model(website="invalid-uri") + + def test_string_format_ipv4(self): + schema = { + "title": "IPv4Test", + "type": "object", + "properties": {"ip": {"type": "string", "format": "ipv4"}}, + } + model = SchemaConverter.build(schema) + self.assertEqual(model(ip="192.168.1.1").ip, IPv4Address("192.168.1.1")) + with self.assertRaises(ValueError): + model(ip="256.256.256.256") + + def test_string_format_ipv6(self): + schema = { + "title": "IPv6Test", + "type": "object", + "properties": {"ip": {"type": "string", "format": "ipv6"}}, + } + model = SchemaConverter.build(schema) + self.assertEqual( + model(ip="2001:0db8:85a3:0000:0000:8a2e:0370:7334").ip, + IPv6Address("2001:0db8:85a3:0000:0000:8a2e:0370:7334"), + ) + with self.assertRaises(ValueError): + model(ip="invalid-ipv6") + + def test_string_format_hostname(self): + schema = { + "title": "HostnameTest", + "type": "object", + "properties": {"hostname": {"type": "string", "format": "hostname"}}, + } + model = SchemaConverter.build(schema) + self.assertEqual(model(hostname="example.com").hostname, "example.com") + with self.assertRaises(ValueError): + model(hostname="invalid..hostname") + + def test_string_format_datetime(self): + schema = { + "title": "DateTimeTest", + "type": "object", + "properties": {"timestamp": {"type": "string", "format": "date-time"}}, + } + model = SchemaConverter.build(schema) + self.assertEqual( + model(timestamp="2024-01-01T12:00:00Z").timestamp.isoformat(), + "2024-01-01T12:00:00+00:00", + ) + with self.assertRaises(ValueError): + model(timestamp="invalid-datetime") + + def test_string_format_time(self): + schema = { + "title": "TimeTest", + "type": "object", + "properties": {"time": {"type": "string", "format": "time"}}, + } + model = SchemaConverter.build(schema) + self.assertEqual( + model(time="20:20:39+00:00").time.isoformat(), "20:20:39+00:00" + ) + with self.assertRaises(ValueError): + model(time="25:00:00") + + def test_string_format_unsupported(self): + schema = { + "title": "InvalidFormat", + "type": "object", + "properties": {"field": {"type": "string", "format": "unsupported"}}, + } + with self.assertRaises(ValueError): + SchemaConverter.build(schema)