Skip to content

Commit 5d57179

Browse files
committed
Add new compute function to field
1 parent 884bc62 commit 5d57179

File tree

21 files changed

+644
-42
lines changed

21 files changed

+644
-42
lines changed

AGENTS.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,33 @@ def create_user():
8787
- **Logic**: `OneOfCondition`, `NOfCondition`, `ExactlyNOfCondition`
8888
- **Comparison**: `EqualCondition`, `NotEqualCondition`
8989

90+
### Computed Fields
91+
Define read-only fields that are automatically calculated from other fields:
92+
93+
```python
94+
class OrderInputFilter(InputFilter):
95+
quantity: int = field(required=True)
96+
price: float = field(required=True)
97+
98+
# Computed field - automatically calculated
99+
total: float = field(
100+
computed=lambda data: data['quantity'] * data['price']
101+
)
102+
103+
# Usage:
104+
result = OrderInputFilter().validate_data({"quantity": 5, "price": 10.0})
105+
# → {"quantity": 5, "price": 10.0, "total": 50.0}
106+
```
107+
108+
**Computed Field Characteristics:**
109+
- Read-only (input values are ignored)
110+
- Evaluated during validation with access to previously processed fields
111+
- Non-blocking (errors set field to `None` and log warning)
112+
- Can use lambda or named functions
113+
- Can depend on other computed fields (order matters)
114+
- Works with filters, validators, copy, defaults, and fallbacks on dependencies
115+
- Useful for: totals, full names, derived booleans, calculations
116+
90117
## Project Structure
91118

92119
```

docs/source/changelog.rst

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ Changelog
44
All notable changes to this project will be documented in this file.
55

66

7+
[0.7.4] - 2025-10-08
8+
--------------------
9+
10+
Added
11+
^^^^^
12+
- **Computed Fields**: New feature to define read-only fields that are
13+
automatically calculated from other fields. Use the ``computed``
14+
parameter in ``field()`` to provide a callable that receives the current
15+
data dictionary and returns the computed value.
16+
See :doc:`Computed Fields documentation <options/field_decorator>` for more details.
17+
18+
719
[0.7.3] - 2025-10-08
820
--------------------
921

docs/source/options/field_decorator.rst

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -333,6 +333,51 @@ independently.
333333
334334
For detailed information, see :doc:`Copy <copy>`.
335335

336+
computed
337+
~~~~~~~~
338+
339+
**Type**: ``Callable[[dict[str, Any]], Any]``
340+
**Default**: ``None``
341+
342+
Define a read-only field that is automatically calculated from other fields.
343+
The provided callable receives the current data dictionary and should return the
344+
computed value.
345+
346+
Computed fields are:
347+
348+
- **Read-only**: Input values are ignored
349+
- **Non-blocking**: Errors let the field stay on ``None`` and log a warning
350+
- Evaluated **during validation**: Have access to all previously processed fields
351+
352+
.. code-block:: python
353+
354+
class OrderInputFilter(InputFilter):
355+
quantity: int = field(required=True)
356+
price: float = field(required=True)
357+
358+
# Computed field using lambda
359+
total: float = field(
360+
computed=lambda data: data['quantity'] * data['price']
361+
)
362+
363+
.. code-block:: python
364+
365+
# Using named function for complex calculations
366+
def calculate_tax(data):
367+
subtotal = data.get('subtotal', 0)
368+
tax_rate = data.get('tax_rate', 0.19)
369+
return subtotal * tax_rate
370+
371+
class InvoiceInputFilter(InputFilter):
372+
subtotal: float = field(required=True)
373+
tax_rate: float = field(default=0.19)
374+
375+
tax: float = field(computed=calculate_tax)
376+
total: float = field(
377+
required=True,
378+
computed=lambda data: data['subtotal'] + data.get('tax', 0)
379+
)
380+
336381
Advanced Field Patterns
337382
-----------------------
338383

flask_inputfilter/_input_filter.pyx

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -260,20 +260,23 @@ cdef class InputFilter:
260260
inheritance chain."""
261261
cdef object cls, attr_value, conditions, validators, filters, base_cls
262262
cdef str attr_name
263-
cdef list dir_attrs
264-
cdef FieldDescriptor field_desc
265263
cdef set added_conditions, added_global_validators, added_global_filters
266264
cdef object condition_id, validator_id, filter_id
267265
cdef object condition, validator, filter_instance
268266

269267
cls = self.__class__
270-
dir_attrs = dir(cls)
268+
added_conditions = set()
269+
added_global_validators = set()
270+
added_global_filters = set()
271271

272-
for attr_name in dir_attrs:
273-
if attr_name.startswith("_"):
272+
for base_cls in reversed(cls.__mro__):
273+
if base_cls is object:
274274
continue
275-
if hasattr(cls, attr_name):
276-
attr_value = getattr(cls, attr_name)
275+
276+
for attr_name, attr_value in base_cls.__dict__.items():
277+
if attr_name.startswith("_"):
278+
continue
279+
277280
if isinstance(attr_value, FieldDescriptor):
278281
self.fields[attr_name] = FieldModel(
279282
attr_value.required,
@@ -284,13 +287,9 @@ cdef class InputFilter:
284287
attr_value.steps,
285288
attr_value.external_api,
286289
attr_value.copy,
290+
attr_value.computed,
287291
)
288292

289-
added_conditions = set()
290-
added_global_validators = set()
291-
added_global_filters = set()
292-
293-
for base_cls in reversed(cls.__mro__):
294293
conditions = getattr(base_cls, "_conditions", None)
295294
if conditions is not None:
296295
for condition in conditions:
@@ -564,6 +563,7 @@ cdef class InputFilter:
564563
steps or [],
565564
external_api,
566565
copy,
566+
None, # computed (not supported in deprecated add method)
567567
)
568568

569569
cpdef bint has(self, str field_name):
@@ -689,6 +689,7 @@ cdef class InputFilter:
689689
steps or [],
690690
external_api,
691691
copy,
692+
None, # computed (not supported in deprecated add method)
692693
)
693694

694695
cpdef void add_global_filter(self, BaseFilter filter):

flask_inputfilter/declarative/_field_descriptor.pxd

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ cdef class FieldDescriptor:
1212
list steps
1313
ExternalApiConfig external_api
1414
str copy
15+
object computed
1516
cdef public:
1617
str name
1718

flask_inputfilter/declarative/_field_descriptor.pyx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ cdef class FieldDescriptor:
3939
list steps = None,
4040
object external_api = None,
4141
str copy = None,
42+
object computed = None,
4243
) -> None:
4344
"""
4445
Initialize a field descriptor.
@@ -59,6 +60,8 @@ cdef class FieldDescriptor:
5960
external API call.
6061
- **copy** (*Optional[str]*): The name of the field to copy the value
6162
from.
63+
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
64+
that computes the field value from validated data.
6265
"""
6366
self.required = required
6467
self._default = default
@@ -68,6 +71,7 @@ cdef class FieldDescriptor:
6871
self.steps = steps if steps is not None else []
6972
self.external_api = external_api
7073
self.copy = copy
74+
self.computed = computed
7175
self.name = None
7276
7377
@property

flask_inputfilter/declarative/field.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ def field(
2222
steps: Optional[list[Union[BaseFilter, BaseValidator]]] = None,
2323
external_api: Optional[ExternalApiConfig] = None,
2424
copy: Optional[str] = None,
25+
computed: Optional[Any] = None,
2526
) -> FieldDescriptor:
2627
"""
2728
Create a field descriptor for declarative field definition.
@@ -47,6 +48,10 @@ def field(
4748
external API call. Default: None.
4849
- **copy** (*Optional[str]*): The name of the field to copy the value
4950
from. Default: None.
51+
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
52+
that computes the field value from validated data. Computed fields are
53+
read-only and calculated after all regular fields are validated.
54+
Default: None.
5055
5156
**Returns:**
5257
@@ -73,4 +78,5 @@ class UserInputFilter(InputFilter):
7378
steps=steps,
7479
external_api=external_api,
7580
copy=copy,
81+
computed=computed,
7682
)

flask_inputfilter/declarative/field_descriptor.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ class FieldDescriptor:
3232
configuration.
3333
- **copy** (*Optional[str]*): Field to copy value from if this field
3434
is missing.
35+
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
36+
that computes the field value from validated data.
3537
3638
**Expected Behavior:**
3739
@@ -50,6 +52,7 @@ def __init__(
5052
steps: Optional[list[Union[BaseFilter, BaseValidator]]] = None,
5153
external_api: Optional[ExternalApiConfig] = None,
5254
copy: Optional[str] = None,
55+
computed: Optional[Any] = None,
5356
) -> None:
5457
"""
5558
Initialize a field descriptor.
@@ -72,6 +75,8 @@ def __init__(
7275
external API call.
7376
- **copy** (*Optional[str]*): The name of the field to copy the value
7477
from.
78+
- **computed** (*Optional[Callable[[dict[str, Any]], Any]]*): A callable
79+
that computes the field value from validated data.
7580
"""
7681
self.required = required
7782
self.default = default
@@ -81,6 +86,7 @@ def __init__(
8186
self.steps = steps or []
8287
self.external_api = external_api
8388
self.copy = copy
89+
self.computed = computed
8490
self.name: Optional[str] = None
8591

8692
def __set_name__(self, owner: type, name: str) -> None:
@@ -135,6 +141,10 @@ def __repr__(self) -> str:
135141
f"required={self.required}, "
136142
f"default={self.default!r}, "
137143
f"filters={len(self.filters)}, "
138-
f"validators={len(self.validators)}"
144+
f"validators={len(self.validators)}, "
145+
f"steps={len(self.steps)}, "
146+
f"external_api={self.external_api!r}, "
147+
f"copy={self.copy!r}, "
148+
f"computed={self.computed!r}, "
139149
f")"
140150
)

flask_inputfilter/declarative/field_descriptor.pyi

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ class FieldDescriptor:
4848
external_api: Optional[ExternalApiConfig]
4949
copy: Optional[str]
5050
name: Optional[str]
51+
computed: Optional[Any]
5152

5253
def __init__(
5354
self,
@@ -59,6 +60,7 @@ class FieldDescriptor:
5960
steps: Optional[list[Union[BaseFilter, BaseValidator]]] = None,
6061
external_api: Optional[ExternalApiConfig] = None,
6162
copy: Optional[str] = None,
63+
computed: Optional[Any] = None,
6264
) -> None: ...
6365
def __set_name__(self, owner: type, name: str) -> None: ...
6466
def __get__(self, obj: Any, objtype: Optional[type] = None) -> Any: ...

flask_inputfilter/input_filter.py

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -225,13 +225,19 @@ def _register_decorator_components(self) -> None:
225225
"""Register decorator-based components from the current class and
226226
inheritance chain."""
227227
cls = self.__class__
228-
dir_attrs = dir(cls)
229228

230-
for attr_name in dir_attrs:
231-
if attr_name.startswith("_"):
229+
added_conditions = set()
230+
added_global_validators = set()
231+
added_global_filters = set()
232+
233+
for base_cls in reversed(cls.__mro__):
234+
if base_cls is object:
232235
continue
233-
if hasattr(cls, attr_name):
234-
attr_value = getattr(cls, attr_name)
236+
237+
for attr_name, attr_value in base_cls.__dict__.items():
238+
if attr_name.startswith("_"):
239+
continue
240+
235241
if isinstance(attr_value, FieldDescriptor):
236242
self.fields[attr_name] = FieldModel(
237243
attr_value.required,
@@ -242,13 +248,9 @@ def _register_decorator_components(self) -> None:
242248
attr_value.steps,
243249
attr_value.external_api,
244250
attr_value.copy,
251+
attr_value.computed,
245252
)
246253

247-
added_conditions = set()
248-
added_global_validators = set()
249-
added_global_filters = set()
250-
251-
for base_cls in reversed(cls.__mro__):
252254
conditions = getattr(base_cls, "_conditions", None)
253255
if conditions is not None:
254256
for condition in conditions:

0 commit comments

Comments
 (0)