Merge pull request #1 from HideyoshiNakazone/initial-fields-validators

Initial Fields Validators for:
- string
- number
- integer
- boolean
- object
- array
This commit was merged in pull request #1.
This commit is contained in:
2025-04-09 23:22:48 -03:00
committed by GitHub
23 changed files with 405 additions and 91 deletions

66
.github/workflows/build.yml vendored Normal file
View File

@@ -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 }}

View File

@@ -1,5 +1,8 @@
from abc import ABC, abstractmethod 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") T = TypeVar("T")
@@ -17,7 +20,7 @@ class GenericTypeParser(ABC, Generic[T]):
@abstractmethod @abstractmethod
def from_properties( def from_properties(
name: str, properties: dict[str, any] name: str, properties: dict[str, any]
) -> tuple[type[T], dict[str, any]]: ... ) -> tuple[type[T], Field]: ...
@classmethod @classmethod
def get_impl(cls, type_name: str) -> Self: def get_impl(cls, type_name: str) -> Self:

View File

@@ -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
)

View File

@@ -1,4 +1,4 @@
from jambo.types._type_parser import GenericTypeParser from jambo.parser._type_parser import GenericTypeParser
class BooleanTypeParser(GenericTypeParser): class BooleanTypeParser(GenericTypeParser):
@@ -8,4 +8,4 @@ class BooleanTypeParser(GenericTypeParser):
@staticmethod @staticmethod
def from_properties(name, properties): def from_properties(name, properties):
return bool, {} return bool, {} # The second argument is not used in this case

View File

@@ -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)

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
from jambo.types._type_parser import GenericTypeParser from jambo.parser._type_parser import GenericTypeParser
class ObjectTypeParser(GenericTypeParser): class ObjectTypeParser(GenericTypeParser):
@@ -10,5 +10,7 @@ class ObjectTypeParser(GenericTypeParser):
def from_properties(name, properties): def from_properties(name, properties):
from jambo.schema_converter import SchemaConverter from jambo.schema_converter import SchemaConverter
_type = SchemaConverter.build_object(name, properties) return (
return _type, {} SchemaConverter.build_object(name, properties),
{}, # The second argument is not used in this case
)

View File

@@ -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)

View File

@@ -1,4 +1,4 @@
from jambo.types import GenericTypeParser from jambo.parser import GenericTypeParser
from jsonschema.exceptions import SchemaError from jsonschema.exceptions import SchemaError
from jsonschema.protocols import Validator from jsonschema.protocols import Validator
@@ -7,27 +7,47 @@ from pydantic.fields import Field
from typing import Type from typing import Type
from jambo.types.json_schema_type import JSONSchema
class SchemaConverter: class SchemaConverter:
@staticmethod """
def build(schema): Converts JSON Schema to Pydantic models.
try:
Validator.check_schema(schema)
except SchemaError as e:
raise ValueError(f"Invalid JSON Schema: {e}")
if schema["type"] != "object": This class is responsible for converting JSON Schema definitions into Pydantic models.
raise TypeError( It validates the schema and generates the corresponding Pydantic model with appropriate
f"Invalid JSON Schema: {schema['type']}. Only 'object' can be converted to Pydantic models." 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) return SchemaConverter.build_object(schema["title"], schema)
@staticmethod @staticmethod
def build_object( def build_object(
name: str, 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": if schema["type"] != "object":
raise TypeError( raise TypeError(
f"Invalid JSON Schema: {schema['type']}. Only 'object' can be converted to Pydantic models." f"Invalid JSON Schema: {schema['type']}. Only 'object' can be converted to Pydantic models."
@@ -60,7 +80,7 @@ class SchemaConverter:
@staticmethod @staticmethod
def _build_field( def _build_field(
name, properties: dict, required_keys: list[str] name, properties: dict, required_keys: list[str]
) -> tuple[type, Field]: ) -> tuple[type, dict]:
_field_type, _field_args = GenericTypeParser.get_impl( _field_type, _field_args = GenericTypeParser.get_impl(
properties["type"] properties["type"]
).from_properties(name, properties) ).from_properties(name, properties)

View File

@@ -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], {}

View File

@@ -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, {}

View File

@@ -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, {}

View File

@@ -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

View File

@@ -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, {}

0
jambo/utils/__init__.py Normal file
View File

View File

@@ -0,0 +1,4 @@
def mappings_properties_builder(properties, mappings):
return {
mappings[key]: value for key, value in properties.items() if key in mappings
}

View File

@@ -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)

View File

View File

@@ -21,7 +21,7 @@ dev = [
# POE Tasks # POE Tasks
[tool.poe.tasks] [tool.poe.tasks]
create-hooks = "bash .githooks/set-hooks.sh" create-hooks = "bash .githooks/set-hooks.sh"
tests = "python -m unittest discover -s tests -v"
# Build System # Build System
[tool.hatch.version] [tool.hatch.version]

View File

@@ -32,7 +32,13 @@ class TestSchemaConverter(TestCase):
"description": "A person", "description": "A person",
"type": "object", "type": "object",
"properties": { "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"], "required": ["name"],
} }
@@ -41,13 +47,29 @@ class TestSchemaConverter(TestCase):
self.assertEqual(model(name="John", age=30).name, "John") 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): def test_validation_integer(self):
schema = { schema = {
"title": "Person", "title": "Person",
"description": "A person", "description": "A person",
"type": "object", "type": "object",
"properties": { "properties": {
"age": {"type": "integer"}, "age": {
"type": "integer",
"minimum": 0,
"maximum": 120,
},
}, },
"required": ["age"], "required": ["age"],
} }
@@ -56,7 +78,11 @@ class TestSchemaConverter(TestCase):
self.assertEqual(model(age=30).age, 30) 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): def test_validation_float(self):
schema = { schema = {
@@ -64,7 +90,11 @@ class TestSchemaConverter(TestCase):
"description": "A person", "description": "A person",
"type": "object", "type": "object",
"properties": { "properties": {
"age": {"type": "number"}, "age": {
"type": "number",
"minimum": 0,
"maximum": 120,
},
}, },
"required": ["age"], "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)
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): def test_validation_boolean(self):
schema = { schema = {
@@ -98,14 +132,28 @@ class TestSchemaConverter(TestCase):
"description": "A person", "description": "A person",
"type": "object", "type": "object",
"properties": { "properties": {
"friends": {"type": "array", "items": {"type": "string"}}, "friends": {
"type": "array",
"items": {"type": "string"},
"minItems": 1,
"maxItems": 2,
"uniqueItems": True,
},
}, },
"required": ["friends"], "required": ["friends"],
} }
model = SchemaConverter.build(schema) 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): def test_validation_object(self):
schema = { schema = {

View File

@@ -1,4 +1,4 @@
from jambo.types import ( from jambo.parser import (
ArrayTypeParser, ArrayTypeParser,
FloatTypeParser, FloatTypeParser,
GenericTypeParser, GenericTypeParser,
@@ -21,21 +21,65 @@ class TestTypeParser(unittest.TestCase):
def test_int_parser(self): def test_int_parser(self):
parser = IntTypeParser() 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): def test_float_parser(self):
parser = FloatTypeParser() 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): def test_string_parser(self):
parser = StringTypeParser() 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): def test_object_parser(self):
parser = ObjectTypeParser() parser = ObjectTypeParser()
@@ -69,18 +113,26 @@ class TestTypeParser(unittest.TestCase):
parser = ArrayTypeParser() parser = ArrayTypeParser()
properties = { properties = {
"type": "array",
"items": { "items": {
"type": "object", "type": "object",
"properties": { "properties": {
"name": {"type": "string"}, "name": {"type": "string"},
"age": {"type": "integer"}, "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) obj = Model(name="name", age=10)
self.assertEqual(obj.name, "name") self.assertEqual(obj.name, "name")