Skip to content

feat: add artifact upload steps for JSONL logs in local and remote test workflows #3905

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 7 commits into from
May 9, 2025
77 changes: 50 additions & 27 deletions .ci/pytest_summary.py
Original file line number Diff line number Diff line change
@@ -1,24 +1,31 @@
import json
import os
from typing import Any, TypedDict

import click
import numpy as np
from numpy.typing import NDArray

BIG_WIDTH = 80
SMALL_WIDTH = 8


def find_json_files(base_dir):
class TEST_STATS_TYPE(TypedDict):
durations: list[str | float]
n_tests: int


def find_json_files(base_dir: str) -> list[str]:
"""Recursively find all JSON files in subdirectories."""
json_files = []
json_files: list[str] = []
for root, _, files in os.walk(base_dir):
for file in files:
if file.endswith(".jsonl"):
json_files.append(os.path.join(root, file))
return json_files


def read_json_file(file_path):
def read_json_file(file_path: str) -> list[dict[str, str]]:
"""Read a JSON file and return its content as a list of test configurations."""
with open(file_path, "r", encoding="utf-8") as f:
try:
Expand All @@ -29,24 +36,28 @@ def read_json_file(file_path):
return []


def extract_tests_with_tags(json_files):
def extract_tests_with_tags(json_files: list[str]) -> list[dict[str, str | list[str]]]:
"""Extract test data and assign a tag based on the directory name."""
tests = []
tests: list[dict[str, str | list[str]]] = []

for file_path in json_files:
directory_name = os.path.basename(os.path.dirname(file_path))
test_data = read_json_file(file_path)

for test in test_data:
if test.get("outcome", "").lower() == "passed" and test.get("duration"):
nodeid = test.get("nodeid")
nodeid: str = test.get("nodeid", "")

if nodeid.startswith("tests/"):
nodeid = nodeid[6:]

when = test.get("when")
duration = test["duration"]
tags = directory_name.split("-")
tags.remove("logs")
when: str = test.get("when", "")
duration: str = test["duration"]
tags: list[str] = directory_name.split("-")

if "logs" in tags:
tags.remove("logs")

id_ = f"{nodeid}({when})"

tests.append(
Expand All @@ -61,12 +72,14 @@ def extract_tests_with_tags(json_files):
return tests


def compute_statistics(tests):
def compute_statistics(
tests: list[dict[str, str | list[str]]],
) -> list[dict[str, str | float]]:
"""Compute average duration and standard deviation per test ID."""
test_stats = {}
test_stats: dict[str, TEST_STATS_TYPE] = {}

for test in tests:
test_id = test["id"]
test_id: str = test["id"]
if test_id not in test_stats:
test_stats[test_id] = {
"durations": [],
Expand All @@ -76,10 +89,10 @@ def compute_statistics(tests):
test_stats[test_id]["durations"].append(test["duration"])
test_stats[test_id]["n_tests"] += 1

summary = []
summary: list[dict[str, Any]] = []

for test_id, data in test_stats.items():
durations = np.array(data["durations"])
durations: NDArray[Any] = np.array(data["durations"])

if durations.size == 0:
continue
Expand Down Expand Up @@ -119,10 +132,15 @@ def compute_statistics(tests):
return summary


def print_table(data, keys, headers, title=""):
def print_table(
data: list[dict[str, str | float]],
keys: list[str],
headers: list[str],
title: str = "",
):
JUNCTION = "|"

def make_bold(s):
def make_bold(s: str) -> str:
return click.style(s, bold=True)

h = [headers[0].ljust(BIG_WIDTH)]
Expand All @@ -135,7 +153,7 @@ def make_bold(s):
+ f"-{JUNCTION}-".join(["-" * len(each) for each in h])
+ f"-{JUNCTION}"
)
top_sep = f"{JUNCTION}" + "-" * (len_h - 2) + f"{JUNCTION}"
# top_sep: str = f"{JUNCTION}" + "-" * (len_h - 2) + f"{JUNCTION}"

if title:
# click.echo(top_sep)
Expand All @@ -148,17 +166,17 @@ def make_bold(s):
click.echo(sep)

for test in data:
s = []
s: list[str] = []
for i, each_key in enumerate(keys):

if i == 0:
id_ = test[each_key]
id_: str = test[each_key]

id_ = (
id_.replace("(", "\(")
.replace(")", "\)")
.replace("[", "\[")
.replace("]", "\]")
id_.replace("(", r"(")
.replace(")", r")")
.replace("[", r"[")
.replace("]", r"]")
)
if len(id_) >= BIG_WIDTH:
id_ = id_[: BIG_WIDTH - 15] + "..." + id_[-12:]
Expand All @@ -177,7 +195,7 @@ def make_bold(s):
# click.echo(sep)


def print_summary(summary, num=10):
def print_summary(summary: list[dict[str, str | float]], num: int = 10):
"""Print the top N longest tests and the top N most variable tests."""
longest_tests = sorted(summary, key=lambda x: -x["average_duration"])[:num]
most_variable_tests = sorted(summary, key=lambda x: -x["std_dev"])[:num]
Expand Down Expand Up @@ -225,15 +243,20 @@ def print_summary(summary, num=10):
default=None,
)
@click.option(
"--num", default=10, help="Number of top tests to display.", show_default=True
"--num",
type=int,
default=10,
help="Number of top tests to display.",
show_default=True,
)
@click.option(
"--save-file",
default=None,
type=click.Path(exists=False, dir_okay=False),
help="File to save the test durations. Default 'tests_durations.json'.",
show_default=True,
)
def analyze_tests(directory, num, save_file):
def analyze_tests(directory: str, num: int, save_file: str):
directory = directory or os.getcwd() # Change this to your base directory
json_files = find_json_files(directory)
tests = extract_tests_with_tags(json_files)
Expand Down
8 changes: 6 additions & 2 deletions .github/actions/pytest-summary/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ runs:
- name: "Download artifacts"
uses: actions/download-artifact@v4
with:
pattern: "reports-*"
path: "artifacts"

- name: "Check if artifacts directory has files"
Expand All @@ -53,7 +54,7 @@ runs:
if: ${{ env.HAS_FILES == 'true' }}
shell: bash
run: |
find . -mindepth 1 -maxdepth 4 -type f -name 'logs-*.tgz' -exec tar -xzvf {} -C $(dirname {}) \;
find . -mindepth 1 -maxdepth 4 -type f -name 'reports-*.tgz' -exec tar -xzvf {} -C $(dirname {}) \;

- name: "List directories"
if: ${{ env.HAS_FILES == 'true' }}
Expand All @@ -66,8 +67,11 @@ runs:
shell: bash
run: |
echo "# Test summary 🚀" >> $GITHUB_STEP_SUMMARY
echo -e "The followin tables show a summary of tests duration and standard desviation for all the jobs.\n" >> $GITHUB_STEP_SUMMARY
echo -e "The following tables show a summary of tests duration and standard deviation for all the jobs.\n" >> $GITHUB_STEP_SUMMARY
echo -e "You have the duration of all tests in the artifact 'tests_durations.json'\n" >> $GITHUB_STEP_SUMMARY
echo "Running Pytest summary..."
python .ci/pytest_summary.py --num 10 --save-file tests_durations.json >> summary.md
echo "Pytest summary done."
echo "$(cat summary.md)" >> $GITHUB_STEP_SUMMARY
cat summary.md

Expand Down
9 changes: 8 additions & 1 deletion .github/workflows/test-local.yml
Original file line number Diff line number Diff line change
Expand Up @@ -259,6 +259,13 @@ jobs:
--report-log=$file_name.jsonl \
--cov-report=xml:$file_name.xml

- name: "Upload pytest reports to GitHub"
if: always()
uses: actions/upload-artifact@v4.6.2
with:
name: "reports-${{ inputs.file-name }}"
path: ./${{ inputs.file-name }}.jsonl

- name: "Collect logs on failure"
if: always()
env:
Expand All @@ -269,7 +276,7 @@ jobs:

- name: "Upload logs to GitHub"
if: always()
uses: actions/upload-artifact@master
uses: actions/upload-artifact@v4.6.2
with:
name: logs-${{ inputs.file-name }}.tgz
path: ./logs-${{ inputs.file-name }}.tgz
Expand Down
13 changes: 10 additions & 3 deletions .github/workflows/test-remote.yml
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,13 @@ jobs:
--report-log=$file_name.jsonl \
--cov-report=xml:$file_name.xml

- name: "Upload pytest reports to GitHub"
if: always()
uses: actions/upload-artifact@v4.6.2
with:
name: "reports-${{ inputs.file-name }}"
path: ./${{ inputs.file-name }}.jsonl

- uses: codecov/codecov-action@v5
name: "Upload coverage to Codecov"
with:
Expand All @@ -230,7 +237,7 @@ jobs:
flags: remote,${{ steps.ubuntu_check.outputs.TAG_UBUNTU }},${{ inputs.mapdl-version }},${{ steps.distributed_mode.outputs.distributed_mode }},${{ steps.student_check.outputs.TAG_STUDENT }}

- name: Upload coverage artifacts
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v4.6.2
with:
name: "${{ inputs.file-name }}.xml"
path: "./${{ inputs.file-name }}.xml"
Expand All @@ -242,7 +249,7 @@ jobs:
twine check dist/*

- name: "Upload wheel and binaries"
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v4.6.2
with:
name: PyMAPDL-packages-${{ inputs.mapdl-version }}
path: dist/
Expand All @@ -260,7 +267,7 @@ jobs:

- name: "Upload logs to GitHub"
if: always()
uses: actions/upload-artifact@v4
uses: actions/upload-artifact@v4.6.2
with:
name: logs-${{ inputs.file-name }}.tgz
path: ./logs-${{ inputs.file-name }}.tgz
Expand Down
1 change: 1 addition & 0 deletions doc/changelog.d/3905.maintenance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
feat: add artifact upload steps for JSONL logs in local and remote test workflows
Loading