Skip to content
Open
Show file tree
Hide file tree
Changes from 5 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
89 changes: 84 additions & 5 deletions src/cr/cube/dimension.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
MARGINAL,
MEASURE,
)
from cr.cube.util import lazyproperty
from cr.cube.util import common_prefix, common_suffix, lazyproperty

from .util import format, format_datetime

Expand All @@ -33,7 +33,71 @@
}


def _formatter(dimension_type, typedef, out_format) -> Union[Callable, partial]:
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]):
"""
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 = [l for l in all_labels if l not in self.SYSTEM_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:
Expand All @@ -46,6 +110,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


Expand Down Expand Up @@ -534,6 +602,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,
Expand All @@ -542,13 +611,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)

Expand Down Expand Up @@ -934,7 +1010,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:
Expand Down Expand Up @@ -1005,7 +1081,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:
Expand Down
19 changes: 19 additions & 0 deletions src/cr/cube/util.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) # ;)
15 changes: 15 additions & 0 deletions tests/integration/test_cubepart.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
65 changes: 56 additions & 9 deletions tests/unit/test_dimension.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,11 @@
import pytest

from cr.cube.dimension import (
Element,
Elements,
Dimension,
Dimensions,
Element,
Elements,
LabelTransformFuncs,
_ElementIdShim,
_ElementTransforms,
_OrderSpec,
Expand All @@ -23,13 +24,7 @@
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:
Expand Down Expand Up @@ -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))
)
Expand Down Expand Up @@ -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"
Loading