Skip to content

Commit dbea81c

Browse files
SCANPY-248 Auto detect test code (#318)
1 parent c9d613f commit dbea81c

16 files changed

Lines changed: 1417 additions & 17 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,3 +171,4 @@ cython_debug/
171171
# Sonar
172172
*.scanner
173173
*.scannerwork
174+
.sonar/

CLI_ARGS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@
6868
| `--sonar-python-analysis-parallel`, `--no-sonar-python-analysis-parallel`, `--analysis-in-parallel`, `--no-analysis-in-parallel`, `-Dsonar.python.analysis.parallel` | When set to False the analysis will be single threaded |
6969
| `--sonar-python-analysis-threads`, `--nr-analysis-threads`, `-Dsonar.python.analysis.threads` | Set the number of threads to use during analysis. This property is ignored if --sonar-python-analysis-parallel is set to False |
7070
| `--sonar-python-skip-unchanged`, `--no-sonar-python-skip-unchanged` | Override the SonarQube configuration of skipping or not the analysis of unchanged Python files |
71+
| `--sonar-python-test-file-heuristic-disabled`, `--no-sonar-python-test-file-heuristic-disabled` | Disable the sonar-python heuristic that silences issues on test-like files when sonar.tests is not set. Use this to analyse all files as main code regardless of their path. |
7172
| `--sonar-qualitygate-timeout`, `-Dsonar.qualitygate.timeout` | The number of seconds that the scanner should wait for a report to be processed |
7273
| `--sonar-qualitygate-wait`, `--no-sonar-qualitygate-wait` | Forces the analysis step to poll the server instance and wait for the Quality Gate status |
7374
| `--sonar-scanner-api-url`, `-Dsonar.scanner.apiUrl` | Base URL for all REST-compliant API calls, https://api.sonarcloud.io for example |
@@ -99,6 +100,7 @@
99100
| `--sonar-working-directory`, `-Dsonar.working.directory` | Path to the working directory used by the Sonar scanner during a project analysis to store temporary data |
100101
| `--toml-path` | Path to the pyproject.toml file or to the folder containing it. If not provided, it will look in the SONAR_PROJECT_BASE_DIR |
101102
| `-Dsonar.python.skipUnchanged` | Equivalent to --sonar-python-skip-unchanged |
103+
| `-Dsonar.python.testFileHeuristic.disabled` | Equivalent to --sonar-python-test-file-heuristic-disabled |
102104
| `-Dsonar.python.xunit.skipDetails` | Equivalent to -Dsonar.python.xunit.skipDetails |
103105
| `-Dsonar.qualitygate.wait` | Equivalent to --sonar-qualitygate-wait |
104106
| `-Dsonar.scm.exclusions.disabled` | Equivalent to --sonar-scm-exclusions-disabled |

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ priority = "primary"
6565
[tool.pytest.ini_options]
6666
addopts = ['--import-mode=importlib', '--strict-markers']
6767
pythonpath = ['src']
68+
norecursedirs = ['tests/its/sources']
6869
markers = [
6970
"its: marks tests as its"
7071
]

src/pysonar_scanner/configuration/cli.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -363,6 +363,17 @@ def __create_parser(cls):
363363
action=argparse.BooleanOptionalAction,
364364
help="Override the SonarQube configuration of skipping or not the analysis of unchanged Python files",
365365
)
366+
scanner_behavior_group.add_argument(
367+
"--sonar-python-test-file-heuristic-disabled",
368+
action=argparse.BooleanOptionalAction,
369+
help="Disable the sonar-python heuristic that silences issues on test-like files when sonar.tests is not set. "
370+
"Use this to analyse all files as main code regardless of their path.",
371+
)
372+
scanner_behavior_group.add_argument(
373+
"-Dsonar.python.testFileHeuristic.disabled",
374+
type=bool,
375+
help="Equivalent to --sonar-python-test-file-heuristic-disabled",
376+
)
366377
scanner_behavior_group.add_argument(
367378
"--dry-run",
368379
action=argparse.BooleanOptionalAction,

src/pysonar_scanner/configuration/configuration_loader.py

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,21 @@
2424
from pysonar_scanner.configuration.cli import CliConfigurationLoader
2525
from pysonar_scanner.configuration.coveragerc_loader import CoverageRCConfigurationLoader
2626
from pysonar_scanner.configuration.pyproject_toml import TomlConfigurationLoader
27-
from pysonar_scanner.configuration.properties import SONAR_PROJECT_KEY, SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, Key
27+
from pysonar_scanner.configuration.properties import (
28+
SONAR_PROJECT_KEY,
29+
SONAR_TOKEN,
30+
SONAR_PROJECT_BASE_DIR,
31+
SONAR_TESTS,
32+
SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED,
33+
Key,
34+
)
2835
from pysonar_scanner.configuration.properties import PROPERTIES
29-
from pysonar_scanner.configuration import sonar_project_properties, environment_variables, dynamic_defaults_loader
36+
from pysonar_scanner.configuration import (
37+
sonar_project_properties,
38+
environment_variables,
39+
dynamic_defaults_loader,
40+
test_paths_loader,
41+
)
3042

3143
from pysonar_scanner.exceptions import MissingProperty, MissingPropertyException
3244

@@ -61,6 +73,19 @@ def load() -> dict[Key, Any]:
6173
resolved_properties.update(toml_properties.sonar_properties)
6274
resolved_properties.update(environment_variables.load())
6375
resolved_properties.update(cli_properties)
76+
77+
# Auto-detect sonar.tests only when the user has not set it in any higher-priority source
78+
# and has not explicitly disabled the sonar-python test file heuristic. When the heuristic
79+
# is disabled the intent is to analyse all files as main code with no test classification.
80+
heuristic_disabled = (
81+
str(resolved_properties.get(SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED, "")).lower() == "true"
82+
)
83+
if SONAR_TESTS not in resolved_properties and not heuristic_disabled:
84+
inferred_props, disable_heuristic = test_paths_loader.load(base_dir)
85+
resolved_properties.update(inferred_props)
86+
if disable_heuristic and SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED not in resolved_properties:
87+
resolved_properties[SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED] = "true"
88+
6489
return resolved_properties
6590

6691
@staticmethod

src/pysonar_scanner/configuration/properties.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@
9494
SONAR_WORKING_DIRECTORY: Key = "sonar.working.directory"
9595
SONAR_SCM_FORCE_RELOAD_ALL: Key = "sonar.scm.forceReloadAll"
9696
SONAR_MODULES: Key = "sonar.modules"
97+
SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED: Key = "sonar.python.testFileHeuristic.disabled"
9798
SONAR_PYTHON_ANALYSIS_PARALLEL: Key = "sonar.python.analysis.parallel"
9899
SONAR_PYTHON_ANALYSIS_THREADS: Key = "sonar.python.analysis.threads"
99100
SONAR_PYTHON_XUNIT_REPORT_PATH: Key = "sonar.python.xunit.reportPath"
@@ -504,6 +505,12 @@ def env_variable_name(self) -> str:
504505
default_value=None,
505506
cli_getter=lambda args: args.sonar_python_skip_unchanged or getattr(args, "Dsonar.python.skipUnchanged")
506507
),
508+
Property(
509+
name=SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED,
510+
default_value=None,
511+
cli_getter=lambda args: args.sonar_python_test_file_heuristic_disabled
512+
or getattr(args, "Dsonar.python.testFileHeuristic.disabled"),
513+
),
507514
Property(
508515
name=SONAR_PYTHON_XUNIT_REPORT_PATH,
509516
default_value=None,
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
#
2+
# Sonar Scanner Python
3+
# Copyright (C) 2011-2026 SonarSource Sàrl
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 Optional
24+
25+
import tomli
26+
27+
from pysonar_scanner.configuration.properties import SONAR_TESTS
28+
29+
_CONVENTIONAL_TEST_DIRS = ["tests", "test", "testing"]
30+
_SETUP_CFG_PYTEST_SECTION = "tool:pytest"
31+
32+
33+
def load(base_dir: pathlib.Path) -> tuple[dict[str, str], bool]:
34+
"""Infer sonar.tests from Python tooling configuration and filesystem conventions.
35+
36+
Returns (properties, disable_heuristic) where:
37+
- properties contains sonar.tests if a test directory was reliably inferred
38+
- disable_heuristic is True when a config file declared testpaths but all paths were
39+
invalid — the user expressed intent, so the sonar-python heuristic should not fire
40+
"""
41+
for loader in [_load_from_pyproject_toml, _load_from_pytest_ini, _load_from_tox_ini, _load_from_setup_cfg]:
42+
result = loader(base_dir)
43+
if result is None:
44+
continue # file absent, no testpaths key, or empty testpaths (no restriction) — try next
45+
if result:
46+
return {SONAR_TESTS: result}, False
47+
return {}, True # declared but all paths invalid: user expressed intent, disable heuristic
48+
49+
# No config file gave a non-empty testpaths declaration; fall back to filesystem conventions.
50+
filesystem_result = _load_from_filesystem(base_dir)
51+
if filesystem_result:
52+
return {SONAR_TESTS: filesystem_result}, False
53+
return {}, False
54+
55+
56+
def _existing_paths(base_dir: pathlib.Path, paths: list[str]) -> list[str]:
57+
"""Filter a list of candidate paths to those that exist as directories under base_dir.
58+
59+
Absolute paths are relativised against the project root. If an absolute path falls
60+
outside the project root it is skipped with a warning. Relative paths that resolve
61+
to a file (not a directory) are skipped with a debug message.
62+
"""
63+
abs_base = base_dir.resolve()
64+
result = []
65+
for p in paths:
66+
path = pathlib.Path(p)
67+
if path.root: # rooted path: absolute on POSIX, or rooted (possibly drive-relative) on Windows
68+
try:
69+
# On Windows, abs_base.resolve() adds a drive letter (e.g. C:\project) while a path
70+
# read from config may be drive-relative (/project/tests, no drive). Attach the base
71+
# drive so relative_to() can compare them correctly.
72+
candidate = pathlib.Path(abs_base.drive + str(path)) if abs_base.drive and not path.drive else path
73+
p = candidate.relative_to(abs_base).as_posix()
74+
logging.debug(f"Converted absolute testpath '{path}' to relative path '{p}' against project root")
75+
except ValueError:
76+
logging.warning(
77+
f"Ignoring '{path}' in testpaths — path is outside the project root '{abs_base}' "
78+
f"and cannot be expressed as a relative path for sonar.tests"
79+
)
80+
continue
81+
resolved = base_dir / p
82+
if resolved.is_dir():
83+
result.append(p)
84+
elif resolved.exists():
85+
logging.debug(
86+
f"Ignoring '{p}' in testpaths — it is a file, not a directory; sonar.tests uses directory roots"
87+
)
88+
return result
89+
90+
91+
def _load_from_pyproject_toml(base_dir: pathlib.Path) -> Optional[str]:
92+
pyproject_path = base_dir / "pyproject.toml"
93+
if not pyproject_path.is_file():
94+
return None
95+
try:
96+
with open(pyproject_path, "rb") as f:
97+
toml_dict = tomli.load(f)
98+
except tomli.TOMLDecodeError as e:
99+
logging.debug(f"Error reading pyproject.toml for pytest testpaths: {e}")
100+
return None
101+
ini_options = toml_dict.get("tool", {}).get("pytest", {}).get("ini_options", {})
102+
if "testpaths" not in ini_options:
103+
return None
104+
testpaths = ini_options["testpaths"]
105+
if not isinstance(testpaths, (list, str)):
106+
logging.warning(
107+
f"testpaths in pyproject.toml [tool.pytest.ini_options] has an unexpected type "
108+
f"({type(testpaths).__name__}) — expected a list or string, skipping"
109+
)
110+
return None
111+
raw = [str(p) for p in (testpaths if isinstance(testpaths, list) else testpaths.split()) if str(p).strip()]
112+
if not raw:
113+
return None # testpaths = [] means "no path restriction" — same as key absent, continue chain
114+
paths = _existing_paths(base_dir, raw)
115+
if paths:
116+
result = ",".join(paths)
117+
logging.debug(f"Detected test paths from pyproject.toml [tool.pytest.ini_options]: {result}")
118+
return result
119+
logging.warning(
120+
f"testpaths is set in pyproject.toml [tool.pytest.ini_options] to {raw} "
121+
f"but none of those paths exist as directories — sonar.tests will not be auto-detected"
122+
)
123+
return "" # declared but all paths invalid: stop the chain
124+
125+
126+
def _load_from_ini_file(base_dir: pathlib.Path, filename: str, section: str) -> Optional[str]:
127+
config_path = base_dir / filename
128+
if not config_path.is_file():
129+
return None
130+
try:
131+
config = configparser.ConfigParser()
132+
config.read(config_path)
133+
except configparser.Error as e:
134+
logging.debug(f"Error reading {filename} for pytest testpaths: {e}")
135+
return None
136+
if section not in config or "testpaths" not in config[section]:
137+
return None
138+
raw = [p for p in config[section]["testpaths"].split() if p]
139+
if not raw:
140+
return None # empty testpaths means "no path restriction" — same as key absent, continue chain
141+
paths = _existing_paths(base_dir, raw)
142+
if paths:
143+
result = ",".join(paths)
144+
logging.debug(f"Detected test paths from {filename} [{section}]: {result}")
145+
return result
146+
logging.warning(
147+
f"testpaths is set in {filename} [{section}] to {raw} "
148+
f"but none of those paths exist as directories — sonar.tests will not be auto-detected"
149+
)
150+
return ""
151+
152+
153+
def _load_from_pytest_ini(base_dir: pathlib.Path) -> Optional[str]:
154+
return _load_from_ini_file(base_dir, "pytest.ini", "pytest")
155+
156+
157+
def _load_from_tox_ini(base_dir: pathlib.Path) -> Optional[str]:
158+
return _load_from_ini_file(base_dir, "tox.ini", "pytest")
159+
160+
161+
def _load_from_setup_cfg(base_dir: pathlib.Path) -> Optional[str]:
162+
return _load_from_ini_file(base_dir, "setup.cfg", _SETUP_CFG_PYTEST_SECTION)
163+
164+
165+
def _load_from_filesystem(base_dir: pathlib.Path) -> Optional[str]:
166+
found = [d for d in _CONVENTIONAL_TEST_DIRS if (base_dir / d).is_dir()]
167+
if found:
168+
result = ",".join(found)
169+
logging.debug(f"Detected test paths from filesystem conventions: {result}")
170+
return result
171+
return None
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
[project]
2+
name = "with-tests"
3+
4+
[tool.sonar]
5+
projectKey = "with-tests"
6+
sources = "src"
7+
8+
[tool.pytest.ini_options]
9+
testpaths = ["tests"]
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
sonar.projectVersion=1.0
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
#
2+
# Sonar Scanner Python
3+
# Copyright (C) 2011-2026 SonarSource Sàrl
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+
def greet(name: str) -> str:
21+
return f"Hello, {name}!"

0 commit comments

Comments
 (0)