Skip to content

Commit b6d7841

Browse files
authored
test: Enable tests from fixtures to work with string-valued Enum params (#1841)
1 parent d1d8b43 commit b6d7841

8 files changed

Lines changed: 163 additions & 58 deletions

File tree

Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
11
{
2-
"args": ["httpd`id`", "on"],
3-
"kwargs": {
4-
"persistent": true
5-
},
6-
"facts": {
7-
"selinux.SEBoolean": {
8-
"boolean=httpd`id`": "off"
9-
}
10-
},
11-
"commands": [
12-
"setsebool -P 'httpd`id`' on"
13-
]
2+
"args": ["httpd`id`", "enum:Boolean:on"],
3+
"kwargs": {"persistent": true},
4+
"facts": {"selinux.SEBoolean": {"boolean=httpd`id`": "off"}},
5+
"commands": ["setsebool -P 'httpd`id`' on"]
146
}
Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,6 @@
11
{
2-
"args": ["some_boolean", "off"],
3-
"kwargs": {
4-
},
5-
"facts": {
6-
"selinux.SEBoolean": {
7-
"boolean=some_boolean": "on"
8-
}
9-
},
10-
"commands": [
11-
"setsebool some_boolean off"
12-
]
2+
"args": ["some_boolean", "enum:Boolean:off"],
3+
"kwargs": {},
4+
"facts": {"selinux.SEBoolean": {"boolean=some_boolean": "on"}},
5+
"commands": ["setsebool some_boolean off"]
136
}
Lines changed: 4 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,6 @@
11
{
2-
"args": ["some_boolean", "on"],
3-
"kwargs": {
4-
"persistent":true
5-
},
6-
"facts": {
7-
"selinux.SEBoolean": {
8-
"boolean=some_boolean": "off"
9-
}
10-
},
11-
"commands": [
12-
"setsebool -P some_boolean on"
13-
]
2+
"args": ["some_boolean", "enum:Boolean:on"],
3+
"kwargs": {"persistent": true},
4+
"facts": {"selinux.SEBoolean": {"boolean=some_boolean": "off"}},
5+
"commands": ["setsebool -P some_boolean on"],
146
}
Lines changed: 4 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,7 @@
11
{
2-
"args": ["some_boolean", "on"],
3-
"kwargs": {
4-
"persistent":true
5-
},
6-
"facts": {
7-
"selinux.SEBoolean": {
8-
"boolean=some_boolean": "on"
9-
}
10-
},
11-
"commands": [
12-
],
2+
"args": ["some_boolean", "enum:Boolean:on"],
3+
"kwargs": {"persistent": true},
4+
"facts": {"selinux.SEBoolean": {"boolean=some_boolean": "on"}},
5+
"commands": [],
136
"noop_description": "boolean 'some_boolean' already had the value 'on'"
147
}

tests/test_facts.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pyinfra.context import ctx_host, ctx_state
1313
from pyinfra_cli.util import json_encode
1414

15-
from .util import FakeState, create_host, get_command_string
15+
from .util import FakeState, create_host, get_command_string, get_enum_map, parse_value
1616

1717
# show full diff on json
1818
TestCase.maxDiff = None
@@ -60,7 +60,7 @@ def _test_fn(self, test_name, test_data, fact):
6060
short_fact = fact
6161
fact = fact.fact()
6262

63-
test_args = test_data.get("arg", [])
63+
test_args = parse_value(test_data.get("arg", []), get_enum_map(fact.command))
6464
command = _make_command(fact.command, test_args)
6565

6666
if "command" in test_data:

tests/test_operations.py

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
from pyinfra.context import ctx_host, ctx_state
1313
from pyinfra_cli.util import json_encode
1414

15-
from .util import FakeState, create_host, get_command_string, parse_value, patch_files
15+
from .util import FakeState, create_host, get_command_string, get_enum_map, parse_value, patch_files
1616

1717
PLATFORM_NAME = platform.system()
1818

@@ -116,13 +116,15 @@ def _test(self, test_name, test_data):
116116

117117
op_test_name = f"{arg}/{test_name}.json"
118118

119-
# Create a host with this tests facts and attach to context host
120-
host = create_host(self.state, facts=test_data.get("facts", {}))
119+
enum_map = get_enum_map(op._inner) # noqa: SLF001
120+
121+
# Create a host with this test's facts and attach to context host
122+
host = create_host(self.state, facts=parse_value(test_data.get("facts", {}), enum_map))
121123

122124
allowed_exception = test_data.get("exception")
123125

124-
args = parse_value(test_data.get("args", []))
125-
kwargs = parse_value(test_data.get("kwargs", {}))
126+
args = parse_value(test_data.get("args", []), enum_map)
127+
kwargs = parse_value(test_data.get("kwargs", {}), enum_map)
126128

127129
with ctx_state.use(self.state):
128130
with ctx_host.use(host):

tests/test_operations_utils.py

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
from enum import Enum
12
from unittest import TestCase
23
from unittest.mock import MagicMock
34

@@ -8,6 +9,8 @@
89
from pyinfra.operations.util.files import ensure_mode_int, unix_path_join
910
from pyinfra.operations.util.packaging import ensure_packages
1011

12+
from .util import get_enum_map
13+
1114

1215
class TestUnixPathJoin(TestCase):
1316
def test_simple_path(self):
@@ -545,3 +548,93 @@ def test_new_format_multi_version_pinned_match_against_any_version(self):
545548
)
546549
assert commands == []
547550
host.noop.assert_called_once_with("package kernel is installed (5.10.0-26,6.1.0-13)")
551+
552+
553+
class TestGetEnumMap(TestCase):
554+
"""ensure get_enum_map works correctly"""
555+
556+
class ZipEnum(Enum):
557+
FOO = 1
558+
BAR = 2
559+
560+
class ZapEnum(Enum):
561+
FOO = 1
562+
BAR = 2
563+
564+
class ZowieEnum(Enum):
565+
FOO = 1
566+
BAR = 2
567+
568+
def test_multiple_simple_types_no_enum_works(self):
569+
def func(a: int, b: str, c: float, d: complex) -> None:
570+
pass
571+
572+
assert get_enum_map(func) == {}
573+
574+
def test_only_simple_enum_works(self) -> None:
575+
def func(a: TestGetEnumMap.ZipEnum) -> None:
576+
pass
577+
578+
assert get_enum_map(func) == {"ZipEnum": TestGetEnumMap.ZipEnum}
579+
580+
def test_union_simple_enum_or_none_works(self) -> None:
581+
def func(a: TestGetEnumMap.ZipEnum | None) -> None:
582+
pass
583+
584+
assert get_enum_map(func) == {"ZipEnum": TestGetEnumMap.ZipEnum}
585+
586+
def test_union_simple_enum_or_str_works(self) -> None:
587+
def func(a: TestGetEnumMap.ZipEnum | str) -> None:
588+
pass
589+
590+
assert get_enum_map(func) == {"ZipEnum": TestGetEnumMap.ZipEnum}
591+
592+
def test_list_of_simple_enum_works(self) -> None:
593+
def func(a: list[TestGetEnumMap.ZipEnum]) -> None:
594+
pass
595+
596+
assert get_enum_map(func) == {"ZipEnum": TestGetEnumMap.ZipEnum}
597+
598+
def test_set_of_simple_enum_works(self) -> None:
599+
def func(a: set[TestGetEnumMap.ZipEnum]) -> None:
600+
pass
601+
602+
assert get_enum_map(func) == {"ZipEnum": TestGetEnumMap.ZipEnum}
603+
604+
def test_tuple_of_simple_enum_works(self) -> None:
605+
def func(a: tuple[TestGetEnumMap.ZipEnum, ...]) -> None:
606+
pass
607+
608+
assert get_enum_map(func) == {"ZipEnum": TestGetEnumMap.ZipEnum}
609+
610+
def test_dict_key_of_simple_enum_works(self) -> None:
611+
def func(a: dict[TestGetEnumMap.ZipEnum, str]) -> None:
612+
pass
613+
614+
assert get_enum_map(func) == {"ZipEnum": TestGetEnumMap.ZipEnum}
615+
616+
def test_dict_value_of_simple_enum_works(self) -> None:
617+
def func(a: dict[str, TestGetEnumMap.ZipEnum]) -> None:
618+
pass
619+
620+
assert get_enum_map(func) == {"ZipEnum": TestGetEnumMap.ZipEnum}
621+
622+
def test_multiple_enums_in_unions_work(self) -> None:
623+
def func(
624+
a: TestGetEnumMap.ZipEnum | None,
625+
b: TestGetEnumMap.ZapEnum | str,
626+
c: TestGetEnumMap.ZowieEnum | complex,
627+
) -> None:
628+
pass
629+
630+
assert get_enum_map(func) == {
631+
"ZipEnum": TestGetEnumMap.ZipEnum,
632+
"ZapEnum": TestGetEnumMap.ZapEnum,
633+
"ZowieEnum": TestGetEnumMap.ZowieEnum,
634+
}
635+
636+
def test_nested_list_of_enum_unions_works(self) -> None:
637+
def func(a: list[list[TestGetEnumMap.ZapEnum | None] | None] | None) -> None:
638+
pass
639+
640+
assert get_enum_map(func) == {"ZapEnum": TestGetEnumMap.ZapEnum}

tests/util.py

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,15 @@
22
import json
33
import os
44
import re
5+
from collections.abc import Callable
56
from datetime import datetime, timezone
6-
from inspect import getcallargs, getfullargspec
7+
from enum import Enum
8+
from inspect import getcallargs, getfullargspec, signature
79
from io import StringIO
810
from os import path
911
from pathlib import Path
12+
from types import UnionType
13+
from typing import Any, Dict, List, Set, Tuple, Union, get_args, get_type_hints, get_origin # noqa: UP035
1014
from unittest.mock import patch
1115

1216
from pyinfra.api import Config, Inventory
@@ -59,7 +63,31 @@ def get_temp_filename(*args):
5963
return "_tempfile_"
6064

6165

62-
def parse_value(value):
66+
AGGREGATES = {Dict, List, Set, Tuple, Union, UnionType, dict, list, set, tuple} # noqa: UP006
67+
68+
69+
def get_enum_map(op: Callable[..., Any]) -> dict[str, type[Enum]]:
70+
"""
71+
Returns a map from type name to type for all enum types used in `ops` parameters.
72+
"""
73+
result: dict[str, type[Enum]] = {}
74+
type_hints = get_type_hints(op)
75+
for param in signature(op).parameters:
76+
if param not in type_hints:
77+
continue
78+
to_do = [type_hints[param]]
79+
while len(to_do) > 0:
80+
the_type = to_do.pop(0)
81+
origin = get_origin(the_type)
82+
if (origin is not None) and (origin in AGGREGATES):
83+
to_do.extend(get_args(the_type))
84+
elif isinstance(the_type, type) and issubclass(the_type, Enum):
85+
result[the_type.__name__] = the_type
86+
87+
return result
88+
89+
90+
def parse_value(value, enum_map: dict[str, type[Enum]] | None = None):
6391
"""
6492
Convert JSON types to more complex Python types because JSON is lacking.
6593
"""
@@ -69,17 +97,29 @@ def parse_value(value):
6997
return datetime.fromisoformat(value[9:])
7098
if value.startswith("path:"):
7199
return Path(value[5:])
100+
if value.startswith("enum:"):
101+
if len(pieces := value.split(":")) != 3: # enum:<enum_type_name>:<value>
102+
raise ValueError(f"invalid enum specifier: {value}")
103+
try:
104+
result = (enum_map or {})[pieces[1]](pieces[2])
105+
except KeyError:
106+
raise ValueError(f"enum '{pieces[1]}' not defined for '{value}'") from None
107+
except ValueError:
108+
raise ValueError(f"value '{pieces[2]}' not valid in '{value}'") from None
109+
else:
110+
return result
111+
72112
if value.startswith("io:"):
73113
return StringIO(value[3:])
74114
return value
75115

76116
if isinstance(value, list):
77117
if value and value[0] == "set:":
78-
return set(parse_value(value) for value in value[1:])
79-
return [parse_value(value) for value in value]
118+
return set(parse_value(value, enum_map) for value in value[1:])
119+
return [parse_value(value, enum_map) for value in value]
80120

81121
if isinstance(value, dict):
82-
return {key: parse_value(value) for key, value in value.items()}
122+
return {key: parse_value(value, enum_map) for key, value in value.items()}
83123

84124
return value
85125

0 commit comments

Comments
 (0)