Skip to content

Commit f799adb

Browse files
committed
Cython decorator
1 parent 0c048cb commit f799adb

31 files changed

+763
-119
lines changed

docs/source/changelog.rst

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,49 @@ Changelog
33

44
All notable changes to this project will be documented in this file.
55

6+
7+
[0.7.0] - 2025-09-25
8+
--------------------
9+
10+
Added
11+
^^^^^
12+
- Added that fields, conditons, global filters and global validators can be
13+
added as decorator and do not require a self.add, self.add_condition,
14+
self.add_global_filter or self.add_global_validator call in the __init__.
15+
16+
**Before**:
17+
.. code-block:: python
18+
19+
class UpdateZipcodeInputFilter(InputFilter):
20+
def __init__(self):
21+
super().__init__()
22+
self.add(
23+
'id',
24+
required=True,
25+
filters=[ToIntegerFilter(), ToNullFilter()],
26+
validators=[
27+
IsIntegerValidator()
28+
]
29+
)
30+
31+
**After**:
32+
.. code-block:: python
33+
34+
class UpdateZipcodeInputFilter(InputFilter):
35+
id: int = field(
36+
required=True,
37+
filters=[ToIntegerFilter(), ToNullFilter()],
38+
validators=[
39+
IsIntegerValidator()
40+
]
41+
)
42+
43+
The Change is fully backward compatible, but the new way is more readable
44+
and maintainable.
45+
46+
You can also mix both ways inside a single InputFilter.
47+
48+
649
[0.6.3] - 2025-09-24
750
--------------------
851

examples/basic/filters/product_inputfilter.py

Lines changed: 5 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -22,28 +22,18 @@ class Tags(Enum):
2222

2323

2424
class ProductInputFilter(InputFilter):
25-
26-
name: str = field(
27-
required=True,
28-
validators=[
29-
IsStringValidator()
30-
]
31-
)
25+
name: str = field(required=True, validators=[IsStringValidator()])
3226

3327
price: float = field(
3428
required=True,
35-
filters=[
36-
ToFloatFilter()
37-
],
38-
validators=[
39-
IsFloatValidator()
40-
]
29+
filters=[ToFloatFilter()],
30+
validators=[IsFloatValidator()],
4131
)
4232

4333
tags: list = field(
4434
required=False,
4535
validators=[
4636
IsArrayValidator(),
47-
ArrayElementValidator(InEnumValidator(Tags))
48-
]
37+
ArrayElementValidator(InEnumValidator(Tags)),
38+
],
4939
)

examples/basic/filters/profile_inputfilter.py

Lines changed: 3 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -23,24 +23,13 @@ class Address:
2323

2424

2525
class ProfileInputFilter(InputFilter):
26-
2726
user: User = field(
28-
required=True,
29-
validators=[
30-
IsDataclassValidator(dataclass_type=User)
31-
]
27+
required=True, validators=[IsDataclassValidator(dataclass_type=User)]
3228
)
3329

3430
address: Address = field(
3531
required=True,
36-
validators=[
37-
IsDataclassValidator(dataclass_type=Address)
38-
]
32+
validators=[IsDataclassValidator(dataclass_type=Address)],
3933
)
4034

41-
phone: str = field(
42-
required=False,
43-
validators=[
44-
IsStringValidator()
45-
]
46-
)
35+
phone: str = field(required=False, validators=[IsStringValidator()])
Lines changed: 9 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,34 +1,22 @@
1+
from dataclasses import dataclass
2+
13
from flask_inputfilter import InputFilter
24
from flask_inputfilter.declarative import field
35
from flask_inputfilter.validators import IsIntegerValidator, IsStringValidator
46

7+
58
@dataclass
69
class User:
710
name: str
811
age: int
912
email: str
1013

11-
class UserInputFilter(InputFilter):
1214

15+
class UserInputFilter(InputFilter):
1316
_model = User
1417

15-
name: str = field(
16-
required=True,
17-
validators=[
18-
IsStringValidator()
19-
]
20-
)
21-
22-
age: int = field(
23-
required=True,
24-
validators=[
25-
IsIntegerValidator()
26-
]
27-
)
28-
29-
email: str = field(
30-
required=True,
31-
validators=[
32-
IsStringValidator()
33-
]
34-
)
18+
name: str = field(required=True, validators=[IsStringValidator()])
19+
20+
age: int = field(required=True, validators=[IsIntegerValidator()])
21+
22+
email: str = field(required=True, validators=[IsStringValidator()])

flask_inputfilter/_input_filter.pxd

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,8 @@ cdef class InputFilter:
4343
bint required=*,
4444
object default=*,
4545
object fallback=*,
46-
list filters=*,
47-
list validators=*,
46+
list[BaseFilter] filters=*,
47+
list[BaseValidator] validators=*,
4848
list steps=*,
4949
ExternalApiConfig external_api=*,
5050
str copy=*,
@@ -74,3 +74,5 @@ cdef class InputFilter:
7474
cpdef object serialize(self)
7575
cpdef void add_global_validator(self, BaseValidator validator)
7676
cpdef list[BaseValidator] get_global_validators(self)
77+
cdef void _set_methods(self, list methods)
78+
cdef void _register_decorator_components(self)

flask_inputfilter/_input_filter.pyx

Lines changed: 88 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,13 @@
11
# cython: language=c++
2+
# cython: boundscheck=False
3+
# cython: wraparound=False
4+
# cython: cdivision=True
5+
# cython: overflowcheck=False
6+
# cython: initializedcheck=False
7+
# cython: nonecheck=False
8+
# cython: optimize.use_switch=True
9+
# cython: optimize.unpack_method_calls=True
10+
# cython: infer_types=True
211

312
import json
413
import logging
@@ -9,11 +18,14 @@ from flask import Response, g, request
918

1019
from flask_inputfilter.exceptions import ValidationError
1120

21+
from flask_inputfilter.declarative.cimports cimport FieldDescriptor
1222
from flask_inputfilter.mixins.cimports cimport DataMixin
1323
from flask_inputfilter.models.cimports cimport BaseCondition, BaseFilter, BaseValidator, ExternalApiConfig, FieldModel
1424

1525
from libcpp.vector cimport vector
1626
from libcpp.string cimport string
27+
from libcpp cimport bool as cbool
28+
from libcpp.algorithm cimport find
1729

1830
cdef dict _INTERNED_STRINGS = {
1931
"_condition": sys.intern("_condition"),
@@ -45,7 +57,8 @@ cdef class InputFilter:
4557
"""
4658

4759
def __cinit__(self) -> None:
48-
self.methods = make_default_methods()
60+
cdef vector[string] default_methods = make_default_methods()
61+
self.methods = default_methods
4962
self.fields = {}
5063
self.conditions = []
5164
self.global_filters = []
@@ -57,8 +70,22 @@ cdef class InputFilter:
5770

5871
def __init__(self, methods: Optional[list[str]] = None) -> None:
5972
if methods is not None:
60-
self.methods.clear()
61-
[self.methods.push_back(method.encode()) for method in methods]
73+
self._set_methods(methods)
74+
75+
self._register_decorator_components()
76+
77+
cdef void _set_methods(self, list methods):
78+
"""Efficiently set HTTP methods using C++ vector operations."""
79+
cdef str method
80+
cdef bytes encoded_method
81+
cdef Py_ssize_t n = len(methods)
82+
83+
self.methods.clear()
84+
self.methods.reserve(n)
85+
86+
for method in methods:
87+
encoded_method = method.encode('utf-8')
88+
self.methods.push_back(string(encoded_method))
6289

6390
cpdef bint is_valid(self):
6491
"""
@@ -122,8 +149,14 @@ cdef class InputFilter:
122149
"""
123150

124151
cdef InputFilter input_filter = cls()
125-
cdef string request_method = request.method.encode()
126-
if not any(request_method == method for method in input_filter.methods):
152+
cdef bytes request_method_bytes = request.method.encode('utf-8')
153+
cdef string request_method = string(request_method_bytes)
154+
cdef vector[string].iterator method_it = find(
155+
input_filter.methods.begin(),
156+
input_filter.methods.end(),
157+
request_method
158+
)
159+
if method_it == input_filter.methods.end():
127160
return Response(status=405, response="Method Not Allowed")
128161

129162
if request.is_json:
@@ -206,6 +239,48 @@ cdef class InputFilter:
206239
"""
207240
self.conditions.append(condition)
208241

242+
cdef void _register_decorator_components(self):
243+
"""Register decorator-based components from the current class only."""
244+
cdef object cls, attr_value, conditions, validators, filters
245+
cdef str attr_name
246+
cdef list dir_attrs
247+
cdef FieldDescriptor field_desc
248+
249+
cls = self.__class__
250+
dir_attrs = dir(cls)
251+
252+
for attr_name in dir_attrs:
253+
if (<bytes>attr_name.encode('utf-8')).startswith(b"_"):
254+
continue
255+
256+
attr_value = getattr(cls, attr_name, None)
257+
if attr_value is not None and isinstance(attr_value, FieldDescriptor):
258+
field_desc = <FieldDescriptor>attr_value
259+
self.fields[attr_name] = FieldModel(
260+
field_desc.required,
261+
field_desc._default,
262+
field_desc.fallback,
263+
field_desc.filters,
264+
field_desc.validators,
265+
field_desc.steps,
266+
field_desc.external_api,
267+
field_desc.copy,
268+
)
269+
270+
conditions = getattr(cls, "_conditions", None)
271+
if conditions is not None:
272+
self.conditions.extend(conditions)
273+
274+
validators = getattr(cls, "_global_validators", None)
275+
if validators is not None:
276+
self.global_validators.extend(validators)
277+
278+
filters = getattr(cls, "_global_filters", None)
279+
if filters is not None:
280+
self.global_filters.extend(filters)
281+
282+
self.model_class = getattr(cls, "_model", self.model_class)
283+
209284
cpdef list get_conditions(self):
210285
"""
211286
Retrieve the list of all registered conditions.
@@ -307,14 +382,19 @@ cdef class InputFilter:
307382

308383
cdef:
309384
Py_ssize_t i, n = len(self.fields)
310-
dict result = {}
385+
dict result
311386
list field_names = list(self.fields.keys())
312387
str field
388+
object field_value
389+
390+
# Pre-allocate dictionary size for better performance
391+
result = {}
313392

314393
for i in range(n):
315394
field = field_names[i]
316-
if field in self.data:
317-
result[field] = self.data[field]
395+
field_value = self.data.get(field)
396+
if field_value is not None:
397+
result[field] = field_value
318398
return result
319399

320400
cpdef dict[str, Any] get_unfiltered_data(self):

flask_inputfilter/declarative/__init__.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
1-
from .factory_functions import field
2-
from .field_descriptor import FieldDescriptor
1+
try:
2+
from ._factory_functions import field
3+
from ._field_descriptor import FieldDescriptor
4+
except ImportError:
5+
from .factory_functions import field
6+
from .field_descriptor import FieldDescriptor
37

48
__all__ = [
59
"FieldDescriptor",
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
# cython: language_level=3
2+
3+
from flask_inputfilter.models.cimports cimport BaseFilter, BaseValidator, ExternalApiConfig
4+
from ._field_descriptor cimport FieldDescriptor
5+
6+
cpdef FieldDescriptor field(
7+
bint required=*,
8+
object default=*,
9+
object fallback=*,
10+
list[BaseFilter] filters=*,
11+
list[BaseValidator] validators=*,
12+
list steps=*,
13+
ExternalApiConfig external_api=*,
14+
str copy=*
15+
)

0 commit comments

Comments
 (0)