Skip to content

Commit

Permalink
#169 Add tests for Pydantic type validation functionality
Browse files Browse the repository at this point in the history
  • Loading branch information
alexhad6 committed Apr 25, 2024
1 parent 8dccf55 commit e7a32f5
Show file tree
Hide file tree
Showing 4 changed files with 216 additions and 20 deletions.
13 changes: 10 additions & 3 deletions paramdb/_param_data/_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ class CustomParam(ParamDataclass):
- validate_assignment: ``True`` (validate on assignment as well as initialization)
- arbitrary_types_allowed: ``True`` (allow arbitrary type hints)
- strict: ``True`` (disable value coercion, e.g. '2' -> 2)
- validate_default: ``True`` (validate default values)
Pydantic configuration options can be updated using the class keyword argument
``pydantic_config``, which will merge new options with the existing configuration.
Expand All @@ -51,6 +52,7 @@ class CustomParam(ParamDataclass):
"validate_assignment": True,
"arbitrary_types_allowed": True,
"strict": True,
"validate_default": True,
}

# Set in __init_subclass__() and used to set attributes within __setattr__()
Expand All @@ -69,15 +71,18 @@ def __init_subclass__(
cls.__type_validation = type_validation
if pydantic_config is not None:
# Merge new Pydantic config with the old one
cls.__pydantic_config |= pydantic_config
cls.__pydantic_config = cls.__pydantic_config | pydantic_config
cls.__base_setattr = object.__setattr__ # type: ignore
if _PYDANTIC_INSTALLED and cls.__type_validation:
# Transform the class into a Pydantic data class, with custom handling for
# validate_assignment
pydantic.dataclasses.dataclass(
config=cls.__pydantic_config | {"validate_assignment": False}, **kwargs
)(cls)
if cls.__pydantic_config["validate_assignment"]:
if (
"validate_assignment" in cls.__pydantic_config
and cls.__pydantic_config["validate_assignment"]
):
pydantic_validator = (
pydantic.dataclasses.is_pydantic_dataclass(cls)
and cls.__pydantic_validator__ # pylint: disable=no-member
Expand All @@ -100,7 +105,9 @@ def __new__(cls, *args: Any, **kwargs: Any) -> Self:
# Prevent instantiating ParamDataclass and call the superclass __init__() here
# since __init__() will be overwritten by dataclass()
if cls is ParamDataclass:
raise TypeError("only subclasses of ParamDataclass can be instantiated")
raise TypeError(
f"only subclasses of {ParamDataclass.__name__} can be instantiated"
)
self = super().__new__(cls)
super().__init__(self)
return self
Expand Down
126 changes: 117 additions & 9 deletions tests/_param_data/test_dataclasses.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,26 +2,47 @@

from typing import Union
from copy import deepcopy
import pydantic
import pytest
from tests.helpers import (
SimpleParam,
NoTypeValidationParam,
WithTypeValidationParam,
NoAssignmentValidationParam,
WithAssignmentValidationParam,
SubclassParam,
ComplexParam,
capture_start_end_times,
)
from paramdb import ParamDataclass, ParamData

ParamDataclassObject = Union[SimpleParam, SubclassParam, ComplexParam]
ParamDataclassObject = Union[
SimpleParam,
NoTypeValidationParam,
WithTypeValidationParam,
NoAssignmentValidationParam,
WithAssignmentValidationParam,
SubclassParam,
ComplexParam,
]


@pytest.fixture(
name="param_dataclass_object",
params=["simple_param", "subclass_param", "complex_param"],
params=[
"simple_param",
"no_type_validation_param",
"with_type_validation_param",
"no_assignment_validation_param",
"with_assignment_validation_param",
"subclass_param",
"complex_param",
],
)
def fixture_param_dataclass_object(
request: pytest.FixtureRequest,
) -> ParamDataclassObject:
"""Parameter dataclass object."""
"""Parameter data class object."""
param_dataclass_object: ParamDataclassObject = deepcopy(
request.getfixturevalue(request.param)
)
Expand All @@ -41,7 +62,7 @@ def test_param_dataclass_get(
param_dataclass_object: ParamDataclassObject, number: float, string: str
) -> None:
"""
Parameter dataclass properties can be accessed via dot notation and index brackets.
Parameter data class properties can be accessed via dot notation and index brackets.
"""
assert param_dataclass_object.number == number
assert param_dataclass_object.string == string
Expand All @@ -53,7 +74,7 @@ def test_param_dataclass_set(
param_dataclass_object: ParamDataclassObject, number: float
) -> None:
"""
Parameter dataclass properties can be updated via dot notation and index brackets.
Parameter data class properties can be updated via dot notation and index brackets.
"""
param_dataclass_object.number += 1
assert param_dataclass_object.number == number + 1
Expand All @@ -65,7 +86,7 @@ def test_param_dataclass_set_last_updated(
param_dataclass_object: ParamDataclassObject,
) -> None:
"""
A parameter dataclass object updates last updated timestamp when a field is set.
A parameter data class object updates last updated timestamp when a field is set.
"""
with capture_start_end_times() as times:
param_dataclass_object.number = 4.56
Expand All @@ -76,8 +97,8 @@ def test_param_dataclass_set_last_updated_non_field(
param_dataclass_object: ParamDataclassObject,
) -> None:
"""
A parameter dataclass object does not update last updated timestamp when a non-field
parameter is set.
A parameter data class object does not update last updated timestamp when a
non-field parameter is set.
"""
with capture_start_end_times() as times:
# Use ParamData's setattr function to bypass Pydantic validation
Expand All @@ -87,7 +108,7 @@ def test_param_dataclass_set_last_updated_non_field(

def test_param_dataclass_init_parent(complex_param: ComplexParam) -> None:
"""
Parameter dataclass children correctly identify their parent after initialization.
Parameter data class children correctly identify their parent after initialization.
"""
assert complex_param.simple_param is not None
assert complex_param.param_list is not None
Expand All @@ -109,3 +130,90 @@ def test_param_dataclass_set_parent(
complex_param.param_data = None
with pytest.raises(ValueError):
_ = param_data.parent


def test_param_dataclass_init_wrong_type(
param_dataclass_object: ParamDataclassObject,
) -> None:
"""
Fails or succeeds to initialize a parameter object with a string value for a float
field, depending on whether type validation is enabled.
"""
string = "123" # Use a string of a number to make sure strict mode is enabled
param_dataclass_class = type(param_dataclass_object)
if param_dataclass_class is NoTypeValidationParam:
param = param_dataclass_class(number=string) # type: ignore
assert param.number == string # type: ignore
else:
with pytest.raises(pydantic.ValidationError) as exc_info:
param_dataclass_class(number=string) # type: ignore
assert "Input should be a valid number" in str(exc_info.value)


def test_param_dataclass_init_default_wrong_type() -> None:
"""
Fails or succeeds to initialize a parameter object with a default value having the
wrong type
"""

class DefaultWrongTypeParam(SimpleParam):
"""Parameter data class with a default value having the wrong type."""

default_number: float = "123" # type: ignore

with pytest.raises(pydantic.ValidationError) as exc_info:
DefaultWrongTypeParam()
assert "Input should be a valid number" in str(exc_info.value)


def test_param_dataclass_init_extra(
param_dataclass_object: ParamDataclassObject, number: float
) -> None:
"""Fails to initialize a parameter object with an extra parameter."""
param_dataclass_class = type(param_dataclass_object)
exc_info: pytest.ExceptionInfo[Exception]
if param_dataclass_class is NoTypeValidationParam:
with pytest.raises(TypeError) as exc_info:
param_dataclass_class(extra=number) # type: ignore
assert "__init__() got an unexpected keyword argument" in str(exc_info.value)
else:
with pytest.raises(pydantic.ValidationError) as exc_info:
param_dataclass_class(extra=number) # type: ignore
assert "Unexpected keyword argument" in str(exc_info.value)


def test_param_dataclass_assignment_wrong_type(
param_dataclass_object: ParamDataclassObject,
) -> None:
"""
Fails or succeeds to assign a string value to a float field, depending on whether
assignment validation is enabled.
"""
string = "123" # Use a string of a number to make sure strict mode is enabled
if isinstance(
param_dataclass_object, (NoTypeValidationParam, NoAssignmentValidationParam)
):
param_dataclass_object.number = string # type: ignore
assert param_dataclass_object.number == string # type: ignore
else:
with pytest.raises(pydantic.ValidationError) as exc_info:
param_dataclass_object.number = string # type: ignore
assert "Input should be a valid number" in str(exc_info.value)


def test_param_dataclass_assignment_extra(
param_dataclass_object: ParamDataclassObject, number: float
) -> None:
"""
Fails or succeeds to assign an extra parameter, depending on whether assignment
validation is enabled.
"""
if isinstance(
param_dataclass_object, (NoTypeValidationParam, NoAssignmentValidationParam)
):
param_dataclass_object.extra = number
assert param_dataclass_object.extra == number # type: ignore
else:
with pytest.raises(pydantic.ValidationError) as exc_info:
param_dataclass_object.extra = number
assert "Object has no attribute 'extra'" in str(exc_info.value)
64 changes: 60 additions & 4 deletions tests/conftest.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
DEFAULT_STRING,
EmptyParam,
SimpleParam,
NoTypeValidationParam,
WithTypeValidationParam,
NoAssignmentValidationParam,
WithAssignmentValidationParam,
SubclassParam,
ComplexParam,
Times,
Expand All @@ -30,30 +34,68 @@ def fixture_string() -> str:

@pytest.fixture(name="empty_param")
def fixture_empty_param() -> EmptyParam:
"""Empty parameter dataclass object."""
"""Empty parameter data class object."""
return EmptyParam()


@pytest.fixture(name="simple_param")
def fixture_simple_param(number: float, string: str) -> SimpleParam:
"""Simple parameter dataclass object."""
"""Simple parameter data class object."""
return SimpleParam(number=number, string=string)


@pytest.fixture(name="no_type_validation_param")
def fixture_no_type_validation_param(
number: float, string: str
) -> NoTypeValidationParam:
"""Parameter data class object without type validation."""
return NoTypeValidationParam(number=number, string=string)


@pytest.fixture(name="with_type_validation_param")
def fixture_with_type_validation_param(
number: float, string: str
) -> WithTypeValidationParam:
"""Parameter data class object type validation re-enabled."""
return WithTypeValidationParam(number=number, string=string)


@pytest.fixture(name="no_assignment_validation_param")
def fixture_no_assignment_validation_param(
number: float, string: str
) -> NoAssignmentValidationParam:
"""Parameter data class object without assignment validation."""
return NoAssignmentValidationParam(number=number, string=string)


@pytest.fixture(name="with_assignment_validation_param")
def fixture_with_assignment_validation_param(
number: float, string: str
) -> WithAssignmentValidationParam:
"""Parameter data class object with assignment validation re-enabled."""
return WithAssignmentValidationParam(number=number, string=string)


@pytest.fixture(name="subclass_param")
def fixture_subclass_param(number: float, string: str) -> SubclassParam:
"""Parameter dataclass object that is a subclass of another parameter dataclass."""
"""
Parameter data class object that is a subclass of another parameter data class.
"""
return SubclassParam(number=number, string=string, second_number=number)


@pytest.fixture(name="complex_param")
def fixture_complex_param(number: float, string: str) -> ComplexParam:
"""Complex parameter dataclass object."""
"""Complex parameter data class object."""
return ComplexParam(
number=number,
string=string,
empty_param=EmptyParam(),
simple_param=SimpleParam(),
no_type_validation_param=NoTypeValidationParam(),
with_type_validation_param=WithTypeValidationParam(),
no_assignment_validation_param=NoAssignmentValidationParam(),
with_assignment_validation_param=WithAssignmentValidationParam(),
subclass_param=SubclassParam(),
complex_param=ComplexParam(),
param_list=ParamList(),
Expand All @@ -69,6 +111,10 @@ def fixture_param_list_contents(number: float, string: str) -> list[Any]:
string,
EmptyParam(),
SimpleParam(),
NoTypeValidationParam(),
WithTypeValidationParam(),
NoAssignmentValidationParam(),
WithAssignmentValidationParam(),
SubclassParam(),
ComplexParam(),
ParamList(),
Expand All @@ -83,6 +129,10 @@ def fixture_param_dict_contents(
string: str,
empty_param: EmptyParam,
simple_param: SimpleParam,
no_type_validation_param: NoTypeValidationParam,
with_type_validation_param: WithTypeValidationParam,
no_assignment_validation_param: NoAssignmentValidationParam,
with_assignment_validation_param: WithAssignmentValidationParam,
subclass_param: SubclassParam,
complex_param: ComplexParam,
) -> dict[str, Any]:
Expand All @@ -92,6 +142,10 @@ def fixture_param_dict_contents(
"string": string,
"empty_param": deepcopy(empty_param),
"simple_param": deepcopy(simple_param),
"no_type_validation_param": deepcopy(no_type_validation_param),
"with_type_validation_param": deepcopy(with_type_validation_param),
"no_assignment_validation_param": deepcopy(no_assignment_validation_param),
"with_assignment_validation_param": deepcopy(with_assignment_validation_param),
"subclass_param": deepcopy(subclass_param),
"complex_param": deepcopy(complex_param),
"param_list": ParamList(),
Expand Down Expand Up @@ -128,6 +182,8 @@ def fixture_param_dict(param_dict_contents: dict[str, Any]) -> ParamDict[Any]:
params=[
"empty_param",
"simple_param",
"no_type_validation_param",
"no_assignment_validation_param",
"subclass_param",
"complex_param",
"empty_param_list",
Expand Down
Loading

0 comments on commit e7a32f5

Please sign in to comment.