From 52ed704352f9ca1a3445e0a4d8fcb7edf2ff63f4 Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Wed, 13 May 2026 15:25:45 -0700 Subject: [PATCH 1/4] add metadata, description support --- .../low_level_wrappers/test_results.py | 11 ++ .../_tests/resources/test_test_results.py | 12 ++ .../_tests/sift_types/test_results.py | 141 ++++++++++++++++++ .../lib/sift_client/sift_types/test_report.py | 64 +++++++- .../util/test_results/context_manager.py | 53 ++++++- 5 files changed, 272 insertions(+), 9 deletions(-) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/test_results.py b/python/lib/sift_client/_internal/low_level_wrappers/test_results.py index ac4896190..b98670208 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/test_results.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/test_results.py @@ -320,6 +320,14 @@ def simulate_update_test_measurement_response( updates["string_expected_value"] = proto.string_bounds.expected_value else: updates["string_expected_value"] = None + if "description" in update_mask_paths: + updates["description"] = proto.description if proto.description else None + if "metadata" in update_mask_paths: + from sift_client.util.metadata import metadata_proto_to_dict + + updates["metadata"] = metadata_proto_to_dict(proto.metadata) if proto.metadata else None # type: ignore[arg-type] + if "channel_names" in update_mask_paths: + updates["channel_names"] = list(proto.channel_names) if proto.channel_names else None return existing.model_copy(update=updates) @@ -1259,6 +1267,9 @@ def _measurement_create_from_simulated( unit=simulated.unit, numeric_bounds=simulated.numeric_bounds, string_expected_value=simulated.string_expected_value, + description=simulated.description, + metadata=simulated.metadata, + channel_names=simulated.channel_names, ) diff --git a/python/lib/sift_client/_tests/resources/test_test_results.py b/python/lib/sift_client/_tests/resources/test_test_results.py index 3ade1d1c3..1600b2e10 100644 --- a/python/lib/sift_client/_tests/resources/test_test_results.py +++ b/python/lib/sift_client/_tests/resources/test_test_results.py @@ -64,6 +64,9 @@ def compare_test_measurement_fields(simulated: TestMeasurement, actual: TestMeas assert simulated.boolean_value == actual.boolean_value assert simulated.passed == actual.passed assert simulated.timestamp == actual.timestamp + assert simulated.description == actual.description + assert simulated.metadata == actual.metadata + assert simulated.channel_names == actual.channel_names def test_client_binding(sift_client): @@ -281,6 +284,9 @@ def test_create_test_measurements(self, sift_client, tmp_path): unit="Celsius", passed=True, timestamp=step1.start_time, + description="Expected nominal: 25.0C", + metadata={"sensor": "thermocouple_a", "channel_index": 1}, + channel_names=["temperature_celsius"], ) # Create simulated measurement first @@ -359,6 +365,9 @@ def test_update_test_measurements(self, sift_client, tmp_path): "passed": False, "string_expected_value": "1.10.4", "unit": "C", + "description": "Updated note after recalibration", + "metadata": {"part_number": "PN-002"}, + "channel_names": ["firmware_version_channel"], } # Test update with log_file first @@ -384,6 +393,9 @@ def test_update_test_measurements(self, sift_client, tmp_path): assert measurement2.passed == False assert measurement2.string_expected_value == "1.10.4" assert measurement2.unit == "C" + assert measurement2.description == "Updated note after recalibration" + assert measurement2.metadata == {"part_number": "PN-002"} + assert measurement2.channel_names == ["firmware_version_channel"] # Update the measurement using class function. measurement4 = measurement4.update( { diff --git a/python/lib/sift_client/_tests/sift_types/test_results.py b/python/lib/sift_client/_tests/sift_types/test_results.py index f073fcb4e..0207b3eb4 100644 --- a/python/lib/sift_client/_tests/sift_types/test_results.py +++ b/python/lib/sift_client/_tests/sift_types/test_results.py @@ -3,15 +3,21 @@ from __future__ import annotations import tempfile +import warnings from datetime import datetime, timedelta, timezone from unittest.mock import MagicMock, call import pytest +from sift.test_reports.v1.test_reports_pb2 import ( + TestMeasurement as TestMeasurementProto, +) +from sift_client.sift_types.channel import Channel, ChannelDataType from sift_client.sift_types.test_report import ( ErrorInfo, NumericBounds, TestMeasurement, + TestMeasurementCreate, TestMeasurementType, TestReport, TestStatus, @@ -84,6 +90,9 @@ def mock_test_measurement(mock_client): unit="Celsius", passed=True, timestamp=simulated_time, + description="Expected nominal: 25.0C", + metadata={"part_number": "PN-001", "serial_number": "SN-42"}, + channel_names=["temperature_celsius"], ) test_measurement._apply_client_to_instance(mock_client) return test_measurement @@ -298,3 +307,135 @@ def test_upload_attachment(self, mock_test_report, mock_test_step, mock_client): ), ] ) + + def test_measurement_description_truncates_with_warning(self): + """Description over the server limit is truncated and a UserWarning is raised.""" + over_limit = "x" * 2001 + with pytest.warns(UserWarning, match="exceeds 2000 characters"): + create = TestMeasurementCreate( + test_step_id="step_123", + name="m", + passed=True, + timestamp=datetime.now(timezone.utc), + numeric_value=1.0, + description=over_limit, + ) + assert create.description is not None + assert len(create.description) == 2000 + + def test_measurement_description_at_limit_is_not_truncated(self): + """A description exactly at the limit should not warn or truncate.""" + at_limit = "x" * 2000 + with warnings.catch_warnings(): + warnings.simplefilter("error") # any warning fails the test + create = TestMeasurementCreate( + test_step_id="step_123", + name="m", + passed=True, + timestamp=datetime.now(timezone.utc), + numeric_value=1.0, + description=at_limit, + ) + assert create.description == at_limit + + def test_measurement_channel_names_accepts_strings(self): + """channel_names accepts a homogeneous list of channel name strings.""" + create = TestMeasurementCreate( + test_step_id="step_123", + name="m", + passed=True, + timestamp=datetime.now(timezone.utc), + numeric_value=1.0, + channel_names=["temperature_celsius", "pressure_psi"], + ) + assert create.channel_names == ["temperature_celsius", "pressure_psi"] + proto = create.to_proto() + assert list(proto.channel_names) == ["temperature_celsius", "pressure_psi"] + + def test_measurement_channel_names_accepts_channels(self): + """channel_names accepts a homogeneous list of Channel instances; names are extracted at serialization.""" + now = datetime.now(timezone.utc) + + def _channel(name: str) -> Channel: + return Channel( + proto=MagicMock(), + id_=f"channel_{name}", + name=name, + data_type=ChannelDataType.DOUBLE, + description="", + unit="", + bit_field_elements=[], + enum_types={}, + asset_id="asset_1", + created_date=now, + modified_date=now, + created_by_user_id="user1", + modified_by_user_id="user1", + ) + + create = TestMeasurementCreate( + test_step_id="step_123", + name="m", + passed=True, + timestamp=now, + numeric_value=1.0, + channel_names=[_channel("temperature_celsius"), _channel("pressure_psi")], + ) + proto = create.to_proto() + assert list(proto.channel_names) == ["temperature_celsius", "pressure_psi"] + + def test_measurement_create_to_proto_writes_new_fields(self): + """to_proto carries description, metadata, and channel_names onto the proto.""" + create = TestMeasurementCreate( + test_step_id="step_123", + name="m", + passed=True, + timestamp=datetime.now(timezone.utc), + numeric_value=1.0, + description="note", + metadata={"pn": "PN-001", "count": 3, "flag": True}, + channel_names=["chan_a", "chan_b"], + ) + proto = create.to_proto() + assert proto.description == "note" + assert list(proto.channel_names) == ["chan_a", "chan_b"] + proto_keys = {m.key.name for m in proto.metadata} + assert proto_keys == {"pn", "count", "flag"} + + def test_measurement_from_proto_round_trips_new_fields(self): + """A proto with the new fields populated round-trips into TestMeasurement.""" + ts = datetime.now(timezone.utc) + source = TestMeasurementCreate( + test_step_id="step_123", + name="m", + passed=True, + timestamp=ts, + numeric_value=1.0, + description="note", + metadata={"pn": "PN-001", "count": 3}, + channel_names=["chan_a"], + ).to_proto() + source.measurement_id = "measurement_456" + source.test_report_id = "report_789" + + measurement = TestMeasurement._from_proto(source) + + assert measurement.description == "note" + assert measurement.metadata == {"pn": "PN-001", "count": 3} + assert measurement.channel_names == ["chan_a"] + + def test_measurement_from_proto_handles_absent_new_fields(self): + """Proto with unset description/metadata/channel_names yields None on the model.""" + proto = TestMeasurementProto( + measurement_id="measurement_abc", + measurement_type=TestMeasurementType.DOUBLE.value, + name="m", + test_step_id="step_123", + test_report_id="report_789", + passed=True, + ) + proto.timestamp.FromDatetime(datetime.now(timezone.utc)) + measurement = TestMeasurement._from_proto(proto) + assert measurement.description is None + assert measurement.metadata is None + assert measurement.channel_names is None diff --git a/python/lib/sift_client/sift_types/test_report.py b/python/lib/sift_client/sift_types/test_report.py index 882c7aab4..413aa87ba 100644 --- a/python/lib/sift_client/sift_types/test_report.py +++ b/python/lib/sift_client/sift_types/test_report.py @@ -1,9 +1,11 @@ from __future__ import annotations +import warnings from datetime import datetime, timezone from enum import Enum from typing import TYPE_CHECKING, ClassVar +from pydantic import field_validator from sift.test_reports.v1.test_reports_pb2 import ( CreateTestReportRequest as CreateTestReportRequestProto, ) @@ -34,8 +36,11 @@ ModelUpdate, ) from sift_client.sift_types._mixins.file_attachments import FileAttachmentsMixin +from sift_client.sift_types.channel import Channel from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict +_MEASUREMENT_DESCRIPTION_MAX_LEN = 2000 + if TYPE_CHECKING: from pathlib import Path @@ -258,6 +263,19 @@ class TestMeasurementBase(ModelCreateUpdateBase): numeric_bounds: NumericBounds | None = None string_expected_value: str | None = None measurement_type: TestMeasurementType | None = None + description: str | None = None + metadata: dict[str, str | float | bool] | None = None + channel_names: list[str] | list[Channel] | None = None + + _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { + "string_expected_value": MappingHelper( + proto_attr_path="string_bounds.expected_value", update_field="string_bounds" + ), + "unit": MappingHelper(proto_attr_path="unit.abbreviated_name", update_field="unit"), + "metadata": MappingHelper( + proto_attr_path="metadata", update_field="metadata", converter=metadata_dict_to_proto + ), + } def _get_proto_class(self) -> type[TestMeasurementProto]: return TestMeasurementProto @@ -272,6 +290,18 @@ def _resolve_measurement_type(self) -> TestMeasurementType: else: raise ValueError("No measurement value provided") + @field_validator("description", mode="after") + @classmethod + def _truncate_description(cls, value: str | None) -> str | None: + if value is None or len(value) <= _MEASUREMENT_DESCRIPTION_MAX_LEN: + return value + warnings.warn( + f"TestMeasurement description exceeds {_MEASUREMENT_DESCRIPTION_MAX_LEN} " + f"characters ({len(value)}); truncating to fit the server limit.", + stacklevel=2, + ) + return value[:_MEASUREMENT_DESCRIPTION_MAX_LEN] + class TestMeasurementUpdate(TestMeasurementBase, ModelUpdate[TestMeasurementProto]): """Update model for TestMeasurement.""" @@ -280,13 +310,6 @@ class TestMeasurementUpdate(TestMeasurementBase, ModelUpdate[TestMeasurementProt passed: bool | None = None timestamp: datetime | None = None - _to_proto_helpers: ClassVar[dict[str, MappingHelper]] = { - "string_expected_value": MappingHelper( - proto_attr_path="string_bounds.expected_value", update_field="string_bounds" - ), - "unit": MappingHelper(proto_attr_path="unit.abbreviated_name", update_field="unit"), - } - def _add_resource_id_to_proto(self, proto_msg: TestMeasurementProto): if self._resource_id is None: raise ValueError("Resource ID must be set before adding to proto") @@ -331,6 +354,17 @@ def to_proto(self) -> TestMeasurementProto: StringBoundsProto(expected_value=self.string_expected_value) ) + if self.description: + proto.description = self.description + + if self.metadata: + proto.metadata.extend(metadata_dict_to_proto(self.metadata)) + + if self.channel_names: + proto.channel_names.extend( + c.name if isinstance(c, Channel) else c for c in self.channel_names + ) + return proto @@ -349,6 +383,10 @@ class TestMeasurement(BaseType[TestMeasurementProto, "TestMeasurement"]): string_expected_value: str | None = None passed: bool timestamp: datetime + description: str | None = None + metadata: dict[str, str | float | bool] | None = None + channel_names: list[str] | None = None + # Set by the resource layer when this instance was produced from a logging-mode call _log_file: str | Path | None = None @@ -386,6 +424,9 @@ def _from_proto( else None, passed=proto.passed, timestamp=proto.timestamp.ToDatetime(tzinfo=timezone.utc), + description=proto.description if proto.description else None, + metadata=metadata_proto_to_dict(proto.metadata) if proto.metadata else None, # type: ignore[arg-type] + channel_names=list(proto.channel_names) if proto.channel_names else None, _client=sift_client, ) @@ -416,6 +457,15 @@ def _to_proto(self) -> TestMeasurementProto: StringBoundsProto(expected_value=self.string_expected_value) ) + if self.description: + proto.description = self.description + + if self.metadata: + proto.metadata.extend(metadata_dict_to_proto(self.metadata)) + + if self.channel_names: + proto.channel_names.extend(self.channel_names) + return proto def update( diff --git a/python/lib/sift_client/util/test_results/context_manager.py b/python/lib/sift_client/util/test_results/context_manager.py index e304e6247..7dad92b63 100644 --- a/python/lib/sift_client/util/test_results/context_manager.py +++ b/python/lib/sift_client/util/test_results/context_manager.py @@ -34,6 +34,7 @@ from numpy.typing import NDArray from sift_client.client import SiftClient + from sift_client.sift_types.channel import Channel logger = logging.getLogger(__name__) @@ -417,6 +418,9 @@ def measure( bounds: dict[str, float] | NumericBounds | str | None = None, timestamp: datetime | None = None, unit: str | None = None, + description: str | None = None, + metadata: dict[str, str | float | bool] | None = None, + channel_names: list[str] | list[Channel] | None = None, ) -> bool: """Measure a value and return the result. @@ -426,6 +430,14 @@ def measure( bounds: [Optional] The bounds to compare the value to. timestamp: [Optional] The timestamp of the measurement. Defaults to the current time. unit: [Optional] The unit of the measurement. + description: [Optional] Notes about the measurement. Server caps at 2000 characters; + longer strings are truncated with a warning. + metadata: [Optional] Structured key/value metadata to attach to the measurement. + For metadata shared across measurements, prefer the `metadata` attribute of the + enclosing `TestStep` or `TestReport`. + channel_names: [Optional] Sift channel names or `Channel` instances this measurement + is associated with. Enables cross-plotting in Explore using the report's + associated Run. returns: The result of the measurement. """ @@ -436,6 +448,9 @@ def measure( passed=True, timestamp=timestamp if timestamp else datetime.now(timezone.utc), unit=unit, + description=description, + metadata=metadata, + channel_names=channel_names, ) evaluate_measurement_bounds(create, value, bounds) measurement = self.client.test_results.create_measurement( @@ -453,6 +468,9 @@ def measure_avg( bounds: dict[str, float] | NumericBounds, timestamp: datetime | None = None, unit: str | None = None, + description: str | None = None, + metadata: dict[str, str | float | bool] | None = None, + channel_names: list[str] | list[Channel] | None = None, ) -> bool: """Calculate the average of a list of values, measure the average against given bounds, and return the result. @@ -462,6 +480,11 @@ def measure_avg( bounds: The bounds to compare the value to. timestamp: [Optional] The timestamp of the measurement. Defaults to the current time. unit: [Optional] The unit of the measurement. + description: [Optional] Notes about the measurement. Server caps at 2000 characters; + longer strings are truncated with a warning. + metadata: [Optional] Structured key/value metadata to attach to the measurement. + channel_names: [Optional] Sift channel names or `Channel` instances this measurement + is associated with. returns: The true if the average of the values is within the bounds, false otherwise. """ @@ -476,7 +499,16 @@ def measure_avg( else: raise ValueError(f"Invalid value type: {type(values)}") avg = float(np.mean(np_array)) - result = self.measure(name=name, value=avg, bounds=bounds, timestamp=timestamp, unit=unit) + result = self.measure( + name=name, + value=avg, + bounds=bounds, + timestamp=timestamp, + unit=unit, + description=description, + metadata=metadata, + channel_names=channel_names, + ) assert self.current_step is not None self.report_context.record_step_outcome(result, self.current_step) @@ -490,6 +522,9 @@ def measure_all( bounds: dict[str, float] | NumericBounds, timestamp: datetime | None = None, unit: str | None = None, + description: str | None = None, + metadata: dict[str, str | float | bool] | None = None, + channel_names: list[str] | list[Channel] | None = None, ) -> bool: """Ensure that all values in a list are within bounds and return the result. Records measurements for all values outside the bounds. @@ -501,6 +536,11 @@ def measure_all( bounds: The bounds to compare the value to. timestamp: [Optional] The timestamp of the measurement. Defaults to the current time. unit: [Optional] The unit of the measurement. + description: [Optional] Notes attached to each out-of-bounds measurement. Server caps + at 2000 characters; longer strings are truncated with a warning. + metadata: [Optional] Structured key/value metadata for each out-of-bounds measurement. + channel_names: [Optional] Sift channel names or `Channel` instances to associate with + each out-of-bounds measurement. returns: The true if all values are within the bounds, false otherwise. """ @@ -531,7 +571,16 @@ def measure_all( rows_outside_bounds = np_array[mask] for row in rows_outside_bounds: - self.measure(name=name, value=row, bounds=bounds, timestamp=timestamp, unit=unit) + self.measure( + name=name, + value=row, + bounds=bounds, + timestamp=timestamp, + unit=unit, + description=description, + metadata=metadata, + channel_names=channel_names, + ) result = rows_outside_bounds.size == 0 assert self.current_step is not None From 3f137c64bfec8e8eb914384cb40b41c8d087bfce Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Thu, 14 May 2026 12:21:49 -0700 Subject: [PATCH 2/4] bug fixes --- .../low_level_wrappers/calculated_channels.py | 4 ++-- .../sift_client/util/test_results/pytest_util.py | 15 ++++++++------- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py b/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py index 3c1ded855..5c93f2494 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py @@ -229,7 +229,7 @@ async def list_calculated_channel_versions( raise ValueError("Either calculated_channel_id or client_key must be provided") if page_size is not None: - request_kwargs["page_size"] = str(page_size) + request_kwargs["page_size"] = page_size if page_token is not None: request_kwargs["page_token"] = page_token if query_filter is not None: @@ -239,7 +239,7 @@ async def list_calculated_channel_versions( if organization_id is not None: request_kwargs["organization_id"] = organization_id - request = ListCalculatedChannelVersionsRequest(**request_kwargs) # type: ignore # mypy thinks we should pass an int + request = ListCalculatedChannelVersionsRequest(**request_kwargs) response = await self._grpc_client.get_stub( CalculatedChannelServiceStub ).ListCalculatedChannelVersions(request) diff --git a/python/lib/sift_client/util/test_results/pytest_util.py b/python/lib/sift_client/util/test_results/pytest_util.py index ac7ca957a..a96a47fb3 100644 --- a/python/lib/sift_client/util/test_results/pytest_util.py +++ b/python/lib/sift_client/util/test_results/pytest_util.py @@ -77,13 +77,14 @@ def _report_context_impl( request: pytest.FixtureRequest, pytestconfig: pytest.Config | None = None, ) -> Generator[ReportContext | None, None, None]: - test_path = Path(request.config.invocation_params.args[0]) - base_name = ( - test_path.name - if test_path.exists() - else "pytest " + " ".join(request.config.invocation_params.args) - ) - test_case = test_path if test_path.exists() else base_name + args = request.config.invocation_params.args + test_path = Path(args[0]) if args else None + if test_path is not None and test_path.exists(): + base_name = test_path.name + test_case: Path | str = test_path + else: + base_name = "pytest " + " ".join(args) if args else "pytest" + test_case = base_name log_file = _resolve_log_file(pytestconfig) include_git_metadata = ( bool(pytestconfig.getoption("sift_test_results_git_metadata", default=True)) From b03398e72cf655708497bad8d785d2b9b5bb1486 Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Thu, 14 May 2026 12:39:24 -0700 Subject: [PATCH 3/4] add integration test --- .../_tests/util/test_test_results_utils.py | 20 ++++++++++++++++++- 1 file changed, 19 insertions(+), 1 deletion(-) diff --git a/python/lib/sift_client/_tests/util/test_test_results_utils.py b/python/lib/sift_client/_tests/util/test_test_results_utils.py index f2a5cb13b..e23003d2f 100644 --- a/python/lib/sift_client/_tests/util/test_test_results_utils.py +++ b/python/lib/sift_client/_tests/util/test_test_results_utils.py @@ -137,10 +137,25 @@ def test_new_step(self, report_context): assert test_step.status == TestStatus.PASSED def test_measurement_update(self, report_context): + expected_description = "Round-trip check for the new fields on TestMeasurement." + expected_metadata = { + "ctx_test_serial": "SN-CTX-001", + "ctx_test_value": 42.5, + "ctx_test_pass": True, + } + expected_channel_names = ["temperature_celsius"] + test_step = None with report_context.new_step("Test Measure", "Test Measure Description") as new_step: test_step = new_step.current_step - new_step.measure(name="Test Measurement", value=10, bounds={"min": 0, "max": 10}) + new_step.measure( + name="Test Measurement", + value=10, + bounds={"min": 0, "max": 10}, + description=expected_description, + metadata=expected_metadata, + channel_names=expected_channel_names, + ) new_step.measure(name="Test Measurement 2", value="string value", bounds="string value") new_step.measure(name="Test Measurement 3", value=True, bounds="true") @@ -149,6 +164,9 @@ def test_measurement_update(self, report_context): assert measurements[0].name == "Test Measurement" assert measurements[0].numeric_value == 10 assert measurements[0].measurement_type == TestMeasurementType.DOUBLE + assert measurements[0].description == expected_description + assert measurements[0].metadata == expected_metadata + assert measurements[0].channel_names == expected_channel_names assert measurements[1].name == "Test Measurement 2" assert measurements[1].string_value == "string value" assert measurements[1].measurement_type == TestMeasurementType.STRING From e7d61047a7eb7347c481edef53fe941fa9cc0d5d Mon Sep 17 00:00:00 2001 From: Alex Luck Date: Thu, 14 May 2026 12:47:58 -0700 Subject: [PATCH 4/4] fix types --- .../_internal/low_level_wrappers/calculated_channels.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py b/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py index 5c93f2494..b2e6ddbdd 100644 --- a/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py +++ b/python/lib/sift_client/_internal/low_level_wrappers/calculated_channels.py @@ -220,11 +220,11 @@ async def list_calculated_channel_versions( Raises: ValueError: If neither calculated_channel_id nor client_key is provided. """ - request_kwargs = {} + request_kwargs: dict[str, Any] = {} if calculated_channel_id: - request_kwargs = {"calculated_channel_id": calculated_channel_id} + request_kwargs["calculated_channel_id"] = calculated_channel_id elif client_key: - request_kwargs = {"client_key": client_key} + request_kwargs["client_key"] = client_key else: raise ValueError("Either calculated_channel_id or client_key must be provided")