diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..4263e09 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,66 @@ +name: Example + + +on: + push + +permissions: + contents: read + +jobs: + test: + name: run-tests + runs-on: ubuntu-latest + strategy: + matrix: + python-version: + - "3.10" + - "3.11" + - "3.12" + + + steps: + - uses: actions/checkout@v4 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + # Install a specific version of uv. + version: "0.6.14" + enable-cache: true + cache-dependency-glob: "uv.lock" + python-version: ${{ matrix.python-version }} + + - name: Install dependencies + run: uv sync --all-extras --dev + + - name: Run tests + run: uv run poe tests + + publish: + name: publish + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/v') + needs: [test] + + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Install uv + uses: astral-sh/setup-uv@v5 + with: + # Install a specific version of uv. + version: "0.6.14" + enable-cache: true + cache-dependency-glob: "uv.lock" + + - name: Set up Python + run: uv python install + + - name: Build + run: uv build + + - name: Publish + run: uv publish -t ${{ secrets.PYPI_TOKEN }} \ No newline at end of file diff --git a/jambo/types/__init__.py b/jambo/parser/__init__.py similarity index 100% rename from jambo/types/__init__.py rename to jambo/parser/__init__.py diff --git a/jambo/types/_type_parser.py b/jambo/parser/_type_parser.py similarity index 81% rename from jambo/types/_type_parser.py rename to jambo/parser/_type_parser.py index 2a76d01..16dd833 100644 --- a/jambo/types/_type_parser.py +++ b/jambo/parser/_type_parser.py @@ -1,5 +1,8 @@ from abc import ABC, abstractmethod -from typing import Generic, Self, TypeVar +from typing import Generic, TypeVar +from typing_extensions import Self + +from pydantic import Field T = TypeVar("T") @@ -17,7 +20,7 @@ class GenericTypeParser(ABC, Generic[T]): @abstractmethod def from_properties( name: str, properties: dict[str, any] - ) -> tuple[type[T], dict[str, any]]: ... + ) -> tuple[type[T], Field]: ... @classmethod def get_impl(cls, type_name: str) -> Self: diff --git a/jambo/parser/array_type_parser.py b/jambo/parser/array_type_parser.py new file mode 100644 index 0000000..e7f6a6e --- /dev/null +++ b/jambo/parser/array_type_parser.py @@ -0,0 +1,32 @@ +from jambo.parser._type_parser import GenericTypeParser + +from typing import TypeVar + +from jambo.utils.properties_builder.mappings_properties_builder import ( + mappings_properties_builder, +) + +V = TypeVar("V") + + +class ArrayTypeParser(GenericTypeParser): + mapped_type = list + + json_schema_type = "array" + + @classmethod + def from_properties(cls, name, properties): + _item_type, _item_args = GenericTypeParser.get_impl( + properties["items"]["type"] + ).from_properties(name, properties["items"]) + + _mappings = { + "maxItems": "max_length", + "minItems": "min_length", + } + + wrapper_type = set if properties.get("uniqueItems", False) else list + + return wrapper_type[_item_type], mappings_properties_builder( + properties, _mappings + ) diff --git a/jambo/types/boolean_type_parser.py b/jambo/parser/boolean_type_parser.py similarity index 56% rename from jambo/types/boolean_type_parser.py rename to jambo/parser/boolean_type_parser.py index 2af98fb..c549062 100644 --- a/jambo/types/boolean_type_parser.py +++ b/jambo/parser/boolean_type_parser.py @@ -1,4 +1,4 @@ -from jambo.types._type_parser import GenericTypeParser +from jambo.parser._type_parser import GenericTypeParser class BooleanTypeParser(GenericTypeParser): @@ -8,4 +8,4 @@ class BooleanTypeParser(GenericTypeParser): @staticmethod def from_properties(name, properties): - return bool, {} + return bool, {} # The second argument is not used in this case diff --git a/jambo/parser/float_type_parser.py b/jambo/parser/float_type_parser.py new file mode 100644 index 0000000..c303ff7 --- /dev/null +++ b/jambo/parser/float_type_parser.py @@ -0,0 +1,12 @@ +from jambo.parser._type_parser import GenericTypeParser +from jambo.utils.properties_builder.numeric_properties_builder import numeric_properties_builder + + +class FloatTypeParser(GenericTypeParser): + mapped_type = float + + json_schema_type = "number" + + @staticmethod + def from_properties(name, properties): + return float, numeric_properties_builder(properties) diff --git a/jambo/parser/int_type_parser.py b/jambo/parser/int_type_parser.py new file mode 100644 index 0000000..365e346 --- /dev/null +++ b/jambo/parser/int_type_parser.py @@ -0,0 +1,12 @@ +from jambo.parser._type_parser import GenericTypeParser +from jambo.utils.properties_builder.numeric_properties_builder import numeric_properties_builder + + +class IntTypeParser(GenericTypeParser): + mapped_type = int + + json_schema_type = "integer" + + @staticmethod + def from_properties(name, properties): + return int, numeric_properties_builder(properties) diff --git a/jambo/types/object_type_parser.py b/jambo/parser/object_type_parser.py similarity index 52% rename from jambo/types/object_type_parser.py rename to jambo/parser/object_type_parser.py index b63de81..b5e7f5d 100644 --- a/jambo/types/object_type_parser.py +++ b/jambo/parser/object_type_parser.py @@ -1,4 +1,4 @@ -from jambo.types._type_parser import GenericTypeParser +from jambo.parser._type_parser import GenericTypeParser class ObjectTypeParser(GenericTypeParser): @@ -10,5 +10,7 @@ class ObjectTypeParser(GenericTypeParser): def from_properties(name, properties): from jambo.schema_converter import SchemaConverter - _type = SchemaConverter.build_object(name, properties) - return _type, {} + return ( + SchemaConverter.build_object(name, properties), + {}, # The second argument is not used in this case + ) diff --git a/jambo/parser/string_type_parser.py b/jambo/parser/string_type_parser.py new file mode 100644 index 0000000..c6ac1e4 --- /dev/null +++ b/jambo/parser/string_type_parser.py @@ -0,0 +1,20 @@ +from jambo.parser._type_parser import GenericTypeParser +from jambo.utils.properties_builder.mappings_properties_builder import ( + mappings_properties_builder, +) + + +class StringTypeParser(GenericTypeParser): + mapped_type = str + + json_schema_type = "string" + + @staticmethod + def from_properties(name, properties): + _mappings = { + "maxLength": "max_length", + "minLength": "min_length", + "pattern": "pattern", + } + + return str, mappings_properties_builder(properties, _mappings) diff --git a/jambo/schema_converter.py b/jambo/schema_converter.py index e2f4fe9..676a78b 100644 --- a/jambo/schema_converter.py +++ b/jambo/schema_converter.py @@ -1,4 +1,4 @@ -from jambo.types import GenericTypeParser +from jambo.parser import GenericTypeParser from jsonschema.exceptions import SchemaError from jsonschema.protocols import Validator @@ -7,27 +7,47 @@ from pydantic.fields import Field from typing import Type +from jambo.types.json_schema_type import JSONSchema + class SchemaConverter: - @staticmethod - def build(schema): - try: - Validator.check_schema(schema) - except SchemaError as e: - raise ValueError(f"Invalid JSON Schema: {e}") + """ + Converts JSON Schema to Pydantic models. - if schema["type"] != "object": - raise TypeError( - f"Invalid JSON Schema: {schema['type']}. Only 'object' can be converted to Pydantic models." - ) + This class is responsible for converting JSON Schema definitions into Pydantic models. + It validates the schema and generates the corresponding Pydantic model with appropriate + fields and types. The generated model can be used for data validation and serialization. + """ + + @staticmethod + def build(schema: JSONSchema) -> Type: + """ + Converts a JSON Schema to a Pydantic model. + :param schema: The JSON Schema to convert. + :return: A Pydantic model class. + """ + if "title" not in schema: + raise ValueError("JSON Schema must have a title.") return SchemaConverter.build_object(schema["title"], schema) @staticmethod def build_object( name: str, - schema: dict, - ): + schema: JSONSchema, + ) -> Type: + """ + Converts a JSON Schema object to a Pydantic model given a name. + :param name: + :param schema: + :return: + """ + + try: + Validator.check_schema(schema) + except SchemaError as e: + raise ValueError(f"Invalid JSON Schema: {e}") + if schema["type"] != "object": raise TypeError( f"Invalid JSON Schema: {schema['type']}. Only 'object' can be converted to Pydantic models." @@ -60,7 +80,7 @@ class SchemaConverter: @staticmethod def _build_field( name, properties: dict, required_keys: list[str] - ) -> tuple[type, Field]: + ) -> tuple[type, dict]: _field_type, _field_args = GenericTypeParser.get_impl( properties["type"] ).from_properties(name, properties) diff --git a/jambo/types/array_type_parser.py b/jambo/types/array_type_parser.py deleted file mode 100644 index 328a6d4..0000000 --- a/jambo/types/array_type_parser.py +++ /dev/null @@ -1,19 +0,0 @@ -from jambo.types._type_parser import GenericTypeParser - -from typing import TypeVar - -V = TypeVar("V") - - -class ArrayTypeParser(GenericTypeParser): - mapped_type = list - - json_schema_type = "array" - - @classmethod - def from_properties(cls, name, properties): - _item_type, _item_args = GenericTypeParser.get_impl( - properties["items"]["type"] - ).from_properties(name, properties["items"]) - - return list[_item_type], {} diff --git a/jambo/types/float_type_parser.py b/jambo/types/float_type_parser.py deleted file mode 100644 index f10f3e3..0000000 --- a/jambo/types/float_type_parser.py +++ /dev/null @@ -1,11 +0,0 @@ -from jambo.types._type_parser import GenericTypeParser - - -class FloatTypeParser(GenericTypeParser): - mapped_type = float - - json_schema_type = "number" - - @staticmethod - def from_properties(name, properties): - return float, {} diff --git a/jambo/types/int_type_parser.py b/jambo/types/int_type_parser.py deleted file mode 100644 index 121bdd4..0000000 --- a/jambo/types/int_type_parser.py +++ /dev/null @@ -1,11 +0,0 @@ -from jambo.types._type_parser import GenericTypeParser - - -class IntTypeParser(GenericTypeParser): - mapped_type = int - - json_schema_type = "integer" - - @staticmethod - def from_properties(name, properties): - return int, {} diff --git a/jambo/types/json_schema_type.py b/jambo/types/json_schema_type.py new file mode 100644 index 0000000..77ec7ff --- /dev/null +++ b/jambo/types/json_schema_type.py @@ -0,0 +1,80 @@ +from typing import List, Dict, Union, TypedDict, Literal + + +JSONSchemaType = Literal[ + "string", "number", "integer", "boolean", "object", "array", "null" +] + + +JSONType = Union[str, int, float, bool, None, Dict[str, "JSONType"], List["JSONType"]] + + +class JSONSchema(TypedDict, total=False): + # Basic metadata + title: str + description: str + default: JSONType + examples: List[JSONType] + + # Type definitions + type: Union[JSONSchemaType, List[JSONSchemaType]] + + # Object-specific keywords + properties: Dict[str, "JSONSchema"] + required: List[str] + additionalProperties: Union[bool, "JSONSchema"] + minProperties: int + maxProperties: int + patternProperties: Dict[str, "JSONSchema"] + dependencies: Dict[str, Union[List[str], "JSONSchema"]] + + # Array-specific keywords + items: Union["JSONSchema", List["JSONSchema"]] + additionalItems: Union[bool, "JSONSchema"] + minItems: int + maxItems: int + uniqueItems: bool + + # String-specific keywords + minLength: int + maxLength: int + pattern: str + format: str + + # Number-specific keywords + minimum: float + maximum: float + exclusiveMinimum: float + exclusiveMaximum: float + multipleOf: float + + # Enum and const + enum: List[JSONType] + const: JSONType + + # Conditionals + if_: "JSONSchema" # 'if' is a reserved word in Python + then: "JSONSchema" + else_: "JSONSchema" # 'else' is also a reserved word + + # Combination keywords + allOf: List["JSONSchema"] + anyOf: List["JSONSchema"] + oneOf: List["JSONSchema"] + not_: "JSONSchema" # 'not' is a reserved word + + +# Fix forward references +JSONSchema.__annotations__["properties"] = Dict[str, JSONSchema] +JSONSchema.__annotations__["items"] = Union[JSONSchema, List[JSONSchema]] +JSONSchema.__annotations__["additionalItems"] = Union[bool, JSONSchema] +JSONSchema.__annotations__["additionalProperties"] = Union[bool, JSONSchema] +JSONSchema.__annotations__["patternProperties"] = Dict[str, JSONSchema] +JSONSchema.__annotations__["dependencies"] = Dict[str, Union[List[str], JSONSchema]] +JSONSchema.__annotations__["if_"] = JSONSchema +JSONSchema.__annotations__["then"] = JSONSchema +JSONSchema.__annotations__["else_"] = JSONSchema +JSONSchema.__annotations__["allOf"] = List[JSONSchema] +JSONSchema.__annotations__["anyOf"] = List[JSONSchema] +JSONSchema.__annotations__["oneOf"] = List[JSONSchema] +JSONSchema.__annotations__["not_"] = JSONSchema diff --git a/jambo/types/string_type_parser.py b/jambo/types/string_type_parser.py deleted file mode 100644 index d846eed..0000000 --- a/jambo/types/string_type_parser.py +++ /dev/null @@ -1,11 +0,0 @@ -from jambo.types._type_parser import GenericTypeParser - - -class StringTypeParser(GenericTypeParser): - mapped_type = str - - json_schema_type = "string" - - @staticmethod - def from_properties(name, properties): - return str, {} diff --git a/jambo/utils/__init__.py b/jambo/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jambo/utils/properties_builder/__init__.py b/jambo/utils/properties_builder/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/jambo/utils/properties_builder/mappings_properties_builder.py b/jambo/utils/properties_builder/mappings_properties_builder.py new file mode 100644 index 0000000..64a7a72 --- /dev/null +++ b/jambo/utils/properties_builder/mappings_properties_builder.py @@ -0,0 +1,4 @@ +def mappings_properties_builder(properties, mappings): + return { + mappings[key]: value for key, value in properties.items() if key in mappings + } diff --git a/jambo/utils/properties_builder/numeric_properties_builder.py b/jambo/utils/properties_builder/numeric_properties_builder.py new file mode 100644 index 0000000..ff512db --- /dev/null +++ b/jambo/utils/properties_builder/numeric_properties_builder.py @@ -0,0 +1,15 @@ +from jambo.utils.properties_builder.mappings_properties_builder import ( + mappings_properties_builder, +) + + +def numeric_properties_builder(properties): + _mappings = { + "minimum": "ge", + "exclusiveMinimum": "gt", + "maximum": "le", + "exclusiveMaximum": "lt", + "multipleOf": "multiple_of", + } + + return mappings_properties_builder(properties, _mappings) diff --git a/jambo/utils/types/__init__.py b/jambo/utils/types/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pyproject.toml b/pyproject.toml index 33a55b6..32fc4ea 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,7 +21,7 @@ dev = [ # POE Tasks [tool.poe.tasks] create-hooks = "bash .githooks/set-hooks.sh" - +tests = "python -m unittest discover -s tests -v" # Build System [tool.hatch.version] diff --git a/tests/test_schema_converter.py b/tests/test_schema_converter.py index d6403f8..c24d160 100644 --- a/tests/test_schema_converter.py +++ b/tests/test_schema_converter.py @@ -32,7 +32,13 @@ class TestSchemaConverter(TestCase): "description": "A person", "type": "object", "properties": { - "name": {"type": "string"}, + "name": {"type": "string", "maxLength": 4, "minLength": 1}, + "email": { + "type": "string", + "maxLength": 50, + "minLength": 5, + "pattern": r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$", + }, }, "required": ["name"], } @@ -41,13 +47,29 @@ class TestSchemaConverter(TestCase): self.assertEqual(model(name="John", age=30).name, "John") + with self.assertRaises(ValueError): + model(name=123, age=30, email="teste@hideyoshi.com") + + with self.assertRaises(ValueError): + model(name="John Invalid", age=45, email="teste@hideyoshi.com") + + with self.assertRaises(ValueError): + model(name="", age=45, email="teste@hideyoshi.com") + + with self.assertRaises(ValueError): + model(name="John", age=45, email="hideyoshi.com") + def test_validation_integer(self): schema = { "title": "Person", "description": "A person", "type": "object", "properties": { - "age": {"type": "integer"}, + "age": { + "type": "integer", + "minimum": 0, + "maximum": 120, + }, }, "required": ["age"], } @@ -56,7 +78,11 @@ class TestSchemaConverter(TestCase): self.assertEqual(model(age=30).age, 30) - self.assertEqual(model(age="30").age, 30) + with self.assertRaises(ValueError): + model(age=-1) + + with self.assertRaises(ValueError): + model(age=121) def test_validation_float(self): schema = { @@ -64,7 +90,11 @@ class TestSchemaConverter(TestCase): "description": "A person", "type": "object", "properties": { - "age": {"type": "number"}, + "age": { + "type": "number", + "minimum": 0, + "maximum": 120, + }, }, "required": ["age"], } @@ -73,7 +103,11 @@ class TestSchemaConverter(TestCase): self.assertEqual(model(age=30).age, 30.0) - self.assertEqual(model(age="30").age, 30.0) + with self.assertRaises(ValueError): + model(age=-1.0) + + with self.assertRaises(ValueError): + model(age=121.0) def test_validation_boolean(self): schema = { @@ -98,14 +132,28 @@ class TestSchemaConverter(TestCase): "description": "A person", "type": "object", "properties": { - "friends": {"type": "array", "items": {"type": "string"}}, + "friends": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 2, + "uniqueItems": True, + }, }, "required": ["friends"], } model = SchemaConverter.build(schema) - self.assertEqual(model(friends=["John", "Jane"]).friends, ["John", "Jane"]) + self.assertEqual( + model(friends=["John", "Jane", "John"]).friends, {"John", "Jane"} + ) + + with self.assertRaises(ValueError): + model(friends=[]) + + with self.assertRaises(ValueError): + model(friends=["John", "Jane", "Invalid"]) def test_validation_object(self): schema = { diff --git a/tests/test_type_parser.py b/tests/test_type_parser.py index b3a1886..cee9818 100644 --- a/tests/test_type_parser.py +++ b/tests/test_type_parser.py @@ -1,4 +1,4 @@ -from jambo.types import ( +from jambo.parser import ( ArrayTypeParser, FloatTypeParser, GenericTypeParser, @@ -21,21 +21,65 @@ class TestTypeParser(unittest.TestCase): def test_int_parser(self): parser = IntTypeParser() - expected_definition = (int, {}) - self.assertEqual(parser.from_properties("placeholder", {}), expected_definition) + type_parsing, type_validator = parser.from_properties( + "placeholder", + { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": 1, + "maximum": 10, + "exclusiveMaximum": 11, + "multipleOf": 2, + }, + ) + + self.assertEqual(type_parsing, int) + self.assertEqual(type_validator["ge"], 0) + self.assertEqual(type_validator["gt"], 1) + self.assertEqual(type_validator["le"], 10) + self.assertEqual(type_validator["lt"], 11) + self.assertEqual(type_validator["multiple_of"], 2) def test_float_parser(self): parser = FloatTypeParser() - expected_definition = (float, {}) - self.assertEqual(parser.from_properties("placeholder", {}), expected_definition) + type_parsing, type_validator = parser.from_properties( + "placeholder", + { + "type": "number", + "minimum": 0, + "exclusiveMinimum": 1, + "maximum": 10, + "exclusiveMaximum": 11, + "multipleOf": 2, + }, + ) + + self.assertEqual(type_parsing, float) + self.assertEqual(type_validator["ge"], 0) + self.assertEqual(type_validator["gt"], 1) + self.assertEqual(type_validator["le"], 10) + self.assertEqual(type_validator["lt"], 11) + self.assertEqual(type_validator["multiple_of"], 2) def test_string_parser(self): parser = StringTypeParser() - expected_definition = (str, {}) - self.assertEqual(parser.from_properties("placeholder", {}), expected_definition) + type_parsing, type_validator = parser.from_properties( + "placeholder", + { + "type": "string", + "maxLength": 10, + "minLength": 1, + "pattern": "[a-zA-Z0-9]", + }, + ) + + self.assertEqual(type_parsing, str) + self.assertEqual(type_validator["max_length"], 10) + self.assertEqual(type_validator["min_length"], 1) + self.assertEqual(type_validator["pattern"], "[a-zA-Z0-9]") def test_object_parser(self): parser = ObjectTypeParser() @@ -69,18 +113,26 @@ class TestTypeParser(unittest.TestCase): parser = ArrayTypeParser() properties = { + "type": "array", "items": { "type": "object", "properties": { "name": {"type": "string"}, "age": {"type": "integer"}, }, - } + }, + "maxItems": 10, + "minItems": 1, + "uniqueItems": True, } - _type, _args = parser.from_properties("placeholder", properties) + type_parsing, type_validator = parser.from_properties("placeholder", properties) - Model = get_args(_type)[0] + self.assertEqual(type_parsing.__origin__, set) + self.assertEqual(type_validator["max_length"], 10) + self.assertEqual(type_validator["min_length"], 1) + + Model = get_args(type_parsing)[0] obj = Model(name="name", age=10) self.assertEqual(obj.name, "name")