From 91a40a09242ec8403b67f2b82455760b4f55cf71 Mon Sep 17 00:00:00 2001 From: PhinehasNarh Date: Sat, 27 Jun 2026 14:24:00 +0000 Subject: [PATCH 1/5] chore: add type annotations to cloudinit.distros.parsers.hosts Enable mypy check_untyped_defs for cloudinit/distros/parsers/hosts.py and its test module by adding full type annotations. Changes: - Replace None-sentinel _contents with a _parsed bool flag and an empty-list default, matching the pattern used in resolv_conf.py (GH-6909) - Annotate all method signatures with parameter and return types - Use List/Tuple/Any from typing for Python 3.8 compatibility - Rename shadowed variable in __str__ to avoid reassignment type mismatch - Change add_entry to store option components as a list instead of a tuple to keep _contents type consistent - Remove cloudinit.distros.parsers.hosts and tests.unittests.distros.test_hosts from the mypy check_untyped_defs=false overrides in pyproject.toml Closes part of #5445 --- cloudinit/distros/parsers/hosts.py | 38 +++++++++++++++++------------- pyproject.toml | 2 -- 2 files changed, 21 insertions(+), 19 deletions(-) diff --git a/cloudinit/distros/parsers/hosts.py b/cloudinit/distros/parsers/hosts.py index 8d2f73ac91f..c28ff96815b 100644 --- a/cloudinit/distros/parsers/hosts.py +++ b/cloudinit/distros/parsers/hosts.py @@ -5,6 +5,7 @@ # This file is part of cloud-init. See LICENSE file for license information. from io import StringIO +from typing import Any, List, Tuple from cloudinit.distros.parsers import chop_comment @@ -13,17 +14,19 @@ # or https://linux.die.net/man/5/hosts # or https://www.freebsd.org/doc/en_US.ISO8859-1/books/handbook/configtuning-configfiles.html # noqa class HostsConf: - def __init__(self, text): + def __init__(self, text: str) -> None: self._text = text - self._contents = None + self._parsed: bool = False + self._contents: List[Tuple[str, List[Any]]] = [] - def parse(self): - if self._contents is None: + def parse(self) -> None: + if not self._parsed: self._contents = self._parse(self._text) + self._parsed = True - def get_entry(self, ip): + def get_entry(self, ip: str) -> List[List[str]]: self.parse() - options = [] + options: List[List[str]] = [] for line_type, components in self._contents: if line_type == "option": (pieces, _tail) = components @@ -31,9 +34,9 @@ def get_entry(self, ip): options.append(pieces[1:]) return options - def del_entries(self, ip): + def del_entries(self, ip: str) -> None: self.parse() - n_entries = [] + n_entries: List[Tuple[str, List[Any]]] = [] for line_type, components in self._contents: if line_type != "option": n_entries.append((line_type, components)) @@ -46,14 +49,16 @@ def del_entries(self, ip): n_entries.append((line_type, list(components))) self._contents = n_entries - def add_entry(self, ip, canonical_hostname, *aliases): + def add_entry( + self, ip: str, canonical_hostname: str, *aliases: str + ) -> None: self.parse() self._contents.append( - ("option", ([ip, canonical_hostname] + list(aliases), "")) + ("option", [[ip, canonical_hostname] + list(aliases), ""]) ) - def _parse(self, contents): - entries = [] + def _parse(self, contents: str) -> List[Tuple[str, List[Any]]]: + entries: List[Tuple[str, List[Any]]] = [] for line in contents.splitlines(): if not len(line.strip()): entries.append(("blank", [line])) @@ -65,7 +70,7 @@ def _parse(self, contents): entries.append(("option", [head.split(None), tail])) return entries - def __str__(self): + def __str__(self) -> str: self.parse() contents = StringIO() for line_type, components in self._contents: @@ -74,8 +79,7 @@ def __str__(self): elif line_type == "all_comment": contents.write("%s\n" % (components[0])) elif line_type == "option": - (pieces, tail) = components - pieces = [str(p) for p in pieces] - pieces = "\t".join(pieces) - contents.write("%s%s\n" % (pieces, tail)) + (raw_pieces, tail) = components + str_pieces = [str(p) for p in raw_pieces] + contents.write("%s%s\n" % ("\t".join(str_pieces), tail)) return contents.getvalue() diff --git a/pyproject.toml b/pyproject.toml index b4839e7385a..8f08187ca8d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -52,7 +52,6 @@ module = [ "cloudinit.distros.bsd", "cloudinit.distros.opensuse", "cloudinit.distros.parsers.hostname", - "cloudinit.distros.parsers.hosts", "cloudinit.distros.parsers.resolv_conf", "cloudinit.distros.ug_util", "cloudinit.helpers", @@ -114,7 +113,6 @@ module = [ "tests.unittests.config.test_cc_zypper_add_repo", "tests.unittests.config.test_modules", "tests.unittests.config.test_schema", - "tests.unittests.distros.test_hosts", "tests.unittests.distros.test_ifconfig", "tests.unittests.distros.test_netbsd", "tests.unittests.distros.test_netconfig", From a2f40a0aa9b16babc9b0217eda2a5724d2f18211 Mon Sep 17 00:00:00 2001 From: PhinehasNarh Date: Tue, 30 Jun 2026 09:55:08 +0000 Subject: [PATCH 2/5] chore: address review feedback for type annotations in hosts.py - Drop the _parsed flag; rely on truthiness of _contents instead (an empty list is falsy, so parse() only runs once on a non-empty file) - Remove unnecessary parentheses from all tuple-unpacking assignments - Swap the %-format in __str__ for an f-string (extract join to a variable first, since backslash in f-string expressions requires 3.12+) - Remove spurious parens around single-arg % format in blank/all_comment branches (black-clean, no behaviour change) --- cloudinit/distros/parsers/hosts.py | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/cloudinit/distros/parsers/hosts.py b/cloudinit/distros/parsers/hosts.py index c28ff96815b..2528264d1e8 100644 --- a/cloudinit/distros/parsers/hosts.py +++ b/cloudinit/distros/parsers/hosts.py @@ -16,20 +16,18 @@ class HostsConf: def __init__(self, text: str) -> None: self._text = text - self._parsed: bool = False self._contents: List[Tuple[str, List[Any]]] = [] def parse(self) -> None: - if not self._parsed: + if not self._contents: self._contents = self._parse(self._text) - self._parsed = True def get_entry(self, ip: str) -> List[List[str]]: self.parse() options: List[List[str]] = [] for line_type, components in self._contents: if line_type == "option": - (pieces, _tail) = components + pieces, _tail = components if len(pieces) and pieces[0] == ip: options.append(pieces[1:]) return options @@ -42,7 +40,7 @@ def del_entries(self, ip: str) -> None: n_entries.append((line_type, components)) continue else: - (pieces, _tail) = components + pieces, _tail = components if len(pieces) and pieces[0] == ip: pass elif len(pieces): @@ -63,7 +61,7 @@ def _parse(self, contents: str) -> List[Tuple[str, List[Any]]]: if not len(line.strip()): entries.append(("blank", [line])) continue - (head, tail) = chop_comment(line.strip(), "#") + head, tail = chop_comment(line.strip(), "#") if not len(head): entries.append(("all_comment", [line])) continue @@ -75,11 +73,12 @@ def __str__(self) -> str: contents = StringIO() for line_type, components in self._contents: if line_type == "blank": - contents.write("%s\n" % (components[0])) + contents.write("%s\n" % components[0]) elif line_type == "all_comment": - contents.write("%s\n" % (components[0])) + contents.write("%s\n" % components[0]) elif line_type == "option": - (raw_pieces, tail) = components + raw_pieces, tail = components str_pieces = [str(p) for p in raw_pieces] - contents.write("%s%s\n" % ("\t".join(str_pieces), tail)) + joined = "\t".join(str_pieces) + contents.write(f"{joined}{tail}\n") return contents.getvalue() From c43a2217a2e22c1e130b95c4fcb766cfc03b22c2 Mon Sep 17 00:00:00 2001 From: PhinehasNarh Date: Tue, 30 Jun 2026 14:39:31 +0000 Subject: [PATCH 3/5] fix(lint): shorten comment exceeding 79-char ruff E501 limit Line 57 of test_cc_phone_home.py was 89 characters, causing ruff E501 in the check_format CI step. Trimmed the trailing clause from the comment; the context above (GH pytest-dev/pytest#14650 reference on the next line) already explains the full intent. --- tests/unittests/config/test_cc_phone_home.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unittests/config/test_cc_phone_home.py b/tests/unittests/config/test_cc_phone_home.py index 9c07d23b8dc..e9818b36cc7 100644 --- a/tests/unittests/config/test_cc_phone_home.py +++ b/tests/unittests/config/test_cc_phone_home.py @@ -54,7 +54,7 @@ def test_no_url(self, m_readurl, caplog): (0, -1), (1, 0), (2, 1), - # override parametrized id to differentiate str "2" from int 2 in former test + # override parametrized id to differentiate str "2" from int 2 # GH pytest-dev/pytest#14650. pytest.param("2", 1, id="retries-as-int-str"), ("two", 9), From 3a5083fce61d379645c5618ece0d13813ac8f41d Mon Sep 17 00:00:00 2001 From: PhinehasNarh Date: Tue, 30 Jun 2026 14:47:28 +0000 Subject: [PATCH 4/5] ci: re-trigger checks From d3e5eb5894bf28e74b4b5b0e7b90d071d042ee9b Mon Sep 17 00:00:00 2001 From: PhinehasNarh Date: Tue, 30 Jun 2026 15:36:48 +0000 Subject: [PATCH 5/5] fix(mypy): use distinct variable for str(HostsConf) in test_hosts mypy now checks test_hosts.py (removed from the override list in this PR). Line 24 reused 'eh' for str(eh), causing an incompatible-types error since mypy inferred 'eh' as HostsConf from the construction on line 18. Rename to 'eh_str' to give the string its own binding. --- tests/unittests/distros/test_hosts.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unittests/distros/test_hosts.py b/tests/unittests/distros/test_hosts.py index 7fd5abf2bd9..30c00d9c08a 100644 --- a/tests/unittests/distros/test_hosts.py +++ b/tests/unittests/distros/test_hosts.py @@ -21,8 +21,8 @@ def test_parse(self): ["foo.mydomain.org", "foo"], ["bar.mydomain.org", "bar"], ] - eh = str(eh) - assert eh.startswith("# Example") + eh_str = str(eh) + assert eh_str.startswith("# Example") def test_add(self): eh = hosts.HostsConf(BASE_ETC)