Skip to content

Commit 4d1249c

Browse files
authored
Updates and small fixes, move ascii_tree to helpers (#969)
2 parents ba10598 + 61f2e3d commit 4d1249c

File tree

4 files changed

+164
-89
lines changed

4 files changed

+164
-89
lines changed

docs/howto/supplier_tree.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -112,7 +112,7 @@ print(mapping2mermaid(rows, title=title))
112112
The table below shows the values for the decision model.
113113
Each row of the table corresponds to a path through the decision model diagram above.
114114

115-
% include-markdown "../_includes/_scrollable_table.md" heading-offset=1 %}
115+
{% include-markdown "../_includes/_scrollable_table.md" heading-offset=1 %}
116116

117117
```python exec="true" idprefix=""
118118

src/README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ After installation, import the package and explore the examples:
2727
print(to_play.LATEST.model_dump_json(indent=2))
2828

2929
#Show decision tree in ascii text art
30-
from ssvc.decision_tables.base import ascii_tree
30+
from ssvc.decision_tables.helpers import ascii_tree
3131
print(ascii_tree(to_play.LATEST))
3232

3333
Explanation
@@ -92,7 +92,7 @@ For usage in vulnerability management scenarios consider the following popular S
9292
print(CISACoordinate.model_dump_json(indent=2))
9393

9494
#Print CISA Decision Table as an ascii tree
95-
from ssvc.decision_tables.base import ascii_tree
95+
from ssvc.decision_tables.helpers import ascii_tree
9696
print(ascii_tree(CISACoordinate))
9797

9898

src/ssvc/decision_tables/base.py

Lines changed: 3 additions & 84 deletions
Original file line numberDiff line numberDiff line change
@@ -702,88 +702,7 @@ def check_topological_order(dt: DecisionTable) -> list[dict]:
702702
df, target=target, target_value_order=target_value_order
703703
)
704704

705-
def build_tree(df: pd.DataFrame, columns: pd.Index | list[str]) -> dict[str, dict[str, str] | list[str]] | list[str]:
706-
"""
707-
Recursively build a nested dict:
708-
{feature_value: subtree_or_list_of_outcomes}
709-
710-
This tree should preserve the original row order of the DataFrame.
711-
"""
712-
# Base case: if only one column is left, it's the outcome.
713-
if len(columns) == 1:
714-
# Last column: return a list of outcomes.
715-
return df[columns[0]].astype(str).tolist()
716-
717-
# Get the first feature column and the rest of the columns.
718-
first, rest = columns[0], columns[1:]
719-
tree = {}
720-
721-
# Iterate through the unique values of the first column in the order they appear.
722-
# This is the key change to preserve the original CSV order.
723-
for val in df[first].unique():
724-
# Filter the DataFrame to get the group for the current value.
725-
group = df[df[first] == val]
726-
# Recursively build the subtree for this group.
727-
tree[str(val)] = build_tree(group, rest)
728-
729-
return tree
730-
731-
def draw_tree(node: dict | list, prefix: str="", lines: list | None = None) -> list:
732-
"""
733-
Pretty-print nested dict/list as a tree.
734-
"""
735-
if lines is None:
736-
lines = []
737-
738-
if isinstance(node, dict):
739-
items = list(node.items())
740-
for i, (k, v) in enumerate(items):
741-
# Determine the branch characters for the tree.
742-
branch = "└── " if i == len(items) - 1 else "├── "
743-
lines.append(prefix + branch + k + " " * 4)
744-
745-
# Calculate the prefix for the next level of the tree.
746-
next_prefix = prefix + (" " * 16 if i == len(items) - 1 else "│" + " " * 15)
747-
# Recursively draw the subtree.
748-
draw_tree(v, next_prefix, lines)
749-
else: # list of outcomes
750-
for i, leaf in enumerate(node):
751-
# Determine the branch characters for the leaves.
752-
branch = "└── " if i == len(node) - 1 else "├── "
753-
lines.append(prefix + branch + f"[{leaf}]")
754-
755-
return lines
756-
757705
def ascii_tree(dt: DecisionTable, df: pd.DataFrame | None = None) -> str:
758-
"""
759-
Reads a Pandas data frame, builds a decision tree, and returns its ASCII representation.
760-
"""
761-
# Check for the optional 'row' column and drop it if it exists.
762-
if df == None:
763-
df = decision_table_to_longform_df(dt)
764-
765-
if 'row' in df.columns:
766-
df.drop(columns='row', inplace=True)
767-
768-
# Separate feature columns from the outcome column.
769-
feature_cols = list(df.columns[:-1])
770-
outcome_col = df.columns[-1]
771-
772-
# Build the tree structure.
773-
tree = build_tree(df, feature_cols + [outcome_col])
774-
# Draw the tree into a list of strings.
775-
lines = draw_tree(tree)
776-
777-
# Generate the header line.
778-
header = ""
779-
for item in df.columns:
780-
if len(item) > 14:
781-
header = header + item[0:12] + ".." + " | "
782-
else:
783-
header = header + item + " " * (14 - len(item)) + " | "
784-
785-
# Generate the separator line.
786-
sep = "-" * len(header)
787-
788-
# Combine the header, separator, and tree lines into a single string.
789-
return "\n".join([header, sep] + lines)
706+
""" Function moved to helpers.py see there for details """
707+
from . import helpers
708+
return helpers.ascii_tree(dt, df)

src/ssvc/decision_tables/helpers.py

Lines changed: 158 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424

2525

2626
import logging
27+
import pandas as pd
2728

2829
from ssvc.decision_tables.base import (
2930
DecisionTable,
@@ -215,7 +216,7 @@ def mapping2mermaid(rows: list[dict[str:str]], title: str = None) -> str:
215216

216217

217218
def dt2df_md(
218-
dt: "DecisionTable",
219+
dt: DecisionTable,
219220
longform: bool = True,
220221
) -> str:
221222
"""
@@ -224,7 +225,7 @@ def dt2df_md(
224225
decision_table (DecisionTable): The decision table to convert.
225226
longform (bool): Whether to return the longform or shortform DataFrame.
226227
Returns:
227-
str: A string representation of the DataFrame in CSV format.
228+
str: A string representation of the DataFrame in text/markdown format.
228229
"""
229230
if longform:
230231
df = decision_table_to_longform_df(dt)
@@ -234,6 +235,161 @@ def dt2df_md(
234235
df.index.rename("Row", inplace=True)
235236
return df.to_markdown(index=True)
236237

238+
def dt2df_html(
239+
dt: DecisionTable,
240+
longform: bool = True) -> str:
241+
"""
242+
Converts a Decision Tree and represent it in friendly HTML Code
243+
Args:
244+
decision_table (DecisionTable): The decision table to convert.
245+
longform (bool): Whether to return the longform or shortform DataFram, defaults to true
246+
Returns:
247+
str: A string representation of the DataFrame in HTML format.
248+
"""
249+
250+
if longform:
251+
df = decision_table_to_longform_df(dt)
252+
else:
253+
df = decision_table_to_shortform_df(dt)
254+
255+
df = decision_table_to_longform_df(dt)
256+
ncols = len(df.columns)
257+
nrows = len(df)
258+
259+
# Precompute rowspan info for every cell
260+
# rowspan[i][j] = number of rows this cell should span; 0 means skip (because merged above)
261+
rowspan = [[1]*ncols for _ in range(nrows)]
262+
263+
for col in range(ncols):
264+
r = 0
265+
while r < nrows:
266+
start = r
267+
val = df.iat[r, col] #data_rows[r][col]
268+
# Count how many subsequent rows have same value
269+
while r + 1 < nrows and df.iat[r + 1, col] == val:#data_rows[r + 1][col] == val:
270+
r += 1
271+
span = r - start + 1
272+
if span > 1:
273+
# Assign span to first, mark rest as 0 (skip)
274+
rowspan[start][col] = span
275+
for k in range(start + 1, start + span):
276+
rowspan[k][col] = 0
277+
r += 1
278+
279+
# Build HTML
280+
html = ["""<style>
281+
table,th,td,tr { border-spacing: 0px; border: 1px solid cyan; padding: 0px; font-family: verdana,courier }
282+
.decision_table th { font-weight: bold; }
283+
td.decision_point { vertical-align: middle}
284+
td.outcome { font-style: italic; font-weight: bold}
285+
</style>"""]
286+
html.append("<table class=\"decision_table\">")
287+
html.append(" <tr>" + "".join(f"<th>{h}</th>" for h in df.columns) + "</tr>")
288+
289+
for i, row in df.iterrows(): #for i, row in enumerate(df):
290+
cells = []
291+
j = 0
292+
for _, val in row.items(): #enumerate(row):
293+
tdtype = "decision_point"
294+
if j == len(row) - 1:
295+
tdtype = "outcome"
296+
if rowspan[i][j] > 0:
297+
span = rowspan[i][j]
298+
if span > 1:
299+
cells.append(f'<td rowspan="{span}" class="{tdtype}">{val}</td>')
300+
else:
301+
cells.append(f'<td class="{tdtype}">{val}</td>')
302+
j = j + 1
303+
html.append(" <tr>" + "".join(cells) + "</tr>")
304+
305+
html.append("</table>")
306+
return "".join(html)
307+
308+
def build_tree(df: pd.DataFrame, columns: pd.Index | list[str]) -> dict[str, dict[str, str] | list[str]] | list[str]:
309+
"""
310+
Helper function recursively build a nested dict:
311+
{feature_value: subtree_or_list_of_outcomes}
312+
This tree should preserve the original row order of the DataFrame.
313+
"""
314+
# Base case: if only one column is left, it's the outcome.
315+
if len(columns) == 1:
316+
# Last column: return a list of outcomes.
317+
return df[columns[0]].astype(str).tolist()
318+
319+
# Get the first feature column and the rest of the columns.
320+
first, rest = columns[0], columns[1:]
321+
tree = {}
322+
323+
# Iterate through the unique values of the first column in the order they appear.
324+
# This is the key change to preserve the original CSV order.
325+
for val in df[first].unique():
326+
# Filter the DataFrame to get the group for the current value.
327+
group = df[df[first] == val]
328+
# Recursively build the subtree for this group.
329+
tree[str(val)] = build_tree(group, rest)
330+
331+
return tree
332+
333+
def draw_tree(node: dict | list, prefix: str="", lines: list | None = None) -> list:
334+
"""
335+
Pretty-print nested dict/list as a tree.
336+
"""
337+
if lines is None:
338+
lines = []
339+
340+
if isinstance(node, dict):
341+
items = list(node.items())
342+
for i, (k, v) in enumerate(items):
343+
# Determine the branch characters for the tree.
344+
branch = "└── " if i == len(items) - 1 else "├── "
345+
lines.append(prefix + branch + k + " " * 4)
346+
347+
# Calculate the prefix for the next level of the tree.
348+
next_prefix = prefix + (" " * 16 if i == len(items) - 1 else "│" + " " * 15)
349+
# Recursively draw the subtree.
350+
draw_tree(v, next_prefix, lines)
351+
else: # list of outcomes
352+
for i, leaf in enumerate(node):
353+
# Determine the branch characters for the leaves.
354+
branch = "└── " if i == len(node) - 1 else "├── "
355+
lines.append(prefix + branch + f"[{leaf}]")
356+
357+
return lines
358+
359+
def ascii_tree(dt: DecisionTable, df: pd.DataFrame | None = None) -> str:
360+
"""
361+
Reads a Pandas data frame, builds a decision tree, and returns its ASCII representation.
362+
"""
363+
# Check for the optional 'row' column and drop it if it exists.
364+
if df == None:
365+
df = decision_table_to_longform_df(dt)
366+
367+
if 'row' in df.columns:
368+
df.drop(columns='row', inplace=True)
369+
370+
# Separate feature columns from the outcome column.
371+
feature_cols = list(df.columns[:-1])
372+
outcome_col = df.columns[-1]
373+
374+
# Build the tree structure.
375+
tree = build_tree(df, feature_cols + [outcome_col])
376+
# Draw the tree into a list of strings.
377+
lines = draw_tree(tree)
378+
379+
# Generate the header line.
380+
header = ""
381+
for item in df.columns:
382+
if len(item) > 14:
383+
header = header + item[0:12] + ".." + " | "
384+
else:
385+
header = header + item + " " * (14 - len(item)) + " | "
386+
387+
# Generate the separator line.
388+
sep = "-" * len(header)
389+
390+
# Combine the header, separator, and tree lines into a single string.
391+
return "\n".join([header, sep] + lines)
392+
237393

238394
def main():
239395
pass

0 commit comments

Comments
 (0)