"
# TODO: Expand on timing. Usually available from a detailed profiling # noqa: TD002, TD003
body += "
"
body += ""
body += "
"
body += ""
return body
def _generate_tree_recursive(self, json_graph: object, cpu_time: float) -> str:
node_prefix_html = "
"
node_suffix_html = "
"
extra_info = ""
estimate = 0
for key in json_graph["extra_info"]:
value = json_graph["extra_info"][key]
if key == "Estimated Cardinality":
estimate = int(value)
else:
extra_info += f"{key}: {value} "
# get rid of some typically long names
extra_info = re.sub(r"__internal_\s*", "__", extra_info)
extra_info = re.sub(r"compress_integral\s*", "compress", extra_info)
node_body = self._get_node_body(
json_graph["operator_type"],
json_graph["operator_timing"],
cpu_time,
json_graph["operator_cardinality"],
estimate,
json_graph["result_set_size"],
re.sub(r",\s*", ", ", extra_info),
)
children_html = ""
if len(json_graph["children"]) >= 1:
children_html += "
"
for child in json_graph["children"]:
children_html += self._generate_tree_recursive(child, cpu_time)
children_html += "
"
return node_prefix_html + node_body + children_html + node_suffix_html
# For generating the table in the top left with expandable phases
def _generate_timing_html(self, graph_json: object, query_timings: object) -> object:
"""Generates timing HTML table with expandable phases."""
json_graph = json.loads(graph_json)
self._gather_timing_information(json_graph, query_timings)
table_head = """
Phase
Time (s)
Percentage
"""
table_body = ""
table_end = "
"
execution_time = query_timings.get_sum_of_all_timings()
all_phases = query_timings.get_phases()
query_timings.add_node_timing(NodeTiming("Execution Time (CPU)", execution_time, None))
all_phases = ["Execution Time (CPU)", *all_phases]
for phase in all_phases:
summarized_phase = query_timings.get_summary_phase_timings(phase)
summarized_phase.calculate_percentage(execution_time)
phase_column = f"{phase}" if phase == "Execution Time (CPU)" else phase
# Main phase row
table_body += f"""
{phase_column}
{round(summarized_phase.time, 8)}
{str(summarized_phase.percentage * 100)[:6]}%
"""
# Add expandable details for individual nodes (except for Execution Time)
if phase != "Execution Time (CPU)":
phase_timings = query_timings.get_phase_timings(phase)
if len(phase_timings) > 1: # Only show details if there are multiple nodes
table_body += f"""
"
# first level of json is general overview
# TODO: make sure json output first level always has only 1 level # noqa: TD002, TD003
tree_body = self._generate_tree_recursive(json_graph["children"][0], cpu_time)
return tree_prefix + tree_body + tree_suffix
def _generate_ipython(self, json_input: str) -> str:
from IPython.core.display import HTML
html_output = self._generate_html(json_input, False)
return HTML(
(
'\n ${CSS}\n ${LIBRARIES}\n \n ${CHART_SCRIPT}\n '
)
.replace("${CSS}", html_output["css"])
.replace("${CHART_SCRIPT}", html_output["chart_script"])
.replace("${LIBRARIES}", html_output["libraries"])
)
@staticmethod
def _generate_style_html(graph_json: str, include_meta_info: bool) -> None: # noqa: FBT001
treeflex_css = '\n'
libraries = '\n' # noqa: E501
return {"treeflex_css": treeflex_css, "duckdb_css": qgraph_css, "libraries": libraries, "chart_script": ""}
def _gather_timing_information(self, json: str, query_timings: object) -> None:
# add up all of the times
# measure each time as a percentage of the total time.
# then you can return a list of [phase, time, percentage]
self._get_child_timings(json["children"][0], query_timings)
def _translate_json_to_html(
self, input_file: str | None = None, input_text: str | None = None, output_file: str = "profile.html"
) -> None:
query_timings = AllTimings()
if input_text is not None:
text = input_text
elif input_file is not None:
with open_utf8(input_file, "r") as f:
text = f.read()
else:
print("please provide either input file or input text")
exit(1)
html_output = self._generate_style_html(text, True)
highlight_metric_grid = self._generate_metric_grid_html(text)
timing_table = self._generate_timing_html(text, query_timings)
tree_output = self._generate_tree_html(text)
sql_query_html = self._generate_sql_query_html(text)
# finally create and write the html
with open_utf8(output_file, "w+") as f:
html = """
Query Profile Graph for Query
${TREEFLEX_CSS}
Query Profile Graph
${METRIC_GRID}
${SQL_QUERY}
${TIMING_TABLE}
${TREE}
""" # noqa: E501
html = html.replace("${TREEFLEX_CSS}", html_output["treeflex_css"])
html = html.replace("${DUCKDB_CSS}", html_output["duckdb_css"])
html = html.replace("${METRIC_GRID}", highlight_metric_grid)
html = html.replace("${SQL_QUERY}", sql_query_html)
html = html.replace("${TIMING_TABLE}", timing_table)
html = html.replace("${TREE}", tree_output)
f.write(html)
def main() -> None: # noqa: D103
parser = argparse.ArgumentParser(
prog="Query Graph Generator",
description="""Given a json profile output, generate a html file showing the query graph and
timings of operators""",
)
parser.add_argument("--profile_input", help="profile input in json")
parser.add_argument("--out", required=False, default=False)
parser.add_argument("--open", required=False, action="store_true", default=True)
args = parser.parse_args()
input = args.profile_input
output = args.out
if not args.out:
if ".json" in input:
output = input.replace(".json", ".html")
else:
print("please provide profile output in json")
exit(1)
else:
if ".html" in args.out:
output = args.out
else:
print("please provide valid .html file for output name")
exit(1)
open_output = args.open
profiling_info = ProfilingInfo(from_file=input)
profiling_info.to_html(output_file=output)
if open_output:
webbrowser.open(f"file://{Path(output).resolve()}", new=2)
if __name__ == "__main__":
main()