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,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