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..47bd9781 100644 --- a/src/pysonar_scanner/configuration/configuration_loader.py +++ b/src/pysonar_scanner/configuration/configuration_loader.py @@ -24,9 +24,21 @@ from pysonar_scanner.configuration.cli import CliConfigurationLoader from pysonar_scanner.configuration.coveragerc_loader import CoverageRCConfigurationLoader from pysonar_scanner.configuration.pyproject_toml import TomlConfigurationLoader -from pysonar_scanner.configuration.properties import SONAR_PROJECT_KEY, SONAR_TOKEN, SONAR_PROJECT_BASE_DIR, Key +from pysonar_scanner.configuration.properties import ( + SONAR_PROJECT_KEY, + SONAR_TOKEN, + SONAR_PROJECT_BASE_DIR, + SONAR_TESTS, + SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED, + Key, +) from pysonar_scanner.configuration.properties import PROPERTIES -from pysonar_scanner.configuration import sonar_project_properties, environment_variables, dynamic_defaults_loader +from pysonar_scanner.configuration import ( + sonar_project_properties, + environment_variables, + dynamic_defaults_loader, + test_paths_loader, +) from pysonar_scanner.exceptions import MissingProperty, MissingPropertyException @@ -61,6 +73,17 @@ def load() -> dict[Key, Any]: resolved_properties.update(toml_properties.sonar_properties) resolved_properties.update(environment_variables.load()) resolved_properties.update(cli_properties) + + # Auto-detect sonar.tests only when the user has not set it in any higher-priority source + # and has not explicitly disabled the sonar-python test file heuristic. When the heuristic + # is disabled the intent is to analyse all files as main code with no test classification. + heuristic_disabled = resolved_properties.get(SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED, "").lower() == "true" + if SONAR_TESTS not in resolved_properties and not heuristic_disabled: + inferred_props, disable_heuristic = test_paths_loader.load(base_dir) + resolved_properties.update(inferred_props) + if disable_heuristic and SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED not in resolved_properties: + resolved_properties[SONAR_PYTHON_TEST_FILE_HEURISTIC_DISABLED] = "true" + return resolved_properties @staticmethod 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 new file mode 100644 index 00000000..9ddd549d --- /dev/null +++ b/src/pysonar_scanner/configuration/test_paths_loader.py @@ -0,0 +1,171 @@ +# +# Sonar Scanner Python +# Copyright (C) 2011-2026 SonarSource Sàrl +# mailto:info AT sonarsource DOT com +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, +# +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +import configparser +import logging +import pathlib +from typing import Optional + +import tomli + +from pysonar_scanner.configuration.properties import SONAR_TESTS + +_CONVENTIONAL_TEST_DIRS = ["tests", "test", "testing"] +_SETUP_CFG_PYTEST_SECTION = "tool:pytest" + + +def load(base_dir: pathlib.Path) -> tuple[dict[str, str], bool]: + """Infer sonar.tests from Python tooling configuration and filesystem conventions. + + Returns (properties, disable_heuristic) where: + - properties contains sonar.tests if a test directory was reliably inferred + - disable_heuristic is True when a config file declared testpaths but all paths were + invalid — the user expressed intent, so the sonar-python heuristic should not fire + """ + for loader in [_load_from_pyproject_toml, _load_from_pytest_ini, _load_from_tox_ini, _load_from_setup_cfg]: + result = loader(base_dir) + if result is None: + continue # file absent, no testpaths key, or empty testpaths (no restriction) — try next + if result: + return {SONAR_TESTS: result}, False + return {}, True # declared but all paths invalid: user expressed intent, disable heuristic + + # No config file gave a non-empty testpaths declaration; fall back to filesystem conventions. + filesystem_result = _load_from_filesystem(base_dir) + if filesystem_result: + return {SONAR_TESTS: filesystem_result}, False + return {}, False + + +def _existing_paths(base_dir: pathlib.Path, paths: list[str]) -> list[str]: + """Filter a list of candidate paths to those that exist as directories under base_dir. + + Absolute paths are relativised against the project root. If an absolute path falls + outside the project root it is skipped with a warning. Relative paths that resolve + to a file (not a directory) are skipped with a debug message. + """ + abs_base = base_dir.resolve() + result = [] + for p in paths: + path = pathlib.Path(p) + if path.root: # rooted path: absolute on POSIX, or rooted (possibly drive-relative) on Windows + try: + # On Windows, abs_base.resolve() adds a drive letter (e.g. C:\project) while a path + # read from config may be drive-relative (/project/tests, no drive). Attach the base + # drive so relative_to() can compare them correctly. + candidate = pathlib.Path(abs_base.drive + str(path)) if abs_base.drive and not path.drive else path + p = candidate.relative_to(abs_base).as_posix() + logging.debug(f"Converted absolute testpath '{path}' to relative path '{p}' against project root") + except ValueError: + logging.warning( + f"Ignoring '{path}' in testpaths — path is outside the project root '{abs_base}' " + f"and cannot be expressed as a relative path for sonar.tests" + ) + continue + resolved = base_dir / p + if resolved.is_dir(): + result.append(p) + elif resolved.exists(): + logging.debug( + f"Ignoring '{p}' in testpaths — it is a file, not a directory; sonar.tests uses directory roots" + ) + return result + + +def _load_from_pyproject_toml(base_dir: pathlib.Path) -> Optional[str]: + pyproject_path = base_dir / "pyproject.toml" + if not pyproject_path.is_file(): + return None + try: + with open(pyproject_path, "rb") as f: + toml_dict = tomli.load(f) + except tomli.TOMLDecodeError as e: + logging.debug(f"Error reading pyproject.toml for pytest testpaths: {e}") + return None + ini_options = toml_dict.get("tool", {}).get("pytest", {}).get("ini_options", {}) + if "testpaths" not in ini_options: + return None + testpaths = ini_options["testpaths"] + if not isinstance(testpaths, (list, str)): + logging.warning( + f"testpaths in pyproject.toml [tool.pytest.ini_options] has an unexpected type " + f"({type(testpaths).__name__}) — expected a list or string, skipping" + ) + return None + raw = [str(p) for p in (testpaths if isinstance(testpaths, list) else testpaths.split()) if str(p).strip()] + if not raw: + return None # testpaths = [] means "no path restriction" — same as key absent, continue chain + paths = _existing_paths(base_dir, raw) + if paths: + result = ",".join(paths) + logging.debug(f"Detected test paths from pyproject.toml [tool.pytest.ini_options]: {result}") + return result + logging.warning( + f"testpaths is set in pyproject.toml [tool.pytest.ini_options] to {raw} " + f"but none of those paths exist as directories — sonar.tests will not be auto-detected" + ) + return "" # declared but all paths invalid: stop the chain + + +def _load_from_ini_file(base_dir: pathlib.Path, filename: str, section: str) -> Optional[str]: + config_path = base_dir / filename + if not config_path.is_file(): + return None + try: + config = configparser.ConfigParser() + config.read(config_path) + except configparser.Error as e: + logging.debug(f"Error reading {filename} for pytest testpaths: {e}") + return None + if section not in config or "testpaths" not in config[section]: + return None + raw = [p for p in config[section]["testpaths"].split() if p] + if not raw: + return None # empty testpaths means "no path restriction" — same as key absent, continue chain + paths = _existing_paths(base_dir, raw) + if paths: + result = ",".join(paths) + logging.debug(f"Detected test paths from {filename} [{section}]: {result}") + return result + logging.warning( + f"testpaths is set in {filename} [{section}] to {raw} " + f"but none of those paths exist as directories — sonar.tests will not be auto-detected" + ) + return "" + + +def _load_from_pytest_ini(base_dir: pathlib.Path) -> Optional[str]: + return _load_from_ini_file(base_dir, "pytest.ini", "pytest") + + +def _load_from_tox_ini(base_dir: pathlib.Path) -> Optional[str]: + return _load_from_ini_file(base_dir, "tox.ini", "pytest") + + +def _load_from_setup_cfg(base_dir: pathlib.Path) -> Optional[str]: + return _load_from_ini_file(base_dir, "setup.cfg", _SETUP_CFG_PYTEST_SECTION) + + +def _load_from_filesystem(base_dir: pathlib.Path) -> Optional[str]: + 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..0f3382dd --- /dev/null +++ b/tests/its/test_auto_detect_tests.py @@ -0,0 +1,54 @@ +# +# Sonar Scanner Python +# Copyright (C) 2011-2026 SonarSource Sàrl +# mailto:info AT sonarsource DOT com +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU Lesser General Public +# License as published by the Free Software Foundation; either +# version 3 of the License, or (at your option) any later version. +# This program is distributed in the hope that it will be useful, +# +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# Lesser General Public License for more details. +# +# You should have received a copy of the GNU Lesser General Public License +# along with this program; if not, write to the Free Software Foundation, +# Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +import pytest +from tests.its.utils.sonarqube_client import SonarQubeClient +from tests.its.utils.cli_client import CliClient, SOURCES_FOLDER_PATH + + +pytestmark = pytest.mark.its + + +def test_auto_detect_tests_from_pyproject_toml(sonarqube_client: SonarQubeClient, cli: CliClient): + """sonar.tests should be inferred from [tool.pytest.ini_options] testpaths in pyproject.toml.""" + process = cli.run_analysis(sources_dir="with-tests", params=["--verbose"]) + assert process.returncode == 0, process.stdout + + task_id = cli._read_ce_task_id(SOURCES_FOLDER_PATH / "with-tests") + assert ( + task_id is not None + ), f"report-task.txt not written — analysis may have failed early.\nScanner output:\n{process.stdout}" + task = sonarqube_client.get_ce_task_by_id(task_id) + + assert task["status"] == "SUCCESS", ( + f"SonarQube CE task did not succeed.\n" f"Task: {task}\n" f"Scanner output:\n{process.stdout}" + ) + assert task.get("componentKey") == "with-tests", ( + f"CE task succeeded for wrong component '{task.get('componentKey')}', expected 'with-tests'.\n" + f"Scanner output:\n{process.stdout}" + ) + + test_files = sonarqube_client.get_project_test_files("with-tests") + test_file_paths = [c["path"] for c in test_files] + assert any("test_app.py" in p for p in test_file_paths), ( + f"Expected tests/test_app.py to be classified as a test file in SonarQube — " + f"sonar.tests auto-detection may not have run correctly.\n" + f"Test files found: {test_file_paths}\n" + f"Scanner output:\n{process.stdout}" + ) diff --git a/tests/its/utils/cli_client.py b/tests/its/utils/cli_client.py index 6013ec6d..1d92f622 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,20 @@ def __run_analysis_normal(self, workdir: pathlib.Path, params: list[str], token: text=True, cwd=workdir, ) - self.sq_client.wait_for_analysis_completion() + self._wait_for_ce_completion(workdir) return process + + def _wait_for_ce_completion(self, workdir: pathlib.Path) -> None: + task_id = self._read_ce_task_id(workdir) + if task_id: + self.sq_client.wait_for_ce_task_by_id(task_id) + + @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..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() @@ -101,3 +88,29 @@ 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_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): + 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 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..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 @@ -407,6 +408,125 @@ 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_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.test_paths_loader") + 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", + contents="sonar.tests=explicit/tests\n", + ) + 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"]) + 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 new file mode 100644 index 00000000..2b4257ac --- /dev/null +++ b/tests/unit/test_test_paths_loader.py @@ -0,0 +1,923 @@ +# +# 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 test_paths_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, 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( + "pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = ["tests"] + """, + ) + 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_no_pytest_section(self): + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.sonar] + projectKey = "my-project" + """, + ) + result, disable = test_paths_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + self.assertFalse(disable) + + 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=""" + [tool.pytest.ini_options] + 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_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 — 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( + "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, 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_unexpected_testpaths_type(self, mock_logging): + # An unexpected type (e.g. integer) is not user intent about test paths — we warn + # and return None so the chain continues (filesystem fallback still runs). + self.fs.create_file( + "pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = 1 + """, + ) + self.fs.create_dir("tests") + result, disable = test_paths_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) + mock_logging.warning.assert_called() + + @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, disable = test_paths_loader.load(Path(".")) + self.assertNotIn(SONAR_TESTS, result) + self.assertFalse(disable) + mock_logging.debug.assert_called() + + # --- pytest.ini --- + + def test_load_from_pytest_ini(self): + self.fs.create_file( + "pytest.ini", + contents="""\ +[pytest] +testpaths = tests integration +""", + ) + self.fs.create_dir("tests") + self.fs.create_dir("integration") + 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( + "pytest.ini", + contents="""\ +[pytest] +testpaths = + tests + integration +""", + ) + self.fs.create_dir("tests") + self.fs.create_dir("integration") + 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( + "pytest.ini", + contents="""\ +[pytest] +addopts = --strict-markers +""", + ) + 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( + "pytest.ini", + contents="""\ +[pytest] +testpaths = tests +""", + ) + # 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 --- + + def test_load_from_tox_ini(self): + self.fs.create_file( + "tox.ini", + contents="""\ +[pytest] +testpaths = tests +""", + ) + 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_tox_ini_multiple_paths(self): + self.fs.create_file( + "tox.ini", + contents="""\ +[pytest] +testpaths = tests integration +""", + ) + self.fs.create_dir("tests") + self.fs.create_dir("integration") + 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( + "tox.ini", + contents="""\ +[pytest] +testpaths = + tests + integration +""", + ) + self.fs.create_dir("tests") + self.fs.create_dir("integration") + 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( + "tox.ini", + contents="""\ +[tox] +envlist = py39 +""", + ) + 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( + "tox.ini", + contents="""\ +[pytest] +addopts = --strict-markers +""", + ) + 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( + "tox.ini", + contents="""\ +[pytest] +testpaths = tests +""", + ) + # 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 --- + + def test_load_from_setup_cfg(self): + self.fs.create_file( + "setup.cfg", + contents="""\ +[tool:pytest] +testpaths = tests +""", + ) + 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_setup_cfg_multiple_paths(self): + self.fs.create_file( + "setup.cfg", + contents="""\ +[tool:pytest] +testpaths = tests integration e2e +""", + ) + self.fs.create_dir("tests") + self.fs.create_dir("integration") + self.fs.create_dir("e2e") + 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( + "setup.cfg", + contents="""\ +[metadata] +name = my-package +""", + ) + 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( + "setup.cfg", + contents="""\ +[tool:pytest] +testpaths = tests +""", + ) + # 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, 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, 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, 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, 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, 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, disable = test_paths_loader.load(Path(".")) + self.assertEqual(result, {}) + self.assertFalse(disable) + + # --- 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] +testpaths = from_ini +""", + ) + self.fs.create_dir("from_toml") + 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( + "pytest.ini", + contents="""\ +[pytest] +testpaths = from_ini +""", + ) + self.fs.create_file( + "tox.ini", + contents="""\ +[pytest] +testpaths = from_tox +""", + ) + self.fs.create_dir("from_ini") + 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( + "tox.ini", + contents="""\ +[pytest] +testpaths = from_tox +""", + ) + self.fs.create_file( + "setup.cfg", + contents="""\ +[tool:pytest] +testpaths = from_setup_cfg +""", + ) + self.fs.create_dir("from_tox") + 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( + "pytest.ini", + contents="""\ +[pytest] +testpaths = from_ini +""", + ) + self.fs.create_file( + "setup.cfg", + contents="""\ +[tool:pytest] +testpaths = from_setup_cfg +""", + ) + self.fs.create_dir("from_ini") + 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( + "setup.cfg", + contents="""\ +[tool:pytest] +testpaths = from_setup_cfg +""", + ) + self.fs.create_dir("from_setup_cfg") + self.fs.create_dir("tests") + 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( + "pyproject.toml", + contents=""" + [tool.sonar] + projectKey = "my-project" + """, + ) + self.fs.create_dir("tests") + result, disable = test_paths_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable) + + 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="""\ +[pytest] +testpaths = nonexistent +""", + ) + self.fs.create_file( + "setup.cfg", + contents="""\ +[tool:pytest] +testpaths = from_setup_cfg +""", + ) + self.fs.create_dir("from_setup_cfg") + self.fs.create_dir("tests") # filesystem fallback would find this + 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 --- + + def test_load_from_custom_base_dir(self): + self.fs.create_dir("custom/path") + self.fs.create_file( + "custom/path/pytest.ini", + contents="""\ +[pytest] +testpaths = custom_tests +""", + ) + self.fs.create_dir("custom/path/custom_tests") + 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/...) + # are unambiguously not under the project and inside-root paths (/project/...) are + # unambiguously convertible, regardless of the fake cwd. + + @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] +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] + 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_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", + 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, 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.""" + self.fs.create_dir("/project/tests") + self.fs.create_file( + "/project/pytest.ini", + contents="""\ +[pytest] +testpaths = /project/tests +""", + ) + 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.""" + self.fs.create_dir("/project/src/tests") + self.fs.create_file( + "/project/pytest.ini", + contents="""\ +[pytest] +testpaths = /project/src/tests +""", + ) + 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.""" + self.fs.create_dir("/project/tests") + self.fs.create_file( + "/project/pytest.ini", + contents="""\ +[pytest] +testpaths = /other/tests tests +""", + ) + 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_and_disables_heuristic(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, 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") + self.fs.create_file( + "/project/pyproject.toml", + contents=""" + [tool.pytest.ini_options] + testpaths = ["/project/tests"] + """, + ) + 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_and_disables_heuristic(self): + self.fs.create_dir("/project") + self.fs.create_file( + "/project/tox.ini", + contents="""\ +[pytest] +testpaths = /other/tests +""", + ) + self.fs.create_dir("/project/tests") + 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_and_disables_heuristic(self): + self.fs.create_dir("/project") + self.fs.create_file( + "/project/setup.cfg", + contents="""\ +[tool:pytest] +testpaths = /other/tests +""", + ) + self.fs.create_dir("/project/tests") + 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, + # 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] +testpaths = C:\\project\\tests +""", + ) + 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): + """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] +testpaths = C:\\project\\src\\tests +""", + ) + 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") + 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] +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] + 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_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", + 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, 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") + 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] +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] + 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.test_paths_loader.logging") + def test_file_path_in_testpaths_emits_debug_log(self, mock_logging): + self.fs.create_file( + "pytest.ini", + contents="""\ +[pytest] +testpaths = tests/test_api.py +""", + ) + self.fs.create_file("tests/test_api.py", contents="") + 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), + f"Expected a debug message mentioning the file path, got: {debug_calls}", + ) + + 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="""\ +[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, + # not the directory — chain stops at pytest.ini, filesystem fallback never runs. + 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.""" + self.fs.create_file( + "pytest.ini", + contents="""\ +[pytest] +testpaths = tests/test_api.py unit +""", + ) + self.fs.create_file("tests/test_api.py", contents="") + self.fs.create_dir("unit") + result, disable = test_paths_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "unit") + self.assertFalse(disable) + + # --- declared-but-missing path warnings --- + + @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] +testpaths = nonexistent +""", + ) + 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.test_paths_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"] + """, + ) + 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.test_paths_loader.logging") + def test_nonexistent_testpaths_in_tox_ini_emits_warning(self, mock_logging): + self.fs.create_file( + "tox.ini", + contents="""\ +[pytest] +testpaths = missing_dir +""", + ) + 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.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] +testpaths = missing_dir +""", + ) + 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}", + ) + + # --- 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] +name = my-project +""", + ) + self.fs.create_dir("tests") + result, disable = test_paths_loader.load(Path(".")) + self.assertEqual(result[SONAR_TESTS], "tests") + self.assertFalse(disable)