Source code for valid8r.core.validators

"""Core validators for validating values against specific criteria.

This module provides a collection of validator functions for common validation scenarios.
All validators follow the same pattern - they take a value and return a Maybe object
that either contains the validated value or an error message.
"""

from __future__ import annotations

import os
import re
from typing import (
    TYPE_CHECKING,
    Generic,
    Protocol,
    TypeVar,
)

from valid8r.core.combinators import (
    and_then,
    not_validator,
    or_else,
)
from valid8r.core.maybe import Maybe

if TYPE_CHECKING:
    from collections.abc import Callable
    from pathlib import Path


[docs] class SupportsComparison(Protocol): # noqa: D101
[docs] def __le__(self, other: object, /) -> bool: ... # noqa: D105
[docs] def __lt__(self, other: object, /) -> bool: ... # noqa: D105
[docs] def __ge__(self, other: object, /) -> bool: ... # noqa: D105
[docs] def __gt__(self, other: object, /) -> bool: ... # noqa: D105
[docs] def __eq__(self, other: object, /) -> bool: ... # noqa: D105
[docs] def __ne__(self, other: object, /) -> bool: ... # noqa: D105
[docs] def __hash__(self, /) -> int: ... # noqa: D105
[docs] T = TypeVar('T')
[docs] U = TypeVar('U')
[docs] N = TypeVar('N', bound=SupportsComparison)
[docs] class Validator(Generic[T]): """A wrapper class for validator functions that supports operator overloading.""" def __init__(self, func: Callable[[T], Maybe[T]]) -> None: """Initialize a validator with a validation function. Args: func: A function that takes a value and returns a Maybe """
[docs] self.func = func
[docs] def __call__(self, value: T) -> Maybe[T]: """Apply the validator to a value. Args: value: The value to validate Returns: A Maybe containing either the validated value or an error """ return self.func(value)
[docs] def __and__(self, other: Validator[T]) -> Validator[T]: """Combine with another validator using logical AND. Args: other: Another validator to combine with Returns: A new validator that passes only if both validators pass """ return Validator(lambda value: and_then(self.func, other.func)(value))
[docs] def __or__(self, other: Validator[T]) -> Validator[T]: """Combine with another validator using logical OR. Args: other: Another validator to combine with Returns: A new validator that passes if either validator passes """ return Validator(lambda value: or_else(self.func, other.func)(value))
[docs] def __invert__(self) -> Validator[T]: """Negate this validator. Returns: A new validator that passes if this validator fails """ return Validator(lambda value: not_validator(self.func, 'Negated validation failed')(value))
[docs] def minimum(min_value: N, error_message: str | None = None) -> Validator[N]: """Create a validator that ensures a value is at least the minimum. Args: min_value: The minimum allowed value (inclusive) error_message: Optional custom error message Returns: Validator[N]: A validator function that accepts values >= min_value Examples: >>> from valid8r.core.validators import minimum >>> validator = minimum(0) >>> validator(5) Success(5) >>> validator(0) Success(0) >>> validator(-1).is_failure() True >>> # With custom error message >>> validator = minimum(18, error_message="Must be an adult") >>> validator(17).error_or("") 'Must be an adult' """ def validator(value: N) -> Maybe[N]: if value >= min_value: return Maybe.success(value) return Maybe.failure(error_message or f'Value must be at least {min_value}') return Validator(validator)
[docs] def maximum(max_value: N, error_message: str | None = None) -> Validator[N]: """Create a validator that ensures a value is at most the maximum. Args: max_value: The maximum allowed value (inclusive) error_message: Optional custom error message Returns: Validator[N]: A validator function that accepts values <= max_value Examples: >>> from valid8r.core.validators import maximum >>> validator = maximum(100) >>> validator(50) Success(50) >>> validator(100) Success(100) >>> validator(101).is_failure() True >>> # With custom error message >>> validator = maximum(120, error_message="Age too high") >>> validator(150).error_or("") 'Age too high' """ def validator(value: N) -> Maybe[N]: if value <= max_value: return Maybe.success(value) return Maybe.failure(error_message or f'Value must be at most {max_value}') return Validator(validator)
[docs] def between(min_value: N, max_value: N, error_message: str | None = None) -> Validator[N]: """Create a validator that ensures a value is between minimum and maximum (inclusive). Args: min_value: The minimum allowed value (inclusive) max_value: The maximum allowed value (inclusive) error_message: Optional custom error message Returns: Validator[N]: A validator function that accepts values where min_value <= value <= max_value Examples: >>> from valid8r.core.validators import between >>> validator = between(0, 100) >>> validator(50) Success(50) >>> validator(0) Success(0) >>> validator(100) Success(100) >>> validator(-1).is_failure() True >>> validator(101).is_failure() True >>> # With custom error message >>> validator = between(1, 10, error_message="Rating must be 1-10") >>> validator(11).error_or("") 'Rating must be 1-10' """ def validator(value: N) -> Maybe[N]: if min_value <= value <= max_value: return Maybe.success(value) return Maybe.failure(error_message or f'Value must be between {min_value} and {max_value}') return Validator(validator)
[docs] def predicate(pred: Callable[[T], bool], error_message: str) -> Validator[T]: """Create a validator using a custom predicate function. Allows creating custom validators for any validation logic by providing a predicate function that returns True for valid values. Args: pred: A function that takes a value and returns True if valid, False otherwise error_message: Error message to return when validation fails Returns: Validator[T]: A validator function that applies the predicate Examples: >>> from valid8r.core.validators import predicate >>> # Validate even numbers >>> is_even = predicate(lambda x: x % 2 == 0, "Must be even") >>> is_even(4) Success(4) >>> is_even(3).is_failure() True >>> # Validate string patterns >>> starts_with_a = predicate(lambda s: s.startswith('a'), "Must start with 'a'") >>> starts_with_a("apple") Success('apple') >>> starts_with_a("banana").error_or("") "Must start with 'a'" """ def validator(value: T) -> Maybe[T]: if pred(value): return Maybe.success(value) return Maybe.failure(error_message) return Validator(validator)
[docs] def length(min_length: int, max_length: int, error_message: str | None = None) -> Validator[str]: """Create a validator that ensures a string's length is within bounds. Args: min_length: Minimum length of the string (inclusive) max_length: Maximum length of the string (inclusive) error_message: Optional custom error message Returns: Validator[str]: A validator function that checks string length Examples: >>> from valid8r.core.validators import length >>> validator = length(3, 10) >>> validator("hello") Success('hello') >>> validator("abc") Success('abc') >>> validator("abcdefghij") Success('abcdefghij') >>> validator("ab").is_failure() True >>> validator("abcdefghijk").is_failure() True >>> # With custom error message >>> validator = length(8, 20, error_message="Password must be 8-20 characters") >>> validator("short").error_or("") 'Password must be 8-20 characters' """ def validator(value: str) -> Maybe[str]: if min_length <= len(value) <= max_length: return Maybe.success(value) return Maybe.failure(error_message or f'String length must be between {min_length} and {max_length}') return Validator(validator)
[docs] def matches_regex(pattern: str | re.Pattern[str], error_message: str | None = None) -> Validator[str]: r"""Create a validator that ensures a string matches a regular expression pattern. Args: pattern: Regular expression pattern (string or compiled Pattern object) error_message: Optional custom error message Returns: Validator[str]: A validator function that checks pattern matching Examples: >>> from valid8r.core.validators import matches_regex >>> import re >>> # String pattern >>> validator = matches_regex(r'^\d{3}-\d{2}-\d{4}$') >>> validator('123-45-6789') Success('123-45-6789') >>> validator('invalid').is_failure() True >>> # Compiled regex pattern >>> pattern = re.compile(r'^[A-Z][a-z]+$') >>> validator = matches_regex(pattern) >>> validator('Hello') Success('Hello') >>> validator('hello').is_failure() True >>> # With custom error message >>> validator = matches_regex(r'^\d{5}$', error_message='Must be a 5-digit ZIP code') >>> validator('1234').error_or('') 'Must be a 5-digit ZIP code' """ compiled_pattern = re.compile(pattern) if isinstance(pattern, str) else pattern def validator(value: str) -> Maybe[str]: if compiled_pattern.match(value): return Maybe.success(value) return Maybe.failure(error_message or f'Value must match pattern {compiled_pattern.pattern}') return Validator(validator)
[docs] def in_set(allowed_values: set[T], error_message: str | None = None) -> Validator[T]: """Create a validator that ensures a value is in a set of allowed values. Args: allowed_values: Set of allowed values error_message: Optional custom error message Returns: Validator[T]: A validator function that checks membership Examples: >>> from valid8r.core.validators import in_set >>> # String values >>> validator = in_set({'red', 'green', 'blue'}) >>> validator('red') Success('red') >>> validator('yellow').is_failure() True >>> # Numeric values >>> validator = in_set({1, 2, 3, 4, 5}) >>> validator(3) Success(3) >>> validator(10).is_failure() True >>> # With custom error message >>> validator = in_set({'small', 'medium', 'large'}, error_message='Size must be S, M, or L') >>> validator('extra-large').error_or('') 'Size must be S, M, or L' """ def validator(value: T) -> Maybe[T]: if value in allowed_values: return Maybe.success(value) return Maybe.failure(error_message or f'Value must be one of {allowed_values}') return Validator(validator)
[docs] def non_empty_string(error_message: str | None = None) -> Validator[str]: """Create a validator that ensures a string is not empty. Validates that a string contains at least one non-whitespace character. Both empty strings and whitespace-only strings are rejected. Args: error_message: Optional custom error message Returns: Validator[str]: A validator function that checks for non-empty strings Examples: >>> from valid8r.core.validators import non_empty_string >>> validator = non_empty_string() >>> validator('hello') Success('hello') >>> validator(' hello ') Success(' hello ') >>> validator('').is_failure() True >>> validator(' ').is_failure() True >>> # With custom error message >>> validator = non_empty_string(error_message='Name is required') >>> validator('').error_or('') 'Name is required' """ def validator(value: str) -> Maybe[str]: if value.strip(): return Maybe.success(value) return Maybe.failure(error_message or 'String must not be empty') return Validator(validator)
[docs] def unique_items(error_message: str | None = None) -> Validator[list[T]]: """Create a validator that ensures all items in a list are unique. Validates that a list contains no duplicate elements by comparing the list length to the set length. Args: error_message: Optional custom error message Returns: Validator[list[T]]: A validator function that checks for unique items Examples: >>> from valid8r.core.validators import unique_items >>> validator = unique_items() >>> validator([1, 2, 3, 4, 5]) Success([1, 2, 3, 4, 5]) >>> validator([1, 2, 2, 3]).is_failure() True >>> # Works with strings >>> validator(['a', 'b', 'c']) Success(['a', 'b', 'c']) >>> validator(['a', 'b', 'a']).is_failure() True >>> # With custom error message >>> validator = unique_items(error_message='Duplicate items found') >>> validator([1, 1, 2]).error_or('') 'Duplicate items found' """ def validator(value: list[T]) -> Maybe[list[T]]: if len(value) == len(set(value)): return Maybe.success(value) return Maybe.failure(error_message or 'All items must be unique') return Validator(validator)
[docs] def subset_of(allowed_set: set[T], error_message: str | None = None) -> Validator[set[T]]: """Create a validator that ensures a set is a subset of allowed values. Validates that all elements in the input set are contained within the allowed set. An empty set is always a valid subset. Args: allowed_set: The set of allowed values error_message: Optional custom error message Returns: Validator[set[T]]: A validator function that checks subset relationship Examples: >>> from valid8r.core.validators import subset_of >>> validator = subset_of({1, 2, 3, 4, 5}) >>> validator({1, 2, 3}) Success({1, 2, 3}) >>> validator({1, 2, 3, 4, 5, 6}).is_failure() True >>> # Empty set is valid subset >>> validator(set()) Success(set()) >>> # With custom error message >>> validator = subset_of({'a', 'b', 'c'}, error_message='Invalid characters') >>> validator({'a', 'd'}).error_or('') 'Invalid characters' """ def validator(value: set[T]) -> Maybe[set[T]]: if value.issubset(allowed_set): return Maybe.success(value) return Maybe.failure(error_message or f'Value must be a subset of {allowed_set}') return Validator(validator)
[docs] def superset_of(required_set: set[T], error_message: str | None = None) -> Validator[set[T]]: """Create a validator that ensures a set is a superset of required values. Validates that the input set contains all elements from the required set. The input set may contain additional elements beyond those required. Args: required_set: The set of required values error_message: Optional custom error message Returns: Validator[set[T]]: A validator function that checks superset relationship Examples: >>> from valid8r.core.validators import superset_of >>> validator = superset_of({1, 2, 3}) >>> validator({1, 2, 3, 4, 5}) Success({1, 2, 3, 4, 5}) >>> validator({1, 2}).is_failure() True >>> # Exact match is valid >>> validator({1, 2, 3}) Success({1, 2, 3}) >>> # With custom error message >>> validator = superset_of({'read', 'write'}, error_message='Missing required permissions') >>> validator({'read'}).error_or('') 'Missing required permissions' """ def validator(value: set[T]) -> Maybe[set[T]]: if value.issuperset(required_set): return Maybe.success(value) return Maybe.failure(error_message or f'Value must be a superset of {required_set}') return Validator(validator)
[docs] def is_sorted(*, reverse: bool = False, error_message: str | None = None) -> Validator[list[N]]: """Create a validator that ensures a list is sorted. Validates that a list is sorted in either ascending or descending order. Uses keyword-only parameters to avoid boolean trap anti-pattern. Args: reverse: If True, checks for descending order; otherwise ascending (default) error_message: Optional custom error message Returns: Validator[list[N]]: A validator function that checks if list is sorted Examples: >>> from valid8r.core.validators import is_sorted >>> # Ascending order (default) >>> validator = is_sorted() >>> validator([1, 2, 3, 4, 5]) Success([1, 2, 3, 4, 5]) >>> validator([3, 1, 4, 2]).is_failure() True >>> # Descending order >>> validator = is_sorted(reverse=True) >>> validator([5, 4, 3, 2, 1]) Success([5, 4, 3, 2, 1]) >>> validator([1, 2, 3]).is_failure() True >>> # Works with strings >>> validator = is_sorted() >>> validator(['a', 'b', 'c']) Success(['a', 'b', 'c']) >>> # With custom error message >>> validator = is_sorted(error_message='List must be in order') >>> validator([3, 1, 2]).error_or('') 'List must be in order' """ def validator(value: list[N]) -> Maybe[list[N]]: sorted_value = sorted(value, reverse=reverse) if value == sorted_value: return Maybe.success(value) direction = 'descending' if reverse else 'ascending' return Maybe.failure(error_message or f'List must be sorted in {direction} order') return Validator(validator)
[docs] def exists() -> Validator[Path]: """Create a validator that ensures a path exists on the filesystem. Validates that a Path object points to an existing file or directory. Follows symbolic links by default (uses Path.exists()). Returns: Validator[Path]: A validator function that checks path existence Examples: >>> from pathlib import Path >>> from valid8r.core.validators import exists >>> # Existing path (doctest creates temp file) >>> import tempfile >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... exists()(path).is_success() True >>> # Non-existent path >>> path = Path('/nonexistent/file.txt') >>> exists()(path).is_failure() True >>> exists()(path).error_or('') 'Path does not exist: /nonexistent/file.txt' """ def validator(value: Path) -> Maybe[Path]: if value.exists(): return Maybe.success(value) return Maybe.failure(f'Path does not exist: {value}') return Validator(validator)
[docs] def is_file() -> Validator[Path]: """Create a validator that ensures a path is a regular file. Validates that a Path object points to an existing regular file (not a directory, symlink, socket, etc.). Note that this also checks that the path exists. For better error messages, chain with exists() first. Returns: Validator[Path]: A validator function that checks path is a file Examples: >>> from pathlib import Path >>> from valid8r.core.validators import is_file >>> # Regular file >>> import tempfile >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... is_file()(path).is_success() True >>> # Directory >>> import os >>> path = Path(os.getcwd()) >>> result = is_file()(path) >>> result.is_failure() True >>> 'not a file' in result.error_or('').lower() True """ def validator(value: Path) -> Maybe[Path]: if value.is_file(): return Maybe.success(value) return Maybe.failure(f'Path is not a file: {value}') return Validator(validator)
[docs] def is_dir() -> Validator[Path]: """Create a validator that ensures a path is a directory. Validates that a Path object points to an existing directory. Note that this also checks that the path exists. For better error messages, chain with exists() first. Returns: Validator[Path]: A validator function that checks path is a directory Examples: >>> from pathlib import Path >>> from valid8r.core.validators import is_dir >>> # Directory >>> import os >>> path = Path(os.getcwd()) >>> is_dir()(path).is_success() True >>> # Regular file >>> import tempfile >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... result = is_dir()(path) ... result.is_failure() True >>> # Non-existent path >>> path = Path('/nonexistent/dir') >>> result = is_dir()(path) >>> result.is_failure() True >>> 'not a directory' in result.error_or('').lower() True """ def validator(value: Path) -> Maybe[Path]: if value.is_dir(): return Maybe.success(value) return Maybe.failure(f'Path is not a directory: {value}') return Validator(validator)
[docs] def is_readable() -> Validator[Path]: """Create a validator that ensures a path has read permissions. Validates that a Path object has read permissions using os.access(). Works with files, directories, and symbolic links. For symlinks, checks the target's permissions (follows the link). Returns: Validator[Path]: A validator function that checks read permissions Examples: >>> from pathlib import Path >>> from valid8r.core.validators import is_readable >>> # Readable file >>> import tempfile >>> import os >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... os.chmod(tmp.name, 0o444) # r--r--r-- ... is_readable()(path).is_success() True >>> # Non-existent path >>> path = Path('/nonexistent/file.txt') >>> is_readable()(path).is_failure() True >>> 'not readable' in is_readable()(path).error_or('').lower() True """ def validator(value: Path) -> Maybe[Path]: if os.access(value, os.R_OK): return Maybe.success(value) return Maybe.failure(f'Path is not readable: {value}') return Validator(validator)
[docs] def is_writable() -> Validator[Path]: """Create a validator that ensures a path has write permissions. Validates that a Path object has write permissions using os.access(). Works with files, directories, and symbolic links. For symlinks, checks the target's permissions (follows the link). Returns: Validator[Path]: A validator function that checks write permissions Examples: >>> from pathlib import Path >>> from valid8r.core.validators import is_writable >>> # Writable file >>> import tempfile >>> import os >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... os.chmod(tmp.name, 0o644) # rw-r--r-- ... is_writable()(path).is_success() True >>> # Non-existent path >>> path = Path('/nonexistent/file.txt') >>> is_writable()(path).is_failure() True >>> 'not writable' in is_writable()(path).error_or('').lower() True """ def validator(value: Path) -> Maybe[Path]: if os.access(value, os.W_OK): return Maybe.success(value) return Maybe.failure(f'Path is not writable: {value}') return Validator(validator)
[docs] def is_executable() -> Validator[Path]: """Create a validator that ensures a path has execute permissions. Validates that a Path object has execute permissions using os.access(). Works with files, directories, and symbolic links. For symlinks, checks the target's permissions (follows the link). Returns: Validator[Path]: A validator function that checks execute permissions Examples: >>> from pathlib import Path >>> from valid8r.core.validators import is_executable >>> # Executable file >>> import tempfile >>> import os >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... os.chmod(tmp.name, 0o755) # rwxr-xr-x ... is_executable()(path).is_success() True >>> # Non-existent path >>> path = Path('/nonexistent/file.sh') >>> is_executable()(path).is_failure() True >>> 'not executable' in is_executable()(path).error_or('').lower() True """ def validator(value: Path) -> Maybe[Path]: if os.access(value, os.X_OK): return Maybe.success(value) return Maybe.failure(f'Path is not executable: {value}') return Validator(validator)
[docs] def max_size(max_bytes: int) -> Validator[Path]: """Create a validator that ensures a file does not exceed a maximum size. Validates that a file's size in bytes is at most the specified maximum. This validator checks that the path is a regular file before checking size. Args: max_bytes: Maximum allowed file size in bytes (inclusive) Returns: Validator[Path]: A validator function that checks file size Examples: >>> from pathlib import Path >>> from valid8r.core.validators import max_size >>> # File under size limit >>> import tempfile >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... path.write_bytes(b'x' * 1024) ... max_size(2048)(path).is_success() 1024 True >>> # File over size limit >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... path.write_bytes(b'x' * 5120) ... result = max_size(1024)(path) ... result.is_failure() 5120 True >>> # Error includes actual size >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... path.write_bytes(b'x' * 5120) ... result = max_size(1024)(path) ... '5120' in result.error_or('') 5120 True """ def validator(value: Path) -> Maybe[Path]: # Check that path is a file (not directory) if not value.is_file(): return Maybe.failure(f'Path is not a file: {value}') # Get file size file_size = value.stat().st_size # Check size limit if file_size <= max_bytes: return Maybe.success(value) return Maybe.failure(f'File size {file_size} bytes exceeds maximum size of {max_bytes} bytes') return Validator(validator)
[docs] def min_size(min_bytes: int) -> Validator[Path]: """Create a validator that ensures a file meets a minimum size requirement. Validates that a file's size in bytes is at least the specified minimum. This validator checks that the path is a regular file before checking size. Args: min_bytes: Minimum required file size in bytes (inclusive) Returns: Validator[Path]: A validator function that checks file size Examples: >>> from pathlib import Path >>> from valid8r.core.validators import min_size >>> # File above size limit >>> import tempfile >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... path.write_bytes(b'x' * 2048) ... min_size(1024)(path).is_success() 2048 True >>> # File below size limit >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... path.write_bytes(b'x' * 512) ... result = min_size(1024)(path) ... result.is_failure() 512 True >>> # Error includes minimum size >>> with tempfile.NamedTemporaryFile() as tmp: ... path = Path(tmp.name) ... path.write_bytes(b'x' * 512) ... result = min_size(1024)(path) ... '1024' in result.error_or('') 512 True """ def validator(value: Path) -> Maybe[Path]: # Check that path is a file (not directory) if not value.is_file(): return Maybe.failure(f'Path is not a file: {value}') # Get file size file_size = value.stat().st_size # Check size limit if file_size >= min_bytes: return Maybe.success(value) return Maybe.failure(f'File size {file_size} bytes is smaller than minimum size of {min_bytes} bytes') return Validator(validator)
[docs] def has_extension(*extensions: str | list[str] | tuple[str, ...]) -> Validator[Path]: """Create a validator that ensures a file has one of the allowed extensions. Validates that a file's extension matches one of the specified extensions. Extension matching is case-insensitive. Extensions should include the dot (e.g., '.txt'). Args: *extensions: Variable number of allowed file extensions (e.g., '.pdf', '.txt') or a single list/tuple of extensions (e.g., ['.pdf', '.txt']) Returns: Validator[Path]: A validator function that checks file extension Examples: >>> from pathlib import Path >>> from valid8r.core.validators import has_extension >>> # Single extension >>> import tempfile >>> import os >>> with tempfile.TemporaryDirectory() as tmpdir: ... path = Path(tmpdir) / 'document.pdf' ... path.write_text('content') ... has_extension('.pdf')(path).is_success() 7 True >>> # Multiple extensions (variadic) >>> with tempfile.TemporaryDirectory() as tmpdir: ... path = Path(tmpdir) / 'document.docx' ... path.write_text('content') ... has_extension('.pdf', '.doc', '.docx')(path).is_success() 7 True >>> # Multiple extensions (list) >>> with tempfile.TemporaryDirectory() as tmpdir: ... path = Path(tmpdir) / 'document.docx' ... path.write_text('content') ... has_extension(['.pdf', '.doc', '.docx'])(path).is_success() 7 True >>> # Case-insensitive >>> with tempfile.TemporaryDirectory() as tmpdir: ... path = Path(tmpdir) / 'DOCUMENT.PDF' ... path.write_text('content') ... has_extension('.pdf')(path).is_success() 7 True >>> # Wrong extension >>> with tempfile.TemporaryDirectory() as tmpdir: ... path = Path(tmpdir) / 'image.png' ... path.write_text('content') ... result = has_extension('.pdf', '.docx')(path) ... result.is_failure() 7 True """ # Normalize extensions: if first arg is a list/tuple, use it; otherwise use all args if len(extensions) == 1 and isinstance(extensions[0], (list, tuple)): ext_list: tuple[str, ...] = tuple(extensions[0]) else: ext_list = tuple(str(ext) for ext in extensions) def validator(value: Path) -> Maybe[Path]: # Get the file extension (lowercase for case-insensitive comparison) file_ext = value.suffix.lower() # Normalize allowed extensions to lowercase, filtering out empty strings allowed_exts = {ext.lower() for ext in ext_list if ext} # Check if file extension is in allowed set (and not empty) if file_ext and file_ext in allowed_exts: return Maybe.success(value) # Format error message with all allowed extensions exts_list = ', '.join(sorted(ext for ext in ext_list if ext)) return Maybe.failure(f'File extension {file_ext or "(none)"} not in allowed extensions: {exts_list}') return Validator(validator)