Files
slic/json_to_md.py
T
tligui_y 9c9ad7c68e
Run Pytest with HTML and XML Test Reports / tests (push) Successful in 27s
Update json_to_md.py
2025-07-18 01:19:29 +02:00

359 lines
14 KiB
Python

from collections import defaultdict
from datetime import datetime
import json
import argparse
import os
import re
import pytest
from pytest import ExitCode
import traceback
import pytz
def stringify(obj, indent=0):
space = ' ' * indent
if obj is None or obj == "":
return "None"
if isinstance(obj, list):
if not obj:
return "[]"
return '\n'.join(
f"{space}- (vide)" if not e or (isinstance(e, dict) and not e)
else f"{space}- {stringify(e, indent + 1)}" if not isinstance(e, dict)
else f"{space}- {stringify(e, indent + 1)}"
for e in obj
)
if isinstance(obj, dict):
if not obj:
return "{}"
return '\n'.join(
f"{space}{k}: {stringify(v, indent + 1)}" if not isinstance(v, (dict, list))
else f"{space}{k}:\n{stringify(v, indent + 1)}"
for k, v in obj.items()
)
return str(obj)
def sanitize_param_value(v):
s = str(v)
return (
s.replace('&', '^')
.replace('<', '(')
.replace('>', ')')
.replace('"', '')
.replace("'", '')
.replace('`', '')
.replace('=', '≡')
.replace('\\', '/')
)
def normalize_nodeid(nodeid):
"""Convert pytest nodeid to Allure fullName format"""
match = re.match(r"(tests[/\\].+?)\.py::(.+?)(?:\[.*)?$", nodeid)
if match:
file_part = match.group(1).replace("/", ".").replace("\\", ".")
func_part = match.group(2)
return f"{file_part}#{func_part}"
return None
def get_details_block(summary, body, level=0, params_str=None):
indent = " " * level
prefix = f"{indent}- " if level > 0 else ""
if params_str:
summary += f" (params: {params_str})"
block = []
block.append(f"{prefix}<details>")
block.append(f"{indent} <summary>{summary}</summary>")
block.append(f"{indent} <div style='margin-left: {10*level}px;'>") # Container for better spacing
block.append("") # empty line
# Add the body with proper indentation
for line in body.strip().split('\n'):
block.append(f"{indent} {line}")
block.append("") # empty line
block.append(f"{indent} </div>")
block.append(f"{indent}</details>")
return "\n".join(block)
def make_test_block(test, status, emoji, level, runtime_params):
nodeid = test.get("nodeid", "")
body_test = ""
summary_note = f"{emoji} #{test['global_number']}"
callspec = runtime_params.get(nodeid, {})
params = callspec.get("params", {}) if isinstance(callspec, dict) else {}
if isinstance(params, dict) and params:
param_display = ", ".join(f"{k}={sanitize_param_value(v)}" for k, v in params.items())
summary_note += f" <span style='color: #888; font-size: 0.9em;'>params: {param_display}</span>"
# Add runtime parameters if available
if callspec:
callspec_block = "```python\n" + stringify(callspec) + "\n```"
body_test += get_details_block("📌 Runtime Parameters", callspec_block, level=1)
# Process test phases
skip_keys = {"nodeid"}
phase_keys = [k for k in test.keys() if isinstance(test[k], dict) and k not in skip_keys]
for phase in phase_keys:
phase_body = ""
for field, value in test[phase].items():
if value is None:
continue
details_body = "```python\n" + stringify(value) + "\n```"
phase_body += get_details_block(f"📌 {field.capitalize()}", details_body, level=2)
if phase_body:
body_test += f"\n### 🔧 {phase.capitalize()} Phase\n\n" + phase_body
return get_details_block(summary_note, body_test, level=0)
def json_to_md_nested(json_path, md_path, runtime_params=None):
with open(json_path) as f:
data = json.load(f)
with open(md_path, 'w', encoding='utf-8') as f:
f.write(f"# 🧪 Test Report\n")
local = pytz.timezone("Europe/Zurich")
now = datetime.now(local)
f.write(f"*Generated on {now.strftime('%Y-%m-%d %H:%M:%S %Z')}*\n\n")
# General Info section
general_info = {}
for k, v in data.items():
if k == "summary":
break
if k not in {"created", "exitcode"}:
general_info[k] = v
if general_info:
f.write("## 🧾 General Info\n")
for key, value in general_info.items():
f.write(f"- **{key}**: {stringify(value)}\n")
f.write("\n")
# Summary section
if 'summary' in data:
f.write("## 📋 Summary\n")
for key, value in data['summary'].items():
f.write(f"- **{key.capitalize()}**: {stringify(value)}\n")
f.write("\n")
# Tests section
if "tests" in data and "summary" in data:
test_counter = 1
for test in data["tests"]:
test['global_number'] = test_counter
test_counter += 1
f.write("## 🔎 Tests\n")
summary_items = list(data["summary"].items())
total_index = next((i for i, (k, _) in enumerate(summary_items) if k == "total"), len(summary_items))
status_order = [k for k, _ in summary_items[:total_index]]
tests_by_status = defaultdict(list)
for test in data["tests"]:
outcome = test.get("outcome", "unknown")
tests_by_status[outcome].append(test)
for status in status_order:
if status not in tests_by_status:
continue
count = len(tests_by_status[status])
emoji = "✅" if status == "passed" else "❌"
status_label = status.capitalize().replace('_', ' ')
body_status = ""
grouped = defaultdict(lambda: defaultdict(list))
for test in tests_by_status[status]:
nodeid = test.get("nodeid", "")
parts = nodeid.split("::")
filename = parts[0].replace("tests\\", "").replace("tests/", "")
funcname = parts[1].split("[")[0]
grouped[filename][funcname].append(test)
for filename, funcs in grouped.items():
body_file = ""
for funcname, tests in funcs.items():
body_func = ""
for test in sorted(tests, key=lambda x: x['global_number']):
body_func += make_test_block(test, status, emoji, level=2, runtime_params=runtime_params) + "\n"
if body_func:
body_file += get_details_block(f"🔧 Function: `{funcname}`", body_func.strip(), level=1) + "\n"
if body_file:
body_status += get_details_block(f"📁 {filename}", body_file.strip(), level=0) + "\n"
if body_status:
f.write(get_details_block(f"{emoji} {status_label} ({count})", body_status.strip(), level=0))
f.write("\n\n")
# Collectors section
if "collectors" in data:
f.write("## 📚 Collected files\n")
grouped = defaultdict(list)
for collector in data["collectors"]:
nodeid = collector.get("nodeid", "unknown")
path = nodeid.split("::")[0]
main_folder = path.split("/")[0] if "/" in path else path
grouped[main_folder].append(collector)
for folder, collectors in grouped.items():
has_fail = any(c.get("outcome") != "passed" for c in collectors)
folder_emoji = "✅" if not has_fail else "❌"
body_collectors = ""
outputs = []
for collector in collectors:
outcome = collector.get("outcome", "unknown")
nodeid = collector.get("nodeid", "unknown")
short_node = nodeid.split("[")[0]
results = collector.get("result", [])
if outcome != "passed" and results:
outputs.append(f"### ❌ {short_node}\n```\n" + "\n".join(
f"{k}: {v}" if isinstance(item, dict) else str(item)
for item in results
for k, v in item.items() if isinstance(item, dict)
) + "\n```")
if outputs:
body_collectors += "### 🧾 Error or Result Summary\n\n"
for out in outputs:
body_collectors += out + "\n"
collectors_sorted = sorted(collectors, key=lambda c: c.get("nodeid", "").split("[")[0])
folder_body = ""
for collector in collectors_sorted:
outcome = collector.get("outcome", "unknown")
emoji = "✅" if outcome == "passed" else "❌"
nodeid = collector.get("nodeid", "unknown")
short_node = nodeid.split("[")[0]
body_coll = f"- **Outcome:** `{outcome}`\n"
other_keys = {k: v for k, v in collector.items() if k not in {"nodeid", "outcome"}}
if other_keys:
for k, v in other_keys.items():
block = stringify(v)
body_coll += f"- **{k}:**\n\n```python\n{block}\n```\n\n"
else:
body_coll += "- **Details:** `None`\n"
folder_body += get_details_block(f"{emoji} {short_node}", body_coll, level=1) + "\n"
if folder_body:
f.write(get_details_block(f"{folder_emoji} {folder} ({len(collectors)} tests)", (folder_body + body_collectors).strip(), level=0))
f.write("\n\n")
# Other sections
EXCLUDED_KEYS = {'tests', 'collectors', 'summary'}
for key in data.keys():
if key in EXCLUDED_KEYS or not data[key]:
continue
f.write(f"## ⚠️ {key.capitalize()}\n\n")
for i, entry in enumerate(data[key], 1):
entry_body = "```\n"
if isinstance(entry, dict):
for k, v in entry.items():
entry_body += f"{k}: {v}\n"
else:
entry_body += str(entry) + "\n"
entry_body += "```\n"
f.write(get_details_block(f"{key.capitalize()} #{i}", entry_body, level=0))
f.write("\n")
def run_pytest_and_generate_banner_with_logs(md_path, log_path, exit_code):
if exit_code == 0 or exit_code == 1:
return
banners = {
2: "⚠️ **Test execution interrupted**\n\n> The test run was interrupted by the user (reasons : KeyboardInterrupt or ...).\n\n",
3: "🛑 **Internal error during testing**\n\n> An internal error occurred while executing the tests.\n\n",
4: "❗ **Pytest command line usage error**\n\n> There was an error in how pytest was invoked.\n\n",
5: "❗ **No tests were collected**\n\n> Pytest did not find any tests to run.\n\n"
}
banner = banners.get(exit_code,
f"❓ **Unknown pytest exit code: {exit_code}**\n\n> Unexpected result during test execution.\n\n")
try:
with open(log_path, "r") as lf:
log_lines = lf.readlines()
except Exception as e:
print(f"❌ Could not read log file: {e}")
return
# Try to read the existing markdown content
try:
with open(md_path, "r") as f:
original_md = f.read()
except Exception as e:
print(f"❌ Could not read markdown report: {e}")
return
# Extract short test summary if available
short_summary = []
in_summary = False
for line in log_lines:
if "short test summary info" in line.lower():
in_summary = True
if in_summary:
short_summary.append(line)
if re.match(r"=+.* in .*s =+", line):
break
# Construct the full banner
full_banner = banner
if short_summary:
full_banner += "### Short Test Summary\n```\n" + "".join(short_summary) + "```\n\n"
full_banner += "<details>\n<summary>🪵 Full raw pytest log</summary>\n\n" + \
"```\n" + "".join(log_lines) + "```\n</details>\n\n---\n\n"
# Write the new content
try:
with open(md_path, "w") as f:
f.write(full_banner + original_md)
print("✅ Banner and log summary added to markdown report.")
except Exception as e:
print(f"❌ Failed to update markdown report: {e}")
return
def main():
parser = argparse.ArgumentParser(description="Convert JSON test results to Markdown.")
parser.add_argument("--input", required=True, help="Path to pytest JSON file")
parser.add_argument("--output", required=True, help="Path to output Markdown file")
parser.add_argument("--log", required=False, help="Path to raw pytest output log (optional)")
parser.add_argument("--params", required=False, help="Path to runtime-params.json")
parser.add_argument("--exit-code", type=int, default=0, help="Exit code from pytest to determine the banner.")
args = parser.parse_args()
runtime_params = {}
if args.params and os.path.exists(args.params):
try:
with open(args.params, "r") as f:
param_list = json.load(f)
for entry in param_list:
if "nodeid" in entry and isinstance(entry.get("callspec"), dict):
runtime_params[entry["nodeid"]] = entry["callspec"]
except Exception as e:
print(f"❌ Failed to read runtime parameters: {e}")
json_to_md_nested(args.input, args.output, runtime_params)
if args.log:
run_pytest_and_generate_banner_with_logs(md_path=args.output, log_path=args.log, exit_code=args.exit_code)
print(f"✅ Report generated at {args.output}")
if __name__ == "__main__":
main()