Skip to content

Commit 16a0db1

Browse files
committed
installer: typecheck of AnswerFile dict
Note the `type: ignore[call-arg]` in test_fixtures is here to overcome a bug/limitation in mypy 1.15.0: in callable_marker() it seems to consider that `value(**params)` produces at least one argument. Signed-off-by: Yann Dirson <[email protected]>
1 parent feebb8a commit 16a0db1

File tree

4 files changed

+63
-25
lines changed

4 files changed

+63
-25
lines changed

data.py-dist

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import os
77
from typing import Any, TYPE_CHECKING
88

99
if TYPE_CHECKING:
10-
from lib.typing import IsoImageDef
10+
from lib.typing import SimpleAnswerfileDict, IsoImageDef
1111

1212
# Default user and password to connect to a host through XAPI
1313
# Note: this won't be used for SSH.
@@ -227,7 +227,7 @@ LVMOISCSI_DEVICE_CONFIG: dict[str, dict[str, str]] = {
227227
# 'SCSIid': 'id'
228228
}
229229

230-
BASE_ANSWERFILES = dict(
230+
BASE_ANSWERFILES: dict[str, "SimpleAnswerfileDict"] = dict(
231231
INSTALL={
232232
"TAG": "installation",
233233
"CONTENTS": (

lib/installer.py

Lines changed: 32 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,42 +1,47 @@
1+
from __future__ import annotations
2+
13
import logging
24
import time
35
import xml.etree.ElementTree as ET
6+
from typing import cast, Optional, Sequence, Union
47

58
from lib.commands import ssh, SSHCommandFailed
69
from lib.common import wait_for
10+
from lib.typing import AnswerfileDict, Self, SimpleAnswerfileDict
711

812
class AnswerFile:
9-
def __init__(self, kind, /):
13+
def __init__(self, kind: str, /) -> None:
1014
from data import BASE_ANSWERFILES
11-
defn = BASE_ANSWERFILES[kind]
15+
defn: SimpleAnswerfileDict = BASE_ANSWERFILES[kind]
1216
self.defn = self._normalize_structure(defn)
1317

14-
def write_xml(self, filename):
18+
def write_xml(self, filename: str) -> None:
1519
etree = ET.ElementTree(self._defn_to_xml_et(self.defn))
1620
etree.write(filename)
1721

1822
# chainable mutators for lambdas
1923

20-
def top_append(self, *defs):
24+
def top_append(self, *defs: Union[SimpleAnswerfileDict, None, ValueError]) -> Self:
25+
assert not isinstance(self.defn['CONTENTS'], str), "a toplevel CONTENTS must be a list"
2126
for defn in defs:
2227
if defn is None:
2328
continue
2429
self.defn['CONTENTS'].append(self._normalize_structure(defn))
2530
return self
2631

27-
def top_setattr(self, attrs):
32+
def top_setattr(self, attrs: "dict[str, str]") -> Self:
2833
assert 'CONTENTS' not in attrs
29-
self.defn.update(attrs)
34+
self.defn.update(cast(AnswerfileDict, attrs))
3035
return self
3136

3237
# makes a mutable deep copy of all `contents`
3338
@staticmethod
34-
def _normalize_structure(defn):
39+
def _normalize_structure(defn: Union[SimpleAnswerfileDict, ValueError]) -> AnswerfileDict:
3540
assert isinstance(defn, dict), f"{defn!r} is not a dict"
3641
assert 'TAG' in defn, f"{defn} has no TAG"
3742

3843
# type mutation through nearly-shallow copy
39-
new_defn = {
44+
new_defn: AnswerfileDict = {
4045
'TAG': defn['TAG'],
4146
'CONTENTS': [],
4247
}
@@ -45,29 +50,37 @@ def _normalize_structure(defn):
4550
if isinstance(value, str):
4651
new_defn['CONTENTS'] = value
4752
else:
53+
value_as_sequence: Sequence["SimpleAnswerfileDict"]
54+
if isinstance(value, Sequence):
55+
value_as_sequence = value
56+
else:
57+
value_as_sequence = (
58+
cast(SimpleAnswerfileDict, value),
59+
)
4860
new_defn['CONTENTS'] = [
4961
AnswerFile._normalize_structure(item)
50-
for item in value
62+
for item in value_as_sequence
5163
if item is not None
5264
]
5365
elif key == 'TAG':
5466
pass # already copied
5567
else:
56-
new_defn[key] = value
68+
new_defn[key] = value # type: ignore[literal-required]
5769

5870
return new_defn
5971

6072
# convert to a ElementTree.Element tree suitable for further
6173
# modification before we serialize it to XML
6274
@staticmethod
63-
def _defn_to_xml_et(defn, /, *, parent=None):
75+
def _defn_to_xml_et(defn: AnswerfileDict, /, *, parent: Optional[ET.Element] = None) -> ET.Element:
6476
assert isinstance(defn, dict)
65-
defn = dict(defn)
66-
name = defn.pop('TAG')
77+
defn_copy = dict(defn)
78+
name = defn_copy.pop('TAG')
6779
assert isinstance(name, str)
68-
contents = defn.pop('CONTENTS', ())
80+
contents = cast(Union[str, "list[AnswerfileDict]"], defn_copy.pop('CONTENTS', []))
6981
assert isinstance(contents, (str, list))
70-
element = ET.Element(name, {}, **defn)
82+
defn_filtered = cast("dict[str, str]", defn_copy)
83+
element = ET.Element(name, {}, **defn_filtered)
7184
if parent is not None:
7285
parent.append(element)
7386
if isinstance(contents, str):
@@ -77,7 +90,7 @@ def _defn_to_xml_et(defn, /, *, parent=None):
7790
AnswerFile._defn_to_xml_et(content, parent=element)
7891
return element
7992

80-
def poweroff(ip):
93+
def poweroff(ip: str) -> None:
8194
try:
8295
ssh(ip, ["poweroff"])
8396
except SSHCommandFailed as e:
@@ -88,7 +101,7 @@ def poweroff(ip):
88101
else:
89102
raise
90103

91-
def monitor_install(*, ip):
104+
def monitor_install(*, ip: str) -> None:
92105
# wait for "yum install" phase to finish
93106
wait_for(lambda: ssh(ip, ["grep",
94107
"'DISPATCH: NEW PHASE: Completing installation'",
@@ -112,7 +125,7 @@ def monitor_install(*, ip):
112125
).returncode == 1,
113126
"Wait for installer to terminate")
114127

115-
def monitor_upgrade(*, ip):
128+
def monitor_upgrade(*, ip: str) -> None:
116129
# wait for "yum install" phase to start
117130
wait_for(lambda: ssh(ip, ["grep",
118131
"'DISPATCH: NEW PHASE: Reading package information'",
@@ -145,7 +158,7 @@ def monitor_upgrade(*, ip):
145158
).returncode == 1,
146159
"Wait for installer to terminate")
147160

148-
def monitor_restore(*, ip):
161+
def monitor_restore(*, ip: str) -> None:
149162
# wait for "yum install" phase to start
150163
wait_for(lambda: ssh(ip, ["grep",
151164
"'Restoring backup'",

lib/typing.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,39 @@
1+
from __future__ import annotations
2+
13
import sys
2-
from typing import TypedDict
4+
from typing import Sequence, TypedDict, Union
35

46
if sys.version_info >= (3, 11):
5-
from typing import NotRequired
7+
from typing import NotRequired, Self
68
else:
7-
from typing_extensions import NotRequired
9+
from typing_extensions import NotRequired, Self
810

911
IsoImageDef = TypedDict('IsoImageDef',
1012
{'path': str,
1113
'net-url': NotRequired[str],
1214
'net-only': NotRequired[bool],
1315
'unsigned': NotRequired[bool],
1416
})
17+
18+
19+
# Dict-based description of an Answerfile object to be built.
20+
AnswerfileDict = TypedDict('AnswerfileDict', {
21+
'TAG': str,
22+
'CONTENTS': Union[str, "list[AnswerfileDict]"],
23+
})
24+
25+
# Simplified version of AnswerfileDict for user input.
26+
# - does not require to write 0 or 1 subelement as a list
27+
SimpleAnswerfileDict = TypedDict('SimpleAnswerfileDict', {
28+
'TAG': str,
29+
'CONTENTS': NotRequired[Union[str, "SimpleAnswerfileDict", Sequence["SimpleAnswerfileDict"]]],
30+
31+
# No way to allow arbitrary fields in addition? This conveys the
32+
# field's type, but allows them in places we wouldn't want them,
33+
# and forces every XML attribute we use to appear here.
34+
'guest-storage': NotRequired[str],
35+
'mode': NotRequired[str],
36+
'name': NotRequired[str],
37+
'proto': NotRequired[str],
38+
'type': NotRequired[str],
39+
})

tests/install/test_fixtures.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55

66
# test the answerfile fixture can run on 2 parametrized instances
77
# of the test in one run
8-
@pytest.mark.answerfile(lambda: AnswerFile("INSTALL").top_append(
8+
@pytest.mark.answerfile(lambda: AnswerFile("INSTALL").top_append( # type: ignore[call-arg]
99
{"TAG": "source", "type": "local"},
1010
{"TAG": "primary-disk", "CONTENTS": "nvme0n1"},
1111
))

0 commit comments

Comments
 (0)