From d19cb9d6bcb80990735e9fce1686aff9e5979ee6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Kr=C3=B6ker?= Date: Mon, 30 Jun 2025 09:24:07 +0200 Subject: [PATCH 1/2] Add default option to field --- README.md | 101 ++++++- statica/core.py | 85 ++++-- tests/test_aliasing.py | 10 +- tests/test_default_field.py | 514 ++++++++++++++++++++++++++++++++++++ 4 files changed, 676 insertions(+), 34 deletions(-) create mode 100644 tests/test_default_field.py diff --git a/README.md b/README.md index 4b7ba6a..6211a18 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ Features - **Type Validation**: Automatically validates types for attributes based on type hints. - **Constraint Validation**: Define constraints like minimum/maximum length, value ranges, and more. +- **Default Field Values**: Set default values for fields that are used when not explicitly provided. - **Customizable Error Handling**: Use custom exception classes for type and constraint errors. - **Flexible Field Descriptors**: Add constraints, casting, and other behaviors to your fields. - **Optional Fields**: Support for optional fields with default values. @@ -116,13 +117,92 @@ payload = OptionalPayload() print(payload.name) # Output: None ``` +### Default Field Values + +You can specify default values for fields in two ways: + +#### Using Field() with default parameter + +```python +class User(Statica): + name: str + age: int = Field(default=25) + active: bool = Field(default=True) + +# Using direct initialization +user = User(name="John") +print(user.name) # Output: "John" +print(user.age) # Output: 25 +print(user.active) # Output: True + +# Using from_map +user = User.from_map({"name": "Jane"}) +print(user.age) # Output: 25 (default used) + +# Explicit values override defaults +user = User(name="Bob", age=30, active=False) +print(user.age) # Output: 30 +``` + +#### Direct assignment (without Field) + +You can also assign default values directly to fields without using `Field()`: + +```python +class Config(Statica): + name: str + timeout: float = 30.0 # Direct assignment + retries: int = 3 # Direct assignment + debug: bool = False # Direct assignment + +config = Config(name="server") +print(config.timeout) # Output: 30.0 +print(config.retries) # Output: 3 +print(config.debug) # Output: False + +# Works with from_map too +config = Config.from_map({"name": "api-server"}) +print(config.timeout) # Output: 30.0 (default used) +``` + +Both approaches work identically and can be mixed within the same class. Use `Field(default=...)` when you need additional constraints or options, and direct assignment for simple defaults. + +#### Validation and Safety + +Default values are validated against any constraints you've defined: + +```python +class Config(Statica): + timeout: float = Field(default=30.0, min_value=1.0, max_value=120.0) + retries: int = Field(default=3, min_value=1) + +config = Config() # Uses defaults: timeout=30.0, retries=3 +``` + +For mutable default values (like lists, dicts, sets), Statica automatically creates copies to prevent shared state issues: + +```python +class UserProfile(Statica): + name: str + tags: list[str] = Field(default=[]) # or tags: list[str] = [] + +user1 = UserProfile(name="Alice") +user2 = UserProfile(name="Bob") + +user1.tags.append("admin") +print(user1.tags) # Output: ["admin"] +print(user2.tags) # Output: [] (not affected) +``` + ### Field Constraints -You can specify constraints on fields: +You can specify constraints and options on fields: +- **Default Values**: `default` (using `Field()`) or direct assignment - **String Constraints**: `min_length`, `max_length`, `strip_whitespace` - **Numeric Constraints**: `min_value`, `max_value` - **Casting**: `cast_to` +- **Aliasing**: `alias` ```python class StringTest(Statica): @@ -130,6 +210,15 @@ class StringTest(Statica): class IntTest(Statica): num: int = Field(min_value=1, max_value=10, cast_to=int) + +class DefaultTest(Statica): + # Using Field() for defaults with constraints + status: str = Field(default="active") + priority: int = Field(default=1, min_value=1, max_value=5) + + # Direct assignment for simple defaults + timeout: float = 30.0 + retries: int = 3 ``` ### Custom Error Classes @@ -181,21 +270,21 @@ Use the `alias` parameter to define an alternative name for both parsing and ser ```python class User(Statica): full_name: str = Field(alias="fullName") - age: int = Field(alias="userAge") + age: int = Field(alias="userAge", default=25) # Parse data with aliases -data = {"fullName": "John Doe", "userAge": 30} +data = {"fullName": "John Doe"} # userAge not provided, uses default user = User.from_map(data) print(user.full_name) # Output: "John Doe" -print(user.age) # Output: 30 +print(user.age) # Output: 25 # Serialize back with aliases (uses the alias for serialization by default) result = user.to_dict() -print(result) # Output: {"fullName": "John Doe", "userAge": 30} +print(result) # Output: {"fullName": "John Doe", "userAge": 25} # Serialize without aliases result_no_alias = user.to_dict(with_aliases=False) -print(result_no_alias) # Output: {"full_name": "John Doe", "age": 30} +print(result_no_alias) # Output: {"full_name": "John Doe", "age": 25} ``` diff --git a/statica/core.py b/statica/core.py index c45b6bf..70181b6 100644 --- a/statica/core.py +++ b/statica/core.py @@ -1,5 +1,6 @@ from __future__ import annotations +import copy from dataclasses import dataclass from dataclasses import field as dataclass_field from types import UnionType @@ -9,9 +10,9 @@ Generic, Self, TypeVar, - cast, dataclass_transform, get_type_hints, + overload, ) from statica.config import StaticaConfig, default_config @@ -76,6 +77,7 @@ class User(Statica): # User-facing dataclass fields + default: T | Any | None = None min_length: int | None = None max_length: int | None = None min_value: float | None = None @@ -117,6 +119,14 @@ def get_statica_subclass(self, sub_types: tuple[type, ...]) -> type[Statica] | N pass return None + def get_default_safe(self) -> Any: + """ + Get the default value of the field, safely handling mutable defaults. + """ + if isinstance(self.default, (list, dict, set)): + return copy.copy(self.default) + return self.default + def __get__(self, instance: object | None, owner: Any) -> Any: """ Get the value of the field from the instance. @@ -200,8 +210,36 @@ def get_field_descriptors(cls: type[Statica]) -> list[FieldDescriptor]: #### MARK: Type-safe field function +@overload +def Field( + *, + default: T, # If default is used, the return type is T + min_length: int | None = None, + max_length: int | None = None, + min_value: float | None = None, + max_value: float | None = None, + strip_whitespace: bool | None = None, + cast_to: Callable[..., T] | None = None, + alias: str | None = None, +) -> T: ... + + +@overload +def Field( + *, # No default provided, return type is Any + min_length: int | None = None, + max_length: int | None = None, + min_value: float | None = None, + max_value: float | None = None, + strip_whitespace: bool | None = None, + cast_to: Callable[..., T] | None = None, + alias: str | None = None, +) -> Any: ... + + def Field( # noqa: N802 *, + default: T | Any | None = None, min_length: int | None = None, max_length: int | None = None, min_value: float | None = None, @@ -211,11 +249,14 @@ def Field( # noqa: N802 alias: str | None = None, ) -> Any: """ - Type-safe field function that returns the correct type for type checkers - but creates a Field descriptor at runtime. - """ + Type-safe field function that provides proper type checking for default values + while creating a FieldDescriptor at runtime. - fd = FieldDescriptor( + When a default value is provided, the return type matches the default's type. + This prevents type mismatches like: active: bool = Field(default="yes") + """ + return FieldDescriptor( + default=default, min_length=min_length, max_length=max_length, min_value=min_value, @@ -225,11 +266,6 @@ def Field( # noqa: N802 alias=alias, ) - if TYPE_CHECKING: - return cast("Any", fd) - - return fd # type: ignore[unreachable] - ######################################################################################## #### MARK: Internal metaclass @@ -265,7 +301,14 @@ def __new__( def statica_init(self: Statica, **kwargs: Any) -> None: for field_name in annotations: - setattr(self, field_name, kwargs.get(field_name)) + field_descriptor = namespace.get(field_name) + assert isinstance(field_descriptor, FieldDescriptor) + + # Use default value if key is missing and default is available + if field_name not in kwargs and field_descriptor.default is not None: + setattr(self, field_name, field_descriptor.get_default_safe()) + else: + setattr(self, field_name, kwargs.get(field_name)) namespace["__init__"] = statica_init @@ -280,8 +323,8 @@ def statica_init(self: Statica, **kwargs: Any) -> None: continue # Case 3: name: str (no assignment) or name: Field[str] (no assignment) - # Create a default Field descriptor - namespace[attr_annotated] = FieldDescriptor() + # Create a Field descriptor with the default if it exists + namespace[attr_annotated] = FieldDescriptor(default=namespace.get(attr_annotated)) return super().__new__(cls, name, bases, namespace) @@ -293,20 +336,16 @@ def statica_init(self: Statica, **kwargs: Any) -> None: class Statica(metaclass=StaticaMeta): @classmethod def from_map(cls, mapping: Mapping[str, Any]) -> Self: - # Fields might have aliases, so we need to map them correctly. - # Here we map the chosen alias to the original field name. - # If no alias is provided, we use the field name itself. - # Priority: parsing alias > general alias > field name - mapping_key_to_field_keys = {} + # Fields might have aliases, so we need to map them correctly + kwargs = {} for field_descriptor in get_field_descriptors(cls): - # Use alias for parsing if it exists - alias = field_descriptor.alias or field_descriptor.name - mapping_key_to_field_keys[alias] = field_descriptor.name + expected_field_name = field_descriptor.alias or field_descriptor.name - parsed_mapping = {mapping_key_to_field_keys[k]: v for k, v in mapping.items()} + if expected_field_name in mapping: + kwargs[field_descriptor.name] = mapping[expected_field_name] - return cls(**parsed_mapping) # Init function will validate fields + return cls(**kwargs) # Init function will validate fields and set defaults def to_dict(self, *, with_aliases: bool = True) -> dict[str, Any]: """ diff --git a/tests/test_aliasing.py b/tests/test_aliasing.py index 6d3c4c4..c27e3f4 100644 --- a/tests/test_aliasing.py +++ b/tests/test_aliasing.py @@ -21,7 +21,7 @@ class AliasTest(Statica): assert instance.to_dict() == data # Test that original field names don't work when alias is used - with pytest.raises(KeyError): + with pytest.raises(TypeValidationError): AliasTest.from_map({"full_name": "John Doe", "age": INTEGER}) @@ -115,12 +115,12 @@ def test_empty_alias_mapping() -> None: class EmptyMappingTest(Statica): field_name: str = Field(alias="expectedAlias") - # Should raise KeyError when the aliased field is missing - with pytest.raises(KeyError): + # Should raise TypeValidationError when the aliased field is missing + with pytest.raises(TypeValidationError): EmptyMappingTest.from_map({"wrongAlias": "value"}) - # Should raise a key error when the original field name is used - with pytest.raises(KeyError): + # Should raise a TypeValidationError when the original field name is used + with pytest.raises(TypeValidationError): EmptyMappingTest.from_map({"field_name": "value"}) diff --git a/tests/test_default_field.py b/tests/test_default_field.py new file mode 100644 index 0000000..997aff4 --- /dev/null +++ b/tests/test_default_field.py @@ -0,0 +1,514 @@ +"""Tests for default field functionality in Statica.""" + +import pytest + +from statica import Field, Statica +from statica.exceptions import ConstraintValidationError, TypeValidationError + + +def test_basic_default_value() -> None: + """Test that default values are used when field is not provided.""" + + age_default = 25 + + class User(Statica): + name: str + age: int = Field(default=age_default) + active: bool = Field(default=True) + + user = User(name="John") + assert user.name == "John" + assert user.age == age_default + assert user.active is True + + +def test_explicit_value_overrides_default() -> None: + """Test that explicitly provided values override defaults.""" + + age_default = 25 + age_init = 30 + + class User(Statica): + name: str + age: int = Field(default=age_default) + active: bool = Field(default=True) + + user = User(name="John", age=age_init, active=False) + assert user.name == "John" + assert user.age == age_init + assert user.active is False + + +def test_none_as_default_value() -> None: + """Test that None can be used as a default value.""" + + class User(Statica): + name: str + description: str | None = Field(default=None) + + user = User(name="John") + assert user.name == "John" + assert user.description is None + + +def test_complex_default_values() -> None: + """Test default values with complex types.""" + + retires_value = 3 + timeout_value = 30.0 + + class Config(Statica): + timeout: float = Field(default=timeout_value) + retries: int = Field(default=retires_value) + tags: list[str] | None = Field(default=None) + + config = Config() + assert config.timeout == timeout_value + assert config.retries == retires_value + assert config.tags is None + + +def test_default_value_validation() -> None: + """Test that default values are validated against constraints.""" + + age_default = 25 + + class User(Statica): + name: str + age: int = Field(default=age_default, min_value=18, max_value=100) + + # Should work with valid default + user = User(name="John") + assert user.age == age_default + + +def test_invalid_default_value_raises_error() -> None: + """Test that invalid default values raise validation errors.""" + + class User(Statica): + name: str + age: int = Field(default=150, min_value=18, max_value=100) + + with pytest.raises(ConstraintValidationError): + User(name="John") # This should trigger validation of default + + +def test_default_with_casting() -> None: + """Test default values work with cast_to functionality.""" + + age_default = 25 + + class User(Statica): + name: str + age: int = Field(default=f"{age_default}", cast_to=int) + + user = User(name="John") + assert user.name == "John" + assert user.age == age_default + + assert isinstance(user.age, int) + + +def test_from_map_with_defaults() -> None: + """Test that from_map uses defaults for missing keys.""" + + age_default = 25 + active_default = True + + class User(Statica): + name: str + age: int = Field(default=age_default) + active: bool = Field(default=active_default) + + user = User.from_map({"name": "John"}) + assert user.name == "John" + assert user.age == age_default + assert user.active is active_default + + +def test_from_map_overrides_defaults() -> None: + """Test that from_map explicit values override defaults.""" + + age_default = 25 + age_init = 30 + active_default = True + active_init = False + + class User(Statica): + name: str + age: int = Field(default=age_default) + active: bool = Field(default=active_default) + + user = User.from_map({"name": "John", "age": age_init, "active": active_init}) + assert user.name == "John" + assert user.age == age_init + assert user.active is active_init + + +def test_nested_statica_with_defaults() -> None: + """Test default values in nested Statica objects.""" + + class Address(Statica): + street: str + city: str = Field(default="Unknown") + + class User(Statica): + name: str + address: Address | None = Field(default=None) + + user = User(name="John") + assert user.name == "John" + assert user.address is None + + +def test_nested_statica_from_map_with_defaults() -> None: + """Test nested Statica objects with defaults when using from_map.""" + + class Address(Statica): + street: str + city: str = Field(default="Unknown") + + class User(Statica): + name: str + address: Address + + user = User.from_map({"name": "John", "address": {"street": "123 Main St"}}) + assert user.name == "John" + assert user.address.street == "123 Main St" + assert user.address.city == "Unknown" + + +def test_default_with_aliases() -> None: + """Test that defaults work properly with field aliases.""" + + age_default = 25 + age_init = 30 + + class User(Statica): + name: str + age: int = Field(default=age_default, alias="userAge") + + # Using from_map with alias + user1 = User.from_map({"name": "John"}) + assert user1.age == age_default + + # Using from_map with explicit alias value + user2 = User.from_map({"name": "Jane", "userAge": age_init}) + assert user2.age == age_init + + +def test_to_dict_with_defaults() -> None: + """Test that to_dict includes default values.""" + + age_default = 25 + + class User(Statica): + name: str + age: int = Field(default=age_default) + active: bool = Field(default=True, alias="isActive") + + user = User(name="John") + + # Without aliases + result = user.to_dict(with_aliases=False) + expected = {"name": "John", "age": age_default, "active": True} + assert result == expected + + # With aliases + result_with_aliases = user.to_dict(with_aliases=True) + expected_with_aliases = {"name": "John", "age": age_default, "isActive": True} + assert result_with_aliases == expected_with_aliases + + +def test_mutable_default_values_are_safe() -> None: + """Test that mutable default values don't cause shared state issues.""" + + class Config(Statica): + name: str + tags: list[str] = Field(default=[]) + + config1 = Config(name="config1") + config2 = Config(name="config2") + + # Modify one instance + config1.tags.append("tag1") + + # Other instance should not be affected + assert config1.tags == ["tag1"] + assert config2.tags == [] + + +def test_default_value_with_strip_whitespace() -> None: + """Test default values work with strip_whitespace constraint.""" + + class User(Statica): + name: str = Field(default=" John ", strip_whitespace=True) + title: str | None = Field(default=None) + + user = User() + assert user.name == "John" # Should be stripped + assert user.title is None + + +def test_field_without_default_requires_value() -> None: + """Test that fields without defaults still require values.""" + + age_default = 25 + + class User(Statica): + name: str # No default + age: int = Field(default=25) + + # Should work when name is provided + user = User(name="John") + assert user.name == "John" + assert user.age == age_default + + # Should fail when name is missing + with pytest.raises(TypeValidationError): + User() # type: ignore[call-arg] + + +def test_default_value_without_field() -> None: + """Test that default values can be set without using Field.""" + + age_default = 25 + + class User(Statica): + name: str + age: int = age_default # Direct assignment without Field + + user = User(name="John") + assert user.name == "John" + assert user.age == age_default + + user_from_map = User.from_map({"name": "Jane"}) + assert user_from_map.name == "Jane" + assert user_from_map.age == age_default + + +def test_direct_assignment_various_types() -> None: + """Test direct assignment of default values for various types.""" + + default_count = 42 + default_ratio = 3.14 + + class Config(Statica): + name: str + count: int = default_count + ratio: float = default_ratio + enabled: bool = True + description: str | None = None + + config = Config(name="test") + assert config.name == "test" + assert config.count == default_count + assert config.ratio == default_ratio + assert config.enabled is True + assert config.description is None + + # Test from_map + config_from_map = Config.from_map({"name": "mapped"}) + assert config_from_map.count == default_count + assert config_from_map.ratio == default_ratio + assert config_from_map.enabled is True + assert config_from_map.description is None + + +def test_direct_assignment_with_mutable_types() -> None: + """Test direct assignment with mutable default values are handled safely.""" + + class Container(Statica): + name: str + items: list[str] = [] # noqa: RUF012 + metadata: dict[str, int] = {} # noqa: RUF012 + tags: set[str] = set() # noqa: RUF012 + + container1 = Container(name="first") + container2 = Container(name="second") + + # Modify mutable defaults on one instance + container1.items.append("item1") + container1.metadata["key"] = 1 + container1.tags.add("tag1") + + # Other instance should not be affected (copies should be made) + assert container1.items == ["item1"] + assert container1.metadata == {"key": 1} + assert container1.tags == {"tag1"} + + assert container2.items == [] + assert container2.metadata == {} + assert container2.tags == set() + + +def test_direct_assignment_mixed_with_field() -> None: + """Test mixing direct assignment with Field() usage.""" + + default_port = 8080 + default_timeout = 30.0 + default_max_connections = 100 + override_port = 9000 + + class MixedConfig(Statica): + # Direct assignments + name: str + port: int = default_port + debug: bool = False + + # Field() assignments + timeout: float = Field(default=default_timeout, min_value=1.0) + max_connections: int = Field(default=default_max_connections, min_value=1, max_value=1000) + + config = MixedConfig(name="server") + assert config.name == "server" + assert config.port == default_port + assert config.debug is False + assert config.timeout == default_timeout + assert config.max_connections == default_max_connections + + # Test from_map + config_from_map = MixedConfig.from_map({"name": "api-server", "port": override_port}) + assert config_from_map.name == "api-server" + assert config_from_map.port == override_port + assert config_from_map.debug is False + assert config_from_map.timeout == default_timeout + assert config_from_map.max_connections == default_max_connections + + +def test_direct_assignment_override_with_explicit_values() -> None: + """Test that explicit values override direct assignment defaults.""" + + default_retries = 3 + default_delay = 1.5 + override_retries = 5 + override_delay = 2.0 + map_retries = 10 + + class Settings(Statica): + name: str + retries: int = default_retries + delay: float = default_delay + verbose: bool = False + + # Override all defaults + settings = Settings(name="custom", retries=override_retries, delay=override_delay, verbose=True) + assert settings.name == "custom" + assert settings.retries == override_retries + assert settings.delay == override_delay + assert settings.verbose is True + + # Override some defaults via from_map + settings_from_map = Settings.from_map( + { + "name": "partial", + "retries": map_retries, + }, + ) + assert settings_from_map.name == "partial" + assert settings_from_map.retries == map_retries + assert settings_from_map.delay == default_delay # default used + assert settings_from_map.verbose is False # default used + + +def test_direct_assignment_with_complex_types() -> None: + """Test direct assignment with complex types like unions.""" + + default_value = 42 + override_value = 3.14 + map_value = 99.9 + + class ComplexConfig(Statica): + name: str + value: int | float = default_value + optional_data: str | None = None + status: str = "pending" + + config = ComplexConfig(name="test") + assert config.name == "test" + assert config.value == default_value + assert config.optional_data is None + assert config.status == "pending" + + # Test type validation still works + config2 = ComplexConfig(name="test2", value=override_value) + assert config2.value == override_value + + # Test from_map + config_from_map = ComplexConfig.from_map( + { + "name": "mapped", + "value": map_value, + "optional_data": "some data", + }, + ) + assert config_from_map.value == map_value + assert config_from_map.optional_data == "some data" + assert config_from_map.status == "pending" # default used + + +def test_direct_assignment_nested_statica() -> None: + """Test direct assignment with nested Statica objects.""" + + default_port = 5432 + + class DatabaseConfig(Statica): + host: str = "localhost" + port: int = default_port + ssl: bool = False + + class AppConfig(Statica): + name: str + database: DatabaseConfig | None = None + + # Test with default None + app = AppConfig(name="myapp") + assert app.name == "myapp" + assert app.database is None + + # Test with provided nested object + app_with_db = AppConfig(name="webapp", database=DatabaseConfig(host="prod.db")) + assert app_with_db.database is not None + assert app_with_db.database.host == "prod.db" + assert app_with_db.database.port == default_port # default used + assert app_with_db.database.ssl is False # default used + + +def test_direct_assignment_to_dict_serialization() -> None: + """Test that to_dict works correctly with direct assignment defaults.""" + + class Profile(Statica): + username: str + bio: str = "No bio provided" + public: bool = True + followers: int = 0 + + profile = Profile(username="john_doe") + + result = profile.to_dict() + expected = { + "username": "john_doe", + "bio": "No bio provided", + "public": True, + "followers": 0, + } + assert result == expected + + +def test_direct_assignment_constants() -> None: + """Test direct assignment using constants and computed values.""" + + default_timeout = 30 + default_retries = 3 + expected_max_size = 30 # 3 * 10 + + class ServiceConfig(Statica): + name: str + timeout: int = default_timeout + retries: int = default_retries + max_size: int = default_retries * 10 # computed default + + config = ServiceConfig(name="auth-service") + assert config.timeout == default_timeout + assert config.retries == default_retries + assert config.max_size == expected_max_size From 97d956abe2bed938a1ee07fb4a36870783d11ebd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcel=20Kro=CC=88ker?= Date: Sat, 5 Jul 2025 11:26:04 +0200 Subject: [PATCH 2/2] bump version --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 313a6a7..80ca3db 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "statica" -version = "1.3.0" +version = "1.4.0" description = "A minimalistic data validation library" readme = "README.md" authors = [{ name = "Marcel Kröker", email = "kroeker.marcel@gmail.com" }]