Skip to content

Commit 87c678e

Browse files
authored
SCANPY-148 Stream logs from the scanner to the user (#157)
1 parent 6f925e6 commit 87c678e

File tree

6 files changed

+298
-98
lines changed

6 files changed

+298
-98
lines changed

poetry.lock

Lines changed: 38 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

src/pysonar_scanner/__main__.py

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,21 +18,30 @@
1818
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
#
2020

21-
from pysonar_scanner import cache
21+
from pysonar_scanner import app_logging, cache
2222
from pysonar_scanner import configuration
2323
from pysonar_scanner.api import get_base_urls, SonarQubeApi
2424
from pysonar_scanner.configuration import ConfigurationLoader
25+
from pysonar_scanner.configuration.properties import SONAR_VERBOSE
2526
from pysonar_scanner.scannerengine import ScannerEngine
2627

2728

2829
def scan():
30+
app_logging.setup()
31+
2932
config = ConfigurationLoader.load()
33+
set_logging_options(config)
34+
3035
cache_manager = cache.get_default()
3136
api = __build_api(config)
3237
scanner = ScannerEngine(api, cache_manager)
3338
return scanner.run(config)
3439

3540

41+
def set_logging_options(config):
42+
app_logging.configure_logging_level(verbose=config.get(SONAR_VERBOSE, False))
43+
44+
3645
def __build_api(config: dict[str, any]) -> SonarQubeApi:
3746
token = configuration.get_token(config)
3847
base_urls = get_base_urls(config)

src/pysonar_scanner/app_logging.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
#
2+
# Sonar Scanner Python
3+
# Copyright (C) 2011-2024 SonarSource SA.
4+
# mailto:info AT sonarsource DOT com
5+
#
6+
# This program is free software; you can redistribute it and/or
7+
# modify it under the terms of the GNU Lesser General Public
8+
# License as published by the Free Software Foundation; either
9+
# version 3 of the License, or (at your option) any later version.
10+
# This program is distributed in the hope that it will be useful,
11+
#
12+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
# Lesser General Public License for more details.
15+
#
16+
# You should have received a copy of the GNU Lesser General Public License
17+
# along with this program; if not, write to the Free Software Foundation,
18+
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
#
20+
import logging
21+
22+
23+
def setup() -> None:
24+
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
25+
26+
27+
def configure_logging_level(verbose: bool) -> None:
28+
logging.getLogger().setLevel(logging.DEBUG if verbose else logging.INFO)

src/pysonar_scanner/scannerengine.py

Lines changed: 83 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -17,9 +17,15 @@
1717
# along with this program; if not, write to the Free Software Foundation,
1818
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
#
20+
from enum import Enum
2021
import json
22+
import logging
23+
from operator import le
2124
import pathlib
22-
from typing import Optional
25+
from threading import Thread
26+
from typing import IO, Callable, Optional
27+
28+
from dataclasses import dataclass
2329

2430
import pysonar_scanner.api as api
2531

@@ -30,6 +36,80 @@
3036
from subprocess import Popen, PIPE
3137

3238

39+
@dataclass(frozen=True)
40+
class LogLine:
41+
level: str
42+
message: str
43+
stacktrace: Optional[str] = None
44+
45+
def get_logging_level(self) -> int:
46+
if self.level == "ERROR":
47+
return logging.ERROR
48+
if self.level == "WARN":
49+
return logging.WARNING
50+
if self.level == "INFO":
51+
return logging.INFO
52+
if self.level == "DEBUG":
53+
return logging.DEBUG
54+
if self.level == "TRACE":
55+
return logging.DEBUG
56+
return logging.INFO
57+
58+
59+
def parse_log_line(line: str) -> LogLine:
60+
try:
61+
line_json = json.loads(line)
62+
level = line_json.get("level", "INFO")
63+
message = line_json.get("message", line)
64+
stacktrace = line_json.get("stacktrace")
65+
return LogLine(level=level, message=message, stacktrace=stacktrace)
66+
except json.JSONDecodeError:
67+
return LogLine(level="INFO", message=line, stacktrace=None)
68+
69+
70+
def default_log_line_listener(log_line: LogLine):
71+
logging.log(log_line.get_logging_level(), log_line.message)
72+
if log_line.stacktrace is not None:
73+
logging.log(log_line.get_logging_level(), log_line.stacktrace)
74+
75+
76+
class CmdExecutor:
77+
def __init__(
78+
self,
79+
cmd: list[str],
80+
properties_str: str,
81+
log_line_listener: Callable[[LogLine], None] = default_log_line_listener,
82+
):
83+
self.cmd = cmd
84+
self.properties_str = properties_str
85+
self.log_line_listener = log_line_listener
86+
87+
def execute(self):
88+
process = Popen(self.cmd, stdin=PIPE, stdout=PIPE, stderr=PIPE)
89+
process.stdin.write(self.properties_str.encode())
90+
process.stdin.close()
91+
92+
output_thread = Thread(target=self.__log_output, args=(process.stdout,))
93+
error_thread = Thread(target=self.__log_output, args=(process.stderr,))
94+
95+
return self.__process_output(output_thread, error_thread, process)
96+
97+
def __log_output(self, stream: IO[bytes]):
98+
for line in stream:
99+
decoded_line = line.decode("utf-8").rstrip()
100+
log_line = parse_log_line(decoded_line)
101+
self.log_line_listener(log_line)
102+
103+
def __process_output(self, output_thread: Thread, error_thread: Thread, process: Popen) -> int:
104+
output_thread.start()
105+
error_thread.start()
106+
process.wait()
107+
output_thread.join()
108+
error_thread.join()
109+
110+
return process.returncode
111+
112+
33113
class ScannerEngineProvisioner:
34114
def __init__(self, api: SonarQubeApi, cache: Cache):
35115
self.api = api
@@ -71,7 +151,8 @@ def run(self, config: dict[str, any]):
71151
jre_path = self.__resolve_jre(config)
72152
scanner_engine_path = self.__fetch_scanner_engine()
73153
cmd = self.__build_command(jre_path, scanner_engine_path)
74-
return self.__execute_scanner_engine(config, cmd)
154+
properties_str = self.__config_to_json(config)
155+
return CmdExecutor(cmd, properties_str).execute()
75156

76157
def __build_command(self, jre_path: JREResolvedPath, scanner_engine_path: pathlib.Path) -> list[str]:
77158
cmd = []
@@ -80,35 +161,10 @@ def __build_command(self, jre_path: JREResolvedPath, scanner_engine_path: pathli
80161
cmd.append(scanner_engine_path)
81162
return cmd
82163

83-
def __execute_scanner_engine(self, config: dict[str, any], cmd: list[str]) -> int:
84-
popen = Popen(cmd, stdout=PIPE, stderr=PIPE, stdin=PIPE)
85-
outs, _ = popen.communicate(self.__config_to_json(config).encode())
86-
exitcode = popen.wait() # 0 means success
87-
self.__extract_errors_from_log(outs)
88-
if exitcode != 0:
89-
errors = self.__extract_errors_from_log(outs)
90-
raise RuntimeError(f"Scan failed with exit code {exitcode}", errors)
91-
return exitcode
92-
93164
def __config_to_json(self, config: dict[str, any]) -> str:
94165
scanner_properties = [{"key": k, "value": v} for k, v in config.items()]
95166
return json.dumps({"scannerProperties": scanner_properties})
96167

97-
def __extract_errors_from_log(self, outs: str) -> list[str]:
98-
try:
99-
errors = []
100-
for line in outs.decode("utf-8").split("\n"):
101-
if line.strip() == "":
102-
continue
103-
out_json = json.loads(line)
104-
if out_json["level"] == "ERROR":
105-
errors.append(out_json["message"])
106-
print(f"{out_json['level']}: {out_json['message']}")
107-
return errors
108-
except Exception as e:
109-
print(e)
110-
return []
111-
112168
def __version_check(self):
113169
if self.api.is_sonar_qube_cloud():
114170
return

tests/test_main.py

Lines changed: 8 additions & 40 deletions
Original file line numberDiff line numberDiff line change
@@ -17,52 +17,20 @@
1717
# along with this program; if not, write to the Free Software Foundation,
1818
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
#
20-
import unittest
2120
from unittest import mock
2221
from unittest.mock import patch
2322

24-
from pysonar_scanner.exceptions import MissingKeyException
25-
from pysonar_scanner.jre import JRE, JREResolvedPath
23+
from pyfakefs import fake_filesystem_unittest as pyfakefs
24+
25+
from pysonar_scanner.configuration import ConfigurationLoader
26+
from pysonar_scanner.configuration.properties import SONAR_PROJECT_KEY, SONAR_TOKEN
2627
from pysonar_scanner.__main__ import scan
27-
from pysonar_scanner import __main__ as main
2828
from pysonar_scanner.scannerengine import ScannerEngine
2929

3030

31-
class TestMain(unittest.TestCase):
32-
33-
@patch("pysonar_scanner.scannerengine.Popen")
34-
@patch.object(ScannerEngine, "_ScannerEngine__resolve_jre")
35-
@patch.object(ScannerEngine, "_ScannerEngine__fetch_scanner_engine")
36-
@patch(
37-
"sys.argv",
38-
["pysonar-scanner", "-t", "myToken", "--sonar-project-key", "myProjectKey"],
39-
)
40-
def test_minimal_run_success(self, mock_fetch_scanner_engine, mock_resolve_jre, mock_popen):
41-
mock_resolve_jre.return_value = JREResolvedPath("")
42-
mock_fetch_scanner_engine.return_value = None
43-
process_mock = mock.Mock()
44-
attrs = {"communicate.return_value": ("output", "error"), "wait.return_value": 0}
45-
process_mock.configure_mock(**attrs)
46-
mock_popen.return_value = process_mock
47-
31+
class TestMain(pyfakefs.TestCase):
32+
@patch.object(ConfigurationLoader, "load", return_value={SONAR_TOKEN: "myToken", SONAR_PROJECT_KEY: "myProjectKey"})
33+
@patch.object(ScannerEngine, "run", return_value=0)
34+
def test_minimal_success_run(self, load_mock, run_mock):
4835
exitcode = scan()
4936
self.assertEqual(exitcode, 0)
50-
51-
@patch("pysonar_scanner.scannerengine.Popen")
52-
@patch.object(ScannerEngine, "_ScannerEngine__resolve_jre")
53-
@patch.object(ScannerEngine, "_ScannerEngine__fetch_scanner_engine")
54-
@patch(
55-
"sys.argv",
56-
["pysonar-scanner", "-t", "myToken", "--sonar-project-key", "myProjectKey"],
57-
)
58-
def test_minimal_run_failure(self, mock_fetch_scanner_engine, mock_resolve_jre, mock_popen):
59-
mock_resolve_jre.return_value = JREResolvedPath("")
60-
process_mock = mock.Mock()
61-
mock_fetch_scanner_engine.return_value = None
62-
attrs = {"communicate.return_value": ("output", "error"), "wait.return_value": 2}
63-
process_mock.configure_mock(**attrs)
64-
mock_popen.return_value = process_mock
65-
with self.assertRaises(RuntimeError) as error:
66-
scan()
67-
print(str(error))
68-
self.assertIn("Scan failed with exit code 2", str(error.exception))

0 commit comments

Comments
 (0)