From f6fc918f490f08cdd7620ad13fd4c83724b9316e Mon Sep 17 00:00:00 2001 From: Guillaume Dequenne Date: Fri, 8 May 2026 14:23:49 +0200 Subject: [PATCH 1/8] Auto detect test code --- .gitignore | 1 + pyproject.toml | 1 + .../configuration/configuration_loader.py | 8 +- .../configuration/python_project_loader.py | 204 ++++++ tests/its/sources/with-tests/pyproject.toml | 9 + .../with-tests/sonar-project.properties | 1 + tests/its/sources/with-tests/src/app.py | 21 + .../its/sources/with-tests/tests/test_app.py | 25 + tests/its/test_auto_detect_tests.py | 56 ++ tests/its/utils/cli_client.py | 28 +- tests/its/utils/sonarqube_client.py | 44 ++ tests/unit/test_configuration_loader.py | 60 ++ tests/unit/test_python_project_loader.py | 649 ++++++++++++++++++ 13 files changed, 1104 insertions(+), 3 deletions(-) create mode 100644 src/pysonar_scanner/configuration/python_project_loader.py create mode 100644 tests/its/sources/with-tests/pyproject.toml create mode 100644 tests/its/sources/with-tests/sonar-project.properties create mode 100644 tests/its/sources/with-tests/src/app.py create mode 100644 tests/its/sources/with-tests/tests/test_app.py create mode 100644 tests/its/test_auto_detect_tests.py create mode 100644 tests/unit/test_python_project_loader.py diff --git a/.gitignore b/.gitignore index b5fdfe39..f5cb6c28 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,4 @@ cython_debug/ # Sonar *.scanner *.scannerwork +.sonar/ diff --git a/pyproject.toml b/pyproject.toml index 4cbef049..72167751 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" ] diff --git a/src/pysonar_scanner/configuration/configuration_loader.py b/src/pysonar_scanner/configuration/configuration_loader.py index 6442112d..059c5e19 100644 --- a/src/pysonar_scanner/configuration/configuration_loader.py +++ b/src/pysonar_scanner/configuration/configuration_loader.py @@ -26,7 +26,12 @@ 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 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, + python_project_loader, +) from pysonar_scanner.exceptions import MissingProperty, MissingPropertyException @@ -56,6 +61,7 @@ def load() -> dict[Key, Any]: resolved_properties = get_static_default_properties() resolved_properties.update(dynamic_defaults_loader.load()) resolved_properties.update(coverage_properties) + resolved_properties.update(python_project_loader.load(base_dir)) resolved_properties.update(toml_properties.project_properties) resolved_properties.update(sonar_project_properties.load(base_dir)) resolved_properties.update(toml_properties.sonar_properties) diff --git a/src/pysonar_scanner/configuration/python_project_loader.py b/src/pysonar_scanner/configuration/python_project_loader.py new file mode 100644 index 00000000..c252a3bc --- /dev/null +++ b/src/pysonar_scanner/configuration/python_project_loader.py @@ -0,0 +1,204 @@ +# +# 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} + 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) + testpaths = toml_dict.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("testpaths") + if not testpaths: + 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 + 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 + except Exception as e: + logging.debug(f"Error reading pyproject.toml for pytest testpaths: {e}") + return None + + +def _load_from_pytest_ini(base_dir: pathlib.Path) -> Optional[str]: + pytest_ini_path = base_dir / "pytest.ini" + if not pytest_ini_path.is_file(): + return None + try: + config = configparser.ConfigParser() + config.read(pytest_ini_path) + if "pytest" not in config or "testpaths" not in config["pytest"]: + return None + raw = [p for p in config["pytest"]["testpaths"].split() if p] + if not raw: + return None + paths = _existing_paths(base_dir, raw) + if paths: + result = ",".join(paths) + logging.debug(f"Detected test paths from pytest.ini: {result}") + return result + logging.warning( + f"testpaths is set in pytest.ini to {raw} " + f"but none of those paths exist as directories — sonar.tests will not be auto-detected" + ) + return "" + except Exception as e: + logging.debug(f"Error reading pytest.ini for testpaths: {e}") + return None + + +def _load_from_tox_ini(base_dir: pathlib.Path) -> Optional[str]: + tox_ini_path = base_dir / "tox.ini" + if not tox_ini_path.is_file(): + return None + try: + config = configparser.ConfigParser() + config.read(tox_ini_path) + if "pytest" not in config or "testpaths" not in config["pytest"]: + return None + raw = [p for p in config["pytest"]["testpaths"].split() if p] + if not raw: + return None + paths = _existing_paths(base_dir, raw) + if paths: + result = ",".join(paths) + logging.debug(f"Detected test paths from tox.ini: {result}") + return result + logging.warning( + f"testpaths is set in tox.ini to {raw} " + f"but none of those paths exist as directories — sonar.tests will not be auto-detected" + ) + return "" + except Exception as e: + logging.debug(f"Error reading tox.ini for testpaths: {e}") + return None + + +def _load_from_setup_cfg(base_dir: pathlib.Path) -> Optional[str]: + setup_cfg_path = base_dir / "setup.cfg" + if not setup_cfg_path.is_file(): + return None + try: + config = configparser.ConfigParser() + config.read(setup_cfg_path) + if _SETUP_CFG_PYTEST_SECTION not in config or "testpaths" not in config[_SETUP_CFG_PYTEST_SECTION]: + return None + raw = [p for p in config[_SETUP_CFG_PYTEST_SECTION]["testpaths"].split() if p] + if not raw: + return None + paths = _existing_paths(base_dir, raw) + if paths: + result = ",".join(paths) + logging.debug(f"Detected test paths from setup.cfg [tool:pytest]: {result}") + return result + logging.warning( + f"testpaths is set in setup.cfg [tool:pytest] to {raw} " + f"but none of those paths exist as directories — sonar.tests will not be auto-detected" + ) + return "" + except Exception as e: + logging.debug(f"Error reading setup.cfg for pytest testpaths: {e}") + return None + + +def _load_from_filesystem(base_dir: pathlib.Path) -> Optional[str]: + 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 diff --git a/tests/its/sources/with-tests/pyproject.toml b/tests/its/sources/with-tests/pyproject.toml new file mode 100644 index 00000000..137a4434 --- /dev/null +++ b/tests/its/sources/with-tests/pyproject.toml @@ -0,0 +1,9 @@ +[project] +name = "with-tests" + +[tool.sonar] +projectKey = "with-tests" +sources = "src" + +[tool.pytest.ini_options] +testpaths = ["tests"] diff --git a/tests/its/sources/with-tests/sonar-project.properties b/tests/its/sources/with-tests/sonar-project.properties new file mode 100644 index 00000000..4548a1e8 --- /dev/null +++ b/tests/its/sources/with-tests/sonar-project.properties @@ -0,0 +1 @@ +sonar.projectVersion=1.0 diff --git a/tests/its/sources/with-tests/src/app.py b/tests/its/sources/with-tests/src/app.py new file mode 100644 index 00000000..0f42452a --- /dev/null +++ b/tests/its/sources/with-tests/src/app.py @@ -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}!" diff --git a/tests/its/sources/with-tests/tests/test_app.py b/tests/its/sources/with-tests/tests/test_app.py new file mode 100644 index 00000000..8e0e7e8d --- /dev/null +++ b/tests/its/sources/with-tests/tests/test_app.py @@ -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 diff --git a/tests/its/test_auto_detect_tests.py b/tests/its/test_auto_detect_tests.py new file mode 100644 index 00000000..cd659bb8 --- /dev/null +++ b/tests/its/test_auto_detect_tests.py @@ -0,0 +1,56 @@ +# +# 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 + + +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 = sonarqube_client.get_latest_ce_task() + projects = sonarqube_client.search_projects() + project_keys = [p["key"] for p in projects] + + assert task is not None and task["status"] == "SUCCESS", ( + f"SonarQube CE task did not succeed.\n" + f"Task: {task}\n" + f"Existing projects: {project_keys}\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"Existing projects: {project_keys}\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}" + ) diff --git a/tests/its/utils/cli_client.py b/tests/its/utils/cli_client.py index 6013ec6d..161a044b 100644 --- a/tests/its/utils/cli_client.py +++ b/tests/its/utils/cli_client.py @@ -21,6 +21,7 @@ import os import pathlib from subprocess import CompletedProcess +from typing import Optional import subprocess import pytest @@ -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: @@ -94,5 +95,28 @@ 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: + """Wait for the CE task to reach a terminal state. + + Reads the CE task ID written by the scanner to .sonar/report-task.txt and polls + api/ce/task until the task is done. Falls back to the queue-empty check when the + task file is absent (e.g. analysis failed before uploading the report). + """ + task_id = self._read_ce_task_id(workdir) + if task_id: + self.sq_client.wait_for_ce_task_by_id(task_id) + else: + self.sq_client.wait_for_analysis_completion() + + @staticmethod + 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 diff --git a/tests/its/utils/sonarqube_client.py b/tests/its/utils/sonarqube_client.py index d528bba5..01f741c9 100644 --- a/tests/its/utils/sonarqube_client.py +++ b/tests/its/utils/sonarqube_client.py @@ -101,3 +101,47 @@ def get_project_analyses(self, project_key: str) -> ProjectAnalysesSearch: resp = self.session.get(f"{self.base_url}/api/project_analyses/search?project={project_key}") resp.raise_for_status() return resp.json() + + def get_project_measures(self, project_key: str, metric_keys: list[str]) -> dict: + resp = self.session.get( + f"{self.base_url}/api/measures/component", + params={"component": project_key, "metricKeys": ",".join(metric_keys)}, + ) + resp.raise_for_status() + return resp.json() + + def get_latest_ce_task(self) -> Optional[dict]: + """Return the most recent CE background task, or None if no task has completed yet.""" + resp = self.session.get( + f"{self.base_url}/api/ce/activity", + params={"type": "REPORT", "ps": 1}, + ) + resp.raise_for_status() + tasks = resp.json().get("tasks", []) + return tasks[0] if tasks else None + + def wait_for_ce_task_by_id(self, task_id: str) -> None: + """Poll a specific CE task until it reaches a terminal state.""" + for _ in range(self.MAX_RETRIES * 10): + resp = self.session.get(f"{self.base_url}/api/ce/task", params={"id": task_id}) + resp.raise_for_status() + task = resp.json().get("task", {}) + if task.get("status") in ("SUCCESS", "FAILED", "CANCELLED"): + return + logger.info("Waiting for CE task to complete") + time.sleep(2) + raise RuntimeError(f"CE task {task_id} did not complete in time") + + def search_projects(self) -> list[dict]: + resp = self.session.get(f"{self.base_url}/api/projects/search") + resp.raise_for_status() + return resp.json().get("components", []) + + def get_project_test_files(self, project_key: str) -> list[dict]: + """Return components classified as unit-test source files (qualifier UTS) for the given project.""" + resp = self.session.get( + f"{self.base_url}/api/components/tree", + params={"component": project_key, "qualifiers": "UTS"}, + ) + resp.raise_for_status() + return resp.json().get("components", []) diff --git a/tests/unit/test_configuration_loader.py b/tests/unit/test_configuration_loader.py index 9bf770fb..ac11a03d 100644 --- a/tests/unit/test_configuration_loader.py +++ b/tests/unit/test_configuration_loader.py @@ -407,6 +407,66 @@ def test_load_coveragerc_properties(self, mock_get_os, mock_get_arch): } self.assertDictEqual(configuration, expected_configuration) + @patch("sys.argv", ["myscript.py"]) + def test_auto_detect_tests_from_pytest_ini(self, mock_get_os, mock_get_arch): + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = tests\n", + ) + self.fs.create_dir("tests") + configuration = ConfigurationLoader.load() + self.assertEqual(configuration[SONAR_TESTS], "tests") + + @patch("sys.argv", ["myscript.py"]) + def test_auto_detect_tests_from_filesystem(self, mock_get_os, mock_get_arch): + self.fs.create_dir("tests") + configuration = ConfigurationLoader.load() + self.assertEqual(configuration[SONAR_TESTS], "tests") + + @patch( + "sys.argv", + ["myscript.py", "--token", "myToken", "--sonar-project-key", "myProjectKey"], + ) + def test_explicit_sonar_tests_overrides_auto_detection(self, mock_get_os, mock_get_arch): + self.fs.create_dir("tests") + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.sonar] + tests = "src/test" + """, + ) + configuration = ConfigurationLoader.load() + self.assertEqual(configuration[SONAR_TESTS], "src/test") + + @patch("sys.argv", ["myscript.py"]) + def test_sonar_project_properties_sonar_tests_overrides_auto_detection(self, mock_get_os, mock_get_arch): + """sonar.tests in sonar-project.properties must override auto-detected value from filesystem/pytest config.""" + self.fs.create_dir("tests") # auto-detection would find this + self.fs.create_dir("tests/unit") + self.fs.create_file( + "sonar-project.properties", + contents="sonar.tests=tests/unit\n", + ) + configuration = ConfigurationLoader.load() + self.assertEqual(configuration[SONAR_TESTS], "tests/unit") + + @patch("sys.argv", ["myscript.py"]) + def test_sonar_project_properties_sonar_tests_overrides_pytest_ini_auto_detection(self, mock_get_os, mock_get_arch): + """sonar.tests in sonar-project.properties overrides auto-detection from pytest.ini testpaths.""" + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = tests\n", + ) + self.fs.create_dir("tests") + self.fs.create_dir("tests/e2e") + self.fs.create_file( + "sonar-project.properties", + contents="sonar.tests=tests/e2e\n", + ) + configuration = ConfigurationLoader.load() + self.assertEqual(configuration[SONAR_TESTS], "tests/e2e") + @patch("sys.argv", ["myscript.py"]) @patch.dict("os.environ", {"SONAR_TOKEN": "TokenFromEnv", "SONAR_PROJECT_KEY": "KeyFromEnv"}, clear=True) def test_load_from_env_variables_only(self, mock_get_os, mock_get_arch): diff --git a/tests/unit/test_python_project_loader.py b/tests/unit/test_python_project_loader.py new file mode 100644 index 00000000..a80f6989 --- /dev/null +++ b/tests/unit/test_python_project_loader.py @@ -0,0 +1,649 @@ +# +# 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 sys +import unittest +from pathlib import Path +from unittest.mock import patch + +from pyfakefs.fake_filesystem_unittest import TestCase + +from pysonar_scanner.configuration import python_project_loader +from pysonar_scanner.configuration.properties import SONAR_TESTS + + +class TestPythonProjectLoader(TestCase): + def setUp(self): + self.setUpPyfakefs() + + # --- pyproject.toml --- + + def test_load_from_pyproject_toml_list(self): + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = ["tests", "integration"] + """, + ) + self.fs.create_dir("tests") + self.fs.create_dir("integration") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests,integration") + + def test_load_from_pyproject_toml_single_entry(self): + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = ["tests"] + """, + ) + self.fs.create_dir("tests") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") + + def test_load_from_pyproject_toml_no_pytest_section(self): + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.sonar] + projectKey = "my-project" + """, + ) + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + def test_load_from_pyproject_toml_empty_testpaths(self): + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = [] + """, + ) + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + def test_load_from_pyproject_toml_nonexistent_path_not_returned(self): + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = ["tests"] + """, + ) + # tests/ directory does NOT exist + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + def test_load_from_pyproject_toml_filters_nonexistent_paths(self): + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = ["tests", "nonexistent"] + """, + ) + self.fs.create_dir("tests") + # nonexistent/ is not on disk — only tests/ should be returned + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") + + @patch("pysonar_scanner.configuration.python_project_loader.logging") + def test_load_from_pyproject_toml_malformed(self, mock_logging): + self.fs.create_file( + "pyproject.toml", + contents="this is not valid toml ][", + ) + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + mock_logging.debug.assert_called() + + # --- pytest.ini --- + + def test_load_from_pytest_ini(self): + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = tests integration\n", + ) + self.fs.create_dir("tests") + self.fs.create_dir("integration") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests,integration") + + def test_load_from_pytest_ini_multiline(self): + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths =\n tests\n integration\n", + ) + self.fs.create_dir("tests") + self.fs.create_dir("integration") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests,integration") + + def test_load_from_pytest_ini_no_testpaths(self): + self.fs.create_file( + "pytest.ini", + contents="[pytest]\naddopts = --strict-markers\n", + ) + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + def test_load_from_pytest_ini_nonexistent_path_not_returned(self): + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = tests\n", + ) + # tests/ directory does NOT exist + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + # --- tox.ini --- + + def test_load_from_tox_ini(self): + self.fs.create_file( + "tox.ini", + contents="[pytest]\ntestpaths = tests\n", + ) + self.fs.create_dir("tests") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") + + def test_load_from_tox_ini_multiple_paths(self): + self.fs.create_file( + "tox.ini", + contents="[pytest]\ntestpaths = tests integration\n", + ) + self.fs.create_dir("tests") + self.fs.create_dir("integration") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests,integration") + + def test_load_from_tox_ini_multiline(self): + self.fs.create_file( + "tox.ini", + contents="[pytest]\ntestpaths =\n tests\n integration\n", + ) + self.fs.create_dir("tests") + self.fs.create_dir("integration") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests,integration") + + def test_load_from_tox_ini_no_pytest_section(self): + self.fs.create_file( + "tox.ini", + contents="[tox]\nenvlist = py39\n", + ) + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + def test_load_from_tox_ini_no_testpaths(self): + self.fs.create_file( + "tox.ini", + contents="[pytest]\naddopts = --strict-markers\n", + ) + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + def test_load_from_tox_ini_nonexistent_path_not_returned(self): + self.fs.create_file( + "tox.ini", + contents="[pytest]\ntestpaths = tests\n", + ) + # tests/ directory does NOT exist + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + # --- setup.cfg --- + + def test_load_from_setup_cfg(self): + self.fs.create_file( + "setup.cfg", + contents="[tool:pytest]\ntestpaths = tests\n", + ) + self.fs.create_dir("tests") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") + + def test_load_from_setup_cfg_multiple_paths(self): + self.fs.create_file( + "setup.cfg", + contents="[tool:pytest]\ntestpaths = tests integration e2e\n", + ) + self.fs.create_dir("tests") + self.fs.create_dir("integration") + self.fs.create_dir("e2e") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests,integration,e2e") + + def test_load_from_setup_cfg_no_pytest_section(self): + self.fs.create_file( + "setup.cfg", + contents="[metadata]\nname = my-package\n", + ) + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + def test_load_from_setup_cfg_nonexistent_path_not_returned(self): + self.fs.create_file( + "setup.cfg", + contents="[tool:pytest]\ntestpaths = tests\n", + ) + # tests/ directory does NOT exist + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + # --- filesystem fallback --- + + def test_load_from_filesystem_tests_dir(self): + self.fs.create_dir("tests") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") + + def test_load_from_filesystem_test_dir(self): + self.fs.create_dir("test") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "test") + + def test_load_from_filesystem_testing_dir(self): + self.fs.create_dir("testing") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "testing") + + def test_load_from_filesystem_multiple_conventional_dirs(self): + self.fs.create_dir("tests") + self.fs.create_dir("test") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests,test") + + def test_load_from_filesystem_no_conventional_dir(self): + self.fs.create_dir("src") + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + # --- nothing found --- + + def test_load_returns_empty_when_nothing_configured(self): + result = python_project_loader.load(Path(".")) + self.assertEqual(result, {}) + + # --- priority order --- + + def test_pyproject_toml_takes_priority_over_pytest_ini(self): + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = ["from_toml"] + """, + ) + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = from_ini\n", + ) + self.fs.create_dir("from_toml") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "from_toml") + + def test_pytest_ini_takes_priority_over_tox_ini(self): + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = from_ini\n", + ) + self.fs.create_file( + "tox.ini", + contents="[pytest]\ntestpaths = from_tox\n", + ) + self.fs.create_dir("from_ini") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "from_ini") + + def test_tox_ini_takes_priority_over_setup_cfg(self): + self.fs.create_file( + "tox.ini", + contents="[pytest]\ntestpaths = from_tox\n", + ) + self.fs.create_file( + "setup.cfg", + contents="[tool:pytest]\ntestpaths = from_setup_cfg\n", + ) + self.fs.create_dir("from_tox") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "from_tox") + + def test_pytest_ini_takes_priority_over_setup_cfg(self): + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = from_ini\n", + ) + self.fs.create_file( + "setup.cfg", + contents="[tool:pytest]\ntestpaths = from_setup_cfg\n", + ) + self.fs.create_dir("from_ini") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "from_ini") + + def test_setup_cfg_takes_priority_over_filesystem(self): + self.fs.create_file( + "setup.cfg", + contents="[tool:pytest]\ntestpaths = from_setup_cfg\n", + ) + self.fs.create_dir("from_setup_cfg") + self.fs.create_dir("tests") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "from_setup_cfg") + + def test_config_without_testpaths_falls_through_to_filesystem(self): + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.sonar] + projectKey = "my-project" + """, + ) + self.fs.create_dir("tests") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") + + def test_declared_nonexistent_testpaths_stops_chain(self): + """When testpaths is explicitly declared but all paths are missing, the chain stops. + No fallthrough to the next config source or filesystem convention.""" + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = nonexistent\n", + ) + self.fs.create_file( + "setup.cfg", + contents="[tool:pytest]\ntestpaths = from_setup_cfg\n", + ) + self.fs.create_dir("from_setup_cfg") + self.fs.create_dir("tests") # filesystem fallback would find this + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + # --- custom base_dir --- + + def test_load_from_custom_base_dir(self): + self.fs.create_dir("custom/path") + self.fs.create_file( + "custom/path/pytest.ini", + contents="[pytest]\ntestpaths = custom_tests\n", + ) + self.fs.create_dir("custom/path/custom_tests") + result = python_project_loader.load(Path("custom/path")) + self.assertEqual(result[SONAR_TESTS], "custom_tests") + + # --- absolute paths --- + # Tests use /project as the explicit base_dir so that outside-root paths (/other/...) + # are unambiguously not under the project and inside-root paths (/project/...) are + # unambiguously convertible, regardless of the fake cwd. + + @patch("pysonar_scanner.configuration.python_project_loader.logging") + def test_absolute_path_outside_project_root_emits_warning(self, mock_logging): + self.fs.create_dir("/project") + self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /other/tests\n") + self.fs.create_dir("/other/tests") + python_project_loader.load(Path("/project")) + warning_calls = [str(c) for c in mock_logging.warning.call_args_list] + self.assertTrue( + any("/other/tests" in c for c in warning_calls), + f"Expected a warning mentioning the outside-root path, got: {warning_calls}", + ) + + def test_absolute_path_outside_project_root_stops_chain(self): + """Absolute path outside the project root is discarded; chain stops — no filesystem fallback.""" + self.fs.create_dir("/project") + self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /other/tests\n") + self.fs.create_dir("/other/tests") + self.fs.create_dir("/project/tests") # filesystem fallback would pick this up if chain continued + result = python_project_loader.load(Path("/project")) + self.assertNotIn(SONAR_TESTS, result) + + def test_absolute_path_inside_project_root_is_converted_to_relative(self): + """Absolute path under the project root is relativised and used as sonar.tests.""" + self.fs.create_dir("/project/tests") + self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /project/tests\n") + result = python_project_loader.load(Path("/project")) + self.assertEqual(result[SONAR_TESTS], "tests") + + def test_absolute_path_inside_project_root_nested(self): + """A deeper absolute path is relativised correctly.""" + self.fs.create_dir("/project/src/tests") + self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /project/src/tests\n") + result = python_project_loader.load(Path("/project")) + self.assertEqual(result[SONAR_TESTS], "src/tests") + + def test_absolute_path_mixed_with_valid_relative_path(self): + """Valid relative path is kept even when another entry is outside the project root.""" + self.fs.create_dir("/project/tests") + self.fs.create_file( + "/project/pytest.ini", + contents="[pytest]\ntestpaths = /other/tests tests\n", + ) + result = python_project_loader.load(Path("/project")) + self.assertEqual(result[SONAR_TESTS], "tests") + + def test_absolute_path_outside_root_in_pyproject_toml_stops_chain(self): + self.fs.create_dir("/project") + self.fs.create_file( + "/project/pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = ["/other/tests"] + """, + ) + self.fs.create_dir("/project/tests") # filesystem fallback + result = python_project_loader.load(Path("/project")) + self.assertNotIn(SONAR_TESTS, result) + + def test_absolute_path_inside_root_in_pyproject_toml_is_converted(self): + self.fs.create_dir("/project/tests") + self.fs.create_file( + "/project/pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = ["/project/tests"] + """, + ) + result = python_project_loader.load(Path("/project")) + self.assertEqual(result[SONAR_TESTS], "tests") + + def test_absolute_path_outside_root_in_tox_ini_stops_chain(self): + self.fs.create_dir("/project") + self.fs.create_file("/project/tox.ini", contents="[pytest]\ntestpaths = /other/tests\n") + self.fs.create_dir("/project/tests") + result = python_project_loader.load(Path("/project")) + self.assertNotIn(SONAR_TESTS, result) + + def test_absolute_path_outside_root_in_setup_cfg_stops_chain(self): + self.fs.create_dir("/project") + self.fs.create_file("/project/setup.cfg", contents="[tool:pytest]\ntestpaths = /other/tests\n") + self.fs.create_dir("/project/tests") + result = python_project_loader.load(Path("/project")) + self.assertNotIn(SONAR_TESTS, result) + + # --- Windows drive-letter paths (Windows only) --- + # pathlib path semantics are platform-native: Path('C:/project').is_absolute() is False on POSIX, + # so these tests only make sense on Windows where pathlib uses WindowsPath semantics. + + @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") + def test_windows_drive_path_inside_root_is_relativized(self): + """C:\\project\\tests under C:\\project is relativised to 'tests'.""" + self.fs.create_dir("C:/project/tests") + self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\project\\tests\n") + result = python_project_loader.load(Path("C:/project")) + self.assertEqual(result[SONAR_TESTS], "tests") + + @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") + def test_windows_drive_path_nested_inside_root_is_relativized(self): + """C:\\project\\src\\tests under C:\\project is relativised to 'src/tests'.""" + self.fs.create_dir("C:/project/src/tests") + self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\project\\src\\tests\n") + result = python_project_loader.load(Path("C:/project")) + self.assertEqual(result[SONAR_TESTS], "src/tests") + + @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") + @patch("pysonar_scanner.configuration.python_project_loader.logging") + def test_windows_drive_path_outside_root_emits_warning(self, mock_logging): + """C:\\other\\tests outside C:\\project emits a warning.""" + self.fs.create_dir("C:/project") + self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\other\\tests\n") + self.fs.create_dir("C:/other/tests") + python_project_loader.load(Path("C:/project")) + warning_calls = [str(c) for c in mock_logging.warning.call_args_list] + self.assertTrue( + any("other" in c for c in warning_calls), + f"Expected a warning mentioning the outside-root path, got: {warning_calls}", + ) + + @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") + def test_windows_drive_path_outside_root_stops_chain(self): + """C:\\other\\tests outside C:\\project is discarded; chain stops — no filesystem fallback.""" + self.fs.create_dir("C:/project") + self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\other\\tests\n") + self.fs.create_dir("C:/other/tests") + self.fs.create_dir("C:/project/tests") # filesystem fallback would pick this up if chain continued + result = python_project_loader.load(Path("C:/project")) + self.assertNotIn(SONAR_TESTS, result) + + @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") + @patch("pysonar_scanner.configuration.python_project_loader.logging") + def test_windows_different_drive_emits_warning(self, mock_logging): + """D:\\tests on a different drive than C:\\project emits a warning.""" + self.fs.create_dir("C:/project") + self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = D:\\tests\n") + self.fs.create_dir("D:/tests") + python_project_loader.load(Path("C:/project")) + warning_calls = [str(c) for c in mock_logging.warning.call_args_list] + self.assertTrue( + any("D:" in c for c in warning_calls), + f"Expected a warning mentioning the different-drive path, got: {warning_calls}", + ) + + # --- file paths (not directories) --- + + @patch("pysonar_scanner.configuration.python_project_loader.logging") + def test_file_path_in_testpaths_emits_debug_log(self, mock_logging): + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = tests/test_api.py\n", + ) + self.fs.create_file("tests/test_api.py", contents="") + python_project_loader.load(Path(".")) + debug_calls = [str(c) for c in mock_logging.debug.call_args_list] + self.assertTrue( + any("tests/test_api.py" in c for c in debug_calls), + f"Expected a debug message mentioning the file path, got: {debug_calls}", + ) + + def test_file_path_in_testpaths_stops_chain(self): + """A file path is dropped; if that leaves no valid directory paths the chain stops. + tests/ is created implicitly by create_file and would be found by filesystem fallback + if the chain continued — but it must not.""" + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = tests/test_api.py\n", + ) + self.fs.create_file("tests/test_api.py", contents="") + # tests/ exists on disk (implicit from create_file), but testpaths names the file, + # not the directory — chain stops at pytest.ini, filesystem fallback never runs. + result = python_project_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + + def test_file_path_mixed_with_valid_directory_path(self): + """Valid directory path is kept even when another entry points to a file.""" + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = tests/test_api.py unit\n", + ) + self.fs.create_file("tests/test_api.py", contents="") + self.fs.create_dir("unit") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "unit") + + # --- declared-but-missing path warnings --- + + @patch("pysonar_scanner.configuration.python_project_loader.logging") + def test_nonexistent_testpaths_in_pytest_ini_emits_warning(self, mock_logging): + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = nonexistent\n", + ) + python_project_loader.load(Path(".")) + warning_calls = [str(c) for c in mock_logging.warning.call_args_list] + self.assertTrue( + any("nonexistent" in c for c in warning_calls), + f"Expected a warning mentioning the missing path, got: {warning_calls}", + ) + + @patch("pysonar_scanner.configuration.python_project_loader.logging") + def test_nonexistent_testpaths_in_pyproject_toml_emits_warning(self, mock_logging): + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = ["nonexistent"] + """, + ) + python_project_loader.load(Path(".")) + warning_calls = [str(c) for c in mock_logging.warning.call_args_list] + self.assertTrue( + any("nonexistent" in c for c in warning_calls), + f"Expected a warning mentioning the missing path, got: {warning_calls}", + ) + + @patch("pysonar_scanner.configuration.python_project_loader.logging") + def test_nonexistent_testpaths_in_tox_ini_emits_warning(self, mock_logging): + self.fs.create_file( + "tox.ini", + contents="[pytest]\ntestpaths = missing_dir\n", + ) + python_project_loader.load(Path(".")) + warning_calls = [str(c) for c in mock_logging.warning.call_args_list] + self.assertTrue( + any("missing_dir" in c for c in warning_calls), + f"Expected a warning mentioning the missing path, got: {warning_calls}", + ) + + @patch("pysonar_scanner.configuration.python_project_loader.logging") + def test_nonexistent_testpaths_in_setup_cfg_emits_warning(self, mock_logging): + self.fs.create_file( + "setup.cfg", + contents="[tool:pytest]\ntestpaths = missing_dir\n", + ) + python_project_loader.load(Path(".")) + warning_calls = [str(c) for c in mock_logging.warning.call_args_list] + self.assertTrue( + any("missing_dir" in c for c in warning_calls), + f"Expected a warning mentioning the missing path, got: {warning_calls}", + ) + + # --- filesystem fallback only when no testpaths key present --- + + def test_filesystem_fallback_skipped_when_config_has_no_testpaths_key_but_pyproject_present(self): + """pyproject.toml present with no pytest section → fall through to filesystem.""" + self.fs.create_file( + "pyproject.toml", + contents="[project]\nname = my-project\n", + ) + self.fs.create_dir("tests") + result = python_project_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") From ce9f8f062dd3da4441fabb1b5f1f052242b77e08 Mon Sep 17 00:00:00 2001 From: Guillaume Dequenne Date: Tue, 12 May 2026 11:43:34 +0200 Subject: [PATCH 2/8] Prevent detection from running when sonar.tests is set --- .../configuration/configuration_loader.py | 16 ++++++++++++-- tests/unit/test_configuration_loader.py | 22 +++++++++++++++++++ 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/src/pysonar_scanner/configuration/configuration_loader.py b/src/pysonar_scanner/configuration/configuration_loader.py index 059c5e19..69b8e23f 100644 --- a/src/pysonar_scanner/configuration/configuration_loader.py +++ b/src/pysonar_scanner/configuration/configuration_loader.py @@ -24,7 +24,13 @@ 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, @@ -61,12 +67,18 @@ def load() -> dict[Key, Any]: resolved_properties = get_static_default_properties() resolved_properties.update(dynamic_defaults_loader.load()) resolved_properties.update(coverage_properties) - resolved_properties.update(python_project_loader.load(base_dir)) resolved_properties.update(toml_properties.project_properties) resolved_properties.update(sonar_project_properties.load(base_dir)) 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. + # Running python_project_loader unconditionally would emit confusing warnings about + # pytest config even when the result would be discarded. + if SONAR_TESTS not in resolved_properties: + resolved_properties.update(python_project_loader.load(base_dir)) + return resolved_properties @staticmethod diff --git a/tests/unit/test_configuration_loader.py b/tests/unit/test_configuration_loader.py index ac11a03d..0921ca09 100644 --- a/tests/unit/test_configuration_loader.py +++ b/tests/unit/test_configuration_loader.py @@ -467,6 +467,28 @@ def test_sonar_project_properties_sonar_tests_overrides_pytest_ini_auto_detectio configuration = ConfigurationLoader.load() self.assertEqual(configuration[SONAR_TESTS], "tests/e2e") + @patch("sys.argv", ["myscript.py"]) + @patch.dict("os.environ", {"SONAR_TESTS": "env/tests"}, clear=False) + def test_env_var_sonar_tests_overrides_auto_detection(self, mock_get_os, mock_get_arch): + """sonar.tests from environment variable wins; auto-detection does not run.""" + self.fs.create_dir("tests") # auto-detection would find this + self.fs.create_dir("env/tests") + configuration = ConfigurationLoader.load() + self.assertEqual(configuration[SONAR_TESTS], "env/tests") + + @patch("sys.argv", ["myscript.py"]) + @patch("pysonar_scanner.configuration.configuration_loader.python_project_loader") + def test_python_project_loader_not_called_when_sonar_tests_already_set( + self, mock_loader, mock_get_os, mock_get_arch + ): + """python_project_loader must not run at all when sonar.tests is already set — avoids spurious warnings.""" + self.fs.create_file( + "sonar-project.properties", + contents="sonar.tests=explicit/tests\n", + ) + ConfigurationLoader.load() + mock_loader.load.assert_not_called() + @patch("sys.argv", ["myscript.py"]) @patch.dict("os.environ", {"SONAR_TOKEN": "TokenFromEnv", "SONAR_PROJECT_KEY": "KeyFromEnv"}, clear=True) def test_load_from_env_variables_only(self, mock_get_os, mock_get_arch): From 566044642e5dc3f0c505cd7011f35a215a9e9291 Mon Sep 17 00:00:00 2001 From: Guillaume Dequenne Date: Tue, 12 May 2026 11:57:22 +0200 Subject: [PATCH 3/8] Fixes --- .../configuration/python_project_loader.py | 134 ++++++------------ 1 file changed, 46 insertions(+), 88 deletions(-) diff --git a/src/pysonar_scanner/configuration/python_project_loader.py b/src/pysonar_scanner/configuration/python_project_loader.py index c252a3bc..f57bd5cf 100644 --- a/src/pysonar_scanner/configuration/python_project_loader.py +++ b/src/pysonar_scanner/configuration/python_project_loader.py @@ -93,106 +93,64 @@ def _load_from_pyproject_toml(base_dir: pathlib.Path) -> Optional[str]: try: with open(pyproject_path, "rb") as f: toml_dict = tomli.load(f) - testpaths = toml_dict.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("testpaths") - if not testpaths: - 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 - 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 - except Exception as e: + except tomli.TOMLDecodeError as e: logging.debug(f"Error reading pyproject.toml for pytest testpaths: {e}") - return None + return None + testpaths = toml_dict.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("testpaths") + if not testpaths: + 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 + 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_pytest_ini(base_dir: pathlib.Path) -> Optional[str]: - pytest_ini_path = base_dir / "pytest.ini" - if not pytest_ini_path.is_file(): +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(pytest_ini_path) - if "pytest" not in config or "testpaths" not in config["pytest"]: - return None - raw = [p for p in config["pytest"]["testpaths"].split() if p] - if not raw: - return None - paths = _existing_paths(base_dir, raw) - if paths: - result = ",".join(paths) - logging.debug(f"Detected test paths from pytest.ini: {result}") - return result - logging.warning( - f"testpaths is set in pytest.ini to {raw} " - f"but none of those paths exist as directories — sonar.tests will not be auto-detected" - ) - return "" - except Exception as e: - logging.debug(f"Error reading pytest.ini for testpaths: {e}") - return None + 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 + 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]: - tox_ini_path = base_dir / "tox.ini" - if not tox_ini_path.is_file(): - return None - try: - config = configparser.ConfigParser() - config.read(tox_ini_path) - if "pytest" not in config or "testpaths" not in config["pytest"]: - return None - raw = [p for p in config["pytest"]["testpaths"].split() if p] - if not raw: - return None - paths = _existing_paths(base_dir, raw) - if paths: - result = ",".join(paths) - logging.debug(f"Detected test paths from tox.ini: {result}") - return result - logging.warning( - f"testpaths is set in tox.ini to {raw} " - f"but none of those paths exist as directories — sonar.tests will not be auto-detected" - ) - return "" - except Exception as e: - logging.debug(f"Error reading tox.ini for testpaths: {e}") - return None + return _load_from_ini_file(base_dir, "tox.ini", "pytest") def _load_from_setup_cfg(base_dir: pathlib.Path) -> Optional[str]: - setup_cfg_path = base_dir / "setup.cfg" - if not setup_cfg_path.is_file(): - return None - try: - config = configparser.ConfigParser() - config.read(setup_cfg_path) - if _SETUP_CFG_PYTEST_SECTION not in config or "testpaths" not in config[_SETUP_CFG_PYTEST_SECTION]: - return None - raw = [p for p in config[_SETUP_CFG_PYTEST_SECTION]["testpaths"].split() if p] - if not raw: - return None - paths = _existing_paths(base_dir, raw) - if paths: - result = ",".join(paths) - logging.debug(f"Detected test paths from setup.cfg [tool:pytest]: {result}") - return result - logging.warning( - f"testpaths is set in setup.cfg [tool:pytest] to {raw} " - f"but none of those paths exist as directories — sonar.tests will not be auto-detected" - ) - return "" - except Exception as e: - logging.debug(f"Error reading setup.cfg for pytest testpaths: {e}") - return None + return _load_from_ini_file(base_dir, "setup.cfg", _SETUP_CFG_PYTEST_SECTION) def _load_from_filesystem(base_dir: pathlib.Path) -> Optional[str]: From e41141bea6189415c4b1de76d7466d3f31e72a79 Mon Sep 17 00:00:00 2001 From: Guillaume Dequenne Date: Tue, 12 May 2026 13:46:23 +0200 Subject: [PATCH 4/8] Get task by id --- tests/its/test_auto_detect_tests.py | 18 ++++++++---------- tests/its/utils/sonarqube_client.py | 5 +++++ 2 files changed, 13 insertions(+), 10 deletions(-) diff --git a/tests/its/test_auto_detect_tests.py b/tests/its/test_auto_detect_tests.py index cd659bb8..0f3382dd 100644 --- a/tests/its/test_auto_detect_tests.py +++ b/tests/its/test_auto_detect_tests.py @@ -19,7 +19,7 @@ # import pytest from tests.its.utils.sonarqube_client import SonarQubeClient -from tests.its.utils.cli_client import CliClient +from tests.its.utils.cli_client import CliClient, SOURCES_FOLDER_PATH pytestmark = pytest.mark.its @@ -30,19 +30,17 @@ def test_auto_detect_tests_from_pyproject_toml(sonarqube_client: SonarQubeClient process = cli.run_analysis(sources_dir="with-tests", params=["--verbose"]) assert process.returncode == 0, process.stdout - task = sonarqube_client.get_latest_ce_task() - projects = sonarqube_client.search_projects() - project_keys = [p["key"] for p in projects] + 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 is not None and task["status"] == "SUCCESS", ( - f"SonarQube CE task did not succeed.\n" - f"Task: {task}\n" - f"Existing projects: {project_keys}\n" - f"Scanner output:\n{process.stdout}" + 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"Existing projects: {project_keys}\n" f"Scanner output:\n{process.stdout}" ) diff --git a/tests/its/utils/sonarqube_client.py b/tests/its/utils/sonarqube_client.py index 01f741c9..c596c58d 100644 --- a/tests/its/utils/sonarqube_client.py +++ b/tests/its/utils/sonarqube_client.py @@ -120,6 +120,11 @@ def get_latest_ce_task(self) -> Optional[dict]: tasks = resp.json().get("tasks", []) return tasks[0] if tasks else None + def get_ce_task_by_id(self, task_id: str) -> dict: + resp = self.session.get(f"{self.base_url}/api/ce/task", params={"id": task_id}) + resp.raise_for_status() + return resp.json().get("task", {}) + def wait_for_ce_task_by_id(self, task_id: str) -> None: """Poll a specific CE task until it reaches a terminal state.""" for _ in range(self.MAX_RETRIES * 10): From e2380f2c04c869f77602fcf174794da18f99bde6 Mon Sep 17 00:00:00 2001 From: Guillaume Dequenne Date: Wed, 13 May 2026 10:23:38 +0200 Subject: [PATCH 5/8] Fix after review --- .../configuration/configuration_loader.py | 4 +- ...project_loader.py => test_paths_loader.py} | 9 +- tests/its/utils/cli_client.py | 8 - tests/its/utils/sonarqube_client.py | 36 ----- tests/unit/test_configuration_loader.py | 6 +- ...ct_loader.py => test_test_paths_loader.py} | 149 ++++++++++-------- 6 files changed, 90 insertions(+), 122 deletions(-) rename src/pysonar_scanner/configuration/{python_project_loader.py => test_paths_loader.py} (95%) rename tests/unit/{test_python_project_loader.py => test_test_paths_loader.py} (84%) diff --git a/src/pysonar_scanner/configuration/configuration_loader.py b/src/pysonar_scanner/configuration/configuration_loader.py index 69b8e23f..22112f9c 100644 --- a/src/pysonar_scanner/configuration/configuration_loader.py +++ b/src/pysonar_scanner/configuration/configuration_loader.py @@ -36,7 +36,7 @@ sonar_project_properties, environment_variables, dynamic_defaults_loader, - python_project_loader, + test_paths_loader, ) from pysonar_scanner.exceptions import MissingProperty, MissingPropertyException @@ -77,7 +77,7 @@ def load() -> dict[Key, Any]: # Running python_project_loader unconditionally would emit confusing warnings about # pytest config even when the result would be discarded. if SONAR_TESTS not in resolved_properties: - resolved_properties.update(python_project_loader.load(base_dir)) + resolved_properties.update(test_paths_loader.load(base_dir)) return resolved_properties diff --git a/src/pysonar_scanner/configuration/python_project_loader.py b/src/pysonar_scanner/configuration/test_paths_loader.py similarity index 95% rename from src/pysonar_scanner/configuration/python_project_loader.py rename to src/pysonar_scanner/configuration/test_paths_loader.py index f57bd5cf..8b80c1c1 100644 --- a/src/pysonar_scanner/configuration/python_project_loader.py +++ b/src/pysonar_scanner/configuration/test_paths_loader.py @@ -96,12 +96,13 @@ def _load_from_pyproject_toml(base_dir: pathlib.Path) -> Optional[str]: except tomli.TOMLDecodeError as e: logging.debug(f"Error reading pyproject.toml for pytest testpaths: {e}") return None - testpaths = toml_dict.get("tool", {}).get("pytest", {}).get("ini_options", {}).get("testpaths") - if not testpaths: + ini_options = toml_dict.get("tool", {}).get("pytest", {}).get("ini_options", {}) + if "testpaths" not in ini_options: 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 None + return "" # testpaths key present but empty — stop chain paths = _existing_paths(base_dir, raw) if paths: result = ",".join(paths) @@ -128,7 +129,7 @@ def _load_from_ini_file(base_dir: pathlib.Path, filename: str, section: str) -> return None raw = [p for p in config[section]["testpaths"].split() if p] if not raw: - return None + return "" # testpaths key present but empty — stop chain paths = _existing_paths(base_dir, raw) if paths: result = ",".join(paths) diff --git a/tests/its/utils/cli_client.py b/tests/its/utils/cli_client.py index 161a044b..1d92f622 100644 --- a/tests/its/utils/cli_client.py +++ b/tests/its/utils/cli_client.py @@ -99,17 +99,9 @@ def __run_analysis_normal(self, workdir: pathlib.Path, params: list[str], token: return process def _wait_for_ce_completion(self, workdir: pathlib.Path) -> None: - """Wait for the CE task to reach a terminal state. - - Reads the CE task ID written by the scanner to .sonar/report-task.txt and polls - api/ce/task until the task is done. Falls back to the queue-empty check when the - task file is absent (e.g. analysis failed before uploading the report). - """ task_id = self._read_ce_task_id(workdir) if task_id: self.sq_client.wait_for_ce_task_by_id(task_id) - else: - self.sq_client.wait_for_analysis_completion() @staticmethod def _read_ce_task_id(workdir: pathlib.Path) -> Optional[str]: diff --git a/tests/its/utils/sonarqube_client.py b/tests/its/utils/sonarqube_client.py index c596c58d..39248ed9 100644 --- a/tests/its/utils/sonarqube_client.py +++ b/tests/its/utils/sonarqube_client.py @@ -79,19 +79,6 @@ def get_system_status(self) -> SystemStatus: resp.raise_for_status() return resp.json() - def wait_for_analysis_completion(self): - empty_queue = False - count = 0 - while not empty_queue: - logger.info("Waiting for analysis completion") - if count > self.MAX_RETRIES: - raise RuntimeError("Too many retries on analysis report") - response = self.session.get(f"{self.base_url}/api/analysis_reports/is_queue_empty") - if "true" == response.text: - empty_queue = True - count = count + 1 - time.sleep(2) - def get_project_issues(self, project_key: str) -> IssuesSearch: resp = self.session.get(f"{self.base_url}/api/issues/search?projects={project_key}") resp.raise_for_status() @@ -102,24 +89,6 @@ def get_project_analyses(self, project_key: str) -> ProjectAnalysesSearch: resp.raise_for_status() return resp.json() - def get_project_measures(self, project_key: str, metric_keys: list[str]) -> dict: - resp = self.session.get( - f"{self.base_url}/api/measures/component", - params={"component": project_key, "metricKeys": ",".join(metric_keys)}, - ) - resp.raise_for_status() - return resp.json() - - def get_latest_ce_task(self) -> Optional[dict]: - """Return the most recent CE background task, or None if no task has completed yet.""" - resp = self.session.get( - f"{self.base_url}/api/ce/activity", - params={"type": "REPORT", "ps": 1}, - ) - resp.raise_for_status() - tasks = resp.json().get("tasks", []) - return tasks[0] if tasks else None - def get_ce_task_by_id(self, task_id: str) -> dict: resp = self.session.get(f"{self.base_url}/api/ce/task", params={"id": task_id}) resp.raise_for_status() @@ -137,11 +106,6 @@ def wait_for_ce_task_by_id(self, task_id: str) -> None: time.sleep(2) raise RuntimeError(f"CE task {task_id} did not complete in time") - def search_projects(self) -> list[dict]: - resp = self.session.get(f"{self.base_url}/api/projects/search") - resp.raise_for_status() - return resp.json().get("components", []) - def get_project_test_files(self, project_key: str) -> list[dict]: """Return components classified as unit-test source files (qualifier UTS) for the given project.""" resp = self.session.get( diff --git a/tests/unit/test_configuration_loader.py b/tests/unit/test_configuration_loader.py index 0921ca09..fb27fd8d 100644 --- a/tests/unit/test_configuration_loader.py +++ b/tests/unit/test_configuration_loader.py @@ -477,11 +477,11 @@ def test_env_var_sonar_tests_overrides_auto_detection(self, mock_get_os, mock_ge self.assertEqual(configuration[SONAR_TESTS], "env/tests") @patch("sys.argv", ["myscript.py"]) - @patch("pysonar_scanner.configuration.configuration_loader.python_project_loader") - def test_python_project_loader_not_called_when_sonar_tests_already_set( + @patch("pysonar_scanner.configuration.configuration_loader.test_paths_loader") + def test_test_paths_loader_not_called_when_sonar_tests_already_set( self, mock_loader, mock_get_os, mock_get_arch ): - """python_project_loader must not run at all when sonar.tests is already set — avoids spurious warnings.""" + """test_paths_loader must not run at all when sonar.tests is already set — avoids spurious warnings.""" self.fs.create_file( "sonar-project.properties", contents="sonar.tests=explicit/tests\n", diff --git a/tests/unit/test_python_project_loader.py b/tests/unit/test_test_paths_loader.py similarity index 84% rename from tests/unit/test_python_project_loader.py rename to tests/unit/test_test_paths_loader.py index a80f6989..5e4097a1 100644 --- a/tests/unit/test_python_project_loader.py +++ b/tests/unit/test_test_paths_loader.py @@ -24,7 +24,7 @@ from pyfakefs.fake_filesystem_unittest import TestCase -from pysonar_scanner.configuration import python_project_loader +from pysonar_scanner.configuration import test_paths_loader from pysonar_scanner.configuration.properties import SONAR_TESTS @@ -44,7 +44,7 @@ def test_load_from_pyproject_toml_list(self): ) self.fs.create_dir("tests") self.fs.create_dir("integration") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration") def test_load_from_pyproject_toml_single_entry(self): @@ -56,7 +56,7 @@ def test_load_from_pyproject_toml_single_entry(self): """, ) self.fs.create_dir("tests") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") def test_load_from_pyproject_toml_no_pytest_section(self): @@ -67,7 +67,7 @@ def test_load_from_pyproject_toml_no_pytest_section(self): projectKey = "my-project" """, ) - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) def test_load_from_pyproject_toml_empty_testpaths(self): @@ -78,7 +78,8 @@ def test_load_from_pyproject_toml_empty_testpaths(self): testpaths = [] """, ) - result = python_project_loader.load(Path(".")) + self.fs.create_dir("tests") # filesystem fallback would pick this up if chain continued + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) def test_load_from_pyproject_toml_nonexistent_path_not_returned(self): @@ -90,7 +91,7 @@ def test_load_from_pyproject_toml_nonexistent_path_not_returned(self): """, ) # tests/ directory does NOT exist - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) def test_load_from_pyproject_toml_filters_nonexistent_paths(self): @@ -103,16 +104,16 @@ def test_load_from_pyproject_toml_filters_nonexistent_paths(self): ) self.fs.create_dir("tests") # nonexistent/ is not on disk — only tests/ should be returned - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") - @patch("pysonar_scanner.configuration.python_project_loader.logging") + @patch("pysonar_scanner.configuration.test_paths_loader.logging") def test_load_from_pyproject_toml_malformed(self, mock_logging): self.fs.create_file( "pyproject.toml", contents="this is not valid toml ][", ) - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) mock_logging.debug.assert_called() @@ -125,17 +126,22 @@ def test_load_from_pytest_ini(self): ) self.fs.create_dir("tests") self.fs.create_dir("integration") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration") def test_load_from_pytest_ini_multiline(self): self.fs.create_file( "pytest.ini", - contents="[pytest]\ntestpaths =\n tests\n integration\n", + contents=( + "[pytest]\n" + "testpaths =\n" + " tests\n" + " integration\n" + ), ) self.fs.create_dir("tests") self.fs.create_dir("integration") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration") def test_load_from_pytest_ini_no_testpaths(self): @@ -143,7 +149,7 @@ def test_load_from_pytest_ini_no_testpaths(self): "pytest.ini", contents="[pytest]\naddopts = --strict-markers\n", ) - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) def test_load_from_pytest_ini_nonexistent_path_not_returned(self): @@ -152,7 +158,7 @@ def test_load_from_pytest_ini_nonexistent_path_not_returned(self): contents="[pytest]\ntestpaths = tests\n", ) # tests/ directory does NOT exist - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) # --- tox.ini --- @@ -163,7 +169,7 @@ def test_load_from_tox_ini(self): contents="[pytest]\ntestpaths = tests\n", ) self.fs.create_dir("tests") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") def test_load_from_tox_ini_multiple_paths(self): @@ -173,17 +179,22 @@ def test_load_from_tox_ini_multiple_paths(self): ) self.fs.create_dir("tests") self.fs.create_dir("integration") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration") def test_load_from_tox_ini_multiline(self): self.fs.create_file( "tox.ini", - contents="[pytest]\ntestpaths =\n tests\n integration\n", + contents=( + "[pytest]\n" + "testpaths =\n" + " tests\n" + " integration\n" + ), ) self.fs.create_dir("tests") self.fs.create_dir("integration") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration") def test_load_from_tox_ini_no_pytest_section(self): @@ -191,7 +202,7 @@ def test_load_from_tox_ini_no_pytest_section(self): "tox.ini", contents="[tox]\nenvlist = py39\n", ) - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) def test_load_from_tox_ini_no_testpaths(self): @@ -199,7 +210,7 @@ def test_load_from_tox_ini_no_testpaths(self): "tox.ini", contents="[pytest]\naddopts = --strict-markers\n", ) - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) def test_load_from_tox_ini_nonexistent_path_not_returned(self): @@ -208,7 +219,7 @@ def test_load_from_tox_ini_nonexistent_path_not_returned(self): contents="[pytest]\ntestpaths = tests\n", ) # tests/ directory does NOT exist - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) # --- setup.cfg --- @@ -219,7 +230,7 @@ def test_load_from_setup_cfg(self): contents="[tool:pytest]\ntestpaths = tests\n", ) self.fs.create_dir("tests") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") def test_load_from_setup_cfg_multiple_paths(self): @@ -230,7 +241,7 @@ def test_load_from_setup_cfg_multiple_paths(self): self.fs.create_dir("tests") self.fs.create_dir("integration") self.fs.create_dir("e2e") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration,e2e") def test_load_from_setup_cfg_no_pytest_section(self): @@ -238,7 +249,7 @@ def test_load_from_setup_cfg_no_pytest_section(self): "setup.cfg", contents="[metadata]\nname = my-package\n", ) - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) def test_load_from_setup_cfg_nonexistent_path_not_returned(self): @@ -247,41 +258,41 @@ def test_load_from_setup_cfg_nonexistent_path_not_returned(self): contents="[tool:pytest]\ntestpaths = tests\n", ) # tests/ directory does NOT exist - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) # --- filesystem fallback --- def test_load_from_filesystem_tests_dir(self): self.fs.create_dir("tests") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") def test_load_from_filesystem_test_dir(self): self.fs.create_dir("test") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "test") def test_load_from_filesystem_testing_dir(self): self.fs.create_dir("testing") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "testing") def test_load_from_filesystem_multiple_conventional_dirs(self): self.fs.create_dir("tests") self.fs.create_dir("test") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,test") def test_load_from_filesystem_no_conventional_dir(self): self.fs.create_dir("src") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) # --- nothing found --- def test_load_returns_empty_when_nothing_configured(self): - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result, {}) # --- priority order --- @@ -299,7 +310,7 @@ def test_pyproject_toml_takes_priority_over_pytest_ini(self): contents="[pytest]\ntestpaths = from_ini\n", ) self.fs.create_dir("from_toml") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "from_toml") def test_pytest_ini_takes_priority_over_tox_ini(self): @@ -312,7 +323,7 @@ def test_pytest_ini_takes_priority_over_tox_ini(self): contents="[pytest]\ntestpaths = from_tox\n", ) self.fs.create_dir("from_ini") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "from_ini") def test_tox_ini_takes_priority_over_setup_cfg(self): @@ -325,7 +336,7 @@ def test_tox_ini_takes_priority_over_setup_cfg(self): contents="[tool:pytest]\ntestpaths = from_setup_cfg\n", ) self.fs.create_dir("from_tox") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "from_tox") def test_pytest_ini_takes_priority_over_setup_cfg(self): @@ -338,7 +349,7 @@ def test_pytest_ini_takes_priority_over_setup_cfg(self): contents="[tool:pytest]\ntestpaths = from_setup_cfg\n", ) self.fs.create_dir("from_ini") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "from_ini") def test_setup_cfg_takes_priority_over_filesystem(self): @@ -348,7 +359,7 @@ def test_setup_cfg_takes_priority_over_filesystem(self): ) self.fs.create_dir("from_setup_cfg") self.fs.create_dir("tests") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "from_setup_cfg") def test_config_without_testpaths_falls_through_to_filesystem(self): @@ -360,7 +371,7 @@ def test_config_without_testpaths_falls_through_to_filesystem(self): """, ) self.fs.create_dir("tests") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") def test_declared_nonexistent_testpaths_stops_chain(self): @@ -376,7 +387,7 @@ def test_declared_nonexistent_testpaths_stops_chain(self): ) self.fs.create_dir("from_setup_cfg") self.fs.create_dir("tests") # filesystem fallback would find this - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) # --- custom base_dir --- @@ -388,7 +399,7 @@ def test_load_from_custom_base_dir(self): contents="[pytest]\ntestpaths = custom_tests\n", ) self.fs.create_dir("custom/path/custom_tests") - result = python_project_loader.load(Path("custom/path")) + result = test_paths_loader.load(Path("custom/path")) self.assertEqual(result[SONAR_TESTS], "custom_tests") # --- absolute paths --- @@ -396,12 +407,12 @@ def test_load_from_custom_base_dir(self): # are unambiguously not under the project and inside-root paths (/project/...) are # unambiguously convertible, regardless of the fake cwd. - @patch("pysonar_scanner.configuration.python_project_loader.logging") + @patch("pysonar_scanner.configuration.test_paths_loader.logging") def test_absolute_path_outside_project_root_emits_warning(self, mock_logging): self.fs.create_dir("/project") self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /other/tests\n") self.fs.create_dir("/other/tests") - python_project_loader.load(Path("/project")) + test_paths_loader.load(Path("/project")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] self.assertTrue( any("/other/tests" in c for c in warning_calls), @@ -414,21 +425,21 @@ def test_absolute_path_outside_project_root_stops_chain(self): self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /other/tests\n") self.fs.create_dir("/other/tests") self.fs.create_dir("/project/tests") # filesystem fallback would pick this up if chain continued - result = python_project_loader.load(Path("/project")) + result = test_paths_loader.load(Path("/project")) self.assertNotIn(SONAR_TESTS, result) def test_absolute_path_inside_project_root_is_converted_to_relative(self): """Absolute path under the project root is relativised and used as sonar.tests.""" self.fs.create_dir("/project/tests") self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /project/tests\n") - result = python_project_loader.load(Path("/project")) + result = test_paths_loader.load(Path("/project")) self.assertEqual(result[SONAR_TESTS], "tests") def test_absolute_path_inside_project_root_nested(self): """A deeper absolute path is relativised correctly.""" self.fs.create_dir("/project/src/tests") self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /project/src/tests\n") - result = python_project_loader.load(Path("/project")) + result = test_paths_loader.load(Path("/project")) self.assertEqual(result[SONAR_TESTS], "src/tests") def test_absolute_path_mixed_with_valid_relative_path(self): @@ -438,7 +449,7 @@ def test_absolute_path_mixed_with_valid_relative_path(self): "/project/pytest.ini", contents="[pytest]\ntestpaths = /other/tests tests\n", ) - result = python_project_loader.load(Path("/project")) + result = test_paths_loader.load(Path("/project")) self.assertEqual(result[SONAR_TESTS], "tests") def test_absolute_path_outside_root_in_pyproject_toml_stops_chain(self): @@ -451,7 +462,7 @@ def test_absolute_path_outside_root_in_pyproject_toml_stops_chain(self): """, ) self.fs.create_dir("/project/tests") # filesystem fallback - result = python_project_loader.load(Path("/project")) + result = test_paths_loader.load(Path("/project")) self.assertNotIn(SONAR_TESTS, result) def test_absolute_path_inside_root_in_pyproject_toml_is_converted(self): @@ -463,21 +474,21 @@ def test_absolute_path_inside_root_in_pyproject_toml_is_converted(self): testpaths = ["/project/tests"] """, ) - result = python_project_loader.load(Path("/project")) + result = test_paths_loader.load(Path("/project")) self.assertEqual(result[SONAR_TESTS], "tests") def test_absolute_path_outside_root_in_tox_ini_stops_chain(self): self.fs.create_dir("/project") self.fs.create_file("/project/tox.ini", contents="[pytest]\ntestpaths = /other/tests\n") self.fs.create_dir("/project/tests") - result = python_project_loader.load(Path("/project")) + result = test_paths_loader.load(Path("/project")) self.assertNotIn(SONAR_TESTS, result) def test_absolute_path_outside_root_in_setup_cfg_stops_chain(self): self.fs.create_dir("/project") self.fs.create_file("/project/setup.cfg", contents="[tool:pytest]\ntestpaths = /other/tests\n") self.fs.create_dir("/project/tests") - result = python_project_loader.load(Path("/project")) + result = test_paths_loader.load(Path("/project")) self.assertNotIn(SONAR_TESTS, result) # --- Windows drive-letter paths (Windows only) --- @@ -489,7 +500,7 @@ def test_windows_drive_path_inside_root_is_relativized(self): """C:\\project\\tests under C:\\project is relativised to 'tests'.""" self.fs.create_dir("C:/project/tests") self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\project\\tests\n") - result = python_project_loader.load(Path("C:/project")) + result = test_paths_loader.load(Path("C:/project")) self.assertEqual(result[SONAR_TESTS], "tests") @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") @@ -497,17 +508,17 @@ def test_windows_drive_path_nested_inside_root_is_relativized(self): """C:\\project\\src\\tests under C:\\project is relativised to 'src/tests'.""" self.fs.create_dir("C:/project/src/tests") self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\project\\src\\tests\n") - result = python_project_loader.load(Path("C:/project")) + result = test_paths_loader.load(Path("C:/project")) self.assertEqual(result[SONAR_TESTS], "src/tests") @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") - @patch("pysonar_scanner.configuration.python_project_loader.logging") + @patch("pysonar_scanner.configuration.test_paths_loader.logging") def test_windows_drive_path_outside_root_emits_warning(self, mock_logging): """C:\\other\\tests outside C:\\project emits a warning.""" self.fs.create_dir("C:/project") self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\other\\tests\n") self.fs.create_dir("C:/other/tests") - python_project_loader.load(Path("C:/project")) + test_paths_loader.load(Path("C:/project")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] self.assertTrue( any("other" in c for c in warning_calls), @@ -521,17 +532,17 @@ def test_windows_drive_path_outside_root_stops_chain(self): self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\other\\tests\n") self.fs.create_dir("C:/other/tests") self.fs.create_dir("C:/project/tests") # filesystem fallback would pick this up if chain continued - result = python_project_loader.load(Path("C:/project")) + result = test_paths_loader.load(Path("C:/project")) self.assertNotIn(SONAR_TESTS, result) @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") - @patch("pysonar_scanner.configuration.python_project_loader.logging") + @patch("pysonar_scanner.configuration.test_paths_loader.logging") def test_windows_different_drive_emits_warning(self, mock_logging): """D:\\tests on a different drive than C:\\project emits a warning.""" self.fs.create_dir("C:/project") self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = D:\\tests\n") self.fs.create_dir("D:/tests") - python_project_loader.load(Path("C:/project")) + test_paths_loader.load(Path("C:/project")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] self.assertTrue( any("D:" in c for c in warning_calls), @@ -540,14 +551,14 @@ def test_windows_different_drive_emits_warning(self, mock_logging): # --- file paths (not directories) --- - @patch("pysonar_scanner.configuration.python_project_loader.logging") + @patch("pysonar_scanner.configuration.test_paths_loader.logging") def test_file_path_in_testpaths_emits_debug_log(self, mock_logging): self.fs.create_file( "pytest.ini", contents="[pytest]\ntestpaths = tests/test_api.py\n", ) self.fs.create_file("tests/test_api.py", contents="") - python_project_loader.load(Path(".")) + test_paths_loader.load(Path(".")) debug_calls = [str(c) for c in mock_logging.debug.call_args_list] self.assertTrue( any("tests/test_api.py" in c for c in debug_calls), @@ -565,7 +576,7 @@ def test_file_path_in_testpaths_stops_chain(self): self.fs.create_file("tests/test_api.py", contents="") # tests/ exists on disk (implicit from create_file), but testpaths names the file, # not the directory — chain stops at pytest.ini, filesystem fallback never runs. - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) def test_file_path_mixed_with_valid_directory_path(self): @@ -576,25 +587,25 @@ def test_file_path_mixed_with_valid_directory_path(self): ) self.fs.create_file("tests/test_api.py", contents="") self.fs.create_dir("unit") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "unit") # --- declared-but-missing path warnings --- - @patch("pysonar_scanner.configuration.python_project_loader.logging") + @patch("pysonar_scanner.configuration.test_paths_loader.logging") def test_nonexistent_testpaths_in_pytest_ini_emits_warning(self, mock_logging): self.fs.create_file( "pytest.ini", contents="[pytest]\ntestpaths = nonexistent\n", ) - python_project_loader.load(Path(".")) + test_paths_loader.load(Path(".")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] self.assertTrue( any("nonexistent" in c for c in warning_calls), f"Expected a warning mentioning the missing path, got: {warning_calls}", ) - @patch("pysonar_scanner.configuration.python_project_loader.logging") + @patch("pysonar_scanner.configuration.test_paths_loader.logging") def test_nonexistent_testpaths_in_pyproject_toml_emits_warning(self, mock_logging): self.fs.create_file( "pyproject.toml", @@ -603,33 +614,33 @@ def test_nonexistent_testpaths_in_pyproject_toml_emits_warning(self, mock_loggin testpaths = ["nonexistent"] """, ) - python_project_loader.load(Path(".")) + test_paths_loader.load(Path(".")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] self.assertTrue( any("nonexistent" in c for c in warning_calls), f"Expected a warning mentioning the missing path, got: {warning_calls}", ) - @patch("pysonar_scanner.configuration.python_project_loader.logging") + @patch("pysonar_scanner.configuration.test_paths_loader.logging") def test_nonexistent_testpaths_in_tox_ini_emits_warning(self, mock_logging): self.fs.create_file( "tox.ini", contents="[pytest]\ntestpaths = missing_dir\n", ) - python_project_loader.load(Path(".")) + test_paths_loader.load(Path(".")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] self.assertTrue( any("missing_dir" in c for c in warning_calls), f"Expected a warning mentioning the missing path, got: {warning_calls}", ) - @patch("pysonar_scanner.configuration.python_project_loader.logging") + @patch("pysonar_scanner.configuration.test_paths_loader.logging") def test_nonexistent_testpaths_in_setup_cfg_emits_warning(self, mock_logging): self.fs.create_file( "setup.cfg", contents="[tool:pytest]\ntestpaths = missing_dir\n", ) - python_project_loader.load(Path(".")) + test_paths_loader.load(Path(".")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] self.assertTrue( any("missing_dir" in c for c in warning_calls), @@ -645,5 +656,5 @@ def test_filesystem_fallback_skipped_when_config_has_no_testpaths_key_but_pyproj contents="[project]\nname = my-project\n", ) self.fs.create_dir("tests") - result = python_project_loader.load(Path(".")) + result = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") From ca838840c9896ef841f287c98de52cd557bd98c7 Mon Sep 17 00:00:00 2001 From: Guillaume Dequenne Date: Wed, 13 May 2026 10:47:08 +0200 Subject: [PATCH 6/8] Disable test file inference when testFileHeuristic is disabled --- .../configuration/configuration_loader.py | 9 +++++---- tests/unit/test_configuration_loader.py | 16 +++++++++++++--- tests/unit/test_test_paths_loader.py | 14 ++------------ 3 files changed, 20 insertions(+), 19 deletions(-) diff --git a/src/pysonar_scanner/configuration/configuration_loader.py b/src/pysonar_scanner/configuration/configuration_loader.py index 22112f9c..f919177d 100644 --- a/src/pysonar_scanner/configuration/configuration_loader.py +++ b/src/pysonar_scanner/configuration/configuration_loader.py @@ -73,10 +73,11 @@ def load() -> dict[Key, Any]: 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. - # Running python_project_loader unconditionally would emit confusing warnings about - # pytest config even when the result would be discarded. - if SONAR_TESTS not in resolved_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 diff --git a/tests/unit/test_configuration_loader.py b/tests/unit/test_configuration_loader.py index fb27fd8d..e37f2d6f 100644 --- a/tests/unit/test_configuration_loader.py +++ b/tests/unit/test_configuration_loader.py @@ -478,9 +478,7 @@ def test_env_var_sonar_tests_overrides_auto_detection(self, mock_get_os, mock_ge @patch("sys.argv", ["myscript.py"]) @patch("pysonar_scanner.configuration.configuration_loader.test_paths_loader") - def test_test_paths_loader_not_called_when_sonar_tests_already_set( - self, mock_loader, mock_get_os, mock_get_arch - ): + def test_test_paths_loader_not_called_when_sonar_tests_already_set(self, mock_loader, mock_get_os, mock_get_arch): """test_paths_loader must not run at all when sonar.tests is already set — avoids spurious warnings.""" self.fs.create_file( "sonar-project.properties", @@ -489,6 +487,18 @@ def test_test_paths_loader_not_called_when_sonar_tests_already_set( ConfigurationLoader.load() mock_loader.load.assert_not_called() + @patch("sys.argv", ["myscript.py"]) + @patch("pysonar_scanner.configuration.configuration_loader.test_paths_loader") + def test_test_paths_loader_not_called_when_heuristic_disabled(self, mock_loader, mock_get_os, mock_get_arch): + """test_paths_loader must not run when sonar.python.testFileHeuristic.disabled=true.""" + self.fs.create_file( + "sonar-project.properties", + contents="sonar.python.testFileHeuristic.disabled=true\n", + ) + self.fs.create_dir("tests") # inference would find this if it ran + ConfigurationLoader.load() + mock_loader.load.assert_not_called() + @patch("sys.argv", ["myscript.py"]) @patch.dict("os.environ", {"SONAR_TOKEN": "TokenFromEnv", "SONAR_PROJECT_KEY": "KeyFromEnv"}, clear=True) def test_load_from_env_variables_only(self, mock_get_os, mock_get_arch): diff --git a/tests/unit/test_test_paths_loader.py b/tests/unit/test_test_paths_loader.py index 5e4097a1..93443f49 100644 --- a/tests/unit/test_test_paths_loader.py +++ b/tests/unit/test_test_paths_loader.py @@ -132,12 +132,7 @@ def test_load_from_pytest_ini(self): def test_load_from_pytest_ini_multiline(self): self.fs.create_file( "pytest.ini", - contents=( - "[pytest]\n" - "testpaths =\n" - " tests\n" - " integration\n" - ), + contents=("[pytest]\n" "testpaths =\n" " tests\n" " integration\n"), ) self.fs.create_dir("tests") self.fs.create_dir("integration") @@ -185,12 +180,7 @@ def test_load_from_tox_ini_multiple_paths(self): def test_load_from_tox_ini_multiline(self): self.fs.create_file( "tox.ini", - contents=( - "[pytest]\n" - "testpaths =\n" - " tests\n" - " integration\n" - ), + contents=("[pytest]\n" "testpaths =\n" " tests\n" " integration\n"), ) self.fs.create_dir("tests") self.fs.create_dir("integration") From 3249dd4f807de678607266de5c3ee742db4cbfcf Mon Sep 17 00:00:00 2001 From: Guillaume Dequenne Date: Wed, 13 May 2026 11:15:22 +0200 Subject: [PATCH 7/8] Consistently use multiline strings --- tests/unit/test_test_paths_loader.py | 257 ++++++++++++++++++++++----- 1 file changed, 213 insertions(+), 44 deletions(-) diff --git a/tests/unit/test_test_paths_loader.py b/tests/unit/test_test_paths_loader.py index 93443f49..7e463e3d 100644 --- a/tests/unit/test_test_paths_loader.py +++ b/tests/unit/test_test_paths_loader.py @@ -122,7 +122,10 @@ def test_load_from_pyproject_toml_malformed(self, mock_logging): def test_load_from_pytest_ini(self): self.fs.create_file( "pytest.ini", - contents="[pytest]\ntestpaths = tests integration\n", + contents="""\ +[pytest] +testpaths = tests integration +""", ) self.fs.create_dir("tests") self.fs.create_dir("integration") @@ -132,7 +135,12 @@ def test_load_from_pytest_ini(self): def test_load_from_pytest_ini_multiline(self): self.fs.create_file( "pytest.ini", - contents=("[pytest]\n" "testpaths =\n" " tests\n" " integration\n"), + contents="""\ +[pytest] +testpaths = + tests + integration +""", ) self.fs.create_dir("tests") self.fs.create_dir("integration") @@ -142,7 +150,10 @@ def test_load_from_pytest_ini_multiline(self): def test_load_from_pytest_ini_no_testpaths(self): self.fs.create_file( "pytest.ini", - contents="[pytest]\naddopts = --strict-markers\n", + contents="""\ +[pytest] +addopts = --strict-markers +""", ) result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) @@ -150,7 +161,10 @@ def test_load_from_pytest_ini_no_testpaths(self): def test_load_from_pytest_ini_nonexistent_path_not_returned(self): self.fs.create_file( "pytest.ini", - contents="[pytest]\ntestpaths = tests\n", + contents="""\ +[pytest] +testpaths = tests +""", ) # tests/ directory does NOT exist result = test_paths_loader.load(Path(".")) @@ -161,7 +175,10 @@ def test_load_from_pytest_ini_nonexistent_path_not_returned(self): def test_load_from_tox_ini(self): self.fs.create_file( "tox.ini", - contents="[pytest]\ntestpaths = tests\n", + contents="""\ +[pytest] +testpaths = tests +""", ) self.fs.create_dir("tests") result = test_paths_loader.load(Path(".")) @@ -170,7 +187,10 @@ def test_load_from_tox_ini(self): def test_load_from_tox_ini_multiple_paths(self): self.fs.create_file( "tox.ini", - contents="[pytest]\ntestpaths = tests integration\n", + contents="""\ +[pytest] +testpaths = tests integration +""", ) self.fs.create_dir("tests") self.fs.create_dir("integration") @@ -180,7 +200,12 @@ def test_load_from_tox_ini_multiple_paths(self): def test_load_from_tox_ini_multiline(self): self.fs.create_file( "tox.ini", - contents=("[pytest]\n" "testpaths =\n" " tests\n" " integration\n"), + contents="""\ +[pytest] +testpaths = + tests + integration +""", ) self.fs.create_dir("tests") self.fs.create_dir("integration") @@ -190,7 +215,10 @@ def test_load_from_tox_ini_multiline(self): def test_load_from_tox_ini_no_pytest_section(self): self.fs.create_file( "tox.ini", - contents="[tox]\nenvlist = py39\n", + contents="""\ +[tox] +envlist = py39 +""", ) result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) @@ -198,7 +226,10 @@ def test_load_from_tox_ini_no_pytest_section(self): def test_load_from_tox_ini_no_testpaths(self): self.fs.create_file( "tox.ini", - contents="[pytest]\naddopts = --strict-markers\n", + contents="""\ +[pytest] +addopts = --strict-markers +""", ) result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) @@ -206,7 +237,10 @@ def test_load_from_tox_ini_no_testpaths(self): def test_load_from_tox_ini_nonexistent_path_not_returned(self): self.fs.create_file( "tox.ini", - contents="[pytest]\ntestpaths = tests\n", + contents="""\ +[pytest] +testpaths = tests +""", ) # tests/ directory does NOT exist result = test_paths_loader.load(Path(".")) @@ -217,7 +251,10 @@ def test_load_from_tox_ini_nonexistent_path_not_returned(self): def test_load_from_setup_cfg(self): self.fs.create_file( "setup.cfg", - contents="[tool:pytest]\ntestpaths = tests\n", + contents="""\ +[tool:pytest] +testpaths = tests +""", ) self.fs.create_dir("tests") result = test_paths_loader.load(Path(".")) @@ -226,7 +263,10 @@ def test_load_from_setup_cfg(self): def test_load_from_setup_cfg_multiple_paths(self): self.fs.create_file( "setup.cfg", - contents="[tool:pytest]\ntestpaths = tests integration e2e\n", + contents="""\ +[tool:pytest] +testpaths = tests integration e2e +""", ) self.fs.create_dir("tests") self.fs.create_dir("integration") @@ -237,7 +277,10 @@ def test_load_from_setup_cfg_multiple_paths(self): def test_load_from_setup_cfg_no_pytest_section(self): self.fs.create_file( "setup.cfg", - contents="[metadata]\nname = my-package\n", + contents="""\ +[metadata] +name = my-package +""", ) result = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) @@ -245,7 +288,10 @@ def test_load_from_setup_cfg_no_pytest_section(self): def test_load_from_setup_cfg_nonexistent_path_not_returned(self): self.fs.create_file( "setup.cfg", - contents="[tool:pytest]\ntestpaths = tests\n", + contents="""\ +[tool:pytest] +testpaths = tests +""", ) # tests/ directory does NOT exist result = test_paths_loader.load(Path(".")) @@ -297,7 +343,10 @@ def test_pyproject_toml_takes_priority_over_pytest_ini(self): ) self.fs.create_file( "pytest.ini", - contents="[pytest]\ntestpaths = from_ini\n", + contents="""\ +[pytest] +testpaths = from_ini +""", ) self.fs.create_dir("from_toml") result = test_paths_loader.load(Path(".")) @@ -306,11 +355,17 @@ def test_pyproject_toml_takes_priority_over_pytest_ini(self): def test_pytest_ini_takes_priority_over_tox_ini(self): self.fs.create_file( "pytest.ini", - contents="[pytest]\ntestpaths = from_ini\n", + contents="""\ +[pytest] +testpaths = from_ini +""", ) self.fs.create_file( "tox.ini", - contents="[pytest]\ntestpaths = from_tox\n", + contents="""\ +[pytest] +testpaths = from_tox +""", ) self.fs.create_dir("from_ini") result = test_paths_loader.load(Path(".")) @@ -319,11 +374,17 @@ def test_pytest_ini_takes_priority_over_tox_ini(self): def test_tox_ini_takes_priority_over_setup_cfg(self): self.fs.create_file( "tox.ini", - contents="[pytest]\ntestpaths = from_tox\n", + contents="""\ +[pytest] +testpaths = from_tox +""", ) self.fs.create_file( "setup.cfg", - contents="[tool:pytest]\ntestpaths = from_setup_cfg\n", + contents="""\ +[tool:pytest] +testpaths = from_setup_cfg +""", ) self.fs.create_dir("from_tox") result = test_paths_loader.load(Path(".")) @@ -332,11 +393,17 @@ def test_tox_ini_takes_priority_over_setup_cfg(self): def test_pytest_ini_takes_priority_over_setup_cfg(self): self.fs.create_file( "pytest.ini", - contents="[pytest]\ntestpaths = from_ini\n", + contents="""\ +[pytest] +testpaths = from_ini +""", ) self.fs.create_file( "setup.cfg", - contents="[tool:pytest]\ntestpaths = from_setup_cfg\n", + contents="""\ +[tool:pytest] +testpaths = from_setup_cfg +""", ) self.fs.create_dir("from_ini") result = test_paths_loader.load(Path(".")) @@ -345,7 +412,10 @@ def test_pytest_ini_takes_priority_over_setup_cfg(self): def test_setup_cfg_takes_priority_over_filesystem(self): self.fs.create_file( "setup.cfg", - contents="[tool:pytest]\ntestpaths = from_setup_cfg\n", + contents="""\ +[tool:pytest] +testpaths = from_setup_cfg +""", ) self.fs.create_dir("from_setup_cfg") self.fs.create_dir("tests") @@ -369,11 +439,17 @@ def test_declared_nonexistent_testpaths_stops_chain(self): No fallthrough to the next config source or filesystem convention.""" self.fs.create_file( "pytest.ini", - contents="[pytest]\ntestpaths = nonexistent\n", + contents="""\ +[pytest] +testpaths = nonexistent +""", ) self.fs.create_file( "setup.cfg", - contents="[tool:pytest]\ntestpaths = from_setup_cfg\n", + contents="""\ +[tool:pytest] +testpaths = from_setup_cfg +""", ) self.fs.create_dir("from_setup_cfg") self.fs.create_dir("tests") # filesystem fallback would find this @@ -386,7 +462,10 @@ def test_load_from_custom_base_dir(self): self.fs.create_dir("custom/path") self.fs.create_file( "custom/path/pytest.ini", - contents="[pytest]\ntestpaths = custom_tests\n", + contents="""\ +[pytest] +testpaths = custom_tests +""", ) self.fs.create_dir("custom/path/custom_tests") result = test_paths_loader.load(Path("custom/path")) @@ -400,7 +479,13 @@ def test_load_from_custom_base_dir(self): @patch("pysonar_scanner.configuration.test_paths_loader.logging") def test_absolute_path_outside_project_root_emits_warning(self, mock_logging): self.fs.create_dir("/project") - self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /other/tests\n") + self.fs.create_file( + "/project/pytest.ini", + contents="""\ +[pytest] +testpaths = /other/tests +""", + ) self.fs.create_dir("/other/tests") test_paths_loader.load(Path("/project")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] @@ -412,7 +497,13 @@ def test_absolute_path_outside_project_root_emits_warning(self, mock_logging): def test_absolute_path_outside_project_root_stops_chain(self): """Absolute path outside the project root is discarded; chain stops — no filesystem fallback.""" self.fs.create_dir("/project") - self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /other/tests\n") + self.fs.create_file( + "/project/pytest.ini", + contents="""\ +[pytest] +testpaths = /other/tests +""", + ) self.fs.create_dir("/other/tests") self.fs.create_dir("/project/tests") # filesystem fallback would pick this up if chain continued result = test_paths_loader.load(Path("/project")) @@ -421,14 +512,26 @@ def test_absolute_path_outside_project_root_stops_chain(self): def test_absolute_path_inside_project_root_is_converted_to_relative(self): """Absolute path under the project root is relativised and used as sonar.tests.""" self.fs.create_dir("/project/tests") - self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /project/tests\n") + self.fs.create_file( + "/project/pytest.ini", + contents="""\ +[pytest] +testpaths = /project/tests +""", + ) result = test_paths_loader.load(Path("/project")) self.assertEqual(result[SONAR_TESTS], "tests") def test_absolute_path_inside_project_root_nested(self): """A deeper absolute path is relativised correctly.""" self.fs.create_dir("/project/src/tests") - self.fs.create_file("/project/pytest.ini", contents="[pytest]\ntestpaths = /project/src/tests\n") + self.fs.create_file( + "/project/pytest.ini", + contents="""\ +[pytest] +testpaths = /project/src/tests +""", + ) result = test_paths_loader.load(Path("/project")) self.assertEqual(result[SONAR_TESTS], "src/tests") @@ -437,7 +540,10 @@ def test_absolute_path_mixed_with_valid_relative_path(self): self.fs.create_dir("/project/tests") self.fs.create_file( "/project/pytest.ini", - contents="[pytest]\ntestpaths = /other/tests tests\n", + contents="""\ +[pytest] +testpaths = /other/tests tests +""", ) result = test_paths_loader.load(Path("/project")) self.assertEqual(result[SONAR_TESTS], "tests") @@ -469,14 +575,26 @@ def test_absolute_path_inside_root_in_pyproject_toml_is_converted(self): def test_absolute_path_outside_root_in_tox_ini_stops_chain(self): self.fs.create_dir("/project") - self.fs.create_file("/project/tox.ini", contents="[pytest]\ntestpaths = /other/tests\n") + self.fs.create_file( + "/project/tox.ini", + contents="""\ +[pytest] +testpaths = /other/tests +""", + ) self.fs.create_dir("/project/tests") result = test_paths_loader.load(Path("/project")) self.assertNotIn(SONAR_TESTS, result) def test_absolute_path_outside_root_in_setup_cfg_stops_chain(self): self.fs.create_dir("/project") - self.fs.create_file("/project/setup.cfg", contents="[tool:pytest]\ntestpaths = /other/tests\n") + self.fs.create_file( + "/project/setup.cfg", + contents="""\ +[tool:pytest] +testpaths = /other/tests +""", + ) self.fs.create_dir("/project/tests") result = test_paths_loader.load(Path("/project")) self.assertNotIn(SONAR_TESTS, result) @@ -489,7 +607,13 @@ def test_absolute_path_outside_root_in_setup_cfg_stops_chain(self): def test_windows_drive_path_inside_root_is_relativized(self): """C:\\project\\tests under C:\\project is relativised to 'tests'.""" self.fs.create_dir("C:/project/tests") - self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\project\\tests\n") + self.fs.create_file( + "C:/project/pytest.ini", + contents="""\ +[pytest] +testpaths = C:\\project\\tests +""", + ) result = test_paths_loader.load(Path("C:/project")) self.assertEqual(result[SONAR_TESTS], "tests") @@ -497,7 +621,13 @@ def test_windows_drive_path_inside_root_is_relativized(self): def test_windows_drive_path_nested_inside_root_is_relativized(self): """C:\\project\\src\\tests under C:\\project is relativised to 'src/tests'.""" self.fs.create_dir("C:/project/src/tests") - self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\project\\src\\tests\n") + self.fs.create_file( + "C:/project/pytest.ini", + contents="""\ +[pytest] +testpaths = C:\\project\\src\\tests +""", + ) result = test_paths_loader.load(Path("C:/project")) self.assertEqual(result[SONAR_TESTS], "src/tests") @@ -506,7 +636,13 @@ def test_windows_drive_path_nested_inside_root_is_relativized(self): def test_windows_drive_path_outside_root_emits_warning(self, mock_logging): """C:\\other\\tests outside C:\\project emits a warning.""" self.fs.create_dir("C:/project") - self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\other\\tests\n") + self.fs.create_file( + "C:/project/pytest.ini", + contents="""\ +[pytest] +testpaths = C:\\other\\tests +""", + ) self.fs.create_dir("C:/other/tests") test_paths_loader.load(Path("C:/project")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] @@ -519,7 +655,13 @@ def test_windows_drive_path_outside_root_emits_warning(self, mock_logging): def test_windows_drive_path_outside_root_stops_chain(self): """C:\\other\\tests outside C:\\project is discarded; chain stops — no filesystem fallback.""" self.fs.create_dir("C:/project") - self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = C:\\other\\tests\n") + self.fs.create_file( + "C:/project/pytest.ini", + contents="""\ +[pytest] +testpaths = C:\\other\\tests +""", + ) self.fs.create_dir("C:/other/tests") self.fs.create_dir("C:/project/tests") # filesystem fallback would pick this up if chain continued result = test_paths_loader.load(Path("C:/project")) @@ -530,7 +672,13 @@ def test_windows_drive_path_outside_root_stops_chain(self): def test_windows_different_drive_emits_warning(self, mock_logging): """D:\\tests on a different drive than C:\\project emits a warning.""" self.fs.create_dir("C:/project") - self.fs.create_file("C:/project/pytest.ini", contents="[pytest]\ntestpaths = D:\\tests\n") + self.fs.create_file( + "C:/project/pytest.ini", + contents="""\ +[pytest] +testpaths = D:\\tests +""", + ) self.fs.create_dir("D:/tests") test_paths_loader.load(Path("C:/project")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] @@ -545,7 +693,10 @@ def test_windows_different_drive_emits_warning(self, mock_logging): def test_file_path_in_testpaths_emits_debug_log(self, mock_logging): self.fs.create_file( "pytest.ini", - contents="[pytest]\ntestpaths = tests/test_api.py\n", + contents="""\ +[pytest] +testpaths = tests/test_api.py +""", ) self.fs.create_file("tests/test_api.py", contents="") test_paths_loader.load(Path(".")) @@ -561,7 +712,10 @@ def test_file_path_in_testpaths_stops_chain(self): if the chain continued — but it must not.""" self.fs.create_file( "pytest.ini", - contents="[pytest]\ntestpaths = tests/test_api.py\n", + contents="""\ +[pytest] +testpaths = tests/test_api.py +""", ) self.fs.create_file("tests/test_api.py", contents="") # tests/ exists on disk (implicit from create_file), but testpaths names the file, @@ -573,7 +727,10 @@ def test_file_path_mixed_with_valid_directory_path(self): """Valid directory path is kept even when another entry points to a file.""" self.fs.create_file( "pytest.ini", - contents="[pytest]\ntestpaths = tests/test_api.py unit\n", + contents="""\ +[pytest] +testpaths = tests/test_api.py unit +""", ) self.fs.create_file("tests/test_api.py", contents="") self.fs.create_dir("unit") @@ -586,7 +743,10 @@ def test_file_path_mixed_with_valid_directory_path(self): def test_nonexistent_testpaths_in_pytest_ini_emits_warning(self, mock_logging): self.fs.create_file( "pytest.ini", - contents="[pytest]\ntestpaths = nonexistent\n", + contents="""\ +[pytest] +testpaths = nonexistent +""", ) test_paths_loader.load(Path(".")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] @@ -615,7 +775,10 @@ def test_nonexistent_testpaths_in_pyproject_toml_emits_warning(self, mock_loggin def test_nonexistent_testpaths_in_tox_ini_emits_warning(self, mock_logging): self.fs.create_file( "tox.ini", - contents="[pytest]\ntestpaths = missing_dir\n", + contents="""\ +[pytest] +testpaths = missing_dir +""", ) test_paths_loader.load(Path(".")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] @@ -628,7 +791,10 @@ def test_nonexistent_testpaths_in_tox_ini_emits_warning(self, mock_logging): def test_nonexistent_testpaths_in_setup_cfg_emits_warning(self, mock_logging): self.fs.create_file( "setup.cfg", - contents="[tool:pytest]\ntestpaths = missing_dir\n", + contents="""\ +[tool:pytest] +testpaths = missing_dir +""", ) test_paths_loader.load(Path(".")) warning_calls = [str(c) for c in mock_logging.warning.call_args_list] @@ -643,7 +809,10 @@ def test_filesystem_fallback_skipped_when_config_has_no_testpaths_key_but_pyproj """pyproject.toml present with no pytest section → fall through to filesystem.""" self.fs.create_file( "pyproject.toml", - contents="[project]\nname = my-project\n", + contents="""\ +[project] +name = my-project +""", ) self.fs.create_dir("tests") result = test_paths_loader.load(Path(".")) From b1b6bf8a79afb116a6ae58d093f9897d95fc85bc Mon Sep 17 00:00:00 2001 From: Guillaume Dequenne Date: Wed, 13 May 2026 11:40:35 +0200 Subject: [PATCH 8/8] Prevent heuristics from running when attempt to set test is made --- .../configuration/configuration_loader.py | 8 +- .../configuration/properties.py | 1 + .../configuration/test_paths_loader.py | 24 +- tests/unit/test_configuration_loader.py | 28 +++ tests/unit/test_test_paths_loader.py | 227 ++++++++++++------ 5 files changed, 205 insertions(+), 83 deletions(-) diff --git a/src/pysonar_scanner/configuration/configuration_loader.py b/src/pysonar_scanner/configuration/configuration_loader.py index f919177d..47bd9781 100644 --- a/src/pysonar_scanner/configuration/configuration_loader.py +++ b/src/pysonar_scanner/configuration/configuration_loader.py @@ -29,6 +29,7 @@ SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, SONAR_TESTS, + SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED, Key, ) from pysonar_scanner.configuration.properties import PROPERTIES @@ -76,9 +77,12 @@ def load() -> dict[Key, Any]: # 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" + heuristic_disabled = resolved_properties.get(SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED, "").lower() == "true" if SONAR_TESTS not in resolved_properties and not heuristic_disabled: - resolved_properties.update(test_paths_loader.load(base_dir)) + 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 diff --git a/src/pysonar_scanner/configuration/properties.py b/src/pysonar_scanner/configuration/properties.py index 65b132ad..f6dce7c1 100644 --- a/src/pysonar_scanner/configuration/properties.py +++ b/src/pysonar_scanner/configuration/properties.py @@ -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" 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" diff --git a/src/pysonar_scanner/configuration/test_paths_loader.py b/src/pysonar_scanner/configuration/test_paths_loader.py index 8b80c1c1..80de56a7 100644 --- a/src/pysonar_scanner/configuration/test_paths_loader.py +++ b/src/pysonar_scanner/configuration/test_paths_loader.py @@ -30,25 +30,27 @@ _SETUP_CFG_PYTEST_SECTION = "tool:pytest" -def load(base_dir: pathlib.Path) -> dict[str, str]: +def load(base_dir: pathlib.Path) -> tuple[dict[str, str], bool]: """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. + 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 or no testpaths key — try next source + continue # file absent, no testpaths key, or empty testpaths (no restriction) — try next if result: - return {SONAR_TESTS: result} - return {} # testpaths declared but all paths were invalid — stop chain, set nothing + return {SONAR_TESTS: result}, False + return {}, True # declared but all paths invalid: user expressed intent, disable heuristic - # No config file declared a testpaths key; fall back to filesystem conventions. + # 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} - return {} + return {SONAR_TESTS: filesystem_result}, False + return {}, False def _existing_paths(base_dir: pathlib.Path, paths: list[str]) -> list[str]: @@ -102,7 +104,7 @@ def _load_from_pyproject_toml(base_dir: pathlib.Path) -> Optional[str]: 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 + return None # testpaths = [] means "no path restriction" — same as key absent, continue chain paths = _existing_paths(base_dir, raw) if paths: result = ",".join(paths) @@ -129,7 +131,7 @@ def _load_from_ini_file(base_dir: pathlib.Path, filename: str, section: str) -> return None raw = [p for p in config[section]["testpaths"].split() if p] if not raw: - return "" # testpaths key present but empty — stop chain + 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) diff --git a/tests/unit/test_configuration_loader.py b/tests/unit/test_configuration_loader.py index e37f2d6f..5d36a47b 100644 --- a/tests/unit/test_configuration_loader.py +++ b/tests/unit/test_configuration_loader.py @@ -53,6 +53,7 @@ SONAR_SCANNER_ARCH, SONAR_SCANNER_OS, SONAR_COVERAGE_EXCLUSIONS, + SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED, ) from pysonar_scanner.utils import Arch, Os from pysonar_scanner.configuration.configuration_loader import ConfigurationLoader, SONAR_PROJECT_BASE_DIR @@ -499,6 +500,33 @@ def test_test_paths_loader_not_called_when_heuristic_disabled(self, mock_loader, ConfigurationLoader.load() mock_loader.load.assert_not_called() + @patch("sys.argv", ["myscript.py"]) + def test_declared_invalid_testpaths_disables_heuristic_in_resolved_properties(self, mock_get_os, mock_get_arch): + """When testpaths is declared but all paths are invalid, the loader signals intent and + configuration_loader sets sonar.python.testFileHeuristic.disabled=true.""" + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = nonexistent\n", + ) + configuration = ConfigurationLoader.load() + self.assertEqual(configuration.get(SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED), "true") + self.assertNotIn(SONAR_TESTS, configuration) + + @patch("sys.argv", ["myscript.py"]) + def test_user_heuristic_disable_not_overridden_by_loader(self, mock_get_os, mock_get_arch): + """An explicit sonar.python.testFileHeuristic.disabled set by the user is not overridden + by the loader's suggestion, even when testpaths resolves to nothing.""" + self.fs.create_file( + "sonar-project.properties", + contents="sonar.python.testFileHeuristic.disabled=false\n", + ) + self.fs.create_file( + "pytest.ini", + contents="[pytest]\ntestpaths = nonexistent\n", + ) + configuration = ConfigurationLoader.load() + self.assertEqual(configuration.get(SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED), "false") + @patch("sys.argv", ["myscript.py"]) @patch.dict("os.environ", {"SONAR_TOKEN": "TokenFromEnv", "SONAR_PROJECT_KEY": "KeyFromEnv"}, clear=True) def test_load_from_env_variables_only(self, mock_get_os, mock_get_arch): diff --git a/tests/unit/test_test_paths_loader.py b/tests/unit/test_test_paths_loader.py index 7e463e3d..675beead 100644 --- a/tests/unit/test_test_paths_loader.py +++ b/tests/unit/test_test_paths_loader.py @@ -44,8 +44,9 @@ def test_load_from_pyproject_toml_list(self): ) self.fs.create_dir("tests") self.fs.create_dir("integration") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration") + self.assertFalse(disable) def test_load_from_pyproject_toml_single_entry(self): self.fs.create_file( @@ -56,8 +57,9 @@ def test_load_from_pyproject_toml_single_entry(self): """, ) self.fs.create_dir("tests") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) def test_load_from_pyproject_toml_no_pytest_section(self): self.fs.create_file( @@ -67,10 +69,13 @@ def test_load_from_pyproject_toml_no_pytest_section(self): projectKey = "my-project" """, ) - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertFalse(disable) - def test_load_from_pyproject_toml_empty_testpaths(self): + def test_load_from_pyproject_toml_empty_testpaths_falls_through_to_filesystem(self): + # testpaths = [] means "no path restriction" — same as key absent. + # Our chain continues and the filesystem fallback still runs. self.fs.create_file( "pyproject.toml", contents=""" @@ -78,9 +83,10 @@ def test_load_from_pyproject_toml_empty_testpaths(self): testpaths = [] """, ) - self.fs.create_dir("tests") # filesystem fallback would pick this up if chain continued - result = test_paths_loader.load(Path(".")) - self.assertNotIn(SONAR_TESTS, result) + self.fs.create_dir("tests") + result, disable = test_paths_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) def test_load_from_pyproject_toml_nonexistent_path_not_returned(self): self.fs.create_file( @@ -90,9 +96,10 @@ def test_load_from_pyproject_toml_nonexistent_path_not_returned(self): testpaths = ["tests"] """, ) - # tests/ directory does NOT exist - result = test_paths_loader.load(Path(".")) + # tests/ directory does NOT exist — user expressed intent but paths are invalid + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertTrue(disable) def test_load_from_pyproject_toml_filters_nonexistent_paths(self): self.fs.create_file( @@ -104,8 +111,9 @@ def test_load_from_pyproject_toml_filters_nonexistent_paths(self): ) self.fs.create_dir("tests") # nonexistent/ is not on disk — only tests/ should be returned - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) @patch("pysonar_scanner.configuration.test_paths_loader.logging") def test_load_from_pyproject_toml_malformed(self, mock_logging): @@ -113,8 +121,9 @@ def test_load_from_pyproject_toml_malformed(self, mock_logging): "pyproject.toml", contents="this is not valid toml ][", ) - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertFalse(disable) mock_logging.debug.assert_called() # --- pytest.ini --- @@ -129,8 +138,9 @@ def test_load_from_pytest_ini(self): ) self.fs.create_dir("tests") self.fs.create_dir("integration") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration") + self.assertFalse(disable) def test_load_from_pytest_ini_multiline(self): self.fs.create_file( @@ -144,8 +154,9 @@ def test_load_from_pytest_ini_multiline(self): ) self.fs.create_dir("tests") self.fs.create_dir("integration") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration") + self.assertFalse(disable) def test_load_from_pytest_ini_no_testpaths(self): self.fs.create_file( @@ -155,8 +166,23 @@ def test_load_from_pytest_ini_no_testpaths(self): addopts = --strict-markers """, ) - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertFalse(disable) + + def test_load_from_pytest_ini_empty_testpaths_falls_through_to_filesystem(self): + # Empty testpaths means "no path restriction" — chain continues to filesystem. + self.fs.create_file( + "pytest.ini", + contents="""\ +[pytest] +testpaths = +""", + ) + self.fs.create_dir("tests") + result, disable = test_paths_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) def test_load_from_pytest_ini_nonexistent_path_not_returned(self): self.fs.create_file( @@ -166,9 +192,10 @@ def test_load_from_pytest_ini_nonexistent_path_not_returned(self): testpaths = tests """, ) - # tests/ directory does NOT exist - result = test_paths_loader.load(Path(".")) + # tests/ directory does NOT exist — user expressed intent but paths are invalid + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertTrue(disable) # --- tox.ini --- @@ -181,8 +208,9 @@ def test_load_from_tox_ini(self): """, ) self.fs.create_dir("tests") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) def test_load_from_tox_ini_multiple_paths(self): self.fs.create_file( @@ -194,8 +222,9 @@ def test_load_from_tox_ini_multiple_paths(self): ) self.fs.create_dir("tests") self.fs.create_dir("integration") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration") + self.assertFalse(disable) def test_load_from_tox_ini_multiline(self): self.fs.create_file( @@ -209,8 +238,9 @@ def test_load_from_tox_ini_multiline(self): ) self.fs.create_dir("tests") self.fs.create_dir("integration") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration") + self.assertFalse(disable) def test_load_from_tox_ini_no_pytest_section(self): self.fs.create_file( @@ -220,8 +250,9 @@ def test_load_from_tox_ini_no_pytest_section(self): envlist = py39 """, ) - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertFalse(disable) def test_load_from_tox_ini_no_testpaths(self): self.fs.create_file( @@ -231,8 +262,9 @@ def test_load_from_tox_ini_no_testpaths(self): addopts = --strict-markers """, ) - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertFalse(disable) def test_load_from_tox_ini_nonexistent_path_not_returned(self): self.fs.create_file( @@ -242,9 +274,10 @@ def test_load_from_tox_ini_nonexistent_path_not_returned(self): testpaths = tests """, ) - # tests/ directory does NOT exist - result = test_paths_loader.load(Path(".")) + # tests/ directory does NOT exist — user expressed intent but paths are invalid + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertTrue(disable) # --- setup.cfg --- @@ -257,8 +290,9 @@ def test_load_from_setup_cfg(self): """, ) self.fs.create_dir("tests") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) def test_load_from_setup_cfg_multiple_paths(self): self.fs.create_file( @@ -271,8 +305,9 @@ def test_load_from_setup_cfg_multiple_paths(self): self.fs.create_dir("tests") self.fs.create_dir("integration") self.fs.create_dir("e2e") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,integration,e2e") + self.assertFalse(disable) def test_load_from_setup_cfg_no_pytest_section(self): self.fs.create_file( @@ -282,8 +317,9 @@ def test_load_from_setup_cfg_no_pytest_section(self): name = my-package """, ) - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertFalse(disable) def test_load_from_setup_cfg_nonexistent_path_not_returned(self): self.fs.create_file( @@ -293,43 +329,50 @@ def test_load_from_setup_cfg_nonexistent_path_not_returned(self): testpaths = tests """, ) - # tests/ directory does NOT exist - result = test_paths_loader.load(Path(".")) + # tests/ directory does NOT exist — user expressed intent but paths are invalid + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertTrue(disable) # --- filesystem fallback --- def test_load_from_filesystem_tests_dir(self): self.fs.create_dir("tests") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) def test_load_from_filesystem_test_dir(self): self.fs.create_dir("test") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "test") + self.assertFalse(disable) def test_load_from_filesystem_testing_dir(self): self.fs.create_dir("testing") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "testing") + self.assertFalse(disable) def test_load_from_filesystem_multiple_conventional_dirs(self): self.fs.create_dir("tests") self.fs.create_dir("test") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests,test") + self.assertFalse(disable) def test_load_from_filesystem_no_conventional_dir(self): self.fs.create_dir("src") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertFalse(disable) # --- nothing found --- def test_load_returns_empty_when_nothing_configured(self): - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result, {}) + self.assertFalse(disable) # --- priority order --- @@ -349,8 +392,9 @@ def test_pyproject_toml_takes_priority_over_pytest_ini(self): """, ) self.fs.create_dir("from_toml") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "from_toml") + self.assertFalse(disable) def test_pytest_ini_takes_priority_over_tox_ini(self): self.fs.create_file( @@ -368,8 +412,9 @@ def test_pytest_ini_takes_priority_over_tox_ini(self): """, ) self.fs.create_dir("from_ini") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "from_ini") + self.assertFalse(disable) def test_tox_ini_takes_priority_over_setup_cfg(self): self.fs.create_file( @@ -387,8 +432,9 @@ def test_tox_ini_takes_priority_over_setup_cfg(self): """, ) self.fs.create_dir("from_tox") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "from_tox") + self.assertFalse(disable) def test_pytest_ini_takes_priority_over_setup_cfg(self): self.fs.create_file( @@ -406,8 +452,9 @@ def test_pytest_ini_takes_priority_over_setup_cfg(self): """, ) self.fs.create_dir("from_ini") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "from_ini") + self.assertFalse(disable) def test_setup_cfg_takes_priority_over_filesystem(self): self.fs.create_file( @@ -419,8 +466,9 @@ def test_setup_cfg_takes_priority_over_filesystem(self): ) self.fs.create_dir("from_setup_cfg") self.fs.create_dir("tests") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "from_setup_cfg") + self.assertFalse(disable) def test_config_without_testpaths_falls_through_to_filesystem(self): self.fs.create_file( @@ -431,12 +479,14 @@ def test_config_without_testpaths_falls_through_to_filesystem(self): """, ) self.fs.create_dir("tests") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) - def test_declared_nonexistent_testpaths_stops_chain(self): - """When testpaths is explicitly declared but all paths are missing, the chain stops. - No fallthrough to the next config source or filesystem convention.""" + def test_declared_nonexistent_testpaths_stops_chain_and_disables_heuristic(self): + """When testpaths is explicitly declared but all paths are missing, the chain stops + and disable_heuristic is True — the user expressed intent, so sonar-python should + not run its own heuristic on top.""" self.fs.create_file( "pytest.ini", contents="""\ @@ -453,8 +503,31 @@ def test_declared_nonexistent_testpaths_stops_chain(self): ) self.fs.create_dir("from_setup_cfg") self.fs.create_dir("tests") # filesystem fallback would find this - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertTrue(disable) + + def test_empty_testpaths_falls_through_to_filesystem(self): + """testpaths = [] / empty testpaths means 'no path restriction' — chain continues, + filesystem fallback still runs, disable_heuristic is False.""" + self.fs.create_file( + "pytest.ini", + contents="""\ +[pytest] +testpaths = +""", + ) + self.fs.create_file( + "setup.cfg", + contents="""\ +[tool:pytest] +testpaths = from_setup_cfg +""", + ) + self.fs.create_dir("from_setup_cfg") + result, disable = test_paths_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "from_setup_cfg") + self.assertFalse(disable) # --- custom base_dir --- @@ -468,8 +541,9 @@ def test_load_from_custom_base_dir(self): """, ) self.fs.create_dir("custom/path/custom_tests") - result = test_paths_loader.load(Path("custom/path")) + result, disable = test_paths_loader.load(Path("custom/path")) self.assertEqual(result[SONAR_TESTS], "custom_tests") + self.assertFalse(disable) # --- absolute paths --- # Tests use /project as the explicit base_dir so that outside-root paths (/other/...) @@ -494,8 +568,8 @@ def test_absolute_path_outside_project_root_emits_warning(self, mock_logging): f"Expected a warning mentioning the outside-root path, got: {warning_calls}", ) - def test_absolute_path_outside_project_root_stops_chain(self): - """Absolute path outside the project root is discarded; chain stops — no filesystem fallback.""" + def test_absolute_path_outside_project_root_stops_chain_and_disables_heuristic(self): + """Absolute path outside the project root is discarded; chain stops with disable_heuristic=True.""" self.fs.create_dir("/project") self.fs.create_file( "/project/pytest.ini", @@ -506,8 +580,9 @@ def test_absolute_path_outside_project_root_stops_chain(self): ) self.fs.create_dir("/other/tests") self.fs.create_dir("/project/tests") # filesystem fallback would pick this up if chain continued - result = test_paths_loader.load(Path("/project")) + result, disable = test_paths_loader.load(Path("/project")) self.assertNotIn(SONAR_TESTS, result) + self.assertTrue(disable) def test_absolute_path_inside_project_root_is_converted_to_relative(self): """Absolute path under the project root is relativised and used as sonar.tests.""" @@ -519,8 +594,9 @@ def test_absolute_path_inside_project_root_is_converted_to_relative(self): testpaths = /project/tests """, ) - result = test_paths_loader.load(Path("/project")) + result, disable = test_paths_loader.load(Path("/project")) self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) def test_absolute_path_inside_project_root_nested(self): """A deeper absolute path is relativised correctly.""" @@ -532,8 +608,9 @@ def test_absolute_path_inside_project_root_nested(self): testpaths = /project/src/tests """, ) - result = test_paths_loader.load(Path("/project")) + result, disable = test_paths_loader.load(Path("/project")) self.assertEqual(result[SONAR_TESTS], "src/tests") + self.assertFalse(disable) def test_absolute_path_mixed_with_valid_relative_path(self): """Valid relative path is kept even when another entry is outside the project root.""" @@ -545,10 +622,11 @@ def test_absolute_path_mixed_with_valid_relative_path(self): testpaths = /other/tests tests """, ) - result = test_paths_loader.load(Path("/project")) + result, disable = test_paths_loader.load(Path("/project")) self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) - def test_absolute_path_outside_root_in_pyproject_toml_stops_chain(self): + def test_absolute_path_outside_root_in_pyproject_toml_stops_chain_and_disables_heuristic(self): self.fs.create_dir("/project") self.fs.create_file( "/project/pyproject.toml", @@ -558,8 +636,9 @@ def test_absolute_path_outside_root_in_pyproject_toml_stops_chain(self): """, ) self.fs.create_dir("/project/tests") # filesystem fallback - result = test_paths_loader.load(Path("/project")) + result, disable = test_paths_loader.load(Path("/project")) self.assertNotIn(SONAR_TESTS, result) + self.assertTrue(disable) def test_absolute_path_inside_root_in_pyproject_toml_is_converted(self): self.fs.create_dir("/project/tests") @@ -570,10 +649,11 @@ def test_absolute_path_inside_root_in_pyproject_toml_is_converted(self): testpaths = ["/project/tests"] """, ) - result = test_paths_loader.load(Path("/project")) + result, disable = test_paths_loader.load(Path("/project")) self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) - def test_absolute_path_outside_root_in_tox_ini_stops_chain(self): + def test_absolute_path_outside_root_in_tox_ini_stops_chain_and_disables_heuristic(self): self.fs.create_dir("/project") self.fs.create_file( "/project/tox.ini", @@ -583,10 +663,11 @@ def test_absolute_path_outside_root_in_tox_ini_stops_chain(self): """, ) self.fs.create_dir("/project/tests") - result = test_paths_loader.load(Path("/project")) + result, disable = test_paths_loader.load(Path("/project")) self.assertNotIn(SONAR_TESTS, result) + self.assertTrue(disable) - def test_absolute_path_outside_root_in_setup_cfg_stops_chain(self): + def test_absolute_path_outside_root_in_setup_cfg_stops_chain_and_disables_heuristic(self): self.fs.create_dir("/project") self.fs.create_file( "/project/setup.cfg", @@ -596,8 +677,9 @@ def test_absolute_path_outside_root_in_setup_cfg_stops_chain(self): """, ) self.fs.create_dir("/project/tests") - result = test_paths_loader.load(Path("/project")) + result, disable = test_paths_loader.load(Path("/project")) self.assertNotIn(SONAR_TESTS, result) + self.assertTrue(disable) # --- Windows drive-letter paths (Windows only) --- # pathlib path semantics are platform-native: Path('C:/project').is_absolute() is False on POSIX, @@ -614,8 +696,9 @@ def test_windows_drive_path_inside_root_is_relativized(self): testpaths = C:\\project\\tests """, ) - result = test_paths_loader.load(Path("C:/project")) + result, disable = test_paths_loader.load(Path("C:/project")) self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") def test_windows_drive_path_nested_inside_root_is_relativized(self): @@ -628,8 +711,9 @@ def test_windows_drive_path_nested_inside_root_is_relativized(self): testpaths = C:\\project\\src\\tests """, ) - result = test_paths_loader.load(Path("C:/project")) + result, disable = test_paths_loader.load(Path("C:/project")) self.assertEqual(result[SONAR_TESTS], "src/tests") + self.assertFalse(disable) @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") @patch("pysonar_scanner.configuration.test_paths_loader.logging") @@ -652,8 +736,8 @@ def test_windows_drive_path_outside_root_emits_warning(self, mock_logging): ) @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") - def test_windows_drive_path_outside_root_stops_chain(self): - """C:\\other\\tests outside C:\\project is discarded; chain stops — no filesystem fallback.""" + def test_windows_drive_path_outside_root_stops_chain_and_disables_heuristic(self): + """C:\\other\\tests outside C:\\project: chain stops with disable_heuristic=True.""" self.fs.create_dir("C:/project") self.fs.create_file( "C:/project/pytest.ini", @@ -664,8 +748,9 @@ def test_windows_drive_path_outside_root_stops_chain(self): ) self.fs.create_dir("C:/other/tests") self.fs.create_dir("C:/project/tests") # filesystem fallback would pick this up if chain continued - result = test_paths_loader.load(Path("C:/project")) + result, disable = test_paths_loader.load(Path("C:/project")) self.assertNotIn(SONAR_TESTS, result) + self.assertTrue(disable) @unittest.skipUnless(sys.platform.startswith("win"), "Windows drive-letter path semantics") @patch("pysonar_scanner.configuration.test_paths_loader.logging") @@ -706,10 +791,9 @@ def test_file_path_in_testpaths_emits_debug_log(self, mock_logging): f"Expected a debug message mentioning the file path, got: {debug_calls}", ) - def test_file_path_in_testpaths_stops_chain(self): - """A file path is dropped; if that leaves no valid directory paths the chain stops. - tests/ is created implicitly by create_file and would be found by filesystem fallback - if the chain continued — but it must not.""" + def test_file_path_in_testpaths_stops_chain_and_disables_heuristic(self): + """A file path is dropped; if that leaves no valid directory paths the chain stops + with disable_heuristic=True — user expressed intent about test paths.""" self.fs.create_file( "pytest.ini", contents="""\ @@ -720,8 +804,9 @@ def test_file_path_in_testpaths_stops_chain(self): self.fs.create_file("tests/test_api.py", contents="") # tests/ exists on disk (implicit from create_file), but testpaths names the file, # not the directory — chain stops at pytest.ini, filesystem fallback never runs. - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertNotIn(SONAR_TESTS, result) + self.assertTrue(disable) def test_file_path_mixed_with_valid_directory_path(self): """Valid directory path is kept even when another entry points to a file.""" @@ -734,8 +819,9 @@ def test_file_path_mixed_with_valid_directory_path(self): ) self.fs.create_file("tests/test_api.py", contents="") self.fs.create_dir("unit") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "unit") + self.assertFalse(disable) # --- declared-but-missing path warnings --- @@ -815,5 +901,6 @@ def test_filesystem_fallback_skipped_when_config_has_no_testpaths_key_but_pyproj """, ) self.fs.create_dir("tests") - result = test_paths_loader.load(Path(".")) + result, disable = test_paths_loader.load(Path(".")) self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable)