Skip to content

Commit 6c70c21

Browse files
authored
CM-46733 - Add CLI output exporting in HTML, SVG, and JSON formats (#297)
1 parent aadb590 commit 6c70c21

17 files changed

+196
-93
lines changed

cycode/cli/app.py

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,15 @@
11
import logging
2+
from pathlib import Path
23
from typing import Annotated, Optional
34

45
import typer
56
from typer.completion import install_callback, show_callback
67

78
from cycode import __version__
89
from cycode.cli.apps import ai_remediation, auth, configure, ignore, report, scan, status
9-
from cycode.cli.cli_types import OutputTypeOption
10+
from cycode.cli.cli_types import ExportTypeOption, OutputTypeOption
1011
from cycode.cli.consts import CLI_CONTEXT_SETTINGS
12+
from cycode.cli.printers import ConsolePrinter
1113
from cycode.cli.user_settings.configuration_manager import ConfigurationManager
1214
from cycode.cli.utils.progress_bar import SCAN_PROGRESS_BAR_SECTIONS, get_progress_bar
1315
from cycode.cli.utils.sentry import add_breadcrumb, init_sentry
@@ -44,7 +46,14 @@ def check_latest_version_on_close(ctx: typer.Context) -> None:
4446
version_checker.check_and_notify_update(current_version=__version__, use_cache=should_use_cache)
4547

4648

49+
def export_if_needed_on_close(ctx: typer.Context) -> None:
50+
printer = ctx.obj.get('console_printer')
51+
if printer.is_recording:
52+
printer.export()
53+
54+
4755
_COMPLETION_RICH_HELP_PANEL = 'Completion options'
56+
_EXPORT_RICH_HELP_PANEL = 'Export options'
4857

4958

5059
@app.callback()
@@ -64,6 +73,27 @@ def app_callback(
6473
Optional[str],
6574
typer.Option(hidden=True, help='Characteristic JSON object that lets servers identify the application.'),
6675
] = None,
76+
export_type: Annotated[
77+
ExportTypeOption,
78+
typer.Option(
79+
'--export-type',
80+
case_sensitive=False,
81+
help='Specify the export type. '
82+
'HTML and SVG will export terminal output and rely on --output option. '
83+
'JSON always exports JSON.',
84+
rich_help_panel=_EXPORT_RICH_HELP_PANEL,
85+
),
86+
] = ExportTypeOption.JSON,
87+
export_file: Annotated[
88+
Optional[Path],
89+
typer.Option(
90+
'--export-file',
91+
help='Export file. Path to the file where the export will be saved. ',
92+
dir_okay=False,
93+
writable=True,
94+
rich_help_panel=_EXPORT_RICH_HELP_PANEL,
95+
),
96+
] = None,
6797
_: Annotated[
6898
Optional[bool],
6999
typer.Option(
@@ -104,6 +134,11 @@ def app_callback(
104134

105135
ctx.obj['progress_bar'] = get_progress_bar(hidden=no_progress_meter, sections=SCAN_PROGRESS_BAR_SECTIONS)
106136

137+
ctx.obj['export_type'] = export_type
138+
ctx.obj['export_file'] = export_file
139+
ctx.obj['console_printer'] = ConsolePrinter(ctx)
140+
ctx.call_on_close(lambda: export_if_needed_on_close(ctx))
141+
107142
if user_agent:
108143
user_agent_option = UserAgentOptionScheme().loads(user_agent)
109144
CycodeClientBase.enrich_user_agent(user_agent_option.user_agent_suffix)

cycode/cli/apps/ai_remediation/apply_fix.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,10 @@
44
from patch_ng import fromstring
55

66
from cycode.cli.models import CliResult
7-
from cycode.cli.printers import ConsolePrinter
87

98

109
def apply_fix(ctx: typer.Context, diff: str, is_fix_available: bool) -> None:
11-
printer = ConsolePrinter(ctx)
10+
printer = ctx.obj.get('console_printer')
1211
if not is_fix_available:
1312
printer.print_result(CliResult(success=False, message='Fix is not available for this violation'))
1413
return

cycode/cli/apps/ai_remediation/print_remediation.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,10 @@
33

44
from cycode.cli.console import console
55
from cycode.cli.models import CliResult
6-
from cycode.cli.printers import ConsolePrinter
76

87

98
def print_remediation(ctx: typer.Context, remediation_markdown: str, is_fix_available: bool) -> None:
10-
printer = ConsolePrinter(ctx)
9+
printer = ctx.obj.get('console_printer')
1110
if printer.is_json_printer:
1211
data = {'remediation': remediation_markdown, 'is_fix_available': is_fix_available}
1312
printer.print_result(CliResult(success=True, message='Remediation fetched successfully', data=data))

cycode/cli/apps/auth/auth_command.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,13 @@
44
from cycode.cli.exceptions.handle_auth_errors import handle_auth_exception
55
from cycode.cli.logger import logger
66
from cycode.cli.models import CliResult
7-
from cycode.cli.printers import ConsolePrinter
87
from cycode.cli.utils.sentry import add_breadcrumb
98

109

1110
def auth_command(ctx: typer.Context) -> None:
1211
"""Authenticates your machine."""
1312
add_breadcrumb('auth')
13+
printer = ctx.obj.get('console_printer')
1414

1515
if ctx.invoked_subcommand is not None:
1616
# if it is a subcommand, do nothing
@@ -23,6 +23,6 @@ def auth_command(ctx: typer.Context) -> None:
2323
auth_manager.authenticate()
2424

2525
result = CliResult(success=True, message='Successfully logged into cycode')
26-
ConsolePrinter(ctx).print_result(result)
26+
printer.print_result(result)
2727
except Exception as err:
2828
handle_auth_exception(ctx, err)

cycode/cli/apps/auth/auth_common.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,14 @@
44

55
from cycode.cli.apps.auth.models import AuthInfo
66
from cycode.cli.exceptions.custom_exceptions import HttpUnauthorizedError, RequestHttpError
7-
from cycode.cli.printers import ConsolePrinter
87
from cycode.cli.user_settings.credentials_manager import CredentialsManager
98
from cycode.cli.utils.jwt_utils import get_user_and_tenant_ids_from_access_token
109
from cycode.cyclient.cycode_token_based_client import CycodeTokenBasedClient
1110

1211

1312
def get_authorization_info(ctx: Optional[typer.Context] = None) -> Optional[AuthInfo]:
13+
printer = ctx.obj.get('console_printer')
14+
1415
client_id, client_secret = CredentialsManager().get_credentials()
1516
if not client_id or not client_secret:
1617
return None
@@ -24,6 +25,6 @@ def get_authorization_info(ctx: Optional[typer.Context] = None) -> Optional[Auth
2425
return AuthInfo(user_id=user_id, tenant_id=tenant_id)
2526
except (RequestHttpError, HttpUnauthorizedError):
2627
if ctx:
27-
ConsolePrinter(ctx).print_exception()
28+
printer.print_exception()
2829

2930
return None

cycode/cli/apps/auth/check_command.py

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,15 +2,14 @@
22

33
from cycode.cli.apps.auth.auth_common import get_authorization_info
44
from cycode.cli.models import CliResult
5-
from cycode.cli.printers import ConsolePrinter
65
from cycode.cli.utils.sentry import add_breadcrumb
76

87

98
def check_command(ctx: typer.Context) -> None:
109
"""Checks that your machine is associating the CLI with your Cycode account."""
1110
add_breadcrumb('check')
1211

13-
printer = ConsolePrinter(ctx)
12+
printer = ctx.obj.get('console_printer')
1413
auth_info = get_authorization_info(ctx)
1514
if auth_info is None:
1615
printer.print_result(CliResult(success=False, message='Cycode authentication failed'))

cycode/cli/apps/scan/code_scanner.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
from cycode.cli.files_collector.sca.sca_code_scanner import perform_pre_scan_documents_actions
2929
from cycode.cli.files_collector.zip_documents import zip_documents
3030
from cycode.cli.models import CliError, Document, DocumentDetections, LocalScanResult
31-
from cycode.cli.printers import ConsolePrinter
3231
from cycode.cli.utils import scan_utils
3332
from cycode.cli.utils.git_proxy import git_proxy
3433
from cycode.cli.utils.path_utils import get_path_by_os
@@ -304,10 +303,11 @@ def scan_documents(
304303
) -> None:
305304
scan_type = ctx.obj['scan_type']
306305
progress_bar = ctx.obj['progress_bar']
306+
printer = ctx.obj.get('console_printer')
307307

308308
if not documents_to_scan:
309309
progress_bar.stop()
310-
ConsolePrinter(ctx).print_error(
310+
printer.print_error(
311311
CliError(
312312
code='no_relevant_files',
313313
message='Error: The scan could not be completed - relevant files to scan are not found. '
@@ -569,7 +569,7 @@ def print_debug_scan_details(scan_details_response: 'ScanDetailsResponse') -> No
569569
def print_results(
570570
ctx: typer.Context, local_scan_results: List[LocalScanResult], errors: Optional[Dict[str, 'CliError']] = None
571571
) -> None:
572-
printer = ConsolePrinter(ctx)
572+
printer = ctx.obj.get('console_printer')
573573
printer.print_scan_results(local_scan_results, errors)
574574

575575

cycode/cli/cli_types.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,12 @@ class OutputTypeOption(str, Enum):
1010
TABLE = 'table'
1111

1212

13+
class ExportTypeOption(str, Enum):
14+
JSON = 'json'
15+
HTML = 'html'
16+
SVG = 'svg'
17+
18+
1319
class ScanTypeOption(str, Enum):
1420
SECRET = consts.SECRET_SCAN_TYPE
1521
SCA = consts.SCA_SCAN_TYPE

cycode/cli/exceptions/handle_errors.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,14 @@
44
import typer
55

66
from cycode.cli.models import CliError, CliErrors
7-
from cycode.cli.printers import ConsolePrinter
87
from cycode.cli.utils.sentry import capture_exception
98

109

1110
def handle_errors(
1211
ctx: typer.Context, err: BaseException, cli_errors: CliErrors, *, return_exception: bool = False
1312
) -> Optional['CliError']:
14-
ConsolePrinter(ctx).print_exception(err)
13+
printer = ctx.obj.get('console_printer')
14+
printer.print_exception(err)
1515

1616
if type(err) in cli_errors:
1717
error = cli_errors[type(err)].enrich(additional_message=str(err))
@@ -22,7 +22,7 @@ def handle_errors(
2222
if return_exception:
2323
return error
2424

25-
ConsolePrinter(ctx).print_error(error)
25+
printer.print_error(error)
2626
return None
2727

2828
if isinstance(err, click.ClickException):
@@ -34,5 +34,5 @@ def handle_errors(
3434
if return_exception:
3535
return unknown_error
3636

37-
ConsolePrinter(ctx).print_error(unknown_error)
37+
printer.print_error(unknown_error)
3838
raise typer.Exit(1)
Lines changed: 84 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
1+
import io
12
from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional, Type
23

34
import typer
5+
from rich.console import Console
46

7+
from cycode.cli import consts
8+
from cycode.cli.cli_types import ExportTypeOption
9+
from cycode.cli.console import console, console_err
510
from cycode.cli.exceptions.custom_exceptions import CycodeError
611
from cycode.cli.models import CliError, CliResult
712
from cycode.cli.printers.json_printer import JsonPrinter
@@ -27,57 +32,115 @@ class ConsolePrinter:
2732
'rich_sca': ScaTablePrinter,
2833
}
2934

30-
def __init__(self, ctx: typer.Context) -> None:
35+
def __init__(
36+
self,
37+
ctx: typer.Context,
38+
console_override: Optional['Console'] = None,
39+
console_err_override: Optional['Console'] = None,
40+
output_type_override: Optional[str] = None,
41+
) -> None:
3142
self.ctx = ctx
43+
self.console = console_override or console
44+
self.console_err = console_err_override or console_err
3245

3346
self.scan_type = self.ctx.obj.get('scan_type')
34-
self.output_type = self.ctx.obj.get('output')
47+
self.output_type = output_type_override or self.ctx.obj.get('output')
3548
self.aggregation_report_url = self.ctx.obj.get('aggregation_report_url')
3649

37-
self._printer_class = self._AVAILABLE_PRINTERS.get(self.output_type)
38-
if self._printer_class is None:
39-
raise CycodeError(f'"{self.output_type}" output type is not supported.')
50+
self.printer = self._get_scan_printer()
4051

41-
def print_scan_results(
42-
self,
43-
local_scan_results: List['LocalScanResult'],
44-
errors: Optional[Dict[str, 'CliError']] = None,
45-
) -> None:
46-
printer = self._get_scan_printer()
47-
printer.print_scan_results(local_scan_results, errors)
52+
self.console_record = None
53+
54+
self.export_type = self.ctx.obj.get('export_type')
55+
self.export_file = self.ctx.obj.get('export_file')
56+
if console_override is None and self.export_type and self.export_file:
57+
self.console_record = ConsolePrinter(
58+
ctx,
59+
console_override=Console(record=True, file=io.StringIO()),
60+
console_err_override=Console(stderr=True, record=True, file=io.StringIO()),
61+
output_type_override='json' if self.export_type == 'json' else self.output_type,
62+
)
4863

4964
def _get_scan_printer(self) -> 'PrinterBase':
50-
printer_class = self._printer_class
65+
printer_class = self._AVAILABLE_PRINTERS.get(self.output_type)
5166

5267
composite_printer = self._AVAILABLE_PRINTERS.get(f'{self.output_type}_{self.scan_type}')
5368
if composite_printer:
5469
printer_class = composite_printer
5570

56-
return printer_class(self.ctx)
71+
if not printer_class:
72+
raise CycodeError(f'"{self.output_type}" output type is not supported.')
73+
74+
return printer_class(self.ctx, self.console, self.console_err)
75+
76+
def print_scan_results(
77+
self,
78+
local_scan_results: List['LocalScanResult'],
79+
errors: Optional[Dict[str, 'CliError']] = None,
80+
) -> None:
81+
if self.console_record:
82+
self.console_record.print_scan_results(local_scan_results, errors)
83+
self.printer.print_scan_results(local_scan_results, errors)
5784

5885
def print_result(self, result: CliResult) -> None:
59-
self._printer_class(self.ctx).print_result(result)
86+
if self.console_record:
87+
self.console_record.print_result(result)
88+
self.printer.print_result(result)
6089

6190
def print_error(self, error: CliError) -> None:
62-
self._printer_class(self.ctx).print_error(error)
91+
if self.console_record:
92+
self.console_record.print_error(error)
93+
self.printer.print_error(error)
6394

6495
def print_exception(self, e: Optional[BaseException] = None, force_print: bool = False) -> None:
6596
"""Print traceback message in stderr if verbose mode is set."""
6697
if force_print or self.ctx.obj.get('verbose', False):
67-
self._printer_class(self.ctx).print_exception(e)
98+
if self.console_record:
99+
self.console_record.print_exception(e)
100+
self.printer.print_exception(e)
101+
102+
def export(self) -> None:
103+
if self.console_record is None:
104+
raise CycodeError('Console recording was not enabled. Cannot export.')
105+
106+
if not self.export_file.suffix:
107+
# resolve file extension based on the export type if not provided in the file name
108+
self.export_file = self.export_file.with_suffix(f'.{self.export_type.lower()}')
109+
110+
if self.export_type is ExportTypeOption.HTML:
111+
self.console_record.console.save_html(self.export_file)
112+
elif self.export_type is ExportTypeOption.SVG:
113+
self.console_record.console.save_svg(self.export_file, title=consts.APP_NAME)
114+
elif self.export_type is ExportTypeOption.JSON:
115+
with open(self.export_file, 'w', encoding='UTF-8') as f:
116+
self.console_record.console.file.seek(0)
117+
f.write(self.console_record.console.file.read())
118+
else:
119+
raise CycodeError(f'Export type "{self.export_type}" is not supported.')
120+
121+
export_format_msg = f'{self.export_type.upper()} format'
122+
if self.export_type in {ExportTypeOption.HTML, ExportTypeOption.SVG}:
123+
export_format_msg += f' with {self.output_type.upper()} output type'
124+
125+
clickable_path = f'[link=file://{self.export_file}]{self.export_file}[/link]'
126+
self.console.print(f'[b green]Cycode CLI output exported to {clickable_path} in {export_format_msg}[/]')
127+
128+
@property
129+
def is_recording(self) -> bool:
130+
return self.console_record is not None
68131

69132
@property
70133
def is_json_printer(self) -> bool:
71-
return self._printer_class == JsonPrinter
134+
return isinstance(self.printer, JsonPrinter)
72135

73136
@property
74137
def is_table_printer(self) -> bool:
75-
return self._printer_class == TablePrinter
138+
return isinstance(self.printer, TablePrinter)
76139

77140
@property
78141
def is_text_printer(self) -> bool:
79-
return self._printer_class == TextPrinter
142+
return isinstance(self.printer, TextPrinter)
80143

81144
@property
82145
def is_rich_printer(self) -> bool:
83-
return self._printer_class == RichPrinter
146+
return isinstance(self.printer, RichPrinter)

0 commit comments

Comments
 (0)