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.
@@ -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)