Skip to content

Commit 9b10e2e

Browse files
authored
use pytest-reportlog to generate the CI message (#171)
* refactor the parser script to make use of reportlog * use reportlog instead of capturing stdout * intentionally fail for test purposes [skip-ci][test-upstream] * fix the workflow [skip-ci][test-upstream] * install reportlog [skip-ci][test-upstream] * fix the option [skip-ci][test-upstream] * try to get the output step to work [skip-ci][test-upstream] * show the output of the log parser * [skip-ci][test-upstream] * pass the proper path [skip-ci][test-upstream] * properly setup the environment (since we need pytest now) * [skip-ci][test-upstream] * remove the failing import [skip-ci][test-upstream] * remove the intentional failure * undo the debug step [skip-ci][test-upstream]
1 parent 60e623f commit 9b10e2e

File tree

2 files changed

+108
-90
lines changed

2 files changed

+108
-90
lines changed

.github/workflows/nightly.yml

+16-8
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,7 @@ jobs:
7070
- name: install dependencies
7171
run: |
7272
python -m pip install -r ci/requirements.txt
73+
python -m pip install pytest-reportlog
7374
7475
- name: install upstream-dev dependencies
7576
run: bash ci/install-upstream-dev.sh
@@ -84,11 +85,8 @@ jobs:
8485
id: status
8586
run: |
8687
set -euo pipefail
87-
python -c 'import pint_xarray' | tee output-${{ matrix.python-version }}-log || (
88-
echo '::set-output name=ARTIFACTS_AVAILABLE::true' && false
89-
)
90-
python -m pytest -rf | tee -a output-${{ matrix.python-version }}-log || (
91-
echo '::set-output name=ARTIFACTS_AVAILABLE::true' && false
88+
python -m pytest -rf --report-log output-${{ matrix.python-version }}-log.jsonl || (
89+
echo '::set-output name=ARTIFACTS_AVAILABLE::true' && false
9290
)
9391
9492
- name: Upload artifacts
@@ -98,8 +96,8 @@ jobs:
9896
&& github.event_name == 'schedule'
9997
uses: actions/upload-artifact@v2
10098
with:
101-
name: output-${{ matrix.python-version }}-log
102-
path: output-${{ matrix.python-version }}-log
99+
name: output-${{ matrix.python-version }}-log.jsonl
100+
path: output-${{ matrix.python-version }}-log.jsonl
103101
retention-days: 5
104102

105103
report:
@@ -114,21 +112,31 @@ jobs:
114112
steps:
115113
- name: checkout the repository
116114
uses: actions/checkout@v3
115+
117116
- name: setup python
118117
uses: actions/setup-python@v3
119118
with:
120119
python-version: "3.x"
120+
121+
- name: setup environment
122+
run: |
123+
python -m pip install --upgrade pip
124+
python -m pip install pytest
125+
121126
- uses: actions/download-artifact@v2
122127
with:
123128
path: /tmp/workspace/logs
129+
124130
- name: Move all log files into a single directory
125131
run: |
126132
rsync -a /tmp/workspace/logs/output-*/ ./logs
127133
ls -R ./logs
134+
128135
- name: Parse logs
129136
run: |
130137
shopt -s globstar
131-
python .github/workflows/parse_logs.py logs/**/*-log
138+
python .github/workflows/parse_logs.py logs/**/*-log.jsonl
139+
132140
- name: Report failures
133141
uses: actions/github-script@v6
134142
with:

.github/workflows/parse_logs.py

+92-82
Original file line numberDiff line numberDiff line change
@@ -1,92 +1,102 @@
11
# type: ignore
22
import argparse
3-
import itertools
3+
import functools
4+
import json
45
import pathlib
56
import textwrap
7+
from dataclasses import dataclass
68

7-
parser = argparse.ArgumentParser()
8-
parser.add_argument("filepaths", nargs="+", type=pathlib.Path)
9-
args = parser.parse_args()
10-
11-
filepaths = sorted(p for p in args.filepaths if p.is_file())
12-
13-
14-
def extract_short_test_summary_info(lines):
15-
up_to_start_of_section = itertools.dropwhile(
16-
lambda l: "=== short test summary info ===" not in l,
17-
lines,
18-
)
19-
up_to_section_content = itertools.islice(up_to_start_of_section, 1, None)
20-
section_content = itertools.takewhile(
21-
lambda l: l.startswith("FAILED"), up_to_section_content
22-
)
23-
content = "\n".join(section_content)
24-
25-
return content
26-
27-
28-
def extract_warnings(lines):
29-
up_to_start_of_section = itertools.dropwhile(
30-
lambda l: "=== warnings summary ===" not in l,
31-
lines,
32-
)
33-
up_to_section_content = itertools.islice(up_to_start_of_section, 1, None)
34-
section_content = itertools.takewhile(
35-
lambda l: not l.startswith("==="),
36-
up_to_section_content,
37-
)
38-
content = "\n".join(section_content)
39-
return content
40-
41-
42-
def format_log_message(path):
43-
py_version = path.name.split("-")[1]
44-
summary = f"Python {py_version} Test Summary Info"
45-
with open(path) as f:
46-
lines = [line.rstrip() for line in f]
47-
data = extract_short_test_summary_info(lines)
48-
warnings = extract_warnings(lines)
49-
50-
message = (
51-
textwrap.dedent(
52-
"""\
53-
<details><summary>{summary}</summary>
54-
55-
```
56-
{data}
57-
```
58-
59-
</details>
60-
"""
61-
)
62-
.rstrip()
63-
.format(summary=summary, data=data)
64-
)
65-
66-
if warnings:
67-
message += (
68-
textwrap.dedent(
69-
"""
70-
71-
<details><summary>Warnings</summary>
72-
73-
```
74-
{warnings}
75-
```
76-
77-
</details>
78-
"""
79-
)
80-
.rstrip()
81-
.format(warnings=warnings)
82-
)
9+
from pytest import CollectReport, TestReport
8310

11+
12+
@dataclass
13+
class SessionStart:
14+
pytest_version: str
15+
outcome: str = "status"
16+
17+
@classmethod
18+
def _from_json(cls, json):
19+
json_ = json.copy()
20+
json_.pop("$report_type")
21+
return cls(**json_)
22+
23+
24+
@dataclass
25+
class SessionFinish:
26+
exitstatus: str
27+
outcome: str = "status"
28+
29+
@classmethod
30+
def _from_json(cls, json):
31+
json_ = json.copy()
32+
json_.pop("$report_type")
33+
return cls(**json_)
34+
35+
36+
def parse_record(record):
37+
report_types = {
38+
"TestReport": TestReport,
39+
"CollectReport": CollectReport,
40+
"SessionStart": SessionStart,
41+
"SessionFinish": SessionFinish,
42+
}
43+
cls = report_types.get(record["$report_type"])
44+
if cls is None:
45+
raise ValueError(f"unknown report type: {record['$report_type']}")
46+
47+
return cls._from_json(record)
48+
49+
50+
@functools.singledispatch
51+
def format_summary(report):
52+
return f"{report.nodeid}: {report}"
53+
54+
55+
@format_summary.register
56+
def _(report: TestReport):
57+
message = report.longrepr.chain[0][1].message
58+
return f"{report.nodeid}: {message}"
59+
60+
61+
@format_summary.register
62+
def _(report: CollectReport):
63+
message = report.longrepr.split("\n")[-1].removeprefix("E").lstrip()
64+
return f"{report.nodeid}: {message}"
65+
66+
67+
def format_report(reports, py_version):
68+
newline = "\n"
69+
summaries = newline.join(format_summary(r) for r in reports)
70+
message = textwrap.dedent(
71+
"""\
72+
<details><summary>Python {py_version} Test Summary</summary>
73+
74+
```
75+
{summaries}
76+
```
77+
78+
</details>
79+
"""
80+
).format(summaries=summaries, py_version=py_version)
8481
return message
8582

8683

87-
print("Parsing logs ...")
88-
message = "\n\n".join(format_log_message(path) for path in filepaths)
84+
if __name__ == "__main__":
85+
parser = argparse.ArgumentParser()
86+
parser.add_argument("filepath", type=pathlib.Path)
87+
args = parser.parse_args()
88+
89+
py_version = args.filepath.stem.split("-")[1]
90+
91+
print("Parsing logs ...")
92+
93+
lines = args.filepath.read_text().splitlines()
94+
reports = [parse_record(json.loads(line)) for line in lines]
95+
96+
failed = [report for report in reports if report.outcome == "failed"]
97+
98+
message = format_report(failed, py_version=py_version)
8999

90-
output_file = pathlib.Path("pytest-logs.txt")
91-
print(f"Writing output file to: {output_file.absolute()}")
92-
output_file.write_text(message)
100+
output_file = pathlib.Path("pytest-logs.txt")
101+
print(f"Writing output file to: {output_file.absolute()}")
102+
output_file.write_text(message)

0 commit comments

Comments
 (0)