Skip to content

Commit 4a8fec7

Browse files
SCANPY-80 Read ".coveragerc" configuration to exclude files from coverage
1 parent 0a47f10 commit 4a8fec7

File tree

8 files changed

+232
-0
lines changed

8 files changed

+232
-0
lines changed

CLI_ARGS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
| `--skip-jre-provisioning`, `-Dsonar.scanner.skipJreProvisioning` | If provided, the provisioning of the JRE will be skipped |
5252
| `--sonar-branch-name`, `-Dsonar.branch.name` | Name of the branch being analyzed |
5353
| `--sonar-build-string`, `-Dsonar.buildString` | The string passed with this property will be stored with the analysis and available in the results of api/project_analyses/search, thus allowing you to later identify a specific analysis and obtain its key for use with api/new_code_periods/set on the SPECIFIC_ANALYSIS type |
54+
| `--sonar-coverage-exclusions`, `--sonar.coverage.exclusions`, `-Dsonar.coverage.exclusions` | Defines the source files to be excluded from the code coverage analysis. |
5455
| `--sonar-cpd-python-minimum-lines`, `-Dsonar.cpd.python.minimumLines` | Minimum number of tokens to be considered as a duplicated block of code |
5556
| `--sonar-cpd-python-minimum-tokens`, `-Dsonar.cpd.python.minimumTokens` | Minimum number of tokens to be considered as a duplicated block of code |
5657
| `--sonar-links-ci`, `-Dsonar.links.ci` | The URL of the continuous integration system used |

src/pysonar_scanner/configuration/cli.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,13 @@ def __create_parser(cls):
469469
type=str,
470470
help="Comma-delimited list of paths to coverage reports in the Cobertura XML format.",
471471
)
472+
reports_group.add_argument(
473+
"--sonar-coverage-exclusions",
474+
"--sonar.coverage.exclusions",
475+
"-Dsonar.coverage.exclusions",
476+
type=str,
477+
help="Defines the source files to be excluded from the code coverage analysis.",
478+
)
472479
reports_group.add_argument(
473480
"-Dsonar.python.skipUnchanged",
474481
type=bool,

src/pysonar_scanner/configuration/configuration_loader.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
from typing import Any
2323

2424
from pysonar_scanner.configuration.cli import CliConfigurationLoader
25+
from pysonar_scanner.configuration.coveragerc_loader import CoverageRCConfigurationLoader
2526
from pysonar_scanner.configuration.pyproject_toml import TomlConfigurationLoader
2627
from pysonar_scanner.configuration.properties import SONAR_PROJECT_KEY, SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, Key
2728
from pysonar_scanner.configuration.properties import PROPERTIES
@@ -50,9 +51,11 @@ def load() -> dict[Key, Any]:
5051
toml_path_property = cli_properties.get("toml-path", ".")
5152
toml_dir = Path(toml_path_property) if "toml-path" in cli_properties else base_dir
5253
toml_properties = TomlConfigurationLoader.load(toml_dir)
54+
coverage_properties = CoverageRCConfigurationLoader.load(base_dir)
5355

5456
resolved_properties = get_static_default_properties()
5557
resolved_properties.update(dynamic_defaults_loader.load())
58+
resolved_properties.update(coverage_properties)
5659
resolved_properties.update(toml_properties.project_properties)
5760
resolved_properties.update(sonar_project_properties.load(base_dir))
5861
resolved_properties.update(toml_properties.sonar_properties)
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 configparser
21+
import logging
22+
import pathlib
23+
from typing import Any
24+
25+
26+
class CoverageRCConfigurationLoader:
27+
28+
@staticmethod
29+
def load(base_dir: pathlib.Path) -> dict[str, str]:
30+
config_file_path = base_dir / ".coveragerc"
31+
result_dict: dict[str, str] = {}
32+
33+
coverage_properties = CoverageRCConfigurationLoader.__read_config(config_file_path)
34+
if len(coverage_properties) == 0:
35+
return result_dict
36+
exclusion_properties = CoverageRCConfigurationLoader.__read_coverage_exclusions_properties(
37+
config_file_path, coverage_properties
38+
)
39+
result_dict.update(exclusion_properties)
40+
return result_dict
41+
42+
@staticmethod
43+
def __read_config(config_file_path: pathlib.Path) -> dict[str, Any]:
44+
config_dict: dict[str, Any] = {}
45+
if not config_file_path.exists():
46+
logging.debug(f"Configuration file not found: {config_file_path}")
47+
return config_dict
48+
49+
try:
50+
config_parser = configparser.ConfigParser()
51+
config_parser.read(config_file_path)
52+
53+
# Iterate over sections and options
54+
for section in config_parser.sections():
55+
section_values = {}
56+
for key, value in config_parser.items(section):
57+
section_values[key] = value
58+
59+
config_dict[section] = section_values
60+
except Exception as e:
61+
logging.debug(f"Error decoding coverage file {config_file_path}: {e}")
62+
return config_dict
63+
64+
@staticmethod
65+
def __read_coverage_exclusions_properties(
66+
config_file_path: pathlib.Path, coverage_properties: dict[str, Any]
67+
) -> dict[str, Any]:
68+
result_dict: dict[str, Any] = {}
69+
if "run" not in coverage_properties:
70+
logging.debug(f"The run key was not found in {config_file_path}")
71+
return result_dict
72+
73+
if "omit" not in coverage_properties["run"]:
74+
logging.debug(f"The run.omit key was not found in {config_file_path}")
75+
return result_dict
76+
77+
omit_exclusions = coverage_properties["run"]["omit"]
78+
patterns_list = [patterns.strip() for patterns in omit_exclusions.splitlines() if patterns.strip()]
79+
translated_exclusions = ", ".join(patterns_list)
80+
81+
result_dict["sonar.coverage.exclusions"] = translated_exclusions
82+
return result_dict

src/pysonar_scanner/configuration/properties.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@
8585
SONAR_PYTHON_VERSION: Key = "sonar.python.version"
8686
SONAR_PYTHON_PYLINT_REPORT_PATH: Key = "sonar.python.pylint.reportPath"
8787
SONAR_PYTHON_COVERAGE_REPORT_PATHS: Key = "sonar.python.coverage.reportPaths"
88+
SONAR_COVERAGE_EXCLUSIONS: Key = "sonar.coverage.exclusions"
8889
SONAR_PYTHON_SKIP_UNCHANGED: Key = "sonar.python.skipUnchanged"
8990
SONAR_NEWCODE_REFERENCE_BRANCH: Key = "sonar.newCode.referenceBranch"
9091
SONAR_SCM_REVISION: Key = "sonar.scm.revision"
@@ -490,6 +491,11 @@ def env_variable_name(self) -> str:
490491
default_value=None,
491492
cli_getter=lambda args: args.sonar_python_coverage_report_paths
492493
),
494+
Property(
495+
name=SONAR_COVERAGE_EXCLUSIONS,
496+
default_value=None,
497+
cli_getter=lambda args: args.sonar_coverage_exclusions
498+
),
493499
Property(
494500
name=SONAR_PYTHON_SKIP_UNCHANGED,
495501
default_value=None,

tests/unit/test_configuration_cli.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
SONAR_SCM_FORCE_RELOAD_ALL,
8989
SONAR_PYTHON_PYLINT_REPORT_PATH,
9090
SONAR_PYTHON_COVERAGE_REPORT_PATHS,
91+
SONAR_COVERAGE_EXCLUSIONS,
9192
SONAR_PYTHON_SKIP_UNCHANGED,
9293
SONAR_PYTHON_XUNIT_REPORT_PATH,
9394
SONAR_PYTHON_XUNIT_SKIP_DETAILS,
@@ -156,6 +157,7 @@
156157
SONAR_SCM_FORCE_RELOAD_ALL: True,
157158
SONAR_PYTHON_PYLINT_REPORT_PATH: "path/to/pylint/report",
158159
SONAR_PYTHON_COVERAGE_REPORT_PATHS: "path/to/coverage1,path/to/coverage2",
160+
SONAR_COVERAGE_EXCLUSIONS: "*/.local/*,/usr/*,utils/tirefire.py",
159161
SONAR_PYTHON_SKIP_UNCHANGED: True,
160162
SONAR_PYTHON_XUNIT_REPORT_PATH: "path/to/xunit/report",
161163
SONAR_PYTHON_XUNIT_SKIP_DETAILS: True,
@@ -384,6 +386,8 @@ def test_impossible_os_choice(self):
384386
"path/to/pylint/report",
385387
"--sonar-python-coverage-report-paths",
386388
"path/to/coverage1,path/to/coverage2",
389+
"--sonar-coverage-exclusions",
390+
"*/.local/*,/usr/*,utils/tirefire.py",
387391
"--sonar-python-skip-unchanged",
388392
"--sonar-python-xunit-report-path",
389393
"path/to/xunit/report",
@@ -469,6 +473,7 @@ def test_all_cli_args(self):
469473
"-Dsonar.log.level=INFO",
470474
"-Dsonar.python.pylint.reportPath=path/to/pylint/report",
471475
"-Dsonar.python.coverage.reportPaths=path/to/coverage1,path/to/coverage2",
476+
"-Dsonar.coverage.exclusions=*/.local/*,/usr/*,utils/tirefire.py",
472477
"-Dsonar.python.skipUnchanged=true",
473478
"-Dsonar.python.xunit.reportPath=path/to/xunit/report",
474479
"-Dsonar.python.xunit.skipDetails=true",

tests/unit/test_configuration_loader.py

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
SONAR_SCANNER_JAVA_OPTS,
5252
SONAR_SCANNER_ARCH,
5353
SONAR_SCANNER_OS,
54+
SONAR_COVERAGE_EXCLUSIONS,
5455
)
5556
from pysonar_scanner.utils import Arch, Os
5657
from pysonar_scanner.configuration.configuration_loader import ConfigurationLoader, SONAR_PROJECT_BASE_DIR
@@ -307,6 +308,45 @@ def test_load_pyproject_toml_from_toml_path(self, mock_get_os, mock_get_arch):
307308
}
308309
self.assertDictEqual(configuration, expected_configuration)
309310

311+
@patch(
312+
"sys.argv",
313+
[
314+
"myscript.py",
315+
],
316+
)
317+
def test_load_coveragerc_properties(self, mock_get_os, mock_get_arch):
318+
self.fs.create_dir("custom/path")
319+
self.fs.create_file(
320+
".coveragerc",
321+
contents=(
322+
"""
323+
[run]
324+
omit =
325+
*/.local/*
326+
/usr/*
327+
utils/tirefire.py
328+
"""
329+
),
330+
)
331+
configuration = ConfigurationLoader.load()
332+
expected_configuration = {
333+
SONAR_SCANNER_APP: "python",
334+
SONAR_SCANNER_APP_VERSION: "1.0",
335+
SONAR_SCANNER_BOOTSTRAP_START_TIME: configuration[SONAR_SCANNER_BOOTSTRAP_START_TIME],
336+
SONAR_VERBOSE: False,
337+
SONAR_SCANNER_SKIP_JRE_PROVISIONING: False,
338+
SONAR_PROJECT_BASE_DIR: os.getcwd(),
339+
SONAR_SCANNER_CONNECT_TIMEOUT: 5,
340+
SONAR_SCANNER_SOCKET_TIMEOUT: 60,
341+
SONAR_SCANNER_RESPONSE_TIMEOUT: 0,
342+
SONAR_SCANNER_KEYSTORE_PASSWORD: "changeit",
343+
SONAR_SCANNER_TRUSTSTORE_PASSWORD: "changeit",
344+
SONAR_SCANNER_OS: Os.LINUX.value,
345+
SONAR_SCANNER_ARCH: Arch.X64.value,
346+
SONAR_COVERAGE_EXCLUSIONS: "*/.local/*, /usr/*, utils/tirefire.py",
347+
}
348+
self.assertDictEqual(configuration, expected_configuration)
349+
310350
@patch("sys.argv", ["myscript.py"])
311351
@patch.dict("os.environ", {"SONAR_TOKEN": "TokenFromEnv", "SONAR_PROJECT_KEY": "KeyFromEnv"}, clear=True)
312352
def test_load_from_env_variables_only(self, mock_get_os, mock_get_arch):

tests/unit/test_coveragerc_loader.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
from pathlib import Path
21+
from unittest import mock
22+
from unittest.mock import MagicMock, patch
23+
24+
from pyfakefs.fake_filesystem_unittest import TestCase
25+
from pysonar_scanner.configuration.pyproject_toml import TomlConfigurationLoader
26+
from pysonar_scanner.configuration.coveragerc_loader import CoverageRCConfigurationLoader
27+
28+
29+
class TestCoverageRcFile(TestCase):
30+
def setUp(self):
31+
self.setUpPyfakefs()
32+
33+
def test_load_coverage_file(self):
34+
self.fs.create_file(
35+
".coveragerc",
36+
contents="""
37+
[run]
38+
omit =
39+
*/.local/*
40+
/usr/*
41+
utils/tirefire.py
42+
""",
43+
)
44+
properties = CoverageRCConfigurationLoader.load(Path("."))
45+
46+
self.assertEqual(properties["sonar.coverage.exclusions"], "*/.local/*, /usr/*, utils/tirefire.py")
47+
48+
@patch("pysonar_scanner.configuration.coveragerc_loader.logging")
49+
def test_load_missing_file(self, mock_logging):
50+
properties = CoverageRCConfigurationLoader.load(Path("."))
51+
self.assertEqual(len(properties), 0)
52+
mock_logging.debug.assert_called_with("Configuration file not found: .coveragerc")
53+
54+
@patch("pysonar_scanner.configuration.coveragerc_loader.logging")
55+
def test_load_without_run_section(self, mock_logging):
56+
self.fs.create_file(
57+
".coveragerc",
58+
contents="""
59+
[something_else]
60+
""",
61+
)
62+
properties = CoverageRCConfigurationLoader.load(Path("."))
63+
self.assertEqual(len(properties), 0)
64+
mock_logging.debug.assert_called_with("The run key was not found in .coveragerc")
65+
66+
@patch("pysonar_scanner.configuration.coveragerc_loader.logging")
67+
def test_load_without_exclusions_property(self, mock_logging):
68+
self.fs.create_file(
69+
".coveragerc",
70+
contents="""
71+
[run]
72+
""",
73+
)
74+
properties = CoverageRCConfigurationLoader.load(Path("."))
75+
self.assertEqual(len(properties), 0)
76+
mock_logging.debug.assert_called_with("The run.omit key was not found in .coveragerc")
77+
78+
@patch("pysonar_scanner.configuration.coveragerc_loader.logging")
79+
def test_load_malformed_file(self, mock_logging):
80+
self.fs.create_file(
81+
".coveragerc",
82+
contents="""
83+
[run
84+
omit =
85+
""",
86+
)
87+
properties = CoverageRCConfigurationLoader.load(Path("."))
88+
self.assertEqual(len(properties), 0)

0 commit comments

Comments
 (0)