Skip to content

Commit 3a26985

Browse files
committed
Update ruff rules to enforce typing
1 parent e3b1acb commit 3a26985

23 files changed

+520
-251
lines changed

docs/source/options/condition.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,13 +38,15 @@ Available Conditions
3838

3939
- `ArrayLengthEqualCondition <#flask_inputfilter.conditions.ArrayLengthEqualCondition>`_
4040
- `ArrayLongerThanCondition <#flask_inputfilter.conditions.ArrayLongerThanCondition>`_
41+
- `AtLeastOneRequiredCondition <#flask_inputfilter.conditions.AtLeastOneRequiredCondition>`_
4142
- `CustomCondition <#flask_inputfilter.conditions.CustomCondition>`_
4243
- `EqualCondition <#flask_inputfilter.conditions.EqualCondition>`_
4344
- `ExactlyNOfCondition <#flask_inputfilter.conditions.ExactlyNOfCondition>`_
4445
- `ExactlyNOfMatchesCondition <#flask_inputfilter.conditions.ExactlyNOfMatchesCondition>`_
4546
- `ExactlyOneOfCondition <#flask_inputfilter.conditions.ExactlyOneOfCondition>`_
4647
- `ExactlyOneOfMatchesCondition <#flask_inputfilter.conditions.ExactlyOneOfMatchesCondition>`_
4748
- `IntegerBiggerThanCondition <#flask_inputfilter.conditions.IntegerBiggerThanCondition>`_
49+
- `MutuallyExclusiveCondition <#flask_inputfilter.conditions.MutuallyExclusiveCondition>`_
4850
- `NOfCondition <#flask_inputfilter.conditions.NOfCondition>`_
4951
- `NOfMatchesCondition <#flask_inputfilter.conditions.NOfMatchesCondition>`_
5052
- `NotEqualCondition <#flask_inputfilter.conditions.NotEqualCondition>`_

docs/source/options/filter.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,8 @@ Available Filters
4242
- `Base64ImageResizeFilter <#flask_inputfilter.filters.Base64ImageResizeFilter>`_
4343
- `BlacklistFilter <#flask_inputfilter.filters.BlacklistFilter>`_
4444
- `LazyFilterChain <#flask_inputfilter.filters.LazyFilterChain>`_
45+
- `PhoneNumberNormalizeFilter <#flask_inputfilter.filters.PhoneNumberNormalizeFilter>`_
46+
- `SanitizeHtmlFilter <#flask_inputfilter.filters.SanitizeHtmlFilter>`_
4547
- `StringRemoveEmojisFilter <#flask_inputfilter.filters.StringRemoveEmojisFilter>`_
4648
- `StringSlugifyFilter <#flask_inputfilter.filters.StringSlugifyFilter>`_
4749
- `StringTrimFilter <#flask_inputfilter.filters.StringTrimFilter>`_

flask_inputfilter/conditions/at_least_one_required_condition.py

Lines changed: 133 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,98 @@
77

88
class AtLeastOneRequiredCondition(BaseCondition):
99
"""
10-
Ensures that at least one field from a list is present.
11-
12-
This condition validates that at least one field from a specified list of
13-
fields is present and has a non-empty value.
10+
Ensures that at least N fields from a list are present and non-empty.
11+
12+
**Parameters:**
13+
14+
- **fields** (*list[str]*): List of field names to check for presence.
15+
- **min_required** (*int*, default: ``1``): Minimum number of fields that
16+
must be present and non-empty.
17+
- **check_empty** (*bool*, default: ``True``): Whether to consider empty
18+
strings, empty lists, and other "falsy" values as absent.
19+
- **custom_message** (*str*, optional): Custom error message to display
20+
when the condition fails.
21+
22+
**Expected Behavior:**
23+
24+
- Validates that at least ``min_required`` fields are present
25+
- Fields are considered "present" if they exist and are not None
26+
- With ``check_empty=True``, empty strings and empty containers count as
27+
absent
28+
- Provides clear error messages indicating how many fields are required vs.
29+
provided
30+
31+
**Use Cases:**
32+
33+
- **Contact information**: At least one of phone, email, or address
34+
- **Flexible forms**: Multiple optional ways to provide the same
35+
information
36+
- **Alternative fields**: Primary field or one of several backup fields
37+
- **Partial requirements**: At least N out of M total fields
38+
39+
**Example Usage:**
40+
41+
.. code-block:: python
42+
43+
class ProfileCompletionFilter(InputFilter):
44+
def __init__(self):
45+
super().__init__()
46+
47+
# All profile fields are optional individually
48+
self.add('bio')
49+
self.add('avatar_url')
50+
self.add('website')
51+
self.add('social_links')
52+
self.add('skills')
53+
self.add('certifications')
54+
55+
# At least 3 profile fields must be completed for profile visibility
56+
self.add_condition(
57+
AtLeastOneRequiredCondition(
58+
[
59+
'bio', 'avatar_url', 'website', 'social_links',
60+
'skills', 'certifications'
61+
],
62+
min_required=3,
63+
check_empty=True,
64+
custom_message="Complete at least 3 profile sections"
65+
)
66+
)
67+
68+
**Validation Examples:**
69+
70+
.. code-block:: python
71+
72+
# Valid: phone provided
73+
{"phone": "555-1234", "email": "", "address": ""}
74+
75+
# Valid: email and address provided (exceeds minimum)
76+
{"phone": "", "email": "[email protected]", "address": "123 Main St"}
77+
78+
# Invalid: all fields empty
79+
{"phone": "", "email": "", "address": ""}
80+
81+
# Invalid: all fields None or missing
82+
{"phone": None}
83+
84+
# With min_required=2:
85+
# Valid: 2 fields provided
86+
{"bio": "Hello", "avatar_url": "http://...", "website": ""}
87+
88+
# Invalid: only 1 field provided
89+
{"bio": "Hello", "avatar_url": "", "website": ""}
90+
91+
**Empty Value Handling:**
92+
93+
When ``check_empty=True`` (default), these values are considered absent:
94+
- ``None``
95+
- Empty string ``""``
96+
- Empty list ``[]``
97+
- Empty dict ``{}``
98+
- Zero ``0`` (depending on context)
99+
100+
When ``check_empty=False``, only ``None`` and missing keys are considered
101+
absent.
14102
"""
15103

16104
def __init__(
@@ -38,6 +126,39 @@ def __init__(
38126
self.check_empty = check_empty
39127
self.custom_message = custom_message
40128

129+
def _is_empty(self, value: Any) -> bool:
130+
"""
131+
Check if a value is considered empty.
132+
133+
Args:
134+
value: The value to check
135+
136+
Returns:
137+
True if value is empty, False otherwise
138+
"""
139+
if value is None:
140+
return True
141+
if not self.check_empty:
142+
return False
143+
if isinstance(value, str) and value.strip() == "":
144+
return True
145+
return isinstance(value, (list, dict)) and len(value) == 0
146+
147+
def _count_present_fields(self, data: Dict[str, Any]) -> int:
148+
"""
149+
Count the number of present (non-empty) fields.
150+
151+
Args:
152+
data: The data dictionary
153+
154+
Returns:
155+
Number of present fields
156+
"""
157+
return sum(
158+
1 for field in self.fields
159+
if field in data and not self._is_empty(data[field])
160+
)
161+
41162
def check(self, data: Dict[str, Any]) -> bool:
42163
"""
43164
Check if at least the minimum required fields are present.
@@ -51,26 +172,7 @@ def check(self, data: Dict[str, Any]) -> bool:
51172
if not isinstance(data, dict):
52173
return False
53174

54-
present_count = 0
55-
56-
for field in self.fields:
57-
if field in data and data[field] is not None:
58-
if (
59-
self.check_empty
60-
and isinstance(data[field], str)
61-
and data[field].strip() == ""
62-
):
63-
continue
64-
65-
if (
66-
self.check_empty
67-
and isinstance(data[field], (list, dict))
68-
and len(data[field]) == 0
69-
):
70-
continue
71-
72-
present_count += 1
73-
175+
present_count = self._count_present_fields(data)
74176
return present_count >= self.min_required
75177

76178
def get_error_message(self, data: Dict[str, Any]) -> str:
@@ -86,35 +188,17 @@ def get_error_message(self, data: Dict[str, Any]) -> str:
86188
if self.custom_message:
87189
return self.custom_message
88190

89-
present_fields = []
90-
missing_fields = []
91-
92-
for field in self.fields:
93-
if field in data and data[field] is not None:
94-
if self.check_empty:
95-
if (
96-
isinstance(data[field], str)
97-
and data[field].strip() == ""
98-
) or (
99-
isinstance(data[field], (list, dict))
100-
and len(data[field]) == 0
101-
):
102-
missing_fields.append(field)
103-
else:
104-
present_fields.append(field)
105-
else:
106-
present_fields.append(field)
107-
else:
108-
missing_fields.append(field)
191+
present_count = self._count_present_fields(data)
192+
fields_list = ", ".join(self.fields)
109193

110194
if self.min_required == 1:
111-
fields_list = ", ".join(self.fields)
112195
return (
113-
f"At least one of the following fields is required: {fields_list}. "
114-
f"None were provided or all were empty."
196+
f"At least one of the following fields is required: "
197+
f"{fields_list}. None were provided or all were empty."
115198
)
116-
fields_list = ", ".join(self.fields)
199+
117200
return (
118201
f"At least {self.min_required} of the following fields "
119-
f"are required: {fields_list}. Only {len(present_fields)} provided."
202+
f"are required: {fields_list}. "
203+
f"Only {present_count} provided."
120204
)

0 commit comments

Comments
 (0)