Skip to content

Commit 67d504e

Browse files
committed
CM-45716 - Add rich tables with more useful information, colorful values, and clickable paths
1 parent b64c67e commit 67d504e

File tree

10 files changed

+138
-155
lines changed

10 files changed

+138
-155
lines changed

cycode/cli/cli_types.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,10 @@ class SeverityOption(str, Enum):
4242
def get_member_weight(name: str) -> int:
4343
return _SEVERITY_WEIGHTS.get(name.lower(), _SEVERITY_DEFAULT_WEIGHT)
4444

45+
@staticmethod
46+
def get_member_color(name: str) -> str:
47+
return _SEVERITY_COLORS.get(name.lower(), _SEVERITY_DEFAULT_COLOR)
48+
4549

4650
_SEVERITY_DEFAULT_WEIGHT = -1
4751
_SEVERITY_WEIGHTS = {
@@ -51,3 +55,12 @@ def get_member_weight(name: str) -> int:
5155
SeverityOption.HIGH.value: 3,
5256
SeverityOption.CRITICAL.value: 4,
5357
}
58+
59+
_SEVERITY_DEFAULT_COLOR = 'white'
60+
_SEVERITY_COLORS = {
61+
SeverityOption.INFO.value: 'deep_sky_blue1',
62+
SeverityOption.LOW.value: 'gold1',
63+
SeverityOption.MEDIUM.value: 'dark_orange',
64+
SeverityOption.HIGH.value: 'red1',
65+
SeverityOption.CRITICAL.value: 'red3',
66+
}

cycode/cli/printers/tables/sca_table_printer.py

Lines changed: 54 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
from collections import defaultdict
22
from typing import TYPE_CHECKING, Dict, List
33

4-
import click
4+
import typer
55

66
from cycode.cli.cli_types import SeverityOption
77
from cycode.cli.consts import LICENSE_COMPLIANCE_POLICY_ID, PACKAGE_VULNERABILITY_POLICY_ID
88
from cycode.cli.models import Detection
99
from cycode.cli.printers.tables.table import Table
10-
from cycode.cli.printers.tables.table_models import ColumnInfoBuilder, ColumnWidths
10+
from cycode.cli.printers.tables.table_models import ColumnInfoBuilder
1111
from cycode.cli.printers.tables.table_printer_base import TablePrinterBase
1212
from cycode.cli.utils.string_utils import shortcut_dependency_paths
1313

@@ -19,36 +19,26 @@
1919
# Building must have strict order. Represents the order of the columns in the table (from left to right)
2020
SEVERITY_COLUMN = column_builder.build(name='Severity')
2121
REPOSITORY_COLUMN = column_builder.build(name='Repository')
22-
CODE_PROJECT_COLUMN = column_builder.build(name='Code Project') # File path to manifest file
23-
ECOSYSTEM_COLUMN = column_builder.build(name='Ecosystem')
24-
PACKAGE_COLUMN = column_builder.build(name='Package')
25-
CVE_COLUMNS = column_builder.build(name='CVE')
22+
CODE_PROJECT_COLUMN = column_builder.build(name='Code Project', highlight=False) # File path to the manifest file
23+
ECOSYSTEM_COLUMN = column_builder.build(name='Ecosystem', highlight=False)
24+
PACKAGE_COLUMN = column_builder.build(name='Package', highlight=False)
25+
CVE_COLUMNS = column_builder.build(name='CVE', highlight=False)
2626
DEPENDENCY_PATHS_COLUMN = column_builder.build(name='Dependency Paths')
2727
UPGRADE_COLUMN = column_builder.build(name='Upgrade')
28-
LICENSE_COLUMN = column_builder.build(name='License')
28+
LICENSE_COLUMN = column_builder.build(name='License', highlight=False)
2929
DIRECT_DEPENDENCY_COLUMN = column_builder.build(name='Direct Dependency')
3030
DEVELOPMENT_DEPENDENCY_COLUMN = column_builder.build(name='Development Dependency')
3131

32-
COLUMN_WIDTHS_CONFIG: ColumnWidths = {
33-
REPOSITORY_COLUMN: 2,
34-
CODE_PROJECT_COLUMN: 2,
35-
PACKAGE_COLUMN: 3,
36-
CVE_COLUMNS: 5,
37-
UPGRADE_COLUMN: 3,
38-
LICENSE_COLUMN: 2,
39-
}
40-
4132

4233
class ScaTablePrinter(TablePrinterBase):
4334
def _print_results(self, local_scan_results: List['LocalScanResult']) -> None:
4435
aggregation_report_url = self.ctx.obj.get('aggregation_report_url')
4536
detections_per_policy_id = self._extract_detections_per_policy_id(local_scan_results)
4637
for policy_id, detections in detections_per_policy_id.items():
4738
table = self._get_table(policy_id)
48-
table.set_cols_width(COLUMN_WIDTHS_CONFIG)
4939

5040
for detection in self._sort_and_group_detections(detections):
51-
self._enrich_table_with_values(table, detection)
41+
self._enrich_table_with_values(policy_id, table, detection)
5242

5343
self._print_summary_issues(len(detections), self._get_title(policy_id))
5444
self._print_table(table)
@@ -90,7 +80,7 @@ def _sort_and_group_detections(self, detections: List[Detection]) -> List[Detect
9080
"""Sort detections by severity and group by repository, code project and package name.
9181
9282
Note:
93-
Code Project is path to manifest file.
83+
Code Project is path to the manifest file.
9484
9585
Grouping by code projects also groups by ecosystem.
9686
Because manifest files are unique per ecosystem.
@@ -114,55 +104,75 @@ def _get_table(self, policy_id: str) -> Table:
114104
table = Table()
115105

116106
if policy_id == PACKAGE_VULNERABILITY_POLICY_ID:
117-
table.add(SEVERITY_COLUMN)
118-
table.add(CVE_COLUMNS)
119-
table.add(UPGRADE_COLUMN)
107+
table.add_column(CVE_COLUMNS)
108+
table.add_column(UPGRADE_COLUMN)
120109
elif policy_id == LICENSE_COMPLIANCE_POLICY_ID:
121-
table.add(LICENSE_COLUMN)
110+
table.add_column(LICENSE_COLUMN)
122111

123112
if self._is_git_repository():
124-
table.add(REPOSITORY_COLUMN)
113+
table.add_column(REPOSITORY_COLUMN)
125114

126-
table.add(CODE_PROJECT_COLUMN)
127-
table.add(ECOSYSTEM_COLUMN)
128-
table.add(PACKAGE_COLUMN)
129-
table.add(DIRECT_DEPENDENCY_COLUMN)
130-
table.add(DEVELOPMENT_DEPENDENCY_COLUMN)
131-
table.add(DEPENDENCY_PATHS_COLUMN)
115+
table.add_column(SEVERITY_COLUMN)
116+
table.add_column(CODE_PROJECT_COLUMN)
117+
table.add_column(ECOSYSTEM_COLUMN)
118+
table.add_column(PACKAGE_COLUMN)
119+
table.add_column(DIRECT_DEPENDENCY_COLUMN)
120+
table.add_column(DEVELOPMENT_DEPENDENCY_COLUMN)
121+
table.add_column(DEPENDENCY_PATHS_COLUMN)
132122

133123
return table
134124

135125
@staticmethod
136-
def _enrich_table_with_values(table: Table, detection: Detection) -> None:
126+
def _enrich_table_with_values(policy_id: str, table: Table, detection: Detection) -> None:
137127
detection_details = detection.detection_details
138128

139-
table.set(SEVERITY_COLUMN, detection_details.get('advisory_severity'))
140-
table.set(REPOSITORY_COLUMN, detection_details.get('repository_name'))
141-
142-
table.set(CODE_PROJECT_COLUMN, detection_details.get('file_name'))
143-
table.set(ECOSYSTEM_COLUMN, detection_details.get('ecosystem'))
144-
table.set(PACKAGE_COLUMN, detection_details.get('package_name'))
145-
table.set(DIRECT_DEPENDENCY_COLUMN, detection_details.get('is_direct_dependency_str'))
146-
table.set(DEVELOPMENT_DEPENDENCY_COLUMN, detection_details.get('is_dev_dependency_str'))
129+
severity = None
130+
if policy_id == PACKAGE_VULNERABILITY_POLICY_ID:
131+
severity = detection_details.get('advisory_severity')
132+
elif policy_id == LICENSE_COMPLIANCE_POLICY_ID:
133+
severity = detection.severity
134+
135+
if not severity:
136+
severity = 'N/A'
137+
138+
table.add_cell(SEVERITY_COLUMN, severity, SeverityOption.get_member_color(severity))
139+
140+
table.add_cell(REPOSITORY_COLUMN, detection_details.get('repository_name'))
141+
table.add_file_path_cell(CODE_PROJECT_COLUMN, detection_details.get('file_name'))
142+
table.add_cell(ECOSYSTEM_COLUMN, detection_details.get('ecosystem'))
143+
table.add_cell(PACKAGE_COLUMN, detection_details.get('package_name'))
144+
145+
direct_dependency_color = 'green' if detection_details.get('is_direct_dependency') else 'red'
146+
table.add_cell(
147+
column=DIRECT_DEPENDENCY_COLUMN,
148+
value=detection_details.get('is_direct_dependency_str'),
149+
color=direct_dependency_color,
150+
)
151+
dev_dependency_color = 'green' if detection_details.get('is_dev_dependency') else 'red'
152+
table.add_cell(
153+
column=DEVELOPMENT_DEPENDENCY_COLUMN,
154+
value=detection_details.get('is_dev_dependency_str'),
155+
color=dev_dependency_color,
156+
)
147157

148158
dependency_paths = 'N/A'
149159
dependency_paths_raw = detection_details.get('dependency_paths')
150160
if dependency_paths_raw:
151161
dependency_paths = shortcut_dependency_paths(dependency_paths_raw)
152-
table.set(DEPENDENCY_PATHS_COLUMN, dependency_paths)
162+
table.add_cell(DEPENDENCY_PATHS_COLUMN, dependency_paths)
153163

154164
upgrade = ''
155165
alert = detection_details.get('alert')
156166
if alert and alert.get('first_patched_version'):
157167
upgrade = f'{alert.get("vulnerable_requirements")} -> {alert.get("first_patched_version")}'
158-
table.set(UPGRADE_COLUMN, upgrade)
168+
table.add_cell(UPGRADE_COLUMN, upgrade)
159169

160-
table.set(CVE_COLUMNS, detection_details.get('vulnerability_id'))
161-
table.set(LICENSE_COLUMN, detection_details.get('license'))
170+
table.add_cell(CVE_COLUMNS, detection_details.get('vulnerability_id'))
171+
table.add_cell(LICENSE_COLUMN, detection_details.get('license'))
162172

163173
@staticmethod
164174
def _print_summary_issues(detections_count: int, title: str) -> None:
165-
click.echo(f'⛔ Found {detections_count} issues of type: {click.style(title, bold=True)}')
175+
typer.echo(f'⛔ Found {detections_count} issues of type: {typer.style(title, bold=True)}')
166176

167177
@staticmethod
168178
def _extract_detections_per_policy_id(
Lines changed: 25 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -1,62 +1,59 @@
1+
import urllib.parse
12
from typing import TYPE_CHECKING, Dict, List, Optional
23

3-
from texttable import Texttable
4+
from rich.markup import escape
5+
from rich.table import Table as RichTable
46

57
if TYPE_CHECKING:
6-
from cycode.cli.printers.tables.table_models import ColumnInfo, ColumnWidths
8+
from cycode.cli.printers.tables.table_models import ColumnInfo
79

810

911
class Table:
1012
"""Helper class to manage columns and their values in the right order and only if the column should be presented."""
1113

1214
def __init__(self, column_infos: Optional[List['ColumnInfo']] = None) -> None:
13-
self._column_widths = None
14-
1515
self._columns: Dict['ColumnInfo', List[str]] = {}
1616
if column_infos:
17-
self._columns: Dict['ColumnInfo', List[str]] = {columns: [] for columns in column_infos}
17+
self._columns = {columns: [] for columns in column_infos}
1818

19-
def add(self, column: 'ColumnInfo') -> None:
19+
def add_column(self, column: 'ColumnInfo') -> None:
2020
self._columns[column] = []
2121

22-
def set(self, column: 'ColumnInfo', value: str) -> None:
22+
def _add_cell_no_error(self, column: 'ColumnInfo', value: str) -> None:
2323
# we push values only for existing columns what were added before
2424
if column in self._columns:
2525
self._columns[column].append(value)
2626

27+
def add_cell(self, column: 'ColumnInfo', value: str, color: Optional[str] = None) -> None:
28+
if color:
29+
value = f'[{color}]{value}[/{color}]'
30+
31+
self._add_cell_no_error(column, value)
32+
33+
def add_file_path_cell(self, column: 'ColumnInfo', path: str) -> None:
34+
encoded_path = urllib.parse.quote(path)
35+
escaped_path = escape(encoded_path)
36+
self._add_cell_no_error(column, f'[link file://{escaped_path}]{path}')
37+
2738
def _get_ordered_columns(self) -> List['ColumnInfo']:
2839
# we are sorting columns by index to make sure that columns will be printed in the right order
2940
return sorted(self._columns, key=lambda column_info: column_info.index)
3041

3142
def get_columns_info(self) -> List['ColumnInfo']:
3243
return self._get_ordered_columns()
3344

34-
def get_headers(self) -> List[str]:
35-
return [header.name for header in self._get_ordered_columns()]
36-
3745
def get_rows(self) -> List[str]:
3846
column_values = [self._columns[column_info] for column_info in self._get_ordered_columns()]
3947
return list(zip(*column_values))
4048

41-
def set_cols_width(self, column_widths: 'ColumnWidths') -> None:
42-
header_width_size = []
43-
for header in self.get_columns_info():
44-
width_multiplier = 1
45-
if header in column_widths:
46-
width_multiplier = column_widths[header]
47-
48-
header_width_size.append(len(header.name) * width_multiplier)
49-
50-
self._column_widths = header_width_size
51-
52-
def get_table(self, max_width: int = 80) -> Texttable:
53-
table = Texttable(max_width)
54-
table.header(self.get_headers())
49+
def get_table(self) -> 'RichTable':
50+
table = RichTable(expand=True, highlight=True)
5551

56-
for row in self.get_rows():
57-
table.add_row(row)
52+
for column in self.get_columns_info():
53+
extra_args = column.column_opts if column.column_opts else {}
54+
table.add_column(header=column.name, overflow='fold', **extra_args)
5855

59-
if self._column_widths:
60-
table.set_cols_width(self._column_widths)
56+
for raw in self.get_rows():
57+
table.add_row(*raw)
6158

6259
return table
Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,25 @@
1-
from typing import Dict, NamedTuple
1+
from typing import Any, Dict, NamedTuple, Optional
22

33

44
class ColumnInfoBuilder:
55
def __init__(self) -> None:
66
self._index = 0
77

8-
def build(self, name: str) -> 'ColumnInfo':
9-
column_info = ColumnInfo(name, self._index)
8+
def build(self, name: str, **column_opts) -> 'ColumnInfo':
9+
column_info = ColumnInfo(name, self._index, column_opts)
1010
self._index += 1
1111
return column_info
1212

1313

1414
class ColumnInfo(NamedTuple):
1515
name: str
1616
index: int # Represents the order of the columns, starting from the left
17+
column_opts: Optional[Dict] = None
1718

19+
def __hash__(self) -> int:
20+
return hash((self.name, self.index))
1821

19-
ColumnWidths = Dict[ColumnInfo, int]
20-
ColumnWidthsConfig = Dict[str, ColumnWidths]
22+
def __eq__(self, other: Any) -> bool:
23+
if not isinstance(other, ColumnInfo):
24+
return NotImplemented
25+
return (self.name, self.index) == (other.name, other.index)

0 commit comments

Comments
 (0)