diff --git a/CLI_ARGS.md b/CLI_ARGS.md index a019443d..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 | 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/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" 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..14398c18 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. See DRY_RUN_MODE.md for details", + ) 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..351c4651 --- /dev/null +++ b/src/pysonar_scanner/dry_run_reporter.py @@ -0,0 +1,212 @@ +# +# 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(): + for warning in validation_result.warnings: + logging.warning(f" • {warning}") + 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 + + 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 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 Parse error: {str(e)}" + ) + except Exception as e: + validation_result.add_error(f"Error validating coverage report format: {report_path}\n Error: {str(e)}") diff --git a/tests/unit/test_configuration_loader.py b/tests/unit/test_configuration_loader.py index a221acc8..cf864d1e 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) @@ -357,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) @@ -396,6 +403,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..35da3396 --- /dev/null +++ b/tests/unit/test_dry_run.py @@ -0,0 +1,285 @@ +# +# 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")