Repository: sodadata/soda-core
Pull request #2232: DTL-325: contracts CLI revision part 2
Author: login: nielsn
Created: 2025-04-09T15:45:15Z | Merged: 2025-04-10T21:55:15Z

### Tweaked parameter verification

- Fail fast 
- Improved messages
- Introduced exceptions to communicate failed authentications
- Changed low level request logging for failed requests from `critical` to `debug` to enable it only when running `-v`

### Unblock verification on agent for remote contracts

- Hotfix missing `metadata.source.filepath` value by adding a fixed string for now. Later development will fetch existing metadata along with the contract, so we can reuse it. Right now we cannot pass this information all the way down to the place where the call to Soda Cloud is being made.
- Implement actual feedback required have the proper exit code (`4` vs other values) for remote agent verification.


Diff:
diff --git a/soda-core/src/soda_core/cli/handlers/contract.py b/soda-core/src/soda_core/cli/handlers/contract.py
index 22e9e0260..958295f78 100644
--- a/soda-core/src/soda_core/cli/handlers/contract.py
+++ b/soda-core/src/soda_core/cli/handlers/contract.py
@@ -25,7 +25,7 @@ def handle_verify_contract(
     blocking_timeout_in_minutes: int,
 ) -> ExitCode:
     if exitcode := validate_verify_arguments(
-        contract_file_paths, dataset_identifiers, data_source_file_path, soda_cloud_file_path
+        contract_file_paths, dataset_identifiers, data_source_file_path, soda_cloud_file_path, publish, use_agent
     ):
         return exitcode
 
@@ -42,10 +42,16 @@ def handle_verify_contract(
     ]
 
     if is_using_remote_contract(dataset_identifiers) and soda_cloud_client:
-        contract_yaml_sources += [
-            ContractYamlSource.from_str(soda_cloud_client.fetch_contract_for_dataset(dataset_identifier))
-            for dataset_identifier in dataset_identifiers
-        ]
+        for dataset_identifier in dataset_identifiers:
+            contract = soda_cloud_client.fetch_contract_for_dataset(dataset_identifier)
+            if not contract:
+                soda_logger.error(f"Could not fetch contract for dataset '{dataset_identifier}': skipping verification")
+                continue
+            contract_yaml_sources.append(ContractYamlSource.from_str(contract))
+
+    if len(contract_yaml_sources) == 0:
+        soda_logger.debug("No contracts given. Exiting.")
+        return ExitCode.OK
 
     data_source_yaml_source: Optional[DataSourceYamlSource] = None
 
@@ -64,9 +70,10 @@ def handle_verify_contract(
         dataset_identifier = dataset_identifiers[0]
 
         soda_logger.debug(f"No local data source config, trying to fetch data source config from cloud")
-        data_source_yaml_source = DataSourceYamlSource.from_str(
-            soda_cloud_client.fetch_data_source_configuration_for_dataset(dataset_identifier)
-        )
+        data_source_config = soda_cloud_client.fetch_data_source_configuration_for_dataset(dataset_identifier)
+        if not data_source_config:
+            return ExitCode.LOG_ERRORS
+        data_source_yaml_source = DataSourceYamlSource.from_str(data_source_config)
 
     # TODO: pass the Soda Cloud client directly into subsequent methods
     contract_verification_session_result: ContractVerificationSessionResult = ContractVerificationSession.execute(
@@ -88,13 +95,32 @@ def validate_verify_arguments(
     dataset_identifiers: Optional[list[str]],
     data_source_file_path: Optional[str],
     soda_cloud_file_path: Optional[str],
+    publish: bool,
+    use_agent: bool,
 ) -> Optional[ExitCode]:
+    if publish and not soda_cloud_file_path:
+        soda_logger.error(
+            "A Soda Cloud configuration file is required to use the -p/--publish argument. "
+            "Please provide the '--soda-cloud' argument with a valid configuration file path."
+        )
+        return ExitCode.LOG_ERRORS
+
+    if use_agent and not soda_cloud_file_path:
+        soda_logger.error(
+            "A Soda Cloud configuration file is required to use the -a/--agent argument. "
+            "Please provide the '--soda-cloud' argument with a valid configuration file path."
+        )
+        return ExitCode.LOG_ERRORS
+
     if all_none_or_empty(contract_file_paths, dataset_identifiers):
-        soda_logger.error("At least one of -c/--contract or -d/--dataset value is required.")
+        soda_logger.error("At least one of -c/--contract or -d/--dataset arguments is required.")
         return ExitCode.LOG_ERRORS
 
     if dataset_identifiers and not soda_cloud_file_path:
-        soda_logger.error("A Soda Cloud configuration is required to use the -d/--dataset switch.")
+        soda_logger.error(
+            "A Soda Cloud configuration file is required to use the -d/--dataset argument."
+            "Please provide the '--soda-cloud' argument with a valid configuration file path."
+        )
         return ExitCode.LOG_ERRORS
 
     if all_none_or_empty(dataset_identifiers) and not data_source_file_path:
diff --git a/soda-core/src/soda_core/common/exceptions.py b/soda-core/src/soda_core/common/exceptions.py
index c1c14faef..de92078b8 100644
--- a/soda-core/src/soda_core/common/exceptions.py
+++ b/soda-core/src/soda_core/common/exceptions.py
@@ -1,3 +1,11 @@
+class SodaCloudAuthenticationFailedException(Exception):
+    """Indicates the authentication to Soda Cloud failed."""
+
+
+class InvalidSodaCloudConfigurationException(Exception):
+    """Indicates missing required keys in the Soda Cloud configuration file."""
+
+
 class DataSourceConnectionException(Exception):
     """Base class for all data source connection exceptions."""
 
diff --git a/soda-core/src/soda_core/common/soda_cloud.py b/soda-core/src/soda_core/common/soda_cloud.py
index cf18fb7b4..d072e755a 100644
--- a/soda-core/src/soda_core/common/soda_cloud.py
+++ b/soda-core/src/soda_core/common/soda_cloud.py
@@ -18,6 +18,10 @@
     convert_datetime_to_str,
     convert_str_to_datetime,
 )
+from soda_core.common.exceptions import (
+    InvalidSodaCloudConfigurationException,
+    SodaCloudAuthenticationFailedException,
+)
 from soda_core.common.logging_constants import Emoticons, ExtraKeys, soda_logger
 from soda_core.common.logs import Location, Logs
 from soda_core.common.version import SODA_CORE_VERSION
@@ -88,12 +92,22 @@ def from_yaml_source(
         soda_cloud_yaml_root_object: YamlObject = soda_cloud_yaml_source.parse()
 
         if not soda_cloud_yaml_root_object:
-            logger.error(f"Invalid Soda Cloud config file: No valid YAML object as file content")
+            logger.error("Invalid Soda Cloud config file: No valid YAML object as file content")
             return None
 
         soda_cloud_yaml_object: Optional[YamlObject] = soda_cloud_yaml_root_object.read_object_opt("soda_cloud")
         if not soda_cloud_yaml_object:
-            logger.debug(f"key 'soda_cloud' is required in a Soda Cloud configuration file.")
+            logger.debug("key 'soda_cloud' is required in a Soda Cloud configuration file.")
+
+        if not soda_cloud_yaml_object.has_key("api_key_id"):
+            raise InvalidSodaCloudConfigurationException(
+                f"Missing required 'api_key_id' property in your Soda Cloud configuration."
+            )
+
+        if not soda_cloud_yaml_object.has_key("api_key_secret"):
+            raise InvalidSodaCloudConfigurationException(
+                f"Missing required 'api_key_secret' property in your Soda Cloud configuration."
+            )
 
         return SodaCloud(
             host=soda_cloud_yaml_object.read_string_opt(key="host", default_value="cloud.soda.io"),
@@ -475,40 +489,43 @@ def verify_contract_on_agent(
         self, contract_yaml: ContractYaml, blocking_timeout_in_minutes: int
     ) -> ContractVerificationResult:
         contract_yaml_str_original: str = contract_yaml.contract_yaml_source.yaml_str_original
-        contract_local_file_path: Optional[str] = contract_yaml.contract_yaml_source.file_path
+        contract_local_file_path: Optional[str] = contract_yaml.contract_yaml_source.file_path or "REMOTE"  # TODO
         data_source_name = contract_yaml.data_source
         dataset_prefix = contract_yaml.dataset_prefix
         dataset_name = contract_yaml.dataset
 
+        verification_result = ContractVerificationResult(
+            contract=Contract(
+                data_source_name=data_source_name,
+                dataset_prefix=dataset_prefix,
+                dataset_name=dataset_name,
+                soda_qualified_dataset_name=None,
+                source=YamlFileContentInfo(
+                    local_file_path=contract_local_file_path,
+                    source_content_str=contract_yaml_str_original,
+                    soda_cloud_file_id=None,
+                ),
+            ),
+            data_source=None,
+            data_timestamp=None,
+            started_timestamp=None,
+            ended_timestamp=None,
+            measurements=[],
+            check_results=[],
+            sending_results_to_soda_cloud_failed=False,
+            log_records=None,
+        )
+
         can_publish_and_verify, reason = self.can_publish_and_verify_contract(
             data_source_name, dataset_prefix, dataset_name
         )
         if not can_publish_and_verify:
             if reason is None:
                 logger.error(f"Skipping contract verification because of an error (see logs)")
+                verification_result.sending_results_to_soda_cloud_failed = True
             else:
                 logger.error(f"Skipping contract verification because of insufficient permissions: {reason}")
-            return ContractVerificationResult(
-                contract=Contract(
-                    data_source_name=data_source_name,
-                    dataset_prefix=dataset_prefix,
-                    dataset_name=dataset_name,
-                    soda_qualified_dataset_name=None,
-                    source=YamlFileContentInfo(
-                        local_file_path=contract_local_file_path,
-                        source_content_str=contract_yaml_str_original,
-                        soda_cloud_file_id=None,
-                    ),
-                ),
-                data_source=None,
-                data_timestamp=None,
-                started_timestamp=None,
-                ended_timestamp=None,
-                measurements=[],
-                check_results=[],
-                sending_results_to_soda_cloud_failed=True,
-                log_records=None,
-            )
+            return verification_result
 
         soda_cloud_file_path: str = (
             contract_local_file_path if isinstance(contract_local_file_path, str) else "contract.yml"
@@ -535,108 +552,101 @@ def verify_contract_on_agent(
         scan_id: str = response_json.get("scanId")
 
         log_records: Optional[list[LogRecord]] = None
-        if response.status_code == 200 and scan_id:
-            scan_is_finished, contract_dataset_cloud_url = self._poll_remote_scan_finished(
-                scan_id=scan_id, blocking_timeout_in_minutes=blocking_timeout_in_minutes
-            )
+        if response.status_code != 200:
+            logger.error("Remote contract verification failed.")
+            verification_result.sending_results_to_soda_cloud_failed = True
+            return verification_result
+
+        if not scan_id:
+            logger.warning("Did not receive a Scan ID from Soda Cloud")
+            return verification_result
+
+        scan_is_finished, contract_dataset_cloud_url = self._poll_remote_scan_finished(
+            scan_id=scan_id, blocking_timeout_in_minutes=blocking_timeout_in_minutes
+        )
 
-            logger.debug(f"Asking Soda Cloud the logs of scan {scan_id}")
-            logs_response: Response = self._get_scan_logs(scan_id=scan_id)
-            logger.debug(f"Soda Cloud responded with {json.dumps(dict(logs_response.headers))}\n{logs_response.text}")
-
-            # Start capturing logs here
-            logs: Logs = Logs()
-
-            response_json: dict = logs_response.json()
-            soda_cloud_log_dicts: list[dict] = response_json.get("content")
-            # TODO implement extra page loading if there are more pages of scan logs....
-            # response body: {
-            #   "content": [...],
-            #   "totalElements": 0,
-            #   "totalPages": 0,
-            #   "number": 0,
-            #   "size": 0,
-            #   "last": true,
-            #   "first": true
-            # }
-            if isinstance(soda_cloud_log_dicts, list):
-                # For explanation, see TimestampToCreatedLoggingFilter
-                logging_filter: TimestampToCreatedLoggingFilter = TimestampToCreatedLoggingFilter()
-                logger.addFilter(logging_filter)
-                try:
-                    for soda_cloud_log_dict in soda_cloud_log_dicts:
-                        if isinstance(soda_cloud_log_dict, dict):
-                            extra: dict = {}
-
-                            level_cloud: str = soda_cloud_log_dict.get("level")
-                            level_logrecord: int = logging.getLevelName(level_cloud.upper())
-
-                            timestamp_cloud: str = soda_cloud_log_dict.get("timestamp")
-                            timestamp_datetime: datetime = convert_str_to_datetime(timestamp_cloud)
-                            if isinstance(timestamp_datetime, datetime):
-                                timestamp_logrecord: float = timestamp_datetime.timestamp() + (
-                                    timestamp_datetime.microsecond / 1000000
-                                )
-                                # For explanation, see TimestampToCreatedLoggingFilter
-                                extra[TimestampToCreatedLoggingFilter.TIMESTAMP] = timestamp_logrecord
-
-                            location_dict: Optional[dict] = soda_cloud_log_dict.get(ExtraKeys.LOCATION)
-                            if isinstance(location_dict, dict):
-                                extra[ExtraKeys.LOCATION] = Location(
-                                    file_path=location_dict.get("filePath"),
-                                    line=location_dict.get("line"),
-                                    column=location_dict.get("col"),
-                                )
-
-                            doc: Optional[str] = soda_cloud_log_dict.get(ExtraKeys.DOC)
-                            if doc:
-                                extra[ExtraKeys.DOC] = doc
-
-                            exception: Optional[str] = soda_cloud_log_dict.get(ExtraKeys.EXCEPTION)
-                            if doc:
-                                extra[ExtraKeys.EXCEPTION] = exception
-
-                            logger.log(level=level_logrecord, msg=soda_cloud_log_dict.get("message"), extra=extra)
-                        else:
-                            logger.debug(
-                                f"Expected dict for logs list element, but was {type(soda_cloud_log_dict).__name__}"
+        logger.debug(f"Asking Soda Cloud the logs of scan {scan_id}")
+        logs_response: Response = self._get_scan_logs(scan_id=scan_id)
+        logger.debug(f"Soda Cloud responded with {json.dumps(dict(logs_response.headers))}\n{logs_response.text}")
+
+        # Start capturing logs here
+        logs: Logs = Logs()
+
+        response_json: dict = logs_response.json()
+        soda_cloud_log_dicts: list[dict] = response_json.get("content")
+        # TODO implement extra page loading if there are more pages of scan logs....
+        # response body: {
+        #   "content": [...],
+        #   "totalElements": 0,
+        #   "totalPages": 0,
+        #   "number": 0,
+        #   "size": 0,
+        #   "last": true,
+        #   "first": true
+        # }
+        if isinstance(soda_cloud_log_dicts, list):
+            # For explanation, see TimestampToCreatedLoggingFilter
+            logging_filter: TimestampToCreatedLoggingFilter = TimestampToCreatedLoggingFilter()
+            logger.addFilter(logging_filter)
+            try:
+                for soda_cloud_log_dict in soda_cloud_log_dicts:
+                    if isinstance(soda_cloud_log_dict, dict):
+                        extra: dict = {}
+
+                        level_cloud: str = soda_cloud_log_dict.get("level")
+                        level_logrecord: int = logging.getLevelName(level_cloud.upper())
+
+                        timestamp_cloud: str = soda_cloud_log_dict.get("timestamp")
+                        timestamp_datetime: datetime = convert_str_to_datetime(timestamp_cloud)
+                        if isinstance(timestamp_datetime, datetime):
+                            timestamp_logrecord: float = timestamp_datetime.timestamp() + (
+                                timestamp_datetime.microsecond / 1000000
+                            )
+                            # For explanation, see TimestampToCreatedLoggingFilter
+                            extra[TimestampToCreatedLoggingFilter.TIMESTAMP] = timestamp_logrecord
+
+                        location_dict: Optional[dict] = soda_cloud_log_dict.get(ExtraKeys.LOCATION)
+                        if isinstance(location_dict, dict):
+                            extra[ExtraKeys.LOCATION] = Location(
+                                file_path=location_dict.get("filePath"),
+                                line=location_dict.get("line"),
+                                column=location_dict.get("col"),
                             )
-                finally:
-                    logger.removeFilter(logging_filter)
 
-            elif soda_cloud_log_dicts is None:
-                logger.debug(f"No logs in Soda Cloud response")
-            else:
-                logger.debug(f"Expected dict for logs, but was {type(soda_cloud_log_dicts).__name__}")
+                        doc: Optional[str] = soda_cloud_log_dict.get(ExtraKeys.DOC)
+                        if doc:
+                            extra[ExtraKeys.DOC] = doc
+
+                        exception: Optional[str] = soda_cloud_log_dict.get(ExtraKeys.EXCEPTION)
+                        if doc:
+                            extra[ExtraKeys.EXCEPTION] = exception
 
-            if not scan_is_finished:
-                logger.error(f"Max retries exceeded. " f"Contract verification did not finish yet.")
+                        logger.log(level=level_logrecord, msg=soda_cloud_log_dict.get("message"), extra=extra)
+                    else:
+                        logger.debug(
+                            f"Expected dict for logs list element, but was {type(soda_cloud_log_dict).__name__}"
+                        )
+            finally:
+                logger.removeFilter(logging_filter)
+
+        elif soda_cloud_log_dicts is None:
+            logger.debug(f"No logs in Soda Cloud response")
+        else:
+            logger.debug(f"Expected dict for logs, but was {type(soda_cloud_log_dicts).__name__}")
 
-            if contract_dataset_cloud_url:
-                logger.info(f"See contract dataset on Soda Cloud: {contract_dataset_cloud_url}")
+        if not scan_is_finished:
+            logger.error(f"Max retries exceeded. " f"Contract verification did not finish yet.")
+            verification_result.sending_results_to_soda_cloud_failed = True
 
-            logs.remove_from_root_logger()
-            log_records = logs.records
+        if contract_dataset_cloud_url:
+            logger.info(f"See contract dataset on Soda Cloud: {contract_dataset_cloud_url}")
 
-        return ContractVerificationResult(
-            contract=Contract(
-                data_source_name=data_source_name,
-                dataset_prefix=dataset_prefix,
-                dataset_name=dataset_name,
-                soda_qualified_dataset_name=None,
-                source=None,
-            ),
-            data_source=None,
-            data_timestamp=None,
-            started_timestamp=None,
-            ended_timestamp=None,
-            measurements=[],
-            check_results=[
-                # TODO
-            ],
-            sending_results_to_soda_cloud_failed=True,
-            log_records=log_records,
-        )
+        logs.remove_from_root_logger()
+        log_records = logs.records
+
+        verification_result.log_records = logs.records
+
+        return verification_result
 
     def fetch_contract_for_dataset(self, dataset_identifier: str) -> Optional[str]:
         """Fetch the contract contents for the given dataset identifier.
@@ -651,14 +661,37 @@ def fetch_contract_for_dataset(self, dataset_identifier: str) -> Optional[str]:
         logger.info(f"{Emoticons.SCROLL} Fetching contract from Soda Cloud for dataset '{dataset_identifier}'")
         parsed_identifier = ContractIdentifier.parse(dataset_identifier)
         request = {
-            "type": "sodaCoreGetContractFile",
+            "type": "sodaCoreGetContract",
             "dataset": {
                 "datasource": parsed_identifier.data_source,
                 "prefixes": parsed_identifier.prefixes,
                 "name": parsed_identifier.dataset,
             },
         }
-        response = self._execute_query(request, request_log_name="get_contract_file")
+        response = self._execute_query(request, request_log_name="get_contract")
+
+        response_dict = response.json()
+
+        if response.status_code == 400:
+            error_code = response_dict.get("code")
+
+            if error_code == "contract_not_found":
+                logger.error(
+                    f"No data contract found for dataset '{dataset_identifier}' in Soda Cloud. "
+                    "Please publish a contract for this dataset in Soda Cloud before proceeding."
+                )
+            elif error_code == "datasource_not_found":
+                logger.error(
+                    f"Data source '{parsed_identifier.data_source}' is unknown in Soda Cloud. "
+                    "Please verify the data source name or configure it in Soda Cloud."
+                )
+            elif error_code == "dataset_not_found":
+                logger.error(
+                    f"Dataset'{dataset_identifier}' is unknown in Soda Cloud. "
+                    "Please verify the dataset name or configure it in Soda Cloud."
+                )
+
+            return None
 
         if response.status_code != 200:
             logger.error(
@@ -666,7 +699,6 @@ def fetch_contract_for_dataset(self, dataset_identifier: str) -> Optional[str]:
             )
             return None
 
-        response_dict = response.json()
         return response_dict.get("contents")
 
     def fetch_data_source_configuration_for_dataset(self, dataset_identifier: str) -> Optional[str]:
@@ -812,7 +844,7 @@ def _execute_cqrs_request(
                     is_retry=False,
                 )
             elif response.status_code != 200:
-                logger.critical(
+                logger.debug(
                     f"Soda Cloud error for {request_type} {request_log_name} | status_code:{response.status_code} | "
                     f"X-Soda-Trace-Id:{trace_id} | response_text:{response.text}"
                 )
@@ -822,6 +854,8 @@ def _execute_cqrs_request(
                 )
 
             return response
+        except SodaCloudAuthenticationFailedException:
+            raise
         except Exception as e:
             logger.critical(
                 msg=f"Error while executing Soda Cloud {request_type} {request_log_name}",
@@ -856,7 +890,7 @@ def _execute_rest_get(self, relative_url_path: str, request_log_name: str, is_re
                 relative_url_path=relative_url_path, request_log_name=request_log_name, is_retry=False
             )
         elif response.status_code != 200:
-            logger.critical(
+            logger.debug(
                 f"Soda Cloud error for {request_log_name} | status_code:{response.status_code} | "
                 f"X-Soda-Trace-Id:{trace_id} | response_text:{response.text}"
             )
@@ -881,7 +915,10 @@ def _get_token(self) -> str:
                 url=f"{self.api_url}/command", headers=self.headers, json=login_command, request_log_name="get_token"
             )
             if login_response.status_code != 200:
-                raise AssertionError(f"Soda Cloud login failed {login_response.status_code}. Check credentials.")
+                raise SodaCloudAuthenticationFailedException(
+                    "Soda Cloud authentication failed. The provided API keys are unknown or invalid. "
+                    "Please verify your credentials."
+                )
             login_response_json = login_response.json()
 
             self.token = login_response_json.get("token")
diff --git a/soda-core/tests/soda_core/tests/components/test_cli_contract_handlers.py b/soda-core/tests/soda_core/tests/components/test_cli_contract_handlers.py
index 4bd038ccf..6de9fb480 100644
--- a/soda-core/tests/soda_core/tests/components/test_cli_contract_handlers.py
+++ b/soda-core/tests/soda_core/tests/components/test_cli_contract_handlers.py
@@ -4,6 +4,7 @@
 from soda_core.cli.exit_codes import ExitCode
 from soda_core.cli.handlers.contract import (
     ContractVerificationSession,
+    all_none_or_empty,
     handle_publish_contract,
     handle_test_contract,
     handle_verify_contract,
@@ -16,6 +17,14 @@
 )
 
 
+@pytest.mark.parametrize(
+    "array, expected", [(["a", "b"], False), (["a", None], False), ([None, None], False), ([], True), (None, True)]
+)
+def test_all_none_or_empty(array, expected):
+    is_all_none_or_empty = all_none_or_empty(array)
+    assert is_all_none_or_empty == expected
+
+
 @pytest.mark.parametrize(
     "has_errors, has_failures, cloud_failed, expected_exit_code",
     [
@@ -52,7 +61,24 @@ def test_handle_verify_contract_exit_codes(mock_execute, has_errors, has_failure
     assert exit_code == expected_exit_code
 
 
-def test_handle_verify_contract_returns_exit_code_3_when_using_dataset_names_without_cloud_configuration():
+def test_handle_verify_contract_returns_exit_code_3_when_using_dataset_names_without_cloud_configuration(caplog):
+    exit_code = handle_verify_contract(
+        contract_file_paths=None,
+        dataset_identifiers=["some_dataset"],
+        data_source_file_path="ds.yaml",
+        soda_cloud_file_path=None,
+        publish=False,
+        use_agent=False,
+        blocking_timeout_in_minutes=10,
+    )
+    assert (
+        "A Soda Cloud configuration file is required to use the -d/--dataset argument."
+        "Please provide the '--soda-cloud' argument with a valid configuration file path."
+    ) in caplog.messages
+    assert exit_code == ExitCode.LOG_ERRORS
+
+
+def test_handle_verify_contract_returns_exit_code_3_when_using_publish_without_cloud_configuration(caplog):
     exit_code = handle_verify_contract(
         contract_file_paths=None,
         dataset_identifiers=["some_dataset"],
@@ -63,9 +89,85 @@ def test_handle_verify_contract_returns_exit_code_3_when_using_dataset_names_wit
         blocking_timeout_in_minutes=10,
     )
 
+    assert (
+        "A Soda Cloud configuration file is required to use the -p/--publish argument. "
+        "Please provide the '--soda-cloud' argument with a valid configuration file path."
+    ) in caplog.messages
+    assert exit_code == ExitCode.LOG_ERRORS
+
+
+def test_handle_verify_contract_returns_exit_code_3_when_no_contract_file_paths_or_dataset_identifiers(caplog):
+    exit_code = handle_verify_contract(
+        contract_file_paths=None,
+        dataset_identifiers=None,
+        data_source_file_path="ds.yaml",
+        soda_cloud_file_path="soda-cloud.yaml",
+        publish=True,
+        use_agent=False,
+        blocking_timeout_in_minutes=10,
+    )
+
+    assert "At least one of -c/--contract or -d/--dataset arguments is required." in caplog.messages
+    assert exit_code == ExitCode.LOG_ERRORS
+
+
+def test_handle_verify_contract_returns_exit_code_3_when_no_data_source_configuration_or_dataset_identifiers(caplog):
+    exit_code = handle_verify_contract(
+        contract_file_paths=["contract.yaml"],
+        dataset_identifiers=None,
+        data_source_file_path=None,
+        soda_cloud_file_path="soda-cloud.yaml",
+        publish=True,
+        use_agent=False,
+        blocking_timeout_in_minutes=10,
+    )
+
+    assert "At least one of -ds/--data-source or -d/--dataset value is required." in caplog.messages
     assert exit_code == ExitCode.LOG_ERRORS
 
 
+def test_handle_verify_contract_skips_contract_when_contract_fetching_from_cloud_returns_errors(caplog):
+    with patch("soda_core.cli.handlers.contract.SodaCloud.from_yaml_source") as mock_cloud_from_yaml:
+        mock_cloud = MagicMock()
+        mock_cloud.fetch_contract_for_dataset.return_value = None
+        mock_cloud_from_yaml.return_value = mock_cloud
+
+        _ = handle_verify_contract(
+            contract_file_paths=None,
+            dataset_identifiers=["my/super/awesome/identifier"],
+            data_source_file_path="ds.yaml",
+            soda_cloud_file_path="soda-cloud.yaml",
+            publish=True,
+            use_agent=False,
+            blocking_timeout_in_minutes=10,
+        )
+
+        assert (
+            "Could not fetch contract for dataset 'my/super/awesome/identifier': skipping verification"
+            in caplog.messages
+        )
+
+
+def test_handle_verify_contract_returns_exit_code_0_when_no_valid_remote_contracts_left(caplog):
+    with patch("soda_core.cli.handlers.contract.SodaCloud.from_yaml_source") as mock_cloud_from_yaml:
+        mock_cloud = MagicMock()
+        mock_cloud.fetch_contract_for_dataset.return_value = None
+        mock_cloud_from_yaml.return_value = mock_cloud
+
+        exit_code = handle_verify_contract(
+            contract_file_paths=None,
+            dataset_identifiers=["my/super/awesome/identifier"],
+            data_source_file_path="ds.yaml",
+            soda_cloud_file_path="soda-cloud.yaml",
+            publish=True,
+            use_agent=False,
+            blocking_timeout_in_minutes=10,
+        )
+
+        assert "No contracts given. Exiting." in caplog.messages
+        assert exit_code == ExitCode.OK
+
+
 @pytest.mark.parametrize(
     "has_errors, cloud_failed, expected_exit_code",
     [
diff --git a/soda-core/tests/soda_core/tests/components/test_soda_cloud.py b/soda-core/tests/soda_core/tests/components/test_soda_cloud.py
index be44db87c..bef2b8832 100644
--- a/soda-core/tests/soda_core/tests/components/test_soda_cloud.py
+++ b/soda-core/tests/soda_core/tests/components/test_soda_cloud.py
@@ -237,7 +237,7 @@ def test_verify_contract_on_agent_permission_check():
     )
 
     assert isinstance(res, ContractVerificationResult)
-    assert res.sending_results_to_soda_cloud_failed is True
+    assert res.sending_results_to_soda_cloud_failed is False
     assert res.contract.dataset_name == "CUSTOMERS"
     assert res.contract.data_source_name == "test"
     assert res.contract.dataset_prefix == ["some", "schema"]


Reviews:
user: login: m1n0
body: A few code design remarks, but none blocking a merge if we don't have the time to address them
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T08:29:29Z
user: login: nielsn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T09:15:54Z
user: login: nielsn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T09:19:14Z
user: login: nielsn
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-04-10T09:26:30Z

Review comments:
diff_hunk: @@ -36,10 +36,13 @@ def handle_verify_contract(
     ]
 
     if is_using_remote_contract(dataset_identifiers) and soda_cloud_client:
-        contract_yaml_sources += [
-            YamlSource.from_str(soda_cloud_client.fetch_contract_for_dataset(dataset_identifier))
-            for dataset_identifier in dataset_identifiers
-        ]
+        for dataset_identifier in dataset_identifiers:
+            contract = soda_cloud_client.fetch_contract_for_dataset(dataset_identifier)
+            if not contract:
+                return (
+                    ExitCode.LOG_ERRORS
+                )  # TODO: verify this is OK as it breaks the "multiple contracts" functionality
path: soda-core/src/soda_core/cli/handlers/contract.py
user: login: m1n0
body: do we need to resolve this dilemma since it applies to local verification as well and therefore is a very fundamental decision?
created_at: 2025-04-10T08:10:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -476,40 +490,43 @@ def verify_contract_on_agent(
         self, contract_yaml: ContractYaml, blocking_timeout_in_minutes: int
     ) -> ContractVerificationResult:
         contract_yaml_str_original: str = contract_yaml.contract_yaml_source.yaml_str_original
-        contract_local_file_path: Optional[str] = contract_yaml.contract_yaml_source.file_path
+        contract_local_file_path: Optional[str] = contract_yaml.contract_yaml_source.file_path or "REMOTE"  # TODO
         data_source_name = contract_yaml.data_source
         dataset_prefix = contract_yaml.dataset_prefix
         dataset_name = contract_yaml.dataset
 
+        verification_result = ContractVerificationResult(
+            contract=Contract(
+                data_source_name=data_source_name,
+                dataset_prefix=dataset_prefix,
+                dataset_name=dataset_name,
+                soda_qualified_dataset_name=None,
+                source=YamlFileContentInfo(
+                    local_file_path=contract_local_file_path,
+                    source_content_str=contract_yaml_str_original,
+                    soda_cloud_file_id=None,
+                ),
+            ),
+            data_source=None,
+            data_timestamp=None,
+            started_timestamp=None,
+            ended_timestamp=None,
+            measurements=[],
+            check_results=[],
+            sending_results_to_soda_cloud_failed=False,
+            log_records=None,
+        )
+
         can_publish_and_verify, reason = self.can_publish_and_verify_contract(
             data_source_name, dataset_prefix, dataset_name
         )
         if not can_publish_and_verify:
             if reason is None:
                 logger.error(f"Skipping contract verification because of an error (see logs)")
+                verification_result.sending_results_to_soda_cloud_failed = True
path: soda-core/src/soda_core/common/soda_cloud.py
user: login: m1n0
body: I am not pushing for strict immutable objects but these instance manipulations which significantly change the logic flow ten to become very murky over time. Would it make sense to remove this flag from ContractVerificationResult, create a sublcass of it which would be e.g. CloudContractVerificationResult, control the flow based on class instead of a bool flag and return a new instance of CLoudContractVerificationResult which just takes the non-cloud ContractVerificationResult one into constructor?

The downside is that it would eliminate the need of the flag, making the cloud subclass empty (nothing added compared to the non-cloud one) but it would make the ContractVerificationResult a single purpose class. Wdyt?
created_at: 2025-04-10T08:18:18Z
author_association: CONTRIBUTOR
diff_hunk: @@ -536,108 +553,101 @@ def verify_contract_on_agent(
         scan_id: str = response_json.get("scanId")
 
         log_records: Optional[list[LogRecord]] = None
-        if response.status_code == 200 and scan_id:
-            scan_is_finished, contract_dataset_cloud_url = self._poll_remote_scan_finished(
-                scan_id=scan_id, blocking_timeout_in_minutes=blocking_timeout_in_minutes
-            )
+        if response.status_code != 200:
+            logger.error("Remote contract verification failed.")
+            verification_result.sending_results_to_soda_cloud_failed = True
+            return verification_result
+
+        if not scan_id:
+            logger.warning("Did not receive a Scan ID from Soda Cloud")
+            return verification_result
path: soda-core/src/soda_core/common/soda_cloud.py
user: login: m1n0
body: There's a quite few `return` scenarios here, with quite a lot of logic/setup/datastructures in between, would it make sense to refactor these bits out into separate functions so that the overall flow here is more intuitive to read? e.g. verify contract command dict setup+getting a response could be a candidate that would become a one-liner here, the logging logic below as well, wdyt?
created_at: 2025-04-10T08:28:29Z
author_association: CONTRIBUTOR
diff_hunk: @@ -36,10 +36,13 @@ def handle_verify_contract(
     ]
 
     if is_using_remote_contract(dataset_identifiers) and soda_cloud_client:
-        contract_yaml_sources += [
-            YamlSource.from_str(soda_cloud_client.fetch_contract_for_dataset(dataset_identifier))
-            for dataset_identifier in dataset_identifiers
-        ]
+        for dataset_identifier in dataset_identifiers:
+            contract = soda_cloud_client.fetch_contract_for_dataset(dataset_identifier)
+            if not contract:
+                return (
+                    ExitCode.LOG_ERRORS
+                )  # TODO: verify this is OK as it breaks the "multiple contracts" functionality
path: soda-core/src/soda_core/cli/handlers/contract.py
user: login: nielsn
body: Don't feel strongly about it. I feel we should still have a discussion on the technicalities of "multiple at once", such as the interpretation of exit codes.

Come to think of it, I could probably just log something here and break to the next entry in the loop. 
created_at: 2025-04-10T09:15:54Z
author_association: CONTRIBUTOR
diff_hunk: @@ -476,40 +490,43 @@ def verify_contract_on_agent(
         self, contract_yaml: ContractYaml, blocking_timeout_in_minutes: int
     ) -> ContractVerificationResult:
         contract_yaml_str_original: str = contract_yaml.contract_yaml_source.yaml_str_original
-        contract_local_file_path: Optional[str] = contract_yaml.contract_yaml_source.file_path
+        contract_local_file_path: Optional[str] = contract_yaml.contract_yaml_source.file_path or "REMOTE"  # TODO
         data_source_name = contract_yaml.data_source
         dataset_prefix = contract_yaml.dataset_prefix
         dataset_name = contract_yaml.dataset
 
+        verification_result = ContractVerificationResult(
+            contract=Contract(
+                data_source_name=data_source_name,
+                dataset_prefix=dataset_prefix,
+                dataset_name=dataset_name,
+                soda_qualified_dataset_name=None,
+                source=YamlFileContentInfo(
+                    local_file_path=contract_local_file_path,
+                    source_content_str=contract_yaml_str_original,
+                    soda_cloud_file_id=None,
+                ),
+            ),
+            data_source=None,
+            data_timestamp=None,
+            started_timestamp=None,
+            ended_timestamp=None,
+            measurements=[],
+            check_results=[],
+            sending_results_to_soda_cloud_failed=False,
+            log_records=None,
+        )
+
         can_publish_and_verify, reason = self.can_publish_and_verify_contract(
             data_source_name, dataset_prefix, dataset_name
         )
         if not can_publish_and_verify:
             if reason is None:
                 logger.error(f"Skipping contract verification because of an error (see logs)")
+                verification_result.sending_results_to_soda_cloud_failed = True
path: soda-core/src/soda_core/common/soda_cloud.py
user: login: nielsn
body: This can definitely be cleaner, but I was counting on just using Exceptions to deal with this kind of "information transfer". 

Right now this flag is the only thing that allows us to interpret the result as either exit code `3` or `4`.
In a better world we just catch exceptions and translate those into exit codes when using CLI. Because the flag itself is already semantically incorrect.

So yes: this should be better, but I think regular exception support will fix that already without the need for more subclassing.
created_at: 2025-04-10T09:19:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -536,108 +553,101 @@ def verify_contract_on_agent(
         scan_id: str = response_json.get("scanId")
 
         log_records: Optional[list[LogRecord]] = None
-        if response.status_code == 200 and scan_id:
-            scan_is_finished, contract_dataset_cloud_url = self._poll_remote_scan_finished(
-                scan_id=scan_id, blocking_timeout_in_minutes=blocking_timeout_in_minutes
-            )
+        if response.status_code != 200:
+            logger.error("Remote contract verification failed.")
+            verification_result.sending_results_to_soda_cloud_failed = True
+            return verification_result
+
+        if not scan_id:
+            logger.warning("Did not receive a Scan ID from Soda Cloud")
+            return verification_result
path: soda-core/src/soda_core/common/soda_cloud.py
user: login: nielsn
body: I still only understand half of what's going on here and haven't put any real attention to it (apart from simplifying control flow a bit). 

I can look into splitting it up into some helpers deffo.
created_at: 2025-04-10T09:26:30Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-04-10T21:54:48Z
body: Please retry analysis of this Pull-Request directly on SonarQube Cloud
author_association: NONE