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
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Integrations with popular Python frameworks and libraries.
|
|
2
|
+
|
|
3
|
+
This module provides integrations with popular Python frameworks:
|
|
4
|
+
|
|
5
|
+
- Click: CLI framework integration via ParamTypeAdapter
|
|
6
|
+
- Pydantic: Field validator integration via validator_from_parser
|
|
7
|
+
- Environment Variables: Schema-based configuration loading via load_env_config
|
|
8
|
+
|
|
9
|
+
Examples:
|
|
10
|
+
>>> # Click integration
|
|
11
|
+
>>> from valid8r.integrations.click import ParamTypeAdapter
|
|
12
|
+
>>> from valid8r.core import parsers
|
|
13
|
+
>>> import click
|
|
14
|
+
>>>
|
|
15
|
+
>>> @click.command()
|
|
16
|
+
... @click.option('--email', type=ParamTypeAdapter(parsers.parse_email))
|
|
17
|
+
... def greet(email):
|
|
18
|
+
... click.echo(f"Hello {email.local}@{email.domain}!")
|
|
19
|
+
|
|
20
|
+
>>> # Pydantic integration
|
|
21
|
+
>>> from valid8r.integrations.pydantic import validator_from_parser
|
|
22
|
+
>>> from pydantic import BaseModel
|
|
23
|
+
>>>
|
|
24
|
+
>>> class User(BaseModel):
|
|
25
|
+
... email: str
|
|
26
|
+
... _validate_email = validator_from_parser(parsers.parse_email)
|
|
27
|
+
|
|
28
|
+
>>> # Environment variable integration
|
|
29
|
+
>>> from valid8r.integrations.env import load_env_config, EnvSchema, EnvField
|
|
30
|
+
>>> from valid8r.core import parsers
|
|
31
|
+
>>>
|
|
32
|
+
>>> schema = EnvSchema(fields={
|
|
33
|
+
... 'port': EnvField(parser=parsers.parse_int, default=8080),
|
|
34
|
+
... 'debug': EnvField(parser=parsers.parse_bool, default=False),
|
|
35
|
+
... })
|
|
36
|
+
>>> result = load_env_config(schema, prefix='APP_')
|
|
37
|
+
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
from valid8r.integrations.env import (
|
|
43
|
+
EnvField,
|
|
44
|
+
EnvSchema,
|
|
45
|
+
load_env_config,
|
|
46
|
+
)
|
|
47
|
+
from valid8r.integrations.pydantic import validator_from_parser
|
|
48
|
+
|
|
49
|
+
__all__ = ['EnvField', 'EnvSchema', 'load_env_config', 'validator_from_parser']
|
|
50
|
+
|
|
51
|
+
# Click integration is optional, only import if click is available
|
|
52
|
+
try:
|
|
53
|
+
from valid8r.integrations.click import ParamTypeAdapter
|
|
54
|
+
|
|
55
|
+
__all__ += ['ParamTypeAdapter']
|
|
56
|
+
except ImportError:
|
|
57
|
+
pass
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
"""Click integration for valid8r parsers.
|
|
2
|
+
|
|
3
|
+
This module provides ParamTypeAdapter to use valid8r parsers as Click ParamTypes.
|
|
4
|
+
|
|
5
|
+
Examples:
|
|
6
|
+
>>> import click
|
|
7
|
+
>>> from valid8r.core import parsers, validators
|
|
8
|
+
>>> from valid8r.integrations.click import ParamTypeAdapter
|
|
9
|
+
>>>
|
|
10
|
+
>>> # Basic usage with email parser
|
|
11
|
+
>>> @click.command()
|
|
12
|
+
... @click.option('--email', type=ParamTypeAdapter(parsers.parse_email))
|
|
13
|
+
... def create_user(email):
|
|
14
|
+
... click.echo(f"Creating user: {email.local}@{email.domain}")
|
|
15
|
+
>>>
|
|
16
|
+
>>> # With chained validators for port validation
|
|
17
|
+
>>> port_parser = parsers.parse_int_with_validation(
|
|
18
|
+
... validators.minimum(1) & validators.maximum(65535)
|
|
19
|
+
... )
|
|
20
|
+
>>> @click.command()
|
|
21
|
+
... @click.option('--port', type=ParamTypeAdapter(port_parser, name='port'))
|
|
22
|
+
... def start_server(port):
|
|
23
|
+
... click.echo(f"Starting server on port {port}")
|
|
24
|
+
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from typing import (
|
|
30
|
+
TYPE_CHECKING,
|
|
31
|
+
Any,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from collections.abc import Callable
|
|
36
|
+
|
|
37
|
+
from valid8r.core.maybe import Maybe
|
|
38
|
+
|
|
39
|
+
import click
|
|
40
|
+
|
|
41
|
+
from valid8r.core.maybe import (
|
|
42
|
+
Failure,
|
|
43
|
+
Success,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
class ParamTypeAdapter(click.ParamType):
|
|
48
|
+
"""Click ParamType adapter for valid8r parsers.
|
|
49
|
+
|
|
50
|
+
This class wraps a valid8r parser function (returning Maybe[T]) into a Click ParamType,
|
|
51
|
+
enabling seamless integration of valid8r's rich validation ecosystem with Click CLIs.
|
|
52
|
+
|
|
53
|
+
Args:
|
|
54
|
+
parser: A function that takes a string and returns Maybe[T]
|
|
55
|
+
name: Optional custom name for the type (defaults to parser.__name__)
|
|
56
|
+
error_prefix: Optional prefix for error messages (e.g., "Email address")
|
|
57
|
+
|
|
58
|
+
Examples:
|
|
59
|
+
>>> from valid8r.core import parsers
|
|
60
|
+
>>> from valid8r.integrations.click import ParamTypeAdapter
|
|
61
|
+
>>>
|
|
62
|
+
>>> # Simple email validation
|
|
63
|
+
>>> email_type = ParamTypeAdapter(parsers.parse_email)
|
|
64
|
+
>>> email_type.name
|
|
65
|
+
'parse_email'
|
|
66
|
+
>>>
|
|
67
|
+
>>> # With custom name
|
|
68
|
+
>>> port_type = ParamTypeAdapter(parsers.parse_int, name='port')
|
|
69
|
+
>>> port_type.name
|
|
70
|
+
'port'
|
|
71
|
+
>>>
|
|
72
|
+
>>> # With custom error prefix
|
|
73
|
+
>>> email_type = ParamTypeAdapter(
|
|
74
|
+
... parsers.parse_email,
|
|
75
|
+
... error_prefix='Email address'
|
|
76
|
+
... )
|
|
77
|
+
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
def __init__(
|
|
81
|
+
self,
|
|
82
|
+
parser: Callable[[str], Maybe[Any]],
|
|
83
|
+
name: str | None = None,
|
|
84
|
+
error_prefix: str | None = None,
|
|
85
|
+
) -> None:
|
|
86
|
+
"""Initialize the ParamTypeAdapter.
|
|
87
|
+
|
|
88
|
+
Args:
|
|
89
|
+
parser: A valid8r parser function
|
|
90
|
+
name: Custom name for the type (defaults to parser.__name__)
|
|
91
|
+
error_prefix: Custom prefix for error messages
|
|
92
|
+
|
|
93
|
+
"""
|
|
94
|
+
self.parser = parser
|
|
95
|
+
self.name = name or parser.__name__
|
|
96
|
+
self.error_prefix = error_prefix
|
|
97
|
+
|
|
98
|
+
def convert(
|
|
99
|
+
self,
|
|
100
|
+
value: Any, # noqa: ANN401
|
|
101
|
+
param: click.Parameter | None,
|
|
102
|
+
ctx: click.Context | None,
|
|
103
|
+
) -> Any: # noqa: ANN401
|
|
104
|
+
"""Convert and validate the input value using the parser.
|
|
105
|
+
|
|
106
|
+
Args:
|
|
107
|
+
value: The input value to convert
|
|
108
|
+
param: The Click parameter being processed
|
|
109
|
+
ctx: The Click context
|
|
110
|
+
|
|
111
|
+
Returns:
|
|
112
|
+
The successfully parsed and validated value
|
|
113
|
+
|
|
114
|
+
Raises:
|
|
115
|
+
click.exceptions.BadParameter: If validation fails
|
|
116
|
+
|
|
117
|
+
"""
|
|
118
|
+
# If value is not a string, it's already been converted (e.g., from a callback)
|
|
119
|
+
# In this case, just pass it through
|
|
120
|
+
if not isinstance(value, str):
|
|
121
|
+
return value
|
|
122
|
+
|
|
123
|
+
# Parse the value using the valid8r parser
|
|
124
|
+
result = self.parser(value)
|
|
125
|
+
|
|
126
|
+
# Handle the Maybe result
|
|
127
|
+
match result:
|
|
128
|
+
case Success(val):
|
|
129
|
+
return val
|
|
130
|
+
case Failure(err):
|
|
131
|
+
# Prepend error prefix if provided
|
|
132
|
+
message = f'{self.error_prefix}: {err}' if self.error_prefix else err
|
|
133
|
+
|
|
134
|
+
# Call Click's fail method to raise BadParameter
|
|
135
|
+
self.fail(message, param, ctx)
|
|
136
|
+
|
|
137
|
+
# This should never be reached due to exhaustive pattern matching
|
|
138
|
+
# but mypy doesn't know that
|
|
139
|
+
msg = 'Unexpected Maybe state'
|
|
140
|
+
raise RuntimeError(msg)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
__all__ = ['ParamTypeAdapter']
|
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""Environment variable integration module for valid8r.
|
|
2
|
+
|
|
3
|
+
This module provides utilities for loading typed, validated configuration
|
|
4
|
+
from environment variables using valid8r parsers.
|
|
5
|
+
|
|
6
|
+
Example:
|
|
7
|
+
>>> from valid8r.integrations.env import load_env_config, EnvSchema, EnvField
|
|
8
|
+
>>> from valid8r.core.parsers import parse_int, parse_bool
|
|
9
|
+
>>> schema = EnvSchema(fields={
|
|
10
|
+
... 'port': EnvField(parser=parse_int, default=8080),
|
|
11
|
+
... 'debug': EnvField(parser=parse_bool, default=False),
|
|
12
|
+
... })
|
|
13
|
+
>>> env = {'APP_PORT': '3000', 'APP_DEBUG': 'true'}
|
|
14
|
+
>>> result = load_env_config(schema, prefix='APP_', environ=env)
|
|
15
|
+
>>> result.value_or({})
|
|
16
|
+
{'port': 3000, 'debug': True}
|
|
17
|
+
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import os
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from typing import (
|
|
25
|
+
TYPE_CHECKING,
|
|
26
|
+
Any,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from collections.abc import Callable
|
|
31
|
+
|
|
32
|
+
from valid8r.core.maybe import (
|
|
33
|
+
Failure,
|
|
34
|
+
Maybe,
|
|
35
|
+
Success,
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class EnvField:
|
|
41
|
+
"""Represents a field in an environment variable schema.
|
|
42
|
+
|
|
43
|
+
Args:
|
|
44
|
+
parser: A function that parses a string into a Maybe[T]
|
|
45
|
+
default: Optional default value if environment variable is not set
|
|
46
|
+
required: Whether this field must be present in the environment
|
|
47
|
+
nested: Optional nested schema for hierarchical configuration
|
|
48
|
+
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
parser: Callable[[str | None], Maybe[Any]] | None
|
|
52
|
+
default: Any = None
|
|
53
|
+
required: bool = False
|
|
54
|
+
nested: EnvSchema | None = None
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class EnvSchema:
|
|
59
|
+
"""Represents a schema for environment variable configuration.
|
|
60
|
+
|
|
61
|
+
Args:
|
|
62
|
+
fields: Dictionary mapping field names to EnvField objects
|
|
63
|
+
|
|
64
|
+
"""
|
|
65
|
+
|
|
66
|
+
fields: dict[str, EnvField]
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _process_nested_field(
|
|
70
|
+
field_name: str,
|
|
71
|
+
field_spec: EnvField,
|
|
72
|
+
prefix: str,
|
|
73
|
+
delimiter: str,
|
|
74
|
+
environ: dict[str, str],
|
|
75
|
+
) -> tuple[dict[str, Any], list[str]]:
|
|
76
|
+
"""Process a nested schema field.
|
|
77
|
+
|
|
78
|
+
Args:
|
|
79
|
+
field_name: Name of the field
|
|
80
|
+
field_spec: Field specification with nested schema
|
|
81
|
+
prefix: Current prefix for environment variables
|
|
82
|
+
delimiter: Delimiter for nested configuration
|
|
83
|
+
environ: Environment variables dictionary
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
Tuple of (config dict, error list)
|
|
87
|
+
|
|
88
|
+
"""
|
|
89
|
+
config: dict[str, Any] = {}
|
|
90
|
+
errors: list[str] = []
|
|
91
|
+
|
|
92
|
+
if field_spec.nested is not None:
|
|
93
|
+
nested_prefix = f'{prefix}{field_name.upper()}{delimiter}'
|
|
94
|
+
nested_result = load_env_config(field_spec.nested, prefix=nested_prefix, delimiter=delimiter, environ=environ)
|
|
95
|
+
|
|
96
|
+
match nested_result:
|
|
97
|
+
case Success(value):
|
|
98
|
+
config[field_name] = value
|
|
99
|
+
case Failure(error):
|
|
100
|
+
errors.append(f'{field_name}: {error}')
|
|
101
|
+
|
|
102
|
+
return config, errors
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _process_missing_field(field_name: str, field_spec: EnvField) -> tuple[dict[str, Any], list[str], bool]:
|
|
106
|
+
"""Handle missing environment variable field.
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
field_name: Name of the field
|
|
110
|
+
field_spec: Field specification
|
|
111
|
+
|
|
112
|
+
Returns:
|
|
113
|
+
Tuple of (config dict, error list, should_continue)
|
|
114
|
+
|
|
115
|
+
"""
|
|
116
|
+
config: dict[str, Any] = {}
|
|
117
|
+
errors: list[str] = []
|
|
118
|
+
|
|
119
|
+
if field_spec.required:
|
|
120
|
+
errors.append(f'{field_name}: required field is missing')
|
|
121
|
+
return config, errors, True
|
|
122
|
+
|
|
123
|
+
if field_spec.default is not None:
|
|
124
|
+
config[field_name] = field_spec.default
|
|
125
|
+
return config, errors, True
|
|
126
|
+
|
|
127
|
+
# Optional field without default - skip it
|
|
128
|
+
return config, errors, True
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _parse_field_value(field_name: str, field_spec: EnvField, env_value: str) -> tuple[dict[str, Any], list[str]]:
|
|
132
|
+
"""Parse a field value from an environment variable.
|
|
133
|
+
|
|
134
|
+
Args:
|
|
135
|
+
field_name: Name of the field
|
|
136
|
+
field_spec: Field specification with parser
|
|
137
|
+
env_value: Raw environment variable value
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Tuple of (config dict, error list)
|
|
141
|
+
|
|
142
|
+
"""
|
|
143
|
+
config: dict[str, Any] = {}
|
|
144
|
+
errors: list[str] = []
|
|
145
|
+
|
|
146
|
+
if field_spec.parser is not None:
|
|
147
|
+
parse_result = field_spec.parser(env_value)
|
|
148
|
+
|
|
149
|
+
match parse_result:
|
|
150
|
+
case Success(value):
|
|
151
|
+
config[field_name] = value
|
|
152
|
+
case Failure(error):
|
|
153
|
+
errors.append(f'{field_name}: {error}')
|
|
154
|
+
|
|
155
|
+
return config, errors
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def load_env_config(
|
|
159
|
+
schema: EnvSchema,
|
|
160
|
+
*,
|
|
161
|
+
prefix: str = '',
|
|
162
|
+
delimiter: str = '_',
|
|
163
|
+
environ: dict[str, str] | None = None,
|
|
164
|
+
) -> Maybe[dict[str, Any]]:
|
|
165
|
+
"""Load and validate configuration from environment variables.
|
|
166
|
+
|
|
167
|
+
Args:
|
|
168
|
+
schema: The EnvSchema defining expected fields and their parsers
|
|
169
|
+
prefix: Optional prefix for environment variable names (e.g., 'APP_')
|
|
170
|
+
delimiter: Delimiter for nested configuration (default: '_')
|
|
171
|
+
environ: Optional dict of environment variables (defaults to os.environ)
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
Maybe[dict]: Success with parsed config dict, or Failure with error message
|
|
175
|
+
|
|
176
|
+
Example:
|
|
177
|
+
>>> from valid8r.integrations.env import load_env_config, EnvSchema, EnvField
|
|
178
|
+
>>> from valid8r.core.parsers import parse_int
|
|
179
|
+
>>> schema = EnvSchema(fields={'port': EnvField(parser=parse_int, default=8080)})
|
|
180
|
+
>>> env = {'APP_PORT': '3000'}
|
|
181
|
+
>>> result = load_env_config(schema, prefix='APP_', environ=env)
|
|
182
|
+
>>> result.value_or({})
|
|
183
|
+
{'port': 3000}
|
|
184
|
+
|
|
185
|
+
"""
|
|
186
|
+
if environ is None:
|
|
187
|
+
environ = dict(os.environ)
|
|
188
|
+
|
|
189
|
+
config: dict[str, Any] = {}
|
|
190
|
+
errors: list[str] = []
|
|
191
|
+
|
|
192
|
+
for field_name, field_spec in schema.fields.items():
|
|
193
|
+
# Handle nested schemas
|
|
194
|
+
if field_spec.nested is not None:
|
|
195
|
+
nested_config, nested_errors = _process_nested_field(field_name, field_spec, prefix, delimiter, environ)
|
|
196
|
+
config.update(nested_config)
|
|
197
|
+
errors.extend(nested_errors)
|
|
198
|
+
continue
|
|
199
|
+
|
|
200
|
+
# Construct environment variable name
|
|
201
|
+
env_var_name = f'{prefix}{field_name.upper()}'
|
|
202
|
+
env_value = environ.get(env_var_name)
|
|
203
|
+
|
|
204
|
+
# Handle missing fields
|
|
205
|
+
if env_value is None:
|
|
206
|
+
field_config, field_errors, _ = _process_missing_field(field_name, field_spec)
|
|
207
|
+
config.update(field_config)
|
|
208
|
+
errors.extend(field_errors)
|
|
209
|
+
continue
|
|
210
|
+
|
|
211
|
+
# Parse the environment variable value
|
|
212
|
+
field_config, field_errors = _parse_field_value(field_name, field_spec, env_value)
|
|
213
|
+
config.update(field_config)
|
|
214
|
+
errors.extend(field_errors)
|
|
215
|
+
|
|
216
|
+
# Return accumulated errors or success
|
|
217
|
+
if errors:
|
|
218
|
+
return Maybe.failure('; '.join(errors))
|
|
219
|
+
|
|
220
|
+
return Maybe.success(config)
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
"""Pydantic integration for valid8r parsers.
|
|
2
|
+
|
|
3
|
+
This module provides utilities to convert valid8r parsers (which return Maybe[T])
|
|
4
|
+
into Pydantic field validators, enabling seamless integration with FastAPI and
|
|
5
|
+
other Pydantic-based frameworks.
|
|
6
|
+
|
|
7
|
+
The integration supports:
|
|
8
|
+
- Simple field validation with type parsing and validation
|
|
9
|
+
- Nested model validation with field path error reporting
|
|
10
|
+
- List[Model] validation with per-item error reporting
|
|
11
|
+
- Dict[K, V] validation with per-value validation
|
|
12
|
+
- Optional fields and complex structures
|
|
13
|
+
|
|
14
|
+
Example - Simple Field Validation:
|
|
15
|
+
>>> from pydantic import BaseModel, field_validator
|
|
16
|
+
>>> from valid8r.core import parsers, validators
|
|
17
|
+
>>> from valid8r.integrations.pydantic import validator_from_parser
|
|
18
|
+
>>>
|
|
19
|
+
>>> class User(BaseModel):
|
|
20
|
+
... age: int
|
|
21
|
+
...
|
|
22
|
+
... @field_validator('age', mode='before')
|
|
23
|
+
... @classmethod
|
|
24
|
+
... def validate_age(cls, v):
|
|
25
|
+
... return validator_from_parser(
|
|
26
|
+
... parsers.parse_int & validators.between(0, 120)
|
|
27
|
+
... )(v)
|
|
28
|
+
|
|
29
|
+
Example - Nested Model Validation:
|
|
30
|
+
>>> from valid8r.core.parsers import PhoneNumber
|
|
31
|
+
>>>
|
|
32
|
+
>>> class Address(BaseModel):
|
|
33
|
+
... phone: PhoneNumber
|
|
34
|
+
...
|
|
35
|
+
... @field_validator('phone', mode='before')
|
|
36
|
+
... @classmethod
|
|
37
|
+
... def validate_phone(cls, v):
|
|
38
|
+
... return validator_from_parser(parsers.parse_phone)(v)
|
|
39
|
+
>>>
|
|
40
|
+
>>> class User(BaseModel):
|
|
41
|
+
... name: str
|
|
42
|
+
... address: Address
|
|
43
|
+
>>>
|
|
44
|
+
>>> # Validation errors include full field path (e.g., 'address.phone')
|
|
45
|
+
>>> user = User(name='Alice', address={'phone': '(206) 234-5678'})
|
|
46
|
+
|
|
47
|
+
Example - List of Models:
|
|
48
|
+
>>> class LineItem(BaseModel):
|
|
49
|
+
... quantity: int
|
|
50
|
+
...
|
|
51
|
+
... @field_validator('quantity', mode='before')
|
|
52
|
+
... @classmethod
|
|
53
|
+
... def validate_quantity(cls, v):
|
|
54
|
+
... def parser(value):
|
|
55
|
+
... return parsers.parse_int(value).bind(validators.minimum(1))
|
|
56
|
+
... return validator_from_parser(parser)(v)
|
|
57
|
+
>>>
|
|
58
|
+
>>> class Order(BaseModel):
|
|
59
|
+
... items: list[LineItem]
|
|
60
|
+
>>>
|
|
61
|
+
>>> # Validation errors include list index (e.g., 'items[1].quantity')
|
|
62
|
+
>>> order = Order(items=[{'quantity': '5'}, {'quantity': '10'}])
|
|
63
|
+
|
|
64
|
+
Example - Dict Value Validation:
|
|
65
|
+
>>> class Config(BaseModel):
|
|
66
|
+
... ports: dict[str, int]
|
|
67
|
+
...
|
|
68
|
+
... @field_validator('ports', mode='before')
|
|
69
|
+
... @classmethod
|
|
70
|
+
... def validate_ports(cls, v):
|
|
71
|
+
... if not isinstance(v, dict):
|
|
72
|
+
... raise ValueError('ports must be a dict')
|
|
73
|
+
... return {k: validator_from_parser(parsers.parse_int)(val) for k, val in v.items()}
|
|
74
|
+
>>>
|
|
75
|
+
>>> config = Config(ports={'http': '80', 'https': '443'})
|
|
76
|
+
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
from __future__ import annotations
|
|
80
|
+
|
|
81
|
+
from typing import (
|
|
82
|
+
TYPE_CHECKING,
|
|
83
|
+
Any,
|
|
84
|
+
TypeVar,
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
if TYPE_CHECKING:
|
|
88
|
+
from collections.abc import Callable
|
|
89
|
+
|
|
90
|
+
from valid8r.core.maybe import Maybe
|
|
91
|
+
|
|
92
|
+
T = TypeVar('T')
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def validator_from_parser(
|
|
96
|
+
parser: Callable[[Any], Maybe[T]],
|
|
97
|
+
*,
|
|
98
|
+
error_prefix: str | None = None,
|
|
99
|
+
) -> Callable[[Any], T]:
|
|
100
|
+
"""Convert a valid8r parser into a Pydantic field validator.
|
|
101
|
+
|
|
102
|
+
This function takes a valid8r parser (any callable that returns Maybe[T])
|
|
103
|
+
and converts it into a function suitable for use with Pydantic's
|
|
104
|
+
field_validator decorator.
|
|
105
|
+
|
|
106
|
+
Works seamlessly with:
|
|
107
|
+
- Simple fields (str, int, custom types)
|
|
108
|
+
- Nested models (User -> Address -> phone)
|
|
109
|
+
- Lists of models (Order with list[LineItem])
|
|
110
|
+
- Dicts with validated values (Config with dict[str, int])
|
|
111
|
+
- Optional fields (field: Model | None)
|
|
112
|
+
|
|
113
|
+
Pydantic automatically handles field path reporting for nested structures,
|
|
114
|
+
so validation errors will include the full path (e.g., 'address.phone' or
|
|
115
|
+
'items[1].quantity').
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
parser: A valid8r parser function that returns Maybe[T].
|
|
119
|
+
error_prefix: Optional prefix to prepend to error messages.
|
|
120
|
+
|
|
121
|
+
Returns:
|
|
122
|
+
A validator function that returns T on success or raises ValueError
|
|
123
|
+
on failure.
|
|
124
|
+
|
|
125
|
+
Raises:
|
|
126
|
+
ValueError: When the parser returns a Failure with the error message.
|
|
127
|
+
|
|
128
|
+
Example:
|
|
129
|
+
>>> from valid8r.core import parsers
|
|
130
|
+
>>> validator = validator_from_parser(parsers.parse_int)
|
|
131
|
+
>>> validator('42')
|
|
132
|
+
42
|
|
133
|
+
>>> validator('invalid') # doctest: +SKIP
|
|
134
|
+
Traceback (most recent call last):
|
|
135
|
+
...
|
|
136
|
+
ValueError: ...
|
|
137
|
+
|
|
138
|
+
>>> # With custom error prefix
|
|
139
|
+
>>> validator = validator_from_parser(parsers.parse_int, error_prefix='User ID')
|
|
140
|
+
>>> validator('invalid') # doctest: +SKIP
|
|
141
|
+
Traceback (most recent call last):
|
|
142
|
+
...
|
|
143
|
+
ValueError: User ID: ...
|
|
144
|
+
|
|
145
|
+
>>> # Nested model validation
|
|
146
|
+
>>> from pydantic import BaseModel, field_validator
|
|
147
|
+
>>> from valid8r.core.parsers import EmailAddress
|
|
148
|
+
>>>
|
|
149
|
+
>>> class Contact(BaseModel):
|
|
150
|
+
... email: EmailAddress
|
|
151
|
+
...
|
|
152
|
+
... @field_validator('email', mode='before')
|
|
153
|
+
... @classmethod
|
|
154
|
+
... def validate_email(cls, v):
|
|
155
|
+
... return validator_from_parser(parsers.parse_email)(v)
|
|
156
|
+
>>>
|
|
157
|
+
>>> contact = Contact(email='user@example.com') # doctest: +SKIP
|
|
158
|
+
|
|
159
|
+
"""
|
|
160
|
+
from valid8r.core.maybe import ( # noqa: PLC0415
|
|
161
|
+
Failure,
|
|
162
|
+
Success,
|
|
163
|
+
)
|
|
164
|
+
|
|
165
|
+
def validate(value: Any) -> T: # noqa: ANN401
|
|
166
|
+
"""Validate the value using the parser.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
value: The value to validate.
|
|
170
|
+
|
|
171
|
+
Returns:
|
|
172
|
+
The parsed value if successful.
|
|
173
|
+
|
|
174
|
+
Raises:
|
|
175
|
+
ValueError: If parsing fails.
|
|
176
|
+
|
|
177
|
+
"""
|
|
178
|
+
result = parser(value)
|
|
179
|
+
|
|
180
|
+
match result:
|
|
181
|
+
case Success(parsed_value):
|
|
182
|
+
return parsed_value # type: ignore[no-any-return]
|
|
183
|
+
case Failure(error_msg):
|
|
184
|
+
if error_prefix:
|
|
185
|
+
msg = f'{error_prefix}: {error_msg}'
|
|
186
|
+
raise ValueError(msg)
|
|
187
|
+
raise ValueError(error_msg)
|
|
188
|
+
case _: # pragma: no cover
|
|
189
|
+
# This should never happen as Maybe only has Success and Failure
|
|
190
|
+
msg = f'Unexpected Maybe type: {type(result)}'
|
|
191
|
+
raise TypeError(msg)
|
|
192
|
+
|
|
193
|
+
return validate
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
__all__ = ['validator_from_parser']
|