From 908d2aa1ce104eacdcf47d5b27c7c145338e93d9 Mon Sep 17 00:00:00 2001 From: Jj Date: Thu, 21 May 2026 18:25:24 +0100 Subject: [PATCH 1/7] Allow label transform functions --- src/cr/cube/dimension.py | 89 +++++++++++++++++++++++++++++++++--- src/cr/cube/util.py | 19 ++++++++ tests/unit/test_dimension.py | 69 +++++++++++++++++++++++----- 3 files changed, 159 insertions(+), 18 deletions(-) diff --git a/src/cr/cube/dimension.py b/src/cr/cube/dimension.py index 38f6f1644..9b13aa216 100644 --- a/src/cr/cube/dimension.py +++ b/src/cr/cube/dimension.py @@ -13,8 +13,8 @@ DIMENSION_TYPE as DT, MARGINAL, MEASURE, -) -from cr.cube.util import lazyproperty + ) +from cr.cube.util import common_prefix, common_suffix, lazyproperty from .util import format, format_datetime @@ -33,7 +33,67 @@ } -def _formatter(dimension_type, typedef, out_format) -> Union[Callable, partial]: +class LabelTransformFuncs: + def __init__(self, label_transforms: list[dict], all_labels: list[str]): + """ + Used to apply the label function transformations on elements of a + dimension + + :param label_transforms: list of dictionary functions. See + Dashboard-models for the list of options and arguments + :param all_labels: list of all the original labels of the dimension. It + is necessary to have them all so we can do the prefix/suffix trimming. + """ + self.label_transforms = label_transforms + self.all_labels = all_labels + + def apply(self, formatter: Union[Callable, partial]) -> Callable[[str], str]: + """ + Receives the formatter and will wrap any other transformation + on top of its output. + :param formatter: Callable that receives a sting and outputs a string + :return: A callable with the same signature as the formatter + """ + if not self.label_transforms: + return formatter + + def __inner(value: str) -> str: + for func_transform in self.label_transforms: + # Invalid function raises AttributeError, this should break + # because it's an incomplete implementation. The functions + # should be specified in the Lark syntax and Dashboard models + # to match the list of allowed operations here. Requires + # 3 repos to be updated. + func = getattr(self, func_transform["function"]) + args = func_transform["args"] + value = func(value, args) + + value = formatter(value) + return value + + return __inner + + @staticmethod + def replace(value: str, args) -> str: + return value.replace(*args) + + def remove_common_suffix(self, value: str, args) -> str: + suffix_pos = common_suffix(self.all_labels) + return value[:suffix_pos] if suffix_pos else value + + def remove_common_prefix(self, value: str, args) -> str: + prefix_pos = common_prefix(self.all_labels) + return value[prefix_pos:] if prefix_pos else prefix_pos + + def trim_common(self, value: str, args) -> str: + no_suffix = self.remove_common_suffix(value, []) + no_prefix_either = self.remove_common_prefix(no_suffix, []) + return no_prefix_either + + +def _formatter( + dimension_type, typedef, out_format, label_transforms, all_labels +) -> Union[Callable, partial]: """Returns a formatting function according to the dimension type.""" if dimension_type != DT.DATETIME: @@ -46,6 +106,10 @@ def _formatter(dimension_type, typedef, out_format) -> Union[Callable, partial]: if orig_format and out_format else format ) + + if label_transforms: # Apply label transforms on the output + formatter = LabelTransformFuncs(label_transforms, all_labels).apply(formatter) + return formatter @@ -534,6 +598,7 @@ def from_typedef( element_defs = [codemap[code] for code in order if code in codemap] all_xforms = dimension_transforms_dict.get("elements", {}) + label_transforms = dimension_transforms_dict.get("label_transforms") if dimension_type == DT.MR_SUBVAR: hidden_xforms = cls._hidden_transforms( element_defs, @@ -542,13 +607,20 @@ def from_typedef( all_xforms = {**hidden_xforms, **all_xforms} elements = [] + all_labels = [elt["name"] for elt in element_defs if "name" in elt] for idx, element_dict in enumerate(element_defs): # --- convert to string for categorical ids element_id = _build_element_id(element_dict, dimension_type) xforms = _ElementTransforms( all_xforms.get(element_id, all_xforms.get(str(element_id), {})) ) - formatter = _formatter(dimension_type, typedef, element_data_format) + formatter = _formatter( + dimension_type, + typedef, + element_data_format, + label_transforms, + all_labels, + ) element = Element(element_dict, idx, xforms, formatter, dimension_type) elements.append(element) @@ -1037,7 +1109,8 @@ def _str_representation_for(self, key: str) -> str: # ---first authority is transform in element transforms--- value = getattr(self._element_transforms, key) if key == "name" else None if value is not None: - return value if value else "" + value = value if value else "" + return self._label_formatter(value) # ---otherwise base-name/alias from element-dict is used according to the key--- element_dict = self._element_dict @@ -1045,7 +1118,8 @@ def _str_representation_for(self, key: str) -> str: # ---category elements have a name/alias item according to the key--- if key in element_dict: value = element_dict[key] - return value if value else "" + value = value if value else "" + return self._label_formatter(value) # ---other types are more complicated--- value = element_dict.get("value") @@ -1062,7 +1136,8 @@ def _str_representation_for(self, key: str) -> str: return self._label_formatter(value) # ---For CA and MR subvar dimensions--- - return value.get("references", {}).get(key) or "" + value = value.get("references", {}).get(key) or "" + return self._label_formatter(value) class _ElementTransforms: diff --git a/src/cr/cube/util.py b/src/cr/cube/util.py index 55b397fe5..5958dff45 100644 --- a/src/cr/cube/util.py +++ b/src/cr/cube/util.py @@ -110,3 +110,22 @@ def __set__(self, obj, value): quite snappy and probably not a rich target for optimization efforts. """ raise AttributeError("can't set attribute") + + +def common_prefix(str_list): + prefix_pos = 0 + for pos, char in enumerate(str_list[0]): + try: + if {s[pos] for s in str_list[1:]} != {char}: + break + prefix_pos += 1 + except IndexError: + # This means the pivot is now beyond the shortest word, this + # means that word is the common prefix + return prefix_pos + return prefix_pos + + +def common_suffix(str_list): + str_list = [list(reversed(s)) for s in str_list] + return -1 * common_prefix(str_list) # ;) diff --git a/tests/unit/test_dimension.py b/tests/unit/test_dimension.py index ec102f110..bece574eb 100644 --- a/tests/unit/test_dimension.py +++ b/tests/unit/test_dimension.py @@ -6,30 +6,25 @@ import pytest from cr.cube.dimension import ( - Element, - Elements, Dimension, Dimensions, + Element, + Elements, + LabelTransformFuncs, _ElementIdShim, _ElementTransforms, _OrderSpec, _Subtotal, _Subtotals, -) + ) from cr.cube.enums import ( COLLATION_METHOD as CM, DIMENSION_TYPE as DT, MARGINAL, MEASURE, -) + ) -from ..unitutil import ( - call, - class_mock, - instance_mock, - method_mock, - property_mock, -) +from ..unitutil import call, class_mock, instance_mock, method_mock, property_mock class TestDimensions: @@ -1190,6 +1185,25 @@ def test_it_knows_its_label( element = Element(element_dict, None, element_transforms_, str, None) assert element.label == expected_value + def test_label_function(self): + typedef = { + "class": "categorical", + "categories": [ + {"id": 1, "name": "xx 1. REPLaced"}, + {"id": 2, "name": "xx 2. REPLace me"}, + ], + } + transforms_dict = { + "label_transforms": [ + {"function": "replace", "args": ["REPL", "repl"]}, + {"function": "trim_common", "args": []}, + {"function": "replace", "args": ["me", "you"]}, + ], + } + elements = Elements.from_typedef(typedef, transforms_dict, DT.CAT, None) + assert elements[0].label == "1. replaced" + assert elements[1].label == "2. replace you" + @pytest.mark.parametrize( ("hide", "expected_value"), ((True, True), (False, False), (None, False)) ) @@ -1773,3 +1787,36 @@ def subtrahend_ids_(self, request): @pytest.fixture def valid_elements_(self, request): return instance_mock(request, Elements) + + +class TestLabelTransformFuncs: + def test_prefix(self): + all_labels = ["a1", "a2", "a3"] + transformer = LabelTransformFuncs([], all_labels) + assert transformer.remove_common_prefix("a2", []) == "2" + + def test_suffix(self): + all_labels = ["a1b", "a2b", "a3b"] + transformer = LabelTransformFuncs([], all_labels) + assert transformer.remove_common_suffix("a2b", []) == "a2" + + def test_trim_common(self): + all_labels = ["a1b", "a2b", "a3b"] + transformer = LabelTransformFuncs([], all_labels) + assert transformer.trim_common("a2b", []) == "2" + + def test_replace(self): + all_labels = ["a1b", "a2b", "a3b"] + transformer = LabelTransformFuncs([], all_labels) + assert transformer.replace("a2b", ["a", "AA"]) == "AA2b" + + def test_application(self): + all_labels = ["a1b", "a2b", "a3b"] + transforms = [ + {"function": "trim_common", "args": []}, + {"function": "replace", "args": ["1", "one"]}, + {"function": "replace", "args": ["2", "two"]}, + ] + transformer = LabelTransformFuncs(transforms, all_labels) + formatter = transformer.apply(lambda x: x.upper()) + assert formatter("a2b") == "TWO" From 15423b01f62321d8455474fd635fd2791ee9655e Mon Sep 17 00:00:00 2001 From: Jj Date: Thu, 21 May 2026 18:26:04 +0100 Subject: [PATCH 2/7] reduce diff --- src/cr/cube/dimension.py | 2 +- tests/unit/test_dimension.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/cr/cube/dimension.py b/src/cr/cube/dimension.py index 9b13aa216..5d70bb348 100644 --- a/src/cr/cube/dimension.py +++ b/src/cr/cube/dimension.py @@ -13,7 +13,7 @@ DIMENSION_TYPE as DT, MARGINAL, MEASURE, - ) +) from cr.cube.util import common_prefix, common_suffix, lazyproperty from .util import format, format_datetime diff --git a/tests/unit/test_dimension.py b/tests/unit/test_dimension.py index bece574eb..1b5d5486d 100644 --- a/tests/unit/test_dimension.py +++ b/tests/unit/test_dimension.py @@ -16,13 +16,13 @@ _OrderSpec, _Subtotal, _Subtotals, - ) +) from cr.cube.enums import ( COLLATION_METHOD as CM, DIMENSION_TYPE as DT, MARGINAL, MEASURE, - ) +) from ..unitutil import call, class_mock, instance_mock, method_mock, property_mock From ede9b0497b8444aafd90afde62d2b49b045644ce Mon Sep 17 00:00:00 2001 From: Jj Date: Tue, 26 May 2026 11:47:29 +0100 Subject: [PATCH 3/7] '' --- src/cr/cube/dimension.py | 20 +++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/src/cr/cube/dimension.py b/src/cr/cube/dimension.py index 5d70bb348..49899e26f 100644 --- a/src/cr/cube/dimension.py +++ b/src/cr/cube/dimension.py @@ -34,6 +34,8 @@ class LabelTransformFuncs: + SYSTEM_LABELS = {"No Data"} + def __init__(self, label_transforms: list[dict], all_labels: list[str]): """ Used to apply the label function transformations on elements of a @@ -45,7 +47,7 @@ def __init__(self, label_transforms: list[dict], all_labels: list[str]): is necessary to have them all so we can do the prefix/suffix trimming. """ self.label_transforms = label_transforms - self.all_labels = all_labels + self.all_labels = [l for l in all_labels if l not in self.SYSTEM_LABELS] def apply(self, formatter: Union[Callable, partial]) -> Callable[[str], str]: """ @@ -1006,7 +1008,7 @@ def __init__( self._element_dict = element_dict self._index = index self._element_transforms = element_transforms - self._label_formatter = label_formatter + self._label_formatter: Callable = label_formatter self._dim_type = dim_type def __repr__(self) -> str: @@ -1077,7 +1079,10 @@ def label(self) -> str: This value is the empty string when no value has been specified or display of the name has been suppressed. """ - return self._str_representation_for("name") + _label = self._str_representation_for("name") + if self._label_formatter is not None: + _label = self._label_formatter(_label) + return _label @lazyproperty def missing(self) -> bool: @@ -1109,8 +1114,7 @@ def _str_representation_for(self, key: str) -> str: # ---first authority is transform in element transforms--- value = getattr(self._element_transforms, key) if key == "name" else None if value is not None: - value = value if value else "" - return self._label_formatter(value) + return value if value else "" # ---otherwise base-name/alias from element-dict is used according to the key--- element_dict = self._element_dict @@ -1118,8 +1122,7 @@ def _str_representation_for(self, key: str) -> str: # ---category elements have a name/alias item according to the key--- if key in element_dict: value = element_dict[key] - value = value if value else "" - return self._label_formatter(value) + return value if value else "" # ---other types are more complicated--- value = element_dict.get("value") @@ -1136,8 +1139,7 @@ def _str_representation_for(self, key: str) -> str: return self._label_formatter(value) # ---For CA and MR subvar dimensions--- - value = value.get("references", {}).get(key) or "" - return self._label_formatter(value) + return value.get("references", {}).get(key) or "" class _ElementTransforms: From 3d0c610715ea0b80c60856c4d907c30c9d0a9315 Mon Sep 17 00:00:00 2001 From: Jj Date: Tue, 26 May 2026 11:50:21 +0100 Subject: [PATCH 4/7] comments --- src/cr/cube/dimension.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/cr/cube/dimension.py b/src/cr/cube/dimension.py index 49899e26f..934d5ae9e 100644 --- a/src/cr/cube/dimension.py +++ b/src/cr/cube/dimension.py @@ -34,6 +34,8 @@ class LabelTransformFuncs: + # This is the system missing. We shouldn't consider it for the labels + # when looking for common prefix. This isn't a user-set value. SYSTEM_LABELS = {"No Data"} def __init__(self, label_transforms: list[dict], all_labels: list[str]): From 4caff9046c520b418c3477f527085cf53e215759 Mon Sep 17 00:00:00 2001 From: Jj Date: Wed, 27 May 2026 12:38:15 +0100 Subject: [PATCH 5/7] Integration test --- tests/integration/test_cubepart.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/integration/test_cubepart.py b/tests/integration/test_cubepart.py index c7bb2e4fa..6b8050d60 100644 --- a/tests/integration/test_cubepart.py +++ b/tests/integration/test_cubepart.py @@ -1224,6 +1224,21 @@ def test_it_can_sort_rows_by_column_percent(self): actual = np.round(slice_.column_percentages, 1).tolist() assert expected == actual, "\n%s\n\n%s" % (expected, actual) + def test_it_applies_label_transforms(self): + transforms = { + "rows_dimension": { + "label_transforms": [ + {"function": "replace", "args": ["nough", "XXX"]}, + ] + } + } + slice_ = _Slice(Cube(CR.CAT_4_X_CAT_5), 0, transforms, None, 0) + + # Note the labels have been replaced from "Enough" to "eXXX" + expected = ['Plenty', 'EXXX', 'Not eXXX', 'N/A'] + actual = slice_.row_labels.tolist() + assert expected == actual, "\n%s\n\n%s" % (expected, actual) + def test_it_can_sort_rows_by_labels(self): """Responds to order:label sort-by-label.""" transforms = { From cb5f6064a58e91ad86c79ceb3b4f5803f2e197d1 Mon Sep 17 00:00:00 2001 From: Jj Date: Wed, 27 May 2026 14:32:08 +0100 Subject: [PATCH 6/7] fix ruff --- src/cr/cube/dimension.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/cr/cube/dimension.py b/src/cr/cube/dimension.py index 934d5ae9e..f441a2919 100644 --- a/src/cr/cube/dimension.py +++ b/src/cr/cube/dimension.py @@ -49,7 +49,7 @@ def __init__(self, label_transforms: list[dict], all_labels: list[str]): is necessary to have them all so we can do the prefix/suffix trimming. """ self.label_transforms = label_transforms - self.all_labels = [l for l in all_labels if l not in self.SYSTEM_LABELS] + self.all_labels = [_l for _l in all_labels if _l not in self.SYSTEM_LABELS] def apply(self, formatter: Union[Callable, partial]) -> Callable[[str], str]: """ From 3164525bc589410b6802a4ba2a5cdc93d17cebb1 Mon Sep 17 00:00:00 2001 From: Jj Date: Wed, 27 May 2026 15:29:34 +0100 Subject: [PATCH 7/7] use double quotes --- tests/integration/test_cubepart.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/integration/test_cubepart.py b/tests/integration/test_cubepart.py index 6b8050d60..d540cffda 100644 --- a/tests/integration/test_cubepart.py +++ b/tests/integration/test_cubepart.py @@ -1235,7 +1235,7 @@ def test_it_applies_label_transforms(self): slice_ = _Slice(Cube(CR.CAT_4_X_CAT_5), 0, transforms, None, 0) # Note the labels have been replaced from "Enough" to "eXXX" - expected = ['Plenty', 'EXXX', 'Not eXXX', 'N/A'] + expected = ["Plenty", "EXXX", "Not eXXX", "N/A"] actual = slice_.row_labels.tolist() assert expected == actual, "\n%s\n\n%s" % (expected, actual)