valid8r 0.2.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.
Potentially problematic release.
This version of valid8r might be problematic. Click here for more details.
- valid8r/__init__.py +27 -0
- valid8r/core/__init__.py +28 -0
- valid8r/core/combinators.py +89 -0
- valid8r/core/maybe.py +162 -0
- valid8r/core/parsers.py +1354 -0
- valid8r/core/validators.py +200 -0
- valid8r/prompt/__init__.py +8 -0
- valid8r/prompt/basic.py +190 -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-0.2.0.dist-info/METADATA +168 -0
- valid8r-0.2.0.dist-info/RECORD +17 -0
- valid8r-0.2.0.dist-info/WHEEL +4 -0
- valid8r-0.2.0.dist-info/entry_points.txt +4 -0
|
@@ -0,0 +1,200 @@
|
|
|
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
|
+
from typing import (
|
|
11
|
+
TYPE_CHECKING,
|
|
12
|
+
Generic,
|
|
13
|
+
Protocol,
|
|
14
|
+
TypeVar,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from valid8r.core.combinators import (
|
|
18
|
+
and_then,
|
|
19
|
+
not_validator,
|
|
20
|
+
or_else,
|
|
21
|
+
)
|
|
22
|
+
from valid8r.core.maybe import Maybe
|
|
23
|
+
|
|
24
|
+
if TYPE_CHECKING:
|
|
25
|
+
from collections.abc import Callable
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class SupportsComparison(Protocol): # noqa: D101
|
|
29
|
+
def __le__(self, other: object, /) -> bool: ... # noqa: D105
|
|
30
|
+
def __lt__(self, other: object, /) -> bool: ... # noqa: D105
|
|
31
|
+
def __ge__(self, other: object, /) -> bool: ... # noqa: D105
|
|
32
|
+
def __gt__(self, other: object, /) -> bool: ... # noqa: D105
|
|
33
|
+
def __eq__(self, other: object, /) -> bool: ... # noqa: D105
|
|
34
|
+
def __ne__(self, other: object, /) -> bool: ... # noqa: D105
|
|
35
|
+
def __hash__(self, /) -> int: ... # noqa: D105
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
T = TypeVar('T')
|
|
39
|
+
U = TypeVar('U')
|
|
40
|
+
N = TypeVar('N', bound=SupportsComparison)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class Validator(Generic[T]):
|
|
44
|
+
"""A wrapper class for validator functions that supports operator overloading."""
|
|
45
|
+
|
|
46
|
+
def __init__(self, func: Callable[[T], Maybe[T]]) -> None:
|
|
47
|
+
"""Initialize a validator with a validation function.
|
|
48
|
+
|
|
49
|
+
Args:
|
|
50
|
+
func: A function that takes a value and returns a Maybe
|
|
51
|
+
|
|
52
|
+
"""
|
|
53
|
+
self.func = func
|
|
54
|
+
|
|
55
|
+
def __call__(self, value: T) -> Maybe[T]:
|
|
56
|
+
"""Apply the validator to a value.
|
|
57
|
+
|
|
58
|
+
Args:
|
|
59
|
+
value: The value to validate
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
A Maybe containing either the validated value or an error
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
return self.func(value)
|
|
66
|
+
|
|
67
|
+
def __and__(self, other: Validator[T]) -> Validator[T]:
|
|
68
|
+
"""Combine with another validator using logical AND.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
other: Another validator to combine with
|
|
72
|
+
|
|
73
|
+
Returns:
|
|
74
|
+
A new validator that passes only if both validators pass
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
return Validator(lambda value: and_then(self.func, other.func)(value))
|
|
78
|
+
|
|
79
|
+
def __or__(self, other: Validator[T]) -> Validator[T]:
|
|
80
|
+
"""Combine with another validator using logical OR.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
other: Another validator to combine with
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
A new validator that passes if either validator passes
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
return Validator(lambda value: or_else(self.func, other.func)(value))
|
|
90
|
+
|
|
91
|
+
def __invert__(self) -> Validator[T]:
|
|
92
|
+
"""Negate this validator.
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
A new validator that passes if this validator fails
|
|
96
|
+
|
|
97
|
+
"""
|
|
98
|
+
return Validator(lambda value: not_validator(self.func, 'Negated validation failed')(value))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def minimum(min_value: N, error_message: str | None = None) -> Validator[N]:
|
|
102
|
+
"""Create a validator that ensures a value is at least the minimum.
|
|
103
|
+
|
|
104
|
+
Args:
|
|
105
|
+
min_value: The minimum allowed value
|
|
106
|
+
error_message: Optional custom error message
|
|
107
|
+
|
|
108
|
+
Returns:
|
|
109
|
+
A validator function
|
|
110
|
+
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
def validator(value: N) -> Maybe[N]:
|
|
114
|
+
if value >= min_value:
|
|
115
|
+
return Maybe.success(value)
|
|
116
|
+
return Maybe.failure(error_message or f'Value must be at least {min_value}')
|
|
117
|
+
|
|
118
|
+
return Validator(validator)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def maximum(max_value: N, error_message: str | None = None) -> Validator[N]:
|
|
122
|
+
"""Create a validator that ensures a value is at most the maximum.
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
max_value: The maximum allowed value
|
|
126
|
+
error_message: Optional custom error message
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
A validator function
|
|
130
|
+
|
|
131
|
+
"""
|
|
132
|
+
|
|
133
|
+
def validator(value: N) -> Maybe[N]:
|
|
134
|
+
if value <= max_value:
|
|
135
|
+
return Maybe.success(value)
|
|
136
|
+
return Maybe.failure(error_message or f'Value must be at most {max_value}')
|
|
137
|
+
|
|
138
|
+
return Validator(validator)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def between(min_value: N, max_value: N, error_message: str | None = None) -> Validator[N]:
|
|
142
|
+
"""Create a validator that ensures a value is between minimum and maximum (inclusive).
|
|
143
|
+
|
|
144
|
+
Args:
|
|
145
|
+
min_value: The minimum allowed value
|
|
146
|
+
max_value: The maximum allowed value
|
|
147
|
+
error_message: Optional custom error message
|
|
148
|
+
|
|
149
|
+
Returns:
|
|
150
|
+
A validator function
|
|
151
|
+
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def validator(value: N) -> Maybe[N]:
|
|
155
|
+
if min_value <= value <= max_value:
|
|
156
|
+
return Maybe.success(value)
|
|
157
|
+
return Maybe.failure(error_message or f'Value must be between {min_value} and {max_value}')
|
|
158
|
+
|
|
159
|
+
return Validator(validator)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def predicate(pred: Callable[[T], bool], error_message: str) -> Validator[T]:
|
|
163
|
+
"""Create a validator using a custom predicate function.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
pred: A function that takes a value and returns a boolean
|
|
167
|
+
error_message: Error message when validation fails
|
|
168
|
+
|
|
169
|
+
Returns:
|
|
170
|
+
A validator function
|
|
171
|
+
|
|
172
|
+
"""
|
|
173
|
+
|
|
174
|
+
def validator(value: T) -> Maybe[T]:
|
|
175
|
+
if pred(value):
|
|
176
|
+
return Maybe.success(value)
|
|
177
|
+
return Maybe.failure(error_message)
|
|
178
|
+
|
|
179
|
+
return Validator(validator)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def length(min_length: int, max_length: int, error_message: str | None = None) -> Validator[str]:
|
|
183
|
+
"""Create a validator that ensures a string's length is within bounds.
|
|
184
|
+
|
|
185
|
+
Args:
|
|
186
|
+
min_length: Minimum length of the string
|
|
187
|
+
max_length: Maximum length of the string
|
|
188
|
+
error_message: Optional custom error message
|
|
189
|
+
|
|
190
|
+
Returns:
|
|
191
|
+
A validator function
|
|
192
|
+
|
|
193
|
+
"""
|
|
194
|
+
|
|
195
|
+
def validator(value: str) -> Maybe[str]:
|
|
196
|
+
if min_length <= len(value) <= max_length:
|
|
197
|
+
return Maybe.success(value)
|
|
198
|
+
return Maybe.failure(error_message or f'String length must be between {min_length} and {max_length}')
|
|
199
|
+
|
|
200
|
+
return Validator(validator)
|
valid8r/prompt/basic.py
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
"""Basic input prompting functions with validation support.
|
|
2
|
+
|
|
3
|
+
This module provides functionality for prompting users for input via the command line
|
|
4
|
+
with built-in parsing, validation, and retry logic.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from typing import (
|
|
11
|
+
TYPE_CHECKING,
|
|
12
|
+
Generic,
|
|
13
|
+
TypeVar,
|
|
14
|
+
cast,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
from valid8r.core.maybe import (
|
|
18
|
+
Failure,
|
|
19
|
+
Maybe,
|
|
20
|
+
Success,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
if TYPE_CHECKING:
|
|
24
|
+
from collections.abc import Callable
|
|
25
|
+
|
|
26
|
+
T = TypeVar('T')
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
@dataclass
|
|
30
|
+
class PromptConfig(Generic[T]):
|
|
31
|
+
"""Configuration for the ask function."""
|
|
32
|
+
|
|
33
|
+
parser: Callable[[str], Maybe[T]] | None = None
|
|
34
|
+
validator: Callable[[T], Maybe[T]] | None = None
|
|
35
|
+
error_message: str | None = None
|
|
36
|
+
default: T | None = None
|
|
37
|
+
retry: bool | int = False
|
|
38
|
+
_test_mode: bool = False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _handle_user_input(prompt_text: str, default: T | None) -> tuple[str, bool]:
|
|
42
|
+
"""Handle getting user input and displaying the prompt.
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
A tuple of (user_input, use_default) where use_default is True if the
|
|
46
|
+
default value should be used.
|
|
47
|
+
|
|
48
|
+
"""
|
|
49
|
+
# Build prompt text with default if available
|
|
50
|
+
display_prompt = prompt_text
|
|
51
|
+
if default is not None:
|
|
52
|
+
display_prompt = f'{prompt_text} [{default}]: '
|
|
53
|
+
|
|
54
|
+
# Get user input
|
|
55
|
+
user_input = input(display_prompt)
|
|
56
|
+
|
|
57
|
+
# Check if we should use the default value
|
|
58
|
+
use_default = not user_input and default is not None
|
|
59
|
+
|
|
60
|
+
return user_input, use_default
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _process_input(user_input: str, parser: Callable[[str], Maybe[T]], validator: Callable[[T], Maybe[T]]) -> Maybe[T]:
|
|
64
|
+
"""Process user input by parsing and validating."""
|
|
65
|
+
# Parse input
|
|
66
|
+
result = parser(user_input)
|
|
67
|
+
|
|
68
|
+
# Validate if parsing was successful
|
|
69
|
+
match result:
|
|
70
|
+
case Success(value):
|
|
71
|
+
return validator(value)
|
|
72
|
+
case Failure(_):
|
|
73
|
+
return result
|
|
74
|
+
|
|
75
|
+
return result # This line is unreachable but keeps type checkers happy pragma: no cover
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def ask( # noqa: PLR0913
|
|
79
|
+
prompt_text: str,
|
|
80
|
+
*, # Force all other parameters to be keyword-only
|
|
81
|
+
parser: Callable[[str], Maybe[T]] | None = None,
|
|
82
|
+
validator: Callable[[T], Maybe[T]] | None = None,
|
|
83
|
+
error_message: str | None = None,
|
|
84
|
+
default: T | None = None,
|
|
85
|
+
retry: bool | int = False,
|
|
86
|
+
_test_mode: bool = False,
|
|
87
|
+
) -> Maybe[T]:
|
|
88
|
+
"""Prompt the user for input with validation.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
prompt_text: The prompt to display to the user
|
|
92
|
+
parser: Function to convert string to desired type
|
|
93
|
+
validator: Function to validate the parsed value
|
|
94
|
+
error_message: Custom error message for invalid input
|
|
95
|
+
default: Default value to use if input is empty
|
|
96
|
+
retry: If True or an integer, retry on invalid input
|
|
97
|
+
_test_mode: Hidden parameter for testing the final return path
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
A Maybe containing the validated input or an error
|
|
101
|
+
|
|
102
|
+
Examples:
|
|
103
|
+
>>> # This would prompt the user and validate their input
|
|
104
|
+
>>> from valid8r.core import parsers, validators
|
|
105
|
+
>>> age = ask(
|
|
106
|
+
... "Enter your age: ",
|
|
107
|
+
... parser=parsers.parse_int,
|
|
108
|
+
... validator=validators.minimum(0),
|
|
109
|
+
... retry=True
|
|
110
|
+
... )
|
|
111
|
+
|
|
112
|
+
"""
|
|
113
|
+
# Create a config object from the parameters
|
|
114
|
+
config = PromptConfig(
|
|
115
|
+
parser=parser,
|
|
116
|
+
validator=validator,
|
|
117
|
+
error_message=error_message,
|
|
118
|
+
default=default,
|
|
119
|
+
retry=retry,
|
|
120
|
+
_test_mode=_test_mode,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
return _ask_with_config(prompt_text, config)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def _ask_with_config(prompt_text: str, config: PromptConfig[T]) -> Maybe[T]:
|
|
127
|
+
"""Implement ask using a PromptConfig object."""
|
|
128
|
+
# For testing the final return path
|
|
129
|
+
if config._test_mode: # noqa: SLF001
|
|
130
|
+
return Maybe.failure(config.error_message or 'Maximum retry attempts reached')
|
|
131
|
+
|
|
132
|
+
# Set default parser and validator if not provided
|
|
133
|
+
def default_parser(s: str) -> Maybe[T]:
|
|
134
|
+
return Maybe.success(cast('T', s))
|
|
135
|
+
|
|
136
|
+
parser: Callable[[str], Maybe[T]] = config.parser if config.parser is not None else default_parser
|
|
137
|
+
validator = config.validator or (lambda v: Maybe.success(v))
|
|
138
|
+
|
|
139
|
+
# Calculate max retries
|
|
140
|
+
max_retries = config.retry if isinstance(config.retry, int) else float('inf') if config.retry else 0
|
|
141
|
+
|
|
142
|
+
return _run_prompt_loop(prompt_text, parser, validator, config.default, max_retries, config.error_message)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _run_prompt_loop( # noqa: PLR0913
|
|
146
|
+
prompt_text: str,
|
|
147
|
+
parser: Callable[[str], Maybe[T]],
|
|
148
|
+
validator: Callable[[T], Maybe[T]],
|
|
149
|
+
default: T | None,
|
|
150
|
+
max_retries: float,
|
|
151
|
+
error_message: str | None,
|
|
152
|
+
) -> Maybe[T]:
|
|
153
|
+
"""Run the prompt loop with retries."""
|
|
154
|
+
attempt = 0
|
|
155
|
+
|
|
156
|
+
while attempt <= max_retries:
|
|
157
|
+
# Get user input
|
|
158
|
+
user_input, use_default = _handle_user_input(prompt_text, default)
|
|
159
|
+
|
|
160
|
+
# Use default if requested
|
|
161
|
+
if use_default:
|
|
162
|
+
if default is None:
|
|
163
|
+
return Maybe.failure('No default value provided')
|
|
164
|
+
return Maybe.success(default)
|
|
165
|
+
# Process the input
|
|
166
|
+
result = _process_input(user_input, parser, validator)
|
|
167
|
+
|
|
168
|
+
match result:
|
|
169
|
+
case Success(_):
|
|
170
|
+
return result
|
|
171
|
+
case Failure(error):
|
|
172
|
+
# Handle invalid input
|
|
173
|
+
attempt += 1
|
|
174
|
+
if attempt <= max_retries:
|
|
175
|
+
_display_error(error, error_message, max_retries, attempt)
|
|
176
|
+
else:
|
|
177
|
+
return result # Return the failed result after max retries
|
|
178
|
+
|
|
179
|
+
return Maybe.failure(error_message or 'Maximum retry attempts reached')
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _display_error(result_error: str, custom_error: str | None, max_retries: float, attempt: int) -> None:
|
|
183
|
+
"""Display error message to the user."""
|
|
184
|
+
err_msg = custom_error or result_error
|
|
185
|
+
remaining = max_retries - attempt if max_retries < float('inf') else None
|
|
186
|
+
|
|
187
|
+
if remaining is not None:
|
|
188
|
+
print(f'Error: {err_msg} ({remaining} attempt(s) remaining)')
|
|
189
|
+
else:
|
|
190
|
+
print(f'Error: {err_msg}')
|
valid8r/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# valid8r/testing/__init__.py
|
|
2
|
+
"""Testing utilities for Valid8r.
|
|
3
|
+
|
|
4
|
+
This module provides tools for testing applications that use Valid8r,
|
|
5
|
+
making it easier to test validation logic, user prompts, and Maybe monads.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from valid8r.testing.assertions import (
|
|
11
|
+
assert_maybe_failure,
|
|
12
|
+
assert_maybe_success,
|
|
13
|
+
)
|
|
14
|
+
from valid8r.testing.generators import (
|
|
15
|
+
generate_random_inputs,
|
|
16
|
+
generate_test_cases,
|
|
17
|
+
test_validator_composition,
|
|
18
|
+
)
|
|
19
|
+
from valid8r.testing.mock_input import (
|
|
20
|
+
MockInputContext,
|
|
21
|
+
configure_mock_input,
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
__all__ = [
|
|
25
|
+
'MockInputContext',
|
|
26
|
+
'assert_maybe_failure',
|
|
27
|
+
'assert_maybe_success',
|
|
28
|
+
'configure_mock_input',
|
|
29
|
+
'generate_random_inputs',
|
|
30
|
+
'generate_test_cases',
|
|
31
|
+
'test_validator_composition',
|
|
32
|
+
]
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Assertion helpers for testing with Maybe monads."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import (
|
|
6
|
+
Any,
|
|
7
|
+
TypeVar,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
from valid8r.core.maybe import (
|
|
11
|
+
Failure,
|
|
12
|
+
Maybe,
|
|
13
|
+
Success,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
T = TypeVar('T')
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def assert_maybe_success(result: Maybe[T], expected_value: Any) -> bool: # noqa: ANN401
|
|
20
|
+
"""Assert that a Maybe is a Success with the expected value.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
result: The Maybe instance to check
|
|
24
|
+
expected_value: The expected value inside the Maybe
|
|
25
|
+
|
|
26
|
+
Returns:
|
|
27
|
+
True if result is a Success with the expected value, False otherwise
|
|
28
|
+
|
|
29
|
+
Examples:
|
|
30
|
+
>>> result = Maybe.success(42)
|
|
31
|
+
>>> assert_maybe_success(result, 42) # Returns True
|
|
32
|
+
>>> assert_maybe_success(result, 43) # Returns False
|
|
33
|
+
|
|
34
|
+
"""
|
|
35
|
+
match result:
|
|
36
|
+
case Success(value):
|
|
37
|
+
return bool(value == expected_value)
|
|
38
|
+
case _:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def assert_maybe_failure(result: Maybe[T], expected_error: str) -> bool:
|
|
43
|
+
"""Assert that a Maybe is a Failure with the expected error message.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
result: The Maybe instance to check
|
|
47
|
+
expected_error: The expected error message inside the Maybe
|
|
48
|
+
|
|
49
|
+
Returns:
|
|
50
|
+
True if result is a Failure with the expected error, False otherwise
|
|
51
|
+
|
|
52
|
+
Examples:
|
|
53
|
+
>>> result = Maybe.failure("Invalid input")
|
|
54
|
+
>>> assert_maybe_failure(result, "Invalid input") # Returns True
|
|
55
|
+
>>> assert_maybe_failure(result, "Other error") # Returns False
|
|
56
|
+
|
|
57
|
+
"""
|
|
58
|
+
match result:
|
|
59
|
+
case Failure(error):
|
|
60
|
+
return error == expected_error
|
|
61
|
+
case _:
|
|
62
|
+
return False
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def assert_error_equals(result: Maybe[T], expected_error: str, default: str = '') -> bool:
|
|
66
|
+
"""Assert error via error_or helper."""
|
|
67
|
+
return result.error_or(default) == expected_error
|