Skip to content

Commit 7289d73

Browse files
authored
SCANPY-149 Provide useful error message in case an exception is raised (#172)
1 parent 6f25157 commit 7289d73

File tree

11 files changed

+201
-38
lines changed

11 files changed

+201
-38
lines changed

src/pysonar_scanner/__main__.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@
2020

2121
from pysonar_scanner import app_logging
2222
from pysonar_scanner import cache
23+
from pysonar_scanner import exceptions
2324
from pysonar_scanner.api import get_base_urls, SonarQubeApi, BaseUrls, MIN_SUPPORTED_SQ_VERSION
2425
from pysonar_scanner.configuration import configuration_loader
2526
from pysonar_scanner.configuration.configuration_loader import ConfigurationLoader
@@ -39,11 +40,20 @@
3940

4041

4142
def scan():
43+
try:
44+
return do_scan()
45+
except Exception as e:
46+
return exceptions.log_error(e)
47+
48+
49+
def do_scan():
4250
app_logging.setup()
4351

4452
config = ConfigurationLoader.load()
4553
set_logging_options(config)
4654

55+
ConfigurationLoader.check_configuration(config)
56+
4757
api = build_api(config)
4858
check_version(api)
4959
update_config_with_api_urls(config, api.base_urls)
@@ -64,13 +74,15 @@ def build_api(config: dict[str, any]) -> SonarQubeApi:
6474
return SonarQubeApi(base_urls, token)
6575

6676

67-
def check_version(api):
77+
def check_version(api: SonarQubeApi):
6878
if api.is_sonar_qube_cloud():
6979
return
7080
version = api.get_analysis_version()
7181
if not version.does_support_bootstrapping():
7282
raise SQTooOldException(
73-
f"Only SonarQube versions >= {MIN_SUPPORTED_SQ_VERSION} are supported, but got {version}"
83+
f"This scanner only supports SonarQube versions >= {MIN_SUPPORTED_SQ_VERSION}. \n"
84+
f"The server at {api.base_urls.base_url} is on version {version}\n"
85+
"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/)."
7486
)
7587

7688

src/pysonar_scanner/api.py

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
#
2020
import typing
2121
from dataclasses import dataclass
22-
from typing import Optional, TypedDict
22+
from typing import NoReturn, Optional, TypedDict
2323

2424
import requests
2525
import requests.auth
@@ -31,8 +31,12 @@
3131
SONAR_REGION,
3232
Key,
3333
)
34-
from pysonar_scanner.exceptions import MissingKeyException, SonarQubeApiException, InconsistentConfiguration
3534
from pysonar_scanner.utils import remove_trailing_slash, OsStr, ArchStr
35+
from pysonar_scanner.exceptions import (
36+
SonarQubeApiException,
37+
InconsistentConfiguration,
38+
SonarQubeApiUnauthroizedException,
39+
)
3640

3741
GLOBAL_SONARCLOUD_URL = "https://sonarcloud.io"
3842
US_SONARCLOUD_URL = "https://sonarqube.us"
@@ -98,18 +102,15 @@ class JRE:
98102

99103
@staticmethod
100104
def from_dict(dict: dict) -> "JRE":
101-
try:
102-
return JRE(
103-
id=dict["id"],
104-
filename=dict["filename"],
105-
sha256=dict["sha256"],
106-
java_path=dict["javaPath"],
107-
os=dict["os"],
108-
arch=dict["arch"],
109-
download_url=dict.get("downloadUrl", None),
110-
)
111-
except KeyError as e:
112-
raise MissingKeyException(f"Missing key in dictionary {dict}") from e
105+
return JRE(
106+
id=dict["id"],
107+
filename=dict["filename"],
108+
sha256=dict["sha256"],
109+
java_path=dict["javaPath"],
110+
os=dict["os"],
111+
arch=dict["arch"],
112+
download_url=dict.get("downloadUrl", None),
113+
)
113114

114115

115116
ApiConfiguration = TypedDict(
@@ -188,6 +189,16 @@ def __init__(self, base_urls: BaseUrls, token: str):
188189
self.base_urls = base_urls
189190
self.auth = BearerAuth(token)
190191

192+
def __raise_exception(self, exception: Exception) -> NoReturn:
193+
if (
194+
isinstance(exception, requests.RequestException)
195+
and exception.response is not None
196+
and exception.response.status_code == 401
197+
):
198+
raise SonarQubeApiUnauthroizedException.create_default(self.base_urls.base_url) from exception
199+
else:
200+
raise SonarQubeApiException("Error while fetching the analysis version") from exception
201+
191202
def is_sonar_qube_cloud(self) -> bool:
192203
return self.base_urls.is_sonar_qube_cloud
193204

@@ -200,7 +211,7 @@ def get_analysis_version(self) -> SQVersion:
200211
res.raise_for_status()
201212
return SQVersion.from_str(res.text)
202213
except requests.RequestException as e:
203-
raise SonarQubeApiException("Error while fetching the analysis version") from e
214+
self.__raise_exception(e)
204215

205216
def get_analysis_engine(self) -> EngineInfo:
206217
try:
@@ -213,7 +224,7 @@ def get_analysis_engine(self) -> EngineInfo:
213224
raise SonarQubeApiException("Invalid response from the server")
214225
return EngineInfo(filename=json["filename"], sha256=json["sha256"])
215226
except requests.RequestException as e:
216-
raise SonarQubeApiException("Error while fetching the analysis engine information") from e
227+
self.__raise_exception(e)
217228

218229
def download_analysis_engine(self, handle: typing.BinaryIO) -> None:
219230
"""
@@ -229,7 +240,7 @@ def download_analysis_engine(self, handle: typing.BinaryIO) -> None:
229240
)
230241
self.__download_file(res, handle)
231242
except requests.RequestException as e:
232-
raise SonarQubeApiException("Error while fetching the analysis engine") from e
243+
self.__raise_exception(e)
233244

234245
def get_analysis_jres(self, os: OsStr, arch: ArchStr) -> list[JRE]:
235246
try:
@@ -243,8 +254,8 @@ def get_analysis_jres(self, os: OsStr, arch: ArchStr) -> list[JRE]:
243254
res.raise_for_status()
244255
json_array = res.json()
245256
return [JRE.from_dict(jre) for jre in json_array]
246-
except (requests.RequestException, MissingKeyException) as e:
247-
raise SonarQubeApiException("Error while fetching the analysis version") from e
257+
except (requests.RequestException, KeyError) as e:
258+
self.__raise_exception(e)
248259

249260
def download_analysis_jre(self, id: str, handle: typing.BinaryIO) -> None:
250261
"""
@@ -260,7 +271,7 @@ def download_analysis_jre(self, id: str, handle: typing.BinaryIO) -> None:
260271
)
261272
self.__download_file(res, handle)
262273
except requests.RequestException as e:
263-
raise SonarQubeApiException("Error while fetching the JRE") from e
274+
self.__raise_exception(e)
264275

265276
def __download_file(self, res: requests.Response, handle: typing.BinaryIO) -> None:
266277
res.raise_for_status()

src/pysonar_scanner/configuration/cli.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ def load(cls) -> dict[str, any]:
3737
# Handle unknown args starting with '-D'
3838
for arg in unknown_args:
3939
if not arg.startswith("-D"):
40-
raise UnexpectedCliArgument(f"Unexpected argument: {arg}")
40+
raise UnexpectedCliArgument(f"Unexpected argument: {arg}\nRun with --help for more information.")
4141
key_value = arg[2:].split("=", 1)
4242
if len(key_value) == 2:
4343
key, value = key_value

src/pysonar_scanner/configuration/configuration_loader.py

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,11 @@
2121

2222
from pysonar_scanner.configuration.cli import CliConfigurationLoader
2323
from pysonar_scanner.configuration.pyproject_toml import TomlConfigurationLoader
24-
from pysonar_scanner.configuration.properties import SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, Key
24+
from pysonar_scanner.configuration.properties import SONAR_PROJECT_KEY, SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, Key
2525
from pysonar_scanner.configuration.properties import PROPERTIES
2626
from pysonar_scanner.configuration import sonar_project_properties, environment_variables, dynamic_defaults_loader
2727

28-
from pysonar_scanner.exceptions import MissingKeyException
28+
from pysonar_scanner.exceptions import MissingProperty, MissingPropertyException
2929

3030

3131
def get_static_default_properties() -> dict[Key, any]:
@@ -56,8 +56,20 @@ def load() -> dict[Key, any]:
5656
resolved_properties.update(cli_properties)
5757
return resolved_properties
5858

59+
@staticmethod
60+
def check_configuration(config: dict[Key, any]) -> None:
61+
missing_keys = []
62+
if SONAR_TOKEN not in config:
63+
missing_keys.append(MissingProperty(SONAR_TOKEN, "--token"))
64+
65+
if SONAR_PROJECT_KEY not in config:
66+
missing_keys.append(MissingProperty(SONAR_PROJECT_KEY, "--project-key"))
67+
68+
if len(missing_keys) > 0:
69+
raise MissingPropertyException.from_missing_keys(*missing_keys)
70+
5971

6072
def get_token(config: dict[Key, any]) -> str:
6173
if SONAR_TOKEN not in config:
62-
raise MissingKeyException(f'Missing property "{SONAR_TOKEN}"')
74+
raise MissingPropertyException(f'Missing property "{SONAR_TOKEN}"')
6375
return config[SONAR_TOKEN]

src/pysonar_scanner/exceptions.py

Lines changed: 48 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,14 +19,44 @@
1919
#
2020

2121

22-
class MissingKeyException(Exception):
23-
pass
22+
from dataclasses import dataclass
23+
import logging
24+
25+
EXCEPTION_RETURN_CODE = 1
26+
27+
28+
@dataclass
29+
class MissingProperty:
30+
property: str
31+
cli_arg: str
32+
33+
34+
class MissingPropertyException(Exception):
35+
@staticmethod
36+
def from_missing_keys(*properties: MissingProperty) -> "MissingPropertyException":
37+
missing_properties = ", ".join([f"{prop.property} ({prop.cli_arg})" for prop in properties])
38+
fix_message = (
39+
"You can provide these properties using one of the following methods:\n"
40+
"- Command line arguments (e.g., --sonar.projectKey=myproject)\n"
41+
"- Environment variables (e.g., SONAR_PROJECTKEY=myproject)\n"
42+
"- Properties file (sonar-project.properties)\n"
43+
"- Project configuration files (e.g., build.gradle, pom.xml)"
44+
)
45+
return MissingPropertyException(f"Missing required properties: {missing_properties}\n\n{fix_message}")
2446

2547

2648
class SonarQubeApiException(Exception):
2749
pass
2850

2951

52+
class SonarQubeApiUnauthroizedException(SonarQubeApiException):
53+
@staticmethod
54+
def create_default(server_url: str) -> "SonarQubeApiUnauthroizedException":
55+
return SonarQubeApiUnauthroizedException(
56+
f'The provided token is invalid for the server at "{server_url}". Please check that both the token and the server URL are correct.'
57+
)
58+
59+
3060
class SQTooOldException(Exception):
3161
pass
3262

@@ -36,7 +66,9 @@ class InconsistentConfiguration(Exception):
3666

3767

3868
class ChecksumException(Exception):
39-
pass
69+
@staticmethod
70+
def create(what: str) -> "ChecksumException":
71+
return ChecksumException(f"Checksum mismatch. The downloaded {what} is corrupted.")
4072

4173

4274
class UnexpectedCliArgument(Exception):
@@ -53,3 +85,16 @@ class NoJreAvailableException(JreProvisioningException):
5385

5486
class UnsupportedArchiveFormat(JreProvisioningException):
5587
pass
88+
89+
90+
def log_error(e: Exception):
91+
logger = logging.getLogger()
92+
is_debug_level = logger.getEffectiveLevel() <= logging.DEBUG
93+
94+
if is_debug_level:
95+
logger.error("The following exception occured while running the analysis", exc_info=True)
96+
else:
97+
logger.error(str(e), exc_info=False)
98+
logger.info("For more details, please enable debug logging by passing the --verbose option.")
99+
100+
return EXCEPTION_RETURN_CODE

src/pysonar_scanner/jre.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -69,9 +69,7 @@ def __attempt_provisioning_jre_with_retry(self) -> tuple[JRE, pathlib.Path]:
6969
if jre_and_resolved_path is None:
7070
jre_and_resolved_path = self.__attempt_provisioning_jre()
7171
if jre_and_resolved_path is None:
72-
raise ChecksumException(
73-
f"Failed to download and verify JRE for {self.sonar_scanner_os} and {self.sonar_scanner_arch}"
74-
)
72+
raise ChecksumException.create("JRE")
7573

7674
return jre_and_resolved_path
7775

@@ -129,7 +127,9 @@ def __extract_jre(self, file_path: pathlib.Path, unzip_dir: pathlib.Path):
129127
with tarfile.open(file_path, "r:gz") as tar_ref:
130128
tar_ref.extractall(unzip_dir, filter="data")
131129
else:
132-
raise UnsupportedArchiveFormat(f"Unsupported archive format: {file_path.suffix}")
130+
raise UnsupportedArchiveFormat(
131+
f"Received JRE is packaged as an unsupported archive format: {file_path.suffix}"
132+
)
133133

134134

135135
@dataclass(frozen=True)

src/pysonar_scanner/scannerengine.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ def provision(self) -> pathlib.Path:
119119
if scanner_file is not None:
120120
return scanner_file.filepath
121121
else:
122-
raise ChecksumException("Failed to download and verify scanner engine")
122+
raise ChecksumException.create("scanner engine JAR")
123123

124124
def __download_and_verify(self) -> Optional[CacheFile]:
125125
engine_info = self.api.get_analysis_engine()

tests/its/test_minimal.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,6 @@ def test_minimal_project_unexpected_arg(cli: CliClient):
4646

4747

4848
def test_invalid_token(sonarqube_client: SonarQubeClient, cli: CliClient):
49-
process = cli.run_analysis(sources_dir="minimal", token="invalid")
49+
process = cli.run_analysis(params=["--verbose"], sources_dir="minimal", token="invalid")
5050
assert process.returncode == 1, str(process.stdout)
51-
assert "401 Client Error" in process.stdout
51+
assert "HTTPError: 401 Client Error" in process.stdout

tests/unit/test_configuration_loader.py

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,8 +51,9 @@
5151
SONAR_SCANNER_ARCH,
5252
SONAR_SCANNER_OS,
5353
)
54-
from pysonar_scanner.exceptions import MissingKeyException
5554
from pysonar_scanner.utils import Arch, Os
55+
from pysonar_scanner.configuration.configuration_loader import ConfigurationLoader, SONAR_PROJECT_BASE_DIR
56+
from pysonar_scanner.exceptions import MissingPropertyException
5657

5758

5859
# 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):
116117
with self.subTest("Token is present"):
117118
self.assertEqual(configuration_loader.get_token({SONAR_TOKEN: "myToken"}), "myToken")
118119

119-
with self.subTest("Token is absent"), self.assertRaises(MissingKeyException):
120+
with self.subTest("Token is absent"), self.assertRaises(MissingPropertyException):
120121
configuration_loader.get_token({})
121122

122123
@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):
428429
self.assertEqual(configuration["another.unknown.property"], "anotherValue")
429430
self.assertEqual(configuration[SONAR_TOKEN], "myToken")
430431
self.assertEqual(configuration[SONAR_PROJECT_KEY], "myProjectKey")
432+
433+
def test_check_configuration(self, mock_get_os, mock_get_arch):
434+
with self.subTest("Both values present"):
435+
ConfigurationLoader.check_configuration({SONAR_TOKEN: "", SONAR_PROJECT_KEY: ""})
436+
437+
with self.subTest("missing keys"):
438+
with self.assertRaises(MissingPropertyException) as cm:
439+
ConfigurationLoader.check_configuration({SONAR_PROJECT_KEY: "myKey"})
440+
self.assertIn(SONAR_TOKEN, str(cm.exception))
441+
442+
with self.assertRaises(MissingPropertyException) as cm:
443+
ConfigurationLoader.check_configuration({SONAR_TOKEN: "myToken"})
444+
self.assertIn(SONAR_PROJECT_KEY, str(cm.exception))
445+
446+
with self.assertRaises(MissingPropertyException) as cm:
447+
ConfigurationLoader.check_configuration({})
448+
self.assertIn(SONAR_PROJECT_KEY, str(cm.exception))
449+
self.assertIn(SONAR_TOKEN, str(cm.exception))

0 commit comments

Comments
 (0)