Skip to content

Commit feef8a7

Browse files
authored
Add default option to field (#12)
1 parent 797fd96 commit feef8a7

File tree

5 files changed

+677
-35
lines changed

5 files changed

+677
-35
lines changed

README.md

Lines changed: 95 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ Features
2525

2626
- **Type Validation**: Automatically validates types for attributes based on type hints.
2727
- **Constraint Validation**: Define constraints like minimum/maximum length, value ranges, and more.
28+
- **Default Field Values**: Set default values for fields that are used when not explicitly provided.
2829
- **Customizable Error Handling**: Use custom exception classes for type and constraint errors.
2930
- **Flexible Field Descriptors**: Add constraints, casting, and other behaviors to your fields.
3031
- **Optional Fields**: Support for optional fields with default values.
@@ -116,20 +117,108 @@ payload = OptionalPayload()
116117
print(payload.name) # Output: None
117118
```
118119

120+
### Default Field Values
121+
122+
You can specify default values for fields in two ways:
123+
124+
#### Using Field() with default parameter
125+
126+
```python
127+
class User(Statica):
128+
name: str
129+
age: int = Field(default=25)
130+
active: bool = Field(default=True)
131+
132+
# Using direct initialization
133+
user = User(name="John")
134+
print(user.name) # Output: "John"
135+
print(user.age) # Output: 25
136+
print(user.active) # Output: True
137+
138+
# Using from_map
139+
user = User.from_map({"name": "Jane"})
140+
print(user.age) # Output: 25 (default used)
141+
142+
# Explicit values override defaults
143+
user = User(name="Bob", age=30, active=False)
144+
print(user.age) # Output: 30
145+
```
146+
147+
#### Direct assignment (without Field)
148+
149+
You can also assign default values directly to fields without using `Field()`:
150+
151+
```python
152+
class Config(Statica):
153+
name: str
154+
timeout: float = 30.0 # Direct assignment
155+
retries: int = 3 # Direct assignment
156+
debug: bool = False # Direct assignment
157+
158+
config = Config(name="server")
159+
print(config.timeout) # Output: 30.0
160+
print(config.retries) # Output: 3
161+
print(config.debug) # Output: False
162+
163+
# Works with from_map too
164+
config = Config.from_map({"name": "api-server"})
165+
print(config.timeout) # Output: 30.0 (default used)
166+
```
167+
168+
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.
169+
170+
#### Validation and Safety
171+
172+
Default values are validated against any constraints you've defined:
173+
174+
```python
175+
class Config(Statica):
176+
timeout: float = Field(default=30.0, min_value=1.0, max_value=120.0)
177+
retries: int = Field(default=3, min_value=1)
178+
179+
config = Config() # Uses defaults: timeout=30.0, retries=3
180+
```
181+
182+
For mutable default values (like lists, dicts, sets), Statica automatically creates copies to prevent shared state issues:
183+
184+
```python
185+
class UserProfile(Statica):
186+
name: str
187+
tags: list[str] = Field(default=[]) # or tags: list[str] = []
188+
189+
user1 = UserProfile(name="Alice")
190+
user2 = UserProfile(name="Bob")
191+
192+
user1.tags.append("admin")
193+
print(user1.tags) # Output: ["admin"]
194+
print(user2.tags) # Output: [] (not affected)
195+
```
196+
119197
### Field Constraints
120198

121-
You can specify constraints on fields:
199+
You can specify constraints and options on fields:
122200

201+
- **Default Values**: `default` (using `Field()`) or direct assignment
123202
- **String Constraints**: `min_length`, `max_length`, `strip_whitespace`
124203
- **Numeric Constraints**: `min_value`, `max_value`
125204
- **Casting**: `cast_to`
205+
- **Aliasing**: `alias`
126206

127207
```python
128208
class StringTest(Statica):
129209
name: str = Field(min_length=3, max_length=5, strip_whitespace=True)
130210

131211
class IntTest(Statica):
132212
num: int = Field(min_value=1, max_value=10, cast_to=int)
213+
214+
class DefaultTest(Statica):
215+
# Using Field() for defaults with constraints
216+
status: str = Field(default="active")
217+
priority: int = Field(default=1, min_value=1, max_value=5)
218+
219+
# Direct assignment for simple defaults
220+
timeout: float = 30.0
221+
retries: int = 3
133222
```
134223

135224
### Custom Error Classes
@@ -181,21 +270,21 @@ Use the `alias` parameter to define an alternative name for both parsing and ser
181270
```python
182271
class User(Statica):
183272
full_name: str = Field(alias="fullName")
184-
age: int = Field(alias="userAge")
273+
age: int = Field(alias="userAge", default=25)
185274

186275
# Parse data with aliases
187-
data = {"fullName": "John Doe", "userAge": 30}
276+
data = {"fullName": "John Doe"} # userAge not provided, uses default
188277
user = User.from_map(data)
189278
print(user.full_name) # Output: "John Doe"
190-
print(user.age) # Output: 30
279+
print(user.age) # Output: 25
191280

192281
# Serialize back with aliases (uses the alias for serialization by default)
193282
result = user.to_dict()
194-
print(result) # Output: {"fullName": "John Doe", "userAge": 30}
283+
print(result) # Output: {"fullName": "John Doe", "userAge": 25}
195284

196285
# Serialize without aliases
197286
result_no_alias = user.to_dict(with_aliases=False)
198-
print(result_no_alias) # Output: {"full_name": "John Doe", "age": 30}
287+
print(result_no_alias) # Output: {"full_name": "John Doe", "age": 25}
199288

200289
```
201290

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[project]
22
name = "statica"
3-
version = "1.3.0"
3+
version = "1.4.0"
44
description = "A minimalistic data validation library"
55
readme = "README.md"
66
authors = [{ name = "Marcel Kröker", email = "kroeker.marcel@gmail.com" }]

statica/core.py

Lines changed: 62 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
from __future__ import annotations
22

3+
import copy
34
from dataclasses import dataclass
45
from dataclasses import field as dataclass_field
56
from types import UnionType
@@ -9,9 +10,9 @@
910
Generic,
1011
Self,
1112
TypeVar,
12-
cast,
1313
dataclass_transform,
1414
get_type_hints,
15+
overload,
1516
)
1617

1718
from statica.config import StaticaConfig, default_config
@@ -76,6 +77,7 @@ class User(Statica):
7677

7778
# User-facing dataclass fields
7879

80+
default: T | Any | None = None
7981
min_length: int | None = None
8082
max_length: int | None = None
8183
min_value: float | None = None
@@ -117,6 +119,14 @@ def get_statica_subclass(self, sub_types: tuple[type, ...]) -> type[Statica] | N
117119
pass
118120
return None
119121

122+
def get_default_safe(self) -> Any:
123+
"""
124+
Get the default value of the field, safely handling mutable defaults.
125+
"""
126+
if isinstance(self.default, (list, dict, set)):
127+
return copy.copy(self.default)
128+
return self.default
129+
120130
def __get__(self, instance: object | None, owner: Any) -> Any:
121131
"""
122132
Get the value of the field from the instance.
@@ -200,8 +210,36 @@ def get_field_descriptors(cls: type[Statica]) -> list[FieldDescriptor]:
200210
#### MARK: Type-safe field function
201211

202212

213+
@overload
214+
def Field(
215+
*,
216+
default: T, # If default is used, the return type is T
217+
min_length: int | None = None,
218+
max_length: int | None = None,
219+
min_value: float | None = None,
220+
max_value: float | None = None,
221+
strip_whitespace: bool | None = None,
222+
cast_to: Callable[..., T] | None = None,
223+
alias: str | None = None,
224+
) -> T: ...
225+
226+
227+
@overload
228+
def Field(
229+
*, # No default provided, return type is Any
230+
min_length: int | None = None,
231+
max_length: int | None = None,
232+
min_value: float | None = None,
233+
max_value: float | None = None,
234+
strip_whitespace: bool | None = None,
235+
cast_to: Callable[..., T] | None = None,
236+
alias: str | None = None,
237+
) -> Any: ...
238+
239+
203240
def Field( # noqa: N802
204241
*,
242+
default: T | Any | None = None,
205243
min_length: int | None = None,
206244
max_length: int | None = None,
207245
min_value: float | None = None,
@@ -211,11 +249,14 @@ def Field( # noqa: N802
211249
alias: str | None = None,
212250
) -> Any:
213251
"""
214-
Type-safe field function that returns the correct type for type checkers
215-
but creates a Field descriptor at runtime.
216-
"""
252+
Type-safe field function that provides proper type checking for default values
253+
while creating a FieldDescriptor at runtime.
217254
218-
fd = FieldDescriptor(
255+
When a default value is provided, the return type matches the default's type.
256+
This prevents type mismatches like: active: bool = Field(default="yes")
257+
"""
258+
return FieldDescriptor(
259+
default=default,
219260
min_length=min_length,
220261
max_length=max_length,
221262
min_value=min_value,
@@ -225,11 +266,6 @@ def Field( # noqa: N802
225266
alias=alias,
226267
)
227268

228-
if TYPE_CHECKING:
229-
return cast("Any", fd)
230-
231-
return fd # type: ignore[unreachable]
232-
233269

234270
########################################################################################
235271
#### MARK: Internal metaclass
@@ -265,7 +301,14 @@ def __new__(
265301

266302
def statica_init(self: Statica, **kwargs: Any) -> None:
267303
for field_name in annotations:
268-
setattr(self, field_name, kwargs.get(field_name))
304+
field_descriptor = namespace.get(field_name)
305+
assert isinstance(field_descriptor, FieldDescriptor)
306+
307+
# Use default value if key is missing and default is available
308+
if field_name not in kwargs and field_descriptor.default is not None:
309+
setattr(self, field_name, field_descriptor.get_default_safe())
310+
else:
311+
setattr(self, field_name, kwargs.get(field_name))
269312

270313
namespace["__init__"] = statica_init
271314

@@ -280,8 +323,8 @@ def statica_init(self: Statica, **kwargs: Any) -> None:
280323
continue
281324

282325
# Case 3: name: str (no assignment) or name: Field[str] (no assignment)
283-
# Create a default Field descriptor
284-
namespace[attr_annotated] = FieldDescriptor()
326+
# Create a Field descriptor with the default if it exists
327+
namespace[attr_annotated] = FieldDescriptor(default=namespace.get(attr_annotated))
285328

286329
return super().__new__(cls, name, bases, namespace)
287330

@@ -293,20 +336,16 @@ def statica_init(self: Statica, **kwargs: Any) -> None:
293336
class Statica(metaclass=StaticaMeta):
294337
@classmethod
295338
def from_map(cls, mapping: Mapping[str, Any]) -> Self:
296-
# Fields might have aliases, so we need to map them correctly.
297-
# Here we map the chosen alias to the original field name.
298-
# If no alias is provided, we use the field name itself.
299-
# Priority: parsing alias > general alias > field name
300-
mapping_key_to_field_keys = {}
339+
# Fields might have aliases, so we need to map them correctly
301340

341+
kwargs = {}
302342
for field_descriptor in get_field_descriptors(cls):
303-
# Use alias for parsing if it exists
304-
alias = field_descriptor.alias or field_descriptor.name
305-
mapping_key_to_field_keys[alias] = field_descriptor.name
343+
expected_field_name = field_descriptor.alias or field_descriptor.name
306344

307-
parsed_mapping = {mapping_key_to_field_keys[k]: v for k, v in mapping.items()}
345+
if expected_field_name in mapping:
346+
kwargs[field_descriptor.name] = mapping[expected_field_name]
308347

309-
return cls(**parsed_mapping) # Init function will validate fields
348+
return cls(**kwargs) # Init function will validate fields and set defaults
310349

311350
def to_dict(self, *, with_aliases: bool = True) -> dict[str, Any]:
312351
"""

tests/test_aliasing.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ class AliasTest(Statica):
2121
assert instance.to_dict() == data
2222

2323
# Test that original field names don't work when alias is used
24-
with pytest.raises(KeyError):
24+
with pytest.raises(TypeValidationError):
2525
AliasTest.from_map({"full_name": "John Doe", "age": INTEGER})
2626

2727

@@ -115,12 +115,12 @@ def test_empty_alias_mapping() -> None:
115115
class EmptyMappingTest(Statica):
116116
field_name: str = Field(alias="expectedAlias")
117117

118-
# Should raise KeyError when the aliased field is missing
119-
with pytest.raises(KeyError):
118+
# Should raise TypeValidationError when the aliased field is missing
119+
with pytest.raises(TypeValidationError):
120120
EmptyMappingTest.from_map({"wrongAlias": "value"})
121121

122-
# Should raise a key error when the original field name is used
123-
with pytest.raises(KeyError):
122+
# Should raise a TypeValidationError when the original field name is used
123+
with pytest.raises(TypeValidationError):
124124
EmptyMappingTest.from_map({"field_name": "value"})
125125

126126

0 commit comments

Comments
 (0)