Skip to content

Commit f418e10

Browse files
authored
Merge pull request #578 from d3flex/style/ty_checks
chore: add type checks in ci for python
2 parents 26e9d1b + 5fb35c4 commit f418e10

6 files changed

Lines changed: 63 additions & 36 deletions

Makefile

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ test-yaml:
5858
@which yamllint >/dev/null 2>&1 || echo "Command 'yamllint' not found, can not execute YAML syntax checks"
5959
yamllint --strict $$(git ls-files "*.yml" "*.yaml" ":!external/")
6060

61-
checkstyle-python: check-ruff check-conventions
61+
checkstyle-python: check-ruff check-conventions check-ty
6262
check-ruff:
6363
@which ruff >/dev/null 2>&1 || echo "Command 'ruff' not found, can not execute python style checks"
6464
@if [ -n "$(PY_FILES)" ]; then ruff format --check $(PY_FILES) && ruff check $(PY_FILES); fi
@@ -70,6 +70,10 @@ check-conventions:
7070
exit 1; \
7171
fi
7272

73+
.PHONY: check-ty
74+
check-ty: ## Run ty type checker
75+
uv run ty check
76+
7377
check-code-health:
7478
@echo "Checking code health…"
7579
@vulture $$(git ls-files "**.py") --min-confidence 80

check-netbox-unused-machine-power.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,21 @@
77
indicating it may still be powered on despite being decommissioned or unused.
88
"""
99

10+
from __future__ import annotations
11+
1012
import os
1113
import re
1214
import sys
15+
from typing import TYPE_CHECKING
1316
from urllib.parse import urlparse
1417

1518
import netsnmp
1619
import pynetbox
1720
from pynetbox.models import dcim
1821

22+
if TYPE_CHECKING:
23+
from pynetbox.models import dcim
24+
1925
BACHMANN_RELAY_ON = 19 # Bachmann PDU relay status value for "on"
2026

2127
verbose = os.environ.get("VERBOSE") == "1"
@@ -74,7 +80,7 @@ def green(s: str) -> str:
7480
return f"\x1b[32m{s}\x1b[0m"
7581

7682

77-
def print_device(device: dcim.Devices, dev_pdu_power: dict, watts: int) -> None:
83+
def print_device(device: dcim.Devices, dev_pdu_power: dict[str, tuple[str, str]], watts: int) -> None:
7884
"""Report device power consumption per PDU outlet for human review."""
7985
s = " " if verbose else ""
8086
pdu_power = " ".join([f"{h}:{green(p) if s else red(p)}={w}W" for (h, p), (w, s) in dev_pdu_power.items()])

pyproject.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ dev = [
3434
"radon",
3535
"ruff",
3636
"vulture",
37+
"ty>=0.0.21",
3738
]
3839

3940
[build-system]
@@ -145,6 +146,15 @@ allowed-unresolved-imports = ["netsnmp"]
145146
author = "SUSE LLC"
146147
notice-rgx = "Copyright"
147148

149+
[tool.ty.rules]
150+
# sh uses __getattr__ to dynamically wrap system commands (e.g. ping), so its
151+
# members are not statically resolvable. netsnmp is a C extension installed as
152+
# a system package (python3-netsnmp) and has no stubs available on PyPI.
153+
unresolved-import = "ignore"
154+
148155
[tool.ruff.lint.flake8-tidy-imports.banned-api]
149156
"os.system".msg = "Use `subprocess.run` or `sh` library instead."
150157
"subprocess.call".msg = "Use `subprocess.run` with `check=True`."
158+
159+
[tool.ty.terminal]
160+
error-on-warning = true

tests/test_openqa_bats_review.py

Lines changed: 20 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
import importlib.util
99
import pathlib
1010
import sys
11-
from typing import Any
11+
from typing import TYPE_CHECKING, Any
1212
from unittest.mock import Mock
1313

1414
import pytest
1515
from requests.exceptions import RequestException
1616

17+
if TYPE_CHECKING:
18+
from pytest_mock import MockerFixture
19+
1720
# Load the script as module "bats_review" (the file is named `openqa-bats-review`)
1821
rootpath = pathlib.Path(__file__).parent.parent.resolve()
1922
loader = importlib.machinery.SourceFileLoader("bats_review", f"{rootpath}/openqa-bats-review")
@@ -29,7 +32,7 @@
2932
#
3033

3134

32-
def test_get_file_success(mocker: pytest.MockerFixture) -> None:
35+
def test_get_file_success(mocker: MockerFixture) -> None:
3336
mock_session = mocker.patch("bats_review.session")
3437
resp = Mock()
3538
resp.text = "hello"
@@ -46,7 +49,7 @@ def test_get_file_success(mocker: pytest.MockerFixture) -> None:
4649
resp.raise_for_status.assert_called_once()
4750

4851

49-
def test_get_file_request_exception(mocker: pytest.MockerFixture) -> None:
52+
def test_get_file_request_exception(mocker: MockerFixture) -> None:
5053
mock_session = mocker.patch("bats_review.session")
5154
mock_log = mocker.patch("bats_review.log")
5255
mock_session.get.side_effect = RequestException("network")
@@ -56,7 +59,7 @@ def test_get_file_request_exception(mocker: pytest.MockerFixture) -> None:
5659
mock_log.exception.assert_called_once()
5760

5861

59-
def test_get_job_success(mocker: pytest.MockerFixture) -> None:
62+
def test_get_job_success(mocker: MockerFixture) -> None:
6063
with contextlib.suppress(Exception):
6164
bats_review.get_job.cache_clear()
6265
mock_session = mocker.patch("bats_review.session")
@@ -74,7 +77,7 @@ def test_get_job_success(mocker: pytest.MockerFixture) -> None:
7477
)
7578

7679

77-
def test_get_job_request_exception(mocker: pytest.MockerFixture) -> None:
80+
def test_get_job_request_exception(mocker: MockerFixture) -> None:
7881
with contextlib.suppress(Exception):
7982
bats_review.get_job.cache_clear()
8083
mock_session = mocker.patch("bats_review.session")
@@ -86,7 +89,7 @@ def test_get_job_request_exception(mocker: pytest.MockerFixture) -> None:
8689
mock_log.exception.assert_called_once()
8790

8891

89-
def test_grep_failures_success(mocker: pytest.MockerFixture) -> None:
92+
def test_grep_failures_success(mocker: MockerFixture) -> None:
9093
mock_get_file = mocker.patch("bats_review.get_file")
9194
# one passing, one failing testcase (with classname)
9295
mock_get_file.return_value = """
@@ -101,7 +104,7 @@ def test_grep_failures_success(mocker: pytest.MockerFixture) -> None:
101104
assert result == {"suite1:failing_test"}
102105

103106

104-
def test_grep_failures_malformed(mocker: pytest.MockerFixture) -> None:
107+
def test_grep_failures_malformed(mocker: MockerFixture) -> None:
105108
mock_get_file = mocker.patch("bats_review.get_file")
106109
mock_log = mocker.patch("bats_review.log")
107110
mock_get_file.return_value = "<this is not xml"
@@ -112,15 +115,15 @@ def test_grep_failures_malformed(mocker: pytest.MockerFixture) -> None:
112115
mock_log.exception.assert_called_once()
113116

114117

115-
def test_process_logs_single_file(mocker: pytest.MockerFixture) -> None:
118+
def test_process_logs_single_file(mocker: MockerFixture) -> None:
116119
mock_grep = mocker.patch("bats_review.grep_failures")
117120
mock_grep.return_value = {"a", "b"}
118121
res = bats_review.process_logs(["http://example.com/a.xml"])
119122
assert res == {"a", "b"}
120123
mock_grep.assert_called_once_with("http://example.com/a.xml")
121124

122125

123-
def test_process_logs_multiple_files(mocker: pytest.MockerFixture) -> None:
126+
def test_process_logs_multiple_files(mocker: MockerFixture) -> None:
124127
mock_executor_class = mocker.patch("bats_review.ThreadPoolExecutor")
125128
# build fake executor that returns map -> iterator of sets
126129
fake_executor = Mock()
@@ -134,7 +137,7 @@ def test_process_logs_multiple_files(mocker: pytest.MockerFixture) -> None:
134137
fake_executor.map.assert_called_once()
135138

136139

137-
def test_resolve_clone_chain_single(mocker: pytest.MockerFixture) -> None:
140+
def test_resolve_clone_chain_single(mocker: MockerFixture) -> None:
138141
with contextlib.suppress(Exception):
139142
bats_review.get_job.cache_clear()
140143
mock_get_job = mocker.patch("bats_review.get_job")
@@ -144,7 +147,7 @@ def test_resolve_clone_chain_single(mocker: pytest.MockerFixture) -> None:
144147
mock_get_job.assert_called_once_with("http://openqa/api/v1/jobs/123/details")
145148

146149

147-
def test_resolve_clone_chain_multiple(mocker: pytest.MockerFixture) -> None:
150+
def test_resolve_clone_chain_multiple(mocker: MockerFixture) -> None:
148151
with contextlib.suppress(Exception):
149152
bats_review.get_job.cache_clear()
150153
mock_get_job = mocker.patch("bats_review.get_job")
@@ -167,7 +170,7 @@ def side(url: str) -> dict[str, Any] | None:
167170
# main
168171

169172

170-
def test_main_no_clones(mocker: pytest.MockerFixture) -> None:
173+
def test_main_no_clones(mocker: MockerFixture) -> None:
171174
with contextlib.suppress(Exception):
172175
bats_review.get_job.cache_clear()
173176
mock_resolve = mocker.patch("bats_review.resolve_clone_chain")
@@ -179,7 +182,7 @@ def test_main_no_clones(mocker: pytest.MockerFixture) -> None:
179182
mock_log.info.assert_called_with("No clones. Exiting")
180183

181184

182-
def test_main_no_common_failures(mocker: pytest.MockerFixture) -> None:
185+
def test_main_no_common_failures(mocker: MockerFixture) -> None:
183186
"""Two jobs in chain; each produces different failures -> no common failures.
184187
185188
main should log Tagging as PASSED.
@@ -208,7 +211,7 @@ def job_resp(url: str) -> dict[str, Any]:
208211
mock_log.info.assert_called_with("No common failures across clone chain. Tagging as PASSED.")
209212

210213

211-
def test_main_insufficient_logs(mocker: pytest.MockerFixture) -> None:
214+
def test_main_insufficient_logs(mocker: MockerFixture) -> None:
212215
"""If jobs do not have the expected number of logs (e.g. podman expects 4 but provides 2).
213216
214217
main should log the 'only X logs' messages for each job and eventually exit(0).
@@ -235,13 +238,13 @@ def job_resp(url: str) -> dict[str, Any]:
235238
mock_log.info.assert_any_call("No logs found in chain. Exiting")
236239

237240

238-
def test_parse_args_success(mocker: pytest.MockerFixture) -> None:
241+
def test_parse_args_success(mocker: MockerFixture) -> None:
239242
mocker.patch("sys.argv", ["script.py", "http://example.com/tests/123"])
240243
args = bats_review.parse_args()
241244
assert args.url == "http://example.com/tests/123"
242245

243246

244-
def test_parse_args_missing_url(mocker: pytest.MockerFixture) -> None:
247+
def test_parse_args_missing_url(mocker: MockerFixture) -> None:
245248
mocker.patch("sys.argv", ["script.py"])
246249
with pytest.raises(SystemExit):
247250
bats_review.parse_args()
@@ -250,7 +253,7 @@ def test_parse_args_missing_url(mocker: pytest.MockerFixture) -> None:
250253
# Integration test
251254

252255

253-
def test_full_workflow_no_common_failures(mocker: pytest.MockerFixture) -> None:
256+
def test_full_workflow_no_common_failures(mocker: MockerFixture) -> None:
254257
"""Patch session.get to simulate two jobs each with a different failing testcase.
255258
256259
Asserts that the script decides to tag as PASSED (dry_run) when there are no common failures.

tests/test_openqa_llm_investigate.py

Lines changed: 14 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,15 @@
88
import logging
99
import pathlib
1010
import sys
11-
from typing import Any
11+
from typing import TYPE_CHECKING, Any
1212
from unittest.mock import MagicMock, Mock
1313

1414
import httpx
1515
import pytest
1616

17+
if TYPE_CHECKING:
18+
from pytest_mock import MockerFixture
19+
1720
# Load the script as module "llm_investigate" (the file is named `openqa-llm-investigate`)
1821
rootpath = pathlib.Path(__file__).parent.parent.resolve()
1922
loader = importlib.machinery.SourceFileLoader("llm_investigate", f"{rootpath}/openqa-llm-investigate")
@@ -72,7 +75,7 @@ def test_fetch_text_failure() -> None:
7275
assert not res
7376

7477

75-
def test_post_comment(mocker: pytest.MockerFixture) -> None:
78+
def test_post_comment(mocker: MockerFixture) -> None:
7679
mock_run = mocker.patch("llm_investigate.subprocess.run")
7780
llm_investigate.post_comment("http://base", "123", "test comment")
7881
mock_run.assert_called_once()
@@ -82,7 +85,7 @@ def test_post_comment(mocker: pytest.MockerFixture) -> None:
8285
assert "text=test comment" in args
8386

8487

85-
def setup_mock_client(mocker: pytest.MockerFixture, overrides: dict[str, Any] | None = None) -> MagicMock:
88+
def setup_mock_client(mocker: MockerFixture, overrides: dict[str, Any] | None = None) -> MagicMock:
8689
mock_client_class = mocker.patch("llm_investigate.httpx.Client")
8790
mock_client = MagicMock()
8891
mock_client_class.return_value.__enter__.return_value = mock_client
@@ -143,7 +146,7 @@ def mock_post_call(url: str, json: dict[str, Any] | None = None, *args: Any, **k
143146
return mock_client
144147

145148

146-
def test_investigate_cmd(mocker: pytest.MockerFixture) -> None:
149+
def test_investigate_cmd(mocker: MockerFixture) -> None:
147150
mock_post = mocker.patch("llm_investigate.post_comment")
148151
mock_print = mocker.patch("builtins.print")
149152
setup_mock_client(mocker)
@@ -153,7 +156,7 @@ def test_investigate_cmd(mocker: pytest.MockerFixture) -> None:
153156
assert "BISECT: YES" in mock_post.call_args[0][2]
154157

155158

156-
def test_investigate_cmd_passed_job(mocker: pytest.MockerFixture) -> None:
159+
def test_investigate_cmd_passed_job(mocker: MockerFixture) -> None:
157160
mock_print = mocker.patch("builtins.print")
158161
setup_mock_client(mocker, overrides={"api/v1/jobs": {"job": {"id": 123, "result": "passed"}}})
159162

@@ -164,7 +167,7 @@ def test_investigate_cmd_passed_job(mocker: pytest.MockerFixture) -> None:
164167
mock_print.assert_not_called()
165168

166169

167-
def test_investigate_cmd_softfailed_job(mocker: pytest.MockerFixture) -> None:
170+
def test_investigate_cmd_softfailed_job(mocker: MockerFixture) -> None:
168171
mock_print = mocker.patch("builtins.print")
169172
setup_mock_client(mocker, overrides={"api/v1/jobs": {"job": {"id": 123, "result": "softfailed"}}})
170173

@@ -175,7 +178,7 @@ def test_investigate_cmd_softfailed_job(mocker: pytest.MockerFixture) -> None:
175178
mock_print.assert_not_called()
176179

177180

178-
def test_investigate_cmd_already_commented(mocker: pytest.MockerFixture) -> None:
181+
def test_investigate_cmd_already_commented(mocker: MockerFixture) -> None:
179182
mock_log = mocker.patch("llm_investigate.log")
180183
setup_mock_client(mocker, overrides={"comments": [{"text": "**LLM Investigation summary:** already done"}]})
181184

@@ -187,7 +190,7 @@ def test_investigate_cmd_already_commented(mocker: pytest.MockerFixture) -> None
187190
assert "already has an LLM investigation summary" in mock_log.info.call_args[0][0]
188191

189192

190-
def test_investigate_cmd_already_commented_with_force(mocker: pytest.MockerFixture) -> None:
193+
def test_investigate_cmd_already_commented_with_force(mocker: MockerFixture) -> None:
191194
mock_post = mocker.patch("llm_investigate.post_comment")
192195
mock_print = mocker.patch("builtins.print")
193196
setup_mock_client(mocker, overrides={"comments": [{"text": "**LLM Investigation summary:** already done"}]})
@@ -199,7 +202,7 @@ def test_investigate_cmd_already_commented_with_force(mocker: pytest.MockerFixtu
199202
assert "BISECT: YES" in mock_post.call_args[0][2]
200203

201204

202-
def test_investigate_cmd_dry_run(mocker: pytest.MockerFixture) -> None:
205+
def test_investigate_cmd_dry_run(mocker: MockerFixture) -> None:
203206
mock_post = mocker.patch("llm_investigate.post_comment")
204207
mock_print = mocker.patch("builtins.print")
205208
client = setup_mock_client(mocker)
@@ -214,7 +217,7 @@ def test_investigate_cmd_dry_run(mocker: pytest.MockerFixture) -> None:
214217
mock_print.assert_any_call("**LLM Investigation summary:**\n\nBISECT: NO. Already known.")
215218

216219

217-
def test_investigate_cmd_connection_error(mocker: pytest.MockerFixture) -> None:
220+
def test_investigate_cmd_connection_error(mocker: MockerFixture) -> None:
218221
mock_log = mocker.patch("llm_investigate.log")
219222
client = setup_mock_client(mocker)
220223
client.post.side_effect = httpx.ConnectError("Connection refused")
@@ -227,7 +230,7 @@ def test_investigate_cmd_connection_error(mocker: pytest.MockerFixture) -> None:
227230
assert "Could not connect to LLM server" in mock_log.error.call_args[0][0]
228231

229232

230-
def test_investigate_logging_levels(mocker: pytest.MockerFixture) -> None:
233+
def test_investigate_logging_levels(mocker: MockerFixture) -> None:
231234
mock_basic_config = mocker.patch("llm_investigate.logging.basicConfig")
232235
mocker.patch("llm_investigate.httpx.Client")
233236
mock_fetch = mocker.patch("llm_investigate.fetch_json")

tests/test_trigger_bisect_jobs.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import re
1010
import subprocess # noqa: S404
1111
from argparse import Namespace
12-
from typing import Any
12+
from typing import Any, cast
1313
from unittest.mock import MagicMock, call, patch
1414
from urllib.parse import urlparse
1515

@@ -20,7 +20,8 @@
2020

2121
loader = importlib.machinery.SourceFileLoader("openqa", f"{rootpath}/openqa-trigger-bisect-jobs")
2222
spec = importlib.util.spec_from_loader(loader.name, loader)
23-
openqa = importlib.util.module_from_spec(spec)
23+
assert spec is not None
24+
openqa = cast("Any", importlib.util.module_from_spec(spec))
2425
loader.exec_module(openqa)
2526

2627
Incident = openqa.Incident
@@ -36,11 +37,11 @@ def args_factory() -> Namespace:
3637

3738
def mocked_fetch_url(url: str, request_type: str = "text") -> Any:
3839
content = ""
39-
url = urlparse(url)
40+
parsed = urlparse(url)
4041

41-
if url.scheme in {"http", "https"}:
42-
path = url.geturl()
43-
path = path[len(url.scheme) + 3 :]
42+
if parsed.scheme in {"http", "https"}:
43+
path = parsed.geturl()
44+
path = path[len(parsed.scheme) + 3 :]
4445
path = "tests/data/python-requests/" + path
4546
content = pathlib.Path(path).read_text(encoding="utf-8")
4647
if request_type == "json":

0 commit comments

Comments
 (0)