From 1f2edbcd66152014ea647fb420581e5de3bb628a Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Tue, 17 Mar 2026 15:49:48 +0100 Subject: [PATCH 1/6] SCANPY-237 Updated poetry.lock --- poetry.lock | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/poetry.lock b/poetry.lock index 51d89ba4..9ea20eee 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.1.1 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.3.2 and should not be changed by hand. [[package]] name = "attrs" @@ -749,7 +749,7 @@ pytest = ">=4.0,<9.0" [package.extras] docker-compose-v1 = ["docker-compose (>=1.27.3,<2.0)"] -tests = ["mypy (>=0.500,<2.000)", "pytest-mypy (>=0.10,<1.0)", "pytest-pycodestyle (>=2.0.0,<3.0)", "pytest-pylint (>=0.14.1,<1.0)", "requests (>=2.22.0,<3.0)", "types-requests (>=2.31,<3.0)", "types-setuptools (>=69.0,<70.0)"] +tests = ["mypy (>=0.500,<2.0)", "pytest-mypy (>=0.10,<1.0)", "pytest-pycodestyle (>=2.0.0,<3.0)", "pytest-pylint (>=0.14.1,<1.0)", "requests (>=2.22.0,<3.0)", "types-requests (>=2.31,<3.0)", "types-setuptools (>=69.0,<70.0)"] [package.source] type = "legacy" @@ -1064,6 +1064,7 @@ files = [ {file = "tomli-2.2.1-py3-none-any.whl", hash = "sha256:cb55c73c5f4408779d0cf3eef9f762b9c9f147a77de7b258bef0a5628adc85cc"}, {file = "tomli-2.2.1.tar.gz", hash = "sha256:cd45e1dc79c835ce60f7404ec8119f2eb06d38b1deba146f07ced3bbc44505ff"}, ] +markers = {dev = "python_full_version <= \"3.11.0a6\""} [package.source] type = "legacy" @@ -1133,4 +1134,4 @@ reference = "jfrog-server" [metadata] lock-version = "2.1" python-versions = ">=3.9" -content-hash = "05bea44c67e5a5cedd5d3d1774c02bc22d69edbba07875c36b2466a1a098f5d3" +content-hash = "8f31c02cd56ff361e783b4e5bca432f8b2a902313da59f34318dd38e133f1438" From 6675c2831efb46ff2026da06f267af12a24a297f Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Wed, 18 Mar 2026 09:28:06 +0100 Subject: [PATCH 2/6] SCANPY-237 Added dry run mode. --- CLI_ARGS.md | 1 + DRY_RUN_MODE.md | 275 ++++++++++++++++ src/pysonar_scanner/__main__.py | 26 ++ src/pysonar_scanner/configuration/cli.py | 6 + .../configuration/properties.py | 6 + src/pysonar_scanner/dry_run_reporter.py | 224 +++++++++++++ tests/unit/test_configuration_loader.py | 7 + tests/unit/test_dry_run.py | 305 ++++++++++++++++++ 8 files changed, 850 insertions(+) create mode 100644 DRY_RUN_MODE.md create mode 100644 src/pysonar_scanner/dry_run_reporter.py create mode 100644 tests/unit/test_dry_run.py diff --git a/CLI_ARGS.md b/CLI_ARGS.md index a019443d..bc8955b2 100644 --- a/CLI_ARGS.md +++ b/CLI_ARGS.md @@ -73,6 +73,7 @@ | `--sonar-scanner-arch`, `-Dsonar.scanner.arch` | Architecture on which the scanner will be running | | `--sonar-scanner-cloud-url`, `-Dsonar.scanner.cloudUrl` | SonarQube Cloud base URL, https://sonarcloud.io for example | | `--sonar-scanner-connect-timeout`, `-Dsonar.scanner.connectTimeout` | Time period to establish connections with the server (in seconds) | +| `--dry-run`, `--no-dry-run` | Enable dry-run mode to validate configuration without connecting to SonarQube server or submitting analysis. See [Dry Run Mode](DRY_RUN_MODE.md) for details. Can also be set via `-Dsonar.scanner.dryRun=true` or `SONAR_SCANNER_DRY_RUN=true` | | `--sonar-scanner-internal-dump-to-file`, `-Dsonar.scanner.internal.dumpToFile` | Filename where the input to the scanner engine will be dumped. Useful for debugging | | `--sonar-scanner-internal-sq-version`, `-Dsonar.scanner.internal.sqVersion` | Emulate the result of the call to get SQ server version. Useful for debugging with --sonar-scanner-internal-dump-to-file | | `--sonar-scanner-java-exe-path`, `-Dsonar.scanner.javaExePath` | If defined, the scanner engine will be run with this JRE | diff --git a/DRY_RUN_MODE.md b/DRY_RUN_MODE.md new file mode 100644 index 00000000..3090927a --- /dev/null +++ b/DRY_RUN_MODE.md @@ -0,0 +1,275 @@ +# Dry Run / Debug Mode for Pysonar Scanner + +The Pysonar scanner supports a **dry-run mode** that helps you troubleshoot configuration issues without connecting to a SonarQube server or submitting analysis results. This is particularly useful when: + +- Setting up new projects +- Adjusting coverage report paths +- Validating configuration properties +- Debugging analysis failures related to configuration + +## Enabling Dry Run Mode + +To run the scanner in dry-run mode, add the `--dry-run` flag: + +```bash +pysonar --token "myToken" --project-key "my:project" --dry-run +``` + +Alternatively, use the property format: + +```bash +pysonar -Dsonar.scanner.dryRun=true +``` + +Or set it as an environment variable: + +```bash +export SONAR_SCANNER_DRY_RUN=true +pysonar +``` + +## What Dry Run Mode Does + +When dry-run mode is enabled, the scanner: + +1. **Skips SonarQube server validation** - No connection attempt to the SonarQube server is made +2. **Skips analysis submission** - No data is sent to or modified on the server +3. **Resolves configuration** - Loads configuration from all sources (CLI, environment variables, pyproject.toml, etc.) +4. **Reports resolved configuration** - Displays the detected settings including: + - Project key and name + - Organization (if applicable) + - Detected main source directories + - Detected test source directories + - Configured coverage report paths + - Server URL (if configured) +5. **Validates coverage reports** - Checks coverage report paths and formats with clear error reporting + +## Configuration Report Output + +In dry-run mode, the scanner outputs a configuration summary. Example: + +``` +================================================================================ +DRY RUN MODE - Configuration Report +================================================================================ + +Project Configuration: + Project Key: my:project + Project Name: My Project + Organization: my-org + +Server Configuration: + Host Url: https://sonarcloud.io + +Source Configuration: + Sources: src + Tests: tests + +Coverage Configuration: + Coverage Report Paths: coverage/cobertura.xml + +================================================================================ +DRY RUN MODE - Validation Results +================================================================================ + +✓ Configuration validation PASSED + +================================================================================ +``` + +## Coverage Report Validation + +The scanner validates coverage reports by checking: + +1. **File existence** - Verifies that the file exists at the specified path +2. **File readability** - Ensures the file is readable and accessible +3. **File format** - Validates that coverage reports are in valid Cobertura XML format +4. **Root element** - Checks that XML root element is `` (expected Cobertura format) + +### Example: Coverage Report Validation Output + +Successful validation: + +``` +Coverage Report Paths: coverage.xml + +✓ Coverage report is valid Cobertura XML: coverage.xml +``` + +Missing file error: + +``` +✗ Configuration validation FAILED with the following issues: + • Coverage report not found: coverage.xml (resolved to /project/coverage.xml) +``` + +Invalid format error: + +``` +✗ Configuration validation FAILED with the following issues: + • Coverage report is not valid XML (Cobertura format): coverage.xml + Parse error: XML not well-formed (invalid token) +``` + +## Exit Codes + +- **0**: Configuration validation passed, no errors found +- **1**: Configuration validation failed, errors were found + +## Use Cases + +### 1. Validating Coverage Report Paths + +Before running a full analysis, verify that coverage reports are correctly configured: + +```bash +pysonar \ + --token "myToken" \ + --project-key "my:project" \ + --sonar-python-coverage-report-paths "coverage/cobertura.xml" \ + --dry-run +``` + +### 2. Checking Configuration Resolution + +Verify that all configuration sources are properly resolved: + +```bash +# Set configuration in multiple places +export SONAR_HOST_URL="https://sonarqube.example.com" +pysonar \ + --token "myToken" \ + --project-key "my:project" \ + --dry-run +``` + +This helps ensure that environment variables, CLI arguments, and configuration files are being read correctly. + +### 3. Troubleshooting Failed Analysis + +If an analysis fails, use dry-run mode to quickly identify configuration issues without waiting for a full analysis: + +```bash +# First, validate the configuration +pysonar \ + --token "myToken" \ + --project-key "my:project" \ + --sonar-python-coverage-report-paths "coverage/cobertura.xml" \ + --dry-run + +# If successful, run the full analysis +pysonar \ + --token "myToken" \ + --project-key "my:project" \ + --sonar-python-coverage-report-paths "coverage/cobertura.xml" +``` + +### 4. Setting Up New Projects + +When onboarding a new project, use dry-run mode to validate the setup before the first full analysis: + +```bash +# Create your configuration in pyproject.toml or via CLI +# Then validate it: +pysonar --dry-run -v +``` + +## Common Issues and Solutions + +### Issue: Coverage report not found + +**Error message:** +``` +Coverage report not found: coverage.xml (resolved to /project/coverage.xml) +``` + +**Solution:** +- Verify the file path is correct relative to the project base directory +- Check that the file actually exists: `ls -la /project/coverage.xml` +- Use absolute paths if relative paths are not working +- Ensure the scanner is run from the correct directory + +### Issue: Coverage report is not readable + +**Error message:** +``` +Coverage report is not readable (permission denied): coverage.xml +``` + +**Solution:** +- Check file permissions: `ls -l coverage.xml` +- Make the file readable: `chmod 644 coverage.xml` +- Ensure the process running the scanner has read access + +### Issue: Invalid XML format + +**Error message:** +``` +Coverage report is not valid XML (Cobertura format): coverage.xml + Parse error: XML not well-formed (invalid token) +``` + +**Solution:** +- Verify the coverage report was generated correctly +- Try generating the coverage report again +- Check the coverage tool documentation for proper output format + +### Issue: Wrong root element + +**Warning message:** +``` +Coverage report root element is 'report', expected 'coverage' (Cobertura format) +``` + +**Solution:** +- The coverage report may not be in Cobertura XML format +- Check that your coverage tool is configured to output Cobertura XML +- For Python projects using coverage.py, use: `coverage xml` + +## Integration with CI/CD + +Dry-run mode is particularly useful in CI/CD pipelines to fail fast on configuration issues: + +### GitHub Actions Example + +```yaml +- name: Validate configuration + run: | + pysonar \ + --token ${{ secrets.SONAR_TOKEN }} \ + --project-key ${{ env.SONAR_PROJECT_KEY }} \ + --sonar-python-coverage-report-paths "coverage/cobertura.xml" \ + --dry-run + +- name: Run analysis + run: | + pysonar \ + --token ${{ secrets.SONAR_TOKEN }} \ + --project-key ${{ env.SONAR_PROJECT_KEY }} \ + --sonar-python-coverage-report-paths "coverage/cobertura.xml" +``` + +### GitLab CI Example + +```yaml +validate_config: + script: + - pysonar + --token $SONAR_TOKEN + --project-key $SONAR_PROJECT_KEY + --sonar-python-coverage-report-paths "coverage/cobertura.xml" + --dry-run + +analyze: + script: + - pysonar + --token $SONAR_TOKEN + --project-key $SONAR_PROJECT_KEY + --sonar-python-coverage-report-paths "coverage/cobertura.xml" +``` + +## Additional Resources + +- [CLI Arguments Reference](CLI_ARGS.md) +- [SonarQube Analysis Parameters](https://docs.sonarsource.com/sonarqube-server/latest/analyzing-source-code/analysis-parameters/) +- [Cobertura XML Format](https://cobertura.github.io/cobertura/) diff --git a/src/pysonar_scanner/__main__.py b/src/pysonar_scanner/__main__.py index 9f68efe0..25e1b981 100644 --- a/src/pysonar_scanner/__main__.py +++ b/src/pysonar_scanner/__main__.py @@ -35,10 +35,14 @@ SONAR_SCANNER_JAVA_EXE_PATH, SONAR_SCANNER_OS, SONAR_SCANNER_ARCH, + SONAR_SCANNER_DRY_RUN, + SONAR_PROJECT_BASE_DIR, + SONAR_PYTHON_COVERAGE_REPORT_PATHS, ) from pysonar_scanner.exceptions import SQTooOldException from pysonar_scanner.jre import JREResolvedPath, JREProvisioner, JREResolver, JREResolverConfiguration from pysonar_scanner.scannerengine import ScannerEngine, ScannerEngineProvisioner +from pysonar_scanner.dry_run_reporter import DryRunReporter, CoverageReportValidator, ValidationResult def main(): @@ -61,6 +65,9 @@ def do_scan(): config = ConfigurationLoader.load() set_logging_options(config) + if config.get(SONAR_SCANNER_DRY_RUN, False): + return run_dry_run(config) + ConfigurationLoader.check_configuration(config) api = build_api(config) @@ -121,3 +128,22 @@ def create_jre(api, cache, config: dict[str, Any]) -> JREResolvedPath: jre_provisioner = JREProvisioner(api, cache, config[SONAR_SCANNER_OS], config[SONAR_SCANNER_ARCH]) jre_resolver = JREResolver(JREResolverConfiguration.from_dict(config), jre_provisioner) return jre_resolver.resolve_jre() + + +def run_dry_run(config: dict[str, Any]) -> int: + """ + Run in dry-run mode without connecting to SonarQube server. + Validates configuration and coverage reports. + """ + logging.info("Running in DRY RUN mode") + logging.info("No server connection will be made and no analysis will be submitted") + + DryRunReporter.report_configuration(config) + + validation_result = ValidationResult() + + coverage_paths = config.get(SONAR_PYTHON_COVERAGE_REPORT_PATHS) + project_base_dir = config.get(SONAR_PROJECT_BASE_DIR, ".") + CoverageReportValidator.validate_coverage_reports(coverage_paths, project_base_dir, validation_result) + + return DryRunReporter.report_validation_results(validation_result) diff --git a/src/pysonar_scanner/configuration/cli.py b/src/pysonar_scanner/configuration/cli.py index 87566ad4..f4fe2d52 100644 --- a/src/pysonar_scanner/configuration/cli.py +++ b/src/pysonar_scanner/configuration/cli.py @@ -363,6 +363,12 @@ def __create_parser(cls): action=argparse.BooleanOptionalAction, help="Override the SonarQube configuration of skipping or not the analysis of unchanged Python files", ) + scanner_behavior_group.add_argument( + "--dry-run", + action=argparse.BooleanOptionalAction, + default=None, + help="Enable dry-run mode to validate configuration without connecting to SonarQube server or submitting analysis", + ) jvm_group = parser.add_argument_group("JVM Settings") jvm_group.add_argument( diff --git a/src/pysonar_scanner/configuration/properties.py b/src/pysonar_scanner/configuration/properties.py index b6becae0..ebc51bdf 100644 --- a/src/pysonar_scanner/configuration/properties.py +++ b/src/pysonar_scanner/configuration/properties.py @@ -102,6 +102,7 @@ SONAR_PYTHON_BANDIT_REPORT_PATHS: Key = "sonar.python.bandit.reportPaths" SONAR_PYTHON_FLAKE8_REPORT_PATHS: Key = "sonar.python.flake8.reportPaths" SONAR_PYTHON_RUFF_REPORT_PATHS: Key = "sonar.python.ruff.reportPaths" +SONAR_SCANNER_DRY_RUN: Key = "sonar.scanner.dryRun" TOML_PATH: Key = "toml-path" # ============ DEPRECATED ============== @@ -554,5 +555,10 @@ def env_variable_name(self) -> str: default_value=None, cli_getter=lambda args: args.sonar_python_analysis_threads ), + Property( + name=SONAR_SCANNER_DRY_RUN, + default_value=False, + cli_getter=lambda args: args.dry_run + ), ] # fmt: on diff --git a/src/pysonar_scanner/dry_run_reporter.py b/src/pysonar_scanner/dry_run_reporter.py new file mode 100644 index 00000000..b742a083 --- /dev/null +++ b/src/pysonar_scanner/dry_run_reporter.py @@ -0,0 +1,224 @@ +# +# Sonar Scanner Python +# Copyright (C) 2011-2024 SonarSource SA. +# 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 logging +import re +import xml.etree.ElementTree as ET +from pathlib import Path +from typing import Any, Optional + +from pysonar_scanner.configuration.properties import ( + SONAR_PROJECT_KEY, + SONAR_ORGANIZATION, + SONAR_SOURCES, + SONAR_TESTS, + SONAR_PYTHON_COVERAGE_REPORT_PATHS, + SONAR_PROJECT_NAME, + SONAR_HOST_URL, +) + + +class DryRunReporter: + """ + Handles reporting of configuration and validation results in dry-run mode. + Provides clear, structured output for troubleshooting configuration issues. + """ + + @staticmethod + def report_configuration(config: dict[str, Any]) -> None: + """ + Log the resolved configuration, focusing on key properties. + """ + logging.info("=" * 80) + logging.info("DRY RUN MODE - Configuration Report") + logging.info("=" * 80) + + DryRunReporter._log_section("Project Configuration", { + SONAR_PROJECT_KEY: config.get(SONAR_PROJECT_KEY), + SONAR_PROJECT_NAME: config.get(SONAR_PROJECT_NAME), + SONAR_ORGANIZATION: config.get(SONAR_ORGANIZATION, "N/A (likely SonarQube Server)"), + }) + + DryRunReporter._log_section("Server Configuration", { + SONAR_HOST_URL: config.get(SONAR_HOST_URL, "N/A"), + }) + + DryRunReporter._log_section("Source Configuration", { + SONAR_SOURCES: config.get(SONAR_SOURCES, "N/A"), + SONAR_TESTS: config.get(SONAR_TESTS, "N/A"), + }) + + DryRunReporter._log_section("Coverage Configuration", { + SONAR_PYTHON_COVERAGE_REPORT_PATHS: config.get(SONAR_PYTHON_COVERAGE_REPORT_PATHS, "N/A"), + }) + + @staticmethod + def report_validation_results(validation_result: "ValidationResult") -> int: + """ + Log validation results and return appropriate exit code. + Returns 0 if validation passed, non-zero if there were errors. + """ + logging.info("=" * 80) + logging.info("DRY RUN MODE - Validation Results") + logging.info("=" * 80) + + if validation_result.is_valid(): + logging.info("✓ Configuration validation PASSED") + logging.info("=" * 80) + return 0 + else: + logging.warning("✗ Configuration validation FAILED with the following issues:") + for error in validation_result.errors: + logging.error(f" • {error}") + for warning in validation_result.warnings: + logging.warning(f" • {warning}") + logging.info("=" * 80) + return 1 + + @staticmethod + def _log_section(title: str, values: dict[str, Any]) -> None: + """Log a section of configuration values.""" + logging.info(f"\n{title}:") + for key, value in values.items(): + formatted_key = DryRunReporter._format_key(key) + logging.info(f" {formatted_key}: {value}") + + @staticmethod + def _format_key(key: str) -> str: + """Format a property key for display.""" + if key.startswith("sonar."): + key = key[6:] + key = key.replace(".", " ").replace("_", " ") + key = re.sub(r"([a-z])([A-Z])", r"\1 \2", key) + return key.title() + + +class ValidationResult: + """Holds validation results for coverage reports and configuration.""" + + def __init__(self): + self.errors: list[str] = [] + self.warnings: list[str] = [] + + def add_error(self, message: str) -> None: + """Add a validation error.""" + self.errors.append(message) + + def add_warning(self, message: str) -> None: + """Add a validation warning.""" + self.warnings.append(message) + + def is_valid(self) -> bool: + """Check if validation passed (no errors).""" + return len(self.errors) == 0 + + +class CoverageReportValidator: + """ + Validates coverage reports for format and accessibility. + Provides clear error messages for common issues. + """ + + @staticmethod + def validate_coverage_reports( + coverage_paths: Optional[str], + project_base_dir: str, + validation_result: ValidationResult, + ) -> None: + """ + Validate coverage report paths. + + Args: + coverage_paths: Comma-separated coverage report paths + project_base_dir: Base directory for the project + validation_result: ValidationResult object to populate + """ + if not coverage_paths: + validation_result.add_warning("No coverage report paths specified") + return + + base_path = Path(project_base_dir) + report_paths = [p.strip() for p in coverage_paths.split(",")] + + for report_path in report_paths: + CoverageReportValidator._validate_single_report( + report_path, base_path, validation_result + ) + + @staticmethod + def _validate_single_report( + report_path: str, base_path: Path, validation_result: ValidationResult + ) -> None: + """Validate a single coverage report file.""" + # Resolve relative path + full_path = base_path / report_path if not Path(report_path).is_absolute() else Path(report_path) + + if not full_path.exists(): + validation_result.add_error( + f"Coverage report not found: {report_path} (resolved to {full_path})" + ) + return + + if not full_path.is_file(): + validation_result.add_error( + f"Coverage report is not a file: {report_path} (resolved to {full_path})" + ) + return + + # Check if it's readable + try: + with open(full_path, "r", encoding="utf-8") as f: + f.read(1) # Try to read first byte + except PermissionError: + validation_result.add_error( + f"Coverage report is not readable (permission denied): {report_path}" + ) + return + except UnicodeDecodeError: + validation_result.add_warning( + f"Coverage report may not be text-based (is it in binary format?): {report_path}" + ) + return + except Exception as e: + validation_result.add_error( + f"Error reading coverage report {report_path}: {str(e)}" + ) + return + + try: + with open(full_path, "r", encoding="utf-8") as f: + tree = ET.parse(f) + root = tree.getroot() + if root.tag != "coverage": + validation_result.add_warning( + f"Coverage report root element is '{root.tag}', expected 'coverage' (Cobertura format)" + ) + else: + logging.info(f" ✓ Coverage report is valid Cobertura XML: {report_path}") + except ET.ParseError as e: + validation_result.add_error( + f"Coverage report is not valid XML (Cobertura format): {report_path}\n" + f" Parse error: {str(e)}" + ) + except Exception as e: + validation_result.add_error( + f"Error validating coverage report format: {report_path}\n" + f" Error: {str(e)}" + ) diff --git a/tests/unit/test_configuration_loader.py b/tests/unit/test_configuration_loader.py index a221acc8..bd6659e6 100644 --- a/tests/unit/test_configuration_loader.py +++ b/tests/unit/test_configuration_loader.py @@ -32,6 +32,7 @@ SONAR_SCANNER_APP_VERSION, SONAR_SCANNER_BOOTSTRAP_START_TIME, SONAR_SCANNER_CONNECT_TIMEOUT, + SONAR_SCANNER_DRY_RUN, SONAR_SCANNER_KEYSTORE_PASSWORD, SONAR_SCANNER_RESPONSE_TIMEOUT, SONAR_SCANNER_SKIP_JRE_PROVISIONING, @@ -90,6 +91,7 @@ def test_defaults(self, mock_get_os, mock_get_arch): SONAR_SCANNER_TRUSTSTORE_PASSWORD: "changeit", SONAR_SCANNER_OS: Os.LINUX.value, SONAR_SCANNER_ARCH: Arch.X64.value, + SONAR_SCANNER_DRY_RUN: False, } self.assertDictEqual(configuration, expected_configuration) @@ -156,6 +158,7 @@ def test_load_sonar_project_properties(self, mock_get_os, mock_get_arch): SONAR_SCANNER_TRUSTSTORE_PASSWORD: "changeit", SONAR_SCANNER_OS: Os.LINUX.value, SONAR_SCANNER_ARCH: Arch.X64.value, + SONAR_SCANNER_DRY_RUN: False, } self.assertDictEqual(configuration, expected_configuration) @@ -204,6 +207,7 @@ def test_load_sonar_project_properties_from_custom_path(self, mock_get_os, mock_ SONAR_SCANNER_TRUSTSTORE_PASSWORD: "changeit", SONAR_SCANNER_OS: Os.LINUX.value, SONAR_SCANNER_ARCH: Arch.X64.value, + SONAR_SCANNER_DRY_RUN: False, } self.assertDictEqual(configuration, expected_configuration) @@ -253,6 +257,7 @@ def test_load_pyproject_toml_from_base_dir(self, mock_get_os, mock_get_arch): SONAR_SCANNER_TRUSTSTORE_PASSWORD: "changeit", SONAR_SCANNER_OS: Os.LINUX.value, SONAR_SCANNER_ARCH: Arch.X64.value, + SONAR_SCANNER_DRY_RUN: False, } self.assertDictEqual(configuration, expected_configuration) @@ -305,6 +310,7 @@ def test_load_pyproject_toml_from_toml_path(self, mock_get_os, mock_get_arch): SONAR_SCANNER_ARCH: Arch.X64.value, TOML_PATH: "custom/path", SONAR_SCANNER_JAVA_HEAP_SIZE: "8000Mb", + SONAR_SCANNER_DRY_RUN: False, } self.assertDictEqual(configuration, expected_configuration) @@ -396,6 +402,7 @@ def test_load_coveragerc_properties(self, mock_get_os, mock_get_arch): SONAR_SCANNER_OS: Os.LINUX.value, SONAR_SCANNER_ARCH: Arch.X64.value, SONAR_COVERAGE_EXCLUSIONS: "*/.local/*, /usr/*, utils/tirefire.py", + SONAR_SCANNER_DRY_RUN: False, } self.assertDictEqual(configuration, expected_configuration) diff --git a/tests/unit/test_dry_run.py b/tests/unit/test_dry_run.py new file mode 100644 index 00000000..7322b311 --- /dev/null +++ b/tests/unit/test_dry_run.py @@ -0,0 +1,305 @@ +# +# Sonar Scanner Python +# Copyright (C) 2011-2024 SonarSource SA. +# 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. +# + +from unittest.mock import patch, call +from pyfakefs import fake_filesystem_unittest as pyfakefs + +from pysonar_scanner.__main__ import run_dry_run +from pysonar_scanner.configuration.properties import ( + SONAR_PROJECT_KEY, + SONAR_PROJECT_NAME, + SONAR_ORGANIZATION, + SONAR_SOURCES, + SONAR_TESTS, + SONAR_PYTHON_COVERAGE_REPORT_PATHS, + SONAR_PROJECT_BASE_DIR, + SONAR_HOST_URL, +) +from pysonar_scanner.dry_run_reporter import ( + DryRunReporter, + CoverageReportValidator, + ValidationResult, +) + + +class TestValidationResult: + + def test_valid_when_no_errors(self): + result = ValidationResult() + assert result.is_valid() + assert len(result.errors) == 0 + assert len(result.warnings) == 0 + + def test_invalid_when_errors_present(self): + result = ValidationResult() + result.add_error("Test error") + assert not result.is_valid() + assert len(result.errors) == 1 + + def test_can_add_warnings_without_becoming_invalid(self): + result = ValidationResult() + result.add_warning("Test warning") + assert result.is_valid() + assert len(result.warnings) == 1 + + def test_multiple_errors_and_warnings(self): + result = ValidationResult() + result.add_error("Error 1") + result.add_error("Error 2") + result.add_warning("Warning 1") + assert not result.is_valid() + assert len(result.errors) == 2 + assert len(result.warnings) == 1 + + +class TestDryRunReporter: + + @patch("pysonar_scanner.dry_run_reporter.logging") + def test_report_configuration_logs_all_sections(self, mock_logging): + config = { + SONAR_PROJECT_KEY: "my-project", + SONAR_PROJECT_NAME: "My Project", + SONAR_ORGANIZATION: "my-org", + SONAR_SOURCES: "src", + SONAR_TESTS: "tests", + SONAR_PYTHON_COVERAGE_REPORT_PATHS: "coverage.xml", + SONAR_HOST_URL: "https://sonarqube.example.com", + } + + DryRunReporter.report_configuration(config) + + logged_messages = [str(c) for c in mock_logging.info.call_args_list] + joined = " ".join(logged_messages) + assert "DRY RUN MODE - Configuration Report" in joined + assert "my-project" in joined + assert "My Project" in joined + assert "my-org" in joined + assert "src" in joined + assert "tests" in joined + assert "coverage.xml" in joined + assert "https://sonarqube.example.com" in joined + + @patch("pysonar_scanner.dry_run_reporter.logging") + def test_report_configuration_shows_na_for_missing_values(self, mock_logging): + config = {} + + DryRunReporter.report_configuration(config) + + logged_messages = [str(c) for c in mock_logging.info.call_args_list] + joined = " ".join(logged_messages) + assert "N/A" in joined + + @patch("pysonar_scanner.dry_run_reporter.logging") + def test_report_validation_results_valid(self, mock_logging): + result = ValidationResult() + exit_code = DryRunReporter.report_validation_results(result) + + assert exit_code == 0 + logged_messages = [str(c) for c in mock_logging.info.call_args_list] + joined = " ".join(logged_messages) + assert "PASSED" in joined + + @patch("pysonar_scanner.dry_run_reporter.logging") + def test_report_validation_results_invalid(self, mock_logging): + result = ValidationResult() + result.add_error("Coverage file not found") + exit_code = DryRunReporter.report_validation_results(result) + + assert exit_code == 1 + mock_logging.warning.assert_called() + mock_logging.error.assert_called() + + def test_format_key_handles_camel_case(self): + assert DryRunReporter._format_key("sonar.projectKey") == "Project Key" + assert DryRunReporter._format_key("sonar.projectName") == "Project Name" + + def test_format_key_handles_dotted_paths(self): + assert DryRunReporter._format_key("sonar.host.url") == "Host Url" + assert DryRunReporter._format_key("sonar.python.coverage.reportPaths") == "Python Coverage Report Paths" + + def test_format_key_handles_simple_keys(self): + assert DryRunReporter._format_key("sonar.sources") == "Sources" + assert DryRunReporter._format_key("sonar.organization") == "Organization" + + +class TestCoverageReportValidator(pyfakefs.TestCase): + + def setUp(self): + self.setUpPyfakefs() + + def test_validate_coverage_reports_no_paths(self): + result = ValidationResult() + CoverageReportValidator.validate_coverage_reports(None, ".", result) + + assert result.is_valid() + assert len(result.warnings) == 1 + assert "No coverage report paths specified" in result.warnings[0] + + def test_validate_single_report_file_not_found(self): + self.fs.create_dir("/project") + result = ValidationResult() + + CoverageReportValidator.validate_coverage_reports( + "coverage.xml", "/project", result + ) + + assert not result.is_valid() + assert len(result.errors) == 1 + assert "not found" in result.errors[0] + + @patch("pysonar_scanner.dry_run_reporter.logging") + def test_validate_single_report_valid_cobertura(self, mock_logging): + self.fs.create_dir("/project") + self.fs.create_file("/project/coverage.xml", contents='\n') + result = ValidationResult() + + CoverageReportValidator.validate_coverage_reports( + "coverage.xml", "/project", result + ) + + assert result.is_valid() + assert len(result.warnings) == 0 + logged_messages = [str(c) for c in mock_logging.info.call_args_list] + joined = " ".join(logged_messages) + assert "valid Cobertura XML" in joined + + def test_validate_multiple_coverage_reports(self): + self.fs.create_dir("/project") + self.fs.create_file("/project/coverage1.xml", contents='\n') + self.fs.create_file("/project/coverage2.xml", contents='\n') + result = ValidationResult() + + CoverageReportValidator.validate_coverage_reports( + "coverage1.xml, coverage2.xml", "/project", result + ) + + assert result.is_valid() + + def test_validate_report_not_a_file(self): + self.fs.create_dir("/project") + self.fs.create_dir("/project/coverage.xml") + result = ValidationResult() + + CoverageReportValidator.validate_coverage_reports( + "coverage.xml", "/project", result + ) + + assert not result.is_valid() + assert "not a file" in result.errors[0] + + def test_validate_report_invalid_xml(self): + self.fs.create_dir("/project") + self.fs.create_file("/project/coverage.xml", contents="not valid xml") + result = ValidationResult() + + CoverageReportValidator.validate_coverage_reports( + "coverage.xml", "/project", result + ) + + assert not result.is_valid() + assert "not valid XML" in result.errors[0] + + def test_validate_report_wrong_root_element(self): + self.fs.create_dir("/project") + self.fs.create_file( + "/project/coverage.xml", + contents='\n' + ) + result = ValidationResult() + + CoverageReportValidator.validate_coverage_reports( + "coverage.xml", "/project", result + ) + + assert result.is_valid() + assert len(result.warnings) == 1 + assert "report" in result.warnings[0] + assert "expected 'coverage'" in result.warnings[0] + + def test_validate_mixed_valid_and_missing_reports(self): + self.fs.create_dir("/project") + self.fs.create_file("/project/exists.xml", contents='\n') + result = ValidationResult() + + CoverageReportValidator.validate_coverage_reports( + "exists.xml, missing.xml", "/project", result + ) + + assert not result.is_valid() + assert len(result.errors) == 1 + assert "missing.xml" in result.errors[0] + + +class TestRunDryRun(pyfakefs.TestCase): + + def setUp(self): + self.setUpPyfakefs() + + @patch("pysonar_scanner.__main__.logging") + def test_run_dry_run_no_coverage_reports(self, mock_logging): + self.fs.create_dir("/project") + config = { + SONAR_PROJECT_KEY: "my-project", + SONAR_PROJECT_BASE_DIR: "/project", + } + + exit_code = run_dry_run(config) + + assert exit_code == 0 + + @patch("pysonar_scanner.__main__.logging") + def test_run_dry_run_with_valid_coverage_reports(self, mock_logging): + self.fs.create_dir("/project") + self.fs.create_file( + "/project/coverage.xml", + contents='\n' + ) + config = { + SONAR_PROJECT_KEY: "my-project", + SONAR_PROJECT_BASE_DIR: "/project", + SONAR_PYTHON_COVERAGE_REPORT_PATHS: "coverage.xml", + } + + exit_code = run_dry_run(config) + + assert exit_code == 0 + + @patch("pysonar_scanner.__main__.logging") + def test_run_dry_run_with_missing_coverage_reports(self, mock_logging): + self.fs.create_dir("/project") + config = { + SONAR_PROJECT_KEY: "my-project", + SONAR_PROJECT_BASE_DIR: "/project", + SONAR_PYTHON_COVERAGE_REPORT_PATHS: "coverage.xml", + } + + exit_code = run_dry_run(config) + + assert exit_code == 1 + + @patch("pysonar_scanner.__main__.logging") + def test_run_dry_run_logs_dry_run_mode(self, mock_logging): + self.fs.create_dir("/project") + config = {SONAR_PROJECT_BASE_DIR: "/project"} + + run_dry_run(config) + + mock_logging.info.assert_any_call("Running in DRY RUN mode") + mock_logging.info.assert_any_call("No server connection will be made and no analysis will be submitted") From 3d22547feafe4cb5386854e070a12fa32ccda9d3 Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Wed, 18 Mar 2026 11:26:36 +0100 Subject: [PATCH 3/6] SCANPY-327 Addressed reviewer comments --- src/pysonar_scanner/dry_run_reporter.py | 30 +++++++++---------------- 1 file changed, 10 insertions(+), 20 deletions(-) diff --git a/src/pysonar_scanner/dry_run_reporter.py b/src/pysonar_scanner/dry_run_reporter.py index b742a083..7b4d5976 100644 --- a/src/pysonar_scanner/dry_run_reporter.py +++ b/src/pysonar_scanner/dry_run_reporter.py @@ -80,6 +80,8 @@ def report_validation_results(validation_result: "ValidationResult") -> int: logging.info("=" * 80) if validation_result.is_valid(): + for warning in validation_result.warnings: + logging.warning(f" • {warning}") logging.info("✓ Configuration validation PASSED") logging.info("=" * 80) return 0 @@ -182,26 +184,6 @@ def _validate_single_report( ) return - # Check if it's readable - try: - with open(full_path, "r", encoding="utf-8") as f: - f.read(1) # Try to read first byte - except PermissionError: - validation_result.add_error( - f"Coverage report is not readable (permission denied): {report_path}" - ) - return - except UnicodeDecodeError: - validation_result.add_warning( - f"Coverage report may not be text-based (is it in binary format?): {report_path}" - ) - return - except Exception as e: - validation_result.add_error( - f"Error reading coverage report {report_path}: {str(e)}" - ) - return - try: with open(full_path, "r", encoding="utf-8") as f: tree = ET.parse(f) @@ -212,6 +194,14 @@ def _validate_single_report( ) else: logging.info(f" ✓ Coverage report is valid Cobertura XML: {report_path}") + except PermissionError: + validation_result.add_error( + f"Coverage report is not readable (permission denied): {report_path}" + ) + except UnicodeDecodeError: + validation_result.add_warning( + f"Coverage report may not be text-based (is it in binary format?): {report_path}" + ) except ET.ParseError as e: validation_result.add_error( f"Coverage report is not valid XML (Cobertura format): {report_path}\n" From a5c57bc4e32d39d6311ec851437e6ce2fac60c9a Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Wed, 18 Mar 2026 11:46:19 +0100 Subject: [PATCH 4/6] SCANPY-327 Fixed additional test after rebasing on master. --- tests/unit/test_configuration_loader.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/unit/test_configuration_loader.py b/tests/unit/test_configuration_loader.py index bd6659e6..cf864d1e 100644 --- a/tests/unit/test_configuration_loader.py +++ b/tests/unit/test_configuration_loader.py @@ -363,6 +363,7 @@ def test_load_pyproject_toml_from_toml_path_with_file(self, mock_get_os, mock_ge SONAR_SCANNER_ARCH: Arch.X64.value, TOML_PATH: "custom/path/pyproject.toml", SONAR_SCANNER_JAVA_HEAP_SIZE: "8000Mb", + SONAR_SCANNER_DRY_RUN: False, } self.assertDictEqual(configuration, expected_configuration) From d8301350c6147ac7184b4361f9cb17b4bccfbb16 Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Wed, 18 Mar 2026 12:12:37 +0100 Subject: [PATCH 5/6] MJ/SCANPY-237 Adjusted line length and updated CLI help text. --- CLI_ARGS.md | 2 +- src/pysonar_scanner/configuration/cli.py | 2 +- src/pysonar_scanner/dry_run_reporter.py | 74 ++++++++++++------------ tests/unit/test_dry_run.py | 38 +++--------- 4 files changed, 48 insertions(+), 68 deletions(-) diff --git a/CLI_ARGS.md b/CLI_ARGS.md index bc8955b2..0a0723f8 100644 --- a/CLI_ARGS.md +++ b/CLI_ARGS.md @@ -48,6 +48,7 @@ | Option | Description | | ------ | ----------- | +| `--dry-run`, `--no-dry-run` | Enable dry-run mode to validate configuration without connecting to SonarQube server or submitting analysis. See DRY_RUN_MODE.md for details | | `--skip-jre-provisioning`, `-Dsonar.scanner.skipJreProvisioning` | If provided, the provisioning of the JRE will be skipped | | `--sonar-branch-name`, `-Dsonar.branch.name` | Name of the branch being analyzed | | `--sonar-build-string`, `-Dsonar.buildString` | The string passed with this property will be stored with the analysis and available in the results of api/project_analyses/search, thus allowing you to later identify a specific analysis and obtain its key for use with api/new_code_periods/set on the SPECIFIC_ANALYSIS type | @@ -73,7 +74,6 @@ | `--sonar-scanner-arch`, `-Dsonar.scanner.arch` | Architecture on which the scanner will be running | | `--sonar-scanner-cloud-url`, `-Dsonar.scanner.cloudUrl` | SonarQube Cloud base URL, https://sonarcloud.io for example | | `--sonar-scanner-connect-timeout`, `-Dsonar.scanner.connectTimeout` | Time period to establish connections with the server (in seconds) | -| `--dry-run`, `--no-dry-run` | Enable dry-run mode to validate configuration without connecting to SonarQube server or submitting analysis. See [Dry Run Mode](DRY_RUN_MODE.md) for details. Can also be set via `-Dsonar.scanner.dryRun=true` or `SONAR_SCANNER_DRY_RUN=true` | | `--sonar-scanner-internal-dump-to-file`, `-Dsonar.scanner.internal.dumpToFile` | Filename where the input to the scanner engine will be dumped. Useful for debugging | | `--sonar-scanner-internal-sq-version`, `-Dsonar.scanner.internal.sqVersion` | Emulate the result of the call to get SQ server version. Useful for debugging with --sonar-scanner-internal-dump-to-file | | `--sonar-scanner-java-exe-path`, `-Dsonar.scanner.javaExePath` | If defined, the scanner engine will be run with this JRE | diff --git a/src/pysonar_scanner/configuration/cli.py b/src/pysonar_scanner/configuration/cli.py index f4fe2d52..14398c18 100644 --- a/src/pysonar_scanner/configuration/cli.py +++ b/src/pysonar_scanner/configuration/cli.py @@ -367,7 +367,7 @@ def __create_parser(cls): "--dry-run", action=argparse.BooleanOptionalAction, default=None, - help="Enable dry-run mode to validate configuration without connecting to SonarQube server or submitting analysis", + help="Enable dry-run mode to validate configuration without connecting to SonarQube server or submitting analysis. See DRY_RUN_MODE.md for details", ) jvm_group = parser.add_argument_group("JVM Settings") diff --git a/src/pysonar_scanner/dry_run_reporter.py b/src/pysonar_scanner/dry_run_reporter.py index 7b4d5976..664030c0 100644 --- a/src/pysonar_scanner/dry_run_reporter.py +++ b/src/pysonar_scanner/dry_run_reporter.py @@ -50,24 +50,36 @@ def report_configuration(config: dict[str, Any]) -> None: logging.info("DRY RUN MODE - Configuration Report") logging.info("=" * 80) - DryRunReporter._log_section("Project Configuration", { - SONAR_PROJECT_KEY: config.get(SONAR_PROJECT_KEY), - SONAR_PROJECT_NAME: config.get(SONAR_PROJECT_NAME), - SONAR_ORGANIZATION: config.get(SONAR_ORGANIZATION, "N/A (likely SonarQube Server)"), - }) - - DryRunReporter._log_section("Server Configuration", { - SONAR_HOST_URL: config.get(SONAR_HOST_URL, "N/A"), - }) - - DryRunReporter._log_section("Source Configuration", { - SONAR_SOURCES: config.get(SONAR_SOURCES, "N/A"), - SONAR_TESTS: config.get(SONAR_TESTS, "N/A"), - }) - - DryRunReporter._log_section("Coverage Configuration", { - SONAR_PYTHON_COVERAGE_REPORT_PATHS: config.get(SONAR_PYTHON_COVERAGE_REPORT_PATHS, "N/A"), - }) + DryRunReporter._log_section( + "Project Configuration", + { + SONAR_PROJECT_KEY: config.get(SONAR_PROJECT_KEY), + SONAR_PROJECT_NAME: config.get(SONAR_PROJECT_NAME), + SONAR_ORGANIZATION: config.get(SONAR_ORGANIZATION, "N/A (likely SonarQube Server)"), + }, + ) + + DryRunReporter._log_section( + "Server Configuration", + { + SONAR_HOST_URL: config.get(SONAR_HOST_URL, "N/A"), + }, + ) + + DryRunReporter._log_section( + "Source Configuration", + { + SONAR_SOURCES: config.get(SONAR_SOURCES, "N/A"), + SONAR_TESTS: config.get(SONAR_TESTS, "N/A"), + }, + ) + + DryRunReporter._log_section( + "Coverage Configuration", + { + SONAR_PYTHON_COVERAGE_REPORT_PATHS: config.get(SONAR_PYTHON_COVERAGE_REPORT_PATHS, "N/A"), + }, + ) @staticmethod def report_validation_results(validation_result: "ValidationResult") -> int: @@ -160,28 +172,20 @@ def validate_coverage_reports( report_paths = [p.strip() for p in coverage_paths.split(",")] for report_path in report_paths: - CoverageReportValidator._validate_single_report( - report_path, base_path, validation_result - ) + CoverageReportValidator._validate_single_report(report_path, base_path, validation_result) @staticmethod - def _validate_single_report( - report_path: str, base_path: Path, validation_result: ValidationResult - ) -> None: + def _validate_single_report(report_path: str, base_path: Path, validation_result: ValidationResult) -> None: """Validate a single coverage report file.""" # Resolve relative path full_path = base_path / report_path if not Path(report_path).is_absolute() else Path(report_path) if not full_path.exists(): - validation_result.add_error( - f"Coverage report not found: {report_path} (resolved to {full_path})" - ) + validation_result.add_error(f"Coverage report not found: {report_path} (resolved to {full_path})") return if not full_path.is_file(): - validation_result.add_error( - f"Coverage report is not a file: {report_path} (resolved to {full_path})" - ) + validation_result.add_error(f"Coverage report is not a file: {report_path} (resolved to {full_path})") return try: @@ -195,20 +199,16 @@ def _validate_single_report( else: logging.info(f" ✓ Coverage report is valid Cobertura XML: {report_path}") except PermissionError: - validation_result.add_error( - f"Coverage report is not readable (permission denied): {report_path}" - ) + validation_result.add_error(f"Coverage report is not readable (permission denied): {report_path}") except UnicodeDecodeError: validation_result.add_warning( f"Coverage report may not be text-based (is it in binary format?): {report_path}" ) except ET.ParseError as e: validation_result.add_error( - f"Coverage report is not valid XML (Cobertura format): {report_path}\n" - f" Parse error: {str(e)}" + f"Coverage report is not valid XML (Cobertura format): {report_path}\n" f" Parse error: {str(e)}" ) except Exception as e: validation_result.add_error( - f"Error validating coverage report format: {report_path}\n" - f" Error: {str(e)}" + f"Error validating coverage report format: {report_path}\n" f" Error: {str(e)}" ) diff --git a/tests/unit/test_dry_run.py b/tests/unit/test_dry_run.py index 7322b311..35da3396 100644 --- a/tests/unit/test_dry_run.py +++ b/tests/unit/test_dry_run.py @@ -156,9 +156,7 @@ def test_validate_single_report_file_not_found(self): self.fs.create_dir("/project") result = ValidationResult() - CoverageReportValidator.validate_coverage_reports( - "coverage.xml", "/project", result - ) + CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project", result) assert not result.is_valid() assert len(result.errors) == 1 @@ -170,9 +168,7 @@ def test_validate_single_report_valid_cobertura(self, mock_logging): self.fs.create_file("/project/coverage.xml", contents='\n') result = ValidationResult() - CoverageReportValidator.validate_coverage_reports( - "coverage.xml", "/project", result - ) + CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project", result) assert result.is_valid() assert len(result.warnings) == 0 @@ -186,9 +182,7 @@ def test_validate_multiple_coverage_reports(self): self.fs.create_file("/project/coverage2.xml", contents='\n') result = ValidationResult() - CoverageReportValidator.validate_coverage_reports( - "coverage1.xml, coverage2.xml", "/project", result - ) + CoverageReportValidator.validate_coverage_reports("coverage1.xml, coverage2.xml", "/project", result) assert result.is_valid() @@ -197,9 +191,7 @@ def test_validate_report_not_a_file(self): self.fs.create_dir("/project/coverage.xml") result = ValidationResult() - CoverageReportValidator.validate_coverage_reports( - "coverage.xml", "/project", result - ) + CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project", result) assert not result.is_valid() assert "not a file" in result.errors[0] @@ -209,24 +201,17 @@ def test_validate_report_invalid_xml(self): self.fs.create_file("/project/coverage.xml", contents="not valid xml") result = ValidationResult() - CoverageReportValidator.validate_coverage_reports( - "coverage.xml", "/project", result - ) + CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project", result) assert not result.is_valid() assert "not valid XML" in result.errors[0] def test_validate_report_wrong_root_element(self): self.fs.create_dir("/project") - self.fs.create_file( - "/project/coverage.xml", - contents='\n' - ) + self.fs.create_file("/project/coverage.xml", contents='\n') result = ValidationResult() - CoverageReportValidator.validate_coverage_reports( - "coverage.xml", "/project", result - ) + CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project", result) assert result.is_valid() assert len(result.warnings) == 1 @@ -238,9 +223,7 @@ def test_validate_mixed_valid_and_missing_reports(self): self.fs.create_file("/project/exists.xml", contents='\n') result = ValidationResult() - CoverageReportValidator.validate_coverage_reports( - "exists.xml, missing.xml", "/project", result - ) + CoverageReportValidator.validate_coverage_reports("exists.xml, missing.xml", "/project", result) assert not result.is_valid() assert len(result.errors) == 1 @@ -267,10 +250,7 @@ def test_run_dry_run_no_coverage_reports(self, mock_logging): @patch("pysonar_scanner.__main__.logging") def test_run_dry_run_with_valid_coverage_reports(self, mock_logging): self.fs.create_dir("/project") - self.fs.create_file( - "/project/coverage.xml", - contents='\n' - ) + self.fs.create_file("/project/coverage.xml", contents='\n') config = { SONAR_PROJECT_KEY: "my-project", SONAR_PROJECT_BASE_DIR: "/project", From da4d005395a0afcb499f8e59bb47fe515bef8efd Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Wed, 18 Mar 2026 12:58:00 +0100 Subject: [PATCH 6/6] SCANPY-237 Fixed quality gate issues. --- src/pysonar_scanner/dry_run_reporter.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/pysonar_scanner/dry_run_reporter.py b/src/pysonar_scanner/dry_run_reporter.py index 664030c0..351c4651 100644 --- a/src/pysonar_scanner/dry_run_reporter.py +++ b/src/pysonar_scanner/dry_run_reporter.py @@ -206,9 +206,7 @@ def _validate_single_report(report_path: str, base_path: Path, validation_result ) except ET.ParseError as e: validation_result.add_error( - f"Coverage report is not valid XML (Cobertura format): {report_path}\n" f" Parse error: {str(e)}" + f"Coverage report is not valid XML (Cobertura format): {report_path}\n Parse error: {str(e)}" ) except Exception as e: - validation_result.add_error( - f"Error validating coverage report format: {report_path}\n" f" Error: {str(e)}" - ) + validation_result.add_error(f"Error validating coverage report format: {report_path}\n Error: {str(e)}")