Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions src/component.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,14 +46,23 @@ 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: please check the "{err.field_path}" parameter '
f"in the configuration. Detail: {err}"
) from err

def run(self):
if self.parameters.source == SourceEnum.CODE:
Expand Down
70 changes: 69 additions & 1 deletion tests/test_component.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,14 @@
import json
import os
import tempfile
import unittest

import mock
from freezegun import freeze_time
from keboola.component.exceptions import UserException

from component import Component
from configuration import Configuration
from configuration import Configuration, SourceEnum, VenvEnum


class TestComponent(unittest.TestCase):
Expand Down Expand Up @@ -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()
Loading