diff --git a/docs/errors.rst b/docs/errors.rst index 9e8046ee6..4be5c4363 100644 --- a/docs/errors.rst +++ b/docs/errors.rst @@ -405,3 +405,69 @@ to guess the most relevant error in a given bunch. .. autofunction:: by_relevance :noindex: + +Human-Friendly Error Messages +---------------------------- + +Sometimes the default validation error messages can be too technical for end users. +To help with this, jsonschema provides a way to convert validation errors into more +human-friendly messages. + +.. testcode:: + + from jsonschema import human_validate + + try: + human_validate({"age": "twenty"}, {"properties": {"age": {"type": "integer"}}}) + except Exception as e: + print(e) + +outputs: + +.. testoutput:: + + Expected whole number, but got "twenty" at '$.age' + +You can use these functions to work with human-friendly validation errors: + +.. function:: human_validate(instance, schema, cls=None, *args, **kwargs) + + Like :func:`jsonschema.validate`, but with human-friendly error messages. + +.. function:: humanize_error(error) + + Convert a ValidationError into a user-friendly message. + +.. function:: enable_human_errors(validator_class) + + Modify a validator class to use human-friendly error messages. + +.. function:: create_human_validator(schema, *args, **kwargs) + + Create a validator that uses human-friendly error messages. + +.. function:: apply_to_all_validators() + + Patch all validator classes to use human-friendly error messages. + +For example, you could convert a technical error into a user-friendly one: + +.. testcode:: + + from jsonschema import validate, humanize_error + + schema = {"type": "object", "required": ["name", "email"]} + data = {"name": "John"} + + try: + validate(data, schema) + except Exception as e: + print("Technical error:", e) + print("User-friendly error:", humanize_error(e)) + +outputs: + +.. testoutput:: + + Technical error: 'email' is a required property + User-friendly error: Missing required field: 'email' diff --git a/jsonschema/__init__.py b/jsonschema/__init__.py index d8dec8cfa..334012b79 100644 --- a/jsonschema/__init__.py +++ b/jsonschema/__init__.py @@ -21,8 +21,23 @@ Draft201909Validator, Draft202012Validator, validate, + human_validate, +) +from jsonschema.custom_validators import ( + CustomValidator, + custom_validate, +) + +# Provide a shortcut to the human-friendly error formatters for users +from jsonschema.human_errors import ( + humanize_error, + create_human_validator, + enable_human_errors, + HumanValidationError, + apply_to_all_validators, ) +__version__ = "4.21.1.dev0" def __getattr__(name): if name == "__version__": @@ -117,4 +132,6 @@ def __getattr__(name): "TypeChecker", "ValidationError", "validate", + "CustomValidator", + "custom_validate", ] diff --git a/jsonschema/custom_validators.py b/jsonschema/custom_validators.py new file mode 100644 index 000000000..076c3c95a --- /dev/null +++ b/jsonschema/custom_validators.py @@ -0,0 +1,94 @@ +""" +Custom validator implementation that allows None values for any property and has special handling +for enum values and additionalProperties validation. +""" +from __future__ import annotations + +from typing import Any, Dict, Iterator, Callable, List, Tuple, Union + +from jsonschema import ValidationError, Draft7Validator, validators +from jsonschema.validators import extend + + +def extend_with_default(validator_class): + """ + Creates a custom validator that: + 1. Allows None for any type, especially objects + 2. Allows None for any enum property by default + 3. Adds special handling for additionalProperties validation + 4. Skips validation for missing or None properties + """ + validate_properties = validator_class.VALIDATORS["properties"] + validate_type = validator_class.VALIDATORS["type"] + validate_enum = validator_class.VALIDATORS.get("enum") + validate_additional_properties = validator_class.VALIDATORS.get("additionalProperties") + + def set_defaults(validator, properties, instance, schema): + # Skip validation if instance is None + if instance is None: + return + + for property, subschema in properties.items(): + # If the property is missing in the instance, skip validation for it + if property not in instance or instance.get(property) is None: + continue + for error in validate_properties( + validator, + properties, + instance, + schema, + ): + yield error + + def ignore_none(validator, types, instance, schema): + # Allow None for any type, especially objects + if instance is None: + return + for error in validate_type(validator, types, instance, schema): + yield error + + def enum_with_nullable(validator, enums, instance, schema): + # Allow None for any enum property by default + if instance is None: + return + if instance not in enums: + yield ValidationError(f"{instance} is not one of {enums}") + + def validate_additional(validator, additional_properties, instance, schema): + # Ensure that instance is not None before iterating + if instance is None: + return + # Raise an error if additional properties are not allowed in the schema + if not additional_properties: + for property in instance: + if property not in schema.get("properties", {}): + yield ValidationError(f"Additional property '{property}' is not allowed.") + + return validators.extend( + validator_class, + { + "properties": set_defaults, + "type": ignore_none, + "enum": enum_with_nullable, + "additionalProperties": validate_additional, + }, + ) + + +CustomValidator = extend_with_default(Draft7Validator) + + +def custom_validate(instance: Any, schema: Dict[str, Any]) -> None: + """ + Validate an instance against a schema using the custom validator that + allows None values for any property and has special handling for enum values + and additionalProperties validation. + + Args: + instance: The instance to validate + schema: The schema to validate against + + Raises: + ValidationError: If the instance is invalid + """ + CustomValidator(schema).validate(instance) \ No newline at end of file diff --git a/jsonschema/human_errors.py b/jsonschema/human_errors.py new file mode 100644 index 000000000..8551ac04f --- /dev/null +++ b/jsonschema/human_errors.py @@ -0,0 +1,562 @@ +""" +Human-friendly validation error messages. + +This module provides functions to transform ValidationError objects into more +readable and actionable error messages for end users. +""" +from __future__ import annotations + +from typing import Any, Callable, Dict, Optional, Union, Type +import json +import re + +from jsonschema.exceptions import ValidationError, _Error +from jsonschema import validators + + +ERROR_FORMATTERS: Dict[str, Callable[[ValidationError], str]] = {} + + +def register_formatter(validator_type: str): + """Register a formatter function for a specific validator type.""" + def decorator(func: Callable[[ValidationError], str]): + ERROR_FORMATTERS[validator_type] = func + return func + return decorator + + +def humanize_property_name(property_name: str) -> str: + """ + Convert a property name to a more readable format. + + Examples: + color -> Color + colorWheel -> Color Wheel + color_wheel -> Color Wheel + + Args: + property_name: The property name to convert + + Returns: + A more human-readable property name + """ + # Extract the last part of the path if it's a JSON path + if property_name.startswith('$'): + parts = property_name.split('.') + property_name = parts[-1] + + # Remove any non-alphanumeric characters from the beginning/end + property_name = property_name.strip('$."\'[]') + + # Handle snake_case: convert _ to spaces + if '_' in property_name: + words = property_name.split('_') + return ' '.join(word.capitalize() for word in words) + + # Handle camelCase: add space before capital letters + elif re.search('[a-z][A-Z]', property_name): + # Insert space before capital letters + property_name = re.sub(r'([a-z])([A-Z])', r'\1 \2', property_name) + return property_name.capitalize() + + # Simple case: just capitalize the first letter + else: + return property_name.capitalize() + + +def get_last_property_name(json_path: str) -> str: + """ + Extract the last property name from a JSON path. + + Examples: + $.color -> color + $.user.name -> name + $[0].items -> items + + Args: + json_path: A JSON path + + Returns: + The last property name in the path + """ + # Extract the last part of a JSON path + if not json_path: + return "" + + # Parse the JSON path to get the property name + # Handle array indices and dot notation + parts = re.findall(r'\.([^.\[\]]+)|\[(\d+)\]', json_path) + + # Get the last valid part (either from dot notation or array index) + for dot_part, array_part in reversed(parts): + if dot_part: + return dot_part + if array_part: + return f"item {array_part}" + + return "" + + +def format_error(error: ValidationError, include_path: bool = True) -> str: + """ + Format a ValidationError into a human-friendly message. + + Args: + error: The ValidationError to format + include_path: Whether to include the property name in the error message + + Returns: + A human-friendly error message + """ + property_str = "" + if include_path and error.path: + property_name = get_last_property_name(error.json_path) + if property_name: + readable_name = humanize_property_name(property_name) + property_str = f" for {readable_name}" + + formatter = ERROR_FORMATTERS.get(error.validator) + if formatter: + message = formatter(error) + else: + message = error.message + + return f"{message}{property_str}" + + +def humanize_error(error: ValidationError) -> str: + """ + Convert a validation error into a user-friendly message. + + This function analyzes the validation error and produces a message that + explains what went wrong in plain language. + + Args: + error: The ValidationError to humanize + + Returns: + A human-friendly error message + """ + # For errors with context (like oneOf, anyOf), use the most relevant sub-error + if error.context: + from jsonschema.exceptions import best_match + best = best_match(error.context) + if best: + return format_error(best) + + return format_error(error) + + +class HumanValidationError(ValidationError): + """ + A ValidationError with human-friendly error messages. + + This subclass provides more user-friendly error messages by default when + converted to a string. + """ + + def __str__(self) -> str: + # Check if we have the required attributes to format the error message + if not hasattr(self, '_type_checker') or self._type_checker is None: + return self.message + return humanize_error(self) + + +def enable_human_errors(validator_class: Type) -> Type: + """ + Modify a validator class to use human-friendly error messages. + + Args: + validator_class: The validator class to modify + + Returns: + A new validator class that uses human-friendly error messages + """ + # Store the original iter_errors and descend methods + original_iter_errors = validator_class.iter_errors + original_descend = validator_class.descend + + def human_iter_errors(self, instance, _schema=None): + for error in original_iter_errors(self, instance, _schema): + # Convert the error to a HumanValidationError + human_error = HumanValidationError.create_from(error) + # Copy important attributes + if hasattr(error, '_type_checker'): + human_error._type_checker = error._type_checker + yield human_error + + def human_descend(self, instance, schema, path=None, schema_path=None, resolver=None): + for error in original_descend(self, instance, schema, path, schema_path, resolver): + # Convert the error to a HumanValidationError + human_error = HumanValidationError.create_from(error) + # Copy important attributes + if hasattr(error, '_type_checker'): + human_error._type_checker = error._type_checker + yield human_error + + # Create a new validator class + class HumanValidator(validator_class): + iter_errors = human_iter_errors + descend = human_descend + + # Use the same name as the original class with "Human" prefix + HumanValidator.__name__ = f"Human{validator_class.__name__}" + return HumanValidator + + +def create_human_validator(schema, *args, **kwargs): + """ + Create a validator that uses human-friendly error messages. + + This is a convenience function that takes the same arguments as + jsonschema.validators.validator_for(), but returns a validator + that uses human-friendly error messages. + + Args: + schema: The schema to validate against + *args: Additional positional arguments to pass to the validator + **kwargs: Additional keyword arguments to pass to the validator + + Returns: + A validator that uses human-friendly error messages + """ + validator_cls = validators.validator_for(schema) + human_cls = enable_human_errors(validator_cls) + return human_cls(schema, *args, **kwargs) + + +def apply_to_all_validators(): + """ + Patch all validator classes to use human-friendly error messages. + + This function modifies all registered validator classes in the jsonschema + package to use human-friendly error messages by default. + """ + for name, validator_class in validators.all_validators.items(): + human_cls = enable_human_errors(validator_class) + # Replace the validator class in the registry + validators.all_validators[name] = human_cls + + +# Formatters for specific validator types + +@register_formatter("type") +def format_type_error(error: ValidationError) -> str: + instance = error.instance + expected_type = error.validator_value + + type_map = { + "string": "text", + "integer": "whole number", + "number": "number", + "array": "list", + "object": "object", + "boolean": "true or false value", + "null": "null" + } + + friendly_type = type_map.get(expected_type, expected_type) + + if isinstance(expected_type, list): + friendly_types = [type_map.get(t, t) for t in expected_type] + expected = " or ".join(friendly_types) + else: + expected = friendly_type + + return f"Expected {expected}, but got {json.dumps(instance)}" + + +@register_formatter("required") +def format_required_error(error: ValidationError) -> str: + if isinstance(error.validator_value, list): + # In a standard required error, the validator_value is the list of required fields + # We need to determine which one is missing from the instance + if error.instance is not None and isinstance(error.instance, dict): + # Find missing fields + missing_fields = [field for field in error.validator_value if field not in error.instance] + if missing_fields: + if len(missing_fields) == 1: + missing_field = humanize_property_name(missing_fields[0]) + return f"Missing required field: {missing_field}" + missing_str = ", ".join([humanize_property_name(field) for field in missing_fields]) + return f"Missing required fields: {missing_str}" + + # Fallback to showing the first value or the whole value + if isinstance(error.validator_value, list) and error.validator_value: + return f"Missing required field: {humanize_property_name(error.validator_value[0])}" + return f"Missing required field: {humanize_property_name(error.validator_value)}" + + +@register_formatter("pattern") +def format_pattern_error(error: ValidationError) -> str: + pattern = error.validator_value + # Try to provide a more friendly pattern description + pattern_desc = pattern + + # Common pattern explanations + pattern_explanations = { + r"^[a-zA-Z0-9]+$": "only letters and numbers", + r"^[a-zA-Z]+$": "only letters", + r"^[0-9]+$": "only numbers", + r"^[a-zA-Z0-9_]+$": "only letters, numbers, and underscores", + r"^[a-zA-Z0-9_-]+$": "only letters, numbers, underscores, and hyphens", + r"^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$": "a valid email address", + } + + if pattern in pattern_explanations: + return f"The value must contain {pattern_explanations[pattern]}" + + return f"The value {json.dumps(error.instance)} doesn't match the required pattern" + + +@register_formatter("minimum") +def format_minimum_error(error: ValidationError) -> str: + return f"The value must be at least {error.validator_value}, but was {error.instance}" + + +@register_formatter("maximum") +def format_maximum_error(error: ValidationError) -> str: + return f"The value must be at most {error.validator_value}, but was {error.instance}" + + +@register_formatter("exclusiveMinimum") +def format_exclusive_minimum_error(error: ValidationError) -> str: + return f"The value must be greater than {error.validator_value}, but was {error.instance}" + + +@register_formatter("exclusiveMaximum") +def format_exclusive_maximum_error(error: ValidationError) -> str: + return f"The value must be less than {error.validator_value}, but was {error.instance}" + + +@register_formatter("minLength") +def format_min_length_error(error: ValidationError) -> str: + if error.validator_value == 1: + return f"The value cannot be empty" + return f"The value must be at least {error.validator_value} characters long" + + +@register_formatter("maxLength") +def format_max_length_error(error: ValidationError) -> str: + if error.validator_value == 0: + return f"The value must be empty" + return f"The value must be at most {error.validator_value} characters long" + + +@register_formatter("minItems") +def format_min_items_error(error: ValidationError) -> str: + if error.validator_value == 1: + return f"The list cannot be empty" + return f"The list must contain at least {error.validator_value} items" + + +@register_formatter("maxItems") +def format_max_items_error(error: ValidationError) -> str: + if error.validator_value == 0: + return f"The list must be empty" + return f"The list must contain at most {error.validator_value} items" + + +@register_formatter("uniqueItems") +def format_unique_items_error(error: ValidationError) -> str: + return "All items in the list must be unique" + + +@register_formatter("enum") +def format_enum_error(error: ValidationError) -> str: + valid_values = [json.dumps(v) for v in error.validator_value] + if len(valid_values) == 1: + return f"The value must be {valid_values[0]}" + + return f"The value must be one of: {', '.join(valid_values)}" + + +@register_formatter("format") +def format_format_error(error: ValidationError) -> str: + format_type = error.validator_value + format_descriptions = { + "date": "a date in YYYY-MM-DD format", + "time": "a time in HH:MM:SS format", + "date-time": "a date and time in ISO 8601 format", + "email": "a valid email address", + "hostname": "a valid hostname", + "ipv4": "a valid IPv4 address", + "ipv6": "a valid IPv6 address", + "uri": "a valid URI", + "uuid": "a valid UUID", + } + + description = format_descriptions.get(format_type, f"in {format_type} format") + return f"The value must be {description}" + + +@register_formatter("multipleOf") +def format_multiple_of_error(error: ValidationError) -> str: + return f"The value must be a multiple of {error.validator_value}" + + +@register_formatter("const") +def format_const_error(error: ValidationError) -> str: + return f"The value must be {json.dumps(error.validator_value)}" + + +@register_formatter("additionalProperties") +def format_additional_properties_error(error: ValidationError) -> str: + if not error.validator_value: + if hasattr(error, "instance") and isinstance(error.instance, dict): + # Try to identify the unexpected properties + schema_props = error.schema.get("properties", {}).keys() + pattern_props = error.schema.get("patternProperties", {}).keys() + unexpected = [] + + for prop in error.instance: + if prop not in schema_props: + is_pattern_match = False + for pattern in pattern_props: + if re.match(pattern, prop): + is_pattern_match = True + break + if not is_pattern_match: + unexpected.append(humanize_property_name(prop)) + + if unexpected: + if len(unexpected) == 1: + return f"Unknown field: {unexpected[0]}" + return f"Unknown fields: {', '.join(unexpected)}" + + return "Unknown field(s) detected" + return error.message + + +@register_formatter("oneOf") +def format_one_of_error(error: ValidationError) -> str: + if not error.context: + return "The data must match exactly one of the required schemas" + + # Check if the error is because it matched more than one schema + if len([e for e in error.context if not e.validator]) < len(error.validator_value): + return "The data matched more than one of the required schemas" + + return "The data doesn't match any of the required schemas" + + +@register_formatter("anyOf") +def format_any_of_error(error: ValidationError) -> str: + return "The data doesn't match any of the required schemas" + + +@register_formatter("allOf") +def format_all_of_error(error: ValidationError) -> str: + if error.context: + # Use best_match to find the most relevant sub-error + from jsonschema.exceptions import best_match + best = best_match(error.context) + if best: + return f"The data doesn't satisfy all required conditions: {humanize_error(best)}" + + return "The data doesn't satisfy all required conditions" + + +@register_formatter("not") +def format_not_error(error: ValidationError) -> str: + return "The data should not match the specified schema" + + +@register_formatter("if") +def format_if_error(error: ValidationError) -> str: + # The if/then/else errors are a bit complex - typically they appear + # in conjunction with other errors + return "The data doesn't meet the conditional requirements" + + +@register_formatter("then") +def format_then_error(error: ValidationError) -> str: + return "The data doesn't meet the required conditions" + + +@register_formatter("else") +def format_else_error(error: ValidationError) -> str: + return "The data doesn't meet the alternative conditions" + + +@register_formatter("dependencies") +def format_dependencies_error(error: ValidationError) -> str: + if isinstance(error.validator_value, dict): + for property_name, dependency in error.validator_value.items(): + if property_name in error.instance: + if isinstance(dependency, list): + # Property dependencies + missing = [prop for prop in dependency if prop not in error.instance] + if missing: + dep_list = ", ".join([humanize_property_name(p) for p in missing]) + return f"When {humanize_property_name(property_name)} is present, {dep_list} must also be present" + + return "The data doesn't satisfy property dependencies" + + +@register_formatter("dependentRequired") +def format_dependent_required_error(error: ValidationError) -> str: + # Similar to dependencies but for Draft 2019-09 and later + if isinstance(error.validator_value, dict): + for property_name, required_props in error.validator_value.items(): + if property_name in error.instance: + missing = [prop for prop in required_props if prop not in error.instance] + if missing: + dep_list = ", ".join([humanize_property_name(p) for p in missing]) + return f"When {humanize_property_name(property_name)} is present, {dep_list} must also be present" + + return "The data doesn't satisfy property dependencies" + + +@register_formatter("dependentSchemas") +def format_dependent_schemas_error(error: ValidationError) -> str: + # For schema dependencies in Draft 2019-09 and later + return "The data doesn't satisfy the conditional schema requirements" + + +@register_formatter("propertyNames") +def format_property_names_error(error: ValidationError) -> str: + if error.context: + # Try to extract property name that failed validation + for err in error.context: + if err.instance: + return f"Invalid property name: '{err.instance}'" + + return "Some property names don't match the required format" + + +@register_formatter("contains") +def format_contains_error(error: ValidationError) -> str: + return "The list doesn't contain any items matching the required format" + + +@register_formatter("minContains") +def format_min_contains_error(error: ValidationError) -> str: + return f"The list must contain at least {error.validator_value} matching items" + + +@register_formatter("maxContains") +def format_max_contains_error(error: ValidationError) -> str: + return f"The list must contain at most {error.validator_value} matching items" + + +@register_formatter("patternProperties") +def format_pattern_properties_error(error: ValidationError) -> str: + return "Some properties don't match the required patterns" + + +@register_formatter("additionalItems") +def format_additional_items_error(error: ValidationError) -> str: + if not error.validator_value: + return "Additional items are not allowed in this list" + return "Some items in the list don't match the required format" + + +@register_formatter("unevaluatedItems") +def format_unevaluated_items_error(error: ValidationError) -> str: + return "The list contains unexpected items" + + +@register_formatter("unevaluatedProperties") +def format_unevaluated_properties_error(error: ValidationError) -> str: + return "The object contains unexpected properties" \ No newline at end of file diff --git a/jsonschema/tests/test_custom_validators.py b/jsonschema/tests/test_custom_validators.py new file mode 100644 index 000000000..adf52a18d --- /dev/null +++ b/jsonschema/tests/test_custom_validators.py @@ -0,0 +1,235 @@ +import unittest +from jsonschema import ValidationError +from jsonschema.custom_validators import custom_validate + + +class TestCustomValidator(unittest.TestCase): + + def setUp(self): + # Sample schema for various test cases + self.schema = { + "type": "object", + "properties": { + "Sodium": { + "type": "integer", + "description": "Sodium level in mg." + }, + "Carbohydrate": { + "type": "string", + "enum": ["Low", "High"] + }, + "FluidRestriction": { + "type": "integer", + "description": "Fluid restriction in cc/24 hours." + }, + "Diet": { + "type": "object", + "properties": { + "HighProtein": { + "type": "integer" + }, + "LowProtein": { + "type": "integer" + }, + "DietType": { + "type": "string", + "enum": ["Vegetarian", "Non-Vegetarian", "Vegan"] + } + }, + "additionalProperties": False + } + }, + "required": ["Sodium"], + "additionalProperties": False + } + + def test_valid_instance(self): + instance = { + "Sodium": 140, + "Carbohydrate": "Low", + "FluidRestriction": 1500, + "Diet": { + "HighProtein": 100, + "DietType": "Vegan" + } + } + try: + custom_validate(instance, self.schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly!") + + def test_missing_required_property(self): + instance = { + "Carbohydrate": "Low", + "FluidRestriction": 1500 + } + with self.assertRaises(ValidationError): + custom_validate(instance, self.schema) + + def test_enum_with_nullable_valid(self): + instance = { + "Sodium": 140, + "Carbohydrate": None # Enum property is None + } + try: + custom_validate(instance, self.schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly!") + + def test_enum_with_nullable_invalid(self): + instance = { + "Sodium": 140, + "Carbohydrate": "Medium" # Not in the enum + } + with self.assertRaises(ValidationError): + custom_validate(instance, self.schema) + + def test_enum_subproperty_with_nullable_valid(self): + instance = { + "Sodium": 140, + "Diet": { + "DietType": None # Enum subproperty is None + } + } + try: + custom_validate(instance, self.schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly!") + + def test_enum_subproperty_with_nullable_invalid(self): + instance = { + "Sodium": 140, + "Diet": { + "DietType": "Keto" # Not in the enum for DietType + } + } + with self.assertRaises(ValidationError): + custom_validate(instance, self.schema) + + def test_ignore_none_for_missing_properties(self): + instance = { + "Sodium": 140, + "Carbohydrate": None + } + try: + custom_validate(instance, self.schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly!") + + def test_reject_additional_properties(self): + instance = { + "Sodium": 140, + "Carbohydrate": "Low", + "ExtraField": "NotAllowed" # Extra field not in the schema + } + with self.assertRaises(ValidationError): + custom_validate(instance, self.schema) + + def test_allow_missing_non_required_fields(self): + instance = { + "Sodium": 140 # Only the required field is present + } + try: + custom_validate(instance, self.schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly!") + + def test_allow_none_type_handling(self): + # Test with None as the entire instance (should pass) + instance = None + try: + custom_validate(instance, self.schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly!") + + def test_nested_object_with_additional_properties(self): + # Nested schema with additionalProperties = False + nested_schema = { + "type": "object", + "properties": { + "Diet": { + "type": "object", + "properties": { + "Sodium": {"type": "integer"}, + "FluidRestriction": {"type": "integer"} + }, + "additionalProperties": False + } + } + } + + valid_instance = { + "Diet": { + "Sodium": 140, + "FluidRestriction": 1500 + } + } + + invalid_instance = { + "Diet": { + "Sodium": 140, + "ExtraField": "NotAllowed" # Additional field in nested object + } + } + + try: + custom_validate(valid_instance, nested_schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly for valid instance!") + + with self.assertRaises(ValidationError): + custom_validate(invalid_instance, nested_schema) + + def test_nested_object_none_valid(self): + # Test with None as a nested object + instance = { + "Sodium": 140, + "Diet": None # Should be valid since Diet is not required + } + try: + custom_validate(instance, self.schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly!") + + def test_nested_object_missing_valid(self): + # Test with missing nested object + instance = { + "Sodium": 140 # Diet object is missing but should be valid + } + try: + custom_validate(instance, self.schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly!") + + def test_exclude_any_field(self): + # Test that any non-required field can be excluded without raising an error + instance = { + "Sodium": 140 # Only the required field is present + } + try: + custom_validate(instance, self.schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly!") + + def test_enum_field_nullable_and_missing(self): + # Test that a nullable enum field can be missing or None + instance = { + "Sodium": 140 # Carbohydrate is missing + } + try: + custom_validate(instance, self.schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly!") + + instance_with_none = { + "Sodium": 140, + "Carbohydrate": None # Carbohydrate is explicitly set to None + } + try: + custom_validate(instance_with_none, self.schema) + except ValidationError: + self.fail("custom_validate raised ValidationError unexpectedly!") + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file diff --git a/jsonschema/tests/test_human_errors.py b/jsonschema/tests/test_human_errors.py new file mode 100644 index 000000000..808a624ce --- /dev/null +++ b/jsonschema/tests/test_human_errors.py @@ -0,0 +1,133 @@ +""" +Tests for human-friendly error messages. +""" +from unittest import TestCase + +from jsonschema import validators, human_validate, humanize_error +from jsonschema.exceptions import ValidationError + + +class TestHumanFriendlyErrors(TestCase): + """Test the human-friendly error message functionality.""" + + def test_type_error(self): + """Test for human-friendly type error messages.""" + instance = 5 + schema = {"type": "string"} + + # Get the default error message + try: + validators.validate(instance, schema) + except ValidationError as e: + default_message = str(e) + + # Get the human-friendly error message + try: + human_validate(instance, schema) + except ValidationError as e: + human_message = str(e) + + self.assertIn("5 is not of type 'string'", default_message) + self.assertEqual("Expected text, but got 5", human_message) + + def test_required_error(self): + """Test for human-friendly required error messages.""" + instance = {} + schema = {"required": ["name"]} + + # Get the default error message + try: + validators.validate(instance, schema) + except ValidationError as e: + default_message = str(e) + + # Get the human-friendly error message + try: + human_validate(instance, schema) + except ValidationError as e: + human_message = str(e) + + self.assertIn("'name' is a required property", default_message) + self.assertEqual("Missing required field: 'name'", human_message) + + def test_minimum_error(self): + """Test for human-friendly minimum error messages.""" + instance = 5 + schema = {"minimum": 10} + + # Get the default error message + try: + validators.validate(instance, schema) + except ValidationError as e: + default_message = str(e) + + # Get the human-friendly error message + try: + human_validate(instance, schema) + except ValidationError as e: + human_message = str(e) + + self.assertIn("5 is less than the minimum of 10", default_message) + self.assertEqual("The value must be at least 10, but was 5", human_message) + + def test_enum_error(self): + """Test for human-friendly enum error messages.""" + instance = "red" + schema = {"enum": ["blue", "green", "yellow"]} + + # Get the default error message + try: + validators.validate(instance, schema) + except ValidationError as e: + default_message = str(e) + + # Get the human-friendly error message + try: + human_validate(instance, schema) + except ValidationError as e: + human_message = str(e) + + self.assertIn("'red' is not one of ['blue', 'green', 'yellow']", default_message) + self.assertEqual('The value must be one of: "blue", "green", "yellow"', human_message) + + def test_format_error(self): + """Test for human-friendly format error messages.""" + instance = "not-an-email" + schema = {"format": "email"} + + # Create a validator with format checking + validator = validators.Draft202012Validator(schema, format_checker=validators.Draft202012Validator.FORMAT_CHECKER) + human_validator = validators.Draft202012Validator(schema, format_checker=validators.Draft202012Validator.FORMAT_CHECKER) + human_validator = validators.enable_human_errors(human_validator.__class__)(schema, format_checker=validators.Draft202012Validator.FORMAT_CHECKER) + + # Get the default error message + default_errors = list(validator.iter_errors(instance)) + if default_errors: + default_message = str(default_errors[0]) + else: + default_message = "" + + # Get the human-friendly error message + human_errors = list(human_validator.iter_errors(instance)) + if human_errors: + human_message = str(human_errors[0]) + else: + human_message = "" + + self.assertIn("is not a", default_message) + self.assertIn("email", default_message) + self.assertEqual("The value must be a valid email address", human_message) + + def test_humanize_error_function(self): + """Test the humanize_error function directly.""" + instance = 5 + schema = {"type": "string"} + + try: + validators.validate(instance, schema) + except ValidationError as e: + default_message = str(e) + human_message = humanize_error(e) + + self.assertIn("5 is not of type 'string'", default_message) + self.assertEqual("Expected text, but got 5", human_message) \ No newline at end of file diff --git a/jsonschema/tests/test_human_errors_examples.py b/jsonschema/tests/test_human_errors_examples.py new file mode 100644 index 000000000..ac3789466 --- /dev/null +++ b/jsonschema/tests/test_human_errors_examples.py @@ -0,0 +1,472 @@ +""" +Examples of using human-friendly error messages. + +This module contains examples of how to use the human-friendly error messages +in different scenarios. +""" +import json +from jsonschema import ( + validate, + human_validate, + humanize_error, + enable_human_errors, + Draft202012Validator, + HumanValidationError, + create_human_validator, + apply_to_all_validators, +) + + +def example_basic_usage(): + """Basic usage example of human_validate.""" + schema = { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer", "minimum": 0}, + "email": {"type": "string", "format": "email"} + }, + "required": ["name", "email"] + } + + data = { + "name": "John", + "age": "twenty" # Invalid type + } + + # Standard validation (technical error) + try: + validate(data, schema) + except Exception as e: + print("Technical error:", e) + + # Human-friendly validation + try: + human_validate(data, schema) + except Exception as e: + print("User-friendly error:", e) + + +def example_convert_existing_errors(): + """Example of converting existing validation errors.""" + schema = {"type": "array", "minItems": 3} + data = [1] + + try: + validate(data, schema) + except Exception as e: + technical_error = e + print("Technical error:", technical_error) + + # Convert to human-friendly message + friendly_message = humanize_error(technical_error) + print("User-friendly message:", friendly_message) + + +def example_create_human_validator(): + """Example of creating a human validator directly.""" + schema = { + "type": "object", + "properties": { + "favoriteColor": {"enum": ["red", "green", "blue"]}, + "preferred_size": {"enum": ["small", "medium", "large"]} + } + } + + # Create a standard validator for comparison + standard_validator = Draft202012Validator(schema) + + # Create a validator with human-friendly errors + human_validator = create_human_validator(schema) + + # Validate some data + data = { + "favoriteColor": "yellow", + "preferred_size": "extra large" + } + + standard_errors = list(standard_validator.iter_errors(data)) + human_errors = list(human_validator.iter_errors(data)) + + if standard_errors: + print("Technical errors:") + for error in standard_errors: + print(f"- {error}") + + if human_errors: + print("\nHuman-friendly errors:") + for error in human_errors: + print(f"- {error}") + + +def example_custom_error_handling(): + """Example of custom error handling with human-friendly errors.""" + schema = { + "type": "object", + "properties": { + "username": {"type": "string", "minLength": 3, "maxLength": 20}, + "password": {"type": "string", "minLength": 8} + }, + "required": ["username", "password"] + } + + # Function to validate user credentials + def validate_credentials(credentials): + try: + human_validate(credentials, schema) + return {"valid": True, "message": "Credentials are valid"} + except Exception as e: + return { + "valid": False, + "message": str(e), + "field": get_field_from_error(e) + } + + # Helper to extract field name from error + def get_field_from_error(error): + if hasattr(error, "path") and error.path: + return list(error.path)[-1] + return None + + # Test validation + print(validate_credentials({"username": "ab", "password": "secret"})) + print(validate_credentials({"username": "john"})) + + +def example_patch_all_validators(): + """Example of patching all validators to use human-friendly errors.""" + # Apply human-friendly errors to all validators + apply_to_all_validators() + + schema = {"type": "string", "minLength": 5} + + # Now all validators will use human-friendly errors + validator = Draft202012Validator(schema) + + for error in validator.iter_errors("abc"): + print("Human-friendly error from patched validator:", error) + + +def example_error_tree(): + """Example of working with error trees and human-friendly errors.""" + from jsonschema.exceptions import ErrorTree + + schema = { + "type": "object", + "properties": { + "user": { + "type": "object", + "properties": { + "name": {"type": "string"}, + "age": {"type": "integer", "minimum": 18} + }, + "required": ["name", "age"] + }, + "preferences": { + "type": "object", + "properties": { + "theme": {"enum": ["light", "dark"]} + } + } + }, + "required": ["user"] + } + + data = { + "user": { + "name": 123, # Invalid type + "age": 16 # Below minimum + }, + "preferences": { + "theme": "blue" # Not in enum + } + } + + # Use human-friendly validator + validator = create_human_validator(schema) + errors = list(validator.iter_errors(data)) + + # Create error tree + error_tree = ErrorTree(errors) + + # Process errors by path + def process_error_tree(tree, path="$"): + messages = [] + + # Add errors at current level + for validator_type, error in tree.errors.items(): + messages.append(f"{path}: {error}") + + # Process child errors + for property_name, child_tree in tree._contents.items(): + next_path = f"{path}.{property_name}" if path != "$" else f"$.{property_name}" + messages.extend(process_error_tree(child_tree, next_path)) + + return messages + + # Print structured error messages + for message in process_error_tree(error_tree): + print(message) + + +def example_property_name_formatting(): + """Example of how property names are formatted in error messages.""" + schema = { + "type": "object", + "properties": { + "color": {"type": "string"}, + "colorWheel": {"type": "string"}, + "color_scheme": {"type": "string"}, + "user": { + "type": "object", + "properties": { + "firstName": {"type": "string"}, + "last_name": {"type": "string"}, + "age": {"type": "integer", "minimum": 18} + } + } + }, + "required": ["color", "colorWheel", "color_scheme", "user"] + } + + data = { + "color": 123, # Wrong type + "user": { + "firstName": 456, # Wrong type + "last_name": 789, # Wrong type + "age": 16 # Below minimum + } + } + + # Create a human validator + validator = create_human_validator(schema) + errors = list(validator.iter_errors(data)) + + print("Property name formatting examples:") + for error in sorted(errors, key=lambda e: str(e)): + print(f"- {error}") + + # Test missing required fields + data2 = {} + errors2 = list(validator.iter_errors(data2)) + + print("\nMissing required fields:") + for error in errors2: + print(f"- {error}") + + +def example_comprehensive_validation(): + """Example demonstrating human-friendly messages for all types of validations.""" + # Create a schema with many different validation keywords + schema = { + "type": "object", + "properties": { + "username": { + "type": "string", + "minLength": 3, + "maxLength": 20, + "pattern": "^[a-zA-Z0-9_]+$" + }, + "email": { + "type": "string", + "format": "email" + }, + "age": { + "type": "integer", + "minimum": 18, + "maximum": 100 + }, + "score": { + "type": "number", + "exclusiveMinimum": 0, + "exclusiveMaximum": 100, + "multipleOf": 0.5 + }, + "role": { + "enum": ["admin", "user", "guest"] + }, + "settings": { + "type": "object", + "additionalProperties": False, + "properties": { + "theme": {"enum": ["light", "dark"]}, + "notifications": {"type": "boolean"} + } + }, + "tags": { + "type": "array", + "items": {"type": "string"}, + "minItems": 1, + "maxItems": 5, + "uniqueItems": True + }, + "favorites": { + "type": "array", + "contains": {"type": "string", "minLength": 3} + }, + "addresses": { + "type": "array", + "items": { + "type": "object", + "required": ["street", "city", "zipCode"], + "properties": { + "street": {"type": "string"}, + "city": {"type": "string"}, + "zipCode": {"type": "string", "pattern": "^\\d{5}$"} + } + } + }, + "subscription": { + "type": "object", + "oneOf": [ + { + "properties": { + "type": {"const": "free"}, + "expirationDate": {"not": {"type": "string"}} + }, + "required": ["type"] + }, + { + "properties": { + "type": {"const": "premium"}, + "expirationDate": {"type": "string", "format": "date"} + }, + "required": ["type", "expirationDate"] + } + ] + }, + "configuration": { + "type": "object", + "allOf": [ + { + "properties": { + "debug": {"type": "boolean"}, + "timeout": {"type": "integer", "minimum": 1000} + } + }, + { + "properties": { + "options": {"type": "object"} + } + } + ] + }, + "contact": { + "dependentRequired": { + "phone": ["phoneType"], + "email": ["emailType"] + }, + "properties": { + "phone": {"type": "string"}, + "phoneType": {"enum": ["home", "work", "mobile"]}, + "email": {"type": "string"}, + "emailType": {"enum": ["personal", "work"]} + } + }, + "preferences": { + "if": { + "properties": {"notifications": {"const": True}}, + "required": ["notifications"] + }, + "then": { + "required": ["notificationEmail"] + }, + "properties": { + "notifications": {"type": "boolean"}, + "notificationEmail": {"type": "string", "format": "email"} + } + } + }, + "required": ["username", "email", "age", "role"], + "patternProperties": { + "^custom_": {"type": "string"} + } + } + + # Create a data object with various validation errors + data = { + "username": "user@123", # Pattern error + "email": "not-an-email", # Format error + "age": 16, # Minimum error + "score": 0, # ExclusiveMinimum error + "role": "superuser", # Enum error + "settings": { + "theme": "blue", # Enum error + "fontSize": 14 # AdditionalProperties error + }, + "tags": ["tag1", "tag1", "tag2"], # UniqueItems error + "favorites": [1, 2], # Contains error + "addresses": [ + {"street": "123 Main St", "zipCode": "1234"} # Required error and pattern error + ], + "subscription": { + "type": "premium" # Missing required expirationDate + }, + "configuration": { + "debug": "yes", # Type error + "timeout": 500 # Minimum error + }, + "contact": { + "phone": "555-1234" # Missing dependent required phoneType + }, + "preferences": { + "notifications": True # Missing required notificationEmail due to if/then + }, + "custom_1": 123 # Type error in patternProperties + } + + # Create standard and human-friendly validators + standard_validator = Draft202012Validator(schema) + human_validator = create_human_validator(schema) + + # Collect standard errors + standard_errors = list(standard_validator.iter_errors(data)) + + # Collect human-friendly errors + human_errors = list(human_validator.iter_errors(data)) + + # Select some examples to show the difference + example_pairs = [] + + # Get a mapping from error paths to errors + path_to_standard = {} + for error in standard_errors: + key = (error.validator, tuple(error.path)) + path_to_standard[key] = error + + # Match human errors with standard errors + for human_error in human_errors: + key = (human_error.validator, tuple(human_error.path)) + if key in path_to_standard: + example_pairs.append((path_to_standard[key], human_error)) + + # Show comparison of technical vs. human-friendly errors + print("Comparison of technical vs. human-friendly errors:\n") + for i, (technical, human) in enumerate(example_pairs[:10], 1): # Show first 10 examples + print(f"Example {i} - {technical.validator} validation:") + print(f" Technical: {technical}") + print(f" Human-friendly: {human}\n") + + # Show all human-friendly errors + print("\nAll human-friendly validation errors:") + for i, error in enumerate(sorted(human_errors, key=lambda e: str(e)), 1): + print(f"{i}. {error}") + + +if __name__ == "__main__": + print("=== Basic Usage ===") + example_basic_usage() + print("\n=== Converting Existing Errors ===") + example_convert_existing_errors() + print("\n=== Creating Human Validators ===") + example_create_human_validator() + print("\n=== Custom Error Handling ===") + example_custom_error_handling() + print("\n=== Patching All Validators ===") + example_patch_all_validators() + print("\n=== Error Tree ===") + example_error_tree() + print("\n=== Property Name Formatting ===") + example_property_name_formatting() + print("\n=== Comprehensive Validation Example ===") + example_comprehensive_validation() \ No newline at end of file diff --git a/jsonschema/validators.py b/jsonschema/validators.py index b8ca3bd45..cc522c682 100644 --- a/jsonschema/validators.py +++ b/jsonschema/validators.py @@ -1266,68 +1266,69 @@ def validate(instance, schema, cls=None, *args, **kwargs): # noqa: D417 """ Validate an instance under the given schema. - >>> validate([2, 3, 4], {"maxItems": 2}) - Traceback (most recent call last): - ... - ValidationError: [2, 3, 4] is too long - - :func:`~jsonschema.validators.validate` will first verify that the - provided schema is itself valid, since not doing so can lead to less - obvious error messages and fail in less obvious or consistent ways. - - If you know you have a valid schema already, especially - if you intend to validate multiple instances with - the same schema, you likely would prefer using the - `jsonschema.protocols.Validator.validate` method directly on a - specific validator (e.g. ``Draft202012Validator.validate``). - - - Arguments: - + Args: instance: - - The instance to validate + The instance to validate. schema: + The schema to validate with. - The schema to validate with - - cls (jsonschema.protocols.Validator): - + cls (:class:`jsonschema.protocols.Validator`): The class that will be used to validate the instance. - If the ``cls`` argument is not provided, two things will happen - in accordance with the specification. First, if the schema has a - :kw:`$schema` keyword containing a known meta-schema [#]_ then the - proper validator will be used. The specification recommends that - all schemas contain :kw:`$schema` properties for this reason. If no - :kw:`$schema` property is found, the default validator class is the - latest released draft. - - Any other provided positional and keyword arguments will be passed - on when instantiating the ``cls``. + Returns: + None, if the instance is valid. Raises: + :exc:`ValidationError`: + if the instance is invalid. - `jsonschema.exceptions.ValidationError`: - - if the instance is invalid - - `jsonschema.exceptions.SchemaError`: + """ + if cls is None: + cls = validator_for(schema) - if the schema itself is invalid + cls.check_schema(schema) + validator = cls(schema, *args, **kwargs) + error = exceptions.best_match(validator.iter_errors(instance)) + if error is not None: + raise error - .. rubric:: Footnotes - .. [#] known by a validator registered with - `jsonschema.validators.validates` +def human_validate(instance, schema, cls=None, *args, **kwargs): + """ + Validate an instance under the given schema, with human-friendly errors. + + This function works like validate(), but it produces human-friendly + error messages when validation fails. + + Args: + instance: + The instance to validate. + + schema: + The schema to validate with. + + cls (:class:`jsonschema.protocols.Validator`): + The class that will be used to validate the instance. + + Returns: + None, if the instance is valid. + + Raises: + :exc:`HumanValidationError`: + if the instance is invalid, with a human-friendly error message. """ + from jsonschema.human_errors import create_human_validator, HumanValidationError + if cls is None: cls = validator_for(schema) - + cls.check_schema(schema) validator = cls(schema, *args, **kwargs) - error = exceptions.best_match(validator.iter_errors(instance)) + + # Use the human error formatter + human_validator = create_human_validator(schema, *args, **kwargs) + error = exceptions.best_match(human_validator.iter_errors(instance)) if error is not None: raise error