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

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

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

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

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
[tool.poe.tasks]
create-hooks = "bash .githooks/set-hooks.sh"
tests = "python -m unittest discover -s tests -v"
# Build System
[tool.hatch.version]

View File

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

View File

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