valid8r 1.6.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- valid8r/__init__.py +39 -0
- valid8r/_version.py +34 -0
- valid8r/core/__init__.py +28 -0
- valid8r/core/combinators.py +89 -0
- valid8r/core/maybe.py +170 -0
- valid8r/core/parsers.py +2115 -0
- valid8r/core/validators.py +982 -0
- valid8r/integrations/__init__.py +57 -0
- valid8r/integrations/click.py +143 -0
- valid8r/integrations/env.py +220 -0
- valid8r/integrations/pydantic.py +196 -0
- valid8r/prompt/__init__.py +8 -0
- valid8r/prompt/basic.py +229 -0
- valid8r/py.typed +0 -0
- valid8r/testing/__init__.py +32 -0
- valid8r/testing/assertions.py +67 -0
- valid8r/testing/generators.py +283 -0
- valid8r/testing/mock_input.py +84 -0
- valid8r-1.6.0.dist-info/METADATA +504 -0
- valid8r-1.6.0.dist-info/RECORD +23 -0
- valid8r-1.6.0.dist-info/WHEEL +4 -0
- valid8r-1.6.0.dist-info/entry_points.txt +3 -0
- valid8r-1.6.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
"""Core validators for validating values against specific criteria.
|
|
2
|
+
|
|
3
|
+
This module provides a collection of validator functions for common validation scenarios.
|
|
4
|
+
All validators follow the same pattern - they take a value and return a Maybe object
|
|
5
|
+
that either contains the validated value or an error message.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
import re
|
|
12
|
+
from typing import (
|
|
13
|
+
TYPE_CHECKING,
|
|
14
|
+
Generic,
|
|
15
|
+
Protocol,
|
|
16
|
+
TypeVar,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
from valid8r.core.combinators import (
|
|
20
|
+
and_then,
|
|
21
|
+
not_validator,
|
|
22
|
+
or_else,
|
|
23
|
+
)
|
|
24
|
+
from valid8r.core.maybe import Maybe
|
|
25
|
+
|
|
26
|
+
if TYPE_CHECKING:
|
|
27
|
+
from collections.abc import Callable
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SupportsComparison(Protocol): # noqa: D101
|
|
32
|
+
def __le__(self, other: object, /) -> bool: ... # noqa: D105
|
|
33
|
+
def __lt__(self, other: object, /) -> bool: ... # noqa: D105
|
|
34
|
+
def __ge__(self, other: object, /) -> bool: ... # noqa: D105
|
|
35
|
+
def __gt__(self, other: object, /) -> bool: ... # noqa: D105
|
|
36
|
+
def __eq__(self, other: object, /) -> bool: ... # noqa: D105
|
|
37
|
+
def __ne__(self, other: object, /) -> bool: ... # noqa: D105
|
|
38
|
+
def __hash__(self, /) -> int: ... # noqa: D105
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
T = TypeVar('T')
|
|
42
|
+
U = TypeVar('U')
|
|
43
|
+
N = TypeVar('N', bound=SupportsComparison)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class Validator(Generic[T]):
|
|
47
|
+
"""A wrapper class for validator functions that supports operator overloading."""
|
|
48
|
+
|
|
49
|
+
def __init__(self, func: Callable[[T], Maybe[T]]) -> None:
|
|
50
|
+
"""Initialize a validator with a validation function.
|
|
51
|
+
|
|
52
|
+
Args:
|
|
53
|
+
func: A function that takes a value and returns a Maybe
|
|
54
|
+
|
|
55
|
+
"""
|
|
56
|
+
self.func = func
|
|
57
|
+
|
|
58
|
+
def __call__(self, value: T) -> Maybe[T]:
|
|
59
|
+
"""Apply the validator to a value.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
value: The value to validate
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
A Maybe containing either the validated value or an error
|
|
66
|
+
|
|
67
|
+
"""
|
|
68
|
+
return self.func(value)
|
|
69
|
+
|
|
70
|
+
def __and__(self, other: Validator[T]) -> Validator[T]:
|
|
71
|
+
"""Combine with another validator using logical AND.
|
|
72
|
+
|
|
73
|
+
Args:
|
|
74
|
+
other: Another validator to combine with
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
A new validator that passes only if both validators pass
|
|
78
|
+
|
|
79
|
+
"""
|
|
80
|
+
return Validator(lambda value: and_then(self.func, other.func)(value))
|
|
81
|
+
|
|
82
|
+
def __or__(self, other: Validator[T]) -> Validator[T]:
|
|
83
|
+
"""Combine with another validator using logical OR.
|
|
84
|
+
|
|
85
|
+
Args:
|
|
86
|
+
other: Another validator to combine with
|
|
87
|
+
|
|
88
|
+
Returns:
|
|
89
|
+
A new validator that passes if either validator passes
|
|
90
|
+
|
|
91
|
+
"""
|
|
92
|
+
return Validator(lambda value: or_else(self.func, other.func)(value))
|
|
93
|
+
|
|
94
|
+
def __invert__(self) -> Validator[T]:
|
|
95
|
+
"""Negate this validator.
|
|
96
|
+
|
|
97
|
+
Returns:
|
|
98
|
+
A new validator that passes if this validator fails
|
|
99
|
+
|
|
100
|
+
"""
|
|
101
|
+
return Validator(lambda value: not_validator(self.func, 'Negated validation failed')(value))
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def minimum(min_value: N, error_message: str | None = None) -> Validator[N]:
|
|
105
|
+
"""Create a validator that ensures a value is at least the minimum.
|
|
106
|
+
|
|
107
|
+
Args:
|
|
108
|
+
min_value: The minimum allowed value (inclusive)
|
|
109
|
+
error_message: Optional custom error message
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
Validator[N]: A validator function that accepts values >= min_value
|
|
113
|
+
|
|
114
|
+
Examples:
|
|
115
|
+
>>> from valid8r.core.validators import minimum
|
|
116
|
+
>>> validator = minimum(0)
|
|
117
|
+
>>> validator(5)
|
|
118
|
+
Success(5)
|
|
119
|
+
>>> validator(0)
|
|
120
|
+
Success(0)
|
|
121
|
+
>>> validator(-1).is_failure()
|
|
122
|
+
True
|
|
123
|
+
>>> # With custom error message
|
|
124
|
+
>>> validator = minimum(18, error_message="Must be an adult")
|
|
125
|
+
>>> validator(17).error_or("")
|
|
126
|
+
'Must be an adult'
|
|
127
|
+
|
|
128
|
+
"""
|
|
129
|
+
|
|
130
|
+
def validator(value: N) -> Maybe[N]:
|
|
131
|
+
if value >= min_value:
|
|
132
|
+
return Maybe.success(value)
|
|
133
|
+
return Maybe.failure(error_message or f'Value must be at least {min_value}')
|
|
134
|
+
|
|
135
|
+
return Validator(validator)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def maximum(max_value: N, error_message: str | None = None) -> Validator[N]:
|
|
139
|
+
"""Create a validator that ensures a value is at most the maximum.
|
|
140
|
+
|
|
141
|
+
Args:
|
|
142
|
+
max_value: The maximum allowed value (inclusive)
|
|
143
|
+
error_message: Optional custom error message
|
|
144
|
+
|
|
145
|
+
Returns:
|
|
146
|
+
Validator[N]: A validator function that accepts values <= max_value
|
|
147
|
+
|
|
148
|
+
Examples:
|
|
149
|
+
>>> from valid8r.core.validators import maximum
|
|
150
|
+
>>> validator = maximum(100)
|
|
151
|
+
>>> validator(50)
|
|
152
|
+
Success(50)
|
|
153
|
+
>>> validator(100)
|
|
154
|
+
Success(100)
|
|
155
|
+
>>> validator(101).is_failure()
|
|
156
|
+
True
|
|
157
|
+
>>> # With custom error message
|
|
158
|
+
>>> validator = maximum(120, error_message="Age too high")
|
|
159
|
+
>>> validator(150).error_or("")
|
|
160
|
+
'Age too high'
|
|
161
|
+
|
|
162
|
+
"""
|
|
163
|
+
|
|
164
|
+
def validator(value: N) -> Maybe[N]:
|
|
165
|
+
if value <= max_value:
|
|
166
|
+
return Maybe.success(value)
|
|
167
|
+
return Maybe.failure(error_message or f'Value must be at most {max_value}')
|
|
168
|
+
|
|
169
|
+
return Validator(validator)
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def between(min_value: N, max_value: N, error_message: str | None = None) -> Validator[N]:
|
|
173
|
+
"""Create a validator that ensures a value is between minimum and maximum (inclusive).
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
min_value: The minimum allowed value (inclusive)
|
|
177
|
+
max_value: The maximum allowed value (inclusive)
|
|
178
|
+
error_message: Optional custom error message
|
|
179
|
+
|
|
180
|
+
Returns:
|
|
181
|
+
Validator[N]: A validator function that accepts values where min_value <= value <= max_value
|
|
182
|
+
|
|
183
|
+
Examples:
|
|
184
|
+
>>> from valid8r.core.validators import between
|
|
185
|
+
>>> validator = between(0, 100)
|
|
186
|
+
>>> validator(50)
|
|
187
|
+
Success(50)
|
|
188
|
+
>>> validator(0)
|
|
189
|
+
Success(0)
|
|
190
|
+
>>> validator(100)
|
|
191
|
+
Success(100)
|
|
192
|
+
>>> validator(-1).is_failure()
|
|
193
|
+
True
|
|
194
|
+
>>> validator(101).is_failure()
|
|
195
|
+
True
|
|
196
|
+
>>> # With custom error message
|
|
197
|
+
>>> validator = between(1, 10, error_message="Rating must be 1-10")
|
|
198
|
+
>>> validator(11).error_or("")
|
|
199
|
+
'Rating must be 1-10'
|
|
200
|
+
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def validator(value: N) -> Maybe[N]:
|
|
204
|
+
if min_value <= value <= max_value:
|
|
205
|
+
return Maybe.success(value)
|
|
206
|
+
return Maybe.failure(error_message or f'Value must be between {min_value} and {max_value}')
|
|
207
|
+
|
|
208
|
+
return Validator(validator)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def predicate(pred: Callable[[T], bool], error_message: str) -> Validator[T]:
|
|
212
|
+
"""Create a validator using a custom predicate function.
|
|
213
|
+
|
|
214
|
+
Allows creating custom validators for any validation logic by providing
|
|
215
|
+
a predicate function that returns True for valid values.
|
|
216
|
+
|
|
217
|
+
Args:
|
|
218
|
+
pred: A function that takes a value and returns True if valid, False otherwise
|
|
219
|
+
error_message: Error message to return when validation fails
|
|
220
|
+
|
|
221
|
+
Returns:
|
|
222
|
+
Validator[T]: A validator function that applies the predicate
|
|
223
|
+
|
|
224
|
+
Examples:
|
|
225
|
+
>>> from valid8r.core.validators import predicate
|
|
226
|
+
>>> # Validate even numbers
|
|
227
|
+
>>> is_even = predicate(lambda x: x % 2 == 0, "Must be even")
|
|
228
|
+
>>> is_even(4)
|
|
229
|
+
Success(4)
|
|
230
|
+
>>> is_even(3).is_failure()
|
|
231
|
+
True
|
|
232
|
+
>>> # Validate string patterns
|
|
233
|
+
>>> starts_with_a = predicate(lambda s: s.startswith('a'), "Must start with 'a'")
|
|
234
|
+
>>> starts_with_a("apple")
|
|
235
|
+
Success('apple')
|
|
236
|
+
>>> starts_with_a("banana").error_or("")
|
|
237
|
+
"Must start with 'a'"
|
|
238
|
+
|
|
239
|
+
"""
|
|
240
|
+
|
|
241
|
+
def validator(value: T) -> Maybe[T]:
|
|
242
|
+
if pred(value):
|
|
243
|
+
return Maybe.success(value)
|
|
244
|
+
return Maybe.failure(error_message)
|
|
245
|
+
|
|
246
|
+
return Validator(validator)
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def length(min_length: int, max_length: int, error_message: str | None = None) -> Validator[str]:
|
|
250
|
+
"""Create a validator that ensures a string's length is within bounds.
|
|
251
|
+
|
|
252
|
+
Args:
|
|
253
|
+
min_length: Minimum length of the string (inclusive)
|
|
254
|
+
max_length: Maximum length of the string (inclusive)
|
|
255
|
+
error_message: Optional custom error message
|
|
256
|
+
|
|
257
|
+
Returns:
|
|
258
|
+
Validator[str]: A validator function that checks string length
|
|
259
|
+
|
|
260
|
+
Examples:
|
|
261
|
+
>>> from valid8r.core.validators import length
|
|
262
|
+
>>> validator = length(3, 10)
|
|
263
|
+
>>> validator("hello")
|
|
264
|
+
Success('hello')
|
|
265
|
+
>>> validator("abc")
|
|
266
|
+
Success('abc')
|
|
267
|
+
>>> validator("abcdefghij")
|
|
268
|
+
Success('abcdefghij')
|
|
269
|
+
>>> validator("ab").is_failure()
|
|
270
|
+
True
|
|
271
|
+
>>> validator("abcdefghijk").is_failure()
|
|
272
|
+
True
|
|
273
|
+
>>> # With custom error message
|
|
274
|
+
>>> validator = length(8, 20, error_message="Password must be 8-20 characters")
|
|
275
|
+
>>> validator("short").error_or("")
|
|
276
|
+
'Password must be 8-20 characters'
|
|
277
|
+
|
|
278
|
+
"""
|
|
279
|
+
|
|
280
|
+
def validator(value: str) -> Maybe[str]:
|
|
281
|
+
if min_length <= len(value) <= max_length:
|
|
282
|
+
return Maybe.success(value)
|
|
283
|
+
return Maybe.failure(error_message or f'String length must be between {min_length} and {max_length}')
|
|
284
|
+
|
|
285
|
+
return Validator(validator)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def matches_regex(pattern: str | re.Pattern[str], error_message: str | None = None) -> Validator[str]:
|
|
289
|
+
r"""Create a validator that ensures a string matches a regular expression pattern.
|
|
290
|
+
|
|
291
|
+
Args:
|
|
292
|
+
pattern: Regular expression pattern (string or compiled Pattern object)
|
|
293
|
+
error_message: Optional custom error message
|
|
294
|
+
|
|
295
|
+
Returns:
|
|
296
|
+
Validator[str]: A validator function that checks pattern matching
|
|
297
|
+
|
|
298
|
+
Examples:
|
|
299
|
+
>>> from valid8r.core.validators import matches_regex
|
|
300
|
+
>>> import re
|
|
301
|
+
>>> # String pattern
|
|
302
|
+
>>> validator = matches_regex(r'^\\d{3}-\\d{2}-\\d{4}$')
|
|
303
|
+
>>> validator('123-45-6789')
|
|
304
|
+
Success('123-45-6789')
|
|
305
|
+
>>> validator('invalid').is_failure()
|
|
306
|
+
True
|
|
307
|
+
>>> # Compiled regex pattern
|
|
308
|
+
>>> pattern = re.compile(r'^[A-Z][a-z]+$')
|
|
309
|
+
>>> validator = matches_regex(pattern)
|
|
310
|
+
>>> validator('Hello')
|
|
311
|
+
Success('Hello')
|
|
312
|
+
>>> validator('hello').is_failure()
|
|
313
|
+
True
|
|
314
|
+
>>> # With custom error message
|
|
315
|
+
>>> validator = matches_regex(r'^\\d{5}$', error_message='Must be a 5-digit ZIP code')
|
|
316
|
+
>>> validator('1234').error_or('')
|
|
317
|
+
'Must be a 5-digit ZIP code'
|
|
318
|
+
|
|
319
|
+
"""
|
|
320
|
+
compiled_pattern = re.compile(pattern) if isinstance(pattern, str) else pattern
|
|
321
|
+
|
|
322
|
+
def validator(value: str) -> Maybe[str]:
|
|
323
|
+
if compiled_pattern.match(value):
|
|
324
|
+
return Maybe.success(value)
|
|
325
|
+
return Maybe.failure(error_message or f'Value must match pattern {compiled_pattern.pattern}')
|
|
326
|
+
|
|
327
|
+
return Validator(validator)
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def in_set(allowed_values: set[T], error_message: str | None = None) -> Validator[T]:
|
|
331
|
+
"""Create a validator that ensures a value is in a set of allowed values.
|
|
332
|
+
|
|
333
|
+
Args:
|
|
334
|
+
allowed_values: Set of allowed values
|
|
335
|
+
error_message: Optional custom error message
|
|
336
|
+
|
|
337
|
+
Returns:
|
|
338
|
+
Validator[T]: A validator function that checks membership
|
|
339
|
+
|
|
340
|
+
Examples:
|
|
341
|
+
>>> from valid8r.core.validators import in_set
|
|
342
|
+
>>> # String values
|
|
343
|
+
>>> validator = in_set({'red', 'green', 'blue'})
|
|
344
|
+
>>> validator('red')
|
|
345
|
+
Success('red')
|
|
346
|
+
>>> validator('yellow').is_failure()
|
|
347
|
+
True
|
|
348
|
+
>>> # Numeric values
|
|
349
|
+
>>> validator = in_set({1, 2, 3, 4, 5})
|
|
350
|
+
>>> validator(3)
|
|
351
|
+
Success(3)
|
|
352
|
+
>>> validator(10).is_failure()
|
|
353
|
+
True
|
|
354
|
+
>>> # With custom error message
|
|
355
|
+
>>> validator = in_set({'small', 'medium', 'large'}, error_message='Size must be S, M, or L')
|
|
356
|
+
>>> validator('extra-large').error_or('')
|
|
357
|
+
'Size must be S, M, or L'
|
|
358
|
+
|
|
359
|
+
"""
|
|
360
|
+
|
|
361
|
+
def validator(value: T) -> Maybe[T]:
|
|
362
|
+
if value in allowed_values:
|
|
363
|
+
return Maybe.success(value)
|
|
364
|
+
return Maybe.failure(error_message or f'Value must be one of {allowed_values}')
|
|
365
|
+
|
|
366
|
+
return Validator(validator)
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def non_empty_string(error_message: str | None = None) -> Validator[str]:
|
|
370
|
+
"""Create a validator that ensures a string is not empty.
|
|
371
|
+
|
|
372
|
+
Validates that a string contains at least one non-whitespace character.
|
|
373
|
+
Both empty strings and whitespace-only strings are rejected.
|
|
374
|
+
|
|
375
|
+
Args:
|
|
376
|
+
error_message: Optional custom error message
|
|
377
|
+
|
|
378
|
+
Returns:
|
|
379
|
+
Validator[str]: A validator function that checks for non-empty strings
|
|
380
|
+
|
|
381
|
+
Examples:
|
|
382
|
+
>>> from valid8r.core.validators import non_empty_string
|
|
383
|
+
>>> validator = non_empty_string()
|
|
384
|
+
>>> validator('hello')
|
|
385
|
+
Success('hello')
|
|
386
|
+
>>> validator(' hello ')
|
|
387
|
+
Success(' hello ')
|
|
388
|
+
>>> validator('').is_failure()
|
|
389
|
+
True
|
|
390
|
+
>>> validator(' ').is_failure()
|
|
391
|
+
True
|
|
392
|
+
>>> # With custom error message
|
|
393
|
+
>>> validator = non_empty_string(error_message='Name is required')
|
|
394
|
+
>>> validator('').error_or('')
|
|
395
|
+
'Name is required'
|
|
396
|
+
|
|
397
|
+
"""
|
|
398
|
+
|
|
399
|
+
def validator(value: str) -> Maybe[str]:
|
|
400
|
+
if value.strip():
|
|
401
|
+
return Maybe.success(value)
|
|
402
|
+
return Maybe.failure(error_message or 'String must not be empty')
|
|
403
|
+
|
|
404
|
+
return Validator(validator)
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def unique_items(error_message: str | None = None) -> Validator[list[T]]:
|
|
408
|
+
"""Create a validator that ensures all items in a list are unique.
|
|
409
|
+
|
|
410
|
+
Validates that a list contains no duplicate elements by comparing
|
|
411
|
+
the list length to the set length.
|
|
412
|
+
|
|
413
|
+
Args:
|
|
414
|
+
error_message: Optional custom error message
|
|
415
|
+
|
|
416
|
+
Returns:
|
|
417
|
+
Validator[list[T]]: A validator function that checks for unique items
|
|
418
|
+
|
|
419
|
+
Examples:
|
|
420
|
+
>>> from valid8r.core.validators import unique_items
|
|
421
|
+
>>> validator = unique_items()
|
|
422
|
+
>>> validator([1, 2, 3, 4, 5])
|
|
423
|
+
Success([1, 2, 3, 4, 5])
|
|
424
|
+
>>> validator([1, 2, 2, 3]).is_failure()
|
|
425
|
+
True
|
|
426
|
+
>>> # Works with strings
|
|
427
|
+
>>> validator(['a', 'b', 'c'])
|
|
428
|
+
Success(['a', 'b', 'c'])
|
|
429
|
+
>>> validator(['a', 'b', 'a']).is_failure()
|
|
430
|
+
True
|
|
431
|
+
>>> # With custom error message
|
|
432
|
+
>>> validator = unique_items(error_message='Duplicate items found')
|
|
433
|
+
>>> validator([1, 1, 2]).error_or('')
|
|
434
|
+
'Duplicate items found'
|
|
435
|
+
|
|
436
|
+
"""
|
|
437
|
+
|
|
438
|
+
def validator(value: list[T]) -> Maybe[list[T]]:
|
|
439
|
+
if len(value) == len(set(value)):
|
|
440
|
+
return Maybe.success(value)
|
|
441
|
+
return Maybe.failure(error_message or 'All items must be unique')
|
|
442
|
+
|
|
443
|
+
return Validator(validator)
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def subset_of(allowed_set: set[T], error_message: str | None = None) -> Validator[set[T]]:
|
|
447
|
+
"""Create a validator that ensures a set is a subset of allowed values.
|
|
448
|
+
|
|
449
|
+
Validates that all elements in the input set are contained within
|
|
450
|
+
the allowed set. An empty set is always a valid subset.
|
|
451
|
+
|
|
452
|
+
Args:
|
|
453
|
+
allowed_set: The set of allowed values
|
|
454
|
+
error_message: Optional custom error message
|
|
455
|
+
|
|
456
|
+
Returns:
|
|
457
|
+
Validator[set[T]]: A validator function that checks subset relationship
|
|
458
|
+
|
|
459
|
+
Examples:
|
|
460
|
+
>>> from valid8r.core.validators import subset_of
|
|
461
|
+
>>> validator = subset_of({1, 2, 3, 4, 5})
|
|
462
|
+
>>> validator({1, 2, 3})
|
|
463
|
+
Success({1, 2, 3})
|
|
464
|
+
>>> validator({1, 2, 3, 4, 5, 6}).is_failure()
|
|
465
|
+
True
|
|
466
|
+
>>> # Empty set is valid subset
|
|
467
|
+
>>> validator(set())
|
|
468
|
+
Success(set())
|
|
469
|
+
>>> # With custom error message
|
|
470
|
+
>>> validator = subset_of({'a', 'b', 'c'}, error_message='Invalid characters')
|
|
471
|
+
>>> validator({'a', 'd'}).error_or('')
|
|
472
|
+
'Invalid characters'
|
|
473
|
+
|
|
474
|
+
"""
|
|
475
|
+
|
|
476
|
+
def validator(value: set[T]) -> Maybe[set[T]]:
|
|
477
|
+
if value.issubset(allowed_set):
|
|
478
|
+
return Maybe.success(value)
|
|
479
|
+
return Maybe.failure(error_message or f'Value must be a subset of {allowed_set}')
|
|
480
|
+
|
|
481
|
+
return Validator(validator)
|
|
482
|
+
|
|
483
|
+
|
|
484
|
+
def superset_of(required_set: set[T], error_message: str | None = None) -> Validator[set[T]]:
|
|
485
|
+
"""Create a validator that ensures a set is a superset of required values.
|
|
486
|
+
|
|
487
|
+
Validates that the input set contains all elements from the required set.
|
|
488
|
+
The input set may contain additional elements beyond those required.
|
|
489
|
+
|
|
490
|
+
Args:
|
|
491
|
+
required_set: The set of required values
|
|
492
|
+
error_message: Optional custom error message
|
|
493
|
+
|
|
494
|
+
Returns:
|
|
495
|
+
Validator[set[T]]: A validator function that checks superset relationship
|
|
496
|
+
|
|
497
|
+
Examples:
|
|
498
|
+
>>> from valid8r.core.validators import superset_of
|
|
499
|
+
>>> validator = superset_of({1, 2, 3})
|
|
500
|
+
>>> validator({1, 2, 3, 4, 5})
|
|
501
|
+
Success({1, 2, 3, 4, 5})
|
|
502
|
+
>>> validator({1, 2}).is_failure()
|
|
503
|
+
True
|
|
504
|
+
>>> # Exact match is valid
|
|
505
|
+
>>> validator({1, 2, 3})
|
|
506
|
+
Success({1, 2, 3})
|
|
507
|
+
>>> # With custom error message
|
|
508
|
+
>>> validator = superset_of({'read', 'write'}, error_message='Missing required permissions')
|
|
509
|
+
>>> validator({'read'}).error_or('')
|
|
510
|
+
'Missing required permissions'
|
|
511
|
+
|
|
512
|
+
"""
|
|
513
|
+
|
|
514
|
+
def validator(value: set[T]) -> Maybe[set[T]]:
|
|
515
|
+
if value.issuperset(required_set):
|
|
516
|
+
return Maybe.success(value)
|
|
517
|
+
return Maybe.failure(error_message or f'Value must be a superset of {required_set}')
|
|
518
|
+
|
|
519
|
+
return Validator(validator)
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def is_sorted(*, reverse: bool = False, error_message: str | None = None) -> Validator[list[N]]:
|
|
523
|
+
"""Create a validator that ensures a list is sorted.
|
|
524
|
+
|
|
525
|
+
Validates that a list is sorted in either ascending or descending order.
|
|
526
|
+
Uses keyword-only parameters to avoid boolean trap anti-pattern.
|
|
527
|
+
|
|
528
|
+
Args:
|
|
529
|
+
reverse: If True, checks for descending order; otherwise ascending (default)
|
|
530
|
+
error_message: Optional custom error message
|
|
531
|
+
|
|
532
|
+
Returns:
|
|
533
|
+
Validator[list[N]]: A validator function that checks if list is sorted
|
|
534
|
+
|
|
535
|
+
Examples:
|
|
536
|
+
>>> from valid8r.core.validators import is_sorted
|
|
537
|
+
>>> # Ascending order (default)
|
|
538
|
+
>>> validator = is_sorted()
|
|
539
|
+
>>> validator([1, 2, 3, 4, 5])
|
|
540
|
+
Success([1, 2, 3, 4, 5])
|
|
541
|
+
>>> validator([3, 1, 4, 2]).is_failure()
|
|
542
|
+
True
|
|
543
|
+
>>> # Descending order
|
|
544
|
+
>>> validator = is_sorted(reverse=True)
|
|
545
|
+
>>> validator([5, 4, 3, 2, 1])
|
|
546
|
+
Success([5, 4, 3, 2, 1])
|
|
547
|
+
>>> validator([1, 2, 3]).is_failure()
|
|
548
|
+
True
|
|
549
|
+
>>> # Works with strings
|
|
550
|
+
>>> validator = is_sorted()
|
|
551
|
+
>>> validator(['a', 'b', 'c'])
|
|
552
|
+
Success(['a', 'b', 'c'])
|
|
553
|
+
>>> # With custom error message
|
|
554
|
+
>>> validator = is_sorted(error_message='List must be in order')
|
|
555
|
+
>>> validator([3, 1, 2]).error_or('')
|
|
556
|
+
'List must be in order'
|
|
557
|
+
|
|
558
|
+
"""
|
|
559
|
+
|
|
560
|
+
def validator(value: list[N]) -> Maybe[list[N]]:
|
|
561
|
+
sorted_value = sorted(value, reverse=reverse)
|
|
562
|
+
if value == sorted_value:
|
|
563
|
+
return Maybe.success(value)
|
|
564
|
+
direction = 'descending' if reverse else 'ascending'
|
|
565
|
+
return Maybe.failure(error_message or f'List must be sorted in {direction} order')
|
|
566
|
+
|
|
567
|
+
return Validator(validator)
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def exists() -> Validator[Path]:
|
|
571
|
+
"""Create a validator that ensures a path exists on the filesystem.
|
|
572
|
+
|
|
573
|
+
Validates that a Path object points to an existing file or directory.
|
|
574
|
+
Follows symbolic links by default (uses Path.exists()).
|
|
575
|
+
|
|
576
|
+
Returns:
|
|
577
|
+
Validator[Path]: A validator function that checks path existence
|
|
578
|
+
|
|
579
|
+
Examples:
|
|
580
|
+
>>> from pathlib import Path
|
|
581
|
+
>>> from valid8r.core.validators import exists
|
|
582
|
+
>>> # Existing path (doctest creates temp file)
|
|
583
|
+
>>> import tempfile
|
|
584
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
585
|
+
... path = Path(tmp.name)
|
|
586
|
+
... exists()(path).is_success()
|
|
587
|
+
True
|
|
588
|
+
>>> # Non-existent path
|
|
589
|
+
>>> path = Path('/nonexistent/file.txt')
|
|
590
|
+
>>> exists()(path).is_failure()
|
|
591
|
+
True
|
|
592
|
+
>>> exists()(path).error_or('')
|
|
593
|
+
'Path does not exist: /nonexistent/file.txt'
|
|
594
|
+
|
|
595
|
+
"""
|
|
596
|
+
|
|
597
|
+
def validator(value: Path) -> Maybe[Path]:
|
|
598
|
+
if value.exists():
|
|
599
|
+
return Maybe.success(value)
|
|
600
|
+
return Maybe.failure(f'Path does not exist: {value}')
|
|
601
|
+
|
|
602
|
+
return Validator(validator)
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def is_file() -> Validator[Path]:
|
|
606
|
+
"""Create a validator that ensures a path is a regular file.
|
|
607
|
+
|
|
608
|
+
Validates that a Path object points to an existing regular file
|
|
609
|
+
(not a directory, symlink, socket, etc.). Note that this also checks
|
|
610
|
+
that the path exists. For better error messages, chain with exists() first.
|
|
611
|
+
|
|
612
|
+
Returns:
|
|
613
|
+
Validator[Path]: A validator function that checks path is a file
|
|
614
|
+
|
|
615
|
+
Examples:
|
|
616
|
+
>>> from pathlib import Path
|
|
617
|
+
>>> from valid8r.core.validators import is_file
|
|
618
|
+
>>> # Regular file
|
|
619
|
+
>>> import tempfile
|
|
620
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
621
|
+
... path = Path(tmp.name)
|
|
622
|
+
... is_file()(path).is_success()
|
|
623
|
+
True
|
|
624
|
+
>>> # Directory
|
|
625
|
+
>>> import os
|
|
626
|
+
>>> path = Path(os.getcwd())
|
|
627
|
+
>>> result = is_file()(path)
|
|
628
|
+
>>> result.is_failure()
|
|
629
|
+
True
|
|
630
|
+
>>> 'not a file' in result.error_or('').lower()
|
|
631
|
+
True
|
|
632
|
+
|
|
633
|
+
"""
|
|
634
|
+
|
|
635
|
+
def validator(value: Path) -> Maybe[Path]:
|
|
636
|
+
if value.is_file():
|
|
637
|
+
return Maybe.success(value)
|
|
638
|
+
return Maybe.failure(f'Path is not a file: {value}')
|
|
639
|
+
|
|
640
|
+
return Validator(validator)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def is_dir() -> Validator[Path]:
|
|
644
|
+
"""Create a validator that ensures a path is a directory.
|
|
645
|
+
|
|
646
|
+
Validates that a Path object points to an existing directory.
|
|
647
|
+
Note that this also checks that the path exists. For better error
|
|
648
|
+
messages, chain with exists() first.
|
|
649
|
+
|
|
650
|
+
Returns:
|
|
651
|
+
Validator[Path]: A validator function that checks path is a directory
|
|
652
|
+
|
|
653
|
+
Examples:
|
|
654
|
+
>>> from pathlib import Path
|
|
655
|
+
>>> from valid8r.core.validators import is_dir
|
|
656
|
+
>>> # Directory
|
|
657
|
+
>>> import os
|
|
658
|
+
>>> path = Path(os.getcwd())
|
|
659
|
+
>>> is_dir()(path).is_success()
|
|
660
|
+
True
|
|
661
|
+
>>> # Regular file
|
|
662
|
+
>>> import tempfile
|
|
663
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
664
|
+
... path = Path(tmp.name)
|
|
665
|
+
... result = is_dir()(path)
|
|
666
|
+
... result.is_failure()
|
|
667
|
+
True
|
|
668
|
+
>>> # Non-existent path
|
|
669
|
+
>>> path = Path('/nonexistent/dir')
|
|
670
|
+
>>> result = is_dir()(path)
|
|
671
|
+
>>> result.is_failure()
|
|
672
|
+
True
|
|
673
|
+
>>> 'not a directory' in result.error_or('').lower()
|
|
674
|
+
True
|
|
675
|
+
|
|
676
|
+
"""
|
|
677
|
+
|
|
678
|
+
def validator(value: Path) -> Maybe[Path]:
|
|
679
|
+
if value.is_dir():
|
|
680
|
+
return Maybe.success(value)
|
|
681
|
+
return Maybe.failure(f'Path is not a directory: {value}')
|
|
682
|
+
|
|
683
|
+
return Validator(validator)
|
|
684
|
+
|
|
685
|
+
|
|
686
|
+
def is_readable() -> Validator[Path]:
|
|
687
|
+
"""Create a validator that ensures a path has read permissions.
|
|
688
|
+
|
|
689
|
+
Validates that a Path object has read permissions using os.access().
|
|
690
|
+
Works with files, directories, and symbolic links. For symlinks, checks
|
|
691
|
+
the target's permissions (follows the link).
|
|
692
|
+
|
|
693
|
+
Returns:
|
|
694
|
+
Validator[Path]: A validator function that checks read permissions
|
|
695
|
+
|
|
696
|
+
Examples:
|
|
697
|
+
>>> from pathlib import Path
|
|
698
|
+
>>> from valid8r.core.validators import is_readable
|
|
699
|
+
>>> # Readable file
|
|
700
|
+
>>> import tempfile
|
|
701
|
+
>>> import os
|
|
702
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
703
|
+
... path = Path(tmp.name)
|
|
704
|
+
... os.chmod(tmp.name, 0o444) # r--r--r--
|
|
705
|
+
... is_readable()(path).is_success()
|
|
706
|
+
True
|
|
707
|
+
>>> # Non-existent path
|
|
708
|
+
>>> path = Path('/nonexistent/file.txt')
|
|
709
|
+
>>> is_readable()(path).is_failure()
|
|
710
|
+
True
|
|
711
|
+
>>> 'not readable' in is_readable()(path).error_or('').lower()
|
|
712
|
+
True
|
|
713
|
+
|
|
714
|
+
"""
|
|
715
|
+
|
|
716
|
+
def validator(value: Path) -> Maybe[Path]:
|
|
717
|
+
if os.access(value, os.R_OK):
|
|
718
|
+
return Maybe.success(value)
|
|
719
|
+
return Maybe.failure(f'Path is not readable: {value}')
|
|
720
|
+
|
|
721
|
+
return Validator(validator)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def is_writable() -> Validator[Path]:
|
|
725
|
+
"""Create a validator that ensures a path has write permissions.
|
|
726
|
+
|
|
727
|
+
Validates that a Path object has write permissions using os.access().
|
|
728
|
+
Works with files, directories, and symbolic links. For symlinks, checks
|
|
729
|
+
the target's permissions (follows the link).
|
|
730
|
+
|
|
731
|
+
Returns:
|
|
732
|
+
Validator[Path]: A validator function that checks write permissions
|
|
733
|
+
|
|
734
|
+
Examples:
|
|
735
|
+
>>> from pathlib import Path
|
|
736
|
+
>>> from valid8r.core.validators import is_writable
|
|
737
|
+
>>> # Writable file
|
|
738
|
+
>>> import tempfile
|
|
739
|
+
>>> import os
|
|
740
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
741
|
+
... path = Path(tmp.name)
|
|
742
|
+
... os.chmod(tmp.name, 0o644) # rw-r--r--
|
|
743
|
+
... is_writable()(path).is_success()
|
|
744
|
+
True
|
|
745
|
+
>>> # Non-existent path
|
|
746
|
+
>>> path = Path('/nonexistent/file.txt')
|
|
747
|
+
>>> is_writable()(path).is_failure()
|
|
748
|
+
True
|
|
749
|
+
>>> 'not writable' in is_writable()(path).error_or('').lower()
|
|
750
|
+
True
|
|
751
|
+
|
|
752
|
+
"""
|
|
753
|
+
|
|
754
|
+
def validator(value: Path) -> Maybe[Path]:
|
|
755
|
+
if os.access(value, os.W_OK):
|
|
756
|
+
return Maybe.success(value)
|
|
757
|
+
return Maybe.failure(f'Path is not writable: {value}')
|
|
758
|
+
|
|
759
|
+
return Validator(validator)
|
|
760
|
+
|
|
761
|
+
|
|
762
|
+
def is_executable() -> Validator[Path]:
|
|
763
|
+
"""Create a validator that ensures a path has execute permissions.
|
|
764
|
+
|
|
765
|
+
Validates that a Path object has execute permissions using os.access().
|
|
766
|
+
Works with files, directories, and symbolic links. For symlinks, checks
|
|
767
|
+
the target's permissions (follows the link).
|
|
768
|
+
|
|
769
|
+
Returns:
|
|
770
|
+
Validator[Path]: A validator function that checks execute permissions
|
|
771
|
+
|
|
772
|
+
Examples:
|
|
773
|
+
>>> from pathlib import Path
|
|
774
|
+
>>> from valid8r.core.validators import is_executable
|
|
775
|
+
>>> # Executable file
|
|
776
|
+
>>> import tempfile
|
|
777
|
+
>>> import os
|
|
778
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
779
|
+
... path = Path(tmp.name)
|
|
780
|
+
... os.chmod(tmp.name, 0o755) # rwxr-xr-x
|
|
781
|
+
... is_executable()(path).is_success()
|
|
782
|
+
True
|
|
783
|
+
>>> # Non-existent path
|
|
784
|
+
>>> path = Path('/nonexistent/file.sh')
|
|
785
|
+
>>> is_executable()(path).is_failure()
|
|
786
|
+
True
|
|
787
|
+
>>> 'not executable' in is_executable()(path).error_or('').lower()
|
|
788
|
+
True
|
|
789
|
+
|
|
790
|
+
"""
|
|
791
|
+
|
|
792
|
+
def validator(value: Path) -> Maybe[Path]:
|
|
793
|
+
if os.access(value, os.X_OK):
|
|
794
|
+
return Maybe.success(value)
|
|
795
|
+
return Maybe.failure(f'Path is not executable: {value}')
|
|
796
|
+
|
|
797
|
+
return Validator(validator)
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def max_size(max_bytes: int) -> Validator[Path]:
|
|
801
|
+
"""Create a validator that ensures a file does not exceed a maximum size.
|
|
802
|
+
|
|
803
|
+
Validates that a file's size in bytes is at most the specified maximum.
|
|
804
|
+
This validator checks that the path is a regular file before checking size.
|
|
805
|
+
|
|
806
|
+
Args:
|
|
807
|
+
max_bytes: Maximum allowed file size in bytes (inclusive)
|
|
808
|
+
|
|
809
|
+
Returns:
|
|
810
|
+
Validator[Path]: A validator function that checks file size
|
|
811
|
+
|
|
812
|
+
Examples:
|
|
813
|
+
>>> from pathlib import Path
|
|
814
|
+
>>> from valid8r.core.validators import max_size
|
|
815
|
+
>>> # File under size limit
|
|
816
|
+
>>> import tempfile
|
|
817
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
818
|
+
... path = Path(tmp.name)
|
|
819
|
+
... path.write_bytes(b'x' * 1024)
|
|
820
|
+
... max_size(2048)(path).is_success()
|
|
821
|
+
1024
|
|
822
|
+
True
|
|
823
|
+
>>> # File over size limit
|
|
824
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
825
|
+
... path = Path(tmp.name)
|
|
826
|
+
... path.write_bytes(b'x' * 5120)
|
|
827
|
+
... result = max_size(1024)(path)
|
|
828
|
+
... result.is_failure()
|
|
829
|
+
5120
|
|
830
|
+
True
|
|
831
|
+
>>> # Error includes actual size
|
|
832
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
833
|
+
... path = Path(tmp.name)
|
|
834
|
+
... path.write_bytes(b'x' * 5120)
|
|
835
|
+
... result = max_size(1024)(path)
|
|
836
|
+
... '5120' in result.error_or('')
|
|
837
|
+
5120
|
|
838
|
+
True
|
|
839
|
+
|
|
840
|
+
"""
|
|
841
|
+
|
|
842
|
+
def validator(value: Path) -> Maybe[Path]:
|
|
843
|
+
# Check that path is a file (not directory)
|
|
844
|
+
if not value.is_file():
|
|
845
|
+
return Maybe.failure(f'Path is not a file: {value}')
|
|
846
|
+
|
|
847
|
+
# Get file size
|
|
848
|
+
file_size = value.stat().st_size
|
|
849
|
+
|
|
850
|
+
# Check size limit
|
|
851
|
+
if file_size <= max_bytes:
|
|
852
|
+
return Maybe.success(value)
|
|
853
|
+
|
|
854
|
+
return Maybe.failure(f'File size {file_size} bytes exceeds maximum size of {max_bytes} bytes')
|
|
855
|
+
|
|
856
|
+
return Validator(validator)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
def min_size(min_bytes: int) -> Validator[Path]:
|
|
860
|
+
"""Create a validator that ensures a file meets a minimum size requirement.
|
|
861
|
+
|
|
862
|
+
Validates that a file's size in bytes is at least the specified minimum.
|
|
863
|
+
This validator checks that the path is a regular file before checking size.
|
|
864
|
+
|
|
865
|
+
Args:
|
|
866
|
+
min_bytes: Minimum required file size in bytes (inclusive)
|
|
867
|
+
|
|
868
|
+
Returns:
|
|
869
|
+
Validator[Path]: A validator function that checks file size
|
|
870
|
+
|
|
871
|
+
Examples:
|
|
872
|
+
>>> from pathlib import Path
|
|
873
|
+
>>> from valid8r.core.validators import min_size
|
|
874
|
+
>>> # File above size limit
|
|
875
|
+
>>> import tempfile
|
|
876
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
877
|
+
... path = Path(tmp.name)
|
|
878
|
+
... path.write_bytes(b'x' * 2048)
|
|
879
|
+
... min_size(1024)(path).is_success()
|
|
880
|
+
2048
|
|
881
|
+
True
|
|
882
|
+
>>> # File below size limit
|
|
883
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
884
|
+
... path = Path(tmp.name)
|
|
885
|
+
... path.write_bytes(b'x' * 512)
|
|
886
|
+
... result = min_size(1024)(path)
|
|
887
|
+
... result.is_failure()
|
|
888
|
+
512
|
|
889
|
+
True
|
|
890
|
+
>>> # Error includes minimum size
|
|
891
|
+
>>> with tempfile.NamedTemporaryFile() as tmp:
|
|
892
|
+
... path = Path(tmp.name)
|
|
893
|
+
... path.write_bytes(b'x' * 512)
|
|
894
|
+
... result = min_size(1024)(path)
|
|
895
|
+
... '1024' in result.error_or('')
|
|
896
|
+
512
|
|
897
|
+
True
|
|
898
|
+
|
|
899
|
+
"""
|
|
900
|
+
|
|
901
|
+
def validator(value: Path) -> Maybe[Path]:
|
|
902
|
+
# Check that path is a file (not directory)
|
|
903
|
+
if not value.is_file():
|
|
904
|
+
return Maybe.failure(f'Path is not a file: {value}')
|
|
905
|
+
|
|
906
|
+
# Get file size
|
|
907
|
+
file_size = value.stat().st_size
|
|
908
|
+
|
|
909
|
+
# Check size limit
|
|
910
|
+
if file_size >= min_bytes:
|
|
911
|
+
return Maybe.success(value)
|
|
912
|
+
|
|
913
|
+
return Maybe.failure(f'File size {file_size} bytes is smaller than minimum size of {min_bytes} bytes')
|
|
914
|
+
|
|
915
|
+
return Validator(validator)
|
|
916
|
+
|
|
917
|
+
|
|
918
|
+
def has_extension(*extensions: str) -> Validator[Path]:
|
|
919
|
+
"""Create a validator that ensures a file has one of the allowed extensions.
|
|
920
|
+
|
|
921
|
+
Validates that a file's extension matches one of the specified extensions.
|
|
922
|
+
Extension matching is case-insensitive. Extensions should include the dot (e.g., '.txt').
|
|
923
|
+
|
|
924
|
+
Args:
|
|
925
|
+
*extensions: Variable number of allowed file extensions (e.g., '.pdf', '.txt')
|
|
926
|
+
|
|
927
|
+
Returns:
|
|
928
|
+
Validator[Path]: A validator function that checks file extension
|
|
929
|
+
|
|
930
|
+
Examples:
|
|
931
|
+
>>> from pathlib import Path
|
|
932
|
+
>>> from valid8r.core.validators import has_extension
|
|
933
|
+
>>> # Single extension
|
|
934
|
+
>>> import tempfile
|
|
935
|
+
>>> import os
|
|
936
|
+
>>> with tempfile.TemporaryDirectory() as tmpdir:
|
|
937
|
+
... path = Path(tmpdir) / 'document.pdf'
|
|
938
|
+
... path.write_text('content')
|
|
939
|
+
... has_extension('.pdf')(path).is_success()
|
|
940
|
+
7
|
|
941
|
+
True
|
|
942
|
+
>>> # Multiple extensions
|
|
943
|
+
>>> with tempfile.TemporaryDirectory() as tmpdir:
|
|
944
|
+
... path = Path(tmpdir) / 'document.docx'
|
|
945
|
+
... path.write_text('content')
|
|
946
|
+
... has_extension('.pdf', '.doc', '.docx')(path).is_success()
|
|
947
|
+
7
|
|
948
|
+
True
|
|
949
|
+
>>> # Case-insensitive
|
|
950
|
+
>>> with tempfile.TemporaryDirectory() as tmpdir:
|
|
951
|
+
... path = Path(tmpdir) / 'DOCUMENT.PDF'
|
|
952
|
+
... path.write_text('content')
|
|
953
|
+
... has_extension('.pdf')(path).is_success()
|
|
954
|
+
7
|
|
955
|
+
True
|
|
956
|
+
>>> # Wrong extension
|
|
957
|
+
>>> with tempfile.TemporaryDirectory() as tmpdir:
|
|
958
|
+
... path = Path(tmpdir) / 'image.png'
|
|
959
|
+
... path.write_text('content')
|
|
960
|
+
... result = has_extension('.pdf', '.docx')(path)
|
|
961
|
+
... result.is_failure()
|
|
962
|
+
7
|
|
963
|
+
True
|
|
964
|
+
|
|
965
|
+
"""
|
|
966
|
+
|
|
967
|
+
def validator(value: Path) -> Maybe[Path]:
|
|
968
|
+
# Get the file extension (lowercase for case-insensitive comparison)
|
|
969
|
+
file_ext = value.suffix.lower()
|
|
970
|
+
|
|
971
|
+
# Normalize allowed extensions to lowercase, filtering out empty strings
|
|
972
|
+
allowed_exts = {ext.lower() for ext in extensions if ext}
|
|
973
|
+
|
|
974
|
+
# Check if file extension is in allowed set (and not empty)
|
|
975
|
+
if file_ext and file_ext in allowed_exts:
|
|
976
|
+
return Maybe.success(value)
|
|
977
|
+
|
|
978
|
+
# Format error message with all allowed extensions
|
|
979
|
+
exts_list = ', '.join(sorted(ext for ext in extensions if ext))
|
|
980
|
+
return Maybe.failure(f'File extension {file_ext or "(none)"} not in allowed extensions: {exts_list}')
|
|
981
|
+
|
|
982
|
+
return Validator(validator)
|