From 1f2edbcd66152014ea647fb420581e5de3bb628a Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Tue, 17 Mar 2026 15:49:48 +0100 Subject: [PATCH 01/12] 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 02/12] 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 03/12] 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 04/12] 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 05/12] 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 06/12] 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)}") From 73765a78b8c2e806f3b2312a80a0013b1fbb1c3b Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Thu, 19 Mar 2026 09:48:39 +0100 Subject: [PATCH 07/12] SCANPY-237 Removed redundancy in the output (validation results in dry run mode) --- src/pysonar_scanner/dry_run_reporter.py | 11 +++++++++-- tests/unit/test_dry_run.py | 5 ++--- 2 files changed, 11 insertions(+), 5 deletions(-) diff --git a/src/pysonar_scanner/dry_run_reporter.py b/src/pysonar_scanner/dry_run_reporter.py index 351c4651..6335c586 100644 --- a/src/pysonar_scanner/dry_run_reporter.py +++ b/src/pysonar_scanner/dry_run_reporter.py @@ -92,8 +92,10 @@ def report_validation_results(validation_result: "ValidationResult") -> int: logging.info("=" * 80) if validation_result.is_valid(): + for info in validation_result.infos: + logging.info(f"✓ {info}") for warning in validation_result.warnings: - logging.warning(f" • {warning}") + logging.warning(f"• {warning}") logging.info("✓ Configuration validation PASSED") logging.info("=" * 80) return 0 @@ -130,6 +132,7 @@ class ValidationResult: def __init__(self): self.errors: list[str] = [] self.warnings: list[str] = [] + self.infos: list[str] = [] def add_error(self, message: str) -> None: """Add a validation error.""" @@ -139,6 +142,10 @@ def add_warning(self, message: str) -> None: """Add a validation warning.""" self.warnings.append(message) + def add_info(self, message: str) -> None: + """Add a validation info message.""" + self.infos.append(message) + def is_valid(self) -> bool: """Check if validation passed (no errors).""" return len(self.errors) == 0 @@ -197,7 +204,7 @@ def _validate_single_report(report_path: str, base_path: Path, validation_result f"Coverage report root element is '{root.tag}', expected 'coverage' (Cobertura format)" ) else: - logging.info(f" ✓ Coverage report is valid Cobertura XML: {report_path}") + validation_result.add_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: diff --git a/tests/unit/test_dry_run.py b/tests/unit/test_dry_run.py index 35da3396..2242b66d 100644 --- a/tests/unit/test_dry_run.py +++ b/tests/unit/test_dry_run.py @@ -172,9 +172,8 @@ def test_validate_single_report_valid_cobertura(self, mock_logging): 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 + assert len(result.infos) == 1 + assert "valid Cobertura XML" in result.infos[0] def test_validate_multiple_coverage_reports(self): self.fs.create_dir("/project") From 347b012c45b756743ebcd835fba105ec47db6032 Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Thu, 19 Mar 2026 09:51:13 +0100 Subject: [PATCH 08/12] SCANPY-237 Removed docstrings to match codebase pattern. --- src/pysonar_scanner/dry_run_reporter.py | 34 ------------------------- 1 file changed, 34 deletions(-) diff --git a/src/pysonar_scanner/dry_run_reporter.py b/src/pysonar_scanner/dry_run_reporter.py index 6335c586..e175bf4f 100644 --- a/src/pysonar_scanner/dry_run_reporter.py +++ b/src/pysonar_scanner/dry_run_reporter.py @@ -36,16 +36,8 @@ 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) @@ -83,10 +75,6 @@ def report_configuration(config: dict[str, Any]) -> None: @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) @@ -110,7 +98,6 @@ def report_validation_results(validation_result: "ValidationResult") -> int: @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) @@ -118,7 +105,6 @@ def _log_section(title: str, values: dict[str, Any]) -> None: @staticmethod def _format_key(key: str) -> str: - """Format a property key for display.""" if key.startswith("sonar."): key = key[6:] key = key.replace(".", " ").replace("_", " ") @@ -127,50 +113,31 @@ def _format_key(key: str) -> str: class ValidationResult: - """Holds validation results for coverage reports and configuration.""" - def __init__(self): self.errors: list[str] = [] self.warnings: list[str] = [] self.infos: 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 add_info(self, message: str) -> None: - """Add a validation info message.""" self.infos.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 @@ -183,7 +150,6 @@ def validate_coverage_reports( @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) From 9f804878308ed617c819dfd0e8164afce1618f20 Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Thu, 19 Mar 2026 12:09:30 +0100 Subject: [PATCH 09/12] SCANPY-237 Improved documentation and output, especially regarding error reporting. Minor refactoring. --- DRY_RUN_MODE.md | 110 ++++++++---------------- src/pysonar_scanner/__main__.py | 4 +- src/pysonar_scanner/dry_run_reporter.py | 20 +++-- tests/unit/test_dry_run.py | 26 ++---- 4 files changed, 59 insertions(+), 101 deletions(-) diff --git a/DRY_RUN_MODE.md b/DRY_RUN_MODE.md index 3090927a..26542aeb 100644 --- a/DRY_RUN_MODE.md +++ b/DRY_RUN_MODE.md @@ -7,6 +7,32 @@ The Pysonar scanner supports a **dry-run mode** that helps you troubleshoot conf - Validating configuration properties - Debugging analysis failures related to configuration +## Use Cases + +### 1. Validating Configuration Before First Analysis + +Use dry-run mode to verify your configuration is correct before running a full analysis. This is especially useful when setting up new projects or making configuration changes: + +```bash +pysonar \ + --token "myToken" \ + --project-key "my:project" \ + --sonar-python-coverage-report-paths "coverage/cobertura.xml" \ + --dry-run +``` + +Or with minimal configuration: + +```bash +pysonar --dry-run -v +``` + +This validates all configuration sources (pyproject.toml, environment variables, CLI arguments) and checks that coverage report paths are valid before submitting any analysis. This also helps ensure that environment variables, CLI arguments, and configuration files are being read correctly. + +### 2. Troubleshooting Failed Analysis + +If an analysis fails, use dry-run mode to quickly identify configuration issues without waiting for a full analysis. This is especially useful for catching problems with configuration files, environment variables, or required properties before attempting a full analysis. + ## Enabling Dry Run Mode To run the scanner in dry-run mode, add the `--dry-run` flag: @@ -79,13 +105,15 @@ DRY RUN MODE - Validation Results ## Coverage Report Validation -The scanner validates coverage reports by checking: +The scanner performs basic validation of 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 +3. **XML format** - Validates that the file can be parsed as valid XML 4. **Root element** - Checks that XML root element is `` (expected Cobertura format) +Note: This is a basic sanity check and does not perform full schema validation of the Cobertura format. + ### Example: Coverage Report Validation Output Successful validation: @@ -93,22 +121,21 @@ Successful validation: ``` Coverage Report Paths: coverage.xml -✓ Coverage report is valid Cobertura XML: coverage.xml +✓ Coverage report check passed: coverage.xml ``` Missing file error: ``` ✗ Configuration validation FAILED with the following issues: - • Coverage report not found: coverage.xml (resolved to /project/coverage.xml) +✗ 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) +✗ Coverage report is not valid XML (Cobertura format): coverage.xml (Parse error: XML not well-formed (invalid token)) ``` ## Exit Codes @@ -116,64 +143,6 @@ Invalid format error: - **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 @@ -199,20 +168,17 @@ 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) +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 +- Verify the coverage report was generated correctly with your coverage tool +- Check the coverage tool documentation for the proper output format ### Issue: Wrong root element @@ -223,8 +189,8 @@ 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` +- For Python projects using coverage.py, generate the report with: `coverage xml` +- Check that your coverage tool is configured to output Cobertura XML format ## Integration with CI/CD diff --git a/src/pysonar_scanner/__main__.py b/src/pysonar_scanner/__main__.py index 25e1b981..266fa09e 100644 --- a/src/pysonar_scanner/__main__.py +++ b/src/pysonar_scanner/__main__.py @@ -140,10 +140,8 @@ def run_dry_run(config: dict[str, Any]) -> int: 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) + validation_result = CoverageReportValidator.validate_coverage_reports(coverage_paths, project_base_dir) return DryRunReporter.report_validation_results(validation_result) diff --git a/src/pysonar_scanner/dry_run_reporter.py b/src/pysonar_scanner/dry_run_reporter.py index e175bf4f..0924bc24 100644 --- a/src/pysonar_scanner/dry_run_reporter.py +++ b/src/pysonar_scanner/dry_run_reporter.py @@ -47,7 +47,7 @@ def report_configuration(config: dict[str, Any]) -> None: { 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)"), + SONAR_ORGANIZATION: config.get(SONAR_ORGANIZATION, "N/A"), }, ) @@ -90,9 +90,9 @@ def report_validation_results(validation_result: "ValidationResult") -> int: else: logging.warning("✗ Configuration validation FAILED with the following issues:") for error in validation_result.errors: - logging.error(f" • {error}") + logging.error(f"✗ {error}") for warning in validation_result.warnings: - logging.warning(f" • {warning}") + logging.warning(f"⚠ {warning}") logging.info("=" * 80) return 1 @@ -136,11 +136,11 @@ class CoverageReportValidator: def validate_coverage_reports( coverage_paths: Optional[str], project_base_dir: str, - validation_result: ValidationResult, - ) -> None: + ) -> ValidationResult: + validation_result = ValidationResult() if not coverage_paths: validation_result.add_warning("No coverage report paths specified") - return + return validation_result base_path = Path(project_base_dir) report_paths = [p.strip() for p in coverage_paths.split(",")] @@ -148,6 +148,8 @@ def validate_coverage_reports( for report_path in report_paths: CoverageReportValidator._validate_single_report(report_path, base_path, validation_result) + return validation_result + @staticmethod def _validate_single_report(report_path: str, base_path: Path, validation_result: ValidationResult) -> None: # Resolve relative path @@ -170,7 +172,7 @@ def _validate_single_report(report_path: str, base_path: Path, validation_result f"Coverage report root element is '{root.tag}', expected 'coverage' (Cobertura format)" ) else: - validation_result.add_info(f"Coverage report is valid Cobertura XML: {report_path}") + validation_result.add_info(f"Coverage report check passed: {report_path}") except PermissionError: validation_result.add_error(f"Coverage report is not readable (permission denied): {report_path}") except UnicodeDecodeError: @@ -179,7 +181,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 Parse error: {str(e)}" + f"Coverage report is not valid XML (Cobertura format): {report_path} (Parse error: {str(e)})" ) except Exception as e: - validation_result.add_error(f"Error validating coverage report format: {report_path}\n Error: {str(e)}") + validation_result.add_error(f"Error validating coverage report format: {report_path} (Error: {str(e)})") diff --git a/tests/unit/test_dry_run.py b/tests/unit/test_dry_run.py index 2242b66d..4a25c4ec 100644 --- a/tests/unit/test_dry_run.py +++ b/tests/unit/test_dry_run.py @@ -145,8 +145,7 @@ def setUp(self): self.setUpPyfakefs() def test_validate_coverage_reports_no_paths(self): - result = ValidationResult() - CoverageReportValidator.validate_coverage_reports(None, ".", result) + result = CoverageReportValidator.validate_coverage_reports(None, ".") assert result.is_valid() assert len(result.warnings) == 1 @@ -154,9 +153,8 @@ def test_validate_coverage_reports_no_paths(self): def test_validate_single_report_file_not_found(self): self.fs.create_dir("/project") - result = ValidationResult() - CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project", result) + result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") assert not result.is_valid() assert len(result.errors) == 1 @@ -166,31 +164,28 @@ def test_validate_single_report_file_not_found(self): 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) + result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") assert result.is_valid() assert len(result.warnings) == 0 assert len(result.infos) == 1 - assert "valid Cobertura XML" in result.infos[0] + assert "Coverage report check passed" in result.infos[0] 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) + result = CoverageReportValidator.validate_coverage_reports("coverage1.xml, coverage2.xml", "/project") 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) + result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") assert not result.is_valid() assert "not a file" in result.errors[0] @@ -198,9 +193,8 @@ def test_validate_report_not_a_file(self): 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) + result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") assert not result.is_valid() assert "not valid XML" in result.errors[0] @@ -208,9 +202,8 @@ def test_validate_report_invalid_xml(self): 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) + result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") assert result.is_valid() assert len(result.warnings) == 1 @@ -220,9 +213,8 @@ def test_validate_report_wrong_root_element(self): 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) + result = CoverageReportValidator.validate_coverage_reports("exists.xml, missing.xml", "/project") assert not result.is_valid() assert len(result.errors) == 1 From 84f231dde0e4f34b3c9fe53457fc08b87c46dcac Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Thu, 19 Mar 2026 14:58:43 +0100 Subject: [PATCH 10/12] SCANPY-237 Improved unit tests --- tests/unit/test_dry_run.py | 172 +++++++++++++++++++++++-------------- 1 file changed, 109 insertions(+), 63 deletions(-) diff --git a/tests/unit/test_dry_run.py b/tests/unit/test_dry_run.py index 4a25c4ec..86f456d8 100644 --- a/tests/unit/test_dry_run.py +++ b/tests/unit/test_dry_run.py @@ -17,9 +17,10 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # +import unittest +from unittest.mock import patch -from unittest.mock import patch, call -from pyfakefs import fake_filesystem_unittest as pyfakefs +import pyfakefs.fake_filesystem_unittest as pyfakefs from pysonar_scanner.__main__ import run_dry_run from pysonar_scanner.configuration.properties import ( @@ -39,37 +40,43 @@ ) -class TestValidationResult: +class TestValidationResult(unittest.TestCase): def test_valid_when_no_errors(self): result = ValidationResult() - assert result.is_valid() - assert len(result.errors) == 0 - assert len(result.warnings) == 0 + self.assertTrue(result.is_valid()) + self.assertEqual(len(result.errors), 0) + self.assertEqual(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 + self.assertFalse(result.is_valid()) + self.assertEqual(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 + self.assertTrue(result.is_valid()) + self.assertEqual(len(result.warnings), 1) + + def test_can_add_infos(self): + result = ValidationResult() + result.add_info("Test info") + self.assertTrue(result.is_valid()) + self.assertEqual(len(result.infos), 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 + self.assertFalse(result.is_valid()) + self.assertEqual(len(result.errors), 2) + self.assertEqual(len(result.warnings), 1) -class TestDryRunReporter: +class TestDryRunReporter(unittest.TestCase): @patch("pysonar_scanner.dry_run_reporter.logging") def test_report_configuration_logs_all_sections(self, mock_logging): @@ -87,56 +94,69 @@ def test_report_configuration_logs_all_sections(self, mock_logging): 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 + self.assertIn("DRY RUN MODE - Configuration Report", joined) + self.assertIn("my-project", joined) + self.assertIn("My Project", joined) + self.assertIn("my-org", joined) + self.assertIn("src", joined) + self.assertIn("tests", joined) + self.assertIn("coverage.xml", joined) + self.assertIn("https://sonarqube.example.com", 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) + DryRunReporter.report_configuration({}) logged_messages = [str(c) for c in mock_logging.info.call_args_list] joined = " ".join(logged_messages) - assert "N/A" in joined + self.assertIn("N/A", 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 + self.assertEqual(exit_code, 0) logged_messages = [str(c) for c in mock_logging.info.call_args_list] joined = " ".join(logged_messages) - assert "PASSED" in joined + self.assertIn("PASSED", joined) + + @patch("pysonar_scanner.dry_run_reporter.logging") + def test_report_validation_results_valid_with_infos_and_warnings(self, mock_logging): + result = ValidationResult() + result.add_info("Coverage report check passed: coverage.xml") + result.add_warning("No tests directory specified") + exit_code = DryRunReporter.report_validation_results(result) + + self.assertEqual(exit_code, 0) + logged_messages = [str(c) for c in mock_logging.info.call_args_list] + self.assertIn("PASSED", " ".join(logged_messages)) + mock_logging.warning.assert_called() @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") + result.add_warning("Unexpected root element") exit_code = DryRunReporter.report_validation_results(result) - assert exit_code == 1 + self.assertEqual(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" + self.assertEqual(DryRunReporter._format_key("sonar.projectKey"), "Project Key") + self.assertEqual(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" + self.assertEqual(DryRunReporter._format_key("sonar.host.url"), "Host Url") + self.assertEqual( + 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" + self.assertEqual(DryRunReporter._format_key("sonar.sources"), "Sources") + self.assertEqual(DryRunReporter._format_key("sonar.organization"), "Organization") class TestCoverageReportValidator(pyfakefs.TestCase): @@ -144,33 +164,39 @@ class TestCoverageReportValidator(pyfakefs.TestCase): def setUp(self): self.setUpPyfakefs() - def test_validate_coverage_reports_no_paths(self): + def test_validate_no_paths(self): result = CoverageReportValidator.validate_coverage_reports(None, ".") - assert result.is_valid() - assert len(result.warnings) == 1 - assert "No coverage report paths specified" in result.warnings[0] + self.assertTrue(result.is_valid()) + self.assertEqual(len(result.warnings), 1) + self.assertIn("No coverage report paths specified", result.warnings[0]) + + def test_validate_empty_string_paths(self): + result = CoverageReportValidator.validate_coverage_reports("", ".") + + self.assertTrue(result.is_valid()) + self.assertEqual(len(result.warnings), 1) + self.assertIn("No coverage report paths specified", result.warnings[0]) def test_validate_single_report_file_not_found(self): self.fs.create_dir("/project") result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") - assert not result.is_valid() - assert len(result.errors) == 1 - assert "not found" in result.errors[0] + self.assertFalse(result.is_valid()) + self.assertEqual(len(result.errors), 1) + self.assertIn("not found", result.errors[0]) - @patch("pysonar_scanner.dry_run_reporter.logging") - def test_validate_single_report_valid_cobertura(self, mock_logging): + def test_validate_single_report_valid_cobertura(self): self.fs.create_dir("/project") self.fs.create_file("/project/coverage.xml", contents='\n') result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") - assert result.is_valid() - assert len(result.warnings) == 0 - assert len(result.infos) == 1 - assert "Coverage report check passed" in result.infos[0] + self.assertTrue(result.is_valid()) + self.assertEqual(len(result.warnings), 0) + self.assertEqual(len(result.infos), 1) + self.assertIn("Coverage report check passed", result.infos[0]) def test_validate_multiple_coverage_reports(self): self.fs.create_dir("/project") @@ -179,7 +205,7 @@ def test_validate_multiple_coverage_reports(self): result = CoverageReportValidator.validate_coverage_reports("coverage1.xml, coverage2.xml", "/project") - assert result.is_valid() + self.assertTrue(result.is_valid()) def test_validate_report_not_a_file(self): self.fs.create_dir("/project") @@ -187,8 +213,8 @@ def test_validate_report_not_a_file(self): result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") - assert not result.is_valid() - assert "not a file" in result.errors[0] + self.assertFalse(result.is_valid()) + self.assertIn("not a file", result.errors[0]) def test_validate_report_invalid_xml(self): self.fs.create_dir("/project") @@ -196,8 +222,8 @@ def test_validate_report_invalid_xml(self): result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") - assert not result.is_valid() - assert "not valid XML" in result.errors[0] + self.assertFalse(result.is_valid()) + self.assertIn("not valid XML", result.errors[0]) def test_validate_report_wrong_root_element(self): self.fs.create_dir("/project") @@ -205,10 +231,30 @@ def test_validate_report_wrong_root_element(self): result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") - assert result.is_valid() - assert len(result.warnings) == 1 - assert "report" in result.warnings[0] - assert "expected 'coverage'" in result.warnings[0] + self.assertTrue(result.is_valid()) + self.assertEqual(len(result.warnings), 1) + self.assertIn("report", result.warnings[0]) + self.assertIn("expected 'coverage'", result.warnings[0]) + + def test_validate_report_permission_denied(self): + self.fs.create_dir("/project") + self.fs.create_file("/project/coverage.xml", contents='\n') + self.fs.chmod("/project/coverage.xml", mode=0o000, force_unix_mode=True) + + result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") + + self.assertFalse(result.is_valid()) + self.assertIn("permission denied", result.errors[0]) + + def test_validate_report_binary_content(self): + self.fs.create_dir("/project") + self.fs.create_file("/project/coverage.xml", contents=b"\x80\x81\x82\x83\xff\xfe") + + result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") + + has_unicode_warning = any("binary format" in w for w in result.warnings) + has_xml_error = any("not valid XML" in e for e in result.errors) + self.assertTrue(has_unicode_warning or has_xml_error) def test_validate_mixed_valid_and_missing_reports(self): self.fs.create_dir("/project") @@ -216,9 +262,9 @@ def test_validate_mixed_valid_and_missing_reports(self): result = CoverageReportValidator.validate_coverage_reports("exists.xml, missing.xml", "/project") - assert not result.is_valid() - assert len(result.errors) == 1 - assert "missing.xml" in result.errors[0] + self.assertFalse(result.is_valid()) + self.assertEqual(len(result.errors), 1) + self.assertIn("missing.xml", result.errors[0]) class TestRunDryRun(pyfakefs.TestCase): @@ -236,7 +282,7 @@ def test_run_dry_run_no_coverage_reports(self, mock_logging): exit_code = run_dry_run(config) - assert exit_code == 0 + self.assertEqual(exit_code, 0) @patch("pysonar_scanner.__main__.logging") def test_run_dry_run_with_valid_coverage_reports(self, mock_logging): @@ -250,7 +296,7 @@ def test_run_dry_run_with_valid_coverage_reports(self, mock_logging): exit_code = run_dry_run(config) - assert exit_code == 0 + self.assertEqual(exit_code, 0) @patch("pysonar_scanner.__main__.logging") def test_run_dry_run_with_missing_coverage_reports(self, mock_logging): @@ -263,7 +309,7 @@ def test_run_dry_run_with_missing_coverage_reports(self, mock_logging): exit_code = run_dry_run(config) - assert exit_code == 1 + self.assertEqual(exit_code, 1) @patch("pysonar_scanner.__main__.logging") def test_run_dry_run_logs_dry_run_mode(self, mock_logging): From 9ba447473277bcda35ddc07d817a14c47b9b47dd Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Thu, 19 Mar 2026 15:17:16 +0100 Subject: [PATCH 11/12] SCANPY-237 Improved precision in one test. --- tests/unit/test_dry_run.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/tests/unit/test_dry_run.py b/tests/unit/test_dry_run.py index 86f456d8..db6e07f7 100644 --- a/tests/unit/test_dry_run.py +++ b/tests/unit/test_dry_run.py @@ -252,9 +252,10 @@ def test_validate_report_binary_content(self): result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") - has_unicode_warning = any("binary format" in w for w in result.warnings) - has_xml_error = any("not valid XML" in e for e in result.errors) - self.assertTrue(has_unicode_warning or has_xml_error) + self.assertTrue(result.is_valid()) + self.assertEqual(len(result.warnings), 1) + self.assertIn("binary format", result.warnings[0]) + self.assertEqual(len(result.errors), 0) def test_validate_mixed_valid_and_missing_reports(self): self.fs.create_dir("/project") From 2488e3ffe0c1b11bca73bf7ec8c2de52bdb3ec1f Mon Sep 17 00:00:00 2001 From: Marc Jasper Date: Thu, 19 Mar 2026 15:23:48 +0100 Subject: [PATCH 12/12] SCANPY-237 Excluded one test when running on Windows. --- tests/unit/test_dry_run.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/unit/test_dry_run.py b/tests/unit/test_dry_run.py index db6e07f7..95e13f88 100644 --- a/tests/unit/test_dry_run.py +++ b/tests/unit/test_dry_run.py @@ -17,6 +17,7 @@ # along with this program; if not, write to the Free Software Foundation, # Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. # +import platform import unittest from unittest.mock import patch @@ -236,6 +237,7 @@ def test_validate_report_wrong_root_element(self): self.assertIn("report", result.warnings[0]) self.assertIn("expected 'coverage'", result.warnings[0]) + @unittest.skipIf(platform.system() == "Windows", "Unix permissions not supported on Windows") def test_validate_report_permission_denied(self): self.fs.create_dir("/project") self.fs.create_file("/project/coverage.xml", contents='\n')