-
Notifications
You must be signed in to change notification settings - Fork 4
SCANPY-248 Auto detect test code #318
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
f6fc918
ce9f8f0
5660446
e41141b
e2380f2
ca83884
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -171,3 +171,4 @@ cython_debug/ | |
| # Sonar | ||
| *.scanner | ||
| *.scannerwork | ||
| .sonar/ | ||
| Original file line number | Diff line number | Diff line change | ||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,163 @@ | ||||||||||||
| # | ||||||||||||
| # Sonar Scanner Python | ||||||||||||
| # Copyright (C) 2011-2026 SonarSource Sàrl | ||||||||||||
| # mailto:info AT sonarsource DOT com | ||||||||||||
| # | ||||||||||||
| # This program is free software; you can redistribute it and/or | ||||||||||||
| # modify it under the terms of the GNU Lesser General Public | ||||||||||||
| # License as published by the Free Software Foundation; either | ||||||||||||
| # version 3 of the License, or (at your option) any later version. | ||||||||||||
| # This program is distributed in the hope that it will be useful, | ||||||||||||
| # | ||||||||||||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||||||||||||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | ||||||||||||
| # Lesser General Public License for more details. | ||||||||||||
| # | ||||||||||||
| # You should have received a copy of the GNU Lesser General Public License | ||||||||||||
| # along with this program; if not, write to the Free Software Foundation, | ||||||||||||
| # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||||||||||||
| # | ||||||||||||
| import configparser | ||||||||||||
| import logging | ||||||||||||
| import pathlib | ||||||||||||
| from typing import Optional | ||||||||||||
|
|
||||||||||||
| import tomli | ||||||||||||
|
|
||||||||||||
| from pysonar_scanner.configuration.properties import SONAR_TESTS | ||||||||||||
|
|
||||||||||||
| _CONVENTIONAL_TEST_DIRS = ["tests", "test", "testing"] | ||||||||||||
| _SETUP_CFG_PYTEST_SECTION = "tool:pytest" | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def load(base_dir: pathlib.Path) -> dict[str, str]: | ||||||||||||
| """Infer sonar.tests from Python tooling configuration and filesystem conventions. | ||||||||||||
|
|
||||||||||||
| Returns sonar.tests if a test directory can be reliably inferred; empty dict otherwise. | ||||||||||||
| Filesystem convention fallback only runs when no config file declares a testpaths key. | ||||||||||||
| """ | ||||||||||||
| for loader in [_load_from_pyproject_toml, _load_from_pytest_ini, _load_from_tox_ini, _load_from_setup_cfg]: | ||||||||||||
| result = loader(base_dir) | ||||||||||||
| if result is None: | ||||||||||||
| continue # file absent or no testpaths key — try next source | ||||||||||||
| if result: | ||||||||||||
| return {SONAR_TESTS: result} | ||||||||||||
|
sonar-review-alpha[bot] marked this conversation as resolved.
|
||||||||||||
| return {} # testpaths declared but all paths were invalid — stop chain, set nothing | ||||||||||||
|
|
||||||||||||
| # No config file declared a testpaths key; fall back to filesystem conventions. | ||||||||||||
| filesystem_result = _load_from_filesystem(base_dir) | ||||||||||||
| if filesystem_result: | ||||||||||||
| return {SONAR_TESTS: filesystem_result} | ||||||||||||
| return {} | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def _existing_paths(base_dir: pathlib.Path, paths: list[str]) -> list[str]: | ||||||||||||
| """Filter a list of candidate paths to those that exist as directories under base_dir. | ||||||||||||
|
|
||||||||||||
| Absolute paths are relativised against the project root. If an absolute path falls | ||||||||||||
| outside the project root it is skipped with a warning. Relative paths that resolve | ||||||||||||
| to a file (not a directory) are skipped with a debug message. | ||||||||||||
| """ | ||||||||||||
| abs_base = base_dir.resolve() | ||||||||||||
| result = [] | ||||||||||||
| for p in paths: | ||||||||||||
| path = pathlib.Path(p) | ||||||||||||
| if path.root: # rooted path: absolute on POSIX, or rooted (possibly drive-relative) on Windows | ||||||||||||
| try: | ||||||||||||
| # On Windows, abs_base.resolve() adds a drive letter (e.g. C:\project) while a path | ||||||||||||
| # read from config may be drive-relative (/project/tests, no drive). Attach the base | ||||||||||||
| # drive so relative_to() can compare them correctly. | ||||||||||||
| candidate = pathlib.Path(abs_base.drive + str(path)) if abs_base.drive and not path.drive else path | ||||||||||||
| p = candidate.relative_to(abs_base).as_posix() | ||||||||||||
| logging.debug(f"Converted absolute testpath '{path}' to relative path '{p}' against project root") | ||||||||||||
| except ValueError: | ||||||||||||
| logging.warning( | ||||||||||||
| f"Ignoring '{path}' in testpaths — path is outside the project root '{abs_base}' " | ||||||||||||
| f"and cannot be expressed as a relative path for sonar.tests" | ||||||||||||
| ) | ||||||||||||
| continue | ||||||||||||
| resolved = base_dir / p | ||||||||||||
| if resolved.is_dir(): | ||||||||||||
| result.append(p) | ||||||||||||
| elif resolved.exists(): | ||||||||||||
| logging.debug( | ||||||||||||
| f"Ignoring '{p}' in testpaths — it is a file, not a directory; sonar.tests uses directory roots" | ||||||||||||
| ) | ||||||||||||
| return result | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def _load_from_pyproject_toml(base_dir: pathlib.Path) -> Optional[str]: | ||||||||||||
| pyproject_path = base_dir / "pyproject.toml" | ||||||||||||
| if not pyproject_path.is_file(): | ||||||||||||
| return None | ||||||||||||
| try: | ||||||||||||
| with open(pyproject_path, "rb") as f: | ||||||||||||
| toml_dict = tomli.load(f) | ||||||||||||
| except tomli.TOMLDecodeError as e: | ||||||||||||
| logging.debug(f"Error reading pyproject.toml for pytest testpaths: {e}") | ||||||||||||
| return None | ||||||||||||
| ini_options = toml_dict.get("tool", {}).get("pytest", {}).get("ini_options", {}) | ||||||||||||
| if "testpaths" not in ini_options: | ||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If if isinstance(testpaths, list):
raw = [str(p) for p in testpaths if str(p).strip()]
elif isinstance(testpaths, str):
raw = [p for p in testpaths.split() if p]
else:
logging.debug(f"Unexpected type for testpaths in pyproject.toml: {type(testpaths)}")
return None
Suggested change
|
||||||||||||
| return None | ||||||||||||
| testpaths = ini_options["testpaths"] | ||||||||||||
| raw = [str(p) for p in (testpaths if isinstance(testpaths, list) else testpaths.split()) if str(p).strip()] | ||||||||||||
| if not raw: | ||||||||||||
| return "" # testpaths key present but empty — stop chain | ||||||||||||
| paths = _existing_paths(base_dir, raw) | ||||||||||||
| if paths: | ||||||||||||
| result = ",".join(paths) | ||||||||||||
| logging.debug(f"Detected test paths from pyproject.toml [tool.pytest.ini_options]: {result}") | ||||||||||||
| return result | ||||||||||||
| logging.warning( | ||||||||||||
| f"testpaths is set in pyproject.toml [tool.pytest.ini_options] to {raw} " | ||||||||||||
| f"but none of those paths exist as directories — sonar.tests will not be auto-detected" | ||||||||||||
| ) | ||||||||||||
| return "" # declared but all paths invalid: stop the chain | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def _load_from_ini_file(base_dir: pathlib.Path, filename: str, section: str) -> Optional[str]: | ||||||||||||
| config_path = base_dir / filename | ||||||||||||
| if not config_path.is_file(): | ||||||||||||
| return None | ||||||||||||
| try: | ||||||||||||
| config = configparser.ConfigParser() | ||||||||||||
| config.read(config_path) | ||||||||||||
| except configparser.Error as e: | ||||||||||||
| logging.debug(f"Error reading {filename} for pytest testpaths: {e}") | ||||||||||||
| return None | ||||||||||||
| if section not in config or "testpaths" not in config[section]: | ||||||||||||
| return None | ||||||||||||
| raw = [p for p in config[section]["testpaths"].split() if p] | ||||||||||||
| if not raw: | ||||||||||||
| return "" # testpaths key present but empty — stop chain | ||||||||||||
| paths = _existing_paths(base_dir, raw) | ||||||||||||
| if paths: | ||||||||||||
| result = ",".join(paths) | ||||||||||||
| logging.debug(f"Detected test paths from {filename} [{section}]: {result}") | ||||||||||||
| return result | ||||||||||||
| logging.warning( | ||||||||||||
| f"testpaths is set in {filename} [{section}] to {raw} " | ||||||||||||
| f"but none of those paths exist as directories — sonar.tests will not be auto-detected" | ||||||||||||
| ) | ||||||||||||
| return "" | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def _load_from_pytest_ini(base_dir: pathlib.Path) -> Optional[str]: | ||||||||||||
| return _load_from_ini_file(base_dir, "pytest.ini", "pytest") | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def _load_from_tox_ini(base_dir: pathlib.Path) -> Optional[str]: | ||||||||||||
| return _load_from_ini_file(base_dir, "tox.ini", "pytest") | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def _load_from_setup_cfg(base_dir: pathlib.Path) -> Optional[str]: | ||||||||||||
| return _load_from_ini_file(base_dir, "setup.cfg", _SETUP_CFG_PYTEST_SECTION) | ||||||||||||
|
|
||||||||||||
|
|
||||||||||||
| def _load_from_filesystem(base_dir: pathlib.Path) -> Optional[str]: | ||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Does it make sense to add this heuristic here as well, given that sonar-python itself has a more extensive set of heuristics.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think the heuristic from Having it here makes the analysis more consistent because the rule execution is in sync with the metrics reporting. It's an argument to use
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. That is a good point. Maybe it would make sense to only fall back to the heuristic if the user hasn't set anything, rather than we haven't found a valid test folder
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. In a way I'm wondering if analysis coming from |
||||||||||||
| 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 | ||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| [project] | ||
| name = "with-tests" | ||
|
|
||
| [tool.sonar] | ||
| projectKey = "with-tests" | ||
| sources = "src" | ||
|
|
||
| [tool.pytest.ini_options] | ||
| testpaths = ["tests"] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| sonar.projectVersion=1.0 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| # | ||
| # Sonar Scanner Python | ||
| # Copyright (C) 2011-2026 SonarSource Sàrl | ||
| # mailto:info AT sonarsource DOT com | ||
| # | ||
| # This program is free software; you can redistribute it and/or | ||
| # modify it under the terms of the GNU Lesser General Public | ||
| # License as published by the Free Software Foundation; either | ||
| # version 3 of the License, or (at your option) any later version. | ||
| # This program is distributed in the hope that it will be useful, | ||
| # | ||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | ||
| # Lesser General Public License for more details. | ||
| # | ||
| # You should have received a copy of the GNU Lesser General Public License | ||
| # along with this program; if not, write to the Free Software Foundation, | ||
| # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||
| # | ||
| def greet(name: str) -> str: | ||
| return f"Hello, {name}!" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| # | ||
| # Sonar Scanner Python | ||
| # Copyright (C) 2011-2026 SonarSource Sàrl | ||
| # mailto:info AT sonarsource DOT com | ||
| # | ||
| # This program is free software; you can redistribute it and/or | ||
| # modify it under the terms of the GNU Lesser General Public | ||
| # License as published by the Free Software Foundation; either | ||
| # version 3 of the License, or (at your option) any later version. | ||
| # This program is distributed in the hope that it will be useful, | ||
| # | ||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | ||
| # Lesser General Public License for more details. | ||
| # | ||
| # You should have received a copy of the GNU Lesser General Public License | ||
| # along with this program; if not, write to the Free Software Foundation, | ||
| # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||
| # | ||
| def add(a: int, b: int) -> int: | ||
| return a + b | ||
|
|
||
|
|
||
| def test_add(): | ||
| assert add(1, 2) == 3 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,54 @@ | ||
| # | ||
| # Sonar Scanner Python | ||
| # Copyright (C) 2011-2026 SonarSource Sàrl | ||
| # mailto:info AT sonarsource DOT com | ||
| # | ||
| # This program is free software; you can redistribute it and/or | ||
| # modify it under the terms of the GNU Lesser General Public | ||
| # License as published by the Free Software Foundation; either | ||
| # version 3 of the License, or (at your option) any later version. | ||
| # This program is distributed in the hope that it will be useful, | ||
| # | ||
| # but WITHOUT ANY WARRANTY; without even the implied warranty of | ||
| # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU | ||
| # Lesser General Public License for more details. | ||
| # | ||
| # You should have received a copy of the GNU Lesser General Public License | ||
| # along with this program; if not, write to the Free Software Foundation, | ||
| # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | ||
| # | ||
| import pytest | ||
| from tests.its.utils.sonarqube_client import SonarQubeClient | ||
| from tests.its.utils.cli_client import CliClient, SOURCES_FOLDER_PATH | ||
|
|
||
|
|
||
| pytestmark = pytest.mark.its | ||
|
|
||
|
|
||
| def test_auto_detect_tests_from_pyproject_toml(sonarqube_client: SonarQubeClient, cli: CliClient): | ||
| """sonar.tests should be inferred from [tool.pytest.ini_options] testpaths in pyproject.toml.""" | ||
| process = cli.run_analysis(sources_dir="with-tests", params=["--verbose"]) | ||
| assert process.returncode == 0, process.stdout | ||
|
|
||
| task_id = cli._read_ce_task_id(SOURCES_FOLDER_PATH / "with-tests") | ||
| assert ( | ||
| task_id is not None | ||
| ), f"report-task.txt not written — analysis may have failed early.\nScanner output:\n{process.stdout}" | ||
| task = sonarqube_client.get_ce_task_by_id(task_id) | ||
|
|
||
| assert task["status"] == "SUCCESS", ( | ||
| f"SonarQube CE task did not succeed.\n" f"Task: {task}\n" f"Scanner output:\n{process.stdout}" | ||
| ) | ||
| assert task.get("componentKey") == "with-tests", ( | ||
| f"CE task succeeded for wrong component '{task.get('componentKey')}', expected 'with-tests'.\n" | ||
| f"Scanner output:\n{process.stdout}" | ||
| ) | ||
|
|
||
| test_files = sonarqube_client.get_project_test_files("with-tests") | ||
| test_file_paths = [c["path"] for c in test_files] | ||
| assert any("test_app.py" in p for p in test_file_paths), ( | ||
| f"Expected tests/test_app.py to be classified as a test file in SonarQube — " | ||
| f"sonar.tests auto-detection may not have run correctly.\n" | ||
| f"Test files found: {test_file_paths}\n" | ||
| f"Scanner output:\n{process.stdout}" | ||
| ) |
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -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: | ||||||||||||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This method does a lot of automatic checks and fallbacks. Is this really necessary for a test? |
||||||||||||||||||||||||
| task_id = self._read_ce_task_id(workdir) | ||||||||||||||||||||||||
| if task_id: | ||||||||||||||||||||||||
| self.sq_client.wait_for_ce_task_by_id(task_id) | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @staticmethod | ||||||||||||||||||||||||
|
Comment on lines
+103
to
+106
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. If the scanner exits before writing Log a warning when the file is missing so the failure is obvious:
Suggested change
|
||||||||||||||||||||||||
| 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 | ||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.