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,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']
@@ -0,0 +1,8 @@
1
+ # valid8r/prompt/__init__.py
2
+ """Input prompting functionality for command-line applications."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from .basic import ask
7
+
8
+ __all__ = ['ask']