Skip to content

Commit 830196d

Browse files
Seppli11guillaume-dequenne
authored andcommitted
SCANPY-101: New bootstrapping: Fetch the Scanner Engine (#133)
1 parent 311989d commit 830196d

File tree

13 files changed

+413
-24
lines changed

13 files changed

+413
-24
lines changed

poetry.lock

Lines changed: 18 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: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ python = '>=3.9'
3232
toml = '>=0.10.2'
3333
requests = "^2.32.3"
3434
responses = "^0.25.6"
35+
pyfakefs = "^5.7.4"
3536

3637
[tool.poetry.group]
3738
[tool.poetry.group.dev]

src/pysonar_scanner/api.py

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,11 +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+
import typing
2021
from dataclasses import dataclass
22+
23+
import requests
24+
import requests.auth
25+
2126
from pysonar_scanner.configuration import Configuration
2227
from pysonar_scanner.exceptions import SonarQubeApiException
2328
from pysonar_scanner.utils import remove_trailing_slash
24-
import requests
2529

2630

2731
@dataclass(frozen=True)
@@ -103,6 +107,12 @@ def __call__(self, r):
103107
return r
104108

105109

110+
@dataclass(frozen=True)
111+
class EngineInfo:
112+
filename: str
113+
sha256: str
114+
115+
106116
class SonarQubeApi:
107117
def __init__(self, base_urls: BaseUrls, token: str):
108118
self.base_urls = base_urls
@@ -121,3 +131,41 @@ def get_analysis_version(self) -> SQVersion:
121131
return SQVersion.from_str(res.text)
122132
except requests.RequestException as e:
123133
raise SonarQubeApiException("Error while fetching the analysis version") from e
134+
135+
def get_analysis_engine(self) -> EngineInfo:
136+
try:
137+
res = requests.get(
138+
f"{self.base_urls.api_base_url}/analysis/engine", headers={"Accept": "application/json"}, auth=self.auth
139+
)
140+
res.raise_for_status()
141+
json = res.json()
142+
if "filename" not in json or "sha256" not in json:
143+
raise SonarQubeApiException("Invalid response from the server")
144+
return EngineInfo(filename=json["filename"], sha256=json["sha256"])
145+
except requests.RequestException as e:
146+
raise SonarQubeApiException("Error while fetching the analysis engine information") from e
147+
148+
def download_analysis_engine(self, handle: typing.BinaryIO) -> None:
149+
"""
150+
This method can raise a SonarQubeApiException if the server doesn't respond successfully.
151+
Alternative, if the file IO fails, an IOError or OSError can be raised.
152+
"""
153+
154+
def fetch_response():
155+
res = requests.get(
156+
f"{self.base_urls.api_base_url}/analysis/engine",
157+
headers={"Accept": "application/octet-stream"},
158+
auth=self.auth,
159+
)
160+
res.raise_for_status()
161+
return res
162+
163+
def download_file(handle: typing.BinaryIO, requests: requests.Response):
164+
for chunk in requests.iter_content(chunk_size=128):
165+
handle.write(chunk)
166+
167+
try:
168+
res = fetch_response()
169+
download_file(handle, res)
170+
except requests.RequestException as e:
171+
raise SonarQubeApiException("Error while fetching the analysis engine information") from e

src/pysonar_scanner/cache.py

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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+
import pathlib
22+
import typing
23+
from dataclasses import dataclass
24+
25+
from pysonar_scanner import utils
26+
27+
OpenBinaryMode = typing.Literal["rb", "xb"]
28+
29+
30+
@dataclass(frozen=True)
31+
class CacheFile:
32+
filepath: pathlib.Path
33+
checksum: str
34+
35+
def is_valid(self) -> bool:
36+
try:
37+
with open(self.filepath, "rb") as f:
38+
calculated_checksum = utils.calculate_checksum(f)
39+
40+
return calculated_checksum == self.checksum
41+
except OSError:
42+
return False
43+
44+
def open(self, mode: OpenBinaryMode = "rb") -> typing.BinaryIO:
45+
return open(self.filepath, mode=mode)
46+
47+
48+
class Cache:
49+
def __init__(self, cache_folder: pathlib.Path):
50+
if not cache_folder.exists():
51+
raise FileNotFoundError(f"Cache folder {cache_folder} does not exist")
52+
self.cache_folder = cache_folder
53+
54+
def get_file(self, filename: str, checksum: str) -> CacheFile:
55+
path = self.cache_folder / filename
56+
return CacheFile(path, checksum)
57+
58+
@staticmethod
59+
def create_cache(cache_folder: pathlib.Path):
60+
if not cache_folder.exists():
61+
cache_folder.mkdir(parents=True)
62+
return Cache(cache_folder)
63+
64+
65+
def get_default() -> Cache:
66+
return Cache.create_cache(pathlib.Path.home() / ".sonar-scanner")

src/pysonar_scanner/configuration.py

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,6 @@
1818
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
#
2020
import time
21-
2221
from dataclasses import dataclass
2322
from enum import Enum
2423
from typing import Optional

src/pysonar_scanner/exceptions.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,7 @@ class SonarQubeApiException(Exception):
2525

2626
class SQTooOldException(Exception):
2727
pass
28+
29+
30+
class ChecksumException(Exception):
31+
pass

src/pysonar_scanner/scannerengine.py

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,42 @@
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
2120
import pysonar_scanner.api as api
22-
from pysonar_scanner.exceptions import SQTooOldException
21+
from pysonar_scanner.api import SonarQubeApi
22+
from pysonar_scanner.cache import Cache, CacheFile
23+
from pysonar_scanner.exceptions import ChecksumException, SQTooOldException
24+
25+
26+
class ScannerEngineProvisioner:
27+
def __init__(self, api: SonarQubeApi, cache: Cache):
28+
self.api = api
29+
self.cache = cache
30+
31+
def provision(self) -> None:
32+
if self.__download_and_verify():
33+
return
34+
# Retry once in case the checksum failed due to the scanner engine being updated between getting the checksum and downloading the jar
35+
if self.__download_and_verify():
36+
return
37+
else:
38+
raise ChecksumException("Failed to download and verify scanner engine")
39+
40+
def __download_and_verify(self) -> bool:
41+
engine_info = self.api.get_analysis_engine()
42+
cache_file = self.cache.get_file(engine_info.filename, engine_info.sha256)
43+
if not cache_file.is_valid():
44+
self.__download_scanner_engine(cache_file)
45+
return cache_file.is_valid()
46+
47+
def __download_scanner_engine(self, cache_file: CacheFile) -> None:
48+
with cache_file.open(mode="wb") as f:
49+
self.api.download_analysis_engine(f)
2350

2451

2552
class ScannerEngine:
26-
def __init__(self, api: SonarQubeApi):
53+
def __init__(self, api: SonarQubeApi, cache: Cache):
2754
self.api = api
55+
self.cache = cache
2856

2957
def __version_check(self):
3058
if self.api.is_sonar_qube_cloud():
@@ -34,3 +62,6 @@ def __version_check(self):
3462
raise SQTooOldException(
3563
f"Only SonarQube versions >= {api.MIN_SUPPORTED_SQ_VERSION} are supported, but got {version}"
3664
)
65+
66+
def __fetch_scanner_engine(self):
67+
ScannerEngineProvisioner(self.api, self.cache).provision()

src/pysonar_scanner/utils.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,16 @@
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 hashlib
21+
import typing
2022

2123

2224
def remove_trailing_slash(url: str) -> str:
2325
return url.rstrip("/ ").lstrip()
26+
27+
28+
def calculate_checksum(filehandle: typing.BinaryIO) -> str:
29+
sha256_hash = hashlib.sha256()
30+
for byte_block in iter(lambda: filehandle.read(4096), b""):
31+
sha256_hash.update(byte_block)
32+
return sha256_hash.hexdigest()

tests/sq_api_utils.py

Lines changed: 33 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -19,9 +19,9 @@
1919
#
2020
import contextlib
2121
from typing import Optional
22-
from typing_extensions import Self
2322
from pysonar_scanner.api import BaseUrls, SonarQubeApi
2423
import responses
24+
from responses import matchers
2525

2626

2727
def get_sq_server() -> SonarQubeApi:
@@ -46,16 +46,40 @@ def __init__(self, base_url: str = "http://sq.home", rsps: Optional[responses.Re
4646
self.api_url = f"{base_url}/api/v2"
4747
self.rsps = rsps or responses
4848

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
49+
def mock_analysis_version(self, version: str = "", status: int = 200) -> responses.BaseResponse:
50+
return self.rsps.get(url=f"{self.api_url}/analysis/version", body=version, status=status)
5251

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
52+
def mock_analysis_engine(
53+
self, filename: Optional[str] = None, sha256: Optional[str] = None, status: int = 200
54+
) -> responses.BaseResponse:
55+
def prepare_json_obj() -> dict:
56+
json_response = {}
57+
if filename:
58+
json_response["filename"] = filename
59+
if sha256:
60+
json_response["sha256"] = sha256
61+
return json_response
62+
63+
return self.rsps.get(
64+
url=f"{self.api_url}/analysis/engine",
65+
json=prepare_json_obj(),
66+
status=status,
67+
match=[matchers.header_matcher({"Accept": "application/json"})],
68+
)
69+
70+
def mock_analysis_engine_download(self, body: bytes = b"", status: int = 200) -> responses.BaseResponse:
71+
return self.rsps.get(
72+
url=f"{self.api_url}/analysis/engine",
73+
body=body,
74+
status=status,
75+
match=[matchers.header_matcher({"Accept": "application/octet-stream"})],
76+
)
77+
78+
def mock_server_version(self, version: str = "", status: int = 200) -> responses.BaseResponse:
79+
return self.rsps.get(url=f"{self.base_url}/api/server/version", body=version, status=status)
5680

5781

5882
@contextlib.contextmanager
59-
def sq_api_mocker(base_url: str = "http://sq.home"):
60-
with responses.RequestsMock() as rsps:
83+
def sq_api_mocker(base_url: str = "http://sq.home", assert_all_requests_are_fired: bool = True):
84+
with responses.RequestsMock(assert_all_requests_are_fired=assert_all_requests_are_fired) as rsps:
6185
yield SQApiMocker(base_url=base_url, rsps=rsps)

0 commit comments

Comments
 (0)