diff --git a/docs/source/usage.const.rst b/docs/source/usage.const.rst new file mode 100644 index 0000000..9fd75a4 --- /dev/null +++ b/docs/source/usage.const.rst @@ -0,0 +1,40 @@ +Const Type +================= + +The const type is a special data type that allows a variable to be a single, fixed value. +It does not have the same properties as the other generic types, but it has the following specific properties: + +- const: The fixed value that the variable must always hold. +- description: Description of the const field. + + +Examples +----------------- + + +.. code-block:: python + + from jambo import SchemaConverter + + + schema = { + "title": "Country", + "type": "object", + "properties": { + "name": { + "const": "United States of America", + } + }, + "required": ["name"], + } + + Model = SchemaConverter.build(schema) + + obj = Model() + self.assertEqual(obj.name, "United States of America") + + with self.assertRaises(ValueError): + obj.name = "Canada" + + with self.assertRaises(ValueError): + Model(name="Canada") \ No newline at end of file diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 2b9855f..8896842 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -45,4 +45,5 @@ For more complex schemas and types see our documentation on usage.reference usage.allof usage.anyof - usage.enum \ No newline at end of file + usage.enum + usage.const \ No newline at end of file diff --git a/jambo/parser/__init__.py b/jambo/parser/__init__.py index 9c63244..a953057 100644 --- a/jambo/parser/__init__.py +++ b/jambo/parser/__init__.py @@ -3,6 +3,7 @@ from .allof_type_parser import AllOfTypeParser from .anyof_type_parser import AnyOfTypeParser from .array_type_parser import ArrayTypeParser from .boolean_type_parser import BooleanTypeParser +from .const_type_parser import ConstTypeParser from .enum_type_parser import EnumTypeParser from .float_type_parser import FloatTypeParser from .int_type_parser import IntTypeParser @@ -14,6 +15,7 @@ from .string_type_parser import StringTypeParser __all__ = [ "GenericTypeParser", "EnumTypeParser", + "ConstTypeParser", "AllOfTypeParser", "AnyOfTypeParser", "ArrayTypeParser", diff --git a/jambo/parser/const_type_parser.py b/jambo/parser/const_type_parser.py new file mode 100644 index 0000000..b5c846f --- /dev/null +++ b/jambo/parser/const_type_parser.py @@ -0,0 +1,43 @@ +from jambo.parser._type_parser import GenericTypeParser +from jambo.types.json_schema_type import JSONSchemaNativeTypes +from jambo.types.type_parser_options import TypeParserOptions + +from pydantic import AfterValidator +from typing_extensions import Annotated, Any, Unpack + + +class ConstTypeParser(GenericTypeParser): + json_schema_type = "const" + + default_mappings = { + "const": "default", + "description": "description", + } + + def from_properties_impl( + self, name, properties, **kwargs: Unpack[TypeParserOptions] + ): + if "const" not in properties: + raise ValueError(f"Const type {name} must have 'const' property defined.") + + const_value = properties["const"] + + if not isinstance(const_value, JSONSchemaNativeTypes): + raise ValueError( + f"Const type {name} must have 'const' value of allowed types: {JSONSchemaNativeTypes}." + ) + + const_type = self._build_const_type(const_value) + parsed_properties = self.mappings_properties_builder(properties, **kwargs) + + return const_type, parsed_properties + + def _build_const_type(self, const_value): + def _validate_const_value(value: Any) -> Any: + if value != const_value: + raise ValueError( + f"Value must be equal to the constant value: {const_value}" + ) + return value + + return Annotated[type(const_value), AfterValidator(_validate_const_value)] diff --git a/jambo/parser/enum_type_parser.py b/jambo/parser/enum_type_parser.py index 45480af..5ea9e67 100644 --- a/jambo/parser/enum_type_parser.py +++ b/jambo/parser/enum_type_parser.py @@ -1,4 +1,5 @@ from jambo.parser._type_parser import GenericTypeParser +from jambo.types.json_schema_type import JSONSchemaNativeTypes from jambo.types.type_parser_options import TypeParserOptions from typing_extensions import Unpack @@ -9,16 +10,6 @@ from enum import Enum class EnumTypeParser(GenericTypeParser): json_schema_type = "enum" - allowed_types: tuple[type] = ( - str, - int, - float, - bool, - list, - set, - type(None), - ) - def from_properties_impl( self, name, properties, **kwargs: Unpack[TypeParserOptions] ): @@ -31,10 +22,10 @@ class EnumTypeParser(GenericTypeParser): raise ValueError(f"Enum type {name} must have 'enum' as a list of values.") if any( - not isinstance(value, self.allowed_types) for value in enum_values + not isinstance(value, JSONSchemaNativeTypes) for value in enum_values ): raise ValueError( - f"Enum type {name} must have 'enum' values of allowed types: {self.allowed_types}." + f"Enum type {name} must have 'enum' values of allowed types: {JSONSchemaNativeTypes}." ) # Create a new Enum type dynamically diff --git a/jambo/parser/object_type_parser.py b/jambo/parser/object_type_parser.py index 6833d40..8deb5ac 100644 --- a/jambo/parser/object_type_parser.py +++ b/jambo/parser/object_type_parser.py @@ -1,7 +1,7 @@ from jambo.parser._type_parser import GenericTypeParser from jambo.types.type_parser_options import TypeParserOptions -from pydantic import BaseModel, Field, create_model +from pydantic import BaseModel, ConfigDict, Field, create_model from typing_extensions import Any, Unpack @@ -43,8 +43,10 @@ class ObjectTypeParser(GenericTypeParser): :param required_keys: List of required keys in the schema. :return: A Pydantic model class. """ + model_config = ConfigDict(validate_assignment=True) fields = cls._parse_properties(schema, required_keys, **kwargs) - return create_model(name, **fields) + + return create_model(name, __config__=model_config, **fields) @classmethod def _parse_properties( diff --git a/jambo/types/json_schema_type.py b/jambo/types/json_schema_type.py index c9c1ffc..6f61837 100644 --- a/jambo/types/json_schema_type.py +++ b/jambo/types/json_schema_type.py @@ -1,11 +1,24 @@ from typing_extensions import Dict, List, Literal, TypedDict, Union +from types import NoneType + JSONSchemaType = Literal[ "string", "number", "integer", "boolean", "object", "array", "null" ] +JSONSchemaNativeTypes: tuple[type, ...] = ( + str, + int, + float, + bool, + list, + set, + NoneType, +) + + JSONType = Union[str, int, float, bool, None, Dict[str, "JSONType"], List["JSONType"]] diff --git a/tests/parser/test_const_type_parser.py b/tests/parser/test_const_type_parser.py new file mode 100644 index 0000000..ca92bb0 --- /dev/null +++ b/tests/parser/test_const_type_parser.py @@ -0,0 +1,49 @@ +from jambo.parser import ConstTypeParser + +from typing_extensions import Annotated, get_args, get_origin + +from unittest import TestCase + + +class TestConstTypeParser(TestCase): + def test_const_type_parser(self): + parser = ConstTypeParser() + + expected_const_value = "United States of America" + properties = {"const": expected_const_value} + + parsed_type, parsed_properties = parser.from_properties_impl( + "country", properties + ) + + self.assertEqual(get_origin(parsed_type), Annotated) + self.assertIn(str, get_args(parsed_type)) + + self.assertEqual(parsed_properties["default"], expected_const_value) + + def test_const_type_parser_invalid_properties(self): + parser = ConstTypeParser() + + expected_const_value = "United States of America" + properties = {"notConst": expected_const_value} + + with self.assertRaises(ValueError) as context: + parser.from_properties_impl("invalid_country", properties) + + self.assertIn( + "Const type invalid_country must have 'const' property defined", + str(context.exception), + ) + + def test_const_type_parser_invalid_const_value(self): + parser = ConstTypeParser() + + properties = {"const": {}} + + with self.assertRaises(ValueError) as context: + parser.from_properties_impl("invalid_country", properties) + + self.assertIn( + "Const type invalid_country must have 'const' value of allowed types", + str(context.exception), + ) diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index a980e3c..fbba3c9 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -634,3 +634,26 @@ class TestSchemaConverter(TestCase): obj = Model() self.assertEqual(obj.status.value, "active") + + def test_const_type_parser(self): + schema = { + "title": "Country", + "type": "object", + "properties": { + "name": { + "const": "United States of America", + } + }, + "required": ["name"], + } + + Model = SchemaConverter.build(schema) + + obj = Model() + self.assertEqual(obj.name, "United States of America") + + with self.assertRaises(ValueError): + obj.name = "Canada" + + with self.assertRaises(ValueError): + Model(name="Canada")