diff --git a/README.md b/README.md index 3c571e8..9de8a41 100644 --- a/README.md +++ b/README.md @@ -688,6 +688,115 @@ cross-field validator will not run: schema({'password': '123', 'password_again': 1337}) ``` +## Dataclasses Support + +*Requires Python 3.7+ for dataclasses support.* + +Voluptuous provides built-in support for Python dataclasses, allowing you to automatically create schemas from dataclass definitions with optional additional validation constraints. + +### Basic Dataclass Schema + +```python +from dataclasses import dataclass +from voluptuous import DataclassSchema + +@dataclass +class Person: + name: str + age: int + active: bool = True + +# Create schema from dataclass +schema = DataclassSchema(Person) + +# Validate and create dataclass instance +result = schema({'name': 'John Doe', 'age': 30, 'active': False}) +# Returns: Person(name='John Doe', age=30, active=False) +``` + +### Dataclass with Additional Constraints + +You can add validation constraints on top of the basic type checking: + +```python +from voluptuous import DataclassSchema, All, Length, Range, Email + +@dataclass +class User: + username: str + email: str + age: int = 18 + +# Add validation constraints +schema = DataclassSchema(User, { + 'username': All(str, Length(min=3, max=20)), + 'email': Email(), + 'age': Range(min=13, max=120) +}) + +result = schema({ + 'username': 'john_doe', + 'email': 'john@example.com', + 'age': 25 +}) +# Returns: User(username='john_doe', email='john@example.com', age=25) +``` + +### Nested Dataclass Validation + +Dataclass schemas can be nested for complex data structures: + +```python +@dataclass +class Address: + street: str + city: str + zipcode: str + +@dataclass +class Person: + name: str + address: dict # Will be validated as Address + +address_schema = DataclassSchema(Address) +person_schema = DataclassSchema(Person, { + 'address': address_schema +}) + +result = person_schema({ + 'name': 'Alice', + 'address': { + 'street': '123 Main St', + 'city': 'Anytown', + 'zipcode': '12345' + } +}) +# Returns: Person with nested Address dataclass instance +``` + +### Alternative Function API + +You can also use the `create_dataclass_schema` function: + +```python +from voluptuous import create_dataclass_schema + +schema = create_dataclass_schema(Person, { + 'name': Length(min=1, max=100), + 'age': Range(min=0, max=150) +}) +``` + +### Key Features + +- **Automatic type validation** from dataclass field annotations +- **Default value support** including `default_factory` functions +- **Seamless integration** with existing voluptuous validators +- **Type annotation handling** for `List[T]`, `Optional[T]`, etc. +- **Full backward compatibility** with existing voluptuous schemas + +For more examples, see `examples/dataclasses_example.py`. + ## Running tests Voluptuous is using `pytest`: diff --git a/examples/dataclasses_example.py b/examples/dataclasses_example.py new file mode 100644 index 0000000..e606ddd --- /dev/null +++ b/examples/dataclasses_example.py @@ -0,0 +1,377 @@ +#!/usr/bin/env python3 +""" +Dataclasses Support Examples for Voluptuous + +This script demonstrates how to use the new dataclasses support functionality +in voluptuous to automatically create schemas from Python dataclasses. + +Requires Python 3.7+ for dataclasses support. +""" + +import sys +from dataclasses import dataclass, field +from typing import List, Optional + +# Check if we have dataclasses support +if sys.version_info < (3, 7): + print("This example requires Python 3.7+ for dataclasses support") + sys.exit(1) + +from voluptuous import ( + All, + Any, + DataclassSchema, + Email, + In, + Length, + Match, + Range, + Schema, + create_dataclass_schema, + is_dataclass, +) + + +def example_basic_dataclass(): + """Demonstrate basic dataclass schema creation.""" + print("=== Basic Dataclass Schema ===") + + @dataclass + class Person: + name: str + age: int + active: bool = True + + # Create schema from dataclass + schema = DataclassSchema(Person) + + # Test with valid data + result = schema({'name': 'John Doe', 'age': 30, 'active': False}) + + print(f"Result: {result}") + print(f"Type: {type(result)}") + print(f"Name: {result.name}") + print(f"Age: {result.age}") + print(f"Active: {result.active}") + print() + + # Test with defaults + result2 = schema({'name': 'Jane Smith', 'age': 25}) + print(f"With defaults: {result2}") + print(f"Active (default): {result2.active}") + print() + + +def example_dataclass_with_constraints(): + """Demonstrate dataclass with additional validation constraints.""" + print("=== Dataclass with Validation Constraints ===") + + @dataclass + class User: + username: str + email: str + age: int = 18 + bio: str = "" + + # Create schema with additional constraints + schema = DataclassSchema( + User, + { + 'username': All(str, Length(min=3, max=20), Match(r'^[a-zA-Z0-9_]+$')), + 'email': Email(), + 'age': Range(min=13, max=120), + 'bio': All(str, Length(max=500)), + }, + ) + + # Test valid data + result = schema( + { + 'username': 'john_doe', + 'email': 'john@example.com', + 'age': 25, + 'bio': 'Software developer with 5 years of experience.', + } + ) + + print(f"Valid user: {result}") + print() + + # Test validation errors + try: + schema({'username': 'jo', 'email': 'john@example.com', 'age': 25}) # Too short + except Exception as e: + print(f"Validation error (short username): {e}") + + try: + schema( + { + 'username': 'john_doe', + 'email': 'invalid-email', # Invalid email + 'age': 25, + } + ) + except Exception as e: + print(f"Validation error (invalid email): {e}") + + print() + + +def example_nested_dataclasses(): + """Demonstrate nested dataclass validation.""" + print("=== Nested Dataclass Validation ===") + + @dataclass + class Address: + street: str + city: str + zipcode: str + country: str = "USA" + + @dataclass + class Person: + name: str + age: int + address: dict # Will be validated as Address + + # Create schemas + address_schema = DataclassSchema( + Address, {'zipcode': Match(r'^\d{5}(-\d{4})?$')} # US zipcode format + ) + + person_schema = DataclassSchema( + Person, + { + 'name': Length(min=1, max=100), + 'age': Range(min=0, max=150), + 'address': address_schema, + }, + ) + + # Test nested validation + result = person_schema( + { + 'name': 'Alice Johnson', + 'age': 28, + 'address': { + 'street': '123 Main St', + 'city': 'Anytown', + 'zipcode': '12345', + 'country': 'USA', + }, + } + ) + + print(f"Person: {result}") + print(f"Address type: {type(result.address)}") + print(f"Street: {result.address.street}") + print(f"City: {result.address.city}") + print() + + +def example_dataclass_with_lists(): + """Demonstrate dataclass with list fields.""" + print("=== Dataclass with List Fields ===") + + @dataclass + class Project: + name: str + description: str + tags: List[str] = field(default_factory=list) + team_members: List[str] = field(default_factory=list) + + schema = DataclassSchema( + Project, + { + 'name': All(str, Length(min=1, max=100)), + 'description': All(str, Length(min=10, max=1000)), + 'tags': [All(str, Length(min=1))], # List of non-empty strings + 'team_members': [All(str, Length(min=1))], # List of non-empty strings + }, + ) + + # Test with list data + result = schema( + { + 'name': 'Awesome Project', + 'description': 'This is a really awesome project that does amazing things.', + 'tags': ['python', 'web', 'api'], + 'team_members': ['Alice', 'Bob', 'Charlie'], + } + ) + + print(f"Project: {result}") + print(f"Tags: {result.tags}") + print(f"Team: {result.team_members}") + print() + + # Test with defaults + result2 = schema( + { + 'name': 'Simple Project', + 'description': 'A simple project with minimal requirements.', + } + ) + + print(f"Project with defaults: {result2}") + print(f"Default tags: {result2.tags}") + print(f"Default team: {result2.team_members}") + print() + + +def example_create_dataclass_schema_function(): + """Demonstrate the create_dataclass_schema function.""" + print("=== Using create_dataclass_schema Function ===") + + @dataclass + class Product: + name: str + price: float + category: str + in_stock: bool = True + description: Optional[str] = None + + # Use the standalone function + schema = create_dataclass_schema( + Product, + { + 'name': All(str, Length(min=1, max=200)), + 'price': All(float, Range(min=0)), + 'category': In(['electronics', 'books', 'clothing', 'home', 'sports']), + 'description': Any(None, All(str, Length(min=10, max=1000))), + }, + ) + + # Test the schema + result = schema( + { + 'name': 'Wireless Headphones', + 'price': 99.99, + 'category': 'electronics', + 'in_stock': True, + 'description': 'High-quality wireless headphones with noise cancellation.', + } + ) + + print(f"Product: {result}") + print(f"Price: ${result.price}") + print(f"In stock: {result.in_stock}") + print() + + +def example_dataclass_detection(): + """Demonstrate dataclass detection functionality.""" + print("=== Dataclass Detection ===") + + @dataclass + class DataclassExample: + field1: str + field2: int + + class RegularClass: + def __init__(self, field1, field2): + self.field1 = field1 + self.field2 = field2 + + # Test detection + print(f"DataclassExample is dataclass: {is_dataclass(DataclassExample)}") + print( + f"DataclassExample instance is dataclass: {is_dataclass(DataclassExample('test', 1))}" + ) + print(f"RegularClass is dataclass: {is_dataclass(RegularClass)}") + print( + f"RegularClass instance is dataclass: {is_dataclass(RegularClass('test', 1))}" + ) + print(f"str is dataclass: {is_dataclass(str)}") + print(f"'hello' is dataclass: {is_dataclass('hello')}") + print() + + +def example_complex_validation(): + """Demonstrate complex validation scenarios.""" + print("=== Complex Validation Scenarios ===") + + @dataclass + class APIConfig: + name: str + version: str + debug: bool = False + max_connections: int = 100 + allowed_hosts: List[str] = field(default_factory=list) + database_url: Optional[str] = None + + schema = DataclassSchema( + APIConfig, + { + 'name': All(str, Length(min=1, max=50), Match(r'^[a-zA-Z][a-zA-Z0-9_-]*$')), + 'version': Match(r'^\d+\.\d+\.\d+$'), # Semantic versioning + 'max_connections': Range(min=1, max=10000), + 'allowed_hosts': [All(str, Length(min=1))], + 'database_url': Any(None, All(str, Length(min=1))), + }, + ) + + # Test complex configuration + result = schema( + { + 'name': 'my-awesome-api', + 'version': '1.2.3', + 'debug': True, + 'max_connections': 500, + 'allowed_hosts': ['localhost', '127.0.0.1', 'api.example.com'], + 'database_url': 'postgresql://user:pass@localhost:5432/mydb', + } + ) + + print(f"API Config: {result}") + print(f"Debug mode: {result.debug}") + print(f"Allowed hosts: {result.allowed_hosts}") + print() + + +def example_backward_compatibility(): + """Demonstrate that dataclass support doesn't break existing functionality.""" + print("=== Backward Compatibility ===") + + # Regular voluptuous schema still works + regular_schema = Schema( + {'name': str, 'age': Range(min=0, max=150), 'email': Email()} + ) + + result = regular_schema( + {'name': 'John Doe', 'age': 30, 'email': 'john@example.com'} + ) + + print(f"Regular schema result: {result}") + print(f"Regular schema type: {type(result)}") + print() + + +def main(): + """Run all examples.""" + print("Voluptuous Dataclasses Support Examples") + print("=" * 50) + print() + + example_basic_dataclass() + example_dataclass_with_constraints() + example_nested_dataclasses() + example_dataclass_with_lists() + example_create_dataclass_schema_function() + example_dataclass_detection() + example_complex_validation() + example_backward_compatibility() + + print("=" * 50) + print("All examples completed successfully!") + print() + print("Key benefits of dataclasses support:") + print("- Automatic schema generation from dataclass definitions") + print("- Type safety with automatic type validation") + print("- Seamless integration with existing voluptuous validators") + print("- Support for default values and default factories") + print("- Full backward compatibility with existing voluptuous features") + + +if __name__ == "__main__": + main() diff --git a/voluptuous/__init__.py b/voluptuous/__init__.py index d030b35..f2cb653 100644 --- a/voluptuous/__init__.py +++ b/voluptuous/__init__.py @@ -80,6 +80,32 @@ from voluptuous.util import * from voluptuous.validators import * +# Dataclasses support (Python 3.7+) +try: + from voluptuous.dataclasses_support import ( + DataclassSchema, + create_dataclass_schema, + is_dataclass, + ) +except ImportError: + # Dataclasses not available, define dummy functions + import typing + + class DataclassSchema: # type: ignore + def __init__(self, *args: typing.Any, **kwargs: typing.Any) -> None: + raise ImportError("Dataclasses support requires Python 3.7+") + + def create_dataclass_schema( + dataclass_type: typing.Type[typing.Any], + additional_constraints: typing.Optional[typing.Dict[str, typing.Any]] = None, + required: bool = False, + extra: typing.Any = None + ) -> "Schema": + raise ImportError("Dataclasses support requires Python 3.7+") + + def is_dataclass(obj: typing.Any) -> bool: + return False + from voluptuous.error import * # isort: skip # fmt: on diff --git a/voluptuous/dataclasses_support.py b/voluptuous/dataclasses_support.py new file mode 100644 index 0000000..86d3835 --- /dev/null +++ b/voluptuous/dataclasses_support.py @@ -0,0 +1,321 @@ +"""Dataclasses support for voluptuous schemas. + +This module provides functionality to automatically create voluptuous schemas +from Python dataclasses, with support for additional validation constraints. + +Requires Python 3.7+ for dataclasses support. +""" + +import sys +from typing import Any, Dict, Optional, Type + +from voluptuous.schema_builder import UNDEFINED +from voluptuous.schema_builder import Optional as OptionalMarker +from voluptuous.schema_builder import Required as RequiredMarker +from voluptuous.schema_builder import Schema +from voluptuous.validators import All + +# Check if dataclasses is available (Python 3.7+) +if sys.version_info >= (3, 7): + try: + import dataclasses + from dataclasses import MISSING, Field + + DATACLASSES_AVAILABLE = True + except ImportError: + DATACLASSES_AVAILABLE = False + dataclasses = None # type: ignore + Field = None # type: ignore + MISSING = None # type: ignore +else: + DATACLASSES_AVAILABLE = False + dataclasses = None # type: ignore + Field = None # type: ignore + MISSING = None # type: ignore + + +def is_dataclass(obj: Any) -> bool: + """Check if an object is a dataclass. + + Args: + obj: Object to check + + Returns: + True if obj is a dataclass, False otherwise + """ + if not DATACLASSES_AVAILABLE: + return False + return dataclasses.is_dataclass(obj) + + +def get_dataclass_fields(dataclass_type: Type) -> Dict[str, Any]: + """Extract field information from a dataclass. + + Args: + dataclass_type: The dataclass type to extract fields from + + Returns: + Dictionary mapping field names to their basic types + + Raises: + ValueError: If the type is not a dataclass + """ + if not DATACLASSES_AVAILABLE: + raise ValueError("Dataclasses are not available (requires Python 3.7+)") + + if not is_dataclass(dataclass_type): + raise ValueError(f"{dataclass_type} is not a dataclass") + + fields: Dict[str, Any] = {} + for field_name, field_info in dataclass_type.__dataclass_fields__.items(): + field_type = field_info.type + + # Convert typing annotations to basic types for voluptuous + # For complex types like List[str], Optional[str], etc., we'll use the base type + # and let additional constraints handle the specifics + if hasattr(field_type, '__origin__'): + # Handle generic types like List[str], Optional[str], etc. + origin = field_type.__origin__ + if origin is list: + fields[field_name] = list + elif origin is dict: + fields[field_name] = dict + elif origin is set: + fields[field_name] = set + elif origin is tuple: + fields[field_name] = tuple + elif ( + hasattr(field_type, '__args__') + and len(field_type.__args__) == 2 + and type(None) in field_type.__args__ + ): + # Optional[T] is Union[T, None] + non_none_type = next( + arg for arg in field_type.__args__ if arg is not type(None) + ) + fields[field_name] = non_none_type + else: + # For other generic types, use the origin + fields[field_name] = origin + else: + # Simple type, use as-is + fields[field_name] = field_type + + return fields + + +def get_dataclass_field_defaults(dataclass_type: Type) -> Dict[str, Any]: + """Extract default values from dataclass fields. + + Args: + dataclass_type: The dataclass type to extract defaults from + + Returns: + Dictionary mapping field names to their default values + """ + if not DATACLASSES_AVAILABLE: + return {} + + if not is_dataclass(dataclass_type): + return {} + + defaults = {} + for field_name, field_info in dataclass_type.__dataclass_fields__.items(): + if field_info.default is not MISSING: + defaults[field_name] = field_info.default + elif field_info.default_factory is not MISSING: + defaults[field_name] = field_info.default_factory + + return defaults + + +def merge_constraints(base_constraint: Any, additional_constraint: Any) -> Any: + """Merge two validation constraints using All validator. + + Args: + base_constraint: The base constraint (usually a type) + additional_constraint: Additional constraint to apply + + Returns: + Combined constraint using All validator + """ + return All(base_constraint, additional_constraint) + + +def merge_schema_constraints( + base_schema: Dict[str, Any], additional_schema: Dict[str, Any] +) -> Dict[str, Any]: + """Merge additional validation constraints into a base schema. + + Args: + base_schema: Base schema dictionary (from dataclass fields) + additional_schema: Additional constraints to merge + + Returns: + Merged schema dictionary + """ + result = {} + + # Start with all base schema fields + for key, value in base_schema.items(): + if key in additional_schema: + # Merge constraints for fields that have additional validation + result[key] = merge_constraints(value, additional_schema[key]) + else: + # Keep base constraint as-is + result[key] = value + + # Add any additional fields that weren't in the base schema + for key, value in additional_schema.items(): + if key not in base_schema: + result[key] = value + + return result + + +def create_dataclass_schema( + dataclass_type: Type, + additional_constraints: Optional[Dict[str, Any]] = None, + required: bool = False, + extra: Any = UNDEFINED, +) -> Schema: + """Create a voluptuous Schema from a dataclass. + + Args: + dataclass_type: The dataclass type to create a schema for + additional_constraints: Optional additional validation constraints + required: Whether all fields should be required by default + extra: How to handle extra fields (ALLOW_EXTRA, PREVENT_EXTRA, etc.) + + Returns: + A voluptuous Schema that validates the dataclass + + Raises: + ValueError: If dataclass_type is not a dataclass or dataclasses unavailable + + Example: + Create a schema from a dataclass with additional constraints: + + @dataclass + class Person: + name: str + age: int = 0 + + schema = create_dataclass_schema( + Person, + {'age': Range(min=0, max=150)} + ) + result = schema({'name': 'John', 'age': 30}) + """ + if not DATACLASSES_AVAILABLE: + raise ValueError("Dataclasses are not available (requires Python 3.7+)") + + if not is_dataclass(dataclass_type): + raise ValueError(f"{dataclass_type} is not a dataclass") + + # Extract field types from dataclass + base_schema = get_dataclass_fields(dataclass_type) + + # Merge with additional constraints if provided + if additional_constraints: + schema_dict = merge_schema_constraints(base_schema, additional_constraints) + else: + schema_dict = base_schema.copy() + + # Convert to proper voluptuous schema with markers + voluptuous_schema: Dict[Any, Any] = {} + + for field_name, constraint in schema_dict.items(): + field_info = dataclass_type.__dataclass_fields__.get(field_name) + + if field_info and field_info.default is not MISSING: + # Field has a default value - make it Optional + voluptuous_schema[ + OptionalMarker(field_name, default=field_info.default) + ] = constraint + elif field_info and field_info.default_factory is not MISSING: + # Field has a default factory - make it Optional + voluptuous_schema[ + OptionalMarker(field_name, default=field_info.default_factory) + ] = constraint + elif field_name in base_schema: + # Field is from dataclass but has no default - make it Required + voluptuous_schema[RequiredMarker(field_name)] = constraint + else: + # Additional field not in dataclass - use as-is + voluptuous_schema[field_name] = constraint + + # Create a custom validator that validates dict and creates dataclass instance + def dataclass_validator(data): + # First validate the dictionary structure + if extra is not UNDEFINED: + dict_schema = Schema(voluptuous_schema, required=required, extra=extra) + else: + dict_schema = Schema(voluptuous_schema, required=required) + validated_data = dict_schema(data) + + # Extract the actual values (removing marker wrappers) + clean_data = {} + for key, value in validated_data.items(): + clean_data[key] = value + + # Create and return dataclass instance + return dataclass_type(**clean_data) + + # Return a schema with the custom validator + return Schema(dataclass_validator) + + +class DataclassSchema(Schema): + """A Schema subclass that automatically handles dataclasses. + + This class provides a convenient way to create schemas from dataclasses + with additional validation constraints. + + Example: + Create a schema from a dataclass with additional constraints: + + @dataclass + class Person: + name: str + age: int = 0 + + schema = DataclassSchema(Person, {'age': Range(min=0, max=150)}) + result = schema({'name': 'John', 'age': 30}) + """ + + def __init__( + self, + dataclass_type: Type, + additional_constraints: Optional[Dict[str, Any]] = None, + required: bool = False, + extra: Any = UNDEFINED, + ): + """Initialize a DataclassSchema. + + Args: + dataclass_type: The dataclass type to create a schema for + additional_constraints: Optional additional validation constraints + required: Whether all fields should be required by default + extra: How to handle extra fields + """ + if not DATACLASSES_AVAILABLE: + raise ValueError("Dataclasses are not available (requires Python 3.7+)") + + if not is_dataclass(dataclass_type): + raise ValueError(f"{dataclass_type} is not a dataclass") + + self.dataclass_type = dataclass_type + self.additional_constraints = additional_constraints or {} + + # Create the schema using the helper function + schema = create_dataclass_schema( + dataclass_type, additional_constraints, required, extra + ) + + # Initialize parent Schema with the created schema + super().__init__( + schema.schema, + required=required, + extra=extra if extra is not UNDEFINED else schema.extra, + ) diff --git a/voluptuous/tests/test_dataclasses.py b/voluptuous/tests/test_dataclasses.py new file mode 100644 index 0000000..73eae42 --- /dev/null +++ b/voluptuous/tests/test_dataclasses.py @@ -0,0 +1,458 @@ +"""Tests for dataclasses support in voluptuous.""" + +import sys + +import pytest + +from voluptuous import ( + All, + Email, + In, + Length, + Match, + MultipleInvalid, + Optional, + Range, + Required, + Schema, +) + +# Skip all tests if dataclasses not available +pytestmark = pytest.mark.skipif( + sys.version_info < (3, 7), reason="Dataclasses require Python 3.7+" +) + +# Import dataclasses and voluptuous dataclass support +if sys.version_info >= (3, 7): + from dataclasses import dataclass, field + + from voluptuous import DataclassSchema, create_dataclass_schema, is_dataclass +else: + # Stub imports for older Python versions + def dataclass(cls): + return cls + + def field(**kwargs): + return None + + +class TestDataclassDetection: + """Test dataclass detection functionality.""" + + def test_is_dataclass_with_dataclass(self): + """Test is_dataclass returns True for dataclasses.""" + + @dataclass + class Person: + name: str + age: int + + assert is_dataclass(Person) is True + assert is_dataclass(Person('John', 30)) is True + + def test_is_dataclass_with_regular_class(self): + """Test is_dataclass returns False for regular classes.""" + + class Person: + def __init__(self, name, age): + self.name = name + self.age = age + + assert is_dataclass(Person) is False + assert is_dataclass(Person('John', 30)) is False + + def test_is_dataclass_with_primitive_types(self): + """Test is_dataclass returns False for primitive types.""" + assert is_dataclass(str) is False + assert is_dataclass(int) is False + assert is_dataclass("hello") is False + assert is_dataclass(42) is False + + +class TestBasicDataclassSchema: + """Test basic dataclass schema functionality.""" + + def test_simple_dataclass_schema(self): + """Test creating schema from simple dataclass.""" + + @dataclass + class Person: + name: str + age: int + + schema = DataclassSchema(Person) + + # Test valid data + result = schema({'name': 'John', 'age': 30}) + assert isinstance(result, Person) + assert result.name == 'John' + assert result.age == 30 + + def test_dataclass_with_defaults(self): + """Test dataclass with default values.""" + + @dataclass + class Person: + name: str + age: int = 0 + active: bool = True + + schema = DataclassSchema(Person) + + # Test with all fields + result = schema({'name': 'John', 'age': 30, 'active': False}) + assert result.name == 'John' + assert result.age == 30 + assert result.active is False + + # Test with defaults + result = schema({'name': 'Jane'}) + assert result.name == 'Jane' + assert result.age == 0 + assert result.active is True + + def test_dataclass_with_default_factory(self): + """Test dataclass with default_factory.""" + + @dataclass + class Person: + name: str + tags: list = field(default_factory=list) + + schema = DataclassSchema(Person) + + # Test with explicit tags + result = schema({'name': 'John', 'tags': ['developer', 'python']}) + assert result.name == 'John' + assert result.tags == ['developer', 'python'] + + # Test with default factory + result = schema({'name': 'Jane'}) + assert result.name == 'Jane' + assert result.tags == [] + + def test_type_validation(self): + """Test that dataclass field types are validated.""" + + @dataclass + class Person: + name: str + age: int + + schema = DataclassSchema(Person) + + # Test invalid types + with pytest.raises(MultipleInvalid): + schema({'name': 123, 'age': 'thirty'}) + + with pytest.raises(MultipleInvalid): + schema({'name': 'John', 'age': 'thirty'}) + + def test_missing_required_fields(self): + """Test validation fails for missing required fields.""" + + @dataclass + class Person: + name: str + age: int + + schema = DataclassSchema(Person) + + with pytest.raises(MultipleInvalid): + schema({'name': 'John'}) # Missing age + + with pytest.raises(MultipleInvalid): + schema({'age': 30}) # Missing name + + +class TestDataclassSchemaWithConstraints: + """Test dataclass schemas with additional validation constraints.""" + + def test_additional_constraints(self): + """Test adding validation constraints to dataclass fields.""" + + @dataclass + class Person: + name: str + age: int + email: str + + schema = DataclassSchema( + Person, + { + 'name': Length(min=1, max=50), + 'age': Range(min=0, max=150), + 'email': Email(), + }, + ) + + # Test valid data + result = schema({'name': 'John Doe', 'age': 30, 'email': 'john@example.com'}) + assert result.name == 'John Doe' + assert result.age == 30 + assert result.email == 'john@example.com' + + # Test constraint violations + with pytest.raises(MultipleInvalid): + schema({'name': '', 'age': 30, 'email': 'john@example.com'}) # Empty name + + with pytest.raises(MultipleInvalid): + schema( + {'name': 'John', 'age': -5, 'email': 'john@example.com'} + ) # Negative age + + with pytest.raises(MultipleInvalid): + schema( + {'name': 'John', 'age': 30, 'email': 'invalid-email'} + ) # Invalid email + + def test_constraint_merging(self): + """Test that constraints are properly merged with base types.""" + + @dataclass + class Product: + name: str + price: float + category: str + + schema = DataclassSchema( + Product, + { + 'name': All(str, Length(min=1)), + 'price': All(float, Range(min=0)), + 'category': In(['electronics', 'books', 'clothing']), + }, + ) + + # Test valid data + result = schema({'name': 'Laptop', 'price': 999.99, 'category': 'electronics'}) + assert result.name == 'Laptop' + assert result.price == 999.99 + assert result.category == 'electronics' + + # Test constraint violations + with pytest.raises(MultipleInvalid): + schema( + {'name': 'Laptop', 'price': -100, 'category': 'electronics'} + ) # Negative price + + with pytest.raises(MultipleInvalid): + schema( + {'name': 'Laptop', 'price': 999.99, 'category': 'invalid'} + ) # Invalid category + + def test_additional_fields_not_in_dataclass(self): + """Test adding validation for fields not in the dataclass.""" + + @dataclass + class Person: + name: str + age: int + + schema = DataclassSchema( + Person, + { + 'name': Length(min=1), + 'age': Range(min=0, max=150), + 'extra_field': str, # Not in dataclass + }, + ) + + # The extra field should be ignored since it's not in the dataclass + result = schema({'name': 'John', 'age': 30}) + assert result.name == 'John' + assert result.age == 30 + + +class TestCreateDataclassSchemaFunction: + """Test the create_dataclass_schema function.""" + + def test_create_dataclass_schema_basic(self): + """Test basic usage of create_dataclass_schema function.""" + + @dataclass + class Person: + name: str + age: int = 0 + + schema = create_dataclass_schema(Person) + + result = schema({'name': 'John', 'age': 25}) + assert isinstance(result, Person) + assert result.name == 'John' + assert result.age == 25 + + def test_create_dataclass_schema_with_constraints(self): + """Test create_dataclass_schema with additional constraints.""" + + @dataclass + class User: + username: str + email: str + age: int = 18 + + schema = create_dataclass_schema( + User, + { + 'username': All(str, Length(min=3, max=20), Match(r'^[a-zA-Z0-9_]+$')), + 'email': Email(), + 'age': Range(min=13, max=120), + }, + ) + + # Test valid data + result = schema( + {'username': 'john_doe', 'email': 'john@example.com', 'age': 25} + ) + assert result.username == 'john_doe' + assert result.email == 'john@example.com' + assert result.age == 25 + + # Test invalid username + with pytest.raises(MultipleInvalid): + schema( + {'username': 'jo', 'email': 'john@example.com', 'age': 25} + ) # Too short + + with pytest.raises(MultipleInvalid): + schema( + {'username': 'john-doe', 'email': 'john@example.com', 'age': 25} + ) # Invalid chars + + +class TestComplexDataclassSchemas: + """Test complex dataclass scenarios.""" + + def test_nested_dataclass_like_structure(self): + """Test dataclass with nested dictionary validation.""" + + @dataclass + class Address: + street: str + city: str + zipcode: str + + @dataclass + class Person: + name: str + age: int + address: dict # We'll validate this as a nested structure + + # Create schema for Address separately + address_schema = DataclassSchema(Address, {'zipcode': Match(r'^\d{5}$')}) + + # Create schema for Person with address validation + person_schema = DataclassSchema( + Person, + { + 'name': Length(min=1), + 'age': Range(min=0, max=150), + 'address': address_schema, + }, + ) + + # Test valid nested data + result = person_schema( + { + 'name': 'John Doe', + 'age': 30, + 'address': { + 'street': '123 Main St', + 'city': 'Anytown', + 'zipcode': '12345', + }, + } + ) + + assert result.name == 'John Doe' + assert result.age == 30 + assert isinstance(result.address, Address) + assert result.address.street == '123 Main St' + assert result.address.city == 'Anytown' + assert result.address.zipcode == '12345' + + def test_dataclass_with_list_field(self): + """Test dataclass with list field validation.""" + + @dataclass + class Team: + name: str + members: list + + schema = DataclassSchema( + Team, {'name': Length(min=1), 'members': [str]} # List of strings + ) + + # Test valid data + result = schema( + {'name': 'Development Team', 'members': ['Alice', 'Bob', 'Charlie']} + ) + assert result.name == 'Development Team' + assert result.members == ['Alice', 'Bob', 'Charlie'] + + # Test invalid member types + with pytest.raises(MultipleInvalid): + schema( + { + 'name': 'Development Team', + 'members': ['Alice', 123, 'Charlie'], # Invalid type in list + } + ) + + +class TestErrorCases: + """Test error cases and edge conditions.""" + + def test_non_dataclass_error(self): + """Test error when trying to create schema from non-dataclass.""" + + class RegularClass: + def __init__(self, name): + self.name = name + + with pytest.raises(ValueError, match="is not a dataclass"): + DataclassSchema(RegularClass) + + with pytest.raises(ValueError, match="is not a dataclass"): + create_dataclass_schema(RegularClass) + + def test_empty_dataclass(self): + """Test dataclass with no fields.""" + + @dataclass + class Empty: + pass + + schema = DataclassSchema(Empty) + result = schema({}) + assert isinstance(result, Empty) + + +class TestBackwardCompatibility: + """Test that dataclass support doesn't break existing functionality.""" + + def test_regular_schema_still_works(self): + """Test that regular Schema functionality is unaffected.""" + schema = Schema({Required('name'): str, Optional('age', default=0): int}) + + result = schema({'name': 'John', 'age': 30}) + assert result == {'name': 'John', 'age': 30} + + result = schema({'name': 'Jane'}) + assert result == {'name': 'Jane', 'age': 0} + + def test_object_validator_still_works(self): + """Test that Object validator functionality is unaffected.""" + from voluptuous import Object + + class Person: + def __init__(self, name=None, age=None): + self.name = name + self.age = age + + schema = Schema(Object({'name': str, 'age': int}, cls=Person)) + + result = schema(Person(name='John', age=30)) + assert isinstance(result, Person) + assert result.name == 'John' + assert result.age == 30