diff --git a/README.md b/README.md index 3c571e8..b3b35f2 100644 --- a/README.md +++ b/README.md @@ -688,6 +688,79 @@ cross-field validator will not run: schema({'password': '123', 'password_again': 1337}) ``` +## JSON Schema Export + +Voluptuous schemas can be exported to [JSON Schema](https://json-schema.org/) format, enabling integration with modern IDEs, API documentation tools, and other validation systems. + +### Basic Usage + +Use the `to_json_schema()` method on any Schema instance: + +```pycon +>>> from voluptuous import Schema, Required, Optional, Range, Email +>>> schema = Schema({ +... Required('name'): str, +... Required('email'): Email(), +... Optional('age'): Range(min=0, max=150) +... }) +>>> json_schema = schema.to_json_schema() +>>> json_schema['type'] +'object' +>>> json_schema['required'] +['name', 'email'] +>>> json_schema['properties']['email']['format'] +'email' +``` + +Alternatively, use the standalone `to_json_schema()` function: + +```pycon +>>> from voluptuous import to_json_schema +>>> json_schema = to_json_schema(Schema(str)) +>>> json_schema['type'] +'string' +``` + +### Supported Validators + +Most voluptuous validators are converted to equivalent JSON Schema constraints: + +- **Range**: Converted to `minimum`/`maximum` constraints +- **Length**: Converted to `minLength`/`maxLength` for strings, `minItems`/`maxItems` for arrays +- **Email**: Converted to `format: "email"` +- **Url**: Converted to `format: "uri"` +- **Date**: Converted to `format: "date"` +- **Datetime**: Converted to `format: "date-time"` +- **Match**: Converted to `pattern` constraint +- **In**: Converted to `enum` constraint +- **All**: Converted to `allOf` constraint +- **Any**: Converted to `anyOf` constraint + +### Use Cases + +The exported JSON Schema can be used with: + +- **IDEs**: For YAML/JSON file validation and autocompletion +- **API Documentation**: Tools like OpenAPI/Swagger +- **Code Generation**: Generate types for other languages +- **Validation Libraries**: Use with JSON Schema validators in any language + +### Example + +```pycon +>>> from voluptuous import Schema, Required, All, Length, Range, In +>>> api_schema = Schema({ +... Required('endpoint'): All(str, Length(min=1)), +... Required('method'): In(['GET', 'POST', 'PUT', 'DELETE']), +... Optional('timeout'): Range(min=1, max=300), +... Optional('retries', default=3): Range(min=0, max=10) +... }) +>>> json_schema = api_schema.to_json_schema() +>>> # Use json_schema with your favorite JSON Schema validator +``` + +For more examples, see `examples/json_schema_export.py`. + ## Running tests Voluptuous is using `pytest`: diff --git a/examples/json_schema_export.py b/examples/json_schema_export.py new file mode 100644 index 0000000..cd1f813 --- /dev/null +++ b/examples/json_schema_export.py @@ -0,0 +1,261 @@ +#!/usr/bin/env python3 +""" +JSON Schema Export Examples for Voluptuous + +This script demonstrates how to use the new JSON Schema export functionality +in voluptuous to convert voluptuous schemas to JSON Schema format. + +The JSON Schema output can be used with: +- IDEs for YAML/JSON validation +- API documentation tools +- Schema validation libraries +- Code generation tools +""" + +import json + +from voluptuous import ( + All, + Any, + Clamp, + Coerce, + Date, + Datetime, + Email, + ExactSequence, + In, + Length, + Match, + Optional, + Range, + Required, + Schema, + Url, + to_json_schema, +) + + +def example_basic_types(): + """Demonstrate basic type conversion.""" + print("=== Basic Types ===") + + # Simple types + schemas = { + "String": Schema(str), + "Integer": Schema(int), + "Float": Schema(float), + "Boolean": Schema(bool), + "Literal": Schema("hello"), + "None": Schema(None), + } + + for name, schema in schemas.items(): + json_schema = schema.to_json_schema() + print(f"{name}: {json.dumps(json_schema, indent=2)}") + print() + + +def example_object_schemas(): + """Demonstrate object schema conversion.""" + print("=== Object Schemas ===") + + # User profile schema + user_schema = Schema( + { + Required('username'): All(str, Length(min=3, max=20)), + Required('email'): Email(), + Optional('age'): Range(min=13, max=120), + Optional('bio', default=""): All(str, Length(max=500)), + Optional('preferences'): { + 'theme': In(['light', 'dark', 'auto']), + 'notifications': bool, + 'language': str, + }, + } + ) + + json_schema = user_schema.to_json_schema() + print("User Profile Schema:") + print(json.dumps(json_schema, indent=2)) + print() + + +def example_array_schemas(): + """Demonstrate array schema conversion.""" + print("=== Array Schemas ===") + + # Simple array + simple_array = Schema([str]) + print("Simple String Array:") + print(json.dumps(simple_array.to_json_schema(), indent=2)) + print() + + # Mixed array with exact sequence + exact_sequence = Schema(ExactSequence([str, int, bool])) + print("Exact Sequence [str, int, bool]:") + print(json.dumps(exact_sequence.to_json_schema(), indent=2)) + print() + + # Set (unique items) + unique_strings = Schema({str}) + print("Set of Strings (unique items):") + print(json.dumps(unique_strings.to_json_schema(), indent=2)) + print() + + +def example_validators(): + """Demonstrate validator conversion.""" + print("=== Validators ===") + + validators = { + "Range": Schema(Range(min=1, max=100)), + "Length": Schema(All(str, Length(min=2, max=50))), + "Email": Schema(Email()), + "URL": Schema(Url()), + "Date": Schema(Date()), + "DateTime": Schema(Datetime()), + "Pattern": Schema(Match(r'^[A-Z][a-z]+$')), + "Enum": Schema(In(['red', 'green', 'blue'])), + "Coerce": Schema(Coerce(int)), + } + + for name, schema in validators.items(): + json_schema = schema.to_json_schema() + print(f"{name}:") + print(json.dumps(json_schema, indent=2)) + print() + + +def example_composite_validators(): + """Demonstrate composite validator conversion.""" + print("=== Composite Validators ===") + + # All validator (must pass all conditions) + all_validator = Schema(All(str, Length(min=1), Match(r'^[a-zA-Z]+$'))) + print("All(str, Length(min=1), Match('^[a-zA-Z]+$')):") + print(json.dumps(all_validator.to_json_schema(), indent=2)) + print() + + # Any validator (must pass at least one condition) + any_validator = Schema(Any(str, int, bool)) + print("Any(str, int, bool):") + print(json.dumps(any_validator.to_json_schema(), indent=2)) + print() + + +def example_complex_schema(): + """Demonstrate a complex, real-world schema.""" + print("=== Complex Real-World Schema ===") + + # API configuration schema + api_config_schema = Schema( + { + Required('api'): { + Required('name'): All(str, Length(min=1, max=100)), + Required('version'): Match(r'^\d+\.\d+\.\d+$'), + Required('endpoints'): [ + { + Required('path'): All(str, Match(r'^/[a-zA-Z0-9/_-]*$')), + Required('method'): In( + ['GET', 'POST', 'PUT', 'DELETE', 'PATCH'] + ), + Optional('auth_required', default=True): bool, + Optional('rate_limit'): Range(min=1, max=10000), + Optional('description'): All(str, Length(max=500)), + } + ], + Optional('database'): { + Required('host'): str, + Required('port'): Range(min=1, max=65535), + Required('name'): All(str, Length(min=1, max=64)), + Optional('ssl', default=True): bool, + Optional('timeout', default=30): Range(min=1, max=300), + }, + }, + Optional('logging'): { + 'level': In(['DEBUG', 'INFO', 'WARNING', 'ERROR']), + 'format': str, + 'file': str, + }, + Optional('features'): {str: bool}, # Feature flags + } + ) + + json_schema = api_config_schema.to_json_schema() + print("API Configuration Schema:") + print(json.dumps(json_schema, indent=2)) + print() + + +def example_usage_with_data(): + """Show how the exported schema can validate actual data.""" + print("=== Usage Example ===") + + # Define a schema + person_schema = Schema( + { + Required('name'): All(str, Length(min=1, max=100)), + Required('email'): Email(), + Optional('age'): Range(min=0, max=150), + Optional('tags'): [str], + } + ) + + # Export to JSON Schema + json_schema = person_schema.to_json_schema() + + # Sample data that would be valid + sample_data = { + "name": "John Doe", + "email": "john@example.com", + "age": 30, + "tags": ["developer", "python"], + } + + print("Voluptuous Schema:") + print(f"Schema: {person_schema}") + print() + + print("Exported JSON Schema:") + print(json.dumps(json_schema, indent=2)) + print() + + print("Sample Valid Data:") + print(json.dumps(sample_data, indent=2)) + print() + + # Validate with voluptuous + try: + validated = person_schema(sample_data) + print("✓ Data is valid according to voluptuous schema") + print(f"Validated data: {validated}") + except Exception as e: + print(f"✗ Validation failed: {e}") + + +def main(): + """Run all examples.""" + print("Voluptuous JSON Schema Export Examples") + print("=" * 50) + print() + + example_basic_types() + example_object_schemas() + example_array_schemas() + example_validators() + example_composite_validators() + example_complex_schema() + example_usage_with_data() + + print("=" * 50) + print("Examples completed!") + print() + print("You can now use these JSON Schemas with:") + print("- JSON Schema validators") + print("- IDE validation for YAML/JSON files") + print("- API documentation tools") + print("- Code generation tools") + + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml index 2fd3294..386addf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,6 @@ python_version = "3.9" warn_unused_ignores = true [tool.pytest.ini_options] -python_files = "tests.py" +python_files = "tests.py test_*.py" testpaths = "voluptuous/tests" addopts = "--doctest-glob=*.md -v" diff --git a/voluptuous/__init__.py b/voluptuous/__init__.py index d030b35..23a30e9 100644 --- a/voluptuous/__init__.py +++ b/voluptuous/__init__.py @@ -74,6 +74,8 @@ True """ +from voluptuous.json_schema import to_json_schema + # flake8: noqa # fmt: off from voluptuous.schema_builder import * diff --git a/voluptuous/json_schema.py b/voluptuous/json_schema.py new file mode 100644 index 0000000..5f61d6e --- /dev/null +++ b/voluptuous/json_schema.py @@ -0,0 +1,439 @@ +"""JSON Schema export functionality for voluptuous schemas. + +This module provides functionality to convert voluptuous schemas to JSON Schema format, +enabling integration with modern IDEs and validation tools that support JSON Schema. +""" + +from collections.abc import Mapping, Sequence +from typing import Any, Callable, Dict, Union + +from voluptuous.schema_builder import ( + Extra, + Marker, +) +from voluptuous.schema_builder import Optional as OptionalMarker +from voluptuous.schema_builder import ( + Remove, +) +from voluptuous.schema_builder import Required as RequiredMarker +from voluptuous.schema_builder import ( + Schema, + primitive_types, +) + + +class JsonSchemaConverter: + """Converts voluptuous schemas to JSON Schema format. + + This converter traverses voluptuous schema structures and generates + equivalent JSON Schema representations that preserve validation semantics + where possible. + """ + + def __init__(self, schema: Schema): + """Initialize converter with a voluptuous schema. + + Args: + schema: The voluptuous Schema instance to convert + """ + self.schema = schema + self.definitions: Dict[str, Any] = {} + self._ref_counter = 0 + + def convert(self) -> Dict[str, Any]: + """Convert the voluptuous schema to JSON Schema format. + + Returns: + A dictionary representing the JSON Schema + """ + json_schema: Dict[str, Any] = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "object", + } + + # Convert the main schema + converted = self._convert_schema_element(self.schema.schema) + + # Merge the converted schema + if isinstance(converted, dict): + json_schema.update(converted) + else: + # If it's not a dict, wrap it appropriately + wrapped_schema = self._wrap_non_object_schema(converted) + json_schema = { + "$schema": "https://json-schema.org/draft/2020-12/schema", + **wrapped_schema, + } + + # Add definitions if any were created + if self.definitions: + json_schema["$defs"] = self.definitions + + return json_schema + + def _convert_schema_element(self, element: Any) -> Any: + """Convert a single schema element to JSON Schema format. + + Args: + element: The schema element to convert + + Returns: + JSON Schema representation of the element + """ + # Handle None + if element is None: + return {"type": "null"} + + # Handle Extra marker + if element is Extra: + return True # Allow additional properties + + # Handle Marker classes (Required, Optional, Remove) + if isinstance(element, Marker): + return self._convert_marker(element) + + # Handle primitive types + if element in primitive_types: + return self._convert_primitive_type(element) + + # Handle Schema instances + if isinstance(element, Schema): + return self._convert_schema_element(element.schema) + + # Handle mappings (dictionaries) + if isinstance(element, Mapping): + return self._convert_mapping(element) + + # Handle sequences (lists, tuples) + if isinstance(element, (list, tuple)): + return self._convert_sequence(element) + + # Handle sets + if isinstance(element, (set, frozenset)): + return self._convert_set(element) + + # Handle validator classes + if hasattr(element, '__class__') and hasattr(element.__class__, '__name__'): + converter_method = getattr( + self, f'_convert_{element.__class__.__name__.lower()}', None + ) + if converter_method: + return converter_method(element) + + # Handle callable validators (including decorated functions like Email, Url) + if callable(element): + # Check if it's a known validator function by name + func_name = getattr(element, '__name__', '').lower() + if func_name: + converter_method = getattr(self, f'_convert_{func_name}', None) + if converter_method: + return converter_method(element) + + return self._convert_callable(element) + + # Handle literal values + return self._convert_literal(element) + + def _convert_primitive_type(self, type_class: type) -> Dict[str, str]: + """Convert Python primitive types to JSON Schema types.""" + type_mapping = { + bool: "boolean", + int: "integer", + float: "number", + str: "string", + bytes: "string", # JSON Schema doesn't have bytes, use string + complex: "string", # Complex numbers as strings + } + return {"type": type_mapping.get(type_class, "string")} + + def _convert_mapping(self, mapping: Mapping) -> Dict[str, Any]: + """Convert a mapping (dictionary) schema to a JSON Schema object.""" + json_schema: Dict[str, Any] = { + "type": "object", + "properties": {}, + "additionalProperties": False, + } + + required_keys = [] + + for key, value in mapping.items(): + if key is Extra: + json_schema["additionalProperties"] = True + continue + + # Handle marker keys + if isinstance(key, RequiredMarker): + prop_name = str(key.schema) + required_keys.append(prop_name) + json_schema["properties"][prop_name] = self._convert_schema_element( + value + ) + elif isinstance(key, OptionalMarker): + prop_name = str(key.schema) + json_schema["properties"][prop_name] = self._convert_schema_element( + value + ) + # Add default if specified + if hasattr(key, 'default') and key.default is not None: + # Handle default_factory functions + if callable(key.default): + try: + default_value = key.default() + # Only add if the default value is JSON serializable + if self._is_json_serializable(default_value): + json_schema["properties"][prop_name][ + "default" + ] = default_value + except (TypeError, ValueError, AttributeError): + # If the default factory fails, skip adding default + pass + else: + # Only add if the default value is JSON serializable + if self._is_json_serializable(key.default): + json_schema["properties"][prop_name][ + "default" + ] = key.default + elif isinstance(key, Remove): + # Remove markers are ignored in JSON Schema + continue + else: + # Regular key + prop_name = str(key) + json_schema["properties"][prop_name] = self._convert_schema_element( + value + ) + # In voluptuous, regular keys are required by default if schema.required is True + if getattr(self.schema, 'required', False): + required_keys.append(prop_name) + + if required_keys: + json_schema["required"] = required_keys + + return json_schema + + def _convert_sequence(self, sequence: Sequence) -> Dict[str, Any]: + """Convert a sequence (list/tuple) schema to JSON Schema array.""" + if not sequence: + return {"type": "array", "maxItems": 0} + + # Convert all items to schemas first + items_schemas = [self._convert_schema_element(item) for item in sequence] + + # Check if all converted schemas are identical + if len(items_schemas) == 1 or all( + schema == items_schemas[0] for schema in items_schemas + ): + # All items have the same schema - use single items schema + return {"type": "array", "items": items_schemas[0]} + + # Multiple different schemas - use prefixItems for ordered validation + return { + "type": "array", + "prefixItems": items_schemas, + "items": False, # No additional items allowed + } + + def _convert_set(self, set_schema: Union[set, frozenset]) -> Dict[str, Any]: + """Convert a set schema to a JSON Schema array with unique items.""" + if not set_schema: + return {"type": "array", "uniqueItems": True, "maxItems": 0} + + # Convert the single item type in the set + item_schema = self._convert_schema_element(next(iter(set_schema))) + return {"type": "array", "items": item_schema, "uniqueItems": True} + + def _convert_marker(self, marker: Marker) -> Any: + """Convert a Marker instance to its underlying schema.""" + return self._convert_schema_element(marker.schema) + + def _convert_callable(self, func: Callable[..., Any]) -> Dict[str, Any]: + """Convert a callable validator to JSON Schema.""" + # For generic callables, we can determine little about the expected type + # This is a limitation of the conversion process + return { + "description": f"Custom validator: {getattr(func, '__name__', 'anonymous')}" + } + + def _convert_literal(self, value: Any) -> Dict[str, Any]: + """Convert a literal value to JSON Schema const.""" + return {"const": value} + + def _wrap_non_object_schema(self, schema: Any) -> Dict[str, Any]: + """Wrap non-object schemas appropriately.""" + if isinstance(schema, dict) and "type" in schema: + return schema + return {"type": "string", "description": "Complex schema"} + + def _is_json_serializable(self, value: Any) -> bool: + """Check if a value is JSON serializable.""" + try: + import json + + json.dumps(value) + return True + except (TypeError, ValueError): + return False + + # Validator-specific conversion methods + + def _convert_range(self, range_validator: Any) -> Dict[str, Any]: + """Convert Range validator to JSON Schema numeric constraints.""" + schema: Dict[str, Any] = {"type": "number"} + + if hasattr(range_validator, 'min') and range_validator.min is not None: + if getattr(range_validator, 'min_included', True): + schema["minimum"] = range_validator.min + else: + schema["exclusiveMinimum"] = range_validator.min + + if hasattr(range_validator, 'max') and range_validator.max is not None: + if getattr(range_validator, 'max_included', True): + schema["maximum"] = range_validator.max + else: + schema["exclusiveMaximum"] = range_validator.max + + return schema + + def _convert_length(self, length_validator: Any) -> Dict[str, Any]: + """Convert Length validator to JSON Schema string/array length constraints.""" + schema: Dict[str, Any] = {} + + if hasattr(length_validator, 'min') and length_validator.min is not None: + schema["minLength"] = length_validator.min + + if hasattr(length_validator, 'max') and length_validator.max is not None: + schema["maxLength"] = length_validator.max + + return schema + + def _convert_all(self, all_validator: Any) -> Dict[str, Any]: + """Convert All validator to JSON Schema allOf constraint.""" + if not hasattr(all_validator, 'validators'): + return {} + + schemas = [] + for validator in all_validator.validators: + converted = self._convert_schema_element(validator) + if converted: + schemas.append(converted) + + if len(schemas) == 1: + return schemas[0] + elif len(schemas) > 1: + return {"allOf": schemas} + else: + return {} + + def _convert_any(self, any_validator: Any) -> Dict[str, Any]: + """Convert Any validator to JSON Schema anyOf constraint.""" + if not hasattr(any_validator, 'validators'): + return {} + + schemas = [] + for validator in any_validator.validators: + converted = self._convert_schema_element(validator) + if converted: + schemas.append(converted) + + if len(schemas) == 1: + return schemas[0] + elif len(schemas) > 1: + return {"anyOf": schemas} + else: + return {} + + def _convert_in(self, in_validator: Any) -> Dict[str, Any]: + """Convert In validator to JSON Schema enum constraint.""" + if hasattr(in_validator, 'container'): + # Handle both Container and Iterable types + container = in_validator.container + if hasattr(container, '__iter__'): + return {"enum": list(container)} + return {} + + def _convert_match(self, match_validator: Any) -> Dict[str, Any]: + """Convert Match validator to JSON Schema pattern constraint.""" + if hasattr(match_validator, 'pattern'): + pattern = match_validator.pattern + if hasattr(pattern, 'pattern'): # compiled regex + pattern = pattern.pattern + return {"type": "string", "pattern": str(pattern)} + return {"type": "string"} + + def _convert_email(self, email_validator: Any) -> Dict[str, Any]: + """Convert Email validator to JSON Schema email format.""" + return {"type": "string", "format": "email"} + + def _convert_url(self, url_validator: Any) -> Dict[str, Any]: + """Convert Url validator to JSON Schema uri format.""" + return {"type": "string", "format": "uri"} + + def _convert_date(self, date_validator: Any) -> Dict[str, Any]: + """Convert Date validator to JSON Schema date format.""" + return {"type": "string", "format": "date"} + + def _convert_datetime(self, datetime_validator: Any) -> Dict[str, Any]: + """Convert Datetime validator to JSON Schema date-time format.""" + return {"type": "string", "format": "date-time"} + + def _convert_coerce(self, coerce_validator: Any) -> Dict[str, Any]: + """Convert Coerce validator based on a target type.""" + if hasattr(coerce_validator, 'type'): + coerce_type = coerce_validator.type + if isinstance(coerce_type, type): + return self._convert_primitive_type(coerce_type) + return {} + + def _convert_clamp(self, clamp_validator: Any) -> Dict[str, Any]: + """Convert Clamp validator to Range-like constraints.""" + schema: Dict[str, Any] = {"type": "number"} + + if hasattr(clamp_validator, 'min') and clamp_validator.min is not None: + schema["minimum"] = clamp_validator.min + + if hasattr(clamp_validator, 'max') and clamp_validator.max is not None: + schema["maximum"] = clamp_validator.max + + return schema + + def _convert_exactsequence(self, exact_validator: Any) -> Dict[str, Any]: + """Convert ExactSequence validator to JSON Schema with exact items.""" + if hasattr(exact_validator, 'validators'): + items_schemas = [ + self._convert_schema_element(validator) + for validator in exact_validator.validators + ] + return { + "type": "array", + "prefixItems": items_schemas, + "items": False, # No additional items + "minItems": len(items_schemas), + "maxItems": len(items_schemas), + } + return {"type": "array"} + + +def to_json_schema(schema: Union[Schema, Any]) -> Dict[str, Any]: + """Convert a voluptuous schema to JSON Schema format. + + This is a convenience function that creates a JsonSchemaConverter + and performs the conversion. + + Args: + schema: A voluptuous Schema instance or schema definition + + Returns: + A dictionary representing the JSON Schema + + Example: + >>> from voluptuous import Schema, Required, Range + >>> schema = Schema({Required('name'): str, 'age': Range(min=0, max=120)}) + >>> json_schema = to_json_schema(schema) + >>> print(json_schema['properties']['name']) + {'type': 'string'} + """ + if not isinstance(schema, Schema): + schema = Schema(schema) + + converter = JsonSchemaConverter(schema) + return converter.convert() diff --git a/voluptuous/schema_builder.py b/voluptuous/schema_builder.py index da20737..3dc49b5 100644 --- a/voluptuous/schema_builder.py +++ b/voluptuous/schema_builder.py @@ -209,6 +209,24 @@ def __call__(self, data): raise er.MultipleInvalid([e]) # return self.validate([], self.schema, data) + def to_json_schema(self) -> typing.Dict[str, typing.Any]: + """Convert this voluptuous schema to JSON Schema format. + + Returns: + A dictionary representing the JSON Schema equivalent of this schema. + + Example: + >>> from voluptuous import Schema, Required, Range + >>> schema = Schema({Required('name'): str, 'age': Range(min=0, max=120)}) + >>> json_schema = schema.to_json_schema() + >>> json_schema['type'] + 'object' + """ + from voluptuous.json_schema import JsonSchemaConverter + + converter = JsonSchemaConverter(self) + return converter.convert() + def _compile(self, schema): if schema is Extra: return lambda _, v: v diff --git a/voluptuous/tests/test_json_schema.py b/voluptuous/tests/test_json_schema.py new file mode 100644 index 0000000..ee32fc0 --- /dev/null +++ b/voluptuous/tests/test_json_schema.py @@ -0,0 +1,307 @@ +"""Tests for JSON Schema export functionality.""" + +from voluptuous import ( + All, + Any, + Clamp, + Coerce, + Date, + Datetime, + Email, + ExactSequence, + In, + Length, + Match, + Optional, + Range, + Required, + Schema, + Url, + to_json_schema, +) + + +class TestBasicTypes: + """Test conversion of basic Python types to JSON Schema.""" + + def test_primitive_types(self): + """Test conversion of primitive Python types.""" + assert to_json_schema(Schema(str)) == { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "string", + } + + assert to_json_schema(Schema(int)) == { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "integer", + } + + assert to_json_schema(Schema(float)) == { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "number", + } + + assert to_json_schema(Schema(bool)) == { + "$schema": "https://json-schema.org/draft/2020-12/schema", + "type": "boolean", + } + + def test_literal_values(self): + """Test conversion of literal values.""" + schema = to_json_schema(Schema("hello")) + assert schema["const"] == "hello" + + schema = to_json_schema(Schema(42)) + assert schema["const"] == 42 + + def test_none_type(self): + """Test conversion of None type.""" + schema = to_json_schema(Schema(None)) + assert schema["type"] == "null" + + +class TestObjectSchemas: + """Test conversion of dictionary/object schemas.""" + + def test_simple_object(self): + """Test simple object schema conversion.""" + voluptuous_schema = Schema({'name': str, 'age': int}) + + json_schema = voluptuous_schema.to_json_schema() + + assert json_schema["type"] == "object" + assert "properties" in json_schema + assert json_schema["properties"]["name"]["type"] == "string" + assert json_schema["properties"]["age"]["type"] == "integer" + assert json_schema["additionalProperties"] is False + + def test_required_optional_keys(self): + """Test Required and Optional markers.""" + voluptuous_schema = Schema( + { + Required('name'): str, + Optional('age'): int, + Optional('email', default='none@example.com'): str, + } + ) + + json_schema = voluptuous_schema.to_json_schema() + + assert json_schema["required"] == ["name"] + assert "name" in json_schema["properties"] + assert "age" in json_schema["properties"] + assert "email" in json_schema["properties"] + assert json_schema["properties"]["email"]["default"] == "none@example.com" + + def test_nested_objects(self): + """Test nested object schemas.""" + voluptuous_schema = Schema( + { + 'user': { + Required('name'): str, + Optional('profile'): {'bio': str, 'age': int}, + } + } + ) + + json_schema = voluptuous_schema.to_json_schema() + + user_schema = json_schema["properties"]["user"] + assert user_schema["type"] == "object" + assert user_schema["required"] == ["name"] + + profile_schema = user_schema["properties"]["profile"] + assert profile_schema["type"] == "object" + assert "bio" in profile_schema["properties"] + assert "age" in profile_schema["properties"] + + +class TestArraySchemas: + """Test conversion of array/list schemas.""" + + def test_simple_array(self): + """Test simple array schema.""" + schema = to_json_schema(Schema([str])) + assert schema["type"] == "array" + assert schema["items"]["type"] == "string" + + def test_mixed_array(self): + """Test array with multiple types.""" + schema = to_json_schema(Schema([str, int, bool])) + assert schema["type"] == "array" + assert "prefixItems" in schema + assert len(schema["prefixItems"]) == 3 + assert schema["prefixItems"][0]["type"] == "string" + assert schema["prefixItems"][1]["type"] == "integer" + assert schema["prefixItems"][2]["type"] == "boolean" + assert schema["items"] is False + + def test_set_schema(self): + """Test set schema conversion.""" + schema = to_json_schema(Schema({str})) + assert schema["type"] == "array" + assert schema["items"]["type"] == "string" + assert schema["uniqueItems"] is True + + +class TestValidators: + """Test conversion of voluptuous validators.""" + + def test_range_validator(self): + """Test Range validator conversion.""" + schema = to_json_schema(Schema(Range(min=1, max=100))) + assert schema["type"] == "number" + assert schema["minimum"] == 1 + assert schema["maximum"] == 100 + + def test_length_validator(self): + """Test Length validator conversion.""" + schema = to_json_schema(Schema(Length(min=2, max=50))) + assert schema["minLength"] == 2 + assert schema["maxLength"] == 50 + + def test_in_validator(self): + """Test In validator conversion.""" + schema = to_json_schema(Schema(In(['red', 'green', 'blue']))) + assert schema["enum"] == ['red', 'green', 'blue'] + + def test_match_validator(self): + """Test Match validator conversion.""" + schema = to_json_schema(Schema(Match(r'^[a-z]+$'))) + assert schema["type"] == "string" + assert schema["pattern"] == '^[a-z]+$' + + def test_email_validator(self): + """Test Email validator conversion.""" + schema = to_json_schema(Schema(Email())) + assert schema["type"] == "string" + assert schema["format"] == "email" + + def test_url_validator(self): + """Test Url validator conversion.""" + schema = to_json_schema(Schema(Url())) + assert schema["type"] == "string" + assert schema["format"] == "uri" + + def test_date_validator(self): + """Test Date validator conversion.""" + schema = to_json_schema(Schema(Date())) + assert schema["type"] == "string" + assert schema["format"] == "date" + + def test_datetime_validator(self): + """Test Datetime validator conversion.""" + schema = to_json_schema(Schema(Datetime())) + assert schema["type"] == "string" + assert schema["format"] == "date-time" + + def test_coerce_validator(self): + """Test Coerce validator conversion.""" + schema = to_json_schema(Schema(Coerce(int))) + assert schema["type"] == "integer" + + def test_clamp_validator(self): + """Test Clamp validator conversion.""" + schema = to_json_schema(Schema(Clamp(min=0, max=1))) + assert schema["type"] == "number" + assert schema["minimum"] == 0 + assert schema["maximum"] == 1 + + +class TestCompositeValidators: + """Test conversion of composite validators like All and Any.""" + + def test_all_validator(self): + """Test All validator conversion.""" + schema = to_json_schema(Schema(All(str, Length(min=1, max=100)))) + assert "allOf" in schema + assert len(schema["allOf"]) == 2 + assert schema["allOf"][0]["type"] == "string" + assert schema["allOf"][1]["minLength"] == 1 + assert schema["allOf"][1]["maxLength"] == 100 + + def test_any_validator(self): + """Test Any validator conversion.""" + schema = to_json_schema(Schema(Any(str, int))) + assert "anyOf" in schema + assert len(schema["anyOf"]) == 2 + assert schema["anyOf"][0]["type"] == "string" + assert schema["anyOf"][1]["type"] == "integer" + + def test_exact_sequence(self): + """Test ExactSequence validator conversion.""" + schema = to_json_schema(Schema(ExactSequence([str, int, bool]))) + assert schema["type"] == "array" + assert "prefixItems" in schema + assert len(schema["prefixItems"]) == 3 + assert schema["items"] is False + assert schema["minItems"] == 3 + assert schema["maxItems"] == 3 + + +class TestComplexSchemas: + """Test conversion of complex, real-world schemas.""" + + def test_user_profile_schema(self): + """Test a realistic user profile schema.""" + voluptuous_schema = Schema( + { + Required('username'): All( + str, Length(min=3, max=20), Match(r'^[a-zA-Z0-9_]+$') + ), + Required('email'): Email(), + Optional('age'): Range(min=13, max=120), + Optional('profile'): { + Optional('bio'): All(str, Length(max=500)), + Optional('website'): Url(), + Optional('tags'): [str], + }, + Optional('preferences'): { + 'theme': In(['light', 'dark']), + 'notifications': bool, + }, + } + ) + + json_schema = voluptuous_schema.to_json_schema() + + # Check top-level structure + assert json_schema["type"] == "object" + assert set(json_schema["required"]) == {"username", "email"} + + # Check username validation + username_schema = json_schema["properties"]["username"] + assert "allOf" in username_schema + + # Check email validation + email_schema = json_schema["properties"]["email"] + assert email_schema["format"] == "email" + + # Check nested profile object + profile_schema = json_schema["properties"]["profile"] + assert profile_schema["type"] == "object" + + # Check preferences + prefs_schema = json_schema["properties"]["preferences"] + assert prefs_schema["properties"]["theme"]["enum"] == ['light', 'dark'] + assert prefs_schema["properties"]["notifications"]["type"] == "boolean" + + +class TestEdgeCases: + """Test edge cases and error conditions.""" + + def test_empty_schema(self): + """Test empty schema conversion.""" + schema = to_json_schema(Schema({})) + assert schema["type"] == "object" + assert schema["properties"] == {} + + def test_callable_validator(self): + """Test custom callable validator.""" + + def custom_validator(value): + return value + + schema = to_json_schema(Schema(custom_validator)) + assert "description" in schema + assert "custom_validator" in schema["description"] diff --git a/voluptuous/tests/tests.py b/voluptuous/tests/tests.py index 71f8810..11d7b9e 100644 --- a/voluptuous/tests/tests.py +++ b/voluptuous/tests/tests.py @@ -8,13 +8,60 @@ import pytest from voluptuous import ( - ALLOW_EXTRA, PREVENT_EXTRA, REMOVE_EXTRA, All, AllInvalid, Any, Clamp, Coerce, - Contains, ContainsInvalid, Date, Datetime, Email, EmailInvalid, Equal, - ExactSequence, Exclusive, Extra, FqdnUrl, In, Inclusive, InInvalid, Invalid, IsDir, - IsFile, Length, Literal, LiteralInvalid, Marker, Match, MatchInvalid, Maybe, - MultipleInvalid, NotIn, NotInInvalid, Number, Object, Optional, PathExists, Range, - Remove, Replace, Required, Schema, Self, SomeOf, TooManyValid, TypeInvalid, Union, - Unordered, Url, UrlInvalid, raises, validate, + ALLOW_EXTRA, + PREVENT_EXTRA, + REMOVE_EXTRA, + All, + AllInvalid, + Any, + Clamp, + Coerce, + Contains, + ContainsInvalid, + Date, + Datetime, + Email, + EmailInvalid, + Equal, + ExactSequence, + Exclusive, + Extra, + FqdnUrl, + In, + Inclusive, + InInvalid, + Invalid, + IsDir, + IsFile, + Length, + Literal, + LiteralInvalid, + Marker, + Match, + MatchInvalid, + Maybe, + MultipleInvalid, + NotIn, + NotInInvalid, + Number, + Object, + Optional, + PathExists, + Range, + Remove, + Replace, + Required, + Schema, + Self, + SomeOf, + TooManyValid, + TypeInvalid, + Union, + Unordered, + Url, + UrlInvalid, + raises, + validate, ) from voluptuous.humanize import humanize_error from voluptuous.util import Capitalize, Lower, Strip, Title, Upper diff --git a/voluptuous/validators.py b/voluptuous/validators.py index 3f026b1..da75415 100644 --- a/voluptuous/validators.py +++ b/voluptuous/validators.py @@ -10,11 +10,31 @@ from functools import wraps from voluptuous.error import ( - AllInvalid, AnyInvalid, BooleanInvalid, CoerceInvalid, ContainsInvalid, DateInvalid, - DatetimeInvalid, DirInvalid, EmailInvalid, ExactSequenceInvalid, FalseInvalid, - FileInvalid, InInvalid, Invalid, LengthInvalid, MatchInvalid, MultipleInvalid, - NotEnoughValid, NotInInvalid, PathInvalid, RangeInvalid, TooManyValid, TrueInvalid, - TypeInvalid, UrlInvalid, + AllInvalid, + AnyInvalid, + BooleanInvalid, + CoerceInvalid, + ContainsInvalid, + DateInvalid, + DatetimeInvalid, + DirInvalid, + EmailInvalid, + ExactSequenceInvalid, + FalseInvalid, + FileInvalid, + InInvalid, + Invalid, + LengthInvalid, + MatchInvalid, + MultipleInvalid, + NotEnoughValid, + NotInInvalid, + PathInvalid, + RangeInvalid, + TooManyValid, + TrueInvalid, + TypeInvalid, + UrlInvalid, ) # F401: flake8 complains about 'raises' not being used, but it is used in doctests