|
26 | 26 | from soda_core.common.env_config_helper import EnvConfigHelper |
27 | 27 | from soda_core.common.exceptions import SodaCoreException, get_exception_stacktrace |
28 | 28 | from soda_core.common.logging_constants import Emoticons, ExtraKeys, soda_logger |
29 | | -from soda_core.common.logs import Location, Logs |
| 29 | +from soda_core.common.logs import Location, Logs, preserve_active_logs |
30 | 30 | from soda_core.common.metadata_types import SamplerType |
31 | 31 | from soda_core.common.soda_cloud_converter import map_sampler_type_from_dto |
32 | 32 | from soda_core.common.soda_cloud_dto import DatasetConfigurationDTO |
@@ -801,6 +801,16 @@ def verify(self) -> CheckCollectionResult: |
801 | 801 | f"collection_id (used to prefix checkPath for backend routing)." |
802 | 802 | ) |
803 | 803 |
|
| 804 | + if self.collection_id and (("." in self.collection_id) or (":" in self.collection_id)): |
| 805 | + raise ValueError( |
| 806 | + f"collection_id {self.collection_id!r} must not contain '.' or ':' (reserved check-path delimiters)" |
| 807 | + ) |
| 808 | + |
| 809 | + if self.wire_source and (("." in self.wire_source) or (":" in self.wire_source)): |
| 810 | + raise ValueError( |
| 811 | + f"wire_source {self.wire_source!r} must not contain '.' or ':' (reserved check-path delimiters)" |
| 812 | + ) |
| 813 | + |
804 | 814 | if self.data_source_impl and self.soda_config.is_running_on_runner: |
805 | 815 | self.data_source_impl.switch_warehouse(self.compute_warehouse, contract_impl=self) |
806 | 816 | data_source: Optional[DataSource] = None |
@@ -846,7 +856,7 @@ def verify(self) -> CheckCollectionResult: |
846 | 856 | # Evaluate the checks |
847 | 857 | for check_impl in self.all_check_impls: |
848 | 858 | if check_impl.skip: |
849 | | - logger.info(f"Skipping evaluation of check at path '{check_impl.path}'") |
| 859 | + logger.info(f"Skipping evaluation of check at path '{check_impl.relative_path}'") |
850 | 860 | check_result: CheckResult = CheckResult( |
851 | 861 | check=check_impl._build_check_info(), outcome=CheckOutcome.EXCLUDED |
852 | 862 | ) |
@@ -1046,6 +1056,30 @@ def build_error_result( |
1046 | 1056 | Used by per-item isolation in ``execute_check_collections``. |
1047 | 1057 | """ |
1048 | 1058 | now = datetime.now(tz=timezone.utc) |
| 1059 | + # Surface the failure instead of only stashing it on ``result.error``. |
| 1060 | + # This is the isolation boundary for ``abort_on_first_error=False`` |
| 1061 | + # callers — always data standards, and any multi-file contract run. |
| 1062 | + # They never re-raise, and the only other reader of ``result.error`` is |
| 1063 | + # the Cloud ``mark_scan_as_failed`` path (gated on a scan_id), so |
| 1064 | + # without this a local run exits non-zero having printed nothing. |
| 1065 | + # |
| 1066 | + # Capture into this placeholder's OWN Logs rather than emitting bare: |
| 1067 | + # constructing a Logs leaves it active, so after the construct phase the |
| 1068 | + # active target is the LAST-constructed collection. A bare emit here |
| 1069 | + # would file this file's error under an unrelated sibling — and ship it |
| 1070 | + # inside that sibling's Cloud log payload. ``preserve_active_logs`` |
| 1071 | + # stops the throwaway target leaking past this call. |
| 1072 | + # No emoticon here: the console formatter already prefixes ERROR records |
| 1073 | + # with one. Yaml parse errors also already carry their own |
| 1074 | + # ", in <file>[line,column]" suffix, so only name the source when the |
| 1075 | + # message does not locate itself. |
| 1076 | + source_description = getattr(yaml_source, "file_path", None) or getattr(yaml_source, "description", None) |
| 1077 | + message = str(exception) or type(exception).__name__ |
| 1078 | + locates_itself = bool(source_description) and str(source_description) in message |
| 1079 | + location = f", in {source_description}" if source_description and not locates_itself else "" |
| 1080 | + with preserve_active_logs(): |
| 1081 | + error_logs = Logs() |
| 1082 | + logger.error(f"{message}{location}") |
1049 | 1083 | # Invariant: this placeholder Contract is never uploaded to Soda Cloud. |
1050 | 1084 | # ``build_error_result`` is only invoked when the YAML failed to parse |
1051 | 1085 | # before a real ``Contract`` could be constructed; the result it |
@@ -1074,7 +1108,10 @@ def build_error_result( |
1074 | 1108 | measurements=[], |
1075 | 1109 | check_results=[], |
1076 | 1110 | sending_results_to_soda_cloud_failed=False, |
1077 | | - log_records=None, |
| 1111 | + # Carries the error line captured above, so the placeholder is |
| 1112 | + # self-diagnosing (a FAILED scan in Cloud no longer ships an empty |
| 1113 | + # log payload) instead of leaving ``result.error`` the only record. |
| 1114 | + log_records=error_logs.get_log_records(), |
1078 | 1115 | post_processing_stages=[], |
1079 | 1116 | ) |
1080 | 1117 | result.error = exception |
@@ -1218,7 +1255,7 @@ def _verify_check_sources_aligned(self, verification_result: "CheckCollectionRes |
1218 | 1255 | # ``result.get_errors()`` with no re-collection. |
1219 | 1256 | for check_result in offending: |
1220 | 1257 | logger.error( |
1221 | | - f"Source mismatch — check '{check_result.check.full_path}' has " |
| 1258 | + f"Source mismatch — check '{check_result.check.check_path}' has " |
1222 | 1259 | f"source={check_result.check.source!r} but parent collection " |
1223 | 1260 | f"declares wire_source={self.wire_source!r}. Skipping Cloud upload to avoid " |
1224 | 1261 | f"a backend-side source-mismatch failure on the whole batch." |
|
0 commit comments