Skip to content

Commit c50a8d8

Browse files
mivdsclaudepre-commit-ci[bot]
authored
OBSL-1025: emit metrics[] in scan-results payload when measurements present (#2774)
* docs(obsl-1013): discovery implementation plan Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * docs(obsl-1013): revise plan — DatasetIdentifier.from_object, per-object DQN via dialect hooks Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(discovery): DatasetIdentifier.from_object builds dialect-correct DQNs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(discovery): DiscoveryRun discovers, filters, maps to DQNs Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(discovery): DQN-only v4 payload builder + sender Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(discovery): handle_discover_data_source handler Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(discovery): soda data-source discover CLI command Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * test(discovery): integration snapshot of DQN-only v4 payload Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * style(discovery): apply isort + black (pre-commit) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * refactor(discovery): push include/exclude to SQL LIKE filters; drop fnmatch Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * feat(cloud): emit metrics[] in scan-results payload when measurements present (OBSL-1025) Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> * fix(discovery): open data source connection in discover handler; add handler-level test (OBSL-1013) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix(test): scope discover-handler lifecycle test to postgres (OBSL-1013) The handler discovers with unscoped prefixes, which doesn't surface the test table on databricks/sparkdf shared metastores. The test verifies the handler's connection open/close lifecycle, which is dialect-independent, so one dialect suffices. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * chore: retrigger CI (SonarCloud analysis crash) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: tighten comments and docs (OBSL-1013) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: tighten comments and docs (OBSL-1025) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Stamp scan id and honor SODA_SCAN_DEFINITION in discovery (OBSL-1035) - Add `"scanId": os.environ.get("SODA_SCAN_ID", None)` to build_discovery_payload - Extract resolve_scan_definition_name helper with precedence: CLI arg > SODA_SCAN_DEFINITION env > default - Use resolve_scan_definition_name in handle_discover_data_source - TDD: 5 new unit tests covering scanId env set/unset and all 3 resolution precedence levels Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Send required scan timestamps and hasErrors on discovery results (OBSL-1013) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Exclude system schemas from discovery, ported from soda-library (OBSL-1013) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Exit RESULTS_NOT_SENT_TO_CLOUD for pre-send discovery failures (OBSL-1013) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: forward-facing comments Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * docs: forward-facing comments Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Report discovery early failures to Soda Cloud via mark_scan_as_failed Previously the discovery CLI exited RESULTS_NOT_SENT_TO_CLOUD (4) at every failure stage without sending anything to Soda Cloud, so for managed scans the launcher's fallback marked the scan failed with a single generic line and the engine's diagnostics were lost. Now the handler parses the Soda Cloud config first (without it nothing can reach Cloud: exit 4, launcher fallback covers visibility) and captures the run's log records with a Logs collector. For the later stages (data source YAML unparseable, connection failure, discovery query failure) it calls the new report_scan_execution_failure helper: with SODA_SCAN_ID set it marks the pre-created Cloud scan FAILED with the full log records and exits LOG_ERRORS (3, failure already visible on Cloud); if that call is rejected or raises it exits 4 so the launcher fallback still fires. Ad-hoc runs (no scan id) exit LOG_ERRORS without sending, matching contract verification. A rejected results upload keeps exiting 4, unchanged. The helper lives in soda_core.cli.handlers.failure_reporting as a stable import point: the soda-profiling and soda-metric-monitoring flows in soda-extensions will reuse it. mark_scan_as_failed now returns a success bool so callers can fall back to exit-code-based visibility; existing callers ignore the return value. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Harden discovery catch-all and surface rejected mark_scan_as_failed calls Follow-up to the discovery failure-reporting flow: - handle_discover_data_source: an unexpected exception after the query phase (payload build, connection close, an exception escaping the results send) previously propagated to the CLI top-level handler and exited LOG_ERRORS with nothing sent to Soda Cloud — a stuck managed scan. A catch-all now routes it through report_scan_execution_failure. The Logs collector is also constructed before the opening info line so the run's first record reaches the failure payload. - Contract flows adopt the mark_scan_as_failed success bool: both the per-file (CheckCollectionImpl.verify) and session-level combined-upload mark sites now set sending_results_to_soda_cloud_failed when Cloud rejects the mark, so the exit code goes > 3 and the launcher fallback marks the scan failed instead of a silent LOG_ERRORS. The verify_api.py mark site is left as-is: it sits in an except block that re-raises, the exception maps to LOG_ERRORS in the CLI handler, and there is no result object to flag — adopting the bool there would change the API's raise contract. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Inject resolved dependencies into the discovery handler; centralize resolution and failure mapping The discovery handler now receives a DataSourceImpl and a required SodaCloud instead of file paths. Parsing/creation moves to reusable utilities in soda_core.cli.handlers.dependencies (resolve_soda_cloud, resolve_data_source), and the exit-code/failure-report mapping lives in one wiring-level construct (run_with_failure_reporting) that owns the Logs collector so resolution failures are captured too. Handlers signal engine failures by raising (ScanExecutionFailedException when already logged); a rejected results upload returns RESULTS_NOT_SENT_TO_CLOUD directly. Behavior contract (stage table) unchanged. Handler tests inject fakes directly; resolution utilities and the exit mapping get their own focused tests (incl. the cloud-first reorder proof and the raising parse shapes). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Polish dependency resolution: note log-level asymmetry, annotate response Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Add local (no Soda Cloud) flow for soda data-source discover Without -sc, discovery now runs locally: the discovered DQNs are printed to the console (one per line, plus a summary count) and nothing is registered in Soda Cloud. The Cloud flow with -sc is unchanged. Local failures follow ad-hoc semantics — logged and mapped to LOG_ERRORS; there is no scan lifecycle and no failure reporting. One guard in the wiring, before choosing the flow: SODA_SCAN_ID set without -sc is a launcher misconfiguration, not a local run — it exits RESULTS_NOT_SENT_TO_CLOUD so the launcher's fallback marks the scan failed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Discover views, warn on ignored local flag, make scan definition name mandatory Three follow-ups to the discover command: - DiscoveryRun now passes object_types explicitly (TABLE, VIEW, MATERIALIZED_VIEW): the metadata query's None default is tables-only for backward compatibility, which silently dropped views — v3 discovery had no type filter. Unknown types still coerce to TABLE and are included, as before. - The local flow warns when --scan-definition-name is provided without -sc: it names the Soda Cloud scan definition and has nothing to apply to in a local run. - The Cloud flow's scan definition name is mandatory — CLI arg > SODA_SCAN_DEFINITION env, no more per-data-source default (an implicit name would silently register a new scan definition on typos or missing config). The name resolves inside the wrapped command, so a missing name takes the standard failure mapping: managed scans get marked failed with the captured logs and exit 3; ad-hoc runs exit 3. The handler now takes the resolved name as a required parameter. The launcher always passes --scan-definition-name, so managed runs are unaffected. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Centralize failure logging in run_with_failure_reporting New ScanExecutionFailedException convention: handlers never log failures and never catch-log-rethrow — the exception carries a user-facing message for expected/validation failures, and unexpected exceptions propagate raw. run_with_failure_reporting is the single logging site, distinguishing only the log form: SEFE logs the clean message (no traceback), everything else logs with the traceback. Both arms then report via report_scan_execution_failure with the captured records (the wrapper's failure line is logged before the records are captured, so it lands in the report). Accordingly: the discovery handler's catch-log-rethrow block is gone (raw propagation, traceback logged by the wrapper) and resolve_scan_definition_name no longer pre-logs — its message moved into the exception. The local flow is untouched: it has no wrapper, so the local handler keeps its own single catch-log → LOG_ERRORS. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Unify resolvers on the exception flow; purify run_with_failure_reporting All CLI dependency resolvers now share one contract: expected/unusable configuration shapes (missing flag, unparseable/invalid config, validation rejection) raise ScanExecutionFailedException carrying the user-facing message — nothing logged at the raise site — while genuinely unexpected failures (e.g. ImportError from a missing plugin) propagate raw so the caller logs them with the traceback, preserving the deliberate traceback-for-data-source-creation asymmetry. run_with_failure_reporting sheds all construction knowledge: it takes the already-constructed reporting channel (soda_cloud) and a zero-arg command, and owns only the Logs lifecycle, the two log-form arms (SEFE clean, everything else with traceback) and failure reporting. The discover wiring resolves the reporting channel first in a small guard (unusable → exit 4, nothing can be reported); the data source and the mandatory scan definition name resolve inside the wrapped command so their failures take the standard mark-with-logs mapping. The local flow adopts the same exception contract with ad-hoc semantics (log → LOG_ERRORS). One nuance: cloud-resolution failure records are no longer captured by the wrapper's Logs — they were never deliverable anyway. ScanExecutionFailedException now extends SodaCoreException (audited: the only 'except SodaCoreException' site wraps contract-verification query execution, which the discover SEFE path never crosses), and SodaCoreException's docstring states its real role as the package-wide base class. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Type the from_object parameters Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address end-state review: YAML parse errors take the clean path - Both resolvers add YamlParserException to their expected-shape catch tuples: a YAML syntax error and a nonexistent file path — the two most common user mistakes — raise it from the parsing layer and now map to a clean ScanExecutionFailedException message instead of a raw traceback. The tuples are symmetric across the two resolvers. - Root fix in SodaCloud.from_yaml_source: a config missing the top-level 'soda_cloud' key now raises InvalidSodaCloudConfigurationException (same contract as the sibling api_key checks) instead of logging a debug line and crashing on a None dereference. - exit_with_code is annotated NoReturn so bindings after guard except arms (e.g. soda_cloud in the discover wiring) are provably assigned. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Move the insert-scan-results transport onto SodaCloud behind a typed DTO send_discovery_results reached into SodaCloud._execute_command from the discovery module; the transport now lives where it belongs: - SodaCoreInsertScanResultsDTO (soda_cloud_dto): TypedDict mirroring the backend SodaCoreInsertScanResultsCommand field set — required keys are the backend's @NotNull/@notempty fields plus the type discriminator, everything else optional via a total=False split (PEP 563-safe __required_keys__). Intended shared shape for all result-inserting flows; profiling adopts it in soda-extensions. - SodaCloud.insert_scan_results(payload) -> bool: owns the _execute_command call, same accepted-bool contract as mark_scan_as_failed. The contract flow keeps its richer send_check_collection_results (response post-processing) — deliberate, converged separately. - build_discovery_payload returns the DTO; send_discovery_results is deleted; the discovery handler calls soda_cloud.insert_scan_results. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address Sonar findings: redundant catches, single-invocation exception tests, wiring complexity - dependencies.py: drop pydantic ValidationError from the resolver catch tuples — it subclasses ValueError in pydantic v2, so the tuple entry was dead; the coverage is now noted in the resolver docstrings. - test_cli_dependencies.py / test_cli_data_source_handlers.py: hoist path construction and mock setup out of pytest.raises blocks so only the call under test can throw; every assertion is kept. - cli.py: extract _resolve_soda_cloud_for_discovery_or_exit and _discover_data_source_locally from the discover wiring to bring its cognitive complexity under the threshold; behavior unchanged. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Final polish: Fabric system schemas, single local logging site, co-located resolvers - FabricSqlDialect.is_system_schema extends (not replaces) the base information_schema exclusion: discovery now includes views, so Fabric was the one dialect where INFORMATION_SCHEMA objects would surface as discoverable datasets. - The local discovery handler no longer catch-logs: failures propagate raw and the local wiring — the single logging site — maps them to LOG_ERRORS, so connection-open failures are no longer mislabeled as query failures and the wiring's generic arm is reachable again. - resolve_scan_definition_name moves to handlers/dependencies.py next to the other resolvers (its tests move to test_cli_dependencies.py). - discovery_payload reads the scan id through EnvConfigHelper, the canonical accessor. - The DTO's type discriminator is Literal["sodaCoreInsertScanResults"]. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Thread the failure-reporting collector into the wrapped command run_with_failure_reporting owns a Logs() collector and reports its records on failure, but until now the wrapped command received nothing. A command that constructs its own inner Logs (e.g. via execute_check_collections with logs=None building per-impl Logs) displaced the wrapper's collector, so records emitted downstream never reached the wrapper's failure report. The command now receives the wrapper's own logs, so a command running a check-collection session can thread it through (each impl built with logs=logs) and keep the failure report complete — matching the shared-logs pattern #2779 relies on. Discovery constructs no inner Logs (DiscoveryRun emits via soda_logger, which already lands in the active wrapper collector), so its wiring accepts and ignores the logs argument. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Address review (#2774): serialise metrics explicitly, drop defensive getattr - payload["metrics"] is attached after the payload's own to_jsonnable wrap, so Decimal/datetime/timedelta values in measurement dicts previously stayed JSON-safe only via the debug-log to_jsonnable side effect at the send site. Run them through to_jsonnable explicitly and pin the contract with a json.dumps test over Decimal/datetime values. - measurement_dicts is a declared field on CheckCollectionResult (default []), so use direct attribute access like the sibling accumulations rather than getattr(..., None). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix SonarCloud reliability finding: keep payload dict-typed to_jsonnable returns `object`, so `payload = to_jsonnable({...})` made payload statically `object` and Sonar flagged the metrics subscript-assign as "not subscriptable" (reliability C). Build the dict first (typed `dict`) and normalize it in place — to_jsonnable mutates and returns the same object, so behavior is identical and the `# type: ignore` is no longer needed. Also avoid a float-equality assertion smell in the metrics test. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
1 parent af0e982 commit c50a8d8

3 files changed

Lines changed: 135 additions & 29 deletions

File tree

soda-core/src/soda_core/check_collections/base.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,11 @@ class CheckCollectionResult:
9090
# producing real output (used by ``execute_check_collections`` per-item
9191
# error isolation). Real verifications leave this as None.
9292
error: Optional[BaseException] = None
93+
# Raw measurement dicts emitted as ``metrics: [...]`` in the scan-results
94+
# payload. Populated by metric-monitoring callers; contracts leave this empty.
95+
# list[dict] rather than list[Measurement] so callers can attach pre-serialised
96+
# cloud-shape dicts without depending on the engine's internal Measurement class.
97+
measurement_dicts: list[dict] = field(default_factory=list)
9398

9499
def get_logs(self) -> list[str]:
95100
return [r.getMessage() for r in self.log_records] if self.log_records else []

soda-core/src/soda_core/common/soda_cloud.py

Lines changed: 49 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -1686,35 +1686,55 @@ def _build_check_collection_results_json_dict(
16861686
ingestion_mode = VerificationIngestionMode.PARTIAL
16871687
break
16881688

1689-
return to_jsonnable( # type: ignore
1690-
{
1691-
"scanId": os.environ.get("SODA_SCAN_ID", None),
1692-
"definitionName": _build_scan_definition_name(head, scan_definition_suffix=scan_definition_suffix),
1693-
"defaultDataSource": head.data_source.name if head.data_source else None,
1694-
"defaultDataSourceProperties": {"type": head.data_source.type} if head.data_source else None,
1695-
"dataTimestamp": head.data_timestamp,
1696-
"scanStartTimestamp": min(started_timestamps) if started_timestamps else head.started_timestamp,
1697-
"scanEndTimestamp": max(ended_timestamps) if ended_timestamps else head.ended_timestamp,
1698-
"hasErrors": any(r.has_errors for r in results),
1699-
"hasWarns": any(r.is_warned for r in results),
1700-
"hasFailures": any(r.is_failed for r in results),
1701-
# Empty checks list serialises as ``None`` to match the legacy
1702-
# combined-builder behaviour the backend already accepts.
1703-
"checks": checks if checks else None,
1704-
"logs": logs,
1705-
"sourceOwner": "soda-core",
1706-
# ``contract`` is contract-handler-routing on the backend.
1707-
# Non-null forces the contract ingestion path; null lets the
1708-
# routing fall through to scan-def-type dispatch for
1709-
# non-contract subtypes.
1710-
"contract": (
1711-
_build_contract_cloud_json_dict(head.check_collection) if wire_source == "soda-contract" else None
1712-
),
1713-
"postProcessingStages": post_processing_stages,
1714-
"resultsIngestionMode": ingestion_mode.value,
1715-
"tokenUsage": token_usage,
1716-
}
1717-
)
1689+
all_measurement_dicts: list[dict] = []
1690+
for r in results:
1691+
if r.measurement_dicts:
1692+
all_measurement_dicts.extend(r.measurement_dicts)
1693+
1694+
payload: dict = {
1695+
"scanId": os.environ.get("SODA_SCAN_ID", None),
1696+
"definitionName": _build_scan_definition_name(head, scan_definition_suffix=scan_definition_suffix),
1697+
"defaultDataSource": head.data_source.name if head.data_source else None,
1698+
"defaultDataSourceProperties": {"type": head.data_source.type} if head.data_source else None,
1699+
"dataTimestamp": head.data_timestamp,
1700+
"scanStartTimestamp": min(started_timestamps) if started_timestamps else head.started_timestamp,
1701+
"scanEndTimestamp": max(ended_timestamps) if ended_timestamps else head.ended_timestamp,
1702+
"hasErrors": any(r.has_errors for r in results),
1703+
"hasWarns": any(r.is_warned for r in results),
1704+
"hasFailures": any(r.is_failed for r in results),
1705+
# Empty checks list serialises as ``None`` to match the legacy
1706+
# combined-builder behaviour the backend already accepts.
1707+
"checks": checks if checks else None,
1708+
"logs": logs,
1709+
"sourceOwner": "soda-core",
1710+
# ``contract`` is contract-handler-routing on the backend.
1711+
# Non-null forces the contract ingestion path; null lets the
1712+
# routing fall through to scan-def-type dispatch for
1713+
# non-contract subtypes.
1714+
"contract": (
1715+
_build_contract_cloud_json_dict(head.check_collection) if wire_source == "soda-contract" else None
1716+
),
1717+
"postProcessingStages": post_processing_stages,
1718+
"resultsIngestionMode": ingestion_mode.value,
1719+
"tokenUsage": token_usage,
1720+
}
1721+
# Normalize Decimal/datetime/tuple values to JSON-safe forms in place
1722+
# (to_jsonnable mutates the dict and returns it); keeps ``payload`` a dict so
1723+
# the ``metrics`` subscript-assign below is on a known-subscriptable type.
1724+
to_jsonnable(payload)
1725+
1726+
# Emit ``metrics`` only when non-empty: contract-only payloads must omit
1727+
# the key entirely (not null, not []). Run the measurement dicts through
1728+
# ``to_jsonnable`` explicitly: they are attached after the payload's own
1729+
# ``to_jsonnable`` wrap above and can carry Decimal/datetime/timedelta
1730+
# values, so serialising them here (rather than relying on the debug-log
1731+
# side effect at the send site) is what keeps the send JSON-safe.
1732+
# ``remove_null_values_in_dicts`` stays at its default True, matching the
1733+
# null-stripping the payload dict already gets.
1734+
if all_measurement_dicts:
1735+
payload["metrics"] = to_jsonnable(all_measurement_dicts)
1736+
1737+
return payload
17181738

17191739

17201740
def _build_contract_cloud_json_dict(contract: Contract):
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
"""``metrics: [...]`` plumbing in the scan-results payload."""
2+
3+
from __future__ import annotations
4+
5+
import json
6+
from datetime import datetime, timezone
7+
from decimal import Decimal
8+
9+
from soda_core.common.soda_cloud import _build_check_collection_results_json_dict
10+
from soda_core.contracts.contract_verification import (
11+
CheckCollectionStatus,
12+
Contract,
13+
ContractVerificationResult,
14+
DataSource,
15+
YamlFileContentInfo,
16+
)
17+
18+
19+
def _make_result(measurement_dicts=None) -> ContractVerificationResult:
20+
now = datetime.now(tz=timezone.utc)
21+
result = ContractVerificationResult(
22+
check_collection=Contract(
23+
data_source_name="test_ds",
24+
dataset_prefix=[],
25+
dataset_name="ORDERS",
26+
soda_qualified_dataset_name="test_ds/ORDERS",
27+
source=YamlFileContentInfo(source_content_str=None, local_file_path="fake.yml"),
28+
),
29+
data_source=DataSource(name="test_ds", type="postgres"),
30+
data_timestamp=now,
31+
started_timestamp=now,
32+
ended_timestamp=now,
33+
status=CheckCollectionStatus.PASSED,
34+
measurements=[],
35+
check_results=[],
36+
sending_results_to_soda_cloud_failed=False,
37+
log_records=[],
38+
post_processing_stages=[],
39+
)
40+
if measurement_dicts is not None:
41+
result.measurement_dicts = measurement_dicts
42+
return result
43+
44+
45+
def test_contracts_only_payload_omits_metrics_key():
46+
"""Without measurement_dicts there must be no ``metrics`` key at all
47+
(``metrics: null`` or ``metrics: []`` are both wrong)."""
48+
result = _make_result()
49+
payload = _build_check_collection_results_json_dict([result])
50+
51+
assert (
52+
"metrics" not in payload
53+
), f"'metrics' key must be absent from contracts-only payload, got: {list(payload.keys())}"
54+
55+
56+
def test_measurement_dicts_present_emits_metrics_array():
57+
"""When measurement_dicts are attached the payload must contain ``metrics: [those dicts]``."""
58+
measurement_dicts = [
59+
{"identity": "m1", "value": 10},
60+
{"identity": "m2", "value": 20},
61+
]
62+
result = _make_result(measurement_dicts=measurement_dicts)
63+
payload = _build_check_collection_results_json_dict([result])
64+
65+
assert "metrics" in payload, f"'metrics' key missing from payload. Keys: {list(payload.keys())}"
66+
assert payload["metrics"] == measurement_dicts, f"Expected metrics={measurement_dicts}, got {payload['metrics']}"
67+
68+
69+
def test_metrics_are_pre_serialised_and_json_dumpable():
70+
"""Measurement dicts are attached after the payload's own to_jsonnable wrap,
71+
so they must be run through to_jsonnable explicitly — a realistic dict with
72+
Decimal/datetime values must round-trip through json.dumps without a helper."""
73+
now = datetime(2026, 7, 20, 8, 30, 0, tzinfo=timezone.utc)
74+
measurement_dicts = [{"identity": "m1", "value": Decimal("10.5"), "dataTimestamp": now}]
75+
result = _make_result(measurement_dicts=measurement_dicts)
76+
payload = _build_check_collection_results_json_dict([result])
77+
78+
# Would raise TypeError if Decimal/datetime slipped through unconverted.
79+
dumped = json.loads(json.dumps(payload))
80+
assert abs(dumped["metrics"][0]["value"] - 10.5) < 1e-9
81+
assert isinstance(dumped["metrics"][0]["dataTimestamp"], str)

0 commit comments

Comments
 (0)