Merge pull request #12 from PuChenTW/main
feat(parser): first‑class support for JSON string.format
This commit was merged in pull request #12.
This commit is contained in:
@@ -1,5 +1,9 @@
|
|||||||
from jambo.parser._type_parser import GenericTypeParser
|
from jambo.parser._type_parser import GenericTypeParser
|
||||||
|
|
||||||
|
from pydantic import EmailStr, HttpUrl, IPvAnyAddress
|
||||||
|
|
||||||
|
from datetime import date, datetime, time
|
||||||
|
|
||||||
|
|
||||||
class StringTypeParser(GenericTypeParser):
|
class StringTypeParser(GenericTypeParser):
|
||||||
mapped_type = str
|
mapped_type = str
|
||||||
@@ -10,13 +14,42 @@ class StringTypeParser(GenericTypeParser):
|
|||||||
"maxLength": "max_length",
|
"maxLength": "max_length",
|
||||||
"minLength": "min_length",
|
"minLength": "min_length",
|
||||||
"pattern": "pattern",
|
"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):
|
def from_properties(self, name, properties, required=False):
|
||||||
mapped_properties = self.mappings_properties_builder(properties, required)
|
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")
|
default_value = properties.get("default")
|
||||||
if default_value is not None:
|
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
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ readme = "README.md"
|
|||||||
|
|
||||||
# Project Dependencies
|
# Project Dependencies
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"email-validator>=2.2.0",
|
||||||
"jsonschema>=4.23.0",
|
"jsonschema>=4.23.0",
|
||||||
"pydantic>=2.10.6",
|
"pydantic>=2.10.6",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
from jambo.parser import StringTypeParser
|
from jambo.parser import StringTypeParser
|
||||||
|
|
||||||
|
from pydantic import EmailStr, HttpUrl, IPvAnyAddress
|
||||||
|
|
||||||
|
from datetime import date, datetime, time
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
|
|
||||||
@@ -85,3 +88,112 @@ class TestStringTypeParser(TestCase):
|
|||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
parser.from_properties("placeholder", properties)
|
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)
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
from jambo import SchemaConverter
|
from jambo import SchemaConverter
|
||||||
|
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, HttpUrl
|
||||||
|
|
||||||
|
from ipaddress import IPv4Address, IPv6Address
|
||||||
from unittest import TestCase
|
from unittest import TestCase
|
||||||
|
|
||||||
|
|
||||||
@@ -397,3 +398,99 @@ class TestSchemaConverter(TestCase):
|
|||||||
|
|
||||||
with self.assertRaises(ValueError):
|
with self.assertRaises(ValueError):
|
||||||
Model(id=11)
|
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)
|
||||||
|
|||||||
33
uv.lock
generated
33
uv.lock
generated
@@ -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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "exceptiongroup"
|
name = "exceptiongroup"
|
||||||
version = "1.2.2"
|
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 },
|
{ 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]]
|
[[package]]
|
||||||
name = "iniconfig"
|
name = "iniconfig"
|
||||||
version = "2.1.0"
|
version = "2.1.0"
|
||||||
@@ -152,6 +183,7 @@ wheels = [
|
|||||||
name = "jambo"
|
name = "jambo"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
{ name = "email-validator" },
|
||||||
{ name = "jsonschema" },
|
{ name = "jsonschema" },
|
||||||
{ name = "pydantic" },
|
{ name = "pydantic" },
|
||||||
]
|
]
|
||||||
@@ -168,6 +200,7 @@ dev = [
|
|||||||
|
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
|
{ name = "email-validator", specifier = ">=2.2.0" },
|
||||||
{ name = "jsonschema", specifier = ">=4.23.0" },
|
{ name = "jsonschema", specifier = ">=4.23.0" },
|
||||||
{ name = "pydantic", specifier = ">=2.10.6" },
|
{ name = "pydantic", specifier = ">=2.10.6" },
|
||||||
]
|
]
|
||||||
|
|||||||
Reference in New Issue
Block a user