From e8e1871bca768a5dafa107b6b3958bf89d7a320d Mon Sep 17 00:00:00 2001 From: tligui_y Date: Thu, 17 Jul 2025 14:02:56 +0200 Subject: [PATCH] Update json_to_md.py --- json_to_md.py | 261 ++++++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 223 insertions(+), 38 deletions(-) diff --git a/json_to_md.py b/json_to_md.py index a960ebb2..eff34b45 100644 --- a/json_to_md.py +++ b/json_to_md.py @@ -4,6 +4,9 @@ import json import argparse import os import re +import pytest +from pytest import ExitCode +import traceback def stringify(obj): if obj is None or obj == "": @@ -15,7 +18,8 @@ def stringify(obj): return str(obj) def normalize_nodeid(nodeid): - match = re.match(r"(tests[/\\].+?)\\.py::(.+?)(?:\[.*)?$", 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) @@ -23,6 +27,8 @@ def normalize_nodeid(nodeid): return None def extract_param_str_from_nodeid(nodeid): + """Extract parameter string from nodeid, e.g. test[x-y-z] → x-y-z. + Handles nested or inner brackets.""" first = nodeid.find('[') last = nodeid.rfind(']') if first != -1 and last != -1 and last > first: @@ -32,61 +38,67 @@ def extract_param_str_from_nodeid(nodeid): def get_details_block(summary, body, level=0, params_str=None): margin = 18 * level border = f"border-left: 2px solid #eee;" if level > 0 else "" + # Affiche les paramètres dans le résumé si présent if params_str: - summary = f"{summary} params: [{params_str}]" + summary = f"{summary} parameters: [{params_str}]" return (f'
\n' f"
\n{summary}\n\n" f"{body}\n" f"
\n" f"
\n\n") -def make_test_block(test, status, emoji, level, callspec_data): + +def make_test_block(test, status, emoji, level, runtime_params): nodeid = test.get("nodeid", "") - param_str = extract_param_str_from_nodeid(nodeid) body_test = "" + summary_note = f"{emoji} #{test['global_number']}" - if nodeid in callspec_data: - spec = callspec_data[nodeid] - extracted = {k: v for k, v in spec.items() if k not in {"getparam", "setmulti", "_arg2scope", "_idlist"}} - body_test += f"### 🔬 Parameters -``` -{stringify(extracted)} -``` -" + # 🔹 Ajout des infos de callspec si disponibles + callspec = runtime_params.get(nodeid, {}) + if callspec: + # 1. Ajoute les paramètres dans le titre (id lisible) + params = callspec.get("params", {}) + if params: + param_display = ", ".join(f"{k}={v}" for k, v in params.items()) + summary_note += f" params: {param_display}" + # 2. Ajoute toutes les infos du callspec dans le corps + body_test += "### 📌 Runtime Parameters\n" + for key, val in callspec.items(): + body_test += f"- **{key}**:\n" + body_test += "```\n" + stringify(val) + "\n```\n" + body_test += "\n" + + # 🔧 Détails par phase (setup, call, teardown) 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: - phase_body += f"- **{field.capitalize()}:** None\n" - else: - details_body = "``` -" + stringify(value) + "\n```" - phase_body += get_details_block(f"📌 {field.capitalize()}", details_body, level+2) + details_body = "```\n" + stringify(value) + "\n```" if value is not None else "None" + 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(f"{emoji} #{test['global_number']}", body_test, level+1, params_str=param_str) + return get_details_block(summary_note, body_test, level + 1) -def json_to_md_nested(json_path, md_path, params_path=None): + +def json_to_md_nested(json_path, md_path, runtime_params=None): with open(json_path) as f: data = json.load(f) - callspec_data = {} - if params_path and os.path.exists(params_path): - with open(params_path) as f: - for entry in json.load(f): - if 'nodeid' in entry and 'callspec' in entry: - callspec_data[entry['nodeid']] = entry['callspec'] - with open(md_path, 'w', encoding='utf-8') as f: f.write(f"# 🧪 Test Report\n") f.write(f"*Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n") - general_info = {k: v for k, v in data.items() if k not in {"summary", "tests", "collectors", "created", "exitcode"}} + 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(): @@ -99,11 +111,15 @@ def json_to_md_nested(json_path, md_path, params_path=None): f.write(f"- **{key.capitalize()}**: {stringify(value)}\n") f.write("\n") - if "tests" in data: - for i, test in enumerate(data["tests"], 1): - test['global_number'] = i + # --------- 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]] @@ -116,14 +132,16 @@ def json_to_md_nested(json_path, md_path, params_path=None): 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 = "" + body_status = "" grouped = defaultdict(lambda: defaultdict(list)) for test in tests_by_status[status]: - parts = test.get("nodeid", "").split("::") + nodeid = test.get("nodeid", "") + parts = nodeid.split("::") filename = parts[0].replace("tests\\", "").replace("tests/", "") funcname = parts[1].split("[")[0] grouped[filename][funcname].append(test) @@ -133,20 +151,187 @@ def json_to_md_nested(json_path, md_path, params_path=None): 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=3, callspec_data=callspec_data) - body_file += get_details_block(f"🔧 Function: `{funcname}`", body_func, level=2) + body_func += make_test_block(test, status, emoji, level=3, runtime_params=runtime_params) + body_file += get_details_block(f"🔧 Function: {funcname}", body_func, level=2) body_status += get_details_block(f"📁 {filename}", body_file, level=1) f.write(get_details_block(f"{emoji} {status_label} ({count})", body_status, level=0)) + # ---------- 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: + body_coll += "- **Details:**\n" + body_coll += " +\n" + for k, v in other_keys.items(): + body_coll += f"{k}:\n" + if v is None: + body_coll += " None\n" + else: + body_coll += stringify(v) + "\n" + body_coll += "\n" + body_coll += " +\n" + else: + body_coll += "- **Details:** None\n" + folder_body += get_details_block(f"{emoji} {short_node}", body_coll, level=2) + f.write(get_details_block(f"{folder_emoji} {folder} ({len(collectors)} tests)", folder_body + body_collectors, level=1)) + + EXCLUDED_KEYS = {'tests', 'collectors'} + + keys = list(data.keys()) + if "summary" in keys: + start_index = keys.index("summary") + 1 + else: + start_index = len(keys) + + for key in keys[start_index:]: + 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=1)) + + +def run_pytest_and_generate_banner_with_logs(md_path, log_path, exit_code): + if exit_code == 0 or exit_code == 1: + return + + if exit_code == 2: + banner = ( + "⚠️ **Test execution interrupted**\n\n" + "> The test run was interrupted by the user (reasons : KeyboardInterrupt or ...).\n\n" + ) + elif exit_code == 3: + banner = ( + "🛑 **Internal error during testing**\n\n" + "> An internal error occurred while executing the tests.\n\n" + ) + elif exit_code == 4: + banner = ( + "❗ **Pytest command line usage error**\n\n" + "> There was an error in how pytest was invoked.\n\n" + ) + elif exit_code == 5: + banner = ( + "❗ **No tests were collected**\n\n" + "> Pytest did not find any tests to run.\n\n" + ) + else: + banner = ( + 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 + + short_summary_lines = [] + in_summary = False + for line in log_lines: + if "short test summary info" in line.lower(): + in_summary = True + if in_summary: + short_summary_lines.append(line) + if re.match(r"=+.* in .*s =+", line): + break + + 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 + + full_banner = ( + banner + "
\n🪵 Full raw pytest log\n\n" + + " +\n" + "".join(log_lines) + " +\n
\n\n" + + "---\n\n" + ) + + 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("--params", required=False, help="Path to runtime_params.json (optional)") + parser.add_argument("--log", required=False, help="Path to raw pytest output log (optional)") + parser.add_argument("--json", required=False, help="Path to pytest-report.json") + 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() - json_to_md_nested(args.input, args.output, params_path=args.params) + json_to_md_nested(args.input, args.output, arg.params) + 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() + main() \ No newline at end of file