Skip to content
Open
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/
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
23 changes: 21 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,20 @@
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,
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 +72,14 @@ 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 = resolved_properties.get("sonar.python.testFileHeuristic.disabled", "").lower() == "true"
if SONAR_TESTS not in resolved_properties and not heuristic_disabled:
resolved_properties.update(test_paths_loader.load(base_dir))

return resolved_properties

@staticmethod
Expand Down
163 changes: 163 additions & 0 deletions src/pysonar_scanner/configuration/test_paths_loader.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
#
# 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) -> dict[str, str]:
"""Infer sonar.tests from Python tooling configuration and filesystem conventions.

Returns sonar.tests if a test directory can be reliably inferred; empty dict otherwise.
Filesystem convention fallback only runs when no config file declares a testpaths key.
"""
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 or no testpaths key — try next source
if result:
return {SONAR_TESTS: result}
Comment thread
sonar-review-alpha[bot] marked this conversation as resolved.
Comment thread
sonar-review-alpha[bot] marked this conversation as resolved.
return {} # testpaths declared but all paths were invalid — stop chain, set nothing

# No config file declared a testpaths key; fall back to filesystem conventions.
filesystem_result = _load_from_filesystem(base_dir)
if filesystem_result:
return {SONAR_TESTS: filesystem_result}
return {}


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:
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If testpaths in TOML is a non-list, non-string scalar (e.g. testpaths = 1), str(p) coerces it to "1" and _existing_paths then looks for a directory named "1". This was flagged in the previous review — the fix is to guard explicitly on the type:

if isinstance(testpaths, list):
    raw = [str(p) for p in testpaths if str(p).strip()]
elif isinstance(testpaths, str):
    raw = [p for p in testpaths.split() if p]
else:
    logging.debug(f"Unexpected type for testpaths in pyproject.toml: {type(testpaths)}")
    return None
Suggested change
if "testpaths" not in ini_options:
raw = [str(p) for p in (testpaths if isinstance(testpaths, list) else testpaths.split() if isinstance(testpaths, str) else []) if str(p).strip()]
if not isinstance(testpaths, (list, str)):
logging.debug(f"Unexpected type for testpaths in pyproject.toml: {type(testpaths)}")
return None
  • Mark as noise

return None
testpaths = ini_options["testpaths"]
raw = [str(p) for p in (testpaths if isinstance(testpaths, list) else testpaths.split()) if str(p).strip()]
if not raw:
return "" # testpaths key present but empty — stop 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 "" # testpaths key present but empty — stop 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?

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}"
)
20 changes: 18 additions & 2 deletions tests/its/utils/cli_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
import os
import pathlib
from subprocess import CompletedProcess
from typing import Optional
import subprocess

import pytest
Expand Down Expand Up @@ -76,7 +77,7 @@ def __run_analysis_with_debugging(self, workdir: pathlib.Path, params: list[str]
text=True,
env=subproc_env,
)
self.sq_client.wait_for_analysis_completion()
self._wait_for_ce_completion(workdir)
return process

def __run_analysis_normal(self, workdir: pathlib.Path, params: list[str], token: str) -> CompletedProcess:
Expand All @@ -94,5 +95,20 @@ def __run_analysis_normal(self, workdir: pathlib.Path, params: list[str], token:
text=True,
cwd=workdir,
)
self.sq_client.wait_for_analysis_completion()
self._wait_for_ce_completion(workdir)
return process

def _wait_for_ce_completion(self, workdir: pathlib.Path) -> None:
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 method does a lot of automatic checks and fallbacks. Is this really necessary for a test?

task_id = self._read_ce_task_id(workdir)
if task_id:
self.sq_client.wait_for_ce_task_by_id(task_id)

@staticmethod
Comment on lines +103 to +106
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

If the scanner exits before writing report-task.txt (e.g. config validation failure, network error before scan starts), _read_ce_task_id returns None, _wait_for_ce_completion returns immediately without polling, and the ITS test proceeds to query SonarQube as if the analysis succeeded. The existing tests will then fail with a confusing assertion error rather than a clear "analysis never ran" message.

Log a warning when the file is missing so the failure is obvious:

Suggested change
if task_id:
self.sq_client.wait_for_ce_task_by_id(task_id)
@staticmethod
def _wait_for_ce_completion(self, workdir: pathlib.Path) -> None:
task_id = self._read_ce_task_id(workdir)
if task_id:
self.sq_client.wait_for_ce_task_by_id(task_id)
else:
import logging
logging.warning(f"report-task.txt not found in {workdir / '.sonar'} — scanner may have failed before submitting a CE task")
  • Mark as noise

def _read_ce_task_id(workdir: pathlib.Path) -> Optional[str]:
report_task_file = workdir / ".sonar" / "report-task.txt"
if not report_task_file.is_file():
return None
for line in report_task_file.read_text().splitlines():
if line.startswith("ceTaskId="):
return line.split("=", 1)[1].strip()
return None
Loading
Loading