33import re
44import shutil
55import sqlite3
6+ import subprocess # nosec
67import sys
78from collections .abc import Iterable
89from pathlib import Path
9- from typing import Optional
10+ from typing import (
11+ Optional ,
12+ Union ,
13+ )
1014
1115import nox
1216from nox import Session
1721 Config ,
1822)
1923
20- COVERAGE_FILE = ".coverage"
24+ COVERAGE_DB = ".coverage"
2125COVERAGE_XML = "ci-coverage.xml"
2226LINT_JSON = ".lint.json"
2327LINT_TXT = ".lint.txt"
2428SECURITY_JSON = ".security.json"
2529
26- ALL_LINT_FILES = {COVERAGE_FILE , LINT_JSON , LINT_TXT , SECURITY_JSON }
30+ ALL_LINT_FILES = {COVERAGE_DB , LINT_JSON , LINT_TXT , SECURITY_JSON }
2731COVERAGE_TABLES = {"coverage_schema" , "meta" , "file" , "line_bits" }
2832LINT_JSON_ATTRIBUTES = {
2933 "type" ,
@@ -54,7 +58,7 @@ def check_artifacts(session: Session) -> None:
5458 _is_valid_lint_txt (Path (PROJECT_CONFIG .root , LINT_TXT )),
5559 _is_valid_lint_json (Path (PROJECT_CONFIG .root , LINT_JSON )),
5660 _is_valid_security_json (Path (PROJECT_CONFIG .root , SECURITY_JSON )),
57- _is_valid_coverage (Path (PROJECT_CONFIG .root , COVERAGE_FILE )),
61+ _is_valid_coverage (Path (PROJECT_CONFIG .root , COVERAGE_DB )),
5862 ]
5963 if not all (all_is_valid_checks ):
6064 sys .exit (1 )
@@ -145,7 +149,7 @@ def copy_artifacts(session: Session) -> None:
145149
146150 artifact_dir = Path (session .posargs [0 ])
147151 suffix = _python_version_suffix ()
148- _combine_coverage (session , artifact_dir , f"coverage{ suffix } */{ COVERAGE_FILE } " )
152+ _combine_coverage (session , artifact_dir , f"coverage{ suffix } */{ COVERAGE_DB } " )
149153 _copy_artifacts (
150154 artifact_dir ,
151155 artifact_dir .parent ,
@@ -183,9 +187,45 @@ def _copy_artifacts(source: Path, dest: Path, files: Iterable[str]):
183187 print (f"File not found { path } " , file = sys .stderr )
184188
185189
186- def _prepare_coverage_xml (session : Session , source : Path ) -> None :
187- command = ["coverage" , "xml" , "-o" , COVERAGE_XML , "--include" , f"{ source } /*" ]
188- session .run (* command )
190+ def _prepare_coverage_xml (
191+ session : Session , source : Path , cwd : Union [Path , None ] = None
192+ ) -> None :
193+ """
194+ Prepare the coverage XML for input into Sonar
195+
196+ The coverage XML is used within Sonar to determine the overall coverage in our
197+ project and the coverage associated with source code changes made in a PR. If the
198+ code coverage associated with changes made in our PR does not meet the set Sonar
199+ guidelines for that project, then when the sonarqubecloud bot writes in the PR, it
200+ will indicate that the coverage constraint was not met, &, if the bot is required in
201+ the branch protections for that project, then it will block the PR from being
202+ merged. Thus, in the preparation of the coverage XML, we set `--fail-under=0` so
203+ that this sub-step will not fail prior to sending the data to Sonar. Otherwise, this
204+ sub-step would fail whenever the `fail_under` in `[tool.coverage.report]` of the
205+ `pyproject.toml` was not met.
206+ """
207+ command = [
208+ "coverage" ,
209+ "xml" ,
210+ "-o" ,
211+ COVERAGE_XML ,
212+ "--include" ,
213+ f"{ source } /*" ,
214+ "--fail-under=0" ,
215+ ]
216+ output = subprocess .run (command , capture_output = True , text = True , cwd = cwd ) # nosec
217+ if output .returncode != 0 :
218+ if output .stdout .strip () == "No data to report." :
219+ # Assuming that previous steps passed in the CI, this indicates — as
220+ # is in the case for newly created projects — that no coverage over the
221+ # `source` files was found. In this scenario, we do not need the CI to
222+ # break from a non-zero exit code. This is because without a coverage file
223+ # the sonarqubecloud bot will still report that the coverage is 0%, but as
224+ # long as the bot is not required in the project's branch protections, then
225+ # the PR can still be merged, provided that the other required constraints
226+ # have been met.
227+ return
228+ session .error (output .returncode , output .stdout , output .stderr )
189229
190230
191231def _upload_to_sonar (
@@ -195,8 +235,6 @@ def _upload_to_sonar(
195235 "pysonar" ,
196236 "--sonar-token" ,
197237 sonar_token ,
198- "--sonar-python-coverage-report-paths" ,
199- COVERAGE_XML ,
200238 "--sonar-python-pylint-report-path" ,
201239 LINT_JSON ,
202240 "--sonar-python-bandit-report-paths" ,
@@ -206,6 +244,8 @@ def _upload_to_sonar(
206244 "--sonar-sources" ,
207245 config .source ,
208246 ]
247+ if Path (COVERAGE_XML ).exists ():
248+ command .extend (["--sonar-python-coverage-report-paths" , COVERAGE_XML ])
209249 session .run (* command ) # type: ignore
210250
211251
0 commit comments