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
valid8r/prompt/basic.py
ADDED
|
@@ -0,0 +1,229 @@
|
|
|
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 parsing and validation.
|
|
89
|
+
|
|
90
|
+
Displays a prompt to the user, parses their input using the provided parser,
|
|
91
|
+
validates the result, and optionally retries on failure. Returns a Maybe monad
|
|
92
|
+
containing either the validated input or an error message.
|
|
93
|
+
|
|
94
|
+
Args:
|
|
95
|
+
prompt_text: The prompt message to display to the user
|
|
96
|
+
parser: Function to convert string input to desired type (default: returns string as-is)
|
|
97
|
+
validator: Function to validate the parsed value (default: accepts any value)
|
|
98
|
+
error_message: Custom error message to display on validation failure
|
|
99
|
+
default: Default value to use if user provides empty input (displays in prompt)
|
|
100
|
+
retry: Enable retry on failure - True for unlimited, integer for max attempts, False to disable
|
|
101
|
+
_test_mode: Internal testing parameter (do not use)
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
Maybe[T]: Success with validated input, or Failure with error message
|
|
105
|
+
|
|
106
|
+
Examples:
|
|
107
|
+
>>> from valid8r.core import parsers, validators
|
|
108
|
+
>>> from valid8r.prompt import ask
|
|
109
|
+
>>>
|
|
110
|
+
>>> # Basic integer input with validation
|
|
111
|
+
>>> result = ask(
|
|
112
|
+
... "Enter your age: ",
|
|
113
|
+
... parser=parsers.parse_int,
|
|
114
|
+
... validator=validators.between(0, 120),
|
|
115
|
+
... retry=True
|
|
116
|
+
... )
|
|
117
|
+
>>> # User enters "25" -> Success(25)
|
|
118
|
+
>>> # User enters "invalid" -> prompts again with error message
|
|
119
|
+
>>>
|
|
120
|
+
>>> # Input with default value
|
|
121
|
+
>>> result = ask(
|
|
122
|
+
... "Enter port: ",
|
|
123
|
+
... parser=parsers.parse_int,
|
|
124
|
+
... default=8080
|
|
125
|
+
... )
|
|
126
|
+
>>> # User presses Enter -> Success(8080)
|
|
127
|
+
>>> # User enters "3000" -> Success(3000)
|
|
128
|
+
>>>
|
|
129
|
+
>>> # Limited retries with custom error
|
|
130
|
+
>>> result = ask(
|
|
131
|
+
... "Email: ",
|
|
132
|
+
... parser=parsers.parse_email,
|
|
133
|
+
... error_message="Invalid email format",
|
|
134
|
+
... retry=3
|
|
135
|
+
... )
|
|
136
|
+
>>> # User has 3 attempts to enter valid email
|
|
137
|
+
>>>
|
|
138
|
+
>>> # Boolean input with retry
|
|
139
|
+
>>> result = ask(
|
|
140
|
+
... "Continue? (yes/no): ",
|
|
141
|
+
... parser=parsers.parse_bool,
|
|
142
|
+
... retry=True
|
|
143
|
+
... )
|
|
144
|
+
>>> # User enters "yes" -> Success(True)
|
|
145
|
+
>>> # User enters "maybe" -> error, retry prompt
|
|
146
|
+
|
|
147
|
+
Note:
|
|
148
|
+
The returned Maybe must be unwrapped to access the value.
|
|
149
|
+
Use pattern matching or .value_or() to extract the result.
|
|
150
|
+
|
|
151
|
+
"""
|
|
152
|
+
# Create a config object from the parameters
|
|
153
|
+
config = PromptConfig(
|
|
154
|
+
parser=parser,
|
|
155
|
+
validator=validator,
|
|
156
|
+
error_message=error_message,
|
|
157
|
+
default=default,
|
|
158
|
+
retry=retry,
|
|
159
|
+
_test_mode=_test_mode,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return _ask_with_config(prompt_text, config)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _ask_with_config(prompt_text: str, config: PromptConfig[T]) -> Maybe[T]:
|
|
166
|
+
"""Implement ask using a PromptConfig object."""
|
|
167
|
+
# For testing the final return path
|
|
168
|
+
if config._test_mode: # noqa: SLF001
|
|
169
|
+
return Maybe.failure(config.error_message or 'Maximum retry attempts reached')
|
|
170
|
+
|
|
171
|
+
# Set default parser and validator if not provided
|
|
172
|
+
def default_parser(s: str) -> Maybe[T]:
|
|
173
|
+
return Maybe.success(cast('T', s))
|
|
174
|
+
|
|
175
|
+
parser: Callable[[str], Maybe[T]] = config.parser if config.parser is not None else default_parser
|
|
176
|
+
validator = config.validator or (lambda v: Maybe.success(v))
|
|
177
|
+
|
|
178
|
+
# Calculate max retries
|
|
179
|
+
max_retries = config.retry if isinstance(config.retry, int) else float('inf') if config.retry else 0
|
|
180
|
+
|
|
181
|
+
return _run_prompt_loop(prompt_text, parser, validator, config.default, max_retries, config.error_message)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _run_prompt_loop( # noqa: PLR0913
|
|
185
|
+
prompt_text: str,
|
|
186
|
+
parser: Callable[[str], Maybe[T]],
|
|
187
|
+
validator: Callable[[T], Maybe[T]],
|
|
188
|
+
default: T | None,
|
|
189
|
+
max_retries: float,
|
|
190
|
+
error_message: str | None,
|
|
191
|
+
) -> Maybe[T]:
|
|
192
|
+
"""Run the prompt loop with retries."""
|
|
193
|
+
attempt = 0
|
|
194
|
+
|
|
195
|
+
while attempt <= max_retries:
|
|
196
|
+
# Get user input
|
|
197
|
+
user_input, use_default = _handle_user_input(prompt_text, default)
|
|
198
|
+
|
|
199
|
+
# Use default if requested
|
|
200
|
+
if use_default:
|
|
201
|
+
if default is None:
|
|
202
|
+
return Maybe.failure('No default value provided')
|
|
203
|
+
return Maybe.success(default)
|
|
204
|
+
# Process the input
|
|
205
|
+
result = _process_input(user_input, parser, validator)
|
|
206
|
+
|
|
207
|
+
match result:
|
|
208
|
+
case Success(_):
|
|
209
|
+
return result
|
|
210
|
+
case Failure(error):
|
|
211
|
+
# Handle invalid input
|
|
212
|
+
attempt += 1
|
|
213
|
+
if attempt <= max_retries:
|
|
214
|
+
_display_error(error, error_message, max_retries, attempt)
|
|
215
|
+
else:
|
|
216
|
+
return result # Return the failed result after max retries
|
|
217
|
+
|
|
218
|
+
return Maybe.failure(error_message or 'Maximum retry attempts reached')
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _display_error(result_error: str, custom_error: str | None, max_retries: float, attempt: int) -> None:
|
|
222
|
+
"""Display error message to the user."""
|
|
223
|
+
err_msg = custom_error or result_error
|
|
224
|
+
remaining = max_retries - attempt if max_retries < float('inf') else None
|
|
225
|
+
|
|
226
|
+
if remaining is not None:
|
|
227
|
+
print(f'Error: {err_msg} ({remaining} attempt(s) remaining)')
|
|
228
|
+
else:
|
|
229
|
+
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
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""Generators for test cases and test input data."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from typing import (
|
|
7
|
+
TYPE_CHECKING,
|
|
8
|
+
Any,
|
|
9
|
+
TypeVar,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
from valid8r.core.maybe import Success
|
|
13
|
+
|
|
14
|
+
if TYPE_CHECKING:
|
|
15
|
+
from collections.abc import Iterable
|
|
16
|
+
from types import CellType
|
|
17
|
+
|
|
18
|
+
from valid8r.core.validators import Validator
|
|
19
|
+
|
|
20
|
+
T = TypeVar('T')
|
|
21
|
+
|
|
22
|
+
# Constants for test case generation
|
|
23
|
+
OFFSET_SMALL = 1
|
|
24
|
+
OFFSET_MEDIUM = 5
|
|
25
|
+
OFFSET_LARGE = 10
|
|
26
|
+
OFFSET_XLARGE = 100
|
|
27
|
+
MULTIPLIER_FAR = 2
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _extract_numeric_value_from_closure(closure: Iterable[CellType]) -> Any | None: # noqa: ANN401
|
|
31
|
+
"""Extract a numeric value from a closure."""
|
|
32
|
+
for cell in closure:
|
|
33
|
+
value = cell.cell_contents
|
|
34
|
+
if isinstance(value, int | float):
|
|
35
|
+
return value
|
|
36
|
+
return None
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _generate_minimum_validator_cases(min_value: float) -> tuple[list[Any], list[Any]]:
|
|
40
|
+
"""Generate test cases for a minimum validator."""
|
|
41
|
+
valid_cases = [
|
|
42
|
+
min_value, # Boundary
|
|
43
|
+
min_value + OFFSET_SMALL, # Just above
|
|
44
|
+
min_value + OFFSET_MEDIUM, # Above
|
|
45
|
+
min_value + OFFSET_LARGE, # Well above
|
|
46
|
+
min_value * OFFSET_LARGE if min_value > 0 else OFFSET_XLARGE, # Far above
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
invalid_cases = [
|
|
50
|
+
min_value - OFFSET_SMALL, # Just below
|
|
51
|
+
min_value - OFFSET_MEDIUM if min_value >= OFFSET_MEDIUM else 0, # Below
|
|
52
|
+
min_value - OFFSET_LARGE if min_value >= OFFSET_LARGE else -1, # Well below
|
|
53
|
+
-10 if min_value > 0 else min_value - 10, # Far below
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
return valid_cases, invalid_cases
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _generate_maximum_validator_cases(max_value: float) -> tuple[list[Any], list[Any]]:
|
|
60
|
+
"""Generate test cases for a maximum validator."""
|
|
61
|
+
valid_cases = [
|
|
62
|
+
max_value, # Boundary
|
|
63
|
+
max_value - OFFSET_SMALL, # Just below
|
|
64
|
+
max_value - OFFSET_MEDIUM if max_value >= OFFSET_MEDIUM else 0, # Below
|
|
65
|
+
max_value - OFFSET_LARGE if max_value >= OFFSET_LARGE else 0, # Well below
|
|
66
|
+
0 if max_value > 0 else max_value // MULTIPLIER_FAR, # Far below
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
invalid_cases = [
|
|
70
|
+
max_value + OFFSET_SMALL, # Just above
|
|
71
|
+
max_value + OFFSET_MEDIUM, # Above
|
|
72
|
+
max_value + OFFSET_LARGE, # Well above
|
|
73
|
+
max_value * MULTIPLIER_FAR, # Far above
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
return valid_cases, invalid_cases
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _generate_between_validator_cases(min_val: float, max_val: float) -> tuple[list[Any], list[Any]]:
|
|
80
|
+
"""Generate test cases for a between validator."""
|
|
81
|
+
range_size = max_val - min_val
|
|
82
|
+
valid_cases = [
|
|
83
|
+
min_val, # Min boundary
|
|
84
|
+
max_val, # Max boundary
|
|
85
|
+
(min_val + max_val) // 2, # Middle
|
|
86
|
+
min_val + range_size // 4, # First quarter
|
|
87
|
+
max_val - range_size // 4, # Last quarter
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
invalid_cases = [
|
|
91
|
+
min_val - 1, # Just below min
|
|
92
|
+
max_val + 1, # Just above max
|
|
93
|
+
min_val - 10, # Well below min
|
|
94
|
+
max_val + 10, # Well above max
|
|
95
|
+
]
|
|
96
|
+
|
|
97
|
+
return valid_cases, invalid_cases
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _identify_validator_type(validator: Validator[T]) -> tuple[str, Any, Any]:
|
|
101
|
+
"""Identify the type of validator and extract its parameters.
|
|
102
|
+
|
|
103
|
+
Returns:
|
|
104
|
+
A tuple of (validator_type, first_param, second_param)
|
|
105
|
+
|
|
106
|
+
"""
|
|
107
|
+
if not hasattr(validator.func, '__closure__') or not validator.func.__closure__:
|
|
108
|
+
return 'unknown', None, None
|
|
109
|
+
|
|
110
|
+
func_str = str(validator.func)
|
|
111
|
+
|
|
112
|
+
if 'minimum' in func_str:
|
|
113
|
+
min_value = _extract_numeric_value_from_closure(validator.func.__closure__)
|
|
114
|
+
return 'minimum', min_value, None
|
|
115
|
+
|
|
116
|
+
if 'maximum' in func_str:
|
|
117
|
+
max_value = _extract_numeric_value_from_closure(validator.func.__closure__)
|
|
118
|
+
return 'maximum', max_value, None
|
|
119
|
+
|
|
120
|
+
if 'between' in func_str:
|
|
121
|
+
values = _extract_two_numeric_values(validator.func.__closure__)
|
|
122
|
+
return 'between', values[0], values[1]
|
|
123
|
+
|
|
124
|
+
return 'unknown', None, None
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _extract_two_numeric_values(closure: Iterable[CellType]) -> tuple[Any, Any]:
|
|
128
|
+
"""Extract two numeric values from a closure."""
|
|
129
|
+
values = []
|
|
130
|
+
for cell in closure:
|
|
131
|
+
value = cell.cell_contents
|
|
132
|
+
if isinstance(value, int | float):
|
|
133
|
+
values.append(value)
|
|
134
|
+
if len(values) >= 2: # noqa: PLR2004
|
|
135
|
+
break
|
|
136
|
+
|
|
137
|
+
if len(values) < 2: # noqa: PLR2004
|
|
138
|
+
return None, None
|
|
139
|
+
return values[0], values[1]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def generate_test_cases(validator: Validator[T]) -> dict[str, list[Any]]:
|
|
143
|
+
"""Generate test cases for a validator.
|
|
144
|
+
|
|
145
|
+
This function analyzes the validator and generates appropriate test cases
|
|
146
|
+
that should pass and fail the validation.
|
|
147
|
+
|
|
148
|
+
Args:
|
|
149
|
+
validator: The validator to generate test cases for
|
|
150
|
+
|
|
151
|
+
Returns:
|
|
152
|
+
A dictionary with 'valid' and 'invalid' lists of test cases
|
|
153
|
+
|
|
154
|
+
Examples:
|
|
155
|
+
>>> test_cases = generate_test_cases(minimum(10))
|
|
156
|
+
>>> test_cases
|
|
157
|
+
{'valid': [10, 11, 15, 20, 100], 'invalid': [9, 5, 0, -10]}
|
|
158
|
+
|
|
159
|
+
"""
|
|
160
|
+
# Identify validator type and extract parameters
|
|
161
|
+
validator_type, param1, param2 = _identify_validator_type(validator)
|
|
162
|
+
|
|
163
|
+
# Generate test cases based on validator type
|
|
164
|
+
valid_cases: list[Any] = []
|
|
165
|
+
invalid_cases: list[Any] = []
|
|
166
|
+
|
|
167
|
+
if validator_type == 'minimum' and param1 is not None:
|
|
168
|
+
valid_cases, invalid_cases = _generate_minimum_validator_cases(param1)
|
|
169
|
+
|
|
170
|
+
elif validator_type == 'maximum' and param1 is not None:
|
|
171
|
+
valid_cases, invalid_cases = _generate_maximum_validator_cases(param1)
|
|
172
|
+
|
|
173
|
+
elif validator_type == 'between' and param1 is not None and param2 is not None:
|
|
174
|
+
valid_cases, invalid_cases = _generate_between_validator_cases(param1, param2)
|
|
175
|
+
|
|
176
|
+
# Use generic cases if we couldn't determine specific ones
|
|
177
|
+
if not valid_cases:
|
|
178
|
+
valid_cases = [0, 1, 10, 42, 100]
|
|
179
|
+
if not invalid_cases:
|
|
180
|
+
invalid_cases = [-1, -10, -100]
|
|
181
|
+
|
|
182
|
+
# Verify categorization against the actual validator
|
|
183
|
+
actual_valid = []
|
|
184
|
+
actual_invalid = []
|
|
185
|
+
|
|
186
|
+
for case in valid_cases + invalid_cases:
|
|
187
|
+
result = validator(case)
|
|
188
|
+
if result.is_success():
|
|
189
|
+
actual_valid.append(case)
|
|
190
|
+
else:
|
|
191
|
+
actual_invalid.append(case)
|
|
192
|
+
|
|
193
|
+
return {'valid': actual_valid, 'invalid': actual_invalid}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def generate_random_inputs(
|
|
197
|
+
validator: Validator[T], count: int = 20, range_min: int = -100, range_max: int = 100
|
|
198
|
+
) -> list[T]:
|
|
199
|
+
"""Generate random inputs that include both valid and invalid cases.
|
|
200
|
+
|
|
201
|
+
Args:
|
|
202
|
+
validator: The validator to test against
|
|
203
|
+
count: Number of inputs to generate
|
|
204
|
+
range_min: Minimum value for generated integers
|
|
205
|
+
range_max: Maximum value for generated integers
|
|
206
|
+
|
|
207
|
+
Returns:
|
|
208
|
+
A list of random integers
|
|
209
|
+
|
|
210
|
+
Examples:
|
|
211
|
+
>>> inputs = generate_random_inputs(minimum(0), count=10)
|
|
212
|
+
>>> len(inputs)
|
|
213
|
+
10
|
|
214
|
+
|
|
215
|
+
"""
|
|
216
|
+
inputs: list[Any] = []
|
|
217
|
+
|
|
218
|
+
# Try to make sure we get both valid and invalid cases
|
|
219
|
+
for _ in range(count):
|
|
220
|
+
value = random.randint(range_min, range_max) # noqa: S311
|
|
221
|
+
inputs.append(value)
|
|
222
|
+
|
|
223
|
+
# Verify we have at least one valid and one invalid case
|
|
224
|
+
has_valid = False
|
|
225
|
+
has_invalid = False
|
|
226
|
+
|
|
227
|
+
for input_val in inputs:
|
|
228
|
+
result = validator(input_val)
|
|
229
|
+
match result:
|
|
230
|
+
case Success(_):
|
|
231
|
+
has_valid = True
|
|
232
|
+
case _:
|
|
233
|
+
has_invalid = True
|
|
234
|
+
|
|
235
|
+
if has_valid and has_invalid:
|
|
236
|
+
break
|
|
237
|
+
|
|
238
|
+
# If we're missing either valid or invalid cases, add them explicitly
|
|
239
|
+
if not has_valid or not has_invalid:
|
|
240
|
+
# Get test cases that are known to be valid/invalid
|
|
241
|
+
test_cases = generate_test_cases(validator)
|
|
242
|
+
|
|
243
|
+
if not has_valid and test_cases['valid']:
|
|
244
|
+
# Replace the first item with a valid case
|
|
245
|
+
inputs[0] = test_cases['valid'][0]
|
|
246
|
+
|
|
247
|
+
if not has_invalid and test_cases['invalid']:
|
|
248
|
+
# Replace the second item with an invalid case
|
|
249
|
+
inputs[1 if len(inputs) > 1 else 0] = test_cases['invalid'][0]
|
|
250
|
+
|
|
251
|
+
return inputs
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_validator_composition(validator: Validator[T]) -> bool:
|
|
255
|
+
"""Test a composed validator with various inputs to verify it works correctly.
|
|
256
|
+
|
|
257
|
+
Args:
|
|
258
|
+
validator: The composed validator to test
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
True if the validator behaves as expected, False otherwise
|
|
262
|
+
|
|
263
|
+
Examples:
|
|
264
|
+
>>> is_valid_age = minimum(0) & maximum(120)
|
|
265
|
+
>>> test_validator_composition(is_valid_age) # Returns True
|
|
266
|
+
|
|
267
|
+
"""
|
|
268
|
+
# Generate test cases
|
|
269
|
+
test_cases = generate_test_cases(validator)
|
|
270
|
+
|
|
271
|
+
# Check that all valid cases pass
|
|
272
|
+
for case in test_cases['valid']:
|
|
273
|
+
result = validator(case)
|
|
274
|
+
if not result.is_success():
|
|
275
|
+
return False
|
|
276
|
+
|
|
277
|
+
# Check that all invalid cases fail
|
|
278
|
+
for case in test_cases['invalid']:
|
|
279
|
+
result = validator(case)
|
|
280
|
+
if not result.is_failure():
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
return True
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Utilities for mocking user input during tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import builtins
|
|
6
|
+
from contextlib import contextmanager
|
|
7
|
+
from typing import TYPE_CHECKING
|
|
8
|
+
|
|
9
|
+
if TYPE_CHECKING:
|
|
10
|
+
from collections.abc import Iterator
|
|
11
|
+
|
|
12
|
+
# Store the original input function
|
|
13
|
+
_original_input = builtins.input
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@contextmanager
|
|
17
|
+
def MockInputContext(inputs: list[str] | None = None) -> Iterator[None]: # noqa: N802
|
|
18
|
+
"""Context manager for mocking user input.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
inputs: A list of strings to be returned sequentially by input().
|
|
22
|
+
|
|
23
|
+
Yields:
|
|
24
|
+
None
|
|
25
|
+
|
|
26
|
+
Examples:
|
|
27
|
+
>>> with MockInputContext(["yes", "42"]):
|
|
28
|
+
... answer = input("Continue? ") # returns "yes"
|
|
29
|
+
... number = input("Enter number: ") # returns "42"
|
|
30
|
+
|
|
31
|
+
"""
|
|
32
|
+
input_values = [] if inputs is None else list(inputs)
|
|
33
|
+
|
|
34
|
+
def mock_input(prompt: object = '') -> str: # noqa: ARG001
|
|
35
|
+
"""Mock implementation of input function.
|
|
36
|
+
|
|
37
|
+
Args:
|
|
38
|
+
prompt: The input prompt (ignored in mock)
|
|
39
|
+
|
|
40
|
+
Returns:
|
|
41
|
+
The next string from the predefined inputs list
|
|
42
|
+
|
|
43
|
+
Raises:
|
|
44
|
+
IndexError: If there are no more inputs available
|
|
45
|
+
|
|
46
|
+
"""
|
|
47
|
+
if not input_values:
|
|
48
|
+
raise IndexError('No more mock inputs available')
|
|
49
|
+
return input_values.pop(0)
|
|
50
|
+
|
|
51
|
+
# Replace the builtin input function
|
|
52
|
+
builtins.input = mock_input
|
|
53
|
+
|
|
54
|
+
try:
|
|
55
|
+
yield
|
|
56
|
+
finally:
|
|
57
|
+
# Restore the original input function
|
|
58
|
+
builtins.input = _original_input
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def configure_mock_input(inputs: list[str]) -> None:
|
|
62
|
+
"""Configure input to be mocked globally.
|
|
63
|
+
|
|
64
|
+
Unlike MockInputContext, this function replaces the input function
|
|
65
|
+
globally without restoring it automatically. Use for simple tests
|
|
66
|
+
where cleanup isn't critical.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
inputs: A list of strings to be returned sequentially by input().
|
|
70
|
+
|
|
71
|
+
Examples:
|
|
72
|
+
>>> configure_mock_input(["yes", "42"])
|
|
73
|
+
>>> answer = input("Continue? ") # returns "yes"
|
|
74
|
+
>>> number = input("Enter number: ") # returns "42"
|
|
75
|
+
|
|
76
|
+
"""
|
|
77
|
+
input_values = list(inputs) # Create a copy
|
|
78
|
+
|
|
79
|
+
def mock_input(prompt: object = '') -> str: # noqa: ARG001
|
|
80
|
+
if not input_values:
|
|
81
|
+
raise IndexError('No more mock inputs available')
|
|
82
|
+
return input_values.pop(0)
|
|
83
|
+
|
|
84
|
+
builtins.input = mock_input
|