From 727a9ad92e6e5742fc3357df24a54113e479183c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Jir=C3=A1t?= Date: Wed, 29 Jul 2026 08:00:25 +0200 Subject: [PATCH 1/2] Surface invalid configuration as UserException instead of internal error An unexpected type in a configuration field escaped dacite.from_dict as a raw DaciteFieldError, which the entrypoint caught as a generic exception and turned into an opaque internal error (exit 2). Observed in production as UnionMatchError on the user_properties field. Re-raise dacite field errors as UserException so the job exits 1 with a message naming the offending field. Valid configurations parse exactly as before. Co-Authored-By: Claude Opus 5 (1M context) --- src/component.py | 22 ++++++++----- tests/test_component.py | 70 ++++++++++++++++++++++++++++++++++++++++- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/src/component.py b/src/component.py index 01b587a..5b0f65b 100644 --- a/src/component.py +++ b/src/component.py @@ -46,14 +46,20 @@ class Component(ComponentBase): def __init__(self): super().__init__() self._set_init_logging_handler() - self.parameters = dacite.from_dict( - Configuration, - self.configuration.parameters, - config=dacite.Config( - cast=[AuthEnum, SourceEnum, VenvEnum], - convert_key=encrypted_keys, - ), - ) + try: + self.parameters = dacite.from_dict( + Configuration, + self.configuration.parameters, + config=dacite.Config( + cast=[AuthEnum, SourceEnum, VenvEnum], + convert_key=encrypted_keys, + ), + ) + except dacite.DaciteFieldError as err: + # A configuration field with an unexpected type or a missing required field is a user + # problem, not an internal one. Re-raise as UserException so the job exits 1 with an + # actionable message instead of exiting 2 with an opaque internal error. + raise UserException(f"Invalid component configuration: {err}") from err def run(self): if self.parameters.source == SourceEnum.CODE: diff --git a/tests/test_component.py b/tests/test_component.py index db13111..cbe119e 100644 --- a/tests/test_component.py +++ b/tests/test_component.py @@ -1,4 +1,6 @@ +import json import os +import tempfile import unittest import mock @@ -6,7 +8,7 @@ from keboola.component.exceptions import UserException from component import Component -from configuration import Configuration +from configuration import Configuration, SourceEnum, VenvEnum class TestComponent(unittest.TestCase): @@ -60,6 +62,72 @@ def test_default_user_properties_is_empty_dict(self): self.assertIsInstance(config.user_properties, dict) +class TestConfigurationParsingErrors(unittest.TestCase): + """Configuration parsing errors must surface as UserException, not as an internal error. + + A configuration field with an unexpected type used to escape ``dacite.from_dict`` as a raw + ``DaciteFieldError``, which the entrypoint caught as a generic exception and turned into an + opaque internal error (exit 2). Such input is a user problem, so it must exit 1 with a message + naming the offending field. + """ + + @staticmethod + def _datadir(parameters: dict): + """Create a temporary data folder holding a config.json with the given parameters.""" + datadir = tempfile.TemporaryDirectory() + with open(os.path.join(datadir.name, "config.json"), "w") as config_file: + json.dump({"parameters": parameters}, config_file) + return datadir + + def _build_component(self, parameters: dict) -> Component: + datadir = self._datadir(parameters) + self.addCleanup(datadir.cleanup) + with mock.patch.dict(os.environ, {"KBC_DATADIR": datadir.name}): + return Component() + + def test_string_user_properties_raises_user_exception(self): + """A string in user_properties must raise UserException naming the field, not exit 2.""" + with self.assertRaises(UserException) as context: + self._build_component({"source": "code", "venv": "base", "user_properties": '{"key": "value"}'}) + self.assertIn("Invalid component configuration", str(context.exception)) + self.assertIn("user_properties", str(context.exception)) + + def test_wrong_type_in_other_field_raises_user_exception(self): + """Any field of an unexpected type is reported the same way.""" + with self.assertRaises(UserException) as context: + self._build_component( + {"source": "code", "venv": "base", "user_properties": {}, "packages": "pandas"} + ) + self.assertIn("Invalid component configuration", str(context.exception)) + self.assertIn("packages", str(context.exception)) + + def test_post_init_user_exception_is_not_rewrapped(self): + """UserException raised in Configuration.__post_init__ keeps its original message.""" + with self.assertRaises(UserException) as context: + self._build_component( + {"source": "code", "venv": "base", "user_properties": ["item1", "item2"]} + ) + self.assertIn("non-empty list not supported", str(context.exception)) + self.assertNotIn("Invalid component configuration", str(context.exception)) + + def test_valid_configuration_is_parsed_unchanged(self): + """A valid configuration still parses into the expected Configuration values.""" + component = self._build_component( + { + "source": "code", + "venv": "3.13", + "user_properties": {"debug": False}, + "packages": ["pandas"], + "code": "print('hello')", + } + ) + self.assertEqual(component.parameters.source, SourceEnum.CODE) + self.assertEqual(component.parameters.venv, VenvEnum.PY_3_13) + self.assertEqual(component.parameters.user_properties, {"debug": False}) + self.assertEqual(component.parameters.packages, ["pandas"]) + self.assertEqual(component.parameters.code, "print('hello')") + + if __name__ == "__main__": # import sys;sys.argv = ['', 'Test.testName'] unittest.main() From 35c91ab0eafd614a75536511b0a20cc7230d8bf0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maty=C3=A1=C5=A1=20Jir=C3=A1t?= Date: Wed, 29 Jul 2026 08:07:28 +0200 Subject: [PATCH 2/2] Add a plain-language lead to the invalid-configuration message Addresses PR review finding #1: the message surfaced dacite's raw text on its own. It now names the offending field in plain language and keeps the library text as trailing detail. Phrased type-agnostically so it also reads correctly for a missing required field. Co-Authored-By: Claude Opus 5 (1M context) --- src/component.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/component.py b/src/component.py index 5b0f65b..9daff32 100644 --- a/src/component.py +++ b/src/component.py @@ -59,7 +59,10 @@ def __init__(self): # A configuration field with an unexpected type or a missing required field is a user # problem, not an internal one. Re-raise as UserException so the job exits 1 with an # actionable message instead of exiting 2 with an opaque internal error. - raise UserException(f"Invalid component configuration: {err}") from err + raise UserException( + f'Invalid component configuration: please check the "{err.field_path}" parameter ' + f"in the configuration. Detail: {err}" + ) from err def run(self): if self.parameters.source == SourceEnum.CODE: