Skip to content

Commit 311989d

Browse files
Seppli11guillaume-dequenne
authored andcommitted
SCANPY-119 New bootstrapping: Version Check (#129)
1 parent 03ac834 commit 311989d

File tree

12 files changed

+592
-8
lines changed

12 files changed

+592
-8
lines changed

its/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ licenseheaders = ">=0.8.8"
3939
black = "25.1.0"
4040
pytest = ">=7.4.3"
4141
pytest-docker = ">=2.0.1"
42-
requests = "2.31.0"
42+
requests = "^2.32.3"
4343
pysonar-scanner = { "path" = "../" }
4444

4545
[[tool.poetry.packages]]

poetry.lock

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

pyproject.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ relative_files = true
3030
[tool.poetry.dependencies]
3131
python = '>=3.9'
3232
toml = '>=0.10.2'
33+
requests = "^2.32.3"
34+
responses = "^0.25.6"
3335

3436
[tool.poetry.group]
3537
[tool.poetry.group.dev]

src/pysonar_scanner/api.py

Lines changed: 65 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,46 @@
1919
#
2020
from dataclasses import dataclass
2121
from pysonar_scanner.configuration import Configuration
22+
from pysonar_scanner.exceptions import SonarQubeApiException
2223
from pysonar_scanner.utils import remove_trailing_slash
24+
import requests
25+
26+
27+
@dataclass(frozen=True)
28+
class SQVersion:
29+
parts: list[str]
30+
31+
def __get_part(self, index: int) -> int:
32+
if index >= len(self.parts):
33+
return 0
34+
part = self.parts[index]
35+
if not part.isdigit():
36+
return 0
37+
return int(part)
38+
39+
def major(self) -> int:
40+
return self.__get_part(0)
41+
42+
def minor(self) -> int:
43+
return self.__get_part(1)
44+
45+
def does_support_bootstrapping(self) -> bool:
46+
if len(self.parts) == 0:
47+
return False
48+
49+
return self.major() > MIN_SUPPORTED_SQ_VERSION.major() or (
50+
self.major() == MIN_SUPPORTED_SQ_VERSION.major() and self.minor() >= MIN_SUPPORTED_SQ_VERSION.minor()
51+
)
52+
53+
def __str__(self) -> str:
54+
return ".".join(self.parts)
55+
56+
@staticmethod
57+
def from_str(version: str) -> "SQVersion":
58+
return SQVersion(version.split("."))
59+
60+
61+
MIN_SUPPORTED_SQ_VERSION: SQVersion = SQVersion.from_str("10.6")
2362

2463

2564
@dataclass(frozen=True)
@@ -55,6 +94,30 @@ def region_with_dot(region: str) -> str:
5594
return BaseUrls(base_url=sonar_host_url, api_base_url=api_base_url, is_sonar_qube_cloud=False)
5695

5796

97+
class BearerAuth(requests.auth.AuthBase):
98+
def __init__(self, token):
99+
self.token = token
100+
101+
def __call__(self, r):
102+
r.headers["Authorization"] = f"Bearer {self.token}"
103+
return r
104+
105+
58106
class SonarQubeApi:
59-
def __init__(self, base_urls: BaseUrls):
60-
self.base_url = base_urls
107+
def __init__(self, base_urls: BaseUrls, token: str):
108+
self.base_urls = base_urls
109+
self.auth = BearerAuth(token)
110+
111+
def is_sonar_qube_cloud(self) -> bool:
112+
return self.base_urls.is_sonar_qube_cloud
113+
114+
def get_analysis_version(self) -> SQVersion:
115+
try:
116+
res = requests.get(f"{self.base_urls.api_base_url}/analysis/version", auth=self.auth)
117+
if res.status_code != 200:
118+
res = requests.get(f"{self.base_urls.base_url}/api/server/version", auth=self.auth)
119+
120+
res.raise_for_status()
121+
return SQVersion.from_str(res.text)
122+
except requests.RequestException as e:
123+
raise SonarQubeApiException("Error while fetching the analysis version") from e

src/pysonar_scanner/configuration.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,7 @@ class Sonar:
8383
token: str = ""
8484
host_url: str = ""
8585
region: str = ""
86+
token: str = ""
8687

8788

8889
@dataclass(frozen=True)

src/pysonar_scanner/exceptions.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
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+
21+
22+
class SonarQubeApiException(Exception):
23+
pass
24+
25+
26+
class SQTooOldException(Exception):
27+
pass

src/pysonar_scanner/scannerengine.py

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,5 +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+
from pysonar_scanner.api import SonarQubeApi
21+
import pysonar_scanner.api as api
22+
from pysonar_scanner.exceptions import SQTooOldException
23+
24+
2025
class ScannerEngine:
21-
pass
26+
def __init__(self, api: SonarQubeApi):
27+
self.api = api
28+
29+
def __version_check(self):
30+
if self.api.is_sonar_qube_cloud():
31+
return
32+
version = self.api.get_analysis_version()
33+
if not version.does_support_bootstrapping():
34+
raise SQTooOldException(
35+
f"Only SonarQube versions >= {api.MIN_SUPPORTED_SQ_VERSION} are supported, but got {version}"
36+
)

src/pysonar_scanner/utils.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,5 +17,7 @@
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+
21+
2022
def remove_trailing_slash(url: str) -> str:
2123
return url.rstrip("/ ").lstrip()

tests/sq_api_utils.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
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 contextlib
21+
from typing import Optional
22+
from typing_extensions import Self
23+
from pysonar_scanner.api import BaseUrls, SonarQubeApi
24+
import responses
25+
26+
27+
def get_sq_server() -> SonarQubeApi:
28+
return SonarQubeApi(
29+
base_urls=BaseUrls(base_url="http://sq.home", api_base_url="http://sq.home/api/v2", is_sonar_qube_cloud=False),
30+
token="<fake_token>",
31+
)
32+
33+
34+
def get_sq_cloud() -> SonarQubeApi:
35+
return SonarQubeApi(
36+
base_urls=BaseUrls(
37+
base_url="http://sonarcloud.io", api_base_url="http://api.sonarcloud.io", is_sonar_qube_cloud=True
38+
),
39+
token="<fake_token>",
40+
)
41+
42+
43+
class SQApiMocker:
44+
def __init__(self, base_url: str = "http://sq.home", rsps: Optional[responses.RequestsMock] = None):
45+
self.base_url = base_url
46+
self.api_url = f"{base_url}/api/v2"
47+
self.rsps = rsps or responses
48+
49+
def mock_analysis_version(self, version: str = "", status: int = 200) -> Self:
50+
self.rsps.get(url=f"{self.api_url}/analysis/version", body=version, status=status)
51+
return self
52+
53+
def mock_server_version(self, version: str = "", status: int = 200) -> Self:
54+
self.rsps.get(url=f"{self.base_url}/api/server/version", body=version, status=status)
55+
return self
56+
57+
58+
@contextlib.contextmanager
59+
def sq_api_mocker(base_url: str = "http://sq.home"):
60+
with responses.RequestsMock() as rsps:
61+
yield SQApiMocker(base_url=base_url, rsps=rsps)

tests/test_api.py

Lines changed: 72 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,40 @@
2020
from typing import TypedDict
2121
import unittest
2222

23-
from pysonar_scanner.api import BaseUrls, get_base_urls
23+
from pysonar_scanner.api import BaseUrls, SonarQubeApi, SonarQubeApiException, get_base_urls
2424
from pysonar_scanner.configuration import Configuration, Scanner, Sonar
2525

26+
from pysonar_scanner.api import SQVersion
27+
from tests import sq_api_utils
28+
from tests.sq_api_utils import sq_api_mocker
2629

27-
class ApiTest(unittest.TestCase):
30+
31+
class TestSQVersion(unittest.TestCase):
32+
def test_does_support_bootstrapping(self):
33+
self.assertTrue(SQVersion.from_str("10.6").does_support_bootstrapping())
34+
self.assertTrue(SQVersion.from_str("10.6.5").does_support_bootstrapping())
35+
self.assertTrue(SQVersion.from_str("10.6.5a").does_support_bootstrapping())
36+
self.assertTrue(SQVersion.from_str("10.7").does_support_bootstrapping())
37+
self.assertTrue(SQVersion.from_str("11.1").does_support_bootstrapping())
38+
self.assertTrue(SQVersion.from_str("11.1.1").does_support_bootstrapping())
39+
self.assertTrue(SQVersion.from_str("11").does_support_bootstrapping())
40+
41+
self.assertFalse(SQVersion.from_str("9.9.9").does_support_bootstrapping())
42+
self.assertFalse(SQVersion.from_str("9.9.9a").does_support_bootstrapping())
43+
self.assertFalse(SQVersion.from_str("9.9").does_support_bootstrapping())
44+
self.assertFalse(SQVersion.from_str("9").does_support_bootstrapping())
45+
self.assertFalse(SQVersion.from_str("10.5").does_support_bootstrapping())
46+
self.assertFalse(SQVersion.from_str("10").does_support_bootstrapping())
47+
48+
self.assertFalse(SQVersion.from_str("a").does_support_bootstrapping())
49+
self.assertFalse(SQVersion.from_str("1.a").does_support_bootstrapping())
50+
51+
def test_str(self):
52+
self.assertEqual(str(SQVersion.from_str("10.6")), "10.6")
53+
self.assertEqual(str(SQVersion.from_str("9.9.9aa")), "9.9.9aa")
54+
55+
56+
class TestApi(unittest.TestCase):
2857
def test_BaseUrls_normalization(self):
2958
self.assertEqual(
3059
BaseUrls("test1/", "test2/", is_sonar_qube_cloud=True),
@@ -136,3 +165,44 @@ class TestCaseDict(TypedDict):
136165
self.assertEqual(base_urls.api_base_url, expected.api_base_url)
137166
self.assertEqual(base_urls.is_sonar_qube_cloud, expected.is_sonar_qube_cloud)
138167
self.assertEqual(base_urls, expected)
168+
169+
170+
class TestSonarQubeApiWithUnreachableSQServer(unittest.TestCase):
171+
def setUp(self):
172+
self.sq = SonarQubeApi(
173+
base_urls=BaseUrls("https://localhost:1000", "https://localhost:1000/api", is_sonar_qube_cloud=True),
174+
token="<invalid_token>",
175+
)
176+
177+
def test_get_analysis_version(self):
178+
with self.assertRaises(SonarQubeApiException):
179+
self.sq.get_analysis_version()
180+
181+
182+
class TestSonarQubeApi(unittest.TestCase):
183+
def setUp(self):
184+
self.sq = sq_api_utils.get_sq_server()
185+
186+
def test_get_analysis_version(self):
187+
with self.subTest("/analysis/version returns 200"), sq_api_mocker() as mocker:
188+
mocker.mock_analysis_version("10.7")
189+
self.assertEqual(self.sq.get_analysis_version(), SQVersion.from_str("10.7"))
190+
191+
with self.subTest("/analysis/version returns error"), sq_api_mocker() as mocker:
192+
mocker.mock_analysis_version(status=404).mock_server_version("10.8")
193+
self.assertEqual(self.sq.get_analysis_version(), SQVersion.from_str("10.8"))
194+
195+
with (
196+
self.subTest("both version endpoints return error"),
197+
sq_api_mocker() as mocker,
198+
self.assertRaises(SonarQubeApiException),
199+
):
200+
mocker.mock_analysis_version(status=404).mock_server_version(status=404)
201+
self.sq.get_analysis_version()
202+
203+
with (
204+
self.subTest("request raises an exception"),
205+
sq_api_mocker() as mocker,
206+
self.assertRaises(SonarQubeApiException),
207+
):
208+
self.sq.get_analysis_version()

0 commit comments

Comments
 (0)