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..26542aeb --- /dev/null +++ b/DRY_RUN_MODE.md @@ -0,0 +1,241 @@ +# 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 + +## 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: + +```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 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. **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: + +``` +Coverage Report Paths: 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) +``` + +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 + +## 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` + +### 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 with your coverage tool +- Check the coverage tool documentation for the 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 +- 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 + +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..266fa09e 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,20 @@ 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) + + coverage_paths = config.get(SONAR_PYTHON_COVERAGE_REPORT_PATHS) + project_base_dir = config.get(SONAR_PROJECT_BASE_DIR, ".") + validation_result = CoverageReportValidator.validate_coverage_reports(coverage_paths, project_base_dir) + + 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..0924bc24 --- /dev/null +++ b/src/pysonar_scanner/dry_run_reporter.py @@ -0,0 +1,187 @@ +# +# 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: + @staticmethod + def report_configuration(config: dict[str, Any]) -> None: + 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"), + }, + ) + + 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: + logging.info("=" * 80) + logging.info("DRY RUN MODE - Validation Results") + 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.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: + 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: + 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: + def __init__(self): + self.errors: list[str] = [] + self.warnings: list[str] = [] + self.infos: list[str] = [] + + def add_error(self, message: str) -> None: + self.errors.append(message) + + def add_warning(self, message: str) -> None: + self.warnings.append(message) + + def add_info(self, message: str) -> None: + self.infos.append(message) + + def is_valid(self) -> bool: + return len(self.errors) == 0 + + +class CoverageReportValidator: + @staticmethod + def validate_coverage_reports( + coverage_paths: Optional[str], + project_base_dir: str, + ) -> ValidationResult: + validation_result = ValidationResult() + if not coverage_paths: + validation_result.add_warning("No coverage report paths specified") + return validation_result + + 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) + + return validation_result + + @staticmethod + def _validate_single_report(report_path: str, base_path: Path, validation_result: ValidationResult) -> None: + # 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: + 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: + 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} (Parse error: {str(e)})" + ) + except Exception as e: + validation_result.add_error(f"Error validating coverage report format: {report_path} (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..95e13f88 --- /dev/null +++ b/tests/unit/test_dry_run.py @@ -0,0 +1,325 @@ +# +# 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 platform +import unittest +from unittest.mock import patch + +import pyfakefs.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(unittest.TestCase): + + def test_valid_when_no_errors(self): + result = ValidationResult() + 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") + 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") + 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") + self.assertFalse(result.is_valid()) + self.assertEqual(len(result.errors), 2) + self.assertEqual(len(result.warnings), 1) + + +class TestDryRunReporter(unittest.TestCase): + + @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) + 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): + DryRunReporter.report_configuration({}) + + logged_messages = [str(c) for c in mock_logging.info.call_args_list] + joined = " ".join(logged_messages) + 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) + + self.assertEqual(exit_code, 0) + logged_messages = [str(c) for c in mock_logging.info.call_args_list] + joined = " ".join(logged_messages) + 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) + + self.assertEqual(exit_code, 1) + mock_logging.warning.assert_called() + mock_logging.error.assert_called() + + def test_format_key_handles_camel_case(self): + 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): + 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): + self.assertEqual(DryRunReporter._format_key("sonar.sources"), "Sources") + self.assertEqual(DryRunReporter._format_key("sonar.organization"), "Organization") + + +class TestCoverageReportValidator(pyfakefs.TestCase): + + def setUp(self): + self.setUpPyfakefs() + + def test_validate_no_paths(self): + result = CoverageReportValidator.validate_coverage_reports(None, ".") + + 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") + + self.assertFalse(result.is_valid()) + self.assertEqual(len(result.errors), 1) + self.assertIn("not found", result.errors[0]) + + 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") + + 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") + self.fs.create_file("/project/coverage1.xml", contents='\n') + self.fs.create_file("/project/coverage2.xml", contents='\n') + + result = CoverageReportValidator.validate_coverage_reports("coverage1.xml, coverage2.xml", "/project") + + self.assertTrue(result.is_valid()) + + def test_validate_report_not_a_file(self): + self.fs.create_dir("/project") + self.fs.create_dir("/project/coverage.xml") + + result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") + + 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") + self.fs.create_file("/project/coverage.xml", contents="not valid xml") + + result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") + + 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") + self.fs.create_file("/project/coverage.xml", contents='\n') + + result = CoverageReportValidator.validate_coverage_reports("coverage.xml", "/project") + + self.assertTrue(result.is_valid()) + self.assertEqual(len(result.warnings), 1) + 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') + 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") + + 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") + self.fs.create_file("/project/exists.xml", contents='\n') + + result = CoverageReportValidator.validate_coverage_reports("exists.xml, missing.xml", "/project") + + self.assertFalse(result.is_valid()) + self.assertEqual(len(result.errors), 1) + self.assertIn("missing.xml", 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) + + self.assertEqual(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) + + self.assertEqual(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) + + self.assertEqual(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")