From 3c141175a9025863d07fddc229ce4a0ac8d8bc51 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Edgar=20Ram=C3=ADrez=20Mondrag=C3=B3n?= Date: Wed, 27 Nov 2024 10:56:06 -0600 Subject: [PATCH] fix: The path of the offending field is now printed for config validation errors --- singer_sdk/plugin_base.py | 24 +++++++++++++++++++++++- tests/core/conftest.py | 18 ++++++++++++++++++ tests/core/test_tap_class.py | 24 ++++++++++++++++++++++++ 3 files changed, 65 insertions(+), 1 deletion(-) diff --git a/singer_sdk/plugin_base.py b/singer_sdk/plugin_base.py index db16560d4..f12f15227 100644 --- a/singer_sdk/plugin_base.py +++ b/singer_sdk/plugin_base.py @@ -38,6 +38,9 @@ extend_validator_with_defaults, ) +if t.TYPE_CHECKING: + from jsonschema import ValidationError + SDK_PACKAGE_NAME = "singer_sdk" JSONSchemaValidator = extend_validator_with_defaults(DEFAULT_JSONSCHEMA_VALIDATOR) @@ -88,6 +91,23 @@ def invoke(self, ctx: click.Context) -> t.Any: # noqa: ANN401 sys.exit(1) +def _format_validation_error(error: ValidationError) -> str: + """Format a JSON Schema validation error. + + Args: + error: A JSON Schema validation error. + + Returns: + A formatted error message. + """ + result = f"{error.message}" + + if error.path: + result += f" in config[{']['.join(repr(index) for index in error.path)}]" + + return result + + class PluginBase(metaclass=abc.ABCMeta): # noqa: PLR0904 """Abstract base class for taps.""" @@ -402,7 +422,9 @@ def _validate_config(self, *, raise_errors: bool = True) -> list[str]: config_jsonschema, ) validator = JSONSchemaValidator(config_jsonschema) - errors = [e.message for e in validator.iter_errors(self._config)] + errors = [ + _format_validation_error(e) for e in validator.iter_errors(self._config) + ] if errors: summary = ( diff --git a/tests/core/conftest.py b/tests/core/conftest.py index 30798b01c..6fa9a5ff3 100644 --- a/tests/core/conftest.py +++ b/tests/core/conftest.py @@ -11,8 +11,10 @@ from singer_sdk import Stream, Tap from singer_sdk.helpers._compat import datetime_fromisoformat from singer_sdk.typing import ( + ArrayType, DateTimeType, IntegerType, + ObjectType, PropertiesList, Property, StringType, @@ -82,6 +84,22 @@ class SimpleTestTap(Tap): Property("username", StringType, required=True), Property("password", StringType, required=True), Property("start_date", DateTimeType), + Property( + "nested", + ObjectType( + Property("key", StringType, required=True), + ), + required=False, + ), + Property( + "array", + ArrayType( + ObjectType( + Property("key", StringType, required=True), + ), + ), + required=False, + ), additional_properties=False, ).to_dict() diff --git a/tests/core/test_tap_class.py b/tests/core/test_tap_class.py index 93015fbb1..48e020299 100644 --- a/tests/core/test_tap_class.py +++ b/tests/core/test_tap_class.py @@ -34,6 +34,30 @@ ["Additional properties are not allowed ('extra' was unexpected)"], id="extra_property", ), + pytest.param( + {"username": None, "password": "ptest"}, + pytest.raises(ConfigValidationError, match="Config validation failed"), + ["None is not of type 'string' in config['username']"], + id="null_username", + ), + pytest.param( + {"username": "utest", "password": "ptest", "nested": {}}, + pytest.raises(ConfigValidationError, match="Config validation failed"), + ["'key' is a required property in config['nested']"], + id="missing_required_nested_key", + ), + pytest.param( + {"username": "utest", "password": "ptest", "array": []}, + nullcontext(), + [], + id="empty_array", + ), + pytest.param( + {"username": "utest", "password": "ptest", "array": [{}]}, + pytest.raises(ConfigValidationError, match="Config validation failed"), + ["'key' is a required property in config['array'][0]"], + id="array_with_empty_object", + ), pytest.param( {"username": "utest", "password": "ptest"}, nullcontext(),