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)}" # โ† plus de \n ! 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): margin = 18 * level border = f"border-left: 2px solid #eee;" if level > 0 else "" if 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, 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" params: {param_display}" if callspec: callspec_block = "```python\n" + stringify(callspec) + "\n```" body_test += get_details_block("๐Ÿ“Œ Runtime Parameters", callspec_block, level + 1) 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(): details_body = "```python\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(summary_note, body_test, level + 1) 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 = {} 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") 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=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: 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=2) f.write(get_details_block(f"{folder_emoji} {folder} ({len(collectors)} tests)", folder_body + body_collectors, level=1)) # ---------- OTHER sections ----------- 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("--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) 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()