Skip to content

Commit b8a9ed6

Browse files
authored
Added custom pytest markers to Python tester to record MarkUs metadata (#592)
1 parent 513eb67 commit b8a9ed6

File tree

3 files changed

+96
-1
lines changed

3 files changed

+96
-1
lines changed

Changelog.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ All notable changes to this project will be documented here.
88
- Update "setting up test environment" message with http response of status code 503 (#589)
99
- Change rlimit resource settings to apply each worker individually (#587)
1010
- Improve error reporting with handled assertion errors (#591)
11+
- Add custom pytest markers to Python tester to record MarkUs metadata (#592)
1112

1213
## [v2.6.0]
1314
- Update python versions in docker file (#568)

server/autotest_server/testers/py/py_tester.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,24 @@ def __init__(self) -> None:
7474
Initialize a pytest plugin for collecting results
7575
"""
7676
self.results = {}
77+
self.tags = set()
78+
self.annotations = []
79+
self.overall_comments = []
80+
81+
def pytest_configure(self, config):
82+
"""Register custom markers for use with MarkUs."""
83+
config.addinivalue_line("markers", "markus_tag(name): indicate that the submission should be given a tag")
84+
config.addinivalue_line(
85+
"markers", "markus_annotation(**ann_data): indicate that the submission should be given an annotation"
86+
)
87+
config.addinivalue_line(
88+
"markers",
89+
"markus_overall_comments(comment): indicate that the submission should be given an overall comment",
90+
)
91+
config.addinivalue_line(
92+
"markers",
93+
"markus_message(text): indicate text that is displayed as part of the test output (even on success)",
94+
)
7795

7896
@pytest.hookimpl(hookwrapper=True, tryfirst=True)
7997
def pytest_runtest_makereport(self, item, call):
@@ -96,8 +114,37 @@ def pytest_runtest_makereport(self, item, call):
96114
"errors": str(rep.longrepr) if rep.failed else "",
97115
"description": item.obj.__doc__,
98116
}
117+
118+
# Only check markers at the end of the test case
119+
if not rep.skipped and rep.when == "teardown":
120+
self._process_markers(item)
121+
99122
return rep
100123

124+
def _process_markers(self, item):
125+
"""Process all markers for the given item.
126+
127+
This looks for custom markers used to represent test metadata for MarkUs.
128+
"""
129+
for marker in item.iter_markers():
130+
if marker.name == "markus_tag":
131+
if len(marker.args) > 0:
132+
self.tags.add(marker.args[0].strip())
133+
elif "name" in marker.kwargs:
134+
self.tags.add(marker.kwargs["name"].strip())
135+
elif marker.name == "markus_annotation":
136+
self.annotations.append(marker.kwargs)
137+
elif marker.name == "markus_overall_comments":
138+
if len(marker.args) > 0:
139+
self.overall_comments.append(marker.args[0])
140+
elif "comment" in marker.kwargs:
141+
self.overall_comments.append(marker.kwargs["comment"])
142+
elif marker.name == "markus_message" and marker.args != [] and item.nodeid in self.results:
143+
if self.results[item.nodeid].get("errors"):
144+
self.results[item.nodeid]["errors"] += f"\n\n{marker.args[0]}"
145+
else:
146+
self.results[item.nodeid]["errors"] = marker.args[0]
147+
101148
def pytest_collectreport(self, report):
102149
"""
103150
Implement a pytest hook that is run after the collector has
@@ -170,6 +217,9 @@ def __init__(
170217
This tester will create tests of type test_class.
171218
"""
172219
super().__init__(specs, test_class, resource_settings=resource_settings)
220+
self.annotations = []
221+
self.overall_comments = []
222+
self.tags = set()
173223

174224
@staticmethod
175225
def _load_unittest_tests(test_file: str) -> unittest.TestSuite:
@@ -210,6 +260,9 @@ def _run_pytest_tests(self, test_file: str) -> List[Dict]:
210260
plugin = PytestPlugin()
211261
pytest.main([test_file, f"--tb={verbosity}"], plugins=[plugin])
212262
results.extend(plugin.results.values())
263+
self.annotations = plugin.annotations
264+
self.overall_comments = plugin.overall_comments
265+
self.tags = plugin.tags
213266
finally:
214267
sys.stdout = sys.__stdout__
215268
return results
@@ -237,3 +290,12 @@ def run(self) -> None:
237290
for res in result:
238291
test = self.test_class(self, test_file, res)
239292
print(test.run(), flush=True)
293+
294+
def after_tester_run(self) -> None:
295+
"""Print all MarkUs metadata from the tests."""
296+
if self.annotations:
297+
print(self.test_class.format_annotations(self.annotations))
298+
if self.tags:
299+
print(self.test_class.format_tags(self.tags))
300+
if self.overall_comments:
301+
print(self.test_class.format_overall_comment(self.overall_comments, separator="\n\n"))

server/autotest_server/testers/tester.py

Lines changed: 33 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
1+
from __future__ import annotations
2+
13
import json
24
from abc import ABC, abstractmethod
35
from functools import wraps
4-
from typing import Optional, Callable, Any, Type, Dict, List
6+
from typing import Optional, Callable, Any, Type, Dict, Iterable, List
57
from .specs import TestSpecs
68
import traceback
79
import resource
@@ -99,6 +101,36 @@ def format_annotations(annotation_data: List[Dict[str, Any]]) -> str:
99101
"""
100102
return json.dumps({"annotations": annotation_data})
101103

104+
@staticmethod
105+
def format_overall_comment(overall_comment_data: str | Iterable[str], separator: str = "\n\n") -> str:
106+
"""
107+
Formats overall comment data.
108+
:param overall_comment_data: the contents of the overall comment
109+
:param separator: if overall_comment_data is a collection, use separator to join the elements
110+
:return a json string representation of the tag data.
111+
"""
112+
if isinstance(overall_comment_data, str):
113+
content = overall_comment_data
114+
else:
115+
content = separator.join(overall_comment_data)
116+
return json.dumps({"overall_comment": content})
117+
118+
@staticmethod
119+
def format_tags(tag_data: Iterable[str | dict[str, str]]) -> str:
120+
"""
121+
Formats tag data.
122+
:param tag_data: an iterable of tag data. Each element is either a tag name (str) or a dictionary with
123+
keys "name" and "description".
124+
:return a json string representation of the tag data.
125+
"""
126+
tag_list = []
127+
for tag in tag_data:
128+
if isinstance(tag, str):
129+
tag_list.append({"name": tag})
130+
else:
131+
tag_list.append(tag)
132+
return json.dumps({"tags": tag_list})
133+
102134
def passed_with_bonus(self, points_bonus: int, message: str = "") -> str:
103135
"""
104136
Passes this test earning bonus points in addition to the test total points. If a feedback file is enabled, adds

0 commit comments

Comments
 (0)