Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -171,3 +171,4 @@ cython_debug/
# Sonar
*.scanner
*.scannerwork
.sonar/
2 changes: 2 additions & 0 deletions CLI_ARGS.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@
| `--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 |
| `--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 |
| `--sonar-python-skip-unchanged`, `--no-sonar-python-skip-unchanged` | Override the SonarQube configuration of skipping or not the analysis of unchanged Python files |
| `--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. |
| `--sonar-qualitygate-timeout`, `-Dsonar.qualitygate.timeout` | The number of seconds that the scanner should wait for a report to be processed |
| `--sonar-qualitygate-wait`, `--no-sonar-qualitygate-wait` | Forces the analysis step to poll the server instance and wait for the Quality Gate status |
| `--sonar-scanner-api-url`, `-Dsonar.scanner.apiUrl` | Base URL for all REST-compliant API calls, https://api.sonarcloud.io for example |
Expand Down Expand Up @@ -99,6 +100,7 @@
| `--sonar-working-directory`, `-Dsonar.working.directory` | Path to the working directory used by the Sonar scanner during a project analysis to store temporary data |
| `--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 |
| `-Dsonar.python.skipUnchanged` | Equivalent to --sonar-python-skip-unchanged |
| `-Dsonar.python.testFileHeuristic.disabled` | Equivalent to --sonar-python-test-file-heuristic-disabled |
| `-Dsonar.python.xunit.skipDetails` | Equivalent to -Dsonar.python.xunit.skipDetails |
| `-Dsonar.qualitygate.wait` | Equivalent to --sonar-qualitygate-wait |
| `-Dsonar.scm.exclusions.disabled` | Equivalent to --sonar-scm-exclusions-disabled |
Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ priority = "primary"
[tool.pytest.ini_options]
addopts = ['--import-mode=importlib', '--strict-markers']
pythonpath = ['src']
norecursedirs = ['tests/its/sources']
markers = [
"its: marks tests as its"
]
Expand Down
11 changes: 11 additions & 0 deletions src/pysonar_scanner/configuration/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -363,6 +363,17 @@ def __create_parser(cls):
action=argparse.BooleanOptionalAction,
help="Override the SonarQube configuration of skipping or not the analysis of unchanged Python files",
)
scanner_behavior_group.add_argument(
"--sonar-python-test-file-heuristic-disabled",
action=argparse.BooleanOptionalAction,
help="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.",
)
scanner_behavior_group.add_argument(
"-Dsonar.python.testFileHeuristic.disabled",
type=bool,
help="Equivalent to --sonar-python-test-file-heuristic-disabled",
)
scanner_behavior_group.add_argument(
"--dry-run",
action=argparse.BooleanOptionalAction,
Expand Down
29 changes: 27 additions & 2 deletions src/pysonar_scanner/configuration/configuration_loader.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,21 @@
from pysonar_scanner.configuration.cli import CliConfigurationLoader
from pysonar_scanner.configuration.coveragerc_loader import CoverageRCConfigurationLoader
from pysonar_scanner.configuration.pyproject_toml import TomlConfigurationLoader
from pysonar_scanner.configuration.properties import SONAR_PROJECT_KEY, SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, Key
from pysonar_scanner.configuration.properties import (
SONAR_PROJECT_KEY,
SONAR_TOKEN,
SONAR_PROJECT_BASE_DIR,
SONAR_TESTS,
SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED,
Key,
)
from pysonar_scanner.configuration.properties import PROPERTIES
from pysonar_scanner.configuration import sonar_project_properties, environment_variables, dynamic_defaults_loader
from pysonar_scanner.configuration import (
sonar_project_properties,
environment_variables,
dynamic_defaults_loader,
test_paths_loader,
)

from pysonar_scanner.exceptions import MissingProperty, MissingPropertyException

Expand Down Expand Up @@ -61,6 +73,19 @@ def load() -> dict[Key, Any]:
resolved_properties.update(toml_properties.sonar_properties)
resolved_properties.update(environment_variables.load())
resolved_properties.update(cli_properties)

# Auto-detect sonar.tests only when the user has not set it in any higher-priority source
# and has not explicitly disabled the sonar-python test file heuristic. When the heuristic
# is disabled the intent is to analyse all files as main code with no test classification.
heuristic_disabled = (
str(resolved_properties.get(SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED, "")).lower() == "true"
)
if SONAR_TESTS not in resolved_properties and not heuristic_disabled:
inferred_props, disable_heuristic = test_paths_loader.load(base_dir)
resolved_properties.update(inferred_props)
if disable_heuristic and SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED not in resolved_properties:
resolved_properties[SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED] = "true"

return resolved_properties

@staticmethod
Expand Down
7 changes: 7 additions & 0 deletions src/pysonar_scanner/configuration/properties.py
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@
SONAR_WORKING_DIRECTORY: Key = "sonar.working.directory"
SONAR_SCM_FORCE_RELOAD_ALL: Key = "sonar.scm.forceReloadAll"
SONAR_MODULES: Key = "sonar.modules"
SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED: Key = "sonar.python.testFileHeuristic.disabled"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This option should also be added to the configuration.cli module as an option, potentially as a switch

SONAR_PYTHON_ANALYSIS_PARALLEL: Key = "sonar.python.analysis.parallel"
SONAR_PYTHON_ANALYSIS_THREADS: Key = "sonar.python.analysis.threads"
SONAR_PYTHON_XUNIT_REPORT_PATH: Key = "sonar.python.xunit.reportPath"
Expand Down Expand Up @@ -504,6 +505,12 @@ def env_variable_name(self) -> str:
default_value=None,
cli_getter=lambda args: args.sonar_python_skip_unchanged or getattr(args, "Dsonar.python.skipUnchanged")
),
Property(
name=SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED,
default_value=None,
cli_getter=lambda args: args.sonar_python_test_file_heuristic_disabled
Comment thread
sonar-review-alpha[bot] marked this conversation as resolved.
or getattr(args, "Dsonar.python.testFileHeuristic.disabled"),
),
Property(
name=SONAR_PYTHON_XUNIT_REPORT_PATH,
default_value=None,
Expand Down
171 changes: 171 additions & 0 deletions src/pysonar_scanner/configuration/test_paths_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
#
# Sonar Scanner Python
# Copyright (C) 2011-2026 SonarSource Sàrl
# mailto:info AT sonarsource DOT com
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3 of the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
#
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
import configparser
import logging
import pathlib
from typing import Optional

import tomli

from pysonar_scanner.configuration.properties import SONAR_TESTS

_CONVENTIONAL_TEST_DIRS = ["tests", "test", "testing"]
_SETUP_CFG_PYTEST_SECTION = "tool:pytest"


def load(base_dir: pathlib.Path) -> tuple[dict[str, str], bool]:
"""Infer sonar.tests from Python tooling configuration and filesystem conventions.

Returns (properties, disable_heuristic) where:
- properties contains sonar.tests if a test directory was reliably inferred
- disable_heuristic is True when a config file declared testpaths but all paths were
invalid — the user expressed intent, so the sonar-python heuristic should not fire
"""
for loader in [_load_from_pyproject_toml, _load_from_pytest_ini, _load_from_tox_ini, _load_from_setup_cfg]:
result = loader(base_dir)
if result is None:
continue # file absent, no testpaths key, or empty testpaths (no restriction) — try next
if result:
return {SONAR_TESTS: result}, False
return {}, True # declared but all paths invalid: user expressed intent, disable heuristic

# No config file gave a non-empty testpaths declaration; fall back to filesystem conventions.
filesystem_result = _load_from_filesystem(base_dir)
if filesystem_result:
return {SONAR_TESTS: filesystem_result}, False
return {}, False


def _existing_paths(base_dir: pathlib.Path, paths: list[str]) -> list[str]:
"""Filter a list of candidate paths to those that exist as directories under base_dir.

Absolute paths are relativised against the project root. If an absolute path falls
outside the project root it is skipped with a warning. Relative paths that resolve
to a file (not a directory) are skipped with a debug message.
"""
abs_base = base_dir.resolve()
result = []
for p in paths:
path = pathlib.Path(p)
if path.root: # rooted path: absolute on POSIX, or rooted (possibly drive-relative) on Windows
try:
# On Windows, abs_base.resolve() adds a drive letter (e.g. C:\project) while a path
# read from config may be drive-relative (/project/tests, no drive). Attach the base
# drive so relative_to() can compare them correctly.
candidate = pathlib.Path(abs_base.drive + str(path)) if abs_base.drive and not path.drive else path
p = candidate.relative_to(abs_base).as_posix()
logging.debug(f"Converted absolute testpath '{path}' to relative path '{p}' against project root")
except ValueError:
logging.warning(
f"Ignoring '{path}' in testpaths — path is outside the project root '{abs_base}' "
f"and cannot be expressed as a relative path for sonar.tests"
)
continue
resolved = base_dir / p
if resolved.is_dir():
result.append(p)
elif resolved.exists():
logging.debug(
f"Ignoring '{p}' in testpaths — it is a file, not a directory; sonar.tests uses directory roots"
)
return result


def _load_from_pyproject_toml(base_dir: pathlib.Path) -> Optional[str]:
pyproject_path = base_dir / "pyproject.toml"
if not pyproject_path.is_file():
return None
try:
with open(pyproject_path, "rb") as f:
toml_dict = tomli.load(f)
except tomli.TOMLDecodeError as e:
logging.debug(f"Error reading pyproject.toml for pytest testpaths: {e}")
return None
ini_options = toml_dict.get("tool", {}).get("pytest", {}).get("ini_options", {})
if "testpaths" not in ini_options:
Comment thread
sonar-review-alpha[bot] marked this conversation as resolved.
Comment thread
sonar-review-alpha[bot] marked this conversation as resolved.
Comment thread
sonar-review-alpha[bot] marked this conversation as resolved.
Comment thread
sonar-review-alpha[bot] marked this conversation as resolved.
return None
testpaths = ini_options["testpaths"]
if not isinstance(testpaths, (list, str)):
logging.warning(
f"testpaths in pyproject.toml [tool.pytest.ini_options] has an unexpected type "
f"({type(testpaths).__name__}) — expected a list or string, skipping"
)
return None
raw = [str(p) for p in (testpaths if isinstance(testpaths, list) else testpaths.split()) if str(p).strip()]
if not raw:
return None # testpaths = [] means "no path restriction" — same as key absent, continue chain
paths = _existing_paths(base_dir, raw)
if paths:
result = ",".join(paths)
logging.debug(f"Detected test paths from pyproject.toml [tool.pytest.ini_options]: {result}")
return result
logging.warning(
f"testpaths is set in pyproject.toml [tool.pytest.ini_options] to {raw} "
f"but none of those paths exist as directories — sonar.tests will not be auto-detected"
)
return "" # declared but all paths invalid: stop the chain


def _load_from_ini_file(base_dir: pathlib.Path, filename: str, section: str) -> Optional[str]:
config_path = base_dir / filename
if not config_path.is_file():
return None
try:
config = configparser.ConfigParser()
config.read(config_path)
except configparser.Error as e:
logging.debug(f"Error reading {filename} for pytest testpaths: {e}")
return None
if section not in config or "testpaths" not in config[section]:
return None
raw = [p for p in config[section]["testpaths"].split() if p]
if not raw:
return None # empty testpaths means "no path restriction" — same as key absent, continue chain
paths = _existing_paths(base_dir, raw)
if paths:
result = ",".join(paths)
logging.debug(f"Detected test paths from {filename} [{section}]: {result}")
return result
logging.warning(
f"testpaths is set in {filename} [{section}] to {raw} "
f"but none of those paths exist as directories — sonar.tests will not be auto-detected"
)
return ""


def _load_from_pytest_ini(base_dir: pathlib.Path) -> Optional[str]:
return _load_from_ini_file(base_dir, "pytest.ini", "pytest")


def _load_from_tox_ini(base_dir: pathlib.Path) -> Optional[str]:
return _load_from_ini_file(base_dir, "tox.ini", "pytest")


def _load_from_setup_cfg(base_dir: pathlib.Path) -> Optional[str]:
return _load_from_ini_file(base_dir, "setup.cfg", _SETUP_CFG_PYTEST_SECTION)


def _load_from_filesystem(base_dir: pathlib.Path) -> Optional[str]:
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does it make sense to add this heuristic here as well, given that sonar-python itself has a more extensive set of heuristics.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the heuristic from sonar-python is a bit of a crutch in the sense that it will only disable rules and still report metrics based on the FileType, because we can't contradict the scanner for the file type classification.
This will reduce noise but ultimately still produce an "inconsistent" analysis.

Having it here makes the analysis more consistent because the rule execution is in sync with the metrics reporting. It's an argument to use pysonar instead of the generic sonar-scanner-cli because here, the manual setup of sonar.tests is truly optional (assuming what is inferred matches the user expectations).

Copy link
Copy Markdown
Contributor

@Seppli11 Seppli11 May 13, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That is a good point. Maybe it would make sense to only fall back to the heuristic if the user hasn't set anything, rather than we haven't found a valid test folder

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In a way I'm wondering if analysis coming from pysonar shouldn't simply disable the heuristic by default?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe that would make the most sense. It would certainly simplify the flow chart of how the heuristics apply.
And, even in the case where they don't specify anything in their respective configuration files, the file based heuristic should still pick up most cases.

found = [d for d in _CONVENTIONAL_TEST_DIRS if (base_dir / d).is_dir()]
if found:
result = ",".join(found)
logging.debug(f"Detected test paths from filesystem conventions: {result}")
return result
return None
9 changes: 9 additions & 0 deletions tests/its/sources/with-tests/pyproject.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
[project]
name = "with-tests"

[tool.sonar]
projectKey = "with-tests"
sources = "src"

[tool.pytest.ini_options]
testpaths = ["tests"]
1 change: 1 addition & 0 deletions tests/its/sources/with-tests/sonar-project.properties
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
sonar.projectVersion=1.0
21 changes: 21 additions & 0 deletions tests/its/sources/with-tests/src/app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
#
# Sonar Scanner Python
# Copyright (C) 2011-2026 SonarSource Sàrl
# mailto:info AT sonarsource DOT com
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3 of the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
#
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
def greet(name: str) -> str:
return f"Hello, {name}!"
25 changes: 25 additions & 0 deletions tests/its/sources/with-tests/tests/test_app.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
#
# Sonar Scanner Python
# Copyright (C) 2011-2026 SonarSource Sàrl
# mailto:info AT sonarsource DOT com
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3 of the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
#
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
def add(a: int, b: int) -> int:
return a + b


def test_add():
assert add(1, 2) == 3
54 changes: 54 additions & 0 deletions tests/its/test_auto_detect_tests.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
#
# Sonar Scanner Python
# Copyright (C) 2011-2026 SonarSource Sàrl
# mailto:info AT sonarsource DOT com
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3 of the License, or (at your option) any later version.
# This program is distributed in the hope that it will be useful,
#
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public License
# along with this program; if not, write to the Free Software Foundation,
# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
#
import pytest
from tests.its.utils.sonarqube_client import SonarQubeClient
from tests.its.utils.cli_client import CliClient, SOURCES_FOLDER_PATH


pytestmark = pytest.mark.its


def test_auto_detect_tests_from_pyproject_toml(sonarqube_client: SonarQubeClient, cli: CliClient):
"""sonar.tests should be inferred from [tool.pytest.ini_options] testpaths in pyproject.toml."""
process = cli.run_analysis(sources_dir="with-tests", params=["--verbose"])
assert process.returncode == 0, process.stdout

task_id = cli._read_ce_task_id(SOURCES_FOLDER_PATH / "with-tests")
assert (
task_id is not None
), f"report-task.txt not written — analysis may have failed early.\nScanner output:\n{process.stdout}"
task = sonarqube_client.get_ce_task_by_id(task_id)

assert task["status"] == "SUCCESS", (
f"SonarQube CE task did not succeed.\n" f"Task: {task}\n" f"Scanner output:\n{process.stdout}"
)
assert task.get("componentKey") == "with-tests", (
f"CE task succeeded for wrong component '{task.get('componentKey')}', expected 'with-tests'.\n"
f"Scanner output:\n{process.stdout}"
)

test_files = sonarqube_client.get_project_test_files("with-tests")
test_file_paths = [c["path"] for c in test_files]
assert any("test_app.py" in p for p in test_file_paths), (
f"Expected tests/test_app.py to be classified as a test file in SonarQube — "
f"sonar.tests auto-detection may not have run correctly.\n"
f"Test files found: {test_file_paths}\n"
f"Scanner output:\n{process.stdout}"
)
Loading
Loading