Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
101 changes: 95 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -116,20 +117,108 @@ 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):
name: str = Field(min_length=3, max_length=5, strip_whitespace=True)

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
Expand Down Expand Up @@ -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}

```

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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" }]
Expand Down
85 changes: 62 additions & 23 deletions statica/core.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -9,9 +10,9 @@
Generic,
Self,
TypeVar,
cast,
dataclass_transform,
get_type_hints,
overload,
)

from statica.config import StaticaConfig, default_config
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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

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

Expand All @@ -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]:
"""
Expand Down
10 changes: 5 additions & 5 deletions tests/test_aliasing.py
Original file line number Diff line number Diff line change
Expand Up @@ -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})


Expand Down Expand Up @@ -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"})


Expand Down
Loading
Loading