diff --git a/bin/deepstate/core/base.py b/bin/deepstate/core/base.py index 4c3aa5e8..d0c92075 100644 --- a/bin/deepstate/core/base.py +++ b/bin/deepstate/core/base.py @@ -184,6 +184,9 @@ def parse_args(cls) -> Optional[argparse.Namespace]: if args.config: _args.update(cls.build_from_config(args.config)) # type: ignore + # configparser gives us strings -- apply argparse types (issue #311) + cls._coerce_config_types(parser, _args) + # Cleanup: force --no_exit_compile to be on, meaning if user specifies a `[test]` section, # execution will continue. Delete config as well _args["no_exit_compile"] = True # type: ignore @@ -269,6 +272,32 @@ def build_from_config(config: str, allowed_keys: Optional[List[str]] = None, inc return context # type: ignore + @staticmethod + def _coerce_config_types(parser: argparse.ArgumentParser, + _args: Dict[str, Any]) -> None: + """Apply argparse types to values loaded from a config file (issue #311).""" + for action in parser._actions: + if action.dest not in _args: + continue + value = _args[action.dest] + if not isinstance(value, str): + continue + + if isinstance(action, (argparse._StoreTrueAction, argparse._StoreFalseAction)): + v = value.strip().lower() + if v in ("true", "1", "yes"): + _args[action.dest] = True + elif v in ("false", "0", "no"): + _args[action.dest] = False + else: + raise AnalysisBackendError(f"Invalid boolean for --{action.dest}: {value!r}") + elif action.type not in (None, str): + try: + _args[action.dest] = action.type(value) + except (ValueError, TypeError): + raise AnalysisBackendError(f"Invalid value for --{action.dest}: {value!r}") + + def init_from_dict(self, _args: Optional[Dict[str, str]] = None) -> None: """ Builder initialization routine used to instantiate the attributes of the frontend object, either from the stored diff --git a/tests/test_config_types.py b/tests/test_config_types.py new file mode 100644 index 00000000..e3a666e0 --- /dev/null +++ b/tests/test_config_types.py @@ -0,0 +1,45 @@ +import argparse +import unittest + +from deepstate.core.base import AnalysisBackend, AnalysisBackendError + + +def make_parser(): + p = argparse.ArgumentParser() + p.add_argument("--timeout", type=int, default=0) + p.add_argument("--ratio", type=float, default=0.0) + p.add_argument("--blackbox", action="store_true") + return p + + +class ConfigTypesTest(unittest.TestCase): + + def test_int(self): + args = {"timeout": "3600"} + AnalysisBackend._coerce_config_types(make_parser(), args) + self.assertEqual(args["timeout"], 3600) + self.assertIsInstance(args["timeout"], int) + + def test_float(self): + args = {"ratio": "0.25"} + AnalysisBackend._coerce_config_types(make_parser(), args) + self.assertEqual(args["ratio"], 0.25) + + def test_bool_true(self): + args = {"blackbox": "true"} + AnalysisBackend._coerce_config_types(make_parser(), args) + self.assertTrue(args["blackbox"]) + + def test_bool_false(self): + args = {"blackbox": "false"} + AnalysisBackend._coerce_config_types(make_parser(), args) + self.assertFalse(args["blackbox"]) + + def test_invalid_int_raises(self): + args = {"timeout": "not a number"} + with self.assertRaises(AnalysisBackendError): + AnalysisBackend._coerce_config_types(make_parser(), args) + + +if __name__ == "__main__": + unittest.main()