-
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
Changes from all commits
f6fc918
ce9f8f0
5660446
e41141b
3e0b7bf
c22a766
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,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: | ||
|
sonar-review-alpha[bot] marked this conversation as resolved.
sonar-review-alpha[bot] marked this conversation as resolved.
sonar-review-alpha[bot] marked this conversation as resolved.
sonar-review-alpha[bot] marked this conversation as resolved.
|
||
| return None | ||
| testpaths = ini_options["testpaths"] | ||
| if not isinstance(testpaths, (list, str)): | ||
| logging.warning( | ||
| f"testpaths in pyproject.toml [tool.pytest.ini_options] has an unexpected type " | ||
| f"({type(testpaths).__name__}) — expected a list or string, skipping" | ||
| ) | ||
| return None | ||
| raw = [str(p) for p in (testpaths if isinstance(testpaths, list) else testpaths.split()) if str(p).strip()] | ||
| if not raw: | ||
| return None # testpaths = [] means "no path restriction" — same as key absent, continue chain | ||
| paths = _existing_paths(base_dir, raw) | ||
| if paths: | ||
| result = ",".join(paths) | ||
| logging.debug(f"Detected test paths from pyproject.toml [tool.pytest.ini_options]: {result}") | ||
| return result | ||
| logging.warning( | ||
| f"testpaths is set in pyproject.toml [tool.pytest.ini_options] to {raw} " | ||
| f"but none of those paths exist as directories — sonar.tests will not be auto-detected" | ||
| ) | ||
| return "" # declared but all paths invalid: stop the chain | ||
|
|
||
|
|
||
| def _load_from_ini_file(base_dir: pathlib.Path, filename: str, section: str) -> Optional[str]: | ||
| config_path = base_dir / filename | ||
| if not config_path.is_file(): | ||
| return None | ||
| try: | ||
| config = configparser.ConfigParser() | ||
| config.read(config_path) | ||
| except configparser.Error as e: | ||
| logging.debug(f"Error reading {filename} for pytest testpaths: {e}") | ||
| return None | ||
| if section not in config or "testpaths" not in config[section]: | ||
| return None | ||
| raw = [p for p in config[section]["testpaths"].split() if p] | ||
| if not raw: | ||
| return None # empty testpaths means "no path restriction" — same as key absent, continue chain | ||
| paths = _existing_paths(base_dir, raw) | ||
| if paths: | ||
| result = ",".join(paths) | ||
| logging.debug(f"Detected test paths from {filename} [{section}]: {result}") | ||
| return result | ||
| logging.warning( | ||
| f"testpaths is set in {filename} [{section}] to {raw} " | ||
| f"but none of those paths exist as directories — sonar.tests will not be auto-detected" | ||
| ) | ||
| return "" | ||
|
|
||
|
|
||
| def _load_from_pytest_ini(base_dir: pathlib.Path) -> Optional[str]: | ||
| return _load_from_ini_file(base_dir, "pytest.ini", "pytest") | ||
|
|
||
|
|
||
| def _load_from_tox_ini(base_dir: pathlib.Path) -> Optional[str]: | ||
| return _load_from_ini_file(base_dir, "tox.ini", "pytest") | ||
|
|
||
|
|
||
| def _load_from_setup_cfg(base_dir: pathlib.Path) -> Optional[str]: | ||
| return _load_from_ini_file(base_dir, "setup.cfg", _SETUP_CFG_PYTEST_SECTION) | ||
|
|
||
|
|
||
| def _load_from_filesystem(base_dir: pathlib.Path) -> Optional[str]: | ||
|
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
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. Maybe that would make the most sense. It would certainly simplify the flow chart of how the heuristics apply. |
||
| 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}" | ||
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This option should also be added to the
configuration.climodule as an option, potentially as a switch