diff --git a/leverage/_casting.py b/leverage/_casting.py new file mode 100644 index 0000000..588b4ff --- /dev/null +++ b/leverage/_casting.py @@ -0,0 +1,53 @@ +""" +Value casting utilities. +""" + +from typing import Any + +import yaml + + +def as_bool(value: str) -> Any: + """Return the boolean representation of ``value`` if possible.""" + try: + parsed = yaml.safe_load(value) + if isinstance(parsed, bool): + return parsed + except yaml.YAMLError: + pass + return value + + +def as_int(value: str) -> Any: + """Return the integer representation of ``value`` if possible.""" + try: + return int(value) + except ValueError: + return value + + +def as_float(value: str) -> Any: + """Return the float representation of ``value`` if possible.""" + try: + return float(value) + except ValueError: + return value + + +def cast_value(value: str) -> Any: + """Try to cast ``value`` to bool, int or float using the helper functions + :func:`as_bool`, :func:`as_int` and :func:`as_float`. + + Args: + value (str): Value to cast. + + Returns: + Any: The value converted to its apparent type or the original string. + """ + value = as_bool(value) + if isinstance(value, str): + value = as_int(value) + if isinstance(value, str): + value = as_float(value) + + return value diff --git a/leverage/_parsing.py b/leverage/_parsing.py index 8b056ec..2a9fa58 100644 --- a/leverage/_parsing.py +++ b/leverage/_parsing.py @@ -2,6 +2,8 @@ Command line arguments and tasks arguments parsing utilities. """ +from leverage._casting import cast_value + class InvalidArgumentOrderError(RuntimeError): pass @@ -40,13 +42,13 @@ def parse_task_args(arguments): f"Positional argument `{argument}` from task `{{task}}` cannot follow a keyword argument." ) - args.append(argument.strip()) + args.append(cast_value(argument.strip())) else: key, value = [part.strip() for part in argument.split("=")] if key in kwargs: raise DuplicateKeywordArgumentError(f"Duplicated keyword argument `{key}` in task `{{task}}`.") - kwargs[key] = value + kwargs[key] = cast_value(value) return args, kwargs diff --git a/leverage/conf.py b/leverage/conf.py index 458aaae..298fd31 100644 --- a/leverage/conf.py +++ b/leverage/conf.py @@ -6,6 +6,8 @@ from yaenv.core import Env +from leverage._casting import cast_value + from leverage import logger from leverage.path import get_root_path from leverage.path import get_working_path @@ -56,6 +58,6 @@ def load(config_filename=ENV_CONFIG_FILE): config_file = Env(config_file_path) for key, val in config_file: - config_dict[key] = val + config_dict[key] = cast_value(val) return config_dict diff --git a/tests/test__parsing.py b/tests/test__parsing.py index 26a5858..9d8a6bd 100644 --- a/tests/test__parsing.py +++ b/tests/test__parsing.py @@ -8,20 +8,35 @@ @pytest.mark.parametrize( "arguments, expected_args, expected_kwargs", [ - ("arg1, arg2, arg3 ", ["arg1", "arg2", "arg3"], {}), # All positional arguments + ("arg1, 2, 3.5 ", ["arg1", 2, 3.5], {}), # Cast positional arguments ( # All keyworded arguments - "kwarg1=/val/1,kwarg2 = val2, kwarg3 = val3 ", + "kwarg1=true,kwarg2 = val2, kwarg3 = 3 ", [], - {"kwarg1": "/val/1", "kwarg2": "val2", "kwarg3": "val3"}, + {"kwarg1": True, "kwarg2": "val2", "kwarg3": 3}, ), ("arg1, arg2, kwarg1=/val/1,kwarg2 = val2", ["arg1", "arg2"], {"kwarg1": "/val/1", "kwarg2": "val2"}), # Both + # Edge cases for casting + ("1e10, inf, nan", [1e10, float('inf'), float('nan')], {}), + ("007, 0123", [7, 123], {}), # Leading zeros + ("kwarg1=1.0,kwarg2=0.0", [], {"kwarg1": 1.0, "kwarg2": 0.0}), # Ensure float casting + # Boolean edge cases + ("True, FALSE, yes, no", [True, False, True, False], {}), + ( + "kwarg1=TRUE,kwarg2=false,kwarg3=1,kwarg4=0", + [], + {"kwarg1": True, "kwarg2": False, "kwarg3": 1, "kwarg4": 0}, + ), (None, [], {}), # No arguments ], ) def test__parse_args(arguments, expected_args, expected_kwargs): args, kwargs = parse_task_args(arguments=arguments) + for received, expected in zip(args, expected_args): + if isinstance(expected, float) and expected != expected: # NaN check + assert received != received + else: + assert received == expected - assert args == expected_args assert kwargs == expected_kwargs diff --git a/tests/test_casting.py b/tests/test_casting.py new file mode 100644 index 0000000..5cc117a --- /dev/null +++ b/tests/test_casting.py @@ -0,0 +1,38 @@ +import math + +import pytest + +from leverage._casting import as_bool, as_int, as_float, cast_value + + +@pytest.mark.parametrize( + "value, expected", + [ + ("true", True), + ("False", False), + ("1", 1), + ("-2", -2), + ("3.14", 3.14), + ("1e3", 1000.0), + ("inf", float("inf")), + ("nan", float("nan")), + ("007", 7), + ("0123", 123), + ("foo", "foo"), + ], +) +def test_cast_value(value, expected): + result = cast_value(value) + if isinstance(expected, float) and math.isnan(expected): + assert math.isnan(result) + else: + assert result == expected + + +def test_helper_functions(): + assert as_bool("true") is True + assert as_bool("no") is False + assert as_int("42") == 42 + assert as_int("bar") == "bar" + assert as_float("3.14") == 3.14 + assert as_float("not") == "not" diff --git a/tests/test_conf.py b/tests/test_conf.py index 57ede97..d1019da 100644 --- a/tests/test_conf.py +++ b/tests/test_conf.py @@ -30,9 +30,9 @@ True, { "PROJECT": "foobar", - "MFA_ENABLED": "true", + "MFA_ENABLED": True, "ENTRYPOINT": "/bin/run", - "DEBUG": "true", + "DEBUG": True, "CONFIG_PATH": "/home/user/.config/", }, ),