Skip to content

Commit 5664f98

Browse files
authored
add option to normalize tag values in TagManager (DataDog#21474)
* add option to normalize tag values in TagManager * add changelog * fix docstr * rename params
1 parent d15cd2f commit 5664f98

3 files changed

Lines changed: 197 additions & 6 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Add tag normalization support to TagManager with optional tag_normalizer parameter and normalize flags for set_tag/set_tags_from_list/delete_tag methods.

datadog_checks_base/datadog_checks/base/utils/db/utils.py

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -471,12 +471,13 @@ class TagManager:
471471
multiple times.
472472
"""
473473

474-
def __init__(self) -> None:
474+
def __init__(self, normalizer: Optional[Callable[[Union[str, bytes]], str]] = None) -> None:
475475
self._tags: Dict[Union[str, TagType], List[str]] = {}
476476
self._cached_tag_list: Optional[tuple[str, ...]] = None
477477
self._keyless: TagType = TagType.KEYLESS
478+
self._normalizer = normalizer
478479

479-
def set_tag(self, key: Optional[str], value: str, replace: bool = False) -> None:
480+
def set_tag(self, key: Optional[str], value: str, replace: bool = False, normalize: bool = False) -> None:
480481
"""
481482
Set a tag with the given key and value.
482483
If key is None or empty, the value is stored as a keyless tag.
@@ -485,7 +486,11 @@ def set_tag(self, key: Optional[str], value: str, replace: bool = False) -> None
485486
value (str): The tag value
486487
replace (bool): If True, replaces all existing values for this key
487488
If False, appends the value if it doesn't exist
489+
normalize (bool): If True, applies tag normalization using the configured normalizer
488490
"""
491+
if normalize and self._normalizer:
492+
value = self._normalizer(value)
493+
489494
if not key:
490495
key = self._keyless
491496

@@ -498,13 +503,14 @@ def set_tag(self, key: Optional[str], value: str, replace: bool = False) -> None
498503
# Invalidate the cache since tags have changed
499504
self._cached_tag_list = None
500505

501-
def set_tags_from_list(self, tag_list: List[str], replace: bool = False) -> None:
506+
def set_tags_from_list(self, tag_list: List[str], replace: bool = False, normalize: bool = False) -> None:
502507
"""
503508
Set multiple tags from a list of strings.
504509
Strings can be in "key:value" format or just "value" format.
505510
Args:
506511
tag_list (List[str]): List of tags in "key:value" format or just "value"
507512
replace (bool): If True, replaces all existing tags with the new tags list
513+
normalize (bool): If True, applies tag normalization using the configured normalizer
508514
"""
509515
if replace:
510516
self._tags.clear()
@@ -513,21 +519,25 @@ def set_tags_from_list(self, tag_list: List[str], replace: bool = False) -> None
513519
for tag in tag_list:
514520
if ':' in tag:
515521
key, value = tag.split(':', 1)
516-
self.set_tag(key, value)
522+
self.set_tag(key, value, normalize=normalize)
517523
else:
518-
self.set_tag(None, tag)
524+
self.set_tag(None, tag, normalize=normalize)
519525

520-
def delete_tag(self, key: Optional[str], value: Optional[str] = None) -> bool:
526+
def delete_tag(self, key: Optional[str], value: Optional[str] = None, normalize: bool = False) -> bool:
521527
"""
522528
Delete a tag or specific value for a tag.
523529
For keyless tags, use None or empty string as the key.
524530
Args:
525531
key (str): The tag key to delete, or None/empty for keyless tags
526532
value (str, optional): If provided, only deletes this specific value for the key.
527533
If None, deletes all values for the key.
534+
normalize (bool): If True, applies tag normalization to the value for lookup
528535
Returns:
529536
bool: True if something was deleted, False otherwise
530537
"""
538+
if normalize and self._normalizer and value:
539+
value = self._normalizer(value)
540+
531541
if not key:
532542
key = self._keyless
533543

datadog_checks_base/tests/base/utils/db/test_util.py

Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -620,3 +620,183 @@ def test_get_tags_mutation(self):
620620
new_tags = tag_manager.get_tags()
621621
assert new_tags == ['test_key:test_value']
622622
assert new_tags != tags # The lists should be different objects
623+
624+
# Normalization tests
625+
def mock_tag_normalizer(self, tag):
626+
"""Mock normalizer that replaces spaces and hyphens with underscores and lowercases"""
627+
return tag.replace(' ', '_').replace('-', '_').lower()
628+
629+
def test_init_with_normalizer(self):
630+
"""Test initialization of TagManager with normalizer"""
631+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
632+
assert tag_manager._normalizer == self.mock_tag_normalizer
633+
assert tag_manager._tags == {}
634+
assert tag_manager._cached_tag_list is None
635+
636+
def test_set_tag_with_normalization(self):
637+
"""Test setting tags with normalization enabled"""
638+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
639+
640+
# Test with normalize=True - only value should be normalized, not key
641+
tag_manager.set_tag('test-key', 'value with spaces', normalize=True)
642+
assert tag_manager._tags == {'test-key': ['value_with_spaces']}
643+
644+
# Test keyless tag normalization
645+
tag_manager.set_tag(None, 'keyless value', normalize=True)
646+
assert TagType.KEYLESS in tag_manager._tags
647+
assert tag_manager._tags[TagType.KEYLESS] == ['keyless_value']
648+
649+
def test_set_tag_without_normalization(self):
650+
"""Test setting tags without normalization"""
651+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
652+
653+
# Test with normalize=False
654+
tag_manager.set_tag('test-key', 'value with spaces', normalize=False)
655+
assert tag_manager._tags == {'test-key': ['value with spaces']}
656+
657+
# Test with no normalize parameter (should default to False)
658+
tag_manager.set_tag('another-key', 'another value')
659+
assert tag_manager._tags == {'test-key': ['value with spaces'], 'another-key': ['another value']}
660+
661+
def test_set_tags_from_list_with_normalization(self):
662+
"""Test setting tags from list with normalization enabled"""
663+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
664+
665+
tag_list = ['env:prod-test', 'service:web app', 'keyless-tag']
666+
tag_manager.set_tags_from_list(tag_list, normalize=True)
667+
668+
expected_tags = sorted(['env:prod_test', 'service:web_app', 'keyless_tag'])
669+
assert sorted(tag_manager.get_tags()) == expected_tags
670+
671+
def test_set_tags_from_list_without_normalization(self):
672+
"""Test setting tags from list without normalization"""
673+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
674+
675+
tag_list = ['env:prod-test', 'service:web app', 'keyless-tag']
676+
tag_manager.set_tags_from_list(tag_list, normalize=False)
677+
678+
expected_tags = sorted(['env:prod-test', 'service:web app', 'keyless-tag'])
679+
assert sorted(tag_manager.get_tags()) == expected_tags
680+
681+
def test_delete_tag_with_normalization(self):
682+
"""Test deleting tags with normalization enabled"""
683+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
684+
685+
# Set tag with normalization (only value normalized)
686+
tag_manager.set_tag('test-key', 'value with spaces', normalize=True)
687+
assert 'test-key' in tag_manager._tags
688+
assert tag_manager._tags['test-key'] == ['value_with_spaces']
689+
690+
# Delete with normalization - should find the normalized value
691+
result = tag_manager.delete_tag('test-key', 'value with spaces', normalize=True)
692+
assert result is True
693+
assert tag_manager._tags == {}
694+
695+
def test_delete_tag_without_normalization(self):
696+
"""Test deleting tags without normalization"""
697+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
698+
699+
# Set tag without normalization
700+
tag_manager.set_tag('test-key', 'value with spaces', normalize=False)
701+
assert tag_manager._tags == {'test-key': ['value with spaces']}
702+
703+
# Delete without normalization
704+
result = tag_manager.delete_tag('test-key', 'value with spaces', normalize=False)
705+
assert result is True
706+
assert tag_manager._tags == {}
707+
708+
def test_delete_tag_normalization_mismatch(self):
709+
"""Test that deletion fails when normalization doesn't match storage"""
710+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
711+
712+
# Set tag with normalization (only value normalized)
713+
tag_manager.set_tag('test-key', 'value with spaces', normalize=True)
714+
assert tag_manager._tags == {'test-key': ['value_with_spaces']}
715+
716+
# Try to delete without normalization - should fail
717+
result = tag_manager.delete_tag('test-key', 'value with spaces', normalize=False)
718+
assert result is False
719+
assert tag_manager._tags == {'test-key': ['value_with_spaces']}
720+
721+
# Delete with normalization - should succeed
722+
result = tag_manager.delete_tag('test-key', 'value with spaces', normalize=True)
723+
assert result is True
724+
assert tag_manager._tags == {}
725+
726+
def test_delete_entire_key_with_normalization(self):
727+
"""Test deleting entire key with normalization"""
728+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
729+
730+
# Set multiple values for a key with normalization (only values normalized)
731+
tag_manager.set_tag('test-key', 'value1', normalize=True)
732+
tag_manager.set_tag('test-key', 'value2', normalize=True)
733+
assert tag_manager._tags == {'test-key': ['value1', 'value2']}
734+
735+
# Delete entire key - key doesn't need normalization
736+
result = tag_manager.delete_tag('test-key', normalize=True)
737+
assert result is True
738+
assert tag_manager._tags == {}
739+
740+
def test_normalization_with_no_normalizer(self):
741+
"""Test that normalize parameter is ignored when no normalizer is provided"""
742+
tag_manager = TagManager() # No normalizer
743+
744+
# Should work the same regardless of normalize parameter
745+
tag_manager.set_tag('test-key', 'value with spaces', normalize=True)
746+
assert tag_manager._tags == {'test-key': ['value with spaces']}
747+
748+
tag_manager.set_tags_from_list(['env:prod-test'], normalize=True)
749+
expected_tags = sorted(['test-key:value with spaces', 'env:prod-test'])
750+
assert sorted(tag_manager.get_tags()) == expected_tags
751+
752+
# Delete should also work
753+
result = tag_manager.delete_tag('test-key', 'value with spaces', normalize=True)
754+
assert result is True
755+
756+
def test_case_sensitivity_normalization(self):
757+
"""Test that normalization handles case sensitivity correctly (only values normalized)"""
758+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
759+
760+
# Test only value gets normalized, key assumed to be lowercase already
761+
tag_manager.set_tag('test_key', 'UPPERCASE-VALUE', normalize=True)
762+
assert tag_manager._tags == {'test_key': ['uppercase_value']}
763+
764+
# Test mixed case value normalization
765+
tag_manager.set_tag('another_key', 'SoMe VaLuE', normalize=True)
766+
assert 'another_key' in tag_manager._tags
767+
assert tag_manager._tags['another_key'] == ['some_value']
768+
769+
# Verify final tags - keys lowercase, values normalized
770+
expected_tags = sorted(['test_key:uppercase_value', 'another_key:some_value'])
771+
assert sorted(tag_manager.get_tags()) == expected_tags
772+
773+
# Test deletion with case sensitivity
774+
result = tag_manager.delete_tag('test_key', 'UPPERCASE-VALUE', normalize=True)
775+
assert result is True
776+
assert tag_manager._tags == {'another_key': ['some_value']}
777+
778+
def test_case_sensitivity_in_tag_list(self):
779+
"""Test case sensitivity normalization in tag lists"""
780+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
781+
782+
tag_list = ['env:PRODUCTION', 'service:WEB-APP', 'datacenter:US-EAST-1', 'KEYLESS-TAG-UPPERCASE']
783+
tag_manager.set_tags_from_list(tag_list, normalize=True)
784+
785+
# When normalize_tag is applied to the full tag string, it normalizes everything
786+
expected_tags = sorted(['env:production', 'service:web_app', 'datacenter:us_east_1', 'keyless_tag_uppercase'])
787+
assert sorted(tag_manager.get_tags()) == expected_tags
788+
789+
def test_case_sensitivity_without_normalization(self):
790+
"""Test that case sensitivity is preserved without normalization"""
791+
tag_manager = TagManager(normalizer=self.mock_tag_normalizer)
792+
793+
# Set tags without normalization - case should be preserved
794+
tag_manager.set_tag('test_key', 'UPPERCASE-VALUE', normalize=False)
795+
assert tag_manager._tags == {'test_key': ['UPPERCASE-VALUE']}
796+
797+
# Set tag list without normalization - case should be preserved
798+
tag_list = ['env:PRODUCTION', 'KEYLESS-TAG-UPPERCASE']
799+
tag_manager.set_tags_from_list(tag_list, normalize=False)
800+
801+
expected_tags = sorted(['test_key:UPPERCASE-VALUE', 'env:PRODUCTION', 'KEYLESS-TAG-UPPERCASE'])
802+
assert sorted(tag_manager.get_tags()) == expected_tags

0 commit comments

Comments
 (0)