Skip to content

Commit 58aaf0e

Browse files
Add casting helpers and comprehensive tests
1 parent a4609c6 commit 58aaf0e

File tree

6 files changed

+119
-9
lines changed

6 files changed

+119
-9
lines changed

leverage/_casting.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
"""
2+
Value casting utilities.
3+
"""
4+
5+
from typing import Any
6+
7+
import yaml
8+
9+
10+
def as_bool(value: str) -> Any:
11+
"""Return the boolean representation of ``value`` if possible."""
12+
try:
13+
parsed = yaml.safe_load(value)
14+
if isinstance(parsed, bool):
15+
return parsed
16+
except yaml.YAMLError:
17+
pass
18+
return value
19+
20+
21+
def as_int(value: str) -> Any:
22+
"""Return the integer representation of ``value`` if possible."""
23+
try:
24+
return int(value)
25+
except ValueError:
26+
return value
27+
28+
29+
def as_float(value: str) -> Any:
30+
"""Return the float representation of ``value`` if possible."""
31+
try:
32+
return float(value)
33+
except ValueError:
34+
return value
35+
36+
37+
def cast_value(value: str) -> Any:
38+
"""Try to cast ``value`` to bool, int or float using the helper functions
39+
:func:`as_bool`, :func:`as_int` and :func:`as_float`.
40+
41+
Args:
42+
value (str): Value to cast.
43+
44+
Returns:
45+
Any: The value converted to its apparent type or the original string.
46+
"""
47+
value = as_bool(value)
48+
if isinstance(value, str):
49+
value = as_int(value)
50+
if isinstance(value, str):
51+
value = as_float(value)
52+
53+
return value

leverage/_parsing.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22
Command line arguments and tasks arguments parsing utilities.
33
"""
44

5+
from leverage._casting import cast_value
6+
57

68
class InvalidArgumentOrderError(RuntimeError):
79
pass
@@ -40,13 +42,13 @@ def parse_task_args(arguments):
4042
f"Positional argument `{argument}` from task `{{task}}` cannot follow a keyword argument."
4143
)
4244

43-
args.append(argument.strip())
45+
args.append(cast_value(argument.strip()))
4446

4547
else:
4648
key, value = [part.strip() for part in argument.split("=")]
4749
if key in kwargs:
4850
raise DuplicateKeywordArgumentError(f"Duplicated keyword argument `{key}` in task `{{task}}`.")
4951

50-
kwargs[key] = value
52+
kwargs[key] = cast_value(value)
5153

5254
return args, kwargs

leverage/conf.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@
66

77
from yaenv.core import Env
88

9+
from leverage._casting import cast_value
10+
911
from leverage import logger
1012
from leverage.path import get_root_path
1113
from leverage.path import get_working_path
@@ -56,6 +58,6 @@ def load(config_filename=ENV_CONFIG_FILE):
5658
config_file = Env(config_file_path)
5759

5860
for key, val in config_file:
59-
config_dict[key] = val
61+
config_dict[key] = cast_value(val)
6062

6163
return config_dict

tests/test__parsing.py

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,20 +8,35 @@
88
@pytest.mark.parametrize(
99
"arguments, expected_args, expected_kwargs",
1010
[
11-
("arg1, arg2, arg3 ", ["arg1", "arg2", "arg3"], {}), # All positional arguments
11+
("arg1, 2, 3.5 ", ["arg1", 2, 3.5], {}), # Cast positional arguments
1212
( # All keyworded arguments
13-
"kwarg1=/val/1,kwarg2 = val2, kwarg3 = val3 ",
13+
"kwarg1=true,kwarg2 = val2, kwarg3 = 3 ",
1414
[],
15-
{"kwarg1": "/val/1", "kwarg2": "val2", "kwarg3": "val3"},
15+
{"kwarg1": True, "kwarg2": "val2", "kwarg3": 3},
1616
),
1717
("arg1, arg2, kwarg1=/val/1,kwarg2 = val2", ["arg1", "arg2"], {"kwarg1": "/val/1", "kwarg2": "val2"}), # Both
18+
# Edge cases for casting
19+
("1e10, inf, nan", [1e10, float('inf'), float('nan')], {}),
20+
("007, 0123", [7, 123], {}), # Leading zeros
21+
("kwarg1=1.0,kwarg2=0.0", [], {"kwarg1": 1.0, "kwarg2": 0.0}), # Ensure float casting
22+
# Boolean edge cases
23+
("True, FALSE, yes, no", [True, False, True, False], {}),
24+
(
25+
"kwarg1=TRUE,kwarg2=false,kwarg3=1,kwarg4=0",
26+
[],
27+
{"kwarg1": True, "kwarg2": False, "kwarg3": 1, "kwarg4": 0},
28+
),
1829
(None, [], {}), # No arguments
1930
],
2031
)
2132
def test__parse_args(arguments, expected_args, expected_kwargs):
2233
args, kwargs = parse_task_args(arguments=arguments)
34+
for received, expected in zip(args, expected_args):
35+
if isinstance(expected, float) and expected != expected: # NaN check
36+
assert received != received
37+
else:
38+
assert received == expected
2339

24-
assert args == expected_args
2540
assert kwargs == expected_kwargs
2641

2742

tests/test_casting.py

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import math
2+
3+
import pytest
4+
5+
from leverage._casting import as_bool, as_int, as_float, cast_value
6+
7+
8+
@pytest.mark.parametrize(
9+
"value, expected",
10+
[
11+
("true", True),
12+
("False", False),
13+
("1", 1),
14+
("-2", -2),
15+
("3.14", 3.14),
16+
("1e3", 1000.0),
17+
("inf", float("inf")),
18+
("nan", float("nan")),
19+
("007", 7),
20+
("0123", 123),
21+
("foo", "foo"),
22+
],
23+
)
24+
def test_cast_value(value, expected):
25+
result = cast_value(value)
26+
if isinstance(expected, float) and math.isnan(expected):
27+
assert math.isnan(result)
28+
else:
29+
assert result == expected
30+
31+
32+
def test_helper_functions():
33+
assert as_bool("true") is True
34+
assert as_bool("no") is False
35+
assert as_int("42") == 42
36+
assert as_int("bar") == "bar"
37+
assert as_float("3.14") == 3.14
38+
assert as_float("not") == "not"

tests/test_conf.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,9 +30,9 @@
3030
True,
3131
{
3232
"PROJECT": "foobar",
33-
"MFA_ENABLED": "true",
33+
"MFA_ENABLED": True,
3434
"ENTRYPOINT": "/bin/run",
35-
"DEBUG": "true",
35+
"DEBUG": True,
3636
"CONFIG_PATH": "/home/user/.config/",
3737
},
3838
),

0 commit comments

Comments
 (0)