From ca739a6c0ff3b9e1751bccdde90380fa5187e83c Mon Sep 17 00:00:00 2001 From: Sebastian Zumbrunn Date: Fri, 28 Mar 2025 14:20:04 +0100 Subject: [PATCH 1/3] raise issue on sonar.token and sonar.projectKey --- src/pysonar_scanner/__main__.py | 2 ++ src/pysonar_scanner/api.py | 25 ++++++++----------- .../configuration/configuration_loader.py | 18 ++++++++++--- src/pysonar_scanner/exceptions.py | 17 +++++++++++-- tests/unit/test_configuration_loader.py | 23 +++++++++++++++-- 5 files changed, 64 insertions(+), 21 deletions(-) diff --git a/src/pysonar_scanner/__main__.py b/src/pysonar_scanner/__main__.py index 31c35854..72453381 100644 --- a/src/pysonar_scanner/__main__.py +++ b/src/pysonar_scanner/__main__.py @@ -44,6 +44,8 @@ def scan(): config = ConfigurationLoader.load() set_logging_options(config) + ConfigurationLoader.check_configuration(config) + api = build_api(config) check_version(api) update_config_with_api_urls(config, api.base_urls) diff --git a/src/pysonar_scanner/api.py b/src/pysonar_scanner/api.py index e13de8f9..8f16958d 100644 --- a/src/pysonar_scanner/api.py +++ b/src/pysonar_scanner/api.py @@ -31,8 +31,8 @@ SONAR_REGION, Key, ) -from pysonar_scanner.exceptions import MissingKeyException, SonarQubeApiException, InconsistentConfiguration from pysonar_scanner.utils import remove_trailing_slash, OsStr, ArchStr +from pysonar_scanner.exceptions import SonarQubeApiException, InconsistentConfiguration GLOBAL_SONARCLOUD_URL = "https://sonarcloud.io" US_SONARCLOUD_URL = "https://sonarqube.us" @@ -98,18 +98,15 @@ class JRE: @staticmethod def from_dict(dict: dict) -> "JRE": - try: - return JRE( - id=dict["id"], - filename=dict["filename"], - sha256=dict["sha256"], - java_path=dict["javaPath"], - os=dict["os"], - arch=dict["arch"], - download_url=dict.get("downloadUrl", None), - ) - except KeyError as e: - raise MissingKeyException(f"Missing key in dictionary {dict}") from e + return JRE( + id=dict["id"], + filename=dict["filename"], + sha256=dict["sha256"], + java_path=dict["javaPath"], + os=dict["os"], + arch=dict["arch"], + download_url=dict.get("downloadUrl", None), + ) ApiConfiguration = TypedDict( @@ -243,7 +240,7 @@ def get_analysis_jres(self, os: OsStr, arch: ArchStr) -> list[JRE]: res.raise_for_status() json_array = res.json() return [JRE.from_dict(jre) for jre in json_array] - except (requests.RequestException, MissingKeyException) as e: + except (requests.RequestException, KeyError) as e: raise SonarQubeApiException("Error while fetching the analysis version") from e def download_analysis_jre(self, id: str, handle: typing.BinaryIO) -> None: diff --git a/src/pysonar_scanner/configuration/configuration_loader.py b/src/pysonar_scanner/configuration/configuration_loader.py index bf3a0130..d0207d51 100644 --- a/src/pysonar_scanner/configuration/configuration_loader.py +++ b/src/pysonar_scanner/configuration/configuration_loader.py @@ -21,11 +21,11 @@ from pysonar_scanner.configuration.cli import CliConfigurationLoader from pysonar_scanner.configuration.pyproject_toml import TomlConfigurationLoader -from pysonar_scanner.configuration.properties import SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, Key +from pysonar_scanner.configuration.properties import SONAR_PROJECT_KEY, SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, Key from pysonar_scanner.configuration.properties import PROPERTIES from pysonar_scanner.configuration import sonar_project_properties, environment_variables, dynamic_defaults_loader -from pysonar_scanner.exceptions import MissingKeyException +from pysonar_scanner.exceptions import MissingProperty, MissingPropertyException def get_static_default_properties() -> dict[Key, any]: @@ -56,8 +56,20 @@ def load() -> dict[Key, any]: resolved_properties.update(cli_properties) return resolved_properties + @staticmethod + def check_configuration(config: dict[Key, any]) -> None: + missing_keys = [] + if SONAR_TOKEN not in config: + missing_keys.append(MissingProperty(SONAR_TOKEN, "--token")) + + if SONAR_PROJECT_KEY not in config: + missing_keys.append(MissingProperty(SONAR_PROJECT_KEY, "--project-key")) + + if len(missing_keys) > 0: + raise MissingPropertyException.from_missing_keys(*missing_keys) + def get_token(config: dict[Key, any]) -> str: if SONAR_TOKEN not in config: - raise MissingKeyException(f'Missing property "{SONAR_TOKEN}"') + raise MissingPropertyException(f'Missing property "{SONAR_TOKEN}"') return config[SONAR_TOKEN] diff --git a/src/pysonar_scanner/exceptions.py b/src/pysonar_scanner/exceptions.py index b19f3291..2fb07a9a 100644 --- a/src/pysonar_scanner/exceptions.py +++ b/src/pysonar_scanner/exceptions.py @@ -19,8 +19,21 @@ # -class MissingKeyException(Exception): - pass +from dataclasses import dataclass +import logging + + +@dataclass +class MissingProperty: + property: str + cli_arg: str + + +class MissingPropertyException(Exception): + @staticmethod + def from_missing_keys(*properties: MissingProperty) -> "MissingPropertyException": + missing_properties = ", ".join([f"{prop.property} ({prop.cli_arg})" for prop in properties]) + return MissingPropertyException(f"Missing required properties: {missing_properties}") class SonarQubeApiException(Exception): diff --git a/tests/unit/test_configuration_loader.py b/tests/unit/test_configuration_loader.py index bc317cd4..c2545057 100644 --- a/tests/unit/test_configuration_loader.py +++ b/tests/unit/test_configuration_loader.py @@ -51,8 +51,9 @@ SONAR_SCANNER_ARCH, SONAR_SCANNER_OS, ) -from pysonar_scanner.exceptions import MissingKeyException from pysonar_scanner.utils import Arch, Os +from pysonar_scanner.configuration.configuration_loader import ConfigurationLoader, SONAR_PROJECT_BASE_DIR +from pysonar_scanner.exceptions import MissingPropertyException # Mock utils.get_os and utils.get_arch at the module level @@ -116,7 +117,7 @@ def test_get_token(self, mock_get_os, mock_get_arch): with self.subTest("Token is present"): self.assertEqual(configuration_loader.get_token({SONAR_TOKEN: "myToken"}), "myToken") - with self.subTest("Token is absent"), self.assertRaises(MissingKeyException): + with self.subTest("Token is absent"), self.assertRaises(MissingPropertyException): configuration_loader.get_token({}) @patch("sys.argv", ["myscript.py", "--token", "myToken", "--sonar-project-key", "myProjectKey"]) @@ -428,3 +429,21 @@ def test_unknown_args_with_D_prefix(self, mock_get_os, mock_get_arch): self.assertEqual(configuration["another.unknown.property"], "anotherValue") self.assertEqual(configuration[SONAR_TOKEN], "myToken") self.assertEqual(configuration[SONAR_PROJECT_KEY], "myProjectKey") + + def test_check_configuration(self, mock_get_os, mock_get_arch): + with self.subTest("Both values present"): + ConfigurationLoader.check_configuration({SONAR_TOKEN: "", SONAR_PROJECT_KEY: ""}) + + with self.subTest("missing keys"): + with self.assertRaises(MissingPropertyException) as cm: + ConfigurationLoader.check_configuration({SONAR_PROJECT_KEY: "myKey"}) + self.assertIn(SONAR_TOKEN, str(cm.exception)) + + with self.assertRaises(MissingPropertyException) as cm: + ConfigurationLoader.check_configuration({SONAR_TOKEN: "myToken"}) + self.assertIn(SONAR_PROJECT_KEY, str(cm.exception)) + + with self.assertRaises(MissingPropertyException) as cm: + ConfigurationLoader.check_configuration({}) + self.assertIn(SONAR_PROJECT_KEY, str(cm.exception)) + self.assertIn(SONAR_TOKEN, str(cm.exception)) From 96e17d9c9888faa46acdc808adef37659073b8d6 Mon Sep 17 00:00:00 2001 From: Sebastian Zumbrunn Date: Fri, 28 Mar 2025 17:11:41 +0100 Subject: [PATCH 2/3] log error --- src/pysonar_scanner/__main__.py | 8 +++++ src/pysonar_scanner/exceptions.py | 11 ++++++ tests/its/test_minimal.py | 2 +- tests/unit/test_exceptions.py | 57 +++++++++++++++++++++++++++++++ tests/unit/test_main.py | 7 ++++ 5 files changed, 84 insertions(+), 1 deletion(-) create mode 100644 tests/unit/test_exceptions.py diff --git a/src/pysonar_scanner/__main__.py b/src/pysonar_scanner/__main__.py index 72453381..57465f88 100644 --- a/src/pysonar_scanner/__main__.py +++ b/src/pysonar_scanner/__main__.py @@ -20,6 +20,7 @@ from pysonar_scanner import app_logging from pysonar_scanner import cache +from pysonar_scanner import exceptions from pysonar_scanner.api import get_base_urls, SonarQubeApi, BaseUrls, MIN_SUPPORTED_SQ_VERSION from pysonar_scanner.configuration import configuration_loader from pysonar_scanner.configuration.configuration_loader import ConfigurationLoader @@ -39,6 +40,13 @@ def scan(): + try: + return do_scan() + except Exception as e: + return exceptions.log_error(e) + + +def do_scan(): app_logging.setup() config = ConfigurationLoader.load() diff --git a/src/pysonar_scanner/exceptions.py b/src/pysonar_scanner/exceptions.py index 2fb07a9a..e97846c2 100644 --- a/src/pysonar_scanner/exceptions.py +++ b/src/pysonar_scanner/exceptions.py @@ -22,6 +22,8 @@ from dataclasses import dataclass import logging +EXCEPTION_RETURN_CODE = 1 + @dataclass class MissingProperty: @@ -66,3 +68,12 @@ class NoJreAvailableException(JreProvisioningException): class UnsupportedArchiveFormat(JreProvisioningException): pass + + +def log_error(e: Exception): + logger = logging.getLogger() + is_debug_level = logger.getEffectiveLevel() <= logging.DEBUG + + logger.error(str(e), exc_info=is_debug_level) + + return EXCEPTION_RETURN_CODE diff --git a/tests/its/test_minimal.py b/tests/its/test_minimal.py index 28ab7ccd..42fdbb32 100644 --- a/tests/its/test_minimal.py +++ b/tests/its/test_minimal.py @@ -48,4 +48,4 @@ def test_minimal_project_unexpected_arg(cli: CliClient): def test_invalid_token(sonarqube_client: SonarQubeClient, cli: CliClient): process = cli.run_analysis(sources_dir="minimal", token="invalid") assert process.returncode == 1, str(process.stdout) - assert "401 Client Error" in process.stdout + assert "Error while fetching the analysis version" in process.stdout diff --git a/tests/unit/test_exceptions.py b/tests/unit/test_exceptions.py new file mode 100644 index 00000000..c4f65f5b --- /dev/null +++ b/tests/unit/test_exceptions.py @@ -0,0 +1,57 @@ +# +# Sonar Scanner Python +# Copyright (C) 2011-2024 SonarSource SA. +# mailto:info AT sonarsource DOT com +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, +# +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +import pytest +import unittest +import logging +from pysonar_scanner.exceptions import log_error, EXCEPTION_RETURN_CODE + + +class TestExceptions(unittest.TestCase): + @pytest.fixture(autouse=True) + def set_caplog(self, caplog: pytest.LogCaptureFixture): + self.caplog = caplog + + def test_log_error_returns_exception_return_code(self): + exception = Exception("Test exception") + result = log_error(exception) + self.assertEqual(result, EXCEPTION_RETURN_CODE) + + def setUp(self) -> None: + self.caplog.clear() + + def test_log_error_logs_message(self): + # Test that log_error logs the exception message + exception = Exception("Test exception") + with self.caplog.at_level(logging.ERROR): + log_error(exception) + + self.assertIn("Test exception", self.caplog.text) + self.assertNotIn("Traceback", self.caplog.text) + + def test_log_error_includes_stack_trace_in_debug_mode(self): + # raises an exception to get an Exception object with a strace trace + try: + raise Exception("Test exception") + except Exception as exception: + with self.caplog.at_level(logging.DEBUG): + log_error(exception) + + self.assertIn("Test exception", self.caplog.text) + self.assertIn("Traceback", self.caplog.text) diff --git a/tests/unit/test_main.py b/tests/unit/test_main.py index 63ae9069..c2deff85 100644 --- a/tests/unit/test_main.py +++ b/tests/unit/test_main.py @@ -84,6 +84,13 @@ def test_minimal_success_run(self, run_mock, create_jre_mock, provision_mock, lo self.assertEqual(expected_config, config) + @patch.object(ConfigurationLoader, "load") + def test_scan_with_exception(self, load_mock): + load_mock.side_effect = Exception("Test exception") + + exitcode = scan() + self.assertEqual(1, exitcode) + def test_version_check_outdated_sonarqube(self): sq_cloud_api = sq_api_utils.get_sq_server() sq_cloud_api.get_analysis_version = Mock(return_value=SQVersion.from_str("9.9.9")) From 4a4750786bd196f557f63c8408d5bc5f9d56285e Mon Sep 17 00:00:00 2001 From: Sebastian Zumbrunn Date: Mon, 31 Mar 2025 14:31:35 +0200 Subject: [PATCH 3/3] improve error messages --- src/pysonar_scanner/__main__.py | 6 +++-- src/pysonar_scanner/api.py | 28 ++++++++++++++++++------ src/pysonar_scanner/configuration/cli.py | 2 +- src/pysonar_scanner/exceptions.py | 27 ++++++++++++++++++++--- src/pysonar_scanner/jre.py | 8 +++---- src/pysonar_scanner/scannerengine.py | 2 +- tests/its/test_minimal.py | 4 ++-- 7 files changed, 57 insertions(+), 20 deletions(-) diff --git a/src/pysonar_scanner/__main__.py b/src/pysonar_scanner/__main__.py index 57465f88..0e047dd3 100644 --- a/src/pysonar_scanner/__main__.py +++ b/src/pysonar_scanner/__main__.py @@ -74,13 +74,15 @@ def build_api(config: dict[str, any]) -> SonarQubeApi: return SonarQubeApi(base_urls, token) -def check_version(api): +def check_version(api: SonarQubeApi): if api.is_sonar_qube_cloud(): return version = api.get_analysis_version() if not version.does_support_bootstrapping(): raise SQTooOldException( - f"Only SonarQube versions >= {MIN_SUPPORTED_SQ_VERSION} are supported, but got {version}" + f"This scanner only supports SonarQube versions >= {MIN_SUPPORTED_SQ_VERSION}. \n" + f"The server at {api.base_urls.base_url} is on version {version}\n" + "Please either upgrade your SonarQube server or use the Sonar Scanner CLI (see https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/scanners/sonarscanner/)." ) diff --git a/src/pysonar_scanner/api.py b/src/pysonar_scanner/api.py index 8f16958d..5b8184eb 100644 --- a/src/pysonar_scanner/api.py +++ b/src/pysonar_scanner/api.py @@ -19,7 +19,7 @@ # import typing from dataclasses import dataclass -from typing import Optional, TypedDict +from typing import NoReturn, Optional, TypedDict import requests import requests.auth @@ -32,7 +32,11 @@ Key, ) from pysonar_scanner.utils import remove_trailing_slash, OsStr, ArchStr -from pysonar_scanner.exceptions import SonarQubeApiException, InconsistentConfiguration +from pysonar_scanner.exceptions import ( + SonarQubeApiException, + InconsistentConfiguration, + SonarQubeApiUnauthroizedException, +) GLOBAL_SONARCLOUD_URL = "https://sonarcloud.io" US_SONARCLOUD_URL = "https://sonarqube.us" @@ -185,6 +189,16 @@ def __init__(self, base_urls: BaseUrls, token: str): self.base_urls = base_urls self.auth = BearerAuth(token) + def __raise_exception(self, exception: Exception) -> NoReturn: + if ( + isinstance(exception, requests.RequestException) + and exception.response is not None + and exception.response.status_code == 401 + ): + raise SonarQubeApiUnauthroizedException.create_default(self.base_urls.base_url) from exception + else: + raise SonarQubeApiException("Error while fetching the analysis version") from exception + def is_sonar_qube_cloud(self) -> bool: return self.base_urls.is_sonar_qube_cloud @@ -197,7 +211,7 @@ def get_analysis_version(self) -> SQVersion: res.raise_for_status() return SQVersion.from_str(res.text) except requests.RequestException as e: - raise SonarQubeApiException("Error while fetching the analysis version") from e + self.__raise_exception(e) def get_analysis_engine(self) -> EngineInfo: try: @@ -210,7 +224,7 @@ def get_analysis_engine(self) -> EngineInfo: raise SonarQubeApiException("Invalid response from the server") return EngineInfo(filename=json["filename"], sha256=json["sha256"]) except requests.RequestException as e: - raise SonarQubeApiException("Error while fetching the analysis engine information") from e + self.__raise_exception(e) def download_analysis_engine(self, handle: typing.BinaryIO) -> None: """ @@ -226,7 +240,7 @@ def download_analysis_engine(self, handle: typing.BinaryIO) -> None: ) self.__download_file(res, handle) except requests.RequestException as e: - raise SonarQubeApiException("Error while fetching the analysis engine") from e + self.__raise_exception(e) def get_analysis_jres(self, os: OsStr, arch: ArchStr) -> list[JRE]: try: @@ -241,7 +255,7 @@ def get_analysis_jres(self, os: OsStr, arch: ArchStr) -> list[JRE]: json_array = res.json() return [JRE.from_dict(jre) for jre in json_array] except (requests.RequestException, KeyError) as e: - raise SonarQubeApiException("Error while fetching the analysis version") from e + self.__raise_exception(e) def download_analysis_jre(self, id: str, handle: typing.BinaryIO) -> None: """ @@ -257,7 +271,7 @@ def download_analysis_jre(self, id: str, handle: typing.BinaryIO) -> None: ) self.__download_file(res, handle) except requests.RequestException as e: - raise SonarQubeApiException("Error while fetching the JRE") from e + self.__raise_exception(e) def __download_file(self, res: requests.Response, handle: typing.BinaryIO) -> None: res.raise_for_status() diff --git a/src/pysonar_scanner/configuration/cli.py b/src/pysonar_scanner/configuration/cli.py index b9dcc918..9948b910 100644 --- a/src/pysonar_scanner/configuration/cli.py +++ b/src/pysonar_scanner/configuration/cli.py @@ -37,7 +37,7 @@ def load(cls) -> dict[str, any]: # Handle unknown args starting with '-D' for arg in unknown_args: if not arg.startswith("-D"): - raise UnexpectedCliArgument(f"Unexpected argument: {arg}") + raise UnexpectedCliArgument(f"Unexpected argument: {arg}\nRun with --help for more information.") key_value = arg[2:].split("=", 1) if len(key_value) == 2: key, value = key_value diff --git a/src/pysonar_scanner/exceptions.py b/src/pysonar_scanner/exceptions.py index e97846c2..d81cbaa2 100644 --- a/src/pysonar_scanner/exceptions.py +++ b/src/pysonar_scanner/exceptions.py @@ -35,13 +35,28 @@ class MissingPropertyException(Exception): @staticmethod def from_missing_keys(*properties: MissingProperty) -> "MissingPropertyException": missing_properties = ", ".join([f"{prop.property} ({prop.cli_arg})" for prop in properties]) - return MissingPropertyException(f"Missing required properties: {missing_properties}") + fix_message = ( + "You can provide these properties using one of the following methods:\n" + "- Command line arguments (e.g., --sonar.projectKey=myproject)\n" + "- Environment variables (e.g., SONAR_PROJECTKEY=myproject)\n" + "- Properties file (sonar-project.properties)\n" + "- Project configuration files (e.g., build.gradle, pom.xml)" + ) + return MissingPropertyException(f"Missing required properties: {missing_properties}\n\n{fix_message}") class SonarQubeApiException(Exception): pass +class SonarQubeApiUnauthroizedException(SonarQubeApiException): + @staticmethod + def create_default(server_url: str) -> "SonarQubeApiUnauthroizedException": + return SonarQubeApiUnauthroizedException( + f'The provided token is invalid for the server at "{server_url}". Please check that both the token and the server URL are correct.' + ) + + class SQTooOldException(Exception): pass @@ -51,7 +66,9 @@ class InconsistentConfiguration(Exception): class ChecksumException(Exception): - pass + @staticmethod + def create(what: str) -> "ChecksumException": + return ChecksumException(f"Checksum mismatch. The downloaded {what} is corrupted.") class UnexpectedCliArgument(Exception): @@ -74,6 +91,10 @@ def log_error(e: Exception): logger = logging.getLogger() is_debug_level = logger.getEffectiveLevel() <= logging.DEBUG - logger.error(str(e), exc_info=is_debug_level) + if is_debug_level: + logger.error("The following exception occured while running the analysis", exc_info=True) + else: + logger.error(str(e), exc_info=False) + logger.info("For more details, please enable debug logging by passing the --verbose option.") return EXCEPTION_RETURN_CODE diff --git a/src/pysonar_scanner/jre.py b/src/pysonar_scanner/jre.py index 12d31e70..6e00ecef 100644 --- a/src/pysonar_scanner/jre.py +++ b/src/pysonar_scanner/jre.py @@ -69,9 +69,7 @@ def __attempt_provisioning_jre_with_retry(self) -> tuple[JRE, pathlib.Path]: if jre_and_resolved_path is None: jre_and_resolved_path = self.__attempt_provisioning_jre() if jre_and_resolved_path is None: - raise ChecksumException( - f"Failed to download and verify JRE for {self.sonar_scanner_os} and {self.sonar_scanner_arch}" - ) + raise ChecksumException.create("JRE") return jre_and_resolved_path @@ -129,7 +127,9 @@ def __extract_jre(self, file_path: pathlib.Path, unzip_dir: pathlib.Path): with tarfile.open(file_path, "r:gz") as tar_ref: tar_ref.extractall(unzip_dir, filter="data") else: - raise UnsupportedArchiveFormat(f"Unsupported archive format: {file_path.suffix}") + raise UnsupportedArchiveFormat( + f"Received JRE is packaged as an unsupported archive format: {file_path.suffix}" + ) @dataclass(frozen=True) diff --git a/src/pysonar_scanner/scannerengine.py b/src/pysonar_scanner/scannerengine.py index d6787412..5f786adc 100644 --- a/src/pysonar_scanner/scannerengine.py +++ b/src/pysonar_scanner/scannerengine.py @@ -119,7 +119,7 @@ def provision(self) -> pathlib.Path: if scanner_file is not None: return scanner_file.filepath else: - raise ChecksumException("Failed to download and verify scanner engine") + raise ChecksumException.create("scanner engine JAR") def __download_and_verify(self) -> Optional[CacheFile]: engine_info = self.api.get_analysis_engine() diff --git a/tests/its/test_minimal.py b/tests/its/test_minimal.py index 42fdbb32..b3befa06 100644 --- a/tests/its/test_minimal.py +++ b/tests/its/test_minimal.py @@ -46,6 +46,6 @@ def test_minimal_project_unexpected_arg(cli: CliClient): def test_invalid_token(sonarqube_client: SonarQubeClient, cli: CliClient): - process = cli.run_analysis(sources_dir="minimal", token="invalid") + process = cli.run_analysis(params=["--verbose"], sources_dir="minimal", token="invalid") assert process.returncode == 1, str(process.stdout) - assert "Error while fetching the analysis version" in process.stdout + assert "HTTPError: 401 Client Error" in process.stdout