From a7dbb04250e02bccfccbfe5b720629cbad4ccf52 Mon Sep 17 00:00:00 2001 From: Thomas Howe Date: Thu, 6 Aug 2026 15:32:49 -0400 Subject: [PATCH] fix(tag): tolerate dict-bodied tags attachment and apply name:value/dict options (CON-737) The SIPREC->conserver interop path (CON-737) DLQ'd every real vCon because the tag link crashed in add_tag. Two defects: - add_tag assumed the existing purpose="tags" attachment body was a list and called .append on it. The SIPREC adapter emits purpose="tags" with a dict body (provenance metadata), so add_tag raised AttributeError: 'dict' object has no attribute 'append' and the vCon was DLQ'd before storage. add_tag now flattens a dict body to the tag convention's "name:value" list (and coerces any other non-list body) before appending. - The tag link applied options as add_tag(tag_name=tag, tag_value=tag), so a configured "source:siprec-adapter" became the doubled tag "source:siprec-adapter:source:siprec-adapter". The link now parses "name:value" strings and also accepts dict-form options {name: value}. Adds regression tests for both fixes. Co-Authored-By: Claude Opus 4.8 --- common/vcon.py | 8 ++++++ conserver/links/tag/__init__.py | 17 ++++++++++-- conserver/links/tag/test_tag.py | 46 +++++++++++++++++++++++++++++++++ 3 files changed, 69 insertions(+), 2 deletions(-) diff --git a/common/vcon.py b/common/vcon.py index 7df19b48..dc45565e 100644 --- a/common/vcon.py +++ b/common/vcon.py @@ -131,6 +131,14 @@ def add_tag(self, tag_name, tag_value): # back as spec-correct ``encoding=json`` + stringified list, per # draft-ietf-vcon-vcon-core-02 ยง2.3.2 (body is always a String). tags = self.decoded_body(tags_attachment) or [] + if isinstance(tags, dict): + # A producer wrote purpose="tags" with a dict body (e.g. the SIPREC + # adapter's provenance metadata). Flatten to the tag convention's + # "name:value" list so get_tag reads it and append never raises. + # (CON-737) + tags = [f"{k}:{v}" for k, v in tags.items()] + elif not isinstance(tags, list): + tags = [tags] tags.append(f"{tag_name}:{tag_value}") tags_attachment["body"] = json.dumps(tags) tags_attachment["encoding"] = "json" diff --git a/conserver/links/tag/__init__.py b/conserver/links/tag/__init__.py index 824cc6e9..cf641a42 100644 --- a/conserver/links/tag/__init__.py +++ b/conserver/links/tag/__init__.py @@ -16,8 +16,21 @@ def run( vcon_redis = VconRedis() vCon = vcon_redis.get_vcon(vcon_uuid) - for tag in opts.get("tags", []): - vCon.add_tag(tag_name=tag, tag_value=tag) + tags = opts.get("tags", []) + if isinstance(tags, dict): + # dict-form options: {name: value} (CON-737) + pairs = list(tags.items()) + else: + # list-form options: "name:value" strings, or bare names. + pairs = [] + for tag in tags: + if isinstance(tag, str) and ":" in tag: + name, value = tag.split(":", 1) + else: + name = value = tag + pairs.append((name, value)) + for name, value in pairs: + vCon.add_tag(tag_name=name, tag_value=value) vcon_redis.store_vcon(vCon) # Return the vcon_uuid down the chain. diff --git a/conserver/links/tag/test_tag.py b/conserver/links/tag/test_tag.py index ffff22fa..dc57b5f8 100644 --- a/conserver/links/tag/test_tag.py +++ b/conserver/links/tag/test_tag.py @@ -1,3 +1,4 @@ +import json from unittest.mock import patch from vcon import Vcon @@ -44,3 +45,48 @@ def test_run_handles_empty_tag_list(mock_vcon_redis): assert result == "test-uuid" assert vcon.get_tag("iron") is None mock_instance.store_vcon.assert_called_once_with(vcon) + + +@patch("links.tag.VconRedis") +def test_run_applies_name_value_string_tags(mock_vcon_redis): + # "name:value" options must set name -> value, not "tag:tag". (CON-737) + vcon = Vcon.build_new() + mock_instance = mock_vcon_redis.return_value + mock_instance.get_vcon.return_value = vcon + + run("test-uuid", "tag", opts={"tags": ["source:siprec-adapter", "pipeline:interop"]}) + + assert vcon.get_tag("source") == "siprec-adapter" + assert vcon.get_tag("pipeline") == "interop" + + +@patch("links.tag.VconRedis") +def test_run_applies_dict_form_tags(mock_vcon_redis): + # dict-form options: {name: value}. (CON-737) + vcon = Vcon.build_new() + mock_instance = mock_vcon_redis.return_value + mock_instance.get_vcon.return_value = vcon + + run("test-uuid", "tag", opts={"tags": {"env": "prod", "team": "interop"}}) + + assert vcon.get_tag("env") == "prod" + assert vcon.get_tag("team") == "interop" + + +@patch("links.tag.VconRedis") +def test_run_tolerates_dict_bodied_tags_attachment(mock_vcon_redis): + # A producer (e.g. the SIPREC adapter) wrote purpose="tags" with a dict + # body. add_tag must flatten it instead of raising on `.append`. (CON-737) + vcon = Vcon.build_new() + vcon.vcon_dict["attachments"].append( + {"purpose": "tags", "encoding": "json", "body": json.dumps({"source": "siprec"})} + ) + mock_instance = mock_vcon_redis.return_value + mock_instance.get_vcon.return_value = vcon + + result = run("test-uuid", "tag", opts={"tags": ["pipeline:interop"]}) + + assert result == "test-uuid" + assert vcon.get_tag("source") == "siprec" # pre-existing dict entry preserved + assert vcon.get_tag("pipeline") == "interop" # new tag appended + mock_instance.store_vcon.assert_called_once_with(vcon)