Skip to content

Commit af6205f

Browse files
authored
Add infra pytest warnings plugin (#2757)
This PR introduce the plugin feature to capture and format pytest warnings to process it in internal CI. These changes will not add any overhead to existing pytest scope. This feature can be fully enabled/disabled by env var - `DPNP_INFRA_WARNINGS_ENABLE`
1 parent 70832ef commit af6205f

File tree

3 files changed

+252
-0
lines changed

3 files changed

+252
-0
lines changed

dpnp/tests/config.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,15 @@
44
float16_types = bool(os.getenv("DPNP_TEST_FLOAT_16", 0))
55
complex_types = bool(os.getenv("DPNP_TEST_COMPLEX_TYPES", 0))
66
bool_types = bool(os.getenv("DPNP_TEST_BOOL_TYPES", 0))
7+
8+
9+
infra_warnings_enable = bool(os.getenv("DPNP_INFRA_WARNINGS_ENABLE", 0))
10+
infra_warnings_directory = os.getenv("DPNP_INFRA_WARNINGS_DIRECTORY", None)
11+
infra_warnings_events_artifact = os.getenv(
12+
"DPNP_INFRA_WARNINGS_EVENTS_ARTIFACT",
13+
"dpnp_infra_warnings_events.jsonl",
14+
)
15+
infra_warnings_summary_artifact = os.getenv(
16+
"DPNP_INFRA_WARNINGS_SUMMARY_ARTIFACT",
17+
"dpnp_infra_warnings_summary.json",
18+
)

dpnp/tests/conftest.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import dpnp
4545

4646
from .helper import get_dev_id
47+
from .infra_warning_utils import register_infra_warnings_plugin_if_enabled
4748

4849
skip_mark = pytest.mark.skip(reason="Skipping test.")
4950

@@ -114,6 +115,8 @@ def pytest_configure(config):
114115
"ignore:invalid value encountered in arccosh:RuntimeWarning",
115116
)
116117

118+
register_infra_warnings_plugin_if_enabled(config)
119+
117120

118121
def pytest_collection_modifyitems(config, items):
119122
test_path = os.path.split(__file__)[0]

dpnp/tests/infra_warning_utils.py

Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import json
2+
import os
3+
import sys
4+
from collections import Counter
5+
from pathlib import Path
6+
7+
import dpctl
8+
import numpy
9+
10+
import dpnp
11+
12+
from . import config as warn_config
13+
14+
15+
def _origin_from_filename(filename: str) -> str:
16+
file = (filename or "").replace("\\", "/")
17+
if "/dpnp/" in file or file.startswith("dpnp/"):
18+
return "dpnp"
19+
if "/numpy/" in file or file.startswith("numpy/"):
20+
return "numpy"
21+
if "/dpctl/" in file or file.startswith("dpctl/"):
22+
return "dpctl"
23+
return "third_party"
24+
25+
26+
def _json_dumps_one_line(obj) -> str:
27+
return json.dumps(obj, separators=(",", ":"))
28+
29+
30+
class DpnpInfraWarningsPlugin:
31+
"""Pytest custom plugin that records pytest-captured warnings.
32+
33+
It only records what pytest already captures (via pytest_warning_recorded).
34+
Does not change warnings filters.
35+
36+
Env vars:
37+
- DPNP_INFRA_WARNINGS_ENABLE=1 (enables the plugin)
38+
- DPNP_INFRA_WARNINGS_DIRECTORY=<dir> (writes artifacts)
39+
- DPNP_INFRA_WARNINGS_EVENTS_ARTIFACT (optional filename)
40+
- DPNP_INFRA_WARNINGS_SUMMARY_ARTIFACT (optional filename)
41+
"""
42+
43+
SUMMARY_BEGIN = "DPNP_WARNINGS_SUMMARY_BEGIN"
44+
SUMMARY_END = "DPNP_WARNINGS_SUMMARY_END"
45+
EVENT_PREFIX = "DPNP_WARNING_EVENT - "
46+
47+
def __init__(self):
48+
self.enabled = warn_config.infra_warnings_enable
49+
self.directory = warn_config.infra_warnings_directory
50+
self.events_artifact = warn_config.infra_warnings_events_artifact
51+
self.summary_artifact = warn_config.infra_warnings_summary_artifact
52+
53+
self._counts = Counter()
54+
self._warnings = {}
55+
self._totals = Counter()
56+
self._env = {}
57+
58+
self._events_fp = None
59+
self._events_file = None
60+
self._summary_file = None
61+
62+
def _log_stdout(self, message: str) -> None:
63+
try:
64+
sys.stderr.write(message.rstrip("\n") + "\n")
65+
sys.stderr.flush()
66+
except Exception:
67+
pass
68+
69+
def pytest_configure(self):
70+
if not self.enabled:
71+
return
72+
73+
self._env.update(
74+
{
75+
"numpy_version": getattr(numpy, "__version__", "unknown"),
76+
"numpy_path": getattr(numpy, "__file__", "unknown"),
77+
"dpnp_version": getattr(dpnp, "__version__", "unknown"),
78+
"dpnp_path": getattr(dpnp, "__file__", "unknown"),
79+
"dpctl_version": getattr(dpctl, "__version__", "unknown"),
80+
"dpctl_path": getattr(dpctl, "__file__", "unknown"),
81+
"job": os.getenv("JOB_NAME", "unknown"),
82+
"build_number": os.getenv("BUILD_NUMBER", "unknown"),
83+
"git_sha": os.getenv("GIT_COMMIT", "unknown"),
84+
}
85+
)
86+
87+
if self.directory:
88+
try:
89+
p = Path(self.directory).expanduser().resolve()
90+
if p.exists() and not p.is_dir():
91+
raise ValueError(f"{p} exists and is not a directory")
92+
93+
p.mkdir(parents=True, exist_ok=True)
94+
95+
if (
96+
not self.events_artifact
97+
or Path(self.events_artifact).name != self.events_artifact
98+
):
99+
raise ValueError(
100+
f"Invalid events artifact filename: {self.events_artifact}"
101+
)
102+
103+
if (
104+
not self.summary_artifact
105+
or Path(self.summary_artifact).name != self.summary_artifact
106+
):
107+
raise ValueError(
108+
f"Invalid summary artifact filename: {self.summary_artifact}"
109+
)
110+
111+
self._events_file = p / self.events_artifact
112+
self._events_fp = self._events_file.open(
113+
mode="w", encoding="utf-8", buffering=1, newline="\n"
114+
)
115+
self._summary_file = p / self.summary_artifact
116+
except Exception as exc:
117+
self._close_events_fp()
118+
self._log_stdout(
119+
"DPNP infra warnings plugin: artifacts disabled "
120+
f"(failed to initialize directory/files): {exc}"
121+
)
122+
123+
def pytest_warning_recorded(self, warning_message, when, nodeid, location):
124+
if not self.enabled:
125+
return
126+
127+
category = getattr(
128+
getattr(warning_message, "category", None),
129+
"__name__",
130+
str(getattr(warning_message, "category", "Warning")),
131+
)
132+
message = str(getattr(warning_message, "message", warning_message))
133+
134+
filename = getattr(warning_message, "filename", None) or (
135+
location[0] if location and len(location) > 0 else None
136+
)
137+
lineno = getattr(warning_message, "lineno", None) or (
138+
location[1] if location and len(location) > 1 else None
139+
)
140+
func = location[2] if location and len(location) > 2 else None
141+
142+
origin = _origin_from_filename(filename or "")
143+
key = f"{category}||{origin}||{message}"
144+
self._counts[key] += 1
145+
self._totals[f"category::{category}"] += 1
146+
self._totals[f"origin::{origin}"] += 1
147+
self._totals[f"phase::{when}"] += 1
148+
149+
if key not in self._warnings:
150+
self._warnings[key] = {
151+
"category": category,
152+
"origin": origin,
153+
"when": when,
154+
"nodeid": nodeid,
155+
"filename": filename,
156+
"lineno": lineno,
157+
"function": func,
158+
"message": message,
159+
}
160+
161+
event = {
162+
"when": when,
163+
"nodeid": nodeid,
164+
"category": category,
165+
"origin": origin,
166+
"message": message,
167+
"filename": filename,
168+
"lineno": lineno,
169+
"function": func,
170+
}
171+
172+
if self._events_fp is not None:
173+
try:
174+
self._events_fp.write(_json_dumps_one_line(event) + "\n")
175+
except Exception:
176+
pass
177+
178+
self._log_stdout(f"{self.EVENT_PREFIX} {_json_dumps_one_line(event)}")
179+
180+
def pytest_terminal_summary(self, terminalreporter, exitstatus):
181+
if not self.enabled:
182+
return
183+
184+
summary = {
185+
"schema_version": "1.0",
186+
"exit_status": exitstatus,
187+
"environment": dict(self._env),
188+
"total_warning_events": int(sum(self._counts.values())),
189+
"unique_warning_types": int(len(self._counts)),
190+
"totals": dict(self._totals),
191+
"top_unique_warnings": [
192+
dict(self._warnings[k], count=c)
193+
for k, c in self._counts.most_common(50)
194+
if k in self._warnings
195+
],
196+
}
197+
198+
if self._summary_file:
199+
try:
200+
with open(self._summary_file, "w", encoding="utf-8") as f:
201+
json.dump(summary, f, indent=2, sort_keys=True)
202+
terminalreporter.write_line(
203+
f"DPNP infrastructure warnings summary written to: {self._summary_file}"
204+
)
205+
except Exception as exc:
206+
terminalreporter.write_line(
207+
f"Failed to write DPNP infrastructure warnings summary to: {self._summary_file}. Error: {exc}"
208+
)
209+
210+
self._close_events_fp()
211+
terminalreporter.write_line(self.SUMMARY_BEGIN)
212+
terminalreporter.write_line(_json_dumps_one_line(summary))
213+
terminalreporter.write_line(self.SUMMARY_END)
214+
215+
def pytest_unconfigure(self):
216+
self._close_events_fp()
217+
218+
def _close_events_fp(self):
219+
if self._events_fp is None:
220+
return
221+
try:
222+
self._events_fp.close()
223+
finally:
224+
self._events_fp = None
225+
226+
227+
def register_infra_warnings_plugin_if_enabled(config) -> None:
228+
"""Register infra warnings plugin if enabled via env var."""
229+
230+
if not warn_config.infra_warnings_enable:
231+
return
232+
233+
plugin_name = "dpnp-infra-warnings"
234+
if config.pluginmanager.get_plugin(plugin_name) is not None:
235+
return
236+
237+
config.pluginmanager.register(DpnpInfraWarningsPlugin(), plugin_name)

0 commit comments

Comments
 (0)