diff --git a/json_to_md.py b/json_to_md.py
index f4bb8ed2..9f6d189b 100644
--- a/json_to_md.py
+++ b/json_to_md.py
@@ -4,9 +4,6 @@ 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 == "":
@@ -17,18 +14,7 @@ def stringify(obj):
return ', '.join(f"`{k}: {stringify(v)}`" for k, v in obj.items())
return f"`{str(obj)}`"
-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 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:
@@ -38,7 +24,6 @@ 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} parameters: [{params_str}]"
return (f'
\n'
@@ -47,33 +32,23 @@ def get_details_block(summary, body, level=0, params_str=None):
f"\n"
f"
\n\n")
-
-
def make_test_block(test, status, emoji, level):
- nodeid = test.get("nodeid", "")
- param_str = extract_param_str_from_nodeid(nodeid)
+ keys = list(test.keys())
+ first_key = keys[0] if keys else "?"
+ param_str = extract_param_str_from_nodeid(test.get(first_key, ""))
body_test = f"- **Status:** {emoji} `{status}`\n"
duration = test.get("call", {}).get("duration")
body_test += f"- **Duration:** `{duration:.6f}` s\n" if duration else "- **Duration**: `None`\n"
-
if param_str:
body_test += f"- **Parameters:** `{param_str}`\n"
-
- for phase in ['setup', 'call', 'teardown']:
- if phase in test:
+ for section in test:
+ if section not in {"outcome", "global_number"}:
phase_body = ""
- for field, value in test[phase].items():
- if value is None:
- phase_body += f"- **{field.capitalize()}:** None\n"
- else:
- details_body = "```\n"
- details_body += stringify(value) + "\n"
- details_body += "```\n"
- phase_body += get_details_block(f"📌 {field.capitalize()}", details_body, level+2)
+ for field, value in test[section].items():
+ details_body = "```\n" + stringify(value) + "\n```\n"
+ phase_body += get_details_block(f"📌 {field}", details_body, level+2)
if phase_body:
- body_test += f"\n### 🔧 {phase.capitalize()} Phase\n\n" + phase_body
-
- # Ajoute param_str au résumé si dispo
+ body_test += f"\n### 🔧 {section.capitalize()}\n\n" + phase_body
return get_details_block(f"{emoji} #{test['global_number']}", body_test, level+1, params_str=param_str)
def json_to_md_nested(json_path, md_path):
@@ -84,158 +59,98 @@ def json_to_md_nested(json_path, md_path):
f.write(f"# 🧪 Test Report\n")
f.write(f"*Generated on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*\n\n")
+ # Metadata before summary
+ for key, value in data.items():
+ if key == "summary":
+ break
+ f.write(f"- **{key}**: {stringify(value)}\n")
+ f.write("\n")
+
+ # Summary
if 'summary' in data:
f.write("## 📋 Summary\n")
for key, value in data['summary'].items():
f.write(f"- **{key.capitalize()}**: {stringify(value)}\n")
- duration = data.get("duration")
- f.write(f"- **Total Duration**: `{duration:.3f}`s\n" if duration else "- **Total Duration**: `None`\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
+ keys_after_summary = list(data.keys())[list(data.keys()).index("summary") + 1:]
- f.write("## 🔎 Tests\n")
+ for section in keys_after_summary:
+ section_data = data[section]
- 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]]
+ if section == "tests":
+ f.write("## 🔎 Tests\n")
+ test_counter = 1
+ tests_by_status = defaultdict(list)
+ for test in section_data:
+ test['global_number'] = test_counter
+ test_counter += 1
+ outcome = test.get("outcome", "unknown")
+ tests_by_status[outcome].append(test)
- tests_by_status = defaultdict(list)
- for test in data["tests"]:
- outcome = test.get("outcome", "unknown")
- tests_by_status[outcome].append(test)
+ for status in sorted(tests_by_status):
+ tests = tests_by_status[status]
+ emoji = "✅" if status == "passed" else "❌"
+ body_status = ""
+ grouped = defaultdict(lambda: defaultdict(list))
+ for test in tests:
+ keys = list(test.keys())
+ nodeid = test[keys[0]]
+ parts = nodeid.split("::")
+ filename = parts[0].replace("tests\\", "").replace("tests/", "")
+ funcname = parts[1].split("[")[0] if len(parts) > 1 else "?"
+ grouped[filename][funcname].append(test)
- for status in status_order:
- if status not in tests_by_status:
- continue
+ 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)
+ body_file += get_details_block(f"🔧 Function: `{funcname}`", body_func, level=2)
+ body_status += get_details_block(f"📁 {filename}", body_file, level=1)
- count = len(tests_by_status[status])
- emoji = "✅" if status == "passed" else "❌"
- status_label = status.capitalize().replace('_', ' ')
+ f.write(get_details_block(f"{emoji} {status.capitalize()} ({len(tests)})", body_status, level=0))
- 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)
- 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")
+ elif section == "collectors":
+ f.write("## 📚 Collected files\n")
+ grouped = defaultdict(list)
+ for collector in section_data:
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```")
+ path = nodeid.split("::")[0]
+ folder = path.split("/")[0] if "/" in path else path
+ grouped[folder].append(collector)
- if outputs:
- body_collectors += "### 🧾 Error or Result Summary\n\n"
- for out in outputs:
- body_collectors += out + "\n"
+ for folder, collectors in grouped.items():
+ folder_emoji = "✅" if all(c.get("outcome") == "passed" for c in collectors) else "❌"
+ folder_body = ""
+ for i, collector in enumerate(collectors):
+ coll_body = ""
+ for k, v in collector.items():
+ coll_body += get_details_block(k, f"```\n{stringify(v)}\n```", level=2)
+ folder_body += get_details_block(f"Collector #{i+1}", coll_body, level=1)
+ f.write(get_details_block(f"{folder_emoji} {folder} ({len(collectors)} entries)", folder_body, level=0))
- 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))
-
- # ---------- Warnings section -----------
- if 'warnings' in data and data['warnings']:
- f.write("## ⚠️ Warnings\n\n")
- for i, warning in enumerate(data['warnings'], 1):
- warn_body = "```\n"
- for k, v in warning.items():
- warn_body += f"{k}: {v}\n"
- warn_body += "```\n"
- f.write(get_details_block(f"Warning #{i}", warn_body, level=1))
+ else:
+ f.write(f"## 📦 {section.capitalize()}\n\n")
+ entries = section_data if isinstance(section_data, list) else [section_data]
+ for i, item in enumerate(entries):
+ block = ""
+ for k, v in item.items():
+ block += get_details_block(k, f"```\n{stringify(v)}\n```", level=2)
+ f.write(get_details_block(f"{section.capitalize()} #{i+1}", block, level=1))
def run_pytest_and_generate_banner_with_logs(md_path, log_path, exit_code):
- #exit_code = pytest.main(["tests/"])
- if exit_code == 0 or exit_code == 1:
+ if exit_code in (0, 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"
- )
+ banners = {
+ 2: "⚠️ **Test execution interrupted**\n\n> The test run was interrupted by the user.",
+ 3: "🛑 **Internal error during testing**\n\n> An internal error occurred while executing the tests.",
+ 4: "❗ **Pytest command line usage error**\n\n> There was an error in how pytest was invoked.",
+ 5: "❗ **No tests were collected**\n\n> Pytest did not find any tests to run."
+ }
+ banner = banners.get(exit_code, f"❓ **Unknown pytest exit code: {exit_code}**\n\n> Unexpected result during test execution.")
try:
with open(log_path, "r") as lf:
@@ -262,27 +177,26 @@ def run_pytest_and_generate_banner_with_logs(md_path, log_path, exit_code):
return
full_banner = (
- banner +
+ "\n---\n\n" +
+ banner + "\n\n" +
"\n📋 Short test summary info
\n\n" +
"```\n" + "".join(short_summary_lines) + "```\n \n\n" +
"\n🪵 Full raw pytest log
\n\n" +
- "```\n" + "".join(log_lines) + "```\n \n\n" +
- "---\n\n"
+ "```\n" + "".join(log_lines) + "```\n\n\n"
)
try:
with open(md_path, "w") as f:
- f.write(full_banner + original_md)
+ f.write(original_md + full_banner)
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("--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("--exit-code", type=int, default=0, help="Exit code from pytest to determine the banner.")