From 43ce95cc9aa06f797daaa568450503a992a2cb59 Mon Sep 17 00:00:00 2001 From: JCHacking Date: Mon, 17 Nov 2025 23:41:16 +0100 Subject: [PATCH] feat(examples): Add examples for primitive types Refs: #52 --- jambo/parser/_type_parser.py | 25 +++++++ jambo/parser/const_type_parser.py | 1 + jambo/parser/enum_type_parser.py | 5 ++ jambo/parser/string_type_parser.py | 31 ++++++++ tests/parser/test_bool_type_parser.py | 16 +++++ tests/parser/test_const_type_parser.py | 12 ++-- tests/parser/test_enum_type_parser.py | 24 +++++++ tests/parser/test_float_type_parser.py | 2 + tests/parser/test_int_type_parser.py | 2 + tests/parser/test_null_type_parser.py | 16 +++++ tests/parser/test_string_type_parser.py | 96 ++++++++++++++++++++++++- 11 files changed, 223 insertions(+), 7 deletions(-) diff --git a/jambo/parser/_type_parser.py b/jambo/parser/_type_parser.py index cb7885e..33d067d 100644 --- a/jambo/parser/_type_parser.py +++ b/jambo/parser/_type_parser.py @@ -18,6 +18,7 @@ class GenericTypeParser(ABC, Generic[T]): default_mappings = { "default": "default", "description": "description", + "examples": "examples", } @abstractmethod @@ -51,6 +52,11 @@ class GenericTypeParser(ABC, Generic[T]): "Default value is not valid", invalid_field=name ) + if not self._validate_default(parsed_type, parsed_properties): + raise InvalidSchemaException( + "Examples values are not valid", invalid_field=name + ) + return parsed_type, parsed_properties @classmethod @@ -127,3 +133,22 @@ class GenericTypeParser(ABC, Generic[T]): return False return True + + @staticmethod + def _validate_examples(field_type: T, field_prop: dict) -> bool: + values = field_prop.get("examples") + + if values is None: + return True + + if not isinstance(values, list): + return False + + try: + field = Annotated[field_type, Field(**field_prop)] # type: ignore + for value in values: + TypeAdapter(field).validate_python(value) + except Exception as _: + return False + + return True diff --git a/jambo/parser/const_type_parser.py b/jambo/parser/const_type_parser.py index 76c6893..2548e68 100644 --- a/jambo/parser/const_type_parser.py +++ b/jambo/parser/const_type_parser.py @@ -13,6 +13,7 @@ class ConstTypeParser(GenericTypeParser): default_mappings = { "const": "default", "description": "description", + "examples": "examples", } def from_properties_impl( diff --git a/jambo/parser/enum_type_parser.py b/jambo/parser/enum_type_parser.py index f0b001f..3d0fee3 100644 --- a/jambo/parser/enum_type_parser.py +++ b/jambo/parser/enum_type_parser.py @@ -41,4 +41,9 @@ class EnumTypeParser(GenericTypeParser): if "default" in parsed_properties and parsed_properties["default"] is not None: parsed_properties["default"] = enum_type(parsed_properties["default"]) + if "examples" in parsed_properties: + parsed_properties["examples"] = [ + enum_type(example) for example in parsed_properties["examples"] + ] + return enum_type, parsed_properties diff --git a/jambo/parser/string_type_parser.py b/jambo/parser/string_type_parser.py index 0cd3aa1..4f970a7 100644 --- a/jambo/parser/string_type_parser.py +++ b/jambo/parser/string_type_parser.py @@ -7,6 +7,7 @@ from typing_extensions import Unpack from datetime import date, datetime, time, timedelta from ipaddress import IPv4Address, IPv6Address +from typing import Any from uuid import UUID @@ -62,8 +63,38 @@ class StringTypeParser(GenericTypeParser): if format_type in self.format_pattern_mapping: mapped_properties["pattern"] = self.format_pattern_mapping[format_type] + print("A") + if "examples" in mapped_properties: + mapped_properties["examples"] = [ + self.__parse_example(example, format_type, mapped_type) + for example in mapped_properties["examples"] + ] + if "json_schema_extra" not in mapped_properties: mapped_properties["json_schema_extra"] = {} mapped_properties["json_schema_extra"]["format"] = format_type return mapped_type, mapped_properties + + def __parse_example( + self, example: Any, format_type: str, mapped_type: type[Any] + ) -> Any: + """ + Parse example from JSON Schema format to python format + :param example: Example Value + :param format_type: Format Type + :param mapped_type: Type to parse + :return: Example parsed + """ + match format_type: + case "date" | "time" | "date-time": + return mapped_type.fromisoformat(example) + case "duration": + # TODO: Implement duration parser + raise NotImplementedError + case "ipv4" | "ipv6": + return mapped_type(example) + case "uuid": + return mapped_type(example) + case _: + return example diff --git a/tests/parser/test_bool_type_parser.py b/tests/parser/test_bool_type_parser.py index 2c2be6c..6e7d6de 100644 --- a/tests/parser/test_bool_type_parser.py +++ b/tests/parser/test_bool_type_parser.py @@ -42,3 +42,19 @@ class TestBoolTypeParser(TestCase): with self.assertRaises(InvalidSchemaException): parser.from_properties_impl("placeholder", properties) + + def test_bool_parser_with_examples(self): + parser = BooleanTypeParser() + + properties = { + "type": "boolean", + "examples": [True, False], + } + + type_parsing, type_validator = parser.from_properties_impl( + "placeholder", properties + ) + + self.assertEqual(type_parsing, bool) + self.assertEqual(type_validator["default"], None) + self.assertEqual(type_validator["examples"], [True, False]) diff --git a/tests/parser/test_const_type_parser.py b/tests/parser/test_const_type_parser.py index 5e0fc29..9b8dae4 100644 --- a/tests/parser/test_const_type_parser.py +++ b/tests/parser/test_const_type_parser.py @@ -12,7 +12,7 @@ class TestConstTypeParser(TestCase): parser = ConstTypeParser() expected_const_value = "United States of America" - properties = {"const": expected_const_value} + properties = {"const": expected_const_value, "examples": [expected_const_value]} parsed_type, parsed_properties = parser.from_properties_impl( "country", properties @@ -23,13 +23,14 @@ class TestConstTypeParser(TestCase): self.assertEqual(get_args(parsed_type), (expected_const_value,)) self.assertEqual(parsed_properties["default"], expected_const_value) + self.assertEqual(parsed_properties["examples"], [expected_const_value]) def test_const_type_parser_non_hashable_value(self): """Test const parser with non-hashable values (uses Annotated with validator)""" parser = ConstTypeParser() expected_const_value = [1, 2, 3] # Lists are not hashable - properties = {"const": expected_const_value} + properties = {"const": expected_const_value, "examples": [expected_const_value]} parsed_type, parsed_properties = parser.from_properties_impl( "list_const", properties @@ -40,13 +41,14 @@ class TestConstTypeParser(TestCase): self.assertIn(list, get_args(parsed_type)) self.assertEqual(parsed_properties["default"], expected_const_value) + self.assertEqual(parsed_properties["examples"], [expected_const_value]) def test_const_type_parser_integer_value(self): """Test const parser with integer values (uses Literal)""" parser = ConstTypeParser() expected_const_value = 42 - properties = {"const": expected_const_value} + properties = {"const": expected_const_value, "examples": [expected_const_value]} parsed_type, parsed_properties = parser.from_properties_impl( "int_const", properties @@ -57,13 +59,14 @@ class TestConstTypeParser(TestCase): self.assertEqual(get_args(parsed_type), (expected_const_value,)) self.assertEqual(parsed_properties["default"], expected_const_value) + self.assertEqual(parsed_properties["examples"], [expected_const_value]) def test_const_type_parser_boolean_value(self): """Test const parser with boolean values (uses Literal)""" parser = ConstTypeParser() expected_const_value = True - properties = {"const": expected_const_value} + properties = {"const": expected_const_value, "examples": [expected_const_value]} parsed_type, parsed_properties = parser.from_properties_impl( "bool_const", properties @@ -74,6 +77,7 @@ class TestConstTypeParser(TestCase): self.assertEqual(get_args(parsed_type), (expected_const_value,)) self.assertEqual(parsed_properties["default"], expected_const_value) + self.assertEqual(parsed_properties["examples"], [expected_const_value]) def test_const_type_parser_invalid_properties(self): parser = ConstTypeParser() diff --git a/tests/parser/test_enum_type_parser.py b/tests/parser/test_enum_type_parser.py index dafb9d8..7665007 100644 --- a/tests/parser/test_enum_type_parser.py +++ b/tests/parser/test_enum_type_parser.py @@ -89,3 +89,27 @@ class TestEnumTypeParser(TestCase): with self.assertRaises(InvalidSchemaException): parser.from_properties_impl("TestEnum", schema) + + def test_enum_type_parser_creates_enum_with_examples(self): + parser = EnumTypeParser() + + schema = { + "enum": ["value1", "value2", "value3"], + "examples": ["value1", "value3"], + } + + parsed_type, parsed_properties = parser.from_properties_impl( + "TestEnum", + schema, + ) + + self.assertIsInstance(parsed_type, type) + self.assertTrue(issubclass(parsed_type, Enum)) + self.assertEqual( + set(parsed_type.__members__.keys()), {"VALUE1", "VALUE2", "VALUE3"} + ) + self.assertEqual(parsed_properties["default"], None) + self.assertEqual( + parsed_properties["examples"], + [getattr(parsed_type, "VALUE1"), getattr(parsed_type, "VALUE3")], + ) diff --git a/tests/parser/test_float_type_parser.py b/tests/parser/test_float_type_parser.py index 1bdd65a..8bcf6e0 100644 --- a/tests/parser/test_float_type_parser.py +++ b/tests/parser/test_float_type_parser.py @@ -23,6 +23,7 @@ class TestFloatTypeParser(TestCase): "maximum": 10.5, "minimum": 1.0, "multipleOf": 0.5, + "examples": [1.5, 2.5], } type_parsing, type_validator = parser.from_properties("placeholder", properties) @@ -31,6 +32,7 @@ class TestFloatTypeParser(TestCase): self.assertEqual(type_validator["le"], 10.5) self.assertEqual(type_validator["ge"], 1.0) self.assertEqual(type_validator["multiple_of"], 0.5) + self.assertEqual(type_validator["examples"], [1.5, 2.5]) def test_float_parser_with_default(self): parser = FloatTypeParser() diff --git a/tests/parser/test_int_type_parser.py b/tests/parser/test_int_type_parser.py index fa563f4..9038642 100644 --- a/tests/parser/test_int_type_parser.py +++ b/tests/parser/test_int_type_parser.py @@ -23,6 +23,7 @@ class TestIntTypeParser(TestCase): "maximum": 10, "minimum": 1, "multipleOf": 2, + "examples": [2, 4], } type_parsing, type_validator = parser.from_properties("placeholder", properties) @@ -31,6 +32,7 @@ class TestIntTypeParser(TestCase): self.assertEqual(type_validator["le"], 10) self.assertEqual(type_validator["ge"], 1) self.assertEqual(type_validator["multiple_of"], 2) + self.assertEqual(type_validator["examples"], [2, 4]) def test_int_parser_with_default(self): parser = IntTypeParser() diff --git a/tests/parser/test_null_type_parser.py b/tests/parser/test_null_type_parser.py index e2732c0..f44b49f 100644 --- a/tests/parser/test_null_type_parser.py +++ b/tests/parser/test_null_type_parser.py @@ -16,6 +16,22 @@ class TestNullTypeParser(TestCase): self.assertEqual(type_parsing, type(None)) self.assertEqual(type_validator, {"default": None}) + def test_null_parser_with_examples(self): + parser = NullTypeParser() + + properties = { + "type": "null", + "examples": [None], + } + + type_parsing, type_validator = parser.from_properties_impl( + "placeholder", properties + ) + + self.assertEqual(type_parsing, type(None)) + self.assertEqual(type_validator["default"], None) + self.assertEqual(type_validator["examples"], [None]) + def test_null_parser_with_invalid_default(self): parser = NullTypeParser() diff --git a/tests/parser/test_string_type_parser.py b/tests/parser/test_string_type_parser.py index ac42145..1db5765 100644 --- a/tests/parser/test_string_type_parser.py +++ b/tests/parser/test_string_type_parser.py @@ -3,8 +3,8 @@ from jambo.parser import StringTypeParser from pydantic import AnyUrl, EmailStr -from datetime import date, datetime, time, timedelta -from ipaddress import IPv4Address, IPv6Address +from datetime import date, datetime, time, timedelta, timezone +from ipaddress import IPv4Address, IPv6Address, ip_address from unittest import TestCase from uuid import UUID @@ -27,6 +27,7 @@ class TestStringTypeParser(TestCase): "maxLength": 10, "minLength": 1, "pattern": "^[a-zA-Z]+$", + "examples": ["test", "TEST"], } type_parsing, type_validator = parser.from_properties("placeholder", properties) @@ -35,6 +36,7 @@ class TestStringTypeParser(TestCase): self.assertEqual(type_validator["max_length"], 10) self.assertEqual(type_validator["min_length"], 1) self.assertEqual(type_validator["pattern"], "^[a-zA-Z]+$") + self.assertEqual(type_validator["examples"], ["test", "TEST"]) def test_string_parser_with_default_value(self): parser = StringTypeParser() @@ -98,11 +100,13 @@ class TestStringTypeParser(TestCase): properties = { "type": "string", "format": "email", + "examples": ["test@example.com"], } type_parsing, type_validator = parser.from_properties("placeholder", properties) self.assertEqual(type_parsing, EmailStr) + self.assertEqual(type_validator["examples"], ["test@example.com"]) def test_string_parser_with_uri_format(self): parser = StringTypeParser() @@ -110,21 +114,27 @@ class TestStringTypeParser(TestCase): properties = { "type": "string", "format": "uri", + "examples": ["test://domain/resource"], } type_parsing, type_validator = parser.from_properties("placeholder", properties) self.assertEqual(type_parsing, AnyUrl) + self.assertEqual(type_validator["examples"], ["test://domain/resource"]) def test_string_parser_with_ip_formats(self): parser = StringTypeParser() formats = {"ipv4": IPv4Address, "ipv6": IPv6Address} + examples = {"ipv4": "192.168.1.1", "ipv6": "::1"} for ip_format, expected_type in formats.items(): + example = examples[ip_format] + properties = { "type": "string", "format": ip_format, + "examples": [example], } type_parsing, type_validator = parser.from_properties( @@ -132,6 +142,7 @@ class TestStringTypeParser(TestCase): ) self.assertEqual(type_parsing, expected_type) + self.assertEqual(type_validator["examples"], [ip_address(example)]) def test_string_parser_with_uuid_format(self): parser = StringTypeParser() @@ -139,11 +150,15 @@ class TestStringTypeParser(TestCase): properties = { "type": "string", "format": "uuid", + "examples": ["ab71aaf4-ab6e-43cd-a369-cebdd9f7a4c6"], } type_parsing, type_validator = parser.from_properties("placeholder", properties) self.assertEqual(type_parsing, UUID) + self.assertEqual( + type_validator["examples"], [UUID("ab71aaf4-ab6e-43cd-a369-cebdd9f7a4c6")] + ) def test_string_parser_with_time_format(self): parser = StringTypeParser() @@ -151,19 +166,34 @@ class TestStringTypeParser(TestCase): properties = { "type": "string", "format": "time", + "examples": ["14:30:00", "09:15:30.500", "23:59:59Z", "10:00:00+02:00"], } type_parsing, type_validator = parser.from_properties("placeholder", properties) self.assertEqual(type_parsing, time) + self.assertEqual( + type_validator["examples"], + [ + time(hour=14, minute=30, second=0), + time(hour=9, minute=15, second=30, microsecond=500_000), + time(hour=23, minute=59, second=59, tzinfo=timezone.utc), + time(hour=10, minute=0, second=0, tzinfo=timezone(timedelta(hours=2))), + ], + ) def test_string_parser_with_pattern_based_formats(self): parser = StringTypeParser() - for format_type in ["hostname"]: + format_types = { + "hostname": "hostname_example", + } + + for format_type, example_type in format_types.items(): properties = { "type": "string", "format": format_type, + "examples": [example_type], } type_parsing, type_validator = parser.from_properties( @@ -175,6 +205,7 @@ class TestStringTypeParser(TestCase): self.assertEqual( type_validator["pattern"], parser.format_pattern_mapping[format_type] ) + self.assertEqual(type_validator["examples"], [example_type]) def test_string_parser_with_unsupported_format(self): parser = StringTypeParser() @@ -198,11 +229,20 @@ class TestStringTypeParser(TestCase): properties = { "type": "string", "format": "date", + "examples": ["2025-11-17", "1999-12-31", "2000-01-01"], } type_parsing, type_validator = parser.from_properties("placeholder", properties) self.assertEqual(type_parsing, date) + self.assertEqual( + type_validator["examples"], + [ + date(year=2025, month=11, day=17), + date(year=1999, month=12, day=31), + date(year=2000, month=1, day=1), + ], + ) def test_string_parser_with_datetime_format(self): parser = StringTypeParser() @@ -210,11 +250,51 @@ class TestStringTypeParser(TestCase): properties = { "type": "string", "format": "date-time", + "examples": [ + "2025-11-17T11:15:00", + "2025-11-17T11:15:00Z", + "2025-11-17T11:15:00+01:00", + "2025-11-17T11:15:00.123456-05:00", + ], } type_parsing, type_validator = parser.from_properties("placeholder", properties) self.assertEqual(type_parsing, datetime) + self.assertEqual( + type_validator["examples"], + [ + datetime(year=2025, month=11, day=17, hour=11, minute=15, second=0), + datetime( + year=2025, + month=11, + day=17, + hour=11, + minute=15, + second=0, + tzinfo=timezone.utc, + ), + datetime( + year=2025, + month=11, + day=17, + hour=11, + minute=15, + second=0, + tzinfo=timezone(timedelta(hours=1)), + ), + datetime( + year=2025, + month=11, + day=17, + hour=11, + minute=15, + second=0, + microsecond=123456, + tzinfo=timezone(timedelta(hours=-5)), + ), + ], + ) def test_string_parser_with_timedelta_format(self): parser = StringTypeParser() @@ -222,8 +302,18 @@ class TestStringTypeParser(TestCase): properties = { "type": "string", "format": "duration", + "examples": ["P1Y2M3DT4H5M6S", "PT30M", "P7D", "PT0.5S"], } type_parsing, type_validator = parser.from_properties("placeholder", properties) self.assertEqual(type_parsing, timedelta) + self.assertEqual( + type_validator["examples"], + [ + timedelta(days=7), + timedelta(minutes=30), + timedelta(hours=4, minutes=5, seconds=6), + timedelta(seconds=0.5), + ], + )