Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions python/lib/sift_client/_tests/resources/test_runs.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,19 @@
from datetime import datetime, timedelta, timezone

import pytest
from google.protobuf.field_mask_pb2 import FieldMask
from grpc import StatusCode
from grpc.aio import AioRpcError
from sift.metadata.v1.metadata_pb2 import METADATA_KEY_TYPE_STRING, MetadataKey, MetadataValue
from sift.runs.v2.runs_pb2 import Run as RunProto
from sift.runs.v2.runs_pb2 import UpdateRunRequest
from sift.runs.v2.runs_pb2_grpc import RunServiceStub

from sift_client import SiftClient
from sift_client.resources import RunsAPI, RunsAPIAsync
from sift_client.sift_types import Run
from sift_client.sift_types.run import RunCreate, RunUpdate
from sift_client.util.metadata import Metadata

pytestmark = pytest.mark.integration

Expand Down Expand Up @@ -393,6 +400,69 @@ async def test_update_with_run_id_string(self, runs_api_async, new_run):
finally:
await runs_api_async.archive(new_run.id_)

class TestMultiValueMetadata:
"""Read-side tests for multi-value metadata.

The public write path is scalar-only (list values land in ENG-13281
Phase 3), so these tests seed duplicate-key metadata through the raw
`UpdateRun` proto. Storing multiple values per key requires the
backend's org-scoped `multi-value-metadata` flag; against a backend
without it the seed either fails or collapses to one value, and the
test skips instead of failing.
"""

@pytest.mark.asyncio
async def test_get_run_exposes_all_values_via_getall(
self, sift_client, runs_api_async, new_run
):
"""Reading a run with duplicate-key metadata yields every value."""
values = ["flux_capacitor", "lightsaber"]
request = UpdateRunRequest(
run=RunProto(
run_id=new_run.id_,
metadata=[
MetadataValue(
key=MetadataKey(name="part_number", type=METADATA_KEY_TYPE_STRING),
string_value=value,
)
for value in values
],
),
update_mask=FieldMask(paths=["metadata"]),
)
try:
try:
response = await sift_client.grpc_client.get_stub(RunServiceStub).UpdateRun(
request
)
except AioRpcError as exc:
if exc.code() == StatusCode.INVALID_ARGUMENT:
pytest.skip(
"backend rejected duplicate metadata keys; "
"multi-value-metadata flag not enabled"
)
raise
stored = [
entry.string_value
for entry in response.run.metadata
if entry.key.name == "part_number"
]
if stored != values:
pytest.skip(
"backend did not store duplicate metadata keys; "
"multi-value-metadata flag not enabled"
)

run = await runs_api_async.get(run_id=new_run.id_)
metadata = run.metadata
assert isinstance(metadata, Metadata)
assert metadata["part_number"] == values[0]
assert metadata.getall("part_number") == values
assert metadata["part_number"] == metadata.getall("part_number")[0]
assert metadata.getall("missing") == []
finally:
await runs_api_async.archive(new_run.id_)

class TestArchive:
"""Tests for the async archive method."""

Expand Down
3 changes: 2 additions & 1 deletion python/lib/sift_client/_tests/sift_types/test_channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

from sift_client.sift_types import Channel
from sift_client.sift_types.channel import ChannelDataType, ChannelReference, ChannelUpdate
from sift_client.util.metadata import Metadata


def _make_channel(
Expand All @@ -26,7 +27,7 @@ def _make_channel(
bit_field_elements=[],
enum_types={},
asset_id="test_asset_id",
metadata={},
metadata=Metadata(),
is_archived=is_archived,
created_date=datetime.now(timezone.utc),
modified_date=datetime.now(timezone.utc),
Expand Down
72 changes: 71 additions & 1 deletion python/lib/sift_client/_tests/util/test_metadata.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,14 @@
import json

import pytest
from pydantic import BaseModel
from sift.metadata.v1.metadata_pb2 import (
MetadataKey,
MetadataKeyType,
MetadataValue,
)

from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict
from sift_client.util.metadata import Metadata, metadata_dict_to_proto, metadata_proto_to_dict


def _string_value(name: str, value: str) -> MetadataValue:
Expand Down Expand Up @@ -46,9 +50,75 @@ def test_multi_value_key_keeps_first_value(self):
"env": "prod",
}

def test_multi_value_key_exposes_all_values_via_getall(self):
metadata = [
_string_value("associated_parts", "ABC"),
_string_value("associated_parts", "XYZ"),
_string_value("env", "prod"),
]
result = metadata_proto_to_dict(metadata)
assert isinstance(result, Metadata)
assert result.getall("associated_parts") == ["ABC", "XYZ"]
assert result.getall("env") == ["prod"]
assert result.getall("missing") == []

def test_empty_metadata(self):
assert metadata_proto_to_dict([]) == {}

def test_round_trip_from_dict(self):
original = {"env": "prod", "build": 1.5, "armed": True}
assert metadata_proto_to_dict(metadata_dict_to_proto(original)) == original


class TestMetadataMapping:
def test_dict_view_and_getall_invariant(self):
md = Metadata(
{"associated_parts": "ABC", "env": "prod"},
{"associated_parts": ["ABC", "XYZ"], "env": ["prod"]},
)
assert md["associated_parts"] == "ABC"
assert md == {"associated_parts": "ABC", "env": "prod"}
for key in md:
assert md[key] == md.getall(key)[0]

def test_getall_falls_back_to_scalar_without_all_values(self):
md = Metadata({"env": "prod"})
assert md.getall("env") == ["prod"]
assert md.getall("missing") == []

def test_getall_returns_a_copy(self):
md = Metadata({"k": "a"}, {"k": ["a", "b"]})
md.getall("k").append("mutated")
assert md.getall("k") == ["a", "b"]

def test_json_serializable(self):
md = Metadata({"env": "prod"}, {"env": ["prod"]})
assert json.loads(json.dumps(md)) == {"env": "prod"}


class TestMetadataPydanticField:
class _Model(BaseModel):
metadata: Metadata

def test_instance_passes_through_validation_with_all_values(self):
# Pydantic must not rebuild the mapping as a plain dict -- that would
# silently drop the extra values behind getall().
md = Metadata({"parts": "ABC"}, {"parts": ["ABC", "XYZ"]})
model = self._Model(metadata=md)
assert isinstance(model.metadata, Metadata)
assert model.metadata.getall("parts") == ["ABC", "XYZ"]

def test_plain_dict_is_wrapped(self):
model = self._Model(metadata={"env": "prod"})
assert isinstance(model.metadata, Metadata)
assert model.metadata.getall("env") == ["prod"]

def test_non_mapping_rejected(self):
with pytest.raises(ValueError, match="expected a metadata mapping"):
self._Model(metadata="not-a-mapping") # type: ignore[arg-type]

def test_model_dump_serializes_as_plain_dict(self):
md = Metadata({"parts": "ABC"}, {"parts": ["ABC", "XYZ"]})
model = self._Model(metadata=md)
assert model.model_dump() == {"metadata": {"parts": "ABC"}}
assert json.loads(model.model_dump_json()) == {"metadata": {"parts": "ABC"}}
4 changes: 2 additions & 2 deletions python/lib/sift_client/sift_types/asset.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
from sift_client.sift_types._base import BaseType, MappingHelper, ModelUpdate
from sift_client.sift_types._mixins.file_attachments import FileAttachmentsMixin
from sift_client.sift_types.tag import Tag
from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict
from sift_client.util.metadata import Metadata, metadata_dict_to_proto, metadata_proto_to_dict

if TYPE_CHECKING:
from sift_client.client import SiftClient
Expand All @@ -29,7 +29,7 @@ class Asset(BaseType[AssetProto, "Asset"], FileAttachmentsMixin):
tags: list[str | Tag]
# NOTE: update() replaces this map wholesale. See TODO(metadata-mixin) in
# sift_types/_mixins/metadata.py before adding keys at runtime.
metadata: dict[str, str | float | bool]
metadata: Metadata
is_archived: bool

# Optional fields
Expand Down
4 changes: 2 additions & 2 deletions python/lib/sift_client/sift_types/channel.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
)

from sift_client.sift_types._base import BaseType, MappingHelper, ModelUpdate
from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict
from sift_client.util.metadata import Metadata, metadata_dict_to_proto, metadata_proto_to_dict

if TYPE_CHECKING:
from sift_stream_bindings import ChannelBitFieldElementPy, ChannelDataTypePy
Expand Down Expand Up @@ -254,7 +254,7 @@ class Channel(BaseType[ChannelProto, "Channel"]):
bit_field_elements: list[ChannelBitFieldElement] = Field(default_factory=list)
enum_types: dict[str, int] = Field(default_factory=dict)
asset_id: str
metadata: dict[str, str | float | bool] = Field(default_factory=dict)
metadata: Metadata = Field(default_factory=Metadata)
is_archived: bool
created_date: datetime
modified_date: datetime
Expand Down
4 changes: 2 additions & 2 deletions python/lib/sift_client/sift_types/report.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

from sift_client.sift_types._base import BaseType, MappingHelper, ModelUpdate
from sift_client.sift_types.tag import Tag
from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict
from sift_client.util.metadata import Metadata, metadata_dict_to_proto, metadata_proto_to_dict

if TYPE_CHECKING:
from sift_client.client import SiftClient
Expand Down Expand Up @@ -110,7 +110,7 @@ class Report(BaseType[ReportProto, "Report"]):
rerun_from_report_id: str | None = None
# NOTE: update() replaces this map wholesale. See TODO(metadata-mixin) in
# sift_types/_mixins/metadata.py before adding keys at runtime.
metadata: dict[str, str | float | bool]
metadata: Metadata
job_id: str
archived_date: datetime | None = None
is_archived: bool
Expand Down
4 changes: 2 additions & 2 deletions python/lib/sift_client/sift_types/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
)
from sift_client.sift_types._mixins.file_attachments import FileAttachmentsMixin
from sift_client.sift_types.tag import Tag
from sift_client.util.metadata import metadata_dict_to_proto, metadata_proto_to_dict
from sift_client.util.metadata import Metadata, metadata_dict_to_proto, metadata_proto_to_dict

if TYPE_CHECKING:
from pathlib import Path
Expand All @@ -42,7 +42,7 @@ class Run(BaseType[RunProto, "Run"], FileAttachmentsMixin):
organization_id: str
# NOTE: update() replaces this map wholesale. See TODO(metadata-mixin) in
# sift_types/_mixins/metadata.py before adding keys at runtime.
metadata: dict[str, str | float | bool]
metadata: Metadata
tags: list[str]
asset_ids: list[str]
is_adhoc: bool
Expand Down
93 changes: 80 additions & 13 deletions python/lib/sift_client/util/metadata.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
from __future__ import annotations

from typing import TYPE_CHECKING, Any, Dict, Union

from pydantic_core import core_schema
from sift.metadata.v1.metadata_pb2 import (
MetadataKey,
MetadataKeyType,
Expand All @@ -8,6 +11,64 @@
MetadataValue as MetadataProto,
)

if TYPE_CHECKING:
from pydantic import GetCoreSchemaHandler


class Metadata(Dict[str, Union[str, float, bool]]):
"""Entity metadata: a dict of key -> first value, plus access to every
value of a key via getall().

A metadata key may hold multiple values (multi-value metadata), kept in
canonical order. Plain dict access (md["k"], md.get("k"), iteration, ==)
sees the first value only -- the same value every single-value context
uses -- so existing code written against scalar metadata keeps working
unchanged. getall(key) returns the full ordered value list; its first
element is always the dict value.
"""

def __init__(
self,
first_values: dict[str, str | float | bool] | None = None,
all_values: dict[str, list[str | float | bool]] | None = None,
):
"""Build the mapping from a first-value dict and optional full value lists."""
super().__init__(first_values or {})
self._all_values: dict[str, list[str | float | bool]] = {
key: list(values) for key, values in (all_values or {}).items()
}

def getall(self, key: str) -> list[str | float | bool]:
"""Return every value of ``key`` as a new list, in canonical order.

Keys absent from the metadata yield []; keys holding a single value
yield a one-element list.
"""
if key in self._all_values:
return list(self._all_values[key])
if key in self:
return [self[key]]
return []

@classmethod
def __get_pydantic_core_schema__(
cls, source_type: Any, handler: GetCoreSchemaHandler
) -> core_schema.CoreSchema:
# Without this hook, pydantic validates ``Metadata`` fields as plain
# dicts and rebuilds them, dropping the extra values behind getall().
# Pass instances through untouched; wrap plain mappings.
def _validate(value: Any) -> Metadata:
if isinstance(value, cls):
return value
if isinstance(value, dict):
return cls(value)
raise ValueError(f"expected a metadata mapping, got {type(value).__name__}")

return core_schema.no_info_plain_validator_function(
_validate,
serialization=core_schema.plain_serializer_function_ser_schema(dict),
)


def metadata_dict_to_proto(_metadata: dict[str, str | float | bool]) -> list[MetadataProto]:
"""Converts metadata dictionary into a list of MetadataValue objects.
Expand Down Expand Up @@ -51,29 +112,35 @@ def metadata_dict_to_proto(_metadata: dict[str, str | float | bool]) -> list[Met
return metadata


def metadata_proto_to_dict(metadata: list[MetadataProto]) -> dict[str, str | float | bool]:
"""Converts a list of MetadataValue objects into a dictionary.
def metadata_proto_to_dict(metadata: list[MetadataProto]) -> Metadata:
"""Converts a list of MetadataValue objects into a Metadata mapping.

A key may appear multiple times when it holds multiple values
(multi-value metadata). The dictionary keeps the first value of each
key -- the API returns values in canonical order, so this matches the
value every other single-value context (e.g. backend flattening) uses.
(multi-value metadata). The mapping's dict view keeps the first value of
each key -- the API returns values in canonical order, so this matches
the value every other single-value context (e.g. backend flattening)
uses -- and ``Metadata.getall(key)`` returns the full ordered list.

Args:
metadata: List of MetadataValue objects.

Returns:
Dictionary of metadata key-value pairs (first value per key).
Metadata mapping of key-value pairs (first value per key; all values
via getall).
"""
unwrapped_metadata: dict[str, str | float | bool] = {}
first_values: dict[str, str | float | bool] = {}
all_values: dict[str, list[str | float | bool]] = {}
for md in metadata:
if md.key.name in unwrapped_metadata:
continue
value: str | float | bool
if md.key.type == MetadataKeyType.METADATA_KEY_TYPE_STRING:
unwrapped_metadata[md.key.name] = md.string_value
value = md.string_value
elif md.key.type == MetadataKeyType.METADATA_KEY_TYPE_BOOLEAN:
unwrapped_metadata[md.key.name] = md.boolean_value
value = md.boolean_value
elif md.key.type == MetadataKeyType.METADATA_KEY_TYPE_NUMBER:
unwrapped_metadata[md.key.name] = md.number_value
value = md.number_value
else:
continue
first_values.setdefault(md.key.name, value)
all_values.setdefault(md.key.name, []).append(value)

return unwrapped_metadata
return Metadata(first_values, all_values)
Loading